dsh-mcp
by Moer2831
README.md
# π³ DeepSeek-DSH-MCP
**English** | [δΈζ](README.zh.md)
> **Give Claude Code / Codex 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. π οΈ
π§ͺ 504 checks green Β· π MCP over stdio Β· π MIT Β· π¬ Community: [linux.do](https://linux.do/)
> π **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.
## π Contents
- [β¨ Why it's different](#-why-its-different) Β· [β‘ Why not just wrap the DSH CLI](#-why-not-just-wrap-the-dsh-cli)
- [π Setup](#-setup) Β· [π§° 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)
- [π Permissions](#-permissions) Β· [π€« Reasoning](#-reasoning-hidden-by-default) Β· [πΆοΈ Logging and privacy](#οΈ-logging-and-privacy)
- [π The write lock](#-the-write-lock-one-writer-per-conversation-at-a-time) Β· [πͺ GUI visibility](#-workspace-visibility-in-the-dsh-gui)
- [βοΈ Environment](#-environment-variables) Β· [π§ͺ Tests](#-tests) Β· [β οΈ Known limitations](#-known-limitations)
```
Claude / Codex ββMCP(stdio)βββΆ dsh-mcp
β newline-delimited JSON-RPC (ACP)
βΌ
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 (e.g. IDA Pro)
```
## β¨ 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 (~18 ms to converge), 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
**Claude Code**
```bash
claude mcp add dsh-mcp -- node /absolute/path/to/DeepSeek-DSH-MCP/bin/dsh-mcp.mjs
```
**Codex** (`~/.codex/config.toml`)
```toml
[mcp_servers.dsh-mcp]
command = "node"
args = ["/absolute/path/to/DeepSeek-DSH-MCP/bin/dsh-mcp.mjs"]
```
**Generic MCP client**
```json
{
"mcpServers": {
"dsh-mcp": {
"command": "node",
"args": ["/absolute/path/to/DeepSeek-DSH-MCP/bin/dsh-mcp.mjs"]
}
}
}
```
### 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 in ~1 ms
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-mcp-workspaces.mjs --list # inspect the registry
node bin/dsh-mcp-workspaces.mjs --backfill # register every session found on disk
node bin/dsh-mcp-workspaces.mjs --prune-temp # drop temp-directory entries
node bin/dsh-mcp-workspaces.mjs --prune-empty # drop empty workspaces whose path is gone
node bin/dsh-mcp-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 |
| `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 ~120 MB**). Set a millisecond value (e.g. `300000`) to save memory and accept that the lock can be taken |
| `DSH_MCP_MAX_LIVE` | `8` (β1 GB) | β
**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_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-mcp-test-*` artifacts, run `node bin/dsh-mcp-workspaces.mjs --purge-test-sessions` explicitly.
| Suite | Checks | Covers |
|---|---|---|
| `smoke` | 28 | 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) |
| `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: 504 checks, all green.** (468 in-suite + 36 acceptance)
## π 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-mcp 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 ~120 MB per conversation)** β that is what buys "the GUI can never take the write lock". 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).
## π¬ Community
This project is announced and discussed on **[linux.do](https://linux.do/)** β usage questions, war stories and suggestions are all welcome there. Issues work too, but you'll usually get a faster answer in the community.
## π License
MIT β see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues