pi-firecode 0.8.0 → 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 +18 -13
  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/review/index.ts CHANGED
@@ -3,26 +3,25 @@
3
3
  *
4
4
  * 职责分界:
5
5
  * - 领域状态只活在纯 reducer(state.ts)里,所有迁移经 reduce() 计算;
6
- * 本文件是唯一执行器,只做副作用(起审查会话、投递反馈、发卡、持久化、状态栏),
7
- * 会话结果一律回灌成事件交给 reducer。模块级只有一个 controller。
6
+ * 本文件是唯一执行器,只做副作用(起审查会话、投递反馈、发卡、持久化、审查活动行),
7
+ * 会话结果一律回灌成事件交给 reducer。
8
+ * - 运行时状态按会话隔离:pi 在同一进程内对同一 cwd 复用扩展模块实例,主会话与每个
9
+ * Worker 子会话共用本文件;`registerReview(pi)` 各自持有一份 ReviewRuntime,模块级不留
10
+ * 任何会话状态,一个会话被 dispose 不会拖累另一个。
8
11
  * - 渲染器在此顶层无条件注册(不懒加载),live 与 reload 外观一致。
9
12
  */
10
13
  import { randomUUID } from "node:crypto";
11
- import type { Model } from "@earendil-works/pi-ai";
14
+ import { wrapEnvelope } from "../deliver.js";
12
15
  import {
13
- getAgentDir,
14
- ModelRuntime,
15
16
  type ExtensionAPI,
16
17
  type ExtensionContext,
17
18
  } from "@earendil-works/pi-coding-agent";
18
- import { loadConfig, type Language, type ReviewConfig } from "../config.js";
19
- import { formatDuration } from "../format.js";
19
+ import { loadConfig, type Language, type ReviewConfig, type Section } from "../config.js";
20
20
  import { herdrPaneEnv, herdrRequest } from "../herdr-client.js";
21
21
  import { InProcessSessionPool } from "../master/spawn.js";
22
- import { buildCard, CARD_TYPE, decisionText, registerCardRenderer } from "./card.js";
22
+ import { buildCard, CARD_TYPE, registerCardRenderer } from "./card.js";
23
23
  import {
24
24
  beginCheckpoint,
25
- CHECKPOINT_TYPE,
26
25
  CheckpointConflictError,
27
26
  type CheckpointStamp,
28
27
  readCheckpoint,
@@ -30,27 +29,16 @@ import {
30
29
  writeCheckpoint,
31
30
  } from "./checkpoint.js";
32
31
  import { buildEvidence } from "./evidence.js";
33
- import {
34
- applySessionEvent,
35
- initialProgress,
36
- type ReviewerProgress,
37
- settleProgress,
38
- } from "./progress.js";
39
- import {
40
- type ActivityView,
41
- hideActivity,
42
- lockEditor,
43
- showActivity,
44
- unlockEditor,
45
- } from "./ui.js";
46
- import { REVIEW_OCCUPANCY_LABEL as OCCUPANCY_LABEL } from "./outcome.js";
47
- import { buildAdvisorPrompt, buildFixFeedback, buildReviewPrompt, buildSummaryPrompt, readPrompt, reviewEnvelope } from "./prompt.js";
32
+ import { hideReviewTitle, lockEditor, showReviewTitle } from "./ui.js";
33
+ import { OCCUPANCY_CHANNEL, OCCUPANCY_LABEL, type OccupancyPayload, type ReviewProgress, type ReviewStage } from "./occupancy.js";
34
+ import { buildAdvisorPrompt, buildFixFeedback, buildReviewPrompt, buildSummaryPrompt, readPrompt } from "./prompt.js";
48
35
  import { runAdvisor } from "./advisor.js";
49
36
  import { runReviewer, type ReviewModelConfig } from "./reviewer.js";
50
37
  import { createReviewSessionRunner, type ReviewSessionRunner } from "./session.js";
51
38
  import {
52
39
  type AdvisorResult,
53
40
  type CardData,
41
+ type Phase,
54
42
  type ReviewEffect,
55
43
  type ReviewEvent,
56
44
  type ReviewLimits,
@@ -62,8 +50,6 @@ import {
62
50
  export const FEEDBACK_TYPE = "firecode-review-feedback";
63
51
  /** 总结回合提示:与修复反馈同通道(进上下文不渲染),不参与证据自指。 */
64
52
  export const SUMMARY_REQUEST_TYPE = "firecode-review-summary";
65
- const STATUS_KEY = "fire-review";
66
- const OCCUPANCY_CHANNEL = "herdr:blocked";
67
53
  const OCCUPANCY_SOURCE = "firecode-review";
68
54
  /** herdr 按 seq 丢弃过期上报;同一 source 单调递增。 */
69
55
  let occupancySeq = Date.now() * 1000;
@@ -93,13 +79,11 @@ function isActive(state: ReviewState) {
93
79
  }
94
80
 
95
81
  interface Controller {
96
- pi: ExtensionAPI;
97
82
  ctx: ExtensionContext;
98
83
  config: ReviewConfig;
99
84
  state: ReviewState;
100
85
  signal: AbortController;
101
86
  watchdog: ReturnType<typeof setTimeout> | undefined;
102
- statusTimer?: ReturnType<typeof setInterval>;
103
87
  /** 本 controller 上一次写入的凭证;null=本审查还没写过,undefined=冲突/失败后停写。 */
104
88
  persistedStamp: CheckpointStamp | null | undefined;
105
89
  /** streaming 时 sendMessage 会变成 steer;展示卡必须等 settled 后再发。 */
@@ -108,63 +92,77 @@ interface Controller {
108
92
  runningAction?: string;
109
93
  /** 当前审查者/顾问任务;执行模型 agent_start 必须 await 它退出后才能继续。 */
110
94
  actionPromise?: Promise<void>;
111
- runSession: ReviewSessionRunner;
112
95
  actionController?: AbortController;
113
96
  /** 反馈 sendMessage 已调用,等待 agent_start 回执。 */
114
97
  feedbackStartTimer?: ReturnType<typeof setTimeout>;
115
- /** 审查会话实时进度:纯 UI 态,高频更新,不入 checkpoint。 */
116
- progress: readonly ReviewerProgress[];
117
- /** 当前 progress 属于审查者还是顾问:修复相据此决定是否展示裁决摘要。 */
118
- progressKind?: "reviewers" | "advisor";
119
- progressStartedAt?: number;
120
- /** 编辑器是否已被审查接管(禁输入 + esc 取消)。 */
121
- editorLocked?: boolean;
98
+ /** 审查接管了编辑器(禁输入 + esc 取消)时的解锁函数,还原成接管前的编辑器。 */
99
+ unlockEditor?: () => void;
122
100
  /** Herdr blocked 频道采用计数语义,每个 true 必须由同一 controller 配对 false。 */
123
101
  occupancyHeld?: boolean;
124
102
  /** 占用标签租约续期计时器;释放与 shutdown 时清除。 */
125
103
  occupancyTimer?: ReturnType<typeof setInterval>;
126
104
  }
127
105
 
128
- let controller: Controller | undefined;
129
- let dispatchQueue: Promise<void> = Promise.resolve();
106
+ /** 一个会话的审查运行时:controller 是当前这场审查,queue 串行化它的状态迁移。 */
107
+ interface ReviewRuntime {
108
+ readonly pi: ExtensionAPI;
109
+ readonly runSession: ReviewSessionRunner;
110
+ controller: Controller | undefined;
111
+ queue: Promise<void>;
112
+ }
130
113
 
131
114
  interface ReviewDependencies {
132
115
  pool?: InProcessSessionPool;
133
- resolveModel?: (id: string) => Promise<Model<any>>;
134
116
  runSession?: ReviewSessionRunner;
135
117
  }
136
118
 
119
+ export interface ReviewHandle {
120
+ /** 等待 event-loop barrier 与其产生的状态迁移排空(测试用)。 */
121
+ settled(): Promise<void>;
122
+ }
123
+
137
124
  export function registerReview(
138
125
  pi: ExtensionAPI,
139
126
  enabled = true,
140
127
  configBroken = false,
141
128
  dependencies: ReviewDependencies = {},
142
- ): void {
129
+ ): ReviewHandle {
143
130
  // 渲染器与开关解耦:关闭 review 后历史卡 reload 仍使用原生结果卡样式。
144
131
  registerCardRenderer(pi);
145
- const runSession = dependencies.runSession ?? createReviewSessionRunner(
146
- dependencies.pool ?? new InProcessSessionPool(),
147
- dependencies.resolveModel ?? resolveConfiguredModel,
148
- );
132
+ const rt: ReviewRuntime = {
133
+ pi,
134
+ runSession: dependencies.runSession ?? createReviewSessionRunner(dependencies.pool ?? new InProcessSessionPool()),
135
+ controller: undefined,
136
+ queue: Promise.resolve(),
137
+ };
138
+ const handle: ReviewHandle = {
139
+ settled: async () => {
140
+ await new Promise<void>((resolve) => setImmediate(resolve));
141
+ await rt.queue;
142
+ await new Promise<void>((resolve) => setImmediate(resolve));
143
+ await rt.queue;
144
+ },
145
+ };
149
146
  if (!enabled) {
150
147
  // 只有用户明确关闭才封存活动 checkpoint(防重新启用后恢复幽灵审查);
151
148
  // features 配置坏掉不是关闭:保留 checkpoint,修好配置重启后继续恢复。
152
149
  if (!configBroken) pi.on("session_start", (_event, ctx) => settleDisabledCheckpoint(pi, ctx));
153
- return;
150
+ return handle;
154
151
  }
155
152
  pi.registerCommand("fire-review", {
156
153
  description: "对抗性审查:审这个会话到目前为止做完的事",
157
- handler: (args, ctx) => handleCommand(pi, args, ctx, runSession),
154
+ handler: (args, ctx) => handleCommand(rt, args, ctx),
158
155
  });
159
- pi.on("session_start", (_event, ctx) => handleSessionStart(pi, ctx, runSession));
156
+ pi.on("session_start", (_event, ctx) => handleSessionStart(rt, ctx));
160
157
  // 宿主保证 resources_discover 在整次 session_start(含所有异步 handler)完成后发出。
161
- pi.on("resources_discover", (_event, ctx) => requestAdvance(pi, ctx));
162
- pi.on("agent_start", () => handleAgentStart(pi));
158
+ pi.on("resources_discover", (_event, ctx) => requestAdvance(rt, ctx));
159
+ pi.on("agent_start", () => handleAgentStart(rt));
163
160
  // agent_end 只记录修复/总结回合的结局,不在此推进审查。
164
- pi.on("agent_end", (event) => handleAgentEnd(pi, event));
161
+ pi.on("agent_end", (event) => handleAgentEnd(rt, event));
165
162
  // settled 后尝试恢复;后续异步 handler 若再触发模型,agent_start 互锁会先停审查。
166
- pi.on("agent_settled", (_event, ctx) => scheduleAfterAgentSettled(pi, ctx));
167
- pi.on("session_shutdown", (event, ctx) => handleShutdown(pi, event.reason, ctx));
163
+ pi.on("agent_settled", (_event, ctx) => requestAdvance(rt, ctx));
164
+ pi.on("session_shutdown", (event, ctx) => handleShutdown(rt, event.reason, ctx));
165
+ return handle;
168
166
  }
169
167
 
170
168
  function settleDisabledCheckpoint(pi: ExtensionAPI, ctx: ExtensionContext): void {
@@ -196,36 +194,32 @@ function settleUnavailableCheckpoint(
196
194
 
197
195
  /** 串行化状态迁移:reducer 同步执行,副作用排队;同一时刻只有一个迁移在跑。
198
196
  * 返回队尾 Promise,让 pi 的事件处理器(session_shutdown 等)可 await 持久化落盘。 */
199
- function dispatch(pi: ExtensionAPI, event: ReviewEvent): Promise<void> {
200
- const run = dispatchQueue.then(async () => {
201
- if (!controller || controller.pi !== pi) return;
202
- const { state, effects } = reduce(
203
- controller.state,
204
- event,
205
- limitsOf(controller.config),
206
- Date.now(),
207
- );
208
- if (state !== controller.state) {
209
- controller.state = state;
197
+ function dispatch(rt: ReviewRuntime, event: ReviewEvent): Promise<void> {
198
+ const run = rt.queue.then(async () => {
199
+ const active = rt.controller;
200
+ if (!active) return;
201
+ const { state, effects } = reduce(active.state, event, limitsOf(active.config), Date.now());
202
+ if (state !== active.state) {
203
+ active.state = state;
210
204
  // 持久化失败不能当成功继续:否则会拿不一致的状态去起会话、投反馈,
211
205
  // 重启后又从旧 checkpoint 恢复,重现幽灵审查与重复反馈。
212
- const persisted = persist(pi, state);
213
- if (!persisted) return;
214
- syncOccupancy(controller);
215
- syncUi();
206
+ if (!persist(rt, state)) return;
207
+ if (!isActive(state)) clearWatchdog(rt);
208
+ syncOccupancy(rt, active);
209
+ syncUi(rt);
216
210
  }
217
- await runEffects(effects);
211
+ await runEffects(rt, effects);
218
212
  });
219
213
  // 队列一旦 rejected 就再也不会执行后续迁移(连 esc 取消也会失效):
220
214
  // 副作用异常只能到此为止,不得杀死状态机。
221
- dispatchQueue = run.catch((error) => {
222
- notifyEffectFailure(error);
215
+ rt.queue = run.catch((error) => {
216
+ notifyEffectFailure(rt, error);
223
217
  });
224
- return dispatchQueue;
218
+ return rt.queue;
225
219
  }
226
220
 
227
- function notifyEffectFailure(error: unknown) {
228
- const active = controller;
221
+ function notifyEffectFailure(rt: ReviewRuntime, error: unknown) {
222
+ const active = rt.controller;
229
223
  if (!active?.ctx.hasUI) return;
230
224
  const message = error instanceof Error ? error.message : String(error);
231
225
  active.ctx.ui.notify(
@@ -236,33 +230,9 @@ function notifyEffectFailure(error: unknown) {
236
230
  );
237
231
  }
238
232
 
239
- /**
240
- * 读 review 配置;存在配置问题就不交出可用配置。
241
- * 命令与恢复两个入口共用:任何一个静默回退默认模型都会花真钱跑错模型。
242
- */
243
- function loadReviewConfig(): { config: ReviewConfig } | { error: string } {
244
- let loaded: ReturnType<typeof loadConfig>;
245
- try {
246
- loaded = loadConfig();
247
- } catch (error) {
248
- return {
249
- error: `fire-review 配置读取失败:${error instanceof Error ? error.message : String(error)}`,
250
- };
251
- }
252
- // 三类都必须阻断:文件整体解析不了、review 节自身有错、
253
- // 以及 features.review 开关类型错(字符串 "false" 会因 `!== false` 静默启用付费审查)。
254
- const problems = loaded.problems.filter(
255
- (problem) =>
256
- problem.startsWith("review") ||
257
- problem.startsWith("未知字段 review.") ||
258
- problem.startsWith("config.jsonc") ||
259
- problem.startsWith("features"),
260
- );
261
- if (problems.length > 0)
262
- return { error: `fire-review 配置有问题,已停止:${problems.join(";")}` };
263
- if (!loaded.config.review.advisor.model || loaded.config.review.reviewers.length === 0)
264
- return { error: "fire-review 配置有问题,已停止:请显式完整配置 review" };
265
- return { config: loaded.config.review };
233
+ /** 命令与恢复两个入口共用同一判定:任何一个静默回退默认模型都会花真钱跑错模型。 */
234
+ function loadReviewConfig(): Section<ReviewConfig> {
235
+ return loadConfig().review;
266
236
  }
267
237
 
268
238
  function limitsOf(config: ReviewConfig): ReviewLimits {
@@ -278,12 +248,7 @@ function limitsOf(config: ReviewConfig): ReviewLimits {
278
248
  };
279
249
  }
280
250
 
281
- async function handleCommand(
282
- pi: ExtensionAPI,
283
- args: string,
284
- ctx: ExtensionContext,
285
- runSession: ReviewSessionRunner,
286
- ) {
251
+ async function handleCommand(rt: ReviewRuntime, args: string, ctx: ExtensionContext) {
287
252
  // 配置解析失败不能让命令无声失败:pi 会捕获 handler 异常,用户只会看到什么都没发生。
288
253
  const loaded = loadReviewConfig();
289
254
  if ("error" in loaded) {
@@ -291,7 +256,7 @@ async function handleCommand(
291
256
  return;
292
257
  }
293
258
  const config = loaded.config;
294
- if (controller && isActive(controller.state)) {
259
+ if (rt.controller && isActive(rt.controller.state)) {
295
260
  if (ctx.hasUI) ctx.ui.notify(
296
261
  config.language === "en"
297
262
  ? "A review is already running."
@@ -300,16 +265,12 @@ async function handleCommand(
300
265
  );
301
266
  return;
302
267
  }
303
- // 旧审查的看门狗必须在覆盖 controller 前停掉:它的回调读的是全局 controller,
304
- // 否则旧超时到点时会把新一场审查中止。
305
- clearWatchdog();
306
268
  const command = parseCommand(args, config.language);
307
269
  if ("error" in command) {
308
270
  if (ctx.hasUI) ctx.ui.notify(command.error, "error");
309
271
  return;
310
272
  }
311
- controller = {
312
- pi,
273
+ rt.controller = {
313
274
  ctx,
314
275
  config,
315
276
  state: initialState(randomUUID()),
@@ -317,13 +278,10 @@ async function handleCommand(
317
278
  watchdog: undefined,
318
279
  persistedStamp: null,
319
280
  pendingCards: [],
320
- runSession,
321
- progress: initialProgress(config.reviewers, config.language),
322
281
  };
323
- armWatchdog();
324
- armStatusTimer();
282
+ armWatchdog(rt);
325
283
  // 命令入口也只提出推进请求;真正开审统一经过下一 event-loop 的 idle barrier.
326
- void dispatch(pi, {
284
+ void dispatch(rt, {
327
285
  type: "START",
328
286
  focus: command.focus,
329
287
  busy: true,
@@ -331,32 +289,18 @@ async function handleCommand(
331
289
  }
332
290
 
333
291
  /** 重启 / 会话恢复:从 checkpoint 重建 controller 并续跑未完成的环节。
334
- * reload / new / resume / fork 是运行时替换(新 pi),先清掉旧 controller,
335
- * 再按新会话的 checkpoint 恢复;quit 之外不 settle,审查能在重启后继续。 */
336
- function handleSessionStart(
337
- pi: ExtensionAPI,
338
- ctx: ExtensionContext,
339
- runSession: ReviewSessionRunner,
340
- ): Promise<void> | void {
341
- if (controller && controller.pi !== pi) {
342
- setOccupancy(controller, false);
343
- controller = undefined;
344
- }
292
+ * reload / new / resume / fork 是运行时替换(新 pi、新 runtime),旧 runtime 已在
293
+ * session_shutdown 收口;这里只按新会话的 checkpoint 恢复,quit 之外不 settle。 */
294
+ function handleSessionStart(rt: ReviewRuntime, ctx: ExtensionContext): Promise<void> | void {
345
295
  const checkpoint = readCheckpoint(ctx);
346
- if (!checkpoint || !isActive(checkpoint)) {
347
- if (controller && !isActive(controller.state)) controller = undefined;
348
- return;
349
- }
350
- if (controller && isActive(controller.state)) return;
296
+ if (!checkpoint || !isActive(checkpoint)) return;
351
297
  const loaded = loadReviewConfig();
352
298
  if ("error" in loaded) {
353
299
  // session_start 的配置告警由入口统一聚合;这里只保留活动 checkpoint,修好后继续。
354
300
  return;
355
301
  }
356
302
  const config = loaded.config;
357
- clearWatchdog();
358
- controller = {
359
- pi,
303
+ rt.controller = {
360
304
  ctx,
361
305
  config,
362
306
  state: checkpoint,
@@ -364,26 +308,23 @@ function handleSessionStart(
364
308
  watchdog: undefined,
365
309
  persistedStamp: readStamp(ctx),
366
310
  pendingCards: [],
367
- runSession,
368
- progress: initialProgress(config.reviewers, config.language),
369
311
  };
370
- armWatchdog();
371
- armStatusTimer();
372
- syncOccupancy(controller);
373
- syncUi();
312
+ armWatchdog(rt);
313
+ syncOccupancy(rt, rt.controller);
314
+ syncUi(rt);
374
315
  // 恢复只更新持久状态并提出推进请求;绝不在 session_start handler 内起任何工作。
375
- return dispatch(pi, { type: "RECOVER" });
316
+ return dispatch(rt, { type: "RECOVER" });
376
317
  }
377
318
 
378
- async function handleAgentStart(pi: ExtensionAPI): Promise<void> {
379
- const active = controller;
380
- if (!active || active.pi !== pi) return;
319
+ async function handleAgentStart(rt: ReviewRuntime): Promise<void> {
320
+ const active = rt.controller;
321
+ if (!active) return;
381
322
  if (
382
323
  active.state.phase === "awaiting_fix" &&
383
324
  active.state.repair?.status === "awaiting_start"
384
325
  ) {
385
326
  clearFeedbackStartTimer(active);
386
- await dispatch(pi, { type: "REPAIR_STARTED" });
327
+ await dispatch(rt, { type: "REPAIR_STARTED" });
387
328
  return;
388
329
  }
389
330
  if (
@@ -391,7 +332,7 @@ async function handleAgentStart(pi: ExtensionAPI): Promise<void> {
391
332
  active.state.summary?.status === "awaiting_start"
392
333
  ) {
393
334
  clearFeedbackStartTimer(active);
394
- await dispatch(pi, { type: "SUMMARY_STARTED" });
335
+ await dispatch(rt, { type: "SUMMARY_STARTED" });
395
336
  return;
396
337
  }
397
338
  // 其他扩展可在我们排队后异步触发执行模型。agent_start 是宿主提供的硬边界:
@@ -399,20 +340,17 @@ async function handleAgentStart(pi: ExtensionAPI): Promise<void> {
399
340
  if (!active.actionPromise) return;
400
341
  active.actionController?.abort();
401
342
  await active.actionPromise;
402
- if (controller !== active || !isActive(active.state)) return;
343
+ if (rt.controller !== active || !isActive(active.state)) return;
403
344
  active.runningAction = undefined;
404
345
  active.actionPromise = undefined;
405
346
  }
406
347
 
407
- function handleAgentEnd(
408
- pi: ExtensionAPI,
409
- event: { messages: readonly unknown[] },
410
- ): Promise<void> | void {
411
- const active = controller;
412
- if (!active || active.pi !== pi) return;
348
+ function handleAgentEnd(rt: ReviewRuntime, event: { messages: readonly unknown[] }): Promise<void> | void {
349
+ const active = rt.controller;
350
+ if (!active) return;
413
351
  // 总结回合任何结局都收尾:裁决已落地,总结失败/中断不重试不升级。
414
352
  if (active.state.phase === "summarizing" && active.state.summary?.status === "running")
415
- return dispatch(pi, { type: "SUMMARY_SETTLED" });
353
+ return dispatch(rt, { type: "SUMMARY_SETTLED" });
416
354
  if (
417
355
  active.state.phase !== "awaiting_fix" ||
418
356
  active.state.repair?.status !== "running"
@@ -421,77 +359,69 @@ function handleAgentEnd(
421
359
  .reverse()
422
360
  .find((message) => isRecord(message) && message.role === "assistant");
423
361
  if (isRecord(assistant) && assistant.stopReason !== "error" && assistant.stopReason !== "aborted")
424
- return dispatch(pi, { type: "REPAIR_COMPLETED" });
362
+ return dispatch(rt, { type: "REPAIR_COMPLETED" });
425
363
  active.signal.abort();
426
- return dispatch(pi, { type: "CANCEL", reason: "user" });
364
+ return dispatch(rt, { type: "CANCEL", reason: "user" });
427
365
  }
428
366
 
429
- function scheduleAfterAgentSettled(pi: ExtensionAPI, ctx: ExtensionContext): void {
430
- requestAdvance(pi, ctx);
431
- }
432
-
433
- function requestAdvance(pi: ExtensionAPI, ctx: ExtensionContext): void {
434
- const active = controller;
435
- if (!active || active.pi !== pi) return;
367
+ function requestAdvance(rt: ReviewRuntime, ctx: ExtensionContext): void {
368
+ const active = rt.controller;
369
+ if (!active) return;
436
370
  const runId = active.state.runId;
437
- void advanceWhenIdle(pi, ctx, runId).catch((error) => {
438
- notifyEffectFailure(error);
439
- if (controller !== active) return;
371
+ void advanceWhenIdle(rt, ctx, runId).catch((error) => {
372
+ notifyEffectFailure(rt, error);
373
+ if (rt.controller !== active) return;
440
374
  active.signal.abort();
441
- void dispatch(pi, { type: "CANCEL", reason: "user" });
375
+ void dispatch(rt, { type: "CANCEL", reason: "user" });
442
376
  });
443
377
  }
444
378
 
445
- async function advanceWhenIdle(
446
- pi: ExtensionAPI,
447
- ctx: ExtensionContext,
448
- runId: string,
449
- ): Promise<void> {
450
- const active = controller;
379
+ async function advanceWhenIdle(rt: ReviewRuntime, ctx: ExtensionContext, runId: string): Promise<void> {
380
+ const active = rt.controller;
451
381
  // 所有启动入口共享 idle 门;正确性另由 agent_start 的同步停审互锁保证。
452
382
  if (
453
383
  !active ||
454
- active.pi !== pi ||
455
384
  active.state.runId !== runId ||
456
385
  active.signal.signal.aborted ||
457
386
  !ctx.isIdle() ||
458
387
  ctx.hasPendingMessages()
459
388
  ) return;
460
389
  active.ctx = ctx;
461
- flushPendingCards(pi);
390
+ flushPendingCards(rt);
462
391
  const { state } = active;
463
392
  if (state.phase === "queued") {
464
- await dispatch(pi, { type: "ADVANCE" });
393
+ await dispatch(rt, { type: "ADVANCE" });
465
394
  return;
466
395
  }
467
396
  if (state.phase === "reviewing") {
468
- startAction(active, `review:${state.round}`, "reviewer", () => startReviewers(pi));
397
+ startAction(rt, active, `review:${state.round}`, "reviewer", () => startReviewers(rt));
469
398
  return;
470
399
  }
471
400
  if (state.phase === "needs_fix") {
472
- startAction(active, `advisor:${state.round}`, "advisor", () => consultAdvisor(pi));
401
+ startAction(rt, active, `advisor:${state.round}`, "advisor", () => consultAdvisor(rt));
473
402
  return;
474
403
  }
475
404
  if (state.phase === "summarizing") {
476
405
  if (state.summary?.status !== "pending") return;
477
- await dispatch(pi, { type: "SUMMARY_DISPATCHED" });
478
- const current = controller?.state;
479
- if (controller === active && current?.phase === "summarizing" && current.summary?.status === "awaiting_start")
480
- deliverSummaryNow(pi, current);
406
+ await dispatch(rt, { type: "SUMMARY_DISPATCHED" });
407
+ const current = rt.controller?.state;
408
+ if (rt.controller === active && current?.phase === "summarizing" && current.summary?.status === "awaiting_start")
409
+ deliverSummaryNow(rt, current);
481
410
  return;
482
411
  }
483
412
  if (state.phase !== "awaiting_fix" || !state.repair) return;
484
413
  if (state.repair.status === "pending") {
485
- await dispatch(pi, { type: "FEEDBACK_DISPATCHED" });
486
- const repair = controller?.state.repair;
487
- if (controller === active && repair?.status === "awaiting_start")
488
- deliverFeedbackNow(pi, repair.details, repair.advisor);
414
+ await dispatch(rt, { type: "FEEDBACK_DISPATCHED" });
415
+ const repair = rt.controller?.state.repair;
416
+ if (rt.controller === active && repair?.status === "awaiting_start")
417
+ deliverFeedbackNow(rt, repair.details, repair.advisor);
489
418
  return;
490
419
  }
491
- if (state.repair.status === "completed") await dispatch(pi, { type: "ADVANCE" });
420
+ if (state.repair.status === "completed") await dispatch(rt, { type: "ADVANCE" });
492
421
  }
493
422
 
494
423
  function startAction(
424
+ rt: ReviewRuntime,
495
425
  active: Controller,
496
426
  key: string,
497
427
  kind: "reviewer" | "advisor",
@@ -502,8 +432,8 @@ function startAction(
502
432
  active.actionController = new AbortController();
503
433
  const action = run()
504
434
  .catch(async (error) => {
505
- if (controller !== active || active.actionController?.signal.aborted) return;
506
- await dispatch(active.pi, {
435
+ if (rt.controller !== active || active.actionController?.signal.aborted) return;
436
+ await dispatch(rt, {
507
437
  type: "INFRASTRUCTURE_ERROR",
508
438
  details: sessionErrorText(kind, active.config.language, error),
509
439
  });
@@ -517,73 +447,54 @@ function startAction(
517
447
  active.actionPromise = action;
518
448
  }
519
449
 
450
+ /** 会话离开当前运行时:取消并等待审查会话退出,quit 落终态,其余保留 checkpoint 给新运行时恢复。
451
+ * 收口后清空 controller——宿主随后 dispose 会作废 ctx,迟到回调看到空 controller 直接返回。 */
520
452
  async function handleShutdown(
521
- pi: ExtensionAPI,
453
+ rt: ReviewRuntime,
522
454
  reason: "quit" | "reload" | "new" | "resume" | "fork",
523
455
  ctx: ExtensionContext,
524
456
  ): Promise<void> {
525
- const active = controller;
457
+ const active = rt.controller;
526
458
  if (!active) return;
527
459
  // 会话离开当前运行时就立即释放;reload 恢复会由新 controller 重新配对喊占用。
528
- setOccupancy(active, false);
460
+ setOccupancy(rt, active, false);
529
461
  // 无论何种终止都先取消并等待当前审查会话;旧动作不得泄漏到新运行时。
530
462
  active.signal.abort();
531
463
  active.actionController?.abort();
532
- clearWatchdog();
533
- clearStatusTimer(active);
464
+ clearWatchdog(rt);
534
465
  clearFeedbackStartTimer(active);
535
466
  await active.actionPromise;
536
467
  if (active.ctx !== ctx) active.ctx = ctx;
537
- if (reason === "quit") {
538
- await dispatch(pi, { type: "CANCEL", reason: "shutdown" });
539
- return;
468
+ if (reason === "quit") await dispatch(rt, { type: "CANCEL", reason: "shutdown" });
469
+ else {
470
+ clearUi(active);
471
+ await rt.queue;
540
472
  }
541
- // reload / new / resume / fork 保留 checkpoint,由新运行时在 post-session 边界恢复。
542
- hideActivity(active.ctx);
543
- releaseEditor(active);
544
- await dispatchQueue;
473
+ if (rt.controller === active) rt.controller = undefined;
545
474
  }
546
475
 
547
- function armWatchdog() {
548
- if (!controller) return;
549
- clearWatchdog();
550
- const elapsed = controller.state.startedAt
551
- ? Math.max(0, Date.now() - controller.state.startedAt)
552
- : 0;
553
- const remaining = Math.max(0, overallTimeoutMs(controller.config) - elapsed);
476
+ function armWatchdog(rt: ReviewRuntime) {
477
+ const active = rt.controller;
478
+ if (!active) return;
479
+ clearWatchdog(rt);
480
+ const elapsed = active.state.startedAt ? Math.max(0, Date.now() - active.state.startedAt) : 0;
481
+ const remaining = Math.max(0, overallTimeoutMs(active.config) - elapsed);
554
482
  if (remaining === 0) {
555
- controller.signal.abort();
556
- void dispatch(controller.pi, { type: "TIMEOUT" });
483
+ active.signal.abort();
484
+ void dispatch(rt, { type: "TIMEOUT" });
557
485
  return;
558
486
  }
559
- controller.watchdog = setTimeout(() => {
560
- const active = controller;
561
- if (!active || !isActive(active.state)) return;
487
+ active.watchdog = setTimeout(() => {
488
+ if (rt.controller !== active || !isActive(active.state)) return;
562
489
  active.signal.abort();
563
490
  active.actionController?.abort();
564
- dispatch(active.pi, { type: "TIMEOUT" });
491
+ dispatch(rt, { type: "TIMEOUT" });
565
492
  }, remaining);
566
- controller.watchdog.unref?.();
567
- }
568
-
569
- function clearWatchdog() {
570
- if (controller?.watchdog) clearTimeout(controller.watchdog);
571
- }
572
-
573
- function armStatusTimer() {
574
- const active = controller;
575
- if (!active || active.statusTimer) return;
576
- // 耗时显示本身就是定时业务语义;宿主没有可订阅的“整秒变化”事件。
577
- active.statusTimer = setInterval(() => {
578
- if (controller !== active || !isActive(active.state)) return;
579
- renderStatus(active.ctx, active.state, active.config.language);
580
- }, 1_000);
581
- active.statusTimer.unref?.();
493
+ active.watchdog.unref?.();
582
494
  }
583
495
 
584
- function clearStatusTimer(active: Controller) {
585
- if (active.statusTimer) clearInterval(active.statusTimer);
586
- active.statusTimer = undefined;
496
+ function clearWatchdog(rt: ReviewRuntime) {
497
+ if (rt.controller?.watchdog) clearTimeout(rt.controller.watchdog);
587
498
  }
588
499
 
589
500
  function clearFeedbackStartTimer(active: Controller): void {
@@ -591,55 +502,46 @@ function clearFeedbackStartTimer(active: Controller): void {
591
502
  active.feedbackStartTimer = undefined;
592
503
  }
593
504
 
594
- // ---- 持久化与状态栏(状态的投影)----
505
+ // ---- 持久化 ----
595
506
 
596
507
  /** 返回是否已可靠落盘;false 时调用方必须停下本次迁移的副作用。 */
597
- function persist(pi: ExtensionAPI, state: ReviewState): boolean {
598
- if (!controller) return false;
599
- const persisted = controller.persistedStamp;
508
+ function persist(rt: ReviewRuntime, state: ReviewState): boolean {
509
+ const active = rt.controller;
510
+ if (!active) return false;
511
+ const persisted = active.persistedStamp;
600
512
  if (persisted === undefined) return false; // 冲突或写入失败后停写
601
513
  try {
602
- controller.persistedStamp =
514
+ active.persistedStamp =
603
515
  persisted === null
604
- ? beginCheckpoint(pi, controller.ctx, state)
605
- : writeCheckpoint(pi, controller.ctx, state, persisted);
516
+ ? beginCheckpoint(rt.pi, active.ctx, state)
517
+ : writeCheckpoint(rt.pi, active.ctx, state, persisted);
606
518
  return true;
607
519
  } catch (error) {
520
+ // 冲突与写入失败共用的收口:停写、释放占用、中止在途动作并告知。
521
+ const halt = (message: string, level: "warning" | "error") => {
522
+ setOccupancy(rt, active, false);
523
+ active.persistedStamp = undefined;
524
+ active.signal.abort();
525
+ active.actionController?.abort();
526
+ if (active.ctx.hasUI) active.ctx.ui.notify(message, level);
527
+ };
528
+ const en = active.config.language === "en";
608
529
  if (error instanceof CheckpointConflictError) {
609
530
  // 持久化里出现不是本 controller 写的 Run ID:并发冲突,停止审查。
610
- setOccupancy(controller, false);
611
- controller.persistedStamp = undefined;
612
- controller.signal.abort();
613
- controller.actionController?.abort();
614
- if (controller.ctx.hasUI)
615
- controller.ctx.ui.notify(
616
- controller.config.language === "en"
617
- ? "fire-review checkpoint conflict; review stopped."
618
- : "fire-review checkpoint 冲突,已停止审查。",
619
- "warning",
620
- );
621
- void dispatch(pi, { type: "CANCEL", reason: "shutdown" });
531
+ halt(en ? "fire-review checkpoint conflict; review stopped." : "fire-review checkpoint 冲突,已停止审查。", "warning");
532
+ void dispatch(rt, { type: "CANCEL", reason: "shutdown" });
622
533
  return false;
623
534
  }
624
535
  // 普通写入失败(如会话落盘异常):停掉本场审查,不带着不一致状态继续跑。
625
- setOccupancy(controller, false);
626
- controller.persistedStamp = undefined;
627
- controller.signal.abort();
628
- controller.actionController?.abort();
629
- if (controller.ctx.hasUI)
630
- controller.ctx.ui.notify(
631
- controller.config.language === "en"
632
- ? `fire-review checkpoint write failed; review stopped: ${errorText(error)}`
633
- : `fire-review checkpoint 写入失败,已停止审查:${errorText(error)}`,
634
- "error",
635
- );
636
- releaseEditor(controller);
637
- hideActivity(controller.ctx);
536
+ halt(en
537
+ ? `fire-review checkpoint write failed; review stopped: ${errorText(error)}`
538
+ : `fire-review checkpoint 写入失败,已停止审查:${errorText(error)}`, "error");
539
+ clearUi(active);
638
540
  // 磁盘上可能还留着上一条活动 checkpoint,重启会把它恢复成幽灵审查:
639
541
  // 尽力补写一条终态。写不进去时不假装成功,在通知里告知用户。
640
542
  let sealed = true;
641
543
  try {
642
- beginCheckpoint(pi, controller.ctx, {
544
+ beginCheckpoint(rt.pi, active.ctx, {
643
545
  ...state,
644
546
  phase: "settled",
645
547
  active: null,
@@ -652,16 +554,12 @@ function persist(pi: ExtensionAPI, state: ReviewState): boolean {
652
554
  }
653
555
  // 内存态也必须释放:只停会话但留着活动态 controller,会把幽灵审查从磁盘搬到内存——
654
556
  // 后续命令永远被「已有审查在进行中」挡住,且无处取消。
655
- clearWatchdog();
656
- clearStatusTimer(controller);
657
- clearFeedbackStartTimer(controller);
658
- const uiCtx = controller.ctx;
659
- const language = controller.config.language;
660
- if (uiCtx.hasUI) uiCtx.ui.setStatus(STATUS_KEY, undefined);
661
- controller = undefined;
662
- if (!sealed && uiCtx.hasUI)
663
- uiCtx.ui.notify(
664
- language === "en"
557
+ clearWatchdog(rt);
558
+ clearFeedbackStartTimer(active);
559
+ rt.controller = undefined;
560
+ if (!sealed && active.ctx.hasUI)
561
+ active.ctx.ui.notify(
562
+ active.config.language === "en"
665
563
  ? "fire-review could not seal the checkpoint; a restart may resume this review — cancel it with esc."
666
564
  : "fire-review 无法写入终态,重启后可能恢复这场审查,到时按 esc 取消。",
667
565
  "warning",
@@ -674,8 +572,8 @@ function errorText(error: unknown) {
674
572
  return error instanceof Error ? error.message : String(error);
675
573
  }
676
574
 
677
- function syncOccupancy(active: Controller): void {
678
- setOccupancy(active, isActive(active.state));
575
+ function syncOccupancy(rt: ReviewRuntime, active: Controller): void {
576
+ setOccupancy(rt, active, isActive(active.state));
679
577
  }
680
578
 
681
579
  /**
@@ -684,7 +582,7 @@ function syncOccupancy(active: Controller): void {
684
582
  * crash/kill 后无 TTL 的标签永驻会让 Master 把 Worker 的真提问误判为审查占用;
685
583
  * 续约同时充当首次投递失败的重试。
686
584
  */
687
- function publishOccupancyLabel(active: Controller, held: boolean): void {
585
+ function publishOccupancyLabel(rt: ReviewRuntime, active: Controller, held: boolean): void {
688
586
  if (held) {
689
587
  void sendOccupancyLabel();
690
588
  if (!active.occupancyTimer) {
@@ -697,7 +595,7 @@ function publishOccupancyLabel(active: Controller, held: boolean): void {
697
595
  clearInterval(active.occupancyTimer);
698
596
  active.occupancyTimer = undefined;
699
597
  }
700
- void clearOccupancyLabel();
598
+ void clearOccupancyLabel(rt);
701
599
  }
702
600
 
703
601
  function sendOccupancyLabel(): Promise<boolean> {
@@ -712,12 +610,12 @@ function sendOccupancyLabel(): Promise<boolean> {
712
610
  });
713
611
  }
714
612
 
715
- async function clearOccupancyLabel(): Promise<void> {
613
+ async function clearOccupancyLabel(rt: ReviewRuntime): Promise<void> {
716
614
  const env = herdrPaneEnv();
717
615
  if (!env) return;
718
616
  for (let attempt = 0; attempt < 2; attempt += 1) {
719
617
  // 新审查已重新持有时中止重试:迟到的清除会撤掉新租约的标签。
720
- if (controller?.occupancyHeld) return;
618
+ if (rt.controller?.occupancyHeld) return;
721
619
  const delivered = await herdrRequest(OCCUPANCY_SOURCE, "pane.report_metadata", {
722
620
  pane_id: env.paneId,
723
621
  source: OCCUPANCY_SOURCE,
@@ -729,18 +627,18 @@ async function clearOccupancyLabel(): Promise<void> {
729
627
  // 两次未送达:标签带 TTL,最迟 60s 自行过期,不会永久残留。
730
628
  }
731
629
 
732
- function setOccupancy(active: Controller, held: boolean): void {
630
+ function setOccupancy(rt: ReviewRuntime, active: Controller, held: boolean): void {
733
631
  if (Boolean(active.occupancyHeld) === held) return;
734
632
  active.occupancyHeld = held;
735
633
  // 标签走 metadata state_labels:herdr 会丢弃 report_agent 的 message(实测),
736
634
  // 只有这条通道能同时到达 Master(state_labels 判定)与侧边栏(state_text token)。
737
635
  // 频道仍要发:它驱动 herdr 集成的 blocked 状态本身。占用信号不伤审查。
738
- publishOccupancyLabel(active, held);
636
+ publishOccupancyLabel(rt, active, held);
739
637
  try {
740
- active.pi.events.emit(OCCUPANCY_CHANNEL, {
741
- active: held,
742
- ...(held ? { label: OCCUPANCY_LABEL } : {}),
743
- });
638
+ const payload: OccupancyPayload = held
639
+ ? { active: true, label: OCCUPANCY_LABEL, progress: () => reviewProgress(rt) }
640
+ : { active: false };
641
+ rt.pi.events.emit(OCCUPANCY_CHANNEL, payload);
744
642
  } catch (error) {
745
643
  // 占用信号只对齐 Herdr 展示;集成故障不能改变审查状态机或会话生命周期。
746
644
  try {
@@ -752,144 +650,81 @@ function setOccupancy(active: Controller, held: boolean): void {
752
650
  }
753
651
  }
754
652
 
755
- /**
756
- * UI 投影:状态栏一行 + 编辑器上方活动条 + esc 接管,全部从当前状态派生。
757
- * 活动条自己按帧重绘,因此进度变化不需要在这里通知。
758
- */
759
- function syncUi(): void {
760
- const active = controller;
653
+ /** UI 投影:终端标题 + esc 接管,全部从当前状态派生;审查进度经占用频道由输入框外壳显示。 */
654
+ function syncUi(rt: ReviewRuntime): void {
655
+ const active = rt.controller;
761
656
  if (!active) return;
762
- renderStatus(active.ctx, active.state, active.config.language);
763
- if (activityView()) {
764
- showActivity(active.ctx, activityView);
765
- // 只在等模型结论时接管编辑器;awaiting_fix 相把输入交还用户。
766
- if (canCancelWithKey()) {
767
- if (!active.editorLocked) {
768
- lockEditor(active.ctx, activityView, cancelByUser);
769
- active.editorLocked = true;
770
- }
771
- } else releaseEditor(active);
772
- return;
773
- }
774
- clearStatusTimer(active);
775
- hideActivity(active.ctx);
657
+ if (!isActive(active.state) || !active.ctx.hasUI) return clearUi(active);
658
+ showReviewTitle(active.ctx, active.state.round, active.config.language);
659
+ // 只在等模型结论时接管编辑器;awaiting_fix 相把输入交还用户。
660
+ if (!canCancelWithKey(rt)) return releaseEditor(active);
661
+ active.unlockEditor ??= lockEditor(active.ctx, () => cancelByUser(rt));
662
+ }
663
+
664
+ function clearUi(active: Controller) {
665
+ hideReviewTitle(active.ctx);
776
666
  releaseEditor(active);
777
667
  }
778
668
 
779
669
  function releaseEditor(active: Controller) {
780
- if (!active.editorLocked) return;
781
- unlockEditor(active.ctx);
782
- active.editorLocked = false;
670
+ active.unlockEditor?.();
671
+ active.unlockEditor = undefined;
783
672
  }
784
673
 
785
- /** 活动条快照;非活动相与总结回合返回 undefined(总结是普通可见回合,不需要活动框)。 */
786
- function activityView(): ActivityView | undefined {
787
- const active = controller;
788
- if (!active || !isActive(active.state) || active.state.phase === "summarizing" || !active.ctx.hasUI) return undefined;
789
- return {
790
- phase: active.state.phase,
791
- round: active.state.round,
792
- focus: active.state.focus,
793
- roundStartedAt: active.state.roundStartedAt,
794
- progressStartedAt: active.progressStartedAt,
795
- reviewers: active.progress,
796
- progressKind: active.progressKind,
797
- advisorRunning: active.state.phase === "needs_fix",
798
- consecutiveFailures: active.state.consecutiveFailures,
799
- cwd: active.ctx.cwd,
800
- language: active.config.language,
801
- };
674
+ const STAGE: Partial<Record<Phase, ReviewStage>> = {
675
+ queued: "queued", reviewing: "reviewing", needs_fix: "advisor", awaiting_fix: "fixing", summarizing: "summarizing",
676
+ };
677
+
678
+ /** 审查进度只读 reducer 的当前状态;只有审查相的票数可数。 */
679
+ function reviewProgress(rt: ReviewRuntime): ReviewProgress | undefined {
680
+ const state = rt.controller?.state;
681
+ const stage = state && isActive(state) ? STAGE[state.phase] : undefined;
682
+ if (!state || !stage) return undefined;
683
+ const reviewers = stage === "reviewing" ? state.active?.reviewers ?? [] : [];
684
+ const count = (status: string) => reviewers.filter((reviewer) => reviewer.status === status).length;
685
+ return { stage, round: state.round, passed: count("passed"), blocked: count("failed"), total: reviewers.length };
802
686
  }
803
687
 
804
- function canCancelWithKey() {
805
- const phase = controller?.state.phase;
688
+ function canCancelWithKey(rt: ReviewRuntime) {
689
+ const phase = rt.controller?.state.phase;
806
690
  return phase === "queued" || phase === "reviewing" || phase === "needs_fix";
807
691
  }
808
692
 
809
- function cancelByUser() {
810
- const active = controller;
693
+ function cancelByUser(rt: ReviewRuntime) {
694
+ const active = rt.controller;
811
695
  if (!active) return;
812
696
  if (active.state.phase === "needs_fix") {
813
697
  // pi-flow 语义:顾问阶段的 Esc 只跳过本次咨询,不取消整场审查。
814
698
  active.actionController?.abort();
815
699
  void (active.actionPromise ?? Promise.resolve()).then(() =>
816
- dispatch(active.pi, { type: "ADVISOR_SKIPPED" }),
700
+ dispatch(rt, { type: "ADVISOR_SKIPPED" }),
817
701
  );
818
702
  return;
819
703
  }
820
704
  active.signal.abort();
821
705
  active.actionController?.abort();
822
- void dispatch(active.pi, { type: "CANCEL", reason: "user" });
823
- }
824
-
825
- function renderStatus(
826
- ctx: ExtensionContext,
827
- state: ReviewState,
828
- language: Language,
829
- ) {
830
- if (!ctx.hasUI) return;
831
- let prefix: string | undefined;
832
- if (state.phase === "queued")
833
- prefix = language === "en"
834
- ? "🔥 running · review when done"
835
- : "🔥 执行中 · 完成后自动审查";
836
- else if (state.phase === "reviewing" || state.phase === "needs_fix")
837
- prefix = `🔥 ${roundStatus(state.round, language === "en" ? "review" : "审查", language)}`;
838
- else if (state.phase === "awaiting_fix")
839
- prefix = `🔥 ${roundStatus(state.round, language === "en" ? "repair" : "修复中", language)}`;
840
- else if (state.phase === "summarizing")
841
- prefix = language === "en" ? "🔥 summarizing" : "🔥 总结中";
842
- if (!prefix) {
843
- ctx.ui.setStatus(STATUS_KEY, undefined);
844
- return;
845
- }
846
- const stepStartedAt = state.phase === "awaiting_fix"
847
- ? state.updatedAt
848
- : state.roundStartedAt || state.startedAt;
849
- const stepMs = stepStartedAt ? Date.now() - stepStartedAt : 0;
850
- const totalMs = state.startedAt ? Date.now() - state.startedAt : 0;
851
- const showTotal = state.round > 1 || state.history.some((round) => round.result === "failed");
852
- const elapsed = showTotal
853
- ? `${formatDuration(stepMs)} / ${language === "en" ? "total" : "总"} ${formatDuration(totalMs)}`
854
- : formatDuration(stepMs);
855
- ctx.ui.setStatus(STATUS_KEY, `${prefix} · ${elapsed}`);
856
- }
857
-
858
-
859
- function roundStatus(round: number, phase: string, language: Language) {
860
- if (round <= 1) return phase;
861
- return language === "en" ? `Round ${round} ${phase}` : `第 ${round} 轮${phase}`;
706
+ void dispatch(rt, { type: "CANCEL", reason: "user" });
862
707
  }
863
708
 
864
709
  // ---- 副作用执行器 ----
865
710
 
866
711
  /** reducer 只发卡或请求推进;所有会启动工作的动作统一经过 idle barrier。 */
867
- async function runEffects(effects: ReviewEffect[]) {
712
+ async function runEffects(rt: ReviewRuntime, effects: ReviewEffect[]) {
868
713
  for (const effect of effects) {
869
- const active = controller;
714
+ const active = rt.controller;
870
715
  if (!active) return;
871
716
  if (effect.kind === "advance") {
872
- requestAdvance(active.pi, active.ctx);
717
+ requestAdvance(rt, active.ctx);
873
718
  continue;
874
719
  }
875
720
  try {
876
- sendCard(active.pi, effect.card);
721
+ sendCard(rt, effect.card);
877
722
  } catch (error) {
878
- notifyEffectFailure(error);
723
+ notifyEffectFailure(rt, error);
879
724
  }
880
725
  }
881
726
  }
882
727
 
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};审查会话只能使用内置 provider 的模型`);
891
- return model;
892
- }
893
728
 
894
729
  function reviewerModelConfig(model: ReviewConfig["advisor"], config: ReviewConfig): ReviewModelConfig {
895
730
  return {
@@ -901,30 +736,14 @@ function reviewerModelConfig(model: ReviewConfig["advisor"], config: ReviewConfi
901
736
  }
902
737
 
903
738
  /** 开审那一刻取会话分支快照构造 prompt,所有审查者共用同一 prompt。 */
904
- async function startReviewers(pi: ExtensionAPI): Promise<void> {
905
- const active = controller;
739
+ async function startReviewers(rt: ReviewRuntime): Promise<void> {
740
+ const active = rt.controller;
906
741
  if (!active) return;
907
742
  const { state, config } = active;
908
743
  if (!state.active) return;
909
744
  const currentActive = state.active;
910
745
  const actionSignal = active.actionController?.signal ?? active.signal.signal;
911
- active.progressStartedAt = Date.now();
912
- active.progressKind = "reviewers";
913
- active.progress = initialProgress(
914
- currentActive.reviewers.map((item) => ({ model: item.model })),
915
- config.language,
916
- );
917
- for (const reviewer of currentActive.reviewers)
918
- if (reviewer.status !== "running" && reviewer.result)
919
- active.progress = settleProgress(
920
- active.progress,
921
- reviewer.index,
922
- reviewer.status,
923
- config.language,
924
- reviewer.result.summary,
925
- reviewer.result.details,
926
- );
927
- const evidence = buildEvidence(sessionEntries(), config.language);
746
+ const evidence = buildEvidence(sessionEntries(rt), config.language, { sessionFile: active.ctx.sessionManager.getSessionFile() });
928
747
  const prompt = buildReviewPrompt(readPrompt("review", config.language), {
929
748
  language: config.language,
930
749
  scope: scopeText(config.language),
@@ -944,31 +763,13 @@ async function startReviewers(pi: ExtensionAPI): Promise<void> {
944
763
  cwd: active.ctx.cwd,
945
764
  language: config.language,
946
765
  signal: actionSignal,
947
- runSession: active.runSession,
948
- onEvent: (event) => {
949
- if (controller !== active) return;
950
- active.progress = applySessionEvent(
951
- active.progress,
952
- reviewer.index,
953
- event,
954
- config.language,
955
- );
956
- },
766
+ runSession: rt.runSession,
957
767
  });
958
- if (controller === active)
959
- active.progress = settleProgress(
960
- active.progress,
961
- result.index,
962
- result.status,
963
- config.language,
964
- result.summary,
965
- result.details,
966
- );
967
768
  if (!actionSignal.aborted)
968
- await dispatch(pi, { type: "REVIEWER_SETTLED", index: result.index, result });
769
+ await dispatch(rt, { type: "REVIEWER_SETTLED", index: result.index, result });
969
770
  } catch (error) {
970
771
  if (actionSignal.aborted) return;
971
- await dispatch(pi, {
772
+ await dispatch(rt, {
972
773
  type: "REVIEWER_SETTLED",
973
774
  index: reviewer.index,
974
775
  result: {
@@ -985,16 +786,13 @@ async function startReviewers(pi: ExtensionAPI): Promise<void> {
985
786
  await Promise.all(tasks);
986
787
  }
987
788
 
988
- async function consultAdvisor(pi: ExtensionAPI): Promise<void> {
989
- const active = controller;
789
+ async function consultAdvisor(rt: ReviewRuntime): Promise<void> {
790
+ const active = rt.controller;
990
791
  if (!active) return;
991
792
  const { state, config } = active;
992
793
  if (!state.pending) return;
993
794
  const pending = state.pending;
994
795
  const actionSignal = active.actionController?.signal ?? active.signal.signal;
995
- active.progressStartedAt = Date.now();
996
- active.progressKind = "advisor";
997
- active.progress = initialProgress([{ model: config.advisor.model }], config.language);
998
796
  const prompt = buildAdvisorPrompt(readPrompt("advisor", config.language), {
999
797
  language: config.language,
1000
798
  focus: state.focus,
@@ -1009,57 +807,19 @@ async function consultAdvisor(pi: ExtensionAPI): Promise<void> {
1009
807
  cwd: active.ctx.cwd,
1010
808
  language: config.language,
1011
809
  signal: actionSignal,
1012
- runSession: active.runSession,
1013
- onEvent: (event) => {
1014
- if (controller !== active) return;
1015
- active.progress = applySessionEvent(active.progress, 0, event, config.language);
1016
- },
810
+ runSession: rt.runSession,
1017
811
  });
1018
- if (controller === active)
1019
- active.progress = settleProgress(
1020
- active.progress,
1021
- 0,
1022
- "passed",
1023
- config.language,
1024
- advisorSummary(result, config.language),
1025
- result.advice,
1026
- );
1027
812
  if (!actionSignal.aborted)
1028
- await dispatch(pi, { type: "ADVISOR_SETTLED", result });
813
+ await dispatch(rt, { type: "ADVISOR_SETTLED", result });
1029
814
  } catch (error) {
1030
815
  if (actionSignal.aborted) return;
1031
- await dispatch(pi, {
816
+ await dispatch(rt, {
1032
817
  type: "INFRASTRUCTURE_ERROR",
1033
818
  details: sessionErrorText("advisor", active.config.language, error),
1034
819
  });
1035
820
  }
1036
821
  }
1037
822
 
1038
- /** 顾问落定后的一行摘要:裁决 + 「下一步方向」首句,与审查者摘要同一展示通道。
1039
- * 建议正文以「核实结论」段开头,直取首行只会露出段标题星号,预览零信息量。 */
1040
- function advisorSummary(result: AdvisorResult, language: Language) {
1041
- const label = decisionText(result.verdict, language);
1042
- const first = adviceHighlight(result.advice);
1043
- if (!first) return label;
1044
- return language === "en" ? `${label}: ${first}` : `${label}:${first}`;
1045
- }
1046
-
1047
- const NEXT_DIRECTION_LABEL = /^\*{0,2}(?:下一步方向|Next direction)\*{0,2}\s*[::]\s*/iu;
1048
-
1049
- function adviceHighlight(advice: string) {
1050
- const lines = advice.split(/\r?\n/u);
1051
- const index = lines.findIndex((line) => NEXT_DIRECTION_LABEL.test(line.trim()));
1052
- const source = index >= 0
1053
- ? [lines[index].trim().replace(NEXT_DIRECTION_LABEL, ""), ...lines.slice(index + 1)]
1054
- : lines;
1055
- const first = source.map(stripBold).find((line) => line.trim())?.trim() ?? "";
1056
- return first.replace(/^[-*+]\s*/u, "").trim();
1057
- }
1058
-
1059
- function stripBold(text: string) {
1060
- return text.replace(/\*\*([^*]+)\*\*/gu, "$1");
1061
- }
1062
-
1063
823
  function sessionErrorText(kind: "reviewer" | "advisor", language: Language, error: unknown) {
1064
824
  const message = error instanceof Error ? error.message : String(error);
1065
825
  if (kind === "reviewer")
@@ -1067,13 +827,9 @@ function sessionErrorText(kind: "reviewer" | "advisor", language: Language, erro
1067
827
  return language === "en" ? `advisor session error: ${message}` : `顾问会话异常:${message}`;
1068
828
  }
1069
829
 
1070
- function deliverFeedbackNow(
1071
- pi: ExtensionAPI,
1072
- details: string,
1073
- advisor: AdvisorResult | null,
1074
- ) {
1075
- const active = controller;
1076
- if (!active || active.pi !== pi) return;
830
+ function deliverFeedbackNow(rt: ReviewRuntime, details: string, advisor: AdvisorResult | null) {
831
+ const active = rt.controller;
832
+ if (!active) return;
1077
833
  const feedback = buildFixFeedback({
1078
834
  language: active.config.language,
1079
835
  details,
@@ -1083,7 +839,7 @@ function deliverFeedbackNow(
1083
839
  // API 返回 void,真实异步失败不会进 try/catch;持久化状态等待 agent_start 回执。
1084
840
  active.feedbackStartTimer = setTimeout(() => {
1085
841
  if (
1086
- controller !== active ||
842
+ rt.controller !== active ||
1087
843
  active.state.phase !== "awaiting_fix" ||
1088
844
  active.state.repair?.status !== "awaiting_start"
1089
845
  ) return;
@@ -1096,12 +852,12 @@ function deliverFeedbackNow(
1096
852
  "error",
1097
853
  );
1098
854
  active.signal.abort();
1099
- void dispatch(pi, { type: "CANCEL", reason: "user" });
855
+ void dispatch(rt, { type: "CANCEL", reason: "user" });
1100
856
  }, FEEDBACK_START_TIMEOUT_MS);
1101
857
  active.feedbackStartTimer.unref?.();
1102
858
  // display:false 的消息进 LLM 上下文但不渲染;triggerTurn 让执行模型开始修复回合。
1103
859
  try {
1104
- pi.sendMessage(
860
+ rt.pi.sendMessage(
1105
861
  { customType: FEEDBACK_TYPE, content: feedback, display: false },
1106
862
  { deliverAs: "followUp", triggerTurn: true },
1107
863
  );
@@ -1112,9 +868,9 @@ function deliverFeedbackNow(
1112
868
  }
1113
869
 
1114
870
  /** 总结提示投递:与修复反馈同一套 agent_start 回执机制;失败不升级,静默收尾。 */
1115
- function deliverSummaryNow(pi: ExtensionAPI, state: ReviewState): void {
1116
- const active = controller;
1117
- if (!active || active.pi !== pi || !state.summary) return;
871
+ function deliverSummaryNow(rt: ReviewRuntime, state: ReviewState): void {
872
+ const active = rt.controller;
873
+ if (!active || !state.summary) return;
1118
874
  const last = state.history.at(-1);
1119
875
  const material = state.summary.kind === "advisor_stop"
1120
876
  ? last?.advisor?.advice ?? last?.details ?? ""
@@ -1128,7 +884,7 @@ function deliverSummaryNow(pi: ExtensionAPI, state: ReviewState): void {
1128
884
  clearFeedbackStartTimer(active);
1129
885
  active.feedbackStartTimer = setTimeout(() => {
1130
886
  if (
1131
- controller !== active ||
887
+ rt.controller !== active ||
1132
888
  active.state.phase !== "summarizing" ||
1133
889
  active.state.summary?.status !== "awaiting_start"
1134
890
  ) return;
@@ -1141,62 +897,62 @@ function deliverSummaryNow(pi: ExtensionAPI, state: ReviewState): void {
1141
897
  : "fire-review 总结回合未能启动,已直接收尾。",
1142
898
  "warning",
1143
899
  );
1144
- void dispatch(pi, { type: "SUMMARY_SETTLED" });
900
+ void dispatch(rt, { type: "SUMMARY_SETTLED" });
1145
901
  }, FEEDBACK_START_TIMEOUT_MS);
1146
902
  active.feedbackStartTimer.unref?.();
1147
903
  try {
1148
- pi.sendMessage(
904
+ rt.pi.sendMessage(
1149
905
  { customType: SUMMARY_REQUEST_TYPE, content: prompt, display: false },
1150
906
  { deliverAs: "followUp", triggerTurn: true },
1151
907
  );
1152
908
  } catch (error) {
1153
909
  clearFeedbackStartTimer(active);
1154
- notifyEffectFailure(error);
1155
- void dispatch(pi, { type: "SUMMARY_SETTLED" });
910
+ notifyEffectFailure(rt, error);
911
+ void dispatch(rt, { type: "SUMMARY_SETTLED" });
1156
912
  }
1157
913
  }
1158
914
 
1159
- function sendCard(pi: ExtensionAPI, card: CardData) {
1160
- if (!controller) return;
915
+ function sendCard(rt: ReviewRuntime, card: CardData) {
916
+ const active = rt.controller;
917
+ if (!active) return;
1161
918
  // pi-flow 的用户取消是即时临时通知,不进会话;shutdown 静默收口。
1162
919
  if (card.kind === "cancel") {
1163
- if (card.reason === "user" && controller.ctx.hasUI)
1164
- controller.ctx.ui.notify(
1165
- controller.config.language === "en"
1166
- ? "⏸ Review cancelled\nStopped by user"
1167
- : "⏸ 审查已取消\n已按你的操作停止",
920
+ if (card.reason === "user" && active.ctx.hasUI)
921
+ active.ctx.ui.notify(
922
+ active.config.language === "en"
923
+ ? "Review cancelled\nStopped by user"
924
+ : "审查已取消\n已按你的操作停止",
1168
925
  "info",
1169
926
  );
1170
927
  return;
1171
928
  }
1172
929
  // 宿主在 streaming 时会把无 options 的 sendMessage 当 steer 塞进当前模型回合。
1173
930
  // 卡片只是 UI 投影,绝不能因此唤醒或打断执行模型。
1174
- if (!controller.ctx.isIdle()) {
1175
- controller.pendingCards.push(card);
931
+ if (!active.ctx.isIdle()) {
932
+ active.pendingCards.push(card);
1176
933
  return;
1177
934
  }
1178
- sendCardNow(pi, card);
935
+ sendCardNow(rt, active, card);
1179
936
  }
1180
937
 
1181
- function flushPendingCards(pi: ExtensionAPI): void {
1182
- const active = controller;
938
+ function flushPendingCards(rt: ReviewRuntime): void {
939
+ const active = rt.controller;
1183
940
  if (!active || !active.ctx.isIdle() || active.pendingCards.length === 0) return;
1184
941
  const cards = active.pendingCards.splice(0);
1185
942
  for (const card of cards) {
1186
943
  try {
1187
- sendCardNow(pi, card);
944
+ sendCardNow(rt, active, card);
1188
945
  } catch (error) {
1189
- notifyEffectFailure(error);
946
+ notifyEffectFailure(rt, error);
1190
947
  }
1191
948
  }
1192
949
  }
1193
950
 
1194
- function sendCardNow(pi: ExtensionAPI, card: CardData): void {
1195
- if (!controller) return;
1196
- const built = buildCard(card, controller.config.language);
1197
- pi.sendMessage({
951
+ function sendCardNow(rt: ReviewRuntime, active: Controller, card: CardData): void {
952
+ const built = buildCard(card, active.config.language);
953
+ rt.pi.sendMessage({
1198
954
  customType: CARD_TYPE,
1199
- content: reviewEnvelope(built.content),
955
+ content: wrapEnvelope("firecode_review", built.content),
1200
956
  display: true,
1201
957
  details: built.details,
1202
958
  });
@@ -1210,8 +966,8 @@ function parseCommand(args: string, language: Language): { focus: string } | { e
1210
966
  }
1211
967
 
1212
968
  /** 会话分支 entries(供证据组装);本插件的卡与反馈消息不参与证据,避免自指。 */
1213
- function sessionEntries() {
1214
- const manager = controller?.ctx.sessionManager as
969
+ function sessionEntries(rt: ReviewRuntime) {
970
+ const manager = rt.controller?.ctx.sessionManager as
1215
971
  | { getBranch?: () => unknown[] }
1216
972
  | undefined;
1217
973
  const entries = manager?.getBranch?.() ?? [];
@@ -1234,13 +990,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1234
990
  return typeof value === "object" && value !== null && !Array.isArray(value);
1235
991
  }
1236
992
 
1237
- // 导出供测试用(纯函数 / 类型)
1238
- export { CHECKPOINT_TYPE };
1239
-
1240
- /** 测试专用:等待 event-loop barrier 与其产生的状态迁移排空。 */
1241
- export async function __reviewFlushForTests(): Promise<void> {
1242
- await new Promise<void>((resolve) => setImmediate(resolve));
1243
- await dispatchQueue;
1244
- await new Promise<void>((resolve) => setImmediate(resolve));
1245
- await dispatchQueue;
1246
- }