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.
Files changed (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/SHIPPED.md +1 -1
  3. package/docs/extending.md +1 -1
  4. package/docs/web-ui.md +17 -3
  5. package/harness.config.json +1 -1
  6. package/js/README.md +1 -1
  7. package/js/web/actions.mjs +61 -16
  8. package/js/web/api.mjs +12 -1
  9. package/js/web/catalog.mjs +3 -0
  10. package/js/web/handler.mjs +83 -30
  11. package/js/web/jobs.mjs +7 -7
  12. package/js/web/server.mjs +4 -0
  13. package/js/web/user-files.mjs +24 -7
  14. package/package.json +1 -1
  15. package/web/dist/assets/{Activity-DNVzYkIY.js → Activity-BvbzB-H1.js} +1 -1
  16. package/web/dist/assets/{ActivityDetail-CMBXjmTo.js → ActivityDetail-CSwA3UmY.js} +1 -1
  17. package/web/dist/assets/{Dashboard-DFY_FarE.js → Dashboard-DAN3juLM.js} +2 -2
  18. package/web/dist/assets/{Docs-BS0NIsA0.js → Docs-BQ2pEV8F.js} +1 -1
  19. package/web/dist/assets/{GreenhouseView-M3wWndqG.js → GreenhouseView-BoJyzeuf.js} +1 -1
  20. package/web/dist/assets/{Installs-CvRxZcZc.js → Installs-3fAay1NE.js} +3 -3
  21. package/web/dist/assets/{JournalView-BSD-1Z83.js → JournalView-Ck5c_3Md.js} +1 -1
  22. package/web/dist/assets/{KitApply-_4Ug9pMF.js → KitApply-CsnEjQi6.js} +1 -1
  23. package/web/dist/assets/{Kits-c5mVesJ9.js → Kits-Bos4VAWe.js} +1 -1
  24. package/web/dist/assets/{Laws-Bpn4ZBzL.js → Laws-r1vX_zU5.js} +1 -1
  25. package/web/dist/assets/{Library-DWgfAfkV.js → Library-DmFLcNYl.js} +1 -1
  26. package/web/dist/assets/{Loops-BqP3c0c-.js → Loops-DhRt563u.js} +1 -1
  27. package/web/dist/assets/{Markdown-zaXXXHz9.js → Markdown-unTJNwOC.js} +3 -3
  28. package/web/dist/assets/{OperatorHudView-DLEGd63S.js → OperatorHudView-DIhkL4qf.js} +1 -1
  29. package/web/dist/assets/Personal-BxtZyYl_.js +1 -0
  30. package/web/dist/assets/{Tabs-RdL1pfIT.js → Tabs-CFVpjPm7.js} +1 -1
  31. package/web/dist/assets/{drawable-inwWR3uw.js → drawable-D2dpNTE8.js} +1 -1
  32. package/web/dist/assets/{index-BAArp8rX.js → index-D-cBvfU2.js} +2 -2
  33. package/web/dist/index.html +1 -1
  34. 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.1**.
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` — 35 rows, each a one-string edit to a
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) and trial chips, add/edit/retire forms, a
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, uninstall), an offline package download, server controls (Stop), and the
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
 
@@ -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.1"
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 guards and body cap (before the body is read), static SPA fallback |
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 |
@@ -27,7 +27,8 @@
27
27
  */
28
28
  import { spawn } from 'node:child_process';
29
29
  import {
30
- accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync,
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
- * expected present -> overwrite/create the deployed copy; expected absent but deployed present
512
- * (an 'added' file) -> delete it; neither -> an error and nothing touched.
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 (isFile(src)) {
572
- mkdirSync(path.dirname(dst), { recursive: true });
573
- // A BYTE copy, so the restored file keeps the render's own line endings rather than
574
- // the platform's (the `writeText`/`copy2` rule, one file over).
575
- copyFileSync(src, dst);
576
- restored.push(rel);
577
- } else if (isFile(dst)) {
578
- unlinkSync(dst);
579
- deleted.push(rel);
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
- errors.push(`${rel}: not in the source render nor deployed`);
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.runDoctor();
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
  }
@@ -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
 
@@ -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, a CSRF token on POST only, and a Host guard against
10
- * DNS rebinding. No streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
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 colon, so
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
- // GUARDS FIRST, BODY SECOND. The Host and token checks need no body, so they answer
367
- // before one is read — a refused request is DISCARDED (`resume`, nothing buffered), never
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`, `uninstall`, `update`,
48
- * `link` and `unlink` each name a verb `bin/geneseed-cli.mjs` runs, plus the three
49
- * `INLINE_ACTIONS` the dispatcher answers without the table. `tests/unit/web_jobs.test.mjs`
50
- * holds the union against a written-out list, so a ninth action cannot appear unchecked.
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 keeps memory (never
458
- // deleted) and runs non-interactively with --yes.
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', () => {
@@ -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].startsWith('## ')) { end = j; break; }
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 Error('a rule needs a title');
192
- if (!text) throw new Error('a rule needs body text');
193
- let scope = bget(body, 'scope');
194
- if (scope !== 'user' && scope !== 'project') scope = 'project';
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 Error('trial until must be YYYY-MM-DD');
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.1",
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-BAArp8rX.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};
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};