init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
File diff suppressed because one or more lines are too long
+370
View File
@@ -0,0 +1,370 @@
import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js'
import { logForDebugging } from '../../../utils/debug.js'
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
import { IT2_COMMAND, isInITerm2, isIt2CliAvailable } from './detection.js'
import { registerITermBackend } from './registry.js'
import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
// Track session IDs for teammates
const teammateSessionIds: string[] = []
// Track whether the first pane has been used
let firstPaneUsed = false
// Lock mechanism to prevent race conditions when spawning teammates in parallel
let paneCreationLock: Promise<void> = Promise.resolve()
/**
* Acquires a lock for pane creation, ensuring sequential execution.
* Returns a release function that must be called when done.
*/
function acquirePaneCreationLock(): Promise<() => void> {
let release: () => void
const newLock = new Promise<void>(resolve => {
release = resolve
})
const previousLock = paneCreationLock
paneCreationLock = newLock
return previousLock.then(() => release!)
}
/**
* Runs an it2 CLI command and returns the result.
*/
function runIt2(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
return execFileNoThrow(IT2_COMMAND, args)
}
/**
* Parses the session ID from `it2 session split` output.
* Format: "Created new pane: <session-id>"
*
* NOTE: This UUID is only valid when splitting from a specific session
* using the -s flag. When splitting from the "active" session, the UUID
* may not be accessible if the split happened in a different window.
*/
function parseSplitOutput(output: string): string {
const match = output.match(/Created new pane:\s*(.+)/)
if (match && match[1]) {
return match[1].trim()
}
return ''
}
/**
* Gets the leader's session ID from ITERM_SESSION_ID env var.
* Format: "wXtYpZ:UUID" - we extract the UUID part after the colon.
* Returns null if not in iTerm2 or env var not set.
*/
function getLeaderSessionId(): string | null {
const itermSessionId = process.env.ITERM_SESSION_ID
if (!itermSessionId) {
return null
}
const colonIndex = itermSessionId.indexOf(':')
if (colonIndex === -1) {
return null
}
return itermSessionId.slice(colonIndex + 1)
}
/**
* ITermBackend implements pane management using iTerm2's native split panes
* via the it2 CLI tool.
*/
export class ITermBackend implements PaneBackend {
readonly type = 'iterm2' as const
readonly displayName = 'iTerm2'
readonly supportsHideShow = false
/**
* Checks if iTerm2 backend is available (in iTerm2 with it2 CLI installed).
*/
async isAvailable(): Promise<boolean> {
const inITerm2 = isInITerm2()
logForDebugging(`[ITermBackend] isAvailable check: inITerm2=${inITerm2}`)
if (!inITerm2) {
logForDebugging('[ITermBackend] isAvailable: false (not in iTerm2)')
return false
}
const it2Available = await isIt2CliAvailable()
logForDebugging(
`[ITermBackend] isAvailable: ${it2Available} (it2 CLI ${it2Available ? 'found' : 'not found'})`,
)
return it2Available
}
/**
* Checks if we're currently running inside iTerm2.
*/
async isRunningInside(): Promise<boolean> {
const result = isInITerm2()
logForDebugging(`[ITermBackend] isRunningInside: ${result}`)
return result
}
/**
* Creates a new teammate pane in the swarm view.
* Uses a lock to prevent race conditions when multiple teammates are spawned in parallel.
*/
async createTeammatePaneInSwarmView(
name: string,
color: AgentColorName,
): Promise<CreatePaneResult> {
logForDebugging(
`[ITermBackend] createTeammatePaneInSwarmView called for ${name} with color ${color}`,
)
const releaseLock = await acquirePaneCreationLock()
try {
// Layout: Leader on left, teammates stacked vertically on the right
// - First teammate: vertical split (-v) from leader's session
// - Subsequent teammates: horizontal split from last teammate's session
//
// We explicitly target the session to split from using -s flag to ensure
// correct layout even if user clicks on different panes.
//
// At-fault recovery: If a targeted teammate session is dead (user closed
// the pane via Cmd+W / X, or process crashed), prune it and retry with
// the next-to-last. Cheaper than a proactive 'it2 session list' on every spawn.
// Bounded at O(N+1) iterations: each continue shrinks teammateSessionIds by 1;
// when empty → firstPaneUsed resets → next iteration has no target → throws.
// eslint-disable-next-line no-constant-condition
while (true) {
const isFirstTeammate = !firstPaneUsed
logForDebugging(
`[ITermBackend] Creating pane: isFirstTeammate=${isFirstTeammate}, existingPanes=${teammateSessionIds.length}`,
)
let splitArgs: string[]
let targetedTeammateId: string | undefined
if (isFirstTeammate) {
// Split from leader's session (extracted from ITERM_SESSION_ID env var)
const leaderSessionId = getLeaderSessionId()
if (leaderSessionId) {
splitArgs = ['session', 'split', '-v', '-s', leaderSessionId]
logForDebugging(
`[ITermBackend] First split from leader session: ${leaderSessionId}`,
)
} else {
// Fallback to active session if we can't get leader's ID
splitArgs = ['session', 'split', '-v']
logForDebugging(
'[ITermBackend] First split from active session (no leader ID)',
)
}
} else {
// Split from the last teammate's session to stack vertically
targetedTeammateId = teammateSessionIds[teammateSessionIds.length - 1]
if (targetedTeammateId) {
splitArgs = ['session', 'split', '-s', targetedTeammateId]
logForDebugging(
`[ITermBackend] Subsequent split from teammate session: ${targetedTeammateId}`,
)
} else {
// Fallback to active session
splitArgs = ['session', 'split']
logForDebugging(
'[ITermBackend] Subsequent split from active session (no teammate ID)',
)
}
}
const splitResult = await runIt2(splitArgs)
if (splitResult.code !== 0) {
// If we targeted a teammate session, confirm it's actually dead before
// pruning — 'session list' distinguishes dead-target from systemic
// failure (Python API off, it2 removed, transient socket error).
// Pruning on systemic failure would drain all live IDs → state corrupted.
if (targetedTeammateId) {
const listResult = await runIt2(['session', 'list'])
if (
listResult.code === 0 &&
!listResult.stdout.includes(targetedTeammateId)
) {
// Confirmed dead — prune and retry with next-to-last (or leader).
logForDebugging(
`[ITermBackend] Split failed targeting dead session ${targetedTeammateId}, pruning and retrying: ${splitResult.stderr}`,
)
const idx = teammateSessionIds.indexOf(targetedTeammateId)
if (idx !== -1) {
teammateSessionIds.splice(idx, 1)
}
if (teammateSessionIds.length === 0) {
firstPaneUsed = false
}
continue
}
// Target is alive or we can't tell — don't corrupt state, surface the error.
}
throw new Error(
`Failed to create iTerm2 split pane: ${splitResult.stderr}`,
)
}
if (isFirstTeammate) {
firstPaneUsed = true
}
// Parse the session ID from split output
// This works because we're splitting from a specific session (-s flag),
// so the new pane is in the same window and the UUID is valid.
const paneId = parseSplitOutput(splitResult.stdout)
if (!paneId) {
throw new Error(
`Failed to parse session ID from split output: ${splitResult.stdout}`,
)
}
logForDebugging(
`[ITermBackend] Created teammate pane for ${name}: ${paneId}`,
)
teammateSessionIds.push(paneId)
// Set pane color and title
// Skip color and title for now - each it2 call is slow (Python process + API)
// The pane is functional without these cosmetic features
// TODO: Consider batching these or making them async/fire-and-forget
return { paneId, isFirstTeammate }
}
} finally {
releaseLock()
}
}
/**
* Sends a command to a specific pane.
*/
async sendCommandToPane(
paneId: PaneId,
command: string,
_useExternalSession?: boolean,
): Promise<void> {
// Use it2 session run to execute command (adds newline automatically)
// Always use -s flag to target specific session - this ensures the command
// goes to the right pane even if user switches windows
const args = paneId
? ['session', 'run', '-s', paneId, command]
: ['session', 'run', command]
const result = await runIt2(args)
if (result.code !== 0) {
throw new Error(
`Failed to send command to iTerm2 pane ${paneId}: ${result.stderr}`,
)
}
}
/**
* No-op for iTerm2 - tab colors would require escape sequences but we skip
* them for performance (each it2 call is slow).
*/
async setPaneBorderColor(
_paneId: PaneId,
_color: AgentColorName,
_useExternalSession?: boolean,
): Promise<void> {
// Skip for performance - each it2 call spawns a Python process
}
/**
* No-op for iTerm2 - titles would require escape sequences but we skip
* them for performance (each it2 call is slow).
*/
async setPaneTitle(
_paneId: PaneId,
_name: string,
_color: AgentColorName,
_useExternalSession?: boolean,
): Promise<void> {
// Skip for performance - each it2 call spawns a Python process
}
/**
* No-op for iTerm2 - pane titles are shown in tabs automatically.
*/
async enablePaneBorderStatus(
_windowTarget?: string,
_useExternalSession?: boolean,
): Promise<void> {
// iTerm2 doesn't have the concept of pane border status like tmux
// Titles are shown in tabs automatically
}
/**
* No-op for iTerm2 - pane balancing is handled automatically.
*/
async rebalancePanes(
_windowTarget: string,
_hasLeader: boolean,
): Promise<void> {
// iTerm2 handles pane balancing automatically
logForDebugging(
'[ITermBackend] Pane rebalancing not implemented for iTerm2',
)
}
/**
* Kills/closes a specific pane using the it2 CLI.
* Also removes the pane from tracked session IDs so subsequent spawns
* don't try to split from a dead session.
*/
async killPane(
paneId: PaneId,
_useExternalSession?: boolean,
): Promise<boolean> {
// -f (force) is required: without it, iTerm2 respects the "Confirm before
// closing" preference and either shows a dialog or refuses when the session
// still has a running process (the shell always is). tmux kill-pane has no
// such prompt, which is why this was only broken for iTerm2.
const result = await runIt2(['session', 'close', '-f', '-s', paneId])
// Clean up module state regardless of close result — even if the pane is
// already gone (e.g., user closed it manually), removing the stale ID is correct.
const idx = teammateSessionIds.indexOf(paneId)
if (idx !== -1) {
teammateSessionIds.splice(idx, 1)
}
if (teammateSessionIds.length === 0) {
firstPaneUsed = false
}
return result.code === 0
}
/**
* Stub for hiding a pane - not supported in iTerm2 backend.
* iTerm2 doesn't have a direct equivalent to tmux's break-pane.
*/
async hidePane(
_paneId: PaneId,
_useExternalSession?: boolean,
): Promise<boolean> {
logForDebugging('[ITermBackend] hidePane not supported in iTerm2')
return false
}
/**
* Stub for showing a hidden pane - not supported in iTerm2 backend.
* iTerm2 doesn't have a direct equivalent to tmux's join-pane.
*/
async showPane(
_paneId: PaneId,
_targetWindowOrPane: string,
_useExternalSession?: boolean,
): Promise<boolean> {
logForDebugging('[ITermBackend] showPane not supported in iTerm2')
return false
}
}
// Register the backend with the registry when this module is imported.
// This side effect is intentional - the registry needs backends to self-register to avoid circular dependencies.
// eslint-disable-next-line custom-rules/no-top-level-side-effects
registerITermBackend(ITermBackend)
+339
View File
@@ -0,0 +1,339 @@
import type { ToolUseContext } from '../../../Tool.js'
import {
findTeammateTaskByAgentId,
requestTeammateShutdown,
} from '../../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
import { parseAgentId } from '../../../utils/agentId.js'
import { logForDebugging } from '../../../utils/debug.js'
import { jsonStringify } from '../../../utils/slowOperations.js'
import {
createShutdownRequestMessage,
writeToMailbox,
} from '../../../utils/teammateMailbox.js'
import { startInProcessTeammate } from '../inProcessRunner.js'
import {
killInProcessTeammate,
spawnInProcessTeammate,
} from '../spawnInProcess.js'
import type {
TeammateExecutor,
TeammateMessage,
TeammateSpawnConfig,
TeammateSpawnResult,
} from './types.js'
/**
* InProcessBackend implements TeammateExecutor for in-process teammates.
*
* Unlike pane-based backends (tmux/iTerm2), in-process teammates run in the
* same Node.js process with isolated context via AsyncLocalStorage. They:
* - Share resources (API client, MCP connections) with the leader
* - Communicate via file-based mailbox (same as pane-based teammates)
* - Are terminated via AbortController (not kill-pane)
*
* IMPORTANT: Before spawning, call setContext() to provide the ToolUseContext
* needed for AppState access. This is intended for use via the TeammateExecutor
* abstraction (getTeammateExecutor() in registry.ts).
*/
export class InProcessBackend implements TeammateExecutor {
readonly type = 'in-process' as const
/**
* Tool use context for AppState access.
* Must be set via setContext() before spawn() is called.
*/
private context: ToolUseContext | null = null
/**
* Sets the ToolUseContext for this backend.
* Called by TeammateTool before spawning to provide AppState access.
*/
setContext(context: ToolUseContext): void {
this.context = context
}
/**
* In-process backend is always available (no external dependencies).
*/
async isAvailable(): Promise<boolean> {
return true
}
/**
* Spawns an in-process teammate.
*
* Uses spawnInProcessTeammate() to:
* 1. Create TeammateContext via createTeammateContext()
* 2. Create independent AbortController (not linked to parent)
* 3. Register teammate in AppState.tasks
* 4. Start agent execution via startInProcessTeammate()
* 5. Return spawn result with agentId, taskId, abortController
*/
async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> {
if (!this.context) {
logForDebugging(
`[InProcessBackend] spawn() called without context for ${config.name}`,
)
return {
success: false,
agentId: `${config.name}@${config.teamName}`,
error:
'InProcessBackend not initialized. Call setContext() before spawn().',
}
}
logForDebugging(`[InProcessBackend] spawn() called for ${config.name}`)
const result = await spawnInProcessTeammate(
{
name: config.name,
teamName: config.teamName,
prompt: config.prompt,
color: config.color,
planModeRequired: config.planModeRequired ?? false,
},
this.context,
)
// If spawn succeeded, start the agent execution loop
if (
result.success &&
result.taskId &&
result.teammateContext &&
result.abortController
) {
// Start the agent loop in the background (fire-and-forget)
// The prompt is passed through the task state and config
startInProcessTeammate({
identity: {
agentId: result.agentId,
agentName: config.name,
teamName: config.teamName,
color: config.color,
planModeRequired: config.planModeRequired ?? false,
parentSessionId: result.teammateContext.parentSessionId,
},
taskId: result.taskId,
prompt: config.prompt,
teammateContext: result.teammateContext,
// Strip messages: the teammate never reads toolUseContext.messages
// (runAgent overrides it via createSubagentContext). Passing the
// parent's conversation would pin it for the teammate's lifetime.
toolUseContext: { ...this.context, messages: [] },
abortController: result.abortController,
model: config.model,
systemPrompt: config.systemPrompt,
systemPromptMode: config.systemPromptMode,
allowedTools: config.permissions,
allowPermissionPrompts: config.allowPermissionPrompts,
})
logForDebugging(
`[InProcessBackend] Started agent execution for ${result.agentId}`,
)
}
return {
success: result.success,
agentId: result.agentId,
taskId: result.taskId,
abortController: result.abortController,
error: result.error,
}
}
/**
* Sends a message to an in-process teammate.
*
* All teammates use file-based mailboxes for simplicity.
*/
async sendMessage(agentId: string, message: TeammateMessage): Promise<void> {
logForDebugging(
`[InProcessBackend] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`,
)
// Parse agentId to get agentName and teamName
// agentId format: "agentName@teamName" (e.g., "researcher@my-team")
const parsed = parseAgentId(agentId)
if (!parsed) {
logForDebugging(`[InProcessBackend] Invalid agentId format: ${agentId}`)
throw new Error(
`Invalid agentId format: ${agentId}. Expected format: agentName@teamName`,
)
}
const { agentName, teamName } = parsed
// Write to file-based mailbox
await writeToMailbox(
agentName,
{
text: message.text,
from: message.from,
color: message.color,
timestamp: message.timestamp ?? new Date().toISOString(),
},
teamName,
)
logForDebugging(`[InProcessBackend] sendMessage() completed for ${agentId}`)
}
/**
* Gracefully terminates an in-process teammate.
*
* Sends a shutdown request message to the teammate and sets the
* shutdownRequested flag. The teammate processes the request and
* either approves (exits) or rejects (continues working).
*
* Unlike pane-based teammates, in-process teammates handle their own
* exit via the shutdown flow - no external killPane() is needed.
*/
async terminate(agentId: string, reason?: string): Promise<boolean> {
logForDebugging(
`[InProcessBackend] terminate() called for ${agentId}: ${reason}`,
)
if (!this.context) {
logForDebugging(
`[InProcessBackend] terminate() failed: no context set for ${agentId}`,
)
return false
}
// Get current AppState to find the task
const state = this.context.getAppState()
const task = findTeammateTaskByAgentId(agentId, state.tasks)
if (!task) {
logForDebugging(
`[InProcessBackend] terminate() failed: task not found for ${agentId}`,
)
return false
}
// Don't send another shutdown request if one is already pending
if (task.shutdownRequested) {
logForDebugging(
`[InProcessBackend] terminate(): shutdown already requested for ${agentId}`,
)
return true
}
// Generate deterministic request ID
const requestId = `shutdown-${agentId}-${Date.now()}`
// Create shutdown request message
const shutdownRequest = createShutdownRequestMessage({
requestId,
from: 'team-lead', // Terminate is always called by the leader
reason,
})
// Send to teammate's mailbox
const teammateAgentName = task.identity.agentName
await writeToMailbox(
teammateAgentName,
{
from: 'team-lead',
text: jsonStringify(shutdownRequest),
timestamp: new Date().toISOString(),
},
task.identity.teamName,
)
// Mark the task as shutdown requested
requestTeammateShutdown(task.id, this.context.setAppState)
logForDebugging(
`[InProcessBackend] terminate() sent shutdown request to ${agentId}`,
)
return true
}
/**
* Force kills an in-process teammate immediately.
*
* Uses the teammate's AbortController to cancel all async operations
* and updates the task state to 'killed'.
*/
async kill(agentId: string): Promise<boolean> {
logForDebugging(`[InProcessBackend] kill() called for ${agentId}`)
if (!this.context) {
logForDebugging(
`[InProcessBackend] kill() failed: no context set for ${agentId}`,
)
return false
}
// Get current AppState to find the task
const state = this.context.getAppState()
const task = findTeammateTaskByAgentId(agentId, state.tasks)
if (!task) {
logForDebugging(
`[InProcessBackend] kill() failed: task not found for ${agentId}`,
)
return false
}
// Kill the teammate via the existing helper function
const killed = killInProcessTeammate(task.id, this.context.setAppState)
logForDebugging(
`[InProcessBackend] kill() ${killed ? 'succeeded' : 'failed'} for ${agentId}`,
)
return killed
}
/**
* Checks if an in-process teammate is still active.
*
* Returns true if the teammate exists, has status 'running',
* and its AbortController has not been aborted.
*/
async isActive(agentId: string): Promise<boolean> {
logForDebugging(`[InProcessBackend] isActive() called for ${agentId}`)
if (!this.context) {
logForDebugging(
`[InProcessBackend] isActive() failed: no context set for ${agentId}`,
)
return false
}
// Get current AppState to find the task
const state = this.context.getAppState()
const task = findTeammateTaskByAgentId(agentId, state.tasks)
if (!task) {
logForDebugging(
`[InProcessBackend] isActive(): task not found for ${agentId}`,
)
return false
}
// Check if task is running and not aborted
const isRunning = task.status === 'running'
const isAborted = task.abortController?.signal.aborted ?? true
const active = isRunning && !isAborted
logForDebugging(
`[InProcessBackend] isActive() for ${agentId}: ${active} (running=${isRunning}, aborted=${isAborted})`,
)
return active
}
}
/**
* Factory function to create an InProcessBackend instance.
* Used by the registry (Task #8) to get backend instances.
*/
export function createInProcessBackend(): InProcessBackend {
return new InProcessBackend()
}
+354
View File
@@ -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)
}
+764
View File
@@ -0,0 +1,764 @@
import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js'
import { logForDebugging } from '../../../utils/debug.js'
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
import { logError } from '../../../utils/log.js'
import { count } from '../../array.js'
import { sleep } from '../../sleep.js'
import {
getSwarmSocketName,
HIDDEN_SESSION_NAME,
SWARM_SESSION_NAME,
SWARM_VIEW_WINDOW_NAME,
TMUX_COMMAND,
} from '../constants.js'
import {
getLeaderPaneId,
isInsideTmux as isInsideTmuxFromDetection,
isTmuxAvailable,
} from './detection.js'
import { registerTmuxBackend } from './registry.js'
import type { CreatePaneResult, PaneBackend, PaneId } from './types.js'
// Track whether the first pane has been used for external swarm session
let firstPaneUsedForExternal = false
// Cached leader window target (session:window format) to avoid repeated queries
let cachedLeaderWindowTarget: string | null = null
// Lock mechanism to prevent race conditions when spawning teammates in parallel
let paneCreationLock: Promise<void> = Promise.resolve()
// Delay after pane creation to allow shell initialization (loading rc files, prompts, etc.)
// 200ms is enough for most shell configurations including slow ones like starship/oh-my-zsh
const PANE_SHELL_INIT_DELAY_MS = 200
function waitForPaneShellReady(): Promise<void> {
return sleep(PANE_SHELL_INIT_DELAY_MS)
}
/**
* Acquires a lock for pane creation, ensuring sequential execution.
* Returns a release function that must be called when done.
*/
function acquirePaneCreationLock(): Promise<() => void> {
let release: () => void
const newLock = new Promise<void>(resolve => {
release = resolve
})
const previousLock = paneCreationLock
paneCreationLock = newLock
return previousLock.then(() => release!)
}
/**
* Gets the tmux color name for a given agent color.
* These are tmux's built-in color names that work with pane-border-style.
*/
function getTmuxColorName(color: AgentColorName): string {
const tmuxColors: Record<AgentColorName, string> = {
red: 'red',
blue: 'blue',
green: 'green',
yellow: 'yellow',
purple: 'magenta',
orange: 'colour208',
pink: 'colour205',
cyan: 'cyan',
}
return tmuxColors[color]
}
/**
* Runs a tmux command in the user's original tmux session (no socket override).
* Use this for operations that interact with the user's tmux panes (split-pane with leader).
*/
function runTmuxInUserSession(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
return execFileNoThrow(TMUX_COMMAND, args)
}
/**
* Runs a tmux command in the external swarm socket.
* Use this for operations in the standalone swarm session (when user is not in tmux).
*/
function runTmuxInSwarm(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
return execFileNoThrow(TMUX_COMMAND, ['-L', getSwarmSocketName(), ...args])
}
/**
* TmuxBackend implements PaneBackend using tmux for pane management.
*
* When running INSIDE tmux (leader is in tmux):
* - Splits the current window to add teammates alongside the leader
* - Leader stays on left (30%), teammates on right (70%)
*
* When running OUTSIDE tmux (leader is in regular terminal):
* - Creates a claude-swarm session with a swarm-view window
* - All teammates are equally distributed (no leader pane)
*/
export class TmuxBackend implements PaneBackend {
readonly type = 'tmux' as const
readonly displayName = 'tmux'
readonly supportsHideShow = true
/**
* Checks if tmux is installed and available.
* Delegates to detection.ts for consistent detection logic.
*/
async isAvailable(): Promise<boolean> {
return isTmuxAvailable()
}
/**
* Checks if we're currently running inside a tmux session.
* Delegates to detection.ts for consistent detection logic.
*/
async isRunningInside(): Promise<boolean> {
return isInsideTmuxFromDetection()
}
/**
* Creates a new teammate pane in the swarm view.
* Uses a lock to prevent race conditions when multiple teammates are spawned in parallel.
*/
async createTeammatePaneInSwarmView(
name: string,
color: AgentColorName,
): Promise<CreatePaneResult> {
const releaseLock = await acquirePaneCreationLock()
try {
const insideTmux = await this.isRunningInside()
if (insideTmux) {
return await this.createTeammatePaneWithLeader(name, color)
}
return await this.createTeammatePaneExternal(name, color)
} finally {
releaseLock()
}
}
/**
* Sends a command to a specific pane.
*/
async sendCommandToPane(
paneId: PaneId,
command: string,
useExternalSession = false,
): Promise<void> {
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
const result = await runTmux(['send-keys', '-t', paneId, command, 'Enter'])
if (result.code !== 0) {
throw new Error(
`Failed to send command to pane ${paneId}: ${result.stderr}`,
)
}
}
/**
* Sets the border color for a specific pane.
*/
async setPaneBorderColor(
paneId: PaneId,
color: AgentColorName,
useExternalSession = false,
): Promise<void> {
const tmuxColor = getTmuxColorName(color)
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
// Set pane-specific border style using pane options (requires tmux 3.2+)
await runTmux([
'select-pane',
'-t',
paneId,
'-P',
`bg=default,fg=${tmuxColor}`,
])
await runTmux([
'set-option',
'-p',
'-t',
paneId,
'pane-border-style',
`fg=${tmuxColor}`,
])
await runTmux([
'set-option',
'-p',
'-t',
paneId,
'pane-active-border-style',
`fg=${tmuxColor}`,
])
}
/**
* Sets the title for a pane (shown in pane border if pane-border-status is set).
*/
async setPaneTitle(
paneId: PaneId,
name: string,
color: AgentColorName,
useExternalSession = false,
): Promise<void> {
const tmuxColor = getTmuxColorName(color)
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
// Set the pane title
await runTmux(['select-pane', '-t', paneId, '-T', name])
// Enable pane border status with colored format
await runTmux([
'set-option',
'-p',
'-t',
paneId,
'pane-border-format',
`#[fg=${tmuxColor},bold] #{pane_title} #[default]`,
])
}
/**
* Enables pane border status for a window (shows pane titles).
*/
async enablePaneBorderStatus(
windowTarget?: string,
useExternalSession = false,
): Promise<void> {
const target = windowTarget || (await this.getCurrentWindowTarget())
if (!target) {
return
}
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
await runTmux([
'set-option',
'-w',
'-t',
target,
'pane-border-status',
'top',
])
}
/**
* Rebalances panes to achieve the desired layout.
*/
async rebalancePanes(
windowTarget: string,
hasLeader: boolean,
): Promise<void> {
if (hasLeader) {
await this.rebalancePanesWithLeader(windowTarget)
} else {
await this.rebalancePanesTiled(windowTarget)
}
}
/**
* Kills/closes a specific pane.
*/
async killPane(paneId: PaneId, useExternalSession = false): Promise<boolean> {
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
const result = await runTmux(['kill-pane', '-t', paneId])
return result.code === 0
}
/**
* Hides a pane by moving it to a detached hidden session.
* Creates the hidden session if it doesn't exist, then uses break-pane to move the pane there.
*/
async hidePane(paneId: PaneId, useExternalSession = false): Promise<boolean> {
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
// Create hidden session if it doesn't exist (detached, not visible)
await runTmux(['new-session', '-d', '-s', HIDDEN_SESSION_NAME])
// Move the pane to the hidden session
const result = await runTmux([
'break-pane',
'-d',
'-s',
paneId,
'-t',
`${HIDDEN_SESSION_NAME}:`,
])
if (result.code === 0) {
logForDebugging(`[TmuxBackend] Hidden pane ${paneId}`)
} else {
logForDebugging(
`[TmuxBackend] Failed to hide pane ${paneId}: ${result.stderr}`,
)
}
return result.code === 0
}
/**
* Shows a previously hidden pane by joining it back into the target window.
* Uses `tmux join-pane` to move the pane back, then reapplies main-vertical layout
* with leader at 30%.
*/
async showPane(
paneId: PaneId,
targetWindowOrPane: string,
useExternalSession = false,
): Promise<boolean> {
const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession
// join-pane -s: source pane to move
// -t: target window/pane to join into
// -h: join horizontally (side by side)
const result = await runTmux([
'join-pane',
'-h',
'-s',
paneId,
'-t',
targetWindowOrPane,
])
if (result.code !== 0) {
logForDebugging(
`[TmuxBackend] Failed to show pane ${paneId}: ${result.stderr}`,
)
return false
}
logForDebugging(
`[TmuxBackend] Showed pane ${paneId} in ${targetWindowOrPane}`,
)
// Reapply main-vertical layout with leader at 30%
await runTmux(['select-layout', '-t', targetWindowOrPane, 'main-vertical'])
// Get the first pane (leader) and resize to 30%
const panesResult = await runTmux([
'list-panes',
'-t',
targetWindowOrPane,
'-F',
'#{pane_id}',
])
const panes = panesResult.stdout.trim().split('\n').filter(Boolean)
if (panes[0]) {
await runTmux(['resize-pane', '-t', panes[0], '-x', '30%'])
}
return true
}
// Private helper methods
/**
* Gets the leader's pane ID.
* Uses the TMUX_PANE env var captured at module load to ensure we always
* get the leader's original pane, even if the user has switched panes.
*/
private async getCurrentPaneId(): Promise<string | null> {
// Use the pane ID captured at startup (from TMUX_PANE env var)
const leaderPane = getLeaderPaneId()
if (leaderPane) {
return leaderPane
}
// Fallback to dynamic query (shouldn't happen if we're inside tmux)
const result = await execFileNoThrow(TMUX_COMMAND, [
'display-message',
'-p',
'#{pane_id}',
])
if (result.code !== 0) {
logForDebugging(
`[TmuxBackend] Failed to get current pane ID (exit ${result.code}): ${result.stderr}`,
)
return null
}
return result.stdout.trim()
}
/**
* Gets the leader's window target (session:window format).
* Uses the leader's pane ID to query for its window, ensuring we get the
* correct window even if the user has switched to a different window.
* Caches the result since the leader's window won't change.
*/
private async getCurrentWindowTarget(): Promise<string | null> {
// Return cached value if available
if (cachedLeaderWindowTarget) {
return cachedLeaderWindowTarget
}
// Build the command - use -t to target the leader's pane specifically
const leaderPane = getLeaderPaneId()
const args = ['display-message']
if (leaderPane) {
args.push('-t', leaderPane)
}
args.push('-p', '#{session_name}:#{window_index}')
const result = await execFileNoThrow(TMUX_COMMAND, args)
if (result.code !== 0) {
logForDebugging(
`[TmuxBackend] Failed to get current window target (exit ${result.code}): ${result.stderr}`,
)
return null
}
cachedLeaderWindowTarget = result.stdout.trim()
return cachedLeaderWindowTarget
}
/**
* Gets the number of panes in a window.
*/
private async getCurrentWindowPaneCount(
windowTarget?: string,
useSwarmSocket = false,
): Promise<number | null> {
const target = windowTarget || (await this.getCurrentWindowTarget())
if (!target) {
return null
}
const args = ['list-panes', '-t', target, '-F', '#{pane_id}']
const result = useSwarmSocket
? await runTmuxInSwarm(args)
: await runTmuxInUserSession(args)
if (result.code !== 0) {
logError(
new Error(
`[TmuxBackend] Failed to get pane count for ${target} (exit ${result.code}): ${result.stderr}`,
),
)
return null
}
return count(result.stdout.trim().split('\n'), Boolean)
}
/**
* Checks if a tmux session exists in the swarm socket.
*/
private async hasSessionInSwarm(sessionName: string): Promise<boolean> {
const result = await runTmuxInSwarm(['has-session', '-t', sessionName])
return result.code === 0
}
/**
* Creates the swarm session with a single window for teammates when running outside tmux.
*/
private async createExternalSwarmSession(): Promise<{
windowTarget: string
paneId: string
}> {
const sessionExists = await this.hasSessionInSwarm(SWARM_SESSION_NAME)
if (!sessionExists) {
const result = await runTmuxInSwarm([
'new-session',
'-d',
'-s',
SWARM_SESSION_NAME,
'-n',
SWARM_VIEW_WINDOW_NAME,
'-P',
'-F',
'#{pane_id}',
])
if (result.code !== 0) {
throw new Error(
`Failed to create swarm session: ${result.stderr || 'Unknown error'}`,
)
}
const paneId = result.stdout.trim()
const windowTarget = `${SWARM_SESSION_NAME}:${SWARM_VIEW_WINDOW_NAME}`
logForDebugging(
`[TmuxBackend] Created external swarm session with window ${windowTarget}, pane ${paneId}`,
)
return { windowTarget, paneId }
}
// Session exists, check if swarm-view window exists
const listResult = await runTmuxInSwarm([
'list-windows',
'-t',
SWARM_SESSION_NAME,
'-F',
'#{window_name}',
])
const windows = listResult.stdout.trim().split('\n').filter(Boolean)
const windowTarget = `${SWARM_SESSION_NAME}:${SWARM_VIEW_WINDOW_NAME}`
if (windows.includes(SWARM_VIEW_WINDOW_NAME)) {
const paneResult = await runTmuxInSwarm([
'list-panes',
'-t',
windowTarget,
'-F',
'#{pane_id}',
])
const panes = paneResult.stdout.trim().split('\n').filter(Boolean)
return { windowTarget, paneId: panes[0] || '' }
}
// Create the swarm-view window
const createResult = await runTmuxInSwarm([
'new-window',
'-t',
SWARM_SESSION_NAME,
'-n',
SWARM_VIEW_WINDOW_NAME,
'-P',
'-F',
'#{pane_id}',
])
if (createResult.code !== 0) {
throw new Error(
`Failed to create swarm-view window: ${createResult.stderr || 'Unknown error'}`,
)
}
return { windowTarget, paneId: createResult.stdout.trim() }
}
/**
* Creates a teammate pane when running inside tmux (with leader).
*/
private async createTeammatePaneWithLeader(
teammateName: string,
teammateColor: AgentColorName,
): Promise<CreatePaneResult> {
const currentPaneId = await this.getCurrentPaneId()
const windowTarget = await this.getCurrentWindowTarget()
if (!currentPaneId || !windowTarget) {
throw new Error('Could not determine current tmux pane/window')
}
const paneCount = await this.getCurrentWindowPaneCount(windowTarget)
if (paneCount === null) {
throw new Error('Could not determine pane count for current window')
}
const isFirstTeammate = paneCount === 1
let splitResult
if (isFirstTeammate) {
// First teammate: split horizontally from the leader pane
splitResult = await execFileNoThrow(TMUX_COMMAND, [
'split-window',
'-t',
currentPaneId,
'-h',
'-l',
'70%',
'-P',
'-F',
'#{pane_id}',
])
} else {
// Additional teammates: split from an existing teammate pane
const listResult = await execFileNoThrow(TMUX_COMMAND, [
'list-panes',
'-t',
windowTarget,
'-F',
'#{pane_id}',
])
const panes = listResult.stdout.trim().split('\n').filter(Boolean)
const teammatePanes = panes.slice(1)
const teammateCount = teammatePanes.length
const splitVertically = teammateCount % 2 === 1
const targetPaneIndex = Math.floor((teammateCount - 1) / 2)
const targetPane =
teammatePanes[targetPaneIndex] ||
teammatePanes[teammatePanes.length - 1]
splitResult = await execFileNoThrow(TMUX_COMMAND, [
'split-window',
'-t',
targetPane!,
splitVertically ? '-v' : '-h',
'-P',
'-F',
'#{pane_id}',
])
}
if (splitResult.code !== 0) {
throw new Error(`Failed to create teammate pane: ${splitResult.stderr}`)
}
const paneId = splitResult.stdout.trim()
logForDebugging(
`[TmuxBackend] Created teammate pane for ${teammateName}: ${paneId}`,
)
await this.setPaneBorderColor(paneId, teammateColor)
await this.setPaneTitle(paneId, teammateName, teammateColor)
await this.rebalancePanesWithLeader(windowTarget)
// Wait for shell to initialize before returning, so commands can be sent immediately
await waitForPaneShellReady()
return { paneId, isFirstTeammate }
}
/**
* Creates a teammate pane when running outside tmux (no leader in tmux).
*/
private async createTeammatePaneExternal(
teammateName: string,
teammateColor: AgentColorName,
): Promise<CreatePaneResult> {
const { windowTarget, paneId: firstPaneId } =
await this.createExternalSwarmSession()
const paneCount = await this.getCurrentWindowPaneCount(windowTarget, true)
if (paneCount === null) {
throw new Error('Could not determine pane count for swarm window')
}
const isFirstTeammate = !firstPaneUsedForExternal && paneCount === 1
let paneId: string
if (isFirstTeammate) {
paneId = firstPaneId
firstPaneUsedForExternal = true
logForDebugging(
`[TmuxBackend] Using initial pane for first teammate ${teammateName}: ${paneId}`,
)
await this.enablePaneBorderStatus(windowTarget, true)
} else {
const listResult = await runTmuxInSwarm([
'list-panes',
'-t',
windowTarget,
'-F',
'#{pane_id}',
])
const panes = listResult.stdout.trim().split('\n').filter(Boolean)
const teammateCount = panes.length
const splitVertically = teammateCount % 2 === 1
const targetPaneIndex = Math.floor((teammateCount - 1) / 2)
const targetPane = panes[targetPaneIndex] || panes[panes.length - 1]
const splitResult = await runTmuxInSwarm([
'split-window',
'-t',
targetPane!,
splitVertically ? '-v' : '-h',
'-P',
'-F',
'#{pane_id}',
])
if (splitResult.code !== 0) {
throw new Error(`Failed to create teammate pane: ${splitResult.stderr}`)
}
paneId = splitResult.stdout.trim()
logForDebugging(
`[TmuxBackend] Created teammate pane for ${teammateName}: ${paneId}`,
)
}
await this.setPaneBorderColor(paneId, teammateColor, true)
await this.setPaneTitle(paneId, teammateName, teammateColor, true)
await this.rebalancePanesTiled(windowTarget)
// Wait for shell to initialize before returning, so commands can be sent immediately
await waitForPaneShellReady()
return { paneId, isFirstTeammate }
}
/**
* Rebalances panes in a window with a leader.
*/
private async rebalancePanesWithLeader(windowTarget: string): Promise<void> {
const listResult = await runTmuxInUserSession([
'list-panes',
'-t',
windowTarget,
'-F',
'#{pane_id}',
])
const panes = listResult.stdout.trim().split('\n').filter(Boolean)
if (panes.length <= 2) {
return
}
await runTmuxInUserSession([
'select-layout',
'-t',
windowTarget,
'main-vertical',
])
const leaderPane = panes[0]
await runTmuxInUserSession(['resize-pane', '-t', leaderPane!, '-x', '30%'])
logForDebugging(
`[TmuxBackend] Rebalanced ${panes.length - 1} teammate panes with leader`,
)
}
/**
* Rebalances panes in a window without a leader (tiled layout).
*/
private async rebalancePanesTiled(windowTarget: string): Promise<void> {
const listResult = await runTmuxInSwarm([
'list-panes',
'-t',
windowTarget,
'-F',
'#{pane_id}',
])
const panes = listResult.stdout.trim().split('\n').filter(Boolean)
if (panes.length <= 1) {
return
}
await runTmuxInSwarm(['select-layout', '-t', windowTarget, 'tiled'])
logForDebugging(
`[TmuxBackend] Rebalanced ${panes.length} teammate panes with tiled layout`,
)
}
}
// Register the backend with the registry when this module is imported.
// This side effect is intentional - the registry needs backends to self-register to avoid circular dependencies.
// eslint-disable-next-line custom-rules/no-top-level-side-effects
registerTmuxBackend(TmuxBackend)
+128
View File
@@ -0,0 +1,128 @@
import { env } from '../../../utils/env.js'
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
import { TMUX_COMMAND } from '../constants.js'
/**
* Captured at module load time to detect if the user started Claude from within tmux.
* Shell.ts may override TMUX env var later, so we capture the original value.
*/
// eslint-disable-next-line custom-rules/no-process-env-top-level
const ORIGINAL_USER_TMUX = process.env.TMUX
/**
* Captured at module load time to get the leader's tmux pane ID.
* TMUX_PANE is set by tmux to the pane ID (e.g., %0, %1) when a process runs inside tmux.
* We capture this at startup so we always know the leader's original pane, even if
* the user switches to a different pane later.
*/
// eslint-disable-next-line custom-rules/no-process-env-top-level
const ORIGINAL_TMUX_PANE = process.env.TMUX_PANE
/** Cached result for isInsideTmux */
let isInsideTmuxCached: boolean | null = null
/** Cached result for isInITerm2 */
let isInITerm2Cached: boolean | null = null
/**
* Checks if we're currently running inside a tmux session (synchronous version).
* Uses the original TMUX value captured at module load, not process.env.TMUX,
* because Shell.ts overrides TMUX when Claude's socket is initialized.
*
* IMPORTANT: We ONLY check the TMUX env var. We do NOT run `tmux display-message`
* as a fallback because that command will succeed if ANY tmux server is running
* on the system, not just if THIS process is inside tmux.
*/
export function isInsideTmuxSync(): boolean {
return !!ORIGINAL_USER_TMUX
}
/**
* Checks if we're currently running inside a tmux session.
* Uses the original TMUX value captured at module load, not process.env.TMUX,
* because Shell.ts overrides TMUX when Claude's socket is initialized.
* Caches the result since this won't change during the process lifetime.
*
* IMPORTANT: We ONLY check the TMUX env var. We do NOT run `tmux display-message`
* as a fallback because that command will succeed if ANY tmux server is running
* on the system, not just if THIS process is inside tmux.
*/
export async function isInsideTmux(): Promise<boolean> {
if (isInsideTmuxCached !== null) {
return isInsideTmuxCached
}
// Check the original TMUX env var (captured at module load)
// This tells us if the user started Claude from within their tmux session
// If TMUX is not set, we are NOT inside tmux - period.
isInsideTmuxCached = !!ORIGINAL_USER_TMUX
return isInsideTmuxCached
}
/**
* Gets the leader's tmux pane ID captured at module load.
* Returns null if not running inside tmux.
*/
export function getLeaderPaneId(): string | null {
return ORIGINAL_TMUX_PANE || null
}
/**
* Checks if tmux is available on the system (installed and in PATH).
*/
export async function isTmuxAvailable(): Promise<boolean> {
const result = await execFileNoThrow(TMUX_COMMAND, ['-V'])
return result.code === 0
}
/**
* Checks if we're currently running inside iTerm2.
* Uses multiple detection methods:
* 1. TERM_PROGRAM env var set to "iTerm.app"
* 2. ITERM_SESSION_ID env var is present
* 3. env.terminal detection from utils/env.ts
*
* Caches the result since this won't change during the process lifetime.
*
* Note: iTerm2 backend uses AppleScript (osascript) which is built into macOS,
* so no external CLI tool installation is required.
*/
export function isInITerm2(): boolean {
if (isInITerm2Cached !== null) {
return isInITerm2Cached
}
// Check multiple indicators for iTerm2
const termProgram = process.env.TERM_PROGRAM
const hasItermSessionId = !!process.env.ITERM_SESSION_ID
const terminalIsITerm = env.terminal === 'iTerm.app'
isInITerm2Cached =
termProgram === 'iTerm.app' || hasItermSessionId || terminalIsITerm
return isInITerm2Cached
}
/**
* The it2 CLI command name.
*/
export const IT2_COMMAND = 'it2'
/**
* Checks if the it2 CLI tool is available AND can reach the iTerm2 Python API.
* Uses 'session list' (not '--version') because --version succeeds even when
* the Python API is disabled in iTerm2 preferences — which would cause
* 'session split' to fail later with no fallback.
*/
export async function isIt2CliAvailable(): Promise<boolean> {
const result = await execFileNoThrow(IT2_COMMAND, ['session', 'list'])
return result.code === 0
}
/**
* Resets all cached detection results. Used for testing.
*/
export function resetDetectionCache(): void {
isInsideTmuxCached = null
isInITerm2Cached = null
}
+245
View File
@@ -0,0 +1,245 @@
import { homedir } from 'os'
import { getGlobalConfig, saveGlobalConfig } from '../../../utils/config.js'
import { logForDebugging } from '../../../utils/debug.js'
import {
execFileNoThrow,
execFileNoThrowWithCwd,
} from '../../../utils/execFileNoThrow.js'
import { logError } from '../../../utils/log.js'
/**
* Package manager types for installing it2.
* Listed in order of preference.
*/
export type PythonPackageManager = 'uvx' | 'pipx' | 'pip'
/**
* Result of attempting to install it2.
*/
export type It2InstallResult = {
success: boolean
error?: string
packageManager?: PythonPackageManager
}
/**
* Result of verifying it2 setup.
*/
export type It2VerifyResult = {
success: boolean
error?: string
needsPythonApiEnabled?: boolean
}
/**
* Detects which Python package manager is available on the system.
* Checks in order of preference: uvx, pipx, pip.
*
* @returns The detected package manager, or null if none found
*/
export async function detectPythonPackageManager(): Promise<PythonPackageManager | null> {
// Check uv first (preferred for isolated environments)
// We check for 'uv' since 'uv tool install' is the install command
const uvResult = await execFileNoThrow('which', ['uv'])
if (uvResult.code === 0) {
logForDebugging('[it2Setup] Found uv (will use uv tool install)')
return 'uvx' // Keep the type name for compatibility
}
// Check pipx (good for isolated environments)
const pipxResult = await execFileNoThrow('which', ['pipx'])
if (pipxResult.code === 0) {
logForDebugging('[it2Setup] Found pipx package manager')
return 'pipx'
}
// Check pip (fallback)
const pipResult = await execFileNoThrow('which', ['pip'])
if (pipResult.code === 0) {
logForDebugging('[it2Setup] Found pip package manager')
return 'pip'
}
// Also check pip3
const pip3Result = await execFileNoThrow('which', ['pip3'])
if (pip3Result.code === 0) {
logForDebugging('[it2Setup] Found pip3 package manager')
return 'pip'
}
logForDebugging('[it2Setup] No Python package manager found')
return null
}
/**
* Checks if the it2 CLI tool is installed and accessible.
*
* @returns true if it2 is available
*/
export async function isIt2CliAvailable(): Promise<boolean> {
const result = await execFileNoThrow('which', ['it2'])
return result.code === 0
}
/**
* Installs the it2 CLI tool using the detected package manager.
*
* @param packageManager - The package manager to use for installation
* @returns Result indicating success or failure
*/
export async function installIt2(
packageManager: PythonPackageManager,
): Promise<It2InstallResult> {
logForDebugging(`[it2Setup] Installing it2 using ${packageManager}`)
// Run from home directory to avoid reading project-level pip.conf/uv.toml
// which could be maliciously crafted to redirect to an attacker's PyPI server
let result
switch (packageManager) {
case 'uvx':
// uv tool install it2 installs it globally in isolated env
// (uvx is for running, uv tool install is for installing)
result = await execFileNoThrowWithCwd('uv', ['tool', 'install', 'it2'], {
cwd: homedir(),
})
break
case 'pipx':
result = await execFileNoThrowWithCwd('pipx', ['install', 'it2'], {
cwd: homedir(),
})
break
case 'pip':
// Use --user to install without sudo
result = await execFileNoThrowWithCwd(
'pip',
['install', '--user', 'it2'],
{ cwd: homedir() },
)
if (result.code !== 0) {
// Try pip3 if pip fails
result = await execFileNoThrowWithCwd(
'pip3',
['install', '--user', 'it2'],
{ cwd: homedir() },
)
}
break
}
if (result.code !== 0) {
const error = result.stderr || 'Unknown installation error'
logError(new Error(`[it2Setup] Failed to install it2: ${error}`))
return {
success: false,
error,
packageManager,
}
}
logForDebugging('[it2Setup] it2 installed successfully')
return {
success: true,
packageManager,
}
}
/**
* Verifies that it2 is properly configured and can communicate with iTerm2.
* This tests the Python API connection by running a simple it2 command.
*
* @returns Result indicating success or the specific failure reason
*/
export async function verifyIt2Setup(): Promise<It2VerifyResult> {
logForDebugging('[it2Setup] Verifying it2 setup...')
// First check if it2 is installed
const installed = await isIt2CliAvailable()
if (!installed) {
return {
success: false,
error: 'it2 CLI is not installed or not in PATH',
}
}
// Try to list sessions - this tests the Python API connection
const result = await execFileNoThrow('it2', ['session', 'list'])
if (result.code !== 0) {
const stderr = result.stderr.toLowerCase()
// Check for common Python API errors
if (
stderr.includes('api') ||
stderr.includes('python') ||
stderr.includes('connection refused') ||
stderr.includes('not enabled')
) {
logForDebugging('[it2Setup] Python API not enabled in iTerm2')
return {
success: false,
error: 'Python API not enabled in iTerm2 preferences',
needsPythonApiEnabled: true,
}
}
return {
success: false,
error: result.stderr || 'Failed to communicate with iTerm2',
}
}
logForDebugging('[it2Setup] it2 setup verified successfully')
return {
success: true,
}
}
/**
* Returns instructions for enabling the Python API in iTerm2.
*/
export function getPythonApiInstructions(): string[] {
return [
'Almost done! Enable the Python API in iTerm2:',
'',
' iTerm2 → Settings → General → Magic → Enable Python API',
'',
'After enabling, you may need to restart iTerm2.',
]
}
/**
* Marks that it2 setup has been completed successfully.
* This prevents showing the setup prompt again.
*/
export function markIt2SetupComplete(): void {
const config = getGlobalConfig()
if (config.iterm2It2SetupComplete !== true) {
saveGlobalConfig(current => ({
...current,
iterm2It2SetupComplete: true,
}))
logForDebugging('[it2Setup] Marked it2 setup as complete')
}
}
/**
* Marks that the user prefers to use tmux over iTerm2 split panes.
* This prevents showing the setup prompt when in iTerm2.
*/
export function setPreferTmuxOverIterm2(prefer: boolean): void {
const config = getGlobalConfig()
if (config.preferTmuxOverIterm2 !== prefer) {
saveGlobalConfig(current => ({
...current,
preferTmuxOverIterm2: prefer,
}))
logForDebugging(`[it2Setup] Set preferTmuxOverIterm2 = ${prefer}`)
}
}
/**
* Checks if the user prefers tmux over iTerm2 split panes.
*/
export function getPreferTmuxOverIterm2(): boolean {
return getGlobalConfig().preferTmuxOverIterm2 === true
}
+464
View File
@@ -0,0 +1,464 @@
import { getIsNonInteractiveSession } from '../../../bootstrap/state.js'
import { logForDebugging } from '../../../utils/debug.js'
import { getPlatform } from '../../../utils/platform.js'
import {
isInITerm2,
isInsideTmux,
isInsideTmuxSync,
isIt2CliAvailable,
isTmuxAvailable,
} from './detection.js'
import { createInProcessBackend } from './InProcessBackend.js'
import { getPreferTmuxOverIterm2 } from './it2Setup.js'
import { createPaneBackendExecutor } from './PaneBackendExecutor.js'
import { getTeammateModeFromSnapshot } from './teammateModeSnapshot.js'
import type {
BackendDetectionResult,
PaneBackend,
PaneBackendType,
TeammateExecutor,
} from './types.js'
/**
* Cached backend detection result.
* Once detected, the backend selection is fixed for the lifetime of the process.
*/
let cachedBackend: PaneBackend | null = null
/**
* Cached detection result with additional metadata.
*/
let cachedDetectionResult: BackendDetectionResult | null = null
/**
* Flag to track if backends have been registered.
*/
let backendsRegistered = false
/**
* Cached in-process backend instance.
*/
let cachedInProcessBackend: TeammateExecutor | null = null
/**
* Cached pane backend executor instance.
* Wraps the detected PaneBackend to provide TeammateExecutor interface.
*/
let cachedPaneBackendExecutor: TeammateExecutor | null = null
/**
* Tracks whether spawn fell back to in-process mode because no pane backend
* was available (e.g., iTerm2 without it2 or tmux installed). Once set,
* isInProcessEnabled() returns true so UI (banner, teams menu) reflects reality.
*/
let inProcessFallbackActive = false
/**
* Placeholder for TmuxBackend - will be replaced with actual implementation.
* This allows the registry to compile before the backend implementations exist.
*/
let TmuxBackendClass: (new () => PaneBackend) | null = null
/**
* Placeholder for ITermBackend - will be replaced with actual implementation.
* This allows the registry to compile before the backend implementations exist.
*/
let ITermBackendClass: (new () => PaneBackend) | null = null
/**
* Ensures backend classes are dynamically imported so getBackendByType() can
* construct them. Unlike detectAndGetBackend(), this never spawns subprocesses
* and never throws — it's the lightweight option when you only need class
* registration (e.g., killing a pane by its stored backendType).
*/
export async function ensureBackendsRegistered(): Promise<void> {
if (backendsRegistered) return
await import('./TmuxBackend.js')
await import('./ITermBackend.js')
backendsRegistered = true
}
/**
* Registers the TmuxBackend class with the registry.
* Called by TmuxBackend.ts to avoid circular dependencies.
*/
export function registerTmuxBackend(backendClass: new () => PaneBackend): void {
TmuxBackendClass = backendClass
}
/**
* Registers the ITermBackend class with the registry.
* Called by ITermBackend.ts to avoid circular dependencies.
*/
export function registerITermBackend(
backendClass: new () => PaneBackend,
): void {
logForDebugging(
`[registry] registerITermBackend called, class=${backendClass?.name || 'undefined'}`,
)
ITermBackendClass = backendClass
}
/**
* Creates a TmuxBackend instance.
* Throws if TmuxBackend hasn't been registered.
*/
function createTmuxBackend(): PaneBackend {
if (!TmuxBackendClass) {
throw new Error(
'TmuxBackend not registered. Import TmuxBackend.ts before using the registry.',
)
}
return new TmuxBackendClass()
}
/**
* Creates an ITermBackend instance.
* Throws if ITermBackend hasn't been registered.
*/
function createITermBackend(): PaneBackend {
if (!ITermBackendClass) {
throw new Error(
'ITermBackend not registered. Import ITermBackend.ts before using the registry.',
)
}
return new ITermBackendClass()
}
/**
* Detection priority flow:
* 1. If inside tmux, always use tmux (even in iTerm2)
* 2. If in iTerm2 with it2 available, use iTerm2 backend
* 3. If in iTerm2 without it2, return result indicating setup needed
* 4. If tmux available, use tmux (creates external session)
* 5. Otherwise, throw error with instructions
*/
export async function detectAndGetBackend(): Promise<BackendDetectionResult> {
// Ensure backends are registered before detection
await ensureBackendsRegistered()
// Return cached result if available
if (cachedDetectionResult) {
logForDebugging(
`[BackendRegistry] Using cached backend: ${cachedDetectionResult.backend.type}`,
)
return cachedDetectionResult
}
logForDebugging('[BackendRegistry] Starting backend detection...')
// Check all environment conditions upfront for logging
const insideTmux = await isInsideTmux()
const inITerm2 = isInITerm2()
logForDebugging(
`[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}`,
)
// Priority 1: If inside tmux, always use tmux
if (insideTmux) {
logForDebugging(
'[BackendRegistry] Selected: tmux (running inside tmux session)',
)
const backend = createTmuxBackend()
cachedBackend = backend
cachedDetectionResult = {
backend,
isNative: true,
needsIt2Setup: false,
}
return cachedDetectionResult
}
// Priority 2: If in iTerm2, try to use native panes
if (inITerm2) {
// Check if user previously chose to prefer tmux over iTerm2
const preferTmux = getPreferTmuxOverIterm2()
if (preferTmux) {
logForDebugging(
'[BackendRegistry] User prefers tmux over iTerm2, skipping iTerm2 detection',
)
} else {
const it2Available = await isIt2CliAvailable()
logForDebugging(
`[BackendRegistry] iTerm2 detected, it2 CLI available: ${it2Available}`,
)
if (it2Available) {
logForDebugging(
'[BackendRegistry] Selected: iterm2 (native iTerm2 with it2 CLI)',
)
const backend = createITermBackend()
cachedBackend = backend
cachedDetectionResult = {
backend,
isNative: true,
needsIt2Setup: false,
}
return cachedDetectionResult
}
}
// In iTerm2 but it2 not available - check if tmux can be used as fallback
const tmuxAvailable = await isTmuxAvailable()
logForDebugging(
`[BackendRegistry] it2 not available, tmux available: ${tmuxAvailable}`,
)
if (tmuxAvailable) {
logForDebugging(
'[BackendRegistry] Selected: tmux (fallback in iTerm2, it2 setup recommended)',
)
// Return tmux as fallback. Only signal it2 setup if the user hasn't already
// chosen to prefer tmux - otherwise they'd be re-prompted on every spawn.
const backend = createTmuxBackend()
cachedBackend = backend
cachedDetectionResult = {
backend,
isNative: false,
needsIt2Setup: !preferTmux,
}
return cachedDetectionResult
}
// In iTerm2 with no it2 and no tmux - it2 setup is required
logForDebugging(
'[BackendRegistry] ERROR: iTerm2 detected but no it2 CLI and no tmux',
)
throw new Error(
'iTerm2 detected but it2 CLI not installed. Install it2 with: pip install it2',
)
}
// Priority 3: Fall back to tmux external session
const tmuxAvailable = await isTmuxAvailable()
logForDebugging(
`[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`,
)
if (tmuxAvailable) {
logForDebugging('[BackendRegistry] Selected: tmux (external session mode)')
const backend = createTmuxBackend()
cachedBackend = backend
cachedDetectionResult = {
backend,
isNative: false,
needsIt2Setup: false,
}
return cachedDetectionResult
}
// No backend available - tmux is not installed
logForDebugging('[BackendRegistry] ERROR: No pane backend available')
throw new Error(getTmuxInstallInstructions())
}
/**
* Returns platform-specific tmux installation instructions.
*/
function getTmuxInstallInstructions(): string {
const platform = getPlatform()
switch (platform) {
case 'macos':
return `To use agent swarms, install tmux:
brew install tmux
Then start a tmux session with: tmux new-session -s claude`
case 'linux':
case 'wsl':
return `To use agent swarms, install tmux:
sudo apt install tmux # Ubuntu/Debian
sudo dnf install tmux # Fedora/RHEL
Then start a tmux session with: tmux new-session -s claude`
case 'windows':
return `To use agent swarms, you need tmux which requires WSL (Windows Subsystem for Linux).
Install WSL first, then inside WSL run:
sudo apt install tmux
Then start a tmux session with: tmux new-session -s claude`
default:
return `To use agent swarms, install tmux using your system's package manager.
Then start a tmux session with: tmux new-session -s claude`
}
}
/**
* Gets a backend by explicit type selection.
* Useful for testing or when the user has a preference.
*
* @param type - The backend type to get
* @returns The requested backend instance
* @throws If the requested backend type is not available
*/
export function getBackendByType(type: PaneBackendType): PaneBackend {
switch (type) {
case 'tmux':
return createTmuxBackend()
case 'iterm2':
return createITermBackend()
}
}
/**
* Gets the currently cached backend, if any.
* Returns null if no backend has been detected yet.
*/
export function getCachedBackend(): PaneBackend | null {
return cachedBackend
}
/**
* Gets the cached backend detection result, if any.
* Returns null if detection hasn't run yet.
* Use `isNative` to check if teammates are visible in native panes.
*/
export function getCachedDetectionResult(): BackendDetectionResult | null {
return cachedDetectionResult
}
/**
* Records that spawn fell back to in-process mode because no pane backend
* was available. After this, isInProcessEnabled() returns true and subsequent
* spawns short-circuit to in-process (the environment won't change mid-session).
*/
export function markInProcessFallback(): void {
logForDebugging('[BackendRegistry] Marking in-process fallback as active')
inProcessFallbackActive = true
}
/**
* Gets the teammate mode for this session.
* Returns the session snapshot captured at startup, ignoring runtime config changes.
*/
function getTeammateMode(): 'auto' | 'tmux' | 'in-process' {
return getTeammateModeFromSnapshot()
}
/**
* Checks if in-process teammate execution is enabled.
*
* Logic:
* - If teammateMode is 'in-process', always enabled
* - If teammateMode is 'tmux', always disabled (use pane backend)
* - If teammateMode is 'auto' (default), check environment:
* - If inside tmux, use pane backend (return false)
* - If inside iTerm2, use pane backend (return false) - detectAndGetBackend()
* will pick ITermBackend if it2 is available, or fall back to tmux
* - Otherwise, use in-process (return true)
*/
export function isInProcessEnabled(): boolean {
// Force in-process mode for non-interactive sessions (-p mode)
// since tmux-based teammates don't make sense without a terminal UI
if (getIsNonInteractiveSession()) {
logForDebugging(
'[BackendRegistry] isInProcessEnabled: true (non-interactive session)',
)
return true
}
const mode = getTeammateMode()
let enabled: boolean
if (mode === 'in-process') {
enabled = true
} else if (mode === 'tmux') {
enabled = false
} else {
// 'auto' mode - if a prior spawn fell back to in-process because no pane
// backend was available, stay in-process (scoped to auto mode only so a
// mid-session Settings change to explicit 'tmux' still takes effect).
if (inProcessFallbackActive) {
logForDebugging(
'[BackendRegistry] isInProcessEnabled: true (fallback after pane backend unavailable)',
)
return true
}
// Check if a pane backend environment is available
// If inside tmux or iTerm2, use pane backend; otherwise use in-process
const insideTmux = isInsideTmuxSync()
const inITerm2 = isInITerm2()
enabled = !insideTmux && !inITerm2
}
logForDebugging(
`[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode}, insideTmux=${isInsideTmuxSync()}, inITerm2=${isInITerm2()})`,
)
return enabled
}
/**
* Returns the resolved teammate executor mode for this session.
* Unlike getTeammateModeFromSnapshot which may return 'auto', this returns
* what 'auto' actually resolves to given the current environment.
*/
export function getResolvedTeammateMode(): 'in-process' | 'tmux' {
return isInProcessEnabled() ? 'in-process' : 'tmux'
}
/**
* Gets the InProcessBackend instance.
* Creates and caches the instance on first call.
*/
export function getInProcessBackend(): TeammateExecutor {
if (!cachedInProcessBackend) {
cachedInProcessBackend = createInProcessBackend()
}
return cachedInProcessBackend
}
/**
* Gets a TeammateExecutor for spawning teammates.
*
* Returns either:
* - InProcessBackend when preferInProcess is true and in-process mode is enabled
* - PaneBackendExecutor wrapping the detected pane backend otherwise
*
* This provides a unified TeammateExecutor interface regardless of execution mode,
* allowing callers to spawn and manage teammates without knowing the backend details.
*
* @param preferInProcess - If true and in-process is enabled, returns InProcessBackend.
* Otherwise returns PaneBackendExecutor.
* @returns TeammateExecutor instance
*/
export async function getTeammateExecutor(
preferInProcess: boolean = false,
): Promise<TeammateExecutor> {
if (preferInProcess && isInProcessEnabled()) {
logForDebugging('[BackendRegistry] Using in-process executor')
return getInProcessBackend()
}
// Return pane backend executor
logForDebugging('[BackendRegistry] Using pane backend executor')
return getPaneBackendExecutor()
}
/**
* Gets the PaneBackendExecutor instance.
* Creates and caches the instance on first call, detecting the appropriate pane backend.
*/
async function getPaneBackendExecutor(): Promise<TeammateExecutor> {
if (!cachedPaneBackendExecutor) {
const detection = await detectAndGetBackend()
cachedPaneBackendExecutor = createPaneBackendExecutor(detection.backend)
logForDebugging(
`[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`,
)
}
return cachedPaneBackendExecutor
}
/**
* Resets the backend detection cache.
* Used for testing to allow re-detection.
*/
export function resetBackendDetection(): void {
cachedBackend = null
cachedDetectionResult = null
cachedInProcessBackend = null
cachedPaneBackendExecutor = null
backendsRegistered = false
inProcessFallbackActive = false
}
@@ -0,0 +1,87 @@
/**
* Teammate mode snapshot module.
*
* Captures the teammate mode at session startup, following the same pattern
* as hooksConfigSnapshot.ts. This ensures that runtime config changes don't
* affect the teammate mode for the current session.
*/
import { getGlobalConfig } from '../../../utils/config.js'
import { logForDebugging } from '../../../utils/debug.js'
import { logError } from '../../../utils/log.js'
export type TeammateMode = 'auto' | 'tmux' | 'in-process'
// Module-level variable to hold the captured mode at startup
let initialTeammateMode: TeammateMode | null = null
// CLI override (set before capture if --teammate-mode is provided)
let cliTeammateModeOverride: TeammateMode | null = null
/**
* Set the CLI override for teammate mode.
* Must be called before captureTeammateModeSnapshot().
*/
export function setCliTeammateModeOverride(mode: TeammateMode): void {
cliTeammateModeOverride = mode
}
/**
* Get the current CLI override, if any.
* Returns null if no CLI override was set.
*/
export function getCliTeammateModeOverride(): TeammateMode | null {
return cliTeammateModeOverride
}
/**
* Clear the CLI override and update the snapshot to the new mode.
* Called when user changes the setting in the UI, allowing their change to take effect.
*
* @param newMode - The new mode the user selected (passed directly to avoid race condition)
*/
export function clearCliTeammateModeOverride(newMode: TeammateMode): void {
cliTeammateModeOverride = null
initialTeammateMode = newMode
logForDebugging(
`[TeammateModeSnapshot] CLI override cleared, new mode: ${newMode}`,
)
}
/**
* Capture the teammate mode at session startup.
* Called early in main.tsx, after CLI args are parsed.
* CLI override takes precedence over config.
*/
export function captureTeammateModeSnapshot(): void {
if (cliTeammateModeOverride) {
initialTeammateMode = cliTeammateModeOverride
logForDebugging(
`[TeammateModeSnapshot] Captured from CLI override: ${initialTeammateMode}`,
)
} else {
const config = getGlobalConfig()
initialTeammateMode = config.teammateMode ?? 'auto'
logForDebugging(
`[TeammateModeSnapshot] Captured from config: ${initialTeammateMode}`,
)
}
}
/**
* Get the teammate mode for this session.
* Returns the snapshot captured at startup, ignoring any runtime config changes.
*/
export function getTeammateModeFromSnapshot(): TeammateMode {
if (initialTeammateMode === null) {
// This indicates an initialization bug - capture should happen in setup()
logError(
new Error(
'getTeammateModeFromSnapshot called before capture - this indicates an initialization bug',
),
)
captureTeammateModeSnapshot()
}
// Fallback to 'auto' if somehow still null (shouldn't happen, but safe)
return initialTeammateMode ?? 'auto'
}
+311
View File
@@ -0,0 +1,311 @@
import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js'
/**
* Types of backends available for teammate execution.
* - 'tmux': Uses tmux for pane management (works in tmux or standalone)
* - 'iterm2': Uses iTerm2 native split panes via the it2 CLI
* - 'in-process': Runs teammate in the same Node.js process with isolated context
*/
export type BackendType = 'tmux' | 'iterm2' | 'in-process'
/**
* Subset of BackendType for pane-based backends only.
* Used in messages and types that specifically deal with terminal panes.
*/
export type PaneBackendType = 'tmux' | 'iterm2'
/**
* Opaque identifier for a pane managed by a backend.
* For tmux, this is the tmux pane ID (e.g., "%1").
* For iTerm2, this is the session ID returned by it2.
*/
export type PaneId = string
/**
* Result of creating a new teammate pane.
*/
export type CreatePaneResult = {
/** The pane ID for the newly created pane */
paneId: PaneId
/** Whether this is the first teammate pane (affects layout strategy) */
isFirstTeammate: boolean
}
/**
* Interface for pane management backends.
* Abstracts operations for creating and managing terminal panes
* for teammate visualization in swarm mode.
*/
export type PaneBackend = {
/** The type identifier for this backend */
readonly type: BackendType
/** Human-readable display name for this backend */
readonly displayName: string
/** Whether this backend supports hiding and showing panes */
readonly supportsHideShow: boolean
/**
* Checks if this backend is available on the system.
* For tmux: checks if tmux command exists.
* For iTerm2: checks if it2 CLI is installed and configured.
*/
isAvailable(): Promise<boolean>
/**
* Checks if we're currently running inside this backend's environment.
* For tmux: checks if we're in a tmux session.
* For iTerm2: checks if we're running in iTerm2.
*/
isRunningInside(): Promise<boolean>
/**
* Creates a new pane for a teammate in the swarm view.
* The backend handles layout strategy (with/without leader pane).
*
* @param name - The teammate's name for display
* @param color - The color to use for the pane border/title
* @returns The pane ID and whether this was the first teammate
*/
createTeammatePaneInSwarmView(
name: string,
color: AgentColorName,
): Promise<CreatePaneResult>
/**
* Sends a command to execute in a specific pane.
*
* @param paneId - The pane to send the command to
* @param command - The command string to execute
* @param useExternalSession - If true, uses external session socket (tmux-specific)
*/
sendCommandToPane(
paneId: PaneId,
command: string,
useExternalSession?: boolean,
): Promise<void>
/**
* Sets the border color for a pane.
*
* @param paneId - The pane to style
* @param color - The color to apply to the border
* @param useExternalSession - If true, uses external session socket (tmux-specific)
*/
setPaneBorderColor(
paneId: PaneId,
color: AgentColorName,
useExternalSession?: boolean,
): Promise<void>
/**
* Sets the title for a pane (displayed in pane border/header).
*
* @param paneId - The pane to title
* @param name - The title to display
* @param color - The color for the title text
* @param useExternalSession - If true, uses external session socket (tmux-specific)
*/
setPaneTitle(
paneId: PaneId,
name: string,
color: AgentColorName,
useExternalSession?: boolean,
): Promise<void>
/**
* Enables pane border status display (shows titles in borders).
*
* @param windowTarget - The window to enable status for (optional)
* @param useExternalSession - If true, uses external session socket (tmux-specific)
*/
enablePaneBorderStatus(
windowTarget?: string,
useExternalSession?: boolean,
): Promise<void>
/**
* Rebalances panes to achieve the desired layout.
*
* @param windowTarget - The window containing the panes
* @param hasLeader - Whether there's a leader pane (affects layout strategy)
*/
rebalancePanes(windowTarget: string, hasLeader: boolean): Promise<void>
/**
* Kills/closes a specific pane.
*
* @param paneId - The pane to kill
* @param useExternalSession - If true, uses external session socket (tmux-specific)
* @returns true if the pane was killed successfully, false otherwise
*/
killPane(paneId: PaneId, useExternalSession?: boolean): Promise<boolean>
/**
* Hides a pane by breaking it out into a hidden window.
* The pane remains running but is not visible in the main layout.
*
* @param paneId - The pane to hide
* @param useExternalSession - If true, uses external session socket (tmux-specific)
* @returns true if the pane was hidden successfully, false otherwise
*/
hidePane(paneId: PaneId, useExternalSession?: boolean): Promise<boolean>
/**
* Shows a previously hidden pane by joining it back into the main window.
*
* @param paneId - The pane to show
* @param targetWindowOrPane - The window or pane to join into
* @param useExternalSession - If true, uses external session socket (tmux-specific)
* @returns true if the pane was shown successfully, false otherwise
*/
showPane(
paneId: PaneId,
targetWindowOrPane: string,
useExternalSession?: boolean,
): Promise<boolean>
}
/**
* Result from backend detection.
*/
export type BackendDetectionResult = {
/** The backend that should be used */
backend: PaneBackend
/** Whether we're running inside the backend's native environment */
isNative: boolean
/** If iTerm2 is detected but it2 not installed, this will be true */
needsIt2Setup?: boolean
}
// =============================================================================
// In-Process Teammate Types
// =============================================================================
/**
* Identity fields for a teammate.
* This is a subset shared with TeammateContext (Task #4) to avoid circular deps.
* lifecycle-specialist defines the full TeammateContext with additional fields.
*/
export type TeammateIdentity = {
/** Agent name (e.g., "researcher", "tester") */
name: string
/** Team name this teammate belongs to */
teamName: string
/** Assigned color for UI differentiation */
color?: AgentColorName
/** Whether plan mode approval is required before implementation */
planModeRequired?: boolean
}
/**
* Configuration for spawning a teammate (any execution mode).
*/
export type TeammateSpawnConfig = TeammateIdentity & {
/** Initial prompt to send to the teammate */
prompt: string
/** Working directory for the teammate */
cwd: string
/** Model to use for this teammate */
model?: string
/** System prompt for this teammate (resolved from workflow config) */
systemPrompt?: string
/** How to apply the system prompt: 'replace' or 'append' to default */
systemPromptMode?: 'default' | 'replace' | 'append'
/** Optional git worktree path */
worktreePath?: string
/** Parent session ID (for context linking) */
parentSessionId: string
/** Tool permissions to grant this teammate */
permissions?: string[]
/** Whether this teammate can show permission prompts for unlisted tools.
* When false (default), unlisted tools are auto-denied. */
allowPermissionPrompts?: boolean
}
/**
* Result from spawning a teammate.
*/
export type TeammateSpawnResult = {
/** Whether spawn was successful */
success: boolean
/** Unique agent ID (format: agentName@teamName) */
agentId: string
/** Error message if spawn failed */
error?: string
/**
* Abort controller for lifecycle management (in-process only).
* Leader uses this to cancel/kill the teammate.
* For pane-based teammates, use kill() method instead.
*/
abortController?: AbortController
/**
* Task ID in AppState.tasks (in-process only).
* Used for UI rendering and progress tracking.
* agentId is the logical identifier; taskId is for AppState indexing.
*/
taskId?: string
/** Pane ID (pane-based only) */
paneId?: PaneId
}
/**
* Message to send to a teammate.
*/
export type TeammateMessage = {
/** Message content */
text: string
/** Sender agent ID */
from: string
/** Sender display color */
color?: string
/** Message timestamp (ISO string) */
timestamp?: string
/** 5-10 word summary shown as preview in the UI */
summary?: string
}
/**
* Common interface for teammate execution backends.
* Abstracts the differences between pane-based (tmux/iTerm2) and in-process execution.
*
* PaneBackend handles low-level pane operations; TeammateExecutor handles
* high-level teammate lifecycle operations that work across all backends.
*/
export type TeammateExecutor = {
/** Backend type identifier */
readonly type: BackendType
/** Check if this executor is available on the system */
isAvailable(): Promise<boolean>
/** Spawn a new teammate with the given configuration */
spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult>
/** Send a message to a teammate */
sendMessage(agentId: string, message: TeammateMessage): Promise<void>
/** Terminate a teammate (graceful shutdown request) */
terminate(agentId: string, reason?: string): Promise<boolean>
/** Force kill a teammate (immediate termination) */
kill(agentId: string): Promise<boolean>
/** Check if a teammate is still active */
isActive(agentId: string): Promise<boolean>
}
// =============================================================================
// Type Guards
// =============================================================================
/**
* Type guard to check if a backend type uses terminal panes.
*/
export function isPaneBackend(type: BackendType): type is 'tmux' | 'iterm2' {
return type === 'tmux' || type === 'iterm2'
}
+33
View File
@@ -0,0 +1,33 @@
export const TEAM_LEAD_NAME = 'team-lead'
export const SWARM_SESSION_NAME = 'claude-swarm'
export const SWARM_VIEW_WINDOW_NAME = 'swarm-view'
export const TMUX_COMMAND = 'tmux'
export const HIDDEN_SESSION_NAME = 'claude-hidden'
/**
* Gets the socket name for external swarm sessions (when user is not in tmux).
* Uses a separate socket to isolate swarm operations from user's tmux sessions.
* Includes PID to ensure multiple Claude instances don't conflict.
*/
export function getSwarmSocketName(): string {
return `claude-swarm-${process.pid}`
}
/**
* Environment variable to override the command used to spawn teammate instances.
* If not set, defaults to process.execPath (the current Claude binary).
* This allows customization for different environments or testing.
*/
export const TEAMMATE_COMMAND_ENV_VAR = 'CLAUDE_CODE_TEAMMATE_COMMAND'
/**
* Environment variable set on spawned teammates to indicate their assigned color.
* Used for colored output and pane identification.
*/
export const TEAMMATE_COLOR_ENV_VAR = 'CLAUDE_CODE_AGENT_COLOR'
/**
* Environment variable set on spawned teammates to require plan mode before implementation.
* When set to 'true', teammates must enter plan mode and get approval before writing code.
*/
export const PLAN_MODE_REQUIRED_ENV_VAR = 'CLAUDE_CODE_PLAN_MODE_REQUIRED'
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
/**
* Leader Permission Bridge
*
* Module-level bridge that allows the REPL to register its setToolUseConfirmQueue
* and setToolPermissionContext functions for in-process teammates to use.
*
* When an in-process teammate requests permissions, it uses the standard
* ToolUseConfirm dialog rather than the worker permission badge. This bridge
* makes the REPL's queue setter and permission context setter accessible
* from non-React code in the in-process runner.
*/
import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
import type { ToolPermissionContext } from '../../Tool.js'
export type SetToolUseConfirmQueueFn = (
updater: (prev: ToolUseConfirm[]) => ToolUseConfirm[],
) => void
export type SetToolPermissionContextFn = (
context: ToolPermissionContext,
options?: { preserveMode?: boolean },
) => void
let registeredSetter: SetToolUseConfirmQueueFn | null = null
let registeredPermissionContextSetter: SetToolPermissionContextFn | null = null
export function registerLeaderToolUseConfirmQueue(
setter: SetToolUseConfirmQueueFn,
): void {
registeredSetter = setter
}
export function getLeaderToolUseConfirmQueue(): SetToolUseConfirmQueueFn | null {
return registeredSetter
}
export function unregisterLeaderToolUseConfirmQueue(): void {
registeredSetter = null
}
export function registerLeaderSetToolPermissionContext(
setter: SetToolPermissionContextFn,
): void {
registeredPermissionContextSetter = setter
}
export function getLeaderSetToolPermissionContext(): SetToolPermissionContextFn | null {
return registeredPermissionContextSetter
}
export function unregisterLeaderSetToolPermissionContext(): void {
registeredPermissionContextSetter = null
}
+928
View File
@@ -0,0 +1,928 @@
/**
* Synchronized Permission Prompts for Agent Swarms
*
* This module provides infrastructure for coordinating permission prompts across
* multiple agents in a swarm. When a worker agent needs permission for a tool use,
* it can forward the request to the team leader, who can then approve or deny it.
*
* The system uses the teammate mailbox for message passing:
* - Workers send permission requests to the leader's mailbox
* - Leaders send permission responses to the worker's mailbox
*
* Flow:
* 1. Worker agent encounters a permission prompt
* 2. Worker sends a permission_request message to the leader's mailbox
* 3. Leader polls for mailbox messages and detects permission requests
* 4. User approves/denies via the leader's UI
* 5. Leader sends a permission_response message to the worker's mailbox
* 6. Worker polls mailbox for responses and continues execution
*/
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { logForDebugging } from '../debug.js'
import { getErrnoCode } from '../errors.js'
import { lazySchema } from '../lazySchema.js'
import * as lockfile from '../lockfile.js'
import { logError } from '../log.js'
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import {
getAgentId,
getAgentName,
getTeammateColor,
getTeamName,
} from '../teammate.js'
import {
createPermissionRequestMessage,
createPermissionResponseMessage,
createSandboxPermissionRequestMessage,
createSandboxPermissionResponseMessage,
writeToMailbox,
} from '../teammateMailbox.js'
import { getTeamDir, readTeamFileAsync } from './teamHelpers.js'
/**
* Full request schema for a permission request from a worker to the leader
*/
export const SwarmPermissionRequestSchema = lazySchema(() =>
z.object({
/** Unique identifier for this request */
id: z.string(),
/** Worker's CLAUDE_CODE_AGENT_ID */
workerId: z.string(),
/** Worker's CLAUDE_CODE_AGENT_NAME */
workerName: z.string(),
/** Worker's CLAUDE_CODE_AGENT_COLOR */
workerColor: z.string().optional(),
/** Team name for routing */
teamName: z.string(),
/** Tool name requiring permission (e.g., "Bash", "Edit") */
toolName: z.string(),
/** Original toolUseID from worker's context */
toolUseId: z.string(),
/** Human-readable description of the tool use */
description: z.string(),
/** Serialized tool input */
input: z.record(z.string(), z.unknown()),
/** Suggested permission rules from the permission result */
permissionSuggestions: z.array(z.unknown()),
/** Status of the request */
status: z.enum(['pending', 'approved', 'rejected']),
/** Who resolved the request */
resolvedBy: z.enum(['worker', 'leader']).optional(),
/** Timestamp when resolved */
resolvedAt: z.number().optional(),
/** Rejection feedback message */
feedback: z.string().optional(),
/** Modified input if changed by resolver */
updatedInput: z.record(z.string(), z.unknown()).optional(),
/** "Always allow" rules applied during resolution */
permissionUpdates: z.array(z.unknown()).optional(),
/** Timestamp when request was created */
createdAt: z.number(),
}),
)
export type SwarmPermissionRequest = z.infer<
ReturnType<typeof SwarmPermissionRequestSchema>
>
/**
* Resolution data returned when leader/worker resolves a request
*/
export type PermissionResolution = {
/** Decision: approved or rejected */
decision: 'approved' | 'rejected'
/** Who resolved it */
resolvedBy: 'worker' | 'leader'
/** Optional feedback message if rejected */
feedback?: string
/** Optional updated input if the resolver modified it */
updatedInput?: Record<string, unknown>
/** Permission updates to apply (e.g., "always allow" rules) */
permissionUpdates?: PermissionUpdate[]
}
/**
* Get the base directory for a team's permission requests
* Path: ~/.claude/teams/{teamName}/permissions/
*/
export function getPermissionDir(teamName: string): string {
return join(getTeamDir(teamName), 'permissions')
}
/**
* Get the pending directory for a team
*/
function getPendingDir(teamName: string): string {
return join(getPermissionDir(teamName), 'pending')
}
/**
* Get the resolved directory for a team
*/
function getResolvedDir(teamName: string): string {
return join(getPermissionDir(teamName), 'resolved')
}
/**
* Ensure the permissions directory structure exists (async)
*/
async function ensurePermissionDirsAsync(teamName: string): Promise<void> {
const permDir = getPermissionDir(teamName)
const pendingDir = getPendingDir(teamName)
const resolvedDir = getResolvedDir(teamName)
for (const dir of [permDir, pendingDir, resolvedDir]) {
await mkdir(dir, { recursive: true })
}
}
/**
* Get the path to a pending request file
*/
function getPendingRequestPath(teamName: string, requestId: string): string {
return join(getPendingDir(teamName), `${requestId}.json`)
}
/**
* Get the path to a resolved request file
*/
function getResolvedRequestPath(teamName: string, requestId: string): string {
return join(getResolvedDir(teamName), `${requestId}.json`)
}
/**
* Generate a unique request ID
*/
export function generateRequestId(): string {
return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Create a new SwarmPermissionRequest object
*/
export function createPermissionRequest(params: {
toolName: string
toolUseId: string
input: Record<string, unknown>
description: string
permissionSuggestions?: unknown[]
teamName?: string
workerId?: string
workerName?: string
workerColor?: string
}): SwarmPermissionRequest {
const teamName = params.teamName || getTeamName()
const workerId = params.workerId || getAgentId()
const workerName = params.workerName || getAgentName()
const workerColor = params.workerColor || getTeammateColor()
if (!teamName) {
throw new Error('Team name is required for permission requests')
}
if (!workerId) {
throw new Error('Worker ID is required for permission requests')
}
if (!workerName) {
throw new Error('Worker name is required for permission requests')
}
return {
id: generateRequestId(),
workerId,
workerName,
workerColor,
teamName,
toolName: params.toolName,
toolUseId: params.toolUseId,
description: params.description,
input: params.input,
permissionSuggestions: params.permissionSuggestions || [],
status: 'pending',
createdAt: Date.now(),
}
}
/**
* Write a permission request to the pending directory with file locking
* Called by worker agents when they need permission approval from the leader
*
* @returns The written request
*/
export async function writePermissionRequest(
request: SwarmPermissionRequest,
): Promise<SwarmPermissionRequest> {
await ensurePermissionDirsAsync(request.teamName)
const pendingPath = getPendingRequestPath(request.teamName, request.id)
const lockDir = getPendingDir(request.teamName)
// Create a directory-level lock file for atomic writes
const lockFilePath = join(lockDir, '.lock')
await writeFile(lockFilePath, '', 'utf-8')
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(lockFilePath)
// Write the request file
await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8')
logForDebugging(
`[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`,
)
return request
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to write permission request: ${error}`,
)
logError(error)
throw error
} finally {
if (release) {
await release()
}
}
}
/**
* Read all pending permission requests for a team
* Called by the team leader to see what requests need attention
*/
export async function readPendingPermissions(
teamName?: string,
): Promise<SwarmPermissionRequest[]> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging('[PermissionSync] No team name available')
return []
}
const pendingDir = getPendingDir(team)
let files: string[]
try {
files = await readdir(pendingDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return []
}
logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`)
logError(e)
return []
}
const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock')
const results = await Promise.all(
jsonFiles.map(async file => {
const filePath = join(pendingDir, file)
try {
const content = await readFile(filePath, 'utf-8')
const parsed = SwarmPermissionRequestSchema().safeParse(
jsonParse(content),
)
if (parsed.success) {
return parsed.data
}
logForDebugging(
`[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`,
)
return null
} catch (err) {
logForDebugging(
`[PermissionSync] Failed to read request file ${file}: ${err}`,
)
return null
}
}),
)
const requests = results.filter(r => r !== null)
// Sort by creation time (oldest first)
requests.sort((a, b) => a.createdAt - b.createdAt)
return requests
}
/**
* Read a resolved permission request by ID
* Called by workers to check if their request has been resolved
*
* @returns The resolved request, or null if not yet resolved
*/
export async function readResolvedPermission(
requestId: string,
teamName?: string,
): Promise<SwarmPermissionRequest | null> {
const team = teamName || getTeamName()
if (!team) {
return null
}
const resolvedPath = getResolvedRequestPath(team, requestId)
try {
const content = await readFile(resolvedPath, 'utf-8')
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
if (parsed.success) {
return parsed.data
}
logForDebugging(
`[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`,
)
return null
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return null
}
logForDebugging(
`[PermissionSync] Failed to read resolved request ${requestId}: ${e}`,
)
logError(e)
return null
}
}
/**
* Resolve a permission request
* Called by the team leader (or worker in self-resolution cases)
*
* Writes the resolution to resolved/, removes from pending/
*/
export async function resolvePermission(
requestId: string,
resolution: PermissionResolution,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging('[PermissionSync] No team name available')
return false
}
await ensurePermissionDirsAsync(team)
const pendingPath = getPendingRequestPath(team, requestId)
const resolvedPath = getResolvedRequestPath(team, requestId)
const lockFilePath = join(getPendingDir(team), '.lock')
await writeFile(lockFilePath, '', 'utf-8')
let release: (() => Promise<void>) | undefined
try {
release = await lockfile.lock(lockFilePath)
// Read the pending request
let content: string
try {
content = await readFile(pendingPath, 'utf-8')
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
logForDebugging(
`[PermissionSync] Pending request not found: ${requestId}`,
)
return false
}
throw e
}
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
if (!parsed.success) {
logForDebugging(
`[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`,
)
return false
}
const request = parsed.data
// Update the request with resolution data
const resolvedRequest: SwarmPermissionRequest = {
...request,
status: resolution.decision === 'approved' ? 'approved' : 'rejected',
resolvedBy: resolution.resolvedBy,
resolvedAt: Date.now(),
feedback: resolution.feedback,
updatedInput: resolution.updatedInput,
permissionUpdates: resolution.permissionUpdates,
}
// Write to resolved directory
await writeFile(
resolvedPath,
jsonStringify(resolvedRequest, null, 2),
'utf-8',
)
// Remove from pending directory
await unlink(pendingPath)
logForDebugging(
`[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`,
)
return true
} catch (error) {
logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`)
logError(error)
return false
} finally {
if (release) {
await release()
}
}
}
/**
* Clean up old resolved permission files
* Called periodically to prevent file accumulation
*
* @param teamName - Team name
* @param maxAgeMs - Maximum age in milliseconds (default: 1 hour)
*/
export async function cleanupOldResolutions(
teamName?: string,
maxAgeMs = 3600000,
): Promise<number> {
const team = teamName || getTeamName()
if (!team) {
return 0
}
const resolvedDir = getResolvedDir(team)
let files: string[]
try {
files = await readdir(resolvedDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return 0
}
logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`)
logError(e)
return 0
}
const now = Date.now()
const jsonFiles = files.filter(f => f.endsWith('.json'))
const cleanupResults = await Promise.all(
jsonFiles.map(async file => {
const filePath = join(resolvedDir, file)
try {
const content = await readFile(filePath, 'utf-8')
const request = jsonParse(content) as SwarmPermissionRequest
// Check if the resolution is old enough to clean up
// Use >= to handle edge case where maxAgeMs is 0 (clean up everything)
const resolvedAt = request.resolvedAt || request.createdAt
if (now - resolvedAt >= maxAgeMs) {
await unlink(filePath)
logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`)
return 1
}
return 0
} catch {
// If we can't parse it, clean it up anyway
try {
await unlink(filePath)
return 1
} catch {
// Ignore deletion errors
return 0
}
}
}),
)
const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0)
if (cleanedCount > 0) {
logForDebugging(
`[PermissionSync] Cleaned up ${cleanedCount} old resolutions`,
)
}
return cleanedCount
}
/**
* Legacy response type for worker polling
* Used for backward compatibility with worker integration code
*/
export type PermissionResponse = {
/** ID of the request this responds to */
requestId: string
/** Decision: approved or denied */
decision: 'approved' | 'denied'
/** Timestamp when response was created */
timestamp: string
/** Optional feedback message if denied */
feedback?: string
/** Optional updated input if the resolver modified it */
updatedInput?: Record<string, unknown>
/** Permission updates to apply (e.g., "always allow" rules) */
permissionUpdates?: unknown[]
}
/**
* Poll for a permission response (worker-side convenience function)
* Converts the resolved request into a simpler response format
*
* @returns The permission response, or null if not yet resolved
*/
export async function pollForResponse(
requestId: string,
_agentName?: string,
teamName?: string,
): Promise<PermissionResponse | null> {
const resolved = await readResolvedPermission(requestId, teamName)
if (!resolved) {
return null
}
return {
requestId: resolved.id,
decision: resolved.status === 'approved' ? 'approved' : 'denied',
timestamp: resolved.resolvedAt
? new Date(resolved.resolvedAt).toISOString()
: new Date(resolved.createdAt).toISOString(),
feedback: resolved.feedback,
updatedInput: resolved.updatedInput,
permissionUpdates: resolved.permissionUpdates,
}
}
/**
* Remove a worker's response after processing
* This is an alias for deleteResolvedPermission for backward compatibility
*/
export async function removeWorkerResponse(
requestId: string,
_agentName?: string,
teamName?: string,
): Promise<void> {
await deleteResolvedPermission(requestId, teamName)
}
/**
* Check if the current agent is a team leader
*/
export function isTeamLeader(teamName?: string): boolean {
const team = teamName || getTeamName()
if (!team) {
return false
}
// Team leaders don't have an agent ID set, or their ID is 'team-lead'
const agentId = getAgentId()
return !agentId || agentId === 'team-lead'
}
/**
* Check if the current agent is a worker in a swarm
*/
export function isSwarmWorker(): boolean {
const teamName = getTeamName()
const agentId = getAgentId()
return !!teamName && !!agentId && !isTeamLeader()
}
/**
* Delete a resolved permission file
* Called after a worker has processed the resolution
*/
export async function deleteResolvedPermission(
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
return false
}
const resolvedPath = getResolvedRequestPath(team, requestId)
try {
await unlink(resolvedPath)
logForDebugging(
`[PermissionSync] Deleted resolved permission: ${requestId}`,
)
return true
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return false
}
logForDebugging(
`[PermissionSync] Failed to delete resolved permission: ${e}`,
)
logError(e)
return false
}
}
/**
* Submit a permission request (alias for writePermissionRequest)
* Provided for backward compatibility with worker integration code
*/
export const submitPermissionRequest = writePermissionRequest
// ============================================================================
// Mailbox-Based Permission System
// ============================================================================
/**
* Get the leader's name from the team file
* This is needed to send permission requests to the leader's mailbox
*/
export async function getLeaderName(teamName?: string): Promise<string | null> {
const team = teamName || getTeamName()
if (!team) {
return null
}
const teamFile = await readTeamFileAsync(team)
if (!teamFile) {
logForDebugging(`[PermissionSync] Team file not found for team: ${team}`)
return null
}
const leadMember = teamFile.members.find(
m => m.agentId === teamFile.leadAgentId,
)
return leadMember?.name || 'team-lead'
}
/**
* Send a permission request to the leader via mailbox.
* This is the new mailbox-based approach that replaces the file-based pending directory.
*
* @param request - The permission request to send
* @returns true if the message was sent successfully
*/
export async function sendPermissionRequestViaMailbox(
request: SwarmPermissionRequest,
): Promise<boolean> {
const leaderName = await getLeaderName(request.teamName)
if (!leaderName) {
logForDebugging(
`[PermissionSync] Cannot send permission request: leader name not found`,
)
return false
}
try {
// Create the permission request message
const message = createPermissionRequestMessage({
request_id: request.id,
agent_id: request.workerName,
tool_name: request.toolName,
tool_use_id: request.toolUseId,
description: request.description,
input: request.input,
permission_suggestions: request.permissionSuggestions,
})
// Send to leader's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
leaderName,
{
from: request.workerName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
color: request.workerColor,
},
request.teamName,
)
logForDebugging(
`[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send permission request via mailbox: ${error}`,
)
logError(error)
return false
}
}
/**
* Send a permission response to a worker via mailbox.
* This is the new mailbox-based approach that replaces the file-based resolved directory.
*
* @param workerName - The worker's name to send the response to
* @param resolution - The permission resolution
* @param requestId - The original request ID
* @param teamName - The team name
* @returns true if the message was sent successfully
*/
export async function sendPermissionResponseViaMailbox(
workerName: string,
resolution: PermissionResolution,
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging(
`[PermissionSync] Cannot send permission response: team name not found`,
)
return false
}
try {
// Create the permission response message
const message = createPermissionResponseMessage({
request_id: requestId,
subtype: resolution.decision === 'approved' ? 'success' : 'error',
error: resolution.feedback,
updated_input: resolution.updatedInput,
permission_updates: resolution.permissionUpdates,
})
// Get the sender name (leader's name)
const senderName = getAgentName() || 'team-lead'
// Send to worker's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
workerName,
{
from: senderName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
},
team,
)
logForDebugging(
`[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send permission response via mailbox: ${error}`,
)
logError(error)
return false
}
}
// ============================================================================
// Sandbox Permission Mailbox System
// ============================================================================
/**
* Generate a unique sandbox permission request ID
*/
export function generateSandboxRequestId(): string {
return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Send a sandbox permission request to the leader via mailbox.
* Called by workers when sandbox runtime needs network access approval.
*
* @param host - The host requesting network access
* @param requestId - Unique ID for this request
* @param teamName - Optional team name
* @returns true if the message was sent successfully
*/
export async function sendSandboxPermissionRequestViaMailbox(
host: string,
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission request: team name not found`,
)
return false
}
const leaderName = await getLeaderName(team)
if (!leaderName) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission request: leader name not found`,
)
return false
}
const workerId = getAgentId()
const workerName = getAgentName()
const workerColor = getTeammateColor()
if (!workerId || !workerName) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`,
)
return false
}
try {
const message = createSandboxPermissionRequestMessage({
requestId,
workerId,
workerName,
workerColor,
host,
})
// Send to leader's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
leaderName,
{
from: workerName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
color: workerColor,
},
team,
)
logForDebugging(
`[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`,
)
logError(error)
return false
}
}
/**
* Send a sandbox permission response to a worker via mailbox.
* Called by the leader when approving/denying a sandbox network access request.
*
* @param workerName - The worker's name to send the response to
* @param requestId - The original request ID
* @param host - The host that was approved/denied
* @param allow - Whether the connection is allowed
* @param teamName - Optional team name
* @returns true if the message was sent successfully
*/
export async function sendSandboxPermissionResponseViaMailbox(
workerName: string,
requestId: string,
host: string,
allow: boolean,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission response: team name not found`,
)
return false
}
try {
const message = createSandboxPermissionResponseMessage({
requestId,
host,
allow,
})
const senderName = getAgentName() || 'team-lead'
// Send to worker's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
workerName,
{
from: senderName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
},
team,
)
logForDebugging(
`[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`,
)
logError(error)
return false
}
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Swarm Reconnection Module
*
* Handles initialization of swarm context for teammates.
* - Fresh spawns: Initialize from CLI args (set in main.tsx via dynamicTeamContext)
* - Resumed sessions: Initialize from teamName/agentName stored in the transcript
*/
import type { AppState } from '../../state/AppState.js'
import { logForDebugging } from '../debug.js'
import { logError } from '../log.js'
import { getDynamicTeamContext } from '../teammate.js'
import { getTeamFilePath, readTeamFile } from './teamHelpers.js'
/**
* Computes the initial teamContext for AppState.
*
* This is called synchronously in main.tsx to compute the teamContext
* BEFORE the first render, eliminating the need for useEffect workarounds.
*
* @returns The teamContext object to include in initialState, or undefined if not a teammate
*/
export function computeInitialTeamContext():
| AppState['teamContext']
| undefined {
// dynamicTeamContext is set in main.tsx from CLI args
const context = getDynamicTeamContext()
if (!context?.teamName || !context?.agentName) {
logForDebugging(
'[Reconnection] computeInitialTeamContext: No teammate context set (not a teammate)',
)
return undefined
}
const { teamName, agentId, agentName } = context
// Read team file to get lead agent ID
const teamFile = readTeamFile(teamName)
if (!teamFile) {
logError(
new Error(
`[computeInitialTeamContext] Could not read team file for ${teamName}`,
),
)
return undefined
}
const teamFilePath = getTeamFilePath(teamName)
const isLeader = !agentId
logForDebugging(
`[Reconnection] Computed initial team context for ${isLeader ? 'leader' : `teammate ${agentName}`} in team ${teamName}`,
)
return {
teamName,
teamFilePath,
leadAgentId: teamFile.leadAgentId,
selfAgentId: agentId,
selfAgentName: agentName,
isLeader,
teammates: {},
}
}
/**
* Initialize teammate context from a resumed session.
*
* This is called when resuming a session that has teamName/agentName stored
* in the transcript. It sets up teamContext in AppState so that heartbeat
* and other swarm features work correctly.
*/
export function initializeTeammateContextFromSession(
setAppState: (updater: (prev: AppState) => AppState) => void,
teamName: string,
agentName: string,
): void {
// Read team file to get lead agent ID
const teamFile = readTeamFile(teamName)
if (!teamFile) {
logError(
new Error(
`[initializeTeammateContextFromSession] Could not read team file for ${teamName} (agent: ${agentName})`,
),
)
return
}
// Find the member in the team file to get their agentId
const member = teamFile.members.find(m => m.name === agentName)
if (!member) {
logForDebugging(
`[Reconnection] Member ${agentName} not found in team ${teamName} - may have been removed`,
)
}
const agentId = member?.agentId
const teamFilePath = getTeamFilePath(teamName)
// Set teamContext in AppState
setAppState(prev => ({
...prev,
teamContext: {
teamName,
teamFilePath,
leadAgentId: teamFile.leadAgentId,
selfAgentId: agentId,
selfAgentName: agentName,
isLeader: false,
teammates: {},
},
}))
logForDebugging(
`[Reconnection] Initialized agent context from session for ${agentName} in team ${teamName}`,
)
}
+328
View File
@@ -0,0 +1,328 @@
/**
* In-process teammate spawning
*
* Creates and registers an in-process teammate task. Unlike process-based
* teammates (tmux/iTerm2), in-process teammates run in the same Node.js
* process using AsyncLocalStorage for context isolation.
*
* The actual agent execution loop is handled by InProcessTeammateTask
* component (Task #14). This module handles:
* 1. Creating TeammateContext
* 2. Creating linked AbortController
* 3. Registering InProcessTeammateTaskState in AppState
* 4. Returning spawn result for backend
*/
import sample from 'lodash-es/sample.js'
import { getSessionId } from '../../bootstrap/state.js'
import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js'
import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'
import type { AppState } from '../../state/AppState.js'
import { createTaskStateBase, generateTaskId } from '../../Task.js'
import type {
InProcessTeammateTaskState,
TeammateIdentity,
} from '../../tasks/InProcessTeammateTask/types.js'
import { createAbortController } from '../abortController.js'
import { formatAgentId } from '../agentId.js'
import { registerCleanup } from '../cleanupRegistry.js'
import { logForDebugging } from '../debug.js'
import { emitTaskTerminatedSdk } from '../sdkEventQueue.js'
import { evictTaskOutput } from '../task/diskOutput.js'
import {
evictTerminalTask,
registerTask,
STOPPED_DISPLAY_MS,
} from '../task/framework.js'
import { createTeammateContext } from '../teammateContext.js'
import {
isPerfettoTracingEnabled,
registerAgent as registerPerfettoAgent,
unregisterAgent as unregisterPerfettoAgent,
} from '../telemetry/perfettoTracing.js'
import { removeMemberByAgentId } from './teamHelpers.js'
type SetAppStateFn = (updater: (prev: AppState) => AppState) => void
/**
* Minimal context required for spawning an in-process teammate.
* This is a subset of ToolUseContext - only what spawnInProcessTeammate actually uses.
*/
export type SpawnContext = {
setAppState: SetAppStateFn
toolUseId?: string
}
/**
* Configuration for spawning an in-process teammate.
*/
export type InProcessSpawnConfig = {
/** Display name for the teammate, e.g., "researcher" */
name: string
/** Team this teammate belongs to */
teamName: string
/** Initial prompt/task for the teammate */
prompt: string
/** Optional UI color for the teammate */
color?: string
/** Whether teammate must enter plan mode before implementing */
planModeRequired: boolean
/** Optional model override for this teammate */
model?: string
}
/**
* Result from spawning an in-process teammate.
*/
export type InProcessSpawnOutput = {
/** Whether spawn was successful */
success: boolean
/** Full agent ID (format: "name@team") */
agentId: string
/** Task ID for tracking in AppState */
taskId?: string
/** AbortController for this teammate (linked to parent) */
abortController?: AbortController
/** Teammate context for AsyncLocalStorage */
teammateContext?: ReturnType<typeof createTeammateContext>
/** Error message if spawn failed */
error?: string
}
/**
* Spawns an in-process teammate.
*
* Creates the teammate's context, registers the task in AppState, and returns
* the spawn result. The actual agent execution is driven by the
* InProcessTeammateTask component which uses runWithTeammateContext() to
* execute the agent loop with proper identity isolation.
*
* @param config - Spawn configuration
* @param context - Context with setAppState for registering task
* @returns Spawn result with teammate info
*/
export async function spawnInProcessTeammate(
config: InProcessSpawnConfig,
context: SpawnContext,
): Promise<InProcessSpawnOutput> {
const { name, teamName, prompt, color, planModeRequired, model } = config
const { setAppState } = context
// Generate deterministic agent ID
const agentId = formatAgentId(name, teamName)
const taskId = generateTaskId('in_process_teammate')
logForDebugging(
`[spawnInProcessTeammate] Spawning ${agentId} (taskId: ${taskId})`,
)
try {
// Create independent AbortController for this teammate
// Teammates should not be aborted when the leader's query is interrupted
const abortController = createAbortController()
// Get parent session ID for transcript correlation
const parentSessionId = getSessionId()
// Create teammate identity (stored as plain data in AppState)
const identity: TeammateIdentity = {
agentId,
agentName: name,
teamName,
color,
planModeRequired,
parentSessionId,
}
// Create teammate context for AsyncLocalStorage
// This will be used by runWithTeammateContext() during agent execution
const teammateContext = createTeammateContext({
agentId,
agentName: name,
teamName,
color,
planModeRequired,
parentSessionId,
abortController,
})
// Register agent in Perfetto trace for hierarchy visualization
if (isPerfettoTracingEnabled()) {
registerPerfettoAgent(agentId, name, parentSessionId)
}
// Create task state
const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}`
const taskState: InProcessTeammateTaskState = {
...createTaskStateBase(
taskId,
'in_process_teammate',
description,
context.toolUseId,
),
type: 'in_process_teammate',
status: 'running',
identity,
prompt,
model,
abortController,
awaitingPlanApproval: false,
spinnerVerb: sample(getSpinnerVerbs()),
pastTenseVerb: sample(TURN_COMPLETION_VERBS),
permissionMode: planModeRequired ? 'plan' : 'default',
isIdle: false,
shutdownRequested: false,
lastReportedToolCount: 0,
lastReportedTokenCount: 0,
pendingUserMessages: [],
messages: [], // Initialize to empty array so getDisplayedMessages works immediately
}
// Register cleanup handler for graceful shutdown
const unregisterCleanup = registerCleanup(async () => {
logForDebugging(`[spawnInProcessTeammate] Cleanup called for ${agentId}`)
abortController.abort()
// Task state will be updated by the execution loop when it detects abort
})
taskState.unregisterCleanup = unregisterCleanup
// Register task in AppState
registerTask(taskState, setAppState)
logForDebugging(
`[spawnInProcessTeammate] Registered ${agentId} in AppState`,
)
return {
success: true,
agentId,
taskId,
abortController,
teammateContext,
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during spawn'
logForDebugging(
`[spawnInProcessTeammate] Failed to spawn ${agentId}: ${errorMessage}`,
)
return {
success: false,
agentId,
error: errorMessage,
}
}
}
/**
* Kills an in-process teammate by aborting its controller.
*
* Note: This is the implementation called by InProcessBackend.kill().
*
* @param taskId - Task ID of the teammate to kill
* @param setAppState - AppState setter
* @returns true if killed successfully
*/
export function killInProcessTeammate(
taskId: string,
setAppState: SetAppStateFn,
): boolean {
let killed = false
let teamName: string | null = null
let agentId: string | null = null
let toolUseId: string | undefined
let description: string | undefined
setAppState((prev: AppState) => {
const task = prev.tasks[taskId]
if (!task || task.type !== 'in_process_teammate') {
return prev
}
const teammateTask = task as InProcessTeammateTaskState
if (teammateTask.status !== 'running') {
return prev
}
// Capture identity for cleanup after state update
teamName = teammateTask.identity.teamName
agentId = teammateTask.identity.agentId
toolUseId = teammateTask.toolUseId
description = teammateTask.description
// Abort the controller to stop execution
teammateTask.abortController?.abort()
// Call cleanup handler
teammateTask.unregisterCleanup?.()
// Update task state and remove from teamContext.teammates
killed = true
// Call pending idle callbacks to unblock any waiters (e.g., engine.waitForIdle)
teammateTask.onIdleCallbacks?.forEach(cb => cb())
// Remove from teamContext.teammates using the agentId
let updatedTeamContext = prev.teamContext
if (prev.teamContext && prev.teamContext.teammates && agentId) {
const { [agentId]: _, ...remainingTeammates } = prev.teamContext.teammates
updatedTeamContext = {
...prev.teamContext,
teammates: remainingTeammates,
}
}
return {
...prev,
teamContext: updatedTeamContext,
tasks: {
...prev.tasks,
[taskId]: {
...teammateTask,
status: 'killed' as const,
notified: true,
endTime: Date.now(),
onIdleCallbacks: [], // Clear callbacks to prevent stale references
messages: teammateTask.messages?.length
? [teammateTask.messages[teammateTask.messages.length - 1]!]
: undefined,
pendingUserMessages: [],
inProgressToolUseIDs: undefined,
abortController: undefined,
unregisterCleanup: undefined,
currentWorkAbortController: undefined,
},
},
}
})
// Remove from team file (outside state updater to avoid file I/O in callback)
if (teamName && agentId) {
removeMemberByAgentId(teamName, agentId)
}
if (killed) {
void evictTaskOutput(taskId)
// notified:true was pre-set so no XML notification fires; close the SDK
// task_started bookend directly. The in-process runner's own
// completion/failure emit guards on status==='running' so it won't
// double-emit after seeing status:killed.
emitTaskTerminatedSdk(taskId, 'stopped', {
toolUseId,
summary: description,
})
setTimeout(
evictTerminalTask.bind(null, taskId, setAppState),
STOPPED_DISPLAY_MS,
)
}
// Release perfetto agent registry entry
if (agentId) {
unregisterPerfettoAgent(agentId)
}
return killed
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Shared utilities for spawning teammates across different backends.
*/
import {
getChromeFlagOverride,
getFlagSettingsPath,
getInlinePlugins,
getMainLoopModelOverride,
getSessionBypassPermissionsMode,
} from '../../bootstrap/state.js'
import { quote } from '../bash/shellQuote.js'
import { isInBundledMode } from '../bundledMode.js'
import type { PermissionMode } from '../permissions/PermissionMode.js'
import { getTeammateModeFromSnapshot } from './backends/teammateModeSnapshot.js'
import { TEAMMATE_COMMAND_ENV_VAR } from './constants.js'
/**
* Gets the command to use for spawning teammate processes.
* Uses TEAMMATE_COMMAND_ENV_VAR if set, otherwise falls back to the
* current process executable path.
*/
export function getTeammateCommand(): string {
if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
return process.env[TEAMMATE_COMMAND_ENV_VAR]
}
return isInBundledMode() ? process.execPath : process.argv[1]!
}
/**
* Builds CLI flags to propagate from the current session to spawned teammates.
* This ensures teammates inherit important settings like permission mode,
* model selection, and plugin configuration from their parent.
*
* @param options.planModeRequired - If true, don't inherit bypass permissions (plan mode takes precedence)
* @param options.permissionMode - Permission mode to propagate
*/
export function buildInheritedCliFlags(options?: {
planModeRequired?: boolean
permissionMode?: PermissionMode
}): string {
const flags: string[] = []
const { planModeRequired, permissionMode } = options || {}
// Propagate permission mode to teammates, but NOT if plan mode is required
// Plan mode takes precedence over bypass permissions for safety
if (planModeRequired) {
// Don't inherit bypass permissions when plan mode is required
} else if (
permissionMode === 'bypassPermissions' ||
getSessionBypassPermissionsMode()
) {
flags.push('--dangerously-skip-permissions')
} else if (permissionMode === 'acceptEdits') {
flags.push('--permission-mode acceptEdits')
}
// Propagate --model if explicitly set via CLI
const modelOverride = getMainLoopModelOverride()
if (modelOverride) {
flags.push(`--model ${quote([modelOverride])}`)
}
// Propagate --settings if set via CLI
const settingsPath = getFlagSettingsPath()
if (settingsPath) {
flags.push(`--settings ${quote([settingsPath])}`)
}
// Propagate --plugin-dir for each inline plugin
const inlinePlugins = getInlinePlugins()
for (const pluginDir of inlinePlugins) {
flags.push(`--plugin-dir ${quote([pluginDir])}`)
}
// Propagate --teammate-mode so tmux teammates use the same mode as leader
const sessionMode = getTeammateModeFromSnapshot()
flags.push(`--teammate-mode ${sessionMode}`)
// Propagate --chrome / --no-chrome if explicitly set on the CLI
const chromeFlagOverride = getChromeFlagOverride()
if (chromeFlagOverride === true) {
flags.push('--chrome')
} else if (chromeFlagOverride === false) {
flags.push('--no-chrome')
}
return flags.join(' ')
}
/**
* Environment variables that must be explicitly forwarded to tmux-spawned
* teammates. Tmux may start a new login shell that doesn't inherit the
* parent's env, so we forward any that are set in the current process.
*/
const TEAMMATE_ENV_VARS = [
// API provider selection — without these, teammates default to firstParty
// and send requests to the wrong endpoint (GitHub issue #23561)
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
// Custom API endpoint
'ANTHROPIC_BASE_URL',
// Config directory override
'CLAUDE_CONFIG_DIR',
// CCR marker — teammates need this for CCR-aware code paths. Auth finds
// its own way via /home/claude/.claude/remote/.oauth_token regardless;
// the FD env var wouldn't help (pipe FDs don't cross tmux).
'CLAUDE_CODE_REMOTE',
// Auto-memory gate (memdir/paths.ts) checks REMOTE && !MEMORY_DIR to
// disable memory on ephemeral CCR filesystems. Forwarding REMOTE alone
// would flip teammates to memory-off when the parent has it on.
'CLAUDE_CODE_REMOTE_MEMORY_DIR',
// Upstream proxy — the parent's MITM relay is reachable from teammates
// (same container network). Forward the proxy vars so teammates route
// customer-configured upstream traffic through the relay for credential
// injection. Without these, teammates bypass the proxy entirely.
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
'NO_PROXY',
'no_proxy',
'SSL_CERT_FILE',
'NODE_EXTRA_CA_CERTS',
'REQUESTS_CA_BUNDLE',
'CURL_CA_BUNDLE',
] as const
/**
* Builds the `env KEY=VALUE ...` string for teammate spawn commands.
* Always includes CLAUDECODE=1 and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1,
* plus any provider/config env vars that are set in the current process.
*/
export function buildInheritedEnvVars(): string {
const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1']
for (const key of TEAMMATE_ENV_VARS) {
const value = process.env[key]
if (value !== undefined && value !== '') {
envVars.push(`${key}=${quote([value])}`)
}
}
return envVars.join(' ')
}
+683
View File
@@ -0,0 +1,683 @@
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { getSessionCreatedTeams } from '../../bootstrap/state.js'
import { logForDebugging } from '../debug.js'
import { getTeamsDir } from '../envUtils.js'
import { errorMessage, getErrnoCode } from '../errors.js'
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
import { gitExe } from '../git.js'
import { lazySchema } from '../lazySchema.js'
import type { PermissionMode } from '../permissions/PermissionMode.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
import { getAgentName, getTeamName, isTeammate } from '../teammate.js'
import { type BackendType, isPaneBackend } from './backends/types.js'
import { TEAM_LEAD_NAME } from './constants.js'
export const inputSchema = lazySchema(() =>
z.strictObject({
operation: z
.enum(['spawnTeam', 'cleanup'])
.describe(
'Operation: spawnTeam to create a team, cleanup to remove team and task directories.',
),
agent_type: z
.string()
.optional()
.describe(
'Type/role of the team lead (e.g., "researcher", "test-runner"). ' +
'Used for team file and inter-agent coordination.',
),
team_name: z
.string()
.optional()
.describe('Name for the new team to create (required for spawnTeam).'),
description: z
.string()
.optional()
.describe('Team description/purpose (only used with spawnTeam).'),
}),
)
// Output types for different operations
export type SpawnTeamOutput = {
team_name: string
team_file_path: string
lead_agent_id: string
}
export type CleanupOutput = {
success: boolean
message: string
team_name?: string
}
export type TeamAllowedPath = {
path: string // Directory path (absolute)
toolName: string // The tool this applies to (e.g., "Edit", "Write")
addedBy: string // Agent name who added this rule
addedAt: number // Timestamp when added
}
export type TeamFile = {
name: string
description?: string
createdAt: number
leadAgentId: string
leadSessionId?: string // Actual session UUID of the leader (for discovery)
hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
members: Array<{
agentId: string
name: string
agentType?: string
model?: string
prompt?: string
color?: string
planModeRequired?: boolean
joinedAt: number
tmuxPaneId: string
cwd: string
worktreePath?: string
sessionId?: string
subscriptions: string[]
backendType?: BackendType
isActive?: boolean // false when idle, undefined/true when active
mode?: PermissionMode // Current permission mode for this teammate
}>
}
export type Input = z.infer<ReturnType<typeof inputSchema>>
// Export SpawnTeamOutput as Output for backward compatibility
export type Output = SpawnTeamOutput
/**
* Sanitizes a name for use in tmux window names, worktree paths, and file paths.
* Replaces all non-alphanumeric characters with hyphens and lowercases.
*/
export function sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
}
/**
* Sanitizes an agent name for use in deterministic agent IDs.
* Replaces @ with - to prevent ambiguity in the agentName@teamName format.
*/
export function sanitizeAgentName(name: string): string {
return name.replace(/@/g, '-')
}
/**
* Gets the path to a team's directory
*/
export function getTeamDir(teamName: string): string {
return join(getTeamsDir(), sanitizeName(teamName))
}
/**
* Gets the path to a team's config.json file
*/
export function getTeamFilePath(teamName: string): string {
return join(getTeamDir(teamName), 'config.json')
}
/**
* Reads a team file by name (sync — for sync contexts like React render paths)
* @internal Exported for team discovery UI
*/
// sync IO: called from sync context
export function readTeamFile(teamName: string): TeamFile | null {
try {
const content = readFileSync(getTeamFilePath(teamName), 'utf-8')
return jsonParse(content) as TeamFile
} catch (e) {
if (getErrnoCode(e) === 'ENOENT') return null
logForDebugging(
`[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
)
return null
}
}
/**
* Reads a team file by name (async — for tool handlers and other async contexts)
*/
export async function readTeamFileAsync(
teamName: string,
): Promise<TeamFile | null> {
try {
const content = await readFile(getTeamFilePath(teamName), 'utf-8')
return jsonParse(content) as TeamFile
} catch (e) {
if (getErrnoCode(e) === 'ENOENT') return null
logForDebugging(
`[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
)
return null
}
}
/**
* Writes a team file (sync — for sync contexts)
*/
// sync IO: called from sync context
function writeTeamFile(teamName: string, teamFile: TeamFile): void {
const teamDir = getTeamDir(teamName)
mkdirSync(teamDir, { recursive: true })
writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
}
/**
* Writes a team file (async — for tool handlers)
*/
export async function writeTeamFileAsync(
teamName: string,
teamFile: TeamFile,
): Promise<void> {
const teamDir = getTeamDir(teamName)
await mkdir(teamDir, { recursive: true })
await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
}
/**
* Removes a teammate from the team file by agent ID or name.
* Used by the leader when processing shutdown approvals.
*/
export function removeTeammateFromTeamFile(
teamName: string,
identifier: { agentId?: string; name?: string },
): boolean {
const identifierStr = identifier.agentId || identifier.name
if (!identifierStr) {
logForDebugging(
'[TeammateTool] removeTeammateFromTeamFile called with no identifier',
)
return false
}
const teamFile = readTeamFile(teamName)
if (!teamFile) {
logForDebugging(
`[TeammateTool] Cannot remove teammate ${identifierStr}: failed to read team file for "${teamName}"`,
)
return false
}
const originalLength = teamFile.members.length
teamFile.members = teamFile.members.filter(m => {
if (identifier.agentId && m.agentId === identifier.agentId) return false
if (identifier.name && m.name === identifier.name) return false
return true
})
if (teamFile.members.length === originalLength) {
logForDebugging(
`[TeammateTool] Teammate ${identifierStr} not found in team file for "${teamName}"`,
)
return false
}
writeTeamFile(teamName, teamFile)
logForDebugging(
`[TeammateTool] Removed teammate from team file: ${identifierStr}`,
)
return true
}
/**
* Adds a pane ID to the hidden panes list in the team file.
* @param teamName - The name of the team
* @param paneId - The pane ID to hide
* @returns true if the pane was added to hidden list, false if team doesn't exist
*/
export function addHiddenPaneId(teamName: string, paneId: string): boolean {
const teamFile = readTeamFile(teamName)
if (!teamFile) {
return false
}
const hiddenPaneIds = teamFile.hiddenPaneIds ?? []
if (!hiddenPaneIds.includes(paneId)) {
hiddenPaneIds.push(paneId)
teamFile.hiddenPaneIds = hiddenPaneIds
writeTeamFile(teamName, teamFile)
logForDebugging(
`[TeammateTool] Added ${paneId} to hidden panes for team ${teamName}`,
)
}
return true
}
/**
* Removes a pane ID from the hidden panes list in the team file.
* @param teamName - The name of the team
* @param paneId - The pane ID to show (remove from hidden list)
* @returns true if the pane was removed from hidden list, false if team doesn't exist
*/
export function removeHiddenPaneId(teamName: string, paneId: string): boolean {
const teamFile = readTeamFile(teamName)
if (!teamFile) {
return false
}
const hiddenPaneIds = teamFile.hiddenPaneIds ?? []
const index = hiddenPaneIds.indexOf(paneId)
if (index !== -1) {
hiddenPaneIds.splice(index, 1)
teamFile.hiddenPaneIds = hiddenPaneIds
writeTeamFile(teamName, teamFile)
logForDebugging(
`[TeammateTool] Removed ${paneId} from hidden panes for team ${teamName}`,
)
}
return true
}
/**
* Removes a teammate from the team config file by pane ID.
* Also removes from hiddenPaneIds if present.
* @param teamName - The name of the team
* @param tmuxPaneId - The pane ID of the teammate to remove
* @returns true if the member was removed, false if team or member doesn't exist
*/
export function removeMemberFromTeam(
teamName: string,
tmuxPaneId: string,
): boolean {
const teamFile = readTeamFile(teamName)
if (!teamFile) {
return false
}
const memberIndex = teamFile.members.findIndex(
m => m.tmuxPaneId === tmuxPaneId,
)
if (memberIndex === -1) {
return false
}
// Remove from members array
teamFile.members.splice(memberIndex, 1)
// Also remove from hiddenPaneIds if present
if (teamFile.hiddenPaneIds) {
const hiddenIndex = teamFile.hiddenPaneIds.indexOf(tmuxPaneId)
if (hiddenIndex !== -1) {
teamFile.hiddenPaneIds.splice(hiddenIndex, 1)
}
}
writeTeamFile(teamName, teamFile)
logForDebugging(
`[TeammateTool] Removed member with pane ${tmuxPaneId} from team ${teamName}`,
)
return true
}
/**
* Removes a teammate from a team's member list by agent ID.
* Use this for in-process teammates which all share the same tmuxPaneId.
* @param teamName - The name of the team
* @param agentId - The agent ID of the teammate to remove (e.g., "researcher@my-team")
* @returns true if the member was removed, false if team or member doesn't exist
*/
export function removeMemberByAgentId(
teamName: string,
agentId: string,
): boolean {
const teamFile = readTeamFile(teamName)
if (!teamFile) {
return false
}
const memberIndex = teamFile.members.findIndex(m => m.agentId === agentId)
if (memberIndex === -1) {
return false
}
// Remove from members array
teamFile.members.splice(memberIndex, 1)
writeTeamFile(teamName, teamFile)
logForDebugging(
`[TeammateTool] Removed member ${agentId} from team ${teamName}`,
)
return true
}
/**
* Sets a team member's permission mode.
* Called when the team leader changes a teammate's mode via the TeamsDialog.
* @param teamName - The name of the team
* @param memberName - The name of the member to update
* @param mode - The new permission mode
*/
export function setMemberMode(
teamName: string,
memberName: string,
mode: PermissionMode,
): boolean {
const teamFile = readTeamFile(teamName)
if (!teamFile) {
return false
}
const member = teamFile.members.find(m => m.name === memberName)
if (!member) {
logForDebugging(
`[TeammateTool] Cannot set member mode: member ${memberName} not found in team ${teamName}`,
)
return false
}
// Only write if the value is actually changing
if (member.mode === mode) {
return true
}
// Create updated members array immutably
const updatedMembers = teamFile.members.map(m =>
m.name === memberName ? { ...m, mode } : m,
)
writeTeamFile(teamName, { ...teamFile, members: updatedMembers })
logForDebugging(
`[TeammateTool] Set member ${memberName} in team ${teamName} to mode: ${mode}`,
)
return true
}
/**
* Sync the current teammate's mode to config.json so team lead sees it.
* No-op if not running as a teammate.
* @param mode - The permission mode to sync
* @param teamNameOverride - Optional team name override (uses env var if not provided)
*/
export function syncTeammateMode(
mode: PermissionMode,
teamNameOverride?: string,
): void {
if (!isTeammate()) return
const teamName = teamNameOverride ?? getTeamName()
const agentName = getAgentName()
if (teamName && agentName) {
setMemberMode(teamName, agentName, mode)
}
}
/**
* Sets multiple team members' permission modes in a single atomic operation.
* Avoids race conditions when updating multiple teammates at once.
* @param teamName - The name of the team
* @param modeUpdates - Array of {memberName, mode} to update
*/
export function setMultipleMemberModes(
teamName: string,
modeUpdates: Array<{ memberName: string; mode: PermissionMode }>,
): boolean {
const teamFile = readTeamFile(teamName)
if (!teamFile) {
return false
}
// Build a map of updates for efficient lookup
const updateMap = new Map(modeUpdates.map(u => [u.memberName, u.mode]))
// Create updated members array immutably
let anyChanged = false
const updatedMembers = teamFile.members.map(member => {
const newMode = updateMap.get(member.name)
if (newMode !== undefined && member.mode !== newMode) {
anyChanged = true
return { ...member, mode: newMode }
}
return member
})
if (anyChanged) {
writeTeamFile(teamName, { ...teamFile, members: updatedMembers })
logForDebugging(
`[TeammateTool] Set ${modeUpdates.length} member modes in team ${teamName}`,
)
}
return true
}
/**
* Sets a team member's active status.
* Called when a teammate becomes idle (isActive=false) or starts a new turn (isActive=true).
* @param teamName - The name of the team
* @param memberName - The name of the member to update
* @param isActive - Whether the member is active (true) or idle (false)
*/
export async function setMemberActive(
teamName: string,
memberName: string,
isActive: boolean,
): Promise<void> {
const teamFile = await readTeamFileAsync(teamName)
if (!teamFile) {
logForDebugging(
`[TeammateTool] Cannot set member active: team ${teamName} not found`,
)
return
}
const member = teamFile.members.find(m => m.name === memberName)
if (!member) {
logForDebugging(
`[TeammateTool] Cannot set member active: member ${memberName} not found in team ${teamName}`,
)
return
}
// Only write if the value is actually changing
if (member.isActive === isActive) {
return
}
member.isActive = isActive
await writeTeamFileAsync(teamName, teamFile)
logForDebugging(
`[TeammateTool] Set member ${memberName} in team ${teamName} to ${isActive ? 'active' : 'idle'}`,
)
}
/**
* Destroys a git worktree at the given path.
* First attempts to use `git worktree remove`, then falls back to rm -rf.
* Safe to call on non-existent paths.
*/
async function destroyWorktree(worktreePath: string): Promise<void> {
// Read the .git file in the worktree to find the main repo
const gitFilePath = join(worktreePath, '.git')
let mainRepoPath: string | null = null
try {
const gitFileContent = (await readFile(gitFilePath, 'utf-8')).trim()
// The .git file contains something like: gitdir: /path/to/repo/.git/worktrees/worktree-name
const match = gitFileContent.match(/^gitdir:\s*(.+)$/)
if (match && match[1]) {
// Extract the main repo .git directory (go up from .git/worktrees/name to .git)
const worktreeGitDir = match[1]
// Go up 2 levels from .git/worktrees/name to get to .git, then get parent for repo root
const mainGitDir = join(worktreeGitDir, '..', '..')
mainRepoPath = join(mainGitDir, '..')
}
} catch {
// Ignore errors reading .git file (path doesn't exist, not a file, etc.)
}
// Try to remove using git worktree remove command
if (mainRepoPath) {
const result = await execFileNoThrowWithCwd(
gitExe(),
['worktree', 'remove', '--force', worktreePath],
{ cwd: mainRepoPath },
)
if (result.code === 0) {
logForDebugging(
`[TeammateTool] Removed worktree via git: ${worktreePath}`,
)
return
}
// Check if the error is "not a working tree" (already removed)
if (result.stderr?.includes('not a working tree')) {
logForDebugging(
`[TeammateTool] Worktree already removed: ${worktreePath}`,
)
return
}
logForDebugging(
`[TeammateTool] git worktree remove failed, falling back to rm: ${result.stderr}`,
)
}
// Fallback: manually remove the directory
try {
await rm(worktreePath, { recursive: true, force: true })
logForDebugging(
`[TeammateTool] Removed worktree directory manually: ${worktreePath}`,
)
} catch (error) {
logForDebugging(
`[TeammateTool] Failed to remove worktree ${worktreePath}: ${errorMessage(error)}`,
)
}
}
/**
* Mark a team as created this session so it gets cleaned up on exit.
* Call this right after the initial writeTeamFile. TeamDelete should
* call unregisterTeamForSessionCleanup to prevent double-cleanup.
* Backing Set lives in bootstrap/state.ts so resetStateForTests()
* clears it between tests (avoids the PR #17615 cross-shard leak class).
*/
export function registerTeamForSessionCleanup(teamName: string): void {
getSessionCreatedTeams().add(teamName)
}
/**
* Remove a team from session cleanup tracking (e.g., after explicit
* TeamDelete — already cleaned, don't try again on shutdown).
*/
export function unregisterTeamForSessionCleanup(teamName: string): void {
getSessionCreatedTeams().delete(teamName)
}
/**
* Clean up all teams created this session that weren't explicitly deleted.
* Registered with gracefulShutdown from init.ts.
*/
export async function cleanupSessionTeams(): Promise<void> {
const sessionCreatedTeams = getSessionCreatedTeams()
if (sessionCreatedTeams.size === 0) return
const teams = Array.from(sessionCreatedTeams)
logForDebugging(
`cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`,
)
// Kill panes first — on SIGINT the teammate processes are still running;
// deleting directories alone would orphan them in open tmux/iTerm2 panes.
// (TeamDeleteTool's path doesn't need this — by then teammates have
// gracefully exited and useInboxPoller has already closed their panes.)
await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name)))
await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name)))
sessionCreatedTeams.clear()
}
/**
* Best-effort kill of all pane-backed teammate panes for a team.
* Called from cleanupSessionTeams on ungraceful leader exit (SIGINT/SIGTERM).
* Dynamic imports avoid adding registry/detection to this module's static
* dep graph — this only runs at shutdown, so the import cost is irrelevant.
*/
async function killOrphanedTeammatePanes(teamName: string): Promise<void> {
const teamFile = readTeamFile(teamName)
if (!teamFile) return
const paneMembers = teamFile.members.filter(
m =>
m.name !== TEAM_LEAD_NAME &&
m.tmuxPaneId &&
m.backendType &&
isPaneBackend(m.backendType),
)
if (paneMembers.length === 0) return
const [{ ensureBackendsRegistered, getBackendByType }, { isInsideTmux }] =
await Promise.all([
import('./backends/registry.js'),
import('./backends/detection.js'),
])
await ensureBackendsRegistered()
const useExternalSession = !(await isInsideTmux())
await Promise.allSettled(
paneMembers.map(async m => {
// filter above guarantees these; narrow for the type system
if (!m.tmuxPaneId || !m.backendType || !isPaneBackend(m.backendType)) {
return
}
const ok = await getBackendByType(m.backendType).killPane(
m.tmuxPaneId,
useExternalSession,
)
logForDebugging(
`cleanupSessionTeams: killPane ${m.name} (${m.backendType} ${m.tmuxPaneId}) → ${ok}`,
)
}),
)
}
/**
* Cleans up team and task directories for a given team name.
* Also cleans up git worktrees created for teammates.
* Called when a swarm session is terminated.
*/
export async function cleanupTeamDirectories(teamName: string): Promise<void> {
const sanitizedName = sanitizeName(teamName)
// Read team file to get worktree paths BEFORE deleting the team directory
const teamFile = readTeamFile(teamName)
const worktreePaths: string[] = []
if (teamFile) {
for (const member of teamFile.members) {
if (member.worktreePath) {
worktreePaths.push(member.worktreePath)
}
}
}
// Clean up worktrees first
for (const worktreePath of worktreePaths) {
await destroyWorktree(worktreePath)
}
// Clean up team directory (~/.claude/teams/{team-name}/)
const teamDir = getTeamDir(teamName)
try {
await rm(teamDir, { recursive: true, force: true })
logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`)
} catch (error) {
logForDebugging(
`[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`,
)
}
// Clean up tasks directory (~/.claude/tasks/{taskListId}/)
// The leader and teammates all store tasks under the sanitized team name.
const tasksDir = getTasksDir(sanitizedName)
try {
await rm(tasksDir, { recursive: true, force: true })
logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`)
notifyTasksUpdated()
} catch (error) {
logForDebugging(
`[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`,
)
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Teammate Initialization Module
*
* Handles initialization for Claude Code instances running as teammates in a swarm.
* Registers a Stop hook to notify the team leader when the teammate becomes idle.
*/
import type { AppState } from '../../state/AppState.js'
import { logForDebugging } from '../debug.js'
import { addFunctionHook } from '../hooks/sessionHooks.js'
import { applyPermissionUpdate } from '../permissions/PermissionUpdate.js'
import { jsonStringify } from '../slowOperations.js'
import { getTeammateColor } from '../teammate.js'
import {
createIdleNotification,
getLastPeerDmSummary,
writeToMailbox,
} from '../teammateMailbox.js'
import { readTeamFile, setMemberActive } from './teamHelpers.js'
/**
* Initializes hooks for a teammate running in a swarm.
* Should be called early in session startup after AppState is available.
*
* Registers a Stop hook that sends an idle notification to the team leader
* when this teammate's session stops.
*/
export function initializeTeammateHooks(
setAppState: (updater: (prev: AppState) => AppState) => void,
sessionId: string,
teamInfo: { teamName: string; agentId: string; agentName: string },
): void {
const { teamName, agentId, agentName } = teamInfo
// Read team file to get leader ID
const teamFile = readTeamFile(teamName)
if (!teamFile) {
logForDebugging(`[TeammateInit] Team file not found for team: ${teamName}`)
return
}
const leadAgentId = teamFile.leadAgentId
// Apply team-wide allowed paths if any exist
if (teamFile.teamAllowedPaths && teamFile.teamAllowedPaths.length > 0) {
logForDebugging(
`[TeammateInit] Found ${teamFile.teamAllowedPaths.length} team-wide allowed path(s)`,
)
for (const allowedPath of teamFile.teamAllowedPaths) {
// For absolute paths (starting with /), prepend one / to create //path/** pattern
// For relative paths, just use path/**
const ruleContent = allowedPath.path.startsWith('/')
? `/${allowedPath.path}/**`
: `${allowedPath.path}/**`
logForDebugging(
`[TeammateInit] Applying team permission: ${allowedPath.toolName} allowed in ${allowedPath.path} (rule: ${ruleContent})`,
)
setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdate(
prev.toolPermissionContext,
{
type: 'addRules',
rules: [
{
toolName: allowedPath.toolName,
ruleContent,
},
],
behavior: 'allow',
destination: 'session',
},
),
}))
}
}
// Find the leader's name from the members array
const leadMember = teamFile.members.find(m => m.agentId === leadAgentId)
const leadAgentName = leadMember?.name || 'team-lead'
// Don't register hook if this agent is the leader
if (agentId === leadAgentId) {
logForDebugging(
'[TeammateInit] This agent is the team leader - skipping idle notification hook',
)
return
}
logForDebugging(
`[TeammateInit] Registering Stop hook for teammate ${agentName} to notify leader ${leadAgentName}`,
)
// Register Stop hook to notify leader when this teammate stops
addFunctionHook(
setAppState,
sessionId,
'Stop',
'', // No matcher - applies to all Stop events
async (messages, _signal) => {
// Mark this teammate as idle in the team config (fire and forget)
void setMemberActive(teamName, agentName, false)
// Send idle notification to the team leader using agent name (not UUID)
// Must await to ensure the write completes before process shutdown
const notification = createIdleNotification(agentName, {
idleReason: 'available',
summary: getLastPeerDmSummary(messages),
})
await writeToMailbox(leadAgentName, {
from: agentName,
text: jsonStringify(notification),
timestamp: new Date().toISOString(),
color: getTeammateColor(),
})
logForDebugging(
`[TeammateInit] Sent idle notification to leader ${leadAgentName}`,
)
return true // Don't block the Stop
},
'Failed to send idle notification to team leader',
{
timeout: 10000,
},
)
}
+107
View File
@@ -0,0 +1,107 @@
import type { AgentColorName } from '../../tools/AgentTool/agentColorManager.js'
import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js'
import { detectAndGetBackend } from './backends/registry.js'
import type { PaneBackend } from './backends/types.js'
// Track color assignments for teammates (persisted per session)
const teammateColorAssignments = new Map<string, AgentColorName>()
let colorIndex = 0
/**
* Gets the appropriate backend for the current environment.
* detectAndGetBackend() caches internally — no need for a second cache here.
*/
async function getBackend(): Promise<PaneBackend> {
return (await detectAndGetBackend()).backend
}
/**
* Assigns a unique color to a teammate from the available palette.
* Colors are assigned in round-robin order.
*/
export function assignTeammateColor(teammateId: string): AgentColorName {
const existing = teammateColorAssignments.get(teammateId)
if (existing) {
return existing
}
const color = AGENT_COLORS[colorIndex % AGENT_COLORS.length]!
teammateColorAssignments.set(teammateId, color)
colorIndex++
return color
}
/**
* Gets the assigned color for a teammate, if any.
*/
export function getTeammateColor(
teammateId: string,
): AgentColorName | undefined {
return teammateColorAssignments.get(teammateId)
}
/**
* Clears all teammate color assignments.
* Called during team cleanup to reset state for potential new teams.
*/
export function clearTeammateColors(): void {
teammateColorAssignments.clear()
colorIndex = 0
}
/**
* Checks if we're currently running inside a tmux session.
* Uses the detection module directly for this check.
*/
export async function isInsideTmux(): Promise<boolean> {
const { isInsideTmux: checkTmux } = await import('./backends/detection.js')
return checkTmux()
}
/**
* Creates a new teammate pane in the swarm view.
* Automatically selects the appropriate backend (tmux or iTerm2) based on environment.
*
* When running INSIDE tmux:
* - Uses TmuxBackend to split the current window
* - Leader stays on left (30%), teammates on right (70%)
*
* When running in iTerm2 (not in tmux) with it2 CLI:
* - Uses ITermBackend for native iTerm2 split panes
*
* When running OUTSIDE tmux/iTerm2:
* - Falls back to TmuxBackend with external claude-swarm session
*/
export async function createTeammatePaneInSwarmView(
teammateName: string,
teammateColor: AgentColorName,
): Promise<{ paneId: string; isFirstTeammate: boolean }> {
const backend = await getBackend()
return backend.createTeammatePaneInSwarmView(teammateName, teammateColor)
}
/**
* Enables pane border status for a window (shows pane titles).
* Delegates to the detected backend.
*/
export async function enablePaneBorderStatus(
windowTarget?: string,
useSwarmSocket = false,
): Promise<void> {
const backend = await getBackend()
return backend.enablePaneBorderStatus(windowTarget, useSwarmSocket)
}
/**
* Sends a command to a specific pane.
* Delegates to the detected backend.
*/
export async function sendCommandToPane(
paneId: string,
command: string,
useSwarmSocket = false,
): Promise<void> {
const backend = await getBackend()
return backend.sendCommandToPane(paneId, command, useSwarmSocket)
}
+10
View File
@@ -0,0 +1,10 @@
import { CLAUDE_OPUS_4_6_CONFIG } from '../model/configs.js'
import { getAPIProvider } from '../model/providers.js'
// @[MODEL LAUNCH]: Update the fallback model below.
// When the user has never set teammateDefaultModel in /config, new teammates
// use Opus 4.6. Must be provider-aware so Bedrock/Vertex/Foundry customers get
// the correct model ID.
export function getHardcodedTeammateModelFallback(): string {
return CLAUDE_OPUS_4_6_CONFIG[getAPIProvider()]
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Teammate-specific system prompt addendum.
*
* This is appended to the full main agent system prompt for teammates.
* It explains visibility constraints and communication requirements.
*/
export const TEAMMATE_SYSTEM_PROMPT_ADDENDUM = `
# Agent Teammate Communication
IMPORTANT: You are running as an agent in a team. To communicate with anyone on your team:
- Use the SendMessage tool with \`to: "<name>"\` to send messages to specific teammates
- Use the SendMessage tool with \`to: "*"\` sparingly for team-wide broadcasts
Just writing a response in text is not visible to others on your team - you MUST use the SendMessage tool.
The user interacts primarily with the team lead. Your work is coordinated through the task system and teammate messaging.
`