/ tasks / LocalMainSessionTask.ts
LocalMainSessionTask.ts
  1  /**
  2   * LocalMainSessionTask - Handles backgrounding the main session query.
  3   *
  4   * When user presses Ctrl+B twice during a query, the session is "backgrounded":
  5   * - The query continues running in the background
  6   * - The UI clears to a fresh prompt
  7   * - A notification is sent when the query completes
  8   *
  9   * This reuses the LocalAgentTask state structure since the behavior is similar.
 10   */
 11  
 12  import type { UUID } from 'crypto'
 13  import { randomBytes } from 'crypto'
 14  import {
 15    OUTPUT_FILE_TAG,
 16    STATUS_TAG,
 17    SUMMARY_TAG,
 18    TASK_ID_TAG,
 19    TASK_NOTIFICATION_TAG,
 20    TOOL_USE_ID_TAG,
 21  } from '../constants/xml.js'
 22  import { type QueryParams, query } from '../query.js'
 23  import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
 24  import type { SetAppState } from '../Task.js'
 25  import { createTaskStateBase } from '../Task.js'
 26  import type {
 27    AgentDefinition,
 28    CustomAgentDefinition,
 29  } from '../tools/AgentTool/loadAgentsDir.js'
 30  import { asAgentId } from '../types/ids.js'
 31  import type { Message } from '../types/message.js'
 32  import { createAbortController } from '../utils/abortController.js'
 33  import {
 34    runWithAgentContext,
 35    type SubagentContext,
 36  } from '../utils/agentContext.js'
 37  import { registerCleanup } from '../utils/cleanupRegistry.js'
 38  import { logForDebugging } from '../utils/debug.js'
 39  import { logError } from '../utils/log.js'
 40  import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
 41  import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
 42  import {
 43    getAgentTranscriptPath,
 44    recordSidechainTranscript,
 45  } from '../utils/sessionStorage.js'
 46  import {
 47    evictTaskOutput,
 48    getTaskOutputPath,
 49    initTaskOutputAsSymlink,
 50  } from '../utils/task/diskOutput.js'
 51  import { registerTask, updateTaskState } from '../utils/task/framework.js'
 52  import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js'
 53  
 54  // Main session tasks use LocalAgentTaskState with agentType='main-session'
 55  export type LocalMainSessionTaskState = LocalAgentTaskState & {
 56    agentType: 'main-session'
 57  }
 58  
 59  /**
 60   * Default agent definition for main session tasks when no agent is specified.
 61   */
 62  const DEFAULT_MAIN_SESSION_AGENT: CustomAgentDefinition = {
 63    agentType: 'main-session',
 64    whenToUse: 'Main session query',
 65    source: 'userSettings',
 66    getSystemPrompt: () => '',
 67  }
 68  
 69  /**
 70   * Generate a unique task ID for main session tasks.
 71   * Uses 's' prefix to distinguish from agent tasks ('a' prefix).
 72   */
 73  const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
 74  
 75  function generateMainSessionTaskId(): string {
 76    const bytes = randomBytes(8)
 77    let id = 's'
 78    for (let i = 0; i < 8; i++) {
 79      id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
 80    }
 81    return id
 82  }
 83  
 84  /**
 85   * Register a backgrounded main session task.
 86   * Called when the user backgrounds the current session query.
 87   *
 88   * @param description - Description of the task
 89   * @param setAppState - State setter function
 90   * @param mainThreadAgentDefinition - Optional agent definition if running with --agent
 91   * @param existingAbortController - Optional abort controller to reuse (for backgrounding an active query)
 92   * @returns Object with task ID and abort signal for stopping the background query
 93   */
 94  export function registerMainSessionTask(
 95    description: string,
 96    setAppState: SetAppState,
 97    mainThreadAgentDefinition?: AgentDefinition,
 98    existingAbortController?: AbortController,
 99  ): { taskId: string; abortSignal: AbortSignal } {
100    const taskId = generateMainSessionTaskId()
101  
102    // Link output to an isolated per-task transcript file (same layout as
103    // sub-agents). Do NOT use getTranscriptPath() — that's the main session's
104    // file, and writing there from a background query after /clear would corrupt
105    // the post-clear conversation. The isolated path lets this task survive
106    // /clear: the symlink re-link in clearConversation handles session ID changes.
107    void initTaskOutputAsSymlink(
108      taskId,
109      getAgentTranscriptPath(asAgentId(taskId)),
110    )
111  
112    // Use the existing abort controller if provided (important for backgrounding an active query)
113    // This ensures that aborting the task will abort the actual query
114    const abortController = existingAbortController ?? createAbortController()
115  
116    const unregisterCleanup = registerCleanup(async () => {
117      // Clean up on process exit
118      setAppState(prev => {
119        const { [taskId]: removed, ...rest } = prev.tasks
120        return { ...prev, tasks: rest }
121      })
122    })
123  
124    // Use provided agent definition or default
125    const selectedAgent = mainThreadAgentDefinition ?? DEFAULT_MAIN_SESSION_AGENT
126  
127    // Create task state - already backgrounded since this is called when user backgrounds
128    const taskState: LocalMainSessionTaskState = {
129      ...createTaskStateBase(taskId, 'local_agent', description),
130      type: 'local_agent',
131      status: 'running',
132      agentId: taskId,
133      prompt: description,
134      selectedAgent,
135      agentType: 'main-session',
136      abortController,
137      unregisterCleanup,
138      retrieved: false,
139      lastReportedToolCount: 0,
140      lastReportedTokenCount: 0,
141      isBackgrounded: true, // Already backgrounded
142      pendingMessages: [],
143      retain: false,
144      diskLoaded: false,
145    }
146  
147    logForDebugging(
148      `[LocalMainSessionTask] Registering task ${taskId} with description: ${description}`,
149    )
150    registerTask(taskState, setAppState)
151  
152    // Verify task was registered by checking state
153    setAppState(prev => {
154      const hasTask = taskId in prev.tasks
155      logForDebugging(
156        `[LocalMainSessionTask] After registration, task ${taskId} exists in state: ${hasTask}`,
157      )
158      return prev
159    })
160  
161    return { taskId, abortSignal: abortController.signal }
162  }
163  
164  /**
165   * Complete the main session task and send notification.
166   * Called when the backgrounded query finishes.
167   */
168  export function completeMainSessionTask(
169    taskId: string,
170    success: boolean,
171    setAppState: SetAppState,
172  ): void {
173    let wasBackgrounded = true
174    let toolUseId: string | undefined
175  
176    updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => {
177      if (task.status !== 'running') {
178        return task
179      }
180  
181      // Track if task was backgrounded (for notification decision)
182      wasBackgrounded = task.isBackgrounded ?? true
183      toolUseId = task.toolUseId
184  
185      task.unregisterCleanup?.()
186  
187      return {
188        ...task,
189        status: success ? 'completed' : 'failed',
190        endTime: Date.now(),
191        messages: task.messages?.length ? [task.messages.at(-1)!] : undefined,
192      }
193    })
194  
195    void evictTaskOutput(taskId)
196  
197    // Only send notification if task is still backgrounded (not foregrounded)
198    // If foregrounded, user is watching it directly - no notification needed
199    if (wasBackgrounded) {
200      enqueueMainSessionNotification(
201        taskId,
202        'Background session',
203        success ? 'completed' : 'failed',
204        setAppState,
205        toolUseId,
206      )
207    } else {
208      // Foregrounded: no XML notification (TUI user is watching), but SDK
209      // consumers still need to see the task_started bookend close.
210      // Set notified so evictTerminalTask/generateTaskAttachments eviction
211      // guards pass; the backgrounded path sets this inside
212      // enqueueMainSessionNotification's check-and-set.
213      updateTaskState(taskId, setAppState, task => ({ ...task, notified: true }))
214      emitTaskTerminatedSdk(taskId, success ? 'completed' : 'failed', {
215        toolUseId,
216        summary: 'Background session',
217      })
218    }
219  }
220  
221  /**
222   * Enqueue a notification about the backgrounded session completing.
223   */
224  function enqueueMainSessionNotification(
225    taskId: string,
226    description: string,
227    status: 'completed' | 'failed',
228    setAppState: SetAppState,
229    toolUseId?: string,
230  ): void {
231    // Atomically check and set notified flag to prevent duplicate notifications.
232    let shouldEnqueue = false
233    updateTaskState(taskId, setAppState, task => {
234      if (task.notified) {
235        return task
236      }
237      shouldEnqueue = true
238      return { ...task, notified: true }
239    })
240  
241    if (!shouldEnqueue) {
242      return
243    }
244  
245    const summary =
246      status === 'completed'
247        ? `Background session "${description}" completed`
248        : `Background session "${description}" failed`
249  
250    const toolUseIdLine = toolUseId
251      ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
252      : ''
253  
254    const outputPath = getTaskOutputPath(taskId)
255    const message = `<${TASK_NOTIFICATION_TAG}>
256  <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
257  <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
258  <${STATUS_TAG}>${status}</${STATUS_TAG}>
259  <${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}>
260  </${TASK_NOTIFICATION_TAG}>`
261  
262    enqueuePendingNotification({ value: message, mode: 'task-notification' })
263  }
264  
265  /**
266   * Foreground a main session task - mark it as foregrounded so its output
267   * appears in the main view. The background query keeps running.
268   * Returns the task's accumulated messages, or undefined if task not found.
269   */
270  export function foregroundMainSessionTask(
271    taskId: string,
272    setAppState: SetAppState,
273  ): Message[] | undefined {
274    let taskMessages: Message[] | undefined
275  
276    setAppState(prev => {
277      const task = prev.tasks[taskId]
278      if (!task || task.type !== 'local_agent') {
279        return prev
280      }
281  
282      taskMessages = (task as LocalMainSessionTaskState).messages
283  
284      // Restore previous foregrounded task to background if it exists
285      const prevId = prev.foregroundedTaskId
286      const prevTask = prevId ? prev.tasks[prevId] : undefined
287      const restorePrev =
288        prevId && prevId !== taskId && prevTask?.type === 'local_agent'
289  
290      return {
291        ...prev,
292        foregroundedTaskId: taskId,
293        tasks: {
294          ...prev.tasks,
295          ...(restorePrev && { [prevId]: { ...prevTask, isBackgrounded: true } }),
296          [taskId]: { ...task, isBackgrounded: false },
297        },
298      }
299    })
300  
301    return taskMessages
302  }
303  
304  /**
305   * Check if a task is a main session task (vs a regular agent task).
306   */
307  export function isMainSessionTask(
308    task: unknown,
309  ): task is LocalMainSessionTaskState {
310    if (
311      typeof task !== 'object' ||
312      task === null ||
313      !('type' in task) ||
314      !('agentType' in task)
315    ) {
316      return false
317    }
318    return (
319      task.type === 'local_agent' &&
320      (task as LocalMainSessionTaskState).agentType === 'main-session'
321    )
322  }
323  
324  // Max recent activities to keep for display
325  const MAX_RECENT_ACTIVITIES = 5
326  
327  type ToolActivity = {
328    toolName: string
329    input: Record<string, unknown>
330  }
331  
332  /**
333   * Start a fresh background session with the given messages.
334   *
335   * Spawns an independent query() call with the current messages and registers it
336   * as a background task. The caller's foreground query continues running normally.
337   */
338  export function startBackgroundSession({
339    messages,
340    queryParams,
341    description,
342    setAppState,
343    agentDefinition,
344  }: {
345    messages: Message[]
346    queryParams: Omit<QueryParams, 'messages'>
347    description: string
348    setAppState: SetAppState
349    agentDefinition?: AgentDefinition
350  }): string {
351    const { taskId, abortSignal } = registerMainSessionTask(
352      description,
353      setAppState,
354      agentDefinition,
355    )
356  
357    // Persist the pre-backgrounding conversation to the task's isolated
358    // transcript so TaskOutput shows context immediately. Subsequent messages
359    // are written incrementally below.
360    void recordSidechainTranscript(messages, taskId).catch(err =>
361      logForDebugging(`bg-session initial transcript write failed: ${err}`),
362    )
363  
364    // Wrap in agent context so skill invocations scope to this task's agentId
365    // (not null). This lets clearInvokedSkills(preservedAgentIds) selectively
366    // preserve this task's skills across /clear. AsyncLocalStorage isolates
367    // concurrent async chains — this wrapper doesn't affect the foreground.
368    const agentContext: SubagentContext = {
369      agentId: taskId,
370      agentType: 'subagent',
371      subagentName: 'main-session',
372      isBuiltIn: true,
373    }
374  
375    void runWithAgentContext(agentContext, async () => {
376      try {
377        const bgMessages: Message[] = [...messages]
378        const recentActivities: ToolActivity[] = []
379        let toolCount = 0
380        let tokenCount = 0
381        let lastRecordedUuid: UUID | null = messages.at(-1)?.uuid ?? null
382  
383        for await (const event of query({
384          messages: bgMessages,
385          ...queryParams,
386        })) {
387          if (abortSignal.aborted) {
388            // Aborted mid-stream — completeMainSessionTask won't be reached.
389            // chat:killAgents path already marked notified + emitted; stopTask path did not.
390            let alreadyNotified = false
391            updateTaskState(taskId, setAppState, task => {
392              alreadyNotified = task.notified === true
393              return alreadyNotified ? task : { ...task, notified: true }
394            })
395            if (!alreadyNotified) {
396              emitTaskTerminatedSdk(taskId, 'stopped', {
397                summary: description,
398              })
399            }
400            return
401          }
402  
403          if (
404            event.type !== 'user' &&
405            event.type !== 'assistant' &&
406            event.type !== 'system'
407          ) {
408            continue
409          }
410  
411          bgMessages.push(event)
412  
413          // Per-message write (matches runAgent.ts pattern) — gives live
414          // TaskOutput progress and keeps the transcript file current even if
415          // /clear re-links the symlink mid-run.
416          void recordSidechainTranscript([event], taskId, lastRecordedUuid).catch(
417            err => logForDebugging(`bg-session transcript write failed: ${err}`),
418          )
419          lastRecordedUuid = event.uuid
420  
421          if (event.type === 'assistant') {
422            for (const block of event.message.content) {
423              if (block.type === 'text') {
424                tokenCount += roughTokenCountEstimation(block.text)
425              } else if (block.type === 'tool_use') {
426                toolCount++
427                const activity: ToolActivity = {
428                  toolName: block.name,
429                  input: block.input as Record<string, unknown>,
430                }
431                recentActivities.push(activity)
432                if (recentActivities.length > MAX_RECENT_ACTIVITIES) {
433                  recentActivities.shift()
434                }
435              }
436            }
437          }
438  
439          setAppState(prev => {
440            const task = prev.tasks[taskId]
441            if (!task || task.type !== 'local_agent') return prev
442            const prevProgress = task.progress
443            if (
444              prevProgress?.tokenCount === tokenCount &&
445              prevProgress.toolUseCount === toolCount &&
446              task.messages === bgMessages
447            ) {
448              return prev
449            }
450            return {
451              ...prev,
452              tasks: {
453                ...prev.tasks,
454                [taskId]: {
455                  ...task,
456                  progress: {
457                    tokenCount,
458                    toolUseCount: toolCount,
459                    recentActivities:
460                      prevProgress?.toolUseCount === toolCount
461                        ? prevProgress.recentActivities
462                        : [...recentActivities],
463                  },
464                  messages: bgMessages,
465                },
466              },
467            }
468          })
469        }
470  
471        completeMainSessionTask(taskId, true, setAppState)
472      } catch (error) {
473        logError(error)
474        completeMainSessionTask(taskId, false, setAppState)
475      }
476    })
477  
478    return taskId
479  }