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:

JobWhere it runsTime budget
Web boot recoveryStandalone Node (watchdog.js)60 s ceiling per incident
CLI doctordsh doctor in your shellNo budget — runs to completion
Tool error captureIn-process, on the tools/* cordis eventsPassive — never blocks the host
Live session watchIn-process, on the session/event cordis eventTick 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/

FilePurpose
config.jsonLast-resolved config (env overrides applied).
installed-markerWrite-once flag set by dsh_doctor_install.
stopped-markerPause flag (set by dsh_doctor_pause).
restart-lockSet by the watchdog when it enters triage; the CLI doctor looks for this and disables its own 60 s budget if found.
last-known-goodJSON snapshot of the last healthy profile patch.
safe-mode.patchAuto-generated patch that overrides every bundle in the profile with a no-op config except the allow-list.
watchdog.jsThe standalone script (regenerated on every install).
watchdog.pidCurrent 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.

PriorityPatternPlan
100EADDRINUSE + :$PORTkill-pid-and-restart (port is busy with the old pid)
90Duplicate loader entrydisable-row (the second listed bundle)
80Schema (parse|validation) error [^\n]*?(@scope/bad-pkg|^\S+$)disable-row
70Cannot find moduledisable-row (the module name)
60Plugin load error: <id>disable-row
no matchsafe-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.