dsh-any-agent 0.5.1 → 0.6.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +194 -1
  2. package/DESIGN.md +2 -2
  3. package/README.md +1 -1
  4. package/README.zh.md +1 -1
  5. package/USAGE-NOTES.en.md +5 -3
  6. package/USAGE-NOTES.md +11 -4
  7. package/bin/dsh-any-agent-workspaces.mjs +12 -5
  8. package/bin/dsh-any-agent.mjs +4 -1
  9. package/bin/restore.mjs +1 -1
  10. package/docs/en/architecture.md +3 -3
  11. package/docs/en/compatibility.md +27 -5
  12. package/docs/en/reference.md +2 -1
  13. package/docs/en/release.md +4 -0
  14. package/docs/en/security.md +2 -0
  15. package/docs/en/tests.md +58 -16
  16. package/docs/en/usage.md +10 -1
  17. package/docs/zh/architecture.md +3 -3
  18. package/docs/zh/compatibility.md +25 -5
  19. package/docs/zh/limits.md +1 -1
  20. package/docs/zh/reference.md +2 -1
  21. package/docs/zh/release.md +3 -0
  22. package/docs/zh/security.md +2 -0
  23. package/docs/zh/tests.md +58 -16
  24. package/docs/zh/usage.md +10 -1
  25. package/package.json +4 -1
  26. package/scripts/detect-dsh.mjs +55 -0
  27. package/scripts/lint.mjs +595 -0
  28. package/scripts/sync-test-counts.mjs +699 -0
  29. package/src/acp-session.mjs +63 -0
  30. package/src/acp.mjs +14 -5
  31. package/src/config.mjs +42 -99
  32. package/src/conversation.mjs +1600 -0
  33. package/src/doctor.mjs +203 -0
  34. package/src/dsh-locate.mjs +560 -0
  35. package/src/git-snapshot.mjs +196 -0
  36. package/src/hub-store.mjs +108 -0
  37. package/src/hub.mjs +26 -1727
  38. package/src/mcp.mjs +21 -72
  39. package/src/residue.mjs +278 -0
  40. package/src/review.mjs +11 -0
  41. package/src/sentinel.mjs +97 -0
  42. package/src/tool-defs.mjs +399 -0
  43. package/src/tool-render.mjs +262 -0
  44. package/src/tools.mjs +27 -690
  45. package/src/workspace-guard.mjs +89 -0
  46. package/src/workspace.mjs +295 -59
package/docs/en/tests.md CHANGED
@@ -6,17 +6,30 @@
6
6
  npm test # offline suites; smoke skips DSH sections if no DSH
7
7
  npm run test:all # offline + model-backed suites (needs DSH/provider)
8
8
  node test/cleanup.mjs # suite teardown on its own (test-created dirs only; --dry-run to preview)
9
+ npm run lint # zero-dependency static checks: syntax, imports, named exports, unused imports
10
+ node test/run.mjs --all --report .state/test-report.json # same, writing the JSON report to a chosen path (every run writes .state/test-report.json by default)
11
+ npm run docs:counts # write the report's check counts back into this page and the CI comment (see "After changing tests")
12
+ npm run docs:counts:check # check that the docs agree with the report; exit 1 and print the diff if not
9
13
  ```
10
14
 
15
+ `npm run lint` (`scripts/lint.mjs`) runs `node --check` on every `.mjs` under `src/ bin/ scripts/ test/ demo/`, and verifies that every relative `import` / `export … from` points at an existing file with **exactly matching case**, that every named import is a real export of the target module, and that no named import is unused. It adds no npm dependency (`package-check` still enforces zero dependencies) and is a token-level scan rather than a full parser: it prefers a missed report to a false one. Run it after splitting modules or moving functions — a forgotten export tends to blow up only on some cold path, which CI without a real DSH never reaches. The offline CI job runs it first in every matrix cell.
16
+
11
17
  Model-backed suites are not part of the hosted CI matrix (they need a real DSH
12
18
  install and provider credentials). Run them locally, or dispatch the
13
19
  `model-suites` workflow with a self-hosted runner that has DSH configured.
14
20
 
15
- The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`, which removes sessions and workspace registrations whose cwd is **a test-created directory** (`tmpdir()/dsh-any-agent-*` or `Desktop/dsh-any-agent-test-*`). Real project sessions — including projects under `%TEMP%` — and your live state directory are never touched. To remove `Desktop/dsh-any-agent-test-*` artifacts explicitly, run `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`.
21
+ The suite **cleans up after itself and leaves no residue**. Each model-backed suite calls `cleanupSuite` (`test/residue.mjs`) in its own `finally` and removes exactly the sessions it created (`McpClient` records every `dsh_start` id automatically; the suite's own temp root additionally catches sessions whose id was lost and the subagent sessions DSH spawns itself), together with **every artifact** they produced:
22
+ - DSH side: the session log, the projection cache `storages/session_projcache/sessions/<id>.json` (deleting the session directory does **not** remove it — older runs piled up hundreds of orphans), the `workspace.json` registration, and the forget tombstone;
23
+ - this project's side: the registry entry, `meta/`, sentinels `runs/`, reviews `reviews/`, and lock markers `locks/` (the state directory now lives inside the suite's own temp root instead of loose under `%TEMP%`).
24
+
25
+ `run.mjs` still ends with a global `cleanup.mjs` as the backstop (killed processes, leftovers from older versions, test orphans in the projection cache). **Guard rail**: a session is only ever deleted when its recorded cwd is **a test-created directory** (this process's `tmpdir()/dsh-any-agent-*`, `Desktop/dsh-any-agent-test-A|B`, or this repo's `.state/ws-probe-*`) — naming an id does not bypass it. Real project sessions (including projects under `%TEMP%`), the temp roots of parallel agents (TMP pointed elsewhere) and your live state directory are never touched. To remove `Desktop/dsh-any-agent-test-*` artifacts explicitly, run `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`.
26
+
27
+ Why model-backed suites do not point `DSH_HOME` at a throwaway sandbox: DSH keeps its credentials in `$DSH_HOME/.credentials.yaml`, so a sandbox has no model access, and copying keys or routes into a temp directory would just open another leak surface. They keep using the real `~/.dsh` and rely on the precise cleanup above (offline suites already use a sandbox `DSH_HOME`, see `test/sandbox-home.mjs`).
16
28
 
17
29
  | Suite | Checks | Covers |
18
30
  |---|---|---|
19
- | `smoke` | 31 | handshake, tool table (15 tools), conversation creation, config, registry purity, prototype-name tool rejection |
31
+ | `smoke` | 33 (20 without DSH) | handshake, **instructions length cap** (clients truncate long instructions) with the core rules up front, tool table (15 tools), conversation creation, config, registry purity, prototype-name tool rejection |
32
+ | `dsh-locate` | 21 | **DSH auto-discovery**: a synthetic `app.asar` is read with plain Node (version, `engines.node`, entry existence), a desktop install dir maps to its bundled Electron runtime with `ELECTRON_RUN_AS_NODE=1`, candidate ranking (runnable first → newer → desktop > global > npx), `DSH_BIN` pointing inside an asar picks the desktop runtime, `DSH_RUNTIME` override, `engines.node` range evaluation, and parsing of `reg export` output (no tokens, no registry access) |
20
33
  | `boundary` | 46 | protocol edges (double initialize, malformed lines, unknown method), argument validation (including read-only + permissive approval rejection, unknown provider/model, and no stale write-lock marker on a failed create), unknown ids, cursor edges, lifecycle idempotence, unicode |
21
34
  | `integration` | 30 | resume-with-memory across processes, interrupt, both interject modes |
22
35
  | `async` | 21 | fire-and-forget + later collection, including the review ticket in the receipt/result/sentinel and a real `dsh_review` round trip |
@@ -24,47 +37,76 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
24
37
  | `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
38
  | `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
39
  | `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 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
- | `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) |
40
+ | `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 |
41
+ | `failure-terminal` | 10 | **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
42
  | `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
43
  | `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 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)` truncates only the text while structured keeps the full answer, audit records metadata only, `dsh_doctor` essential checks (no tokens) |
44
+ | `resume-retry` | 63 | **cold-resume timeout retry** (uses `test/fake-acp.mjs` as DSH, run once shared and once with `DSH_MCP_SHARED_PROCESS=0`): a silent first `session/resume` is retried once on a fresh process (old child exited, lock marker points at the new one); a permanently silent one fails after exactly one retry with no zombie child or marker; a write-lock refusal or invalid params is *not* retried; `AcpProcess` timeout errors carry `code=ACP_TIMEOUT`/`method`; a shared host that still carries another conversation is not retried (no tokens) |
45
+ | `dispatch-tools` | 59 (55 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 (0.2.1-rc.5 / 0.2.2-rc.1 verified, 0.2.3+ unverified) and npx-cache detection, PowerShell sentinel-waiter escaping plus a real run, and the sentinel **reader** command (`-Encoding UTF8`, parses CJK content under Windows PowerShell 5.1; real run on Windows only), audit records metadata only, `dsh_doctor` essential checks (no tokens) |
32
46
  | `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
- | `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) |
47
+ | `read-cursor` | 26 | **`dsh_read` cursor semantics**: text appended to an already-read entry is delivered again (revision cursor, match by `seq`); **each tool call gets its own entry** and a status update replaces that entry in place (they used to be concatenated into one entry under the first call's id); an empty turn's start/end stay two entries; **message segments separated by tool calls get a blank line between them in `answer`** (they used to run together); and `limit` is clamped to 1..500 so `0`/negative cannot spin or drop the tail (no tokens) |
34
48
  | `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) |
35
49
  | `review` | 70 | **caller review**: default `advisory` tickets carry a checklist + evidence + `dsh_review` entry; `dsh_review` writes the verdict to disk and back into the run and sentinel; `fail`/`partial` require notes; running/unknown runs are refused; review still works after a restart (sentinel-backed); queue and `client_run_id` replay receipts carry the ticket; review records share the sentinel TTL; notes, previews, evidence **and checklist items** are credential-redacted; **path-traversal ids are rejected before any file I/O** (no tokens) |
36
50
  | `forget-tombstone` | 10 | **`forget` is terminal**: `release(forget=true)` writes a tombstone, by-id lookups fail with a recovery path, lists exclude it, and deleting the tombstone restores the normal unknown-session path (no tokens) |
37
51
  | `bootstrap-profile` | 24 | **first-start profile works in the published zero-dependency form**: copies routing with the built-in YAML subset parser, strips `apiKey`/`headers`/literal secrets, never writes `defaultPreset`, is idempotent, and leaves a user-maintained profile byte-identical (no tokens) |
38
52
  | `workspace-safety` | 23 | **data-loss boundaries**: `isTempPath` is segment-aware (`tmpdir()+'Suffix'` and `/tmpfoo` are *not* temp), a corrupt `workspace.json` is never overwritten, an unreadable session root never wipes `sessionIds`, and automatic cleanup deletes only test-created sessions (no tokens) |
53
+ | `residue-purge` | 149 | **test-residue cleanup** (sandboxed `DSH_HOME`, no tokens): every artifact kind of a test session (session log, projection cache, workspace registration/archived/pinned, tombstone, registry entry, meta, sentinels, reviews, lock markers) is removed; **non-test sessions stay byte-identical** (naming them does not delete them); subagent cascade, `roots` scoping, zero-write dry run, corrupt files fail closed, path traversal, legacy flat directories need evidence, every model-backed suite is wired to the teardown |
39
54
  | `permission-adopt` | 17 | **no silent privilege escalation**: adopting an external session with no permission record fails closed to `read-only`; a recorded `read-only` cannot be promoted by the sidecar; the runtime read-back rejects a recorded/requested mismatch (no tokens) |
40
55
  | `state-safety` | 26 | **state/sentinel/lock storage**: defaults live under `$DSH_HOME/.dsh-any-agent`, an unwritable directory fails startup loudly, sentinel write failures return `null` + `sentinel_error`, and bad numeric env values land on the safe side (no tokens) |
56
+ | `workspace-snapshot` | 21 | **workspace snapshot**: `workspace_changed` must see full file contents (same status line with different content, changes past the 41st entry, inside untracked directories) and `gitStatus` runs asynchronously so it never blocks the shared MCP event loop (filesystem + a temp git repo, no tokens) |
41
57
  | `setup` | 45 | **the registration CLI**: preview writes nothing, `--remove` takes out only its own entry, an absent client is skipped rather than failed, the Codex TOML block round-trips, and the command recorded in a config is actually spawned and must answer `initialize` (no tokens). It never invokes `claude mcp get` — that CLI rewrites `~/.claude.json` |
42
58
  | `removal-scope` | 24 | **unregistering touches only its own entry**: configs holding unrelated MCP servers, top-level Codex settings and sibling Zed settings are registered and then removed, asserting every other value is byte-identical; removing something never written is a no-op with no write (no tokens) |
43
- | `config-safety` | 407 | **the property the 0.3 line is about**, per client: preview leaves files byte-identical with no `.bak`; register-then-remove restores every other entry byte-for-byte, keeps key order and drops no top-level key; 4-space / 2-space indentation, single-line JSON, missing trailing newline, CRLF and a UTF-8 BOM all survive a write; malformed JSON and read-only files are refused without damage; the second `--yes` is a no-op with no new backup; every `.bak-dsh-<stamp>` appears once and equals the pre-write bytes; a `claude` on PATH is never invoked (no tokens) |
44
- | `real-configs` | 84 (66 with 7/9, 48 with 5/9 fixtures) | **round-trip on the real thing**: copies this machine's actual client configs (an 182693-byte `~/.claude.json` holding hundreds of `projects` entries and case-colliding keys, a 238-line Codex TOML, Zed's full `settings.json`) into a temp dir, registers, then removes, and requires every file back **byte for byte**; entry-presence is checked by key (not a bare substring), and missing fixtures are reported as skipped rather than green (no tokens) |
59
+ | `config-safety` | 407 (406 without client configs) | **the property the 0.3 line is about**, per client: preview leaves files byte-identical with no `.bak`; register-then-remove restores every other entry byte-for-byte, keeps key order and drops no top-level key; 4-space / 2-space indentation, single-line JSON, missing trailing newline, CRLF and a UTF-8 BOM all survive a write; malformed JSON and read-only files are refused without damage; the second `--yes` is a no-op with no new backup; every `.bak-dsh-<stamp>` appears once and equals the pre-write bytes; a `claude` on PATH is never invoked (no tokens) |
60
+ | `real-configs` | 84 (66 with 7/9 fixtures, 48 with 5/9 fixtures) | **round-trip on the real thing**: copies this machine's actual client configs (an 182693-byte `~/.claude.json` holding hundreds of `projects` entries and case-colliding keys, a 238-line Codex TOML, Zed's full `settings.json`) into a temp dir, registers, then removes, and requires every file back **byte for byte**; entry-presence is checked by key (not a bare substring), and missing fixtures are reported as skipped rather than green (no tokens) |
45
61
  | `commit-script` | 26 | commit messages go through stdin, never a temp file that `git add -A` could capture (no tokens) |
62
+ | `test-counts` | 92 | **check-count sync**: parsing suite output (Chinese/English summary lines, whole-suite skip, `✓`/`✗` fallback, `[report]` environment tags), report assembly, round-trips of the "main number (other environments)" cell, rewriting the Chinese and English docs and `tests.yml`, `--check` diffs and `--portable`, model-suite numbers left alone, failing reports refused (no tokens) |
46
63
  | `roots` | 12 | `dsh_start` without `cwd` asks the client for MCP roots; a client without roots gets an actionable error (no tokens) |
47
- | `workspace-titles` | 18 | home-as-workspace **and filesystem-root cwd** are rejected before DSH starts; `title_hint` is returned separately from DSH's automatic title (no tokens) |
64
+ | `workspace-titles` | 18 (17 with TEMP outside the home directory, 16 case-sensitive with TEMP outside home) | home-as-workspace **and filesystem-root cwd** are rejected before DSH starts; `title_hint` is returned separately from DSH's automatic title (no tokens) |
48
65
  | `package-check` | 14 | `files` whitelist, zero runtime dependencies, Node floor, and bin shebangs (no tokens) |
49
- | `timeout` | 38 | **timeout semantics**: `wait=false` is unaffected by `timeout_ms`; a `wait=true` expiry merely downgrades to background (turn not cancelled, result not lost, `busy` never lies); `timeout_ms<=0` waits forever; a failed resume invalidates the process instead of wedging the conversation; an empty prompt yields a clear error |
66
+ | `lint` | 22 | **Regression for the `npm run lint` static check itself**: throwaway trees prove that a syntax error, a missing or wrongly-cased import path, a missing named export (including `export { x } from`, `await import()` destructuring and `node:` builtins) and an unused import are each reported; a tree of tricky-but-valid syntax (uses inside template literals, quotes inside regex literals, `export *`) produces zero false positives; and this repo itself is lint-clean (no tokens) |
67
+ | `timeout` | 40 | **timeout semantics**: `wait=false` is unaffected by `timeout_ms`; a `wait=true` expiry merely downgrades to background (turn not cancelled, result not lost, `busy` never lies); `timeout_ms=0` waits forever and a **negative `timeout_ms` is rejected with a clear error** (no run dispatched, conversation still usable); a failed resume invalidates the process instead of wedging the conversation; an empty prompt yields a clear error |
50
68
  | `lock` | 79 | **write lock and preemption**: four holder classifications, `writeMarker` never overwriting a live holder, malformed/missing registration edge cases; **end-to-end** with two real MCP instances fighting over one conversation → clear error → refusal → `force` takeover; **a crash releases the lock automatically**, **a wedged holder is preempted without `force`**; plus the "one turn, one sentinel" invariant (inline turns and idle interjects included) |
51
69
  | `cycle` | 36 | **the async dispatch lifecycle**: dispatch → collect the sentinel → idle reap → dispatch again, three rounds with no lock error; re-dispatch right on the reap boundary (widening the race); an immediate re-dispatch after `dsh_release`; and an assertion that reaping leaves no unattributable lock (this suite caught the reaper collecting a freshly spawned process as if it were idle) |
52
70
  | `concurrency` | 31 | three simultaneous conversations + live incremental reads |
53
71
  | `capability` | 22 | writing code, running scripts, **spawning its own subagents** (verified on disk via child session headers) |
54
72
  | `workspace-effect` | 24 | workspace actually effective when no path is given |
55
- | `acceptance` | 36 | two folders × two conversations doing a read-only IDA Pro analysis |
73
+ | `acceptance` | 36 (skips without IDA MCP) | two folders × two conversations doing a read-only IDA Pro analysis |
56
74
  | `multi` | 19 | **several instances and non-ASCII paths**: reproduces "two instances share the registry and the later save drops the earlier instance's conversation", then proves **that conversation is recoverable by id alone from the session store and actually usable** (registry loss, not an explicit `forget` — forget is a terminal tombstone); **recovery never silently escalates privileges** (a read-only conversation stays read-only); no `.tmp` residue; and **CJK / emoji workspace paths** create sessions, do real work, and place files in the right directory |
57
75
  | `permission` | 11 | ★ **whether the permission tiers actually take effect** (a safety property): it ignores our own return values (the very thing that used to lie) and reads **the session's own record** (`permissions.preset` / `sandboxMode` in the projection cache), checking all three tiers and that their recorded values differ. **This suite caught a silent privilege escalation**: a `defaultPreset` in the profile overrides `DSH_PERMISSION_MODE` at session creation |
58
- | `list` | 13 | **the cost of `dsh_list`** (measured): a default call with the on-disk probe takes ~1 s (it spawns a DSH process) while **repeated calls hit a cache and drop to single-digit milliseconds**; `only_running=true` and `include_closed=false` **skip the probe** with equivalent semantics (unopened on-disk sessions are simply excluded) |
76
+ | `list` | 17 | **the cost of `dsh_list`**: a default call runs the on-disk probe (it spawns a DSH process — seconds with many sessions) while **repeated calls hit the cache** (asserted via `probe_cached`, not wall-clock: Windows occasionally adds ~700 ms to one cached call); `only_running=true` and `include_closed=false` **skip the probe** (`probe_ok=null`) with equivalent semantics (unopened on-disk sessions are simply excluded) |
59
77
  | `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
60
78
 
61
- **`node test/run.mjs` runs 1012 checks on a machine with seven client fixtures (1030 with all nine)** — smoke 31 + 981 offline checks (999 with nine). Every suite exited 0 on the 0.5.0 tree plus caller review (2026-11-04, Windows / Node 22.19.0; the local five-fixture run reports 994); none needs a provider (smoke skips its session-creation sections when DSH is absent).
79
+ > **2026-11-06**: `node test/run.mjs` now runs **every** suite and lists all failures at the end (`--bail` restores stop-at-first-failure) — a single stale assertion in `timeout.mjs` used to hide eleven model-backed suites. Model-backed suites re-run in full against DSH 0.2.2-rc.1 on the desktop app's bundled runtime.
80
+
81
+ <!-- counts:begin (generated by scripts/sync-test-counts.mjs; do not edit by hand) -->
82
+ **`node test/run.mjs` ran 1417 checks in 31 offline suites on this machine (Windows, DSH installed, 5/9 client config fixtures), all passing.** Parenthesised numbers in the table depend on the environment (DSH present or not, platform, filesystem case sensitivity, how many client configs the machine has), so the total moves with it; this line records the environment of the report used for the last sync.
83
+
84
+ That is **524 assertions in the client-registration suites alone** (`setup` 45, `removal-scope` 24, `config-safety` 407, `real-configs` 48), which is where the 0.3 work actually is.
85
+
86
+ The model-backed suites (`boundary` 46, `integration` 30, `async` 21, `sentinel` 36, `timeout` 40, `lock` 79, `multi` 19, `list` 17, `permission` 11, `shared-process` 8, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `reasoning-check` 8) total **464 checks** nominally; with DSH present `smoke` runs 13 more checks in its session sections. They need a working DSH and provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`); `shared-process` also skips without DSH.
62
87
 
63
- That is **542 assertions in the client-registration suites alone** (`setup` 45, `removal-scope` 24, `config-safety` 407, `real-configs` 66; 560 with all nine fixtures), which is where the 0.3 work actually is.
88
+ > Three different totals are intentional: `node test/run.mjs` on this machine (1417), the CI offline job (nominal 1349 on Linux/macOS, 1355 on Windows; hosted runners have no DSH and no client fixtures, so `smoke` skips its session sections and `real-configs` skips entirely), and the model-backed set (464 nominal). Counts are the numbers each suite prints itself, written back from the report by `npm run docs:counts`; don't copy them by hand and don't use one number for a different runner.
89
+ <!-- counts:end -->
90
+
91
+ ### 🔢 After changing tests: counts are computed
92
+
93
+ The check counts in this page are not typed by hand. Every `node test/run.mjs` run collects each suite's output and parses it into a JSON report (default `.state/test-report.json`, which is gitignored; `--report <path>` picks another location, `--no-report` skips it): per suite `passed` / `failed` / `skipped` / `exitCode` / `durationMs`, plus the platform, Node and DSH versions and how many client-config fixtures the machine had. The numbers come from each suite's own summary line (`===== ...: passed N, failed M =====`); an older suite without one falls back to counting its `✓`/`✗` lines; a new suite should print the standard summary line.
94
+
95
+ **After changing tests:**
96
+
97
+ ```bash
98
+ node test/run.mjs --all --report .state/test-report.json # needs DSH/provider; drop --all to refresh only the offline numbers
99
+ npm run docs:counts # rewrite this table, the totals above (both languages) and the scope comment in .github/workflows/tests.yml
100
+ git diff docs .github # review what changed, then commit
101
+ ```
64
102
 
65
- The model-backed suites (`smoke` 31, `boundary` 46, `integration` 30, `async` 21, `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, `shared-process` 8) total 489. Most were last measured in full on 0.3.9; the suites touched by caller review (`smoke`, `integration`, `async`, `sentinel`, `boundary`) were re-run on this tree. They need a working provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` now **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`); `shared-process` also skips without DSH.
103
+ - **Without `--all`** the report has no model-backed suites, so their numbers are left untouched (the script says so).
104
+ - **Environment-dependent suites** are written as "main number (other environments)": `smoke` (DSH present or not), `dispatch-tools` (Windows also runs the real PowerShell sentinel checks), `config-safety` and `real-configs` (how many client configs the machine has), `workspace-titles` (filesystem case sensitivity, whether `TEMP` is inside the home directory). **Only the cell this run measured is updated**; the others stay as they were. To refresh another cell, run in that environment and pass the reports in order with several `--report` flags.
105
+ - **Suites that skip as a whole** (`shared-process` without DSH, `acceptance` without IDA MCP, `real-configs` without client configs) keep their numbers.
106
+ - **A report with a failing suite is refused** (exit 2): a failing suite's count is not trustworthy.
107
+ - **A new suite**: add it to `OFFLINE`/`MODEL` in `test/run.mjs`, then add a table row (the script does not write the "covers" text; a missing row exits 1 and names the suite).
66
108
 
67
- > Three different totals are intentional: `node test/run.mjs` (1012/1030), the CI offline job (nominal 981/999, no smoke; hosted runners without client fixtures skip `real-configs` entirely), and the model-backed set (489 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
109
+ **Keeping it from drifting:** `npm run docs:counts:check` writes nothing; if the docs disagree with the report it prints the diff and exits 1. The CI offline job runs `node test/run.mjs --report` and then `docs:counts:check --portable`, which only checks environment-independent suites plus `smoke` / `config-safety` in the cell hosted runners land in (no DSH, no client configs); platform-dependent suites are left to the full local check (a step in the release checklist). The `test-counts` suite covers the parser and the rewriting itself.
68
110
 
69
111
  ### 🧯 Why `sandbox-guard` exists
70
112
 
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
- 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)`.
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
 
@@ -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-team │ 同 (cwd, 权限) 的会话
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/搜索/
@@ -11,7 +11,8 @@
11
11
  |---|---|---|---|
12
12
  | 0.5.x | `0.1.7-rc.1` 及以上 | ✅ 已验证 | ACP + 实验性 Agent Teams 包均可用;团队模式需 `dsh-any-agent team on` |
13
13
  | 0.5.x | `0.1.5-rc.1` | ⚠️ 部分可用 | ACP 基本路径可用;缺 team 包,团队模式会自动保持关闭 |
14
- | 0.5.x | `0.2.x` | ⚠️ 未验证 | ACP 的 `session/*` 面可能变化;team 包必须与 DSH 版本匹配。升级前先跑 `dsh_doctor` 和离线套件 |
14
+ | 0.5.x | `0.2.1-rc.5` ~ `0.2.2-rc.1` | ✅ 已验证 | 2026-11-06 全量套件实测(0.2.1-rc.5 走 npm,0.2.2-rc.1 走桌面版自带运行时);0.2.2 起 DSH 要求 Node ≥ 24.9 |
15
+ | 0.5.x | `0.2.3` 及以上 | ⚠️ 未验证 | ACP 的 `session/*` 面可能变化;team 包必须与 DSH 版本匹配。升级前先跑 `dsh_doctor` 和离线套件 |
15
16
  | 0.4.x | `0.1.7-rc.1` | ✅ 已验证 | 团队模式当时默认开启 |
16
17
  | 0.4.x | `0.1.5-rc.1` | ⚠️ 部分可用 | 无 team 包 |
17
18
 
@@ -20,21 +21,39 @@
20
21
 
21
22
  ## 运行时要求
22
23
 
23
- - **Node.js ≥ 22.15**(用到 `node:zlib` 的 `zstdDecompressSync` 读 DSH 会话头);
24
+ - **Node.js ≥ 22.15**(本服务自身;用到 `node:zlib` 的 `zstdDecompressSync` 读 DSH 会话头);
24
25
  - DSH 已安装,且 `~/.dsh` 下已有可用的 provider 与凭据(GUI 能正常聊天即可);
26
+ - **DSH 自己的 Node 要求**(`engines.node`,0.2.2 起是 ≥ 24.9)由拉起 DSH 的运行时满足:
27
+ 用桌面版时是桌面版自带的运行时(无需本机 Node 24);用 npm/npx 安装时是本服务所在的 node;
28
+
29
+ ## DSH 从哪里来(自动识别,无需配置)
30
+
31
+ 服务按下面的顺序自动找 DSH,**不需要手填 `DSH_BIN`**:
32
+
33
+ 1. **DeepSeek Harness 桌面版**:Windows 读卸载注册表(HKCU/HKLM)里的安装目录,
34
+ 各平台再查默认安装位置。CLI 在 `resources/app.asar` 里,服务用桌面版自带的 Electron
35
+ 以 Node 模式(`ELECTRON_RUN_AS_NODE=1`)运行它 —— 与 GUI 用的是同一份 DSH、同一个运行时;
36
+ 2. **npm 全局安装**(`npm i -g @deepseek-ai/dsh`);
37
+ 3. **npx 缓存**(随时可能被清掉,同等条件下最低优先)。
38
+
39
+ 多个来源同时存在时:当前运行时能满足 `engines.node` 的优先 → 版本高的优先 → 桌面版 > 全局 > npx。
40
+ `DSH_BIN`(可指进 `.asar` 内部)与 `DSH_RUNTIME`(指定拉起 DSH 的可执行文件)只作显式覆盖。
25
41
  - 团队模式额外要求 DSH `>= 0.1.7-0`,并且 profile 里真的挂上了两个 team 包。
26
42
 
27
43
  ## 怎么确认
28
44
 
29
45
  ```bash
30
46
  dsh-any-agent --version # 本服务版本
31
- node <DSH_BIN> --version # DSH 版本(DSH_BIN 可用环境变量指定)
32
47
  dsh-any-agent team status # 团队开关与 profile
33
48
  ```
34
49
 
35
50
  在 MCP 客户端里调用 `dsh_doctor`,重点看:
36
51
 
37
- - `dsh_bin`:DSH 入口与版本;
52
+ - `dsh_bin`:DSH 入口、版本、来源(桌面版 / npm 全局 / npx 缓存 / DSH_BIN)与运行时;入口落在 npx 缓存(`_npx/<hash>/`)时 **warn** —— 清缓存就会失效;
53
+ - `dsh_runtime`:拉起 DSH 的运行时的 Node 版本是否满足 DSH 的 `engines.node`,不满足时 **warn**;
54
+ - `dsh_desktop`:桌面版版本,与本服务所用 DSH 不一致时 **warn**(两边共用 `~/.dsh` 会话存储);
55
+ - `dsh_candidates`:发现的全部 DSH 来源,以及自动选中了哪一个;
56
+ - `dsh_version`:按上表给版本定档 —— `verified` 为 ok;`partial` / `unsupported` / `unverified`(如 0.2.3+)/ 读不到版本为 **warn**;
38
57
  - `profile`:是否存在、是否漂移、路由来源;
39
58
  - `team`:`on` / `off` / `requested but unavailable`(版本或 profile 不含 team 包);
40
59
  - `config`:当前 permission、空闲回收、共享进程等生效值。
@@ -46,4 +65,5 @@ dsh-any-agent team status # 团队开关与 profile
46
65
  3. 如果团队模式打不开,先看 `dsh-any-agent team status` 和 `dsh_doctor` 的 `team` 检查,
47
66
  再确认 `~/.dsh/profiles/dsh-team/cordis.patch.yml` 是否包含
48
67
  `@deepseek-ai/dsh-experimental-agent-team`;
49
- 4. 需要固定 DSH 版本时,显式设置 `DSH_BIN`,不要让 npx 缓存自动挑版本。
68
+ 4. 默认跟随桌面版:桌面版升级后服务会在下次拉起 DSH 时自动用上新版本(发现结果缓存 60 秒)。
69
+ 确实需要固定某个版本时才显式设置 `DSH_BIN`。
package/docs/zh/limits.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  因为改名会打断一堆已经落盘的东西。
19
19
 
20
- 本服务首次启动时会在 `~/.dsh/profiles/dsh-mcp/` 建一个 DSH profile。0.4.0 起团队模式默认改用 `dsh-team`(同样由本服务自动创建);`dsh-mcp` 仍用于团队关闭或既有部署。两者共用同一套 `DSH_MCP_*` 变量与 `# dsh-mcp:managed` 标记。
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
 
@@ -4,7 +4,8 @@
4
4
 
5
5
  | 变量 | 默认 | 说明 |
6
6
  |---|---|---|
7
- | `DSH_BIN` | 自动探测 | `@deepseek-ai/dsh/lib/bin.js` 路径 |
7
+ | `DSH_BIN` | 自动探测(桌面版 → npm 全局 → npx 缓存) | 显式指定 `@deepseek-ai/dsh/lib/bin.js` 路径(可指进桌面版 `app.asar` 内部) |
8
+ | `DSH_RUNTIME` | 自动 | 显式指定拉起 DSH 的可执行文件(如一个 Node 24)。配合 `DSH_BIN` 使用,或单独设置时作用于自动发现的 npm 全局 / npx 版 DSH(并按它的 Node 版本重新判定 `engines.node`);桌面版始终用其自带运行时(asar 只有它读得了)。不设时:桌面版用自带运行时,其余用本服务的 node |
8
9
  | `DSH_MCP_PROFILE` | `dsh-mcp`(团队开启时 `dsh-team`) | 驱动用的 DSH profile;显式设置优先 |
9
10
  | `DSH_MCP_TEAM` | `0`(关闭) | 团队模式,按需开启。`dsh-any-agent team on` 写设置文件;这里设 `1`/`0` 可覆盖它。开启后使用 `dsh-team` profile,并在 `dsh_send` 前注入"需要时先建团队"的提示;单次关闭用 `dsh_send(team=false)` |
10
11
  | `DSH_MCP_SETTINGS` | `$DSH_HOME/.dsh-any-agent/settings.json` | 团队开关的设置文件(`dsh-any-agent team on/off` 写它) |
@@ -14,6 +14,9 @@ npm version patch --no-git-tag-version
14
14
  node test/run.mjs
15
15
  node test/package-check.mjs
16
16
  npm run check:release # 版本号 / CHANGELOG / 文档链接 / 打包清单一致性
17
+ npm run docs:counts:check # tests.md 与 CI 注释里的检查数 = 刚才 node test/run.mjs 写出的报告
18
+ # 不一致就是改过测试没更新数字:node test/run.mjs --all --report .state/test-report.json && npm run docs:counts
19
+ # (模型套件的数字只有带 --all 的报告才会更新,见 docs/zh/tests.md「改了测试之后」)
17
20
 
18
21
  # 3. 确认 tarball 本身:打得出来、装得上、起得来
19
22
  npm pack --dry-run
@@ -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
@@ -6,16 +6,29 @@
6
6
  npm test # 全部离线套件;没有 DSH 时 smoke 自动跳过会话段
7
7
  npm run test:all # 离线 + 需要模型的套件(需要 DSH/provider)
8
8
  node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目录;--dry-run 可预演)
9
+ npm run lint # 零依赖静态检查:语法、导入路径、具名导出、未使用导入
10
+ node test/run.mjs --all --report .state/test-report.json # 同上,并指定 JSON 报告位置(每次运行默认都写 .state/test-report.json)
11
+ npm run docs:counts # 把报告里的检查数写回本文与 CI 注释(见下文「改了测试之后」)
12
+ npm run docs:counts:check # 只检查文档与报告是否一致,不一致 exit 1 并打印差异
9
13
  ```
10
14
 
15
+ `npm run lint`(`scripts/lint.mjs`)对 `src/ bin/ scripts/ test/ demo/` 下每个 `.mjs` 跑 `node --check`,并核对每条相对路径的 `import` / `export … from` 都指向存在且**大小写一致**的文件、每个具名导入都是目标模块的真实导出、没有未使用的具名导入。它不引入任何 npm 依赖(`package-check` 仍然强制零依赖),是 token 级扫描而不是完整解析器:宁可漏报也不误报。拆模块、挪函数之后先跑它 —— 一个漏掉的导出在没有真实 DSH 的 CI 上往往要到某条冷路径才会炸。CI 的离线 job 在每个矩阵格里都会先跑一遍。
16
+
11
17
  模型套件不在 GitHub 托管 CI 里(需要真实 DSH、provider 与凭据)。在本机跑,
12
18
  或用 `model-suites` workflow 指定一台装好 DSH 的 self-hosted runner。
13
19
 
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`。
20
+ 测试套件**会自己收尾,跑完不留残留**。真机套件会在各自的 `finally` 里调用 `test/residue.mjs` 的 `cleanupSuite`,精确清掉**自己造的**会话(`McpClient` 自动记下每个 `dsh_start` 返回的 id;再按套件自己的临时根兜住没记到 id 的会话与 DSH 自己派出的子代理会话),连同它的**全部产物**:
21
+ - DSH 侧:会话日志、投影缓存 `storages/session_projcache/sessions/<id>.json`(删会话目录**不会**带走它,旧版跑多了会攒出几百个孤儿)、`workspace.json` 里的登记、forget 墓碑;
22
+ - 本项目侧:注册表条目、`meta/`、哨兵 `runs/`、审核 `reviews/`、写锁登记 `locks/`(状态目录收在套件自己的临时根里,不再散落在 `%TEMP%` 根下)。
23
+
24
+ `run.mjs` 结束时再执行一遍全局 `cleanup.mjs` 作兜底(处理被杀掉的进程、旧版本的残留、投影缓存里的测试孤儿)。**护栏**:只有 cwd 是**测试自己造的目录**(当前进程的 `tmpdir()/dsh-any-agent-*`、桌面 `dsh-any-agent-test-A|B`、本仓库 `.state/ws-probe-*`)的会话才会被删,**点名 id 也绕不过**;用户在 `%TEMP%` 里自己的真实项目、并行 agent 的临时根(TMP 指到别处)、以及你的状态目录都不会被碰。要连桌面测试目录一起清,可显式跑 `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`。
25
+
26
+ 为什么真机套件不把 `DSH_HOME` 指到一次性沙箱:DSH 的凭据在 `$DSH_HOME/.credentials.yaml`,沙箱里没有它真机套件就拿不到模型,而把密钥/路由复制进临时目录等于多开一个泄漏面;所以真机套件继续用真实 `~/.dsh`,靠上面这套精确清理保证不留残留(纯离线套件早就用沙箱 `DSH_HOME`,见 `test/sandbox-home.mjs`)。
15
27
 
16
28
  | 套件 | 检查数 | 覆盖 |
17
29
  |---|---|---|
18
- | `smoke` | 31 | 握手、工具表(15 个工具)、建会话、配置、注册表纯净性、原型链工具名拒绝 |
30
+ | `smoke` | 33(无 DSH 时 20) | 握手、**instructions 长度上限**(客户端会截断过长说明)且铁律在最前、工具表(15 个工具)、建会话、配置、注册表纯净性、原型链工具名拒绝 |
31
+ | `dsh-locate` | 21 | **DSH 自动发现**:纯 Node 读合成的 `app.asar`(版本、`engines.node`、入口存在性)、桌面版目录映射到自带 Electron 运行时 + `ELECTRON_RUN_AS_NODE=1`、候选排序(能跑的优先 → 版本高 → 桌面版 > 全局 > npx)、`DSH_BIN` 指进 asar 自动配桌面版运行时、`DSH_RUNTIME` 覆盖、`engines.node` 区间判定、`reg export` 输出解析(不花 token、不读注册表) |
19
32
  | `boundary` | 46 | 协议边界(重复 initialize、脏行、未知方法)、参数校验(含 read-only + 宽松审批必须拒绝、未知 provider/model、失败创建不留下写锁登记)、未知 id、游标边界、生命周期幂等、unicode |
20
33
  | `integration` | 30 | 跨进程 resume 与记忆、中断、两种插话 |
21
34
  | `async` | 21 | fire-and-forget + 事后验收,含收据/结果/哨兵里的审核单,以及一次真实 `dsh_review` 往返 |
@@ -23,47 +36,76 @@ node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目
23
36
  | `prune` | 11 | **哨兵留存**:清超期与崩溃残留、留新、清空壳目录、`TTL=0` 关闭清理、且绝不碰同目录的会话注册表(不花 token,每次必跑) |
24
37
  | `guards` | 40 | **环境变量守卫**:奇怪的数值一律压到安全侧 —— `IDLE_TTL_MS` 的 `-1` 当"永不回收"、非法值/空串回落默认 10 分钟、回收间隔过小压回默认(防空转紧循环)、**失联阈值下限 30 秒**(防别的实例"合法地"抢占并杀掉我们的回合);以及团队模式的默认值与优先级(`DSH_MCP_TEAM` > `settings.json` > 默认关)(不花 token,每次必跑) |
25
38
  | `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) |
39
+ | `approval-details` | 37 | **审批审查上下文**:待决审批的快照带命令、理由、cwd 与原始审批 ID —— 不只是一个裸 ID,调用方才能真正裁决;缺失的 `rawInput` 会按 ID 从先前的 `tool_call` 更新关联回来;凭据形状的字段与常见内联字面量在结构化上下文和展示标题里都会脱敏;关闭会话会取消待决审批。经真实 stdio MCP 端到端验证 |
40
+ | `failure-terminal` | 10 | **失败路径的终态**:`ensureAlive` 失败时唤醒 `dsh_interject` 等待者、落 `status=error` 哨兵、错误对象挂 `runId`;审批没有对应 option 时 `effective=cancelled` 而不是假报 allow;`dsh_get` 能从哨兵文件回读(不花 token) |
28
41
  | `shared-host` | 25 | **共享进程宿主**:通知/审批按 `sessionId` 路由、未知会话审批一律取消、进程退出扇出所有挂载会话、最后一个会话 detach 才停进程;**默认开启且 `=0` 可关闭**(空串按未设置处理);`takeover` 杀共享进程前的连坐检查;以及 RSS 读取(不花 token) |
29
42
  | `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) |
43
+ | `resume-retry` | 63 | **冷启动 resume 超时重试**(用 `test/fake-acp.mjs` 当 DSH,共享进程与 `DSH_MCP_SHARED_PROCESS=0` 各跑一遍):第一次 `session/resume` 没回话 → 在新进程上重试一次(旧子进程已退出、写锁登记指向新进程);一直没回话 → 恰好重试一次后如实报错,不留僵尸子进程与登记;撞写锁、invalid params 一律**不**重试;`AcpProcess` 超时错误带 `code=ACP_TIMEOUT` 与 `method`;共享宿主上还挂着别的会话时不重试(不花 token) |
44
+ | `dispatch-tools` | 59(非 Windows 55) | **派发面**:`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 版本档位(0.2.1-rc.5 / 0.2.2-rc.1 已验证,0.2.3+ 未验证)与 npx 缓存识别、PowerShell 哨兵等待命令的转义与真机执行、哨兵**读取**命令(`-Encoding UTF8`,在 Windows PowerShell 5.1 下正确解析中文;真机执行仅 Windows)、审计只记元数据不记正文、`dsh_doctor` 关键检查项(不花 token) |
45
+ | `read-cursor` | 26 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);**每个工具调用各占一条**、状态更新原地替换(以前会拼成一条、只剩第一个工具的 id);空回合的开始/结束仍是两条;**`answer` 里被工具调用隔开的正文之间补空行**(以前首尾相接);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
32
46
  | `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
33
47
  | `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
34
48
  | `review` | 70 | **调用方审核**:默认 `advisory` 审核单带清单 + 证据 + `dsh_review` 入口;`dsh_review` 把裁决落盘并回写内存 run 与哨兵;`fail`/`partial` 必须给理由;未完成/未知 run 被拒绝;重启后仍可凭哨兵审核;队列与 `client_run_id` 重放收据也带审核单;审核记录与哨兵共享 TTL;理由、预览、证据**与清单项**都凭据脱敏;**路径穿越 id 在触盘前就被拒绝**(不花 token) |
35
49
  | `forget-tombstone` | 10 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
36
50
  | `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
37
51
  | `workspace-safety` | 23 | **数据丢失边界**:`isTempPath` 按路径分段(`tmpdir()+'Suffix'`、`/tmpfoo` 都不算临时目录)、坏 `workspace.json` 绝不覆盖、会话根不可读时绝不清空 `sessionIds`、自动清理只删测试造的会话(不花 token) |
52
+ | `residue-purge` | 149 | **测试残留清理**(沙箱 `DSH_HOME`,不花 token):一个测试会话的每种产物(会话日志、投影缓存、工作区登记/archived/pinned、墓碑、注册表条目、元数据、哨兵、审核、锁登记)都被清掉;**非测试会话逐字节不变**(点名也删不掉);子会话级联、`roots` 限定、干跑零写入、坏文件 fail-closed、路径穿越、旧版扁平目录必须有证据才清、各真机套件都接上了收尾 |
38
53
  | `permission-adopt` | 17 | **不无声提权**:接管没有权限记录的外部会话时 fail-closed 到 `read-only`;记录为 `read-only` 的会话不能被侧车提升;运行期回读记录与请求不一致会失败(不花 token) |
39
54
  | `state-safety` | 26 | **状态/哨兵/锁目录**:默认在 `$DSH_HOME/.dsh-any-agent`、不可写时启动明确失败、哨兵写失败返回 `null` + `sentinel_error`、非法数值落安全侧(不花 token) |
55
+ | `workspace-snapshot` | 21 | **工作区快照**:`workspace_changed` 必须看全量文件内容(同一状态行但内容变了、第 41 条之后的改动、未跟踪目录内部),且 `gitStatus` 异步执行、不阻塞共享 MCP 事件循环(纯文件系统 + 临时 git 仓库,不花 token) |
40
56
  | `setup` | 45 | **注册 CLI**:预览不写盘、`--remove` 只摘自己那条、没装客户端算跳过不算失败、Codex TOML 段往返、以及把写进配置里的命令真的拉起一次并要求应答 `initialize`(不花 token)。**不会调用 `claude mcp get`** —— 那个 CLI 会整体回写 `~/.claude.json` |
41
57
  | `removal-scope` | 24 | **注销只动自己那条**:给一份装着别的 MCP 服务器、顶层 Codex 设置和 Zed 兄弟设置的配置,注册再注销,断言其余每个值逐字节不变;删一个从没写过的东西 = 无写入、无报错(不花 token) |
42
- | `config-safety` | 407 | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
43
- | `real-configs` | 84(7/9 配置时 66,本机 5/9 时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
58
+ | `config-safety` | 407(无客户端配置时 406) | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
59
+ | `real-configs` | 84(7/9 配置时 66,5/9 配置时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
44
60
  | `commit-script` | 26 | 提交信息走 stdin,绝不落临时文件让 `git add -A` 卷进发布提交(不花 token) |
61
+ | `test-counts` | 92 | **检查数同步**:套件输出解析(中英文汇总行、整套 skip、`✓`/`✗` 兜底、`[report]` 环境标注)、报告拼装、「主数字(其它环境的数字)」单元格往返、对中英文文档与 `tests.yml` 的重写、`--check` 差异与 `--portable`、模型套件数字不动、失败的报告被拒绝(不花 token) |
45
62
  | `roots` | 12 | 不传 `cwd` 时通过 MCP roots 取客户端当前项目;不支持 roots 的客户端得到可操作的报错(不花 token) |
46
- | `workspace-titles` | 18 | 家目录**与文件系统根目录**当工作区都会在 DSH 启动前被拦住;`title_hint` 与 DSH 自动标题分开返回(不花 token) |
63
+ | `workspace-titles` | 18(TEMP 在家目录外时 17,区分大小写且 TEMP 在家目录外时 16) | 家目录**与文件系统根目录**当工作区都会在 DSH 启动前被拦住;`title_hint` 与 DSH 自动标题分开返回(不花 token) |
47
64
  | `package-check` | 14 | `files` 白名单、零运行时依赖、Node 下限、bin shebang(不花 token) |
48
- | `timeout` | 38 | **超时语义**:`wait=false` 不受 `timeout_ms` 影响、`wait=true` 到期只转后台(不取消/不丢结果、`busy` 不说谎)、`timeout_ms<=0` 一直等、resume 失败会作废进程而不永久卡死、空 prompt 给清晰错误 |
65
+ | `lint` | 22 | **静态检查 `npm run lint` 自身的回归**:在临时目录造一棵有问题的树,逐条断言语法错、导入路径不存在 / 大小写不一致、具名导出不存在(含 `export { x } from`、`await import()` 解构、`node:` 内置模块)、未使用导入都会报;写法刁钻但合法的树(模板字符串里的用法、正则里的引号、`export *`)零误报;并断言本仓库自己是 lint 干净的(不花 token) |
66
+ | `timeout` | 40 | **超时语义**:`wait=false` 不受 `timeout_ms` 影响、`wait=true` 到期只转后台(不取消/不丢结果、`busy` 不说谎)、`timeout_ms=0` 一直等、**负 `timeout_ms` 明确报错**(不派出回合、会话照常可用)、resume 失败会作废进程而不永久卡死、空 prompt 给清晰错误 |
49
67
  | `lock` | 79 | **写锁与抢占**:四类持有者判定、`writeMarker` 不覆盖活着的持有者、登记文件损坏/目录缺失等边界;**端到端**两实例抢锁 → 清晰报错 → 拒绝 → `force` 夺锁;**崩溃自动释放锁**、**卡死持有者无 force 自动抢占**;以及"一个回合 = 一个哨兵"的不变量(含内联回合与空闲插话) |
50
68
  | `cycle` | 36 | **异步派活的生命周期**:异步派活 → 收哨兵 → 空闲回收 → 再派活,连做 3 轮必须全程无锁错误;贴着回收窗口立刻重派(放大竞态);`dsh_release` 后立刻重派;并断言回收后**不留下无法归因的锁**(这条实测抓到过"刚拉起的进程被 reaper 当空闲收掉"的竞态) |
51
69
  | `concurrency` | 31 | 三会话并发 + 实时增量读 |
52
70
  | `capability` | 22 | 写代码、跑脚本、**自行派子代理**(用磁盘上的子会话头验证) |
53
71
  | `workspace-effect` | 24 | 不给路径时工作区是否真的生效 |
54
- | `acceptance` | 36 | 两个文件夹 × 两个会话做只读 IDA Pro 分析 |
72
+ | `acceptance` | 36(没有 IDA MCP 时 skip) | 两个文件夹 × 两个会话做只读 IDA Pro 分析 |
55
73
  | `multi` | 19 | **多实例共存 + 非 ASCII 路径**:复现"两实例共写注册表 → 后写的把先建的会话抹掉",并验证**仅凭 id 就能从会话存储找回并真的派活**(指注册表丢失,不是显式 `forget` —— forget 是终态墓碑);**兜底取回不会悄悄提权**(read-only 会话取回后仍是 read-only);不留 `.tmp` 残渣;**中文 / emoji 工作区路径**能建会话、能干活、文件落在正确目录 |
56
74
  | `permission` | 11 | ★ **权限档是否真的生效**(安全属性):不看我们的返回值(那正是会说谎的地方),而是读**会话自己记录的事实**(投影缓存里的 `permissions.preset` / `sandboxMode`),三档逐一核对且三档记录值互不相同。**这条测试抓到过无声提权**:profile 里 `defaultPreset` 会在会话创建时盖掉 `DSH_PERMISSION_MODE` |
57
- | `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
75
+ | `list` | 17 | **`dsh_list` 的开销**:含磁盘探测的默认调用要 spawn 一个 DSH 进程(会话多时数秒),**连续调用命中缓存**(用 `probe_cached` 判定而不是墙钟:Windows 上偶尔有一次缓存调用多出约 700ms);`only_running=true` 与 `include_closed=false` **跳过探测**(`probe_ok=null`)且语义等价(不把磁盘上的未打开会话算进来) |
58
76
  | `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
59
77
 
60
- **`node test/run.mjs` 在本机 7/9 客户端配置时跑 1012 项(九种齐全时 1030 项)** —— smoke 31 + 离线套件 981 项(九种齐全时 999 项)。**0.5.0 加调用方审核后全部退出码 0**(2026-11-04,Windows / Node 22.19.0;本机 5/9 配置时实测 994 项);都不需要 provider(没有 DSH 时 smoke 自动跳过建会话段)。
78
+ > **2026-11-06**:`node test/run.mjs` 现在**跑完全部套件**再在末尾列出所有失败项(`--bail` 恢复遇错即停)—— 以前 `timeout.mjs` 一条过时断言就让后面 11 个真机套件全部没跑。需要模型的套件已对桌面版自带运行时上的 DSH 0.2.2-rc.1 全量重跑。
79
+
80
+ <!-- counts:begin (generated by scripts/sync-test-counts.mjs; do not edit by hand) -->
81
+ **`node test/run.mjs` 在本机(Windows,装了 DSH,5/9 个客户端配置 fixture)跑了 31 个离线套件、1417 项检查,全部通过。**表中带括号的数字随环境变化(有没有 DSH、哪个平台、文件系统是否区分大小写、本机装了几个客户端配置),套件总数也随之变化;这一行记录的是上次同步所用报告的环境。
82
+
83
+ 其中 **524 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 48)—— 0.3 的活其实都在这里。
84
+
85
+ 需要模型的套件(`boundary` 46、`integration` 30、`async` 21、`sentinel` 36、`timeout` 40、`lock` 79、`multi` 19、`list` 17、`permission` 11、`shared-process` 8、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`reasoning-check` 8)名义合计 **464 项**;有 DSH 时 `smoke` 还会多跑 13 项会话段。它们要可用的 DSH 与 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**;`shared-process` 没有 DSH 时也会 skip。
61
86
 
62
- 其中 **542 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 66;九种齐全时 560 项)—— 0.3 的活其实都在这里。
87
+ > 三个总数是刻意分开的:本机 `node test/run.mjs`(1417)、CI 离线 job(名义 Linux/macOS 1349,Windows 1355;hosted runner 没有 DSH 也没有客户端 fixture,`smoke` 跳过会话段、`real-configs` 整体跳过)、需要模型的集合(名义 464)。数字取各套件自己打印的汇总,由 `npm run docs:counts` 从报告写回,不在这里手抄;不要把一个数字套到另一个 runner 上。
88
+ <!-- counts:end -->
89
+
90
+ ### 🔢 改了测试之后:检查数是算出来的
91
+
92
+ 本文的检查数不手写。`node test/run.mjs` 每次运行都会把各套件的输出收下来,解析成一份 JSON 报告(默认 `.state/test-report.json`,已被 `.gitignore`;`--report <路径>` 换位置,`--no-report` 不写):每个套件的 `passed` / `failed` / `skipped` / `exitCode` / `durationMs`,加上平台、Node 与 DSH 版本、本机有几个客户端配置 fixture。数字取各套件自己打印的汇总行(`===== …:通过 N,失败 M =====`);没有汇总行的老套件退回去数 `✓`/`✗` 行;新写的套件请直接打印标准汇总行。
93
+
94
+ **改完测试后:**
95
+
96
+ ```bash
97
+ node test/run.mjs --all --report .state/test-report.json # 要有 DSH/provider;只想更新离线数字就去掉 --all
98
+ npm run docs:counts # 写回本表、上面的总数段落(中英文)与 .github/workflows/tests.yml 的范围注释
99
+ git diff docs .github # 看一眼改了什么,然后提交
100
+ ```
63
101
 
64
- 需要模型的套件(`smoke` 31、`boundary` 46、`integration` 30、`async` 21、`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、`shared-process` 8)合计 489 项。多数最近一次完整实测在 0.3.9;与调用方审核相关的 `smoke`/`integration`/`async`/`sentinel`/`boundary` 已在本树重跑。它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**;`shared-process` 没有 DSH 时也会 skip。
102
+ - **没带 `--all`**:报告里没有需要模型的套件,模型套件的数字原样保留(脚本会说明)。
103
+ - **随环境变化的套件**写成「主数字(其它环境的数字)」:`smoke`(有无 DSH)、`dispatch-tools`(Windows 多跑 PowerShell 哨兵的真机执行)、`config-safety` 与 `real-configs`(本机装了几个客户端配置)、`workspace-titles`(文件系统是否区分大小写、`TEMP` 是否在家目录内)。**只更新这次量到的那一格**,其余格子原样保留 —— 想补哪一格,就在对应环境里再跑一次,把几份报告按顺序用多个 `--report` 传给 `docs:counts`。
104
+ - **整套 skip 的套件**(没有 DSH 的 `shared-process`、没有 IDA MCP 的 `acceptance`、没有客户端配置的 `real-configs`)不动数字。
105
+ - **报告里有失败的套件时脚本拒绝写入**(exit 2):失败套件的检查数不可信。
106
+ - **新增套件**:先加进 `test/run.mjs` 的 `OFFLINE`/`MODEL`,再在表里补一行(脚本不替你写"覆盖"说明;缺行会 exit 1 并点名)。
65
107
 
66
- > 三个总数是刻意分开的:`node test/run.mjs`(1012/1030)、CI 离线 job(名义 981/999,不含 smoke;hosted runner 没有客户端 fixture 时 `real-configs` 会整体跳过)、需要模型的集合(名义 489)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
108
+ **防漂移:** `npm run docs:counts:check` 不写文件,文档与报告不一致就打印差异并 exit 1。CI 的离线 job 跑完 `node test/run.mjs --report` 后执行 `docs:counts:check --portable`,只核对与环境无关的套件,以及只取决于「没有 DSH、没有客户端配置」的 `smoke` / `config-safety` 在 runner 上那一格;随平台变化的套件留给本机的完整检查(发版清单里有这一步)。`test-counts` 套件覆盖报告解析与文档重写本身。
67
109
 
68
110
  ### 🧯 为什么会有 `sandbox-guard`
69
111
 
package/docs/zh/usage.md CHANGED
@@ -43,7 +43,16 @@ done
43
43
  echo "DONE" # 后台任务退出 → 宿主唤醒你 → 读文件收活
44
44
  ```
45
45
 
46
- 文件出现后:**直接读它**即得 `status` 和 `result`(推荐——不受内存窗口限制、还扛服务重启),或用 `dsh_get(conversation_id, run_id)` 验收。
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.1",
3
+ "version": "0.6.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",
@@ -53,7 +53,10 @@
53
53
  "test:smoke": "node ./test/smoke.mjs",
54
54
  "test:guards": "node ./test/guards.mjs",
55
55
  "test:prune": "node ./test/prune.mjs",
56
+ "lint": "node ./scripts/lint.mjs",
56
57
  "check:release": "node ./scripts/check-release.mjs",
58
+ "docs:counts": "node ./scripts/sync-test-counts.mjs",
59
+ "docs:counts:check": "node ./scripts/sync-test-counts.mjs --check",
57
60
  "commit": "node ./scripts/commit.mjs"
58
61
  },
59
62
  "engines": {
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI 用:问项目自己"这台机器上找得到 DSH 吗"。
4
+ *
5
+ * 为什么不在 workflow 里写 `command -v dsh`:服务自己并不靠 PATH 上的 `dsh` 找 DSH,
6
+ * 而是用 src/dsh-locate.mjs 的 resolveDsh()(桌面版注册表 / 默认安装路径、npm 全局、
7
+ * npx 缓存,`DSH_BIN` 可显式覆盖)。只装了桌面版、PATH 里没有 `dsh` 的自托管 runner
8
+ * 用旧写法会被误判成"没有 DSH",模型套件整个跳过。这里直接复用同一套逻辑,
9
+ * CI 判定与服务实际行为就不会再分叉。
10
+ *
11
+ * 写成脚本而不是内联 `node -e`:workflow 的 shell 在 Linux(bash)与 Windows(pwsh)上
12
+ * 对引号的处理不同,`node scripts/detect-dsh.mjs` 在两边写法一致。
13
+ *
14
+ * 输出:
15
+ * - 标准输出一行人类可读的结果;
16
+ * - 在 GitHub Actions 里(有 GITHUB_OUTPUT)再写 ok=1|0、source、version,
17
+ * 后续步骤用 steps.<id>.outputs.ok 判断;
18
+ * - 找不到时退出码仍是 0(这是"跳过",不是失败),由 workflow 决定后续步骤。
19
+ *
20
+ * 只读:不拉起 DSH,只读安装目录 / asar 里的版本信息(以及 Windows 上的 `reg export`)。
21
+ */
22
+
23
+ import { appendFileSync } from 'node:fs';
24
+ import { SOURCE_LABEL, resolveDsh } from '../src/dsh-locate.mjs';
25
+
26
+ let ok = false;
27
+ let source = '';
28
+ let version = '';
29
+ let detail = '';
30
+
31
+ try {
32
+ const dsh = resolveDsh({ fresh: true });
33
+ ok = true;
34
+ source = dsh.source;
35
+ version = dsh.version ?? '';
36
+ detail = `${SOURCE_LABEL[dsh.source] ?? dsh.source} / DSH ${dsh.version ?? '未知版本'} / ${dsh.bin}`;
37
+ console.log(`DSH found: ${detail}`);
38
+ } catch (e) {
39
+ // resolveDsh 的报错是多行的;GitHub 注解里换行需要转义,这里只取第一行
40
+ detail = String(e.message).split('\n')[0];
41
+ console.log(`DSH not found: ${detail}`);
42
+ }
43
+
44
+ if (process.env.GITHUB_OUTPUT) {
45
+ appendFileSync(process.env.GITHUB_OUTPUT, `ok=${ok ? '1' : '0'}\nsource=${source}\nversion=${version}\n`);
46
+ }
47
+ if (process.env.GITHUB_ACTIONS === 'true') {
48
+ if (ok) console.log(`::notice title=DSH detected::${detail}`);
49
+ else {
50
+ console.log(
51
+ `::warning title=Model suites skipped::${detail} -- use a self-hosted runner with DeepSeek Harness ` +
52
+ '(desktop app, npm global or npx cache; or set the DSH_BIN secret), a provider and credentials.',
53
+ );
54
+ }
55
+ }