/** * In-process teammate spawning * * Creates and registers an in-process teammate task. Unlike process-based * teammates (tmux/iTerm2), in-process teammates run in the same Node.js * process using AsyncLocalStorage for context isolation. * * The actual agent execution loop is handled by InProcessTeammateTask * component (Task #14). This module handles: * 1. Creating TeammateContext * 2. Creating linked AbortController * 3. Registering InProcessTeammateTaskState in AppState * 4. Returning spawn result for backend */ import sample from 'lodash-es/sample.js' import { getSessionId } from '../../bootstrap/state.js' import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js' import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' import type { AppState } from '../../state/AppState.js' import { createTaskStateBase, generateTaskId } from '../../Task.js' import type { InProcessTeammateTaskState, TeammateIdentity, } from '../../tasks/InProcessTeammateTask/types.js' import { createAbortController } from '../abortController.js' import { formatAgentId } from '../agentId.js' import { registerCleanup } from '../cleanupRegistry.js' import { logForDebugging } from '../debug.js' import { emitTaskTerminatedSdk } from '../sdkEventQueue.js' import { evictTaskOutput } from '../task/diskOutput.js' import { evictTerminalTask, registerTask, STOPPED_DISPLAY_MS, } from '../task/framework.js' import { createTeammateContext } from '../teammateContext.js' import { isPerfettoTracingEnabled, registerAgent as registerPerfettoAgent, unregisterAgent as unregisterPerfettoAgent, } from '../telemetry/perfettoTracing.js' import { removeMemberByAgentId } from './teamHelpers.js' type SetAppStateFn = (updater: (prev: AppState) => AppState) => void /** * Minimal context required for spawning an in-process teammate. * This is a subset of ToolUseContext - only what spawnInProcessTeammate actually uses. */ export type SpawnContext = { setAppState: SetAppStateFn toolUseId?: string } /** * Configuration for spawning an in-process teammate. */ export type InProcessSpawnConfig = { /** Display name for the teammate, e.g., "researcher" */ name: string /** Team this teammate belongs to */ teamName: string /** Initial prompt/task for the teammate */ prompt: string /** Optional UI color for the teammate */ color?: string /** Whether teammate must enter plan mode before implementing */ planModeRequired: boolean /** Optional model override for this teammate */ model?: string } /** * Result from spawning an in-process teammate. */ export type InProcessSpawnOutput = { /** Whether spawn was successful */ success: boolean /** Full agent ID (format: "name@team") */ agentId: string /** Task ID for tracking in AppState */ taskId?: string /** AbortController for this teammate (linked to parent) */ abortController?: AbortController /** Teammate context for AsyncLocalStorage */ teammateContext?: ReturnType /** Error message if spawn failed */ error?: string } /** * Spawns an in-process teammate. * * Creates the teammate's context, registers the task in AppState, and returns * the spawn result. The actual agent execution is driven by the * InProcessTeammateTask component which uses runWithTeammateContext() to * execute the agent loop with proper identity isolation. * * @param config - Spawn configuration * @param context - Context with setAppState for registering task * @returns Spawn result with teammate info */ export async function spawnInProcessTeammate( config: InProcessSpawnConfig, context: SpawnContext, ): Promise { const { name, teamName, prompt, color, planModeRequired, model } = config const { setAppState } = context // Generate deterministic agent ID const agentId = formatAgentId(name, teamName) const taskId = generateTaskId('in_process_teammate') logForDebugging( `[spawnInProcessTeammate] Spawning ${agentId} (taskId: ${taskId})`, ) try { // Create independent AbortController for this teammate // Teammates should not be aborted when the leader's query is interrupted const abortController = createAbortController() // Get parent session ID for transcript correlation const parentSessionId = getSessionId() // Create teammate identity (stored as plain data in AppState) const identity: TeammateIdentity = { agentId, agentName: name, teamName, color, planModeRequired, parentSessionId, } // Create teammate context for AsyncLocalStorage // This will be used by runWithTeammateContext() during agent execution const teammateContext = createTeammateContext({ agentId, agentName: name, teamName, color, planModeRequired, parentSessionId, abortController, }) // Register agent in Perfetto trace for hierarchy visualization if (isPerfettoTracingEnabled()) { registerPerfettoAgent(agentId, name, parentSessionId) } // Create task state const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}` const taskState: InProcessTeammateTaskState = { ...createTaskStateBase( taskId, 'in_process_teammate', description, context.toolUseId, ), type: 'in_process_teammate', status: 'running', identity, prompt, model, abortController, awaitingPlanApproval: false, spinnerVerb: sample(getSpinnerVerbs()), pastTenseVerb: sample(TURN_COMPLETION_VERBS), permissionMode: planModeRequired ? 'plan' : 'default', isIdle: false, shutdownRequested: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, pendingUserMessages: [], messages: [], // Initialize to empty array so getDisplayedMessages works immediately } // Register cleanup handler for graceful shutdown const unregisterCleanup = registerCleanup(async () => { logForDebugging(`[spawnInProcessTeammate] Cleanup called for ${agentId}`) abortController.abort() // Task state will be updated by the execution loop when it detects abort }) taskState.unregisterCleanup = unregisterCleanup // Register task in AppState registerTask(taskState, setAppState) logForDebugging( `[spawnInProcessTeammate] Registered ${agentId} in AppState`, ) return { success: true, agentId, taskId, abortController, teammateContext, } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error during spawn' logForDebugging( `[spawnInProcessTeammate] Failed to spawn ${agentId}: ${errorMessage}`, ) return { success: false, agentId, error: errorMessage, } } } /** * Kills an in-process teammate by aborting its controller. * * Note: This is the implementation called by InProcessBackend.kill(). * * @param taskId - Task ID of the teammate to kill * @param setAppState - AppState setter * @returns true if killed successfully */ export function killInProcessTeammate( taskId: string, setAppState: SetAppStateFn, ): boolean { let killed = false let teamName: string | null = null let agentId: string | null = null let toolUseId: string | undefined let description: string | undefined setAppState((prev: AppState) => { const task = prev.tasks[taskId] if (!task || task.type !== 'in_process_teammate') { return prev } const teammateTask = task as InProcessTeammateTaskState if (teammateTask.status !== 'running') { return prev } // Capture identity for cleanup after state update teamName = teammateTask.identity.teamName agentId = teammateTask.identity.agentId toolUseId = teammateTask.toolUseId description = teammateTask.description // Abort the controller to stop execution teammateTask.abortController?.abort() // Call cleanup handler teammateTask.unregisterCleanup?.() // Update task state and remove from teamContext.teammates killed = true // Call pending idle callbacks to unblock any waiters (e.g., engine.waitForIdle) teammateTask.onIdleCallbacks?.forEach(cb => cb()) // Remove from teamContext.teammates using the agentId let updatedTeamContext = prev.teamContext if (prev.teamContext && prev.teamContext.teammates && agentId) { const { [agentId]: _, ...remainingTeammates } = prev.teamContext.teammates updatedTeamContext = { ...prev.teamContext, teammates: remainingTeammates, } } return { ...prev, teamContext: updatedTeamContext, tasks: { ...prev.tasks, [taskId]: { ...teammateTask, status: 'killed' as const, notified: true, endTime: Date.now(), onIdleCallbacks: [], // Clear callbacks to prevent stale references messages: teammateTask.messages?.length ? [teammateTask.messages[teammateTask.messages.length - 1]!] : undefined, pendingUserMessages: [], inProgressToolUseIDs: undefined, abortController: undefined, unregisterCleanup: undefined, currentWorkAbortController: undefined, }, }, } }) // Remove from team file (outside state updater to avoid file I/O in callback) if (teamName && agentId) { removeMemberByAgentId(teamName, agentId) } if (killed) { void evictTaskOutput(taskId) // notified:true was pre-set so no XML notification fires; close the SDK // task_started bookend directly. The in-process runner's own // completion/failure emit guards on status==='running' so it won't // double-emit after seeing status:killed. emitTaskTerminatedSdk(taskId, 'stopped', { toolUseId, summary: description, }) setTimeout( evictTerminalTask.bind(null, taskId, setAppState), STOPPED_DISPLAY_MS, ) } // Release perfetto agent registry entry if (agentId) { unregisterPerfettoAgent(agentId) } return killed }