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
+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'
}