Architecture
September 7, 2026 · View on GitHub
This document expands on the README's overview. It is the canonical reference for the watchdog state machine, the CLI doctor, the triage decision tree, the file layout under $DSH_HOME/doctor/, the in-process tool error capture, and the in-process session watch.
Scope (v0.2.0)
dsh-doctor v0.2.0 ships four independent jobs in one plugin:
| Job | Where it runs | Time budget |
|---|---|---|
| Web boot recovery | Standalone Node (watchdog.js) | 60 s ceiling per incident |
| CLI doctor | dsh doctor in your shell | No budget — runs to completion |
| Tool error capture | In-process, on the tools/* cordis events | Passive — never blocks the host |
| Live session watch | In-process, on the session/event cordis event | Tick every 30 s, never blocks the host |
All four are observable independently through dsh_doctor_status and the 12 model-facing tools.
Components
1. Plugin process — apply(ctx, config)
- Runs inside the dsh host process (cordis composition row).
- Registers 12 model-facing tools on the
toolsservice:- 9 for installation / status / triage / safe-mode / drain.
- 3 for live session watch.
- Wires the tool error capture to the public
tools/executeandtools/post-executecordis events. - Wires the session watch to the public
session/eventcordis event and thectx.agentsservice (both injected by the dsh host at load time).
2. Standalone watchdog — $DSH_HOME/doctor/watchdog.js
- A dep-free Node script (only
node:fs/path/os/http/child_process/crypto). - Runs as a per-user platform service (LaunchAgent on macOS, systemd user unit on Linux, Task Scheduler on Windows).
- Probes
http://127.0.0.1:$DSH_WEB_PORT/healthevery 30 s. - Triage + simple / complex recovery within a 60 s ceiling per incident.
- Generated by
dsh_doctor_installand replaced on every install.
3. Filesystem state — $DSH_HOME/doctor/
| File | Purpose |
|---|---|
config.json | Last-resolved config (env overrides applied). |
installed-marker | Write-once flag set by dsh_doctor_install. |
stopped-marker | Pause flag (set by dsh_doctor_pause). |
restart-lock | Set by the watchdog when it enters triage; the CLI doctor looks for this and disables its own 60 s budget if found. |
last-known-good | JSON snapshot of the last healthy profile patch. |
safe-mode.patch | Auto-generated patch that overrides every bundle in the profile with a no-op config except the allow-list. |
watchdog.js | The standalone script (regenerated on every install). |
watchdog.pid | Current watchdog pid. |
logs/watchdog.log (5 MB × 3) | Watchdog own log. |
logs/doctor.log (5 MB × 3) | Plugin process log. |
logs/tool-errors.log (5 MB × 3) | In-process tool error capture log. |
platform/... | The platform service spec (LaunchAgent / systemd / Windows XML + VBS). |
Watchdog state machine
┌──────────────────────────┐
│ IDLE │
│ 30 s health probe tick │
└────────────┬─────────────┘
│ /health fails 3×
▼
┌──────────────────────────┐
│ TRIAGE │
│ - read 200 log lines │
│ - regex pattern table │
│ - elapsed: ~5 s │
└────────────┬─────────────┘
│ simple plan
▼
┌──────────────────────────┐
│ SIMPLE PATH │
│ - write sibling patch │
│ to disable one row │
│ - kill recorded pid │
│ - start dsh web │
│ - elapsed: 10–25 s │
└────────────┬─────────────┘
│ /health still failing
│ or 60 s budget exceeded
▼
┌──────────────────────────┐
│ COMPLEX PATH │
│ - drop safe-mode patch │
│ - kill, start, probe │
│ - elapsed: 25–50 s │
└────────────┬─────────────┘
│ /health back
▼
┌──────────────────────────┐
│ RECOVERED │
│ - back to IDLE │
└──────────────────────────┘
If 60 s budget exceeded: back off to IDLE,
retry on the next probe tick (no thrashing).
CLI state machine
┌──────────────────────────┐
│ TRIAGE │
│ (same triage engine) │
└────────────┬─────────────┘
│ simple plan
▼
┌──────────────────────────┐
│ APPLY │
│ (same writeFileAtomic) │
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ VERIFY │
│ - restart dsh web │
│ - probe /health │
└────────────┬─────────────┘
│
┌────────┴────────┐
│ healthy? │
└───┬─────────┬──┘
yes │ │ no
▼ ▼
┌────────────┐ ┌────────────────┐
│ DONE │ │ ITERATE │
│ summary │ │ next plan / │
│ printed │ │ disable more │
└────────────┘ │ bundles │
│ (no budget) │
└────────────────┘
The CLI doctor does not exit until either:
- dsh web reports healthy and the human quits the doctor, or
- Every non-allow-listed bundle is disabled and the profile still fails to boot (then it exits with a summary telling the human what to do).
Triage decision tree
The triage engine runs a priority-ordered regex table against the last N log lines. The first match wins.
| Priority | Pattern | Plan |
|---|---|---|
| 100 | EADDRINUSE + :$PORT | kill-pid-and-restart (port is busy with the old pid) |
| 90 | Duplicate loader entry | disable-row (the second listed bundle) |
| 80 | Schema (parse|validation) error [^\n]*?(@scope/bad-pkg|^\S+$) | disable-row |
| 70 | Cannot find module | disable-row (the module name) |
| 60 | Plugin load error: <id> | disable-row |
| — | no match | safe-mode (complex path) |
Session watch state machine (per session)
┌──────────────────────────────────────────────┐
│ every session/event │
│ ├ turn/start → turnRunning = true │
│ ├ turn/end:completed → reset nudgesSent │
│ ├ turn/end:error → record lastFailure │
│ ├ user/message:user → reset nudgesSent │
│ └ everything else → lastEventAt = now │
└──────────────────────────────────────────────┘
│
│ every watchTickIntervalMs
▼
┌──────────────────────────────────────────────┐
│ candidate? │
│ turnRunning? AND │
│ now - lastEventAt ≥ watchIdleThresholdMs? │
│ nudgesSent < watchMaxNudgesPerSession? │
│ now - lastNudgeAt ≥ watchNudgeCooldownMs? │
│ user NOT currently driving? │
└────────────────────┬─────────────────────────┘
yes │
▼
┌──────────────────────────────────────────────┐
│ ctx.agents.get(sessionId).followup({ │
│ content: [{type: 'text', text: '继续'}], │
│ source: {kind: 'user'}, │
│ }) │
│ │
│ nudgesSent += 1; lastNudgeAt = now │
└──────────────────────────────────────────────┘
File layout
~/.dsh/doctor/
├── config.json # last-resolved config (env overrides applied)
├── installed-marker # { installedAt, version }
├── stopped-marker # pause flag
├── restart-lock # triage in progress
├── last-known-good # JSON snapshot
├── safe-mode.patch # current safe-mode override
├── watchdog.js # standalone dep-free script
├── watchdog.pid # current watchdog pid
├── platform/ # service spec (LaunchAgent / systemd / .vbs)
│ ├── com.dsh.doctor.plist # macOS
│ ├── dsh-doctor.service # Linux
│ ├── dsh-doctor.xml # Windows Task Scheduler
│ └── dsh-doctor.vbs # Windows hidden-launcher
└── logs/
├── watchdog.log
├── watchdog.log.1
├── watchdog.log.2
├── doctor.log
├── doctor.log.1
├── doctor.log.2
└── tool-errors.log
Recovery invariants
watchdog.jsonly signals the PID stored in~/.dsh/profiles/web/.dsh-web.pid. It never invokespkillorkillall. The in-process doctor writes the file at apply() with process.pid, and a stale entry is treated as already-dead by the kill branch.- Sibling-file patch writes mean your real
cordis.patch.ymlis never silently mutated. Inspect / revert at any time. - If 60 s elapses without a healthy probe, the watchdog backs off and retries on the next probe tick instead of thrashing.
- The CLI doctor and the watchdog coordinate through the
restart-lockfile — whoever holds the lock is the one currently driving the recovery. - The session watch only sends a
继续user message throughagent.followup. It does not modify model state, tool calls, or the waterfall.
Tool error capture flow
dsh host
│
│ any tool call
▼
tools/pre-execute ◀── pre flight (we don't act here)
│
▼
tools/execute ◀── waterfall `next()` then post
│ ├── before next(): nothing
│ ├── result.isError === true
│ └── after next(): classify → record → log
▼
tools/post-execute ◀── last-chance hook (also observes)
│
▼
result returned to caller
Why the watchdog is dep-free
The watchdog has to keep working when:
- dsh cannot spawn a child because of a broken bundle.
- pnpm/Node is in a weird state because of a half-installed plugin.
So the watchdog is shipped as a generated, dependency-free Node script.
It only uses node:fs/path/os/http/child_process/crypto. If Node still
runs, the watchdog runs.
Why the tool error capture and session watch are in-process
The tools/* and session/event cordis events are only fired inside
the dsh host process. A standalone watchdog cannot observe them. The
capture + watch run inside apply(ctx, config) and live as long as
the host lives. The standalone watchdog writes its own log
(logs/watchdog.log); the in-process capture writes
logs/tool-errors.log. The two never share state — that is intentional,
so a crash in the host does not corrupt the watchdog's log and vice versa.
Why the session watch does not require dsh-agent / dsh-session as deps
The community dsh-auto-continue plugin (HsiangNianian) implements the
same feature with import type from @deepseek-ai/dsh-agent /
@deepseek-ai/dsh-session. That works for that plugin because it ships
as a single built bundle and resolves those packages from the dsh host's
node_modules at runtime through the same pnpm profile that the host
itself uses.
For dsh-doctor we took a more conservative path: src/session-watch.ts
declares only the structural types it needs (DshAgent,
DshAgentsService, DshSession, DshSessionEvent) and accesses the
cordis context via duck-typing ((ctx as any).agents,
(ctx as any).on('session/event', ...)). This means:
- The plugin never
require()s@deepseek-ai/dsh-agentor@deepseek-ai/dsh-sessionat runtime. - We do not list those packages as devDependencies, peerDependencies, or peerDependenciesMeta — we don't need them to typecheck because the types are local.
- The plugin works as long as the dsh host provides the standard
ctx.agentsservice (every shipped dsh version does) and fires thesession/eventcordis event (every shipped dsh version does). If the host does not provide them, the watch degrades to a no-op and logs a warning — the other 9 tools and the standalone watchdog still work.
The trade-off is that if dsh ever renames ctx.agents or
session/event, the watch will silently no-op until a dsh-doctor
release catches up. We accepted this risk because the dsh host
internals (the names above) have been stable since rc.6 and the watch
is intentionally opt-out (watchEnabled: false).
Relationship to other community plugins
dsh-daemon(chenkai2) — independent of dsh-doctor. Both can be installed at the same time and provide layered protection. dsh-doctor explicitly does not call dsh-daemon.dsh-auto-continue(HsiangNianian) — overlaps with the session watch. dsh-doctor's v0.2.0 watch re-implements the core feature (idle detection + nudge) inside the dsh-doctor bundle, using the sameagent.followupprimitive. If you depend ondsh-auto-continue's UI / notification bridge, keep it installed alongside dsh-doctor — they do not conflict.