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.
- package/CHANGELOG.md +938 -0
- package/DESIGN.md +1423 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +48 -0
- package/README.md +336 -0
- package/SETUP.md +1027 -0
- package/SHIPPED.md +70 -0
- package/adapters/bob/README.md +100 -0
- package/adapters/claude-code/README.md +101 -0
- package/adapters/claude-code/settings.json +47 -0
- package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
- package/adapters/copilot/README.md +76 -0
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
- package/adapters/opencode/README.md +582 -0
- package/adapters/opencode/agents/orchestrator.md +25 -0
- package/adapters/opencode/opencode.json +44 -0
- package/adapters/opencode/plugins/geneseed-activity.js +430 -0
- package/adapters/opencode/plugins/geneseed-context.js +876 -0
- package/adapters/opencode/plugins/geneseed-guard.js +290 -0
- package/adapters/opencode/plugins/geneseed-learn.js +396 -0
- package/adapters/opencode/plugins/geneseed-notify.js +142 -0
- package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
- package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
- package/adapters/opencode/workflows/_runtime.js +277 -0
- package/adapters/opencode/workflows/council.js +64 -0
- package/adapters/opencode/workflows/dispatch.js +89 -0
- package/adapters/opencode/workflows/research-plan-implement.js +43 -0
- package/adapters/opencode/workflows/review.js +57 -0
- package/bin/geneseed-cli.mjs +412 -0
- package/bin/geneseed-hook.mjs +129 -0
- package/bin/geneseed.mjs +1086 -0
- package/docs/declined.md +149 -0
- package/docs/limits.md +270 -0
- package/docs/opencode-plugin-setup.md +64 -0
- package/docs/token-footprint.md +68 -0
- package/docs/web/_groups.json +45 -0
- package/docs/web/about.md +6 -0
- package/docs/web/adapters-claude-code.md +8 -0
- package/docs/web/adapters-opencode-loads.md +8 -0
- package/docs/web/adapters-opencode-spec.md +8 -0
- package/docs/web/adapters-opencode.md +8 -0
- package/docs/web/agents.md +8 -0
- package/docs/web/authoring.md +11 -0
- package/docs/web/autostart.md +9 -0
- package/docs/web/claude-hooks.md +36 -0
- package/docs/web/cli.md +6 -0
- package/docs/web/collaboration.md +27 -0
- package/docs/web/configure-wiki.md +9 -0
- package/docs/web/design.md +7 -0
- package/docs/web/env-knobs.md +9 -0
- package/docs/web/exclusions.md +26 -0
- package/docs/web/first-session.md +28 -0
- package/docs/web/footprint.md +31 -0
- package/docs/web/foreman-mode.md +33 -0
- package/docs/web/glossary.md +6 -0
- package/docs/web/harnesses.md +28 -0
- package/docs/web/headless.md +9 -0
- package/docs/web/ingest-docs.md +9 -0
- package/docs/web/install-paths.md +9 -0
- package/docs/web/install-quick.md +35 -0
- package/docs/web/lsp-overview.md +37 -0
- package/docs/web/mcp-claude-code.md +10 -0
- package/docs/web/mcp-filesystem.md +9 -0
- package/docs/web/mcp-gitlab.md +9 -0
- package/docs/web/mcp-markitdown.md +9 -0
- package/docs/web/mcp-overview.md +34 -0
- package/docs/web/mcp-trouble.md +9 -0
- package/docs/web/mcp-verify.md +9 -0
- package/docs/web/memory.md +7 -0
- package/docs/web/migrate.md +39 -0
- package/docs/web/model.md +20 -0
- package/docs/web/notebook.md +7 -0
- package/docs/web/pipelines.md +24 -0
- package/docs/web/plugin-activity.md +23 -0
- package/docs/web/plugin-context.md +26 -0
- package/docs/web/plugin-guard.md +24 -0
- package/docs/web/plugin-learn.md +27 -0
- package/docs/web/plugin-notify.md +24 -0
- package/docs/web/plugin-ponytail.md +23 -0
- package/docs/web/plugin-setup.md +7 -0
- package/docs/web/plugin-workflow.md +24 -0
- package/docs/web/plugins.md +17 -0
- package/docs/web/project-context.md +9 -0
- package/docs/web/rules.md +8 -0
- package/docs/web/run-anywhere.md +9 -0
- package/docs/web/self-improve.md +8 -0
- package/docs/web/setup-choices.md +39 -0
- package/docs/web/skills.md +8 -0
- package/docs/web/themes-author.md +8 -0
- package/docs/web/themes.md +9 -0
- package/docs/web/token-footprint.md +7 -0
- package/docs/web/trouble.md +38 -0
- package/docs/web/upgrade.md +9 -0
- package/docs/web/verify.md +30 -0
- package/docs/web/wiki.md +7 -0
- package/docs/web/worktree.md +10 -0
- package/docs/web-ui.md +144 -0
- package/docs/wiki.md +43 -0
- package/geneseed +39 -0
- package/geneseed.cmd +34 -0
- package/harness.config.json +6 -0
- package/js/anim.mjs +326 -0
- package/js/catalog.mjs +112 -0
- package/js/checkout.mjs +88 -0
- package/js/cli-table.json +1034 -0
- package/js/cli.mjs +498 -0
- package/js/diff.mjs +384 -0
- package/js/doctor.mjs +1364 -0
- package/js/emit.mjs +1373 -0
- package/js/excludes.mjs +271 -0
- package/js/generate.mjs +379 -0
- package/js/hooks.mjs +1026 -0
- package/js/hosts.mjs +253 -0
- package/js/installs.mjs +378 -0
- package/js/inventory.mjs +175 -0
- package/js/lib/fs.mjs +913 -0
- package/js/lib/proc.mjs +28 -0
- package/js/lib/udiff.mjs +275 -0
- package/js/link.mjs +286 -0
- package/js/mcp.mjs +335 -0
- package/js/memory.mjs +108 -0
- package/js/menu.mjs +118 -0
- package/js/migrate.mjs +251 -0
- package/js/native.mjs +426 -0
- package/js/opencode.mjs +325 -0
- package/js/registry.mjs +110 -0
- package/js/render.mjs +282 -0
- package/js/settings.mjs +924 -0
- package/js/setup.mjs +492 -0
- package/js/status.mjs +281 -0
- package/js/themes.mjs +195 -0
- package/js/tui.mjs +444 -0
- package/js/uninstall.mjs +1137 -0
- package/js/update.mjs +910 -0
- package/js/web/actions.mjs +868 -0
- package/js/web/activity.mjs +284 -0
- package/js/web/api.mjs +873 -0
- package/js/web/docs.mjs +531 -0
- package/js/web/graph.mjs +0 -0
- package/js/web/jobs.mjs +480 -0
- package/js/web/server.mjs +1135 -0
- package/package.json +54 -0
- package/registry.json +474 -0
- package/src/AGENT.md.tmpl +390 -0
- package/src/agents/_template.md +76 -0
- package/src/agents/advocate.md +36 -0
- package/src/agents/architect.md +55 -0
- package/src/agents/developer.md +66 -0
- package/src/agents/docs.md +54 -0
- package/src/agents/empiricist.md +39 -0
- package/src/agents/explorer.md +54 -0
- package/src/agents/framer.md +36 -0
- package/src/agents/historian.md +51 -0
- package/src/agents/operator.md +37 -0
- package/src/agents/pragmatist.md +37 -0
- package/src/agents/reviewer.md +52 -0
- package/src/agents/security.md +57 -0
- package/src/agents/skeptic.md +48 -0
- package/src/agents/steward.md +36 -0
- package/src/agents/tester.md +54 -0
- package/src/agents/user-advocate.md +36 -0
- package/src/agents/visionary.md +36 -0
- package/src/laws/universal.md +387 -0
- package/src/memory/README.md +109 -0
- package/src/memory/gitignore +6 -0
- package/src/modes/README.md +26 -0
- package/src/modes/direct.md +2 -0
- package/src/modes/foreman.md +19 -0
- package/src/notebook/README.md +39 -0
- package/src/notebook/gitignore +7 -0
- package/src/postures/README.md +31 -0
- package/src/postures/artisan.md +14 -0
- package/src/postures/assistant.md +12 -0
- package/src/postures/expert.md +11 -0
- package/src/postures/mentor.md +11 -0
- package/src/postures/peer.md +12 -0
- package/src/skills/_template.md +47 -0
- package/src/skills/brainstorm.md +28 -0
- package/src/skills/clarify.md +28 -0
- package/src/skills/codebase-design.md +44 -0
- package/src/skills/commit.md +48 -0
- package/src/skills/council.md +34 -0
- package/src/skills/crash-course.md +28 -0
- package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
- package/src/skills/daydream/SKILL.md +74 -0
- package/src/skills/daydream/VENDOR.md +25 -0
- package/src/skills/daydream/critic-prompt.md +38 -0
- package/src/skills/daydream/instructions.md +422 -0
- package/src/skills/daydream/synthesizer-prompt.md +41 -0
- package/src/skills/debug.md +37 -0
- package/src/skills/decode.md +28 -0
- package/src/skills/develop.md +29 -0
- package/src/skills/document-project.md +79 -0
- package/src/skills/domain-modeling.md +38 -0
- package/src/skills/drill.md +28 -0
- package/src/skills/feynman.md +26 -0
- package/src/skills/forge-mcp.md +48 -0
- package/src/skills/fresh-eyes.md +29 -0
- package/src/skills/frontend-design.md +58 -0
- package/src/skills/gap-detector.md +27 -0
- package/src/skills/geneseed-code-review.md +33 -0
- package/src/skills/geneseed.md +29 -0
- package/src/skills/git-archaeology.md +47 -0
- package/src/skills/git-rescue.md +68 -0
- package/src/skills/handoff.md +29 -0
- package/src/skills/herdr.md +164 -0
- package/src/skills/ingest.md +49 -0
- package/src/skills/learning-path.md +26 -0
- package/src/skills/migrate.md +45 -0
- package/src/skills/opencode-theme.md +52 -0
- package/src/skills/parallel-agents.md +28 -0
- package/src/skills/pipeline.md +52 -0
- package/src/skills/plan.md +45 -0
- package/src/skills/ponytail.md +29 -0
- package/src/skills/profile.md +58 -0
- package/src/skills/prose.md +42 -0
- package/src/skills/prototype.md +44 -0
- package/src/skills/react-view-transitions/AGENTS.md +955 -0
- package/src/skills/react-view-transitions/README.md +42 -0
- package/src/skills/react-view-transitions/SKILL.md +320 -0
- package/src/skills/react-view-transitions/VENDOR.md +16 -0
- package/src/skills/react-view-transitions/metadata.json +12 -0
- package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
- package/src/skills/react-view-transitions/references/implementation.md +182 -0
- package/src/skills/react-view-transitions/references/nextjs.md +176 -0
- package/src/skills/react-view-transitions/references/patterns.md +262 -0
- package/src/skills/refactor.md +32 -0
- package/src/skills/release.md +43 -0
- package/src/skills/repo-map.md +36 -0
- package/src/skills/research.md +42 -0
- package/src/skills/review-response.md +36 -0
- package/src/skills/roast-me.md +28 -0
- package/src/skills/rule.md +100 -0
- package/src/skills/ship.md +49 -0
- package/src/skills/tdd.md +31 -0
- package/src/skills/tickets.md +44 -0
- package/src/skills/token-report/SKILL.md +76 -0
- package/src/skills/token-report/VENDOR.md +23 -0
- package/src/skills/token-report/scripts/token_report.mjs +1301 -0
- package/src/skills/wayfinder.md +49 -0
- package/src/skills/wiki.md +68 -0
- package/src/skills/workflow.md +38 -0
- package/themes/README.md +69 -0
- package/themes/_TEMPLATE.json +147 -0
- package/themes/biker.json +147 -0
- package/themes/commentator.json +147 -0
- package/themes/cyberpunk.json +147 -0
- package/themes/gamer.json +147 -0
- package/themes/imperial.json +147 -0
- package/themes/joker.json +147 -0
- package/themes/marvin.json +147 -0
- package/themes/mean.json +147 -0
- package/themes/military.json +147 -0
- package/themes/neutral.json +147 -0
- package/themes/opencode/README.md +58 -0
- package/themes/opencode/catppuccin.json +27 -0
- package/themes/opencode/dracula.json +27 -0
- package/themes/opencode/everforest.json +27 -0
- package/themes/opencode/gruvbox.json +27 -0
- package/themes/opencode/kanagawa.json +27 -0
- package/themes/opencode/neon.json +27 -0
- package/themes/opencode/nord.json +27 -0
- package/themes/opencode/rosepine.json +27 -0
- package/themes/opencode/tokyonight.json +27 -0
- package/themes/pirate.json +147 -0
- package/themes/sports.json +147 -0
- package/themes/verstappen.json +147 -0
- package/themes/wizard.json +147 -0
- package/web/dist/assets/About-DX1-VTBP.js +1 -0
- package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
- package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
- package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
- package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
- package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
- package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
- package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
- package/web/dist/assets/Library-BEZscyaQ.js +1 -0
- package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
- package/web/dist/assets/Profile-DydukTxP.js +1 -0
- package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
- package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
- package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
- package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
- package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-B1PIx7RM.js +1 -0
- package/web/dist/assets/index-BnTQUSUz.js +12 -0
- package/web/dist/assets/index-Ck2iCdaC.js +2 -0
- package/web/dist/assets/index-DUCsDv7z.css +1 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/react-DOpiTn5v.js +32 -0
- package/web/dist/assets/roman-DkYN0LX9.js +1 -0
- package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/web/dist/icon-maskable.svg +10 -0
- package/web/dist/icon.svg +8 -0
- package/web/dist/index.html +21 -0
- package/web/dist/manifest.webmanifest +14 -0
- package/web/dist/sw.js +40 -0
- 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
|