diff --git a/src/components/PromptInput/useSwarmBanner.ts b/src/components/PromptInput/useSwarmBanner.ts index 18feb0503..93512f9b1 100644 --- a/src/components/PromptInput/useSwarmBanner.ts +++ b/src/components/PromptInput/useSwarmBanner.ts @@ -81,11 +81,17 @@ export function useSwarmBanner(): SwarmBannerInfo { const viewedTeammate = getViewedTeammateTask(state) const viewedColor = toThemeColor(viewedTeammate?.identity.color) const inProcessMode = isInProcessEnabled() - const nativePanes = getCachedDetectionResult()?.isNative ?? false + const detection = getCachedDetectionResult() + const nativePanes = detection?.isNative ?? false + const backendType = detection?.backend.type if (insideTmux === false && !inProcessMode && !nativePanes) { + const hint = + backendType === 'windows-terminal' + ? 'View teammates in the Windows Terminal tabs spawned for each teammate' + : `View teammates: \`tmux -L ${getSwarmSocketName()} a\`` return { - text: `View teammates: \`tmux -L ${getSwarmSocketName()} a\``, + text: hint, bgColor: viewedColor, } } diff --git a/src/utils/agentSwarmsEnabled.ts b/src/utils/agentSwarmsEnabled.ts index fac5404c7..f349a8c75 100644 --- a/src/utils/agentSwarmsEnabled.ts +++ b/src/utils/agentSwarmsEnabled.ts @@ -1,42 +1,15 @@ -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { isEnvTruthy } from './envUtils.js' -/** - * Check if --agent-teams flag is provided via CLI. - * Checks process.argv directly to avoid import cycles with bootstrap/state. - * Note: The flag is only shown in help for ant users, but if external users - * pass it anyway, it will work (subject to the killswitch). - */ -function isAgentTeamsFlagSet(): boolean { - return process.argv.includes('--agent-teams') -} - /** * Centralized runtime check for agent teams/teammate features. * This is the single gate that should be checked everywhere teammates * are referenced (prompts, code, tools isEnabled, UI, etc.). * - * Ant builds: always enabled. - * External builds require both: - * 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag - * 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch) + * Fork build: enabled by default. Can be disabled via + * CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 if needed. */ export function isAgentSwarmsEnabled(): boolean { - // Ant: always on - if (process.env.USER_TYPE === 'ant') { - return true - } - - // External: require opt-in via env var or --agent-teams flag - if ( - !isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) && - !isAgentTeamsFlagSet() - ) { - return false - } - - // Killswitch — always respected for external users - if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true)) { + if (isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED)) { return false } diff --git a/src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts b/src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts new file mode 100644 index 000000000..2bc5dbb70 --- /dev/null +++ b/src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +let terminateCalls: string[] = [] + +mock.module('src/utils/swarm/backends/registry.js', () => { + const executor = { + type: 'in-process' as const, + setContext() {}, + async isAvailable() { + return true + }, + async spawn(config: { name: string; teamName: string; color?: string }) { + return { + success: true, + agentId: `${config.name}@${config.teamName}`, + taskId: `task-${config.name}`, + backendType: 'in-process', + color: config.color, + isSplitPane: false, + } + }, + async sendMessage() {}, + async terminate(agentId: string) { + terminateCalls.push(agentId) + return true + }, + async kill() { + return true + }, + async isActive() { + return true + }, + } + + return { + getTeammateExecutor: async () => executor, + getInProcessBackend: () => executor, + detectAndGetBackend: async () => ({ + backend: { type: 'in-process' }, + isNative: false, + needsIt2Setup: false, + }), + isInProcessEnabled: () => true, + markInProcessFallback: () => {}, + resetBackendDetection: () => {}, + getCachedBackend: () => null, + getCachedDetectionResult: () => null, + getResolvedTeammateMode: () => 'in-process', + ensureBackendsRegistered: async () => {}, + getBackendByType: () => ({ + type: 'tmux', + killPane: async () => true, + }), + } +}) + +let tempHome: string +let previousConfigDir: string | undefined +let previousAnthropicApiKey: string | undefined +let state: any + +function setState(updater: (prev: any) => any): void { + state = updater(state) +} + +function readTeamConfig(teamName: string): any { + return JSON.parse( + readFileSync(join(tempHome, 'teams', teamName, 'config.json'), 'utf-8'), + ) +} + +function writeTeamConfig(teamName: string, config: unknown): void { + const teamDir = join(tempHome, 'teams', teamName) + mkdirSync(teamDir, { recursive: true }) + writeFileSync(join(teamDir, 'config.json'), JSON.stringify(config, null, 2)) +} + +beforeEach(() => { + terminateCalls = [] + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY + tempHome = join( + tmpdir(), + `agent-teams-lifecycle-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + process.env.CLAUDE_CONFIG_DIR = tempHome + process.env.ANTHROPIC_API_KEY = 'test-key' + state = { + teamContext: undefined, + tasks: {}, + inbox: { messages: [] }, + toolPermissionContext: { + mode: 'default', + alwaysAllowRules: {}, + alwaysDenyRules: {}, + additionalWorkingDirectories: new Map(), + }, + mainLoopModel: null, + mainLoopModelForSession: null, + agentNameRegistry: new Map(), + mcp: { tools: [] }, + } +}) + +afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (previousAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey + } + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('Agent Teams lifecycle', () => { + test('runs TeamCreate -> spawn -> TaskUpdate -> SendMessage -> TeamDelete', async () => { + const { TeamCreateTool } = await import( + '@claude-code-best/builtin-tools/tools/TeamCreateTool/TeamCreateTool.js' + ) + const { spawnTeammate } = await import( + '@claude-code-best/builtin-tools/tools/shared/spawnMultiAgent.js' + ) + const { TaskCreateTool } = await import( + '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js' + ) + const { TaskUpdateTool } = await import( + '@claude-code-best/builtin-tools/tools/TaskUpdateTool/TaskUpdateTool.js' + ) + const { SendMessageTool } = await import( + '@claude-code-best/builtin-tools/tools/SendMessageTool/SendMessageTool.js' + ) + const { TeamDeleteTool } = await import( + '@claude-code-best/builtin-tools/tools/TeamDeleteTool/TeamDeleteTool.js' + ) + + const context = { + getAppState: () => state, + setAppState: setState, + options: { + agentDefinitions: { activeAgents: [] }, + }, + abortController: new AbortController(), + } as any + + const created = await TeamCreateTool.call( + { team_name: 'alpha', description: 'test team' }, + context, + undefined as any, + undefined as any, + ) + expect(created.data.team_name).toBe('alpha') + + const spawned = await spawnTeammate( + { + name: 'worker', + prompt: 'handle assigned tasks', + team_name: 'alpha', + }, + context, + ) + expect(spawned.data.agent_id).toBe('worker@alpha') + + const task = await TaskCreateTool.call( + { subject: 'Check lifecycle', description: 'Verify team task flow' }, + context, + ) + await TaskUpdateTool.call( + { taskId: task.data.task.id, owner: 'worker' }, + context, + ) + + const message = await SendMessageTool.call( + { + to: 'worker', + summary: 'Status request', + message: 'Please report status.', + }, + context, + async () => ({ behavior: 'allow' as const }), + undefined as any, + ) + expect(message.data.success).toBe(true) + + const blockedDelete = await TeamDeleteTool.call( + {}, + context, + undefined as any, + undefined as any, + ) + expect(blockedDelete.data.success).toBe(false) + expect(terminateCalls).toEqual(['worker@alpha']) + + const config = readTeamConfig('alpha') + config.members = config.members.map((member: any) => + member.name === 'worker' ? { ...member, isActive: false } : member, + ) + writeTeamConfig('alpha', config) + + const deleted = await TeamDeleteTool.call( + {}, + context, + undefined as any, + undefined as any, + ) + expect(deleted.data.success).toBe(true) + }) + + test('TeamDelete waits for active teammates to become inactive before cleanup', async () => { + const { TeamDeleteTool } = await import( + '@claude-code-best/builtin-tools/tools/TeamDeleteTool/TeamDeleteTool.js' + ) + const now = Date.now() + writeTeamConfig('alpha', { + name: 'alpha', + createdAt: now, + leadAgentId: 'team-lead@alpha', + members: [ + { + agentId: 'team-lead@alpha', + name: 'team-lead', + joinedAt: now, + tmuxPaneId: '', + cwd: tempHome, + subscriptions: [], + }, + { + agentId: 'worker@alpha', + name: 'worker', + joinedAt: now, + tmuxPaneId: 'in-process', + cwd: tempHome, + subscriptions: [], + backendType: 'in-process', + }, + ], + }) + state.teamContext = { + teamName: 'alpha', + teamFilePath: join(tempHome, 'teams', 'alpha', 'config.json'), + leadAgentId: 'team-lead@alpha', + teammates: { + 'worker@alpha': { + name: 'worker', + tmuxSessionName: 'in-process', + tmuxPaneId: 'in-process', + cwd: tempHome, + spawnedAt: now, + }, + }, + } + + setTimeout(() => { + const config = readTeamConfig('alpha') + config.members = config.members.map((member: any) => + member.name === 'worker' ? { ...member, isActive: false } : member, + ) + writeTeamConfig('alpha', config) + }, 25) + + const result = await TeamDeleteTool.call( + { wait_ms: 1000 }, + { + getAppState: () => state, + setAppState: setState, + } as any, + undefined as any, + undefined as any, + ) + + expect(result.data.success).toBe(true) + }) +}) diff --git a/src/utils/swarm/__tests__/spawnInProcess.test.ts b/src/utils/swarm/__tests__/spawnInProcess.test.ts new file mode 100644 index 000000000..2e30f354a --- /dev/null +++ b/src/utils/swarm/__tests__/spawnInProcess.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { getDefaultAppState } from '../../../state/AppStateStore' +import { readMailbox, writeToMailbox } from '../../teammateMailbox' +import { + killInProcessTeammateByAgentId, + spawnInProcessTeammate, +} from '../spawnInProcess' + +let tempHome: string +let previousConfigDir: string | undefined + +beforeEach(() => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + tempHome = join( + tmpdir(), + `spawn-in-process-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + process.env.CLAUDE_CONFIG_DIR = tempHome +}) + +afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('killInProcessTeammateByAgentId', () => { + test('registers a real in-process teammate task and mailbox', async () => { + let state = getDefaultAppState() as any + const result = await spawnInProcessTeammate( + { + name: 'worker', + teamName: 'alpha', + prompt: 'smoke test task', + color: 'blue', + planModeRequired: false, + }, + { + setAppState(updater) { + state = updater(state) + }, + toolUseId: 'toolu_smoke', + }, + ) + + expect(result.success).toBe(true) + expect(result.agentId).toBe('worker@alpha') + expect(result.taskId).toBeString() + expect(state.tasks[result.taskId!].type).toBe('in_process_teammate') + expect(state.tasks[result.taskId!].identity.agentId).toBe('worker@alpha') + expect(state.tasks[result.taskId!].messages).toEqual([]) + + await writeToMailbox( + 'worker', + { + from: 'team-lead', + text: 'mailbox smoke', + timestamp: new Date(0).toISOString(), + }, + 'alpha', + ) + const messages = await readMailbox('worker', 'alpha') + + expect(messages).toHaveLength(1) + expect(messages[0]!.text).toBe('mailbox smoke') + expect(messages[0]!.read).toBe(false) + }) + + test('aborts the running teammate and removes it from team context by agent id', () => { + const abortController = new AbortController() + let state: any = { + teamContext: { + teamName: 'alpha', + teammates: { + 'worker@alpha': { + name: 'worker', + }, + }, + }, + tasks: { + teammate_task_1: { + id: 'teammate_task_1', + type: 'in_process_teammate', + status: 'running', + identity: { + agentId: 'worker@alpha', + agentName: 'worker', + teamName: 'alpha', + planModeRequired: false, + parentSessionId: 'session', + }, + abortController, + pendingUserMessages: [], + onIdleCallbacks: [], + messages: [], + }, + }, + } + + const killed = killInProcessTeammateByAgentId('worker@alpha', updater => { + state = updater(state) + }) + + expect(killed).toBe(true) + expect(abortController.signal.aborted).toBe(true) + expect(state.tasks.teammate_task_1.status).toBe('killed') + expect(state.teamContext.teammates['worker@alpha']).toBeUndefined() + }) +}) diff --git a/src/utils/swarm/__tests__/spawnUtils.test.ts b/src/utils/swarm/__tests__/spawnUtils.test.ts new file mode 100644 index 000000000..2dde21f14 --- /dev/null +++ b/src/utils/swarm/__tests__/spawnUtils.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from 'bun:test' +import { buildInheritedCliFlags } from '../spawnUtils' + +describe('buildInheritedCliFlags', () => { + test('propagates auto permission mode to process-based teammates', () => { + const flags = buildInheritedCliFlags({ permissionMode: 'auto' }) + + expect(flags).toContain('--permission-mode auto') + }) +}) diff --git a/src/utils/swarm/backends/InProcessBackend.ts b/src/utils/swarm/backends/InProcessBackend.ts index 0f43f81fe..6bd96133b 100644 --- a/src/utils/swarm/backends/InProcessBackend.ts +++ b/src/utils/swarm/backends/InProcessBackend.ts @@ -91,6 +91,7 @@ export class InProcessBackend implements TeammateExecutor { prompt: config.prompt, color: config.color, planModeRequired: config.planModeRequired ?? false, + model: config.model, }, this.context, ) @@ -115,6 +116,8 @@ export class InProcessBackend implements TeammateExecutor { }, taskId: result.taskId, prompt: config.prompt, + description: config.description, + agentDefinition: config.agentDefinition, teammateContext: result.teammateContext, // Strip messages: the teammate never reads toolUseContext.messages // (runAgent overrides it via createSubagentContext). Passing the @@ -126,6 +129,7 @@ export class InProcessBackend implements TeammateExecutor { systemPromptMode: config.systemPromptMode, allowedTools: config.permissions, allowPermissionPrompts: config.allowPermissionPrompts, + invokingRequestId: config.invokingRequestId, }) logForDebugging( @@ -138,6 +142,8 @@ export class InProcessBackend implements TeammateExecutor { agentId: result.agentId, taskId: result.taskId, abortController: result.abortController, + backendType: this.type, + color: config.color, error: result.error, } } diff --git a/src/utils/swarm/backends/PaneBackendExecutor.ts b/src/utils/swarm/backends/PaneBackendExecutor.ts index a978e032c..e1436a71d 100644 --- a/src/utils/swarm/backends/PaneBackendExecutor.ts +++ b/src/utils/swarm/backends/PaneBackendExecutor.ts @@ -2,13 +2,15 @@ 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 { isInBundledMode } from '../../../utils/bundledMode.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, + buildInheritedCliArgParts, buildInheritedEnvVars, + getInheritedEnvVarAssignments, getTeammateCommand, } from '../spawnUtils.js' import { assignTeammateColor } from '../teammateLayoutManager.js' @@ -22,6 +24,43 @@ import type { TeammateSpawnResult, } from './types.js' +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function withoutModelArg(args: string[]): string[] { + const filtered: string[] = [] + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--model') { + i += 1 + continue + } + filtered.push(args[i]!) + } + return filtered +} + +function buildPowerShellSpawnCommand( + binaryPath: string, + args: string[], + cwd: string, +): string { + const envAssignments = getInheritedEnvVarAssignments().map( + ([key, value]) => `$env:${key} = ${quotePowerShellString(value)}`, + ) + // In dev mode (non-bundled), binaryPath is a .ts/.tsx file that PowerShell + // cannot execute directly. Prepend `bun run` so the teammate process starts + // through Bun's runtime, matching how `bun run dev` works. + const invocation = isInBundledMode() + ? `& ${quotePowerShellString(binaryPath)}` + : `& ${quotePowerShellString(process.execPath)} ${quotePowerShellString(binaryPath)}` + return [ + `Set-Location -LiteralPath ${quotePowerShellString(cwd)}`, + ...envAssignments, + `${invocation} ${args.map(quotePowerShellString).join(' ')}`, + ].join('; ') +} + /** * PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface. * @@ -95,12 +134,18 @@ export class PaneBackendExecutor implements TeammateExecutor { // 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, - ) + const paneResult = + config.useSplitPane === false && + this.backend.createTeammateWindowInSwarmView + ? await this.backend.createTeammateWindowInSwarmView( + config.name, + teammateColor, + ) + : await this.backend.createTeammatePaneInSwarmView( + config.name, + teammateColor, + ) + const { paneId, isFirstTeammate } = paneResult // Check if we're inside tmux to determine how to send commands const insideTmux = await isInsideTmux() @@ -115,43 +160,43 @@ export class PaneBackendExecutor implements TeammateExecutor { // 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' : '', + '--agent-id', + agentId, + '--agent-name', + config.name, + '--team-name', + config.teamName, + '--agent-color', + teammateColor, + '--parent-session-id', + config.parentSessionId || getSessionId(), + ...(config.planModeRequired ? ['--plan-mode-required'] : []), + ...(config.agentType ? ['--agent-type', config.agentType] : []), ] - .filter(Boolean) - .join(' ') // Build CLI flags to propagate to teammate const appState = this.context.getAppState() - let inheritedFlags = buildInheritedCliFlags({ + let inheritedArgParts = buildInheritedCliArgParts({ 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])}` + inheritedArgParts = withoutModelArg(inheritedArgParts) + inheritedArgParts.push('--model', 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}` + const allArgs = [...teammateArgs, ...inheritedArgParts] + const spawnCommand = + this.type === 'windows-terminal' + ? buildPowerShellSpawnCommand(binaryPath, allArgs, workingDir) + : `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${quote(allArgs)}` // Send the command to the new pane // Use swarm socket when running outside tmux (external swarm session) @@ -193,6 +238,14 @@ export class PaneBackendExecutor implements TeammateExecutor { success: true, agentId, paneId, + backendType: this.type, + color: teammateColor, + insideTmux, + windowName: + 'windowName' in paneResult + ? (paneResult as { windowName: string }).windowName + : undefined, + isSplitPane: config.useSplitPane !== false, } } catch (error) { const errorMessage = diff --git a/src/utils/swarm/backends/TmuxBackend.ts b/src/utils/swarm/backends/TmuxBackend.ts index 321958c7b..98c151fcd 100644 --- a/src/utils/swarm/backends/TmuxBackend.ts +++ b/src/utils/swarm/backends/TmuxBackend.ts @@ -145,6 +145,42 @@ export class TmuxBackend implements PaneBackend { } } + /** + * Creates a separate tmux window for a teammate in the swarm session. + * Used by the legacy `use_splitpane: false` path. + */ + async createTeammateWindowInSwarmView( + name: string, + color: AgentColorName, + ): Promise { + const windowName = `teammate-${name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}` + const { windowTarget } = await this.createExternalSwarmSession() + void windowTarget + + const result = await runTmuxInSwarm([ + 'new-window', + '-t', + SWARM_SESSION_NAME, + '-n', + windowName, + '-P', + '-F', + '#{pane_id}', + ]) + + if (result.code !== 0) { + throw new Error( + `Failed to create tmux window: ${result.stderr || 'Unknown error'}`, + ) + } + + const paneId = result.stdout.trim() + await this.setPaneTitle(paneId, name, color, true) + await this.setPaneBorderColor(paneId, color, true) + + return { paneId, isFirstTeammate: false, windowName } + } + /** * Sends a command to a specific pane. */ diff --git a/src/utils/swarm/backends/WindowsTerminalBackend.ts b/src/utils/swarm/backends/WindowsTerminalBackend.ts new file mode 100644 index 000000000..b0e45e312 --- /dev/null +++ b/src/utils/swarm/backends/WindowsTerminalBackend.ts @@ -0,0 +1,237 @@ +import { randomUUID } from 'crypto' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' +import { logForDebugging } from '../../../utils/debug.js' +import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' +import { getPlatform, type Platform } from '../../../utils/platform.js' +import { isInWindowsTerminal } from './detection.js' +import { registerWindowsTerminalBackend } from './registry.js' +import type { CreatePaneResult, PaneBackend, PaneId } from './types.js' + +type CommandResult = { stdout: string; stderr: string; code: number } +type CommandRunner = (command: string, args: string[]) => Promise + +type WindowsTerminalPane = { + title: string + mode: 'pane' | 'window' + pidFile: string +} + +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function wrapPowerShellCommand(command: string, pidFile: string): string { + const quotedPidFile = quotePowerShellString(pidFile) + // PowerShell requires try/catch/finally to be a single compound statement — + // semicolons between the blocks cause "Try 语句缺少自己的 Catch 或 Finally 块". + // Use newlines (\n) so the parser treats it as one statement. + return [ + "$ErrorActionPreference = 'Stop'", + `Set-Content -LiteralPath ${quotedPidFile} -Value $PID`, + [ + `try { ${command}; if ($LASTEXITCODE -is [int]) { exit $LASTEXITCODE } }`, + `catch { Write-Error $_; exit 1 }`, + `finally { Remove-Item -LiteralPath ${quotedPidFile} -Force -ErrorAction SilentlyContinue }`, + ].join('\n'), + ].join('; ') +} + +function makePidFile(paneId: string): string { + return join(tmpdir(), `${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`) +} + +/** + * WindowsTerminalBackend uses wt.exe to create visible teammate panes/tabs. + * + * Windows Terminal's CLI starts commands directly in a new pane; it does not + * expose a stable pane id that can later receive arbitrary input. To fit the + * PaneBackend contract, createTeammatePaneInSwarmView allocates an internal id, + * and sendCommandToPane performs the actual `wt split-pane` launch. + */ +export class WindowsTerminalBackend implements PaneBackend { + readonly type = 'windows-terminal' as const + readonly displayName = 'Windows Terminal' + readonly supportsHideShow = false + + private panes = new Map() + + constructor( + private readonly runCommand: CommandRunner = execFileNoThrow, + private readonly getPlatformValue: () => Platform = getPlatform, + ) {} + + async isAvailable(): Promise { + if (this.getPlatformValue() !== 'windows') { + return false + } + // Do NOT run `wt.exe --version` — wt.exe is a UWP app bridge that opens + // the Windows Terminal app to render version info, producing a phantom + // "Windows 终端 1.24.x" window every time availability is checked. + // Instead, check the WT_SESSION env var (set inside WT) or verify the + // binary exists on PATH without executing it. + if (process.env.WT_SESSION) { + return true + } + const result = await this.runCommand('where.exe', ['wt.exe']) + return result.code === 0 + } + + async isRunningInside(): Promise { + return this.getPlatformValue() === 'windows' && isInWindowsTerminal() + } + + async createTeammatePaneInSwarmView( + name: string, + _color: AgentColorName, + ): Promise { + const paneId = `wt-${randomUUID()}` + const isFirstTeammate = this.panes.size === 0 + this.panes.set(paneId, { + title: name, + mode: 'pane', + pidFile: makePidFile(paneId), + }) + return { paneId, isFirstTeammate } + } + + async createTeammateWindowInSwarmView( + name: string, + _color: AgentColorName, + ): Promise { + const paneId = `wt-${randomUUID()}` + const windowName = `teammate-${name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}` + this.panes.set(paneId, { + title: name, + mode: 'window', + pidFile: makePidFile(paneId), + }) + return { paneId, isFirstTeammate: false, windowName } + } + + async sendCommandToPane( + paneId: PaneId, + command: string, + _useExternalSession?: boolean, + ): Promise { + const pane = this.panes.get(paneId) + if (!pane) { + throw new Error(`Unknown Windows Terminal pane id: ${paneId}`) + } + + const launcher = wrapPowerShellCommand(command, pane.pidFile) + // wt.exe treats ';' as its own command separator, which breaks + // multi-statement PowerShell commands passed via -Command. Encode the + // entire script as Base64 UTF-16LE and use -EncodedCommand instead. + const encoded = Buffer.from(launcher, 'utf16le').toString('base64') + const args = + pane.mode === 'window' + ? ['-w', '-1', 'new-tab', '--title', pane.title] + : ['-w', '0', 'split-pane', '--vertical', '--title', pane.title] + + const result = await this.runCommand('wt.exe', [ + ...args, + 'powershell.exe', + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded, + ]) + + if (result.code !== 0) { + throw new Error( + `Failed to launch Windows Terminal teammate ${paneId}: ${result.stderr}`, + ) + } + } + + async setPaneBorderColor( + _paneId: PaneId, + _color: AgentColorName, + _useExternalSession?: boolean, + ): Promise { + // Windows Terminal does not expose per-pane border colors through wt.exe. + } + + async setPaneTitle( + _paneId: PaneId, + _name: string, + _color: AgentColorName, + _useExternalSession?: boolean, + ): Promise { + // Title is passed at launch in sendCommandToPane. + } + + async enablePaneBorderStatus( + _windowTarget?: string, + _useExternalSession?: boolean, + ): Promise { + // Not supported by Windows Terminal's wt.exe surface. + } + + async rebalancePanes( + _windowTarget: string, + _hasLeader: boolean, + ): Promise { + // Windows Terminal handles split layout itself. + } + + async killPane( + paneId: PaneId, + _useExternalSession?: boolean, + ): Promise { + const pane = this.panes.get(paneId) + if (!pane) { + return false + } + + let pid: number + try { + pid = Number.parseInt((await readFile(pane.pidFile, 'utf-8')).trim(), 10) + } catch { + this.panes.delete(paneId) + return false + } + + if (!Number.isFinite(pid)) { + this.panes.delete(paneId) + return false + } + + const result = await this.runCommand('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-Command', + `Stop-Process -Id ${pid} -Force -ErrorAction Stop`, + ]) + this.panes.delete(paneId) + logForDebugging( + `[WindowsTerminalBackend] killPane ${paneId} pid=${pid} code=${result.code}`, + ) + return result.code === 0 + } + + async hidePane( + _paneId: PaneId, + _useExternalSession?: boolean, + ): Promise { + return false + } + + async showPane( + _paneId: PaneId, + _targetWindowOrPane: string, + _useExternalSession?: boolean, + ): Promise { + 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. +// eslint-disable-next-line custom-rules/no-top-level-side-effects +registerWindowsTerminalBackend(WindowsTerminalBackend) diff --git a/src/utils/swarm/backends/__tests__/PaneBackendExecutor.test.ts b/src/utils/swarm/backends/__tests__/PaneBackendExecutor.test.ts new file mode 100644 index 000000000..2e95922a9 --- /dev/null +++ b/src/utils/swarm/backends/__tests__/PaneBackendExecutor.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createPaneBackendExecutor } from '../PaneBackendExecutor' +import type { PaneBackend } from '../types' + +let tempHome: string +let previousConfigDir: string | undefined + +beforeEach(() => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + tempHome = join( + tmpdir(), + `pane-backend-executor-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + process.env.CLAUDE_CONFIG_DIR = tempHome +}) + +afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('PaneBackendExecutor', () => { + test('spawns process teammates with agent type and inherited auto permission mode', async () => { + let sentCommand = '' + const backend: PaneBackend = { + type: 'tmux', + displayName: 'tmux', + supportsHideShow: true, + async isAvailable() { + return true + }, + async isRunningInside() { + return false + }, + async createTeammatePaneInSwarmView() { + return { paneId: '%1', isFirstTeammate: false } + }, + async sendCommandToPane(_paneId, command) { + sentCommand = command + }, + async setPaneBorderColor() {}, + async setPaneTitle() {}, + async enablePaneBorderStatus() {}, + async rebalancePanes() {}, + async killPane() { + return true + }, + async hidePane() { + return true + }, + async showPane() { + return true + }, + } + + const executor = createPaneBackendExecutor(backend) + executor.setContext({ + getAppState: () => ({ + toolPermissionContext: { + mode: 'auto', + }, + }), + } as any) + + const result = await executor.spawn({ + name: 'reviewer', + teamName: 'alpha', + prompt: 'review the change', + cwd: tempHome, + parentSessionId: 'parent-session', + agentType: 'code-reviewer', + planModeRequired: false, + }) + + expect(result.success).toBe(true) + expect(result.backendType).toBe('tmux') + expect(result.paneId).toBe('%1') + expect(sentCommand).toContain('--agent-type code-reviewer') + expect(sentCommand).toContain('--permission-mode auto') + }) + + test('preserves legacy separate-window spawning when useSplitPane is false', async () => { + let paneSpawned = false + let windowSpawned = false + const backend: PaneBackend = { + type: 'tmux', + displayName: 'tmux', + supportsHideShow: true, + async isAvailable() { + return true + }, + async isRunningInside() { + return false + }, + async createTeammatePaneInSwarmView() { + paneSpawned = true + return { paneId: '%pane', isFirstTeammate: false } + }, + async createTeammateWindowInSwarmView() { + windowSpawned = true + return { + paneId: '%window', + windowName: 'teammate-worker', + isFirstTeammate: false, + } + }, + async sendCommandToPane() {}, + async setPaneBorderColor() {}, + async setPaneTitle() {}, + async enablePaneBorderStatus() {}, + async rebalancePanes() {}, + async killPane() { + return true + }, + async hidePane() { + return true + }, + async showPane() { + return true + }, + } + + const executor = createPaneBackendExecutor(backend) + executor.setContext({ + getAppState: () => ({ + toolPermissionContext: { + mode: 'default', + }, + }), + } as any) + + const result = await executor.spawn({ + name: 'worker', + teamName: 'alpha', + prompt: 'do work', + cwd: tempHome, + parentSessionId: 'parent-session', + useSplitPane: false, + }) + + expect(result.success).toBe(true) + expect(paneSpawned).toBe(false) + expect(windowSpawned).toBe(true) + expect(result.paneId).toBe('%window') + expect(result.windowName).toBe('teammate-worker') + expect(result.isSplitPane).toBe(false) + }) +}) diff --git a/src/utils/swarm/backends/__tests__/WindowsTerminalBackend.test.ts b/src/utils/swarm/backends/__tests__/WindowsTerminalBackend.test.ts new file mode 100644 index 000000000..bd06effd4 --- /dev/null +++ b/src/utils/swarm/backends/__tests__/WindowsTerminalBackend.test.ts @@ -0,0 +1,102 @@ +import { mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach, afterEach, describe, expect, test } from 'bun:test' +import { WindowsTerminalBackend } from '../WindowsTerminalBackend' + +type Call = { command: string; args: string[] } + +let tempDir: string + +beforeEach(async () => { + tempDir = join( + tmpdir(), + `windows-terminal-backend-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ) + await mkdir(tempDir, { recursive: true }) +}) + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) +}) + +function createBackend(calls: Call[]): WindowsTerminalBackend { + return new WindowsTerminalBackend( + async (command, args) => { + calls.push({ command, args }) + return { stdout: 'ok', stderr: '', code: 0 } + }, + () => 'windows', + ) +} + +function decodeEncodedCommand(call: Call): { + args: string[] + decodedLauncher: string +} { + expect(call.command).toBe('wt.exe') + const encIdx = call.args.indexOf('-EncodedCommand') + expect(encIdx).toBeGreaterThanOrEqual(0) + const encoded = call.args[encIdx + 1]! + const decodedLauncher = Buffer.from(encoded, 'base64').toString('utf16le') + return { args: call.args, decodedLauncher } +} + +describe('WindowsTerminalBackend', () => { + test('launches split panes through wt.exe with a wrapped PowerShell command', async () => { + const calls: Call[] = [] + const backend = createBackend(calls) + const pane = await backend.createTeammatePaneInSwarmView('worker', 'blue') + + await backend.sendCommandToPane( + pane.paneId, + "Set-Location -LiteralPath 'C:\\repo'; & 'claude.exe' '--agent-id' 'worker@alpha'", + ) + + expect(calls).toHaveLength(1) + const { args, decodedLauncher } = decodeEncodedCommand(calls[0]!) + expect(args).toContain('split-pane') + expect(args).toContain('--vertical') + expect(args).toContain('--title') + expect(args).toContain('worker') + expect(decodedLauncher).toContain('Set-Content -LiteralPath') + expect(decodedLauncher).toContain('claude.exe') + }) + + test('preserves use_splitpane false as a separate Windows Terminal window', async () => { + const calls: Call[] = [] + const backend = createBackend(calls) + const pane = await backend.createTeammateWindowInSwarmView( + 'reviewer', + 'cyan', + ) + + await backend.sendCommandToPane(pane.paneId, "Write-Output 'hello'") + + expect(pane.windowName).toBe('teammate-reviewer') + const { args } = decodeEncodedCommand(calls[0]!) + expect(args.join(' ')).toContain('-w -1 new-tab --title') + }) + + test('force kills the recorded teammate shell pid when available', async () => { + const calls: Call[] = [] + const backend = createBackend(calls) + const pane = await backend.createTeammatePaneInSwarmView('killer', 'red') + + await backend.sendCommandToPane(pane.paneId, "Write-Output 'running'") + const { decodedLauncher } = decodeEncodedCommand(calls[0]!) + const pidFile = decodedLauncher.match( + /Set-Content -LiteralPath '([^']+)'/, + )?.[1] + expect(pidFile).toBeString() + await writeFile(pidFile!, '12345', 'utf-8') + + const killed = await backend.killPane(pane.paneId) + + expect(killed).toBe(true) + expect(calls[calls.length - 1]!.command).toBe('powershell.exe') + expect(calls[calls.length - 1]!.args.join(' ')).toContain( + 'Stop-Process -Id 12345', + ) + }) +}) diff --git a/src/utils/swarm/backends/detection.ts b/src/utils/swarm/backends/detection.ts index 4812fcd58..be45b2aa7 100644 --- a/src/utils/swarm/backends/detection.ts +++ b/src/utils/swarm/backends/detection.ts @@ -24,6 +24,9 @@ let isInsideTmuxCached: boolean | null = null /** Cached result for isInITerm2 */ let isInITerm2Cached: boolean | null = null +/** Cached result for isInWindowsTerminal */ +let isInWindowsTerminalCached: 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, @@ -75,6 +78,20 @@ export async function isTmuxAvailable(): Promise { return result.code === 0 } +/** + * Checks if wt.exe is available without executing it. + * Do NOT run `wt.exe --version` — wt.exe is a UWP app bridge that opens + * the Windows Terminal GUI to render version info, producing a phantom + * "Windows 终端 1.24.x" window every time availability is checked. + */ +export async function isWindowsTerminalAvailable(): Promise { + if (process.env.WT_SESSION) { + return true + } + const result = await execFileNoThrow('where.exe', ['wt.exe']) + return result.code === 0 +} + /** * Checks if we're currently running inside iTerm2. * Uses multiple detection methods: @@ -103,6 +120,18 @@ export function isInITerm2(): boolean { return isInITerm2Cached } +/** + * Checks if we're currently running inside Windows Terminal. + * Windows Terminal sets WT_SESSION for child processes. + */ +export function isInWindowsTerminal(): boolean { + if (isInWindowsTerminalCached !== null) { + return isInWindowsTerminalCached + } + isInWindowsTerminalCached = !!process.env.WT_SESSION + return isInWindowsTerminalCached +} + /** * The it2 CLI command name. */ @@ -125,4 +154,5 @@ export async function isIt2CliAvailable(): Promise { export function resetDetectionCache(): void { isInsideTmuxCached = null isInITerm2Cached = null + isInWindowsTerminalCached = null } diff --git a/src/utils/swarm/backends/registry.ts b/src/utils/swarm/backends/registry.ts index 4035a821d..b1e0b7d4b 100644 --- a/src/utils/swarm/backends/registry.ts +++ b/src/utils/swarm/backends/registry.ts @@ -1,12 +1,15 @@ import { getIsNonInteractiveSession } from '../../../bootstrap/state.js' import { logForDebugging } from '../../../utils/debug.js' +import { errorMessage } from '../../../utils/errors.js' import { getPlatform } from '../../../utils/platform.js' import { isInITerm2, + isInWindowsTerminal, isInsideTmux, isInsideTmuxSync, isIt2CliAvailable, isTmuxAvailable, + isWindowsTerminalAvailable, } from './detection.js' import { createInProcessBackend } from './InProcessBackend.js' import { getPreferTmuxOverIterm2 } from './it2Setup.js' @@ -65,6 +68,11 @@ let TmuxBackendClass: (new () => PaneBackend) | null = null */ let ITermBackendClass: (new () => PaneBackend) | null = null +/** + * Placeholder for WindowsTerminalBackend. + */ +let WindowsTerminalBackendClass: (new () => PaneBackend) | null = null + /** * Ensures backend classes are dynamically imported so getBackendByType() can * construct them. Unlike detectAndGetBackend(), this never spawns subprocesses @@ -75,6 +83,7 @@ export async function ensureBackendsRegistered(): Promise { if (backendsRegistered) return await import('./TmuxBackend.js') await import('./ITermBackend.js') + await import('./WindowsTerminalBackend.js') backendsRegistered = true } @@ -99,6 +108,12 @@ export function registerITermBackend( ITermBackendClass = backendClass } +export function registerWindowsTerminalBackend( + backendClass: new () => PaneBackend, +): void { + WindowsTerminalBackendClass = backendClass +} + /** * Creates a TmuxBackend instance. * Throws if TmuxBackend hasn't been registered. @@ -125,6 +140,15 @@ function createITermBackend(): PaneBackend { return new ITermBackendClass() } +function createWindowsTerminalBackend(): PaneBackend { + if (!WindowsTerminalBackendClass) { + throw new Error( + 'WindowsTerminalBackend not registered. Import WindowsTerminalBackend.ts before using the registry.', + ) + } + return new WindowsTerminalBackendClass() +} + /** * Detection priority flow: * 1. If inside tmux, always use tmux (even in iTerm2) @@ -150,11 +174,32 @@ export async function detectAndGetBackend(): Promise { // Check all environment conditions upfront for logging const insideTmux = await isInsideTmux() const inITerm2 = isInITerm2() + const inWindowsTerminal = isInWindowsTerminal() logForDebugging( - `[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}`, + `[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}, inWindowsTerminal=${inWindowsTerminal}`, ) + if (getTeammateMode() === 'windows-terminal') { + if (getPlatform() !== 'windows') { + throw new Error( + 'Windows Terminal teammate mode is only available on Windows', + ) + } + const wtAvailable = await isWindowsTerminalAvailable() + if (!wtAvailable) { + throw new Error('Windows Terminal teammate mode requires wt.exe in PATH') + } + const backend = createWindowsTerminalBackend() + cachedBackend = backend + cachedDetectionResult = { + backend, + isNative: inWindowsTerminal, + needsIt2Setup: false, + } + return cachedDetectionResult + } + // Priority 1: If inside tmux, always use tmux if (insideTmux) { logForDebugging( @@ -230,7 +275,30 @@ export async function detectAndGetBackend(): Promise { ) } - // Priority 3: Fall back to tmux external session + // Priority 3: Native Windows Terminal panes/tabs — only when actually + // running INSIDE Windows Terminal. If running in VS Code's integrated + // terminal or another non-WT environment, fall through to in-process + // mode instead of opening an external Windows Terminal window. + if (getPlatform() === 'windows' && inWindowsTerminal) { + const wtAvailable = await isWindowsTerminalAvailable() + logForDebugging( + `[BackendRegistry] Inside Windows Terminal, wt.exe available: ${wtAvailable}`, + ) + + if (wtAvailable) { + logForDebugging('[BackendRegistry] Selected: Windows Terminal (wt.exe)') + const backend = createWindowsTerminalBackend() + cachedBackend = backend + cachedDetectionResult = { + backend, + isNative: true, + needsIt2Setup: false, + } + return cachedDetectionResult + } + } + + // Priority 4: Fall back to tmux external session const tmuxAvailable = await isTmuxAvailable() logForDebugging( `[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`, @@ -298,6 +366,8 @@ export function getBackendByType(type: PaneBackendType): PaneBackend { return createTmuxBackend() case 'iterm2': return createITermBackend() + case 'windows-terminal': + return createWindowsTerminalBackend() } } @@ -332,7 +402,11 @@ export function markInProcessFallback(): void { * Gets the teammate mode for this session. * Returns the session snapshot captured at startup, ignoring runtime config changes. */ -function getTeammateMode(): 'auto' | 'tmux' | 'in-process' { +function getTeammateMode(): + | 'auto' + | 'tmux' + | 'windows-terminal' + | 'in-process' { return getTeammateModeFromSnapshot() } @@ -346,6 +420,7 @@ function getTeammateMode(): 'auto' | 'tmux' | 'in-process' { * - 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 + * - If inside Windows Terminal, use pane backend (return false) * - Otherwise, use in-process (return true) */ export function isInProcessEnabled(): boolean { @@ -363,7 +438,7 @@ export function isInProcessEnabled(): boolean { let enabled: boolean if (mode === 'in-process') { enabled = true - } else if (mode === 'tmux') { + } else if (mode === 'tmux' || mode === 'windows-terminal') { enabled = false } else { // 'auto' mode - if a prior spawn fell back to in-process because no pane @@ -376,14 +451,26 @@ export function isInProcessEnabled(): boolean { return true } // Check if a pane backend environment is available - // If inside tmux or iTerm2, use pane backend; otherwise use in-process + // If inside tmux, iTerm2, or Windows Terminal, use pane backend; otherwise use in-process const insideTmux = isInsideTmuxSync() const inITerm2 = isInITerm2() - enabled = !insideTmux && !inITerm2 + const inWindowsTerminal = isInWindowsTerminal() + if ( + !insideTmux && + !inITerm2 && + !inWindowsTerminal && + getPlatform() === 'windows' + ) { + // On Windows, even outside Windows Terminal (e.g. VS Code terminal, cmd.exe), + // wt.exe may still be available. Let detectAndGetBackend() do the full async check. + enabled = false + } else { + enabled = !insideTmux && !inITerm2 && !inWindowsTerminal + } } logForDebugging( - `[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode}, insideTmux=${isInsideTmuxSync()}, inITerm2=${isInITerm2()})`, + `[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode})`, ) return enabled } @@ -393,8 +480,15 @@ export function isInProcessEnabled(): boolean { * 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' +export function getResolvedTeammateMode(): + | 'in-process' + | 'tmux' + | 'windows-terminal' { + if (isInProcessEnabled()) return 'in-process' + const mode = getTeammateMode() + if (mode === 'windows-terminal') return 'windows-terminal' + if (mode === 'auto' && getPlatform() === 'windows') return 'windows-terminal' + return 'tmux' } /** @@ -424,24 +518,51 @@ export function getInProcessBackend(): TeammateExecutor { */ export async function getTeammateExecutor( preferInProcess: boolean = false, + options?: { + onNeedsIt2Setup?: ( + tmuxAvailable: boolean, + ) => Promise<'installed' | 'use-tmux' | 'cancelled'> + }, ): Promise { if (preferInProcess && isInProcessEnabled()) { logForDebugging('[BackendRegistry] Using in-process executor') return getInProcessBackend() } - // Return pane backend executor - logForDebugging('[BackendRegistry] Using pane backend executor') - return getPaneBackendExecutor() + try { + logForDebugging('[BackendRegistry] Using pane backend executor') + return await getPaneBackendExecutor(options) + } catch (error) { + if (getTeammateModeFromSnapshot() !== 'auto') { + throw error + } + logForDebugging( + `[BackendRegistry] No pane backend available, falling back to in-process: ${errorMessage(error)}`, + ) + markInProcessFallback() + return getInProcessBackend() + } } /** * Gets the PaneBackendExecutor instance. * Creates and caches the instance on first call, detecting the appropriate pane backend. */ -async function getPaneBackendExecutor(): Promise { +async function getPaneBackendExecutor(options?: { + onNeedsIt2Setup?: ( + tmuxAvailable: boolean, + ) => Promise<'installed' | 'use-tmux' | 'cancelled'> +}): Promise { if (!cachedPaneBackendExecutor) { const detection = await detectAndGetBackend() + if (detection.needsIt2Setup && options?.onNeedsIt2Setup) { + const setupResult = await options.onNeedsIt2Setup(await isTmuxAvailable()) + if (setupResult === 'cancelled') { + throw new Error('Teammate spawn cancelled - iTerm2 setup required') + } + resetBackendDetection() + return getPaneBackendExecutor(options) + } cachedPaneBackendExecutor = createPaneBackendExecutor(detection.backend) logForDebugging( `[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`, diff --git a/src/utils/swarm/backends/teammateModeSnapshot.ts b/src/utils/swarm/backends/teammateModeSnapshot.ts index e73f9d61d..535458605 100644 --- a/src/utils/swarm/backends/teammateModeSnapshot.ts +++ b/src/utils/swarm/backends/teammateModeSnapshot.ts @@ -10,7 +10,7 @@ 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' +export type TeammateMode = 'auto' | 'tmux' | 'windows-terminal' | 'in-process' // Module-level variable to hold the captured mode at startup let initialTeammateMode: TeammateMode | null = null diff --git a/src/utils/swarm/backends/types.ts b/src/utils/swarm/backends/types.ts index 185253ad5..0bc97b7e8 100644 --- a/src/utils/swarm/backends/types.ts +++ b/src/utils/swarm/backends/types.ts @@ -1,23 +1,27 @@ import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' +import type { CustomAgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' +import type { ToolUseContext } from '../../../Tool.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 + * - 'windows-terminal': Uses Windows Terminal panes/tabs via wt.exe * - 'in-process': Runs teammate in the same Node.js process with isolated context */ -export type BackendType = 'tmux' | 'iterm2' | 'in-process' +export type BackendType = 'tmux' | 'iterm2' | 'windows-terminal' | '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' +export type PaneBackendType = 'tmux' | 'iterm2' | 'windows-terminal' /** * 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. + * For Windows Terminal, this is an internal id mapped to the spawned shell PID. */ export type PaneId = string @@ -73,6 +77,15 @@ export type PaneBackend = { color: AgentColorName, ): Promise + /** + * Creates a separate terminal window/tab for a teammate when supported. + * This preserves the legacy `use_splitpane: false` behavior. + */ + createTeammateWindowInSwarmView?( + name: string, + color: AgentColorName, + ): Promise + /** * Sends a command to execute in a specific pane. * @@ -209,14 +222,24 @@ export type TeammateSpawnConfig = TeammateIdentity & { cwd: string /** Model to use for this teammate */ model?: string + /** Optional custom agent type for process-based teammates. */ + agentType?: string + /** Optional resolved custom agent definition for in-process teammates. */ + agentDefinition?: CustomAgentDefinition + /** Short description of the task, used for prompt display. */ + description?: 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 + /** false preserves legacy separate-window spawning for pane-capable backends. */ + useSplitPane?: boolean /** Parent session ID (for context linking) */ parentSessionId: string + /** request_id of the API call that spawned this teammate. */ + invokingRequestId?: string /** Tool permissions to grant this teammate */ permissions?: string[] /** Whether this teammate can show permission prompts for unlisted tools. @@ -251,6 +274,16 @@ export type TeammateSpawnResult = { /** Pane ID (pane-based only) */ paneId?: PaneId + /** Backend used for the spawned teammate. */ + backendType?: BackendType + /** Assigned color for display. */ + color?: AgentColorName + /** Whether the pane was spawned inside the user's current tmux session. */ + insideTmux?: boolean + /** Window/tab name when the backend created a separate window. */ + windowName?: string + /** Whether the backend used split panes. */ + isSplitPane?: boolean } /** @@ -280,6 +313,9 @@ export type TeammateExecutor = { /** Backend type identifier */ readonly type: BackendType + /** Provide AppState/tool context before lifecycle operations that need it. */ + setContext?(context: ToolUseContext): void + /** Check if this executor is available on the system */ isAvailable(): Promise @@ -306,6 +342,8 @@ export type TeammateExecutor = { /** * 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' +export function isPaneBackend( + type: BackendType, +): type is 'tmux' | 'iterm2' | 'windows-terminal' { + return type === 'tmux' || type === 'iterm2' || type === 'windows-terminal' } diff --git a/src/utils/swarm/spawnInProcess.ts b/src/utils/swarm/spawnInProcess.ts index 1a4f6c857..5cfa0ab5a 100644 --- a/src/utils/swarm/spawnInProcess.ts +++ b/src/utils/swarm/spawnInProcess.ts @@ -339,3 +339,35 @@ export function killInProcessTeammate( return killed } + +/** + * Kills an in-process teammate by logical agent ID. + * Used by team-level UI/actions where the stable identifier is + * "name@team", not the AppState task id. + */ +export function killInProcessTeammateByAgentId( + agentIdToKill: string, + setAppState: SetAppStateFn, +): boolean { + let taskIdToKill: string | undefined + + setAppState((prev: AppState) => { + for (const [taskId, task] of Object.entries(prev.tasks)) { + if ( + task.type === 'in_process_teammate' && + task.identity.agentId === agentIdToKill && + task.status === 'running' + ) { + taskIdToKill = taskId + break + } + } + return prev + }) + + if (!taskIdToKill) { + return false + } + + return killInProcessTeammate(taskIdToKill, setAppState) +} diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index cfccdf5a2..5aaa9386b 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -39,6 +39,13 @@ export function buildInheritedCliFlags(options?: { planModeRequired?: boolean permissionMode?: PermissionMode }): string { + return quote(buildInheritedCliArgParts(options)) +} + +export function buildInheritedCliArgParts(options?: { + planModeRequired?: boolean + permissionMode?: PermissionMode +}): string[] { const flags: string[] = [] const { planModeRequired, permissionMode } = options || {} @@ -52,30 +59,33 @@ export function buildInheritedCliFlags(options?: { ) { flags.push('--dangerously-skip-permissions') } else if (permissionMode === 'acceptEdits') { - flags.push('--permission-mode acceptEdits') + flags.push('--permission-mode', 'acceptEdits') + } else if (permissionMode === 'auto') { + // Teammates inherit auto mode so the classifier evaluates their tool calls too. + flags.push('--permission-mode', 'auto') } // Propagate --model if explicitly set via CLI const modelOverride = getMainLoopModelOverride() if (modelOverride) { - flags.push(`--model ${quote([modelOverride])}`) + flags.push('--model', modelOverride) } // Propagate --settings if set via CLI const settingsPath = getFlagSettingsPath() if (settingsPath) { - flags.push(`--settings ${quote([settingsPath])}`) + flags.push('--settings', settingsPath) } // Propagate --plugin-dir for each inline plugin const inlinePlugins = getInlinePlugins() for (const pluginDir of inlinePlugins) { - flags.push(`--plugin-dir ${quote([pluginDir])}`) + flags.push('--plugin-dir', pluginDir) } // Propagate --teammate-mode so tmux teammates use the same mode as leader const sessionMode = getTeammateModeFromSnapshot() - flags.push(`--teammate-mode ${sessionMode}`) + flags.push('--teammate-mode', sessionMode) // Propagate --chrome / --no-chrome if explicitly set on the CLI const chromeFlagOverride = getChromeFlagOverride() @@ -85,7 +95,7 @@ export function buildInheritedCliFlags(options?: { flags.push('--no-chrome') } - return flags.join(' ') + return flags } /** @@ -133,14 +143,23 @@ const TEAMMATE_ENV_VARS = [ * 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'] + return getInheritedEnvVarAssignments() + .map(([key, value]) => `${key}=${quote([value])}`) + .join(' ') +} + +export function getInheritedEnvVarAssignments(): Array<[string, string]> { + const envVars: Array<[string, string]> = [ + ['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])}`) + envVars.push([key, value]) } } - return envVars.join(' ') + return envVars }