mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加 Windows Terminal swarm 后端及 swarm 增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
279
src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts
Normal file
279
src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
115
src/utils/swarm/__tests__/spawnInProcess.test.ts
Normal file
115
src/utils/swarm/__tests__/spawnInProcess.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
10
src/utils/swarm/__tests__/spawnUtils.test.ts
Normal file
10
src/utils/swarm/__tests__/spawnUtils.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
237
src/utils/swarm/backends/WindowsTerminalBackend.ts
Normal file
237
src/utils/swarm/backends/WindowsTerminalBackend.ts
Normal 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)
|
||||
155
src/utils/swarm/backends/__tests__/PaneBackendExecutor.test.ts
Normal file
155
src/utils/swarm/backends/__tests__/PaneBackendExecutor.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user