Skip to content

Commit 59f8675

Browse files
unraidclaude
andcommitted
feat: 添加 Windows Terminal swarm 后端及 swarm 增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c4775ff commit 59f8675

17 files changed

Lines changed: 1298 additions & 86 deletions

src/components/PromptInput/useSwarmBanner.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,17 @@ export function useSwarmBanner(): SwarmBannerInfo {
8181
const viewedTeammate = getViewedTeammateTask(state)
8282
const viewedColor = toThemeColor(viewedTeammate?.identity.color)
8383
const inProcessMode = isInProcessEnabled()
84-
const nativePanes = getCachedDetectionResult()?.isNative ?? false
84+
const detection = getCachedDetectionResult()
85+
const nativePanes = detection?.isNative ?? false
86+
const backendType = detection?.backend.type
8587

8688
if (insideTmux === false && !inProcessMode && !nativePanes) {
89+
const hint =
90+
backendType === 'windows-terminal'
91+
? 'View teammates in the Windows Terminal tabs spawned for each teammate'
92+
: `View teammates: \`tmux -L ${getSwarmSocketName()} a\``
8793
return {
88-
text: `View teammates: \`tmux -L ${getSwarmSocketName()} a\``,
94+
text: hint,
8995
bgColor: viewedColor,
9096
}
9197
}

src/utils/agentSwarmsEnabled.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,15 @@
1-
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
21
import { isEnvTruthy } from './envUtils.js'
32

4-
/**
5-
* Check if --agent-teams flag is provided via CLI.
6-
* Checks process.argv directly to avoid import cycles with bootstrap/state.
7-
* Note: The flag is only shown in help for ant users, but if external users
8-
* pass it anyway, it will work (subject to the killswitch).
9-
*/
10-
function isAgentTeamsFlagSet(): boolean {
11-
return process.argv.includes('--agent-teams')
12-
}
13-
143
/**
154
* Centralized runtime check for agent teams/teammate features.
165
* This is the single gate that should be checked everywhere teammates
176
* are referenced (prompts, code, tools isEnabled, UI, etc.).
187
*
19-
* Ant builds: always enabled.
20-
* External builds require both:
21-
* 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag
22-
* 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch)
8+
* Fork build: enabled by default. Can be disabled via
9+
* CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 if needed.
2310
*/
2411
export function isAgentSwarmsEnabled(): boolean {
25-
// Ant: always on
26-
if (process.env.USER_TYPE === 'ant') {
27-
return true
28-
}
29-
30-
// External: require opt-in via env var or --agent-teams flag
31-
if (
32-
!isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) &&
33-
!isAgentTeamsFlagSet()
34-
) {
35-
return false
36-
}
37-
38-
// Killswitch — always respected for external users
39-
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true)) {
12+
if (isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED)) {
4013
return false
4114
}
4215

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
6+
let terminateCalls: string[] = []
7+
8+
mock.module('src/utils/swarm/backends/registry.js', () => {
9+
const executor = {
10+
type: 'in-process' as const,
11+
setContext() {},
12+
async isAvailable() {
13+
return true
14+
},
15+
async spawn(config: { name: string; teamName: string; color?: string }) {
16+
return {
17+
success: true,
18+
agentId: `${config.name}@${config.teamName}`,
19+
taskId: `task-${config.name}`,
20+
backendType: 'in-process',
21+
color: config.color,
22+
isSplitPane: false,
23+
}
24+
},
25+
async sendMessage() {},
26+
async terminate(agentId: string) {
27+
terminateCalls.push(agentId)
28+
return true
29+
},
30+
async kill() {
31+
return true
32+
},
33+
async isActive() {
34+
return true
35+
},
36+
}
37+
38+
return {
39+
getTeammateExecutor: async () => executor,
40+
getInProcessBackend: () => executor,
41+
detectAndGetBackend: async () => ({
42+
backend: { type: 'in-process' },
43+
isNative: false,
44+
needsIt2Setup: false,
45+
}),
46+
isInProcessEnabled: () => true,
47+
markInProcessFallback: () => {},
48+
resetBackendDetection: () => {},
49+
getCachedBackend: () => null,
50+
getCachedDetectionResult: () => null,
51+
getResolvedTeammateMode: () => 'in-process',
52+
ensureBackendsRegistered: async () => {},
53+
getBackendByType: () => ({
54+
type: 'tmux',
55+
killPane: async () => true,
56+
}),
57+
}
58+
})
59+
60+
let tempHome: string
61+
let previousConfigDir: string | undefined
62+
let previousAnthropicApiKey: string | undefined
63+
let state: any
64+
65+
function setState(updater: (prev: any) => any): void {
66+
state = updater(state)
67+
}
68+
69+
function readTeamConfig(teamName: string): any {
70+
return JSON.parse(
71+
readFileSync(join(tempHome, 'teams', teamName, 'config.json'), 'utf-8'),
72+
)
73+
}
74+
75+
function writeTeamConfig(teamName: string, config: unknown): void {
76+
const teamDir = join(tempHome, 'teams', teamName)
77+
mkdirSync(teamDir, { recursive: true })
78+
writeFileSync(join(teamDir, 'config.json'), JSON.stringify(config, null, 2))
79+
}
80+
81+
beforeEach(() => {
82+
terminateCalls = []
83+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
84+
previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY
85+
tempHome = join(
86+
tmpdir(),
87+
`agent-teams-lifecycle-${Date.now()}-${Math.random().toString(16).slice(2)}`,
88+
)
89+
process.env.CLAUDE_CONFIG_DIR = tempHome
90+
process.env.ANTHROPIC_API_KEY = 'test-key'
91+
state = {
92+
teamContext: undefined,
93+
tasks: {},
94+
inbox: { messages: [] },
95+
toolPermissionContext: {
96+
mode: 'default',
97+
alwaysAllowRules: {},
98+
alwaysDenyRules: {},
99+
additionalWorkingDirectories: new Map(),
100+
},
101+
mainLoopModel: null,
102+
mainLoopModelForSession: null,
103+
agentNameRegistry: new Map(),
104+
mcp: { tools: [] },
105+
}
106+
})
107+
108+
afterEach(() => {
109+
if (previousConfigDir === undefined) {
110+
delete process.env.CLAUDE_CONFIG_DIR
111+
} else {
112+
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
113+
}
114+
if (previousAnthropicApiKey === undefined) {
115+
delete process.env.ANTHROPIC_API_KEY
116+
} else {
117+
process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey
118+
}
119+
rmSync(tempHome, { recursive: true, force: true })
120+
})
121+
122+
describe('Agent Teams lifecycle', () => {
123+
test('runs TeamCreate -> spawn -> TaskUpdate -> SendMessage -> TeamDelete', async () => {
124+
const { TeamCreateTool } = await import(
125+
'@claude-code-best/builtin-tools/tools/TeamCreateTool/TeamCreateTool.js'
126+
)
127+
const { spawnTeammate } = await import(
128+
'@claude-code-best/builtin-tools/tools/shared/spawnMultiAgent.js'
129+
)
130+
const { TaskCreateTool } = await import(
131+
'@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
132+
)
133+
const { TaskUpdateTool } = await import(
134+
'@claude-code-best/builtin-tools/tools/TaskUpdateTool/TaskUpdateTool.js'
135+
)
136+
const { SendMessageTool } = await import(
137+
'@claude-code-best/builtin-tools/tools/SendMessageTool/SendMessageTool.js'
138+
)
139+
const { TeamDeleteTool } = await import(
140+
'@claude-code-best/builtin-tools/tools/TeamDeleteTool/TeamDeleteTool.js'
141+
)
142+
143+
const context = {
144+
getAppState: () => state,
145+
setAppState: setState,
146+
options: {
147+
agentDefinitions: { activeAgents: [] },
148+
},
149+
abortController: new AbortController(),
150+
} as any
151+
152+
const created = await TeamCreateTool.call(
153+
{ team_name: 'alpha', description: 'test team' },
154+
context,
155+
undefined as any,
156+
undefined as any,
157+
)
158+
expect(created.data.team_name).toBe('alpha')
159+
160+
const spawned = await spawnTeammate(
161+
{
162+
name: 'worker',
163+
prompt: 'handle assigned tasks',
164+
team_name: 'alpha',
165+
},
166+
context,
167+
)
168+
expect(spawned.data.agent_id).toBe('worker@alpha')
169+
170+
const task = await TaskCreateTool.call(
171+
{ subject: 'Check lifecycle', description: 'Verify team task flow' },
172+
context,
173+
)
174+
await TaskUpdateTool.call(
175+
{ taskId: task.data.task.id, owner: 'worker' },
176+
context,
177+
)
178+
179+
const message = await SendMessageTool.call(
180+
{
181+
to: 'worker',
182+
summary: 'Status request',
183+
message: 'Please report status.',
184+
},
185+
context,
186+
async () => ({ behavior: 'allow' as const }),
187+
undefined as any,
188+
)
189+
expect(message.data.success).toBe(true)
190+
191+
const blockedDelete = await TeamDeleteTool.call(
192+
{},
193+
context,
194+
undefined as any,
195+
undefined as any,
196+
)
197+
expect(blockedDelete.data.success).toBe(false)
198+
expect(terminateCalls).toEqual(['worker@alpha'])
199+
200+
const config = readTeamConfig('alpha')
201+
config.members = config.members.map((member: any) =>
202+
member.name === 'worker' ? { ...member, isActive: false } : member,
203+
)
204+
writeTeamConfig('alpha', config)
205+
206+
const deleted = await TeamDeleteTool.call(
207+
{},
208+
context,
209+
undefined as any,
210+
undefined as any,
211+
)
212+
expect(deleted.data.success).toBe(true)
213+
})
214+
215+
test('TeamDelete waits for active teammates to become inactive before cleanup', async () => {
216+
const { TeamDeleteTool } = await import(
217+
'@claude-code-best/builtin-tools/tools/TeamDeleteTool/TeamDeleteTool.js'
218+
)
219+
const now = Date.now()
220+
writeTeamConfig('alpha', {
221+
name: 'alpha',
222+
createdAt: now,
223+
leadAgentId: 'team-lead@alpha',
224+
members: [
225+
{
226+
agentId: 'team-lead@alpha',
227+
name: 'team-lead',
228+
joinedAt: now,
229+
tmuxPaneId: '',
230+
cwd: tempHome,
231+
subscriptions: [],
232+
},
233+
{
234+
agentId: 'worker@alpha',
235+
name: 'worker',
236+
joinedAt: now,
237+
tmuxPaneId: 'in-process',
238+
cwd: tempHome,
239+
subscriptions: [],
240+
backendType: 'in-process',
241+
},
242+
],
243+
})
244+
state.teamContext = {
245+
teamName: 'alpha',
246+
teamFilePath: join(tempHome, 'teams', 'alpha', 'config.json'),
247+
leadAgentId: 'team-lead@alpha',
248+
teammates: {
249+
'worker@alpha': {
250+
name: 'worker',
251+
tmuxSessionName: 'in-process',
252+
tmuxPaneId: 'in-process',
253+
cwd: tempHome,
254+
spawnedAt: now,
255+
},
256+
},
257+
}
258+
259+
setTimeout(() => {
260+
const config = readTeamConfig('alpha')
261+
config.members = config.members.map((member: any) =>
262+
member.name === 'worker' ? { ...member, isActive: false } : member,
263+
)
264+
writeTeamConfig('alpha', config)
265+
}, 25)
266+
267+
const result = await TeamDeleteTool.call(
268+
{ wait_ms: 1000 },
269+
{
270+
getAppState: () => state,
271+
setAppState: setState,
272+
} as any,
273+
undefined as any,
274+
undefined as any,
275+
)
276+
277+
expect(result.data.success).toBe(true)
278+
})
279+
})

0 commit comments

Comments
 (0)