import type { UUID } from 'crypto' import { randomUUID } from 'crypto' import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js' import type { SdkWorkflowProgress } from '../types/tools.js' type TaskStartedEvent = { type: 'system' subtype: 'task_started' task_id: string tool_use_id?: string description: string task_type?: string workflow_name?: string prompt?: string } type TaskProgressEvent = { type: 'system' subtype: 'task_progress' task_id: string tool_use_id?: string description: string usage: { total_tokens: number tool_uses: number duration_ms: number } last_tool_name?: string summary?: string // Delta batch of workflow state changes. Clients upsert by // `${type}:${index}` then group by phaseIndex to rebuild the phase tree, // same fold as collectFromEvents + groupByPhase in PhaseProgress.tsx. workflow_progress?: SdkWorkflowProgress[] } // Emitted when a foreground agent completes without being backgrounded. // Drained by drainSdkEvents() directly into the output stream — does NOT // go through the print.ts XML task_notification parser and does NOT trigger // the LLM loop. Consumers (e.g. VS Code session.ts) use this to remove the // task from the subagent panel. type TaskNotificationSdkEvent = { type: 'system' subtype: 'task_notification' task_id: string tool_use_id?: string status: 'completed' | 'failed' | 'stopped' output_file: string summary: string usage?: { total_tokens: number tool_uses: number duration_ms: number } } // Mirrors notifySessionStateChanged. The CCR bridge already receives this // via its own listener; SDK consumers (scmuxd, VS Code) need the same signal // to know when the main turn's generator is idle vs actively producing. // The 'idle' transition fires AFTER heldBackResult flushes and the bg-agent // do-while loop exits — so SDK consumers can trust it as the authoritative // "turn is over" signal even when result was withheld for background agents. type SessionStateChangedEvent = { type: 'system' subtype: 'session_state_changed' state: 'idle' | 'running' | 'requires_action' } export type SdkEvent = | TaskStartedEvent | TaskProgressEvent | TaskNotificationSdkEvent | SessionStateChangedEvent const MAX_QUEUE_SIZE = 1000 const queue: SdkEvent[] = [] export function enqueueSdkEvent(event: SdkEvent): void { // SDK events are only consumed (drained) in headless/streaming mode. // In TUI mode they would accumulate up to the cap and never be read. if (!getIsNonInteractiveSession()) { return } if (queue.length >= MAX_QUEUE_SIZE) { queue.shift() } queue.push(event) } export function drainSdkEvents(): Array< SdkEvent & { uuid: UUID; session_id: string } > { if (queue.length === 0) { return [] } const events = queue.splice(0) return events.map(e => ({ ...e, uuid: randomUUID(), session_id: getSessionId(), })) } /** * Emit a task_notification SDK event for a task reaching a terminal state. * * registerTask() always emits task_started; this is the closing bookend. * Call this from any exit path that sets a task terminal WITHOUT going * through enqueuePendingNotification-with- (print.ts parses that * XML into the same SDK event, so paths that do both would double-emit). * Paths that suppress the XML notification (notified:true pre-set, kill * paths, abort branches) must call this directly so SDK consumers * (Scuttle's bg-task dot, VS Code subagent panel) see the task close. */ export function emitTaskTerminatedSdk( taskId: string, status: 'completed' | 'failed' | 'stopped', opts?: { toolUseId?: string summary?: string outputFile?: string usage?: { total_tokens: number; tool_uses: number; duration_ms: number } }, ): void { enqueueSdkEvent({ type: 'system', subtype: 'task_notification', task_id: taskId, tool_use_id: opts?.toolUseId, status, output_file: opts?.outputFile ?? '', summary: opts?.summary ?? '', usage: opts?.usage, }) }