source dump of claude code
at main 370 lines 13 kB view raw
1import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' 2import { logForDebugging } from '../../../utils/debug.js' 3import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' 4import { IT2_COMMAND, isInITerm2, isIt2CliAvailable } from './detection.js' 5import { registerITermBackend } from './registry.js' 6import type { CreatePaneResult, PaneBackend, PaneId } from './types.js' 7 8// Track session IDs for teammates 9const teammateSessionIds: string[] = [] 10 11// Track whether the first pane has been used 12let firstPaneUsed = false 13 14// Lock mechanism to prevent race conditions when spawning teammates in parallel 15let paneCreationLock: Promise<void> = Promise.resolve() 16 17/** 18 * Acquires a lock for pane creation, ensuring sequential execution. 19 * Returns a release function that must be called when done. 20 */ 21function acquirePaneCreationLock(): Promise<() => void> { 22 let release: () => void 23 const newLock = new Promise<void>(resolve => { 24 release = resolve 25 }) 26 27 const previousLock = paneCreationLock 28 paneCreationLock = newLock 29 30 return previousLock.then(() => release!) 31} 32 33/** 34 * Runs an it2 CLI command and returns the result. 35 */ 36function runIt2( 37 args: string[], 38): Promise<{ stdout: string; stderr: string; code: number }> { 39 return execFileNoThrow(IT2_COMMAND, args) 40} 41 42/** 43 * Parses the session ID from `it2 session split` output. 44 * Format: "Created new pane: <session-id>" 45 * 46 * NOTE: This UUID is only valid when splitting from a specific session 47 * using the -s flag. When splitting from the "active" session, the UUID 48 * may not be accessible if the split happened in a different window. 49 */ 50function parseSplitOutput(output: string): string { 51 const match = output.match(/Created new pane:\s*(.+)/) 52 if (match && match[1]) { 53 return match[1].trim() 54 } 55 return '' 56} 57 58/** 59 * Gets the leader's session ID from ITERM_SESSION_ID env var. 60 * Format: "wXtYpZ:UUID" - we extract the UUID part after the colon. 61 * Returns null if not in iTerm2 or env var not set. 62 */ 63function getLeaderSessionId(): string | null { 64 const itermSessionId = process.env.ITERM_SESSION_ID 65 if (!itermSessionId) { 66 return null 67 } 68 const colonIndex = itermSessionId.indexOf(':') 69 if (colonIndex === -1) { 70 return null 71 } 72 return itermSessionId.slice(colonIndex + 1) 73} 74 75/** 76 * ITermBackend implements pane management using iTerm2's native split panes 77 * via the it2 CLI tool. 78 */ 79export class ITermBackend implements PaneBackend { 80 readonly type = 'iterm2' as const 81 readonly displayName = 'iTerm2' 82 readonly supportsHideShow = false 83 84 /** 85 * Checks if iTerm2 backend is available (in iTerm2 with it2 CLI installed). 86 */ 87 async isAvailable(): Promise<boolean> { 88 const inITerm2 = isInITerm2() 89 logForDebugging(`[ITermBackend] isAvailable check: inITerm2=${inITerm2}`) 90 if (!inITerm2) { 91 logForDebugging('[ITermBackend] isAvailable: false (not in iTerm2)') 92 return false 93 } 94 const it2Available = await isIt2CliAvailable() 95 logForDebugging( 96 `[ITermBackend] isAvailable: ${it2Available} (it2 CLI ${it2Available ? 'found' : 'not found'})`, 97 ) 98 return it2Available 99 } 100 101 /** 102 * Checks if we're currently running inside iTerm2. 103 */ 104 async isRunningInside(): Promise<boolean> { 105 const result = isInITerm2() 106 logForDebugging(`[ITermBackend] isRunningInside: ${result}`) 107 return result 108 } 109 110 /** 111 * Creates a new teammate pane in the swarm view. 112 * Uses a lock to prevent race conditions when multiple teammates are spawned in parallel. 113 */ 114 async createTeammatePaneInSwarmView( 115 name: string, 116 color: AgentColorName, 117 ): Promise<CreatePaneResult> { 118 logForDebugging( 119 `[ITermBackend] createTeammatePaneInSwarmView called for ${name} with color ${color}`, 120 ) 121 const releaseLock = await acquirePaneCreationLock() 122 123 try { 124 // Layout: Leader on left, teammates stacked vertically on the right 125 // - First teammate: vertical split (-v) from leader's session 126 // - Subsequent teammates: horizontal split from last teammate's session 127 // 128 // We explicitly target the session to split from using -s flag to ensure 129 // correct layout even if user clicks on different panes. 130 // 131 // At-fault recovery: If a targeted teammate session is dead (user closed 132 // the pane via Cmd+W / X, or process crashed), prune it and retry with 133 // the next-to-last. Cheaper than a proactive 'it2 session list' on every spawn. 134 // Bounded at O(N+1) iterations: each continue shrinks teammateSessionIds by 1; 135 // when empty → firstPaneUsed resets → next iteration has no target → throws. 136 // eslint-disable-next-line no-constant-condition 137 while (true) { 138 const isFirstTeammate = !firstPaneUsed 139 logForDebugging( 140 `[ITermBackend] Creating pane: isFirstTeammate=${isFirstTeammate}, existingPanes=${teammateSessionIds.length}`, 141 ) 142 143 let splitArgs: string[] 144 let targetedTeammateId: string | undefined 145 if (isFirstTeammate) { 146 // Split from leader's session (extracted from ITERM_SESSION_ID env var) 147 const leaderSessionId = getLeaderSessionId() 148 if (leaderSessionId) { 149 splitArgs = ['session', 'split', '-v', '-s', leaderSessionId] 150 logForDebugging( 151 `[ITermBackend] First split from leader session: ${leaderSessionId}`, 152 ) 153 } else { 154 // Fallback to active session if we can't get leader's ID 155 splitArgs = ['session', 'split', '-v'] 156 logForDebugging( 157 '[ITermBackend] First split from active session (no leader ID)', 158 ) 159 } 160 } else { 161 // Split from the last teammate's session to stack vertically 162 targetedTeammateId = teammateSessionIds[teammateSessionIds.length - 1] 163 if (targetedTeammateId) { 164 splitArgs = ['session', 'split', '-s', targetedTeammateId] 165 logForDebugging( 166 `[ITermBackend] Subsequent split from teammate session: ${targetedTeammateId}`, 167 ) 168 } else { 169 // Fallback to active session 170 splitArgs = ['session', 'split'] 171 logForDebugging( 172 '[ITermBackend] Subsequent split from active session (no teammate ID)', 173 ) 174 } 175 } 176 177 const splitResult = await runIt2(splitArgs) 178 179 if (splitResult.code !== 0) { 180 // If we targeted a teammate session, confirm it's actually dead before 181 // pruning — 'session list' distinguishes dead-target from systemic 182 // failure (Python API off, it2 removed, transient socket error). 183 // Pruning on systemic failure would drain all live IDs → state corrupted. 184 if (targetedTeammateId) { 185 const listResult = await runIt2(['session', 'list']) 186 if ( 187 listResult.code === 0 && 188 !listResult.stdout.includes(targetedTeammateId) 189 ) { 190 // Confirmed dead — prune and retry with next-to-last (or leader). 191 logForDebugging( 192 `[ITermBackend] Split failed targeting dead session ${targetedTeammateId}, pruning and retrying: ${splitResult.stderr}`, 193 ) 194 const idx = teammateSessionIds.indexOf(targetedTeammateId) 195 if (idx !== -1) { 196 teammateSessionIds.splice(idx, 1) 197 } 198 if (teammateSessionIds.length === 0) { 199 firstPaneUsed = false 200 } 201 continue 202 } 203 // Target is alive or we can't tell — don't corrupt state, surface the error. 204 } 205 throw new Error( 206 `Failed to create iTerm2 split pane: ${splitResult.stderr}`, 207 ) 208 } 209 210 if (isFirstTeammate) { 211 firstPaneUsed = true 212 } 213 214 // Parse the session ID from split output 215 // This works because we're splitting from a specific session (-s flag), 216 // so the new pane is in the same window and the UUID is valid. 217 const paneId = parseSplitOutput(splitResult.stdout) 218 219 if (!paneId) { 220 throw new Error( 221 `Failed to parse session ID from split output: ${splitResult.stdout}`, 222 ) 223 } 224 logForDebugging( 225 `[ITermBackend] Created teammate pane for ${name}: ${paneId}`, 226 ) 227 228 teammateSessionIds.push(paneId) 229 230 // Set pane color and title 231 // Skip color and title for now - each it2 call is slow (Python process + API) 232 // The pane is functional without these cosmetic features 233 // TODO: Consider batching these or making them async/fire-and-forget 234 235 return { paneId, isFirstTeammate } 236 } 237 } finally { 238 releaseLock() 239 } 240 } 241 242 /** 243 * Sends a command to a specific pane. 244 */ 245 async sendCommandToPane( 246 paneId: PaneId, 247 command: string, 248 _useExternalSession?: boolean, 249 ): Promise<void> { 250 // Use it2 session run to execute command (adds newline automatically) 251 // Always use -s flag to target specific session - this ensures the command 252 // goes to the right pane even if user switches windows 253 const args = paneId 254 ? ['session', 'run', '-s', paneId, command] 255 : ['session', 'run', command] 256 257 const result = await runIt2(args) 258 259 if (result.code !== 0) { 260 throw new Error( 261 `Failed to send command to iTerm2 pane ${paneId}: ${result.stderr}`, 262 ) 263 } 264 } 265 266 /** 267 * No-op for iTerm2 - tab colors would require escape sequences but we skip 268 * them for performance (each it2 call is slow). 269 */ 270 async setPaneBorderColor( 271 _paneId: PaneId, 272 _color: AgentColorName, 273 _useExternalSession?: boolean, 274 ): Promise<void> { 275 // Skip for performance - each it2 call spawns a Python process 276 } 277 278 /** 279 * No-op for iTerm2 - titles would require escape sequences but we skip 280 * them for performance (each it2 call is slow). 281 */ 282 async setPaneTitle( 283 _paneId: PaneId, 284 _name: string, 285 _color: AgentColorName, 286 _useExternalSession?: boolean, 287 ): Promise<void> { 288 // Skip for performance - each it2 call spawns a Python process 289 } 290 291 /** 292 * No-op for iTerm2 - pane titles are shown in tabs automatically. 293 */ 294 async enablePaneBorderStatus( 295 _windowTarget?: string, 296 _useExternalSession?: boolean, 297 ): Promise<void> { 298 // iTerm2 doesn't have the concept of pane border status like tmux 299 // Titles are shown in tabs automatically 300 } 301 302 /** 303 * No-op for iTerm2 - pane balancing is handled automatically. 304 */ 305 async rebalancePanes( 306 _windowTarget: string, 307 _hasLeader: boolean, 308 ): Promise<void> { 309 // iTerm2 handles pane balancing automatically 310 logForDebugging( 311 '[ITermBackend] Pane rebalancing not implemented for iTerm2', 312 ) 313 } 314 315 /** 316 * Kills/closes a specific pane using the it2 CLI. 317 * Also removes the pane from tracked session IDs so subsequent spawns 318 * don't try to split from a dead session. 319 */ 320 async killPane( 321 paneId: PaneId, 322 _useExternalSession?: boolean, 323 ): Promise<boolean> { 324 // -f (force) is required: without it, iTerm2 respects the "Confirm before 325 // closing" preference and either shows a dialog or refuses when the session 326 // still has a running process (the shell always is). tmux kill-pane has no 327 // such prompt, which is why this was only broken for iTerm2. 328 const result = await runIt2(['session', 'close', '-f', '-s', paneId]) 329 // Clean up module state regardless of close result — even if the pane is 330 // already gone (e.g., user closed it manually), removing the stale ID is correct. 331 const idx = teammateSessionIds.indexOf(paneId) 332 if (idx !== -1) { 333 teammateSessionIds.splice(idx, 1) 334 } 335 if (teammateSessionIds.length === 0) { 336 firstPaneUsed = false 337 } 338 return result.code === 0 339 } 340 341 /** 342 * Stub for hiding a pane - not supported in iTerm2 backend. 343 * iTerm2 doesn't have a direct equivalent to tmux's break-pane. 344 */ 345 async hidePane( 346 _paneId: PaneId, 347 _useExternalSession?: boolean, 348 ): Promise<boolean> { 349 logForDebugging('[ITermBackend] hidePane not supported in iTerm2') 350 return false 351 } 352 353 /** 354 * Stub for showing a hidden pane - not supported in iTerm2 backend. 355 * iTerm2 doesn't have a direct equivalent to tmux's join-pane. 356 */ 357 async showPane( 358 _paneId: PaneId, 359 _targetWindowOrPane: string, 360 _useExternalSession?: boolean, 361 ): Promise<boolean> { 362 logForDebugging('[ITermBackend] showPane not supported in iTerm2') 363 return false 364 } 365} 366 367// Register the backend with the registry when this module is imported. 368// This side effect is intentional - the registry needs backends to self-register to avoid circular dependencies. 369// eslint-disable-next-line custom-rules/no-top-level-side-effects 370registerITermBackend(ITermBackend)