dsh-any-agent 0.5.0 → 0.5.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/src/tools.mjs CHANGED
@@ -12,12 +12,15 @@ import {
12
12
  DEFAULT_PERMISSION,
13
13
  DEFAULT_REASONING,
14
14
  DEFAULT_REASONING_EFFORT,
15
+ DEFAULT_REVIEW,
15
16
  IDLE_TTL_MS,
16
17
  MAX_LIVE,
17
18
  MAX_RSS_MB,
18
19
  PERMISSION_TIERS,
19
20
  REASONING_EFFORTS,
20
21
  REASONING_MODES,
22
+ REVIEW_MODES,
23
+ REVIEWS_DIR,
21
24
  RUNS_DIR,
22
25
  SHARED_PROCESS,
23
26
  STATE_FILE,
@@ -26,11 +29,14 @@ import {
26
29
  DSH_PROFILE,
27
30
  toPosixPath,
28
31
  resolveDshBin,
32
+ dshVersionStatus,
33
+ isNpxCachePath,
29
34
  } from './config.mjs';
30
35
  import { inspectProfile } from './bootstrap.mjs';
31
36
  import { audit } from './audit.mjs';
32
37
  import { listOrphans, LOCKS_DIR } from './locks.mjs';
33
38
  import { rssMb } from './procstat.mjs';
39
+ import { REVIEW_VERDICTS, normalizeReviewMode } from './review.mjs';
34
40
 
35
41
  const S = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false });
36
42
 
@@ -93,8 +99,9 @@ export const TOOL_DEFS = [
93
99
  },
94
100
  title_hint: {
95
101
  type: 'string',
102
+ maxLength: 200,
96
103
  description:
97
- '可读的会话标签。注意 DSH 自己的标题是首条消息的截断前缀(约 13–15 字),会优先显示;' +
104
+ '可读的会话标签(最长 200 字)。注意 DSH 自己的标题是首条消息的截断前缀(约 13–15 字),会优先显示;' +
98
105
  '这里给的值不会被覆盖,而是在 dsh_get / dsh_start 里作为「标题提示」一并返回。',
99
106
  },
100
107
  reasoning_effort: {
@@ -102,6 +109,13 @@ export const TOOL_DEFS = [
102
109
  enum: REASONING_EFFORTS,
103
110
  description: `思考深度,默认 ${DEFAULT_REASONING_EFFORT}。注意 ACP 自身的默认是"Provider default"(不设),本服务会显式设成 max。想省钱可调低。`,
104
111
  },
112
+ review: {
113
+ type: 'string',
114
+ enum: REVIEW_MODES,
115
+ description:
116
+ `调用方审核策略,默认 ${DEFAULT_REVIEW}。advisory:每次派活的结果/哨兵都会附审核单(核验清单 + 证据 + dsh_review 入口),` +
117
+ '但**不阻塞**后续派活;off:不生成审核单。可被单次 dsh_send 覆盖。',
118
+ },
105
119
  provider: { type: 'string', description: '模型提供方名(如 deepseek-official)。留空用 profile 默认。' },
106
120
  model: { type: 'string', description: '模型名(如 deepseek-flash)。指定时必须同时给 provider。' },
107
121
  keep_alive: {
@@ -128,10 +142,11 @@ export const TOOL_DEFS = [
128
142
  '而是自动降级为后台:返回 run_id + sentinel_file,回合继续跑完,结果稍后由哨兵文件通知。' +
129
143
  '(timeout_ms=0 表示一直等。)所以任何时候你都能拿到结果,不用怕"超时了白干"。' +
130
144
  '等待模式与后台模式都在回合结束时原子落地一个含最终结果的小文件——' +
131
- '**推荐用后台任务(Bash run_in_background + `until [ -f sentinel_file ]`)等它,跑完自动通知你,无需轮询**;' +
145
+ '**推荐用后台任务(Bash run_in_background + `until [ -f sentinel_file ]`;Windows/PowerShell 用 `Test-Path` 循环,收据里给出现成命令)等它,跑完自动通知你,无需轮询**;' +
132
146
  '文件出现后读它即得结果,或用 dsh_get(conversation_id, run_id) 验收,dsh_read 可中途看输出。' +
133
147
  '默认隐藏思考内容(只返回正文 + 思考统计)。' +
134
- '若会话正在忙,本工具会报错——改用 dsh_interject 插话,或用 dsh_interrupt 先打断。',
148
+ '结果与后台收据都会带 `review` 审核单(默认 advisory):调用方核验后用 dsh_review 提交裁决。' +
149
+ '若会话正在忙,本工具会报错——改用 dsh_interject 插话、dsh_send(queue=true) 入队,或用 dsh_interrupt 先打断。',
135
150
  inputSchema: S(
136
151
  {
137
152
  conversation_id: CONV_ID,
@@ -151,6 +166,12 @@ export const TOOL_DEFS = [
151
166
  description:
152
167
  '仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
153
168
  },
169
+ review: {
170
+ type: 'string',
171
+ enum: REVIEW_MODES,
172
+ description:
173
+ '本次派活的审核策略,覆盖会话默认(默认 advisory)。advisory:结果/哨兵附审核单,提示调用方独立核验后用 dsh_review 提交裁决;off:本次不附。',
174
+ },
154
175
  queue: {
155
176
  type: 'boolean',
156
177
  description:
@@ -159,6 +180,7 @@ export const TOOL_DEFS = [
159
180
  },
160
181
  client_run_id: {
161
182
  type: 'string',
183
+ maxLength: 128,
162
184
  description:
163
185
  '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交同一个键不会创建第二个 run,' +
164
186
  '而是返回首次 run 的结果或收据(replayed=true)。用于"响应在传输层丢了、想安全重发"的场景。',
@@ -218,26 +240,56 @@ export const TOOL_DEFS = [
218
240
  description:
219
241
  '查看单个会话的详情;带 run_id 时**取那一次派活的最终结果**(异步模式的验收入口)。' +
220
242
  '不传 run_id 则取最近一次回合。返回:标题、工作区、权限档、状态、轮数、token 用量、' +
221
- '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)。' +
222
- '**run 记录只在内存、最多保留最近 20 条**;服务重启或记录被挤掉后请改读哨兵文件(它落盘且扛重启)。',
243
+ '上下文压力、待决审批,以及该回合的完整结果(正文/工具/用量/错误)和 **review 审核单**。' +
244
+ '**run 记录只在内存、最多保留最近 20 条**;服务重启或记录被挤掉后会从哨兵文件回读(它落盘且扛重启)。',
223
245
  inputSchema: S(
224
246
  {
225
247
  conversation_id: CONV_ID,
226
- run_id: { type: 'string', description: 'dsh_send 返回的 run_id;不传则取最近一次回合。' },
248
+ run_id: { type: 'string', maxLength: 200, description: 'dsh_send 返回的 run_id;不传则取最近一次回合。' },
227
249
  client_run_id: {
228
250
  type: 'string',
251
+ maxLength: 128,
229
252
  description: '按调用方幂等键取 run(与 run_id 二选一;只对仍在内存里的 run 有效,最多最近 20 条)。',
230
253
  },
231
254
  max_chars: {
232
255
  type: 'number',
233
256
  description:
234
- '正文在 **text** 里的最大字符数(默认 0 = 不截断)。' +
235
- 'structured.run.result 仍保留完整内容;截断时附带 answer_truncated/next_offset,可自行分页。',
257
+ '结果预算(默认 0 = 不截断,返回完整结果)。>0 时**同时**作用于 text 与 structured:' +
258
+ '正文截到该长度(附 answer_truncated/next_offset),structured.run.result.tools_used 只保留' +
259
+ '工具名/状态与 input_chars,不再带完整输入。完整结果始终在 sentinel_file 里。' +
260
+ '一个回合用了很多工具时,默认返回可能超过客户端单次工具结果上限,建议传 8000 左右。',
236
261
  },
237
262
  },
238
263
  ['conversation_id'],
239
264
  ),
240
265
  },
266
+ {
267
+ name: 'dsh_review',
268
+ description:
269
+ '提交对一次回合结果的**调用方审核裁决**。dsh_send / dsh_interject / 哨兵里的 result 会带 `review` 审核单' +
270
+ '(核验清单 + 证据摘要 + review_id)。在把结果当作完成之前,调用方应自己核验(读改动的文件、看 git diff、跑测试等),' +
271
+ '再调用本工具记录 pass / partial / fail。' +
272
+ '**默认是建议式审核**:不阻塞后续 dsh_send,也不会自动把审核意见回灌给 DSH;需要修就再 dsh_send。' +
273
+ 'run 记录被内存挤出或服务重启后仍可提交(以哨兵文件为准)。',
274
+ inputSchema: S(
275
+ {
276
+ conversation_id: CONV_ID,
277
+ run_id: { type: 'string', maxLength: 200, description: '要审核的那次派活(审核单里的 run_id)。' },
278
+ verdict: {
279
+ type: 'string',
280
+ enum: REVIEW_VERDICTS,
281
+ description: 'pass=可直接采用;partial=部分可用、需补充;fail=不可用、需重做。fail/partial 必须说明理由。',
282
+ },
283
+ notes: { type: 'string', description: '审核理由。fail/partial 必填(或改用 findings),最多 8000 字。' },
284
+ findings: {
285
+ type: 'array',
286
+ items: { type: 'string' },
287
+ description: '逐条问题清单,可选;最多 50 条、每条 2000 字。',
288
+ },
289
+ },
290
+ ['conversation_id', 'run_id', 'verdict'],
291
+ ),
292
+ },
241
293
  {
242
294
  name: 'dsh_interject',
243
295
  description:
@@ -261,8 +313,14 @@ export const TOOL_DEFS = [
261
313
  description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
262
314
  },
263
315
  timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
316
+ review: {
317
+ type: 'string',
318
+ enum: REVIEW_MODES,
319
+ description: '本次插话回合的审核策略,覆盖会话默认(默认 advisory);off 可关闭本次审核单。',
320
+ },
264
321
  client_run_id: {
265
322
  type: 'string',
323
+ maxLength: 128,
266
324
  description: '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交返回首次的 run,不会重复插话。',
267
325
  },
268
326
  },
@@ -365,7 +423,7 @@ export const TOOL_DEFS = [
365
423
  {
366
424
  conversation_id: CONV_ID,
367
425
  action: { type: 'string', enum: ['list', 'remove', 'update', 'promote'] },
368
- run_id: { type: 'string', description: 'remove / update / promote 必填' },
426
+ run_id: { type: 'string', maxLength: 200, description: 'remove / update / promote 必填' },
369
427
  text: { type: 'string', description: 'update 时的新文本' },
370
428
  },
371
429
  ['conversation_id', 'action'],
@@ -380,6 +438,88 @@ export const TOOL_DEFS = [
380
438
  },
381
439
  ];
382
440
 
441
+ /**
442
+ * 把审核单渲染成调用方一眼能看到的文本块。
443
+ *
444
+ * 为什么放在正文后面而不是单独一次工具调用:MCP 调用方拿到结果后往往直接转述,
445
+ * 审核要求必须和结果出现在同一条消息里,才有机会改变它的下一步动作。
446
+ */
447
+ function renderReviewSection(review) {
448
+ if (!review || typeof review !== 'object') return '';
449
+ const lines = [];
450
+ const pending = review.status === 'pending';
451
+ lines.push(`【调用方审核${pending ? ' · 待提交' : ` · ${review.status}`}】`);
452
+ if (review.prompt_preview) lines.push(`原任务: ${review.prompt_preview}`);
453
+ const ev = review.evidence ?? {};
454
+ const bits = [];
455
+ if (ev.stop_reason) bits.push(`stop=${ev.stop_reason}`);
456
+ if (ev.workspace_changed != null) bits.push(`工作区变化=${ev.workspace_changed ? '是' : '否'}`);
457
+ if (ev.changed_files != null) bits.push(`改动文件=${ev.changed_files}`);
458
+ if (Array.isArray(ev.tools) && ev.tools.length) bits.push(`工具=[${ev.tools.join(', ')}]`);
459
+ if (ev.auto_approvals) {
460
+ bits.push(`⚠ 自动放行审批=${ev.auto_approvals}${ev.auto_escalations ? `(含提权 ${ev.auto_escalations})` : ''}`);
461
+ }
462
+ if (bits.length) lines.push(`证据: ${bits.join(' · ')}`);
463
+ if (pending) {
464
+ lines.push('在把这次结果当作完成之前,请独立核验:');
465
+ (review.checklist ?? []).forEach((item, i) => lines.push(`${i + 1}. ${item}`));
466
+ if (review.instructions) lines.push(review.instructions);
467
+ lines.push(
468
+ `提交裁决: dsh_review(conversation_id="${review.conversation_id}", run_id="${review.run_id}", ` +
469
+ 'verdict="pass|partial|fail", notes="审核理由")',
470
+ );
471
+ } else {
472
+ if (review.notes) lines.push(`审核理由: ${review.notes}`);
473
+ if (Array.isArray(review.findings) && review.findings.length) {
474
+ lines.push(`审核问题: ${review.findings.join(';')}`);
475
+ }
476
+ if (review.review_file) lines.push(`审核记录: ${review.review_file}`);
477
+ }
478
+ return lines.join('\n');
479
+ }
480
+
481
+ /**
482
+ * 按 `max_chars` 预算压缩一份回合结果(只用于返回给调用方,不改原对象)。
483
+ *
484
+ * 体积大头是 tools_used[].input:edit/write 会带完整的新旧文本,几十次工具调用
485
+ * 轻松几万字。压缩后每个工具只留 id/title/name/status/kind 与 input_chars;
486
+ * 正文与思考截到预算长度。完整版本仍在哨兵文件里。
487
+ */
488
+ export function compactResultForBudget(result, cap) {
489
+ if (!result || typeof result !== 'object' || !(cap > 0)) return result ?? null;
490
+ const out = { ...result };
491
+ if (typeof out.answer === 'string' && out.answer.length > cap) out.answer = out.answer.slice(0, cap);
492
+ if (typeof out.thinking === 'string' && out.thinking.length > cap) out.thinking = out.thinking.slice(0, cap);
493
+ if (Array.isArray(out.tools_used)) {
494
+ out.tools_used = out.tools_used.map((t) => {
495
+ if (!t || typeof t !== 'object') return t;
496
+ const { input, ...meta } = t;
497
+ if (input === undefined) return meta;
498
+ let chars = 0;
499
+ try {
500
+ chars = typeof input === 'string' ? input.length : JSON.stringify(input)?.length ?? 0;
501
+ } catch {
502
+ chars = 0;
503
+ }
504
+ return { ...meta, input_chars: chars };
505
+ });
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /**
511
+ * PowerShell 版的哨兵等待命令(Windows 宿主上 Git Bash 可能不可用或挂住)。
512
+ * 路径放进单引号字面量:PowerShell 单引号里只有 `'` 需要转义(写成 `''`),
513
+ * `$`、反引号都不展开,所以不会被路径内容注入。
514
+ */
515
+ export function psWaitCommand(sentinelPath, timeoutSec = 2100) {
516
+ const lit = `'${String(sentinelPath).replace(/'/g, "''")}'`;
517
+ return (
518
+ `$f=${lit}; $dl=(Get-Date).AddSeconds(${Math.max(1, Math.floor(timeoutSec))}); ` +
519
+ `while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }; 'DONE'`
520
+ );
521
+ }
522
+
383
523
  /** 把结构化结果渲染成给模型看的文本。 */
384
524
  function renderSendResult(r) {
385
525
  const lines = [];
@@ -405,9 +545,20 @@ function renderSendResult(r) {
405
545
  const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
406
546
  if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
407
547
  if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
548
+ if (r.auto_approvals_total) {
549
+ meta.push(
550
+ `自动放行审批 ${r.auto_approvals_total} 次` +
551
+ (r.auto_escalations_total ? `(含沙箱提权 ${r.auto_escalations_total} 次)` : ''),
552
+ );
553
+ }
408
554
  lines.push('');
409
555
  lines.push(`[${meta.join(' · ')}]`);
410
556
 
557
+ if (r.review) {
558
+ lines.push('');
559
+ lines.push(renderReviewSection(r.review));
560
+ }
561
+
411
562
  if (r.error) {
412
563
  lines.push('');
413
564
  lines.push(`⚠️ 回合失败: ${r.error}`);
@@ -423,6 +574,7 @@ function renderSendResult(r) {
423
574
  function backgroundReceipt(hub, conv, res) {
424
575
  const sentinel = res.sentinel_file ?? hub.runSentinelPath(conv.id, res.run_id);
425
576
  const sentinelPosix = toPosixPath(sentinel);
577
+ const review = conv.pendingReviewTicket?.(res.run_id) ?? null;
426
578
  const waited = res.waited_ms
427
579
  ? `已等待 ${(res.waited_ms / 1000).toFixed(0)}s 仍未结束,改为后台继续(**回合没有被取消**)。`
428
580
  : '已在后台执行(**回合没有被取消**)。';
@@ -436,6 +588,9 @@ function backgroundReceipt(hub, conv, res) {
436
588
  sentinel_file: sentinel,
437
589
  /** Bash/MSYS 形式(D:\a\b → /d/a/b);非 Windows 等于 sentinel_file。 */
438
590
  sentinel_file_posix: sentinelPosix,
591
+ /** Windows 宿主:现成的 PowerShell 等待命令(路径已按单引号字面量转义)。 */
592
+ ...(process.platform === 'win32' ? { sentinel_wait_powershell: psWaitCommand(sentinel) } : {}),
593
+ ...(review ? { review } : {}),
439
594
  },
440
595
  text:
441
596
  `${waited}(run_id=${res.run_id})\n\n` +
@@ -446,8 +601,13 @@ function backgroundReceipt(hub, conv, res) {
446
601
  `推荐用后台任务等它出现,文件一到就会唤醒你:\n` +
447
602
  ` Bash(run_in_background): sent="${sentinelPosix}"; \\\n` +
448
603
  ` dl=$(( $(date +%s)+2100 )); until [ -f "$sent" ]; do [ $(date +%s) -ge $dl ] && { echo TIMEOUT; exit 1; }; sleep 2; done; echo DONE\n` +
604
+ (process.platform === 'win32'
605
+ ? `Windows 上 Bash 不可用(或会挂住)时,用 PowerShell 版本(原生路径,已转义好):\n` +
606
+ ` PowerShell(run_in_background): ${psWaitCommand(sentinel)}\n`
607
+ : '') +
449
608
  `文件出现后:直接读它拿结果(推荐——不受内存窗口限制、还扛服务重启),或 dsh_get(conversation_id, run_id) 验收。\n` +
450
609
  `消费完可以 rm 掉它。其它随时可用:dsh_status(是否在跑)、dsh_read(增量看输出)。` +
610
+ (review ? `\n\n${renderReviewSection(review)}` : '') +
451
611
  (res.waited_ms
452
612
  ? `\n\n下次想立刻返回就传 wait=false;想等更久就调大 timeout_ms(**0 = 一直等**)。`
453
613
  : ''),
@@ -471,6 +631,7 @@ function replayRun(hub, conv, run, wantResult) {
471
631
  `\n哨兵文件: ${sentinel}`,
472
632
  };
473
633
  }
634
+ const review = conv.pendingReviewTicket?.(run.run_id) ?? null;
474
635
  return {
475
636
  structured: {
476
637
  conversation_id: conv.id,
@@ -482,10 +643,12 @@ function replayRun(hub, conv, run, wantResult) {
482
643
  replayed: true,
483
644
  sentinel_file: sentinel,
484
645
  sentinel_file_posix: sentinelPosix,
646
+ ...(review ? { review } : {}),
485
647
  },
486
648
  text:
487
649
  `(幂等命中:同一个 client_run_id 已在跑或排队,未重复派活)run_id=${run.run_id} status=${run.status}\n` +
488
- `哨兵文件: ${sentinel}`,
650
+ `哨兵文件: ${sentinel}` +
651
+ (review ? `\n\n${renderReviewSection(review)}` : ''),
489
652
  };
490
653
  }
491
654
 
@@ -493,6 +656,7 @@ function replayRun(hub, conv, run, wantResult) {
493
656
  function queuedReceipt(hub, conv, run, position) {
494
657
  const sentinel = hub.runSentinelPath(conv.id, run.run_id);
495
658
  const sentinelPosix = toPosixPath(sentinel);
659
+ const review = conv.pendingReviewTicket?.(run.run_id) ?? null;
496
660
  return {
497
661
  structured: {
498
662
  conversation_id: conv.id,
@@ -503,11 +667,13 @@ function queuedReceipt(hub, conv, run, position) {
503
667
  status: 'queued',
504
668
  sentinel_file: sentinel,
505
669
  sentinel_file_posix: sentinelPosix,
670
+ ...(review ? { review } : {}),
506
671
  },
507
672
  text:
508
673
  `已入队(第 ${position} 位)。当前回合结束后自动执行;**入队时不会写哨兵**,真正结束时才写:\n` +
509
674
  ` ${sentinel}\n` +
510
- `用 dsh_queue list 查看/修改/删除;取消:dsh_queue(conversation_id, action="remove", run_id="${run.run_id}")。`,
675
+ `用 dsh_queue list 查看/修改/删除;取消:dsh_queue(conversation_id, action="remove", run_id="${run.run_id}")。` +
676
+ (review ? `\n\n${renderReviewSection(review)}` : ''),
511
677
  };
512
678
  }
513
679
 
@@ -539,6 +705,7 @@ export function createHandlers(hub, opts = {}) {
539
705
  onApproval: args.on_approval,
540
706
  titleHint: args.title_hint,
541
707
  reasoningEffort: args.reasoning_effort,
708
+ review: args.review,
542
709
  provider: args.provider,
543
710
  model: args.model,
544
711
  keepAlive: args.keep_alive === true,
@@ -587,7 +754,9 @@ export function createHandlers(hub, opts = {}) {
587
754
  throw new Error('prompt 不能为空:至少包含一个非空白字符');
588
755
  }
589
756
  const conv = await hub.get(args.conversation_id);
757
+ const review = args.review === undefined ? undefined : normalizeReviewMode(args.review, conv.review);
590
758
  const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
759
+ if (review !== undefined) opts.review = review;
591
760
  if (args.timeout_ms !== undefined && args.timeout_ms !== null) opts.timeoutMs = parseTimeoutMs(args.timeout_ms);
592
761
 
593
762
  // ── 幂等:同一个 client_run_id 直接返回首次的 run,不重复执行 ──
@@ -615,6 +784,7 @@ export function createHandlers(hub, opts = {}) {
615
784
  reasoning: opts.reasoning,
616
785
  clientRunId: args.client_run_id ?? null,
617
786
  preview: args.prompt,
787
+ review: review ?? conv.review,
618
788
  });
619
789
  if (deduped) return replayRun(hub, conv, run, false);
620
790
  audit('send_queued', {
@@ -633,7 +803,10 @@ export function createHandlers(hub, opts = {}) {
633
803
 
634
804
  const wait = args.wait === true;
635
805
  // 记录与预览保留调用方原文;实际派发用 promptText(可能带团队提示)。
636
- const run = conv.createRun(args.prompt, { clientRunId: args.client_run_id ?? null });
806
+ const run = conv.createRun(args.prompt, {
807
+ clientRunId: args.client_run_id ?? null,
808
+ review: review ?? conv.review,
809
+ });
637
810
  // prompt() 自己负责收尾与写哨兵。这里 await 是为了**同步暴露 resume/启动失败** ——
638
811
  // 否则调用方会拿到一个 accepted 但注定失败的收据(冷启动要几秒,热会话瞬时)。
639
812
  const r = await conv.prompt(promptText, { ...opts, run, wait });
@@ -805,7 +978,9 @@ export function createHandlers(hub, opts = {}) {
805
978
  `未知 run_id: ${args.run_id}(内存只留最近 20 条;哨兵文件 ${hub.runSentinelPath(args.conversation_id, args.run_id)} 也不存在或不可读)`,
806
979
  );
807
980
  }
808
- // 正文预算只作用于 text;structured 里保留完整 result,调用方要做程序化处理时仍然拿得到。
981
+ // 预算同时作用于 text 与 structured:Claude Code 等客户端会把 structuredContent
982
+ // 一并塞进上下文,只截 text 时一次带几十个工具输入的回合仍会超过单次结果上限
983
+ // (实测 75K 字符被拒收)。完整结果始终在哨兵文件里。
809
984
  const answerFull = String(run?.result?.answer ?? '');
810
985
  const capRaw = Number(args.max_chars);
811
986
  const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 0;
@@ -833,6 +1008,11 @@ export function createHandlers(hub, opts = {}) {
833
1008
  meta.push(` (思考 ${run.result.thinking_stats.chars} 字已隐藏)`);
834
1009
  }
835
1010
  if (run.result?.error) meta.push(` ⚠️ 失败: ${run.result.error}`);
1011
+ const review = run.review ?? run.result?.review;
1012
+ if (review) {
1013
+ meta.push('');
1014
+ meta.push(renderReviewSection(review));
1015
+ }
836
1016
  }
837
1017
  const sentinelFile = run ? hub.runSentinelPath(args.conversation_id, run.run_id) : null;
838
1018
  const runView = run
@@ -848,10 +1028,15 @@ export function createHandlers(hub, opts = {}) {
848
1028
  answer_chars: answerFull.length,
849
1029
  answer_truncated: truncated,
850
1030
  next_offset: truncated ? cap : null,
851
- result: run.result,
1031
+ review: run.review ?? run.result?.review ?? null,
1032
+ result: cap > 0 ? compactResultForBudget(run.result, cap) : run.result,
1033
+ ...(cap > 0 ? { result_compacted: true } : {}),
852
1034
  }
853
1035
  : null;
854
- if (sentinelFile) meta.push(`\n哨兵文件: ${sentinelFile}`);
1036
+ if (sentinelFile) {
1037
+ meta.push(`\n哨兵文件: ${sentinelFile}`);
1038
+ if (cap > 0) meta.push('(max_chars 已同时压缩 structured:正文截断、工具输入只留长度;完整结果读这个文件)');
1039
+ }
855
1040
  return {
856
1041
  structured: {
857
1042
  ...snap,
@@ -864,6 +1049,31 @@ export function createHandlers(hub, opts = {}) {
864
1049
  };
865
1050
  },
866
1051
 
1052
+ async dsh_review(args) {
1053
+ const conv = await hub.get(args.conversation_id);
1054
+ const r = conv.submitReview(args.run_id, {
1055
+ verdict: args.verdict,
1056
+ notes: args.notes,
1057
+ findings: args.findings,
1058
+ });
1059
+ audit('review', {
1060
+ conversation_id: conv.id,
1061
+ run_id: args.run_id,
1062
+ verdict: args.verdict,
1063
+ });
1064
+ const lines = [
1065
+ `审核已记录:${r.run_id} → ${r.status}(${r.review_id})`,
1066
+ r.notes ? `理由: ${r.notes}` : '理由: (未填写)',
1067
+ ];
1068
+ if (Array.isArray(r.findings) && r.findings.length) {
1069
+ lines.push('问题:');
1070
+ for (const f of r.findings) lines.push(` - ${f}`);
1071
+ }
1072
+ lines.push(`审核记录: ${r.review_file}`);
1073
+ lines.push('审核只记录裁决,不会自动回灌给 DSH;需要修复请用 dsh_send 再派活。');
1074
+ return { structured: r, text: lines.join('\n') };
1075
+ },
1076
+
867
1077
  async dsh_interject(args) {
868
1078
  if (typeof args.message !== 'string' || args.message.trim() === '') {
869
1079
  throw new Error('message 不能为空:至少包含一个非空白字符');
@@ -886,6 +1096,7 @@ export function createHandlers(hub, opts = {}) {
886
1096
  reasoning: args.reasoning ?? DEFAULT_REASONING,
887
1097
  clientRunId: args.client_run_id ?? null,
888
1098
  };
1099
+ if (args.review !== undefined) opts.review = normalizeReviewMode(args.review, conv.review);
889
1100
  if (args.timeout_ms !== undefined && args.timeout_ms !== null) opts.timeoutMs = parseTimeoutMs(args.timeout_ms);
890
1101
  const r = await conv.interject(args.message, opts);
891
1102
  conv.lastResult = r;
@@ -986,6 +1197,7 @@ export function createHandlers(hub, opts = {}) {
986
1197
  const conv = await hub.get(args.conversation_id);
987
1198
  const snap = conv.snapshot();
988
1199
  const run = conv.getRun();
1200
+ const review = run ? (run.review ?? run.result?.review ?? null) : null;
989
1201
  const bits = [`状态=${snap.state}`, `轮数=${snap.turns}`];
990
1202
  if (snap.state === 'running') {
991
1203
  bits.push(`已跑=${((snap.running_ms ?? 0) / 1000).toFixed(1)}s`);
@@ -993,6 +1205,9 @@ export function createHandlers(hub, opts = {}) {
993
1205
  bits.push(`本轮输出=${snap.out_chars ?? 0}字`);
994
1206
  }
995
1207
  if (run) bits.push(`最近回合=${run.run_id}(${run.status})`);
1208
+ if (review) {
1209
+ bits.push(`审核=${review.status}${review.verdict ? `(${review.verdict})` : ''}${review.status === 'pending' ? '(待提交)' : ''}`);
1210
+ }
996
1211
  bits.push(`待决审批=${snap.pending_approvals.length}`);
997
1212
  if (snap.rss_mb != null) bits.push(`rss=${snap.rss_mb}MB`);
998
1213
  if (snap.keep_alive) bits.push('保活=是');
@@ -1037,6 +1252,9 @@ export function createHandlers(hub, opts = {}) {
1037
1252
  lock_holder: snap.lock_holder,
1038
1253
  lock_holder_reason: snap.lock_holder_reason,
1039
1254
  lock_conflict: snap.lock_conflict,
1255
+ review: review
1256
+ ? { review_id: review.review_id, status: review.status, verdict: review.verdict ?? null }
1257
+ : null,
1040
1258
  run: run
1041
1259
  ? {
1042
1260
  run_id: run.run_id,
@@ -1145,7 +1363,26 @@ export function createHandlers(hub, opts = {}) {
1145
1363
  version =
1146
1364
  JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null;
1147
1365
  } catch {}
1148
- add('dsh_bin', 'ok', `${bin}${version ? ` (DSH ${version})` : ''}`);
1366
+ const fromNpx = isNpxCachePath(bin);
1367
+ add(
1368
+ 'dsh_bin',
1369
+ fromNpx ? 'warn' : 'ok',
1370
+ `${bin}${version ? ` (DSH ${version})` : ''}${fromNpx ? ' —— 位于 npx 缓存,清缓存后会失效' : ''}`,
1371
+ fromNpx
1372
+ ? '全局安装 DSH(npm i -g @deepseek-ai/dsh)或把 DSH_BIN 指向一个固定位置的 lib/bin.js'
1373
+ : null,
1374
+ );
1375
+ const vs = dshVersionStatus(version);
1376
+ add(
1377
+ 'dsh_version',
1378
+ vs.status,
1379
+ `${vs.level}: ${vs.note}`,
1380
+ vs.status === 'ok'
1381
+ ? null
1382
+ : vs.level === 'unverified'
1383
+ ? '对照 docs/zh/compatibility.md;升级后跑一遍 npm test,必要时 npm run test:all'
1384
+ : '升级到 DSH 0.1.7-rc.1 及以上',
1385
+ );
1149
1386
  } catch (e) {
1150
1387
  add('dsh_bin', 'fail', e.message, '设置 DSH_BIN 指向 @deepseek-ai/dsh/lib/bin.js');
1151
1388
  }
@@ -1175,6 +1412,7 @@ export function createHandlers(hub, opts = {}) {
1175
1412
 
1176
1413
  for (const [id, dir] of [
1177
1414
  ['runs_dir', RUNS_DIR],
1415
+ ['reviews_dir', REVIEWS_DIR],
1178
1416
  ['locks_dir', LOCKS_DIR],
1179
1417
  ]) {
1180
1418
  try {