# dsh-ankh-guard
English | [中文](README.md)
Let an agent change its own code and restart its own service — without taking the whole thing down.
When the agent wants to restart after editing code, this plugin asks one question first: did the build and tests pass? Yes, go ahead. No, blocked — so broken code can't take the service, and the conversation running inside it, down with it.
## How it works
One rule at the core: **prove the code is good before you allow a restart.**
After `record --run -- PROGRAM ...` executes the build/test argv and observes exit 0, the plugin records a credential bound to the current git commit, valid for 10 minutes (`maxAgeMinutes`). On a restart request it checks four things:
1. does a credential exist;
2. is it younger than `maxAgeMinutes`;
3. does the current HEAD match the commit the credential was recorded on;
4. is the checkout free of staged, unstaged, and untracked inputs — any one invalidates it.
That ten-minute window constrains build evidence which has not yet survived a real boot; it does not make byte-identical deployed software become untested every ten minutes. Only after a watchdog restart passes ownership/readiness, composition preflight, and the post-restart canary does the guard promote the credential into a durable `provenDeployment`. A later `schedule-exit` may reuse it after credential expiry, but only after recomputing and matching the credential-repo and harness clean HEADs, complete launch spec, profile configuration, directly installed packages and `file:` archives, host installation metadata, and the explicitly bound source/built preflight runner and anchor. Any byte, link target, launch input, or repository-state drift fails closed and requires build + test again. A legacy `last-good-boot.json` is never promoted implicitly; enabling this path requires one full gate and successful canary under the new protocol.
That one rule catches a whole class of incidents: broken builds, missed config registration, wrong imports — all of these fail the build/typecheck, so no credential exists and the restart is refused before it can hurt.
A green build still says nothing about the profile composition: bad patch YAML, a missing built file, a duplicate loader entry id, a typert manifest ownership mismatch, a plugin whose apply throws — all of these fail only at boot. So a second gate runs after the credential check, before anything is stopped: `preflight` deep-dry-runs the exact composition in a subprocess (full plugin tree booted through the same engine, then disposed), and a composition that does not boot means the running instance is never stopped. See [preflight: the composition gate](#preflight-the-composition-gate).
The restart itself is handed to a watchdog: a detached supervisor that brings the host back if it dies, rolls back to the last known-good revision (the healthy-boot stamp — the last revision that actually came up in this deployment — else the checkpoint, else the green credential's HEAD) if it can't come up, and stops at a crash page after four consecutive failures. A boot failure whose origin lives outside the checkout (a freshly installed plugin is the common case) cannot be fixed by reverting the repository — so the watchdog instead rolls back the **profile composition**: every healthy boot snapshots the profile's composition inputs (the bundles layer and the profile manifest), and an out-of-repo boot failure restores that snapshot (unmounting the newest plugin change; the failing inputs are kept under `composition-backup-*`) and reports the recovery, naming what was unmounted, through the restart-report channel. Every rollback leaves `guard-backup-*` recovery anchors for the discarded HEAD and any uncommitted work. `checkpoint` records the existing clean HEAD before a batch; a dirty tree is refused unless the complete path set is reviewed and explicitly approved with `--include-dirty`. `reset` hard-resets to that point (anchored the same way), and `canary` re-verifies after a restart. Checkpoints and credentials persist in a state file that survives restarts, so the canary runs after the new instance is up.
Readiness is application-aware. Any HTTP response, including a naked 401, proves only that the transport is up. A public root is ready at HTTP 200. A protected root is ready only after the watchdog extracts the final process's same-authority launch URL from that process's output and uses a temporary cookie jar to prove launch URL → 303 → authenticated `/` → 200. HTTP success alone is insufficient: the spawned direct child must still be alive, the port's sole listener must belong to that child's process tree, child/listener PIDs and start identities must remain unchanged through the stability window, and the current retry must be zero. Browser handoff is a separate evidence plane and runs only after that proof and the canary pass. Every responsive registered original tab waits before shutdown, reloads if its cookie remains valid, or receives the final process's one-time URL in memory and calls `location.replace()`; after the cookie exchange it returns to its credential-free same-origin pathname. One real authenticated page acknowledgement gates terminal readiness, while slower registered tabs remain recoverable through the exact final listener. Bearer URLs are absent from durable state and logs.
## Install and load
This package is a dsh plugin: it guards a running dsh web instance against broken self-modification restarts. Its single identity is **`@khorsheed/dsh-ankh-guard`**, developed in the `dsh-plugins` monorepo and published to npm from there. Install the host and add the plugin as a profile bundle:
```sh
npm install @deepseek-ai/dsh # the host (dsh web / dsh CLI)
dsh plugin --profile web add @khorsheed/dsh-ankh-guard # this plugin
```
The package declares `dsh.bundle`, so the add reconciles its `cordis.patch.yml` row (a bare `ankh-guard` mount) into the profile's bundles layer — no hand-edited cordis.yml. One caveat: a composition may mount the `ankh-guard` row id only once. Official images (the published npm line and upstream master) mount no such row, so the add above is the install path; a composition that already mounts the id by other means — the pre-2026-08-16 deploy fork's base bundle did — must not also add the package, because a duplicate loader entry id fails boot. When in doubt, check the composed tree first: `dsh --profile web --dump-config | grep ankh-guard` printing nothing means the add is safe. From source: clone the monorepo; the package lives at `packages/ankh-guard` (`pnpm install && pnpm run build`).
Config (all optional): `stateDir` (default `$DSH_HOME/state`, else `/.dsh-guard-state`), `repoDir` (default the process cwd), `maxAgeMinutes` (credential freshness, default 10), `reportRestartContext` (`followup` autonomous report / `step` ride the next turn / `off`, default `followup`), `resumeInterrupted` (resume restart-interrupted sessions and queue a continue turn, default true), `resumeDelayMs` (default 5000), `resumeMaxSnapshotAgeMs` (default 600000).
Runtime needs: `node`, `bash`, `lsof` on macOS/Linux for listener discovery (`--pid` bypasses it), and `pgrep` for descendant reaping (watchdog cleanup and `restart`'s forced-kill escalation walk the child tree instead of assuming a process group). The guard probes system absolute paths such as `/usr/sbin/lsof` and `/usr/bin/lsof` before falling back to PATH, so dsh's restricted tool PATH cannot silently disable listener checks. No build step for consumers — the published `lib/` is the runnable artifact.
## Prerequisites for a self-restart (for the agent driving it)
- **git is required.** The credential, checkpoints, and rollback are all git-based: the credential binds a clean HEAD, a checkpoint names a real commit (reusing clean HEAD or creating an explicitly approved dirty snapshot), and rollback is a reset. If the deployment directory is not a git repository, `git init` it and make an initial commit before `record` — otherwise the gate refuses with "current git HEAD unavailable". The `git init` is not ceremony: with a repository in place, the checkpoint/rollback recovery anchors actually work.
- **Full-access (unsandboxed) permissions.** The restart loop spawns detached processes, kills processes, and binds ports; sandboxed tool runners (workspace-write and the like) deny those operations with EPERM and the instance dies at the shell layer. The agent CANNOT switch its own sandbox — that is the point of the sandbox; `/permission` is a user-typed command, and per-command escalation prompts the user for approval. For a permanent deployment the easier official path is starting the instance with `DSH_PERMISSION_MODE=danger-full-access` (the base bundle's deployment-level switch — sandbox and approval policy both open), so every session starts unsandboxed. Otherwise, before initiating a self-restart, ask the user to switch THIS session: `/permission danger-full-access` — the settings page only affects NEW sessions, and an open persistent terminal (PTY) fences the switch until closed. (`verify` and `record` print this hint too.)
- **The first restart after install must be driven by the CLI.** The running instance has not loaded the plugin yet — composition changes need a boot — and no watchdog exists yet, so a bare exit leaves the service DOWN with nothing to bring it back. Right after the add, run `dsh-ankh-guard supervise --port N --start "CMD"` (it adopts the running instance and respawns ANY exit from then on), or drive the first restart with `dsh-ankh-guard restart --port N --start "CMD" --rollback` (it owns the whole stop→start→canary loop in a detached process), or install the launchd/systemd supervisor. The adoption takeover files a report record addressed to the session that ran `supervise` (via `$DSH_SESSION_ID`), so that first bounce reports back like a scheduled restart — the driving session does not park silently. `verify`/`record` warn while no live watchdog exists; `schedule-exit` hard-refuses instead of scheduling a guaranteed outage.
## Known install pitfalls
- **A GitHub install builds from source.** `dsh plugin add github:…` clones and runs `prepare` (a full devDependency install + build). The npm release (`@khorsheed/dsh-ankh-guard`) ships the built `lib/` — prefer it unless you specifically need the repo edge.
- **pnpm blocks dependency build scripts by default.** If the add fails on a build-script interception, allow the toolchain entries via `allowBuilds` and retry.
- **A root-owned npm cache** (one `sudo npm …` in the past) fails the prepare build with EPERM: `sudo chown -R $(id -u):$(id -g) ~/.npm`.
- **`--start` does not run from your cwd.** The watchdog `cd`s into the dsh home (else `/tmp`) before launching, so the start command must be self-contained — absolute paths, or an explicit `cd` inside it.
- **Put `--no-open` in the start command.** It prevents the host itself from opening a tab on every respawn. During a protected launch cutover, `reconfigure` first asks an existing tab to wait and recover in place. If that tab cannot acknowledge within the handoff timeout, the watchdog opens the final process URL in a fallback tab; a successful `open` call is only an attempted fallback, not proof that a browser took over. A candidate process's URL is never reused. The preflight dry-run never opens a browser.
- **The first rollout of original-tab handoff necessarily falls back.** A page loaded by the old plugin has no handoff client. To validate in-place recovery, first deploy this ankh-guard version to the previous host, refresh the original tab so the new client is active, and only then run the host cutover. Falling back to a new tab during the bootstrap deployment is expected.
- **Supervision adopted from a sandboxed session stays sandboxed.** A watchdog spawned from inside a workspace-write sandbox passes that profile to every respawned instance (nested sandbox-exec then fails, and every command degrades to approvals). For a permanent deployment, use the layered shape (the launchd/systemd installer) so the watchdog chain starts outside any sandbox.
## CLI
The primary interface is the CLI, usable even when the instance is down. Use the `dsh-ankh-guard` bin (or `node lib/cli.js`). `--repo` always names the credential/rollback repository; `--harness-root` names the host root used by preflight and the child, and the two can — and usually do — differ. State commands take `--state-dir "$DSH_HOME/state"`.
```sh
dsh-ankh-guard verify # is it safe to restart right now
dsh-ankh-guard record build+test --run -- sh -c 'pnpm run build && pnpm run test'
dsh-ankh-guard checkpoint --message "what changed" # checkpoint before editing
dsh-ankh-guard preflight # deep dry-run: does the profile composition boot
dsh-ankh-guard canary --port 3080 # confirm after restart
dsh-ankh-guard supervise --port 3080 --start "CMD" # hand the port to a watchdog
dsh-ankh-guard reconfigure --start "NEW CMD" --repo "" \
--harness-root "" --on-failure restore-previous
```
Full commands: `verify`, `record`, `status`, `clear`, `checkpoint`, `reset`, `canary`, `preflight`, `restart`, `schedule-exit`, `configure-launch`, `launch-status`, `reconfigure`, `abort-cutover`, `restore-previous`, `supervise`.
### preflight: the composition gate
`preflight` deep-dry-runs the exact composition a restart would boot: it composes the profile's full patch stack through the same path as the real launcher (bundle layers, user layers, overlays), boots the **whole plugin tree** in a subprocess through the same engine — every plugin's apply runs, because apply is activation — with an overlay pinning the webserver port to 0 (OS-assigned, so it never collides with the live instance), checks every registered client bundle artifact exists, and disposes (registrations are effects, so dispose rolls the dry-run back). Exit codes are the contract:
- `0` — the composition boots clean.
- `1` — a composition verdict: the tree a restart would boot is broken; the output names the failing layer.
- `3` — preflight itself could not execute (missing app layout, infrastructure crash) — **not** a verdict on the composition.
`schedule-exit`, `restart`, and `reconfigure` run this gate after the credential check, before anything is stopped. A composition failure refuses with the preflight's diagnostics; an infrastructure failure also refuses — worded differently and with the manual override (stop the instance by hand, let the watchdog respawn it) — because the guard will not stop a healthy instance it cannot prove will come back. The CLI emits `composition preflight START` before waiting, then emits PASS or refusal only after the runner settles. The gate locates the dsh app to dry-run via `--harness-root`, then the durable selected launch spec, `DSH_HARNESS`, and finally the conventional `~/code/deepseek-harness` checkout; the credential `--repo` never participates in host resolution. Where none resolves (a pure npm deployment with no harness checkout), there is no engine to boot the profile with, so the gate warns once and proceeds. Flags: `--profile NAME` (default `$DSH_PROFILE`, else `web`) and `--preflight-timeout-ms MS` (default 120000); `DSH_PREFLIGHT_COMMAND` replaces the resolved app bin wholesale (test hook). A managed shell/tool invoking the command needs a separate, longer wait budget: at least 180000 ms for the default preflight, or at least 30000 ms beyond a custom `--preflight-timeout-ms`. A caller timeout is not a guard refusal, and without `exit scheduled` no restart was authorized; inspect durable markers/receipts before retrying. Run it by hand any time with `dsh-ankh-guard preflight --profile web --harness-root "$DSH_HARNESS"`.
### The self-restart protocol
Six steps for a safe restart after editing code. A pure restart with no file, dependency, profile, installed-artifact, or launch-spec change skips step 1. When the new guard has already written a same-fingerprint `provenDeployment` after a successful canary, `schedule-exit` can also reuse steps 3–4. Without that proof, with legacy state, or after any fingerprint drift, the complete sequence remains mandatory:
1. **checkpoint** — record clean HEAD; a dirty tree is refused until the complete snapshot is reviewed and approved with `--include-dirty`: `dsh-ankh-guard checkpoint --message ""`
2. **modify** — make the change; register every surface it needs (aggregates, paths, bundle rows, dependencies).
3. **build + test** — the narrow full set for the changed surface; no green, no credential.
4. **record** — make the guard execute the evidence: `dsh-ankh-guard record build+test --run -- sh -c 'pnpm run build && pnpm run test'`
5. **verify** — `dsh-ankh-guard verify` must exit 0. It prefers a fresh credential and accepts a proven deployment only with stable durable launch state and an exact current fingerprint; a denial (missing proof, fingerprint drift, HEAD mismatch, or dirty checkout) means clean up, rebuild, and re-record.
6. **restart + canary** — after the new instance is up, `dsh-ankh-guard canary --port N` confirms it.
### supervise: seamless restart
`restart` runs the whole kill → start → probe → canary loop in one CLI process (use `--delay-ms` so the scheduling turn finishes first). For deployments where nobody should touch a terminal, `supervise` hands the job to a **watchdog** — a detached supervisor process that survives the instance:
```sh
dsh-ankh-guard supervise --port 3080 --start "CMD" --state-dir "$DSH_HOME/state" \
--repo "" --harness-root ""
```
`supervise` also needs the dsh home the supervised instance boots with (the watchdog exports it as the instance's `DSH_HOME`): `--home DIR` wins, else `$DSH_HOME`; with neither set it refuses loudly — a home guessed from `--state-dir` would silently boot the instance on the wrong profiles/credentials. First-time persistence also requires an explicit `--harness-root` or `DSH_HARNESS`; it never guesses the host root from the credential repo.
It spawns `scripts/dsh-watchdog.sh` (ships with the package) detached with `--wait-owner`: the watchdog idles while the current instance runs, takes over the port when the instance exits (intentional restart or crash), respawns it, runs the guard canary on intentional restarts (a `restart-requested.json` marker), and clears the marker on pass. Two consecutive boot failures roll the checkout back to the last known-good revision — the healthy-boot stamp (`last-good-boot.json`, written every time the instance comes up, so it names the last revision that genuinely ran in this deployment), else the guard checkpoint, else the credential's HEAD — but only when the boot failure's error subject is a path inside the repository. When the subject lives outside the checkout (a broken profile overlay or an installed plugin), a checkout reset cannot help, so the watchdog instead restores the last healthy **profile composition**: the snapshot of the profile's composition inputs (`last-good-composition/`, taken at every healthy boot) replaces the live bundles layer and manifest, unmounting the newest plugin change, with the failing inputs preserved under `composition-backup-*` and the recovered report naming exactly what was unmounted. The same exemption logic covers a start command that does not bind the supervised port: when the boot window times out while the instance is listening elsewhere — or fails with `EADDRINUSE` naming a port this watchdog does not own — the watchdog names the bound port and skips both rollbacks, because resetting files cannot change a command-line argument. `EADDRINUSE` on the supervised port keeps its free-and-retry escape hatch, now bounded at five attempts. Every reset (watchdog, CLI, or service) first creates `guard-backup-*` branch anchors for the discarded HEAD and for uncommitted tracked changes, so recovery never depends on the reflog. Four failures serve a crash page on the port with a retry button (SIGUSR1 to the watchdog). A `watchdog-stop` marker exits the watchdog for good. The instance itself can adopt supervision before a self-restart — the user never starts the watchdog by hand.
When a watchdog is already supervising, the restart trigger is `schedule-exit`: it gets the port, credential repo, host root, and profile from the durable active launch spec, rejects conflicting explicit flags, and verifies the supervisor's complete command before writing the restart marker and spawning a detached process explicitly labelled `exit-agent pid`. It prefers a credential within the ten-minute freshness window. Once that expires, only an exact `provenDeployment` may take the pure-restart fast path; the selected evidence SHA is copied into the short-lived restart marker and the new watchdog rechecks that same SHA and the live fingerprint during canary, closing the check-to-stop evidence replacement window. A successful canary then promotes or retains the proof. For an agent Bash/tool call, set that call's `timeoutMs` to 180000; this is tool metadata, not a CLI flag, and keeps the caller alive longer than the 120-second preflight gate. The managed shell cannot reap the exit agent, so the scheduled kill lands after the scheduling turn ends. The watchdog respawns, runs the canary, and the new instance reports via `last-restart.json`; watchdog lifecycle lines are timestamped. With no live watchdog, `schedule-exit` hard-refuses: establish supervision first or use `restart`, which owns the complete single-shot loop. The latter lacks durable supervisor/launch ownership and therefore still requires a fresh credential.
### reconfigure: transactional launch changes
`schedule-exit` is the same-launch fast path. If the command, dsh home, credential/rollback repository, host root, or profile changes, use `reconfigure`; changing the online port is deliberately refused because it needs a separately supervised traffic cutover.
```sh
dsh-ankh-guard reconfigure \
--start "" \
--repo "" \
--harness-root "" \
--preflight-surface built \
--preflight-install-anchor "/node_modules/@deepseek-ai/dsh/package.json" \
--candidate-probe-command "" \
--transition-file "" \
--on-failure restore-previous \
--browser-handoff required \
--state-dir "$DSH_HOME/state"
```
The recovery choice is mandatory and therefore approved before the old host stops: `restore-previous` restores the entire previous launch specification, while `wait-for-user` parks for intervention without resetting any repository. The full previous/target pair separately persists command, home, credential repo, harness root, profile, and port. Each new spec also binds the composition preflight's `source|built` surface, runner executable/path/content SHA, the actual `@deepseek-ai/dsh/package.json` install anchor, and the target command SHA. A built successor resolves modules from its npm toolchain; the guard never chooses checkout source merely because its own process happens to use tsx. `reconfigure` additionally requires the caller to supply a one-shot `--candidate-probe-command` committed with the target command SHA. It runs that probe against an isolated home before composition preflight runs on the bound execution surface. The guard binds and executes both command digests; it cannot prove that an arbitrary successful shell command was automatically derived from the target argv. The bundled Skill constructs a DSH `--dump-config` probe from the same executable and launcher argv; integrations outside that caller trust boundary must establish the same semantic provenance themselves. Either failure occurs before previous stops. The mode-0600 `launch-spec.json` contains the commands and selected side; the receipt contains only bindings, hashes, and PASS outcomes, never probe/start commands or bearer URLs.
The atomic selected-side rename is the configuration commit point. If no complete durable previous spec exists, initialize it first with the real current values and explicit preflight surface/install anchor through `configure-launch`: legacy `instance-launch.json` cannot supply the missing roles, and a target `--repo` is never backfilled into previous. A replacement watchdog then atomically claims `watchdog.pid` while the old host is still serving. During preparation the guard captures PID/start identities for the old supervisor, its direct child, and its listener. The successor waits for the exact supervisor identity to yield with a 15-second default bound (configurable through `--supervisor-yield-timeout-ms`) and consumes abort/restore throughout that wait. On timeout it may retire only the frozen, revalidated old supervisor tree. It then stops only the proven child/listener identities and confirms port release; it never chooses or kills an arbitrary process found by port. PID reuse, a hung watchdog, a short-lived `reconfigure` caller, an outer launchd/systemd waiter, and nested shells therefore cannot blur ownership or suspend a cutover forever.
For a protected target, the watchdog accepts a launch URL only from the final process's output, only for the exact supervised loopback authority, and without depending on a parameter name. It proves 303 cookie exchange and authenticated root 200 with a temporary jar, then spends a three-second default stability window proving that the child is alive, its sole listener remains in that child tree, PID/start identities do not change, and retry is zero. The browser half uses held long polls on a same-origin plugin route rather than a permanent 500 ms loop. The proven previous listener stores only hashes of per-tab capabilities and puts every responsive registered tab into a waiting state. Only after ownership-stable service readiness and canary success does the final listener tell each tab to reload when its cookie is still accepted, or return that final process's same-origin one-time URL for an in-memory `location.replace()` after 401. The authenticated page acknowledges and returns to the original safe pathname with query and fragment discarded. One real ACK gates terminal ready; other registered, unacknowledged tabs remain eligible after state compaction. If no original tab registered or none acknowledges in time, the watchdog requests one system-open fallback and still waits for its authenticated page acknowledgement; opener exit 0 alone never completes handoff. Server readiness/canary and browser handoff are recorded separately, and all required evidence must complete before session wake-up. A naked 401, an old listener's 200, a target's transient 200, or a subsequent target exit can never become ready or hand a rejected target URL to the browser. No raw browser capability or bearer URL enters a state file, durable log, or receipt. `launch-cutover.json` records redacted configuration summaries, process identities, authentication, handoff, stability, and per-role failures. Target readiness/canary remains under `targetValidation`; previous recovery readiness and a passing, failing, or explicitly skipped recovery canary (when only a target-scoped credential exists) live under `recovery.validation`. A restored receipt can no longer carry an unqualified target canary failure beside previous readiness. `launch-status` prints the receipt without exposing either command. During a transaction, `abort-cutover --state-dir "$DSH_HOME/state"` applies the pre-approved recovery policy, while `restore-previous --state-dir "$DSH_HOME/state"` explicitly authorizes stopping the proven target and restoring the complete previous spec. The two actions use separate atomic markers and restore always wins on read, so even concurrent sessions cannot let a later ordinary abort downgrade an explicit restore.
When a candidate cannot read a reconstructible projection or cache left by the previous host, `--transition-file` can submit a reviewed schema-v1 quarantine plan. A plan accepts only non-overlapping, symlink-free paths below `home` that exclude guard state, with an explicit `quarantine` operation; it contains no host-version or filename knowledge. Example: `{"schemaVersion":1,"home":"/absolute/dsh-home","operations":[{"kind":"quarantine","path":"storages/","expect":"present"}]}`. Each `expect` is `present` or `absent`; isolated preflight and live apply must observe that same state or refuse before previous stops or target starts. The plan must cover both old paths that need to leave before target starts and new output paths that must leave before previous can recover after a target failure; list the latter explicitly with `expect: "absent"` even when they do not exist at preparation. Do not use this mechanism for authoritative logs, credentials, or irreplaceable data. Formats that require content transformation need a separate reversible migration tool and review.
The guard first copies the live home's physical files with copy-on-write preference, then rebuilds pnpm/Cordis links as relative links whose targets are snapshot-owned copies; legitimate internal dependency cycles remain intact. External targets enter a hash-named, deduplicated materialization area inside the snapshot. A linked `node_modules` target brings its ancestor resolution layer once so Node's package lookup semantics survive. A post-copy `realpath` audit requires every writable link target to remain under the snapshot root. Runtime entries without copyable semantics (sockets, FIFOs, and links to them) are skipped and counted, and the top-level `scratch/` tree never joins the copy; dangling or unresolvable links, any other special files, writable escapes, and read/copy failures make `reconfigure` fail closed before it runs the candidate, creates a cutover, or stops previous. It then applies the same quarantine and runs target composition preflight in that safe copy. If the copy cannot be prepared or the target does not boot, previous keeps serving and the live home stays unchanged. Only after the successor owns supervision and has stopped and revalidated the previous process tree does it apply the hash-bound durable plan with same-filesystem renames. If the target is rejected, the watchdog first stops its proven process, retains replacements it created at transitioned paths under `launch-transitions//rejected-target/`, restores the exact previous bytes and records the result, and only then permits previous to start. Any unproven step parks at `awaiting-user` instead of exposing previous to mixed state. After target success, the quarantined previous content remains in the cutover directory for operator disposition; it is never deleted automatically.
**The restart report reaches the model by itself — and waits for its owner.** After a scheduled restart (a pending `last-restart.json` record), the plugin queues the report as the next turn via `agent.followup` — the official wake-the-agent seam the schedule system uses for reminders — so the agent reports the restart result without any user message. Session restore after a restart is lazy (an agent is created only when the UI or an RPC touches the session), so the report goes ONLY to the session that scheduled the exit (`schedule-exit` records `$DSH_SESSION_ID` as the initiator), whenever it resumes — no other session is ever woken for reporting, and the record stays pending until its owner resumes or the next restart replaces it (new `exitAt`). A record without an initiator is claimed by the first root agent created. Only root agents, once (acknowledged on delivery). Config `reportRestartContext`: `followup` (default, autonomous), `step` (ride the first step of whatever turn comes next), or `off`.
**Interrupted sessions resume and continue by themselves.** At SIGTERM the plugin snapshots which root sessions had a live turn (plus the restart's initiator) into `interrupted-sessions.json`; on the next restart boot — a cold start drops the snapshot — it resumes those sessions via `ctx.agents.resume` and queues a "continue" followup for the interrupted ones (their logs were closed with `reason.kind === 'interrupted'` by crash-recovery repair), so a self-restart no longer silently pauses every other session. One boundary: a turn **parked on user input** (an open `ask_user_question` call or an undecided approval, read from the repaired log tail) is not interrupted work — the card persists and the user answers whenever — so such sessions are neither resumed nor continued. Config `resumeInterrupted` (default true) and `resumeDelayMs` (default 5000, lets the app's services come up first).
### supervise: one port, one owner
A port must have exactly one supervision owner, but the owner itself should be supervised — a bare detached watchdog that dies (SIGKILL, a wide `pkill`, a closed terminal, OOM) leaves the service down with zero automatic recovery. Three deployment shapes:
- **A — pure guard**: no external supervisor; the instance adopts the watchdog via `supervise` before a self-restart. Simplest, but nothing pulls the host back after an unexpected crash.
- **B — pure launchd/systemd**: the launcher owns the port with KeepAlive. Solid for crashes, but self-modification restarts are not guarded by the credential gate.
- **C — layered (recommended)**: launchd supervises the watchdog, the watchdog supervises the instance. One owner per port, and the owner is supervised. macOS: `scripts/install-launchd.sh --start "CMD"` generates a `com.dsh.watchdog.plist` (whose `ProgramArguments` run the CLI in the foreground) into `~/Library/LaunchAgents` and bootstraps it; `--force` replaces a running detached watchdog; `--uninstall` removes the job. systemd: `scripts/install-systemd.sh --start "CMD"` generates and enables the user unit `~/.config/systemd/user/dsh-watchdog.service` — `Restart=on-failure` is the counterpart of launchd's `SuccessfulExit: false`, `StartLimitIntervalSec=0` disables the start rate limit (the default puts a repeatedly restarting unit into `failed` and stops trying, which ends supervision silently), `--print` writes the unit to stdout without touching systemctl, and `--force`/`--uninstall` match the launchd installer. A user unit stops when the session ends; surviving logout needs an administrator to run `loginctl enable-linger `. Both platforms run the same command:
```sh
# The installers initialize this once, then every KeepAlive start follows the
# selected durable launch specification:
dsh-ankh-guard configure-launch --if-absent --port 3093 --start "" \
--home "$DSH_HOME" --state-dir "$DSH_HOME/state" \
--repo "" --harness-root "" \
--preflight-surface built --preflight-install-anchor "" &&
exec dsh-ankh-guard supervise --foreground --state-dir "$DSH_HOME/state"
```
`--foreground` runs the watchdog inline (adopting the port) and exits with it, so a dead watchdog triggers the external supervisor's restart. On TERM/INT or any exit the watchdog reaps what it spawned — the instance child and the give-up crash page — and removes its own pidfile, then exits non-zero; under the installed plist's `KeepAlive SuccessfulExit: false` a killed watchdog restarts the whole chain, while a deliberate `watchdog-stop` (exit 0) stays down. If a live detached watchdog already holds the pidfile, `--foreground` waits for it to exit and then takes over. If the cutover failed and restored previous during that wait, it rereads `launch-spec.json` and the receipt before spawning, so it cannot revive the pre-wait target snapshot. Exiting 0 instead would read as an intentional stop, idle the launchd job, and silently leave the other watchdog unsupervised. The detached form (`supervise` without `--foreground`) is a debug / one-shot tool — the instance adopting supervision ahead of a self-restart, or a quick manual session — not a production supervision shape, because nothing supervises the detached watchdog itself.
The checkpoint/rollback round trip:
```sh
dsh-ankh-guard checkpoint --message "before batch"
# ... modify, build, test, record, verify ...
dsh-ankh-guard canary --port 3080 # fails → roll back
dsh-ankh-guard reset
```
`restart` owns the whole restart loop in a detached process that outlives the restarted instance. It refuses to stop the instance when the gate denies (the credential check is enforced in the restart path itself, not just by procedure), SIGTERMs the listener on `--port` and waits `--stop-timeout-ms` (default 30000 — large sessions flushing out tens of thousands of log tokens can take tens of seconds) for a graceful exit before escalating to SIGKILL, starts the `--start` command detached, polls until the port listens, re-verifies, and with `--rollback` hard-resets to the recorded checkpoint when the new instance never comes up. The escalation prints a line naming the pid — it correlates with the watchdog log's `Killed: 9` for the same pid, which lives in a different log than the CLI's stdout:
```sh
dsh-ankh-guard restart \
--port 3080 --start "DSH_HOME=$HOME/.dsh-official pnpm dsh web" --rollback \
--state-dir "$DSH_HOME/state" --repo "$PWD"
```
Mounted as a cordis plugin (base bundle), the same surface is available as the `selfRestartGuard` service for in-app gates. Config: `maxAgeMinutes` (default 10), `stateDir`, `repoDir`, `reportRestartContext` (default `followup`), `fallbackGraceMs` (default 300000).
Beyond the verify/record/canary gates, the service exposes `requestRestart({ start, profile, initiator })` — the in-process restart trigger for UI-grade callers (e.g. the mode switcher): `initiator` is required (the requesting session's real id) and the port comes from the launch record. With no live watchdog it drives `restart`; supervised instances drive the `reconfigure` transactional cutover (the only safe way to change the launch command under a watchdog). The credential/preflight/marker/lock gate chain is the CLI's own, refusals return a structured `{ accepted, stage, reason }` and never stop the instance. On the browser side, beside the cutover receipt channel there is a boot-generation channel: after an ordinary restart or a crash respawn, open tabs notice the process's boot id changed over the handoff long-poll and reload once to pick up the new bundle; a sustained disconnection (~5 s) raises a neutral overlay that never promises auto-recovery, and transient blips stay silent.
## Model Experience
A shipped skill, plus two followup messages, no tool schema. The `dsh-self-restart-guard` skill is registered at apply: the full restart protocol rides the skill catalog, so an agent discovers it exactly when a task involves restarting the instance — no per-session push notice. Every boot records the registration outcome (`skill-registration.json`), surfaced as the `skill:` line in `check-env`, and a composition without the skill capability now warns in the boot log — a migration or repackaging that drops the skill shows up there instead of vanishing silently. After a restart, the restart report / interrupted-session continuation reaches only the initiating session and the sessions the restart interrupted, as plugin-sourced followup user messages; every other session stays untouched.
#### KV Cache effect
None.
## Compatibility
- npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ⚠️ degraded — everything works; the composition-preflight gate runs through the standalone `preflight-runner` (composing through the published `@deepseek-ai/dsh-app-boot` primitives with a drift tripwire, since 0.1.5-rc.1 still does not export `composeProfile`) wherever a dsh app layout resolves — `--harness-root`, the durable launch spec, `DSH_HARNESS`, or the default checkout. On a pure npm deployment with no harness checkout the gate reports a notice and proceeds instead. The original-tab bridge feature-probes the optional WebServer/connection authentication seams; hosts without token auth naturally take the existing-cookie path. Cold reads (the parked probe and the preset derivation) ride the 0.1.5 handle-based sessionPersistence (`open(id, 'read')` → `read` → `close`; the one-shot `inspect` is gone). Every other capability is intact on the npm line. minHost moves up to 0.1.5-rc.1 — older hosts stay on the previous release line.
- Historical verification: a live npm-host 0.1.1-rc.2 → 0.1.2-alpha.4 isolated cutover passed (transition preflight removed a v3 whole-unit projection cache with an old-schema record from a home copy, live apply quarantined the old file, and target completed Token URL → 303 → cookie 200, the ownership stability window, and canary at zero retries; the old file remained byte-exact in the cutover directory. An untransitioned control over the same home failed on the missing Alpha.4 record fields, demonstrating that acceptance covered the real schema break).
- source line (deepseek-harness master, fork or upstream): ✅ (verifiedHost: 0.1.5-rc.1) — the gate runs through the standalone `preflight-runner` (resolves the published `@deepseek-ai/dsh-app-boot` etc. from the live checkout), so no fork patch is required.
**Version line mapping**: the first release after 0.2.0 supports host `0.1.5-rc.1` and later; hosts on `0.1.2-rc.1` stay on `0.2.0`, hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.1`).
## Known Limitations and Deferred Work
- **The gate is enforced in `restart`/`supervise`, not the launcher** — both refuse to stop the instance on a denial, but a manual `kill`/start outside the guard still bypasses it; the watchdog is the automatic safety net that makes a bypassed gate recoverable.
- **The preflight dry-run cannot see the boot-time world** — it proves the composition applies and disposes cleanly, not the restart instant: environment divergence between the preflight subprocess and the real boot, a port taken at the restart moment, real persistent state (databases, session logs a boot migrates), and post-apply timing all remain outside its view. The watchdog's rollback-to-known-good stays the net for that territory.
- **A preflight infrastructure failure blocks restarts by design** — a subprocess that cannot produce a verdict is treated as "unproven", not "probably fine"; the refusal message names the manual exit path (kill the listener by hand; the watchdog respawns).
- **A SIGKILL crash writes no interrupted-session snapshot** — interrupted-session auto-continue covers graceful stops (SIGTERM: scheduled exits, watchdog takeovers); crash-interrupted sessions still resume lazily on open.
- **The watchdog needs a supervisor to outlive the instance** — `supervise` spawns it detached (setsid); a watchdog spawned from inside a process that is about to die must be orphaned first, so the app adopts supervision *before* exiting.
- **The guard watches the checkout, not who else works on it** — concurrent self-modifying sessions share the tree; rollbacks are anchored and recoverable, but nothing serializes the sessions themselves.
- **Dirty-tree checkpoints refuse by default** — `--include-dirty` commits the full staged/unstaged/untracked path set, so use it only after path-by-path review, explicit user approval, and where repository policy permits; pure restarts skip the checkpoint.
- **`restart`/`supervise` discover the listener via `lsof`** (macOS/Linux with lsof); the guard prefers absolute system paths, while other platforms need `--pid`.
- **Kills are per-pid identity with a descendant sweep, never per process group** — the instance is not setsid'd, so `restart`, `schedule-exit`'s exit agent, and watchdog cleanup target recorded child/listener identities. Cutover forced paths `SIGSTOP` first, then revalidate a Linux boot/start-tick token or macOS `proc_pidinfo` microsecond start time; a mismatch is only resumed and refused. Descendants are likewise frozen and their parentage rechecked while walking `pgrep -P`. Ordinary non-cutover port recovery retains a bounded listener-cleanup escape hatch; cutovers never select or kill a process by port.
## Changelog
See [CHANGELOG.md](CHANGELOG.md).