import React from 'react' /** * Shared spawn module for teammate creation. * Extracted from TeammateTool to allow reuse by AgentTool. */ import { getSessionId, } from 'src/bootstrap/state.js' import type { ToolUseContext } from 'src/Tool.js' import { formatAgentId } from 'src/utils/agentId.js' import { getGlobalConfig } from 'src/utils/config.js' import { getCwd } from 'src/utils/cwd.js' import { logForDebugging } from 'src/utils/debug.js' import { parseUserSpecifiedModel } from 'src/utils/model/model.js' import { getTeammateExecutor, } from 'src/utils/swarm/backends/registry.js' import type { BackendType, TeammateSpawnResult } from 'src/utils/swarm/backends/types.js' import { SWARM_SESSION_NAME, TEAM_LEAD_NAME, } from 'src/utils/swarm/constants.js' import { It2SetupPrompt } from 'src/utils/swarm/It2SetupPrompt.js' import { getTeamFilePath, readTeamFileAsync, sanitizeAgentName, writeTeamFileAsync, type TeamFile, } from 'src/utils/swarm/teamHelpers.js' import { assignTeammateColor, } from 'src/utils/swarm/teammateLayoutManager.js' import { getHardcodedTeammateModelFallback } from 'src/utils/swarm/teammateModel.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 // ============================================================================ /** * 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 { 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 Handler // ============================================================================ type ResolvedSpawn = { teamName: string teamFile: TeamFile sanitizedName: string teammateId: string model: string teammateColor: ReturnType workingDir: string agentDefinition?: CustomAgentDefinition } async function resolveSpawn( input: SpawnInput, context: ToolUseContext, ): Promise { if (!input.name || !input.prompt) { throw new Error('name and prompt are required for spawn operation') } const appState = context.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 TeamCreate first to establish team context.', ) } const teamFile = await readTeamFileAsync(teamName) if (!teamFile) { throw new Error( `Team "${teamName}" does not exist. Call TeamCreate first to create the team before spawning teammates.`, ) } const uniqueName = await generateUniqueTeammateName(input.name, teamName) const sanitizedName = sanitizeAgentName(uniqueName) const teammateId = formatAgentId(sanitizedName, teamName) const model = resolveTeammateModel(input.model, appState.mainLoopModel) const teammateColor = assignTeammateColor(teammateId) const workingDir = input.cwd || getCwd() let agentDefinition: CustomAgentDefinition | undefined if (input.agent_type) { const foundAgent = context.options.agentDefinitions.activeAgents.find( a => a.agentType === input.agent_type, ) if (foundAgent && isCustomAgent(foundAgent)) { agentDefinition = foundAgent } logForDebugging( `[spawnTeammate] agent_type=${input.agent_type}, found=${!!agentDefinition}`, ) } return { teamName, teamFile, sanitizedName, teammateId, model, teammateColor, workingDir, agentDefinition, } } function getBackendDisplay(result: TeammateSpawnResult): { sessionName: string windowName: string paneId: string isSplitPane: boolean } { if (result.backendType === 'in-process') { return { sessionName: 'in-process', windowName: 'in-process', paneId: 'in-process', isSplitPane: false, } } return { sessionName: result.insideTmux ? 'current' : SWARM_SESSION_NAME, windowName: result.windowName ?? (result.insideTmux ? 'current' : 'swarm-view'), paneId: result.paneId ?? '', isSplitPane: result.isSplitPane ?? true, } } function updateTeamContext( context: ToolUseContext, spawn: ResolvedSpawn, result: TeammateSpawnResult, ): void { const display = getBackendDisplay(result) context.setAppState(prev => { const leadAgentId = prev.teamContext?.leadAgentId || spawn.teamFile.leadAgentId const existingTeammates = prev.teamContext?.teammates || {} const needsLeaderEntry = !(leadAgentId in existingTeammates) const leadMember = spawn.teamFile.members.find(m => m.name === TEAM_LEAD_NAME) return { ...prev, teamContext: { ...prev.teamContext, teamName: spawn.teamName, teamFilePath: prev.teamContext?.teamFilePath || getTeamFilePath(spawn.teamName), leadAgentId, teammates: { ...existingTeammates, ...(needsLeaderEntry ? { [leadAgentId]: { name: TEAM_LEAD_NAME, agentType: leadMember?.agentType ?? TEAM_LEAD_NAME, color: assignTeammateColor(leadAgentId), tmuxSessionName: leadMember?.backendType === 'in-process' ? 'in-process' : '', tmuxPaneId: leadMember?.tmuxPaneId ?? '', cwd: leadMember?.cwd ?? getCwd(), spawnedAt: leadMember?.joinedAt ?? Date.now(), }, } : {}), [spawn.teammateId]: { name: spawn.sanitizedName, agentType: spawn.agentDefinition?.agentType, color: spawn.teammateColor, tmuxSessionName: display.sessionName, tmuxPaneId: display.paneId, cwd: spawn.workingDir, spawnedAt: Date.now(), }, }, }, } }) } async function appendTeamMember( input: SpawnInput, spawn: ResolvedSpawn, result: TeammateSpawnResult, ): Promise { const teamFile = await readTeamFileAsync(spawn.teamName) if (!teamFile) { throw new Error(`Team "${spawn.teamName}" disappeared during teammate spawn.`) } const display = getBackendDisplay(result) teamFile.members.push({ agentId: spawn.teammateId, name: spawn.sanitizedName, agentType: input.agent_type, model: spawn.model, prompt: input.prompt, color: spawn.teammateColor, planModeRequired: input.plan_mode_required, joinedAt: Date.now(), tmuxPaneId: display.paneId, cwd: spawn.workingDir, subscriptions: [], backendType: result.backendType, }) await writeTeamFileAsync(spawn.teamName, teamFile) } async function handleSpawn( input: SpawnInput, context: ToolUseContext, ): Promise<{ data: SpawnOutput }> { const spawn = await resolveSpawn(input, context) const executor = await getTeammateExecutor(true, { onNeedsIt2Setup: context.setToolJSX ? tmuxAvailable => new Promise(resolve => { context.setToolJSX!({ jsx: React.createElement(It2SetupPrompt, { onDone: result => { context.setToolJSX!(null) resolve(result) }, tmuxAvailable, }), shouldHidePromptInput: true, }) }) : undefined, }) executor.setContext?.(context) const result = await executor.spawn({ name: spawn.sanitizedName, teamName: spawn.teamName, color: spawn.teammateColor, prompt: input.prompt, cwd: spawn.workingDir, model: spawn.model, agentType: input.agent_type, agentDefinition: spawn.agentDefinition, description: input.description, planModeRequired: input.plan_mode_required ?? false, parentSessionId: getSessionId(), invokingRequestId: input.invokingRequestId, useSplitPane: input.use_splitpane !== false, }) if (!result.success) { throw new Error(result.error ?? 'Failed to spawn teammate') } updateTeamContext(context, spawn, result) await appendTeamMember(input, spawn, result) const display = getBackendDisplay(result) return { data: { teammate_id: spawn.teammateId, agent_id: spawn.teammateId, agent_type: input.agent_type, model: spawn.model, name: spawn.sanitizedName, color: spawn.teammateColor, tmux_session_name: display.sessionName, tmux_window_name: display.windowName, tmux_pane_id: display.paneId, team_name: spawn.teamName, is_splitpane: display.isSplitPane, plan_mode_required: input.plan_mode_required, }, } } // ============================================================================ // 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) }