dsh-any-agent 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  </div>
17
17
 
18
- 🧪 0.3.8 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
18
+ 🧪 0.3.9 本机 843 项检查全绿(九种客户端配置齐全时 879 项;其中 524 项属于客户端注册器套件,齐全时 560 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
19
19
 
20
20
  一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
21
21
 
@@ -75,6 +75,54 @@ DSH(DeepSeek Harness)本身**全都能做** —— 它有文件/shell 工具
75
75
  | 🧩 **能力可叠加** | DSH 自己还能挂别的 MCP(IDA Pro、浏览器、内部工具)——这是一座通往整个工具箱的桥。 |
76
76
  | 🛡️ **只读档真能只读** | `read-only` 沙箱 + 审批一律拒绝,适合分析不信任样本。 |
77
77
 
78
+ ## 🧭 定位:本服务只负责"派活",能力复用 DSH
79
+
80
+ **它不是能力聚合器,而是派发层。** DSH 已经有的能力,本服务默认不重做、不代理、不预加载——
81
+ 调用方给一个目标,DSH 自己决定用哪些工具、技能、子代理或团队。
82
+
83
+ | 归 DSH 管(本服务不碰) | 归本服务管(派发语义) |
84
+ |---|---|
85
+ | 文件读写、shell、搜索、编辑 | 会话身份与跨崩溃 resume |
86
+ | 技能、子代理、Agent Teams、任务板 | 异步派活、收据、完成哨兵 |
87
+ | 模型路由、沙箱、审批策略 | 增量读、插话、中断 |
88
+ | 记忆/上下文/压缩 | 审批桥、写锁治理、队列、收件箱 |
89
+ | worktree(agent 自己用 git 建) | 安装、状态、体检、审计 |
90
+
91
+ **按需派发**:`dsh_send` 只传目标,不传工具清单,也不要求调用方先了解 DSH 有哪些工具。
92
+ DSH 的 agent 按任务自己选能力——包括它自己新长出来的能力(比如实验性 Agent Teams),
93
+ 本服务不需要为每一个新能力加一个 MCP 工具。
94
+
95
+ **团队模式(0.4.0 默认开)**:默认 profile 是 `dsh-team`(首启自动创建,含实验性 Agent Teams),
96
+ `dsh_send` 会在 prompt 前注入一句"需要并行/多角色时先建团队";简单任务不会组队。
97
+ 关闭:`DSH_MCP_TEAM=0`(回到 `dsh-mcp`、不注入),或单次 `dsh_send(team=false)`。
98
+ 注意 team profile 会关闭旧的 `subagent` 工具,换成 9 个团队工具;成员共享一个工作区。
99
+
100
+ **只有"派发语义"本身需要的才做成工具**:建会话、派活、读进度、验收、插话、中断、审批、
101
+ 队列、收件箱、状态、释放、抢占、体检。文件读写 / shell / 编辑器 / 任务板都不做。
102
+
103
+ **什么时候需要外层再加东西**:只有当你需要"保证"而不是"行为"时——
104
+ 例如多个顶层会话并发改同一个仓库,需要外层 worktree 隔离;团队协作则优先让 DSH 自己开团队
105
+ (需要把实验性 team 包挂进 profile,成员共享一个工作区)。
106
+
107
+ ## 🧑‍🤝‍🧑 团队模式(0.4.0 默认开)
108
+
109
+ 默认 profile 是 `dsh-team`:首次启动自动创建,包含实验性 Agent Teams,并关闭旧的 `subagent` 工具。
110
+ `dsh_send` 会在调用方的 prompt 前注入一句"需要并行/多角色时先建团队";简单任务不会被强制组队。
111
+
112
+ | 想做什么 | 怎么做 |
113
+ |---|---|
114
+ | 保持默认(开) | 什么都不用设 |
115
+ | **全局关闭** | `DSH_MCP_TEAM=0`(回到 `dsh-mcp` profile,不再注入团队提示) |
116
+ | **只关这一次** | `dsh_send(..., team=false)` |
117
+ | 用自定义 profile | `DSH_MCP_PROFILE=<name>`;服务会检查该 profile 是否真的含 team 包,不含就自动不注入 |
118
+ | 确认当前状态 | `dsh_start` 的返回、或 `dsh_doctor` 的 `config` 检查(`team=on/off`) |
119
+
120
+ 注意:
121
+
122
+ - 团队模式要求 DSH `>= 0.1.7-0`;版本不够会自动退回 `dsh-mcp`。
123
+ - team profile 会关闭旧的 `subagent` 工具,换成 9 个团队工具;成员共享一个工作区。
124
+ - 团队是**实验性**能力,DSH 不提供稳定性承诺;不要在关键路径上假定它永远可用。
125
+
78
126
  ## ⚡ 为什么不是直接把 DSH 包一层
79
127
 
80
128
  DSH 自带两个可编程入口。`sdk` profile 看着更简单,但它的 JSON-RPC 只有 **3 个请求 + 4 个通知**,缺了三样让 agent 实际不可用的能力;`acp` profile(Agent Client Protocol)全都有。以下结论全部针对 DSH `0.2.1-rc.5` 实测得出。
@@ -227,7 +275,7 @@ Zed 33 -> 33 个键 只有 context_servers 多了个子项
227
275
 
228
276
  Codex 的 `config.toml` 同理,是按行处理的:其它 `[表]` 及其注释、顶层键(`model`、`approval_policy`、`sandbox_mode` 等)、CRLF 行尾都原样保留 —— `--remove` 只删 `[mcp_servers.dsh-any-agent]` 那两段,其余一律不动。
229
277
 
230
- 这些断言的详细覆盖见 [`test/`](test/)(`test/config-safety.mjs` 共 406 项,逐条断言上面每一条)。
278
+ 这些断言的详细覆盖见 [`test/`](test/)(`test/config-safety.mjs` 共 407 项,逐条断言上面每一条)。
231
279
 
232
280
  **在真实 home 上做过只读验证。** 用 `setup --json`(纯计划模式)跑一遍开发机上的真实配置:9 个客户端全部报出,而现存的每份配置都**原封未动** —— size、mtime、SHA-256 全同,没有新增任何 `.bak-dsh-*`,也没有新建任何文件:
233
281
 
@@ -376,7 +424,7 @@ tool_timeout_sec = 120.0
376
424
 
377
425
  本地目录安装时改用 `"command": "node"` 和 `"args": ["/绝对路径/server/bin/dsh-any-agent.mjs"]`。
378
426
 
379
- > Cursor、Windsurf、Zed、Cline、Continue 等大部分客户端都吃同一套 `mcpServers` 结构——只要能拉起 stdio MCP 服务,就能用。
427
+ > Cursor、Windsurf、Cline、Continue 等大部分客户端都吃同一套 `mcpServers` 结构——只要能拉起 stdio MCP 服务,就能用。Zed 是例外:它用 `context_servers`(见上方示例)。
380
428
 
381
429
  </details>
382
430
 
@@ -402,7 +450,10 @@ node test/smoke.mjs # 不调用 LLM,验证整条管道
402
450
  | `dsh_interrupt` | 打断当前回合(会话不会损坏) |
403
451
  | `dsh_release` | 交还会话(释放写锁,之后可在你自己的 DSH GUI 里打开)。仍可 resume;`forget=true` 会写墓碑,此后按 id 查询一律拒绝,直到删除 `$DSH_HOME/.dsh-any-agent-forgotten/<id>.json` |
404
452
  | `dsh_approval_decide` | 裁决待决审批(仅当 `on_approval=ask`) |
405
- | `dsh_status` | 轻量状态查询 |
453
+ | `dsh_status` | 轻量状态查询(单个会话) |
454
+ | `dsh_inbox` | **跨会话待决审批收件箱**:一次列出所有会话的审批、原因、输入与等待时长;裁决仍用 `dsh_approval_decide` |
455
+ | `dsh_queue` | 查看/修改会话队列:`list` / `remove` / `update` / `promote`(队列由 `dsh_send(queue=true)` 产生,仅内存) |
456
+ | `dsh_doctor` | 只读体检:DSH 版本、profile、状态/目录可写、孤儿锁、会话与 RSS、生效配置 |
406
457
 
407
458
  ## 📋 用法:先派活,回头再验收
408
459
 
@@ -476,13 +527,13 @@ DSH 在模型层就把思考与正文分开,ACP 又把它们做成**两条独
476
527
 
477
528
  | 档位 | 审批默认 | 用途 |
478
529
  |---|---|---|
479
- | `danger-full-access`(默认) | `auto-allow` | 零摩擦 |
480
- | `workspace-write` | `auto-allow` | 越界操作会冒泡为待决审批 |
530
+ | `danger-full-access`(服务端裸默认) | `auto-allow` | 零摩擦;`setup` 默认写入 `workspace-write` |
531
+ | `workspace-write` | `auto-allow` | DSH 沙箱限制可写范围;越界请求默认放行,传 `on_approval=ask` 才挂起审批 |
481
532
  | `read-only` | `auto-deny` | 分析不信任样本(恶意软件、来路不明 dump) |
482
533
 
483
534
  `on_approval` 可选 `auto-allow` / `auto-deny` / `ask`(`ask` 会把审批挂起,交给 `dsh_approval_decide` 裁决)。
484
535
 
485
- **`read-only` 是强制边界,不只是默认值**:它固定使用 `auto-deny`;给只读会话传 `auto-allow` / `ask` 会在 `dsh_start` 被直接拒绝,审批层也会拒绝来自只读会话的任何权限请求。否则 DSH 可以请求 `sandbox_permissions` 提权,而宽松的审批策略会把写操作放行(对抗审计实测过)。
536
+ **`read-only` 是强制边界,不只是默认值**:它固定使用 `auto-deny`;给只读会话传 `auto-allow` / `ask` 会在 `dsh_start` 被直接拒绝,审批层也会拒绝来自只读会话的任何权限请求。建会话/resume 后还会回读会话自己记录的权限档;只读档如果在 DSH 投影缓存刷盘窗口内仍读不到记录,会 **fail-closed 拒绝继续**,而不是只依赖审批层兜底。否则 profile 的 `defaultPreset` 加宽松审批策略可能放行写操作(对抗审计实测过)。
486
537
 
487
538
  ## 🪟 会话在 DSH GUI 里的可见性
488
539
 
@@ -535,7 +586,8 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
535
586
  | 变量 | 默认 | 说明 |
536
587
  |---|---|---|
537
588
  | `DSH_BIN` | 自动探测 | `@deepseek-ai/dsh/lib/bin.js` 路径 |
538
- | `DSH_MCP_PROFILE` | `dsh-mcp` | 驱动用的 DSH profile |
589
+ | `DSH_MCP_PROFILE` | `dsh-team`(团队开)/ `dsh-mcp`(团队关) | 驱动用的 DSH profile;显式设置优先 |
590
+ | `DSH_MCP_TEAM` | `1` | 团队模式。默认开:用 `dsh-team` profile,并在 `dsh_send` 前注入"需要时先建团队"的提示。设 `0` 关闭(回到 `dsh-mcp`、不注入);单次关闭用 `dsh_send(team=false)` |
539
591
  | `DSH_HOME` | `~/.dsh` | DSH 主目录。**默认就用本机用户自己的**,凭据与会话历史因此与 GUI 共用。指向别处即隔离环境(那就得自己准备那个目录的凭据与 profile) |
540
592
  | `DSH_MCP_BOOTSTRAP` | 开 | `0` 关闭首启 profile 自动创建/同步(关掉后只驱动已存在的 profile,不写任何文件) |
541
593
  | `DSH_MCP_ROUTE_SOURCE` | 自动 | 复制模型路由的来源 profile 名;`__home__` 表示只认 `$DSH_HOME/cordis.patch.yml` |
@@ -545,12 +597,16 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
545
597
  | `DSH_MCP_APPROVAL` | `auto-allow`(非只读档) | 默认审批策略。设成 `ask` 才能让越界请求挂起给 `dsh_approval_decide`,而不是自动放行 |
546
598
  | `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | 待决 `ask` 审批的等待上限;超时按拒绝处理 |
547
599
  | `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | `dsh_release(forget=true)` 写的墓碑目录 |
548
- | `DSH_MCP_LOCK_STALE_MS` | `90000` | 持锁登记多久没续心跳就算"持有者失联"(此时可被 `dsh_takeover` 自动抢占) |
549
- | `DSH_MCP_IDLE_TTL_MS` | `0`(永不回收) | 会话空闲多久回收其 DSH 进程。★★ **`0` = 永不回收(默认)**:本服务一直握着写锁,**GUI 抢不走**(你在 GUI 里点开是**只读**,照样能看 ✓)。代价:每会话常驻一个 DSH 进程(**两次冷启动实测 476MB / 295MB**)。设成毫秒数(如 `300000`)可省内存,但要接受"锁可能被 GUI 抢走" |
550
- | `DSH_MCP_MAX_LIVE` | `8`(最坏约 2.5–4GB,见第 8 条) | ★ **最多同时保活几个会话进程**(`0` = 不限)。超标时回收**最久没用过**的(**在跑的回合绝不回收**)—— "**内存有界,锁尽量在手**"。内存紧用 `4`,机器大可以 `0`。用完想立刻放掉空闲会话:`dsh_release(all=true)`(busy 回合默认跳过,`force=true` 才中断) |
600
+ | `DSH_MCP_LOCK_STALE_MS` | `90000`(3× `REAP_INTERVAL_MS`,下限 30 秒) | 持锁登记多久没续心跳就算"持有者失联"(此时可被 `dsh_takeover` 自动抢占);改回收间隔会改变推导出的默认值 |
601
+ | `DSH_MCP_IDLE_TTL_MS` | `600000`(10 分钟) | 会话空闲多久回收其 DSH 进程。**默认 10 分钟**:回收后内存释放,下次派活自动 resume(对话不丢),代价是写锁会空出来、GUI 可能拿走。设 `0` = 永不回收(一直握锁,代价是每会话常驻 300–480MB);需要长期握锁的个别会话用 `dsh_start(keep_alive=true)` |
602
+ | `DSH_MCP_MAX_LIVE` | `4`(最坏约 1.2–1.9GB) | ★ **最多同时保活几个会话进程**(`0` = 不限)。超标时回收**最久没用过**的(**在跑的回合绝不回收**)。`keep_alive=true` 的会话不受本上限约束。用完想立刻放掉空闲会话:`dsh_release(all=true)`(busy 回合默认跳过,`force=true` 才中断) |
603
+ | `DSH_MCP_MAX_RSS_MB` | `0`(关闭) | 全局子进程内存预算(MB)。实际 RSS 超过预算时按"最久未用"回收空闲会话,`keep_alive` 会话最后才动。按实际内存而不是会话数,适合上下文大小差异大的场景 |
604
+ | `DSH_MCP_CHILD_MAX_OLD_SPACE_MB` | `0`(不设) | 给每个 DSH 子进程追加 `--max-old-space-size`,兜底失控的上下文。设太小会让长会话 OOM |
605
+ | `DSH_MCP_SHARED_PROCESS` | `0`(关闭) | **实验性**:同 `(cwd, permission)` 的会话共享一个 ACP 进程。显著省内存与冷启动(实测第二个会话 `session/new` 约 300ms,独立进程约 4s)。代价:进程崩溃会连坐同组会话(会统一写 error 哨兵,之后各自 resume) |
606
+ | `DSH_MCP_AUDIT_FILE` | 空(关闭) | 元数据审计 JSONL 路径。只记事件、会话/回合 ID、决定与计数;**不记** prompt、正文、思考、工具输入。10MB 滚动到 `.1` |
551
607
  | `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | `dsh_list` 磁盘探测结果的缓存时长(探测要 spawn 一个 DSH 进程,实测约 1 秒) |
552
608
  | `DSH_MCP_SENTINEL_TTL_MS` | `604800000`(7 天) | 启动时清理超过此时长的哨兵;`0` 关闭清理 |
553
- | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"的保证**) |
609
+ | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"的保证**);answer/error/title/工具输入仍会脱敏 |
554
610
  | `DSH_MCP_PERMISSION` | `danger-full-access` | `dsh_start` 未指定时的权限档 |
555
611
  | `DSH_MCP_REASONING_EFFORT` | `max` | 默认思考深度 |
556
612
  | `DSH_MCP_PROMPT_TIMEOUT_MS` | `0`(不设超时) | **仅 `wait=true` 时**的等待上限;到期只转后台,不取消回合 |
@@ -583,19 +639,24 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
583
639
  | `async` | 16 | fire-and-forget + 事后验收 |
584
640
  | `sentinel` | 36 | 完成哨兵:原子性、闩锁语义、多会话并发不串台、被取消也落地、**思考不进文件** |
585
641
  | `prune` | 11 | **哨兵留存**:清超期与崩溃残留、留新、清空壳目录、`TTL=0` 关闭清理、且绝不碰同目录的会话注册表(不花 token,每次必跑) |
586
- | `guards` | 31 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1`/非法值都当"永不回收"(绝不主动让出写锁)、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合)(不花 token,每次必跑) |
587
- | `sandbox-guard` | 18 | **测试沙箱自身**:「临时 HOME」如果落到真实用户目录、它的子目录、盘符根目录,或 OS 临时目录之外,一律报错拒绝,绝不被写入和删除。其余套件都通过 `test/sandbox-home.mjs` 拿目录、只销毁自己建的那个(不花 token,每次必跑) |
642
+ | `guards` | 36 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1` 当"永不回收"、非法值/空串回落默认 10 分钟、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合)(不花 token,每次必跑) |
643
+ | `sandbox-guard` | 21 | **测试沙箱自身**:「临时 HOME」如果落到真实用户目录、它的子目录、盘符根目录,或 OS 临时目录之外,一律报错拒绝,绝不被写入和删除。其余套件都通过 `test/sandbox-home.mjs` 拿目录、只销毁自己建的那个(不花 token,每次必跑) |
588
644
  | `approval-details` | 27 | **审批审查上下文**:待决审批的快照带命令、理由、cwd 与原始审批 ID —— 不只是一个裸 ID,调用方才能真正裁决;缺失的 `rawInput` 会按 ID 从先前的 `tool_call` 更新关联回来;凭据形状的字段与常见内联字面量在结构化上下文和展示标题里都会脱敏;关闭会话会取消待决审批。经真实 stdio MCP 端到端验证 |
645
+ | `failure-terminal` | 11 | **失败路径的终态**:`ensureAlive` 失败时唤醒 `dsh_interject` 等待者、落 `status=error` 哨兵、错误对象挂 `runId`;审批没有对应 option 时 `effective=cancelled` 而不是假报 allow;`dsh_get` 能从哨兵文件回读(不花 token) |
646
+ | `shared-host` | 15 | **共享进程宿主**:通知/审批按 `sessionId` 路由、未知会话审批一律取消、进程退出扇出所有挂载会话、最后一个会话 detach 才停进程;以及 RSS 读取(不花 token) |
647
+ | `shared-process` | 8(无 DSH 时 skip) | **共享进程真机验证**:同 `(cwd, permission)` 的两个会话落在同一个 PID、关一个不影响另一个、全部关闭后宿主清空且 PID 退出(需要本机 DSH,`--all` 时运行) |
648
+ | `dispatch-tools` | 31 | **派发面**:`dsh_inbox` 跨会话汇总待决审批、`client_run_id` 幂等命中与淘汰、队列 list/update/promote/remove(remove 写 cancelled 哨兵)、`dsh_get(max_chars)` 只截断 text 且 structured 保留全文、审计只记元数据不记正文、`dsh_doctor` 关键检查项(不花 token) |
589
649
  | `read-cursor` | 10 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
590
- | `sentinel-redaction` | 15 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
591
- | `forget-tombstone` | 7 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
650
+ | `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
651
+ | `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
652
+ | `forget-tombstone` | 10 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
592
653
  | `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
593
654
  | `workspace-safety` | 23 | **数据丢失边界**:`isTempPath` 按路径分段(`tmpdir()+'Suffix'`、`/tmpfoo` 都不算临时目录)、坏 `workspace.json` 绝不覆盖、会话根不可读时绝不清空 `sessionIds`、自动清理只删测试造的会话(不花 token) |
594
- | `permission-adopt` | 13 | **不无声提权**:接管没有权限记录的外部会话时 fail-closed 到 `read-only`;记录为 `read-only` 的会话不能被侧车提升;运行期回读记录与请求不一致会失败(不花 token) |
595
- | `state-safety` | 21 | **状态/哨兵/锁目录**:默认在 `$DSH_HOME/.dsh-any-agent`、不可写时启动明确失败、哨兵写失败返回 `null` + `sentinel_error`、非法数值落安全侧(不花 token) |
596
- | `setup` | 41 | **注册 CLI**:预览不写盘、`--remove` 只摘自己那条、没装客户端算跳过不算失败、Codex TOML 段往返、以及把写进配置里的命令真的拉起一次并要求应答 `initialize`(不花 token)。**不会调用 `claude mcp get`** —— 那个 CLI 会整体回写 `~/.claude.json` |
655
+ | `permission-adopt` | 17 | **不无声提权**:接管没有权限记录的外部会话时 fail-closed 到 `read-only`;记录为 `read-only` 的会话不能被侧车提升;运行期回读记录与请求不一致会失败(不花 token) |
656
+ | `state-safety` | 26 | **状态/哨兵/锁目录**:默认在 `$DSH_HOME/.dsh-any-agent`、不可写时启动明确失败、哨兵写失败返回 `null` + `sentinel_error`、非法数值落安全侧(不花 token) |
657
+ | `setup` | 45 | **注册 CLI**:预览不写盘、`--remove` 只摘自己那条、没装客户端算跳过不算失败、Codex TOML 段往返、以及把写进配置里的命令真的拉起一次并要求应答 `initialize`(不花 token)。**不会调用 `claude mcp get`** —— 那个 CLI 会整体回写 `~/.claude.json` |
597
658
  | `removal-scope` | 24 | **注销只动自己那条**:给一份装着别的 MCP 服务器、顶层 Codex 设置和 Zed 兄弟设置的配置,注册再注销,断言其余每个值逐字节不变;删一个从没写过的东西 = 无写入、无报错(不花 token) |
598
- | `config-safety` | 406 | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
659
+ | `config-safety` | 407 | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
599
660
  | `real-configs` | 84(7/9 配置时 66,本机 5/9 时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
600
661
  | `commit-script` | 26 | 提交信息走 stdin,绝不落临时文件让 `git add -A` 卷进发布提交(不花 token) |
601
662
  | `roots` | 12 | 不传 `cwd` 时通过 MCP roots 取客户端当前项目;不支持 roots 的客户端得到可操作的报错(不花 token) |
@@ -613,13 +674,13 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
613
674
  | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
614
675
  | `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
615
676
 
616
- **`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.8 上全部退出码 0;都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
677
+ **`node test/run.mjs` 在本机 5/9 客户端配置时跑 843 项(九种齐全时 879 项)** —— smoke 29 + 离线套件 814 项(九种齐全时 850 项)。0.3.9 上全部退出码 0;都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
617
678
 
618
- 其中 **519 项属于客户端注册器套件**(`setup` 41、`removal-scope` 24、`config-safety` 406、`real-configs` 48;九种齐全时 555 项)—— 0.3 的活其实都在这里。
679
+ 其中 **524 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 48;九种齐全时 560 项)—— 0.3 的活其实都在这里。
619
680
 
620
- 需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.8** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
681
+ 需要模型的套件(`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
682
 
622
- > 三个总数是刻意分开的:`node test/run.mjs`(815/851)、CI 离线 job(786/822,不含 smoke)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
683
+ > 三个总数是刻意分开的:`node test/run.mjs`(843/879)、CI 离线 job(名义 814/850,不含 smoke;hosted runner 没有客户端 fixture 时 real-configs 的 48 项会跳过)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
623
684
 
624
685
  ### 🧯 为什么会有 `sandbox-guard`
625
686
 
@@ -657,7 +718,7 @@ DSH 的会话有**跨进程写锁**:**持有者活着就永不过期,也没
657
718
 
658
719
  | 情形 | 结论 |
659
720
  |---|---|
660
- | **本服务持有锁**(默认) | ✅ 你在 GUI 里点开它是**只读**的:**内容照看**,但**抢不走锁**。默认 `DSH_MCP_IDLE_TTL_MS=0`(永不回收)保证锁不会空出来 |
721
+ | **本服务持有锁**(最近用过,或 `keep_alive=true`) | ✅ 你在 GUI 里点开它是**只读**的:**内容照看**,但**抢不走锁**。0.4.0 起默认空闲 **10 分钟**后才回收;`keep_alive=true` 的会话永不因空闲回收 |
661
722
  | **锁空着时你在 GUI 点开它** | ❌ **GUI 会永久持有它**(切走、等待、归档都不释放),本服务**再也接不回来**,只能重启 `dsh web` |
662
723
  | **推荐做法** | 看进度用 **`dsh_read`**(游标增量读)或等**哨兵文件**;非要用 GUI 就先 `dsh_release`(会中断在跑的回合,适合任务间隙) |
663
724
 
@@ -684,13 +745,15 @@ DSH 的会话有**跨进程写锁**:**持有者活着就永不过期,也没
684
745
  5. **首次成功回合之前就死掉的会话可能尚未落盘** —— 此时 resume 会报清晰错误。发出第一条消息后就安全了。
685
746
  6. **不能改名** —— DSH 的标题子系统没有对外改名接口(`SessionTitleService.rename` 需要进程内的活会话)。标题一律由首条消息自动生成。
686
747
  7. **`session/list` 只返回 `{sessionId, cwd}`,且排除已打开的会话** —— 对话名由本服务从 DSH 投影缓存补全。
687
- 8. **默认"永不回收"要花内存(实测 300–480MB/会话)** —— 换来的是"GUI 抢不走写锁"。两次冷启动实测单个活跃会话新增常驻内存分别为 476MB 和 295MB(含 DSH 子进程与本服务),冷进程首次偏高。两种省内存办法:① **`DSH_MCP_MAX_LIVE`(默认 8**,超标就回收最久没用过的 → 内存有界 ✓ 锁仍尽量在手 ✓);② 把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价是锁可能被 GUI 拿走)。一批活干完想立刻归零:**`dsh_release(all=true)`** ✓(默认只释放空闲会话,**正在跑的回合会跳过、继续跑**;要连它们一起停掉传 `force=true`。之后照样 resume)。
748
+ 8. **内存(实测 300–480MB/活跃会话)** —— 0.4.0 起默认 **空闲 10 分钟回收 + `MAX_LIVE=4`**,最坏约 1.2–1.9GB;`keep_alive=true` 的会话不因空闲被回收(代价是常驻)。还可以:① `DSH_MCP_MAX_RSS_MB` 按实际 RSS 预算回收;② `DSH_MCP_CHILD_MAX_OLD_SPACE_MB` 给子进程设堆上限;③ `DSH_MCP_SHARED_PROCESS=1` 让同 `(cwd, permission)` 的会话共享一个 ACP 进程(实验性,省内存也省冷启动,但崩溃会连坐同组)。一批活干完想立刻归零:**`dsh_release(all=true)`** ✓(默认只释放空闲会话,**正在跑的回合会跳过、继续跑**;要连它们一起停掉传 `force=true`。之后照样 resume)。
688
749
 
689
750
  ## ❓ 为什么 profile 还叫 `dsh-mcp`?
690
751
 
691
752
  因为改名会打断一堆已经落盘的东西。
692
753
 
693
- 本服务首次启动时会在 `~/.dsh/profiles/dsh-mcp/` 建一个 DSH profile。这个目录名被另外三样持久化标识引用着:
754
+ 本服务首次启动时会在 `~/.dsh/profiles/dsh-mcp/` 建一个 DSH profile。0.4.0 起团队模式默认改用 `dsh-team`(同样由本服务自动创建);`dsh-mcp` 仍用于团队关闭或既有部署。两者共用同一套 `DSH_MCP_*` 变量与 `# dsh-mcp:managed` 标记。
755
+
756
+ 这个目录名被另外三样持久化标识引用着:
694
757
 
695
758
  | 持久标识 | 位置 | 改名的后果 |
696
759
  |---|---|---|
@@ -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. External MCP tools mounted in DSH are outside DSH's
117
- sandbox; read `USAGE-NOTES.en.md` before using them with untrusted data.
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, write-lock
159
- measurements and website deployment notes, see `USAGE-MIGRATION.md`,
160
- `README.md` and `USAGE-NOTES.en.md`.
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`.
@@ -294,13 +294,13 @@ codex mcp list # 应看到 dsh-any-agent ... enabled
294
294
 
295
295
  ### 权限档怎么选
296
296
 
297
- `DSH_MCP_PERMISSION` 默认 `danger-full-access`(零摩擦)。建议按用途改:
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` | 分析不信任的样本(配合 `DSH_MCP_APPROVAL=auto-deny`) |
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 kernel 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).
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.** DSH can touch anything your account can. That default exists because, unattended, ACP's approval channel tends to degrade into silent failures. For untrusted material (malware, unknown dumps, unfamiliar repos) use `permission: "read-only"`.
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` means "deterministically reject", not "auto-allow".** It has no downside under `danger-full-access` only because the sandbox stops producing asks. To let the caller adjudicate escalation, use `workspace-write` + `on_approval: "ask"` + `dsh_approval_decide`.
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` / `diff_stat` tell you which files it touched.
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
- - **现象**:`.state/runs/<会话id>/` 下文件越攒越多,每个都含完整正文。
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
- - **对策**:不用管。想让它一直热着就调大 `DSH_MCP_IDLE_TTL_MS`,或者定期 `dsh_status`(会刷新 `lastUsedAt` 吗?不会——只有派活算使用。所以真要常驻就调 TTL)。
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
- - **原因**:锁是 Windows 命名内核信号量,持有者进程一死内核就释放 —— 这是**唯一**的夺锁手段。
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
- - **现象**:DSH 能碰你账户能碰的任何东西。
182
- - **原因**:默认 `danger-full-access` —— 这是为了零摩擦,因为**ACP 的审批通道在无人值守时很容易变成"静默失败"**。
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
- - **原因**:`never` = 每次询问都返回拒绝。它之所以在 `danger-full-access` 下没副作用,是因为沙箱根本不产生询问。
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. **要审查产出就用工作区变更**:返回值里的 `workspace_changed` / `diff_stat` 告诉你它动了哪些文件。
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) if (!byTarget.has(b.target)) byTarget.set(b.target, b);
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.8",
3
+ "version": "0.4.0",
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
@@ -11,7 +11,14 @@
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
13
  import { ensureProfile } from './bootstrap.mjs';
14
- import { DSH_PROFILE, PKG_VERSION, REQUEST_TIMEOUT_MS, resolveDshBin } from './config.mjs';
14
+ import {
15
+ CHILD_MAX_OLD_SPACE_MB,
16
+ DSH_PROFILE,
17
+ PKG_VERSION,
18
+ REQUEST_TIMEOUT_MS,
19
+ resolveDshBin,
20
+ } from './config.mjs';
21
+ import { LineDecoder } from './line-decoder.mjs';
15
22
 
16
23
  /** ACP 协议版本(@agentclientprotocol/sdk 的 v1)。 */
17
24
  const PROTOCOL_VERSION = 1;
@@ -25,12 +32,15 @@ export class AcpProcess {
25
32
  #child = null;
26
33
  #nextId = 1;
27
34
  #pending = new Map();
28
- #buf = '';
35
+ #lineDecoder = new LineDecoder();
36
+ #errDecoder = new LineDecoder();
29
37
  #stderrTail = [];
30
38
  #stopping = false;
31
39
  #log = NOOP_LOG;
32
40
  #onPermissionRequest;
33
41
  #onNotification;
42
+ #onExit;
43
+ #exitNotified = false;
34
44
 
35
45
  /**
36
46
  * @param {object} opts
@@ -40,15 +50,27 @@ export class AcpProcess {
40
50
  * @param {(params:object)=>Promise<object>} [opts.onPermissionRequest]
41
51
  * @param {(params:object)=>void} [opts.onNotification]
42
52
  */
43
- constructor({ cwd, permission, log, onPermissionRequest, onNotification }) {
53
+ constructor({ cwd, permission, log, onPermissionRequest, onNotification, onExit }) {
44
54
  this.cwd = cwd;
45
55
  this.permission = permission;
46
56
  if (log) this.#log = log;
47
57
  this.#onPermissionRequest = onPermissionRequest;
48
58
  this.#onNotification = onNotification;
59
+ this.#onExit = onExit;
49
60
  this.initialized = false;
50
61
  }
51
62
 
63
+ /** 非主动 stop 的退出通知(共享进程池靠它把同组会话标成失败并唤醒等待者)。 */
64
+ #notifyExit(reason) {
65
+ if (this.#exitNotified || this.#stopping) return;
66
+ this.#exitNotified = true;
67
+ try {
68
+ this.#onExit?.(reason);
69
+ } catch (e) {
70
+ this.#log.debug(`[acp] onExit 处理异常: ${e.message}`);
71
+ }
72
+ }
73
+
52
74
  get alive() {
53
75
  return this.#child !== null && this.#child.exitCode === null && !this.#stopping;
54
76
  }
@@ -76,9 +98,15 @@ export class AcpProcess {
76
98
  this.#log.debug(`[acp] profile 引导跳过: ${ready.skipped}`);
77
99
  }
78
100
  const bin = resolveDshBin();
101
+ const env = { ...process.env, DSH_PERMISSION_MODE: this.permission };
102
+ // 0.4.0:可选的子进程堆上限。只加参数、不覆盖用户已有的 NODE_OPTIONS。
103
+ if (CHILD_MAX_OLD_SPACE_MB > 0) {
104
+ const prev = String(env.NODE_OPTIONS ?? '').trim();
105
+ env.NODE_OPTIONS = `${prev} --max-old-space-size=${CHILD_MAX_OLD_SPACE_MB}`.trim();
106
+ }
79
107
  this.#child = spawn(process.execPath, [bin, '--profile', DSH_PROFILE], {
80
108
  cwd: this.cwd,
81
- env: { ...process.env, DSH_PERMISSION_MODE: this.permission },
109
+ env,
82
110
  stdio: ['pipe', 'pipe', 'pipe'],
83
111
  });
84
112
 
@@ -95,12 +123,13 @@ export class AcpProcess {
95
123
  this.#pending.clear();
96
124
  this.#child = null;
97
125
  this.initialized = false;
126
+ this.#notifyExit(`DSH stdio 不可用: ${e.message}`);
98
127
  };
99
128
  this.#child.stdin.on('error', streamError);
100
129
  this.#child.stdout.on('error', streamError);
101
130
  this.#child.stderr.on('error', streamError);
102
131
  this.#child.stderr.on('data', (d) => {
103
- for (const line of d.toString('utf8').split(/\r?\n/)) {
132
+ for (const line of this.#errDecoder.push(d)) {
104
133
  if (!line.trim()) continue;
105
134
  this.#stderrTail.push(line.trim());
106
135
  if (this.#stderrTail.length > STDERR_TAIL_LINES) this.#stderrTail.shift();
@@ -117,6 +146,7 @@ export class AcpProcess {
117
146
  this.#pending.clear();
118
147
  this.#child = null;
119
148
  this.initialized = false;
149
+ this.#notifyExit(`DSH 进程退出(code=${code})`);
120
150
  this.#log.debug(`[acp] 进程退出 code=${code}`);
121
151
  });
122
152
  this.#child.on('error', (e) => this.#log.info(`[acp] spawn 失败: ${e.message}`));
@@ -124,11 +154,7 @@ export class AcpProcess {
124
154
  }
125
155
 
126
156
  #onData(chunk) {
127
- this.#buf += chunk.toString('utf8');
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);
157
+ for (const line of this.#lineDecoder.push(chunk)) {
132
158
  if (!line.trim()) continue;
133
159
  let msg;
134
160
  try {
@@ -258,7 +284,7 @@ export class AcpProcess {
258
284
  /** 优雅收尾:close 由调用方负责,这里只关 stdin(DSH 绑定 stdin EOF → 有界退出)。 */
259
285
  async stop({ graceMs = 3000, killWaitMs = 5000 } = {}) {
260
286
  const child = this.#child;
261
- if (!child) return;
287
+ if (!child) return true;
262
288
  this.#stopping = true;
263
289
  try {
264
290
  child.stdin.end();
@@ -273,7 +299,7 @@ export class AcpProcess {
273
299
  });
274
300
  });
275
301
  // 1) 先给它体面退出的机会(stdin EOF → DSH 有界退出)
276
- if (await waitExit(graceMs)) return;
302
+ if (await waitExit(graceMs)) return true;
277
303
  // 2) 超时则强杀,**并且等它真的退出**。
278
304
  // ★ 旧实现 kill() 之后立刻 resolve —— 于是调用方会在"子进程还活着、还握着写锁"
279
305
  // 的时候继续往下走:删掉登记、再 spawn 新进程去 resume,就会撞上一个**无法归因的锁**
@@ -282,6 +308,6 @@ export class AcpProcess {
282
308
  try {
283
309
  child.kill();
284
310
  } catch {}
285
- await waitExit(killWaitMs);
311
+ return await waitExit(killWaitMs);
286
312
  }
287
313
  }
package/src/audit.mjs ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 元数据审计日志(可选,默认关闭)。
3
+ *
4
+ * 只记录"谁在什么时候对哪个会话/回合做了什么",**不记录** prompt、正文、思考、
5
+ * 工具输入 —— 项目的隐私红线不变。设 `DSH_MCP_AUDIT_FILE` 指向一个文件即可开启;
6
+ * 单文件超过 10 MB 时滚动成 `<file>.1`(只保留一份旧档,避免无限增长)。
7
+ *
8
+ * 审计写入失败绝不能影响主流程:所有异常吞掉。
9
+ */
10
+
11
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+
14
+ const MAX_BYTES = 10 * 1024 * 1024;
15
+
16
+ export function audit(event, data = {}) {
17
+ const file = process.env.DSH_MCP_AUDIT_FILE;
18
+ if (!file) return;
19
+ try {
20
+ mkdirSync(dirname(file), { recursive: true });
21
+ if (existsSync(file) && statSync(file).size > MAX_BYTES) {
22
+ renameSync(file, `${file}.1`);
23
+ }
24
+ appendFileSync(
25
+ file,
26
+ `${JSON.stringify({ ts: new Date().toISOString(), event, ...data })}\n`,
27
+ 'utf8',
28
+ );
29
+ } catch {
30
+ // 审计失败不影响派活
31
+ }
32
+ }