1094 lines
35 KiB
TypeScript
1094 lines
35 KiB
TypeScript
/**
|
|
* 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)
|
|
}
|