init claude-code
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user