@jayyuen66/dsh-ocr-review 0.1.4 → 0.1.6

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.
package/host.js CHANGED
@@ -1,17 +1,15 @@
1
1
  // GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
2
2
  import { createRequire } from "node:module";
3
3
  import Schema from "@deepseek-ai/schemastery";
4
- import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
4
+ import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import path from "node:path";
7
- import { randomUUID } from "node:crypto";
7
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
8
8
  import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
9
- import { jobOutcomeOf } from "@jayyuen66/dsh-plugin-shared/lib/job-outcome";
10
- import { guardBody, sendJson } from "@jayyuen66/dsh-plugin-shared/lib/http";
11
- import { guardTrust } from "@jayyuen66/dsh-plugin-shared/lib/trust";
9
+ import { jobOutcomeOf, killProc } from "@jayyuen66/dsh-plugin-shared/lib/job-outcome";
12
10
  import { LOCALE_SETTINGS_NAMESPACE, messagesFor, resolveLocalePreference } from "@jayyuen66/dsh-plugin-shared/lib/locale";
13
11
  import { truncateEnd, truncateStart } from "@jayyuen66/dsh-plugin-shared/lib/text";
14
- import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
12
+ import { fieldOf, hasMethods, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
15
13
  import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
16
14
  import { errorText } from "@jayyuen66/dsh-plugin-shared/lib/errors";
17
15
  import { mkdir } from "node:fs/promises";
@@ -20,15 +18,20 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
20
18
  const MESSAGES = {
21
19
  zh: {
22
20
  middleEllipsis: " …[中间省略]… ",
23
- execTimedOut: "ocr 执行超时(实际生效 {minutes} 分钟 = min(请求, shell.maxTimeoutMs)):请缩小审查范围(exclude/--commit/更小改动)、调低 OCR 每组 --timeout,调大 settings.yaml shell.maxTimeoutMs,或改用 wait=false 后台模式(无超时)",
21
+ execTimedOut: "ocr 执行超时(实际生效 {minutes} 分钟 = min(请求, shell.maxTimeoutMs)):请缩小审查范围(exclude/--commit/更小改动)、调低 OCR 每组 --timeout,抬高 shell 执行器(bash-local/pwsh-local)设置项里的 maxTimeoutMs,或改用 wait=false 后台模式(默认不设时限)",
24
22
  execAborted: "ocr 执行被中止",
25
- execNotFound: "ocr 命令不存在:请先 `brew install open-code-review` 或 `npm i -g @alibaba-group/open-code-review`",
23
+ execNotFound: "ocr 命令不存在:命令词交下去没被系统找到(PATH 上那份可能刚被移走或改名)。请到「设置 → 插件 → ocr-review」的外部 CLI 那一行看与本插件核对版本一致的装法,装完直接重试本工具即可",
24
+ ocrBinaryMissing: "两条路都没找到 ocr:PATH 上没有可执行候选,本插件也不随包分发它(装法由你选,免得装上本插件就顺带改了全局工具链)。{install} 装完直接重试本工具即可,不必重启 dsh(每次调用都重新判一次,结果不缓存)。",
25
+ installHintBoth: "两条命令任选:brew 那条 `{brewCmd}`;npm 那条 `{npmCmd}`(brew 的 formula 只跟上游最新版,钉不住 {version};要严格装上这一版只能走 npm)",
26
+ installHintBrew: "brew 那条命令:`{brewCmd}`。brew 的 formula 只跟上游最新版,钉不住 {version}——本机装的那一版与本插件核对的版本不一致时,结论口径可能有差异;要严格这一版请用 npm 那条:`{npmCmd}`",
27
+ installHintNpm: "npm 那条命令:`{npmCmd}`(版本钉在本插件核对过的那一版,换版要显式改这条命令)",
28
+ versionMismatch: "版本核对:外部 ocr CLI 实测 {actual},而本插件的契约按 {expected} 逐条核对。两个版本之间的评论口径、字段形态与 flag 语义都可能漂移,别把这份摘要的结构当作「与插件同版本」的结果;换版命令见「设置 → 插件 → ocr-review」的外部 CLI 那一行。",
26
29
  execFailed: "ocr 执行失败(exit={exitCode})",
27
30
  detailSeparator: ":",
28
31
  stdoutTruncated: "[ocr-review] 注意:输出超过 {limit} 字节上限已截断{spill}",
29
32
  outputSpill: "(完整输出已落盘:{path})",
30
- backgroundPolling: "先 job_output(job_id=<本回执的 jobId>) 读这条后台评审的实时输出与最终结局——按 id 走,启动即可读,一定有结果;要停它就 job_kill(job_id=<同一 id>)。会话落盘后再走 ocr_session:ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files == selected_files 即已完成;再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台运行不设宿主超时,只在兜底窗口(ocrBackgroundMaxMs,默认 2 小时)到点后回收(挂死不动会一直占着官方作业桶),取消本工具调用即终止该后台 OCR(宿主 kill + 命令内回收守护)。",
31
- jobReasonAborted: "ocr-review 工具调用被中止",
33
+ backgroundPolling: "先 job_output(job_id=<本回执的 jobId>) 读这条后台评审的实时输出与最终结局——按 id 走,启动即可读,一定有结果;要停它就 job_kill(job_id=<同一 id>)。会话落盘后再走 ocr_session:ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files == selected_files 即已完成;再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台评审不随工具调用结束而停止,默认也不设时限(ocrBackgroundMaxMs=0 表示无兜底窗口);只有 dsh 宿主退出、本会话被停止、插件卸载或显式 job_kill 才会结束。",
34
+ jobReasonSessionStopped: "ocr-review 会话已停止,回收在跑的后台评审",
32
35
  jobReasonTimeout: "ocr-review 后台评审超过兜底窗口({minutes} 分钟),兜底回收",
33
36
  jobReasonUnload: "ocr-review 插件卸载",
34
37
  jobOutputGap: "\n[ocr-review] 注意:有一次输出读取失败,已按原游标重试(这段可能不完整)",
@@ -65,7 +68,7 @@ const MESSAGES = {
65
68
  statusNone: "(无)",
66
69
  ocrCommentsNotArray: "OCR 输出 comments 字段不是数组,无法确认评论完整性",
67
70
  warningSeparator: ":",
68
- reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr session show <session_id>` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。status=partial 表示部分文件未完成审查(失败明细在 warnings),评论仍然有效。判读口径:droppedCount 与 invalidCommentCount 同时为 0 时本摘要才覆盖 OCR 的全部产出;否则请调大 maxComments(或置 0 = 不截断)后复核,切勿把「本摘要无 critical」当作「代码干净」。",
71
+ reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr_session(action=show/comments, id=<session_id>)` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。两套口径不要混:droppedCount 与 invalidCommentCount 同时为 0 只说明**本摘要**列全了 OCR 产出的评论(否则调大 maxComments 或置 0 后复核);而**这一场有没有审完**看 coverage(selected 是分母,completed/reused/failed/waived 是它的互斥划分;coverage 为 null 时退回看 warnings 与 status=partial)和 budgetExceeded(true = token 预算花完提前收工,status 里只写 partial,看不出这一层)。toolCalls.failure 大于 0 说明当前模型的工具调用没跑通,换模型比重试同一个更省 token。切勿把「本摘要无 critical」当作「代码干净」。",
69
72
  lineZeroNote: "(line 0: 定位失败,按 existing_code 手动定位)",
70
73
  parseFailedWithReason: "{tool} 输出解析失败:{message}\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
71
74
  parseFailedPlain: "{tool} 输出解析失败\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
@@ -86,8 +89,12 @@ const MESSAGES = {
86
89
  keyNotReachable: "provider {provider} 的 API key 存在于凭据层「{source}」,OCR 运行时取不到它:dsh 起 shell 会剥掉名字含 KEY/PASSWORD/SECRET/TOKEN 或 DSH_ 前缀的环境变量,get-cred 也刻意不读被审查仓库的 .env。请把 key 存入 dsh 托管凭据($DSH_HOME/.credentials.yaml 的 refs.{ref})或 $DSH_HOME/.env 后重试",
87
90
  credScriptMissing: "get-cred 脚本缺失:{path}(api_key_cmd 无法工作)",
88
91
  reaperDegraded: "[ocr-review] 宿主回收守护已降级:ps/pgrep 不可用(进程信息命令被沙箱拒绝),改由宿主管进程树。dsh 硬退出时 ocr 可能留孤儿进程。",
89
- warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/error 字段)",
92
+ warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/message/type 字段)",
93
+ diagnosticUnreadable: "(OCR 的 {field} 字段形态不合,这一场的诊断数据读不出来)",
94
+ diagnosticOmitted: "(另有 {count} 条同类明细已省略,全量见 ocr_session 的 show/comments)",
90
95
  commaInListItem: "{name} 的数组形式里有一项自身含逗号:{item}。数组与逗号字符串两条路径 join 后无法区分,而 glob 花括号({ts,tsx})恰好含逗号——静默拆成多项会改变审查/过滤范围。改用逗号字符串形式。",
96
+ probeNoVersion: "ocr version 跑通了(exit 0),但回执里读不出版本号:那通常是未发布构建。",
97
+ probeTimedOut: "ocr version 超时({seconds} 秒内没回来):那条进程已被收走。稍后重试,或直接在终端里跑 ocr version——本插件不会替它代跑安装或升级。",
91
98
  providerModelRequired: "provider 与 model 必填",
92
99
  bodyParseFailed: "body 解析失败:{message}",
93
100
  reviewToolDescription: "用 open-code-review(ocr)对 Git 变更做深度学习代码审查:OCR 调用其自身配置的 LLM(provider/model 在 ocr-review 设置卡选择)独立审查,返回按严重度/类别聚合的评论摘要。scope=workspace 审工作区 staged+unstaged+untracked 变更(默认);scope=commit 审单个提交;scope=branch 用从/到分支对比(merge-base)。常用先 ocr_delegate_preview 看范围。effort 控制深度(low=1 轮/medium=2/high=3,越深越贵)。background 提供需求/业务上下文能显著提升质量。首次审查前确保 ocr 已配置且 'ocr llm test' 通过。",
@@ -138,28 +145,37 @@ const MESSAGES = {
138
145
  - ocr_delegate_preview / ocr_delegate_rule:只取「文件清单 + 适配规则」的确定性脚手架(OCR 端零 LLM 消耗),
139
146
  配合 git diff 由你自己完成审查——适合复用本会话模型能力、或先确认审查范围再决定是否跑 ocr_review。
140
147
  - 审查前若不确定范围,先 ocr_delegate_preview(不花 token);范围过大时用 exclude 收敛或按 commit 拆。
141
- - OCR 端点/provider/model 由「设置 → 插件 → ocr-review 卡片」从 dsh 的 settings.yaml providers 选择;
142
- 也可在工具参数里用 --provider/--model 临时覆盖。
148
+ - OCR 端点/provider/model 由「设置 → 插件 → ocr-review 卡片」从宿主设置服务的 provider 清单选择
149
+ (key 只按 api_key_cmd 动态读取,不落盘明文);也可在工具参数里用 provider/model 逐次覆盖。
143
150
  - 调用 ocr_review / ocr_scan 前,先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务上下文
144
151
  传入 background(open-code-review 官方 skill 明确推荐"始终携带",是质量提升最有效的单一参数)。
145
- - 若 ocr_review/ocr_scan 输出含 "No tool calls parsed" 或大量 "Max tool requests reached",
146
- 是当前模型的工具调用能力问题——建议在 ocr-review 卡片换一个支持原生工具调用的模型(sensenova、deepseek、glm、qwen 系),
147
- 不要反复重试同一个模型。
148
- - LLM 调用本身有重试:OCR 内置重试(429/408/409 与 5xx 自动退避重试);per-file 组失败被隔离并进 warnings,不整体重跑。
149
- 工具层超时(min(请求, shell.maxTimeoutMs),缺省 10 分钟)或部分文件失败后,可 ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审。
152
+ - 读结果分两套口径,不要互相替代:coverage(selected 是分母)与 budgetExceeded 说「这一场有没有审完」,
153
+ droppedCount/invalidCommentCount 只说「本摘要有没有列全」。toolCalls.failure 大于 0 是当前模型不会调
154
+ 工具——换支持原生工具调用的模型(sensenova、deepseek、glm、qwen 系),别重试同一个;OCR 以
155
+ --audience agent 运行时进度行被丢弃,"No tool calls parsed" 这类文本永远不会出现在输出里,
156
+ 只能看这个字段。
157
+ - LLM 调用本身有重试:OCR 内置重试(429/408/409 与 5xx 自动退避重试);per-file 组失败被隔离,进
158
+ coverage.failedItems(产出里没有 manifest 时进 warnings),不整体重跑。
159
+ 工具层超时(min(请求, shell 执行器自己设置项里的 maxTimeoutMs),缺省 10 分钟)或部分文件失败后,可
160
+ ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审。
150
161
  `.trim()
151
162
  },
152
163
  en: {
153
164
  middleEllipsis: " …[middle omitted]… ",
154
- execTimedOut: "ocr timed out (effective {minutes} min = min(request, shell.maxTimeoutMs)): narrow the review scope (exclude / --commit / a smaller diff), lower OCR's per-group --timeout, raise shell.maxTimeoutMs in settings.yaml, or switch to background mode with wait=false (no timeout)",
165
+ execTimedOut: "ocr timed out (effective {minutes} min = min(request, shell.maxTimeoutMs)): narrow the review scope (exclude / --commit / a smaller diff), lower OCR's per-group --timeout, raise maxTimeoutMs in the shell executor's own settings (bash-local/pwsh-local), or switch to background mode with wait=false (no time limit by default)",
155
166
  execAborted: "ocr execution aborted",
156
- execNotFound: "ocr command not found: install it first with `brew install open-code-review` or `npm i -g @alibaba-group/open-code-review`",
167
+ execNotFound: "ocr command not found: the command word was handed to the shell and nothing answered it (the PATH candidate may have just been moved or renamed). Open the External CLI row in Settings → Plugins → ocr-review for an install that matches the version this plugin was verified against, then retry this call",
168
+ ocrBinaryMissing: "no ocr candidate on either path: PATH has none, and this plugin no longer ships the CLI (installing the plugin should not silently change your global toolchain). {install} Once installed, just retry this call - the verdict is re-read per call, so no dsh restart is needed",
169
+ installHintBoth: "either command works: the brew one `{brewCmd}`; the npm one `{npmCmd}` (the brew formula only tracks the newest upstream release and cannot pin {version}; npm is the only way to install exactly that version)",
170
+ installHintBrew: "the brew command: `{brewCmd}`. The brew formula only tracks the newest upstream release and cannot pin {version} - when the version on this machine differs from the one this plugin was verified against, the reported conclusions may differ too. To get exactly {version} use the npm one: `{npmCmd}`",
171
+ installHintNpm: "the npm command: `{npmCmd}` (the version is pinned to the one this plugin was verified against; changing it means editing this command)",
172
+ versionMismatch: "Version check: the external ocr CLI reports {actual}, while this plugin's contract was verified against {expected}. Comment wording, output shapes and flag semantics can drift between versions, so do not treat this summary as if it came from the verified version - the upgrade command is on the External CLI row of Settings → Plugins → ocr-review",
157
173
  execFailed: "ocr failed (exit={exitCode})",
158
174
  detailSeparator: ": ",
159
175
  stdoutTruncated: "[ocr-review] note: output exceeded the {limit}-byte cap and was truncated{spill}",
160
176
  outputSpill: " (full output spilled to {path})",
161
- backgroundPolling: "First read this background review's live output and final outcome with job_output(job_id=<the jobId in this receipt>) — that path goes by id, so it is readable immediately after start and always has a result; job_kill(job_id=<the same id>) stops it. Once the session has landed, use ocr_session: ocr_session(action=list) finds this repository's newest session (it appears a few seconds after start; match by scanPaths/time); completed_files == selected_files means it is done. Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. Background runs get no host timeout; they are reclaimed when the backstop window (ocrBackgroundMaxMs, 2 hours by default) elapses (a wedged one would otherwise keep occupying the official job bucket); cancelling this tool call terminates the background OCR (host kill + the reaper guard inside the command).",
162
- jobReasonAborted: "ocr-review tool call aborted",
177
+ backgroundPolling: "First read this background review's live output and final outcome with job_output(job_id=<the jobId in this receipt>) — that path goes by id, so it is readable immediately after start and always has a result; job_kill(job_id=<the same id>) stops it. Once the session has landed, use ocr_session: ocr_session(action=list) finds this repository's newest session (it appears a few seconds after start; match by scanPaths/time); completed_files == selected_files means it is done. Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. A background run does not stop when the tool call ends and has no time limit by default (ocrBackgroundMaxMs=0 means no backstop window); it ends only when the dsh host exits, this session is stopped, the plugin unloads, or you call job_kill.",
178
+ jobReasonSessionStopped: "the ocr-review session stopped; running background reviews were reclaimed",
163
179
  jobReasonTimeout: "the ocr-review background review exceeded its backstop window ({minutes} minutes) and was reclaimed",
164
180
  jobReasonUnload: "ocr-review plugin unloaded",
165
181
  jobOutputGap: "\n[ocr-review] note: one output read failed and was retried from the same offset (this stretch may be incomplete)",
@@ -196,7 +212,7 @@ const MESSAGES = {
196
212
  statusNone: "(none)",
197
213
  ocrCommentsNotArray: "the comments field of the OCR output is not an array, comment completeness cannot be confirmed",
198
214
  warningSeparator: ": ",
199
- reviewSummaryHint: "Use `ocr session show <session_id>` for the full comments and per-file failure details; start_line=0 means OCR could not anchor a line — locate it via suggestion/existing_code. status=partial means some files were not reviewed (failure details are in warnings); the comments still stand. How to read this summary: only when droppedCount and invalidCommentCount are both 0 does it cover everything OCR produced; otherwise raise maxComments (or set 0 = no truncation) and re-check — never treat \"no critical in this summary\" as \"the code is clean\".",
215
+ reviewSummaryHint: "Use `ocr_session(action=show/comments, id=<session_id>)` for the full comments and per-file failure details; start_line=0 means OCR could not anchor a line — locate it via suggestion/existing_code. Keep the two notions apart: droppedCount and invalidCommentCount both being 0 only means THIS SUMMARY listed every comment OCR produced (otherwise raise maxComments, or set it to 0, and re-check). Whether the run itself covered everything is answered by coverage (selected is the denominator, completed/reused/failed/waived are its disjoint partition; fall back to warnings and status=partial when coverage is null) and by budgetExceeded (true = the token budget stopped the run early — status still only says partial). A toolCalls.failure above 0 means the current model's tool calls did not work: switch models instead of retrying the same one. Never treat \"no critical in this summary\" as \"the code is clean\".",
200
216
  lineZeroNote: "(line 0: not anchored, locate it via existing_code)",
201
217
  parseFailedWithReason: "{tool} output parsing failed: {message}\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
202
218
  parseFailedPlain: "{tool} output parsing failed\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
@@ -217,8 +233,12 @@ const MESSAGES = {
217
233
  keyNotReachable: "the API key of provider {provider} lives in the credential source \"{source}\", which OCR cannot reach at runtime: dsh strips variables named KEY/PASSWORD/SECRET/TOKEN or prefixed DSH_ from spawned shells, and get-cred deliberately does not read the reviewed repository's .env. Store the key in the dsh managed credentials ($DSH_HOME/.credentials.yaml refs.{ref}) or $DSH_HOME/.env and retry",
218
234
  credScriptMissing: "get-cred script missing: {path} (api_key_cmd cannot work)",
219
235
  reaperDegraded: "[ocr-review] host reaper degraded: ps/pgrep are unavailable (process-info commands denied by the sandbox), so the host manages the process tree. dsh may leave an orphaned ocr process.",
220
- warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/error)",
236
+ warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/message/type)",
237
+ diagnosticUnreadable: "(the {field} field of the OCR output is malformed; its diagnostics cannot be read for this run)",
238
+ diagnosticOmitted: "({count} more entries of the same kind were omitted; use ocr_session show/comments for the full list)",
221
239
  commaInListItem: "an item of the array form of {name} contains a comma itself: {item}. The array form and the comma-joined string are indistinguishable after joining, and glob braces ({ts,tsx}) contain commas — silently splitting changes the review/filter scope. Use the comma-separated string form.",
240
+ probeNoVersion: "ocr version ran fine (exit 0), yet no version could be read from its output: that usually means an unreleased build",
241
+ probeTimedOut: "ocr version timed out (nothing back within {seconds}s): that process was reaped. Retry later, or run ocr version in a terminal yourself — this plugin never runs an install or upgrade on your behalf",
222
242
  providerModelRequired: "provider and model are both required",
223
243
  bodyParseFailed: "failed to parse the body: {message}",
224
244
  reviewToolDescription: "Deep code review of Git changes with open-code-review (ocr): OCR runs its own configured LLM (provider/model picked in the ocr-review settings card) as an independent reviewer and returns comments aggregated by severity/category. scope=workspace reviews staged+unstaged+untracked changes (default); scope=commit reviews one commit; scope=branch compares from/to branches (merge-base). Commonly start with ocr_delegate_preview to see the scope. effort controls depth (low=1 pass / medium=2 / high=3, deeper costs more). Passing requirements/business context via background markedly improves quality. Before the first review make sure ocr is configured and 'ocr llm test' passes.",
@@ -271,18 +291,23 @@ const MESSAGES = {
271
291
  or for confirming the scope before deciding to run ocr_review.
272
292
  - Unsure about the scope? Run ocr_delegate_preview first (costs no tokens); if the scope is huge, narrow it with
273
293
  exclude or split by commit.
274
- - The OCR endpoint/provider/model comes from "Settings → Plugins → ocr-review card", fed by dsh's settings.yaml
275
- providers; --provider/--model can override per call.
294
+ - The OCR endpoint/provider/model comes from "Settings → Plugins → ocr-review card", fed by the provider list of
295
+ the host settings service (the key is read dynamically through api_key_cmd and never stored in plaintext);
296
+ provider/model can also be overridden per call.
276
297
  - Before ocr_review / ocr_scan, analyse the change intent via git log/diff/commit messages and pass 3-5 sentences of
277
298
  business context as background (the official open-code-review skill says "always carry it" — the single most
278
299
  effective quality knob).
279
- - If ocr_review/ocr_scan output contains "No tool calls parsed" or many "Max tool requests reached", the current
280
- model's tool-calling ability is the problem — switch to a model with native tool calls in the ocr-review card
281
- (sensenova, deepseek, glm, qwen families) instead of retrying the same one.
300
+ - Read the result through two separate notions, neither replaces the other: coverage (selected is the denominator)
301
+ and budgetExceeded say whether the run finished covering everything, while droppedCount/invalidCommentCount only
302
+ say whether this summary listed everything OCR produced. A toolCalls.failure above 0 means the current model does
303
+ not drive tool calls — switch to a model with native tool calls (sensenova, deepseek, glm, qwen families) instead
304
+ of retrying the same one; when OCR runs with --audience agent its progress lines are dropped, so text such as
305
+ "No tool calls parsed" can never show up in the output and this field is the only way to see it.
282
306
  - LLM calls retry themselves: OCR has built-in backoff retries (429/408/409 and 5xx); per-file group failures are
283
- isolated and land in warnings instead of re-running everything. After a tool-layer timeout (min(request,
284
- shell.maxTimeoutMs), 10 minutes by default) or partial file failures, use ocr_session to find the session_id and
285
- ocr_review --resume to continue an interrupted range/commit review.
307
+ isolated and land in coverage.failedItems (in warnings when the output carries no manifest) instead of re-running
308
+ everything. After a tool-layer timeout (min(request, the maxTimeoutMs setting of the shell executor itself,
309
+ 10 minutes by default) or partial file failures, use ocr_session to find the session_id and ocr_review --resume
310
+ to continue an interrupted range/commit review.
286
311
  `.trim()
287
312
  }
288
313
  };
@@ -368,8 +393,13 @@ function arrayViolations(schema, value, path, walk) {
368
393
  * 为什么额外要求随机串里至少有一个数字或大写字母:真实 key 是随机 base62(32 位
369
394
  * 纯小写的概率约 1e-12),而审查输出里的 kebab 小写长串几乎只可能是文件路径
370
395
  * (`src/my-super-long-directory-name/index.ts`)。少了这道约束,session/delegate
371
- * 透传出的路径会被抹成 ***REDACTED***,模型拿不到可行动的位置信息。 */
372
- const STANDALONE_KEY = /\b[A-Za-z]{2,5}-(?=[A-Za-z0-9_-]{20,}\b)(?=[A-Za-z0-9_-]*[0-9A-Z])[A-Za-z0-9_-]{20,}\b/gu;
396
+ * 透传出的路径会被抹成 ***REDACTED***,模型拿不到可行动的位置信息。
397
+ *
398
+ * 这道约束写在替换回调里而**不写成前瞻**:`-` 同属前缀与随机串的字符集,一条长 run 里
399
+ * 能嵌套任意多个候选起点,而「run 内是否存在数字或大写」这类前瞻要把后面整段 run 反向
400
+ * 回溯一遍——逐起点相乘就是 O(n²)(实测 400_000 字符 51 秒,正好是 `stdoutMaxBytes`
401
+ * 的默认档)。run 是首个 `-` 之后的那一段:前缀只含字母,故首个 `-` 必是分界。 */
402
+ const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[A-Za-z0-9_-]{20,}\b/gu;
373
403
  /** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
374
404
  * 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
375
405
  * vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
@@ -384,6 +414,10 @@ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
384
414
  * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。 */
385
415
  const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
386
416
  const REDACTION = "***REDACTED***";
417
+ /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。 */
418
+ function redactStandaloneKey(match) {
419
+ return /[0-9A-Z]/u.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
420
+ }
387
421
  /**
388
422
  * 抹掉文本里 key 形状的片段。
389
423
  *
@@ -397,7 +431,20 @@ const REDACTION = "***REDACTED***";
397
431
  * 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
398
432
  */
399
433
  function redactKeyMaterial(text) {
400
- return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
434
+ return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, redactStandaloneKey);
435
+ }
436
+ /** ECMA-48 的 CSI 序列:ESC `[` + 参数位 + 中间位 + 终结位(SGR 颜色码就是终结位 `m`)。 */
437
+ const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
438
+ /**
439
+ * 剥掉终端控制序列,只给**要显示给人复制**的那段输出用。
440
+ *
441
+ * 上游 launcher 那句「有新版本,跑这条命令」是硬编码 `console.error("\x1b[33m…")` 打到
442
+ * stderr 的,`--color never` 管不到它(那句在 JS 包装层打印,Go 那侧的旗标还没生效)。
443
+ * 留着这些字节,浏览器里看到的就是 `[33m[ocr] A new version …[0m` 这种噪声,而用户要选中
444
+ * 复制进终端的恰好是那条 `npm i -g …`——复制到的是带控制字符的脏命令。
445
+ */
446
+ function stripAnsi(text) {
447
+ return text.replaceAll(ANSI_CSI, "");
401
448
  }
402
449
  /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
403
450
  * 恰好一支命中,多支/零支都算违规。 */
@@ -543,6 +590,16 @@ function resolveOutputFormat(value) {
543
590
  /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
544
591
  * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
545
592
  const BACKGROUND_FLAG = "--background";
593
+ /**
594
+ * 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
595
+ * 的核对提示都读这一枚)。
596
+ *
597
+ * 声明归 package.json 的 `ocrCliVersion`(发布出去的包页面上看得到,用户据此装对应版本),
598
+ * 这里是它在本包代码里的唯一镜像:test/publish-manifest.test.ts 逐字断言两处相等,
599
+ * test/readme-parity.test.ts 再断言两侧 README 写的也是它。三处只有一处是人写的、
600
+ * 靠门禁钉住另两处,就不会出现「README 教用户装 A、代码按 B 校验」这种两边各自自洽的错。
601
+ */
602
+ const EXPECTED_OCR_CLI_VERSION = "1.13.0";
546
603
  /** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。 */
547
604
  function resolveRoot(value, fallback, messages) {
548
605
  if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
@@ -632,7 +689,7 @@ function pushTextFlag(parts, flag, value, messages) {
632
689
  if (text !== "") parts.push(flag, shq(text));
633
690
  }
634
691
  /** --exclude/--background/--provider/--model 等通用 flag。
635
- * 注意:ocr 的 --exclude 是 String 类型(实测 v1.12.0 多 flag 实例只认最后一个,
692
+ * 注意:ocr 的 --exclude 是 String 类型(实测多个 flag 实例只认最后一个,
636
693
  * 逗号分隔才生效)——多值必须合并为一个 flag 逗号连接,绝不能展开多个。
637
694
  * resume 不在这里:它与 scope 是互斥语义(workspace 禁恢复),由
638
695
  * buildReviewCommand 在拿到 scopeFlags 结果后前置校验。 */
@@ -660,7 +717,7 @@ function optionalFlags(args, messages) {
660
717
  * →补 KILL 全部目标,自我清场的组 KILL 排在最后一步。)
661
718
  * - 硬退出:守护随 launchd 收养后 ppid 改变,监视循环探测 ppid != 初始值 →
662
719
  * reap 后退出。TERM 先行是因 OCR 原生 signal.Notify
663
- * (cmd/opencodereview/interrupt.go:48)会优雅落盘会话清单再退。
720
+ * (cmd/opencodereview/interrupt.go 的 signal.Notify)会优雅落盘会话清单再退。
664
721
  *
665
722
  * 组语义:dsh shell runner 以 detached:true(setsid)启动 `bash -c <cmd>`,
666
723
  * 守护即组长,`kill -- -$$` 覆盖组内任意深度后代;守护另有组长判定,非组长
@@ -695,32 +752,146 @@ function wrapWithHostReaper(command, messages) {
695
752
  `wait "$ocr"; exit $?`
696
753
  ].join("; "))}`;
697
754
  }
698
- function locateLauncher() {
755
+ /** 随包兜底那一份的 package.json 说明符:解析到它才谈得上取它的 bin 入口。 */
756
+ const OCR_PACKAGE_JSON = "@alibaba-group/open-code-review/package.json";
757
+ function resolveFromOwnTree(specifier) {
758
+ return createRequire(import.meta.url).resolve(specifier);
759
+ }
760
+ function locateLauncher(resolveEntry = resolveFromOwnTree) {
699
761
  let launcher = null;
700
762
  try {
701
- const entry = createRequire(import.meta.url).resolve("@alibaba-group/open-code-review/package.json");
763
+ const entry = resolveEntry(OCR_PACKAGE_JSON);
702
764
  const candidate = path.join(path.dirname(entry), "bin", "ocr.js");
703
765
  if (existsSync(candidate)) launcher = candidate;
704
766
  } catch {}
705
767
  return launcher;
706
768
  }
769
+ /** 是不是一个普通文件(PATH 里恰好有个叫 ocr 的目录不算命中)。 */
770
+ function isRegularFile(candidate) {
771
+ try {
772
+ return statSync(candidate).isFile();
773
+ } catch {
774
+ return false;
775
+ }
776
+ }
777
+ /**
778
+ * 符号链接解到底。brew 与 npm 全局都在 bin 目录留一条软链,真身分别在 Cellar 与
779
+ * node_modules 下:不解开就只看得到 `…/bin/ocr`,两种装法长得一模一样。
780
+ * 解不开(目标已消失、无权限)就用字面值判——来源最多退化成 `unknown`,
781
+ * 那一条只会让用户看到两条命令,不会报错误的命令。
782
+ */
783
+ function realPathOrSelf(candidate) {
784
+ try {
785
+ return realpathSync(candidate);
786
+ } catch {
787
+ return candidate;
788
+ }
789
+ }
790
+ /**
791
+ * 从可执行体的位置推断装法。判据只有两条,且都是「真身在哪棵目录树下」这种事实:
792
+ * - `node_modules` → npm 一类的包管理器装法(`~/.nvm/…/lib/node_modules`、
793
+ * brew 装的 node 的 `/opt/homebrew/lib/node_modules`、Windows 的 `%APPDATA%\npm\node_modules`)。
794
+ * - `Cellar` / `homebrew` → formula 装法(Apple Silicon `/opt/homebrew/Cellar`、
795
+ * Intel `/usr/local/Homebrew/Cellar`、Linuxbrew `/home/linuxbrew/.linuxbrew/Cellar`)。
796
+ * `node_modules` 必须先判:brew 装的 node 把自己装出来的全局包也放在 `<brew 前缀>` 下,
797
+ * 那条路径同时含 homebrew,反过来判就把 npm 装法报成 brew。
798
+ * 两条都不命中就是 unknown(手工 `cp` 到 ~/bin、npx、别的前缀),不猜。
799
+ * @param candidate - 命中的绝对路径。
800
+ * @returns 安装来源。
801
+ */
802
+ function inferOcrInstallChannel(candidate) {
803
+ const resolved = realPathOrSelf(candidate);
804
+ if (resolved.includes("node_modules")) return "npm";
805
+ return /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu.test(resolved) ? "brew" : "unknown";
806
+ }
807
+ /** brew 的 formula 名与 npm 的包名不同:formula 不带 scope。 */
808
+ const OCR_BREW_FORMULA = "open-code-review";
809
+ const OCR_NPM_PACKAGE = "@alibaba-group/open-code-review";
810
+ /**
811
+ * 给人照抄的两条命令。两条不对称是有原因的,不是漏写:
812
+ * - npm 那条恒带版本号(`npm i -g <包名>@<版本>` 覆盖安装即换版),因为本插件的契约
813
+ * 只按 EXPECTED_OCR_CLI_VERSION 那一版核对过,把版本钉住就不会出现「装完才发现对不上」。
814
+ * - brew 那条不带版本:homebrew/core 只有一份 stable formula,没有 `open-code-review@x.y`
815
+ * 这种版本化 formula,装/升出来就是上游最新。所以它必须和一句「钉不住版本」的说明
816
+ * 一起出现(文案在 lib/messages.ts),不能让用户以为 `brew install` 得到的是这一版。
817
+ * @param channel - 已命中那份的来源;没命中时传 "unknown"。
818
+ * @param installed - 是否已有一份候选(决定 brew 那条说 install 还是 upgrade)。
819
+ * @returns 两条命令的字面串。
820
+ */
821
+ function ocrCommandsFor(channel, installed) {
822
+ return {
823
+ brew: channel === "brew" && installed ? `brew upgrade ${OCR_BREW_FORMULA}` : `brew install ${OCR_BREW_FORMULA}`,
824
+ npm: `npm i -g ${OCR_NPM_PACKAGE}@${EXPECTED_OCR_CLI_VERSION}`
825
+ };
826
+ }
827
+ /**
828
+ * 只用文件系统的事实回答:`ocr` 这个候选在哪条路上找得到。
829
+ *
830
+ * 为什么需要:`ocrCommand()` 在两条路都没有候选时**假设** PATH 上有裸 `ocr`,而这个假设
831
+ * 过去只有等子进程 exit 127 才暴露——用户看到的是「ocr 命令不存在」而不是「哪条路都
832
+ * 没找到」。本函数给设置页的状态行与工具的前置短路同一个可先问的判据。
833
+ *
834
+ * 口径:
835
+ * - PATH 先、随包入口后:用户在终端里 `which ocr` 看到的那一份,就是本插件跑的与卡片
836
+ * 报的那一份。两处顺序一致是这条功能最基本的诚实性要求。
837
+ * - 只回答「找不找得到」,不回答「跑不跑得起来」:丢执行位、入口是符号链接指向空缺
838
+ * 这类事实在真实 spawn 那一侧由子进程自己报(比 fs 侧猜权限准确得多),本判据也不
839
+ * 为此引入 `accessSync` 或 mode 位运算。
840
+ * - 因此误报方向是安全的:`none` 只在两条路都没有候选文件时给出,而前置短路只认
841
+ * `none`——不会把一套能跑的环境挡掉。
842
+ * - Windows 永不报 `bundled`:`ocrCommand()` 在那边刻意跳过随包入口(shebang 脚本
843
+ * pwsh/cmd 无法直执行),两处口径必须一致,否则状态说可用、命令却走另一条路。
844
+ * - 「包在但平台二进制缺失」不在这里判——那要读 OCR 自己的内部布局(平台子包名与
845
+ * bin 文件名),抄进本包就等于养一条随上游漂移的判据;那一档由 launcher 自己那句
846
+ * `[ERROR] OpenCodeReview binary not found`(exit 1)报出来。
847
+ * @param env - 进程环境(默认生产那一份;单测注入 stub 以构造 PATH 形态)。
848
+ * @param locate - 随包 launcher 的定位口(默认即 `ocrCommand()` 用的那一个)。
849
+ * @param platform - 平台(默认 `process.platform`;单测换成 stub 以覆盖分隔符与后缀)。
850
+ * @returns 来源、命中路径与那份的安装渠道。
851
+ */
852
+ function resolveOcrAvailability(env = process.env, locate = locateLauncher, platform = process.platform) {
853
+ const windows = platform === "win32";
854
+ const dirs = (env["PATH"] ?? env["Path"] ?? "").split(windows ? ";" : ":").filter((dir) => dir !== "");
855
+ const suffixes = windows ? [.../* @__PURE__ */ new Set(["", ...(env["PATHEXT"] ?? "").split(";").filter(Boolean)])] : [""];
856
+ for (const dir of dirs) for (const suffix of suffixes) {
857
+ const candidate = path.resolve(dir, `ocr${suffix}`);
858
+ if (isRegularFile(candidate)) return {
859
+ source: "path",
860
+ path: candidate,
861
+ channel: inferOcrInstallChannel(candidate)
862
+ };
863
+ }
864
+ if (!windows) {
865
+ const launcher = locate();
866
+ if (launcher !== null) return {
867
+ source: "bundled",
868
+ path: launcher,
869
+ channel: inferOcrInstallChannel(launcher)
870
+ };
871
+ }
872
+ return { source: "none" };
873
+ }
707
874
  /** 进程级记忆槽(默认那一档):整包共用一次解析结果。 */
708
875
  const memoBox = {};
709
- /** 解析出的 `ocr` 命令词(已 shq 转义):随包入口的绝对路径,或 PATH 上的裸 `ocr`。
710
- * 惰性求值 + 每进程记忆一次,与 dsh 核心解析 @vscode/ripgrep 同款:解析失败绝不在
711
- * 装载期抛,否则整包会因为一个可选二进制而下线。
712
- * 导出给同包的命令构造单测——它们按同一个解析结果写期望值,测试与生产同源,
713
- * 而不是各自把「ocr 还是某个绝对路径」写死一遍。
876
+ /**
877
+ * 解析出的 `ocr` 命令词(已 shq 转义):PATH 上用户自装那份的绝对路径,退一步是随包入口
878
+ * 的绝对路径,两处都没有候选时才是裸 `ocr`。
879
+ * 命令词一律取自 `resolveOcrAvailability()`,与设置页状态行、工具前置短路同一份判据——
880
+ * 分成两套就会各说各话:卡片报「PATH 上那份」,实际跑的却是随包那份。
881
+ * 两条路都没有候选时仍交回裸 `ocr` 而不是抛错:装载期不许因为一个外部命令缺席把五个工具
882
+ * 一并拖下线(本包目标平台是 POSIX 优先,Windows 那半见下面)。
883
+ * 惰性求值 + 每进程记忆一次,与 dsh 核心解析 @vscode/ripgrep 同款:解析失败绝不在装载期抛。
884
+ * 导出给同包的命令构造单测——它们按同一个解析结果写期望值,测试与生产同源,
885
+ * 而不是各自把「ocr 还是某个绝对路径」写死一遍。
714
886
  * @param locate 随包 launcher 的定位口(默认实现即生产那一档)。
715
887
  * @param memo 记忆槽(默认进程级单例;单测传自己的盒以便反复构造两条分支)。
888
+ * @param env 进程环境(默认生产那一份;单测注入 stub 以构造 PATH 形态)。
716
889
  */
717
- function ocrCommand(locate = locateLauncher, memo = memoBox) {
890
+ function ocrCommand(locate = locateLauncher, memo = memoBox, env = process.env) {
718
891
  if (memo.value === void 0) {
719
- if (process.platform === "win32") memo.value = "ocr";
720
- else {
721
- const launcher = locate();
722
- memo.value = launcher === null ? "ocr" : shq(launcher);
723
- }
892
+ const windows = process.platform === "win32";
893
+ const hit = resolveOcrAvailability(env, locate, process.platform).path;
894
+ memo.value = windows || hit === void 0 ? "ocr" : shq(hit);
724
895
  }
725
896
  return memo.value;
726
897
  }
@@ -744,15 +915,35 @@ function withHomeEnv(command, home) {
744
915
  }
745
916
  /**
746
917
  * `ocr llm test` 命令词(select 的 autoVerify 与卡片「测试连接」共用)。
747
- * 必须与五个工具走同一个解析源(ocrCommand()):随包 launcher 装法下 PATH 上没有
748
- * 裸 `ocr`,硬编码字符串会让 llm test 永远 exit 127 而评审照常工作——autoVerify
749
- * 从此没有一次能通过,还误导用户去装全局包。`--color` 是 root 持久 flag
750
- * (color.go:35-39),子命令合法。
918
+ * 必须与五个工具走同一个解析源(ocrCommand()):卡片验的那一份与评审吃的那一份必须是同一
919
+ * 个二进制,否则「测试连接通过」这句回执毫无价值。硬编码裸 `ocr` 在随包兜底装法下还会永远
920
+ * exit 127。`--color` 是 root 持久 flag(color.go:35-39),子命令合法。
751
921
  */
752
922
  function buildLlmTestCommand() {
753
923
  return `${ocrCommand()} llm test --color never`;
754
924
  }
755
925
  /**
926
+ * `ocr version` 命令词(设置页的状态探测专用)。与五个工具同一个解析源,理由与
927
+ * `llm test` 完全一致;也不套宿主回收守护——它是秒级本地打印,与
928
+ * delegate/session/llm test 同一档(`wrapWithHostReaper` 只管分钟级的 review/scan)。
929
+ */
930
+ function buildVersionCommand() {
931
+ return `${ocrCommand()} version`;
932
+ }
933
+ /**
934
+ * 从 `ocr version` 的回执里取版本号。上游那句自述是 `open-code-review <Version> <os>/<arch>`
935
+ * (`cmd/opencodereview/root.go` 的 versionString),而 `<Version>` 有两种**都在野外**的形态:
936
+ * 上游自己那层 Go 构建把 git tag 整个塞进去、带 `v` 前缀与 commit 段,包管理器装到 PATH 上
937
+ * 那份只有裸数字。上游自己那层 npm 包装的 parseVersionOutput 只认带 `v` 的一种,本包两种都得
938
+ * 读——按包管理器装法的用户按下「检测版本」却看到「没有版本号」,这个功能对他就是失效的。
939
+ * 锚在产品名上而不是「文本里第一个像版本号的串」:launcher 那句升级提示里也带一个版本号,
940
+ * 那是**可用的新版本**而非本机装的这份,抓错就把建议读成了现状。
941
+ * 读不出即 null:未发布构建的 `<Version>` 是 `dev`,没有版本号可报,猜一个等于编一个。
942
+ */
943
+ function parseOcrVersion(text) {
944
+ return /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u.exec(text)?.groups?.["version"] ?? null;
945
+ }
946
+ /**
756
947
  * 构造 `ocr review` 命令。--output 固定为调用方给定的临时文件路径(防截断),
757
948
  * --audience agent --format json 是默认(结构化、无进度行)。
758
949
  * outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。
@@ -790,7 +981,7 @@ function buildReviewCommand(args, outputPath, messages) {
790
981
  }
791
982
  /** 构造 `ocr scan` 命令(无 git diff 的全文件扫描)。
792
983
  * 与 review 相同的坑:--path/--exclude 都是 String 类型,多值必须逗号连接
793
- * (实测 v1.12.0:多 flag 实例不生效),绝不能展开多个。
984
+ * (实测多个 flag 实例不生效),绝不能展开多个。
794
985
  * outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。 */
795
986
  function buildScanCommand(args, outputPath, messages) {
796
987
  const repo = assertAbsoluteRoot(args.repo, messages);
@@ -965,16 +1156,21 @@ function aggregateComments(comments) {
965
1156
  //#endregion
966
1157
  //#region lib/parse.ts
967
1158
  /**
968
- * OCR 评审 JSON 的 status 枚举(1.12.x 源码核对:cmd/opencodereview/output.go 的
969
- * jsonOutput 装配 + internal/session/manifest.go:166-169 的 TerminalState)。
1159
+ * OCR 评审 JSON 的 status 枚举(判据在 CLI 侧 `cmd/opencodereview/output.go` 的 jsonOutput
1160
+ * 装配与 `internal/session/manifest.go` 的 TerminalState)。
970
1161
  * 白名单外的一律视为失败:exit 0 不等于评审成功——`{"error":"auth failed"}`
971
1162
  * 这类「没有 status 的错误对象」若被当作 ok:true,模型会把「0 条评论」读成
972
- * 「代码干净」,是最坏的一类假阳性。
1163
+ * 「代码干净」,是最坏的一类假阳性。`failed` 也刻意留在白名单外:CLI 的退出契约是
1164
+ * 「全场失败才非零」(`review_cmd.go` 的 reviewResultError),那份文档随非零退出
1165
+ * 一起被 shell 侧的失败详情拦下,本解析拿不到它。
1166
+ *
1167
+ * partial 是 manifest 终态之一:部分文件失败但整体有覆盖、以及 token 预算截断后仍有
1168
+ * 覆盖,都以 exit 0 + status "partial" 输出。此前白名单漏了它,整场已花费 token 的
1169
+ * 评审被误报成「status 非法」并丢弃全部评论。
973
1170
  *
974
- * partial 是 manifest 终态之一(StatePartial):部分文件失败但整体有覆盖、以及
975
- * --max-tokens-budget 截断后仍有覆盖,都以 exit 0 + status "partial" 输出
976
- * (review_cmd.go 的退出注释 "complete/partial/skipped all succeed")。此前白名单
977
- * 漏了它,整场已花费 token 的评审被误报成「status 非法」并丢弃全部评论。
1171
+ * 有 manifest 时 status 就是它的终态(complete/partial/skipped),success 与
1172
+ * completed_with_warnings/completed_with_errors 只在没有 manifest 的产出上出现——
1173
+ * 逐文件失败在那两种形态下的落点也不同(前者在 coverage.failed,后者在 warnings)。
978
1174
  */
979
1175
  const REVIEW_STATUSES = [
980
1176
  "success",
@@ -991,11 +1187,6 @@ function str(value) {
991
1187
  return "";
992
1188
  }
993
1189
  /**
994
- * 数值字段解析:number 与「数字字符串」都接受(OCR 部分 provider 实测把
995
- * start_line 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),
996
- * 空串/NaN/Infinity/其它类型回 0。
997
- */
998
- /**
999
1190
  * 行号与增删统计的有效域:有限、非负、整数。
1000
1191
  *
1001
1192
  * 为什么把负数和小数一起挡掉:行号出现 "-3" 会被下游当成「未锚定的评论」而丢弃
@@ -1008,6 +1199,9 @@ function nonNegativeInt(value) {
1008
1199
  if (!Number.isSafeInteger(truncated) || truncated < 0) return 0;
1009
1200
  return truncated;
1010
1201
  }
1202
+ /** 数值字段解析:number 与「数字字符串」都接受(OCR 部分 provider 实测把 start_line
1203
+ * 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),空串/NaN/Infinity/
1204
+ * 其它类型回 0。落进 `nonNegativeInt` 那道有效域。 */
1011
1205
  function num(value) {
1012
1206
  if (typeof value === "number") return nonNegativeInt(value);
1013
1207
  if (typeof value === "string") {
@@ -1017,6 +1211,127 @@ function num(value) {
1017
1211
  }
1018
1212
  return 0;
1019
1213
  }
1214
+ /**
1215
+ * 明细行上限:coverage.failed 与 tool_calls.failure_details 在大会话里可以是几百条,
1216
+ * 逐条铺开会把「省 token 的摘要」变成第二份原始输出,而模型只需要知道「有哪些文件没审成」。
1217
+ */
1218
+ const DIAGNOSTIC_DETAIL_LIMIT = 8;
1219
+ /** 布尔字段:只认真布尔,缺省与非真值一律 false(OCR 用 omitempty 表示「没有发生」)。 */
1220
+ function boolOf(value) {
1221
+ return value === true;
1222
+ }
1223
+ /**
1224
+ * 明细行收尾:截断时补一条「另有 N 条已省略」,让读的人知道后面还有,而不是以为看到了全部。
1225
+ * @param lines - 已渲染的明细行
1226
+ * @param total - 该组的总条数
1227
+ * @param messages - 文案表
1228
+ * @returns 不超过上限的行数组
1229
+ */
1230
+ function capLines(lines, total, messages) {
1231
+ if (lines.length >= total) return lines;
1232
+ return [...lines, format(messages.diagnosticOmitted, { count: String(total - lines.length) })];
1233
+ }
1234
+ /** 计数字段的合法形态:非负整数或纯十进制数字串(与 {@link num} 的取值域对齐,
1235
+ * 否则 "x" 或 -1 会被 num 折成 0,把「读不出」伪装成「零失败」)。 */
1236
+ function isCount(value) {
1237
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
1238
+ return typeof value === "string" && /^\d+$/u.test(value.trim());
1239
+ }
1240
+ /**
1241
+ * 解析 `tool_calls`。字段缺失或为 null(这一场没有工具调用记录)返回 null;
1242
+ * 存在却读不出 total/failure 两个计数、或 failure_details 不是数组,同样返回 null,
1243
+ * 并交出 unreadable 让调用方把「读不出」这条挂进 warnings——诊断字段坏掉不该让整场
1244
+ * 评审作废(评论仍然有效),但也不能把它读成「零失败」。
1245
+ */
1246
+ function toolCallsOf(raw, messages) {
1247
+ if (raw === void 0 || raw === null) return {
1248
+ view: null,
1249
+ unreadable: false
1250
+ };
1251
+ const doc = isRecord(raw) ? raw : void 0;
1252
+ const details = doc?.["failure_details"];
1253
+ if (doc === void 0 || !isCount(doc["total"]) || !isCount(doc["failure"]) || details !== void 0 && !Array.isArray(details)) return {
1254
+ view: null,
1255
+ unreadable: true
1256
+ };
1257
+ const rows = Array.isArray(details) ? details : [];
1258
+ const rendered = rows.filter((row) => isRecord(row)).map((row) => {
1259
+ const key = str(row["file_path"]);
1260
+ const tool = str(row["tool_name"]);
1261
+ const error = str(row["error"]);
1262
+ return `${tool || messages.statusNone}${key === "" ? "" : ` @ ${key}`}: ${error}`;
1263
+ });
1264
+ return {
1265
+ view: {
1266
+ total: num(doc["total"]),
1267
+ failure: num(doc["failure"]),
1268
+ failures: capLines(rendered.slice(0, DIAGNOSTIC_DETAIL_LIMIT), rows.length, messages)
1269
+ },
1270
+ unreadable: false
1271
+ };
1272
+ }
1273
+ /** coverage 的一个集合:不是数组即读不出(undefined)。 */
1274
+ function setOf(coverage, key) {
1275
+ const items = coverage[key];
1276
+ return Array.isArray(items) ? items : void 0;
1277
+ }
1278
+ /**
1279
+ * 解析 `manifest.coverage`。五个键与 CLI 侧 Coverage 结构一一对应:selected 是分母,
1280
+ * 其余四个是它的互斥划分。任一不是数组即整体读不出——宁可回 null 让模型看见「这里没有
1281
+ * 覆盖度数据」,也不要用 0 冒充「一个文件都没审」。
1282
+ * @param raw - `root["manifest"]`
1283
+ * @param messages - 文案表
1284
+ * @returns 覆盖度视图,与「manifest 存在但读不出」的标记
1285
+ */
1286
+ function coverageOf(raw, messages) {
1287
+ if (raw === void 0 || raw === null) return {
1288
+ view: null,
1289
+ unreadable: false
1290
+ };
1291
+ const coverage = isRecord(raw) ? raw["coverage"] : void 0;
1292
+ if (!isRecord(coverage)) return {
1293
+ view: null,
1294
+ unreadable: true
1295
+ };
1296
+ const selected = setOf(coverage, "selected");
1297
+ const completed = setOf(coverage, "completed");
1298
+ const reused = setOf(coverage, "reused");
1299
+ const failedRows = setOf(coverage, "failed");
1300
+ const waived = setOf(coverage, "waived");
1301
+ if (selected === void 0 || completed === void 0 || reused === void 0 || failedRows === void 0 || waived === void 0) return {
1302
+ view: null,
1303
+ unreadable: true
1304
+ };
1305
+ const rendered = failedRows.filter((item) => isRecord(item)).map((item) => {
1306
+ const path = str(item["path"]);
1307
+ const classification = str(item["classification"]);
1308
+ const reason = str(item["reason"]);
1309
+ return `${path || messages.statusNone}${classification === "" ? "" : ` [${classification}]`}${reason === "" ? "" : ` ${reason}`}`;
1310
+ });
1311
+ return {
1312
+ view: {
1313
+ selected: selected.length,
1314
+ completed: completed.length,
1315
+ reused: reused.length,
1316
+ failed: failedRows.length,
1317
+ waived: waived.length,
1318
+ failedItems: capLines(rendered.slice(0, DIAGNOSTIC_DETAIL_LIMIT), failedRows.length, messages)
1319
+ },
1320
+ unreadable: false
1321
+ };
1322
+ }
1323
+ /**
1324
+ * 单条 warning 的渲染:字符串原样;`{file,message,type}` 拼成 `file:message [type]`;
1325
+ * 三者都读不出即落占位说明(空字符串条目会被模型读成「有一条空告警」,那不是事实)。
1326
+ */
1327
+ function warningLineOf(warn, messages) {
1328
+ if (typeof warn === "string") return warn;
1329
+ if (!isRecord(warn)) return messages.warningUnreadable;
1330
+ const head = [str(warn["file"]), str(warn["message"])].filter(Boolean).join(messages.warningSeparator);
1331
+ const type = str(warn["type"]);
1332
+ const line = [head, type === "" ? "" : `[${type}]`].filter(Boolean).join(" ");
1333
+ return line === "" ? messages.warningUnreadable : line;
1334
+ }
1020
1335
  function commentOf(raw) {
1021
1336
  if (!isRecord(raw)) return null;
1022
1337
  const content = str(raw["content"]);
@@ -1040,13 +1355,45 @@ function reviewFailure(status, message) {
1040
1355
  message,
1041
1356
  skipped: false,
1042
1357
  summary: null,
1358
+ budgetExceeded: false,
1043
1359
  comments: [],
1044
1360
  warnings: [],
1361
+ toolCalls: null,
1362
+ coverage: null,
1045
1363
  sessionId: "",
1046
1364
  droppedComments: 0
1047
1365
  };
1048
1366
  }
1049
1367
  /**
1368
+ * 三个诊断出口的合并:告警行、工具调用统计、运行覆盖度。
1369
+ *
1370
+ * warnings 条目是 `{file,message,type}`——CLI 侧的 AgentWarning 没有 `error` 这一项,
1371
+ * 照 error 读等于把失败原因整条丢掉、只留一个文件名。type 是 CLI 自己分类的依据
1372
+ * (subtask_error / scan_subtask_error 与其余),带出去才能让模型分清「哪个文件没审成」
1373
+ * 和「只是有个非致命提醒」。字段存在却不是数组、或条目既不是字符串也读不出任何内容
1374
+ * ⇒ 保留一条明确的「格式无法解析」占位,而不是静默清空。
1375
+ *
1376
+ * 诊断字段读不出来时**不判整场失败**(评论仍然有效、token 已经花掉),而是各补一条
1377
+ * 说明进同一个 warnings 出口——「这一路没有数据」必须看得见,但不能把结果作废。
1378
+ */
1379
+ function diagnosticsOf(root, messages) {
1380
+ const warnings = [];
1381
+ const warningsRaw = root["warnings"];
1382
+ if (warningsRaw !== void 0) {
1383
+ if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) warnings.push(warningLineOf(warn, messages));
1384
+ else warnings.push(messages.warningUnreadable);
1385
+ }
1386
+ const toolCalls = toolCallsOf(root["tool_calls"], messages);
1387
+ const coverage = coverageOf(root["manifest"], messages);
1388
+ if (toolCalls.unreadable) warnings.push(format(messages.diagnosticUnreadable, { field: "tool_calls" }));
1389
+ if (coverage.unreadable) warnings.push(format(messages.diagnosticUnreadable, { field: "manifest.coverage" }));
1390
+ return {
1391
+ warnings,
1392
+ toolCalls: toolCalls.view,
1393
+ coverage: coverage.view
1394
+ };
1395
+ }
1396
+ /**
1050
1397
  * 解析 review/scan JSON。text 应为 `ocr review --format json` 的完整 stdout/
1051
1398
  * 输出文件内容。解析失败(非 JSON)或 status 不在白名单(含缺失,例如
1052
1399
  * `{"error":"..."}` 这类 exit 0 的错误对象)返回 ok=false,由调用方报错。
@@ -1079,18 +1426,7 @@ function parseReviewOutput(text, messages) {
1079
1426
  const skipped = status === "skipped";
1080
1427
  const sumRaw = root["summary"];
1081
1428
  const sum = isRecord(sumRaw) ? sumRaw : void 0;
1082
- const warningsRaw = root["warnings"];
1083
- let warningsArr;
1084
- if (warningsRaw === void 0) warningsArr = [];
1085
- else if (Array.isArray(warningsRaw)) warningsArr = warningsRaw.map((warn) => {
1086
- if (typeof warn === "string") return warn;
1087
- if (isRecord(warn)) {
1088
- const joined = [str(warn["file"]), str(warn["error"])].filter(Boolean).join(messages.warningSeparator);
1089
- if (joined !== "") return joined;
1090
- }
1091
- return messages.warningUnreadable;
1092
- }).filter(Boolean);
1093
- else warningsArr = [messages.warningUnreadable];
1429
+ const diagnostics = diagnosticsOf(root, messages);
1094
1430
  return {
1095
1431
  ok: true,
1096
1432
  status,
@@ -1104,8 +1440,11 @@ function parseReviewOutput(text, messages) {
1104
1440
  outputTokens: num(sum["output_tokens"]),
1105
1441
  elapsed: str(sum["elapsed"])
1106
1442
  } : null,
1443
+ budgetExceeded: sum === void 0 ? false : boolOf(sum["budget_exceeded"]),
1107
1444
  comments,
1108
- warnings: warningsArr,
1445
+ warnings: diagnostics.warnings,
1446
+ toolCalls: diagnostics.toolCalls,
1447
+ coverage: diagnostics.coverage,
1109
1448
  sessionId: str(root["session_id"]),
1110
1449
  droppedComments
1111
1450
  };
@@ -1136,6 +1475,7 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
1136
1475
  skipped: parsed.skipped,
1137
1476
  message: parsed.message || null,
1138
1477
  summary: parsed.summary ?? null,
1478
+ budgetExceeded: parsed.budgetExceeded,
1139
1479
  sessionId: parsed.sessionId || null,
1140
1480
  aggregation: agg,
1141
1481
  totalCommentCount: parsed.comments.length,
@@ -1149,6 +1489,8 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
1149
1489
  suggestion: comment.suggestionCode || null
1150
1490
  })),
1151
1491
  warnings: parsed.warnings,
1492
+ toolCalls: parsed.toolCalls,
1493
+ coverage: parsed.coverage,
1152
1494
  hint: messages.reviewSummaryHint
1153
1495
  };
1154
1496
  }
@@ -1385,7 +1727,7 @@ const SOURCE_READY = Object.freeze({
1385
1727
  /** settings 通道侧抛错(health / descriptors / 投影三处共用同一状态与同一份文案)。 */
1386
1728
  const SOURCE_SETTINGS_FAILED = "settings-failed";
1387
1729
  /**
1388
- * 读 provider 清单并把一切失败折进 source(不抛):会话热路径与 webServer 端点
1730
+ * 读 provider 清单并把一切失败折进 source(不抛):会话热路径与卡片读面
1389
1731
  * 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
1390
1732
  */
1391
1733
  function readProviders(gateway, messages) {
@@ -1854,7 +2196,7 @@ const HOST_SCRUBBED_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN|^DSH_/iu;
1854
2196
  *
1855
2197
  * 宿主侧 resolve() 的 source 是「dsh 进程自己的取值层」,不等于「OCR 子进程够得到的
1856
2198
  * 层」——少了这道判定就是「select 成功 → OCR 401」的假绿:
1857
- * - `file`($DSH_HOME/.credentials.yaml)→ get-cred 第 2 层,可达;
2199
+ * - `file`($DSH_HOME/.credentials.yaml)→ get-cred 第 1 层,可达;
1858
2200
  * - `user-env`($DSH_HOME/.env)→ get-cred 第 3 层,可达;
1859
2201
  * - `env`(dsh 进程继承的环境)→ 只有 ref 名**不**命中宿主净化模式时才可达
1860
2202
  * (API key 类 ref 几乎全含 KEY/TOKEN,实际常不可达);
@@ -1917,7 +2259,7 @@ function ocrHomeOverride(configured) {
1917
2259
  * 组装设置页数据(绝不返回 key 明文;credentials 缺席时 hasKey 全 false 并给出原因,
1918
2260
  * 让卡片显示「为什么不可用」而不是假装每个 provider 都没配 key)。
1919
2261
  */
1920
- async function providersForCard(gateway, paths, messages) {
2262
+ async function providersForCard(gateway, paths, messages, endpointVerify) {
1921
2263
  const { providers, source } = readProviders(gateway, messages);
1922
2264
  const keyed = source.status === "ready" ? await markKeyFlags(gateway, providers) : withoutKeyFlags(providers);
1923
2265
  const { current } = loadOcrConfig(readText(paths.ocrConfigJson));
@@ -1925,12 +2267,13 @@ async function providersForCard(gateway, paths, messages) {
1925
2267
  providers: keyed,
1926
2268
  current,
1927
2269
  getCredReady: existsSync(credentialScriptPath(paths)),
1928
- source
2270
+ source,
2271
+ endpointVerify
1929
2272
  };
1930
2273
  }
1931
2274
  /**
1932
2275
  * 应用选择:官方通道取 provider 清单 → 校验 key 可解析 → 生成新配置 → 写回(0600)。
1933
- * 任何一步不合条件都抛可读错误(webServer 端点兜成 400,卡片显示原因)。
2276
+ * 任何一步不合条件都抛可读错误(Remote 的 select/migrate 兜成 `status: 400`,卡片显示原因)。
1934
2277
  */
1935
2278
  async function applyProviderSelection(gateway, paths, selection, messages) {
1936
2279
  const { provider, model } = selection;
@@ -2100,35 +2443,40 @@ async function ensureKeyScriptPointer(paths, messages) {
2100
2443
  }
2101
2444
  }
2102
2445
  //#endregion
2446
+ //#region lib/remote-shape.ts
2447
+ /** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
2448
+ const OCR_REMOTE_NAMESPACE = "ocrReview";
2449
+ //#endregion
2103
2450
  //#region host.ts
2104
2451
  const PLUGIN_DIR = import.meta.dirname;
2105
2452
  const MAX_TIMEOUT_REQUEST_MS = 2147483647;
2106
2453
  const LLM_TEST_TIMEOUT_MS = 9e4;
2454
+ /** 状态探测(`ocr version`)的墙钟(毫秒)。CLI 自己那层 npm 包装给同一件事是 3 秒
2455
+ * (`scripts/update.js` 里 `spawnSync(bin, ["version"], {timeout: 3000})`),但那是
2456
+ * 直跑原生二进制;我们从 shell 起、还要过一层 JS launcher,故放宽到 15 秒。它只在
2457
+ * 人按下「检测」时跑,不进任何热路径,因此不做成设置项。 */
2458
+ const VERSION_PROBE_TIMEOUT_MS = 15e3;
2107
2459
  const STDOUT_MAX_BYTES = 4e5;
2108
2460
  /** 解析失败时回显给模型的原始内容长度上限(UTF-16 码元;已脱敏)。
2109
2461
  * 单位写码元而不是"字符":本常量喂给 `echoRaw`→`truncateEnd`,那一刀按码元数计
2110
2462
  * (一张 emoji = 2 枚),写"字符"会把给模型的说明夸大约一倍。 */
2111
2463
  const RAW_ECHO_LIMIT = 500;
2112
- const BODY_MAX_BYTES = 8192;
2113
2464
  /** 评论截断条数的内置默认(旧 BUILTIN_BASE 的那一项):schema 的 `.default()` 与
2114
2465
  * 「读到坏值时的回退」(readMaxComments)共用这一个数,两处不许各写一份字面量。 */
2115
2466
  const MAX_COMMENTS_DEFAULT = 12;
2116
- /** 会话内 CSRF token 头名(与 lesson-loop/zvec-grep 同族命名)。 */
2117
- const OCR_CSRF_HEADER = "x-ocr-csrf";
2118
- const PROVIDERS_PATH = "/_dsh/ocr-review/providers";
2119
- const SELECT_PATH = "/_dsh/ocr-review/select";
2120
- const MIGRATE_PATH = "/_dsh/ocr-review/migrate";
2121
- const TEST_PATH = "/_dsh/ocr-review/test";
2122
- /** 官方注册表里本包作业的 kind;宿主据此签发的 id 形如 `ocr-review-N`。 */
2467
+ /** 官方注册表里本包作业的 kind;宿主据此签发的 id 形如 `ocr-review-<16 位 hex>`。 */
2123
2468
  const OCR_JOB_KIND = "ocr-review";
2124
2469
  /**
2125
- * 后台评审的兜底上限。本包刻意不套宿主 timeoutMs(`onExpiry: "none"`:外部 CLI 的正常
2126
- * 时长跨度太大,套了就等于替用户砍掉长评审),但换装后"完全没有终点"有了新的受害者——
2127
- * 未拥有作业共用一只 10 格的桶(实测 `running` 与 `stopping` 都计数,满员是**拒绝**
2128
- * 后来的 start),一条挂死的 CLI 会永久占一格,连别的插件也起不来。2 小时远超正常评审,
2129
- * 到点经注册表收:记录推到 stopping,理由落进模型可见的 detail。
2470
+ * 后台评审的兜底回收窗口(毫秒);0 = 不设窗口,进程只由宿主退出、会话 dispose、插件卸载
2471
+ * 或显式 job_kill 结束。
2472
+ *
2473
+ * 本包刻意不套宿主 timeoutMs(`onExpiry: "none"`:外部 CLI 的正常时长跨度太大,套了就等于
2474
+ * 替用户砍掉长评审)。不设窗口的代价落在注册表上:未拥有作业共用一只 10 格的桶(实测
2475
+ * `running` 与 `stopping` 都计数,满员是**拒绝**后来的 start),一条挂死的 CLI 就永久占一格,
2476
+ * 连别的插件也起不来。要给这一格腾位的部署把本项设成非零毫秒数:到点经注册表收,记录推到
2477
+ * stopping,理由落进模型可见的 detail。
2130
2478
  */
2131
- const OCR_BACKGROUND_MAX_MS = 72e5;
2479
+ const OCR_BACKGROUND_MAX_MS = 0;
2132
2480
  /** "仍在跑"的两枚官方状态:`stopping` 是取消已发出、进程还没收完。 */
2133
2481
  function isLiveOcrStatus(status) {
2134
2482
  return status === "running" || status === "stopping";
@@ -2136,7 +2484,7 @@ function isLiveOcrStatus(status) {
2136
2484
  /**
2137
2485
  * 剪掉本 kind 最老的已落定记录,给下一次登记腾出窗口(注册表按登记序返回,故取头部)。
2138
2486
  * 在跑与正在收的一条不动:官方件对活作业的 remove 直接抛,而"静默杀掉用户发起的评审"
2139
- * 正是这次换装要消灭的形态。
2487
+ * 正是本包要消灭的形态。
2140
2488
  * @param jobs 官方注册表
2141
2489
  * @returns {void}
2142
2490
  */
@@ -2171,10 +2519,13 @@ function startUnderOcrController(jobs, spec) {
2171
2519
  * @param jobs 官方注册表
2172
2520
  * @param id 这次后台评审的作业 id
2173
2521
  * @param reason 到点回收时写进模型可见 detail 的理由
2522
+ * @param backgroundMaxMs 窗口毫秒数;`<= 0`(部署级设置项 `ocrBackgroundMaxMs` 的默认值)
2523
+ * 表示这条不设兜底窗口,直接返回、不去敲 `wait`
2174
2524
  * @returns 等完这一条的兜底窗口;抛错一律就地收敛(记录没了就等于无事可做)
2175
2525
  */
2176
2526
  async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
2177
2527
  try {
2528
+ if (backgroundMaxMs <= 0) return;
2178
2529
  if ((await jobs.wait(id, backgroundMaxMs)).status === "running") jobs.kill(id, void 0, reason);
2179
2530
  } catch {}
2180
2531
  }
@@ -2202,20 +2553,11 @@ function sessionHeaderCwd(exec) {
2202
2553
  const raw = exec.agent?.session?.header?.cwd;
2203
2554
  return typeof raw === "string" && raw.length > 0 ? raw : void 0;
2204
2555
  }
2205
- /** 服务面校验:对象存在且声明的方法全部可调用。
2206
- * 只做 `typeof value["shell"] === "object"` 的话,守卫声称的是「有 shell 服务」
2207
- * 而实际证明的只是「有个对象」——运行到一半才炸。逐个点名方法,守卫才名副其实
2208
- * (HostCtx 用到的每个方法都在这里被验证过一遍)。 */
2209
- function hasMethods(value, methods) {
2210
- if (!isRecord(value)) return false;
2211
- for (const method of methods) if (typeof value[method] !== "function") return false;
2212
- return true;
2213
- }
2214
2556
  /** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
2215
2557
  * 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
2216
2558
  * settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
2217
2559
  * 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
2218
- * 0.1.7 起 settings 再无 register/get,多点名一个就是永不成立的死分支。 */
2560
+ * settings 面没有 register/get,多点名一个就是永不成立的死分支。 */
2219
2561
  function isOcrReviewHost(value) {
2220
2562
  if (!isRecord(value)) return false;
2221
2563
  const { settings, shell, tools, systemPrompt, inject, get } = value;
@@ -2226,10 +2568,10 @@ function resolveHost(ctx) {
2226
2568
  if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
2227
2569
  return ctx;
2228
2570
  }
2229
- /** describe() 行里按命名空间取解析值(0.1.7 的跨命名空间读只有 describe() 一条路:
2571
+ /** describe() 行里按命名空间取解析值(跨命名空间读只有 describe() 一条路:
2230
2572
  * `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
2231
2573
  * @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
2232
- * @param ns - 要读的命名空间(官方字段名是 `ns`,上游有改名 namespace 的 TODO)。
2574
+ * @param ns - 要读的命名空间(官方字段名是 `ns`)。
2233
2575
  */
2234
2576
  function describedValue(rows, ns) {
2235
2577
  return rows.find((row) => row.ns === ns)?.value;
@@ -2315,6 +2657,7 @@ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2315
2657
  "skipped",
2316
2658
  "message",
2317
2659
  "summary",
2660
+ "budgetExceeded",
2318
2661
  "sessionId",
2319
2662
  "aggregation",
2320
2663
  "totalCommentCount",
@@ -2322,6 +2665,8 @@ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2322
2665
  "invalidCommentCount",
2323
2666
  "topComments",
2324
2667
  "warnings",
2668
+ "toolCalls",
2669
+ "coverage",
2325
2670
  "hint"
2326
2671
  ],
2327
2672
  properties: {
@@ -2358,6 +2703,7 @@ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2358
2703
  elapsed: { type: "string" }
2359
2704
  }
2360
2705
  }] },
2706
+ budgetExceeded: { type: "boolean" },
2361
2707
  sessionId: NULLABLE_STRING,
2362
2708
  aggregation: {
2363
2709
  type: "object",
@@ -2418,6 +2764,46 @@ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
2418
2764
  type: "array",
2419
2765
  items: { type: "string" }
2420
2766
  },
2767
+ toolCalls: { oneOf: [{ type: "null" }, {
2768
+ type: "object",
2769
+ additionalProperties: false,
2770
+ required: [
2771
+ "total",
2772
+ "failure",
2773
+ "failures"
2774
+ ],
2775
+ properties: {
2776
+ total: { type: "integer" },
2777
+ failure: { type: "integer" },
2778
+ failures: {
2779
+ type: "array",
2780
+ items: { type: "string" }
2781
+ }
2782
+ }
2783
+ }] },
2784
+ coverage: { oneOf: [{ type: "null" }, {
2785
+ type: "object",
2786
+ additionalProperties: false,
2787
+ required: [
2788
+ "selected",
2789
+ "completed",
2790
+ "reused",
2791
+ "failed",
2792
+ "waived",
2793
+ "failedItems"
2794
+ ],
2795
+ properties: {
2796
+ selected: { type: "integer" },
2797
+ completed: { type: "integer" },
2798
+ reused: { type: "integer" },
2799
+ failed: { type: "integer" },
2800
+ waived: { type: "integer" },
2801
+ failedItems: {
2802
+ type: "array",
2803
+ items: { type: "string" }
2804
+ }
2805
+ }
2806
+ }] },
2421
2807
  hint: { type: "string" }
2422
2808
  }
2423
2809
  },
@@ -2539,9 +2925,12 @@ const SESSION_OUTPUT_SCHEMA = {
2539
2925
  * 密钥与状态管理跟着进仓,且换发布形态就要再搬一次——为绕开一条策略把用户目录写进用户仓。
2540
2926
  * owner 口径是「状态归 ocr 自己,围栏由本包显式放开」,故直接要 `danger-full-access`。
2541
2927
  *
2542
- * **「能不能只放开需要写的那一条路径」:不能。** 官方按调用携带的 `SandboxPolicyRequest`
2543
- * 只有 `session` 与 `mode` 两个字段(dsh-sandbox-policy 0.2.1-alpha.2 的
2544
- * lib/types/index.d.ts:50),**没有**附加可写路径、没有自定义 HOME、没有目录级豁免。
2928
+ * **「能不能只放开需要写的那一条路径」:不能。** 两样东西一起锁死了这条路:
2929
+ * 按调用携带的 `SandboxPolicyRequest` 只有 `session` 与 `mode` 两个字段,**没有**附加可写
2930
+ * 路径、没有自定义 HOME、没有目录级豁免;而 `SandboxMode` 的取值只有
2931
+ * `read-only | workspace-write | danger-full-access` 三档,中间那一档的可写根就是
2932
+ * `workspaceRoot` 一枚。两处声明都取自本包实际解析到的那版(dsh-sandbox-policy 与
2933
+ * dsh-sandbox 的 0.2.1-rc.2,`lib/types/index.d.ts`)。
2545
2934
  * 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
2546
2935
  * 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
2547
2936
  * 文件系统写权限,不是宿主其它能力;且 ocr 读什么、写什么仍由上面那条 argv 闸与
@@ -2586,6 +2975,7 @@ async function runForeground(host, command, workdir, run, signal) {
2586
2975
  timeoutMs: run.timeoutMs,
2587
2976
  stdoutMaxBytes: run.stdoutMaxBytes,
2588
2977
  signal,
2978
+ env: run.env,
2589
2979
  sandboxPolicy: ocrSandboxPolicy(workdir)
2590
2980
  });
2591
2981
  const result = await (await host.shell.execute(spec)).result();
@@ -2608,21 +2998,6 @@ async function runForeground(host, command, workdir, run, signal) {
2608
2998
  };
2609
2999
  }
2610
3000
  /**
2611
- * 终止 OCR 进程,忽略"已经没有必要杀"的那一类异常(进程自然退出、已被注册表或宿主回收)。
2612
- *
2613
- * 这层兜不是风格:官方契约明写生产者的 `cancel` 会往上抛(installed
2614
- * `dsh-jobs/lib/types/types.d.ts:98-102` "throws propagate"),而 `killJob` 裸调
2615
- * `job.cancel(reason)`、只有 teardown 那一臂逐条 try/catch(同包 `lib/index.js:611-622`
2616
- * 对照 `:805-824`)。本包的 `cancel` 是从 `AbortSignal` 监听器里被回调的,Node 对监听器
2617
- * 抛出的处理是 `process.nextTick(() => { throw err })` ⇒ **未捕获异常、宿主进程退出**
2618
- * (本机实跑复现)。一次取消杀掉整个 dsh 不是可接受的失败形态。
2619
- */
2620
- function killProc(proc) {
2621
- try {
2622
- proc.kill();
2623
- } catch {}
2624
- }
2625
- /**
2626
3001
  * 进程的两条流作为注册表 pull source(与宿主 bash 工具的 processSources 同一形状)。
2627
3002
  * 交给注册表按它自己的节奏泵 ⇒ 没人来看时输出也照样进环;dsh-shell 的 `observed` 是
2628
3003
  * **非消费**的按偏移读,与 `readOutput()` 的游标互不争夺(installed
@@ -2668,13 +3043,8 @@ async function outcomeWhenSettled(proc) {
2668
3043
  await proc.done;
2669
3044
  return jobOutcomeOf(proc);
2670
3045
  }
2671
- /** 子进程落定后摘掉 abort 监听:fire-and-forget 的一臂,等的是收尾而不是启动。 */
2672
- async function detachWhenSettled(proc, detach) {
2673
- await proc.done;
2674
- detach();
2675
- }
2676
3046
  /**
2677
- * 后台启动:resolve 显式带 `onExpiry: "none"`,故不套宿主 deadline(0.1.7 起
3047
+ * 后台启动:resolve 显式带 `onExpiry: "none"`,故不套宿主 deadline(宿主
2678
3048
  * bash-local 缺省是 'kill',不传就会被缺省 timeoutMs 杀掉),启动准备完成即返回,不等
2679
3049
  * 运行结束、不写 --output。结果以 OCR 会话记录为准(增量落盘),由模型经
2680
3050
  * ocr_session 工具轮询。dsh 无论优雅退出(teardown 组 TERM→KILL)还是硬退出
@@ -2683,23 +3053,21 @@ async function detachWhenSettled(proc, detach) {
2683
3053
  *
2684
3054
  * 那次子进程现在同时是一枚**官方作业**(kind `ocr-review`):官方 `job_list` /
2685
3055
  * `job_output` / `job_kill` 从此看得见它,本包不再需要为"起完就没人管"负责。
2686
- * 工具的返回值是下面的结构化回执对象(canonical 值直返;作业面换装只动了"谁能看见
2687
- * 并停掉它",这份回执的内容自此未变——变的只是不再包一层 `{ text }` 字符串)。
3056
+ * 工具的返回值是下面的结构化回执对象(canonical 值直返,不包一层 `{ text }` 字符串;
3057
+ * 作业面只管"谁能看见并停掉它",不改这份回执的内容)。
2688
3058
  *
2689
- * 取消语义(此前句柄被直接丢弃 → 工具调用被中止后外部 CLI 仍在跑):
2690
- * - spec 带 signal:取消落在「启动准备阶段」时由宿主直接拒绝启动;
2691
- * - 已拿到句柄后 abort:经注册表 `kill(id, undefined, reason)` 收,它把记录推到
2692
- * stopping、把 reason 落进 detail,再回调本包的 `cancel()` 去杀进程;
2693
- * - 进程自然落定(done 永不 reject)后摘掉 abort 监听,不在长会话里堆监听器。
2694
- * `kill` 的 reason 只在这一处带:注册表在 killed 时会把同一个 reason 追加进 detail,
2695
- * 生产者的 `done` 结局再带一遍就是把同一句写两遍(`jobOutcomeOf` 因此刻意不带)。
3059
+ * 生命周期(任何模式下都可后台运行):
3060
+ * - 不接工具调用的中止信号:program 模式在程序结束时会对活跃调用发 abort,
3061
+ * 接上它就等于「后台评审随一次程序执行结束而停」,与后台语义矛盾;
3062
+ * - 结束只发生在四处:宿主退出(命令内的回收守护)、会话 dispose(apply 里那条
3063
+ * 效应)、插件卸载、或显式 `job_kill`;
3064
+ * - 可选时限由 `ocrBackgroundMaxMs` 给(0 = 无窗口,默认),到点经注册表 kill。
2696
3065
  *
2697
3066
  * @param host 宿主服务面
2698
3067
  * @param job 这次后台评审的描述
2699
- * @param signal 工具调用的中止信号
2700
3068
  * @returns 后台启动回执(canonical 对象,模型侧契约)
2701
3069
  */
2702
- async function startBackground(host, job, signal) {
3070
+ async function startBackground(host, job) {
2703
3071
  const registry = host.get("jobs");
2704
3072
  if (registry === void 0) throw new Error("background review unavailable: the host provides no ctx.jobs (load @deepseek-ai/dsh-jobs-local); run it with wait:true instead");
2705
3073
  const spec = host.shell.resolve({
@@ -2707,7 +3075,7 @@ async function startBackground(host, job, signal) {
2707
3075
  workdir: job.workdir,
2708
3076
  onExpiry: "none",
2709
3077
  stdoutMaxBytes: job.stdoutMaxBytes,
2710
- signal,
3078
+ env: job.env,
2711
3079
  sandboxPolicy: ocrSandboxPolicy(job.workdir)
2712
3080
  });
2713
3081
  const proc = await host.shell.execute(spec);
@@ -2732,21 +3100,6 @@ async function startBackground(host, job, signal) {
2732
3100
  }
2733
3101
  const backstopMinutes = Math.round(job.backgroundMaxMs / 6e4);
2734
3102
  enforceOcrDeadline(registry, jobId, format(messages.jobReasonTimeout, { minutes: String(backstopMinutes) }), job.backgroundMaxMs);
2735
- const stop = () => {
2736
- try {
2737
- registry.kill(jobId, void 0, messages.jobReasonAborted);
2738
- } catch {
2739
- killProc(proc);
2740
- }
2741
- };
2742
- const detach = () => {
2743
- signal.removeEventListener("abort", stop);
2744
- };
2745
- if (signal.aborted) stop();
2746
- else {
2747
- signal.addEventListener("abort", stop, { once: true });
2748
- detachWhenSettled(proc, detach);
2749
- }
2750
3103
  return {
2751
3104
  started: true,
2752
3105
  mode: "background",
@@ -2757,19 +3110,27 @@ async function startBackground(host, job, signal) {
2757
3110
  polling: messages.backgroundPolling
2758
3111
  };
2759
3112
  }
2760
- /** 只收集不抛错(llm test 等需要展示原始失败的命令)。输出必先脱敏(直通卡片)。 */
3113
+ /** 只收集不抛错(llm test 等需要展示原始失败的命令)。输出必先脱敏(直通卡片)。
3114
+ * `timedOut` 必须一起交回:被墙钟收走的那条进程没有退出码可报,只留 exitCode 的调用方
3115
+ * 会把「超时」写成「执行失败(exit=null)」,把用户指到不存在的那个退出码上。 */
3116
+ /** 两路输出合并:任一路为空就不添分隔符(空 stderr 也常见,别在正文后留一条空行)。 */
3117
+ function joinedOutput(stdout, stderr) {
3118
+ return stdout === "" || stderr === "" ? stdout + stderr : `${stdout}\n${stderr}`;
3119
+ }
2761
3120
  async function runCollect(host, command, workdir, run) {
2762
3121
  const spec = host.shell.resolve({
2763
3122
  command,
2764
3123
  workdir,
2765
3124
  timeoutMs: run.timeoutMs,
2766
3125
  stdoutMaxBytes: run.stdoutMaxBytes,
3126
+ env: run.env,
2767
3127
  sandboxPolicy: ocrSandboxPolicy(workdir)
2768
3128
  });
2769
3129
  const result = await (await host.shell.execute(spec)).result();
2770
- const joined = result.stdout.text === "" || result.stderr.text === "" ? result.stdout.text + result.stderr.text : `${result.stdout.text}\n${result.stderr.text}`;
3130
+ const joined = joinedOutput(result.stdout.text, result.stderr.text);
2771
3131
  return {
2772
3132
  exitCode: result.exitCode,
3133
+ timedOut: result.timedOut,
2773
3134
  output: truncateEnd(redactKeyMaterial(joined), 8e3)
2774
3135
  };
2775
3136
  }
@@ -2801,12 +3162,24 @@ function readTimeoutMinutes(config) {
2801
3162
  if (Number.isSafeInteger(num) && num > 0) return num * 6e4;
2802
3163
  return MAX_TIMEOUT_REQUEST_MS;
2803
3164
  }
2804
- /** 一次前台执行的两枚部署值:墙钟每次现读(`timeoutMinutes` 是 volatile 设置项),
2805
- * stdout 上限取 entry config(非 volatile,装载期定值)。 */
3165
+ /** 关掉上游自动更新的那一枚变量(`ocrNoAutoUpdate` 与状态探测共用同一个值)。 */
3166
+ const NO_UPDATE_ENV = { OCR_NO_UPDATE: "1" };
3167
+ /**
3168
+ * `ocrNoAutoUpdate` 落地的那一枚环境变量:开着就给 `{OCR_NO_UPDATE:"1"}`,关着给
3169
+ * undefined(不注入,维持上游行为)。每次现读并给新对象,不让执行器与本包共享同一份
3170
+ * 引用——被净化环境是宿主的事,本包只保证「这条命令带上/不带这个变量」。
3171
+ */
3172
+ function ocrEnv(config) {
3173
+ return config.ocrNoAutoUpdate ? { ...NO_UPDATE_ENV } : void 0;
3174
+ }
3175
+ /** 一次前台执行的下发值:墙钟每次现读(`timeoutMinutes` 是 volatile 设置项),stdout 上限
3176
+ * 与自动更新开关取装载期 config(非 volatile)。后台与 llm test 各有各的墙钟来源,但那一枚
3177
+ * 开关三条路径都经 `ocrEnv` 现取,所以不会在某一条 spawn 上漏。 */
2806
3178
  function tuningOf(config) {
2807
3179
  return {
2808
3180
  timeoutMs: readTimeoutMinutes(config),
2809
- stdoutMaxBytes: config.stdoutMaxBytes
3181
+ stdoutMaxBytes: config.stdoutMaxBytes,
3182
+ env: ocrEnv(config)
2810
3183
  };
2811
3184
  }
2812
3185
  /** parameters.required 的读取(本插件自己写的 schema,但门缺一即放行 = 副作用
@@ -2854,38 +3227,6 @@ function define(host, spec) {
2854
3227
  }
2855
3228
  });
2856
3229
  }
2857
- /** body 文本 → JSON 对象(空 body = {};非对象抛错)。 */
2858
- function parseJsonBody(text, messages) {
2859
- if (text.trim() === "") return {};
2860
- const parsed = JSON.parse(text);
2861
- if (!isRecord(parsed)) throw new Error(messages.bodyMustBeObject);
2862
- return parsed;
2863
- }
2864
- /**
2865
- * POST 端点统一前置:405 → 跨域 → CSRF → body 上限(shared.guardBody)。
2866
- *
2867
- * 此前只有 sec-fetch-site(curl/本地进程可伪造头,且空头直通),而写端点会改
2868
- * ~/.opencodereview/config.json;/migrate 与 /test 甚至从不读 body——keep-alive
2869
- * 连接上未读尽的请求体会把残余字节留给后续响应,且超限请求可以一直占内存。
2870
- * 返回 null 表示已就地响应,调用方必须立即返回。
2871
- */
2872
- async function guardPost(req, res, csrf) {
2873
- if (req.method !== "POST") {
2874
- res.setHeader("Allow", "POST");
2875
- sendJson(res, 405, {
2876
- ok: false,
2877
- error: "POST only"
2878
- });
2879
- return null;
2880
- }
2881
- return await guardBody(req, res, {
2882
- maxBytes: BODY_MAX_BYTES,
2883
- csrf: {
2884
- token: csrf,
2885
- headerName: OCR_CSRF_HEADER
2886
- }
2887
- });
2888
- }
2889
3230
  /** 原始输出回显给模型/卡片前的两道收口:截断 + 明文 key 脱敏。 */
2890
3231
  function echoRaw(text, limit) {
2891
3232
  return redactKeyMaterial(truncateEnd(text, limit));
@@ -2905,7 +3246,7 @@ function readOutFile(outPath, messages) {
2905
3246
  /**
2906
3247
  * OCR 原生数值参数的 tool schema 片段(忠实透传语义,桥接层不设钳制)。
2907
3248
  * unit 区分 review(每组)/ scan(每文件)的措辞(取消息表的 unitGroup/unitFile,
2908
- * 语言随设置页偏好走);OCR 侧语义见 shared_flags.go:52-58 与 scan_cmd.go:231-236。
3249
+ * 语言随设置页偏好走);OCR 侧语义见 shared_flags.go:52-58 与同文件的 registerScanFlags。
2909
3250
  */
2910
3251
  function tuningParams(messages, unit) {
2911
3252
  const of = (template) => ({
@@ -2938,8 +3279,15 @@ const configSchema = Schema.object({
2938
3279
  timeoutMinutes: Schema.natural().default(0).volatile(),
2939
3280
  ocrConfigPath: Schema.string().volatile(),
2940
3281
  llmTestTimeoutMs: Schema.natural().min(1).default(LLM_TEST_TIMEOUT_MS),
2941
- ocrBackgroundMaxMs: Schema.natural().min(1).default(OCR_BACKGROUND_MAX_MS),
2942
- stdoutMaxBytes: Schema.natural().min(1).default(STDOUT_MAX_BYTES)
3282
+ startupVerify: Schema.boolean().default(true),
3283
+ ocrBackgroundMaxMs: Schema.natural().default(OCR_BACKGROUND_MAX_MS),
3284
+ stdoutMaxBytes: Schema.natural().min(1).default(STDOUT_MAX_BYTES),
3285
+ ocrNoAutoUpdate: Schema.boolean().default(false),
3286
+ ocrInstallHint: Schema.union([
3287
+ "both",
3288
+ "brew",
3289
+ "npm"
3290
+ ]).default("both")
2943
3291
  });
2944
3292
  /** 设置项里的 ocrConfigPath:非字符串或空白一律视为「未覆盖」。 */
2945
3293
  function readOcrConfigPath(config) {
@@ -2974,7 +3322,7 @@ function ocrHome(config) {
2974
3322
  * 目录,而插件会被重装、换 profile(desktop / browser / 任意自定义名)、换发布形态。
2975
3323
  * 目录一变那条命令就悬空,ocr 侧报的是「resolve LLM endpoint 失败」,与真因毫无
2976
3324
  * 关系——五个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
2977
- * 当前路径一律取自 PLUGIN_DIR(import.meta.dirname,host.ts:181),与 profile 命名
3325
+ * 当前路径一律取自 PLUGIN_DIR(import.meta.dirname),与 profile 命名
2978
3326
  * 无关,故 desktop / browser / 自定义名走的是同一条路径。
2979
3327
  *
2980
3328
  * 与 ocrHome 同一口径:**不抛错**。pluginPaths 在坏设置下会抛,而那正是「工具照常
@@ -2986,14 +3334,141 @@ async function healKeyScriptPointer(host, config) {
2986
3334
  await ensureKeyScriptPointer(pluginPaths(config, messages), messages);
2987
3335
  } catch {}
2988
3336
  }
3337
+ /**
3338
+ * 探测失败的理由分三档,各说各的话:
3339
+ * - exit 127 说明连命令词都没找到,报「执行失败(exit=127)」是把用户往错方向指
3340
+ * (与 runForeground 里同一分工)。
3341
+ * - 超时那条进程是被墙钟收走的,压根没有退出码,报 `exit=null` 等于让用户去查一个
3342
+ * 不存在的返回值。
3343
+ * - 余下才是真退出码。随包入口在、平台二进制缺失那一档是 exit 1 带
3344
+ * `[ERROR] OpenCodeReview binary not found`,那句原文已在 output 里。
3345
+ * @param messages - 文案表(理由是人读文本,随官方 locale 偏好走)。
3346
+ * @param exitCode - 执行器交回的退出码(被杀为 null)。
3347
+ * @param timedOut - 执行器是否把它按超时收走。
3348
+ * @returns 给人看的那一句失败理由。
3349
+ */
3350
+ function probeFailureReason(messages, exitCode, timedOut) {
3351
+ if (exitCode === 127) return messages.execNotFound;
3352
+ if (timedOut) return format(messages.probeTimedOut, { seconds: String(VERSION_PROBE_TIMEOUT_MS / 1e3) });
3353
+ return format(messages.execFailed, { exitCode: String(exitCode) });
3354
+ }
3355
+ /**
3356
+ * 跑一次 `ocr version`,把外部 CLI 的自述取回来给设置页。
3357
+ *
3358
+ * 两条必须写死的口径:
3359
+ * - 带 `OCR_NO_UPDATE=1`。不加这一条,「查一下版本」就顺带触发了 CLI 那层 npm 包装的
3360
+ * **全局安装**(`scripts/update.js` 见到更新版本就 spawnSync `npm i -g <pkg>@<latest>`,
3361
+ * 默认 18 分钟冷却一次)。状态读侧不该有写侧副作用,所以这条不受 `ocrNoAutoUpdate`
3362
+ * 那个部署开关影响,恒定带上。
3363
+ * - HOME 与五个工具走同一个重定向口。launcher 那句「自动更新没成功,请手工执行」的
3364
+ * 提示写在 `$HOME/.opencodereview/` 下,不带重定向就会去读另一个世界的状态,
3365
+ * 卡片报的与评审实际吃到的就不是同一份。
3366
+ * @param host 宿主服务面
3367
+ * @param config 本次 apply 的配置
3368
+ * @param messages 文案表(回执里给人看的理由)
3369
+ * @returns 是否取到版本、版本串(取不到为 null)、一手输出(已脱敏、已截断、已去色)、失败理由。
3370
+ */
3371
+ async function runVersionProbe(host, config, messages) {
3372
+ const raw = await runCollect(host, withHomeEnv(buildVersionCommand(), ocrHome(config)), process.env["HOME"] ?? "/", {
3373
+ timeoutMs: VERSION_PROBE_TIMEOUT_MS,
3374
+ stdoutMaxBytes: config.stdoutMaxBytes,
3375
+ env: { ...NO_UPDATE_ENV }
3376
+ });
3377
+ const { exitCode, timedOut } = raw;
3378
+ const output = stripAnsi(raw.output);
3379
+ if (exitCode !== 0) return {
3380
+ ok: false,
3381
+ version: null,
3382
+ output,
3383
+ reason: probeFailureReason(messages, exitCode, timedOut)
3384
+ };
3385
+ const version = parseOcrVersion(output);
3386
+ return {
3387
+ ok: version !== null,
3388
+ version,
3389
+ output,
3390
+ reason: version === null ? messages.probeNoVersion : ""
3391
+ };
3392
+ }
3393
+ /** 三条装法文案的键,按「来源 / 部署偏好」这个联合取值挑。收成一张表是因为
3394
+ * `variant === "brew" ? … : variant === "npm" ? … : …` 那种嵌套三元在本仓是 lint 级禁令。 */
3395
+ const INSTALL_HINT_KEYS = {
3396
+ both: "installHintBoth",
3397
+ brew: "installHintBrew",
3398
+ npm: "installHintNpm"
3399
+ };
3400
+ /**
3401
+ * 「怎么把外部 CLI 装/换成核对过的这一版」那一句文案。
3402
+ *
3403
+ * 两条判据的分工是刻意的:本机已有一份候选时**按那份的真实来源说**(brew 装的提示 brew,
3404
+ * npm 装的提示 npm),因为用户要执行的是替换他手上那一份的命令;判不出来源(unknown)或
3405
+ * 压根没装时才退到部署偏好 `ocrInstallHint`。反过来让偏好盖来源,就会给 brew 装的用户端出
3406
+ * 一条 `npm i -g`,装完机器上出现第二份 ocr 而 PATH 上那条还是旧的。
3407
+ * @param status - 现读口交回的可用性判定(来源、命中路径与那份的渠道)。
3408
+ * @param config - 本次 apply 的配置(取 `ocrInstallHint`)。
3409
+ * @param messages - 文案表。
3410
+ * @returns 给人照抄的那一句(含命令原文)。
3411
+ */
3412
+ function ocrInstallText(status, config, messages) {
3413
+ const channel = status.channel ?? "unknown";
3414
+ const variant = channel === "unknown" ? config.ocrInstallHint : channel;
3415
+ const commands = ocrCommandsFor(channel, status.path !== void 0);
3416
+ return format(messages[INSTALL_HINT_KEYS[variant]], {
3417
+ brewCmd: commands.brew,
3418
+ npmCmd: commands.npm,
3419
+ version: EXPECTED_OCR_CLI_VERSION
3420
+ });
3421
+ }
3422
+ /**
3423
+ * GET providers 里 `ocrBinary` 那半份载荷:文件系统的事实 + 版本核对所需的两样 + 装法文案。
3424
+ * 三个显示面(卡片状态行、详情页徽标/区块、启用指引)都读这一份,凑不出两处口径差。
3425
+ * @param status - 现读口交回的可用性判定。
3426
+ * @param config - 本次 apply 的配置(装法文案的部署偏好)。
3427
+ * @param messages - 文案表。
3428
+ * @param measured - 最近一次实测到的版本;null = 还没测过。
3429
+ * @returns 给卡片的对象。
3430
+ */
3431
+ function cliStatusPayload(status, config, messages, measured) {
3432
+ return {
3433
+ ...status,
3434
+ expectedVersion: EXPECTED_OCR_CLI_VERSION,
3435
+ measuredVersion: measured,
3436
+ installHint: ocrInstallText(status, config, messages)
3437
+ };
3438
+ }
3439
+ /**
3440
+ * 把版本核对提示追加到摘要的 `hint` 尾部,让模型读得到「这份结果不是按插件核对的版本跑的」。
3441
+ *
3442
+ * 为什么写在 hint 里而不是新增一枚字段:output schema 是 `additionalProperties: false` +
3443
+ * 逐字段 `required`,加一枚要同时动 schema、必填表和 lib/output.ts 的形态闸三处;而这条信息
3444
+ * 的全部价值就是「模型读到它」,hint 那段本来就是教模型怎么读这份结果。
3445
+ * 没实测值(装载期自检关着、或那条命令跑砸了)就原样交回:不知道就是不知道,
3446
+ * 编一句「版本可能不一致」会让模型把没发生过的事转述给用户。
3447
+ * @param summary - summarizeReview 的产物。
3448
+ * @param measured - 最近一次实测到的外部 CLI 版本;null = 还没测过。
3449
+ * @param messages - 文案表。
3450
+ * @returns 同一份摘要,或 hint 尾部多了一句的副本。
3451
+ */
3452
+ function withCliVersionNote(summary, measured, messages) {
3453
+ if (measured === null || measured === "1.13.0") return summary;
3454
+ const note = format(messages.versionMismatch, {
3455
+ actual: measured,
3456
+ expected: EXPECTED_OCR_CLI_VERSION
3457
+ });
3458
+ return {
3459
+ ...summary,
3460
+ hint: `${summary.hint}\n${note}`
3461
+ };
3462
+ }
2989
3463
  /** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
2990
- * 命令词与五个工具同一解析源(buildLlmTestCommand:随包 launcher 装法下 PATH 上
2991
- * 没有裸 `ocr`),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry config
2992
- * 字段(非 volatile,改值随重启生效)。 */
3464
+ * 命令词与五个工具同一解析源(buildLlmTestCommand 走 ocrCommand():卡片验的那一份与评审
3465
+ * 吃的那一份必须同一个二进制),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry
3466
+ * config 字段(非 volatile,改值随重启生效)。 */
2993
3467
  async function runLlmTest(host, config) {
2994
3468
  const { exitCode, output } = await runCollect(host, withHomeEnv(buildLlmTestCommand(), ocrHome(config)), process.env["HOME"] ?? "/", {
2995
3469
  timeoutMs: config.llmTestTimeoutMs,
2996
- stdoutMaxBytes: config.stdoutMaxBytes
3470
+ stdoutMaxBytes: config.stdoutMaxBytes,
3471
+ env: ocrEnv(config)
2997
3472
  });
2998
3473
  return {
2999
3474
  ok: exitCode === 0,
@@ -3110,42 +3585,19 @@ async function canonicalReviewRoot(host, root) {
3110
3585
  return realpathOrSelf(root);
3111
3586
  }
3112
3587
  /**
3113
- * 判定 webserver 绑定地址字面量是否回环(127/8、::1 及其全展开/IPv4 映射形态;
3114
- * `%zone` 后缀剥掉再比,对齐宿主 isLoopbackHost 的判定域)。
3588
+ * 应用一次 provider/model 选择。
3115
3589
  *
3116
- * 为什么不是 `host === "0.0.0.0"`:宿主 webserver 的 Config 在装载期就**拒绝通配
3117
- * 地址**(0.2.1-alpha.2 packages/host/webserver/src/index.ts:263-267 —— 0.0.0.0/::
3118
- * 一律 throw,要求绑定本机某个具体接口的 IP 字面量),所以那个等式是永假死分支,
3119
- * 唯一需要 trust 闸放行局域网 Host 头的场景(绑定 LAN IP,容器/远端部署的正路)
3120
- * 反而被当成回环、整批 403。host 经 config 校验必为具体 IP 字面量,故本函数
3121
- * 不必处理主机名与通配,只枚举回环字面量的常见书写。
3590
+ * 缺字段就地回 400 而不是让 applyProviderSelection 抛:那句文案与卡片「选一个」的提示
3591
+ * 同源,走异常只会让调用方看见一层包装。其余失败一律折成 `{ ok: false, status }`:
3592
+ * `status` 是本包保留的语义码(见 `lib/remote-shape.ts` 的 OcrRemoteRefused),不是 HTTP 状态。
3122
3593
  */
3123
- function isLoopbackBindAddress(host) {
3124
- const value = host.toLowerCase().replace(/%.*$/u, "");
3125
- return value === "::1" || value === "0:0:0:0:0:0:0:1" || value.startsWith("127.") || /^::ffff:127(?:\.\d{1,3}){3}$/u.test(value);
3126
- }
3127
- /** POST select 的响应:把 body 里的 provider/model 应用到 OCR 配置,按需附带 llm test 回执。 */
3128
- async function sendSelectResponse(text, res, deps) {
3594
+ async function selectReply(provider, model, deps) {
3129
3595
  const msgs = deps.messages();
3130
- let body;
3131
- try {
3132
- body = parseJsonBody(text, msgs);
3133
- } catch (error) {
3134
- sendJson(res, 400, {
3135
- ok: false,
3136
- error: format(msgs.bodyParseFailed, { message: errorText(error) })
3137
- });
3138
- return;
3139
- }
3140
- const provider = typeof body["provider"] === "string" ? body["provider"] : "";
3141
- const model = typeof body["model"] === "string" ? body["model"] : "";
3142
- if (provider === "" || model === "") {
3143
- sendJson(res, 400, {
3144
- ok: false,
3145
- error: msgs.providerModelRequired
3146
- });
3147
- return;
3148
- }
3596
+ if (provider === "" || model === "") return {
3597
+ ok: false,
3598
+ error: msgs.providerModelRequired,
3599
+ status: 400
3600
+ };
3149
3601
  try {
3150
3602
  const language = readLanguage(deps.config);
3151
3603
  const applied = await applyProviderSelection(deps.gateway, pluginPaths(deps.config, msgs), {
@@ -3155,112 +3607,212 @@ async function sendSelectResponse(text, res, deps) {
3155
3607
  }, msgs);
3156
3608
  let llmTest = null;
3157
3609
  if (readAutoVerify(deps.config)) llmTest = await runLlmTest(deps.host, deps.config);
3158
- sendJson(res, 200, {
3610
+ return {
3159
3611
  ok: true,
3160
3612
  applied,
3161
3613
  llmTest
3162
- });
3614
+ };
3163
3615
  } catch (error) {
3164
- sendJson(res, 400, {
3616
+ return {
3165
3617
  ok: false,
3166
- error: errorText(error)
3167
- });
3618
+ error: errorText(error),
3619
+ status: 400
3620
+ };
3168
3621
  }
3169
3622
  }
3170
- /** 注册 webServer 配置端点(卡片专用:providers/select/migrate/test)。
3171
- * csrf 为每次 apply 生成的会话内 token:GET providers 下发、三个写端点回填
3172
- * 校验(sec-fetch-site 只挡浏览器,挡不住能构造请求头的本地进程)。
3173
- * ctx 只用来挂释放器,给的是 `inject(["webServer"])` 子 fiber 的上下文。 */
3174
- function registerConfigEndpoints(ctx, host, config, csrf) {
3175
- const webServer = host.get("webServer");
3176
- if (!webServer) return;
3177
- const servingNonLoopback = !isLoopbackBindAddress(webServer.host);
3178
- const gateway = createGateway(host);
3179
- const messages = () => localeMessages(host);
3180
- const disposeProviders = webServer.register({
3181
- kind: "exact",
3182
- path: PROVIDERS_PATH,
3183
- handler: async (req, res) => {
3184
- if (!guardTrust(req, res, { servingNonLoopback })) return;
3185
- try {
3186
- const msgs = messages();
3187
- const payload = await providersForCard(gateway, pluginPaths(config, msgs), msgs);
3188
- sendJson(res, 200, {
3189
- ...payload,
3190
- csrf
3191
- });
3192
- } catch (error) {
3193
- sendJson(res, 500, {
3194
- ok: false,
3195
- error: errorText(error)
3196
- });
3197
- }
3198
- }
3199
- });
3200
- const disposeSelect = webServer.register({
3201
- kind: "exact",
3202
- path: SELECT_PATH,
3203
- handler: async (req, res) => {
3204
- if (!guardTrust(req, res, { servingNonLoopback })) return;
3205
- const text = await guardPost(req, res, csrf);
3206
- if (text === null) return;
3207
- await sendSelectResponse(text, res, {
3208
- config,
3209
- gateway,
3210
- host,
3211
- messages
3212
- });
3213
- }
3214
- });
3215
- const disposeMigrate = webServer.register({
3216
- kind: "exact",
3217
- path: MIGRATE_PATH,
3218
- handler: async (req, res) => {
3219
- if (!guardTrust(req, res, { servingNonLoopback })) return;
3220
- if (await guardPost(req, res, csrf) === null) return;
3221
- try {
3222
- const msgs = messages();
3223
- const result = await migratePlaintextKeys(gateway, pluginPaths(config, msgs), msgs);
3224
- sendJson(res, 200, {
3225
- ok: true,
3226
- ...result
3227
- });
3228
- } catch (error) {
3229
- sendJson(res, 400, {
3230
- ok: false,
3231
- error: errorText(error)
3232
- });
3233
- }
3234
- }
3235
- });
3236
- const disposeTest = webServer.register({
3237
- kind: "exact",
3238
- path: TEST_PATH,
3239
- handler: async (req, res) => {
3240
- if (!guardTrust(req, res, { servingNonLoopback })) return;
3241
- if (await guardPost(req, res, csrf) === null) return;
3242
- try {
3243
- const llmTest = await runLlmTest(host, config);
3244
- sendJson(res, 200, {
3245
- ok: llmTest.ok,
3246
- output: llmTest.output
3247
- });
3248
- } catch (error) {
3249
- sendJson(res, 400, {
3250
- ok: false,
3251
- error: errorText(error)
3252
- });
3253
- }
3623
+ /**
3624
+ * 跑一次状态探测并落成卡片吃得下的那份载荷。
3625
+ *
3626
+ * `reason` 与 `output` 的分工:`output` 是外部 CLI 的一手回执(`runCollect` 已脱敏、
3627
+ * 已按码元截断),那句「自动更新没成功,请手工执行 npm i -g …」就原样活在里面——本包
3628
+ * 不去正则它,因为那句措辞归上游改,抄一份判据就多一处会跟着漂的地方;卡片把整段
3629
+ * 显示出来供人选中文本复制即可。`reason` 只说这一路为什么没取到版本。
3630
+ *
3631
+ * `consistent` 是三态而不是布尔:`null` = 压根没读出版本(未发布构建、超时、没装),
3632
+ * 这时对用户宣称「不一致」是把没证据的事说成结论。`changeText` 只在**有把握给出命令**
3633
+ * 时才非空——实测到别的版本,或文件系统那边根本没有候选。
3634
+ */
3635
+ async function probeReply(deps) {
3636
+ try {
3637
+ const messages = deps.messages();
3638
+ const probe = await runVersionProbe(deps.host, deps.config, messages);
3639
+ deps.versionSink.value = probe.version;
3640
+ const status = deps.availability();
3641
+ const consistent = probe.version === null ? null : probe.version === EXPECTED_OCR_CLI_VERSION;
3642
+ const showCommand = consistent === false || status.source === "none";
3643
+ return {
3644
+ ok: probe.ok,
3645
+ version: probe.version,
3646
+ expectedVersion: EXPECTED_OCR_CLI_VERSION,
3647
+ consistent,
3648
+ output: probe.output,
3649
+ reason: probe.reason,
3650
+ changeText: showCommand ? ocrInstallText(status, deps.config, messages) : ""
3651
+ };
3652
+ } catch (error) {
3653
+ return {
3654
+ ok: false,
3655
+ error: errorText(error),
3656
+ status: 400
3657
+ };
3658
+ }
3659
+ }
3660
+ /**
3661
+ * 组好设置页那份载荷(**不含 csrf**:本包不再持有任何写侧闸门,鉴权归宿主网关)。
3662
+ *
3663
+ * `ocrBinary` 只用文件系统的事实(不起进程),卡片据此显示「装没装、走的哪条路、那一份是什么
3664
+ * 装法」。实测版本不在这里 spawn——这里只交最近一次检测落在闭包里的那个值(没测过就是 null);
3665
+ * 现跑一次 `ocr version` 归 probe,由人按下「检测」才拿。
3666
+ */
3667
+ async function providersReply(deps) {
3668
+ try {
3669
+ const msgs = deps.messages();
3670
+ return {
3671
+ ...await providersForCard(deps.gateway, pluginPaths(deps.config, msgs), msgs, deps.extras.verifyOf()),
3672
+ ocrBinary: cliStatusPayload(deps.extras.availability(), deps.config, msgs, deps.extras.versionSink.value)
3673
+ };
3674
+ } catch (error) {
3675
+ return {
3676
+ ok: false,
3677
+ error: errorText(error),
3678
+ status: 500
3679
+ };
3680
+ }
3681
+ }
3682
+ /** 明文 key 一键迁移。 */
3683
+ async function migrateReply(deps) {
3684
+ try {
3685
+ const msgs = deps.messages();
3686
+ return {
3687
+ ok: true,
3688
+ ...await migratePlaintextKeys(deps.gateway, pluginPaths(deps.config, msgs), msgs)
3689
+ };
3690
+ } catch (error) {
3691
+ return {
3692
+ ok: false,
3693
+ error: errorText(error),
3694
+ status: 400
3695
+ };
3696
+ }
3697
+ }
3698
+ /** 手动 `ocr llm test`:`ok` 是测试结论,不是调用成败。 */
3699
+ async function testReply(deps) {
3700
+ try {
3701
+ const llmTest = await runLlmTest(deps.host, deps.config);
3702
+ return {
3703
+ ok: llmTest.ok,
3704
+ output: llmTest.output
3705
+ };
3706
+ } catch (error) {
3707
+ return {
3708
+ ok: false,
3709
+ error: errorText(error),
3710
+ status: 400
3711
+ };
3712
+ }
3713
+ }
3714
+ /**
3715
+ * 设置卡的五条读/写方法(providers / select / migrate / test / probe)。
3716
+ *
3717
+ * ⚠ 服务在**构造时**注册(cordis `Service` 的契约:`ctx.reflect.provide`,随所属 fiber
3718
+ * 卸载自动撤),所以调用方拿实例没有任何用处——装入口 `installOcrRemote` 说明了这一点。
3719
+ * 五个方法都不依赖可选宿主服务(gateway 与取用口都由 apply 交进来),故它跟着 apply
3720
+ * 无条件上线:宿主没有 web 层(TUI)时这五条照样在。
3721
+ */
3722
+ var OcrReviewRemoteService = class extends TypertRemoteService {
3723
+ deps;
3724
+ constructor(ctx, deps) {
3725
+ super(ctx, OCR_REMOTE_NAMESPACE);
3726
+ this.deps = deps;
3727
+ }
3728
+ /** 设置页数据:provider 清单 + 当前 OCR 配置 + 降级原因 + 外部 CLI 状态(无 key 明文)。 */
3729
+ async providers() {
3730
+ return providersReply(this.deps);
3731
+ }
3732
+ /** 应用 provider/model 选择;回执里按 autoVerify 决定是否带 llm test。 */
3733
+ async select(request) {
3734
+ return selectReply(isRecord(request) && typeof request.provider === "string" ? request.provider : "", isRecord(request) && typeof request.model === "string" ? request.model : "", this.deps);
3735
+ }
3736
+ /** 明文 api_key → api_key_cmd 一键迁移。 */
3737
+ async migrate() {
3738
+ return migrateReply(this.deps);
3739
+ }
3740
+ /** 手动触发 ocr llm test。 */
3741
+ async test() {
3742
+ return testReply(this.deps);
3743
+ }
3744
+ /** 跑一次 `ocr version`,取外部 CLI 的自述给状态行(不前置可用性:检测就是为了知道装没装)。 */
3745
+ async probe() {
3746
+ return probeReply({
3747
+ config: this.deps.config,
3748
+ host: this.deps.host,
3749
+ messages: this.deps.messages,
3750
+ availability: this.deps.extras.availability,
3751
+ versionSink: this.deps.extras.versionSink
3752
+ });
3753
+ }
3754
+ };
3755
+ /**
3756
+ * 标准装饰器的**程序化调用**:`@Remote` 的全部作用就是往类原型写一枚描述符,而标准装饰器
3757
+ * 语法需要一条 tsc 降级管线(本仓的测试/构建解析器都不降级它)。这里直接按 TC39 的
3758
+ * `ClassMethodDecoratorContext` 形状调同一个 decorator 函数,把标记落到真正的原型上。
3759
+ */
3760
+ function markRemote(prototype, methodName) {
3761
+ const context = {
3762
+ kind: "method",
3763
+ name: methodName,
3764
+ static: false,
3765
+ private: false,
3766
+ addInitializer: (initializer) => {
3767
+ Reflect.apply(initializer, Object.create(prototype), []);
3254
3768
  }
3255
- });
3256
- ctx.effect(() => () => {
3257
- disposeProviders();
3258
- disposeSelect();
3259
- disposeMigrate();
3260
- disposeTest();
3261
- }, "ocr-review: config endpoints");
3769
+ };
3770
+ const decorate = Remote(methodName);
3771
+ Reflect.apply(decorate, void 0, [Reflect.get(prototype, methodName), context]);
3772
+ }
3773
+ markRemote(OcrReviewRemoteService.prototype, "providers");
3774
+ markRemote(OcrReviewRemoteService.prototype, "select");
3775
+ markRemote(OcrReviewRemoteService.prototype, "migrate");
3776
+ markRemote(OcrReviewRemoteService.prototype, "test");
3777
+ markRemote(OcrReviewRemoteService.prototype, "probe");
3778
+ /**
3779
+ * 把读面装到这个上下文上(构造即注册,见类文档;host 侧唯一该调的口)。
3780
+ *
3781
+ * 注册口先探一次(`ctx.reflect.provide`):替身/畸形上下文未必给,少了它只该少掉卡片
3782
+ * 这一栏,绝不该让 apply 当场炸掉。本服务没有自己的收尾资源——卸载由 Service 基类登记的
3783
+ * 反射面 disposer 负责,不另挂 effect。
3784
+ *
3785
+ * @param ctx - 宿主上下文
3786
+ * @param deps - 读面的全部依赖(见 OcrReviewRemoteDeps)
3787
+ * @returns 装上了没有(调用方不需要它——返回它只是让这条路可断言)
3788
+ */
3789
+ function installOcrRemote(ctx, deps) {
3790
+ if (!isRecord(ctx)) return false;
3791
+ const { reflect } = ctx;
3792
+ if (!isRecord(reflect) || typeof reflect.provide !== "function") return false;
3793
+ new OcrReviewRemoteService(ctx, deps);
3794
+ return true;
3262
3795
  }
3263
- function registerReviewTool(host, config) {
3796
+ /**
3797
+ * 生产侧的可用性现读口:每次调用问一次真实文件系统。
3798
+ * 单独具名导出是为了让它能被结构性地钉住(单测拿它和 `lib/cli.ts` 那份判据逐字比),
3799
+ * 而不必让任何用例去分支「这台机器恰好装没装 ocr」。
3800
+ */
3801
+ const productionProbe = () => resolveOcrAvailability();
3802
+ /**
3803
+ * 两条路都没有候选时就地抛,让模型与用户看到的是「哪条路都没找到 + 怎么装(带核对版本)」,
3804
+ * 而不是子进程 exit 127 之后那句反推出来的「ocr 命令不存在」。
3805
+ * 判据只回答「找不找得到」:丢执行位、随包平台二进制缺失这些「找到了但跑不起来」的事实
3806
+ * 仍由真实 spawn 报(lib/cli.ts 的 resolveOcrAvailability 头注释写了同一口径),
3807
+ * 所以 `execNotFound` 那条兜底文案必须留着。
3808
+ * @param status - 现读口交回的可用性判定。
3809
+ * @param messages - 文案表(拒绝理由是人读文本,随官方 locale 偏好走)。
3810
+ * @param install - 那条装法文案(`ocrInstallText` 现组;只有真要拒时才付这次拼接)。
3811
+ */
3812
+ function assertOcrPresent(status, messages, install) {
3813
+ if (status.source === "none") throw new Error(format(messages.ocrBinaryMissing, { install }));
3814
+ }
3815
+ function registerReviewTool(host, config, probe, versionSink) {
3264
3816
  const msgs = localeMessages(host);
3265
3817
  define(host, {
3266
3818
  name: "ocr_review",
@@ -3334,6 +3886,8 @@ function registerReviewTool(host, config) {
3334
3886
  schema: REVIEW_OUTPUT_SCHEMA,
3335
3887
  run: async (args, exec) => {
3336
3888
  const runMsgs = localeMessages(host);
3889
+ const cliStatus = probe();
3890
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3337
3891
  const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
3338
3892
  const eff = effortOf(args["effort"], config, runMsgs);
3339
3893
  const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
@@ -3353,8 +3907,9 @@ function registerReviewTool(host, config) {
3353
3907
  tool: "ocr_review",
3354
3908
  repo,
3355
3909
  stdoutMaxBytes: config.stdoutMaxBytes,
3356
- backgroundMaxMs: config.ocrBackgroundMaxMs
3357
- }, exec.signal);
3910
+ backgroundMaxMs: config.ocrBackgroundMaxMs,
3911
+ env: ocrEnv(config)
3912
+ });
3358
3913
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
3359
3914
  const text = readOutFile(outPath, runMsgs);
3360
3915
  const parsed = parseReviewOutput(text, runMsgs);
@@ -3364,7 +3919,7 @@ function registerReviewTool(host, config) {
3364
3919
  limit: String(RAW_ECHO_LIMIT),
3365
3920
  raw: echoRaw(text, RAW_ECHO_LIMIT)
3366
3921
  });
3367
- return summarizeReview(parsed, runMsgs, { maxComments: readMaxComments(config) });
3922
+ return withCliVersionNote(summarizeReview(parsed, runMsgs, { maxComments: readMaxComments(config) }), versionSink.value, runMsgs);
3368
3923
  } finally {
3369
3924
  if (tmp !== void 0) rmSync(tmp, {
3370
3925
  recursive: true,
@@ -3374,7 +3929,7 @@ function registerReviewTool(host, config) {
3374
3929
  }
3375
3930
  });
3376
3931
  }
3377
- function registerScanTool(host, config) {
3932
+ function registerScanTool(host, config, probe, versionSink) {
3378
3933
  const msgs = localeMessages(host);
3379
3934
  define(host, {
3380
3935
  name: "ocr_scan",
@@ -3428,6 +3983,8 @@ function registerScanTool(host, config) {
3428
3983
  schema: REVIEW_OUTPUT_SCHEMA,
3429
3984
  run: async (args, exec) => {
3430
3985
  const runMsgs = localeMessages(host);
3986
+ const cliStatus = probe();
3987
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3431
3988
  const repo = ocrRepo(args, exec, runMsgs);
3432
3989
  const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
3433
3990
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
@@ -3445,8 +4002,9 @@ function registerScanTool(host, config) {
3445
4002
  tool: "ocr_scan",
3446
4003
  repo,
3447
4004
  stdoutMaxBytes: config.stdoutMaxBytes,
3448
- backgroundMaxMs: config.ocrBackgroundMaxMs
3449
- }, exec.signal);
4005
+ backgroundMaxMs: config.ocrBackgroundMaxMs,
4006
+ env: ocrEnv(config)
4007
+ });
3450
4008
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
3451
4009
  const text = readOutFile(outPath, runMsgs);
3452
4010
  const parsed = parseReviewOutput(text, runMsgs);
@@ -3456,7 +4014,7 @@ function registerScanTool(host, config) {
3456
4014
  limit: String(RAW_ECHO_LIMIT),
3457
4015
  raw: echoRaw(text, RAW_ECHO_LIMIT)
3458
4016
  });
3459
- return summarizeReview(parsed, runMsgs, { maxComments: readMaxComments(config) });
4017
+ return withCliVersionNote(summarizeReview(parsed, runMsgs, { maxComments: readMaxComments(config) }), versionSink.value, runMsgs);
3460
4018
  } finally {
3461
4019
  if (tmp !== void 0) rmSync(tmp, {
3462
4020
  recursive: true,
@@ -3466,7 +4024,7 @@ function registerScanTool(host, config) {
3466
4024
  }
3467
4025
  });
3468
4026
  }
3469
- function registerDelegatePreviewTool(host, config) {
4027
+ function registerDelegatePreviewTool(host, config, probe) {
3470
4028
  const msgs = localeMessages(host);
3471
4029
  define(host, {
3472
4030
  name: "ocr_delegate_preview",
@@ -3514,6 +4072,8 @@ function registerDelegatePreviewTool(host, config) {
3514
4072
  schema: PREVIEW_OUTPUT_SCHEMA,
3515
4073
  run: async (args, exec) => {
3516
4074
  const runMsgs = localeMessages(host);
4075
+ const cliStatus = probe();
4076
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3517
4077
  const repo = ocrRepo(args, exec, runMsgs);
3518
4078
  await healKeyScriptPointer(host, config);
3519
4079
  const homeOverride = ocrHome(config);
@@ -3542,7 +4102,7 @@ function registerDelegatePreviewTool(host, config) {
3542
4102
  }
3543
4103
  });
3544
4104
  }
3545
- function registerDelegateRuleTool(host, config) {
4105
+ function registerDelegateRuleTool(host, config, probe) {
3546
4106
  const msgs = localeMessages(host);
3547
4107
  define(host, {
3548
4108
  name: "ocr_delegate_rule",
@@ -3566,6 +4126,8 @@ function registerDelegateRuleTool(host, config) {
3566
4126
  schema: RULES_OUTPUT_SCHEMA,
3567
4127
  run: async (args, exec) => {
3568
4128
  const runMsgs = localeMessages(host);
4129
+ const cliStatus = probe();
4130
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3569
4131
  const { command, workdir } = buildDelegateRuleCommand({
3570
4132
  repo: ocrRepo(args, exec, runMsgs),
3571
4133
  paths: args["paths"]
@@ -3590,7 +4152,7 @@ function registerDelegateRuleTool(host, config) {
3590
4152
  }
3591
4153
  });
3592
4154
  }
3593
- function registerSessionTool(host, config) {
4155
+ function registerSessionTool(host, config, probe) {
3594
4156
  const msgs = localeMessages(host);
3595
4157
  define(host, {
3596
4158
  name: "ocr_session",
@@ -3625,6 +4187,8 @@ function registerSessionTool(host, config) {
3625
4187
  schema: SESSION_OUTPUT_SCHEMA,
3626
4188
  run: async (args, exec) => {
3627
4189
  const runMsgs = localeMessages(host);
4190
+ const cliStatus = probe();
4191
+ assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
3628
4192
  const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
3629
4193
  const { command, workdir } = buildSessionCommand({
3630
4194
  ...args,
@@ -3642,27 +4206,87 @@ function registerSessionTool(host, config) {
3642
4206
  }
3643
4207
  });
3644
4208
  }
3645
- function apply(ctx, config) {
4209
+ /** 装载期自检结论里保留的输出上限(字符):卡片只用来提示,留全量没有意义。 */
4210
+ const ENDPOINT_VERIFY_CHARS = 400;
4211
+ /** 装载期端点自检(fire-and-forget):跑一次 `ocr llm test`,把结论写进本次 apply 的闭包,
4212
+ * 经 GET providers 随载荷交给卡片。
4213
+ *
4214
+ * 为什么需要:卡片写盘只写 url/model/key 通道,**不验端点能力**。实测踩到过——网关强制
4215
+ * 要求一枚能力头,缺了它每个文件都 400,而模型侧看到的是「N 个文件审查失败」,与真因
4216
+ * (端点配置)隔着一层。自检失败不抛、不阻塞装载:它只负责让这件事早点可见。
4217
+ * @param host 宿主服务面
4218
+ * @param config 本次 apply 的配置
4219
+ * @param sink 本次 apply 的闭包槽(不是常驻状态)
4220
+ */
4221
+ /** 装载期自检结果 → 卡片读的那一格(成功不占文案,失败截到预算内)。 */
4222
+ function endpointVerifyOf(result) {
4223
+ return result.ok ? {
4224
+ ok: true,
4225
+ message: ""
4226
+ } : {
4227
+ ok: false,
4228
+ message: result.output.trim().slice(0, ENDPOINT_VERIFY_CHARS)
4229
+ };
4230
+ }
4231
+ async function probeEndpointAtApply(host, config, sink) {
4232
+ sink.value = endpointVerifyOf(await runLlmTest(host, config));
4233
+ }
4234
+ /**
4235
+ * 装载期版本自检(fire-and-forget):跑一次 `ocr version`,把实测版本落在本次 apply 的闭包里。
4236
+ *
4237
+ * 为什么要有这一次:工具回执里那句「实测与核对版本不一致」只能来自一次真实测量,而按下
4238
+ * 「检测」是人在页面上的动作——把它留给用户,模型侧的版本提示就永远晚一步。与端点自检同批、
4239
+ * 同受 `startupVerify` 管:那条设置的判据就是「装载期允不允许起进程」,这里起的同样是进程。
4240
+ * 关掉它的部署退化成「页面上点过检测之后,工具才开始报」,那也是一句实话。
4241
+ * @param host 宿主服务面
4242
+ * @param config 本次 apply 的配置
4243
+ * @param sink 本次 apply 的闭包槽(与 POST probe 写的是同一个)
4244
+ */
4245
+ async function probeVersionAtApply(host, config, sink) {
4246
+ sink.value = (await runVersionProbe(host, config, localeMessages(host))).version;
4247
+ }
4248
+ function apply(ctx, config, probe) {
3646
4249
  const host = resolveHost(ctx);
4250
+ const endpointVerify = { value: null };
4251
+ const versionSink = { value: null };
4252
+ if (config.startupVerify) {
4253
+ probeEndpointAtApply(host, config, endpointVerify);
4254
+ probeVersionAtApply(host, config, versionSink);
4255
+ }
3647
4256
  ctx.effect(() => () => {
3648
4257
  const jobs = ctx.get("jobs");
3649
4258
  if (jobs !== void 0) reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonUnload);
3650
4259
  }, "ocr-review: background jobs");
4260
+ ctx.effect(() => ctx.on("session/disposed", () => {
4261
+ const jobs = ctx.get("jobs");
4262
+ if (jobs === void 0) return;
4263
+ try {
4264
+ reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonSessionStopped);
4265
+ } catch {}
4266
+ }), "ocr-review: session stop reaps background runs");
3651
4267
  host.inject(["settings"], (child) => {
3652
4268
  child.effect(() => child.settings.configure({ auto: false }, host.fiber));
3653
4269
  });
3654
- registerReviewTool(host, config);
3655
- registerScanTool(host, config);
3656
- registerDelegatePreviewTool(host, config);
3657
- registerDelegateRuleTool(host, config);
3658
- registerSessionTool(host, config);
4270
+ registerReviewTool(host, config, probe, versionSink);
4271
+ registerScanTool(host, config, probe, versionSink);
4272
+ registerDelegatePreviewTool(host, config, probe);
4273
+ registerDelegateRuleTool(host, config, probe);
4274
+ registerSessionTool(host, config, probe);
3659
4275
  host.systemPrompt.section({
3660
4276
  name: ROUTING_NAME,
3661
4277
  order: ROUTING_ORDER,
3662
4278
  text: localeMessages(host).routingText
3663
4279
  });
3664
- host.inject(["webServer"], (child) => {
3665
- registerConfigEndpoints(child, host, config, randomUUID());
4280
+ installOcrRemote(ctx, {
4281
+ host,
4282
+ config,
4283
+ gateway: createGateway(host),
4284
+ messages: () => localeMessages(host),
4285
+ extras: {
4286
+ verifyOf: () => endpointVerify.value,
4287
+ availability: probe,
4288
+ versionSink
4289
+ }
3666
4290
  });
3667
4291
  }
3668
4292
  var host_default = {
@@ -3673,7 +4297,9 @@ var host_default = {
3673
4297
  "systemPrompt"
3674
4298
  ],
3675
4299
  Config: configSchema,
3676
- apply
4300
+ apply: (ctx, config) => {
4301
+ apply(ctx, config, productionProbe);
4302
+ }
3677
4303
  };
3678
4304
  //#endregion
3679
- export { configSchema as Config, createGateway, host_default as default, isLoopbackBindAddress, ocrRepo, parseJsonBody, registerConfigEndpoints, requiredKeys };
4305
+ export { configSchema as Config, OcrReviewRemoteService, apply, assertOcrPresent, createGateway, host_default as default, endpointVerifyOf, installOcrRemote, joinedOutput, ocrEnv, ocrRepo, productionProbe, readLanguage, requiredKeys };