source dump of claude code
at main 1093 lines 36 kB view raw
1/** 2 * Shared spawn module for teammate creation. 3 * Extracted from TeammateTool to allow reuse by AgentTool. 4 */ 5 6import React from 'react' 7import { 8 getChromeFlagOverride, 9 getFlagSettingsPath, 10 getInlinePlugins, 11 getMainLoopModelOverride, 12 getSessionBypassPermissionsMode, 13 getSessionId, 14} from '../../bootstrap/state.js' 15import type { AppState } from '../../state/AppState.js' 16import { createTaskStateBase, generateTaskId } from '../../Task.js' 17import type { ToolUseContext } from '../../Tool.js' 18import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' 19import { formatAgentId } from '../../utils/agentId.js' 20import { quote } from '../../utils/bash/shellQuote.js' 21import { isInBundledMode } from '../../utils/bundledMode.js' 22import { getGlobalConfig } from '../../utils/config.js' 23import { getCwd } from '../../utils/cwd.js' 24import { logForDebugging } from '../../utils/debug.js' 25import { errorMessage } from '../../utils/errors.js' 26import { execFileNoThrow } from '../../utils/execFileNoThrow.js' 27import { parseUserSpecifiedModel } from '../../utils/model/model.js' 28import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' 29import { isTmuxAvailable } from '../../utils/swarm/backends/detection.js' 30import { 31 detectAndGetBackend, 32 getBackendByType, 33 isInProcessEnabled, 34 markInProcessFallback, 35 resetBackendDetection, 36} from '../../utils/swarm/backends/registry.js' 37import { getTeammateModeFromSnapshot } from '../../utils/swarm/backends/teammateModeSnapshot.js' 38import type { BackendType } from '../../utils/swarm/backends/types.js' 39import { isPaneBackend } from '../../utils/swarm/backends/types.js' 40import { 41 SWARM_SESSION_NAME, 42 TEAM_LEAD_NAME, 43 TEAMMATE_COMMAND_ENV_VAR, 44 TMUX_COMMAND, 45} from '../../utils/swarm/constants.js' 46import { It2SetupPrompt } from '../../utils/swarm/It2SetupPrompt.js' 47import { startInProcessTeammate } from '../../utils/swarm/inProcessRunner.js' 48import { 49 type InProcessSpawnConfig, 50 spawnInProcessTeammate, 51} from '../../utils/swarm/spawnInProcess.js' 52import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js' 53import { 54 readTeamFileAsync, 55 sanitizeAgentName, 56 sanitizeName, 57 writeTeamFileAsync, 58} from '../../utils/swarm/teamHelpers.js' 59import { 60 assignTeammateColor, 61 createTeammatePaneInSwarmView, 62 enablePaneBorderStatus, 63 isInsideTmux, 64 sendCommandToPane, 65} from '../../utils/swarm/teammateLayoutManager.js' 66import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js' 67import { registerTask } from '../../utils/task/framework.js' 68import { writeToMailbox } from '../../utils/teammateMailbox.js' 69import type { CustomAgentDefinition } from '../AgentTool/loadAgentsDir.js' 70import { isCustomAgent } from '../AgentTool/loadAgentsDir.js' 71 72function getDefaultTeammateModel(leaderModel: string | null): string { 73 const configured = getGlobalConfig().teammateDefaultModel 74 if (configured === null) { 75 // User picked "Default" in the /config picker — follow the leader. 76 return leaderModel ?? getHardcodedTeammateModelFallback() 77 } 78 if (configured !== undefined) { 79 return parseUserSpecifiedModel(configured) 80 } 81 return getHardcodedTeammateModelFallback() 82} 83 84/** 85 * Resolve a teammate model value. Handles the 'inherit' alias (from agent 86 * frontmatter) by substituting the leader's model. gh-31069: 'inherit' was 87 * passed literally to --model, producing "It may not exist or you may not 88 * have access". If leader model is null (not yet set), falls through to the 89 * default. 90 * 91 * Exported for testing. 92 */ 93export function resolveTeammateModel( 94 inputModel: string | undefined, 95 leaderModel: string | null, 96): string { 97 if (inputModel === 'inherit') { 98 return leaderModel ?? getDefaultTeammateModel(leaderModel) 99 } 100 return inputModel ?? getDefaultTeammateModel(leaderModel) 101} 102 103// ============================================================================ 104// Types 105// ============================================================================ 106 107export type SpawnOutput = { 108 teammate_id: string 109 agent_id: string 110 agent_type?: string 111 model?: string 112 name: string 113 color?: string 114 tmux_session_name: string 115 tmux_window_name: string 116 tmux_pane_id: string 117 team_name?: string 118 is_splitpane?: boolean 119 plan_mode_required?: boolean 120} 121 122export type SpawnTeammateConfig = { 123 name: string 124 prompt: string 125 team_name?: string 126 cwd?: string 127 use_splitpane?: boolean 128 plan_mode_required?: boolean 129 model?: string 130 agent_type?: string 131 description?: string 132 /** request_id of the API call whose response contained the tool_use that 133 * spawned this teammate. Threaded through to TeammateAgentContext for 134 * lineage tracing on tengu_api_* events. */ 135 invokingRequestId?: string 136} 137 138// Internal input type matching TeammateTool's spawn parameters 139type SpawnInput = { 140 name: string 141 prompt: string 142 team_name?: string 143 cwd?: string 144 use_splitpane?: boolean 145 plan_mode_required?: boolean 146 model?: string 147 agent_type?: string 148 description?: string 149 invokingRequestId?: string 150} 151 152// ============================================================================ 153// Helper Functions 154// ============================================================================ 155 156/** 157 * Checks if a tmux session exists 158 */ 159async function hasSession(sessionName: string): Promise<boolean> { 160 const result = await execFileNoThrow(TMUX_COMMAND, [ 161 'has-session', 162 '-t', 163 sessionName, 164 ]) 165 return result.code === 0 166} 167 168/** 169 * Creates a new tmux session if it doesn't exist 170 */ 171async function ensureSession(sessionName: string): Promise<void> { 172 const exists = await hasSession(sessionName) 173 if (!exists) { 174 const result = await execFileNoThrow(TMUX_COMMAND, [ 175 'new-session', 176 '-d', 177 '-s', 178 sessionName, 179 ]) 180 if (result.code !== 0) { 181 throw new Error( 182 `Failed to create tmux session '${sessionName}': ${result.stderr || 'Unknown error'}`, 183 ) 184 } 185 } 186} 187 188/** 189 * Gets the command to spawn a teammate. 190 * For native builds (compiled binaries), use process.execPath. 191 * For non-native (node/bun running a script), use process.argv[1]. 192 */ 193function getTeammateCommand(): string { 194 if (process.env[TEAMMATE_COMMAND_ENV_VAR]) { 195 return process.env[TEAMMATE_COMMAND_ENV_VAR] 196 } 197 return isInBundledMode() ? process.execPath : process.argv[1]! 198} 199 200/** 201 * Builds CLI flags to propagate from the current session to spawned teammates. 202 * This ensures teammates inherit important settings like permission mode, 203 * model selection, and plugin configuration from their parent. 204 * 205 * @param options.planModeRequired - If true, don't inherit bypass permissions (plan mode takes precedence) 206 * @param options.permissionMode - Permission mode to propagate 207 */ 208function buildInheritedCliFlags(options?: { 209 planModeRequired?: boolean 210 permissionMode?: PermissionMode 211}): string { 212 const flags: string[] = [] 213 const { planModeRequired, permissionMode } = options || {} 214 215 // Propagate permission mode to teammates, but NOT if plan mode is required 216 // Plan mode takes precedence over bypass permissions for safety 217 if (planModeRequired) { 218 // Don't inherit bypass permissions when plan mode is required 219 } else if ( 220 permissionMode === 'bypassPermissions' || 221 getSessionBypassPermissionsMode() 222 ) { 223 flags.push('--dangerously-skip-permissions') 224 } else if (permissionMode === 'acceptEdits') { 225 flags.push('--permission-mode acceptEdits') 226 } else if (permissionMode === 'auto') { 227 // Teammates inherit auto mode so the classifier auto-approves their tool 228 // calls too. The teammate's own startup (permissionSetup.ts) handles 229 // GrowthBook gate checks and setAutoModeActive(true) independently. 230 flags.push('--permission-mode auto') 231 } 232 233 // Propagate --model if explicitly set via CLI 234 const modelOverride = getMainLoopModelOverride() 235 if (modelOverride) { 236 flags.push(`--model ${quote([modelOverride])}`) 237 } 238 239 // Propagate --settings if set via CLI 240 const settingsPath = getFlagSettingsPath() 241 if (settingsPath) { 242 flags.push(`--settings ${quote([settingsPath])}`) 243 } 244 245 // Propagate --plugin-dir for each inline plugin 246 const inlinePlugins = getInlinePlugins() 247 for (const pluginDir of inlinePlugins) { 248 flags.push(`--plugin-dir ${quote([pluginDir])}`) 249 } 250 251 // Propagate --chrome / --no-chrome if explicitly set on the CLI 252 const chromeFlagOverride = getChromeFlagOverride() 253 if (chromeFlagOverride === true) { 254 flags.push('--chrome') 255 } else if (chromeFlagOverride === false) { 256 flags.push('--no-chrome') 257 } 258 259 return flags.join(' ') 260} 261 262/** 263 * Generates a unique teammate name by checking existing team members. 264 * If the name already exists, appends a numeric suffix (e.g., tester-2, tester-3). 265 * @internal Exported for testing 266 */ 267export async function generateUniqueTeammateName( 268 baseName: string, 269 teamName: string | undefined, 270): Promise<string> { 271 if (!teamName) { 272 return baseName 273 } 274 275 const teamFile = await readTeamFileAsync(teamName) 276 if (!teamFile) { 277 return baseName 278 } 279 280 const existingNames = new Set(teamFile.members.map(m => m.name.toLowerCase())) 281 282 // If the base name doesn't exist, use it as-is 283 if (!existingNames.has(baseName.toLowerCase())) { 284 return baseName 285 } 286 287 // Find the next available suffix 288 let suffix = 2 289 while (existingNames.has(`${baseName}-${suffix}`.toLowerCase())) { 290 suffix++ 291 } 292 293 return `${baseName}-${suffix}` 294} 295 296// ============================================================================ 297// Spawn Handlers 298// ============================================================================ 299 300/** 301 * Handle spawn operation using split-pane view (default). 302 * When inside tmux: Creates teammates in a shared window with leader on left, teammates on right. 303 * When outside tmux: Creates a claude-swarm session with all teammates in a tiled layout. 304 */ 305async function handleSpawnSplitPane( 306 input: SpawnInput, 307 context: ToolUseContext, 308): Promise<{ data: SpawnOutput }> { 309 const { setAppState, getAppState } = context 310 const { name, prompt, agent_type, cwd, plan_mode_required } = input 311 312 // Resolve model: 'inherit' → leader's model; undefined → default Opus 313 const model = resolveTeammateModel(input.model, getAppState().mainLoopModel) 314 315 if (!name || !prompt) { 316 throw new Error('name and prompt are required for spawn operation') 317 } 318 319 // Get team name from input or inherit from leader's team context 320 const appState = getAppState() 321 const teamName = input.team_name || appState.teamContext?.teamName 322 323 if (!teamName) { 324 throw new Error( 325 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', 326 ) 327 } 328 329 // Generate unique name if duplicate exists in team 330 const uniqueName = await generateUniqueTeammateName(name, teamName) 331 332 // Sanitize the name to prevent @ in agent IDs (would break agentName@teamName format) 333 const sanitizedName = sanitizeAgentName(uniqueName) 334 335 // Generate deterministic agent ID from name and team 336 const teammateId = formatAgentId(sanitizedName, teamName) 337 const workingDir = cwd || getCwd() 338 339 // Detect the appropriate backend and check if setup is needed 340 let detectionResult = await detectAndGetBackend() 341 342 // If in iTerm2 but it2 isn't set up, prompt the user 343 if (detectionResult.needsIt2Setup && context.setToolJSX) { 344 const tmuxAvailable = await isTmuxAvailable() 345 346 // Show the setup prompt and wait for user decision 347 const setupResult = await new Promise< 348 'installed' | 'use-tmux' | 'cancelled' 349 >(resolve => { 350 context.setToolJSX!({ 351 jsx: React.createElement(It2SetupPrompt, { 352 onDone: resolve, 353 tmuxAvailable, 354 }), 355 shouldHidePromptInput: true, 356 }) 357 }) 358 359 // Clear the JSX 360 context.setToolJSX(null) 361 362 if (setupResult === 'cancelled') { 363 throw new Error('Teammate spawn cancelled - iTerm2 setup required') 364 } 365 366 // If they installed it2 or chose tmux, clear cached detection and re-fetch 367 // so the local detectionResult matches the backend that will actually 368 // spawn the pane. 369 // - 'installed': re-detect to pick up the ITermBackend (it2 is now available) 370 // - 'use-tmux': re-detect so needsIt2Setup is false (preferTmux is now saved) 371 // and subsequent spawns skip this prompt 372 if (setupResult === 'installed' || setupResult === 'use-tmux') { 373 resetBackendDetection() 374 detectionResult = await detectAndGetBackend() 375 } 376 } 377 378 // Check if we're inside tmux to determine session naming 379 const insideTmux = await isInsideTmux() 380 381 // Assign a unique color to this teammate 382 const teammateColor = assignTeammateColor(teammateId) 383 384 // Create a pane in the swarm view 385 // - Inside tmux: splits current window (leader on left, teammates on right) 386 // - In iTerm2 with it2: uses native iTerm2 split panes 387 // - Outside both: creates claude-swarm session with tiled teammates 388 const { paneId, isFirstTeammate } = await createTeammatePaneInSwarmView( 389 sanitizedName, 390 teammateColor, 391 ) 392 393 // Enable pane border status on first teammate when inside tmux 394 // (outside tmux, this is handled in createTeammatePaneInSwarmView) 395 if (isFirstTeammate && insideTmux) { 396 await enablePaneBorderStatus() 397 } 398 399 // Build the command to spawn Claude Code with teammate identity 400 // Note: We spawn without a prompt - initial instructions are sent via mailbox 401 const binaryPath = getTeammateCommand() 402 403 // Build teammate identity CLI args (replaces CLAUDE_CODE_* env vars) 404 const teammateArgs = [ 405 `--agent-id ${quote([teammateId])}`, 406 `--agent-name ${quote([sanitizedName])}`, 407 `--team-name ${quote([teamName])}`, 408 `--agent-color ${quote([teammateColor])}`, 409 `--parent-session-id ${quote([getSessionId()])}`, 410 plan_mode_required ? '--plan-mode-required' : '', 411 agent_type ? `--agent-type ${quote([agent_type])}` : '', 412 ] 413 .filter(Boolean) 414 .join(' ') 415 416 // Build CLI flags to propagate to teammate 417 // Pass plan_mode_required to prevent inheriting bypass permissions 418 let inheritedFlags = buildInheritedCliFlags({ 419 planModeRequired: plan_mode_required, 420 permissionMode: appState.toolPermissionContext.mode, 421 }) 422 423 // If teammate has a custom model, add --model flag (or replace inherited one) 424 if (model) { 425 // Remove any inherited --model flag first 426 inheritedFlags = inheritedFlags 427 .split(' ') 428 .filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model') 429 .join(' ') 430 // Add the teammate's model 431 inheritedFlags = inheritedFlags 432 ? `${inheritedFlags} --model ${quote([model])}` 433 : `--model ${quote([model])}` 434 } 435 436 const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' 437 // Propagate env vars that teammates need but may not inherit from tmux split-window shells. 438 // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars. 439 const envStr = buildInheritedEnvVars() 440 const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` 441 442 // Send the command to the new pane 443 // Use swarm socket when running outside tmux (external swarm session) 444 await sendCommandToPane(paneId, spawnCommand, !insideTmux) 445 446 // Determine session/window names for output 447 const sessionName = insideTmux ? 'current' : SWARM_SESSION_NAME 448 const windowName = insideTmux ? 'current' : 'swarm-view' 449 450 // Track the teammate in AppState's teamContext with color 451 // If spawning without spawnTeam, set up the leader as team lead 452 setAppState(prev => ({ 453 ...prev, 454 teamContext: { 455 ...prev.teamContext, 456 teamName: teamName ?? prev.teamContext?.teamName ?? 'default', 457 teamFilePath: prev.teamContext?.teamFilePath ?? '', 458 leadAgentId: prev.teamContext?.leadAgentId ?? '', 459 teammates: { 460 ...(prev.teamContext?.teammates || {}), 461 [teammateId]: { 462 name: sanitizedName, 463 agentType: agent_type, 464 color: teammateColor, 465 tmuxSessionName: sessionName, 466 tmuxPaneId: paneId, 467 cwd: workingDir, 468 spawnedAt: Date.now(), 469 }, 470 }, 471 }, 472 })) 473 474 // Register background task so teammates appear in the tasks pill/dialog 475 registerOutOfProcessTeammateTask(setAppState, { 476 teammateId, 477 sanitizedName, 478 teamName, 479 teammateColor, 480 prompt, 481 plan_mode_required, 482 paneId, 483 insideTmux, 484 backendType: detectionResult.backend.type, 485 toolUseId: context.toolUseId, 486 }) 487 488 // Register agent in the team file 489 const teamFile = await readTeamFileAsync(teamName) 490 if (!teamFile) { 491 throw new Error( 492 `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, 493 ) 494 } 495 teamFile.members.push({ 496 agentId: teammateId, 497 name: sanitizedName, 498 agentType: agent_type, 499 model, 500 prompt, 501 color: teammateColor, 502 planModeRequired: plan_mode_required, 503 joinedAt: Date.now(), 504 tmuxPaneId: paneId, 505 cwd: workingDir, 506 subscriptions: [], 507 backendType: detectionResult.backend.type, 508 }) 509 await writeTeamFileAsync(teamName, teamFile) 510 511 // Send initial instructions to teammate via mailbox 512 // The teammate's inbox poller will pick this up and submit it as their first turn 513 await writeToMailbox( 514 sanitizedName, 515 { 516 from: TEAM_LEAD_NAME, 517 text: prompt, 518 timestamp: new Date().toISOString(), 519 }, 520 teamName, 521 ) 522 523 return { 524 data: { 525 teammate_id: teammateId, 526 agent_id: teammateId, 527 agent_type, 528 model, 529 name: sanitizedName, 530 color: teammateColor, 531 tmux_session_name: sessionName, 532 tmux_window_name: windowName, 533 tmux_pane_id: paneId, 534 team_name: teamName, 535 is_splitpane: true, 536 plan_mode_required, 537 }, 538 } 539} 540 541/** 542 * Handle spawn operation using separate windows (legacy behavior). 543 * Creates each teammate in its own tmux window. 544 */ 545async function handleSpawnSeparateWindow( 546 input: SpawnInput, 547 context: ToolUseContext, 548): Promise<{ data: SpawnOutput }> { 549 const { setAppState, getAppState } = context 550 const { name, prompt, agent_type, cwd, plan_mode_required } = input 551 552 // Resolve model: 'inherit' → leader's model; undefined → default Opus 553 const model = resolveTeammateModel(input.model, getAppState().mainLoopModel) 554 555 if (!name || !prompt) { 556 throw new Error('name and prompt are required for spawn operation') 557 } 558 559 // Get team name from input or inherit from leader's team context 560 const appState = getAppState() 561 const teamName = input.team_name || appState.teamContext?.teamName 562 563 if (!teamName) { 564 throw new Error( 565 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', 566 ) 567 } 568 569 // Generate unique name if duplicate exists in team 570 const uniqueName = await generateUniqueTeammateName(name, teamName) 571 572 // Sanitize the name to prevent @ in agent IDs (would break agentName@teamName format) 573 const sanitizedName = sanitizeAgentName(uniqueName) 574 575 // Generate deterministic agent ID from name and team 576 const teammateId = formatAgentId(sanitizedName, teamName) 577 const windowName = `teammate-${sanitizeName(sanitizedName)}` 578 const workingDir = cwd || getCwd() 579 580 // Ensure the swarm session exists 581 await ensureSession(SWARM_SESSION_NAME) 582 583 // Assign a unique color to this teammate 584 const teammateColor = assignTeammateColor(teammateId) 585 586 // Create a new window for this teammate 587 const createWindowResult = await execFileNoThrow(TMUX_COMMAND, [ 588 'new-window', 589 '-t', 590 SWARM_SESSION_NAME, 591 '-n', 592 windowName, 593 '-P', 594 '-F', 595 '#{pane_id}', 596 ]) 597 598 if (createWindowResult.code !== 0) { 599 throw new Error( 600 `Failed to create tmux window: ${createWindowResult.stderr}`, 601 ) 602 } 603 604 const paneId = createWindowResult.stdout.trim() 605 606 // Build the command to spawn Claude Code with teammate identity 607 // Note: We spawn without a prompt - initial instructions are sent via mailbox 608 const binaryPath = getTeammateCommand() 609 610 // Build teammate identity CLI args (replaces CLAUDE_CODE_* env vars) 611 const teammateArgs = [ 612 `--agent-id ${quote([teammateId])}`, 613 `--agent-name ${quote([sanitizedName])}`, 614 `--team-name ${quote([teamName])}`, 615 `--agent-color ${quote([teammateColor])}`, 616 `--parent-session-id ${quote([getSessionId()])}`, 617 plan_mode_required ? '--plan-mode-required' : '', 618 agent_type ? `--agent-type ${quote([agent_type])}` : '', 619 ] 620 .filter(Boolean) 621 .join(' ') 622 623 // Build CLI flags to propagate to teammate 624 // Pass plan_mode_required to prevent inheriting bypass permissions 625 let inheritedFlags = buildInheritedCliFlags({ 626 planModeRequired: plan_mode_required, 627 permissionMode: appState.toolPermissionContext.mode, 628 }) 629 630 // If teammate has a custom model, add --model flag (or replace inherited one) 631 if (model) { 632 // Remove any inherited --model flag first 633 inheritedFlags = inheritedFlags 634 .split(' ') 635 .filter((flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model') 636 .join(' ') 637 // Add the teammate's model 638 inheritedFlags = inheritedFlags 639 ? `${inheritedFlags} --model ${quote([model])}` 640 : `--model ${quote([model])}` 641 } 642 643 const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' 644 // Propagate env vars that teammates need but may not inherit from tmux split-window shells. 645 // Includes CLAUDECODE, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and API provider vars. 646 const envStr = buildInheritedEnvVars() 647 const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` 648 649 // Send the command to the new window 650 const sendKeysResult = await execFileNoThrow(TMUX_COMMAND, [ 651 'send-keys', 652 '-t', 653 `${SWARM_SESSION_NAME}:${windowName}`, 654 spawnCommand, 655 'Enter', 656 ]) 657 658 if (sendKeysResult.code !== 0) { 659 throw new Error( 660 `Failed to send command to tmux window: ${sendKeysResult.stderr}`, 661 ) 662 } 663 664 // Track the teammate in AppState's teamContext 665 setAppState(prev => ({ 666 ...prev, 667 teamContext: { 668 ...prev.teamContext, 669 teamName: teamName ?? prev.teamContext?.teamName ?? 'default', 670 teamFilePath: prev.teamContext?.teamFilePath ?? '', 671 leadAgentId: prev.teamContext?.leadAgentId ?? '', 672 teammates: { 673 ...(prev.teamContext?.teammates || {}), 674 [teammateId]: { 675 name: sanitizedName, 676 agentType: agent_type, 677 color: teammateColor, 678 tmuxSessionName: SWARM_SESSION_NAME, 679 tmuxPaneId: paneId, 680 cwd: workingDir, 681 spawnedAt: Date.now(), 682 }, 683 }, 684 }, 685 })) 686 687 // Register background task so tmux teammates appear in the tasks pill/dialog 688 // Separate window spawns are always outside tmux (external swarm session) 689 registerOutOfProcessTeammateTask(setAppState, { 690 teammateId, 691 sanitizedName, 692 teamName, 693 teammateColor, 694 prompt, 695 plan_mode_required, 696 paneId, 697 insideTmux: false, 698 backendType: 'tmux', 699 toolUseId: context.toolUseId, 700 }) 701 702 // Register agent in the team file 703 const teamFile = await readTeamFileAsync(teamName) 704 if (!teamFile) { 705 throw new Error( 706 `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, 707 ) 708 } 709 teamFile.members.push({ 710 agentId: teammateId, 711 name: sanitizedName, 712 agentType: agent_type, 713 model, 714 prompt, 715 color: teammateColor, 716 planModeRequired: plan_mode_required, 717 joinedAt: Date.now(), 718 tmuxPaneId: paneId, 719 cwd: workingDir, 720 subscriptions: [], 721 backendType: 'tmux', // This handler always uses tmux directly 722 }) 723 await writeTeamFileAsync(teamName, teamFile) 724 725 // Send initial instructions to teammate via mailbox 726 // The teammate's inbox poller will pick this up and submit it as their first turn 727 await writeToMailbox( 728 sanitizedName, 729 { 730 from: TEAM_LEAD_NAME, 731 text: prompt, 732 timestamp: new Date().toISOString(), 733 }, 734 teamName, 735 ) 736 737 return { 738 data: { 739 teammate_id: teammateId, 740 agent_id: teammateId, 741 agent_type, 742 model, 743 name: sanitizedName, 744 color: teammateColor, 745 tmux_session_name: SWARM_SESSION_NAME, 746 tmux_window_name: windowName, 747 tmux_pane_id: paneId, 748 team_name: teamName, 749 is_splitpane: false, 750 plan_mode_required, 751 }, 752 } 753} 754 755/** 756 * Register a background task entry for an out-of-process (tmux/iTerm2) teammate. 757 * This makes tmux teammates visible in the background tasks pill and dialog, 758 * matching how in-process teammates are tracked. 759 */ 760function registerOutOfProcessTeammateTask( 761 setAppState: (updater: (prev: AppState) => AppState) => void, 762 { 763 teammateId, 764 sanitizedName, 765 teamName, 766 teammateColor, 767 prompt, 768 plan_mode_required, 769 paneId, 770 insideTmux, 771 backendType, 772 toolUseId, 773 }: { 774 teammateId: string 775 sanitizedName: string 776 teamName: string 777 teammateColor: string 778 prompt: string 779 plan_mode_required?: boolean 780 paneId: string 781 insideTmux: boolean 782 backendType: BackendType 783 toolUseId?: string 784 }, 785): void { 786 const taskId = generateTaskId('in_process_teammate') 787 const description = `${sanitizedName}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}` 788 789 const abortController = new AbortController() 790 791 const taskState: InProcessTeammateTaskState = { 792 ...createTaskStateBase( 793 taskId, 794 'in_process_teammate', 795 description, 796 toolUseId, 797 ), 798 type: 'in_process_teammate', 799 status: 'running', 800 identity: { 801 agentId: teammateId, 802 agentName: sanitizedName, 803 teamName, 804 color: teammateColor, 805 planModeRequired: plan_mode_required ?? false, 806 parentSessionId: getSessionId(), 807 }, 808 prompt, 809 abortController, 810 awaitingPlanApproval: false, 811 permissionMode: plan_mode_required ? 'plan' : 'default', 812 isIdle: false, 813 shutdownRequested: false, 814 lastReportedToolCount: 0, 815 lastReportedTokenCount: 0, 816 pendingUserMessages: [], 817 } 818 819 registerTask(taskState, setAppState) 820 821 // When abort is signaled, kill the pane using the backend that created it 822 // (tmux kill-pane for tmux panes, it2 session close for iTerm2 native panes). 823 // SDK task_notification bookend is emitted by killInProcessTeammate (the 824 // sole abort trigger for this controller). 825 abortController.signal.addEventListener( 826 'abort', 827 () => { 828 if (isPaneBackend(backendType)) { 829 void getBackendByType(backendType).killPane(paneId, !insideTmux) 830 } 831 }, 832 { once: true }, 833 ) 834} 835 836/** 837 * Handle spawn operation for in-process teammates. 838 * In-process teammates run in the same Node.js process using AsyncLocalStorage. 839 */ 840async function handleSpawnInProcess( 841 input: SpawnInput, 842 context: ToolUseContext, 843): Promise<{ data: SpawnOutput }> { 844 const { setAppState, getAppState } = context 845 const { name, prompt, agent_type, plan_mode_required } = input 846 847 // Resolve model: 'inherit' → leader's model; undefined → default Opus 848 const model = resolveTeammateModel(input.model, getAppState().mainLoopModel) 849 850 if (!name || !prompt) { 851 throw new Error('name and prompt are required for spawn operation') 852 } 853 854 // Get team name from input or inherit from leader's team context 855 const appState = getAppState() 856 const teamName = input.team_name || appState.teamContext?.teamName 857 858 if (!teamName) { 859 throw new Error( 860 'team_name is required for spawn operation. Either provide team_name in input or call spawnTeam first to establish team context.', 861 ) 862 } 863 864 // Generate unique name if duplicate exists in team 865 const uniqueName = await generateUniqueTeammateName(name, teamName) 866 867 // Sanitize the name to prevent @ in agent IDs 868 const sanitizedName = sanitizeAgentName(uniqueName) 869 870 // Generate deterministic agent ID from name and team 871 const teammateId = formatAgentId(sanitizedName, teamName) 872 873 // Assign a unique color to this teammate 874 const teammateColor = assignTeammateColor(teammateId) 875 876 // Look up custom agent definition if agent_type is provided 877 let agentDefinition: CustomAgentDefinition | undefined 878 if (agent_type) { 879 const allAgents = context.options.agentDefinitions.activeAgents 880 const foundAgent = allAgents.find(a => a.agentType === agent_type) 881 if (foundAgent && isCustomAgent(foundAgent)) { 882 agentDefinition = foundAgent 883 } 884 logForDebugging( 885 `[handleSpawnInProcess] agent_type=${agent_type}, found=${!!agentDefinition}`, 886 ) 887 } 888 889 // Spawn in-process teammate 890 const config: InProcessSpawnConfig = { 891 name: sanitizedName, 892 teamName, 893 prompt, 894 color: teammateColor, 895 planModeRequired: plan_mode_required ?? false, 896 model, 897 } 898 899 const result = await spawnInProcessTeammate(config, context) 900 901 if (!result.success) { 902 throw new Error(result.error ?? 'Failed to spawn in-process teammate') 903 } 904 905 // Debug: log what spawn returned 906 logForDebugging( 907 `[handleSpawnInProcess] spawn result: taskId=${result.taskId}, hasContext=${!!result.teammateContext}, hasAbort=${!!result.abortController}`, 908 ) 909 910 // Start the agent execution loop (fire-and-forget) 911 if (result.taskId && result.teammateContext && result.abortController) { 912 startInProcessTeammate({ 913 identity: { 914 agentId: teammateId, 915 agentName: sanitizedName, 916 teamName, 917 color: teammateColor, 918 planModeRequired: plan_mode_required ?? false, 919 parentSessionId: result.teammateContext.parentSessionId, 920 }, 921 taskId: result.taskId, 922 prompt, 923 description: input.description, 924 model, 925 agentDefinition, 926 teammateContext: result.teammateContext, 927 // Strip messages: the teammate never reads toolUseContext.messages 928 // (it builds its own history via allMessages in inProcessRunner). 929 // Passing the parent's full conversation here would pin it for the 930 // teammate's lifetime, surviving /clear and auto-compact. 931 toolUseContext: { ...context, messages: [] }, 932 abortController: result.abortController, 933 invokingRequestId: input.invokingRequestId, 934 }) 935 logForDebugging( 936 `[handleSpawnInProcess] Started agent execution for ${teammateId}`, 937 ) 938 } 939 940 // Track the teammate in AppState's teamContext 941 // Auto-register leader if spawning without prior spawnTeam call 942 setAppState(prev => { 943 const needsLeaderSetup = !prev.teamContext?.leadAgentId 944 const leadAgentId = needsLeaderSetup 945 ? formatAgentId(TEAM_LEAD_NAME, teamName) 946 : prev.teamContext!.leadAgentId 947 948 // Build teammates map, including leader if needed for inbox polling 949 const existingTeammates = prev.teamContext?.teammates || {} 950 const leadEntry = needsLeaderSetup 951 ? { 952 [leadAgentId]: { 953 name: TEAM_LEAD_NAME, 954 agentType: TEAM_LEAD_NAME, 955 color: assignTeammateColor(leadAgentId), 956 tmuxSessionName: 'in-process', 957 tmuxPaneId: 'leader', 958 cwd: getCwd(), 959 spawnedAt: Date.now(), 960 }, 961 } 962 : {} 963 964 return { 965 ...prev, 966 teamContext: { 967 ...prev.teamContext, 968 teamName: teamName ?? prev.teamContext?.teamName ?? 'default', 969 teamFilePath: prev.teamContext?.teamFilePath ?? '', 970 leadAgentId, 971 teammates: { 972 ...existingTeammates, 973 ...leadEntry, 974 [teammateId]: { 975 name: sanitizedName, 976 agentType: agent_type, 977 color: teammateColor, 978 tmuxSessionName: 'in-process', 979 tmuxPaneId: 'in-process', 980 cwd: getCwd(), 981 spawnedAt: Date.now(), 982 }, 983 }, 984 }, 985 } 986 }) 987 988 // Register agent in the team file 989 const teamFile = await readTeamFileAsync(teamName) 990 if (!teamFile) { 991 throw new Error( 992 `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, 993 ) 994 } 995 teamFile.members.push({ 996 agentId: teammateId, 997 name: sanitizedName, 998 agentType: agent_type, 999 model, 1000 prompt, 1001 color: teammateColor, 1002 planModeRequired: plan_mode_required, 1003 joinedAt: Date.now(), 1004 tmuxPaneId: 'in-process', 1005 cwd: getCwd(), 1006 subscriptions: [], 1007 backendType: 'in-process', 1008 }) 1009 await writeTeamFileAsync(teamName, teamFile) 1010 1011 // Note: Do NOT send the prompt via mailbox for in-process teammates. 1012 // In-process teammates receive the prompt directly via startInProcessTeammate(). 1013 // The mailbox is only needed for tmux-based teammates which poll for their initial message. 1014 // Sending via both paths would cause duplicate welcome messages. 1015 1016 return { 1017 data: { 1018 teammate_id: teammateId, 1019 agent_id: teammateId, 1020 agent_type, 1021 model, 1022 name: sanitizedName, 1023 color: teammateColor, 1024 tmux_session_name: 'in-process', 1025 tmux_window_name: 'in-process', 1026 tmux_pane_id: 'in-process', 1027 team_name: teamName, 1028 is_splitpane: false, 1029 plan_mode_required, 1030 }, 1031 } 1032} 1033 1034/** 1035 * Handle spawn operation - creates a new Claude Code instance. 1036 * Uses in-process mode when enabled, otherwise uses tmux/iTerm2 split-pane view. 1037 * Falls back to in-process if pane backend detection fails (e.g., iTerm2 without 1038 * it2 CLI or tmux installed). 1039 */ 1040async function handleSpawn( 1041 input: SpawnInput, 1042 context: ToolUseContext, 1043): Promise<{ data: SpawnOutput }> { 1044 // Check if in-process mode is enabled via feature flag 1045 if (isInProcessEnabled()) { 1046 return handleSpawnInProcess(input, context) 1047 } 1048 1049 // Pre-flight: ensure a pane backend is available before attempting pane-based spawn. 1050 // This handles auto-mode cases like iTerm2 without it2 or tmux installed, where 1051 // isInProcessEnabled() returns false but detectAndGetBackend() has no viable backend. 1052 // Narrowly scoped so user cancellation and other spawn errors propagate normally. 1053 try { 1054 await detectAndGetBackend() 1055 } catch (error) { 1056 // Only fall back silently in auto mode. If the user explicitly configured 1057 // teammateMode: 'tmux', let the error propagate so they see the actionable 1058 // install instructions from getTmuxInstallInstructions(). 1059 if (getTeammateModeFromSnapshot() !== 'auto') { 1060 throw error 1061 } 1062 logForDebugging( 1063 `[handleSpawn] No pane backend available, falling back to in-process: ${errorMessage(error)}`, 1064 ) 1065 // Record the fallback so isInProcessEnabled() reflects the actual mode 1066 // (fixes banner and other UI that would otherwise show tmux attach commands). 1067 markInProcessFallback() 1068 return handleSpawnInProcess(input, context) 1069 } 1070 1071 // Backend is available (and now cached) - proceed with pane spawning. 1072 // Any errors here (user cancellation, validation, etc.) propagate to the caller. 1073 const useSplitPane = input.use_splitpane !== false 1074 if (useSplitPane) { 1075 return handleSpawnSplitPane(input, context) 1076 } 1077 return handleSpawnSeparateWindow(input, context) 1078} 1079 1080// ============================================================================ 1081// Main Export 1082// ============================================================================ 1083 1084/** 1085 * Spawns a new teammate with the given configuration. 1086 * This is the main entry point for teammate spawning, used by both TeammateTool and AgentTool. 1087 */ 1088export async function spawnTeammate( 1089 config: SpawnTeammateConfig, 1090 context: ToolUseContext, 1091): Promise<{ data: SpawnOutput }> { 1092 return handleSpawn(config, context) 1093}