feat: 添加 Windows Terminal swarm 后端及 swarm 增强

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:09 +08:00
parent c4775fff58
commit 59f8675fa3
17 changed files with 1298 additions and 86 deletions

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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,
}
}

View File

@@ -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 =

View File

@@ -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<CreatePaneResult & { windowName: string }> {
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.
*/

View File

@@ -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<CommandResult>
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<PaneId, WindowsTerminalPane>()
constructor(
private readonly runCommand: CommandRunner = execFileNoThrow,
private readonly getPlatformValue: () => Platform = getPlatform,
) {}
async isAvailable(): Promise<boolean> {
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<boolean> {
return this.getPlatformValue() === 'windows' && isInWindowsTerminal()
}
async createTeammatePaneInSwarmView(
name: string,
_color: AgentColorName,
): Promise<CreatePaneResult> {
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<CreatePaneResult & { windowName: string }> {
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<void> {
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<void> {
// Windows Terminal does not expose per-pane border colors through wt.exe.
}
async setPaneTitle(
_paneId: PaneId,
_name: string,
_color: AgentColorName,
_useExternalSession?: boolean,
): Promise<void> {
// Title is passed at launch in sendCommandToPane.
}
async enablePaneBorderStatus(
_windowTarget?: string,
_useExternalSession?: boolean,
): Promise<void> {
// Not supported by Windows Terminal's wt.exe surface.
}
async rebalancePanes(
_windowTarget: string,
_hasLeader: boolean,
): Promise<void> {
// Windows Terminal handles split layout itself.
}
async killPane(
paneId: PaneId,
_useExternalSession?: boolean,
): Promise<boolean> {
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<boolean> {
return false
}
async showPane(
_paneId: PaneId,
_targetWindowOrPane: string,
_useExternalSession?: boolean,
): Promise<boolean> {
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)

View File

@@ -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)
})
})

View File

@@ -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',
)
})
})

View File

@@ -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<boolean> {
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<boolean> {
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<boolean> {
export function resetDetectionCache(): void {
isInsideTmuxCached = null
isInITerm2Cached = null
isInWindowsTerminalCached = null
}

View File

@@ -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<void> {
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<BackendDetectionResult> {
// 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<BackendDetectionResult> {
)
}
// 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<TeammateExecutor> {
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<TeammateExecutor> {
async function getPaneBackendExecutor(options?: {
onNeedsIt2Setup?: (
tmuxAvailable: boolean,
) => Promise<'installed' | 'use-tmux' | 'cancelled'>
}): Promise<TeammateExecutor> {
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}`,

View File

@@ -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

View File

@@ -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<CreatePaneResult>
/**
* 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<CreatePaneResult & { windowName: string }>
/**
* 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<boolean>
@@ -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'
}

View File

@@ -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)
}

View File

@@ -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
}