dsh-any-agent 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # ⚡ Why not just wrap the DSH CLI?
4
+
5
+ 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`.
6
+
7
+ | Capability | `sdk` profile | `acp` profile (**this project**) |
8
+ |---|---|---|
9
+ | **Resume a conversation** | ❌ no resume method; reusing an id fails with `already exists` | ✅ `session/resume` — verified **across processes** |
10
+ | **Interrupt a running turn** | ❌ only by killing the process | ✅ `session/cancel` — the same path as the user's own stop button |
11
+ | **Answer approval prompts** | ❌ no channel; operations silently fail closed | ✅ `session/request_permission` round-trip |
12
+ | List / close sessions | ❌ none | ✅ `session/list`, `session/close` |
13
+ | Gray vs. black streaming | one message, split by a `type` field | ✅ two separate channels: `agent_thought_chunk` / `agent_message_chunk` |
14
+ | Per-session MCP servers | ❌ process-wide | ✅ `mcpServers` accepted by `session/new` and `session/resume` |
15
+
16
+ **Core idea: the process is a cache, not the identity.** A conversation's identity is DSH's `sessionId`, persisted under `~/.dsh/sessions`. If the process is reaped, crashes, or the whole MCP server restarts, the next call transparently `session/resume`s it — history intact.
17
+
18
+ <details>
19
+ <summary>How the clients, server and DSH connect</summary>
20
+
21
+ ```
22
+ any MCP client ← Claude Code, Codex, Cursor, Zed, Cline, your own…
23
+ (Claude/Codex/Cursor/…) │
24
+ │ MCP over stdio │ newline-delimited JSON-RPC (ACP)
25
+ ▼ ▼
26
+ ┌─────────────┐ ┌──────────────────────────┐
27
+ │dsh-any-agent│───────────▶│ dsh --profile dsh-team │ sessions with the same
28
+ └─────────────┘ │ │ (cwd, permission) share
29
+ └────────────┬─────────────┘ one process (default since 0.5.0)
30
+ │
31
+ ┌────────────┴─────────────┐
32
+ │ DSH's own tools │ files, shell, search,
33
+ │ │ skills, subagents, workflows
34
+ ├──────────────────────────┤
35
+ │ dsh-mcp-client │ → your other MCP servers
36
+ └──────────────────────────┘
37
+ ```
38
+
39
+ **Process model.** A conversation's identity is its `sessionId`; the process is a disposable carrier. Since 0.5.0, sessions with the same `(cwd, permission)` share one ACP process by default (`src/host.mjs`): DSH's ACP server accepts multiple sessions per connection, each with its own Agent, but the sandbox root and approval policy are **process-wide**, so grouping can only follow those two. The win: the second session costs ≈300 ms instead of a ≈4 s cold start, and memory is paid once per group. The price: if that one process dies, every in-flight turn in the group dies with it (the conversations themselves are intact and resume on the next call) — which is why `dsh_takeover` refuses to kill a shared process without `force`. Set `DSH_MCP_SHARED_PROCESS=0` for one process per conversation.
40
+
41
+ **MCP compatibility.** The server uses only `initialize`, `ping`, `tools/list` and `tools/call`. It has also been verified with a bare-bones client that negotiates an older protocol version and never sends `notifications/initialized`.
42
+
43
+ </details>
@@ -0,0 +1,51 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # 🧩 Compatibility matrix
4
+
5
+ `dsh-any-agent` drives DSH through its ACP profile; team mode additionally needs the
6
+ experimental Agent Teams packages shipped since DSH 0.1.7. Check this table before
7
+ upgrading DSH, and run `dsh_doctor` afterwards.
8
+
9
+ ## Versions
10
+
11
+ | dsh-any-agent | DSH | Status | Notes |
12
+ |---|---|---|---|
13
+ | 0.5.x | `0.1.7-rc.1` or newer | ✅ Verified | ACP and the experimental Agent Teams packages are available; team mode is opt-in via `dsh-any-agent team on` |
14
+ | 0.5.x | `0.1.5-rc.1` | ⚠️ Partial | ACP basics work; no team packages, so team mode stays off |
15
+ | 0.5.x | `0.2.x` | ⚠️ Unverified | The ACP `session/*` surface may change; team packages must match the DSH version. Run `dsh_doctor` and the offline suite before upgrading |
16
+ | 0.4.x | `0.1.7-rc.1` | ✅ Verified | Team mode was on by default at that time |
17
+ | 0.4.x | `0.1.5-rc.1` | ⚠️ Partial | No team packages |
18
+
19
+ > The experimental packages (`dsh-experimental-agent-team`,
20
+ > `dsh-experimental-tool-agent-team`) carry **no stability promise**. They ship with
21
+ > a DSH version and must not be mixed across major versions.
22
+
23
+ ## Runtime requirements
24
+
25
+ - **Node.js ≥ 22.15** (uses `zstdDecompressSync` from `node:zlib` to read DSH session headers);
26
+ - DSH installed and `~/.dsh` already has a working provider and credentials (the GUI can chat);
27
+ - Team mode also requires DSH `>= 0.1.7-0` and a profile that actually mounts both team packages.
28
+
29
+ ## How to check
30
+
31
+ ```bash
32
+ dsh-any-agent --version # this server
33
+ node <DSH_BIN> --version # DSH version (set DSH_BIN to pin it)
34
+ dsh-any-agent team status # team switch and profile
35
+ ```
36
+
37
+ In an MCP client, call `dsh_doctor` and look at:
38
+
39
+ - `dsh_bin`: DSH entry point and version;
40
+ - `profile`: exists, drift, route source;
41
+ - `team`: `on` / `off` / `requested but unavailable` (version or profile lacks the team package);
42
+ - `config`: effective permission, idle reaping, shared process, and so on.
43
+
44
+ ## Upgrade advice
45
+
46
+ 1. Record `dsh_doctor` before upgrading DSH;
47
+ 2. After upgrading, run `node test/run.mjs` (offline) and `node test/run.mjs --all` (needs a provider);
48
+ 3. If team mode will not turn on, check `dsh-any-agent team status` and `dsh_doctor`'s
49
+ `team` check, then verify `~/.dsh/profiles/dsh-team/cordis.patch.yml` contains
50
+ `@deepseek-ai/dsh-experimental-agent-team`;
51
+ 4. To pin a DSH version, set `DSH_BIN` explicitly instead of letting the npx cache pick one.
@@ -0,0 +1,90 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # ▶️ Five minutes: dispatch → interject → collect
4
+
5
+ This page is not a feature list; it is a **path that actually runs**. When you are done you will have seen an agent finish work in the background, you will have changed requirements twice mid-flight, and you will have verified the artifacts yourself — instead of taking "done" for an answer.
6
+
7
+ ```bash
8
+ npm run demo
9
+ ```
10
+
11
+ ## What problem this solves
12
+
13
+ Your editor already has a coding agent, and it is trapped in your window: close the editor and it is gone; want to change course? Wait for the turn to end.
14
+
15
+ `dsh-any-agent` turns DSH into **a backend colleague for your other agents**: you dispatch from Claude Code / Codex / Cursor, it works in the background, and a sentinel file tells you when it is done. This demo is the smallest runnable version of that loop.
16
+
17
+ ## 0. Prerequisites
18
+
19
+ - **Node.js ≥ 22.15** (the server uses `node:zlib`'s `zstdDecompressSync` to read session headers; older versions lack that API)
20
+ - **A working DSH**: `npm i -g @deepseek-ai/dsh`, with a usable provider and credentials under `~/.dsh` (if the DSH GUI can chat normally, you are set)
21
+ - No Claude Code or Codex required — the demo is its own MCP client
22
+
23
+ ## 1. Run it
24
+
25
+ ```bash
26
+ npm run demo
27
+ ```
28
+
29
+ It creates an empty workspace in a temp directory, then asks DSH to write two files there (`fib.mjs` and `test-fib.mjs`) and run the tests. **It interjects twice along the way**:
30
+
31
+ 1. `fib(n)` must throw `RangeError` for negatives, with a matching assertion;
32
+ 2. a comment at the top of `fib.mjs` stating the time complexity.
33
+
34
+ When the turn ends the script **verifies the artifacts itself**: it checks that both files exist and runs `node --test test-fib.mjs` itself. The model's own summary does not count here.
35
+
36
+ Measured: one full run took **57.6 s** on this machine (Windows / Node 24.21.0 / default profile) — three turns, two interjections, four test cases passing. It moves with the model, which is why the claim is "five minutes" and not "one minute".
37
+
38
+ ## 2. What the six steps do, and why each one matters
39
+
40
+ | Step | Tool | Why it matters |
41
+ |---|---|---|
42
+ | 1 | `initialize` / `tools/list` | Only four standard MCP methods; any MCP-over-stdio client can connect |
43
+ | 2 | `dsh_start` | Creates a **long-lived** conversation. The identity is the on-disk `sessionId`; the process is only a cache |
44
+ | 3 | `dsh_send(wait=false)` | Returns `run_id` + `sentinel_file` immediately and runs the turn in the background — your client's own timeout cannot kill the work |
45
+ | 4 | `dsh_interject` ×2 | Change course mid-turn. It is "cancel + immediately start a new turn"; **work already done is not lost** |
46
+ | 5 | read the sentinels | **One turn, one sentinel**: the interrupted turn writes `cancelled`, the last turn writes `done`. The file lands atomically and survives a service restart |
47
+ | 6 | `dsh_get` + own verification | `run_id` is the receipt; but the verdict comes from what is on disk, not from what the model says |
48
+
49
+ ## 3. Three design points that are easy to miss
50
+
51
+ **Why doesn't step 3 wait?** MCP tool calls block, and client response timeouts are usually a few minutes. `wait=false` makes "dispatch" instant and closes the loop with a **sentinel file** — that is the precondition for handing over multi-hour work.
52
+
53
+ **`dsh_interject` blocks until the turn it started has finished.** It is not a fire-and-forget notification: the elapsed time you see in step 4 includes a whole model turn. That is usually what you want (returning means the redirection took effect), but give it room — use `timeout_ms` to bound the wait; if it never reaches idle the call fails and this interjection lands as `cancelled` (it does **not** sit in a queue and run later).
54
+
55
+ **Why doesn't an interjection destroy finished work?** ACP explicitly rejects a concurrent prompt (`a prompt is already in flight`), so `dsh_interject` cancels the current turn and starts a new one with the full history. It is a practical equivalent, not true mid-turn steering — see [known limits](limits.md) item 2.
56
+
57
+ **Why does the demo use `workspace-write` instead of the default tier?** So the sandbox actually does something: the task should only touch two files inside its workspace. `read-only` cannot start a shell at all, and `danger-full-access` is a poor default example.
58
+
59
+ ## 4. A different task / a zero-cost preview
60
+
61
+ ```bash
62
+ # your own task (the built-in interjections are skipped)
63
+ npm run demo -- --prompt "translate the README in this directory into English and check the Markdown"
64
+
65
+ # replay the last recorded run: no DSH, no tokens, a few seconds
66
+ npm run demo -- --replay
67
+
68
+ # keep the temp workspace so you can inspect the result
69
+ npm run demo -- --keep
70
+ ```
71
+
72
+ To record a replayable transcript:
73
+
74
+ ```bash
75
+ npm run demo -- --save # writes demo/transcript.json on success
76
+ ```
77
+
78
+ ## 5. Isolation: it does not touch your daily state
79
+
80
+ The registry, sentinels and write-lock markers all live in a temp directory that is deleted at the end. Running the demo **cannot pollute the conversation list you actually use from Claude Code**.
81
+
82
+ Only one thing stays in `~/.dsh`: DSH's own session log (credentials and providers live there, so it cannot be isolated). The script calls `dsh_release(forget=true)` at the end to remove the conversation from the registry.
83
+
84
+ ## 6. What it proves, and what it does not
85
+
86
+ **Proves**: the MCP handshake works, a conversation can be created, background dispatch delivers a sentinel, an interjection redirects the task without breaking it, and the artifacts land on disk with tests that really pass.
87
+
88
+ **Does not prove**: that your model will behave as expected (models differ a lot), that a job is still healthy hours later (the demo runs for minutes), or how it behaves with many concurrent conversations — [the tests](tests.md) cover those separately.
89
+
90
+ > Be aware: **the demo really calls a model and really spends tokens**. To see the flow before deciding to run it, start with `--replay`.
@@ -0,0 +1,257 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # 📦 Requirements
4
+
5
+ - **Node.js ≥ 22.15** — the server uses `zstdDecompressSync` from `node:zlib` to read DSH session headers, and that API does not exist before 22.15
6
+ - **DSH installed** (`@deepseek-ai/dsh`), with working credentials under `~/.dsh`
7
+ - A working provider/model route in your local DSH configuration. The server prepares its `dsh-mcp` ACP profile automatically from that route; no provider config or credentials are bundled.
8
+
9
+ ## 🚀 Setup
10
+
11
+ ### 1. Prepare the DSH profile
12
+
13
+ On first launch, the server prepares a `dsh-mcp` profile from the shipped ACP template and copies the model route from your existing DSH configuration. Credentials stay with DSH. Use `DSH_MCP_ROUTE_SOURCE` to choose a source profile, or `DSH_MCP_BOOTSTRAP=0` to manage the profile yourself. See [Environment variables](reference.md#-environment-variables).
14
+
15
+ <details>
16
+ <summary>Manual profile setup and ACP configuration details</summary>
17
+
18
+ ```bash
19
+ dsh --profile dsh-mcp --from-default-profile acp
20
+ ```
21
+
22
+ Then edit `~/.dsh/profiles/dsh-mcp/cordis.patch.yml`, remove its `# dsh-mcp:managed` marker if present, and declare your provider and model. Start from [`profile-example/cordis.patch.yml`](../../profile-example/cordis.patch.yml).
23
+
24
+ > ⚠️ **Two traps here** (both learned the hard way):
25
+ >
26
+ > 1. **ACP's model does NOT come from `agent-default-model`.** It comes from the `dsh-acp` plugin's own `config.provider` / `config.model`, which the `acp` bundle hard-codes to `deepseek-official`. If you don't override the `acp` row, your sessions silently run on the wrong provider and fail with `no API key for provider route "deepseek-official"`.
27
+ > 2. **ACP's `reasoning_effort` defaults to empty** (= "Provider default"), not to the maximum. This server explicitly sets it to `max` on every `session/new` and after every `session/resume` — because a fresh process does not remember the previous choice.
28
+
29
+ </details>
30
+
31
+ ### 2. Register the server
32
+
33
+ Run the three commands in [Quick start](../../README.md#quick-start), then reconnect your client. For a manual configuration, expand the client examples below.
34
+
35
+ `setup` finds the MCP clients on your machine — **Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code** — and writes the entry into each one it finds, skipping the rest. It is safe by construction:
36
+
37
+ - **dry-run by default**; `--yes` is required to write anything, and a preview starts **no subprocess at all** — it only reads the files it would touch
38
+ - every file is backed up to `<file>.bak-dsh-<timestamp>` first, holding the exact **pre-write bytes**
39
+ - the config is parsed before and after writing, so a broken config never lands; malformed JSON and a read-only file are refused rather than forced, while a missing directory is created safely
40
+ - **text-level writes**: only the bytes of setup's own entry are replaced, so indentation (2-space, 4-space, tabs), key order, CRLF endings, a UTF-8 BOM and a missing trailing newline all survive untouched
41
+ - **idempotent** — re-running only changes what differs, and a second `--yes` writes nothing and adds no `.bak`
42
+
43
+ **Measured impact scope (2026-11-02, plan mode on this machine):** 5 of the 9 clients were detected (Claude Code, Codex, Cursor, Zed, VS Code) and 4 were skipped; `--json` reported `mode=plan, changed=5, skipped=4, failed=0`. The SHA-256, size and mtime of every existing client config were unchanged. A real `--yes` is additive: only the single `dsh-any-agent` entry is written, every other byte is preserved, and a `.bak-dsh-<timestamp>` copy is taken first; `--remove` deletes only that entry.
44
+
45
+ ```bash
46
+ dsh-any-agent-setup --yes --only cursor,zed # just these
47
+ dsh-any-agent-setup --yes --permission read-only
48
+ dsh-any-agent-setup --remove --yes # take it back out; only its own entry is removed
49
+ ```
50
+
51
+ <details>
52
+ <summary>Advanced flags and client configuration paths</summary>
53
+
54
+ **Three new flags**
55
+
56
+ | Flag | What it does |
57
+ |---|---|
58
+ | `--json` | **stdout carries one parseable JSON object and nothing else** — every human-readable line goes to stderr. Shape: `{ ok, mode, clients: [{ id, label, path, status, summary, error? }], changed, skipped, failed }` with `mode` being `plan`, `apply` or `remove`. This is a contract, not a convenience: a script can pipe stdout straight into a JSON parser |
59
+ | `--home <dir>` | use this directory instead of the real `HOME` / `USERPROFILE` / `APPDATA`, so a register-then-remove cycle can be rehearsed without touching a live config |
60
+ | `--force` | only two things: (a) create a directory or file that does not exist yet (it does that by default too — the flag makes it reliable for automation, including when the client itself was not detected); (b) overwrite a same-named entry that exists but is not the shape this program expects. It never deletes somebody else's entry and never skips validation |
61
+
62
+ **Which file each client actually gets**
63
+
64
+ A `.bak-dsh-<timestamp>` backup lands beside every file before it is written. Paths are generic examples; "its own entry" means the single entry/section named `dsh-any-agent`.
65
+
66
+ | Client (`--only` id) | Config file | Container key | Install / update | `--remove` | Undo |
67
+ |---|---|---|---|---|---|
68
+ | Claude Code (`claude-code`) | `~/.claude.json` | `mcpServers` | its own entry only, with `type: "stdio"` | deletes its own entry only | copy the backup back |
69
+ | Claude Desktop (`claude-desktop`) | Windows `%APPDATA%\Claude\claude_desktop_config.json`; macOS `~/Library/Application Support/Claude/claude_desktop_config.json`; Linux `~/.config/Claude/claude_desktop_config.json` | `mcpServers` | adds or rewrites its own entry only | deletes its own entry only | copy the backup back |
70
+ | Codex (`codex`) | `~/.codex/config.toml` | `[mcp_servers.dsh-any-agent]` | appends its two TOML sections line-wise | deletes those two sections and nothing else | copy the backup back |
71
+ | Cursor (`cursor`) | `~/.cursor/mcp.json` | `mcpServers` | its own entry only | its own entry only | copy the backup back |
72
+ | Windsurf (`windsurf`) | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` | its own entry only | its own entry only | copy the backup back |
73
+ | Zed (`zed`) | Windows `%APPDATA%\Zed\settings.json`; elsewhere `~/.config/zed/settings.json` | `context_servers` | its own entry only | its own entry only | copy the backup back |
74
+ | Gemini CLI (`gemini`) | `~/.gemini/settings.json` | `mcpServers` | its own entry only | its own entry only | copy the backup back |
75
+ | opencode (`opencode`) | `~/.config/opencode/opencode.json` | `mcp` | its own entry only, written in opencode's shape: `{ type: "local", command: [exe, ...args], enabled: true }` — one array, not separate `command`/`args` | its own entry only | copy the backup back |
76
+ | VS Code (`vscode`) | Windows `%APPDATA%\Code\User\mcp.json`; elsewhere the same `mcp.json` under your VS Code user directory | `servers` — not `mcpServers` | its own entry only | its own entry only | copy the backup back |
77
+
78
+ For the exact path on your machine, read the `path` field for each client in `--json` output — that is the file this run would write, on your OS, which beats any table in a README.
79
+
80
+ Three shapes worth spelling out, because getting them wrong is silent. **VS Code** stores MCP servers under `servers`, not `mcpServers`; writing the usual key there produces a config the editor ignores. **opencode** takes a single `command` array (`{ type: "local", command: [exe, ...args], enabled: true }`) rather than separate `command`/`args` fields. **Claude Code** is the reason this release does not use a JSON library at all: `~/.claude.json` really does hold a `projects` map with keys that differ only by letter case (`.../Rust` next to `.../rust`), so a naive `JSON.parse` fails on it and any whole-file rewrite would destroy the user's project list. A byte-range scanner that never builds a value object handles it, and an earlier design that shelled out to `claude mcp add` was dropped for the same reason — that command rewrites the file in its own formatting with no backup you could restore.
81
+
82
+ </details>
83
+
84
+ <details>
85
+ <summary>Registration checks and round-trip verification</summary>
86
+
87
+ **What `setup` will and will not touch**
88
+
89
+ It is additive, and it only ever touches its own entry. Verified against a real machine holding existing MCP servers (Cursor with four, Codex with two, Claude Code with five, Zed with 33 settings keys): every pre-existing entry survived **byte for byte**, key order included, and the only diff was one new entry per client.
90
+
91
+ ```
92
+ Cursor 4 -> 5 entries existing entries byte-identical
93
+ Codex 2 -> 3 sections node_repl and rea bodies unchanged
94
+ Claude Code 5 -> 6 servers all four originals still listed
95
+ Zed 33 -> 33 keys only context_servers gained a child
96
+ ```
97
+
98
+ What changed since 0.1.x: JSON configs used to be **re-serialised whole** (values preserved, layout lost), which is why the docs said whitespace was not guaranteed. Writes are text-level now — apart from its own entry, the file comes back byte-identical, whether it was indented with 4 spaces, 2 spaces or tabs, laid out as one long line, missing its trailing newline, using CRLF, or carrying a UTF-8 BOM.
99
+
100
+ 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.
101
+
102
+ The coverage for each of those claims is in [`test/`](../../test/) (`test/config-safety.mjs` asserts them one by one, 407 checks).
103
+
104
+ **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:
105
+
106
+ ```
107
+ ~/.claude.json 182693 B unchanged
108
+ ~/.cursor/mcp.json 1396 B unchanged
109
+ ~/.codex/config.toml 6647 B unchanged
110
+ ~/.gemini/settings.json 465 B unchanged
111
+ ~/.config/opencode/opencode.json 441 B unchanged
112
+ %APPDATA%/Zed/settings.json 5647 B unchanged
113
+ %APPDATA%/Claude/claude_desktop_config.json 247 B unchanged
114
+ ```
115
+
116
+ The same run with `--home <tmp>` put **every** reported path inside that directory, `APPDATA` included — which is what makes the register/remove cycle safe to rehearse.
117
+
118
+ **A full register -> remove round trip on the real home, not just a preview.** All seven configs were backed up, `--yes` was run for real, then `--remove --yes`, and everything was verified byte by byte:
119
+
120
+ | Client | Before | After register | After remove | Other MCP servers |
121
+ |---|---|---|---|---|
122
+ | ~/.claude.json | 182693 B | 182693 B (already current) | byte-identical to baseline | untouched; 67 top-level keys, 60 projects entries and both case-only duplicate keys still there |
123
+ | ~/.cursor/mcp.json | 1396 B | 1389 B | 1009 B | stitch / obsidian-mcp-server / codegraph / rea identical field by field |
124
+ | ~/.codex/config.toml | 6647 B | 6647 B (already current) | 6239 B | every other table, comment and top-level key kept |
125
+ | %APPDATA%/Zed/settings.json | 5647 B | 5647 B (already current) | 5240 B | all 32 top-level settings keys kept |
126
+ | ~/.gemini/settings.json | 465 B | 845 B | 465 B | restored byte for byte |
127
+ | ~/.config/opencode/opencode.json | 441 B | 869 B | 441 B | `$schema` and the pencil entry kept |
128
+ | %APPDATA%/Claude/claude_desktop_config.json | 247 B | 627 B | 247 B | restored byte for byte |
129
+
130
+ Removal is not accepted because it *looks* right: the check is that **taking our one entry out of the backup with the same editing engine reproduces the file now on disk, byte for byte**. All seven files pass. That proves removal is the strict inverse of registration and cannot quietly take anything else with it.
131
+
132
+ Afterwards every JSON config still parses and `codex mcp list` still reads the file; no client needs hand repair.
133
+
134
+ </details>
135
+
136
+ **`cwd` is optional: it defaults to the project the client has open**
137
+
138
+ The client already knows which folder the user is working in (MCP roots). `dsh_start` without `cwd` now sends one `roots/list` request and converts the returned `file://` URI into a path, so "DSH works where the client is looking" is the default - which also removes the usual way to end up on the home directory by building the path by hand. A client that does not advertise roots, or does not answer in time, gets an actionable error instead of a guessed directory.
139
+
140
+ **Do not use the home directory as `cwd`**
141
+
142
+ DSH's own ACL sandbox requires its temp directory to be **outside** the workspace. On Windows `%TEMP%` is `C:/Users/<you>/AppData/Local/Temp`, inside the home directory, so `cwd` set to home makes the first shell command fail with `Windows ACL: temp root must be outside the workspace`. `dsh_start` now rejects that combination and names both escapes: a narrower project directory, or `permission=read-only` / `danger-full-access`.
143
+
144
+ **Backups and rollback**
145
+
146
+ Both registering and removing snapshot the file first: `<config>.bak-dsh-<timestamp>`, holding the exact pre-operation bytes. Both directions, because the state you want back differs — the pre-register backup undoes a bad install, the pre-remove backup reinstalls after a bad removal.
147
+
148
+ After every real write, each client entry in `--json` carries a `backup` field, and the human-readable output prints `backup: <path>` under each written entry.
149
+
150
+ The copies live next to each client's config, so picking the right one by hand is error-prone. There is a companion command:
151
+
152
+ | What | Command |
153
+ |---|---|
154
+ | List backups, newest first | `dsh-any-agent-restore --list` |
155
+ | Restore the newest backup per file | `dsh-any-agent-restore --latest` |
156
+ | Restore one batch | `dsh-any-agent-restore --stamp 2026-10-30T01-35` |
157
+ | Restore one specific copy | `dsh-any-agent-restore --file <backup path>` |
158
+ | See what would happen | add `--dry-run` |
159
+
160
+ A restore is itself destructive, so it copies the **current** file aside as `.bak-dsh-pre-restore-<timestamp>` before overwriting — a restore can be undone.
161
+
162
+ **Install / update / remove**
163
+
164
+ | Task | Command |
165
+ |---|---|
166
+ | Preview what would change | `dsh-any-agent-setup` |
167
+ | Register with every client found | `dsh-any-agent-setup --yes` |
168
+ | Register with just some | `dsh-any-agent-setup --yes --only cursor,zed` |
169
+ | Change the permission tier in place | `dsh-any-agent-setup --yes --permission read-only` |
170
+ | Update after upgrading the package | `dsh-any-agent-setup --yes` (idempotent; only differing entries are rewritten) |
171
+ | Unregister from every client | `dsh-any-agent-setup --remove --yes` |
172
+ | Unregister from just some | `dsh-any-agent-setup --remove --yes --only codex` |
173
+ | Undo one bad write | copy `.bak-dsh-<stamp>` back over the config |
174
+
175
+ <details>
176
+ <summary>What removal changes: verification details</summary>
177
+
178
+ **What `--remove` actually does — measured**
179
+
180
+ Unregistering touches exactly one key per client and leaves everything else alone. Measured on a real machine, before → after:
181
+
182
+ ```
183
+ Cursor { keep, dsh-any-agent } -> { keep }
184
+ the hand-written entry survived untouched
185
+ Claude Code 6 servers -> 5 servers
186
+ all five originals still listed
187
+ Zed 33 top-level keys -> 33 keys
188
+ only context_servers lost its child, now empty
189
+ Codex our two TOML blocks -> removed
190
+ model, approval_policy, sandbox_mode, node_repl and rea all intact
191
+ ```
192
+
193
+ Nothing outside those entries is read, rewritten or deleted. Your credentials, other MCP servers and unrelated settings are not touched. The package itself is *not* uninstalled by `--remove` — that is `npm uninstall -g dsh-any-agent`, and the two are independent: you can keep the package installed with no clients registered, or unregister without removing it.
194
+
195
+ To verify by hand afterwards: `claude mcp list`, `codex mcp list`, and inspect `~/.cursor/mcp.json` / the `context_servers` key of Zed's settings.
196
+
197
+ </details>
198
+
199
+ **The recorded path is absolute**
200
+
201
+ `setup` writes the absolute path of the installation it ran from. **If you move or delete that directory, every client will fail with `CONNECTION_CLOSED`** until you either re-run `setup` from the new location or `--remove` the stale entries. This bites when you install into a temporary folder to try things out, then clean it up: the configs still point into the void.
202
+
203
+ <details>
204
+ <summary>Manual client configuration: Claude Code, Codex and generic MCP</summary>
205
+
206
+ **From a local checkout**
207
+
208
+ Clone or unzip anywhere, then point `command` at `node` and `args` at `<checkout>/bin/dsh-any-agent.mjs` (shown below).
209
+
210
+ ---
211
+
212
+ **Claude Code**
213
+
214
+ ```bash
215
+ # global install
216
+ claude mcp add dsh-any-agent -- dsh-any-agent
217
+
218
+ # local checkout
219
+ claude mcp add dsh-any-agent -- node /absolute/path/to/server/bin/dsh-any-agent.mjs
220
+ ```
221
+
222
+ **Codex** (`~/.codex/config.toml`)
223
+
224
+ ```toml
225
+ [mcp_servers.dsh-any-agent]
226
+ command = "dsh-any-agent" # or "node" + path to bin/dsh-any-agent.mjs
227
+ args = []
228
+ startup_timeout_sec = 20.0
229
+ tool_timeout_sec = 120.0
230
+ ```
231
+
232
+ **Generic MCP client** — anything that speaks MCP over stdio:
233
+
234
+ ```json
235
+ {
236
+ "mcpServers": {
237
+ "dsh-any-agent": {
238
+ "command": "dsh-any-agent",
239
+ "args": []
240
+ }
241
+ }
242
+ }
243
+ ```
244
+
245
+ For a local checkout, use `"command": "node"` and `"args": ["/absolute/path/to/server/bin/dsh-any-agent.mjs"]`.
246
+
247
+ > Cursor, Windsurf, Cline, Continue and most other clients use this same `mcpServers` shape — if it can launch a stdio MCP server, it works. Zed is the exception: it uses `context_servers` (shown above).
248
+
249
+ </details>
250
+
251
+ ### 3. Try it
252
+
253
+ ```bash
254
+ dsh-any-agent --version # prints the version, starts nothing
255
+ dsh-any-agent --help # flags, key env vars, docs link
256
+ node test/smoke.mjs # no LLM calls, verifies the whole plumbing
257
+ ```
@@ -0,0 +1,27 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # ⚠️ Known limitations
4
+
5
+ 1. **No token-level streaming into model context** — an MCP limitation, not DSH's. Callers get per-step results; humans can follow progress via stderr logs.
6
+ 2. **True mid-turn steering is impossible** — ACP rejects concurrent prompts (`a prompt is already in flight for this session`). `dsh_interject` is the practical equivalent: cancel, then immediately start a new turn, history preserved.
7
+ 3. **Image prompts are unsupported** — ACP advertises `promptCapabilities: {image: false}`.
8
+ 4. **Restarting the MCP server takes its children with it** — when this service is restarted or killed, the DSH children it spawned exit too (their stdio is a pipe to us): **in-flight turns are interrupted and no sentinel lands** (judge by inspecting the workspace, don't just wait for the file). The good news: the **write lock is released automatically**, so a restart always gets it back. The service also pushes no MCP notifications; "completion notification" is the sentinel file plus the caller's background waiter.
9
+ 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.
10
+ 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.
11
+ 7. **`session/list` returns only `{sessionId, cwd}` and excludes already-open sessions** — titles are filled in by this server from DSH's projection cache.
12
+ 8. **Memory (measured 300–480 MB per live process)** — since 0.5.0 sessions with the same `(cwd, permission)` **share one ACP process by default**, so that cost is once per group rather than once per conversation; combined with the 0.4.0 defaults (**10-minute idle reaping + `MAX_LIVE=4`**) the worst case is ≈1.2–1.9 GB. `keep_alive=true` sessions are exempt from idle reaping (at the cost of staying resident). You can also: ① cap by measured RSS with `DSH_MCP_MAX_RSS_MB`; ② cap the child heap with `DSH_MCP_CHILD_MAX_OLD_SPACE_MB`. 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).
13
+ 9. **A shared process takes its group down with it** — the default shared host puts sessions with the same `(cwd, permission)` on one PID. Sessions do not interfere with each other (each has its own Agent and context), but **if the process crashes or is killed, every in-flight turn in the group dies at once** (error sentinels are written and waiters wake up; each session resumes on its next call — the conversation itself is intact). Hence: ① `dsh_takeover` requires `force=true` to kill such a process, and lists the other sessions riding on it; ② for strict isolation (untrusted samples, mutually untrusted tasks) set `DSH_MCP_SHARED_PROCESS=0`; ③ different workspaces or permission tiers still get separate processes — that follows from `dsh-base`'s process-wide sandbox and approval config, not a choice.
14
+
15
+ ## ❓ Why is the profile still called `dsh-mcp`?
16
+
17
+ Because renaming it would break things that already exist on disk.
18
+
19
+ 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:
20
+
21
+ | Persisted identifier | Location | What breaks if renamed |
22
+ |---|---|---|
23
+ | profile directory | `~/.dsh/profiles/dsh-mcp/` | existing sessions can no longer resolve their provider (`no adapter registered for provider "…"`) |
24
+ | `DSH_MCP_*` environment variables | your client configs | every deployment must change at once |
25
+ | `# 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 |
26
+
27
+ 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.
@@ -0,0 +1,24 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # 🔒 The write lock: one writer per conversation at a time
4
+
5
+ DSH conversations carry a **cross-process write lock**: **it never expires while the holder lives, and no API can take it from a live holder** — so **whoever gets there first decides everything**. This server turns that into "**you can look, we can write**":
6
+
7
+ | Situation | Outcome |
8
+ |---|---|
9
+ | **This server holds the lock** (recently used, or `keep_alive=true`) | ✅ Opening it in the GUI is **read-only**: you still **see the content**, but you **cannot take the lock**. Since 0.4.0 the process is reaped only after **10 minutes** idle; `keep_alive=true` sessions are never reaped for idleness |
10
+ | **You open it in the GUI while the lock is free** | ❌ **The GUI keeps it permanently** (navigating away, waiting and archiving all fail to release it) and this server **can never reconnect** — only restarting `dsh web` helps |
11
+ | **Recommended** | Watch progress with **`dsh_read`** (cursor-based incremental reads) or the **sentinel file**; if you must use the GUI, `dsh_release` first (that interrupts a running turn, so it fits the gaps between tasks) |
12
+
13
+ **Who holds it** is reported by `dsh_status.lock_holder`; `dsh_takeover` handles the four cases:
14
+
15
+ | `lock_holder` | Meaning | Action |
16
+ |---|---|---|
17
+ | `self` | this process holds it | nothing to preempt |
18
+ | `stale-mcp` | the holder is **alive but silent** (no heartbeat for 90 s — typically that MCP is wedged) | ✅ `dsh_takeover` **kills and takes over** |
19
+ | `live-mcp` | another **live** dsh-any-agent instance is using it | ⚠️ refused by default; `force=true` takes it |
20
+ | `none` | no registration → most likely **your own DSH GUI** | ❌ **never killed** (GUI conversations live inside the single `dsh web` process, so killing it takes down your whole UI and every GUI conversation with it), reported only |
21
+
22
+ > **Two measured footnotes.** ① **A crash leaves no orphan lock** — `SIGKILL` the MCP and its children exit with it (their stdio pipe closes), releasing the lock automatically ✓; so `stale-mcp` really means "**alive but wedged**". ② **Several instances**: the registry is last-writer-wins (a later save drops conversations it never saw), but the **session store is authoritative** — any session on disk is recoverable **by id alone**; if you give instances different `DSH_MCP_STATE` files, **point `DSH_MCP_LOCKS_DIR` at one shared directory**, or they cannot see each other's holder registrations.
23
+ >
24
+ > The full measurements (the A/B probe method, the evidence that the GUI opens read-only, why even archiving does not release the lock, and crash-vs-wedge) are in [Usage Notes §3.5–3.12](../../USAGE-NOTES.en.md).
@@ -0,0 +1,40 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # ⚙️ Environment variables
4
+
5
+ | Variable | Default | Purpose |
6
+ |---|---|---|
7
+ | `DSH_BIN` | auto-detected | Path to `@deepseek-ai/dsh/lib/bin.js` |
8
+ | `DSH_HOME` | `~/.dsh` | DSH home shared with the GUI (credentials, sessions, workspace registry). Point it elsewhere only for isolation/multi-account |
9
+ | `DSH_MCP_PROFILE` | `dsh-mcp` (`dsh-team` when team is on) | DSH profile to drive; an explicit value wins |
10
+ | `DSH_MCP_TEAM` | `0` (off) | Team mode, opt-in. `dsh-any-agent team on` writes the settings file; this env var overrides it. When on, uses the `dsh-team` profile and injects a "form a team when needed" line before `dsh_send`; per call use `dsh_send(team=false)` |
11
+ | `DSH_MCP_SETTINGS` | `$DSH_HOME/.dsh-any-agent/settings.json` | Settings file written by `dsh-any-agent team on/off` |
12
+ | `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 |
13
+ | `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 |
14
+ | `DSH_MCP_STATE` | `$DSH_HOME/.dsh-any-agent/conversations.json` | Conversation registry, survives restarts. The old `<repo>/.state` location is migrated once on startup |
15
+ | `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 |
16
+ | `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | How long a pending `ask` approval waits before being cancelled |
17
+ | `DSH_MCP_FORGOTTEN_DIR` | `$DSH_HOME/.dsh-any-agent-forgotten` | Tombstones written by `dsh_release(forget=true)` |
18
+ | `DSH_MCP_RUNS_DIR` | `<state file dir>/runs` | Root for async completion sentinels (`<this>/<conversation_id>/<run_id>.json`) |
19
+ | `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 |
20
+ | `DSH_MCP_LOCK_STALE_MS` | `90000` (3× `REAP_INTERVAL_MS`, floor 30 s) | How long without a heartbeat before a holder counts as lost (and becomes auto-preemptible by `dsh_takeover`); changing the reap interval changes the derived default |
21
+ | `DSH_MCP_IDLE_TTL_MS` | `600000` (10 minutes) | How long a conversation may sit idle before its DSH process is reaped. **Default 10 minutes**: the memory is released and the next call resumes the conversation transparently; the cost is that the write lock goes free and the GUI may take it. Set `0` to never reap (hold the lock forever; ~300–480 MB per conversation), or use `dsh_start(keep_alive=true)` for the specific sessions that must stay attached |
22
+ | `DSH_MCP_MAX_LIVE` | `4` (≈1.2–1.9 GB worst case) | ★ **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). `keep_alive=true` sessions are exempt. To free idle conversations at once: `dsh_release(all=true)` (busy turns are skipped unless `force=true`) |
23
+ | `DSH_MCP_MAX_RSS_MB` | `0` (off) | Global child-process memory budget in MB. When the measured RSS exceeds it, idle conversations are reaped least-recently-used first; `keep_alive` sessions are reaped last |
24
+ | `DSH_MCP_CHILD_MAX_OLD_SPACE_MB` | `0` (unset) | Appends `--max-old-space-size` to each DSH child's `NODE_OPTIONS`. A backstop for runaway contexts; too small will OOM long conversations |
25
+ | `DSH_MCP_SHARED_PROCESS` | `1` (on) | **On by default**: sessions with the same `(cwd, permission)` share one ACP process. Large memory and cold-start win (measured: second `session/new` ≈300 ms vs ≈4 s standalone), and `dsh_doctor` de-dupes RSS by PID. Trade-off: a process crash takes down the whole group (error sentinels are written; each session resumes on the next call), so `dsh_takeover` requires `force=true` to kill such a process. Set `0` for one process per conversation |
26
+ | `DSH_MCP_AUDIT_FILE` | empty (off) | Metadata audit JSONL path. Records events, conversation/run ids, decisions and counts; **never** prompts, answers, thinking or tool input. Rotates at 10 MB |
27
+ | `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) |
28
+ | `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels; `0` disables pruning |
29
+ | `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**); answer/error/title/tool inputs are still credential-redacted |
30
+ | `DSH_MCP_PERMISSION` | `workspace-write` | Default tier for `dsh_start`. `danger-full-access` additionally requires `DSH_MCP_ALLOW_FULL_ACCESS=1` |
31
+ | `DSH_MCP_ALLOW_FULL_ACCESS` | `0` | Whether `permission=danger-full-access` is allowed. Denied by default: it hands the caller whole-machine write access |
32
+ | `DSH_MCP_REASONING_EFFORT` | `max` | Default reasoning effort |
33
+ | `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
34
+ | `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
35
+ | `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) |
36
+ | `DSH_MCP_BACKUP_KEEP` | `20` | How many `.bak-dsh-*` copies to keep per client config (rotation is best-effort) |
37
+ | `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | Per-request timeout on the ACP channel (initialize, session/new, …) |
38
+ | `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 |
39
+ | `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
40
+ | `DSH_MCP_LOG_STDERR` | unset | `1` forwards DSH's raw stderr |
@@ -0,0 +1,33 @@
1
+ > Back to [README](../../README.md)
2
+
3
+ # 📦 Release process
4
+
5
+ The order matters; two of these steps exist because a bad release shipped without them.
6
+
7
+ ```bash
8
+ # 1. Bump the version. SERVER_VERSION reads package.json, nothing else.
9
+ npm version patch --no-git-tag-version
10
+ # Then edit CHANGELOG.md: turn [Unreleased] into the new version.
11
+
12
+ # 2. Run the offline suites locally (same set as CI, no tokens, no DSH needed for most).
13
+ node test/run.mjs
14
+ node test/package-check.mjs
15
+ npm run check:release # version / CHANGELOG / doc links / package contents agree
16
+
17
+ # 3. Verify the tarball itself: builds, installs, starts.
18
+ npm pack --dry-run
19
+ # Then in a temp dir: npm install <tgz> && node_modules/.bin/dsh-any-agent --version
20
+
21
+ # 4. Tag and push (wait for CI before publishing).
22
+ git add -A && git commit -m "chore: release x.y.z"
23
+ git tag vx.y.z && git push origin main --tags
24
+ # The pushed tag triggers .github/workflows/release.yml, which creates the
25
+ # GitHub Release with generated notes; no local gh CLI is required.
26
+
27
+ # 5. Publish to npm and verify.
28
+ npm publish --access public
29
+ npm view dsh-any-agent version
30
+ # A 404 right after publish is usually propagation, not failure: npm returns
31
+ # 202 and the registry can lag briefly. Confirm the local npm log shows the
32
+ # version before retrying.
33
+ ```