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.
- package/CHANGELOG.md +180 -0
- package/README.md +24 -14
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +143 -89
- package/bin/geneseed-cli.mjs +63 -106
- package/bin/geneseed-hook.mjs +26 -16
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -15
- package/js/build/emit-claude.mjs +26 -12
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +50 -32
- package/js/build/source.mjs +29 -8
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +243 -81
- package/js/hosts/hosts.mjs +57 -5
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +2 -2
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +54 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +206 -115
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +5 -11
- package/js/inspect/inventory.mjs +38 -56
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +51 -3
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +127 -88
- package/js/maintain/update.mjs +4 -4
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +130 -226
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +59 -126
- package/js/web/handler.mjs +94 -123
- package/js/web/jobs.mjs +116 -178
- package/js/web/routes.mjs +47 -192
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/process.md +4 -2
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +133 -43
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/clarify.md +1 -1
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/ingest.md +2 -1
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
- package/web/dist/assets/Diff-BIjxXx46.js +5 -0
- package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/index-BEZvPvpF.js +1 -0
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/index-PmQGRS1X.js +2 -0
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +119 -130
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-B49fE_ug.js +0 -1
- package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
- package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/js/web/actions.mjs
CHANGED
|
@@ -1,103 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The web console's WRITES —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* WHAT THE PLAN'S TABLE GOT WRONG, measured by reading `js/` rather than by trusting it.
|
|
6
|
-
* Three of its five "already ported" claims were false and one endpoint was out by an order
|
|
7
|
-
* of magnitude:
|
|
8
|
-
*
|
|
9
|
-
* * `_memory_drop_index` — "check whether P5's `learn` ported it". It did not; nothing in
|
|
10
|
-
* `js/` referenced it. It lands in `js/hosts/hooks.mjs`, beside the other memory-store
|
|
11
|
-
* primitives, because that is where its Python siblings' twins already live.
|
|
12
|
-
* * `_mcp_load` — "already in js/ (P6c)". What crossed in P6c is `readJsonc` and a
|
|
13
|
-
* five-line no-host reader inside `js/web/api.mjs` for `wiki.jsonc`. The host fork and
|
|
14
|
-
* the other twelve MCP functions are 168 lines of new `js/hosts/mcp.mjs`.
|
|
15
|
-
* * `settings.mjs`'s atomic write — a DIFFERENT Python function from `_mcp_save`, with a
|
|
16
|
-
* different temp suffix and no parent-directory creation. See `js/hosts/mcp.mjs`.
|
|
17
|
-
* * `api_install_toggle` — "~25 new; `_move_tree`, `_prune_empty_ancestors` and the stash
|
|
18
|
-
* ARE in `js/maintain/uninstall.mjs` (P5h)". `DISABLED_STASH`, `installKind` and `installState`
|
|
19
|
-
* are. `_move_tree`, `_prune_empty_ancestors`, `_install_move_list`, `_install_relive`,
|
|
20
|
-
* `_install_readd_entry`, `_install_deactivate`, `_install_reactivate`,
|
|
21
|
-
* `_claude_deactivate` and `_claude_reactivate` are not — 293 lines of unported
|
|
22
|
-
* move-and-roll-back engine, scored at zero. It is deferred; see below.
|
|
2
|
+
* The web console's WRITES — the mutating endpoints, plus the two GETs that belong beside
|
|
3
|
+
* them.
|
|
23
4
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* options: (a) the Node twin returns its own argv head and the harness normalises it, or
|
|
30
|
-
* (b) the endpoint returns the ACTION and its arguments and the job runner resolves the
|
|
31
|
-
* command — "the better design", at the cost of changing the reference and rebuilding the
|
|
32
|
-
* tracked `web/dist`.
|
|
33
|
-
*
|
|
34
|
-
* MEASURED FIRST, as the phase's own rule says. Two measurements decide it:
|
|
35
|
-
*
|
|
36
|
-
* 1. `grep` over `web/src/` finds NO consumer of either response. The React client's only
|
|
37
|
-
* calls into that area are `pickFolder`; nothing reads a `cmd` field.
|
|
38
|
-
* 2. `rituals/_web_server.py` explains why. `_post_routes` calls `api_install_cmd`, and
|
|
39
|
-
* the ONLY thing it does with the result is `if "error" in plan: send_json(plan, 409)`
|
|
40
|
-
* / `jm.start("install", plan["cmd"], …)`. The argv is handed to the job runner in the
|
|
41
|
-
* SAME PROCESS. `{"cmd": [...]}` never reaches the wire; the client sees a `job_id` at
|
|
42
|
-
* 202 or an `error`.
|
|
43
|
-
*
|
|
44
|
-
* So there is no argv in a response body. `{"cmd": [...]}` is a function's return TYPE, not
|
|
45
|
-
* an endpoint's response, and the byte gate was never going to see it. That collapses the
|
|
46
|
-
* choice:
|
|
47
|
-
*
|
|
48
|
-
* * Option (b) buys nothing. Its whole value was making two implementations return the
|
|
49
|
-
* same wire bytes; they already do, because neither returns these bytes at all. The
|
|
50
|
-
* resolver and the runner are already in one process, so "let the runner resolve it" is
|
|
51
|
-
* a rename. It would change the reference and force a `web/dist` rebuild for zero
|
|
52
|
-
* observable difference — the definition of work this port refuses.
|
|
53
|
-
* * Option (a) is what remains, minus the harness change: the Node twin returns
|
|
54
|
-
* `[process.execPath, bin/geneseed-cli.mjs, …]`, which is the honest command for the
|
|
55
|
-
* runtime it will run on, and naming a Python interpreter there would put Python back into
|
|
56
|
-
* a "no Python needed" install for the one operation the console exists to run.
|
|
57
|
-
*
|
|
58
|
-
* `web/dist` therefore does NOT need rebuilding, and it was not rebuilt.
|
|
59
|
-
*
|
|
60
|
-
* WHAT GATES IT, since no cell can. The value never reaches a response, so a request-level
|
|
61
|
-
* corpus is structurally blind to it — exactly the shape a function-level gate answers and a
|
|
62
|
-
* cell cannot. That gate is `tests/unit/web_jobs.test.mjs`: it asserts the argv HEAD
|
|
63
|
-
* absolutely — `process.execPath` plus `bin/build-driver.mjs`, and no interpreter name
|
|
64
|
-
* anywhere in it — and pins the TAIL, every argument after the head, against a frozen
|
|
65
|
-
* literal. The tail is where a real bug would live, and asserting it absolutely rather than
|
|
66
|
-
* by comparison is what let it outlive the implementation it was once compared against.
|
|
67
|
-
*
|
|
68
|
-
* THE CODE IS P6g's, THE DECISION IS THIS PHASE's. `api_install_cmd`, `api_deploy_cmd` and
|
|
69
|
-
* `api_restore` all sit behind `/api/actions/<x>`, which needs the `JobManager` to route at
|
|
70
|
-
* all — so porting them here would produce three functions with no call site, no cell and
|
|
71
|
-
* no route, which is the "declaration that is not a dispatch" this project keeps being
|
|
72
|
-
* bitten by. P6g wires the prefix and brings them across with the gate described above.
|
|
5
|
+
* `{cmd: [...]}`, returned by `apiInstallCmd` and `apiDeployCmd` below, IS A FUNCTION'S
|
|
6
|
+
* RETURN TYPE, NOT A RESPONSE BODY: the dispatcher hands it straight to the job runner in
|
|
7
|
+
* the same process, and the client only ever sees a `job_id` at 202 or an `error`. That is
|
|
8
|
+
* why the argv head is `process.execPath` + `bin/build-driver.mjs` — the honest command for
|
|
9
|
+
* the runtime this actually runs on.
|
|
73
10
|
*
|
|
74
11
|
* ---------------------------------------------------------------------------------------
|
|
75
|
-
* `
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* dialog. So it is DECLINED rather than deferred, and `js/web/server.mjs` declares it in
|
|
81
|
-
* `DECLINED_POST` — a set separate from `NOT_PORTED_POST`, because the two mean different
|
|
82
|
-
* things and a phase that empties the second must not silently empty the first. The UI
|
|
83
|
-
* already falls back to its editable path field when this endpoint errors, which is the
|
|
84
|
-
* behaviour a 501 produces.
|
|
12
|
+
* `apiPickFolder` CROSSED 2026-08-27 — a user override of the permanent decline this section
|
|
13
|
+
* used to record (why a folder dialog looked permanently un-portable lives in git history,
|
|
14
|
+
* under `DECLINED_POST` in `js/web/routes.mjs`). See `apiPickFolder`'s own docblock, below,
|
|
15
|
+
* for the argv, the async-spawn reasoning, and why it dispatches inline from
|
|
16
|
+
* `js/web/handler.mjs` rather than through `POST_ROUTES`.
|
|
85
17
|
*
|
|
86
18
|
* ---------------------------------------------------------------------------------------
|
|
87
|
-
* `
|
|
19
|
+
* `apiInstallToggle` is a thin endpoint over an engine that lives elsewhere on purpose.
|
|
88
20
|
*
|
|
89
|
-
*
|
|
90
|
-
* keeping: two of the three pull in 293 lines of all-or-nothing tree moves with rollback, a
|
|
21
|
+
* Two of its three actions pull in 293 lines of all-or-nothing tree moves with rollback, a
|
|
91
22
|
* stash layout, an `instructions`-entry unmerge, a re-emit-while-disabled guard and a host
|
|
92
|
-
* fork. None of that is web code, and
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* `
|
|
96
|
-
* reversible siblings of the same owned-file walk, and `apiInstallToggle` below is the thin
|
|
97
|
-
* endpoint over them.
|
|
23
|
+
* fork. None of that is web code, and putting the largest engine block behind a web request
|
|
24
|
+
* script would give it the weakest available gate instead of a file-move fixture. So it
|
|
25
|
+
* lives in `js/maintain/uninstall.mjs`: `installDeactivate` and `installReactivate` beside
|
|
26
|
+
* `installUninstall`, the reversible siblings of the same owned-file walk.
|
|
98
27
|
*/
|
|
28
|
+
import { spawn } from 'node:child_process';
|
|
99
29
|
import {
|
|
100
|
-
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync,
|
|
30
|
+
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, unlinkSync,
|
|
101
31
|
} from 'node:fs';
|
|
102
32
|
import os from 'node:os';
|
|
103
33
|
import path from 'node:path';
|
|
@@ -122,8 +52,9 @@ import {
|
|
|
122
52
|
MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
|
|
123
53
|
mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
|
|
124
54
|
} from '../hosts/mcp.mjs';
|
|
125
|
-
import { readText, writeText } from '../lib/fs.mjs';
|
|
55
|
+
import { readText, writeText, isFile, isDir } from '../lib/fs.mjs';
|
|
126
56
|
import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
|
|
57
|
+
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
127
58
|
import { WHITESPACE, codePointLength, stripWhitespace } from '../lib/text.mjs';
|
|
128
59
|
import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
|
|
129
60
|
import { splitLines } from '../lib/udiff.mjs';
|
|
@@ -131,14 +62,9 @@ import {
|
|
|
131
62
|
NotFound, deployed, emitChoices, fingerprint, themeChoices, viewCfg, within,
|
|
132
63
|
} from './api.mjs';
|
|
133
64
|
|
|
134
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
135
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
136
|
-
|
|
137
|
-
/** `dict.get(key, default)` over a `json.loads` result. */
|
|
138
65
|
const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, key)
|
|
139
66
|
? body[key] : dflt);
|
|
140
67
|
|
|
141
|
-
/** `str(x or "")` — Python's truthiness, then Python's `str`. */
|
|
142
68
|
const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
|
|
143
69
|
|
|
144
70
|
/**
|
|
@@ -197,22 +123,21 @@ function bodyExcludeRules(body) {
|
|
|
197
123
|
return known.filter((id) => ids.includes(id));
|
|
198
124
|
}
|
|
199
125
|
|
|
200
|
-
/**
|
|
126
|
+
/** Splits on runs of `WHITESPACE` (not `\s`), dropping empty strings. */
|
|
201
127
|
const splitWords = (s) => s.split(new RegExp(`[${WHITESPACE}]+`)).filter(Boolean);
|
|
202
128
|
|
|
203
|
-
/**
|
|
129
|
+
/** Today's date, LOCAL (not UTC) — this is a user-facing date, not a wire timestamp. */
|
|
204
130
|
export function todayIso(d = new Date()) {
|
|
205
131
|
const p2 = (n) => String(n).padStart(2, '0');
|
|
206
132
|
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
207
133
|
}
|
|
208
134
|
|
|
209
135
|
/**
|
|
210
|
-
* `today` and `today +
|
|
136
|
+
* `today` and `today + n days`, from ONE sample of the clock.
|
|
211
137
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* to debug.
|
|
138
|
+
* Two separate `new Date()` calls would let the provenance line and the expiry land on
|
|
139
|
+
* either side of a midnight that fell between them — a one-run-in-86400 disagreement, which
|
|
140
|
+
* is the worst kind to debug.
|
|
216
141
|
*/
|
|
217
142
|
function promoteDates(days) {
|
|
218
143
|
const d = new Date();
|
|
@@ -222,46 +147,148 @@ function promoteDates(days) {
|
|
|
222
147
|
}
|
|
223
148
|
|
|
224
149
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* value, so a client sending `"fingerprint": 0` against an ABSENT file (whose fingerprint is
|
|
229
|
-
* `""`) gets a conflict there, where a coerced comparison would find `"" == ""` and write.
|
|
150
|
+
* Strict equality on the RAW body value, not run through `strOr` first. `strOr(0)` folds to
|
|
151
|
+
* `''` (0 is falsy), which would wrongly equal an absent file's `''` fingerprint and let a
|
|
152
|
+
* client send `"fingerprint": 0` and overwrite regardless of what is actually on disk.
|
|
230
153
|
*/
|
|
231
154
|
const fpMatches = (got, want) => typeof got === 'string' && got === want;
|
|
232
155
|
|
|
156
|
+
// ---- OS-native folder picker ---------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* ms before an open dialog is abandoned. The Python original's 300 s, kept as a named
|
|
160
|
+
* constant rather than a literal in the spawn call. `GENESEED_PICK_TIMEOUT_MS` is a TEST HOOK
|
|
161
|
+
* ONLY — it overrides the constant so the timeout path can be proven in seconds instead of
|
|
162
|
+
* five minutes (nobody is at the dialog to cancel it in an automated run); the 300 s default
|
|
163
|
+
* ships untouched for a real daemon.
|
|
164
|
+
*/
|
|
165
|
+
// `Number.isFinite`, not `||`: the latter would ignore an explicit `GENESEED_PICK_TIMEOUT_MS=0`
|
|
166
|
+
// (falsy) and fall back to the 300 s default, which defeats a test that wants a timeout of 0.
|
|
167
|
+
const pickTimeoutOverride = Number(process.env.GENESEED_PICK_TIMEOUT_MS);
|
|
168
|
+
const PICK_TIMEOUT_MS = Number.isFinite(pickTimeoutOverride) ? pickTimeoutOverride : 300_000;
|
|
169
|
+
|
|
170
|
+
// A SINGLE STABLE LITERAL, never built from a request: `apiPickFolder` takes no client input
|
|
171
|
+
// at all (the endpoint's whole POST body is ignored), so there is nothing to interpolate into
|
|
172
|
+
// this command line in the first place — and `tests/unit/hook_cli.test.mjs`'s spawn allow-list
|
|
173
|
+
// asserts this exact string, which only holds while that stays true. `-STA`: a WinForms dialog
|
|
174
|
+
// needs the single-threaded apartment; `-NoProfile`: skip the user's `$PROFILE` script, which
|
|
175
|
+
// this one-shot invocation has no business running.
|
|
176
|
+
const PICK_SCRIPT_WIN = 'Add-Type -AssemblyName System.Windows.Forms; '
|
|
177
|
+
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog; '
|
|
178
|
+
+ 'if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) '
|
|
179
|
+
+ '{ Write-Output $d.SelectedPath }';
|
|
180
|
+
|
|
181
|
+
// Ported VERBATIM from the deleted Python (`git show 8860c72:rituals/_web_actions.py`,
|
|
182
|
+
// `api_pick_folder`) — same prompt text, same AppleScript shape. `choose folder` is a
|
|
183
|
+
// StandardAdditions command that runs in-process inside `osascript`, so it needs no
|
|
184
|
+
// automation-consent prompt the way scripting another app would.
|
|
185
|
+
const PICK_SCRIPT_MAC = 'set f to POSIX path of (choose folder with prompt '
|
|
186
|
+
+ '"Choose a folder to deploy the harness into")\nreturn f';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Spawns `cmd args`, collects stdout/stderr, and calls `resultOf(exitCode, stdout, stderr)` to
|
|
190
|
+
* build the response once the child exits (or is killed on timeout, or never starts at all).
|
|
191
|
+
* `done` fires EXACTLY ONCE regardless of which of those three ends it — timeout, `error`
|
|
192
|
+
* (spawn itself failed, e.g. the binary is not on PATH), or `close` (the normal exit) all race
|
|
193
|
+
* for the same `settled` guard.
|
|
194
|
+
*/
|
|
195
|
+
function runPicker(cmd, args, done, resultOf) {
|
|
196
|
+
let child;
|
|
197
|
+
try {
|
|
198
|
+
// `...NO_WINDOW`: this call captures stdout/stderr below rather than inheriting them, and
|
|
199
|
+
// `tests/unit/spawn_hygiene.test.mjs` gates every such call site in `js/`/`bin/` on it.
|
|
200
|
+
// Hiding the PowerShell/osascript CONSOLE window is exactly what is wanted here — the GUI
|
|
201
|
+
// dialog itself (`FolderBrowserDialog`, `choose folder`) is a separate Win32/Carbon window
|
|
202
|
+
// neither process's own console visibility controls.
|
|
203
|
+
child = spawn(cmd, args, { ...NO_WINDOW });
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return done({ error: `folder picker unavailable: ${e.message}` });
|
|
206
|
+
}
|
|
207
|
+
let out = '';
|
|
208
|
+
let err = '';
|
|
209
|
+
let settled = false;
|
|
210
|
+
const finish = (result) => {
|
|
211
|
+
if (settled) return;
|
|
212
|
+
settled = true;
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
done(result);
|
|
215
|
+
};
|
|
216
|
+
const timer = setTimeout(() => { child.kill(); finish({ error: 'folder picker timed out' }); },
|
|
217
|
+
PICK_TIMEOUT_MS);
|
|
218
|
+
child.stdout.on('data', (c) => { out += c; });
|
|
219
|
+
child.stderr.on('data', (c) => { err += c; });
|
|
220
|
+
child.on('error', (e) => finish({ error: `folder picker unavailable: ${e.message}` }));
|
|
221
|
+
child.on('close', (code) => finish(resultOf(code, out, err)));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* `/api/pick-folder` — the OS-NATIVE folder chooser, opened ON THE DAEMON HOST. `done` is
|
|
226
|
+
* called with `{path}` on OK, `{cancelled: true}` on cancel, or `{error}` — the shape the
|
|
227
|
+
* client's `pickFolder()` (in `web/src/api/index.js`) already expects, unchanged since the
|
|
228
|
+
* endpoint was DECLINED (see this file's header for that history, overridden by the user
|
|
229
|
+
* 2026-08-27).
|
|
230
|
+
*
|
|
231
|
+
* ASYNC, NEVER `spawnSync`: a folder dialog stays open until a human answers it — seconds to
|
|
232
|
+
* minutes — and a synchronous spawn would block this single-threaded server's event loop for
|
|
233
|
+
* exactly that long, so no OTHER request could be served while the dialog is up. That is also
|
|
234
|
+
* why `js/web/handler.mjs`'s `doPost` calls this INLINE rather than through `POST_ROUTES`:
|
|
235
|
+
* that table's dispatch is `fn(state, body) -> object`, sent to the client the moment `fn`
|
|
236
|
+
* returns — a shape with no room for "answer later," which is exactly what this endpoint does.
|
|
237
|
+
*
|
|
238
|
+
* The daemon must be running IN A DESKTOP SESSION for a dialog to actually appear; headless
|
|
239
|
+
* (no GUI session, or a platform with no branch below), the process still answers — an
|
|
240
|
+
* `{error}` — never a hang, because `runPicker`'s timeout and `error` handlers both fire
|
|
241
|
+
* `done` on their own.
|
|
242
|
+
*/
|
|
243
|
+
export function apiPickFolder(done) {
|
|
244
|
+
if (process.platform === 'win32') {
|
|
245
|
+
return runPicker('powershell', ['-NoProfile', '-STA', '-Command', PICK_SCRIPT_WIN], done,
|
|
246
|
+
(code, out, err) => {
|
|
247
|
+
if (code !== 0) return { error: (err || out).trim() || 'folder picker failed' };
|
|
248
|
+
const p = out.trim();
|
|
249
|
+
return p ? { path: p } : { cancelled: true };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (process.platform === 'darwin') {
|
|
253
|
+
return runPicker('osascript', ['-e', PICK_SCRIPT_MAC], done, (code, out, err) => {
|
|
254
|
+
if (code === 0) return { path: out.trim() };
|
|
255
|
+
if (err.includes('-128') || err.includes('User canceled')) return { cancelled: true };
|
|
256
|
+
return { error: err.trim() || 'folder picker failed' };
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return done({ error: 'no native folder dialog on this platform' });
|
|
260
|
+
}
|
|
261
|
+
|
|
233
262
|
// ---- user rules (user-rules.md) ----------------------------------------------------------
|
|
234
263
|
|
|
235
|
-
/** `build.RULES_FILE`. */
|
|
236
264
|
export const RULES_FILE = 'user-rules.md';
|
|
237
265
|
const rulesPath = (state) => path.join(state.target, RULES_FILE);
|
|
238
266
|
|
|
239
267
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* The whitespace classes are `WHITESPACE`, not `\s`:
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* and the web editor cannot produce (it writes `R<n>` itself from `max(ids) + 1`).
|
|
268
|
+
* `## R<n> — Title`, anchored at column 0 so a rule body's fenced code and the stub's
|
|
269
|
+
* indented format example never parse as rules.
|
|
270
|
+
*
|
|
271
|
+
* The whitespace classes are `WHITESPACE`, not `\s`: this pattern runs over a HAND-EDITED
|
|
272
|
+
* file, which is where a non-breaking space actually turns up. `\S` is its complement for
|
|
273
|
+
* the same reason.
|
|
274
|
+
*
|
|
275
|
+
* `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight, unlike
|
|
276
|
+
* `ruleFields` below which uses `\p{Nd}`. No theme seeds a non-ASCII rule id, no build
|
|
277
|
+
* writes one, and the web editor cannot produce one either — it writes `R<n>` itself from
|
|
278
|
+
* `max(ids) + 1`. Widening this to match would only move the gap one line down, to whatever
|
|
279
|
+
* parses the captured digits back into a number.
|
|
253
280
|
*/
|
|
254
281
|
export const RULE_HEAD_RE = new RegExp(
|
|
255
282
|
`^##[${WHITESPACE}]+R(\\d+)[${WHITESPACE}]*[—–-]+[${WHITESPACE}]*`
|
|
256
283
|
+ `([^${WHITESPACE}].*?)[${WHITESPACE}]*$`,
|
|
257
284
|
);
|
|
258
285
|
|
|
259
|
-
/**
|
|
286
|
+
/** Advisory only; nothing blocks past it. */
|
|
260
287
|
export const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
|
|
261
288
|
|
|
262
289
|
const META_RE = new RegExp(`^\\((.+)\\)[${WHITESPACE}]*$`);
|
|
263
290
|
|
|
264
|
-
/** `
|
|
291
|
+
/** `{}` when the line is body rather than metadata. */
|
|
265
292
|
function parseRuleMeta(line) {
|
|
266
293
|
const m = META_RE.exec(stripWhitespace(line));
|
|
267
294
|
if (!m) return {};
|
|
@@ -278,7 +305,7 @@ function parseRuleMeta(line) {
|
|
|
278
305
|
}
|
|
279
306
|
|
|
280
307
|
/**
|
|
281
|
-
* `
|
|
308
|
+
* `user-rules.md` parsed to `[rules, warnings]`.
|
|
282
309
|
*
|
|
283
310
|
* Every rule carries its `start`/`end` LINE INDICES, which is what lets `apiRulesMutate`
|
|
284
311
|
* splice exactly one block and leave every other byte of the user's file — prose,
|
|
@@ -317,7 +344,6 @@ export function parseRules(text) {
|
|
|
317
344
|
return [rules, warnings];
|
|
318
345
|
}
|
|
319
346
|
|
|
320
|
-
/** `_web_catalog.api_rules` — the Rules page payload. */
|
|
321
347
|
export function apiRules(state) {
|
|
322
348
|
const p = rulesPath(state);
|
|
323
349
|
if (!isFile(p)) {
|
|
@@ -335,19 +361,17 @@ export function apiRules(state) {
|
|
|
335
361
|
body: r.body,
|
|
336
362
|
}));
|
|
337
363
|
return { exists: true, path: p, rules: out, warnings, fingerprint: fingerprint(text),
|
|
338
|
-
//
|
|
339
|
-
//
|
|
364
|
+
// CODE POINTS, not `String.length` (UTF-16 units) — an emoji in a rule body would
|
|
365
|
+
// otherwise put the token estimate off.
|
|
340
366
|
stats: { rules: rules.length, lines: splitLines(text).length,
|
|
341
367
|
tokens: Math.floor(codePointLength(text) / 4), ...RULES_BUDGET } };
|
|
342
368
|
}
|
|
343
369
|
|
|
344
|
-
/** `_web_actions._rules_read`. */
|
|
345
370
|
function rulesRead(state) {
|
|
346
371
|
const p = rulesPath(state);
|
|
347
372
|
return [p, isFile(p) ? (readMaybe(p) ?? '') : ''];
|
|
348
373
|
}
|
|
349
374
|
|
|
350
|
-
/** `_web_actions._rule_block`. */
|
|
351
375
|
function ruleBlock(rid, title, scope, source, trialUntil, body) {
|
|
352
376
|
const meta = [`scope: ${scope}`];
|
|
353
377
|
if (source) meta.push(`source: ${source}`);
|
|
@@ -356,12 +380,12 @@ function ruleBlock(rid, title, scope, source, trialUntil, body) {
|
|
|
356
380
|
}
|
|
357
381
|
|
|
358
382
|
/**
|
|
359
|
-
*
|
|
383
|
+
* Validate and normalise a rule's writable fields. Throws (→ the shell's JSON 500 carrying
|
|
384
|
+
* the message) on an unusable rule.
|
|
360
385
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* `int()`.
|
|
386
|
+
* `\p{Nd}` here and not `\d`, unlike `RULE_HEAD_RE` above: this is a pure predicate over a
|
|
387
|
+
* string that stays a string, so accepting any Unicode decimal digit costs nothing and
|
|
388
|
+
* needs no numeric parse afterward.
|
|
365
389
|
*/
|
|
366
390
|
function ruleFields(body) {
|
|
367
391
|
const title = splitWords(strOr(bget(body, 'title'))).join(' ');
|
|
@@ -379,11 +403,10 @@ function ruleFields(body) {
|
|
|
379
403
|
}
|
|
380
404
|
|
|
381
405
|
/**
|
|
382
|
-
* `
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* and the reference catches both.
|
|
406
|
+
* Coerces whatever `id` came out of the parsed body into an integer, or throws
|
|
407
|
+
* `NotFound('rule id')`. Handles a decimal string (surrounding whitespace stripped), a
|
|
408
|
+
* boolean, a bare number (truncated), and a `JsonNumber`-shaped value via its `valueOf()` —
|
|
409
|
+
* whatever `parseJson` could have produced for this field.
|
|
387
410
|
*/
|
|
388
411
|
function ruleId(v) {
|
|
389
412
|
if (typeof v === 'string') {
|
|
@@ -401,7 +424,7 @@ function ruleId(v) {
|
|
|
401
424
|
}
|
|
402
425
|
|
|
403
426
|
/**
|
|
404
|
-
*
|
|
427
|
+
* Add / update / delete on user-rules.md.
|
|
405
428
|
*
|
|
406
429
|
* EVERY MUTATION REQUIRES THE FINGERPRINT OF THE CONTENT THE CLIENT LAST READ. An agent
|
|
407
430
|
* session may be editing the same file mid-flight, so a stale write returns `ok: false`
|
|
@@ -451,11 +474,9 @@ export function apiRulesMutate(state, body) {
|
|
|
451
474
|
}
|
|
452
475
|
|
|
453
476
|
/**
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* are DATES read from the clock. `tests/web_golden.py` gates them from its own shared
|
|
458
|
-
* per-cell clock rather than destamping them — a destamp would erase exactly the thing.
|
|
477
|
+
* One memory fact promoted into a trial rule. The provenance line and the month of
|
|
478
|
+
* probation are the rule this endpoint owns, and both are DATES read from the real clock —
|
|
479
|
+
* not destamped, since destamping would erase exactly the thing being recorded.
|
|
459
480
|
*/
|
|
460
481
|
export function apiRulesPromote(state, body) {
|
|
461
482
|
const name = strOr(bget(body, 'name'));
|
|
@@ -490,7 +511,7 @@ export function apiRulesPromote(state, body) {
|
|
|
490
511
|
|
|
491
512
|
// ---- PROFILE.md --------------------------------------------------------------------------
|
|
492
513
|
|
|
493
|
-
/**
|
|
514
|
+
/** The whole file, fingerprint-guarded like the rules above. */
|
|
494
515
|
export function apiProfileSave(state, body) {
|
|
495
516
|
const p = path.join(state.target, 'PROFILE.md');
|
|
496
517
|
const cur = isFile(p) ? (readMaybe(p) ?? '') : '';
|
|
@@ -508,11 +529,11 @@ export function apiProfileSave(state, body) {
|
|
|
508
529
|
|
|
509
530
|
// ---- memory ------------------------------------------------------------------------------
|
|
510
531
|
|
|
511
|
-
/**
|
|
532
|
+
/** Always `<target>/memory` — never the CWD-scanning resolver. */
|
|
512
533
|
const memoryDir = (state) => path.join(state.target, 'memory');
|
|
513
534
|
|
|
514
535
|
/**
|
|
515
|
-
*
|
|
536
|
+
* One fact file, and its line in the MEMORY.md index.
|
|
516
537
|
*
|
|
517
538
|
* `name` is a BARE SLUG: a path separator, or one of the two reserved names, is refused, so
|
|
518
539
|
* this can only ever remove a fact inside the resolved memory dir. MEMORY.md is the index the
|
|
@@ -538,8 +559,8 @@ export function apiMemoryDelete(state, name) {
|
|
|
538
559
|
// ---- sovereign-repo exclusions -------------------------------------------------------------
|
|
539
560
|
|
|
540
561
|
/**
|
|
541
|
-
*
|
|
542
|
-
*
|
|
562
|
+
* This endpoint owns no exclusion logic of its own; it validates the body and hands off to
|
|
563
|
+
* the same engine `harness exclude add|remove` calls.
|
|
543
564
|
*
|
|
544
565
|
* A malformed body is rejected with `ok: false` (→ 409) rather than reaching `excludeAdd`,
|
|
545
566
|
* which assumes a real path string. That arm is also where a body that never parsed lands:
|
|
@@ -557,7 +578,6 @@ export function apiExcludesMutate(state, body) {
|
|
|
557
578
|
|
|
558
579
|
// ---- MCP ----------------------------------------------------------------------------------
|
|
559
580
|
|
|
560
|
-
/** `_web_actions.api_mcp` — MCP servers per ACTIVE install, host-aware. */
|
|
561
581
|
export function apiMcp() {
|
|
562
582
|
const out = [];
|
|
563
583
|
for (const [label, p, host, scope, root] of mcpInstallTargets()) {
|
|
@@ -577,20 +597,6 @@ export function apiMcp() {
|
|
|
577
597
|
return { targets: out, default: 0 };
|
|
578
598
|
}
|
|
579
599
|
|
|
580
|
-
/**
|
|
581
|
-
* `_web_actions.api_mcp_toggle` — enable / disable / first-add one server.
|
|
582
|
-
*
|
|
583
|
-
* THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
|
|
584
|
-
* endpoint: `known` is built from the detected install targets and an unlisted path is a 404
|
|
585
|
-
* before anything is read or written. Without it this is "write arbitrary JSON to an
|
|
586
|
-
* arbitrary file", behind a CSRF token and nothing else.
|
|
587
|
-
*
|
|
588
|
-
* The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
|
|
589
|
-
* rewritten — so the safety check and the value saved come from the same read, with no
|
|
590
|
-
* time-of-check/time-of-use gap and no comment-stripper touching a string that contains
|
|
591
|
-
* `,]`. A file that will not parse is refused rather than clobbered: it may be
|
|
592
|
-
* `~/.claude.json`, which holds projects and history far beyond MCP wiring.
|
|
593
|
-
*/
|
|
594
600
|
/**
|
|
595
601
|
* The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
|
|
596
602
|
*
|
|
@@ -610,6 +616,20 @@ export function mcpTargetPaths() {
|
|
|
610
616
|
return known;
|
|
611
617
|
}
|
|
612
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Enable / disable / first-add one server.
|
|
621
|
+
*
|
|
622
|
+
* THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
|
|
623
|
+
* endpoint: `known` is built from the detected install targets and an unlisted path is a 404
|
|
624
|
+
* before anything is read or written. Without it this is "write arbitrary JSON to an
|
|
625
|
+
* arbitrary file", behind a CSRF token and nothing else.
|
|
626
|
+
*
|
|
627
|
+
* The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
|
|
628
|
+
* rewritten — so the safety check and the value saved come from the same read, with no
|
|
629
|
+
* time-of-check/time-of-use gap and no comment-stripper touching a string that contains
|
|
630
|
+
* `,]`. A file that will not parse is refused rather than clobbered: it may be
|
|
631
|
+
* `~/.claude.json`, which holds projects and history far beyond MCP wiring.
|
|
632
|
+
*/
|
|
613
633
|
export function apiMcpToggle(state, body) {
|
|
614
634
|
const name = strOr(bget(body, 'name'));
|
|
615
635
|
const want = isTruthy(bget(body, 'enabled'));
|
|
@@ -660,7 +680,7 @@ export function apiMcpToggle(state, body) {
|
|
|
660
680
|
// ---- the harness selector ------------------------------------------------------------------
|
|
661
681
|
|
|
662
682
|
/**
|
|
663
|
-
*
|
|
683
|
+
* Re-point the whole console at a detected install.
|
|
664
684
|
*
|
|
665
685
|
* The (host, path) PAIR must be one of the detected targets, and the ROOT is threaded through
|
|
666
686
|
* beside the data dir: markers and sigils live at the install root, not in the data dir, and
|
|
@@ -684,14 +704,14 @@ export function apiSelectView(state, body) {
|
|
|
684
704
|
return { ok: true, target: state.target, theme: state.theme, emit: state.emit };
|
|
685
705
|
}
|
|
686
706
|
|
|
687
|
-
// ----
|
|
707
|
+
// ---- restore, and the two build-command resolvers ---------------------------------------
|
|
688
708
|
|
|
689
709
|
/**
|
|
690
|
-
*
|
|
710
|
+
* (theme, emit) for a Build POST.
|
|
691
711
|
*
|
|
692
712
|
* A valid override in the body wins; anything missing or unrecognised falls back to the
|
|
693
713
|
* DETECTED install, so a bogus body value can never reach the build argv. That is the same
|
|
694
|
-
* allowlist shape `
|
|
714
|
+
* allowlist shape `apiInstallCmd` uses, and it is the reason neither endpoint needs to
|
|
695
715
|
* sanitise a string: an unknown one is simply not used.
|
|
696
716
|
*/
|
|
697
717
|
export function buildOverride(state, body) {
|
|
@@ -703,13 +723,12 @@ export function buildOverride(state, body) {
|
|
|
703
723
|
}
|
|
704
724
|
|
|
705
725
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
726
|
+
* The global emit matching a deployed install's `.geneseed-emit`, so the EXPECTED render
|
|
727
|
+
* uses the install's own host dialect.
|
|
708
728
|
*
|
|
709
|
-
* Returns the HOST rather than a function, because `emitGlobalInto(host, …)` is
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
* `.get(..., default)` calls do.
|
|
729
|
+
* Returns the HOST rather than a function, because `emitGlobalInto(host, …)` is the shape
|
|
730
|
+
* `js/inspect/diff.mjs` already calls it with. An unknown or missing marker falls back to
|
|
731
|
+
* OpenCode.
|
|
713
732
|
*/
|
|
714
733
|
export function globalEmitHostFor(emit) {
|
|
715
734
|
const host = (EMIT_HOST_SCOPE.get(emit || '') ?? ['opencode', 'global'])[0];
|
|
@@ -717,22 +736,21 @@ export function globalEmitHostFor(emit) {
|
|
|
717
736
|
}
|
|
718
737
|
|
|
719
738
|
/**
|
|
720
|
-
*
|
|
739
|
+
* Restore selected drifted files from the SOURCE render.
|
|
721
740
|
*
|
|
722
741
|
* Source wins and local edits are discarded (the inverse, keeping them, is Export
|
|
723
|
-
* improvements). Renders the expected copy exactly as `
|
|
742
|
+
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel:
|
|
724
743
|
* expected present -> overwrite/create the deployed copy; expected absent but deployed present
|
|
725
744
|
* (an 'added' file) -> delete it; neither -> an error and nothing touched.
|
|
726
745
|
*
|
|
727
746
|
* SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
|
|
728
|
-
* structured result rather than a job id — which is why
|
|
747
|
+
* structured result rather than a job id — which is why the dispatcher answers it before it
|
|
729
748
|
* ever consults the action table.
|
|
730
749
|
*
|
|
731
|
-
* THE EMIT AND THE FOOTPRINT ARE READ OFF THE DEPLOYMENT
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
* emit writes.
|
|
750
|
+
* THE EMIT AND THE FOOTPRINT ARE READ OFF THE DEPLOYMENT: a silently-OpenCode `expected`
|
|
751
|
+
* would overwrite a Claude install's agents with the wrong frontmatter, and a full-footprint
|
|
752
|
+
* `expected` on a lean install rewrites AGENT.md with the inlined laws and DELETES
|
|
753
|
+
* `laws/universal.md`, which only the lean emit writes.
|
|
736
754
|
*
|
|
737
755
|
* THE POSTURE AND THE MODE cost the most of the four, because this verb WRITES. Rendering
|
|
738
756
|
* `expected` at `peer`/`direct` made restoring AGENT.md silently revert the user's chosen
|
|
@@ -775,8 +793,8 @@ export function apiRestore(state, files) {
|
|
|
775
793
|
errors.push(`${rel}: outside the deployed tree`);
|
|
776
794
|
} else if (isFile(src)) {
|
|
777
795
|
mkdirSync(path.dirname(dst), { recursive: true });
|
|
778
|
-
//
|
|
779
|
-
//
|
|
796
|
+
// A BYTE copy, so the restored file keeps the render's own line endings rather than
|
|
797
|
+
// the platform's (the `writeText`/`copy2` rule, one file over).
|
|
780
798
|
copyFileSync(src, dst);
|
|
781
799
|
restored.push(rel);
|
|
782
800
|
} else if (isFile(dst)) {
|
|
@@ -787,14 +805,13 @@ export function apiRestore(state, files) {
|
|
|
787
805
|
}
|
|
788
806
|
}
|
|
789
807
|
} finally {
|
|
790
|
-
// `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
|
|
791
808
|
rmSync(tmp, { recursive: true, force: true });
|
|
792
809
|
}
|
|
793
810
|
state.refresh();
|
|
794
811
|
return { restored, deleted, errors };
|
|
795
812
|
}
|
|
796
813
|
|
|
797
|
-
/**
|
|
814
|
+
/** (host, scope) -> the emit name that installs it. */
|
|
798
815
|
const EMIT_FOR = new Map([
|
|
799
816
|
['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
|
|
800
817
|
['claude global', 'claude-global'], ['claude project', 'claude'],
|
|
@@ -803,21 +820,17 @@ const EMIT_FOR = new Map([
|
|
|
803
820
|
]);
|
|
804
821
|
|
|
805
822
|
/**
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* way).
|
|
823
|
+
* The build command that installs Geneseed into a DETECTED location, or re-themes an
|
|
824
|
+
* already-active one (an in-place re-emit, the same command either way).
|
|
809
825
|
*
|
|
810
826
|
* The (host, path) pair MUST be one of the detected targets — the same allowlist
|
|
811
|
-
* `
|
|
827
|
+
* `apiSelectView` uses, and the reason the target is never built from raw body input — and
|
|
812
828
|
* it must not be `disabled` (reactivate first). Every other field follows the same rule: a
|
|
813
829
|
* VALID picked value wins, else the install's own, so a re-theme never silently flips the
|
|
814
830
|
* footprint, the register or the mode, and a bogus body value cannot reach the argv.
|
|
815
831
|
*
|
|
816
|
-
* `{cmd: [...]}` IS A RETURN TYPE, NOT A RESPONSE
|
|
817
|
-
*
|
|
818
|
-
* "argv-in-a-response-body" question — see this file's header — and why the twin's head is
|
|
819
|
-
* `process.execPath` + `bin/build-driver.mjs` rather than a Python argv it would then have to
|
|
820
|
-
* pretend to run.
|
|
832
|
+
* `{cmd: [...]}` IS A RETURN TYPE, NOT A RESPONSE — see this file's header for why that
|
|
833
|
+
* matters and what the argv head is.
|
|
821
834
|
*/
|
|
822
835
|
export function apiInstallCmd(state, body) {
|
|
823
836
|
const known = new Map();
|
|
@@ -864,28 +877,17 @@ export function apiInstallCmd(state, body) {
|
|
|
864
877
|
}
|
|
865
878
|
|
|
866
879
|
/**
|
|
867
|
-
*
|
|
868
|
-
* an arbitrary folder the user chose.
|
|
869
|
-
*
|
|
870
|
-
* The open-ended sibling of `api_install_cmd`, which only rebuilds a pre-detected target from a
|
|
871
|
-
* tight allowlist. Scope is always `project`: a global lands in the host's config dir, never a
|
|
872
|
-
* chosen folder. THIS ENDPOINT TAKES A RAW PATH, so it is the trust boundary — the path is
|
|
873
|
-
* validated here as an existing, writable directory that is not a host's own global config dir
|
|
874
|
-
* (deploying a `project` emit there would mislabel as the global row and collide on dedup).
|
|
875
|
-
*/
|
|
876
|
-
/**
|
|
877
|
-
* `_web_actions.api_install_toggle` — deactivate, reactivate, or REMOVE one install.
|
|
880
|
+
* Deactivate, reactivate, or REMOVE one install.
|
|
878
881
|
*
|
|
879
882
|
* KEYED ON THE (host, path) PAIR, and the pair must be one of the DETECTED installs. A cwd can
|
|
880
883
|
* carry both an OpenCode and a Claude install at the same path, so a path alone is ambiguous;
|
|
881
884
|
* and this endpoint moves and deletes whole trees, so the root is never built from raw body
|
|
882
885
|
* input. An unknown pair is a `NotFound`, which the shell answers 404.
|
|
883
886
|
*
|
|
884
|
-
* THE ENGINE IS `js/maintain/uninstall.mjs`'s
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
* and reaches the wire for the first time here.
|
|
887
|
+
* THE ENGINE IS `js/maintain/uninstall.mjs`'s: `installDeactivate` / `installReactivate` are
|
|
888
|
+
* the reversible siblings of `installUninstall` — the same owned-file walk and ancestor prune
|
|
889
|
+
* with `move` where the reversal has `unlink` — which is why they live beside it rather than
|
|
890
|
+
* in the web tree.
|
|
889
891
|
*/
|
|
890
892
|
export function apiInstallToggle(state, body) {
|
|
891
893
|
const known = new Map();
|
|
@@ -910,14 +912,25 @@ export function apiInstallToggle(state, body) {
|
|
|
910
912
|
const mem = bget(body, 'memory');
|
|
911
913
|
res = installUninstall(root, host, scope, isTruthy(mem) ? mem : 'keep');
|
|
912
914
|
} else {
|
|
913
|
-
// `
|
|
914
|
-
//
|
|
915
|
+
// `formatRepr`, not `JSON.stringify` — SINGLE-quoted, consistent with every other error
|
|
916
|
+
// message in this file that echoes an unrecognised value.
|
|
915
917
|
res = { ok: false, error: `unknown action ${formatRepr(action)}` };
|
|
916
918
|
}
|
|
917
919
|
state.refresh();
|
|
918
920
|
return res;
|
|
919
921
|
}
|
|
920
922
|
|
|
923
|
+
/**
|
|
924
|
+
* The build command that deploys a FRESH per-repo harness into an arbitrary folder the user
|
|
925
|
+
* chose.
|
|
926
|
+
*
|
|
927
|
+
* The open-ended sibling of `apiInstallCmd`, which only rebuilds a pre-detected target from
|
|
928
|
+
* a tight allowlist. Scope is always `project`: a global lands in the host's config dir,
|
|
929
|
+
* never a chosen folder. THIS ENDPOINT TAKES A RAW PATH, so it is the trust boundary — the
|
|
930
|
+
* path is validated here as an existing, writable directory that is not a host's own global
|
|
931
|
+
* config dir (deploying a `project` emit there would mislabel as the global row and collide
|
|
932
|
+
* on dedup).
|
|
933
|
+
*/
|
|
921
934
|
export function apiDeployCmd(state, body) {
|
|
922
935
|
const host = stripWhitespace(strOr(bget(body, 'host')));
|
|
923
936
|
if (!HOSTS.some((h) => h.host === host)) {
|
|
@@ -933,7 +946,7 @@ export function apiDeployCmd(state, body) {
|
|
|
933
946
|
}
|
|
934
947
|
if (!isDir(root)) return { error: `not a folder: ${root}` };
|
|
935
948
|
try {
|
|
936
|
-
accessSync(root, constants.W_OK);
|
|
949
|
+
accessSync(root, constants.W_OK);
|
|
937
950
|
} catch {
|
|
938
951
|
return { error: `folder not writable: ${root}` };
|
|
939
952
|
}
|
|
@@ -941,7 +954,7 @@ export function apiDeployCmd(state, body) {
|
|
|
941
954
|
for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir, copilotConfigDir]) {
|
|
942
955
|
try {
|
|
943
956
|
cfgdirs.add(resolvePath(fn()));
|
|
944
|
-
} catch { /*
|
|
957
|
+
} catch { /* not every host has a resolvable config dir; skip it */ }
|
|
945
958
|
}
|
|
946
959
|
if (cfgdirs.has(root)) {
|
|
947
960
|
return { error: "that's a host global config dir — use its existing row to build a global install" };
|