/ utils / swarm / backends / TmuxBackend.ts
TmuxBackend.ts
  1  import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js'
  2  import { logForDebugging } from '../../../utils/debug.js'
  3  import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
  4  import { logError } from '../../../utils/log.js'
  5  import { count } from '../../array.js'
  6  import { sleep } from '../../sleep.js'
  7  import {
  8    getSwarmSocketName,
  9    HIDDEN_SESSION_NAME,
 10    SWARM_SESSION_NAME,
 11    SWARM_VIEW_WINDOW_NAME,
 12    TMUX_COMMAND,
 13  } from '../constants.js'
 14  import {
 15    getLeaderPaneId,
 16    isInsideTmux as isInsideTmuxFromDetection,
 17    isTmuxAvailable,
 18  } from './detection.js'
 19  import { registerTmuxBackend } from './registry.js'
 20  import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
 21  
 22  // Track whether the first pane has been used for external swarm session
 23  let firstPaneUsedForExternal = false
 24  
 25  // Cached leader window target (session:window format) to avoid repeated queries
 26  let cachedLeaderWindowTarget: string | null = null
 27  
 28  // Lock mechanism to prevent race conditions when spawning teammates in parallel
 29  let 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
 33  const PANE_SHELL_INIT_DELAY_MS = 200
 34  
 35  function 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   */
 43  function 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   */
 59  function 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   */
 77  function 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   */
 87  function 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   */
104  export 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
764  registerTmuxBackend(TmuxBackend)