source dump of claude code
at main 354 lines 11 kB view raw
1import { getSessionId } from '../../../bootstrap/state.js' 2import type { ToolUseContext } from '../../../Tool.js' 3import { formatAgentId, parseAgentId } from '../../../utils/agentId.js' 4import { quote } from '../../../utils/bash/shellQuote.js' 5import { registerCleanup } from '../../../utils/cleanupRegistry.js' 6import { logForDebugging } from '../../../utils/debug.js' 7import { jsonStringify } from '../../../utils/slowOperations.js' 8import { writeToMailbox } from '../../../utils/teammateMailbox.js' 9import { 10 buildInheritedCliFlags, 11 buildInheritedEnvVars, 12 getTeammateCommand, 13} from '../spawnUtils.js' 14import { assignTeammateColor } from '../teammateLayoutManager.js' 15import { isInsideTmux } from './detection.js' 16import type { 17 BackendType, 18 PaneBackend, 19 TeammateExecutor, 20 TeammateMessage, 21 TeammateSpawnConfig, 22 TeammateSpawnResult, 23} from './types.js' 24 25/** 26 * PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface. 27 * 28 * This allows pane-based backends (tmux, iTerm2) to be used through the same 29 * TeammateExecutor abstraction as InProcessBackend, making getTeammateExecutor() 30 * return a meaningful executor regardless of execution mode. 31 * 32 * The adapter handles: 33 * - spawn(): Creates a pane and sends the Claude CLI command to it 34 * - sendMessage(): Writes to the teammate's file-based mailbox 35 * - terminate(): Sends a shutdown request via mailbox 36 * - kill(): Kills the pane via the backend 37 * - isActive(): Checks if the pane is still running 38 */ 39export class PaneBackendExecutor implements TeammateExecutor { 40 readonly type: BackendType 41 42 private backend: PaneBackend 43 private context: ToolUseContext | null = null 44 45 /** 46 * Track spawned teammates by agentId -> paneId mapping. 47 * This allows us to find the pane for operations like kill/terminate. 48 */ 49 private spawnedTeammates: Map<string, { paneId: string; insideTmux: boolean }> 50 private cleanupRegistered = false 51 52 constructor(backend: PaneBackend) { 53 this.backend = backend 54 this.type = backend.type 55 this.spawnedTeammates = new Map() 56 } 57 58 /** 59 * Sets the ToolUseContext for this executor. 60 * Must be called before spawn() to provide access to AppState and permissions. 61 */ 62 setContext(context: ToolUseContext): void { 63 this.context = context 64 } 65 66 /** 67 * Checks if the underlying pane backend is available. 68 */ 69 async isAvailable(): Promise<boolean> { 70 return this.backend.isAvailable() 71 } 72 73 /** 74 * Spawns a teammate in a new pane. 75 * 76 * Creates a pane via the backend, builds the CLI command with teammate 77 * identity flags, and sends it to the pane. 78 */ 79 async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> { 80 const agentId = formatAgentId(config.name, config.teamName) 81 82 if (!this.context) { 83 logForDebugging( 84 `[PaneBackendExecutor] spawn() called without context for ${config.name}`, 85 ) 86 return { 87 success: false, 88 agentId, 89 error: 90 'PaneBackendExecutor not initialized. Call setContext() before spawn().', 91 } 92 } 93 94 try { 95 // Assign a unique color to this teammate 96 const teammateColor = config.color ?? assignTeammateColor(agentId) 97 98 // Create a pane in the swarm view 99 const { paneId, isFirstTeammate } = 100 await this.backend.createTeammatePaneInSwarmView( 101 config.name, 102 teammateColor, 103 ) 104 105 // Check if we're inside tmux to determine how to send commands 106 const insideTmux = await isInsideTmux() 107 108 // Enable pane border status on first teammate when inside tmux 109 if (isFirstTeammate && insideTmux) { 110 await this.backend.enablePaneBorderStatus() 111 } 112 113 // Build the command to spawn Claude Code with teammate identity 114 const binaryPath = getTeammateCommand() 115 116 // Build teammate identity CLI args 117 const teammateArgs = [ 118 `--agent-id ${quote([agentId])}`, 119 `--agent-name ${quote([config.name])}`, 120 `--team-name ${quote([config.teamName])}`, 121 `--agent-color ${quote([teammateColor])}`, 122 `--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`, 123 config.planModeRequired ? '--plan-mode-required' : '', 124 ] 125 .filter(Boolean) 126 .join(' ') 127 128 // Build CLI flags to propagate to teammate 129 const appState = this.context.getAppState() 130 let inheritedFlags = buildInheritedCliFlags({ 131 planModeRequired: config.planModeRequired, 132 permissionMode: appState.toolPermissionContext.mode, 133 }) 134 135 // If teammate has a custom model, add --model flag (or replace inherited one) 136 if (config.model) { 137 inheritedFlags = inheritedFlags 138 .split(' ') 139 .filter( 140 (flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model', 141 ) 142 .join(' ') 143 inheritedFlags = inheritedFlags 144 ? `${inheritedFlags} --model ${quote([config.model])}` 145 : `--model ${quote([config.model])}` 146 } 147 148 const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' 149 const workingDir = config.cwd 150 151 // Build environment variables to forward to teammate 152 const envStr = buildInheritedEnvVars() 153 154 const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` 155 156 // Send the command to the new pane 157 // Use swarm socket when running outside tmux (external swarm session) 158 await this.backend.sendCommandToPane(paneId, spawnCommand, !insideTmux) 159 160 // Track the spawned teammate 161 this.spawnedTeammates.set(agentId, { paneId, insideTmux }) 162 163 // Register cleanup to kill all panes on leader exit (e.g., SIGHUP) 164 if (!this.cleanupRegistered) { 165 this.cleanupRegistered = true 166 registerCleanup(async () => { 167 for (const [id, info] of this.spawnedTeammates) { 168 logForDebugging( 169 `[PaneBackendExecutor] Cleanup: killing pane for ${id}`, 170 ) 171 await this.backend.killPane(info.paneId, !info.insideTmux) 172 } 173 this.spawnedTeammates.clear() 174 }) 175 } 176 177 // Send initial instructions to teammate via mailbox 178 await writeToMailbox( 179 config.name, 180 { 181 from: 'team-lead', 182 text: config.prompt, 183 timestamp: new Date().toISOString(), 184 }, 185 config.teamName, 186 ) 187 188 logForDebugging( 189 `[PaneBackendExecutor] Spawned teammate ${agentId} in pane ${paneId}`, 190 ) 191 192 return { 193 success: true, 194 agentId, 195 paneId, 196 } 197 } catch (error) { 198 const errorMessage = 199 error instanceof Error ? error.message : String(error) 200 logForDebugging( 201 `[PaneBackendExecutor] Failed to spawn ${agentId}: ${errorMessage}`, 202 ) 203 return { 204 success: false, 205 agentId, 206 error: errorMessage, 207 } 208 } 209 } 210 211 /** 212 * Sends a message to a pane-based teammate via file-based mailbox. 213 * 214 * All teammates (pane and in-process) use the same mailbox mechanism. 215 */ 216 async sendMessage(agentId: string, message: TeammateMessage): Promise<void> { 217 logForDebugging( 218 `[PaneBackendExecutor] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`, 219 ) 220 221 const parsed = parseAgentId(agentId) 222 if (!parsed) { 223 throw new Error( 224 `Invalid agentId format: ${agentId}. Expected format: agentName@teamName`, 225 ) 226 } 227 228 const { agentName, teamName } = parsed 229 230 await writeToMailbox( 231 agentName, 232 { 233 text: message.text, 234 from: message.from, 235 color: message.color, 236 timestamp: message.timestamp ?? new Date().toISOString(), 237 }, 238 teamName, 239 ) 240 241 logForDebugging( 242 `[PaneBackendExecutor] sendMessage() completed for ${agentId}`, 243 ) 244 } 245 246 /** 247 * Gracefully terminates a pane-based teammate. 248 * 249 * For pane-based teammates, we send a shutdown request via mailbox and 250 * let the teammate process handle exit gracefully. 251 */ 252 async terminate(agentId: string, reason?: string): Promise<boolean> { 253 logForDebugging( 254 `[PaneBackendExecutor] terminate() called for ${agentId}: ${reason}`, 255 ) 256 257 const parsed = parseAgentId(agentId) 258 if (!parsed) { 259 logForDebugging( 260 `[PaneBackendExecutor] terminate() failed: invalid agentId format`, 261 ) 262 return false 263 } 264 265 const { agentName, teamName } = parsed 266 267 // Send shutdown request via mailbox 268 const shutdownRequest = { 269 type: 'shutdown_request', 270 requestId: `shutdown-${agentId}-${Date.now()}`, 271 from: 'team-lead', 272 reason, 273 } 274 275 await writeToMailbox( 276 agentName, 277 { 278 from: 'team-lead', 279 text: jsonStringify(shutdownRequest), 280 timestamp: new Date().toISOString(), 281 }, 282 teamName, 283 ) 284 285 logForDebugging( 286 `[PaneBackendExecutor] terminate() sent shutdown request to ${agentId}`, 287 ) 288 289 return true 290 } 291 292 /** 293 * Force kills a pane-based teammate by killing its pane. 294 */ 295 async kill(agentId: string): Promise<boolean> { 296 logForDebugging(`[PaneBackendExecutor] kill() called for ${agentId}`) 297 298 const teammateInfo = this.spawnedTeammates.get(agentId) 299 if (!teammateInfo) { 300 logForDebugging( 301 `[PaneBackendExecutor] kill() failed: teammate ${agentId} not found in spawned map`, 302 ) 303 return false 304 } 305 306 const { paneId, insideTmux } = teammateInfo 307 308 // Kill the pane via the backend 309 // Use external session socket when we spawned outside tmux 310 const killed = await this.backend.killPane(paneId, !insideTmux) 311 312 if (killed) { 313 this.spawnedTeammates.delete(agentId) 314 logForDebugging(`[PaneBackendExecutor] kill() succeeded for ${agentId}`) 315 } else { 316 logForDebugging(`[PaneBackendExecutor] kill() failed for ${agentId}`) 317 } 318 319 return killed 320 } 321 322 /** 323 * Checks if a pane-based teammate is still active. 324 * 325 * For pane-based teammates, we check if the pane still exists. 326 * This is a best-effort check - the pane may exist but the process inside 327 * may have exited. 328 */ 329 async isActive(agentId: string): Promise<boolean> { 330 logForDebugging(`[PaneBackendExecutor] isActive() called for ${agentId}`) 331 332 const teammateInfo = this.spawnedTeammates.get(agentId) 333 if (!teammateInfo) { 334 logForDebugging( 335 `[PaneBackendExecutor] isActive(): teammate ${agentId} not found`, 336 ) 337 return false 338 } 339 340 // For now, assume active if we have a record of it 341 // A more robust check would query the backend for pane existence 342 // but that would require adding a new method to PaneBackend 343 return true 344 } 345} 346 347/** 348 * Creates a PaneBackendExecutor wrapping the given PaneBackend. 349 */ 350export function createPaneBackendExecutor( 351 backend: PaneBackend, 352): PaneBackendExecutor { 353 return new PaneBackendExecutor(backend) 354}