dsh-any-agent 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,8 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ Post-0.3.6 hardening.
11
+
12
+ ## [0.3.6] — 2026-11-02
13
+
10
14
  Post-0.3.5 hardening (remaining completeness-audit items).
11
15
 
16
+ ### Fixed
17
+
18
+ - `dsh_status` now reports `lock_holder_reason` and no longer calls `none`
19
+ "your GUI" when the conversation is simply idle/released; the GUI hint is
20
+ only shown after an actual lock conflict.
21
+ - `dsh_get` reports `title_source` (`native` / `title_hint` / `null`) and no
22
+ longer labels a `title_hint` fallback as "DSH generated".
23
+ - `dsh_read` caps a single output entry at 200k characters (keeps the tail)
24
+ so one runaway tool output cannot grow the in-memory buffer without bound.
25
+ - `dsh_list` exposes `probe_ok` / `probe_error` / `probe_cached`; a failed
26
+ on-disk probe is no longer indistinguishable from "no sessions".
27
+ - MCP clients that send `notifications/roots/list_changed` now invalidate the
28
+ cached project directory and re-request `roots/list`.
29
+ - Client config backups rotate (default 20 per file, `DSH_MCP_BACKUP_KEEP`);
30
+ `dsh-any-agent-restore` writes atomically and preserves the target mode.
31
+ - The shared `workspace.json` writer keeps a `<file>.bak-dsh` last-known-good
32
+ copy before replacing it.
33
+ - `dsh_takeover` now compares the child's OS start time with the registration
34
+ before sending SIGKILL; a mismatched PID (reuse) is refused.
35
+ - Website: full English page at `/en/`, hreflang alternates, bilingual sitemap,
36
+ JSON-LD, Cloudflare Insights domains in CSP, and a pre-paint theme script
37
+ (CSP sha256) so light-system users no longer see a dark flash.
38
+
39
+ ### Known residual items
40
+
41
+ - Cloudflare Managed robots.txt / Content Signals are dashboard-managed; the
42
+ repository documents the split instead of trying to override them.
43
+ - Session-log header decompression is still synchronous; it is bounded by the
44
+ fact that only the header is parsed, but a very large log can still block
45
+ the event loop briefly. A fully async reader needs a broader refactor of
46
+ `Hub.get`/`findSessionHeader`.
47
+
12
48
  ## [0.3.5] — 2026-11-02
13
49
 
14
50
  Completeness-audit follow-up. Data-safety and privilege boundaries first,
@@ -616,7 +652,8 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
616
652
  profiles, sessions and client configs. The product name changed; the on-disk
617
653
  contract did not. See the README FAQ.
618
654
 
619
- [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...HEAD
655
+ [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...HEAD
656
+ [0.3.6]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...v0.3.6
620
657
  [0.3.5]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.4...v0.3.5
621
658
  [0.3.4]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.3...v0.3.4
622
659
  [0.3.3]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.2...v0.3.3
package/DESIGN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 目标:做一个 MCP Server,让 **Codex / Claude Code** 能调用 **DeepSeek Harness (DSH)** 当"外包工程师"——写代码、搭脚手架、跑任务,并且 DSH 自己还能挂它自己的 MCP。
4
4
  >
5
- > 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.5 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
5
+ > 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.6 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
6
6
 
7
7
  ---
8
8
 
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 815 checks green on 0.3.5 on this machine (851 when all nine client fixtures exist); 519 of them are in the client-registration suites (555 with all fixtures) · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
18
+ 🧪 815 checks green on 0.3.6 on this machine (851 when all nine client fixtures exist); 519 of them are in the client-registration suites (555 with all fixtures) · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
19
19
 
20
20
  An MCP server that drives DeepSeek Harness as a **long-lived coding agent**. Hand it a goal, let it write code and run scripts, steer it mid-flight, and collect the result when you are ready.
21
21
 
@@ -541,6 +541,7 @@ Silent by default — **not one byte is written**.
541
541
  | `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
542
542
  | `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
543
543
  | `DSH_MCP_GIT_STATUS` | on | `0` disables the per-turn `git status` summary. Useful for untrusted repos (git can run repo-configured fsmonitor/filters outside DSH's sandbox) |
544
+ | `DSH_MCP_BACKUP_KEEP` | `20` | How many `.bak-dsh-*` copies to keep per client config (rotation is best-effort) |
544
545
  | `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | Per-request timeout on the ACP channel (initialize, session/new, …) |
545
546
  | `DSH_MCP_REAP_INTERVAL_MS` | `30000` | How often the idle reaper checks for conversations to reclaim. Values below the safe floor fall back to the default |
546
547
  | `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
@@ -594,11 +595,11 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
594
595
  | `list` | 13 | **the cost of `dsh_list`** (measured): a default call with the on-disk probe takes ~1 s (it spawns a DSH process) while **repeated calls hit a cache and drop to single-digit milliseconds**; `only_running=true` and `include_closed=false` **skip the probe** with equivalent semantics (unopened on-disk sessions are simply excluded) |
595
596
  | `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
596
597
 
597
- **`node test/run.mjs` runs 815 checks on a machine with five client fixtures (851 with all nine)** — smoke 29 + 786 offline checks (822 with nine). Every suite exited 0 on 0.3.5; none needs a provider (smoke needs a DSH install for its session-creation section).
598
+ **`node test/run.mjs` runs 815 checks on a machine with five client fixtures (851 with all nine)** — smoke 29 + 786 offline checks (822 with nine). Every suite exited 0 on 0.3.6; none needs a provider (smoke needs a DSH install for its session-creation section).
598
599
 
599
600
  That is **519 assertions in the client-registration suites alone** (`setup` 41, `removal-scope` 24, `config-safety` 406, `real-configs` 48; 555 with all nine fixtures), which is where the 0.3 work actually is.
600
601
 
601
- The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8) total 474 and were last measured in full on 0.3.5 — they need a working provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` now **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`).
602
+ The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8) total 474 and were last measured in full on 0.3.6 — they need a working provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` now **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`).
602
603
 
603
604
  > Three different totals are intentional: `node test/run.mjs` (815/851), the CI offline job (786/822, no smoke), and the model-backed set (474 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
604
605
 
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 0.3.5 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
18
+ 🧪 0.3.6 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
19
19
 
20
20
  一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
21
21
 
@@ -556,6 +556,7 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
556
556
  | `DSH_MCP_PROMPT_TIMEOUT_MS` | `0`(不设超时) | **仅 `wait=true` 时**的等待上限;到期只转后台,不取消回合 |
557
557
  | `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
558
558
  | `DSH_MCP_GIT_STATUS` | 开 | `0` 关闭每回合的 `git status` 摘要。分析不可信仓库时建议关掉(git 可能触发仓库配置的 fsmonitor/filter,且发生在 DSH 沙箱之外) |
559
+ | `DSH_MCP_BACKUP_KEEP` | `20` | 每个客户端配置最多保留多少份 `.bak-dsh-*`(尽力轮转) |
559
560
  | `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | ACP 通道单请求超时(initialize、session/new 等) |
560
561
  | `DSH_MCP_REAP_INTERVAL_MS` | `30000` | 空闲回收器多久检查一次。低于安全下限的值会回落到默认值 |
561
562
  | `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
@@ -609,11 +610,11 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
609
610
  | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
610
611
  | `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
611
612
 
612
- **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.5 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
613
+ **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.6 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
613
614
 
614
615
  其中 **519 项属于客户端注册器套件**(`setup` 41、`removal-scope` 24、`config-safety` 406、`real-configs` 48;九种齐全时 555 项)—— 0.3 的活其实都在这里。
615
616
 
616
- 需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.5** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
617
+ 需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.6** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
617
618
 
618
619
  > 三个总数是刻意分开的:`node test/run.mjs`(815/851)、CI 离线 job(786/822,不含 smoke)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
619
620
 
package/bin/restore.mjs CHANGED
@@ -16,10 +16,10 @@
16
16
  * (后缀 .bak-dsh-pre-restore-<stamp>),退错了还能再退回来。
17
17
  */
18
18
 
19
- import { copyFileSync, existsSync, readdirSync, statSync } from 'node:fs';
19
+ import { chmodSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
20
  import { basename, dirname, join } from 'node:path';
21
21
 
22
- import { backupFile, clientList, resolveDirs, ENTRY_NAME } from '../src/clients.mjs';
22
+ import { atomicWrite, backupFile, clientList, resolveDirs, ENTRY_NAME } from '../src/clients.mjs';
23
23
 
24
24
  const argv = process.argv.slice(2);
25
25
  const has = (f) => argv.includes(f);
@@ -127,7 +127,11 @@ for (const b of chosen) {
127
127
  try {
128
128
  // 还原也是破坏性操作:先把「现在」也存一份。
129
129
  if (existsSync(b.target)) row.safety = backupFile(b.target, 'pre-restore-' + stamp);
130
- copyFileSync(b.file, b.target);
130
+ // tmp + rename:崩溃时不会留下半截配置;还原后保留原文件的权限位。
131
+ let mode = null;
132
+ try { mode = statSync(b.target).mode; } catch {}
133
+ atomicWrite(b.target, readFileSync(b.file, 'utf8'));
134
+ if (mode !== null) { try { chmodSync(b.target, mode); } catch {} }
131
135
  } catch (e) {
132
136
  row.action = 'failed';
133
137
  row.error = e.message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-agent",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code. Resume-aware, interruptible, approval-capable, and it only ever touches the one config entry it owns.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/clients.mjs CHANGED
@@ -25,13 +25,14 @@ import {
25
25
  existsSync,
26
26
  mkdirSync,
27
27
  readFileSync,
28
+ readdirSync,
28
29
  renameSync,
29
30
  statSync,
30
31
  unlinkSync,
31
32
  writeFileSync,
32
33
  } from 'node:fs';
33
34
  import { platform } from 'node:os';
34
- import { dirname } from 'node:path';
35
+ import { basename, dirname, join } from 'node:path';
35
36
  import { fileURLToPath } from 'node:url';
36
37
 
37
38
  /** 写进每个客户端的条目名。同时也是 --remove 唯一的识别依据。 */
@@ -57,9 +58,31 @@ export function backupFile(file, stamp) {
57
58
  let dst = base;
58
59
  for (let i = 2; existsSync(dst); i++) dst = base + '-' + i;
59
60
  copyFileSync(file, dst);
61
+ pruneBackups(file);
60
62
  return dst;
61
63
  }
62
64
 
65
+ /**
66
+ * 备份轮转:同一目标只保留最近 N 份(默认 20,DSH_MCP_BACKUP_KEEP 可调)。
67
+ * 备份里可能有凭据,无限累积既占空间也扩大暴露面;保留最近若干份足够回滚。
68
+ */
69
+ export function pruneBackups(file) {
70
+ const raw = Number(process.env.DSH_MCP_BACKUP_KEEP ?? 20);
71
+ const keep = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 20;
72
+ try {
73
+ const dir = dirname(file);
74
+ const prefix = basename(file) + '.bak-dsh-';
75
+ const backups = readdirSync(dir)
76
+ .filter((name) => name.startsWith(prefix))
77
+ .sort();
78
+ for (const name of backups.slice(0, Math.max(0, backups.length - keep))) {
79
+ try { unlinkSync(join(dir, name)); } catch {}
80
+ }
81
+ } catch {
82
+ /* 轮转失败不影响本次备份 */
83
+ }
84
+ }
85
+
63
86
  /**
64
87
  * 先写临时文件再改名,避免崩溃时留下半截配置。
65
88
  * 临时名带 pid + 随机后缀:同名会在并发运行时互相覆盖。
@@ -585,7 +608,7 @@ export function describeEntry(shape, entry) {
585
608
 
586
609
  import { spawnSync } from 'node:child_process';
587
610
  import { homedir } from 'node:os';
588
- import { join, resolve } from 'node:path';
611
+ import { resolve } from 'node:path';
589
612
 
590
613
  /** 解析出本次要用的 home 与 appdata(--home / 环境变量 / 平台默认)。 */
591
614
  export function resolveDirs(homeOverride) {
package/src/hub.mjs CHANGED
@@ -155,6 +155,8 @@ function pruneStaleTombstones() {
155
155
 
156
156
  /** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
157
157
  const MAX_OUT_ENTRIES = 500;
158
+ /** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
159
+ const MAX_OUT_TEXT = 200_000;
158
160
  /** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
159
161
  const MAX_PROMPT_ECHO = 4000;
160
162
  /** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
@@ -350,10 +352,15 @@ export class Conversation {
350
352
  snapshot() {
351
353
  const meta = readConversationMeta(this.id);
352
354
  const nativeTitle = meta?.title ?? null;
355
+ const holder = this.proc?.alive
356
+ ? { kind: 'self', reason: '本进程持有该会话的写锁' }
357
+ : classifyHolder(this.id, { knownChildPid: this.lastChildPid });
353
358
  return {
354
359
  conversation_id: this.id,
355
360
  /** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
356
361
  title: nativeTitle ?? this.titleHint ?? null,
362
+ /** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
363
+ title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
357
364
  /**
358
365
  * DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
359
366
  * 例如「这是一次连通性冒烟测试,请」),信息量很低。
@@ -390,8 +397,13 @@ export class Conversation {
390
397
  approval_id: approvalId,
391
398
  ...pending.params.details,
392
399
  })),
393
- /** 不在本进程手里时,写锁在谁手上(none=多半是你自己的 GUI / stale-mcp=可抢占的孤儿 / live-mcp=另一个实例)。 */
394
- lock_holder: this.proc?.alive ? 'self' : classifyHolder(this.id, { knownChildPid: this.lastChildPid }).kind,
400
+ /**
401
+ * 不在本进程手里时,写锁在谁手上:
402
+ * self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
403
+ * 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
404
+ */
405
+ lock_holder: holder.kind,
406
+ lock_holder_reason: holder.reason ?? null,
395
407
  /** ★ 最近一次"撞写锁"的持久记录。
396
408
  * 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
397
409
  * dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
@@ -569,7 +581,7 @@ export class Conversation {
569
581
  }
570
582
 
571
583
  // 3) 杀 + 重试(内核在持有者进程退出时释放信号量)
572
- const kill = killHolderChild(killTarget);
584
+ const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
573
585
  report.killed_pid = killTarget;
574
586
  report.kill = kill;
575
587
  if (!kill.killed) {
@@ -949,14 +961,15 @@ export class Conversation {
949
961
  /** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
950
962
  pushOut(kind, text, extra = {}) {
951
963
  const last = this.out[this.out.length - 1];
964
+ const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
952
965
  if (last && last.kind === kind && last.turn === this.currentTurn && text) {
953
- last.text += text;
966
+ last.text = cap(last.text + text);
954
967
  last.at = now();
955
968
  // ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
956
969
  // 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
957
970
  last.rev = ++this.outRev;
958
971
  } else {
959
- this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: text ?? '', at: now(), ...extra });
972
+ this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
960
973
  if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
961
974
  }
962
975
  if (kind === 'text') this.turnOutChars += text?.length ?? 0;
@@ -1183,6 +1196,8 @@ export class Hub {
1183
1196
  constructor({ log } = {}) {
1184
1197
  this.conversations = new Map();
1185
1198
  this.log = log ?? NOOP_LOG;
1199
+ /** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
1200
+ this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
1186
1201
  const rawApprovalTimeout = Number(process.env.DSH_MCP_APPROVAL_TIMEOUT_MS ?? 5 * 60 * 1000);
1187
1202
  // 空串会变成 0 / NaN → setTimeout(0) 会把 ask 审批立刻取消;统一回落到 5 分钟。
1188
1203
  this.approvalTimeoutMs =
@@ -1591,7 +1606,10 @@ export class Hub {
1591
1606
  if (isForgotten(c.id)) continue;
1592
1607
  out.set(c.id, c.snapshot());
1593
1608
  }
1594
- if (!includeClosed) return [...out.values()];
1609
+ if (!includeClosed) {
1610
+ this.lastProbe = { ok: null, error: null, cached: false, at: now() };
1611
+ return [...out.values()];
1612
+ }
1595
1613
 
1596
1614
  // 磁盘探测要 spawn 一个 DSH 进程(实测约 1 秒),所以同 cwd 的结果缓存一小段时间,
1597
1615
  // 避免"连着列几次会话"就反复拉进程。
@@ -1603,7 +1621,9 @@ export class Hub {
1603
1621
 
1604
1622
  const probeCwd = cwd ?? [...this.conversations.values()][0]?.cwd ?? process.cwd();
1605
1623
  let sessions = cached;
1606
- if (!sessions) {
1624
+ if (cached) {
1625
+ this.lastProbe = { ok: true, error: null, cached: true, at: this.#probeCache.at };
1626
+ } else if (!sessions) {
1607
1627
  let proc = null;
1608
1628
  try {
1609
1629
  proc = new AcpProcess({ cwd: probeCwd, permission: 'read-only' });
@@ -1612,9 +1632,12 @@ export class Hub {
1612
1632
  const res = await proc.request('session/list', cwd ? { cwd } : {});
1613
1633
  sessions = res?.sessions ?? [];
1614
1634
  this.#probeCache = { key: cacheKey, at: now(), sessions };
1635
+ this.lastProbe = { ok: true, error: null, cached: false, at: this.#probeCache.at };
1615
1636
  } catch (e) {
1616
1637
  this.log.info(`[hub] session/list 探测失败: ${e.message}`);
1617
1638
  sessions = []; // 失败不缓存,下次重试
1639
+ // ★ 调用方必须能区分"没有别的会话"和"探测失败",否则会以为会话丢了。
1640
+ this.lastProbe = { ok: false, error: e.message, cached: false, at: now() };
1618
1641
  } finally {
1619
1642
  await proc?.stop().catch(() => {});
1620
1643
  }
package/src/locks.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, renameSync } from 'node:fs';
30
+ import { spawnSync } from 'node:child_process';
30
31
  import { join, dirname } from 'node:path';
31
32
  import { randomUUID } from 'node:crypto';
32
33
  import { STATE_FILE, REAP_INTERVAL_MS, numEnv } from './config.mjs';
@@ -220,8 +221,21 @@ export function classifyHolder(conversationId, { knownChildPid = null } = {}) {
220
221
  * 杀掉一个 DSH 子进程。**只允许对 classification 判定为 `stale-mcp` / `live-mcp(force)`
221
222
  * 的、由本 MCP 登记过的进程调用** —— 调用方负责先做分类。
222
223
  */
223
- export function killHolderChild(pid) {
224
+ export function killHolderChild(pid, { expectedSpawnedAt } = {}) {
224
225
  if (!pid || !isPidAlive(pid)) return { killed: false, reason: '进程已经不在了' };
226
+ // ★ PID 会被复用。抢占是"杀进程"这种不可逆动作,所以先核对启动时间:
227
+ // 登记时间与 OS 记录的启动时间差太多 → 这已经不是当初登记的那个进程,拒绝杀。
228
+ if (Number.isFinite(expectedSpawnedAt)) {
229
+ const actual = processStartTime(pid);
230
+ if (Number.isFinite(actual) && Math.abs(actual - expectedSpawnedAt) > 10 * 60 * 1000) {
231
+ return {
232
+ killed: false,
233
+ reason:
234
+ `PID ${pid} 的启动时间与登记不符(登记 ${new Date(expectedSpawnedAt).toISOString()},` +
235
+ `实际 ${new Date(actual).toISOString()}),疑似 PID 已被复用,拒绝杀。`,
236
+ };
237
+ }
238
+ }
225
239
  try {
226
240
  process.kill(pid, 'SIGKILL');
227
241
  } catch (e) {
@@ -230,6 +244,30 @@ export function killHolderChild(pid) {
230
244
  return { killed: true, reason: `已向 PID ${pid} 发送 SIGKILL` };
231
245
  }
232
246
 
247
+ /**
248
+ * 读取进程启动时间(毫秒时间戳)。拿不到时返回 null(调用方按旧行为处理)。
249
+ * Windows 用 PowerShell 的 Get-Process.StartTime;POSIX 用 ps -o lstart=。
250
+ * 只在 takeover 这种低频、不可逆动作前调用,不在热路径上。
251
+ */
252
+ export function processStartTime(pid) {
253
+ if (!pid) return null;
254
+ try {
255
+ const args =
256
+ process.platform === 'win32'
257
+ ? ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToString('o')`]
258
+ : ['-o', 'lstart=', '-p', String(pid)];
259
+ const r = spawnSync(process.platform === 'win32' ? 'powershell' : 'ps', args, {
260
+ encoding: 'utf8',
261
+ timeout: 3000,
262
+ windowsHide: true,
263
+ });
264
+ const t = Date.parse(String(r.stdout || '').trim());
265
+ return Number.isFinite(t) ? t : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
233
271
  /**
234
272
  * 启动清理:删掉"MCP 已死 + 子进程也死了"的登记文件。
235
273
  * 注意**不删**那些子进程还活着的登记 —— 那正是后面抢占要用的线索。
package/src/mcp.mjs CHANGED
@@ -183,6 +183,12 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
183
183
  // 通知(无 id):不需要回包
184
184
  if (id === undefined) {
185
185
  if (method === 'notifications/initialized') initialized = true;
186
+ // 客户端换了打开的项目:丢掉缓存并重新问一次,下一次 dsh_start 才会用新目录。
187
+ if (method === 'notifications/roots/list_changed') {
188
+ clientRoots = null;
189
+ rootsRequested = false;
190
+ requestRoots().catch(() => {});
191
+ }
186
192
  return;
187
193
  }
188
194
 
package/src/tools.mjs CHANGED
@@ -459,9 +459,13 @@ export function createHandlers(hub, opts = {}) {
459
459
  if (c.turns != null) bits.push(`轮数=${c.turns}`);
460
460
  return `- ${bits.join(' | ')}`;
461
461
  };
462
+ const probe = hub.lastProbe ?? { ok: null, error: null, cached: false };
463
+ const probeNote = probe.ok === false
464
+ ? `\n\n⚠️ 磁盘会话探测失败:${probe.error}\n 上面只列出本服务已打开的会话;磁盘上未打开的会话可能没显示。`
465
+ : '';
462
466
  const text = items.length
463
467
  ? `${items.length} 个会话(其中 ${running} 个正在跑):\n${items.map(describe).join('\n')}`
464
- : '(没有会话)';
468
+ : probe.ok === false ? '(本服务没有已打开的会话;磁盘探测失败)' : '(没有会话)';
465
469
  // 孤儿锁持有者:上一代 MCP 崩了但它的 DSH 子进程还活着握着写锁 —— 不主动报出来,
466
470
  // 用户只会在下一次撞上"被占用"时才发现。这里顺手提示,并给出可执行的动作。
467
471
  const orphans = listOrphans();
@@ -471,8 +475,16 @@ export function createHandlers(hub, opts = {}) {
471
475
  '\n→ 用 dsh_takeover 抢占即可(这些是本服务自己的残留,抢占是安全的)。'
472
476
  : '';
473
477
  return {
474
- structured: { count: items.length, running, conversations: items, orphan_holders: orphans },
475
- text: text + orphanNote,
478
+ structured: {
479
+ count: items.length,
480
+ running,
481
+ conversations: items,
482
+ orphan_holders: orphans,
483
+ probe_ok: probe.ok,
484
+ probe_error: probe.error ?? null,
485
+ probe_cached: probe.cached === true,
486
+ },
487
+ text: text + probeNote + orphanNote,
476
488
  };
477
489
  },
478
490
 
@@ -508,7 +520,12 @@ export function createHandlers(hub, opts = {}) {
508
520
  let run = null;
509
521
  const meta = [];
510
522
  // DSH 的自动标题是首条消息的截断前缀,信息量低;调用方给过 title_hint 就一并显示。
511
- meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(DSH 自动生成)` +
523
+ const titleKind = snap.title_source === 'native'
524
+ ? 'DSH 自动生成'
525
+ : snap.title_source === 'title_hint'
526
+ ? '来自 title_hint(DSH 尚未生成标题)'
527
+ : '尚未生成';
528
+ meta.push(`对话名: ${snap.title ?? '(尚未生成)'}(${titleKind})` +
512
529
  (snap.title_hint && snap.title !== snap.title_hint ? ` / 标题提示: ${snap.title_hint}` : ''));
513
530
  meta.push(`工作区: ${snap.cwd}`);
514
531
  meta.push(`状态: ${snap.busy ? '忙' : snap.alive ? '空闲(进程活着)' : '未打开(可用 resume 复活)'}`);
@@ -641,11 +658,14 @@ export function createHandlers(hub, opts = {}) {
641
658
  if (snap.lock_holder && snap.lock_holder !== 'self') {
642
659
  const hint =
643
660
  snap.lock_holder === 'stale-mcp'
644
- ? '(本服务残留的孤儿,可 dsh_takeover 抢占)'
661
+ ? '(本服务残留/卡死的持有者,可 dsh_takeover 抢占)'
645
662
  : snap.lock_holder === 'live-mcp'
646
663
  ? '(另一个活着的 dsh-any-agent 实例在用;要夺需 dsh_takeover(force=true))'
647
- : '(多半是你自己的 DSH GUI 正开着它;本服务不会去杀它)';
664
+ : snap.lock_conflict
665
+ ? '(最近撞过锁:多半是你自己的 DSH GUI 正开着它;本服务不会去杀它)'
666
+ : '(没有本服务的持锁登记;空闲/已释放时也会是 none,只有在撞锁时才可能是 GUI)';
648
667
  bits.push(`写锁=${snap.lock_holder}${hint}`);
668
+ if (snap.lock_holder_reason) bits.push(` 判定:${snap.lock_holder_reason}`);
649
669
  }
650
670
  // ★ 撞过锁就把"谁占用 + 怎么解"讲清楚。实测:GUI 点开一次就会长期持有写锁
651
671
  // (切走、等十几分钟都不释放),所以这条信息必须持久可见,而不是一闪而过的报错。
@@ -669,6 +689,7 @@ export function createHandlers(hub, opts = {}) {
669
689
  pending_approval_details: snap.pending_approval_details,
670
690
  turns: snap.turns,
671
691
  lock_holder: snap.lock_holder,
692
+ lock_holder_reason: snap.lock_holder_reason,
672
693
  lock_conflict: snap.lock_conflict,
673
694
  run: run
674
695
  ? {
package/src/workspace.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
16
16
  */
17
17
 
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
18
+ import { copyFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
21
21
  import { tmpdir } from 'node:os';
@@ -152,6 +152,12 @@ function readDoc() {
152
152
 
153
153
  function writeDoc(doc) {
154
154
  mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
155
+ // 保留上一份**可解析**的注册表:逻辑写坏时用户还能手工回滚。
156
+ try {
157
+ if (existsSync(WORKSPACE_FILE)) copyFileSync(WORKSPACE_FILE, `${WORKSPACE_FILE}.bak-dsh`);
158
+ } catch {
159
+ /* 备份失败不阻断写入 */
160
+ }
155
161
  // 临时文件名必须唯一:固定 `.dshmcp.tmp` 会让两个实例/CLI 并发写互相覆盖。
156
162
  const tmp = `${WORKSPACE_FILE}.${process.pid}.${randomUUID()}.tmp`;
157
163
  try {
@@ -355,9 +361,10 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
355
361
  * 删除测试会话(**这是唯一会动磁盘会话文件的函数**,所以规则写得很死)。
356
362
  *
357
363
  * 只会删这几类,其余一律不碰:
358
- * 1) 会话头里的 cwd 位于系统临时目录下
359
- * 2) 会话头里的 cwd 匹配 Desktop\dsh-any-agent-test-* —— 传 tempOnly 可关掉这一条
360
- * 3) ids 里显式指定的
364
+ * 1) testOnly:cwd 在 `tmpdir()/dsh-any-agent-*` 下(本仓库测试自己造的)
365
+ * 2) cwd 匹配 Desktop\dsh-any-agent-test-*
366
+ * 3) tempOnly:cwd 在系统临时目录下(显式人工命令才用,范围最大)
367
+ * 4) ids 里显式指定的
361
368
  *
362
369
  * @param {object} opts
363
370
  * @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)