Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
36 kB · 1093 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094/** * Shared spawn module for teammate creation. * Extracted from TeammateTool to allow reuse by AgentTool. */
import React from 'react'import { getChromeFlagOverride, getFlagSettingsPath, getInlinePlugins, getMainLoopModelOverride, getSessionBypassPermissionsMode, getSessionId,} from '../../bootstrap/state.js'import type { AppState } from '../../state/AppState.js'import { createTaskStateBase, generateTaskId } from '../../Task.js'import type { ToolUseContext } from '../../Tool.js'import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'import { formatAgentId } from '../../utils/agentId.js'import { quote } from '../../utils/bash/shellQuote.js'import { isInBundledMode } from '../../utils/bundledMode.js'import { getGlobalConfig } from '../../utils/config.js'import { getCwd } from '../../utils/cwd.js'import { logForDebugging } from '../../utils/debug.js'import { errorMessage } from '../../utils/errors.js'import { execFileNoThrow } from '../../utils/execFileNoThrow.js'import { parseUserSpecifiedModel } from '../../utils/model/model.js'import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'import { isTmuxAvailable } from '../../utils/swarm/backends/detection.js'import { detectAndGetBackend, getBackendByType, isInProcessEnabled, markInProcessFallback, resetBackendDetection,} from '../../utils/swarm/backends/registry.js'import { getTeammateModeFromSnapshot } from '../../utils/swarm/backends/teammateModeSnapshot.js'import type { BackendType } from '../../utils/swarm/backends/types.js'import { isPaneBackend } from '../../utils/swarm/backends/types.js'import { SWARM_SESSION_NAME, TEAM_LEAD_NAME, TEAMMATE_COMMAND_ENV_VAR, TMUX_COMMAND,} from '../../utils/swarm/constants.js'import { It2SetupPrompt } from '../../utils/swarm/It2SetupPrompt.js'import { startInProcessTeammate } from '../../utils/swarm/inProcessRunner.js'import { type InProcessSpawnConfig, spawnInProcessTeammate,} from '../../utils/swarm/spawnInProcess.js'import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'import { readTeamFileAsync, sanitizeAgentName, sanitizeName, writeTeamFileAsync,} from '../../utils/swarm/teamHelpers.js'import { assignTeammateColor, createTeammatePaneInSwarmView, enablePaneBorderStatus, isInsideTmux, sendCommandToPane,} from '../../utils/swarm/teammateLayoutManager.js'import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js'import { registerTask } from '../../utils/task/framework.js'import { writeToMailbox } from '../../utils/teammateMailbox.js'import type { CustomAgentDefinition } from '../AgentTool/loadAgentsDir.js'import { isCustomAgent } from '../AgentTool/loadAgentsDir.js'
function getDefaultTeammateModel(leaderModel: string | null): string { const configured = getGlobalConfig().teammateDefaultModel if (configured === null) { // User picked "Default" in the /config picker — follow the leader. return leaderModel ?? getHardcodedTeammateModelFallback() } if (configured !== undefined) { return parseUserSpecifiedModel(configured) } return getHardcodedTeammateModelFallback()}
/** * Resolve a teammate model value. Handles the 'inherit' alias (from agent * frontmatter) by substituting the leader's model. gh-31069: 'inherit' was * passed literally to --model, producing "It may not exist or you may not * have access". If leader model is null (not yet set), falls through to the * default. * * Exported for testing. */export function resolveTeammateModel( inputModel: string | undefined, leaderModel: string | null,): string { if (inputModel === 'inherit') { return leaderModel ?? getDefaultTeammateModel(leaderModel) } return inputModel ?? getDefaultTeammateModel(leaderModel)}
// ============================================================================// Types// ============================================================================
export type SpawnOutput = { teammate_id: string agent_id: string agent_type?: string model?: string name: string color?: string tmux_session_name: string tmux_window_name: string tmux_pane_id: string team_name?: string is_splitpane?: boolean plan_mode_required?: boolean}
export type SpawnTeammateConfig = { name: string prompt: string team_name?: string cwd?: string use_splitpane?: boolean plan_mode_required?: boolean model?: string agent_type?: string description?: string /** request_id of the API call whose response contained the tool_use that * spawned this teammate. Threaded through to TeammateAgentContext for * lineage tracing on tengu_api_* events. */ invokingRequestId?: string}
// Internal input type matching TeammateTool's spawn parameterstype SpawnInput = { name: string prompt: string team_name?: string cwd?: string use_splitpane?: boolean plan_mode_required?: boolean model?: string agent_type?: string description?: string invokingRequestId?: string}
// ============================================================================// Helper Functions// ============================================================================
/** * Checks if a tmux session exists */async function hasSession(sessionName: string): Promise<boolean> { const result = await execFileNoThrow(TMUX_COMMAND, [ 'has-session', '-t', sessionName, ]) return result.code === 0}
/** * Creates a new tmux session if it doesn't exist */async function ensureSession(sessionName: string): Promise<void> { const exists = await hasSession(sessionName) if (!exists) { const result = await execFileNoThrow(TMUX_COMMAND, [ 'new-session', '-d', '-s', sessionName, ]) if (result.code !== 0) { throw new Error( `Failed to create tmux session '${sessionName}': ${result.stderr || 'Unknown error'}`, ) } }}
/** * Gets the command to spawn a teammate. * For native builds (compiled binaries), use process.execPath. * For non-native (node/bun running a script), use process.argv[1]. */function getTeammateCommand(): string { if (process.env[TEAMMATE_COMMAND_ENV_VAR]) { return process.env[TEAMMATE_COMMAND_ENV_VAR] } return isInBundledMode() ? process.execPath : process.argv[1]!}
/** * Builds CLI flags to propagate from the current session to spawned teammates. * This ensures teammates inherit important settings like permission mode, * model selection, and plugin configuration from their parent. * * @param options.planModeRequired - If true, don't inherit bypass permissions (plan mode takes precedence) * @param options.permissionMode - Permission mode to propagate */function buildInheritedCliFlags(options?: { planModeRequired?: boolean permissionMode?: PermissionMode}): string { const flags: string[] = [] const { planModeRequired, permissionMode } = options || {}
// Propagate permission mode to teammates, but NOT if plan mode is required // Plan mode takes precedence over bypass permissions for safety if (planModeRequired) { // Don't inherit bypass permissions when plan mode is required } else if ( permissionMode === 'bypassPermissions' || getSessionBypassPermissionsMode() ) { flags.push('--dangerously-skip-permissions') } else if (permissionMode === 'acceptEdits') { flags.push('--permission-mode acceptEdits') } else if (permissionMode === 'auto') { // Teammates inherit auto mode so the classifier auto-approves their tool // calls too. The teammate's own startup (permissionSetup.ts) handles // GrowthBook gate checks and setAutoModeActive(true) independently. flags.push('--permission-mode auto') }
// Propagate --model if explicitly set via CLI const modelOverride = getMainLoopModelOverride() if (modelOverride) { flags.push(`--model ${quote([modelOverride])}`) }
// Propagate --settings if set via CLI const settingsPath = getFlagSettingsPath() if (settingsPath) { flags.push(`--settings ${quote([settingsPath])}`) }
// Propagate --plugin-dir for each inline plugin const inlinePlugins = getInlinePlugins() for (const pluginDir of inlinePlugins) { flags.push(`--plugin-dir ${quote([pluginDir])}`) }
// Propagate --chrome / --no-chrome if explicitly set on the CLI const chromeFlagOverride = getChromeFlagOverride() if (chromeFlagOverride === true) { flags.push('--chrome') } else if (chromeFlagOverride === false) { flags.push('--no-chrome') }
return flags.join(' ')}
/** * Generates a unique teammate name by checking existing team members. * If the name already exists, appends a numeric suffix (e.g., tester-2, tester-3). * @internal Exported for testing */export async function generateUniqueTeammateName( baseName: string, teamName: string | undefined,): Promise<string> { if (!teamName) { return baseName }
const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { return baseName }
const existingNames = new Set(teamFile.members.map(m => m.name.toLowerCase()))
// If the base name doesn't exist, use it as-is if (!existingNames.has(baseName.toLowerCase())) { return baseName }
// Find the next available suffix let suffix = 2 while (existingNames.has(`${baseName}-${suffix}`.toLowerCase())) { suffix++ }
return `${baseName}-${suffix}`}
// ============================================================================// Spawn Handlers// ============================================================================
/** * Handle spawn operation using split-pane view (default). * When inside tmux: Creates teammates in a shared window with leader on left, teammates on right. * When outside tmux: Creates a claude-swarm session with all teammates in a tiled layout. */async function handleSpawnSplitPane( input: SpawnInput, context: ToolUseContext,): Promise<{ data: SpawnOutput }> { const { setAppState, getAppState } = context const { name, prompt, agent_type, cwd, plan_mode_required } = input
// Resolve model: 'inherit' → leader's model; undefined → default Opus const model = resolveTeammateModel(input.model, getAppState().mainLoopModel)
if (!name || !prompt) { throw new Error('name and prompt are required for spawn operation') }
// Get team name from input or inherit from leader's team context const appState = getAppState() const teamName = input.team_name || appState.teamContext?.teamName
if (!teamName) { throw new Error( 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', ) }
// Generate unique name if duplicate exists in team const uniqueName = await generateUniqueTeammateName(name, teamName)
// Sanitize the name to prevent @ in agent IDs (would break agentName@teamName format) const sanitizedName = sanitizeAgentName(uniqueName)
// Generate deterministic agent ID from name and team const teammateId = formatAgentId(sanitizedName, teamName) const workingDir = cwd || getCwd()
// Detect the appropriate backend and check if setup is needed let detectionResult = await detectAndGetBackend()
// If in iTerm2 but it2 isn't set up, prompt the user if (detectionResult.needsIt2Setup && context.setToolJSX) { const tmuxAvailable = await isTmuxAvailable()
// Show the setup prompt and wait for user decision const setupResult = await new Promise< 'installed' | 'use-tmux' | 'cancelled' >(resolve => { context.setToolJSX!({ jsx: React.createElement(It2SetupPrompt, { onDone: resolve, tmuxAvailable, }), shouldHidePromptInput: true, }) })
// Clear the JSX context.setToolJSX(null)
if (setupResult === 'cancelled') { throw new Error('Teammate spawn cancelled - iTerm2 setup required') }
// If they installed it2 or chose tmux, clear cached detection and re-fetch // so the local detectionResult matches the backend that will actually // spawn the pane. // - 'installed': re-detect to pick up the ITermBackend (it2 is now available) // - 'use-tmux': re-detect so needsIt2Setup is false (preferTmux is now saved) // and subsequent spawns skip this prompt if (setupResult === 'installed' || setupResult === 'use-tmux') { resetBackendDetection() detectionResult = await detectAndGetBackend() } }
// Check if we're inside tmux to determine session naming const insideTmux = await isInsideTmux()
// Assign a unique color to this teammate const teammateColor = assignTeammateColor(teammateId)
// Create a pane in the swarm view // - Inside tmux: splits current window (leader on left, teammates on right) // - In iTerm2 with it2: uses native iTerm2 split panes // - Outside both: creates claude-swarm session with tiled teammates const { paneId, isFirstTeammate } = await createTeammatePaneInSwarmView( sanitizedName, teammateColor, )
// Enable pane border status on first teammate when inside tmux // (outside tmux, this is handled in createTeammatePaneInSwarmView) if (isFirstTeammate && insideTmux) { await enablePaneBorderStatus() }
// Build the command to spawn Claude Code with teammate identity // Note: We spawn without a prompt - initial instructions are sent via mailbox const binaryPath = getTeammateCommand()
// Build teammate identity CLI args (replaces CLAUDE_CODE_* env vars) const teammateArgs = [ `--agent-id ${quote([teammateId])}`, `--agent-name ${quote([sanitizedName])}`, `--team-name ${quote([teamName])}`, `--agent-color ${quote([teammateColor])}`, `--parent-session-id ${quote([getSessionId()])}`, plan_mode_required ? '--plan-mode-required' : '', agent_type ? `--agent-type ${quote([agent_type])}` : '', ] .filter(Boolean) .join(' ')
// Build CLI flags to propagate to teammate // Pass plan_mode_required to prevent inheriting bypass permissions let inheritedFlags = buildInheritedCliFlags({ planModeRequired: plan_mode_required, permissionMode: appState.toolPermissionContext.mode, })
// If teammate has a custom model, add --model flag (or replace inherited one) if (model) { // Remove any inherited --model flag first inheritedFlags = inheritedFlags .split(' ') .filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model') .join(' ') // Add the teammate's model inheritedFlags = inheritedFlags ? `${inheritedFlags} --model ${quote([model])}` : `--model ${quote([model])}` }
const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' // Propagate env vars that teammates need but may not inherit from tmux split-window shells. // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars. const envStr = buildInheritedEnvVars() const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}`
// Send the command to the new pane // Use swarm socket when running outside tmux (external swarm session) await sendCommandToPane(paneId, spawnCommand, !insideTmux)
// Determine session/window names for output const sessionName = insideTmux ? 'current' : SWARM_SESSION_NAME const windowName = insideTmux ? 'current' : 'swarm-view'
// Track the teammate in AppState's teamContext with color // If spawning without spawnTeam, set up the leader as team lead setAppState(prev => ({ ...prev, teamContext: { ...prev.teamContext, teamName: teamName ?? prev.teamContext?.teamName ?? 'default', teamFilePath: prev.teamContext?.teamFilePath ?? '', leadAgentId: prev.teamContext?.leadAgentId ?? '', teammates: { ...(prev.teamContext?.teammates || {}), [teammateId]: { name: sanitizedName, agentType: agent_type, color: teammateColor, tmuxSessionName: sessionName, tmuxPaneId: paneId, cwd: workingDir, spawnedAt: Date.now(), }, }, }, }))
// Register background task so teammates appear in the tasks pill/dialog registerOutOfProcessTeammateTask(setAppState, { teammateId, sanitizedName, teamName, teammateColor, prompt, plan_mode_required, paneId, insideTmux, backendType: detectionResult.backend.type, toolUseId: context.toolUseId, })
// Register agent in the team file const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { throw new Error( `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, ) } teamFile.members.push({ agentId: teammateId, name: sanitizedName, agentType: agent_type, model, prompt, color: teammateColor, planModeRequired: plan_mode_required, joinedAt: Date.now(), tmuxPaneId: paneId, cwd: workingDir, subscriptions: [], backendType: detectionResult.backend.type, }) await writeTeamFileAsync(teamName, teamFile)
// Send initial instructions to teammate via mailbox // The teammate's inbox poller will pick this up and submit it as their first turn await writeToMailbox( sanitizedName, { from: TEAM_LEAD_NAME, text: prompt, timestamp: new Date().toISOString(), }, teamName, )
return { data: { teammate_id: teammateId, agent_id: teammateId, agent_type, model, name: sanitizedName, color: teammateColor, tmux_session_name: sessionName, tmux_window_name: windowName, tmux_pane_id: paneId, team_name: teamName, is_splitpane: true, plan_mode_required, }, }}
/** * Handle spawn operation using separate windows (legacy behavior). * Creates each teammate in its own tmux window. */async function handleSpawnSeparateWindow( input: SpawnInput, context: ToolUseContext,): Promise<{ data: SpawnOutput }> { const { setAppState, getAppState } = context const { name, prompt, agent_type, cwd, plan_mode_required } = input
// Resolve model: 'inherit' → leader's model; undefined → default Opus const model = resolveTeammateModel(input.model, getAppState().mainLoopModel)
if (!name || !prompt) { throw new Error('name and prompt are required for spawn operation') }
// Get team name from input or inherit from leader's team context const appState = getAppState() const teamName = input.team_name || appState.teamContext?.teamName
if (!teamName) { throw new Error( 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', ) }
// Generate unique name if duplicate exists in team const uniqueName = await generateUniqueTeammateName(name, teamName)
// Sanitize the name to prevent @ in agent IDs (would break agentName@teamName format) const sanitizedName = sanitizeAgentName(uniqueName)
// Generate deterministic agent ID from name and team const teammateId = formatAgentId(sanitizedName, teamName) const windowName = `teammate-${sanitizeName(sanitizedName)}` const workingDir = cwd || getCwd()
// Ensure the swarm session exists await ensureSession(SWARM_SESSION_NAME)
// Assign a unique color to this teammate const teammateColor = assignTeammateColor(teammateId)
// Create a new window for this teammate const createWindowResult = await execFileNoThrow(TMUX_COMMAND, [ 'new-window', '-t', SWARM_SESSION_NAME, '-n', windowName, '-P', '-F', '#{pane_id}', ])
if (createWindowResult.code !== 0) { throw new Error( `Failed to create tmux window: ${createWindowResult.stderr}`, ) }
const paneId = createWindowResult.stdout.trim()
// Build the command to spawn Claude Code with teammate identity // Note: We spawn without a prompt - initial instructions are sent via mailbox const binaryPath = getTeammateCommand()
// Build teammate identity CLI args (replaces CLAUDE_CODE_* env vars) const teammateArgs = [ `--agent-id ${quote([teammateId])}`, `--agent-name ${quote([sanitizedName])}`, `--team-name ${quote([teamName])}`, `--agent-color ${quote([teammateColor])}`, `--parent-session-id ${quote([getSessionId()])}`, plan_mode_required ? '--plan-mode-required' : '', agent_type ? `--agent-type ${quote([agent_type])}` : '', ] .filter(Boolean) .join(' ')
// Build CLI flags to propagate to teammate // Pass plan_mode_required to prevent inheriting bypass permissions let inheritedFlags = buildInheritedCliFlags({ planModeRequired: plan_mode_required, permissionMode: appState.toolPermissionContext.mode, })
// If teammate has a custom model, add --model flag (or replace inherited one) if (model) { // Remove any inherited --model flag first inheritedFlags = inheritedFlags .split(' ') .filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model') .join(' ') // Add the teammate's model inheritedFlags = inheritedFlags ? `${inheritedFlags} --model ${quote([model])}` : `--model ${quote([model])}` }
const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' // Propagate env vars that teammates need but may not inherit from tmux split-window shells. // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars. const envStr = buildInheritedEnvVars() const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}`
// Send the command to the new window const sendKeysResult = await execFileNoThrow(TMUX_COMMAND, [ 'send-keys', '-t', `${SWARM_SESSION_NAME}:${windowName}`, spawnCommand, 'Enter', ])
if (sendKeysResult.code !== 0) { throw new Error( `Failed to send command to tmux window: ${sendKeysResult.stderr}`, ) }
// Track the teammate in AppState's teamContext setAppState(prev => ({ ...prev, teamContext: { ...prev.teamContext, teamName: teamName ?? prev.teamContext?.teamName ?? 'default', teamFilePath: prev.teamContext?.teamFilePath ?? '', leadAgentId: prev.teamContext?.leadAgentId ?? '', teammates: { ...(prev.teamContext?.teammates || {}), [teammateId]: { name: sanitizedName, agentType: agent_type, color: teammateColor, tmuxSessionName: SWARM_SESSION_NAME, tmuxPaneId: paneId, cwd: workingDir, spawnedAt: Date.now(), }, }, }, }))
// Register background task so tmux teammates appear in the tasks pill/dialog // Separate window spawns are always outside tmux (external swarm session) registerOutOfProcessTeammateTask(setAppState, { teammateId, sanitizedName, teamName, teammateColor, prompt, plan_mode_required, paneId, insideTmux: false, backendType: 'tmux', toolUseId: context.toolUseId, })
// Register agent in the team file const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { throw new Error( `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, ) } teamFile.members.push({ agentId: teammateId, name: sanitizedName, agentType: agent_type, model, prompt, color: teammateColor, planModeRequired: plan_mode_required, joinedAt: Date.now(), tmuxPaneId: paneId, cwd: workingDir, subscriptions: [], backendType: 'tmux', // This handler always uses tmux directly }) await writeTeamFileAsync(teamName, teamFile)
// Send initial instructions to teammate via mailbox // The teammate's inbox poller will pick this up and submit it as their first turn await writeToMailbox( sanitizedName, { from: TEAM_LEAD_NAME, text: prompt, timestamp: new Date().toISOString(), }, teamName, )
return { data: { teammate_id: teammateId, agent_id: teammateId, agent_type, model, name: sanitizedName, color: teammateColor, tmux_session_name: SWARM_SESSION_NAME, tmux_window_name: windowName, tmux_pane_id: paneId, team_name: teamName, is_splitpane: false, plan_mode_required, }, }}
/** * Register a background task entry for an out-of-process (tmux/iTerm2) teammate. * This makes tmux teammates visible in the background tasks pill and dialog, * matching how in-process teammates are tracked. */function registerOutOfProcessTeammateTask( setAppState: (updater: (prev: AppState) => AppState) => void, { teammateId, sanitizedName, teamName, teammateColor, prompt, plan_mode_required, paneId, insideTmux, backendType, toolUseId, }: { teammateId: string sanitizedName: string teamName: string teammateColor: string prompt: string plan_mode_required?: boolean paneId: string insideTmux: boolean backendType: BackendType toolUseId?: string },): void { const taskId = generateTaskId('in_process_teammate') const description = `${sanitizedName}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}`
const abortController = new AbortController()
const taskState: InProcessTeammateTaskState = { ...createTaskStateBase( taskId, 'in_process_teammate', description, toolUseId, ), type: 'in_process_teammate', status: 'running', identity: { agentId: teammateId, agentName: sanitizedName, teamName, color: teammateColor, planModeRequired: plan_mode_required ?? false, parentSessionId: getSessionId(), }, prompt, abortController, awaitingPlanApproval: false, permissionMode: plan_mode_required ? 'plan' : 'default', isIdle: false, shutdownRequested: false, lastReportedToolCount: 0, lastReportedTokenCount: 0, pendingUserMessages: [], }
registerTask(taskState, setAppState)
// When abort is signaled, kill the pane using the backend that created it // (tmux kill-pane for tmux panes, it2 session close for iTerm2 native panes). // SDK task_notification bookend is emitted by killInProcessTeammate (the // sole abort trigger for this controller). abortController.signal.addEventListener( 'abort', () => { if (isPaneBackend(backendType)) { void getBackendByType(backendType).killPane(paneId, !insideTmux) } }, { once: true }, )}
/** * Handle spawn operation for in-process teammates. * In-process teammates run in the same Node.js process using AsyncLocalStorage. */async function handleSpawnInProcess( input: SpawnInput, context: ToolUseContext,): Promise<{ data: SpawnOutput }> { const { setAppState, getAppState } = context const { name, prompt, agent_type, plan_mode_required } = input
// Resolve model: 'inherit' → leader's model; undefined → default Opus const model = resolveTeammateModel(input.model, getAppState().mainLoopModel)
if (!name || !prompt) { throw new Error('name and prompt are required for spawn operation') }
// Get team name from input or inherit from leader's team context const appState = getAppState() const teamName = input.team_name || appState.teamContext?.teamName
if (!teamName) { throw new Error( 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', ) }
// Generate unique name if duplicate exists in team const uniqueName = await generateUniqueTeammateName(name, teamName)
// Sanitize the name to prevent @ in agent IDs const sanitizedName = sanitizeAgentName(uniqueName)
// Generate deterministic agent ID from name and team const teammateId = formatAgentId(sanitizedName, teamName)
// Assign a unique color to this teammate const teammateColor = assignTeammateColor(teammateId)
// Look up custom agent definition if agent_type is provided let agentDefinition: CustomAgentDefinition | undefined if (agent_type) { const allAgents = context.options.agentDefinitions.activeAgents const foundAgent = allAgents.find(a => a.agentType === agent_type) if (foundAgent && isCustomAgent(foundAgent)) { agentDefinition = foundAgent } logForDebugging( `[handleSpawnInProcess] agent_type=${agent_type}, found=${!!agentDefinition}`, ) }
// Spawn in-process teammate const config: InProcessSpawnConfig = { name: sanitizedName, teamName, prompt, color: teammateColor, planModeRequired: plan_mode_required ?? false, model, }
const result = await spawnInProcessTeammate(config, context)
if (!result.success) { throw new Error(result.error ?? 'Failed to spawn in-process teammate') }
// Debug: log what spawn returned logForDebugging( `[handleSpawnInProcess] spawn result: taskId=${result.taskId}, hasContext=${!!result.teammateContext}, hasAbort=${!!result.abortController}`, )
// Start the agent execution loop (fire-and-forget) if (result.taskId && result.teammateContext && result.abortController) { startInProcessTeammate({ identity: { agentId: teammateId, agentName: sanitizedName, teamName, color: teammateColor, planModeRequired: plan_mode_required ?? false, parentSessionId: result.teammateContext.parentSessionId, }, taskId: result.taskId, prompt, description: input.description, model, agentDefinition, teammateContext: result.teammateContext, // Strip messages: the teammate never reads toolUseContext.messages // (it builds its own history via allMessages in inProcessRunner). // Passing the parent's full conversation here would pin it for the // teammate's lifetime, surviving /clear and auto-compact. toolUseContext: { ...context, messages: [] }, abortController: result.abortController, invokingRequestId: input.invokingRequestId, }) logForDebugging( `[handleSpawnInProcess] Started agent execution for ${teammateId}`, ) }
// Track the teammate in AppState's teamContext // Auto-register leader if spawning without prior spawnTeam call setAppState(prev => { const needsLeaderSetup = !prev.teamContext?.leadAgentId const leadAgentId = needsLeaderSetup ? formatAgentId(TEAM_LEAD_NAME, teamName) : prev.teamContext!.leadAgentId
// Build teammates map, including leader if needed for inbox polling const existingTeammates = prev.teamContext?.teammates || {} const leadEntry = needsLeaderSetup ? { [leadAgentId]: { name: TEAM_LEAD_NAME, agentType: TEAM_LEAD_NAME, color: assignTeammateColor(leadAgentId), tmuxSessionName: 'in-process', tmuxPaneId: 'leader', cwd: getCwd(), spawnedAt: Date.now(), }, } : {}
return { ...prev, teamContext: { ...prev.teamContext, teamName: teamName ?? prev.teamContext?.teamName ?? 'default', teamFilePath: prev.teamContext?.teamFilePath ?? '', leadAgentId, teammates: { ...existingTeammates, ...leadEntry, [teammateId]: { name: sanitizedName, agentType: agent_type, color: teammateColor, tmuxSessionName: 'in-process', tmuxPaneId: 'in-process', cwd: getCwd(), spawnedAt: Date.now(), }, }, }, } })
// Register agent in the team file const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { throw new Error( `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, ) } teamFile.members.push({ agentId: teammateId, name: sanitizedName, agentType: agent_type, model, prompt, color: teammateColor, planModeRequired: plan_mode_required, joinedAt: Date.now(), tmuxPaneId: 'in-process', cwd: getCwd(), subscriptions: [], backendType: 'in-process', }) await writeTeamFileAsync(teamName, teamFile)
// Note: Do NOT send the prompt via mailbox for in-process teammates. // In-process teammates receive the prompt directly via startInProcessTeammate(). // The mailbox is only needed for tmux-based teammates which poll for their initial message. // Sending via both paths would cause duplicate welcome messages.
return { data: { teammate_id: teammateId, agent_id: teammateId, agent_type, model, name: sanitizedName, color: teammateColor, tmux_session_name: 'in-process', tmux_window_name: 'in-process', tmux_pane_id: 'in-process', team_name: teamName, is_splitpane: false, plan_mode_required, }, }}
/** * Handle spawn operation - creates a new Claude Code instance. * Uses in-process mode when enabled, otherwise uses tmux/iTerm2 split-pane view. * Falls back to in-process if pane backend detection fails (e.g., iTerm2 without * it2 CLI or tmux installed). */async function handleSpawn( input: SpawnInput, context: ToolUseContext,): Promise<{ data: SpawnOutput }> { // Check if in-process mode is enabled via feature flag if (isInProcessEnabled()) { return handleSpawnInProcess(input, context) }
// Pre-flight: ensure a pane backend is available before attempting pane-based spawn. // This handles auto-mode cases like iTerm2 without it2 or tmux installed, where // isInProcessEnabled() returns false but detectAndGetBackend() has no viable backend. // Narrowly scoped so user cancellation and other spawn errors propagate normally. try { await detectAndGetBackend() } catch (error) { // Only fall back silently in auto mode. If the user explicitly configured // teammateMode: 'tmux', let the error propagate so they see the actionable // install instructions from getTmuxInstallInstructions(). if (getTeammateModeFromSnapshot() !== 'auto') { throw error } logForDebugging( `[handleSpawn] No pane backend available, falling back to in-process: ${errorMessage(error)}`, ) // Record the fallback so isInProcessEnabled() reflects the actual mode // (fixes banner and other UI that would otherwise show tmux attach commands). markInProcessFallback() return handleSpawnInProcess(input, context) }
// Backend is available (and now cached) - proceed with pane spawning. // Any errors here (user cancellation, validation, etc.) propagate to the caller. const useSplitPane = input.use_splitpane !== false if (useSplitPane) { return handleSpawnSplitPane(input, context) } return handleSpawnSeparateWindow(input, context)}
// ============================================================================// Main Export// ============================================================================
/** * Spawns a new teammate with the given configuration. * This is the main entry point for teammate spawning, used by both TeammateTool and AgentTool. */export async function spawnTeammate( config: SpawnTeammateConfig, context: ToolUseContext,): Promise<{ data: SpawnOutput }> { return handleSpawn(config, context)}