# Architecture 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 `tools` service: - 9 for installation / status / triage / safe-mode / drain. - 3 for live session watch. - Wires the **tool error capture** to the public `tools/execute` and `tools/post-execute` cordis events. - Wires the **session watch** to the public `session/event` cordis event and the `ctx.agents` service (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/health` every 30 s. - Triage + simple / complex recovery within a 60 s ceiling per incident. - Generated by `dsh_doctor_install` and 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: ` | `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.js` only signals the PID stored in `~/.dsh/profiles/web/.dsh-web.pid`. It never invokes `pkill` or `killall`. 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.yml` is 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-lock` file — whoever holds the lock is the one currently driving the recovery. - The session watch **only** sends a `继续` user message through `agent.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-agent` or `@deepseek-ai/dsh-session` at 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.agents` service (every shipped dsh version does) and fires the `session/event` cordis 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 same `agent.followup` primitive. If you depend on `dsh-auto-continue`'s UI / notification bridge, keep it installed alongside dsh-doctor — they do not conflict.