dsh-any-agent 0.3.4 → 0.3.6
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 +665 -0
- package/DESIGN.md +2 -2
- package/README.md +66 -22
- package/README.zh.md +37 -25
- package/USAGE-MIGRATION.md +9 -9
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +7 -7
- package/bin/dsh-any-agent-workspaces.mjs +22 -3
- package/bin/dsh-any-agent.mjs +3 -2
- package/bin/restore.mjs +7 -3
- package/package.json +2 -1
- package/src/acp.mjs +24 -3
- package/src/bootstrap.mjs +73 -64
- package/src/clients.mjs +35 -17
- package/src/config.mjs +59 -8
- package/src/hub.mjs +154 -19
- package/src/locks.mjs +39 -1
- package/src/mcp.mjs +6 -0
- package/src/tools.mjs +43 -14
- package/src/workspace.mjs +148 -43
- package/src/yaml-lite.mjs +424 -0
package/DESIGN.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# dsh-any-agent 功能设计(v0.2
|
|
1
|
+
# dsh-any-agent 功能设计(v0.2 草案,历史文档)
|
|
2
2
|
|
|
3
3
|
> 目标:做一个 MCP Server,让 **Codex / Claude Code** 能调用 **DeepSeek Harness (DSH)** 当"外包工程师"——写代码、搭脚手架、跑任务,并且 DSH 自己还能挂它自己的 MCP。
|
|
4
4
|
>
|
|
5
|
-
>
|
|
5
|
+
> 本文只谈**功能与接口**,不写实现代码;其中 M2/M3 工具(`dsh_rename` 等)是分期计划,不是当前 0.3.6 的缺失功能。技术判断最初基于 DSH 0.1.7-rc.2,当前验证基线是 **DSH 0.2.1-rc.5**(见 §2 证据)。
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪
|
|
18
|
+
🧪 815 checks green on 0.3.6 on this machine (851 when all nine client fixtures exist); 519 of them are in the client-registration suites (555 with all fixtures) · 🔌 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
|
|
|
@@ -77,7 +77,7 @@ DSH (DeepSeek Harness) can do all of that — it has file/shell tools, skills, s
|
|
|
77
77
|
|
|
78
78
|
## ⚡ Why not just wrap the DSH CLI?
|
|
79
79
|
|
|
80
|
-
DSH ships two programmable entry points. The `sdk` profile looks simpler, but its JSON-RPC surface has only **3 requests and 4 notifications** — and it lacks three things that make an agent unusable in practice. The `acp` profile (Agent Client Protocol) has all of them. Everything below was verified against DSH `0.1
|
|
80
|
+
DSH ships two programmable entry points. The `sdk` profile looks simpler, but its JSON-RPC surface has only **3 requests and 4 notifications** — and it lacks three things that make an agent unusable in practice. The `acp` profile (Agent Client Protocol) has all of them. Everything below was verified against DSH `0.2.1-rc.5`.
|
|
81
81
|
|
|
82
82
|
| Capability | `sdk` profile | `acp` profile (**this project**) |
|
|
83
83
|
|---|---|---|
|
|
@@ -214,7 +214,7 @@ What changed since 0.1.x: JSON configs used to be **re-serialised whole** (value
|
|
|
214
214
|
|
|
215
215
|
Codex's `config.toml` gets the same treatment line-wise: your other `[tables]` and their comments, the top-level keys (`model`, `approval_policy`, `sandbox_mode`, …) and CRLF endings come back exactly as they were — `--remove` deletes only the two `[mcp_servers.dsh-any-agent]` sections and leaves the rest intact.
|
|
216
216
|
|
|
217
|
-
The coverage for each of those claims is in [`test/`](test/) (`test/config-safety.mjs` asserts them one by one,
|
|
217
|
+
The coverage for each of those claims is in [`test/`](test/) (`test/config-safety.mjs` asserts them one by one, 406 checks).
|
|
218
218
|
|
|
219
219
|
**Checked against a real home, read-only.** Running `setup --json` (plan mode) against an actual developer profile reported all nine clients and left every existing config untouched — identical size, mtime and SHA-256, no `.bak-dsh-*` created, no new file created:
|
|
220
220
|
|
|
@@ -415,7 +415,7 @@ Besides `run_id`, `wait=false` returns a **`sentinel_file`** path plus **`sentin
|
|
|
415
415
|
|
|
416
416
|
```bash
|
|
417
417
|
# sent="$sentinel_file_posix" ← copy it straight from the tool result
|
|
418
|
-
sent='/
|
|
418
|
+
sent='/c/Users/you/.dsh/.dsh-any-agent/runs/<conversation_id>/<run_id>.json'
|
|
419
419
|
dl=$(( $(date +%s) + 2100 )) # 35min safety net so a dead service can't hang you forever
|
|
420
420
|
until [ -f "$sent" ]; do
|
|
421
421
|
[ "$(date +%s)" -ge "$dl" ] && { echo "TIMEOUT"; exit 1; }
|
|
@@ -428,7 +428,7 @@ Once the file exists: **read it** for `status` and `result` (preferred — immun
|
|
|
428
428
|
|
|
429
429
|
Design notes (especially under **concurrent multi-session** use):
|
|
430
430
|
|
|
431
|
-
- **No cross-talk**: the path is
|
|
431
|
+
- **No cross-talk**: the path is `<state dir>/runs/<conversation_id>/<run_id>.json`, namespaced by conversation. `run_id` sequence numbers are counted per-conversation, so two conversations *can* mint the same `run_id` in the same millisecond — the per-conversation directory keeps them apart. Arm one background task per run; each completes and wakes you independently.
|
|
432
432
|
- **Latch semantics**: the file is written once and kept. Even if the run finishes *before* you arm the waiter, `[ -f ]` is immediately true — you never miss it.
|
|
433
433
|
- **Atomic write**: `tmp` + `rename`; if the file exists its contents are complete. Each run uses its own tmp, so concurrent writes never clobber.
|
|
434
434
|
- **No server push**: the service only answers requests over MCP (it sends no notifications). "Completion notification" is entirely the sentinel file plus your background waiter.
|
|
@@ -509,7 +509,7 @@ Silent by default — **not one byte is written**.
|
|
|
509
509
|
|
|
510
510
|
**This server never writes reasoning to disk** — with exactly one deliberate exception:
|
|
511
511
|
|
|
512
|
-
- Not in logs (silent by default). The state file and the per-conversation metadata sidecars under
|
|
512
|
+
- Not in logs (silent by default). The state file and the per-conversation metadata sidecars under `<state dir>/meta/` hold metadata only (id, cwd, permissions, counters, title hint) — a smoke-test assertion guards the registry.
|
|
513
513
|
- **The async completion sentinel persists the answer, usage and tool inputs** (it must, so a caller can collect offline) but **strips `result.thinking`** and always reports `thinking_omitted` (true when reasoning was removed, false when there was none). Tool inputs are passed through the same credential redaction as approvals, but the sentinel still contains commands and file contents — treat it as sensitive. A test asserts that even when `reasoning=full` is requested, no reasoning text appears in the sentinel — while `dsh_get(run_id)` still returns it from memory. Set `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` to break this on purpose.
|
|
514
514
|
- Sentinels are pruned after 7 days by default (`DSH_MCP_SENTINEL_TTL_MS`; `0` keeps them forever).
|
|
515
515
|
|
|
@@ -520,23 +520,28 @@ Silent by default — **not one byte is written**.
|
|
|
520
520
|
| Variable | Default | Purpose |
|
|
521
521
|
|---|---|---|
|
|
522
522
|
| `DSH_BIN` | auto-detected | Path to `@deepseek-ai/dsh/lib/bin.js` |
|
|
523
|
+
| `DSH_HOME` | `~/.dsh` | DSH home shared with the GUI (credentials, sessions, workspace registry). Point it elsewhere only for isolation/multi-account |
|
|
523
524
|
| `DSH_MCP_PROFILE` | `dsh-mcp` | DSH profile to drive. ⚠️ The stale name is deliberate — see [Why is the profile still called `dsh-mcp`?](#-why-is-the-profile-still-called-dsh-mcp) |
|
|
524
525
|
| `DSH_MCP_BOOTSTRAP` | on | Whether this server may create/sync the DSH profile on first start. `0` makes it drive an existing profile only and never write a file |
|
|
525
526
|
| `DSH_MCP_ROUTE_SOURCE` | unset | Pin where model routes are copied from during bootstrap: a profile name (`desktop`, `web`, …) or `__home__` for `$DSH_HOME/cordis.patch.yml`. Unset = try home → desktop → web → headless → acp |
|
|
526
|
-
| `DSH_MCP_STATE` |
|
|
527
|
+
| `DSH_MCP_STATE` | `$DSH_HOME/.dsh-any-agent/conversations.json` | Conversation registry, survives restarts. The old `<repo>/.state` location is migrated once on startup |
|
|
528
|
+
| `DSH_MCP_APPROVAL` | `auto-allow` (non-read-only) | Default approval policy. `ask` makes out-of-workspace requests surface as pending approvals instead of being auto-allowed |
|
|
529
|
+
| `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | How long a pending `ask` approval waits before being cancelled |
|
|
530
|
+
| `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | Tombstones written by `dsh_release(forget=true)` |
|
|
527
531
|
| `DSH_MCP_RUNS_DIR` | `<state file dir>/runs` | Root for async completion sentinels (`<this>/<conversation_id>/<run_id>.json`) |
|
|
528
532
|
| `DSH_MCP_LOCKS_DIR` | `<state file dir>/locks` | Holder registrations for the write lock. ⚠️ **All instances must agree on this value**, or they cannot see each other's registrations and will misread each other as an unidentifiable holder (a GUI) and refuse to preempt |
|
|
529
533
|
| `DSH_MCP_LOCK_STALE_MS` | `90000` | How long without a heartbeat before a holder counts as lost (and becomes auto-preemptible by `dsh_takeover`) |
|
|
530
534
|
| `DSH_MCP_IDLE_TTL_MS` | `0` (never reap) | How long a conversation may sit idle before its DSH process is reaped. ★★ **`0` means never reap (the default)**: the server keeps holding the write lock, so **the GUI cannot take it** — opening the conversation there is read-only and still works. Cost: one resident DSH process per conversation (**measured 300 MB and 476 MB in two cold starts**). Set a millisecond value (e.g. `300000`) to save memory and accept that the lock can be taken |
|
|
531
|
-
| `DSH_MCP_MAX_LIVE` | `8` (≈2.5–4 GB worst case; see note 8) | ★ **How many conversation processes to keep alive at once** (`0` = unlimited). Going over the cap reaps the **least recently used** one (a running turn is never reaped) — "**bounded memory, locks kept where it matters**". Use `4` when memory is tight, `0` on a big machine. To free
|
|
535
|
+
| `DSH_MCP_MAX_LIVE` | `8` (≈2.5–4 GB worst case; see note 8) | ★ **How many conversation processes to keep alive at once** (`0` = unlimited). Going over the cap reaps the **least recently used** one (a running turn is never reaped) — "**bounded memory, locks kept where it matters**". Use `4` when memory is tight, `0` on a big machine. To free idle conversations at once: `dsh_release(all=true)` (busy turns are skipped unless `force=true`) |
|
|
532
536
|
| `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | Cache lifetime for `dsh_list`'s on-disk probe (the probe spawns a DSH process and takes ~1 s) |
|
|
533
537
|
| `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels; `0` disables pruning |
|
|
534
538
|
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**) |
|
|
535
539
|
| `DSH_MCP_PERMISSION` | `danger-full-access` | Default tier for `dsh_start` |
|
|
536
540
|
| `DSH_MCP_REASONING_EFFORT` | `max` | Default reasoning effort |
|
|
537
541
|
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
|
|
538
|
-
| `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | How long a pending approval waits |
|
|
539
542
|
| `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
|
|
543
|
+
| `DSH_MCP_GIT_STATUS` | on | `0` disables the per-turn `git status` summary. Useful for untrusted repos (git can run repo-configured fsmonitor/filters outside DSH's sandbox) |
|
|
544
|
+
| `DSH_MCP_BACKUP_KEEP` | `20` | How many `.bak-dsh-*` copies to keep per client config (rotation is best-effort) |
|
|
540
545
|
| `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | Per-request timeout on the ACP channel (initialize, session/new, …) |
|
|
541
546
|
| `DSH_MCP_REAP_INTERVAL_MS` | `30000` | How often the idle reaper checks for conversations to reclaim. Values below the safe floor fall back to the default |
|
|
542
547
|
| `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
|
|
@@ -545,12 +550,12 @@ Silent by default — **not one byte is written**.
|
|
|
545
550
|
## 🧪 Tests
|
|
546
551
|
|
|
547
552
|
```bash
|
|
548
|
-
node test/run.mjs #
|
|
549
|
-
node test/run.mjs --all #
|
|
550
|
-
node test/cleanup.mjs # suite teardown on its own (
|
|
553
|
+
node test/run.mjs # all offline suites (no LLM calls; needs DSH for smoke)
|
|
554
|
+
node test/run.mjs --all # offline + model-backed suites
|
|
555
|
+
node test/cleanup.mjs # suite teardown on its own (test-created dirs only; --dry-run to preview)
|
|
551
556
|
```
|
|
552
557
|
|
|
553
|
-
The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`, which removes
|
|
558
|
+
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`.
|
|
554
559
|
|
|
555
560
|
| Suite | Checks | Covers |
|
|
556
561
|
|---|---|---|
|
|
@@ -564,12 +569,20 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
564
569
|
| `sandbox-guard` | 18 | **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) |
|
|
565
570
|
| `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 |
|
|
566
571
|
| `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) |
|
|
567
|
-
| `sentinel-redaction` |
|
|
572
|
+
| `sentinel-redaction` | 15 | **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) |
|
|
568
573
|
| `forget-tombstone` | 7 | **`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) |
|
|
569
|
-
| `
|
|
574
|
+
| `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) |
|
|
575
|
+
| `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) |
|
|
576
|
+
| `permission-adopt` | 13 | **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) |
|
|
577
|
+
| `state-safety` | 21 | **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) |
|
|
578
|
+
| `setup` | 41 | **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` |
|
|
570
579
|
| `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) |
|
|
571
|
-
| `config-safety` |
|
|
572
|
-
| `real-configs` | 66
|
|
580
|
+
| `config-safety` | 406 | **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) |
|
|
581
|
+
| `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) |
|
|
582
|
+
| `commit-script` | 26 | commit messages go through stdin, never a temp file that `git add -A` could capture (no tokens) |
|
|
583
|
+
| `roots` | 12 | `dsh_start` without `cwd` asks the client for MCP roots; a client without roots gets an actionable error (no tokens) |
|
|
584
|
+
| `workspace-titles` | 15 | home-as-workspace is rejected before DSH starts; `title_hint` is returned separately from DSH's automatic title (no tokens) |
|
|
585
|
+
| `package-check` | 14 | `files` whitelist, zero runtime dependencies, Node floor, and bin shebangs (no tokens) |
|
|
573
586
|
| `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 |
|
|
574
587
|
| `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) |
|
|
575
588
|
| `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) |
|
|
@@ -580,14 +593,15 @@ The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`,
|
|
|
580
593
|
| `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 |
|
|
581
594
|
| `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 |
|
|
582
595
|
| `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) |
|
|
596
|
+
| `reasoning-check` | 8 | **reasoning visibility**: `reasoning=hide` returns no thinking body but non-zero stats; `reasoning=full` returns the text and the rendered section |
|
|
583
597
|
|
|
584
|
-
|
|
598
|
+
**`node test/run.mjs` runs 815 checks on a machine with five client fixtures (851 with all nine)** — smoke 29 + 786 offline checks (822 with nine). Every suite exited 0 on 0.3.6; none needs a provider (smoke needs a DSH install for its session-creation section).
|
|
585
599
|
|
|
586
|
-
That is **
|
|
600
|
+
That is **519 assertions in the client-registration suites alone** (`setup` 41, `removal-scope` 24, `config-safety` 406, `real-configs` 48; 555 with all nine fixtures), which is where the 0.3 work actually is.
|
|
587
601
|
|
|
588
|
-
The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13) total
|
|
602
|
+
The model-backed suites (`smoke` 29, `boundary` 46, `integration` 30, `async` 16, `sentinel` 36, `timeout` 38, `lock` 79, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `multi` 19, `permission` 11, `list` 13, `reasoning-check` 8) total 474 and were last measured in full on 0.3.6 — 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__*`).
|
|
589
603
|
|
|
590
|
-
> Counts are the numbers each suite prints itself
|
|
604
|
+
> Three different totals are intentional: `node test/run.mjs` (815/851), the CI offline job (786/822, no smoke), and the model-backed set (474 nominal). Counts are the numbers each suite prints itself; don't use one number for a different runner.
|
|
591
605
|
|
|
592
606
|
### 🧯 Why `sandbox-guard` exists
|
|
593
607
|
|
|
@@ -651,7 +665,7 @@ DSH conversations carry a **cross-process write lock**: **it never expires while
|
|
|
651
665
|
5. **A conversation that dies before its first successful turn may never have materialized on disk** — resume then fails with a clear error. Safe after the first message.
|
|
652
666
|
6. **No renaming** — DSH's title subsystem has no external rename API (`SessionTitleService.rename` requires a live in-process session). Titles are auto-generated from the first message.
|
|
653
667
|
7. **`session/list` returns only `{sessionId, cwd}` and excludes already-open sessions** — titles are filled in by this server from DSH's projection cache.
|
|
654
|
-
8. **"Never reap" costs memory (measured 300–480 MB per conversation)** — that is what buys "the GUI can never take the write lock". Two fresh-start measurements of one active conversation gave 476 MB and 295 MB of added resident memory (the DSH child plus this server); expect the higher figures on a cold process. Two ways to spend less: ① **`DSH_MCP_MAX_LIVE` (default 8** — over the cap, the least recently used process is reaped → bounded memory, locks still held where it matters); ② set `DSH_MCP_IDLE_TTL_MS` to a millisecond value (at the cost of the lock being takeable). To drop to zero right after a batch of work: **`dsh_release(all=true)`** ✓ (
|
|
668
|
+
8. **"Never reap" costs memory (measured 300–480 MB per conversation)** — that is what buys "the GUI can never take the write lock". Two fresh-start measurements of one active conversation gave 476 MB and 295 MB of added resident memory (the DSH child plus this server); expect the higher figures on a cold process. Two ways to spend less: ① **`DSH_MCP_MAX_LIVE` (default 8** — over the cap, the least recently used process is reaped → bounded memory, locks still held where it matters); ② set `DSH_MCP_IDLE_TTL_MS` to a millisecond value (at the cost of the lock being takeable). To drop to zero right after a batch of work: **`dsh_release(all=true)`** ✓ (releases idle conversations; busy turns are skipped and keep running — pass `force=true` to interrupt them too. Everything resumes on demand).
|
|
655
669
|
|
|
656
670
|
## ❓ Why is the profile still called `dsh-mcp`?
|
|
657
671
|
|
|
@@ -659,6 +673,36 @@ Because renaming it would break things that already exist on disk.
|
|
|
659
673
|
|
|
660
674
|
When you first start this server it creates a DSH profile under `~/.dsh/profiles/dsh-mcp/`. That directory name is referenced by three other persisted things:
|
|
661
675
|
|
|
676
|
+
| Persisted identifier | Location | What breaks if renamed |
|
|
677
|
+
|---|---|---|
|
|
678
|
+
| profile directory | `~/.dsh/profiles/dsh-mcp/` | existing sessions can no longer resolve their provider (`no adapter registered for provider "…"`) |
|
|
679
|
+
| `DSH_MCP_*` environment variables | your client configs | every deployment must change at once |
|
|
680
|
+
| `# dsh-mcp:managed` marker line | first line of the generated `cordis.patch.yml` | bootstrap no longer recognizes the file as its own and stops syncing it |
|
|
681
|
+
|
|
682
|
+
So the **product name** is `dsh-any-agent`, while the **on-disk identifiers** deliberately keep the old name. This is a compatibility decision, not an oversight. For a fresh deployment that wants a different profile name, set `DSH_MCP_PROFILE` — the server will use the name you give it.
|
|
683
|
+
|
|
684
|
+
## 📦 Release process
|
|
685
|
+
|
|
686
|
+
The order matters; two of these steps exist because a bad release shipped without them.
|
|
687
|
+
|
|
688
|
+
```bash
|
|
689
|
+
# 1. Bump the version. SERVER_VERSION reads package.json, nothing else.
|
|
690
|
+
npm version patch --no-git-tag-version
|
|
691
|
+
# Then edit CHANGELOG.md: turn [Unreleased] into the new version.
|
|
692
|
+
|
|
693
|
+
# 2. Run the offline suites locally (same set as CI, no tokens, no DSH needed for most).
|
|
694
|
+
node test/run.mjs
|
|
695
|
+
node test/package-check.mjs
|
|
696
|
+
|
|
697
|
+
# 3. Verify the tarball itself: builds, installs, starts.
|
|
698
|
+
npm pack --dry-run
|
|
699
|
+
# Then in a temp dir: npm install <tgz> && node_modules/.bin/dsh-any-agent --version
|
|
700
|
+
|
|
701
|
+
# 4. Tag and push (wait for CI before publishing).
|
|
702
|
+
git add -A && git commit -m "chore: release x.y.z"
|
|
703
|
+
git tag vx.y.z && git push origin main --tags
|
|
704
|
+
```
|
|
705
|
+
|
|
662
706
|
## Star History
|
|
663
707
|
|
|
664
708
|
<a href="https://www.star-history.com/?repos=xmtlzzz%2Fdsh-any-agent&type=date&legend=top-left">
|
package/README.zh.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
🧪 0.3.
|
|
18
|
+
🧪 0.3.6 本机 815 项检查全绿(九种客户端配置齐全时 851 项;其中 519 项属于客户端注册器套件,齐全时 555 项) · 🔌 MCP over stdio · 📦 零依赖 · 📜 MIT
|
|
19
19
|
|
|
20
20
|
一个 MCP 服务端,把 DeepSeek Harness 当作**长驻的编码 agent** 来驱动。开个会话、扔个目标,它自己写代码、跑脚本、派子代理;你随时能看、能插话、能回头验收。
|
|
21
21
|
|
|
@@ -77,7 +77,7 @@ DSH(DeepSeek Harness)本身**全都能做** —— 它有文件/shell 工具
|
|
|
77
77
|
|
|
78
78
|
## ⚡ 为什么不是直接把 DSH 包一层
|
|
79
79
|
|
|
80
|
-
DSH 自带两个可编程入口。`sdk` profile 看着更简单,但它的 JSON-RPC 只有 **3 个请求 + 4 个通知**,缺了三样让 agent 实际不可用的能力;`acp` profile(Agent Client Protocol)全都有。以下结论全部针对 DSH `0.1
|
|
80
|
+
DSH 自带两个可编程入口。`sdk` profile 看着更简单,但它的 JSON-RPC 只有 **3 个请求 + 4 个通知**,缺了三样让 agent 实际不可用的能力;`acp` profile(Agent Client Protocol)全都有。以下结论全部针对 DSH `0.2.1-rc.5` 实测得出。
|
|
81
81
|
|
|
82
82
|
| 能力 | `sdk` profile | `acp` profile(**本项目**) |
|
|
83
83
|
|---|---|---|
|
|
@@ -132,7 +132,7 @@ DSH 自带两个可编程入口。`sdk` profile 看着更简单,但它的 JSON
|
|
|
132
132
|
|
|
133
133
|
1. 在 `$DSH_HOME/profiles/dsh-mcp/` 建一个基于出厂 `acp` 模板的 profile;
|
|
134
134
|
2. 从本机**已有**的 profile(`$DSH_HOME/cordis.patch.yml` → `desktop` → `web` → `headless` → `acp` 取第一个有配置的)复制模型路由;
|
|
135
|
-
3. 写 `acp
|
|
135
|
+
3. 写 `acp`、`agent-default-model` 与三档 `permission` presets(**不写 `defaultPreset`**,否则会覆盖权限档),让 ACP 会话用与 GUI 相同的模型,并让 read-only 真正生效。
|
|
136
136
|
|
|
137
137
|
复制的内容只有 `apiKeyEnv` / `baseURL` / `api` / `models` / `reasoningEfforts`;`apiKey`(适配器从不读取的死字段)与 `headers`(可能夹凭据)一律剥掉。凭据本身由 DSH 的凭据服务逐次请求解析,路径与 GUI 完全一致。
|
|
138
138
|
|
|
@@ -227,7 +227,7 @@ Zed 33 -> 33 个键 只有 context_servers 多了个子项
|
|
|
227
227
|
|
|
228
228
|
Codex 的 `config.toml` 同理,是按行处理的:其它 `[表]` 及其注释、顶层键(`model`、`approval_policy`、`sandbox_mode` 等)、CRLF 行尾都原样保留 —— `--remove` 只删 `[mcp_servers.dsh-any-agent]` 那两段,其余一律不动。
|
|
229
229
|
|
|
230
|
-
这些断言的详细覆盖见 [`test/`](test/)(`test/config-safety.mjs` 共
|
|
230
|
+
这些断言的详细覆盖见 [`test/`](test/)(`test/config-safety.mjs` 共 406 项,逐条断言上面每一条)。
|
|
231
231
|
|
|
232
232
|
**在真实 home 上做过只读验证。** 用 `setup --json`(纯计划模式)跑一遍开发机上的真实配置:9 个客户端全部报出,而现存的每份配置都**原封未动** —— size、mtime、SHA-256 全同,没有新增任何 `.bak-dsh-*`,也没有新建任何文件:
|
|
233
233
|
|
|
@@ -432,7 +432,7 @@ MCP 工具调用是**阻塞**的,所以 `dsh_send` **默认就不阻塞**(`w
|
|
|
432
432
|
|
|
433
433
|
```bash
|
|
434
434
|
# sent="$sentinel_file_posix" ← 直接用工具返回里那个字段
|
|
435
|
-
sent='/
|
|
435
|
+
sent='/c/Users/you/.dsh/.dsh-any-agent/runs/<conversation_id>/<run_id>.json'
|
|
436
436
|
dl=$(( $(date +%s) + 2100 )) # 35min 兜底,防服务中途死掉时无限挂
|
|
437
437
|
until [ -f "$sent" ]; do
|
|
438
438
|
[ "$(date +%s)" -ge "$dl" ] && { echo "TIMEOUT"; exit 1; }
|
|
@@ -445,7 +445,7 @@ echo "DONE" # 后台任务退出 → 宿主唤醒你
|
|
|
445
445
|
|
|
446
446
|
设计要点(尤其**多会话并发**):
|
|
447
447
|
|
|
448
|
-
- **不串台**:哨兵路径为
|
|
448
|
+
- **不串台**:哨兵路径为 `<状态目录>/runs/<conversation_id>/<run_id>.json`,按会话分目录。`run_id` 的序号是每会话独立计数的,不同会话在同一毫秒可能生成同名 `run_id`——分目录后各写各的,绝不互相覆盖。每个 run 起一个独立后台任务即可,各自完成、各自唤醒。
|
|
449
449
|
- **闩锁语义**:文件写一次并保留。即便 run 在你启动等待任务**之前**就已完成,`[ -f ]` 也立刻为真,不会漏。
|
|
450
450
|
- **原子落地**:`tmp` + `rename`,文件一旦存在内容必然完整;每个 run 用独立 tmp,并发写互不干扰。
|
|
451
451
|
- **无主动推送**:本服务在 MCP 协议上只应答请求、不发通知;"跑完通知"完全由这个哨兵文件 + 你的后台等待任务实现。
|
|
@@ -524,7 +524,7 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
|
|
|
524
524
|
|
|
525
525
|
**本服务不把思考内容写进自己的落盘物**,只有一个**刻意留下**的例外:
|
|
526
526
|
|
|
527
|
-
- 不写日志(默认静默);状态文件与
|
|
527
|
+
- 不写日志(默认静默);状态文件与 `<状态目录>/meta/` 下的会话元数据侧车都只有元数据(id、cwd、权限、计数、标题提示),冒烟测试里有一条断言专门守注册表。
|
|
528
528
|
- **异步完成哨兵需要把正文写进文件**(否则调用方无法离线收活),载荷还包含用量与(已按审批规则脱敏的)工具输入,但 `result.thinking` 会被**剥掉**,并始终给出布尔 `thinking_omitted`。测试里专门验证了:即使请求 `reasoning=full`,哨兵里也不出现任何思考文本——而同一 run 在内存(`dsh_get`)里仍能看到。设 `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` 可刻意打破这条。
|
|
529
529
|
- 哨兵默认 7 天后由启动清理删除(`DSH_MCP_SENTINEL_TTL_MS`,`0` 表示永久保留)。
|
|
530
530
|
|
|
@@ -539,20 +539,24 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
|
|
|
539
539
|
| `DSH_HOME` | `~/.dsh` | DSH 主目录。**默认就用本机用户自己的**,凭据与会话历史因此与 GUI 共用。指向别处即隔离环境(那就得自己准备那个目录的凭据与 profile) |
|
|
540
540
|
| `DSH_MCP_BOOTSTRAP` | 开 | `0` 关闭首启 profile 自动创建/同步(关掉后只驱动已存在的 profile,不写任何文件) |
|
|
541
541
|
| `DSH_MCP_ROUTE_SOURCE` | 自动 | 复制模型路由的来源 profile 名;`__home__` 表示只认 `$DSH_HOME/cordis.patch.yml` |
|
|
542
|
-
| `DSH_MCP_STATE` |
|
|
542
|
+
| `DSH_MCP_STATE` | `$DSH_HOME/.dsh-any-agent/conversations.json` | 会话注册表,跨重启存活。旧版 `<仓库>/.state` 会在首次启动时自动迁移一次 |
|
|
543
543
|
| `DSH_MCP_RUNS_DIR` | `<状态文件同目录>/runs` | 异步完成哨兵的根目录(`<此目录>/<会话id>/<run_id>.json`) |
|
|
544
544
|
| `DSH_MCP_LOCKS_DIR` | `<状态文件同目录>/locks` | 写锁"持有者登记"的目录。⚠️ **多个实例必须指向同一个值**,否则彼此看不见对方的持锁登记、会把对方误判成"无法识别的持有者(GUI)"而拒绝抢占 |
|
|
545
|
+
| `DSH_MCP_APPROVAL` | `auto-allow`(非只读档) | 默认审批策略。设成 `ask` 才能让越界请求挂起给 `dsh_approval_decide`,而不是自动放行 |
|
|
546
|
+
| `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | 待决 `ask` 审批的等待上限;超时按拒绝处理 |
|
|
547
|
+
| `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | `dsh_release(forget=true)` 写的墓碑目录 |
|
|
545
548
|
| `DSH_MCP_LOCK_STALE_MS` | `90000` | 持锁登记多久没续心跳就算"持有者失联"(此时可被 `dsh_takeover` 自动抢占) |
|
|
546
549
|
| `DSH_MCP_IDLE_TTL_MS` | `0`(永不回收) | 会话空闲多久回收其 DSH 进程。★★ **`0` = 永不回收(默认)**:本服务一直握着写锁,**GUI 抢不走**(你在 GUI 里点开是**只读**,照样能看 ✓)。代价:每会话常驻一个 DSH 进程(**两次冷启动实测 476MB / 295MB**)。设成毫秒数(如 `300000`)可省内存,但要接受"锁可能被 GUI 抢走" |
|
|
547
|
-
| `DSH_MCP_MAX_LIVE` | `8`(最坏约 2.5–4GB,见第 8 条) | ★ **最多同时保活几个会话进程**(`0` = 不限)。超标时回收**最久没用过**的(**在跑的回合绝不回收**)—— "**内存有界,锁尽量在手**"。内存紧用 `4`,机器大可以 `0
|
|
550
|
+
| `DSH_MCP_MAX_LIVE` | `8`(最坏约 2.5–4GB,见第 8 条) | ★ **最多同时保活几个会话进程**(`0` = 不限)。超标时回收**最久没用过**的(**在跑的回合绝不回收**)—— "**内存有界,锁尽量在手**"。内存紧用 `4`,机器大可以 `0`。用完想立刻放掉空闲会话:`dsh_release(all=true)`(busy 回合默认跳过,`force=true` 才中断) |
|
|
548
551
|
| `DSH_MCP_LIST_PROBE_TTL_MS` | `10000` | `dsh_list` 磁盘探测结果的缓存时长(探测要 spawn 一个 DSH 进程,实测约 1 秒) |
|
|
549
552
|
| `DSH_MCP_SENTINEL_TTL_MS` | `604800000`(7 天) | 启动时清理超过此时长的哨兵;`0` 关闭清理 |
|
|
550
553
|
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | 未设置 | `1` 会把 `result.thinking` 一并写进哨兵(**打破"思考不落盘"的保证**) |
|
|
551
554
|
| `DSH_MCP_PERMISSION` | `danger-full-access` | `dsh_start` 未指定时的权限档 |
|
|
552
555
|
| `DSH_MCP_REASONING_EFFORT` | `max` | 默认思考深度 |
|
|
553
556
|
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0`(不设超时) | **仅 `wait=true` 时**的等待上限;到期只转后台,不取消回合 |
|
|
554
|
-
| `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | 待决审批的等待上限 |
|
|
555
557
|
| `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
|
|
558
|
+
| `DSH_MCP_GIT_STATUS` | 开 | `0` 关闭每回合的 `git status` 摘要。分析不可信仓库时建议关掉(git 可能触发仓库配置的 fsmonitor/filter,且发生在 DSH 沙箱之外) |
|
|
559
|
+
| `DSH_MCP_BACKUP_KEEP` | `20` | 每个客户端配置最多保留多少份 `.bak-dsh-*`(尽力轮转) |
|
|
556
560
|
| `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | ACP 通道单请求超时(initialize、session/new 等) |
|
|
557
561
|
| `DSH_MCP_REAP_INTERVAL_MS` | `30000` | 空闲回收器多久检查一次。低于安全下限的值会回落到默认值 |
|
|
558
562
|
| `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
|
|
@@ -561,12 +565,12 @@ node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # 删测试会话
|
|
|
561
565
|
## 🧪 测试
|
|
562
566
|
|
|
563
567
|
```bash
|
|
564
|
-
node test/run.mjs #
|
|
565
|
-
node test/run.mjs --all #
|
|
566
|
-
node test/cleanup.mjs #
|
|
568
|
+
node test/run.mjs # 全部离线套件(不消耗 token;smoke 段需要装 DSH)
|
|
569
|
+
node test/run.mjs --all # 离线 + 需要模型的套件
|
|
570
|
+
node test/cleanup.mjs # 单独跑收尾清理(只清测试自己造的目录;--dry-run 可预演)
|
|
567
571
|
```
|
|
568
572
|
|
|
569
|
-
测试套件**会自己收尾**:`run.mjs` 结束时必定执行 `cleanup.mjs
|
|
573
|
+
测试套件**会自己收尾**:`run.mjs` 结束时必定执行 `cleanup.mjs`,它只清理 cwd 在**测试自己造的目录**(`tmpdir()/dsh-any-agent-*` 或桌面 `dsh-any-agent-test-*`)里的会话与工作区登记。**用户在 `%TEMP%` 里自己的真实项目、以及你的状态目录都不会被碰**。要连桌面测试目录一起清,可显式跑 `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions`。
|
|
570
574
|
|
|
571
575
|
| 套件 | 检查数 | 覆盖 |
|
|
572
576
|
|---|---|---|
|
|
@@ -580,12 +584,20 @@ node test/cleanup.mjs # 单独跑收尾清理(只针对临时目录;--d
|
|
|
580
584
|
| `sandbox-guard` | 18 | **测试沙箱自身**:「临时 HOME」如果落到真实用户目录、它的子目录、盘符根目录,或 OS 临时目录之外,一律报错拒绝,绝不被写入和删除。其余套件都通过 `test/sandbox-home.mjs` 拿目录、只销毁自己建的那个(不花 token,每次必跑) |
|
|
581
585
|
| `approval-details` | 27 | **审批审查上下文**:待决审批的快照带命令、理由、cwd 与原始审批 ID —— 不只是一个裸 ID,调用方才能真正裁决;缺失的 `rawInput` 会按 ID 从先前的 `tool_call` 更新关联回来;凭据形状的字段与常见内联字面量在结构化上下文和展示标题里都会脱敏;关闭会话会取消待决审批。经真实 stdio MCP 端到端验证 |
|
|
582
586
|
| `read-cursor` | 10 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
|
|
583
|
-
| `sentinel-redaction` |
|
|
587
|
+
| `sentinel-redaction` | 15 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
|
|
584
588
|
| `forget-tombstone` | 7 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
|
|
585
|
-
| `
|
|
589
|
+
| `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
|
|
590
|
+
| `workspace-safety` | 23 | **数据丢失边界**:`isTempPath` 按路径分段(`tmpdir()+'Suffix'`、`/tmpfoo` 都不算临时目录)、坏 `workspace.json` 绝不覆盖、会话根不可读时绝不清空 `sessionIds`、自动清理只删测试造的会话(不花 token) |
|
|
591
|
+
| `permission-adopt` | 13 | **不无声提权**:接管没有权限记录的外部会话时 fail-closed 到 `read-only`;记录为 `read-only` 的会话不能被侧车提升;运行期回读记录与请求不一致会失败(不花 token) |
|
|
592
|
+
| `state-safety` | 21 | **状态/哨兵/锁目录**:默认在 `$DSH_HOME/.dsh-any-agent`、不可写时启动明确失败、哨兵写失败返回 `null` + `sentinel_error`、非法数值落安全侧(不花 token) |
|
|
593
|
+
| `setup` | 41 | **注册 CLI**:预览不写盘、`--remove` 只摘自己那条、没装客户端算跳过不算失败、Codex TOML 段往返、以及把写进配置里的命令真的拉起一次并要求应答 `initialize`(不花 token)。**不会调用 `claude mcp get`** —— 那个 CLI 会整体回写 `~/.claude.json` |
|
|
586
594
|
| `removal-scope` | 24 | **注销只动自己那条**:给一份装着别的 MCP 服务器、顶层 Codex 设置和 Zed 兄弟设置的配置,注册再注销,断言其余每个值逐字节不变;删一个从没写过的东西 = 无写入、无报错(不花 token) |
|
|
587
|
-
| `config-safety` |
|
|
588
|
-
| `real-configs` | 66
|
|
595
|
+
| `config-safety` | 406 | **0.3 这条线的核心性质**,逐客户端验证:预览后文件逐字节不变且不产生 `.bak`;注册再注销后其余条目逐字节还原、键顺序不变、顶层键不丢;4 空格 / 2 空格缩进、单行 JSON、无末尾换行、CRLF、UTF-8 BOM 都能在写入后保留;坏 JSON 与只读文件被拒绝且原文件无损;第二次 `--yes` 空跑不新增备份;每个 `.bak-dsh-<时间戳>` 只出现一次且内容等于写入前字节;PATH 上有 `claude` 也绝不调用(不花 token) |
|
|
596
|
+
| `real-configs` | 84(7/9 配置时 66,本机 5/9 时 48) | **拿真家伙做往返**:把本机**真实**的客户端配置复制进临时目录(18 万字节、含几百个 `projects` 条目与大小写重复键的 `~/.claude.json`,238 行的 Codex TOML,Zed 的完整 `settings.json`),注册再撤销,要求每个文件**逐字节**还原;条目是否存在按**键**判断而不是裸子串;缺少 fixture 时明确打印 `skipped`,绝不假绿(不花 token) |
|
|
597
|
+
| `commit-script` | 26 | 提交信息走 stdin,绝不落临时文件让 `git add -A` 卷进发布提交(不花 token) |
|
|
598
|
+
| `roots` | 12 | 不传 `cwd` 时通过 MCP roots 取客户端当前项目;不支持 roots 的客户端得到可操作的报错(不花 token) |
|
|
599
|
+
| `workspace-titles` | 15 | 家目录当工作区会被提前拦住;`title_hint` 与 DSH 自动标题分开返回(不花 token) |
|
|
600
|
+
| `package-check` | 14 | `files` 白名单、零运行时依赖、Node 下限、bin shebang(不花 token) |
|
|
589
601
|
| `timeout` | 38 | **超时语义**:`wait=false` 不受 `timeout_ms` 影响、`wait=true` 到期只转后台(不取消/不丢结果、`busy` 不说谎)、`timeout_ms<=0` 一直等、resume 失败会作废进程而不永久卡死、空 prompt 给清晰错误 |
|
|
590
602
|
| `lock` | 79 | **写锁与抢占**:四类持有者判定、`writeMarker` 不覆盖活着的持有者、登记文件损坏/目录缺失等边界;**端到端**两实例抢锁 → 清晰报错 → 拒绝 → `force` 夺锁;**崩溃自动释放锁**、**卡死持有者无 force 自动抢占**;以及"一个回合 = 一个哨兵"的不变量(含内联回合与空闲插话) |
|
|
591
603
|
| `cycle` | 36 | **异步派活的生命周期**:异步派活 → 收哨兵 → 空闲回收 → 再派活,连做 3 轮必须全程无锁错误;贴着回收窗口立刻重派(放大竞态);`dsh_release` 后立刻重派;并断言回收后**不留下无法归因的锁**(这条实测抓到过"刚拉起的进程被 reaper 当空闲收掉"的竞态) |
|
|
@@ -596,14 +608,15 @@ node test/cleanup.mjs # 单独跑收尾清理(只针对临时目录;--d
|
|
|
596
608
|
| `multi` | 19 | **多实例共存 + 非 ASCII 路径**:复现"两实例共写注册表 → 后写的把先建的会话抹掉",并验证**仅凭 id 就能从会话存储找回并真的派活**(指注册表丢失,不是显式 `forget` —— forget 是终态墓碑);**兜底取回不会悄悄提权**(read-only 会话取回后仍是 read-only);不留 `.tmp` 残渣;**中文 / emoji 工作区路径**能建会话、能干活、文件落在正确目录 |
|
|
597
609
|
| `permission` | 11 | ★ **权限档是否真的生效**(安全属性):不看我们的返回值(那正是会说谎的地方),而是读**会话自己记录的事实**(投影缓存里的 `permissions.preset` / `sandboxMode`),三档逐一核对且三档记录值互不相同。**这条测试抓到过无声提权**:profile 里 `defaultPreset` 会在会话创建时盖掉 `DSH_PERMISSION_MODE` |
|
|
598
610
|
| `list` | 13 | **`dsh_list` 的开销**(实测驱动):含磁盘探测的默认调用约 1 秒(要 spawn 一个 DSH 进程),**连续调用命中缓存降到个位数毫秒**;`only_running=true` 与 `include_closed=false` **跳过探测**且语义等价(不把磁盘上的未打开会话算进来) |
|
|
611
|
+
| `reasoning-check` | 8 | **思考可见性**:`reasoning=hide` 不返回思考正文但统计非零;`reasoning=full` 返回正文且渲染出思考段 |
|
|
599
612
|
|
|
600
|
-
|
|
613
|
+
**`node test/run.mjs` 在本机 5/9 客户端配置时跑 815 项(九种齐全时 851 项)** —— smoke 29 + 离线套件 786 项(九种齐全时 822 项)。0.3.6 上全部退出码 0;都不需要 provider(smoke 的建会话段需要装 DSH)。
|
|
601
614
|
|
|
602
|
-
其中 **
|
|
615
|
+
其中 **519 项属于客户端注册器套件**(`setup` 41、`removal-scope` 24、`config-safety` 406、`real-configs` 48;九种齐全时 555 项)—— 0.3 的活其实都在这里。
|
|
603
616
|
|
|
604
|
-
需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13)合计
|
|
617
|
+
需要模型的套件(`smoke` 29、`boundary` 46、`integration` 30、`async` 16、`sentinel` 36、`timeout` 38、`lock` 79、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`multi` 19、`permission` 11、`list` 13、`reasoning-check` 8)合计 474 项,**最近一次完整实测在 0.3.6** —— 它们要可用 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**。
|
|
605
618
|
|
|
606
|
-
>
|
|
619
|
+
> 三个总数是刻意分开的:`node test/run.mjs`(815/851)、CI 离线 job(786/822,不含 smoke)、需要模型的集合(名义 474)。数字取各套件自己打印的汇总,不在这里手抄;不要把一个数字套到另一个 runner 上。
|
|
607
620
|
|
|
608
621
|
### 🧯 为什么会有 `sandbox-guard`
|
|
609
622
|
|
|
@@ -668,7 +681,7 @@ DSH 的会话有**跨进程写锁**:**持有者活着就永不过期,也没
|
|
|
668
681
|
5. **首次成功回合之前就死掉的会话可能尚未落盘** —— 此时 resume 会报清晰错误。发出第一条消息后就安全了。
|
|
669
682
|
6. **不能改名** —— DSH 的标题子系统没有对外改名接口(`SessionTitleService.rename` 需要进程内的活会话)。标题一律由首条消息自动生成。
|
|
670
683
|
7. **`session/list` 只返回 `{sessionId, cwd}`,且排除已打开的会话** —— 对话名由本服务从 DSH 投影缓存补全。
|
|
671
|
-
8. **默认"永不回收"要花内存(实测 300–480MB/会话)** —— 换来的是"GUI 抢不走写锁"。两次冷启动实测单个活跃会话新增常驻内存分别为 476MB 和 295MB(含 DSH 子进程与本服务),冷进程首次偏高。两种省内存办法:① **`DSH_MCP_MAX_LIVE`(默认 8**,超标就回收最久没用过的 → 内存有界 ✓ 锁仍尽量在手 ✓);② 把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价是锁可能被 GUI 拿走)。一批活干完想立刻归零:**`dsh_release(all=true)`**
|
|
684
|
+
8. **默认"永不回收"要花内存(实测 300–480MB/会话)** —— 换来的是"GUI 抢不走写锁"。两次冷启动实测单个活跃会话新增常驻内存分别为 476MB 和 295MB(含 DSH 子进程与本服务),冷进程首次偏高。两种省内存办法:① **`DSH_MCP_MAX_LIVE`(默认 8**,超标就回收最久没用过的 → 内存有界 ✓ 锁仍尽量在手 ✓);② 把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价是锁可能被 GUI 拿走)。一批活干完想立刻归零:**`dsh_release(all=true)`** ✓(默认只释放空闲会话,**正在跑的回合会跳过、继续跑**;要连它们一起停掉传 `force=true`。之后照样 resume)。
|
|
672
685
|
|
|
673
686
|
## ❓ 为什么 profile 还叫 `dsh-mcp`?
|
|
674
687
|
|
|
@@ -695,8 +708,7 @@ npm version patch --no-git-tag-version
|
|
|
695
708
|
# 然后手工改 CHANGELOG.md:把 [Unreleased] 变成新版本一节
|
|
696
709
|
|
|
697
710
|
# 2. 本地跑一遍离线套件(和 CI 相同,不花 token、不需要 DSH)
|
|
698
|
-
node test/
|
|
699
|
-
node test/approval-details.mjs && node test/setup.mjs
|
|
711
|
+
node test/run.mjs
|
|
700
712
|
node test/package-check.mjs
|
|
701
713
|
|
|
702
714
|
# 3. 确认 tarball 本身:打得出来、装得上、起得来
|
package/USAGE-MIGRATION.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
| 项目 | 要求 | 检查命令 |
|
|
28
28
|
|---|---|---|
|
|
29
|
-
| Node.js | **≥
|
|
29
|
+
| Node.js | **≥ 22.15**(`zstdDecompressSync` 的下限) | `node -v` |
|
|
30
30
|
| DSH | 已安装且**能正常聊天** | 打开 DSH,随便发一句 |
|
|
31
31
|
| provider 与凭据 | 已配好,写进 `~/.dsh/.credentials.yaml` 或环境变量 | 见下 |
|
|
32
32
|
|
|
@@ -91,27 +91,27 @@ npm install -g dsh-any-agent
|
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
93
|
# 先看它会改什么(默认不写任何文件)
|
|
94
|
-
dsh-any-agent
|
|
94
|
+
dsh-any-agent-setup
|
|
95
95
|
|
|
96
96
|
# 确认后真正写入
|
|
97
|
-
dsh-any-agent
|
|
97
|
+
dsh-any-agent-setup --yes
|
|
98
98
|
|
|
99
99
|
# 只装某几个 / 换权限档
|
|
100
|
-
dsh-any-agent
|
|
101
|
-
dsh-any-agent
|
|
100
|
+
dsh-any-agent-setup --yes --only cursor,zed
|
|
101
|
+
dsh-any-agent-setup --yes --permission read-only
|
|
102
102
|
|
|
103
103
|
# 反悔了,全部移除
|
|
104
|
-
dsh-any-agent
|
|
104
|
+
dsh-any-agent-setup --remove --yes
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
支持的客户端:**Claude Code、Codex、Cursor、Windsurf、Zed**。没装的那些会被跳过,不算失败。
|
|
107
|
+
支持的客户端:**Claude Code、Claude Desktop、Codex、Cursor、Windsurf、Zed、Gemini CLI、opencode、VS Code**。没装的那些会被跳过,不算失败。
|
|
108
108
|
|
|
109
109
|
它做的几件保证安全的事:
|
|
110
110
|
|
|
111
111
|
- **默认 dry-run**,加 `--yes` 才写
|
|
112
112
|
- 每个文件写前备份成 `<file>.bak-dsh-<时间戳>`
|
|
113
|
-
- JSON
|
|
114
|
-
-
|
|
113
|
+
- JSON/JSONC/TOML 全部**文本级定点编辑**:只改自己那一条,保留缩进、键序、CRLF、BOM 与注释;坏文件直接拒绝
|
|
114
|
+
- **不调用任何客户端 CLI**(`claude mcp get` 会整体回写 `~/.claude.json`,已移除该探针)
|
|
115
115
|
- **幂等**:重复运行只会更新变化的部分
|
|
116
116
|
|
|
117
117
|
> 用 npm 全局安装时命令叫 `dsh-any-agent-setup`(或 `npx dsh-any-agent-setup`)。
|
package/USAGE-NOTES.en.md
CHANGED
|
@@ -44,19 +44,19 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
44
44
|
|
|
45
45
|
**3.1 One turn per conversation at a time.** ACP rejects concurrent prompts: `a prompt is already in flight for this session`. To redirect, use `dsh_interject` (`interject` = stop and redirect, `queue` = speak after the current turn); to stop, `dsh_interrupt`.
|
|
46
46
|
|
|
47
|
-
**3.2 Idle processes are reaped
|
|
47
|
+
**3.2 Idle processes are never reaped by default.** `DSH_MCP_IDLE_TTL_MS=0` is the default, so the MCP keeps holding the write lock (and the GUI cannot take it). Set a millisecond value to enable reaping. `dsh_status` showing `detached` just means no process is attached — **this is not an error**; the conversation lives on disk and the next call resumes it transparently.
|
|
48
48
|
|
|
49
49
|
**3.3 Don't open the same conversation in two places.** DSH session directories carry a cross-process write lock that never expires while the holder lives. `dsh_release` hands it back so you can open it in your own DSH GUI; conversely, a conversation open in your GUI cannot be written by this server.
|
|
50
50
|
|
|
51
51
|
**3.4 Session ids are server-assigned.** Never invent one; take ids from `dsh_start` / `dsh_list`.
|
|
52
52
|
|
|
53
|
-
**3.5 ★ The write lock: one writer per conversation at a time.** A session suddenly stops responding with a vague error (`Internal error`, or "this session is in use") and re-dispatching doesn't help. Cause: a DSH session directory carries a **cross-process write lock** whose semantics are "**the holder keeps it while it lives**", with **no API to take it from a live holder**. This server keeps a DSH process alive per conversation, so the two sides are **mutually exclusive**: while we hold a conversation your own DSH GUI cannot open it, and while your GUI has it open our `session/resume` cannot acquire it. The error is opaque because `SessionAlreadyOwnedError` is not a standard JSON-RPC error — ACP wraps it as a generic `-32603 "Internal error"` and hides the real cause in `error.data` (this server now surfaces and translates it). Fix: read `lock_holder` from `dsh_status` — `stale-mcp` (
|
|
53
|
+
**3.5 ★ The write lock: one writer per conversation at a time.** A session suddenly stops responding with a vague error (`Internal error`, or "this session is in use") and re-dispatching doesn't help. Cause: a DSH session directory carries a **cross-process write lock** whose semantics are "**the holder keeps it while it lives**", with **no API to take it from a live holder**. This server keeps a DSH process alive per conversation, so the two sides are **mutually exclusive**: while we hold a conversation your own DSH GUI cannot open it, and while your GUI has it open our `session/resume` cannot acquire it. The error is opaque because `SessionAlreadyOwnedError` is not a standard JSON-RPC error — ACP wraps it as a generic `-32603 "Internal error"` and hides the real cause in `error.data` (this server now surfaces and translates it). Fix: read `lock_holder` from `dsh_status` — `stale-mcp` (alive but wedged: its heartbeat expired; a real crash releases the lock automatically) → `dsh_takeover` takes it without `force` and kills that process; `live-mcp` (another live dsh-any-agent instance) → `dsh_release` on that side, or `dsh_takeover(force=true)`; `none` (most likely your GUI has it open) → **never killed by us** — close it in that window and retry.
|
|
54
54
|
|
|
55
55
|
**3.6 ★ "Taking the lock" means killing the holder, so we must know who that is.** The lock is a Windows named kernel semaphore, released by the kernel when the holder process dies — killing is the *only* way to take it. So this server registers a conversation **only once it actually holds it** (`<state dir>/locks/<conversation id>.json`, with pid + heartbeat). Holders it cannot identify are **never killed**. Why the GUI is untouchable: GUI conversations run inside the single **`dsh web` process** (not one process per conversation), so killing the lock holder would take down your entire UI *and every GUI conversation in that process, including the one you're reading*. Sessions on disk survive resumption, but in-flight turns are lost. **Operational rule: never open a conversation this server drives in your DSH GUI.** To view it there, `dsh_release` first and dispatch again afterwards (it resumes automatically).
|
|
56
56
|
|
|
57
57
|
**3.7 ★ Crash vs. wedge — measured, don't guess.** A **crash** (the MCP is killed or restarted): the DSH children it spawned **exit with it** — their stdio is a pipe to the parent, so closing it gives them EOF and they shut down. **The lock is therefore released automatically; no orphan keeps holding it**, so a restart always gets it back. The cost: **in-flight turns are interrupted and no sentinel lands** (the process is gone) — don't wait for that file, inspect the workspace (`git status`, files) and decide whether to re-dispatch. A **wedge** (the MCP is alive but stalled; no heartbeat for 90 s) is what `stale-mcp` really means, and `dsh_takeover` handles it **without `force`**. Judge with `dsh_status`'s `lock_holder` — and note that `none` does not mean "the lock is free"; it can also mean "a process we don't manage holds it" (usually the GUI).
|
|
58
58
|
|
|
59
|
-
**3.8 ★ The async dispatch lifecycle ("dispatch, wait for the task, come back later").** Dispatching asynchronously never leaves a half-held lock: the lock follows the **process** — alive means held, exited means released. Idle reaping is safe: past `DSH_MCP_IDLE_TTL_MS`
|
|
59
|
+
**3.8 ★ The async dispatch lifecycle ("dispatch, wait for the task, come back later").** Dispatching asynchronously never leaves a half-held lock: the lock follows the **process** — alive means held, exited means released. Idle reaping is safe when enabled: past `DSH_MCP_IDLE_TTL_MS` the process is reaped, the lock is released, and the next dispatch resumes it automatically. The default is `0` (never reap), so this only happens if you set a value. Reaping **confirms the process really exited before deleting the registration** (otherwise the lock becomes anonymously held and even `dsh_takeover` refuses). Coming back later has three outcomes: the process is still alive → you continue; it was reaped or you released it → it is rebuilt and resumed automatically; **someone else holds the lock** → you get a "write lock is held" error that names the holder (see 3.5). The only case that loses work is a **crash or restart of the MCP server itself**: its children exit with it, in-flight turns are interrupted and **no sentinel lands** — inspect the workspace instead of waiting for that file.
|
|
60
60
|
|
|
61
61
|
**3.9 ★★ The silent privilege-escalation trap (the most dangerous pitfall here).** Symptom: `dsh_start(permission: 'read-only')` **returns** `"read-only"` while the session actually runs with **full access** — you think you are analysing a sample in a sandbox and you are not. Cause: if the profile sets `defaultPreset`, `dsh-permission-presets` applies it **at session creation** to the sandbox mode and approval policy, **overriding** the mode `dsh-base` derived from `DSH_PERMISSION_MODE`; pinning `danger-full-access` therefore escalates every session. Correct form: **define the presets but do not write `defaultPreset`**. The plugin then falls back to its inferred default (matching a preset by the sandbox + approval pair), and `dsh-base`'s rule — `mode = DSH_PERMISSION_MODE ?? 'workspace-write'`, `policy = (mode === 'danger-full-access') ? 'never' : 'ask'` — lines up with the three tiers exactly, so **each session's real tier is decided by the env passed at spawn time**. `profile-example/cordis.patch.yml` already does it this way. How to verify: **do not trust the tool's return value** (that is exactly what used to lie) — read **the session's own record** (`permissions.preset` / `sandboxMode` in the projection cache). `test/permission.mjs` does precisely that, checking all three tiers and that their recorded values differ. One more caveat: the tier is fixed at **`dsh_start`**; DSH is designed so that **a resumed session keeps its recorded permission**, so changing the `permission` argument later will not alter an existing conversation — create a new one to change tiers.
|
|
62
62
|
|
|
@@ -64,7 +64,7 @@ Practical gotchas and trade-offs you *will* hit when letting Claude / Codex driv
|
|
|
64
64
|
|
|
65
65
|
**3.11 ★★ Opening it once hands the conversation to the GUI permanently (measured; harsher than 3.10).** Measured: you open the conversation in the web UI (the GUI takes the lock), then **switch away to another conversation**, then we probe again — **the lock is still held**. So `dsh web` does **not** release it when you navigate away: **once opened, that conversation belongs to the GUI for the entire lifetime of the web process**, and the MCP cannot touch it again until you **restart `dsh web`**. That is why it is "always": it is not taken on every peek, it is taken once and stays taken — so from then on every retry fails until the GUI restarts. Practical advice: (1) never open a conversation the MCP drives, not even once — seeing its title in the list is fine; (2) to read content, have the agent call `dsh_read` and paste the output to you; (3) if you already opened it, **restart `dsh web`** — switching away in the UI does nothing.
|
|
66
66
|
|
|
67
|
-
**3.12 ★★ The lossless answer: keep the MCP holding the lock (`DSH_MCP_IDLE_TTL_MS=0`).** The full causal chain, all measured: a task finishes, the conversation sits idle
|
|
67
|
+
**3.12 ★★ The lossless answer: keep the MCP holding the lock (`DSH_MCP_IDLE_TTL_MS=0`).** The full causal chain, all measured: with a non-zero TTL, a task finishes, the conversation sits idle past that TTL, **idle reaping kills the child and frees the write lock**, you happen to open that conversation in the web UI right then, **the GUI takes the lock and never returns it** (navigating away ✗, waiting ✗, **archiving ✗** — verified that the archive really landed), and from then on every MCP resume fails. That is "the caller can never get back in after a task". The fix: **`DSH_MCP_IDLE_TTL_MS=0` is now the default** (never reap), so the MCP keeps holding the write lock. Then opening it in the GUI **opens it read-only and you can still read it** (measured: the turns and answers are visible), and probing from a separate instance confirms **the holder is still this server's own PID with a fresh heartbeat** — the lock was never taken. **Neither side is harmed**: you keep visibility, while the MCP's turns keep running and later calls reconnect normally. Zero data loss, zero duplication, zero extra disk. The cost, measured: **300–480 MB per live conversation** (two cold starts of a single active conversation added 476 MB and 295 MB; roughly linear, so ten conversations reach 3–5 GB). It will not blow up, but it is real. Three ways to control it: ★ **on by default — `DSH_MCP_MAX_LIVE=8`**: over the cap, the **least recently used** process is reaped (a running turn is never reaped), giving "**bounded memory, locks kept where it matters**" ✓; **after a batch of work — `dsh_release(all=true)`** frees every **idle** conversation, dropping memory ✓ and it is lossless (the logs are on disk, everything resumes on demand ✓); busy turns are skipped by default — pass `force=true` to interrupt them too; or set `DSH_MCP_IDLE_TTL_MS` to a millisecond value (at the cost of the lock being takeable ✗). In one line: reaping saves memory but hands the lock away; not reaping spends memory to buy "never silently locked out". Your data is never at risk either way: a GUI-pinned conversation is intact, and the MCP resumes it normally once `dsh web` restarts — what you lose is only immediate usability, not content.
|
|
68
68
|
|
|
69
69
|
## 4. Permissions and safety
|
|
70
70
|
|
package/USAGE-NOTES.md
CHANGED
|
@@ -83,9 +83,9 @@
|
|
|
83
83
|
- **原因**:ACP **明确拒绝并发 prompt**:`a prompt is already in flight for this session`。
|
|
84
84
|
- **对策**:改口用 `dsh_interject`(`interject` 打断改口 / `queue` 排队接着说),或先 `dsh_interrupt`。
|
|
85
85
|
|
|
86
|
-
### 3.2
|
|
87
|
-
- **现象**:`dsh_status` 显示 `detached
|
|
88
|
-
-
|
|
86
|
+
### 3.2 默认**不回收**空闲进程;显式设置 TTL 才回收
|
|
87
|
+
- **现象**:`dsh_status` 显示 `detached`(没有附着进程)。
|
|
88
|
+
- **原因**:默认 `DSH_MCP_IDLE_TTL_MS=0` = 永不回收,本服务一直握着写锁。显式设成毫秒数(如 `300000`)才会按 TTL 回收。**`detached` 不是错误** —— 会话本体在磁盘上,下次派活会自动 `session/resume` 复活,历史完整。
|
|
89
89
|
- **对策**:不用管。想让它一直热着就调大 `DSH_MCP_IDLE_TTL_MS`,或者定期 `dsh_status`(会刷新 `lastUsedAt` 吗?不会——只有派活算使用。所以真要常驻就调 TTL)。
|
|
90
90
|
|
|
91
91
|
### 3.3 同一个会话别在两处同时打开
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
- 你在 GUI 里开着的会话,本服务的 `session/resume` 也拿不到锁。
|
|
108
108
|
- **为什么错误看不懂**:`SessionAlreadyOwnedError` 不是 JSON-RPC 标准错误,会被 ACP 包成笼统的 `-32603 "Internal error"`,真实原因藏在 `error.data` 里(本服务现在会取出来并翻译成人话)。
|
|
109
109
|
- **对策**:看 `dsh_status` 的 `lock_holder` 字段:
|
|
110
|
-
- `stale-mcp
|
|
110
|
+
- `stale-mcp`(**活着但卡死**:心跳超过 90 秒没续;真正的崩溃会让子进程随管道关闭退出、锁自动释放)→ `dsh_takeover` **直接抢**(不需要 force),会自动杀掉那个卡死的进程;
|
|
111
111
|
- `live-mcp`(另一个活着的 dsh-any-agent 实例在用)→ 先在那边 `dsh_release`;确实要夺用 `dsh_takeover(force=true)`;
|
|
112
112
|
- `none`(多半是你自己的 GUI 开着它)→ **本服务绝不杀**,请在那个窗口里关掉该会话后重试。
|
|
113
113
|
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
|
|
126
126
|
### 3.8 ★ 异步派活的生命周期("派完 → 等 task → 回头再进")
|
|
127
127
|
- **不会**因为你异步派活就留下什么"半持锁"状态:锁跟着**进程**走 —— 进程活着就握着,进程退出就释放。
|
|
128
|
-
-
|
|
128
|
+
- **空闲回收(显式开启时)是安全的**:空闲超过 `DSH_MCP_IDLE_TTL_MS` 后回收进程 → 锁释放 → 下次派活自动 resume 复活。默认 `0` = 永不回收,所以默认不会发生。回收时**先确认进程真的退出、再删登记**(否则锁会变成"无名持有",连 `dsh_takeover` 都拒绝)。
|
|
129
129
|
- **回头再进的三种情形**:
|
|
130
130
|
- 进程还活着 → 直接续上 ✓
|
|
131
131
|
- 进程已被回收(或你 `dsh_release` 过)→ 自动重建 + resume ✓
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
3. 已经点开过了 → **重启 `dsh web`** 才能真正把锁还回来(界面上切走没用)。
|
|
163
163
|
|
|
164
164
|
### 3.12 ★★ 无损解法:让 MCP 一直握着锁(`DSH_MCP_IDLE_TTL_MS=0`)
|
|
165
|
-
-
|
|
165
|
+
- **病因链(已完整实测)**:设了非零 TTL 时,任务跑完 → 会话空闲超过 TTL → **空闲回收把子进程收掉、写锁腾出来** → 你正好在这时去 web 看进度 → **GUI 拿走锁并且永不归还**(切走 ✗ 等待 ✗ **归档也不释放** ✗,已核实归档确实生效)→ MCP 之后每一次 resume 都失败 = "任务完成后总是接不回去"。
|
|
166
166
|
- **解法**:**默认就是 `DSH_MCP_IDLE_TTL_MS=0`(永不回收)**。MCP 就一直握着写锁:
|
|
167
167
|
- 你在 GUI 里点它 → **GUI 以只读方式打开,你照样能看到内容** ✓(实测:能看、能看到回合与答复 ✓)
|
|
168
168
|
- 用独立实例探测确认:**持有者仍是本服务的 PID、心跳新鲜** → **锁没有被抢走** ✓
|
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
- **零数据损失、零重复、零磁盘开销** ✓
|
|
171
171
|
- **代价(实测内存)**:每个保活的会话约 **300–480MB**(两次冷启动实测单个活跃会话新增 476MB / 295MB;**近似线性**,10 个可达 3–5GB)✗ 不会"爆",但确实要花。三种控制手段:
|
|
172
172
|
- ★ **默认就有:`DSH_MCP_MAX_LIVE=8`** —— 超过就回收**最久没用过**的那个(**在跑的回合绝不回收**)→ "**内存有界,锁尽量在手**" ✓ 窗口内的会话写锁照握、GUI 抢不走 ✓
|
|
173
|
-
- **一批活干完**:`dsh_release(all=true)`
|
|
173
|
+
- **一批活干完**:`dsh_release(all=true)` 一次放掉**空闲**会话,内存回收 ✓ **无损**(会话日志都在磁盘上,之后照常 resume ✓);正在跑的回合默认跳过,要连它们一起停掉传 `force=true`(那会中断回合)。
|
|
174
174
|
- 或把 `DSH_MCP_IDLE_TTL_MS` 设成毫秒数(代价:锁可能被 GUI 拿走 ✗)
|
|
175
175
|
- **一句话**:回收省内存,但会把锁让出去;不让锁 = 用内存换"永远不会被静默锁死"。
|
|
176
176
|
- **数据始终无损**:被 GUI 占住的会话**本体完好**,重启 `dsh web` 后 MCP 照常 resume 它 —— 丢的只是"当下能不能接着用",不是内容。
|