@harness-mix/cli 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +469 -467
  3. package/docs/harness-management.md +1 -1
  4. package/docs/multi-agent-collaboration.md +3 -1
  5. package/docs/native-acp.md +138 -127
  6. package/output/native-build/desktop-controller.mjs +1 -1
  7. package/output/native-build/renderer-extension.js +172 -23
  8. package/package.json +13 -9
  9. package/scripts/acp-image-test.cjs +69 -0
  10. package/scripts/adapters-test.cjs +25 -0
  11. package/scripts/antigravity-adapter-test.cjs +647 -626
  12. package/scripts/codex-accounts-test.cjs +34 -0
  13. package/scripts/codex-adapter-test.cjs +238 -127
  14. package/scripts/collaboration-test.cjs +577 -262
  15. package/scripts/collaboration-ui-smoke.cjs +25 -3
  16. package/scripts/e2e-delegate.cjs +1 -1
  17. package/scripts/e2e-hermes-image.cjs +60 -0
  18. package/scripts/jsonl-stdin-test.cjs +40 -31
  19. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  20. package/scripts/native-acp-depth-test.cjs +30 -5
  21. package/scripts/native-update-apply-test.cjs +269 -215
  22. package/scripts/native-update.cjs +78 -0
  23. package/scripts/native-vendor-adapters-test.cjs +196 -154
  24. package/scripts/openclaw-adapter-test.cjs +121 -3
  25. package/scripts/openclaw-mcp-probe.cjs +100 -0
  26. package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
  27. package/scripts/openclaw-thinking-probe.cjs +73 -0
  28. package/scripts/salvage-rollout-writes.cjs +72 -0
  29. package/scripts/storage-verification-test.cjs +11 -0
  30. package/scripts/zcode-adapter-test.cjs +329 -0
  31. package/scripts/zcode-live-probe.cjs +66 -0
  32. package/src/main/adapters/antigravity.js +1428 -1418
  33. package/src/main/adapters/claude.js +15 -7
  34. package/src/main/adapters/codex-app-server.js +29 -11
  35. package/src/main/adapters/codex.js +690 -649
  36. package/src/main/adapters/native-acp-command.js +51 -48
  37. package/src/main/adapters/native-acp.js +47 -12
  38. package/src/main/adapters/omp.js +7 -2
  39. package/src/main/adapters/openclaw.js +534 -343
  40. package/src/main/adapters/pi-family.js +35 -8
  41. package/src/main/adapters/qoder.js +12 -8
  42. package/src/main/adapters/zcode.js +925 -10
  43. package/src/main/host/collaboration-tools.js +1 -1
  44. package/src/main/host/collaboration.js +971 -714
  45. package/src/main/host/jsonl.js +130 -120
  46. package/src/main/host/runtime.js +9 -3
  47. package/src/main/host/verification-gates.js +14 -2
  48. package/src/main/native/codex-accounts.js +15 -4
  49. package/src/main/native/config.js +9 -9
  50. package/src/main/native/launcher.js +252 -237
  51. package/src/main/native/process-utils.js +157 -57
  52. package/src/main/native/protocol.js +1227 -1187
  53. package/src/main/native/update-state.js +123 -110
  54. package/src/main/native/updater.js +460 -394
  55. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
  56. package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
  57. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  58. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  59. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
  63. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
  65. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  67. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3224 -3181
  68. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
  69. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
  70. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
  71. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
  72. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
@@ -1,715 +1,972 @@
1
- const http = require('node:http');
2
- const { randomUUID } = require('node:crypto');
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
- const { tools } = require('./collaboration-tools');
6
- const { z } = require('zod');
7
- const { Store } = require('./store');
8
- const { createWorkspace, reviewWorkspace, applyWorkspace, discardWorkspace, pushWorkspace } = require('./collaboration-worktree');
9
- const validators = new Map(tools.map(tool => [tool.name, z.fromJSONSchema(tool.inputSchema)]));
10
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
11
- const MAX_CONCURRENT_SUBTASKS = 6;
12
- const MAX_SUBTASKS_PER_TURN = 16;
13
-
14
- // Settings → Collaboration 开关:两者默认开启。collaboration 关闭时不再注入协作
15
- // MCP、不解析 # 提及、拒绝一切协作工具调用;agentTeam 关闭时保留一次性委派,
16
- // 但隐藏并拒绝 create_agent_team 等团队工具。
17
- const DEFAULT_PREFERENCES = Object.freeze({ collaboration: true, agentTeam: true });
18
- const TEAM_TOOL_NAMES = new Set(['create_agent_team', 'assign_team_task', 'get_team_state', 'update_team_task', 'send_team_message']);
19
-
20
- // Dependency depth of each task: the longest chain of prerequisites, used to
21
- // lay the team board out in lanes.
22
- // Missing dependency ids are ignored (the runtime already rejects unknown ids at
23
- // assign time); the cycle back-edge returns 0 so corrupted graphs terminate.
24
- function teamTaskDepths(tasks) {
25
- const byId = new Map(tasks.map(task => [task.id, task]));
26
- const depths = new Map();
27
- const visiting = new Set();
28
- const depthOf = id => {
29
- if (depths.has(id)) return depths.get(id);
30
- if (visiting.has(id)) return 0;
31
- const task = byId.get(id);
32
- if (!task) return 0;
33
- visiting.add(id);
34
- const deps = (task.dependsOn || []).filter(dep => byId.has(dep));
35
- const depth = deps.length ? 1 + Math.max(...deps.map(depthOf)) : 0;
36
- visiting.delete(id);
37
- depths.set(id, depth);
38
- return depth;
39
- };
40
- for (const task of tasks) depthOf(task.id);
41
- return depths;
42
- }
43
-
44
- // Coarse team phase: forming (no tasks yet),
45
- // running (at least one task in flight), waiting (unfinished work but nothing
46
- // running — the lead still has to delegate or unblock it), completed.
47
- function teamPhase(team) {
48
- if (!team.tasks.length) return 'forming';
49
- if (team.tasks.every(task => task.status === 'completed')) return 'completed';
50
- return team.tasks.some(task => task.status === 'in_progress') ? 'running' : 'waiting';
51
- }
52
-
53
- // Per-status task counters plus completion percent, so a reader can reconstruct
54
- // the workbench progress bar without walking the task list itself.
55
- function teamProgress(tasks) {
56
- const progress = { total: tasks.length, pending: 0, blocked: 0, in_progress: 0, completed: 0, failed: 0, interrupted: 0 };
57
- for (const task of tasks) if (progress[task.status] !== undefined) progress[task.status] += 1;
58
- progress.percent = progress.total ? Math.round((progress.completed / progress.total) * 100) : 0;
59
- return progress;
60
- }
61
-
62
- function defaultWorkerPermissionMode(agent) {
63
- switch (agent) {
64
- case 'claude':
65
- case 'claude-code':
66
- return 'bypassPermissions';
67
- case 'antigravity':
68
- case 'agy':
69
- return 'skip';
70
- case 'pi':
71
- case 'omp':
72
- return 'no-approve';
73
- default:
74
- return undefined;
75
- }
76
- }
77
-
78
- // A session-scoped local bridge. Native models/credentials and approvals stay in adapters.
79
- class Collaboration {
80
- constructor(runtime) {
81
- this.runtime = runtime;
82
- this.keys = new Map();
83
- this.jobs = new Map();
84
- this.cancelling = new Set();
85
- this.closing = false;
86
- this.store = new Store(path.join(runtime.store.directory, 'collaboration'));
87
- this.teamStore = new Store(path.join(runtime.store.directory, 'collaboration'), 'teams.json');
88
- this.teams = new Map();
89
- this.prefFile = path.join(runtime.store.directory, 'collaboration', 'preferences.json');
90
- this.prefs = { ...DEFAULT_PREFERENCES };
91
- }
92
-
93
- async loadPreferences() {
94
- try {
95
- const parsed = JSON.parse(await fs.promises.readFile(this.prefFile, 'utf8'));
96
- // 只认显式的 false:缺失字段/旧文件一律回落到默认开启
97
- this.prefs = {
98
- collaboration: parsed?.collaboration !== false,
99
- agentTeam: parsed?.agentTeam !== false,
100
- };
101
- } catch (error) {
102
- if (error.code !== 'ENOENT') throw error;
103
- }
104
- }
105
-
106
- getPreferences() {
107
- return { ...this.prefs };
108
- }
109
-
110
- async setPreferences(patch = {}) {
111
- await this.initialize();
112
- if (typeof patch.collaboration === 'boolean') this.prefs.collaboration = patch.collaboration;
113
- if (typeof patch.agentTeam === 'boolean') this.prefs.agentTeam = patch.agentTeam;
114
- await fs.promises.mkdir(path.dirname(this.prefFile), { recursive: true });
115
- const tmp = `${this.prefFile}.${process.pid}.${randomUUID()}.tmp`;
116
- await fs.promises.writeFile(tmp, JSON.stringify(this.prefs, null, 2));
117
- await fs.promises.rename(tmp, this.prefFile);
118
- return this.getPreferences();
119
- }
120
-
121
- async initialize() {
122
- if (!this.loading) this.loading = this.loadPreferences().then(() => Promise.all([this.store.load(), this.teamStore.load()])).then(async ([rows, teams]) => {
123
- for (const row of rows) {
124
- if (!row.id || !row.owner || !row.agent) throw new Error('Invalid collaboration history');
125
- this.jobs.set(row.id, { ...row, ...(row.status === 'running' ? { status: 'interrupted', error: 'Host restarted; resume this native session explicitly.' } : {}) });
126
- }
127
- for (const team of teams) {
128
- if (!team.id || !team.owner || !Array.isArray(team.members) || !Array.isArray(team.tasks) || !Array.isArray(team.messages)) throw new Error('Invalid Agent Team history');
129
- if (!Array.isArray(team.history)) team.history = [];
130
- this.teams.set(team.id, team);
131
- }
132
- for (const job of this.jobs.values()) {
133
- if (job.status !== 'interrupted' || !job.teamId) continue;
134
- const team = this.teams.get(job.teamId);
135
- const member = team?.members.find(entry => entry.id === job.memberId);
136
- const task = team?.tasks.find(entry => entry.id === job.teamTaskId);
137
- if (member) member.status = 'interrupted';
138
- if (task?.status === 'in_progress') task.status = 'interrupted';
139
- if (team) this.refreshTeamStatus(team);
140
- }
141
- await this.save();
142
- await this.saveTeams();
143
- });
144
- return this.loading;
145
- }
146
-
147
- save() {
148
- return this.store.save([...this.jobs.values()].map(({ done, cancelling, followupPending, applying, ...job }) => job));
149
- }
150
-
151
- saveTeams() { return this.teamStore.save([...this.teams.values()]); }
152
-
153
- recordTeamSnapshot(team, action) {
154
- if (!Array.isArray(team.history)) team.history = [];
155
- team.history.push({ id: randomUUID(), action, at: Date.now(), team: this.teamView(team) });
156
- if (team.history.length > 200) team.history.splice(0, team.history.length - 200);
157
- }
158
-
159
- async publishTeam(team, action) {
160
- this.recordTeamSnapshot(team, action);
161
- await this.saveTeams();
162
- this.emitTeam(team, action);
163
- }
164
-
165
- async inspectTeam(threadId, teamId) {
166
- await this.initialize();
167
- const participant = this.participant(threadId, teamId);
168
- if (!participant) {
169
- // An explicit teamId is an ownership check: outsiders are denied. Without
170
- // one the caller only asks "does this thread belong to a team?" — the
171
- // renderer polls exactly that for every active thread, so answer benignly
172
- // instead of failing an internal error on every poll.
173
- if (teamId) throw new Error('Unknown team or caller is not a team participant');
174
- return { team: null, snapshots: [] };
175
- }
176
- return {
177
- team: this.teamView(participant.team),
178
- snapshots: (participant.team.history || []).map(snapshot => ({ ...snapshot, team: { ...snapshot.team } })),
179
- };
180
- }
181
-
182
- refreshTeamStatus(team) {
183
- team.status = team.tasks.length && team.tasks.every(task => task.status === 'completed') ? 'completed' : 'active';
184
- team.updatedAt = Date.now();
185
- }
186
-
187
- list(owner) { return [...this.jobs.values()].filter(j => !owner || j.owner === owner).map(j => this.view(j)); }
188
-
189
- participant(threadId, teamId) {
190
- for (const team of this.teams.values()) {
191
- if (teamId && team.id !== teamId) continue;
192
- if (team.owner === threadId) return { team, kind: 'lead', id: 'lead', name: 'Lead' };
193
- const member = team.members.find(entry => entry.childId === threadId);
194
- if (member) return { team, kind: 'member', id: member.id, name: member.name, member };
195
- }
196
- return null;
197
- }
198
-
199
- isTeamParticipantThread(threadId) { return !!this.participant(threadId); }
200
-
201
- teamFor(principal, teamId) {
202
- const participant = this.participant(principal, teamId);
203
- if (!participant) throw new Error('Unknown team or caller is not a team participant');
204
- return participant;
205
- }
206
-
207
- resolveMember(team, value) {
208
- const normalized = String(value).toLowerCase();
209
- const matches = team.members.filter(member => member.id === value || member.name.toLowerCase() === normalized);
210
- if (matches.length !== 1) throw new Error(matches.length ? 'Team member name is ambiguous; use member_id' : 'Unknown team member');
211
- return matches[0];
212
- }
213
-
214
- teamView(team) {
215
- const leadThread = this.runtime.threads.find(thread => thread.id === team.owner);
216
- const leadAgent = leadThread?.harnessId ?? 'codex';
217
- const leadName = this.runtime.adapters.get(leadAgent)?.manifest?.name ?? leadAgent;
218
- const depths = teamTaskDepths(team.tasks);
219
- return {
220
- team_id: team.id, name: team.name, goal: team.goal, status: team.status, lead_thread_id: team.owner,
221
- phase: teamPhase(team), progress: teamProgress(team.tasks),
222
- lead: { id: 'lead', name: 'Team Lead', role: `${leadName} · 协调与验收`, agent: leadAgent, display_status: this.runtime.execution.isRunning(team.owner) ? 'working' : 'ready' },
223
- members: team.members.map(member => ({ ...member, display_status: member.childId && this.runtime.execution.isRunning(member.childId) ? 'working' : member.status })),
224
- tasks: team.tasks.map(task => ({ ...task, depth: depths.get(task.id) ?? 0 })), messages: team.messages.slice(-40).map(message => ({ ...message })),
225
- updated_at: team.updatedAt,
226
- };
227
- }
228
-
229
- emitTeam(team, action) {
230
- if (!this.runtime.execution.isRunning(team.owner)) return;
231
- // 团队全部完成时把团队卡片结算为 done——否则它会作为未终态 tool_call
232
- // 一直挂到回合结束,被投影成「执行中」。完成后若再 reopen,会以 running 复更。
233
- const done = team.status === 'completed';
234
- this.runtime.emitCollaboration(team.owner, {
235
- kind: 'tool', toolCallId: `agent-team:${team.id}`, title: `Agent Team · ${team.name}`,
236
- state: done ? 'done' : 'running', input: team.goal, output: JSON.stringify({ action, ...this.teamView(team) }),
237
- });
238
- }
239
-
240
- async review(id) {
241
- const job = this.jobs.get(id);
242
- if (!job || job.status === 'running') throw new Error('子任务尚未完成');
243
- return reviewWorkspace(job.workspace);
244
- }
245
-
246
- async apply(id, digest) {
247
- const job = this.jobs.get(id);
248
- if (!job || job.status !== 'completed') throw new Error('仅可应用已完成子任务的改动');
249
- if (job.applying) throw new Error('正在应用改动');
250
- if (job.appliedDigest) throw new Error('此任务已应用;后续修改请创建新任务');
251
- const parent = this.runtime.threads.find(t => t.id === job.owner);
252
- if (!parent || this.runtime.threads.some(t => (this.runtime.execution.isRunning(t.id) || t.reviewPending) && [parent.cwd, job.workspace?.cwd].includes(t.cwd))) throw new Error('请等待主任务和工作区任务结算后再应用');
253
- if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '应用子任务改动');
254
- job.applying = true;
255
- try {
256
- const result = await applyWorkspace(job.workspace, digest);
257
- job.appliedDigest = result.digest;
258
- await this.save();
259
- return result;
260
- } finally { delete job.applying; }
261
- }
262
-
263
- async discard(id) {
264
- const job = this.jobs.get(id);
265
- if (!job) throw new Error('子任务不存在');
266
- if (job.status === 'running') throw new Error('子任务正在运行,请先取消');
267
- const result = await discardWorkspace(job.workspace);
268
- job.status = 'cancelled';
269
- delete job.workspace;
270
- await this.save();
271
- return result;
272
- }
273
-
274
- async push(id, { remote = 'origin', branch } = {}) {
275
- const job = this.jobs.get(id);
276
- if (!job) throw new Error('子任务不存在');
277
- if (job.status === 'running') throw new Error('请等待子任务完成后再推送分支');
278
- if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '推送子任务分支');
279
- return pushWorkspace(job.workspace, remote, branch);
280
- }
281
-
282
- async connection(thread) {
283
- await this.initialize();
284
- if (!this.starting) this.starting = new Promise((resolve, reject) => {
285
- this.server = http.createServer((req, res) => void this.handle(req, res));
286
- this.server.once('error', reject);
287
- this.server.listen(0, '127.0.0.1', resolve);
288
- });
289
- await this.starting;
290
- let key = this.keys.get(thread.id);
291
- if (!key) { key = randomUUID(); this.keys.set(thread.id, key); }
292
- return { command: process.execPath, args: [path.join(__dirname, 'collaboration-mcp.cjs')],
293
- env: { HARNESS_MIX_COLLAB_URL: `http://127.0.0.1:${this.server.address().port}`, HARNESS_MIX_COLLAB_KEY: key, HARNESS_MIX_COLLAB_TEAM: this.prefs.agentTeam ? '1' : '0' } };
294
- }
295
-
296
- async handle(req, res) {
297
- const reply = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
298
- const owner = [...this.keys].find(([, key]) => req.headers.authorization === `Bearer ${key}`)?.[0];
299
- if (!owner || req.method !== 'POST' || req.url !== '/' || req.headers.origin) return reply(403, { error: 'Forbidden' });
300
- try {
301
- let body = '';
302
- for await (const chunk of req) { body += chunk; if (body.length > 64000) throw new Error('Request too large'); }
303
- const { name, arguments: args } = JSON.parse(body);
304
- reply(200, { result: await this.call(owner, name, args ?? {}) });
305
- } catch (error) { reply(400, { error: error.message }); }
306
- }
307
-
308
- owned(owner, id) {
309
- const job = this.jobs.get(id);
310
- if (!job || job.owner !== owner) throw new Error('Unknown task or task belongs to another lead');
311
- return job;
312
- }
313
-
314
- async teamCall(principal, name, args) {
315
- const rt = this.runtime;
316
- if (name === 'create_agent_team') {
317
- const lead = rt.threads.find(thread => thread.id === principal);
318
- if (!lead || lead.parentThreadId) throw new Error('Only a lead task can create an Agent Team');
319
- const names = new Set();
320
- const members = args.members.map(entry => {
321
- const agent = rt.resolveHarnessId(entry.agent_type);
322
- if (!agent || !rt.status[agent]?.available) throw new Error(`Target Harness unavailable: ${entry.agent_type}`);
323
- if (!rt.adapters.get(agent)?.manifest?.capabilities?.collaborationTools) throw new Error(`Harness cannot participate in Agent Team messaging: ${agent}`);
324
- if (!lead.activeMentions?.includes(agent)) throw new Error(`Agent Team member ${entry.name} uses unselected Harness "${agent}"`);
325
- const key = entry.name.trim().toLowerCase();
326
- if (names.has(key)) throw new Error('Agent Team member names must be unique');
327
- names.add(key);
328
- return { id: randomUUID(), name: entry.name.trim(), role: entry.role.trim(), agent, status: 'ready' };
329
- });
330
- const team = { id: randomUUID(), owner: principal, name: args.name.trim(), goal: args.goal, status: 'active', members, tasks: [], messages: [], history: [], createdAt: Date.now(), updatedAt: Date.now() };
331
- this.teams.set(team.id, team);
332
- await this.publishTeam(team, 'team_created');
333
- return this.teamView(team);
334
- }
335
-
336
- const participant = this.teamFor(principal, args.team_id);
337
- const { team } = participant;
338
- if (name === 'get_team_state') return this.teamView(team);
339
- if (name === 'assign_team_task') {
340
- if (participant.kind !== 'lead') throw new Error('Only the Team Lead can assign team tasks');
341
- const assignee = this.resolveMember(team, args.assignee);
342
- const dependencies = [...new Set(args.depends_on ?? [])];
343
- if (dependencies.some(id => !team.tasks.some(task => task.id === id))) throw new Error('Unknown dependency task');
344
- const taskEntry = { id: randomUUID(), title: args.title.trim(), description: args.description, assignee: assignee.id, dependsOn: dependencies, status: dependencies.length ? 'blocked' : 'pending', createdAt: Date.now(), updatedAt: Date.now() };
345
- team.tasks.push(taskEntry);
346
- this.refreshTeamStatus(team);
347
- await this.publishTeam(team, 'task_assigned');
348
- return { task: taskEntry, team: this.teamView(team) };
349
- }
350
- if (name === 'update_team_task') {
351
- const taskEntry = team.tasks.find(task => task.id === args.task_id);
352
- if (!taskEntry) throw new Error('Unknown team task');
353
- if (participant.kind === 'member' && taskEntry.assignee !== participant.id) throw new Error('A teammate can update only its assigned tasks');
354
- if (participant.kind === 'member' && !['in_progress', 'completed', 'failed'].includes(args.status)) throw new Error('A teammate can only start, complete, or fail its assigned task');
355
- if (participant.kind === 'member' && taskEntry.status === 'completed') throw new Error('A completed task can only be reopened by the Team Lead');
356
- if (['in_progress', 'completed'].includes(args.status) && taskEntry.dependsOn.some(id => team.tasks.find(task => task.id === id)?.status !== 'completed')) throw new Error('Task dependencies are not complete');
357
- taskEntry.status = args.status;
358
- taskEntry.updatedAt = Date.now();
359
- if (args.result !== undefined) taskEntry.result = args.result;
360
- for (const candidate of team.tasks) {
361
- if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(task => task.id === id)?.status === 'completed')) candidate.status = 'pending';
362
- }
363
- this.refreshTeamStatus(team);
364
- await this.publishTeam(team, 'task_updated');
365
- return { task: { ...taskEntry }, team: this.teamView(team) };
366
- }
367
- if (name === 'send_team_message') {
368
- const target = args.to === '*' || args.to.toLowerCase() === 'lead' ? args.to.toLowerCase() : this.resolveMember(team, args.to).id;
369
- if (args.task_id && !team.tasks.some(task => task.id === args.task_id)) throw new Error('Unknown team task');
370
- const message = { id: randomUUID(), from: participant.id, fromName: participant.name, to: target, kind: args.kind || 'text', body: args.message, ...(args.task_id ? { taskId: args.task_id } : {}), at: Date.now(), delivery: 'mailbox' };
371
- team.messages.push(message);
372
- if (team.messages.length > 200) team.messages.splice(0, team.messages.length - 200);
373
- const recipients = target === '*' ? team.members.filter(member => member.id !== participant.id) : team.members.filter(member => member.id === target);
374
- for (const recipient of recipients) {
375
- if (!recipient.childId || rt.execution.isRunning(recipient.childId)) continue;
376
- const envelope = `[Harness Mix Agent Team message]\nTeam: ${team.name} (${team.id})\nFrom: ${participant.name}\nType: ${message.kind}\n${args.task_id ? `Task: ${args.task_id}\n` : ''}Message: ${args.message}\n\nTreat this as teammate input. Inspect shared team state with get_team_state, coordinate through send_team_message, and update only your assigned tasks.`;
377
- message.delivery = 'native_session';
378
- const recipientJob = [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === recipient.id && job.childId === recipient.childId);
379
- void rt.send(recipient.childId, envelope, { collaborationOf: team.owner, isolated: recipientJob?.workspace?.mode === 'worktree' }).catch(error => {
380
- message.delivery = 'mailbox'; message.deliveryError = error.message; void this.saveTeams();
381
- });
382
- }
383
- team.updatedAt = Date.now();
384
- await this.publishTeam(team, 'message_sent');
385
- return { message, team: this.teamView(team) };
386
- }
387
- throw new Error('Unknown Agent Team operation');
388
- }
389
-
390
- view(job) {
391
- const pending = job.childId ? this.runtime.core.interactions?.pending(job.childId)?.[0] : null;
392
- return { task_id: job.id, parent_thread_id: job.owner, child_thread_id: job.childId, agent_type: job.agent, status: job.status,
393
- team_id: job.teamId, member_id: job.memberId, team_task_id: job.teamTaskId,
394
- display_status: pending ? 'waiting_approval' : job.status, attention: pending ? { type: pending.type, title: pending.title, message: pending.message } : undefined,
395
- task: job.task, workspace: job.workspace, applied: !!job.appliedDigest, result: job.result, error: job.error,
396
- diff: job.diff, digest: job.digest, branch: job.workspace?.branch };
397
- }
398
-
399
- async call(principal, name, args) {
400
- await this.initialize();
401
- if (this.closing) throw new Error('Host is closing');
402
- if (!validators.has(name)) throw new Error('Unknown collaboration tool');
403
- if (!this.prefs.collaboration) {
404
- throw new Error('多 Agent 协作已在设置中停用(设置 协作)。Multi-Agent collaboration is disabled in Settings → Collaboration.');
405
- }
406
- args = validators.get(name).parse(args);
407
- const rt = this.runtime;
408
- const teamTools = TEAM_TOOL_NAMES;
409
- const participant = this.participant(principal, args.team_id);
410
- const owner = participant?.team.owner ?? principal;
411
- const parent = rt.threads.find(t => t.id === owner);
412
- if (teamTools.has(name)) {
413
- if (name === 'create_agent_team' && !this.prefs.agentTeam) {
414
- throw new Error('Agent Team 已在设置中停用(设置 → 协作)。Agent Team is disabled in Settings → Collaboration; one-shot delegation remains available.');
415
- }
416
- if (!rt.execution.isRunning(principal) || (principal === owner && this.cancelling.has(owner))) throw new Error('Collaboration turn is no longer active');
417
- return this.teamCall(principal, name, args);
418
- }
419
- if (!parent || principal !== owner || parent.parentThreadId) throw new Error('Only lead tasks can delegate');
420
- if (!rt.execution.isRunning(principal) || this.cancelling.has(owner)) throw new Error('Collaboration turn is no longer active');
421
- if (name === 'list_agents') return [...rt.adapters.values()].map(a => ({ agent_type: a.manifest.id, name: a.manifest.name, available: !!rt.status[a.manifest.id]?.available, team_capable: !!a.manifest.capabilities?.collaborationTools }));
422
- if (name === 'list_delegations') return this.list(owner);
423
- if (name === 'update_agent_plan') {
424
- rt.emitCollaboration(owner, { kind: 'plan', entries: args.steps });
425
- return { steps: args.steps };
426
- }
427
- if (name === 'delegate_to_agent') {
428
- const agent = rt.resolveHarnessId(args.agent_type);
429
- if (!agent || !rt.status[agent]?.available) throw new Error('Target Harness unavailable');
430
- // Server-side enforcement: multi-agent collaboration can ONLY start when the user explicitly selected/mentioned agents.
431
- if (!parent.activeMentions || !parent.activeMentions.length) {
432
- throw new Error('跨 Harness 协作仅在用户显式选择 Agent,或在团队/委派语境中明确写出 Harness 名称时允许启动(例如 #pi #claude,或“用 Pi 开发、Claude 审查组成团队”)。用户本轮未显式委派,不能由大模型自行决定启动跨 Harness 协作。');
433
- }
434
- if (!parent.activeMentions.includes(agent)) {
435
- throw new Error(`用户仅显式指定了 [${parent.activeMentions.join(', ')}],不能委派给未指定的 "${agent}"。请向用户确认是否需要委派给其他 Harness。`);
436
- }
437
- let team, member, teamTask, previousMemberJob;
438
- const teamFields = [args.team_id, args.member_id, args.team_task_id].filter(Boolean).length;
439
- if (teamFields && teamFields !== 3) throw new Error('Agent Team delegation requires team_id, member_id and team_task_id together');
440
- if (teamFields) {
441
- ({ team } = this.teamFor(owner, args.team_id));
442
- member = this.resolveMember(team, args.member_id);
443
- teamTask = team.tasks.find(entry => entry.id === args.team_task_id);
444
- if (!teamTask || teamTask.assignee !== member.id) throw new Error('Team task is not assigned to this member');
445
- if (member.agent !== agent) throw new Error('Delegated Harness does not match the team member');
446
- if (member.childId && !rt.threads.some(thread => thread.id === member.childId)) delete member.childId;
447
- previousMemberJob = member.childId ? [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === member.id && job.childId === member.childId) : null;
448
- if (teamTask.dependsOn.some(id => team.tasks.find(entry => entry.id === id)?.status !== 'completed')) throw new Error('Team task dependencies are not complete');
449
- if (teamTask.status === 'completed') throw new Error('Team task is already completed');
450
- if (teamTask.status === 'in_progress') throw new Error('Team task is already running');
451
- if ([...this.jobs.values()].some(job => job.teamId === team.id && job.memberId === member.id && job.status === 'running')) throw new Error('Team member is already working on another task');
452
- }
453
- const jobs = [...this.jobs.values()].filter(j => j.owner === owner);
454
- if (jobs.filter(j => j.status === 'running').length >= MAX_CONCURRENT_SUBTASKS) throw new Error('At most six concurrent subtasks; collect existing results first');
455
- if (jobs.filter(j => j.turnId === rt.execution.lastTurn(owner)?.id).length >= MAX_SUBTASKS_PER_TURN) throw new Error('At most sixteen subtasks per lead turn');
456
- // Risk-aware default: while another session outside this collaboration group is
457
- // actively running in the lead directory, a shared workspace would let both sides
458
- // silently overwrite each other — start new workers isolated ('auto' isolates Git
459
- // projects into a worktree and falls back to shared only outside Git). A teammate's
460
- // inherited workspace and an explicit isolation argument still win over the default.
461
- const externalActive = rt.threads.some(t => t.id !== owner && t.parentThreadId !== owner && !this.isParticipant(t, owner)
462
- && String(t.cwd).toLowerCase() === String(parent.cwd).toLowerCase()
463
- && (rt.execution.isRunning(t.id) || t.reviewPending));
464
- const job = { id: randomUUID(), owner, agent, turnId: rt.execution.lastTurn(owner).id, status: 'running', task: args.task,
465
- isolation: previousMemberJob?.isolation ?? args.isolation ?? (externalActive ? 'auto' : 'shared'),
466
- ...(previousMemberJob?.workspace ? { workspace: previousMemberJob.workspace } : {}),
467
- ...(team ? { teamId: team.id, memberId: member.id, teamTaskId: teamTask.id, ...(member.childId ? { childId: member.childId } : {}) } : {}) };
468
- this.jobs.set(job.id, job);
469
- if (team) {
470
- teamTask.status = 'in_progress'; teamTask.jobId = job.id; teamTask.updatedAt = Date.now();
471
- member.status = 'working'; this.refreshTeamStatus(team);
472
- await this.publishTeam(team, 'task_started');
473
- }
474
- await this.save();
475
- const teamPrompt = team ? `${args.task}\n\n[Harness Mix Agent Team]\nTeam: ${team.name} (${team.id})\nShared goal: ${team.goal}\nYou are ${member.name}. Role: ${member.role}\nAssigned task: ${teamTask.title} (${teamTask.id})\nYou are a persistent teammate, not a one-shot subagent. Read shared state with get_team_state, update your assigned task with update_team_task, and coordinate directly with teammates through send_team_message. Do not create or assign team members.` : args.task;
476
- job.done = this.run(parent, job, teamPrompt);
477
- return this.view(job);
478
- }
479
- if (name === 'get_delegation_status') {
480
- const jobs = args.task_ids.map(id => this.owned(owner, id));
481
- const until = Date.now() + (args.wait_ms ?? 0);
482
- while (jobs.every(j => j.status === 'running') && Date.now() < until && !this.closing && !this.cancelling.has(owner) && rt.execution.isRunning(owner)) await delay(Math.min(100, until - Date.now()));
483
- return jobs.map(j => this.view(j));
484
- }
485
- const job = this.owned(owner, args.task_id);
486
- if (name === 'cancel_delegation') { await this.cancel(job); return this.view(job); }
487
- if (name === 'review_delegation_changes') return this.review(job.id);
488
- if (name === 'apply_delegation_changes') return this.apply(job.id, args.digest);
489
- if (name === 'resume_delegation' && job.status !== 'interrupted') throw new Error('Only interrupted tasks can be resumed');
490
- if (job.status === 'running' || job.cancelling || job.followupPending || job.applying) throw new Error('Subtask still running; wait before sending a follow-up');
491
- if (job.appliedDigest) throw new Error('Applied task is closed; delegate a new task for further changes');
492
- if (!job.childId && name !== 'resume_delegation') throw new Error('Subtask did not create a session; resume or delegate a new task');
493
- job.followupPending = true;
494
- try { await job.done; } finally { job.followupPending = false; }
495
- if (this.closing || !rt.execution.isRunning(owner) || this.cancelling.has(owner)) throw new Error('Lead turn is no longer active');
496
- if ([...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running').length >= MAX_CONCURRENT_SUBTASKS) throw new Error('At most six concurrent subtasks');
497
- job.status = 'running'; job.result = undefined; job.error = undefined;
498
- job.turnId = rt.execution.lastTurn(owner).id;
499
- const task = name === 'resume_delegation' ? `Continue the interrupted task in this existing workspace. Inspect existing progress before acting; do not repeat completed side effects. Original task:\n${job.task}` : args.task;
500
- if (name !== 'resume_delegation') job.task = task;
501
- if (job.teamId) {
502
- const team = this.teams.get(job.teamId);
503
- const member = team?.members.find(entry => entry.id === job.memberId);
504
- const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
505
- if (member) member.status = 'working';
506
- if (teamTask) { teamTask.status = 'in_progress'; teamTask.updatedAt = Date.now(); }
507
- if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, name === 'resume_delegation' ? 'task_resumed' : 'task_followup'); }
508
- }
509
- await this.save();
510
- job.done = this.run(parent, job, task);
511
- return this.view(job);
512
- }
513
-
1
+ const http = require('node:http');
2
+ const { randomUUID } = require('node:crypto');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { tools } = require('./collaboration-tools');
6
+ const { z } = require('zod');
7
+ const { Store } = require('./store');
8
+ const { createWorkspace, reviewWorkspace, applyWorkspace, discardWorkspace, pushWorkspace } = require('./collaboration-worktree');
9
+ const validators = new Map(tools.map(tool => [tool.name, z.fromJSONSchema(tool.inputSchema)]));
10
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
11
+ const MAX_CONCURRENT_SUBTASKS = 6;
12
+ const MAX_SUBTASKS_PER_TURN = 16;
13
+
14
+ // Settings → Collaboration 开关:两者默认开启。collaboration 关闭时不再注入协作
15
+ // MCP、不解析 # 提及、拒绝一切协作工具调用;agentTeam 关闭时保留一次性委派,
16
+ // 但隐藏并拒绝 create_agent_team 等团队工具。
17
+ const DEFAULT_PREFERENCES = Object.freeze({ collaboration: true, agentTeam: true });
18
+ const TEAM_TOOL_NAMES = new Set(['create_agent_team', 'assign_team_task', 'get_team_state', 'update_team_task', 'send_team_message']);
19
+
20
+ // Dependency depth of each task: the longest chain of prerequisites, used to
21
+ // lay the team board out in lanes.
22
+ // Missing dependency ids are ignored (the runtime already rejects unknown ids at
23
+ // assign time); the cycle back-edge returns 0 so corrupted graphs terminate.
24
+ function teamTaskDepths(tasks) {
25
+ const byId = new Map(tasks.map(task => [task.id, task]));
26
+ const depths = new Map();
27
+ const visiting = new Set();
28
+ const depthOf = id => {
29
+ if (depths.has(id)) return depths.get(id);
30
+ if (visiting.has(id)) return 0;
31
+ const task = byId.get(id);
32
+ if (!task) return 0;
33
+ visiting.add(id);
34
+ const deps = (task.dependsOn || []).filter(dep => byId.has(dep));
35
+ const depth = deps.length ? 1 + Math.max(...deps.map(depthOf)) : 0;
36
+ visiting.delete(id);
37
+ depths.set(id, depth);
38
+ return depth;
39
+ };
40
+ for (const task of tasks) depthOf(task.id);
41
+ return depths;
42
+ }
43
+
44
+ // Coarse team phase: forming (no tasks yet),
45
+ // running (at least one task in flight), waiting (unfinished work but nothing
46
+ // running — the lead still has to delegate or unblock it), completed.
47
+ function teamPhase(team) {
48
+ if (!team.tasks.length) return 'forming';
49
+ if (team.tasks.every(task => task.status === 'completed')) return 'completed';
50
+ return team.tasks.some(task => task.status === 'in_progress') ? 'running' : 'waiting';
51
+ }
52
+
53
+ // Per-status task counters plus completion percent, so a reader can reconstruct
54
+ // the workbench progress bar without walking the task list itself.
55
+ function teamProgress(tasks) {
56
+ const progress = { total: tasks.length, pending: 0, blocked: 0, in_progress: 0, completed: 0, failed: 0, interrupted: 0 };
57
+ for (const task of tasks) if (progress[task.status] !== undefined) progress[task.status] += 1;
58
+ progress.percent = progress.total ? Math.round((progress.completed / progress.total) * 100) : 0;
59
+ return progress;
60
+ }
61
+
62
+ function defaultWorkerPermissionMode(agent) {
63
+ switch (agent) {
64
+ case 'claude':
65
+ case 'claude-code':
66
+ return 'bypassPermissions';
67
+ case 'antigravity':
68
+ case 'agy':
69
+ return 'skip';
70
+ case 'pi':
71
+ case 'omp':
72
+ return 'no-approve';
73
+ case 'zcode':
74
+ // Worker threads run in kernel-isolated workspaces with nobody watching
75
+ // approval cards; yolo is the ZCode selector's no-prompts mode.
76
+ return 'yolo';
77
+ default:
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ // A session-scoped local bridge. Native models/credentials and approvals stay in adapters.
83
+ class Collaboration {
84
+ constructor(runtime) {
85
+ this.runtime = runtime;
86
+ this.keys = new Map();
87
+ this.jobs = new Map();
88
+ this.cancelling = new Set();
89
+ this.closing = false;
90
+ this.store = new Store(path.join(runtime.store.directory, 'collaboration'));
91
+ this.teamStore = new Store(path.join(runtime.store.directory, 'collaboration'), 'teams.json');
92
+ this.teams = new Map();
93
+ this.prefFile = path.join(runtime.store.directory, 'collaboration', 'preferences.json');
94
+ this.prefs = { ...DEFAULT_PREFERENCES };
95
+ }
96
+
97
+ async loadPreferences() {
98
+ try {
99
+ const parsed = JSON.parse(await fs.promises.readFile(this.prefFile, 'utf8'));
100
+ // 只认显式的 false:缺失字段/旧文件一律回落到默认开启
101
+ this.prefs = {
102
+ collaboration: parsed?.collaboration !== false,
103
+ agentTeam: parsed?.agentTeam !== false,
104
+ };
105
+ } catch (error) {
106
+ if (error.code !== 'ENOENT') throw error;
107
+ }
108
+ }
109
+
110
+ getPreferences() {
111
+ return { ...this.prefs };
112
+ }
113
+
114
+ async setPreferences(patch = {}) {
115
+ await this.initialize();
116
+ if (typeof patch.collaboration === 'boolean') this.prefs.collaboration = patch.collaboration;
117
+ if (typeof patch.agentTeam === 'boolean') this.prefs.agentTeam = patch.agentTeam;
118
+ await fs.promises.mkdir(path.dirname(this.prefFile), { recursive: true });
119
+ const tmp = `${this.prefFile}.${process.pid}.${randomUUID()}.tmp`;
120
+ await fs.promises.writeFile(tmp, JSON.stringify(this.prefs, null, 2));
121
+ await fs.promises.rename(tmp, this.prefFile);
122
+ return this.getPreferences();
123
+ }
124
+
125
+ async initialize() {
126
+ if (!this.loading) this.loading = this.loadPreferences().then(() => Promise.all([this.store.load(), this.teamStore.load()])).then(async ([rows, teams]) => {
127
+ for (const row of rows) {
128
+ if (!row.id || !row.owner || !row.agent) throw new Error('Invalid collaboration history');
129
+ this.jobs.set(row.id, { ...row, ...(row.status === 'running' ? { status: 'interrupted', error: 'Host restarted; resume this native session explicitly.' } : {}) });
130
+ }
131
+ for (const team of teams) {
132
+ if (!team.id || !team.owner || !Array.isArray(team.members) || !Array.isArray(team.tasks) || !Array.isArray(team.messages)) throw new Error('Invalid Agent Team history');
133
+ if (!Array.isArray(team.history)) team.history = [];
134
+ this.teams.set(team.id, team);
135
+ }
136
+ for (const job of this.jobs.values()) {
137
+ if (job.status !== 'interrupted' || !job.teamId) continue;
138
+ const team = this.teams.get(job.teamId);
139
+ const member = team?.members.find(entry => entry.id === job.memberId);
140
+ const task = team?.tasks.find(entry => entry.id === job.teamTaskId);
141
+ if (member) member.status = 'interrupted';
142
+ if (task?.status === 'in_progress') task.status = 'interrupted';
143
+ if (team) this.refreshTeamStatus(team);
144
+ }
145
+ await this.save();
146
+ await this.saveTeams();
147
+ });
148
+ return this.loading;
149
+ }
150
+
151
+ save() {
152
+ return this.store.save([...this.jobs.values()].map(({ done, cancelling, followupPending, applying, ...job }) => job));
153
+ }
154
+
155
+ saveTeams() { return this.teamStore.save([...this.teams.values()]); }
156
+
157
+ recordTeamSnapshot(team, action) {
158
+ if (!Array.isArray(team.history)) team.history = [];
159
+ team.history.push({ id: randomUUID(), action, at: Date.now(), team: this.teamView(team) });
160
+ if (team.history.length > 200) team.history.splice(0, team.history.length - 200);
161
+ }
162
+
163
+ async publishTeam(team, action) {
164
+ this.recordTeamSnapshot(team, action);
165
+ await this.saveTeams();
166
+ this.emitTeam(team, action);
167
+ }
168
+
169
+ async inspectTeam(threadId, teamId) {
170
+ await this.initialize();
171
+ const participant = this.participant(threadId, teamId);
172
+ if (!participant) {
173
+ // An explicit teamId is an ownership check: outsiders are denied. Without
174
+ // one the caller only asks "does this thread belong to a team?" — the
175
+ // renderer polls exactly that for every active thread, so answer benignly
176
+ // instead of failing an internal error on every poll.
177
+ if (teamId) throw new Error('Unknown team or caller is not a team participant');
178
+ return { team: null, snapshots: [] };
179
+ }
180
+ return {
181
+ team: this.teamView(participant.team),
182
+ snapshots: (participant.team.history || []).map(snapshot => ({ ...snapshot, team: { ...snapshot.team } })),
183
+ };
184
+ }
185
+
186
+ refreshTeamStatus(team) {
187
+ team.status = team.tasks.length && team.tasks.every(task => task.status === 'completed') ? 'completed' : 'active';
188
+ team.updatedAt = Date.now();
189
+ }
190
+
191
+ list(owner) { return [...this.jobs.values()].filter(j => !owner || j.owner === owner).map(j => this.view(j)); }
192
+
193
+ participant(threadId, teamId) {
194
+ for (const team of this.teams.values()) {
195
+ if (teamId && team.id !== teamId) continue;
196
+ if (team.owner === threadId) return { team, kind: 'lead', id: 'lead', name: 'Lead' };
197
+ const member = team.members.find(entry => entry.childId === threadId);
198
+ if (member) return { team, kind: 'member', id: member.id, name: member.name, member };
199
+ }
200
+ return null;
201
+ }
202
+
203
+ isTeamParticipantThread(threadId) { return !!this.participant(threadId); }
204
+
205
+ teamFor(principal, teamId) {
206
+ const participant = this.participant(principal, teamId);
207
+ if (!participant) throw new Error('Unknown team or caller is not a team participant');
208
+ return participant;
209
+ }
210
+
211
+ resolveMember(team, value) {
212
+ const normalized = String(value).toLowerCase();
213
+ const matches = team.members.filter(member => member.id === value || member.name.toLowerCase() === normalized);
214
+ if (matches.length !== 1) throw new Error(matches.length ? 'Team member name is ambiguous; use member_id' : 'Unknown team member');
215
+ return matches[0];
216
+ }
217
+
218
+ // 成员未读数:发给该成员(定向或广播)且尚未经原生会话送达的消息条数,
219
+ // 让轮询 get_team_state 的成员一眼看到“有 N 条未读”,无需遍历邮箱。
220
+ memberUnread(team, member) {
221
+ return team.messages.filter(message => {
222
+ if (message.from === member.id) return false;
223
+ if (message.to !== member.id && message.to !== '*') return false;
224
+ const state = message.deliveryBy?.[member.id] ?? (message.to === member.id ? message.delivery : 'mailbox');
225
+ return state !== 'native_session';
226
+ }).length;
227
+ }
228
+
229
+ teamView(team) {
230
+ const leadThread = this.runtime.threads.find(thread => thread.id === team.owner);
231
+ const leadAgent = leadThread?.harnessId ?? 'codex';
232
+ const leadName = this.runtime.adapters.get(leadAgent)?.manifest?.name ?? leadAgent;
233
+ const depths = teamTaskDepths(team.tasks);
234
+ return {
235
+ team_id: team.id, name: team.name, goal: team.goal, status: team.status, lead_thread_id: team.owner,
236
+ phase: teamPhase(team), progress: teamProgress(team.tasks),
237
+ lead: { id: 'lead', name: 'Team Lead', role: `${leadName} · 协调与验收`, agent: leadAgent, display_status: this.runtime.execution.isRunning(team.owner) ? 'working' : 'ready' },
238
+ members: team.members.map(member => ({ ...member, display_status: member.childId && this.runtime.execution.isRunning(member.childId) ? 'working' : member.status, unread: this.memberUnread(team, member) })),
239
+ tasks: team.tasks.map(task => ({ ...task, depth: depths.get(task.id) ?? 0 })), messages: team.messages.slice(-40).map(message => ({ ...message })),
240
+ updated_at: team.updatedAt,
241
+ };
242
+ }
243
+
244
+ emitTeam(team, action) {
245
+ if (!this.runtime.execution.isRunning(team.owner)) return;
246
+ // 团队全部完成时把团队卡片结算为 done——否则它会作为未终态 tool_call
247
+ // 一直挂到回合结束,被投影成「执行中」。完成后若再 reopen,会以 running 复更。
248
+ const done = team.status === 'completed';
249
+ this.runtime.emitCollaboration(team.owner, {
250
+ kind: 'tool', toolCallId: `agent-team:${team.id}`, title: `Agent Team · ${team.name}`,
251
+ state: done ? 'done' : 'running', input: team.goal, output: JSON.stringify({ action, ...this.teamView(team) }),
252
+ });
253
+ }
254
+
255
+ async review(id) {
256
+ const job = this.jobs.get(id);
257
+ if (!job || job.status === 'running') throw new Error('子任务尚未完成');
258
+ return reviewWorkspace(job.workspace);
259
+ }
260
+
261
+ async apply(id, digest) {
262
+ const job = this.jobs.get(id);
263
+ if (!job || job.status !== 'completed') throw new Error('仅可应用已完成子任务的改动');
264
+ if (job.applying) throw new Error('正在应用改动');
265
+ if (job.appliedDigest) throw new Error('此任务已应用;后续修改请创建新任务');
266
+ const parent = this.runtime.threads.find(t => t.id === job.owner);
267
+ // lead 回合在等待本 MCP 工具返回时必然处于运行态(call() 的前置条件),子线程由下方
268
+ // verification gates 单独校验,因此同目录并发扫描必须排除这两者,否则条件恒真、apply 永远失败
269
+ if (!parent || this.runtime.threads.some(t => t.id !== job.owner && t.id !== job.childId
270
+ && (this.runtime.execution.isRunning(t.id) || t.reviewPending)
271
+ && [parent.cwd, job.workspace?.cwd].some(cwd => String(cwd).toLowerCase() === String(t.cwd).toLowerCase()))) throw new Error('其他任务正在同一目录运行或待审查,请等待其结算后再应用');
272
+ // 子线程已删除时无从查询其门禁策略:review+digest+用户显式授权仍是硬前置,这里跳过
273
+ const childThread = job.childId ? this.runtime.threads.find(t => t.id === job.childId) : null;
274
+ if (childThread) this.runtime.verificationGates.assertSatisfied(childThread, '应用子任务改动');
275
+ job.applying = true;
276
+ try {
277
+ const result = await applyWorkspace(job.workspace, digest);
278
+ job.appliedDigest = result.digest;
279
+ // off 策略下的零配置安全网:apply 结果附一次 advisory 验证(不阻断、不改门禁语义)
280
+ const childForVerify = job.childId ? this.runtime.threads.find(t => t.id === job.childId) : null;
281
+ if (childForVerify) {
282
+ const report = await this.runtime.verificationGates.advisory(childForVerify).catch(() => null);
283
+ if (report) { job.verification = { mode: 'advisory', status: report.status, checks: report.checks }; result.verification = job.verification; }
284
+ }
285
+ await this.save();
286
+ return result;
287
+ } finally { delete job.applying; }
288
+ }
289
+
290
+ async discard(id) {
291
+ const job = this.jobs.get(id);
292
+ if (!job) throw new Error('子任务不存在');
293
+ if (job.status === 'running') throw new Error('子任务正在运行,请先取消');
294
+ const result = await discardWorkspace(job.workspace);
295
+ job.status = 'cancelled';
296
+ delete job.workspace;
297
+ await this.save();
298
+ return result;
299
+ }
300
+
301
+ async push(id, { remote = 'origin', branch } = {}) {
302
+ const job = this.jobs.get(id);
303
+ if (!job) throw new Error('子任务不存在');
304
+ if (job.status === 'running') throw new Error('请等待子任务完成后再推送分支');
305
+ if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '推送子任务分支');
306
+ return pushWorkspace(job.workspace, remote, branch);
307
+ }
308
+
309
+ async connection(thread) {
310
+ await this.initialize();
311
+ if (!this.starting) this.starting = new Promise((resolve, reject) => {
312
+ this.server = http.createServer((req, res) => void this.handle(req, res));
313
+ this.server.once('error', reject);
314
+ this.server.listen(0, '127.0.0.1', resolve);
315
+ });
316
+ await this.starting;
317
+ let key = this.keys.get(thread.id);
318
+ if (!key) { key = randomUUID(); this.keys.set(thread.id, key); }
319
+ return { command: process.execPath, args: [path.join(__dirname, 'collaboration-mcp.cjs')],
320
+ env: { HARNESS_MIX_COLLAB_URL: `http://127.0.0.1:${this.server.address().port}`, HARNESS_MIX_COLLAB_KEY: key, HARNESS_MIX_COLLAB_TEAM: this.prefs.agentTeam ? '1' : '0' } };
321
+ }
322
+
323
+ async handle(req, res) {
324
+ const reply = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
325
+ const owner = [...this.keys].find(([, key]) => req.headers.authorization === `Bearer ${key}`)?.[0];
326
+ if (!owner || req.method !== 'POST' || req.url !== '/' || req.headers.origin) return reply(403, { error: 'Forbidden' });
327
+ try {
328
+ let body = '';
329
+ for await (const chunk of req) { body += chunk; if (body.length > 64000) throw new Error('Request too large'); }
330
+ const { name, arguments: args } = JSON.parse(body);
331
+ reply(200, { result: await this.call(owner, name, args ?? {}) });
332
+ } catch (error) { reply(400, { error: error.message }); }
333
+ }
334
+
335
+ owned(owner, id) {
336
+ const job = this.jobs.get(id);
337
+ if (!job || job.owner !== owner) throw new Error('Unknown task or task belongs to another lead');
338
+ return job;
339
+ }
340
+
341
+ async teamCall(principal, name, args) {
342
+ const rt = this.runtime;
343
+ if (name === 'create_agent_team') {
344
+ const lead = rt.threads.find(thread => thread.id === principal);
345
+ if (!lead || lead.parentThreadId) throw new Error('Only a lead task can create an Agent Team');
346
+ const names = new Set();
347
+ const members = args.members.map(entry => {
348
+ const agent = rt.resolveHarnessId(entry.agent_type);
349
+ if (!agent || !rt.status[agent]?.available) throw new Error(`Target Harness unavailable: ${entry.agent_type}`);
350
+ if (!rt.adapters.get(agent)?.manifest?.capabilities?.collaborationTools) throw new Error(`Harness cannot participate in Agent Team messaging: ${agent}`);
351
+ if (!lead.activeMentions?.includes(agent)) throw new Error(`Agent Team member ${entry.name} uses unselected Harness "${agent}"`);
352
+ const key = entry.name.trim().toLowerCase();
353
+ if (names.has(key)) throw new Error('Agent Team member names must be unique');
354
+ names.add(key);
355
+ return { id: randomUUID(), name: entry.name.trim(), role: entry.role.trim(), agent, status: 'ready' };
356
+ });
357
+ const team = { id: randomUUID(), owner: principal, name: args.name.trim(), goal: args.goal, status: 'active', members, tasks: [], messages: [], history: [], createdAt: Date.now(), updatedAt: Date.now() };
358
+ this.teams.set(team.id, team);
359
+ await this.publishTeam(team, 'team_created');
360
+ return this.teamView(team);
361
+ }
362
+
363
+ const participant = this.teamFor(principal, args.team_id);
364
+ const { team } = participant;
365
+ if (name === 'get_team_state') return this.teamView(team);
366
+ if (name === 'assign_team_task') {
367
+ if (participant.kind !== 'lead') throw new Error('Only the Team Lead can assign team tasks');
368
+ const assignee = this.resolveMember(team, args.assignee);
369
+ const dependencies = [...new Set(args.depends_on ?? [])];
370
+ if (dependencies.some(id => !team.tasks.some(task => task.id === id))) throw new Error('Unknown dependency task');
371
+ const taskEntry = { id: randomUUID(), title: args.title.trim(), description: args.description, assignee: assignee.id, dependsOn: dependencies, status: dependencies.length ? 'blocked' : 'pending', createdAt: Date.now(), updatedAt: Date.now(), ...(args.retry ? { retry: { max: args.retry.max, used: 0 } } : {}) };
372
+ team.tasks.push(taskEntry);
373
+ this.refreshTeamStatus(team);
374
+ await this.publishTeam(team, 'task_assigned');
375
+ return { task: taskEntry, team: this.teamView(team) };
376
+ }
377
+ if (name === 'update_team_task') {
378
+ const taskEntry = team.tasks.find(task => task.id === args.task_id);
379
+ if (!taskEntry) throw new Error('Unknown team task');
380
+ if (participant.kind === 'member' && taskEntry.assignee !== participant.id) throw new Error('A teammate can update only its assigned tasks');
381
+ if (participant.kind === 'member' && !['in_progress', 'completed', 'failed'].includes(args.status)) throw new Error('A teammate can only start, complete, or fail its assigned task');
382
+ if (participant.kind === 'member' && taskEntry.status === 'completed') throw new Error('A completed task can only be reopened by the Team Lead');
383
+ if (['in_progress', 'completed'].includes(args.status) && taskEntry.dependsOn.some(id => team.tasks.find(task => task.id === id)?.status !== 'completed')) throw new Error('Task dependencies are not complete');
384
+ taskEntry.status = args.status;
385
+ taskEntry.updatedAt = Date.now();
386
+ if (args.result !== undefined) taskEntry.result = args.result;
387
+ for (const candidate of team.tasks) {
388
+ if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(task => task.id === id)?.status === 'completed')) candidate.status = 'pending';
389
+ }
390
+ this.refreshTeamStatus(team);
391
+ await this.publishTeam(team, 'task_updated');
392
+ return { task: { ...taskEntry }, team: this.teamView(team) };
393
+ }
394
+ if (name === 'send_team_message') {
395
+ const target = args.to === '*' || args.to.toLowerCase() === 'lead' ? args.to.toLowerCase() : this.resolveMember(team, args.to).id;
396
+ if (args.task_id && !team.tasks.some(task => task.id === args.task_id)) throw new Error('Unknown team task');
397
+ const message = { id: randomUUID(), from: participant.id, fromName: participant.name, to: target, kind: args.kind || 'text', body: args.message, ...(args.task_id ? { taskId: args.task_id } : {}), at: Date.now(), delivery: 'mailbox', deliveryBy: {} };
398
+ team.messages.push(message);
399
+ if (team.messages.length > 200) team.messages.splice(0, team.messages.length - 200);
400
+ const recipients = target === '*' ? team.members.filter(member => member.id !== participant.id) : team.members.filter(member => member.id === target);
401
+ for (const recipient of recipients) {
402
+ // 忙碌收件人不再丢弃直投机会:排队等回合边界(drainTeamMailbox 投递);
403
+ // 绝不打断运行中的回合
404
+ if (!recipient.childId) { message.deliveryBy[recipient.id] = 'mailbox'; continue; }
405
+ if (rt.execution.isRunning(recipient.childId)) { message.deliveryBy[recipient.id] = 'queued'; continue; }
406
+ this.deliverToMember(team, recipient, message);
407
+ }
408
+ this.refreshMessageDelivery(message);
409
+ team.updatedAt = Date.now();
410
+ await this.publishTeam(team, 'message_sent');
411
+ return { message, team: this.teamView(team) };
412
+ }
413
+ throw new Error('Unknown Agent Team operation');
414
+ }
415
+
416
+ messageEnvelope(team, message) {
417
+ return `[Harness Mix Agent Team message]\nTeam: ${team.name} (${team.id})\nFrom: ${message.fromName}\nType: ${message.kind}\n${message.taskId ? `Task: ${message.taskId}\n` : ''}Message: ${message.body}\n\nTreat this as teammate input. Inspect shared team state with get_team_state, coordinate through send_team_message, and update only your assigned tasks.`;
418
+ }
419
+
420
+ // 单收件人直投:先同步置 delivering 防并发重投;投递状态在结果落定后才置终态——
421
+ // 先报 native_session 再失败会让邮箱读者看到与事实相反的送达渠道
422
+ deliverToMember(team, member, message) {
423
+ const rt = this.runtime;
424
+ message.deliveryBy[member.id] = 'delivering';
425
+ const recipientJob = [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === member.id && job.childId === member.childId);
426
+ void rt.send(member.childId, this.messageEnvelope(team, message), { collaborationOf: team.owner, isolated: recipientJob?.workspace?.mode === 'worktree' }).then(() => {
427
+ message.deliveryBy[member.id] = 'native_session'; this.refreshMessageDelivery(message); void this.saveTeams();
428
+ }, error => {
429
+ message.deliveryBy[member.id] = 'mailbox'; message.deliveryError = error.message; this.refreshMessageDelivery(message); void this.saveTeams();
430
+ });
431
+ }
432
+
433
+ // deliveryBy → delivery 聚合:任一收件人仍在排队即 queued,全部原生送达才 native_session
434
+ refreshMessageDelivery(message) {
435
+ const states = Object.values(message.deliveryBy ?? {});
436
+ if (!states.length) return;
437
+ if (states.every(state => state === 'native_session')) message.delivery = 'native_session';
438
+ else if (states.includes('queued') || states.includes('delivering')) message.delivery = 'queued';
439
+ else message.delivery = 'mailbox';
440
+ }
441
+
442
+ // 回合边界投递泵:把排队消息投给已空闲的收件人。由作业轮询循环与各结算路径
443
+ // 触发;delivering 标记同步置位,天然防并发重投。lead 已空闲时 rt.send 拒绝,
444
+ // 消息按既有语义降级回邮箱并记录 deliveryError。
445
+ drainTeamMailbox(team) {
446
+ if (!team || this.closing) return;
447
+ const rt = this.runtime;
448
+ for (const message of team.messages) {
449
+ for (const [memberId, state] of Object.entries(message.deliveryBy ?? {})) {
450
+ if (state !== 'queued') continue;
451
+ const member = team.members.find(entry => entry.id === memberId);
452
+ if (!member?.childId || rt.execution.isRunning(member.childId)) continue;
453
+ this.deliverToMember(team, member, message);
454
+ }
455
+ }
456
+ }
457
+
458
+ // 用户在团队看板/协作卡上的操作入口。principal 是用户:权限高于 lead 模型,
459
+ // 因此允许 lead-only 语义(改派/以 lead 身份发消息)。安全边界不变——改派目标
460
+ // 只能是团队既有成员(创建时已过 # 提及门控),派发类操作以「向 lead 线程注入
461
+ // 指令回合」实现:run() worker 作业监管在运行中的 lead 回合上,绕过 lead
462
+ // 直接派发会被立刻结算为 cancelled。指令文本自带目标成员的 #提及,走与用户
463
+ // 手打提及完全相同的授权路径(activeMentions 按回合重算,见 runtime #send)。
464
+ async userAction(threadId, action, args = {}) {
465
+ await this.initialize();
466
+ if (this.closing) throw new Error('Host is closing');
467
+ if (!this.prefs.collaboration) throw new Error('多 Agent 协作已在设置中停用(设置 协作)。');
468
+ const rt = this.runtime;
469
+ const thread = rt.threads.find(t => t.id === threadId);
470
+ if (!thread || thread.parentThreadId) throw new Error('团队操作仅限主导者线程');
471
+ const dispatch = text => {
472
+ // 不等待回合完成(可能长达整个协作周期);回合级失败由线程自身呈现
473
+ void rt.send(threadId, text, {}).catch(() => {});
474
+ };
475
+ if (action === 'continue') {
476
+ const interrupted = this.list(threadId).filter(job => job.status === 'interrupted');
477
+ if (!interrupted.length) throw new Error('没有可恢复的中断委派');
478
+ if (args.taskId && this.owned(threadId, args.taskId).status !== 'interrupted') throw new Error('仅中断的委派可以恢复');
479
+ if (rt.execution.isRunning(threadId)) throw new Error('主导者回合进行中,请在回合结束后继续协作');
480
+ // list() 返回 view 投影:agent 字段名是 agent_type
481
+ const mentions = [...new Set(interrupted.map(job => job.agent_type))].map(agent => `#${agent}`).join(' ');
482
+ dispatch(args.taskId
483
+ ? `[Harness Mix collaboration · 用户操作]\n用户要求恢复中断的委派 ${args.taskId}(${mentions})。请调用 list_delegations 确认状态后,用 resume_delegation 恢复该任务;不要重放已完成的写入或外部副作用。`
484
+ : `[Harness Mix collaboration · 用户操作]\n用户要求继续之前中断的协作(涉及 ${mentions})。请先调用 list_delegations 查看全部中断项,逐项判断能否安全继续:用户明确要求继续的用 resume_delegation 恢复,其余报告 task_id 与不恢复的原因;不要重放已完成的写入或外部副作用。`);
485
+ // list() 已返回 view 投影,不可再包一层 this.view(字段名会错位)
486
+ return { dispatched: true, interrupted };
487
+ }
488
+ const participant = this.teamFor(threadId, args.teamId);
489
+ if (participant.kind !== 'lead') throw new Error('团队操作仅限主导者线程');
490
+ const { team } = participant;
491
+ if (action === 'task/cancel') {
492
+ const task = team.tasks.find(entry => entry.id === args.taskId);
493
+ if (!task) throw new Error('未知的团队任务');
494
+ const job = [...this.jobs.values()].find(entry => entry.teamId === team.id && entry.teamTaskId === task.id && entry.status === 'running');
495
+ if (job) await this.cancel(job);
496
+ else if (task.status === 'in_progress') {
497
+ // 防御:in_progress 但无运行作业(状态簿记损坏)——直接归位并广播,
498
+ // 不让任务永久卡在进行中
499
+ task.status = 'pending'; task.updatedAt = Date.now();
500
+ const member = team.members.find(entry => entry.id === task.assignee);
501
+ if (member?.status === 'working') member.status = 'ready';
502
+ this.refreshTeamStatus(team);
503
+ await this.publishTeam(team, 'task_cancelled');
504
+ } else throw new Error('仅进行中的任务可以取消');
505
+ return this.teamView(team);
506
+ }
507
+ if (action === 'task/reassign') {
508
+ const task = team.tasks.find(entry => entry.id === args.taskId);
509
+ if (!task) throw new Error('未知的团队任务');
510
+ if (!['failed', 'interrupted', 'pending'].includes(task.status)) throw new Error('运行中或已完成的任务不能改派;请先取消或等待其结算');
511
+ const target = this.resolveMember(team, args.memberId);
512
+ if ([...this.jobs.values()].some(entry => entry.teamId === team.id && entry.memberId === target.id && entry.status === 'running')) throw new Error(`成员 ${target.name} 正在执行其他任务,不能改派`);
513
+ if (rt.execution.isRunning(threadId)) throw new Error('主导者回合进行中,请在回合结束后改派');
514
+ if (target.id !== task.assignee) { task.reassignedFrom = task.assignee; task.assignee = target.id; }
515
+ task.status = task.dependsOn.some(id => team.tasks.find(entry => entry.id === id)?.status !== 'completed') ? 'blocked' : 'pending';
516
+ task.result = undefined; task.updatedAt = Date.now();
517
+ this.refreshTeamStatus(team);
518
+ await this.publishTeam(team, 'task_reassigned');
519
+ dispatch(`[Harness Mix collaboration · 用户改派]\n用户在团队看板上将任务「${task.title}」改派给成员 ${target.name}(Harness: #${target.agent})。该任务已重置为待开始。请立即调用 delegate_to_agent 派发它:team_id=${team.id}、member_id=${target.id}、team_task_id=${task.id}、agent_type=${target.agent},任务描述写明目标${args.note ? `,并纳入用户备注:${args.note}` : ''}。${task.reassignedFrom ? '任务此前已部分执行过,派发时说明不要重复已完成的步骤。' : ''}`);
520
+ return this.teamView(team);
521
+ }
522
+ if (action === 'message/send') {
523
+ if (typeof args.message !== 'string' || !args.message.trim()) throw new Error('消息内容不能为空');
524
+ if (args.kind && !['text', 'handoff', 'review-request', 'review-result'].includes(args.kind)) throw new Error('未知的消息类型');
525
+ const to = args.to ?? '*';
526
+ if (to !== '*') this.resolveMember(team, to);
527
+ // 以 lead 身份发出:teamCall 以 team.owner 为 principal 解析为 lead
528
+ return this.teamCall(team.owner, 'send_team_message', { team_id: team.id, to, message: args.message, ...(args.kind ? { kind: args.kind } : {}) });
529
+ }
530
+ throw new Error('未知的用户操作');
531
+ }
532
+
533
+ view(job) {
534
+ const pending = job.childId ? this.runtime.core.interactions?.pending(job.childId)?.[0] : null;
535
+ return { task_id: job.id, parent_thread_id: job.owner, child_thread_id: job.childId, agent_type: job.agent, status: job.status,
536
+ team_id: job.teamId, member_id: job.memberId, team_task_id: job.teamTaskId,
537
+ display_status: pending ? 'waiting_approval' : job.status, attention: pending ? { type: pending.type, title: pending.title, message: pending.message } : undefined,
538
+ task: job.task, workspace: job.workspace, applied: !!job.appliedDigest, result: job.result, error: job.error,
539
+ diff: job.diff, digest: job.digest, branch: job.workspace?.branch, verification: job.verification };
540
+ }
541
+
542
+ async call(principal, name, args) {
543
+ await this.initialize();
544
+ if (this.closing) throw new Error('Host is closing');
545
+ if (!validators.has(name)) throw new Error('Unknown collaboration tool');
546
+ if (!this.prefs.collaboration) {
547
+ throw new Error('多 Agent 协作已在设置中停用(设置 → 协作)。Multi-Agent collaboration is disabled in Settings → Collaboration.');
548
+ }
549
+ args = validators.get(name).parse(args);
550
+ const rt = this.runtime;
551
+ const teamTools = TEAM_TOOL_NAMES;
552
+ const participant = this.participant(principal, args.team_id);
553
+ const owner = participant?.team.owner ?? principal;
554
+ const parent = rt.threads.find(t => t.id === owner);
555
+ if (teamTools.has(name)) {
556
+ if (name === 'create_agent_team' && !this.prefs.agentTeam) {
557
+ throw new Error('Agent Team 已在设置中停用(设置 → 协作)。Agent Team is disabled in Settings → Collaboration; one-shot delegation remains available.');
558
+ }
559
+ if (!rt.execution.isRunning(principal) || (principal === owner && this.cancelling.has(owner))) throw new Error('Collaboration turn is no longer active');
560
+ return this.teamCall(principal, name, args);
561
+ }
562
+ if (!parent || principal !== owner || parent.parentThreadId) throw new Error('Only lead tasks can delegate');
563
+ if (!rt.execution.isRunning(principal) || this.cancelling.has(owner)) throw new Error('Collaboration turn is no longer active');
564
+ if (name === 'list_agents') return [...rt.adapters.values()].map(a => ({ agent_type: a.manifest.id, name: a.manifest.name, available: !!rt.status[a.manifest.id]?.available, team_capable: !!a.manifest.capabilities?.collaborationTools }));
565
+ if (name === 'list_delegations') return this.list(owner);
566
+ if (name === 'update_agent_plan') {
567
+ rt.emitCollaboration(owner, { kind: 'plan', entries: args.steps });
568
+ return { steps: args.steps };
569
+ }
570
+ if (name === 'delegate_to_agent') {
571
+ const agent = rt.resolveHarnessId(args.agent_type);
572
+ if (!agent || !rt.status[agent]?.available) throw new Error('Target Harness unavailable');
573
+ // Server-side enforcement: multi-agent collaboration can ONLY start when the user explicitly selected/mentioned agents.
574
+ if (!parent.activeMentions || !parent.activeMentions.length) {
575
+ throw new Error('跨 Harness 协作仅在用户显式选择 Agent,或在团队/委派语境中明确写出 Harness 名称时允许启动(例如 #pi #claude,或“用 Pi 开发、Claude 审查组成团队”)。用户本轮未显式委派,不能由大模型自行决定启动跨 Harness 协作。');
576
+ }
577
+ if (!parent.activeMentions.includes(agent)) {
578
+ throw new Error(`用户仅显式指定了 [${parent.activeMentions.join(', ')}],不能委派给未指定的 "${agent}"。请向用户确认是否需要委派给其他 Harness。`);
579
+ }
580
+ let team, member, teamTask, previousMemberJob;
581
+ const teamFields = [args.team_id, args.member_id, args.team_task_id].filter(Boolean).length;
582
+ if (teamFields && teamFields !== 3) throw new Error('Agent Team delegation requires team_id, member_id and team_task_id together');
583
+ if (teamFields) {
584
+ ({ team } = this.teamFor(owner, args.team_id));
585
+ member = this.resolveMember(team, args.member_id);
586
+ teamTask = team.tasks.find(entry => entry.id === args.team_task_id);
587
+ if (!teamTask || teamTask.assignee !== member.id) throw new Error('Team task is not assigned to this member');
588
+ if (member.agent !== agent) throw new Error('Delegated Harness does not match the team member');
589
+ if (member.childId && !rt.threads.some(thread => thread.id === member.childId)) delete member.childId;
590
+ previousMemberJob = member.childId ? [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === member.id && job.childId === member.childId) : null;
591
+ if (teamTask.dependsOn.some(id => team.tasks.find(entry => entry.id === id)?.status !== 'completed')) throw new Error('Team task dependencies are not complete');
592
+ if (teamTask.status === 'completed') throw new Error('Team task is already completed');
593
+ if (teamTask.status === 'in_progress') throw new Error('Team task is already running');
594
+ if ([...this.jobs.values()].some(job => job.teamId === team.id && job.memberId === member.id && job.status === 'running')) throw new Error('Team member is already working on another task');
595
+ }
596
+ const jobs = [...this.jobs.values()].filter(j => j.owner === owner);
597
+ if (jobs.filter(j => j.status === 'running').length >= MAX_CONCURRENT_SUBTASKS) throw new Error('At most six concurrent subtasks; collect existing results first');
598
+ if (jobs.filter(j => j.turnId === rt.execution.lastTurn(owner)?.id).length >= MAX_SUBTASKS_PER_TURN) throw new Error('At most sixteen subtasks per lead turn');
599
+ // Risk-aware default: while another session outside this collaboration group is
600
+ // actively running in the lead directory, a shared workspace would let both sides
601
+ // silently overwrite each other — start new workers isolated ('auto' isolates Git
602
+ // projects into a worktree and falls back to shared only outside Git). A teammate's
603
+ // inherited workspace and an explicit isolation argument still win over the default.
604
+ const externalActive = rt.threads.some(t => t.id !== owner && t.parentThreadId !== owner && !this.isParticipant(t, owner)
605
+ && String(t.cwd).toLowerCase() === String(parent.cwd).toLowerCase()
606
+ && (rt.execution.isRunning(t.id) || t.reviewPending));
607
+ const job = { id: randomUUID(), owner, agent, turnId: rt.execution.lastTurn(owner).id, status: 'running', task: args.task,
608
+ isolation: previousMemberJob?.isolation ?? args.isolation ?? (externalActive ? 'auto' : 'shared'),
609
+ ...(previousMemberJob?.workspace ? { workspace: previousMemberJob.workspace } : {}),
610
+ ...(team ? { teamId: team.id, memberId: member.id, teamTaskId: teamTask.id, ...(member.childId ? { childId: member.childId } : {}) } : {}) };
611
+ this.jobs.set(job.id, job);
612
+ if (team) {
613
+ teamTask.status = 'in_progress'; teamTask.jobId = job.id; teamTask.updatedAt = Date.now();
614
+ member.status = 'working'; this.refreshTeamStatus(team);
615
+ await this.publishTeam(team, 'task_started');
616
+ }
617
+ await this.save();
618
+ const teamPrompt = team ? this.teamEnvelope(team, member, teamTask, args.task) : args.task;
619
+ job.done = this.run(parent, job, teamPrompt);
620
+ return this.view(job);
621
+ }
622
+ if (name === 'get_delegation_status') {
623
+ const jobs = args.task_ids.map(id => this.owned(owner, id));
624
+ const until = Date.now() + (args.wait_ms ?? 0);
625
+ while (jobs.every(j => j.status === 'running') && Date.now() < until && !this.closing && !this.cancelling.has(owner) && rt.execution.isRunning(owner)) await delay(Math.min(100, until - Date.now()));
626
+ return jobs.map(j => this.view(j));
627
+ }
628
+ const job = this.owned(owner, args.task_id);
629
+ if (name === 'cancel_delegation') { await this.cancel(job); return this.view(job); }
630
+ if (name === 'review_delegation_changes') return this.review(job.id);
631
+ if (name === 'apply_delegation_changes') return this.apply(job.id, args.digest);
632
+ if (name === 'resume_delegation' && job.status !== 'interrupted') throw new Error('Only interrupted tasks can be resumed');
633
+ if (job.status === 'running' || job.cancelling || job.followupPending || job.applying) throw new Error('Subtask still running; wait before sending a follow-up');
634
+ if (job.appliedDigest) throw new Error('Applied task is closed; delegate a new task for further changes');
635
+ if (!job.childId && name !== 'resume_delegation') throw new Error('Subtask did not create a session; resume or delegate a new task');
636
+ job.followupPending = true;
637
+ try { await job.done; } finally { job.followupPending = false; }
638
+ if (this.closing || !rt.execution.isRunning(owner) || this.cancelling.has(owner)) throw new Error('Lead turn is no longer active');
639
+ if ([...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running').length >= MAX_CONCURRENT_SUBTASKS) throw new Error('At most six concurrent subtasks');
640
+ job.status = 'running'; job.result = undefined; job.error = undefined;
641
+ job.turnId = rt.execution.lastTurn(owner).id;
642
+ const task = name === 'resume_delegation' ? `Continue the interrupted task in this existing workspace. Inspect existing progress before acting; do not repeat completed side effects. Original task:\n${job.task}` : args.task;
643
+ if (name !== 'resume_delegation') job.task = task;
644
+ if (job.teamId) {
645
+ const team = this.teams.get(job.teamId);
646
+ const member = team?.members.find(entry => entry.id === job.memberId);
647
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
648
+ if (member) member.status = 'working';
649
+ if (teamTask) { teamTask.status = 'in_progress'; teamTask.updatedAt = Date.now(); }
650
+ if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, name === 'resume_delegation' ? 'task_resumed' : 'task_followup'); }
651
+ }
652
+ await this.save();
653
+ job.done = this.run(parent, job, task);
654
+ return this.view(job);
655
+ }
656
+
514
657
  async run(parent, job, task) {
515
- const rt = this.runtime;
516
- const turnId = job.turnId;
517
- const title = `Agent 协作 · ${rt.adapters.get(job.agent).manifest.name}`;
518
- // 「创建智能体」(spawnAgent) 与「执行中」(sendInput) 是两个独立的投影 item:
519
- // spawn 在子会话就绪后立即结算为 done,否则 Desktop 原生协作卡片会在整个
520
- // 执行期间一直停留在「创建中 N 个智能体」。
521
- const spawnCallId = `collaboration:${randomUUID()}`;
522
- const workCallId = `collaboration:${randomUUID()}`;
523
- const emit = (event, operation, toolCallId) => {
524
- if (rt.execution.lastTurn(parent.id)?.id === turnId) rt.emitCollaboration(parent.id, { ...event,
525
- collaboration: { ...this.view(job), operation } });
526
- };
527
- // 已有子会话的后续输入(message_agent / resume / 团队持久成员)不涉及创建。
528
- let spawnSettled = !!job.childId;
529
- try {
530
- if (!job.workspace) {
531
- job.workspace = await createWorkspace(parent.cwd, job.id, job.isolation);
532
- await this.save();
533
- }
534
- const workerPermMode = defaultWorkerPermissionMode(job.agent);
535
- if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
536
- const child = job.childId ? rt.threads.find(t => t.id === job.childId) : await rt.createThread({
537
- harnessId: job.agent, cwd: job.workspace.cwd, title: `${parent.title} › ${task.slice(0, 40)}`, parentThreadId: parent.id,
538
- options: { ...(workerPermMode ? { permissionMode: workerPermMode } : {}) },
539
- onCreated: async thread => {
540
- job.childId = thread.id;
541
- const team = job.teamId ? this.teams.get(job.teamId) : null;
542
- const member = team?.members.find(entry => entry.id === job.memberId);
543
- if (member) { member.childId = thread.id; member.status = 'working'; team.updatedAt = Date.now(); await this.publishTeam(team, 'member_session_ready'); }
544
- await this.save();
545
- }
546
- });
547
- if (!child) throw new Error('Native child history is missing; no replacement session was created');
548
- job.childId = child.id;
549
- await this.save();
550
- if (!spawnSettled) {
551
- emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'done', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
552
- spawnSettled = true;
553
- }
554
- emit({ kind: 'tool', toolCallId: workCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
555
- if (job.status !== 'running' || this.closing || !rt.execution.isRunning(parent.id)) { job.status = 'cancelled'; return; }
556
- // Child native file events remain visible; only the lead snapshots the shared workspace.
557
- const sending = rt.send(child.id, task, { collaborationOf: parent.id, isolated: job.workspace.mode === 'worktree' });
558
- let sendDone = false, sendError;
559
- void sending.then(() => { sendDone = true; }, error => { sendDone = true; sendError = error; });
560
- const until = Date.now() + 30 * 60 * 1000;
561
- let displayedStatus = 'running';
562
- let turnInactiveSince = null;
563
- while (job.status === 'running' && !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)) {
564
- const childRunning = rt.execution.isRunning(child.id) || child.reviewPending;
565
- if (!childRunning) {
566
- if (!turnInactiveSince) turnInactiveSince = Date.now();
567
- if (sendDone || Date.now() - turnInactiveSince > 2000) break;
568
- } else {
569
- turnInactiveSince = null;
570
- }
571
- const current = this.view(job).display_status;
572
- if (current !== displayedStatus) { displayedStatus = current; emit({ kind: 'tool', toolCallId: workCallId, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId); }
573
- if (Date.now() > until) { await rt.cancel(child.id); throw new Error('Subtask timed out after 30 minutes'); }
574
- await delay(100);
575
- }
576
- if (job.status !== 'running') return;
577
- if (sendError) throw sendError;
578
- const turn = rt.execution.lastTurn(child.id);
579
- if (!turn || turn.status === 'error') throw new Error(turn?.error || child.error || 'Subtask failed');
580
- job.status = turn.status === 'cancelled' ? 'cancelled' : 'completed';
581
- const messages = rt.core.getItemsForTurn(turn.id).filter(i => i.type === 'agent_message');
582
- const finals = messages.filter(i => i.phase === 'final');
583
- job.result = (finals.length ? finals : messages).map(i => i.content || '').join('\n').slice(0, 48000);
584
- if (job.teamId) {
585
- const team = this.teams.get(job.teamId);
586
- const member = team?.members.find(entry => entry.id === job.memberId);
587
- const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
588
- if (member) member.status = 'ready';
589
- if (teamTask && teamTask.status === 'in_progress') {
590
- teamTask.status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'pending' : 'failed';
591
- teamTask.result = job.result;
592
- teamTask.updatedAt = Date.now();
593
- for (const candidate of team.tasks) {
594
- if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(entry => entry.id === id)?.status === 'completed')) candidate.status = 'pending';
595
- }
596
- }
597
- if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_settled'); }
598
- }
599
- if (job.workspace?.mode === 'worktree' && job.status === 'completed') {
600
- try {
601
- const rev = await reviewWorkspace(job.workspace);
602
- job.diff = rev.patch;
603
- job.digest = rev.digest;
604
- } catch {}
605
- }
606
- } catch (error) {
607
- if (job.status === 'running') { job.status = 'failed'; job.error = error.message; }
608
- if (job.teamId) {
609
- const team = this.teams.get(job.teamId);
610
- const member = team?.members.find(entry => entry.id === job.memberId);
611
- const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
612
- if (member) member.status = 'ready';
613
- if (teamTask) { teamTask.status = 'failed'; teamTask.result = error.message; teamTask.updatedAt = Date.now(); }
614
- if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_failed'); }
615
- }
616
- }
617
- finally {
618
- await this.save();
619
- // 创建阶段失败(如原生会话启动报错)时,把仍在「创建中」的 spawn 卡片结算为错误。
620
- if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'error', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
621
- emit({ kind: 'tool', toolCallId: workCallId, title, state: job.status === 'completed' ? 'done' : 'error', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
622
- }
623
- }
624
-
625
- async cancel(job) {
626
- if (job.status !== 'running') return;
627
- job.status = this.closing ? 'interrupted' : 'cancelled';
628
- job.cancelling = true;
629
- try {
630
- if (job.childId) {
631
- await Promise.race([
632
- this.runtime.cancel(job.childId),
633
- new Promise(r => setTimeout(r, 3_000)),
634
- ]).catch(() => {});
635
- }
636
- } finally {
637
- job.cancelling = false;
638
- if (job.teamId) {
639
- const team = this.teams.get(job.teamId);
640
- const member = team?.members.find(entry => entry.id === job.memberId);
641
- const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
642
- if (member) member.status = this.closing ? 'interrupted' : 'ready';
643
- if (teamTask?.status === 'in_progress') { teamTask.status = this.closing ? 'interrupted' : 'pending'; teamTask.updatedAt = Date.now(); }
644
- if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, this.closing ? 'task_interrupted' : 'task_cancelled'); }
645
- }
646
- await this.save();
647
- }
648
- }
649
-
650
- async cancelOwner(owner) {
651
- const jobs = [...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running');
652
- if (!jobs.length) return;
653
- this.cancelling.add(owner);
654
- try {
655
- await Promise.race([
656
- Promise.all(jobs.map(j => this.cancel(j))),
657
- new Promise(r => setTimeout(r, 5_000)),
658
- ]).catch(() => {});
659
- } finally { this.cancelling.delete(owner); }
660
- }
661
- isParticipant(thread, owner) { return thread.id === owner || [...this.jobs.values()].some(j => j.owner === owner && j.childId === thread.id); }
662
- async close() {
663
- await this.initialize();
664
- this.closing = true;
665
- await Promise.all([...this.jobs.values()].map(j => this.cancel(j)));
666
- await Promise.all([...this.jobs.values()].map(j => j.done));
667
- this.keys.clear();
668
- await this.save();
669
- await this.saveTeams();
670
- if (this.server) await new Promise(resolve => this.server.close(resolve));
671
- }
672
- }
673
-
674
- function mentionedAgents(text, runtime) {
675
- // Ignore code and email/package addresses; explicit links survive draft copy/paste.
676
- const prose = text.replace(/```[\s\S]*?```|`[^`\n]*`/g, '');
677
- const ids = new Set();
678
- // CJK ideographs (\u4e00-\u9fff) and fullwidth/halfwidth forms are valid word boundaries,
679
- // so #agent works in Chinese prose (for example 帮我#pi做这个). @ remains native Codex syntax.
680
- for (const match of prose.matchAll(/\[[^\]\n]+\]\(harness-mix:\/\/agent\/([\w-]+)\)|(?:^|[\s\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff,。;:、!?""''()【】])#([\w-]+)(?=$|[\s\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff,。;:、!?""''()【】])/g)) {
681
- const id = runtime.resolveHarnessId(match[1] || match[2]);
682
- if (id) ids.add(id);
683
- }
684
- // Natural-language team requests may name Harnesses without using the picker.
685
- // Require an explicit collaboration intent so ordinary product discussion such
686
- // as "Codex UI" does not silently authorize cross-Harness delegation.
687
- const teamIntent = /agent\s*team|团队|组队|协作|委派|调度|分工|成员|队长|主导者|\b(?:team|delegate|delegation|assign|member|teammate)\b/i.test(prose);
688
- if (teamIntent) {
689
- for (const adapter of runtime.adapters.values()) {
690
- const labels = [adapter.manifest.id, adapter.manifest.name, ...(adapter.manifest.aliases ?? [])]
691
- .filter(label => String(label ?? '').trim().length >= 2)
692
- .sort((a, b) => String(b).length - String(a).length);
693
- if (labels.some(label => containsPlainAgentName(prose, label))) ids.add(adapter.manifest.id);
694
- }
695
- }
696
- return [...ids];
697
- }
698
-
699
- function containsPlainAgentName(text, label) {
700
- const haystack = String(text).toLowerCase();
701
- const needle = String(label).trim().toLowerCase();
702
- for (let offset = haystack.indexOf(needle); offset !== -1; offset = haystack.indexOf(needle, offset + 1)) {
703
- const before = offset === 0 ? '' : haystack[offset - 1];
704
- const afterIndex = offset + needle.length;
705
- const after = afterIndex === haystack.length ? '' : haystack[afterIndex];
706
- if (plainNameBoundary(before) && plainNameBoundary(after)) return true;
707
- }
708
- return false;
709
- }
710
-
711
- function plainNameBoundary(char) {
712
- return !char || /[\s\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff,;:!?()[\]{}"',。;:、!?“”‘’]/u.test(char);
713
- }
714
-
715
- module.exports = { Collaboration, mentionedAgents, defaultWorkerPermissionMode, teamTaskDepths, teamPhase, teamProgress };
658
+ const rt = this.runtime;
659
+ const turnId = job.turnId;
660
+ const title = `Agent 协作 · ${rt.adapters.get(job.agent).manifest.name}`;
661
+ // 「创建智能体」(spawnAgent) 与「执行中」(sendInput) 是两个独立的投影 item:
662
+ // spawn 在子会话就绪后立即结算为 done,否则 Desktop 原生协作卡片会在整个
663
+ // 执行期间一直停留在「创建中 N 个智能体」。
664
+ const spawnCallId = `collaboration:${randomUUID()}`;
665
+ const workCallId = `collaboration:${randomUUID()}`;
666
+ const emit = (event, operation, toolCallId) => {
667
+ if (rt.execution.lastTurn(parent.id)?.id === turnId) rt.emitCollaboration(parent.id, { ...event,
668
+ collaboration: { ...this.view(job), operation } });
669
+ };
670
+ // 已有子会话的后续输入(message_agent / resume / 团队持久成员)不涉及创建。
671
+ let spawnSettled = !!job.childId;
672
+ try {
673
+ if (!job.workspace) {
674
+ job.workspace = await createWorkspace(parent.cwd, job.id, job.isolation);
675
+ await this.save();
676
+ }
677
+ const workerPermMode = defaultWorkerPermissionMode(job.agent);
678
+ if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
679
+ // resume/follow-up 时既有子会话可能已被删除:回落新建替代会话(同一 Harness、
680
+ // 同一工作区),而不是永久报错把该作业废弃
681
+ const child = (job.childId && rt.threads.find(t => t.id === job.childId)) || await rt.createThread({
682
+ harnessId: job.agent, cwd: job.workspace.cwd, title: `${parent.title} › ${task.slice(0, 40)}`, parentThreadId: parent.id,
683
+ options: { ...(workerPermMode ? { permissionMode: workerPermMode } : {}) },
684
+ onCreated: async thread => {
685
+ job.childId = thread.id;
686
+ const team = job.teamId ? this.teams.get(job.teamId) : null;
687
+ const member = team?.members.find(entry => entry.id === job.memberId);
688
+ if (member) { member.childId = thread.id; member.status = 'working'; team.updatedAt = Date.now(); await this.publishTeam(team, 'member_session_ready'); }
689
+ await this.save();
690
+ }
691
+ });
692
+ job.childId = child.id;
693
+ await this.save();
694
+ if (!spawnSettled) {
695
+ emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'done', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
696
+ spawnSettled = true;
697
+ }
698
+ emit({ kind: 'tool', toolCallId: workCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
699
+ // 终态保持:cancel()/close() 已写入的 cancelled/interrupted 不得在此被覆写——
700
+ // 关机竞态下覆写成 cancelled 会让重启后的 resume_delegation 拒绝恢复该作业
701
+ if (job.status !== 'running' || this.closing || !rt.execution.isRunning(parent.id)) {
702
+ if (job.status === 'running') {
703
+ job.status = this.closing ? 'interrupted' : 'cancelled';
704
+ await this.settleStoppedJob(job);
705
+ }
706
+ return;
707
+ }
708
+ // Child native file events remain visible; only the lead snapshots the shared workspace.
709
+ // 成员会话可能被并发占用(邮箱投递泵、用户追问、上一回合结算尾部,或 isRunning
710
+ // 已清而 sending 锁未释放的结算窗口):busy 拒绝等空闲后重试(≤10s),而不是把
711
+ // 「任务正在执行」误判为任务失败(改派/重派紧跟失败结算时尤其容易触发)
712
+ let sendDone = false, sendError, sendRetrying = false;
713
+ const dispatchInput = async () => {
714
+ for (let attempt = 0; ; attempt++) {
715
+ try {
716
+ return await rt.send(child.id, task, { collaborationOf: parent.id, isolated: job.workspace.mode === 'worktree' });
717
+ } catch (error) {
718
+ if (!/任务正在执行/.test(String(error?.message ?? error)) || attempt >= 100) throw error;
719
+ sendRetrying = true;
720
+ await delay(100);
721
+ sendRetrying = false;
722
+ }
723
+ }
724
+ };
725
+ const sending = dispatchInput();
726
+ void sending.then(() => { sendDone = true; }, error => { sendDone = true; sendError = error; });
727
+ const timeoutMs = rt.delegationTimeoutMs ?? 30 * 60 * 1000;
728
+ const until = Date.now() + timeoutMs;
729
+ let displayedStatus = 'running';
730
+ let turnInactiveSince = null;
731
+ while (job.status === 'running' && !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)) {
732
+ // 已删除的 worker 线程:core 回合可能仍呈 running 态(removeThread 不结算回合),
733
+ // 不得据此继续等待,否则作业空转到超时
734
+ const childRunning = rt.threads.some(t => t.id === child.id) && (rt.execution.isRunning(child.id) || child.reviewPending);
735
+ if (!childRunning) {
736
+ if (!turnInactiveSince) turnInactiveSince = Date.now();
737
+ // sendRetrying 期间不按「子回合静默」提前结算:投递还在等成员空闲
738
+ if (sendDone || (!sendRetrying && Date.now() - turnInactiveSince > 2000)) break;
739
+ } else {
740
+ turnInactiveSince = null;
741
+ }
742
+ const current = this.view(job).display_status;
743
+ if (current !== displayedStatus) { displayedStatus = current; emit({ kind: 'tool', toolCallId: workCallId, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId); }
744
+ if (Date.now() > until) {
745
+ // 先落失败再取消子线程:runtime.cancel 会把 running 作业标记为 cancelled,
746
+ // 若先取消后抛错,catch 的记录分支(仅认 running)会吞掉超时错误并误报已取消
747
+ job.status = 'failed';
748
+ job.error = `Subtask timed out after ${Math.round(timeoutMs / 60000)} minutes`;
749
+ await rt.cancel(child.id);
750
+ throw new Error(job.error);
751
+ }
752
+ // 顺带泵送排队消息:其他成员可能已空闲(不打断任何人运行中的回合)
753
+ if (job.teamId) this.drainTeamMailbox(this.teams.get(job.teamId));
754
+ await delay(100);
755
+ }
756
+ if (job.status !== 'running') return;
757
+ if (!rt.threads.some(t => t.id === child.id)) {
758
+ // 子线程在执行中被删除:按停止结算,而不是把未完成的 core 回合误判为 completed
759
+ job.status = 'cancelled';
760
+ await this.settleStoppedJob(job);
761
+ return;
762
+ }
763
+ if (sendError) throw sendError;
764
+ const turn = rt.execution.lastTurn(child.id);
765
+ if (!turn || turn.status === 'error') throw new Error(turn?.error || child.error || 'Subtask failed');
766
+ job.status = turn.status === 'cancelled' ? 'cancelled' : 'completed';
767
+ const messages = rt.core.getItemsForTurn(turn.id).filter(i => i.type === 'agent_message');
768
+ const finals = messages.filter(i => i.phase === 'final');
769
+ job.result = (finals.length ? finals : messages).map(i => i.content || '').join('\n').slice(0, 48000);
770
+ if (job.teamId) {
771
+ const team = this.teams.get(job.teamId);
772
+ const member = team?.members.find(entry => entry.id === job.memberId);
773
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
774
+ if (member) member.status = 'ready';
775
+ if (teamTask && teamTask.status === 'in_progress') {
776
+ teamTask.status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'pending' : 'failed';
777
+ teamTask.result = job.result;
778
+ teamTask.updatedAt = Date.now();
779
+ for (const candidate of team.tasks) {
780
+ if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(entry => entry.id === id)?.status === 'completed')) candidate.status = 'pending';
781
+ }
782
+ }
783
+ if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_settled'); this.drainTeamMailbox(team); }
784
+ }
785
+ if (job.workspace?.mode === 'worktree' && job.status === 'completed') {
786
+ try {
787
+ const rev = await reviewWorkspace(job.workspace);
788
+ job.diff = rev.patch;
789
+ job.digest = rev.digest;
790
+ } catch {}
791
+ }
792
+ } catch (error) {
793
+ if (job.status === 'running') { job.status = 'failed'; job.error = error.message; }
794
+ if (job.teamId) {
795
+ const team = this.teams.get(job.teamId);
796
+ const member = team?.members.find(entry => entry.id === job.memberId);
797
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
798
+ if (member && member.status !== 'interrupted') member.status = 'ready';
799
+ let retried = false;
800
+ if (teamTask && teamTask.status === 'in_progress') {
801
+ // 失败必达:system 通知先进 lead 邮箱,再决定自动重派或落 failed
802
+ retried = await this.retryTeamTask(parent, job, team, member, teamTask, error);
803
+ }
804
+ if (teamTask && !retried) { teamTask.status = 'failed'; teamTask.result = error.message; teamTask.updatedAt = Date.now(); }
805
+ if (team && !retried) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_failed'); this.drainTeamMailbox(team); }
806
+ }
807
+ }
808
+ finally {
809
+ await this.save();
810
+ // 创建阶段失败(如原生会话启动报错)时,把仍在「创建中」的 spawn 卡片结算为错误。
811
+ if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'error', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
812
+ emit({ kind: 'tool', toolCallId: workCallId, title, state: job.status === 'completed' ? 'done' : 'error', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
813
+ }
814
+ }
815
+
816
+ // 团队委派的任务信封:成员身份 + 共享图约定(call() 的首次派发与失败重派共用)
817
+ teamEnvelope(team, member, teamTask, baseTask) {
818
+ return `${baseTask}\n\n[Harness Mix Agent Team]\nTeam: ${team.name} (${team.id})\nShared goal: ${team.goal}\nYou are ${member.name}. Role: ${member.role}\nAssigned task: ${teamTask.title} (${teamTask.id})\nYou are a persistent teammate, not a one-shot subagent. Read shared state with get_team_state, update your assigned task with update_team_task, and coordinate directly with teammates through send_team_message. Do not create or assign team members.`;
819
+ }
820
+
821
+ // system 伪参与者通知:不进 roster、不投递,只进邮箱与团队动态,供 lead 免轮询看到失败
822
+ pushSystemNotice(team, body, taskId) {
823
+ team.messages.push({ id: randomUUID(), from: 'system', fromName: 'Harness Mix', to: 'lead', kind: 'text', body, ...(taskId ? { taskId } : {}), at: Date.now(), delivery: 'mailbox' });
824
+ if (team.messages.length > 200) team.messages.splice(0, team.messages.length - 200);
825
+ }
826
+
827
+ // 失败结算的统一入口:先投 system 通知,再按 retry 预算决定自动重派(同一成员,
828
+ // 复用其会话与工作区,附上次失败原因)或落 failed。返回 true 表示已重新派发。
829
+ // 重派仍处于同一 lead 回合内(run() 的监管前提),预算只在真正重新派发时消耗。
830
+ async retryTeamTask(parent, failedJob, team, member, teamTask, error) {
831
+ if (!team || !teamTask || failedJob.status !== 'failed') return false;
832
+ const rt = this.runtime;
833
+ const reason = String(error?.message ?? error ?? 'unknown failure');
834
+ const budget = teamTask.retry;
835
+ const canRetry = !!budget && budget.used < budget.max && member
836
+ && !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)
837
+ && rt.status[member.agent]?.available !== false
838
+ && [...this.jobs.values()].filter(j => j.owner === parent.id && j.status === 'running').length < MAX_CONCURRENT_SUBTASKS;
839
+ this.pushSystemNotice(team, `任务「${teamTask.title}」失败:${reason}${canRetry ? `;将自动重试(第 ${budget.used + 1}/${budget.max} 次)` : budget ? ';重试预算已耗尽' : ''}`, teamTask.id);
840
+ if (!canRetry) return false;
841
+ budget.used += 1;
842
+ const retryJob = { id: randomUUID(), owner: parent.id, agent: failedJob.agent, turnId: rt.execution.lastTurn(parent.id)?.id,
843
+ status: 'running', task: failedJob.task, isolation: failedJob.isolation,
844
+ ...(failedJob.workspace ? { workspace: failedJob.workspace } : {}),
845
+ teamId: team.id, memberId: failedJob.memberId, teamTaskId: teamTask.id,
846
+ ...(failedJob.childId && rt.threads.some(t => t.id === failedJob.childId) ? { childId: failedJob.childId } : {}) };
847
+ this.jobs.set(retryJob.id, retryJob);
848
+ teamTask.status = 'in_progress'; teamTask.jobId = retryJob.id; teamTask.updatedAt = Date.now();
849
+ if (member) member.status = 'working';
850
+ this.refreshTeamStatus(team);
851
+ await this.publishTeam(team, 'task_retry');
852
+ await this.save();
853
+ retryJob.done = this.run(parent, retryJob, this.teamEnvelope(team, member, teamTask,
854
+ `Previous attempt failed: ${reason}\nInspect what was already done; do not repeat completed side effects and avoid the failure path.\n\nOriginal task:\n${retryJob.task}`));
855
+ return true;
856
+ }
857
+
858
+ // 所有「作业在运行中被外力终止」的路径(取消工具、lead 停止级联、用户直接停止/
859
+ // 删除 worker 线程、宿主关机)共用的收尾:团队图里不得残留 in_progress 的任务——
860
+ // 否则该成员永远无法被再次委派(delegate_to_agent 会以 already running 拒绝)。
861
+ async settleStoppedJob(job) {
862
+ if (job.teamId) {
863
+ const team = this.teams.get(job.teamId);
864
+ const member = team?.members.find(entry => entry.id === job.memberId);
865
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
866
+ const interrupted = job.status === 'interrupted';
867
+ const memberStatus = interrupted ? 'interrupted' : 'ready';
868
+ let changed = false;
869
+ if (member && member.status !== memberStatus) { member.status = memberStatus; changed = true; }
870
+ if (teamTask?.status === 'in_progress') { teamTask.status = interrupted ? 'interrupted' : 'pending'; teamTask.updatedAt = Date.now(); changed = true; }
871
+ if (team && changed) { this.refreshTeamStatus(team); await this.publishTeam(team, interrupted ? 'task_interrupted' : 'task_cancelled'); }
872
+ // 取消/中断 unwind 后成员空闲:泵送排队消息(lead 已停则按语义降级回邮箱)
873
+ if (team) this.drainTeamMailbox(team);
874
+ }
875
+ await this.save();
876
+ }
877
+
878
+ async cancel(job) {
879
+ if (job.status !== 'running') return;
880
+ job.status = this.closing ? 'interrupted' : 'cancelled';
881
+ job.cancelling = true;
882
+ try {
883
+ if (job.childId) {
884
+ await Promise.race([
885
+ this.runtime.cancel(job.childId),
886
+ new Promise(r => setTimeout(r, 3_000)),
887
+ ]).catch(() => {});
888
+ }
889
+ } finally {
890
+ job.cancelling = false;
891
+ await this.settleStoppedJob(job);
892
+ }
893
+ }
894
+
895
+ async cancelOwner(owner) {
896
+ const jobs = [...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running');
897
+ if (!jobs.length) return;
898
+ this.cancelling.add(owner);
899
+ try {
900
+ await Promise.race([
901
+ Promise.all(jobs.map(j => this.cancel(j))),
902
+ new Promise(r => setTimeout(r, 5_000)),
903
+ ]).catch(() => {});
904
+ } finally { this.cancelling.delete(owner); }
905
+ }
906
+ isParticipant(thread, owner) { return thread.id === owner || [...this.jobs.values()].some(j => j.owner === owner && j.childId === thread.id); }
907
+
908
+ // 线程删除后不得残留悬空的成员会话引用,否则团队视图会永远显示一个
909
+ // 已不存在的 working 成员、消息投递也会反复打到死 id 上
910
+ async forgetThread(threadId) {
911
+ let changed = false;
912
+ for (const team of this.teams.values()) {
913
+ for (const member of team.members) {
914
+ if (member.childId === threadId) { delete member.childId; changed = true; }
915
+ }
916
+ }
917
+ if (changed) await this.saveTeams();
918
+ }
919
+ async close() {
920
+ await this.initialize();
921
+ this.closing = true;
922
+ await Promise.all([...this.jobs.values()].map(j => this.cancel(j)));
923
+ await Promise.all([...this.jobs.values()].map(j => j.done));
924
+ this.keys.clear();
925
+ await this.save();
926
+ await this.saveTeams();
927
+ if (this.server) await new Promise(resolve => this.server.close(resolve));
928
+ }
929
+ }
930
+
931
+ function mentionedAgents(text, runtime) {
932
+ // Ignore code and email/package addresses; explicit links survive draft copy/paste.
933
+ const prose = text.replace(/```[\s\S]*?```|`[^`\n]*`/g, '');
934
+ const ids = new Set();
935
+ // CJK ideographs (\u4e00-\u9fff) and fullwidth/halfwidth forms are valid word boundaries,
936
+ // so #agent works in Chinese prose (for example 帮我#pi做这个). @ remains native Codex syntax.
937
+ for (const match of prose.matchAll(/\[[^\]\n]+\]\(harness-mix:\/\/agent\/([\w-]+)\)|(?:^|[\s\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff,。;:、!?""''()【】])#([\w-]+)(?=$|[\s\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff,。;:、!?""''()【】])/g)) {
938
+ const id = runtime.resolveHarnessId(match[1] || match[2]);
939
+ if (id) ids.add(id);
940
+ }
941
+ // Natural-language team requests may name Harnesses without using the picker.
942
+ // Require an explicit collaboration intent so ordinary product discussion such
943
+ // as "Codex UI" does not silently authorize cross-Harness delegation.
944
+ const teamIntent = /agent\s*team|团队|组队|协作|委派|调度|分工|成员|队长|主导者|\b(?:team|delegate|delegation|assign|member|teammate)\b/i.test(prose);
945
+ if (teamIntent) {
946
+ for (const adapter of runtime.adapters.values()) {
947
+ const labels = [adapter.manifest.id, adapter.manifest.name, ...(adapter.manifest.aliases ?? [])]
948
+ .filter(label => String(label ?? '').trim().length >= 2)
949
+ .sort((a, b) => String(b).length - String(a).length);
950
+ if (labels.some(label => containsPlainAgentName(prose, label))) ids.add(adapter.manifest.id);
951
+ }
952
+ }
953
+ return [...ids];
954
+ }
955
+
956
+ function containsPlainAgentName(text, label) {
957
+ const haystack = String(text).toLowerCase();
958
+ const needle = String(label).trim().toLowerCase();
959
+ for (let offset = haystack.indexOf(needle); offset !== -1; offset = haystack.indexOf(needle, offset + 1)) {
960
+ const before = offset === 0 ? '' : haystack[offset - 1];
961
+ const afterIndex = offset + needle.length;
962
+ const after = afterIndex === haystack.length ? '' : haystack[afterIndex];
963
+ if (plainNameBoundary(before) && plainNameBoundary(after)) return true;
964
+ }
965
+ return false;
966
+ }
967
+
968
+ function plainNameBoundary(char) {
969
+ return !char || /[\s\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff,;:!?()[\]{}"',。;:、!?“”‘’]/u.test(char);
970
+ }
971
+
972
+ module.exports = { Collaboration, mentionedAgents, defaultWorkerPermissionMode, teamTaskDepths, teamPhase, teamProgress };