/ utils / sdkEventQueue.ts
sdkEventQueue.ts
  1  import type { UUID } from 'crypto'
  2  import { randomUUID } from 'crypto'
  3  import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js'
  4  import type { SdkWorkflowProgress } from '../types/tools.js'
  5  
  6  type TaskStartedEvent = {
  7    type: 'system'
  8    subtype: 'task_started'
  9    task_id: string
 10    tool_use_id?: string
 11    description: string
 12    task_type?: string
 13    workflow_name?: string
 14    prompt?: string
 15  }
 16  
 17  type TaskProgressEvent = {
 18    type: 'system'
 19    subtype: 'task_progress'
 20    task_id: string
 21    tool_use_id?: string
 22    description: string
 23    usage: {
 24      total_tokens: number
 25      tool_uses: number
 26      duration_ms: number
 27    }
 28    last_tool_name?: string
 29    summary?: string
 30    // Delta batch of workflow state changes. Clients upsert by
 31    // `${type}:${index}` then group by phaseIndex to rebuild the phase tree,
 32    // same fold as collectFromEvents + groupByPhase in PhaseProgress.tsx.
 33    workflow_progress?: SdkWorkflowProgress[]
 34  }
 35  
 36  // Emitted when a foreground agent completes without being backgrounded.
 37  // Drained by drainSdkEvents() directly into the output stream — does NOT
 38  // go through the print.ts XML task_notification parser and does NOT trigger
 39  // the LLM loop. Consumers (e.g. VS Code session.ts) use this to remove the
 40  // task from the subagent panel.
 41  type TaskNotificationSdkEvent = {
 42    type: 'system'
 43    subtype: 'task_notification'
 44    task_id: string
 45    tool_use_id?: string
 46    status: 'completed' | 'failed' | 'stopped'
 47    output_file: string
 48    summary: string
 49    usage?: {
 50      total_tokens: number
 51      tool_uses: number
 52      duration_ms: number
 53    }
 54  }
 55  
 56  // Mirrors notifySessionStateChanged. The CCR bridge already receives this
 57  // via its own listener; SDK consumers (scmuxd, VS Code) need the same signal
 58  // to know when the main turn's generator is idle vs actively producing.
 59  // The 'idle' transition fires AFTER heldBackResult flushes and the bg-agent
 60  // do-while loop exits — so SDK consumers can trust it as the authoritative
 61  // "turn is over" signal even when result was withheld for background agents.
 62  type SessionStateChangedEvent = {
 63    type: 'system'
 64    subtype: 'session_state_changed'
 65    state: 'idle' | 'running' | 'requires_action'
 66  }
 67  
 68  export type SdkEvent =
 69    | TaskStartedEvent
 70    | TaskProgressEvent
 71    | TaskNotificationSdkEvent
 72    | SessionStateChangedEvent
 73  
 74  const MAX_QUEUE_SIZE = 1000
 75  const queue: SdkEvent[] = []
 76  
 77  export function enqueueSdkEvent(event: SdkEvent): void {
 78    // SDK events are only consumed (drained) in headless/streaming mode.
 79    // In TUI mode they would accumulate up to the cap and never be read.
 80    if (!getIsNonInteractiveSession()) {
 81      return
 82    }
 83    if (queue.length >= MAX_QUEUE_SIZE) {
 84      queue.shift()
 85    }
 86    queue.push(event)
 87  }
 88  
 89  export function drainSdkEvents(): Array<
 90    SdkEvent & { uuid: UUID; session_id: string }
 91  > {
 92    if (queue.length === 0) {
 93      return []
 94    }
 95    const events = queue.splice(0)
 96    return events.map(e => ({
 97      ...e,
 98      uuid: randomUUID(),
 99      session_id: getSessionId(),
100    }))
101  }
102  
103  /**
104   * Emit a task_notification SDK event for a task reaching a terminal state.
105   *
106   * registerTask() always emits task_started; this is the closing bookend.
107   * Call this from any exit path that sets a task terminal WITHOUT going
108   * through enqueuePendingNotification-with-<task-id> (print.ts parses that
109   * XML into the same SDK event, so paths that do both would double-emit).
110   * Paths that suppress the XML notification (notified:true pre-set, kill
111   * paths, abort branches) must call this directly so SDK consumers
112   * (Scuttle's bg-task dot, VS Code subagent panel) see the task close.
113   */
114  export function emitTaskTerminatedSdk(
115    taskId: string,
116    status: 'completed' | 'failed' | 'stopped',
117    opts?: {
118      toolUseId?: string
119      summary?: string
120      outputFile?: string
121      usage?: { total_tokens: number; tool_uses: number; duration_ms: number }
122    },
123  ): void {
124    enqueueSdkEvent({
125      type: 'system',
126      subtype: 'task_notification',
127      task_id: taskId,
128      tool_use_id: opts?.toolUseId,
129      status,
130      output_file: opts?.outputFile ?? '',
131      summary: opts?.summary ?? '',
132      usage: opts?.usage,
133    })
134  }