/ utils / swarm / backends / PaneBackendExecutor.ts
PaneBackendExecutor.ts
  1  import { getSessionId } from '../../../bootstrap/state.js'
  2  import type { ToolUseContext } from '../../../Tool.js'
  3  import { formatAgentId, parseAgentId } from '../../../utils/agentId.js'
  4  import { quote } from '../../../utils/bash/shellQuote.js'
  5  import { registerCleanup } from '../../../utils/cleanupRegistry.js'
  6  import { logForDebugging } from '../../../utils/debug.js'
  7  import { jsonStringify } from '../../../utils/slowOperations.js'
  8  import { writeToMailbox } from '../../../utils/teammateMailbox.js'
  9  import {
 10    buildInheritedCliFlags,
 11    buildInheritedEnvVars,
 12    getTeammateCommand,
 13  } from '../spawnUtils.js'
 14  import { assignTeammateColor } from '../teammateLayoutManager.js'
 15  import { isInsideTmux } from './detection.js'
 16  import type {
 17    BackendType,
 18    PaneBackend,
 19    TeammateExecutor,
 20    TeammateMessage,
 21    TeammateSpawnConfig,
 22    TeammateSpawnResult,
 23  } from './types.js'
 24  
 25  /**
 26   * PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface.
 27   *
 28   * This allows pane-based backends (tmux, iTerm2) to be used through the same
 29   * TeammateExecutor abstraction as InProcessBackend, making getTeammateExecutor()
 30   * return a meaningful executor regardless of execution mode.
 31   *
 32   * The adapter handles:
 33   * - spawn(): Creates a pane and sends the Claude CLI command to it
 34   * - sendMessage(): Writes to the teammate's file-based mailbox
 35   * - terminate(): Sends a shutdown request via mailbox
 36   * - kill(): Kills the pane via the backend
 37   * - isActive(): Checks if the pane is still running
 38   */
 39  export class PaneBackendExecutor implements TeammateExecutor {
 40    readonly type: BackendType
 41  
 42    private backend: PaneBackend
 43    private context: ToolUseContext | null = null
 44  
 45    /**
 46     * Track spawned teammates by agentId -> paneId mapping.
 47     * This allows us to find the pane for operations like kill/terminate.
 48     */
 49    private spawnedTeammates: Map<string, { paneId: string; insideTmux: boolean }>
 50    private cleanupRegistered = false
 51  
 52    constructor(backend: PaneBackend) {
 53      this.backend = backend
 54      this.type = backend.type
 55      this.spawnedTeammates = new Map()
 56    }
 57  
 58    /**
 59     * Sets the ToolUseContext for this executor.
 60     * Must be called before spawn() to provide access to AppState and permissions.
 61     */
 62    setContext(context: ToolUseContext): void {
 63      this.context = context
 64    }
 65  
 66    /**
 67     * Checks if the underlying pane backend is available.
 68     */
 69    async isAvailable(): Promise<boolean> {
 70      return this.backend.isAvailable()
 71    }
 72  
 73    /**
 74     * Spawns a teammate in a new pane.
 75     *
 76     * Creates a pane via the backend, builds the CLI command with teammate
 77     * identity flags, and sends it to the pane.
 78     */
 79    async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> {
 80      const agentId = formatAgentId(config.name, config.teamName)
 81  
 82      if (!this.context) {
 83        logForDebugging(
 84          `[PaneBackendExecutor] spawn() called without context for ${config.name}`,
 85        )
 86        return {
 87          success: false,
 88          agentId,
 89          error:
 90            'PaneBackendExecutor not initialized. Call setContext() before spawn().',
 91        }
 92      }
 93  
 94      try {
 95        // Assign a unique color to this teammate
 96        const teammateColor = config.color ?? assignTeammateColor(agentId)
 97  
 98        // Create a pane in the swarm view
 99        const { paneId, isFirstTeammate } =
100          await this.backend.createTeammatePaneInSwarmView(
101            config.name,
102            teammateColor,
103          )
104  
105        // Check if we're inside tmux to determine how to send commands
106        const insideTmux = await isInsideTmux()
107  
108        // Enable pane border status on first teammate when inside tmux
109        if (isFirstTeammate && insideTmux) {
110          await this.backend.enablePaneBorderStatus()
111        }
112  
113        // Build the command to spawn Claude Code with teammate identity
114        const binaryPath = getTeammateCommand()
115  
116        // Build teammate identity CLI args
117        const teammateArgs = [
118          `--agent-id ${quote([agentId])}`,
119          `--agent-name ${quote([config.name])}`,
120          `--team-name ${quote([config.teamName])}`,
121          `--agent-color ${quote([teammateColor])}`,
122          `--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`,
123          config.planModeRequired ? '--plan-mode-required' : '',
124        ]
125          .filter(Boolean)
126          .join(' ')
127  
128        // Build CLI flags to propagate to teammate
129        const appState = this.context.getAppState()
130        let inheritedFlags = buildInheritedCliFlags({
131          planModeRequired: config.planModeRequired,
132          permissionMode: appState.toolPermissionContext.mode,
133        })
134  
135        // If teammate has a custom model, add --model flag (or replace inherited one)
136        if (config.model) {
137          inheritedFlags = inheritedFlags
138            .split(' ')
139            .filter(
140              (flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model',
141            )
142            .join(' ')
143          inheritedFlags = inheritedFlags
144            ? `${inheritedFlags} --model ${quote([config.model])}`
145            : `--model ${quote([config.model])}`
146        }
147  
148        const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : ''
149        const workingDir = config.cwd
150  
151        // Build environment variables to forward to teammate
152        const envStr = buildInheritedEnvVars()
153  
154        const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}`
155  
156        // Send the command to the new pane
157        // Use swarm socket when running outside tmux (external swarm session)
158        await this.backend.sendCommandToPane(paneId, spawnCommand, !insideTmux)
159  
160        // Track the spawned teammate
161        this.spawnedTeammates.set(agentId, { paneId, insideTmux })
162  
163        // Register cleanup to kill all panes on leader exit (e.g., SIGHUP)
164        if (!this.cleanupRegistered) {
165          this.cleanupRegistered = true
166          registerCleanup(async () => {
167            for (const [id, info] of this.spawnedTeammates) {
168              logForDebugging(
169                `[PaneBackendExecutor] Cleanup: killing pane for ${id}`,
170              )
171              await this.backend.killPane(info.paneId, !info.insideTmux)
172            }
173            this.spawnedTeammates.clear()
174          })
175        }
176  
177        // Send initial instructions to teammate via mailbox
178        await writeToMailbox(
179          config.name,
180          {
181            from: 'team-lead',
182            text: config.prompt,
183            timestamp: new Date().toISOString(),
184          },
185          config.teamName,
186        )
187  
188        logForDebugging(
189          `[PaneBackendExecutor] Spawned teammate ${agentId} in pane ${paneId}`,
190        )
191  
192        return {
193          success: true,
194          agentId,
195          paneId,
196        }
197      } catch (error) {
198        const errorMessage =
199          error instanceof Error ? error.message : String(error)
200        logForDebugging(
201          `[PaneBackendExecutor] Failed to spawn ${agentId}: ${errorMessage}`,
202        )
203        return {
204          success: false,
205          agentId,
206          error: errorMessage,
207        }
208      }
209    }
210  
211    /**
212     * Sends a message to a pane-based teammate via file-based mailbox.
213     *
214     * All teammates (pane and in-process) use the same mailbox mechanism.
215     */
216    async sendMessage(agentId: string, message: TeammateMessage): Promise<void> {
217      logForDebugging(
218        `[PaneBackendExecutor] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`,
219      )
220  
221      const parsed = parseAgentId(agentId)
222      if (!parsed) {
223        throw new Error(
224          `Invalid agentId format: ${agentId}. Expected format: agentName@teamName`,
225        )
226      }
227  
228      const { agentName, teamName } = parsed
229  
230      await writeToMailbox(
231        agentName,
232        {
233          text: message.text,
234          from: message.from,
235          color: message.color,
236          timestamp: message.timestamp ?? new Date().toISOString(),
237        },
238        teamName,
239      )
240  
241      logForDebugging(
242        `[PaneBackendExecutor] sendMessage() completed for ${agentId}`,
243      )
244    }
245  
246    /**
247     * Gracefully terminates a pane-based teammate.
248     *
249     * For pane-based teammates, we send a shutdown request via mailbox and
250     * let the teammate process handle exit gracefully.
251     */
252    async terminate(agentId: string, reason?: string): Promise<boolean> {
253      logForDebugging(
254        `[PaneBackendExecutor] terminate() called for ${agentId}: ${reason}`,
255      )
256  
257      const parsed = parseAgentId(agentId)
258      if (!parsed) {
259        logForDebugging(
260          `[PaneBackendExecutor] terminate() failed: invalid agentId format`,
261        )
262        return false
263      }
264  
265      const { agentName, teamName } = parsed
266  
267      // Send shutdown request via mailbox
268      const shutdownRequest = {
269        type: 'shutdown_request',
270        requestId: `shutdown-${agentId}-${Date.now()}`,
271        from: 'team-lead',
272        reason,
273      }
274  
275      await writeToMailbox(
276        agentName,
277        {
278          from: 'team-lead',
279          text: jsonStringify(shutdownRequest),
280          timestamp: new Date().toISOString(),
281        },
282        teamName,
283      )
284  
285      logForDebugging(
286        `[PaneBackendExecutor] terminate() sent shutdown request to ${agentId}`,
287      )
288  
289      return true
290    }
291  
292    /**
293     * Force kills a pane-based teammate by killing its pane.
294     */
295    async kill(agentId: string): Promise<boolean> {
296      logForDebugging(`[PaneBackendExecutor] kill() called for ${agentId}`)
297  
298      const teammateInfo = this.spawnedTeammates.get(agentId)
299      if (!teammateInfo) {
300        logForDebugging(
301          `[PaneBackendExecutor] kill() failed: teammate ${agentId} not found in spawned map`,
302        )
303        return false
304      }
305  
306      const { paneId, insideTmux } = teammateInfo
307  
308      // Kill the pane via the backend
309      // Use external session socket when we spawned outside tmux
310      const killed = await this.backend.killPane(paneId, !insideTmux)
311  
312      if (killed) {
313        this.spawnedTeammates.delete(agentId)
314        logForDebugging(`[PaneBackendExecutor] kill() succeeded for ${agentId}`)
315      } else {
316        logForDebugging(`[PaneBackendExecutor] kill() failed for ${agentId}`)
317      }
318  
319      return killed
320    }
321  
322    /**
323     * Checks if a pane-based teammate is still active.
324     *
325     * For pane-based teammates, we check if the pane still exists.
326     * This is a best-effort check - the pane may exist but the process inside
327     * may have exited.
328     */
329    async isActive(agentId: string): Promise<boolean> {
330      logForDebugging(`[PaneBackendExecutor] isActive() called for ${agentId}`)
331  
332      const teammateInfo = this.spawnedTeammates.get(agentId)
333      if (!teammateInfo) {
334        logForDebugging(
335          `[PaneBackendExecutor] isActive(): teammate ${agentId} not found`,
336        )
337        return false
338      }
339  
340      // For now, assume active if we have a record of it
341      // A more robust check would query the backend for pane existence
342      // but that would require adding a new method to PaneBackend
343      return true
344    }
345  }
346  
347  /**
348   * Creates a PaneBackendExecutor wrapping the given PaneBackend.
349   */
350  export function createPaneBackendExecutor(
351    backend: PaneBackend,
352  ): PaneBackendExecutor {
353    return new PaneBackendExecutor(backend)
354  }