pi-firecode 0.8.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/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/config.example.jsonc +100 -0
  4. package/config.ts +474 -0
  5. package/deliver.ts +32 -0
  6. package/flame-frames.ts +460 -0
  7. package/format.ts +80 -0
  8. package/header.ts +92 -0
  9. package/herdr-client.ts +60 -0
  10. package/index.ts +62 -0
  11. package/jsonc.ts +34 -0
  12. package/master/event-card.ts +106 -0
  13. package/master/event-format.ts +52 -0
  14. package/master/index.ts +1200 -0
  15. package/master/prompt.ts +26 -0
  16. package/master/prompts/master.zh.md +17 -0
  17. package/master/prompts/worker.zh.md +1 -0
  18. package/master/role.ts +13 -0
  19. package/master/spawn.ts +193 -0
  20. package/master/state.ts +201 -0
  21. package/package.json +59 -0
  22. package/provider/claude-sub.ts +129 -0
  23. package/provider/openai-native/index.ts +8 -0
  24. package/provider/openai-native/src/compact-client.ts +362 -0
  25. package/provider/openai-native/src/config.ts +297 -0
  26. package/provider/openai-native/src/extension.ts +93 -0
  27. package/provider/openai-native/src/native-compaction.ts +151 -0
  28. package/provider/openai-native/src/native-details.ts +157 -0
  29. package/provider/openai-native/src/native-replay.ts +253 -0
  30. package/provider/openai-native/src/native-runtime.ts +144 -0
  31. package/provider/openai-native/src/options.ts +85 -0
  32. package/provider/openai-native/src/request-pipeline.ts +21 -0
  33. package/provider/openai-native/src/responses-input.ts +433 -0
  34. package/review/advisor.ts +112 -0
  35. package/review/card.ts +385 -0
  36. package/review/checkpoint.ts +384 -0
  37. package/review/evidence.ts +232 -0
  38. package/review/index.ts +1246 -0
  39. package/review/outcome.ts +74 -0
  40. package/review/progress.ts +324 -0
  41. package/review/prompt.ts +210 -0
  42. package/review/prompts/advisor.en.md +41 -0
  43. package/review/prompts/advisor.zh.md +42 -0
  44. package/review/prompts/review.en.md +75 -0
  45. package/review/prompts/review.zh.md +75 -0
  46. package/review/reviewer.ts +356 -0
  47. package/review/session.ts +129 -0
  48. package/review/state.ts +905 -0
  49. package/review/ui.ts +528 -0
  50. package/session/bark.ts +157 -0
  51. package/session/herdr-display.ts +87 -0
  52. package/session/presets.ts +257 -0
  53. package/session/rename.ts +46 -0
  54. package/session/stats.ts +310 -0
  55. package/session/working-flame.ts +116 -0
  56. package/statusbar/index.ts +118 -0
  57. package/statusbar/quota-cache.ts +60 -0
  58. package/statusbar/quota-parse.ts +85 -0
  59. package/statusbar/quota.ts +203 -0
  60. package/statusbar/render.ts +181 -0
  61. package/statusbar/tps.ts +87 -0
  62. package/theme.ts +84 -0
  63. package/tools/grouping.ts +233 -0
  64. package/tools/index.ts +183 -0
  65. package/tools/line.ts +194 -0
  66. package/tools/parts.ts +143 -0
  67. package/tools/timing.ts +22 -0
  68. package/watcher/card.ts +85 -0
  69. package/watcher/index.ts +204 -0
  70. package/watcher/observer.ts +75 -0
  71. package/watcher/prompts/watch.zh.md +46 -0
  72. package/watcher/transcript.ts +64 -0
@@ -0,0 +1,1200 @@
1
+ import { statSync } from "node:fs";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
6
+ import {
7
+ getAgentDir,
8
+ isToolCallEventType,
9
+ ModelRuntime,
10
+ type AgentSession,
11
+ type AgentSessionEvent,
12
+ type ExtensionAPI,
13
+ type ExtensionContext,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { MASTER_ROLES, loadConfig, type MasterModelAtom, type MasterRole } from "../config.js";
16
+ import { deliver } from "../deliver.js";
17
+ import { formatDuration } from "../format.js";
18
+ import { readReviewOutcome, type ReviewOutcome } from "../review/outcome.js";
19
+ import { ToolLine, makeResultRenderer } from "../tools/line.js";
20
+ import type { Part } from "../tools/parts.js";
21
+ import { registerMasterEventRenderer } from "./event-card.js";
22
+ import { MASTER_EVENT_TYPE, masterEventDetails, sectionLine } from "./event-format.js";
23
+ import { assembleMasterPrompt, assembleWorkerPrompt, readMasterPrompt } from "./prompt.js";
24
+ import { InProcessSessionPool, preallocateWorkerSession } from "./spawn.js";
25
+ import {
26
+ MasterStore,
27
+ THINKING_LEVELS,
28
+ recoverMasterState,
29
+ loadMasterState,
30
+ masterStatePath,
31
+ requireWorker,
32
+ type MasterState,
33
+ type WorkerRef,
34
+ type WorkerStatus,
35
+ } from "./state.js";
36
+
37
+ const MASTER_TOOL = "subagents";
38
+ const MASTER_LIST_TOOL = "subagents_list";
39
+ const MASTER_TOOLS = [MASTER_TOOL, MASTER_LIST_TOOL];
40
+ const WORKER_TOOLS = ["read", "bash", "edit", "write"];
41
+ const PENDING_EVENT_TYPE = "firecode-master-pending-event";
42
+ const EVENT_ACK_TYPE = "firecode-master-event-ack";
43
+ const EVENT_RETRY_MS = 5_000;
44
+
45
+ interface PendingMasterEvent {
46
+ id: string;
47
+ content: string;
48
+ worker?: string;
49
+ }
50
+
51
+ interface MasterDependencies {
52
+ resolveModel?: (id: string) => Promise<Model<any>>;
53
+ pool?: InProcessSessionPool;
54
+ interruptResumeMs?: number;
55
+ }
56
+
57
+ interface CurrentTool {
58
+ tool: string;
59
+ startedAt: number;
60
+ }
61
+
62
+ interface ReviewProgress {
63
+ kind: "review";
64
+ round: number;
65
+ settled: number;
66
+ total: number;
67
+ }
68
+
69
+ interface WorkerTerminal {
70
+ text: string;
71
+ stopReason?: string;
72
+ errorMessage?: string;
73
+ }
74
+
75
+ interface ObservedSession {
76
+ session: AgentSession;
77
+ unsubscribe: () => void;
78
+ }
79
+
80
+ interface MasterRuntime {
81
+ ctx: ExtensionContext;
82
+ store: MasterStore;
83
+ pool: InProcessSessionPool;
84
+ events: PendingMasterEvent[];
85
+ currentTools: Map<string, Map<string, CurrentTool>>;
86
+ idleSince: Map<string, number>;
87
+ reviewProgress: Map<string, ReviewProgress>;
88
+ observedSessions: Map<string, ObservedSession>;
89
+ flushTimer?: NodeJS.Timeout;
90
+ }
91
+
92
+ export function registerMaster(
93
+ pi: ExtensionAPI,
94
+ dependencies: MasterDependencies = {},
95
+ worker = false,
96
+ ): void {
97
+ if (worker) {
98
+ pi.on("tool_call", async (event, ctx) => {
99
+ if (!isToolCallEventType("edit", event) && !isToolCallEventType("write", event)) return;
100
+ const reason = await outsideCheckoutReason(event.input.path, ctx.cwd);
101
+ if (reason) return { block: true, reason };
102
+ });
103
+ return;
104
+ }
105
+ let runtime: MasterRuntime | undefined;
106
+ const loaded = loadMasterConfiguration();
107
+ const prompts = loadMasterPrompts();
108
+ const startupError = "error" in loaded ? loaded.error : "error" in prompts ? prompts.error : undefined;
109
+ const roster = "error" in loaded ? [] : loaded.roles;
110
+ const exclusions = "error" in loaded ? [] : loaded.workerExcludeExtensions;
111
+ const autoActivate = "error" in loaded ? false : loaded.autoActivate;
112
+ const requirePrompts = () => {
113
+ if ("error" in prompts) throw new Error(prompts.error);
114
+ return prompts;
115
+ };
116
+ const reviewGate = reviewGateError();
117
+ const pool = dependencies.pool ?? new InProcessSessionPool();
118
+ const activeRuns = new Map<string, symbol>();
119
+ const interruptedRuns = new Map<string, symbol>();
120
+ const startingNames = new Set<string>();
121
+ const transitioningNames = new Set<string>();
122
+ const interruptTimers = new Map<string, NodeJS.Timeout>();
123
+ registerMasterEventRenderer(pi);
124
+
125
+ const setTools = (active: boolean) => {
126
+ const tools = pi.getActiveTools().filter((name) => !MASTER_TOOLS.includes(name));
127
+ pi.setActiveTools(active ? [...tools, ...MASTER_TOOLS] : tools);
128
+ };
129
+ const ownsRuntime = (active: MasterRuntime): boolean => runtime === active;
130
+ const requireRuntimeOwner = (active: MasterRuntime): void => {
131
+ if (!ownsRuntime(active)) throw new Error("Master 会话已替换,取消旧会话动作");
132
+ };
133
+ let spinFrame = 0;
134
+ let spinTimer: ReturnType<typeof setInterval> | undefined;
135
+ /** 计时器只在活动期存活:状态变化起停,全部落定即停,无常驻轮询。 */
136
+ const syncSpinner = (active: boolean) => {
137
+ if (active === (spinTimer !== undefined)) return;
138
+ if (!active) {
139
+ clearInterval(spinTimer);
140
+ spinTimer = undefined;
141
+ return;
142
+ }
143
+ spinTimer = setInterval(() => {
144
+ spinFrame += 1;
145
+ renderStatus();
146
+ }, SPINNER_MS);
147
+ spinTimer.unref?.();
148
+ };
149
+ const renderStatus = () => {
150
+ if (!runtime) return;
151
+ const workers = runtime.store.state.workers;
152
+ syncSpinner(masterActive(workers));
153
+ runtime.ctx.ui.setStatus("master", masterStatusLine(workers, runtime.ctx.ui.theme, spinFrame));
154
+ };
155
+ const activate = (ctx: ExtensionContext, restored?: MasterState): MasterRuntime => {
156
+ if (startupError) throw new Error(startupError);
157
+ if (runtime) {
158
+ runtime.ctx = ctx;
159
+ return runtime;
160
+ }
161
+ let active!: MasterRuntime;
162
+ const store = new MasterStore(masterStatePath(ctx.sessionManager.getSessionId()), restored, () => {
163
+ if (ownsRuntime(active)) renderStatus();
164
+ });
165
+ active = {
166
+ ctx,
167
+ store,
168
+ pool,
169
+ events: [],
170
+ currentTools: new Map(),
171
+ idleSince: new Map(),
172
+ reviewProgress: new Map(),
173
+ observedSessions: new Map(),
174
+ };
175
+ runtime = active;
176
+ setTools(true);
177
+ if (store.discardedLegacyVersion !== undefined)
178
+ ctx.ui.notify(`旧版 v${store.discardedLegacyVersion} 子代理池已丢弃并从空池重建;旧运行时进程不会纳入新池,请手动清理`, "warning");
179
+ // store 创建时 runtime 尚未就位,激活完成后只补这一次首绘。
180
+ renderStatus();
181
+ return active;
182
+ };
183
+ const deactivate = () => {
184
+ const active = runtime;
185
+ runtime = undefined;
186
+ pool.disposeAll();
187
+ syncSpinner(false);
188
+ for (const timer of interruptTimers.values()) clearTimeout(timer);
189
+ interruptTimers.clear();
190
+ activeRuns.clear();
191
+ interruptedRuns.clear();
192
+ startingNames.clear();
193
+ transitioningNames.clear();
194
+ if (active?.flushTimer) clearTimeout(active.flushTimer);
195
+ for (const observed of active?.observedSessions.values() ?? []) observed.unsubscribe();
196
+ active?.ctx.ui.setStatus("master", undefined);
197
+ setTools(false);
198
+ };
199
+ const flushEvents = (active: MasterRuntime) => {
200
+ if (!ownsRuntime(active)) return;
201
+ active.flushTimer = undefined;
202
+ if (!active.events.length) return;
203
+ const batch = active.events.splice(0);
204
+ const content = batch.map((event) => event.content).join("\n\n");
205
+ deliver(pi, active.ctx, {
206
+ customType: MASTER_EVENT_TYPE,
207
+ content: masterEventEnvelope(content),
208
+ details: masterEventDetails(batch.map((event) => event.content)),
209
+ }).then(() => {
210
+ if (!ownsRuntime(active)) return;
211
+ try {
212
+ pi.appendEntry(EVENT_ACK_TYPE, { ids: batch.map((event) => event.id) });
213
+ } catch (error) {
214
+ active.ctx.ui.notify(`子代理结果确认写入失败,reload 后可能重复投递:${String(error)}`, "warning");
215
+ }
216
+ for (const event of batch) {
217
+ if (!event.worker) continue;
218
+ const worker = active.store.state.workers.find((candidate) => candidate.name === event.worker);
219
+ if (worker?.status === "idle" && worker.disposition !== "reminded")
220
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: { ...worker, disposition: "pending" } });
221
+ }
222
+ }, (error) => {
223
+ if (!ownsRuntime(active)) return;
224
+ active.events.unshift(...batch);
225
+ active.ctx.ui.notify(`子代理结果投递失败,将自动重试:${String(error)}`, "warning");
226
+ active.flushTimer = setTimeout(() => flushEvents(active), EVENT_RETRY_MS);
227
+ active.flushTimer.unref?.();
228
+ });
229
+ };
230
+ const enqueueEvent = (
231
+ active: MasterRuntime,
232
+ content: string,
233
+ worker?: string,
234
+ persist = true,
235
+ id = crypto.randomUUID(),
236
+ ) => {
237
+ if (!ownsRuntime(active)) return;
238
+ const event: PendingMasterEvent = { id, content, ...(worker ? { worker } : {}) };
239
+ if (persist) {
240
+ try {
241
+ pi.appendEntry(PENDING_EVENT_TYPE, event);
242
+ } catch (error) {
243
+ active.ctx.ui.notify(`子代理结果持久化失败,crash 时可能丢失:${String(error)}`, "warning");
244
+ }
245
+ }
246
+ active.events.push(event);
247
+ if (!active.flushTimer) {
248
+ active.flushTimer = setTimeout(() => flushEvents(active), 0);
249
+ active.flushTimer.unref?.();
250
+ }
251
+ };
252
+ const clearInterruptTimer = (name: string) => {
253
+ const timer = interruptTimers.get(name);
254
+ if (timer) clearTimeout(timer);
255
+ interruptTimers.delete(name);
256
+ };
257
+ const armInterruptReminder = (active: MasterRuntime, worker: WorkerRef) => {
258
+ if (!ownsRuntime(active)) return;
259
+ clearInterruptTimer(worker.name);
260
+ const duration = dependencies.interruptResumeMs ?? 5 * 60_000;
261
+ const delay = Math.max(0, (worker.interruptedAt ?? Date.now()) + duration - Date.now());
262
+ const timer = setTimeout(() => {
263
+ if (!ownsRuntime(active)) return;
264
+ interruptTimers.delete(worker.name);
265
+ const current = active.store.state.workers.find((candidate) => candidate.name === worker.name);
266
+ if (!current?.interruptedAt || current.interruptedAt !== worker.interruptedAt) return;
267
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: { ...current, disposition: "reminded" } });
268
+ enqueueEvent(active, `子代理 ${worker.name} 自动续跑提醒:上次回合被外部中断,请 send 续派或 kill 收口`, worker.name);
269
+ }, delay);
270
+ timer.unref?.();
271
+ interruptTimers.set(worker.name, timer);
272
+ };
273
+ const activateSession = (ctx: ExtensionContext): MasterRuntime => {
274
+ if (runtime) return activate(ctx);
275
+ let restored: MasterState | undefined;
276
+ try {
277
+ restored = loadMasterState(masterStatePath(ctx.sessionManager.getSessionId()));
278
+ } catch {
279
+ return activate(ctx);
280
+ }
281
+ const active = activate(ctx, restored);
282
+ if (restored) {
283
+ const recovered = recoverMasterState(restored);
284
+ for (const worker of recovered.workers) {
285
+ if (worker !== restored.workers.find((candidate) => candidate.name === worker.name))
286
+ active.store.dispatch({ type: "UPSERT_WORKER", worker });
287
+ if (worker.interruptedAt) armInterruptReminder(active, worker);
288
+ }
289
+ }
290
+ for (const event of unackedEvents(ctx))
291
+ enqueueEvent(active, event.content, event.worker, false, event.id);
292
+ return active;
293
+ };
294
+ const currentWorker = (active: MasterRuntime, identity: WorkerRef) => {
295
+ requireRuntimeOwner(active);
296
+ const current = active.store.state.workers.find((worker) => worker.name === identity.name);
297
+ if (current?.sessionPath === identity.sessionPath) return current;
298
+ active.pool.dispose(identity.sessionPath);
299
+ throw new Error(`${identity.name} 已被 kill,取消本次动作`);
300
+ };
301
+ const openWorkerSession = async (active: MasterRuntime, worker: WorkerRef) => {
302
+ requireRuntimeOwner(active);
303
+ const hot = active.pool.getSession(worker.sessionPath);
304
+ if (hot) return hot;
305
+ const model = await (dependencies.resolveModel ?? resolveConfiguredModel)(worker.model);
306
+ requireRuntimeOwner(active);
307
+ const spawned = await active.pool.spawn({
308
+ cwd: worker.cwd ?? process.cwd(),
309
+ role: "worker",
310
+ model,
311
+ thinking: worker.thinking,
312
+ tools: WORKER_TOOLS,
313
+ excludeExtensions: exclusions,
314
+ systemPrompt: { mode: "append", text: assembleWorkerPrompt(requirePrompts().worker, worker.name) },
315
+ contextFiles: true,
316
+ persistence: { type: "file", sessionPath: worker.sessionPath, resume: true },
317
+ });
318
+ if (!ownsRuntime(active)) {
319
+ spawned.dispose();
320
+ requireRuntimeOwner(active);
321
+ }
322
+ return spawned.session;
323
+ };
324
+ const observeWorker = (active: MasterRuntime, sessionPath: string, session: AgentSession) => {
325
+ requireRuntimeOwner(active);
326
+ const previous = active.observedSessions.get(sessionPath);
327
+ if (previous?.session === session) return;
328
+ previous?.unsubscribe();
329
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
330
+ if (!ownsRuntime(active)) return;
331
+ if (event.type === "tool_execution_start") {
332
+ const tools = active.currentTools.get(sessionPath) ?? new Map<string, CurrentTool>();
333
+ tools.set(event.toolCallId, { tool: event.toolName, startedAt: Date.now() });
334
+ active.currentTools.set(sessionPath, tools);
335
+ }
336
+ if (event.type === "tool_execution_end") {
337
+ const tools = active.currentTools.get(sessionPath);
338
+ tools?.delete(event.toolCallId);
339
+ if (!tools?.size) active.currentTools.delete(sessionPath);
340
+ }
341
+ if (event.type === "entry_appended") {
342
+ const progress = reviewProgressFromEntry(event.entry);
343
+ if (progress) active.reviewProgress.set(sessionPath, progress);
344
+ }
345
+ });
346
+ active.observedSessions.set(sessionPath, { session, unsubscribe });
347
+ };
348
+ const runWorker = (active: MasterRuntime, worker: WorkerRef, session: Awaited<ReturnType<typeof openWorkerSession>>, prompt: string) => {
349
+ requireRuntimeOwner(active);
350
+ observeWorker(active, worker.sessionPath, session);
351
+ const run = Symbol(worker.name);
352
+ let terminal: WorkerTerminal | undefined;
353
+ activeRuns.set(worker.sessionPath, run);
354
+ const unsubscribeTerminal = session.subscribe((event) => {
355
+ if (!ownsRuntime(active) || activeRuns.get(worker.sessionPath) !== run || event.type !== "agent_end") return;
356
+ terminal = captureWorkerTerminal(event.messages);
357
+ });
358
+ const settled = async (error?: unknown) => {
359
+ unsubscribeTerminal();
360
+ if (!ownsRuntime(active) || activeRuns.get(worker.sessionPath) !== run) return;
361
+ activeRuns.delete(worker.sessionPath);
362
+ if (interruptedRuns.get(worker.sessionPath) === run) {
363
+ interruptedRuns.delete(worker.sessionPath);
364
+ const current = active.store.state.workers.find((candidate) => candidate.name === worker.name);
365
+ if (!current || current.sessionPath !== worker.sessionPath) return;
366
+ const interrupted: WorkerRef = { ...current, status: "idle", interruptedAt: Date.now() };
367
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: interrupted });
368
+ active.currentTools.delete(worker.sessionPath);
369
+ active.idleSince.set(worker.sessionPath, Date.now());
370
+ enqueueEvent(active, `子代理 ${worker.name} 已中断,会话与审查义务均已保留`, worker.name);
371
+ armInterruptReminder(active, interrupted);
372
+ return;
373
+ }
374
+ const fault = providerFaultReason(terminal);
375
+ if (fault && error === undefined) {
376
+ await resumeWithFallback(active, worker, session, terminal!, fault);
377
+ return;
378
+ }
379
+ const content = settleWorker(active, worker, terminal, error);
380
+ if (content) enqueueEvent(active, content, worker.name);
381
+ };
382
+ void session.prompt(prompt).then(() => void settled(), (error) => void settled(error));
383
+ };
384
+ const resumeWithFallback = async (
385
+ active: MasterRuntime,
386
+ identity: WorkerRef,
387
+ session: Awaited<ReturnType<typeof openWorkerSession>>,
388
+ terminal: WorkerTerminal,
389
+ reason: string,
390
+ ) => {
391
+ const current = currentWorker(active, identity);
392
+ const configuredRole = roster.find((entry) => entry.role === current.role);
393
+ if (!configuredRole) {
394
+ const failure = `${terminalFailure(terminal)}\n角色 ${current.role} 已不在角色表,无法 fallback`;
395
+ const content = settleWorker(active, current, terminal, new Error(failure));
396
+ if (content) enqueueEvent(active, content, current.name);
397
+ return;
398
+ }
399
+ const fallback = nextFallback(configuredRole, current);
400
+ if (!fallback) {
401
+ const failure = `${terminalFailure(terminal)}\n角色 ${current.role} 的 fallback 链已用尽`;
402
+ const content = settleWorker(active, current, terminal, new Error(failure));
403
+ if (content) enqueueEvent(active, content, current.name);
404
+ return;
405
+ }
406
+ try {
407
+ const model = await (dependencies.resolveModel ?? resolveConfiguredModel)(fallback.model);
408
+ requireRuntimeOwner(active);
409
+ await session.setModel(model);
410
+ requireRuntimeOwner(active);
411
+ session.setThinkingLevel(fallback.thinking);
412
+ const latest = currentWorker(active, current);
413
+ const switched: WorkerRef = { ...latest, ...fallback, status: "working" };
414
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: switched });
415
+ const from = modelAtomText(current);
416
+ const to = modelAtomText(fallback);
417
+ enqueueEvent(active, `子代理 ${current.name} 已切换 ${from}→${to}(${reason}),正在同一会话自动续跑`, current.name);
418
+ runWorker(active, switched, session, fallbackResumePrompt(from, to, reason));
419
+ } catch (error) {
420
+ const failure = `${terminalFailure(terminal)}\nfallback 切换失败:${error instanceof Error ? error.message : String(error)}`;
421
+ const content = settleWorker(active, current, terminal, new Error(failure));
422
+ if (content) enqueueEvent(active, content, current.name);
423
+ }
424
+ };
425
+
426
+ pi.registerCommand("fire-master", {
427
+ description: "翻转当前会话的指挥官模式;status 查看状态",
428
+ handler: async (args, ctx) => {
429
+ const input = args.trim();
430
+ if (input === "status") {
431
+ ctx.ui.notify(runtime ? statusText(runtime.store.state.workers) : "指挥官模式未启动", "info");
432
+ return;
433
+ }
434
+ if (input) {
435
+ ctx.ui.notify("/fire-master 只接受 status;裸命令翻转开关", "error");
436
+ return;
437
+ }
438
+ if (runtime) {
439
+ deactivate();
440
+ ctx.ui.notify("指挥官模式已关闭", "info");
441
+ return;
442
+ }
443
+ try {
444
+ activateSession(ctx);
445
+ ctx.ui.notify("指挥官模式已启动", "info");
446
+ } catch (error) {
447
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
448
+ }
449
+ },
450
+ });
451
+
452
+ pi.on("before_agent_start", async (event) => {
453
+ if (!runtime || !pi.getActiveTools().includes(MASTER_TOOL)) return;
454
+ return {
455
+ systemPrompt: `${event.systemPrompt}\n\n${assembleMasterPrompt(requirePrompts().master, rosterText(roster))}`,
456
+ };
457
+ });
458
+
459
+ pi.registerTool({
460
+ name: MASTER_LIST_TOOL,
461
+ label: "子代理",
462
+ description: "查看子代理池快照",
463
+ renderShell: "self",
464
+ renderCall: (_args, theme, ctx) =>
465
+ new ToolLine({ label: "子代理", value: subagentsCallParts({ action: "list" }), clip: "end", theme, ctx }),
466
+ renderResult: (result, options, theme, context) => {
467
+ const details = result.details as { workers?: unknown } | undefined;
468
+ context.state.meta = !context.isError && Array.isArray(details?.workers) ? listMeta(details.workers) : undefined;
469
+ if (options.expanded && Array.isArray(details?.workers))
470
+ return expandedWorkerList(details.workers, theme, context);
471
+ return renderSubagentsResult(result, options, theme, context);
472
+ },
473
+ parameters: Type.Object({}),
474
+ async execute() {
475
+ const active = runtime;
476
+ if (!active) throw new Error("subagents_list 只在 Master 中可用");
477
+ const workers = active.store.state.workers.map(compactWorker);
478
+ return {
479
+ content: [{ type: "text" as const, text: JSON.stringify({ workers }) }],
480
+ details: {
481
+ workers: workers.map((worker) => ({
482
+ ...worker,
483
+ currentAction: currentWorkerAction(active, worker),
484
+ })),
485
+ },
486
+ };
487
+ },
488
+ });
489
+
490
+ pi.registerTool({
491
+ name: MASTER_TOOL,
492
+ label: "子代理",
493
+ description: "指挥官的七动作子代理接口:start 按角色新建,send 续派或切换角色,interrupt 中断,review 显式审查,tail 读轨迹,ack 确认落定,kill 收口移除;无 sleep/session。",
494
+ renderShell: "self",
495
+ renderCall: (args, theme, ctx) =>
496
+ new ToolLine({ label: "子代理", value: subagentsCallParts(args as Record<string, unknown>), clip: "end", theme, ctx }),
497
+ renderResult: renderSubagentsResult,
498
+ parameters: Type.Object({
499
+ action: StringEnum(["start", "send", "interrupt", "review", "tail", "ack", "kill"] as const, {
500
+ description: "七动作之一;等待状态变化,不要用 sleep 轮询。",
501
+ }),
502
+ worker: Type.String({ description: "start 起简短任务名;其余动作填目标 Worker。" }),
503
+ prompt: Type.Optional(Type.String({ description: "start/send 必填自包含任务说明,包括交付物、限制与验证要求。" })),
504
+ role: Type.Optional(StringEnum(MASTER_ROLES, { description: "start 必填固定角色;send 可选,传入时切换角色,省略则沿用。" })),
505
+ thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "可选思考档覆盖;省略时使用角色原子档或当前档。" })),
506
+ cwd: Type.Optional(Type.String({ description: "仅 start 可选:Worker 工作目录的绝对路径,默认当前目录。" })),
507
+ review: Type.Optional(Type.Boolean({ description: "按审查纪律为 start/send 记录义务;true 不自动开审。" })),
508
+ }),
509
+ async execute(_id, params: Record<string, unknown>, _signal, _update, ctx) {
510
+ const active = runtime;
511
+ if (!active) throw new Error("subagents 只在 Master 中可用");
512
+ if (params.action === "kill") {
513
+ const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
514
+ clearInterruptTimer(target.name);
515
+ activeRuns.delete(target.sessionPath);
516
+ interruptedRuns.delete(target.sessionPath);
517
+ active.currentTools.delete(target.sessionPath);
518
+ active.idleSince.delete(target.sessionPath);
519
+ active.reviewProgress.delete(target.sessionPath);
520
+ active.observedSessions.get(target.sessionPath)?.unsubscribe();
521
+ active.observedSessions.delete(target.sessionPath);
522
+ active.pool.dispose(target.sessionPath);
523
+ active.store.dispatch({ type: "REMOVE_WORKER", name: target.name });
524
+ return toolResult({ killed: true });
525
+ }
526
+ if (params.action === "tail") {
527
+ const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
528
+ return { content: [{ type: "text" as const, text: await readWorkerTrace(target) }] };
529
+ }
530
+ if (params.action === "ack") {
531
+ const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
532
+ if (target.reviewNeeded) throw new Error(`${target.name} 此票有审查义务,完成 review 后才能 ack`);
533
+ if (target.status !== "idle") throw new Error(`${target.name} 正在 ${target.status},不能 ack`);
534
+ if (target.disposition) {
535
+ const { disposition: _disposition, ...rest } = target;
536
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: rest });
537
+ }
538
+ return toolResult({ acked: true });
539
+ }
540
+ if (params.action === "review") {
541
+ if (reviewGate) throw new Error(reviewGate);
542
+ const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
543
+ if (target.status !== "idle" || transitioningNames.has(target.name))
544
+ throw new Error(`${target.name} 正在处理其他动作,不能 review`);
545
+ transitioningNames.add(target.name);
546
+ try {
547
+ const session = await openWorkerSession(active, target);
548
+ await session.waitForIdle();
549
+ requireRuntimeOwner(active);
550
+ observeWorker(active, target.sessionPath, session);
551
+ const current = currentWorker(active, target);
552
+ const previousRunId = reviewRunId(readReviewOutcome(target.sessionPath));
553
+ const { disposition: _disposition, interruptedAt: _interruptedAt, ...rest } = current;
554
+ clearInterruptTimer(target.name);
555
+ const reviewing: WorkerRef = { ...rest, status: "reviewing" };
556
+ active.idleSince.delete(target.sessionPath);
557
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: reviewing });
558
+ void monitorReview(session, target.sessionPath, previousRunId).then(
559
+ (outcome) => {
560
+ if (!ownsRuntime(active)) return;
561
+ const current = active.store.state.workers.find((worker) => worker.name === target.name);
562
+ if (!current || current.sessionPath !== target.sessionPath || current.status !== "reviewing") return;
563
+ const { reviewNeeded: _needed, ...fulfilled } = current;
564
+ const worker = outcome.status === "passed" || outcome.status === "stopped"
565
+ ? fulfilled
566
+ : current;
567
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: { ...worker, status: "idle" } });
568
+ active.reviewProgress.delete(target.sessionPath);
569
+ active.idleSince.set(target.sessionPath, Date.now());
570
+ active.pool.markIdle(target.sessionPath);
571
+ enqueueEvent(active, reviewOutcomeText(target.name, outcome, session.messages), target.name);
572
+ },
573
+ (error) => {
574
+ if (!ownsRuntime(active)) return;
575
+ const current = active.store.state.workers.find((worker) => worker.name === target.name);
576
+ if (!current || current.status !== "reviewing") return;
577
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: { ...current, status: "idle" } });
578
+ active.reviewProgress.delete(target.sessionPath);
579
+ active.idleSince.set(target.sessionPath, Date.now());
580
+ active.pool.markIdle(target.sessionPath);
581
+ enqueueEvent(active, `子代理 ${target.name} 审查未完成:${String(error)}`, target.name);
582
+ },
583
+ );
584
+ return toolResult({ reviewing: true });
585
+ } finally {
586
+ if (ownsRuntime(active)) transitioningNames.delete(target.name);
587
+ }
588
+ }
589
+ if (params.action === "interrupt") {
590
+ const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
591
+ if (target.status !== "working") throw new Error(`${target.name} 当前是 ${target.status},不能 interrupt`);
592
+ const session = active.pool.getSession(target.sessionPath);
593
+ if (!session) throw new Error(`${target.name} 的进程内会话已释放,无法 interrupt`);
594
+ const run = activeRuns.get(target.sessionPath);
595
+ if (!run) throw new Error(`${target.name} 当前没有可中断的回合`);
596
+ interruptedRuns.set(target.sessionPath, run);
597
+ try {
598
+ await session.abort();
599
+ requireRuntimeOwner(active);
600
+ return toolResult({ interrupted: true });
601
+ } catch (error) {
602
+ if (ownsRuntime(active) && interruptedRuns.get(target.sessionPath) === run) interruptedRuns.delete(target.sessionPath);
603
+ throw error;
604
+ }
605
+ }
606
+ if (params.action === "send") {
607
+ if (params.review === true && reviewGate) throw new Error(reviewGate);
608
+ const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
609
+ if (target.status !== "idle" || transitioningNames.has(target.name))
610
+ throw new Error(`${target.name} 正在处理其他动作;急件先 interrupt 再 send`);
611
+ const requestedRole = optionalString(params.role);
612
+ const selection = requestedRole ? resolveRole(roster, requestedRole) : undefined;
613
+ const requestedThinking = optionalString(params.thinking);
614
+ if (requestedThinking && !THINKING_LEVELS.includes(requestedThinking as WorkerRef["thinking"]))
615
+ throw new Error(`thinking 值无效:${requestedThinking}`);
616
+ const prompt = requiredString(params.prompt, "prompt");
617
+ validateDelegationText(prompt);
618
+ transitioningNames.add(target.name);
619
+ try {
620
+ const nextModel = selection
621
+ ? await (dependencies.resolveModel ?? resolveConfiguredModel)(selection.model)
622
+ : undefined;
623
+ requireRuntimeOwner(active);
624
+ const session = await openWorkerSession(active, target);
625
+ await session.waitForIdle();
626
+ requireRuntimeOwner(active);
627
+ let role = target.role;
628
+ let model = target.model;
629
+ let thinking = target.thinking;
630
+ if (selection && nextModel) {
631
+ await session.setModel(nextModel);
632
+ requireRuntimeOwner(active);
633
+ role = selection.role;
634
+ model = selection.model;
635
+ thinking = selection.thinking;
636
+ }
637
+ if (selection || requestedThinking) {
638
+ thinking = requestedThinking as WorkerRef["thinking"] | undefined ?? thinking;
639
+ session.setThinkingLevel(thinking);
640
+ }
641
+ const current = currentWorker(active, target);
642
+ const { disposition: _disposition, interruptedAt, ...rest } = current;
643
+ const activeWorker: WorkerRef = {
644
+ ...rest,
645
+ role,
646
+ model,
647
+ thinking,
648
+ status: "working",
649
+ ...(params.review === true || target.reviewNeeded ? { reviewNeeded: true } : {}),
650
+ };
651
+ clearInterruptTimer(target.name);
652
+ active.idleSince.delete(target.sessionPath);
653
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: activeWorker });
654
+ const text = interruptedAt ? `${resumeCheckPrompt()}\n\n${prompt}` : prompt;
655
+ runWorker(active, activeWorker, session, text);
656
+ return toolResult({ sent: true });
657
+ } finally {
658
+ if (ownsRuntime(active)) transitioningNames.delete(target.name);
659
+ }
660
+ }
661
+ if (params.action !== "start") throw new Error(`未知 subagents action:${String(params.action)}`);
662
+ if (params.review === true && reviewGate) throw new Error(reviewGate);
663
+ if (typeof params.worker !== "string" || !params.worker.trim())
664
+ throw new Error("start 需要 worker:给子代理起个简短任务名(如 fix-auth、repo-scan)");
665
+ const name = params.worker.trim();
666
+ validateWorkerName(name);
667
+ if (active.store.state.workers.some((worker) => worker.name === name) || startingNames.has(name))
668
+ throw new Error(`子代理已存在:${name}`);
669
+ const inFlight = active.store.state.workers.filter((worker) => worker.status === "working" || worker.status === "reviewing");
670
+ if (inFlight.length + startingNames.size >= 15)
671
+ throw new Error(`Worker 并发上限 15,当前在飞:${[...inFlight.map((worker) => worker.name), ...startingNames].join("、")}`);
672
+ const prompt = requiredString(params.prompt, "prompt");
673
+ validateDelegationText(prompt);
674
+ const selectedRole = resolveRole(roster, requiredString(params.role, "start 必须指定 role"));
675
+ const requestedThinking = optionalString(params.thinking);
676
+ if (requestedThinking && !THINKING_LEVELS.includes(requestedThinking as WorkerRef["thinking"]))
677
+ throw new Error(`thinking 值无效:${requestedThinking}`);
678
+ const selection = {
679
+ ...selectedRole,
680
+ thinking: requestedThinking as WorkerRef["thinking"] | undefined ?? selectedRole.thinking,
681
+ };
682
+ startingNames.add(name);
683
+ try {
684
+ const cwd = await resolveWorkerCwd(optionalString(params.cwd) ?? ctx.cwd);
685
+ requireRuntimeOwner(active);
686
+ const mainSessionPath = ctx.sessionManager.getSessionFile?.();
687
+ if (!mainSessionPath) throw new Error("主会话尚未落盘,无法创建子代理会话目录");
688
+ const sessionPath = preallocateWorkerSession(mainSessionPath, cwd);
689
+ const worker: WorkerRef = {
690
+ name,
691
+ role: selection.role,
692
+ model: selection.model,
693
+ thinking: selection.thinking,
694
+ status: "working",
695
+ sessionPath,
696
+ cwd,
697
+ ...(params.review === true ? { reviewNeeded: true } : {}),
698
+ };
699
+ active.store.dispatch({ type: "UPSERT_WORKER", worker });
700
+ startingNames.delete(name);
701
+ try {
702
+ const model = await (dependencies.resolveModel ?? resolveConfiguredModel)(selection.model);
703
+ requireRuntimeOwner(active);
704
+ const spawned = await active.pool.spawn({
705
+ cwd,
706
+ role: "worker",
707
+ model,
708
+ thinking: selection.thinking,
709
+ tools: WORKER_TOOLS,
710
+ excludeExtensions: exclusions,
711
+ systemPrompt: { mode: "append", text: assembleWorkerPrompt(requirePrompts().worker, name) },
712
+ contextFiles: true,
713
+ persistence: { type: "file", sessionPath },
714
+ });
715
+ if (!ownsRuntime(active)) {
716
+ spawned.dispose();
717
+ requireRuntimeOwner(active);
718
+ }
719
+ runWorker(active, worker, spawned.session, prompt);
720
+ return toolResult({ started: true, worker: compactWorker(worker) });
721
+ } catch (error) {
722
+ if (ownsRuntime(active)) active.store.dispatch({ type: "REMOVE_WORKER", name });
723
+ throw error;
724
+ }
725
+ } finally {
726
+ if (ownsRuntime(active)) startingNames.delete(name);
727
+ }
728
+ },
729
+ });
730
+
731
+ pi.on("session_start", (_event, ctx) => {
732
+ deactivate();
733
+ if (!autoActivate) return;
734
+ try {
735
+ activateSession(ctx);
736
+ } catch (error) {
737
+ ctx.ui.notify(`指挥官模式恢复失败:${error instanceof Error ? error.message : String(error)}`, "error");
738
+ }
739
+ });
740
+
741
+ pi.on("session_shutdown", () => deactivate());
742
+ }
743
+
744
+ function settleWorker(
745
+ active: MasterRuntime,
746
+ identity: WorkerRef,
747
+ terminal: WorkerTerminal | undefined,
748
+ error?: unknown,
749
+ ): string | undefined {
750
+ const current = active.store.state.workers.find((worker) => worker.name === identity.name);
751
+ if (!current || current.sessionPath !== identity.sessionPath) return undefined;
752
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: { ...current, status: "idle" } });
753
+ active.currentTools.delete(identity.sessionPath);
754
+ active.idleSince.set(identity.sessionPath, Date.now());
755
+ const obligation = current.reviewNeeded ? "\n此票有审查义务,请显式 review。" : "";
756
+ const failure = error instanceof Error ? error.message : error === undefined ? terminalFailure(terminal) : String(error);
757
+ return failure
758
+ ? `子代理 ${identity.name} 已停下\n${sectionLine("error")}\n${failure}${obligation}`
759
+ : `子代理 ${identity.name} 已停下\n${sectionLine("reply")}\n${terminal!.text}${obligation}`;
760
+ }
761
+
762
+ function unackedEvents(ctx: ExtensionContext): PendingMasterEvent[] {
763
+ const entries = ctx.sessionManager.getEntries?.() ?? [];
764
+ const pending = new Map<string, PendingMasterEvent>();
765
+ const acked = new Set<string>();
766
+ for (const entry of entries) {
767
+ if (!entry || typeof entry !== "object") continue;
768
+ const record = entry as { type?: unknown; customType?: unknown; data?: unknown };
769
+ if (record.type !== "custom" || !record.data || typeof record.data !== "object") continue;
770
+ const data = record.data as Record<string, unknown>;
771
+ if (record.customType === PENDING_EVENT_TYPE && typeof data.id === "string" && typeof data.content === "string")
772
+ pending.set(data.id, { id: data.id, content: data.content, ...(typeof data.worker === "string" ? { worker: data.worker } : {}) });
773
+ if (record.customType === EVENT_ACK_TYPE && Array.isArray(data.ids))
774
+ for (const id of data.ids) if (typeof id === "string") acked.add(id);
775
+ }
776
+ return [...pending.values()].filter((event) => !acked.has(event.id));
777
+ }
778
+
779
+ function monitorReview(
780
+ session: { subscribe: (listener: (event: { type: string }) => void) => () => void; prompt: (text: string) => Promise<void> },
781
+ sessionPath: string,
782
+ previousRunId: string | undefined,
783
+ ): Promise<ReviewOutcome> {
784
+ return new Promise((resolve, reject) => {
785
+ let settled = false;
786
+ let unsubscribe = () => {};
787
+ const fail = (error: unknown) => {
788
+ if (settled) return;
789
+ settled = true;
790
+ unsubscribe();
791
+ reject(error);
792
+ };
793
+ const finish = (outcome: ReviewOutcome) => {
794
+ if (settled) return;
795
+ const runId = reviewRunId(outcome);
796
+ if (outcome.status !== "error"
797
+ && (!runId || runId === previousRunId || outcome.status === "in_progress" || outcome.status === "none")) return;
798
+ settled = true;
799
+ unsubscribe();
800
+ resolve(outcome);
801
+ };
802
+ unsubscribe = session.subscribe((event) => {
803
+ if (event.type === "entry_appended") finish(readReviewOutcome(sessionPath));
804
+ });
805
+ void session.prompt("/fire-review").then(
806
+ () => {
807
+ const outcome = readReviewOutcome(sessionPath);
808
+ const runId = reviewRunId(outcome);
809
+ if (outcome.status === "error") return finish(outcome);
810
+ if (!runId || runId === previousRunId)
811
+ return fail(new Error("fire-review 审查未启动"));
812
+ finish(outcome);
813
+ },
814
+ fail,
815
+ );
816
+ });
817
+ }
818
+
819
+ function reviewRunId(outcome: ReviewOutcome): string | undefined {
820
+ return "runId" in outcome ? outcome.runId : undefined;
821
+ }
822
+
823
+ function reviewOutcomeText(
824
+ name: string,
825
+ outcome: ReviewOutcome,
826
+ messages: Array<{ role: string; content?: unknown }>,
827
+ ): string {
828
+ const reply = latestAssistantText(messages) || "(无回复)";
829
+ if (outcome.status === "passed") return `子代理 ${name} 审查通过(${outcome.rounds} 轮)\n${sectionLine("finalReply")}\n${reply}`;
830
+ if (outcome.status === "stopped") return `子代理 ${name} 审查停止(${outcome.rounds} 轮)${outcome.advisorAdvice ? `:${outcome.advisorAdvice}` : ""}\n${sectionLine("finalReply")}\n${reply}`;
831
+ if (outcome.status === "failed") return `子代理 ${name} 审查未完成:${outcome.reason}\n${sectionLine("finalReply")}\n${reply}`;
832
+ if (outcome.status === "error") return `子代理 ${name} 审查读取失败:${outcome.message}`;
833
+ return `子代理 ${name} 审查未完成`;
834
+ }
835
+
836
+ function captureWorkerTerminal(
837
+ messages: Array<{ role: string; content?: unknown; stopReason?: string; errorMessage?: string }>,
838
+ ): WorkerTerminal | undefined {
839
+ const message = messages.findLast((candidate) => candidate.role === "assistant");
840
+ if (!message) return undefined;
841
+ return {
842
+ text: assistantText(message.content),
843
+ ...(message.stopReason ? { stopReason: message.stopReason } : {}),
844
+ ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}),
845
+ };
846
+ }
847
+
848
+ function terminalFailure(terminal: WorkerTerminal | undefined): string | undefined {
849
+ if (!terminal) return "回合结束但未产生 assistant 终态";
850
+ if (terminal.stopReason === "error") return terminal.errorMessage || "供应商返回未知错误";
851
+ if (terminal.stopReason === "aborted") return `回合意外中止:${terminal.errorMessage || "供应商未提供原因"}`;
852
+ if (!terminal.text) return "回合结束但未产生回复";
853
+ return undefined;
854
+ }
855
+
856
+ function providerFaultReason(terminal: WorkerTerminal | undefined): string | undefined {
857
+ if (terminal?.stopReason !== "error" || !terminal.errorMessage) return undefined;
858
+ const message = terminal.errorMessage;
859
+ if (/GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota (?:exceeded|exhausted)|billing/iu.test(message))
860
+ return "额度或计费耗尽";
861
+ if (/(?:model|deployment).*(?:not found|does not exist|unavailable|not available|unsupported)|(?:not found|unavailable).*(?:model|deployment)/iu.test(message))
862
+ return "模型不可用或找不到";
863
+ if (/\b(?:500|502|503|504|524)\b|service.?unavailable|internal.?server.?error/iu.test(message))
864
+ return "持续 5xx,宿主重试已用尽";
865
+ return undefined;
866
+ }
867
+
868
+ function latestAssistantText(messages: Array<{ role: string; content?: unknown }>): string {
869
+ const message = messages.findLast((candidate) => candidate.role === "assistant");
870
+ return assistantText(message?.content);
871
+ }
872
+
873
+ function assistantText(content: unknown): string {
874
+ if (!Array.isArray(content)) return "";
875
+ return content
876
+ .filter((part): part is { type: "text"; text: string } =>
877
+ !!part && typeof part === "object" && (part as { type?: unknown }).type === "text"
878
+ && typeof (part as { text?: unknown }).text === "string")
879
+ .map((part) => part.text)
880
+ .join("\n");
881
+ }
882
+
883
+ async function resolveConfiguredModel(id: string): Promise<Model<any>> {
884
+ const runtime = await ModelRuntime.create({
885
+ authPath: `${getAgentDir()}/auth.json`,
886
+ modelsPath: `${getAgentDir()}/models.json`,
887
+ });
888
+ const slash = id.indexOf("/");
889
+ const model = slash > 0 ? runtime.getModel(id.slice(0, slash), id.slice(slash + 1)) : undefined;
890
+ if (!model) throw new Error(`找不到模型:${id}`);
891
+ return model;
892
+ }
893
+
894
+ function reviewGateError(): string | undefined {
895
+ const loaded = loadConfig();
896
+ if (loaded.config.features.review === false) return "fire-review 已关闭,不能挂审查义务或发起审查";
897
+ const problems = loaded.problems.filter((problem) =>
898
+ problem.startsWith("review") || problem.startsWith("未知字段 review.") || problem.startsWith("config.jsonc"));
899
+ return problems.length ? `fire-review 配置有问题,已停止:${problems.join(";")}` : undefined;
900
+ }
901
+
902
+ function loadMasterPrompts() {
903
+ try {
904
+ return {
905
+ master: readMasterPrompt("master"),
906
+ worker: readMasterPrompt("worker"),
907
+ };
908
+ } catch (error) {
909
+ return { error: error instanceof Error ? error.message : String(error) };
910
+ }
911
+ }
912
+
913
+ function loadMasterConfiguration() {
914
+ let loaded: ReturnType<typeof loadConfig>;
915
+ try {
916
+ loaded = loadConfig();
917
+ } catch (error) {
918
+ return { error: `Master 配置读取失败:${error instanceof Error ? error.message : String(error)}` };
919
+ }
920
+ const problems = loaded.problems.filter((problem) => problem.startsWith("master") || problem.startsWith("未知字段 master.") || problem.startsWith("未知角色 master.") || problem.startsWith("config.jsonc") || problem.startsWith("features"));
921
+ if (problems.length) return { error: `Master 配置有问题,已停止:${problems.join(";")}` };
922
+ if (!loaded.config.master.roles.length) return { error: "Master 配置有问题,已停止:请在 master.roles 至少配置一个角色" };
923
+ return loaded.config.master;
924
+ }
925
+
926
+ function resolveRole(roles: MasterRole[], role: string): MasterRole {
927
+ const entry = roles.find((candidate) => candidate.role === role);
928
+ if (!entry) throw new Error(`角色未配置:${role}。已配置角色:${roles.map((candidate) => candidate.role).join("、")}`);
929
+ return entry;
930
+ }
931
+
932
+ function nextFallback(role: MasterRole, worker: WorkerRef): MasterModelAtom | undefined {
933
+ const chain: MasterModelAtom[] = [role, ...role.fallback];
934
+ let index = chain.findIndex((atom) => atom.model === worker.model && atom.thinking === worker.thinking);
935
+ if (index < 0) index = chain.findIndex((atom) => atom.model === worker.model);
936
+ return chain[index + 1];
937
+ }
938
+
939
+ function rosterText(models: MasterRole[]): string {
940
+ return models.map((entry) => {
941
+ const fallback = entry.fallback.length
942
+ ? `,fallback ${entry.fallback.map(modelAtomText).join(" → ")}`
943
+ : "";
944
+ return `${entry.role}:${modelAtomText(entry)}(${entry.use}${fallback})`;
945
+ }).join(";");
946
+ }
947
+
948
+ function modelAtomText(atom: Pick<MasterModelAtom, "model" | "thinking">): string {
949
+ return `${atom.model}/${atom.thinking}`;
950
+ }
951
+
952
+ function masterEventEnvelope(content: string): string {
953
+ return `<firecode_master_event>\n${content}\n</firecode_master_event>`;
954
+ }
955
+
956
+ function resumeCheckPrompt(): string {
957
+ return masterEventEnvelope("上次被外部中断,先核对 git status 与现场再继续,避免重复执行已经发生的副作用。");
958
+ }
959
+
960
+ function fallbackResumePrompt(from: string, to: string, reason: string): string {
961
+ return masterEventEnvelope(`供应商故障,已切换 ${from}→${to}(${reason})。沿用当前会话与原工作说明,从中断处继续,不要重复已经完成的副作用。`);
962
+ }
963
+
964
+ const ACTION_VERB: Record<string, string> = { start: "启动", list: "查看", kill: "移除", send: "发送", interrupt: "中断", review: "审查", tail: "近况", ack: "待命" };
965
+ function subagentsCallParts(args: Record<string, unknown>): Part[] {
966
+ const action = typeof args.action === "string" ? args.action : "?";
967
+ const parts: Part[] = [{ text: ACTION_VERB[action] ?? action, bold: true }];
968
+ const target = optionalString(args.worker);
969
+ if (target) parts.push({ text: ` ${target}`, color: "accent" });
970
+ const role = optionalString(args.role);
971
+ if ((action === "start" || action === "send") && role) parts.push({ text: ` · ${role}`, color: "muted" });
972
+ const prompt = optionalString(args.prompt)?.split("\n", 1)[0];
973
+ if (prompt && action === "start") parts.push({ text: ` — ${prompt}`, color: "muted" });
974
+ return parts;
975
+ }
976
+
977
+ const renderSubagentsResult = makeResultRenderer(false);
978
+ const STATUS_WORD = { working: "工作", idle: "空闲", reviewing: "审查" } satisfies Record<WorkerStatus, string>;
979
+ const SPINNER_FRAMES = [..."⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"];
980
+ const SPINNER_MS = 120;
981
+
982
+ function reviewProgressFromEntry(entry: unknown): ReviewProgress | undefined {
983
+ if (!entry || typeof entry !== "object") return undefined;
984
+ const record = entry as { type?: unknown; customType?: unknown; data?: unknown };
985
+ if (record.type !== "custom" || record.customType !== "firecode-review-checkpoint") return undefined;
986
+ if (!record.data || typeof record.data !== "object") return undefined;
987
+ const active = (record.data as { active?: unknown }).active;
988
+ if (!active || typeof active !== "object") return undefined;
989
+ const progress = active as { round?: unknown; settledCount?: unknown; reviewers?: unknown };
990
+ if (
991
+ typeof progress.round !== "number"
992
+ || typeof progress.settledCount !== "number"
993
+ || !Array.isArray(progress.reviewers)
994
+ ) return undefined;
995
+ return {
996
+ kind: "review",
997
+ round: progress.round,
998
+ settled: progress.settledCount,
999
+ total: progress.reviewers.length,
1000
+ };
1001
+ }
1002
+
1003
+ function currentWorkerAction(active: MasterRuntime, worker: ReturnType<typeof compactWorker>) {
1004
+ if (worker.status === "reviewing") return active.reviewProgress.get(worker.session);
1005
+ if (worker.status === "idle") {
1006
+ let since = active.idleSince.get(worker.session);
1007
+ try {
1008
+ since ??= statSync(worker.session).mtimeMs;
1009
+ } catch {
1010
+ // 缺失档案仍可 list;真正恢复时由 send 明确报错。
1011
+ }
1012
+ return { kind: "idle" as const, ...(since ? { since } : {}) };
1013
+ }
1014
+ const current = [...(active.currentTools.get(worker.session)?.values() ?? [])].at(-1);
1015
+ return current ? { kind: "tool" as const, ...current } : undefined;
1016
+ }
1017
+
1018
+ function expandedWorkerList(
1019
+ workers: unknown[],
1020
+ theme: ExtensionContext["ui"]["theme"],
1021
+ context: Parameters<typeof renderSubagentsResult>[3],
1022
+ ) {
1023
+ return {
1024
+ invalidate() {},
1025
+ render(width: number): string[] {
1026
+ return ["", ...workers.flatMap((value) => {
1027
+ const worker = value as Record<string, unknown>;
1028
+ const action = worker.currentAction as {
1029
+ kind?: string;
1030
+ tool?: string;
1031
+ startedAt?: number;
1032
+ since?: number;
1033
+ round?: number;
1034
+ settled?: number;
1035
+ total?: number;
1036
+ } | undefined;
1037
+ const actionParts: Part[] = action?.kind === "tool" && action.tool && action.startedAt
1038
+ ? [{
1039
+ text: ` · ${action.tool} · 已 ${formatDuration(Math.max(0, Date.now() - action.startedAt))}`,
1040
+ color: "accent",
1041
+ }]
1042
+ : action?.kind === "idle"
1043
+ ? [{
1044
+ text: action.since ? ` · 落定 ${formatDuration(Date.now() - action.since)}前` : " · 已落定",
1045
+ color: "muted",
1046
+ }]
1047
+ : action?.kind === "review"
1048
+ ? [{ text: ` · 第 ${action.round} 轮 · 审查者 ${action.settled}/${action.total}`, color: "accent" }]
1049
+ : [];
1050
+ return new ToolLine({
1051
+ label: String(worker.name),
1052
+ value: [
1053
+ { text: roleStatusText(worker), color: "accent" },
1054
+ ...actionParts,
1055
+ { text: ` · ${String(worker.model).split("/").pop()}/${String(worker.thinking)}`, color: "muted" },
1056
+ ],
1057
+ clip: "end",
1058
+ theme,
1059
+ ctx: { ...context, state: {}, expanded: false },
1060
+ }).render(width);
1061
+ })];
1062
+ },
1063
+ };
1064
+ }
1065
+ function listMeta(workers: unknown[]): Part[] {
1066
+ if (!workers.length) return [{ text: " — 池 0", color: "muted" }];
1067
+ return [{ text: ` — 池 ${workers.length}:${workers.map((value) => {
1068
+ const worker = value as Record<string, unknown>;
1069
+ return `${String(worker.name)} ${roleStatusText(worker)}`;
1070
+ }).join(" · ")}`, color: "muted" }];
1071
+ }
1072
+ /** 角色为主的状态投影:「工程师·工作」;档案缺角色时退到纯状态词。 */
1073
+ function roleStatusText(worker: { role?: unknown; status?: unknown }): string {
1074
+ const status = STATUS_WORD[worker.status as WorkerStatus] ?? String(worker.status);
1075
+ return worker.role ? `${String(worker.role)}·${status}` : status;
1076
+ }
1077
+ /** 有子代理在飞(工作或审查)时底栏活动动画运转,也是动画计时器的唯一起停判据。 */
1078
+ export function masterActive(workers: ReadonlyArray<Pick<WorkerRef, "status">>): boolean {
1079
+ return workers.some((worker) => worker.status !== "idle");
1080
+ }
1081
+ export function masterStatusLine(
1082
+ workers: ReadonlyArray<Pick<WorkerRef, "status" | "role">>,
1083
+ theme: Pick<ExtensionContext["ui"]["theme"], "fg">,
1084
+ frame = 0,
1085
+ ): string {
1086
+ if (!workers.length) return theme.fg("dim", "👑 指挥官");
1087
+ const spinner = masterActive(workers) ? `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ` : "";
1088
+ const byRole = new Map<string, number>();
1089
+ let idle = 0;
1090
+ for (const worker of workers) {
1091
+ if (worker.status === "idle") idle += 1;
1092
+ else {
1093
+ const initial = roleInitial(worker.role);
1094
+ byRole.set(initial, (byRole.get(initial) ?? 0) + 1);
1095
+ }
1096
+ }
1097
+ const counts = [...byRole].map(([initial, count]) => `${initial}${count}`);
1098
+ if (idle) counts.push(`闲${idle}`);
1099
+ return theme.fg("dim", `👑 ${spinner}${counts.join("·")}`);
1100
+ }
1101
+ /** 角色首字(按 code point 取,兼容 emoji 角色名);无角色时以工作态首字兜底。 */
1102
+ function roleInitial(role: string | undefined): string {
1103
+ return [...(role ?? "")][0] ?? STATUS_WORD.working[0]!;
1104
+ }
1105
+ export function statusText(workers: WorkerRef[]): string {
1106
+ return workers.length
1107
+ ? workers.map((worker) => `${worker.name} ${roleStatusText(worker)} ${worker.model.split("/").pop()}`).join("\n")
1108
+ : "没有子代理";
1109
+ }
1110
+ function compactWorker(worker: WorkerRef) {
1111
+ return {
1112
+ name: worker.name,
1113
+ role: worker.role,
1114
+ status: worker.status,
1115
+ model: worker.model,
1116
+ thinking: worker.thinking,
1117
+ session: worker.sessionPath,
1118
+ ...(worker.interruptedAt ? { interruptedAt: worker.interruptedAt } : {}),
1119
+ ...(worker.reviewNeeded ? { reviewNeeded: true } : {}),
1120
+ ...(worker.disposition ? { disposition: worker.disposition } : {}),
1121
+ };
1122
+ }
1123
+
1124
+ async function readWorkerTrace(worker: WorkerRef): Promise<string> {
1125
+ let raw: string;
1126
+ try {
1127
+ raw = await readFile(worker.sessionPath, "utf8");
1128
+ } catch (error) {
1129
+ throw new Error(`无法读取子代理 ${worker.name} 会话:${error instanceof Error ? error.message : String(error)}`);
1130
+ }
1131
+ const lines: string[] = [];
1132
+ for (const line of raw.split(/\r?\n/u)) {
1133
+ if (!line) continue;
1134
+ try {
1135
+ const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
1136
+ if (entry.type !== "message" || !entry.message?.role) continue;
1137
+ const text = messageText(entry.message.content);
1138
+ if (text) lines.push(`${entry.message.role}: ${text}`);
1139
+ } catch {
1140
+ // 正在追加的尾行可暂时不完整;近况保留此前完整记录。
1141
+ }
1142
+ }
1143
+ return `子代理 ${worker.name} 近况(${worker.status})\n${lines.join("\n").slice(-4_000)}`;
1144
+ }
1145
+
1146
+ function messageText(content: unknown): string {
1147
+ if (typeof content === "string") return content;
1148
+ if (!Array.isArray(content)) return "";
1149
+ return content
1150
+ .filter((part): part is { type: "text"; text: string } =>
1151
+ !!part && typeof part === "object" && (part as { type?: unknown }).type === "text"
1152
+ && typeof (part as { text?: unknown }).text === "string")
1153
+ .map((part) => part.text)
1154
+ .join("\n");
1155
+ }
1156
+ function toolResult(value: unknown) {
1157
+ return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
1158
+ }
1159
+ function requiredString(value: unknown, field: string): string {
1160
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${field} 不能为空`);
1161
+ return value.trim();
1162
+ }
1163
+ function optionalString(value: unknown): string | undefined {
1164
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1165
+ }
1166
+ function validateWorkerName(name: string): void {
1167
+ if (!/^[a-z][a-z0-9_-]{0,31}$/u.test(name)) throw new Error("Worker name 必须匹配 [a-z][a-z0-9_-]{0,31}");
1168
+ }
1169
+ function validateDelegationText(prompt: string): void {
1170
+ const text = prompt.trimStart();
1171
+ if (/^\/skills?:/u.test(text) && !text.startsWith("/skill:tdd ")) throw new Error("委派文本只允许 /skill:tdd 技能前缀");
1172
+ }
1173
+ async function resolveWorkerCwd(path: string): Promise<string> {
1174
+ if (!isAbsolute(path)) throw new Error("cwd 必须是已存在的绝对目录");
1175
+ try {
1176
+ return await realpath(path);
1177
+ } catch {
1178
+ throw new Error(`cwd 不存在:${path}`);
1179
+ }
1180
+ }
1181
+ async function outsideCheckoutReason(path: string, cwd: string): Promise<string | undefined> {
1182
+ const root = await realpath(cwd);
1183
+ const target = await canonicalWritePath(resolve(cwd, path));
1184
+ const local = relative(root, target);
1185
+ return local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local) ? `子代理只能修改当前 checkout:${path}` : undefined;
1186
+ }
1187
+ async function canonicalWritePath(path: string): Promise<string> {
1188
+ let ancestor = path;
1189
+ const missing: string[] = [];
1190
+ while (true) {
1191
+ try {
1192
+ return resolve(await realpath(ancestor), ...missing.reverse());
1193
+ } catch {
1194
+ const parent = dirname(ancestor);
1195
+ if (parent === ancestor) return path;
1196
+ missing.push(basename(ancestor));
1197
+ ancestor = parent;
1198
+ }
1199
+ }
1200
+ }