geneseed 3.1.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -7,78 +7,64 @@
7
7
  * directory self-cleans), and returns what is left. Writer and reader share only that
8
8
  * directory: no RPC, crash-isolated, and cross-tool by construction.
9
9
  *
10
- * BOTH VERBS CROSS. `/api/activity` answers a GET through `apiActivity` and a POST through
11
- * `apiActivityToggle` at the bottom of this file; `NOT_PORTED_POST` is empty and still
12
- * declared. It is also the one POST route whose result is sent at 200 whatever `ok` says,
13
- * which is why `POST_ROUTES` in `js/web/routes.mjs` carries a 409 COLUMN rather than a rule —
14
- * and giving this row the 409 treatment is mutation M22.
10
+ * `/api/activity` answers a GET through `apiActivity` and a POST through `apiActivityToggle`
11
+ * at the bottom of this file. It is the one POST route whose result is sent at 200 whatever
12
+ * `ok` says, which is why `POST_ROUTES` in `js/web/routes.mjs` carries a 409 COLUMN rather
13
+ * than a rule — and giving this row the 409 treatment is mutation M22.
15
14
  *
16
- * THE PLAN GUESSED `tasklist` AND THE SOURCE SAYS OTHERWISE. `_win_pid_alive` shells
17
- * nothing: it goes through `ctypes` to `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` and
18
- * `GetExitCodeProcess`. So there is no sixth `_ALLOWED_SPAWNS` row to argue — not because
19
- * the Node twin avoids a spawn, but because the REFERENCE never had one. `process.kill(pid,
20
- * 0)` is the twin here; it is a core `process` API and not `child_process`, so the ban that
21
- * P4a's source check enforces is untouched. libuv's `uv_kill` opens the process for query
22
- * and answers `ESRCH` when the pid is free and `EPERM` when it cannot tell, which is
23
- * exactly the reference's "no such process" / "err toward alive" pair.
15
+ * `process.kill(pid, 0)` PROBES LIVENESS WITHOUT A SPAWN — it is a core `process` API, not
16
+ * `child_process`, so it stays clear of the spawn ban. libuv's `uv_kill` opens the process
17
+ * for query and answers `ESRCH` when the pid is free and `EPERM` when it cannot tell —
18
+ * "no such process" vs "err toward alive".
24
19
  *
25
- * THE DIVERGENCE THAT LEAVES, DECLARED RATHER THAN HIDDEN. The reference also reads the
26
- * exit code and calls a process that has EXITED but whose handle is still held dead;
27
- * `process.kill` reports it alive, and the staleness backstop is what prunes it thirty
28
- * minutes later. Reproducing the exit-code read would mean a native binding, which this
29
- * port will not add for a best-effort liveness probe whose own docstring says any
30
- * inconclusive answer errs toward alive.
20
+ * `process.kill` alone cannot tell a truly-dead pid from one that EXITED but whose handle is
21
+ * still held: it reports the latter alive too. The staleness backstop (below) is what prunes
22
+ * it, thirty minutes later — reproducing an exit-code read would need a native binding, not
23
+ * worth it for a best-effort probe that already errs toward "alive" on any inconclusive
24
+ * answer.
31
25
  *
32
- * `parseJson` AND NOT `JSON.parse`, WHICH IS NOT A STYLE CHOICE. These files are written by
33
- * a third party and carry `cost` — a float. Python's `json.loads` types `1.0` a float and
34
- * `json.dumps` writes it back `1.0`; `JSON.parse` gives a bare `1` that `bareInts` then
35
- * renders `1`. `parseJson`'s wrapper is the only thing that survives the round trip, and
36
- * `s-live`'s `"cost": 1.0` is the cell that says so. Its companion is `isTruthy`: an
37
- * `entry.get("cost") or 0` over a wrapper object is TRUE for a wrapped zero, where Python's
38
- * `0.0 or 0` is the int `0` — so `s-older`'s `"cost": 0.0` must come back `0` and not `0.0`.
26
+ * `parseJson`, NOT `JSON.parse` — NOT a style choice. These files are written by a third
27
+ * party and carry `cost` as a float. `JSON.parse` collapses `1.0` to the bare integer `1`,
28
+ * which `bareInts` then renders back as `1`, losing the distinction the source file made;
29
+ * `parseJson`'s wrapper is what survives the round trip.
30
+ *
31
+ * Its companion is `isTruthy`: a naive `entry.cost || 0` fallback is TRUE for a wrapped zero
32
+ * (the wrapper is an object, never falsy on its own), which would echo back the wrapper's
33
+ * `0.0` instead of collapsing to the fallback `0`. `isTruthy` is what makes a wrapped zero
34
+ * read as falsy, so a `"cost": 0.0` entry correctly comes back `0`.
39
35
  */
40
- import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
36
+ import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';
41
37
  import path from 'node:path';
42
38
 
43
- import { readText, writeText } from '../lib/fs.mjs';
39
+ import { readText, writeText, isDir, isFile } from '../lib/fs.mjs';
44
40
  import { parseJson, isTruthy } from '../lib/json.mjs';
45
41
  import { normcase } from '../lib/paths.mjs';
46
42
  import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
47
- import { NotFound } from './api.mjs';
43
+ import { NotFound, stemOf } from './api.mjs';
48
44
 
49
45
  /**
50
- * `_web_activity.ACTIVITY_STALE_SECONDS` — a session whose writer is gone, or that has not
51
- * been touched in this long, is pruned. pid-liveness is the real signal; the staleness
52
- * backstop covers a reused pid and a platform where the probe cannot tell. Generous on
53
- * purpose: streaming bumps `updated_at` constantly, so only a genuinely abandoned
54
- * (process-still-alive) session ages out.
46
+ * A session whose writer is gone, or that has not been touched in this long, is pruned.
47
+ * pid-liveness is the real signal; the staleness backstop covers a reused pid and a
48
+ * platform where the probe cannot tell. Generous on purpose: streaming bumps `updated_at`
49
+ * constantly, so only a genuinely abandoned (process-still-alive) session ages out.
55
50
  */
56
51
  export const ACTIVITY_STALE_SECONDS = 1800;
57
52
 
58
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
59
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
60
-
61
- /** `time.time()` — unix seconds as a float. */
62
53
  const nowSeconds = () => Date.now() / 1000;
63
54
 
64
55
  /**
65
- * `_activity_dir` — `<target>/activity`. `state.target` is ALREADY the OpenCode config dir,
66
- * so this must not append `opencode/` again; that double-join is the classic seam bug.
56
+ * `<target>/activity`. `state.target` is ALREADY the OpenCode config dir, so this must not
57
+ * append `opencode/` again; that double-join is the classic seam bug.
67
58
  */
68
59
  const activityDir = (state) => path.join(state.target, 'activity');
69
60
 
70
- /** `_activity_flag` — the runtime on/off flag, the same path the plugin resolves. */
61
+ /** The runtime on/off flag — the same path the plugin resolves. */
71
62
  const activityFlag = (state) => path.join(state.target, '.geneseed-activity');
72
63
 
73
64
  /**
74
- * `_activity_enabled` — on unless the flag file explicitly says off. An absent (or
75
- * unreadable) file is the default every user who has never toggled is in.
76
- *
77
- * The one input this cannot mirror is an undecodable flag file: Python's
78
- * `read_text(encoding="utf-8")` raises a `UnicodeDecodeError`, which is a `ValueError` and
79
- * not caught, where Node substitutes U+FFFD and reads on. No cell seeds one — it would take
80
- * a binary file where a four-letter word belongs — and the reference's answer there is a
81
- * 500, which is not a behaviour worth reproducing.
65
+ * On unless the flag file explicitly says off. An absent (or unreadable) file is the
66
+ * default every user who has never toggled is in. An undecodable flag file is not treated
67
+ * specially: Node substitutes U+FFFD and reads on rather than raising.
82
68
  */
83
69
  export function activityEnabled(state) {
84
70
  let raw;
@@ -91,14 +77,14 @@ export function activityEnabled(state) {
91
77
  }
92
78
 
93
79
  /**
94
- * `int(value)` as `_pid_alive` calls it — `TypeError` and `ValueError` both mean "not a
95
- * pid", so both come back null here.
80
+ * Coerces a raw pid value to an integer, or `null` for anything unusable.
96
81
  *
97
- * The value came out of `parseJson`, so a JSON number arrives inside a wrapper this module
98
- * cannot import by name. It is recognised by the only thing the wrapper promises, a numeric
99
- * `valueOf` — a plain object's returns itself and an array is excluded outright, which is
100
- * what `int({})` and `int([])` raising looks like from here. `int(1.9)` truncates and
101
- * `int(True)` is 1, both of which a hand-edited snapshot can produce.
82
+ * The value came out of `parseJson`, so a JSON number may arrive wrapped in `JsonNumber`,
83
+ * which this module cannot import by name (only `parseJson`/`isTruthy`/etc are exported). It
84
+ * is recognised by the only thing the wrapper promises: a numeric `valueOf`. A plain
85
+ * object's `valueOf` returns itself (rejected by the `typeof n !== 'number'` check below)
86
+ * and an array is excluded outright. Also handles a decimal string, a boolean (`true` → 1),
87
+ * and a float (truncated) — whatever a hand-edited snapshot can produce.
102
88
  */
103
89
  function toIntOr(raw) {
104
90
  if (raw === true) return 1;
@@ -110,7 +96,7 @@ function toIntOr(raw) {
110
96
  return Math.trunc(n);
111
97
  }
112
98
 
113
- /** `_pid_alive` — best-effort liveness for the writer process. See this file's header. */
99
+ /** Best-effort liveness for the writer process. See this file's header. */
114
100
  export function pidAlive(raw) {
115
101
  const pid = toIntOr(raw);
116
102
  if (pid === null || pid <= 0) return false;
@@ -118,17 +104,16 @@ export function pidAlive(raw) {
118
104
  process.kill(pid, 0);
119
105
  return true;
120
106
  } catch (e) {
121
- // `ProcessLookupError` → dead. Anything else (`EPERM`, and whatever a locked-down host
122
- // answers) → alive, and the staleness backstop decides.
107
+ // ESRCH → dead. Anything else (`EPERM`, and whatever a locked-down host answers) →
108
+ // alive, and the staleness backstop decides.
123
109
  return !(e && e.code === 'ESRCH');
124
110
  }
125
111
  }
126
112
 
127
113
  /**
128
- * `_normalize_entry` — one session snapshot into the stable shape the UI consumes. v1
129
- * writers omit the enrichment keys, so those default to null/0 and the UI hides what is
130
- * absent. The KEY ORDER is the reference's dict-literal order, which is what the byte
131
- * comparison over every response gates.
114
+ * One session snapshot normalised into the stable shape the UI consumes. v1 writers omit
115
+ * the enrichment keys, so those default to null/0 and the UI hides what is absent. The KEY
116
+ * ORDER here is deliberate and stable — do not reshuffle it.
132
117
  */
133
118
  function normalizeEntry(entry, stem) {
134
119
  const g = (k) => (Object.hasOwn(entry, k) ? entry[k] : null);
@@ -152,7 +137,7 @@ function normalizeEntry(entry, stem) {
152
137
  };
153
138
  }
154
139
 
155
- /** `_read_entry` — null on a missing, garbage or non-dict file. Never throws. */
140
+ /** `null` on a missing, garbage or non-dict file. Never throws. */
156
141
  function readEntry(p) {
157
142
  let entry;
158
143
  try {
@@ -163,7 +148,7 @@ function readEntry(p) {
163
148
  return (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) ? entry : null;
164
149
  }
165
150
 
166
- /** `_is_live` — the writer's pid is alive AND the snapshot is not stale. */
151
+ /** The writer's pid is alive AND the snapshot is not stale. */
167
152
  function isLive(entry, now) {
168
153
  const updated = isTruthy(Object.hasOwn(entry, 'updated_at') ? entry.updated_at : null)
169
154
  ? entry.updated_at : 0;
@@ -171,13 +156,10 @@ function isLive(entry, now) {
171
156
  Object.hasOwn(entry, 'pid') ? entry.pid : null);
172
157
  }
173
158
 
174
- /** `Path.stem` — the name without its LAST suffix, so `s-live.detail.json` keeps the dot. */
175
- const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
176
-
177
159
  /**
178
- * `_activity_entries` — every live session, dead and stale ones pruned AND their files
179
- * removed. Never throws: a missing dir is `[]`, a garbage file is skipped (and kept —
180
- * only a snapshot that PARSED and failed the liveness test is unlinked), newest first.
160
+ * Every live session, dead and stale ones pruned AND their files removed. Never throws: a
161
+ * missing dir is `[]`, a garbage file is skipped (and kept — only a snapshot that PARSED and
162
+ * failed the liveness test is unlinked), newest first.
181
163
  */
182
164
  export function activityEntries(state) {
183
165
  const d = activityDir(state);
@@ -190,8 +172,9 @@ export function activityEntries(state) {
190
172
  } catch {
191
173
  return [];
192
174
  }
193
- // `Path.glob("*.json")` is case-INSENSITIVE on Windows, as pathlib's is; `str.endswith`
194
- // on the detail suffix is not, on either platform. Two different rules, deliberately.
175
+ // Case-INSENSITIVE for the `.json` suffix (matches Windows filesystem behaviour); the
176
+ // `.detail.json` check below is case-SENSITIVE on both platforms. Two different rules,
177
+ // deliberately.
195
178
  for (const name of names.filter((n) => normcase(n).endsWith('.json')).sort()) {
196
179
  if (name.endsWith('.detail.json')) continue;
197
180
  const p = path.join(d, name);
@@ -201,34 +184,34 @@ export function activityEntries(state) {
201
184
  try {
202
185
  unlinkSync(p); // crashed / abandoned writer
203
186
  unlinkSync(path.join(d, `${stemOf(name)}.detail.json`)); // its detail file too
204
- } catch { /* `contextlib.suppress(OSError)` wraps BOTH unlinks, as this does */ }
187
+ } catch { /* both unlinks are best-effort; a lingering file next scan is harmless */ }
205
188
  continue;
206
189
  }
207
190
  out.push(normalizeEntry(entry, stemOf(name)));
208
191
  }
209
- // Stable in both languages, and the tie is the point: `sorted(reverse=True)` preserves the
210
- // original order of equal keys, and so does a `b - a` comparator over ES2019's stable sort.
192
+ // STABLE sort matters when `updated_at` ties: a `b - a` comparator over ES2019's
193
+ // guaranteed-stable `Array.sort` preserves the original (directory-listing) order for
194
+ // equal keys.
211
195
  out.sort((a, b) => Number(b.updated_at) - Number(a.updated_at));
212
196
  return out;
213
197
  }
214
198
 
215
- /** `_web_activity.api_activity` (GET). */
216
199
  export function apiActivity(state) {
217
200
  const enabled = activityEnabled(state);
218
201
  return { enabled, activity: enabled ? activityEntries(state) : [] };
219
202
  }
220
203
 
221
204
  /**
222
- * `_web_activity.api_activity_detail` — one session's snapshot plus its step timeline and
223
- * its UNCAPPED files/todos. 404 if the session is absent or has aged out; a missing or
224
- * garbage detail file degrades to an empty timeline rather than a 500.
205
+ * One session's snapshot plus its step timeline and its UNCAPPED files/todos. 404 if the
206
+ * session is absent or has aged out; a missing or garbage detail file degrades to an empty
207
+ * timeline rather than a 500.
225
208
  */
226
209
  export function apiActivityDetail(state, sid) {
227
210
  const d = activityDir(state);
228
211
  // Resolve by the writer's safe-name scheme, which is also what keeps the read inside the
229
212
  // directory: every separator, `..` and drive colon becomes `_`, so there is no join to
230
- // steer. The `u` flag makes an astral character ONE replacement, as Python's code-point
231
- // `re.sub` does, rather than one per surrogate.
213
+ // steer. The `u` flag makes an astral character ONE replacement rather than one per
214
+ // surrogate half.
232
215
  const stem = sid.replace(/[^A-Za-z0-9_.-]/gu, '_');
233
216
  const snap = path.join(d, `${stem}.json`);
234
217
  const entry = isFile(snap) ? readEntry(snap) : null;
@@ -244,8 +227,6 @@ export function apiActivityDetail(state, sid) {
244
227
  }
245
228
  const fg = (k) => (Object.hasOwn(full, k) ? full[k] : null);
246
229
  // The detail file carries the uncapped lists; fall back to the snapshot's capped ones.
247
- // Assigning over an existing key keeps its POSITION in both languages, which is what lets
248
- // the response stay byte-comparable.
249
230
  session.files = isTruthy(fg('files')) ? fg('files') : session.files;
250
231
  session.todos = isTruthy(fg('todos')) ? fg('todos') : session.todos;
251
232
  // The compact transcript: an ordered list of {role, text, t} turns. Empty falls back to
@@ -260,16 +241,17 @@ export function apiActivityDetail(state, sid) {
260
241
  }
261
242
 
262
243
  /**
263
- * `_web_activity.api_activity_toggle` — flip the runtime on/off flag.
244
+ * Flip the runtime on/off flag.
264
245
  *
265
246
  * The OpenCode plugin reads this file on every event, so the change takes effect without
266
247
  * restarting anything; writing `off` also makes the plugin clear its snapshots on its next
267
248
  * event, while the reader gates the surface immediately.
268
249
  *
269
- * `bool(body.get("enabled", True))` DEFAULTS TO ON — an empty body enables, which is the
270
- * arm a port spelled `body.enabled === true` inverts. And this is the one POST in P6f whose
271
- * result is sent at 200 whatever `ok` says: the 409 convention is per-route in the
272
- * reference, not a rule about the shape of the body.
250
+ * DEFAULTS TO ON: an empty or missing `enabled` field in the body enables. Do not read this
251
+ * as `body.enabled === true` — that would flip an absent field to "disable" instead.
252
+ *
253
+ * This is the one POST route whose result is sent at 200 whatever `ok` says — see this
254
+ * file's header, and `POST_ROUTES_CONVENTION` in `js/web/routes.mjs`.
273
255
  */
274
256
  export function apiActivityToggle(state, body) {
275
257
  const raw = (body && Object.hasOwn(body, 'enabled')) ? body.enabled : true;