geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -15,21 +15,25 @@ import { doctrinesForBuild } from '../hosts/installs.mjs';
15
15
  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
- import { apiDeployCmd, apiInstallCmd, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
18
+ import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
19
19
  import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
20
20
  import { openUrl, requestRestart } from './daemon.mjs';
21
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';
22
+ import { actionCommands } from './jobs.mjs';
23
+ import { POST_ROUTES, readJsonBody } from './routes.mjs';
24
+ import { isFile } from '../lib/fs.mjs';
24
25
  import { readFileSync, statSync } from 'node:fs';
25
26
  import { basename, dirname, extname, join, sep } from 'node:path';
26
27
  import { gzipSync } from 'node:zlib';
27
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
+
28
33
  /**
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.
34
+ * DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST colon, so
35
+ * `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
36
+ * prefix check above it is for.
33
37
  */
34
38
  export function localHost(host) {
35
39
  const h = (host || '').trim().toLowerCase();
@@ -52,19 +56,18 @@ const CTYPES = {
52
56
 
53
57
  // ---- the request handler ---------------------------------------------------
54
58
 
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
59
  export function makeHandler(state, jm, token, dist, holder = null) {
60
+ // fp -> { mtime, data, gz }. Keyed by path but VALIDATED by mtime on every hit, so a
61
+ // rebuilt web/dist serves fresh bytes without `web restart` — the re-stat costs one
62
+ // syscall against the readFileSync + gzipSync it saves. `gz` is the gzipped body,
63
+ // computed once on the first gzip-accepting request instead of per request.
61
64
  const staticCache = new Map();
62
65
 
63
- function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
66
+ function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null) {
64
67
  const headers = { ...(extra || {}) };
65
68
  if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
66
69
  && (acceptEncoding || '').includes('gzip')) {
67
- body = gzipSync(body, { level: 6 });
70
+ body = gzStore ? (gzStore.gz ??= gzipSync(body, { level: 6 })) : gzipSync(body, { level: 6 });
68
71
  headers['Content-Encoding'] = 'gzip';
69
72
  headers.Vary = 'Accept-Encoding';
70
73
  }
@@ -85,33 +88,35 @@ export function makeHandler(state, jm, token, dist, holder = null) {
85
88
  const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
86
89
  const index = resolvePath(join(dist, 'index.html'));
87
90
  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.
91
+ // STRICTLY under `dist`: `fp === dist` itself must also take the fallback below, which
92
+ // is why this checks `fp !== dist` and not just the prefix.
90
93
  if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
91
94
  if (!isFile(fp)) fp = join(dist, 'index.html');
92
95
  if (!isFile(fp)) {
93
96
  return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
94
97
  }
95
- let data = staticCache.get(fp);
96
- if (data === undefined) {
97
- data = readFileSync(fp);
98
- staticCache.set(fp, data);
98
+ const mtime = statSync(fp).mtimeMs;
99
+ let entry = staticCache.get(fp);
100
+ if (entry === undefined || entry.mtime !== mtime) {
101
+ entry = { mtime, data: readFileSync(fp), gz: null };
102
+ staticCache.set(fp, entry);
99
103
  }
104
+ let data = entry.data;
100
105
  let extra = null;
101
106
  if (basename(fp) === 'index.html') {
102
107
  const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
103
108
  data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
104
109
  extra = { 'Cache-Control': 'no-store' };
110
+ // The injected bytes differ from `entry.data`, so the gz cache must not serve them.
111
+ entry = null;
105
112
  } else if (path.includes('/assets/')) {
106
113
  extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
107
114
  }
108
115
  return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
109
- 200, extra, acceptEncoding);
116
+ 200, extra, acceptEncoding, entry);
110
117
  }
111
118
 
112
119
  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
120
  const route = STATE_ROUTES[path];
116
121
  if (route !== undefined) return sendJson(res, route(state), 200, ae);
117
122
  if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
@@ -123,26 +128,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
123
128
  }
124
129
  if (path.startsWith('/api/docs/page/')) {
125
130
  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
- }
131
+ return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
134
132
  }
135
133
  if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
136
134
  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.
135
+ // The LAST segment: `/api/jobs/a/b` looks up `b` and misses (no such job) rather than
136
+ // 404ing on the URL shape.
139
137
  const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
140
138
  return j ? sendJson(res, j, 200, ae)
141
139
  : sendJson(res, { error: 'no such job' }, 404, ae);
142
140
  }
143
- if (notPorted(path)) {
144
- return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
145
- }
146
141
  return serveStatic(res, path, ae);
147
142
  }
148
143
 
@@ -154,12 +149,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
154
149
  return sendJson(res, { error: 'forbidden' }, 403, ae);
155
150
  }
156
151
  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.
152
+ // The one POST route the SHELL owns. It must answer BEFORE it stops, so closing is
153
+ // deferred to `res.on('finish', ...)` so the response flushes first.
154
+ // `closeAllConnections` is the other half: `close()` alone waits for idle keep-alive
155
+ // sockets, and the client that just sent this request is holding one open.
163
156
  res.on('finish', () => {
164
157
  holder.srv.close();
165
158
  holder.srv.closeAllConnections();
@@ -169,19 +162,15 @@ export function makeHandler(state, jm, token, dist, holder = null) {
169
162
  if (path === '/api/restart') {
170
163
  // THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
171
164
  // `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.
165
+ // record names and starts a fresh one that outlives the caller. Actually driving this
166
+ // route from a test would either stop the test's own server or, worse, orphan a real
167
+ // daemon that binds 4747 and serves the checkout forever in the developer's own
168
+ // environment.
179
169
  //
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.
170
+ // So the gate asserts this dispatches to `requestRestart(state.theme)` directly rather
171
+ // than by hitting the route through a live request — a declaration is not normally
172
+ // enough to stand in for a dispatcher, but here probing the dispatcher costs more than
173
+ // the assurance is worth.
185
174
  requestRestart(state.theme);
186
175
  return sendJson(res, { restarting: true }, 200, ae);
187
176
  }
@@ -213,6 +202,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
213
202
  openUrl(dir);
214
203
  return sendJson(res, { ok: true, dir }, 200, ae);
215
204
  }
205
+ if (path === '/api/pick-folder') {
206
+ // INLINE, NOT `POST_ROUTES`: that table's `fn(state, body) -> object` shape answers the
207
+ // instant `fn` returns, and this endpoint cannot — it spawns a native dialog that a human
208
+ // may sit in front of for a while. `doPost` returns to its caller immediately, unresolved;
209
+ // `apiPickFolder`'s `done` calls `sendJson` whenever the child actually exits, which is
210
+ // what lets the daemon keep answering every OTHER request while the dialog is open — see
211
+ // `apiPickFolder` in `js/web/actions.mjs` for the async-spawn reasoning.
212
+ apiPickFolder((result) => sendJson(res, result, 200, ae));
213
+ return;
214
+ }
216
215
  const route = POST_ROUTES.get(path);
217
216
  if (route !== undefined) {
218
217
  const [fn, okIs409] = route;
@@ -220,8 +219,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
220
219
  return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
221
220
  }
222
221
  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.
222
+ // Positional: `/api/jobs/<id>/cancel` takes `<id>` and a deeper path takes its third
223
+ // segment regardless.
225
224
  const jid = path.split('/')[3];
226
225
  return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
227
226
  : sendJson(res, { error: 'no running job by that id' }, 404, ae);
@@ -229,33 +228,27 @@ export function makeHandler(state, jm, token, dist, holder = null) {
229
228
  if (path.startsWith('/api/actions/')) {
230
229
  return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
231
230
  }
232
- if (notPortedPost(path)) {
233
- return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
234
- }
235
231
  return sendJson(res, { error: 'not found' }, 404, ae);
236
232
  }
237
233
 
238
234
  /**
239
- * `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
235
+ * `/api/actions/<x>` dispatch order, and the order is deliberate rather than incidental.
240
236
  *
241
237
  * `restore` is answered BEFORE anything else because it is synchronous and returns a result
242
238
  * rather than a job id. `install` and `deploy` resolve their argv from the body, and their
243
239
  * refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
244
240
  * 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.
241
+ * so a refusal means the body was wrong). Unifying the two would be wrong for one of them.
247
242
  *
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.
243
+ * `update` COMES NEXT AND IS INLINE: it is gated on a LOCAL-ONLY preflight before a job is
244
+ * ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI renders
245
+ * as an info popup instead of a job that spawns and then fails. Its finish handler is its own
246
+ * too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run` in
247
+ * `jobs.mjs`), so the restart happens HERE, after the job is saved as finished, and only when
248
+ * it succeeded: a failed update changed nothing worth reloading and bouncing would disconnect
249
+ * the PWA for no reason.
255
250
  *
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.
251
+ * THEN the table, which answers a 404 for any action it does not name.
259
252
  */
260
253
  function doAction(res, action, body, ae) {
261
254
  if (action === 'restore') {
@@ -284,9 +277,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
284
277
  if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
285
278
  return sendJson(res, { job_id: jid }, 202, ae);
286
279
  }
287
- if (NOT_PORTED_ACTIONS.has(action)) {
288
- return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
289
- }
290
280
  // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
291
281
  // the deployed theme downstream. Footprint, posture and mode always follow the current
292
282
  // install, so a re-theme preserves lean/full, the register and the operating mode.
@@ -309,7 +299,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
309
299
  /**
310
300
  * The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
311
301
  * 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).
302
+ * the console keeps serving the old mode.
313
303
  */
314
304
  function startJob(res, action, cmds, ae) {
315
305
  const jid = jm.start(action, cmds, () => state.refresh());
@@ -328,10 +318,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
328
318
  try {
329
319
  return doGet(req, res, path, ae);
330
320
  } 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.
321
+ // The `NotFound` → 404 convention wraps the ROUTE DISPATCH as a whole, not each
322
+ // handler individually: an endpoint several frames down raises and this decides
323
+ // the status. Anything else still falls to the outer 500.
335
324
  if (e instanceof NotFound) {
336
325
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
337
326
  }
@@ -339,30 +328,20 @@ export function makeHandler(state, jm, token, dist, holder = null) {
339
328
  }
340
329
  }
341
330
  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.
331
+ // The body is drained before routing regardless of what the route needs it for:
332
+ // `readJsonBody` needs the bytes for a real POST body, and Node's own HTTP parser
333
+ // owns the message boundary anyway — unread bytes are discarded when the response
334
+ // ends rather than mis-parsed as the next request line, so this is where the bytes
335
+ // have to come from either way.
355
336
  const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
356
337
  return readBody(req, length, (buf) => {
357
338
  req._body = buf;
358
339
  try {
359
340
  return doPost(req, res, path, ae, buf);
360
341
  } 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.
342
+ // `apiMcpToggle`, `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and
343
+ // `apiRulesPromote` all raise `NotFound` for an unknown target, mapped to 404
344
+ // here; anything else falls to the 500 arm below, carrying its own message.
366
345
  if (e instanceof NotFound) {
367
346
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
368
347
  }
@@ -381,10 +360,19 @@ export function makeHandler(state, jm, token, dist, holder = null) {
381
360
  * `Handler._harness` — the `?harness=` query param, `null` when absent.
382
361
  *
383
362
  * `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]`.
363
+ * needs and no further: values unquoted with `+` meaning a space, and a BLANK value dropped
364
+ * (`keep_blank_values=False`), so `?harness=` resolves to the installed default rather than
365
+ * to the empty string — `'' || null` is what drops it below. The first occurrence wins,
366
+ * because `parse_qs` returns a list and the reference takes `[0]`, and so does
367
+ * `URLSearchParams.get`.
368
+ *
369
+ * `new URL(url, 'http://x')` rather than a hand-split on `&`/`=`: the base is a throwaway,
370
+ * needed only because `URL` refuses a bare path-plus-query with no scheme. One measured
371
+ * difference from the hand-split version this replaced — found by diffing both against a
372
+ * battery of query strings before this landed — and it is a FIX, not a regression: a literal
373
+ * `#` in the query is a URL FRAGMENT, which `URLSearchParams` correctly excludes and the old
374
+ * split-on-`&` did not, because a fragment is a client-side-only construct an HTTP request
375
+ * line can never actually carry — no `req.url` this handler ever sees can contain one.
388
376
  *
389
377
  * THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
390
378
  * mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
@@ -396,16 +384,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
396
384
  * through the one consumer it has.
397
385
  */
398
386
  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;
387
+ return new URL(url || '', 'http://x').searchParams.get('harness') || null;
409
388
  }
410
389
 
411
390
  function readBody(req, length, done) {
@@ -416,8 +395,8 @@ function readBody(req, length, done) {
416
395
  const chunks = [];
417
396
  let seen = 0;
418
397
  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.
398
+ // Exactly `Content-Length` bytes: any more belongs to the next request and is the HTTP
399
+ // parser's concern, not this handler's.
421
400
  if (seen >= length) return;
422
401
  chunks.push(c.subarray(0, length - seen));
423
402
  seen += c.length;
@@ -425,15 +404,7 @@ function readBody(req, length, done) {
425
404
  req.on('end', () => done(Buffer.concat(chunks)));
426
405
  }
427
406
 
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. */
407
+ /** Replaces the first occurrence only. */
437
408
  function replaceOnce(buf, find, repl) {
438
409
  const at = buf.indexOf(find);
439
410
  if (at < 0) return buf;