Development & Runtime

dsh-daemon

chenkai2/dsh-daemon · ★ 10

chenkai2

Registers the DeepSeek Harness web server (dsh web) as an auto-start, self-healing background service — LaunchAgent / systemd / cron plus a 30s health-check watchdog — with seven dsh_daemon_* tools (install/uninstall/reinstall/status/start/stop/update).

License
MIT
Added
2026-08-21
Maintenance
active
Install kind
github
npm
@chenkai114/dsh-daemon

No manifest check is available; listing does not imply compatibility or security review.

安装

dsh plugin --profile web add github:chenkai2/dsh-daemon

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: en · Read Chinese README

Table of contents
  1. Compatibility (dsh-daemon vs dsh)
  2. dsh-daemon
  3. Usage
  4. Option A — install with dsh plugin, mount as a composition row
  5. Option B — dynamic Cordis plugin (no install)
  6. Port
  7. Architecture
  8. 1. Platform registration
  9. 2. The watchdog loop
  10. 3. Daemon-aware start / stop
  11. Tools
  12. Command line (dsh-daemon)
  13. State files ($DSH_HOME/daemon/, $DSH_HOME defaults to ~/.dsh)
  14. Auto-update
  15. Verification
  16. Local test
  17. v0.1.29 — dsh compatibility gate for auto-update, diagnosable check failures
  18. v0.1.28 — no more false kills, and the restart storm is closed
  19. v0.1.27 — 10 s health checks, diagnosable failed starts (dsh-daemon logs)
  20. v0.1.19 — dsh web token-auth adaptation (seeding the ?token= launch token)
  21. v0.1.18 — Windows black-box fix: hidden console, not no console; start waits for health
  22. v0.1.17 — pass --no-open only when the dsh version supports it
  23. v0.1.16 — health check, browser pop-ups, and env forwarding
  24. v0.1.15 — test/sandboxed installs no longer touch the host
  25. v0.1.14 — restart is now the default auto-update mode
  26. v0.1.13 — watchdog regenerated automatically after auto-update
  27. v0.1.12 — Windows console-flash regression fix
  28. License

Compatibility (dsh-daemon vs dsh)

dsh-daemon Supported dsh Notes
0.1.29 0.1.x and 0.2.x dsh compatibility gate for auto-update: every release declares dsh.requires.dsh, and the updater compares it with the installed dsh version — unsatisfied / undeclared / unreadable dsh version skips the automatic update (a human can force it with dsh-daemon update --apply); the registry probe now retries twice with a 15 s budget and classifies failures (timeout / DNS / HTTP / connection), and status shows the last successful check
0.1.28 0.1.x and 0.2.x Fixes health-check false kills and the restart storm: per-probe /health budget 5 s → 20 s, failure threshold 3 → 5 (~50 s of real misses before a restart); serialized ticks (tickBusy) plus a single-flight launch() so two dsh web processes can no longer be spawned in the same second (EADDRINUSE); adopts an unmanaged dsh web that already serves the port (corrects the pid file) instead of launching a second one; verifies that a PID really is a dsh web before signalling it (no more killing a recycled PID); status reports a pid-file/listener mismatch; refuses non-JS entries (dsh.ps1/.cmd shims) with a clear error
0.1.27 0.1.x and 0.2.x Health-check interval default 30 s → 10 s (faster self-heal); adds dsh-daemon logs and dsh_daemon_logs: a failed dsh web start now prints a systemctl-style details pointer (web + watchdog log tails, /health, PID, launch age)
0.1.26 0.1.x and 0.2.x Startup grace: a slow-booting dsh web (large plugin set, cold caches) is no longer declared dead after ~90 s and restarted forever — health misses inside the grace never count (DSH_DAEMON_STARTUP_GRACE, default 5m); stopping the previous server now sends SIGTERM before SIGKILL and waits for the port to be free (fixes the EADDRINUSE restart storm); status / start report "still starting" honestly
0.1.25 0.1.x and 0.2.x Adapts to the renamed shell service in 0.2.x: run(spec) → execute(spec) plus a handle's result() (0.2.1 failed reinstall with shell.run is not a function), with tolerant result shapes
0.1.24 0.1.x (parts of 0.2.x broken) Adds the OIDC publish pipeline; does not adapt to the 0.2.x shell API
0.1.23 ≥ 0.1.2-alpha.1 (token auth) Registration-drift self-heal, honest registration status, refuses ephemeral DSH_HOME
0.1.21–0.1.22 same Single-instance guard verifies cmdline; explicit cwd for dsh web
0.1.20 same DSH_DAEMON_TRUSTED_HOST: appends --trusted-host behind a reverse proxy
0.1.19 same Opens the browser once to seed the token-auth cookie
≤ 0.1.18 ≥ 0.1.0-rc.8 (--no-open) Older dsh lacks --no-open; the watchdog omits the flag

Thresholds are read from code (lib/index.js): DSH_NO_OPEN_MIN = 0.1.0-rc.8, DSH_TOKEN_AUTH_MIN = 0.1.2-alpha.1. The daemon touches only three dsh interfaces: web launch flags, the /health probe, and the injected shell/fs services — the last being the one 0.2.x changed.


dsh-daemon

中文 | English

Register the DeepSeek Harness web server (dsh web) as an auto-start, self-healing background service.

After install, dsh web:

  • starts automatically on login (LaunchAgent RunAtLoad / systemd WantedBy=default.target / cron @reboot),
  • restarts automatically after sleep/wake,
  • self-heals: a watchdog health-checks http://127.0.0.1:<port>/health every 10 s (configurable) and restarts the server after 3 consecutive failures,
  • tolerates slow boots: while a freshly launched dsh web is inside its startup grace (DSH_DAEMON_STARTUP_GRACE, default 5m) health misses never count towards a restart and the process is never killed,
  • survives this session: the watchdog is a standalone generated script, not an in-memory plugin.

The currently running session is never touched by install/uninstall.

For the account map (npm scope / GitHub account) see CONTEXT.md.


Usage

Option A — install with dsh plugin, mount as a composition row

  1. Install the package into the web profile with the official plugin manager (runs pnpm in the profile directory, so the loader can resolve it; a plain global install is not enough — see below):

    dsh plugin --profile web add @chenkai114/dsh-daemon
    

    (needs pnpm on PATH — enable it once with corepack enable.)

    Why not just npm install -g? The loader imports name: rows with Node's ESM resolution anchored at the profile directory (~/.dsh/profiles/web/); the global node_modules is not on that resolution chain (and NODE_PATH does not apply to ESM). The profile's own node_modules — managed here by pnpm — is what makes the package reachable.

  2. Restart dsh web. The package declares a dsh.bundle manifest, so dsh plugin add automatically appends it to dsh.profile.bundles and it mounts as a bundle layer at boot — you do not need (and must not) also insert the same row manually into ~/.dsh/profiles/web/cordis.patch.yml, or boot fails with duplicate loader entry id: dsh-daemon.

    The seven dsh_daemon_* tools then become available to every agent — just ask the agent to run dsh_daemon_install.

To upgrade later: dsh plugin --profile web update @chenkai114/dsh-daemon (plus a restart).

⚠️ Upgrading from v0.1.8 or earlier: if you previously followed the old docs and added a manual - insert: dsh-daemon row to ~/.dsh/profiles/web/cordis.patch.yml, you must delete that row (keep anything else in the file) after upgrading — otherwise the bundle layer and the manual layer insert the same id: dsh-daemon twice and dsh web fails to boot with duplicate loader entry id. Restart after deleting it.

Permissions: the daemon manages per-user system services (LaunchAgent plists, state files under $DSH_HOME), so the plugin requests danger-full-access for its file and command operations. On a deployment that denies escalation the tools fail with sandbox denials.

Option B — dynamic Cordis plugin (no install)

Paste the content of lib/index.js into the code.host field of cordis_define and run it. This is how the plugin is developed and verified in a live session: the sandbox supplies the harness global, and the file ends with return plugin;.

Port

Default port is the currently listening webServer port (usually 3080), then DSH_WEB_PORT, then the explicit port tool argument. After changing the port, run dsh_daemon_reinstall.


Architecture

The daemon is a watchdog supervisor made of three parts.

1. Platform registration

A per-user service that starts the watchdog at login and keeps it alive:

Platform Mechanism
macOS LaunchAgent ~/Library/LaunchAgents/com.deepseek-ai.dsh-watchdog.plist — ProgramArguments=[node, watchdog.js], RunAtLoad, KeepAlive{SuccessfulExit:false}, ThrottleInterval=10, environment carries DSH_WEB_PORT and DSH_HOME. Loaded with launchctl load -w.
Linux systemd user unit ~/.config/systemd/user/dsh-watchdog.service — Type=simple, Restart=always, RestartSec=10, StartLimitIntervalSec=0; enabled with systemctl --user enable --now. Falls back to a cron @reboot entry when systemd is unavailable.
Windows VBS launcher + Task Scheduler — task DshWatchdog (XML in $DSH_HOME/daemon/dsh-watchdog-task.xml, UTF-16LE) runs wscript.exe //B dsh-watchdog.vbs at logon; the VBS sets DSH_WEB_PORT/DSH_HOME and starts node watchdog.js hidden. RestartOnFailure PT1M/999, MultipleInstancesPolicy=IgnoreNew. Registered with schtasks /Create.

Windows support is implemented mirroring the macOS/Linux behavior (the plugin's shell layer switches to PowerShell, which is the DSH shell executor on win32) but has not yet been verified on a real Windows machine.

2. The watchdog loop

The generated standalone script $DSH_HOME/daemon/watchdog.js (dependency-free, runs on any Node ≥ 18, no session required):

  • writes its PID to .dsh-watchdog.pid; SIGINT / SIGTERM / SIGHUP clean up and exit; a single-instance lock refuses duplicate watchers;
  • at startup, launches the web server (node <dsh> web --port <port>, detached, output to logs/dsh-web.log) if http://127.0.0.1:<port>/health is not OK;
  • then every 10 s (configurable via DSH_DAEMON_HEALTH_INTERVAL):
    • skips when .daemon-stopped exists (user paused monitoring) or .daemon-restart.lock is fresh (< 120 s, a restart is in progress);
    • restarts the server when a tick gap exceeds 90 s (sleep/wake);
    • logs "still starting" (never kills, never counts failures) while the process is alive inside its startup grace (.dsh-web.started younger than DSH_DAEMON_STARTUP_GRACE, default 5m) — a dsh web with a large plugin set or cold caches can take tens of seconds to minutes before it serves /health, and the old hard 90 s verdict made it impossible to start;
    • restarts immediately when the process is gone (crash, EADDRINUSE during plugin activation, OOM) instead of waiting out the failure threshold;
    • restarts the server after 3 consecutive failed health checks (process alive past the grace but never healthy);
    • stops the previous server with SIGTERM first, waits up to 8 s for dsh web to dispose cleanly and release its listener, escalates to SIGKILL, then waits for the port to be free (up to 5 s) before launching — a fresh dsh web that starts onto a held port dies with EADDRINUSE during activation;
    • exits when the .daemon-installed marker disappears (uninstalled);
  • logs to logs/watchdog.log (5 MB × 3 rotation).

3. Daemon-aware start / stop

  • dsh_daemon_stop writes .daemon-stopped (the watchdog will not restart the server) and stops the daemon-managed server if one is running.
  • dsh_daemon_start clears the flag, makes sure the watchdog runs, and launches the server if it is unhealthy.

Tools

The plugin is Host-only and registers eight model-callable tools:

Tool What it does
dsh_daemon_install Generates watchdog.js + state files, writes the LaunchAgent plist (or systemd unit / cron entry, VBS + Task Scheduler on Windows), starts the watchdog now. Optional port argument.
dsh_daemon_uninstall Stops the watchdog, unloads and deletes the platform registration, removes all state files.
dsh_daemon_reinstall uninstall + install (use after upgrading dsh or changing the port; also regenerates the watchdog with the current auto-update configuration).
dsh_daemon_status Installed since, port, local/latest versions, update state, watchdog PID/liveness, manual-stop flag, server health, last log lines. When the web server is unhealthy it adds a Details: line pointing at dsh_daemon_logs.
dsh_daemon_logs The detail entry point for a failed dsh web start: the tail of the web server's stdout/stderr log and of the watchdog log, plus the current /health code, web PID and launch age. Optional lines (default 50) and source (all/web/watchdog). Terminal equivalent: dsh-daemon logs.
dsh_daemon_start Clears the stopped flag, ensures the watchdog runs, launches the server if unhealthy.
dsh_daemon_stop Writes the stopped flag (watchdog will not restart), stops the daemon-managed server if one is running. Never touches the current session.
dsh_daemon_update Check for a newer version (apply: false, default) or download and apply it (apply: true). Also the manual entry point for major version changes.

Command line (dsh-daemon)

dsh_daemon_install also writes a thin dsh-daemon command into the node bin directory (PATH), so the daemon is controllable from a terminal without opening the GUI:

Command What it does
dsh-daemon status Same status as the GUI tool.
dsh-daemon restart Immediately restarts dsh web (kills the process on the port and launches a new one; no waiting for the health loop), verified healthy before returning.
dsh-daemon start Clears the stopped flag, starts the watchdog if missing, launches the web server if unhealthy.
dsh-daemon stop Writes the stopped flag and kills the web server (including a manually started one).
dsh-daemon logs The details: tail of the web log and the watchdog log (-n 100 for line count, --web/--watchdog for one of them). Use this when dsh web fails to start.
dsh-daemon update Check the registry (--apply to download and apply).
dsh-daemon install / uninstall / reinstall Registration operations, executed by the plugin through its /dsh-daemon/command route — these need dsh web to be up (the supervision commands above work standalone via the watchdog script).
dsh-daemon help Usage.

restart/stop interrupt all open sessions, exactly like a manual pkill — the watchdog relaunches the web server on the next health cycle if the direct launch fails.

Reading the details of a failed start

Just as systemctl prints a "use journalctl for details" pointer when a unit fails, every daemon failure message points at one command:

$ dsh-daemon restart
dsh-daemon: restarting dsh web on port 3080...
  launched dsh web (PID 51234)
❌ dsh web exited during startup — last log lines:
     | Error: cannot find module @acme/plugin-x
     |     plugin activation failed for row "plugin-x"
   Details: dsh-daemon logs -n 100  (raw log: ~/.dsh/daemon/logs/dsh-web.log)

$ dsh-daemon logs -n 50
dsh-daemon logs — dsh web startup diagnostics
  port 3080  |  /health ❌ not serving  |  web pid 51234 (not running)  |  last launch 3s ago
  web log:      ~/.dsh/daemon/logs/dsh-web.log  (272 bytes, updated ...)
  watchdog log: ~/.dsh/daemon/logs/watchdog.log  (975 bytes, updated ...)

--- dsh-web.log (last 50 lines, ~/.dsh/daemon/logs/dsh-web.log) ---
  | Error: cannot find module @acme/plugin-x
  |     plugin activation failed for row "plugin-x"

--- watchdog.log (last 50 lines, ~/.dsh/daemon/logs/watchdog.log) ---
  | [2026-10-16T12:23:42.653Z] [watchdog] web server not healthy on port 3080 at startup, launching... (details: dsh-daemon logs -n 100)
  • dsh-daemon logs prints 50 lines per log by default and ends with the tail -f commands for live following;
  • the diagnosis header answers the three questions that matter first: is /health serving, is the tracked web PID alive, and how long ago was the current launch (a fresh launch still inside the startup grace is not a failure);
  • in a sandbox or without a terminal, the dsh_daemon_logs tool (lines, source) returns the same content;
  • the log files are always at $DSH_HOME/daemon/logs/dsh-web.log (the web server's stdout/stderr — a startup crash is explained here) and $DSH_HOME/daemon/logs/watchdog.log (the watchdog's decisions).

State files ($DSH_HOME/daemon/, $DSH_HOME defaults to ~/.dsh)

daemon/
├── watchdog.js            # generated watchdog script (standalone, no deps)
├── .daemon-installed      # install timestamp marker
├── .daemon-port           # supervised port
├── .daemon-stopped        # pause flag: watchdog will not restart the server
├── .daemon-restart.lock   # restart-in-progress marker (TTL 120 s)
├── .dsh-watchdog.pid      # watchdog PID
├── .dsh-web.pid           # daemon-managed web server PID
├── .daemon-update.lock    # update-in-progress lock (concurrency guard)
├── .daemon-update-pending # downloaded update awaiting a restart to activate
├── .daemon-update-check.json  # last update check result (status display)
├── dsh-watchdog.vbs       # Windows: hidden wscript launcher
├── dsh-watchdog-task.xml  # Windows: Task Scheduler XML (UTF-16LE)
└── logs/
    ├── watchdog.log       # watchdog log (5 MB × 3 rotation)
    └── dsh-web.log        # web server stdout/stderr when launched by the watchdog

Auto-update

The watchdog checks the npm registry at startup and every 6 h and updates @chenkai114/dsh-daemon in the profile directory with pnpm:

  • Version policy: same-major versions (0.1.3 → 0.1.4, 0.2.x → 0.2.y) update automatically; a major change (0.x → 1.x, 1.x → 2.x, …) is only reported and requires the manual dsh_daemon_update tool.
  • dsh compatibility gate: beyond the daemon's own version, the new release must declare a dsh.requires.dsh range that the installed dsh satisfies. Unsatisfied, undeclared, or unreadable local dsh version ⇒ report only, never auto-install (dsh-daemon update --apply forces it).
  • Update modes (DSH_DAEMON_UPDATE_MODE):
    • restart (default): after downloading, the watchdog polls the plugin's /dsh-daemon/activity endpoint (agent turns + background jobs) every 30 s and restarts dsh web only after it has been idle for the quiet window — an in-progress conversation or job defers the restart until it finishes. If the endpoint is unreachable (plugin not mounted), the restart still happens after DSH_DAEMON_DEFER_MAX. Fully unattended.
    • download: the new package is installed in the profile and a pending marker is written; the update activates on the next natural dsh web restart. No session is ever interrupted — the user decides when the update takes effect.
  • Failure safety: registry unreachable, pnpm failure, or a version mismatch after update only writes a log line and the check state; the old package stays installed (pnpm's store keeps it, so dsh plugin --profile web add @chenkai114/dsh-daemon@<old> rolls back).

Configuration is captured at dsh_daemon_install/reinstall time and embedded into the generated watchdog script:

Env var Default Meaning
DSH_DAEMON_AUTO_UPDATE 1 0 disables the checks
DSH_DAEMON_UPDATE_INTERVAL 6h check interval (ms/s/m/h/d)
DSH_DAEMON_UPDATE_MODE restart restart or download
DSH_DAEMON_QUIET_WINDOW 5m idle time required before a restart-mode restart
DSH_DAEMON_DEFER_MAX 15m max wait for the activity endpoint before restarting anyway
DSH_DAEMON_NPM_REGISTRY https://registry.npmjs.org registry used for checks and pnpm update
DSH_DAEMON_PROFILE web profile directory holding the plugin
DSH_DAEMON_HEALTH_INTERVAL 10s health-check interval of the watchdog loop (ms/s/m; 3 failures outside the startup grace trigger a restart — about 30 s on the default tick)
DSH_DAEMON_STARTUP_GRACE 5m startup grace for dsh web (ms/s/m): health misses inside it never count towards a restart and never kill the process. Keep the default for a large plugin set or a slow cold boot; lower it (e.g. 90s) if you know your dsh web is up in seconds and you want a genuinely hung boot detected sooner
DSH_DAEMON_OPEN_BROWSER 1 when 0, never auto-open the browser even when a new-dsh launch token is detected (the URL is still written to ~/.dsh/daemon/.web-auth-url and the watchdog log for manual access)
DSH_DAEMON_CLI_DIR node bin dir directory for the generated dsh-daemon CLI (tests/sandboxed installs point it at a temp dir to avoid polluting the real PATH)
DSH_DAEMON_NO_SYSTEM unset when 1, skips system-level registration (launchd/schtasks/systemd) — test/sandboxed installs never touch the host's services; the watchdog is still started directly
DSH_DAEMON_TRUSTED_HOST unset Reverse-proxy deployments: the host (comma-separated list) is captured at install/reinstall time and embedded into the generated watchdog so dsh web is launched with --trusted-host; without it dsh's /api trust fence answers 403 for the public Host. It is embedded because the plugin's own nohup-started watchdog never sees the plist/unit environment
DSH_DAEMON_ALLOW_EPHEMERAL_HOME unset 1 allows registering against a temporary DSH_HOME (/tmp, /var/folders); by default the canonical ~/.dsh is used instead, so cleaning a temp dir can no longer leave the LaunchAgent/systemd registration permanently broken (test runs with DSH_DAEMON_NO_SYSTEM=1 are never redirected)
DSH_DAEMON_TRUSTED_HOST unset comma-separated --trusted-host list (e.g. dsh.example.com,192.168.5.5:8080). When dsh web is reached through a reverse proxy (nginx) the Host header is the public hostname and the /api browser-trust fence would reject it with 403 — set this to make dsh web trust those Hosts

The auto-update logic lives in the generated watchdog.js; after upgrading to a version with new update logic, run dsh_daemon_reinstall once to regenerate it.


Verification

All of the following were verified end-to-end against the real plugin code:

  • install → plutil -lint OK, launchctl list shows the agent, watchdog logs watchdog started (PID …, port 3080) / web server already healthy on port 3080;
  • on an empty port the watchdog launches a real dsh web --port <port> at startup (health OK on the new port);
  • self-heal: after SIGKILL of the managed server → health check failed (1/3 → 2/3 → 3/3) → failure threshold reached, restarting web server → new process serves 200;
  • startup grace (node test/startup-grace.test.js, real generated watchdog + fake dsh): a boot that only serves /health after 8 s is only logged as "still starting" inside a 20 s grace (no restart, no kill); once a 2 s grace expires the previous process is stopped with SIGTERM before a relaunch, and every relaunch follows such a stop (port released first);
  • diagnosable failed starts (node test/logs-command.test.js, real generated watchdog + a fake dsh that dies instantly): the default tick is baked as INTERVAL_MS = 10000; a failed --start prints Details: dsh-daemon logs -n 100; dsh-daemon logs / --logs / dsh_daemon_logs all show the dsh web crash output and the watchdog decisions;
  • launchd KeepAlive: SIGKILL of the watchdog → launchd restarts it within ~11 s;
  • single-instance guard: running watchdog.js a second time exits immediately;
  • stop writes the pause flag and kills only the daemon-managed server; start clears it; uninstall removes launchd registration, plist, state files and frees the port; status reflects every state.

Local test

node test/harness.js dsh_daemon_status          # static package mode
DYNAMIC=1 node test/harness.js dsh_daemon_status # dynamic sandbox mode
node test/startup-grace.test.js                 # startup grace: slow boot survives, grace expiry restarts, graceful stop
node test/restart-loop.test.js                  # adoption of an unmanaged listener + slow /health is tolerated
node test/update-gate.test.js                   # dsh compatibility range gate + classified check failures
node test/logs-command.test.js                  # 10s tick default + dsh-daemon logs / failure-path details pointer

The harness runs the real plugin code with real bash/fs and invokes the tool for real.

v0.1.29 — dsh compatibility gate for auto-update, diagnosable check failures

Background: the updater only ever compared daemon versions (isNewer + sameMajor). A client on an older dsh would automatically receive a daemon that requires a newer one, then crash-loop and take dsh web down with it — the 0.2.x shell-API change was survived by supporting both shapes, not by a gate.

Compatibility gate (conservative by default — refuse rather than guess):

  • every release declares the dsh range it supports in package.json: "dsh": { "requires": { "dsh": ">=0.1.2-alpha.1 <0.3.0" } };
  • the check reads that field from the registry /latest manifest and compares it with the installed dsh version (range syntax: >= > <= < = joined by spaces; no ^ ~ || — an unparseable range is treated as "cannot verify", never guessed);
  • three outcomes skip the automatic update and say why in status: incompatible (range unsatisfied, shows required vs installed), unknown-dsh (cannot read the local dsh version), and unverified-compat (the new version declares no range);
  • human override: dsh-daemon update --apply / dsh_daemon_update with apply:true is treated as forced — it installs and logs that compatibility was not satisfied.

Classified failures + retry: the probe went from one 10 s attempt to 15 s × 2 (2 s apart) and reports timeout after 15s / DNS lookup failed (ENOTFOUND) / connection failed (ECONNREFUSED) / registry HTTP 5xx instead of one blanket "registry unreachable"; status also shows Last successful registry check.

Adds test/update-gate.test.js: 18 range-parser boundary cases (including prerelease ordering and "cannot verify" for garbage ranges), template wiring, and a real watchdog generation checked with node --check.

v0.1.28 — no more false kills, and the restart storm is closed

From a real incident (8-core host under load average 100+, swap exhausted): /health occasionally took 4.6 s while the watchdog's per-probe budget was 5 s and the failure threshold 3, so a healthy dsh web was killed three times within half an hour. After the third restart the pid file pointed at a process that was already gone while an untracked instance kept serving the port; the daemon could neither recognise nor manage it, and launched a fresh instance every few minutes — each dying with EADDRINUSE and adding load to an already saturated host.

  • Per-probe budget 5 s → 20 s, failure threshold 3 → 5: a restart now needs ~50 s of real misses, so GC/swap stalls and transient spikes no longer kill a healthy server.
  • Serialized ticks: setInterval async bodies can overlap; once a probe got slow, two rounds both saw "the process is gone" and each launched — exactly how two dsh web processes appeared inside one second. tickBusy now makes a slow probe harmless.
  • Single-flight launch(): tick, CLI and updater share one in-flight promise, so the loser can no longer be recorded as the tracked PID while the winner serves the port.
  • Adoption: when the pid file points at a dead process but a healthy dsh web serves the port, the pid file is corrected to the real listener (logged as adopted the running dsh web) instead of launching a duplicate; a port held by a non-dsh process is logged and never blindly relaunched onto.
  • Identity before signalling: only a PID that really is a dsh web (or that really owns the port) gets signalled, so PID reuse can no longer make the daemon kill an unrelated process (issue #3, problem 1).
  • Honest status: a pid-file/listener mismatch is reported explicitly, with the dsh-daemon restart remedy.
  • Non-JS entries are refused: an unresolved dsh.ps1 / .cmd / pnpm sh shim is reported instead of being handed to node (backstop for issue #3 N1), so the "log says launched, nothing ever listens" loop is gone.
  • Merged PR #2 (@QiE2035): resolve pnpm sh / .cmd shims to the real lib/bin.js, and self-heal DSH_BIN through the shim after a pnpm store GC or reinstall.

Adds test/restart-loop.test.js: the real generated watchdog plus a fake dsh web replay both scenes — (1) adoption of the real listener when the pid file points at a dead process, with no restart; (2) a 6 s /health answer no longer triggers a restart.

v0.1.27 — 10 s health checks, diagnosable failed starts (dsh-daemon logs)

Faster self-heal: the watchdog health-check interval default moves from 30 s to 10 s (DSH_DAEMON_HEALTH_INTERVAL, still overridable with ms/s/m). The failure threshold is still 3 consecutive misses, so "process alive but past the grace and never healthy" now triggers a restart in ~30 s instead of ~90 s; the sleep/wake gap (90 s) and the minimum restart gap (15 s) stay above the tick, so nothing false-positives or hot-loops.

Failed starts have a details entry point: when dsh web does not come up, you previously had to find the files under ~/.dsh/daemon/logs/ yourself. Now:

  • new dsh-daemon logs [-n N] [--web | --watchdog] (the watchdog's --logs mode; the bash/cmd wrappers forward to it): 50 lines of the web and watchdog logs by default, preceded by a diagnosis header (/health state, whether the tracked PID is alive, how old the current launch is, whether monitoring is paused) and followed by the tail -f commands for live following;
  • every failure path prints the pointer, systemctl-style: dsh-daemon start / restart failures print Details: dsh-daemon logs -n 100 (raw log: …/dsh-web.log), status prints the same line when unhealthy, and the generated watchdog writes (details: dsh-daemon logs -n 100) into its own log — so tailing watchdog.log alone still leads to the detail command;
  • new dsh_daemon_logs tool (lines, source) returns the same content in a sandbox or without a terminal;
  • regression test test/logs-command.test.js drives the real generated watchdog against a fake dsh that dies instantly and covers the 10 s default tick, the failure hint, both --logs filters, unknown options, the CLI forwarding and the tool output.

Generated artifacts must be regenerated: after an upgrade the watchdog rewrites itself based on GEN_VERSION; if the version did not change, run dsh_daemon_reinstall once.

v0.1.19 — dsh web token-auth adaptation (seeding the ?token= launch token)

Since dsh ≥ 0.1.2-alpha.1 (harness commit 3e24087bfa) dsh web generates an in-process random token at startup and prints dsh web: http://127.0.0.1:<port>/?token=...: the browser must visit that URL once to seed a 30-day host-only Cookie (the signing key persists across restarts; a bare 3080 visit with no Cookie → 401). The watchdog used to launch with --no-open, so the user's browser never got a Cookie and the daemon-managed web was 401.

Adaptation: after launching, the watchdog extracts the current run's token URL from dsh-web.log (the web process stdout), then:

  • ?token= detected (new dsh) → open the default browser once to seed the Cookie (same as manual dsh web); if opening fails (headless), the URL is already on disk for manual access;
  • not detected (old dsh) → status quo (--no-open, never touch the browser);
  • the URL is always written to ~/.dsh/daemon/.web-auth-url (0600, overwritten each launch) and shown by dsh-daemon status;
  • DSH_DAEMON_OPEN_BROWSER=0 disables the auto-popup (disk + log only);
  • the scan is anchored to the pre-launch file offset: the append-only POSIX log never reuses an old run's dead token (the LAST dsh web: line of the current run's segment wins; win32 Start-Process overwrite semantics fall back to reading the whole file, and dsh-web.log.1 — the previous process — is never read);
  • the probe is cross-version stable: dsh web: http://... has been printed by every dsh version, old and new differ only in whether the URL carries ?token= — so ?token= presence is the judge, robust to future versions;
  • reverse insurance: if a new dsh's token line is slow to appear the watchdog KEEPS WAITING (fast poll 15s@250ms then a slow retry phase 75s@5s) instead of dismissing it as "no token" (treating a probe failure as old dsh would miss the auth); on total timeout it only logs and the next launch retries;
  • popup throttle: the token changes on every start but the 30-day cookie outlives any single token (persistent signing key), so the browser is not re-opened when the URL equals the last recorded one (.web-auth-url is still refreshed for manual access); SSH_CONNECTION/SSH_TTY non-empty suppresses the popup entirely (never open a browser on a remote host's desktop);
  • the version gate mirrors the --no-open pattern (DSH_TOKEN_AUTH_MIN = 0.1.2-alpha.1, module-scope functions inlined into the watchdog via toString(), re-decided at every launch) and only skips the poll when the installed dsh is known to predate token auth; the URL-line probe is the authoritative detector.

v0.1.18 — Windows black-box fix: hidden console, not no console; start waits for health

On Windows the watchdog spawns dsh web, pnpm, netstat, etc. with CP.spawn(..., { detached: true }), and Node gives detached children their own console window by default (the watchdog itself runs hidden — VBS / Task Scheduler — and has no console), so every launch/restart flashed black boxes. Once v0.1.17 fixed the --no-open restart loop, the black boxes became the visible problem.

Mechanism choice (deepseek-harness discussion #1564 / #810): dsh web must NOT be launched with windowsHide (CREATE_NO_WINDOW) — a console-less host forces every child it spawns to allocate a new visible console, and CREATE_NO_WINDOW kills the Windows ACL sandbox's restricted-token children with 0xC0000142 (DLL initialization failed). The correct approach is to give dsh web a hidden console (STARTF_USESHOWWINDOW + SW_HIDE, keeping dwCreationFlags=0 — on Windows implemented via Start-Process -WindowStyle Hidden, matching the dsh-daemon start direct-launch path): dsh web has no visible window itself, and its console children inherit that hidden console, so nothing flashes at any level.

  • on win32 the watchdog now launches dsh web through powershell.exe -Command "Start-Process -FilePath <node> -ArgumentList ... -WindowStyle Hidden -RedirectStandardOutput <web.log> -PassThru" (the powershell wrapper itself uses windowsHide — short-lived, normal token, safe); the wrapper writes the PID file and the watchdog polls for it; ⚠️ the wrapper must NOT use detached: true — Node maps it to DETACHED_PROCESS on Windows, which hangs the Start-Process command (no PID file, child never starts; reproduced empirically). Start-Process children are independent processes anyway, so the short-lived wrapper needs no detachment; Start-Process redirects with OVERWRITE semantics, so before each launch the old dsh-web.log is rotated to dsh-web.log.1 (the previous run's crash output survives; the log stays bounded — current + one previous run, never unbounded);
  • the watchdog's other short-lived children (self-spawn, pnpm, idle-restart waiter, netstat/lsof probes) keep windowsHide: true — safe under a normal token, consistent with the discussion's subprocess-local treatment;
  • dsh-daemon start now polls for health after launching (like restart does, up to ~13 s) before returning — previously start returned while dsh web was still booting, the next status looked unhealthy, and users ran start again, which killed the still-booting first instance via the PID file;
  • template assertions: every spawn site carries windowsHide, and the win32 web launch must go through Start-Process -WindowStyle Hidden (regression guard).

Note: the DSH-internal layer from #1564 (two spawns in dsh-sandbox-windows-acl dwFlags:256→257 + wShowWindow:0; windowsHide:true in dsh-subprocess-local) is a patch to dsh itself, not this repo; re-apply it after upgrading dsh (the community script Culeot/dsh-no-console-flash is idempotent).

v0.1.17 — pass --no-open only when the dsh version supports it

Since v0.1.16 the watchdog launched dsh web --port <port> --no-open, but --no-open only exists in @deepseek-ai/dsh 0.1.0-rc.8 (dsh-web-app 0.1.0-rc.8, which also introduced default browser opening). Older CLIs reject the flag with unknown option '--no-open' and exit immediately, so the watchdog fell into a restart loop: launch → instant death → failed health check → relaunch, and web never came up.

  • The watchdog reads the dsh package package.json version at every launch and appends --no-open only when it is ≥ 0.1.0-rc.8 (semver, including prerelease ordering); an unknown/unreadable version conservatively skips the flag — the server still starts, and pre-rc.8 dsh never opened a browser anyway, so nothing is lost;
  • the dsh-daemon start direct-launch commands (Windows Start-Process / Unix nohup) make the same version-based decision;
  • the gate is a single module-scope implementation; the watchdog inlines the exact same code via Function.prototype.toString(), so dsh upgrades or downgrades take effect at the next launch without reinstalling the daemon;
  • new test/version-gate.test.js unit tests (run by npm test).

v0.1.16 — health check, browser pop-ups, and env forwarding

  • /health route: the watchdog health-checks http://127.0.0.1:<port>/health every 30 s, but deepseek-harness's web server has no such route (unknown paths 404), so a running web was reported unhealthy forever. The plugin now registers /health itself, returning 200 {"ok":true} — plugin up means web up, and the check is reliable.
  • --no-open: daemon-managed web restarts (auto-update, self-heal) no longer pop a browser tab; manual dsh web still opens by default.
  • Env forwarding: dsh-daemon install/uninstall/reinstall execute in the web process via the /dsh-daemon/command route, so DSH_DAEMON_* variables from the invoking shell never reached the plugin. The CLI wrapper now collects all DSH_DAEMON_* from the current shell and forwards them with the request, so DSH_DAEMON_UPDATE_INTERVAL=1m dsh-daemon reinstall configures the watchdog correctly.

v0.1.15 — test/sandboxed installs no longer touch the host

Test harness installs with a temp HOME used to pollute the real environment; two switches close that gap:

  • DSH_DAEMON_CLI_DIR: overrides where the generated dsh-daemon CLI is written (default: node bin) — tests point it at a temp dir so the real wrapper on PATH is never overwritten;
  • DSH_DAEMON_NO_SYSTEM: when 1, skips system-level registration (launchd/schtasks/systemd) so a test install cannot steal the system service label and leave the real daemon dead. The harness sets both by default.

v0.1.14 — restart is now the default auto-update mode

The default of DSH_DAEMON_UPDATE_MODE changed from download to restart: when unset, after an update is downloaded the watchdog restarts dsh web on its own once it is idle (fully unattended, never interrupting an in-progress session). Set it explicitly to download when you want to control when the update takes effect.

v0.1.13 — watchdog regenerated automatically after auto-update

Previously auto-update only refreshed the npm package; the already-generated watchdog.js (a one-time artifact from install time) never picked up the new generator logic — a manual dsh_daemon_reinstall was required. Since v0.1.13:

  • the plugin version is embedded into the generated watchdog (GEN_VERSION);
  • on every plugin boot the script's GEN_VERSION is compared with the installed package version; when they differ (after an auto-update, or a manual package upgrade) the plugin regenerates watchdog.js and the CLI wrapper and restarts the watchdog process;
  • so after an auto-update (user restarts web in download mode, or the idle-aware restart does it in restart mode) or a manual upgrade + web restart, the watchdog catches up with the new version on its own — no manual dsh_daemon_reinstall;
  • test/development loads can skip the sync with DSH_DAEMON_AUTOREGEN=0.

v0.1.12 — Windows console-flash regression fix

v0.1.11 added windowsHide: true (Windows CREATE_NO_WINDOW) to the watchdog's launch() and other spawns. The side effect: the dsh web process lost its console handle, so any child it spawns afterwards (git, tool executions, …) gets a visible console window on Windows — frequent console flashes while the server runs (issue #1).

v0.1.12 removes all four windowsHide flags and restores the v0.1.10 model: the watchdog is started by VBS shell.Run ..., 0 (SW_HIDE) with its own hidden console, the web process inherits it, and web's children inherit in turn — the whole chain stays windowless (verified on v0.1.10).

Note: if console windows still flash during plugin install/command execution (the DSH sandbox/subprocess path, not this plugin's watchdog), that is a deepseek-harness Windows console-handling issue, not this plugin — see discussion #1564 and the Culeot/dsh-no-console-flash patch.


License

MIT