/ utils / swarm / backends / ITermBackend.ts
ITermBackend.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 { IT2_COMMAND, isInITerm2, isIt2CliAvailable } from './detection.js'
  5  import { registerITermBackend } from './registry.js'
  6  import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
  7  
  8  // Track session IDs for teammates
  9  const teammateSessionIds: string[] = []
 10  
 11  // Track whether the first pane has been used
 12  let firstPaneUsed = false
 13  
 14  // Lock mechanism to prevent race conditions when spawning teammates in parallel
 15  let 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   */
 21  function 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   */
 36  function 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   */
 50  function 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   */
 63  function 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   */
 79  export 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
370  registerITermBackend(ITermBackend)