dsh-any-agent 0.3.8 → 0.4.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/src/tools.mjs CHANGED
@@ -5,17 +5,32 @@
5
5
  * 因为调用方 agent 已经有那些工具了。
6
6
  */
7
7
 
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
8
10
  import {
9
11
  APPROVAL_POLICIES,
10
12
  DEFAULT_PERMISSION,
11
13
  DEFAULT_REASONING,
12
14
  DEFAULT_REASONING_EFFORT,
15
+ IDLE_TTL_MS,
16
+ MAX_LIVE,
17
+ MAX_RSS_MB,
13
18
  PERMISSION_TIERS,
14
19
  REASONING_EFFORTS,
15
20
  REASONING_MODES,
21
+ RUNS_DIR,
22
+ SHARED_PROCESS,
23
+ STATE_FILE,
24
+ TEAM_ACTIVE,
25
+ TEAM_MODE,
26
+ DSH_PROFILE,
16
27
  toPosixPath,
28
+ resolveDshBin,
17
29
  } from './config.mjs';
18
- import { listOrphans } from './locks.mjs';
30
+ import { inspectProfile } from './bootstrap.mjs';
31
+ import { audit } from './audit.mjs';
32
+ import { listOrphans, LOCKS_DIR } from './locks.mjs';
33
+ import { rssMb } from './procstat.mjs';
19
34
 
20
35
  const S = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false });
21
36
 
@@ -24,6 +39,26 @@ const CONV_ID = {
24
39
  description: '会话 id,来自 dsh_start 或 dsh_list。',
25
40
  };
26
41
 
42
+ /**
43
+ * timeout_ms 校验:只接受 >= 0 的有限数字(0 = 一直等)。
44
+ *
45
+ * 之前直接透传:'abc' → NaN → prompt 里 `NaN <= 0` 为假、setTimeout(NaN) 立即触发,
46
+ * 静默降级成后台收据;非有限值或负数一律报错,别让调用方以为自己在原地等。
47
+ */
48
+ function parseTimeoutMs(raw) {
49
+ const n = Number(raw);
50
+ if (!Number.isFinite(n) || n < 0) throw new Error('timeout_ms 必须是 >= 0 的有限数字');
51
+ return n;
52
+ }
53
+
54
+ /**
55
+ * 团队模式下注入到派发 prompt 前的一句策略。
56
+ * 只描述"需要时怎么做",不改变任务本身;简单任务不会因此组队。
57
+ */
58
+ const TEAM_PREFIX =
59
+ '【团队】如果这个任务需要并行或多角色协作,先用 spawn_teammate 创建团队再执行;' +
60
+ '不需要就单 agent 完成。不要为简单任务组队。\n\n';
61
+
27
62
  /** 工具清单(JSON Schema)。 */
28
63
  export const TOOL_DEFS = [
29
64
  {
@@ -69,6 +104,13 @@ export const TOOL_DEFS = [
69
104
  },
70
105
  provider: { type: 'string', description: '模型提供方名(如 deepseek-official)。留空用 profile 默认。' },
71
106
  model: { type: 'string', description: '模型名(如 deepseek-flash)。指定时必须同时给 provider。' },
107
+ keep_alive: {
108
+ type: 'boolean',
109
+ description:
110
+ 'true = 这个会话的进程不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。' +
111
+ '默认 false:空闲 10 分钟后进程会被回收、内存释放,下次派活自动 resume(对话不丢)。' +
112
+ '设 true 时请记得用完 `dsh_release`,否则它会一直占着一个 DSH 进程(约 300–480MB)。',
113
+ },
72
114
  },
73
115
  // cwd 不再是必填:有 MCP roots 的客户端会自动带上当前项目目录。
74
116
  [],
@@ -77,7 +119,9 @@ export const TOOL_DEFS = [
77
119
  {
78
120
  name: 'dsh_send',
79
121
  description:
80
- '向会话派一个任务。**默认不阻塞(wait=false)**:立即返回 run_id + sentinel_file,' +
122
+ '向会话派一个任务。**给目标,不要给命令**:DSH 自己决定用哪些文件/shell/搜索/技能/子代理/团队工具,' +
123
+ '本服务不预先加载或代理 DSH 的工具面。' +
124
+ '**默认不阻塞(wait=false)**:立即返回 run_id + sentinel_file,' +
81
125
  '回合在后台跑,跑完自动通知你 —— 你继续做别的事,也永远不用担心任务被"超时"打断。' +
82
126
  '想原地等结果就显式传 wait=true(可配 timeout_ms;到期只会转后台,不会取消回合)。' +
83
127
  '【超时不会毁掉任务】wait=true 到达 timeout_ms 仍没跑完时,本工具**不会报错、也不会取消回合**,' +
@@ -107,6 +151,24 @@ export const TOOL_DEFS = [
107
151
  description:
108
152
  '仅 wait=true 时的**等待**上限(毫秒),**默认 0 = 一直等**(不会凭空发明 30 分钟)。到期不会取消回合,只会转成后台并给你 run_id。',
109
153
  },
154
+ queue: {
155
+ type: 'boolean',
156
+ description:
157
+ '会话正忙时是否入队(默认 false = 直接报错)。true 时立即返回 run_id + queue_position,' +
158
+ '本轮结束后自动执行;哨兵只在真正结束时写,入队时不写。队列仅内存、重启丢失,用 dsh_queue 管理。',
159
+ },
160
+ client_run_id: {
161
+ type: 'string',
162
+ description:
163
+ '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交同一个键不会创建第二个 run,' +
164
+ '而是返回首次 run 的结果或收据(replayed=true)。用于"响应在传输层丢了、想安全重发"的场景。',
165
+ },
166
+ team: {
167
+ type: 'boolean',
168
+ description:
169
+ '是否在本次派发前注入"需要并行/多角色时先建团队"的提示(默认跟随 DSH_MCP_TEAM,0.4.0 起默认开)。' +
170
+ '传 false 只对这一次关闭;彻底关闭用 DSH_MCP_TEAM=0。',
171
+ },
110
172
  },
111
173
  ['conversation_id', 'prompt'],
112
174
  ),
@@ -162,6 +224,16 @@ export const TOOL_DEFS = [
162
224
  {
163
225
  conversation_id: CONV_ID,
164
226
  run_id: { type: 'string', description: 'dsh_send 返回的 run_id;不传则取最近一次回合。' },
227
+ client_run_id: {
228
+ type: 'string',
229
+ description: '按调用方幂等键取 run(与 run_id 二选一;只对仍在内存里的 run 有效,最多最近 20 条)。',
230
+ },
231
+ max_chars: {
232
+ type: 'number',
233
+ description:
234
+ '正文在 **text** 里的最大字符数(默认 0 = 不截断)。' +
235
+ 'structured.run.result 仍保留完整内容;截断时附带 answer_truncated/next_offset,可自行分页。',
236
+ },
165
237
  },
166
238
  ['conversation_id'],
167
239
  ),
@@ -189,6 +261,10 @@ export const TOOL_DEFS = [
189
261
  description: `思考内容展示档位,默认 ${DEFAULT_REASONING}。`,
190
262
  },
191
263
  timeout_ms: { type: 'number', description: '新回合的**等待**上限(毫秒),**默认 0 = 一直等**;queue 模式也用它限制等待上一轮结束。忙时等待超时会报错(本次插话未发送,会话不受损);空闲时到期只转后台,不取消回合。' },
264
+ client_run_id: {
265
+ type: 'string',
266
+ description: '调用方生成的幂等键(≤128 字符,作用域=本会话)。重复提交返回首次的 run,不会重复插话。',
267
+ },
192
268
  },
193
269
  ['conversation_id', 'message'],
194
270
  ),
@@ -266,6 +342,42 @@ export const TOOL_DEFS = [
266
342
  description: '轻量状态查询:会话是否活着、是否在忙、待决审批数、最近一次回合的结果。',
267
343
  inputSchema: S({ conversation_id: CONV_ID }, ['conversation_id']),
268
344
  },
345
+ {
346
+ name: 'dsh_inbox',
347
+ description:
348
+ '**跨会话的待决审批收件箱**(dsh_status 只看单个会话)。' +
349
+ '一次列出所有会话里等你裁决的审批:会话 id/标题、审批 id、工具、原因、命令/路径摘要、等待时长。' +
350
+ '裁决用 dsh_approval_decide。没有待决审批时返回空列表。',
351
+ inputSchema: S(
352
+ {
353
+ conversation_id: { type: 'string', description: '只看某个会话(省略 = 全部)' },
354
+ include_input: { type: 'boolean', description: '是否带操作输入摘要(默认 true)' },
355
+ },
356
+ [],
357
+ ),
358
+ },
359
+ {
360
+ name: 'dsh_queue',
361
+ description:
362
+ '查看/修改会话的待执行队列(由 dsh_send(queue=true) 产生;**仅内存**,重启丢失)。' +
363
+ 'action=list 列出;remove 取消并写 cancelled 哨兵;update 改写排队文本;promote 提到队首。',
364
+ inputSchema: S(
365
+ {
366
+ conversation_id: CONV_ID,
367
+ action: { type: 'string', enum: ['list', 'remove', 'update', 'promote'] },
368
+ run_id: { type: 'string', description: 'remove / update / promote 必填' },
369
+ text: { type: 'string', description: 'update 时的新文本' },
370
+ },
371
+ ['conversation_id', 'action'],
372
+ ),
373
+ },
374
+ {
375
+ name: 'dsh_doctor',
376
+ description:
377
+ '只读体检:DSH 可执行文件与版本、profile 是否存在/漂移、状态文件、runs/locks 目录可写、孤儿锁、' +
378
+ '当前会话数与 RSS、生效配置。只报告不修改;ok=false 表示存在 fail 项。',
379
+ inputSchema: S({}, []),
380
+ },
269
381
  ];
270
382
 
271
383
  /** 把结构化结果渲染成给模型看的文本。 */
@@ -292,7 +404,7 @@ function renderSendResult(r) {
292
404
  }
293
405
  const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
294
406
  if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
295
- if (r.workspace_changed) meta.push(`工作区有改动(${r.diff_stat?.changed_files ?? '?'} 个文件)`);
407
+ if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
296
408
  lines.push('');
297
409
  lines.push(`[${meta.join(' · ')}]`);
298
410
 
@@ -342,6 +454,63 @@ function backgroundReceipt(hub, conv, res) {
342
454
  };
343
455
  }
344
456
 
457
+ /**
458
+ * 幂等命中:返回同一个 client_run_id 首次对应 run 的结果或收据。
459
+ * 绝不创建第二个 run —— 这正是"响应在传输层丢了、想安全重发"需要的语义。
460
+ */
461
+ function replayRun(hub, conv, run, wantResult) {
462
+ const sentinel = hub.runSentinelPath(conv.id, run.run_id);
463
+ const sentinelPosix = toPosixPath(sentinel);
464
+ const terminal = run.status === 'done' || run.status === 'error' || run.status === 'cancelled';
465
+ if (terminal && run.result) {
466
+ return {
467
+ structured: { ...run.result, replayed: true, ...(wantResult ? {} : { background: true }) },
468
+ text:
469
+ `(幂等命中:client_run_id 已对应一个已结束的回合,未重复派活)\n` +
470
+ renderSendResult(run.result) +
471
+ `\n哨兵文件: ${sentinel}`,
472
+ };
473
+ }
474
+ return {
475
+ structured: {
476
+ conversation_id: conv.id,
477
+ run_id: run.run_id,
478
+ status: run.status,
479
+ accepted: true,
480
+ background: true,
481
+ still_running: true,
482
+ replayed: true,
483
+ sentinel_file: sentinel,
484
+ sentinel_file_posix: sentinelPosix,
485
+ },
486
+ text:
487
+ `(幂等命中:同一个 client_run_id 已在跑或排队,未重复派活)run_id=${run.run_id} status=${run.status}\n` +
488
+ `哨兵文件: ${sentinel}`,
489
+ };
490
+ }
491
+
492
+ /** 入队回执:入队时不写哨兵,真正结束才写。 */
493
+ function queuedReceipt(hub, conv, run, position) {
494
+ const sentinel = hub.runSentinelPath(conv.id, run.run_id);
495
+ const sentinelPosix = toPosixPath(sentinel);
496
+ return {
497
+ structured: {
498
+ conversation_id: conv.id,
499
+ run_id: run.run_id,
500
+ accepted: true,
501
+ queued: true,
502
+ queue_position: position,
503
+ status: 'queued',
504
+ sentinel_file: sentinel,
505
+ sentinel_file_posix: sentinelPosix,
506
+ },
507
+ text:
508
+ `已入队(第 ${position} 位)。当前回合结束后自动执行;**入队时不会写哨兵**,真正结束时才写:\n` +
509
+ ` ${sentinel}\n` +
510
+ `用 dsh_queue list 查看/修改/删除;取消:dsh_queue(conversation_id, action="remove", run_id="${run.run_id}")。`,
511
+ };
512
+ }
513
+
345
514
  /**
346
515
  * @param {object} hub
347
516
  * @param {{projectDir?: () => Promise<string|null>}} [opts]
@@ -372,8 +541,14 @@ export function createHandlers(hub, opts = {}) {
372
541
  reasoningEffort: args.reasoning_effort,
373
542
  provider: args.provider,
374
543
  model: args.model,
544
+ keepAlive: args.keep_alive === true,
375
545
  });
376
546
  if (args.title_hint) conv.titleHint = args.title_hint;
547
+ audit('start', {
548
+ conversation_id: conv.id,
549
+ permission: conv.permission,
550
+ keep_alive: conv.keepAlive === true,
551
+ });
377
552
  const snap = conv.snapshot();
378
553
  const lines = [
379
554
  '已创建会话。',
@@ -381,6 +556,14 @@ export function createHandlers(hub, opts = {}) {
381
556
  `工作区: ${snap.cwd}` + (cwdSource === 'client-root' ? '(取自客户端当前打开的项目)' : ''),
382
557
  `权限档: ${snap.permission}(审批策略 ${snap.on_approval})`,
383
558
  `思考深度: ${snap.reasoning_effort}`,
559
+ snap.keep_alive
560
+ ? '保活: 是(不受空闲 TTL / MAX_LIVE 回收;用完请 dsh_release)'
561
+ : '保活: 否(空闲后可被回收,下次派活自动 resume)',
562
+ `团队模式: ${
563
+ TEAM_ACTIVE
564
+ ? `开(profile=${DSH_PROFILE};派发时会提示"需要时先建团队",简单任务不会组队)`
565
+ : '关(DSH_MCP_TEAM=0 或 DSH < 0.1.7)'
566
+ }`,
384
567
  `对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}` +
385
568
  (snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
386
569
  `工作区登记: ${snap.workspace_registered ? '已写入 DSH 工作区注册表(GUI 里能看到)' : '未登记'}`,
@@ -405,28 +588,63 @@ export function createHandlers(hub, opts = {}) {
405
588
  }
406
589
  const conv = await hub.get(args.conversation_id);
407
590
  const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
408
- if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
591
+ if (args.timeout_ms !== undefined && args.timeout_ms !== null) opts.timeoutMs = parseTimeoutMs(args.timeout_ms);
409
592
 
410
- // ── 默认路径:后台派活(wait !== true)──────────
411
- // 默认不阻塞:立即给收据 + 哨兵路径,回合结束时自动通知。想原地等就显式传 wait=true。
412
- if (args.wait !== true) {
413
- if (conv.busy) {
414
- throw new Error(
415
- '会话正在跑另一个回合,无法再排一个。可以:dsh_interject(插话)或 dsh_interrupt(打断)后重试。',
416
- );
593
+ // ── 幂等:同一个 client_run_id 直接返回首次的 run,不重复执行 ──
594
+ if (args.client_run_id) {
595
+ if (String(args.client_run_id).length > 128) throw new Error('client_run_id 最长 128 字符');
596
+ const existing = conv.getRunByClientId(args.client_run_id);
597
+ if (existing) {
598
+ audit('send_replay', {
599
+ conversation_id: conv.id,
600
+ run_id: existing.run_id,
601
+ client_run_id: args.client_run_id,
602
+ });
603
+ return replayRun(hub, conv, existing, args.wait === true);
604
+ }
605
+ }
606
+
607
+ // 团队提示:只描述"需要时怎么做",不改变任务本身;简单任务不会因此组队。
608
+ const teamOn = TEAM_ACTIVE && args.team !== false;
609
+ const promptText = teamOn ? TEAM_PREFIX + args.prompt : args.prompt;
610
+
611
+ // ── 忙时:显式 queue=true 才入队;否则保持"直接报错"的旧契约 ──
612
+ if (conv.busy) {
613
+ if (args.queue === true && args.wait !== true) {
614
+ const { run, position, deduped } = conv.enqueue(promptText, {
615
+ reasoning: opts.reasoning,
616
+ clientRunId: args.client_run_id ?? null,
617
+ preview: args.prompt,
618
+ });
619
+ if (deduped) return replayRun(hub, conv, run, false);
620
+ audit('send_queued', {
621
+ conversation_id: conv.id,
622
+ run_id: run.run_id,
623
+ position,
624
+ client_run_id: args.client_run_id ?? null,
625
+ });
626
+ return queuedReceipt(hub, conv, run, position);
417
627
  }
418
- const run = conv.createRun(args.prompt);
419
- // prompt() 自己负责收尾与写哨兵(sentinel: true)。这里 await 是为了
420
- // **同步暴露 resume/启动失败** —— 否则调用方会拿到一个 accepted 但注定失败的收据。
421
- // 冷启动时这一步要几秒(要拉进程 + resume),热会话是瞬时。
422
- const res = await conv.prompt(args.prompt, { ...opts, run, wait: false });
423
- conv.lastResult = res;
424
- return res.still_running ? backgroundReceipt(hub, conv, res) : { structured: res, text: renderSendResult(res) };
628
+ throw new Error(
629
+ '会话正在跑另一个回合,无法再排一个。可以:dsh_interject(插话)、dsh_interrupt(打断)后重试,' +
630
+ '或 dsh_send(queue=true) 入队。',
631
+ );
425
632
  }
426
633
 
427
- // ── 等待模式:等到就返回结果;等超时则自动降级为后台(不报错、不丢结果)──
428
- const r = await conv.prompt(args.prompt, opts);
634
+ const wait = args.wait === true;
635
+ // 记录与预览保留调用方原文;实际派发用 promptText(可能带团队提示)。
636
+ const run = conv.createRun(args.prompt, { clientRunId: args.client_run_id ?? null });
637
+ // prompt() 自己负责收尾与写哨兵。这里 await 是为了**同步暴露 resume/启动失败** ——
638
+ // 否则调用方会拿到一个 accepted 但注定失败的收据(冷启动要几秒,热会话瞬时)。
639
+ const r = await conv.prompt(promptText, { ...opts, run, wait });
429
640
  conv.lastResult = r;
641
+ audit('send', {
642
+ conversation_id: conv.id,
643
+ run_id: r?.run_id ?? run.run_id,
644
+ wait,
645
+ still_running: Boolean(r?.still_running),
646
+ client_run_id: args.client_run_id ?? null,
647
+ });
430
648
  if (r?.still_running) return backgroundReceipt(hub, conv, r);
431
649
  return { structured: r, text: renderSendResult(r) };
432
650
  },
@@ -457,6 +675,9 @@ export function createHandlers(hub, opts = {}) {
457
675
  bits.push(`cwd=${c.cwd}`);
458
676
  if (c.permission) bits.push(`权限=${c.permission}`);
459
677
  if (c.turns != null) bits.push(`轮数=${c.turns}`);
678
+ if (c.rss_mb != null) bits.push(`rss=${c.rss_mb}MB`);
679
+ if (c.keep_alive) bits.push('保活');
680
+ if (c.queue_length) bits.push(`队列=${c.queue_length}`);
460
681
  return `- ${bits.join(' | ')}`;
461
682
  };
462
683
  const probe = hub.lastProbe ?? { ok: null, error: null, cached: false };
@@ -518,6 +739,7 @@ export function createHandlers(hub, opts = {}) {
518
739
  const conv = await hub.get(args.conversation_id);
519
740
  const snap = conv.snapshot();
520
741
  let run = null;
742
+ let runSource = null;
521
743
  const meta = [];
522
744
  // DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
523
745
  const titleKind = snap.title_source === 'native'
@@ -535,40 +757,111 @@ export function createHandlers(hub, opts = {}) {
535
757
  if (snap.usage) meta.push(`token: in=${snap.usage.uncachedInputTokens ?? 0} out=${snap.usage.outputTokens ?? 0} cache=${snap.usage.cacheReadTokens ?? 0}`);
536
758
  if (snap.context_pressure) meta.push(`上下文压力: ${snap.context_pressure.pressureTokens}/${snap.context_pressure.contextWindow}`);
537
759
  if (snap.pending_approvals?.length) meta.push(`待决审批: ${snap.pending_approvals.join(', ')}`);
760
+ if (snap.queue_length) meta.push(`队列: ${snap.queue_length} 条待执行(dsh_queue list)`);
538
761
  for (const detail of snap.pending_approval_details ?? []) {
539
762
  const summary = [detail.title ?? detail.tool, detail.reason ?? detail.description].filter(Boolean).join(' — ');
540
763
  meta.push(` ${detail.approval_id}: ${summary.slice(0, 300)}(操作输入见 pending_approval_details)`);
541
764
  }
542
- run = args.run_id ? conv.getRun(args.run_id) : conv.getRun();
765
+ if (args.client_run_id) {
766
+ run = conv.getRunByClientId(args.client_run_id);
767
+ if (!run) {
768
+ throw new Error(
769
+ `未知 client_run_id: ${args.client_run_id}(只在内存里保留最近 20 条;已淘汰/服务重启后请用 run_id 或直接读哨兵文件)`,
770
+ );
771
+ }
772
+ runSource = 'client_run_id';
773
+ } else if (args.run_id) {
774
+ try {
775
+ run = conv.getRun(args.run_id);
776
+ } catch {
777
+ run = null;
778
+ }
779
+ } else {
780
+ run = conv.getRun();
781
+ }
782
+ if (run && !runSource) runSource = 'memory';
783
+ // ★ 内存只留最近 MAX_RUNS 条;服务重启或被挤出后,run_id 只能从哨兵文件回读。
784
+ // 哨兵是"一个回合 = 一个文件"的落盘权威,把它接进入口,调用方不必自己拼路径。
785
+ if (!run && args.run_id) {
786
+ const file = hub.runSentinelPath(args.conversation_id, args.run_id);
787
+ try {
788
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
789
+ run = {
790
+ run_id: raw.run_id ?? args.run_id,
791
+ status: raw.status ?? 'unknown',
792
+ started_at: null,
793
+ ended_at: raw.ended_at ?? null,
794
+ elapsed_ms: null,
795
+ prompt_preview: null,
796
+ result: raw.result ?? null,
797
+ };
798
+ runSource = 'sentinel';
799
+ } catch {
800
+ // 文件不存在或不可解析:保持 run=null,下面给明确报错
801
+ }
802
+ }
803
+ if (args.run_id && !run) {
804
+ throw new Error(
805
+ `未知 run_id: ${args.run_id}(内存只留最近 20 条;哨兵文件 ${hub.runSentinelPath(args.conversation_id, args.run_id)} 也不存在或不可读)`,
806
+ );
807
+ }
808
+ // 正文预算只作用于 text;structured 里保留完整 result,调用方要做程序化处理时仍然拿得到。
809
+ const answerFull = String(run?.result?.answer ?? '');
810
+ const capRaw = Number(args.max_chars);
811
+ const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 0;
812
+ const truncated = cap > 0 && answerFull.length > cap;
543
813
  if (run) {
544
814
  meta.push('');
545
815
  meta.push(
546
816
  `回合 ${run.run_id}: 状态=${run.status}` +
817
+ (runSource === 'sentinel' ? '(来自哨兵文件;内存记录已过期或服务重启过)' : '') +
547
818
  (run.elapsed_ms != null ? ` 耗时=${(run.elapsed_ms / 1000).toFixed(1)}s` : '') +
548
819
  (run.status === 'running' ? '(还在跑,可继续用 dsh_read 看输出)' : ''),
549
820
  );
550
- meta.push(` 指令: ${run.prompt_preview}`);
821
+ if (run.prompt_preview) meta.push(` 指令: ${run.prompt_preview}`);
551
822
  if (run.result?.answer) {
552
- meta.push(' 最终答复:');
553
- meta.push(run.result.answer);
823
+ if (truncated) {
824
+ meta.push(` 最终答复(已截断,共 ${answerFull.length} 字):`);
825
+ meta.push(answerFull.slice(0, cap));
826
+ meta.push(` …(余下 ${answerFull.length - cap} 字;max_chars=0 取全文)`);
827
+ } else {
828
+ meta.push(' 最终答复:');
829
+ meta.push(answerFull);
830
+ }
554
831
  }
555
832
  if (run.result?.thinking_stats?.chars) {
556
833
  meta.push(` (思考 ${run.result.thinking_stats.chars} 字已隐藏)`);
557
834
  }
558
835
  if (run.result?.error) meta.push(` ⚠️ 失败: ${run.result.error}`);
559
836
  }
837
+ const sentinelFile = run ? hub.runSentinelPath(args.conversation_id, run.run_id) : null;
560
838
  const runView = run
561
839
  ? {
562
840
  run_id: run.run_id,
841
+ client_run_id: run.client_run_id ?? null,
842
+ source: runSource,
563
843
  status: run.status,
564
844
  started_at: run.started_at ? new Date(run.started_at).toISOString() : null,
565
845
  ended_at: run.ended_at ? new Date(run.ended_at).toISOString() : null,
566
846
  elapsed_ms: run.elapsed_ms,
567
847
  prompt_preview: run.prompt_preview,
848
+ answer_chars: answerFull.length,
849
+ answer_truncated: truncated,
850
+ next_offset: truncated ? cap : null,
568
851
  result: run.result,
569
852
  }
570
853
  : null;
571
- return { structured: { ...snap, run: runView }, text: meta.join('\n') };
854
+ if (sentinelFile) meta.push(`\n哨兵文件: ${sentinelFile}`);
855
+ return {
856
+ structured: {
857
+ ...snap,
858
+ run: runView,
859
+ ...(sentinelFile
860
+ ? { sentinel_file: sentinelFile, sentinel_file_posix: toPosixPath(sentinelFile) }
861
+ : {}),
862
+ },
863
+ text: meta.join('\n'),
864
+ };
572
865
  },
573
866
 
574
867
  async dsh_interject(args) {
@@ -576,10 +869,32 @@ export function createHandlers(hub, opts = {}) {
576
869
  throw new Error('message 不能为空:至少包含一个非空白字符');
577
870
  }
578
871
  const conv = await hub.get(args.conversation_id);
579
- const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
580
- if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
872
+ if (args.client_run_id) {
873
+ if (String(args.client_run_id).length > 128) throw new Error('client_run_id 最长 128 字符');
874
+ const existing = conv.getRunByClientId(args.client_run_id);
875
+ if (existing) {
876
+ audit('interject_replay', {
877
+ conversation_id: conv.id,
878
+ run_id: existing.run_id,
879
+ client_run_id: args.client_run_id,
880
+ });
881
+ return replayRun(hub, conv, existing, true);
882
+ }
883
+ }
884
+ const opts = {
885
+ mode: args.mode ?? 'interject',
886
+ reasoning: args.reasoning ?? DEFAULT_REASONING,
887
+ clientRunId: args.client_run_id ?? null,
888
+ };
889
+ if (args.timeout_ms !== undefined && args.timeout_ms !== null) opts.timeoutMs = parseTimeoutMs(args.timeout_ms);
581
890
  const r = await conv.interject(args.message, opts);
582
891
  conv.lastResult = r;
892
+ audit('interject', {
893
+ conversation_id: conv.id,
894
+ run_id: r?.run_id ?? null,
895
+ mode: r?.interjected ?? opts.mode,
896
+ client_run_id: args.client_run_id ?? null,
897
+ });
583
898
  const head =
584
899
  r.interjected === 'idle'
585
900
  ? '会话当时空闲,已按普通回合执行。'
@@ -602,6 +917,7 @@ export function createHandlers(hub, opts = {}) {
602
917
  async dsh_interrupt(args) {
603
918
  const conv = await hub.get(args.conversation_id);
604
919
  const r = await conv.interrupt();
920
+ audit('interrupt', { conversation_id: conv.id, interrupted: Boolean(r.interrupted) });
605
921
  return { structured: r, text: r.interrupted ? '已发送中断请求。' : `未中断:${r.reason}` };
606
922
  },
607
923
 
@@ -610,18 +926,27 @@ export function createHandlers(hub, opts = {}) {
610
926
  // 无损:会话日志都在磁盘上,之后照样 resume。
611
927
  if (args.all === true) {
612
928
  const r = await hub.releaseAll({ force: args.force === true });
929
+ audit('release_all', { released: r.released, failed: r.failed.length, skipped_busy: r.skipped_busy.length });
613
930
  return {
614
- structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
931
+ structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy, failed: r.failed },
615
932
  text:
616
933
  `已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
617
934
  (r.skipped_busy.length
618
935
  ? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
619
936
  : '') +
937
+ (r.failed.length
938
+ ? `\n⚠️ ${r.failed.length} 个会话的进程未确认退出,写锁登记已保留;可稍后用 dsh_status/dsh_takeover 处理。`
939
+ : '') +
620
940
  `\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
621
941
  };
622
942
  }
623
943
  const conv = await hub.get(args.conversation_id);
624
944
  const r = await conv.close({ forget: args.forget === true });
945
+ audit('release', {
946
+ conversation_id: conv.id,
947
+ forgotten: Boolean(r.forgotten),
948
+ deleted: Boolean(r.deleted),
949
+ });
625
950
  return {
626
951
  structured: { conversation_id: args.conversation_id, ...r },
627
952
  text: r.forgotten
@@ -633,13 +958,28 @@ export function createHandlers(hub, opts = {}) {
633
958
  async dsh_takeover(args) {
634
959
  const conv = await hub.get(args.conversation_id);
635
960
  const r = await conv.takeover({ force: args.force === true });
961
+ audit('takeover', { conversation_id: conv.id, ok: Boolean(r.ok), forced: args.force === true });
636
962
  return { structured: r, text: `${r.ok ? '✅' : '❌'} ${r.message}` };
637
963
  },
638
964
 
639
965
  async dsh_approval_decide(args) {
640
966
  const conv = await hub.get(args.conversation_id);
641
967
  const r = conv.decideApproval(args.approval_id, args.decision);
642
- return { structured: r, text: `审批 ${r.approval_id} 已裁决为 ${r.decision}。` };
968
+ audit('approval_decide', {
969
+ conversation_id: conv.id,
970
+ approval_id: r.approval_id,
971
+ decision: r.decision,
972
+ effective: r.effective,
973
+ });
974
+ // ★ 只有 effective=selected 才是真的把决定送达了 DSH。
975
+ // 没有对应 option 时 ACP 收到的是 cancelled,绝不能渲染成"已裁决为 allow"。
976
+ const ok = r.effective === 'selected';
977
+ return {
978
+ structured: r,
979
+ text: ok
980
+ ? `审批 ${r.approval_id} 已裁决为 ${r.decision}。`
981
+ : `审批 ${r.approval_id} 未生效:DSH 没有提供 ${r.decision} 对应的选项,已按取消处理(effective=cancelled)。`,
982
+ };
643
983
  },
644
984
 
645
985
  async dsh_status(args) {
@@ -654,6 +994,9 @@ export function createHandlers(hub, opts = {}) {
654
994
  }
655
995
  if (run) bits.push(`最近回合=${run.run_id}(${run.status})`);
656
996
  bits.push(`待决审批=${snap.pending_approvals.length}`);
997
+ if (snap.rss_mb != null) bits.push(`rss=${snap.rss_mb}MB`);
998
+ if (snap.keep_alive) bits.push('保活=是');
999
+ if (snap.queue_length) bits.push(`队列=${snap.queue_length}`);
657
1000
  // 写锁在谁手上:区分"自己握着"和"被别人占着",后者直接给出可执行动作
658
1001
  if (snap.lock_holder && snap.lock_holder !== 'self') {
659
1002
  const hint =
@@ -688,6 +1031,9 @@ export function createHandlers(hub, opts = {}) {
688
1031
  pending_approvals: snap.pending_approvals,
689
1032
  pending_approval_details: snap.pending_approval_details,
690
1033
  turns: snap.turns,
1034
+ rss_mb: snap.rss_mb,
1035
+ keep_alive: snap.keep_alive,
1036
+ queue_length: snap.queue_length,
691
1037
  lock_holder: snap.lock_holder,
692
1038
  lock_holder_reason: snap.lock_holder_reason,
693
1039
  lock_conflict: snap.lock_conflict,
@@ -704,5 +1050,176 @@ export function createHandlers(hub, opts = {}) {
704
1050
  text: bits.join(' | '),
705
1051
  };
706
1052
  },
1053
+
1054
+ async dsh_inbox(args) {
1055
+ const convs = args.conversation_id
1056
+ ? [await hub.get(args.conversation_id)]
1057
+ : [...hub.conversations.values()];
1058
+ const items = [];
1059
+ for (const conv of convs) {
1060
+ const snap = conv.snapshot();
1061
+ for (const d of snap.pending_approval_details ?? []) {
1062
+ items.push({
1063
+ conversation_id: snap.conversation_id,
1064
+ title: snap.title ?? null,
1065
+ cwd: snap.cwd,
1066
+ approval_id: d.approval_id,
1067
+ tool: d.tool ?? null,
1068
+ reason: d.reason ?? d.description ?? null,
1069
+ created_at: d.created_at ?? null,
1070
+ age_ms: d.created_at ? Date.now() - d.created_at : null,
1071
+ options: (d.options ?? []).map((o) => ({
1072
+ option_id: o.optionId,
1073
+ kind: o.kind,
1074
+ name: o.name,
1075
+ })),
1076
+ ...(args.include_input === false ? {} : { input: d.input ?? null }),
1077
+ });
1078
+ }
1079
+ }
1080
+ items.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0));
1081
+ const lines = items.map(
1082
+ (i) =>
1083
+ `- [${i.conversation_id}] ${i.title ?? '(无标题)'} | ${i.approval_id} | ${i.tool ?? '?'} | ` +
1084
+ `${i.reason ?? ''} | 等待=${i.age_ms == null ? '?' : `${Math.round(i.age_ms / 1000)}s`}`,
1085
+ );
1086
+ return {
1087
+ structured: { count: items.length, approvals: items },
1088
+ text: items.length
1089
+ ? `${items.length} 个待决审批:\n${lines.join('\n')}\n\n` +
1090
+ `裁决:dsh_approval_decide(conversation_id, approval_id, decision)`
1091
+ : '(没有待决审批)',
1092
+ };
1093
+ },
1094
+
1095
+ async dsh_queue(args) {
1096
+ const conv = await hub.get(args.conversation_id);
1097
+ switch (args.action) {
1098
+ case 'list': {
1099
+ const items = conv.queueList();
1100
+ return {
1101
+ structured: { count: items.length, queue: items },
1102
+ text: items.length
1103
+ ? `${items.length} 条排队:\n` +
1104
+ items.map((i) => `- #${i.position} ${i.run_id} | ${i.preview}`).join('\n')
1105
+ : '(队列为空)',
1106
+ };
1107
+ }
1108
+ case 'remove': {
1109
+ if (!args.run_id) throw new Error('remove 需要 run_id');
1110
+ const r = conv.queueRemove(args.run_id);
1111
+ audit('queue_remove', { conversation_id: conv.id, run_id: args.run_id });
1112
+ return {
1113
+ structured: r,
1114
+ text: `已从队列移除 ${args.run_id}(已写 cancelled 哨兵,等待方不会空等)`,
1115
+ };
1116
+ }
1117
+ case 'update': {
1118
+ if (!args.run_id) throw new Error('update 需要 run_id');
1119
+ if (typeof args.text !== 'string' || args.text.trim() === '') {
1120
+ throw new Error('update 需要非空 text');
1121
+ }
1122
+ const r = conv.queueUpdate(args.run_id, args.text);
1123
+ audit('queue_update', { conversation_id: conv.id, run_id: args.run_id });
1124
+ return { structured: r, text: `已更新 ${args.run_id} 的排队文本` };
1125
+ }
1126
+ case 'promote': {
1127
+ if (!args.run_id) throw new Error('promote 需要 run_id');
1128
+ const r = conv.queuePromote(args.run_id);
1129
+ audit('queue_promote', { conversation_id: conv.id, run_id: args.run_id });
1130
+ return { structured: r, text: `已把 ${args.run_id} 提到队首` };
1131
+ }
1132
+ default:
1133
+ throw new Error(`未知 action: ${args.action}`);
1134
+ }
1135
+ },
1136
+
1137
+ async dsh_doctor() {
1138
+ const checks = [];
1139
+ const add = (id, status, detail, fix = null) => checks.push({ id, status, detail, fix });
1140
+
1141
+ try {
1142
+ const bin = resolveDshBin();
1143
+ let version = null;
1144
+ try {
1145
+ version =
1146
+ JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null;
1147
+ } catch {}
1148
+ add('dsh_bin', 'ok', `${bin}${version ? ` (DSH ${version})` : ''}`);
1149
+ } catch (e) {
1150
+ add('dsh_bin', 'fail', e.message, '设置 DSH_BIN 指向 @deepseek-ai/dsh/lib/bin.js');
1151
+ }
1152
+
1153
+ try {
1154
+ const p = inspectProfile();
1155
+ add(
1156
+ 'profile',
1157
+ p.exists ? (p.drifted ? 'warn' : 'ok') : 'warn',
1158
+ `${p.dir} exists=${p.exists} managed=${p.managed} drifted=${p.drifted} routes=${p.routes.join(',') || 'none'}`,
1159
+ p.exists ? null : '让服务首启自动创建,或运行 dsh-any-agent-setup',
1160
+ );
1161
+ } catch (e) {
1162
+ add('profile', 'fail', e.message);
1163
+ }
1164
+
1165
+ try {
1166
+ const raw = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
1167
+ add(
1168
+ 'state',
1169
+ raw.version === 1 ? 'ok' : 'warn',
1170
+ `${STATE_FILE} version=${raw.version} conversations=${raw.conversations?.length ?? 0}`,
1171
+ );
1172
+ } catch (e) {
1173
+ add('state', 'warn', `${STATE_FILE}: ${e.message}`, '首次启动会自动创建');
1174
+ }
1175
+
1176
+ for (const [id, dir] of [
1177
+ ['runs_dir', RUNS_DIR],
1178
+ ['locks_dir', LOCKS_DIR],
1179
+ ]) {
1180
+ try {
1181
+ mkdirSync(dir, { recursive: true });
1182
+ const probe = join(dir, `.doctor-${process.pid}`);
1183
+ writeFileSync(probe, '');
1184
+ rmSync(probe, { force: true });
1185
+ add(id, 'ok', dir);
1186
+ } catch (e) {
1187
+ add(id, 'fail', `${dir}: ${e.message}`, '检查权限,或用对应环境变量指向可写目录');
1188
+ }
1189
+ }
1190
+
1191
+ try {
1192
+ const orphans = listOrphans();
1193
+ add(
1194
+ 'orphan_locks',
1195
+ orphans.length ? 'warn' : 'ok',
1196
+ orphans.length ? `${orphans.length} 个孤儿锁持有者(可 dsh_takeover)` : '没有孤儿锁',
1197
+ );
1198
+ } catch (e) {
1199
+ add('orphan_locks', 'warn', e.message);
1200
+ }
1201
+
1202
+ const all = [...hub.conversations.values()];
1203
+ const alive = all.filter((c) => c.proc?.alive);
1204
+ const totalRss = alive.reduce((s, c) => s + (rssMb(c.proc?.pid) ?? 0), 0);
1205
+ add('sessions', 'ok', `共 ${all.length},活进程 ${alive.length},RSS 合计 ${totalRss}MB`);
1206
+ add(
1207
+ 'config',
1208
+ 'ok',
1209
+ `profile=${DSH_PROFILE} team=${TEAM_ACTIVE ? 'on' : 'off'} permission=${DEFAULT_PERMISSION} ` +
1210
+ `idle_ttl=${IDLE_TTL_MS}ms max_live=${MAX_LIVE} max_rss=${MAX_RSS_MB}MB shared=${SHARED_PROCESS}`,
1211
+ );
1212
+
1213
+ const ok = !checks.some((c) => c.status === 'fail');
1214
+ const lines = checks.map(
1215
+ (c) =>
1216
+ `${c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : '✗'} ${c.id}: ${c.detail}` +
1217
+ (c.fix ? ` → ${c.fix}` : ''),
1218
+ );
1219
+ return {
1220
+ structured: { ok, verdict: ok ? 'ok' : 'fail', checks },
1221
+ text: `${ok ? '体检通过' : '体检发现失败项'}\n${lines.join('\n')}`,
1222
+ };
1223
+ },
707
1224
  };
708
1225
  }