dsh-any-agent 0.4.0 → 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 +39 -0
- package/README.md +45 -674
- package/README.zh.md +41 -688
- 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/config.mjs +61 -23
- package/src/host.mjs +1 -1
- package/src/hub.mjs +76 -5
- package/src/tools.mjs +23 -3
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/config.mjs
CHANGED
|
@@ -129,17 +129,45 @@ export function resolveDshBin() {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
|
-
*
|
|
132
|
+
* DSH 主目录。**默认就是本机用户自己的 ~/.dsh** —— 本服务不拥有独立的 home,
|
|
133
|
+
* 因此凭据(.credentials.yaml)、会话历史、工作区登记与 GUI 完全共用一份。
|
|
134
|
+
*
|
|
135
|
+
* 环境变量仍然保留,但只作高级用途(隔离测试、多账号):
|
|
136
|
+
* 一旦指向别的目录,就要自己准备那个目录下的凭据与 profile。
|
|
137
|
+
*/
|
|
138
|
+
export const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 团队开关(0.5.0 起默认关闭,按需开启)。
|
|
133
142
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* 单次关闭:`dsh_send(team=false)`。
|
|
143
|
+
* 优先级:`DSH_MCP_TEAM` 环境变量 > `<DSH_HOME>/.dsh-any-agent/settings.json`
|
|
144
|
+
* 的 `team` 字段 > 默认关闭。用 `dsh-any-agent team on|off` 写这个文件,
|
|
145
|
+
* 不必手改客户端配置。
|
|
138
146
|
*/
|
|
147
|
+
export const TEAM_SETTINGS_FILE =
|
|
148
|
+
process.env.DSH_MCP_SETTINGS ?? join(DSH_HOME, '.dsh-any-agent', 'settings.json');
|
|
149
|
+
|
|
150
|
+
function readTeamSetting() {
|
|
151
|
+
try {
|
|
152
|
+
const j = JSON.parse(readFileSync(TEAM_SETTINGS_FILE, 'utf8'));
|
|
153
|
+
return typeof j.team === 'boolean' ? j.team : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const TEAM_MODE_SOURCE = (() => {
|
|
160
|
+
const v = process.env.DSH_MCP_TEAM;
|
|
161
|
+
if (v !== undefined && String(v).trim() !== '') return 'env';
|
|
162
|
+
return readTeamSetting() === null ? 'default' : 'file';
|
|
163
|
+
})();
|
|
164
|
+
|
|
139
165
|
export const TEAM_MODE = (() => {
|
|
140
166
|
const v = process.env.DSH_MCP_TEAM;
|
|
141
|
-
if (v
|
|
142
|
-
|
|
167
|
+
if (v !== undefined && String(v).trim() !== '') {
|
|
168
|
+
return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
|
|
169
|
+
}
|
|
170
|
+
return readTeamSetting() ?? false;
|
|
143
171
|
})();
|
|
144
172
|
|
|
145
173
|
/** 运行中的 DSH 版本(读不到为 null)。 */
|
|
@@ -158,15 +186,6 @@ export const DSH_VERSION = (() => {
|
|
|
158
186
|
// 但 team 包正是随 0.1.7-rc.1 发布的。
|
|
159
187
|
const teamVersionOk = DSH_VERSION === null || cmpVersion(DSH_VERSION, '0.1.7-0') >= 0;
|
|
160
188
|
|
|
161
|
-
/**
|
|
162
|
-
* DSH 主目录。**默认就是本机用户自己的 ~/.dsh** —— 本服务不拥有独立的 home,
|
|
163
|
-
* 因此凭据(.credentials.yaml)、会话历史、工作区登记与 GUI 完全共用一份。
|
|
164
|
-
*
|
|
165
|
-
* 环境变量仍然保留,但只作高级用途(隔离测试、多账号):
|
|
166
|
-
* 一旦指向别的目录,就要自己准备那个目录下的凭据与 profile。
|
|
167
|
-
*/
|
|
168
|
-
export const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
169
|
-
|
|
170
189
|
/** 用于驱动 DSH 的 profile(由 bootstrap 在首启时按 acp 模板创建)。 */
|
|
171
190
|
export const DSH_PROFILE =
|
|
172
191
|
process.env.DSH_MCP_PROFILE ?? (TEAM_MODE && teamVersionOk ? 'dsh-team' : 'dsh-mcp');
|
|
@@ -391,13 +410,23 @@ export const CHILD_MAX_OLD_SPACE_MB =
|
|
|
391
410
|
Number.isFinite(rawChildHeap) && rawChildHeap > 0 ? Math.floor(rawChildHeap) : 0;
|
|
392
411
|
|
|
393
412
|
/**
|
|
394
|
-
*
|
|
413
|
+
* 同一 `(cwd, permission)` 的多个会话共享一个 ACP 进程(0.4.0 引入,**0.5.0 起默认开启**)。
|
|
414
|
+
*
|
|
415
|
+
* 为什么默认开:DSH 的 ACP 服务端支持一条连接上多会话,且每个 session 有独立 Agent。
|
|
416
|
+
* 实测(DSH 0.1.7):两个会话 → 一个 PID,第二个 `session/new` 约 300ms,独立进程约 4s;
|
|
417
|
+
* 内存也从"每会话 300-480MB"降到"每个 (cwd, 权限) 组一份"。
|
|
418
|
+
*
|
|
419
|
+
* 为什么仍然分组:`sandbox-policy.workspaceRoot` / `mode` 与 `approval.policy` 在
|
|
420
|
+
* `dsh-base` 里是**进程级**配置,不同工作区/权限档混进一个进程会让沙箱边界失真 ——
|
|
421
|
+
* 那两种情况仍各起一个进程(硬约束,不是实现偷懒)。
|
|
422
|
+
*
|
|
423
|
+
* 代价(已写进文档,别当它不存在):共享进程崩溃会连坐同组的**所有**会话。崩溃时统一写
|
|
424
|
+
* error 哨兵并唤醒等待者,下次派活各自 resume —— 会话本体无损,但进行中的回合会断。
|
|
425
|
+
* 所以 `dsh_takeover` 在杀一个还挂着其它会话的共享进程前,会要求显式 `force=true`。
|
|
395
426
|
*
|
|
396
|
-
*
|
|
397
|
-
* 但 `sandbox-policy.workspaceRoot` / `mode` 与 `approval.policy` 都是**进程级**配置,
|
|
398
|
-
* 所以只能按 `(cwd, permission)` 分组共享。默认关闭:共享进程的崩溃会连坐同组会话。
|
|
427
|
+
* 设 `DSH_MCP_SHARED_PROCESS=0` 回到"一会话一进程"(拿内存换隔离性)。
|
|
399
428
|
*/
|
|
400
|
-
export const SHARED_PROCESS = numEnv('DSH_MCP_SHARED_PROCESS',
|
|
429
|
+
export const SHARED_PROCESS = numEnv('DSH_MCP_SHARED_PROCESS', 1) === 1;
|
|
401
430
|
|
|
402
431
|
/**
|
|
403
432
|
* `dsh_list` 的"磁盘探测"结果缓存多久(毫秒)。
|
|
@@ -410,10 +439,19 @@ export const LIST_PROBE_TTL_MS = numEnv('DSH_MCP_LIST_PROBE_TTL_MS', 10_000);
|
|
|
410
439
|
|
|
411
440
|
/** 权限档 → 该档下的默认审批策略。 */
|
|
412
441
|
export const PERMISSION_TIERS = ['read-only', 'workspace-write', 'danger-full-access'];
|
|
413
|
-
/**
|
|
442
|
+
/**
|
|
443
|
+
* 新建会话的默认权限档。0.5.0 起默认 **workspace-write**(沙箱把写入限制在工作区内),
|
|
444
|
+
* 不再是 danger-full-access;空串/非法值也回落到它。
|
|
445
|
+
*
|
|
446
|
+
* `danger-full-access` 需要机器侧显式允许(`DSH_MCP_ALLOW_FULL_ACCESS=1`),
|
|
447
|
+
* 避免"任何 MCP 客户端都能驱动"的服务默认把整机写权限交出去。
|
|
448
|
+
*/
|
|
414
449
|
export const DEFAULT_PERMISSION = PERMISSION_TIERS.includes(process.env.DSH_MCP_PERMISSION)
|
|
415
450
|
? process.env.DSH_MCP_PERMISSION
|
|
416
|
-
: '
|
|
451
|
+
: 'workspace-write';
|
|
452
|
+
|
|
453
|
+
/** 是否允许 `permission=danger-full-access`(默认关)。 */
|
|
454
|
+
export const ALLOW_FULL_ACCESS = process.env.DSH_MCP_ALLOW_FULL_ACCESS === '1';
|
|
417
455
|
|
|
418
456
|
/**
|
|
419
457
|
* 审批处理策略:
|
package/src/host.mjs
CHANGED
package/src/hub.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
copyFileSync,
|
|
18
18
|
cpSync,
|
|
19
19
|
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
20
21
|
readFileSync,
|
|
21
22
|
writeFileSync,
|
|
22
23
|
existsSync,
|
|
@@ -29,6 +30,7 @@ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
|
29
30
|
import { homedir, tmpdir } from 'node:os';
|
|
30
31
|
import { spawnSync } from 'node:child_process';
|
|
31
32
|
import {
|
|
33
|
+
ALLOW_FULL_ACCESS,
|
|
32
34
|
APPROVAL_POLICIES,
|
|
33
35
|
DEFAULT_PERMISSION,
|
|
34
36
|
DEFAULT_REASONING_EFFORT,
|
|
@@ -641,7 +643,23 @@ export class Conversation {
|
|
|
641
643
|
return report;
|
|
642
644
|
}
|
|
643
645
|
|
|
644
|
-
// 3)
|
|
646
|
+
// 3) 共享进程:杀它会连坐同组会话,先把代价说清楚,别让对方在不知情下按下去。
|
|
647
|
+
// 共享进程池(0.5.0 默认开启)让一个 PID 载着多个会话,takeover 只看"谁握着锁"
|
|
648
|
+
// 会做出一个很贵的决定 —— 杀掉持有者 = 同组所有会话的进行中回合一起断。
|
|
649
|
+
const sharedVictims = this.#hub.hostVictimsOf(killTarget, this.id);
|
|
650
|
+
if (sharedVictims.length > 0) {
|
|
651
|
+
report.shared_sessions = sharedVictims;
|
|
652
|
+
if (!force) {
|
|
653
|
+
report.message =
|
|
654
|
+
`抢占被拒绝:PID ${killTarget} 是共享进程,上面还挂着 ${sharedVictims.length} 个其它会话` +
|
|
655
|
+
`(${sharedVictims.map((id) => id.slice(0, 8)).join(', ')})。\n` +
|
|
656
|
+
'杀掉它会连坐这些会话:各自进行中的回合会中断(会话本体无损,之后照常 resume)。\n' +
|
|
657
|
+
'确认可以接受就带 force=true 重试;不想连坐就先 dsh_release 那些会话,或等它们空闲。';
|
|
658
|
+
return report;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// 4) 杀 + 重试(内核在持有者进程退出时释放信号量)
|
|
645
663
|
const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
|
|
646
664
|
report.killed_pid = killTarget;
|
|
647
665
|
report.kill = kill;
|
|
@@ -661,7 +679,10 @@ export class Conversation {
|
|
|
661
679
|
report.ok = true;
|
|
662
680
|
report.message =
|
|
663
681
|
`已抢占:杀掉持有者 PID ${killTarget}(${holder.reason}),并成功接管会话。` +
|
|
664
|
-
`\n⚠️ 如果那个进程当时正在跑回合,那一轮的工作会中断(会话日志会记为 interrupted
|
|
682
|
+
`\n⚠️ 如果那个进程当时正在跑回合,那一轮的工作会中断(会话日志会记为 interrupted)。` +
|
|
683
|
+
(sharedVictims.length > 0
|
|
684
|
+
? `\n⚠️ 它是共享进程,另外 ${sharedVictims.length} 个会话也被一起断掉了(各自 resume 即可)。`
|
|
685
|
+
: '');
|
|
665
686
|
} catch (e) {
|
|
666
687
|
report.message = `杀掉 PID ${killTarget} 之后仍无法接管:${e.message}`;
|
|
667
688
|
}
|
|
@@ -1460,13 +1481,38 @@ export function formatReasoning(c, mode) {
|
|
|
1460
1481
|
*
|
|
1461
1482
|
* @returns {string|null} null 表示没问题,否则返回一段可直接抛出的说明。
|
|
1462
1483
|
*/
|
|
1484
|
+
/**
|
|
1485
|
+
* 当前文件系统是否大小写不敏感(缓存一次)。
|
|
1486
|
+
*
|
|
1487
|
+
* 不能按平台猜:GitHub 的 `macos-latest` 是**大小写敏感的 APFS**,而普通 macOS
|
|
1488
|
+
* 默认又不敏感;Windows 一定不敏感;Linux 一定敏感。用临时文件探针最稳。
|
|
1489
|
+
*/
|
|
1490
|
+
let caseInsensitiveCache = null;
|
|
1491
|
+
export function fsCaseInsensitive() {
|
|
1492
|
+
if (caseInsensitiveCache !== null) return caseInsensitiveCache;
|
|
1493
|
+
if (process.platform === 'win32') {
|
|
1494
|
+
caseInsensitiveCache = true;
|
|
1495
|
+
return true;
|
|
1496
|
+
}
|
|
1497
|
+
try {
|
|
1498
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-any-agent-case-'));
|
|
1499
|
+
writeFileSync(join(dir, 'probe'), '');
|
|
1500
|
+
caseInsensitiveCache = existsSync(join(dir, 'PROBE'));
|
|
1501
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1502
|
+
} catch {
|
|
1503
|
+
caseInsensitiveCache = false;
|
|
1504
|
+
}
|
|
1505
|
+
return caseInsensitiveCache;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1463
1508
|
export function homeWorkspaceProblem(cwd, permission) {
|
|
1464
1509
|
if (permission === 'read-only') return null;
|
|
1465
1510
|
const home = homedir();
|
|
1466
1511
|
if (!home) return null;
|
|
1512
|
+
const caseInsensitive = process.platform === 'win32' || fsCaseInsensitive();
|
|
1467
1513
|
const norm = (p) => {
|
|
1468
1514
|
const r = resolve(p);
|
|
1469
|
-
return
|
|
1515
|
+
return caseInsensitive ? r.toLowerCase() : r;
|
|
1470
1516
|
};
|
|
1471
1517
|
if (norm(cwd) !== norm(home)) return null;
|
|
1472
1518
|
|
|
@@ -1488,7 +1534,7 @@ export function homeWorkspaceProblem(cwd, permission) {
|
|
|
1488
1534
|
export class Hub {
|
|
1489
1535
|
constructor({ log } = {}) {
|
|
1490
1536
|
this.conversations = new Map();
|
|
1491
|
-
/** 共享 ACP 进程池:key = `${cwd}\0${permission}
|
|
1537
|
+
/** 共享 ACP 进程池:key = `${cwd}\0${permission}`(0.5.0 起默认启用,`DSH_MCP_SHARED_PROCESS=0` 关闭)。 */
|
|
1492
1538
|
this.hosts = new Map();
|
|
1493
1539
|
this.log = log ?? NOOP_LOG;
|
|
1494
1540
|
/** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
|
|
@@ -1816,6 +1862,23 @@ export class Hub {
|
|
|
1816
1862
|
}
|
|
1817
1863
|
}
|
|
1818
1864
|
|
|
1865
|
+
/**
|
|
1866
|
+
* 共享进程下,某个子进程 PID 上还挂着哪些**别的**会话。
|
|
1867
|
+
*
|
|
1868
|
+
* 用途只有一个:`takeover` 要杀这个 PID 之前,先看看会不会连坐 ——
|
|
1869
|
+
* 杀一个还载着三个会话的宿主,代价是那三个会话的进行中回合一起断。
|
|
1870
|
+
* 只有本服务自己的宿主池查得清这件事;别的实例的宿主编号外部,查不到就只能让调用方
|
|
1871
|
+
* 自己用 `force` 承担。
|
|
1872
|
+
*/
|
|
1873
|
+
hostVictimsOf(pid, exceptId) {
|
|
1874
|
+
if (!pid) return [];
|
|
1875
|
+
for (const host of this.hosts.values()) {
|
|
1876
|
+
if (host.pid !== pid) continue;
|
|
1877
|
+
return [...host.conversations.keys()].filter((id) => id !== exceptId);
|
|
1878
|
+
}
|
|
1879
|
+
return [];
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1819
1882
|
/** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
|
|
1820
1883
|
async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model, keepAlive = false } = {}) {
|
|
1821
1884
|
if (!cwd) {
|
|
@@ -1835,6 +1898,14 @@ export class Hub {
|
|
|
1835
1898
|
if (!PERMISSION_TIERS.includes(permission)) {
|
|
1836
1899
|
throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
|
|
1837
1900
|
}
|
|
1901
|
+
// ★ 0.5.0:danger-full-access 不再默认可用。它把整机写权限交给调用方,
|
|
1902
|
+
// 而本服务可能被任何 MCP 客户端拉起;必须由机器所有者显式放行。
|
|
1903
|
+
if (permission === 'danger-full-access' && !ALLOW_FULL_ACCESS) {
|
|
1904
|
+
throw new Error(
|
|
1905
|
+
'permission=danger-full-access 需要机器侧显式允许:设置 DSH_MCP_ALLOW_FULL_ACCESS=1 后重试。' +
|
|
1906
|
+
' 默认请用 workspace-write(写入限制在工作区内)或 read-only。',
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1838
1909
|
const policy = onApproval ?? defaultApprovalPolicy(permission);
|
|
1839
1910
|
if (!APPROVAL_POLICIES.includes(policy)) {
|
|
1840
1911
|
throw new Error(`on_approval 必须是 ${APPROVAL_POLICIES.join(' | ')} 之一`);
|
|
@@ -2016,7 +2087,7 @@ export class Hub {
|
|
|
2016
2087
|
|
|
2017
2088
|
/** 空闲回收:停掉闲置进程,会话本身保留(可 resume 复活)。
|
|
2018
2089
|
*
|
|
2019
|
-
* ★ `DSH_MCP_IDLE_TTL_MS=0
|
|
2090
|
+
* ★ `DSH_MCP_IDLE_TTL_MS=0` = **永不回收**(0.4.0 起默认是 10 分钟,不再是默认值)。
|
|
2020
2091
|
* 理由(实测,见 USAGE-NOTES §3.10~3.12):空闲回收是写锁**唯一**会变空的时刻,
|
|
2021
2092
|
* 而只要锁一空,你在 web 里点开那条会话,`dsh web` 就会**永久**持有它
|
|
2022
2093
|
* (切走 ✗ 等待 ✗ 归档 ✗ 都不释放),本服务之后每一次 resume 都失败 ——
|
package/src/tools.mjs
CHANGED
|
@@ -562,7 +562,7 @@ export function createHandlers(hub, opts = {}) {
|
|
|
562
562
|
`团队模式: ${
|
|
563
563
|
TEAM_ACTIVE
|
|
564
564
|
? `开(profile=${DSH_PROFILE};派发时会提示"需要时先建团队",简单任务不会组队)`
|
|
565
|
-
: '
|
|
565
|
+
: '关(默认关闭;需要时运行 dsh-any-agent team on)'
|
|
566
566
|
}`,
|
|
567
567
|
`对话名: ${snap.title ?? '(DSH 尚未生成,发出第一条消息后自动生成)'}` +
|
|
568
568
|
(snap.title_hint && snap.title !== snap.title_hint ? `(你给的标题提示: ${snap.title_hint})` : ''),
|
|
@@ -1201,14 +1201,34 @@ export function createHandlers(hub, opts = {}) {
|
|
|
1201
1201
|
|
|
1202
1202
|
const all = [...hub.conversations.values()];
|
|
1203
1203
|
const alive = all.filter((c) => c.proc?.alive);
|
|
1204
|
-
|
|
1205
|
-
|
|
1204
|
+
// ★ 共享进程下多个会话共用一个 PID:必须按 PID 去重,否则同一份 RSS 会被按会话
|
|
1205
|
+
// 重复累加,"RSS 合计"就会凭空翻几倍,把这行诊断变成误导。
|
|
1206
|
+
const livePids = new Set(alive.map((c) => c.proc?.pid).filter(Boolean));
|
|
1207
|
+
let totalRss = 0;
|
|
1208
|
+
for (const pid of livePids) totalRss += rssMb(pid) ?? 0;
|
|
1209
|
+
add(
|
|
1210
|
+
'sessions',
|
|
1211
|
+
'ok',
|
|
1212
|
+
`共 ${all.length} 个会话,活进程 ${livePids.size} 个` +
|
|
1213
|
+
(SHARED_PROCESS ? `(共享宿主 ${hub.hosts?.size ?? 0} 个)` : '(一会话一进程)') +
|
|
1214
|
+
`,RSS 合计 ${totalRss}MB`,
|
|
1215
|
+
);
|
|
1206
1216
|
add(
|
|
1207
1217
|
'config',
|
|
1208
1218
|
'ok',
|
|
1209
1219
|
`profile=${DSH_PROFILE} team=${TEAM_ACTIVE ? 'on' : 'off'} permission=${DEFAULT_PERMISSION} ` +
|
|
1210
1220
|
`idle_ttl=${IDLE_TTL_MS}ms max_live=${MAX_LIVE} max_rss=${MAX_RSS_MB}MB shared=${SHARED_PROCESS}`,
|
|
1211
1221
|
);
|
|
1222
|
+
add(
|
|
1223
|
+
'team',
|
|
1224
|
+
TEAM_ACTIVE ? 'ok' : TEAM_MODE ? 'warn' : 'ok',
|
|
1225
|
+
TEAM_ACTIVE
|
|
1226
|
+
? `on(profile=${DSH_PROFILE})`
|
|
1227
|
+
: TEAM_MODE
|
|
1228
|
+
? 'requested but unavailable:DSH < 0.1.7-0,或该 profile 不含 team 包'
|
|
1229
|
+
: 'off(opt-in:dsh-any-agent team on)',
|
|
1230
|
+
TEAM_ACTIVE ? null : TEAM_MODE ? '检查 DSH 版本 / profile,或 dsh-any-agent team off' : null,
|
|
1231
|
+
);
|
|
1212
1232
|
|
|
1213
1233
|
const ok = !checks.some((c) => c.status === 'fail');
|
|
1214
1234
|
const lines = checks.map(
|