dsh-any-agent 0.3.6 → 0.3.8

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,7 +7,51 @@ 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.
10
+ Post-0.3.8 hardening.
11
+
12
+ ## [0.3.8] — 2026-11-02
13
+
14
+ ### Changed
15
+
16
+ - `npm test` now works without a local DSH: smoke still checks handshake and
17
+ the tool table, then skips session-dependent sections instead of failing, so
18
+ all offline suites run. `npm run test:all` is the explicit full-suite entry.
19
+ - Added the `model-suites` workflow for a self-hosted runner with DSH and
20
+ provider credentials; hosted runners print an explicit skip.
21
+ - Added an English `USAGE-MIGRATION.en.md` and linked it from the English
22
+ README and `/en/` install section.
23
+
24
+ ### Testing
25
+
26
+ - Removed the remaining always-true `check(..., true)` assertions; cleanup
27
+ checks now assert actual release/forget results or timeout state.
28
+ - `prune` runs with an isolated `DSH_HOME` so it cannot touch real tombstones.
29
+ - Added 8 static accessibility tests for both locales (28 website tests total
30
+ counting the existing site/release/demo suites).
31
+ - `readHeaderAsync` now uses `StringDecoder` for multi-byte UTF-8 boundaries.
32
+
33
+ ### Website
34
+
35
+ - `robots.txt` now mirrors the complete Cloudflare Managed crawler list, not
36
+ just the four most common bots.
37
+
38
+ ## [0.3.7] — 2026-11-02
39
+
40
+ ### Fixed
41
+
42
+ - **Session-header lookup no longer blocks the event loop.** `Hub.get()`,
43
+ `Hub.load()` and `pruneStaleTombstones()` now use a streaming
44
+ `readHeaderAsync()` that stops at the first newline, with a 5 s timeout and
45
+ 1 MiB header cap. Large session logs can no longer stall MCP requests,
46
+ heartbeat renewal, or approval handling while recovering an unknown
47
+ conversation. The synchronous reader remains only in the explicit
48
+ workspace CLI/test-cleanup path.
49
+ - **Website robots policy mirrors Cloudflare Managed content.** The generated
50
+ `robots.txt` now states `Content-Signal: search=yes, ai-train=no` and
51
+ disallows GPTBot/ClaudeBot/Google-Extended/CCBot, so non-Cloudflare hosts
52
+ and the repository agree with the production policy.
53
+ - Website tests now cover the English locale, robots policy, hreflang, CSP
54
+ hash, and bilingual pages (20 tests).
11
55
 
12
56
  ## [0.3.6] — 2026-11-02
13
57
 
@@ -652,7 +696,9 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
652
696
  profiles, sessions and client configs. The product name changed; the on-disk
653
697
  contract did not. See the README FAQ.
654
698
 
655
- [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...HEAD
699
+ [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.8...HEAD
700
+ [0.3.8]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.7...v0.3.8
701
+ [0.3.7]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...v0.3.7
656
702
  [0.3.6]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...v0.3.6
657
703
  [0.3.5]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.4...v0.3.5
658
704
  [0.3.4]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.3...v0.3.4
package/DESIGN.md CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  > 目标:做一个 MCP Server,让 **Codex / Claude Code** 能调用 **DeepSeek Harness (DSH)** 当"外包工程师"——写代码、搭脚手架、跑任务,并且 DSH 自己还能挂它自己的 MCP。
4
4
  >
5
- > 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.6 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
5
+ > 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.8 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
6
+
7
+ ## 当前实现状态(0.3.8)
8
+
9
+ 当前对外暴露 11 个工具,均已实现并有回归:
10
+
11
+ `dsh_start`、`dsh_send`、`dsh_list`、`dsh_read`、`dsh_get`、`dsh_interject`、`dsh_interrupt`、`dsh_release`、`dsh_takeover`、`dsh_approval_decide`、`dsh_status`
12
+
13
+ 本文后续出现的 `dsh_resume`、`dsh_rename`、`dsh_delete`、`dsh_transcript`、`dsh_diff`、`dsh_doctor` 等属于 M2/M3 路线图,**不是当前版本的缺失**。当前版本的能力边界以 README 的 Known limitations 与 USAGE-NOTES 为准。
6
14
 
7
15
  ---
8
16
 
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
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
18
+ 🧪 815 checks green on 0.3.8 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
 
@@ -33,7 +33,7 @@ dsh-any-agent-setup --yes
33
33
 
34
34
  The second command previews the changes; `--yes` applies them. Reconnect your client's MCP server, then ask your agent to start a DSH conversation in your project, dispatch a task, and collect the result. The [dispatch workflow](#-workflow-dispatch-then-collect-later) shows the tool sequence.
35
35
 
36
- > **Before your first task:** read the [Usage Notes](USAGE-NOTES.en.md), especially the model-config and external-MCP permission boundaries. External MCP tools bypass DSH's sandbox. For a fresh machine, follow the [migration guide](USAGE-MIGRATION.md).
36
+ > **Before your first task:** read the [Usage Notes](USAGE-NOTES.en.md), especially the model-config and external-MCP permission boundaries. External MCP tools bypass DSH's sandbox. For a fresh machine, follow the [migration guide](USAGE-MIGRATION.en.md).
37
37
 
38
38
  <details>
39
39
  <summary>Full contents</summary>
@@ -550,11 +550,15 @@ Silent by default — **not one byte is written**.
550
550
  ## 🧪 Tests
551
551
 
552
552
  ```bash
553
- node test/run.mjs # all offline suites (no LLM calls; needs DSH for smoke)
554
- node test/run.mjs --all # offline + model-backed suites
553
+ npm test # offline suites; smoke skips DSH sections if no DSH
554
+ npm run test:all # offline + model-backed suites (needs DSH/provider)
555
555
  node test/cleanup.mjs # suite teardown on its own (test-created dirs only; --dry-run to preview)
556
556
  ```
557
557
 
558
+ Model-backed suites are not part of the hosted CI matrix (they need a real DSH
559
+ install and provider credentials). Run them locally, or dispatch the
560
+ `model-suites` workflow with a self-hosted runner that has DSH configured.
561
+
558
562
  The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`, which removes sessions and workspace registrations whose cwd is **a test-created directory** (`tmpdir()/dsh-any-agent-*` or `Desktop/dsh-any-agent-test-*`). Real project sessions — including projects under `%TEMP%` — and your live state directory are never touched. To remove `Desktop/dsh-any-agent-test-*` artifacts explicitly, run `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`.
559
563
 
560
564
  | Suite | Checks | Covers |
@@ -595,11 +599,11 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
595
599
  | `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) |
596
600
  | `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
597
601
 
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).
602
+ **`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.8; none needs a provider (smoke skips its session-creation sections when DSH is absent).
599
603
 
600
604
  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.
601
605
 
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__*`).
606
+ 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.8 — 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__*`).
603
607
 
604
608
  > 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.
605
609
 
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 0.3.6 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
18
+ 🧪 0.3.8 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
19
19
 
20
20
  一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
21
21
 
@@ -565,11 +565,14 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
565
565
  ## 🧪 测试
566
566
 
567
567
  ```bash
568
- node test/run.mjs # 全部离线套件(不消耗 token;smoke 段需要装 DSH)
569
- node test/run.mjs --all # 离线 + 需要模型的套件
568
+ npm test # 全部离线套件;没有 DSH 时 smoke 自动跳过会话段
569
+ npm run test:all # 离线 + 需要模型的套件(需要 DSH/provider)
570
570
  node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目录;--dry-run 可预演)
571
571
  ```
572
572
 
573
+ 模型套件不在 GitHub 托管 CI 里(需要真实 DSH、provider 与凭据)。在本机跑,
574
+ 或用 `model-suites` workflow 指定一台装好 DSH 的 self-hosted runner。
575
+
573
576
  测试套件**会自己收尾**:`run.mjs` 结束时必定执行 `cleanup.mjs`,它只清理 cwd 在**测试自己造的目录**(`tmpdir()/dsh-any-agent-*` 或桌面 `dsh-any-agent-test-*`)里的会话与工作区登记。**用户在 `%TEMP%` 里自己的真实项目、以及你的状态目录都不会被碰**。要连桌面测试目录一起清,可显式跑 `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`。
574
577
 
575
578
  | 套件 | 检查数 | 覆盖 |
@@ -610,11 +613,11 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
610
613
  | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
611
614
  | `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
612
615
 
613
- **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.6 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
616
+ **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.8 上全部退出码 0;都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
614
617
 
615
618
  其中 **519 项属于客户端注册器套件**(`setup` 41、`removal-scope` 24、`config-safety` 406、`real-configs` 48;九种齐全时 555 项)—— 0.3 的活其实都在这里。
616
619
 
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**。
620
+ 需要模型的套件(`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.8** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
618
621
 
619
622
  > 三个总数是刻意分开的:`node test/run.mjs`(815/851)、CI 离线 job(786/822,不含 smoke)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
620
623
 
@@ -0,0 +1,160 @@
1
+ # Moving to a new machine / first-time setup (English)
2
+
3
+ This is the short English version of `USAGE-MIGRATION.md`. It covers a fresh
4
+ install, registering the MCP server with your client, and the checks worth
5
+ doing before you trust it with real work.
6
+
7
+ ## 1. Prerequisites
8
+
9
+ | Item | Requirement |
10
+ |---|---|
11
+ | Node.js | **≥ 22.15** (`zstdDecompressSync` is required) |
12
+ | DSH | DeepSeek Harness installed and able to answer a normal chat turn |
13
+ | Provider | A model route configured in DSH, with credentials available through `~/.dsh/.credentials.yaml` or environment variables |
14
+
15
+ > The order matters. This server does **not** ship a provider or credentials.
16
+ > On first start it copies the model route from an existing DSH profile. If DSH
17
+ > itself is not configured, the first call fails with a provider error.
18
+
19
+ ## 2. Install and register
20
+
21
+ ```bash
22
+ npm install -g dsh-any-agent
23
+
24
+ # Preview only: reads configs, writes nothing
25
+ dsh-any-agent-setup
26
+
27
+ # Apply
28
+ dsh-any-agent-setup --yes
29
+
30
+ # Limit to some clients, or choose a permission tier
31
+ dsh-any-agent-setup --yes --only cursor,zed
32
+ dsh-any-agent-setup --yes --permission workspace-write
33
+
34
+ # Remove only this server's entry
35
+ dsh-any-agent-setup --remove --yes
36
+ ```
37
+
38
+ Supported clients: **Claude Code, Claude Desktop, Codex, Cursor, Windsurf,
39
+ Zed, Gemini CLI, opencode, VS Code**. Clients that are not installed are
40
+ reported as skipped, not failed.
41
+
42
+ The writer is deliberately conservative:
43
+
44
+ - preview mode never writes and never starts a child process;
45
+ - every changed file is backed up as `<file>.bak-dsh-<stamp>`;
46
+ - JSON/JSONC/TOML edits are text-level and only touch the
47
+ `dsh-any-agent` entry (indentation, key order, CRLF, BOM and comments are
48
+ preserved);
49
+ - malformed or read-only configs are refused rather than overwritten;
50
+ - a second `--yes` is a no-op;
51
+ - `setup` does **not** invoke client CLIs such as `claude mcp get` (that can
52
+ make Claude Code rewrite `~/.claude.json`).
53
+
54
+ ## 3. Manual configuration (optional)
55
+
56
+ If your client is not covered by setup, add a stdio MCP server. For example:
57
+
58
+ Codex `~/.codex/config.toml`:
59
+
60
+ ```toml
61
+ [mcp_servers.dsh-any-agent]
62
+ command = "dsh-any-agent"
63
+ args = []
64
+ startup_timeout_sec = 20.0
65
+ tool_timeout_sec = 120.0
66
+ ```
67
+
68
+ Generic JSON client:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "dsh-any-agent": {
74
+ "command": "dsh-any-agent",
75
+ "args": []
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ `setup --yes` writes the absolute node path, the entry file, and
82
+ `DSH_MCP_PERMISSION` / `DSH_MCP_LOG`; the examples above assume
83
+ `dsh-any-agent` is on `PATH`.
84
+
85
+ ## 4. First start and profile bootstrap
86
+
87
+ On first start the server creates `~/.dsh/profiles/dsh-mcp/` and copies the
88
+ model route from an existing DSH profile (`home` → `desktop` → `web` →
89
+ `headless` → `acp`, first usable one). It uses a built-in zero-dependency YAML
90
+ subset parser, so no extra npm package is needed.
91
+
92
+ What is copied: `apiKeyEnv`, `baseURL`, `api`, `models`, reasoning metadata.
93
+ What is stripped: literal API keys, nested `headers`, and URL userinfo. The
94
+ generated profile includes the three permission presets but **never**
95
+ `defaultPreset` (that would override the permission tier at session creation).
96
+
97
+ Credentials remain in DSH's own credential store; this project never owns them.
98
+ Set `DSH_MCP_BOOTSTRAP=0` to disable profile writing entirely, or
99
+ `DSH_MCP_ROUTE_SOURCE=<profile>` / `__home__` to pin the route source.
100
+
101
+ ## 5. Permission tiers
102
+
103
+ | Tier | Meaning |
104
+ |---|---|
105
+ | `read-only` | DSH's sandbox is read-only; approvals are forced to `auto-deny` |
106
+ | `workspace-write` | Writes are limited to the workspace by DSH's sandbox |
107
+ | `danger-full-access` | No workspace sandbox |
108
+
109
+ The server's raw default is `danger-full-access` when `DSH_MCP_PERMISSION` is
110
+ not set; `setup` writes `workspace-write` into the client config. Out-of-
111
+ workspace approval requests default to `auto-allow`; pass `on_approval=ask`
112
+ when creating a session if you want `dsh_approval_decide` to adjudicate them.
113
+
114
+ `read-only` is a hard boundary at this layer: `read-only + auto-allow/ask` is
115
+ rejected, and the server reads back the session's recorded permission tier
116
+ after create/resume. External MCP tools mounted in DSH are outside DSH's
117
+ sandbox; read `USAGE-NOTES.en.md` before using them with untrusted data.
118
+
119
+ ## 6. Verify
120
+
121
+ ```bash
122
+ dsh-any-agent --version
123
+ dsh-any-agent-setup --help
124
+ dsh-any-agent-setup # preview should show your clients
125
+ ```
126
+
127
+ In the client, ask the agent to call `dsh_start` with a small project
128
+ directory, then `dsh_send` a short prompt and `dsh_get` the result. If the
129
+ client cannot see the server, restart the client after setup.
130
+
131
+ ## 7. Common problems
132
+
133
+ - **`DSH CLI entry not found`** — set `DSH_BIN` to
134
+ `.../node_modules/@deepseek-ai/dsh/lib/bin.js`, or install DSH in a location
135
+ the server can discover.
136
+ - **`no adapter registered for provider` / no API key** — DSH itself is not
137
+ configured, or the profile route was not copied. Open DSH once, configure a
138
+ model, then restart the MCP server.
139
+ - **`session ... is already owned by an active write handle`** — the session
140
+ is held by another process (often the DSH GUI). Use `dsh_status` to see
141
+ `lock_holder`; use `dsh_release` on the other instance or `dsh_takeover`
142
+ when the holder is a stale dsh-any-agent process. Never kill a GUI process.
143
+ - **Nothing happens when waiting for completion** — use the returned
144
+ `sentinel_file`; a turn that is interrupted by a process exit may not land
145
+ one. Inspect the workspace and re-dispatch.
146
+ - **Old state after upgrading** — the registry moved from
147
+ `<repo>/.state/conversations.json` to
148
+ `$DSH_HOME/.dsh-any-agent/conversations.json`; the first 0.3.x start
149
+ migrates it automatically. Use `DSH_MCP_STATE` to override.
150
+
151
+ ## 8. Upgrade / rollback
152
+
153
+ `npm install -g dsh-any-agent@latest` upgrades the server. Client configs are
154
+ not rewritten by an upgrade; run `dsh-any-agent-setup` again only if you want
155
+ to refresh the entry or permission env. `dsh-any-agent-restore` can restore a
156
+ client config from a `.bak-dsh-*` backup.
157
+
158
+ For the full Chinese guide, environment-variable reference, write-lock
159
+ measurements and website deployment notes, see `USAGE-MIGRATION.md`,
160
+ `README.md` and `USAGE-NOTES.en.md`.
@@ -59,7 +59,7 @@ const { createHandlers } = await import('../src/tools.mjs');
59
59
  const log = createLogger();
60
60
 
61
61
  const hub = new Hub({ log });
62
- hub.load();
62
+ await hub.load();
63
63
  hub.startReaper();
64
64
  // 启动时清一次过期哨兵(哨兵内含正文,长期不清理会无声堆积)
65
65
  const prunedSentinels = hub.pruneSentinels();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-agent",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",
@@ -36,6 +36,7 @@
36
36
  "USAGE-NOTES.en.md",
37
37
  "USAGE-NOTES.md",
38
38
  "USAGE-MIGRATION.md",
39
+ "USAGE-MIGRATION.en.md",
39
40
  "CHANGELOG.md",
40
41
  "LICENSE",
41
42
  "DESIGN.md",
@@ -45,6 +46,7 @@
45
46
  "start": "node ./bin/dsh-any-agent.mjs",
46
47
  "setup": "node ./bin/setup.mjs",
47
48
  "test": "node ./test/run.mjs",
49
+ "test:all": "node ./test/run.mjs --all",
48
50
  "test:smoke": "node ./test/smoke.mjs",
49
51
  "test:guards": "node ./test/guards.mjs",
50
52
  "test:prune": "node ./test/prune.mjs",
package/src/hub.mjs CHANGED
@@ -134,14 +134,15 @@ function isForgotten(id) {
134
134
  * 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
135
135
  * 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
136
136
  */
137
- function pruneStaleTombstones() {
137
+ async function pruneStaleTombstones() {
138
138
  let removed = 0;
139
139
  try {
140
140
  if (!existsSync(FORGOTTEN_DIR)) return 0;
141
141
  for (const f of readdirSync(FORGOTTEN_DIR)) {
142
142
  if (!f.endsWith('.json')) continue;
143
143
  const id = f.slice(0, -5);
144
- if (findSessionHeader(id)) continue;
144
+ // 异步读会话头:启动路径不能被大日志的同步解压卡住。
145
+ if (await findSessionHeader(id)) continue;
145
146
  try {
146
147
  rmSync(join(FORGOTTEN_DIR, f), { force: true });
147
148
  removed++;
@@ -1209,7 +1210,7 @@ export class Hub {
1209
1210
  /** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
1210
1211
  #probeCache = null;
1211
1212
 
1212
- load() {
1213
+ async load() {
1213
1214
  // ★ 启动即确认三个关键目录可写。以前"写失败只记 silent 日志"会让服务
1214
1215
  // 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
1215
1216
  assertWritableDir(dirname(STATE_FILE), '状态目录');
@@ -1234,7 +1235,7 @@ export class Hub {
1234
1235
  }
1235
1236
  }
1236
1237
 
1237
- const stale = pruneStaleTombstones();
1238
+ const stale = await pruneStaleTombstones();
1238
1239
  if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
1239
1240
  if (!existsSync(STATE_FILE)) return;
1240
1241
  try {
@@ -1373,7 +1374,7 @@ export class Hub {
1373
1374
  }
1374
1375
  }
1375
1376
 
1376
- get(id) {
1377
+ async get(id) {
1377
1378
  // ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
1378
1379
  // 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
1379
1380
  if (isForgotten(id)) {
@@ -1392,7 +1393,7 @@ export class Hub {
1392
1393
  if (!c) {
1393
1394
  // 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
1394
1395
  // 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
1395
- const header = findSessionHeader(id);
1396
+ const header = await findSessionHeader(id);
1396
1397
  if (header?.cwd) {
1397
1398
  // ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
1398
1399
  // 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
package/src/tools.mjs CHANGED
@@ -403,7 +403,7 @@ export function createHandlers(hub, opts = {}) {
403
403
  if (typeof args.prompt !== 'string' || args.prompt.trim() === '') {
404
404
  throw new Error('prompt 不能为空:至少包含一个非空白字符');
405
405
  }
406
- const conv = hub.get(args.conversation_id);
406
+ const conv = await hub.get(args.conversation_id);
407
407
  const opts = { reasoning: args.reasoning ?? DEFAULT_REASONING };
408
408
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
409
409
 
@@ -489,7 +489,7 @@ export function createHandlers(hub, opts = {}) {
489
489
  },
490
490
 
491
491
  async dsh_read(args) {
492
- const conv = hub.get(args.conversation_id);
492
+ const conv = await hub.get(args.conversation_id);
493
493
  const r = conv.readOut({
494
494
  cursor: args.cursor ?? 0,
495
495
  includeReasoning: args.include_reasoning === true,
@@ -515,7 +515,7 @@ export function createHandlers(hub, opts = {}) {
515
515
  },
516
516
 
517
517
  async dsh_get(args) {
518
- const conv = hub.get(args.conversation_id);
518
+ const conv = await hub.get(args.conversation_id);
519
519
  const snap = conv.snapshot();
520
520
  let run = null;
521
521
  const meta = [];
@@ -575,7 +575,7 @@ export function createHandlers(hub, opts = {}) {
575
575
  if (typeof args.message !== 'string' || args.message.trim() === '') {
576
576
  throw new Error('message 不能为空:至少包含一个非空白字符');
577
577
  }
578
- const conv = hub.get(args.conversation_id);
578
+ const conv = await hub.get(args.conversation_id);
579
579
  const opts = { mode: args.mode ?? 'interject', reasoning: args.reasoning ?? DEFAULT_REASONING };
580
580
  if (args.timeout_ms) opts.timeoutMs = args.timeout_ms;
581
581
  const r = await conv.interject(args.message, opts);
@@ -600,7 +600,7 @@ export function createHandlers(hub, opts = {}) {
600
600
  },
601
601
 
602
602
  async dsh_interrupt(args) {
603
- const conv = hub.get(args.conversation_id);
603
+ const conv = await hub.get(args.conversation_id);
604
604
  const r = await conv.interrupt();
605
605
  return { structured: r, text: r.interrupted ? '已发送中断请求。' : `未中断:${r.reason}` };
606
606
  },
@@ -620,7 +620,7 @@ export function createHandlers(hub, opts = {}) {
620
620
  `\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
621
621
  };
622
622
  }
623
- const conv = hub.get(args.conversation_id);
623
+ const conv = await hub.get(args.conversation_id);
624
624
  const r = await conv.close({ forget: args.forget === true });
625
625
  return {
626
626
  structured: { conversation_id: args.conversation_id, ...r },
@@ -631,19 +631,19 @@ export function createHandlers(hub, opts = {}) {
631
631
  },
632
632
 
633
633
  async dsh_takeover(args) {
634
- const conv = hub.get(args.conversation_id);
634
+ const conv = await hub.get(args.conversation_id);
635
635
  const r = await conv.takeover({ force: args.force === true });
636
636
  return { structured: r, text: `${r.ok ? '✅' : '❌'} ${r.message}` };
637
637
  },
638
638
 
639
639
  async dsh_approval_decide(args) {
640
- const conv = hub.get(args.conversation_id);
640
+ const conv = await hub.get(args.conversation_id);
641
641
  const r = conv.decideApproval(args.approval_id, args.decision);
642
642
  return { structured: r, text: `审批 ${r.approval_id} 已裁决为 ${r.decision}。` };
643
643
  },
644
644
 
645
645
  async dsh_status(args) {
646
- const conv = hub.get(args.conversation_id);
646
+ const conv = await hub.get(args.conversation_id);
647
647
  const snap = conv.snapshot();
648
648
  const run = conv.getRun();
649
649
  const bits = [`状态=${snap.state}`, `轮数=${snap.turns}`];
package/src/workspace.mjs CHANGED
@@ -15,11 +15,12 @@
15
15
  * 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
16
16
  */
17
17
 
18
- import { copyFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
18
+ import { copyFileSync, createReadStream, 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';
22
- import { zstdDecompressSync } from 'node:zlib';
22
+ import { StringDecoder } from 'node:string_decoder';
23
+ import { createZstdDecompress, zstdDecompressSync } from 'node:zlib';
23
24
  import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
24
25
 
25
26
  const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
@@ -220,7 +221,7 @@ export function registerWorkspace(cwd, sessionId) {
220
221
  }
221
222
  }
222
223
 
223
- /** 读取一个会话头的第一个 zstd 帧(就是头行)。 */
224
+ /** 读取一个会话头的第一个 zstd 帧(就是头行)。仅用于 CLI/测试清理,不用于 MCP 热路径。 */
224
225
  function readHeader(file) {
225
226
  try {
226
227
  const text = zstdDecompressSync(readFileSync(file)).toString('utf8');
@@ -230,6 +231,62 @@ function readHeader(file) {
230
231
  }
231
232
  }
232
233
 
234
+ /**
235
+ * 异步读取会话头:流式解压,拿到第一行就销毁流。
236
+ *
237
+ * 为什么需要它:MCP 服务在 `Hub.get()` 里找回磁盘会话时必须读头;
238
+ * `readHeader()` 会把整份日志同步解压,超大日志会阻塞事件循环、停掉
239
+ * 心跳(多实例下可能被误判 stale-mcp)。这里只解到第一个换行,
240
+ * 不把整份日志读进内存,也不阻塞事件循环。
241
+ *
242
+ * 超时/读失败返回 null;调用方按“找不到会话头”处理。
243
+ */
244
+ export async function readHeaderAsync(file, { timeoutMs = 5000, maxHeaderBytes = 1024 * 1024 } = {}) {
245
+ return new Promise((resolve) => {
246
+ let settled = false;
247
+ let buf = '';
248
+ let timer = null;
249
+ const decoder = new StringDecoder('utf8');
250
+ const rs = createReadStream(file);
251
+ const zs = createZstdDecompress();
252
+ const finish = (value) => {
253
+ if (settled) return;
254
+ settled = true;
255
+ if (timer) clearTimeout(timer);
256
+ try { rs.destroy(); } catch {}
257
+ try { zs.destroy(); } catch {}
258
+ resolve(value);
259
+ };
260
+ const parseLine = (line) => {
261
+ try {
262
+ const value = JSON.parse(String(line).trim());
263
+ finish(value && typeof value === 'object' ? value : null);
264
+ } catch {
265
+ finish(null);
266
+ }
267
+ };
268
+ timer = setTimeout(() => finish(null), timeoutMs);
269
+ timer.unref?.();
270
+ rs.on('error', () => finish(null));
271
+ zs.on('error', () => finish(null));
272
+ zs.on('data', (chunk) => {
273
+ // 用 StringDecoder 处理跨 chunk 的多字节 UTF-8(中文/emoji 路径),
274
+ // 避免 chunk.toString() 把字符截成乱码。
275
+ buf += decoder.write(chunk);
276
+ const nl = buf.search(/\r?\n/);
277
+ if (nl >= 0) parseLine(buf.slice(0, nl));
278
+ else if (buf.length > maxHeaderBytes) finish(null);
279
+ });
280
+ zs.on('end', () => {
281
+ buf += decoder.end();
282
+ const line = buf.split(/\r?\n/).find(Boolean);
283
+ if (line) parseLine(line);
284
+ else finish(null);
285
+ });
286
+ rs.pipe(zs);
287
+ });
288
+ }
289
+
233
290
  /**
234
291
  * 扫描磁盘上所有会话,按 header.cwd 归组。数据源是会话头,不是目录名(目录名有损)。
235
292
  *
@@ -272,7 +329,7 @@ export function scanSessions() {
272
329
  *
273
330
  * 会对 id 做形状校验(只允许 UUID / `session-` 前缀那类字符),避免 `..` 之类逃出会话目录。
274
331
  */
275
- export function findSessionHeader(sessionId) {
332
+ export async function findSessionHeader(sessionId) {
276
333
  if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) return null;
277
334
  if (!existsSync(SESSIONS_ROOT)) return null;
278
335
  let buckets;
@@ -284,7 +341,7 @@ export function findSessionHeader(sessionId) {
284
341
  for (const bucket of buckets) {
285
342
  const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
286
343
  if (!existsSync(f)) continue;
287
- const header = readHeader(f);
344
+ const header = await readHeaderAsync(f);
288
345
  if (header?.id === sessionId) return header;
289
346
  }
290
347
  return null;