geneseed 4.17.1 → 4.17.2
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 +13 -0
- package/SHIPPED.md +1 -1
- package/docs/extending.md +1 -1
- package/docs/web-ui.md +17 -3
- package/harness.config.json +1 -1
- package/js/README.md +1 -1
- package/js/web/actions.mjs +61 -16
- package/js/web/api.mjs +12 -1
- package/js/web/catalog.mjs +3 -0
- package/js/web/handler.mjs +83 -30
- package/js/web/jobs.mjs +7 -7
- package/js/web/server.mjs +4 -0
- package/js/web/user-files.mjs +24 -7
- package/package.json +1 -1
- package/web/dist/assets/{Activity-DNVzYkIY.js → Activity-BvbzB-H1.js} +1 -1
- package/web/dist/assets/{ActivityDetail-CMBXjmTo.js → ActivityDetail-CSwA3UmY.js} +1 -1
- package/web/dist/assets/{Dashboard-DFY_FarE.js → Dashboard-DAN3juLM.js} +2 -2
- package/web/dist/assets/{Docs-BS0NIsA0.js → Docs-BQ2pEV8F.js} +1 -1
- package/web/dist/assets/{GreenhouseView-M3wWndqG.js → GreenhouseView-BoJyzeuf.js} +1 -1
- package/web/dist/assets/{Installs-CvRxZcZc.js → Installs-3fAay1NE.js} +3 -3
- package/web/dist/assets/{JournalView-BSD-1Z83.js → JournalView-Ck5c_3Md.js} +1 -1
- package/web/dist/assets/{KitApply-_4Ug9pMF.js → KitApply-CsnEjQi6.js} +1 -1
- package/web/dist/assets/{Kits-c5mVesJ9.js → Kits-Bos4VAWe.js} +1 -1
- package/web/dist/assets/{Laws-Bpn4ZBzL.js → Laws-r1vX_zU5.js} +1 -1
- package/web/dist/assets/{Library-DWgfAfkV.js → Library-DmFLcNYl.js} +1 -1
- package/web/dist/assets/{Loops-BqP3c0c-.js → Loops-DhRt563u.js} +1 -1
- package/web/dist/assets/{Markdown-zaXXXHz9.js → Markdown-unTJNwOC.js} +3 -3
- package/web/dist/assets/{OperatorHudView-DLEGd63S.js → OperatorHudView-DIhkL4qf.js} +1 -1
- package/web/dist/assets/Personal-BxtZyYl_.js +1 -0
- package/web/dist/assets/{Tabs-RdL1pfIT.js → Tabs-CFVpjPm7.js} +1 -1
- package/web/dist/assets/{drawable-inwWR3uw.js → drawable-D2dpNTE8.js} +1 -1
- package/web/dist/assets/{index-BAArp8rX.js → index-D-cBvfU2.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/dist/assets/Personal-B_LeqnDL.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,19 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [4.17.2] — 2026-10-24
|
|
12
|
+
|
|
13
|
+
Patch: the web console closes its security and data-loss holes from the 2026-10-24 audit.
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
- **Restore acts only on what the diff offers.** `POST /api/actions/restore` trusted the client's file list: one call could reset `user-rules.md`, `PROFILE.md` or a memory file to its seed, or delete a file the diff never listed. It now restores only the paths `/api/diff` offers, computed on the server. It never touches the files written once and then left to you (`user-rules.md`, `PROFILE.md`, `memory/`, `notebook/`). An added file is moved to `archived-restore/<stamp>/` in the install instead of being deleted.
|
|
17
|
+
- **The web console cannot be framed or driven from another site.** Every response carries a Content-Security-Policy with `frame-ancestors 'none'` and a per-response nonce on the token script, plus `X-Frame-Options: DENY`, `nosniff` and `Referrer-Policy: no-referrer`. One request guard checks the exact Host and port, refuses a cross-site `/api` request (`Sec-Fetch-Site`, foreign `Origin`), and compares the token in constant time. A repeated `GET /api/doctor?fresh=1` reuses a run less than 3 s old. The server sets request and header timeouts.
|
|
18
|
+
- **The markdown sanitizer drops SVG animation elements, `template`, raw-text elements and `style` attributes.** These closed a `javascript:` link set through `<set>`/`<animate>`, a full-page overlay drawn with `style`, and a mutation XSS through `xmp`, `noembed`, `noframes`, `noscript` or `plaintext`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Settings › Maintenance no longer offers an Uninstall.** It ran `geneseed uninstall --yes` with no target, so it always removed the OpenCode global install, whichever install you were viewing. Each install keeps its own Uninstall in Installs › Hosts.
|
|
22
|
+
- **Editing a rule keeps its scope.** A rule written `scope: global` was saved back as `project` on every Edit or Graduate. A scope the editor does not list is now kept as written. A rule body with a line that reads as a rule heading (`## …`) is refused with a 400, because it could create a second rule with a forged id. So is a `|` in the scope or source.
|
|
23
|
+
|
|
11
24
|
## [4.17.1] — 2026-10-23
|
|
12
25
|
|
|
13
26
|
Patch: the web console's Update button works on a global npm install and re-checks for updates while the page is open.
|
package/SHIPPED.md
CHANGED
|
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
|
|
|
20
20
|
> **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
|
|
21
21
|
> human-readable label only. The canonical identity of an installed harness is the
|
|
22
22
|
> **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
|
|
23
|
-
> string. Current label: **4.17.
|
|
23
|
+
> string. Current label: **4.17.2**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
package/docs/extending.md
CHANGED
|
@@ -125,7 +125,7 @@ Notes that matter:
|
|
|
125
125
|
|
|
126
126
|
### The gates CI does not run
|
|
127
127
|
|
|
128
|
-
- **The mutation matrix itself.** `tests/mutate.mjs` —
|
|
128
|
+
- **The mutation matrix itself.** `tests/mutate.mjs` — 34 rows, each a one-string edit to a
|
|
129
129
|
product file with a written argument for which gate should catch it. The full run is **in no
|
|
130
130
|
workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
|
|
131
131
|
Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
|
package/docs/web-ui.md
CHANGED
|
@@ -44,6 +44,15 @@ so other sites can't trigger builds, deletes, or shutdown. Mutating actions (bui
|
|
|
44
44
|
update, export, restore, memory-delete, uninstall) run in the background and report back
|
|
45
45
|
as toasts (fire-and-notify). Read-only browsing needs no token.
|
|
46
46
|
|
|
47
|
+
Every request passes one guard first: the `Host` must be `127.0.0.1`, `localhost` or `[::1]`
|
|
48
|
+
**at the server's own port** (DNS rebinding, other local servers), `/api/*` refuses a
|
|
49
|
+
`Sec-Fetch-Site` of `cross-site`/`same-site` or a foreign `Origin` (so another site cannot read
|
|
50
|
+
the API either), and every write compares the token in constant time. Every response carries
|
|
51
|
+
`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and a
|
|
52
|
+
strict CSP (`frame-ancestors 'none'`, same-origin only, the one inline token script admitted by
|
|
53
|
+
a per-response nonce). `?fresh=1` on the doctor re-runs at most once per 3 s, and the server
|
|
54
|
+
drops a request whose headers take over 10 s or whose whole request takes over 30 s.
|
|
55
|
+
|
|
47
56
|
## The rail
|
|
48
57
|
|
|
49
58
|
The left rail mirrors the harness's own shape:
|
|
@@ -89,7 +98,9 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
89
98
|
|
|
90
99
|
- **Rules** (`#/rules`) — your own standing rules, read from and written to the
|
|
91
100
|
`user-rules.md` beside the deployed AGENT.md (the seed-once file no update ever
|
|
92
|
-
touches). List with scope (user/project
|
|
101
|
+
touches). List with scope (user/project, or any other scope written by hand, which a
|
|
102
|
+
save keeps as written) and trial chips, add/edit/retire forms (a body line starting
|
|
103
|
+
`## ` is refused: the file would read it as a new rule heading), a
|
|
93
104
|
**Graduate** action that adopts a trial rule for good, and a budget meter that turns
|
|
94
105
|
amber as the always-loaded set gets heavy. Every write carries the fingerprint of the
|
|
95
106
|
content the page last read — if an agent session edited the file meanwhile, the save
|
|
@@ -169,6 +180,9 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
169
180
|
- **Changes** (`#/diff`) — the diff between the deployed harness and the source: edited /
|
|
170
181
|
added / missing files with per-file expansion and coloured unified-diff lines. Select
|
|
171
182
|
files to **export an `improvements.md`** back-port, or **restore** them to source.
|
|
183
|
+
Restore acts only on files the diff lists, and never on your own files (`user-rules.md`,
|
|
184
|
+
`PROFILE.md`, `memory/`, `notebook/`). An added file is moved into
|
|
185
|
+
`archived-restore/<date-time>/` in the install, not deleted.
|
|
172
186
|
- **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
|
|
173
187
|
theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
|
|
174
188
|
the server's last run — warmed at start, redone after a job, an install toggle or a view
|
|
@@ -207,8 +221,8 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
207
221
|
console: Cultivar, Operator, Matrix or Atlas) with a **Dashboard layout** control (Auto follows each theme's designed Status
|
|
208
222
|
lens; Cultivar / Greenhouse / Operator / Journal force one regardless of skin), the
|
|
209
223
|
install snapshot (deployed/target/theme/version/memory store), a Maintenance card (PATH
|
|
210
|
-
link/unlink
|
|
211
|
-
**About** colophon — project + creator credits and the source link, folded in from the
|
|
224
|
+
link/unlink and Update; each install is uninstalled from its own panel in Hosts), an
|
|
225
|
+
offline package download, server controls (Stop), and the **About** colophon — project + creator credits and the source link, folded in from the
|
|
212
226
|
retired `#/about` tab (which still resolves here). Build, update, and MCP wiring live in
|
|
213
227
|
the Harness tab.
|
|
214
228
|
|
package/harness.config.json
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
"_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
|
|
3
3
|
"theme": "neutral",
|
|
4
4
|
"product": "Geneseed",
|
|
5
|
-
"version": "4.17.
|
|
5
|
+
"version": "4.17.2"
|
|
6
6
|
}
|
package/js/README.md
CHANGED
|
@@ -235,7 +235,7 @@ the deployed harness as JSON.
|
|
|
235
235
|
|---|---|
|
|
236
236
|
| `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
|
|
237
237
|
| `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
|
|
238
|
-
| `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF
|
|
238
|
+
| `handler.mjs` | One function per request: dispatch, gzip, `requestGuard` (Host+port, Origin/Sec-Fetch-Site on `/api`, constant-time CSRF token) and body cap (before the body is read), `securityHeaders` (CSP nonce, framing), static SPA fallback |
|
|
239
239
|
| `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
|
|
240
240
|
| `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
|
|
241
241
|
| `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
|
package/js/web/actions.mjs
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { spawn } from 'node:child_process';
|
|
29
29
|
import {
|
|
30
|
-
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync,
|
|
30
|
+
accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync,
|
|
31
|
+
renameSync, rmSync,
|
|
31
32
|
} from 'node:fs';
|
|
32
33
|
import os from 'node:os';
|
|
33
34
|
import path from 'node:path';
|
|
@@ -36,7 +37,8 @@ import { emitGlobalInto } from '../build/driver.mjs';
|
|
|
36
37
|
import {
|
|
37
38
|
ROOT, discoverNames, knownRuleIds, loadKits, PACK_ORDER,
|
|
38
39
|
} from '../build/source.mjs';
|
|
39
|
-
import { withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
40
|
+
import { diffCollect, drifted, withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
41
|
+
import { SESSION_FILES } from '../hosts/hooks-context.mjs';
|
|
40
42
|
import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
|
|
41
43
|
import { installProfile, setupBuildArgs } from '../build/generate.mjs';
|
|
42
44
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
@@ -503,13 +505,30 @@ export function globalEmitHostFor(emit) {
|
|
|
503
505
|
return HOSTS.some((h) => h.host === host) ? host : 'opencode';
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
/**
|
|
509
|
+
* The session files' names and their top folders (`memory/`, `notebook/`, …): seed-once, the
|
|
510
|
+
* user's after the first build. Derived from `SESSION_FILES`, the one list of them.
|
|
511
|
+
*/
|
|
512
|
+
const SEED_FILES = new Set(SESSION_FILES.filter((r) => !r.includes('/')));
|
|
513
|
+
const SEED_DIRS = new Set(SESSION_FILES.filter((r) => r.includes('/')).map((r) => r.split('/')[0]));
|
|
514
|
+
const isSeedOnce = (rel) => SEED_FILES.has(rel) || SEED_DIRS.has(rel.split('/')[0]);
|
|
515
|
+
|
|
506
516
|
/**
|
|
507
517
|
* Restore selected drifted files from the SOURCE render.
|
|
508
518
|
*
|
|
509
519
|
* Source wins and local edits are discarded (the inverse, keeping them, is Export
|
|
510
|
-
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel
|
|
511
|
-
*
|
|
512
|
-
*
|
|
520
|
+
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel, BY ITS
|
|
521
|
+
* DIFF STATUS: edited/missing -> overwrite/create the deployed copy; added -> move it into
|
|
522
|
+
* `archived-restore/<stamp>/` inside the install; anything else -> an error and nothing touched.
|
|
523
|
+
*
|
|
524
|
+
* THE CLIENT'S LIST IS A SELECTION, NEVER AN AUTHORITY. Only a rel the drift list offers is
|
|
525
|
+
* acted on, and that list is `diffCollect` with the arguments `apiDiff` passes, so the panel and
|
|
526
|
+
* this verb cannot disagree. Trusting the POST made it a write-or-delete primitive over the whole
|
|
527
|
+
* install (audit SE-1, 2026-10): one request reset `user-rules.md` and the memory index to their
|
|
528
|
+
* stubs, a second unlinked a memory fact and an unrelated secret file. The seed-once files are
|
|
529
|
+
* refused even when a manifest claims them. An added file is ARCHIVED, never unlinked, in the
|
|
530
|
+
* `archived-<name>/<stamp>` shape `archiveStore` gives uninstall; nothing owns the archive, so
|
|
531
|
+
* the next diff does not list it.
|
|
513
532
|
*
|
|
514
533
|
* SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
|
|
515
534
|
* structured result rather than a job id — which is why the dispatcher answers it before it
|
|
@@ -534,7 +553,11 @@ export function apiRestore(state, files) {
|
|
|
534
553
|
const restored = [];
|
|
535
554
|
const deleted = [];
|
|
536
555
|
const errors = [];
|
|
556
|
+
let archive = null;
|
|
537
557
|
const target = resolvePath(state.target);
|
|
558
|
+
const offered = new Map(drifted(diffCollect({
|
|
559
|
+
target: state.target, theme: state.theme, emit: state.emit }).files ?? [])
|
|
560
|
+
.map((f) => [f.rel, f.status]));
|
|
538
561
|
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-restore-'));
|
|
539
562
|
try {
|
|
540
563
|
const expected = resolvePath(path.join(tmp, 'expected'));
|
|
@@ -566,26 +589,48 @@ export function apiRestore(state, files) {
|
|
|
566
589
|
const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
567
590
|
const dst = resolvePath(path.join(target, rel));
|
|
568
591
|
const src = resolvePath(path.join(expected, rel));
|
|
592
|
+
const status = offered.get(rel);
|
|
569
593
|
if (!rel || !within(dst, target) || !within(src, expected)) {
|
|
570
594
|
errors.push(`${rel}: outside the deployed tree`);
|
|
571
|
-
} else if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
595
|
+
} else if (!status || isSeedOnce(rel)) {
|
|
596
|
+
errors.push(`${rel}: not a drifted file restore may touch`);
|
|
597
|
+
} else if (status !== 'added' && !isFile(src)) {
|
|
598
|
+
errors.push(`${rel}: not in the source render`);
|
|
599
|
+
} else if (status !== 'added') {
|
|
600
|
+
// Caught per file, so one EISDIR/EBUSY cannot lose the report of the files before it.
|
|
601
|
+
try {
|
|
602
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
603
|
+
// A BYTE copy, so the restored file keeps the render's own line endings rather than
|
|
604
|
+
// the platform's (the `writeText`/`copy2` rule, one file over).
|
|
605
|
+
copyFileSync(src, dst);
|
|
606
|
+
restored.push(rel);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
errors.push(`${rel}: could not restore (${e.code || e.message})`);
|
|
609
|
+
}
|
|
580
610
|
} else {
|
|
581
|
-
|
|
611
|
+
// `archiveStore`'s local `YYYYMMDD-HHMMSS` stamp, one per call; a taken name gets `-2`, …
|
|
612
|
+
archive ??= path.join(target, 'archived-restore', new Date().toLocaleString('sv-SE')
|
|
613
|
+
.replace(/\D/g, '').replace(/^(\d{8})/, '$1-'));
|
|
614
|
+
let dest = resolvePath(path.join(archive, rel));
|
|
615
|
+
for (let n = 2; existsSync(dest); n += 1) dest = resolvePath(path.join(archive, `${rel}-${n}`));
|
|
616
|
+
if (!within(dest, archive)) {
|
|
617
|
+
errors.push(`${rel}: outside the archive`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
622
|
+
renameSync(dst, dest);
|
|
623
|
+
deleted.push(rel);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
errors.push(`${rel}: could not archive (${e.code || e.message})`);
|
|
626
|
+
}
|
|
582
627
|
}
|
|
583
628
|
}
|
|
584
629
|
} finally {
|
|
585
630
|
rmSync(tmp, { recursive: true, force: true });
|
|
586
631
|
}
|
|
587
632
|
state.refresh();
|
|
588
|
-
return { restored, deleted, errors };
|
|
633
|
+
return { restored, deleted, errors, archive };
|
|
589
634
|
}
|
|
590
635
|
|
|
591
636
|
/** (host, scope) -> the emit name that installs it. */
|
package/js/web/api.mjs
CHANGED
|
@@ -80,13 +80,24 @@ export function apiSetup(state) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Short: a user who fixes a problem and clicks Re-run must not be shown the old verdict for long.
|
|
84
|
+
const DOCTOR_COOLDOWN_MS = 3_000;
|
|
85
|
+
|
|
83
86
|
/**
|
|
84
87
|
* The same engine as the `doctor` verb, grouped per check — served from the state's cached run
|
|
85
88
|
* (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
|
|
86
89
|
* for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
|
|
90
|
+
*
|
|
91
|
+
* A fresh ask within `DOCTOR_COOLDOWN_MS` of the last one THIS PATH ran reuses that run, so a
|
|
92
|
+
* page looping on `?fresh=1` cannot pin the event loop. `_doctorFreshAt` is written here only: a
|
|
93
|
+
* verdict planted any other way still re-runs. No single-flight promise — `runDoctor` is
|
|
94
|
+
* synchronous, so two fresh runs cannot overlap (revisit if it ever goes async).
|
|
87
95
|
*/
|
|
88
96
|
export function apiDoctor(state, fresh = false) {
|
|
89
|
-
if (fresh) state.
|
|
97
|
+
if (fresh && Date.now() - (state._doctorFreshAt ?? 0) >= DOCTOR_COOLDOWN_MS) {
|
|
98
|
+
state.runDoctor();
|
|
99
|
+
state._doctorFreshAt = Date.now();
|
|
100
|
+
}
|
|
90
101
|
const { ok, problems, checked_at } = state.doctor;
|
|
91
102
|
return { ...state._doctorRun, ok, problems, checked_at };
|
|
92
103
|
}
|
package/js/web/catalog.mjs
CHANGED
|
@@ -29,6 +29,9 @@ import { loadKits } from '../build/source.mjs';
|
|
|
29
29
|
/** A requested catalog section or item that does not exist. */
|
|
30
30
|
export class NotFound extends Error {}
|
|
31
31
|
|
|
32
|
+
/** A request body the route refuses on its content (a 400, not a 500): the caller can fix it. */
|
|
33
|
+
export class BadRequest extends Error {}
|
|
34
|
+
|
|
32
35
|
/** `??` would also swallow an explicit `null` the file declared; this does not. */
|
|
33
36
|
export const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
34
37
|
|
package/js/web/handler.mjs
CHANGED
|
@@ -6,29 +6,32 @@
|
|
|
6
6
|
* surface testable at all. Keep the arity.
|
|
7
7
|
*
|
|
8
8
|
* What it actually uses of HTTP is small and deliberate — a whole-buffer `gzipSync` over a few
|
|
9
|
-
* content types, two Cache-Control policies,
|
|
10
|
-
*
|
|
9
|
+
* content types, two Cache-Control policies, one set of security headers (CSP with a per-response
|
|
10
|
+
* nonce) on every response, and one request guard (`requestGuard`) in front of every route. No
|
|
11
|
+
* streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
|
|
11
12
|
* declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
|
|
12
13
|
*/
|
|
13
14
|
import { resolvePath } from '../hosts/hosts.mjs';
|
|
14
15
|
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
15
16
|
import { parseIntStrict, percentDecode } from '../lib/text.mjs';
|
|
16
17
|
import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildActionCmds, mcpTargetPaths } from './actions.mjs';
|
|
17
|
-
import { NotFound } from './catalog.mjs';
|
|
18
|
+
import { BadRequest, NotFound } from './catalog.mjs';
|
|
18
19
|
import { openUrl, requestRestart } from './daemon.mjs';
|
|
19
20
|
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
20
21
|
import { actionCommands, updatePlan } from './jobs.mjs';
|
|
21
22
|
import { POST_ROUTES, PREFIX_ROUTES, STATE_ROUTES, readJsonBody } from './routes.mjs';
|
|
22
23
|
import { installKind } from './update-check.mjs';
|
|
23
24
|
import { isFile } from '../lib/fs.mjs';
|
|
25
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
24
26
|
import { readFileSync, statSync } from 'node:fs';
|
|
25
27
|
import { basename, dirname, extname, join, sep } from 'node:path';
|
|
26
28
|
import { gzipSync } from 'node:zlib';
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
* DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST
|
|
30
|
-
* `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
|
|
31
|
-
* prefix check above it is for.
|
|
31
|
+
* DNS-rebinding guard, HOSTNAME ONLY. `h.split(":", 1)[0]` keeps everything before the FIRST
|
|
32
|
+
* colon, so `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
|
|
33
|
+
* prefix check above it is for. The server itself no longer calls it: `requestGuard` matches the
|
|
34
|
+
* exact `name:port` instead, which this looser predicate cannot express.
|
|
32
35
|
*/
|
|
33
36
|
export function localHost(host) {
|
|
34
37
|
const h = (host || '').trim().toLowerCase();
|
|
@@ -36,6 +39,62 @@ export function localHost(host) {
|
|
|
36
39
|
return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Sent on EVERY response, merged last so no caller's `extra` can weaken one. `frame-ancestors` +
|
|
44
|
+
* `X-Frame-Options` stop a foreign page from framing the console and clickjacking its buttons; the
|
|
45
|
+
* CSP admits the one inline script (the token) by `nonce`, so an injected `<script>` cannot run
|
|
46
|
+
* and read the token. `style-src 'unsafe-inline'` because the React UI sets `style=` attributes.
|
|
47
|
+
*/
|
|
48
|
+
export function securityHeaders(nonce) {
|
|
49
|
+
return {
|
|
50
|
+
'X-Frame-Options': 'DENY',
|
|
51
|
+
'Content-Security-Policy': "frame-ancestors 'none'; default-src 'self'; "
|
|
52
|
+
+ `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; `
|
|
53
|
+
+ "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; "
|
|
54
|
+
+ "manifest-src 'self'; base-uri 'none'; form-action 'none'",
|
|
55
|
+
'X-Content-Type-Options': 'nosniff',
|
|
56
|
+
'Referrer-Policy': 'no-referrer',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* THE one request guard, answered before dispatch (and before a POST body is read). `null` =
|
|
62
|
+
* pass, else `{ code, body }`.
|
|
63
|
+
*
|
|
64
|
+
* - Host must be an allowed hostname AT THIS SOCKET'S PORT, exactly: the hostname stops DNS
|
|
65
|
+
* rebinding, the port stops another local server's page from posing as this origin.
|
|
66
|
+
* - `/api/*`: a cross-site or same-site fetch, or a foreign `Origin`, is refused even on GET —
|
|
67
|
+
* the reads expose paths and memories. A request with neither header (curl, Node) passes.
|
|
68
|
+
* - Any non-GET: the CSRF token, compared in constant time so its bytes cannot be timed out.
|
|
69
|
+
*/
|
|
70
|
+
export function requestGuard(req, token) {
|
|
71
|
+
const host = req.headers.host || '';
|
|
72
|
+
// Exact `name:port`, not `localHost` + a suffix test: that pair let `[::1]x.evil:4747` and
|
|
73
|
+
// `localhost:1:4747` through. Lower-cased the way browsers send it.
|
|
74
|
+
const want = `:${req.socket.localPort}`;
|
|
75
|
+
const h = host.trim().toLowerCase();
|
|
76
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(h.endsWith(want) ? h.slice(0, -want.length) : '')) {
|
|
77
|
+
return { code: 403, body: { error: 'forbidden host' } };
|
|
78
|
+
}
|
|
79
|
+
const path = (req.url || '').split('?', 1)[0];
|
|
80
|
+
if (path.startsWith('/api/')) {
|
|
81
|
+
const site = req.headers['sec-fetch-site'];
|
|
82
|
+
const origin = req.headers.origin;
|
|
83
|
+
if (site === 'cross-site' || site === 'same-site'
|
|
84
|
+
|| (origin !== undefined && origin !== `http://${host}`)) {
|
|
85
|
+
return { code: 403, body: { error: 'forbidden origin' } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (req.method !== 'GET') {
|
|
89
|
+
const got = Buffer.from(String(req.headers['x-geneseed-token'] ?? ''), 'utf-8');
|
|
90
|
+
const want = Buffer.from(token, 'utf-8');
|
|
91
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) {
|
|
92
|
+
return { code: 403, body: { error: 'forbidden' } };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
39
98
|
const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
|
|
40
99
|
'application/manifest+json', 'application/javascript'];
|
|
41
100
|
const GZIP_MIN = 1024;
|
|
@@ -74,7 +133,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
74
133
|
// page stayed blank with nothing logged on either side.
|
|
75
134
|
const distReal = resolvePath(dist);
|
|
76
135
|
|
|
77
|
-
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null
|
|
136
|
+
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null,
|
|
137
|
+
nonce = randomBytes(16).toString('base64')) {
|
|
78
138
|
const headers = { ...(extra || {}) };
|
|
79
139
|
if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
|
|
80
140
|
&& (acceptEncoding || '').includes('gzip')) {
|
|
@@ -86,6 +146,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
86
146
|
'Content-Type': ctype,
|
|
87
147
|
'Content-Length': String(body.length),
|
|
88
148
|
...headers,
|
|
149
|
+
...securityHeaders(nonce),
|
|
89
150
|
});
|
|
90
151
|
res.end(body);
|
|
91
152
|
}
|
|
@@ -114,8 +175,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
114
175
|
}
|
|
115
176
|
let data = entry.data;
|
|
116
177
|
let extra = null;
|
|
178
|
+
// Fresh per response; for index.html the same value goes in the token script and its CSP.
|
|
179
|
+
const nonce = randomBytes(16).toString('base64');
|
|
117
180
|
if (basename(fp) === 'index.html') {
|
|
118
|
-
const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
181
|
+
const inject = Buffer.from(`<script nonce="${nonce}">window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
119
182
|
data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
|
|
120
183
|
extra = { 'Cache-Control': 'no-store' };
|
|
121
184
|
// The injected bytes differ from `entry.data`, so the gz cache must not serve them.
|
|
@@ -124,7 +187,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
124
187
|
extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
125
188
|
}
|
|
126
189
|
return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
|
|
127
|
-
200, extra, acceptEncoding, entry);
|
|
190
|
+
200, extra, acceptEncoding, entry, nonce);
|
|
128
191
|
}
|
|
129
192
|
|
|
130
193
|
function doGet(req, res, path, ae) {
|
|
@@ -156,19 +219,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
156
219
|
return serveStatic(res, path, ae);
|
|
157
220
|
}
|
|
158
221
|
|
|
159
|
-
/** The Host and token guards, answered BEFORE a byte of the body is read. `true` = refused. */
|
|
160
|
-
function refusePost(req, res, ae) {
|
|
161
|
-
if (!localHost(req.headers.host)) {
|
|
162
|
-
sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
if (req.headers['x-geneseed-token'] !== token) {
|
|
166
|
-
sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
222
|
function doPost(req, res, path, ae, body) {
|
|
173
223
|
if (path === '/api/shutdown') {
|
|
174
224
|
if (jm.busy) return sendJson(res, BUSY, 409, ae);
|
|
@@ -346,10 +396,13 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
346
396
|
const ae = req.headers['accept-encoding'] || '';
|
|
347
397
|
const path = (req.url || '').split('?', 1)[0];
|
|
348
398
|
try {
|
|
399
|
+
// GUARD FIRST, BODY SECOND: a refused request is DISCARDED (`resume`, nothing buffered).
|
|
400
|
+
const refused = requestGuard(req, token);
|
|
401
|
+
if (refused) {
|
|
402
|
+
sendJson(res, refused.body, refused.code, ae);
|
|
403
|
+
return req.resume();
|
|
404
|
+
}
|
|
349
405
|
if (req.method === 'GET') {
|
|
350
|
-
if (!localHost(req.headers.host)) {
|
|
351
|
-
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
352
|
-
}
|
|
353
406
|
try {
|
|
354
407
|
return doGet(req, res, path, ae);
|
|
355
408
|
} catch (e) {
|
|
@@ -363,13 +416,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
363
416
|
}
|
|
364
417
|
}
|
|
365
418
|
if (req.method === 'POST') {
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// collected. A body over `MAX_BODY` is refused on its declared length, and the socket is
|
|
369
|
-
// closed rather than drained. Otherwise the body is buffered before routing regardless
|
|
419
|
+
// The guard above needed no body, so it answered before one was read. A body over
|
|
420
|
+
// `MAX_BODY` is refused on its declared length, and the socket is closed rather than drained. Otherwise the body is buffered before routing regardless
|
|
370
421
|
// of what the route needs it for: Node's parser owns the message boundary, so unread
|
|
371
422
|
// bytes are discarded when the response ends rather than mis-parsed as the next request.
|
|
372
|
-
if (refusePost(req, res, ae)) return req.resume();
|
|
373
423
|
const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
374
424
|
if (length > MAX_BODY) {
|
|
375
425
|
res.setHeader('Connection', 'close');
|
|
@@ -387,6 +437,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
387
437
|
if (e instanceof NotFound) {
|
|
388
438
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
389
439
|
}
|
|
440
|
+
// A body the route refused on its content (a rule body with a `## ` heading) is
|
|
441
|
+
// the caller's to fix: 400, not the 500 of a server fault.
|
|
442
|
+
if (e instanceof BadRequest) return sendJson(res, { error: e.message }, 400, ae);
|
|
390
443
|
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
391
444
|
}
|
|
392
445
|
});
|
package/js/web/jobs.mjs
CHANGED
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
* `taskkill /T` is a machine primitive here, not this program.
|
|
45
45
|
*
|
|
46
46
|
* ---------------------------------------------------------------------------------------
|
|
47
|
-
* THE ACTION SURFACE — `doctor`, `build`, `build-all`, `export`, `
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* THE ACTION SURFACE — `doctor`, `build`, `build-all`, `export`, `update`, `link` and
|
|
48
|
+
* `unlink` each name a verb `bin/geneseed-cli.mjs` runs, plus the three `INLINE_ACTIONS` the
|
|
49
|
+
* dispatcher answers without the table. `tests/unit/web_jobs.test.mjs` holds the union against
|
|
50
|
+
* a written-out list, so an eighth action cannot appear unchecked.
|
|
51
51
|
*
|
|
52
52
|
* ---------------------------------------------------------------------------------------
|
|
53
53
|
* THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
|
|
@@ -454,11 +454,11 @@ function actionTable({
|
|
|
454
454
|
// by `js/maintain/update.mjs`.
|
|
455
455
|
update: [[NODE(), CLI(), 'upgrade']],
|
|
456
456
|
export: [[NODE(), CLI(), 'diff', '--out']],
|
|
457
|
-
// Local-machine maintenance, surfaced in the web Settings. uninstall
|
|
458
|
-
//
|
|
457
|
+
// Local-machine maintenance, surfaced in the web Settings. No `uninstall` row: it took no
|
|
458
|
+
// target, so it always fell back to the OpenCode global. The per-install remove is
|
|
459
|
+
// `installUninstall` in `js/web/actions.mjs`, bound to the (host, path) the panel shows.
|
|
459
460
|
link: [[NODE(), CLI(), 'link']],
|
|
460
461
|
unlink: [[NODE(), CLI(), 'unlink']],
|
|
461
|
-
uninstall: [[NODE(), CLI(), 'uninstall', '--yes']],
|
|
462
462
|
};
|
|
463
463
|
}
|
|
464
464
|
|
package/js/web/server.mjs
CHANGED
|
@@ -153,6 +153,10 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
153
153
|
// Read by `/api/update`, whose background `git fetch` must not race a running update's pull.
|
|
154
154
|
state.jobBusy = () => jm.busy;
|
|
155
155
|
const srv = createServer(makeHandler(state, jm, token, dist, holder));
|
|
156
|
+
// A client trickling headers or a body (slowloris) would hold a socket forever otherwise; every
|
|
157
|
+
// real request here is a few KB from the same machine.
|
|
158
|
+
srv.requestTimeout = 30_000;
|
|
159
|
+
srv.headersTimeout = 10_000;
|
|
156
160
|
holder.srv = srv;
|
|
157
161
|
return new Promise((done) => {
|
|
158
162
|
srv.on('error', () => {
|
package/js/web/user-files.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { WHITESPACE, codePointLength, stripWhitespace } from '../lib/text.mjs';
|
|
|
19
19
|
import { splitLines } from '../lib/udiff.mjs';
|
|
20
20
|
import { isFactName } from '../maintain/memory.mjs';
|
|
21
21
|
import { apiMemoryDelete, bget, strOr } from './actions.mjs';
|
|
22
|
-
import { NotFound, memoryDir } from './catalog.mjs';
|
|
22
|
+
import { BadRequest, NotFound, memoryDir } from './catalog.mjs';
|
|
23
23
|
|
|
24
24
|
/** Splits on runs of `WHITESPACE` (not `\s`), dropping empty strings. */
|
|
25
25
|
const splitWords = (s) => s.split(new RegExp(`[${WHITESPACE}]+`)).filter(Boolean);
|
|
@@ -80,6 +80,14 @@ const RULE_HEAD_RE = new RegExp(
|
|
|
80
80
|
+ `([^${WHITESPACE}].*?)[${WHITESPACE}]*$`,
|
|
81
81
|
);
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A line `parseRules` reads as the edge of a rule: `## ` ends the current block, and a
|
|
85
|
+
* `RULE_HEAD_RE` line opens one (a non-breaking space after `##` does the second, not the first).
|
|
86
|
+
* ONE predicate for both the parser and `ruleFields`, so a body the writer accepts can never
|
|
87
|
+
* split or forge a rule when read back.
|
|
88
|
+
*/
|
|
89
|
+
const isRuleEdge = (line) => line.startsWith('## ') || RULE_HEAD_RE.test(line);
|
|
90
|
+
|
|
83
91
|
/** Advisory only; nothing blocks past it. */
|
|
84
92
|
const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
|
|
85
93
|
|
|
@@ -119,7 +127,7 @@ export function parseRules(text) {
|
|
|
119
127
|
if (!m) continue;
|
|
120
128
|
let end = lines.length;
|
|
121
129
|
for (let j = idx + 1; j < lines.length; j += 1) {
|
|
122
|
-
if (lines[j]
|
|
130
|
+
if (isRuleEdge(lines[j])) { end = j; break; }
|
|
123
131
|
}
|
|
124
132
|
const rid = Number(m[1]);
|
|
125
133
|
if (seen.has(rid)) warnings.push(`duplicate rule id R${rid}`);
|
|
@@ -184,18 +192,27 @@ function ruleBlock(rid, title, scope, source, trialUntil, body) {
|
|
|
184
192
|
* `\p{Nd}` here and not `\d`, unlike `RULE_HEAD_RE` above: this is a pure predicate over a
|
|
185
193
|
* string that stays a string, so accepting any Unicode decimal digit costs nothing and
|
|
186
194
|
* needs no numeric parse afterward.
|
|
195
|
+
*
|
|
196
|
+
* REFUSED, NEVER REWRITTEN: a body line that `isRuleEdge` reads would truncate this rule and
|
|
197
|
+
* could forge another rule's id, and a `|` in the scope or source would forge a metadata field. An
|
|
198
|
+
* unknown scope (`global`, written by hand) is kept as written — coercing it to `project` made
|
|
199
|
+
* every Edit or Graduate silently change the user's rule.
|
|
187
200
|
*/
|
|
188
201
|
function ruleFields(body) {
|
|
189
202
|
const title = splitWords(strOr(bget(body, 'title'))).join(' ');
|
|
190
203
|
const text = stripWhitespace(strOr(bget(body, 'body')));
|
|
191
|
-
if (!title) throw new
|
|
192
|
-
if (!text) throw new
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
if (!title) throw new BadRequest('a rule needs a title');
|
|
205
|
+
if (!text) throw new BadRequest('a rule needs body text');
|
|
206
|
+
if (splitLines(text).some(isRuleEdge)) {
|
|
207
|
+
throw new BadRequest('a rule body cannot have a line that starts with "## " (a rule heading)');
|
|
208
|
+
}
|
|
209
|
+
const scope = splitWords(strOr(bget(body, 'scope'))).join(' ') || 'project';
|
|
210
|
+
if (scope.includes('|')) throw new BadRequest('a rule scope cannot contain "|"');
|
|
195
211
|
const source = splitWords(strOr(bget(body, 'source'))).join(' ');
|
|
212
|
+
if (source.includes('|')) throw new BadRequest('a rule source cannot contain "|" (write it as "/")');
|
|
196
213
|
const trial = stripWhitespace(strOr(bget(body, 'trial_until')));
|
|
197
214
|
if (trial && !/^\p{Nd}{4}-\p{Nd}{2}-\p{Nd}{2}$/u.test(trial)) {
|
|
198
|
-
throw new
|
|
215
|
+
throw new BadRequest('trial until must be YYYY-MM-DD');
|
|
199
216
|
}
|
|
200
217
|
return [title, text, scope, source, trial];
|
|
201
218
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
|
|
3
3
|
"name": "geneseed",
|
|
4
|
-
"version": "4.17.
|
|
4
|
+
"version": "4.17.2",
|
|
5
5
|
"description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"ai",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{r as e}from"./rolldown-runtime-hePW80VL.js";import{i as t,t as n}from"./react-DlYQp10C.js";import{t as r}from"./api-BfpZmUMI.js";import{$ as i,P as a,S as o,w as s,z as c}from"./index-
|
|
1
|
+
import{r as e}from"./rolldown-runtime-hePW80VL.js";import{i as t,t as n}from"./react-DlYQp10C.js";import{t as r}from"./api-BfpZmUMI.js";import{$ as i,P as a,S as o,w as s,z as c}from"./index-D-cBvfU2.js";import{a as l,i as u,n as d,o as f,r as p,t as m}from"./activity-Jw8XcPiP.js";import{t as h}from"./usePoll-bDFN-Hr6.js";var g=e(t(),1),_=n();function v({s:e}){let t=p[e.status]||p.idle,n=e.files,r=e.todos,i=r&&r.total>0||e.error||e.blocked_on;return(0,_.jsxs)(`a`,{className:`card pad-md act-card`,href:`#/activity/${encodeURIComponent(e.session_id)}`,children:[(0,_.jsxs)(`div`,{className:`row between wrap gap-12`,children:[(0,_.jsxs)(`div`,{className:`row gap-10`,style:{minWidth:0},children:[(0,_.jsx)(`span`,{className:`feed-dot ${t.cls||`acc`}`,style:{width:9,height:9}}),(0,_.jsxs)(`div`,{style:{minWidth:0},children:[(0,_.jsx)(`h3`,{style:{margin:0},children:e.title||l(e.cwd)||e.session_id}),e.cwd&&(0,_.jsx)(`div`,{className:`dim mono`,style:{fontSize:12,...m},title:e.cwd,children:e.cwd})]})]}),(0,_.jsxs)(`div`,{className:`row gap-8`,style:{flexShrink:0},children:[e.model&&(0,_.jsx)(`span`,{className:`badge mono`,title:`model`,children:e.model}),e.agent&&(0,_.jsx)(`span`,{className:`badge`,children:e.agent}),(0,_.jsxs)(`span`,{className:`badge ${t.cls}`,children:[(0,_.jsx)(`span`,{className:`dot`}),t.label]}),(0,_.jsx)(a,{name:`chevron`,className:`glyph act-chev`})]})]}),e.phase&&(0,_.jsxs)(`div`,{className:`row gap-8`,style:{marginTop:10,fontSize:14},children:[(0,_.jsx)(a,{name:`activity`}),(0,_.jsx)(`span`,{style:m,children:e.phase})]}),(e.tokens>0||e.cost>0||e.turn_started_at||n&&n.count>0)&&(0,_.jsxs)(`div`,{className:`row wrap`,style:{gap:16,marginTop:12,fontSize:13,color:`var(--text-2)`},children:[e.tokens>0&&(0,_.jsxs)(`span`,{children:[f(e.tokens),` tok`]}),e.cost>0&&(0,_.jsxs)(`span`,{children:[`$`,e.cost.toFixed(2)]}),e.turn_started_at?(0,_.jsx)(`span`,{children:(0,_.jsx)(d,{startedAt:e.turn_started_at})}):null,n&&n.count>0&&(0,_.jsxs)(`span`,{children:[n.count,` file`,n.count===1?``:`s`,` `,(0,_.jsxs)(`span`,{style:{color:`var(--good)`},children:[`+`,n.additions]}),` `,(0,_.jsxs)(`span`,{style:{color:`var(--bad)`},children:[`−`,n.deletions]})]})]}),i&&(0,_.jsxs)(`div`,{className:`row between wrap gap-12`,style:{marginTop:12,paddingTop:10,borderTop:`1px solid var(--line)`},children:[r&&r.total>0?(0,_.jsxs)(`span`,{className:`row gap-8 dim`,style:{fontSize:13},children:[(0,_.jsx)(u,{done:r.done,total:r.total}),`plan `,r.done,`/`,r.total]}):(0,_.jsx)(`span`,{}),e.error?(0,_.jsx)(`span`,{className:`badge bad`,title:e.error,style:{maxWidth:300,...m},children:e.error}):e.blocked_on?(0,_.jsx)(`span`,{className:`badge bad`,title:e.blocked_on,style:{maxWidth:300,...m},children:e.blocked_on}):null]}),(0,_.jsxs)(`div`,{className:`dim mono`,style:{fontSize:11,marginTop:8},children:[`updated `,c(e.updated_at),` ago`]})]})}function y(){let[e,t]=(0,g.useState)(null),[n,a]=(0,g.useState)(``);h(e=>r.activity().then(n=>{e()&&(t(n),a(``))},t=>{e()&&a(t.message)}),2e3,[]);let c=!e||e.enabled!==!1,l=e?.activity||[],u=i(),d=async()=>{let e=!c;try{let n=await r.activityToggle(e);t(e=>({...e||{},enabled:n.enabled,activity:n.enabled&&e?.activity||[]})),a(``)}catch(e){a(e.message)}};return(0,_.jsxs)(`div`,{className:`narrow`,children:[(0,_.jsxs)(`div`,{className:`head-row mb-16`,children:[(0,_.jsxs)(`div`,{children:[(0,_.jsx)(`h1`,{className:`h`,children:`Activity`}),(0,_.jsx)(`p`,{className:`sub`,children:`Live OpenCode sessions this harness is running: what each one is doing right now. Refreshes every couple of seconds; sessions drop off when their process exits.`})]}),(0,_.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`center`,flexShrink:0},children:[(0,_.jsx)(`span`,{className:`dim mono`,style:{fontSize:12},children:c?`tracking on`:`tracking off`}),(0,_.jsx)(`div`,{className:`sw-toggle${c?` on`:``}`,role:`switch`,"aria-checked":c,"aria-label":`Toggle activity tracking`,tabIndex:0,onClick:d,onKeyDown:e=>{(e.key===`Enter`||e.key===` `)&&(e.key===` `&&e.preventDefault(),d())}})]})]}),(0,_.jsx)(o,{error:n,style:{marginBottom:12}}),e?c?l.length===0?(0,_.jsxs)(`div`,{className:`empty`,children:[(0,_.jsx)(`div`,{className:`big`,children:`No active sessions`}),`Run `,(0,_.jsx)(`code`,{children:`opencode`}),` and the sessions it spins up appear here.`]}):(0,_.jsx)(`div`,{className:`stack gap-12`,ref:u,children:l.map(e=>(0,_.jsx)(`div`,{"data-flip":e.session_id,children:(0,_.jsx)(v,{s:e})},e.session_id))}):(0,_.jsxs)(`div`,{className:`empty`,children:[(0,_.jsx)(`div`,{className:`big`,children:`Activity tracking is off`}),`The plugin isn't recording sessions. Flip the switch to turn it back on.`]}):(0,_.jsx)(s,{label:`Reading live sessions…`})]})}export{y as default};
|