@jayyuen66/dsh-ocr-review 0.1.0 → 0.1.2

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
@@ -12,10 +12,10 @@ import { guardTrust } from "@jayyuen66/dsh-plugin-shared/lib/trust";
12
12
  import { LOCALE_SETTINGS_NAMESPACE, messagesFor, resolveLocalePreference } from "@jayyuen66/dsh-plugin-shared/lib/locale";
13
13
  import { truncateEnd, truncateStart } from "@jayyuen66/dsh-plugin-shared/lib/text";
14
14
  import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
15
+ import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
15
16
  import { errorText } from "@jayyuen66/dsh-plugin-shared/lib/errors";
16
17
  import { mkdir } from "node:fs/promises";
17
18
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
18
- import { expandHomePath } from "@deepseek-ai/dsh-home-paths";
19
19
  //#region lib/messages.ts
20
20
  const MESSAGES = {
21
21
  zh: {
@@ -51,8 +51,12 @@ const MESSAGES = {
51
51
  branchNeedsFromAndTo: "scope=branch 需要同时提供 from 与 to",
52
52
  commitNeedsValue: "scope=commit 需要 commit 参数",
53
53
  unknownScope: "未知 scope:{scope}(workspace/commit/branch)",
54
+ resumeNeedsScope: "resume 需要 scope=commit 或 scope=branch(workspace 工作区评审不支持恢复,OCR 会直接拒绝);请同时给出 commit 或 from/to",
55
+ waitMustBeBoolean: "wait 必须是布尔值(true/false),收到 {received}",
54
56
  pathsRequired: "paths 必填:至少传一个待审查文件的仓库相对路径",
55
57
  invalidAction: "action 非法:{action}(期望 {expected})",
58
+ sessionLimitRange: "limit 需要为 1-100 的整数(缺省 10),收到 {received}",
59
+ listRejectsId: "action=list 不接受 id(查单个会话请用 action=show 或 action=comments)",
56
60
  resultSchemaViolation: "ocr {tool}: 返回值不符合该工具的 output schema:{violations}",
57
61
  resultSchemaUnsupported: "ocr {tool}: 返回形态守卫不支持 output schema 关键字 {keyword}(插件内 bug:lib/output.ts 的关键字子集没跟上 schema)",
58
62
  ocrNotJson: "OCR 未返回合法 JSON 输出",
@@ -61,7 +65,7 @@ const MESSAGES = {
61
65
  statusNone: "(无)",
62
66
  ocrCommentsNotArray: "OCR 输出 comments 字段不是数组,无法确认评论完整性",
63
67
  warningSeparator: ":",
64
- reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr session show <session_id>` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。判读口径:droppedCount 与 invalidCommentCount 同时为 0 时本摘要才覆盖 OCR 的全部产出;否则请调大 maxComments(或置 0 = 不截断)后复核,切勿把「本摘要无 critical」当作「代码干净」。",
68
+ reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr session show <session_id>` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。status=partial 表示部分文件未完成审查(失败明细在 warnings),评论仍然有效。判读口径:droppedCount 与 invalidCommentCount 同时为 0 时本摘要才覆盖 OCR 的全部产出;否则请调大 maxComments(或置 0 = 不截断)后复核,切勿把「本摘要无 critical」当作「代码干净」。",
65
69
  lineZeroNote: "(line 0: 定位失败,按 existing_code 手动定位)",
66
70
  parseFailedWithReason: "{tool} 输出解析失败:{message}\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
67
71
  parseFailedPlain: "{tool} 输出解析失败\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
@@ -69,12 +73,14 @@ const MESSAGES = {
69
73
  sourceSettingsFailed: "调用 dsh 设置服务失败:{message}",
70
74
  sourceNamespaceUnavailable: "dsh 配置里没有 {ns} 命名空间:未安装 pi-ai 适配器,或还没配置任何 provider",
71
75
  sourceCredentialsUnavailable: "dsh 凭据服务(ctx.credentials)未装配:无法确认 provider 的 API key 是否已配置",
72
- badEnvKey: "非法 env key:{key}",
76
+ badEnvKey: "非法 env key:{key}(get-cred 只接受 ^[A-Z0-9_]+$ 的大写 ref 名;请在 dsh 配置里把该 provider 的 apiKeyEnv 改成大写字母/数字/下划线)",
73
77
  configNotJson: "{where} 不是合法 JSON,已中止写入以保护既有配置(请先修复或删掉该文件):{cause}",
74
78
  configNotObject: "{where} 顶层不是 JSON 对象,已中止写入以保护既有配置",
75
79
  providerNotInList: "provider 不在 dsh 配置的 {ns}.providers 列表:{provider}",
76
80
  modelNotInList: "model 不在 {provider} 的 models 列表中:{model}",
81
+ protocolUnsupported: "provider {provider} 的 api 协议 {protocol} 无法映射到 OCR 支持的 protocol(openai/openai-responses/anthropic);请在 dsh 配置里改用 openai-completions/openai-responses/anthropic-messages,或升级本插件",
77
82
  ocrConfigPathInvalid: "设置项 ocrConfigPath 必须是绝对路径或 ~ 开头的路径:{path}",
83
+ configPathLayoutInvalid: "设置项 ocrConfigPath 必须是 <X>/.opencodereview/config.json 布局:{path}。OCR 端没有配置文件级的路径覆盖(只按 <HOME>/.opencodereview/config.json 定位),本插件通过给 ocr 子进程注入 HOME=<X> 让它生效;任意形状的文件路径写下去 OCR 也不会读",
78
84
  noApiKeyEnv: "provider {provider} 未声明 apiKeyEnv,无法动态读取 key(OCR 自定义 provider 不支持无 key 解析)",
79
85
  keyNotResolvable: "provider {provider} 的 API key 未配置:dsh 凭据服务未解析到 {ref}",
80
86
  credScriptMissing: "get-cred 脚本缺失:{path}(api_key_cmd 无法工作)",
@@ -113,7 +119,7 @@ const MESSAGES = {
113
119
  unitFile: "文件",
114
120
  tuningConcurrency: "并发{unit}数(OCR --concurrency;缺省不传=OCR 原生 8)",
115
121
  tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;0=不限时;缺省不传=OCR 原生 15)",
116
- tuningMaxTools: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;缺省不传=模板默认)",
122
+ tuningMaxTools: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;1-49 会被 OCR 抬到 50,帮助文本 min 50;缺省不传=模板默认)",
117
123
  tuningMaxTokens: "每{unit}提示词 token 上限(OCR --max-tokens;0=配置/模板默认;缺省不传=模板默认)",
118
124
  tuningMaxTokensBudget: "本次运行 total token(input+output)预算闸门,超出即停止派发部分结果(OCR --max-tokens-budget;0=不限;缺省不传=不限)",
119
125
  previewHint: "对每个 reviewable 文件:用 git diff <merge_base>..<to> -- <path>(branch)/git show <commit> -- <path>(commit)/ git diff HEAD -- <path> 或直接读文件(workspace 新文件)取 diff。",
@@ -136,7 +142,7 @@ const MESSAGES = {
136
142
  是当前模型的工具调用能力问题——建议在 ocr-review 卡片换一个支持原生工具调用的模型(sensenova、deepseek、glm、qwen 系),
137
143
  不要反复重试同一个模型。
138
144
  - LLM 调用本身有重试:OCR 内置重试(429/408/409 与 5xx 自动退避重试);per-file 组失败被隔离并进 warnings,不整体重跑。
139
- 工具层超时(10 分钟)或部分文件失败后,可 ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审。
145
+ 工具层超时(min(请求, shell.maxTimeoutMs),缺省 10 分钟)或部分文件失败后,可 ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审。
140
146
  `.trim()
141
147
  },
142
148
  en: {
@@ -172,8 +178,12 @@ const MESSAGES = {
172
178
  branchNeedsFromAndTo: "scope=branch requires both from and to",
173
179
  commitNeedsValue: "scope=commit requires the commit parameter",
174
180
  unknownScope: "unknown scope: {scope} (workspace/commit/branch)",
181
+ resumeNeedsScope: "resume requires scope=commit or scope=branch (workspace reviews cannot be resumed; OCR rejects it outright); pass commit or from/to as well",
182
+ waitMustBeBoolean: "wait must be a boolean (true/false), got {received}",
175
183
  pathsRequired: "paths is required: pass at least one repo-relative path of a file to review",
176
184
  invalidAction: "invalid action: {action} (expected {expected})",
185
+ sessionLimitRange: "limit must be an integer 1-100 (default 10), got {received}",
186
+ listRejectsId: "action=list does not take an id (use action=show or action=comments for a single session)",
177
187
  resultSchemaViolation: "ocr {tool}: the tool result does not match its output schema: {violations}",
178
188
  resultSchemaUnsupported: "ocr {tool}: the result guard does not support the output schema keyword {keyword} (plugin bug: the keyword subset in lib/output.ts lags the schema)",
179
189
  ocrNotJson: "OCR returned no valid JSON output",
@@ -182,7 +192,7 @@ const MESSAGES = {
182
192
  statusNone: "(none)",
183
193
  ocrCommentsNotArray: "the comments field of the OCR output is not an array, comment completeness cannot be confirmed",
184
194
  warningSeparator: ": ",
185
- 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. 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\".",
195
+ 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\".",
186
196
  lineZeroNote: "(line 0: not anchored, locate it via existing_code)",
187
197
  parseFailedWithReason: "{tool} output parsing failed: {message}\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
188
198
  parseFailedPlain: "{tool} output parsing failed\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
@@ -190,12 +200,14 @@ const MESSAGES = {
190
200
  sourceSettingsFailed: "calling the dsh settings service failed: {message}",
191
201
  sourceNamespaceUnavailable: "the dsh configuration has no {ns} namespace: the pi-ai adapter is not installed, or no provider is configured yet",
192
202
  sourceCredentialsUnavailable: "the dsh credentials service (ctx.credentials) is not wired up: cannot confirm whether a provider's API key is configured",
193
- badEnvKey: "invalid env key: {key}",
203
+ badEnvKey: "invalid env key: {key} (get-cred only accepts uppercase ref names matching ^[A-Z0-9_]+$; change the provider's apiKeyEnv in the dsh configuration to uppercase letters/digits/underscores)",
194
204
  configNotJson: "{where} is not valid JSON; the write was aborted to protect the existing configuration (fix or delete that file first): {cause}",
195
205
  configNotObject: "the top level of {where} is not a JSON object; the write was aborted to protect the existing configuration",
196
206
  providerNotInList: "provider is not in the {ns}.providers list configured by dsh: {provider}",
197
207
  modelNotInList: "model is not in the models list of {provider}: {model}",
208
+ protocolUnsupported: "the api protocol {protocol} of provider {provider} cannot be mapped to an OCR-supported protocol (openai/openai-responses/anthropic); use openai-completions/openai-responses/anthropic-messages in the dsh configuration, or upgrade this plugin",
198
209
  ocrConfigPathInvalid: "the ocrConfigPath setting must be absolute or start with ~: {path}",
210
+ configPathLayoutInvalid: "the ocrConfigPath setting must follow the <X>/.opencodereview/config.json layout: {path}. OCR has no file-level path override (it only looks at <HOME>/.opencodereview/config.json); this plugin makes it effective by injecting HOME=<X> into the ocr subprocess. A file path of any other shape would never be read by OCR",
199
211
  noApiKeyEnv: "provider {provider} declares no apiKeyEnv, so the key cannot be read dynamically (OCR custom providers do not support keyless resolution)",
200
212
  keyNotResolvable: "the API key of provider {provider} is not configured: the dsh credentials service resolved no {ref}",
201
213
  credScriptMissing: "get-cred script missing: {path} (api_key_cmd cannot work)",
@@ -234,7 +246,7 @@ const MESSAGES = {
234
246
  unitFile: "file",
235
247
  tuningConcurrency: "Concurrent {unit} count (OCR --concurrency; unset = OCR native 8)",
236
248
  tuningTimeout: "Per-{unit} task timeout in minutes (OCR --timeout; 0 = unlimited; unset = OCR native 15)",
237
- tuningMaxTools: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; unset = template default)",
249
+ tuningMaxTools: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; 1-49 is raised to 50 by OCR, help text says min 50; unset = template default)",
238
250
  tuningMaxTokens: "Per-{unit} prompt token cap (OCR --max-tokens; 0 = config/template default; unset = template default)",
239
251
  tuningMaxTokensBudget: "Total token (input+output) budget gate for this run; dispatch stops once exceeded (OCR --max-tokens-budget; 0 = unlimited; unset = unlimited)",
240
252
  previewHint: "For each reviewable file, get its diff with git diff <merge_base>..<to> -- <path> (branch) / git show <commit> -- <path> (commit) / git diff HEAD -- <path>, or read the file directly (new workspace files).",
@@ -260,9 +272,9 @@ const MESSAGES = {
260
272
  model's tool-calling ability is the problem — switch to a model with native tool calls in the ocr-review card
261
273
  (sensenova, deepseek, glm, qwen families) instead of retrying the same one.
262
274
  - LLM calls retry themselves: OCR has built-in backoff retries (429/408/409 and 5xx); per-file group failures are
263
- isolated and land in warnings instead of re-running everything. After a tool-layer timeout (10 minutes) or partial
264
- file failures, use ocr_session to find the session_id and ocr_review --resume to continue an interrupted
265
- range/commit review.
275
+ isolated and land in warnings instead of re-running everything. After a tool-layer timeout (min(request,
276
+ shell.maxTimeoutMs), 10 minutes by default) or partial file failures, use ocr_session to find the session_id and
277
+ ocr_review --resume to continue an interrupted range/commit review.
266
278
  `.trim()
267
279
  }
268
280
  };
@@ -431,13 +443,6 @@ const EFFORTS = [
431
443
  function clampEffort(value, fallback) {
432
444
  return EFFORTS.find((level) => level === value) ?? fallback;
433
445
  }
434
- /** 正整数钳制(concurrency/timeout/maxTokens 用,非正整数回 fallback)。 */
435
- function clampPositiveInt(value, fallback, min, max) {
436
- if (value === void 0 || value === null) return fallback;
437
- const parsed = typeof value === "number" ? value : Number(value);
438
- if (!Number.isSafeInteger(parsed)) return fallback;
439
- return Math.min(max, Math.max(min, parsed));
440
- }
441
446
  /** 入参 → 原始项序列(commaList 的第一段:只判「值从哪来」,不判内容)。
442
447
  * 数组分支保留逐元素 for-of 而不是 `value.map`:map 会跳过稀疏数组的空洞,
443
448
  * 空洞本是「非字符串元素」这一条该拦的形态,跳过就等于静默少一项过滤条件。 */
@@ -502,7 +507,8 @@ function resolveRoot(value, fallback, messages) {
502
507
  if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
503
508
  throw new Error(messages.repoRequiredUnknownWorkspace);
504
509
  }
505
- /** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。 */
510
+ /** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。host.ts 的入参闸
511
+ * (effort/wait)复用同一口径,故导出。 */
506
512
  function describeValue(value) {
507
513
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
508
514
  return Array.isArray(value) ? "array" : typeof value;
@@ -582,7 +588,8 @@ function pushTextFlag(parts, flag, value, messages) {
582
588
  /** --exclude/--background/--provider/--model 等通用 flag。
583
589
  * 注意:ocr 的 --exclude 是 String 类型(实测 v1.12.0 多 flag 实例只认最后一个,
584
590
  * 逗号分隔才生效)——多值必须合并为一个 flag 逗号连接,绝不能展开多个。
585
- */
591
+ * resume 不在这里:它与 scope 是互斥语义(workspace 禁恢复),由
592
+ * buildReviewCommand 在拿到 scopeFlags 结果后前置校验。 */
586
593
  function optionalFlags(args, messages) {
587
594
  const parts = [];
588
595
  const excludes = commaList(args.exclude, "exclude", 50, messages);
@@ -590,7 +597,6 @@ function optionalFlags(args, messages) {
590
597
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
591
598
  pushTextFlag(parts, "--provider", args.provider, messages);
592
599
  pushTextFlag(parts, "--model", args.model, messages);
593
- pushTextFlag(parts, "--resume", args.resume, messages);
594
600
  return parts;
595
601
  }
596
602
  /**
@@ -662,6 +668,34 @@ function ocrCommand(locate = locateLauncher, memo = memoBox) {
662
668
  return memo.value;
663
669
  }
664
670
  /**
671
+ * HOME 重定向:ocrConfigPath 设置项生效的唯一通道。OCR 的 Go 侧全部家目录定位
672
+ * (config、sessions、shell-rc 回退)都走 `os.UserHomeDir()`——Unix 上就是 `$HOME`
673
+ * 环境变量(go doc os.UserHomeDir 原文 "On Unix, including macOS, it returns the
674
+ * $HOME environment variable"),而 config 文件本身没有任何 env/flag 可改
675
+ * (config_cmd.go:92-99 的 defaultConfigPath 是唯一定位,全仓 Getenv 无 config 类
676
+ * 覆盖)。设置项命中 `<X>/.opencodereview/config.json` 布局时,本包把 X 传进来,
677
+ * 在命令串前加 POSIX 赋值前缀 `HOME=<X>`——OCR 及其全部子孙(含 api_key_cmd 起的
678
+ * get-cred)就在重定向后的家目录里读 config、写 sessions,world 自洽。
679
+ *
680
+ * home 为 undefined(默认路径)时不加前缀,命令串一字不动。win32 没有 POSIX
681
+ * 赋值前缀与 HOME 语义(且 resolveOcrConfigPath 对自定义值的布局校验两平台一致),
682
+ * 与 wrapWithHostReaper 同款按平台放行。
683
+ */
684
+ function withHomeEnv(command, home) {
685
+ if (home === void 0 || process.platform === "win32") return command;
686
+ return `HOME=${shq(home)} ${command}`;
687
+ }
688
+ /**
689
+ * `ocr llm test` 命令词(select 的 autoVerify 与卡片「测试连接」共用)。
690
+ * 必须与五个工具走同一个解析源(ocrCommand()):随包 launcher 装法下 PATH 上没有
691
+ * 裸 `ocr`,硬编码字符串会让 llm test 永远 exit 127 而评审照常工作——autoVerify
692
+ * 从此没有一次能通过,还误导用户去装全局包。`--color` 是 root 持久 flag
693
+ * (color.go:35-39),子命令合法。
694
+ */
695
+ function buildLlmTestCommand() {
696
+ return `${ocrCommand()} llm test --color never`;
697
+ }
698
+ /**
665
699
  * 构造 `ocr review` 命令。--output 固定为调用方给定的临时文件路径(防截断),
666
700
  * --audience agent --format json 是默认(结构化、无进度行)。
667
701
  * outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。
@@ -684,7 +718,13 @@ function buildReviewCommand(args, outputPath, messages) {
684
718
  pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
685
719
  pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
686
720
  pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
687
- parts.push(...scopeFlags(args, messages), ...optionalFlags(args, messages));
721
+ const scopeParts = scopeFlags(args, messages);
722
+ parts.push(...scopeParts, ...optionalFlags(args, messages));
723
+ const resume = idValue(args.resume, "resume", messages);
724
+ if (resume !== "") {
725
+ if (scopeParts.length === 0) throw new Error(messages.resumeNeedsScope);
726
+ parts.push("--resume", shq(resume));
727
+ }
688
728
  if (outputPath !== void 0) parts.push("--output", shq(outputPath));
689
729
  return {
690
730
  command: parts.join(" "),
@@ -710,6 +750,8 @@ function buildScanCommand(args, outputPath, messages) {
710
750
  const excludes = commaList(args.exclude, "exclude", 50, messages);
711
751
  if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
712
752
  pushTextFlag(parts, "--batch", args.batch, messages);
753
+ pushTextFlag(parts, "--provider", args.provider, messages);
754
+ pushTextFlag(parts, "--model", args.model, messages);
713
755
  pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
714
756
  pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
715
757
  pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
@@ -769,6 +811,18 @@ const SESSION_ACTIONS = [
769
811
  "show",
770
812
  "comments"
771
813
  ];
814
+ /** session list --limit:缺省 10(OCR 原生默认 20,本包收紧;OCR 的 0=不限在本包
815
+ * 上限 100 下无意义)。显式给出时必须是 1-100 的整数(与工具 description 的承诺
816
+ * 一致),越界/错类型抛错而不是钳制——静默钳到 1 会把「想看更多」的 0 变成
817
+ * 「只看一条」。数字字符串接受("3"),与 pushIntFlag 的数值槽位同一口径。 */
818
+ function sessionLimit(value, messages) {
819
+ if (value === void 0 || value === null) return 10;
820
+ let num = NaN;
821
+ if (typeof value === "number") num = value;
822
+ else if (typeof value === "string") num = Number(value);
823
+ if (!Number.isInteger(num) || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
824
+ return num;
825
+ }
772
826
  /** 构造 `ocr session list/show/comments` 命令(历史评审会话闭环,Json 输出)。 */
773
827
  function buildSessionCommand(args, messages) {
774
828
  const repo = assertAbsoluteRoot(args.repo, messages);
@@ -779,19 +833,18 @@ function buildSessionCommand(args, messages) {
779
833
  action,
780
834
  expected: SESSION_ACTIONS.join("/")
781
835
  }));
836
+ if (requested === "list" && id !== "") throw new Error(messages.listRejectsId);
782
837
  let sub;
783
838
  if (requested === "comments" || requested === "show") sub = id === "" ? "list" : requested;
839
+ else if (requested === "list") sub = "list";
784
840
  else sub = id === "" ? "list" : "show";
785
841
  const parts = [
786
842
  ocrCommand(),
787
843
  "session",
788
844
  sub
789
845
  ];
790
- if (sub === "list") {
791
- parts.push("--json", "--repo", shq(repo));
792
- const limit = clampPositiveInt(args.limit, 10, 1, 100);
793
- parts.push("--limit", String(limit));
794
- } else parts.push("--json", "--repo", shq(repo), shq(id));
846
+ if (sub === "list") parts.push("--json", "--repo", shq(repo), "--limit", String(sessionLimit(args.limit, messages)));
847
+ else parts.push("--json", "--repo", shq(repo), shq(id));
795
848
  return {
796
849
  command: parts.join(" "),
797
850
  workdir: repo
@@ -840,14 +893,21 @@ function aggregateComments(comments) {
840
893
  //#endregion
841
894
  //#region lib/parse.ts
842
895
  /**
843
- * OCR 评审 JSON 的 status 枚举(docs/cli-reference + 1.12.0 实测)。
896
+ * OCR 评审 JSON 的 status 枚举(1.12.x 源码核对:cmd/opencodereview/output.go 的
897
+ * jsonOutput 装配 + internal/session/manifest.go:166-169 的 TerminalState)。
844
898
  * 白名单外的一律视为失败:exit 0 不等于评审成功——`{"error":"auth failed"}`
845
899
  * 这类「没有 status 的错误对象」若被当作 ok:true,模型会把「0 条评论」读成
846
900
  * 「代码干净」,是最坏的一类假阳性。
901
+ *
902
+ * partial 是 manifest 终态之一(StatePartial):部分文件失败但整体有覆盖、以及
903
+ * --max-tokens-budget 截断后仍有覆盖,都以 exit 0 + status "partial" 输出
904
+ * (review_cmd.go 的退出注释 "complete/partial/skipped all succeed")。此前白名单
905
+ * 漏了它,整场已花费 token 的评审被误报成「status 非法」并丢弃全部评论。
847
906
  */
848
907
  const REVIEW_STATUSES = [
849
908
  "success",
850
909
  "complete",
910
+ "partial",
851
911
  "completed_with_warnings",
852
912
  "completed_with_errors",
853
913
  "skipped"
@@ -1103,6 +1163,22 @@ const ROUTING_ORDER = 1555;
1103
1163
  //#region lib/provider-projection.ts
1104
1164
  /** llm-pi-ai 命名空间里 provider 表的字段名。 */
1105
1165
  const PROVIDERS_FIELD = "providers";
1166
+ /**
1167
+ * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
1168
+ *
1169
+ * 两侧枚举的对照(dsh packages/llm/llm-pi-ai/src/provider.ts:44-48 的 PROTOCOLS
1170
+ * 对 OCR config_cmd.go:796 的 "Protocol values: anthropic, anthropic-bedrock,
1171
+ * openai, openai-responses"):openai-completions 与 OCR 的 openai 是同一条
1172
+ * OpenAI Chat Completions 线协议;anthropic-messages 即 OCR 的 anthropic。
1173
+ * 此前「非 openai-responses 一律 openai」的二值映射会把 anthropic-messages 的
1174
+ * 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
1175
+ * renderSelectedConfig 的白名单给出可读错误。
1176
+ */
1177
+ const OCR_PROTOCOL_BY_API = {
1178
+ "openai-completions": "openai",
1179
+ "openai-responses": "openai-responses",
1180
+ "anthropic-messages": "anthropic"
1181
+ };
1106
1182
  /** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
1107
1183
  function modelFromRaw(modelRaw) {
1108
1184
  if (!isRecord(modelRaw)) return null;
@@ -1119,7 +1195,8 @@ function providerFromRaw(name, raw) {
1119
1195
  if (!isRecord(raw)) return null;
1120
1196
  const entry = raw;
1121
1197
  const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
1122
- const protocol = entry["api"] === "openai-responses" ? "openai-responses" : "openai";
1198
+ const api = typeof entry["api"] === "string" ? entry["api"] : "";
1199
+ const protocol = api === "" ? "openai" : OCR_PROTOCOL_BY_API[api] ?? api;
1123
1200
  const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"] : "";
1124
1201
  if (!baseURL) return null;
1125
1202
  const models = Array.isArray(entry["models"]) ? entry["models"].map(modelFromRaw).filter((model) => model !== null) : [];
@@ -1135,7 +1212,7 @@ function providerFromRaw(name, raw) {
1135
1212
  /**
1136
1213
  * settings 命名空间解析值 → provider 列表(纯投影,不含任何 key 值)。
1137
1214
  * 上游形状(settings 里 llm-pi-ai.providers.<name>):
1138
- * { displayName?, apiKeyEnv?, api: openai-completions|openai-responses,
1215
+ * { displayName?, apiKeyEnv?, api: openai-completions|openai-responses|anthropic-messages,
1139
1216
  * baseURL, models?: [{id,name,…}] }
1140
1217
  * 形状不合的条目整条丢弃(读侧宽容:卡片要能打开让用户去修),写侧才 fail-loud。
1141
1218
  */
@@ -1257,10 +1334,22 @@ function readText(configPath) {
1257
1334
  function shqDyn(value) {
1258
1335
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
1259
1336
  }
1260
- /** api_key_cmd 值构造:把脚本路径与 env key 名 shq 转义后拼成 shell 命令。 */
1261
- function buildApiKeyCmd(scriptPath, envKey, messages) {
1337
+ /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
1338
+ * 拼成 shell 命令。第三参数是 get-cred.mjs 数据目录定位的**第一档**(显式参数 >
1339
+ * $DSH_HOME > homedir()/.dsh):ocrConfigPath 重定向 ocr 子进程的 HOME 后,
1340
+ * get-cred 的 homedir() 兜底会跟着跑偏,须在宿主侧把解析出的数据目录显式钉进
1341
+ * 命令。undefined(未重定向)时省略,沿用 env/默认档。
1342
+ * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
1343
+ * 惯例大写,且这是写进 config.json 的可信输入)。 */
1344
+ function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
1262
1345
  if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
1263
- return `node ${shqDyn(scriptPath)} ${shqDyn(envKey)}`;
1346
+ const parts = [
1347
+ "node",
1348
+ shqDyn(scriptPath),
1349
+ shqDyn(envKey)
1350
+ ];
1351
+ if (dshHome !== void 0) parts.push(shqDyn(dshHome));
1352
+ return parts.join(" ");
1264
1353
  }
1265
1354
  /** 现有 config 文本 → 盘面对象。
1266
1355
  *
@@ -1321,6 +1410,16 @@ async function writeConfigAtomic(targetPath, text) {
1321
1410
  dirMode: 448
1322
1411
  });
1323
1412
  }
1413
+ /** 本包允许写进 custom_providers.<name>.protocol 的白名单(OCR config_cmd.go:796
1414
+ * "Protocol values: anthropic, anthropic-bedrock, openai, openai-responses")。
1415
+ * 本包只会经 provider-projection 的映射表产出前三种;anthropic-bedrock 无对应的
1416
+ * llm-pi-ai 协议、不由本包写出。表外值 = 投影透传的未知 `api`:拒绝写入,绝不落
1417
+ * 一份 OCR 解析不了的配置(此前二值映射会把 anthropic-messages 静默写成 openai)。 */
1418
+ const WRITABLE_PROTOCOLS = /* @__PURE__ */ new Set([
1419
+ "openai",
1420
+ "openai-responses",
1421
+ "anthropic"
1422
+ ]);
1324
1423
  /**
1325
1424
  * 生成「选择 provider+model」后的 config.json 全文。
1326
1425
  * - provider 顶层设为目标名;custom_providers.<name> 写 url/protocol/model/
@@ -1340,6 +1439,10 @@ function renderSelectedConfig(input, messages) {
1340
1439
  provider: input.provider,
1341
1440
  model: input.model
1342
1441
  }));
1442
+ if (!WRITABLE_PROTOCOLS.has(target.protocol)) throw new Error(format(messages.protocolUnsupported, {
1443
+ provider: input.provider,
1444
+ protocol: target.protocol
1445
+ }));
1343
1446
  const customsRaw = fieldOf(doc, "custom_providers");
1344
1447
  const customs = isRecord(customsRaw) ? customsRaw : {};
1345
1448
  const existingRaw = fieldOf(customs, input.provider);
@@ -1391,19 +1494,44 @@ function withoutKeyFlags(providers) {
1391
1494
  }));
1392
1495
  }
1393
1496
  /**
1497
+ * 自定义路径必须呈 `<X>/.opencodereview/config.json` 布局时返回 X(= 重定向给
1498
+ * ocr 子进程的 HOME),否则 null。OCR 端定位 config 只有一条路
1499
+ * `<HOME>/.opencodereview/config.json`(config_cmd.go:92-99,无 env/flag),本包
1500
+ * 让自定义值生效的唯一通道是 HOME 注入(lib/cli.ts withHomeEnv)——任意形状的
1501
+ * 文件路径写下去 OCR 也读不到,只会静默失配,必须在设置层就拒绝。
1502
+ */
1503
+ function layoutHomeOf(configPath) {
1504
+ const parent = path.dirname(configPath);
1505
+ if (path.basename(configPath) !== OCR_CONFIG_FILE || path.basename(parent) !== OCR_CONFIG_DIR) return null;
1506
+ return path.dirname(parent);
1507
+ }
1508
+ /**
1394
1509
  * OCR 配置文件路径:设置项 ocrConfigPath 覆盖,未覆盖时按 os.homedir() 派生。
1395
1510
  * 家目录展开交官方 `expandHomePath`,但本地保留两条它没有的判据(见
1396
1511
  * `expandHomeForSeparator` 与下面的绝对性闸门):直写相对路径会把配置落到
1397
- * 「当时的工作区」,用户找不到也修不了。
1512
+ * 「当时的工作区」,用户找不到也修不了。自定义值还必须是
1513
+ * `<X>/.opencodereview/config.json` 布局(见 layoutHomeOf)。
1398
1514
  */
1399
1515
  function resolveOcrConfigPath(configured, messages) {
1400
1516
  const trimmed = typeof configured === "string" ? configured.trim() : "";
1401
1517
  if (trimmed === "") return path.join(homedir(), OCR_CONFIG_DIR, OCR_CONFIG_FILE);
1402
1518
  const expanded = expandHomeForSeparator(trimmed, path.sep);
1403
1519
  if (!path.isAbsolute(expanded)) throw new Error(format(messages.ocrConfigPathInvalid, { path: trimmed }));
1520
+ if (layoutHomeOf(expanded) === null) throw new Error(format(messages.configPathLayoutInvalid, { path: trimmed }));
1404
1521
  return expanded;
1405
1522
  }
1406
1523
  /**
1524
+ * ocr 子进程的 HOME 重定向值:设置项命中 `<X>/.opencodereview/config.json` 布局时
1525
+ * 返回 X,否则 undefined(默认路径零重定向)。**绝不抛错**——布局/绝对性不合法时
1526
+ * 返回 undefined,让工具执行不受坏设置拖累(错误由 resolveOcrConfigPath 在端点侧
1527
+ * 报出,卡片显示原因,「工具与设置照常可用」的既有口径不变)。
1528
+ */
1529
+ function ocrHomeOverride(configured) {
1530
+ const trimmed = typeof configured === "string" ? configured.trim() : "";
1531
+ const expanded = trimmed === "" ? "" : expandHomeForSeparator(trimmed, path.sep);
1532
+ return (path.isAbsolute(expanded) ? layoutHomeOf(expanded) : null) ?? void 0;
1533
+ }
1534
+ /**
1407
1535
  * 组装设置页数据(绝不返回 key 明文;credentials 缺席时 hasKey 全 false 并给出原因,
1408
1536
  * 让卡片显示「为什么不可用」而不是假装每个 provider 都没配 key)。
1409
1537
  */
@@ -1438,7 +1566,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1438
1566
  }));
1439
1567
  const scriptPath = credentialScriptPath(paths);
1440
1568
  if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
1441
- const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, messages);
1569
+ const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, resolveDshHome(), messages);
1442
1570
  await updateConfigLocked(paths.ocrConfigJson, async () => {
1443
1571
  const next = renderSelectedConfig({
1444
1572
  existingText: readText(paths.ocrConfigJson),
@@ -1463,11 +1591,11 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1463
1591
  * api_key_cmd 并删除明文,返回 'migrated';找不到 envKey 或构造命令失败 →
1464
1592
  * 'unknown';无明文 → 'noPlain'(不动作)。
1465
1593
  */
1466
- function migrateEntry(entry, envKey, scriptPath, messages) {
1594
+ function migrateEntry(entry, envKey, scriptPath, dshHome, messages) {
1467
1595
  if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
1468
1596
  if (envKey === void 0 || envKey === "") return "unknown";
1469
1597
  try {
1470
- entry["api_key_cmd"] = buildApiKeyCmd(scriptPath, envKey, messages);
1598
+ entry["api_key_cmd"] = buildApiKeyCmd(scriptPath, envKey, dshHome, messages);
1471
1599
  } catch {
1472
1600
  return "unknown";
1473
1601
  }
@@ -1498,6 +1626,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1498
1626
  migrated: [],
1499
1627
  skippedUnknown: []
1500
1628
  };
1629
+ const dshHome = resolveDshHome();
1501
1630
  const envByProvider = /* @__PURE__ */ new Map();
1502
1631
  for (const providerCfg of providers) if (providerCfg.apiKeyEnv !== null && providerCfg.apiKeyEnv !== "") envByProvider.set(providerCfg.name, providerCfg.apiKeyEnv);
1503
1632
  return await updateConfigLocked(paths.ocrConfigJson, async () => {
@@ -1511,7 +1640,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1511
1640
  const migrated = [];
1512
1641
  const skippedUnknown = [];
1513
1642
  for (const [name, raw] of Object.entries(customs)) if (isRecord(raw)) {
1514
- const result = migrateEntry(raw, envByProvider.get(name), scriptPath, messages);
1643
+ const result = migrateEntry(raw, envByProvider.get(name), scriptPath, dshHome, messages);
1515
1644
  if (result === "migrated") migrated.push(name);
1516
1645
  else if (result === "unknown") skippedUnknown.push(name);
1517
1646
  }
@@ -1755,6 +1884,7 @@ const REVIEW_OUTPUT_SCHEMA = { oneOf: [
1755
1884
  enum: [
1756
1885
  "success",
1757
1886
  "complete",
1887
+ "partial",
1758
1888
  "completed_with_warnings",
1759
1889
  "completed_with_errors",
1760
1890
  "skipped"
@@ -1852,6 +1982,7 @@ const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
1852
1982
  required: [
1853
1983
  "mode",
1854
1984
  "repository",
1985
+ "mergeBase",
1855
1986
  "reviewableCount",
1856
1987
  "insertions",
1857
1988
  "deletions",
@@ -1862,6 +1993,7 @@ const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
1862
1993
  properties: {
1863
1994
  mode: { type: "string" },
1864
1995
  repository: { type: "string" },
1996
+ mergeBase: NULLABLE_STRING,
1865
1997
  reviewableCount: { type: "integer" },
1866
1998
  insertions: { type: "number" },
1867
1999
  deletions: { type: "number" },
@@ -2281,18 +2413,6 @@ function readOutFile(outPath, messages) {
2281
2413
  }), { cause: error });
2282
2414
  }
2283
2415
  }
2284
- /** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
2285
- * 墙钟与输出上限都是 entry config 字段(非 volatile,改值随重启生效)。 */
2286
- async function runLlmTest(host, config) {
2287
- const { exitCode, output } = await runCollect(host, "ocr llm test --color never", process.env["HOME"] ?? "/", {
2288
- timeoutMs: config.llmTestTimeoutMs,
2289
- stdoutMaxBytes: config.stdoutMaxBytes
2290
- });
2291
- return {
2292
- ok: exitCode === 0,
2293
- output
2294
- };
2295
- }
2296
2416
  /**
2297
2417
  * OCR 原生数值参数的 tool schema 片段(忠实透传语义,桥接层不设钳制)。
2298
2418
  * unit 区分 review(每组)/ scan(每文件)的措辞(取消息表的 unitGroup/unitFile,
@@ -2340,12 +2460,54 @@ function readOcrConfigPath(config) {
2340
2460
  /** 本包剩下的文件路径:只剩外部 ocr CLI 的 config.json(可被设置项覆盖)+ 脚本位置。
2341
2461
  * messages 只用于「设置项里的路径不合形态」那条可读错误。 */
2342
2462
  function pluginPaths(config, messages) {
2463
+ const configured = readOcrConfigPath(config);
2343
2464
  return {
2344
- ocrConfigJson: resolveOcrConfigPath(readOcrConfigPath(config), messages),
2465
+ ocrConfigJson: resolveOcrConfigPath(configured, messages),
2466
+ homeOverride: ocrHomeOverride(configured),
2345
2467
  pluginDir: PLUGIN_DIR,
2346
2468
  getCredScript: defaultCredScriptPath(PLUGIN_DIR)
2347
2469
  };
2348
2470
  }
2471
+ /**
2472
+ * ocr 子进程的 HOME 重定向值(工具执行侧专用,**不抛错**):设置项命中
2473
+ * `<X>/.opencodereview/config.json` 布局 → X;未设置/坏设置 → undefined。工具不
2474
+ * 走 pluginPaths(那会在坏设置下把五个工具一并拖死),坏设置的错误由端点侧的
2475
+ * resolveOcrConfigPath 报给卡片——「工具与设置照常可用」的口径不变。
2476
+ */
2477
+ function ocrHome(config) {
2478
+ return ocrHomeOverride(readOcrConfigPath(config));
2479
+ }
2480
+ /** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
2481
+ * 命令词与五个工具同一解析源(buildLlmTestCommand:随包 launcher 装法下 PATH 上
2482
+ * 没有裸 `ocr`),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry config
2483
+ * 字段(非 volatile,改值随重启生效)。 */
2484
+ async function runLlmTest(host, config) {
2485
+ const { exitCode, output } = await runCollect(host, withHomeEnv(buildLlmTestCommand(), ocrHome(config)), process.env["HOME"] ?? "/", {
2486
+ timeoutMs: config.llmTestTimeoutMs,
2487
+ stdoutMaxBytes: config.stdoutMaxBytes
2488
+ });
2489
+ return {
2490
+ ok: exitCode === 0,
2491
+ output
2492
+ };
2493
+ }
2494
+ /** effort 入参闸:undefined/null → 设置卡默认;字符串 → 白名单钳制(未知值回落
2495
+ * medium,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
2496
+ function effortOf(value, config, messages) {
2497
+ if (value === void 0 || value === null) return readEffort(config);
2498
+ if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
2499
+ name: "effort",
2500
+ received: describeValue(value)
2501
+ }));
2502
+ return clampEffort(value, "medium");
2503
+ }
2504
+ /** wait 入参闸:undefined → true(前台缺省);布尔透传;其余类型抛错——字符串
2505
+ * "false" 若被 `!== false` 当真值处理会静默从后台变前台,吞掉用户的等待语义。 */
2506
+ function waitOf(value, messages) {
2507
+ if (value === void 0 || value === null) return true;
2508
+ if (typeof value !== "boolean") throw new TypeError(format(messages.waitMustBeBoolean, { received: describeValue(value) }));
2509
+ return value;
2510
+ }
2349
2511
  /** 服务界面齐备才算可用(get() 可能返回半装配对象,也可能返回 undefined)。 */
2350
2512
  function usableService(value, methods) {
2351
2513
  return value !== void 0 && hasMethods(value, methods) ? value : void 0;
@@ -2608,16 +2770,17 @@ function registerReviewTool(host, config) {
2608
2770
  run: async (args, exec) => {
2609
2771
  const runMsgs = localeMessages(host);
2610
2772
  const repo = ocrRepo(args, exec, runMsgs);
2611
- const eff = args["effort"] === void 0 ? readEffort(config) : args["effort"];
2612
- const tmp = args["wait"] !== false ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
2773
+ const eff = effortOf(args["effort"], config, runMsgs);
2774
+ const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
2613
2775
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
2776
+ const homeOverride = ocrHome(config);
2614
2777
  try {
2615
2778
  const { command, workdir } = buildReviewCommand({
2616
2779
  ...args,
2617
2780
  repo,
2618
2781
  effort: eff
2619
2782
  }, outPath, runMsgs);
2620
- const guarded = wrapWithHostReaper(command);
2783
+ const guarded = wrapWithHostReaper(withHomeEnv(command, homeOverride));
2621
2784
  if (outPath === void 0) return await startBackground(host, {
2622
2785
  command: guarded,
2623
2786
  workdir,
@@ -2681,6 +2844,14 @@ function registerScanTool(host, config) {
2681
2844
  ],
2682
2845
  description: msgs.paramBatch
2683
2846
  },
2847
+ provider: {
2848
+ type: "string",
2849
+ description: msgs.paramProvider
2850
+ },
2851
+ model: {
2852
+ type: "string",
2853
+ description: msgs.paramModel
2854
+ },
2684
2855
  ...tuningParams(msgs, msgs.unitFile),
2685
2856
  wait: {
2686
2857
  type: "boolean",
@@ -2692,14 +2863,15 @@ function registerScanTool(host, config) {
2692
2863
  run: async (args, exec) => {
2693
2864
  const runMsgs = localeMessages(host);
2694
2865
  const repo = ocrRepo(args, exec, runMsgs);
2695
- const tmp = args["wait"] !== false ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
2866
+ const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
2696
2867
  const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
2868
+ const homeOverride = ocrHome(config);
2697
2869
  try {
2698
2870
  const { command, workdir } = buildScanCommand({
2699
2871
  ...args,
2700
2872
  repo
2701
2873
  }, outPath, runMsgs);
2702
- const guarded = wrapWithHostReaper(command);
2874
+ const guarded = wrapWithHostReaper(withHomeEnv(command, homeOverride));
2703
2875
  if (outPath === void 0) return await startBackground(host, {
2704
2876
  command: guarded,
2705
2877
  workdir,
@@ -2776,11 +2948,12 @@ function registerDelegatePreviewTool(host, config) {
2776
2948
  run: async (args, exec) => {
2777
2949
  const runMsgs = localeMessages(host);
2778
2950
  const repo = ocrRepo(args, exec, runMsgs);
2951
+ const homeOverride = ocrHome(config);
2779
2952
  const { command, workdir } = buildDelegatePreviewCommand({
2780
2953
  ...args,
2781
2954
  repo
2782
2955
  }, runMsgs);
2783
- const text = await runForeground(host, command, workdir, tuningOf(config), exec.signal);
2956
+ const text = await runForeground(host, withHomeEnv(command, homeOverride), workdir, tuningOf(config), exec.signal);
2784
2957
  const parsed = parseDelegatePreview(text);
2785
2958
  if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
2786
2959
  tool: "ocr delegate preview",
@@ -2790,6 +2963,7 @@ function registerDelegatePreviewTool(host, config) {
2790
2963
  return {
2791
2964
  mode: parsed.mode,
2792
2965
  repository: parsed.repository,
2966
+ mergeBase: parsed.mergeBase === "" ? null : parsed.mergeBase,
2793
2967
  reviewableCount: parsed.reviewable.length,
2794
2968
  insertions: parsed.reviewable.reduce((acc, file) => acc + file.insertions, 0),
2795
2969
  deletions: parsed.reviewable.reduce((acc, file) => acc + file.deletions, 0),
@@ -2828,7 +3002,7 @@ function registerDelegateRuleTool(host, config) {
2828
3002
  repo: ocrRepo(args, exec, runMsgs),
2829
3003
  paths: args["paths"]
2830
3004
  }, runMsgs);
2831
- const text = await runForeground(host, command, workdir, tuningOf(config), exec.signal);
3005
+ const text = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
2832
3006
  const parsed = parseDelegateRules(text);
2833
3007
  if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
2834
3008
  tool: "ocr delegate rule",
@@ -2888,7 +3062,7 @@ function registerSessionTool(host, config) {
2888
3062
  ...args,
2889
3063
  repo
2890
3064
  }, runMsgs);
2891
- const text = await runForeground(host, command, workdir, tuningOf(config), exec.signal);
3065
+ const text = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
2892
3066
  const stripped = text.trim();
2893
3067
  const empty = stripped === "null" || stripped === "[]";
2894
3068
  const capped = text.length > 12e3 ? `${echoRaw(text, 11900)}\n${runMsgs.sessionTruncated}` : redactKeyMaterial(text);