pi-firecode 0.8.1 → 1.0.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 (79) hide show
  1. package/README.md +35 -15
  2. package/activity.ts +92 -0
  3. package/busy.ts +188 -0
  4. package/config.example.jsonc +38 -47
  5. package/config.ts +198 -172
  6. package/deliver.ts +100 -13
  7. package/flame.ts +149 -0
  8. package/format.ts +84 -31
  9. package/header.ts +208 -82
  10. package/index.ts +17 -11
  11. package/master/actions.ts +293 -0
  12. package/master/activity-list.ts +281 -0
  13. package/master/event-card.ts +18 -37
  14. package/master/event-format.ts +71 -40
  15. package/master/guard.ts +46 -0
  16. package/master/index.ts +88 -1036
  17. package/master/list-view.ts +129 -0
  18. package/master/outbox.ts +188 -0
  19. package/master/prompts/master.zh.md +17 -12
  20. package/master/prompts/worker.zh.md +1 -1
  21. package/master/role.ts +6 -1
  22. package/master/run.ts +315 -0
  23. package/master/runtime.ts +259 -0
  24. package/master/spawn.ts +79 -38
  25. package/master/state.ts +30 -6
  26. package/master/worker-view.ts +517 -0
  27. package/package.json +1 -1
  28. package/provider/claude-sub.ts +35 -13
  29. package/provider/openai-native/src/compact-client.ts +2 -14
  30. package/provider/openai-native/src/options.ts +4 -2
  31. package/review/advisor.ts +0 -2
  32. package/review/card.ts +36 -23
  33. package/review/checkpoint.ts +2 -8
  34. package/review/evidence.ts +42 -44
  35. package/review/index.ts +271 -525
  36. package/review/occupancy.ts +25 -0
  37. package/review/outcome.ts +31 -7
  38. package/review/prompt.ts +7 -8
  39. package/review/prompts/review.en.md +7 -6
  40. package/review/prompts/review.zh.md +7 -6
  41. package/review/reviewer.ts +2 -5
  42. package/review/session.ts +11 -30
  43. package/review/state.ts +32 -60
  44. package/review/ui.ts +30 -469
  45. package/round-recorder.ts +18 -0
  46. package/session/herdr-display.ts +1 -1
  47. package/session/presets.ts +130 -87
  48. package/session/quota.ts +123 -0
  49. package/session/stats.ts +4 -1
  50. package/statusbar/index.ts +242 -104
  51. package/statusbar/render.ts +95 -163
  52. package/theme.ts +2 -41
  53. package/tools/actions.ts +53 -0
  54. package/tools/assistant-view.ts +64 -0
  55. package/tools/click-anchor.ts +56 -0
  56. package/tools/group-view.ts +420 -0
  57. package/tools/grouping.ts +146 -221
  58. package/tools/host.ts +369 -0
  59. package/tools/index.ts +108 -105
  60. package/tools/line.ts +95 -58
  61. package/tools/machine.ts +78 -0
  62. package/tools/parts.ts +2 -2
  63. package/tools/round.ts +95 -0
  64. package/tools/turn-clock.ts +41 -0
  65. package/tools/turn-summary.ts +109 -0
  66. package/truncated-write.ts +18 -0
  67. package/watcher/card.ts +28 -22
  68. package/watcher/index.ts +16 -44
  69. package/watcher/observer.ts +3 -3
  70. package/watcher/prompts/watch.zh.md +1 -1
  71. package/watcher/transcript.ts +4 -12
  72. package/flame-frames.ts +0 -460
  73. package/review/progress.ts +0 -324
  74. package/session/bark.ts +0 -157
  75. package/session/working-flame.ts +0 -116
  76. package/statusbar/quota-cache.ts +0 -60
  77. package/statusbar/quota-parse.ts +0 -85
  78. package/statusbar/quota.ts +0 -203
  79. package/statusbar/tps.ts +0 -87
package/master/run.ts ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * 回合编排:打开 Worker 会话、跑一个回合、按终态落定(成功/失败/中断/fallback 续跑)、审查监视与中断续跑提醒。
3
+ * 所有 await 之后的写回经 runtime.current/commit 重读档案:kill 赢过迟到的异步写回。
4
+ */
5
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
6
+ import type { MasterRole, ModelAtom } from "../config.js";
7
+ import { wrapEnvelope } from "../deliver.js";
8
+ import { clip, firstSentence, textOf } from "../format.js";
9
+ import { outcomeOfEntry, readReviewOutcome, reviewProgressOf, type ReviewOutcome } from "../review/outcome.js";
10
+ import { masterEvent, type MasterEvent } from "./event-format.js";
11
+ import { assembleWorkerPrompt } from "./prompt.js";
12
+ import type { MasterRuntime, WorkerLive } from "./runtime.js";
13
+ import type { WorkerRef } from "./state.js";
14
+
15
+ export const WORKER_TOOLS = ["read", "bash", "edit", "write"];
16
+ /** Worker 跟随指挥官是否启用 codemode;on/only 由 Worker 会话读到的同一份 settings 决定,不另传。 */
17
+ const CODEMODE_TOOL = "codemode";
18
+ const FAULT_SUMMARY_WIDTH = 80;
19
+ /** 算作“有输出”的子会话事件:模型 token 流与工具执行;活动列表据此判卡住。 */
20
+ const OUTPUT_EVENTS = new Set(["message_update", "tool_execution_start", "tool_execution_update", "tool_execution_end"]);
21
+
22
+ interface WorkerTerminal {
23
+ text: string;
24
+ stopReason?: string;
25
+ errorMessage?: string;
26
+ }
27
+
28
+ /** 新建(start)或恢复(send/review 冷启动)Worker 会话;会话路径是档案身份的唯一事实源。 */
29
+ export async function spawnWorker(active: MasterRuntime, worker: WorkerRef, resume: boolean): Promise<AgentSession> {
30
+ const { pool, exclusions } = active.setup;
31
+ const model = await pool.resolveModel(worker.model);
32
+ active.assertOpen();
33
+ const spawned = await pool.spawn({
34
+ cwd: worker.cwd ?? process.cwd(),
35
+ role: "worker",
36
+ model,
37
+ thinking: worker.thinking,
38
+ tools: active.setup.pi.getActiveTools().includes(CODEMODE_TOOL) ? [...WORKER_TOOLS, CODEMODE_TOOL] : WORKER_TOOLS,
39
+ excludeExtensions: exclusions,
40
+ systemPrompt: { mode: "append", text: assembleWorkerPrompt(active.setup.workerPrompt(), worker.name) },
41
+ contextFiles: true,
42
+ persistence: { type: "file", sessionPath: worker.sessionPath, ...(resume ? { resume: true } : {}) },
43
+ });
44
+ if (active.closed) {
45
+ await spawned.dispose();
46
+ active.assertOpen();
47
+ }
48
+ return spawned.session;
49
+ }
50
+
51
+ export async function openWorkerSession(active: MasterRuntime, worker: WorkerRef): Promise<AgentSession> {
52
+ active.assertOpen();
53
+ return active.setup.pool.getSession(worker.sessionPath) ?? spawnWorker(active, worker, true);
54
+ }
55
+
56
+ /** 投影当前动作、卡住判定与审查进度的唯一订阅。 */
57
+ export function observeWorker(active: MasterRuntime, worker: WorkerRef, session: AgentSession): void {
58
+ active.observe(worker, session, (live, event) => {
59
+ if (OUTPUT_EVENTS.has(event.type)) live.lastOutputAt = Date.now();
60
+ if (event.type === "tool_execution_start")
61
+ live.currentTools.set(event.toolCallId, { tool: event.toolName, args: event.args, startedAt: Date.now() });
62
+ if (event.type === "tool_execution_end") live.currentTools.delete(event.toolCallId);
63
+ if (event.type === "entry_appended") live.reviewProgress = reviewProgressOf(event.entry) ?? live.reviewProgress;
64
+ });
65
+ }
66
+
67
+ /** resolve 即回合已在飞:宿主 prompt 的前置阶段仍报空闲,期间 abort 会被丢弃,interrupt 必须晚于 preflight。 */
68
+ export async function runWorker(active: MasterRuntime, worker: WorkerRef, session: AgentSession, prompt: string): Promise<void> {
69
+ // 启动回合的路径都经这里:准备期间被 kill 的不调模型、不留热会话。
70
+ active.current(worker);
71
+ observeWorker(active, worker, session);
72
+ const live = active.liveOf(worker.name);
73
+ const run = Symbol(worker.name);
74
+ live.run = run;
75
+ const mine = () => !active.closed && active.live.get(worker.name) === live && live.run === run;
76
+ let terminal: WorkerTerminal | undefined;
77
+ const unsubscribeTerminal = session.subscribe((event) => {
78
+ if (mine() && event.type === "agent_end") terminal = captureWorkerTerminal(event.messages);
79
+ });
80
+ const settled = async (error?: unknown) => {
81
+ unsubscribeTerminal();
82
+ if (!mine()) return;
83
+ const stranded = session.clearQueue().steering;
84
+ if (stranded.length) active.outbox.enqueue(masterEvent.stranded(worker.name, stranded), worker.name);
85
+ live.run = undefined;
86
+ if (live.interruptedRun === run) {
87
+ live.interruptedRun = undefined;
88
+ settleInterrupted(active, worker);
89
+ return;
90
+ }
91
+ if (terminal?.stopReason === "error" && error === undefined) {
92
+ await resumeWithFallback(active, worker, session, terminal, faultSummary(terminal));
93
+ return;
94
+ }
95
+ settleWorker(active, worker, terminal, error);
96
+ };
97
+ const inflight = Promise.withResolvers<void>();
98
+ void session.prompt(prompt, { preflightResult: () => inflight.resolve() })
99
+ .then(() => settled(), settled)
100
+ .finally(() => inflight.resolve());
101
+ await inflight.promise;
102
+ }
103
+
104
+ function settleInterrupted(active: MasterRuntime, identity: WorkerRef): void {
105
+ const current = active.find(identity);
106
+ if (!current) return;
107
+ const interrupted: WorkerRef = { ...current, status: "idle", interruptedAt: Date.now() };
108
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: interrupted });
109
+ active.markIdle(interrupted, { kind: "interrupted" }, interrupted.interruptedAt);
110
+ active.outbox.enqueue(masterEvent.interrupted(identity.name, current.reviewNeeded === true, takeViewPrompts(active, identity)), identity.name);
111
+ }
112
+
113
+ /**
114
+ * 会话重载打断的回合:恢复后满时限仍未续派就提醒指挥官;按档案里的中断时刻补算剩余时间。指挥官自己 interrupt 的不提醒。
115
+ * 只提醒、不代发“继续”:工作说明归指挥官,插件盲续会复活它已作废的任务。
116
+ */
117
+ export function armInterruptReminder(active: MasterRuntime, worker: WorkerRef): void {
118
+ if (active.closed) return;
119
+ const live = active.liveOf(worker.name);
120
+ clearTimeout(live.interruptTimer);
121
+ const delay = Math.max(0, (worker.interruptedAt ?? Date.now()) + active.setup.interruptResumeMs - Date.now());
122
+ live.interruptTimer = setTimeout(() => {
123
+ if (active.closed || active.live.get(worker.name) !== live) return;
124
+ live.interruptTimer = undefined;
125
+ const current = active.find(worker);
126
+ if (!current?.interruptedAt || current.interruptedAt !== worker.interruptedAt) return;
127
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: { ...current, disposition: "reminded" } });
128
+ active.outbox.enqueue(masterEvent.resumeReminder(worker.name), worker.name);
129
+ }, delay);
130
+ live.interruptTimer.unref?.();
131
+ }
132
+
133
+ /**
134
+ * 任何 error 终态都沿角色链切换,不按关键词分类故障:供应商措辞多变,关键词白名单实测一次未命中,分类也不改变决策。
135
+ * 瞬时限流与 Claude 令牌换发在 error 之前已由宿主重试和 claude-sub 自愈处理。由插件而非宿主做:宿主不知道角色与档案。
136
+ */
137
+ async function resumeWithFallback(
138
+ active: MasterRuntime,
139
+ identity: WorkerRef,
140
+ session: AgentSession,
141
+ terminal: WorkerTerminal,
142
+ reason: string,
143
+ ): Promise<void> {
144
+ const current = active.current(identity);
145
+ const configuredRole = active.setup.roster.find((entry) => entry.role === current.role);
146
+ if (!configuredRole)
147
+ return settleWorker(active, current, terminal, new Error(`${terminalFailure(terminal)}\n角色 ${current.role} 已不在角色表,无法 fallback`));
148
+ const fallback = nextFallback(configuredRole, current);
149
+ if (!fallback)
150
+ return settleWorker(active, current, terminal, new Error(`${terminalFailure(terminal)}\n角色 ${current.role} 的 fallback 链已用尽`));
151
+ try {
152
+ const model = await active.setup.pool.resolveModel(fallback.model);
153
+ active.assertOpen();
154
+ await session.setModel(model);
155
+ active.assertOpen();
156
+ session.setThinkingLevel(fallback.thinking);
157
+ const switched = active.commit(current, (latest) => ({ ...latest, ...fallback, status: "working" }));
158
+ const from = modelAtomText(current);
159
+ const to = modelAtomText(fallback);
160
+ active.outbox.enqueue(masterEvent.modelSwitched(current.name, from, to, reason), current.name);
161
+ await runWorker(active, switched, session, fallbackResumePrompt(from, to, reason));
162
+ } catch (error) {
163
+ const failure = `${terminalFailure(terminal)}\nfallback 切换失败:${error instanceof Error ? error.message : String(error)}`;
164
+ settleWorker(active, current, terminal, new Error(failure));
165
+ }
166
+ }
167
+
168
+ function settleWorker(active: MasterRuntime, identity: WorkerRef, terminal: WorkerTerminal | undefined, error?: unknown): void {
169
+ const current = active.find(identity);
170
+ if (!current) return;
171
+ const failure = error instanceof Error ? error.message : error === undefined ? terminalFailure(terminal) : String(error);
172
+ const idle: WorkerRef = { ...current, status: "idle" };
173
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: idle });
174
+ // 活动列表展开行显示结果首句(失败是错误首句),不是千篇一律的“已返回”。
175
+ active.markIdle(idle, failure ? { kind: "failed", note: firstSentence(failure) } : { kind: "done", note: firstSentence(terminal!.text) });
176
+ const obligation = current.reviewNeeded === true;
177
+ const event: MasterEvent = failure
178
+ ? masterEvent.failed(identity.name, failure, obligation, takeViewPrompts(active, identity))
179
+ : masterEvent.returned(identity.name, terminal!.text, obligation, takeViewPrompts(active, identity));
180
+ active.outbox.enqueue(event, identity.name);
181
+ }
182
+
183
+ /** 起审:在原 Worker 会话里跑 /fire-review,终态经 review/outcome.ts 读取后落定并投递。 */
184
+ export function monitorAndSettleReview(active: MasterRuntime, target: WorkerRef, session: AgentSession, previousRunId: string | undefined): void {
185
+ const live = active.liveOf(target.name);
186
+ const reviewing = () => {
187
+ const current = active.closed || active.live.get(target.name) !== live ? undefined : active.find(target);
188
+ return current?.status === "reviewing" ? current : undefined;
189
+ };
190
+ void monitorReview(session, target.sessionPath, previousRunId).then(
191
+ (outcome) => {
192
+ const current = reviewing();
193
+ if (!current) return;
194
+ const { reviewNeeded: _needed, ...fulfilled } = current;
195
+ const idle: WorkerRef = { ...(outcome.status === "passed" || outcome.status === "stopped" ? fulfilled : current), status: "idle" };
196
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: idle });
197
+ active.markIdle(idle, reviewOutcomeRow(outcome));
198
+ active.outbox.enqueue(masterEvent.review(target.name, outcome, latestAssistantText(session.messages)), target.name);
199
+ },
200
+ (error) => {
201
+ const current = reviewing();
202
+ if (!current) return;
203
+ const idle: WorkerRef = { ...current, status: "idle" };
204
+ active.store.dispatch({ type: "UPSERT_WORKER", worker: idle });
205
+ active.markIdle(idle, { kind: "failed", note: "审查未完成" });
206
+ active.outbox.enqueue(masterEvent.reviewIncomplete(target.name, String(error)), target.name);
207
+ },
208
+ );
209
+ }
210
+
211
+ /** 这次运行里视图说过的话交给落定事件后清空:下一次运行另算。 */
212
+ function takeViewPrompts(active: MasterRuntime, worker: WorkerRef): string[] {
213
+ const live = active.liveOf(worker.name);
214
+ const prompts = live.viewPrompts;
215
+ live.viewPrompts = [];
216
+ return prompts;
217
+ }
218
+
219
+ export function reviewRunId(outcome: ReviewOutcome): string | undefined {
220
+ return "runId" in outcome ? outcome.runId : undefined;
221
+ }
222
+
223
+ function monitorReview(session: AgentSession, sessionPath: string, previousRunId: string | undefined): Promise<ReviewOutcome> {
224
+ return new Promise((resolve, reject) => {
225
+ let done = false;
226
+ let unsubscribe = () => {};
227
+ const fail = (error: unknown) => {
228
+ if (done) return;
229
+ done = true;
230
+ unsubscribe();
231
+ reject(error);
232
+ };
233
+ const finish = (outcome: ReviewOutcome) => {
234
+ if (done) return;
235
+ const runId = reviewRunId(outcome);
236
+ if (outcome.status !== "error"
237
+ && (!runId || runId === previousRunId || outcome.status === "in_progress" || outcome.status === "none")) return;
238
+ done = true;
239
+ unsubscribe();
240
+ resolve(outcome);
241
+ };
242
+ // 只看刚追加的那条记录,不每条都重读整份 JSONL;回合结束时再读一次文件兜底。
243
+ unsubscribe = session.subscribe((event) => {
244
+ const outcome = event.type === "entry_appended" ? outcomeOfEntry(event.entry) : undefined;
245
+ if (outcome) finish(outcome);
246
+ });
247
+ void session.prompt("/fire-review").then(
248
+ () => {
249
+ const outcome = readReviewOutcome(sessionPath);
250
+ const runId = reviewRunId(outcome);
251
+ if (outcome.status === "error") return finish(outcome);
252
+ if (!runId || runId === previousRunId) return fail(new Error("fire-review 审查未启动"));
253
+ finish(outcome);
254
+ },
255
+ fail,
256
+ );
257
+ });
258
+ }
259
+
260
+ /** 审查落定在活动列表上的结局:只有通过算完成,停止与未完成都是要指挥官看的失败行。 */
261
+ function reviewOutcomeRow(outcome: ReviewOutcome): WorkerLive["outcome"] {
262
+ if (outcome.status === "passed") return { kind: "done", note: "审查通过" };
263
+ return { kind: "failed", note: outcome.status === "stopped" ? "审查停止" : "审查未完成" };
264
+ }
265
+
266
+ function captureWorkerTerminal(
267
+ messages: Array<{ role: string; content?: unknown; stopReason?: string; errorMessage?: string }>,
268
+ ): WorkerTerminal | undefined {
269
+ const message = messages.findLast((candidate) => candidate.role === "assistant");
270
+ if (!message) return undefined;
271
+ return {
272
+ text: textOf(message.content),
273
+ ...(message.stopReason ? { stopReason: message.stopReason } : {}),
274
+ ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}),
275
+ };
276
+ }
277
+
278
+ function terminalFailure(terminal: WorkerTerminal | undefined): string | undefined {
279
+ if (!terminal) return "回合结束但未产生 assistant 终态";
280
+ if (terminal.stopReason === "error") return terminal.errorMessage || "供应商返回未知错误";
281
+ if (terminal.stopReason === "aborted") return `回合意外中止:${terminal.errorMessage || "供应商未提供原因"}`;
282
+ if (!terminal.text) return "回合结束但未产生回复";
283
+ return undefined;
284
+ }
285
+
286
+ function faultSummary(terminal: WorkerTerminal): string {
287
+ const message = terminal.errorMessage?.trim();
288
+ if (!message) return "供应商返回未知错误";
289
+ const [first = message] = message.split(/(?<=[.。!!??])\s|\n/u);
290
+ return clip(first.trim(), FAULT_SUMMARY_WIDTH);
291
+ }
292
+
293
+ function latestAssistantText(messages: Array<{ role: string; content?: unknown }>): string {
294
+ return textOf(messages.findLast((candidate) => candidate.role === "assistant")?.content);
295
+ }
296
+
297
+ function nextFallback(role: MasterRole, worker: WorkerRef): ModelAtom | undefined {
298
+ const chain: ModelAtom[] = [role, ...role.fallback];
299
+ let index = chain.findIndex((atom) => atom.model === worker.model && atom.thinking === worker.thinking);
300
+ if (index < 0) index = chain.findIndex((atom) => atom.model === worker.model);
301
+ return chain[index + 1];
302
+ }
303
+
304
+ export function modelAtomText(atom: Pick<ModelAtom, "model" | "thinking">): string {
305
+ return `${atom.model}/${atom.thinking}`;
306
+ }
307
+
308
+ /** 首次续派前置的现场核对提示。 */
309
+ export function resumeCheckPrompt(): string {
310
+ return wrapEnvelope("firecode_master_event", "上次被外部中断,先核对 git status 与现场再继续,避免重复执行已经发生的副作用。");
311
+ }
312
+
313
+ function fallbackResumePrompt(from: string, to: string, reason: string): string {
314
+ return wrapEnvelope("firecode_master_event", `供应商故障,已切换 ${from}→${to}(${reason})。沿用当前会话与原工作说明,从中断处继续,不要重复已经完成的副作用。`);
315
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 一个指挥官会话的运行时:持久化档案(store)、按名字索引的 Worker 运行时事实(live)、事件发件箱与活动列表。
3
+ * 运行时事实只在进程内;档案与 JSONL 才是身份与续派的事实源。会话关闭后 closed 置位,迟到的异步续延一律作废。
4
+ */
5
+ import { getAgentDir, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { MasterRole } from "../config.js";
7
+ import { HEAT_COLORS, paint } from "../flame.js";
8
+ import type { ReviewProgress } from "../review/outcome.js";
9
+ import { ActivityList, visibleRows, type ActivityFacts, type SettledFact } from "./activity-list.js";
10
+ import { Outbox } from "./outbox.js";
11
+ import { openWorkerView } from "./worker-view.js";
12
+ import type { InProcessSessionPool } from "./spawn.js";
13
+ import { MasterStore, masterStatePath, type MasterState, type WorkerRef } from "./state.js";
14
+
15
+ /** 注册时定下、跨会话不变的配置与依赖。 */
16
+ export interface MasterSetup {
17
+ pi: ExtensionAPI;
18
+ pool: InProcessSessionPool;
19
+ roster: MasterRole[];
20
+ exclusions: string[];
21
+ /** Worker 系统提示;提示词文件坏了时抛错。 */
22
+ workerPrompt(): string;
23
+ /** fire-review 不可用的原因;可用时为 undefined。 */
24
+ reviewGate?: string;
25
+ interruptResumeMs: number;
26
+ /** 指挥官空闲时合并唤醒的安静窗口(见 outbox.ts)。 */
27
+ wakeQuietMs: number;
28
+ /** 在飞子代理数的唯一发布口(跨会话保持上次发布值以便配对)。 */
29
+ publishInFlight(count: number): void;
30
+ /** 本段会话进行中的起点(busy.ts 的 since,与上边框计时同一事实);歇下时为 undefined。 */
31
+ sessionSince(): number | undefined;
32
+ }
33
+
34
+ export interface CurrentTool {
35
+ tool: string;
36
+ args: unknown;
37
+ startedAt: number;
38
+ }
39
+
40
+ /**
41
+ * 这次运行是谁派的。在飞数的定义是“指挥官在等结果的运行”:用户在全过程视图里直接派的运行指挥官并不在等,
42
+ * 不计入在飞数(主会话不因它进入进行中、不产生主会话轮记录),落定事件只告知不唤醒;
43
+ * 指挥官在这次运行中途又 send 给它时转为指挥官的(它从此在等)。
44
+ */
45
+ export type RunOrigin = "master" | "view";
46
+
47
+ /** 一个 Worker 名下的全部运行时事实;kill 时整条删除。 */
48
+ export interface WorkerLive {
49
+ /** start 已占名但档案尚未落盘。 */
50
+ starting?: true;
51
+ /** send/review 的准备过程单飞。 */
52
+ transitioning?: true;
53
+ /** 当前回合的令牌;落定回调只认自己的令牌。 */
54
+ run?: symbol;
55
+ /** interrupt 标记的回合:它落定时按中断处理。 */
56
+ interruptedRun?: symbol;
57
+ interruptTimer?: NodeJS.Timeout;
58
+ /** 本次运行(start/send/review 投递)的起点:活动列表的实时耗时,以及从子代理会话里挑出属于这次运行的轮记录。 */
59
+ runStartedAt?: number;
60
+ /** 这次运行里用户在子代理全过程视图直接说的话;落定事件据此注明来源。 */
61
+ viewPrompts: string[];
62
+ origin: RunOrigin;
63
+ /** 最近一次输出(模型 token 或工具事件),活动列表据此判卡住。 */
64
+ lastOutputAt?: number;
65
+ currentTools: Map<string, CurrentTool>;
66
+ reviewProgress?: ReviewProgress;
67
+ /** 最近一次落定的时刻:池起释放计时、列表“落定 X 前”与活动列表冻结耗时共用这一份。 */
68
+ idleAt?: number;
69
+ /** 活动列表上的落定结局;ack 清掉失败与被中断,完成留到 kill。 */
70
+ outcome?: Omit<SettledFact, "at">;
71
+ observed?: { session: AgentSession; sessionPath: string; unsubscribe: () => void };
72
+ }
73
+
74
+ const LIST_WIDGET_KEY = "firecode-master-list";
75
+ /** 边框身份:纯文字,子代理状态由输入框上方的活动列表承担。 */
76
+ const MASTER_IDENTITY = paint(HEAT_COLORS.orange, "指挥官");
77
+
78
+ export class MasterRuntime {
79
+ readonly store: MasterStore;
80
+ readonly outbox: Outbox;
81
+ readonly live = new Map<string, WorkerLive>();
82
+ /** 最近取出的启动序;接着档案里已有的最大值,恢复后新 start 仍排在后面。 */
83
+ private launchSeq: number;
84
+ private list?: ActivityList;
85
+ private closedValue = false;
86
+ private readonly stopReleaseWatch: () => void;
87
+ /** 子代理会话被接上订阅(冷启动或重开)时通知:全过程视图据此在第一条事件之前接上自己的订阅。 */
88
+ private readonly sessionListeners = new Set<(name: string) => void>();
89
+ /** 子代理被移除(kill 或启动失败撤票)时通知:全过程视图据此显示已移除。 */
90
+ private readonly removedListeners = new Set<(name: string) => void>();
91
+
92
+ constructor(readonly setup: MasterSetup, public ctx: ExtensionContext, restored?: MasterState) {
93
+ this.outbox = new Outbox(this);
94
+ this.store = new MasterStore(masterStatePath(getAgentDir(), ctx.sessionManager.getSessionId()), restored, () => this.render());
95
+ this.launchSeq = Math.max(0, ...this.store.state.workers.map((worker) => worker.launch));
96
+ // 池空闲释放热会话后放掉订阅:不再持有已关闭的会话。
97
+ this.stopReleaseWatch = setup.pool.onRelease((sessionPath) => {
98
+ for (const live of this.live.values())
99
+ if (live.observed?.sessionPath === sessionPath) this.unobserve(live);
100
+ });
101
+ ctx.ui.setWidget(LIST_WIDGET_KEY, (tui, theme) => {
102
+ this.list = new ActivityList(tui, theme, () => this.activityFacts(),
103
+ () => visibleRows(tui.terminal?.rows), (name) => void openWorkerView(this, name));
104
+ return this.list;
105
+ }, { placement: "aboveEditor" });
106
+ }
107
+
108
+ get closed(): boolean {
109
+ return this.closedValue;
110
+ }
111
+
112
+ assertOpen(): void {
113
+ if (this.closedValue) throw new Error("Master 会话已替换,取消旧会话动作");
114
+ }
115
+
116
+ /** 事实变化后的唯一重绘入口:在飞数、边框身份与活动列表。 */
117
+ render(): void {
118
+ if (this.closedValue) return;
119
+ this.outbox.scheduleInFlight();
120
+ this.ctx.ui.setStatus("master", MASTER_IDENTITY);
121
+ this.list?.sync();
122
+ }
123
+
124
+ /** 新的一轮(人类输入)开始:活动列表的展开收起。 */
125
+ collapseList(): void {
126
+ this.list?.collapse();
127
+ }
128
+
129
+ liveOf(name: string): WorkerLive {
130
+ let live = this.live.get(name);
131
+ if (!live) this.live.set(name, live = { currentTools: new Map(), viewPrompts: [], origin: "master" });
132
+ return live;
133
+ }
134
+
135
+ /** start 同步段占名并取启动序(并发 start 越过后续 await 的先后不定,序号必须在此取)。 */
136
+ reserve(name: string): { live: WorkerLive; launch: number } {
137
+ const live: WorkerLive = { currentTools: new Map(), viewPrompts: [], origin: "master", starting: true };
138
+ this.live.set(name, live);
139
+ return { live, launch: ++this.launchSeq };
140
+ }
141
+
142
+ /**
143
+ * 移除子代理的唯一入口:删名下全部运行时事实与档案,档案确有这一票时通知订阅方。
144
+ * 运行时事实只删属于 expected 的那一条(kill 后同名重开的新票不受影响)。
145
+ */
146
+ remove(name: string, expected = this.live.get(name)): void {
147
+ const live = this.live.get(name);
148
+ if (live && live === expected) {
149
+ clearTimeout(live.interruptTimer);
150
+ if (live.observed) this.unobserve(live);
151
+ this.live.delete(name);
152
+ }
153
+ const before = this.store.state;
154
+ if (this.store.dispatch({ type: "REMOVE_WORKER", name }) === before) return;
155
+ for (const notify of this.removedListeners) notify(name);
156
+ }
157
+
158
+ /** await 之后的唯一重读点:档案已被 kill(或同名换票)就释放热会话并放弃本次动作。 */
159
+ current(identity: Pick<WorkerRef, "name" | "sessionPath">): WorkerRef {
160
+ this.assertOpen();
161
+ const current = this.store.state.workers.find((worker) => worker.name === identity.name);
162
+ if (current?.sessionPath === identity.sessionPath) return current;
163
+ void this.setup.pool.dispose(identity.sessionPath);
164
+ throw new Error(`${identity.name} 已被 kill,取消本次动作`);
165
+ }
166
+
167
+ /** await 之后的写回只经这里:基于重读的最新档案做函数式更新,不拿 await 前的快照覆盖。 */
168
+ commit(identity: Pick<WorkerRef, "name" | "sessionPath">, update: (current: WorkerRef) => WorkerRef): WorkerRef {
169
+ const next = update(this.current(identity));
170
+ this.store.dispatch({ type: "UPSERT_WORKER", worker: next });
171
+ return next;
172
+ }
173
+
174
+ /** 同步读:档案仍是这一票时返回,否则 undefined(落定回调用,不抛)。 */
175
+ find(identity: Pick<WorkerRef, "name" | "sessionPath">): WorkerRef | undefined {
176
+ const current = this.store.state.workers.find((worker) => worker.name === identity.name);
177
+ return current?.sessionPath === identity.sessionPath ? current : undefined;
178
+ }
179
+
180
+ /** Worker 闲下来的唯一登记点:记落定时刻与结局,池据此起释放计时。 */
181
+ markIdle(worker: WorkerRef, outcome: WorkerLive["outcome"], at = Date.now()): void {
182
+ const live = this.liveOf(worker.name);
183
+ live.idleAt = at;
184
+ live.outcome = outcome;
185
+ live.currentTools.clear();
186
+ live.reviewProgress = undefined;
187
+ this.setup.pool.markIdle(worker.sessionPath);
188
+ }
189
+
190
+ /** 开始一次运行(start/send/review):起点与来源归这一次,旧的中断提醒作废。 */
191
+ beginRun(name: string, origin: RunOrigin = "master"): void {
192
+ const live = this.liveOf(name);
193
+ clearTimeout(live.interruptTimer);
194
+ live.interruptTimer = undefined;
195
+ live.runStartedAt = Date.now();
196
+ live.viewPrompts = [];
197
+ live.origin = origin;
198
+ }
199
+
200
+ /** 每个 Worker 只挂一个会话订阅;换了会话(释放后重开)才重挂。 */
201
+ observe(worker: WorkerRef, session: AgentSession, listener: (live: WorkerLive, event: AgentSessionEvent) => void): void {
202
+ this.assertOpen();
203
+ const live = this.liveOf(worker.name);
204
+ if (live.observed?.session === session) return;
205
+ if (live.observed) this.unobserve(live);
206
+ const unsubscribe = session.subscribe((event) => {
207
+ if (!this.closedValue && this.live.get(worker.name) === live) listener(live, event);
208
+ });
209
+ live.observed = { session, sessionPath: worker.sessionPath, unsubscribe };
210
+ for (const notify of this.sessionListeners) notify(worker.name);
211
+ }
212
+
213
+ onWorkerSession(listener: (name: string) => void): () => void {
214
+ this.sessionListeners.add(listener);
215
+ return () => this.sessionListeners.delete(listener);
216
+ }
217
+
218
+ onWorkerRemoved(listener: (name: string) => void): () => void {
219
+ this.removedListeners.add(listener);
220
+ return () => this.removedListeners.delete(listener);
221
+ }
222
+
223
+ close(): void {
224
+ this.closedValue = true;
225
+ this.stopReleaseWatch();
226
+ for (const live of this.live.values()) {
227
+ clearTimeout(live.interruptTimer);
228
+ if (live.observed) this.unobserve(live);
229
+ }
230
+ this.live.clear();
231
+ this.outbox.close();
232
+ this.list?.dispose();
233
+ this.ctx.ui.setWidget(LIST_WIDGET_KEY, undefined);
234
+ this.ctx.ui.setStatus("master", undefined);
235
+ }
236
+
237
+ private unobserve(live: WorkerLive): void {
238
+ live.observed?.unsubscribe();
239
+ live.observed = undefined;
240
+ }
241
+
242
+ /** 活动列表的输入:档案加运行时事实的一次投影(全过程视图按同一顺序换子代理)。 */
243
+ activityFacts(): ActivityFacts {
244
+ const workers = this.store.state.workers;
245
+ const byPath = <T>(pick: (live: WorkerLive) => T | undefined) => new Map(workers.flatMap((worker) => {
246
+ const live = this.live.get(worker.name);
247
+ const value = live && pick(live);
248
+ return value === undefined ? [] : [[worker.sessionPath, value] as const];
249
+ }));
250
+ return {
251
+ workers,
252
+ currentTools: byPath((live) => (live.currentTools.size ? live.currentTools : undefined)),
253
+ reviewProgress: byPath((live) => live.reviewProgress),
254
+ runStartedAt: byPath((live) => live.runStartedAt),
255
+ lastOutputAt: byPath((live) => live.lastOutputAt),
256
+ settled: byPath((live) => (live.outcome && live.idleAt !== undefined ? { ...live.outcome, at: live.idleAt } : undefined)),
257
+ };
258
+ }
259
+ }