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