@harness-mix/cli 0.2.2 → 0.2.4

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 (45) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +469 -467
  3. package/output/native-build/desktop-controller.mjs +1 -1
  4. package/output/native-build/renderer-extension.js +23 -4
  5. package/package.json +16 -9
  6. package/scripts/antigravity-adapter-test.cjs +647 -626
  7. package/scripts/codex-adapter-test.cjs +162 -127
  8. package/scripts/collaboration-test.cjs +274 -262
  9. package/scripts/core-review-test.cjs +68 -0
  10. package/scripts/delegation-await-test.cjs +76 -0
  11. package/scripts/jsonl-stdin-test.cjs +40 -0
  12. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  13. package/scripts/native-acp-depth-test.cjs +30 -5
  14. package/scripts/native-protocol-test.cjs +14 -1
  15. package/scripts/native-update-apply-test.cjs +269 -215
  16. package/scripts/native-update.cjs +78 -0
  17. package/scripts/native-vendor-adapters-test.cjs +196 -154
  18. package/scripts/salvage-rollout-writes.cjs +72 -0
  19. package/scripts/send-cancel-race-test.cjs +80 -0
  20. package/scripts/send-pre-turn-cancel-test.cjs +100 -0
  21. package/scripts/stuck-turn-test.cjs +6 -1
  22. package/scripts/zcode-adapter-test.cjs +329 -0
  23. package/scripts/zcode-live-probe.cjs +66 -0
  24. package/src/main/adapters/antigravity.js +1428 -1415
  25. package/src/main/adapters/codex.js +656 -649
  26. package/src/main/adapters/native-acp-command.js +51 -48
  27. package/src/main/adapters/native-acp.js +47 -12
  28. package/src/main/adapters/qoder.js +12 -8
  29. package/src/main/adapters/zcode.js +921 -10
  30. package/src/main/harness-adapter/event-normalizer.js +5 -2
  31. package/src/main/host/collaboration.js +723 -715
  32. package/src/main/host/jsonl.js +130 -116
  33. package/src/main/host/runtime.js +30 -14
  34. package/src/main/native/config.js +9 -9
  35. package/src/main/native/host.js +2 -0
  36. package/src/main/native/launcher.js +252 -237
  37. package/src/main/native/process-utils.js +157 -57
  38. package/src/main/native/protocol.js +1221 -1177
  39. package/src/main/native/secure-store.js +2 -0
  40. package/src/main/native/update-state.js +123 -110
  41. package/src/main/native/updater.js +460 -394
  42. package/src/main/workspace/core-review.js +13 -5
  43. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  44. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3211 -3181
  45. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
@@ -1,715 +1,723 @@
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
-
514
- 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 };
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
+ teamView(team) {
219
+ const leadThread = this.runtime.threads.find(thread => thread.id === team.owner);
220
+ const leadAgent = leadThread?.harnessId ?? 'codex';
221
+ const leadName = this.runtime.adapters.get(leadAgent)?.manifest?.name ?? leadAgent;
222
+ const depths = teamTaskDepths(team.tasks);
223
+ return {
224
+ team_id: team.id, name: team.name, goal: team.goal, status: team.status, lead_thread_id: team.owner,
225
+ phase: teamPhase(team), progress: teamProgress(team.tasks),
226
+ lead: { id: 'lead', name: 'Team Lead', role: `${leadName} · 协调与验收`, agent: leadAgent, display_status: this.runtime.execution.isRunning(team.owner) ? 'working' : 'ready' },
227
+ members: team.members.map(member => ({ ...member, display_status: member.childId && this.runtime.execution.isRunning(member.childId) ? 'working' : member.status })),
228
+ tasks: team.tasks.map(task => ({ ...task, depth: depths.get(task.id) ?? 0 })), messages: team.messages.slice(-40).map(message => ({ ...message })),
229
+ updated_at: team.updatedAt,
230
+ };
231
+ }
232
+
233
+ emitTeam(team, action) {
234
+ if (!this.runtime.execution.isRunning(team.owner)) return;
235
+ // 团队全部完成时把团队卡片结算为 done——否则它会作为未终态 tool_call
236
+ // 一直挂到回合结束,被投影成「执行中」。完成后若再 reopen,会以 running 复更。
237
+ const done = team.status === 'completed';
238
+ this.runtime.emitCollaboration(team.owner, {
239
+ kind: 'tool', toolCallId: `agent-team:${team.id}`, title: `Agent Team · ${team.name}`,
240
+ state: done ? 'done' : 'running', input: team.goal, output: JSON.stringify({ action, ...this.teamView(team) }),
241
+ });
242
+ }
243
+
244
+ async review(id) {
245
+ const job = this.jobs.get(id);
246
+ if (!job || job.status === 'running') throw new Error('子任务尚未完成');
247
+ return reviewWorkspace(job.workspace);
248
+ }
249
+
250
+ async apply(id, digest) {
251
+ const job = this.jobs.get(id);
252
+ if (!job || job.status !== 'completed') throw new Error('仅可应用已完成子任务的改动');
253
+ if (job.applying) throw new Error('正在应用改动');
254
+ if (job.appliedDigest) throw new Error('此任务已应用;后续修改请创建新任务');
255
+ const parent = this.runtime.threads.find(t => t.id === job.owner);
256
+ // lead 回合在等待本 MCP 工具返回时必然处于运行态(call() 的前置条件),子线程由下方
257
+ // verification gates 单独校验,因此同目录并发扫描必须排除这两者,否则条件恒真、apply 永远失败
258
+ if (!parent || this.runtime.threads.some(t => t.id !== job.owner && t.id !== job.childId
259
+ && (this.runtime.execution.isRunning(t.id) || t.reviewPending)
260
+ && [parent.cwd, job.workspace?.cwd].some(cwd => String(cwd).toLowerCase() === String(t.cwd).toLowerCase()))) throw new Error('其他任务正在同一目录运行或待审查,请等待其结算后再应用');
261
+ if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '应用子任务改动');
262
+ job.applying = true;
263
+ try {
264
+ const result = await applyWorkspace(job.workspace, digest);
265
+ job.appliedDigest = result.digest;
266
+ await this.save();
267
+ return result;
268
+ } finally { delete job.applying; }
269
+ }
270
+
271
+ async discard(id) {
272
+ const job = this.jobs.get(id);
273
+ if (!job) throw new Error('子任务不存在');
274
+ if (job.status === 'running') throw new Error('子任务正在运行,请先取消');
275
+ const result = await discardWorkspace(job.workspace);
276
+ job.status = 'cancelled';
277
+ delete job.workspace;
278
+ await this.save();
279
+ return result;
280
+ }
281
+
282
+ async push(id, { remote = 'origin', branch } = {}) {
283
+ const job = this.jobs.get(id);
284
+ if (!job) throw new Error('子任务不存在');
285
+ if (job.status === 'running') throw new Error('请等待子任务完成后再推送分支');
286
+ if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '推送子任务分支');
287
+ return pushWorkspace(job.workspace, remote, branch);
288
+ }
289
+
290
+ async connection(thread) {
291
+ await this.initialize();
292
+ if (!this.starting) this.starting = new Promise((resolve, reject) => {
293
+ this.server = http.createServer((req, res) => void this.handle(req, res));
294
+ this.server.once('error', reject);
295
+ this.server.listen(0, '127.0.0.1', resolve);
296
+ });
297
+ await this.starting;
298
+ let key = this.keys.get(thread.id);
299
+ if (!key) { key = randomUUID(); this.keys.set(thread.id, key); }
300
+ return { command: process.execPath, args: [path.join(__dirname, 'collaboration-mcp.cjs')],
301
+ 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' } };
302
+ }
303
+
304
+ async handle(req, res) {
305
+ const reply = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
306
+ const owner = [...this.keys].find(([, key]) => req.headers.authorization === `Bearer ${key}`)?.[0];
307
+ if (!owner || req.method !== 'POST' || req.url !== '/' || req.headers.origin) return reply(403, { error: 'Forbidden' });
308
+ try {
309
+ let body = '';
310
+ for await (const chunk of req) { body += chunk; if (body.length > 64000) throw new Error('Request too large'); }
311
+ const { name, arguments: args } = JSON.parse(body);
312
+ reply(200, { result: await this.call(owner, name, args ?? {}) });
313
+ } catch (error) { reply(400, { error: error.message }); }
314
+ }
315
+
316
+ owned(owner, id) {
317
+ const job = this.jobs.get(id);
318
+ if (!job || job.owner !== owner) throw new Error('Unknown task or task belongs to another lead');
319
+ return job;
320
+ }
321
+
322
+ async teamCall(principal, name, args) {
323
+ const rt = this.runtime;
324
+ if (name === 'create_agent_team') {
325
+ const lead = rt.threads.find(thread => thread.id === principal);
326
+ if (!lead || lead.parentThreadId) throw new Error('Only a lead task can create an Agent Team');
327
+ const names = new Set();
328
+ const members = args.members.map(entry => {
329
+ const agent = rt.resolveHarnessId(entry.agent_type);
330
+ if (!agent || !rt.status[agent]?.available) throw new Error(`Target Harness unavailable: ${entry.agent_type}`);
331
+ if (!rt.adapters.get(agent)?.manifest?.capabilities?.collaborationTools) throw new Error(`Harness cannot participate in Agent Team messaging: ${agent}`);
332
+ if (!lead.activeMentions?.includes(agent)) throw new Error(`Agent Team member ${entry.name} uses unselected Harness "${agent}"`);
333
+ const key = entry.name.trim().toLowerCase();
334
+ if (names.has(key)) throw new Error('Agent Team member names must be unique');
335
+ names.add(key);
336
+ return { id: randomUUID(), name: entry.name.trim(), role: entry.role.trim(), agent, status: 'ready' };
337
+ });
338
+ 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() };
339
+ this.teams.set(team.id, team);
340
+ await this.publishTeam(team, 'team_created');
341
+ return this.teamView(team);
342
+ }
343
+
344
+ const participant = this.teamFor(principal, args.team_id);
345
+ const { team } = participant;
346
+ if (name === 'get_team_state') return this.teamView(team);
347
+ if (name === 'assign_team_task') {
348
+ if (participant.kind !== 'lead') throw new Error('Only the Team Lead can assign team tasks');
349
+ const assignee = this.resolveMember(team, args.assignee);
350
+ const dependencies = [...new Set(args.depends_on ?? [])];
351
+ if (dependencies.some(id => !team.tasks.some(task => task.id === id))) throw new Error('Unknown dependency task');
352
+ 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() };
353
+ team.tasks.push(taskEntry);
354
+ this.refreshTeamStatus(team);
355
+ await this.publishTeam(team, 'task_assigned');
356
+ return { task: taskEntry, team: this.teamView(team) };
357
+ }
358
+ if (name === 'update_team_task') {
359
+ const taskEntry = team.tasks.find(task => task.id === args.task_id);
360
+ if (!taskEntry) throw new Error('Unknown team task');
361
+ if (participant.kind === 'member' && taskEntry.assignee !== participant.id) throw new Error('A teammate can update only its assigned tasks');
362
+ 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');
363
+ if (participant.kind === 'member' && taskEntry.status === 'completed') throw new Error('A completed task can only be reopened by the Team Lead');
364
+ 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');
365
+ taskEntry.status = args.status;
366
+ taskEntry.updatedAt = Date.now();
367
+ if (args.result !== undefined) taskEntry.result = args.result;
368
+ for (const candidate of team.tasks) {
369
+ if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(task => task.id === id)?.status === 'completed')) candidate.status = 'pending';
370
+ }
371
+ this.refreshTeamStatus(team);
372
+ await this.publishTeam(team, 'task_updated');
373
+ return { task: { ...taskEntry }, team: this.teamView(team) };
374
+ }
375
+ if (name === 'send_team_message') {
376
+ const target = args.to === '*' || args.to.toLowerCase() === 'lead' ? args.to.toLowerCase() : this.resolveMember(team, args.to).id;
377
+ if (args.task_id && !team.tasks.some(task => task.id === args.task_id)) throw new Error('Unknown team task');
378
+ 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' };
379
+ team.messages.push(message);
380
+ if (team.messages.length > 200) team.messages.splice(0, team.messages.length - 200);
381
+ const recipients = target === '*' ? team.members.filter(member => member.id !== participant.id) : team.members.filter(member => member.id === target);
382
+ for (const recipient of recipients) {
383
+ if (!recipient.childId || rt.execution.isRunning(recipient.childId)) continue;
384
+ 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.`;
385
+ message.delivery = 'native_session';
386
+ const recipientJob = [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === recipient.id && job.childId === recipient.childId);
387
+ void rt.send(recipient.childId, envelope, { collaborationOf: team.owner, isolated: recipientJob?.workspace?.mode === 'worktree' }).catch(error => {
388
+ message.delivery = 'mailbox'; message.deliveryError = error.message; void this.saveTeams();
389
+ });
390
+ }
391
+ team.updatedAt = Date.now();
392
+ await this.publishTeam(team, 'message_sent');
393
+ return { message, team: this.teamView(team) };
394
+ }
395
+ throw new Error('Unknown Agent Team operation');
396
+ }
397
+
398
+ view(job) {
399
+ const pending = job.childId ? this.runtime.core.interactions?.pending(job.childId)?.[0] : null;
400
+ return { task_id: job.id, parent_thread_id: job.owner, child_thread_id: job.childId, agent_type: job.agent, status: job.status,
401
+ team_id: job.teamId, member_id: job.memberId, team_task_id: job.teamTaskId,
402
+ display_status: pending ? 'waiting_approval' : job.status, attention: pending ? { type: pending.type, title: pending.title, message: pending.message } : undefined,
403
+ task: job.task, workspace: job.workspace, applied: !!job.appliedDigest, result: job.result, error: job.error,
404
+ diff: job.diff, digest: job.digest, branch: job.workspace?.branch };
405
+ }
406
+
407
+ async call(principal, name, args) {
408
+ await this.initialize();
409
+ if (this.closing) throw new Error('Host is closing');
410
+ if (!validators.has(name)) throw new Error('Unknown collaboration tool');
411
+ if (!this.prefs.collaboration) {
412
+ throw new Error('多 Agent 协作已在设置中停用(设置 → 协作)。Multi-Agent collaboration is disabled in Settings → Collaboration.');
413
+ }
414
+ args = validators.get(name).parse(args);
415
+ const rt = this.runtime;
416
+ const teamTools = TEAM_TOOL_NAMES;
417
+ const participant = this.participant(principal, args.team_id);
418
+ const owner = participant?.team.owner ?? principal;
419
+ const parent = rt.threads.find(t => t.id === owner);
420
+ if (teamTools.has(name)) {
421
+ if (name === 'create_agent_team' && !this.prefs.agentTeam) {
422
+ throw new Error('Agent Team 已在设置中停用(设置 协作)。Agent Team is disabled in Settings → Collaboration; one-shot delegation remains available.');
423
+ }
424
+ if (!rt.execution.isRunning(principal) || (principal === owner && this.cancelling.has(owner))) throw new Error('Collaboration turn is no longer active');
425
+ return this.teamCall(principal, name, args);
426
+ }
427
+ if (!parent || principal !== owner || parent.parentThreadId) throw new Error('Only lead tasks can delegate');
428
+ if (!rt.execution.isRunning(principal) || this.cancelling.has(owner)) throw new Error('Collaboration turn is no longer active');
429
+ 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 }));
430
+ if (name === 'list_delegations') return this.list(owner);
431
+ if (name === 'update_agent_plan') {
432
+ rt.emitCollaboration(owner, { kind: 'plan', entries: args.steps });
433
+ return { steps: args.steps };
434
+ }
435
+ if (name === 'delegate_to_agent') {
436
+ const agent = rt.resolveHarnessId(args.agent_type);
437
+ if (!agent || !rt.status[agent]?.available) throw new Error('Target Harness unavailable');
438
+ // Server-side enforcement: multi-agent collaboration can ONLY start when the user explicitly selected/mentioned agents.
439
+ if (!parent.activeMentions || !parent.activeMentions.length) {
440
+ throw new Error('跨 Harness 协作仅在用户显式选择 Agent,或在团队/委派语境中明确写出 Harness 名称时允许启动(例如 #pi #claude,或“用 Pi 开发、Claude 审查组成团队”)。用户本轮未显式委派,不能由大模型自行决定启动跨 Harness 协作。');
441
+ }
442
+ if (!parent.activeMentions.includes(agent)) {
443
+ throw new Error(`用户仅显式指定了 [${parent.activeMentions.join(', ')}],不能委派给未指定的 "${agent}"。请向用户确认是否需要委派给其他 Harness。`);
444
+ }
445
+ let team, member, teamTask, previousMemberJob;
446
+ const teamFields = [args.team_id, args.member_id, args.team_task_id].filter(Boolean).length;
447
+ if (teamFields && teamFields !== 3) throw new Error('Agent Team delegation requires team_id, member_id and team_task_id together');
448
+ if (teamFields) {
449
+ ({ team } = this.teamFor(owner, args.team_id));
450
+ member = this.resolveMember(team, args.member_id);
451
+ teamTask = team.tasks.find(entry => entry.id === args.team_task_id);
452
+ if (!teamTask || teamTask.assignee !== member.id) throw new Error('Team task is not assigned to this member');
453
+ if (member.agent !== agent) throw new Error('Delegated Harness does not match the team member');
454
+ if (member.childId && !rt.threads.some(thread => thread.id === member.childId)) delete member.childId;
455
+ previousMemberJob = member.childId ? [...this.jobs.values()].reverse().find(job => job.teamId === team.id && job.memberId === member.id && job.childId === member.childId) : null;
456
+ if (teamTask.dependsOn.some(id => team.tasks.find(entry => entry.id === id)?.status !== 'completed')) throw new Error('Team task dependencies are not complete');
457
+ if (teamTask.status === 'completed') throw new Error('Team task is already completed');
458
+ if (teamTask.status === 'in_progress') throw new Error('Team task is already running');
459
+ 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');
460
+ }
461
+ const jobs = [...this.jobs.values()].filter(j => j.owner === owner);
462
+ if (jobs.filter(j => j.status === 'running').length >= MAX_CONCURRENT_SUBTASKS) throw new Error('At most six concurrent subtasks; collect existing results first');
463
+ 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');
464
+ // Risk-aware default: while another session outside this collaboration group is
465
+ // actively running in the lead directory, a shared workspace would let both sides
466
+ // silently overwrite each other start new workers isolated ('auto' isolates Git
467
+ // projects into a worktree and falls back to shared only outside Git). A teammate's
468
+ // inherited workspace and an explicit isolation argument still win over the default.
469
+ const externalActive = rt.threads.some(t => t.id !== owner && t.parentThreadId !== owner && !this.isParticipant(t, owner)
470
+ && String(t.cwd).toLowerCase() === String(parent.cwd).toLowerCase()
471
+ && (rt.execution.isRunning(t.id) || t.reviewPending));
472
+ const job = { id: randomUUID(), owner, agent, turnId: rt.execution.lastTurn(owner).id, status: 'running', task: args.task,
473
+ isolation: previousMemberJob?.isolation ?? args.isolation ?? (externalActive ? 'auto' : 'shared'),
474
+ ...(previousMemberJob?.workspace ? { workspace: previousMemberJob.workspace } : {}),
475
+ ...(team ? { teamId: team.id, memberId: member.id, teamTaskId: teamTask.id, ...(member.childId ? { childId: member.childId } : {}) } : {}) };
476
+ this.jobs.set(job.id, job);
477
+ if (team) {
478
+ teamTask.status = 'in_progress'; teamTask.jobId = job.id; teamTask.updatedAt = Date.now();
479
+ member.status = 'working'; this.refreshTeamStatus(team);
480
+ await this.publishTeam(team, 'task_started');
481
+ }
482
+ await this.save();
483
+ 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;
484
+ job.done = this.run(parent, job, teamPrompt);
485
+ return this.view(job);
486
+ }
487
+ if (name === 'get_delegation_status') {
488
+ const jobs = args.task_ids.map(id => this.owned(owner, id));
489
+ const until = Date.now() + (args.wait_ms ?? 0);
490
+ 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()));
491
+ return jobs.map(j => this.view(j));
492
+ }
493
+ const job = this.owned(owner, args.task_id);
494
+ if (name === 'cancel_delegation') { await this.cancel(job); return this.view(job); }
495
+ if (name === 'review_delegation_changes') return this.review(job.id);
496
+ if (name === 'apply_delegation_changes') return this.apply(job.id, args.digest);
497
+ if (name === 'resume_delegation' && job.status !== 'interrupted') throw new Error('Only interrupted tasks can be resumed');
498
+ if (job.status === 'running' || job.cancelling || job.followupPending || job.applying) throw new Error('Subtask still running; wait before sending a follow-up');
499
+ if (job.appliedDigest) throw new Error('Applied task is closed; delegate a new task for further changes');
500
+ if (!job.childId && name !== 'resume_delegation') throw new Error('Subtask did not create a session; resume or delegate a new task');
501
+ job.followupPending = true;
502
+ try { await job.done; } finally { job.followupPending = false; }
503
+ if (this.closing || !rt.execution.isRunning(owner) || this.cancelling.has(owner)) throw new Error('Lead turn is no longer active');
504
+ if ([...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running').length >= MAX_CONCURRENT_SUBTASKS) throw new Error('At most six concurrent subtasks');
505
+ job.status = 'running'; job.result = undefined; job.error = undefined;
506
+ job.turnId = rt.execution.lastTurn(owner).id;
507
+ 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;
508
+ if (name !== 'resume_delegation') job.task = task;
509
+ if (job.teamId) {
510
+ const team = this.teams.get(job.teamId);
511
+ const member = team?.members.find(entry => entry.id === job.memberId);
512
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
513
+ if (member) member.status = 'working';
514
+ if (teamTask) { teamTask.status = 'in_progress'; teamTask.updatedAt = Date.now(); }
515
+ if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, name === 'resume_delegation' ? 'task_resumed' : 'task_followup'); }
516
+ }
517
+ await this.save();
518
+ job.done = this.run(parent, job, task);
519
+ return this.view(job);
520
+ }
521
+
522
+ async run(parent, job, task) {
523
+ const rt = this.runtime;
524
+ const turnId = job.turnId;
525
+ const title = `Agent 协作 · ${rt.adapters.get(job.agent).manifest.name}`;
526
+ // 「创建智能体」(spawnAgent) 与「执行中」(sendInput) 是两个独立的投影 item:
527
+ // spawn 在子会话就绪后立即结算为 done,否则 Desktop 原生协作卡片会在整个
528
+ // 执行期间一直停留在「创建中 N 个智能体」。
529
+ const spawnCallId = `collaboration:${randomUUID()}`;
530
+ const workCallId = `collaboration:${randomUUID()}`;
531
+ const emit = (event, operation, toolCallId) => {
532
+ if (rt.execution.lastTurn(parent.id)?.id === turnId) rt.emitCollaboration(parent.id, { ...event,
533
+ collaboration: { ...this.view(job), operation } });
534
+ };
535
+ // 已有子会话的后续输入(message_agent / resume / 团队持久成员)不涉及创建。
536
+ let spawnSettled = !!job.childId;
537
+ try {
538
+ if (!job.workspace) {
539
+ job.workspace = await createWorkspace(parent.cwd, job.id, job.isolation);
540
+ await this.save();
541
+ }
542
+ const workerPermMode = defaultWorkerPermissionMode(job.agent);
543
+ if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
544
+ const child = job.childId ? rt.threads.find(t => t.id === job.childId) : await rt.createThread({
545
+ harnessId: job.agent, cwd: job.workspace.cwd, title: `${parent.title} › ${task.slice(0, 40)}`, parentThreadId: parent.id,
546
+ options: { ...(workerPermMode ? { permissionMode: workerPermMode } : {}) },
547
+ onCreated: async thread => {
548
+ job.childId = thread.id;
549
+ const team = job.teamId ? this.teams.get(job.teamId) : null;
550
+ const member = team?.members.find(entry => entry.id === job.memberId);
551
+ if (member) { member.childId = thread.id; member.status = 'working'; team.updatedAt = Date.now(); await this.publishTeam(team, 'member_session_ready'); }
552
+ await this.save();
553
+ }
554
+ });
555
+ if (!child) throw new Error('Native child history is missing; no replacement session was created');
556
+ job.childId = child.id;
557
+ await this.save();
558
+ if (!spawnSettled) {
559
+ emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'done', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
560
+ spawnSettled = true;
561
+ }
562
+ emit({ kind: 'tool', toolCallId: workCallId, title, input: task, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
563
+ if (job.status !== 'running' || this.closing || !rt.execution.isRunning(parent.id)) { job.status = 'cancelled'; return; }
564
+ // Child native file events remain visible; only the lead snapshots the shared workspace.
565
+ const sending = rt.send(child.id, task, { collaborationOf: parent.id, isolated: job.workspace.mode === 'worktree' });
566
+ let sendDone = false, sendError;
567
+ void sending.then(() => { sendDone = true; }, error => { sendDone = true; sendError = error; });
568
+ const until = Date.now() + 30 * 60 * 1000;
569
+ let displayedStatus = 'running';
570
+ let turnInactiveSince = null;
571
+ while (job.status === 'running' && !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)) {
572
+ const childRunning = rt.execution.isRunning(child.id) || child.reviewPending;
573
+ if (!childRunning) {
574
+ if (!turnInactiveSince) turnInactiveSince = Date.now();
575
+ if (sendDone || Date.now() - turnInactiveSince > 2000) break;
576
+ } else {
577
+ turnInactiveSince = null;
578
+ }
579
+ const current = this.view(job).display_status;
580
+ if (current !== displayedStatus) { displayedStatus = current; emit({ kind: 'tool', toolCallId: workCallId, state: 'running', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId); }
581
+ if (Date.now() > until) { await rt.cancel(child.id); throw new Error('Subtask timed out after 30 minutes'); }
582
+ await delay(100);
583
+ }
584
+ if (job.status !== 'running') return;
585
+ if (sendError) throw sendError;
586
+ const turn = rt.execution.lastTurn(child.id);
587
+ if (!turn || turn.status === 'error') throw new Error(turn?.error || child.error || 'Subtask failed');
588
+ job.status = turn.status === 'cancelled' ? 'cancelled' : 'completed';
589
+ const messages = rt.core.getItemsForTurn(turn.id).filter(i => i.type === 'agent_message');
590
+ const finals = messages.filter(i => i.phase === 'final');
591
+ job.result = (finals.length ? finals : messages).map(i => i.content || '').join('\n').slice(0, 48000);
592
+ if (job.teamId) {
593
+ const team = this.teams.get(job.teamId);
594
+ const member = team?.members.find(entry => entry.id === job.memberId);
595
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
596
+ if (member) member.status = 'ready';
597
+ if (teamTask && teamTask.status === 'in_progress') {
598
+ teamTask.status = job.status === 'completed' ? 'completed' : job.status === 'cancelled' ? 'pending' : 'failed';
599
+ teamTask.result = job.result;
600
+ teamTask.updatedAt = Date.now();
601
+ for (const candidate of team.tasks) {
602
+ if (candidate.status === 'blocked' && candidate.dependsOn.every(id => team.tasks.find(entry => entry.id === id)?.status === 'completed')) candidate.status = 'pending';
603
+ }
604
+ }
605
+ if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_settled'); }
606
+ }
607
+ if (job.workspace?.mode === 'worktree' && job.status === 'completed') {
608
+ try {
609
+ const rev = await reviewWorkspace(job.workspace);
610
+ job.diff = rev.patch;
611
+ job.digest = rev.digest;
612
+ } catch {}
613
+ }
614
+ } catch (error) {
615
+ if (job.status === 'running') { job.status = 'failed'; job.error = error.message; }
616
+ if (job.teamId) {
617
+ const team = this.teams.get(job.teamId);
618
+ const member = team?.members.find(entry => entry.id === job.memberId);
619
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
620
+ if (member) member.status = 'ready';
621
+ if (teamTask) { teamTask.status = 'failed'; teamTask.result = error.message; teamTask.updatedAt = Date.now(); }
622
+ if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, 'task_failed'); }
623
+ }
624
+ }
625
+ finally {
626
+ await this.save();
627
+ // 创建阶段失败(如原生会话启动报错)时,把仍在「创建中」的 spawn 卡片结算为错误。
628
+ if (!spawnSettled) emit({ kind: 'tool', toolCallId: spawnCallId, title, input: task, state: 'error', output: JSON.stringify(this.view(job)) }, 'spawnAgent', spawnCallId);
629
+ emit({ kind: 'tool', toolCallId: workCallId, title, state: job.status === 'completed' ? 'done' : 'error', output: JSON.stringify(this.view(job)) }, 'sendInput', workCallId);
630
+ }
631
+ }
632
+
633
+ async cancel(job) {
634
+ if (job.status !== 'running') return;
635
+ job.status = this.closing ? 'interrupted' : 'cancelled';
636
+ job.cancelling = true;
637
+ try {
638
+ if (job.childId) {
639
+ await Promise.race([
640
+ this.runtime.cancel(job.childId),
641
+ new Promise(r => setTimeout(r, 3_000)),
642
+ ]).catch(() => {});
643
+ }
644
+ } finally {
645
+ job.cancelling = false;
646
+ if (job.teamId) {
647
+ const team = this.teams.get(job.teamId);
648
+ const member = team?.members.find(entry => entry.id === job.memberId);
649
+ const teamTask = team?.tasks.find(entry => entry.id === job.teamTaskId);
650
+ if (member) member.status = this.closing ? 'interrupted' : 'ready';
651
+ if (teamTask?.status === 'in_progress') { teamTask.status = this.closing ? 'interrupted' : 'pending'; teamTask.updatedAt = Date.now(); }
652
+ if (team) { this.refreshTeamStatus(team); await this.publishTeam(team, this.closing ? 'task_interrupted' : 'task_cancelled'); }
653
+ }
654
+ await this.save();
655
+ }
656
+ }
657
+
658
+ async cancelOwner(owner) {
659
+ const jobs = [...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running');
660
+ if (!jobs.length) return;
661
+ this.cancelling.add(owner);
662
+ try {
663
+ await Promise.race([
664
+ Promise.all(jobs.map(j => this.cancel(j))),
665
+ new Promise(r => setTimeout(r, 5_000)),
666
+ ]).catch(() => {});
667
+ } finally { this.cancelling.delete(owner); }
668
+ }
669
+ isParticipant(thread, owner) { return thread.id === owner || [...this.jobs.values()].some(j => j.owner === owner && j.childId === thread.id); }
670
+ async close() {
671
+ await this.initialize();
672
+ this.closing = true;
673
+ await Promise.all([...this.jobs.values()].map(j => this.cancel(j)));
674
+ await Promise.all([...this.jobs.values()].map(j => j.done));
675
+ this.keys.clear();
676
+ await this.save();
677
+ await this.saveTeams();
678
+ if (this.server) await new Promise(resolve => this.server.close(resolve));
679
+ }
680
+ }
681
+
682
+ function mentionedAgents(text, runtime) {
683
+ // Ignore code and email/package addresses; explicit links survive draft copy/paste.
684
+ const prose = text.replace(/```[\s\S]*?```|`[^`\n]*`/g, '');
685
+ const ids = new Set();
686
+ // CJK ideographs (\u4e00-\u9fff) and fullwidth/halfwidth forms are valid word boundaries,
687
+ // so #agent works in Chinese prose (for example 帮我#pi做这个). @ remains native Codex syntax.
688
+ 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)) {
689
+ const id = runtime.resolveHarnessId(match[1] || match[2]);
690
+ if (id) ids.add(id);
691
+ }
692
+ // Natural-language team requests may name Harnesses without using the picker.
693
+ // Require an explicit collaboration intent so ordinary product discussion such
694
+ // as "Codex UI" does not silently authorize cross-Harness delegation.
695
+ const teamIntent = /agent\s*team|团队|组队|协作|委派|调度|分工|成员|队长|主导者|\b(?:team|delegate|delegation|assign|member|teammate)\b/i.test(prose);
696
+ if (teamIntent) {
697
+ for (const adapter of runtime.adapters.values()) {
698
+ const labels = [adapter.manifest.id, adapter.manifest.name, ...(adapter.manifest.aliases ?? [])]
699
+ .filter(label => String(label ?? '').trim().length >= 2)
700
+ .sort((a, b) => String(b).length - String(a).length);
701
+ if (labels.some(label => containsPlainAgentName(prose, label))) ids.add(adapter.manifest.id);
702
+ }
703
+ }
704
+ return [...ids];
705
+ }
706
+
707
+ function containsPlainAgentName(text, label) {
708
+ const haystack = String(text).toLowerCase();
709
+ const needle = String(label).trim().toLowerCase();
710
+ for (let offset = haystack.indexOf(needle); offset !== -1; offset = haystack.indexOf(needle, offset + 1)) {
711
+ const before = offset === 0 ? '' : haystack[offset - 1];
712
+ const afterIndex = offset + needle.length;
713
+ const after = afterIndex === haystack.length ? '' : haystack[afterIndex];
714
+ if (plainNameBoundary(before) && plainNameBoundary(after)) return true;
715
+ }
716
+ return false;
717
+ }
718
+
719
+ function plainNameBoundary(char) {
720
+ return !char || /[\s\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff,;:!?()[\]{}"',。;:、!?“”‘’]/u.test(char);
721
+ }
722
+
723
+ module.exports = { Collaboration, mentionedAgents, defaultWorkerPermissionMode, teamTaskDepths, teamPhase, teamProgress };