# dsh-midtalk **Mid-task interjection + optional interruption + a structured recovery card** for DeepSeek Harness (DSH). Two slash commands that separate "I want to say something" from "stop this step right now" — without losing the scene when it stops. **English** · [中文](README.md) - Host-side Cordis plugin only. **No client code**, no build step, **no third-party runtime dependencies**; there is one peer dependency on the host package `@deepseek-ai/dsh-llm` — only `index.js` uses it to build the injected message; `lib/core.js` and the self-test do not need it. - No model calls, no network, no telemetry. It only writes its own boot marker, wake log and recovery cards under `$DSH_HOME/midtalk/`. - **Not distributed via npm**: `package.json` sets `"private": true`; you install it by placing the directory into a profile (§3). --- ## 1. The problem While a task is running you have two extremes: | Option | Cost | |---|---| | Wait for the turn to finish | Slow — a long step can take minutes | | Hit the UI stop button | Instant, but it **kills the current step**, may leave half-written state, and your sentence gets no reply | This plugin splits those into two commands and lets **one Enter key** decide. It never upgrades `/say` into `/cut` on its own (a deliberate choice — see §6). ## 2. Commands | | `/say ` | `/cut [text]` | |---|---|---| | Semantics | **Lossless**: insert at the next step boundary | **Lossy**: abort the current step | | Interrupts? | No | Yes (in-flight tool calls are cancelled) | | Latency | = remaining time of the current step | waits for the agent to reach `idle`, 3 s fallback (one local observation: 27 ms — evidence in `$DSH_HOME/midtalk/wake.log`) | | Turn | Answered **within the same turn** | Current turn ends; your text arrives as a **new turn** | | Leftovers | None | Possibly (the aborted step) | | Recovery | Not needed | A **structured recovery card** is written automatically | | Use when | Default | I'm stuck in a very long step and you must stop me now | Both commands append "the last tool call started X seconds ago (estimate)" to their reply — **information only, semantics unchanged**. ## 3. Install The package declares `dsh.bundle.patch` in `package.json` (pointing at `cordis.patch.yml` in the repo root), so DSH's own plugin command can install it into a profile **as a layer**; placing it by hand works too (Option B). **Both files are required**: `index.js` and `lib/core.js` — `index.js:44` imports `./lib/core.js` relatively, so a missing file means a failed mount. ### Option A: `dsh plugin add` (recommended, restart required) ```bash dsh plugin --profile web add "github:blueberrymaid/dsh-midtalk" ``` A **thin pnpm forwarder** (`@deepseek-ai/dsh/lib/plugin-Ddi42qoW.js:7-17`): it runs `pnpm add ` inside the profile directory, then reconciles the profile's layer list against the installed state — a dependency that resolves to a package declaring `dsh.bundle` joins `dsh.profile.bundles`; anything else is installed as a plain dependency with a one-line warning (`:25-33`, `:46-78`). So `dsh.bundle.patch` is not decoration: **without it the plugin installs but never becomes a profile layer**. Restart `dsh web` to activate. ### Option B: place it in a profile + roster (no pnpm / no network) **Step 1 — put it into the module directory** ```bash # from GitHub git clone https://github.com/blueberrymaid/dsh-midtalk.git dsh-midtalk ``` Copy the whole directory (at least `index.js` + `lib/core.js`) into: ``` $DSH_HOME/profiles//node_modules/dsh-midtalk/ ``` `$DSH_HOME` defaults to `.dsh` under your home directory; on Windows the real path looks like `C:\Users\\.dsh\profiles\web\node_modules\dsh-midtalk\` (the profile name is usually `web`; check the directories under `$DSH_HOME\profiles\`). **Step 2 — add it to the roster** Add one `insert:` entry to `$DSH_HOME/profiles//cordis.patch.yml`: ```yaml - insert: - id: midtalk name: dsh-midtalk ``` > **Back that file up first** (e.g. `Copy-Item cordis.patch.yml cordis.patch.yml.bak`). It is read **once at startup, all-or-nothing**: one bad line stops `dsh web` from booting. `name` also accepts a relative or absolute path (e.g. `./node_modules/dsh-midtalk/index.js`). **Step 3 — restart `dsh web`**, then verify below. ### How to verify it is loaded Read `$DSH_HOME/midtalk/boot.json` — the plugin writes it in `apply()` and again once command registration finishes (`index.js:210`, `index.js:235`): ```json { "plugin": "dsh-midtalk", "version": "1.0.0", "stage": "registered", "commands": ["say", "cut"], "pid": 18092 } ``` - `version` must match what you installed; `stage` reaches `registered` only after the commands are registered. - `pid` is the process that wrote the file. **If the version does not change, or `stage` stays at `apply`, the live process is not running the code you just copied** — the ESM module cache can serve the old version; a restart is the reliable cure. - Typing `/` in the composer should list `/say` and `/cut`. ### Appendix: no-restart path (`dsh-my-guardian` staging, as tested locally) Machines with `dsh-my-guardian` installed can hot-mount through its staging file, **without restarting**. This is the flow the author tested locally — **not a general requirement**: 1. copy the package to `$DSH_HOME/profiles/web/node_modules/dsh-midtalk/`; 2. write one line into `$DSH_HOME/profiles/web/cordis.staged.json`: ```json [{"id":"midtalk","name":"./node_modules/dsh-midtalk/index.js?v=1"}] ``` 3. within seconds guardian tries to mount it: on success the staging file is emptied and the entry appears in `promoted` inside `$DSH_HOME/guardian/state.json`; on failure the line stays and `lastError` explains why (3 consecutive failures freeze it). **Two traps we hit for real:** - **A new module name needs a new `id`.** Keeping the old `id` with a new `name` is silently ignored (the staging line just sits there). - **Re-mounting the same path does not reload code.** The ESM module cache keeps the old code alive (measured: `dsh-progress-report` stayed inert across three installs). The only lever is a **query string** on the module name (`?v=2`) — then confirm with `version` in `boot.json`. ### Uninstall / rollback 1. remove that `insert:` entry from `cordis.patch.yml` (with guardian you can instead `POST http://127.0.0.1:3080/guardian/api/remove` with `{"id":""}` for a hot unmount); 2. delete `$DSH_HOME/profiles//node_modules/dsh-midtalk/`; 3. optionally delete `$DSH_HOME/midtalk/` (boot marker, wake log, recovery cards — plain text, safe to keep). ## 4. Recovery card Before interrupting, `/cut` writes the scene to `$DSH_HOME/midtalk/last-interrupt-.json` — **one card per session, never overwritten across sessions**; written atomically (temp file + same-directory rename, so a half-written JSON is impossible). Fields: `at`, `reason`, `plugin`, `version`, `agentStatus`, `sessionId`, `interruptedAt{turn,step,attemptId}`, `userText`, `pendingToolCalls[]{at,attemptId,callId,name,arguments}` (arguments parsed to objects), `lastReasoning`, `lastText`, `usage`, `lastFinish`, `diagnostics` (frame-type counters, parse errors) and `frames` (last 12 stream frames). **Recovery protocol (after an interruption; relies on this card only, no other file):** 1. list state first, do not continue working; 2. read the card — especially `pendingToolCalls`, the tool calls that were in flight at the moment of interruption (name + arguments); 3. decide for each one whether it **landed**: the card records **intent**, not an inventory, so check the files/commands it touched yourself (read the file, check exit codes, compare timestamps); 4. hand that assessment (landed / roll back / verify first) to the user for a decision; 5. after their confirmation, **redo the interrupted step**. `agent.cancel` **does not undo** anything already written. ## 5. Hard rule line (plugin-only) Both commands end their injected text with: > 〔硬规则 · 收到插话先输出一句正文回复(确认收到 + 回应内容),再决定是否继续调用工具;只发工具调用、或不回话=违规〕 It is hard-coded in `lib/core.js:16` and **does not go into the host's per-turn `AGENTS.md`**: this is the plugin's own behavioural constraint and should not pollute global instructions. It comes from a real incident: an interjection was answered with tool calls only, twice. It is a **text constraint** with no enforcement layer — but it makes "asked and not answered" an explicit violation rather than a style question. ## 6. What it deliberately does **not** do - **No background killing**: `jobs.kill` / `terminals.kill` / `subagents.interrupt` / `goals.disarm` are not wired. Killing background work creates half-done state, which contradicts the whole point. - **No automatic threshold upgrade**: it will not watch how long the current step ran and silently turn `/say` into `/cut`. That would turn your one guarantee ("`/say` never breaks anything") into a conditional guarantee decided by state you cannot see. - **No hiding of the bracketed text**: injected behaviour notes render as a **system row** (`source: {kind:"plugin", form:"notice"}`), clearly not your message — but visible. True invisibility needs an unrendered channel; not available. - **No rollback of already-written files** (the platform has none). ## 7. Undocumented internals it depends on Every row below was checked against the sources; **DSH version tested: `@deepseek-ai/dsh` 0.1.5-rc.3 and `@deepseek-ai/dsh-llm` 0.1.5-rc.3** (read from `version` in those packages' `package.json:4`). **Upgrading DSH can change any of them — and when one changes the plugin fails silently** (it is dynamically loaded JS, so there is no compile error). | Use | Evidence (package + `file:line`) | |---|---| | Lossless delivery | `agent.steer(createUserMessage(...))`; `createUserMessage` comes from `@deepseek-ai/dsh-llm` (top-level import at `index.js:25`, used at `index.js:177`) | | Lossy delivery | `agent.cancel({kind:"user"}, {keepInbox:true})` — same call as the UI stop button: `@deepseek-ai/dsh-api-session-controller/lib/index.js:872-878` (called at `index.js:198`) | | Wake timing | `agent.status` getter (anything but `idle`/`maintenance` is `running`): `@deepseek-ai/dsh-agent-loop/lib/index.js:773-775`; status changes emit `agent/status`: same file `:776-782`; the payload carries the agent: `@deepseek-ai/dsh-tool-cordis/lib/index.js:5061-5065` | | Card content | `agent/assistant-stream` frames (subscribed at `index.js:211`); `frame.chunk` is an **object** (StreamChunk), not a string (`lib/core.js:65-70` accepts both) | | System row | `source: {kind:"plugin", plugin, form:"notice", summary}` (pattern: `@deepseek-ai/dsh-agent/lib/index.js:99-114`; used at `index.js:148-153`) | | Command registration | `ctx.commands.register({name, description, input, handler})`: `@deepseek-ai/dsh-commands/lib/index.js:257-259`; duplicate names throw: same file `:82` (and there is no public unregister API — unmount the old plugin before mounting a new version) | | Command invocation payload | the handler receives `{agent, rawInput, ...}` (`index.js:166-171`) | ## 8. Known limits - `/say` latency = remaining time of the current step (a 100-second step means 100 seconds of silence; split long work into short steps or use `/cut`). - **The pending-wake queue caps at 8 and silently drops the oldest**: `MAX_PENDING_WAKES = 8` (`lib/core.js:20`). More than 8 queued sends for one agent means the oldest is dropped (`index.js:132-143`), leaving only a `"why":"dropped:queue-full"` line in `$DSH_HOME/midtalk/wake.log` — **the user is never told**. Normal use (one `/cut` at a time) cannot reach it; only rapid repeated `/cut` can. - The card records tool-call **intent**, not a "did it land" inventory — that is not structurally available. - More than 20 tool calls in one step: only the last 20 are kept (`lib/core.js:26`). - `lastText` / `lastReasoning` are clipped at 2000 chars (`lib/core.js:24-25`); `frames` keeps only the last 12 (`lib/core.js:21`). - An empty `/say` (no text) injects an interjection with no content. - Compatibility was verified only against the **version tested** (0.1.5-rc.3): 2026-09-25, Windows + node 22. Linux / macOS were not tested (no platform-specific branches in the code, but it has never been run there). ## 9. Development ``` dsh-midtalk/ ├── index.js host half: command registration, cancel, disk writes, event subscriptions ├── lib/core.js pure logic: frames→trace, trace→recovery card, injected text (zero deps; the self-test covers only this layer) ├── test/plugin.spec.mjs self-test (no framework, node built-ins only) ├── .github/workflows/test.yml CI: ubuntu-latest + windows-latest × node 22 ├── .gitignore / .gitattributes ├── cordis.patch.yml layer patch referenced by dsh.bundle.patch (used by Option A) ├── package.json ├── README.md / README.en.md └── CHANGELOG.md / LICENSE ``` ```bash node test/plugin.spec.mjs # dsh-midtalk self-test: 38 passed, 0 failed ``` The self-test covers `lib/core.js` only (it imports no DSH package, touches no filesystem and writes nothing under `.dsh`), so it runs anywhere, on any platform, with no `npm install`. ## 10. License MIT (see `LICENSE`).