Download src/tools/shared/spawnMultiAgent.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 35.6 kB
-
https://huggingface.co/chenbhao/codev/resolve/714ab7d15ffd78992a4c3d4e2bf153c2f62f4841/src/tools/shared/spawnMultiAgent.ts
- Command line
-
hf download hf://chenbhao/codev@714ab7d15ffd78992a4c3d4e2bf153c2f62f4841/src/tools/shared/spawnMultiAgent.ts
-
curl -L -o spawnMultiAgent.ts https://huggingface.co/chenbhao/codev/resolve/714ab7d15ffd78992a4c3d4e2bf153c2f62f4841/src/tools/shared/spawnMultiAgent.ts
35.6 kB
| /** | |
| * 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 parameters | |
| type 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) | |
| } | |