geneseed 3.13.0 → 3.15.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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/web/docs.mjs CHANGED
@@ -291,11 +291,20 @@ function subCounts(state, body) {
291
291
  /**
292
292
  * The Docs selector names a HOST; a tag names a host or a FAMILY. Bob and OpenClaude emit
293
293
  * through the Claude engine, so a `claude` tag (page or block) is the Claude family and shows
294
- * to all three of them, while `bob` / `openclaude` narrow to the one host. The same mapping as
295
- * the console's host table (`web/src/lib/hosts.js`, its `docs` column).
294
+ * to all three of them, while `bob` / `openclaude` narrow to the one host. `name` is how a
295
+ * host-only passage's visible label spells the host. One row per host: the family table, the
296
+ * host list and both marker patterns below are read off it.
296
297
  */
297
- export const HOST_FAMILY = { opencode: 'opencode', claude: 'claude', openclaude: 'claude', bob: 'claude' };
298
- export const HARNESSES = Object.keys(HOST_FAMILY);
298
+ const DOC_HOSTS = {
299
+ opencode: { family: 'opencode', name: 'OpenCode' },
300
+ claude: { family: 'claude', name: 'Claude Code' },
301
+ openclaude: { family: 'claude', name: 'OpenClaude' },
302
+ bob: { family: 'claude', name: 'IBM Bob' },
303
+ };
304
+ export const HOST_FAMILY = Object.fromEntries(
305
+ Object.entries(DOC_HOSTS).map(([id, h]) => [id, h.family]));
306
+ export const HARNESSES = Object.keys(DOC_HOSTS);
307
+ const anyOf = (words) => `(?:${words.join('|')})`;
299
308
  /**
300
309
  * A page or block tagged `tag` shows to `host`. Untagged content shows to every host. One tag
301
310
  * names a host or a family; a LIST names hosts exactly, no family widening, because picking
@@ -307,7 +316,7 @@ export const harnessShows = (tag, host) => (Array.isArray(tag)
307
316
  : !tag || tag === host || tag === HOST_FAMILY[host]);
308
317
  /** A block's tag: `claude` alone, or `claude,openclaude` — the list, read as above. */
309
318
  const harnessTag = (raw) => (raw.includes(',') ? raw.split(/\s*,\s*/) : raw);
310
- const HOST_ID = '(?:opencode|claude|openclaude|bob)';
319
+ const HOST_ID = anyOf(HARNESSES);
311
320
  const HARNESS_OPEN_RE = new RegExp(String.raw`^\s*<!--\s*harness:(${HOST_ID}(?:\s*,\s*${HOST_ID})*)\s*-->\s*$`);
312
321
  const HARNESS_CLOSE_RE = /^\s*<!--\s*\/harness\s*-->\s*$/;
313
322
  /**
@@ -328,9 +337,9 @@ export function normHarness(value, state) {
328
337
  /**
329
338
  * Every open has a matching close, with no nesting.
330
339
  *
331
- * A marker inside a ``` fence is example text, not a marker (the same rule `sliceSection`
332
- * uses). Its purpose is to FAIL OPEN: an unbalanced marker leaves the body untouched, so a
333
- * typo can never blank the rest of a page.
340
+ * A marker inside a ``` fence is example text, not a marker (the same rule the frontend's
341
+ * outline uses). Its purpose is to FAIL OPEN: an unbalanced marker leaves the body untouched,
342
+ * so a typo can never blank the rest of a page.
334
343
  *
335
344
  * EXPORTED FOR THE AUTHORING GATE, in the shape `installAgentEntryOf` established: failing
336
345
  * open means a typo is INVISIBLE in the rendered output — the page simply shows everything,
@@ -359,7 +368,7 @@ export function harnessBlocksBalanced(lines) {
359
368
  * the marker; the console has already filtered the block to the reader's host, so there the
360
369
  * label only repeats what the host selector says, and it goes with the marker.
361
370
  */
362
- const HOST_NAME = '(?:OpenCode|Claude Code|OpenClaude|IBM Bob)';
371
+ const HOST_NAME = anyOf(Object.values(DOC_HOSTS).map((h) => h.name));
363
372
  const HOST_LABEL_RE = new RegExp(String.raw`^\s*\*\(${HOST_NAME}(?: and ${HOST_NAME})* only\)\*\s*$`);
364
373
 
365
374
  export function stripHarnessBlocks(body, harnessName) {
@@ -411,16 +420,14 @@ function visibleGroups(harnessName) {
411
420
  return groups;
412
421
  }
413
422
 
414
- // ---- slugs and section slicing ----------------------------------------------------------
423
+ // ---- heading slugs -----------------------------------------------------------------------
415
424
 
416
425
  const SLUG_STRIP_RE = new RegExp(`[^a-z0-9${WHITESPACE}-]`, 'g');
417
426
  const SLUG_WS_RE = new RegExp(`[${WHITESPACE}]+`, 'g');
418
427
  const SLUG_DASH_RE = /-+/g;
419
- const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
420
428
 
421
429
  /**
422
- * The same rules the frontend's `slug()` uses, so a registry `anchor` written against a
423
- * heading matches the id the renderer assigns.
430
+ * The same rules the frontend's `slug()` uses for the id it gives a rendered heading.
424
431
  *
425
432
  * `WHITESPACE` and `stripWhitespace` rather than `\s` and `trim()` — see `WHITESPACE`'s own
426
433
  * docblock for the measured set of characters where this matters.
@@ -431,45 +438,6 @@ export function slugifyHeading(text) {
431
438
  return s.replace(/^-+|-+$/g, '');
432
439
  }
433
440
 
434
- /**
435
- * The body trimmed to one heading's section.
436
- *
437
- * Returns `[body, ok]`; `ok === false` (with the ORIGINAL body) when the anchor is
438
- * missing, so the caller falls back to the whole document. An H1 slice stops at the first
439
- * H2 — `max(level, 2)` — so it captures an intro paragraph rather than the whole file.
440
- */
441
- export function sliceSection(body, anchor) {
442
- const lines = splitLines(body);
443
- let start = -1;
444
- let startLevel = 0;
445
- let inFence = false;
446
- for (let i = 0; i < lines.length; i += 1) {
447
- const ln = lines[i];
448
- if (ln.startsWith('```')) { inFence = !inFence; continue; }
449
- if (inFence) continue;
450
- const m = HEADING_RE.exec(ln);
451
- if (!m) continue;
452
- if (slugifyHeading(m[2]) === anchor) {
453
- start = i;
454
- startLevel = Math.max(m[1].length, 2);
455
- break;
456
- }
457
- }
458
- if (start < 0) return [body, false];
459
- const out = [lines[start]];
460
- inFence = false;
461
- for (let j = start + 1; j < lines.length; j += 1) {
462
- const ln = lines[j];
463
- if (ln.startsWith('```')) { inFence = !inFence; out.push(ln); continue; }
464
- if (inFence) { out.push(ln); continue; }
465
- const m = HEADING_RE.exec(ln);
466
- if (m && m[1].length <= startLevel) break;
467
- out.push(ln);
468
- }
469
- while (out.length && out[out.length - 1].trim() === '') out.pop();
470
- return [`${out.join('\n')}\n`, true];
471
- }
472
-
473
441
  // ---- the endpoints -----------------------------------------------------------------------
474
442
 
475
443
  function findDocPage(pageId) {
@@ -523,23 +491,20 @@ function glossary(state, page) {
523
491
  * what gates the github-shaped deep links in the UI. Both come from `originDisplay()`,
524
492
  * which shells out to `git remote get-url` (the `git` row in `ALLOWED_SPAWNS`).
525
493
  *
526
- * `version` IS NOT WHAT IT LOOKS LIKE, and it is the shape this page has always had rather than
527
- * a shortcut here. It reads a `version` key off the status snapshot and THERE IS NO SUCH KEY —
528
- * the snapshot spells the fingerprints `source_fp` / `installed_fp` / `version_verdict`. So the
529
- * field is `{}`, always, and `sd.version || {}` reproduces the EXPRESSION rather than the
530
- * constant: if the key ever appears, the page gains it without another edit.
494
+ * `version` carries the status snapshot's fingerprints under the names the About page reads.
495
+ * It used to read a `version` key the snapshot never had, so the page's build rows were
496
+ * always empty; the snapshot spells them `installed_fp` / `source_fp` / `version_verdict`.
531
497
  *
532
498
  * THERE IS NO INTERPRETER FIELD, and the absence is deliberate — see `apiSetup` in
533
499
  * `api.mjs`, which spells the same absence for the same reason.
534
- *
535
- * `statusData()` is called for one dead field, and deliberately: this page has always paid that
536
- * cost, and skipping it would answer faster in a way no gate measures and no user asked for.
537
500
  */
538
501
  function about(state) {
539
502
  const sd = statusData();
540
503
  const od = originDisplay();
541
504
  return {
542
- version: sd.version || {},
505
+ version: {
506
+ installed_fp: sd.installed_fp, source_fp: sd.source_fp, verdict: sd.version_verdict,
507
+ },
543
508
  theme: state.theme,
544
509
  emit: state.emit,
545
510
  deployed: deployed(state),
@@ -590,18 +555,10 @@ export function apiDocsPage(state, pageId, harnessName = null) {
590
555
  */
591
556
  const KIND_ROUTES = {
592
557
  markdown: (state, pageId, page, hn) => {
593
- let body = readDocSource(page.source);
594
- let anchor = page.anchor ?? null;
595
- // A successful slice drops the anchor: the heading is already at the top, so the
596
- // client must not also try to scroll to it.
597
- if (anchor && page.slice) {
598
- const [sliced, ok] = sliceSection(body, anchor);
599
- body = sliced;
600
- if (ok) anchor = null;
601
- }
602
- body = rewriteDocLinks(stripHarnessBlocks(body, hn), page.source, idsByRel());
558
+ const body = rewriteDocLinks(stripHarnessBlocks(readDocSource(page.source), hn),
559
+ page.source, idsByRel());
603
560
  return { id: pageId, title: page.title, kind: 'markdown', body,
604
- source: page.source, anchor, links: resolveLinks(state, body) };
561
+ source: page.source, links: resolveLinks(state, body) };
605
562
  },
606
563
  concept: (state, pageId, page, hn) => {
607
564
  const body = rewriteDocLinks(subCounts(state, stripHarnessBlocks(page.body ?? '', hn)),
@@ -16,20 +16,16 @@ import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
16
16
  import { parseIntStrict, percentDecode } from '../lib/text.mjs';
17
17
  import { preflight } from '../maintain/update.mjs';
18
18
  import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
19
- import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
19
+ import { NotFound } from './catalog.mjs';
20
20
  import { openUrl, requestRestart } from './daemon.mjs';
21
21
  import { apiDocs, apiDocsPage } from './docs.mjs';
22
22
  import { actionCommands } from './jobs.mjs';
23
- import { POST_ROUTES, readJsonBody } from './routes.mjs';
23
+ import { POST_ROUTES, PREFIX_ROUTES, STATE_ROUTES, readJsonBody } from './routes.mjs';
24
24
  import { isFile } from '../lib/fs.mjs';
25
25
  import { readFileSync, statSync } from 'node:fs';
26
26
  import { basename, dirname, extname, join, sep } from 'node:path';
27
27
  import { gzipSync } from 'node:zlib';
28
28
 
29
- // `isFile` is owned by `js/lib/fs.mjs` now (single owner). Re-exported here so
30
- // `js/web/server.mjs`'s existing `import { isFile, ... } from './handler.mjs'` keeps working.
31
- export { isFile };
32
-
33
29
  /**
34
30
  * DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST colon, so
35
31
  * `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
@@ -45,6 +41,15 @@ const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
45
41
  'application/manifest+json', 'application/javascript'];
46
42
  const GZIP_MIN = 1024;
47
43
 
44
+ /**
45
+ * The largest POST body read, in bytes. Every real body is a few KB of JSON (a rule, a profile,
46
+ * a path); without a cap any web page could `no-cors` POST an arbitrary blob and this process
47
+ * would buffer all of it before the token check refused it.
48
+ */
49
+ export const MAX_BODY = 1024 * 1024;
50
+
51
+ const BUSY = { error: 'a job is running — wait for it to finish (or cancel it) first' };
52
+
48
53
  const CTYPES = {
49
54
  '.html': 'text/html', '.js': 'text/javascript',
50
55
  '.css': 'text/css', '.json': 'application/json',
@@ -126,7 +131,11 @@ export function makeHandler(state, jm, token, dist, holder = null) {
126
131
  function doGet(req, res, path, ae) {
127
132
  const route = STATE_ROUTES[path];
128
133
  if (route !== undefined) return sendJson(res, route(state), 200, ae);
129
- if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
134
+ // `pid` is what a restarting client waits on: the OLD server keeps answering this ping for
135
+ // a moment after `/api/restart` returns, and a reload onto it fetches the old token.
136
+ if (path === '/api/ping') {
137
+ return sendJson(res, { ok: true, theme: state.theme, pid: process.pid }, 200, ae);
138
+ }
130
139
  for (const [prefix, handler] of PREFIX_ROUTES) {
131
140
  if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
132
141
  }
@@ -148,14 +157,22 @@ export function makeHandler(state, jm, token, dist, holder = null) {
148
157
  return serveStatic(res, path, ae);
149
158
  }
150
159
 
151
- function doPost(req, res, path, ae, body) {
160
+ /** The Host and token guards, answered BEFORE a byte of the body is read. `true` = refused. */
161
+ function refusePost(req, res, ae) {
152
162
  if (!localHost(req.headers.host)) {
153
- return sendJson(res, { error: 'forbidden host' }, 403, ae);
163
+ sendJson(res, { error: 'forbidden host' }, 403, ae);
164
+ return true;
154
165
  }
155
166
  if (req.headers['x-geneseed-token'] !== token) {
156
- return sendJson(res, { error: 'forbidden' }, 403, ae);
167
+ sendJson(res, { error: 'forbidden' }, 403, ae);
168
+ return true;
157
169
  }
170
+ return false;
171
+ }
172
+
173
+ function doPost(req, res, path, ae, body) {
158
174
  if (path === '/api/shutdown') {
175
+ if (jm.busy) return sendJson(res, BUSY, 409, ae);
159
176
  // The one POST route the SHELL owns. It must answer BEFORE it stops, so closing is
160
177
  // deferred to `res.on('finish', ...)` so the response flushes first.
161
178
  // `closeAllConnections` is the other half: `close()` alone waits for idle keep-alive
@@ -167,18 +184,17 @@ export function makeHandler(state, jm, token, dist, holder = null) {
167
184
  return sendJson(res, { stopping: true }, 200, ae);
168
185
  }
169
186
  if (path === '/api/restart') {
170
- // THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
171
- // `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
172
- // record names and starts a fresh one that outlives the caller. Actually driving this
173
- // route from a test would either stop the test's own server or, worse, orphan a real
174
- // daemon that binds 4747 and serves the checkout forever in the developer's own
175
- // environment.
176
- //
177
- // So the gate asserts this dispatches to `requestRestart(state.theme)` directly rather
178
- // than by hitting the route through a live request — a declaration is not normally
179
- // enough to stand in for a dispatcher, but here probing the dispatcher costs more than
180
- // the assurance is worth.
181
- requestRestart(state.theme);
187
+ // THE SUCCESS ARM NO TEST MAY REACH. `requestRestart` spawns a DETACHED `web restart`,
188
+ // which stops whatever daemon the record names and starts a fresh one that outlives the
189
+ // caller — driven from a test, it would orphan a real daemon on 4747 in the developer's
190
+ // own environment. Its two REFUSALS are reachable and probed: a running job (409, it
191
+ // would be orphaned), and a server that is not the recorded daemon (409, see
192
+ // `requestRestart`) — which a test's server never is.
193
+ if (jm.busy) return sendJson(res, BUSY, 409, ae);
194
+ if (!requestRestart(state.theme)) {
195
+ return sendJson(res,
196
+ { error: 'foreground server: restart it from its terminal' }, 409, ae);
197
+ }
182
198
  return sendJson(res, { restarting: true }, 200, ae);
183
199
  }
184
200
  if (path === '/api/reveal') {
@@ -277,6 +293,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
277
293
  return sendJson(res,
278
294
  { precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
279
295
  }
296
+ // `requestRestart` declines on a foreground server, which then shows its stale banner.
280
297
  const jid = jm.start('update', actionCommands('update'), (rc) => {
281
298
  state.refresh();
282
299
  if (rc === 0) requestRestart(state.theme);
@@ -336,12 +353,19 @@ export function makeHandler(state, jm, token, dist, holder = null) {
336
353
  }
337
354
  }
338
355
  if (req.method === 'POST') {
339
- // The body is drained before routing regardless of what the route needs it for:
340
- // `readJsonBody` needs the bytes for a real POST body, and Node's own HTTP parser
341
- // owns the message boundary anyway — unread bytes are discarded when the response
342
- // ends rather than mis-parsed as the next request line, so this is where the bytes
343
- // have to come from either way.
356
+ // GUARDS FIRST, BODY SECOND. The Host and token checks need no body, so they answer
357
+ // before one is read — a refused request is DISCARDED (`resume`, nothing buffered), never
358
+ // collected. A body over `MAX_BODY` is refused on its declared length, and the socket is
359
+ // closed rather than drained. Otherwise the body is buffered before routing regardless
360
+ // of what the route needs it for: Node's parser owns the message boundary, so unread
361
+ // bytes are discarded when the response ends rather than mis-parsed as the next request.
362
+ if (refusePost(req, res, ae)) return req.resume();
344
363
  const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
364
+ if (length > MAX_BODY) {
365
+ res.setHeader('Connection', 'close');
366
+ sendJson(res, { error: `request body over ${MAX_BODY} bytes` }, 413, ae);
367
+ return req.resume();
368
+ }
345
369
  return readBody(req, length, (buf) => {
346
370
  req._body = buf;
347
371
  try {
package/js/web/jobs.mjs CHANGED
@@ -91,6 +91,7 @@ import { setupBuildArgs } from '../build/generate.mjs';
91
91
  import { DEFAULT_PRESET } from '../loop/score.mjs';
92
92
  import { isDict } from '../hosts/mcp.mjs';
93
93
  import { readText, writeText, isFile } from '../lib/fs.mjs';
94
+ import { which } from '../lib/paths.mjs';
94
95
  import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
95
96
 
96
97
  /**
@@ -103,7 +104,10 @@ function killJobTree(child) {
103
104
  try {
104
105
  if (process.platform === 'win32') {
105
106
  // The one machine primitive with no Node equivalent — see this module's header.
106
- spawnSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], {
107
+ // `which`, never a bare name (cwd-first on Windows); none on PATH → `child.kill()` below.
108
+ const taskkill = which('taskkill');
109
+ if (!taskkill) throw new Error('taskkill not on PATH');
110
+ spawnSync(taskkill, ['/T', '/F', '/PID', String(child.pid)], {
107
111
  timeout: 15000, windowsHide: true, stdio: 'ignore',
108
112
  });
109
113
  } else {
@@ -210,8 +214,13 @@ export class JobManager {
210
214
  }
211
215
  }
212
216
 
217
+ /**
218
+ * Persist the newest `HISTORY_MAX` finished jobs — and DROP the rest from memory too. The
219
+ * file was always capped; `_jobs` was not, so a daemon left running for weeks held every job
220
+ * it ever ran, each with up to `MEM_CAP` chars of output. Memory now holds what the file
221
+ * holds, plus whatever is running (which `_run` saves again when it finishes).
222
+ */
213
223
  _saveHistory() {
214
- if (!this._historyPath) return;
215
224
  const jobs = [...this._jobs.values()].filter((j) => j.status !== 'running')
216
225
  .map((j) => ({ ...j }));
217
226
  // STABLE sort matters: two jobs started inside the same clock tick keep insertion
@@ -219,11 +228,24 @@ export class JobManager {
219
228
  jobs.sort((a, b) => (Number(a.started ?? 0) || 0) - (Number(b.started ?? 0) || 0));
220
229
  const kept = jobs.slice(-JobManager.HISTORY_MAX)
221
230
  .map((j) => ({ ...j, output: tailChars(String(j.output), JobManager.OUTPUT_CAP) }));
231
+ const keep = new Set(kept.map((j) => String(j.id)));
232
+ for (const [id, j] of this._jobs) {
233
+ if (j.status !== 'running' && !keep.has(id)) this._jobs.delete(id);
234
+ }
235
+ if (!this._historyPath) return;
222
236
  try {
223
237
  writeText(this._historyPath, jsonDumpsCompact(kept, { bareInts: true }));
224
238
  } catch { /* a console that cannot persist its history still runs */ }
225
239
  }
226
240
 
241
+ /**
242
+ * A job is running. `/api/shutdown` and `/api/restart` refuse while it is: a running job is
243
+ * not persisted, and stopping the server under it orphans the child and loses its record.
244
+ */
245
+ get busy() {
246
+ return this._busy;
247
+ }
248
+
227
249
  /** Last `n` jobs, oldest first — the order the console appends in. */
228
250
  recent(n = JobManager.HISTORY_MAX) {
229
251
  const jobs = [...this._jobs.values()]
package/js/web/routes.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * WHAT THE WEB API ANSWERS — as a declaration, separate from the dispatcher that honours it.
3
3
  *
4
- * Two tables and two inline lists. They are not documentation: `tests/unit/web_server.test.mjs`
5
- * holds their union against a surface list written out in the test, and probes the real handler,
6
- * because a declaration is not a dispatcher — a route can be declared here and missing from the
7
- * dispatch, and nothing but a probe notices.
4
+ * Four tables and two inline lists. The two GET tables (`STATE_ROUTES`, `PREFIX_ROUTES`) used
5
+ * to live in `api.mjs`, which made it import every route module that also imported it. None of
6
+ * this is documentation: `tests/unit/web_server.test.mjs` holds their union against a surface
7
+ * list written out in the test, and probes the real handler, because a declaration is not a
8
+ * dispatcher — a route can be declared here and missing from the dispatch, and nothing but a
9
+ * probe notices.
8
10
  *
9
11
  * This file used to also carry the Python→Node port's NOT_PORTED / DECLINED partition — five
10
12
  * sets, all empty once the port finished. They were deleted when the port bookkeeping retired
@@ -15,8 +17,15 @@
15
17
  * expose X?", which has nothing to do with sockets, daemons or gzip.
16
18
  */
17
19
  import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
18
- import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcpToggle, apiMemoryDelete, apiProfileSave, apiRulesMutate, apiRulesPromote, apiSelectView } from './actions.mjs';
19
- import { apiActivityToggle } from './activity.mjs';
20
+ import { percentDecode } from '../lib/text.mjs';
21
+ import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMcpToggle, apiMemoryDelete, apiSelectView } from './actions.mjs';
22
+ import { apiActivity, apiActivityDetail, apiActivityToggle } from './activity.mjs';
23
+ import {
24
+ apiDiff, apiDoctor, apiExcludes, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
25
+ apiRecent, apiSetup, apiThemes,
26
+ } from './api.mjs';
27
+ import { NotFound, apiCatalog, apiItem } from './catalog.mjs';
28
+ import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote } from './user-files.mjs';
20
29
 
21
30
  /**
22
31
  * The POSTs this daemon answers — and this table IS the dispatch, not a declaration beside
@@ -102,3 +111,49 @@ export function readJsonBody(buf) {
102
111
  return (obj !== null && typeof obj === 'object' && obj.constructor === Object) ? obj : {};
103
112
  }
104
113
 
114
+ /**
115
+ * The prefix routes — path in, response out.
116
+ *
117
+ * `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
118
+ * that is not an escape, where this shell instead answers a 404 naming the literal text.
119
+ */
120
+ export const PREFIX_ROUTES = [
121
+ ['/api/catalog/', (state, p) => apiCatalog(state, p.split('/').pop())],
122
+ // /api/item/<type>/<name> — TYPE has no slash, the NAME keeps its slashes so a wiki
123
+ // page's relpath survives. A missing name is a 404 here rather than a 500 two frames down.
124
+ ['/api/item/', (state, p) => {
125
+ const m = /^\/api\/item\/([^/]+)\/(.+)$/.exec(p);
126
+ if (!m) throw new NotFound(p);
127
+ return apiItem(state, m[1], percentDecode(m[2]));
128
+ }],
129
+ // The sid is passed through unquoted — `apiActivityDetail`'s safe-name scheme is what
130
+ // makes the lookup safe, rather than a check out here.
131
+ ['/api/activity/', (state, p) => apiActivityDetail(
132
+ state, percentDecode(p.slice('/api/activity/'.length)))],
133
+ ];
134
+
135
+ /**
136
+ * A literal path to `api_X(state)`, table-keyed rather than a chain of `if`s — the reason
137
+ * it is a table is `tests/unit/web_server.test.mjs`'s cross-check, which requires this
138
+ * table plus `PREFIX_ROUTES` and `GET_INLINE` to equal a surface list written out in the
139
+ * test, so a route cannot appear or vanish unenumerated.
140
+ */
141
+ export const STATE_ROUTES = {
142
+ '/api/overview': apiOverview,
143
+ '/api/recent': apiRecent,
144
+ '/api/themes': apiThemes,
145
+ '/api/setup': apiSetup,
146
+ '/api/doctor': apiDoctor,
147
+ '/api/installs': apiInstalls,
148
+ '/api/excludes': apiExcludes,
149
+ '/api/profile': apiProfile,
150
+ '/api/diff': apiDiff,
151
+ '/api/activity': apiActivity,
152
+ // `/api/rules` must exist as a PAIR with its POST: `apiRulesMutate` splices `parseRules`'
153
+ // line indices, and the fingerprint every mutation must send back is what this GET
154
+ // answers — a daemon whose POST worked and whose GET 501'd would have no way to obtain one.
155
+ '/api/rules': apiRules,
156
+ '/api/mcp': apiMcp,
157
+ '/api/loops': apiLoops,
158
+ '/api/loops/active': apiLoopsActive,
159
+ };
package/js/web/server.mjs CHANGED
@@ -23,13 +23,13 @@
23
23
  * surface now lives in `js/web/routes.mjs` and `tests/unit/web_server.test.mjs`.
24
24
  */
25
25
  import { GLOBAL_MANIFEST, resolvePath } from '../hosts/hosts.mjs';
26
- import { printOut } from '../lib/fs.mjs';
26
+ import { isFile, printOut } from '../lib/fs.mjs';
27
27
  import { which } from '../lib/paths.mjs';
28
28
  import { parseIntStrict } from '../lib/text.mjs';
29
- import { promptLine } from '../maintain/setup.mjs';
30
- import { webState } from './api.mjs';
29
+ import { promptLine } from '../lib/prompt.mjs';
30
+ import { webState } from './state.mjs';
31
31
  import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
32
- import { isFile, makeHandler } from './handler.mjs';
32
+ import { makeHandler } from './handler.mjs';
33
33
  import { JobManager } from './jobs.mjs';
34
34
  import { spawnSync } from 'node:child_process';
35
35
  import { randomBytes } from 'node:crypto';
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `WebState` — the resolved view of the deployed harness every endpoint reads from.
3
+ *
4
+ * Its own module so that `daemon.mjs`, which needs only `webState(theme).target`, does not load
5
+ * every read endpoint to get it (it imported all of `api.mjs` for this one factory). It still
6
+ * reaches `catalog.mjs`, for the inventory it caches.
7
+ */
8
+ import { existsSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { opencodeConfigDir } from '../hosts/hosts.mjs';
12
+ import {
13
+ footprintOfDir, installedDefaults, modeOfDir, postureOfDir, themeOfDir, trustOfDir,
14
+ } from '../hosts/installs.mjs';
15
+ import { doctorCollect } from '../inspect/doctor.mjs';
16
+ import { DEFAULT_PRESET } from '../loop/score.mjs';
17
+ import { readText, isFile } from '../lib/fs.mjs';
18
+ import { stripWhitespace } from '../lib/text.mjs';
19
+ import { inventoryFor } from './catalog.mjs';
20
+
21
+ /**
22
+ * `YYYY-MM-DD HH:MM`, LOCAL time — `toISOString` would be UTC, which reads wrong in every
23
+ * timezone but one for a user-facing timestamp.
24
+ */
25
+ export function stampMinute(ms) {
26
+ const d = new Date(ms);
27
+ const p2 = (n) => String(n).padStart(2, '0');
28
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} `
29
+ + `${p2(d.getHours())}:${p2(d.getMinutes())}`;
30
+ }
31
+
32
+ // ---- WebState ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * The resolved view of the deployed harness a request reads from.
36
+ *
37
+ * A factory returning a plain object rather than a class, because the two cached
38
+ * properties are the only behaviour and a getter pair expresses them without ceremony.
39
+ * THE CACHING IS BEHAVIOUR, NOT AN OPTIMISATION: `doctorCollect` renders a theme per call
40
+ * and the console GETs `/api/overview` on mount and again after every mutation, so an
41
+ * uncached `doctor` would put a full build on each of those requests. `refresh()` is what a mutation calls to
42
+ * drop both.
43
+ */
44
+ export function webState(theme = null, target = null) {
45
+ const st = {
46
+ // NOT resolved: `target` is printed by three endpoints, so resolving it here would
47
+ // change what they answer.
48
+ target: target || opencodeConfigDir(),
49
+ root: null,
50
+ theme: null,
51
+ emit: null,
52
+ footprint: null,
53
+ posture: null,
54
+ mode: null,
55
+ trust: null,
56
+ _inv: null,
57
+ _doctor: null,
58
+ };
59
+ // The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob
60
+ // PROJECT install keeps its data under <repo>/.claude while the markers land at <repo>/.
61
+ st.root = st.target;
62
+ st.theme = theme || themeOfDir(st.target) || 'neutral';
63
+ st.emit = installedDefaults().emit || 'opencode-global';
64
+ // The install's sigils, each defaulted when its marker is absent (footprint to 'full').
65
+ // Read from the ROOT, where every emit writes them.
66
+ const readMarkers = () => {
67
+ st.footprint = footprintOfDir(st.root);
68
+ st.posture = postureOfDir(st.root) || 'peer';
69
+ st.mode = modeOfDir(st.root) || 'direct';
70
+ st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
71
+ };
72
+ readMarkers();
73
+
74
+ Object.defineProperty(st, 'inventory', {
75
+ get() {
76
+ if (st._inv === null) st._inv = inventoryFor(st);
77
+ return st._inv;
78
+ },
79
+ });
80
+ Object.defineProperty(st, 'doctor', {
81
+ get() {
82
+ if (st._doctor === null) {
83
+ const [, problems] = doctorCollect({ theme: st.theme });
84
+ st.stampDoctor(problems);
85
+ }
86
+ return st._doctor;
87
+ },
88
+ });
89
+ st.stampDoctor = (problems) => {
90
+ st._doctor = { ok: !problems.length, problems, checked_at: stampMinute(Date.now()) };
91
+ };
92
+ /**
93
+ * The CURRENT install's mode, from its `.geneseed-emit` marker: the ROOT first (where
94
+ * every emit writes it), then the data dir.
95
+ *
96
+ * `refresh()` below MUST call this: without it, `emit` goes stale after any write that
97
+ * re-themes or re-points the install, since the constructor only reads it once via
98
+ * `installedDefaults()`.
99
+ */
100
+ st.detectEmit = () => {
101
+ for (const d of [st.root, st.target]) {
102
+ try {
103
+ const em = path.join(d, '.geneseed-emit');
104
+ if (isFile(em)) {
105
+ const v = stripWhitespace(readText(em));
106
+ if (v) return v;
107
+ }
108
+ } catch { /* unreadable marker: try the next candidate */ }
109
+ }
110
+ return existsSync(path.join(st.root, 'CLAUDE.md')) ? 'claude-global' : 'opencode-global';
111
+ };
112
+ /**
113
+ * Re-point the console at another detected install's data dir.
114
+ *
115
+ * `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
116
+ * differs only for claude/bob/openclaude PROJECT installs, where the data sits under
117
+ * `<repo>/.claude|.bob|.openclaude` while `.geneseed-emit`/`-theme`/`-footprint` land at
118
+ * `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
119
+ * and a Diff or a Restore would then overwrite it in the wrong dialect.
120
+ */
121
+ st.selectView = (target, root = null) => {
122
+ st.target = target;
123
+ st.root = root || target;
124
+ st.theme = themeOfDir(st.root) || themeOfDir(st.target) || 'neutral';
125
+ st.emit = st.detectEmit();
126
+ readMarkers();
127
+ st._inv = null;
128
+ st._doctor = null;
129
+ };
130
+ st.refresh = () => {
131
+ st._inv = null;
132
+ st._doctor = null;
133
+ st.theme = themeOfDir(st.root) || themeOfDir(st.target) || st.theme;
134
+ st.emit = st.detectEmit() || st.emit;
135
+ readMarkers();
136
+ };
137
+ return st;
138
+ }