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
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 审查占用频道:review 是唯一发布者,herdr 集成、输入框外壳与观察员订阅;频道名与 payload 只在这里定义。
3
+ * 消费者按 active 的 true/false 做计数配对,所以进度变化不能靠重发 true:持有时带活的 progress 访问器,
4
+ * 外壳每次绘制调用它。progress 是进程内求值函数,频道不可序列化转发。
5
+ */
6
+ export const OCCUPANCY_CHANNEL = "herdr:blocked";
7
+ /** 持有时的展示标签:herdr 侧边栏与 Master 的 state_labels 判定都读它。 */
8
+ export const OCCUPANCY_LABEL = "对抗审查进行中";
9
+
10
+ /** 审查此刻在哪一步:排队等回合结束、审查者在审、顾问介入、执行模型修复、总结回合。 */
11
+ export type ReviewStage = "queued" | "reviewing" | "advisor" | "fixing" | "summarizing";
12
+
13
+ export interface ReviewProgress {
14
+ stage: ReviewStage;
15
+ /** 当前轮次;排队时为 0。 */
16
+ round: number;
17
+ /** 本轮通过 / 阻断 / 审查者总数;只有 reviewing 有意义。 */
18
+ passed: number;
19
+ blocked: number;
20
+ total: number;
21
+ }
22
+
23
+ export type OccupancyPayload =
24
+ | { active: true; label: string; progress: () => ReviewProgress | undefined }
25
+ | { active: false };
package/review/outcome.ts CHANGED
@@ -2,9 +2,6 @@ import { readFileSync } from "node:fs";
2
2
  import { CHECKPOINT_TYPE, isValidCheckpoint } from "./checkpoint.js";
3
3
  import type { ReviewState } from "./state.js";
4
4
 
5
- /** 审查活跃期在 herdr:blocked 频道发布的展示标签。 */
6
- export const REVIEW_OCCUPANCY_LABEL = "对抗审查进行中";
7
-
8
5
  export type ReviewOutcome =
9
6
  | { status: "passed"; runId: string; rounds: number }
10
7
  | { status: "stopped"; runId: string; rounds: number; advisorAdvice?: string }
@@ -13,7 +10,7 @@ export type ReviewOutcome =
13
10
  | { status: "none"; runId?: string }
14
11
  | { status: "error"; message: string };
15
12
 
16
- /** 只读 Worker session,解析最近一条 fire-review checkpoint 的判定。 */
13
+ /** 只读 Worker session,解析最近一条 fire-review checkpoint 的判定;会话结束时的一次性兜底读取用它。 */
17
14
  export function readReviewOutcome(sessionPath: string): ReviewOutcome {
18
15
  let content: string;
19
16
  try {
@@ -41,20 +38,47 @@ export function readReviewOutcome(sessionPath: string): ReviewOutcome {
41
38
  else damage ??= "fire-review checkpoint 格式无效";
42
39
  }
43
40
  if (!latest) return damage ? { status: "error", message: damage } : { status: "none" };
41
+ return outcomeOf(latest);
42
+ }
43
+
44
+ /** 会话事件里刚追加的一条记录若是有效 checkpoint,给出它的判定;订阅方据此增量跟进,不重读整份 JSONL。 */
45
+ export function outcomeOfEntry(entry: unknown): ReviewOutcome | undefined {
46
+ const state = checkpointOf(entry);
47
+ return state && outcomeOf(state);
48
+ }
49
+
50
+ /** 审查进行中的轮次与审查者进度;不是审查相的 checkpoint 或不是 checkpoint 都给 undefined。 */
51
+ export interface ReviewProgress {
52
+ round: number;
53
+ settled: number;
54
+ total: number;
55
+ }
56
+
57
+ export function reviewProgressOf(entry: unknown): ReviewProgress | undefined {
58
+ const active = checkpointOf(entry)?.active;
59
+ return active ? { round: active.round, settled: active.settledCount, total: active.reviewers.length } : undefined;
60
+ }
61
+
62
+ function checkpointOf(entry: unknown): ReviewState | undefined {
63
+ return isCheckpointEntry(entry) && isValidCheckpoint(entry.data) ? entry.data as ReviewState : undefined;
64
+ }
44
65
 
66
+ function outcomeOf(latest: ReviewState): ReviewOutcome {
45
67
  if (latest.phase === "idle") return { status: "none", runId: latest.runId };
46
68
  if (latest.phase !== "settled") return { status: "in_progress", runId: latest.runId };
47
69
  const rounds = latest.history.length;
48
- const result = latest.history.at(-1)?.result;
70
+ const last = latest.history.at(-1);
71
+ const result = last?.result;
49
72
  if (result === "passed") return { status: "passed", runId: latest.runId, rounds };
50
73
  // stopped(顾问叫停)与 failed(maxRounds 用尽)都是质量裁决终止;
51
74
  // error / cancelled / timed_out 是基础设施故障或人为中断,不弱化成“停止”。
52
75
  if (result === "stopped" || result === "failed") {
53
76
  // 顾问叫停时把裁决带给读取方:Master 拿到停止原因才能调整方向。
54
- const advice = latest.history.at(-1)?.advisor?.advice;
77
+ const advice = last?.advisor?.advice;
55
78
  return { status: "stopped", runId: latest.runId, rounds, ...(advice ? { advisorAdvice: advice } : {}) };
56
79
  }
57
- return { status: "failed", runId: latest.runId, rounds, reason: result ?? "unknown" };
80
+ // 轮记录的 details 已写明故障形态(超时/供应商报错);枚举名只是它缺失时的兜底。
81
+ return { status: "failed", runId: latest.runId, rounds, reason: last?.details?.trim() || result || "unknown" };
58
82
  }
59
83
 
60
84
  function isCheckpointEntry(value: unknown): value is { data: unknown } {
package/review/prompt.ts CHANGED
@@ -6,6 +6,7 @@ import { readFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import type { Language } from "../config.js";
9
+ import { wrapEnvelope } from "../deliver.js";
9
10
  import type { AdvisorResult, ReviewState, SummaryKind } from "./state.js";
10
11
 
11
12
  const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "prompts");
@@ -67,7 +68,7 @@ function reviewReminder(language: Language) {
67
68
  }
68
69
 
69
70
  /** 往轮 FAIL 发现清单(两相收敛的闭环输入):第 2 轮起注入。 */
70
- export function priorRoundsSection(
71
+ function priorRoundsSection(
71
72
  history: ReviewState["history"],
72
73
  round: number,
73
74
  language: Language,
@@ -141,12 +142,9 @@ export function buildFixFeedback(input: FixFeedbackInput): string {
141
142
  : "顾问建议";
142
143
  parts.push("", `${label}${input.language === "en" ? ":" : ":"}`, input.advisor.advice);
143
144
  }
144
- return reviewEnvelope(parts.join("\n"));
145
+ return wrapEnvelope("firecode_review", parts.join("\n"));
145
146
  }
146
147
 
147
- export function reviewEnvelope(content: string): string {
148
- return `<firecode_review>\n${content}\n</firecode_review>`;
149
- }
150
148
 
151
149
  function narrowInstruction(language: Language, narrowed: boolean) {
152
150
  if (!narrowed) return language === "en" ? FIX_INSTRUCTION_EN : FIX_INSTRUCTION_ZH;
@@ -175,14 +173,15 @@ export interface SummaryPromptInput {
175
173
 
176
174
  /** 质量裁决终态后投给执行模型的总结回合提示:人话收尾,带反循环禁令。 */
177
175
  export function buildSummaryPrompt(input: SummaryPromptInput): string {
178
- const material = input.material.length > SUMMARY_MATERIAL_LIMIT
179
- ? `${input.material.slice(0, SUMMARY_MATERIAL_LIMIT)}\n…`
176
+ const omitted = input.material.length - SUMMARY_MATERIAL_LIMIT;
177
+ const material = omitted > 0
178
+ ? `${input.material.slice(0, SUMMARY_MATERIAL_LIMIT)}\n${input.language === "en" ? `[material truncated: ${omitted} characters omitted]` : `[材料截断:省略 ${omitted} 字]`}`
180
179
  : input.material;
181
180
  const body = summaryInstruction(input.language, input.kind, input.rounds);
182
181
  const content = material.trim()
183
182
  ? `${body}\n\n${summaryMaterialLabel(input.language, input.kind)}\n${material}`
184
183
  : body;
185
- return reviewEnvelope(content);
184
+ return wrapEnvelope("firecode_review", content);
186
185
  }
187
186
 
188
187
  function summaryMaterialLabel(language: Language, kind: SummaryKind): string {
@@ -22,6 +22,8 @@ Requirement anchor: the first user message is the original request; later user m
22
22
  Blocking candidates (High/Medium):
23
23
  - Logic defects: wrong assumptions, missed edge cases, missing error handling, races
24
24
  - Fake or insufficient tests: new logic uncovered, weak assertions, hardcoded bypass of real logic
25
+ - Acceptance-test integrity: the implementation commit modified acceptance tests (assertions, cases, special-cased inputs); acceptance tests are implementation-coupled or tautological and verify the implementation rather than the requirement; the red run did not fail for "not yet implemented"
26
+ - Engineering principles (Medium): a second source for the same fact, rule, or config; fallbacks, dead code, or stale notes kept for old paths; patching the symptom while the root cause stands; a new layer or abstraction that does not absorb existing duplication; new or touched tests that fail the better-test value gate
25
27
  - Key delivery claims you verified to be false
26
28
  - Regression risk: changes break existing behavior
27
29
  - The original or corrected current scope is unmet
@@ -31,12 +33,11 @@ Non-blocking (always to suggestions, never FAIL): unrelated changes mixed into d
31
33
 
32
34
  ## Evidence
33
35
 
34
- - Only two kinds of facts count: project files you actually read, and output of safe verification commands you actually ran. Session evidence is a lead, never a substitute.
35
- - Second-hand claims are not evidence: "done / changed / tests pass" claims are not review evidence; verify independently.
36
- - The working tree may contain parallel work or pre-existing uncommitted changes: scope-violation or unrelated-change findings must be grounded in edits this session actually performed per the session evidence; diff changes that cannot be attributed to this session must not be filed as findings — at most note them in the suggestions section.
37
- - First-hand code and logic verification is the strongest evidence. Test or command failures may come from parallel edits on a shared checkout: when a failure involves areas this session never touched, first verify whether parallel changes caused it; failures that cannot be attributed to this session must not be filed — at most note them in the suggestions section with a rerun hint.
38
- - Running tests alone is not enough for PASS: actually read the source files relevant to this change and check the implementation logic; a passing test is not proof of correctness.
39
- - If you use bash, only run safe verification; never modify files, install dependencies, delete files, or run git reset/clean/checkout/commit/rebase.
36
+ - Only two kinds of facts count: project files you actually read, and output of verification commands you actually ran. Session evidence is a lead; key judgments return to these two.
37
+ - "evidence truncated" / "truncated, N chars" markers in the session record are omissions made when assembling evidence, not messages that were left unfinished; before judging a deliverable incomplete, read the original from the session file the marker points to.
38
+ - Attribution: the checkout may carry parallel work or pre-existing uncommitted changes; attribute by the tool trail in the session record (the paths this session actually edited). File scope-violation, unrelated-change, and command-failure findings only against changes attributable to this session; anything else goes to suggestions at most, failures with a rerun hint.
39
+ - PASS rests on reading the source relevant to this change and checking its logic; run verification through the project's existing entry points at the narrowest scope covering the change, widening only when the affected scope cannot be judged.
40
+ - bash is for verification only; never modify files, install dependencies, delete files, or run git reset/clean/checkout/commit/rebase.
40
41
 
41
42
  ## Two-phase convergence (no lowering the bar)
42
43
 
@@ -22,6 +22,8 @@
22
22
  阻塞项(高/中候选):
23
23
  - 逻辑缺陷:错误假设、边界条件遗漏、错误处理缺失、竞态
24
24
  - 虚假或不充分的测试:未覆盖新逻辑、断言过弱、硬编码绕过真实逻辑
25
+ - 验收测试失信:实现提交改动了验收测试(断言、用例、特判输入);验收测试与实现耦合或同义反复,验的不是需求;红的原因不是"尚未实现"
26
+ - 工程原则(中):同一事实、规则或配置出现第二个来源;为兼容旧路径保留的回退、死代码或过时说明;在症状处打补丁而根因未动;新增中间层或抽象却没有收口既有重复;新增或触及的测试不过 better-test 的价值门
25
27
  - 关键交付声明经你核实不成立
26
28
  - 回归风险:变更破坏既有行为
27
29
  - 原始需求或修正后的当前范围未被满足
@@ -31,12 +33,11 @@
31
33
 
32
34
  ## 证据规则
33
35
 
34
- - 事实证据只有两类:你实际读取的当前项目文件、你实际运行的安全验证命令输出。会话证据是线索,可作指引,但关键判断必须回到这两类证据。
35
- - 二手声明不是证据:assistant 自称"已完成 / 已修改 / 测试通过"都不是审查证据;必须独立核实。
36
- - 工作树可能包含并行工作或既有未提交改动:范围越界、混入无关变更类判定必须以会话证据中本会话实际执行的编辑为准;无法归因到本会话的 diff 变更不得立案为发现,至多写入建议区。
37
- - 代码与逻辑的一手核对是最硬的证据。测试或命令失败可能来自共享 checkout 上并行修改的干扰:失败涉及本会话未触碰的区域时,先核实是否并行改动所致;无法归因到本会话的失败不得立案,至多写入建议区并提示复跑。
38
- - 只运行测试或检查命令不足以支撑 PASS:必须实际读取与本次变更相关的源码文件,核对实现逻辑;测试通过不等于逻辑正确。
39
- - 若使用 bash,只做安全验证;不得修改文件、安装依赖、删除文件,或执行 git reset/clean/checkout/commit/rebase。
36
+ - 事实证据只有两类:你实际读取的当前项目文件、你实际运行的验证命令输出。会话证据是线索,关键判断回到这两类证据。
37
+ - 会话记录里的「证据截断」「截断,原文 N 字」标记是证据组装为控制篇幅做的省略,不是消息本身没写完;判断交付是否完整前,按标记给出的会话文件路径用 read 核对原文。
38
+ - 归因:checkout 可能含并行工作或既有未提交改动,以会话记录中的工具轨迹(本会话实际编辑的路径)归因。范围越界、混入无关变更与命令失败只对归因到本会话的改动立案;归因不到的至多写入建议区,失败附复跑提示。
39
+ - PASS 建立在实读与本次变更相关的源码并核对逻辑之上;验证命令走项目现有入口,跑覆盖变更的最窄范围,影响范围无法判断时再扩大。
40
+ - bash 只做验证;不得修改文件、安装依赖、删除文件,或执行 git reset/clean/checkout/commit/rebase。
40
41
 
41
42
  ## 两相收敛(不降低质量标准)
42
43
 
@@ -19,8 +19,6 @@ export interface RunReviewerOptions {
19
19
  language: Language;
20
20
  signal?: AbortSignal;
21
21
  runSession: ReviewSessionRunner;
22
- /** 结构化会话事件:驱动活动条的实时进度。 */
23
- onEvent?: (event: Record<string, unknown>) => void;
24
22
  }
25
23
 
26
24
  export type ParseOutcome = {
@@ -40,7 +38,6 @@ export async function runReviewer(options: RunReviewerOptions): Promise<Reviewer
40
38
  cwd: options.cwd,
41
39
  timeoutMs: options.config.timeoutMs,
42
40
  signal: options.signal,
43
- onEvent: options.onEvent,
44
41
  });
45
42
  const parsed =
46
43
  result.kind === "output"
@@ -193,7 +190,7 @@ const FINDING_FIELDS = [
193
190
  const SUGGESTIONS_HEADING = /^##\s+(?:建议(非阻塞)|Suggestions \(non-blocking\))\s*$/iu;
194
191
 
195
192
  /** PASS 证据锚点闸门:摘要行在前,首个证据行必须同时含文件段(带扩展名)与命令段。 */
196
- export function passIssue(body: string): string | undefined {
193
+ function passIssue(body: string): string | undefined {
197
194
  const lines = body
198
195
  .split(/\r?\n/)
199
196
  .map((line) => line.trim())
@@ -211,7 +208,7 @@ export function passIssue(body: string): string | undefined {
211
208
  * FAIL 发现闸门:至少一条带「问题」的发现,且不能全落在「建议(非阻塞)」区。
212
209
  * 空 FAIL 或一段散文都不能驱动执行模型改代码——格式非法的票一律作废为基础设施错误。
213
210
  */
214
- export function failIssue(body: string, language: Language): string | undefined {
211
+ function failIssue(body: string, language: Language): string | undefined {
215
212
  const noFinding =
216
213
  language === "en"
217
214
  ? "FAIL has no blocking finding: a `## Finding` section is required"
package/review/session.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
1
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
2
  import type { ThinkingLevelValue } from "../config.js";
4
3
  import type { InProcessSessionPool } from "../master/spawn.js";
5
4
  import type { PromptLayers } from "./prompt.js";
5
+ import { textOf } from "../format.js";
6
6
 
7
7
  export type ReviewSessionResult =
8
8
  | { kind: "output"; text: string }
@@ -13,7 +13,6 @@ export type ReviewSessionResult =
13
13
 
14
14
  export interface ReviewSessionOptions {
15
15
  pool: InProcessSessionPool;
16
- resolveModel(id: string): Promise<Model<any>>;
17
16
  role: "reviewer" | "advisor";
18
17
  model: string;
19
18
  thinking: ThinkingLevelValue;
@@ -22,28 +21,24 @@ export interface ReviewSessionOptions {
22
21
  cwd: string;
23
22
  timeoutMs: number;
24
23
  signal?: AbortSignal;
25
- onEvent?: (event: AgentSessionEvent) => void;
26
24
  }
27
25
 
28
26
  export type ReviewSessionRunner = (
29
- options: Omit<ReviewSessionOptions, "pool" | "resolveModel">,
27
+ options: Omit<ReviewSessionOptions, "pool">,
30
28
  ) => Promise<ReviewSessionResult>;
31
29
 
32
- export function createReviewSessionRunner(
33
- pool: InProcessSessionPool,
34
- resolveModel: ReviewSessionOptions["resolveModel"],
35
- ): ReviewSessionRunner {
36
- return (options) => runReviewSession({ ...options, pool, resolveModel });
30
+ export function createReviewSessionRunner(pool: InProcessSessionPool): ReviewSessionRunner {
31
+ return (options) => runReviewSession({ ...options, pool });
37
32
  }
38
33
 
39
- export async function runReviewSession(options: ReviewSessionOptions): Promise<ReviewSessionResult> {
34
+ async function runReviewSession(options: ReviewSessionOptions): Promise<ReviewSessionResult> {
40
35
  if (options.signal?.aborted) return { kind: "aborted" };
41
36
  let spawned: Awaited<ReturnType<InProcessSessionPool["spawn"]>>;
42
37
  try {
43
38
  spawned = await options.pool.spawn({
44
39
  cwd: options.cwd,
45
40
  role: options.role,
46
- model: await options.resolveModel(options.model),
41
+ model: await options.pool.resolveModel(options.model),
47
42
  thinking: options.thinking,
48
43
  tools: [...new Set(options.tools)].filter((tool) => tool !== "write" && tool !== "edit"),
49
44
  systemPrompt: { mode: "replace", text: clean(options.prompt.system) },
@@ -57,7 +52,7 @@ export async function runReviewSession(options: ReviewSessionOptions): Promise<R
57
52
  : { kind: "error", message: errorText(error) };
58
53
  }
59
54
  if (options.signal?.aborted) {
60
- spawned.dispose();
55
+ await spawned.dispose();
61
56
  return { kind: "aborted" };
62
57
  }
63
58
 
@@ -66,12 +61,11 @@ export async function runReviewSession(options: ReviewSessionOptions): Promise<R
66
61
  const unsubscribe = spawned.session.subscribe((event) => {
67
62
  const assistant = assistantMessage(event);
68
63
  if (assistant) {
69
- finalText = messageText(assistant.content);
64
+ finalText = textOf(assistant.content);
70
65
  finalError = assistant.stopReason === "error"
71
66
  ? assistant.errorMessage || "model error"
72
67
  : undefined;
73
68
  }
74
- options.onEvent?.(event);
75
69
  });
76
70
  let interrupted: "aborted" | "timeout" | undefined;
77
71
  let wake!: () => void;
@@ -84,17 +78,15 @@ export async function runReviewSession(options: ReviewSessionOptions): Promise<R
84
78
  finalError = errorText(error);
85
79
  });
86
80
  await Promise.race([run, interruption]);
87
- if (interrupted) {
88
- await spawned.session.abort();
89
- return { kind: interrupted };
90
- }
81
+ // 中断只靠 finally 的 dispose 收尾:pi 的 abort 在模型流卡死时永不返回,等它会拖住整个关闭链。
82
+ if (interrupted) return { kind: interrupted };
91
83
  if (finalError) return { kind: "error", message: finalError };
92
84
  return finalText?.trim() ? { kind: "output", text: finalText } : { kind: "empty" };
93
85
  } finally {
94
86
  clearTimeout(timeout);
95
87
  options.signal?.removeEventListener("abort", onAbort);
96
88
  unsubscribe();
97
- spawned.dispose();
89
+ await spawned.dispose();
98
90
  }
99
91
  }
100
92
 
@@ -109,17 +101,6 @@ function assistantMessage(event: AgentSessionEvent): {
109
101
  return [...event.messages].reverse().find((message) => message.role === "assistant");
110
102
  }
111
103
 
112
- function messageText(content: unknown): string {
113
- if (typeof content === "string") return content;
114
- if (!Array.isArray(content)) return "";
115
- return content
116
- .filter((part): part is { type: "text"; text: string } =>
117
- typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text"
118
- && typeof (part as { text?: unknown }).text === "string")
119
- .map((part) => part.text)
120
- .join("\n");
121
- }
122
-
123
104
  function clean(text: string): string {
124
105
  return text.replaceAll("\0", "");
125
106
  }
package/review/state.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * 事故终态(取消 / 超时 / 基础设施错误)直接 settled,不烧总结回合。
11
11
  * 不变量:同一时刻至多一个活动轮;round 单调递增;history 只追加不改写。
12
12
  */
13
+ import type { ModelAtom } from "../config.js";
14
+
13
15
  export type Phase =
14
16
  | "idle"
15
17
  | "queued"
@@ -25,10 +27,8 @@ export type AdvisorVerdict = "continue" | "stop" | "narrow";
25
27
  export type StopReason = "advisor" | "max_rounds" | "user" | "shutdown" | "timeout";
26
28
 
27
29
  /** 单个审查者的输出(output 契约解析结果,纯数据)。 */
28
- export interface ReviewerResult {
30
+ export interface ReviewerResult extends ModelAtom {
29
31
  index: number;
30
- model: string;
31
- thinking: string;
32
32
  status: Exclude<ReviewerStatus, "running">;
33
33
  /** 短摘要:PASS 一行收敛摘要 / FAIL 发现一句话。 */
34
34
  summary: string;
@@ -37,10 +37,8 @@ export interface ReviewerResult {
37
37
  }
38
38
 
39
39
  /** 审查中某个审查者的进行状态;settled 后携带完整结果。 */
40
- export interface ActiveReviewer {
40
+ export interface ActiveReviewer extends ModelAtom {
41
41
  index: number;
42
- model: string;
43
- thinking: string;
44
42
  status: ReviewerStatus;
45
43
  result: ReviewerResult | null;
46
44
  }
@@ -125,7 +123,7 @@ export interface ReviewLimits {
125
123
  advisorAfterFailures: number;
126
124
  advisorModel: string;
127
125
  /** 本轮审查者(model/thinking),beginRound 时填入 active。 */
128
- reviewers: { model: string; thinking: string }[];
126
+ reviewers: ModelAtom[];
129
127
  language?: "zh" | "en";
130
128
  }
131
129
 
@@ -242,7 +240,7 @@ function onStart(
242
240
  if (state.phase === "reviewing" || state.phase === "needs_fix" || state.phase === "awaiting_fix")
243
241
  return { state, effects: [] };
244
242
  const focus = event.focus.trim();
245
- // 排队相不发卡:状态栏与活动条已各有一份排队提示,记录里只留开始/结果卡。
243
+ // 排队相不发卡:输入框边框与审查活动行已各有一份排队提示,记录里只留开始/结果卡。
246
244
  if (event.busy)
247
245
  return {
248
246
  state: {
@@ -490,7 +488,7 @@ function settleRound(
490
488
  return {
491
489
  state: { ...base, phase: "needs_fix", pending, consecutiveFailures },
492
490
  // 顾问可能裁定 stop,反馈永远不会投递:此时不能提前宣布「已交回修复」;
493
- // 卡里也不写「顾问介入中」——持久记录会过时,实况由活动条与状态栏承担。
491
+ // 卡里也不写「顾问介入中」——持久记录会过时,实况由审查活动行与输入框边框承担。
494
492
  effects: [failCard, { kind: "advance" }],
495
493
  };
496
494
  return {
@@ -518,7 +516,6 @@ function onAdvisorSettled(
518
516
  if (state.phase !== "needs_fix" || !state.pending) return { state, effects: [] };
519
517
  const pending = state.pending;
520
518
  const advisor = event.result;
521
- const displayDetails = aggregateDetails(pending.reviewers, limits.language ?? "zh");
522
519
  if (advisor.verdict === "stop") {
523
520
  const round = roundRecord(pending.round, "stopped", advisor.advice, pending.reviewers, advisor, state.roundStartedAt, now);
524
521
  return {
@@ -718,20 +715,14 @@ function aggregate(
718
715
  archiveDetails: string;
719
716
  summary: string;
720
717
  } {
721
- const failed = reviewers.filter((item) => item.status === "failed");
722
- const errors = reviewers.filter((item) => item.status === "error");
718
+ const verdicts = reviewers.filter((item) => item.status === "passed" || item.status === "failed");
719
+ const absent = reviewers.filter((item) => item.status === "error");
723
720
  const archiveDetails = aggregateDetails(reviewers, language);
724
- const fatalErrors = errors.filter((item) => !isFormatError(item.details));
725
- // 只忽略明确的格式错误票;任何真实基础设施错误都阻止形成质量结论,
726
- // 即使同轮已有 FAIL,也不能把未完整形成的审查误报为 Review Failed。
727
- if (fatalErrors.length > 0)
728
- return {
729
- result: "error",
730
- displayDetails: aggregateDetails(fatalErrors, language),
731
- feedbackDetails: "",
732
- archiveDetails,
733
- summary: "",
734
- };
721
+ // 有裁决就成轮:缺席者(会话故障或输出契约违例)只在结论里点名,不阻止形成质量结论;
722
+ // 全员缺席才是基础设施不可用。否则一个供应商额度耗尽就会让整条审查通道停摆。
723
+ if (verdicts.length === 0)
724
+ return { result: "error", displayDetails: archiveDetails, feedbackDetails: "", archiveDetails, summary: "" };
725
+ const failed = verdicts.filter((item) => item.status === "failed");
735
726
  if (failed.length > 0)
736
727
  return {
737
728
  result: "failed",
@@ -740,65 +731,46 @@ function aggregate(
740
731
  archiveDetails,
741
732
  summary: "",
742
733
  };
743
- const passed = reviewers.filter((item) => item.status === "passed");
744
- if (passed.length === 0)
745
- return {
746
- result: "error",
747
- displayDetails: aggregateDetails(errors, language),
748
- feedbackDetails: "",
749
- archiveDetails,
750
- summary: "",
751
- };
752
734
  return {
753
735
  result: "passed",
754
736
  displayDetails: archiveDetails,
755
737
  feedbackDetails: "",
756
738
  archiveDetails,
757
- summary: aggregatePassSummary(passed, language),
739
+ summary: aggregatePassSummary(verdicts, absent, language),
758
740
  };
759
741
  }
760
742
 
761
- function isFormatError(details: string) {
762
- return details.startsWith("审查输出格式无效") || details.startsWith("review output format invalid");
763
- }
764
-
765
743
  function aggregateDetails(reviewers: ReviewerResult[], language: "zh" | "en"): string {
766
744
  return reviewers
767
745
  .map((item) => `${modelLabel(item, language)}\n${item.details.trim()}`)
768
746
  .join("\n\n");
769
747
  }
770
748
 
771
- function aggregatePassSummary(reviewers: ReviewerResult[], language: "zh" | "en") {
772
- const fallback = language === "en" ? "Review passed." : "审查通过。";
773
- if (reviewers.length === 1) {
774
- const reviewer = reviewers[0];
775
- const body = passBody(reviewer?.summary ?? "") || fallback;
776
- const suggestions = splitSuggestions(reviewer?.details ?? "").suggestions;
777
- if (suggestions.length === 0) return body;
778
- return [
779
- body,
780
- "",
781
- language === "en" ? "## Suggestions (non-blocking)" : "## 建议(非阻塞)",
782
- ...suggestions.map((item) => `- ${item}`),
783
- ].join("\n");
784
- }
785
- const parts = reviewers.map((item) => ({
786
- body: item.summary,
787
- suggestions: splitSuggestions(item.details).suggestions,
788
- }));
789
- const lines = parts.map((part, index) =>
790
- `• ${shortModel(reviewers[index]?.model ?? "")}${language === "en" ? ": " : ":"}${passBody(part.body) || fallback}`,
791
- );
792
- const suggestions = [...new Set(parts.flatMap((part) => part.suggestions))];
749
+ function aggregatePassSummary(passed: ReviewerResult[], absent: ReviewerResult[], language: "zh" | "en") {
750
+ const en = language === "en";
751
+ const fallback = en ? "Review passed." : "审查通过。";
752
+ const lines = passed.length === 1
753
+ ? [passBody(passed[0]?.summary ?? "") || fallback]
754
+ : passed.map((item) => `• ${shortModel(item.model)}${en ? ": " : ":"}${passBody(item.summary) || fallback}`);
755
+ if (absent.length > 0)
756
+ lines.push(
757
+ (en ? "No verdict from " : "未形成裁决:")
758
+ + absent.map((item) => `${shortModel(item.model)}${en ? ` (${firstLine(item.details)})` : `(${firstLine(item.details)})`}`).join(en ? ", " : "、"),
759
+ );
760
+ const suggestions = [...new Set(passed.flatMap((item) => splitSuggestions(item.details).suggestions))];
793
761
  if (suggestions.length === 0) return lines.join("\n");
794
762
  return [
795
763
  ...lines,
796
764
  "",
797
- language === "en" ? "## Suggestions (non-blocking)" : "## 建议(非阻塞)",
765
+ en ? "## Suggestions (non-blocking)" : "## 建议(非阻塞)",
798
766
  ...suggestions.map((item) => `- ${item}`),
799
767
  ].join("\n");
800
768
  }
801
769
 
770
+ function firstLine(text: string) {
771
+ return text.trim().split(/\r?\n/u, 1)[0]?.split(/[::]/u, 1)[0]?.trim() ?? "";
772
+ }
773
+
802
774
  function splitSuggestions(summary: string) {
803
775
  const lines = summary.split(/\r?\n/u);
804
776
  const index = lines.findIndex((line) => /^##\s*(?:建议(非阻塞)|Suggestions \(non-blocking\))/iu.test(line.trim()));