sandbase-harness
sandbaseai
Local-first, self-hosted AI agent runtime and MCP bridge with sandboxed sessions, memory, credentials, audit/replay, and a local Console.
d86e/dsh-doctor
dsh-doctor: self-healing watchdog for the DeepSeek Harness web profile. Recovers from plugin-induced boot failures within 60s, runs an unbounded CLI doctor, captures every tool error, and watches all live sessions for stuck turns.
PROJECT TOPICS
INSTALL REFERENCE
dsh plugin --profile web add github:d86e/dsh-doctor
该命令指向仓库当前默认分支;尚无绑定当前 commit 的完整验证结果。
PROJECT README
Self-healing watchdog for the DeepSeek Harness web profile. Recovers from plugin-induced boot failures within a 60-second downtime budget. Captures every tool error through the official
tools/*event hooks. Watches every live session and nudges stuck turns back to life. (Adsh doctorCLI subcommand for already-broken installs is planned for v0.3.0.)
dsh-doctor runs as an independent Node process (LaunchAgent on macOS, systemd user unit on Linux, Task Scheduler on Windows) so it survives even when dsh web cannot spawn a child. It is fully independent of dsh-daemon: it does not call it, does not require it, and does not conflict with it. If both are installed, you get layered protection.
Four jobs, in one plugin:
| Job | Where it runs | Time budget | Trigger |
|---|---|---|---|
| 1. Web boot recovery | Standalone Node process (LaunchAgent / systemd / Task Scheduler) | 60 seconds per incident | dsh web health probe fails N times in a row |
| 2. CLI doctor | (planned for v0.3.0) | — | — |
| 3. Tool error capture | In-process, attached to the tools/* cordis event waterfalls |
Passive — never blocks the host | Any tool call fails in any session |
| 4. Live session watch | In-process, attached to session/event |
Tick every 30 s, no events lost | A turn is running with no new event for watchIdleThresholdMs (default 3 min) |
Every 30 s the watchdog probes http://127.0.0.1:$DSH_WEB_PORT/health. After 3 consecutive failures it enters the triage state machine, with a hard 60-second ceiling on total downtime per incident.
Simple path (~10 s) — the most common case, a single broken plugin:
~/.dsh/profiles/web/cordis.patch.yml (write the change to a sibling cordis.patch.yml.dr-disabled-<bundle> file — your original is never edited).Complex path (≤60 s) — multiple bundles, the simple path didn't work, or the failure is unknown:
safeMode: true), except an explicit allow-list (safeModeBundles, default ["dsh-core"]).restart-lock marker so a dsh doctor invocation running in parallel can skip the 60 s budget and work unbounded until safe mode is gone.Invariants enforced on every recovery:
~/.dsh/profiles/web/.dsh-web.pid. It never invokes pkill, killall, or any pattern-killer. The in-process doctor writes that file at apply() time with process.pid (it runs inside dsh web, so that pid IS the web's); a stale pid reads as the already-dead case the kill branch exists for.cordis.patch.yml is never silently mutated. Inspect / revert at any time.⚠️ Not shipped yet. A future release will add a
dsh doctorsubcommand that runs the same triage + recovery engine in the foreground with no time budget, for users whosedsh webis so broken it never even started. Today, the only recovery path is the in-process plugin + standalone watchdog (jobs 1 + 3 + 4 above).Until then, the recommended way to recover a completely dead install is: ask any working dsh agent to call
dsh_doctor_diagnoseto identify the failing bundle, then manually disable that bundle in~/.dsh/profiles/web/cordis.patch.ymlanddsh webto restart.Track progress: https://github.com/d86e/dsh-doctor/issues
Every tool call in dsh passes through a tools/* cordis event waterfall. dsh-doctor subscribes to tools/execute and tools/post-execute and runs every failed result through a classifier (default: transient / agent / business) and a policy (default: record + log, optionally defer). The doctor never mutates the waterfall itself — it observes.
The 9th model-facing tool, dsh_doctor_drain_deferred(sessionId), lets the agent pull queued errors at a quiet moment in the current turn and decide what to do.
Every session in the dsh process emits a session/event (turn/start, turn/end, tool/call, tool/result, user/message, assistant/message, …). dsh-doctor keeps a per-session state machine:
┌──────────────┐
│ event fires │ ◀── every session event
└──────┬───────┘
│
┌──────▼───────┐
│ reset idle │
│ counter │
└──────┬───────┘
│
(every watchTickIntervalMs)
│
┌──────▼───────────────────┐
│ is turn running? │
│ is no-event-time > N? │
│ cooldown elapsed? │
│ nudgesSent < cap? │
│ user is not currently │
│ driving the session? │
└──────┬───────────────────┘
yes │
┌──────▼────────────────────┐
│ agent.followup("继续") │
│ count +1 │
└───────────────────────────┘
The doctor does not write code, does not run commands, does not touch the model. It sends a 继续 user message through the same agent.followup primitive the dsh community's dsh-auto-continue uses. If the agent is in a true infinite loop, dsh_doctor_watch_cancel is also exposed for explicit user intervention.
Manual control tools are also exposed: dsh_doctor_watch_list shows every tracked session, dsh_doctor_watch_nudge lets you inject a custom message, dsh_doctor_watch_cancel aborts the current turn with kind: 'user' so it is not confused with a system stop.
You want dsh-doctor if you:
dsh web for hours because you had to SSH in, read logs, edit JSON, and restart by hand.dsh-auto-continue and want a more thorough, self-contained solution (dsh-doctor subsumes its core logic — keep using both if you depend on its UI; or uninstall it once you upgrade to dsh-doctor ≥ 0.2.0).dsh-daemon (they don't conflict; dsh-doctor also covers the "dsh web came up at all" case).You do not want dsh-doctor if:
# Install into the web profile (the only profile dsh-doctor supports today)
dsh plugin --profile web add https://github.com/d86e/dsh-doctor.git
dsh plugin --profile web reload
You can pin a version with a git ref:
# Pin a specific tag
dsh plugin --profile web add https://github.com/d86e/dsh-doctor.git#v0.2.0
# Or a branch
dsh plugin --profile web add https://github.com/d86e/dsh-doctor.git#main
The doctor is published as a git repository rather than an npm package because
DSH plugins are loaded as cordis.patch.yml composition rows by the host
process — they never need to be require()'d from a node_modules tree.
Git install keeps the version control simple: a git pull && dsh plugin reload
is the entire upgrade story.
That's the entire install story. On the very first load, the doctor plugin automatically:
~/.dsh/doctor/watchdog.js (standalone dep-free Node).http://127.0.0.1:3080/health
every 30 s.The auto-install runs as a detached child process so a slow launchctl
or systemctl call never blocks dsh boot. It is idempotent — every
subsequent plugin load checks for the script + service + running pid, and
does nothing if everything is already in place.
The 12 dsh_doctor_* tools also register as soon as the plugin loads.
You can call dsh_doctor_status from any agent to confirm:
> dsh_doctor_status
{
"installed": true,
"running": true,
"pid": 25632,
"platform": "darwin",
...
}
Set autoInstall: false in cordis.patch.yml:
- insert:
- id: dsh-doctor
name: '@d86e/dsh-doctor'
config:
autoInstall: false
or the env var DSH_DOCTOR_AUTO_INSTALL=0. You can then install the
watchdog manually through the model-facing dsh_doctor_install tool:
> dsh_doctor_install
dsh_doctor_install supports dryRun: true to preview the writes without
actually registering the service, and a purgeLogs: true flag in
dsh_doctor_uninstall to also delete ~/.dsh/doctor/logs/.
If you prefer npm, the same source is also published as @d86e/dsh-doctor:
dsh plugin --profile web add @d86e/dsh-doctor
dsh plugin add accepts a git URL, a <owner>/<repo> shorthand, or an npm
package name — they all end up in the same place.
Useful for a single session, no install. Ask any agent:
> Use the dsh doctor from https://raw.githubusercontent.com/d86e/dsh-doctor/v0.2.0/lib/index.js
Then call dsh_doctor_install when you are ready to make it permanent.
After install, you can ask the agent:
> dsh_doctor_status
> dsh_doctor_diagnose
> dsh_doctor_watch_list
> dsh_doctor_safe_mode_enter
> dsh_doctor_drain_deferred
From a shell:
# (Planned) dsh doctor # unbounded CLI doctor (foreground)
# (Planned) dsh doctor --dry-run # triage only, no writes
To uninstall:
> dsh_doctor_uninstall
See docs/ARCHITECTURE.md for the full state machine, file layout, and inter-process protocol.
┌──────────────────────────────────────────────────────────────┐
│ dsh web process (cordis composition rows) │
│ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ tools/ event hooks │ │ session/event hooks│ │
│ └────────┬───────────┘ └─────────┬──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ dsh-doctor (apply) │ │
│ │ ├ tool error capture (waterfall listener) │ │
│ │ ├ session watch (timer + ctx.agents) │ │
│ │ └ 12 dsh_doctor_* tools │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│ (file-system state)
▼
┌──────────────────────────────────────────────────┐
│ $DSH_HOME/doctor/ │
│ ├ watchdog.js standalone dep-free script │
│ ├ watchdog.pid current watchdog pid │
│ ├ installed-marker plugin-version stamp │
│ ├ stopped-marker pause flag │
│ ├ safe-mode.patch auto-generated disable │
│ └ logs/ │
│ ├ watchdog.log (5MB × 3 rotation) │
│ ├ doctor.log (5MB × 3 rotation) │
│ └ tool-errors.log │
└──────────────────────────────────────────────────┘
▲
│ (HTTP /health probe)
│
┌────────────────────────┴─────────────────────────┐
│ watchdog.js (LaunchAgent / systemd / Task Sched) │
│ - 30 s health probe │
│ - triage + simple/complex recovery │
│ - no pkill, no killall, no remote fetch │
└────────────────────────────────────────────────────┘
All knobs can be set either in cordis.patch.yml (under config:) or via DSH_DOCTOR_* environment variables.
| Field | Env var | Default | Description |
|---|---|---|---|
healthIntervalMs |
DSH_DOCTOR_HEALTH_INTERVAL |
30000 |
Health probe period |
healthFailuresToRecover |
DSH_DOCTOR_HEALTH_FAILURES |
3 |
Failures before triage |
recoveryBudgetMs |
DSH_DOCTOR_BUDGET_MS |
60000 |
Hard ceiling per incident (watchdog) |
logMaxBytes |
— | 5242880 |
Per-log rotation size |
logBackups |
— | 3 |
Rotated log files kept |
safeModeBundles |
— | ["dsh-core"] |
Bundles kept in safe mode |
toolErrorCapture |
DSH_DOCTOR_TOOL_ERROR_CAPTURE |
true |
Subscribe to tools/* |
toolErrorMaxQueue |
DSH_DOCTOR_TOOL_ERROR_QUEUE |
500 |
Per-session queue cap |
watchEnabled |
DSH_DOCTOR_WATCH_ENABLED |
true |
Master switch for session watch |
watchIdleThresholdMs |
DSH_DOCTOR_WATCH_IDLE_MS |
600000 |
Idle timeout (10 min) |
watchNudgeCooldownMs |
DSH_DOCTOR_WATCH_COOLDOWN_MS |
300000 |
Min interval between nudges |
watchMaxNudgesPerSession |
DSH_DOCTOR_WATCH_MAX_NUDGES |
3 |
Cap before giving up |
watchContinueText |
DSH_DOCTOR_WATCH_TEXT |
"继续" |
Text to inject (supports {elapsed}, {turn}, {sessionId}) |
watchTickIntervalMs |
DSH_DOCTOR_WATCH_TICK_MS |
30000 |
Idle-check period |
If the default classification (network/5xx/429 → transient, 401/403/quota/context-overflow → agent, else → business) is wrong for your stack, pass your own classifier / policy when you register the plugin from a wrapper bundle. Both functions receive the full ToolErrorContext and return synchronously.
watchContinueText accepts the placeholders {elapsed} (seconds since last event), {turn} (current turn number), {sessionId}. So "已经过去 {elapsed} 了,请继续第 {turn} 步" works.
13 model-facing tools, all dsh_doctor_* prefixed.
| Tool | Purpose |
|---|---|
dsh_doctor_install |
Generate the standalone watchdog and platform service |
dsh_doctor_uninstall |
Unregister, remove state files (logs optional) |
dsh_doctor_status |
Installed? running? uptime? last 5 recoveries? watch snapshot? |
dsh_doctor_pause |
Stop recovery, keep probing |
dsh_doctor_resume |
Re-enable recovery |
dsh_doctor_diagnose |
One-shot triage, no writes |
dsh_doctor_recent_log |
Tail one of the doctor-managed logs (web / watchdog / doctor / tool-errors) |
dsh_doctor_safe_mode_enter |
Manually drop a safe-mode patch |
dsh_doctor_safe_mode_exit |
Remove the safe-mode patch |
dsh_doctor_drain_deferred |
Pull queued agent-class tool errors for a session |
dsh_doctor_watch_list |
List every session the doctor is tracking |
dsh_doctor_watch_nudge |
Manually inject a "继续" message into a session |
dsh_doctor_watch_cancel |
Cancel the current turn of a session (kind=user) |
By default the doctor observes tool errors; it never retries and never mutates the waterfall. To change that, the installToolErrorCapture API is exported for wrapper bundles:
import { installToolErrorCapture, defaultClassify, defaultPolicy } from '@d86e/dsh-doctor/tool-errors'
installToolErrorCapture(ctx, config, log, {
// user classifier: nil-pointers count as agent-class
(ctx) => ctx.message.includes('panic') ? 'agent' : null,
}, defaultPolicy)
See src/tool-errors.ts for the full contract.
The doctor keeps a per-session state machine in-process. It is read-only with respect to the model and the agent's tool calls — the only action it takes is agent.followup({content: [{type: 'text', text: '继续'}], source: {kind: 'user'}}).
Three protections against over-firing:
watchNudgeCooldownMs apart.watchMaxNudgesPerSession times in its lifetime is left alone until the next turn/end:completed.user/message (source.kind === 'user') arrived within 5 s of the candidate nudge time, the doctor steps back and assumes the human is driving.The watch is degrades silently if the dsh host does not expose ctx.agents (i.e. an older dsh). The 12 tools still work, the watchdog still works — only the in-process nudging is gone.
127.0.0.1 only).pkill / killall anywhere. Only the recorded ~/.dsh/profiles/web/.dsh-web.pid is signaled.cordis.patch.yml. Disable / safe-mode actions write to sibling files (cordis.patch.yml.dr-disabled-<bundle>, cordis.patch.yml.dr-safemode).node:fs/path/os/http/child_process/crypto). It runs even if dsh cannot start its own node_modules.dsh_doctor_safe_mode_exit is idempotent — running it twice is safe.Done:
Future:
dsh_doctor_simulate — run a fake failure end-to-end against a test profile to validate the watchdog.git clone https://github.com/d86e/dsh-doctor
cd dsh-doctor
pnpm install
pnpm test # 83 unit tests
pnpm run build # tsc → lib/
pnpm run typecheck
tests/dsh-smoke.sh is a shell-only smoke that builds → packs → would dsh plugin add. It is skipped in CI because the CI host does not have dsh installed.
See CONTRIBUTING.md.
See SECURITY.md. Report vulnerabilities via the GitHub Security tab — do not file a public issue.
MIT — 2026 Tommy (d86e).
CLASSIFICATION EVIDENCE
系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: doctor、self-healing。