355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import { getSessionId } from '../../../bootstrap/state.js'
|
|
import type { ToolUseContext } from '../../../Tool.js'
|
|
import { formatAgentId, parseAgentId } from '../../../utils/agentId.js'
|
|
import { quote } from '../../../utils/bash/shellQuote.js'
|
|
import { registerCleanup } from '../../../utils/cleanupRegistry.js'
|
|
import { logForDebugging } from '../../../utils/debug.js'
|
|
import { jsonStringify } from '../../../utils/slowOperations.js'
|
|
import { writeToMailbox } from '../../../utils/teammateMailbox.js'
|
|
import {
|
|
buildInheritedCliFlags,
|
|
buildInheritedEnvVars,
|
|
getTeammateCommand,
|
|
} from '../spawnUtils.js'
|
|
import { assignTeammateColor } from '../teammateLayoutManager.js'
|
|
import { isInsideTmux } from './detection.js'
|
|
import type {
|
|
BackendType,
|
|
PaneBackend,
|
|
TeammateExecutor,
|
|
TeammateMessage,
|
|
TeammateSpawnConfig,
|
|
TeammateSpawnResult,
|
|
} from './types.js'
|
|
|
|
/**
|
|
* PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface.
|
|
*
|
|
* This allows pane-based backends (tmux, iTerm2) to be used through the same
|
|
* TeammateExecutor abstraction as InProcessBackend, making getTeammateExecutor()
|
|
* return a meaningful executor regardless of execution mode.
|
|
*
|
|
* The adapter handles:
|
|
* - spawn(): Creates a pane and sends the Claude CLI command to it
|
|
* - sendMessage(): Writes to the teammate's file-based mailbox
|
|
* - terminate(): Sends a shutdown request via mailbox
|
|
* - kill(): Kills the pane via the backend
|
|
* - isActive(): Checks if the pane is still running
|
|
*/
|
|
export class PaneBackendExecutor implements TeammateExecutor {
|
|
readonly type: BackendType
|
|
|
|
private backend: PaneBackend
|
|
private context: ToolUseContext | null = null
|
|
|
|
/**
|
|
* Track spawned teammates by agentId -> paneId mapping.
|
|
* This allows us to find the pane for operations like kill/terminate.
|
|
*/
|
|
private spawnedTeammates: Map<string, { paneId: string; insideTmux: boolean }>
|
|
private cleanupRegistered = false
|
|
|
|
constructor(backend: PaneBackend) {
|
|
this.backend = backend
|
|
this.type = backend.type
|
|
this.spawnedTeammates = new Map()
|
|
}
|
|
|
|
/**
|
|
* Sets the ToolUseContext for this executor.
|
|
* Must be called before spawn() to provide access to AppState and permissions.
|
|
*/
|
|
setContext(context: ToolUseContext): void {
|
|
this.context = context
|
|
}
|
|
|
|
/**
|
|
* Checks if the underlying pane backend is available.
|
|
*/
|
|
async isAvailable(): Promise<boolean> {
|
|
return this.backend.isAvailable()
|
|
}
|
|
|
|
/**
|
|
* Spawns a teammate in a new pane.
|
|
*
|
|
* Creates a pane via the backend, builds the CLI command with teammate
|
|
* identity flags, and sends it to the pane.
|
|
*/
|
|
async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> {
|
|
const agentId = formatAgentId(config.name, config.teamName)
|
|
|
|
if (!this.context) {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] spawn() called without context for ${config.name}`,
|
|
)
|
|
return {
|
|
success: false,
|
|
agentId,
|
|
error:
|
|
'PaneBackendExecutor not initialized. Call setContext() before spawn().',
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Assign a unique color to this teammate
|
|
const teammateColor = config.color ?? assignTeammateColor(agentId)
|
|
|
|
// Create a pane in the swarm view
|
|
const { paneId, isFirstTeammate } =
|
|
await this.backend.createTeammatePaneInSwarmView(
|
|
config.name,
|
|
teammateColor,
|
|
)
|
|
|
|
// Check if we're inside tmux to determine how to send commands
|
|
const insideTmux = await isInsideTmux()
|
|
|
|
// Enable pane border status on first teammate when inside tmux
|
|
if (isFirstTeammate && insideTmux) {
|
|
await this.backend.enablePaneBorderStatus()
|
|
}
|
|
|
|
// Build the command to spawn Claude Code with teammate identity
|
|
const binaryPath = getTeammateCommand()
|
|
|
|
// Build teammate identity CLI args
|
|
const teammateArgs = [
|
|
`--agent-id ${quote([agentId])}`,
|
|
`--agent-name ${quote([config.name])}`,
|
|
`--team-name ${quote([config.teamName])}`,
|
|
`--agent-color ${quote([teammateColor])}`,
|
|
`--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`,
|
|
config.planModeRequired ? '--plan-mode-required' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
// Build CLI flags to propagate to teammate
|
|
const appState = this.context.getAppState()
|
|
let inheritedFlags = buildInheritedCliFlags({
|
|
planModeRequired: config.planModeRequired,
|
|
permissionMode: appState.toolPermissionContext.mode,
|
|
})
|
|
|
|
// If teammate has a custom model, add --model flag (or replace inherited one)
|
|
if (config.model) {
|
|
inheritedFlags = inheritedFlags
|
|
.split(' ')
|
|
.filter(
|
|
(flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model',
|
|
)
|
|
.join(' ')
|
|
inheritedFlags = inheritedFlags
|
|
? `${inheritedFlags} --model ${quote([config.model])}`
|
|
: `--model ${quote([config.model])}`
|
|
}
|
|
|
|
const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : ''
|
|
const workingDir = config.cwd
|
|
|
|
// Build environment variables to forward to teammate
|
|
const envStr = buildInheritedEnvVars()
|
|
|
|
const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}`
|
|
|
|
// Send the command to the new pane
|
|
// Use swarm socket when running outside tmux (external swarm session)
|
|
await this.backend.sendCommandToPane(paneId, spawnCommand, !insideTmux)
|
|
|
|
// Track the spawned teammate
|
|
this.spawnedTeammates.set(agentId, { paneId, insideTmux })
|
|
|
|
// Register cleanup to kill all panes on leader exit (e.g., SIGHUP)
|
|
if (!this.cleanupRegistered) {
|
|
this.cleanupRegistered = true
|
|
registerCleanup(async () => {
|
|
for (const [id, info] of this.spawnedTeammates) {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] Cleanup: killing pane for ${id}`,
|
|
)
|
|
await this.backend.killPane(info.paneId, !info.insideTmux)
|
|
}
|
|
this.spawnedTeammates.clear()
|
|
})
|
|
}
|
|
|
|
// Send initial instructions to teammate via mailbox
|
|
await writeToMailbox(
|
|
config.name,
|
|
{
|
|
from: 'team-lead',
|
|
text: config.prompt,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
config.teamName,
|
|
)
|
|
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] Spawned teammate ${agentId} in pane ${paneId}`,
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
agentId,
|
|
paneId,
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error)
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] Failed to spawn ${agentId}: ${errorMessage}`,
|
|
)
|
|
return {
|
|
success: false,
|
|
agentId,
|
|
error: errorMessage,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a message to a pane-based teammate via file-based mailbox.
|
|
*
|
|
* All teammates (pane and in-process) use the same mailbox mechanism.
|
|
*/
|
|
async sendMessage(agentId: string, message: TeammateMessage): Promise<void> {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`,
|
|
)
|
|
|
|
const parsed = parseAgentId(agentId)
|
|
if (!parsed) {
|
|
throw new Error(
|
|
`Invalid agentId format: ${agentId}. Expected format: agentName@teamName`,
|
|
)
|
|
}
|
|
|
|
const { agentName, teamName } = parsed
|
|
|
|
await writeToMailbox(
|
|
agentName,
|
|
{
|
|
text: message.text,
|
|
from: message.from,
|
|
color: message.color,
|
|
timestamp: message.timestamp ?? new Date().toISOString(),
|
|
},
|
|
teamName,
|
|
)
|
|
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] sendMessage() completed for ${agentId}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Gracefully terminates a pane-based teammate.
|
|
*
|
|
* For pane-based teammates, we send a shutdown request via mailbox and
|
|
* let the teammate process handle exit gracefully.
|
|
*/
|
|
async terminate(agentId: string, reason?: string): Promise<boolean> {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] terminate() called for ${agentId}: ${reason}`,
|
|
)
|
|
|
|
const parsed = parseAgentId(agentId)
|
|
if (!parsed) {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] terminate() failed: invalid agentId format`,
|
|
)
|
|
return false
|
|
}
|
|
|
|
const { agentName, teamName } = parsed
|
|
|
|
// Send shutdown request via mailbox
|
|
const shutdownRequest = {
|
|
type: 'shutdown_request',
|
|
requestId: `shutdown-${agentId}-${Date.now()}`,
|
|
from: 'team-lead',
|
|
reason,
|
|
}
|
|
|
|
await writeToMailbox(
|
|
agentName,
|
|
{
|
|
from: 'team-lead',
|
|
text: jsonStringify(shutdownRequest),
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
teamName,
|
|
)
|
|
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] terminate() sent shutdown request to ${agentId}`,
|
|
)
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Force kills a pane-based teammate by killing its pane.
|
|
*/
|
|
async kill(agentId: string): Promise<boolean> {
|
|
logForDebugging(`[PaneBackendExecutor] kill() called for ${agentId}`)
|
|
|
|
const teammateInfo = this.spawnedTeammates.get(agentId)
|
|
if (!teammateInfo) {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] kill() failed: teammate ${agentId} not found in spawned map`,
|
|
)
|
|
return false
|
|
}
|
|
|
|
const { paneId, insideTmux } = teammateInfo
|
|
|
|
// Kill the pane via the backend
|
|
// Use external session socket when we spawned outside tmux
|
|
const killed = await this.backend.killPane(paneId, !insideTmux)
|
|
|
|
if (killed) {
|
|
this.spawnedTeammates.delete(agentId)
|
|
logForDebugging(`[PaneBackendExecutor] kill() succeeded for ${agentId}`)
|
|
} else {
|
|
logForDebugging(`[PaneBackendExecutor] kill() failed for ${agentId}`)
|
|
}
|
|
|
|
return killed
|
|
}
|
|
|
|
/**
|
|
* Checks if a pane-based teammate is still active.
|
|
*
|
|
* For pane-based teammates, we check if the pane still exists.
|
|
* This is a best-effort check - the pane may exist but the process inside
|
|
* may have exited.
|
|
*/
|
|
async isActive(agentId: string): Promise<boolean> {
|
|
logForDebugging(`[PaneBackendExecutor] isActive() called for ${agentId}`)
|
|
|
|
const teammateInfo = this.spawnedTeammates.get(agentId)
|
|
if (!teammateInfo) {
|
|
logForDebugging(
|
|
`[PaneBackendExecutor] isActive(): teammate ${agentId} not found`,
|
|
)
|
|
return false
|
|
}
|
|
|
|
// For now, assume active if we have a record of it
|
|
// A more robust check would query the backend for pane existence
|
|
// but that would require adding a new method to PaneBackend
|
|
return true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a PaneBackendExecutor wrapping the given PaneBackend.
|
|
*/
|
|
export function createPaneBackendExecutor(
|
|
backend: PaneBackend,
|
|
): PaneBackendExecutor {
|
|
return new PaneBackendExecutor(backend)
|
|
}
|