@jayyuen66/dsh-ocr-review 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JayYuen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # @jayyuen66/dsh-ocr-review
2
+
3
+ [中文](#中文) · [English](#english)
4
+
5
+ ## 中文
6
+
7
+ ### 它做什么
8
+
9
+ - 把外部 `ocr`(open-code-review)CLI 封装成 dsh 的 5 个模型工具 + 1 张 web 设置卡片;宿主对工具入参零校验,形状与类型闸全在本包内做。
10
+ - Host 半(`host.ts`)声明 6 个 `.volatile()` 设置项(0.1.7 起命名空间是隐式的:宿主按 profile 条目 id `ocr-review` 投影表单,插件不再 `settings.register`)、注册 5 个模型工具、一段 systemPrompt 路由(`ocr-review-routing`,order 1555)与 4 个 `/_dsh/ocr-review/*` 端点。
11
+ - Client 半(`src/client-entry.ts` → `client.js`)是设置卡片,provider/model 取自设置服务的 `llm-pi-ai` 命名空间。
12
+ - 卡片把 provider/model 落盘到外部 ocr CLI 自己的 `~/.opencodereview/config.json`,写进去的是 `api_key_cmd` 命令而非明文 key。
13
+
14
+ ### 前置条件(外部 CLI)
15
+
16
+ - 本包不 import 任何 OCR 库,只起进程:`ocr review` / `ocr scan` / `ocr delegate preview` / `ocr delegate rule` / `ocr session` / `ocr llm test`。
17
+ - 命令按 v1.12.0 实测契约拼装(`lib/cli.ts`、`lib/parse.ts` 头注释),形如 `ocr review --audience agent --format json --effort medium --output <临时文件>`。
18
+ - 多值 `--exclude`/`--path` 合并成一个逗号分隔 flag,`--format` 只认 json/text/sarif。
19
+ - 命令解析优先级:先解析**随包装上**的 `@alibaba-group/open-code-review`(`optionalDependencies`,平台二进制由它自己的 optionalDependencies 选一),取其 `bin/ocr.js` 绝对路径;解析不到才回落到 PATH 上的裸 `ocr`——两种装法都继续支持,装上本插件即工具可用,不必用户自己再 brew/npm i -g 一遍。解析是惰性的且每进程只做一次(与 dsh 核心解析 `@vscode/ripgrep` 同款),解析失败不在装载期抛,否则整包会因一个可选二进制而下线。
20
+ - 两条路径都没有时子进程 exit 127,工具报「ocr 命令不存在」并给出 `brew install open-code-review` 或 `npm i -g @alibaba-group/open-code-review`。
21
+ - 平台:目标平台是 macOS / Linux。进程收割(`ocr_review` / `ocr_scan` 的 reaper 守护)依赖 POSIX 工具链——bash 的 trap 与作业控制,加上 `ps` / `pgrep` / `kill` 的进程组语义。Windows 上这层整体缺席,本包按 `process.platform` 直接放行裸命令(不收割但能跑);代价是宿主硬退出后 OCR 可能留下孤儿进程。
22
+ - provider 清单与 key 状态走宿主官方通道 `ctx.settings.describe()`(`llm-pi-ai` 那条的 `value.providers`)与 `ctx.credentials`。
23
+ - 两者缺席时卡片显示降级原因,工具与设置照常可用。
24
+ - 宿主版本要求 `>=0.2.0-rc.2`:写在 `peerDependencies`(0.1.7-rc 起宿主装插件时校验它;alpha.1 还没有这道门)与 `engines.dsh`(同值、无人读)。
25
+
26
+ ### 安装
27
+
28
+ ```sh
29
+ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
30
+ ```
31
+
32
+ - 包在公共 npm 上,安装不需要凭据。
33
+ - 发布面只含 `host.js`、`client.js`、`cordis.patch.yml`、`scripts`(`prepack` 重建两个 bundle),源码仓见 package.json 的 `repository.url`。
34
+ - 运行期值依赖 `@jayyuen66/dsh-plugin-shared`、`@deepseek-ai/schemastery`(宿主 fork 的 schemastery,0.1.7 的 `.volatile()` 解析只在它有实现)与 `js-yaml`(后者只被 `scripts/get-cred.mjs` 用)。
35
+
36
+ ### 在 dsh 里启用
37
+
38
+ - 包内 `cordis.patch.yml` 声明 `- id: ocr-review` / `name: "@jayyuen66/dsh-ocr-review"`,由 `package.json` 的 `dsh.bundle.patch` 指向。
39
+ - `dsh plugin --profile web add/remove` 负责登记与摘除,改完重启 dsh。
40
+ - 卡片要 web profile(`dsh.client.platform: web`、`immediately: true`)。
41
+ - 4 个 `/_dsh/ocr-review/*` 端点挂在 `inject(["webServer"])` 的子 fiber 上:宿主没有 webServer(如 TUI)时子 fiber 不激活、端点不存在,工具与设置照常。真实宿主上 webServer 比本条目晚到位约 1 秒,所以它必须是依赖而不是在 apply 里 `ctx.get` 读一次。
42
+ - 部署默认可写在 profile 注册行的 `config:` 上(cordis 按导出的 `Config` schema 校验并填默认),优先级:设置卡运行时值 > 行 config > 内置默认。
43
+
44
+ ### 提供给模型的工具
45
+
46
+ | 工具 | 入参与约束 |
47
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | `ocr_review` | repo(绝对路径,缺省=当前会话工作区)、scope workspace/commit/branch(branch 需同时给 from+to,且与 commit 互斥)、effort、background、exclude(最多 50 条)、provider/model/resume、wait |
49
+ | 数值参数组(`ocr_review` 的入参) | concurrency/timeoutMinutes/maxTools/maxTokens/maxTokensBudget,落 `--concurrency` `--timeout` `--max-tools` `--max-tokens` `--max-tokens-budget`,非整数或小于下限即报错 |
50
+ | `ocr_scan` | repo、path(最多 100 条)、exclude(50)、batch none/by-language/by-directory、同一组数值参数、wait;无需 git diff |
51
+ | `ocr_delegate_preview` / `ocr_delegate_rule` | 前者:repo、scope/commit/from/to、exclude 50、background;后者:repo、paths 必填 1–200 条,经 `--` 分隔的位置参数逐条转义。OCR 端零 LLM 消耗,只回文件清单与规则分组 |
52
+ | `ocr_session` | repo、action list/show/comments(白名单,越界即报错)、id、limit(钳到 1–100,缺省 10);show/comments 缺 id 时回落 list |
53
+ | 后台与回收 | `wait: false`(后台启动、不挂宿主 deadline、2 小时后兜底回收、不写 `--output`,结果用 `ocr_session` 轮询)与宿主回收守护只作用于 review/scan 这两条分钟级命令。**那次子进程同时是一枚官方作业**(见下面的「后台作业面」一节),工具回执那份 JSON 在换装那次一字未改(输出契约后来另改为 canonical 对象直返,见下节) |
54
+
55
+ ### 设置项
56
+
57
+ | 字段 | 取值、默认与用途 |
58
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
59
+ | `effort` | low/medium/high,内置默认 medium;`ocr_review` 未显式传 effort 时用它 |
60
+ | `language` | 中文/English,默认 中文;应用选择时作为顶层 `language` 键写进 OCR config |
61
+ | `autoVerify` | 默认 true;true 时 `/select` 成功后自动跑一次 `ocr llm test --color never`(90 秒上限) |
62
+ | `maxComments` | 默认 12,即摘要保留条数;0 = 不截断,非整数或负数回退 12 |
63
+ | `timeoutMinutes` | 默认 0 = 请求宿主上限(宿主按 min(请求, shell.maxTimeoutMs) 收口),>0 为分钟数 |
64
+ | `ocrConfigPath` | 外部 ocr CLI 配置的位置;留空按 `os.homedir()/.opencodereview/config.json` 派生,接受 `~` 与 `~/` 前缀,相对路径直接报错 |
65
+
66
+ ### 凭据与配置文件
67
+
68
+ | 面 | 事实 |
69
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
70
+ | config 里的凭据形态 | 写进去的是命令不是明文:`api_key_cmd` = `node '<本包 scripts/get-cred.mjs 的路径>' '<REF>'`(两段都过单引号转义),REF 名必须匹配 `^[A-Z0-9_]+$`,否则拒绝构造 |
71
+ | 写入方式 | 只走原子替换(交官方 `@deepseek-ai/dsh-atomic-write`):同目录随机后缀 `.<hex>.tmp` → rename,新文件权限 0600 由新 inode 携带、父目录 0700;备份 `.bak` 由本包在 rename **之前**复制并 chmod 0600;整段「读→判→写」由官方 `withFileLock` 串行化,RMW 期间同目录会短暂出现 `config.json.lock`(正常路径下 `finally` 删除,持锁进程崩溃时按 pid 探活接管);现有 config 是坏 JSON 或顶层非对象时中止写入,绝不「按空配置覆盖」。**已知不一致**:并发争用超过官方默认 2s 等待上限时,写会以 `atomic-write: timed out waiting for the writer lock at …` 抛出并回 400 JSON,这条文本来自官方件、不走 `lib/messages.ts`(本包其余用户可见错误都在消息表里);换装前 RMW 同处一个 tick、没有超时这条路,是异步化换来的新失败模式,映射它需要给锁等待时间加注入缝并为 2s 超时写测试,本包选择记录而不是静默扩面 |
72
+ | `POST migrate` | 把 custom provider 条目里的明文 `api_key` 换成 `api_key_cmd` 并删除明文;settings 里查不到 `apiKeyEnv` 的条目原样不动、列进 `skippedUnknown`;一条都没迁移成功时不写盘 |
73
+ | `scripts/get-cred.mjs` | 由外部 ocr CLI 自己起进程调用,是本包唯一还在读宿主凭据文件的地方。数据目录按「命令行第三参数 > `$DSH_HOME` > `~/.dsh`」定位,取值层序为继承来的进程环境变量 > `<dsh 数据目录>/.credentials.yaml` 的 `refs.<REF>` > `<dsh 数据目录>/.env`。刻意不读被审查仓库的项目 `.env`;三层全落空即非零退出,并把查过的位置一并报出 |
74
+
75
+ ### 对外接口
76
+
77
+ | 路由 | 方法 | 入参 | 成功回执 |
78
+ | ---------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
79
+ | `/_dsh/ocr-review/providers` | GET | — | `{providers(每条附 hasKey 布尔), current, getCredReady, source, csrf}`,不含任何 key 明文 |
80
+ | `/_dsh/ocr-review/select` | POST | body `{provider, model}`,两者必须命中 `describe()` 给的清单 | `{ok, applied, llmTest}`(`autoVerify` 关闭时 `llmTest` 为 null) |
81
+ | `/_dsh/ocr-review/migrate` | POST | — | `{ok, migrated, skippedUnknown}` |
82
+ | `/_dsh/ocr-review/test` | POST | — | `{ok, output}`(output 先脱敏再截 8000 字符) |
83
+ | 三个写端点的统一前置 | — | — | handler 第一句是 `guardTrust`(Host 权威 → `sec-fetch-site` 白名单 → `origin` 逐字比对,不过则 403 + `{ok:false,error:"untrusted host authority" \| "cross-origin request rejected"}`);非 POST 405 带 `Allow: POST` 与 `{ok:false,error:"POST only"}`;缺或错 `x-ocr-csrf` 头 403、body 超 8KB 413、流中断 400;token 每次插件 apply 重新生成,旧 token 随即失效 |
84
+
85
+ 这四条路由随插件的注入子 fiber 生灭。官方 `webServer.register` 交回的是**必须自己调用**的释放器,且对重复路径当场抛错(installed dsh-host-webserver/lib/index.js:177-184),而路由表挂在宿主提供的 webServer 实例上、**不**随本插件卸载而死。本包把四枚释放器挂在 `inject(["webServer"])` 子 fiber 的效应上,于是:热重载(旧 fiber dispose ⇒ 新 apply)不会撞名而挂掉整张设置卡,禁用或卸载插件后这四条端点也不再可达。
86
+
87
+ ### 后台作业面(官方 `ctx.jobs`)
88
+
89
+ `wait: false` 起的那只外部 `ocr` 子进程,从今天起同时登记为宿主 `ctx.jobs` 里的一枚**未拥有**作业(`kind: "ocr-review"` ⇒ id `ocr-review-N`)。改的是"谁能看见并停掉它",不是"模型拿到什么":
90
+
91
+ | 面 | 事实 |
92
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | 模型的观察面 | 多了三位:官方 `job_list` 列得出这次评审、`job_output` 读得到它的 stdout/stderr(注册表按自己的节奏从 `proc.observed` 拉,非消费式,与 `readOutput()` 游标互不争夺 — 官方 d.ts 明写 "without stealing bytes from `readOutput`")、`job_kill` 停得掉它。评审结果本身仍以 `ocr_session` 会话记录为准;工具回执的内容自换装起未变,其后随输出契约改造改为 canonical 对象直返(不再把回执 stringify 进 `{ text }` 双重编码) |
94
+ | 坏掉的执行器读者 | 官方泵对"源抛错"的答法是只往宿主日志写一行、此后这一路读作空(实测)⇒ 本包额外写**一条**"这段不完整"进环,只写一次、也不再去敲坏掉的读者 |
95
+ | controller 寿命 | 只挂在 `start` 那一瞬:官方件在"没有 controller 服务这个 owner"时拒绝 `start`,而 web 面宿主的 `tool-jobs` 在各 preset realm 内(宿主平面那一行被 disabled)⇒ 未拥有作业必须自己挂一枚。挂完立刻摘(实测:摘掉即恢复拒绝、后续 read/kill/落定都不依赖它)——常驻等于在"宿主故意没装 job 工具"的组成里替全宿主开着那道闸门 |
96
+ | 三条取消路径 | 工具调用被中止、插件卸载、2 小时兜底 deadline 都是 `jobs.kill(id, undefined, reason)` 而不是绕过它杀进程 ⇒ 记录推到 `stopping`、reason 落进 `detail`(实测绕过那一层,名册里这条会永远停在 `running`)。三条可以叠加(实测对 `stopping` 再 kill 仍回 `requested`、`proc.kill()` 幂等)。reason 是模型可见文本 ⇒ 取双语字典 |
97
+ | 取消链里不许抛 | `abort` 监听器里抛出的错误由 Node 以 `process.nextTick` 重抛 ⇒ 未捕获异常 ⇒ **整个宿主进程退出**(本机实跑复现:`Error: unknown job ...` + `EXIT=1`)。官方件也不替生产者兜:`killJob` 裸调 `job.cancel()`(installed dsh-jobs-local:611-622,只有 teardown 那一臂逐条 try/catch),注册表被重载/关掉时对陌生 id 抛(同文件 `:554-558`)。于是两层各兜一次:`cancel` 经 `killProc` 吞掉"进程早就不在了"那一类,`stop` 兜住注册表那一臂,兜不住时退化成直接杀我们自己的进程(各有一条用例,删掉任一层那条就红) |
98
+ | 2 小时兜底 | 本包仍不给外部 CLI 套宿主 `timeoutMs`(长评审会被误砍),但"完全没有终点"在换装后有了新受害者:未拥有的 10 格桶是**全宿主共用**的(实测 `running` 与 `stopping` 都计数、满员直接拒绝新 `start`)⇒ 一条挂死的 CLI 会永久占一格。收法用注册表自己的 `wait(id, ms)` + `kill`,不用宿主 timer 也不裸 `setTimeout` |
99
+ | 未拥有的暴露面 | 官方件按 owner 隔离,未拥有作业任何 caller 都能 `list`/`read`/`kill`。这是本波已知并接受的暴露面(受有作业要 `dsh-agent` 注册表,实测不可得);回滚这次换装是唯一的收回方式 |
100
+ | 落定记录窗口 | 注册表不会自行回收已落定的记录,而它随宿主活着、不随本插件卸载而死 ⇒ 本包在每次登记前剪掉 `ocr-review` 这一 kind 最老的已落定记录,剪到 9 条再起新的 ⇒ 在册任何时刻都不超过 10 条(每条最多带一只 256 KiB 的环)。卸载时两件事都做:活着的经注册表 kill,已落定的 remove —— 用户直接禁用本包就没有"下一次 apply"来剪窗口了。在跑与正在收的一条都不被剪 —— 官方件对超额的答法是**拒绝新 start**(实测未拥有桶 10),不是替我们杀评审 |
101
+ | 没注册表没后台 | 宿主没装 `dsh-jobs-local` 时,`wait: false` 直接抛出点名缺件的错误,**不会**先把子进程起在没人看得见的地方。前台(`wait: true`)不受影响 |
102
+
103
+ ### 数据与隐私
104
+
105
+ | 面 | 事实 |
106
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | 进程边界 | 被审查仓库的路径与 diff 内容进 `ocr` 子进程,OCR 再把它发给自己在 config.json 里配置的 LLM 端点;这条外发是审查功能本身,不经 dsh 宿主。明文 key 不进工具返回、不进 `/_dsh/*`,宿主侧 `resolve()` 拿到的值只用于确认「现在解析得到」,当场丢弃 |
108
+ | 原始输出脱敏 | OCR 的原始 stdout/stderr(失败详情、llm test、解析失败回显、session 透传)出宿主前统一过 `redactKeyMaterial()`:Bearer 与赋值形态从严抹除,独立 key 形态额外要求随机串特征,以免把要交给模型的文件路径一起抹掉 |
109
+ | 写盘范围 | 本包写盘只有两处:外部 ocr CLI 的 `config.json`(含同目录随机后缀 `.tmp`、备份 `.bak`,以及 RMW 期间短暂存在、正常路径下即删的 `config.json.lock`)与系统临时目录下的 `ocr-review-*`/`ocr-scan-*`(读完即删)。迁移前的明文可能仍留在同一次写入产生的 `.bak` 里(权限 0600),要彻底清掉需自行删除该备份 |
110
+
111
+ ### 常见问题
112
+
113
+ | 现象 | 结论 |
114
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | exit 0 就以为评审成功 | exit 0 不等于评审成功:`status` 不在 success/complete/completed_with_warnings/completed_with_errors/skipped 白名单内即按失败报(`{"error": …}` 属这类),解析失败时回显脱敏后的原文前 500 码元(UTF-16,一张 emoji 占 2 枚) |
116
+ | 摘要是否覆盖了全部产出 | `droppedCount` 与 `invalidCommentCount` 同时为 0 才覆盖 OCR 全部产出,否则调大 `maxComments`(或置 0)再复核;stdout 侧另有 400000 字节上限,所以评审结果统一走 `--output` 临时文件 |
117
+ | dsh 退出时的后台进程 | dsh 以任何方式退出(含 kill -9 后被 launchd 收养)时,review/scan 的 OCR 进程树由命令内守护 TERM→1s→KILL 收走;取消工具调用即终止对应的后台进程 |
118
+
119
+ ## English
120
+
121
+ ### What it does
122
+
123
+ - Wraps the external `ocr` (open-code-review) CLI into 5 model tools plus 1 web settings card for dsh; the host validates tool arguments not at all, so every shape and type gate lives in this package.
124
+ - The host half (`host.ts`) declares 6 `.volatile()` settings fields — as of 0.1.7 the namespace is implicit: the host projects the form from the profile entry id `ocr-review` and the plugin calls no `settings.register`.
125
+ - It also registers 5 tools, one systemPrompt routing section (`ocr-review-routing`, order 1555) and 4 `/_dsh/ocr-review/*` endpoints.
126
+ - The client half (`src/client-entry.ts` → `client.js`) is the settings card, taking provider/model from the `llm-pi-ai` namespace of the settings service.
127
+ - It writes provider/model into the external ocr CLI's own `~/.opencodereview/config.json` — as an `api_key_cmd` command, never as a plaintext key.
128
+
129
+ ### Prerequisites (external CLI)
130
+
131
+ - This package imports no OCR library, it only spawns processes: `ocr review` / `ocr scan` / `ocr delegate preview` / `ocr delegate rule` / `ocr session` / `ocr llm test`.
132
+ - Commands are built against the contract verified on v1.12.0 (header comments of `lib/cli.ts` and `lib/parse.ts`), shaped like `ocr review --audience agent --format json --effort medium --output <temp file>`.
133
+ - Multi-value `--exclude`/`--path` are merged into one comma-separated flag, and `--format` only accepts json/text/sarif.
134
+ - Command resolution order: the launcher bundled with the package first - `@alibaba-group/open-code-review` (an `optionalDependencies` entry, whose own optionalDependencies pick the platform binary) resolved to the absolute path of its `bin/ocr.js` - and only when that does not resolve does it fall back to a bare `ocr` on PATH. Both installation shapes keep working, so installing this plugin is enough to get a working tool without a separate brew / npm i -g step. Resolution is lazy and memoized once per process (the same shape dsh core uses to resolve `@vscode/ripgrep`); a failed resolve never throws at load time, which would take the whole plugin offline over one optional binary.
135
+ - With neither path available the child exits 127 and the tool reports "ocr command not found", offering `brew install open-code-review` or `npm i -g @alibaba-group/open-code-review`.
136
+ - Platforms: macOS and Linux are the targets. Process reaping (the reaper guard around `ocr_review` / `ocr_scan`) depends on a POSIX toolchain - bash traps and job control plus the process-group semantics of `ps` / `pgrep` / `kill`. None of that exists on Windows, so the package runs the bare command there (guarded by `process.platform`): no reaping, but it still works. The cost is that a hard host exit can leave an orphaned OCR process.
137
+ - The provider list and the key status come from the official host channels `ctx.settings.describe()` (the `value.providers` of the `llm-pi-ai` entry) and `ctx.credentials`.
138
+ - When either is missing the card shows the degradation reason while tools and settings keep working.
139
+ - The host must be `>=0.2.0-rc.2`: declared in `peerDependencies` (the host checks it on plugin install from 0.1.7-rc; alpha.1 has no such gate yet) and in `engines.dsh` (same value, read by nothing).
140
+
141
+ ### Installation
142
+
143
+ ```sh
144
+ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
145
+ ```
146
+
147
+ - The packages are on the public npm registry, so installation needs no credentials.
148
+ - The published files are only `host.js`, `client.js`, `cordis.patch.yml` and `scripts` (`prepack` rebuilds both bundles); source repository: see `repository.url` in package.json.
149
+ - Runtime value dependencies are `@jayyuen66/dsh-plugin-shared`, `@deepseek-ai/schemastery` (the host's fork of schemastery — only it implements the 0.1.7 `.volatile()` resolution) and `js-yaml` (the last one used only by `scripts/get-cred.mjs`).
150
+
151
+ ### Enabling it in dsh
152
+
153
+ - The in-package `cordis.patch.yml` declares `- id: ocr-review` / `name: "@jayyuen66/dsh-ocr-review"` and is pointed at by `dsh.bundle.patch` in `package.json`.
154
+ - `dsh plugin --profile web add/remove` registers and removes it, then restart dsh.
155
+ - The card needs a web profile (`dsh.client.platform: web`, `immediately: true`).
156
+ - The four `/_dsh/ocr-review/*` endpoints hang off an `inject(["webServer"])` child fiber: with no webServer (a TUI host) the child never activates, so the endpoints simply do not exist while tools and settings stay available.
157
+ - On the real host webServer arrives about a second after this entry, which is why it has to be a dependency rather than a one-off `ctx.get` in apply.
158
+ - Deployment defaults can go on the profile's `config:` line (cordis validates and fills defaults through the exported `Config` schema); precedence: runtime settings value > line config > built-in default.
159
+
160
+ ### Tools exposed to the model
161
+
162
+ | Tool | Arguments and constraints |
163
+ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164
+ | `ocr_review` | repo (absolute path, default = current session workspace), scope workspace/commit/branch (branch needs both from+to and is mutually exclusive with commit), effort, background, exclude (at most 50 entries), provider/model/resume, wait |
165
+ | Numeric group (arguments of `ocr_review`) | concurrency/timeoutMinutes/maxTools/maxTokens/maxTokensBudget (mapped to `--concurrency` `--timeout` `--max-tools` `--max-tokens` `--max-tokens-budget`; non-integers or values below the floor are rejected) |
166
+ | `ocr_scan` | repo, path (at most 100 entries), exclude (50), batch none/by-language/by-directory, the same numeric group, wait; no git diff required |
167
+ | `ocr_delegate_preview` / `ocr_delegate_rule` | The former: repo, scope/commit/from/to, exclude 50, background. The latter: repo, paths required, 1–200 entries, positional arguments after `--`, each escaped. Zero LLM cost on the OCR side, they return only the file list and the rule groups |
168
+ | `ocr_session` | repo, action list/show/comments (whitelist, anything else errors), id, limit (clamped to 1–100, default 10); show/comments without an id falls back to list |
169
+ | Background and reaper | `wait: false` (background start, no host deadline, reclaimed by a 2-hour backstop, no `--output`, poll results with `ocr_session`) and the host reaper guard apply only to these two minute-scale commands, review and scan - that subprocess is also an official job (see the job-surface section below); that change left the tool's JSON untouched (a later output-contract rework switched it to the direct canonical object, see below) |
170
+
171
+ ### Settings
172
+
173
+ | Field | Values, defaults and use |
174
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
+ | `effort` | low/medium/high, built-in default medium; used when `ocr_review` is called without an explicit effort |
176
+ | `language` | 中文/English, default 中文; written as the top-level `language` key of the OCR config when a selection is applied |
177
+ | `autoVerify` | default true; when true, a successful `/select` also runs `ocr llm test --color never` (capped at 90 seconds) |
178
+ | `maxComments` | default 12, the number of comments kept in the summary; 0 = no truncation, non-integers and negatives fall back to 12 |
179
+ | `timeoutMinutes` | default 0 = request the host cap (the host clamps to min(request, shell.maxTimeoutMs)), >0 is a number of minutes |
180
+ | `ocrConfigPath` | where the external ocr CLI config lives; left empty it derives `os.homedir()/.opencodereview/config.json`, `~` and `~/` prefixes are accepted, relative paths error out |
181
+
182
+ ### Credentials and config files
183
+
184
+ | Aspect | Fact |
185
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
186
+ | Credential form in the config | What lands in the config is a command, not a secret: `api_key_cmd` = `node '<path of scripts/get-cred.mjs in this package>' '<REF>'` (both parts single-quote escaped), and the REF name must match `^[A-Z0-9_]+$` or the command is refused |
187
+ | How writes happen | Atomic replacements via the official `@deepseek-ai/dsh-atomic-write`: a random-suffixed `.<hex>.tmp` sibling created with `wx`, then rename, with the new inode carrying 0600 (parent directories 0700). The `.bak` backup is copied by this package **before** the rename and chmod-ed to 0600 (the official helper has no backup semantics). The whole read→decide→write span is serialized by the official `withFileLock`, so a short-lived `config.json.lock` appears in that directory during a write and is removed in `finally`; a lock left by a crashed holder is taken over via a pid liveness probe. **Known inconsistency**: contention beyond the official default 2s wait makes the write throw `atomic-write: timed out waiting for the writer lock at …`, and the endpoint answers 400 with that English text verbatim — it comes from the official package and bypasses `lib/messages.ts`, unlike every other user-visible error here. Before this swap the read-modify-write shared one event-loop tick, so this failure mode did not exist; mapping it would need an injectable wait budget plus a timeout test, so this package documents it instead of silently widening the surface. An existing config that is invalid JSON or whose top level is not an object aborts the write instead of "treating it as empty and overwriting" |
188
+ | `POST migrate` | Replaces the plaintext `api_key` of custom provider entries with `api_key_cmd` and deletes the plaintext; entries whose `apiKeyEnv` is not found in settings are left untouched and listed in `skippedUnknown`; nothing is written when nothing was migrated |
189
+ | `scripts/get-cred.mjs` | Spawned by the external ocr CLI itself and the only place in this package still reading host credential files. The data directory is resolved as explicit third argument > `$DSH_HOME` > `~/.dsh`, and values are looked up in inherited process environment > `refs.<REF>` of `<dsh data dir>/.credentials.yaml` > `<dsh data dir>/.env`. The project `.env` of the reviewed repository is deliberately not read; when all three layers miss it exits non-zero and names every location it checked |
190
+
191
+ ### Public endpoints
192
+
193
+ | Route | Method | Input | Success payload |
194
+ | ---------------------------------------- | ------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
195
+ | `/_dsh/ocr-review/providers` | GET | — | `{providers (each with a hasKey boolean), current, getCredReady, source, csrf}`, never containing a key |
196
+ | `/_dsh/ocr-review/select` | POST | body `{provider, model}`, both must hit the list returned by `describe()` | `{ok, applied, llmTest}` (`llmTest` is null when `autoVerify` is off) |
197
+ | `/_dsh/ocr-review/migrate` | POST | — | `{ok, migrated, skippedUnknown}` |
198
+ | `/_dsh/ocr-review/test` | POST | — | `{ok, output}` (output is redacted first, then cut to 8000 characters) |
199
+ | Front guard of the three write endpoints | — | — | The first statement of each handler is `guardTrust` (Host authority -> the `sec-fetch-site` allowlist -> a verbatim `origin` compare; failing it gives 403 with `{ok:false,error:"untrusted host authority" \| "cross-origin request rejected"}`); 405 for non-POST carrying `Allow: POST` and `{ok:false,error:"POST only"}`; 403 for a missing or wrong `x-ocr-csrf` header; 413 above the 8KB body cap; 400 for an aborted stream. The token is regenerated on every plugin apply and the old one stops working |
200
+
201
+ These four routes live and die with the plugin's injected child fiber. The official `webServer.register` hands back a disposer the **caller must invoke**, and it throws on a duplicate path outright (installed dsh-host-webserver/lib/index.js:177-184), while the route table belongs to the host-provided webServer instance and does **not** die when this plugin's fiber does. This package attaches all four disposers to the effect of the `inject(["webServer"])` child fiber, so a hot reload (old fiber disposes, then a new apply runs) cannot collide on the path and kill the whole settings card, and disabling or uninstalling the plugin makes these endpoints unreachable again.
202
+
203
+ ### The background job surface (official `ctx.jobs`)
204
+
205
+ The external `ocr` subprocess started by `wait: false` is now also registered as an **unowned** job in the host's `ctx.jobs` (`kind: "ocr-review"`, id `ocr-review-N`). What changed is who can see and stop it, not what the model receives:
206
+
207
+ | Aspect | Fact |
208
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
209
+ | Model observation surface | Three more tools: the official `job_list` shows the review, `job_output` reads its stdout/stderr (the registry pulls from `proc.observed` on its own cadence - non-consuming, so it never steals bytes from the `readOutput()` cursor), and `job_kill` stops it. Results still come from the `ocr_session` record; the payload content is unchanged since the job-surface swap, and a later output-contract rework returns the canonical object directly (no more stringify-ing the receipt into a `{ text }` wrapper) |
210
+ | Broken executor reader | When the executor's reader throws, the official pump only writes one line to the host log and afterwards reads that channel as empty (measured) ⇒ this package writes **one** "this section is incomplete" note into the ring, exactly once, and stops knocking on the broken reader |
211
+ | Controller lifetime | Attached only for the instant of `start`: the registry refuses `start` when no controller serves the owner, and on the web surface the host's own `tool-jobs` row is disabled at the host plane (each preset realm mounts its own), so an unowned job needs a controller of our own. We attach it and detach it immediately (measured: detaching restores the refusal, and read/kill/settlement never need it again) - a resident token would pry that gate open for a composition that deliberately has no job tools |
212
+ | Three cancellation paths | An aborted tool call, a plugin unload, and the 2-hour backstop all call `jobs.kill(id, undefined, reason)` instead of killing the process around it, so the record moves to `stopping` and the reason lands in `detail` (measured: bypassing it left the row stuck at `running` forever). They may overlap - killing a `stopping` job still returns `requested` and `proc.kill()` is idempotent. Reasons are model-facing, so they come from the bilingual catalog |
213
+ | Nothing there may throw | An error raised inside an `abort` listener is rethrown by Node via `process.nextTick` ⇒ uncaught ⇒ **the whole host process exits** (reproduced locally: `Error: unknown job ...` followed by `EXIT=1`). The official package does not cover for producers either - `killJob` calls `job.cancel()` bare (installed dsh-jobs-local:611-622, and only the teardown arm wraps each call in try/catch), and a reloaded registry throws for an unfamiliar id (same file `:554-558`). So two layers each catch once: `cancel` swallows the "process is already gone" family through `killProc`, `stop` catches the registry arm and falls back to killing our own process. One test per layer - deleting either layer turns it red |
214
+ | The 2-hour backstop | We still give the external CLI no host `timeoutMs` (a long review would be cut off), but "no end at all" gained a victim with this adoption - the 10-slot unowned bucket is **shared host-wide** (measured: `running` and `stopping` both count, overflow refuses new `start`s), so one hung CLI parks a slot forever. The backstop uses the registry's own `wait(id, ms)` + `kill`, not a host timer and not a bare `setTimeout` |
215
+ | Unowned exposure | Unowned means visible to every session - any caller can `list`/`read`/`kill` it. Known and accepted for this wave (owned jobs would need the `dsh-agent` registry, measured unavailable); rolling the adoption back is the only way to close it |
216
+ | Settled-record window | The registry never recycles settled records on its own, and it outlives this plugin - so before each registration this package prunes the oldest settled `ocr-review` records down to 9 and only then starts, so the roster never holds more than 10 (each ring is capped at 256 KiB). At unload both jobs are done: live ones are killed through the registry, settled ones are removed - disabling this plugin means there is no "next apply" left to prune the window. Live and stopping records are never pruned: the registry answers overflow by **refusing `start`** (measured: the unowned bucket holds 10), not by killing somebody's review |
217
+ | No registry, no background | When the host has not loaded `dsh-jobs-local`, `wait: false` throws an error naming the missing piece instead of starting a subprocess nobody can observe. Foreground runs (`wait: true`) are unaffected |
218
+
219
+ ### Data and privacy
220
+
221
+ | Aspect | Fact |
222
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
223
+ | Process boundary | The reviewed repository's paths and diff content go into the `ocr` child process, and OCR sends them to the LLM endpoint it has 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 in `/_dsh/*`, and the value the host gets from `resolve()` is used only to confirm "it resolves right now" and is discarded on the spot |
224
+ | Redaction of raw output | Raw OCR stdout/stderr (failure details, llm test, parse-failure echoes, session passthrough) all go through `redactKeyMaterial()` before leaving the host: bearer and assignment forms are redacted strictly, while the standalone-key form additionally demands random-string features so that file paths handed to the model are not erased as well |
225
+ | Places written | This package writes to only two places: the external ocr CLI's `config.json` (plus its random-suffixed `.tmp`, the `.bak` backup, and the short-lived `config.json.lock` held only for the duration of one read-modify-write) and `ocr-review-*`/`ocr-scan-*` in the system temp directory (removed right after reading). Plaintext that predates a migration may still sit in the `.bak` produced by that same write (mode 0600); removing it for good means deleting that backup yourself |
226
+
227
+ ### FAQ
228
+
229
+ | Symptom | Verdict |
230
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
231
+ | Exit 0 taken for a successful review | Exit 0 is not a successful review: any `status` outside success/complete/completed_with_warnings/completed_with_errors/skipped is reported as failure (`{"error": …}` is of that kind), and a parse failure echoes the first 500 redacted UTF-16 code units of the raw output (an emoji takes 2) |
232
+ | Whether the summary covered everything | It covers everything OCR produced only when `droppedCount` and `invalidCommentCount` are both 0, otherwise raise `maxComments` (or set 0) and re-check; stdout has its own 400000-byte cap, which is why review results always go through the `--output` temp file |
233
+ | Background processes when dsh exits | When dsh exits in any way (including kill -9 followed by launchd adoption), the OCR process tree of review/scan is taken down by the reaper guard inside the command with TERM→1s→KILL; cancelling the tool call terminates the corresponding background process too |