init claude-code
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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<typeof createTeammateContext>
|
||||
/** 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<InProcessSpawnOutput> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user