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

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