/ utils / swarm / backends / registry.ts
registry.ts
  1  import { getIsNonInteractiveSession } from '../../../bootstrap/state.js'
  2  import { logForDebugging } from '../../../utils/debug.js'
  3  import { getPlatform } from '../../../utils/platform.js'
  4  import {
  5    isInITerm2,
  6    isInsideTmux,
  7    isInsideTmuxSync,
  8    isIt2CliAvailable,
  9    isTmuxAvailable,
 10  } from './detection.js'
 11  import { createInProcessBackend } from './InProcessBackend.js'
 12  import { getPreferTmuxOverIterm2 } from './it2Setup.js'
 13  import { createPaneBackendExecutor } from './PaneBackendExecutor.js'
 14  import { getTeammateModeFromSnapshot } from './teammateModeSnapshot.js'
 15  import type {
 16    BackendDetectionResult,
 17    PaneBackend,
 18    PaneBackendType,
 19    TeammateExecutor,
 20  } from './types.js'
 21  
 22  /**
 23   * Cached backend detection result.
 24   * Once detected, the backend selection is fixed for the lifetime of the process.
 25   */
 26  let cachedBackend: PaneBackend | null = null
 27  
 28  /**
 29   * Cached detection result with additional metadata.
 30   */
 31  let cachedDetectionResult: BackendDetectionResult | null = null
 32  
 33  /**
 34   * Flag to track if backends have been registered.
 35   */
 36  let backendsRegistered = false
 37  
 38  /**
 39   * Cached in-process backend instance.
 40   */
 41  let cachedInProcessBackend: TeammateExecutor | null = null
 42  
 43  /**
 44   * Cached pane backend executor instance.
 45   * Wraps the detected PaneBackend to provide TeammateExecutor interface.
 46   */
 47  let cachedPaneBackendExecutor: TeammateExecutor | null = null
 48  
 49  /**
 50   * Tracks whether spawn fell back to in-process mode because no pane backend
 51   * was available (e.g., iTerm2 without it2 or tmux installed). Once set,
 52   * isInProcessEnabled() returns true so UI (banner, teams menu) reflects reality.
 53   */
 54  let inProcessFallbackActive = false
 55  
 56  /**
 57   * Placeholder for TmuxBackend - will be replaced with actual implementation.
 58   * This allows the registry to compile before the backend implementations exist.
 59   */
 60  let TmuxBackendClass: (new () => PaneBackend) | null = null
 61  
 62  /**
 63   * Placeholder for ITermBackend - will be replaced with actual implementation.
 64   * This allows the registry to compile before the backend implementations exist.
 65   */
 66  let ITermBackendClass: (new () => PaneBackend) | null = null
 67  
 68  /**
 69   * Ensures backend classes are dynamically imported so getBackendByType() can
 70   * construct them. Unlike detectAndGetBackend(), this never spawns subprocesses
 71   * and never throws — it's the lightweight option when you only need class
 72   * registration (e.g., killing a pane by its stored backendType).
 73   */
 74  export async function ensureBackendsRegistered(): Promise<void> {
 75    if (backendsRegistered) return
 76    await import('./TmuxBackend.js')
 77    await import('./ITermBackend.js')
 78    backendsRegistered = true
 79  }
 80  
 81  /**
 82   * Registers the TmuxBackend class with the registry.
 83   * Called by TmuxBackend.ts to avoid circular dependencies.
 84   */
 85  export function registerTmuxBackend(backendClass: new () => PaneBackend): void {
 86    TmuxBackendClass = backendClass
 87  }
 88  
 89  /**
 90   * Registers the ITermBackend class with the registry.
 91   * Called by ITermBackend.ts to avoid circular dependencies.
 92   */
 93  export function registerITermBackend(
 94    backendClass: new () => PaneBackend,
 95  ): void {
 96    logForDebugging(
 97      `[registry] registerITermBackend called, class=${backendClass?.name || 'undefined'}`,
 98    )
 99    ITermBackendClass = backendClass
100  }
101  
102  /**
103   * Creates a TmuxBackend instance.
104   * Throws if TmuxBackend hasn't been registered.
105   */
106  function createTmuxBackend(): PaneBackend {
107    if (!TmuxBackendClass) {
108      throw new Error(
109        'TmuxBackend not registered. Import TmuxBackend.ts before using the registry.',
110      )
111    }
112    return new TmuxBackendClass()
113  }
114  
115  /**
116   * Creates an ITermBackend instance.
117   * Throws if ITermBackend hasn't been registered.
118   */
119  function createITermBackend(): PaneBackend {
120    if (!ITermBackendClass) {
121      throw new Error(
122        'ITermBackend not registered. Import ITermBackend.ts before using the registry.',
123      )
124    }
125    return new ITermBackendClass()
126  }
127  
128  /**
129   * Detection priority flow:
130   * 1. If inside tmux, always use tmux (even in iTerm2)
131   * 2. If in iTerm2 with it2 available, use iTerm2 backend
132   * 3. If in iTerm2 without it2, return result indicating setup needed
133   * 4. If tmux available, use tmux (creates external session)
134   * 5. Otherwise, throw error with instructions
135   */
136  export async function detectAndGetBackend(): Promise<BackendDetectionResult> {
137    // Ensure backends are registered before detection
138    await ensureBackendsRegistered()
139  
140    // Return cached result if available
141    if (cachedDetectionResult) {
142      logForDebugging(
143        `[BackendRegistry] Using cached backend: ${cachedDetectionResult.backend.type}`,
144      )
145      return cachedDetectionResult
146    }
147  
148    logForDebugging('[BackendRegistry] Starting backend detection...')
149  
150    // Check all environment conditions upfront for logging
151    const insideTmux = await isInsideTmux()
152    const inITerm2 = isInITerm2()
153  
154    logForDebugging(
155      `[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}`,
156    )
157  
158    // Priority 1: If inside tmux, always use tmux
159    if (insideTmux) {
160      logForDebugging(
161        '[BackendRegistry] Selected: tmux (running inside tmux session)',
162      )
163      const backend = createTmuxBackend()
164      cachedBackend = backend
165      cachedDetectionResult = {
166        backend,
167        isNative: true,
168        needsIt2Setup: false,
169      }
170      return cachedDetectionResult
171    }
172  
173    // Priority 2: If in iTerm2, try to use native panes
174    if (inITerm2) {
175      // Check if user previously chose to prefer tmux over iTerm2
176      const preferTmux = getPreferTmuxOverIterm2()
177      if (preferTmux) {
178        logForDebugging(
179          '[BackendRegistry] User prefers tmux over iTerm2, skipping iTerm2 detection',
180        )
181      } else {
182        const it2Available = await isIt2CliAvailable()
183        logForDebugging(
184          `[BackendRegistry] iTerm2 detected, it2 CLI available: ${it2Available}`,
185        )
186  
187        if (it2Available) {
188          logForDebugging(
189            '[BackendRegistry] Selected: iterm2 (native iTerm2 with it2 CLI)',
190          )
191          const backend = createITermBackend()
192          cachedBackend = backend
193          cachedDetectionResult = {
194            backend,
195            isNative: true,
196            needsIt2Setup: false,
197          }
198          return cachedDetectionResult
199        }
200      }
201  
202      // In iTerm2 but it2 not available - check if tmux can be used as fallback
203      const tmuxAvailable = await isTmuxAvailable()
204      logForDebugging(
205        `[BackendRegistry] it2 not available, tmux available: ${tmuxAvailable}`,
206      )
207  
208      if (tmuxAvailable) {
209        logForDebugging(
210          '[BackendRegistry] Selected: tmux (fallback in iTerm2, it2 setup recommended)',
211        )
212        // Return tmux as fallback. Only signal it2 setup if the user hasn't already
213        // chosen to prefer tmux - otherwise they'd be re-prompted on every spawn.
214        const backend = createTmuxBackend()
215        cachedBackend = backend
216        cachedDetectionResult = {
217          backend,
218          isNative: false,
219          needsIt2Setup: !preferTmux,
220        }
221        return cachedDetectionResult
222      }
223  
224      // In iTerm2 with no it2 and no tmux - it2 setup is required
225      logForDebugging(
226        '[BackendRegistry] ERROR: iTerm2 detected but no it2 CLI and no tmux',
227      )
228      throw new Error(
229        'iTerm2 detected but it2 CLI not installed. Install it2 with: pip install it2',
230      )
231    }
232  
233    // Priority 3: Fall back to tmux external session
234    const tmuxAvailable = await isTmuxAvailable()
235    logForDebugging(
236      `[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`,
237    )
238  
239    if (tmuxAvailable) {
240      logForDebugging('[BackendRegistry] Selected: tmux (external session mode)')
241      const backend = createTmuxBackend()
242      cachedBackend = backend
243      cachedDetectionResult = {
244        backend,
245        isNative: false,
246        needsIt2Setup: false,
247      }
248      return cachedDetectionResult
249    }
250  
251    // No backend available - tmux is not installed
252    logForDebugging('[BackendRegistry] ERROR: No pane backend available')
253    throw new Error(getTmuxInstallInstructions())
254  }
255  
256  /**
257   * Returns platform-specific tmux installation instructions.
258   */
259  function getTmuxInstallInstructions(): string {
260    const platform = getPlatform()
261  
262    switch (platform) {
263      case 'macos':
264        return `To use agent swarms, install tmux:
265    brew install tmux
266  Then start a tmux session with: tmux new-session -s claude`
267  
268      case 'linux':
269      case 'wsl':
270        return `To use agent swarms, install tmux:
271    sudo apt install tmux    # Ubuntu/Debian
272    sudo dnf install tmux    # Fedora/RHEL
273  Then start a tmux session with: tmux new-session -s claude`
274  
275      case 'windows':
276        return `To use agent swarms, you need tmux which requires WSL (Windows Subsystem for Linux).
277  Install WSL first, then inside WSL run:
278    sudo apt install tmux
279  Then start a tmux session with: tmux new-session -s claude`
280  
281      default:
282        return `To use agent swarms, install tmux using your system's package manager.
283  Then start a tmux session with: tmux new-session -s claude`
284    }
285  }
286  
287  /**
288   * Gets a backend by explicit type selection.
289   * Useful for testing or when the user has a preference.
290   *
291   * @param type - The backend type to get
292   * @returns The requested backend instance
293   * @throws If the requested backend type is not available
294   */
295  export function getBackendByType(type: PaneBackendType): PaneBackend {
296    switch (type) {
297      case 'tmux':
298        return createTmuxBackend()
299      case 'iterm2':
300        return createITermBackend()
301    }
302  }
303  
304  /**
305   * Gets the currently cached backend, if any.
306   * Returns null if no backend has been detected yet.
307   */
308  export function getCachedBackend(): PaneBackend | null {
309    return cachedBackend
310  }
311  
312  /**
313   * Gets the cached backend detection result, if any.
314   * Returns null if detection hasn't run yet.
315   * Use `isNative` to check if teammates are visible in native panes.
316   */
317  export function getCachedDetectionResult(): BackendDetectionResult | null {
318    return cachedDetectionResult
319  }
320  
321  /**
322   * Records that spawn fell back to in-process mode because no pane backend
323   * was available. After this, isInProcessEnabled() returns true and subsequent
324   * spawns short-circuit to in-process (the environment won't change mid-session).
325   */
326  export function markInProcessFallback(): void {
327    logForDebugging('[BackendRegistry] Marking in-process fallback as active')
328    inProcessFallbackActive = true
329  }
330  
331  /**
332   * Gets the teammate mode for this session.
333   * Returns the session snapshot captured at startup, ignoring runtime config changes.
334   */
335  function getTeammateMode(): 'auto' | 'tmux' | 'in-process' {
336    return getTeammateModeFromSnapshot()
337  }
338  
339  /**
340   * Checks if in-process teammate execution is enabled.
341   *
342   * Logic:
343   * - If teammateMode is 'in-process', always enabled
344   * - If teammateMode is 'tmux', always disabled (use pane backend)
345   * - If teammateMode is 'auto' (default), check environment:
346   *   - If inside tmux, use pane backend (return false)
347   *   - If inside iTerm2, use pane backend (return false) - detectAndGetBackend()
348   *     will pick ITermBackend if it2 is available, or fall back to tmux
349   *   - Otherwise, use in-process (return true)
350   */
351  export function isInProcessEnabled(): boolean {
352    // Force in-process mode for non-interactive sessions (-p mode)
353    // since tmux-based teammates don't make sense without a terminal UI
354    if (getIsNonInteractiveSession()) {
355      logForDebugging(
356        '[BackendRegistry] isInProcessEnabled: true (non-interactive session)',
357      )
358      return true
359    }
360  
361    const mode = getTeammateMode()
362  
363    let enabled: boolean
364    if (mode === 'in-process') {
365      enabled = true
366    } else if (mode === 'tmux') {
367      enabled = false
368    } else {
369      // 'auto' mode - if a prior spawn fell back to in-process because no pane
370      // backend was available, stay in-process (scoped to auto mode only so a
371      // mid-session Settings change to explicit 'tmux' still takes effect).
372      if (inProcessFallbackActive) {
373        logForDebugging(
374          '[BackendRegistry] isInProcessEnabled: true (fallback after pane backend unavailable)',
375        )
376        return true
377      }
378      // Check if a pane backend environment is available
379      // If inside tmux or iTerm2, use pane backend; otherwise use in-process
380      const insideTmux = isInsideTmuxSync()
381      const inITerm2 = isInITerm2()
382      enabled = !insideTmux && !inITerm2
383    }
384  
385    logForDebugging(
386      `[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode}, insideTmux=${isInsideTmuxSync()}, inITerm2=${isInITerm2()})`,
387    )
388    return enabled
389  }
390  
391  /**
392   * Returns the resolved teammate executor mode for this session.
393   * Unlike getTeammateModeFromSnapshot which may return 'auto', this returns
394   * what 'auto' actually resolves to given the current environment.
395   */
396  export function getResolvedTeammateMode(): 'in-process' | 'tmux' {
397    return isInProcessEnabled() ? 'in-process' : 'tmux'
398  }
399  
400  /**
401   * Gets the InProcessBackend instance.
402   * Creates and caches the instance on first call.
403   */
404  export function getInProcessBackend(): TeammateExecutor {
405    if (!cachedInProcessBackend) {
406      cachedInProcessBackend = createInProcessBackend()
407    }
408    return cachedInProcessBackend
409  }
410  
411  /**
412   * Gets a TeammateExecutor for spawning teammates.
413   *
414   * Returns either:
415   * - InProcessBackend when preferInProcess is true and in-process mode is enabled
416   * - PaneBackendExecutor wrapping the detected pane backend otherwise
417   *
418   * This provides a unified TeammateExecutor interface regardless of execution mode,
419   * allowing callers to spawn and manage teammates without knowing the backend details.
420   *
421   * @param preferInProcess - If true and in-process is enabled, returns InProcessBackend.
422   *                          Otherwise returns PaneBackendExecutor.
423   * @returns TeammateExecutor instance
424   */
425  export async function getTeammateExecutor(
426    preferInProcess: boolean = false,
427  ): Promise<TeammateExecutor> {
428    if (preferInProcess && isInProcessEnabled()) {
429      logForDebugging('[BackendRegistry] Using in-process executor')
430      return getInProcessBackend()
431    }
432  
433    // Return pane backend executor
434    logForDebugging('[BackendRegistry] Using pane backend executor')
435    return getPaneBackendExecutor()
436  }
437  
438  /**
439   * Gets the PaneBackendExecutor instance.
440   * Creates and caches the instance on first call, detecting the appropriate pane backend.
441   */
442  async function getPaneBackendExecutor(): Promise<TeammateExecutor> {
443    if (!cachedPaneBackendExecutor) {
444      const detection = await detectAndGetBackend()
445      cachedPaneBackendExecutor = createPaneBackendExecutor(detection.backend)
446      logForDebugging(
447        `[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`,
448      )
449    }
450    return cachedPaneBackendExecutor
451  }
452  
453  /**
454   * Resets the backend detection cache.
455   * Used for testing to allow re-detection.
456   */
457  export function resetBackendDetection(): void {
458    cachedBackend = null
459    cachedDetectionResult = null
460    cachedInProcessBackend = null
461    cachedPaneBackendExecutor = null
462    backendsRegistered = false
463    inProcessFallbackActive = false
464  }