dsh-any-agent 0.1.0 → 0.1.1
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/README.md +53 -9
- package/README.zh.md +53 -9
- package/USAGE-MIGRATION.md +96 -13
- package/USAGE-NOTES.en.md +2 -2
- package/USAGE-NOTES.md +2 -2
- package/bin/dsh-any-agent.mjs +39 -4
- package/bin/setup.mjs +286 -0
- package/package.json +5 -3
- package/src/config.mjs +2 -2
- package/src/hub.mjs +2 -2
- package/src/mcp.mjs +17 -1
- package/src/tools.mjs +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ DSH (DeepSeek Harness) can do all of that — it has file/shell tools, skills, s
|
|
|
66
66
|
| | |
|
|
67
67
|
|---|---|
|
|
68
68
|
| 🔄 **A dead process never kills a conversation** | The identity lives on disk. A crash, an idle reap, even restarting this whole server costs you nothing — the next call transparently resumes it, memory intact (verified across processes). |
|
|
69
|
-
| 🗣️ **You are never stuck waiting** | Steer a conversation mid-flight — **interject** to stop and redirect (~
|
|
69
|
+
| 🗣️ **You are never stuck waiting** | Steer a conversation mid-flight — **interject** to stop and redirect (~100 ms to reach idle, measured over four cancelled turns), or **queue** a remark for after the current turn. Interrupting never damages the conversation. |
|
|
70
70
|
| 📡 **Completion notification, not polling** | Long tasks return instantly with a `run_id`; when the turn ends, a **sentinel file** is written atomically so a background task in *your* host wakes you. No polling, no occupied turn. |
|
|
71
71
|
| 🪟 **Many conversations at once** | `dsh_list(only_running)` shows what's live; `dsh_read` gives cursor-based incremental output. Cycle between them like windows. |
|
|
72
72
|
| 🧠 **Reasoning hidden by default** | You get "thought for N chars / M seconds" statistics instead of context-burning prose — and it is never logged, never written to disk. |
|
|
@@ -91,7 +91,7 @@ DSH ships two programmable entry points. The `sdk` profile looks simpler, but it
|
|
|
91
91
|
|
|
92
92
|
## 📦 Requirements
|
|
93
93
|
|
|
94
|
-
- **Node.js ≥
|
|
94
|
+
- **Node.js ≥ 22.15** — the server uses `zstdDecompressSync` from `node:zlib` to read DSH session headers, and that API does not exist before 22.15
|
|
95
95
|
- **DSH installed** (`@deepseek-ai/dsh`), with working credentials under `~/.dsh`
|
|
96
96
|
- A DSH **profile named `dsh-mcp`** derived from the shipped `acp` template, containing **your own** provider/model config (this repo deliberately ships no provider config)
|
|
97
97
|
|
|
@@ -112,13 +112,31 @@ Then edit `~/.dsh/profiles/dsh-mcp/cordis.patch.yml` and declare your provider a
|
|
|
112
112
|
|
|
113
113
|
### 2. Register the server
|
|
114
114
|
|
|
115
|
-
**From npm (recommended)**
|
|
115
|
+
**From npm, one command (recommended)**
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
npm install -g dsh-any-agent
|
|
119
|
+
dsh-any-agent-setup # detect the clients you have; writes nothing
|
|
120
|
+
dsh-any-agent-setup --yes # apply
|
|
119
121
|
```
|
|
120
122
|
|
|
121
|
-
|
|
123
|
+
`setup` finds the MCP clients on your machine — **Claude Code, Codex, Cursor, Windsurf, Zed** — and writes the entry into each one it finds, skipping the rest. It is safe by construction:
|
|
124
|
+
|
|
125
|
+
- **dry-run by default**; `--yes` is required to write anything
|
|
126
|
+
- every file is backed up to `<file>.bak-dsh-<timestamp>` first
|
|
127
|
+
- JSON clients are parsed before and after writing, so a broken config never lands
|
|
128
|
+
- Claude Code and Codex go through their own `mcp add` subcommands rather than a hand-edited file
|
|
129
|
+
- **idempotent** — re-running only changes what differs
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
dsh-any-agent-setup --yes --only cursor,zed # just these
|
|
133
|
+
dsh-any-agent-setup --yes --permission read-only
|
|
134
|
+
dsh-any-agent-setup --remove --yes # take it back out
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**From a local checkout**
|
|
138
|
+
|
|
139
|
+
The `dsh-any-agent` executable is on your `PATH` after a global install, so every client below can call it by name. Otherwise point `command` at `node` and `args` at `<checkout>/server/bin/dsh-any-agent.mjs`.
|
|
122
140
|
|
|
123
141
|
**From a local checkout**
|
|
124
142
|
|
|
@@ -166,7 +184,9 @@ For a local checkout, use `"command": "node"` and `"args": ["/absolute/path/to/s
|
|
|
166
184
|
### 3. Try it
|
|
167
185
|
|
|
168
186
|
```bash
|
|
169
|
-
|
|
187
|
+
dsh-any-agent --version # prints the version, starts nothing
|
|
188
|
+
dsh-any-agent --help # flags, key env vars, docs link
|
|
189
|
+
node test/smoke.mjs # no LLM calls, verifies the whole plumbing
|
|
170
190
|
```
|
|
171
191
|
|
|
172
192
|
## 🧰 Tools
|
|
@@ -191,7 +211,7 @@ MCP tool calls **block**, so `dsh_send` **does not block by default** (`wait=fal
|
|
|
191
211
|
|
|
192
212
|
```text
|
|
193
213
|
1. dsh_send(conversation_id, prompt, wait=false)
|
|
194
|
-
→ { run_id: "run-abc123", background: true } # returns
|
|
214
|
+
→ { run_id: "run-abc123", background: true } # returns without waiting for the turn
|
|
195
215
|
|
|
196
216
|
2. …keep working on something else (verified: another conversation ran a full turn meanwhile)…
|
|
197
217
|
|
|
@@ -321,8 +341,8 @@ Silent by default — **not one byte is written**.
|
|
|
321
341
|
| `DSH_MCP_RUNS_DIR` | `<state file dir>/runs` | Root for async completion sentinels (`<this>/<conversation_id>/<run_id>.json`) |
|
|
322
342
|
| `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 |
|
|
323
343
|
| `DSH_MCP_LOCK_STALE_MS` | `90000` | How long without a heartbeat before a holder counts as lost (and becomes auto-preemptible by `dsh_takeover`) |
|
|
324
|
-
| `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
|
|
325
|
-
| `DSH_MCP_MAX_LIVE` | `8` (≈
|
|
344
|
+
| `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 |
|
|
345
|
+
| `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 everything at once: `dsh_release(all=true)` |
|
|
326
346
|
| `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) |
|
|
327
347
|
| `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels; `0` disables pruning |
|
|
328
348
|
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**) |
|
|
@@ -371,6 +391,30 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
371
391
|
|
|
372
392
|
> Counts are the numbers each suite prints itself. `smoke`/`prune`/`guards`/`approval-details`/`boundary` run without LLM calls; the rest need a working provider.
|
|
373
393
|
|
|
394
|
+
### 📏 Numbers in this README must be reproducible
|
|
395
|
+
|
|
396
|
+
Every figure in this document is either **printed by a test** or **measured with a command you can re-run**. If you change a number, change it to a measurement — not to an estimate, and never to a plausible-looking value.
|
|
397
|
+
|
|
398
|
+
This section exists because that rule was violated. An early draft claimed `dsh_send(wait=false)` returned "in ~1 ms" (measured: 81 ms), that interruption converged in "~18 ms" (no test ever produced that number — `integration.mjs` only asserts `waited_ms` is between 0 and 25 s; four manual samples gave 91/102/105/119 ms), and that a conversation costs "~120 MB" (measured: 295–476 MB) — the last one contradicting itself in a single sentence, quoting both "120 MB each" and "three = 428 MB".
|
|
399
|
+
|
|
400
|
+
**Three tiers, and how to state each:**
|
|
401
|
+
|
|
402
|
+
| Tier | What it means | How to write it |
|
|
403
|
+
|---|---|---|
|
|
404
|
+
| **Tested** | A suite asserts it | Give the number; the suite is the authority |
|
|
405
|
+
| **Measured** | You ran a command | Give the number **and the method**, so a reader can reproduce it |
|
|
406
|
+
| **Estimated** | Derived or bounded | Say so ("roughly", "worst case", "≈") and show the arithmetic |
|
|
407
|
+
|
|
408
|
+
**Recording a measurement inline.** The memory figures model the pattern — they name the tool, the before/after, and both samples:
|
|
409
|
+
|
|
410
|
+
```text
|
|
411
|
+
measured 300-480 MB per conversation
|
|
412
|
+
(Get-Process node: WorkingSet64 summed before vs. after one active
|
|
413
|
+
conversation reached end_turn; two cold starts gave 476 MB and 295 MB)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**When yours disagrees, say so.** Figures depend on machine, DSH version and load. A measurement outside a documented range is worth an issue — the range is a claim, not decoration.
|
|
417
|
+
|
|
374
418
|
## 🔒 The write lock: one writer per conversation at a time
|
|
375
419
|
|
|
376
420
|
DSH conversations carry a **cross-process write lock**: **it never expires while the holder lives, and no API can take it from a live holder** — so **whoever gets there first decides everything**. This server turns that into "**you can look, we can write**":
|
|
@@ -403,7 +447,7 @@ DSH conversations carry a **cross-process write lock**: **it never expires while
|
|
|
403
447
|
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.
|
|
404
448
|
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.
|
|
405
449
|
7. **`session/list` returns only `{sessionId, cwd}` and excludes already-open sessions** — titles are filled in by this server from DSH's projection cache.
|
|
406
|
-
8. **"Never reap" costs memory (measured
|
|
450
|
+
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)`** ✓ (lossless, everything resumes on demand).
|
|
407
451
|
|
|
408
452
|
## ❓ Why is the profile still called `dsh-mcp`?
|
|
409
453
|
|
package/README.zh.md
CHANGED
|
@@ -66,7 +66,7 @@ DSH(DeepSeek Harness)本身**全都能做** —— 它有文件/shell 工具
|
|
|
66
66
|
| | |
|
|
67
67
|
|---|---|
|
|
68
68
|
| 🔄 **进程死了对话也不丢** | 对话身份在磁盘上。进程崩溃、被空闲回收、甚至整个服务重启,都不影响继续——下次调用自动 `resume`,记忆完整(跨进程实测)。 |
|
|
69
|
-
| 🗣️ **不用傻等** |
|
|
69
|
+
| 🗣️ **不用傻等** | 回合跑偏了能**插话改口**(约 100ms 收敛到 idle,四次打断实测),或**排队**等这轮跑完再说;打断后会话不损坏。 |
|
|
70
70
|
| 📡 **跑完自动通知** | 长任务 `wait=false` 立刻返回 `run_id`;回合结束时**原子写出哨兵文件**,用你宿主的后台任务等它即可。不轮询、不占轮次。 |
|
|
71
71
|
| 🪟 **多会话并发** | `dsh_list(only_running)` 看谁在跑,`dsh_read` 游标式增量读——像切窗口一样轮流盯。 |
|
|
72
72
|
| 🧠 **灰色思考默认隐藏** | 只给"想了 N 字 / M 秒"的统计,不烧上下文;**不写日志、不落盘**。 |
|
|
@@ -91,7 +91,7 @@ DSH 自带两个可编程入口。`sdk` profile 看着更简单,但它的 JSON
|
|
|
91
91
|
|
|
92
92
|
## 📦 前置要求
|
|
93
93
|
|
|
94
|
-
- **Node.js ≥
|
|
94
|
+
- **Node.js ≥ 22.15** —— 服务用 `node:zlib` 的 `zstdDecompressSync` 读 DSH 会话头,这个 API 在 22.15 之前不存在
|
|
95
95
|
- **已安装 DSH**(`@deepseek-ai/dsh`),且 `~/.dsh` 下已有可用的 provider 与凭据(GUI 能正常聊天即可)
|
|
96
96
|
|
|
97
97
|
> 本服务**不自带** provider 配置,也**从不**接收或保存 API 密钥。
|
|
@@ -127,13 +127,31 @@ dsh --profile dsh-mcp --from-default-profile acp
|
|
|
127
127
|
|
|
128
128
|
### 2. 注册 MCP 服务
|
|
129
129
|
|
|
130
|
-
**从 npm
|
|
130
|
+
**从 npm 安装,一条命令(推荐)**
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
133
|
npm install -g dsh-any-agent
|
|
134
|
+
dsh-any-agent-setup # 检测你装了哪些客户端,不写任何文件
|
|
135
|
+
dsh-any-agent-setup --yes # 真正写入
|
|
134
136
|
```
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
`setup` 会找出你机器上的 MCP 客户端 —— **Claude Code、Codex、Cursor、Windsurf、Zed** —— 逐个写进去,没装的跳过。它的安全性是结构性保证的:
|
|
139
|
+
|
|
140
|
+
- **默认 dry-run**,必须加 `--yes` 才写
|
|
141
|
+
- 每个文件写前备份成 `<file>.bak-dsh-<时间戳>`
|
|
142
|
+
- JSON 类客户端写入前后各解析一次,坏配置绝不落盘
|
|
143
|
+
- Claude Code / Codex 走它们自己的 `mcp add` 子命令,不手改文件
|
|
144
|
+
- **幂等**,重复运行只改变有差异的部分
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
dsh-any-agent-setup --yes --only cursor,zed # 只装这几个
|
|
148
|
+
dsh-any-agent-setup --yes --permission read-only
|
|
149
|
+
dsh-any-agent-setup --remove --yes # 全部移除
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**从本地目录安装**
|
|
153
|
+
|
|
154
|
+
全局安装后 `dsh-any-agent` 命令就在 `PATH` 上,下面每种客户端都可以直接按名字调用。否则把 `command` 指向 `node`、`args` 指向 `<解压目录>/server/bin/dsh-any-agent.mjs`。
|
|
137
155
|
|
|
138
156
|
**从本地目录安装**
|
|
139
157
|
|
|
@@ -181,7 +199,9 @@ tool_timeout_sec = 120.0
|
|
|
181
199
|
### 3. 自检
|
|
182
200
|
|
|
183
201
|
```bash
|
|
184
|
-
|
|
202
|
+
dsh-any-agent --version # 打印版本,不启动任何东西
|
|
203
|
+
dsh-any-agent --help # 参数、关键环境变量、文档链接
|
|
204
|
+
node test/smoke.mjs # 不调用 LLM,验证整条管道
|
|
185
205
|
```
|
|
186
206
|
|
|
187
207
|
## 🧰 工具
|
|
@@ -206,7 +226,7 @@ MCP 工具调用是**阻塞**的,所以 `dsh_send` **默认就不阻塞**(`w
|
|
|
206
226
|
|
|
207
227
|
```text
|
|
208
228
|
1. dsh_send(conversation_id, prompt, wait=false)
|
|
209
|
-
→ { run_id: "run-abc123", background: true } #
|
|
229
|
+
→ { run_id: "run-abc123", background: true } # 不等回合跑完就返回
|
|
210
230
|
|
|
211
231
|
2. …继续做你自己的事(实测:期间在另一个会话上跑完了一整个回合)…
|
|
212
232
|
|
|
@@ -337,8 +357,8 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
|
|
|
337
357
|
| `DSH_MCP_RUNS_DIR` | `<状态文件同目录>/runs` | 异步完成哨兵的根目录(`<此目录>/<会话id>/<run_id>.json`) |
|
|
338
358
|
| `DSH_MCP_LOCKS_DIR` | `<状态文件同目录>/locks` | 写锁"持有者登记"的目录。⚠️ **多个实例必须指向同一个值**,否则彼此看不见对方的持锁登记、会把对方误判成"无法识别的持有者(GUI)"而拒绝抢占 |
|
|
339
359
|
| `DSH_MCP_LOCK_STALE_MS` | `90000` | 持锁登记多久没续心跳就算"持有者失联"(此时可被 `dsh_takeover` 自动抢占) |
|
|
340
|
-
| `DSH_MCP_IDLE_TTL_MS` | `0`(永不回收) | 会话空闲多久回收其 DSH 进程。★★ **`0` = 永不回收(默认)**:本服务一直握着写锁,**GUI 抢不走**(你在 GUI 里点开是**只读**,照样能看 ✓)。代价:每会话常驻一个 DSH
|
|
341
|
-
| `DSH_MCP_MAX_LIVE` | `8
|
|
360
|
+
| `DSH_MCP_IDLE_TTL_MS` | `0`(永不回收) | 会话空闲多久回收其 DSH 进程。★★ **`0` = 永不回收(默认)**:本服务一直握着写锁,**GUI 抢不走**(你在 GUI 里点开是**只读**,照样能看 ✓)。代价:每会话常驻一个 DSH 进程(**两次冷启动实测 476MB / 295MB**)。设成毫秒数(如 `300000`)可省内存,但要接受"锁可能被 GUI 抢走" |
|
|
361
|
+
| `DSH_MCP_MAX_LIVE` | `8`(最坏约 2.5–4GB,见第 8 条) | ★ **最多同时保活几个会话进程**(`0` = 不限)。超标时回收**最久没用过**的(**在跑的回合绝不回收**)—— "**内存有界,锁尽量在手**"。内存紧用 `4`,机器大可以 `0`。用完想立刻全放掉:`dsh_release(all=true)` |
|
|
342
362
|
| `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | `dsh_list` 磁盘探测结果的缓存时长(探测要 spawn 一个 DSH 进程,实测约 1 秒) |
|
|
343
363
|
| `DSH_MCP_SENTINEL_TTL_MS` | `604800000`(7 天) | 启动时清理超过此时长的哨兵;`0` 关闭清理 |
|
|
344
364
|
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"的保证**) |
|
|
@@ -387,6 +407,30 @@ node test/cleanup.mjs # 单独跑收尾清理(只针对临时目录;--d
|
|
|
387
407
|
|
|
388
408
|
> 数字取各套件自己打印的汇总。`smoke`/`prune`/`guards`/`approval-details`/`boundary` 不调用模型;其余需要可用的 provider。
|
|
389
409
|
|
|
410
|
+
### 📏 本文档里的数字必须可复现
|
|
411
|
+
|
|
412
|
+
这里的每一个数字,要么是**测试打印出来的**,要么是**你用一条命令就能重跑出来的**。要改数字,就改成实测值 —— 不是估算,更不是一个看起来合理的值。
|
|
413
|
+
|
|
414
|
+
单列这一节,是因为这条规矩被违反过。早期版本的 README 声称 `dsh_send(wait=false)` "在 ~1ms 内返回"(实测 81ms)、打断收敛 "~18ms"(没有任何测试产出过这个数 —— `integration.mjs` 只断言 `waited_ms` 落在 0–25 秒之间;人工实测四次为 91/102/105/119ms)、每会话占用 "~120MB"(实测 295–476MB)—— 最后这个还在同一句话里自相矛盾,同时写着 "每个 120MB" 和 "3 个 = 428MB"。
|
|
415
|
+
|
|
416
|
+
**三档,以及各自该怎么写:**
|
|
417
|
+
|
|
418
|
+
| 档位 | 含义 | 写法 |
|
|
419
|
+
|---|---|---|
|
|
420
|
+
| **测试断言** | 有套件在断言它 | 直接给数字,套件就是权威 |
|
|
421
|
+
| **实测** | 你跑过命令 | 给数字 **和测法**,让别人能复现 |
|
|
422
|
+
| **估算** | 推导或上界 | 明确写出来("约"、"最坏"、"≈"),并给出算法 |
|
|
423
|
+
|
|
424
|
+
**把测法写在同一处。** 内存那两个数字就是范例 —— 它写清了工具、前后对比、以及两次采样:
|
|
425
|
+
|
|
426
|
+
```text
|
|
427
|
+
实测 300-480MB / 会话
|
|
428
|
+
(Get-Process node: 在单个活跃会话跑到 end_turn 前后分别求和 WorkingSet64;
|
|
429
|
+
两次冷启动分别得到 476MB 和 295MB)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
**你的测量和文档不符,就说出来。** 数字依赖机器、DSH 版本和负载。实测落在文档区间之外,值得开个 issue —— 区间是一个主张,不是装饰。
|
|
433
|
+
|
|
390
434
|
## 🔒 写锁:一个会话同一时刻只有一个写者
|
|
391
435
|
|
|
392
436
|
DSH 的会话有**跨进程写锁**:**持有者活着就永不过期,也没有 API 能从活的持有者手里夺走** —— 所以**谁先拿到就决定一切**。本服务把它做成"**你看得见,我们写得动**":
|
|
@@ -420,7 +464,7 @@ DSH 的会话有**跨进程写锁**:**持有者活着就永不过期,也没
|
|
|
420
464
|
5. **首次成功回合之前就死掉的会话可能尚未落盘** —— 此时 resume 会报清晰错误。发出第一条消息后就安全了。
|
|
421
465
|
6. **不能改名** —— DSH 的标题子系统没有对外改名接口(`SessionTitleService.rename` 需要进程内的活会话)。标题一律由首条消息自动生成。
|
|
422
466
|
7. **`session/list` 只返回 `{sessionId, cwd}`,且排除已打开的会话** —— 对话名由本服务从 DSH 投影缓存补全。
|
|
423
|
-
8. **默认"永不回收"
|
|
467
|
+
8. **默认"永不回收"要花内存(实测 300–480MB/会话)** —— 换来的是"GUI 抢不走写锁"。两次冷启动实测单个活跃会话新增常驻内存分别为 476MB 和 295MB(含 DSH 子进程与本服务),冷进程首次偏高。两种省内存办法:① **`DSH_MCP_MAX_LIVE`(默认 8**,超标就回收最久没用过的 → 内存有界 ✓ 锁仍尽量在手 ✓);② 把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价是锁可能被 GUI 拿走)。一批活干完想立刻归零:**`dsh_release(all=true)`** ✓(无损,之后照样 resume)。
|
|
424
468
|
|
|
425
469
|
## ❓ 为什么 profile 还叫 `dsh-mcp`?
|
|
426
470
|
|
package/USAGE-MIGRATION.md
CHANGED
|
@@ -36,8 +36,18 @@
|
|
|
36
36
|
|
|
37
37
|
### 1.1 确认 DSH 的位置
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
本服务需要一个 DSH 入口路径。**自动探测只找两处**:npx 缓存、npm 全局安装目录
|
|
40
|
+
(`%APPDATA%\npm\node_modules`、`/usr/local/lib/node_modules`、`/usr/lib/node_modules`)。
|
|
41
|
+
|
|
42
|
+
**实测结论**:用 `npx @deepseek-ai/dsh` 跑过 DSH 的机器,缓存里就有,**不设 `DSH_BIN` 也能自动找到**
|
|
43
|
+
(已验证:不给 `DSH_BIN` 时握手和建会话都成功)。
|
|
44
|
+
|
|
45
|
+
但两种情况探测不到,必须手动指定:
|
|
46
|
+
|
|
47
|
+
- **DSH 桌面版(Electron)** —— 装在 `...\WindowsApps\...\app.asar\dsh\`,探测逻辑不查那里
|
|
48
|
+
- **DSH 装在自定义路径**,或 npx 缓存被清理过
|
|
49
|
+
|
|
50
|
+
手动找入口文件:
|
|
41
51
|
|
|
42
52
|
```powershell
|
|
43
53
|
# 找一下你机器上 DSH 的入口文件
|
|
@@ -75,23 +85,73 @@ npm install -g dsh-any-agent
|
|
|
75
85
|
|
|
76
86
|
## 3. 接进你的 agent
|
|
77
87
|
|
|
88
|
+
### 3.0 一条命令搞定(推荐)
|
|
89
|
+
|
|
90
|
+
本服务自带一个 setup 命令,**自动检测你装了哪些客户端并写进配置**:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# 先看它会改什么(默认不写任何文件)
|
|
94
|
+
dsh-any-agent setup
|
|
95
|
+
|
|
96
|
+
# 确认后真正写入
|
|
97
|
+
dsh-any-agent setup --yes
|
|
98
|
+
|
|
99
|
+
# 只装某几个 / 换权限档
|
|
100
|
+
dsh-any-agent setup --yes --only cursor,zed
|
|
101
|
+
dsh-any-agent setup --yes --permission read-only
|
|
102
|
+
|
|
103
|
+
# 反悔了,全部移除
|
|
104
|
+
dsh-any-agent setup --remove --yes
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
支持的客户端:**Claude Code、Codex、Cursor、Windsurf、Zed**。没装的那些会被跳过,不算失败。
|
|
108
|
+
|
|
109
|
+
它做的几件保证安全的事:
|
|
110
|
+
|
|
111
|
+
- **默认 dry-run**,加 `--yes` 才写
|
|
112
|
+
- 每个文件写前备份成 `<file>.bak-dsh-<时间戳>`
|
|
113
|
+
- JSON 类客户端:写入前解析原文件、写后回读校验,坏文件绝不留在磁盘上
|
|
114
|
+
- Claude Code / Codex 走它们自己的 `mcp add` 子命令,不手改数据库
|
|
115
|
+
- **幂等**:重复运行只会更新变化的部分
|
|
116
|
+
|
|
117
|
+
> 用 npm 全局安装时命令叫 `dsh-any-agent-setup`(或 `npx dsh-any-agent-setup`)。
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### 3.1 手工配置(setup 不支持你的客户端时)
|
|
122
|
+
|
|
78
123
|
所有客户端本质上是同一件事:**告诉它去哪里启动这个 stdio 服务**。
|
|
79
124
|
下面按客户端给出现成配置块,直接复制。
|
|
80
125
|
|
|
81
126
|
> 🔑 **通用要点**:如果 `dsh-any-agent` 不在 `PATH` 上(或你想用指定的 Node),
|
|
82
127
|
> 把 `command` 换成 node 的绝对路径、`args` 换成 `<安装目录>/bin/dsh-any-agent.mjs`。
|
|
83
128
|
|
|
84
|
-
|
|
129
|
+
#### Claude Code
|
|
85
130
|
|
|
86
131
|
```bash
|
|
87
132
|
# 全局安装的情况
|
|
88
133
|
claude mcp add dsh-any-agent -- dsh-any-agent
|
|
89
134
|
|
|
90
|
-
# 指定 DSH
|
|
91
|
-
|
|
135
|
+
# 指定 DSH 入口 + 环境变量(-e 必须紧跟服务名,写在 -- 之后会报
|
|
136
|
+
# "missing required argument 'commandOrUrl'")
|
|
137
|
+
claude mcp add dsh-any-agent -e DSH_BIN=<DSH_BIN> -e DSH_MCP_PERMISSION=workspace-write -- dsh-any-agent
|
|
138
|
+
|
|
139
|
+
# 加到用户级配置(默认是项目级)
|
|
140
|
+
claude mcp add dsh-any-agent -s user -e DSH_BIN=<DSH_BIN> -- dsh-any-agent
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
验证:
|
|
144
|
+
|
|
145
|
+
```powershell
|
|
146
|
+
claude mcp list | Select-String dsh-any-agent
|
|
147
|
+
# 期望: dsh-any-agent: dsh-any-agent - √ Connected
|
|
92
148
|
```
|
|
93
149
|
|
|
94
|
-
|
|
150
|
+
> ⚠️ **前提**:`dsh-any-agent` 必须在 `PATH` 上。如果你只是在仓库里 `npm install`(不加 `-g`),
|
|
151
|
+
> 命令名不会存在,Claude Code 会报 `CONNECTION_CLOSED`。此时改用绝对路径:
|
|
152
|
+
> `claude mcp add dsh-any-agent -e DSH_BIN=<DSH_BIN> -- node <安装目录>/bin/dsh-any-agent.mjs`
|
|
153
|
+
|
|
154
|
+
#### Codex(`~/.codex/config.toml`)
|
|
95
155
|
|
|
96
156
|
```toml
|
|
97
157
|
[mcp_servers.dsh-any-agent]
|
|
@@ -109,13 +169,13 @@ DSH_MCP_LOG = "silent"
|
|
|
109
169
|
|
|
110
170
|
> 从迁移包安装的话,这段由 `install.cmd` 自动写入,不用手抄。
|
|
111
171
|
|
|
112
|
-
|
|
172
|
+
#### Cursor / Windsurf / Cline / Continue(`mcpServers` 型)
|
|
113
173
|
|
|
114
|
-
|
|
174
|
+
这些客户端用同一套 `mcpServers` 结构。配置文件位置:
|
|
115
175
|
|
|
116
|
-
- Cursor
|
|
117
|
-
-
|
|
118
|
-
-
|
|
176
|
+
- **Cursor**:`%USERPROFILE%\.cursor\mcp.json`(全局)或项目内 `.cursor/mcp.json`
|
|
177
|
+
- **Windsurf**:`%USERPROFILE%\.codeium\windsurf\mcp_config.json`
|
|
178
|
+
- **Cline / Continue**:客户端设置里的 MCP Servers 面板
|
|
119
179
|
|
|
120
180
|
```json
|
|
121
181
|
{
|
|
@@ -133,13 +193,36 @@ DSH_MCP_LOG = "silent"
|
|
|
133
193
|
}
|
|
134
194
|
```
|
|
135
195
|
|
|
136
|
-
|
|
196
|
+
#### Zed(`context_servers` 型,格式不同)
|
|
197
|
+
|
|
198
|
+
Zed **不用** `mcpServers`。它的键叫 `context_servers`,且 `command` 是**字符串**而非对象:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"context_servers": {
|
|
203
|
+
"dsh-any-agent": {
|
|
204
|
+
"command": "dsh-any-agent",
|
|
205
|
+
"args": [],
|
|
206
|
+
"env": {
|
|
207
|
+
"DSH_BIN": "<DSH_BIN>",
|
|
208
|
+
"DSH_MCP_PERMISSION": "workspace-write"
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
文件位置:Windows 是 `%APPDATA%\Zed\settings.json`,macOS / Linux 是 `~/.config/zed/settings.json`。
|
|
216
|
+
|
|
217
|
+
> Zed 的远程 MCP 服务器用 `{"url": "...", "headers": {...}}`,但本项目是本地 stdio 服务,用上面的 `command` 形式。
|
|
218
|
+
|
|
219
|
+
#### 其他任意 MCP 客户端
|
|
137
220
|
|
|
138
221
|
只要能启动 stdio MCP 服务就行。它只实现四个标准方法
|
|
139
222
|
(`initialize` / `ping` / `tools/list` / `tools/call`),
|
|
140
223
|
不依赖任何宿主专有扩展 —— 连协议版本协商到旧版、不发 `notifications/initialized` 的客户端都测过。
|
|
141
224
|
|
|
142
|
-
### 3.
|
|
225
|
+
### 3.2 装完先验证
|
|
143
226
|
|
|
144
227
|
重启客户端,然后确认它发现了服务。各客户端查看方式不同,Codex 是:
|
|
145
228
|
|
package/USAGE-NOTES.en.md
CHANGED
|
@@ -64,7 +64,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
64
64
|
|
|
65
65
|
**3.11 ★★ Opening it once hands the conversation to the GUI permanently (measured; harsher than 3.10).** Measured: you open the conversation in the web UI (the GUI takes the lock), then **switch away to another conversation**, then we probe again — **the lock is still held**. So `dsh web` does **not** release it when you navigate away: **once opened, that conversation belongs to the GUI for the entire lifetime of the web process**, and the MCP cannot touch it again until you **restart `dsh web`**. That is why it is "always": it is not taken on every peek, it is taken once and stays taken — so from then on every retry fails until the GUI restarts. Practical advice: (1) never open a conversation the MCP drives, not even once — seeing its title in the list is fine; (2) to read content, have the agent call `dsh_read` and paste the output to you; (3) if you already opened it, **restart `dsh web`** — switching away in the UI does nothing.
|
|
66
66
|
|
|
67
|
-
**3.12 ★★ The lossless answer: keep the MCP holding the lock (`DSH_MCP_IDLE_TTL_MS=0`).** The full causal chain, all measured: a task finishes, the conversation sits idle for five minutes, **idle reaping kills the child and frees the write lock**, you happen to open that conversation in the web UI right then, **the GUI takes the lock and never returns it** (navigating away ✗, waiting ✗, **archiving ✗** — verified that the archive really landed), and from then on every MCP resume fails. That is "the caller can never get back in after a task". The fix: **`DSH_MCP_IDLE_TTL_MS=0` is now the default** (never reap), so the MCP keeps holding the write lock. Then opening it in the GUI **opens it read-only and you can still read it** (measured: the turns and answers are visible), and probing from a separate instance confirms **the holder is still this server's own PID with a fresh heartbeat** — the lock was never taken. **Neither side is harmed**: you keep visibility, while the MCP's turns keep running and later calls reconnect normally. Zero data loss, zero duplication, zero extra disk. The cost, measured:
|
|
67
|
+
**3.12 ★★ The lossless answer: keep the MCP holding the lock (`DSH_MCP_IDLE_TTL_MS=0`).** The full causal chain, all measured: a task finishes, the conversation sits idle for five minutes, **idle reaping kills the child and frees the write lock**, you happen to open that conversation in the web UI right then, **the GUI takes the lock and never returns it** (navigating away ✗, waiting ✗, **archiving ✗** — verified that the archive really landed), and from then on every MCP resume fails. That is "the caller can never get back in after a task". The fix: **`DSH_MCP_IDLE_TTL_MS=0` is now the default** (never reap), so the MCP keeps holding the write lock. Then opening it in the GUI **opens it read-only and you can still read it** (measured: the turns and answers are visible), and probing from a separate instance confirms **the holder is still this server's own PID with a fresh heartbeat** — the lock was never taken. **Neither side is harmed**: you keep visibility, while the MCP's turns keep running and later calls reconnect normally. Zero data loss, zero duplication, zero extra disk. The cost, measured: **300–480 MB per live conversation** (two cold starts of a single active conversation added 476 MB and 295 MB; roughly linear, so ten conversations reach 3–5 GB). It will not blow up, but it is real. Three ways to control it: ★ **on by default — `DSH_MCP_MAX_LIVE=8`**: over the cap, the **least recently used** process is reaped (a running turn is never reaped), giving "**bounded memory, locks kept where it matters**" ✓; **after a batch of work — `dsh_release(all=true)`** frees everything at once, dropping memory to zero ✓ and it is lossless (the logs are on disk, everything resumes on demand ✓); or set `DSH_MCP_IDLE_TTL_MS` to a millisecond value (at the cost of the lock being takeable ✗). In one line: reaping saves memory but hands the lock away; not reaping spends memory to buy "never silently locked out". Your data is never at risk either way: a GUI-pinned conversation is intact, and the MCP resumes it normally once `dsh web` restarts — what you lose is only immediate usability, not content.
|
|
68
68
|
|
|
69
69
|
## 4. Permissions and safety
|
|
70
70
|
|
|
@@ -81,7 +81,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
81
81
|
| You want | Reality |
|
|
82
82
|
|---|---|
|
|
83
83
|
| Token-by-token streaming | ❌ Cannot enter model context (MCP limitation). Callers get per-step results; humans follow stderr logs |
|
|
84
|
-
| Mid-turn steering | ❌ ACP rejects concurrent prompts. `dsh_interject` is the equivalent: cancel + immediately start a new turn (~
|
|
84
|
+
| Mid-turn steering | ❌ ACP rejects concurrent prompts. `dsh_interject` is the equivalent: cancel + immediately start a new turn (~100 ms to converge — the `waited_ms` field, four samples gave 91/102/105/119 ms, covering only `session/cancel` through reaching idle) |
|
|
85
85
|
| Paste a screenshot | ❌ ACP advertises `promptCapabilities: {image: false}` |
|
|
86
86
|
| Rename a conversation | ❌ DSH's title subsystem has no external rename API; titles come from the first message |
|
|
87
87
|
| Have the agent ask you questions | ⚠️ `ask_user_question` isn't mounted in the sdk/acp profiles; it won't block waiting for you |
|
package/USAGE-NOTES.md
CHANGED
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
- 用独立实例探测确认:**持有者仍是本服务的 PID、心跳新鲜** → **锁没有被抢走** ✓
|
|
169
169
|
- **两边都不受影响**:你看得见进度,而 MCP 的任务照跑、之后照常接回 ✓
|
|
170
170
|
- **零数据损失、零重复、零磁盘开销** ✓
|
|
171
|
-
- **代价(实测内存)**:每个保活的会话约 **
|
|
171
|
+
- **代价(实测内存)**:每个保活的会话约 **300–480MB**(两次冷启动实测单个活跃会话新增 476MB / 295MB;**近似线性**,10 个可达 3–5GB)✗ 不会"爆",但确实要花。三种控制手段:
|
|
172
172
|
- ★ **默认就有:`DSH_MCP_MAX_LIVE=8`** —— 超过就回收**最久没用过**的那个(**在跑的回合绝不回收**)→ "**内存有界,锁尽量在手**" ✓ 窗口内的会话写锁照握、GUI 抢不走 ✓
|
|
173
173
|
- **一批活干完**:`dsh_release(all=true)` 一次全放掉,内存立刻归零 ✓ **无损**(会话日志都在磁盘上,之后照常 resume ✓)
|
|
174
174
|
- 或把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价:锁可能被 GUI 拿走 ✗)
|
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
| 你想要的 | 实际能做到的 |
|
|
205
205
|
|---|---|
|
|
206
206
|
| 逐字流式输出 | ❌ 进不了模型上下文(MCP 固有限制)。调用方拿到的是**分步**结果;人能通过 stderr 日志跟进 |
|
|
207
|
-
| 中途插话让模型即时改向 | ❌ ACP 拒绝并发 prompt。`dsh_interject` 是等价物:**取消 +
|
|
207
|
+
| 中途插话让模型即时改向 | ❌ ACP 拒绝并发 prompt。`dsh_interject` 是等价物:**取消 + 立刻开新回合**,历史保留(打断收敛约 100ms —— 即 `waited_ms` 字段,四次实测 91/102/105/119ms,只含 `session/cancel` 到 idle) |
|
|
208
208
|
| 给 DSH 看截图 | ❌ ACP 自报 `promptCapabilities: {image: false}` |
|
|
209
209
|
| 给会话改个名字 | ❌ DSH 标题子系统没有对外改名接口,标题一律按首条消息自动生成 |
|
|
210
210
|
| 让 agent 主动问我问题 | ⚠️ `ask_user_question` 工具在 sdk/acp profile 里没挂载,它不会卡住等你 |
|
package/bin/dsh-any-agent.mjs
CHANGED
|
@@ -14,10 +14,45 @@
|
|
|
14
14
|
* DSH_MCP_LOG_STDERR 1 才转发 DSH 子树进程的原始 stderr(可能含思考内容)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
import { SERVER_VERSION } from '../src/mcp.mjs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `--version` / `--help` must be answered *before* the hub starts: the module
|
|
21
|
+
* graph above already pulls in config and locks, and starting the hub would
|
|
22
|
+
* spawn reapers and touch the registry just to print a version string.
|
|
23
|
+
*/
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
26
|
+
process.stdout.write(SERVER_VERSION + '\n');
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
30
|
+
process.stdout.write(
|
|
31
|
+
'dsh-any-agent ' + SERVER_VERSION + '\n\n' +
|
|
32
|
+
'An MCP server that drives DeepSeek Harness (DSH) as a long-lived coding agent.\n' +
|
|
33
|
+
'Runs as an MCP stdio server; normally launched by your MCP client, not by hand.\n\n' +
|
|
34
|
+
'Usage:\n' +
|
|
35
|
+
' dsh-any-agent run the MCP server on stdio\n' +
|
|
36
|
+
' dsh-any-agent --version print the version and exit\n' +
|
|
37
|
+
' dsh-any-agent --help this text\n\n' +
|
|
38
|
+
'Register it with your client:\n' +
|
|
39
|
+
' dsh-any-agent-setup detect clients and preview (writes nothing)\n' +
|
|
40
|
+
' dsh-any-agent-setup --yes apply\n\n' +
|
|
41
|
+
'Key environment variables (all optional):\n' +
|
|
42
|
+
' DSH_BIN path to @deepseek-ai/dsh/lib/bin.js; auto-detected\n' +
|
|
43
|
+
' DSH_MCP_PROFILE DSH profile to drive (default dsh-mcp)\n' +
|
|
44
|
+
' DSH_MCP_PERMISSION danger-full-access | workspace-write | read-only\n' +
|
|
45
|
+
' DSH_MCP_APPROVAL auto-allow | auto-deny | ask\n' +
|
|
46
|
+
' DSH_MCP_LOG silent (default) | info | debug\n\n' +
|
|
47
|
+
'Docs: https://github.com/xmtlzzz/dsh-any-agent\n',
|
|
48
|
+
);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { createLogger } = await import('../src/config.mjs');
|
|
53
|
+
const { Hub } = await import('../src/hub.mjs');
|
|
54
|
+
const { createMcpServer } = await import('../src/mcp.mjs');
|
|
55
|
+
const { listOrphans, pruneDeadMarkers } = await import('../src/locks.mjs');
|
|
21
56
|
|
|
22
57
|
const log = createLogger();
|
|
23
58
|
|
package/bin/setup.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-any-agent setup -- wire this server into the MCP clients you already have.
|
|
4
|
+
*
|
|
5
|
+
* dsh-any-agent setup detect and preview; writes nothing
|
|
6
|
+
* dsh-any-agent setup --yes actually write (backs up first)
|
|
7
|
+
* dsh-any-agent setup --yes --only cursor,zed
|
|
8
|
+
* dsh-any-agent setup --yes --permission read-only
|
|
9
|
+
* dsh-any-agent setup --remove --yes take it back out
|
|
10
|
+
*
|
|
11
|
+
* Constraints, each one learned the hard way:
|
|
12
|
+
* 1. Dry-run by default. Touching someone else's config deserves a preview.
|
|
13
|
+
* 2. Every file is backed up to <file>.bak-dsh-<stamp> before it is written.
|
|
14
|
+
* 3. JSON clients: parse the original, build the new object, parse the result
|
|
15
|
+
* again, and only then write. A half-written config is worse than none.
|
|
16
|
+
* 4. A client that is not installed is skipped, not failed.
|
|
17
|
+
* 5. Claude Code and Codex have their own `mcp` subcommands; prefer them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
21
|
+
import { homedir, platform } from 'node:os';
|
|
22
|
+
import { join, dirname } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
|
|
26
|
+
const NAME = 'dsh-any-agent';
|
|
27
|
+
|
|
28
|
+
/** Same source of truth as the server: package.json, not a hard-coded copy. */
|
|
29
|
+
const VERSION = (() => {
|
|
30
|
+
try {
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
33
|
+
} catch { return '0.0.0'; }
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
const argv = process.argv.slice(2);
|
|
37
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
38
|
+
process.stdout.write(VERSION + '\n');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
42
|
+
process.stdout.write(
|
|
43
|
+
'dsh-any-agent-setup ' + VERSION + '\n\n' +
|
|
44
|
+
'Detect installed MCP clients and register ' + NAME + ' with each.\n\n' +
|
|
45
|
+
'Usage:\n' +
|
|
46
|
+
' dsh-any-agent-setup preview (writes nothing)\n' +
|
|
47
|
+
' dsh-any-agent-setup --yes apply\n' +
|
|
48
|
+
' dsh-any-agent-setup --yes --only a,b only these clients\n' +
|
|
49
|
+
' dsh-any-agent-setup --yes --permission read-only\n' +
|
|
50
|
+
' dsh-any-agent-setup --remove --yes unregister\n\n' +
|
|
51
|
+
'Clients: Claude Code, Codex, Cursor, Windsurf, Zed.\n' +
|
|
52
|
+
'Every write is preceded by a .bak-dsh-<timestamp> backup; JSON clients are\n' +
|
|
53
|
+
'validated before and after writing; re-running is idempotent.\n',
|
|
54
|
+
);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const has = (f) => argv.includes(f);
|
|
59
|
+
const val = (f, d) => {
|
|
60
|
+
const i = argv.indexOf(f);
|
|
61
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const WRITE = has('--yes') || has('-y');
|
|
65
|
+
const REMOVE = has('--remove');
|
|
66
|
+
const ONLY = (val('--only', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
67
|
+
const PERMISSION = val('--permission', 'workspace-write');
|
|
68
|
+
const STAMP = new Date().toISOString().replace(/[:.]/g, '-');
|
|
69
|
+
|
|
70
|
+
const HOME = homedir();
|
|
71
|
+
const IS_WIN = platform() === 'win32';
|
|
72
|
+
const APPDATA = process.env.APPDATA || join(HOME, 'AppData', 'Roaming');
|
|
73
|
+
|
|
74
|
+
const log = (s = '') => console.log(s);
|
|
75
|
+
const tick = (s) => log(' OK ' + s);
|
|
76
|
+
const dash = (s) => log(' --- ' + s);
|
|
77
|
+
const bang = (s) => log(' FAIL ' + s);
|
|
78
|
+
|
|
79
|
+
function which(cmd) {
|
|
80
|
+
const probe = spawnSync(IS_WIN ? 'where' : 'which', [cmd], { encoding: 'utf8', shell: IS_WIN });
|
|
81
|
+
return probe.status === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function desiredEnv() {
|
|
85
|
+
const env = { DSH_MCP_PERMISSION: PERMISSION, DSH_MCP_LOG: 'silent' };
|
|
86
|
+
if (process.env.DSH_BIN) env.DSH_BIN = process.env.DSH_BIN;
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Prefer this package's own entry point; fall back to the PATH command. */
|
|
91
|
+
function launch() {
|
|
92
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
93
|
+
const sibling = join(here, 'dsh-any-agent.mjs');
|
|
94
|
+
if (existsSync(sibling)) return { command: process.execPath, args: [sibling], style: 'node + sibling entry' };
|
|
95
|
+
return { command: NAME, args: [], style: 'PATH command' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function backup(file) {
|
|
99
|
+
const dst = file + '.bak-dsh-' + STAMP;
|
|
100
|
+
copyFileSync(file, dst);
|
|
101
|
+
return dst;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Temp-then-rename so a crash cannot leave a truncated config. */
|
|
105
|
+
function atomicWrite(file, text) {
|
|
106
|
+
const tmp = file + '.tmp-dsh-' + process.pid;
|
|
107
|
+
writeFileSync(tmp, text, 'utf8');
|
|
108
|
+
renameSync(tmp, file);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseJson(file) {
|
|
112
|
+
const raw = readFileSync(file, 'utf8');
|
|
113
|
+
try {
|
|
114
|
+
return { json: JSON.parse(raw), raw };
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { error: e.message };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CLIENTS = [
|
|
121
|
+
{
|
|
122
|
+
id: 'claude',
|
|
123
|
+
label: 'Claude Code',
|
|
124
|
+
detect: () => which('claude') && existsSync(join(HOME, '.claude.json')),
|
|
125
|
+
apply() {
|
|
126
|
+
const run = (a) => spawnSync('claude', a, { encoding: 'utf8', shell: IS_WIN });
|
|
127
|
+
const tried = (r) => (((r.stdout || '') + (r.stderr || '')).toLowerCase());
|
|
128
|
+
|
|
129
|
+
// Dry-run must not shell out: `claude mcp add` writes immediately, so
|
|
130
|
+
// previewing by running it would be a write, not a preview.
|
|
131
|
+
if (!WRITE) {
|
|
132
|
+
const probe = run(['mcp', 'get', NAME]);
|
|
133
|
+
const registered = probe.status === 0;
|
|
134
|
+
if (REMOVE) {
|
|
135
|
+
return registered
|
|
136
|
+
? { preview: 'would be unregistered via \`claude mcp remove\`' }
|
|
137
|
+
: { skipped: 'not registered' };
|
|
138
|
+
}
|
|
139
|
+
return { preview: registered
|
|
140
|
+
? 'would be re-registered via \`claude mcp add\` (replacing the existing entry)'
|
|
141
|
+
: 'would be registered via \`claude mcp add\` (user scope)' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (REMOVE) {
|
|
145
|
+
const r = run(['mcp', 'remove', NAME, '-s', 'user']);
|
|
146
|
+
if (r.status !== 0) {
|
|
147
|
+
return tried(r).includes('no mcp server named')
|
|
148
|
+
? { skipped: 'not registered' }
|
|
149
|
+
: { error: tried(r).trim().split('\n')[0] || 'claude exited ' + r.status };
|
|
150
|
+
}
|
|
151
|
+
return { done: 'removed via \`claude mcp remove\` (user scope)' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `add` refuses to overwrite, so clear any existing entry first. A
|
|
155
|
+
// missing entry is the normal path, not an error.
|
|
156
|
+
const existing = run(['mcp', 'remove', NAME, '-s', 'user']);
|
|
157
|
+
const hadIt = existing.status === 0;
|
|
158
|
+
|
|
159
|
+
const { command, args } = launch();
|
|
160
|
+
const envs = [];
|
|
161
|
+
for (const [k, v] of Object.entries(desiredEnv())) envs.push('-e', k + '=' + v);
|
|
162
|
+
const r = run(['mcp', 'add', NAME, '-s', 'user', ...envs, '--', command, ...args]);
|
|
163
|
+
if (r.status !== 0) {
|
|
164
|
+
return { error: tried(r).trim().split('\n')[0] || 'claude exited ' + r.status };
|
|
165
|
+
}
|
|
166
|
+
return { done: 'registered via \`claude mcp add\` (user scope)' + (hadIt ? ', replacing the old entry' : '') };
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'codex',
|
|
171
|
+
label: 'Codex',
|
|
172
|
+
detect: () => existsSync(join(HOME, '.codex', 'config.toml')),
|
|
173
|
+
apply() {
|
|
174
|
+
const cfg = join(HOME, '.codex', 'config.toml');
|
|
175
|
+
const raw = readFileSync(cfg, 'utf8');
|
|
176
|
+
const kept = [];
|
|
177
|
+
let skip = false;
|
|
178
|
+
const re = new RegExp('^mcp_servers\\.(?:' + NAME + '|"' + NAME + '")(?:\\.|$)');
|
|
179
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
180
|
+
const m = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
181
|
+
if (m) skip = re.test(m[1]);
|
|
182
|
+
if (!skip) kept.push(line);
|
|
183
|
+
}
|
|
184
|
+
let out = kept.join('\n').trimEnd();
|
|
185
|
+
if (!REMOVE) {
|
|
186
|
+
const { command, args } = launch();
|
|
187
|
+
const envLines = Object.entries(desiredEnv())
|
|
188
|
+
.map(([k, v]) => k + ' = ' + JSON.stringify(v)).join('\n');
|
|
189
|
+
out += (out ? '\n\n' : '') +
|
|
190
|
+
'[mcp_servers.' + NAME + ']\ncommand = ' + JSON.stringify(command) +
|
|
191
|
+
'\nargs = ' + JSON.stringify(args) +
|
|
192
|
+
'\nstartup_timeout_sec = 20.0\ntool_timeout_sec = 120.0\n\n' +
|
|
193
|
+
'[mcp_servers.' + NAME + '.env]\n' + envLines;
|
|
194
|
+
}
|
|
195
|
+
out += '\n';
|
|
196
|
+
if (out === raw) return { skipped: 'already up to date' };
|
|
197
|
+
if (!WRITE) return { preview: REMOVE ? 'TOML block would be removed' : 'TOML block would be written' };
|
|
198
|
+
backup(cfg);
|
|
199
|
+
atomicWrite(cfg, out);
|
|
200
|
+
return { done: REMOVE ? 'TOML block removed' : 'TOML block written' };
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{ id: 'cursor', label: 'Cursor', json: () => join(HOME, '.cursor', 'mcp.json'), key: 'mcpServers' },
|
|
204
|
+
{ id: 'windsurf', label: 'Windsurf', json: () => join(HOME, '.codeium', 'windsurf', 'mcp_config.json'), key: 'mcpServers' },
|
|
205
|
+
{ id: 'zed', label: 'Zed', json: () => join(IS_WIN ? APPDATA : join(HOME, '.config'), IS_WIN ? join('Zed','settings.json') : join('zed','settings.json')), key: 'context_servers' },
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/** Shared reader/writer for the JSON-shaped clients. */
|
|
209
|
+
function applyJson(c) {
|
|
210
|
+
const file = c.json();
|
|
211
|
+
const parsed = parseJson(file);
|
|
212
|
+
if (parsed.error) return { error: 'existing file is not valid JSON (' + parsed.error + ')' };
|
|
213
|
+
|
|
214
|
+
const root = (parsed.json && typeof parsed.json === 'object') ? parsed.json : {};
|
|
215
|
+
const bag = (root[c.key] && typeof root[c.key] === 'object') ? root[c.key] : {};
|
|
216
|
+
|
|
217
|
+
if (REMOVE) {
|
|
218
|
+
if (!Object.prototype.hasOwnProperty.call(bag, NAME)) return { skipped: 'not present' };
|
|
219
|
+
delete bag[NAME];
|
|
220
|
+
} else {
|
|
221
|
+
const { command, args } = launch();
|
|
222
|
+
const next = { command, args, env: desiredEnv() };
|
|
223
|
+
if (JSON.stringify(bag[NAME]) === JSON.stringify(next)) return { skipped: 'already up to date' };
|
|
224
|
+
bag[NAME] = next;
|
|
225
|
+
}
|
|
226
|
+
root[c.key] = bag;
|
|
227
|
+
|
|
228
|
+
const text = JSON.stringify(root, null, 2) + '\n';
|
|
229
|
+
if (text === parsed.raw.trimEnd() + '\n') return { skipped: 'no change' };
|
|
230
|
+
JSON.parse(text); // self-check before touching disk
|
|
231
|
+
|
|
232
|
+
// Dry-run must stop here. This file used to write unconditionally and only
|
|
233
|
+
// reported "would write" in the orchestration layer, which meant `setup`
|
|
234
|
+
// without --yes silently modified every client config it found -- the exact
|
|
235
|
+
// opposite of what the docs promise.
|
|
236
|
+
if (!WRITE) return { preview: REMOVE ? 'entry would be removed' : 'entry would be written' };
|
|
237
|
+
|
|
238
|
+
backup(file);
|
|
239
|
+
atomicWrite(file, text);
|
|
240
|
+
const back = parseJson(file); // self-check after writing
|
|
241
|
+
if (back.error) return { error: 'wrote a file that no longer parses (' + back.error + '); restore the .bak' };
|
|
242
|
+
return { done: REMOVE ? 'entry removed' : 'entry written' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- main -----------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const lc = launch();
|
|
248
|
+
log('');
|
|
249
|
+
log(WRITE
|
|
250
|
+
? (REMOVE ? 'Removing ' + NAME + ' from MCP clients' : 'Installing ' + NAME + ' into MCP clients')
|
|
251
|
+
: 'Preview only -- nothing is written. Re-run with --yes to apply.');
|
|
252
|
+
log(' permission : ' + PERMISSION);
|
|
253
|
+
log(' launch : ' + lc.command + (lc.args.length ? ' ' + lc.args.join(' ') : '') + ' (' + lc.style + ')');
|
|
254
|
+
log(' DSH_BIN : ' + (process.env.DSH_BIN || '(unset -- auto-detect at runtime)'));
|
|
255
|
+
log('');
|
|
256
|
+
|
|
257
|
+
let changed = 0, skipped = 0, failed = 0;
|
|
258
|
+
|
|
259
|
+
for (const c of CLIENTS) {
|
|
260
|
+
if (ONLY.length && !ONLY.includes(c.id)) continue;
|
|
261
|
+
|
|
262
|
+
let present = false;
|
|
263
|
+
try { present = c.detect ? c.detect() : existsSync(c.json()); } catch {}
|
|
264
|
+
log(c.label + (present ? '' : ' (not installed)'));
|
|
265
|
+
|
|
266
|
+
if (!present) { dash('skipped: client not found'); skipped++; log(''); continue; }
|
|
267
|
+
|
|
268
|
+
let r;
|
|
269
|
+
try { r = c.apply ? c.apply() : applyJson(c); }
|
|
270
|
+
catch (e) { r = { error: e.message }; }
|
|
271
|
+
|
|
272
|
+
if (r.error) { bang(r.error); failed++; }
|
|
273
|
+
else if (r.skipped) { dash('skipped: ' + r.skipped); skipped++; }
|
|
274
|
+
else if (r.preview) { tick(r.preview); changed++; }
|
|
275
|
+
else { tick(r.done); changed++; }
|
|
276
|
+
log('');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
log('Summary: ' + changed + ' ' + (WRITE ? 'changed' : 'would change') + ', ' + skipped + ' skipped, ' + failed + ' failed');
|
|
280
|
+
if (!WRITE && changed) log('Nothing was written. Re-run with --yes to apply.');
|
|
281
|
+
if (failed) {
|
|
282
|
+
log('');
|
|
283
|
+
log('A failure above left no damage: every writer validates the result and keeps');
|
|
284
|
+
log('a .bak-dsh-* copy beside the file it touched.');
|
|
285
|
+
}
|
|
286
|
+
process.exit(failed ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Codex, Cursor, or your own. Resume-aware, interruptible, approval-capable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"orchestration"
|
|
24
24
|
],
|
|
25
25
|
"bin": {
|
|
26
|
-
"dsh-any-agent": "
|
|
26
|
+
"dsh-any-agent": "bin/dsh-any-agent.mjs",
|
|
27
|
+
"dsh-any-agent-setup": "bin/setup.mjs"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
29
30
|
"bin/",
|
|
@@ -39,13 +40,14 @@
|
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
42
|
"start": "node ./bin/dsh-any-agent.mjs",
|
|
43
|
+
"setup": "node ./bin/setup.mjs",
|
|
42
44
|
"test": "node ./test/run.mjs",
|
|
43
45
|
"test:smoke": "node ./test/smoke.mjs",
|
|
44
46
|
"test:guards": "node ./test/guards.mjs",
|
|
45
47
|
"test:prune": "node ./test/prune.mjs"
|
|
46
48
|
},
|
|
47
49
|
"engines": {
|
|
48
|
-
"node": ">=
|
|
50
|
+
"node": ">=22.15"
|
|
49
51
|
},
|
|
50
52
|
"repository": {
|
|
51
53
|
"type": "git",
|
package/src/config.mjs
CHANGED
|
@@ -210,13 +210,13 @@ export const REAP_INTERVAL_MS = Number.isFinite(rawReap) && rawReap >= 200 ? raw
|
|
|
210
210
|
* 最多同时保活多少个会话进程(`0` = 不限)。
|
|
211
211
|
*
|
|
212
212
|
* 为什么需要它:`IDLE_TTL_MS=0`(永不回收)能保证"GUI 抢不走写锁",但代价是
|
|
213
|
-
* **每个用过的会话永久占一个 DSH 子进程** —— 实测约
|
|
213
|
+
* **每个用过的会话永久占一个 DSH 子进程** —— 实测约 300-480MB/个(近似线性),10 个可达 3-5GB ✗。
|
|
214
214
|
* 超过上限时**回收最久没用过的那个**(busy 的绝不回收):
|
|
215
215
|
* - 保活窗口内的会话:写锁在手,GUI 抢不走 ✓
|
|
216
216
|
* - 窗口外的:进程回收、内存释放 ✓(下次派活自动 resume,无损 ✓)
|
|
217
217
|
* 即"**内存有界,锁尽量在手**"。
|
|
218
218
|
*
|
|
219
|
-
* **默认 8
|
|
219
|
+
* **默认 8**(按实测 300-480MB/个,最坏约 2.5-4GB):让"内存有界"成为默认行为,而不是要用户先发现再配 ✗。
|
|
220
220
|
* 被回收是无损的(会话日志在磁盘上 ✓),代价只是那个会话的写锁会空出来、
|
|
221
221
|
* 且下次派活要 cold start。想做更多并行就调大;机器大也可以设 0(不限)。
|
|
222
222
|
*/
|
package/src/hub.mjs
CHANGED
|
@@ -1275,7 +1275,7 @@ export class Hub {
|
|
|
1275
1275
|
* ★ 但**心跳必须一直续**,两种模式都一样:心跳停了,别的实例 90 秒后就会把我们
|
|
1276
1276
|
* 判成"卡死",`dsh_takeover` 于是会**杀掉我们正在跑的回合**。
|
|
1277
1277
|
*
|
|
1278
|
-
* ★ `DSH_MCP_MAX_LIVE`(保活上限):`IDLE_TTL_MS=0` 时进程会永远留着(实测约
|
|
1278
|
+
* ★ `DSH_MCP_MAX_LIVE`(保活上限):`IDLE_TTL_MS=0` 时进程会永远留着(实测约 300-480MB/个 ✗),
|
|
1279
1279
|
* 设了上限就在超标时回收**最久没用过**的那个(busy 的绝不回收)—— "内存有界,锁尽量在手"。
|
|
1280
1280
|
*/
|
|
1281
1281
|
startReaper() {
|
|
@@ -1338,7 +1338,7 @@ export class Hub {
|
|
|
1338
1338
|
/**
|
|
1339
1339
|
* 释放**所有**活着的会话进程(腾内存用,无损:会话日志都在磁盘上,之后照样 resume)。
|
|
1340
1340
|
*
|
|
1341
|
-
* 场景:默认"永不回收"下每个用过的会话会常驻一个 DSH 进程(实测约
|
|
1341
|
+
* 场景:默认"永不回收"下每个用过的会话会常驻一个 DSH 进程(实测约 300-480MB ✗)。
|
|
1342
1342
|
* 一批活干完了就调它,内存立刻归零 ✓;下次派活按需 resume ✓。
|
|
1343
1343
|
*/
|
|
1344
1344
|
async releaseAll() {
|
package/src/mcp.mjs
CHANGED
|
@@ -5,11 +5,27 @@
|
|
|
5
5
|
* 因为混进 stdout 的日志会直接破坏报文。日志默认静默(见 config.createLogger)。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
8
11
|
import { createHandlers, TOOL_DEFS } from './tools.mjs';
|
|
9
12
|
import { DEFAULT_PERMISSION } from './config.mjs';
|
|
10
13
|
|
|
11
14
|
const SERVER_NAME = 'dsh-any-agent';
|
|
12
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Version comes from package.json so it cannot drift from the published
|
|
17
|
+
* artifact. The old hard-coded copy is exactly the kind of duplicated fact
|
|
18
|
+
* that goes stale the moment someone runs `npm version`.
|
|
19
|
+
*/
|
|
20
|
+
export const SERVER_VERSION = (() => {
|
|
21
|
+
try {
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
|
|
24
|
+
return pkg.version ?? '0.0.0';
|
|
25
|
+
} catch {
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
13
29
|
const FALLBACK_PROTOCOL = '2025-06-18';
|
|
14
30
|
|
|
15
31
|
const INSTRUCTIONS = `这个服务把 DeepSeek Harness (DSH) 当作一个**可长期共事的编码 agent** 来驱动。
|
package/src/tools.mjs
CHANGED
|
@@ -211,7 +211,7 @@ export const TOOL_DEFS = [
|
|
|
211
211
|
description:
|
|
212
212
|
'释放会话占用的 DSH 进程(释放写锁,让用户能在自己的 DSH GUI 里打开同一个会话)。' +
|
|
213
213
|
'会话本体留在磁盘上,之后仍可 resume;forget=true 才会从注册表里摘掉。' +
|
|
214
|
-
'★ 用完了想一次性腾内存就传 all=true(默认"永不回收"下每个会话约
|
|
214
|
+
'★ 用完了想一次性腾内存就传 all=true(默认"永不回收"下每个会话约 300-480MB,全放掉即立刻归零,之后随时 resume)。',
|
|
215
215
|
inputSchema: S(
|
|
216
216
|
{
|
|
217
217
|
conversation_id: CONV_ID,
|