/ tools / shared / spawnMultiAgent.ts
spawnMultiAgent.ts
   1  /**
   2   * Shared spawn module for teammate creation.
   3   * Extracted from TeammateTool to allow reuse by AgentTool.
   4   */
   5  
   6  import React from 'react'
   7  import {
   8    getChromeFlagOverride,
   9    getFlagSettingsPath,
  10    getInlinePlugins,
  11    getMainLoopModelOverride,
  12    getSessionBypassPermissionsMode,
  13    getSessionId,
  14  } from '../../bootstrap/state.js'
  15  import type { AppState } from '../../state/AppState.js'
  16  import { createTaskStateBase, generateTaskId } from '../../Task.js'
  17  import type { ToolUseContext } from '../../Tool.js'
  18  import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'
  19  import { formatAgentId } from '../../utils/agentId.js'
  20  import { quote } from '../../utils/bash/shellQuote.js'
  21  import { isInBundledMode } from '../../utils/bundledMode.js'
  22  import { getGlobalConfig } from '../../utils/config.js'
  23  import { getCwd } from '../../utils/cwd.js'
  24  import { logForDebugging } from '../../utils/debug.js'
  25  import { errorMessage } from '../../utils/errors.js'
  26  import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
  27  import { parseUserSpecifiedModel } from '../../utils/model/model.js'
  28  import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
  29  import { isTmuxAvailable } from '../../utils/swarm/backends/detection.js'
  30  import {
  31    detectAndGetBackend,
  32    getBackendByType,
  33    isInProcessEnabled,
  34    markInProcessFallback,
  35    resetBackendDetection,
  36  } from '../../utils/swarm/backends/registry.js'
  37  import { getTeammateModeFromSnapshot } from '../../utils/swarm/backends/teammateModeSnapshot.js'
  38  import type { BackendType } from '../../utils/swarm/backends/types.js'
  39  import { isPaneBackend } from '../../utils/swarm/backends/types.js'
  40  import {
  41    SWARM_SESSION_NAME,
  42    TEAM_LEAD_NAME,
  43    TEAMMATE_COMMAND_ENV_VAR,
  44    TMUX_COMMAND,
  45  } from '../../utils/swarm/constants.js'
  46  import { It2SetupPrompt } from '../../utils/swarm/It2SetupPrompt.js'
  47  import { startInProcessTeammate } from '../../utils/swarm/inProcessRunner.js'
  48  import {
  49    type InProcessSpawnConfig,
  50    spawnInProcessTeammate,
  51  } from '../../utils/swarm/spawnInProcess.js'
  52  import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'
  53  import {
  54    readTeamFileAsync,
  55    sanitizeAgentName,
  56    sanitizeName,
  57    writeTeamFileAsync,
  58  } from '../../utils/swarm/teamHelpers.js'
  59  import {
  60    assignTeammateColor,
  61    createTeammatePaneInSwarmView,
  62    enablePaneBorderStatus,
  63    isInsideTmux,
  64    sendCommandToPane,
  65  } from '../../utils/swarm/teammateLayoutManager.js'
  66  import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js'
  67  import { registerTask } from '../../utils/task/framework.js'
  68  import { writeToMailbox } from '../../utils/teammateMailbox.js'
  69  import type { CustomAgentDefinition } from '../AgentTool/loadAgentsDir.js'
  70  import { isCustomAgent } from '../AgentTool/loadAgentsDir.js'
  71  
  72  function 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   */
  93  export 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  
 107  export 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  
 122  export 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
 139  type 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   */
 159  async 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   */
 171  async 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   */
 193  function 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   */
 208  function 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   */
 267  export 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   */
 305  async 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   */
 545  async 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   */
 760  function 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   */
 840  async 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   */
1040  async 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   */
1088  export async function spawnTeammate(
1089    config: SpawnTeammateConfig,
1090    context: ToolUseContext,
1091  ): Promise<{ data: SpawnOutput }> {
1092    return handleSpawn(config, context)
1093  }