import { getSessionId } from '../../../bootstrap/state.js' import type { ToolUseContext } from '../../../Tool.js' import { formatAgentId, parseAgentId } from '../../../utils/agentId.js' import { quote } from '../../../utils/bash/shellQuote.js' import { registerCleanup } from '../../../utils/cleanupRegistry.js' import { logForDebugging } from '../../../utils/debug.js' import { jsonStringify } from '../../../utils/slowOperations.js' import { writeToMailbox } from '../../../utils/teammateMailbox.js' import { buildInheritedCliFlags, buildInheritedEnvVars, getTeammateCommand, } from '../spawnUtils.js' import { assignTeammateColor } from '../teammateLayoutManager.js' import { isInsideTmux } from './detection.js' import type { BackendType, PaneBackend, TeammateExecutor, TeammateMessage, TeammateSpawnConfig, TeammateSpawnResult, } from './types.js' /** * PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface. * * This allows pane-based backends (tmux, iTerm2) to be used through the same * TeammateExecutor abstraction as InProcessBackend, making getTeammateExecutor() * return a meaningful executor regardless of execution mode. * * The adapter handles: * - spawn(): Creates a pane and sends the Claude CLI command to it * - sendMessage(): Writes to the teammate's file-based mailbox * - terminate(): Sends a shutdown request via mailbox * - kill(): Kills the pane via the backend * - isActive(): Checks if the pane is still running */ export class PaneBackendExecutor implements TeammateExecutor { readonly type: BackendType private backend: PaneBackend private context: ToolUseContext | null = null /** * Track spawned teammates by agentId -> paneId mapping. * This allows us to find the pane for operations like kill/terminate. */ private spawnedTeammates: Map private cleanupRegistered = false constructor(backend: PaneBackend) { this.backend = backend this.type = backend.type this.spawnedTeammates = new Map() } /** * Sets the ToolUseContext for this executor. * Must be called before spawn() to provide access to AppState and permissions. */ setContext(context: ToolUseContext): void { this.context = context } /** * Checks if the underlying pane backend is available. */ async isAvailable(): Promise { return this.backend.isAvailable() } /** * Spawns a teammate in a new pane. * * Creates a pane via the backend, builds the CLI command with teammate * identity flags, and sends it to the pane. */ async spawn(config: TeammateSpawnConfig): Promise { const agentId = formatAgentId(config.name, config.teamName) if (!this.context) { logForDebugging( `[PaneBackendExecutor] spawn() called without context for ${config.name}`, ) return { success: false, agentId, error: 'PaneBackendExecutor not initialized. Call setContext() before spawn().', } } try { // Assign a unique color to this teammate const teammateColor = config.color ?? assignTeammateColor(agentId) // Create a pane in the swarm view const { paneId, isFirstTeammate } = await this.backend.createTeammatePaneInSwarmView( config.name, teammateColor, ) // Check if we're inside tmux to determine how to send commands const insideTmux = await isInsideTmux() // Enable pane border status on first teammate when inside tmux if (isFirstTeammate && insideTmux) { await this.backend.enablePaneBorderStatus() } // Build the command to spawn Claude Code with teammate identity const binaryPath = getTeammateCommand() // Build teammate identity CLI args const teammateArgs = [ `--agent-id ${quote([agentId])}`, `--agent-name ${quote([config.name])}`, `--team-name ${quote([config.teamName])}`, `--agent-color ${quote([teammateColor])}`, `--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`, config.planModeRequired ? '--plan-mode-required' : '', ] .filter(Boolean) .join(' ') // Build CLI flags to propagate to teammate const appState = this.context.getAppState() let inheritedFlags = buildInheritedCliFlags({ planModeRequired: config.planModeRequired, permissionMode: appState.toolPermissionContext.mode, }) // If teammate has a custom model, add --model flag (or replace inherited one) if (config.model) { inheritedFlags = inheritedFlags .split(' ') .filter( (flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model', ) .join(' ') inheritedFlags = inheritedFlags ? `${inheritedFlags} --model ${quote([config.model])}` : `--model ${quote([config.model])}` } const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' const workingDir = config.cwd // Build environment variables to forward to teammate 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 this.backend.sendCommandToPane(paneId, spawnCommand, !insideTmux) // Track the spawned teammate this.spawnedTeammates.set(agentId, { paneId, insideTmux }) // Register cleanup to kill all panes on leader exit (e.g., SIGHUP) if (!this.cleanupRegistered) { this.cleanupRegistered = true registerCleanup(async () => { for (const [id, info] of this.spawnedTeammates) { logForDebugging( `[PaneBackendExecutor] Cleanup: killing pane for ${id}`, ) await this.backend.killPane(info.paneId, !info.insideTmux) } this.spawnedTeammates.clear() }) } // Send initial instructions to teammate via mailbox await writeToMailbox( config.name, { from: 'team-lead', text: config.prompt, timestamp: new Date().toISOString(), }, config.teamName, ) logForDebugging( `[PaneBackendExecutor] Spawned teammate ${agentId} in pane ${paneId}`, ) return { success: true, agentId, paneId, } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging( `[PaneBackendExecutor] Failed to spawn ${agentId}: ${errorMessage}`, ) return { success: false, agentId, error: errorMessage, } } } /** * Sends a message to a pane-based teammate via file-based mailbox. * * All teammates (pane and in-process) use the same mailbox mechanism. */ async sendMessage(agentId: string, message: TeammateMessage): Promise { logForDebugging( `[PaneBackendExecutor] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`, ) const parsed = parseAgentId(agentId) if (!parsed) { throw new Error( `Invalid agentId format: ${agentId}. Expected format: agentName@teamName`, ) } const { agentName, teamName } = parsed await writeToMailbox( agentName, { text: message.text, from: message.from, color: message.color, timestamp: message.timestamp ?? new Date().toISOString(), }, teamName, ) logForDebugging( `[PaneBackendExecutor] sendMessage() completed for ${agentId}`, ) } /** * Gracefully terminates a pane-based teammate. * * For pane-based teammates, we send a shutdown request via mailbox and * let the teammate process handle exit gracefully. */ async terminate(agentId: string, reason?: string): Promise { logForDebugging( `[PaneBackendExecutor] terminate() called for ${agentId}: ${reason}`, ) const parsed = parseAgentId(agentId) if (!parsed) { logForDebugging( `[PaneBackendExecutor] terminate() failed: invalid agentId format`, ) return false } const { agentName, teamName } = parsed // Send shutdown request via mailbox const shutdownRequest = { type: 'shutdown_request', requestId: `shutdown-${agentId}-${Date.now()}`, from: 'team-lead', reason, } await writeToMailbox( agentName, { from: 'team-lead', text: jsonStringify(shutdownRequest), timestamp: new Date().toISOString(), }, teamName, ) logForDebugging( `[PaneBackendExecutor] terminate() sent shutdown request to ${agentId}`, ) return true } /** * Force kills a pane-based teammate by killing its pane. */ async kill(agentId: string): Promise { logForDebugging(`[PaneBackendExecutor] kill() called for ${agentId}`) const teammateInfo = this.spawnedTeammates.get(agentId) if (!teammateInfo) { logForDebugging( `[PaneBackendExecutor] kill() failed: teammate ${agentId} not found in spawned map`, ) return false } const { paneId, insideTmux } = teammateInfo // Kill the pane via the backend // Use external session socket when we spawned outside tmux const killed = await this.backend.killPane(paneId, !insideTmux) if (killed) { this.spawnedTeammates.delete(agentId) logForDebugging(`[PaneBackendExecutor] kill() succeeded for ${agentId}`) } else { logForDebugging(`[PaneBackendExecutor] kill() failed for ${agentId}`) } return killed } /** * Checks if a pane-based teammate is still active. * * For pane-based teammates, we check if the pane still exists. * This is a best-effort check - the pane may exist but the process inside * may have exited. */ async isActive(agentId: string): Promise { logForDebugging(`[PaneBackendExecutor] isActive() called for ${agentId}`) const teammateInfo = this.spawnedTeammates.get(agentId) if (!teammateInfo) { logForDebugging( `[PaneBackendExecutor] isActive(): teammate ${agentId} not found`, ) return false } // For now, assume active if we have a record of it // A more robust check would query the backend for pane existence // but that would require adding a new method to PaneBackend return true } } /** * Creates a PaneBackendExecutor wrapping the given PaneBackend. */ export function createPaneBackendExecutor( backend: PaneBackend, ): PaneBackendExecutor { return new PaneBackendExecutor(backend) }