dsh-any-agent 0.3.8 → 0.3.9
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 +62 -2
- package/DESIGN.md +6 -6
- package/README.md +29 -21
- package/README.zh.md +21 -20
- package/USAGE-MIGRATION.en.md +7 -5
- package/USAGE-MIGRATION.md +3 -3
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +9 -9
- package/bin/restore.mjs +6 -2
- package/bin/setup.mjs +6 -0
- package/package.json +1 -1
- package/src/acp.mjs +8 -10
- package/src/clients.mjs +13 -2
- package/src/hub.mjs +99 -33
- package/src/line-decoder.mjs +41 -0
- package/src/locks.mjs +6 -1
- package/src/mcp.mjs +3 -6
- package/src/tools.mjs +5 -2
- package/src/workspace.mjs +18 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,66 @@ 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.
|
|
10
|
+
Post-0.3.9 hardening.
|
|
11
|
+
|
|
12
|
+
## [0.3.9] — 2026-11-02
|
|
13
|
+
|
|
14
|
+
### Security / robustness
|
|
15
|
+
|
|
16
|
+
- **UTF-8 stream framing is lossless.** MCP and ACP now use a shared
|
|
17
|
+
`LineDecoder` backed by `StringDecoder`, so a multi-byte Chinese/emoji
|
|
18
|
+
character split across stream chunks is no longer silently replaced by
|
|
19
|
+
U+FFFD. Covers both input prompts and DSH output.
|
|
20
|
+
- **Concurrent adoption of the same disk session is deduplicated.** `Hub.get`
|
|
21
|
+
now shares one in-flight `findSessionHeader`/adoption promise per id and
|
|
22
|
+
rechecks the registry, preventing two Conversation objects (and the
|
|
23
|
+
unreachable child/write-lock leak that followed).
|
|
24
|
+
- **`read-only` runtime verification fails closed.** It waits up to ~10s
|
|
25
|
+
(covering DSH's projection-cache flush window); if no recorded permission is
|
|
26
|
+
available, the call is refused instead of relying on an approval layer that
|
|
27
|
+
a danger sandbox may never invoke.
|
|
28
|
+
- **`spawned_at` is reset when the child PID changes.** PID-reuse protection no
|
|
29
|
+
longer rejects takeover of a legitimate restarted child.
|
|
30
|
+
- **`forget` tombstones survive unreadable session headers.** Header read is
|
|
31
|
+
three-state (found / missing / error); only a genuinely missing session file
|
|
32
|
+
prunes a tombstone.
|
|
33
|
+
- **`DSH_MCP_SENTINEL_INCLUDE_REASONING=1` no longer disables redaction.**
|
|
34
|
+
Thinking is kept, but answer/error/title/tool inputs are still sanitized.
|
|
35
|
+
- **`close`/`releaseAll`/`shutdown` keep lock registrations until the child is
|
|
36
|
+
confirmed dead.** If stop times out, the registration is retained so
|
|
37
|
+
takeover can still attribute the orphan; `forget` is refused while the child
|
|
38
|
+
is alive.
|
|
39
|
+
- setup preserves the target file's mode (e.g. 0600) and rejects an invalid
|
|
40
|
+
`--permission` with exit code 2.
|
|
41
|
+
- `restore --latest` excludes its own `pre-restore-*` safety copies, so running
|
|
42
|
+
it twice no longer undoes the first restore.
|
|
43
|
+
|
|
44
|
+
### Behavior
|
|
45
|
+
|
|
46
|
+
- `workspace_changed` compares git-status entry snapshots instead of object
|
|
47
|
+
identity; output now says "workspace state changed" rather than claiming a
|
|
48
|
+
per-turn file count.
|
|
49
|
+
- `dsh_takeover` prefers a live `known_child_pid` when the marker points at a
|
|
50
|
+
dead child.
|
|
51
|
+
|
|
52
|
+
### Website
|
|
53
|
+
|
|
54
|
+
- Mobile navigation keeps the language switch visible; the footer also carries
|
|
55
|
+
a language link.
|
|
56
|
+
- `hero.jpg` and `favicon.svg` are content-hashed; all `/assets/*` can stay
|
|
57
|
+
immutable.
|
|
58
|
+
- Light-theme muted/faint/accent colors were darkened to meet WCAG AA on the
|
|
59
|
+
surfaces they are used on.
|
|
60
|
+
|
|
61
|
+
### Docs / tests
|
|
62
|
+
|
|
63
|
+
- README/Chinese README/USAGE-MIGRATION: workspace-write approval semantics,
|
|
64
|
+
setup's `workspace-write` default, Zed `context_servers`, English npm publish
|
|
65
|
+
step, current route/limit links.
|
|
66
|
+
- New `line-decoder` suite (4), expanded sentinel redaction (19), tombstone
|
|
67
|
+
pruning (10), permission adoption (17), state/lock safety (26), sandbox guard
|
|
68
|
+
(21), setup (45), config-safety (407). Offline total 843 / 879 with nine
|
|
69
|
+
client fixtures; website 29.
|
|
11
70
|
|
|
12
71
|
## [0.3.8] — 2026-11-02
|
|
13
72
|
|
|
@@ -696,7 +755,8 @@ First public release, published to npm as [`dsh-any-agent`](https://www.npmjs.co
|
|
|
696
755
|
profiles, sessions and client configs. The product name changed; the on-disk
|
|
697
756
|
contract did not. See the README FAQ.
|
|
698
757
|
|
|
699
|
-
[Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.
|
|
758
|
+
[Unreleased]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.9...HEAD
|
|
759
|
+
[0.3.9]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.8...v0.3.9
|
|
700
760
|
[0.3.8]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.7...v0.3.8
|
|
701
761
|
[0.3.7]: https://github.com/xmtlzzz/dsh-any-agent/compare/v0.3.6...v0.3.7
|
|
702
762
|
[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.
|
|
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.
|
|
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
|
|
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 默认值?(太长会占锁让用户打不开,太短会反复重启丢上下文) |
|
|
454
|
+
| Q2 | 空闲回收 TTL 默认值?(太长会占锁让用户打不开,太短会反复重启丢上下文) | ✅ **已定:默认 `0` = 永不回收**(`dsh_release` 可显式交还;显式设非零值才回收) |
|
|
455
455
|
| Q3 | **中断怎么做?** SDK 协议没有 cancel,且杀进程 = 永久失去该会话(约束 6) | 强烈倾向 ②:写个极小 DSH 插件补 `session/interrupt`。① 已排除——headless 无法接管 web 建的会话 |
|
|
456
|
-
| Q4 | 默认权限档给哪一档? | ✅
|
|
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) |
|
|
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
|
-
🧪
|
|
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
|
|
|
@@ -214,7 +214,7 @@ What changed since 0.1.x: JSON configs used to be **re-serialised whole** (value
|
|
|
214
214
|
|
|
215
215
|
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
216
|
|
|
217
|
-
The coverage for each of those claims is in [`test/`](test/) (`test/config-safety.mjs` asserts them one by one,
|
|
217
|
+
The coverage for each of those claims is in [`test/`](test/) (`test/config-safety.mjs` asserts them one by one, 407 checks).
|
|
218
218
|
|
|
219
219
|
**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
220
|
|
|
@@ -359,7 +359,7 @@ tool_timeout_sec = 120.0
|
|
|
359
359
|
|
|
360
360
|
For a local checkout, use `"command": "node"` and `"args": ["/absolute/path/to/server/bin/dsh-any-agent.mjs"]`.
|
|
361
361
|
|
|
362
|
-
> Cursor, Windsurf,
|
|
362
|
+
> 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
363
|
|
|
364
364
|
</details>
|
|
365
365
|
|
|
@@ -432,7 +432,7 @@ Design notes (especially under **concurrent multi-session** use):
|
|
|
432
432
|
- **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
433
|
- **Atomic write**: `tmp` + `rename`; if the file exists its contents are complete. Each run uses its own tmp, so concurrent writes never clobber.
|
|
434
434
|
- **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](
|
|
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).
|
|
436
436
|
|
|
437
437
|
> 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
438
|
|
|
@@ -461,13 +461,13 @@ DSH separates reasoning from answer text at the model layer, and ACP exposes the
|
|
|
461
461
|
|
|
462
462
|
| Tier | Approval default | Use for |
|
|
463
463
|
|---|---|---|
|
|
464
|
-
| `danger-full-access` (default) | `auto-allow` | Zero friction |
|
|
465
|
-
| `workspace-write` | `auto-allow` |
|
|
464
|
+
| `danger-full-access` (raw server default) | `auto-allow` | Zero friction; `setup` writes `workspace-write` by default |
|
|
465
|
+
| `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
466
|
| `read-only` | `auto-deny` | Analyzing untrusted samples (malware, unknown dumps) |
|
|
467
467
|
|
|
468
468
|
`on_approval` can be `auto-allow`, `auto-deny`, or `ask` (which exposes a pending approval for `dsh_approval_decide` to adjudicate).
|
|
469
469
|
|
|
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.
|
|
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. 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
471
|
|
|
472
472
|
## 🪟 Workspace visibility in the DSH GUI
|
|
473
473
|
|
|
@@ -530,12 +530,12 @@ Silent by default — **not one byte is written**.
|
|
|
530
530
|
| `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | Tombstones written by `dsh_release(forget=true)` |
|
|
531
531
|
| `DSH_MCP_RUNS_DIR` | `<state file dir>/runs` | Root for async completion sentinels (`<this>/<conversation_id>/<run_id>.json`) |
|
|
532
532
|
| `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`) |
|
|
533
|
+
| `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 |
|
|
534
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
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`) |
|
|
536
536
|
| `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
537
|
| `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**) |
|
|
538
|
+
| `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
539
|
| `DSH_MCP_PERMISSION` | `danger-full-access` | Default tier for `dsh_start` |
|
|
540
540
|
| `DSH_MCP_REASONING_EFFORT` | `max` | Default reasoning effort |
|
|
541
541
|
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
|
|
@@ -570,18 +570,19 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
570
570
|
| `sentinel` | 36 | completion sentinel: atomicity, latch semantics, per-conversation namespacing under concurrency, cancelled runs still land it, and **reasoning never reaching the file** |
|
|
571
571
|
| `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
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` |
|
|
573
|
+
| `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
574
|
| `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 |
|
|
575
|
+
| `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
576
|
| `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` |
|
|
577
|
-
| `forget-tombstone` |
|
|
577
|
+
| `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) |
|
|
578
|
+
| `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
579
|
| `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
580
|
| `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` |
|
|
581
|
-
| `state-safety` |
|
|
582
|
-
| `setup` |
|
|
581
|
+
| `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) |
|
|
582
|
+
| `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) |
|
|
583
|
+
| `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
584
|
| `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` |
|
|
585
|
+
| `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
586
|
| `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
587
|
| `commit-script` | 26 | commit messages go through stdin, never a temp file that `git add -A` could capture (no tokens) |
|
|
587
588
|
| `roots` | 12 | `dsh_start` without `cwd` asks the client for MCP roots; a client without roots gets an actionable error (no tokens) |
|
|
@@ -599,13 +600,13 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
599
600
|
| `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
601
|
| `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
602
|
|
|
602
|
-
**`node test/run.mjs` runs
|
|
603
|
+
**`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
604
|
|
|
604
|
-
That is **
|
|
605
|
+
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
606
|
|
|
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.
|
|
607
|
+
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
608
|
|
|
608
|
-
> Three different totals are intentional: `node test/run.mjs` (
|
|
609
|
+
> 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
610
|
|
|
610
611
|
### 🧯 Why `sandbox-guard` exists
|
|
611
612
|
|
|
@@ -669,7 +670,7 @@ DSH conversations carry a **cross-process write lock**: **it never expires while
|
|
|
669
670
|
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
671
|
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
672
|
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
|
|
673
|
+
8. **"Never reap" costs memory (measured 300–480 MB per conversation)** — within the `MAX_LIVE` window that is what buys "the GUI cannot 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).
|
|
673
674
|
|
|
674
675
|
## ❓ Why is the profile still called `dsh-mcp`?
|
|
675
676
|
|
|
@@ -705,6 +706,13 @@ npm pack --dry-run
|
|
|
705
706
|
# 4. Tag and push (wait for CI before publishing).
|
|
706
707
|
git add -A && git commit -m "chore: release x.y.z"
|
|
707
708
|
git tag vx.y.z && git push origin main --tags
|
|
709
|
+
|
|
710
|
+
# 5. Publish to npm and verify.
|
|
711
|
+
npm publish --access public
|
|
712
|
+
npm view dsh-any-agent version
|
|
713
|
+
# A 404 right after publish is usually propagation, not failure: npm returns
|
|
714
|
+
# 202 and the registry can lag briefly. Confirm the local npm log shows the
|
|
715
|
+
# version before retrying.
|
|
708
716
|
```
|
|
709
717
|
|
|
710
718
|
## Star History
|
package/README.zh.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪 0.3.
|
|
18
|
+
🧪 0.3.9 本机 843 项检查全绿(九种客户端配置齐全时 879 项;其中 524 项属于客户端注册器套件,齐全时 560 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
|
|
19
19
|
|
|
20
20
|
一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
|
|
21
21
|
|
|
@@ -227,7 +227,7 @@ Zed 33 -> 33 个键 只有 context_servers 多了个子项
|
|
|
227
227
|
|
|
228
228
|
Codex 的 `config.toml` 同理,是按行处理的:其它 `[表]` 及其注释、顶层键(`model`、`approval_policy`、`sandbox_mode` 等)、CRLF 行尾都原样保留 —— `--remove` 只删 `[mcp_servers.dsh-any-agent]` 那两段,其余一律不动。
|
|
229
229
|
|
|
230
|
-
这些断言的详细覆盖见 [`test/`](test/)(`test/config-safety.mjs` 共
|
|
230
|
+
这些断言的详细覆盖见 [`test/`](test/)(`test/config-safety.mjs` 共 407 项,逐条断言上面每一条)。
|
|
231
231
|
|
|
232
232
|
**在真实 home 上做过只读验证。** 用 `setup --json`(纯计划模式)跑一遍开发机上的真实配置:9 个客户端全部报出,而现存的每份配置都**原封未动** —— size、mtime、SHA-256 全同,没有新增任何 `.bak-dsh-*`,也没有新建任何文件:
|
|
233
233
|
|
|
@@ -376,7 +376,7 @@ tool_timeout_sec = 120.0
|
|
|
376
376
|
|
|
377
377
|
本地目录安装时改用 `"command": "node"` 和 `"args": ["/绝对路径/server/bin/dsh-any-agent.mjs"]`。
|
|
378
378
|
|
|
379
|
-
> Cursor、Windsurf、
|
|
379
|
+
> Cursor、Windsurf、Cline、Continue 等大部分客户端都吃同一套 `mcpServers` 结构——只要能拉起 stdio MCP 服务,就能用。Zed 是例外:它用 `context_servers`(见上方示例)。
|
|
380
380
|
|
|
381
381
|
</details>
|
|
382
382
|
|
|
@@ -476,13 +476,13 @@ DSH 在模型层就把思考与正文分开,ACP 又把它们做成**两条独
|
|
|
476
476
|
|
|
477
477
|
| 档位 | 审批默认 | 用途 |
|
|
478
478
|
|---|---|---|
|
|
479
|
-
| `danger-full-access
|
|
480
|
-
| `workspace-write` | `auto-allow` |
|
|
479
|
+
| `danger-full-access`(服务端裸默认) | `auto-allow` | 零摩擦;`setup` 默认写入 `workspace-write` |
|
|
480
|
+
| `workspace-write` | `auto-allow` | DSH 沙箱限制可写范围;越界请求默认放行,传 `on_approval=ask` 才挂起审批 |
|
|
481
481
|
| `read-only` | `auto-deny` | 分析不信任样本(恶意软件、来路不明 dump) |
|
|
482
482
|
|
|
483
483
|
`on_approval` 可选 `auto-allow` / `auto-deny` / `ask`(`ask` 会把审批挂起,交给 `dsh_approval_decide` 裁决)。
|
|
484
484
|
|
|
485
|
-
**`read-only` 是强制边界,不只是默认值**:它固定使用 `auto-deny`;给只读会话传 `auto-allow` / `ask` 会在 `dsh_start`
|
|
485
|
+
**`read-only` 是强制边界,不只是默认值**:它固定使用 `auto-deny`;给只读会话传 `auto-allow` / `ask` 会在 `dsh_start` 被直接拒绝,审批层也会拒绝来自只读会话的任何权限请求。建会话/resume 后还会回读会话自己记录的权限档;只读档如果在 DSH 投影缓存刷盘窗口内仍读不到记录,会 **fail-closed 拒绝继续**,而不是只依赖审批层兜底。否则 profile 的 `defaultPreset` 加宽松审批策略可能放行写操作(对抗审计实测过)。
|
|
486
486
|
|
|
487
487
|
## 🪟 会话在 DSH GUI 里的可见性
|
|
488
488
|
|
|
@@ -545,12 +545,12 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
|
|
|
545
545
|
| `DSH_MCP_APPROVAL` | `auto-allow`(非只读档) | 默认审批策略。设成 `ask` 才能让越界请求挂起给 `dsh_approval_decide`,而不是自动放行 |
|
|
546
546
|
| `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | 待决 `ask` 审批的等待上限;超时按拒绝处理 |
|
|
547
547
|
| `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | `dsh_release(forget=true)` 写的墓碑目录 |
|
|
548
|
-
| `DSH_MCP_LOCK_STALE_MS` | `90000` | 持锁登记多久没续心跳就算"持有者失联"(此时可被 `dsh_takeover`
|
|
548
|
+
| `DSH_MCP_LOCK_STALE_MS` | `90000`(3× `REAP_INTERVAL_MS`,下限 30 秒) | 持锁登记多久没续心跳就算"持有者失联"(此时可被 `dsh_takeover` 自动抢占);改回收间隔会改变推导出的默认值 |
|
|
549
549
|
| `DSH_MCP_IDLE_TTL_MS` | `0`(永不回收) | 会话空闲多久回收其 DSH 进程。★★ **`0` = 永不回收(默认)**:本服务一直握着写锁,**GUI 抢不走**(你在 GUI 里点开是**只读**,照样能看 ✓)。代价:每会话常驻一个 DSH 进程(**两次冷启动实测 476MB / 295MB**)。设成毫秒数(如 `300000`)可省内存,但要接受"锁可能被 GUI 抢走" |
|
|
550
550
|
| `DSH_MCP_MAX_LIVE` | `8`(最坏约 2.5–4GB,见第 8 条) | ★ **最多同时保活几个会话进程**(`0` = 不限)。超标时回收**最久没用过**的(**在跑的回合绝不回收**)—— "**内存有界,锁尽量在手**"。内存紧用 `4`,机器大可以 `0`。用完想立刻放掉空闲会话:`dsh_release(all=true)`(busy 回合默认跳过,`force=true` 才中断) |
|
|
551
551
|
| `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | `dsh_list` 磁盘探测结果的缓存时长(探测要 spawn 一个 DSH 进程,实测约 1 秒) |
|
|
552
552
|
| `DSH_MCP_SENTINEL_TTL_MS` | `604800000`(7 天) | 启动时清理超过此时长的哨兵;`0` 关闭清理 |
|
|
553
|
-
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"
|
|
553
|
+
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"的保证**);answer/error/title/工具输入仍会脱敏 |
|
|
554
554
|
| `DSH_MCP_PERMISSION` | `danger-full-access` | `dsh_start` 未指定时的权限档 |
|
|
555
555
|
| `DSH_MCP_REASONING_EFFORT` | `max` | 默认思考深度 |
|
|
556
556
|
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0`(不设超时) | **仅 `wait=true` 时**的等待上限;到期只转后台,不取消回合 |
|
|
@@ -584,18 +584,19 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
|
|
|
584
584
|
| `sentinel` | 36 | 完成哨兵:原子性、闩锁语义、多会话并发不串台、被取消也落地、**思考不进文件** |
|
|
585
585
|
| `prune` | 11 | **哨兵留存**:清超期与崩溃残留、留新、清空壳目录、`TTL=0` 关闭清理、且绝不碰同目录的会话注册表(不花 token,每次必跑) |
|
|
586
586
|
| `guards` | 31 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1`/非法值都当"永不回收"(绝不主动让出写锁)、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合)(不花 token,每次必跑) |
|
|
587
|
-
| `sandbox-guard` |
|
|
587
|
+
| `sandbox-guard` | 21 | **测试沙箱自身**:「临时 HOME」如果落到真实用户目录、它的子目录、盘符根目录,或 OS 临时目录之外,一律报错拒绝,绝不被写入和删除。其余套件都通过 `test/sandbox-home.mjs` 拿目录、只销毁自己建的那个(不花 token,每次必跑) |
|
|
588
588
|
| `approval-details` | 27 | **审批审查上下文**:待决审批的快照带命令、理由、cwd 与原始审批 ID —— 不只是一个裸 ID,调用方才能真正裁决;缺失的 `rawInput` 会按 ID 从先前的 `tool_call` 更新关联回来;凭据形状的字段与常见内联字面量在结构化上下文和展示标题里都会脱敏;关闭会话会取消待决审批。经真实 stdio MCP 端到端验证 |
|
|
589
589
|
| `read-cursor` | 10 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
|
|
590
|
-
| `
|
|
591
|
-
| `
|
|
590
|
+
| `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
|
|
591
|
+
| `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
|
|
592
|
+
| `forget-tombstone` | 10 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
|
|
592
593
|
| `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
|
|
593
594
|
| `workspace-safety` | 23 | **数据丢失边界**:`isTempPath` 按路径分段(`tmpdir()+'Suffix'`、`/tmpfoo` 都不算临时目录)、坏 `workspace.json` 绝不覆盖、会话根不可读时绝不清空 `sessionIds`、自动清理只删测试造的会话(不花 token) |
|
|
594
|
-
| `permission-adopt` |
|
|
595
|
-
| `state-safety` |
|
|
596
|
-
| `setup` |
|
|
595
|
+
| `permission-adopt` | 17 | **不无声提权**:接管没有权限记录的外部会话时 fail-closed 到 `read-only`;记录为 `read-only` 的会话不能被侧车提升;运行期回读记录与请求不一致会失败(不花 token) |
|
|
596
|
+
| `state-safety` | 26 | **状态/哨兵/锁目录**:默认在 `$DSH_HOME/.dsh-any-agent`、不可写时启动明确失败、哨兵写失败返回 `null` + `sentinel_error`、非法数值落安全侧(不花 token) |
|
|
597
|
+
| `setup` | 45 | **注册 CLI**:预览不写盘、`--remove` 只摘自己那条、没装客户端算跳过不算失败、Codex TOML 段往返、以及把写进配置里的命令真的拉起一次并要求应答 `initialize`(不花 token)。**不会调用 `claude mcp get`** —— 那个 CLI 会整体回写 `~/.claude.json` |
|
|
597
598
|
| `removal-scope` | 24 | **注销只动自己那条**:给一份装着别的 MCP 服务器、顶层 Codex 设置和 Zed 兄弟设置的配置,注册再注销,断言其余每个值逐字节不变;删一个从没写过的东西 = 无写入、无报错(不花 token) |
|
|
598
|
-
| `config-safety` |
|
|
599
|
+
| `config-safety` | 407 | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
|
|
599
600
|
| `real-configs` | 84(7/9 配置时 66,本机 5/9 时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
|
|
600
601
|
| `commit-script` | 26 | 提交信息走 stdin,绝不落临时文件让 `git add -A` 卷进发布提交(不花 token) |
|
|
601
602
|
| `roots` | 12 | 不传 `cwd` 时通过 MCP roots 取客户端当前项目;不支持 roots 的客户端得到可操作的报错(不花 token) |
|
|
@@ -613,13 +614,13 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
|
|
|
613
614
|
| `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
|
|
614
615
|
| `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
|
|
615
616
|
|
|
616
|
-
**`node test/run.mjs` 在本机 5/9 客户端配置时跑
|
|
617
|
+
**`node test/run.mjs` 在本机 5/9 客户端配置时跑 843 项(九种齐全时 879 项)** —— smoke 29 + 离线套件 814 项(九种齐全时 850 项)。0.3.9 上全部退出码 0;都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
|
|
617
618
|
|
|
618
|
-
其中 **
|
|
619
|
+
其中 **524 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 48;九种齐全时 560 项)—— 0.3 的活其实都在这里。
|
|
619
620
|
|
|
620
|
-
需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.
|
|
621
|
+
需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.9** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
|
|
621
622
|
|
|
622
|
-
> 三个总数是刻意分开的:`node test/run.mjs`(
|
|
623
|
+
> 三个总数是刻意分开的:`node test/run.mjs`(843/879)、CI 离线 job(名义 814/850,不含 smoke;hosted runner 没有客户端 fixture 时 real-configs 的 48 项会跳过)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
|
|
623
624
|
|
|
624
625
|
### 🧯 为什么会有 `sandbox-guard`
|
|
625
626
|
|
|
@@ -684,7 +685,7 @@ DSH 的会话有**跨进程写锁**:**持有者活着就永不过期,也没
|
|
|
684
685
|
5. **首次成功回合之前就死掉的会话可能尚未落盘** —— 此时 resume 会报清晰错误。发出第一条消息后就安全了。
|
|
685
686
|
6. **不能改名** —— DSH 的标题子系统没有对外改名接口(`SessionTitleService.rename` 需要进程内的活会话)。标题一律由首条消息自动生成。
|
|
686
687
|
7. **`session/list` 只返回 `{sessionId, cwd}`,且排除已打开的会话** —— 对话名由本服务从 DSH 投影缓存补全。
|
|
687
|
-
8. **默认"永不回收"要花内存(实测 300–480MB/会话)** ——
|
|
688
|
+
8. **默认"永不回收"要花内存(实测 300–480MB/会话)** —— 在 `MAX_LIVE` 窗口内,换来的是"GUI 抢不走写锁"。两次冷启动实测单个活跃会话新增常驻内存分别为 476MB 和 295MB(含 DSH 子进程与本服务),冷进程首次偏高。两种省内存办法:① **`DSH_MCP_MAX_LIVE`(默认 8**,超标就回收最久没用过的 → 内存有界 ✓ 锁仍尽量在手 ✓);② 把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价是锁可能被 GUI 拿走)。一批活干完想立刻归零:**`dsh_release(all=true)`** ✓(默认只释放空闲会话,**正在跑的回合会跳过、继续跑**;要连它们一起停掉传 `force=true`。之后照样 resume)。
|
|
688
689
|
|
|
689
690
|
## ❓ 为什么 profile 还叫 `dsh-mcp`?
|
|
690
691
|
|
package/USAGE-MIGRATION.en.md
CHANGED
|
@@ -113,8 +113,10 @@ when creating a session if you want `dsh_approval_decide` to adjudicate them.
|
|
|
113
113
|
|
|
114
114
|
`read-only` is a hard boundary at this layer: `read-only + auto-allow/ask` is
|
|
115
115
|
rejected, and the server reads back the session's recorded permission tier
|
|
116
|
-
after create/resume.
|
|
117
|
-
|
|
116
|
+
after create/resume. If the projection cache has no record within the DSH
|
|
117
|
+
flush window, read-only **fails closed** instead of relying on approvals.
|
|
118
|
+
External MCP tools mounted in DSH are outside DSH's sandbox; read
|
|
119
|
+
`USAGE-NOTES.en.md` before using them with untrusted data.
|
|
118
120
|
|
|
119
121
|
## 6. Verify
|
|
120
122
|
|
|
@@ -155,6 +157,6 @@ not rewritten by an upgrade; run `dsh-any-agent-setup` again only if you want
|
|
|
155
157
|
to refresh the entry or permission env. `dsh-any-agent-restore` can restore a
|
|
156
158
|
client config from a `.bak-dsh-*` backup.
|
|
157
159
|
|
|
158
|
-
For the full Chinese guide, environment-variable reference
|
|
159
|
-
measurements
|
|
160
|
-
|
|
160
|
+
For the full Chinese guide, environment-variable reference and write-lock
|
|
161
|
+
measurements, see `USAGE-MIGRATION.md`, `README.md` and `USAGE-NOTES.en.md`.
|
|
162
|
+
Website build/deploy notes live in `website/README.md`.
|
package/USAGE-MIGRATION.md
CHANGED
|
@@ -294,13 +294,13 @@ codex mcp list # 应看到 dsh-any-agent ... enabled
|
|
|
294
294
|
|
|
295
295
|
### 权限档怎么选
|
|
296
296
|
|
|
297
|
-
`DSH_MCP_PERMISSION`
|
|
297
|
+
服务端未设置 `DSH_MCP_PERMISSION` 时默认 `danger-full-access`;`setup --yes` 默认写入 **`workspace-write`**。建议按用途改:
|
|
298
298
|
|
|
299
299
|
| 档位 | 适用于 |
|
|
300
300
|
|---|---|
|
|
301
301
|
| `danger-full-access` | 自己的项目,追求速度 |
|
|
302
|
-
| `workspace-write` |
|
|
303
|
-
| `read-only` |
|
|
302
|
+
| `workspace-write` | **setup 默认**;DSH 沙箱限制可写范围,越界请求默认 auto-allow,传 `on_approval=ask` 才挂起审批 |
|
|
303
|
+
| `read-only` | 分析不信任的样本(强制 `on_approval=auto-deny`) |
|
|
304
304
|
|
|
305
305
|
`DSH_MCP_APPROVAL` 三选一:`auto-allow` / `auto-deny` / `ask`。
|
|
306
306
|
`ask` 时待决审批会出现在 `dsh_status` 里,用 `dsh_approval_decide` 裁决。
|
package/USAGE-NOTES.en.md
CHANGED
|
@@ -52,7 +52,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
52
52
|
|
|
53
53
|
**3.5 ★ The write lock: one writer per conversation at a time.** A session suddenly stops responding with a vague error (`Internal error`, or "this session is in use") and re-dispatching doesn't help. Cause: a DSH session directory carries a **cross-process write lock** whose semantics are "**the holder keeps it while it lives**", with **no API to take it from a live holder**. This server keeps a DSH process alive per conversation, so the two sides are **mutually exclusive**: while we hold a conversation your own DSH GUI cannot open it, and while your GUI has it open our `session/resume` cannot acquire it. The error is opaque because `SessionAlreadyOwnedError` is not a standard JSON-RPC error — ACP wraps it as a generic `-32603 "Internal error"` and hides the real cause in `error.data` (this server now surfaces and translates it). Fix: read `lock_holder` from `dsh_status` — `stale-mcp` (alive but wedged: its heartbeat expired; a real crash releases the lock automatically) → `dsh_takeover` takes it without `force` and kills that process; `live-mcp` (another live dsh-any-agent instance) → `dsh_release` on that side, or `dsh_takeover(force=true)`; `none` (most likely your GUI has it open) → **never killed by us** — close it in that window and retry.
|
|
54
54
|
|
|
55
|
-
**3.6 ★ "Taking the lock" means killing the holder, so we must know who that is.** The lock is a Windows named kernel semaphore, released by the
|
|
55
|
+
**3.6 ★ "Taking the lock" means killing the holder, so we must know who that is.** The lock is a Windows named kernel semaphore (or POSIX `flock`), released by the OS when the holder process dies — killing is the *only* way to take it. So this server registers a conversation **only once it actually holds it** (`<state dir>/locks/<conversation id>.json`, with pid + heartbeat). Holders it cannot identify are **never killed**. Why the GUI is untouchable: GUI conversations run inside the single **`dsh web` process** (not one process per conversation), so killing the lock holder would take down your entire UI *and every GUI conversation in that process, including the one you're reading*. Sessions on disk survive resumption, but in-flight turns are lost. **Operational rule: never open a conversation this server drives in your DSH GUI.** To view it there, `dsh_release` first and dispatch again afterwards (it resumes automatically).
|
|
56
56
|
|
|
57
57
|
**3.7 ★ Crash vs. wedge — measured, don't guess.** A **crash** (the MCP is killed or restarted): the DSH children it spawned **exit with it** — their stdio is a pipe to the parent, so closing it gives them EOF and they shut down. **The lock is therefore released automatically; no orphan keeps holding it**, so a restart always gets it back. The cost: **in-flight turns are interrupted and no sentinel lands** (the process is gone) — don't wait for that file, inspect the workspace (`git status`, files) and decide whether to re-dispatch. A **wedge** (the MCP is alive but stalled; no heartbeat for 90 s) is what `stale-mcp` really means, and `dsh_takeover` handles it **without `force`**. Judge with `dsh_status`'s `lock_holder` — and note that `none` does not mean "the lock is free"; it can also mean "a process we don't manage holds it" (usually the GUI).
|
|
58
58
|
|
|
@@ -68,9 +68,9 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
68
68
|
|
|
69
69
|
## 4. Permissions and safety
|
|
70
70
|
|
|
71
|
-
**4.1 The default is full access
|
|
71
|
+
**4.1 The raw server default is full access; `setup` writes `workspace-write`.** With no `DSH_MCP_PERMISSION`, DSH can touch anything your account can. That default exists because, unattended, ACP's approval channel tends to degrade into silent failures. `dsh-any-agent-setup --yes` writes `workspace-write` into the client config unless you pass `--permission`. For untrusted material (malware, unknown dumps, unfamiliar repos) use `permission: "read-only"`.
|
|
72
72
|
|
|
73
|
-
**4.2 `approval: never`
|
|
73
|
+
**4.2 The public approval policy is `on_approval` (`auto-allow` / `auto-deny` / `ask`).** DSH's internal `approval: never` corresponds to `auto-deny`: it deterministically rejects prompts, it does not auto-allow. Under `danger-full-access` the sandbox produces no asks, so the distinction is invisible. To let the caller adjudicate escalation, use `workspace-write` + `on_approval: "ask"` + `dsh_approval_decide`.
|
|
74
74
|
|
|
75
75
|
**4.3 ★ Side effects of *external MCP tools* are NOT covered by DSH's sandbox.** DSH's permission tiers govern DSH's own file and shell tools. Tools from an MCP server mounted into DSH (e.g. IDA Pro's `patch` / `rename` / `set_comments`) execute **inside that MCP server**, where DSH's tier has no effect. For read-only work, forbid write-class tools **in the prompt** and verify afterwards that `tools_used` contains none. Do not rely on `permission` alone.
|
|
76
76
|
|
|
@@ -130,7 +130,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
130
130
|
1. **Give goals, not commands.** DSH is an agent: say "add tests for this module and make them pass", not "run pytest". It picks tools, plans steps, and spawns subagents on its own.
|
|
131
131
|
2. **One conversation per task** — mixing unrelated work pollutes history and costs more.
|
|
132
132
|
3. **Split long work into dispatch + collect**: `wait=false` → do something else → `dsh_get(run_id)`.
|
|
133
|
-
4. **Review output via workspace changes** — `workspace_changed`
|
|
133
|
+
4. **Review output via workspace changes** — `workspace_changed` is true when the `git status` entry set changed across the turn; `diff_stat` lists the current dirty files (it is not a per-turn diff).
|
|
134
134
|
5. **Ask for a plan first** if you want to approve before execution (DSH has plan mode).
|
|
135
135
|
6. **Three cost knobs**: lower `reasoning_effort`, keep `reasoning=hide`, release and start fresh.
|
|
136
136
|
7. **Don't paste large code blocks into the prompt** — tell it which file to read instead; DSH has its own file tools.
|
package/USAGE-NOTES.md
CHANGED
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
- **对策**:用 `wait=false` 时**直接靠完成哨兵文件收活**(它自带结果、不受内存窗口限制、还扛服务重启);否则当场落盘,或用 `dsh_read` 把输出缓冲拉干(同样是内存、有界 500 条)。
|
|
71
71
|
|
|
72
72
|
### 2.6 哨兵文件会占磁盘
|
|
73
|
-
-
|
|
73
|
+
- **现象**:`$DSH_HOME/.dsh-any-agent/runs/<conversation_id>/` 下文件越攒越多,每个都含完整正文。
|
|
74
74
|
- **原因**:哨兵是**闩锁**——写一次并保留,晚到的等待方才能不漏,所以它不会自己消失。
|
|
75
75
|
- **对策**:服务**启动时会清掉超过 7 天的**(`DSH_MCP_SENTINEL_TTL_MS` 调,`0` 关闭)。等待任务消费后顺手 `rm` 更干净。哨兵**不含思考内容**(`result.thinking` 被剥掉,并始终给出布尔 `thinking_omitted`);它还会包含用量与**已按审批规则脱敏的工具输入**,仍应按敏感文件对待。
|
|
76
76
|
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
### 3.2 默认**不回收**空闲进程;显式设置 TTL 才回收
|
|
87
87
|
- **现象**:`dsh_status` 显示 `detached`(没有附着进程)。
|
|
88
88
|
- **原因**:默认 `DSH_MCP_IDLE_TTL_MS=0` = 永不回收,本服务一直握着写锁。显式设成毫秒数(如 `300000`)才会按 TTL 回收。**`detached` 不是错误** —— 会话本体在磁盘上,下次派活会自动 `session/resume` 复活,历史完整。
|
|
89
|
-
-
|
|
89
|
+
- **对策**:默认 `0` 已经就是“一直热着、永不回收”;只有你显式把 `DSH_MCP_IDLE_TTL_MS` 设成非零,才会按 TTL 回收。
|
|
90
90
|
|
|
91
91
|
### 3.3 同一个会话别在两处同时打开
|
|
92
92
|
- **现象**:你自己的 DSH GUI 打不开某个会话,或者对方报被占用。
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
- `none`(多半是你自己的 GUI 开着它)→ **本服务绝不杀**,请在那个窗口里关掉该会话后重试。
|
|
113
113
|
|
|
114
114
|
### 3.6 ★ "抢锁" = 杀掉持有者,所以必须先知道那是谁
|
|
115
|
-
-
|
|
115
|
+
- **原因**:Windows 上是命名内核信号量,POSIX 上是 `flock`;持有者进程一死,系统就释放 —— 这是**唯一**的夺锁手段。
|
|
116
116
|
- **所以**本服务只登记**自己的子进程在真正拿到锁之后**的持有状态(`<状态目录>/locks/<会话id>.json`,含 pid + 心跳)。判定不了的持有者**一律不杀**。
|
|
117
117
|
- **为什么对 GUI 绝不杀**:GUI 的会话跑在 **`dsh web` 那一个进程里**(不是一会话一进程)。杀掉占锁的它 = 整个界面 + **该进程里所有 GUI 会话(含你正在看的那个)一起断**。磁盘上的会话能 resume,但进行中的回合会丢。
|
|
118
118
|
- **操作铁律**:本服务驱动的会话**不要在 GUI 里打开**。要用 GUI 看,先 `dsh_release` 交还;看完再重派(会自动 resume)。
|
|
@@ -177,14 +177,14 @@
|
|
|
177
177
|
|
|
178
178
|
## 四、权限与安全(认真看)
|
|
179
179
|
|
|
180
|
-
### 4.1
|
|
181
|
-
-
|
|
182
|
-
-
|
|
180
|
+
### 4.1 服务端裸默认是"完全权限";setup 写入 `workspace-write`
|
|
181
|
+
- **现象**:未设置 `DSH_MCP_PERMISSION` 时,DSH 能碰你账户能碰的任何东西。
|
|
182
|
+
- **原因**:服务端裸默认 `danger-full-access` —— 这是为了零摩擦,因为**ACP 的审批通道在无人值守时很容易变成"静默失败"**。`dsh-any-agent-setup --yes` 默认会把 `workspace-write` 写进客户端配置(除非显式传 `--permission`)。
|
|
183
183
|
- **对策**:分析不信任的东西(恶意软件、来路不明的 dump、陌生仓库)时,用 `permission: "read-only"`(沙箱只读 + 审批一律拒绝)。
|
|
184
184
|
|
|
185
|
-
### 4.2 `approval: never`
|
|
185
|
+
### 4.2 公开审批参数是 `on_approval`;DSH 内部 `approval: never` 对应 `auto-deny`
|
|
186
186
|
- **现象**:以为关掉审批就万事大吉,结果某些操作还是失败。
|
|
187
|
-
-
|
|
187
|
+
- **原因**:DSH 内部的 `approval: never` = 每次询问都返回拒绝(确定性拒绝),不是自动放行;公开参数应写 `on_approval: "auto-deny"`。它在 `danger-full-access` 下没有副作用,是因为沙箱根本不产生询问。
|
|
188
188
|
- **对策**:需要"越界操作由调用方裁决"时,用 `permission: "workspace-write"` + `on_approval: "ask"`,再用 `dsh_approval_decide` 裁决。
|
|
189
189
|
|
|
190
190
|
### 4.3 ★ MCP 工具的副作用**不受** DSH 沙箱约束
|
|
@@ -271,7 +271,7 @@
|
|
|
271
271
|
1. **给它目标,不要给命令**。DSH 是 agent:说"把这个模块的测试补上并跑通",别说"执行 pytest"。它自己会选工具、分步骤、必要时派子代理。
|
|
272
272
|
2. **一个会话一件事**。别往一个会话里塞互不相关的任务,历史会互相污染且越来越贵。
|
|
273
273
|
3. **长任务拆成"派活 + 验收"**:`wait=false` → 干别的 → `dsh_get(run_id)`。
|
|
274
|
-
4.
|
|
274
|
+
4. **要审查产出就用工作区变更**:`workspace_changed` 表示本回合前后 `git status` 的条目集合发生了变化;`diff_stat` 列出当前仍处于改动状态的文件(不是本回合的精确 diff)。
|
|
275
275
|
5. **要它先想清楚再动手**:让它先给方案、你确认后再执行(DSH 有计划模式)。
|
|
276
276
|
6. **省钱的三个旋钮**:`reasoning_effort` 调低、`reasoning` 保持 `hide`、及时 `dsh_release` 并开新会话。
|
|
277
277
|
7. **别在提示词里重复贴大段代码**——告诉它去看哪个文件更划算(DSH 自己有文件工具)。
|
package/bin/restore.mjs
CHANGED
|
@@ -108,9 +108,13 @@ if (wantFile) {
|
|
|
108
108
|
process.exit(1);
|
|
109
109
|
}
|
|
110
110
|
} else {
|
|
111
|
-
//
|
|
111
|
+
// 每个文件只取最新的一份;但排除本工具自己写的 pre-restore 安全副本,
|
|
112
|
+
// 否则连续两次 --latest 会把上一次还原再撤回去。
|
|
112
113
|
const byTarget = new Map();
|
|
113
|
-
for (const b of all)
|
|
114
|
+
for (const b of all) {
|
|
115
|
+
if (b.stamp.startsWith('pre-restore-')) continue;
|
|
116
|
+
if (!byTarget.has(b.target)) byTarget.set(b.target, b);
|
|
117
|
+
}
|
|
114
118
|
chosen = [...byTarget.values()];
|
|
115
119
|
}
|
|
116
120
|
|
package/bin/setup.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
planClient,
|
|
39
39
|
resolveDirs,
|
|
40
40
|
} from '../src/clients.mjs';
|
|
41
|
+
import { PERMISSION_TIERS } from '../src/config.mjs';
|
|
41
42
|
|
|
42
43
|
/** 版本号以 package.json 为准,避免 npm version 之后这里变旧。 */
|
|
43
44
|
const VERSION = (() => {
|
|
@@ -115,6 +116,11 @@ const tick = (s) => log(' OK ' + s);
|
|
|
115
116
|
const dash = (s) => log(' --- ' + s);
|
|
116
117
|
const bang = (s) => log(' FAIL ' + s);
|
|
117
118
|
|
|
119
|
+
if (!PERMISSION_TIERS.includes(PERMISSION)) {
|
|
120
|
+
log(`--permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一;收到: ${JSON.stringify(PERMISSION)}`);
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
|
|
118
124
|
// ── 主流程 ─────────────────────────────────────────────────────────
|
|
119
125
|
|
|
120
126
|
const dirs = resolveDirs(HOME_OVERRIDE);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code. Resume-aware, interruptible, approval-capable, and it only ever touches the one config entry it owns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/acp.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { ensureProfile } from './bootstrap.mjs';
|
|
14
14
|
import { DSH_PROFILE, PKG_VERSION, REQUEST_TIMEOUT_MS, resolveDshBin } from './config.mjs';
|
|
15
|
+
import { LineDecoder } from './line-decoder.mjs';
|
|
15
16
|
|
|
16
17
|
/** ACP 协议版本(@agentclientprotocol/sdk 的 v1)。 */
|
|
17
18
|
const PROTOCOL_VERSION = 1;
|
|
@@ -25,7 +26,8 @@ export class AcpProcess {
|
|
|
25
26
|
#child = null;
|
|
26
27
|
#nextId = 1;
|
|
27
28
|
#pending = new Map();
|
|
28
|
-
#
|
|
29
|
+
#lineDecoder = new LineDecoder();
|
|
30
|
+
#errDecoder = new LineDecoder();
|
|
29
31
|
#stderrTail = [];
|
|
30
32
|
#stopping = false;
|
|
31
33
|
#log = NOOP_LOG;
|
|
@@ -100,7 +102,7 @@ export class AcpProcess {
|
|
|
100
102
|
this.#child.stdout.on('error', streamError);
|
|
101
103
|
this.#child.stderr.on('error', streamError);
|
|
102
104
|
this.#child.stderr.on('data', (d) => {
|
|
103
|
-
for (const line of
|
|
105
|
+
for (const line of this.#errDecoder.push(d)) {
|
|
104
106
|
if (!line.trim()) continue;
|
|
105
107
|
this.#stderrTail.push(line.trim());
|
|
106
108
|
if (this.#stderrTail.length > STDERR_TAIL_LINES) this.#stderrTail.shift();
|
|
@@ -124,11 +126,7 @@ export class AcpProcess {
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
#onData(chunk) {
|
|
127
|
-
this.#
|
|
128
|
-
let idx;
|
|
129
|
-
while ((idx = this.#buf.indexOf('\n')) !== -1) {
|
|
130
|
-
const line = this.#buf.slice(0, idx);
|
|
131
|
-
this.#buf = this.#buf.slice(idx + 1);
|
|
129
|
+
for (const line of this.#lineDecoder.push(chunk)) {
|
|
132
130
|
if (!line.trim()) continue;
|
|
133
131
|
let msg;
|
|
134
132
|
try {
|
|
@@ -258,7 +256,7 @@ export class AcpProcess {
|
|
|
258
256
|
/** 优雅收尾:close 由调用方负责,这里只关 stdin(DSH 绑定 stdin EOF → 有界退出)。 */
|
|
259
257
|
async stop({ graceMs = 3000, killWaitMs = 5000 } = {}) {
|
|
260
258
|
const child = this.#child;
|
|
261
|
-
if (!child) return;
|
|
259
|
+
if (!child) return true;
|
|
262
260
|
this.#stopping = true;
|
|
263
261
|
try {
|
|
264
262
|
child.stdin.end();
|
|
@@ -273,7 +271,7 @@ export class AcpProcess {
|
|
|
273
271
|
});
|
|
274
272
|
});
|
|
275
273
|
// 1) 先给它体面退出的机会(stdin EOF → DSH 有界退出)
|
|
276
|
-
if (await waitExit(graceMs)) return;
|
|
274
|
+
if (await waitExit(graceMs)) return true;
|
|
277
275
|
// 2) 超时则强杀,**并且等它真的退出**。
|
|
278
276
|
// ★ 旧实现 kill() 之后立刻 resolve —— 于是调用方会在"子进程还活着、还握着写锁"
|
|
279
277
|
// 的时候继续往下走:删掉登记、再 spawn 新进程去 resume,就会撞上一个**无法归因的锁**
|
|
@@ -282,6 +280,6 @@ export class AcpProcess {
|
|
|
282
280
|
try {
|
|
283
281
|
child.kill();
|
|
284
282
|
} catch {}
|
|
285
|
-
await waitExit(killWaitMs);
|
|
283
|
+
return await waitExit(killWaitMs);
|
|
286
284
|
}
|
|
287
285
|
}
|
package/src/clients.mjs
CHANGED
|
@@ -89,11 +89,22 @@ export function pruneBackups(file) {
|
|
|
89
89
|
* 任何一步失败都必须删掉临时文件 —— 曾经有版本在 rename 失败后
|
|
90
90
|
* 把 mcp.json.tmp-dsh-<pid> 留在了客户端目录里。
|
|
91
91
|
*/
|
|
92
|
-
export function atomicWrite(file, text) {
|
|
92
|
+
export function atomicWrite(file, text, { mode } = {}) {
|
|
93
|
+
// 尽量保留原文件权限:POSIX 上 0600 的客户端配置不能因为一次 setup 变成 0644。
|
|
94
|
+
let originalMode = mode;
|
|
95
|
+
if (originalMode === undefined) {
|
|
96
|
+
try { originalMode = statSync(file).mode; } catch { originalMode = null; }
|
|
97
|
+
}
|
|
93
98
|
const tmp = file + '.tmp-dsh-' + process.pid + '-' + Math.random().toString(36).slice(2, 8);
|
|
94
99
|
try {
|
|
95
|
-
writeFileSync(tmp, text,
|
|
100
|
+
writeFileSync(tmp, text, {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
...(originalMode === null ? {} : { mode: originalMode & 0o7777 }),
|
|
103
|
+
});
|
|
96
104
|
renameSync(tmp, file);
|
|
105
|
+
if (originalMode !== null) {
|
|
106
|
+
try { chmodSync(file, originalMode & 0o7777); } catch { /* 尽力而为 */ }
|
|
107
|
+
}
|
|
97
108
|
} catch (e) {
|
|
98
109
|
try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* 尽力而为 */ }
|
|
99
110
|
throw e;
|
package/src/hub.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
} from './config.mjs';
|
|
52
52
|
import { AcpProcess } from './acp.mjs';
|
|
53
53
|
import { readConversationMeta } from './titles.mjs';
|
|
54
|
-
import { registerWorkspace,
|
|
54
|
+
import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
|
|
55
55
|
import {
|
|
56
56
|
LOCKS_DIR,
|
|
57
57
|
classifyHolder,
|
|
@@ -134,7 +134,7 @@ function isForgotten(id) {
|
|
|
134
134
|
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
135
135
|
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
136
136
|
*/
|
|
137
|
-
async function pruneStaleTombstones() {
|
|
137
|
+
async function pruneStaleTombstones(log = NOOP_LOG) {
|
|
138
138
|
let removed = 0;
|
|
139
139
|
try {
|
|
140
140
|
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
@@ -142,7 +142,13 @@ async function pruneStaleTombstones() {
|
|
|
142
142
|
if (!f.endsWith('.json')) continue;
|
|
143
143
|
const id = f.slice(0, -5);
|
|
144
144
|
// 异步读会话头:启动路径不能被大日志的同步解压卡住。
|
|
145
|
-
|
|
145
|
+
// ★ 只有确认会话文件不存在时才删墓碑;读超时/损坏时保留,
|
|
146
|
+
// 否则一次慢盘/挂载异常就会让 forget 失效。
|
|
147
|
+
const found = await findSessionHeaderStatus(id);
|
|
148
|
+
if (found.status === 'found' || found.status === 'error') {
|
|
149
|
+
if (found.status === 'error') log.info(`[hub] 墓碑 ${id} 暂不清理:${found.reason}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
146
152
|
try {
|
|
147
153
|
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
148
154
|
removed++;
|
|
@@ -247,8 +253,9 @@ async function applySessionConfig(proc, sessionId, cfg) {
|
|
|
247
253
|
*/
|
|
248
254
|
export function sanitizeResultForSentinel(result) {
|
|
249
255
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
250
|
-
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
251
256
|
const { thinking, ...rest } = result;
|
|
257
|
+
// ★ 即使 DSH_MCP_SENTINEL_INCLUDE_REASONING=1,也只额外保留 thinking;
|
|
258
|
+
// answer/error/title/tools_used 仍然必须脱敏(以前这个开关会跳过整段清洗)。
|
|
252
259
|
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
253
260
|
for (const key of ['answer', 'error', 'title']) {
|
|
254
261
|
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
@@ -264,7 +271,9 @@ export function sanitizeResultForSentinel(result) {
|
|
|
264
271
|
});
|
|
265
272
|
}
|
|
266
273
|
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
267
|
-
|
|
274
|
+
const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
|
|
275
|
+
if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
|
|
276
|
+
return out;
|
|
268
277
|
}
|
|
269
278
|
|
|
270
279
|
export class Conversation {
|
|
@@ -462,9 +471,10 @@ export class Conversation {
|
|
|
462
471
|
// 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
|
|
463
472
|
// 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
|
|
464
473
|
// 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
|
|
465
|
-
await proc.stop().catch(() =>
|
|
466
|
-
// 只删**自己的**登记(removeMarker 会校验 token
|
|
467
|
-
|
|
474
|
+
const stopped = await proc.stop().catch(() => false);
|
|
475
|
+
// 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
|
|
476
|
+
// 让 classifyHolder/takeover 仍能归因。
|
|
477
|
+
if (stopped) removeMarker(this.id);
|
|
468
478
|
// 写锁冲突是最常见也最需要"说人话"的失败:DSH 的 SessionAlreadyOwnedError 会被
|
|
469
479
|
// ACP 包成 -32603 "Internal error",只说这句调用方根本不知道该怎么办。
|
|
470
480
|
if (isLeaseError(e.message)) {
|
|
@@ -500,11 +510,14 @@ export class Conversation {
|
|
|
500
510
|
*
|
|
501
511
|
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
502
512
|
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
503
|
-
* read-only
|
|
504
|
-
*
|
|
513
|
+
* read-only 被静默提升)。投影缓存可能稍后才刷盘;read-only 必须 fail-closed:
|
|
514
|
+
* 等待至少一个刷盘周期,仍读不到就拒绝继续,不能依赖“审批层兜底”
|
|
515
|
+
* (danger sandbox 下根本不会产生审批请求)。
|
|
505
516
|
*/
|
|
506
|
-
async verifyPermissionRecorded({ attempts
|
|
507
|
-
|
|
517
|
+
async verifyPermissionRecorded({ attempts, delayMs = 250 } = {}) {
|
|
518
|
+
// read-only 需要覆盖 DSH 约 5 秒的投影缓存刷新周期;其它档位保持较短等待。
|
|
519
|
+
const tries = Number.isFinite(attempts) ? attempts : this.permission === 'read-only' ? 40 : 6;
|
|
520
|
+
for (let i = 0; i < tries; i++) {
|
|
508
521
|
const meta = readConversationMeta(this.id);
|
|
509
522
|
const recorded = meta?.permissions?.preset;
|
|
510
523
|
if (recorded) {
|
|
@@ -519,11 +532,18 @@ export class Conversation {
|
|
|
519
532
|
this.permissionVerified = true;
|
|
520
533
|
return recorded;
|
|
521
534
|
}
|
|
522
|
-
if (i <
|
|
535
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
523
536
|
}
|
|
524
537
|
this.permissionVerified = false;
|
|
538
|
+
if (this.permission === 'read-only') {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`无法在 ${(tries * delayMs) / 1000}s 内确认会话实际为 read-only(投影缓存无权限记录)。\n` +
|
|
541
|
+
' 为避免 profile 的 defaultPreset 把只读会话静默提升为完全访问,本次调用已拒绝。\n' +
|
|
542
|
+
' 请确认 DSH 会话存储可读,或稍后重试;非只读档位不受此限制。',
|
|
543
|
+
);
|
|
544
|
+
}
|
|
525
545
|
this.#hub.log.info(
|
|
526
|
-
`[conv ${this.id}]
|
|
546
|
+
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(非只读档)`,
|
|
527
547
|
);
|
|
528
548
|
return null;
|
|
529
549
|
}
|
|
@@ -565,7 +585,8 @@ export class Conversation {
|
|
|
565
585
|
report.holder_kind = holder.kind;
|
|
566
586
|
report.holder = holder.marker;
|
|
567
587
|
// 登记可能已随回收被删,但"我们自己的残留子进程"仍可归因(见 locks.classifyHolder)
|
|
568
|
-
const
|
|
588
|
+
const markerChild = holder.marker?.child_pid ?? null;
|
|
589
|
+
const killTarget = isPidAlive(markerChild) ? markerChild : (holder.known_child_pid ?? markerChild);
|
|
569
590
|
|
|
570
591
|
if (holder.kind === 'none' || !holder.childAlive) {
|
|
571
592
|
report.message =
|
|
@@ -857,7 +878,7 @@ export class Conversation {
|
|
|
857
878
|
},
|
|
858
879
|
tools_used: [...c.tools.values()],
|
|
859
880
|
usage: c.usage,
|
|
860
|
-
workspace_changed: before
|
|
881
|
+
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
861
882
|
diff_stat: after,
|
|
862
883
|
elapsed_ms: now() - t0,
|
|
863
884
|
};
|
|
@@ -1092,11 +1113,12 @@ export class Conversation {
|
|
|
1092
1113
|
async close({ forget = false } = {}) {
|
|
1093
1114
|
const proc = this.proc;
|
|
1094
1115
|
this.proc = null;
|
|
1116
|
+
let stopped = true;
|
|
1095
1117
|
if (proc?.alive) {
|
|
1096
1118
|
try {
|
|
1097
1119
|
await proc.request('session/close', { sessionId: this.id }, 10_000);
|
|
1098
1120
|
} catch {}
|
|
1099
|
-
await proc.stop();
|
|
1121
|
+
stopped = await proc.stop().catch(() => false);
|
|
1100
1122
|
}
|
|
1101
1123
|
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1102
1124
|
for (const [, p] of this.pendingApprovals) {
|
|
@@ -1105,18 +1127,32 @@ export class Conversation {
|
|
|
1105
1127
|
} catch {}
|
|
1106
1128
|
}
|
|
1107
1129
|
this.pendingApprovals.clear();
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1130
|
+
// 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
|
|
1131
|
+
// 还能把这个仍握着写锁的孤儿归因出来。
|
|
1132
|
+
if (stopped) removeMarker(this.id);
|
|
1133
|
+
else {
|
|
1134
|
+
this.#hub.log.info(`[conv ${this.id}] 进程未能确认退出,保留写锁登记以便后续归因`);
|
|
1135
|
+
// 进程还活着时不能 forget:墓碑会让按 id 查询直接失败,而活着的进程
|
|
1136
|
+
// 仍握着写锁,之后连 takeover 都无法归因/接管。
|
|
1137
|
+
if (forget) return { closed: false, forgotten: false, error: '进程未能确认退出,拒绝 forget' };
|
|
1138
|
+
}
|
|
1110
1139
|
if (forget) {
|
|
1111
1140
|
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1112
1141
|
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1113
1142
|
writeTombstone(this.id);
|
|
1114
1143
|
this.#hub.remove(this.id);
|
|
1115
1144
|
} else this.#hub.save();
|
|
1116
|
-
return { closed:
|
|
1145
|
+
return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
|
|
1117
1146
|
}
|
|
1118
1147
|
}
|
|
1119
1148
|
|
|
1149
|
+
/** 两次 git status 快照是否表示同一组工作区变更。 */
|
|
1150
|
+
export function sameWorkspaceSnapshot(a, b) {
|
|
1151
|
+
if (!a && !b) return true;
|
|
1152
|
+
if (!a || !b) return false;
|
|
1153
|
+
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1120
1156
|
/** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
|
|
1121
1157
|
export function gitStatus(cwd) {
|
|
1122
1158
|
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
@@ -1207,6 +1243,8 @@ export class Hub {
|
|
|
1207
1243
|
}
|
|
1208
1244
|
|
|
1209
1245
|
#reaper;
|
|
1246
|
+
/** 同一 id 的磁盘接管 in-flight Promise,防止并发请求各自构造一个 Conversation。 */
|
|
1247
|
+
#adopting = new Map();
|
|
1210
1248
|
/** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
|
|
1211
1249
|
#probeCache = null;
|
|
1212
1250
|
|
|
@@ -1235,7 +1273,7 @@ export class Hub {
|
|
|
1235
1273
|
}
|
|
1236
1274
|
}
|
|
1237
1275
|
|
|
1238
|
-
const stale = await pruneStaleTombstones();
|
|
1276
|
+
const stale = await pruneStaleTombstones(this.log);
|
|
1239
1277
|
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
1240
1278
|
if (!existsSync(STATE_FILE)) return;
|
|
1241
1279
|
try {
|
|
@@ -1390,10 +1428,26 @@ export class Hub {
|
|
|
1390
1428
|
this.adoptNewFromDisk();
|
|
1391
1429
|
c = this.conversations.get(id);
|
|
1392
1430
|
}
|
|
1431
|
+
if (!c) {
|
|
1432
|
+
// 同一个磁盘态会话的并发查询共用一次 adopt,避免各自构造 Conversation。
|
|
1433
|
+
const inflight = this.#adopting.get(id);
|
|
1434
|
+
if (inflight) {
|
|
1435
|
+
await inflight;
|
|
1436
|
+
c = this.conversations.get(id);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1393
1439
|
if (!c) {
|
|
1394
1440
|
// 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
|
|
1395
1441
|
// 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
|
|
1396
|
-
|
|
1442
|
+
let release;
|
|
1443
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
1444
|
+
this.#adopting.set(id, gate);
|
|
1445
|
+
try {
|
|
1446
|
+
const found = await findSessionHeaderStatus(id);
|
|
1447
|
+
if (found.status === 'error') {
|
|
1448
|
+
throw new Error(`无法读取会话 ${id} 的会话头:${found.reason}`);
|
|
1449
|
+
}
|
|
1450
|
+
const header = found.header;
|
|
1397
1451
|
if (header?.cwd) {
|
|
1398
1452
|
// ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
|
|
1399
1453
|
// 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
|
|
@@ -1442,6 +1496,10 @@ export class Hub {
|
|
|
1442
1496
|
this.save();
|
|
1443
1497
|
this.log.info(`[hub] 从会话存储吸收了 ${id}(注册表里没有,但磁盘上有;权限档沿用 ${permission})`);
|
|
1444
1498
|
}
|
|
1499
|
+
} finally {
|
|
1500
|
+
this.#adopting.delete(id);
|
|
1501
|
+
release();
|
|
1502
|
+
}
|
|
1445
1503
|
}
|
|
1446
1504
|
if (!c) {
|
|
1447
1505
|
throw new Error(
|
|
@@ -1593,8 +1651,8 @@ export class Hub {
|
|
|
1593
1651
|
// 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
|
|
1594
1652
|
// 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
|
|
1595
1653
|
// 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
|
|
1596
|
-
|
|
1597
|
-
|
|
1654
|
+
const stopped = await proc.stop().catch(() => false);
|
|
1655
|
+
if (createdSessionId && stopped) removeMarker(createdSessionId);
|
|
1598
1656
|
throw e;
|
|
1599
1657
|
}
|
|
1600
1658
|
}
|
|
@@ -1732,8 +1790,8 @@ export class Hub {
|
|
|
1732
1790
|
// ★ 顺序要紧:**先确认子进程真的退出(写锁真的释放),再删登记**。
|
|
1733
1791
|
// 反过来的话,一旦它没死透,锁的持有者就变成"无登记的未知进程" ——
|
|
1734
1792
|
// 既报不清是谁,连 dsh_takeover 都会拒绝(判成 none)。
|
|
1735
|
-
await proc.stop().catch(() =>
|
|
1736
|
-
removeMarker(c.id);
|
|
1793
|
+
const stopped = await proc.stop().catch(() => false);
|
|
1794
|
+
if (stopped) removeMarker(c.id);
|
|
1737
1795
|
}
|
|
1738
1796
|
} catch (e) {
|
|
1739
1797
|
this.log.info(`[hub] 回收周期异常(已忽略,下个周期继续): ${e.message}`);
|
|
@@ -1751,6 +1809,7 @@ export class Hub {
|
|
|
1751
1809
|
async releaseAll({ force = false } = {}) {
|
|
1752
1810
|
const released = [];
|
|
1753
1811
|
const skippedBusy = [];
|
|
1812
|
+
const failed = [];
|
|
1754
1813
|
for (const c of this.conversations.values()) {
|
|
1755
1814
|
if (!c.proc?.alive) continue;
|
|
1756
1815
|
// ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
|
|
@@ -1761,12 +1820,18 @@ export class Hub {
|
|
|
1761
1820
|
}
|
|
1762
1821
|
const proc = c.proc;
|
|
1763
1822
|
c.proc = null;
|
|
1764
|
-
await proc.stop().catch(() =>
|
|
1765
|
-
|
|
1766
|
-
|
|
1823
|
+
const stopped = await proc.stop().catch(() => false);
|
|
1824
|
+
if (stopped) {
|
|
1825
|
+
removeMarker(c.id);
|
|
1826
|
+
released.push(c.id);
|
|
1827
|
+
} else {
|
|
1828
|
+
// 进程没确认退出:保留登记,让 takeover 还能归因这个孤儿。
|
|
1829
|
+
failed.push(c.id);
|
|
1830
|
+
this.log.info(`[hub] 释放 ${c.id} 时进程未确认退出,保留写锁登记`);
|
|
1831
|
+
}
|
|
1767
1832
|
}
|
|
1768
1833
|
this.save();
|
|
1769
|
-
return { released: released.length, conversations: released, skipped_busy: skippedBusy };
|
|
1834
|
+
return { released: released.length, conversations: released, skipped_busy: skippedBusy, failed };
|
|
1770
1835
|
}
|
|
1771
1836
|
|
|
1772
1837
|
async shutdown() {
|
|
@@ -1775,9 +1840,10 @@ export class Hub {
|
|
|
1775
1840
|
for (const c of this.conversations.values()) {
|
|
1776
1841
|
const proc = c.proc;
|
|
1777
1842
|
c.proc = null;
|
|
1778
|
-
await proc?.stop().catch(() =>
|
|
1779
|
-
//
|
|
1780
|
-
removeMarker(c.id);
|
|
1843
|
+
const stopped = await proc?.stop().catch(() => false);
|
|
1844
|
+
// 只有确认子进程退出、锁真的释放后才删登记;否则留给下个实例归因。
|
|
1845
|
+
if (stopped) removeMarker(c.id);
|
|
1846
|
+
else this.log.info(`[hub] 关闭 ${c.id} 时进程未确认退出,保留写锁登记`);
|
|
1781
1847
|
}
|
|
1782
1848
|
this.save();
|
|
1783
1849
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行式 UTF-8 解码器。
|
|
3
|
+
*
|
|
4
|
+
* Node 的 stream 回调给的是 Buffer;直接对每个 chunk 调 `toString('utf8')`
|
|
5
|
+
* 会在 chunk 边界把一个多字节字符(中文/emoji)截成 U+FFFD,JSON 仍然合法,
|
|
6
|
+
* 但内容被静默改写。StringDecoder 会跨 chunk 保留未完成的多字节序列。
|
|
7
|
+
*
|
|
8
|
+
* 同时负责按 `\n` 切行;行内保留 `\r`,由调用方按需 trim。
|
|
9
|
+
*/
|
|
10
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
11
|
+
|
|
12
|
+
export class LineDecoder {
|
|
13
|
+
#decoder = new StringDecoder('utf8');
|
|
14
|
+
#buf = '';
|
|
15
|
+
|
|
16
|
+
/** 喂一个 Buffer;返回本轮切出的完整行(不含换行符)。 */
|
|
17
|
+
push(chunk) {
|
|
18
|
+
this.#buf += this.#decoder.write(chunk);
|
|
19
|
+
return this.#drain(false);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** EOF:把 decoder 里残留的半个字符和最后一行一起吐出来。 */
|
|
23
|
+
end() {
|
|
24
|
+
this.#buf += this.#decoder.end();
|
|
25
|
+
return this.#drain(true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#drain(flush) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
let i;
|
|
31
|
+
while ((i = this.#buf.indexOf('\n')) !== -1) {
|
|
32
|
+
lines.push(this.#buf.slice(0, i));
|
|
33
|
+
this.#buf = this.#buf.slice(i + 1);
|
|
34
|
+
}
|
|
35
|
+
if (flush && this.#buf) {
|
|
36
|
+
lines.push(this.#buf);
|
|
37
|
+
this.#buf = '';
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/locks.mjs
CHANGED
|
@@ -106,7 +106,12 @@ export function writeMarker(conversationId, { childPid, cwd }) {
|
|
|
106
106
|
mcp_token: MCP_TOKEN,
|
|
107
107
|
child_pid: childPid ?? null,
|
|
108
108
|
cwd: cwd ?? null,
|
|
109
|
-
|
|
109
|
+
// 只有同一个子进程才沿用旧启动时间;子进程换了(PID 变化)必须重置,
|
|
110
|
+
// 否则 PID 复用保护会把新进程误判成“复用旧 PID”而拒绝 takeover。
|
|
111
|
+
spawned_at:
|
|
112
|
+
prev && prev.child_pid === childPid && Number.isFinite(prev.spawned_at)
|
|
113
|
+
? prev.spawned_at
|
|
114
|
+
: now,
|
|
110
115
|
heartbeat_at: now,
|
|
111
116
|
});
|
|
112
117
|
return true;
|
package/src/mcp.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { createHandlers, TOOL_DEFS } from './tools.mjs';
|
|
12
12
|
import { DEFAULT_PERMISSION } from './config.mjs';
|
|
13
|
+
import { LineDecoder } from './line-decoder.mjs';
|
|
13
14
|
|
|
14
15
|
const SERVER_NAME = 'dsh-any-agent';
|
|
15
16
|
/**
|
|
@@ -117,7 +118,7 @@ function fileURLToPathSafe(uri) {
|
|
|
117
118
|
|
|
118
119
|
export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHandlers(hub), log }) {
|
|
119
120
|
const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
120
|
-
|
|
121
|
+
const lineDecoder = new LineDecoder();
|
|
121
122
|
let initialized = false;
|
|
122
123
|
let clientCapabilities = {};
|
|
123
124
|
let rootsSeq = 0;
|
|
@@ -251,11 +252,7 @@ export function createMcpServer({ hub, toolDefs = TOOL_DEFS, handlers = createHa
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
function onData(chunk) {
|
|
254
|
-
|
|
255
|
-
let idx;
|
|
256
|
-
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
257
|
-
const line = buffer.slice(0, idx);
|
|
258
|
-
buffer = buffer.slice(idx + 1);
|
|
255
|
+
for (const line of lineDecoder.push(chunk)) {
|
|
259
256
|
if (!line.trim()) continue;
|
|
260
257
|
let msg;
|
|
261
258
|
try {
|
package/src/tools.mjs
CHANGED
|
@@ -292,7 +292,7 @@ function renderSendResult(r) {
|
|
|
292
292
|
}
|
|
293
293
|
const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
|
|
294
294
|
if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
|
|
295
|
-
if (r.workspace_changed) meta.push(
|
|
295
|
+
if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
|
|
296
296
|
lines.push('');
|
|
297
297
|
lines.push(`[${meta.join(' · ')}]`);
|
|
298
298
|
|
|
@@ -611,12 +611,15 @@ export function createHandlers(hub, opts = {}) {
|
|
|
611
611
|
if (args.all === true) {
|
|
612
612
|
const r = await hub.releaseAll({ force: args.force === true });
|
|
613
613
|
return {
|
|
614
|
-
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy },
|
|
614
|
+
structured: { released: r.released, conversations: r.conversations, skipped_busy: r.skipped_busy, failed: r.failed },
|
|
615
615
|
text:
|
|
616
616
|
`已释放 ${r.released} 个空闲会话进程(写锁让出,内存回收)。` +
|
|
617
617
|
(r.skipped_busy.length
|
|
618
618
|
? `\n跳过 ${r.skipped_busy.length} 个正在跑回合的会话;要连它们一起停掉请传 force=true。`
|
|
619
619
|
: '') +
|
|
620
|
+
(r.failed.length
|
|
621
|
+
? `\n⚠️ ${r.failed.length} 个会话的进程未确认退出,写锁登记已保留;可稍后用 dsh_status/dsh_takeover 处理。`
|
|
622
|
+
: '') +
|
|
620
623
|
`\n会话日志都在磁盘上,之后派活会自动 resume;没有一个可释放时 released=0。`,
|
|
621
624
|
};
|
|
622
625
|
}
|
package/src/workspace.mjs
CHANGED
|
@@ -329,22 +329,33 @@ export function scanSessions() {
|
|
|
329
329
|
*
|
|
330
330
|
* 会对 id 做形状校验(只允许 UUID / `session-` 前缀那类字符),避免 `..` 之类逃出会话目录。
|
|
331
331
|
*/
|
|
332
|
-
export async function
|
|
333
|
-
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId))
|
|
334
|
-
|
|
332
|
+
export async function findSessionHeaderStatus(sessionId) {
|
|
333
|
+
if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{4,80}$/.test(sessionId)) {
|
|
334
|
+
return { status: 'missing', header: null, reason: 'id 形状非法' };
|
|
335
|
+
}
|
|
336
|
+
if (!existsSync(SESSIONS_ROOT)) return { status: 'missing', header: null, reason: '会话目录不存在' };
|
|
335
337
|
let buckets;
|
|
336
338
|
try {
|
|
337
339
|
buckets = readdirSync(SESSIONS_ROOT);
|
|
338
|
-
} catch {
|
|
339
|
-
return null;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return { status: 'error', header: null, reason: `会话目录不可读: ${e.message}` };
|
|
340
342
|
}
|
|
341
343
|
for (const bucket of buckets) {
|
|
342
344
|
const f = join(SESSIONS_ROOT, bucket, sessionId, 'session.v4.jsonl.zstd');
|
|
343
345
|
if (!existsSync(f)) continue;
|
|
344
346
|
const header = await readHeaderAsync(f);
|
|
345
|
-
if (header?.id === sessionId) return header;
|
|
347
|
+
if (header?.id === sessionId) return { status: 'found', header, file: f };
|
|
348
|
+
if (header === null) {
|
|
349
|
+
// 文件存在但头读不出来(超时/损坏/IO):不能当成“会话不存在”,
|
|
350
|
+
// 否则 forget 墓碑会被误删、按 id 查询也会误报未知会话。
|
|
351
|
+
return { status: 'error', header: null, reason: `无法读取会话头: ${f}`, file: f };
|
|
352
|
+
}
|
|
346
353
|
}
|
|
347
|
-
return null;
|
|
354
|
+
return { status: 'missing', header: null, reason: '会话文件不存在' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function findSessionHeader(sessionId) {
|
|
358
|
+
return (await findSessionHeaderStatus(sessionId)).header;
|
|
348
359
|
}
|
|
349
360
|
|
|
350
361
|
/**
|