init claude-code
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const TEAM_LEAD_NAME = 'team-lead'
|
||||
export const SWARM_SESSION_NAME = 'claude-swarm'
|
||||
export const SWARM_VIEW_WINDOW_NAME = 'swarm-view'
|
||||
export const TMUX_COMMAND = 'tmux'
|
||||
export const HIDDEN_SESSION_NAME = 'claude-hidden'
|
||||
|
||||
/**
|
||||
* Gets the socket name for external swarm sessions (when user is not in tmux).
|
||||
* Uses a separate socket to isolate swarm operations from user's tmux sessions.
|
||||
* Includes PID to ensure multiple Claude instances don't conflict.
|
||||
*/
|
||||
export function getSwarmSocketName(): string {
|
||||
return `claude-swarm-${process.pid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variable to override the command used to spawn teammate instances.
|
||||
* If not set, defaults to process.execPath (the current Claude binary).
|
||||
* This allows customization for different environments or testing.
|
||||
*/
|
||||
export const TEAMMATE_COMMAND_ENV_VAR = 'CLAUDE_CODE_TEAMMATE_COMMAND'
|
||||
|
||||
/**
|
||||
* Environment variable set on spawned teammates to indicate their assigned color.
|
||||
* Used for colored output and pane identification.
|
||||
*/
|
||||
export const TEAMMATE_COLOR_ENV_VAR = 'CLAUDE_CODE_AGENT_COLOR'
|
||||
|
||||
/**
|
||||
* Environment variable set on spawned teammates to require plan mode before implementation.
|
||||
* When set to 'true', teammates must enter plan mode and get approval before writing code.
|
||||
*/
|
||||
export const PLAN_MODE_REQUIRED_ENV_VAR = 'CLAUDE_CODE_PLAN_MODE_REQUIRED'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Leader Permission Bridge
|
||||
*
|
||||
* Module-level bridge that allows the REPL to register its setToolUseConfirmQueue
|
||||
* and setToolPermissionContext functions for in-process teammates to use.
|
||||
*
|
||||
* When an in-process teammate requests permissions, it uses the standard
|
||||
* ToolUseConfirm dialog rather than the worker permission badge. This bridge
|
||||
* makes the REPL's queue setter and permission context setter accessible
|
||||
* from non-React code in the in-process runner.
|
||||
*/
|
||||
|
||||
import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
|
||||
import type { ToolPermissionContext } from '../../Tool.js'
|
||||
|
||||
export type SetToolUseConfirmQueueFn = (
|
||||
updater: (prev: ToolUseConfirm[]) => ToolUseConfirm[],
|
||||
) => void
|
||||
|
||||
export type SetToolPermissionContextFn = (
|
||||
context: ToolPermissionContext,
|
||||
options?: { preserveMode?: boolean },
|
||||
) => void
|
||||
|
||||
let registeredSetter: SetToolUseConfirmQueueFn | null = null
|
||||
let registeredPermissionContextSetter: SetToolPermissionContextFn | null = null
|
||||
|
||||
export function registerLeaderToolUseConfirmQueue(
|
||||
setter: SetToolUseConfirmQueueFn,
|
||||
): void {
|
||||
registeredSetter = setter
|
||||
}
|
||||
|
||||
export function getLeaderToolUseConfirmQueue(): SetToolUseConfirmQueueFn | null {
|
||||
return registeredSetter
|
||||
}
|
||||
|
||||
export function unregisterLeaderToolUseConfirmQueue(): void {
|
||||
registeredSetter = null
|
||||
}
|
||||
|
||||
export function registerLeaderSetToolPermissionContext(
|
||||
setter: SetToolPermissionContextFn,
|
||||
): void {
|
||||
registeredPermissionContextSetter = setter
|
||||
}
|
||||
|
||||
export function getLeaderSetToolPermissionContext(): SetToolPermissionContextFn | null {
|
||||
return registeredPermissionContextSetter
|
||||
}
|
||||
|
||||
export function unregisterLeaderSetToolPermissionContext(): void {
|
||||
registeredPermissionContextSetter = null
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* Synchronized Permission Prompts for Agent Swarms
|
||||
*
|
||||
* This module provides infrastructure for coordinating permission prompts across
|
||||
* multiple agents in a swarm. When a worker agent needs permission for a tool use,
|
||||
* it can forward the request to the team leader, who can then approve or deny it.
|
||||
*
|
||||
* The system uses the teammate mailbox for message passing:
|
||||
* - Workers send permission requests to the leader's mailbox
|
||||
* - Leaders send permission responses to the worker's mailbox
|
||||
*
|
||||
* Flow:
|
||||
* 1. Worker agent encounters a permission prompt
|
||||
* 2. Worker sends a permission_request message to the leader's mailbox
|
||||
* 3. Leader polls for mailbox messages and detects permission requests
|
||||
* 4. User approves/denies via the leader's UI
|
||||
* 5. Leader sends a permission_response message to the worker's mailbox
|
||||
* 6. Worker polls mailbox for responses and continues execution
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getErrnoCode } from '../errors.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import * as lockfile from '../lockfile.js'
|
||||
import { logError } from '../log.js'
|
||||
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import {
|
||||
getAgentId,
|
||||
getAgentName,
|
||||
getTeammateColor,
|
||||
getTeamName,
|
||||
} from '../teammate.js'
|
||||
import {
|
||||
createPermissionRequestMessage,
|
||||
createPermissionResponseMessage,
|
||||
createSandboxPermissionRequestMessage,
|
||||
createSandboxPermissionResponseMessage,
|
||||
writeToMailbox,
|
||||
} from '../teammateMailbox.js'
|
||||
import { getTeamDir, readTeamFileAsync } from './teamHelpers.js'
|
||||
|
||||
/**
|
||||
* Full request schema for a permission request from a worker to the leader
|
||||
*/
|
||||
export const SwarmPermissionRequestSchema = lazySchema(() =>
|
||||
z.object({
|
||||
/** Unique identifier for this request */
|
||||
id: z.string(),
|
||||
/** Worker's CLAUDE_CODE_AGENT_ID */
|
||||
workerId: z.string(),
|
||||
/** Worker's CLAUDE_CODE_AGENT_NAME */
|
||||
workerName: z.string(),
|
||||
/** Worker's CLAUDE_CODE_AGENT_COLOR */
|
||||
workerColor: z.string().optional(),
|
||||
/** Team name for routing */
|
||||
teamName: z.string(),
|
||||
/** Tool name requiring permission (e.g., "Bash", "Edit") */
|
||||
toolName: z.string(),
|
||||
/** Original toolUseID from worker's context */
|
||||
toolUseId: z.string(),
|
||||
/** Human-readable description of the tool use */
|
||||
description: z.string(),
|
||||
/** Serialized tool input */
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
/** Suggested permission rules from the permission result */
|
||||
permissionSuggestions: z.array(z.unknown()),
|
||||
/** Status of the request */
|
||||
status: z.enum(['pending', 'approved', 'rejected']),
|
||||
/** Who resolved the request */
|
||||
resolvedBy: z.enum(['worker', 'leader']).optional(),
|
||||
/** Timestamp when resolved */
|
||||
resolvedAt: z.number().optional(),
|
||||
/** Rejection feedback message */
|
||||
feedback: z.string().optional(),
|
||||
/** Modified input if changed by resolver */
|
||||
updatedInput: z.record(z.string(), z.unknown()).optional(),
|
||||
/** "Always allow" rules applied during resolution */
|
||||
permissionUpdates: z.array(z.unknown()).optional(),
|
||||
/** Timestamp when request was created */
|
||||
createdAt: z.number(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type SwarmPermissionRequest = z.infer<
|
||||
ReturnType<typeof SwarmPermissionRequestSchema>
|
||||
>
|
||||
|
||||
/**
|
||||
* Resolution data returned when leader/worker resolves a request
|
||||
*/
|
||||
export type PermissionResolution = {
|
||||
/** Decision: approved or rejected */
|
||||
decision: 'approved' | 'rejected'
|
||||
/** Who resolved it */
|
||||
resolvedBy: 'worker' | 'leader'
|
||||
/** Optional feedback message if rejected */
|
||||
feedback?: string
|
||||
/** Optional updated input if the resolver modified it */
|
||||
updatedInput?: Record<string, unknown>
|
||||
/** Permission updates to apply (e.g., "always allow" rules) */
|
||||
permissionUpdates?: PermissionUpdate[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory for a team's permission requests
|
||||
* Path: ~/.claude/teams/{teamName}/permissions/
|
||||
*/
|
||||
export function getPermissionDir(teamName: string): string {
|
||||
return join(getTeamDir(teamName), 'permissions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pending directory for a team
|
||||
*/
|
||||
function getPendingDir(teamName: string): string {
|
||||
return join(getPermissionDir(teamName), 'pending')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved directory for a team
|
||||
*/
|
||||
function getResolvedDir(teamName: string): string {
|
||||
return join(getPermissionDir(teamName), 'resolved')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the permissions directory structure exists (async)
|
||||
*/
|
||||
async function ensurePermissionDirsAsync(teamName: string): Promise<void> {
|
||||
const permDir = getPermissionDir(teamName)
|
||||
const pendingDir = getPendingDir(teamName)
|
||||
const resolvedDir = getResolvedDir(teamName)
|
||||
|
||||
for (const dir of [permDir, pendingDir, resolvedDir]) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a pending request file
|
||||
*/
|
||||
function getPendingRequestPath(teamName: string, requestId: string): string {
|
||||
return join(getPendingDir(teamName), `${requestId}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a resolved request file
|
||||
*/
|
||||
function getResolvedRequestPath(teamName: string, requestId: string): string {
|
||||
return join(getResolvedDir(teamName), `${requestId}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
*/
|
||||
export function generateRequestId(): string {
|
||||
return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new SwarmPermissionRequest object
|
||||
*/
|
||||
export function createPermissionRequest(params: {
|
||||
toolName: string
|
||||
toolUseId: string
|
||||
input: Record<string, unknown>
|
||||
description: string
|
||||
permissionSuggestions?: unknown[]
|
||||
teamName?: string
|
||||
workerId?: string
|
||||
workerName?: string
|
||||
workerColor?: string
|
||||
}): SwarmPermissionRequest {
|
||||
const teamName = params.teamName || getTeamName()
|
||||
const workerId = params.workerId || getAgentId()
|
||||
const workerName = params.workerName || getAgentName()
|
||||
const workerColor = params.workerColor || getTeammateColor()
|
||||
|
||||
if (!teamName) {
|
||||
throw new Error('Team name is required for permission requests')
|
||||
}
|
||||
if (!workerId) {
|
||||
throw new Error('Worker ID is required for permission requests')
|
||||
}
|
||||
if (!workerName) {
|
||||
throw new Error('Worker name is required for permission requests')
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateRequestId(),
|
||||
workerId,
|
||||
workerName,
|
||||
workerColor,
|
||||
teamName,
|
||||
toolName: params.toolName,
|
||||
toolUseId: params.toolUseId,
|
||||
description: params.description,
|
||||
input: params.input,
|
||||
permissionSuggestions: params.permissionSuggestions || [],
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a permission request to the pending directory with file locking
|
||||
* Called by worker agents when they need permission approval from the leader
|
||||
*
|
||||
* @returns The written request
|
||||
*/
|
||||
export async function writePermissionRequest(
|
||||
request: SwarmPermissionRequest,
|
||||
): Promise<SwarmPermissionRequest> {
|
||||
await ensurePermissionDirsAsync(request.teamName)
|
||||
|
||||
const pendingPath = getPendingRequestPath(request.teamName, request.id)
|
||||
const lockDir = getPendingDir(request.teamName)
|
||||
|
||||
// Create a directory-level lock file for atomic writes
|
||||
const lockFilePath = join(lockDir, '.lock')
|
||||
await writeFile(lockFilePath, '', 'utf-8')
|
||||
|
||||
let release: (() => Promise<void>) | undefined
|
||||
try {
|
||||
release = await lockfile.lock(lockFilePath)
|
||||
|
||||
// Write the request file
|
||||
await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8')
|
||||
|
||||
logForDebugging(
|
||||
`[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`,
|
||||
)
|
||||
|
||||
return request
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to write permission request: ${error}`,
|
||||
)
|
||||
logError(error)
|
||||
throw error
|
||||
} finally {
|
||||
if (release) {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all pending permission requests for a team
|
||||
* Called by the team leader to see what requests need attention
|
||||
*/
|
||||
export async function readPendingPermissions(
|
||||
teamName?: string,
|
||||
): Promise<SwarmPermissionRequest[]> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
logForDebugging('[PermissionSync] No team name available')
|
||||
return []
|
||||
}
|
||||
|
||||
const pendingDir = getPendingDir(team)
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(pendingDir)
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`)
|
||||
logError(e)
|
||||
return []
|
||||
}
|
||||
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock')
|
||||
|
||||
const results = await Promise.all(
|
||||
jsonFiles.map(async file => {
|
||||
const filePath = join(pendingDir, file)
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const parsed = SwarmPermissionRequestSchema().safeParse(
|
||||
jsonParse(content),
|
||||
)
|
||||
if (parsed.success) {
|
||||
return parsed.data
|
||||
}
|
||||
logForDebugging(
|
||||
`[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`,
|
||||
)
|
||||
return null
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to read request file ${file}: ${err}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const requests = results.filter(r => r !== null)
|
||||
|
||||
// Sort by creation time (oldest first)
|
||||
requests.sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resolved permission request by ID
|
||||
* Called by workers to check if their request has been resolved
|
||||
*
|
||||
* @returns The resolved request, or null if not yet resolved
|
||||
*/
|
||||
export async function readResolvedPermission(
|
||||
requestId: string,
|
||||
teamName?: string,
|
||||
): Promise<SwarmPermissionRequest | null> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolvedPath = getResolvedRequestPath(team, requestId)
|
||||
|
||||
try {
|
||||
const content = await readFile(resolvedPath, 'utf-8')
|
||||
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
|
||||
if (parsed.success) {
|
||||
return parsed.data
|
||||
}
|
||||
logForDebugging(
|
||||
`[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`,
|
||||
)
|
||||
return null
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to read resolved request ${requestId}: ${e}`,
|
||||
)
|
||||
logError(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a permission request
|
||||
* Called by the team leader (or worker in self-resolution cases)
|
||||
*
|
||||
* Writes the resolution to resolved/, removes from pending/
|
||||
*/
|
||||
export async function resolvePermission(
|
||||
requestId: string,
|
||||
resolution: PermissionResolution,
|
||||
teamName?: string,
|
||||
): Promise<boolean> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
logForDebugging('[PermissionSync] No team name available')
|
||||
return false
|
||||
}
|
||||
|
||||
await ensurePermissionDirsAsync(team)
|
||||
|
||||
const pendingPath = getPendingRequestPath(team, requestId)
|
||||
const resolvedPath = getResolvedRequestPath(team, requestId)
|
||||
const lockFilePath = join(getPendingDir(team), '.lock')
|
||||
|
||||
await writeFile(lockFilePath, '', 'utf-8')
|
||||
|
||||
let release: (() => Promise<void>) | undefined
|
||||
try {
|
||||
release = await lockfile.lock(lockFilePath)
|
||||
|
||||
// Read the pending request
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(pendingPath, 'utf-8')
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'ENOENT') {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Pending request not found: ${requestId}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
|
||||
if (!parsed.success) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const request = parsed.data
|
||||
|
||||
// Update the request with resolution data
|
||||
const resolvedRequest: SwarmPermissionRequest = {
|
||||
...request,
|
||||
status: resolution.decision === 'approved' ? 'approved' : 'rejected',
|
||||
resolvedBy: resolution.resolvedBy,
|
||||
resolvedAt: Date.now(),
|
||||
feedback: resolution.feedback,
|
||||
updatedInput: resolution.updatedInput,
|
||||
permissionUpdates: resolution.permissionUpdates,
|
||||
}
|
||||
|
||||
// Write to resolved directory
|
||||
await writeFile(
|
||||
resolvedPath,
|
||||
jsonStringify(resolvedRequest, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
// Remove from pending directory
|
||||
await unlink(pendingPath)
|
||||
|
||||
logForDebugging(
|
||||
`[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`,
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`)
|
||||
logError(error)
|
||||
return false
|
||||
} finally {
|
||||
if (release) {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old resolved permission files
|
||||
* Called periodically to prevent file accumulation
|
||||
*
|
||||
* @param teamName - Team name
|
||||
* @param maxAgeMs - Maximum age in milliseconds (default: 1 hour)
|
||||
*/
|
||||
export async function cleanupOldResolutions(
|
||||
teamName?: string,
|
||||
maxAgeMs = 3600000,
|
||||
): Promise<number> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const resolvedDir = getResolvedDir(team)
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(resolvedDir)
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'ENOENT') {
|
||||
return 0
|
||||
}
|
||||
logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`)
|
||||
logError(e)
|
||||
return 0
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'))
|
||||
|
||||
const cleanupResults = await Promise.all(
|
||||
jsonFiles.map(async file => {
|
||||
const filePath = join(resolvedDir, file)
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const request = jsonParse(content) as SwarmPermissionRequest
|
||||
|
||||
// Check if the resolution is old enough to clean up
|
||||
// Use >= to handle edge case where maxAgeMs is 0 (clean up everything)
|
||||
const resolvedAt = request.resolvedAt || request.createdAt
|
||||
if (now - resolvedAt >= maxAgeMs) {
|
||||
await unlink(filePath)
|
||||
logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
} catch {
|
||||
// If we can't parse it, clean it up anyway
|
||||
try {
|
||||
await unlink(filePath)
|
||||
return 1
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0)
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cleaned up ${cleanedCount} old resolutions`,
|
||||
)
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy response type for worker polling
|
||||
* Used for backward compatibility with worker integration code
|
||||
*/
|
||||
export type PermissionResponse = {
|
||||
/** ID of the request this responds to */
|
||||
requestId: string
|
||||
/** Decision: approved or denied */
|
||||
decision: 'approved' | 'denied'
|
||||
/** Timestamp when response was created */
|
||||
timestamp: string
|
||||
/** Optional feedback message if denied */
|
||||
feedback?: string
|
||||
/** Optional updated input if the resolver modified it */
|
||||
updatedInput?: Record<string, unknown>
|
||||
/** Permission updates to apply (e.g., "always allow" rules) */
|
||||
permissionUpdates?: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for a permission response (worker-side convenience function)
|
||||
* Converts the resolved request into a simpler response format
|
||||
*
|
||||
* @returns The permission response, or null if not yet resolved
|
||||
*/
|
||||
export async function pollForResponse(
|
||||
requestId: string,
|
||||
_agentName?: string,
|
||||
teamName?: string,
|
||||
): Promise<PermissionResponse | null> {
|
||||
const resolved = await readResolvedPermission(requestId, teamName)
|
||||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: resolved.id,
|
||||
decision: resolved.status === 'approved' ? 'approved' : 'denied',
|
||||
timestamp: resolved.resolvedAt
|
||||
? new Date(resolved.resolvedAt).toISOString()
|
||||
: new Date(resolved.createdAt).toISOString(),
|
||||
feedback: resolved.feedback,
|
||||
updatedInput: resolved.updatedInput,
|
||||
permissionUpdates: resolved.permissionUpdates,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worker's response after processing
|
||||
* This is an alias for deleteResolvedPermission for backward compatibility
|
||||
*/
|
||||
export async function removeWorkerResponse(
|
||||
requestId: string,
|
||||
_agentName?: string,
|
||||
teamName?: string,
|
||||
): Promise<void> {
|
||||
await deleteResolvedPermission(requestId, teamName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current agent is a team leader
|
||||
*/
|
||||
export function isTeamLeader(teamName?: string): boolean {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Team leaders don't have an agent ID set, or their ID is 'team-lead'
|
||||
const agentId = getAgentId()
|
||||
|
||||
return !agentId || agentId === 'team-lead'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current agent is a worker in a swarm
|
||||
*/
|
||||
export function isSwarmWorker(): boolean {
|
||||
const teamName = getTeamName()
|
||||
const agentId = getAgentId()
|
||||
|
||||
return !!teamName && !!agentId && !isTeamLeader()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a resolved permission file
|
||||
* Called after a worker has processed the resolution
|
||||
*/
|
||||
export async function deleteResolvedPermission(
|
||||
requestId: string,
|
||||
teamName?: string,
|
||||
): Promise<boolean> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resolvedPath = getResolvedRequestPath(team, requestId)
|
||||
|
||||
try {
|
||||
await unlink(resolvedPath)
|
||||
logForDebugging(
|
||||
`[PermissionSync] Deleted resolved permission: ${requestId}`,
|
||||
)
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to delete resolved permission: ${e}`,
|
||||
)
|
||||
logError(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a permission request (alias for writePermissionRequest)
|
||||
* Provided for backward compatibility with worker integration code
|
||||
*/
|
||||
export const submitPermissionRequest = writePermissionRequest
|
||||
|
||||
// ============================================================================
|
||||
// Mailbox-Based Permission System
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the leader's name from the team file
|
||||
* This is needed to send permission requests to the leader's mailbox
|
||||
*/
|
||||
export async function getLeaderName(teamName?: string): Promise<string | null> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
return null
|
||||
}
|
||||
|
||||
const teamFile = await readTeamFileAsync(team)
|
||||
if (!teamFile) {
|
||||
logForDebugging(`[PermissionSync] Team file not found for team: ${team}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const leadMember = teamFile.members.find(
|
||||
m => m.agentId === teamFile.leadAgentId,
|
||||
)
|
||||
return leadMember?.name || 'team-lead'
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a permission request to the leader via mailbox.
|
||||
* This is the new mailbox-based approach that replaces the file-based pending directory.
|
||||
*
|
||||
* @param request - The permission request to send
|
||||
* @returns true if the message was sent successfully
|
||||
*/
|
||||
export async function sendPermissionRequestViaMailbox(
|
||||
request: SwarmPermissionRequest,
|
||||
): Promise<boolean> {
|
||||
const leaderName = await getLeaderName(request.teamName)
|
||||
if (!leaderName) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cannot send permission request: leader name not found`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the permission request message
|
||||
const message = createPermissionRequestMessage({
|
||||
request_id: request.id,
|
||||
agent_id: request.workerName,
|
||||
tool_name: request.toolName,
|
||||
tool_use_id: request.toolUseId,
|
||||
description: request.description,
|
||||
input: request.input,
|
||||
permission_suggestions: request.permissionSuggestions,
|
||||
})
|
||||
|
||||
// Send to leader's mailbox (routes to in-process or file-based based on recipient)
|
||||
await writeToMailbox(
|
||||
leaderName,
|
||||
{
|
||||
from: request.workerName,
|
||||
text: jsonStringify(message),
|
||||
timestamp: new Date().toISOString(),
|
||||
color: request.workerColor,
|
||||
},
|
||||
request.teamName,
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`,
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to send permission request via mailbox: ${error}`,
|
||||
)
|
||||
logError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a permission response to a worker via mailbox.
|
||||
* This is the new mailbox-based approach that replaces the file-based resolved directory.
|
||||
*
|
||||
* @param workerName - The worker's name to send the response to
|
||||
* @param resolution - The permission resolution
|
||||
* @param requestId - The original request ID
|
||||
* @param teamName - The team name
|
||||
* @returns true if the message was sent successfully
|
||||
*/
|
||||
export async function sendPermissionResponseViaMailbox(
|
||||
workerName: string,
|
||||
resolution: PermissionResolution,
|
||||
requestId: string,
|
||||
teamName?: string,
|
||||
): Promise<boolean> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cannot send permission response: team name not found`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the permission response message
|
||||
const message = createPermissionResponseMessage({
|
||||
request_id: requestId,
|
||||
subtype: resolution.decision === 'approved' ? 'success' : 'error',
|
||||
error: resolution.feedback,
|
||||
updated_input: resolution.updatedInput,
|
||||
permission_updates: resolution.permissionUpdates,
|
||||
})
|
||||
|
||||
// Get the sender name (leader's name)
|
||||
const senderName = getAgentName() || 'team-lead'
|
||||
|
||||
// Send to worker's mailbox (routes to in-process or file-based based on recipient)
|
||||
await writeToMailbox(
|
||||
workerName,
|
||||
{
|
||||
from: senderName,
|
||||
text: jsonStringify(message),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
team,
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`,
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to send permission response via mailbox: ${error}`,
|
||||
)
|
||||
logError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sandbox Permission Mailbox System
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique sandbox permission request ID
|
||||
*/
|
||||
export function generateSandboxRequestId(): string {
|
||||
return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sandbox permission request to the leader via mailbox.
|
||||
* Called by workers when sandbox runtime needs network access approval.
|
||||
*
|
||||
* @param host - The host requesting network access
|
||||
* @param requestId - Unique ID for this request
|
||||
* @param teamName - Optional team name
|
||||
* @returns true if the message was sent successfully
|
||||
*/
|
||||
export async function sendSandboxPermissionRequestViaMailbox(
|
||||
host: string,
|
||||
requestId: string,
|
||||
teamName?: string,
|
||||
): Promise<boolean> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cannot send sandbox permission request: team name not found`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const leaderName = await getLeaderName(team)
|
||||
if (!leaderName) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cannot send sandbox permission request: leader name not found`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const workerId = getAgentId()
|
||||
const workerName = getAgentName()
|
||||
const workerColor = getTeammateColor()
|
||||
|
||||
if (!workerId || !workerName) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const message = createSandboxPermissionRequestMessage({
|
||||
requestId,
|
||||
workerId,
|
||||
workerName,
|
||||
workerColor,
|
||||
host,
|
||||
})
|
||||
|
||||
// Send to leader's mailbox (routes to in-process or file-based based on recipient)
|
||||
await writeToMailbox(
|
||||
leaderName,
|
||||
{
|
||||
from: workerName,
|
||||
text: jsonStringify(message),
|
||||
timestamp: new Date().toISOString(),
|
||||
color: workerColor,
|
||||
},
|
||||
team,
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`,
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`,
|
||||
)
|
||||
logError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sandbox permission response to a worker via mailbox.
|
||||
* Called by the leader when approving/denying a sandbox network access request.
|
||||
*
|
||||
* @param workerName - The worker's name to send the response to
|
||||
* @param requestId - The original request ID
|
||||
* @param host - The host that was approved/denied
|
||||
* @param allow - Whether the connection is allowed
|
||||
* @param teamName - Optional team name
|
||||
* @returns true if the message was sent successfully
|
||||
*/
|
||||
export async function sendSandboxPermissionResponseViaMailbox(
|
||||
workerName: string,
|
||||
requestId: string,
|
||||
host: string,
|
||||
allow: boolean,
|
||||
teamName?: string,
|
||||
): Promise<boolean> {
|
||||
const team = teamName || getTeamName()
|
||||
if (!team) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Cannot send sandbox permission response: team name not found`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const message = createSandboxPermissionResponseMessage({
|
||||
requestId,
|
||||
host,
|
||||
allow,
|
||||
})
|
||||
|
||||
const senderName = getAgentName() || 'team-lead'
|
||||
|
||||
// Send to worker's mailbox (routes to in-process or file-based based on recipient)
|
||||
await writeToMailbox(
|
||||
workerName,
|
||||
{
|
||||
from: senderName,
|
||||
text: jsonStringify(message),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
team,
|
||||
)
|
||||
|
||||
logForDebugging(
|
||||
`[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`,
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`,
|
||||
)
|
||||
logError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Swarm Reconnection Module
|
||||
*
|
||||
* Handles initialization of swarm context for teammates.
|
||||
* - Fresh spawns: Initialize from CLI args (set in main.tsx via dynamicTeamContext)
|
||||
* - Resumed sessions: Initialize from teamName/agentName stored in the transcript
|
||||
*/
|
||||
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { logError } from '../log.js'
|
||||
import { getDynamicTeamContext } from '../teammate.js'
|
||||
import { getTeamFilePath, readTeamFile } from './teamHelpers.js'
|
||||
|
||||
/**
|
||||
* Computes the initial teamContext for AppState.
|
||||
*
|
||||
* This is called synchronously in main.tsx to compute the teamContext
|
||||
* BEFORE the first render, eliminating the need for useEffect workarounds.
|
||||
*
|
||||
* @returns The teamContext object to include in initialState, or undefined if not a teammate
|
||||
*/
|
||||
export function computeInitialTeamContext():
|
||||
| AppState['teamContext']
|
||||
| undefined {
|
||||
// dynamicTeamContext is set in main.tsx from CLI args
|
||||
const context = getDynamicTeamContext()
|
||||
|
||||
if (!context?.teamName || !context?.agentName) {
|
||||
logForDebugging(
|
||||
'[Reconnection] computeInitialTeamContext: No teammate context set (not a teammate)',
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { teamName, agentId, agentName } = context
|
||||
|
||||
// Read team file to get lead agent ID
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
logError(
|
||||
new Error(
|
||||
`[computeInitialTeamContext] Could not read team file for ${teamName}`,
|
||||
),
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const teamFilePath = getTeamFilePath(teamName)
|
||||
|
||||
const isLeader = !agentId
|
||||
|
||||
logForDebugging(
|
||||
`[Reconnection] Computed initial team context for ${isLeader ? 'leader' : `teammate ${agentName}`} in team ${teamName}`,
|
||||
)
|
||||
|
||||
return {
|
||||
teamName,
|
||||
teamFilePath,
|
||||
leadAgentId: teamFile.leadAgentId,
|
||||
selfAgentId: agentId,
|
||||
selfAgentName: agentName,
|
||||
isLeader,
|
||||
teammates: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize teammate context from a resumed session.
|
||||
*
|
||||
* This is called when resuming a session that has teamName/agentName stored
|
||||
* in the transcript. It sets up teamContext in AppState so that heartbeat
|
||||
* and other swarm features work correctly.
|
||||
*/
|
||||
export function initializeTeammateContextFromSession(
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void,
|
||||
teamName: string,
|
||||
agentName: string,
|
||||
): void {
|
||||
// Read team file to get lead agent ID
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
logError(
|
||||
new Error(
|
||||
`[initializeTeammateContextFromSession] Could not read team file for ${teamName} (agent: ${agentName})`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the member in the team file to get their agentId
|
||||
const member = teamFile.members.find(m => m.name === agentName)
|
||||
if (!member) {
|
||||
logForDebugging(
|
||||
`[Reconnection] Member ${agentName} not found in team ${teamName} - may have been removed`,
|
||||
)
|
||||
}
|
||||
const agentId = member?.agentId
|
||||
|
||||
const teamFilePath = getTeamFilePath(teamName)
|
||||
|
||||
// Set teamContext in AppState
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
teamContext: {
|
||||
teamName,
|
||||
teamFilePath,
|
||||
leadAgentId: teamFile.leadAgentId,
|
||||
selfAgentId: agentId,
|
||||
selfAgentName: agentName,
|
||||
isLeader: false,
|
||||
teammates: {},
|
||||
},
|
||||
}))
|
||||
|
||||
logForDebugging(
|
||||
`[Reconnection] Initialized agent context from session for ${agentName} in team ${teamName}`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* In-process teammate spawning
|
||||
*
|
||||
* Creates and registers an in-process teammate task. Unlike process-based
|
||||
* teammates (tmux/iTerm2), in-process teammates run in the same Node.js
|
||||
* process using AsyncLocalStorage for context isolation.
|
||||
*
|
||||
* The actual agent execution loop is handled by InProcessTeammateTask
|
||||
* component (Task #14). This module handles:
|
||||
* 1. Creating TeammateContext
|
||||
* 2. Creating linked AbortController
|
||||
* 3. Registering InProcessTeammateTaskState in AppState
|
||||
* 4. Returning spawn result for backend
|
||||
*/
|
||||
|
||||
import sample from 'lodash-es/sample.js'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js'
|
||||
import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { createTaskStateBase, generateTaskId } from '../../Task.js'
|
||||
import type {
|
||||
InProcessTeammateTaskState,
|
||||
TeammateIdentity,
|
||||
} from '../../tasks/InProcessTeammateTask/types.js'
|
||||
import { createAbortController } from '../abortController.js'
|
||||
import { formatAgentId } from '../agentId.js'
|
||||
import { registerCleanup } from '../cleanupRegistry.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { emitTaskTerminatedSdk } from '../sdkEventQueue.js'
|
||||
import { evictTaskOutput } from '../task/diskOutput.js'
|
||||
import {
|
||||
evictTerminalTask,
|
||||
registerTask,
|
||||
STOPPED_DISPLAY_MS,
|
||||
} from '../task/framework.js'
|
||||
import { createTeammateContext } from '../teammateContext.js'
|
||||
import {
|
||||
isPerfettoTracingEnabled,
|
||||
registerAgent as registerPerfettoAgent,
|
||||
unregisterAgent as unregisterPerfettoAgent,
|
||||
} from '../telemetry/perfettoTracing.js'
|
||||
import { removeMemberByAgentId } from './teamHelpers.js'
|
||||
|
||||
type SetAppStateFn = (updater: (prev: AppState) => AppState) => void
|
||||
|
||||
/**
|
||||
* Minimal context required for spawning an in-process teammate.
|
||||
* This is a subset of ToolUseContext - only what spawnInProcessTeammate actually uses.
|
||||
*/
|
||||
export type SpawnContext = {
|
||||
setAppState: SetAppStateFn
|
||||
toolUseId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for spawning an in-process teammate.
|
||||
*/
|
||||
export type InProcessSpawnConfig = {
|
||||
/** Display name for the teammate, e.g., "researcher" */
|
||||
name: string
|
||||
/** Team this teammate belongs to */
|
||||
teamName: string
|
||||
/** Initial prompt/task for the teammate */
|
||||
prompt: string
|
||||
/** Optional UI color for the teammate */
|
||||
color?: string
|
||||
/** Whether teammate must enter plan mode before implementing */
|
||||
planModeRequired: boolean
|
||||
/** Optional model override for this teammate */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from spawning an in-process teammate.
|
||||
*/
|
||||
export type InProcessSpawnOutput = {
|
||||
/** Whether spawn was successful */
|
||||
success: boolean
|
||||
/** Full agent ID (format: "name@team") */
|
||||
agentId: string
|
||||
/** Task ID for tracking in AppState */
|
||||
taskId?: string
|
||||
/** AbortController for this teammate (linked to parent) */
|
||||
abortController?: AbortController
|
||||
/** Teammate context for AsyncLocalStorage */
|
||||
teammateContext?: ReturnType<typeof createTeammateContext>
|
||||
/** Error message if spawn failed */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns an in-process teammate.
|
||||
*
|
||||
* Creates the teammate's context, registers the task in AppState, and returns
|
||||
* the spawn result. The actual agent execution is driven by the
|
||||
* InProcessTeammateTask component which uses runWithTeammateContext() to
|
||||
* execute the agent loop with proper identity isolation.
|
||||
*
|
||||
* @param config - Spawn configuration
|
||||
* @param context - Context with setAppState for registering task
|
||||
* @returns Spawn result with teammate info
|
||||
*/
|
||||
export async function spawnInProcessTeammate(
|
||||
config: InProcessSpawnConfig,
|
||||
context: SpawnContext,
|
||||
): Promise<InProcessSpawnOutput> {
|
||||
const { name, teamName, prompt, color, planModeRequired, model } = config
|
||||
const { setAppState } = context
|
||||
|
||||
// Generate deterministic agent ID
|
||||
const agentId = formatAgentId(name, teamName)
|
||||
const taskId = generateTaskId('in_process_teammate')
|
||||
|
||||
logForDebugging(
|
||||
`[spawnInProcessTeammate] Spawning ${agentId} (taskId: ${taskId})`,
|
||||
)
|
||||
|
||||
try {
|
||||
// Create independent AbortController for this teammate
|
||||
// Teammates should not be aborted when the leader's query is interrupted
|
||||
const abortController = createAbortController()
|
||||
|
||||
// Get parent session ID for transcript correlation
|
||||
const parentSessionId = getSessionId()
|
||||
|
||||
// Create teammate identity (stored as plain data in AppState)
|
||||
const identity: TeammateIdentity = {
|
||||
agentId,
|
||||
agentName: name,
|
||||
teamName,
|
||||
color,
|
||||
planModeRequired,
|
||||
parentSessionId,
|
||||
}
|
||||
|
||||
// Create teammate context for AsyncLocalStorage
|
||||
// This will be used by runWithTeammateContext() during agent execution
|
||||
const teammateContext = createTeammateContext({
|
||||
agentId,
|
||||
agentName: name,
|
||||
teamName,
|
||||
color,
|
||||
planModeRequired,
|
||||
parentSessionId,
|
||||
abortController,
|
||||
})
|
||||
|
||||
// Register agent in Perfetto trace for hierarchy visualization
|
||||
if (isPerfettoTracingEnabled()) {
|
||||
registerPerfettoAgent(agentId, name, parentSessionId)
|
||||
}
|
||||
|
||||
// Create task state
|
||||
const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}`
|
||||
|
||||
const taskState: InProcessTeammateTaskState = {
|
||||
...createTaskStateBase(
|
||||
taskId,
|
||||
'in_process_teammate',
|
||||
description,
|
||||
context.toolUseId,
|
||||
),
|
||||
type: 'in_process_teammate',
|
||||
status: 'running',
|
||||
identity,
|
||||
prompt,
|
||||
model,
|
||||
abortController,
|
||||
awaitingPlanApproval: false,
|
||||
spinnerVerb: sample(getSpinnerVerbs()),
|
||||
pastTenseVerb: sample(TURN_COMPLETION_VERBS),
|
||||
permissionMode: planModeRequired ? 'plan' : 'default',
|
||||
isIdle: false,
|
||||
shutdownRequested: false,
|
||||
lastReportedToolCount: 0,
|
||||
lastReportedTokenCount: 0,
|
||||
pendingUserMessages: [],
|
||||
messages: [], // Initialize to empty array so getDisplayedMessages works immediately
|
||||
}
|
||||
|
||||
// Register cleanup handler for graceful shutdown
|
||||
const unregisterCleanup = registerCleanup(async () => {
|
||||
logForDebugging(`[spawnInProcessTeammate] Cleanup called for ${agentId}`)
|
||||
abortController.abort()
|
||||
// Task state will be updated by the execution loop when it detects abort
|
||||
})
|
||||
taskState.unregisterCleanup = unregisterCleanup
|
||||
|
||||
// Register task in AppState
|
||||
registerTask(taskState, setAppState)
|
||||
|
||||
logForDebugging(
|
||||
`[spawnInProcessTeammate] Registered ${agentId} in AppState`,
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agentId,
|
||||
taskId,
|
||||
abortController,
|
||||
teammateContext,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during spawn'
|
||||
logForDebugging(
|
||||
`[spawnInProcessTeammate] Failed to spawn ${agentId}: ${errorMessage}`,
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
agentId,
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills an in-process teammate by aborting its controller.
|
||||
*
|
||||
* Note: This is the implementation called by InProcessBackend.kill().
|
||||
*
|
||||
* @param taskId - Task ID of the teammate to kill
|
||||
* @param setAppState - AppState setter
|
||||
* @returns true if killed successfully
|
||||
*/
|
||||
export function killInProcessTeammate(
|
||||
taskId: string,
|
||||
setAppState: SetAppStateFn,
|
||||
): boolean {
|
||||
let killed = false
|
||||
let teamName: string | null = null
|
||||
let agentId: string | null = null
|
||||
let toolUseId: string | undefined
|
||||
let description: string | undefined
|
||||
|
||||
setAppState((prev: AppState) => {
|
||||
const task = prev.tasks[taskId]
|
||||
if (!task || task.type !== 'in_process_teammate') {
|
||||
return prev
|
||||
}
|
||||
|
||||
const teammateTask = task as InProcessTeammateTaskState
|
||||
|
||||
if (teammateTask.status !== 'running') {
|
||||
return prev
|
||||
}
|
||||
|
||||
// Capture identity for cleanup after state update
|
||||
teamName = teammateTask.identity.teamName
|
||||
agentId = teammateTask.identity.agentId
|
||||
toolUseId = teammateTask.toolUseId
|
||||
description = teammateTask.description
|
||||
|
||||
// Abort the controller to stop execution
|
||||
teammateTask.abortController?.abort()
|
||||
|
||||
// Call cleanup handler
|
||||
teammateTask.unregisterCleanup?.()
|
||||
|
||||
// Update task state and remove from teamContext.teammates
|
||||
killed = true
|
||||
|
||||
// Call pending idle callbacks to unblock any waiters (e.g., engine.waitForIdle)
|
||||
teammateTask.onIdleCallbacks?.forEach(cb => cb())
|
||||
|
||||
// Remove from teamContext.teammates using the agentId
|
||||
let updatedTeamContext = prev.teamContext
|
||||
if (prev.teamContext && prev.teamContext.teammates && agentId) {
|
||||
const { [agentId]: _, ...remainingTeammates } = prev.teamContext.teammates
|
||||
updatedTeamContext = {
|
||||
...prev.teamContext,
|
||||
teammates: remainingTeammates,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
teamContext: updatedTeamContext,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[taskId]: {
|
||||
...teammateTask,
|
||||
status: 'killed' as const,
|
||||
notified: true,
|
||||
endTime: Date.now(),
|
||||
onIdleCallbacks: [], // Clear callbacks to prevent stale references
|
||||
messages: teammateTask.messages?.length
|
||||
? [teammateTask.messages[teammateTask.messages.length - 1]!]
|
||||
: undefined,
|
||||
pendingUserMessages: [],
|
||||
inProgressToolUseIDs: undefined,
|
||||
abortController: undefined,
|
||||
unregisterCleanup: undefined,
|
||||
currentWorkAbortController: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Remove from team file (outside state updater to avoid file I/O in callback)
|
||||
if (teamName && agentId) {
|
||||
removeMemberByAgentId(teamName, agentId)
|
||||
}
|
||||
|
||||
if (killed) {
|
||||
void evictTaskOutput(taskId)
|
||||
// notified:true was pre-set so no XML notification fires; close the SDK
|
||||
// task_started bookend directly. The in-process runner's own
|
||||
// completion/failure emit guards on status==='running' so it won't
|
||||
// double-emit after seeing status:killed.
|
||||
emitTaskTerminatedSdk(taskId, 'stopped', {
|
||||
toolUseId,
|
||||
summary: description,
|
||||
})
|
||||
setTimeout(
|
||||
evictTerminalTask.bind(null, taskId, setAppState),
|
||||
STOPPED_DISPLAY_MS,
|
||||
)
|
||||
}
|
||||
|
||||
// Release perfetto agent registry entry
|
||||
if (agentId) {
|
||||
unregisterPerfettoAgent(agentId)
|
||||
}
|
||||
|
||||
return killed
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Shared utilities for spawning teammates across different backends.
|
||||
*/
|
||||
|
||||
import {
|
||||
getChromeFlagOverride,
|
||||
getFlagSettingsPath,
|
||||
getInlinePlugins,
|
||||
getMainLoopModelOverride,
|
||||
getSessionBypassPermissionsMode,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { quote } from '../bash/shellQuote.js'
|
||||
import { isInBundledMode } from '../bundledMode.js'
|
||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||
import { getTeammateModeFromSnapshot } from './backends/teammateModeSnapshot.js'
|
||||
import { TEAMMATE_COMMAND_ENV_VAR } from './constants.js'
|
||||
|
||||
/**
|
||||
* Gets the command to use for spawning teammate processes.
|
||||
* Uses TEAMMATE_COMMAND_ENV_VAR if set, otherwise falls back to the
|
||||
* current process executable path.
|
||||
*/
|
||||
export function getTeammateCommand(): string {
|
||||
if (process.env[TEAMMATE_COMMAND_ENV_VAR]) {
|
||||
return process.env[TEAMMATE_COMMAND_ENV_VAR]
|
||||
}
|
||||
return isInBundledMode() ? process.execPath : process.argv[1]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds CLI flags to propagate from the current session to spawned teammates.
|
||||
* This ensures teammates inherit important settings like permission mode,
|
||||
* model selection, and plugin configuration from their parent.
|
||||
*
|
||||
* @param options.planModeRequired - If true, don't inherit bypass permissions (plan mode takes precedence)
|
||||
* @param options.permissionMode - Permission mode to propagate
|
||||
*/
|
||||
export function buildInheritedCliFlags(options?: {
|
||||
planModeRequired?: boolean
|
||||
permissionMode?: PermissionMode
|
||||
}): string {
|
||||
const flags: string[] = []
|
||||
const { planModeRequired, permissionMode } = options || {}
|
||||
|
||||
// Propagate permission mode to teammates, but NOT if plan mode is required
|
||||
// Plan mode takes precedence over bypass permissions for safety
|
||||
if (planModeRequired) {
|
||||
// Don't inherit bypass permissions when plan mode is required
|
||||
} else if (
|
||||
permissionMode === 'bypassPermissions' ||
|
||||
getSessionBypassPermissionsMode()
|
||||
) {
|
||||
flags.push('--dangerously-skip-permissions')
|
||||
} else if (permissionMode === 'acceptEdits') {
|
||||
flags.push('--permission-mode acceptEdits')
|
||||
}
|
||||
|
||||
// Propagate --model if explicitly set via CLI
|
||||
const modelOverride = getMainLoopModelOverride()
|
||||
if (modelOverride) {
|
||||
flags.push(`--model ${quote([modelOverride])}`)
|
||||
}
|
||||
|
||||
// Propagate --settings if set via CLI
|
||||
const settingsPath = getFlagSettingsPath()
|
||||
if (settingsPath) {
|
||||
flags.push(`--settings ${quote([settingsPath])}`)
|
||||
}
|
||||
|
||||
// Propagate --plugin-dir for each inline plugin
|
||||
const inlinePlugins = getInlinePlugins()
|
||||
for (const pluginDir of inlinePlugins) {
|
||||
flags.push(`--plugin-dir ${quote([pluginDir])}`)
|
||||
}
|
||||
|
||||
// Propagate --teammate-mode so tmux teammates use the same mode as leader
|
||||
const sessionMode = getTeammateModeFromSnapshot()
|
||||
flags.push(`--teammate-mode ${sessionMode}`)
|
||||
|
||||
// Propagate --chrome / --no-chrome if explicitly set on the CLI
|
||||
const chromeFlagOverride = getChromeFlagOverride()
|
||||
if (chromeFlagOverride === true) {
|
||||
flags.push('--chrome')
|
||||
} else if (chromeFlagOverride === false) {
|
||||
flags.push('--no-chrome')
|
||||
}
|
||||
|
||||
return flags.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variables that must be explicitly forwarded to tmux-spawned
|
||||
* teammates. Tmux may start a new login shell that doesn't inherit the
|
||||
* parent's env, so we forward any that are set in the current process.
|
||||
*/
|
||||
const TEAMMATE_ENV_VARS = [
|
||||
// API provider selection — without these, teammates default to firstParty
|
||||
// and send requests to the wrong endpoint (GitHub issue #23561)
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
// Custom API endpoint
|
||||
'ANTHROPIC_BASE_URL',
|
||||
// Config directory override
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
// CCR marker — teammates need this for CCR-aware code paths. Auth finds
|
||||
// its own way via /home/claude/.claude/remote/.oauth_token regardless;
|
||||
// the FD env var wouldn't help (pipe FDs don't cross tmux).
|
||||
'CLAUDE_CODE_REMOTE',
|
||||
// Auto-memory gate (memdir/paths.ts) checks REMOTE && !MEMORY_DIR to
|
||||
// disable memory on ephemeral CCR filesystems. Forwarding REMOTE alone
|
||||
// would flip teammates to memory-off when the parent has it on.
|
||||
'CLAUDE_CODE_REMOTE_MEMORY_DIR',
|
||||
// Upstream proxy — the parent's MITM relay is reachable from teammates
|
||||
// (same container network). Forward the proxy vars so teammates route
|
||||
// customer-configured upstream traffic through the relay for credential
|
||||
// injection. Without these, teammates bypass the proxy entirely.
|
||||
'HTTPS_PROXY',
|
||||
'https_proxy',
|
||||
'HTTP_PROXY',
|
||||
'http_proxy',
|
||||
'NO_PROXY',
|
||||
'no_proxy',
|
||||
'SSL_CERT_FILE',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'REQUESTS_CA_BUNDLE',
|
||||
'CURL_CA_BUNDLE',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Builds the `env KEY=VALUE ...` string for teammate spawn commands.
|
||||
* Always includes CLAUDECODE=1 and CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1,
|
||||
* plus any provider/config env vars that are set in the current process.
|
||||
*/
|
||||
export function buildInheritedEnvVars(): string {
|
||||
const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1']
|
||||
|
||||
for (const key of TEAMMATE_ENV_VARS) {
|
||||
const value = process.env[key]
|
||||
if (value !== undefined && value !== '') {
|
||||
envVars.push(`${key}=${quote([value])}`)
|
||||
}
|
||||
}
|
||||
|
||||
return envVars.join(' ')
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { getSessionCreatedTeams } from '../../bootstrap/state.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getTeamsDir } from '../envUtils.js'
|
||||
import { errorMessage, getErrnoCode } from '../errors.js'
|
||||
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
|
||||
import { gitExe } from '../git.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
|
||||
import { getAgentName, getTeamName, isTeammate } from '../teammate.js'
|
||||
import { type BackendType, isPaneBackend } from './backends/types.js'
|
||||
import { TEAM_LEAD_NAME } from './constants.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
operation: z
|
||||
.enum(['spawnTeam', 'cleanup'])
|
||||
.describe(
|
||||
'Operation: spawnTeam to create a team, cleanup to remove team and task directories.',
|
||||
),
|
||||
agent_type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Type/role of the team lead (e.g., "researcher", "test-runner"). ' +
|
||||
'Used for team file and inter-agent coordination.',
|
||||
),
|
||||
team_name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Name for the new team to create (required for spawnTeam).'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Team description/purpose (only used with spawnTeam).'),
|
||||
}),
|
||||
)
|
||||
|
||||
// Output types for different operations
|
||||
export type SpawnTeamOutput = {
|
||||
team_name: string
|
||||
team_file_path: string
|
||||
lead_agent_id: string
|
||||
}
|
||||
|
||||
export type CleanupOutput = {
|
||||
success: boolean
|
||||
message: string
|
||||
team_name?: string
|
||||
}
|
||||
|
||||
export type TeamAllowedPath = {
|
||||
path: string // Directory path (absolute)
|
||||
toolName: string // The tool this applies to (e.g., "Edit", "Write")
|
||||
addedBy: string // Agent name who added this rule
|
||||
addedAt: number // Timestamp when added
|
||||
}
|
||||
|
||||
export type TeamFile = {
|
||||
name: string
|
||||
description?: string
|
||||
createdAt: number
|
||||
leadAgentId: string
|
||||
leadSessionId?: string // Actual session UUID of the leader (for discovery)
|
||||
hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
|
||||
teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
|
||||
members: Array<{
|
||||
agentId: string
|
||||
name: string
|
||||
agentType?: string
|
||||
model?: string
|
||||
prompt?: string
|
||||
color?: string
|
||||
planModeRequired?: boolean
|
||||
joinedAt: number
|
||||
tmuxPaneId: string
|
||||
cwd: string
|
||||
worktreePath?: string
|
||||
sessionId?: string
|
||||
subscriptions: string[]
|
||||
backendType?: BackendType
|
||||
isActive?: boolean // false when idle, undefined/true when active
|
||||
mode?: PermissionMode // Current permission mode for this teammate
|
||||
}>
|
||||
}
|
||||
|
||||
export type Input = z.infer<ReturnType<typeof inputSchema>>
|
||||
// Export SpawnTeamOutput as Output for backward compatibility
|
||||
export type Output = SpawnTeamOutput
|
||||
|
||||
/**
|
||||
* Sanitizes a name for use in tmux window names, worktree paths, and file paths.
|
||||
* Replaces all non-alphanumeric characters with hyphens and lowercases.
|
||||
*/
|
||||
export function sanitizeName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an agent name for use in deterministic agent IDs.
|
||||
* Replaces @ with - to prevent ambiguity in the agentName@teamName format.
|
||||
*/
|
||||
export function sanitizeAgentName(name: string): string {
|
||||
return name.replace(/@/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to a team's directory
|
||||
*/
|
||||
export function getTeamDir(teamName: string): string {
|
||||
return join(getTeamsDir(), sanitizeName(teamName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to a team's config.json file
|
||||
*/
|
||||
export function getTeamFilePath(teamName: string): string {
|
||||
return join(getTeamDir(teamName), 'config.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a team file by name (sync — for sync contexts like React render paths)
|
||||
* @internal Exported for team discovery UI
|
||||
*/
|
||||
// sync IO: called from sync context
|
||||
export function readTeamFile(teamName: string): TeamFile | null {
|
||||
try {
|
||||
const content = readFileSync(getTeamFilePath(teamName), 'utf-8')
|
||||
return jsonParse(content) as TeamFile
|
||||
} catch (e) {
|
||||
if (getErrnoCode(e) === 'ENOENT') return null
|
||||
logForDebugging(
|
||||
`[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a team file by name (async — for tool handlers and other async contexts)
|
||||
*/
|
||||
export async function readTeamFileAsync(
|
||||
teamName: string,
|
||||
): Promise<TeamFile | null> {
|
||||
try {
|
||||
const content = await readFile(getTeamFilePath(teamName), 'utf-8')
|
||||
return jsonParse(content) as TeamFile
|
||||
} catch (e) {
|
||||
if (getErrnoCode(e) === 'ENOENT') return null
|
||||
logForDebugging(
|
||||
`[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a team file (sync — for sync contexts)
|
||||
*/
|
||||
// sync IO: called from sync context
|
||||
function writeTeamFile(teamName: string, teamFile: TeamFile): void {
|
||||
const teamDir = getTeamDir(teamName)
|
||||
mkdirSync(teamDir, { recursive: true })
|
||||
writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a team file (async — for tool handlers)
|
||||
*/
|
||||
export async function writeTeamFileAsync(
|
||||
teamName: string,
|
||||
teamFile: TeamFile,
|
||||
): Promise<void> {
|
||||
const teamDir = getTeamDir(teamName)
|
||||
await mkdir(teamDir, { recursive: true })
|
||||
await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a teammate from the team file by agent ID or name.
|
||||
* Used by the leader when processing shutdown approvals.
|
||||
*/
|
||||
export function removeTeammateFromTeamFile(
|
||||
teamName: string,
|
||||
identifier: { agentId?: string; name?: string },
|
||||
): boolean {
|
||||
const identifierStr = identifier.agentId || identifier.name
|
||||
if (!identifierStr) {
|
||||
logForDebugging(
|
||||
'[TeammateTool] removeTeammateFromTeamFile called with no identifier',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Cannot remove teammate ${identifierStr}: failed to read team file for "${teamName}"`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const originalLength = teamFile.members.length
|
||||
teamFile.members = teamFile.members.filter(m => {
|
||||
if (identifier.agentId && m.agentId === identifier.agentId) return false
|
||||
if (identifier.name && m.name === identifier.name) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (teamFile.members.length === originalLength) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Teammate ${identifierStr} not found in team file for "${teamName}"`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
writeTeamFile(teamName, teamFile)
|
||||
logForDebugging(
|
||||
`[TeammateTool] Removed teammate from team file: ${identifierStr}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pane ID to the hidden panes list in the team file.
|
||||
* @param teamName - The name of the team
|
||||
* @param paneId - The pane ID to hide
|
||||
* @returns true if the pane was added to hidden list, false if team doesn't exist
|
||||
*/
|
||||
export function addHiddenPaneId(teamName: string, paneId: string): boolean {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hiddenPaneIds = teamFile.hiddenPaneIds ?? []
|
||||
if (!hiddenPaneIds.includes(paneId)) {
|
||||
hiddenPaneIds.push(paneId)
|
||||
teamFile.hiddenPaneIds = hiddenPaneIds
|
||||
writeTeamFile(teamName, teamFile)
|
||||
logForDebugging(
|
||||
`[TeammateTool] Added ${paneId} to hidden panes for team ${teamName}`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a pane ID from the hidden panes list in the team file.
|
||||
* @param teamName - The name of the team
|
||||
* @param paneId - The pane ID to show (remove from hidden list)
|
||||
* @returns true if the pane was removed from hidden list, false if team doesn't exist
|
||||
*/
|
||||
export function removeHiddenPaneId(teamName: string, paneId: string): boolean {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hiddenPaneIds = teamFile.hiddenPaneIds ?? []
|
||||
const index = hiddenPaneIds.indexOf(paneId)
|
||||
if (index !== -1) {
|
||||
hiddenPaneIds.splice(index, 1)
|
||||
teamFile.hiddenPaneIds = hiddenPaneIds
|
||||
writeTeamFile(teamName, teamFile)
|
||||
logForDebugging(
|
||||
`[TeammateTool] Removed ${paneId} from hidden panes for team ${teamName}`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a teammate from the team config file by pane ID.
|
||||
* Also removes from hiddenPaneIds if present.
|
||||
* @param teamName - The name of the team
|
||||
* @param tmuxPaneId - The pane ID of the teammate to remove
|
||||
* @returns true if the member was removed, false if team or member doesn't exist
|
||||
*/
|
||||
export function removeMemberFromTeam(
|
||||
teamName: string,
|
||||
tmuxPaneId: string,
|
||||
): boolean {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const memberIndex = teamFile.members.findIndex(
|
||||
m => m.tmuxPaneId === tmuxPaneId,
|
||||
)
|
||||
if (memberIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove from members array
|
||||
teamFile.members.splice(memberIndex, 1)
|
||||
|
||||
// Also remove from hiddenPaneIds if present
|
||||
if (teamFile.hiddenPaneIds) {
|
||||
const hiddenIndex = teamFile.hiddenPaneIds.indexOf(tmuxPaneId)
|
||||
if (hiddenIndex !== -1) {
|
||||
teamFile.hiddenPaneIds.splice(hiddenIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
writeTeamFile(teamName, teamFile)
|
||||
logForDebugging(
|
||||
`[TeammateTool] Removed member with pane ${tmuxPaneId} from team ${teamName}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a teammate from a team's member list by agent ID.
|
||||
* Use this for in-process teammates which all share the same tmuxPaneId.
|
||||
* @param teamName - The name of the team
|
||||
* @param agentId - The agent ID of the teammate to remove (e.g., "researcher@my-team")
|
||||
* @returns true if the member was removed, false if team or member doesn't exist
|
||||
*/
|
||||
export function removeMemberByAgentId(
|
||||
teamName: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const memberIndex = teamFile.members.findIndex(m => m.agentId === agentId)
|
||||
if (memberIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove from members array
|
||||
teamFile.members.splice(memberIndex, 1)
|
||||
|
||||
writeTeamFile(teamName, teamFile)
|
||||
logForDebugging(
|
||||
`[TeammateTool] Removed member ${agentId} from team ${teamName}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a team member's permission mode.
|
||||
* Called when the team leader changes a teammate's mode via the TeamsDialog.
|
||||
* @param teamName - The name of the team
|
||||
* @param memberName - The name of the member to update
|
||||
* @param mode - The new permission mode
|
||||
*/
|
||||
export function setMemberMode(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
mode: PermissionMode,
|
||||
): boolean {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
const member = teamFile.members.find(m => m.name === memberName)
|
||||
if (!member) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Cannot set member mode: member ${memberName} not found in team ${teamName}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only write if the value is actually changing
|
||||
if (member.mode === mode) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Create updated members array immutably
|
||||
const updatedMembers = teamFile.members.map(m =>
|
||||
m.name === memberName ? { ...m, mode } : m,
|
||||
)
|
||||
writeTeamFile(teamName, { ...teamFile, members: updatedMembers })
|
||||
logForDebugging(
|
||||
`[TeammateTool] Set member ${memberName} in team ${teamName} to mode: ${mode}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the current teammate's mode to config.json so team lead sees it.
|
||||
* No-op if not running as a teammate.
|
||||
* @param mode - The permission mode to sync
|
||||
* @param teamNameOverride - Optional team name override (uses env var if not provided)
|
||||
*/
|
||||
export function syncTeammateMode(
|
||||
mode: PermissionMode,
|
||||
teamNameOverride?: string,
|
||||
): void {
|
||||
if (!isTeammate()) return
|
||||
const teamName = teamNameOverride ?? getTeamName()
|
||||
const agentName = getAgentName()
|
||||
if (teamName && agentName) {
|
||||
setMemberMode(teamName, agentName, mode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets multiple team members' permission modes in a single atomic operation.
|
||||
* Avoids race conditions when updating multiple teammates at once.
|
||||
* @param teamName - The name of the team
|
||||
* @param modeUpdates - Array of {memberName, mode} to update
|
||||
*/
|
||||
export function setMultipleMemberModes(
|
||||
teamName: string,
|
||||
modeUpdates: Array<{ memberName: string; mode: PermissionMode }>,
|
||||
): boolean {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Build a map of updates for efficient lookup
|
||||
const updateMap = new Map(modeUpdates.map(u => [u.memberName, u.mode]))
|
||||
|
||||
// Create updated members array immutably
|
||||
let anyChanged = false
|
||||
const updatedMembers = teamFile.members.map(member => {
|
||||
const newMode = updateMap.get(member.name)
|
||||
if (newMode !== undefined && member.mode !== newMode) {
|
||||
anyChanged = true
|
||||
return { ...member, mode: newMode }
|
||||
}
|
||||
return member
|
||||
})
|
||||
|
||||
if (anyChanged) {
|
||||
writeTeamFile(teamName, { ...teamFile, members: updatedMembers })
|
||||
logForDebugging(
|
||||
`[TeammateTool] Set ${modeUpdates.length} member modes in team ${teamName}`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a team member's active status.
|
||||
* Called when a teammate becomes idle (isActive=false) or starts a new turn (isActive=true).
|
||||
* @param teamName - The name of the team
|
||||
* @param memberName - The name of the member to update
|
||||
* @param isActive - Whether the member is active (true) or idle (false)
|
||||
*/
|
||||
export async function setMemberActive(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
isActive: boolean,
|
||||
): Promise<void> {
|
||||
const teamFile = await readTeamFileAsync(teamName)
|
||||
if (!teamFile) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Cannot set member active: team ${teamName} not found`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const member = teamFile.members.find(m => m.name === memberName)
|
||||
if (!member) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Cannot set member active: member ${memberName} not found in team ${teamName}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Only write if the value is actually changing
|
||||
if (member.isActive === isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
member.isActive = isActive
|
||||
await writeTeamFileAsync(teamName, teamFile)
|
||||
logForDebugging(
|
||||
`[TeammateTool] Set member ${memberName} in team ${teamName} to ${isActive ? 'active' : 'idle'}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys a git worktree at the given path.
|
||||
* First attempts to use `git worktree remove`, then falls back to rm -rf.
|
||||
* Safe to call on non-existent paths.
|
||||
*/
|
||||
async function destroyWorktree(worktreePath: string): Promise<void> {
|
||||
// Read the .git file in the worktree to find the main repo
|
||||
const gitFilePath = join(worktreePath, '.git')
|
||||
let mainRepoPath: string | null = null
|
||||
|
||||
try {
|
||||
const gitFileContent = (await readFile(gitFilePath, 'utf-8')).trim()
|
||||
// The .git file contains something like: gitdir: /path/to/repo/.git/worktrees/worktree-name
|
||||
const match = gitFileContent.match(/^gitdir:\s*(.+)$/)
|
||||
if (match && match[1]) {
|
||||
// Extract the main repo .git directory (go up from .git/worktrees/name to .git)
|
||||
const worktreeGitDir = match[1]
|
||||
// Go up 2 levels from .git/worktrees/name to get to .git, then get parent for repo root
|
||||
const mainGitDir = join(worktreeGitDir, '..', '..')
|
||||
mainRepoPath = join(mainGitDir, '..')
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading .git file (path doesn't exist, not a file, etc.)
|
||||
}
|
||||
|
||||
// Try to remove using git worktree remove command
|
||||
if (mainRepoPath) {
|
||||
const result = await execFileNoThrowWithCwd(
|
||||
gitExe(),
|
||||
['worktree', 'remove', '--force', worktreePath],
|
||||
{ cwd: mainRepoPath },
|
||||
)
|
||||
|
||||
if (result.code === 0) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Removed worktree via git: ${worktreePath}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the error is "not a working tree" (already removed)
|
||||
if (result.stderr?.includes('not a working tree')) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Worktree already removed: ${worktreePath}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[TeammateTool] git worktree remove failed, falling back to rm: ${result.stderr}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback: manually remove the directory
|
||||
try {
|
||||
await rm(worktreePath, { recursive: true, force: true })
|
||||
logForDebugging(
|
||||
`[TeammateTool] Removed worktree directory manually: ${worktreePath}`,
|
||||
)
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Failed to remove worktree ${worktreePath}: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a team as created this session so it gets cleaned up on exit.
|
||||
* Call this right after the initial writeTeamFile. TeamDelete should
|
||||
* call unregisterTeamForSessionCleanup to prevent double-cleanup.
|
||||
* Backing Set lives in bootstrap/state.ts so resetStateForTests()
|
||||
* clears it between tests (avoids the PR #17615 cross-shard leak class).
|
||||
*/
|
||||
export function registerTeamForSessionCleanup(teamName: string): void {
|
||||
getSessionCreatedTeams().add(teamName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a team from session cleanup tracking (e.g., after explicit
|
||||
* TeamDelete — already cleaned, don't try again on shutdown).
|
||||
*/
|
||||
export function unregisterTeamForSessionCleanup(teamName: string): void {
|
||||
getSessionCreatedTeams().delete(teamName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all teams created this session that weren't explicitly deleted.
|
||||
* Registered with gracefulShutdown from init.ts.
|
||||
*/
|
||||
export async function cleanupSessionTeams(): Promise<void> {
|
||||
const sessionCreatedTeams = getSessionCreatedTeams()
|
||||
if (sessionCreatedTeams.size === 0) return
|
||||
const teams = Array.from(sessionCreatedTeams)
|
||||
logForDebugging(
|
||||
`cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`,
|
||||
)
|
||||
// Kill panes first — on SIGINT the teammate processes are still running;
|
||||
// deleting directories alone would orphan them in open tmux/iTerm2 panes.
|
||||
// (TeamDeleteTool's path doesn't need this — by then teammates have
|
||||
// gracefully exited and useInboxPoller has already closed their panes.)
|
||||
await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name)))
|
||||
await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name)))
|
||||
sessionCreatedTeams.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort kill of all pane-backed teammate panes for a team.
|
||||
* Called from cleanupSessionTeams on ungraceful leader exit (SIGINT/SIGTERM).
|
||||
* Dynamic imports avoid adding registry/detection to this module's static
|
||||
* dep graph — this only runs at shutdown, so the import cost is irrelevant.
|
||||
*/
|
||||
async function killOrphanedTeammatePanes(teamName: string): Promise<void> {
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) return
|
||||
|
||||
const paneMembers = teamFile.members.filter(
|
||||
m =>
|
||||
m.name !== TEAM_LEAD_NAME &&
|
||||
m.tmuxPaneId &&
|
||||
m.backendType &&
|
||||
isPaneBackend(m.backendType),
|
||||
)
|
||||
if (paneMembers.length === 0) return
|
||||
|
||||
const [{ ensureBackendsRegistered, getBackendByType }, { isInsideTmux }] =
|
||||
await Promise.all([
|
||||
import('./backends/registry.js'),
|
||||
import('./backends/detection.js'),
|
||||
])
|
||||
await ensureBackendsRegistered()
|
||||
const useExternalSession = !(await isInsideTmux())
|
||||
|
||||
await Promise.allSettled(
|
||||
paneMembers.map(async m => {
|
||||
// filter above guarantees these; narrow for the type system
|
||||
if (!m.tmuxPaneId || !m.backendType || !isPaneBackend(m.backendType)) {
|
||||
return
|
||||
}
|
||||
const ok = await getBackendByType(m.backendType).killPane(
|
||||
m.tmuxPaneId,
|
||||
useExternalSession,
|
||||
)
|
||||
logForDebugging(
|
||||
`cleanupSessionTeams: killPane ${m.name} (${m.backendType} ${m.tmuxPaneId}) → ${ok}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up team and task directories for a given team name.
|
||||
* Also cleans up git worktrees created for teammates.
|
||||
* Called when a swarm session is terminated.
|
||||
*/
|
||||
export async function cleanupTeamDirectories(teamName: string): Promise<void> {
|
||||
const sanitizedName = sanitizeName(teamName)
|
||||
|
||||
// Read team file to get worktree paths BEFORE deleting the team directory
|
||||
const teamFile = readTeamFile(teamName)
|
||||
const worktreePaths: string[] = []
|
||||
if (teamFile) {
|
||||
for (const member of teamFile.members) {
|
||||
if (member.worktreePath) {
|
||||
worktreePaths.push(member.worktreePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up worktrees first
|
||||
for (const worktreePath of worktreePaths) {
|
||||
await destroyWorktree(worktreePath)
|
||||
}
|
||||
|
||||
// Clean up team directory (~/.claude/teams/{team-name}/)
|
||||
const teamDir = getTeamDir(teamName)
|
||||
try {
|
||||
await rm(teamDir, { recursive: true, force: true })
|
||||
logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`)
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up tasks directory (~/.claude/tasks/{taskListId}/)
|
||||
// The leader and teammates all store tasks under the sanitized team name.
|
||||
const tasksDir = getTasksDir(sanitizedName)
|
||||
try {
|
||||
await rm(tasksDir, { recursive: true, force: true })
|
||||
logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`)
|
||||
notifyTasksUpdated()
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Teammate Initialization Module
|
||||
*
|
||||
* Handles initialization for Claude Code instances running as teammates in a swarm.
|
||||
* Registers a Stop hook to notify the team leader when the teammate becomes idle.
|
||||
*/
|
||||
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { addFunctionHook } from '../hooks/sessionHooks.js'
|
||||
import { applyPermissionUpdate } from '../permissions/PermissionUpdate.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { getTeammateColor } from '../teammate.js'
|
||||
import {
|
||||
createIdleNotification,
|
||||
getLastPeerDmSummary,
|
||||
writeToMailbox,
|
||||
} from '../teammateMailbox.js'
|
||||
import { readTeamFile, setMemberActive } from './teamHelpers.js'
|
||||
|
||||
/**
|
||||
* Initializes hooks for a teammate running in a swarm.
|
||||
* Should be called early in session startup after AppState is available.
|
||||
*
|
||||
* Registers a Stop hook that sends an idle notification to the team leader
|
||||
* when this teammate's session stops.
|
||||
*/
|
||||
export function initializeTeammateHooks(
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void,
|
||||
sessionId: string,
|
||||
teamInfo: { teamName: string; agentId: string; agentName: string },
|
||||
): void {
|
||||
const { teamName, agentId, agentName } = teamInfo
|
||||
|
||||
// Read team file to get leader ID
|
||||
const teamFile = readTeamFile(teamName)
|
||||
if (!teamFile) {
|
||||
logForDebugging(`[TeammateInit] Team file not found for team: ${teamName}`)
|
||||
return
|
||||
}
|
||||
|
||||
const leadAgentId = teamFile.leadAgentId
|
||||
|
||||
// Apply team-wide allowed paths if any exist
|
||||
if (teamFile.teamAllowedPaths && teamFile.teamAllowedPaths.length > 0) {
|
||||
logForDebugging(
|
||||
`[TeammateInit] Found ${teamFile.teamAllowedPaths.length} team-wide allowed path(s)`,
|
||||
)
|
||||
|
||||
for (const allowedPath of teamFile.teamAllowedPaths) {
|
||||
// For absolute paths (starting with /), prepend one / to create //path/** pattern
|
||||
// For relative paths, just use path/**
|
||||
const ruleContent = allowedPath.path.startsWith('/')
|
||||
? `/${allowedPath.path}/**`
|
||||
: `${allowedPath.path}/**`
|
||||
|
||||
logForDebugging(
|
||||
`[TeammateInit] Applying team permission: ${allowedPath.toolName} allowed in ${allowedPath.path} (rule: ${ruleContent})`,
|
||||
)
|
||||
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prev.toolPermissionContext,
|
||||
{
|
||||
type: 'addRules',
|
||||
rules: [
|
||||
{
|
||||
toolName: allowedPath.toolName,
|
||||
ruleContent,
|
||||
},
|
||||
],
|
||||
behavior: 'allow',
|
||||
destination: 'session',
|
||||
},
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Find the leader's name from the members array
|
||||
const leadMember = teamFile.members.find(m => m.agentId === leadAgentId)
|
||||
const leadAgentName = leadMember?.name || 'team-lead'
|
||||
|
||||
// Don't register hook if this agent is the leader
|
||||
if (agentId === leadAgentId) {
|
||||
logForDebugging(
|
||||
'[TeammateInit] This agent is the team leader - skipping idle notification hook',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[TeammateInit] Registering Stop hook for teammate ${agentName} to notify leader ${leadAgentName}`,
|
||||
)
|
||||
|
||||
// Register Stop hook to notify leader when this teammate stops
|
||||
addFunctionHook(
|
||||
setAppState,
|
||||
sessionId,
|
||||
'Stop',
|
||||
'', // No matcher - applies to all Stop events
|
||||
async (messages, _signal) => {
|
||||
// Mark this teammate as idle in the team config (fire and forget)
|
||||
void setMemberActive(teamName, agentName, false)
|
||||
|
||||
// Send idle notification to the team leader using agent name (not UUID)
|
||||
// Must await to ensure the write completes before process shutdown
|
||||
const notification = createIdleNotification(agentName, {
|
||||
idleReason: 'available',
|
||||
summary: getLastPeerDmSummary(messages),
|
||||
})
|
||||
await writeToMailbox(leadAgentName, {
|
||||
from: agentName,
|
||||
text: jsonStringify(notification),
|
||||
timestamp: new Date().toISOString(),
|
||||
color: getTeammateColor(),
|
||||
})
|
||||
logForDebugging(
|
||||
`[TeammateInit] Sent idle notification to leader ${leadAgentName}`,
|
||||
)
|
||||
return true // Don't block the Stop
|
||||
},
|
||||
'Failed to send idle notification to team leader',
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { AgentColorName } from '../../tools/AgentTool/agentColorManager.js'
|
||||
import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js'
|
||||
import { detectAndGetBackend } from './backends/registry.js'
|
||||
import type { PaneBackend } from './backends/types.js'
|
||||
|
||||
// Track color assignments for teammates (persisted per session)
|
||||
const teammateColorAssignments = new Map<string, AgentColorName>()
|
||||
let colorIndex = 0
|
||||
|
||||
/**
|
||||
* Gets the appropriate backend for the current environment.
|
||||
* detectAndGetBackend() caches internally — no need for a second cache here.
|
||||
*/
|
||||
async function getBackend(): Promise<PaneBackend> {
|
||||
return (await detectAndGetBackend()).backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a unique color to a teammate from the available palette.
|
||||
* Colors are assigned in round-robin order.
|
||||
*/
|
||||
export function assignTeammateColor(teammateId: string): AgentColorName {
|
||||
const existing = teammateColorAssignments.get(teammateId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const color = AGENT_COLORS[colorIndex % AGENT_COLORS.length]!
|
||||
teammateColorAssignments.set(teammateId, color)
|
||||
colorIndex++
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the assigned color for a teammate, if any.
|
||||
*/
|
||||
export function getTeammateColor(
|
||||
teammateId: string,
|
||||
): AgentColorName | undefined {
|
||||
return teammateColorAssignments.get(teammateId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all teammate color assignments.
|
||||
* Called during team cleanup to reset state for potential new teams.
|
||||
*/
|
||||
export function clearTeammateColors(): void {
|
||||
teammateColorAssignments.clear()
|
||||
colorIndex = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently running inside a tmux session.
|
||||
* Uses the detection module directly for this check.
|
||||
*/
|
||||
export async function isInsideTmux(): Promise<boolean> {
|
||||
const { isInsideTmux: checkTmux } = await import('./backends/detection.js')
|
||||
return checkTmux()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new teammate pane in the swarm view.
|
||||
* Automatically selects the appropriate backend (tmux or iTerm2) based on environment.
|
||||
*
|
||||
* When running INSIDE tmux:
|
||||
* - Uses TmuxBackend to split the current window
|
||||
* - Leader stays on left (30%), teammates on right (70%)
|
||||
*
|
||||
* When running in iTerm2 (not in tmux) with it2 CLI:
|
||||
* - Uses ITermBackend for native iTerm2 split panes
|
||||
*
|
||||
* When running OUTSIDE tmux/iTerm2:
|
||||
* - Falls back to TmuxBackend with external claude-swarm session
|
||||
*/
|
||||
export async function createTeammatePaneInSwarmView(
|
||||
teammateName: string,
|
||||
teammateColor: AgentColorName,
|
||||
): Promise<{ paneId: string; isFirstTeammate: boolean }> {
|
||||
const backend = await getBackend()
|
||||
return backend.createTeammatePaneInSwarmView(teammateName, teammateColor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables pane border status for a window (shows pane titles).
|
||||
* Delegates to the detected backend.
|
||||
*/
|
||||
export async function enablePaneBorderStatus(
|
||||
windowTarget?: string,
|
||||
useSwarmSocket = false,
|
||||
): Promise<void> {
|
||||
const backend = await getBackend()
|
||||
return backend.enablePaneBorderStatus(windowTarget, useSwarmSocket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a command to a specific pane.
|
||||
* Delegates to the detected backend.
|
||||
*/
|
||||
export async function sendCommandToPane(
|
||||
paneId: string,
|
||||
command: string,
|
||||
useSwarmSocket = false,
|
||||
): Promise<void> {
|
||||
const backend = await getBackend()
|
||||
return backend.sendCommandToPane(paneId, command, useSwarmSocket)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CLAUDE_OPUS_4_6_CONFIG } from '../model/configs.js'
|
||||
import { getAPIProvider } from '../model/providers.js'
|
||||
|
||||
// @[MODEL LAUNCH]: Update the fallback model below.
|
||||
// When the user has never set teammateDefaultModel in /config, new teammates
|
||||
// use Opus 4.6. Must be provider-aware so Bedrock/Vertex/Foundry customers get
|
||||
// the correct model ID.
|
||||
export function getHardcodedTeammateModelFallback(): string {
|
||||
return CLAUDE_OPUS_4_6_CONFIG[getAPIProvider()]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Teammate-specific system prompt addendum.
|
||||
*
|
||||
* This is appended to the full main agent system prompt for teammates.
|
||||
* It explains visibility constraints and communication requirements.
|
||||
*/
|
||||
|
||||
export const TEAMMATE_SYSTEM_PROMPT_ADDENDUM = `
|
||||
# Agent Teammate Communication
|
||||
|
||||
IMPORTANT: You are running as an agent in a team. To communicate with anyone on your team:
|
||||
- Use the SendMessage tool with \`to: "<name>"\` to send messages to specific teammates
|
||||
- Use the SendMessage tool with \`to: "*"\` sparingly for team-wide broadcasts
|
||||
|
||||
Just writing a response in text is not visible to others on your team - you MUST use the SendMessage tool.
|
||||
|
||||
The user interacts primarily with the team lead. Your work is coordinated through the task system and teammate messaging.
|
||||
`
|
||||
Reference in New Issue
Block a user