source dump of claude code
at main 764 lines 22 kB view raw
1import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' 2import { logForDebugging } from '../../../utils/debug.js' 3import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' 4import { logError } from '../../../utils/log.js' 5import { count } from '../../array.js' 6import { sleep } from '../../sleep.js' 7import { 8 getSwarmSocketName, 9 HIDDEN_SESSION_NAME, 10 SWARM_SESSION_NAME, 11 SWARM_VIEW_WINDOW_NAME, 12 TMUX_COMMAND, 13} from '../constants.js' 14import { 15 getLeaderPaneId, 16 isInsideTmux as isInsideTmuxFromDetection, 17 isTmuxAvailable, 18} from './detection.js' 19import { registerTmuxBackend } from './registry.js' 20import type { CreatePaneResult, PaneBackend, PaneId } from './types.js' 21 22// Track whether the first pane has been used for external swarm session 23let firstPaneUsedForExternal = false 24 25// Cached leader window target (session:window format) to avoid repeated queries 26let cachedLeaderWindowTarget: string | null = null 27 28// Lock mechanism to prevent race conditions when spawning teammates in parallel 29let paneCreationLock: Promise<void> = Promise.resolve() 30 31// Delay after pane creation to allow shell initialization (loading rc files, prompts, etc.) 32// 200ms is enough for most shell configurations including slow ones like starship/oh-my-zsh 33const PANE_SHELL_INIT_DELAY_MS = 200 34 35function waitForPaneShellReady(): Promise<void> { 36 return sleep(PANE_SHELL_INIT_DELAY_MS) 37} 38 39/** 40 * Acquires a lock for pane creation, ensuring sequential execution. 41 * Returns a release function that must be called when done. 42 */ 43function acquirePaneCreationLock(): Promise<() => void> { 44 let release: () => void 45 const newLock = new Promise<void>(resolve => { 46 release = resolve 47 }) 48 49 const previousLock = paneCreationLock 50 paneCreationLock = newLock 51 52 return previousLock.then(() => release!) 53} 54 55/** 56 * Gets the tmux color name for a given agent color. 57 * These are tmux's built-in color names that work with pane-border-style. 58 */ 59function getTmuxColorName(color: AgentColorName): string { 60 const tmuxColors: Record<AgentColorName, string> = { 61 red: 'red', 62 blue: 'blue', 63 green: 'green', 64 yellow: 'yellow', 65 purple: 'magenta', 66 orange: 'colour208', 67 pink: 'colour205', 68 cyan: 'cyan', 69 } 70 return tmuxColors[color] 71} 72 73/** 74 * Runs a tmux command in the user's original tmux session (no socket override). 75 * Use this for operations that interact with the user's tmux panes (split-pane with leader). 76 */ 77function runTmuxInUserSession( 78 args: string[], 79): Promise<{ stdout: string; stderr: string; code: number }> { 80 return execFileNoThrow(TMUX_COMMAND, args) 81} 82 83/** 84 * Runs a tmux command in the external swarm socket. 85 * Use this for operations in the standalone swarm session (when user is not in tmux). 86 */ 87function runTmuxInSwarm( 88 args: string[], 89): Promise<{ stdout: string; stderr: string; code: number }> { 90 return execFileNoThrow(TMUX_COMMAND, ['-L', getSwarmSocketName(), ...args]) 91} 92 93/** 94 * TmuxBackend implements PaneBackend using tmux for pane management. 95 * 96 * When running INSIDE tmux (leader is in tmux): 97 * - Splits the current window to add teammates alongside the leader 98 * - Leader stays on left (30%), teammates on right (70%) 99 * 100 * When running OUTSIDE tmux (leader is in regular terminal): 101 * - Creates a claude-swarm session with a swarm-view window 102 * - All teammates are equally distributed (no leader pane) 103 */ 104export class TmuxBackend implements PaneBackend { 105 readonly type = 'tmux' as const 106 readonly displayName = 'tmux' 107 readonly supportsHideShow = true 108 109 /** 110 * Checks if tmux is installed and available. 111 * Delegates to detection.ts for consistent detection logic. 112 */ 113 async isAvailable(): Promise<boolean> { 114 return isTmuxAvailable() 115 } 116 117 /** 118 * Checks if we're currently running inside a tmux session. 119 * Delegates to detection.ts for consistent detection logic. 120 */ 121 async isRunningInside(): Promise<boolean> { 122 return isInsideTmuxFromDetection() 123 } 124 125 /** 126 * Creates a new teammate pane in the swarm view. 127 * Uses a lock to prevent race conditions when multiple teammates are spawned in parallel. 128 */ 129 async createTeammatePaneInSwarmView( 130 name: string, 131 color: AgentColorName, 132 ): Promise<CreatePaneResult> { 133 const releaseLock = await acquirePaneCreationLock() 134 135 try { 136 const insideTmux = await this.isRunningInside() 137 138 if (insideTmux) { 139 return await this.createTeammatePaneWithLeader(name, color) 140 } 141 142 return await this.createTeammatePaneExternal(name, color) 143 } finally { 144 releaseLock() 145 } 146 } 147 148 /** 149 * Sends a command to a specific pane. 150 */ 151 async sendCommandToPane( 152 paneId: PaneId, 153 command: string, 154 useExternalSession = false, 155 ): Promise<void> { 156 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 157 const result = await runTmux(['send-keys', '-t', paneId, command, 'Enter']) 158 159 if (result.code !== 0) { 160 throw new Error( 161 `Failed to send command to pane ${paneId}: ${result.stderr}`, 162 ) 163 } 164 } 165 166 /** 167 * Sets the border color for a specific pane. 168 */ 169 async setPaneBorderColor( 170 paneId: PaneId, 171 color: AgentColorName, 172 useExternalSession = false, 173 ): Promise<void> { 174 const tmuxColor = getTmuxColorName(color) 175 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 176 177 // Set pane-specific border style using pane options (requires tmux 3.2+) 178 await runTmux([ 179 'select-pane', 180 '-t', 181 paneId, 182 '-P', 183 `bg=default,fg=${tmuxColor}`, 184 ]) 185 186 await runTmux([ 187 'set-option', 188 '-p', 189 '-t', 190 paneId, 191 'pane-border-style', 192 `fg=${tmuxColor}`, 193 ]) 194 195 await runTmux([ 196 'set-option', 197 '-p', 198 '-t', 199 paneId, 200 'pane-active-border-style', 201 `fg=${tmuxColor}`, 202 ]) 203 } 204 205 /** 206 * Sets the title for a pane (shown in pane border if pane-border-status is set). 207 */ 208 async setPaneTitle( 209 paneId: PaneId, 210 name: string, 211 color: AgentColorName, 212 useExternalSession = false, 213 ): Promise<void> { 214 const tmuxColor = getTmuxColorName(color) 215 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 216 217 // Set the pane title 218 await runTmux(['select-pane', '-t', paneId, '-T', name]) 219 220 // Enable pane border status with colored format 221 await runTmux([ 222 'set-option', 223 '-p', 224 '-t', 225 paneId, 226 'pane-border-format', 227 `#[fg=${tmuxColor},bold] #{pane_title} #[default]`, 228 ]) 229 } 230 231 /** 232 * Enables pane border status for a window (shows pane titles). 233 */ 234 async enablePaneBorderStatus( 235 windowTarget?: string, 236 useExternalSession = false, 237 ): Promise<void> { 238 const target = windowTarget || (await this.getCurrentWindowTarget()) 239 if (!target) { 240 return 241 } 242 243 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 244 await runTmux([ 245 'set-option', 246 '-w', 247 '-t', 248 target, 249 'pane-border-status', 250 'top', 251 ]) 252 } 253 254 /** 255 * Rebalances panes to achieve the desired layout. 256 */ 257 async rebalancePanes( 258 windowTarget: string, 259 hasLeader: boolean, 260 ): Promise<void> { 261 if (hasLeader) { 262 await this.rebalancePanesWithLeader(windowTarget) 263 } else { 264 await this.rebalancePanesTiled(windowTarget) 265 } 266 } 267 268 /** 269 * Kills/closes a specific pane. 270 */ 271 async killPane(paneId: PaneId, useExternalSession = false): Promise<boolean> { 272 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 273 const result = await runTmux(['kill-pane', '-t', paneId]) 274 return result.code === 0 275 } 276 277 /** 278 * Hides a pane by moving it to a detached hidden session. 279 * Creates the hidden session if it doesn't exist, then uses break-pane to move the pane there. 280 */ 281 async hidePane(paneId: PaneId, useExternalSession = false): Promise<boolean> { 282 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 283 284 // Create hidden session if it doesn't exist (detached, not visible) 285 await runTmux(['new-session', '-d', '-s', HIDDEN_SESSION_NAME]) 286 287 // Move the pane to the hidden session 288 const result = await runTmux([ 289 'break-pane', 290 '-d', 291 '-s', 292 paneId, 293 '-t', 294 `${HIDDEN_SESSION_NAME}:`, 295 ]) 296 297 if (result.code === 0) { 298 logForDebugging(`[TmuxBackend] Hidden pane ${paneId}`) 299 } else { 300 logForDebugging( 301 `[TmuxBackend] Failed to hide pane ${paneId}: ${result.stderr}`, 302 ) 303 } 304 305 return result.code === 0 306 } 307 308 /** 309 * Shows a previously hidden pane by joining it back into the target window. 310 * Uses `tmux join-pane` to move the pane back, then reapplies main-vertical layout 311 * with leader at 30%. 312 */ 313 async showPane( 314 paneId: PaneId, 315 targetWindowOrPane: string, 316 useExternalSession = false, 317 ): Promise<boolean> { 318 const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 319 320 // join-pane -s: source pane to move 321 // -t: target window/pane to join into 322 // -h: join horizontally (side by side) 323 const result = await runTmux([ 324 'join-pane', 325 '-h', 326 '-s', 327 paneId, 328 '-t', 329 targetWindowOrPane, 330 ]) 331 332 if (result.code !== 0) { 333 logForDebugging( 334 `[TmuxBackend] Failed to show pane ${paneId}: ${result.stderr}`, 335 ) 336 return false 337 } 338 339 logForDebugging( 340 `[TmuxBackend] Showed pane ${paneId} in ${targetWindowOrPane}`, 341 ) 342 343 // Reapply main-vertical layout with leader at 30% 344 await runTmux(['select-layout', '-t', targetWindowOrPane, 'main-vertical']) 345 346 // Get the first pane (leader) and resize to 30% 347 const panesResult = await runTmux([ 348 'list-panes', 349 '-t', 350 targetWindowOrPane, 351 '-F', 352 '#{pane_id}', 353 ]) 354 355 const panes = panesResult.stdout.trim().split('\n').filter(Boolean) 356 if (panes[0]) { 357 await runTmux(['resize-pane', '-t', panes[0], '-x', '30%']) 358 } 359 360 return true 361 } 362 363 // Private helper methods 364 365 /** 366 * Gets the leader's pane ID. 367 * Uses the TMUX_PANE env var captured at module load to ensure we always 368 * get the leader's original pane, even if the user has switched panes. 369 */ 370 private async getCurrentPaneId(): Promise<string | null> { 371 // Use the pane ID captured at startup (from TMUX_PANE env var) 372 const leaderPane = getLeaderPaneId() 373 if (leaderPane) { 374 return leaderPane 375 } 376 377 // Fallback to dynamic query (shouldn't happen if we're inside tmux) 378 const result = await execFileNoThrow(TMUX_COMMAND, [ 379 'display-message', 380 '-p', 381 '#{pane_id}', 382 ]) 383 384 if (result.code !== 0) { 385 logForDebugging( 386 `[TmuxBackend] Failed to get current pane ID (exit ${result.code}): ${result.stderr}`, 387 ) 388 return null 389 } 390 391 return result.stdout.trim() 392 } 393 394 /** 395 * Gets the leader's window target (session:window format). 396 * Uses the leader's pane ID to query for its window, ensuring we get the 397 * correct window even if the user has switched to a different window. 398 * Caches the result since the leader's window won't change. 399 */ 400 private async getCurrentWindowTarget(): Promise<string | null> { 401 // Return cached value if available 402 if (cachedLeaderWindowTarget) { 403 return cachedLeaderWindowTarget 404 } 405 406 // Build the command - use -t to target the leader's pane specifically 407 const leaderPane = getLeaderPaneId() 408 const args = ['display-message'] 409 if (leaderPane) { 410 args.push('-t', leaderPane) 411 } 412 args.push('-p', '#{session_name}:#{window_index}') 413 414 const result = await execFileNoThrow(TMUX_COMMAND, args) 415 416 if (result.code !== 0) { 417 logForDebugging( 418 `[TmuxBackend] Failed to get current window target (exit ${result.code}): ${result.stderr}`, 419 ) 420 return null 421 } 422 423 cachedLeaderWindowTarget = result.stdout.trim() 424 return cachedLeaderWindowTarget 425 } 426 427 /** 428 * Gets the number of panes in a window. 429 */ 430 private async getCurrentWindowPaneCount( 431 windowTarget?: string, 432 useSwarmSocket = false, 433 ): Promise<number | null> { 434 const target = windowTarget || (await this.getCurrentWindowTarget()) 435 if (!target) { 436 return null 437 } 438 439 const args = ['list-panes', '-t', target, '-F', '#{pane_id}'] 440 const result = useSwarmSocket 441 ? await runTmuxInSwarm(args) 442 : await runTmuxInUserSession(args) 443 444 if (result.code !== 0) { 445 logError( 446 new Error( 447 `[TmuxBackend] Failed to get pane count for ${target} (exit ${result.code}): ${result.stderr}`, 448 ), 449 ) 450 return null 451 } 452 453 return count(result.stdout.trim().split('\n'), Boolean) 454 } 455 456 /** 457 * Checks if a tmux session exists in the swarm socket. 458 */ 459 private async hasSessionInSwarm(sessionName: string): Promise<boolean> { 460 const result = await runTmuxInSwarm(['has-session', '-t', sessionName]) 461 return result.code === 0 462 } 463 464 /** 465 * Creates the swarm session with a single window for teammates when running outside tmux. 466 */ 467 private async createExternalSwarmSession(): Promise<{ 468 windowTarget: string 469 paneId: string 470 }> { 471 const sessionExists = await this.hasSessionInSwarm(SWARM_SESSION_NAME) 472 473 if (!sessionExists) { 474 const result = await runTmuxInSwarm([ 475 'new-session', 476 '-d', 477 '-s', 478 SWARM_SESSION_NAME, 479 '-n', 480 SWARM_VIEW_WINDOW_NAME, 481 '-P', 482 '-F', 483 '#{pane_id}', 484 ]) 485 486 if (result.code !== 0) { 487 throw new Error( 488 `Failed to create swarm session: ${result.stderr || 'Unknown error'}`, 489 ) 490 } 491 492 const paneId = result.stdout.trim() 493 const windowTarget = `${SWARM_SESSION_NAME}:${SWARM_VIEW_WINDOW_NAME}` 494 495 logForDebugging( 496 `[TmuxBackend] Created external swarm session with window ${windowTarget}, pane ${paneId}`, 497 ) 498 499 return { windowTarget, paneId } 500 } 501 502 // Session exists, check if swarm-view window exists 503 const listResult = await runTmuxInSwarm([ 504 'list-windows', 505 '-t', 506 SWARM_SESSION_NAME, 507 '-F', 508 '#{window_name}', 509 ]) 510 511 const windows = listResult.stdout.trim().split('\n').filter(Boolean) 512 const windowTarget = `${SWARM_SESSION_NAME}:${SWARM_VIEW_WINDOW_NAME}` 513 514 if (windows.includes(SWARM_VIEW_WINDOW_NAME)) { 515 const paneResult = await runTmuxInSwarm([ 516 'list-panes', 517 '-t', 518 windowTarget, 519 '-F', 520 '#{pane_id}', 521 ]) 522 523 const panes = paneResult.stdout.trim().split('\n').filter(Boolean) 524 return { windowTarget, paneId: panes[0] || '' } 525 } 526 527 // Create the swarm-view window 528 const createResult = await runTmuxInSwarm([ 529 'new-window', 530 '-t', 531 SWARM_SESSION_NAME, 532 '-n', 533 SWARM_VIEW_WINDOW_NAME, 534 '-P', 535 '-F', 536 '#{pane_id}', 537 ]) 538 539 if (createResult.code !== 0) { 540 throw new Error( 541 `Failed to create swarm-view window: ${createResult.stderr || 'Unknown error'}`, 542 ) 543 } 544 545 return { windowTarget, paneId: createResult.stdout.trim() } 546 } 547 548 /** 549 * Creates a teammate pane when running inside tmux (with leader). 550 */ 551 private async createTeammatePaneWithLeader( 552 teammateName: string, 553 teammateColor: AgentColorName, 554 ): Promise<CreatePaneResult> { 555 const currentPaneId = await this.getCurrentPaneId() 556 const windowTarget = await this.getCurrentWindowTarget() 557 558 if (!currentPaneId || !windowTarget) { 559 throw new Error('Could not determine current tmux pane/window') 560 } 561 562 const paneCount = await this.getCurrentWindowPaneCount(windowTarget) 563 if (paneCount === null) { 564 throw new Error('Could not determine pane count for current window') 565 } 566 const isFirstTeammate = paneCount === 1 567 568 let splitResult 569 if (isFirstTeammate) { 570 // First teammate: split horizontally from the leader pane 571 splitResult = await execFileNoThrow(TMUX_COMMAND, [ 572 'split-window', 573 '-t', 574 currentPaneId, 575 '-h', 576 '-l', 577 '70%', 578 '-P', 579 '-F', 580 '#{pane_id}', 581 ]) 582 } else { 583 // Additional teammates: split from an existing teammate pane 584 const listResult = await execFileNoThrow(TMUX_COMMAND, [ 585 'list-panes', 586 '-t', 587 windowTarget, 588 '-F', 589 '#{pane_id}', 590 ]) 591 592 const panes = listResult.stdout.trim().split('\n').filter(Boolean) 593 const teammatePanes = panes.slice(1) 594 const teammateCount = teammatePanes.length 595 596 const splitVertically = teammateCount % 2 === 1 597 const targetPaneIndex = Math.floor((teammateCount - 1) / 2) 598 const targetPane = 599 teammatePanes[targetPaneIndex] || 600 teammatePanes[teammatePanes.length - 1] 601 602 splitResult = await execFileNoThrow(TMUX_COMMAND, [ 603 'split-window', 604 '-t', 605 targetPane!, 606 splitVertically ? '-v' : '-h', 607 '-P', 608 '-F', 609 '#{pane_id}', 610 ]) 611 } 612 613 if (splitResult.code !== 0) { 614 throw new Error(`Failed to create teammate pane: ${splitResult.stderr}`) 615 } 616 617 const paneId = splitResult.stdout.trim() 618 logForDebugging( 619 `[TmuxBackend] Created teammate pane for ${teammateName}: ${paneId}`, 620 ) 621 622 await this.setPaneBorderColor(paneId, teammateColor) 623 await this.setPaneTitle(paneId, teammateName, teammateColor) 624 await this.rebalancePanesWithLeader(windowTarget) 625 626 // Wait for shell to initialize before returning, so commands can be sent immediately 627 await waitForPaneShellReady() 628 629 return { paneId, isFirstTeammate } 630 } 631 632 /** 633 * Creates a teammate pane when running outside tmux (no leader in tmux). 634 */ 635 private async createTeammatePaneExternal( 636 teammateName: string, 637 teammateColor: AgentColorName, 638 ): Promise<CreatePaneResult> { 639 const { windowTarget, paneId: firstPaneId } = 640 await this.createExternalSwarmSession() 641 642 const paneCount = await this.getCurrentWindowPaneCount(windowTarget, true) 643 if (paneCount === null) { 644 throw new Error('Could not determine pane count for swarm window') 645 } 646 const isFirstTeammate = !firstPaneUsedForExternal && paneCount === 1 647 648 let paneId: string 649 650 if (isFirstTeammate) { 651 paneId = firstPaneId 652 firstPaneUsedForExternal = true 653 logForDebugging( 654 `[TmuxBackend] Using initial pane for first teammate ${teammateName}: ${paneId}`, 655 ) 656 657 await this.enablePaneBorderStatus(windowTarget, true) 658 } else { 659 const listResult = await runTmuxInSwarm([ 660 'list-panes', 661 '-t', 662 windowTarget, 663 '-F', 664 '#{pane_id}', 665 ]) 666 667 const panes = listResult.stdout.trim().split('\n').filter(Boolean) 668 const teammateCount = panes.length 669 670 const splitVertically = teammateCount % 2 === 1 671 const targetPaneIndex = Math.floor((teammateCount - 1) / 2) 672 const targetPane = panes[targetPaneIndex] || panes[panes.length - 1] 673 674 const splitResult = await runTmuxInSwarm([ 675 'split-window', 676 '-t', 677 targetPane!, 678 splitVertically ? '-v' : '-h', 679 '-P', 680 '-F', 681 '#{pane_id}', 682 ]) 683 684 if (splitResult.code !== 0) { 685 throw new Error(`Failed to create teammate pane: ${splitResult.stderr}`) 686 } 687 688 paneId = splitResult.stdout.trim() 689 logForDebugging( 690 `[TmuxBackend] Created teammate pane for ${teammateName}: ${paneId}`, 691 ) 692 } 693 694 await this.setPaneBorderColor(paneId, teammateColor, true) 695 await this.setPaneTitle(paneId, teammateName, teammateColor, true) 696 await this.rebalancePanesTiled(windowTarget) 697 698 // Wait for shell to initialize before returning, so commands can be sent immediately 699 await waitForPaneShellReady() 700 701 return { paneId, isFirstTeammate } 702 } 703 704 /** 705 * Rebalances panes in a window with a leader. 706 */ 707 private async rebalancePanesWithLeader(windowTarget: string): Promise<void> { 708 const listResult = await runTmuxInUserSession([ 709 'list-panes', 710 '-t', 711 windowTarget, 712 '-F', 713 '#{pane_id}', 714 ]) 715 716 const panes = listResult.stdout.trim().split('\n').filter(Boolean) 717 if (panes.length <= 2) { 718 return 719 } 720 721 await runTmuxInUserSession([ 722 'select-layout', 723 '-t', 724 windowTarget, 725 'main-vertical', 726 ]) 727 728 const leaderPane = panes[0] 729 await runTmuxInUserSession(['resize-pane', '-t', leaderPane!, '-x', '30%']) 730 731 logForDebugging( 732 `[TmuxBackend] Rebalanced ${panes.length - 1} teammate panes with leader`, 733 ) 734 } 735 736 /** 737 * Rebalances panes in a window without a leader (tiled layout). 738 */ 739 private async rebalancePanesTiled(windowTarget: string): Promise<void> { 740 const listResult = await runTmuxInSwarm([ 741 'list-panes', 742 '-t', 743 windowTarget, 744 '-F', 745 '#{pane_id}', 746 ]) 747 748 const panes = listResult.stdout.trim().split('\n').filter(Boolean) 749 if (panes.length <= 1) { 750 return 751 } 752 753 await runTmuxInSwarm(['select-layout', '-t', windowTarget, 'tiled']) 754 755 logForDebugging( 756 `[TmuxBackend] Rebalanced ${panes.length} teammate panes with tiled layout`, 757 ) 758 } 759} 760 761// Register the backend with the registry when this module is imported. 762// This side effect is intentional - the registry needs backends to self-register to avoid circular dependencies. 763// eslint-disable-next-line custom-rules/no-top-level-side-effects 764registerTmuxBackend(TmuxBackend)