geneseed 4.9.0 → 4.9.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 (68) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +7 -1
  4. package/adapters/opencode/plugins/geneseed-guard.js +139 -27
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/hooks.md +1 -1
  7. package/docs/web-ui.md +3 -2
  8. package/harness.config.json +1 -1
  9. package/js/README.md +30 -9
  10. package/js/build/args.mjs +9 -8
  11. package/js/build/driver.mjs +21 -21
  12. package/js/build/emit-claude.mjs +3 -3
  13. package/js/build/generate.mjs +4 -5
  14. package/js/build/source.mjs +18 -22
  15. package/js/hosts/gitref.mjs +5 -3
  16. package/js/hosts/hooks-done.mjs +21 -9
  17. package/js/hosts/hooks-learn.mjs +23 -10
  18. package/js/hosts/hooks-prims.mjs +25 -0
  19. package/js/hosts/hooks.mjs +17 -8
  20. package/js/hosts/installs.mjs +5 -4
  21. package/js/hosts/loss.mjs +124 -18
  22. package/js/hosts/native.mjs +3 -1
  23. package/js/hosts/owned.mjs +34 -6
  24. package/js/hosts/settings.mjs +5 -4
  25. package/js/inspect/cost.mjs +37 -5
  26. package/js/inspect/diff.mjs +132 -61
  27. package/js/inspect/doctor.mjs +4 -3
  28. package/js/inspect/validate.mjs +2 -2
  29. package/js/lib/fs.mjs +73 -2
  30. package/js/maintain/setup.mjs +31 -7
  31. package/js/maintain/uninstall.mjs +1 -1
  32. package/js/maintain/update.mjs +1 -1
  33. package/js/web/actions.mjs +38 -4
  34. package/js/web/api.mjs +21 -13
  35. package/js/web/handler.mjs +5 -17
  36. package/js/web/jobs.mjs +5 -3
  37. package/js/web/routes.mjs +3 -1
  38. package/js/web/server.mjs +3 -1
  39. package/js/web/state.mjs +35 -8
  40. package/package.json +1 -1
  41. package/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-D7Igwuoo.js} +1 -1
  42. package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-53rp05hx.js} +1 -1
  43. package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
  44. package/web/dist/assets/{Docs-DUxizutI.js → Docs-BM56RCT8.js} +3 -3
  45. package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
  46. package/web/dist/assets/Installs-Dh1tB1NK.js +2 -0
  47. package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
  48. package/web/dist/assets/Laws-iEheocNI.js +3 -0
  49. package/web/dist/assets/Library-d4_uOGmC.js +1 -0
  50. package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BbYeWl70.js} +2 -2
  51. package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-Dki9Q6W3.js} +1 -1
  52. package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
  53. package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-CAl6oFwG.js} +1 -1
  54. package/web/dist/assets/api-CiXlkbe0.js +1 -0
  55. package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
  56. package/web/dist/assets/index-BZgEGRbL.js +2 -0
  57. package/web/dist/index.html +2 -2
  58. package/web/src/pages/Laws.jsx +60 -353
  59. package/web/dist/assets/Dashboard-COLskkOJ.js +0 -2
  60. package/web/dist/assets/GreenhouseView-BSzVX_l7.js +0 -2
  61. package/web/dist/assets/Installs-Ck4YyEzs.js +0 -2
  62. package/web/dist/assets/JournalView-BbbACsYK.js +0 -1
  63. package/web/dist/assets/Laws-BZ_M86r3.js +0 -3
  64. package/web/dist/assets/Library-D_4LbUg2.js +0 -1
  65. package/web/dist/assets/Personal-C66284wd.js +0 -1
  66. package/web/dist/assets/api-Bp5eW_6m.js +0 -1
  67. package/web/dist/assets/drawable-DlrXDOJ7.js +0 -1
  68. package/web/dist/assets/index-DJorAjeH.js +0 -2
@@ -25,9 +25,10 @@
25
25
  * that this tool never shells back to the generator — is asserted directly instead.
26
26
  *
27
27
  * WHAT `doctorCollect` DOES NOT REACH, MEASURED RATHER THAN ASSUMED. It takes `groups` and
28
- * `cmdDoctor` does not pass it: that parameter is the WEB's, and `apiDoctor` in
29
- * `js/web/api.mjs` is its only caller. `ran` exists for it — four lines of accumulator that
30
- * keep the Doctor page's per-check cards a parameter rather than a rewrite.
28
+ * `cmdDoctor` does not pass it: that parameter is the WEB's, and `runDoctor` in
29
+ * `js/web/state.mjs` (the cache `apiDoctor` serves) is its only caller. `ran` exists for it —
30
+ * four lines of accumulator that keep the Doctor page's per-check cards a parameter rather
31
+ * than a rewrite.
31
32
  */
32
33
  import path from 'node:path';
33
34
  import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
@@ -130,8 +130,8 @@ export function cmdValidate(args) {
130
130
  const opts = {
131
131
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
132
132
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
133
- excludeSkills: args.excludeSkills, deletionGate: args.noDeletionGate ? false : null,
134
- secretsGate: args.noSecretsGate ? false : null, doneGate: args.noDoneGate ? false : null,
133
+ excludeSkills: args.excludeSkills,
134
+ ...Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, args[g.arg] ? false : null])),
135
135
  };
136
136
  let scanDirs;
137
137
  try {
package/js/lib/fs.mjs CHANGED
@@ -29,8 +29,32 @@
29
29
  */
30
30
  import {
31
31
  appendFileSync, writeFileSync, readFileSync, copyFileSync, statSync, utimesSync,
32
+ openSync, fstatSync, readSync, closeSync,
32
33
  } from 'node:fs';
33
34
  import { EOL } from 'node:os';
35
+ import { resolve } from 'node:path';
36
+
37
+ /**
38
+ * What `writeText` wrote while an emit has armed this (`withWriteRecord` below, around the
39
+ * three manifest-writing emits in `js/build/driver.mjs`): resolved path ->
40
+ * `{ text, size, mtimeMs }`, the translated text and a stat taken right after the write.
41
+ * `ownedHashes` (`js/hosts/owned.mjs`) hashes that text instead of reading the file back —
42
+ * the read-back was ~25% of `doctor --all` — and only while the file's size and mtime still
43
+ * match, so a later rewrite by any writer that does not record (a raw `writeFileSync`, a
44
+ * rename) falls back to the read. `copyFile` and `appendText` drop the entry outright.
45
+ * `null`, the default, records nothing: no other caller pays the stat or retains the text.
46
+ */
47
+ export const textWrites = { map: null };
48
+
49
+ /**
50
+ * Run one emit with `textWrites` armed, and disarm it on EVERY exit: an emit that throws inside
51
+ * a long-lived process (the web daemon emits in-process) would otherwise leave every later
52
+ * `writeText` paying a stat and keeping its text in memory.
53
+ */
54
+ export function withWriteRecord(fn) {
55
+ textWrites.map = new Map();
56
+ try { return fn(); } finally { textWrites.map = null; }
57
+ }
34
58
  /**
35
59
  * `Path.write_text(text, encoding="utf-8")`.
36
60
  *
@@ -49,7 +73,12 @@ import { EOL } from 'node:os';
49
73
  * The same vendored skill folder can contain files from both categories.
50
74
  */
51
75
  export function writeText(path, text) {
52
- writeFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
76
+ const out = EOL === '\n' ? text : text.replaceAll('\n', EOL);
77
+ writeFileSync(path, out, 'utf8');
78
+ if (textWrites.map) {
79
+ const st = statSync(path);
80
+ textWrites.map.set(resolve(path), { text: out, size: st.size, mtimeMs: st.mtimeMs });
81
+ }
53
82
  }
54
83
 
55
84
  /**
@@ -62,6 +91,7 @@ export function writeText(path, text) {
62
91
  * invisible in an editor and a whole-file difference to the gate.
63
92
  */
64
93
  export function appendText(path, text) {
94
+ textWrites.map?.delete(resolve(path));
65
95
  appendFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
66
96
  }
67
97
 
@@ -79,6 +109,46 @@ export function readText(path) {
79
109
  return readFileSync(path, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
80
110
  }
81
111
 
112
+ /**
113
+ * The last `bytes` of a file, decoded as `readText` decodes it, and the byte offset the window
114
+ * began at (0: the whole file). A window that starts mid-line drops that partial line, cut at a
115
+ * newline BYTE before decoding — so a multibyte character the window split falls in the dropped
116
+ * part, and a `\r\n` split between its two bytes leaves only an empty line. One byte before the
117
+ * window is read too, so a window that starts exactly on a line keeps it. Throws as
118
+ * `readFileSync` does (missing, unreadable, a directory).
119
+ */
120
+ export function readTail(path, bytes) {
121
+ const fd = openSync(path, 'r');
122
+ try {
123
+ const st = fstatSync(fd);
124
+ // Windows opens a directory without complaint and reports size 0: refuse it as readFileSync does.
125
+ if (st.isDirectory()) {
126
+ throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${path}'`),
127
+ { code: 'EISDIR' });
128
+ }
129
+ const size = st.size;
130
+ const start = Math.max(0, size - bytes);
131
+ const from = start > 0 ? start - 1 : 0;
132
+ const buf = Buffer.alloc(size - from);
133
+ let got = 0;
134
+ while (got < buf.length) {
135
+ const n = readSync(fd, buf, got, buf.length - got, from + got);
136
+ if (!n) break;
137
+ got += n;
138
+ }
139
+ let cut = 0;
140
+ if (start > 0) {
141
+ const read = buf.subarray(0, got);
142
+ const ends = [read.indexOf(0x0a), read.indexOf(0x0d)].filter((i) => i !== -1);
143
+ cut = ends.length ? Math.min(...ends) + 1 : got; // no line end: nothing whole to decode
144
+ }
145
+ const text = buf.toString('utf8', cut, got).replaceAll('\r\n', '\n').replaceAll('\r', '\n');
146
+ return { text, start };
147
+ } finally {
148
+ closeSync(fd);
149
+ }
150
+ }
151
+
82
152
  /**
83
153
  * `shutil.copy2(src, dest)`.
84
154
  *
@@ -94,6 +164,7 @@ export function readText(path) {
94
164
  * mode the Python side has and nothing depends on. Mode is not copied here.
95
165
  */
96
166
  export function copyFile(src, dest) {
167
+ textWrites.map?.delete(resolve(dest));
97
168
  copyFileSync(src, dest);
98
169
  const st = statSync(src);
99
170
  utimesSync(dest, st.atime, st.mtime);
@@ -216,7 +287,7 @@ export function withPlatformNewlines(fn) {
216
287
  */
217
288
  const NOT_THERE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP', 'EBADF']);
218
289
 
219
- function statOrNull(p) {
290
+ export function statOrNull(p) {
220
291
  try {
221
292
  return statSync(p);
222
293
  } catch (e) {
@@ -46,13 +46,13 @@ import path from 'node:path';
46
46
 
47
47
  import { main as driverMain, resolveOut } from '../build/driver.mjs';
48
48
  import { playLine } from '../ui/anim.mjs';
49
- import { discoverNames, PACK_ORDER } from '../build/source.mjs';
49
+ import { discoverNames, GATE_SWITCHES, PACK_ORDER } from '../build/source.mjs';
50
50
  import { exportImprovements } from '../inspect/diff.mjs';
51
51
  import { cmdDoctor } from '../inspect/doctor.mjs';
52
52
  import { setupBuildArgs } from '../build/generate.mjs';
53
53
  import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir } from '../hosts/hosts.mjs';
54
54
  import {
55
- claudeCfg, defaultMode, defaultPosture, defaultTheme, EMIT_HOST_SCOPE, installedDefaults,
55
+ claudeCfg, defaultMode, defaultPosture, defaultTheme, EMIT_HOST_SCOPE, gatesOfDir, installedDefaults,
56
56
  readJsonMaybe, themeFiles,
57
57
  } from '../hosts/installs.mjs';
58
58
  import { printOut, printErr } from '../lib/fs.mjs';
@@ -242,6 +242,24 @@ function askDoctrines(deployed = null) {
242
242
  * whole, `null` included, because `askDoctrines` distinguishes "no carrier said" from "a
243
243
  * carrier said none" and the two open the question differently.
244
244
  */
245
+ /**
246
+ * The gate switches of the one install a setup answer re-runs: the chosen emit's global config
247
+ * dir, or the project/bundle root it names. Never another host's install — carrying its `off`
248
+ * across would disarm a gate nobody switched off here. FAILS CLOSED: no install there, or a dir
249
+ * that cannot be resolved, reads as all armed (`gatesOfDir` already fails closed per line).
250
+ */
251
+ function targetGates(emit, out) {
252
+ const [host, scope] = EMIT_HOST_SCOPE.get(emit) ?? [];
253
+ try {
254
+ if (!host) return gatesOfDir(resolveOut(out || 'Harness'));
255
+ const dir = scope === 'global'
256
+ ? HOSTS.find((h) => h.host === host).configDir() : resolveOut(out || '.');
257
+ return gatesOfDir(dir, host);
258
+ } catch {
259
+ return Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, true]));
260
+ }
261
+ }
262
+
245
263
  export function collectSetupLines() {
246
264
  printOut('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
247
265
  const inst = installedDefaults();
@@ -279,14 +297,20 @@ export function collectSetupLines() {
279
297
  const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
280
298
  // Skills left out are preserved the same way, and elide when there are none.
281
299
  const excludeSkills = inst.excludeSkills?.length ? inst.excludeSkills : null;
300
+ // The gate switches too: a re-run that dropped them would re-arm every gate its owner switched
301
+ // off. Read off the install THIS answer targets, never the machine walk above (`targetGates`).
302
+ // `setupBuildArgs` elides an armed gate, so an all-armed install prints what it always did.
303
+ const gates = targetGates(emit, out);
304
+ const gated = Object.values(gates).some((on) => on === false);
282
305
  printOut(`\nAbout to run: geneseed build ${
283
306
  setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
284
- excludeRules, undefined, excludeSkills).join(' ')}\n`);
307
+ excludeRules, undefined, excludeSkills, gates).join(' ')}\n`);
285
308
  if (!confirm('Proceed?', true)) return null;
286
- // The key only when there is something to carry, so a selection that excludes no skill is
287
- // the same object it was before the flag existed.
309
+ // The key only when there is something to carry, so a selection that excludes no skill and
310
+ // switches off no gate is the same object it was before either existed.
288
311
  return {
289
312
  theme, posture, mode, doctrines, excludeRules, ...(excludeSkills ? { excludeSkills } : {}),
313
+ ...(gated ? { gates } : {}),
290
314
  emit, out, root, footprint,
291
315
  };
292
316
  }
@@ -470,7 +494,7 @@ export function setupLines() {
470
494
  }
471
495
  const {
472
496
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
473
- doctrines = null, excludeRules = null, excludeSkills = null,
497
+ doctrines = null, excludeRules = null, excludeSkills = null, gates = {},
474
498
  } = sel;
475
499
  if (emit === 'opencode-global') {
476
500
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -489,7 +513,7 @@ export function setupLines() {
489
513
  // runs must carry the pack selection the plan line just previewed, or the wizard would print
490
514
  // one command and run another.
491
515
  const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
492
- doctrineOptions().map(([n]) => n), excludeRules, undefined, excludeSkills);
516
+ doctrineOptions().map(([n]) => n), excludeRules, undefined, excludeSkills, gates);
493
517
  printOut(`Running: geneseed build ${argv.join(' ')}\n`);
494
518
  const rc = driverMain(argv);
495
519
  if (rc !== 0) {
@@ -1179,7 +1179,7 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1179
1179
  const gates = Object.fromEntries(Object.keys(atRoot).map((k) => [k, atRoot[k] && atCfg[k]]));
1180
1180
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1181
1181
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg,
1182
- gates.deletionGate, gates);
1182
+ gates);
1183
1183
  // `and data` — a manifest that did not parse is not one to write back.
1184
1184
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1185
1185
  managed.settings_hooks = claims;
@@ -632,7 +632,7 @@ export async function installAxes(dir, { installProfile, setupBuildArgs }, notif
632
632
  if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
633
633
  const p = installProfile('opencode', 'global', String(dir), notify);
634
634
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
635
- undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate, p).slice(4);
635
+ undefined, p.excludeRules, p.trust, p.excludeSkills, p).slice(4);
636
636
  }
637
637
 
638
638
  /**
@@ -38,7 +38,7 @@ import {
38
38
  } from '../build/source.mjs';
39
39
  import { withStdoutSwallowed } from '../inspect/diff.mjs';
40
40
  import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
- import { setupBuildArgs } from '../build/generate.mjs';
41
+ import { installProfile, setupBuildArgs } from '../build/generate.mjs';
42
42
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
43
43
  import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
44
44
  import {
@@ -61,6 +61,7 @@ import { stripWhitespace } from '../lib/text.mjs';
61
61
  import { memoryDelete } from '../maintain/memory.mjs';
62
62
  import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
63
63
  import { emitChoices, themeChoices, viewCfg } from './api.mjs';
64
+ import { actionCommands } from './jobs.mjs';
64
65
  import { NotFound, deployed, memoryDir } from './catalog.mjs';
65
66
 
66
67
  /** One body field, `dflt` when the body is not an object or does not carry the key. */
@@ -307,7 +308,8 @@ export function apiMemoryDelete(state, name) {
307
308
  // CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
308
309
  // resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
309
310
  if (memoryDelete(d, name) === null) throw new NotFound(name);
310
- state.refresh();
311
+ // The doctor reads nothing in the memory store, so its cached verdict survives (`refresh`).
312
+ state.refresh({ doctor: false });
311
313
  return { archived: name };
312
314
  }
313
315
 
@@ -438,6 +440,38 @@ export function buildOverride(state, body) {
438
440
  return [themes.has(t) ? t : state.theme, emits.has(e) ? e : state.emit];
439
441
  }
440
442
 
443
+ /**
444
+ * The job steps for a Build POST.
445
+ *
446
+ * A DEPLOYED INSTALL REBUILT IN ITS OWN EMIT TAKES THE ARGV ITS PROFILE OWNS (`installProfile`,
447
+ * the one reader `rebuild-all`, `status` and `upgrade` share), with only the theme the picker
448
+ * chose swapped in. This path used to re-assemble the axes it happened to carry — no gate
449
+ * switches, `--exclude-rules none`, no excluded skills — so a console Build silently re-armed
450
+ * every switched-off gate and handed back every rule and skill its owner had left out.
451
+ *
452
+ * Nothing deployed (the first-deploy Onboarding form) or a re-target to another emit has no
453
+ * install of that emit to honour — a re-target is a FRESH target, by decision (2026-10-18), so it
454
+ * deliberately carries none of this install's switches or exclusions — and keeps the defaults it
455
+ * always had: footprint, posture
456
+ * and mode off the viewed dir, and ALL packs when unknown — never `harness.config.json`, which
457
+ * would drop the consent gate.
458
+ */
459
+ export function buildActionCmds(state, body) {
460
+ const [theme, emit] = buildOverride(state, body);
461
+ const [host, scope] = EMIT_HOST_SCOPE.get(state.emit || '') ?? [];
462
+ if (host && deployed(state) && emit === state.emit) {
463
+ // `setupBuildArgs` always leads with `--theme <theme>`, so the swap is the head alone.
464
+ // `state.root`, not `target`: a project install's markers live at the repo root.
465
+ const [, , ...rest] = installProfile(host, scope, state.root).argv;
466
+ return actionCommands('build', { buildArgv: ['--theme', theme, ...rest] });
467
+ }
468
+ return actionCommands('build', {
469
+ theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
470
+ trust: state.trust,
471
+ doctrines: doctrinesForBuild(state.target),
472
+ });
473
+ }
474
+
441
475
  /**
442
476
  * The global emit matching a deployed install's `.geneseed-emit`, so the EXPECTED render
443
477
  * uses the install's own host dialect.
@@ -594,7 +628,7 @@ export function apiInstallCmd(state, body) {
594
628
  const gates = bodyGates(body, root, host);
595
629
  const out = scope === 'global' ? null : String(root);
596
630
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
597
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
631
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates);
598
632
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
599
633
  }
600
634
 
@@ -696,6 +730,6 @@ export function apiDeployCmd(state, body) {
696
730
  const gates = bodyGates(body, root, host);
697
731
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
698
732
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
699
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
733
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates);
700
734
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
701
735
  }
package/js/web/api.mjs CHANGED
@@ -15,8 +15,7 @@ import { CONFIG, ROOT, THEMES, discoverNames, makeCfg } from '../build/source.mj
15
15
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
16
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
17
  import { alwaysOnCost } from '../inspect/cost.mjs';
18
- import { diffCollect } from '../inspect/diff.mjs';
19
- import { doctorCollect } from '../inspect/doctor.mjs';
18
+ import { diffCollect, renderMemo } from '../inspect/diff.mjs';
20
19
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
20
  import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
22
21
  import {
@@ -79,13 +78,15 @@ export function apiSetup(state) {
79
78
  };
80
79
  }
81
80
 
82
- /** The same engine as the `doctor` verb, grouped per check. */
83
- export function apiDoctor(state) {
84
- const groups = [];
85
- const [themes, problems] = doctorCollect({ theme: state.theme, groups });
86
- state.stampDoctor(problems);
87
- return { themes, ok: !problems.length, problems, groups,
88
- checked_at: state.doctor.checked_at };
81
+ /**
82
+ * The same engine as the `doctor` verb, grouped per check — served from the state's cached run
83
+ * (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
84
+ * for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
85
+ */
86
+ export function apiDoctor(state, fresh = false) {
87
+ if (fresh) state.runDoctor();
88
+ const { ok, problems, checked_at } = state.doctor;
89
+ return { ...state._doctorRun, ok, problems, checked_at };
89
90
  }
90
91
 
91
92
  /**
@@ -129,6 +130,12 @@ export function* activeInstalls(targets) {
129
130
  }
130
131
  }
131
132
 
133
+ /**
134
+ * The state's memo of `expected` renders, made on first use: overview and fleet diff the same
135
+ * install on every refresh, and only the compare has to run again (see `renderMemo`).
136
+ */
137
+ const memoOf = (state) => (state.renderMemo ??= renderMemo());
138
+
132
139
  /** The edited/added/missing tally of a `diffCollect` file list. */
133
140
  const driftCounts = (files) => ({
134
141
  edited: files.filter((f) => f.status === 'edited').length,
@@ -178,8 +185,9 @@ export function apiInstalls(state) {
178
185
  *
179
186
  * Its own route, not more fields on `/api/installs`: the sidebar refetches that list on every
180
187
  * data revision, and the drift check below re-renders a whole emit per global install.
181
- * ponytail: one `diffCollect` render per global install per request, fine for the handful a
182
- * machine carries; cache per (root, build mtime) if a fleet ever grows past that.
188
+ * Each global install's `expected` render is memoised on the state (`renderMemo`, keyed on the
189
+ * source fingerprint computed once per request and every axis), so a refresh re-runs only the
190
+ * compare; the memo holds a handful of installs, enough for any machine seen so far.
183
191
  *
184
192
  * Each row reads ITS OWN `.geneseed-version`. `statusData().installed_fp` is the first carrier
185
193
  * a fixed walk finds, which describes the wrong install as soon as two hosts are deployed.
@@ -196,7 +204,7 @@ export function apiFleet(state) {
196
204
  let drift = null;
197
205
  if (st === 'active') {
198
206
  try {
199
- const { files } = diffCollect({ target: dir });
207
+ const { files } = diffCollect({ target: dir, memo: memoOf(state), sourceFp });
200
208
  if (files !== null) drift = driftCounts(files);
201
209
  } catch { /* an install that cannot be re-rendered reports no drift figure, not zero */ }
202
210
  }
@@ -225,7 +233,7 @@ export function apiOverview(state) {
225
233
  let diff = null;
226
234
  if (deployed(state)) {
227
235
  const { files } = diffCollect({
228
- target: state.target, theme: state.theme, emit: state.emit });
236
+ target: state.target, theme: state.theme, emit: state.emit, memo: memoOf(state) });
229
237
  if (files !== null) diff = driftCounts(files);
230
238
  }
231
239
  let buildTime = null;
@@ -11,11 +11,10 @@
11
11
  * declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
12
12
  */
13
13
  import { resolvePath } from '../hosts/hosts.mjs';
14
- import { doctrinesForBuild } from '../hosts/installs.mjs';
15
14
  import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
16
15
  import { parseIntStrict, percentDecode } from '../lib/text.mjs';
17
16
  import { preflight } from '../maintain/update.mjs';
18
- import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
17
+ import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildActionCmds, mcpTargetPaths } from './actions.mjs';
19
18
  import { NotFound } from './catalog.mjs';
20
19
  import { openUrl, requestRestart } from './daemon.mjs';
21
20
  import { apiDocs, apiDocsPage } from './docs.mjs';
@@ -130,7 +129,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
130
129
 
131
130
  function doGet(req, res, path, ae) {
132
131
  const route = STATE_ROUTES[path];
133
- if (route !== undefined) return sendJson(res, route(state), 200, ae);
132
+ if (route !== undefined) return sendJson(res, route(state, req.url), 200, ae);
134
133
  // `pid` is what a restarting client waits on: the OLD server keeps answering this ping for
135
134
  // a moment after `/api/restart` returns, and a reload onto it fetches the old token.
136
135
  if (path === '/api/ping') {
@@ -301,20 +300,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
301
300
  if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
302
301
  return sendJson(res, { job_id: jid }, 202, ae);
303
302
  }
304
- // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
305
- // the deployed theme downstream. Footprint, posture and mode always follow the current
306
- // install, so a re-theme preserves lean/full, the register and the operating mode.
307
- const [theme, emit] = action === 'build'
308
- ? buildOverride(state, body) : [state.theme, state.emit];
309
- // The pack selection follows the install like footprint/posture/mode, and is read here
310
- // rather than left to the table's default: a console Build must re-emit the constitution
311
- // the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
312
- // packs — never to `harness.config.json`, which would drop the consent gate.
313
- const cmds = actionCommands(action, {
314
- theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
315
- trust: state.trust,
316
- doctrines: doctrinesForBuild(state.target),
317
- });
303
+ // Build resolves its argv off the install (`buildActionCmds`); the other actions take no
304
+ // axes and self-resolve the deployed theme downstream.
305
+ const cmds = action === 'build' ? buildActionCmds(state, body) : actionCommands(action);
318
306
  if (!isTruthy(cmds)) {
319
307
  return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
320
308
  }
package/js/web/jobs.mjs CHANGED
@@ -421,14 +421,16 @@ export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
421
421
  * `harness.config.json` exactly as the pack list does, so `{"excludeRules":["process.5"]}`
422
422
  * there would take the consent gate out of a console Build that never asked for it. `[]` is
423
423
  * spelled `--exclude-rules none` and says "exclude nothing" out loud.
424
+ *
425
+ * `buildArgv`, when given, is used whole and the axes above are ignored: it is the argv a
426
+ * deployed install's profile owns (`buildActionCmds`), switches and exclusions included.
424
427
  */
425
428
  function actionTable({
426
429
  theme = 'neutral', emit = 'opencode-global', footprint = 'full',
427
430
  posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER], excludeRules = [],
428
- trust = DEFAULT_PRESET,
431
+ trust = DEFAULT_PRESET, buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture,
432
+ mode, doctrines, PACK_ORDER, excludeRules, trust),
429
433
  } = {}) {
430
- const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode,
431
- doctrines, PACK_ORDER, excludeRules, trust);
432
434
  return {
433
435
  doctor: [[NODE(), CLI(), 'doctor']],
434
436
  build: [[NODE(), GEN(), ...buildArgv]],
package/js/web/routes.mjs CHANGED
@@ -146,7 +146,9 @@ export const STATE_ROUTES = {
146
146
  '/api/recent': apiRecent,
147
147
  '/api/themes': apiThemes,
148
148
  '/api/setup': apiSetup,
149
- '/api/doctor': apiDoctor,
149
+ // The one route here that reads its query: `?fresh=1` re-runs instead of serving the cache.
150
+ '/api/doctor': (state, url) => apiDoctor(state,
151
+ new URL(url || '', 'http://x').searchParams.has('fresh')),
150
152
  '/api/installs': apiInstalls,
151
153
  '/api/fleet': apiFleet,
152
154
  '/api/update': apiUpdate,
package/js/web/server.mjs CHANGED
@@ -27,7 +27,7 @@ import { isFile, printOut } from '../lib/fs.mjs';
27
27
  import { which } from '../lib/paths.mjs';
28
28
  import { parseIntStrict } from '../lib/text.mjs';
29
29
  import { promptLine } from '../lib/prompt.mjs';
30
- import { webState } from './state.mjs';
30
+ import { warmDoctor, webState } from './state.mjs';
31
31
  import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
32
32
  import { makeHandler } from './handler.mjs';
33
33
  import { JobManager } from './jobs.mjs';
@@ -165,6 +165,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
165
165
  printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
166
166
  printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
167
167
  if (openBrowser) openUrl(url);
168
+ // After the record and the ready line, so `web start`'s wait loop already has its URL.
169
+ warmDoctor(state);
168
170
  // Node kills on SIGINT by default, which would skip this message and the record
169
171
  // cleanup in `srv.on('close', ...)` below — a foreground `--daemon-internal` server
170
172
  // stopped with Ctrl-C would otherwise leave a record pointing at a dead port.
package/js/web/state.mjs CHANGED
@@ -40,7 +40,10 @@ export function stampMinute(ms) {
40
40
  * THE CACHING IS BEHAVIOUR, NOT AN OPTIMISATION: `doctorCollect` renders a theme per call
41
41
  * and the console GETs `/api/overview` on mount and again after every mutation, so an
42
42
  * uncached `doctor` would put a full build on each of those requests. `refresh()` is what a mutation calls to
43
- * drop both.
43
+ * drop both — except where the mutation cannot change anything the doctor reads (the checkout,
44
+ * the hook shim, the hook shell, the OpenCode config dirs, the deployed install): a memory
45
+ * delete passes `{ doctor: false }` and keeps the ~3.6 s verdict. Rule and profile writes do not
46
+ * call `refresh()` at all. Every other caller keeps the default, so a new one is slow, never stale.
44
47
  */
45
48
  export function webState(theme = null, target = null) {
46
49
  const st = {
@@ -56,6 +59,13 @@ export function webState(theme = null, target = null) {
56
59
  trust: null,
57
60
  _inv: null,
58
61
  _doctor: null,
62
+ // The Doctor page's per-check cards and theme list, from the SAME run as `_doctor` — kept
63
+ // beside it rather than inside so `/api/overview` (fetched after every mutation) does not
64
+ // carry every group.
65
+ _doctorRun: null,
66
+ // `diffCollect`'s memo of `expected` renders, made on first use by `api.mjs` (`memoOf`);
67
+ // it re-renders on its own key change, so `refresh()` leaves it alone.
68
+ renderMemo: null,
59
69
  };
60
70
  // The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob/openclaude
61
71
  // PROJECT install keeps its data under <repo>/.claude|.bob|.openclaude while the markers
@@ -87,15 +97,19 @@ export function webState(theme = null, target = null) {
87
97
  });
88
98
  Object.defineProperty(st, 'doctor', {
89
99
  get() {
90
- if (st._doctor === null) {
91
- const [, problems] = doctorCollect({ theme: st.theme });
92
- st.stampDoctor(problems);
93
- }
100
+ if (st._doctor === null) st.runDoctor();
94
101
  return st._doctor;
95
102
  },
96
103
  });
97
- st.stampDoctor = (problems) => {
104
+ /** One fresh doctor run, cached whole: the verdict and the grouped detail of the same run. */
105
+ st.runDoctor = () => {
106
+ const groups = [];
107
+ const [themes, problems] = doctorCollect({ theme: st.theme, groups });
108
+ st.stampDoctor(problems, themes, groups);
109
+ };
110
+ st.stampDoctor = (problems, themes = [], groups = []) => {
98
111
  st._doctor = { ok: !problems.length, problems, checked_at: stampMinute(Date.now()) };
112
+ st._doctorRun = { themes, groups };
99
113
  };
100
114
  /**
101
115
  * The CURRENT install's mode, from its `.geneseed-emit` marker: the ROOT first (where
@@ -139,9 +153,9 @@ export function webState(theme = null, target = null) {
139
153
  st._inv = null;
140
154
  st._doctor = null;
141
155
  };
142
- st.refresh = () => {
156
+ st.refresh = ({ doctor = true } = {}) => {
143
157
  st._inv = null;
144
- st._doctor = null;
158
+ if (doctor) st._doctor = null;
145
159
  st.emit = st.detectEmit() || st.emit;
146
160
  const host = hostOf();
147
161
  st.theme = themeOfDir(st.root, host) || themeOfDir(st.target, host) || st.theme;
@@ -149,3 +163,16 @@ export function webState(theme = null, target = null) {
149
163
  };
150
164
  return st;
151
165
  }
166
+
167
+ /**
168
+ * Run the doctor once in the background, right after the server binds, so the first
169
+ * `/api/overview` finds a verdict instead of paying the ~3.6 s itself. Still synchronous once it
170
+ * starts — the event loop is blocked for that run — so a request landing mid-run waits for it,
171
+ * no longer than it would have. The catch is not optional: a throw inside `setImmediate` is
172
+ * uncaught and would kill the daemon; a failed warm-up just leaves the cache cold.
173
+ */
174
+ export function warmDoctor(st) {
175
+ setImmediate(() => {
176
+ try { if (st._doctor === null) st.runDoctor(); } catch { /* cold cache, computed on demand */ }
177
+ });
178
+ }
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.9.0",
4
+ "version": "4.9.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{s as e}from"./sections-BET3c1WT.js";import{i as t,t as n}from"./react-BOF6yL40.js";import{t as r}from"./api-Bp5eW_6m.js";import{A as i,F as a,Y as o,b as s,v as c}from"./index-DJorAjeH.js";import{a as l,i as u,n as d,o as f,r as p,t as m}from"./activity-DvPxoS4Y.js";import{t as h}from"./usePoll-NSTzLOF4.js";var g=e(t(),1),_=n();function v({s:e}){let t=p[e.status]||p.idle,n=e.files,r=e.todos,o=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)(i,{name:`chevron`,className:`glyph act-chev`})]})]}),e.phase&&(0,_.jsxs)(`div`,{className:`row gap-8`,style:{marginTop:10,fontSize:14},children:[(0,_.jsx)(i,{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]})]})]}),o&&(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 `,a(e.updated_at),` ago`]})]})}function y(){let[e,t]=(0,g.useState)(null),[n,i]=(0,g.useState)(``);h(e=>r.activity().then(n=>{e()&&(t(n),i(``))},t=>{e()&&i(t.message)}),2e3,[]);let a=!e||e.enabled!==!1,l=e?.activity||[],u=o(),d=async()=>{let e=!a;try{let n=await r.activityToggle(e);t(e=>({...e||{},enabled:n.enabled,activity:n.enabled&&e?.activity||[]})),i(``)}catch(e){i(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:a?`tracking on`:`tracking off`}),(0,_.jsx)(`div`,{className:`sw-toggle${a?` on`:``}`,role:`switch`,"aria-checked":a,"aria-label":`Toggle activity tracking`,tabIndex:0,onClick:d,onKeyDown:e=>{(e.key===`Enter`||e.key===` `)&&(e.key===` `&&e.preventDefault(),d())}})]})]}),(0,_.jsx)(c,{error:n,style:{marginBottom:12}}),e?a?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{s as e}from"./sections-BET3c1WT.js";import{i as t,t as n}from"./react-BOF6yL40.js";import{t as r}from"./api-CiXlkbe0.js";import{C as i,N as a,Q as o,R as s,x as c}from"./index-BZgEGRbL.js";import{a as l,i as u,n as d,o as f,r as p,t as m}from"./activity-DvPxoS4Y.js";import{t as h}from"./usePoll-NSTzLOF4.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 `,s(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 s=!e||e.enabled!==!1,l=e?.activity||[],u=o(),d=async()=>{let e=!s;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:s?`tracking on`:`tracking off`}),(0,_.jsx)(`div`,{className:`sw-toggle${s?` on`:``}`,role:`switch`,"aria-checked":s,"aria-label":`Toggle activity tracking`,tabIndex:0,onClick:d,onKeyDown:e=>{(e.key===`Enter`||e.key===` `)&&(e.key===` `&&e.preventDefault(),d())}})]})]}),(0,_.jsx)(c,{error:n,style:{marginBottom:12}}),e?s?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)(i,{label:`Reading live sessions…`})]})}export{y as default};