@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -18,6 +18,9 @@
18
18
  #
19
19
  # Usage:
20
20
  # install-launchd.sh --start "CMD" [--port N] [--home DIR] [--repo DIR]
21
+ # [--harness-root DIR] [--profile NAME]
22
+ # --preflight-surface source|built --preflight-install-anchor FILE
23
+ # [--preflight-runner FILE]
21
24
  # [--cli "CMD"] [--label NAME] [--force]
22
25
  # install-launchd.sh --uninstall [--label NAME]
23
26
  #
@@ -27,7 +30,14 @@
27
30
  # --home DIR dsh root: state/, logs, DSH_HOME for the job (default
28
31
  # $DSH_HOME, else $HOME/.dsh-official).
29
32
  # --repo DIR checkout the guard credential/rollback binds to (default
30
- # DSH_HARNESS, else $HOME/code/deepseek-harness).
33
+ # current directory); this may be a plugin migration repo.
34
+ # --harness-root DIR dsh host checkout used by preflight and exported to
35
+ # the child (default DSH_HARNESS, else conventional host).
36
+ # --profile NAME dsh profile used for preflight/canary metadata (default
37
+ # $DSH_PROFILE, else web).
38
+ # --preflight-surface source|built execution surface used by this start command.
39
+ # --preflight-install-anchor FILE exact @deepseek-ai/dsh/package.json used by it.
40
+ # --preflight-runner FILE matching ankh-guard runner (normally auto-resolved).
31
41
  # --cli "CMD" guard CLI invocation prefix, e.g. "node lib/cli.js" or
32
42
  # "node --import <tsx> src/cli.ts" (default: this package's
33
43
  # built lib/cli.js run via node).
@@ -44,7 +54,12 @@ set -u
44
54
  LABEL="${DSH_WD_LABEL:-com.dsh.watchdog}"
45
55
  PORT="${DSH_WD_PORT:-3080}"
46
56
  HOME_DIR="${DSH_WD_HOME:-${DSH_HOME:-$HOME/.dsh-official}}"
47
- REPO="${DSH_WD_REPO:-${DSH_HARNESS:-$HOME/code/deepseek-harness}}"
57
+ REPO="${DSH_WD_REPO:-$PWD}"
58
+ HARNESS_ROOT="${DSH_WD_HARNESS_ROOT:-${DSH_HARNESS:-$HOME/code/deepseek-harness}}"
59
+ PROFILE="${DSH_WD_PROFILE:-${DSH_PROFILE:-web}}"
60
+ PREFLIGHT_SURFACE="${DSH_WD_PREFLIGHT_SURFACE:-}"
61
+ PREFLIGHT_INSTALL_ANCHOR="${DSH_WD_PREFLIGHT_INSTALL_ANCHOR:-}"
62
+ PREFLIGHT_RUNNER="${DSH_WD_PREFLIGHT_RUNNER:-}"
48
63
  START=""
49
64
  CLI=""
50
65
  FORCE=0
@@ -70,6 +85,11 @@ while [ $# -gt 0 ]; do
70
85
  --port) PORT="${2:-}"; shift 2 ;;
71
86
  --home) HOME_DIR="${2:-}"; shift 2 ;;
72
87
  --repo) REPO="${2:-}"; shift 2 ;;
88
+ --harness-root) HARNESS_ROOT="${2:-}"; shift 2 ;;
89
+ --profile) PROFILE="${2:-}"; shift 2 ;;
90
+ --preflight-surface) PREFLIGHT_SURFACE="${2:-}"; shift 2 ;;
91
+ --preflight-install-anchor) PREFLIGHT_INSTALL_ANCHOR="${2:-}"; shift 2 ;;
92
+ --preflight-runner) PREFLIGHT_RUNNER="${2:-}"; shift 2 ;;
73
93
  --cli) CLI="${2:-}"; shift 2 ;;
74
94
  --label) LABEL="${2:-}"; shift 2 ;;
75
95
  --force) FORCE=1; shift ;;
@@ -100,6 +120,16 @@ fi
100
120
 
101
121
  STATE_DIR="$HOME_DIR/state"
102
122
  PIDFILE="$STATE_DIR/watchdog.pid"
123
+ if [ ! -f "$STATE_DIR/launch-spec.json" ]; then
124
+ if [ "$PREFLIGHT_SURFACE" != "source" ] && [ "$PREFLIGHT_SURFACE" != "built" ]; then
125
+ echo "install-launchd.sh: fresh launch state requires --preflight-surface source|built" >&2
126
+ exit 2
127
+ fi
128
+ if [ -z "$PREFLIGHT_INSTALL_ANCHOR" ]; then
129
+ echo "install-launchd.sh: fresh launch state requires --preflight-install-anchor FILE for the actual dsh execution surface" >&2
130
+ exit 2
131
+ fi
132
+ fi
103
133
  if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
104
134
  OLD_PID="$(cat "$PIDFILE")"
105
135
  if [ "$FORCE" = "1" ]; then
@@ -129,9 +159,17 @@ xml_escape() {
129
159
  printf '%s' "$1" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g; s/"/\&quot;/g; s/'"'"'/\&apos;/g'
130
160
  }
131
161
 
132
- # One bash -c line: `exec <cli> supervise --foreground ...` so launchd restarts
133
- # the CLI (which exits with the watchdog) — never the watchdog script directly.
134
- PROGRAM="exec $CLI supervise --foreground --port $PORT --start $(printf '%q' "$START") --state-dir $(printf '%q' "$STATE_DIR") --repo $(printf '%q' "$REPO") --home $(printf '%q' "$HOME_DIR")"
162
+ # Initialize the durable launch specification once, then always start from its
163
+ # selected side. Reinstalling an OS service must not silently overwrite a
164
+ # cutover/rollback decision; launch changes go through `reconfigure`.
165
+ PREFLIGHT_FLAGS=""
166
+ if [ -n "$PREFLIGHT_SURFACE" ]; then PREFLIGHT_FLAGS="$PREFLIGHT_FLAGS --preflight-surface $(printf '%q' "$PREFLIGHT_SURFACE")"; fi
167
+ if [ -n "$PREFLIGHT_INSTALL_ANCHOR" ]; then PREFLIGHT_FLAGS="$PREFLIGHT_FLAGS --preflight-install-anchor $(printf '%q' "$PREFLIGHT_INSTALL_ANCHOR")"; fi
168
+ if [ -n "$PREFLIGHT_RUNNER" ]; then PREFLIGHT_FLAGS="$PREFLIGHT_FLAGS --preflight-runner $(printf '%q' "$PREFLIGHT_RUNNER")"; fi
169
+ INIT="$CLI configure-launch --if-absent --port $PORT --start $(printf '%q' "$START") --state-dir $(printf '%q' "$STATE_DIR") --repo $(printf '%q' "$REPO") --harness-root $(printf '%q' "$HARNESS_ROOT") --home $(printf '%q' "$HOME_DIR") --profile $(printf '%q' "$PROFILE")$PREFLIGHT_FLAGS"
170
+ # One bash -c line: the CLI process (and not this setup shell) becomes the
171
+ # watchdog's parent, so launchd observes the watchdog's eventual exit status.
172
+ PROGRAM="$INIT && exec $CLI supervise --foreground --state-dir $(printf '%q' "$STATE_DIR")"
135
173
 
136
174
  PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
137
175
  mkdir -p "$(dirname "$PLIST")"
@@ -31,6 +31,9 @@
31
31
  #
32
32
  # Usage:
33
33
  # install-systemd.sh --start "CMD" [--port N] [--home DIR] [--repo DIR]
34
+ # [--harness-root DIR] [--profile NAME]
35
+ # --preflight-surface source|built --preflight-install-anchor FILE
36
+ # [--preflight-runner FILE]
34
37
  # [--cli "CMD"] [--label NAME] [--force] [--print]
35
38
  # install-systemd.sh --uninstall [--label NAME]
36
39
  #
@@ -40,7 +43,14 @@
40
43
  # --home DIR dsh root: state/, logs, DSH_HOME for the unit (default
41
44
  # $DSH_HOME, else $HOME/.dsh-official).
42
45
  # --repo DIR checkout the guard credential/rollback binds to (default
43
- # DSH_HARNESS, else $HOME/code/deepseek-harness).
46
+ # current directory); this may be a plugin migration repo.
47
+ # --harness-root DIR dsh host checkout used by preflight and exported to
48
+ # the child (default DSH_HARNESS, else conventional host).
49
+ # --profile NAME dsh profile used for preflight/canary metadata (default
50
+ # $DSH_PROFILE, else web).
51
+ # --preflight-surface source|built execution surface used by this start command.
52
+ # --preflight-install-anchor FILE exact @deepseek-ai/dsh/package.json used by it.
53
+ # --preflight-runner FILE matching ankh-guard runner (normally auto-resolved).
44
54
  # --cli "CMD" guard CLI invocation prefix (default: this package's built
45
55
  # lib/cli.js run via an absolute node path).
46
56
  # --label NAME unit name without .service (default dsh-watchdog).
@@ -60,7 +70,12 @@ set -u
60
70
  LABEL="${DSH_WD_LABEL:-dsh-watchdog}"
61
71
  PORT="${DSH_WD_PORT:-3080}"
62
72
  HOME_DIR="${DSH_WD_HOME:-${DSH_HOME:-$HOME/.dsh-official}}"
63
- REPO="${DSH_WD_REPO:-${DSH_HARNESS:-$HOME/code/deepseek-harness}}"
73
+ REPO="${DSH_WD_REPO:-$PWD}"
74
+ HARNESS_ROOT="${DSH_WD_HARNESS_ROOT:-${DSH_HARNESS:-$HOME/code/deepseek-harness}}"
75
+ PROFILE="${DSH_WD_PROFILE:-${DSH_PROFILE:-web}}"
76
+ PREFLIGHT_SURFACE="${DSH_WD_PREFLIGHT_SURFACE:-}"
77
+ PREFLIGHT_INSTALL_ANCHOR="${DSH_WD_PREFLIGHT_INSTALL_ANCHOR:-}"
78
+ PREFLIGHT_RUNNER="${DSH_WD_PREFLIGHT_RUNNER:-}"
64
79
  START=""
65
80
  CLI=""
66
81
  FORCE=0
@@ -86,6 +101,11 @@ while [ $# -gt 0 ]; do
86
101
  --port) PORT="${2:-}"; shift 2 ;;
87
102
  --home) HOME_DIR="${2:-}"; shift 2 ;;
88
103
  --repo) REPO="${2:-}"; shift 2 ;;
104
+ --harness-root) HARNESS_ROOT="${2:-}"; shift 2 ;;
105
+ --profile) PROFILE="${2:-}"; shift 2 ;;
106
+ --preflight-surface) PREFLIGHT_SURFACE="${2:-}"; shift 2 ;;
107
+ --preflight-install-anchor) PREFLIGHT_INSTALL_ANCHOR="${2:-}"; shift 2 ;;
108
+ --preflight-runner) PREFLIGHT_RUNNER="${2:-}"; shift 2 ;;
89
109
  --cli) CLI="${2:-}"; shift 2 ;;
90
110
  --label) LABEL="${2:-}"; shift 2 ;;
91
111
  --force) FORCE=1; shift ;;
@@ -119,12 +139,30 @@ if [ -z "$CLI" ]; then
119
139
  fi
120
140
 
121
141
  STATE_DIR="$HOME_DIR/state"
142
+ if [ ! -f "$STATE_DIR/launch-spec.json" ]; then
143
+ if [ "$PREFLIGHT_SURFACE" != "source" ] && [ "$PREFLIGHT_SURFACE" != "built" ]; then
144
+ echo "install-systemd.sh: fresh launch state requires --preflight-surface source|built" >&2
145
+ exit 2
146
+ fi
147
+ if [ -z "$PREFLIGHT_INSTALL_ANCHOR" ]; then
148
+ echo "install-systemd.sh: fresh launch state requires --preflight-install-anchor FILE for the actual dsh execution surface" >&2
149
+ exit 2
150
+ fi
151
+ fi
122
152
  LOG_OUT="$STATE_DIR/watchdog.log"
123
153
  LOG_ERR="$STATE_DIR/watchdog.stderr.log"
124
154
 
125
- # One bash -c line: `exec <cli> supervise --foreground ...` so systemd restarts
126
- # the CLI (which exits with the watchdog) — never the watchdog script directly.
127
- PROGRAM="exec $CLI supervise --foreground --port $PORT --start $(printf '%q' "$START") --state-dir $(printf '%q' "$STATE_DIR") --repo $(printf '%q' "$REPO") --home $(printf '%q' "$HOME_DIR")"
155
+ # Initialize the durable launch specification once, then always start from its
156
+ # selected side. Reinstalling an OS service must not silently overwrite a
157
+ # cutover/rollback decision; launch changes go through `reconfigure`.
158
+ PREFLIGHT_FLAGS=""
159
+ if [ -n "$PREFLIGHT_SURFACE" ]; then PREFLIGHT_FLAGS="$PREFLIGHT_FLAGS --preflight-surface $(printf '%q' "$PREFLIGHT_SURFACE")"; fi
160
+ if [ -n "$PREFLIGHT_INSTALL_ANCHOR" ]; then PREFLIGHT_FLAGS="$PREFLIGHT_FLAGS --preflight-install-anchor $(printf '%q' "$PREFLIGHT_INSTALL_ANCHOR")"; fi
161
+ if [ -n "$PREFLIGHT_RUNNER" ]; then PREFLIGHT_FLAGS="$PREFLIGHT_FLAGS --preflight-runner $(printf '%q' "$PREFLIGHT_RUNNER")"; fi
162
+ INIT="$CLI configure-launch --if-absent --port $PORT --start $(printf '%q' "$START") --state-dir $(printf '%q' "$STATE_DIR") --repo $(printf '%q' "$REPO") --harness-root $(printf '%q' "$HARNESS_ROOT") --home $(printf '%q' "$HOME_DIR") --profile $(printf '%q' "$PROFILE")$PREFLIGHT_FLAGS"
163
+ # One bash -c line: the CLI process (and not this setup shell) becomes the
164
+ # watchdog's parent, so systemd observes the watchdog's eventual exit status.
165
+ PROGRAM="$INIT && exec $CLI supervise --foreground --state-dir $(printf '%q' "$STATE_DIR")"
128
166
 
129
167
  # Quote one value as a single systemd argument. systemd does NOT parse shell
130
168
  # quoting: it splits on whitespace and understands double quotes with backslash
@@ -16,7 +16,7 @@ if (process.cwd().includes(`${sep}node_modules${sep}`)) {
16
16
  the running instance has not loaded the plugin and no watchdog exists yet, so a bare
17
17
  exit leaves the service DOWN:
18
18
  node_modules/@khorsheed/dsh-ankh-guard/lib/cli.js check-env
19
- node_modules/@khorsheed/dsh-ankh-guard/lib/cli.js record build+test --state-dir "$DSH_HOME/state" --repo "$PWD"
19
+ node_modules/@khorsheed/dsh-ankh-guard/lib/cli.js record build+test --state-dir "$DSH_HOME/state" --repo "$PWD" --run -- sh -c 'pnpm run build && pnpm run test'
20
20
  node_modules/@khorsheed/dsh-ankh-guard/lib/cli.js restart --port <port> --start "<start command>"
21
21
  or establish the watchdog first with \`supervise\`. NEVER hand-roll sleep/kill/nohup restart
22
22
  scripts — they die with the instance (its teardown reaps managed processes).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dsh-self-restart-guard
3
- description: Use before ANY self-modification batch that may end in restarting the running dsh web instance (product code, client plugins, tsconfig/bundle registrations, dependencies). Enforces the green-build credential gate, the pre-batch checkpoint, and the post-restart canary so a broken self-change rolls back instead of taking the instance down. Also consult it to query the guard state or the recorded pitfalls before deployment.
3
+ description: Use before a self-modification batch, an agent-driven restart, or a launch-configuration cutover of the running dsh web instance. Enforces execution-backed green evidence, clean-tree/HEAD binding, authoritative supervisor/child/listener ownership, the checkpoint policy for real edits, durable abort/full-spec recovery, and the post-restart canary. Also consult it to query guard state or recorded deployment pitfalls.
4
4
  ---
5
5
 
6
6
  # Self-Restart Guard: atomic self-modification protocol
@@ -21,50 +21,76 @@ Never restart "the instance" from a recipe — restart the one you are actually
21
21
 
22
22
  1. **State dir**: `$DSH_HOME/state` (already in your environment). If `DSH_HOME` is unset, the deployment is custom — ask the user rather than guessing.
23
23
  2. **Start command and repo**: read `$DSH_HOME/state/instance-launch.json` once the plugin has booted there — the plugin records the instance's exact launch command (cwd, env, full argv incl. `--import` chains) at every boot. `restart`/`supervise` consume it automatically, so `--start` is usually unnecessary. Only construct a start command yourself when the record does not exist (plugin not loaded yet).
24
- 3. **Port**: the GUI address in your context (e.g. the system prompt's URL), else `lsof -a -p <instance-pid> -iTCP -sTCP:LISTEN -P`. The `restart` verb also defaults `--port` from the launch record.
24
+ 3. **Port**: use the GUI address in your context (e.g. the system prompt's URL); otherwise run `/usr/sbin/lsof -a -p <instance-pid> -iTCP -sTCP:LISTEN -P` on macOS (or the platform's absolute `lsof` path). The `restart` verb also defaults `--port` from the launch record.
25
25
 
26
26
  Run `$GUARD check-env --state-dir "$DSH_HOME/state" --repo <repo>` for a one-shot readout of all of the above plus sandbox status and watchdog presence.
27
27
 
28
- ## The protocol (every batch)
28
+ ## Classify the operation first
29
29
 
30
- 1. **Checkpoint** — before touching anything, snapshot the working tree of the repo you are about to change:
30
+ - **Pure same-launch restart**: no source, dependency, profile, generated output, installed artifact, or launch-spec input changed. Skip the Git checkpoint because there is no pre-edit state to preserve. Run `verify` first: a fresh credential passes, and a `proven deployment valid` result may reuse the build/test evidence that a previous successful watchdog canary promoted for this exact deployment fingerprint. Missing legacy proof or any fingerprint drift requires build/test + record again. Preflight, watchdog ownership, readiness, and canary are never skipped.
31
+ - **Modification followed by restart**: take the checkpoint before editing, then commit the logical change and prove the resulting clean HEAD.
32
+ - **Launch configuration changes**: use the modification protocol as applicable, then `reconfigure`; never smuggle a new command/repo/host root/profile through `schedule-exit`.
33
+
34
+ ## The protocol
35
+
36
+ 1. **Checkpoint real edits only** — before touching anything, record the rollback point of the repo you are about to change. A clean tree records the existing HEAD without creating an empty commit. A dirty tree is refused by default; inspect every path and, only with explicit approval and where repository policy permits committing the complete snapshot, rerun with `--include-dirty`:
31
37
 
32
38
  ```sh
33
39
  $GUARD checkpoint --message "before <batch-name>" --repo <repo> --state-dir "$DSH_HOME/state"
40
+ # reviewed dirty snapshot only:
41
+ $GUARD checkpoint --message "before <batch-name>" --include-dirty --repo <repo> --state-dir "$DSH_HOME/state"
34
42
  ```
35
43
 
36
44
  2. **Modify** — make the change; register every surface the change needs (package `files`, `dsh.bundle.patch`, identity triangle, dependencies). Missing registrations are the single most common failure class — an unregistered package is invisible to every gate.
37
45
 
38
- 3. **Prove green** — run the build and tests that cover the change, then record the green-build credential bound to the repo's HEAD (freshness window 10 minutes):
46
+ 3. **Prove green on the final clean commit** — after any modification, use `record --run --` to make the guard execute the exact argv and observe exit 0. The guard clears any old credential and proven-deployment reuse before the command, and records only if HEAD is unchanged and staged, unstaged, and untracked inputs are all absent afterward. For multiple shell steps, invoke the shell explicitly as the evidence program:
39
47
 
40
48
  ```sh
41
- $GUARD record build --command "<the command that went green>" --repo <repo> --state-dir "$DSH_HOME/state"
49
+ $GUARD record build+test --repo <repo> --state-dir "$DSH_HOME/state" --run -- sh -c 'pnpm run build && pnpm run test'
42
50
  $GUARD verify --repo <repo> --state-dir "$DSH_HOME/state"
43
51
  ```
44
52
 
45
- 4. **Preflight** — the composition dry-run gate; a FAIL blocks the restart and must never be bypassed:
53
+ For a classified pure same-launch restart, run only the `verify` line first. If it reports either a fresh green credential or `proven deployment valid`, continue without rerunning the expensive command. The reusable proof exists only after this guard version has observed a complete watchdog restart and canary; `last-good-boot.json` by itself, an older guard state, or a matching HEAD without the runtime fingerprint does not qualify. `schedule-exit` recomputes the fingerprint and pins the selected evidence SHA into the short-lived restart marker, and the successor watchdog revalidates it before accepting canary. A refusal is not bypassable: run the full evidence command above.
54
+
55
+ `--trust-command --command "..."` is reserved for an external orchestrator that already observed the command's real exit status (for example, the repository's deployment driver). It is not an agent shortcut.
56
+
57
+ 4. **Composition preflight** — `restart`, `schedule-exit`, and `reconfigure` each run this gate internally exactly once and refuse before stopping the healthy host. For an ordinary same-launch restart, call the stop-capable verb directly; use the standalone verb only to diagnose an already-observed failure, never as a speculative duplicate immediately before that verb:
46
58
 
47
59
  ```sh
48
60
  $GUARD preflight --profile web
49
61
  ```
50
62
 
51
- 5. **Restart** — the path depends on supervision:
52
- - **No watchdog yet** (e.g. right after installing the plugin): drive it with the `restart` verb — it owns stop → start → canary in a detached driver and self-detaches from the dying instance. `--start` defaults to the launch record when one exists.
53
- - **Watchdog-supervised** (a `watchdog.pid` is live in the state dir): `schedule-exit --port <port> --delay-ms 5000 --repo <repo> --initiator <your-agent-id>`. It re-verifies the credential against the repo's CURRENT HEAD, runs the composition preflight, exits the instance after the delay (so the current turn finishes), and the watchdog respawns + canaries automatically. Do NOT use bare `restart` against a supervised instance — it fights the supervisor.
63
+ The caller deadline is a separate safety budget from the guard's `--preflight-timeout-ms` (default 120000 ms). A managed Bash/tool call MUST wait longer than the internal gate: with the default, set the call's execution option to `timeoutMs: 180000`; with a custom preflight timeout, keep at least 30000 ms of caller margin. `timeoutMs` is tool metadata, not a guard CLI flag. Never wrap the command in a shorter shell timeout. The CLI prints `composition preflight START` before the wait and a PASS/refusal only after the runner settles. If the caller nevertheless kills the CLI, absence of `exit scheduled` means no restart was authorized; inspect the restart marker and receipt before deciding whether a retry is safe.
64
+
65
+ 5. **Restart or launch cutover** — the path depends on supervision and whether the complete launch specification changes:
66
+ - **No watchdog yet** (e.g. right after installing the plugin): `schedule-exit` hard-refuses because killing the host would guarantee an outage. Establish supervision, or drive the first bounce with the `restart` verb — it owns stop → start → canary in a detached driver and self-detaches from the dying instance. `--start` defaults to the launch record when one exists.
67
+ - **Watchdog-supervised, same command, home, credential repo, harness root, and profile**: run `schedule-exit --delay-ms 5000 --state-dir "$DSH_HOME/state"` (`--port` may confirm the durable port on legacy state), with `timeoutMs: 180000` on the managed Bash/tool call. It reloads the durable active spec, rejects explicit repo/host-root/profile/port conflicts, verifies that the live supervisor record owns the same command, accepts either the active repo's fresh credential or an exact canary-promoted deployment proof, runs one composition preflight against the active host root, then exits the child. Proof reuse never skips preflight or canary. The watchdog respawns + canaries automatically. Do NOT use bare `restart` against a supervised instance — it fights the supervisor and lacks the durable ownership needed for proof reuse. **Never pass `--initiator` by hand**: it defaults to `$DSH_SESSION_ID`, which the shell environment already sets to THIS session's id — that is what routes the post-restart wake-up report back to you. An invented value sends the report to a session that does not exist and you are never woken (a branch name is not a session id).
68
+ - **Watchdog-supervised, command, home, credential repo, harness root, profile, or execution surface changes**: first ensure `launch-status` shows a complete durable previous spec. If it does not, initialize the real current values with `configure-launch`; never infer previous from the target repo or legacy command record. Explicitly classify the command actually used by the successor: `built` for an npm toolchain `.../@deepseek-ai/dsh/lib/bin.js`, `source` for checkout TypeScript. Pass `--preflight-surface <source|built>` and `--preflight-install-anchor <the exact successor @deepseek-ai/dsh/package.json>`; pass `--preflight-runner` only when the matching installed guard runner cannot be auto-resolved. Do not infer the surface from `process.execArgv`, `DSH_HARNESS`, or the guard's own launch mode. Treat `--candidate-probe-command` as a caller-supplied trust boundary: the guard hash-binds it beside `--start` but cannot prove that arbitrary shell text is semantically derived from the target argv. Construct it from the same target executable and launcher argv (for the official dsh CLI, preserve the same launcher flags/order and replace the long-running action with one-shot `--dump-config`); never use an unrelated success command, because it will not catch misplaced or unknown final argv. Then use `reconfigure --start "<complete target command>" --repo <target-credential-repo> --harness-root <target-host-root> --preflight-surface ... --preflight-install-anchor ... --candidate-probe-command "..." --on-failure <policy> --browser-handoff required`. Before running it, obtain the user's explicit recovery choice: `restore-previous` restores the entire previous launch specification; `wait-for-user` parks without resetting a repository. `reconfigure` rebuilds internal pnpm/Cordis links against copied nodes, keeps contained dependency cycles, maps external targets into a deduplicated snapshot-owned area, and rejects dangling/unresolvable links, special files, or any writable target outside the snapshot before it can run candidate or stop previous. Candidate and composition checks then run there, after which the pidfile transfers atomically. It persists start identities for the old supervisor/direct child/listener plus the new driver/watchdog. The successor consumes abort/restore while waiting, bounds old-supervisor yield (default 15 seconds), and may stop only a frozen then revalidated identity; it never treats a process found by port as the target. Online port changes are refused; deploy a separately supervised authority and cut traffic over instead.
69
+ - **Candidate rejects reconstructible old state**: derive the incompatible path from source/schema evidence, classify it as reconstructible rather than authoritative, and show the user the exact plan before approval. Pass a schema-v1 JSON file through `reconfigure --transition-file <plan>`; do not put `mv`, deletion, or migration logic in `--start`. The only supported operation is `{"kind":"quarantine","path":"<home-relative path>","expect":"present|absent"}`. Build the recovery closure: list both old paths target must not see and target output paths previous must not see after a failed attempt, including paths absent before cutover. `expect` binds that observation across isolated preflight and live apply; drift refuses rather than silently changing rollback meaning. The guard also rejects traversal, symlinks, overlapping paths, guard-state overlap, cross-filesystem moves, or a plan whose bytes change after preparation. It preflights the target against an isolated transitioned copy while previous remains live, applies the hash-bound plan only after previous is proven stopped, and on recovery retains target replacements before restoring exact previous bytes. A rollback failure parks without starting previous. Never quarantine session logs, credentials, or other authority; content conversion requires a separate reviewed migration mechanism.
54
70
  - Never hand-roll `sleep; kill; nohup start` scripts — they die with the instance (teardown reaps managed processes).
55
71
 
56
- 6. **Verify after** — the port must listen again and the canary must PASS (`$DSH_HOME/state/restart.log` for the verb path, `watchdog.log` for the supervised path). On repeated boot failure the watchdog rolls the checkout back to the last known-good revision, leaving `guard-backup-*` branches on the discarded HEAD.
72
+ 6. **Verify after** — the port must listen again and the canary must PASS (`$DSH_HOME/state/restart.log` for the verb path, timestamped `watchdog.log` for the supervised path). For `reconfigure`, wait for `launch-status` to show a terminal receipt, read `$DSH_HOME/state/launch-cutover.json`, and report its supervisor/child/listener identities, redacted launch summaries, `preflight` surface/runner/install-anchor/target-command bindings and both PASS outcomes, server authentication readiness, stable-window/retry-zero proof, the later page-authored `browserHandoff` acknowledgement, per-role failure counts, transition digest/count/terminal phase when present, and recovery outcome. Read target readiness/canary from `targetValidation`; after restore, read previous readiness and its pass/fail/explicitly-skipped recovery canary from `recovery.validation`. Never describe a retained target canary failure as the restored previous host's health. A port response is never sufficient: the receipt must prove that the selected child remained alive, uniquely owned the listener through the stability window, completed at retry zero, passed or explicitly classified its role-specific canary before any browser URL was issued, and—when browser handoff is required—received an authenticated ACK from the original or fallback page. On repeated ordinary boot failure the watchdog rolls the checkout back to the last known-good revision; a cutover follows only its pre-approved full-spec recovery policy.
57
73
 
58
74
  ## Querying state
59
75
 
60
76
  ```sh
61
77
  $GUARD status --state-dir "$DSH_HOME/state" # credentials, checkpoints, restart records
78
+ $GUARD launch-status --state-dir "$DSH_HOME/state" # redacted selected spec + durable cutover receipt
62
79
  $GUARD canary --port <port> --state-dir "$DSH_HOME/state" --repo <repo>
80
+ # In-flight cutover controls:
81
+ $GUARD abort-cutover --state-dir "$DSH_HOME/state" # execute its pre-approved policy
82
+ $GUARD restore-previous --state-dir "$DSH_HOME/state" # explicit full-previous restore authorization
63
83
  ```
64
84
 
65
85
  ## Pitfalls this protocol exists for
66
86
 
67
- - **Credential/HEAD mismatch**: `schedule-exit`/`restart` refuse when the credential's revision ≠ current HEAD — rebuild and re-record after every commit. Record with `--state-dir` pointing at the SAME state the restart reads (`$DSH_HOME/state`); a record without it lands in a stray `<cwd>/.dsh-guard-state` and the gate will not see it.
87
+ - **Credential/deployment mismatch**: `verify` and `schedule-exit` prefer the fresh credential; after it expires they accept only a proof written by a successful canary for the exact clean credential/harness HEADs, launch spec, profile/install artifacts, and bound preflight execution surface. `restart` still requires the fresh credential. Any changed HEAD, staged/unstaged/untracked input, profile package, linked target, archive, runner, or anchor refuses. Commit/remove the input, then rerun the execution-backed evidence. Record with `--state-dir` pointing at the SAME state the restart reads (`$DSH_HOME/state`); a record without it lands in a stray `<cwd>/.dsh-guard-state` and the gate will not see it.
68
88
  - **Sandboxed sessions**: restart verbs refuse in a sandboxed turn (a detached driver would be reaped). Escalate through your host's per-command approval, or the user runs `/permission danger-full-access` in the session — settings pages only affect NEW sessions.
69
89
  - **Preflight FAIL is information, not friction**: it has caught unbootable profile patches and duplicate loader entry ids before they could take prod down. Fix the composition; never bypass.
90
+ - **Caller timeout is not a guard verdict**: the default internal preflight budget is 120 seconds, while managed shell tools may default to 60 seconds. Set `timeoutMs: 180000` (or at least the configured preflight timeout plus 30000 ms). `composition preflight START` without PASS/refusal or `exit scheduled` means the caller interrupted the gate before any restart was authorized; check durable markers before retrying.
70
91
  - **Profile `link:`/`file:` deps**: after rebuilding a plugin, refresh the profile install (`dsh plugin --profile web add <path-or-tarball>`) before restarting — a restart serves whatever the profile's `node_modules` currently contains.
92
+ - **401 is transport, not readiness**: require a protected final process to announce a same-authority launch URL; the watchdog proves URL → 303 → cookie-authenticated `/` → 200 with a temporary jar, holds one child/listener identity stable at retry zero, and passes canary before browser handoff. With `--browser-handoff required`, every responsive tab uses a held same-origin poll to register a per-tab capability before shutdown and waits. The final listener asks each tab to reload when its cookie is valid, or returns its own one-time same-origin URL in memory for `location.replace()` after 401; after ACK, the page returns to the original credential-free pathname. One real ACK gates terminal readiness, while slower registered tabs remain recoverable. Only if no original tab registers or none acknowledges before timeout does the watchdog request a system-open fallback, and opener success never substitutes for an authenticated page ACK. Never copy a candidate instance's URL into the final restart, and never persist or print bearer URLs or raw capabilities in state, logs, receipts, or reports.
93
+ - **First handoff rollout uses fallback**: a page loaded by an older ankh-guard has no handoff client. To test original-tab recovery, first deploy this plugin to the previous host and refresh that page, then run the host cutover. A fallback tab during the bootstrap deployment is expected.
94
+ - **An arbitrary 200 is not target identity**: the spawned direct child must still be alive; the port must have exactly one listener in that child tree; child/listener PID and start identity must remain unchanged through the stability window at retry zero. `EADDRINUSE`, a stale old listener, or a child that exits after provisional readiness fails the attempt and invokes the approved recovery path.
95
+ - **Restricted PATH is expected**: the guard resolves `/usr/sbin/lsof`, `/usr/bin/lsof`, and corresponding system `ps`/`pgrep` paths before PATH. Do not replace ownership proof with a bare `lsof` shell check or a port-only kill.
96
+ - **Intervene durably, never by signal alone**: use `abort-cutover` to execute the policy approved before the stop, or `restore-previous` to explicitly authorize a complete previous-spec restore. Each writes its own atomic marker before waking the watchdog; restore is monotonically stronger even when sessions race. Do not manually kill supervisor or child PIDs.
@@ -1,127 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- //#region lib/types/processes.js
3
- /**
4
- * Process primitives shared by the guard CLI and the exit agent: locating the
5
- * listener on a port and killing a process tree. One owner — the watchdog's
6
- * bash side keeps its own copy (different runtime), but every TypeScript
7
- * caller goes through here.
8
- */
9
- /** The first process listening on a TCP port, or null when none is (via lsof). */
10
- function findPidOnPort(port) {
11
- try {
12
- const first = execFileSync("lsof", [
13
- `-tiTCP:${port}`,
14
- "-sTCP:LISTEN",
15
- "-P"
16
- ], {
17
- encoding: "utf8",
18
- stdio: "pipe"
19
- }).trim().split("\n")[0];
20
- return first !== void 0 && first !== "" ? first : null;
21
- } catch {
22
- return null;
23
- }
24
- }
25
- /** POSIX single-quote one word for a shell command line. */
26
- function shellQuote(word) {
27
- return `'${word.replace(/'/g, "'\\''")}'`;
28
- }
29
- /**
30
- * Discover how the process on a port was launched — its exact argv from
31
- * `ps -o command=`, its cwd from lsof, and its DSH_* environment from
32
- * `ps eww` — rendered as a shell command. This exists because the agent's
33
- * sandbox blocks ps entirely, so every fresh-machine agent fell into a
34
- * process-tree archaeology loop before its first restart; the CLI (running
35
- * unsandboxed) answers the same question mechanically and reliably. Returns
36
- * null when the process is gone or ps/lsof are unavailable.
37
- * @param pid - the listener's pid.
38
- */
39
- function discoverLaunchCommand(pid) {
40
- let argv;
41
- let cwd;
42
- try {
43
- argv = execFileSync("ps", [
44
- "-o",
45
- "command=",
46
- "-p",
47
- pid
48
- ], {
49
- encoding: "utf8",
50
- stdio: "pipe"
51
- }).trim();
52
- if (argv === "") return null;
53
- } catch (error) {
54
- throw new Error(`ps unavailable: ${String(error)}`);
55
- }
56
- try {
57
- const out = execFileSync("lsof", [
58
- "-a",
59
- "-p",
60
- pid,
61
- "-d",
62
- "cwd",
63
- "-Fn"
64
- ], {
65
- encoding: "utf8",
66
- stdio: "pipe"
67
- });
68
- const match = /^n(.+)$/m.exec(out);
69
- if (match === null) return null;
70
- cwd = match[1] ?? "";
71
- } catch (error) {
72
- throw new Error(`lsof cwd unavailable: ${String(error)}`);
73
- }
74
- const TRANSIENT = /* @__PURE__ */ new Set([
75
- "DSH_ANKH_RESTART_DRIVER",
76
- "DSH_SESSION_ID",
77
- "DSH_SESSION_JSONL",
78
- "DSH_WEB_URL",
79
- "DSH_SHELL"
80
- ]);
81
- const env = {};
82
- try {
83
- const out = execFileSync("ps", [
84
- "eww",
85
- "-o",
86
- "command",
87
- "-p",
88
- pid
89
- ], {
90
- encoding: "utf8",
91
- stdio: "pipe"
92
- });
93
- for (const token of out.split(/\s+/)) {
94
- const eq = token.indexOf("=");
95
- if (eq > 0 && token.slice(0, eq).startsWith("DSH_") && !TRANSIENT.has(token.slice(0, eq))) env[token.slice(0, eq)] = token.slice(eq + 1);
96
- }
97
- } catch {}
98
- const envPart = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
99
- return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
100
- }
101
- /**
102
- * Kill a pid AND its descendants, deepest first (best effort). The supervised
103
- * instance may have forked children; a plain signal on the pid alone would
104
- * orphan them (the EADDRINUSE race the watchdog's EADDRINUSE branch exists
105
- * for). The process-group model is NOT assumed — the instance is not
106
- * setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
107
- * returning nothing is fine: the pid itself still gets the signal.
108
- */
109
- function killPidTree(pid, signal) {
110
- let children = [];
111
- try {
112
- const out = execFileSync("pgrep", ["-P", String(pid)], {
113
- encoding: "utf8",
114
- stdio: "pipe"
115
- }).trim();
116
- children = out === "" ? [] : out.split("\n");
117
- } catch {}
118
- for (const raw of children) {
119
- const child = Number(raw);
120
- if (Number.isInteger(child) && child > 0) killPidTree(child, signal);
121
- }
122
- try {
123
- process.kill(pid, signal);
124
- } catch {}
125
- }
126
- //#endregion
127
- export { findPidOnPort as n, killPidTree as r, discoverLaunchCommand as t };