init claude-code
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user