/ utils / swarm / spawnUtils.ts
spawnUtils.ts
  1  /**
  2   * Shared utilities for spawning teammates across different backends.
  3   */
  4  
  5  import {
  6    getChromeFlagOverride,
  7    getFlagSettingsPath,
  8    getInlinePlugins,
  9    getMainLoopModelOverride,
 10    getSessionBypassPermissionsMode,
 11  } from '../../bootstrap/state.js'
 12  import { quote } from '../bash/shellQuote.js'
 13  import { isInBundledMode } from '../bundledMode.js'
 14  import type { PermissionMode } from '../permissions/PermissionMode.js'
 15  import { getTeammateModeFromSnapshot } from './backends/teammateModeSnapshot.js'
 16  import { TEAMMATE_COMMAND_ENV_VAR } from './constants.js'
 17  
 18  /**
 19   * Gets the command to use for spawning teammate processes.
 20   * Uses TEAMMATE_COMMAND_ENV_VAR if set, otherwise falls back to the
 21   * current process executable path.
 22   */
 23  export function getTeammateCommand(): string {
 24    if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
 25      return process.env[TEAMMATE_COMMAND_ENV_VAR]
 26    }
 27    return isInBundledMode() ? process.execPath : process.argv[1]!
 28  }
 29  
 30  /**
 31   * Builds CLI flags to propagate from the current session to spawned teammates.
 32   * This ensures teammates inherit important settings like permission mode,
 33   * model selection, and plugin configuration from their parent.
 34   *
 35   * @param options.planModeRequired - If true, don't inherit bypass permissions (plan mode takes precedence)
 36   * @param options.permissionMode - Permission mode to propagate
 37   */
 38  export function buildInheritedCliFlags(options?: {
 39    planModeRequired?: boolean
 40    permissionMode?: PermissionMode
 41  }): string {
 42    const flags: string[] = []
 43    const { planModeRequired, permissionMode } = options || {}
 44  
 45    // Propagate permission mode to teammates, but NOT if plan mode is required
 46    // Plan mode takes precedence over bypass permissions for safety
 47    if (planModeRequired) {
 48      // Don't inherit bypass permissions when plan mode is required
 49    } else if (
 50      permissionMode === 'bypassPermissions' ||
 51      getSessionBypassPermissionsMode()
 52    ) {
 53      flags.push('--dangerously-skip-permissions')
 54    } else if (permissionMode === 'acceptEdits') {
 55      flags.push('--permission-mode acceptEdits')
 56    }
 57  
 58    // Propagate --model if explicitly set via CLI
 59    const modelOverride = getMainLoopModelOverride()
 60    if (modelOverride) {
 61      flags.push(`--model ${quote([modelOverride])}`)
 62    }
 63  
 64    // Propagate --settings if set via CLI
 65    const settingsPath = getFlagSettingsPath()
 66    if (settingsPath) {
 67      flags.push(`--settings ${quote([settingsPath])}`)
 68    }
 69  
 70    // Propagate --plugin-dir for each inline plugin
 71    const inlinePlugins = getInlinePlugins()
 72    for (const pluginDir of inlinePlugins) {
 73      flags.push(`--plugin-dir ${quote([pluginDir])}`)
 74    }
 75  
 76    // Propagate --teammate-mode so tmux teammates use the same mode as leader
 77    const sessionMode = getTeammateModeFromSnapshot()
 78    flags.push(`--teammate-mode ${sessionMode}`)
 79  
 80    // Propagate --chrome / --no-chrome if explicitly set on the CLI
 81    const chromeFlagOverride = getChromeFlagOverride()
 82    if (chromeFlagOverride === true) {
 83      flags.push('--chrome')
 84    } else if (chromeFlagOverride === false) {
 85      flags.push('--no-chrome')
 86    }
 87  
 88    return flags.join(' ')
 89  }
 90  
 91  /**
 92   * Environment variables that must be explicitly forwarded to tmux-spawned
 93   * teammates. Tmux may start a new login shell that doesn't inherit the
 94   * parent's env, so we forward any that are set in the current process.
 95   */
 96  const TEAMMATE_ENV_VARS = [
 97    // API provider selection — without these, teammates default to firstParty
 98    // and send requests to the wrong endpoint (GitHub issue #23561)
 99    'CLAUDE_CODE_USE_BEDROCK',
100    'CLAUDE_CODE_USE_VERTEX',
101    'CLAUDE_CODE_USE_FOUNDRY',
102    // Custom API endpoint
103    'ANTHROPIC_BASE_URL',
104    // Config directory override
105    'CLAUDE_CONFIG_DIR',
106    // CCR marker — teammates need this for CCR-aware code paths. Auth finds
107    // its own way via /home/claude/.claude/remote/.oauth_token regardless;
108    // the FD env var wouldn't help (pipe FDs don't cross tmux).
109    'CLAUDE_CODE_REMOTE',
110    // Auto-memory gate (memdir/paths.ts) checks REMOTE && !MEMORY_DIR to
111    // disable memory on ephemeral CCR filesystems. Forwarding REMOTE alone
112    // would flip teammates to memory-off when the parent has it on.
113    'CLAUDE_CODE_REMOTE_MEMORY_DIR',
114    // Upstream proxy — the parent's MITM relay is reachable from teammates
115    // (same container network). Forward the proxy vars so teammates route
116    // customer-configured upstream traffic through the relay for credential
117    // injection. Without these, teammates bypass the proxy entirely.
118    'HTTPS_PROXY',
119    'https_proxy',
120    'HTTP_PROXY',
121    'http_proxy',
122    'NO_PROXY',
123    'no_proxy',
124    'SSL_CERT_FILE',
125    'NODE_EXTRA_CA_CERTS',
126    'REQUESTS_CA_BUNDLE',
127    'CURL_CA_BUNDLE',
128  ] as const
129  
130  /**
131   * Builds the `env KEY=VALUE ...` string for teammate spawn commands.
132   * Always includes CLAUDECODE=1 and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1,
133   * plus any provider/config env vars that are set in the current process.
134   */
135  export function buildInheritedEnvVars(): string {
136    const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1']
137  
138    for (const key of TEAMMATE_ENV_VARS) {
139      const value = process.env[key]
140      if (value !== undefined && value !== '') {
141        envVars.push(`${key}=${quote([value])}`)
142      }
143    }
144  
145    return envVars.join(' ')
146  }