dsh-any-agent 0.5.1 → 0.5.2
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 +68 -0
- package/DESIGN.md +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/USAGE-NOTES.en.md +2 -2
- package/USAGE-NOTES.md +2 -2
- package/docs/en/architecture.md +3 -3
- package/docs/en/compatibility.md +2 -1
- package/docs/en/security.md +2 -0
- package/docs/en/tests.md +2 -2
- package/docs/en/usage.md +10 -1
- package/docs/zh/architecture.md +3 -3
- package/docs/zh/compatibility.md +2 -1
- package/docs/zh/limits.md +1 -1
- package/docs/zh/security.md +2 -0
- package/docs/zh/tests.md +2 -2
- package/docs/zh/usage.md +10 -1
- package/package.json +1 -1
- package/src/config.mjs +40 -0
- package/src/hub.mjs +368 -123
- package/src/mcp.mjs +3 -0
- package/src/review.mjs +11 -0
- package/src/tools.mjs +93 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.2] — 2026-11-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`workspace_changed` no longer misses real edits.** The turn snapshot now
|
|
15
|
+
fingerprints every `git status` entry (status line + per-path size/mtime,
|
|
16
|
+
with untracked directories expanded recursively), not just the first 40
|
|
17
|
+
status lines. A file that was already dirty and changed again, or any change
|
|
18
|
+
past the 40th entry, is detected; `changed_files` and `entries` keep their
|
|
19
|
+
old meaning (`entries` is still display-truncated to 40).
|
|
20
|
+
- **`gitStatus` is asynchronous.** Turn start/end snapshots now use `execFile`
|
|
21
|
+
instead of `spawnSync`, so a large repository's `git status` (up to 8 s) no
|
|
22
|
+
longer blocks the shared MCP event loop, ACP messages, approvals or other
|
|
23
|
+
conversations. `busy` is still set before any await; turn finalization keeps
|
|
24
|
+
releasing `busy`, waking interject waiters and scheduling the drain before
|
|
25
|
+
waiting for git, and only writes the sentinel after the result (including
|
|
26
|
+
`workspace_changed` and the review ticket) is complete.
|
|
27
|
+
- `--no-optional-locks` is passed as a **global** git option (before the
|
|
28
|
+
subcommand); previously `git status ... --no-optional-locks` exited 129, so
|
|
29
|
+
every real snapshot was `null` and `workspace_changed` stayed false.
|
|
30
|
+
- **`dsh_get(max_chars)` now bounds the whole response.** The budget used to
|
|
31
|
+
apply only to the rendered text while `structured.run.result` kept every
|
|
32
|
+
tool's full input, so a 77-tool turn came back as ~75K characters and was
|
|
33
|
+
rejected by Claude Code's per-call limit even with `max_chars=8000`. With
|
|
34
|
+
`max_chars > 0`, structured is compacted too: the answer is truncated and
|
|
35
|
+
`tools_used[]` keeps `id/title/name/status/kind` plus `input_chars`
|
|
36
|
+
(`run.result_compacted: true`). `max_chars=0` (default) is unchanged; the
|
|
37
|
+
full result is always in the sentinel file.
|
|
38
|
+
- **`dsh_doctor` flags unverified DSH versions and fragile entry points.** A new
|
|
39
|
+
`dsh_version` check grades the DSH version against the compatibility matrix
|
|
40
|
+
(`verified` 0.1.7 ≤ v < 0.2.0 → ok; `partial`, `unsupported`, `unverified`
|
|
41
|
+
such as 0.2.x, or unreadable → warn). `dsh_bin` warns when DSH resolves from
|
|
42
|
+
an npx cache (`_npx/<hash>/`), which disappears on cache cleanup.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Auto-approved approvals are visible.** Under `on_approval=auto-allow`, every
|
|
47
|
+
turn result now carries `auto_approvals_total`, `auto_escalations_total`
|
|
48
|
+
(requests with `sandbox_permissions`, e.g. `danger-full-access`) and up to 20
|
|
49
|
+
redacted `auto_approvals` summaries (tool, escalation, justification, command
|
|
50
|
+
preview). The review ticket's evidence and checklist call them out, so a
|
|
51
|
+
`workspace-write` session that silently escalated no longer looks like it
|
|
52
|
+
stayed in bounds. Defaults are unchanged; use `on_approval=ask` to gate each.
|
|
53
|
+
- **PowerShell sentinel waiter.** On Windows the background receipt includes a
|
|
54
|
+
ready-made, quote-escaped `sentinel_wait_powershell` command (also in the
|
|
55
|
+
text), and the server instructions document it, for hosts where Git Bash is
|
|
56
|
+
missing or hangs.
|
|
57
|
+
|
|
58
|
+
### Website
|
|
59
|
+
|
|
60
|
+
- Playground: step backward, stateful connection-line status, sentinel receipt
|
|
61
|
+
pulse, and a "try interject" cross-scenario action on dispatch frames.
|
|
62
|
+
- Code snippets now use one-pass syntax highlighting (quoted keys, values,
|
|
63
|
+
comments and arrows) without nested spans; the Cursor config tab and copy
|
|
64
|
+
success icon were added; `/en/` HTML is explicitly revalidated.
|
|
65
|
+
- Navigation now surfaces the FAQ; English and Chinese nav order match.
|
|
66
|
+
|
|
67
|
+
### Documentation
|
|
68
|
+
|
|
69
|
+
- Fix the profile-creation command in `USAGE-NOTES.md` / `.en.md`: it needs
|
|
70
|
+
`dsh --profile dsh-mcp --from-default-profile acp`, and 0.5.0+ normally
|
|
71
|
+
bootstraps the profile automatically.
|
|
72
|
+
- Correct the team-mode profile default in `docs/zh/limits.md` and the
|
|
73
|
+
architecture diagrams: `dsh-mcp` is default; `dsh-team` is used only when
|
|
74
|
+
team mode is on.
|
|
75
|
+
- Clarify the website README's Node.js floor (website build ≥ 22; the MCP
|
|
76
|
+
server itself requires ≥ 22.15).
|
|
77
|
+
|
|
10
78
|
## [0.5.1] — 2026-11-04
|
|
11
79
|
|
|
12
80
|
### Security
|
package/DESIGN.md
CHANGED
|
@@ -252,7 +252,7 @@ Profile = `~/.dsh/profiles/<name>/`,由 `package.json` 的 `dsh.profile.bundle
|
|
|
252
252
|
|
|
253
253
|
**B11 设计要点(调用方审核)**
|
|
254
254
|
- **为什么放进返回载荷**:返回值是"另一个 agent 的自述"。调用方最常见的失败模式是直接转述"已完成/已修好"。审核要求必须和结果出现在同一条消息里(`content.text`、`structuredContent`、哨兵 `result.review`),才可能改变调用方的下一步动作;单独一个"记得审核"的约定会被忽略。
|
|
255
|
-
- **审核单形状**:`{review_id, mode, status, run_id, prompt_preview, checklist, evidence, submit:{tool:"dsh_review", arguments}, instructions}`。清单随回合形状变化(有没有改工作区、有没有错误、用了哪些工具);`evidence` 只放核对所需的最小事实(stop_reason、error、changed_files
|
|
255
|
+
- **审核单形状**:`{review_id, mode, status, run_id, prompt_preview, checklist, evidence, submit:{tool:"dsh_review", arguments}, instructions}`。清单随回合形状变化(有没有改工作区、有没有错误、用了哪些工具);`evidence` 只放核对所需的最小事实(stop_reason、error、changed_files、工具名)。`workspace_changed` 必须基于**全量**快照指纹(逐路径 size/mtime,未跟踪目录递归),只比前 40 条状态行会把"同一状态行但内容变了"误报成没落地,审核清单也就失去了证据价值。
|
|
256
256
|
- **默认 advisory,不阻塞**:这是刻意的产品选择。强制门禁会打断"审核失败 → 派修复回合"的自然流程,也会破坏既有调用方;advisory 只把审核请求显性化。`review=off` 可按会话/单次派活关闭;即使 off,`dsh_review` 仍可对旧 run 显式补审核。
|
|
257
257
|
- **裁决落盘与回写**:`reviews/<会话id>/<run_id>.review.json` 原子写入(tmp + rename,唯一 tmp 名),含裁决、理由/findings、清单与证据。`submitReview` 依次从内存 run → 哨兵 → 已有审核记录取事实,所以服务重启、run 被挤出内存后仍可审核。提交后 **原子重写哨兵里的 `result.review`**,避免异步调用方下次读文件看到与事实相反的 `pending`。
|
|
258
258
|
- **隐私**:审核单不含思考正文;理由、findings、任务预览、证据**与每个 checklist 项**在落盘前都走 `redact.sanitizeApprovalContext`(与审批上下文同一套规则)——checklist 会拼接 `result.error`,漏掉它就会成为第二个明文泄漏点。审核记录与哨兵共用 `DSH_MCP_SENTINEL_TTL_MS` 的 7 天清理。
|
|
@@ -305,7 +305,7 @@ Profile = `~/.dsh/profiles/<name>/`,由 `package.json` 的 `dsh.profile.bundle
|
|
|
305
305
|
|
|
306
306
|
| # | 功能 | 说明 |
|
|
307
307
|
|---|---|---|
|
|
308
|
-
| E1 |
|
|
308
|
+
| E1 | **返回工作区变更** | 回合结束后返回 `workspace_changed`(本回合前后 `git status` 的**全量**快照指纹:状态行 + 逐路径 size/mtime,未跟踪目录递归展开)与 `diff_stat`(当前脏文件摘要)。快照走异步子进程,不阻塞共享 MCP 事件循环。**让调用方 agent 能审阅 DSH 的产出再决定是否接受**——这是"外包工程师"模式的关键 |
|
|
309
309
|
| E2 | 计划模式 | 只出计划不落地(`dsh-plan-mode`),调用方批准后再执行 |
|
|
310
310
|
| E3 | 审查模式 | 对既有改动做对抗式审查 |
|
|
311
311
|
| E4 | 接受/回滚 | 会话级 checkpoint(DSH 有 `dsh-session-checkpoint-policy`)配合 git 做回滚 |
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪 0.5.
|
|
18
|
+
🧪 0.5.2 offline suite green, including the workspace-change detection fix and visible auto-approvals · 🔌 MCP over stdio · 📦 Zero dependencies · 📜 MIT
|
|
19
19
|
|
|
20
20
|
An MCP server that drives DeepSeek Harness as a **long-lived coding agent**. Hand it a goal, let it write code and run scripts, steer it mid-flight, and collect the result when you are ready.
|
|
21
21
|
|
package/README.zh.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪 0.5.
|
|
18
|
+
🧪 0.5.2 本机离线套件全绿,含工作区变化检测修复、自动放行审批可见 · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
|
|
19
19
|
|
|
20
20
|
一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
|
|
21
21
|
|
package/USAGE-NOTES.en.md
CHANGED
|
@@ -8,7 +8,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
8
8
|
|
|
9
9
|
## 1. Setup
|
|
10
10
|
|
|
11
|
-
**1.1 The profile must exist first.** Otherwise every turn fails with `no adapter registered for provider "xxx"`. This server ships no provider config; it drives `~/.dsh/profiles/dsh-mcp/`. Create it with `dsh dsh-mcp --from-default-profile acp` and fill in your own provider per [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml).
|
|
11
|
+
**1.1 The profile must exist first.** Otherwise every turn fails with `no adapter registered for provider "xxx"`. This server ships no provider config; it drives `~/.dsh/profiles/dsh-mcp/`. Create it with `dsh --profile dsh-mcp --from-default-profile acp` and fill in your own provider per [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml). On 0.5.0+ the server bootstraps this profile on first start, so the manual command is normally only needed when `DSH_MCP_BOOTSTRAP=0` or the profile is broken.
|
|
12
12
|
|
|
13
13
|
**1.2 ★ The most common trap: ACP's model does NOT come from `agent-default-model`.** You configure your provider there, yet turns fail with `no API key for provider route "deepseek-official"`. ACP reads the `dsh-acp` plugin's own `config.provider` / `config.model`, which the `acp` bundle hard-codes. You must override the `acp` row:
|
|
14
14
|
|
|
@@ -130,7 +130,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
130
130
|
1. **Give goals, not commands.** DSH is an agent: say "add tests for this module and make them pass", not "run pytest". It picks tools, plans steps, and spawns subagents on its own.
|
|
131
131
|
2. **One conversation per task** — mixing unrelated work pollutes history and costs more.
|
|
132
132
|
3. **Split long work into dispatch + collect**: `wait=false` → do something else → `dsh_get(run_id)`.
|
|
133
|
-
4. **Review output via workspace changes** — `workspace_changed`
|
|
133
|
+
4. **Review output via workspace changes** — `workspace_changed` compares a **full** `git status` snapshot fingerprint taken before and after the turn (status line plus each dirty/untracked path's size and mtime; untracked directories are expanded recursively), so an already-dirty file that changes again, or a change past the 40th entry, is still detected. `diff_stat` lists the current dirty files (it is not a per-turn diff).
|
|
134
134
|
5. **Review the result, don't just relay it** — every result carries a `review` ticket. Check at least one claim with your own tools (read the changed file, inspect `git diff`, run the relevant test), then record `pass` / `partial` / `fail` with `dsh_review`. Default `advisory` does not block the next send; `fail`/`partial` need a reason, and a failed review is not fed back to DSH automatically.
|
|
135
135
|
6. **Ask for a plan first** if you want to approve before execution (DSH has plan mode).
|
|
136
136
|
7. **Three cost knobs**: lower `reasoning_effort`, keep `reasoning=hide`, release and start fresh.
|
package/USAGE-NOTES.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
### 1.1 profile 必须先建,否则连不上模型
|
|
12
12
|
- **现象**:所有回合都失败,报 `no adapter registered for provider "xxx"`。
|
|
13
13
|
- **原因**:本服务不自带任何 provider 配置,它驱动的是 `~/.dsh/profiles/dsh-mcp/` 这个 profile。
|
|
14
|
-
- **对策**:`dsh dsh-mcp --from-default-profile acp`,然后照 [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml) 填上你自己的 provider。
|
|
14
|
+
- **对策**:`dsh --profile dsh-mcp --from-default-profile acp`,然后照 [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml) 填上你自己的 provider。0.5.0+ 首次启动会自动 bootstrap 这个 profile,通常无需手工执行;只有在 `DSH_MCP_BOOTSTRAP=0` 或 profile 损坏时才需要。
|
|
15
15
|
|
|
16
16
|
### 1.2 ★ 最常见的坑:ACP 的模型不走 `agent-default-model`
|
|
17
17
|
- **现象**:明明在 `agent-default-model` 里配了自己的 provider,会话却报 `no API key for provider route "deepseek-official"`。
|
|
@@ -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`
|
|
274
|
+
4. **要审查产出就用工作区变更**:`workspace_changed` 比较的是本回合前后 `git status` 的**全量**快照指纹(状态行 + 每个脏/未跟踪路径的 size/mtime;未跟踪目录会递归展开),所以"回合前已经脏、回合内又被改内容"或"改动落在第 41 条之后"都能识别。`diff_stat` 列出当前仍处于改动状态的文件(不是本回合的精确 diff)。
|
|
275
275
|
5. **审核结果,别直接转述**:每个结果都带 `review` 审核单。用自己的工具核验至少一项关键声明(读改动的文件、看 `git diff`、跑相关测试),再用 `dsh_review` 记录 `pass` / `partial` / `fail`。默认 `advisory` 不阻塞下一次派活;`fail`/`partial` 必须给理由,且审核不通过不会自动回灌给 DSH。
|
|
276
276
|
6. **要它先想清楚再动手**:让它先给方案、你确认后再执行(DSH 有计划模式)。
|
|
277
277
|
7. **省钱的三个旋钮**:`reasoning_effort` 调低、`reasoning` 保持 `hide`、及时 `dsh_release` 并开新会话。
|
package/docs/en/architecture.md
CHANGED
|
@@ -24,9 +24,9 @@ DSH ships two programmable entry points. The `sdk` profile looks simpler, but it
|
|
|
24
24
|
│ MCP over stdio │ newline-delimited JSON-RPC (ACP)
|
|
25
25
|
▼ ▼
|
|
26
26
|
┌─────────────┐ ┌──────────────────────────┐
|
|
27
|
-
│dsh-any-agent│───────────▶│ dsh --profile dsh-
|
|
28
|
-
└─────────────┘ │
|
|
29
|
-
|
|
27
|
+
│dsh-any-agent│───────────▶│ dsh --profile dsh-mcp │ sessions with the same
|
|
28
|
+
└─────────────┘ │ (dsh-team when team on) │ (cwd, permission) share
|
|
29
|
+
└────────────┬──────────────┘ one process (default since 0.5.0)
|
|
30
30
|
│
|
|
31
31
|
┌────────────┴─────────────┐
|
|
32
32
|
│ DSH's own tools │ files, shell, search,
|
package/docs/en/compatibility.md
CHANGED
|
@@ -36,7 +36,8 @@ dsh-any-agent team status # team switch and profile
|
|
|
36
36
|
|
|
37
37
|
In an MCP client, call `dsh_doctor` and look at:
|
|
38
38
|
|
|
39
|
-
- `dsh_bin`: DSH entry point and version;
|
|
39
|
+
- `dsh_bin`: DSH entry point and version; **warn** when it resolves from an npx cache (`_npx/<hash>/`), which disappears on cache cleanup — install DSH globally or pin `DSH_BIN`;
|
|
40
|
+
- `dsh_version`: the version graded against the table above — `verified` is ok; `partial` / `unsupported` / `unverified` (e.g. 0.2.x) / unreadable are **warn**;
|
|
40
41
|
- `profile`: exists, drift, route source;
|
|
41
42
|
- `team`: `on` / `off` / `requested but unavailable` (version or profile lacks the team package);
|
|
42
43
|
- `config`: effective permission, idle reaping, shared process, and so on.
|
package/docs/en/security.md
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
`on_approval` can be `auto-allow`, `auto-deny`, or `ask` (which exposes a pending approval for `dsh_approval_decide` to adjudicate).
|
|
12
12
|
|
|
13
|
+
**Under `auto-allow`, `workspace-write` is a soft boundary.** When DSH's sandbox blocks something (e.g. child-process pipes on Windows), DSH asks for `sandbox_permissions: danger-full-access` and `auto-allow` grants it. The default is unchanged, but the grant is **no longer silent**: every turn result carries `auto_approvals_total`, `auto_escalations_total` and up to 20 redacted `auto_approvals` summaries (tool, escalation tier, justification, command preview), and the review ticket's evidence and checklist call out "N auto-approved this turn, M of them escalations". To gate each one, start a conversation with `on_approval=ask`.
|
|
14
|
+
|
|
13
15
|
**`read-only` is enforced, not merely a default**: it always runs with `auto-deny`; passing `auto-allow` or `ask` for a read-only conversation is rejected at `dsh_start`, and the approval layer refuses any permission request from a read-only conversation. After create/resume the server reads back the session's recorded tier; for read-only it **fails closed** if the projection cache has no permission record within the DSH flush window, rather than relying on the approval layer alone. Without that, a profile `defaultPreset` plus a permissive approval policy could let a write through (found by the adversarial audit).
|
|
14
16
|
|
|
15
17
|
## 🧷 Input boundaries
|
package/docs/en/tests.md
CHANGED
|
@@ -24,11 +24,11 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
24
24
|
| `prune` | 11 | **sentinel retention**: prunes over-age and crash-leftover files, keeps fresh ones, removes empty shells, `TTL=0` disables pruning, and never touches the conversation registry beside it (no tokens, runs every time) |
|
|
25
25
|
| `guards` | 40 | **environment-variable guards**: odd numbers always land on the safe side — `IDLE_TTL_MS=-1` means "never reap", garbage/empty falls back to the 10-minute default, a too-small reap interval falls back to the default (no busy loop), and the **lost-holder threshold is floored at 30 s** (so another instance cannot "legitimately" preempt and kill our turn); plus the team-mode default and precedence (`DSH_MCP_TEAM` > `settings.json` > off) (no tokens, runs every time) |
|
|
26
26
|
| `sandbox-guard` | 21 | **the test sandbox itself**: a "throwaway HOME" that resolves to the real user profile, to the filesystem root, or to anywhere outside the OS temp dir is refused with an error instead of being written to and deleted. Every other suite builds its HOME through `test/sandbox-home.mjs` and can only destroy what it created there (no tokens, runs every time) |
|
|
27
|
-
| `approval-details` |
|
|
27
|
+
| `approval-details` | 37 | **approval review context**: the pending-approval snapshot carries the command, reason, cwd and original approval ID — not just a bare ID — so a caller can actually adjudicate; missing `rawInput` is correlated from the earlier `tool_call` update by ID; credential-shaped fields and common inline literals are redacted; closing a conversation cancels pending approvals. Verified end-to-end over real stdio MCP |
|
|
28
28
|
| `failure-terminal` | 11 | **terminal state on failure**: an `ensureAlive` failure wakes `dsh_interject` waiters, writes an `error` sentinel, and attaches `runId` to the error; an approval with no matching option reports `effective=cancelled` instead of a fake allow; `dsh_get` reads a run back from its sentinel (no tokens) |
|
|
29
29
|
| `shared-host` | 25 | **shared process host**: notifications and permission requests route by `sessionId`, unknown-session approvals are cancelled, a process exit fans out to every attached conversation, the child stops only when the last conversation detaches; **on by default and switchable off with `=0`** (an empty value counts as unset); the pre-takeover collision check for a shared process; RSS helper (no tokens) |
|
|
30
30
|
| `shared-process` | 8 (skips without DSH) | **real-DSH shared process**: two conversations with the same `(cwd, permission)` share one PID, closing one leaves the other alive, closing both clears the host and exits the PID (needs a local DSH; runs with `--all`) |
|
|
31
|
-
| `dispatch-tools` |
|
|
31
|
+
| `dispatch-tools` | 55 (53 off Windows) | **dispatch surface**: `dsh_inbox` aggregates pending approvals across conversations, `client_run_id` idempotency hits and evicts with its run, queue list/update/promote/remove (remove writes a cancelled sentinel), `dsh_get(max_chars)` compacts both text and structured (answer truncated, tool inputs reduced to `input_chars`; `max_chars=0` still returns everything), `dsh_doctor` DSH-version grading and npx-cache detection, PowerShell sentinel-waiter escaping plus a real run (Windows only), audit records metadata only, `dsh_doctor` essential checks (no tokens) |
|
|
32
32
|
| `line-decoder` | 4 | **UTF-8 line framing**: multi-byte characters split across MCP/ACP stream chunks are preserved (no U+FFFD), including CRLF and EOF-without-newline (no tokens) |
|
|
33
33
|
| `read-cursor` | 10 | **`dsh_read` cursor semantics**: text appended to an already-read entry is delivered again (revision cursor, match by `seq`), and `limit` is clamped to 1..500 so `0`/negative cannot spin or drop the tail (no tokens) |
|
|
34
34
|
| `sentinel-redaction` | 19 | **sentinel disk hygiene**: `result.thinking` is stripped, `thinking_omitted` is always a boolean, and `answer`/`error`/`title`/`tools_used[].input` all pass the same credential redaction as approvals (no tokens) |
|
package/docs/en/usage.md
CHANGED
|
@@ -43,7 +43,16 @@ done
|
|
|
43
43
|
echo "DONE" # task exits → host wakes you → read the file to collect
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
On a Windows host where Git Bash is unavailable (or hangs), wait on the native `sentinel_file` with PowerShell. On Windows the background receipt's structured content carries a ready-made, escaped **`sentinel_wait_powershell`** — use it directly:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
$f='C:\Users\you\.dsh\.dsh-any-agent\runs\<conversation_id>\<run_id>.json' # write ' as '' inside the path
|
|
50
|
+
$dl=(Get-Date).AddSeconds(2100)
|
|
51
|
+
while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }
|
|
52
|
+
'DONE'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Once the file exists: **read it** for `status` and `result` (preferred — immune to the in-memory window and survives restarts), or use `dsh_get(conversation_id, run_id)`. When a turn used many tools, the full result (every tool's complete input included) can exceed the client's per-call tool-result limit; pass `max_chars` (e.g. 8000) to `dsh_get`: the budget now applies to **both** text and structured — the answer is truncated and each tool keeps only its name/status plus `input_chars`. The full version is always in the sentinel file.
|
|
47
56
|
|
|
48
57
|
Design notes (especially under **concurrent multi-session** use):
|
|
49
58
|
|
package/docs/zh/architecture.md
CHANGED
|
@@ -24,9 +24,9 @@ DSH 自带两个可编程入口。`sdk` profile 看着更简单,但它的 JSON
|
|
|
24
24
|
│ MCP over stdio │ 换行分隔 JSON-RPC(ACP)
|
|
25
25
|
▼ ▼
|
|
26
26
|
┌─────────────┐ ┌──────────────────────────┐
|
|
27
|
-
│dsh-any-agent│─────────▶│ dsh --profile dsh-
|
|
28
|
-
└─────────────┘ │
|
|
29
|
-
└────────────┬─────────────┘ (0.5.0
|
|
27
|
+
│dsh-any-agent│─────────▶│ dsh --profile dsh-mcp │ 同 (cwd, 权限) 的会话
|
|
28
|
+
└─────────────┘ │ (team on → dsh-team) │ 共用一个长驻进程
|
|
29
|
+
└────────────┬─────────────┘ (0.5.0 起默认共享进程)
|
|
30
30
|
│
|
|
31
31
|
┌────────────┴─────────────┐
|
|
32
32
|
│ DSH 自带工具 │ 文件/shell/搜索/
|
package/docs/zh/compatibility.md
CHANGED
|
@@ -34,7 +34,8 @@ dsh-any-agent team status # 团队开关与 profile
|
|
|
34
34
|
|
|
35
35
|
在 MCP 客户端里调用 `dsh_doctor`,重点看:
|
|
36
36
|
|
|
37
|
-
- `dsh_bin`:DSH
|
|
37
|
+
- `dsh_bin`:DSH 入口与版本;入口落在 npx 缓存(`_npx/<hash>/`)时 **warn** —— 清缓存就会失效,建议全局安装 DSH 或固定 `DSH_BIN`;
|
|
38
|
+
- `dsh_version`:按上表给版本定档 —— `verified` 为 ok;`partial` / `unsupported` / `unverified`(如 0.2.x)/ 读不到版本为 **warn**;
|
|
38
39
|
- `profile`:是否存在、是否漂移、路由来源;
|
|
39
40
|
- `team`:`on` / `off` / `requested but unavailable`(版本或 profile 不含 team 包);
|
|
40
41
|
- `config`:当前 permission、空闲回收、共享进程等生效值。
|
package/docs/zh/limits.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
因为改名会打断一堆已经落盘的东西。
|
|
19
19
|
|
|
20
|
-
本服务首次启动时会在 `~/.dsh/profiles/dsh-mcp/` 建一个 DSH profile。0.
|
|
20
|
+
本服务首次启动时会在 `~/.dsh/profiles/dsh-mcp/` 建一个 DSH profile。0.5.0 起团队模式按需开启:开启时 profile 切到 `dsh-team`(同样由本服务自动创建),默认或关闭时仍用 `dsh-mcp`。两者共用同一套 `DSH_MCP_*` 变量与 `# dsh-mcp:managed` 标记。
|
|
21
21
|
|
|
22
22
|
这个目录名被另外三样持久化标识引用着:
|
|
23
23
|
|
package/docs/zh/security.md
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
`on_approval` 可选 `auto-allow` / `auto-deny` / `ask`(`ask` 会把审批挂起,交给 `dsh_approval_decide` 裁决)。
|
|
12
12
|
|
|
13
|
+
**`auto-allow` 下 `workspace-write` 是软边界。** DSH 碰到沙箱挡住的操作(例如 Windows 上子进程管道被拦)会申请 `sandbox_permissions: danger-full-access`,`auto-allow` 会直接放行。默认值不变,但放行**不再是静默的**:每个回合结果都带 `auto_approvals_total`、`auto_escalations_total` 和最多 20 条脱敏后的 `auto_approvals` 摘要(工具、提权档、理由、命令预览);审核单的证据与清单会点名"本轮有 N 次自动放行,其中 M 次提权"。要逐次把关,就用 `on_approval=ask` 新建会话。
|
|
14
|
+
|
|
13
15
|
**`read-only` 是强制边界,不只是默认值**:它固定使用 `auto-deny`;给只读会话传 `auto-allow` / `ask` 会在 `dsh_start` 被直接拒绝,审批层也会拒绝来自只读会话的任何权限请求。建会话/resume 后还会回读会话自己记录的权限档;只读档如果在 DSH 投影缓存刷盘窗口内仍读不到记录,会 **fail-closed 拒绝继续**,而不是只依赖审批层兜底。否则 profile 的 `defaultPreset` 加宽松审批策略可能放行写操作(对抗审计实测过)。
|
|
14
16
|
|
|
15
17
|
## 🧷 输入边界
|
package/docs/zh/tests.md
CHANGED
|
@@ -23,11 +23,11 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
|
|
|
23
23
|
| `prune` | 11 | **哨兵留存**:清超期与崩溃残留、留新、清空壳目录、`TTL=0` 关闭清理、且绝不碰同目录的会话注册表(不花 token,每次必跑) |
|
|
24
24
|
| `guards` | 40 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1` 当"永不回收"、非法值/空串回落默认 10 分钟、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合);以及团队模式的默认值与优先级(`DSH_MCP_TEAM` > `settings.json` > 默认关)(不花 token,每次必跑) |
|
|
25
25
|
| `sandbox-guard` | 21 | **测试沙箱自身**:「临时 HOME」如果落到真实用户目录、它的子目录、盘符根目录,或 OS 临时目录之外,一律报错拒绝,绝不被写入和删除。其余套件都通过 `test/sandbox-home.mjs` 拿目录、只销毁自己建的那个(不花 token,每次必跑) |
|
|
26
|
-
| `approval-details` |
|
|
26
|
+
| `approval-details` | 37 | **审批审查上下文**:待决审批的快照带命令、理由、cwd 与原始审批 ID —— 不只是一个裸 ID,调用方才能真正裁决;缺失的 `rawInput` 会按 ID 从先前的 `tool_call` 更新关联回来;凭据形状的字段与常见内联字面量在结构化上下文和展示标题里都会脱敏;关闭会话会取消待决审批。经真实 stdio MCP 端到端验证 |
|
|
27
27
|
| `failure-terminal` | 11 | **失败路径的终态**:`ensureAlive` 失败时唤醒 `dsh_interject` 等待者、落 `status=error` 哨兵、错误对象挂 `runId`;审批没有对应 option 时 `effective=cancelled` 而不是假报 allow;`dsh_get` 能从哨兵文件回读(不花 token) |
|
|
28
28
|
| `shared-host` | 25 | **共享进程宿主**:通知/审批按 `sessionId` 路由、未知会话审批一律取消、进程退出扇出所有挂载会话、最后一个会话 detach 才停进程;**默认开启且 `=0` 可关闭**(空串按未设置处理);`takeover` 杀共享进程前的连坐检查;以及 RSS 读取(不花 token) |
|
|
29
29
|
| `shared-process` | 8(无 DSH 时 skip) | **共享进程真机验证**:同 `(cwd, permission)` 的两个会话落在同一个 PID、关一个不影响另一个、全部关闭后宿主清空且 PID 退出(需要本机 DSH,`--all` 时运行) |
|
|
30
|
-
| `dispatch-tools` |
|
|
30
|
+
| `dispatch-tools` | 55(非 Windows 53) | **派发面**:`dsh_inbox` 跨会话汇总待决审批、`client_run_id` 幂等命中与淘汰、队列 list/update/promote/remove(remove 写 cancelled 哨兵)、`dsh_get(max_chars)` 同时压缩 text 与 structured(正文截断、工具输入只留 `input_chars`,`max_chars=0` 仍返回全文)、`dsh_doctor` 的 DSH 版本档位与 npx 缓存识别、PowerShell 哨兵等待命令的转义与真机执行(仅 Windows)、审计只记元数据不记正文、`dsh_doctor` 关键检查项(不花 token) |
|
|
31
31
|
| `read-cursor` | 10 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
|
|
32
32
|
| `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
|
|
33
33
|
| `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
|
package/docs/zh/usage.md
CHANGED
|
@@ -43,7 +43,16 @@ done
|
|
|
43
43
|
echo "DONE" # 后台任务退出 → 宿主唤醒你 → 读文件收活
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Windows 宿主上 Git Bash 不可用(或会挂住)时,用 PowerShell 等原生路径 `sentinel_file`。在 Windows 上,后台收据的 structured 里有拼好、已转义的 **`sentinel_wait_powershell`**,直接用它:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
$f='C:\Users\you\.dsh\.dsh-any-agent\runs\<conversation_id>\<run_id>.json' # 路径里的 ' 写成 ''
|
|
50
|
+
$dl=(Get-Date).AddSeconds(2100)
|
|
51
|
+
while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }
|
|
52
|
+
'DONE'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
文件出现后:**直接读它**即得 `status` 和 `result`(推荐——不受内存窗口限制、还扛服务重启),或用 `dsh_get(conversation_id, run_id)` 验收。一个回合用了很多工具时,完整结果(含每个工具的完整输入)可能超过客户端单次工具结果的上限;这时给 `dsh_get` 传 `max_chars`(如 8000):预算**同时**作用于 text 与 structured,正文截断、工具只留名字/状态与 `input_chars`,完整版本始终在哨兵文件里。
|
|
47
56
|
|
|
48
57
|
设计要点(尤其**多会话并发**):
|
|
49
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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/config.mjs
CHANGED
|
@@ -128,6 +128,46 @@ export function resolveDshBin() {
|
|
|
128
128
|
);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* 兼容性矩阵(docs/zh/compatibility.md)的机器可读版本,供 dsh_doctor 使用。
|
|
133
|
+
* 改矩阵时同步改这里:`[verifiedMin, verifiedBelow)` 是“已验证”区间。
|
|
134
|
+
* 用 `-0` 后缀是为了把同号的 rc 版本也算进去(semver 里 `0.1.7-rc.1 < 0.1.7`)。
|
|
135
|
+
*/
|
|
136
|
+
export const DSH_COMPAT = Object.freeze({
|
|
137
|
+
partialMin: '0.1.5-0',
|
|
138
|
+
verifiedMin: '0.1.7-0',
|
|
139
|
+
verifiedBelow: '0.2.0-0',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 给一个 DSH 版本打兼容性档位:ok(已验证)/ warn(部分可用、未验证或读不到)。
|
|
144
|
+
* 只是体检提示,不拦启动 —— 未验证不等于不能用。
|
|
145
|
+
*/
|
|
146
|
+
export function dshVersionStatus(version) {
|
|
147
|
+
if (!version) {
|
|
148
|
+
return { status: 'warn', level: 'unknown', note: '读不到 DSH 版本(package.json 缺失或不可读)' };
|
|
149
|
+
}
|
|
150
|
+
if (cmpVersion(version, DSH_COMPAT.partialMin) < 0) {
|
|
151
|
+
return { status: 'warn', level: 'unsupported', note: `DSH ${version} 低于已知可用的最低版本 0.1.5` };
|
|
152
|
+
}
|
|
153
|
+
if (cmpVersion(version, DSH_COMPAT.verifiedMin) < 0) {
|
|
154
|
+
return { status: 'warn', level: 'partial', note: `DSH ${version} 部分可用:ACP 基本路径可用,缺 team 包` };
|
|
155
|
+
}
|
|
156
|
+
if (cmpVersion(version, DSH_COMPAT.verifiedBelow) < 0) {
|
|
157
|
+
return { status: 'ok', level: 'verified', note: `DSH ${version} 在已验证区间(0.1.7 ~ 0.2.0 之前)` };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
status: 'warn',
|
|
161
|
+
level: 'unverified',
|
|
162
|
+
note: `DSH ${version} 未经本版本验证(兼容性矩阵只覆盖到 0.1.x),ACP session/* 面可能有变化`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** DSH 入口是不是落在 npx 缓存里(`_npx/<hash>/…`):那里随 `npm cache clean` 或 npx 自身淘汰随时消失。 */
|
|
167
|
+
export function isNpxCachePath(p) {
|
|
168
|
+
return /[\\/]_npx[\\/]/.test(String(p ?? ''));
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
/**
|
|
132
172
|
* DSH 主目录。**默认就是本机用户自己的 ~/.dsh** —— 本服务不拥有独立的 home,
|
|
133
173
|
* 因此凭据(.credentials.yaml)、会话历史、工作区登记与 GUI 完全共用一份。
|
package/src/hub.mjs
CHANGED
|
@@ -24,12 +24,15 @@ import {
|
|
|
24
24
|
existsSync,
|
|
25
25
|
renameSync,
|
|
26
26
|
statSync,
|
|
27
|
+
lstatSync,
|
|
27
28
|
rmSync,
|
|
28
29
|
readdirSync,
|
|
29
30
|
} from 'node:fs';
|
|
30
31
|
import { dirname, isAbsolute, join, parse, resolve, sep } from 'node:path';
|
|
31
32
|
import { homedir, tmpdir } from 'node:os';
|
|
32
|
-
import {
|
|
33
|
+
import { execFile } from 'node:child_process';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { promisify } from 'node:util';
|
|
33
36
|
import {
|
|
34
37
|
ALLOW_FULL_ACCESS,
|
|
35
38
|
APPROVAL_POLICIES,
|
|
@@ -190,6 +193,10 @@ const MAX_PROMPT_ECHO = 4000;
|
|
|
190
193
|
const MAX_RUNS = 20;
|
|
191
194
|
/** 回合记录里 prompt 预览的长度。 */
|
|
192
195
|
const MAX_RUN_PREVIEW = 200;
|
|
196
|
+
/** 每个回合最多保留多少条"自动放行审批"摘要(总数另计,不受此限)。 */
|
|
197
|
+
const MAX_AUTO_APPROVALS_KEPT = 20;
|
|
198
|
+
/** 自动放行审批里命令预览的长度。 */
|
|
199
|
+
const MAX_AUTO_APPROVAL_PREVIEW = 200;
|
|
193
200
|
|
|
194
201
|
/**
|
|
195
202
|
* 清掉 `dir` 下超过 TTL 的文件与空会话目录;`.tmp` 残留不论年龄都按同一 cutoff 清。
|
|
@@ -310,6 +317,10 @@ export function sanitizeResultForSentinel(result) {
|
|
|
310
317
|
return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
|
|
311
318
|
});
|
|
312
319
|
}
|
|
320
|
+
// 自动放行审批摘要记录时已脱敏;落盘前再过一遍,防止将来有人加字段漏掉。
|
|
321
|
+
if (Array.isArray(rest.auto_approvals)) {
|
|
322
|
+
rest.auto_approvals = rest.auto_approvals.map((a) => (a && typeof a === 'object' ? sanitizeApprovalContext(a) : a));
|
|
323
|
+
}
|
|
313
324
|
// 审核单里可能有原任务预览、审核理由与证据摘要;哨兵会保留 7 天,同样要脱敏。
|
|
314
325
|
if (rest.review && typeof rest.review === 'object') {
|
|
315
326
|
const rv = { ...rest.review };
|
|
@@ -822,6 +833,9 @@ export class Conversation {
|
|
|
822
833
|
}
|
|
823
834
|
if (this.onApproval === 'auto-allow') {
|
|
824
835
|
this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
|
|
836
|
+
// ★ 自动放行必须对调用方可见:DSH 会借审批把沙箱提升到 danger-full-access,
|
|
837
|
+
// 不记下来的话 workspace-write 会话事后看起来像从未越界(实测一轮 6 次提权无人知晓)。
|
|
838
|
+
if (allowId) this.#recordAutoApproval(params, toolCall, desc, options);
|
|
825
839
|
return allowId
|
|
826
840
|
? { outcome: { outcome: 'selected', optionId: allowId } }
|
|
827
841
|
: { outcome: { outcome: 'cancelled' } };
|
|
@@ -854,6 +868,35 @@ export class Conversation {
|
|
|
854
868
|
});
|
|
855
869
|
}
|
|
856
870
|
|
|
871
|
+
/**
|
|
872
|
+
* 记一条"被自动放行的审批"到本回合收集器(只记摘要,且先脱敏)。
|
|
873
|
+
* 回合外到来的审批(没有收集器)不记 —— 没有结果可挂。
|
|
874
|
+
*/
|
|
875
|
+
#recordAutoApproval(params, toolCall, desc, options) {
|
|
876
|
+
const c = this.collector;
|
|
877
|
+
if (!c) return;
|
|
878
|
+
c.autoApprovalTotal = (c.autoApprovalTotal ?? 0) + 1;
|
|
879
|
+
const input = toolCall.rawInput ?? toolCall.input ?? null;
|
|
880
|
+
const escalation =
|
|
881
|
+
input && typeof input === 'object' && typeof input.sandbox_permissions === 'string'
|
|
882
|
+
? input.sandbox_permissions
|
|
883
|
+
: null;
|
|
884
|
+
if (escalation) c.autoEscalationTotal = (c.autoEscalationTotal ?? 0) + 1;
|
|
885
|
+
if (!Array.isArray(c.autoApprovals)) c.autoApprovals = [];
|
|
886
|
+
if (c.autoApprovals.length >= MAX_AUTO_APPROVALS_KEPT) return;
|
|
887
|
+
const ctx = approvalContext(params, toolCall, desc, options);
|
|
888
|
+
const cmd = input && typeof input === 'object' ? input.command ?? input.cmd ?? null : null;
|
|
889
|
+
c.autoApprovals.push({
|
|
890
|
+
tool: ctx.tool ?? desc,
|
|
891
|
+
tool_call_id: ctx.tool_call_id ?? null,
|
|
892
|
+
escalation,
|
|
893
|
+
reason: typeof ctx.reason === 'string' ? ctx.reason.slice(0, 300) : null,
|
|
894
|
+
command_preview:
|
|
895
|
+
typeof cmd === 'string' ? sanitizeApprovalContext(cmd, 'command').slice(0, MAX_AUTO_APPROVAL_PREVIEW) : null,
|
|
896
|
+
at: new Date().toISOString(),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
857
900
|
/** 调用方裁决一个待决审批。 */
|
|
858
901
|
decideApproval(approvalId, decision) {
|
|
859
902
|
const p = this.pendingApprovals.get(approvalId);
|
|
@@ -975,17 +1018,22 @@ export class Conversation {
|
|
|
975
1018
|
runRecord.status = 'running';
|
|
976
1019
|
runRecord.started_at = now();
|
|
977
1020
|
this.currentRun = runRecord;
|
|
978
|
-
|
|
1021
|
+
let before = null;
|
|
979
1022
|
let proc;
|
|
980
1023
|
// ★ `busy` 必须在 ensureAlive **之前**置位。
|
|
981
1024
|
// 否则"把进程拉起来"这段(spawn + initialize + resume,可能好几秒)是一个**裸奔窗口**:
|
|
982
1025
|
// ① reaper 看到 busy=false 且 lastUsedAt 还是上一轮的时刻(已过期),会把这个**刚拉起来**
|
|
983
1026
|
// 的进程当空闲回收掉 → 紧接着 resume 就报"DSH 进程未运行"(test/cycle.mjs 实测抓到);
|
|
984
1027
|
// ② 第二个 dsh_send 同样能通过 busy 检查,于是为同一会话再拉一个子进程 → 互相撞锁 + 泄漏。
|
|
1028
|
+
// ★ 同理,git 快照的 await 也必须放在 busy 置位**之后**(见下面的 Promise.all)。
|
|
985
1029
|
this.busy = true;
|
|
986
1030
|
this.lastUsedAt = now();
|
|
1031
|
+
const beforePromise = gitStatus(this.cwd);
|
|
987
1032
|
try {
|
|
988
|
-
|
|
1033
|
+
// 快照与拉起进程并行:快照只是读盘,不改变生命周期,早取晚取语义一致。
|
|
1034
|
+
const [beforeSnap, liveProc] = await Promise.all([beforePromise, this.ensureAlive()]);
|
|
1035
|
+
before = beforeSnap;
|
|
1036
|
+
proc = liveProc;
|
|
989
1037
|
} catch (e) {
|
|
990
1038
|
// 起不来(撞写锁 / 会话损坏)就**必须放开 busy**,否则这个会话被永久占住;
|
|
991
1039
|
// 同时别留一条永远 "running" 的幽灵记录。
|
|
@@ -1013,6 +1061,10 @@ export class Conversation {
|
|
|
1013
1061
|
usage: null,
|
|
1014
1062
|
plan: null,
|
|
1015
1063
|
otherUpdates: [],
|
|
1064
|
+
/** 本回合被 auto-allow 放行的审批摘要(最多 MAX_AUTO_APPROVALS_KEPT 条)与总数。 */
|
|
1065
|
+
autoApprovals: [],
|
|
1066
|
+
autoApprovalTotal: 0,
|
|
1067
|
+
autoEscalationTotal: 0,
|
|
1016
1068
|
};
|
|
1017
1069
|
this.collector = c;
|
|
1018
1070
|
const t0 = now();
|
|
@@ -1038,102 +1090,137 @@ export class Conversation {
|
|
|
1038
1090
|
return result;
|
|
1039
1091
|
};
|
|
1040
1092
|
|
|
1041
|
-
/**
|
|
1093
|
+
/**
|
|
1094
|
+
* 回合真正结束时收尾。无论有没有人在等,都**只执行一次**。
|
|
1095
|
+
*
|
|
1096
|
+
* 现在是异步的(git 快照走异步子进程),但保持了三个不变量:
|
|
1097
|
+
* 1. 幂等:重复调用返回同一个 Promise,不会重复写哨兵;
|
|
1098
|
+
* 2. 不拖累生命周期:`busy=false`、唤醒 idleWaiters、#scheduleDrain
|
|
1099
|
+
* 都在**第一个 await 之前**同步完成,git 最慢 8s 也影响不到 reaper/插话;
|
|
1100
|
+
* 3. 防弹:内部异常一律兜底成 error 结果 + 哨兵,返回的 Promise 不会 reject
|
|
1101
|
+
* (wait=false 的调用方不会 await 它,不能让 unhandled rejection 杀掉服务)。
|
|
1102
|
+
*/
|
|
1103
|
+
let finalizePromise = null;
|
|
1042
1104
|
const finalize = (stopReason, failure) => {
|
|
1043
|
-
if (
|
|
1105
|
+
if (finalizePromise) return finalizePromise;
|
|
1044
1106
|
settled = true;
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1107
|
+
finalizePromise = (async () => {
|
|
1108
|
+
// ★ 先启动 after 快照:调用 gitStatus 时子进程立刻 spawn,但**不 await**;
|
|
1109
|
+
// 这样 snapshot 的时间点仍贴近回合结束,而 busy 释放/唤醒不必等 git。
|
|
1110
|
+
const afterPromise = gitStatus(this.cwd);
|
|
1111
|
+
try {
|
|
1112
|
+
this.busy = false;
|
|
1113
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
1114
|
+
this.collector = null;
|
|
1115
|
+
this.currentTool = null;
|
|
1116
|
+
// 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
|
|
1117
|
+
// 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
|
|
1118
|
+
for (const [, p] of this.pendingApprovals) {
|
|
1119
|
+
try {
|
|
1120
|
+
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1121
|
+
} catch {}
|
|
1122
|
+
}
|
|
1123
|
+
this.pendingApprovals.clear();
|
|
1124
|
+
this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
|
|
1125
|
+
phase: 'end',
|
|
1126
|
+
stop_reason: stopReason,
|
|
1127
|
+
error: failure ?? undefined,
|
|
1128
|
+
});
|
|
1129
|
+
this.turnCount += 1;
|
|
1130
|
+
this.lastUsedAt = now();
|
|
1131
|
+
this.#hub.save();
|
|
1132
|
+
// 唤醒所有"等本轮结束"的插话请求(busy 已置 false,它们可以安全地发下一轮)
|
|
1133
|
+
const waiters = this.idleWaiters;
|
|
1134
|
+
this.idleWaiters = [];
|
|
1135
|
+
for (const w of waiters) w();
|
|
1136
|
+
this.#scheduleDrain();
|
|
1137
|
+
// 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
|
|
1138
|
+
const after = await afterPromise;
|
|
1139
|
+
const result = {
|
|
1140
|
+
conversation_id: this.id,
|
|
1141
|
+
run_id: runRecord.run_id,
|
|
1142
|
+
stop_reason: stopReason,
|
|
1143
|
+
error: failure,
|
|
1144
|
+
answer: c.answer,
|
|
1145
|
+
thinking: formatReasoning(c, reasoning),
|
|
1146
|
+
thinking_stats: {
|
|
1147
|
+
hidden: reasoning === 'hide' || reasoning === 'marker',
|
|
1148
|
+
chars: c.reasoning.length,
|
|
1149
|
+
chunks: c.reasoningChunks,
|
|
1150
|
+
ms: c.reasoningStartedAt ? now() - c.reasoningStartedAt : 0,
|
|
1151
|
+
},
|
|
1152
|
+
tools_used: [...c.tools.values()],
|
|
1153
|
+
usage: c.usage,
|
|
1154
|
+
auto_approvals_total: c.autoApprovalTotal,
|
|
1155
|
+
auto_escalations_total: c.autoEscalationTotal,
|
|
1156
|
+
auto_approvals: c.autoApprovals,
|
|
1157
|
+
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
1158
|
+
diff_stat: after,
|
|
1159
|
+
elapsed_ms: now() - t0,
|
|
1160
|
+
};
|
|
1161
|
+
attachReview(result);
|
|
1162
|
+
runRecord.ended_at = now();
|
|
1163
|
+
runRecord.elapsed_ms = result.elapsed_ms;
|
|
1164
|
+
runRecord.status = failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done';
|
|
1165
|
+
runRecord.result = result;
|
|
1166
|
+
// ★ 无条件写哨兵:**一个回合 = 一个哨兵文件**,没有例外。
|
|
1167
|
+
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
1168
|
+
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
1169
|
+
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
1170
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1171
|
+
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1172
|
+
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1173
|
+
return result;
|
|
1174
|
+
} catch (e) {
|
|
1175
|
+
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
1176
|
+
this.busy = false;
|
|
1177
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
1178
|
+
this.collector = null;
|
|
1179
|
+
const waiters = this.idleWaiters;
|
|
1180
|
+
this.idleWaiters = [];
|
|
1181
|
+
for (const w of waiters) w();
|
|
1182
|
+
this.#scheduleDrain();
|
|
1183
|
+
const fallback = {
|
|
1184
|
+
conversation_id: this.id,
|
|
1185
|
+
run_id: runRecord.run_id,
|
|
1186
|
+
stop_reason: 'error',
|
|
1187
|
+
error: `回合收尾异常: ${e.message}`,
|
|
1188
|
+
answer: c.answer ?? '',
|
|
1189
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1190
|
+
tools_used: [...(c.tools?.values?.() ?? [])],
|
|
1191
|
+
workspace_changed: false,
|
|
1192
|
+
elapsed_ms: now() - t0,
|
|
1193
|
+
};
|
|
1194
|
+
attachReview(fallback);
|
|
1195
|
+
runRecord.ended_at = now();
|
|
1196
|
+
runRecord.status = 'error';
|
|
1197
|
+
runRecord.error = fallback.error;
|
|
1198
|
+
runRecord.result = fallback;
|
|
1199
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1200
|
+
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1201
|
+
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1202
|
+
return fallback;
|
|
1059
1203
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
answer: c.answer,
|
|
1081
|
-
thinking: formatReasoning(c, reasoning),
|
|
1082
|
-
thinking_stats: {
|
|
1083
|
-
hidden: reasoning === 'hide' || reasoning === 'marker',
|
|
1084
|
-
chars: c.reasoning.length,
|
|
1085
|
-
chunks: c.reasoningChunks,
|
|
1086
|
-
ms: c.reasoningStartedAt ? now() - c.reasoningStartedAt : 0,
|
|
1087
|
-
},
|
|
1088
|
-
tools_used: [...c.tools.values()],
|
|
1089
|
-
usage: c.usage,
|
|
1090
|
-
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
1091
|
-
diff_stat: after,
|
|
1092
|
-
elapsed_ms: now() - t0,
|
|
1093
|
-
};
|
|
1094
|
-
attachReview(result);
|
|
1095
|
-
runRecord.ended_at = now();
|
|
1096
|
-
runRecord.elapsed_ms = result.elapsed_ms;
|
|
1097
|
-
runRecord.status = failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done';
|
|
1098
|
-
runRecord.result = result;
|
|
1099
|
-
// ★ 无条件写哨兵:**一个回合 = 一个哨兵文件**,没有例外。
|
|
1100
|
-
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
1101
|
-
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
1102
|
-
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
1103
|
-
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1104
|
-
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1105
|
-
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1106
|
-
return result;
|
|
1107
|
-
} catch (e) {
|
|
1108
|
-
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
1109
|
-
this.busy = false;
|
|
1110
|
-
if (this.currentRun === runRecord) this.currentRun = null;
|
|
1111
|
-
this.collector = null;
|
|
1112
|
-
const waiters = this.idleWaiters;
|
|
1113
|
-
this.idleWaiters = [];
|
|
1114
|
-
for (const w of waiters) w();
|
|
1115
|
-
this.#scheduleDrain();
|
|
1116
|
-
const fallback = {
|
|
1117
|
-
conversation_id: this.id,
|
|
1118
|
-
run_id: runRecord.run_id,
|
|
1119
|
-
stop_reason: 'error',
|
|
1120
|
-
error: `回合收尾异常: ${e.message}`,
|
|
1121
|
-
answer: c.answer ?? '',
|
|
1122
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1123
|
-
tools_used: [...(c.tools?.values?.() ?? [])],
|
|
1124
|
-
workspace_changed: false,
|
|
1125
|
-
elapsed_ms: now() - t0,
|
|
1126
|
-
};
|
|
1127
|
-
attachReview(fallback);
|
|
1128
|
-
runRecord.ended_at = now();
|
|
1129
|
-
runRecord.status = 'error';
|
|
1130
|
-
runRecord.error = fallback.error;
|
|
1131
|
-
runRecord.result = fallback;
|
|
1132
|
-
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1133
|
-
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1134
|
-
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1135
|
-
return fallback;
|
|
1136
|
-
}
|
|
1204
|
+
})().catch((e) => {
|
|
1205
|
+
// 最后一层保险:收尾连兜底都失败时也绝不把 rejection 丢给事件循环。
|
|
1206
|
+
this.#hub.log.info(`[conv ${this.id}] 回合收尾二次异常(已忽略): ${e.message}`);
|
|
1207
|
+
if (!runRecord.result) {
|
|
1208
|
+
runRecord.result = {
|
|
1209
|
+
conversation_id: this.id,
|
|
1210
|
+
run_id: runRecord.run_id,
|
|
1211
|
+
stop_reason: 'error',
|
|
1212
|
+
error: `回合收尾异常: ${e.message}`,
|
|
1213
|
+
answer: c.answer ?? '',
|
|
1214
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1215
|
+
tools_used: [],
|
|
1216
|
+
workspace_changed: false,
|
|
1217
|
+
elapsed_ms: now() - t0,
|
|
1218
|
+
};
|
|
1219
|
+
runRecord.status = 'error';
|
|
1220
|
+
}
|
|
1221
|
+
return runRecord.result;
|
|
1222
|
+
});
|
|
1223
|
+
return finalizePromise;
|
|
1137
1224
|
};
|
|
1138
1225
|
|
|
1139
1226
|
/** 没等到结果时给调用方的收据(它靠 run_id + 哨兵文件事后验收)。 */
|
|
@@ -1150,30 +1237,40 @@ export class Conversation {
|
|
|
1150
1237
|
|
|
1151
1238
|
// 关键:第三个参数传 0 = **不设超时**。回合只能由它自己结束,或由 session/cancel 结束。
|
|
1152
1239
|
const acp = proc.request('session/prompt', { sessionId: this.id, prompt: [{ type: 'text', text }] }, 0);
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1240
|
+
// 这个 Promise 在 finalize **真正完成**(含 workspace_changed、审核单、哨兵)后才 settle。
|
|
1241
|
+
// 等待方必须等它,而不是只等 acp —— 否则会拿到还没填完的 runRecord.result。
|
|
1242
|
+
const finished = acp
|
|
1243
|
+
.then(
|
|
1244
|
+
(res) => {
|
|
1245
|
+
if (res?.usage) c.usage = c.usage ?? res.usage;
|
|
1246
|
+
return finalize(res?.stopReason ?? null, null);
|
|
1247
|
+
},
|
|
1248
|
+
(e) => finalize('error', e.message),
|
|
1249
|
+
)
|
|
1250
|
+
.catch((e) => {
|
|
1251
|
+
// finalize 自己是防弹的;这里只兜住同步抛出的极端情况。
|
|
1252
|
+
this.#hub.log.info(`[conv ${this.id}] 回合回调异常(已兜底): ${e.message}`);
|
|
1253
|
+
return runRecord.result ?? null;
|
|
1254
|
+
});
|
|
1160
1255
|
|
|
1161
|
-
if (!wait)
|
|
1256
|
+
if (!wait) {
|
|
1257
|
+
// 同步走到这里时 finalize 还没开始(ACP 回调在微任务里),返回收据即可。
|
|
1258
|
+
// 若极端情况下已经收尾,则等它给出完整结果,不要返回半成品。
|
|
1259
|
+
return settled ? await (finalizePromise ?? runRecord.result) : receipt(0);
|
|
1260
|
+
}
|
|
1162
1261
|
if (timeoutMs <= 0) {
|
|
1163
|
-
await
|
|
1164
|
-
() => {},
|
|
1165
|
-
() => {},
|
|
1166
|
-
);
|
|
1262
|
+
await finished;
|
|
1167
1263
|
return runRecord.result;
|
|
1168
1264
|
}
|
|
1169
1265
|
const timedOut = await Promise.race([
|
|
1170
|
-
|
|
1266
|
+
finished.then(
|
|
1171
1267
|
() => false,
|
|
1172
1268
|
() => false,
|
|
1173
1269
|
),
|
|
1174
1270
|
new Promise((r) => setTimeout(() => r(true), timeoutMs)),
|
|
1175
1271
|
]);
|
|
1176
|
-
|
|
1272
|
+
// 超时发生在 finalize 完成之前(包括 git 快照还在跑):给收据,不返回半成品结果。
|
|
1273
|
+
if (timedOut) return receipt(timeoutMs);
|
|
1177
1274
|
return runRecord.result;
|
|
1178
1275
|
}
|
|
1179
1276
|
|
|
@@ -1667,30 +1764,178 @@ export class Conversation {
|
|
|
1667
1764
|
}
|
|
1668
1765
|
}
|
|
1669
1766
|
|
|
1670
|
-
/**
|
|
1767
|
+
/**
|
|
1768
|
+
* 两次 workspace 快照是否表示同一组工作区变更。
|
|
1769
|
+
*
|
|
1770
|
+
* 优先比较 `fingerprint`(覆盖**全量**条目:状态行 + 每个路径的 size/mtime,
|
|
1771
|
+
* 未跟踪目录递归展开)。只比较前 40 条 entries 会把"同一状态行但内容变了"
|
|
1772
|
+
* 和第 41 条之后的改动都漏掉(workspace_changed 假阴性)。
|
|
1773
|
+
*/
|
|
1671
1774
|
export function sameWorkspaceSnapshot(a, b) {
|
|
1672
1775
|
if (!a && !b) return true;
|
|
1673
1776
|
if (!a || !b) return false;
|
|
1777
|
+
if (typeof a.fingerprint === 'string' && typeof b.fingerprint === 'string') {
|
|
1778
|
+
return a.fingerprint === b.fingerprint;
|
|
1779
|
+
}
|
|
1780
|
+
// 兼容旧快照/测试里手写的 { changed_files, entries }。
|
|
1674
1781
|
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
1675
1782
|
}
|
|
1676
1783
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1784
|
+
const GIT_TIMEOUT_MS = 8000;
|
|
1785
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
1786
|
+
const MAX_UNTRACKED_DIR_ENTRIES = 2000;
|
|
1787
|
+
const execFileAsync = promisify(execFile);
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* 安全 git 参数(放 subcommand 之前)。
|
|
1791
|
+
*
|
|
1792
|
+
* `--no-optional-locks` 是 **git 全局选项**,放在 `status` 之后会被 git 当成
|
|
1793
|
+
* 未知参数直接退出 129 —— 那样快照永远是 null,workspace_changed 恒为 false。
|
|
1794
|
+
*/
|
|
1795
|
+
const GIT_SAFE = ['--no-optional-locks', '-c', 'core.fsmonitor=false'];
|
|
1796
|
+
|
|
1797
|
+
function gitEnv() {
|
|
1798
|
+
return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** 异步跑一次 git 并取 stdout;非 git 目录/超时/输出过大都抛给调用方。 */
|
|
1802
|
+
async function gitOutput(cwd, args) {
|
|
1803
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
1804
|
+
cwd,
|
|
1805
|
+
encoding: 'utf8',
|
|
1806
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1807
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
1808
|
+
windowsHide: true,
|
|
1809
|
+
env: gitEnv(),
|
|
1810
|
+
});
|
|
1811
|
+
return stdout;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** 解析 porcelain v1 -z 状态行;rename/copy 的“原路径”是下一个 NUL 段。 */
|
|
1815
|
+
function parsePorcelainZ(stdout) {
|
|
1816
|
+
const tokens = String(stdout ?? '').split('\0');
|
|
1817
|
+
const rows = [];
|
|
1818
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1819
|
+
const token = tokens[i];
|
|
1820
|
+
if (!token) continue;
|
|
1821
|
+
// 形如 "XY path";posix 允许路径里有空格,所以只切前三个字符。
|
|
1822
|
+
if (token.length < 4 || token[2] !== ' ') continue;
|
|
1823
|
+
const status = token.slice(0, 2);
|
|
1824
|
+
const path = token.slice(3);
|
|
1825
|
+
let orig = null;
|
|
1826
|
+
if (/[RC]/.test(status)) orig = tokens[++i] || null;
|
|
1827
|
+
rows.push({ status, path, orig });
|
|
1828
|
+
}
|
|
1829
|
+
return rows;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* 把一个路径的当前状态(类型 + size + mtimeMs)混进指纹。
|
|
1834
|
+
*
|
|
1835
|
+
* - 普通文件:size + mtimeMs 足以识别"状态行没变、内容变了";
|
|
1836
|
+
* - 未跟踪目录(`?? dir/`):目录 mtime 只在直接子项增删时变,光看它不够,
|
|
1837
|
+
* 所以递归展开(只用 lstat,不跟随符号链接/不钻 .git,且有预算上限);
|
|
1838
|
+
* - 已跟踪的目录条目(子模块):只看它自己的 lstat,避免把子模块整个走一遍。
|
|
1839
|
+
*/
|
|
1840
|
+
function hashPathStat(hash, full, label, budget) {
|
|
1841
|
+
let st;
|
|
1842
|
+
try {
|
|
1843
|
+
st = lstatSync(full);
|
|
1844
|
+
} catch {
|
|
1845
|
+
hash.update(`${label}\0missing\0`);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (st.isDirectory() && budget) {
|
|
1849
|
+
hash.update(`${label}\0dir\0${st.mtimeMs}\0`);
|
|
1850
|
+
let entries;
|
|
1851
|
+
try {
|
|
1852
|
+
entries = readdirSync(full, { withFileTypes: true });
|
|
1853
|
+
} catch {
|
|
1854
|
+
hash.update('unreadable\0');
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1858
|
+
for (const ent of entries) {
|
|
1859
|
+
if (ent.name === '.git') continue;
|
|
1860
|
+
if (budget.left <= 0) {
|
|
1861
|
+
hash.update(`${label}\0truncated\0`);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
budget.left -= 1;
|
|
1865
|
+
hashPathStat(hash, join(full, ent.name), `${label}/${ent.name}`, budget);
|
|
1866
|
+
}
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const kind = st.isSymbolicLink() ? 'link' : st.isFile() ? 'file' : st.isDirectory() ? 'dir' : 'other';
|
|
1870
|
+
hash.update(`${label}\0${kind}\0${st.size}\0${st.mtimeMs}\0`);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。
|
|
1875
|
+
*
|
|
1876
|
+
* 异步实现:`execFile` 不占用事件循环 —— 一个 MCP 服务进程承载多个会话,
|
|
1877
|
+
* 同步 `spawnSync` 最长 8s 会把所有会话的 ACP 消息/审批一起卡住。
|
|
1878
|
+
*
|
|
1879
|
+
* 返回对象向后兼容:`changed_files` = 全量状态条目数,`entries` = 前 40 条
|
|
1880
|
+
* 展示用状态行;新增 `fingerprint` 覆盖**全量**条目(含逐路径 size/mtime),
|
|
1881
|
+
* 供 `sameWorkspaceSnapshot` 判断"内容是否真的变了"。
|
|
1882
|
+
*/
|
|
1883
|
+
export async function gitStatus(cwd) {
|
|
1679
1884
|
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
1680
1885
|
try {
|
|
1681
1886
|
// ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
|
|
1682
1887
|
// 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
|
|
1683
1888
|
// DSH_MCP_GIT_STATUS=0 完全关掉。
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
if (
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1889
|
+
// porcelain 的路径相对 **仓库根**(cwd 是子目录时也是),所以同时取 top-level。
|
|
1890
|
+
const [statusOut, rootOut] = await Promise.all([
|
|
1891
|
+
gitOutput(cwd, [...GIT_SAFE, 'status', '--porcelain', '-z']),
|
|
1892
|
+
gitOutput(cwd, [...GIT_SAFE, 'rev-parse', '--show-toplevel']),
|
|
1893
|
+
]);
|
|
1894
|
+
const rows = parsePorcelainZ(statusOut);
|
|
1895
|
+
if (!rows.length) return null;
|
|
1896
|
+
// git 输出的是 `C:/...` 风格,统一成原生分隔符,后面 startsWith 才不会误判越界。
|
|
1897
|
+
const root = resolve(String(rootOut ?? '').trim() || cwd);
|
|
1898
|
+
|
|
1899
|
+
// 状态行按路径排序后参与指纹,避免依赖 git 的输出顺序。
|
|
1900
|
+
const sorted = rows
|
|
1901
|
+
.slice()
|
|
1902
|
+
.sort((a, b) =>
|
|
1903
|
+
a.path === b.path
|
|
1904
|
+
? a.status < b.status
|
|
1905
|
+
? -1
|
|
1906
|
+
: a.status > b.status
|
|
1907
|
+
? 1
|
|
1908
|
+
: 0
|
|
1909
|
+
: a.path < b.path
|
|
1910
|
+
? -1
|
|
1911
|
+
: 1,
|
|
1912
|
+
);
|
|
1913
|
+
|
|
1914
|
+
const hash = createHash('sha256');
|
|
1915
|
+
const budget = { left: MAX_UNTRACKED_DIR_ENTRIES };
|
|
1916
|
+
const entries = [];
|
|
1917
|
+
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
|
1918
|
+
for (const row of sorted) {
|
|
1919
|
+
const display = row.orig ? `${row.status} ${row.orig} -> ${row.path}` : `${row.status} ${row.path}`;
|
|
1920
|
+
if (entries.length < 40) entries.push(display);
|
|
1921
|
+
hash.update(display);
|
|
1922
|
+
hash.update('\0');
|
|
1923
|
+
|
|
1924
|
+
const cleanPath = row.path.endsWith('/') ? row.path.slice(0, -1) : row.path;
|
|
1925
|
+
const full = resolve(root, cleanPath);
|
|
1926
|
+
if (full !== root && !full.startsWith(rootPrefix)) {
|
|
1927
|
+
hash.update('outside-worktree\0');
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
// 只有未跟踪目录需要递归;已跟踪条目(含子模块)看 lstat 即可。
|
|
1931
|
+
hashPathStat(hash, full, cleanPath, row.status === '??' ? budget : null);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return {
|
|
1935
|
+
changed_files: rows.length,
|
|
1936
|
+
entries,
|
|
1937
|
+
fingerprint: hash.digest('hex'),
|
|
1938
|
+
};
|
|
1694
1939
|
} catch {
|
|
1695
1940
|
return null;
|
|
1696
1941
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -80,6 +80,9 @@ dsh_interject —— 一个回合 = 一个哨兵,没有例外)。dsh_send
|
|
|
80
80
|
你再读文件即得结果。示例(Bash;直接用返回里的 sentinel_file_posix,别手工转换路径):
|
|
81
81
|
sent="$sentinel_file_posix"
|
|
82
82
|
dl=$(( $(date +%s)+2100 )); until [ -f "$sent" ]; do [ $(date +%s) -ge $dl ] && { echo TIMEOUT; exit 1; }; sleep 2; done; echo DONE
|
|
83
|
+
宿主是 Windows、只有 PowerShell(或 Git Bash 不可用)时,用原生路径 sentinel_file:
|
|
84
|
+
$f='<sentinel_file>'; $dl=(Get-Date).AddSeconds(2100); while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }; 'DONE'
|
|
85
|
+
(路径里有单引号时写成两个单引号;后台收据会直接给出拼好的这一行。)
|
|
83
86
|
读文件即可拿到 status 和 result(result.review 是审核单);**不受内存窗口限制、也扛本服务重启**。文件里 status=error 表示失败。
|
|
84
87
|
拿到结果后先按审核单独立核验,再调用 dsh_review 提交裁决;审核状态会回写进哨兵。
|
|
85
88
|
- 想阻塞拿结果 → 传 **wait=true**;即使等超时也不会丢结果:返回会变成收据(still_running + sentinel_file),
|
package/src/review.mjs
CHANGED
|
@@ -85,6 +85,15 @@ export function buildReviewChecklist({ result = {}, permission = null } = {}) {
|
|
|
85
85
|
}),确认影响并决定是否重派。`,
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
|
+
const autoTotal = Number(result.auto_approvals_total) || 0;
|
|
89
|
+
if (autoTotal > 0) {
|
|
90
|
+
const esc = Number(result.auto_escalations_total) || 0;
|
|
91
|
+
items.push(
|
|
92
|
+
`本轮有 ${autoTotal} 次审批被自动放行(on_approval=auto-allow)` +
|
|
93
|
+
(esc > 0 ? `,其中 ${esc} 次申请了沙箱提权(如 danger-full-access),这些操作已在 ${permission ?? '权限档'} 之外执行` : '') +
|
|
94
|
+
':对照 result.auto_approvals 逐条确认是否在预期内;需要逐次把关就用 on_approval=ask 新建会话。',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
88
97
|
if (permission) items.push(`确认操作没有超出会话权限档(${permission})。`);
|
|
89
98
|
items.push('裁决:pass(可直接采用)/ partial(部分可用,需补充)/ fail(不可用,需重做);fail/partial 必须在 notes 里说明理由。');
|
|
90
99
|
return items;
|
|
@@ -109,6 +118,8 @@ export function buildReviewTicket({
|
|
|
109
118
|
workspace_changed: result?.workspace_changed ?? null,
|
|
110
119
|
changed_files: result?.diff_stat?.changed_files ?? null,
|
|
111
120
|
tools: toolNames(result),
|
|
121
|
+
auto_approvals: Number.isFinite(result?.auto_approvals_total) ? result.auto_approvals_total : null,
|
|
122
|
+
auto_escalations: Number.isFinite(result?.auto_escalations_total) ? result.auto_escalations_total : null,
|
|
112
123
|
elapsed_ms: result?.elapsed_ms ?? null,
|
|
113
124
|
};
|
|
114
125
|
return {
|
package/src/tools.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
DSH_PROFILE,
|
|
30
30
|
toPosixPath,
|
|
31
31
|
resolveDshBin,
|
|
32
|
+
dshVersionStatus,
|
|
33
|
+
isNpxCachePath,
|
|
32
34
|
} from './config.mjs';
|
|
33
35
|
import { inspectProfile } from './bootstrap.mjs';
|
|
34
36
|
import { audit } from './audit.mjs';
|
|
@@ -140,7 +142,7 @@ export const TOOL_DEFS = [
|
|
|
140
142
|
'而是自动降级为后台:返回 run_id + sentinel_file,回合继续跑完,结果稍后由哨兵文件通知。' +
|
|
141
143
|
'(timeout_ms=0 表示一直等。)所以任何时候你都能拿到结果,不用怕"超时了白干"。' +
|
|
142
144
|
'等待模式与后台模式都在回合结束时原子落地一个含最终结果的小文件——' +
|
|
143
|
-
'**推荐用后台任务(Bash run_in_background + `until [ -f sentinel_file ]
|
|
145
|
+
'**推荐用后台任务(Bash run_in_background + `until [ -f sentinel_file ]`;Windows/PowerShell 用 `Test-Path` 循环,收据里给出现成命令)等它,跑完自动通知你,无需轮询**;' +
|
|
144
146
|
'文件出现后读它即得结果,或用 dsh_get(conversation_id, run_id) 验收,dsh_read 可中途看输出。' +
|
|
145
147
|
'默认隐藏思考内容(只返回正文 + 思考统计)。' +
|
|
146
148
|
'结果与后台收据都会带 `review` 审核单(默认 advisory):调用方核验后用 dsh_review 提交裁决。' +
|
|
@@ -252,8 +254,10 @@ export const TOOL_DEFS = [
|
|
|
252
254
|
max_chars: {
|
|
253
255
|
type: 'number',
|
|
254
256
|
description:
|
|
255
|
-
'
|
|
256
|
-
'structured.run.result
|
|
257
|
+
'结果预算(默认 0 = 不截断,返回完整结果)。>0 时**同时**作用于 text 与 structured:' +
|
|
258
|
+
'正文截到该长度(附 answer_truncated/next_offset),structured.run.result.tools_used 只保留' +
|
|
259
|
+
'工具名/状态与 input_chars,不再带完整输入。完整结果始终在 sentinel_file 里。' +
|
|
260
|
+
'一个回合用了很多工具时,默认返回可能超过客户端单次工具结果上限,建议传 8000 左右。',
|
|
257
261
|
},
|
|
258
262
|
},
|
|
259
263
|
['conversation_id'],
|
|
@@ -452,6 +456,9 @@ function renderReviewSection(review) {
|
|
|
452
456
|
if (ev.workspace_changed != null) bits.push(`工作区变化=${ev.workspace_changed ? '是' : '否'}`);
|
|
453
457
|
if (ev.changed_files != null) bits.push(`改动文件=${ev.changed_files}`);
|
|
454
458
|
if (Array.isArray(ev.tools) && ev.tools.length) bits.push(`工具=[${ev.tools.join(', ')}]`);
|
|
459
|
+
if (ev.auto_approvals) {
|
|
460
|
+
bits.push(`⚠ 自动放行审批=${ev.auto_approvals}${ev.auto_escalations ? `(含提权 ${ev.auto_escalations})` : ''}`);
|
|
461
|
+
}
|
|
455
462
|
if (bits.length) lines.push(`证据: ${bits.join(' · ')}`);
|
|
456
463
|
if (pending) {
|
|
457
464
|
lines.push('在把这次结果当作完成之前,请独立核验:');
|
|
@@ -471,6 +478,48 @@ function renderReviewSection(review) {
|
|
|
471
478
|
return lines.join('\n');
|
|
472
479
|
}
|
|
473
480
|
|
|
481
|
+
/**
|
|
482
|
+
* 按 `max_chars` 预算压缩一份回合结果(只用于返回给调用方,不改原对象)。
|
|
483
|
+
*
|
|
484
|
+
* 体积大头是 tools_used[].input:edit/write 会带完整的新旧文本,几十次工具调用
|
|
485
|
+
* 轻松几万字。压缩后每个工具只留 id/title/name/status/kind 与 input_chars;
|
|
486
|
+
* 正文与思考截到预算长度。完整版本仍在哨兵文件里。
|
|
487
|
+
*/
|
|
488
|
+
export function compactResultForBudget(result, cap) {
|
|
489
|
+
if (!result || typeof result !== 'object' || !(cap > 0)) return result ?? null;
|
|
490
|
+
const out = { ...result };
|
|
491
|
+
if (typeof out.answer === 'string' && out.answer.length > cap) out.answer = out.answer.slice(0, cap);
|
|
492
|
+
if (typeof out.thinking === 'string' && out.thinking.length > cap) out.thinking = out.thinking.slice(0, cap);
|
|
493
|
+
if (Array.isArray(out.tools_used)) {
|
|
494
|
+
out.tools_used = out.tools_used.map((t) => {
|
|
495
|
+
if (!t || typeof t !== 'object') return t;
|
|
496
|
+
const { input, ...meta } = t;
|
|
497
|
+
if (input === undefined) return meta;
|
|
498
|
+
let chars = 0;
|
|
499
|
+
try {
|
|
500
|
+
chars = typeof input === 'string' ? input.length : JSON.stringify(input)?.length ?? 0;
|
|
501
|
+
} catch {
|
|
502
|
+
chars = 0;
|
|
503
|
+
}
|
|
504
|
+
return { ...meta, input_chars: chars };
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* PowerShell 版的哨兵等待命令(Windows 宿主上 Git Bash 可能不可用或挂住)。
|
|
512
|
+
* 路径放进单引号字面量:PowerShell 单引号里只有 `'` 需要转义(写成 `''`),
|
|
513
|
+
* `$`、反引号都不展开,所以不会被路径内容注入。
|
|
514
|
+
*/
|
|
515
|
+
export function psWaitCommand(sentinelPath, timeoutSec = 2100) {
|
|
516
|
+
const lit = `'${String(sentinelPath).replace(/'/g, "''")}'`;
|
|
517
|
+
return (
|
|
518
|
+
`$f=${lit}; $dl=(Get-Date).AddSeconds(${Math.max(1, Math.floor(timeoutSec))}); ` +
|
|
519
|
+
`while(-not (Test-Path -LiteralPath $f)){ if((Get-Date) -gt $dl){ 'TIMEOUT'; exit 1 }; Start-Sleep -Seconds 2 }; 'DONE'`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
474
523
|
/** 把结构化结果渲染成给模型看的文本。 */
|
|
475
524
|
function renderSendResult(r) {
|
|
476
525
|
const lines = [];
|
|
@@ -496,6 +545,12 @@ function renderSendResult(r) {
|
|
|
496
545
|
const tools = (r.tools_used ?? []).map((t) => t.name ?? t.title).filter(Boolean);
|
|
497
546
|
if (tools.length) meta.push(`工具=[${[...new Set(tools)].join(', ')}]`);
|
|
498
547
|
if (r.workspace_changed) meta.push(`本轮工作区状态发生变化(当前共 ${r.diff_stat?.changed_files ?? '?'} 个文件有改动)`);
|
|
548
|
+
if (r.auto_approvals_total) {
|
|
549
|
+
meta.push(
|
|
550
|
+
`自动放行审批 ${r.auto_approvals_total} 次` +
|
|
551
|
+
(r.auto_escalations_total ? `(含沙箱提权 ${r.auto_escalations_total} 次)` : ''),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
499
554
|
lines.push('');
|
|
500
555
|
lines.push(`[${meta.join(' · ')}]`);
|
|
501
556
|
|
|
@@ -533,6 +588,8 @@ function backgroundReceipt(hub, conv, res) {
|
|
|
533
588
|
sentinel_file: sentinel,
|
|
534
589
|
/** Bash/MSYS 形式(D:\a\b → /d/a/b);非 Windows 等于 sentinel_file。 */
|
|
535
590
|
sentinel_file_posix: sentinelPosix,
|
|
591
|
+
/** Windows 宿主:现成的 PowerShell 等待命令(路径已按单引号字面量转义)。 */
|
|
592
|
+
...(process.platform === 'win32' ? { sentinel_wait_powershell: psWaitCommand(sentinel) } : {}),
|
|
536
593
|
...(review ? { review } : {}),
|
|
537
594
|
},
|
|
538
595
|
text:
|
|
@@ -544,6 +601,10 @@ function backgroundReceipt(hub, conv, res) {
|
|
|
544
601
|
`推荐用后台任务等它出现,文件一到就会唤醒你:\n` +
|
|
545
602
|
` Bash(run_in_background): sent="${sentinelPosix}"; \\\n` +
|
|
546
603
|
` dl=$(( $(date +%s)+2100 )); until [ -f "$sent" ]; do [ $(date +%s) -ge $dl ] && { echo TIMEOUT; exit 1; }; sleep 2; done; echo DONE\n` +
|
|
604
|
+
(process.platform === 'win32'
|
|
605
|
+
? `Windows 上 Bash 不可用(或会挂住)时,用 PowerShell 版本(原生路径,已转义好):\n` +
|
|
606
|
+
` PowerShell(run_in_background): ${psWaitCommand(sentinel)}\n`
|
|
607
|
+
: '') +
|
|
547
608
|
`文件出现后:直接读它拿结果(推荐——不受内存窗口限制、还扛服务重启),或 dsh_get(conversation_id, run_id) 验收。\n` +
|
|
548
609
|
`消费完可以 rm 掉它。其它随时可用:dsh_status(是否在跑)、dsh_read(增量看输出)。` +
|
|
549
610
|
(review ? `\n\n${renderReviewSection(review)}` : '') +
|
|
@@ -917,7 +978,9 @@ export function createHandlers(hub, opts = {}) {
|
|
|
917
978
|
`未知 run_id: ${args.run_id}(内存只留最近 20 条;哨兵文件 ${hub.runSentinelPath(args.conversation_id, args.run_id)} 也不存在或不可读)`,
|
|
918
979
|
);
|
|
919
980
|
}
|
|
920
|
-
//
|
|
981
|
+
// 预算同时作用于 text 与 structured:Claude Code 等客户端会把 structuredContent
|
|
982
|
+
// 一并塞进上下文,只截 text 时一次带几十个工具输入的回合仍会超过单次结果上限
|
|
983
|
+
// (实测 75K 字符被拒收)。完整结果始终在哨兵文件里。
|
|
921
984
|
const answerFull = String(run?.result?.answer ?? '');
|
|
922
985
|
const capRaw = Number(args.max_chars);
|
|
923
986
|
const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 0;
|
|
@@ -966,10 +1029,14 @@ export function createHandlers(hub, opts = {}) {
|
|
|
966
1029
|
answer_truncated: truncated,
|
|
967
1030
|
next_offset: truncated ? cap : null,
|
|
968
1031
|
review: run.review ?? run.result?.review ?? null,
|
|
969
|
-
result: run.result,
|
|
1032
|
+
result: cap > 0 ? compactResultForBudget(run.result, cap) : run.result,
|
|
1033
|
+
...(cap > 0 ? { result_compacted: true } : {}),
|
|
970
1034
|
}
|
|
971
1035
|
: null;
|
|
972
|
-
if (sentinelFile)
|
|
1036
|
+
if (sentinelFile) {
|
|
1037
|
+
meta.push(`\n哨兵文件: ${sentinelFile}`);
|
|
1038
|
+
if (cap > 0) meta.push('(max_chars 已同时压缩 structured:正文截断、工具输入只留长度;完整结果读这个文件)');
|
|
1039
|
+
}
|
|
973
1040
|
return {
|
|
974
1041
|
structured: {
|
|
975
1042
|
...snap,
|
|
@@ -1296,7 +1363,26 @@ export function createHandlers(hub, opts = {}) {
|
|
|
1296
1363
|
version =
|
|
1297
1364
|
JSON.parse(readFileSync(join(dirname(bin), '..', 'package.json'), 'utf8')).version ?? null;
|
|
1298
1365
|
} catch {}
|
|
1299
|
-
|
|
1366
|
+
const fromNpx = isNpxCachePath(bin);
|
|
1367
|
+
add(
|
|
1368
|
+
'dsh_bin',
|
|
1369
|
+
fromNpx ? 'warn' : 'ok',
|
|
1370
|
+
`${bin}${version ? ` (DSH ${version})` : ''}${fromNpx ? ' —— 位于 npx 缓存,清缓存后会失效' : ''}`,
|
|
1371
|
+
fromNpx
|
|
1372
|
+
? '全局安装 DSH(npm i -g @deepseek-ai/dsh)或把 DSH_BIN 指向一个固定位置的 lib/bin.js'
|
|
1373
|
+
: null,
|
|
1374
|
+
);
|
|
1375
|
+
const vs = dshVersionStatus(version);
|
|
1376
|
+
add(
|
|
1377
|
+
'dsh_version',
|
|
1378
|
+
vs.status,
|
|
1379
|
+
`${vs.level}: ${vs.note}`,
|
|
1380
|
+
vs.status === 'ok'
|
|
1381
|
+
? null
|
|
1382
|
+
: vs.level === 'unverified'
|
|
1383
|
+
? '对照 docs/zh/compatibility.md;升级后跑一遍 npm test,必要时 npm run test:all'
|
|
1384
|
+
: '升级到 DSH 0.1.7-rc.1 及以上',
|
|
1385
|
+
);
|
|
1300
1386
|
} catch (e) {
|
|
1301
1387
|
add('dsh_bin', 'fail', e.message, '设置 DSH_BIN 指向 @deepseek-ai/dsh/lib/bin.js');
|
|
1302
1388
|
}
|