# dsh-harden
A runtime hardening layer for DSH — every stop must have a clear reason
中文 ·
GitHub
> **Status**: guard rules H1, H2 and H3 are all implemented; **all 184 tests pass**, typecheck is clean, and the build succeeds.
## What it solves
A model turn should **fail loudly** when something goes wrong. But sometimes a failure is swallowed by the framework, the turn **looks like it completed normally** — and the conversation stalls with no visible explanation.
dsh-harden attaches to key points in the agent lifecycle and catches these **silent failures**.
| Case | Today |
|---|---|
| API expired / rate-limited (429), explicit failure after retries are exhausted | ✅ The platform already does this, and users **accept** it |
| A tool call fails but is swallowed, and the turn "looks like" it completed | ✅ **Covered by this project** |
| A turn's last step emits only reasoning, with neither reply text nor a tool call | ✅ **Covered by this project** |
| A network request is interrupted and the official retry budget runs out | ✅ **Covered by this project** |
In one sentence: **an explicit failure is fine; a silent stop is not.**
## Features
- **Continue after a failed tool call** — when a tool call fails and the framework still treats the turn as complete, a note is injected so the model retries. (needs **Failure warning prefixes** filled in on the settings page, otherwise this rule stays off)
- **Recover a turn that ends without any reply text** — when the last step has only reasoning, the turn is pulled back for one more step.
- **Continue after a network interruption** — after the official retry budget runs out, the plugin adds extra attempts for the failure kinds you list.
- **Background task tool** — gives the model a `job_background` tool to run long commands in the background without blocking the turn.
- **Unbounded and switchable** — no cap on injections, and every guard rule has its own toggle (the write-side protection is always on).
- **Never touches your config or patches the platform** — switches live in the platform `settings`; it only listens to public events and patches no `@deepseek-ai/*` package.
- **One-click repair of corrupted session logs** — when a session log gets corrupted and the platform refuses to open the history, the settings page scans and repairs it in one click.
- **Automatic context compaction** — once the conversation grows past a threshold, the older history is condensed into a summary; the UI reuses the platform's own notice.
- **Aggregate subagent notices** — when several subagents finish in parallel, their completion notices are held back and delivered together as one message once every subagent is done, instead of waking the model one by one. Off by default.
- **Automatic repair for broken subagent sessions** — fixes the case where a session suddenly refuses to open while subagents are running. The plugin corrects non-conforming retry events before they are stored; a light note appears at the end of the turn when a correction happens. It is **always on and has no toggle**.
## Problem screenshots
When a session log gets corrupted, the platform throws an error and the whole history becomes unopenable; the plugin scans and repairs it in one click from the settings page:
When a tool call fails and gets swallowed, the plugin asks the model to retry, so the turn no longer ends for no reason:
When a turn ends with only reasoning and no reply text, the plugin asks the model to add the missing reply, so the turn no longer ends for no reason:
## Screenshots
## Install
Pick one of the three routes, then register the plugin in a profile — that step is required (see below).
### 1. From npm
```bash
npm install dsh-harden
```
### 2. From a GitHub Release
Download `dsh-harden-.tgz` from [Releases](https://github.com/thissensen/dsh-harden/releases):
```bash
dsh plugin --profile web add file:
```
### 3. From source
```bash
git clone https://github.com/thissensen/dsh-harden.git
cd dsh-harden
pnpm install
node scripts/link-deps.mjs # link host packages into this project's node_modules
pnpm build # tsc (host) + vite (client)
```
The first build must finish before DSH starts (the client shell is bound by the DSH startup snapshot).
## Register in a profile
The routes above only put the files in place; you still have to register `dsh-harden` in a profile so DSH loads it at startup.
**Desktop**: edit `~/.dsh/profiles/desktop/package.json` by hand in two places:
1. add `"dsh-harden": "link:"` to `dependencies` (use a version range if you installed from npm);
2. add `"dsh-harden"` to the `dsh.profile.bundles` array.
Then run `pnpm install` in that directory and **restart the desktop app**.
**Web**: one CLI command
```bash
dsh plugin --profile web add link:
```
## Settings
The settings page lives under the "DSH Optimize" section and has **eight** cards:
| Card | Description |
|---|---|
| **Continue after a failed tool call** | Toggle. A swallowed tool-call failure that still ends the turn as "complete" causes a note to be injected so the model retries. Also has **Failure warning prefixes**: the fixed opening of the platform warning, matched against the start of the whole reply text (lines are **not** split), case-sensitive. **Empty by default — empty means rule H1 stays off.** Example: `⚠ Could not execute tool`. |
| **Turn ends without a reply** | Toggle. A turn whose last step has only reasoning is pulled back for one more step. |
| **Continue after a network interruption** | Toggle plus **Network retry count** (0–99 extra attempts after the official retries run out; 0 disables it) and **Failures that trigger a retry** (failure codes `SERVER` / `RATE_LIMIT` / `TIMEOUT` / `TRANSPORT` and HTTP statuses `502` / `429`; empty retries nothing). |
| **Automatic context compaction** | Toggle plus **Compaction scope** (a dropdown: All / Main only / Subagents only — default All), **Trigger threshold** (e.g. `200K` / `1M` / `100000`; the plugin default is `200K`) and **Summary instruction** (the instruction sent to the model when a summary is generated). Once the session exceeds the threshold, an earlier stretch of history is condensed into a summary and the platform's own "context compacted" notice is shown. |
| **Background task tool** | Toggle. When on, the model can start background commands with `job_background` and manage them with `job_list` / `job_output` / `job_kill`. When off, the tool disappears. |
| **Aggregate subagent notices** | Toggle, **off by default**. When several subagents finish in parallel, their completion notices are held back and delivered together as one message once every subagent is done, instead of waking the model one by one. |
| **Automatic repair for broken subagent sessions** | **No toggle — always on.** When the platform is about to store a non-conforming retry event, the plugin corrects it before it is written, so the session never becomes unopenable. A light note appears at the end of the turn when a correction happens. |
| **Repair corrupted sessions** | Scan button. Scans every session log and repairs the known kinds of corruption, so a history that refuses to open becomes usable again. |
> ⚠️ **The one that matters most**: **"Failure warning prefixes" is empty by default, so leaving it blank after install means rule H1 is off.** Open the settings page and paste the fixed beginning of your platform's warning text (example: `⚠ Could not execute tool`).
## Development
```bash
pnpm install
node scripts/link-deps.mjs # link host packages into this project's node_modules
pnpm build # tsc (host) + vite (client)
pnpm typecheck # type check only
pnpm test # vitest, 184 tests
```
## Design
- **Public platform events only.** Rules H1 (failed tool call) and H2 (turn without a reply) hang on the serial `agent/turn-stopping` hook: as the turn is about to close it reads the last step's text, and on a hit calls `agent.steer()` to re-inject guidance. Rule H3 hangs on the waterfall `agent/request-error`: it first `await next()` so the official retry decides, and only steps in after that gives up.
- **Unbounded guard rules.** A hit injects a correction note with no cap; a single counter is kept for logging only.
- **Absent means do not interfere.** Unreadable configuration falls back to defaults; the plugin mounts without optional services.
## License
[MIT](./LICENSE)