dsh-any-agent 0.3.9 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -1
- package/README.md +75 -634
- package/README.zh.md +64 -649
- package/bin/dsh-any-agent.mjs +59 -0
- package/demo/mcp-client.mjs +130 -0
- package/demo/run.mjs +326 -0
- package/demo/transcript.json +47 -0
- package/docs/brand/README.md +58 -0
- package/docs/brand/header.jpg +0 -0
- package/docs/en/architecture.md +43 -0
- package/docs/en/compatibility.md +51 -0
- package/docs/en/demo.md +90 -0
- package/docs/en/install.md +257 -0
- package/docs/en/limits.md +27 -0
- package/docs/en/locking.md +24 -0
- package/docs/en/reference.md +40 -0
- package/docs/en/release.md +33 -0
- package/docs/en/security.md +59 -0
- package/docs/en/team.md +74 -0
- package/docs/en/tests.md +96 -0
- package/docs/en/usage.md +71 -0
- package/docs/zh/architecture.md +43 -0
- package/docs/zh/compatibility.md +49 -0
- package/docs/zh/demo.md +90 -0
- package/docs/zh/install.md +274 -0
- package/docs/zh/limits.md +30 -0
- package/docs/zh/locking.md +24 -0
- package/docs/zh/reference.md +40 -0
- package/docs/zh/release.md +38 -0
- package/docs/zh/security.md +59 -0
- package/docs/zh/team.md +71 -0
- package/docs/zh/tests.md +95 -0
- package/docs/zh/usage.md +69 -0
- package/package.json +5 -1
- package/scripts/check-release.mjs +73 -0
- package/src/acp.mjs +31 -3
- package/src/audit.mjs +32 -0
- package/src/bootstrap.mjs +26 -1
- package/src/config.mjs +178 -9
- package/src/host.mjs +148 -0
- package/src/hub.mjs +494 -73
- package/src/procstat.mjs +72 -0
- package/src/tools.mjs +561 -27
package/docs/zh/tests.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
> 返回 [README](../../README.zh.md)
|
|
2
|
+
|
|
3
|
+
# 🧪 测试
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm test # 全部离线套件;没有 DSH 时 smoke 自动跳过会话段
|
|
7
|
+
npm run test:all # 离线 + 需要模型的套件(需要 DSH/provider)
|
|
8
|
+
node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目录;--dry-run 可预演)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
模型套件不在 GitHub 托管 CI 里(需要真实 DSH、provider 与凭据)。在本机跑,
|
|
12
|
+
或用 `model-suites` workflow 指定一台装好 DSH 的 self-hosted runner。
|
|
13
|
+
|
|
14
|
+
测试套件**会自己收尾**:`run.mjs` 结束时必定执行 `cleanup.mjs`,它只清理 cwd 在**测试自己造的目录**(`tmpdir()/dsh-any-agent-*` 或桌面 `dsh-any-agent-test-*`)里的会话与工作区登记。**用户在 `%TEMP%` 里自己的真实项目、以及你的状态目录都不会被碰**。要连桌面测试目录一起清,可显式跑 `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`。
|
|
15
|
+
|
|
16
|
+
| 套件 | 检查数 | 覆盖 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `smoke` | 29 | 握手、工具表、建会话、配置、注册表纯净性 |
|
|
19
|
+
| `boundary` | 46 | 协议边界(重复 initialize、脏行、未知方法)、参数校验(含 read-only + 宽松审批必须拒绝、未知 provider/model、失败创建不留下写锁登记)、未知 id、游标边界、生命周期幂等、unicode |
|
|
20
|
+
| `integration` | 30 | 跨进程 resume 与记忆、中断、两种插话 |
|
|
21
|
+
| `async` | 16 | fire-and-forget + 事后验收 |
|
|
22
|
+
| `sentinel` | 36 | 完成哨兵:原子性、闩锁语义、多会话并发不串台、被取消也落地、**思考不进文件** |
|
|
23
|
+
| `prune` | 11 | **哨兵留存**:清超期与崩溃残留、留新、清空壳目录、`TTL=0` 关闭清理、且绝不碰同目录的会话注册表(不花 token,每次必跑) |
|
|
24
|
+
| `guards` | 40 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1` 当"永不回收"、非法值/空串回落默认 10 分钟、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合);以及团队模式的默认值与优先级(`DSH_MCP_TEAM` > `settings.json` > 默认关)(不花 token,每次必跑) |
|
|
25
|
+
| `sandbox-guard` | 21 | **测试沙箱自身**:「临时 HOME」如果落到真实用户目录、它的子目录、盘符根目录,或 OS 临时目录之外,一律报错拒绝,绝不被写入和删除。其余套件都通过 `test/sandbox-home.mjs` 拿目录、只销毁自己建的那个(不花 token,每次必跑) |
|
|
26
|
+
| `approval-details` | 27 | **审批审查上下文**:待决审批的快照带命令、理由、cwd 与原始审批 ID —— 不只是一个裸 ID,调用方才能真正裁决;缺失的 `rawInput` 会按 ID 从先前的 `tool_call` 更新关联回来;凭据形状的字段与常见内联字面量在结构化上下文和展示标题里都会脱敏;关闭会话会取消待决审批。经真实 stdio MCP 端到端验证 |
|
|
27
|
+
| `failure-terminal` | 11 | **失败路径的终态**:`ensureAlive` 失败时唤醒 `dsh_interject` 等待者、落 `status=error` 哨兵、错误对象挂 `runId`;审批没有对应 option 时 `effective=cancelled` 而不是假报 allow;`dsh_get` 能从哨兵文件回读(不花 token) |
|
|
28
|
+
| `shared-host` | 25 | **共享进程宿主**:通知/审批按 `sessionId` 路由、未知会话审批一律取消、进程退出扇出所有挂载会话、最后一个会话 detach 才停进程;**默认开启且 `=0` 可关闭**(空串按未设置处理);`takeover` 杀共享进程前的连坐检查;以及 RSS 读取(不花 token) |
|
|
29
|
+
| `shared-process` | 8(无 DSH 时 skip) | **共享进程真机验证**:同 `(cwd, permission)` 的两个会话落在同一个 PID、关一个不影响另一个、全部关闭后宿主清空且 PID 退出(需要本机 DSH,`--all` 时运行) |
|
|
30
|
+
| `dispatch-tools` | 31 | **派发面**:`dsh_inbox` 跨会话汇总待决审批、`client_run_id` 幂等命中与淘汰、队列 list/update/promote/remove(remove 写 cancelled 哨兵)、`dsh_get(max_chars)` 只截断 text 且 structured 保留全文、审计只记元数据不记正文、`dsh_doctor` 关键检查项(不花 token) |
|
|
31
|
+
| `read-cursor` | 10 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
|
|
32
|
+
| `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
|
|
33
|
+
| `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
|
|
34
|
+
| `forget-tombstone` | 10 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
|
|
35
|
+
| `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
|
|
36
|
+
| `workspace-safety` | 23 | **数据丢失边界**:`isTempPath` 按路径分段(`tmpdir()+'Suffix'`、`/tmpfoo` 都不算临时目录)、坏 `workspace.json` 绝不覆盖、会话根不可读时绝不清空 `sessionIds`、自动清理只删测试造的会话(不花 token) |
|
|
37
|
+
| `permission-adopt` | 17 | **不无声提权**:接管没有权限记录的外部会话时 fail-closed 到 `read-only`;记录为 `read-only` 的会话不能被侧车提升;运行期回读记录与请求不一致会失败(不花 token) |
|
|
38
|
+
| `state-safety` | 26 | **状态/哨兵/锁目录**:默认在 `$DSH_HOME/.dsh-any-agent`、不可写时启动明确失败、哨兵写失败返回 `null` + `sentinel_error`、非法数值落安全侧(不花 token) |
|
|
39
|
+
| `setup` | 45 | **注册 CLI**:预览不写盘、`--remove` 只摘自己那条、没装客户端算跳过不算失败、Codex TOML 段往返、以及把写进配置里的命令真的拉起一次并要求应答 `initialize`(不花 token)。**不会调用 `claude mcp get`** —— 那个 CLI 会整体回写 `~/.claude.json` |
|
|
40
|
+
| `removal-scope` | 24 | **注销只动自己那条**:给一份装着别的 MCP 服务器、顶层 Codex 设置和 Zed 兄弟设置的配置,注册再注销,断言其余每个值逐字节不变;删一个从没写过的东西 = 无写入、无报错(不花 token) |
|
|
41
|
+
| `config-safety` | 407 | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
|
|
42
|
+
| `real-configs` | 84(7/9 配置时 66,本机 5/9 时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
|
|
43
|
+
| `commit-script` | 26 | 提交信息走 stdin,绝不落临时文件让 `git add -A` 卷进发布提交(不花 token) |
|
|
44
|
+
| `roots` | 12 | 不传 `cwd` 时通过 MCP roots 取客户端当前项目;不支持 roots 的客户端得到可操作的报错(不花 token) |
|
|
45
|
+
| `workspace-titles` | 15 | 家目录当工作区会被提前拦住;`title_hint` 与 DSH 自动标题分开返回(不花 token) |
|
|
46
|
+
| `package-check` | 14 | `files` 白名单、零运行时依赖、Node 下限、bin shebang(不花 token) |
|
|
47
|
+
| `timeout` | 38 | **超时语义**:`wait=false` 不受 `timeout_ms` 影响、`wait=true` 到期只转后台(不取消/不丢结果、`busy` 不说谎)、`timeout_ms<=0` 一直等、resume 失败会作废进程而不永久卡死、空 prompt 给清晰错误 |
|
|
48
|
+
| `lock` | 79 | **写锁与抢占**:四类持有者判定、`writeMarker` 不覆盖活着的持有者、登记文件损坏/目录缺失等边界;**端到端**两实例抢锁 → 清晰报错 → 拒绝 → `force` 夺锁;**崩溃自动释放锁**、**卡死持有者无 force 自动抢占**;以及"一个回合 = 一个哨兵"的不变量(含内联回合与空闲插话) |
|
|
49
|
+
| `cycle` | 36 | **异步派活的生命周期**:异步派活 → 收哨兵 → 空闲回收 → 再派活,连做 3 轮必须全程无锁错误;贴着回收窗口立刻重派(放大竞态);`dsh_release` 后立刻重派;并断言回收后**不留下无法归因的锁**(这条实测抓到过"刚拉起的进程被 reaper 当空闲收掉"的竞态) |
|
|
50
|
+
| `concurrency` | 31 | 三会话并发 + 实时增量读 |
|
|
51
|
+
| `capability` | 22 | 写代码、跑脚本、**自行派子代理**(用磁盘上的子会话头验证) |
|
|
52
|
+
| `workspace-effect` | 24 | 不给路径时工作区是否真的生效 |
|
|
53
|
+
| `acceptance` | 36 | 两个文件夹 × 两个会话做只读 IDA Pro 分析 |
|
|
54
|
+
| `multi` | 19 | **多实例共存 + 非 ASCII 路径**:复现"两实例共写注册表 → 后写的把先建的会话抹掉",并验证**仅凭 id 就能从会话存储找回并真的派活**(指注册表丢失,不是显式 `forget` —— forget 是终态墓碑);**兜底取回不会悄悄提权**(read-only 会话取回后仍是 read-only);不留 `.tmp` 残渣;**中文 / emoji 工作区路径**能建会话、能干活、文件落在正确目录 |
|
|
55
|
+
| `permission` | 11 | ★ **权限档是否真的生效**(安全属性):不看我们的返回值(那正是会说谎的地方),而是读**会话自己记录的事实**(投影缓存里的 `permissions.preset` / `sandboxMode`),三档逐一核对且三档记录值互不相同。**这条测试抓到过无声提权**:profile 里 `defaultPreset` 会在会话创建时盖掉 `DSH_PERMISSION_MODE` |
|
|
56
|
+
| `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
|
|
57
|
+
| `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
|
|
58
|
+
|
|
59
|
+
**`node test/run.mjs` 在本机 7/9 客户端配置时跑 937 项(九种齐全时 955 项)** —— smoke 29 + 离线套件 908 项(九种齐全时 926 项)。**0.5.0 上全部退出码 0**(2026-11-03,Windows / Node 24.21.0);都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
|
|
60
|
+
|
|
61
|
+
其中 **542 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 66;九种齐全时 560 项)—— 0.3 的活其实都在这里。
|
|
62
|
+
|
|
63
|
+
需要模型的套件(`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**。
|
|
64
|
+
|
|
65
|
+
> 三个总数是刻意分开的:`node test/run.mjs`(937/955)、CI 离线 job(名义 908/926,不含 smoke;hosted runner 没有客户端 fixture 时 `real-configs` 会整体跳过)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
|
|
66
|
+
|
|
67
|
+
### 🧯 为什么会有 `sandbox-guard`
|
|
68
|
+
|
|
69
|
+
2026-10-29 的一次性验证脚本把沙箱路径存进了 **PowerShell** 的 `$home`。`$home` 是只读自动变量,赋值静默失败(脚本设了 `$ErrorActionPreference = 'Continue'`),`$env:USERPROFILE` 照样被改成了那个假路径,于是收尾那句 `Remove-Item $home -Recurse -Force` 落在**真实用户目录**上。那次运行在 120 秒超时被杀在半路,删掉了 `~/.codex`、`~/.cursor`、`~/.claude` 的一部分。
|
|
70
|
+
|
|
71
|
+
此前所有套件对**写入方**的断言都是真的,缺的是对**沙箱本身**的断言。`sandbox-guard` 就是这条断言:真实 HOME、它的子目录、大小写变体、盘符根目录、`os.tmpdir()` 之外的位置,全部拒绝。现在 `test/sandbox-home.mjs` 是唯一创建与销毁沙箱的地方,`setup`/`removal-scope` 自己不再调用 `mkdtempSync` 或 `rmSync` —— 一旦退回旧写法,套件立刻失败。
|
|
72
|
+
|
|
73
|
+
### 📏 本文档里的数字必须可复现
|
|
74
|
+
|
|
75
|
+
这里的每一个数字,要么是**测试打印出来的**,要么是**你用一条命令就能重跑出来的**。要改数字,就改成实测值 —— 不是估算,更不是一个看起来合理的值。
|
|
76
|
+
|
|
77
|
+
单列这一节,是因为这条规矩被违反过。早期版本的 README 声称 `dsh_send(wait=false)` "在 ~1ms 内返回"(实测 81ms)、打断收敛 "~18ms"(没有任何测试产出过这个数 —— `integration.mjs` 只断言 `waited_ms` 落在 0–25 秒之间;人工实测四次为 91/102/105/119ms)、每会话占用 "~120MB"(实测 295–476MB)—— 最后这个还在同一句话里自相矛盾,同时写着 "每个 120MB" 和 "3 个 = 428MB"。
|
|
78
|
+
|
|
79
|
+
**三档,以及各自该怎么写:**
|
|
80
|
+
|
|
81
|
+
| 档位 | 含义 | 写法 |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| **测试断言** | 有套件在断言它 | 直接给数字,套件就是权威 |
|
|
84
|
+
| **实测** | 你跑过命令 | 给数字 **和测法**,让别人能复现 |
|
|
85
|
+
| **估算** | 推导或上界 | 明确写出来("约"、"最坏"、"≈"),并给出算法 |
|
|
86
|
+
|
|
87
|
+
**把测法写在同一处。** 内存那两个数字就是范例 —— 它写清了工具、前后对比、以及两次采样:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
实测 300-480MB / 会话
|
|
91
|
+
(Get-Process node: 在单个活跃会话跑到 end_turn 前后分别求和 WorkingSet64;
|
|
92
|
+
两次冷启动分别得到 476MB 和 295MB)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**你的测量和文档不符,就说出来。** 数字依赖机器、DSH 版本和负载。实测落在文档区间之外,值得开个 issue —— 区间是一个主张,不是装饰。
|
package/docs/zh/usage.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
> 返回 [README](../../README.zh.md)
|
|
2
|
+
|
|
3
|
+
## 📋 用法:先派活,回头再验收
|
|
4
|
+
|
|
5
|
+
MCP 工具调用是**阻塞**的,所以 `dsh_send` **默认就不阻塞**(`wait=false`):派完立刻拿到收据,回合在后台跑,跑完由哨兵文件唤醒你。整个流程是这样:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
1. dsh_send(conversation_id, prompt, wait=false)
|
|
9
|
+
→ { run_id: "run-abc123", background: true } # 不等回合跑完就返回
|
|
10
|
+
|
|
11
|
+
2. …继续做你自己的事(实测:期间在另一个会话上跑完了一整个回合)…
|
|
12
|
+
|
|
13
|
+
3. dsh_status(conversation_id)
|
|
14
|
+
→ state=running | elapsed=3.5s | current_tool=pwsh | out=37 chars
|
|
15
|
+
|
|
16
|
+
4. dsh_read(conversation_id, cursor) # 中途偷看
|
|
17
|
+
→ [我] … | [轮次] 第 1 轮开始 | [工具] pwsh [in_progress]
|
|
18
|
+
|
|
19
|
+
5. dsh_get(conversation_id, run_id) # 验收
|
|
20
|
+
→ status=done, elapsed=20.3s, 完整答复
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`run_id` 是验收凭据。回合记录**只在内存、只留最近 20 条**;并发跑很多 run 时旧记录会被挤掉,`dsh_get` 就会失败——这正是下面"完成哨兵"自带结果的原因。
|
|
24
|
+
|
|
25
|
+
## 🔔 用法:跑完自动通知(不轮询)
|
|
26
|
+
|
|
27
|
+
`wait=false` 除了 `run_id`,还会返回 **`sentinel_file`**(哨兵文件路径)以及 **`sentinel_file_posix`**(Bash/MSYS 形式,**直接用它,别无手工转换**——转错了等待任务会一直挂到超时)。回合结束时——**无论成功、失败还是被取消**——本服务都会往这个路径**原子写出**一个含最终 `status` 与 `result` 的小文件。于是你不用轮询、也不用傻等:**用你自己宿主(Claude/Codex)的后台任务等这个文件出现即可,文件一到就会唤醒你。**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# sent="$sentinel_file_posix" ← 直接用工具返回里那个字段
|
|
31
|
+
sent='/c/Users/you/.dsh/.dsh-any-agent/runs/<conversation_id>/<run_id>.json'
|
|
32
|
+
dl=$(( $(date +%s) + 2100 )) # 35min 兜底,防服务中途死掉时无限挂
|
|
33
|
+
until [ -f "$sent" ]; do
|
|
34
|
+
[ "$(date +%s)" -ge "$dl" ] && { echo "TIMEOUT"; exit 1; }
|
|
35
|
+
sleep 2
|
|
36
|
+
done
|
|
37
|
+
echo "DONE" # 后台任务退出 → 宿主唤醒你 → 读文件收活
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
文件出现后:**直接读它**即得 `status` 和 `result`(推荐——不受内存窗口限制、还扛服务重启),或用 `dsh_get(conversation_id, run_id)` 验收。
|
|
41
|
+
|
|
42
|
+
设计要点(尤其**多会话并发**):
|
|
43
|
+
|
|
44
|
+
- **不串台**:哨兵路径为 `<状态目录>/runs/<conversation_id>/<run_id>.json`,按会话分目录。`run_id` 的序号是每会话独立计数的,不同会话在同一毫秒可能生成同名 `run_id`——分目录后各写各的,绝不互相覆盖。每个 run 起一个独立后台任务即可,各自完成、各自唤醒。
|
|
45
|
+
- **闩锁语义**:文件写一次并保留。即便 run 在你启动等待任务**之前**就已完成,`[ -f ]` 也立刻为真,不会漏。
|
|
46
|
+
- **原子落地**:`tmp` + `rename`,文件一旦存在内容必然完整;每个 run 用独立 tmp,并发写互不干扰。
|
|
47
|
+
- **无主动推送**:本服务在 MCP 协议上只应答请求、不发通知;"跑完通知"完全由这个哨兵文件 + 你的后台等待任务实现。
|
|
48
|
+
- **★ 哨兵里没有思考内容**:载荷含正文、思考**统计**、用量与(已脱敏的)工具输入,但没有思考正文;`result.thinking` 会被剥掉,并始终给出布尔 `thinking_omitted`(剥离了为 `true`,本来没有为 `false`)——本服务自己的落盘物里不写思考。`dsh_get(run_id)` 在内存里仍能看到它(记录还在时)。设 `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` 可以故意打破这条保证。注意:**DSH 自己的会话日志仍含思考**,见“日志与隐私”。
|
|
49
|
+
|
|
50
|
+
> 清理:哨兵内含完整正文,所以服务**启动时会清掉超过 7 天的哨兵**(用 `DSH_MCP_SENTINEL_TTL_MS` 调;`0` 关闭)。等待任务消费后仍建议自行 `rm`。
|
|
51
|
+
|
|
52
|
+
## 🧵 用法:同时控制多个会话
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
1. dsh_list(only_running=true)
|
|
56
|
+
- … | id=448b239b… | 🔵进行中(已跑9s, 工具=pwsh, 本轮输出=40字) | cwd=…
|
|
57
|
+
- … | id=33427096… | 🔵进行中(已跑8s, 本轮输出=34字) | cwd=…
|
|
58
|
+
2. dsh_read(id, cursor=0) # 读第一个,记下 cursor
|
|
59
|
+
3. dsh_read(id2, cursor=0) # 切到第二个
|
|
60
|
+
4. dsh_read(id, cursor=<上次>) # 切回来;上次之后被追加过的条目会再次出现(按 seq 替换全文)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 🤫 思考内容:默认隐藏
|
|
64
|
+
|
|
65
|
+
DSH 在模型层就把思考与正文分开,ACP 又把它们做成**两条独立流**。本服务默认隐藏思考内容,只返回**统计**(字数、耗时、段数)——调用方知道它想过,但不必为思考内容付上下文成本。
|
|
66
|
+
|
|
67
|
+
- `reasoning=hide`(默认)—— 丢弃内容,返回统计
|
|
68
|
+
- `reasoning=marker` / `summary` / `full` —— 逐步放出更多内容(注意上下文预算)
|
|
69
|
+
- `dsh_read(include_reasoning=true)` —— 只返回**当前正在跑那一轮**的实时思考;不进缓冲,回合结束即丢弃
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"bin/",
|
|
32
32
|
"src/",
|
|
33
|
+
"demo/",
|
|
34
|
+
"docs/",
|
|
33
35
|
"profile-example/",
|
|
34
36
|
"README.md",
|
|
35
37
|
"README.zh.md",
|
|
@@ -45,11 +47,13 @@
|
|
|
45
47
|
"scripts": {
|
|
46
48
|
"start": "node ./bin/dsh-any-agent.mjs",
|
|
47
49
|
"setup": "node ./bin/setup.mjs",
|
|
50
|
+
"demo": "node ./demo/run.mjs",
|
|
48
51
|
"test": "node ./test/run.mjs",
|
|
49
52
|
"test:all": "node ./test/run.mjs --all",
|
|
50
53
|
"test:smoke": "node ./test/smoke.mjs",
|
|
51
54
|
"test:guards": "node ./test/guards.mjs",
|
|
52
55
|
"test:prune": "node ./test/prune.mjs",
|
|
56
|
+
"check:release": "node ./scripts/check-release.mjs",
|
|
53
57
|
"commit": "node ./scripts/commit.mjs"
|
|
54
58
|
},
|
|
55
59
|
"engines": {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release gate: the package version, CHANGELOG and docs must agree.
|
|
3
|
+
*
|
|
4
|
+
* Catches the two drifts that actually happened in this repo:
|
|
5
|
+
* - publishing a version whose CHANGELOG section was never written;
|
|
6
|
+
* - splitting docs and leaving relative links that 404 on GitHub.
|
|
7
|
+
*
|
|
8
|
+
* No network, no DSH, no tokens. Run before `npm publish`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = path.resolve(HERE, '..');
|
|
17
|
+
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
|
18
|
+
|
|
19
|
+
let failed = 0;
|
|
20
|
+
const ok = (msg) => console.log(` ✓ ${msg}`);
|
|
21
|
+
const bad = (msg) => {
|
|
22
|
+
failed += 1;
|
|
23
|
+
console.log(` ✗ ${msg}`);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const pkg = JSON.parse(read('package.json'));
|
|
27
|
+
const version = pkg.version;
|
|
28
|
+
const changelog = read('CHANGELOG.md');
|
|
29
|
+
|
|
30
|
+
console.log(`\nrelease check: dsh-any-agent ${version}`);
|
|
31
|
+
|
|
32
|
+
if (changelog.includes(`## [${version}]`)) ok(`CHANGELOG has a ${version} section`);
|
|
33
|
+
else bad(`CHANGELOG.md is missing "## [${version}]"`);
|
|
34
|
+
|
|
35
|
+
if (changelog.includes(`## [${version}]`) && version !== '0.0.0') {
|
|
36
|
+
const section = changelog.split(`## [${version}]`)[1] ?? '';
|
|
37
|
+
if (section.trim().length > 0) ok('CHANGELOG section is not empty');
|
|
38
|
+
else bad(`CHANGELOG section for ${version} is empty`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const docFiles = [
|
|
42
|
+
'README.zh.md',
|
|
43
|
+
'README.md',
|
|
44
|
+
...fs.readdirSync(path.join(ROOT, 'docs/zh')).map((f) => `docs/zh/${f}`),
|
|
45
|
+
...fs.readdirSync(path.join(ROOT, 'docs/en')).map((f) => `docs/en/${f}`),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
let links = 0;
|
|
49
|
+
for (const file of docFiles) {
|
|
50
|
+
const text = read(file);
|
|
51
|
+
for (const m of text.matchAll(/\]\(([^)]+)\)/g)) {
|
|
52
|
+
const link = m[1];
|
|
53
|
+
if (/^(https?:|#|mailto:)/.test(link)) continue;
|
|
54
|
+
const [target] = link.split('#');
|
|
55
|
+
if (!target) continue;
|
|
56
|
+
links += 1;
|
|
57
|
+
if (!fs.existsSync(path.resolve(ROOT, path.dirname(file), target))) {
|
|
58
|
+
bad(`${file}: missing link target ${link}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (links > 0) ok(`${links} relative links resolve`);
|
|
63
|
+
|
|
64
|
+
for (const required of ['docs/zh/team.md', 'docs/en/team.md', 'docs/zh/compatibility.md', 'docs/en/compatibility.md']) {
|
|
65
|
+
if (fs.existsSync(path.join(ROOT, required))) ok(`${required} exists`);
|
|
66
|
+
else bad(`${required} is missing`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pkg.files?.includes('docs/')) ok('package.json ships docs/');
|
|
70
|
+
else bad('package.json "files" does not include docs/');
|
|
71
|
+
|
|
72
|
+
console.log(`\n${failed === 0 ? 'release check: 通过 ✅' : `release check: ${failed} 项失败 ❌`}`);
|
|
73
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/src/acp.mjs
CHANGED
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { ensureProfile } from './bootstrap.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CHILD_MAX_OLD_SPACE_MB,
|
|
16
|
+
DSH_PROFILE,
|
|
17
|
+
PKG_VERSION,
|
|
18
|
+
REQUEST_TIMEOUT_MS,
|
|
19
|
+
resolveDshBin,
|
|
20
|
+
} from './config.mjs';
|
|
15
21
|
import { LineDecoder } from './line-decoder.mjs';
|
|
16
22
|
|
|
17
23
|
/** ACP 协议版本(@agentclientprotocol/sdk 的 v1)。 */
|
|
@@ -33,6 +39,8 @@ export class AcpProcess {
|
|
|
33
39
|
#log = NOOP_LOG;
|
|
34
40
|
#onPermissionRequest;
|
|
35
41
|
#onNotification;
|
|
42
|
+
#onExit;
|
|
43
|
+
#exitNotified = false;
|
|
36
44
|
|
|
37
45
|
/**
|
|
38
46
|
* @param {object} opts
|
|
@@ -42,15 +50,27 @@ export class AcpProcess {
|
|
|
42
50
|
* @param {(params:object)=>Promise<object>} [opts.onPermissionRequest]
|
|
43
51
|
* @param {(params:object)=>void} [opts.onNotification]
|
|
44
52
|
*/
|
|
45
|
-
constructor({ cwd, permission, log, onPermissionRequest, onNotification }) {
|
|
53
|
+
constructor({ cwd, permission, log, onPermissionRequest, onNotification, onExit }) {
|
|
46
54
|
this.cwd = cwd;
|
|
47
55
|
this.permission = permission;
|
|
48
56
|
if (log) this.#log = log;
|
|
49
57
|
this.#onPermissionRequest = onPermissionRequest;
|
|
50
58
|
this.#onNotification = onNotification;
|
|
59
|
+
this.#onExit = onExit;
|
|
51
60
|
this.initialized = false;
|
|
52
61
|
}
|
|
53
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
|
+
|
|
54
74
|
get alive() {
|
|
55
75
|
return this.#child !== null && this.#child.exitCode === null && !this.#stopping;
|
|
56
76
|
}
|
|
@@ -78,9 +98,15 @@ export class AcpProcess {
|
|
|
78
98
|
this.#log.debug(`[acp] profile 引导跳过: ${ready.skipped}`);
|
|
79
99
|
}
|
|
80
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
|
+
}
|
|
81
107
|
this.#child = spawn(process.execPath, [bin, '--profile', DSH_PROFILE], {
|
|
82
108
|
cwd: this.cwd,
|
|
83
|
-
env
|
|
109
|
+
env,
|
|
84
110
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
111
|
});
|
|
86
112
|
|
|
@@ -97,6 +123,7 @@ export class AcpProcess {
|
|
|
97
123
|
this.#pending.clear();
|
|
98
124
|
this.#child = null;
|
|
99
125
|
this.initialized = false;
|
|
126
|
+
this.#notifyExit(`DSH stdio 不可用: ${e.message}`);
|
|
100
127
|
};
|
|
101
128
|
this.#child.stdin.on('error', streamError);
|
|
102
129
|
this.#child.stdout.on('error', streamError);
|
|
@@ -119,6 +146,7 @@ export class AcpProcess {
|
|
|
119
146
|
this.#pending.clear();
|
|
120
147
|
this.#child = null;
|
|
121
148
|
this.initialized = false;
|
|
149
|
+
this.#notifyExit(`DSH 进程退出(code=${code})`);
|
|
122
150
|
this.#log.debug(`[acp] 进程退出 code=${code}`);
|
|
123
151
|
});
|
|
124
152
|
this.#child.on('error', (e) => this.#log.info(`[acp] spawn 失败: ${e.message}`));
|
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
|
+
}
|
package/src/bootstrap.mjs
CHANGED
|
@@ -23,7 +23,13 @@ import { createHash } from 'node:crypto';
|
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
BOOTSTRAP_ENABLED,
|
|
28
|
+
DSH_HOME,
|
|
29
|
+
DSH_PROFILE,
|
|
30
|
+
ROUTE_SOURCE_PROFILE,
|
|
31
|
+
TEAM_ACTIVE,
|
|
32
|
+
} from './config.mjs';
|
|
27
33
|
import { load as yamlLoad, dump as yamlDump } from './yaml-lite.mjs';
|
|
28
34
|
|
|
29
35
|
/** 与 dsh 内置 acp 模板一致的组合包列表。 */
|
|
@@ -279,6 +285,25 @@ function buildEntries(routing) {
|
|
|
279
285
|
},
|
|
280
286
|
},
|
|
281
287
|
});
|
|
288
|
+
|
|
289
|
+
// ★ 团队模式(默认开):官方 team profile 层会关掉旧的 subagent 工具,
|
|
290
|
+
// 换成 9 个 Agent Teams 工具。DSH < 0.1.7 时 TEAM_ACTIVE=false,不写这些行。
|
|
291
|
+
if (TEAM_ACTIVE) {
|
|
292
|
+
entries.push({ id: 'tool-subagent-control', disabled: true });
|
|
293
|
+
entries.push({ id: 'tool-subagent-list-agents', disabled: true });
|
|
294
|
+
entries.push({ id: 'tool-subagent', disabled: true });
|
|
295
|
+
entries.push({ id: 'tool-subagent-fork', disabled: true });
|
|
296
|
+
entries.push({
|
|
297
|
+
id: 'agent-team',
|
|
298
|
+
name: '@deepseek-ai/dsh-experimental-agent-team',
|
|
299
|
+
config: { maxMembers: 8 },
|
|
300
|
+
});
|
|
301
|
+
entries.push({
|
|
302
|
+
id: 'tool-agent-team',
|
|
303
|
+
name: '@deepseek-ai/dsh-experimental-tool-agent-team',
|
|
304
|
+
config: { freshProvider: 'spawn', forkProvider: 'fork' },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
282
307
|
return entries;
|
|
283
308
|
}
|
|
284
309
|
|