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/CHANGELOG.md CHANGED
@@ -7,7 +7,179 @@ 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.8 hardening.
10
+ ## [0.4.0] — 2026-11-03
11
+
12
+ ### Memory
13
+
14
+ - **Idle reaping is on by default.** `DSH_MCP_IDLE_TTL_MS` now defaults to
15
+ 10 minutes (was `0` = never). Idle sessions release their DSH process and
16
+ resume automatically on the next call; the write lock is released with it.
17
+ Set `0` to restore the old "hold the lock forever" behavior.
18
+ - **`DSH_MCP_MAX_LIVE` defaults to 4** (was 8), keeping the worst case around
19
+ 1.2–1.9 GB instead of 2.5–4 GB at the documented 300–480 MB/session.
20
+ - **`keep_alive` on `dsh_start`.** A session created with `keep_alive: true`
21
+ is exempt from idle TTL and `MAX_LIVE`; it keeps its DSH process (and write
22
+ lock) until `dsh_release`. Use it for "must stay attached" sessions.
23
+ - **Global RSS budget** via `DSH_MCP_MAX_RSS_MB` (default `0` = off). When the
24
+ sum of live child RSS exceeds the budget, the least-recently-used idle
25
+ sessions are reaped until it fits. `keep_alive` sessions are reaped last.
26
+ - **`dsh_list` / `dsh_status` report `rss_mb`** (and `keep_alive`), so the cost
27
+ is visible instead of guessed.
28
+ - **Optional child heap cap** via `DSH_MCP_CHILD_MAX_OLD_SPACE_MB` (default
29
+ `0` = off). It appends `--max-old-space-size=<n>` to the child's
30
+ `NODE_OPTIONS` without clobbering existing options.
31
+ - **Experimental shared ACP process** via `DSH_MCP_SHARED_PROCESS=1`. Sessions
32
+ with the same `(cwd, permission)` share one DSH process; ACP supports
33
+ concurrent sessions over one connection and DSH gives each session its own
34
+ Agent. Measured on DSH 0.1.7: two sessions → one PID, second `session/new`
35
+ took ~300 ms instead of ~4 s. Same `(cwd, permission)` is required because
36
+ `sandbox-policy.workspaceRoot`/`mode` and `approval.policy` are process-wide
37
+ in `dsh-base`. Trade-off: a process crash takes down the whole group; the
38
+ host writes error sentinels for every in-flight run and each session resumes
39
+ on the next call.
40
+
41
+ ### Dispatch surface
42
+
43
+ - **`dsh_inbox`** — a cross-conversation approval inbox. `dsh_status` remains
44
+ single-conversation; this tool lists every pending approval with
45
+ conversation id/title, approval id, tool, reason, input, options and age.
46
+ - **`client_run_id` idempotency** on `dsh_send` / `dsh_interject`: resubmitting
47
+ the same key returns the first run (`replayed=true`) instead of creating a
48
+ second one. The key is written into the sentinel and evicted together with
49
+ the run record.
50
+ - **Session queue** via `dsh_send(queue=true)` (explicit opt-in; the default
51
+ busy behavior stays "error") plus **`dsh_queue`** for list/remove/update/
52
+ promote. Sentinels are written only at terminal state; removing a queued
53
+ item writes a `cancelled` sentinel.
54
+ - **`dsh_get` text budget**: `max_chars` truncates only the rendered text
55
+ (structured content keeps the full result) and reports
56
+ `answer_truncated` / `next_offset`.
57
+ - **`dsh_doctor`** — read-only health check: DSH binary/version, profile
58
+ existence/drift, state file, `runs`/`locks` writability, orphan lock holders,
59
+ session/RSS totals, effective config.
60
+ - **Optional metadata audit** via `DSH_MCP_AUDIT_FILE`: JSONL records for
61
+ start/send/queue/interject/interrupt/approve/release/takeover. It records
62
+ IDs, decisions and counts only — never prompts, answers, thinking or tool
63
+ input. Rotates at 10 MB.
64
+ - **`resolveDshBin` prefers the newest cached DSH** instead of the first
65
+ directory it finds. This machine had both 0.1.5 and 0.1.7 cached; picking
66
+ 0.1.5 silently hid the experimental Agent Teams packages.
67
+ - **README positioning section**: the server is a dispatcher, not a capability
68
+ aggregator; DSH owns its tools/skills/teams, and dispatch is on demand.
69
+
70
+ ### Team mode
71
+
72
+ - **Agent Teams is on by default** (`DSH_MCP_TEAM=1`). The default profile is
73
+ now `dsh-team`, auto-created from the same routing source with
74
+ `@deepseek-ai/dsh-experimental-agent-team` +
75
+ `@deepseek-ai/dsh-experimental-tool-agent-team`; the legacy `subagent` tools
76
+ are disabled, matching the official team profile layer. Requires DSH
77
+ `>= 0.1.7-0` (the packages ship with 0.1.7-rc.1).
78
+ - `dsh_send` injects a short "form a team if this needs parallel or multi-role
79
+ work" line before the caller's prompt; simple tasks are not forced into a
80
+ team. `dsh_send(team=false)` disables it for one call; `DSH_MCP_TEAM=0`
81
+ returns to `dsh-mcp` and disables injection.
82
+ - `TEAM_ACTIVE` is false when the selected profile does not actually contain
83
+ the team package, so an explicit `DSH_MCP_PROFILE` pointing at a non-team
84
+ profile does not inject a tool the agent cannot use.
85
+ - **Switching**: `DSH_MCP_TEAM=0` returns to `dsh-mcp` and disables injection;
86
+ `dsh_send(team=false)` disables it for one call; `DSH_MCP_PROFILE` still
87
+ wins over the default profile. `dsh_start` / `dsh_doctor` report the
88
+ effective team mode and profile.
89
+
90
+ ### Correctness
91
+
92
+ - **A failed `ensureAlive` now has a terminal state.** It wakes every
93
+ `dsh_interject` waiter, writes an `error` sentinel, and attaches
94
+ `runId`/`status`/`sentinelFile` to the thrown error. Previously an interject
95
+ that arrived during a lock-conflict/cold-start window could wait forever
96
+ (`timeout_ms=0` has no timer).
97
+ - **`dsh_approval_decide` reports the effective outcome.** `decideApproval`
98
+ returns `effective: 'selected' | 'cancelled'` (plus `reason: 'no_option'`);
99
+ the tool text no longer claims "decided as allow" when ACP actually received
100
+ `cancelled`.
101
+ - **`dsh_get` can read a run back from its sentinel.** On a memory miss it
102
+ reads `RUNS_DIR/<conversation>/<run_id>.json`, returns
103
+ `run.source='sentinel'`, and always includes `sentinel_file` /
104
+ `sentinel_file_posix`.
105
+ - **Reap/respawn race fixed.** `stop()` is registered as `recycling` and
106
+ `proc` is cleared only after the child is confirmed gone, so a concurrent
107
+ `dsh_send` waits instead of spawning a new process against an unreleased
108
+ write lock. `lastChildPid` is updated only after a successful resume, so a
109
+ failed resume no longer loses the previous generation's takeover clue.
110
+ - **`timeout_ms` is validated.** Non-finite or negative values now raise
111
+ instead of silently becoming `NaN`/immediate background receipts.
112
+
113
+ ### Tests
114
+
115
+ - New `failure-terminal.mjs` (interject wake + error sentinel + approval
116
+ effective + sentinel read-back).
117
+ - New `shared-host.mjs` (session routing, exit fan-out, refcount teardown,
118
+ RSS helper).
119
+ - New `shared-process.mjs` (real-DSH shared process; skips without DSH).
120
+ - New `dispatch-tools.mjs` (`dsh_inbox`, `client_run_id` idempotency, session
121
+ queue operations, `dsh_get` text budget).
122
+ - `guards.mjs` and `smoke.mjs` updated for the new defaults and the
123
+ `keepAlive` metadata field.
124
+
125
+ ## [0.3.9] — 2026-11-02
126
+
127
+ ### Security / robustness
128
+
129
+ - **UTF-8 stream framing is lossless.** MCP and ACP now use a shared
130
+ `LineDecoder` backed by `StringDecoder`, so a multi-byte Chinese/emoji
131
+ character split across stream chunks is no longer silently replaced by
132
+ U+FFFD. Covers both input prompts and DSH output.
133
+ - **Concurrent adoption of the same disk session is deduplicated.** `Hub.get`
134
+ now shares one in-flight `findSessionHeader`/adoption promise per id and
135
+ rechecks the registry, preventing two Conversation objects (and the
136
+ unreachable child/write-lock leak that followed).
137
+ - **`read-only` runtime verification fails closed.** It waits up to ~10s
138
+ (covering DSH's projection-cache flush window); if no recorded permission is
139
+ available, the call is refused instead of relying on an approval layer that
140
+ a danger sandbox may never invoke.
141
+ - **`spawned_at` is reset when the child PID changes.** PID-reuse protection no
142
+ longer rejects takeover of a legitimate restarted child.
143
+ - **`forget` tombstones survive unreadable session headers.** Header read is
144
+ three-state (found / missing / error); only a genuinely missing session file
145
+ prunes a tombstone.
146
+ - **`DSH_MCP_SENTINEL_INCLUDE_REASONING=1` no longer disables redaction.**
147
+ Thinking is kept, but answer/error/title/tool inputs are still sanitized.
148
+ - **`close`/`releaseAll`/`shutdown` keep lock registrations until the child is
149
+ confirmed dead.** If stop times out, the registration is retained so
150
+ takeover can still attribute the orphan; `forget` is refused while the child
151
+ is alive.
152
+ - setup preserves the target file's mode (e.g. 0600) and rejects an invalid
153
+ `--permission` with exit code 2.
154
+ - `restore --latest` excludes its own `pre-restore-*` safety copies, so running
155
+ it twice no longer undoes the first restore.
156
+
157
+ ### Behavior
158
+
159
+ - `workspace_changed` compares git-status entry snapshots instead of object
160
+ identity; output now says "workspace state changed" rather than claiming a
161
+ per-turn file count.
162
+ - `dsh_takeover` prefers a live `known_child_pid` when the marker points at a
163
+ dead child.
164
+
165
+ ### Website
166
+
167
+ - Mobile navigation keeps the language switch visible; the footer also carries
168
+ a language link.
169
+ - `hero.jpg` and `favicon.svg` are content-hashed; all `/assets/*` can stay
170
+ immutable.
171
+ - Light-theme muted/faint/accent colors were darkened to meet WCAG AA on the
172
+ surfaces they are used on.
173
+
174
+ ### Docs / tests
175
+
176
+ - README/Chinese README/USAGE-MIGRATION: workspace-write approval semantics,
177
+ setup's `workspace-write` default, Zed `context_servers`, English npm publish
178
+ step, current route/limit links.
179
+ - New `line-decoder` suite (4), expanded sentinel redaction (19), tombstone
180
+ pruning (10), permission adoption (17), state/lock safety (26), sandbox guard
181
+ (21), setup (45), config-safety (407). Offline total 843 / 879 with nine
182
+ client fixtures; website 29.
11
183
 
12
184
  ## [0.3.8] — 2026-11-02
13
185
 
@@ -696,7 +868,8 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
696
868
  profiles, sessions and client configs. The product name changed; the on-disk
697
869
  contract did not. See the README FAQ.
698
870
 
699
- [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.8...HEAD
871
+ [Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.9...HEAD
872
+ [0.3.9]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.8...v0.3.9
700
873
  [0.3.8]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.7...v0.3.8
701
874
  [0.3.7]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...v0.3.7
702
875
  [0.3.6]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.5...v0.3.6
package/DESIGN.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  > 目标:做一个 MCP Server,让 **Codex / Claude Code** 能调用 **DeepSeek Harness (DSH)** 当"外包工程师"——写代码、搭脚手架、跑任务,并且 DSH 自己还能挂它自己的 MCP。
4
4
  >
5
- > 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.8 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
5
+ > 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.9 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
6
6
 
7
- ## 当前实现状态(0.3.8)
7
+ ## 当前实现状态(0.3.9)
8
8
 
9
9
  当前对外暴露 11 个工具,均已实现并有回归:
10
10
 
@@ -439,7 +439,7 @@ Profile = `~/.dsh/profiles/<name>/`,由 `package.json` 的 `dsh.profile.bundle
439
439
  `dsh_start` / `dsh_send` / `dsh_list` / `dsh_get` / `dsh_release` + 专用 profile 脚手架(provider/model/权限/标题 LLM)+ 思考默认隐藏 + Windows 拉起方式 + 进程池与空闲回收。
440
440
 
441
441
  **M2(把它变成好用的"外包工程师")**
442
- `dsh_diff`、计划模式、`dsh_read` 游标增量、progress/log 通知、审批**报告**(G3,注意不可应答)、脱敏、`dsh_search`(需先开 `session-query-sqlite` 的 `openAt`,默认 `never` 时全文搜索直接报 `SESSION_QUERY_SEARCH_DISABLED`)、resources/prompts。
442
+ `dsh_diff`、计划模式、`dsh_read` 游标增量、progress/log 通知、审批**可应答**(G3 已由 `dsh_approval_decide` 实现)、脱敏、`dsh_search`(需先开 `session-query-sqlite` 的 `openAt`,默认 `never` 时全文搜索直接报 `SESSION_QUERY_SEARCH_DISABLED`)、resources/prompts。
443
443
 
444
444
  **M3(能力面与体验)**
445
445
  MCP 服务器增删查(F2)、插件与技能管理、`dsh_doctor`、分叉/回滚、可选 TUI 伴生进程。
@@ -451,9 +451,9 @@ MCP 服务器增删查(F2)、插件与技能管理、`dsh_doctor`、分叉/
451
451
  | # | 问题 | 我的建议 |
452
452
  |---|---|---|
453
453
  | Q1 | 会话空间:与用户 GUI 共用 `~/.dsh`(历史统一、但会抢写锁)还是独立 `DSH_HOME`(隔离、但要另配凭据)? | 默认共用,提供 `--dsh-home` 覆盖;MCP 起的会话打标记便于过滤 |
454
- | Q2 | 空闲回收 TTL 默认值?(太长会占锁让用户打不开,太短会反复重启丢上下文) | 5 分钟,且 `dsh_release` 可显式交还 |
454
+ | Q2 | 空闲回收 TTL 默认值?(太长会占锁让用户打不开,太短会反复重启丢上下文) | ✅ **已定:默认 `0` = 永不回收**(`dsh_release` 可显式交还;显式设非零值才回收) |
455
455
  | Q3 | **中断怎么做?** SDK 协议没有 cancel,且杀进程 = 永久失去该会话(约束 6) | 强烈倾向 ②:写个极小 DSH 插件补 `session/interrupt`。① 已排除——headless 无法接管 web 建的会话 |
456
- | Q4 | 默认权限档给哪一档? | ✅ **已定:默认 `danger-full-access`,其余可选。** 成因几经变化:SDK 协议没有审批通道时中间档等于废档(越界操作静默失败);**改用 ACP 后 `session/request_permission` 让中间档复活**,所以现在是"默认最省事 + 需要时可收窄",而不是被迫只留一档。`read-only` 留给分析不信任样本的场景。注意 `approval.policy: never` 的语义是"每次询问都确定性拒绝"(不是自动放行),它只在 `danger-full-access` 下无副作用——因为沙箱根本不产生询问 |
456
+ | Q4 | 默认权限档给哪一档? | ✅ **已定:服务端裸默认 `danger-full-access`;`setup` 写入 `workspace-write`;其余可选。** 成因几经变化:SDK 协议没有审批通道时中间档等于废档(越界操作静默失败);**改用 ACP 后 `session/request_permission` 让中间档复活**,所以现在是"默认最省事 + 需要时可收窄",而不是被迫只留一档。`read-only` 留给分析不信任样本的场景。注意 `approval.policy: never` 的语义是"每次询问都确定性拒绝"(不是自动放行),它只在 `danger-full-access` 下无副作用——因为沙箱根本不产生询问 |
457
457
  | Q5 | 会话 id 是否直接采用 DSH 原生 `session-<uuid>`? | ✅ 已定:采用。实测语义 = 全新 id 落成磁盘会话 id(磁盘/标题/GUI 全对齐);已存在的 id 直接报错。故**每个会话只在首次 prompt 生成一次 id,之后仅在本进程内复用** |
458
458
  | Q6 | 首版面向 Codex 还是 Claude Code?(两者 MCP 能力不同:progress/log 通知、resources、prompts 支持度有差异) | 先保 Codex 的保守子集(tools 即可),Claude 的增强能力渐进加 |
459
459
  | Q7 | 要不要把 `session-title-llm` 打开(每个会话多一次小 LLM 调用换更好的标题)? | 打开,标题质量对"列表里找对话"影响很大 |
@@ -468,7 +468,7 @@ MCP 服务器增删查(F2)、插件与技能管理、`dsh_doctor`、分叉/
468
468
  | 位置 | 内容 |
469
469
  |---|---|
470
470
  | [`src/`](src) | 实现:ACP 客户端、会话池与注册表、工具层、MCP 服务端、工作区登记 |
471
- | [`test/`](test) | 可复跑的测试套件(冒烟 / 边界 / 集成 / 异步 / 并发 / 能力 / 工作区有效性 / 验收),合计 229 项检查 |
471
+ | [`test/`](test) | 可复跑的测试套件;当前基线见 README(0.3.9:离线 843 项、官网 29 项,模型套件另计)。本文件是历史设计稿,不再重复抄写计数 |
472
472
  | [`README.md`](README.md) / [`README.zh.md`](README.zh.md) | 使用文档(英文 / 中文) |
473
473
  | [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml) | DSH profile 补丁层模板(含两个必踩的坑的说明) |
474
474
 
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
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
18
+ 🧪 843 checks green on 0.3.9 on this machine (879 when all nine client fixtures exist); 524 of them are in the client-registration suites (560 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
 
@@ -75,6 +75,64 @@ DSH (DeepSeek Harness) can do all of that — it has file/shell tools, skills, s
75
75
  | 🧩 **Capabilities compose** | DSH can mount its own MCP servers (IDA Pro, browsers, your internal tooling), so this is a bridge to a whole toolbox. |
76
76
  | 🛡️ **Read-only that really is read-only** | `read-only` sandbox plus auto-denied approvals, for working on untrusted samples. |
77
77
 
78
+ ## 🧭 Positioning: a dispatcher, not a capability aggregator
79
+
80
+ **This server dispatches; it does not re-implement what DSH already does.** The caller
81
+ gives a goal, and DSH decides which tools, skills, subagents or team to use. Nothing
82
+ from DSH's tool surface is proxied, duplicated or preloaded.
83
+
84
+ | Owned by DSH (this server stays out) | Owned by this server (dispatch semantics) |
85
+ |---|---|
86
+ | File I/O, shell, search, editing | Session identity and cross-crash resume |
87
+ | Skills, subagents, Agent Teams, task board | Async dispatch, receipts, completion sentinels |
88
+ | Model routing, sandbox, approval policy | Incremental read, interject, interrupt |
89
+ | Memory/context/compaction | Approval bridge, write-lock governance, queue, inbox |
90
+ | worktrees (the agent creates them with git) | Setup, status, doctor, audit |
91
+
92
+ **Dispatch on demand**: `dsh_send` passes a goal, not a tool manifest, and the caller
93
+ does not need to know DSH's tool list. The DSH agent picks what it needs — including
94
+ capabilities added after this server was built (for example the experimental Agent
95
+ Teams). This server does not grow one MCP tool per DSH feature.
96
+
97
+ **Team mode (on by default since 0.4.0)**: the default profile is `dsh-team`
98
+ (auto-created on first start with the experimental Agent Teams packages), and
99
+ `dsh_send` injects a short "form a team if this needs parallel or multi-role work"
100
+ line before the caller's prompt; simple tasks are not forced into a team. Disable it
101
+ globally with `DSH_MCP_TEAM=0` (back to `dsh-mcp`, no injection) or per call with
102
+ `dsh_send(team=false)`. The team profile disables the legacy `subagent` tools and
103
+ replaces them with nine team tools; teammates share one workspace.
104
+
105
+ **Only dispatch semantics become tools**: start, send, read, get, interject,
106
+ interrupt, approve, queue, inbox, status, release, takeover, doctor. No file I/O, no
107
+ shell, no editor, no task board.
108
+
109
+ **When to add something outside DSH**: only when you need a *guarantee* rather than a
110
+ *behavior* — for example, several top-level sessions editing the same repository need
111
+ outer worktree isolation. For collaboration, prefer letting DSH run the team itself
112
+ (mount the experimental team packages into the profile; teammates share one workspace).
113
+
114
+ ## 🧑‍🤝‍🧑 Team mode (on by default since 0.4.0)
115
+
116
+ The default profile is `dsh-team`: created on first start, it mounts the
117
+ experimental Agent Teams packages and disables the legacy `subagent` tools.
118
+ `dsh_send` injects a short "form a team when this needs parallel or multi-role
119
+ work" line before the caller's prompt; simple tasks are not forced into a team.
120
+
121
+ | What you want | How |
122
+ |---|---|
123
+ | Keep the default (on) | Nothing to set |
124
+ | **Turn it off globally** | `DSH_MCP_TEAM=0` (back to the `dsh-mcp` profile, no team prompt) |
125
+ | **Turn it off for one call** | `dsh_send(..., team=false)` |
126
+ | Use a custom profile | `DSH_MCP_PROFILE=<name>`; the server checks whether that profile actually contains the team package and suppresses injection if it does not |
127
+ | Check the current state | The `dsh_start` reply, or `dsh_doctor`'s `config` check (`team=on/off`) |
128
+
129
+ Notes:
130
+
131
+ - Team mode requires DSH `>= 0.1.7-0`; an older runtime falls back to `dsh-mcp`.
132
+ - The team profile disables the legacy `subagent` tools and replaces them with
133
+ nine team tools; teammates share one workspace.
134
+ - Agent Teams is **experimental** and carries no stability promise.
135
+
78
136
  ## ⚡ Why not just wrap the DSH CLI?
79
137
 
80
138
  DSH ships two programmable entry points. The `sdk` profile looks simpler, but its JSON-RPC surface has only **3 requests and 4 notifications** — and it lacks three things that make an agent unusable in practice. The `acp` profile (Agent Client Protocol) has all of them. Everything below was verified against DSH `0.2.1-rc.5`.
@@ -214,7 +272,7 @@ What changed since 0.1.x: JSON configs used to be **re-serialised whole** (value
214
272
 
215
273
  Codex's `config.toml` gets the same treatment line-wise: your other `[tables]` and their comments, the top-level keys (`model`, `approval_policy`, `sandbox_mode`, …) and CRLF endings come back exactly as they were — `--remove` deletes only the two `[mcp_servers.dsh-any-agent]` sections and leaves the rest intact.
216
274
 
217
- The coverage for each of those claims is in [`test/`](test/) (`test/config-safety.mjs` asserts them one by one, 406 checks).
275
+ The coverage for each of those claims is in [`test/`](test/) (`test/config-safety.mjs` asserts them one by one, 407 checks).
218
276
 
219
277
  **Checked against a real home, read-only.** Running `setup --json` (plan mode) against an actual developer profile reported all nine clients and left every existing config untouched — identical size, mtime and SHA-256, no `.bak-dsh-*` created, no new file created:
220
278
 
@@ -359,7 +417,7 @@ tool_timeout_sec = 120.0
359
417
 
360
418
  For a local checkout, use `"command": "node"` and `"args": ["/absolute/path/to/server/bin/dsh-any-agent.mjs"]`.
361
419
 
362
- > Cursor, Windsurf, Zed, Cline, Continue and most other clients use this same `mcpServers` shape — if it can launch a stdio MCP server, it works.
420
+ > Cursor, Windsurf, Cline, Continue and most other clients use this same `mcpServers` shape — if it can launch a stdio MCP server, it works. Zed is the exception: it uses `context_servers` (shown above).
363
421
 
364
422
  </details>
365
423
 
@@ -385,7 +443,10 @@ node test/smoke.mjs # no LLM calls, verifies the whole plumbing
385
443
  | `dsh_interrupt` | Stop the current turn (the conversation stays healthy) |
386
444
  | `dsh_release` | Hand the conversation back (frees the write lock so you can open it in your own DSH GUI). Still resumable; `forget=true` writes a tombstone — by-id lookups are refused until you delete `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json` |
387
445
  | `dsh_approval_decide` | Adjudicate a pending approval (only when `on_approval=ask`) |
388
- | `dsh_status` | Lightweight status check |
446
+ | `dsh_status` | Lightweight status check (single conversation) |
447
+ | `dsh_inbox` | **Cross-conversation approval inbox**: every pending approval with reason, input and age; decide with `dsh_approval_decide` |
448
+ | `dsh_queue` | Inspect/modify the session queue: `list` / `remove` / `update` / `promote` (created by `dsh_send(queue=true)`, memory only) |
449
+ | `dsh_doctor` | Read-only health check: DSH version, profile, state/dir writability, orphan locks, sessions and RSS, effective config |
389
450
 
390
451
  ## 📋 Workflow: dispatch, then collect later
391
452
 
@@ -432,7 +493,7 @@ Design notes (especially under **concurrent multi-session** use):
432
493
  - **Latch semantics**: the file is written once and kept. Even if the run finishes *before* you arm the waiter, `[ -f ]` is immediately true — you never miss it.
433
494
  - **Atomic write**: `tmp` + `rename`; if the file exists its contents are complete. Each run uses its own tmp, so concurrent writes never clobber.
434
495
  - **No server push**: the service only answers requests over MCP (it sends no notifications). "Completion notification" is entirely the sentinel file plus your background waiter.
435
- - **★ No reasoning in the sentinel**: the payload carries the answer, thinking *statistics*, usage and (credential-redacted) tool inputs, but never reasoning text — `result.thinking` is stripped and replaced by a boolean `thinking_omitted` (`true` when reasoning was removed, `false` when there was none), because this server's rule is that reasoning never reaches its own disk artifacts. `dsh_get(run_id)` still returns it from memory while the record lives. `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` breaks that guarantee deliberately. Note that **DSH's own session log does contain reasoning** — see [Logging and privacy](#️-logging-and-privacy).
496
+ - **★ No reasoning in the sentinel**: the payload carries the answer, thinking *statistics*, usage and (credential-redacted) tool inputs, but never reasoning text — `result.thinking` is stripped and replaced by a boolean `thinking_omitted` (`true` when reasoning was removed, `false` when there was none), because this server's rule is that reasoning never reaches its own disk artifacts. `dsh_get(run_id)` still returns it from memory while the record lives. `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` breaks that guarantee deliberately. Note that **DSH's own session log does contain reasoning** — see [Logging and privacy](#-logging-and-privacy).
436
497
 
437
498
  > Cleanup: sentinels contain full answers, so the service **prunes sentinels older than 7 days at startup** (tune with `DSH_MCP_SENTINEL_TTL_MS`; `0` disables). Your waiter should still `rm` the file after consuming it.
438
499
 
@@ -461,13 +522,13 @@ DSH separates reasoning from answer text at the model layer, and ACP exposes the
461
522
 
462
523
  | Tier | Approval default | Use for |
463
524
  |---|---|---|
464
- | `danger-full-access` (default) | `auto-allow` | Zero friction |
465
- | `workspace-write` | `auto-allow` | Out-of-workspace operations surface as pending approvals |
525
+ | `danger-full-access` (raw server default) | `auto-allow` | Zero friction; `setup` writes `workspace-write` by default |
526
+ | `workspace-write` | `auto-allow` | Writes are limited to the workspace by DSH's sandbox; out-of-workspace requests are auto-allowed unless you pass `on_approval=ask` |
466
527
  | `read-only` | `auto-deny` | Analyzing untrusted samples (malware, unknown dumps) |
467
528
 
468
529
  `on_approval` can be `auto-allow`, `auto-deny`, or `ask` (which exposes a pending approval for `dsh_approval_decide` to adjudicate).
469
530
 
470
- **`read-only` is enforced, not merely a default**: it always runs with `auto-deny`; passing `auto-allow` or `ask` for a read-only conversation is rejected at `dsh_start`, and the approval layer refuses any permission request from a read-only conversation. Without that, DSH can ask for a `sandbox_permissions` escalation and a permissive approval policy would let the write through (found by the adversarial audit).
531
+ **`read-only` is enforced, not merely a default**: it always runs with `auto-deny`; passing `auto-allow` or `ask` for a read-only conversation is rejected at `dsh_start`, and the approval layer refuses any permission request from a read-only conversation. After create/resume the server reads back the session's recorded tier; for read-only it **fails closed** if the projection cache has no permission record within the DSH flush window, rather than relying on the approval layer alone. Without that, a profile `defaultPreset` plus a permissive approval policy could let a write through (found by the adversarial audit).
471
532
 
472
533
  ## 🪟 Workspace visibility in the DSH GUI
473
534
 
@@ -521,7 +582,8 @@ Silent by default — **not one byte is written**.
521
582
  |---|---|---|
522
583
  | `DSH_BIN` | auto-detected | Path to `@deepseek-ai/dsh/lib/bin.js` |
523
584
  | `DSH_HOME` | `~/.dsh` | DSH home shared with the GUI (credentials, sessions, workspace registry). Point it elsewhere only for isolation/multi-account |
524
- | `DSH_MCP_PROFILE` | `dsh-mcp` | DSH profile to drive. ⚠️ The stale name is deliberate — see [Why is the profile still called `dsh-mcp`?](#-why-is-the-profile-still-called-dsh-mcp) |
585
+ | `DSH_MCP_PROFILE` | `dsh-team` (team on) / `dsh-mcp` (team off) | DSH profile to drive; an explicit value wins |
586
+ | `DSH_MCP_TEAM` | `1` | Team mode. On by default: uses the `dsh-team` profile and injects a "form a team when needed" line before `dsh_send`. Set `0` to disable (back to `dsh-mcp`, no injection); per call use `dsh_send(team=false)` |
525
587
  | `DSH_MCP_BOOTSTRAP` | on | Whether this server may create/sync the DSH profile on first start. `0` makes it drive an existing profile only and never write a file |
526
588
  | `DSH_MCP_ROUTE_SOURCE` | unset | Pin where model routes are copied from during bootstrap: a profile name (`desktop`, `web`, …) or `__home__` for `$DSH_HOME/cordis.patch.yml`. Unset = try home → desktop → web → headless → acp |
527
589
  | `DSH_MCP_STATE` | `$DSH_HOME/.dsh-any-agent/conversations.json` | Conversation registry, survives restarts. The old `<repo>/.state` location is migrated once on startup |
@@ -530,12 +592,16 @@ Silent by default — **not one byte is written**.
530
592
  | `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | Tombstones written by `dsh_release(forget=true)` |
531
593
  | `DSH_MCP_RUNS_DIR` | `<state file dir>/runs` | Root for async completion sentinels (`<this>/<conversation_id>/<run_id>.json`) |
532
594
  | `DSH_MCP_LOCKS_DIR` | `<state file dir>/locks` | Holder registrations for the write lock. ⚠️ **All instances must agree on this value**, or they cannot see each other's registrations and will misread each other as an unidentifiable holder (a GUI) and refuse to preempt |
533
- | `DSH_MCP_LOCK_STALE_MS` | `90000` | How long without a heartbeat before a holder counts as lost (and becomes auto-preemptible by `dsh_takeover`) |
534
- | `DSH_MCP_IDLE_TTL_MS` | `0` (never reap) | How long a conversation may sit idle before its DSH process is reaped. ★★ **`0` means never reap (the default)**: the server keeps holding the write lock, so **the GUI cannot take it** — opening the conversation there is read-only and still works. Cost: one resident DSH process per conversation (**measured 300 MB and 476 MB in two cold starts**). Set a millisecond value (e.g. `300000`) to save memory and accept that the lock can be taken |
535
- | `DSH_MCP_MAX_LIVE` | `8` (≈2.5–4 GB worst case; see note 8) | ★ **How many conversation processes to keep alive at once** (`0` = unlimited). Going over the cap reaps the **least recently used** one (a running turn is never reaped) — "**bounded memory, locks kept where it matters**". Use `4` when memory is tight, `0` on a big machine. To free idle conversations at once: `dsh_release(all=true)` (busy turns are skipped unless `force=true`) |
595
+ | `DSH_MCP_LOCK_STALE_MS` | `90000` (3× `REAP_INTERVAL_MS`, floor 30 s) | How long without a heartbeat before a holder counts as lost (and becomes auto-preemptible by `dsh_takeover`); changing the reap interval changes the derived default |
596
+ | `DSH_MCP_IDLE_TTL_MS` | `600000` (10 minutes) | How long a conversation may sit idle before its DSH process is reaped. **Default 10 minutes**: the memory is released and the next call resumes the conversation transparently; the cost is that the write lock goes free and the GUI may take it. Set `0` to never reap (hold the lock forever; ~300–480 MB per conversation), or use `dsh_start(keep_alive=true)` for the specific sessions that must stay attached |
597
+ | `DSH_MCP_MAX_LIVE` | `4` (≈1.2–1.9 GB worst case) | ★ **How many conversation processes to keep alive at once** (`0` = unlimited). Going over the cap reaps the **least recently used** one (a running turn is never reaped). `keep_alive=true` sessions are exempt. To free idle conversations at once: `dsh_release(all=true)` (busy turns are skipped unless `force=true`) |
598
+ | `DSH_MCP_MAX_RSS_MB` | `0` (off) | Global child-process memory budget in MB. When the measured RSS exceeds it, idle conversations are reaped least-recently-used first; `keep_alive` sessions are reaped last |
599
+ | `DSH_MCP_CHILD_MAX_OLD_SPACE_MB` | `0` (unset) | Appends `--max-old-space-size` to each DSH child's `NODE_OPTIONS`. A backstop for runaway contexts; too small will OOM long conversations |
600
+ | `DSH_MCP_SHARED_PROCESS` | `0` (off) | **Experimental**: sessions with the same `(cwd, permission)` share one ACP process. Large memory and cold-start win (measured: second `session/new` ≈300 ms vs ≈4 s standalone). Trade-off: a process crash takes down the whole group (error sentinels are written; each session resumes on the next call) |
601
+ | `DSH_MCP_AUDIT_FILE` | empty (off) | Metadata audit JSONL path. Records events, conversation/run ids, decisions and counts; **never** prompts, answers, thinking or tool input. Rotates at 10 MB |
536
602
  | `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | Cache lifetime for `dsh_list`'s on-disk probe (the probe spawns a DSH process and takes ~1 s) |
537
603
  | `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels; `0` disables pruning |
538
- | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**) |
604
+ | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**); answer/error/title/tool inputs are still credential-redacted |
539
605
  | `DSH_MCP_PERMISSION` | `danger-full-access` | Default tier for `dsh_start` |
540
606
  | `DSH_MCP_REASONING_EFFORT` | `max` | Default reasoning effort |
541
607
  | `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
@@ -569,19 +635,24 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
569
635
  | `async` | 16 | fire-and-forget + later collection |
570
636
  | `sentinel` | 36 | completion sentinel: atomicity, latch semantics, per-conversation namespacing under concurrency, cancelled runs still land it, and **reasoning never reaching the file** |
571
637
  | `prune` | 11 | **sentinel retention**: prunes over-age and crash-leftover files, keeps fresh ones, removes empty shells, `TTL=0` disables pruning, and never touches the conversation registry beside it (no tokens, runs every time) |
572
- | `guards` | 31 | **environment-variable guards**: odd numbers always land on the safe side — `IDLE_TTL_MS` values like `-1` or garbage mean "never reap" (never give the write lock away), a too-small reap interval falls back to the default (no busy loop), and the **lost-holder threshold is floored at 30 s** (so another instance cannot "legitimately" preempt and kill our turn) (no tokens, runs every time) |
573
- | `sandbox-guard` | 18 | **the test sandbox itself**: a "throwaway HOME" that resolves to the real user profile, to the filesystem root, or to anywhere outside the OS temp dir is refused with an error instead of being written to and deleted. Every other suite builds its HOME through `test/sandbox-home.mjs` and can only destroy what it created there (no tokens, runs every time) |
638
+ | `guards` | 36 | **environment-variable guards**: odd numbers always land on the safe side — `IDLE_TTL_MS=-1` means "never reap", garbage/empty falls back to the 10-minute default, a too-small reap interval falls back to the default (no busy loop), and the **lost-holder threshold is floored at 30 s** (so another instance cannot "legitimately" preempt and kill our turn) (no tokens, runs every time) |
639
+ | `sandbox-guard` | 21 | **the test sandbox itself**: a "throwaway HOME" that resolves to the real user profile, to the filesystem root, or to anywhere outside the OS temp dir is refused with an error instead of being written to and deleted. Every other suite builds its HOME through `test/sandbox-home.mjs` and can only destroy what it created there (no tokens, runs every time) |
574
640
  | `approval-details` | 27 | **approval review context**: the pending-approval snapshot carries the command, reason, cwd and original approval ID — not just a bare ID — so a caller can actually adjudicate; missing `rawInput` is correlated from the earlier `tool_call` update by ID; credential-shaped fields and common inline literals are redacted; closing a conversation cancels pending approvals. Verified end-to-end over real stdio MCP |
641
+ | `failure-terminal` | 11 | **terminal state on failure**: an `ensureAlive` failure wakes `dsh_interject` waiters, writes an `error` sentinel, and attaches `runId` to the error; an approval with no matching option reports `effective=cancelled` instead of a fake allow; `dsh_get` reads a run back from its sentinel (no tokens) |
642
+ | `shared-host` | 15 | **shared process host**: notifications and permission requests route by `sessionId`, unknown-session approvals are cancelled, a process exit fans out to every attached conversation, the child stops only when the last conversation detaches; RSS helper (no tokens) |
643
+ | `shared-process` | 8 (skips without DSH) | **real-DSH shared process**: two conversations with the same `(cwd, permission)` share one PID, closing one leaves the other alive, closing both clears the host and exits the PID (needs a local DSH; runs with `--all`) |
644
+ | `dispatch-tools` | 31 | **dispatch surface**: `dsh_inbox` aggregates pending approvals across conversations, `client_run_id` idempotency hits and evicts with its run, queue list/update/promote/remove (remove writes a cancelled sentinel), `dsh_get(max_chars)` truncates only the text while structured keeps the full answer, audit records metadata only, `dsh_doctor` essential checks (no tokens) |
645
+ | `line-decoder` | 4 | **UTF-8 line framing**: multi-byte characters split across MCP/ACP stream chunks are preserved (no U+FFFD), including CRLF and EOF-without-newline (no tokens) |
575
646
  | `read-cursor` | 10 | **`dsh_read` cursor semantics**: text appended to an already-read entry is delivered again (revision cursor, match by `seq`), and `limit` is clamped to 1..500 so `0`/negative cannot spin or drop the tail (no tokens) |
576
- | `sentinel-redaction` | 15 | **sentinel disk hygiene**: `result.thinking` is stripped, `thinking_omitted` is always a boolean, and `answer`/`error`/`title`/`tools_used[].input` all pass the same credential redaction as approvals (no tokens) |
577
- | `forget-tombstone` | 7 | **`forget` is terminal**: `release(forget=true)` writes a tombstone, by-id lookups fail with a recovery path, lists exclude it, and deleting the tombstone restores the normal unknown-session path (no tokens) |
647
+ | `sentinel-redaction` | 19 | **sentinel disk hygiene**: `result.thinking` is stripped, `thinking_omitted` is always a boolean, and `answer`/`error`/`title`/`tools_used[].input` all pass the same credential redaction as approvals (no tokens) |
648
+ | `forget-tombstone` | 10 | **`forget` is terminal**: `release(forget=true)` writes a tombstone, by-id lookups fail with a recovery path, lists exclude it, and deleting the tombstone restores the normal unknown-session path (no tokens) |
578
649
  | `bootstrap-profile` | 24 | **first-start profile works in the published zero-dependency form**: copies routing with the built-in YAML subset parser, strips `apiKey`/`headers`/literal secrets, never writes `defaultPreset`, is idempotent, and leaves a user-maintained profile byte-identical (no tokens) |
579
650
  | `workspace-safety` | 23 | **data-loss boundaries**: `isTempPath` is segment-aware (`tmpdir()+'Suffix'` and `/tmpfoo` are *not* temp), a corrupt `workspace.json` is never overwritten, an unreadable session root never wipes `sessionIds`, and automatic cleanup deletes only test-created sessions (no tokens) |
580
- | `permission-adopt` | 13 | **no silent privilege escalation**: adopting an external session with no permission record fails closed to `read-only`; a recorded `read-only` cannot be promoted by the sidecar; the runtime read-back rejects a recorded/requested mismatch (no tokens) |
581
- | `state-safety` | 21 | **state/sentinel/lock storage**: defaults live under `$DSH_HOME/.dsh-any-agent`, an unwritable directory fails startup loudly, sentinel write failures return `null` + `sentinel_error`, and bad numeric env values land on the safe side (no tokens) |
582
- | `setup` | 41 | **the registration CLI**: preview writes nothing, `--remove` takes out only its own entry, an absent client is skipped rather than failed, the Codex TOML block round-trips, and the command recorded in a config is actually spawned and must answer `initialize` (no tokens). It never invokes `claude mcp get` — that CLI rewrites `~/.claude.json` |
651
+ | `permission-adopt` | 17 | **no silent privilege escalation**: adopting an external session with no permission record fails closed to `read-only`; a recorded `read-only` cannot be promoted by the sidecar; the runtime read-back rejects a recorded/requested mismatch (no tokens) |
652
+ | `state-safety` | 26 | **state/sentinel/lock storage**: defaults live under `$DSH_HOME/.dsh-any-agent`, an unwritable directory fails startup loudly, sentinel write failures return `null` + `sentinel_error`, and bad numeric env values land on the safe side (no tokens) |
653
+ | `setup` | 45 | **the registration CLI**: preview writes nothing, `--remove` takes out only its own entry, an absent client is skipped rather than failed, the Codex TOML block round-trips, and the command recorded in a config is actually spawned and must answer `initialize` (no tokens). It never invokes `claude mcp get` — that CLI rewrites `~/.claude.json` |
583
654
  | `removal-scope` | 24 | **unregistering touches only its own entry**: configs holding unrelated MCP servers, top-level Codex settings and sibling Zed settings are registered and then removed, asserting every other value is byte-identical; removing something never written is a no-op with no write (no tokens) |
584
- | `config-safety` | 406 | **the property the 0.3 line is about**, per client: preview leaves files byte-identical with no `.bak`; register-then-remove restores every other entry byte-for-byte, keeps key order and drops no top-level key; 4-space / 2-space indentation, single-line JSON, missing trailing newline, CRLF and a UTF-8 BOM all survive a write; malformed JSON and read-only files are refused without damage; the second `--yes` is a no-op with no new backup; every `.bak-dsh-<stamp>` appears once and equals the pre-write bytes; a `claude` on PATH is never invoked (no tokens) |
655
+ | `config-safety` | 407 | **the property the 0.3 line is about**, per client: preview leaves files byte-identical with no `.bak`; register-then-remove restores every other entry byte-for-byte, keeps key order and drops no top-level key; 4-space / 2-space indentation, single-line JSON, missing trailing newline, CRLF and a UTF-8 BOM all survive a write; malformed JSON and read-only files are refused without damage; the second `--yes` is a no-op with no new backup; every `.bak-dsh-<stamp>` appears once and equals the pre-write bytes; a `claude` on PATH is never invoked (no tokens) |
585
656
  | `real-configs` | 84 (66 with 7/9, 48 with 5/9 fixtures) | **round-trip on the real thing**: copies this machine's actual client configs (an 182693-byte `~/.claude.json` holding hundreds of `projects` entries and case-colliding keys, a 238-line Codex TOML, Zed's full `settings.json`) into a temp dir, registers, then removes, and requires every file back **byte for byte**; entry-presence is checked by key (not a bare substring), and missing fixtures are reported as skipped rather than green (no tokens) |
586
657
  | `commit-script` | 26 | commit messages go through stdin, never a temp file that `git add -A` could capture (no tokens) |
587
658
  | `roots` | 12 | `dsh_start` without `cwd` asks the client for MCP roots; a client without roots gets an actionable error (no tokens) |
@@ -599,13 +670,13 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
599
670
  | `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) |
600
671
  | `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
601
672
 
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).
673
+ **`node test/run.mjs` runs 843 checks on a machine with five client fixtures (879 with all nine)** — smoke 29 + 814 offline checks (850 with nine). Every suite exited 0 on 0.3.9; none needs a provider (smoke skips its session-creation sections when DSH is absent).
603
674
 
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.
675
+ That is **524 assertions in the client-registration suites alone** (`setup` 45, `removal-scope` 24, `config-safety` 407, `real-configs` 48; 560 with all nine fixtures), which is where the 0.3 work actually is.
605
676
 
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__*`).
677
+ 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.9 — 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__*`).
607
678
 
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.
679
+ > Three different totals are intentional: `node test/run.mjs` (843/879), the CI offline job (nominal 814/850, no smoke; hosted runners without client fixtures skip real-configs' 48 checks), and the model-backed set (474 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
609
680
 
610
681
  ### 🧯 Why `sandbox-guard` exists
611
682
 
@@ -643,7 +714,7 @@ DSH conversations carry a **cross-process write lock**: **it never expires while
643
714
 
644
715
  | Situation | Outcome |
645
716
  |---|---|
646
- | **This server holds the lock** (the default) | ✅ Opening it in the GUI is **read-only**: you still **see the content**, but you **cannot take the lock**. The default `DSH_MCP_IDLE_TTL_MS=0` (never reap) keeps the lock from ever going free |
717
+ | **This server holds the lock** (recently used, or `keep_alive=true`) | ✅ Opening it in the GUI is **read-only**: you still **see the content**, but you **cannot take the lock**. Since 0.4.0 the process is reaped only after **10 minutes** idle; `keep_alive=true` sessions are never reaped for idleness |
647
718
  | **You open it in the GUI while the lock is free** | ❌ **The GUI keeps it permanently** (navigating away, waiting and archiving all fail to release it) and this server **can never reconnect** — only restarting `dsh web` helps |
648
719
  | **Recommended** | Watch progress with **`dsh_read`** (cursor-based incremental reads) or the **sentinel file**; if you must use the GUI, `dsh_release` first (that interrupts a running turn, so it fits the gaps between tasks) |
649
720
 
@@ -669,7 +740,7 @@ DSH conversations carry a **cross-process write lock**: **it never expires while
669
740
  5. **A conversation that dies before its first successful turn may never have materialized on disk** — resume then fails with a clear error. Safe after the first message.
670
741
  6. **No renaming** — DSH's title subsystem has no external rename API (`SessionTitleService.rename` requires a live in-process session). Titles are auto-generated from the first message.
671
742
  7. **`session/list` returns only `{sessionId, cwd}` and excludes already-open sessions** — titles are filled in by this server from DSH's projection cache.
672
- 8. **"Never reap" costs memory (measured 300–480 MB per conversation)** — that is what buys "the GUI can never take the write lock". Two fresh-start measurements of one active conversation gave 476 MB and 295 MB of added resident memory (the DSH child plus this server); expect the higher figures on a cold process. Two ways to spend less: ① **`DSH_MCP_MAX_LIVE` (default 8** — over the cap, the least recently used process is reaped → bounded memory, locks still held where it matters); ② set `DSH_MCP_IDLE_TTL_MS` to a millisecond value (at the cost of the lock being takeable). To drop to zero right after a batch of work: **`dsh_release(all=true)`** ✓ (releases idle conversations; busy turns are skipped and keep running — pass `force=true` to interrupt them too. Everything resumes on demand).
743
+ 8. **Memory (measured 300–480 MB per active conversation)** — since 0.4.0 the defaults are **10-minute idle reaping + `MAX_LIVE=4`**, so the worst case is ≈1.2–1.9 GB; `keep_alive=true` sessions are exempt from idle reaping (at the cost of staying resident). You can also: ① cap by measured RSS with `DSH_MCP_MAX_RSS_MB`; ② cap the child heap with `DSH_MCP_CHILD_MAX_OLD_SPACE_MB`; ③ set `DSH_MCP_SHARED_PROCESS=1` to share one ACP process across sessions with the same `(cwd, permission)` (experimental: saves memory and cold start, but a crash takes down the group). To drop to zero right after a batch of work: **`dsh_release(all=true)`** ✓ (releases idle conversations; busy turns are skipped and keep running — pass `force=true` to interrupt them too. Everything resumes on demand).
673
744
 
674
745
  ## ❓ Why is the profile still called `dsh-mcp`?
675
746
 
@@ -705,6 +776,13 @@ npm pack --dry-run
705
776
  # 4. Tag and push (wait for CI before publishing).
706
777
  git add -A && git commit -m "chore: release x.y.z"
707
778
  git tag vx.y.z && git push origin main --tags
779
+
780
+ # 5. Publish to npm and verify.
781
+ npm publish --access public
782
+ npm view dsh-any-agent version
783
+ # A 404 right after publish is usually propagation, not failure: npm returns
784
+ # 202 and the registry can lag briefly. Confirm the local npm log shows the
785
+ # version before retrying.
708
786
  ```
709
787
 
710
788
  ## Star History