geneseed 4.9.0 → 4.9.1

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 (33) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-guard.js +121 -17
  4. package/docs/web-ui.md +3 -2
  5. package/harness.config.json +1 -1
  6. package/js/hosts/hooks.mjs +11 -7
  7. package/js/hosts/loss.mjs +124 -18
  8. package/js/inspect/doctor.mjs +4 -3
  9. package/js/web/actions.mjs +2 -1
  10. package/js/web/api.mjs +9 -8
  11. package/js/web/handler.mjs +1 -1
  12. package/js/web/routes.mjs +3 -1
  13. package/js/web/server.mjs +3 -1
  14. package/js/web/state.mjs +32 -8
  15. package/package.json +1 -1
  16. package/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-GQGAZHIE.js} +1 -1
  17. package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-DktWyxim.js} +1 -1
  18. package/web/dist/assets/{Dashboard-COLskkOJ.js → Dashboard-CmSn6JfK.js} +2 -2
  19. package/web/dist/assets/{Docs-DUxizutI.js → Docs-CsWYLnN1.js} +1 -1
  20. package/web/dist/assets/{GreenhouseView-BSzVX_l7.js → GreenhouseView-Dt_cIxAY.js} +1 -1
  21. package/web/dist/assets/{Installs-Ck4YyEzs.js → Installs-CWuUU4I3.js} +2 -2
  22. package/web/dist/assets/{JournalView-BbbACsYK.js → JournalView-ClbZj_YF.js} +1 -1
  23. package/web/dist/assets/{Laws-BZ_M86r3.js → Laws-R5zZCmya.js} +1 -1
  24. package/web/dist/assets/{Library-D_4LbUg2.js → Library-u36V7oSD.js} +1 -1
  25. package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BUBOeM0C.js} +1 -1
  26. package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-BJuCYsjf.js} +1 -1
  27. package/web/dist/assets/{Personal-C66284wd.js → Personal-DWQuSAUz.js} +1 -1
  28. package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-GPQlocTw.js} +1 -1
  29. package/web/dist/assets/api-CiXlkbe0.js +1 -0
  30. package/web/dist/assets/{drawable-DlrXDOJ7.js → drawable-CE-arUnF.js} +1 -1
  31. package/web/dist/assets/{index-DJorAjeH.js → index-3lnPT_1J.js} +2 -2
  32. package/web/dist/index.html +2 -2
  33. package/web/dist/assets/api-Bp5eW_6m.js +0 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.9.1] — 2026-10-18
12
+
13
+ Patch: Deletion Is Deliberate and the root-scan check no longer fail open behind shell structure; the console stops re-running the doctor on every change.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the fix.
17
+
18
+ ### Fixed
19
+ - **Deletion Is Deliberate and the root-scan check no longer fail open when shell structure hides the program word.** `true & git reset --hard`, `(git reset --hard)`, `if …; then git reset --hard; fi`, `{ rm x; }`, `echo $(rm x)`, `` echo `rm x` ``, `sudo -u root rm x`, `env -u FOO rm x`, `nice -n 5 rm x`, `timeout 5 rm x`, `true & find / …` and `(find / …)` passed where the plain form asks. The `reset --hard` forms had regressed in 4.7.0. Several changes close it, on Claude Code, OpenClaude and Bob (`git-gate`/`tool-gate`) and in the OpenCode guard:
20
+ - A lone `&` now separates commands. `2>&1`, `<&3`, `&>` and `|&` still do not.
21
+ - Leading `(`, `{`, `!`, `then`, `do` and `else` are stripped before the program word, and so are closing `)`/`}`.
22
+ - A command substitution is judged as a command of its own.
23
+ - Wrappers drop their value-taking flags. `timeout` is now a wrapper.
24
+ - A removal that names no path asks: `… | Remove-Item`, `ri -Force`.
25
+ - `find -delete` asks, and `unlink` counts as a removal.
26
+ - Behind all of these, a removal word written unquoted after a program the gate does not read asks ("could not verify"). That covers a wrapper it does not know (`setsid rm x`), a wrapper flag of unknown arity, `ForEach-Object { Remove-Item $_ }` and `case … rm …`. So does `ssh host rm x`. The exceptions are programs whose `rm` is their own subcommand (npm, pnpm, yarn, bun, cargo, brew, docker, podman) and programs that never run their arguments (echo, printf, the grep family, `which`, `type`, `man`, `Get-Command`, `Get-Help`).
27
+ - Proven-safe forms still pass: a clean tree, a tracked clean file, a quoted pattern, and a `$(cat <<'EOF' … EOF)` commit message.
28
+
29
+ ### Changed
30
+ - **The console stops re-running the doctor when nothing it checks has changed.** A memory delete no longer drops the cached verdict (~3.6 s saved on the next overview); a finished job, an install toggle and a view switch still do. The Doctor page, the Greenhouse and Operator HUD lenses now read the cached run (groups and themes included) instead of running every check on each visit; the page's *Re-run checks* button asks for a fresh run (`GET /api/doctor?fresh=1`). The server warms the cache right after it starts listening, so an overview requested a few seconds later answers in ~0.15 s rather than ~3.8 s.
31
+
11
32
  ## [4.9.0] — 2026-10-18
12
33
 
13
34
  Minor: on/off switches for the Sealed Secrets and done gates, and the console calls the laws Dictates.
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.9.0**.
23
+ > string. Current label: **4.9.1**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -297,16 +297,31 @@ function stashExists(root) {
297
297
  * a superset of what the parse acts on.
298
298
  */
299
299
  const LOSS_CANDIDATE_RE =
300
- /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
300
+ /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
301
301
 
302
302
  class Unverified extends Error {}
303
303
  const unverified = (why) => { throw new Unverified(why); };
304
304
 
305
- const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
305
+ const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
306
306
  // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
307
307
  const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
308
308
  const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
309
- const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
309
+ // A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
310
+ // none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
311
+ // `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
312
+ const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
313
+ ['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
314
+ ['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
315
+ const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
316
+ '--non-interactive', '--ignore-environment', '--null']);
317
+ // Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
318
+ const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
319
+ // The only programs this reader does not parse that may still name a removal word and pass: their
320
+ // `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
321
+ // (a substitution in one is judged on its own). Any other program over a removal word asks.
322
+ const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
323
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
324
+ 'get-help']);
310
325
  // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
311
326
  // not on this command line in a form the parse can read, so a removal word inside one asks.
312
327
  const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
@@ -671,6 +686,8 @@ function rmAct(prog, args, dir) {
671
686
  if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
672
687
  targets.push(a);
673
688
  }
689
+ // `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
690
+ if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
674
691
  const groups = new Map();
675
692
  for (const t of targets) {
676
693
  const shown = t.replace(OPAQUE, '');
@@ -712,6 +729,69 @@ function rmAct(prog, args, dir) {
712
729
  /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
713
730
  const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
714
731
 
732
+ /**
733
+ * A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
734
+ * `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
735
+ * `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
736
+ * shares it, so `(find / …)` is a scan.
737
+ */
738
+ function stripStructure(raw) {
739
+ const t = raw.slice();
740
+ while (t.length) {
741
+ if (STRUCTURE.has(t[0])) t.shift();
742
+ else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
743
+ else break;
744
+ }
745
+ while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
746
+ if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
747
+ return t;
748
+ }
749
+
750
+ /**
751
+ * The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
752
+ * single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
753
+ * the splitter already cut an unquoted `$(a; b)` at its `;`.
754
+ */
755
+ function substitutions(text) {
756
+ const out = [];
757
+ let sq = false; let dq = false;
758
+ for (let i = 0; i < text.length; i++) {
759
+ const c = text[i];
760
+ if (sq) { if (c === "'") sq = false; continue; }
761
+ if (c === "'" && !dq) { sq = true; continue; }
762
+ if (c === '"') { dq = !dq; continue; }
763
+ if (c === '`') {
764
+ const end = text.indexOf('`', i + 1);
765
+ out.push(text.slice(i + 1, end < 0 ? text.length : end));
766
+ i = end < 0 ? text.length : end;
767
+ } else if ('$<>'.includes(c) && text[i + 1] === '(') {
768
+ let depth = 1; let j = i + 2;
769
+ for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
770
+ out.push(text.slice(i + 2, depth ? text.length : j - 1));
771
+ i = j - 1;
772
+ }
773
+ }
774
+ return out;
775
+ }
776
+
777
+ /** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
778
+ function pastWrapper(name, w) {
779
+ const [takes, plain] = WRAPPERS.get(name).split('|');
780
+ let i = 0;
781
+ for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
782
+ const a = w[i];
783
+ if (a === '--') { i += 1; break; }
784
+ if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
785
+ if (name === 'nice' && /^-\d+$/.test(a)) continue;
786
+ for (let j = 1; j < a.length; j++) {
787
+ if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
788
+ if (!plain.includes(a[j])) return null;
789
+ }
790
+ }
791
+ if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
792
+ return w.slice(i);
793
+ }
794
+
715
795
  /**
716
796
  * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
717
797
  * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
@@ -722,19 +802,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
722
802
  * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
723
803
  * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
724
804
  * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
805
+ * `split` cuts a command substitution's body into segments the same way (both callers pass their
806
+ * splitter); without it, a substitution that names a removal asks.
807
+ * @param {string[][]} segments
808
+ * @param {string} cwd
809
+ * @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
725
810
  */
726
- function lossReason(segments, cwd, { always = true } = {}) {
727
- let dir = cwd;
728
- try {
729
- for (const raw of segments) {
811
+ function lossReason(segments, cwd, { always = true, split } = {}) {
812
+ /** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
813
+ const judge = (segs, start, depth = 0) => {
814
+ let dir = start;
815
+ for (const raw of segs.map(stripStructure)) {
816
+ const text = raw.join(' ');
817
+ const named = LOSS_CANDIDATE_RE.test(text);
818
+ for (const body of named ? substitutions(text) : []) {
819
+ if (!LOSS_CANDIDATE_RE.test(body)) continue;
820
+ // Bounded: `$($($(…` nested past any real command would rescan the text once per level.
821
+ if (!split || depth >= 4) unverified('a command substitution names a removal');
822
+ const v = judge(split(body), dir, depth + 1);
823
+ if (v) return v;
824
+ }
730
825
  let w = words(raw);
731
- while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
826
+ while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
732
827
  const wrapper = progOf(w[0]);
733
- w = w.slice(1);
734
- // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
735
- if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
828
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
829
+ if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
736
830
  }
737
- if (!w.length) continue;
831
+ if (!w || !w.length) continue;
738
832
  const prog = progOf(w[0]);
739
833
  const args = w.slice(1);
740
834
  if (CD_PROGS.has(prog)) {
@@ -747,10 +841,18 @@ function lossReason(segments, cwd, { always = true } = {}) {
747
841
  else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
748
842
  else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
749
843
  unverified(`\`${prog}\` runs a command the gate cannot read`);
844
+ } else if (named && !INERT.has(prog)
845
+ && LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
846
+ // A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
847
+ // removal word: it may run it, so it is not proven safe. A quoted word is data.
848
+ unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
750
849
  }
751
850
  if (verdict) return verdict;
752
851
  }
753
852
  return null;
853
+ };
854
+ try {
855
+ return judge(segments, cwd);
754
856
  } catch (e) {
755
857
  if (!(e instanceof Unverified)) throw e;
756
858
  return `the gate could not verify what this command would lose (${e.message})`;
@@ -776,9 +878,9 @@ const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i
776
878
  const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i
777
879
  const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g
778
880
  const SCAN_WRAPPERS = new Set(["sudo", "command", "\\builtin", "time", "nice"])
779
- // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator, or a heredoc
780
- // opener (never the `<<<` here-string).
781
- const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
881
+ // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator (a lone `&`
882
+ // too, never the one in `2>&1`/`<&3`/`&>`/`|&`), or a heredoc opener (never the `<<<` here-string).
883
+ const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
782
884
  // Cut at separators OUTSIDE quotes and drop every heredoc body (to its TAG line; `<<-` allows
783
885
  // tabs): a commit message that names a root scan is data, not a command to block.
784
886
  function scanSegments(command) {
@@ -812,7 +914,8 @@ function rootScan(command) {
812
914
  if (!SCAN_WORD_RE.test(command)) return null
813
915
  for (const seg of scanSegments(command)) {
814
916
  // Quotes kept until the root test, so a quoted "a:" is told from a bare C:.
815
- let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
917
+ // `(find / …)`, `then find / …`: the grammar around the command is not its program.
918
+ let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
816
919
  while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1)
817
920
  if (!toks.length) continue
818
921
  // The program word without its path (either slash) or an `.exe` suffix.
@@ -1218,7 +1321,8 @@ export const GeneseedGuard = async (ctx) => {
1218
1321
  if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
1219
1322
  // OpenCode's bash tool may run in `workdir`, resolved against the session directory.
1220
1323
  const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
1221
- const loss = lossReason(scanSegments(c).map((seg) => seg.match(SCAN_TOKEN_RE) || []), wd, { always: false })
1324
+ const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1325
+ const loss = lossReason(cut(c), wd, { always: false, split: cut })
1222
1326
  if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
1223
1327
  }
1224
1328
  }
package/docs/web-ui.md CHANGED
@@ -165,8 +165,9 @@ streams the output of background jobs and keeps their history across reloads.
165
165
  added / missing files with per-file expansion and coloured unified-diff lines. Select
166
166
  files to **export an `improvements.md`** back-port, or **restore** them to source.
167
167
  - **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
168
- theme, parity, links, authoring gates, drift), grouping any problems per check. Re-run
169
- on demand.
168
+ theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
169
+ the server's last run — warmed at start, redone after a job, an install toggle or a view
170
+ switch — and **Re-run checks** runs every check again on demand.
170
171
 
171
172
  ### 🎨 Setup
172
173
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.9.0"
5
+ "version": "4.9.1"
6
6
  }
@@ -58,7 +58,7 @@ import {
58
58
  GATE_LEDGER, globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass,
59
59
  } from './hosts.mjs';
60
60
  import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
61
- import { LOSS_CANDIDATE_RE, lossReason } from './loss.mjs';
61
+ import { LOSS_CANDIDATE_RE, lossReason, stripStructure } from './loss.mjs';
62
62
  import { selfAndParents, readStdin } from './hooks-prims.mjs';
63
63
  import { recordUse } from './usage.mjs';
64
64
 
@@ -349,13 +349,14 @@ const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-c
349
349
  const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g;
350
350
  const SCAN_WRAPPERS = new Set(['sudo', 'command', '\\builtin', 'time', 'nice']);
351
351
  // One left-to-right pass: a comment (`#` starting a word, to the end of the line — an apostrophe
352
- // in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator, or a
352
+ // in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator (a lone `&`
353
+ // backgrounds a command and runs the next, but `2>&1`, `<&3`, `&>` and `|&` are not one), or a
353
354
  // heredoc opener (`<<TAG`, `<<-TAG`, `<< 'TAG'`, `<<"TAG"` — never the here-string `<<<`).
354
355
  // Every alternative consumes at least one character, and none can backtrack past its start.
355
- const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
356
+ const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
356
357
 
357
358
  /**
358
- * The command cut at `|`/`;`/`&&`/`||`/newline OUTSIDE quotes, with every heredoc body (the
359
+ * The command cut at `|`/`;`/`&`/`&&`/`||`/newline OUTSIDE quotes, with every heredoc body (the
359
360
  * lines after `<<TAG` up to the `TAG` line; `<<-` lets it be tab-indented) dropped: both are data.
360
361
  * Without this, `git commit -F - <<'EOF'` — the mandated multi-line commit form — or a quoted
361
362
  * `-m "fix; find / loop"` whose text merely names a root scan reads as one, which on Bob and
@@ -401,7 +402,8 @@ export function rootScan(command) {
401
402
  if (!SCAN_WORD_RE.test(command)) return null;
402
403
  for (const seg of scanSegments(command)) {
403
404
  // Tokens keep their quotes until the root test, so a quoted `"a:"` can be told from a bare `C:`.
404
- let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t));
405
+ // `(find / …)`, `then find / …`: the grammar around the command is not its program.
406
+ let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t));
405
407
  while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1);
406
408
  if (!toks.length) continue;
407
409
  // The program word without its path (either slash) or an `.exe` suffix: `C:\tools\rg.exe`.
@@ -427,6 +429,8 @@ export function rootScan(command) {
427
429
  return null;
428
430
  }
429
431
  const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
432
+ // The segments `lossReason` reads: raw tokens, quotes still on — also how it cuts a `$(…)` body.
433
+ const lossSegments = (s) => scanSegments(s).map((seg) => seg.match(SCAN_TOKEN_RE) || []);
430
434
  // A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
431
435
  // (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
432
436
  const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
@@ -439,8 +443,8 @@ function gitDecide(args, payload) {
439
443
  if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
440
444
  // A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
441
445
  // a push, and process 5 below still asks for it.
442
- const loss = lossReason(scanSegments(command).map((seg) => seg.match(SCAN_TOKEN_RE) || []),
443
- (payload && payload.cwd) || process.cwd());
446
+ const loss = lossReason(lossSegments(command), (payload && payload.cwd) || process.cwd(),
447
+ { split: lossSegments });
444
448
  if (loss) {
445
449
  return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 ${loss}. `
446
450
  + 'Confirm this specific command, or leave the data in place.');
package/js/hosts/loss.mjs CHANGED
@@ -18,14 +18,18 @@
18
18
  * `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
19
19
  * holds — the same forms on a feature branch pass;
20
20
  * - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
21
- * - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of anything git
22
- * cannot bring back: untracked, ignored or modified files, a repository's own `.git`, or
23
- * any path outside a git repository.
21
+ * - a file removal (`rm`, `rmdir`, `unlink`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
22
+ * anything git cannot bring back: untracked, ignored or modified files, a repository's own
23
+ * `.git`, or any path outside a git repository.
24
24
  *
25
25
  * FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
26
- * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`) carrying a removal,
27
- * a git that fails, times out or is missing — asks, and the reason says it could not verify.
28
- * "Not proven safe" is an ask; only "proven safe" passes.
26
+ * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`, `find -delete`)
27
+ * carrying a removal, a removal that names no path (`… | Remove-Item` takes its paths from the
28
+ * pipe), a wrapper flag whose arity it does not know, ANY program it does not read (`setsid`,
29
+ * `ForEach-Object`, `case`) with a removal named unquoted after it, a git that fails, times out
30
+ * or is missing — asks, and the reason says it could not verify. A command substitution
31
+ * (`$(…)`, backticks) is judged as a command of its own. "Not proven safe" is an ask; only
32
+ * "proven safe" passes.
29
33
  *
30
34
  * THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
31
35
  * files are untracked, and whether a branch's commits are reachable elsewhere are not readable
@@ -60,16 +64,31 @@ import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitr
60
64
  * a superset of what the parse acts on.
61
65
  */
62
66
  export const LOSS_CANDIDATE_RE =
63
- /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
67
+ /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
64
68
 
65
69
  class Unverified extends Error {}
66
70
  const unverified = (why) => { throw new Unverified(why); };
67
71
 
68
- const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
72
+ const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
69
73
  // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
70
74
  const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
71
75
  const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
72
- const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
76
+ // A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
77
+ // none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
78
+ // `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
79
+ const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
80
+ ['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
81
+ ['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
82
+ const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
83
+ '--non-interactive', '--ignore-environment', '--null']);
84
+ // Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
85
+ const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
86
+ // The only programs this reader does not parse that may still name a removal word and pass: their
87
+ // `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
88
+ // (a substitution in one is judged on its own). Any other program over a removal word asks.
89
+ const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
90
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
91
+ 'get-help']);
73
92
  // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
74
93
  // not on this command line in a form the parse can read, so a removal word inside one asks.
75
94
  const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
@@ -434,6 +453,8 @@ function rmAct(prog, args, dir) {
434
453
  if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
435
454
  targets.push(a);
436
455
  }
456
+ // `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
457
+ if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
437
458
  const groups = new Map();
438
459
  for (const t of targets) {
439
460
  const shown = t.replace(OPAQUE, '');
@@ -475,6 +496,69 @@ function rmAct(prog, args, dir) {
475
496
  /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
476
497
  const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
477
498
 
499
+ /**
500
+ * A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
501
+ * `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
502
+ * `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
503
+ * shares it, so `(find / …)` is a scan.
504
+ */
505
+ export function stripStructure(raw) {
506
+ const t = raw.slice();
507
+ while (t.length) {
508
+ if (STRUCTURE.has(t[0])) t.shift();
509
+ else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
510
+ else break;
511
+ }
512
+ while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
513
+ if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
514
+ return t;
515
+ }
516
+
517
+ /**
518
+ * The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
519
+ * single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
520
+ * the splitter already cut an unquoted `$(a; b)` at its `;`.
521
+ */
522
+ function substitutions(text) {
523
+ const out = [];
524
+ let sq = false; let dq = false;
525
+ for (let i = 0; i < text.length; i++) {
526
+ const c = text[i];
527
+ if (sq) { if (c === "'") sq = false; continue; }
528
+ if (c === "'" && !dq) { sq = true; continue; }
529
+ if (c === '"') { dq = !dq; continue; }
530
+ if (c === '`') {
531
+ const end = text.indexOf('`', i + 1);
532
+ out.push(text.slice(i + 1, end < 0 ? text.length : end));
533
+ i = end < 0 ? text.length : end;
534
+ } else if ('$<>'.includes(c) && text[i + 1] === '(') {
535
+ let depth = 1; let j = i + 2;
536
+ for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
537
+ out.push(text.slice(i + 2, depth ? text.length : j - 1));
538
+ i = j - 1;
539
+ }
540
+ }
541
+ return out;
542
+ }
543
+
544
+ /** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
545
+ function pastWrapper(name, w) {
546
+ const [takes, plain] = WRAPPERS.get(name).split('|');
547
+ let i = 0;
548
+ for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
549
+ const a = w[i];
550
+ if (a === '--') { i += 1; break; }
551
+ if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
552
+ if (name === 'nice' && /^-\d+$/.test(a)) continue;
553
+ for (let j = 1; j < a.length; j++) {
554
+ if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
555
+ if (!plain.includes(a[j])) return null;
556
+ }
557
+ }
558
+ if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
559
+ return w.slice(i);
560
+ }
561
+
478
562
  /**
479
563
  * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
480
564
  * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
@@ -485,19 +569,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
485
569
  * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
486
570
  * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
487
571
  * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
572
+ * `split` cuts a command substitution's body into segments the same way (both callers pass their
573
+ * splitter); without it, a substitution that names a removal asks.
574
+ * @param {string[][]} segments
575
+ * @param {string} cwd
576
+ * @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
488
577
  */
489
- export function lossReason(segments, cwd, { always = true } = {}) {
490
- let dir = cwd;
491
- try {
492
- for (const raw of segments) {
578
+ export function lossReason(segments, cwd, { always = true, split } = {}) {
579
+ /** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
580
+ const judge = (segs, start, depth = 0) => {
581
+ let dir = start;
582
+ for (const raw of segs.map(stripStructure)) {
583
+ const text = raw.join(' ');
584
+ const named = LOSS_CANDIDATE_RE.test(text);
585
+ for (const body of named ? substitutions(text) : []) {
586
+ if (!LOSS_CANDIDATE_RE.test(body)) continue;
587
+ // Bounded: `$($($(…` nested past any real command would rescan the text once per level.
588
+ if (!split || depth >= 4) unverified('a command substitution names a removal');
589
+ const v = judge(split(body), dir, depth + 1);
590
+ if (v) return v;
591
+ }
493
592
  let w = words(raw);
494
- while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
593
+ while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
495
594
  const wrapper = progOf(w[0]);
496
- w = w.slice(1);
497
- // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
498
- if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
595
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
596
+ if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
499
597
  }
500
- if (!w.length) continue;
598
+ if (!w || !w.length) continue;
501
599
  const prog = progOf(w[0]);
502
600
  const args = w.slice(1);
503
601
  if (CD_PROGS.has(prog)) {
@@ -510,10 +608,18 @@ export function lossReason(segments, cwd, { always = true } = {}) {
510
608
  else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
511
609
  else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
512
610
  unverified(`\`${prog}\` runs a command the gate cannot read`);
611
+ } else if (named && !INERT.has(prog)
612
+ && LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
613
+ // A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
614
+ // removal word: it may run it, so it is not proven safe. A quoted word is data.
615
+ unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
513
616
  }
514
617
  if (verdict) return verdict;
515
618
  }
516
619
  return null;
620
+ };
621
+ try {
622
+ return judge(segments, cwd);
517
623
  } catch (e) {
518
624
  if (!(e instanceof Unverified)) throw e;
519
625
  return `the gate could not verify what this command would lose (${e.message})`;
@@ -25,9 +25,10 @@
25
25
  * that this tool never shells back to the generator — is asserted directly instead.
26
26
  *
27
27
  * WHAT `doctorCollect` DOES NOT REACH, MEASURED RATHER THAN ASSUMED. It takes `groups` and
28
- * `cmdDoctor` does not pass it: that parameter is the WEB's, and `apiDoctor` in
29
- * `js/web/api.mjs` is its only caller. `ran` exists for it — four lines of accumulator that
30
- * keep the Doctor page's per-check cards a parameter rather than a rewrite.
28
+ * `cmdDoctor` does not pass it: that parameter is the WEB's, and `runDoctor` in
29
+ * `js/web/state.mjs` (the cache `apiDoctor` serves) is its only caller. `ran` exists for it —
30
+ * four lines of accumulator that keep the Doctor page's per-check cards a parameter rather
31
+ * than a rewrite.
31
32
  */
32
33
  import path from 'node:path';
33
34
  import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
@@ -307,7 +307,8 @@ export function apiMemoryDelete(state, name) {
307
307
  // CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
308
308
  // resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
309
309
  if (memoryDelete(d, name) === null) throw new NotFound(name);
310
- state.refresh();
310
+ // The doctor reads nothing in the memory store, so its cached verdict survives (`refresh`).
311
+ state.refresh({ doctor: false });
311
312
  return { archived: name };
312
313
  }
313
314
 
package/js/web/api.mjs CHANGED
@@ -16,7 +16,6 @@ import { readVersion, sourceFingerprint } from '../build/version.mjs';
16
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
17
  import { alwaysOnCost } from '../inspect/cost.mjs';
18
18
  import { diffCollect } from '../inspect/diff.mjs';
19
- import { doctorCollect } from '../inspect/doctor.mjs';
20
19
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
20
  import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
22
21
  import {
@@ -79,13 +78,15 @@ export function apiSetup(state) {
79
78
  };
80
79
  }
81
80
 
82
- /** The same engine as the `doctor` verb, grouped per check. */
83
- export function apiDoctor(state) {
84
- const groups = [];
85
- const [themes, problems] = doctorCollect({ theme: state.theme, groups });
86
- state.stampDoctor(problems);
87
- return { themes, ok: !problems.length, problems, groups,
88
- checked_at: state.doctor.checked_at };
81
+ /**
82
+ * The same engine as the `doctor` verb, grouped per check — served from the state's cached run
83
+ * (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
84
+ * for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
85
+ */
86
+ export function apiDoctor(state, fresh = false) {
87
+ if (fresh) state.runDoctor();
88
+ const { ok, problems, checked_at } = state.doctor;
89
+ return { ...state._doctorRun, ok, problems, checked_at };
89
90
  }
90
91
 
91
92
  /**
@@ -130,7 +130,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
130
130
 
131
131
  function doGet(req, res, path, ae) {
132
132
  const route = STATE_ROUTES[path];
133
- if (route !== undefined) return sendJson(res, route(state), 200, ae);
133
+ if (route !== undefined) return sendJson(res, route(state, req.url), 200, ae);
134
134
  // `pid` is what a restarting client waits on: the OLD server keeps answering this ping for
135
135
  // a moment after `/api/restart` returns, and a reload onto it fetches the old token.
136
136
  if (path === '/api/ping') {
package/js/web/routes.mjs CHANGED
@@ -146,7 +146,9 @@ export const STATE_ROUTES = {
146
146
  '/api/recent': apiRecent,
147
147
  '/api/themes': apiThemes,
148
148
  '/api/setup': apiSetup,
149
- '/api/doctor': apiDoctor,
149
+ // The one route here that reads its query: `?fresh=1` re-runs instead of serving the cache.
150
+ '/api/doctor': (state, url) => apiDoctor(state,
151
+ new URL(url || '', 'http://x').searchParams.has('fresh')),
150
152
  '/api/installs': apiInstalls,
151
153
  '/api/fleet': apiFleet,
152
154
  '/api/update': apiUpdate,
package/js/web/server.mjs CHANGED
@@ -27,7 +27,7 @@ import { isFile, printOut } from '../lib/fs.mjs';
27
27
  import { which } from '../lib/paths.mjs';
28
28
  import { parseIntStrict } from '../lib/text.mjs';
29
29
  import { promptLine } from '../lib/prompt.mjs';
30
- import { webState } from './state.mjs';
30
+ import { warmDoctor, webState } from './state.mjs';
31
31
  import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
32
32
  import { makeHandler } from './handler.mjs';
33
33
  import { JobManager } from './jobs.mjs';
@@ -165,6 +165,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
165
165
  printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
166
166
  printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
167
167
  if (openBrowser) openUrl(url);
168
+ // After the record and the ready line, so `web start`'s wait loop already has its URL.
169
+ warmDoctor(state);
168
170
  // Node kills on SIGINT by default, which would skip this message and the record
169
171
  // cleanup in `srv.on('close', ...)` below — a foreground `--daemon-internal` server
170
172
  // stopped with Ctrl-C would otherwise leave a record pointing at a dead port.