dsh-any-agent
# π³ dsh-any-agent
**English** | [δΈζ](README.zh.md)
[](https://www.npmjs.com/package/dsh-any-agent)
[](https://nodejs.org)
[](LICENSE)
[](package.json)
> **Give any MCP client a coding buddy that sticks around** β powered by DeepSeek Harness.
An MCP server that drives DSH as a **long-lived agent runtime** instead of wrapping a CLI. Open a conversation, hand it a goal, and it writes code, runs scripts and spawns its own subagents β while you watch, cut in, and collect the result whenever you like. π οΈ
**Works with any MCP client.** Nothing here is Codex- or Claude-specific: the server speaks plain MCP over stdio and uses only the four standard methods (`initialize`, `ping`, `tools/list`, `tools/call`). Verified against a bare-bones client that negotiates an older protocol version and never sends `notifications/initialized`.
π§ͺ 528 checks green Β· π MCP over stdio Β· π¦ Zero dependencies Β· π MIT
> π **Read the [Usage Notes](USAGE-NOTES.en.md) first** β the practical gotchas that will actually bite you: the ACP model-config trap, external MCP tools bypassing DSH's sandbox, the workspace/GUI registry cache, cost control, and a troubleshooting table.
>
> π **Setting this up on a fresh machine?** [USAGE-MIGRATION.md](USAGE-MIGRATION.md) is a step-by-step walkthrough for a new environment: prerequisites, how to find your `DSH_BIN`, ready-to-paste config blocks for Claude Code / Codex / Cursor / Windsurf / Cline / Zed, and a first-turn checklist.
## π Contents
- **Start here** β [The problem](#-the-problem-this-solves) Β· [Why it's different](#-why-its-different) Β· [Why not just wrap the DSH CLI](#-why-not-just-wrap-the-dsh-cli) Β· [Requirements](#-requirements) Β· [Setup](#-setup)
- **Using it** β [Tools](#-tools) Β· [Dispatch then collect](#-workflow-dispatch-then-collect-later) Β· [Completion notifications](#-workflow-get-notified-on-completion-no-polling) Β· [Many conversations](#-workflow-many-conversations-at-once)
- **Behavior** β [Permissions](#-permissions) Β· [Reasoning](#-reasoning-hidden-by-default) Β· [Logging and privacy](#-logging-and-privacy) Β· [Workspace & GUI](#workspace-visibility-in-the-dsh-gui)
- **Hard parts** β [The write lock](#-the-write-lock-one-writer-per-conversation-at-a-time)
- **Reference** β [Environment](#-environment-variables) Β· [Tests](#-tests) Β· [Known limitations](#-known-limitations) Β· [Why the old profile name?](#-why-is-the-profile-still-called-dsh-mcp)
```
any MCP client β Claude Code, Codex, Cursor, Zed, Cline, your ownβ¦
(Claude/Codex/Cursor/β¦) β
β MCP over stdio β newline-delimited JSON-RPC (ACP)
βΌ βΌ
βββββββββββββββ ββββββββββββββββββββββββββββ
βdsh-any-agentβββββββββββββΆβ dsh --profile dsh-mcp β one long-lived process
βββββββββββββββ β β per conversation
ββββββββββββββ¬ββββββββββββββ
β
ββββββββββββββ΄ββββββββββββββ
β DSH's own tools β files, shell, search,
β β skills, subagents, workflows
ββββββββββββββββββββββββββββ€
β dsh-mcp-client β β your other MCP servers
ββββββββββββββββββββββββββββ
```
## π― The problem this solves
You already have a coding agent in your editor. What you do **not** have is one that can:
- **hand a multi-hour job to another agent and walk away** β then collect the result when it's done;
- **keep the job alive across restarts** of your editor, your session, your machine's idle reap;
- **cut in mid-flight** when it's going the wrong way, without destroying the work;
- **run several of them at once** and switch between them.
DSH (DeepSeek Harness) can do all of that β it has file/shell tools, skills, subagents and workflows β but it is a GUI-first application. Its own `sdk` profile exposes only 3 requests and can't resume, cancel, or answer approvals.
**This server is the missing adapter:** it makes DSH into a *well-behaved background agent* that any MCP client can hire, supervise and collect from.
> Think of it as `&job` β like `&` in a shell, but for an agent.
## β¨ Why it's different
| | |
|---|---|
| π **A dead process never kills a conversation** | The identity lives on disk. A crash, an idle reap, even restarting this whole server costs you nothing β the next call transparently resumes it, memory intact (verified across processes). |
| π£οΈ **You are never stuck waiting** | Steer a conversation mid-flight β **interject** to stop and redirect (converges in well under a second), or **queue** a remark for after the current turn. Interrupting never damages the conversation. |
| π‘ **Completion notification, not polling** | Long tasks return instantly with a `run_id`; when the turn ends, a **sentinel file** is written atomically so a background task in *your* host wakes you. No polling, no occupied turn. |
| πͺ **Many conversations at once** | `dsh_list(only_running)` shows what's live; `dsh_read` gives cursor-based incremental output. Cycle between them like windows. |
| π§ **Reasoning hidden by default** | You get "thought for N chars / M seconds" statistics instead of context-burning prose β and it is never logged, never written to disk. |
| π€« **Silent by default** | Not one byte on stderr, so nothing pollutes your host's logs. stdout carries the protocol and nothing else. |
| π§© **Capabilities compose** | DSH can mount its own MCP servers (IDA Pro, browsers, your internal tooling), so this is a bridge to a whole toolbox. |
| π‘οΈ **Read-only that really is read-only** | `read-only` sandbox plus auto-denied approvals, for working on untrusted samples. |
## β‘ Why not just wrap the DSH CLI?
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.7-rc.2`.
| Capability | `sdk` profile | `acp` profile (**this project**) |
|---|---|---|
| **Resume a conversation** | β no resume method; reusing an id fails with `already exists` | β
`session/resume` β verified **across processes** |
| **Interrupt a running turn** | β only by killing the process | β
`session/cancel` β the same path as the user's own stop button |
| **Answer approval prompts** | β no channel; operations silently fail closed | β
`session/request_permission` round-trip |
| List / close sessions | β none | β
`session/list`, `session/close` |
| Gray vs. black streaming | one message, split by a `type` field | β
two separate channels: `agent_thought_chunk` / `agent_message_chunk` |
| Per-session MCP servers | β process-wide | β
`mcpServers` accepted by `session/new` and `session/resume` |
**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.
## π¦ Requirements
- **Node.js β₯ 20**
- **DSH installed** (`@deepseek-ai/dsh`), with working credentials under `~/.dsh`
- A DSH **profile named `dsh-mcp`** derived from the shipped `acp` template, containing **your own** provider/model config (this repo deliberately ships no provider config)
## π Setup
### 1. Create the DSH profile
```bash
dsh dsh-mcp --from-default-profile acp
```
Then edit `~/.dsh/profiles/dsh-mcp/cordis.patch.yml` and declare your provider and model. Start from [`profile-example/cordis.patch.yml`](profile-example/cordis.patch.yml).
> β οΈ **Two traps here** (both learned the hard way):
>
> 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"`.
> 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.
### 2. Register the server
**From npm (recommended)**
```bash
npm install -g dsh-any-agent
```
The `dsh-any-agent` executable is then on your `PATH` and every client below can just call it by name.
**From a local checkout**
Clone or unzip anywhere, then point `command` at `node` and `args` at `<checkout>/server/bin/dsh-any-agent.mjs` (shown below).
---
**Claude Code**
```bash
# global install
claude mcp add dsh-any-agent -- dsh-any-agent
# local checkout
claude mcp add dsh-any-agent -- node /absolute/path/to/server/bin/dsh-any-agent.mjs
```
**Codex** (`~/.codex/config.toml`)
```toml
[mcp_servers.dsh-any-agent]
command = "dsh-any-agent" # or "node" + path to bin/dsh-any-agent.mjs
args = []
startup_timeout_sec = 20.0
tool_timeout_sec = 120.0
```
**Generic MCP client** β anything that speaks MCP over stdio:
```json
{
"mcpServers": {
"dsh-any-agent": {
"command": "dsh-any-agent",
"args": []
}
}
}
```
For a local checkout, use `"command": "node"` and `"args": ["/absolute/path/to/server/bin/dsh-any-agent.mjs"]`.
> Cursor, Windsurf, Zed, Cline, Continue and most other clients use this same `mcpServers` shape β if it can launch a stdio MCP server, it works.
### 3. Try it
```bash
node test/smoke.mjs # no LLM calls, verifies the whole plumbing
```
## π§° Tools
| Tool | What it does |
|---|---|
| `dsh_start` | Create a conversation: workspace, permission tier, approval policy, **reasoning effort (default `max`)**, model |
| `dsh_send` | Hand it a task. **Defaults to `wait=false`: returns a `run_id` + `sentinel_file` immediately**, the turn runs in the background and the sentinel file notifies you when it's done (recommended). `wait=true` blocks until the turn ends; hitting `timeout_ms` (default `0` = wait forever) **only downgrades to background β the turn is never cancelled and the result is never lost** |
| `dsh_list` | All conversations with live state: `running` / `idle` / `detached`, elapsed time, current tool, output so far. `only_running=true` for active ones |
| `dsh_read` | **Cursor-based incremental read** of what a *running* conversation is producing right now |
| `dsh_get` | Conversation details; with `run_id`, the **final result of that dispatch** (the "collect the result later" entry point) |
| `dsh_interject` | Interject while it's busy: `interject` = stop and redirect, `queue` = wait for the current turn, then speak. **This round writes a sentinel too**, and the reply carries its `sentinel_file` |
| `dsh_takeover` | **Preempt the write lock.** The only way out when another DSH process holds it (DSH has no steal API β preempting means killing the holder). It classifies the holder first: our own orphaned child β taken automatically; another live instance β needs `force`; **GUI/unknown β never killed**, reported instead |
| `dsh_interrupt` | Stop the current turn (the conversation stays healthy) |
| `dsh_release` | Hand the conversation back (frees the write lock so you can open it in your own DSH GUI). Still resumable |
| `dsh_approval_decide` | Adjudicate a pending approval (only when `on_approval=ask`) |
| `dsh_status` | Lightweight status check |
## π Workflow: dispatch, then collect later
MCP tool calls **block**, so `dsh_send` **does not block by default** (`wait=false`): you get a receipt immediately, the turn runs in the background, and the sentinel file wakes you when it finishes. The whole flow:
```text
1. dsh_send(conversation_id, prompt, wait=false)
β { run_id: "run-abc123", background: true } # returns without waiting for the turn
2. β¦keep working on something else (verified: another conversation ran a full turn meanwhile)β¦
3. dsh_status(conversation_id)
β state=running | elapsed=3.5s | current_tool=pwsh | out=37 chars
4. dsh_read(conversation_id, cursor) # peek mid-flight
β [me] β¦ | [turn] turn 1 started | [tool] pwsh [in_progress]
5. dsh_get(conversation_id, run_id) # collect the result
β status=done, elapsed=20.3s, full answer
```
`run_id` is the receipt. Run records are **memory-only, last 20 kept**; under many concurrent runs old ones get evicted and `dsh_get` will fail β which is exactly why the completion sentinel below carries the result.
## π Workflow: get notified on completion (no polling)
Besides `run_id`, `wait=false` returns a **`sentinel_file`** path plus **`sentinel_file_posix`** (the Bash/MSYS form β use it directly; hand-converting Windows paths is the easiest way to make your waiter hang until timeout). When the turn ends β **whether it succeeds, fails, or is cancelled** β the service **atomically writes** a small file there containing the final `status` and `result`. So you neither poll nor block: **wait for that file with a background task in your own host (Claude/Codex); the moment it appears, you're woken up.**
```bash
# sent="$sentinel_file_posix" β copy it straight from the tool result
sent='/d/AI_MCP/DSH_MCP/.state/runs/<conversation_id>/<run_id>.json'
dl=$(( $(date +%s) + 2100 )) # 35min safety net so a dead service can't hang you forever
until [ -f "$sent" ]; do
[ "$(date +%s)" -ge "$dl" ] && { echo "TIMEOUT"; exit 1; }
sleep 2
done
echo "DONE" # task exits β host wakes you β read the file to collect
```
Once the file exists: **read it** for `status` and `result` (preferred β immune to the in-memory window and survives restarts), or use `dsh_get(conversation_id, run_id)`.
Design notes (especially under **concurrent multi-session** use):
- **No cross-talk**: the path is `.state/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.
- **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.
- **Atomic write**: `tmp` + `rename`; if the file exists its contents are complete. Each run uses its own tmp, so concurrent writes never clobber.
- **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.
- **β
No reasoning in the sentinel**: the payload carries the answer and thinking *statistics* only β `result.thinking` is stripped and replaced by `thinking_omitted: true`, because the project's rule is that reasoning never reaches disk. `dsh_get(run_id)` still returns it from memory while the record lives. `DSH_MCP_SENTINEL_INCLUDE_REASONING=1` breaks that guarantee deliberately.
> Cleanup: sentinels contain full answers, so the service **prunes sentinels older than 7 days at startup** (tune with `DSH_MCP_SENTINEL_TTL_MS`; `0` disables). Your waiter should still `rm` the file after consuming it.
## π§΅ Workflow: many conversations at once
```text
1. dsh_list(only_running=true)
- β¦ | id=448b239bβ¦ | π΅ running (9s, tool=pwsh, 40 chars this turn) | cwd=β¦
- β¦ | id=33427096β¦ | π΅ running (8s, 34 chars this turn) | cwd=β¦
2. dsh_read(id, cursor=0) # read the first, note the cursor
3. dsh_read(id2, cursor=0) # switch to the second
4. dsh_read(id, cursor=<prev>) # switch back, deltas only
```
## π€« Reasoning: hidden by default
DSH separates reasoning from answer text at the model layer, and ACP exposes them as **two independent streams**. This server hides reasoning by default and returns only **statistics** (characters, duration, chunk count) β so the caller knows thinking happened without paying for it in context.
- `reasoning=hide` (default) β content dropped, stats returned
- `reasoning=marker` / `summary` / `full` β progressively more content (mind your context budget)
- `dsh_read(include_reasoning=true)` β live reasoning of the *currently running* turn only; it is never buffered and is discarded when the turn ends
## π Permissions
| Tier | Approval default | Use for |
|---|---|---|
| `danger-full-access` (default) | `auto-allow` | Zero friction |
| `workspace-write` | `auto-allow` | Out-of-workspace operations surface as pending approvals |
| `read-only` | `auto-deny` | Analyzing untrusted samples (malware, unknown dumps) |
`on_approval` can be `auto-allow`, `auto-deny`, or `ask` (which exposes a pending approval for `dsh_approval_decide` to adjudicate).
## πͺ Workspace visibility in the DSH GUI
Two different things β don't confuse them:
| | Depends on | Status |
|---|---|---|
| **Workspace effectiveness** | the conversation's `cwd` | β
Always correct. Process working directory, file placement and sandbox write root all follow it |
| **GUI grouping** | `sessionIds` in `~/.dsh/storages/workspace.json` | β οΈ This server registers it, but **the running DSH server caches the registry in memory** β external file edits only take effect after a DSH restart |
Verified experimentally ([`test/workspace-effect.mjs`](test/workspace-effect.mjs)): two conversations in two desktop folders, given **no path at all**, each wrote its file into its own workspace, self-reported the correct working directory, and leaked nothing into the server's cwd / home / temp / the other workspace (24/24 checks).
Maintenance CLI:
```bash
node bin/dsh-any-agent-workspaces.mjs --list # inspect the registry
node bin/dsh-any-agent-workspaces.mjs --backfill # register every session found on disk
node bin/dsh-any-agent-workspaces.mjs --prune-temp # drop temp-directory entries
node bin/dsh-any-agent-workspaces.mjs --prune-empty # drop empty workspaces whose path is gone
node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions # delete test sessions (strict rules)
```
> β οΈ Before restarting DSH, **don't touch workspaces in the GUI** β the server will write its in-memory state back over the file.
## πΆοΈ Logging and privacy
Silent by default β **not one byte is written**.
1. MCP's **stdout is the protocol channel**; anything mixed in corrupts the stream.
2. This server is launched by Claude/Codex, so its stderr lands in *their* logs.
3. DSH's own stderr may carry model output **including reasoning**, so it is never forwarded by default.
| Level | Behavior |
|---|---|
| `silent` (default) | nothing at all |
| `info` | this server's own lifecycle events only β **never any DSH output** |
| `debug` | more of our own events; still no DSH output |
| `DSH_MCP_LOG_STDERR=1` | additionally forward DSH's raw stderr (**may contain reasoning**) |
**Reasoning never reaches disk** β with exactly one deliberate exception:
- Not in logs (silent by default), and the state file holds metadata only (id, cwd, permissions, counters) β a smoke-test assertion guards this.
- **The async completion sentinel does persist the answer** (it must, so a caller can collect offline) but **strips `result.thinking`** and marks `thinking_omitted: true`. 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.
- Sentinels are pruned after 7 days by default (`DSH_MCP_SENTINEL_TTL_MS`; `0` keeps them forever).
(DSH itself writes its own session log under `~/.dsh/sessions`; that is what makes resume possible and is outside this server's control.)
## βοΈ Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `DSH_BIN` | auto-detected | Path to `@deepseek-ai/dsh/lib/bin.js` |
| `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) |
| `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 |
| `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 |
| `DSH_MCP_STATE` | `<repo>/.state/conversations.json` | Conversation registry, survives restarts |
| `DSH_MCP_RUNS_DIR` | `<state file dir>/runs` | Root for async completion sentinels (`<this>/<conversation_id>/<run_id>.json`) |
| `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 |
| `DSH_MCP_LOCK_STALE_MS` | `90000` | How long without a heartbeat before a holder counts as lost (and becomes auto-preemptible by `dsh_takeover`) |
| `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 |
| `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 everything at once: `dsh_release(all=true)` |
| `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) |
| `DSH_MCP_SENTINEL_TTL_MS` | `604800000` (7 days) | Startup pruning age for sentinels; `0` disables pruning |
| `DSH_MCP_SENTINEL_INCLUDE_REASONING` | unset | `1` writes `result.thinking` into the sentinel (**breaks the never-on-disk guarantee**) |
| `DSH_MCP_PERMISSION` | `danger-full-access` | Default tier for `dsh_start` |
| `DSH_MCP_REASONING_EFFORT` | `max` | Default reasoning effort |
| `DSH_MCP_PROMPT_TIMEOUT_MS` | `0` (no timeout) | Wait bound for **`wait=true` only**; on expiry the turn merely moves to the background |
| `DSH_MCP_APPROVAL_TIMEOUT_MS` | `300000` | How long a pending approval waits |
| `DSH_MCP_REGISTER_WORKSPACE` | `project` | `project` / `all` / `0` |
| `DSH_MCP_REQUEST_TIMEOUT_MS` | `60000` | Per-request timeout on the ACP channel (initialize, session/new, β¦) |
| `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 |
| `DSH_MCP_LOG` | `silent` | `silent` / `info` / `debug` |
| `DSH_MCP_LOG_STDERR` | unset | `1` forwards DSH's raw stderr |
## π§ͺ Tests
```bash
node test/run.mjs # smoke only (no LLM calls)
node test/run.mjs --all # everything
node test/cleanup.mjs # suite teardown on its own (temp dirs only; --dry-run to preview)
```
The suite **cleans up after itself**: `run.mjs` always ends with `cleanup.mjs`, which removes test sessions and workspace registrations **inside the OS temp directory only**. Real project sessions and the repo's `.state/` (your live conversation registry and sentinels) are never touched. To also remove `Desktop\dsh-any-agent-test-*` artifacts, run `node bin/dsh-any-agent-workspaces.mjs --purge-test-sessions` explicitly.
| Suite | Checks | Covers |
|---|---|---|
| `smoke` | 29 | handshake, tool table, conversation creation, config, registry purity |
| `boundary` | 42 | protocol edges (double initialize, malformed lines, unknown method), argument validation, unknown ids, cursor edges, lifecycle idempotence, unicode |
| `integration` | 30 | resume-with-memory across processes, interrupt, both interject modes |
| `async` | 16 | fire-and-forget + later collection |
| `sentinel` | 36 | completion sentinel: atomicity, latch semantics, per-conversation namespacing under concurrency, cancelled runs still land it, and **reasoning never reaching the file** |
| `prune` | 11 | **sentinel retention**: prunes over-age and crash-leftover files, keeps fresh ones, removes empty shells, `TTL=0` disables pruning, and never touches the conversation registry beside it (no tokens, runs every time) |
| `guards` | 31 | **environment-variable guards**: odd numbers always land on the safe side β `IDLE_TTL_MS` values like `-1` or garbage mean "never reap" (never give the write lock away), a too-small reap interval falls back to the default (no busy loop), and the **lost-holder threshold is floored at 30 s** (so another instance cannot "legitimately" preempt and kill our turn) (no tokens, runs every time) |
| `approval-details` | 24 | **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; credential-shaped fields and common inline literals are redacted in both the structured context and the displayed title. Verified end-to-end over real stdio MCP |
| `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 |
| `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) |
| `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) |
| `concurrency` | 31 | three simultaneous conversations + live incremental reads |
| `capability` | 22 | writing code, running scripts, **spawning its own subagents** (verified on disk via child session headers) |
| `workspace-effect` | 24 | workspace actually effective when no path is given |
| `acceptance` | 36 | two folders Γ two conversations doing a read-only IDA Pro analysis |
| `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**; **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 |
| `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 |
| `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) |
**Total: 528 checks, all green.** (492 in-suite + 36 acceptance)
> Counts are the numbers each suite prints itself. `smoke`/`prune`/`guards`/`approval-details`/`boundary` run without LLM calls; the rest need a working provider.
## π The write lock: one writer per conversation at a time
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**":
| Situation | Outcome |
|---|---|
| **This server holds the lock** (the default) | β
Opening it in the GUI is **read-only**: you still **see the content**, but you **cannot take the lock**. The default `DSH_MCP_IDLE_TTL_MS=0` (never reap) keeps the lock from ever going free |
| **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 |
| **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) |
**Who holds it** is reported by `dsh_status.lock_holder`; `dsh_takeover` handles the four cases:
| `lock_holder` | Meaning | Action |
|---|---|---|
| `self` | this process holds it | nothing to preempt |
| `stale-mcp` | the holder is **alive but silent** (no heartbeat for 90 s β typically that MCP is wedged) | β
`dsh_takeover` **kills and takes over** |
| `live-mcp` | another **live** dsh-any-agent instance is using it | β οΈ refused by default; `force=true` takes it |
| `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 |
> **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.
>
> 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).
## β οΈ Known limitations
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.
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.
3. **Image prompts are unsupported** β ACP advertises `promptCapabilities: {image: false}`.
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.
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.
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.
7. **`session/list` returns only `{sessionId, cwd}` and excludes already-open sessions** β titles are filled in by this server from DSH's projection cache.
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)`** β (lossless, everything resumes on demand).
## β Why is the profile still called `dsh-mcp`?
Because renaming it would break things that already exist on disk.
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:
| Persisted identifier | Where | Breaks if renamed |
|---|---|---|
| the profile directory | `~/.dsh/profiles/dsh-mcp/` | existing sessions can no longer resolve their provider (`no adapter registered for provider "β¦"`) |
| `DSH_MCP_*` environment variables | your client config | every deployment would need editing at once |
| the `# dsh-mcp:managed` marker line | first line of the generated `cordis.patch.yml` | the bootstrap would stop recognising the file as its own and never sync it again |
So the **product** is `dsh-any-agent`; the **persisted identifiers** keep the old name on purpose. This is a deliberate compatibility decision, not an oversight. If you are setting this up from scratch and want a different profile name, set `DSH_MCP_PROFILE` β the server will use whatever you give it.
## π¬ Support
- **Bug reports, feature requests, setup questions** β [open an issue](https://github.com/xmtlzzz/dsh-any-agent/issues); all of it is welcome there
- **Before reporting a failure** β check the [troubleshooting table](USAGE-MIGRATION.md) and the [Usage Notes](USAGE-NOTES.en.md); most setup failures are one of a handful of known causes
This is a one-maintainer project, so a clear reproduction (the exact call, the error text, your DSH version) gets a much faster answer than a description.
## π License
MIT β see [LICENSE](LICENSE).
TDQS
Scored across 11 tools
Most tools target clearly distinct operations (start, send, interrupt, release, takeover, approval_decide), but there is real overlap between dsh_get, dsh_status, and dsh_read β all report on a session's state/output β and between dsh_interject (interrupt mode) and dsh_interrupt. The descriptions do disambiguate these (read=cursor-based live output, status=lightweight check, get=full detail/run result), so confusion is limited but not zero.
All names use a consistent dsh_ prefix with snake_case, and 9 of 11 follow a clean verb style (start, send, release, list, read, get, interject, interrupt, takeover). The deviations β dsh_approval_decide (noun_verb) and dsh_status (noun) β are minor and still readable.
11 tools is well within the sweet spot for a session-management server, and each tool maps to a distinct lifecycle concern: create, dispatch, observe, steer, cancel, reclaim, and approve. Nothing feels redundant in count.
The lifecycle is well covered: create, send, list, read, get, interject, interrupt, release (with forget), takeover, and approval handling, plus auto-resume via ACP. Minor gaps: no explicit resume/open tool, no way to change a session's permission profile or on_approval setting after creation, and no delete/purge beyond release+forget.