source dump of claude code
at main 328 lines 10 kB view raw
1/** 2 * In-process teammate spawning 3 * 4 * Creates and registers an in-process teammate task. Unlike process-based 5 * teammates (tmux/iTerm2), in-process teammates run in the same Node.js 6 * process using AsyncLocalStorage for context isolation. 7 * 8 * The actual agent execution loop is handled by InProcessTeammateTask 9 * component (Task #14). This module handles: 10 * 1. Creating TeammateContext 11 * 2. Creating linked AbortController 12 * 3. Registering InProcessTeammateTaskState in AppState 13 * 4. Returning spawn result for backend 14 */ 15 16import sample from 'lodash-es/sample.js' 17import { getSessionId } from '../../bootstrap/state.js' 18import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js' 19import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' 20import type { AppState } from '../../state/AppState.js' 21import { createTaskStateBase, generateTaskId } from '../../Task.js' 22import type { 23 InProcessTeammateTaskState, 24 TeammateIdentity, 25} from '../../tasks/InProcessTeammateTask/types.js' 26import { createAbortController } from '../abortController.js' 27import { formatAgentId } from '../agentId.js' 28import { registerCleanup } from '../cleanupRegistry.js' 29import { logForDebugging } from '../debug.js' 30import { emitTaskTerminatedSdk } from '../sdkEventQueue.js' 31import { evictTaskOutput } from '../task/diskOutput.js' 32import { 33 evictTerminalTask, 34 registerTask, 35 STOPPED_DISPLAY_MS, 36} from '../task/framework.js' 37import { createTeammateContext } from '../teammateContext.js' 38import { 39 isPerfettoTracingEnabled, 40 registerAgent as registerPerfettoAgent, 41 unregisterAgent as unregisterPerfettoAgent, 42} from '../telemetry/perfettoTracing.js' 43import { removeMemberByAgentId } from './teamHelpers.js' 44 45type SetAppStateFn = (updater: (prev: AppState) => AppState) => void 46 47/** 48 * Minimal context required for spawning an in-process teammate. 49 * This is a subset of ToolUseContext - only what spawnInProcessTeammate actually uses. 50 */ 51export type SpawnContext = { 52 setAppState: SetAppStateFn 53 toolUseId?: string 54} 55 56/** 57 * Configuration for spawning an in-process teammate. 58 */ 59export type InProcessSpawnConfig = { 60 /** Display name for the teammate, e.g., "researcher" */ 61 name: string 62 /** Team this teammate belongs to */ 63 teamName: string 64 /** Initial prompt/task for the teammate */ 65 prompt: string 66 /** Optional UI color for the teammate */ 67 color?: string 68 /** Whether teammate must enter plan mode before implementing */ 69 planModeRequired: boolean 70 /** Optional model override for this teammate */ 71 model?: string 72} 73 74/** 75 * Result from spawning an in-process teammate. 76 */ 77export type InProcessSpawnOutput = { 78 /** Whether spawn was successful */ 79 success: boolean 80 /** Full agent ID (format: "name@team") */ 81 agentId: string 82 /** Task ID for tracking in AppState */ 83 taskId?: string 84 /** AbortController for this teammate (linked to parent) */ 85 abortController?: AbortController 86 /** Teammate context for AsyncLocalStorage */ 87 teammateContext?: ReturnType<typeof createTeammateContext> 88 /** Error message if spawn failed */ 89 error?: string 90} 91 92/** 93 * Spawns an in-process teammate. 94 * 95 * Creates the teammate's context, registers the task in AppState, and returns 96 * the spawn result. The actual agent execution is driven by the 97 * InProcessTeammateTask component which uses runWithTeammateContext() to 98 * execute the agent loop with proper identity isolation. 99 * 100 * @param config - Spawn configuration 101 * @param context - Context with setAppState for registering task 102 * @returns Spawn result with teammate info 103 */ 104export async function spawnInProcessTeammate( 105 config: InProcessSpawnConfig, 106 context: SpawnContext, 107): Promise<InProcessSpawnOutput> { 108 const { name, teamName, prompt, color, planModeRequired, model } = config 109 const { setAppState } = context 110 111 // Generate deterministic agent ID 112 const agentId = formatAgentId(name, teamName) 113 const taskId = generateTaskId('in_process_teammate') 114 115 logForDebugging( 116 `[spawnInProcessTeammate] Spawning ${agentId} (taskId: ${taskId})`, 117 ) 118 119 try { 120 // Create independent AbortController for this teammate 121 // Teammates should not be aborted when the leader's query is interrupted 122 const abortController = createAbortController() 123 124 // Get parent session ID for transcript correlation 125 const parentSessionId = getSessionId() 126 127 // Create teammate identity (stored as plain data in AppState) 128 const identity: TeammateIdentity = { 129 agentId, 130 agentName: name, 131 teamName, 132 color, 133 planModeRequired, 134 parentSessionId, 135 } 136 137 // Create teammate context for AsyncLocalStorage 138 // This will be used by runWithTeammateContext() during agent execution 139 const teammateContext = createTeammateContext({ 140 agentId, 141 agentName: name, 142 teamName, 143 color, 144 planModeRequired, 145 parentSessionId, 146 abortController, 147 }) 148 149 // Register agent in Perfetto trace for hierarchy visualization 150 if (isPerfettoTracingEnabled()) { 151 registerPerfettoAgent(agentId, name, parentSessionId) 152 } 153 154 // Create task state 155 const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}` 156 157 const taskState: InProcessTeammateTaskState = { 158 ...createTaskStateBase( 159 taskId, 160 'in_process_teammate', 161 description, 162 context.toolUseId, 163 ), 164 type: 'in_process_teammate', 165 status: 'running', 166 identity, 167 prompt, 168 model, 169 abortController, 170 awaitingPlanApproval: false, 171 spinnerVerb: sample(getSpinnerVerbs()), 172 pastTenseVerb: sample(TURN_COMPLETION_VERBS), 173 permissionMode: planModeRequired ? 'plan' : 'default', 174 isIdle: false, 175 shutdownRequested: false, 176 lastReportedToolCount: 0, 177 lastReportedTokenCount: 0, 178 pendingUserMessages: [], 179 messages: [], // Initialize to empty array so getDisplayedMessages works immediately 180 } 181 182 // Register cleanup handler for graceful shutdown 183 const unregisterCleanup = registerCleanup(async () => { 184 logForDebugging(`[spawnInProcessTeammate] Cleanup called for ${agentId}`) 185 abortController.abort() 186 // Task state will be updated by the execution loop when it detects abort 187 }) 188 taskState.unregisterCleanup = unregisterCleanup 189 190 // Register task in AppState 191 registerTask(taskState, setAppState) 192 193 logForDebugging( 194 `[spawnInProcessTeammate] Registered ${agentId} in AppState`, 195 ) 196 197 return { 198 success: true, 199 agentId, 200 taskId, 201 abortController, 202 teammateContext, 203 } 204 } catch (error) { 205 const errorMessage = 206 error instanceof Error ? error.message : 'Unknown error during spawn' 207 logForDebugging( 208 `[spawnInProcessTeammate] Failed to spawn ${agentId}: ${errorMessage}`, 209 ) 210 return { 211 success: false, 212 agentId, 213 error: errorMessage, 214 } 215 } 216} 217 218/** 219 * Kills an in-process teammate by aborting its controller. 220 * 221 * Note: This is the implementation called by InProcessBackend.kill(). 222 * 223 * @param taskId - Task ID of the teammate to kill 224 * @param setAppState - AppState setter 225 * @returns true if killed successfully 226 */ 227export function killInProcessTeammate( 228 taskId: string, 229 setAppState: SetAppStateFn, 230): boolean { 231 let killed = false 232 let teamName: string | null = null 233 let agentId: string | null = null 234 let toolUseId: string | undefined 235 let description: string | undefined 236 237 setAppState((prev: AppState) => { 238 const task = prev.tasks[taskId] 239 if (!task || task.type !== 'in_process_teammate') { 240 return prev 241 } 242 243 const teammateTask = task as InProcessTeammateTaskState 244 245 if (teammateTask.status !== 'running') { 246 return prev 247 } 248 249 // Capture identity for cleanup after state update 250 teamName = teammateTask.identity.teamName 251 agentId = teammateTask.identity.agentId 252 toolUseId = teammateTask.toolUseId 253 description = teammateTask.description 254 255 // Abort the controller to stop execution 256 teammateTask.abortController?.abort() 257 258 // Call cleanup handler 259 teammateTask.unregisterCleanup?.() 260 261 // Update task state and remove from teamContext.teammates 262 killed = true 263 264 // Call pending idle callbacks to unblock any waiters (e.g., engine.waitForIdle) 265 teammateTask.onIdleCallbacks?.forEach(cb => cb()) 266 267 // Remove from teamContext.teammates using the agentId 268 let updatedTeamContext = prev.teamContext 269 if (prev.teamContext && prev.teamContext.teammates && agentId) { 270 const { [agentId]: _, ...remainingTeammates } = prev.teamContext.teammates 271 updatedTeamContext = { 272 ...prev.teamContext, 273 teammates: remainingTeammates, 274 } 275 } 276 277 return { 278 ...prev, 279 teamContext: updatedTeamContext, 280 tasks: { 281 ...prev.tasks, 282 [taskId]: { 283 ...teammateTask, 284 status: 'killed' as const, 285 notified: true, 286 endTime: Date.now(), 287 onIdleCallbacks: [], // Clear callbacks to prevent stale references 288 messages: teammateTask.messages?.length 289 ? [teammateTask.messages[teammateTask.messages.length - 1]!] 290 : undefined, 291 pendingUserMessages: [], 292 inProgressToolUseIDs: undefined, 293 abortController: undefined, 294 unregisterCleanup: undefined, 295 currentWorkAbortController: undefined, 296 }, 297 }, 298 } 299 }) 300 301 // Remove from team file (outside state updater to avoid file I/O in callback) 302 if (teamName && agentId) { 303 removeMemberByAgentId(teamName, agentId) 304 } 305 306 if (killed) { 307 void evictTaskOutput(taskId) 308 // notified:true was pre-set so no XML notification fires; close the SDK 309 // task_started bookend directly. The in-process runner's own 310 // completion/failure emit guards on status==='running' so it won't 311 // double-emit after seeing status:killed. 312 emitTaskTerminatedSdk(taskId, 'stopped', { 313 toolUseId, 314 summary: description, 315 }) 316 setTimeout( 317 evictTerminalTask.bind(null, taskId, setAppState), 318 STOPPED_DISPLAY_MS, 319 ) 320 } 321 322 // Release perfetto agent registry entry 323 if (agentId) { 324 unregisterPerfettoAgent(agentId) 325 } 326 327 return killed 328}