geneseed 3.14.0 → 3.16.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 +66 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +45 -11
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +37 -11
- 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 +22 -20
- package/bin/geneseed-hook.mjs +6 -3
- package/docs/concepts/exclusions.md +21 -9
- package/docs/guides/install-scripted.md +1 -1
- package/docs/guides/project-context.md +1 -1
- package/docs/token-footprint.md +10 -7
- package/harness.config.json +1 -1
- package/js/README.md +9 -5
- package/js/build/args.mjs +38 -6
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +16 -14
- package/js/build/emit-claude.mjs +3 -2
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +26 -16
- package/js/build/render.mjs +34 -4
- package/js/build/source.mjs +43 -7
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +85 -34
- package/js/hosts/hooks-prims.mjs +2 -2
- package/js/hosts/hosts.mjs +24 -26
- package/js/hosts/installs.mjs +58 -16
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/memory-files.mjs +13 -4
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +46 -36
- package/js/hosts/shim.mjs +4 -2
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/status.mjs +20 -22
- package/js/inspect/validate.mjs +5 -3
- 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/maintain/memory.mjs +5 -6
- package/js/maintain/migrate.mjs +4 -3
- package/js/maintain/setup.mjs +20 -135
- package/js/maintain/uninstall.mjs +10 -9
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +29 -35
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +87 -389
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +33 -717
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- 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/src/AGENT.md.tmpl +1 -1
- package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
- package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
- package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
- package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
- package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
- package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
- package/web/dist/assets/Tabs-B0cdf8k6.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-ujKG1ZNf.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
- package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
- package/web/dist/assets/index-KHt3wE_K.js +0 -2
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
|
+
}
|