geneseed 3.1.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
@@ -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
22
  import { NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
23
23
  import { POST_ROUTES, notPorted, notPortedPost, readJsonBody } from './routes.mjs';
24
- import { readFileSync, statSync } from 'node:fs';
24
+ import { isFile } from '../lib/fs.mjs';
25
+ import { readFileSync } 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,11 +56,6 @@ 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) {
61
60
  const staticCache = new Map();
62
61
 
@@ -85,8 +84,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
85
84
  const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
86
85
  const index = resolvePath(join(dist, 'index.html'));
87
86
  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.
87
+ // STRICTLY under `dist`: `fp === dist` itself must also take the fallback below, which
88
+ // is why this checks `fp !== dist` and not just the prefix.
90
89
  if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
91
90
  if (!isFile(fp)) fp = join(dist, 'index.html');
92
91
  if (!isFile(fp)) {
@@ -110,8 +109,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
110
109
  }
111
110
 
112
111
  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
112
  const route = STATE_ROUTES[path];
116
113
  if (route !== undefined) return sendJson(res, route(state), 200, ae);
117
114
  if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
@@ -126,16 +123,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
126
123
  try {
127
124
  return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
128
125
  } 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.
126
+ // An unimplemented doc kind says so explicitly (501), rather than falling through
127
+ // to the `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
131
128
  if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
132
129
  throw e;
133
130
  }
134
131
  }
135
132
  if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
136
133
  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.
134
+ // The LAST segment: `/api/jobs/a/b` looks up `b` and misses (no such job) rather than
135
+ // 404ing on the URL shape.
139
136
  const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
140
137
  return j ? sendJson(res, j, 200, ae)
141
138
  : sendJson(res, { error: 'no such job' }, 404, ae);
@@ -154,12 +151,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
154
151
  return sendJson(res, { error: 'forbidden' }, 403, ae);
155
152
  }
156
153
  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.
154
+ // The one POST route the SHELL owns. It must answer BEFORE it stops, so closing is
155
+ // deferred to `res.on('finish', ...)` so the response flushes first.
156
+ // `closeAllConnections` is the other half: `close()` alone waits for idle keep-alive
157
+ // sockets, and the client that just sent this request is holding one open.
163
158
  res.on('finish', () => {
164
159
  holder.srv.close();
165
160
  holder.srv.closeAllConnections();
@@ -169,19 +164,15 @@ export function makeHandler(state, jm, token, dist, holder = null) {
169
164
  if (path === '/api/restart') {
170
165
  // THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
171
166
  // `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.
167
+ // record names and starts a fresh one that outlives the caller. Actually driving this
168
+ // route from a test would either stop the test's own server or, worse, orphan a real
169
+ // daemon that binds 4747 and serves the checkout forever in the developer's own
170
+ // environment.
179
171
  //
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.
172
+ // So the gate asserts this dispatches to `requestRestart(state.theme)` directly rather
173
+ // than by hitting the route through a live request — a declaration is not normally
174
+ // enough to stand in for a dispatcher, but here probing the dispatcher costs more than
175
+ // the assurance is worth.
185
176
  requestRestart(state.theme);
186
177
  return sendJson(res, { restarting: true }, 200, ae);
187
178
  }
@@ -213,6 +204,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
213
204
  openUrl(dir);
214
205
  return sendJson(res, { ok: true, dir }, 200, ae);
215
206
  }
207
+ if (path === '/api/pick-folder') {
208
+ // INLINE, NOT `POST_ROUTES`: that table's `fn(state, body) -> object` shape answers the
209
+ // instant `fn` returns, and this endpoint cannot — it spawns a native dialog that a human
210
+ // may sit in front of for a while. `doPost` returns to its caller immediately, unresolved;
211
+ // `apiPickFolder`'s `done` calls `sendJson` whenever the child actually exits, which is
212
+ // what lets the daemon keep answering every OTHER request while the dialog is open — see
213
+ // `apiPickFolder` in `js/web/actions.mjs` for the async-spawn reasoning.
214
+ apiPickFolder((result) => sendJson(res, result, 200, ae));
215
+ return;
216
+ }
216
217
  const route = POST_ROUTES.get(path);
217
218
  if (route !== undefined) {
218
219
  const [fn, okIs409] = route;
@@ -220,8 +221,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
220
221
  return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
221
222
  }
222
223
  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.
224
+ // Positional: `/api/jobs/<id>/cancel` takes `<id>` and a deeper path takes its third
225
+ // segment regardless.
225
226
  const jid = path.split('/')[3];
226
227
  return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
227
228
  : sendJson(res, { error: 'no running job by that id' }, 404, ae);
@@ -236,22 +237,21 @@ export function makeHandler(state, jm, token, dist, holder = null) {
236
237
  }
237
238
 
238
239
  /**
239
- * `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
240
+ * `/api/actions/<x>` dispatch order, and the order is deliberate rather than incidental.
240
241
  *
241
242
  * `restore` is answered BEFORE anything else because it is synchronous and returns a result
242
243
  * rather than a job id. `install` and `deploy` resolve their argv from the body, and their
243
244
  * refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
244
245
  * 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.
246
+ * so a refusal means the body was wrong). Unifying the two would be wrong for one of them.
247
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.
248
+ * `update` COMES NEXT AND IS INLINE: it is gated on a LOCAL-ONLY preflight before a job is
249
+ * ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI renders
250
+ * as an info popup instead of a job that spawns and then fails. Its finish handler is its own
251
+ * too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run` in
252
+ * `jobs.mjs`), so the restart happens HERE, after the job is saved as finished, and only when
253
+ * it succeeded: a failed update changed nothing worth reloading and bouncing would disconnect
254
+ * the PWA for no reason.
255
255
  *
256
256
  * THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
257
257
  * actions whose verb has not crossed, and letting them fall through to `actionCommands` would
@@ -309,7 +309,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
309
309
  /**
310
310
  * The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
311
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).
312
+ * the console keeps serving the old mode.
313
313
  */
314
314
  function startJob(res, action, cmds, ae) {
315
315
  const jid = jm.start(action, cmds, () => state.refresh());
@@ -328,10 +328,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
328
328
  try {
329
329
  return doGet(req, res, path, ae);
330
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.
331
+ // The `NotFound` → 404 convention wraps the ROUTE DISPATCH as a whole, not each
332
+ // handler individually: an endpoint several frames down raises and this decides
333
+ // the status. Anything else still falls to the outer 500.
335
334
  if (e instanceof NotFound) {
336
335
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
337
336
  }
@@ -339,30 +338,20 @@ export function makeHandler(state, jm, token, dist, holder = null) {
339
338
  }
340
339
  }
341
340
  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.
341
+ // The body is drained before routing regardless of what the route needs it for:
342
+ // `readJsonBody` needs the bytes for a real POST body, and Node's own HTTP parser
343
+ // owns the message boundary anyway — unread bytes are discarded when the response
344
+ // ends rather than mis-parsed as the next request line, so this is where the bytes
345
+ // have to come from either way.
355
346
  const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
356
347
  return readBody(req, length, (buf) => {
357
348
  req._body = buf;
358
349
  try {
359
350
  return doPost(req, res, path, ae, buf);
360
351
  } 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.
352
+ // `apiMcpToggle`, `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and
353
+ // `apiRulesPromote` all raise `NotFound` for an unknown target, mapped to 404
354
+ // here; anything else falls to the 500 arm below, carrying its own message.
366
355
  if (e instanceof NotFound) {
367
356
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
368
357
  }
@@ -381,10 +370,19 @@ export function makeHandler(state, jm, token, dist, holder = null) {
381
370
  * `Handler._harness` — the `?harness=` query param, `null` when absent.
382
371
  *
383
372
  * `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]`.
373
+ * needs and no further: values unquoted with `+` meaning a space, and a BLANK value dropped
374
+ * (`keep_blank_values=False`), so `?harness=` resolves to the installed default rather than
375
+ * to the empty string — `'' || null` is what drops it below. The first occurrence wins,
376
+ * because `parse_qs` returns a list and the reference takes `[0]`, and so does
377
+ * `URLSearchParams.get`.
378
+ *
379
+ * `new URL(url, 'http://x')` rather than a hand-split on `&`/`=`: the base is a throwaway,
380
+ * needed only because `URL` refuses a bare path-plus-query with no scheme. One measured
381
+ * difference from the hand-split version this replaced — found by diffing both against a
382
+ * battery of query strings before this landed — and it is a FIX, not a regression: a literal
383
+ * `#` in the query is a URL FRAGMENT, which `URLSearchParams` correctly excludes and the old
384
+ * split-on-`&` did not, because a fragment is a client-side-only construct an HTTP request
385
+ * line can never actually carry — no `req.url` this handler ever sees can contain one.
388
386
  *
389
387
  * THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
390
388
  * mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
@@ -396,16 +394,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
396
394
  * through the one consumer it has.
397
395
  */
398
396
  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;
397
+ return new URL(url || '', 'http://x').searchParams.get('harness') || null;
409
398
  }
410
399
 
411
400
  function readBody(req, length, done) {
@@ -416,8 +405,8 @@ function readBody(req, length, done) {
416
405
  const chunks = [];
417
406
  let seen = 0;
418
407
  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.
408
+ // Exactly `Content-Length` bytes: any more belongs to the next request and is the HTTP
409
+ // parser's concern, not this handler's.
421
410
  if (seen >= length) return;
422
411
  chunks.push(c.subarray(0, length - seen));
423
412
  seen += c.length;
@@ -425,15 +414,7 @@ function readBody(req, length, done) {
425
414
  req.on('end', () => done(Buffer.concat(chunks)));
426
415
  }
427
416
 
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. */
417
+ /** Replaces the first occurrence only. */
437
418
  function replaceOnce(buf, find, repl) {
438
419
  const at = buf.indexOf(find);
439
420
  if (at < 0) return buf;