geneseed 3.1.0 → 3.1.2

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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
package/js/web/docs.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * cannot even run. This port's standing rule is that a copy of a value under test stops
17
17
  * being the value under test, and this would be the largest copy in it. **PAID IN P10c**,
18
18
  * by the fix the deferral named: the parser's metadata became a FILE both implementations
19
- * read — `js/cli-table.json` since P2. `js/cli.mjs` is the reader,
19
+ * read — `js/cli-table.json` since P2. `js/ui/cli.mjs` is the reader,
20
20
  * `tests/test_cli_reference.py` is what fails when the table and the parser part company
21
21
  * for as long as the parser exists, and `bin/geneseed-cli.mjs`'s `VERBS` table
22
22
  * — which was a SECOND transcription of the same 43 calls — reads it too, so the port
@@ -28,9 +28,10 @@
28
28
  * removal from `NOT_PORTED_KINDS` and a row in `KIND_ROUTES`.
29
29
  *
30
30
  * So `NOT_PORTED_KINDS` declares what is left, `api_docs_page` answers 501 for it, and
31
- * `tests/test_web_server.py` cross-checks that set against the kinds `_web_docs.py`
32
- * actually dispatches on — the same partition shape the route table took in P6b, which is
33
- * why it is a table here too and not two `if`s.
31
+ * `tests/unit/web_server.test.mjs` requires `PORTED_KINDS` ∪ `NOT_PORTED_KINDS` to equal
32
+ * `REF_KINDS` — a FROZEN LITERAL of the five kinds the reference dispatched on, since the
33
+ * reference itself is deleted. Same partition shape as the route table, which is why it is a
34
+ * table here too and not two `if`s.
34
35
  *
35
36
  * THE `?harness=` QUERY PARAM LANDS HERE. P6a skipped it deliberately (no caller); it is
36
37
  * the Docs selector, and it is the ONLY input to these endpoints that is not the checkout,
@@ -40,13 +41,15 @@
40
41
  import { readdirSync, statSync } from 'node:fs';
41
42
  import path from 'node:path';
42
43
 
43
- import { ROOT, THEMES } from '../checkout.mjs';
44
- import { cliReference } from '../cli.mjs';
45
- import { readJsonMaybe, readMaybe } from '../installs.mjs';
46
- import { pyResolve } from '../hosts.mjs';
47
- import { PY_SPACE, normcase, parseJson, pyStripSpace } from '../lib/fs.mjs';
48
- import { statusData } from '../status.mjs';
49
- import { originDisplay } from '../update.mjs';
44
+ import { ROOT, THEMES } from '../build/source.mjs';
45
+ import { cliReference } from '../ui/cli.mjs';
46
+ import { readJsonMaybe, readMaybe } from '../hosts/installs.mjs';
47
+ import { resolvePath } from '../hosts/hosts.mjs';
48
+ import { parseJson } from '../lib/json.mjs';
49
+ import { normcase } from '../lib/paths.mjs';
50
+ import { WHITESPACE, stripWhitespace } from '../lib/text.mjs';
51
+ import { statusData } from '../inspect/status.mjs';
52
+ import { originDisplay } from '../maintain/update.mjs';
50
53
  import { NotFound, deployed, resolveLinks } from './api.mjs';
51
54
 
52
55
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
@@ -262,7 +265,7 @@ export function harnessBlocksBalanced(lines) {
262
265
  /** `_web_docs._strip_harness_blocks`. */
263
266
  export function stripHarnessBlocks(body, harnessName) {
264
267
  if (!HARNESS_HINT_RE.test(body)) return body;
265
- const lines = pySplitlines(body);
268
+ const lines = splitLines(body);
266
269
  if (!harnessBlocksBalanced(lines)) return body;
267
270
  const out = [];
268
271
  let keep = true;
@@ -288,7 +291,7 @@ export function stripHarnessBlocks(body, harnessName) {
288
291
  * instead of yielding a final empty string, and that difference reaches the output here
289
292
  * because the result is re-joined with `\n`.
290
293
  */
291
- function pySplitlines(s) {
294
+ function splitLines(s) {
292
295
  const parts = s.split('\n');
293
296
  if (parts.length && parts[parts.length - 1] === '') parts.pop();
294
297
  return parts;
@@ -307,8 +310,8 @@ function visibleGroups(harnessName) {
307
310
 
308
311
  // ---- slugs and section slicing ----------------------------------------------------------
309
312
 
310
- const SLUG_STRIP_RE = new RegExp(`[^a-z0-9${PY_SPACE}-]`, 'g');
311
- const SLUG_WS_RE = new RegExp(`[${PY_SPACE}]+`, 'g');
313
+ const SLUG_STRIP_RE = new RegExp(`[^a-z0-9${WHITESPACE}-]`, 'g');
314
+ const SLUG_WS_RE = new RegExp(`[${WHITESPACE}]+`, 'g');
312
315
  const SLUG_DASH_RE = /-+/g;
313
316
  const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
314
317
 
@@ -316,14 +319,14 @@ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
316
319
  * `_web_docs._slugify_heading` — the same rules the frontend's `slug()` uses, so a
317
320
  * registry `anchor` written against a heading matches the id the renderer assigns.
318
321
  *
319
- * `PY_SPACE` and `pyStripSpace` rather than `\s` and `trim()`, and the corpus in
322
+ * `WHITESPACE` and `stripWhitespace` rather than `\s` and `trim()`, and the corpus in
320
323
  * `tests/test_pure_function_parity.py` is what made that necessary rather than tidy: the
321
324
  * two languages' whitespace classes differ at U+FEFF and at U+001C-U+001F, and a heading
322
- * carrying one slugs differently on each side. See `PY_SPACE`'s docblock for the measured
325
+ * carrying one slugs differently on each side. See `WHITESPACE`'s docblock for the measured
323
326
  * set.
324
327
  */
325
328
  export function slugifyHeading(text) {
326
- let s = pyStripSpace(text.toLowerCase()).replace(SLUG_STRIP_RE, '');
329
+ let s = stripWhitespace(text.toLowerCase()).replace(SLUG_STRIP_RE, '');
327
330
  s = s.replace(SLUG_WS_RE, '-').replace(SLUG_DASH_RE, '-');
328
331
  return s.replace(/^-+|-+$/g, '');
329
332
  }
@@ -336,7 +339,7 @@ export function slugifyHeading(text) {
336
339
  * H2 — `max(level, 2)` — so it captures an intro paragraph rather than the whole file.
337
340
  */
338
341
  export function sliceSection(body, anchor) {
339
- const lines = pySplitlines(body);
342
+ const lines = splitLines(body);
340
343
  let start = -1;
341
344
  let startLevel = 0;
342
345
  let inFence = false;
@@ -383,7 +386,7 @@ function findDocPage(pageId) {
383
386
  * but the guard is what makes that safe to say.
384
387
  */
385
388
  function readDocSource(rel) {
386
- const target = pyResolve(path.join(ROOT, rel));
389
+ const target = resolvePath(path.join(ROOT, rel));
387
390
  if (!within(target, ROOT) || !isFile(target)) throw new NotFound(rel);
388
391
  return readMaybe(target) ?? '';
389
392
  }
@@ -480,7 +483,7 @@ function about(state) {
480
483
  *
481
484
  * `about` left in P8c (`_origin_display` and the `git` spawn behind it, not the page).
482
485
  * `cli` left in P10c, and its blocker was neither: it was that `harness.build_argparser()`
483
- * cannot be introspected from Node at all. See `js/cli.mjs`.
486
+ * cannot be introspected from Node at all. See `js/ui/cli.mjs`.
484
487
  */
485
488
  export const NOT_PORTED_KINDS = new Set();
486
489
 
package/js/web/graph.mjs CHANGED
Binary file
@@ -0,0 +1,442 @@
1
+ /**
2
+ * The request handler — one function per request, from method dispatch to bytes on the wire.
3
+ *
4
+ * `makeHandler(state, jm, token, dist, holder)` is a FACTORY and the shape is load-bearing: three
5
+ * test call sites build one directly and drive it without a socket, which is what makes the web
6
+ * surface testable at all. Keep the arity.
7
+ *
8
+ * What it actually uses of HTTP is small and deliberate — a whole-buffer `gzipSync` over a few
9
+ * content types, two Cache-Control policies, a CSRF token on POST only, and a Host guard against
10
+ * DNS rebinding. No streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
11
+ * declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
12
+ */
13
+ import { resolvePath } from '../hosts/hosts.mjs';
14
+ import { doctrinesForBuild } from '../hosts/installs.mjs';
15
+ import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
16
+ import { parseIntStrict, percentDecode } from '../lib/text.mjs';
17
+ import { preflight } from '../maintain/update.mjs';
18
+ import { apiDeployCmd, apiInstallCmd, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
19
+ import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
20
+ import { openUrl, requestRestart } from './daemon.mjs';
21
+ import { apiDocs, apiDocsPage } from './docs.mjs';
22
+ import { NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
23
+ import { POST_ROUTES, notPorted, notPortedPost, readJsonBody } from './routes.mjs';
24
+ import { readFileSync, statSync } from 'node:fs';
25
+ import { basename, dirname, extname, join, sep } from 'node:path';
26
+ import { gzipSync } from 'node:zlib';
27
+
28
+ /**
29
+ * DNS-rebinding guard. The reference's own comment carries the why; the shape is the
30
+ * part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
31
+ * so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
32
+ * `[::1]` prefix test above it is for.
33
+ */
34
+ export function localHost(host) {
35
+ const h = (host || '').trim().toLowerCase();
36
+ if (h.startsWith('[::1]')) return true;
37
+ return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
38
+ }
39
+
40
+ const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
41
+ 'application/manifest+json', 'application/javascript'];
42
+ const GZIP_MIN = 1024;
43
+
44
+ const CTYPES = {
45
+ '.html': 'text/html', '.js': 'text/javascript',
46
+ '.css': 'text/css', '.json': 'application/json',
47
+ '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
48
+ '.woff2': 'font/woff2', '.woff': 'font/woff',
49
+ '.webmanifest': 'application/manifest+json',
50
+ '.png': 'image/png',
51
+ };
52
+
53
+ // ---- the request handler ---------------------------------------------------
54
+
55
+ /**
56
+ * `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
57
+ * there. P6a shipped this without it because there was no `JobManager` to pass; every caller
58
+ * moved in the same commit that added one.
59
+ */
60
+ export function makeHandler(state, jm, token, dist, holder = null) {
61
+ const staticCache = new Map();
62
+
63
+ function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
64
+ const headers = { ...(extra || {}) };
65
+ if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
66
+ && (acceptEncoding || '').includes('gzip')) {
67
+ body = gzipSync(body, { level: 6 });
68
+ headers['Content-Encoding'] = 'gzip';
69
+ headers.Vary = 'Accept-Encoding';
70
+ }
71
+ res.writeHead(code, {
72
+ 'Content-Type': ctype,
73
+ 'Content-Length': String(body.length),
74
+ ...headers,
75
+ });
76
+ res.end(body);
77
+ }
78
+
79
+ function sendJson(res, obj, code = 200, acceptEncoding = '') {
80
+ sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
81
+ 'application/json', code, null, acceptEncoding);
82
+ }
83
+
84
+ function serveStatic(res, path, acceptEncoding) {
85
+ const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
86
+ const index = resolvePath(join(dist, 'index.html'));
87
+ let fp = resolvePath(join(dist, rel));
88
+ // `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
89
+ // path itself, so `fp == dist` takes the fallback too.
90
+ if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
91
+ if (!isFile(fp)) fp = join(dist, 'index.html');
92
+ if (!isFile(fp)) {
93
+ return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
94
+ }
95
+ let data = staticCache.get(fp);
96
+ if (data === undefined) {
97
+ data = readFileSync(fp);
98
+ staticCache.set(fp, data);
99
+ }
100
+ let extra = null;
101
+ if (basename(fp) === 'index.html') {
102
+ const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
103
+ data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
104
+ extra = { 'Cache-Control': 'no-store' };
105
+ } else if (path.includes('/assets/')) {
106
+ extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
107
+ }
108
+ return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
109
+ 200, extra, acceptEncoding);
110
+ }
111
+
112
+ function doGet(req, res, path, ae) {
113
+ // The reference's own table first, then the routes that parse the path — and `ping`
114
+ // sits between them there too, so the order here is its order.
115
+ const route = STATE_ROUTES[path];
116
+ if (route !== undefined) return sendJson(res, route(state), 200, ae);
117
+ if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
118
+ for (const [prefix, handler] of PREFIX_ROUTES) {
119
+ if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
120
+ }
121
+ if (path === '/api/docs') {
122
+ return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
123
+ }
124
+ if (path.startsWith('/api/docs/page/')) {
125
+ const pid = percentDecode(path.slice('/api/docs/page/'.length));
126
+ try {
127
+ return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
128
+ } catch (e) {
129
+ // A kind P6d does not answer says so, rather than falling through to the
130
+ // `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
131
+ if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
132
+ throw e;
133
+ }
134
+ }
135
+ if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
136
+ if (path.startsWith('/api/jobs/')) {
137
+ // `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
138
+ // misses, rather than 404ing on the shape.
139
+ const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
140
+ return j ? sendJson(res, j, 200, ae)
141
+ : sendJson(res, { error: 'no such job' }, 404, ae);
142
+ }
143
+ if (notPorted(path)) {
144
+ return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
145
+ }
146
+ return serveStatic(res, path, ae);
147
+ }
148
+
149
+ function doPost(req, res, path, ae, body) {
150
+ if (!localHost(req.headers.host)) {
151
+ return sendJson(res, { error: 'forbidden host' }, 403, ae);
152
+ }
153
+ if (req.headers['x-geneseed-token'] !== token) {
154
+ return sendJson(res, { error: 'forbidden' }, 403, ae);
155
+ }
156
+ if (path === '/api/shutdown') {
157
+ // The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
158
+ // the reference runs `srv.shutdown()` on its own thread — called inline it would
159
+ // deadlock against `serve_forever`. Here the equivalent is closing after the
160
+ // response has flushed. `closeAllConnections` is the other half: `close()` alone
161
+ // waits for idle keep-alive sockets, and the client that just sent this request is
162
+ // holding one open. The reference drops them by exiting.
163
+ res.on('finish', () => {
164
+ holder.srv.close();
165
+ holder.srv.closeAllConnections();
166
+ });
167
+ return sendJson(res, { stopping: true }, 200, ae);
168
+ }
169
+ 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. Every way of asking it
173
+ // a question runs it: `tests/web_golden.py` would have its own server stopped and a
174
+ // replacement orphaned into a sandbox about to be deleted, and
175
+ // `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
176
+ // where the replacement would bind 4747 and serve the checkout forever. That is the
177
+ // same reasoning P6g used to ban a job cell that starts real work, one step further:
178
+ // there the child was merely orphaned, here it is a second daemon.
179
+ //
180
+ // So the gate is `tests/test_web_daemon.py`, which reads both implementations and
181
+ // asserts they dispatch this path to `request_restart(state.theme)` and answer the
182
+ // same body. Rule 7 says a declaration is not a dispatcher — this is the one route
183
+ // where probing the dispatcher costs more than the assurance is worth, and saying so
184
+ // beats a silently missing probe.
185
+ requestRestart(state.theme);
186
+ return sendJson(res, { restarting: true }, 200, ae);
187
+ }
188
+ if (path === '/api/reveal') {
189
+ // "It asks for a token and a URL and I don't know where to put them." The screen already
190
+ // PRINTS the config path above each target's rows; what it could not do is take you there.
191
+ //
192
+ // THE ALLOWLIST IS THE WHOLE SECURITY OF THIS ENDPOINT, same as the toggle beside it, and
193
+ // it is the same allowlist — `mcpTargetPaths()`, one function with two callers rather than
194
+ // two copies that drift. An unlisted path is a 404 before anything is opened. Without it
195
+ // this is "ask the desktop to open any path on the machine", behind a CSRF token and
196
+ // nothing else, and on Windows `openUrl` interpolates into a `cmd` command line with
197
+ // `windowsVerbatimArguments` — so the exact-match lookup is also what bounds the strings
198
+ // that can ever reach a shell to the ones the machine's own install registry produced.
199
+ //
200
+ // THE FOLDER, NOT THE FILE. `open`/`start`/`xdg-open` on a `.json` hands it to whatever
201
+ // claims that extension — which on a stock Windows box is a browser, i.e. a read-only view
202
+ // of the exact file the user is trying to EDIT. The containing folder lands in Explorer /
203
+ // Finder / the file manager every time, and the filename is already on screen beside the
204
+ // button.
205
+ const want = String(readJsonBody(body).path ?? '');
206
+ if (!mcpTargetPaths().has(want)) {
207
+ return sendJson(res, { error: `not found: ${want || '(none)'}` }, 404, ae);
208
+ }
209
+ const dir = dirname(want);
210
+ // `openUrl` SWALLOWS EVERY FAILURE BY DESIGN (a headless box has no opener), so `ok` here
211
+ // means "the path was allowed and the request was made", never "a window appeared". The
212
+ // response says which directory, so the UI can print it as the fallback instruction.
213
+ openUrl(dir);
214
+ return sendJson(res, { ok: true, dir }, 200, ae);
215
+ }
216
+ const route = POST_ROUTES.get(path);
217
+ if (route !== undefined) {
218
+ const [fn, okIs409] = route;
219
+ const obj = fn(state, readJsonBody(body));
220
+ return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
221
+ }
222
+ if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
223
+ // `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
224
+ // deeper path takes its third segment, which is what the reference looks up.
225
+ const jid = path.split('/')[3];
226
+ return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
227
+ : sendJson(res, { error: 'no running job by that id' }, 404, ae);
228
+ }
229
+ if (path.startsWith('/api/actions/')) {
230
+ return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
231
+ }
232
+ if (notPortedPost(path)) {
233
+ return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
234
+ }
235
+ return sendJson(res, { error: 'not found' }, 404, ae);
236
+ }
237
+
238
+ /**
239
+ * `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
240
+ *
241
+ * `restore` is answered BEFORE anything else because it is synchronous and returns a result
242
+ * rather than a job id. `install` and `deploy` resolve their argv from the body, and their
243
+ * refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
244
+ * means the world changed under the client) and 400 for deploy (its path came from the body,
245
+ * so a refusal means the body was wrong). That asymmetry is the reference's; a port that
246
+ * unified them would be wrong on one of the two and no cell would say which.
247
+ *
248
+ * `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
249
+ * a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
250
+ * renders as an info popup instead of a job that spawns and then fails. Its finish handler is
251
+ * its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
252
+ * the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
253
+ * failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
254
+ * reason.
255
+ *
256
+ * THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
257
+ * actions whose verb has not crossed, and letting them fall through to `actionCommands` would
258
+ * answer `{"error": "unknown action link"}` — the same thing a typo gets.
259
+ */
260
+ function doAction(res, action, body, ae) {
261
+ if (action === 'restore') {
262
+ return sendJson(res, apiRestore(state, isTruthy(body.files) ? body.files : []), 200, ae);
263
+ }
264
+ if (action === 'install') {
265
+ const plan = apiInstallCmd(state, body);
266
+ if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
267
+ return startJob(res, 'install', [plan.cmd], ae);
268
+ }
269
+ if (action === 'deploy') {
270
+ const plan = apiDeployCmd(state, body);
271
+ if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
272
+ return startJob(res, 'deploy', [plan.cmd], ae);
273
+ }
274
+ if (action === 'update') {
275
+ const pre = preflight();
276
+ if (!pre.ok) {
277
+ return sendJson(res,
278
+ { precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
279
+ }
280
+ const jid = jm.start('update', actionCommands('update'), (rc) => {
281
+ state.refresh();
282
+ if (rc === 0) requestRestart(state.theme);
283
+ });
284
+ if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
285
+ return sendJson(res, { job_id: jid }, 202, ae);
286
+ }
287
+ if (NOT_PORTED_ACTIONS.has(action)) {
288
+ return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
289
+ }
290
+ // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
291
+ // the deployed theme downstream. Footprint, posture and mode always follow the current
292
+ // install, so a re-theme preserves lean/full, the register and the operating mode.
293
+ const [theme, emit] = action === 'build'
294
+ ? buildOverride(state, body) : [state.theme, state.emit];
295
+ // The pack selection follows the install like footprint/posture/mode, and is read here
296
+ // rather than left to the table's default: a console Build must re-emit the constitution
297
+ // the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
298
+ // packs — never to `harness.config.json`, which would drop the consent gate.
299
+ const cmds = actionCommands(action, {
300
+ theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
301
+ doctrines: doctrinesForBuild(state.target),
302
+ });
303
+ if (!isTruthy(cmds)) {
304
+ return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
305
+ }
306
+ return startJob(res, action, cmds, ae);
307
+ }
308
+
309
+ /**
310
+ * The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
311
+ * Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
312
+ * the console keeps serving the old mode (the line P6b dropped and P6f restored).
313
+ */
314
+ function startJob(res, action, cmds, ae) {
315
+ const jid = jm.start(action, cmds, () => state.refresh());
316
+ if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
317
+ return sendJson(res, { job_id: jid }, 202, ae);
318
+ }
319
+
320
+ return function handler(req, res) {
321
+ const ae = req.headers['accept-encoding'] || '';
322
+ const path = (req.url || '').split('?', 1)[0];
323
+ try {
324
+ if (req.method === 'GET') {
325
+ if (!localHost(req.headers.host)) {
326
+ return sendJson(res, { error: 'forbidden host' }, 403, ae);
327
+ }
328
+ try {
329
+ return doGet(req, res, path, ae);
330
+ } catch (e) {
331
+ // The `NotFound` → 404 convention, which P6c is the first phase to raise. It
332
+ // wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
333
+ // `try` around `do_GET`'s body does — an endpoint several frames down raises and
334
+ // the shell decides the status. Anything else is still the outer 500.
335
+ if (e instanceof NotFound) {
336
+ return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
337
+ }
338
+ throw e;
339
+ }
340
+ }
341
+ if (req.method === 'POST') {
342
+ // Drain the body BEFORE routing, as the reference does and for its stated
343
+ // reason — under keep-alive an unread body is parsed as the next request line,
344
+ // and both guards answer without ever reaching a route that would read it.
345
+ //
346
+ // THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
347
+ // two mutations — reading zero bytes, then neither reading nor resuming the
348
+ // stream — both survived the keep-alive cell, which the reference needs the
349
+ // drain to pass. Node's HTTP parser owns the message boundary, so unread body
350
+ // bytes are discarded when the response ends instead of being re-parsed. It
351
+ // stays because `_read_json_body` needs the bytes from P6f on, and because a
352
+ // shell whose two implementations diverged only under load would be the worst
353
+ // kind of divergence to carry. UNREACHABLE is not the word for it: the branch
354
+ // runs on every POST, it simply cannot be observed to matter on this runtime.
355
+ const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
356
+ return readBody(req, length, (buf) => {
357
+ req._body = buf;
358
+ try {
359
+ return doPost(req, res, path, ae, buf);
360
+ } catch (e) {
361
+ // `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
362
+ // arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
363
+ // `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
364
+ // unknown target, and the reference answers 404. The 500 arm below it is what
365
+ // carries `_rule_fields`' ValueError message, which is a 500 there too.
366
+ if (e instanceof NotFound) {
367
+ return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
368
+ }
369
+ return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
370
+ }
371
+ });
372
+ }
373
+ return sendJson(res, { error: 'not found' }, 404, ae);
374
+ } catch (e) {
375
+ return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
376
+ }
377
+ };
378
+ }
379
+
380
+ /**
381
+ * `Handler._harness` — the `?harness=` query param, `null` when absent.
382
+ *
383
+ * `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
384
+ * needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
385
+ * space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
386
+ * the installed default rather than to the empty string. The first occurrence wins,
387
+ * because `parse_qs` returns a list and the reference takes `[0]`.
388
+ *
389
+ * THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
390
+ * mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
391
+ * fall through to the installed default. It stays for the reason `parse_qs`' own default
392
+ * exists — the next parameter read through this function may be one whose empty string
393
+ * means something — and because a shell whose two implementations agreed only by accident
394
+ * is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
395
+ * the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
396
+ * through the one consumer it has.
397
+ */
398
+ function harnessParam(url) {
399
+ const at = (url || '').indexOf('?');
400
+ if (at < 0) return null;
401
+ for (const pair of url.slice(at + 1).split('&')) {
402
+ if (!pair) continue;
403
+ const eq = pair.indexOf('=');
404
+ const k = percentDecode((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
405
+ const v = eq < 0 ? '' : percentDecode(pair.slice(eq + 1).replace(/\+/g, ' '));
406
+ if (k === 'harness' && v !== '') return v;
407
+ }
408
+ return null;
409
+ }
410
+
411
+ function readBody(req, length, done) {
412
+ if (!(length > 0)) {
413
+ req.resume();
414
+ return done(Buffer.alloc(0));
415
+ }
416
+ const chunks = [];
417
+ let seen = 0;
418
+ req.on('data', (c) => {
419
+ // Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
420
+ // the next request's bytes and belongs to the parser, not to this handler.
421
+ if (seen >= length) return;
422
+ chunks.push(c.subarray(0, length - seen));
423
+ seen += c.length;
424
+ });
425
+ req.on('end', () => done(Buffer.concat(chunks)));
426
+ }
427
+
428
+ export function isFile(p) {
429
+ try {
430
+ return statSync(p).isFile();
431
+ } catch {
432
+ return false;
433
+ }
434
+ }
435
+
436
+ /** `bytes.replace(old, new, 1)` — the first occurrence only. */
437
+ function replaceOnce(buf, find, repl) {
438
+ const at = buf.indexOf(find);
439
+ if (at < 0) return buf;
440
+ return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
441
+ }
442
+