mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25: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 viewedTeammate = getViewedTeammateTask(state)
|
||||||
const viewedColor = toThemeColor(viewedTeammate?.identity.color)
|
const viewedColor = toThemeColor(viewedTeammate?.identity.color)
|
||||||
const inProcessMode = isInProcessEnabled()
|
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) {
|
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 {
|
return {
|
||||||
text: `View teammates: \`tmux -L ${getSwarmSocketName()} a\``,
|
text: hint,
|
||||||
bgColor: viewedColor,
|
bgColor: viewedColor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,15 @@
|
|||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
|
||||||
import { isEnvTruthy } from './envUtils.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.
|
* Centralized runtime check for agent teams/teammate features.
|
||||||
* This is the single gate that should be checked everywhere teammates
|
* This is the single gate that should be checked everywhere teammates
|
||||||
* are referenced (prompts, code, tools isEnabled, UI, etc.).
|
* are referenced (prompts, code, tools isEnabled, UI, etc.).
|
||||||
*
|
*
|
||||||
* Ant builds: always enabled.
|
* Fork build: enabled by default. Can be disabled via
|
||||||
* External builds require both:
|
* CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 if needed.
|
||||||
* 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag
|
|
||||||
* 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch)
|
|
||||||
*/
|
*/
|
||||||
export function isAgentSwarmsEnabled(): boolean {
|
export function isAgentSwarmsEnabled(): boolean {
|
||||||
// Ant: always on
|
if (isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED)) {
|
||||||
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)) {
|
|
||||||
return false
|
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,
|
prompt: config.prompt,
|
||||||
color: config.color,
|
color: config.color,
|
||||||
planModeRequired: config.planModeRequired ?? false,
|
planModeRequired: config.planModeRequired ?? false,
|
||||||
|
model: config.model,
|
||||||
},
|
},
|
||||||
this.context,
|
this.context,
|
||||||
)
|
)
|
||||||
@@ -115,6 +116,8 @@ export class InProcessBackend implements TeammateExecutor {
|
|||||||
},
|
},
|
||||||
taskId: result.taskId,
|
taskId: result.taskId,
|
||||||
prompt: config.prompt,
|
prompt: config.prompt,
|
||||||
|
description: config.description,
|
||||||
|
agentDefinition: config.agentDefinition,
|
||||||
teammateContext: result.teammateContext,
|
teammateContext: result.teammateContext,
|
||||||
// Strip messages: the teammate never reads toolUseContext.messages
|
// Strip messages: the teammate never reads toolUseContext.messages
|
||||||
// (runAgent overrides it via createSubagentContext). Passing the
|
// (runAgent overrides it via createSubagentContext). Passing the
|
||||||
@@ -126,6 +129,7 @@ export class InProcessBackend implements TeammateExecutor {
|
|||||||
systemPromptMode: config.systemPromptMode,
|
systemPromptMode: config.systemPromptMode,
|
||||||
allowedTools: config.permissions,
|
allowedTools: config.permissions,
|
||||||
allowPermissionPrompts: config.allowPermissionPrompts,
|
allowPermissionPrompts: config.allowPermissionPrompts,
|
||||||
|
invokingRequestId: config.invokingRequestId,
|
||||||
})
|
})
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
@@ -138,6 +142,8 @@ export class InProcessBackend implements TeammateExecutor {
|
|||||||
agentId: result.agentId,
|
agentId: result.agentId,
|
||||||
taskId: result.taskId,
|
taskId: result.taskId,
|
||||||
abortController: result.abortController,
|
abortController: result.abortController,
|
||||||
|
backendType: this.type,
|
||||||
|
color: config.color,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import { getSessionId } from '../../../bootstrap/state.js'
|
|||||||
import type { ToolUseContext } from '../../../Tool.js'
|
import type { ToolUseContext } from '../../../Tool.js'
|
||||||
import { formatAgentId, parseAgentId } from '../../../utils/agentId.js'
|
import { formatAgentId, parseAgentId } from '../../../utils/agentId.js'
|
||||||
import { quote } from '../../../utils/bash/shellQuote.js'
|
import { quote } from '../../../utils/bash/shellQuote.js'
|
||||||
|
import { isInBundledMode } from '../../../utils/bundledMode.js'
|
||||||
import { registerCleanup } from '../../../utils/cleanupRegistry.js'
|
import { registerCleanup } from '../../../utils/cleanupRegistry.js'
|
||||||
import { logForDebugging } from '../../../utils/debug.js'
|
import { logForDebugging } from '../../../utils/debug.js'
|
||||||
import { jsonStringify } from '../../../utils/slowOperations.js'
|
import { jsonStringify } from '../../../utils/slowOperations.js'
|
||||||
import { writeToMailbox } from '../../../utils/teammateMailbox.js'
|
import { writeToMailbox } from '../../../utils/teammateMailbox.js'
|
||||||
import {
|
import {
|
||||||
buildInheritedCliFlags,
|
buildInheritedCliArgParts,
|
||||||
buildInheritedEnvVars,
|
buildInheritedEnvVars,
|
||||||
|
getInheritedEnvVarAssignments,
|
||||||
getTeammateCommand,
|
getTeammateCommand,
|
||||||
} from '../spawnUtils.js'
|
} from '../spawnUtils.js'
|
||||||
import { assignTeammateColor } from '../teammateLayoutManager.js'
|
import { assignTeammateColor } from '../teammateLayoutManager.js'
|
||||||
@@ -22,6 +24,43 @@ import type {
|
|||||||
TeammateSpawnResult,
|
TeammateSpawnResult,
|
||||||
} from './types.js'
|
} 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.
|
* PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface.
|
||||||
*
|
*
|
||||||
@@ -95,12 +134,18 @@ export class PaneBackendExecutor implements TeammateExecutor {
|
|||||||
// Assign a unique color to this teammate
|
// Assign a unique color to this teammate
|
||||||
const teammateColor = config.color ?? assignTeammateColor(agentId)
|
const teammateColor = config.color ?? assignTeammateColor(agentId)
|
||||||
|
|
||||||
// Create a pane in the swarm view
|
const paneResult =
|
||||||
const { paneId, isFirstTeammate } =
|
config.useSplitPane === false &&
|
||||||
await this.backend.createTeammatePaneInSwarmView(
|
this.backend.createTeammateWindowInSwarmView
|
||||||
config.name,
|
? await this.backend.createTeammateWindowInSwarmView(
|
||||||
teammateColor,
|
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
|
// Check if we're inside tmux to determine how to send commands
|
||||||
const insideTmux = await isInsideTmux()
|
const insideTmux = await isInsideTmux()
|
||||||
@@ -115,43 +160,43 @@ export class PaneBackendExecutor implements TeammateExecutor {
|
|||||||
|
|
||||||
// Build teammate identity CLI args
|
// Build teammate identity CLI args
|
||||||
const teammateArgs = [
|
const teammateArgs = [
|
||||||
`--agent-id ${quote([agentId])}`,
|
'--agent-id',
|
||||||
`--agent-name ${quote([config.name])}`,
|
agentId,
|
||||||
`--team-name ${quote([config.teamName])}`,
|
'--agent-name',
|
||||||
`--agent-color ${quote([teammateColor])}`,
|
config.name,
|
||||||
`--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`,
|
'--team-name',
|
||||||
config.planModeRequired ? '--plan-mode-required' : '',
|
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
|
// Build CLI flags to propagate to teammate
|
||||||
const appState = this.context.getAppState()
|
const appState = this.context.getAppState()
|
||||||
let inheritedFlags = buildInheritedCliFlags({
|
let inheritedArgParts = buildInheritedCliArgParts({
|
||||||
planModeRequired: config.planModeRequired,
|
planModeRequired: config.planModeRequired,
|
||||||
permissionMode: appState.toolPermissionContext.mode,
|
permissionMode: appState.toolPermissionContext.mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If teammate has a custom model, add --model flag (or replace inherited one)
|
// If teammate has a custom model, add --model flag (or replace inherited one)
|
||||||
if (config.model) {
|
if (config.model) {
|
||||||
inheritedFlags = inheritedFlags
|
inheritedArgParts = withoutModelArg(inheritedArgParts)
|
||||||
.split(' ')
|
inheritedArgParts.push('--model', config.model)
|
||||||
.filter(
|
|
||||||
(flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model',
|
|
||||||
)
|
|
||||||
.join(' ')
|
|
||||||
inheritedFlags = inheritedFlags
|
|
||||||
? `${inheritedFlags} --model ${quote([config.model])}`
|
|
||||||
: `--model ${quote([config.model])}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : ''
|
|
||||||
const workingDir = config.cwd
|
const workingDir = config.cwd
|
||||||
|
|
||||||
// Build environment variables to forward to teammate
|
// Build environment variables to forward to teammate
|
||||||
const envStr = buildInheritedEnvVars()
|
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
|
// Send the command to the new pane
|
||||||
// Use swarm socket when running outside tmux (external swarm session)
|
// Use swarm socket when running outside tmux (external swarm session)
|
||||||
@@ -193,6 +238,14 @@ export class PaneBackendExecutor implements TeammateExecutor {
|
|||||||
success: true,
|
success: true,
|
||||||
agentId,
|
agentId,
|
||||||
paneId,
|
paneId,
|
||||||
|
backendType: this.type,
|
||||||
|
color: teammateColor,
|
||||||
|
insideTmux,
|
||||||
|
windowName:
|
||||||
|
'windowName' in paneResult
|
||||||
|
? (paneResult as { windowName: string }).windowName
|
||||||
|
: undefined,
|
||||||
|
isSplitPane: config.useSplitPane !== false,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
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.
|
* 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 */
|
/** Cached result for isInITerm2 */
|
||||||
let isInITerm2Cached: boolean | null = null
|
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).
|
* 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,
|
* 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
|
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.
|
* Checks if we're currently running inside iTerm2.
|
||||||
* Uses multiple detection methods:
|
* Uses multiple detection methods:
|
||||||
@@ -103,6 +120,18 @@ export function isInITerm2(): boolean {
|
|||||||
return isInITerm2Cached
|
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.
|
* The it2 CLI command name.
|
||||||
*/
|
*/
|
||||||
@@ -125,4 +154,5 @@ export async function isIt2CliAvailable(): Promise<boolean> {
|
|||||||
export function resetDetectionCache(): void {
|
export function resetDetectionCache(): void {
|
||||||
isInsideTmuxCached = null
|
isInsideTmuxCached = null
|
||||||
isInITerm2Cached = null
|
isInITerm2Cached = null
|
||||||
|
isInWindowsTerminalCached = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { getIsNonInteractiveSession } from '../../../bootstrap/state.js'
|
import { getIsNonInteractiveSession } from '../../../bootstrap/state.js'
|
||||||
import { logForDebugging } from '../../../utils/debug.js'
|
import { logForDebugging } from '../../../utils/debug.js'
|
||||||
|
import { errorMessage } from '../../../utils/errors.js'
|
||||||
import { getPlatform } from '../../../utils/platform.js'
|
import { getPlatform } from '../../../utils/platform.js'
|
||||||
import {
|
import {
|
||||||
isInITerm2,
|
isInITerm2,
|
||||||
|
isInWindowsTerminal,
|
||||||
isInsideTmux,
|
isInsideTmux,
|
||||||
isInsideTmuxSync,
|
isInsideTmuxSync,
|
||||||
isIt2CliAvailable,
|
isIt2CliAvailable,
|
||||||
isTmuxAvailable,
|
isTmuxAvailable,
|
||||||
|
isWindowsTerminalAvailable,
|
||||||
} from './detection.js'
|
} from './detection.js'
|
||||||
import { createInProcessBackend } from './InProcessBackend.js'
|
import { createInProcessBackend } from './InProcessBackend.js'
|
||||||
import { getPreferTmuxOverIterm2 } from './it2Setup.js'
|
import { getPreferTmuxOverIterm2 } from './it2Setup.js'
|
||||||
@@ -65,6 +68,11 @@ let TmuxBackendClass: (new () => PaneBackend) | null = null
|
|||||||
*/
|
*/
|
||||||
let ITermBackendClass: (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
|
* Ensures backend classes are dynamically imported so getBackendByType() can
|
||||||
* construct them. Unlike detectAndGetBackend(), this never spawns subprocesses
|
* construct them. Unlike detectAndGetBackend(), this never spawns subprocesses
|
||||||
@@ -75,6 +83,7 @@ export async function ensureBackendsRegistered(): Promise<void> {
|
|||||||
if (backendsRegistered) return
|
if (backendsRegistered) return
|
||||||
await import('./TmuxBackend.js')
|
await import('./TmuxBackend.js')
|
||||||
await import('./ITermBackend.js')
|
await import('./ITermBackend.js')
|
||||||
|
await import('./WindowsTerminalBackend.js')
|
||||||
backendsRegistered = true
|
backendsRegistered = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +108,12 @@ export function registerITermBackend(
|
|||||||
ITermBackendClass = backendClass
|
ITermBackendClass = backendClass
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function registerWindowsTerminalBackend(
|
||||||
|
backendClass: new () => PaneBackend,
|
||||||
|
): void {
|
||||||
|
WindowsTerminalBackendClass = backendClass
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a TmuxBackend instance.
|
* Creates a TmuxBackend instance.
|
||||||
* Throws if TmuxBackend hasn't been registered.
|
* Throws if TmuxBackend hasn't been registered.
|
||||||
@@ -125,6 +140,15 @@ function createITermBackend(): PaneBackend {
|
|||||||
return new ITermBackendClass()
|
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:
|
* Detection priority flow:
|
||||||
* 1. If inside tmux, always use tmux (even in iTerm2)
|
* 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
|
// Check all environment conditions upfront for logging
|
||||||
const insideTmux = await isInsideTmux()
|
const insideTmux = await isInsideTmux()
|
||||||
const inITerm2 = isInITerm2()
|
const inITerm2 = isInITerm2()
|
||||||
|
const inWindowsTerminal = isInWindowsTerminal()
|
||||||
|
|
||||||
logForDebugging(
|
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
|
// Priority 1: If inside tmux, always use tmux
|
||||||
if (insideTmux) {
|
if (insideTmux) {
|
||||||
logForDebugging(
|
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()
|
const tmuxAvailable = await isTmuxAvailable()
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`,
|
`[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`,
|
||||||
@@ -298,6 +366,8 @@ export function getBackendByType(type: PaneBackendType): PaneBackend {
|
|||||||
return createTmuxBackend()
|
return createTmuxBackend()
|
||||||
case 'iterm2':
|
case 'iterm2':
|
||||||
return createITermBackend()
|
return createITermBackend()
|
||||||
|
case 'windows-terminal':
|
||||||
|
return createWindowsTerminalBackend()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +402,11 @@ export function markInProcessFallback(): void {
|
|||||||
* Gets the teammate mode for this session.
|
* Gets the teammate mode for this session.
|
||||||
* Returns the session snapshot captured at startup, ignoring runtime config changes.
|
* 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()
|
return getTeammateModeFromSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +420,7 @@ function getTeammateMode(): 'auto' | 'tmux' | 'in-process' {
|
|||||||
* - If inside tmux, use pane backend (return false)
|
* - If inside tmux, use pane backend (return false)
|
||||||
* - If inside iTerm2, use pane backend (return false) - detectAndGetBackend()
|
* - If inside iTerm2, use pane backend (return false) - detectAndGetBackend()
|
||||||
* will pick ITermBackend if it2 is available, or fall back to tmux
|
* 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)
|
* - Otherwise, use in-process (return true)
|
||||||
*/
|
*/
|
||||||
export function isInProcessEnabled(): boolean {
|
export function isInProcessEnabled(): boolean {
|
||||||
@@ -363,7 +438,7 @@ export function isInProcessEnabled(): boolean {
|
|||||||
let enabled: boolean
|
let enabled: boolean
|
||||||
if (mode === 'in-process') {
|
if (mode === 'in-process') {
|
||||||
enabled = true
|
enabled = true
|
||||||
} else if (mode === 'tmux') {
|
} else if (mode === 'tmux' || mode === 'windows-terminal') {
|
||||||
enabled = false
|
enabled = false
|
||||||
} else {
|
} else {
|
||||||
// 'auto' mode - if a prior spawn fell back to in-process because no pane
|
// 'auto' mode - if a prior spawn fell back to in-process because no pane
|
||||||
@@ -376,14 +451,26 @@ export function isInProcessEnabled(): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check if a pane backend environment is available
|
// 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 insideTmux = isInsideTmuxSync()
|
||||||
const inITerm2 = isInITerm2()
|
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(
|
logForDebugging(
|
||||||
`[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode}, insideTmux=${isInsideTmuxSync()}, inITerm2=${isInITerm2()})`,
|
`[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode})`,
|
||||||
)
|
)
|
||||||
return enabled
|
return enabled
|
||||||
}
|
}
|
||||||
@@ -393,8 +480,15 @@ export function isInProcessEnabled(): boolean {
|
|||||||
* Unlike getTeammateModeFromSnapshot which may return 'auto', this returns
|
* Unlike getTeammateModeFromSnapshot which may return 'auto', this returns
|
||||||
* what 'auto' actually resolves to given the current environment.
|
* what 'auto' actually resolves to given the current environment.
|
||||||
*/
|
*/
|
||||||
export function getResolvedTeammateMode(): 'in-process' | 'tmux' {
|
export function getResolvedTeammateMode():
|
||||||
return isInProcessEnabled() ? 'in-process' : 'tmux'
|
| '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(
|
export async function getTeammateExecutor(
|
||||||
preferInProcess: boolean = false,
|
preferInProcess: boolean = false,
|
||||||
|
options?: {
|
||||||
|
onNeedsIt2Setup?: (
|
||||||
|
tmuxAvailable: boolean,
|
||||||
|
) => Promise<'installed' | 'use-tmux' | 'cancelled'>
|
||||||
|
},
|
||||||
): Promise<TeammateExecutor> {
|
): Promise<TeammateExecutor> {
|
||||||
if (preferInProcess && isInProcessEnabled()) {
|
if (preferInProcess && isInProcessEnabled()) {
|
||||||
logForDebugging('[BackendRegistry] Using in-process executor')
|
logForDebugging('[BackendRegistry] Using in-process executor')
|
||||||
return getInProcessBackend()
|
return getInProcessBackend()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return pane backend executor
|
try {
|
||||||
logForDebugging('[BackendRegistry] Using pane backend executor')
|
logForDebugging('[BackendRegistry] Using pane backend executor')
|
||||||
return getPaneBackendExecutor()
|
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.
|
* Gets the PaneBackendExecutor instance.
|
||||||
* Creates and caches the instance on first call, detecting the appropriate pane backend.
|
* 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) {
|
if (!cachedPaneBackendExecutor) {
|
||||||
const detection = await detectAndGetBackend()
|
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)
|
cachedPaneBackendExecutor = createPaneBackendExecutor(detection.backend)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`,
|
`[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getGlobalConfig } from '../../../utils/config.js'
|
|||||||
import { logForDebugging } from '../../../utils/debug.js'
|
import { logForDebugging } from '../../../utils/debug.js'
|
||||||
import { logError } from '../../../utils/log.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
|
// Module-level variable to hold the captured mode at startup
|
||||||
let initialTeammateMode: TeammateMode | null = null
|
let initialTeammateMode: TeammateMode | null = null
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
|
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.
|
* Types of backends available for teammate execution.
|
||||||
* - 'tmux': Uses tmux for pane management (works in tmux or standalone)
|
* - 'tmux': Uses tmux for pane management (works in tmux or standalone)
|
||||||
* - 'iterm2': Uses iTerm2 native split panes via the it2 CLI
|
* - '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
|
* - '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.
|
* Subset of BackendType for pane-based backends only.
|
||||||
* Used in messages and types that specifically deal with terminal panes.
|
* 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.
|
* Opaque identifier for a pane managed by a backend.
|
||||||
* For tmux, this is the tmux pane ID (e.g., "%1").
|
* For tmux, this is the tmux pane ID (e.g., "%1").
|
||||||
* For iTerm2, this is the session ID returned by it2.
|
* 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
|
export type PaneId = string
|
||||||
|
|
||||||
@@ -73,6 +77,15 @@ export type PaneBackend = {
|
|||||||
color: AgentColorName,
|
color: AgentColorName,
|
||||||
): Promise<CreatePaneResult>
|
): 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.
|
* Sends a command to execute in a specific pane.
|
||||||
*
|
*
|
||||||
@@ -209,14 +222,24 @@ export type TeammateSpawnConfig = TeammateIdentity & {
|
|||||||
cwd: string
|
cwd: string
|
||||||
/** Model to use for this teammate */
|
/** Model to use for this teammate */
|
||||||
model?: string
|
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) */
|
/** System prompt for this teammate (resolved from workflow config) */
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
/** How to apply the system prompt: 'replace' or 'append' to default */
|
/** How to apply the system prompt: 'replace' or 'append' to default */
|
||||||
systemPromptMode?: 'default' | 'replace' | 'append'
|
systemPromptMode?: 'default' | 'replace' | 'append'
|
||||||
/** Optional git worktree path */
|
/** Optional git worktree path */
|
||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
|
/** false preserves legacy separate-window spawning for pane-capable backends. */
|
||||||
|
useSplitPane?: boolean
|
||||||
/** Parent session ID (for context linking) */
|
/** Parent session ID (for context linking) */
|
||||||
parentSessionId: string
|
parentSessionId: string
|
||||||
|
/** request_id of the API call that spawned this teammate. */
|
||||||
|
invokingRequestId?: string
|
||||||
/** Tool permissions to grant this teammate */
|
/** Tool permissions to grant this teammate */
|
||||||
permissions?: string[]
|
permissions?: string[]
|
||||||
/** Whether this teammate can show permission prompts for unlisted tools.
|
/** Whether this teammate can show permission prompts for unlisted tools.
|
||||||
@@ -251,6 +274,16 @@ export type TeammateSpawnResult = {
|
|||||||
|
|
||||||
/** Pane ID (pane-based only) */
|
/** Pane ID (pane-based only) */
|
||||||
paneId?: PaneId
|
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 */
|
/** Backend type identifier */
|
||||||
readonly type: BackendType
|
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 */
|
/** Check if this executor is available on the system */
|
||||||
isAvailable(): Promise<boolean>
|
isAvailable(): Promise<boolean>
|
||||||
|
|
||||||
@@ -306,6 +342,8 @@ export type TeammateExecutor = {
|
|||||||
/**
|
/**
|
||||||
* Type guard to check if a backend type uses terminal panes.
|
* Type guard to check if a backend type uses terminal panes.
|
||||||
*/
|
*/
|
||||||
export function isPaneBackend(type: BackendType): type is 'tmux' | 'iterm2' {
|
export function isPaneBackend(
|
||||||
return type === 'tmux' || type === 'iterm2'
|
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
|
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
|
planModeRequired?: boolean
|
||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
}): string {
|
}): string {
|
||||||
|
return quote(buildInheritedCliArgParts(options))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInheritedCliArgParts(options?: {
|
||||||
|
planModeRequired?: boolean
|
||||||
|
permissionMode?: PermissionMode
|
||||||
|
}): string[] {
|
||||||
const flags: string[] = []
|
const flags: string[] = []
|
||||||
const { planModeRequired, permissionMode } = options || {}
|
const { planModeRequired, permissionMode } = options || {}
|
||||||
|
|
||||||
@@ -52,30 +59,33 @@ export function buildInheritedCliFlags(options?: {
|
|||||||
) {
|
) {
|
||||||
flags.push('--dangerously-skip-permissions')
|
flags.push('--dangerously-skip-permissions')
|
||||||
} else if (permissionMode === 'acceptEdits') {
|
} 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
|
// Propagate --model if explicitly set via CLI
|
||||||
const modelOverride = getMainLoopModelOverride()
|
const modelOverride = getMainLoopModelOverride()
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
flags.push(`--model ${quote([modelOverride])}`)
|
flags.push('--model', modelOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Propagate --settings if set via CLI
|
// Propagate --settings if set via CLI
|
||||||
const settingsPath = getFlagSettingsPath()
|
const settingsPath = getFlagSettingsPath()
|
||||||
if (settingsPath) {
|
if (settingsPath) {
|
||||||
flags.push(`--settings ${quote([settingsPath])}`)
|
flags.push('--settings', settingsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Propagate --plugin-dir for each inline plugin
|
// Propagate --plugin-dir for each inline plugin
|
||||||
const inlinePlugins = getInlinePlugins()
|
const inlinePlugins = getInlinePlugins()
|
||||||
for (const pluginDir of inlinePlugins) {
|
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
|
// Propagate --teammate-mode so tmux teammates use the same mode as leader
|
||||||
const sessionMode = getTeammateModeFromSnapshot()
|
const sessionMode = getTeammateModeFromSnapshot()
|
||||||
flags.push(`--teammate-mode ${sessionMode}`)
|
flags.push('--teammate-mode', sessionMode)
|
||||||
|
|
||||||
// Propagate --chrome / --no-chrome if explicitly set on the CLI
|
// Propagate --chrome / --no-chrome if explicitly set on the CLI
|
||||||
const chromeFlagOverride = getChromeFlagOverride()
|
const chromeFlagOverride = getChromeFlagOverride()
|
||||||
@@ -85,7 +95,7 @@ export function buildInheritedCliFlags(options?: {
|
|||||||
flags.push('--no-chrome')
|
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.
|
* plus any provider/config env vars that are set in the current process.
|
||||||
*/
|
*/
|
||||||
export function buildInheritedEnvVars(): string {
|
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) {
|
for (const key of TEAMMATE_ENV_VARS) {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
if (value !== undefined && value !== '') {
|
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