@jayyuen66/dsh-ocr-review 0.1.9 → 0.1.10

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/CHANGELOG.md CHANGED
@@ -4,10 +4,37 @@
4
4
  [语义化版本](https://semver.org/lang/zh-CN/),最新版本在最上。条目自 0.1.0 起;更早的
5
5
  1.0.0 / 0.6.x 是作用域迁移前的历史编号,npm 已永久记录,不在本文件重建。
6
6
 
7
+ ## 0.1.10
8
+
9
+ 一轮跨面审查修复:路径闸、MCP 配置写面与并发覆盖、provider 面与解析判据收口;设置卡与工具出口同步加固。对外工具与 Remote 方法名单不变。
10
+
11
+ ### Changed
12
+
13
+ - **MCP 写面加 revision 基线**:`mcp()` 回执新增 `revision`,`saveMcp` 入参加可选 `revision`——带基线时锁内比对,过期整笔拒绝回 `mcpSaveConflict`;不带基线沿用旧的后写覆盖语义(旧客户端兼容)。双开卡片或手改 config.json 时后保存者不再静默抹掉他人更新。
14
+ - **发布链加固**(`.github/workflows/publish.yml`):发布步 `pnpm install --ignore-scripts`、构建移至无凭据步、`pnpm publish --ignore-scripts`,生命周期脚本不再能接触发布凭据;`prepack` 删除改 `prepare`(同为双构建)。
15
+ - **依赖与元数据**:js-yaml 改精确钉 `5.4.3`;`@deepseek-ai/schemastery` peer 放宽为 `^3.18.6-alpha.1`;catalog 的 shared 抬到 0.2.2;author 改对象形(name+url)。
16
+
17
+ ### Fixed
18
+
19
+ - **路径闸**(`lib/argv-guard.ts`):归一后落在文件系统根(`/`、盘符根、UNC 根)一律拒绝,堵 `--rule /etc/passwd` 被当仓内文件读进 OCR;`statSync` 改 `throwIfNoEntry: false`;会话根判定改 `path.isAbsolute`(Windows `C:/…` 不再落相对分支)。
20
+ - **Remote 拒绝判据**(`lib/remote-shape.ts`):`isRemoteRefused` 收 `unknown`,须同时满足 `ok===false`、非空 `error`、整数 `status`,null/NaN/原始值不再误判;状态码集中为导出常量 `OCR_REFUSE_STATUS`。
21
+ - **MCP 配置**(`lib/mcp-config.ts`):写失败独立文案;输出容器改 null 原型(`__proto__` 键不再丢失/污染);写侧把盘上同条目的未知字段接回;providers.chatgpt 非法改显式报错;读侧加 64 KiB 上限;URL 判据拒空主机。
22
+ - **OCR config 迁移**(`lib/ocr-config.ts`):不覆写已有非空 `api_key_cmd`;provider/model 入口一次 trim;空白 `language` 不落键;迁移写回统一走 `renderConfigDoc`。
23
+ - **provider 面**(`lib/provider-projection.ts`、`lib/provider-source.ts`):baseURL 禁内嵌 `user:pass@`、空白/控制字符与无主机;`apiKeyEnv` 按导出白名单收口,非法 ref 单独暴露;health 与探测异常收口,失败出口统一 `settingsFailed()`。
24
+ - **解析与聚合**(`lib/parse.ts`、`lib/severity.ts`):`isCount` 限安全整数域、`toolCallsOf` 总数口径、warnings 限流;`hitsContract` 只认 reviewable 文件/组,`{"error":…}` 载荷不再假报 0 文件;`byCategory` 落 null 原型。
25
+ - **工具出口守卫**(`lib/output.ts`):新增容器形状闸;违规收集改 `ViolationSink`,收集期截断 20 条/2000 字,不再先攒几十万条。
26
+ - **host**(`host.ts`):后台作业剪枝与取文案包 try 并先 killProc(不留无主 OCR 进程);saveMcp/chatGpt 加外层 try 回可读回执;装载期 fire-and-forget 自检包 try(防未处理 rejection 终止宿主);effort 未知值回落设置卡值。
27
+ - **设置卡**(`src/client-entry.ts`):channel 改逐值比对;MCP 未读成功时禁编辑/保存;llmTest 失败按失败着色;refresh 并发只认最后一次。
28
+ - **CLI 与凭据脚本**(`lib/cli.ts`、`scripts/get-cred.mjs`):pgrep 探针退出码 1(无匹配)不再算被拒;preview 也转发 `--max-tokens`;YAML 报错不再读 message/toString,防明文 snippet 外泄。
29
+
30
+ ### Added
31
+
32
+ - **测试三层**:`test/e2e/stub-ocr.e2e.test.ts`(官方执行器真 spawn stub 全链路)、`test/e2e/real-cli.e2e.test.ts`(真 ocr 二进制对照,缺席显式跳过)、`test/platform/guards.platform.test.ts`、`test/property/parse-chain.property.test.ts`(fast-check)、`test/ref-syntax-parity.test.ts`、`test/write-surface.test.ts`;devDependencies 新增 fast-check 4.10.2 与 `@deepseek-ai/dsh-bash-local` / `@deepseek-ai/dsh-subprocess-local`。
33
+
7
34
  ## 0.1.9
8
35
 
9
36
  这一版把后台评审的作业归属钉到发起会话,并把设置卡与读面校验统一到 shared 组件和 zod;
10
- 插件页展示面进包,宿主开发线抬到 0.2.2-alpha.2。对外工具与 Remote 方法面不变。
37
+ 插件页展示面进包,宿主开发线抬到 0.2.2-alpha.2。对外工具与 Remote 方法名单不变,后台作业的可见性按发起会话收紧。
11
38
 
12
39
  ### Changed
13
40
 
@@ -23,7 +50,7 @@
23
50
  - **插件页展示面进包**:新增 `locale/en.json`、`locale/zh.json` 与 `docs/DETAIL.md`、
24
51
  `docs/DETAIL.zh.md`;`exports` 增加 `./locale/*.json`,`files` 收进 locale 与 docs。
25
52
  - **宿主开发线抬到 `0.2.2-alpha.2`**:devDependencies 与 catalog/overrides 同值;`engines.dsh`
26
- 与 peer 仍写支持窗口 `^0.2.1-rc.3`,README 补滚动窗口说明。
53
+ 与 peer 写支持窗口 `^0.2.1-rc.3`,README 补滚动窗口说明。
27
54
  - **正则常量提升**:argv-guard / config-store / mcp-config / output / parse / provider-projection
28
55
  的内联正则提取为命名常量,行为不变,满足 lint 的重复字面量门禁。
29
56
 
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 JayYuen666
3
+ Copyright (c) 2026 JayYuen (JayYuen666)
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -53,7 +53,7 @@
53
53
  - **One settings card also carries the admin surfaces**: a validated MCP servers JSON editor (saving touches only `mcp_servers`) and a ChatGPT section that reports `ocr auth` status and switches OCR's provider/model. Both are human-managed; neither is a model tool (a stdio MCP server runs commands, and OAuth sign-in needs a browser plus a local callback).
54
54
  - **Wraps the external `ocr` (open-code-review) CLI into seven model tools.** The host validates tool arguments not at all, so every shape, type and enum gate lives in this package: user-controlled strings are single-quote escaped, enums are whitelist-clamped, and wrong-typed values error instead of silently narrowing or widening a review.
55
55
  - **One settings card** (the web client half) picks provider/model from the host's own `llm-pi-ai` settings entry and key status from the host credentials service. When either service is absent the card shows the degradation reason; tools and settings keep working.
56
- - **Applying a selection writes the CLI's own `~/.opencodereview/config.json`** — as an `api_key_cmd` command that reads the host credential ref at OCR runtime. A plaintext key is never written anywhere.
56
+ - **Applying a selection writes the CLI's own `~/.opencodereview/config.json`** — as an `api_key_cmd` command that reads the host credential ref at OCR runtime. The plugin never writes a plaintext key into `config.json`; the `.bak` it copies before the first rewrite keeps whatever the file held before, which may include a pre-migration plaintext key (mode 0600) — delete that backup after migrating.
57
57
  - **Results are a structured summary with two distinct notions**: what OCR found (`status`, `aggregation`, `topComments`, `warnings`) and whether the run actually covered the selected files (`coverage`, `budgetExceeded`, `toolCalls`). The CLI is invoked with `--audience agent`, which suppresses its progress output — the second notion is only reachable through those fields.
58
58
  - **CLI availability is one judgement** (`resolveOcrAvailability` in `lib/cli.ts`): pure filesystem, never a spawn, re-read per call. The card status row, the detail-page badge and the activation guidance all render that one answer, so "which copy is in use" cannot be told three ways.
59
59
  - **Background runs (`wait: false`) are official host jobs**: `job_list` / `job_output` / `job_kill` can observe and stop a review that outlives the tool call.
@@ -67,10 +67,10 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
67
67
  Remove with `dsh plugin --profile web remove @jayyuen66/dsh-ocr-review`. The package is on the public npm registry, so installation needs no credentials.
68
68
 
69
69
  - **Upgrading an installed copy is remove-then-add.** On a profile running HMR (the shipped ones do), `add` on a package the profile already depends on refuses before pnpm runs (`already-installed`) — run `remove` first, then `add` the target version; the new one starts without a restart. Without HMR the same `add` runs pnpm and reports `restart-required`.
70
- - **Requires Node.js `>= 22.12` and host `dsh` on the `0.2.1-rc.3` line.** The host reads `engines.dsh` (`^0.2.1-rc.3`) and decides on it alone; prereleases participate in the comparison. An incompatible host needs the explicit risk grant `dsh plugin --profile <p> allow-version @jayyuen66/dsh-ocr-review@<version> --dsh-version <exact> --accept-risk`. The support window rolls: this package is verified against the three most recently released host versions at publish time and re-verified once the window moves, so the version number in this bullet is not a standing promise.
70
+ - **Requires Node.js `>= 22.12` and host `dsh` on the `0.2.1-rc.3` / `0.2.2-alpha.2` line.** The host reads `engines.dsh` (`^0.2.1-rc.3`) and decides on it alone; prereleases participate in the comparison. An incompatible host needs the explicit risk grant `dsh plugin --profile <p> allow-version @jayyuen66/dsh-ocr-review@<version> --dsh-version <exact> --accept-risk`. The support window rolls: this package is verified against the three most recently released host versions at publish time and re-verified once the window moves, so the version number in this bullet is not a standing promise.
71
71
  - **No `@deepseek-ai/*` under `dependencies`, by contract** (`test/publish-manifest.test.ts` enforces it). Consumer profiles install with `nodeLinker: hoisted` and `autoInstallPeers: false`, so a host package there is materialized as a real directory next to the copy the running host already loaded. `@deepseek-ai/dsh-tools` keys its scheduler with a plain `Symbol` (not `Symbol.for`), so symbol identity is per instance and **every native tool call in that profile throws** — while this package's build, typecheck and coverage gates stay green right up to that moment.
72
72
  - **The five host packages `host.js` imports as values are required peers**: `@deepseek-ai/schemastery` (the host fork — only it resolves `.volatile()` fields), `@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths`, `@deepseek-ai/dsh-atomic-write` and `@deepseek-ai/dsh-typert-protocol` (the Remote service base). Each is repeated in `devDependencies` at the same pin so this repository's own typecheck resolves them; the build externalizes all manifest tables, keeping bare specifiers in the artifact.
73
- - **What ships**: `host.js`, `client.js`, `cordis.patch.yml`, `scripts/`, `icon.svg`, `CHANGELOG.md` and `THIRD_PARTY_NOTICES.md`. `prepack` rebuilds the bundles, so nothing stale is published.
73
+ - **What ships** (see `files` in `package.json`): `host.js`, `client.js`, `cordis.patch.yml`, `scripts/`, `locale/*.json`, `docs/`, `icon.svg`, `CHANGELOG.md` and `THIRD_PARTY_NOTICES.md`. `prepare` rebuilds the bundles, so nothing stale is published.
74
74
 
75
75
  ### <img src="https://api.iconify.design/lucide/arrow-down-to-line.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> Shared version floor
76
76
 
@@ -105,7 +105,7 @@ npm i -g @alibaba-group/open-code-review@1.13.3 # pinned to the version thi
105
105
  - **It does not install or upgrade the CLI.** The brew/npm commands are shown, never run; there is no update action and no clipboard API (a non-secure context would fail silently, looking like a dead button).
106
106
  - **No HTTP route of its own.** The data plane is one official Typert Remote namespace (`ocrReview`) through the host `/api` gateway; authentication, Host / DNS-rebinding / Origin checks and body limits are the gateway's. This package holds no CSRF token, no body cap and no route disposer.
107
107
  - **No CLI subcommand.** `dsh ocr …` does not exist.
108
- - **It reads no file outside** the CLI's config path, the temp files it creates itself, and the host settings / credentials services it is handed.
108
+ - **The plugin itself reads no file outside** the CLI's config path, the host credential document and `$DSH_HOME/.env` (through `scripts/get-cred.mjs`), the PATH directories it probes for the `ocr` binary, the OCR session store it lists, the temp files it creates, and the host settings / credentials services it is handed. Repository contents are read by the `ocr` child process as the reviewed input.
109
109
  - What it *does* consume: the host `settings` and `credentials` services (provider list, key status), `shell` (every `ocr` subprocess), `jobs` (background runs) and `systemPrompt` (one routing section). A missing settings or credentials service degrades the card only — the tools keep working.
110
110
 
111
111
  ## <img src="https://api.iconify.design/lucide/plug.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> Enabling it in dsh
@@ -128,7 +128,7 @@ npm i -g @alibaba-group/open-code-review@1.13.3 # pinned to the version thi
128
128
  | `ocr_llm` | action providers/models: read-only LLM catalogue queries; no review and no connectivity test |
129
129
  | List caps | exclude and path take up to 2000 entries each; exceeding the cap rejects the call. Entries are never silently dropped — "reviewed fewer files than the list" must not be reportable as success |
130
130
 
131
- `wait: false` starts a background run with no host deadline (poll `ocr_session`; the subprocess is also an official job, see [Background runs as host jobs](#background-runs-as-host-jobs)), and the reaper guard takes the OCR process tree down when dsh exits in any way. Cancelling the tool call stops the corresponding background process.
131
+ `wait: false` starts a background run with no host deadline (poll `ocr_session`; the subprocess is also an official job, see [Background runs as host jobs](#background-runs-as-host-jobs)), and on POSIX the reaper guard takes the OCR process tree down when dsh exits in any way (Windows has no reaper, see [Platforms](#platforms)). Cancelling the tool call stops the corresponding background process.
132
132
 
133
133
  ## <img src="https://api.iconify.design/lucide/sliders-horizontal.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> Settings
134
134
 
@@ -165,8 +165,8 @@ One official Typert Remote namespace, `ocrReview` (`OCR_REMOTE_NAMESPACE` in `li
165
165
  | `migrate()` | — | `{ok, migrated, skippedUnknown}` |
166
166
  | `test()` | — | `{ok, output}` — `ok` is the **test verdict**, not whether the call ran; output is redacted first, then cut to 8000 characters |
167
167
  | `probe()` | — | `{ok, version, expectedVersion, consistent, output, reason, changeText}` — one `ocr version` under a 15 s wall clock, with `OCR_NO_UPDATE=1`. `consistent` is three-state (`null` = nothing was measured, where claiming a mismatch would be claiming without evidence); `reason` names the failure arm; `changeText` carries the install/upgrade sentence only when there is a command to give |
168
- | `mcp()` | — | `{ok, json}` — only the `mcp_servers` object as pretty JSON, never providers or keys |
169
- | `saveMcp(request)` | `{json}` — raw JSON text, at most 64 KiB; stdio/remote entries are validated field by field | `{ok, count}` — writes only `mcp_servers`; other config is preserved |
168
+ | `mcp()` | — | `{ok, json, revision}` — only the `mcp_servers` object as pretty JSON, never providers or keys; `revision` is that branch's digest |
169
+ | `saveMcp(request)` | `{json, revision?}` — raw JSON text, at most 64 KiB; stdio/remote entries are validated field by field; `revision` is the digest from `mcp()` | `{ok, count, revision}` — writes only `mcp_servers`; other config is preserved. A stale `revision` refuses the whole write (nothing is written) and returns a readable "changed after it was read" error; omitting it keeps the old last-writer-wins behavior |
170
170
  | `auth()` | — | `{ok, output, loginCommand}` — read-only `ocr auth status --provider chatgpt`; sign-in itself stays a terminal action because it needs a browser and a local callback |
171
171
  | `chatGpt(request)` | `{model}` | `{ok, model}` — sets `provider: "chatgpt"` and `providers.chatgpt.model`, writes no key |
172
172
 
@@ -201,7 +201,7 @@ Refusals answer `{ok: false, error, status}` where `status` is a **retained sema
201
201
  | --- | --- |
202
202
  | Process boundary | The reviewed repository's paths and diff content go into the `ocr` child process, and the CLI sends them to the LLM endpoint configured in its own config.json — that egress is the review feature itself and does not pass through the dsh host. Plaintext keys never appear in tool results or Remote payloads; a value resolved by the host is used only to confirm "it resolves right now" and is discarded on the spot |
203
203
  | Redaction | All raw CLI stdout/stderr leaving this package (failure details, llm test, parse-failure echoes, session passthrough) goes through `redactKeyMaterial()`: bearer and assignment forms strictly, the standalone-key form only with random-string features, so file paths handed to the model are not erased with it |
204
- | On disk | Two places only: the CLI's `config.json` (plus its temp sibling, the `.bak` backup and the short-lived lock) and `ocr-review-*` / `ocr-scan-*` temp files, deleted right after reading. Pre-migration plaintext may persist in that `.bak` (mode 0600) — delete the backup yourself to be rid of it. Outside these two, the only writer is the CLI's own npm wrapper unless `ocrNoAutoUpdate` is on |
204
+ | On disk | The CLI's `config.json` (plus its temp sibling, the `.bak` backup and the short-lived lock), the `ocr-review-*` / `ocr-scan-*` temp files (deleted right after reading), and `ocr-session-export-*` directories, which hold the HTML returned by `ocr_session` `export` and are left in place for the caller. Pre-migration plaintext may persist in that `.bak` (mode 0600) — delete the backup yourself to be rid of it. Outside these, the only writer is the CLI's own npm wrapper unless `ocrNoAutoUpdate` is on |
205
205
 
206
206
  ## <img src="https://api.iconify.design/lucide/triangle-alert.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> Known boundaries
207
207
 
@@ -219,7 +219,7 @@ Refusals answer `{ok: false, error, status}` where `status` is a **retained sema
219
219
  | Exit 0 was taken as a successful review | Exit 0 is not success: any `status` outside success / complete / partial / completed_with_warnings / completed_with_errors / skipped reports as failure. `partial` means some files failed while coverage and the comments stand. A parse failure echoes the first 500 redacted UTF-16 code units of the raw output |
220
220
  | Did the summary cover everything? | Two notions, neither replaces the other. `droppedCount` / `invalidCommentCount` say whether **this summary** listed every comment (raise `maxComments` or set 0 and re-check). Whether **the run** covered the selected files is `coverage` (`selected` is the denominator, `failedItems` names the files with their reasons) plus `budgetExceeded` and `toolCalls` |
221
221
  | `add` was refused with `already-installed` | HMR profiles refuse `add` on an already-installed package before pnpm runs — `remove` first, then `add` the target version (see Install) |
222
- | What happens to background processes when dsh exits? | The reaper guard takes the review/scan process tree down with TERM→1s→KILL on any host exit (including kill -9 followed by adoption); cancelling the tool call stops the corresponding background process |
222
+ | What happens to background processes when dsh exits? | On POSIX the reaper guard takes the review/scan process tree down with TERM→1s→KILL on any host exit (including kill -9 followed by adoption); Windows has no reaper, so a hard host exit can leave an orphaned process. Cancelling the tool call stops the corresponding background process |
223
223
  | Where might a plaintext key still sit? | Nowhere this package writes. A pre-migration config's plaintext may survive in the `.bak` produced by the first write after you migrate (mode 0600); delete that backup to be rid of it |
224
224
 
225
225
  ## <img src="https://api.iconify.design/lucide/wrench.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> Development
@@ -231,7 +231,7 @@ pnpm run check # typecheck (both sides) → lint → client build → host bui
231
231
 
232
232
  - Coverage thresholds are **100%** across four metrics over the surface `vitest.config.ts` opts into (`coverageInclude`: `host.ts` + `lib/**`); two areas are deliberately outside it: the client half (`src/**`) is exercised through the bundle and the settings-card tests, and `scripts/get-cred.mjs` is driven by process-level tests spawning a child (v8 coverage cannot see a child process) — its behavioral gate lives in `test/get-cred.test.ts`.
233
233
  - Adding this package's **local directory** to a profile (`pnpm add <path>`) needs pnpm ≥ 12.3.0: earlier pnpm 12 releases resolve an unaliased local path as a registry package name (upstream #14437, fixed in 12.3.0). Installing from the registry is unaffected.
234
- - Bundle freshness is pinned by `test/build-host.test.ts`: a stale `host.js` / `client.js` fails the suite. Never hand-edit the bundles; `prepack` rebuilds them.
234
+ - Bundle freshness is pinned by `test/build-host.test.ts`: a stale `host.js` / `client.js` fails the suite. Never hand-edit the bundles; `prepare` rebuilds them.
235
235
  - The plugin source is TypeScript loaded directly by the host's cordis loader (Node ≥ 22.18 type stripping); the bundles exist for the published npm surface.
236
236
 
237
237
  ## <img src="https://api.iconify.design/lucide/scale.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> License
package/README.zh-CN.md CHANGED
@@ -53,7 +53,7 @@
53
53
  - **设置卡还承载两块管理面**:可校验的 MCP servers JSON 编辑器(保存只改 `mcp_servers`),以及报告 `ocr auth` 登录态并切换 OCR provider/model 的 ChatGPT 区。两块都由人操作,不是模型工具(stdio MCP server 会执行命令,OAuth 登录需要浏览器与本地回调)。
54
54
  - **把外部 `ocr`(open-code-review)CLI 封装成七个模型工具。** 宿主对工具入参零校验,形状、类型与枚举闸全在本包内:用户可控字符串一律单引号转义,枚举一律白名单钳制,错类型直接报错——绝不静默收窄或放宽审查范围。
55
55
  - **一张设置卡片**(client 半)从宿主自己的 `llm-pi-ai` 设置条目取 provider/model 清单、从宿主凭据服务取 key 状态。两个服务缺席时卡片显示降级原因,工具与设置照常可用。
56
- - **应用一次选择写的是 CLI 自己的 `~/.opencodereview/config.json`**——写进去的是 `api_key_cmd` 命令,OCR 运行期经它读宿主凭据 ref;任何地方都不落明文 key。
56
+ - **应用一次选择写的是 CLI 自己的 `~/.opencodereview/config.json`**——写进去的是 `api_key_cmd` 命令,OCR 运行期经它读宿主凭据 ref;本包不把明文 key 写进 `config.json`,但首次改写前复制的 `.bak` 保留改写前的整份内容,其中可能有迁移前的明文 key(权限 0600),迁移后自行删除该备份。
57
57
  - **评审结果是一份结构化摘要,字段分两套口径**:OCR 审出了什么(`status`/`aggregation`/`topComments`/`warnings`),以及这一场有没有真把选中的文件审完(`coverage`/`budgetExceeded`/`toolCalls`)。CLI 以 `--audience agent` 被调用,进度输出被它压掉——后一套口径只能从这些字段里读。
58
58
  - **CLI 可得性只有一份判据**(`lib/cli.ts` 的 `resolveOcrAvailability`):纯读文件系统、绝不起进程、每次调用现读。设置卡状态行、插件详情页徽标与启用指引渲染的都是这同一份答案,「用的是哪一份」不许有三套说法。
59
59
  - **后台运行(`wait: false`)是官方宿主作业**:`job_list` / `job_output` / `job_kill` 看得见、读得到、也停得掉一场超出工具调用生命周期的评审。
@@ -67,10 +67,10 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
67
67
  卸载用 `dsh plugin --profile web remove @jayyuen66/dsh-ocr-review`。包在公共 npm 上,安装不需要凭据。
68
68
 
69
69
  - **升级已装的那一份要「先 remove 再 add」。** profile 跑着 HMR(出厂那几个都跑)时,`add` 一个 profile 已经依赖着的包会在 pnpm 动手之前就被拒(`already-installed`)——先 `remove` 再 `add` 目标版本,新版不必重启就起跑。没跑 HMR 时同一个 `add` 照样走 pnpm,报 `restart-required`。
70
- - **要求 Node.js `>= 22.12`,宿主 dsh 在 `0.2.1-rc.3` 线上。** 宿主只读 `engines.dsh`(`^0.2.1-rc.3`)并独立裁决,预发布号参与比较。不合的宿主要显式登记风险豁免:`dsh plugin --profile <p> allow-version @jayyuen66/dsh-ocr-review@<版本> --dsh-version <确切运行版本> --accept-risk`。支持窗口是滚动的:本包在发布时最近的三个已发布宿主版本上逐版取证,窗口滚动后重新取证,不把这一句里的版本号当长期承诺。
70
+ - **要求 Node.js `>= 22.12`,宿主 dsh 在 `0.2.1-rc.3` / `0.2.2-alpha.2` 线上。** 宿主只读 `engines.dsh`(`^0.2.1-rc.3`)并独立裁决,预发布号参与比较。不合的宿主要显式登记风险豁免:`dsh plugin --profile <p> allow-version @jayyuen66/dsh-ocr-review@<版本> --dsh-version <确切运行版本> --accept-risk`。支持窗口是滚动的:本包在发布时最近的三个已发布宿主版本上逐版取证,窗口滚动后重新取证,不把这一句里的版本号当长期承诺。
71
71
  - **`dependencies` 里不放任何 `@deepseek-ai/*`,这是契约**(`test/publish-manifest.test.ts` 钉着)。消费方 profile 用 `nodeLinker: hoisted` 与 `autoInstallPeers: false` 安装,宿主包进了那张表就会落成真实目录,与宿主进程已加载的那份并存。`@deepseek-ai/dsh-tools` 的调度器键是普通 `Symbol`(不是 `Symbol.for`),符号身份按实例计算——那个 profile 里**每一次原生工具调用都抛**,而本包的构建、类型检查与覆盖率门在此前一路绿。
72
72
  - **`host.js` 以值导入的五枚宿主包是非 optional 的 peer**:`@deepseek-ai/schemastery`(宿主 fork——`.volatile()` 的解析只在它有实现)、`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-atomic-write` 与 `@deepseek-ai/dsh-typert-protocol`(Remote 的 Service 基类)。五枚同时以同一钉版另列进 `devDependencies`,本仓自己的类型检查才解析得到;构建对 manifest 三张表一律外部化,产物里保持裸说明符。
73
- - **发布面**:`host.js`、`client.js`、`cordis.patch.yml`、`scripts/`、`icon.svg`、`CHANGELOG.md` 与 `THIRD_PARTY_NOTICES.md`。`prepack` 重建两个 bundle,发出去的永远是当前源码的产物。
73
+ - **发布面**(以 `package.json` 的 `files` 为准):`host.js`、`client.js`、`cordis.patch.yml`、`scripts/`、`locale/*.json`、`docs/`、`icon.svg`、`CHANGELOG.md` 与 `THIRD_PARTY_NOTICES.md`。`prepare` 重建两个 bundle,发出去的永远是当前源码的产物。
74
74
 
75
75
  ### <img src="https://api.iconify.design/lucide/arrow-down-to-line.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> shared 的版本下限
76
76
 
@@ -105,7 +105,7 @@ npm i -g @alibaba-group/open-code-review@1.13.3 # 钉在本包核对的那
105
105
  - **它不安装、不升级 CLI。** brew/npm 命令只展示、不执行;没有更新动作,也不接剪贴板 API(非安全上下文会静默失败,看上去像按钮没反应)。
106
106
  - **没有自己的 HTTP 路由。** 数据面是一枚官方 Typert Remote 命名空间(`ocrReview`),经宿主 `/api` 网关到达;认证、Host / DNS 重绑定 / Origin 判据与请求体上限都是网关的。本包没有 CSRF token、没有 body 上限,也没有路由释放器。
107
107
  - **没有 CLI 子命令。** 不存在 `dsh ocr …`。
108
- - **不读以下范围之外的文件**:CLI 配置路径、自己创建的临时文件、以及宿主交给它的设置 / 凭据服务。
108
+ - **本插件自己不读以下范围之外的文件**:CLI 配置路径、宿主凭据文档与 `$DSH_HOME/.env`(经 `scripts/get-cred.mjs`)、为找 `ocr` 二进制而探测的 PATH 目录、`ocr_session` 列出的会话存储、自己创建的临时文件,以及宿主交给它的设置 / 凭据服务。被审查仓库的内容由 `ocr` 子进程作为评审输入读取。
109
109
  - 它*确实*消费的:宿主 `settings` 与 `credentials` 服务(provider 清单、key 状态)、`shell`(每一条 `ocr` 子进程)、`jobs`(后台运行)与 `systemPrompt`(一段路由)。settings 或 credentials 缺席只降级卡片——工具照常可用。
110
110
 
111
111
  ## <img src="https://api.iconify.design/lucide/plug.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 在 dsh 里启用
@@ -128,7 +128,7 @@ npm i -g @alibaba-group/open-code-review@1.13.3 # 钉在本包核对的那
128
128
  | `ocr_llm` | action providers/models:只读查询 LLM 清单;不评审、不测连通性 |
129
129
  | 清单上限 | exclude 与 path 各收 2000 条;超限直接拒绝整次调用。条目绝不静默丢弃——「比清单少审了几份」不许以成功的口径上报 |
130
130
 
131
- `wait: false` 起后台运行、不挂宿主 deadline(用 `ocr_session` 轮询;该子进程同时是一枚官方作业,见[后台运行与官方作业](#后台运行与官方作业));dsh 以任何方式退出时,收割守护把 OCR 进程树收走。取消工具调用即终止对应的后台进程。
131
+ `wait: false` 起后台运行、不挂宿主 deadline(用 `ocr_session` 轮询;该子进程同时是一枚官方作业,见[后台运行与官方作业](#后台运行与官方作业));POSIX 上 dsh 以任何方式退出时,收割守护把 OCR 进程树收走(Windows 没有收割,见「已知边界」)。取消工具调用即终止对应的后台进程。
132
132
 
133
133
  ## <img src="https://api.iconify.design/lucide/sliders-horizontal.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 设置项
134
134
 
@@ -165,8 +165,8 @@ npm i -g @alibaba-group/open-code-review@1.13.3 # 钉在本包核对的那
165
165
  | `migrate()` | — | `{ok, migrated, skippedUnknown}` |
166
166
  | `test()` | — | `{ok, output}`——`ok` 是**测试结论**,不是这次调用成没成;output 先脱敏再截 8000 字符 |
167
167
  | `probe()` | — | `{ok, version, expectedVersion, consistent, output, reason, changeText}`——15 秒墙钟内跑一次 `ocr version`,恒定带 `OCR_NO_UPDATE=1`。`consistent` 是三态(`null` = 没量到,此时宣称「不一致」就是把没证据的事说成结论);`reason` 点名失败那一档;`changeText` 只在真有一条该给的命令时非空 |
168
- | `mcp()` | — | `{ok, json}`——只回 `mcp_servers` 对象(pretty JSON),不含 providers/keys |
169
- | `saveMcp(request)` | `{json}`——原始 JSON 文本,上限 64 KiB;stdio/remote 条目逐字段校验 | `{ok, count}`——只写 `mcp_servers`,其他配置原样保留 |
168
+ | `mcp()` | — | `{ok, json, revision}`——只回 `mcp_servers` 对象(pretty JSON),不含 providers/keys;`revision` 是这一支的摘要 |
169
+ | `saveMcp(request)` | `{json, revision?}`——原始 JSON 文本,上限 64 KiB;stdio/remote 条目逐字段校验;`revision` 取 `mcp()` 交回的那一枚 | `{ok, count, revision}`——只写 `mcp_servers`,其他配置原样保留。基线过期时整笔拒绝(盘上不动)并回可读的「读取之后已被改动」;不带基线则沿用原来的后写覆盖 |
170
170
  | `auth()` | — | `{ok, output, loginCommand}`——只读 `ocr auth status --provider chatgpt`;登录本身要浏览器与本地回调,仍留在终端执行 |
171
171
  | `chatGpt(request)` | `{model}` | `{ok, model}`——写 `provider: "chatgpt"` 与 `providers.chatgpt.model`,不写 key |
172
172
 
@@ -201,7 +201,7 @@ npm i -g @alibaba-group/open-code-review@1.13.3 # 钉在本包核对的那
201
201
  | --- | --- |
202
202
  | 进程边界 | 被审查仓库的路径与 diff 内容进 `ocr` 子进程,CLI 再把它们发给自己在 config.json 里配置的 LLM 端点——这条外发是审查功能本身,不经 dsh 宿主。明文 key 不进工具返回、也不进 Remote 应答;宿主侧解析到的值只用于确认「现在解析得到」,当场丢弃 |
203
203
  | 原始输出脱敏 | 本包出去的 CLI 原始 stdout/stderr(失败详情、llm test、解析失败回显、session 透传)统一过 `redactKeyMaterial()`:Bearer 与赋值形态从严抹除,独立 key 形态额外要求随机串特征,免得把要交给模型的文件路径一起抹掉 |
204
- | 写盘范围 | 只有两处:CLI 的 `config.json`(含临时文件、`.bak` 备份与短暂的锁)与系统临时目录下的 `ocr-review-*` / `ocr-scan-*`(读完即删)。迁移前的明文可能留在那次写入产生的 `.bak` 里(权限 0600)——要彻底清掉需自行删除该备份。这两处之外,`ocrNoAutoUpdate` 没置真时唯一的写者就是 CLI 自己那层 npm 包装 |
204
+ | 写盘范围 | CLI 的 `config.json`(含临时文件、`.bak` 备份与短暂的锁)、系统临时目录下的 `ocr-review-*` / `ocr-scan-*`(读完即删),以及 `ocr-session-export-*` 目录——那是 `ocr_session` 的 `export` 返回的 HTML,留给调用方,不自动删除。迁移前的明文可能留在那次写入产生的 `.bak` 里(权限 0600)——要彻底清掉需自行删除该备份。这些之外,`ocrNoAutoUpdate` 没置真时唯一的写者就是 CLI 自己那层 npm 包装 |
205
205
 
206
206
  ## <img src="https://api.iconify.design/lucide/triangle-alert.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 已知边界
207
207
 
@@ -231,7 +231,7 @@ pnpm run check # typecheck(两侧)→ lint → client 构建 → host 构
231
231
 
232
232
  - 覆盖率阈值**四项 100%**,分母是 `vitest.config.ts` 里 `coverageInclude` 选定的那一面(`host.ts` + `lib/**`);两块刻意在分母外:客户端半(`src/**`)经 bundle 与设置卡用例覆盖,`scripts/get-cred.mjs` 由进程级用例另起进程驱动(v8 覆盖率看不到子进程),它的行为门禁在 `test/get-cred.test.ts`。
233
233
  - 往 profile 加本包的**本地目录**(`pnpm add <路径>`)需要 pnpm ≥ 12.3.0:更早的 pnpm 12 把无别名的本地路径当 registry 包名解析(上游 #14437,12.3.0 修复)。从 registry 装不受影响。
234
- - 产物新鲜度由 `test/build-host.test.ts` 钉着:`host.js` / `client.js` 落后于源码就红。发布物不手改;`prepack` 重建。
234
+ - 产物新鲜度由 `test/build-host.test.ts` 钉着:`host.js` / `client.js` 落后于源码就红。发布物不手改;`prepare` 重建。
235
235
  - 插件源码是 TypeScript,由宿主的 cordis Loader 直接加载(Node ≥ 22.18 类型剥离);bundle 只为 npm 发布面存在。
236
236
 
237
237
  ## <img src="https://api.iconify.design/lucide/scale.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 许可
@@ -25,6 +25,32 @@
25
25
  `currentColor` 换成本包插件图标配色(`#658EFF` 主色 + `#54ECE7` 底衬),并把外层 `<g>` 与
26
26
  画布尺寸对齐本族插件统一的 `36×36` / `viewBox 0 0 24 24` 规格。
27
27
 
28
+ `icon.svg` 随包分发,没有独立的 npm 包承载上游许可文本,因此在下文完整保留 MIT 正文:
29
+
30
+ ```text
31
+ MIT License
32
+
33
+ Copyright (c) Paweł Kuna
34
+
35
+ Permission is hereby granted, free of charge, to any person obtaining a copy
36
+ of this software and associated documentation files (the "Software"), to deal
37
+ in the Software without restriction, including without limitation the rights
38
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
39
+ copies of the Software, and to permit persons to whom the Software is
40
+ furnished to do so, subject to the following conditions:
41
+
42
+ The above copyright notice and this permission notice shall be included in all
43
+ copies or substantial portions of the Software.
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
47
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
48
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
49
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
50
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
51
+ SOFTWARE.
52
+ ```
53
+
28
54
  ## 机制与接口来源:open-code-review(ocr CLI)
29
55
 
30
56
  - 上游项目:`open-code-review`
@@ -56,7 +82,8 @@
56
82
  | 依赖 | 版本 | 许可 | 引入方 |
57
83
  | --- | --- | --- | --- |
58
84
  | `@alibaba-group/open-code-review` | 1.13.3(核对版本,非依赖) | Apache-2.0(Alibaba) | **不在依赖表里**:用户自选 `brew install open-code-review` 或 `npm i -g @alibaba-group/open-code-review@<版本>`,本包只 spawn 它 |
59
- | `js-yaml` | ^5.4.3 | MIT | `scripts/get-cred.mjs` 读 dsh 托管凭据文档 |
85
+ | `js-yaml` | 5.4.3(精确钉版) | MIT | `scripts/get-cred.mjs` 读 dsh 托管凭据文档 |
86
+ | `@jayyuen66/dsh-plugin-shared` | catalog(伞根 0.2.2) | MIT | `host.ts` / `lib/*` / `src/*` 的 job-outcome、locale、record、errors、card-* 等公共面(值导入,构建时外部化) |
60
87
  | `zod` | catalog(伞根 4.6.7) | MIT | 设置卡 `src/remote.ts` 的 strict codec 形状校验;client 产物由宿主 `PLATFORM_MODULES` 提供 |
61
88
 
62
89
  dsh 宿主侧的包分两档:产物里以**值**导入、且列在 `peerDependencies` 的五枚(`@deepseek-ai/schemastery` 宿主 fork、`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-atomic-write` 与 `@deepseek-ai/dsh-typert-protocol`)由宿主在场提供、不随本包分发;其余 `@deepseek-ai/dsh-*` 与 `@deepseek-ai/cordis` 只在 `devDependencies` 里,用途是把类型绑到官方声明上(type-only 绑定),产物里不留说明符。本包按 dsh `0.2.2-alpha.2` 逐条核对过(同版的 cordis `4.0.6-alpha.1` 与 schemastery `3.18.6-alpha.1` fork 一并核对)。
package/client.js CHANGED
@@ -21,7 +21,6 @@ const UI_MESSAGES = {
21
21
  save: "保存",
22
22
  saving: "保存中…",
23
23
  revert: "撤销",
24
- saveFailed: "保存失败:",
25
24
  saveRefused: "未写入(宿主拒收或跳过):{fields}",
26
25
  loadProvidersFailed: "加载 providers 失败:{error}(远程面未就绪?重启 dsh 后重试)",
27
26
  remoteUnavailable: "远程服务不可用:宿主没有挂上本插件的设置通道。",
@@ -74,6 +73,7 @@ const UI_MESSAGES = {
74
73
  mcpSaving: "保存中…",
75
74
  mcpSaved: "MCP servers 已保存",
76
75
  mcpLoadFailed: "读取 MCP 配置失败:",
76
+ mcpNotLoaded: "还没读到当前 MCP 配置,先等读取完成(失败时先修好读取)再保存",
77
77
  chatGptTitle: "ChatGPT(OCR auth)",
78
78
  chatGptHint: "登录态由 ocr auth 管理;本卡片只读查询状态,并在登录后把 provider 切到 chatgpt。",
79
79
  chatGptCheck: "检查登录态",
@@ -91,7 +91,7 @@ const UI_MESSAGES = {
91
91
  binaryUnknown: "状态未取到:刷新本页,或确认 dsh 的 web 服务已起来",
92
92
  binaryBundled: "随本插件分发的兜底入口已就位:{path}({channel})",
93
93
  binaryPath: "用 PATH 上的全局安装:{path}({channel})",
94
- binaryNone: "未找到 ocr:PATH 与随包兜底两条路都没有可执行候选。装法与版本看下一行(由插件按核对版本给出)",
94
+ binaryNone: "未找到 ocr:PATH 与随包兜底两条路都没有可执行候选",
95
95
  channelBrew: "brew formula",
96
96
  channelNpm: "npm 全局包",
97
97
  channelUnknown: "装法未识别",
@@ -122,7 +122,6 @@ const UI_MESSAGES = {
122
122
  save: "Save",
123
123
  saving: "Saving…",
124
124
  revert: "Revert",
125
- saveFailed: "save failed: ",
126
125
  saveRefused: "not written (host refused or skipped): {fields}",
127
126
  loadProvidersFailed: "failed to load providers: {error} (remote face not ready? restart dsh and retry)",
128
127
  remoteUnavailable: "remote service unavailable: the host did not mount this plugin's settings channel",
@@ -175,6 +174,7 @@ const UI_MESSAGES = {
175
174
  mcpSaving: "Saving…",
176
175
  mcpSaved: "MCP servers saved",
177
176
  mcpLoadFailed: "Failed to read the MCP config: ",
177
+ mcpNotLoaded: "The current MCP config has not loaded yet; wait for the read (or fix it) before saving",
178
178
  chatGptTitle: "ChatGPT (OCR auth)",
179
179
  chatGptHint: "Sign-in is managed by ocr auth; this card only reports the status and switches OCR's provider to chatgpt after you sign in.",
180
180
  chatGptCheck: "Check sign-in",
@@ -192,7 +192,7 @@ const UI_MESSAGES = {
192
192
  binaryUnknown: "status not reported: reload this page, or check that the dsh web server is up",
193
193
  binaryBundled: "bundled fallback entry in place: {path} ({channel})",
194
194
  binaryPath: "using the global install on PATH: {path} ({channel})",
195
- binaryNone: "ocr not found: neither PATH nor the bundled fallback has an executable candidate. The install command and version are on the next line (this plugin states the version its contract was verified against)",
195
+ binaryNone: "ocr not found: neither PATH nor the bundled fallback has an executable candidate",
196
196
  channelBrew: "brew formula",
197
197
  channelNpm: "npm global package",
198
198
  channelUnknown: "install method unrecognised",
@@ -201,7 +201,7 @@ const UI_MESSAGES = {
201
201
  versionDiverged: "this machine reports {actual}, which is not the verified {expected}: comment wording and severity may drift between versions",
202
202
  badgeDiverged: "ocr version differs",
203
203
  detectCli: "Detect version",
204
- detecting: "Detecting...",
204
+ detecting: "Detecting…",
205
205
  probeVersion: "ocr version: {version}",
206
206
  probeReason: "no version read: {reason}",
207
207
  badgeBundled: "ocr from bundled fallback",
@@ -827,7 +827,10 @@ const SELECT_SCHEMA = zod.z.object({
827
827
  model: zod.z.string({ error: SELECT_MESSAGE })
828
828
  }, { error: "select request must be an object" });
829
829
  const MCP_SAVE_MESSAGE = "mcp save request requires a string json";
830
- const MCP_SAVE_SCHEMA = zod.z.object({ json: zod.z.string({ error: MCP_SAVE_MESSAGE }) }, { error: MCP_SAVE_MESSAGE });
830
+ const MCP_SAVE_SCHEMA = zod.z.object({
831
+ json: zod.z.string({ error: MCP_SAVE_MESSAGE }),
832
+ revision: zod.z.string().optional()
833
+ }, { error: MCP_SAVE_MESSAGE });
831
834
  const CHAT_GPT_MESSAGE = "chatgpt request requires a string model";
832
835
  const CHAT_GPT_SCHEMA = zod.z.object({ model: zod.z.string({ error: CHAT_GPT_MESSAGE }) }, { error: CHAT_GPT_MESSAGE });
833
836
  /** 把宿主那九个方法挂到客户端远程面上的描述符(`id` 的形状照生成器:`<包>#<命名空间>/<方法>`)。
@@ -953,9 +956,10 @@ const OCR_DESCRIPTORS = [
953
956
  const isRemoteMounter = (value) => e$3(value) && typeof value["$mount"] === "function";
954
957
  /** 同上:命名空间服务(挂载之后才有,缺它说明网关没认这枚贡献)。 */
955
958
  const isOcrNamespace = (value) => e$3(value) && typeof value["providers"] === "function" && typeof value["select"] === "function" && typeof value["migrate"] === "function" && typeof value["test"] === "function" && typeof value["probe"] === "function" && typeof value["mcp"] === "function" && typeof value["saveMcp"] === "function" && typeof value["auth"] === "function" && typeof value["chatGpt"] === "function";
956
- /** 网关回执 → 值;载体/网关失败抛(卡片那层按「请求失败」显示)。 */
959
+ /** 网关回执 → 值;载体/网关失败抛(卡片那层按「请求失败」显示)。
960
+ * 机器码放 `cause`:卡片只展示 message,`REMOTE_...` 这类内部标识不是给用户看的。 */
957
961
  function unwrap(result) {
958
- if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
962
+ if (!result.ok) throw new Error(result.error.message, { cause: { code: result.error.code } });
959
963
  return result.value;
960
964
  }
961
965
  /**
@@ -971,7 +975,7 @@ function buildOcrReader(ctx) {
971
975
  let mounted;
972
976
  const remoteOf = () => {
973
977
  const remote = ctx.get?.("remote", false);
974
- if (!isRemoteMounter(remote)) throw new Error("remote 未提供:当前部署没有远程读面(每次读取都会重新解析)");
978
+ if (!isRemoteMounter(remote)) throw new Error("remote 未提供:当前部署没有远程读面");
975
979
  return remote;
976
980
  };
977
981
  const namespaceOf = async () => {
@@ -987,7 +991,10 @@ function buildOcrReader(ctx) {
987
991
  throw error;
988
992
  }
989
993
  const namespace = ctx.get?.(OCR_SERVICE_KEY, false);
990
- if (!isOcrNamespace(namespace)) throw new Error(`${OCR_SERVICE_KEY} 未提供:宿主网关没有挂上这枚命名空间`);
994
+ if (!isOcrNamespace(namespace)) {
995
+ mounted = void 0;
996
+ throw new Error(`${OCR_SERVICE_KEY} 未提供:宿主网关没有挂上这枚命名空间`);
997
+ }
991
998
  return namespace;
992
999
  };
993
1000
  return {
@@ -1012,8 +1019,11 @@ function buildOcrReader(ctx) {
1012
1019
  async mcp() {
1013
1020
  return unwrap(await (await namespaceOf()).mcp());
1014
1021
  },
1015
- async saveMcp(json) {
1016
- return unwrap(await (await namespaceOf()).saveMcp({ json }));
1022
+ async saveMcp(json, revision) {
1023
+ return unwrap(await (await namespaceOf()).saveMcp(revision === void 0 ? { json } : {
1024
+ json,
1025
+ revision
1026
+ }));
1017
1027
  },
1018
1028
  async auth() {
1019
1029
  return unwrap(await (await namespaceOf()).auth());
@@ -1253,14 +1263,13 @@ function currentConfigText(t, current) {
1253
1263
  keyPart: t(current.keyIsDynamic ? "dynamicKey" : "plainKeyWarning")
1254
1264
  });
1255
1265
  }
1256
- /** 装法枚举到文案键:查表而不是嵌套三元(本仓 lint 禁嵌套三元),查不到就是「未识别」那一档。 */
1257
- const CHANNEL_KEYS = {
1258
- brew: "channelBrew",
1259
- npm: "channelNpm"
1260
- };
1261
- /** 那份 CLI 是怎么装上的,翻成人话。判不出来源(含 host 没报这一档)不猜。 */
1266
+ /** 那份 CLI 是怎么装上的,翻成人话。判不出来源(含 host 没报这一档)不猜。
1267
+ * 逐个比对已知值而不是查一张普通对象表:`channel` 来自 wire,只校验了「是字符串」,
1268
+ * 用属性查表时 `"toString"`、`"constructor"` 一类会命中原型上的成员,把函数当文案键传下去。 */
1262
1269
  function channelText(t, channel) {
1263
- return t(CHANNEL_KEYS[channel ?? ""] ?? "channelUnknown");
1270
+ if (channel === "brew") return t("channelBrew");
1271
+ if (channel === "npm") return t("channelNpm");
1272
+ return t("channelUnknown");
1264
1273
  }
1265
1274
  /**
1266
1275
  * 外部 CLI 可得性那一行的文本。三档来源与「宿主没报」四种说法,判据全在 host 侧
@@ -1719,7 +1728,7 @@ function buildMcpAdmin(props) {
1719
1728
  const textarea = (0, react.createElement)("textarea", {
1720
1729
  "data-field": "mcpJson",
1721
1730
  value: props.mcpJson,
1722
- disabled: !props.writable || props.mcpBusy || !props.available,
1731
+ disabled: !props.writable || props.mcpBusy || !props.available || !props.mcpReady,
1723
1732
  "aria-label": t("mcpTitle"),
1724
1733
  className: "dsu-input dsu-textarea dsu-mono ocr-adminTextarea",
1725
1734
  onChange: (event) => {
@@ -1730,7 +1739,7 @@ function buildMcpAdmin(props) {
1730
1739
  type: "button",
1731
1740
  className: "dsu-btn",
1732
1741
  "data-field": "mcpSave",
1733
- disabled: !props.writable || props.mcpBusy || !props.available,
1742
+ disabled: !props.writable || props.mcpBusy || !props.available || !props.mcpReady,
1734
1743
  onClick: props.onMcpSave
1735
1744
  }, props.mcpBusy ? t("mcpSaving") : t("mcpSave"));
1736
1745
  const message = props.mcpMsg === null ? null : (0, react.createElement)("p", { className: "dsu-hint" }, props.mcpMsg);
@@ -1825,7 +1834,8 @@ function readerPost(reader, t) {
1825
1834
  }
1826
1835
  };
1827
1836
  }
1828
- /** POST select:把选中的 provider/model 写进 OCR 配置,回执里按 autoVerify 决定是否带 llm test。 */
1837
+ /** POST select:把选中的 provider/model 写进 OCR 配置,回执里按 autoVerify 决定是否带 llm test。
1838
+ * 导出与 `runProbe` 同因:卡片上的动作以纯函数形态可测(回执着色与文案都在这里定)。 */
1829
1839
  async function runApplySelection(deps) {
1830
1840
  const { t, effectiveProvider, effectiveModel } = deps;
1831
1841
  if (!effectiveProvider || !effectiveModel) {
@@ -1844,10 +1854,12 @@ async function runApplySelection(deps) {
1844
1854
  })).json();
1845
1855
  if (i$1(selJson, "ok") === true) {
1846
1856
  let testText;
1857
+ let verified = true;
1847
1858
  const llmTestRaw = i$1(selJson, "llmTest");
1848
1859
  if (e$3(llmTestRaw)) {
1849
1860
  const testOutput = strOf(i$1(llmTestRaw, "output")).slice(0, 400);
1850
- testText = i$1(llmTestRaw, "ok") === true ? t("llmTestPassed") : `${t("llmTestFailed")}${testOutput}`;
1861
+ verified = i$1(llmTestRaw, "ok") === true;
1862
+ testText = verified ? t("llmTestPassed") : `${t("llmTestFailed")}${testOutput}`;
1851
1863
  } else testText = t("notVerified");
1852
1864
  const appliedText = t("applied", {
1853
1865
  provider: effectiveProvider,
@@ -1855,7 +1867,7 @@ async function runApplySelection(deps) {
1855
1867
  test: testText
1856
1868
  });
1857
1869
  deps.setActionMsg({
1858
- ok: true,
1870
+ ok: verified,
1859
1871
  text: appliedText
1860
1872
  });
1861
1873
  deps.refresh();
@@ -1888,7 +1900,9 @@ async function runMigrateKeys(deps) {
1888
1900
  const migrated = Array.isArray(migratedVal) ? migratedVal.filter((item) => typeof item === "string") : [];
1889
1901
  const skipped = Array.isArray(skippedVal) ? skippedVal.filter((item) => typeof item === "string") : [];
1890
1902
  const ok = i$1(json, "ok") === true;
1891
- const head = ok ? (migrated.length > 0 ? t("migratedPrefix") + migrated.join(", ") : t("noPlainKey")) + (skipped.length > 0 ? t("skippedPrefix") + skipped.join(", ") : "") : t("migrateFailed");
1903
+ const migratedText = migrated.length > 0 ? t("migratedPrefix") + migrated.join(", ") : t("noPlainKey");
1904
+ const skippedText = skipped.length > 0 ? t("skippedPrefix") + skipped.join(", ") : "";
1905
+ const head = ok ? migratedText + skippedText : t("migrateFailed");
1892
1906
  deps.setActionMsg({
1893
1907
  ok,
1894
1908
  text: head
@@ -2026,16 +2040,14 @@ function buildProviderPanel(deps) {
2026
2040
  const { actionMsg } = deps;
2027
2041
  return [buildProviderSection(deps), actionMsg ? (0, react.createElement)("p", { className: statusClass(actionMsg.ok) }, actionMsg.text) : null];
2028
2042
  }
2029
- /** 管理区的状态与动作(MCP 配置 + ChatGPT 登录态/切换);OcrCard 只负责摆位置。 */
2030
- function useAdminPanel(reader, t) {
2043
+ /** MCP 配置编辑器的状态与动作:读、写,以及「没读成功不许写」的判据。 */
2044
+ function useMcpPanel(reader, t) {
2031
2045
  const [mcpJson, setMcpJson] = (0, react.useState)("{}");
2032
2046
  const [mcpBusy, setMcpBusy] = (0, react.useState)(false);
2033
2047
  const [mcpMsg, setMcpMsg] = (0, react.useState)(null);
2034
- const [authOutput, setAuthOutput] = (0, react.useState)(null);
2035
- const [authBusy, setAuthBusy] = (0, react.useState)(false);
2036
- const [authMsg, setAuthMsg] = (0, react.useState)(null);
2037
- const [authModel, setAuthModel] = (0, react.useState)("");
2038
- const [loginCommand, setLoginCommand] = (0, react.useState)("ocr auth login --provider chatgpt");
2048
+ const [mcpReady, setMcpReady] = (0, react.useState)(false);
2049
+ const mcpDirtyRef = (0, react.useRef)(false);
2050
+ const mcpRevisionRef = (0, react.useRef)(void 0);
2039
2051
  const loadMcp = (0, react.useCallback)(async () => {
2040
2052
  if (reader === void 0) {
2041
2053
  setMcpMsg(t("adminUnavailable"));
@@ -2043,10 +2055,17 @@ function useAdminPanel(reader, t) {
2043
2055
  }
2044
2056
  try {
2045
2057
  const reply = await reader.mcp();
2046
- if ("status" in reply) setMcpMsg(reply.error);
2047
- else setMcpJson(reply.json);
2058
+ if ("status" in reply) {
2059
+ setMcpMsg(reply.error);
2060
+ setMcpReady(false);
2061
+ } else {
2062
+ if (!mcpDirtyRef.current) setMcpJson(reply.json);
2063
+ mcpRevisionRef.current = reply.revision;
2064
+ setMcpReady(true);
2065
+ }
2048
2066
  } catch (error) {
2049
2067
  setMcpMsg(`${t("mcpLoadFailed")}${String(error)}`);
2068
+ setMcpReady(false);
2050
2069
  }
2051
2070
  }, [reader, t]);
2052
2071
  (0, react.useEffect)(() => {
@@ -2057,17 +2076,48 @@ function useAdminPanel(reader, t) {
2057
2076
  setMcpMsg(t("adminUnavailable"));
2058
2077
  return;
2059
2078
  }
2079
+ if (!mcpReady) {
2080
+ setMcpMsg(t("mcpNotLoaded"));
2081
+ return;
2082
+ }
2060
2083
  setMcpBusy(true);
2061
2084
  setMcpMsg(null);
2062
2085
  try {
2063
- const reply = await reader.saveMcp(mcpJson);
2064
- setMcpMsg("status" in reply ? reply.error : t("mcpSaved"));
2086
+ const reply = await reader.saveMcp(mcpJson, mcpRevisionRef.current);
2087
+ if ("status" in reply) setMcpMsg(reply.error);
2088
+ else {
2089
+ mcpDirtyRef.current = false;
2090
+ mcpRevisionRef.current = reply.revision;
2091
+ setMcpMsg(t("mcpSaved"));
2092
+ }
2065
2093
  } catch (error) {
2066
2094
  setMcpMsg(String(error));
2067
2095
  } finally {
2068
2096
  setMcpBusy(false);
2069
2097
  }
2070
2098
  }
2099
+ return {
2100
+ mcpJson,
2101
+ mcpReady,
2102
+ mcpBusy,
2103
+ mcpMsg,
2104
+ onMcpChange: (value) => {
2105
+ mcpDirtyRef.current = true;
2106
+ setMcpJson(value);
2107
+ },
2108
+ onMcpSave: () => {
2109
+ saveMcp();
2110
+ }
2111
+ };
2112
+ }
2113
+ /** 管理区的状态与动作(MCP 配置 + ChatGPT 登录态/切换);OcrCard 只负责摆位置。 */
2114
+ function useAdminPanel(reader, t) {
2115
+ const mcp = useMcpPanel(reader, t);
2116
+ const [authOutput, setAuthOutput] = (0, react.useState)(null);
2117
+ const [authBusy, setAuthBusy] = (0, react.useState)(false);
2118
+ const [authMsg, setAuthMsg] = (0, react.useState)(null);
2119
+ const [authModel, setAuthModel] = (0, react.useState)("");
2120
+ const [loginCommand, setLoginCommand] = (0, react.useState)("ocr auth login --provider chatgpt");
2071
2121
  async function checkAuth() {
2072
2122
  if (reader === void 0) {
2073
2123
  setAuthMsg(t("adminUnavailable"));
@@ -2107,18 +2157,12 @@ function useAdminPanel(reader, t) {
2107
2157
  }
2108
2158
  return {
2109
2159
  available: reader !== void 0,
2110
- mcpJson,
2111
- mcpBusy,
2112
- mcpMsg,
2160
+ ...mcp,
2113
2161
  authOutput,
2114
2162
  authBusy,
2115
2163
  authMsg,
2116
2164
  authModel,
2117
2165
  loginCommand,
2118
- onMcpChange: setMcpJson,
2119
- onMcpSave: () => {
2120
- saveMcp();
2121
- },
2122
2166
  onAuthModelChange: setAuthModel,
2123
2167
  onCheckAuth: () => {
2124
2168
  checkAuth();
@@ -2140,10 +2184,19 @@ function OcrCard(props) {
2140
2184
  const [busy, setBusy] = (0, react.useState)(null);
2141
2185
  const [actionMsg, setActionMsg] = (0, react.useState)(null);
2142
2186
  const [probeMsg, setProbeMsg] = (0, react.useState)(null);
2143
- const refresh = (0, react.useCallback)(async () => refreshProviders(props.reader, t, {
2144
- setData,
2145
- setFetchError
2146
- }), [props.reader, t]);
2187
+ const refreshSeq = (0, react.useRef)(0);
2188
+ const refresh = (0, react.useCallback)(async () => {
2189
+ refreshSeq.current += 1;
2190
+ const seq = refreshSeq.current;
2191
+ return refreshProviders(props.reader, t, {
2192
+ setData: (payload) => {
2193
+ if (seq === refreshSeq.current) setData(payload);
2194
+ },
2195
+ setFetchError: (message) => {
2196
+ if (seq === refreshSeq.current) setFetchError(message);
2197
+ }
2198
+ });
2199
+ }, [props.reader, t]);
2147
2200
  (0, react.useEffect)(() => {
2148
2201
  refresh();
2149
2202
  }, [refresh]);
@@ -2323,6 +2376,7 @@ exports.inject = inject;
2323
2376
  exports.isOwnSubject = isOwnSubject;
2324
2377
  exports.loadCliStatus = loadCliStatus;
2325
2378
  exports.providerOptionLabel = providerOptionLabel;
2379
+ exports.runApplySelection = runApplySelection;
2326
2380
  exports.runProbe = runProbe;
2327
2381
  exports.saveSettingsDraft = saveSettingsDraft;
2328
2382
  return module.exports;