geneseed 2.0.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 (337) hide show
  1. package/CHANGELOG.md +938 -0
  2. package/DESIGN.md +1423 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +48 -0
  5. package/README.md +336 -0
  6. package/SETUP.md +1027 -0
  7. package/SHIPPED.md +70 -0
  8. package/adapters/bob/README.md +100 -0
  9. package/adapters/claude-code/README.md +101 -0
  10. package/adapters/claude-code/settings.json +47 -0
  11. package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
  12. package/adapters/copilot/README.md +76 -0
  13. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
  14. package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
  15. package/adapters/opencode/README.md +582 -0
  16. package/adapters/opencode/agents/orchestrator.md +25 -0
  17. package/adapters/opencode/opencode.json +44 -0
  18. package/adapters/opencode/plugins/geneseed-activity.js +430 -0
  19. package/adapters/opencode/plugins/geneseed-context.js +876 -0
  20. package/adapters/opencode/plugins/geneseed-guard.js +290 -0
  21. package/adapters/opencode/plugins/geneseed-learn.js +396 -0
  22. package/adapters/opencode/plugins/geneseed-notify.js +142 -0
  23. package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
  24. package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
  25. package/adapters/opencode/workflows/_runtime.js +277 -0
  26. package/adapters/opencode/workflows/council.js +64 -0
  27. package/adapters/opencode/workflows/dispatch.js +89 -0
  28. package/adapters/opencode/workflows/research-plan-implement.js +43 -0
  29. package/adapters/opencode/workflows/review.js +57 -0
  30. package/bin/geneseed-cli.mjs +412 -0
  31. package/bin/geneseed-hook.mjs +129 -0
  32. package/bin/geneseed.mjs +1086 -0
  33. package/docs/declined.md +149 -0
  34. package/docs/limits.md +270 -0
  35. package/docs/opencode-plugin-setup.md +64 -0
  36. package/docs/token-footprint.md +68 -0
  37. package/docs/web/_groups.json +45 -0
  38. package/docs/web/about.md +6 -0
  39. package/docs/web/adapters-claude-code.md +8 -0
  40. package/docs/web/adapters-opencode-loads.md +8 -0
  41. package/docs/web/adapters-opencode-spec.md +8 -0
  42. package/docs/web/adapters-opencode.md +8 -0
  43. package/docs/web/agents.md +8 -0
  44. package/docs/web/authoring.md +11 -0
  45. package/docs/web/autostart.md +9 -0
  46. package/docs/web/claude-hooks.md +36 -0
  47. package/docs/web/cli.md +6 -0
  48. package/docs/web/collaboration.md +27 -0
  49. package/docs/web/configure-wiki.md +9 -0
  50. package/docs/web/design.md +7 -0
  51. package/docs/web/env-knobs.md +9 -0
  52. package/docs/web/exclusions.md +26 -0
  53. package/docs/web/first-session.md +28 -0
  54. package/docs/web/footprint.md +31 -0
  55. package/docs/web/foreman-mode.md +33 -0
  56. package/docs/web/glossary.md +6 -0
  57. package/docs/web/harnesses.md +28 -0
  58. package/docs/web/headless.md +9 -0
  59. package/docs/web/ingest-docs.md +9 -0
  60. package/docs/web/install-paths.md +9 -0
  61. package/docs/web/install-quick.md +35 -0
  62. package/docs/web/lsp-overview.md +37 -0
  63. package/docs/web/mcp-claude-code.md +10 -0
  64. package/docs/web/mcp-filesystem.md +9 -0
  65. package/docs/web/mcp-gitlab.md +9 -0
  66. package/docs/web/mcp-markitdown.md +9 -0
  67. package/docs/web/mcp-overview.md +34 -0
  68. package/docs/web/mcp-trouble.md +9 -0
  69. package/docs/web/mcp-verify.md +9 -0
  70. package/docs/web/memory.md +7 -0
  71. package/docs/web/migrate.md +39 -0
  72. package/docs/web/model.md +20 -0
  73. package/docs/web/notebook.md +7 -0
  74. package/docs/web/pipelines.md +24 -0
  75. package/docs/web/plugin-activity.md +23 -0
  76. package/docs/web/plugin-context.md +26 -0
  77. package/docs/web/plugin-guard.md +24 -0
  78. package/docs/web/plugin-learn.md +27 -0
  79. package/docs/web/plugin-notify.md +24 -0
  80. package/docs/web/plugin-ponytail.md +23 -0
  81. package/docs/web/plugin-setup.md +7 -0
  82. package/docs/web/plugin-workflow.md +24 -0
  83. package/docs/web/plugins.md +17 -0
  84. package/docs/web/project-context.md +9 -0
  85. package/docs/web/rules.md +8 -0
  86. package/docs/web/run-anywhere.md +9 -0
  87. package/docs/web/self-improve.md +8 -0
  88. package/docs/web/setup-choices.md +39 -0
  89. package/docs/web/skills.md +8 -0
  90. package/docs/web/themes-author.md +8 -0
  91. package/docs/web/themes.md +9 -0
  92. package/docs/web/token-footprint.md +7 -0
  93. package/docs/web/trouble.md +38 -0
  94. package/docs/web/upgrade.md +9 -0
  95. package/docs/web/verify.md +30 -0
  96. package/docs/web/wiki.md +7 -0
  97. package/docs/web/worktree.md +10 -0
  98. package/docs/web-ui.md +144 -0
  99. package/docs/wiki.md +43 -0
  100. package/geneseed +39 -0
  101. package/geneseed.cmd +34 -0
  102. package/harness.config.json +6 -0
  103. package/js/anim.mjs +326 -0
  104. package/js/catalog.mjs +112 -0
  105. package/js/checkout.mjs +88 -0
  106. package/js/cli-table.json +1034 -0
  107. package/js/cli.mjs +498 -0
  108. package/js/diff.mjs +384 -0
  109. package/js/doctor.mjs +1364 -0
  110. package/js/emit.mjs +1373 -0
  111. package/js/excludes.mjs +271 -0
  112. package/js/generate.mjs +379 -0
  113. package/js/hooks.mjs +1026 -0
  114. package/js/hosts.mjs +253 -0
  115. package/js/installs.mjs +378 -0
  116. package/js/inventory.mjs +175 -0
  117. package/js/lib/fs.mjs +913 -0
  118. package/js/lib/proc.mjs +28 -0
  119. package/js/lib/udiff.mjs +275 -0
  120. package/js/link.mjs +286 -0
  121. package/js/mcp.mjs +335 -0
  122. package/js/memory.mjs +108 -0
  123. package/js/menu.mjs +118 -0
  124. package/js/migrate.mjs +251 -0
  125. package/js/native.mjs +426 -0
  126. package/js/opencode.mjs +325 -0
  127. package/js/registry.mjs +110 -0
  128. package/js/render.mjs +282 -0
  129. package/js/settings.mjs +924 -0
  130. package/js/setup.mjs +492 -0
  131. package/js/status.mjs +281 -0
  132. package/js/themes.mjs +195 -0
  133. package/js/tui.mjs +444 -0
  134. package/js/uninstall.mjs +1137 -0
  135. package/js/update.mjs +910 -0
  136. package/js/web/actions.mjs +868 -0
  137. package/js/web/activity.mjs +284 -0
  138. package/js/web/api.mjs +873 -0
  139. package/js/web/docs.mjs +531 -0
  140. package/js/web/graph.mjs +0 -0
  141. package/js/web/jobs.mjs +480 -0
  142. package/js/web/server.mjs +1135 -0
  143. package/package.json +54 -0
  144. package/registry.json +474 -0
  145. package/src/AGENT.md.tmpl +390 -0
  146. package/src/agents/_template.md +76 -0
  147. package/src/agents/advocate.md +36 -0
  148. package/src/agents/architect.md +55 -0
  149. package/src/agents/developer.md +66 -0
  150. package/src/agents/docs.md +54 -0
  151. package/src/agents/empiricist.md +39 -0
  152. package/src/agents/explorer.md +54 -0
  153. package/src/agents/framer.md +36 -0
  154. package/src/agents/historian.md +51 -0
  155. package/src/agents/operator.md +37 -0
  156. package/src/agents/pragmatist.md +37 -0
  157. package/src/agents/reviewer.md +52 -0
  158. package/src/agents/security.md +57 -0
  159. package/src/agents/skeptic.md +48 -0
  160. package/src/agents/steward.md +36 -0
  161. package/src/agents/tester.md +54 -0
  162. package/src/agents/user-advocate.md +36 -0
  163. package/src/agents/visionary.md +36 -0
  164. package/src/laws/universal.md +387 -0
  165. package/src/memory/README.md +109 -0
  166. package/src/memory/gitignore +6 -0
  167. package/src/modes/README.md +26 -0
  168. package/src/modes/direct.md +2 -0
  169. package/src/modes/foreman.md +19 -0
  170. package/src/notebook/README.md +39 -0
  171. package/src/notebook/gitignore +7 -0
  172. package/src/postures/README.md +31 -0
  173. package/src/postures/artisan.md +14 -0
  174. package/src/postures/assistant.md +12 -0
  175. package/src/postures/expert.md +11 -0
  176. package/src/postures/mentor.md +11 -0
  177. package/src/postures/peer.md +12 -0
  178. package/src/skills/_template.md +47 -0
  179. package/src/skills/brainstorm.md +28 -0
  180. package/src/skills/clarify.md +28 -0
  181. package/src/skills/codebase-design.md +44 -0
  182. package/src/skills/commit.md +48 -0
  183. package/src/skills/council.md +34 -0
  184. package/src/skills/crash-course.md +28 -0
  185. package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
  186. package/src/skills/daydream/SKILL.md +74 -0
  187. package/src/skills/daydream/VENDOR.md +25 -0
  188. package/src/skills/daydream/critic-prompt.md +38 -0
  189. package/src/skills/daydream/instructions.md +422 -0
  190. package/src/skills/daydream/synthesizer-prompt.md +41 -0
  191. package/src/skills/debug.md +37 -0
  192. package/src/skills/decode.md +28 -0
  193. package/src/skills/develop.md +29 -0
  194. package/src/skills/document-project.md +79 -0
  195. package/src/skills/domain-modeling.md +38 -0
  196. package/src/skills/drill.md +28 -0
  197. package/src/skills/feynman.md +26 -0
  198. package/src/skills/forge-mcp.md +48 -0
  199. package/src/skills/fresh-eyes.md +29 -0
  200. package/src/skills/frontend-design.md +58 -0
  201. package/src/skills/gap-detector.md +27 -0
  202. package/src/skills/geneseed-code-review.md +33 -0
  203. package/src/skills/geneseed.md +29 -0
  204. package/src/skills/git-archaeology.md +47 -0
  205. package/src/skills/git-rescue.md +68 -0
  206. package/src/skills/handoff.md +29 -0
  207. package/src/skills/herdr.md +164 -0
  208. package/src/skills/ingest.md +49 -0
  209. package/src/skills/learning-path.md +26 -0
  210. package/src/skills/migrate.md +45 -0
  211. package/src/skills/opencode-theme.md +52 -0
  212. package/src/skills/parallel-agents.md +28 -0
  213. package/src/skills/pipeline.md +52 -0
  214. package/src/skills/plan.md +45 -0
  215. package/src/skills/ponytail.md +29 -0
  216. package/src/skills/profile.md +58 -0
  217. package/src/skills/prose.md +42 -0
  218. package/src/skills/prototype.md +44 -0
  219. package/src/skills/react-view-transitions/AGENTS.md +955 -0
  220. package/src/skills/react-view-transitions/README.md +42 -0
  221. package/src/skills/react-view-transitions/SKILL.md +320 -0
  222. package/src/skills/react-view-transitions/VENDOR.md +16 -0
  223. package/src/skills/react-view-transitions/metadata.json +12 -0
  224. package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
  225. package/src/skills/react-view-transitions/references/implementation.md +182 -0
  226. package/src/skills/react-view-transitions/references/nextjs.md +176 -0
  227. package/src/skills/react-view-transitions/references/patterns.md +262 -0
  228. package/src/skills/refactor.md +32 -0
  229. package/src/skills/release.md +43 -0
  230. package/src/skills/repo-map.md +36 -0
  231. package/src/skills/research.md +42 -0
  232. package/src/skills/review-response.md +36 -0
  233. package/src/skills/roast-me.md +28 -0
  234. package/src/skills/rule.md +100 -0
  235. package/src/skills/ship.md +49 -0
  236. package/src/skills/tdd.md +31 -0
  237. package/src/skills/tickets.md +44 -0
  238. package/src/skills/token-report/SKILL.md +76 -0
  239. package/src/skills/token-report/VENDOR.md +23 -0
  240. package/src/skills/token-report/scripts/token_report.mjs +1301 -0
  241. package/src/skills/wayfinder.md +49 -0
  242. package/src/skills/wiki.md +68 -0
  243. package/src/skills/workflow.md +38 -0
  244. package/themes/README.md +69 -0
  245. package/themes/_TEMPLATE.json +147 -0
  246. package/themes/biker.json +147 -0
  247. package/themes/commentator.json +147 -0
  248. package/themes/cyberpunk.json +147 -0
  249. package/themes/gamer.json +147 -0
  250. package/themes/imperial.json +147 -0
  251. package/themes/joker.json +147 -0
  252. package/themes/marvin.json +147 -0
  253. package/themes/mean.json +147 -0
  254. package/themes/military.json +147 -0
  255. package/themes/neutral.json +147 -0
  256. package/themes/opencode/README.md +58 -0
  257. package/themes/opencode/catppuccin.json +27 -0
  258. package/themes/opencode/dracula.json +27 -0
  259. package/themes/opencode/everforest.json +27 -0
  260. package/themes/opencode/gruvbox.json +27 -0
  261. package/themes/opencode/kanagawa.json +27 -0
  262. package/themes/opencode/neon.json +27 -0
  263. package/themes/opencode/nord.json +27 -0
  264. package/themes/opencode/rosepine.json +27 -0
  265. package/themes/opencode/tokyonight.json +27 -0
  266. package/themes/pirate.json +147 -0
  267. package/themes/sports.json +147 -0
  268. package/themes/verstappen.json +147 -0
  269. package/themes/wizard.json +147 -0
  270. package/web/dist/assets/About-DX1-VTBP.js +1 -0
  271. package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
  272. package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
  273. package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
  274. package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
  275. package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
  276. package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
  277. package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
  278. package/web/dist/assets/Library-BEZscyaQ.js +1 -0
  279. package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
  280. package/web/dist/assets/Profile-DydukTxP.js +1 -0
  281. package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
  282. package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
  283. package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
  284. package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
  285. package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
  286. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  287. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  288. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  289. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  290. package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
  291. package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
  292. package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
  293. package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
  294. package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
  295. package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
  296. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  297. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  298. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  299. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  300. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  301. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  302. package/web/dist/assets/index-B1PIx7RM.js +1 -0
  303. package/web/dist/assets/index-BnTQUSUz.js +12 -0
  304. package/web/dist/assets/index-Ck2iCdaC.js +2 -0
  305. package/web/dist/assets/index-DUCsDv7z.css +1 -0
  306. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  307. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  308. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  309. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  310. package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  311. package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  312. package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  313. package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
  314. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  315. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  316. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  317. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  318. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  319. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  320. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  321. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  322. package/web/dist/assets/react-DOpiTn5v.js +32 -0
  323. package/web/dist/assets/roman-DkYN0LX9.js +1 -0
  324. package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  325. package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  326. package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  327. package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  328. package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  329. package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  330. package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
  331. package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  332. package/web/dist/icon-maskable.svg +10 -0
  333. package/web/dist/icon.svg +8 -0
  334. package/web/dist/index.html +21 -0
  335. package/web/dist/manifest.webmanifest +14 -0
  336. package/web/dist/sw.js +40 -0
  337. package/web/src/pages/Laws.jsx +230 -0
@@ -0,0 +1,430 @@
1
+ // Geneseed — OpenCode live-activity writer plugin.
2
+ //
3
+ // Surfaces what the harness is DOING (not just what it is) to the web console's
4
+ // Activity view. Writes one small JSON file per session into the OpenCode config
5
+ // dir's `activity/` folder; the web console's activity reader globs and renders them.
6
+ // Fully decoupled: the writer never talks to the reader, only the filesystem —
7
+ // language-agnostic, crash-isolated, no RPC. Mirrors the file-IPC seam
8
+ // the other plugins already use (geneseed-ponytail's state file, geneseed-workflow's
9
+ // per-run trace shards).
10
+ //
11
+ // ONE FILE PER SESSION (`activity/<session_id>.json`), owned exclusively by the
12
+ // process that created the session — so concurrent `opencode` invocations never
13
+ // contend on a shared file and no lock is needed. Atomic write (tmp + rename) keeps
14
+ // the reader from ever seeing a torn file. The reader prunes entries whose `pid` is
15
+ // dead or whose `updated_at` is stale, so a crashed writer's file self-cleans.
16
+ //
17
+ // FLAT: one entry per top-level session. Sub-agent fan-out (subtask parts,
18
+ // parent→children tree) is deferred — child sessions (those with a parentID) and the
19
+ // learn plugin's throwaway `geneseed-*` distil sessions are skipped entirely.
20
+ //
21
+ // v1.1 enriches each entry from events/fields it already receives or one `case` away:
22
+ // the current phase (tool/thinking), model, session token+cost totals, turn-elapsed,
23
+ // files-touched + churn, the todo/plan, blocked-on-permission, and the last error.
24
+ //
25
+ // GENESEED_ACTIVITY=off disables it. GENESEED_DEBUG=1 logs writes. Every failure is
26
+ // swallowed — it never blocks a session. See docs/specs/2026-06-15-live-activity-surface.md
27
+ // and docs/specs/2026-06-18-activity-v1.1-enriched-cards.md.
28
+
29
+ import fs from "node:fs"
30
+ import os from "node:os"
31
+ import path from "node:path"
32
+
33
+ // GENESEED_ACTIVITY=off is a hard kill switch at startup; the web console's runtime
34
+ // toggle is the .geneseed-activity flag file checked per event (see isEnabled).
35
+ const ENV_OFF = ["off", "0", "false", "no"].includes((process.env.GENESEED_ACTIVITY || "on").toLowerCase())
36
+ const DEBUG = !!process.env.GENESEED_DEBUG
37
+ const PID = process.pid
38
+ function log(msg) { if (DEBUG) console.error(`[geneseed-activity] ${msg}`) }
39
+
40
+ // The OpenCode config dir, resolved to EXACTLY match the reader's own config-dir
41
+ // precedence ($OPENCODE_CONFIG_DIR > $XDG_CONFIG_HOME/opencode > ~/.config/opencode).
42
+ // Copying geneseed-ponytail's statePath() verbatim would check XDG only and diverge
43
+ // from the reader whenever $OPENCODE_CONFIG_DIR is set — the one path-divergence bug
44
+ // this seam exists to avoid.
45
+ function configBase() {
46
+ return (
47
+ process.env.OPENCODE_CONFIG_DIR ||
48
+ path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode")
49
+ )
50
+ }
51
+ function activityDir() { return path.join(configBase(), "activity") }
52
+
53
+ // The runtime on/off flag the web console writes (one word). Beside the OpenCode
54
+ // config like geneseed-ponytail's state file, and read every event so the toggle
55
+ // takes effect without restarting opencode. The reader resolves the same path
56
+ // (state.target / ".geneseed-activity").
57
+ function stateFile() { return path.join(configBase(), ".geneseed-activity") }
58
+
59
+ // Pure: a flag-file body disables only when it explicitly says so; absent/empty/
60
+ // anything-else → enabled. Exported for tests.
61
+ function enabledFromFlag(raw) {
62
+ return !["off", "0", "false", "no"].includes(String(raw ?? "").trim().toLowerCase())
63
+ }
64
+ function isEnabled() {
65
+ if (ENV_OFF) return false
66
+ try { return enabledFromFlag(fs.readFileSync(stateFile(), "utf8")) } catch { return true }
67
+ }
68
+
69
+ // Bound the inline lists written per session; the v1.2 detail page carries the full ones.
70
+ const FILE_CAP = 8
71
+ const TODO_CAP = 12
72
+ // v1.2 detail file (timeline). LOG_CAP bounds the ring; the detail write is throttled
73
+ // so a text-streaming turn doesn't rewrite the file per delta; SNIPPET_LEN bounds the
74
+ // one-line text/reasoning records (no full prose to the console).
75
+ const LOG_CAP = 200
76
+ const DETAIL_THROTTLE_MS = 700
77
+ const SNIPPET_LEN = 160
78
+ // The conversation transcript (one bounded turn per user/assistant message) is capped
79
+ // so a very long chat stays bounded on disk; oldest turns drop first.
80
+ const CONV_CAP = 60
81
+
82
+ // --- pure helpers (exported for tests) ---------------------------------------
83
+
84
+ // Session id from an event's properties, across the shapes OpenCode uses:
85
+ // session.status / permission.* carry `sessionID`; message.updated's `info` (a Message)
86
+ // carries `sessionID`; message.part.updated's `part` carries `sessionID`; session.*
87
+ // carry `info` (a Session) whose own id is `id`.
88
+ function sidOf(props) {
89
+ if (!props) return null
90
+ return props.sessionID
91
+ ?? props.info?.sessionID
92
+ ?? props.part?.sessionID
93
+ ?? props.info?.id
94
+ ?? null
95
+ }
96
+
97
+ // The status an event implies, or null for "no change". busy/retry → busy; a
98
+ // finished turn (session.idle) → waiting-input ("your move"); session.status idle →
99
+ // idle; any streaming message → busy.
100
+ function nextStatus(type, statusType) {
101
+ switch (type) {
102
+ case "session.idle": return "waiting-input"
103
+ case "message.updated":
104
+ case "message.part.updated": return "busy"
105
+ case "session.status": return statusType === "idle" ? "idle" : "busy" // busy | retry
106
+ case "session.created": return "idle"
107
+ default: return null
108
+ }
109
+ }
110
+
111
+ // A readable one-liner from an OpenCode error — a plain string (ToolStateError) or the
112
+ // {name, data:{message}} union (AssistantMessage.error / session.error). null → null.
113
+ function errStr(e) {
114
+ if (!e) return null
115
+ if (typeof e === "string") return e
116
+ return e.data?.message || e.name || "error"
117
+ }
118
+
119
+ // Per-session token+cost accumulator. cost/tokens are per-AssistantMessage and
120
+ // message.updated re-fires for the SAME streaming message (same id, growing numbers),
121
+ // so we key by message id and sum — same id overwrites (streaming), new id adds (next
122
+ // turn). Mutates `map`, returns the session totals. Exported for tests.
123
+ function acctTotals(map, msgId, cost, tokens) {
124
+ map.set(msgId, { cost: cost || 0, tokens: tokens || 0 })
125
+ let c = 0, t = 0
126
+ for (const v of map.values()) { c += v.cost; t += v.tokens }
127
+ return { cost: c, tokens: t }
128
+ }
129
+
130
+ // Pure reducer: prev entry (or null) + an extracted event → next entry, or null to
131
+ // delete the file. The hook does the IO + derivation; this just merges. Truthy-set
132
+ // fields (title/cwd/agent/model) only overwrite when provided; clearable fields
133
+ // (phase/turn_started_at/error/blocked_on) use `in` so null can reset them.
134
+ function applyEvent(prev, ev) {
135
+ if (ev.type === "session.deleted") return null
136
+ const next = prev
137
+ ? { ...prev }
138
+ : { session_id: ev.sid, agent: null, title: null, cwd: null, status: "idle", pid: ev.pid }
139
+ next.updated_at = ev.nowSec
140
+ if (ev.title != null) next.title = ev.title || null
141
+ if (ev.cwd) next.cwd = ev.cwd
142
+ if (ev.agent) next.agent = ev.agent
143
+ if (ev.model) next.model = ev.model
144
+ if (ev.cost != null) next.cost = ev.cost
145
+ if (ev.tokens != null) next.tokens = ev.tokens
146
+ if (ev.files !== undefined) next.files = ev.files
147
+ if (ev.todos !== undefined) next.todos = ev.todos
148
+ if ("phase" in ev) next.phase = ev.phase
149
+ if ("turn_started_at" in ev) next.turn_started_at = ev.turn_started_at
150
+ if ("error" in ev) next.error = ev.error
151
+ if ("blocked_on" in ev) next.blocked_on = ev.blocked_on
152
+ const st = ev.status || nextStatus(ev.type, ev.statusType)
153
+ if (st) next.status = st
154
+ return next
155
+ }
156
+
157
+ function safeName(sid) {
158
+ return String(sid).replace(/[^A-Za-z0-9_.-]/g, "_")
159
+ }
160
+
161
+ // One-line snippet for a transcript/timeline text record (no full prose persisted).
162
+ // Conversation prompts/replies pass a longer cap so the question stays readable.
163
+ const snip = (s, n = SNIPPET_LEN) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n)
164
+ const CONV_LEN = 240
165
+
166
+ // --- IO ----------------------------------------------------------------------
167
+
168
+ // Atomic write: tmp + rename so the reader never sees a half-written file.
169
+ function writeJson(file, obj) {
170
+ fs.mkdirSync(path.dirname(file), { recursive: true })
171
+ const tmp = `${file}.${PID}.tmp`
172
+ fs.writeFileSync(tmp, JSON.stringify(obj))
173
+ fs.renameSync(tmp, file)
174
+ }
175
+
176
+ const entryPath = (sid) => path.join(activityDir(), `${safeName(sid)}.json`)
177
+ // Detail file (v1.2): full files/todos + the step timeline. Named *.detail.json so
178
+ // the list glob (which skips that suffix) never mistakes it for a session snapshot.
179
+ const detailPath = (sid) => path.join(activityDir(), `${safeName(sid)}.detail.json`)
180
+
181
+ function writeEntry(entry) { writeJson(entryPath(entry.session_id), entry) }
182
+ function removeEntry(sid) { try { fs.unlinkSync(entryPath(sid)) } catch {} }
183
+ function removeDetail(sid) { try { fs.unlinkSync(detailPath(sid)) } catch {} }
184
+
185
+ // --- plugin ------------------------------------------------------------------
186
+
187
+ export const GeneseedActivity = async () => {
188
+ const owned = new Map() // sid -> entry (top-level sessions this process tracks)
189
+ const skipped = new Set() // sid -> child / geneseed throwaway, ignored from here on
190
+ const accts = new Map() // sid -> Map(messageId -> {cost,tokens}) — token/cost accumulation
191
+ const pending = new Map() // sid -> Set(permissionId) — open permission prompts (→ blocked)
192
+ const timelines = new Map() // sid -> Map(key -> step record) — the v1.2 detail ring (insertion-ordered)
193
+ const details = new Map() // sid -> { files, todos, lastMs } — uncapped lists + flush throttle
194
+
195
+ const acctFor = (sid) => { let m = accts.get(sid); if (!m) { m = new Map(); accts.set(sid, m) } return m }
196
+ const pendingFor = (sid) => { let s = pending.get(sid); if (!s) { s = new Set(); pending.set(sid, s) } return s }
197
+ const tlFor = (sid) => { let m = timelines.get(sid); if (!m) { m = new Map(); timelines.set(sid, m) } return m }
198
+ // convo: an ordered Map(messageId -> {role,text,t}) — one bounded turn per message,
199
+ // keyed by message id so a streaming assistant turn updates in place (not per delta).
200
+ const detFor = (sid) => {
201
+ let d = details.get(sid)
202
+ if (!d) { d = { files: null, todos: null, lastMs: 0, convo: new Map() }; details.set(sid, d) }
203
+ return d
204
+ }
205
+ const forget = (sid) => {
206
+ owned.delete(sid); accts.delete(sid); pending.delete(sid); timelines.delete(sid); details.delete(sid)
207
+ removeEntry(sid); removeDetail(sid)
208
+ }
209
+
210
+ // Push a step into the ring; the same key (callID / part id) updates in place so a
211
+ // streaming part doesn't flood, and the ring is capped to the most recent LOG_CAP.
212
+ const pushStep = (sid, key, rec) => {
213
+ const m = tlFor(sid)
214
+ m.set(key, rec)
215
+ while (m.size > LOG_CAP) m.delete(m.keys().next().value)
216
+ }
217
+ // Record a conversation turn (user prompt or assistant reply), keyed by message id so
218
+ // a streaming assistant turn updates in place. Capped to the most recent CONV_CAP.
219
+ let userSeq = 0
220
+ const pushTurn = (sid, key, role, text) => {
221
+ const c = detFor(sid).convo
222
+ c.set(key || `u${userSeq++}`, { role, text: snip(text, CONV_LEN), t: Date.now() })
223
+ while (c.size > CONV_CAP) c.delete(c.keys().next().value)
224
+ }
225
+ // Rewrite the detail file (uncapped files/todos + timeline). Throttled per session —
226
+ // a text-streaming turn updates the ring in memory but only flushes ~1/700ms, except
227
+ // on `force` (tool end / step / subtask / idle / error) which flushes immediately.
228
+ const flushDetail = (sid, force) => {
229
+ try {
230
+ const d = detFor(sid)
231
+ const now = Date.now()
232
+ if (!force && now - d.lastMs < DETAIL_THROTTLE_MS) return
233
+ d.lastMs = now
234
+ writeJson(detailPath(sid), { files: d.files, todos: d.todos, conversation: [...d.convo.values()], timeline: [...tlFor(sid).values()] })
235
+ } catch (err) { log(`detail ${sid} failed: ${err?.message ?? err}`) }
236
+ }
237
+ // Record a message part into the timeline. Returns true when it should force a flush.
238
+ const recordPart = (sid, part) => {
239
+ const t = Date.now()
240
+ switch (part.type) {
241
+ case "tool": {
242
+ const s = part.state ?? {}
243
+ pushStep(sid, `tool:${part.callID}`, {
244
+ t, kind: "tool", label: s.title || part.tool || "tool", tool: part.tool, status: s.status,
245
+ ms: s.time?.end && s.time?.start ? s.time.end - s.time.start : undefined,
246
+ error: s.status === "error" ? errStr(s.error) : undefined,
247
+ })
248
+ return s.status === "completed" || s.status === "error"
249
+ }
250
+ case "reasoning": pushStep(sid, part.id, { t, kind: "thinking", snippet: snip(part.text) }); return false
251
+ case "text":
252
+ // Streaming assistant text — a timeline step AND an assistant turn in the
253
+ // conversation (keyed by message id so the streaming turn updates in place).
254
+ pushStep(sid, part.id, { t, kind: "text", snippet: snip(part.text) })
255
+ pushTurn(sid, part.messageID, "assistant", part.text)
256
+ return false
257
+ case "subtask": pushStep(sid, part.id, { t, kind: "subtask", agent: part.agent, label: part.description }); return true
258
+ case "step-finish": pushStep(sid, part.id, { t, kind: "step", reason: part.reason, cost: part.cost, tokens: (part.tokens?.input || 0) + (part.tokens?.output || 0) }); return true
259
+ case "retry": pushStep(sid, part.id, { t, kind: "retry", label: `retry #${part.attempt}` }); return true
260
+ default: return false
261
+ }
262
+ }
263
+
264
+ const isThrowaway = (title, parentID) =>
265
+ !!parentID || (typeof title === "string" && title.startsWith("geneseed-"))
266
+
267
+ const persist = (sid, ev) => {
268
+ try {
269
+ const next = applyEvent(owned.get(sid) || null, {
270
+ ...ev, sid, pid: PID, nowSec: Math.floor(Date.now() / 1000),
271
+ })
272
+ if (next === null) { forget(sid); return }
273
+ owned.set(sid, next)
274
+ writeEntry(next)
275
+ } catch (err) {
276
+ log(`persist ${sid} failed: ${err?.message ?? err}`)
277
+ }
278
+ }
279
+
280
+ return {
281
+ event: async ({ event }) => {
282
+ if (!event) return
283
+ // Runtime toggle: when disabled, stop writing and clear any files we already
284
+ // own (so the dir empties out), then no-op until re-enabled.
285
+ if (!isEnabled()) {
286
+ if (owned.size) { for (const sid of [...owned.keys()]) forget(sid) }
287
+ return
288
+ }
289
+ const props = event.properties ?? event.payload ?? {}
290
+ const sid = sidOf(props)
291
+ if (!sid || skipped.has(sid)) return
292
+
293
+ switch (event.type) {
294
+ case "session.created":
295
+ case "session.updated": {
296
+ const info = props.info ?? {}
297
+ // A child/subagent session or a geneseed-* distil session is background
298
+ // machinery — drop it (and any file we already wrote when its title was
299
+ // still blank). Sub-agent fan-out is the deferred tree.
300
+ if (isThrowaway(info.title, info.parentID ?? info.parentId)) {
301
+ skipped.add(sid)
302
+ if (owned.has(sid)) forget(sid)
303
+ return
304
+ }
305
+ if (event.type !== "session.created" && !owned.has(sid)) return
306
+ const ev = { type: event.type, title: info.title ?? null, cwd: info.directory }
307
+ // Files touched + churn ride the session summary (cumulative).
308
+ const s = info.summary
309
+ if (s) {
310
+ const all = (s.diffs || []).map((d) => ({ file: d.file, additions: d.additions, deletions: d.deletions }))
311
+ const files = { count: s.files || 0, additions: s.additions || 0, deletions: s.deletions || 0 }
312
+ ev.files = { ...files, items: all.slice(0, FILE_CAP) } // card: capped
313
+ detFor(sid).files = { ...files, items: all } // detail: uncapped
314
+ }
315
+ persist(sid, ev)
316
+ if (s) flushDetail(sid, true)
317
+ return
318
+ }
319
+ case "session.deleted":
320
+ forget(sid)
321
+ return
322
+ case "session.idle":
323
+ // Turn finished — stop the elapsed clock and clear the live phase.
324
+ if (owned.has(sid)) { persist(sid, { type: event.type, phase: null, turn_started_at: null }); flushDetail(sid, true) }
325
+ return
326
+ case "session.status": {
327
+ if (!owned.has(sid)) return
328
+ const st = props.status?.type
329
+ const ev = { type: event.type, statusType: st }
330
+ if (st === "idle") { ev.phase = null; ev.turn_started_at = null }
331
+ else if (st === "retry") ev.phase = "Retrying…"
332
+ persist(sid, ev)
333
+ return
334
+ }
335
+ case "message.updated": {
336
+ if (!owned.has(sid)) return
337
+ const info = props.info ?? {}
338
+ if (info.role !== "assistant") { persist(sid, { type: event.type }); return } // user msg → bump busy
339
+ const m = acctFor(sid)
340
+ const isNewTurn = !m.has(info.id)
341
+ const totals = acctTotals(m, info.id,
342
+ info.cost, (info.tokens?.input || 0) + (info.tokens?.output || 0))
343
+ const ev = {
344
+ type: event.type,
345
+ agent: info.mode, cwd: info.path?.cwd, model: info.modelID,
346
+ cost: totals.cost, tokens: totals.tokens,
347
+ // Elapsed ticks from the current assistant turn's start; cleared on completion.
348
+ turn_started_at: info.time?.completed ? null
349
+ : (info.time?.created ? Math.floor(info.time.created / 1000) : null),
350
+ }
351
+ if (info.error) ev.error = errStr(info.error)
352
+ else if (isNewTurn) ev.error = null // a fresh turn clears a stale error
353
+ persist(sid, ev)
354
+ return
355
+ }
356
+ case "message.part.updated": {
357
+ if (!owned.has(sid)) return
358
+ const part = props.part ?? {}
359
+ const ev = { type: event.type } // any part → busy + bump
360
+ if (part.type === "tool") {
361
+ const ts = part.state ?? {}
362
+ if (ts.status === "running") ev.phase = ts.title || part.tool || "working"
363
+ else if (ts.status === "error") ev.error = errStr(ts.error)
364
+ } else if (part.type === "reasoning") {
365
+ ev.phase = "Thinking"
366
+ }
367
+ persist(sid, ev)
368
+ flushDetail(sid, recordPart(sid, part)) // v1.2: record the step; force-flush on completion
369
+ return
370
+ }
371
+ case "todo.updated": {
372
+ if (!owned.has(sid)) return
373
+ const todos = props.todos || []
374
+ const counts = { done: todos.filter((t) => t.status === "completed").length, total: todos.length }
375
+ const items = todos.map((t) => ({ content: t.content, status: t.status }))
376
+ persist(sid, { type: event.type, todos: { ...counts, items: items.slice(0, TODO_CAP) } }) // card: capped
377
+ detFor(sid).todos = { ...counts, items } // detail: uncapped
378
+ flushDetail(sid, true)
379
+ return
380
+ }
381
+ case "permission.updated": {
382
+ if (!owned.has(sid)) return
383
+ pendingFor(sid).add(props.id)
384
+ persist(sid, { type: event.type, status: "blocked", blocked_on: props.title || props.type || "permission" })
385
+ return
386
+ }
387
+ case "permission.replied": {
388
+ if (!owned.has(sid)) return
389
+ const p = pendingFor(sid); p.delete(props.permissionID)
390
+ persist(sid, { type: event.type, status: p.size ? "blocked" : "busy", blocked_on: p.size ? undefined : null })
391
+ return
392
+ }
393
+ case "session.error": {
394
+ if (!owned.has(sid)) return
395
+ const msg = errStr(props.error)
396
+ persist(sid, { type: event.type, error: msg })
397
+ pushStep(sid, `err:${Date.now()}`, { t: Date.now(), kind: "error", label: msg })
398
+ flushDetail(sid, true)
399
+ return
400
+ }
401
+ default:
402
+ return
403
+ }
404
+ },
405
+
406
+ // Capture each user prompt as a conversation turn — the purpose-built signal for
407
+ // "a user message arrived with its text". Assistant replies come from the streaming
408
+ // text parts above; together they form the full transcript. Toggle-gated.
409
+ "chat.message": async (input, output) => {
410
+ try {
411
+ if (!isEnabled()) return
412
+ const sid = input?.sessionID
413
+ if (!sid || skipped.has(sid) || !owned.has(sid)) return
414
+ const parts = output?.parts || []
415
+ const text = parts.filter((p) => p?.type === "text" && p.text).map((p) => p.text).join(" ").trim()
416
+ if (!text) return
417
+ pushTurn(sid, input.messageID, "user", text)
418
+ flushDetail(sid, true)
419
+ } catch (err) {
420
+ log(`chat.message ${input?.sessionID} failed: ${err?.message ?? err}`)
421
+ }
422
+ },
423
+ }
424
+ }
425
+
426
+ // ponytail: OpenCode treats every export as a plugin and rejects non-functions; hang
427
+ // the test helpers off the factory instead — reachable via import, invisible to the loader.
428
+ Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag })
429
+
430
+ export default GeneseedActivity