@jeffober/thatch 0.1.48 → 0.2.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 (46) hide show
  1. package/README.md +43 -4
  2. package/bin/thatch +31 -16
  3. package/docs/dev/README.md +28 -6
  4. package/docs/dev/features/behavior-engine.md +3 -2
  5. package/docs/dev/features/cicd.md +1 -1
  6. package/docs/dev/features/compaction-recovery.md +1 -1
  7. package/docs/dev/features/cross-session-chat.md +4 -2
  8. package/docs/dev/features/extraction.md +10 -2
  9. package/docs/dev/features/hygiene.md +3 -1
  10. package/docs/dev/features/multi-host.md +4 -2
  11. package/docs/dev/features/notifications.md +121 -4
  12. package/docs/dev/features/nudge-pipeline.md +4 -2
  13. package/docs/dev/features/opencode-plugin.md +133 -0
  14. package/docs/dev/features/qa-system.md +43 -0
  15. package/docs/dev/features/repo-identity.md +1 -1
  16. package/docs/dev/features/session-lifecycle.md +5 -2
  17. package/docs/dev/features/watchers.md +12 -5
  18. package/docs/dev/gotchas.md +36 -3
  19. package/docs/dev/mcp-parity.md +4 -3
  20. package/docs/dev/setup-and-hooks.md +6 -1
  21. package/docs/plans/llm-alerts.md +287 -0
  22. package/docs/plans/prediction-consolidation.md +3 -3
  23. package/docs/user/README.md +7 -3
  24. package/docs/user/notifications.md +63 -4
  25. package/docs/user/setup.md +4 -0
  26. package/docs/user/watchers.md +15 -7
  27. package/package.json +3 -1
  28. package/src/alerts.ts +360 -0
  29. package/src/capabilities.ts +174 -0
  30. package/src/chat.ts +63 -5
  31. package/src/commands.ts +44 -5
  32. package/src/config.ts +32 -1
  33. package/src/db.ts +80 -0
  34. package/src/embeddings.ts +43 -0
  35. package/src/extraction.ts +124 -0
  36. package/src/index.ts +38 -1345
  37. package/src/opencode/v1.ts +54 -0
  38. package/src/opencode/v2.ts +561 -0
  39. package/src/os-args.ts +99 -0
  40. package/src/prompts.ts +77 -1
  41. package/src/runtime.ts +1815 -0
  42. package/src/seed-behaviors.ts +1 -1
  43. package/src/session-db.ts +32 -0
  44. package/src/tool-defs.ts +185 -19
  45. package/src/tools.ts +16 -35
  46. package/src/watchers.ts +95 -14
@@ -0,0 +1,133 @@
1
+ # Feature: OpenCode plugin dual-adapter (v1 + v2)
2
+
3
+ The opencode plugin supports both opencode lines from one npm package: the
4
+ 1.18.x plugin API and the 2.x plugin API. The user's setup (shim file, config
5
+ key, DB, skills) is identical under either host.
6
+
7
+ ## Architecture
8
+
9
+ ```text
10
+ src/index.ts dual entry -- merged default export { id, setup, server }
11
+ │ opencode v1 reads default.server; v2 reads
12
+ │ default.setup and strips excess keys. Both loaders
13
+ │ verified against tagged upstream source.
14
+ ├── (lazy) → src/opencode/v1.ts v1 adapter (@opencode-ai/plugin)
15
+ └── (lazy) → src/opencode/v2.ts v2 adapter (@opencode/plugin)
16
+ both delegate to:
17
+ src/runtime.ts shared runtime (nudges, system prompt,
18
+ session events, extraction, bookkeeping)
19
+ ↑ consumes
20
+ src/capabilities.ts HostCapabilities seam
21
+ ```
22
+
23
+ ### The isolation rule
24
+
25
+ `src/index.ts`'s static import graph must stay SDK-free. Each adapter
26
+ imports its host's SDK (`@opencode-ai/plugin` vs `@opencode/plugin`), and the
27
+ SDKs resolve only under their own host's install, so the entry reaches both
28
+ adapters exclusively through dynamic `import()`. A top-level await would make
29
+ either host's load block on (or fail from) the other host's adapter. The pure
30
+ helpers (`osProcessArgs`, `startupSessionId`, ...) live in `src/os-args.ts`,
31
+ which is safe to re-export statically.
32
+
33
+ ### The export shape
34
+
35
+ Upstream loaders decide everything:
36
+
37
+ - **v1** (`packages/opencode/src/plugin/shared.ts`, `readV1Plugin`, identical
38
+ across 1.18.0-1.18.32): reads only `mod.default`; uses `default.server` if
39
+ present and never reaches the named-export ("legacy") fallback. Excess keys
40
+ (like `setup`) are invisible to it. A default export with `id` but no
41
+ `server` throws and the host SILENTLY SKIPS the plugin (error-publish is
42
+ commented out in the upstream caller).
43
+ - **v2** (`packages/core/src/plugin/module.ts` @ v2.0.9): decodes `default`
44
+ as `{ id, effect }` or `{ id, setup }` with Effect Schema; excess keys
45
+ (like `server`) are tolerated and stripped (verified empirically at
46
+ effect@4.0.0-rc.112, the version v2 pins).
47
+
48
+ So the merged object `{ id, setup, server }` satisfies both. A module with a
49
+ named `server` export plus a v2-shaped default does NOT work on v1 -- that
50
+ shape throws in `readV1Plugin`.
51
+
52
+ ## The capability seam
53
+
54
+ `HostCapabilities` (src/capabilities.ts) is the interface of host operations
55
+ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
56
+ (src/tool-defs.ts, the per-tool-call seam); they stay separate on purpose.
57
+
58
+ | Capability | v1 source | v2 source | v2 strategy |
59
+ |---|---|---|---|
60
+ | bus events | `event` hook | `event.subscribe` (raw SSE) | client-side location filter; location-less events resolve via `session.get` (`{sessionID}` input) and drop unresolvable (mirrors v1's server-side filter); events for plugin-created extraction children pass by ID (they live in the project directory, which differs from the instance directory on below-root launches) |
61
+ | incoming message | `chat.message` hook | `session.hook("prompt")` | the hook awaits the runtime; nudge injections append to the OUTBOUND request's last user message in the generate hook (v2 wire `Message` carries parts in `content`, not `parts`) |
62
+ | system prompt | `experimental.chat.system.transform` | `session.hook("context")` | mutates the request's system array; the runtime's plain strings convert to `{type: "text", text}` SystemPart objects at the boundary |
63
+ | compaction | `experimental.session.compacting` + `.autocontinue` + `session.compacted` | `session.hook("compaction")` | flag lands; context-injection surface unverified |
64
+ | wrap-up commands | `command.execute.before` + command files | `command.transform` + `CommandEditor.add` | registered in code: `execute` arms the greenlight check (`onCommandExecuteBefore`) and delivers the same prompt body the v1 file carries, substituting the invocation's prompt text for `$ARGUMENTS` (v2's `session.prompt` does no template expansion); the runtime installs only ACTION command files on v2 and REMOVES stale wrap-up files from earlier v1 runs (a file and a registered command with the same name would collide) |
65
+ | tools | `hooks.tool` map via `tool()` | `tool.transform` + `ToolEditor.add` | zod shapes pre-converted to JSON Schema with our own zod (v2's `instanceof $ZodType` detection fails for ours and drops the schema); results wrap as `{ content }` |
66
+ | tool buffering | `tool.execute.after` hook | `tool.hook("execute.after")` | wired: feeds the same extraction buffer; `Tool.Result.content` is flattened (string passes through, content-part arrays contribute their text parts); the title is derived from the tool name and args (`deriveTitle`), since v2's `Tool.Result.output` is a typed output value, not a title |
67
+ | noReply deliveries | `promptAsync` with `noReply` | none | gated off via `HostCapabilities.noReplyDelivery` -- chat echoes and the session-start reminder are skipped on v2 (delivering them would start real model turns: a feedback loop) |
68
+ | synthetic wake deliveries | `promptAsync` with `synthetic` parts | `session.synthetic` endpoint | watcher + chat wake nudges route to v2's synthetic endpoint (TUI-hidden), matching v1 |
69
+ | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete degrades (extraction children are not cleaned up on v2 -- the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
70
+ | session status | `client.session.status` | none | returns `{}`; the wake gate treats unknown as idle and the event-fed status map does the gating |
71
+ | session messages | `client.session.messages` | `session.context` | mapped into the v1 `{info: {role}, parts}` shape, so the wrap-up greenlight check works (the promise domain has no `message` accessor) |
72
+ | session list | `client.session.list` | none | degrade (`-c` resume listing loses its data source) |
73
+ | compaction trigger | `client.tui.executeCommand("session_compact")` | none | degrade (no `session.compact` on the promise domain's Pick, and the built-in `/compact` is a TUI palette action calling the server endpoint directly - the server command registry only knows config/plugin-registered names; the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
+ | toasts | `client.tui.showToast` | none reachable | degrade (the `tui.toast.show` event has no producer surface from the promise context; a `tui` companion plugin entrypoint is the known lift candidate) |
75
+ | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
76
+ | tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
77
+ | wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
78
+ | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
79
+
80
+ ## Live verification and remaining unknowns
81
+
82
+ The v2 adapter was written against tagged source (v2.0.9) before a live
83
+ binary was available, then verified against a real v2.0.15 binary on
84
+ 2026-09-23: merged-default loading, tool schemas (via pre-converted JSON
85
+ Schema - see the gotcha), event shapes, the execution-lifecycle idle signal,
86
+ synthetic wake delivery, and command registration all work; the full QA
87
+ suite runs green against a v2 serve.
88
+
89
+ Still unverified against the live binary (static analysis only):
90
+
91
+ - Whether `session.hook("prompt")` fires for `session.synthetic` inbox
92
+ items. If it does not, `pendingInjections` from the prior real turn gets
93
+ appended to the synthetic turn's outbound request.
94
+ - Whether `SessionGenerate.messages` are fresh objects per model call within
95
+ a turn. If not, the generate hook pushes duplicate nudges on every tool
96
+ round trip.
97
+ - An npm-installed copy (not the dev checkout, which has devDependencies
98
+ present) loading under v2 - the isolation rule's real-world proof.
99
+
100
+ The app-exit gap (no server-side publish surface for `tui.command.execute`)
101
+ is upstream:
102
+ [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984).
103
+ Install opencode-v2 (brew formula `anomalyco/tap/opencode-v2`; conflicts
104
+ with the v1 formula's binary name, so it cannot coexist) and re-run the QA
105
+ live suite against it.
106
+
107
+ ## User setup compatibility
108
+
109
+ No config file moves or changes format. The DB, model cache, version-check
110
+ files, and skills dirs are host-agnostic and shared. opencode's own config
111
+ key (`plugin` vs `plugins`) is auto-migrated by v2. The only user-owned file
112
+ with a content change is the dev-session shim, and its new dual-shape content
113
+ loads under both hosts.
114
+
115
+ ## Source files
116
+
117
+ - `src/index.ts` -- dual entry, merged default export, lazy wrappers
118
+ - `src/opencode/v1.ts` -- v1 hooks adapter
119
+ - `src/opencode/v2.ts` -- v2 promise-context adapter + capability impl
120
+ - `src/capabilities.ts` -- `HostCapabilities`, `capabilitiesFromClient`
121
+ - `src/runtime.ts` -- the shared runtime (all behavioral logic)
122
+ - `src/os-args.ts` -- pure argv helpers
123
+ - `tests/opencode-v2.test.ts` -- v2 adapter contract tests (mocked context)
124
+ - `tests/plugin.test.ts` -- v1 adapter contract tests (mocked client)
125
+
126
+ ## Interactions with other features
127
+
128
+ Everything downstream of the hooks is unchanged by the dual adapter:
129
+ [extraction.md](extraction.md), [nudge-pipeline.md](nudge-pipeline.md),
130
+ [session-lifecycle.md](session-lifecycle.md),
131
+ [cross-session-chat.md](cross-session-chat.md),
132
+ [watchers.md](watchers.md), [notifications.md](notifications.md). The MCP
133
+ path ([mcp-parity.md](../mcp-parity.md)) does not touch any of this.
@@ -96,6 +96,49 @@ opencode session timeout, compaction trigger). Run them locally with
96
96
  | `qa-auto` | `bin/qa-run auto` | Run automatable only (fast, no LLM). Pass UC names as args to select a subset. |
97
97
  | `qa-live` | `bin/qa-run live` | Run live only (spawns opencode, costs tokens). Pass UC names as args to select a subset. |
98
98
 
99
+ ### The opencode version matrix (default behavior)
100
+
101
+ Every task that runs opencode-driven use cases runs one leg per
102
+ DISCOVERED opencode install, with test names suffixed
103
+ (`UC-001-memory-roundtrip [v1]`, `UC-001-memory-roundtrip [v2]`) when
104
+ there are several. There is no separate matrix command - the ordinary
105
+ tasks just do the matrix based on what is available.
106
+
107
+ Discovery (tests/qa/binaries.ts) asks the package manager where its
108
+ formulas are installed (`brew --prefix opencode`, `brew --prefix
109
+ opencode-v2`), falls back to the PATH-resolved binary, validates each
110
+ candidate by running `--version`, tags it by major (`v1`, `v2` - the
111
+ version output decides, not the formula name), and dedups candidates
112
+ that resolve to the same binary. A machine with one install runs
113
+ single-leg with unsuffixed names, so single-binary setups behave
114
+ exactly as they did before the matrix existed.
115
+
116
+ A use case opts into or restricts the matrix with `hosts: ["v1"]` /
117
+ `["v2"]` / `["v1", "v2"]` on the `UseCase`:
118
+
119
+ - Live use cases (no custom `run`) are matrixed by default - the default
120
+ `runViaOpencode` path spawns opencode by definition.
121
+ - Automatable use cases keep a single leg unless they declare `hosts` -
122
+ the fast suite is not doubled for cases that never touch opencode.
123
+ Declare `hosts` when a custom `run` spawns opencode itself (UC-098) or
124
+ drives a serve (UC-100).
125
+
126
+ `QA_HOSTS=v1` (comma-separated) narrows the legs when iterating against
127
+ one major; a value matching no discovered install registers nothing
128
+ with an explanatory line.
129
+
130
+ Legs get separate fixtures (the fixture directory is keyed by test name
131
+ suffix) because hosts cannot share one: a v2 session db in a shared
132
+ fixture fails the v1 leg's serve with "Database is not empty and has no
133
+ session table".
134
+
135
+ The v1 and v2 `run` CLIs disagree on flags (v1 has `--dir`, which v2
136
+ dropped; v2 needs `--standalone` to skip the background daemon, which a
137
+ fixture sandbox must not boot). `opencodeRunArgs(ctx, prompt)` in the
138
+ runner builds the right invocation per leg by version-detecting the
139
+ binary the leg's PATH selects; `runViaOpencode` and UC-098's custom runs
140
+ go through it.
141
+
99
142
  The `qa` task runs auto first via `&&`. Auto failures stop before live runs.
100
143
  The `qa-live` task does not use `--concurrent` — live sessions are
101
144
  resource-heavy and benefit from sequential execution.
@@ -104,7 +104,7 @@ are per-worktree -- so git reads are equivalent from either. Consumers:
104
104
 
105
105
  - `src/git.ts` -- resolution chain, cache validation, spawn-cwd fallback
106
106
  - `src/db.ts` -- `repo_paths` table, accessors, `repoPathCache` adapter
107
- - `src/index.ts` -- plugin init order (DB before detection), identity from
107
+ - `src/runtime.ts` -- plugin init order (DB before detection), identity from
108
108
  the session directory, watcher runner wrapper
109
109
  - `src/mcp.ts` -- MCP server init order
110
110
  - `bin/thatch` -- CLI `defaultStore()` and hook-line identity resolution
@@ -1,5 +1,7 @@
1
1
  # Session Lifecycle Management (opencode)
2
2
 
3
+ Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
4
+
3
5
  opencode emits bus events for session lifecycle changes. Thatch subscribes to these events to manage the extraction pipeline, send session-start reminders, handle child session cleanup, and resolve wrap-up commands.
4
6
 
5
7
  ## What it does
@@ -15,7 +17,7 @@ opencode emits bus events for session lifecycle changes. Thatch subscribes to th
15
17
 
16
18
  ## How it works
17
19
 
18
- ### Event handler (`src/index.ts`, event hook)
20
+ ### Event handler (`src/runtime.ts`, shared by both adapters)
19
21
 
20
22
  Subscribes to all session bus events. Dispatches based on `event.type`.
21
23
 
@@ -122,7 +124,8 @@ first-ever install is invisible until the next server start.
122
124
 
123
125
  ## Source files
124
126
 
125
- - `src/index.ts` -- event handler (all session events), `triggerExtraction`, `cleanupChild`, internal state maps
127
+ - `src/runtime.ts` -- event handler (all session events), `triggerExtraction`, `cleanupChild`, internal state maps
128
+ - `src/opencode/v1.ts`, `src/opencode/v2.ts` -- host adapters wiring the runtime into each opencode plugin API
126
129
 
127
130
  ## Key invariants
128
131
 
@@ -1,5 +1,7 @@
1
1
  # Watchers
2
2
 
3
+ Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
4
+
3
5
  Watchers are the general mechanism for event-driven notifications from
4
6
  external sources: the model registers a watch on something outside the
5
7
  session, thatch polls it in the background, and the plugin prompts the
@@ -42,10 +44,15 @@ The registry lives in plugin memory, never in SQLite. Three reasons:
42
44
  in-memory; the file-backed queue exists only for the MCP path's
43
45
  separate short-lived hook processes.
44
46
 
45
- The cost: a restart loses watches. Accepted because the session that
46
- created them loses its conversational context too - a notification
47
- into a session that no longer knows why it is being watched is worse
48
- than a lost watch.
47
+ The cost used to be "a restart loses watches", accepted because the
48
+ session that created them loses its conversational context too - a
49
+ notification into a session that no longer knows why it is being
50
+ watched is worse than a lost watch. The dormant-recovery layer keeps
51
+ the best of both: the definition survives the restart (journaled),
52
+ nothing polls or delivers while its session is away, and resuming the
53
+ session re-arms the watches into a session that again knows why it is
54
+ being watched. See the `runtime_state` row in
55
+ `docs/dev/features/opencode-plugin.md` for the journal mechanics.
49
56
 
50
57
  `session.deleted` also cancels the session's watchers and pending
51
58
  events, and `dispose` stops the poller. These are cheap insurance;
@@ -221,7 +228,7 @@ src/watchers.ts, test-enforced); command watchers register the single
221
228
  - `src/watchers.ts` - registry, poller, gh CLI runner, command runner,
222
229
  diff functions
223
230
  - `src/tool-defs.ts` - the watch tool definitions (see `TOOL_DEFS`)
224
- - `src/index.ts` - registry construction, delivery closure,
231
+ - `src/runtime.ts` - registry construction, delivery closure,
225
232
  session.status tracking, session.deleted cleanup, dispose
226
233
  - `src/prompts.ts` - `watcherNotificationNudge()`, system prompt
227
234
  Watchers section
@@ -70,7 +70,7 @@ here first. These are the things that have already cost time.
70
70
  - **`chat.message` has two priority tiers**: extraction nudge first (returns
71
71
  early), then the prompt-aware recall nudge. Don't run both in one turn.
72
72
  - **The extraction nudge peeks, never flushes.** The buffer is NOT drained
73
- on nudge delivery (`index.ts:366` — `peek()` call). It persists until the
73
+ on nudge delivery (`extraction.peek()` in `triggerExtraction`, src/runtime.ts). It persists until the
74
74
  agent writes a memory or calls `thatch_extraction_done`. Ignored nudges
75
75
  accumulate; the `missedNudges` counter escalates the tone (polite at 0-1
76
76
  misses, insistent at 2, ALL-CAPS at 3+). The counter resets when the
@@ -80,8 +80,8 @@ here first. These are the things that have already cost time.
80
80
  is active. The peek-not-drain semantics still hold for the fallback nudge
81
81
  path (MCP hosts and any `triggerExtraction` failure).
82
82
  - **A child sub-agent's `thatch_memory_remember` drains the parent's buffer**
83
- via the `childToParent` Map (`index.ts:79` declaration, `index.ts:272`
84
- lookup). Two paths reach this machinery: (a) the **plugin-initiated child
83
+ via the `childToParent` Map in src/runtime.ts (declaration plus `.get()`
84
+ lookups in the idle and remember handlers). Two paths reach this machinery: (a) the **plugin-initiated child
85
85
  session** — `triggerExtraction` calls `client.session.create` with a
86
86
  `parentID`, the primary opencode path; (b) the **agent-initiated background
87
87
  task** — the model dispatches the fact-extractor via the `task` tool after
@@ -196,3 +196,36 @@ prose end marker stopped matching, and setup could never update the block
196
196
  again while `checkSetup` reported `markers-broken` on every session. Rule:
197
197
  never use file content as its own delimiter; agents will reword it. The
198
198
  legacy-prose detection constants exist only to migrate old installs.
199
+
200
+ ## The dual-shape plugin entry: named `server` + v2 default export silently breaks v1
201
+
202
+ opencode v1's plugin loader (`readV1Plugin`, identical across 1.18.x) reads
203
+ ONLY `mod.default`. A module that exports a named `server` plus a v2-shaped
204
+ default `{ id, setup }` (no `server` inside the default) makes v1 throw on
205
+ load - and the host swallows the error and SKIPS the plugin with no visible
206
+ message. The failure looks like "thatch tools disappeared", not like a load
207
+ error. This is why the dual entry exports a MERGED default object
208
+ (`{ id, setup, server }`) and why every shim (the user's
209
+ `~/.config/opencode/plugins/thatch.ts` and the QA runner's generated one)
210
+ must re-export the default as well as the name. A named-only shim loads on
211
+ v1 and silently disables thatch on v2 - same invisible failure, opposite
212
+ host.
213
+
214
+ ## The extraction buffer matches on tool NAME, so Code Mode execute calls re-queue pipeline traffic
215
+
216
+ The non-bufferable filter in `onToolExecuteAfter` (`src/runtime.ts`) matches
217
+ `input.tool`, but a Code Mode `execute` call that wraps
218
+ `tools.thatch_extraction_done` / `tools.thatch_memory_remember` inside its
219
+ code string arrives as tool name `execute`. Unfixed, every execute-wrapped
220
+ ack landed back in the buffer as an extractable interaction: each
221
+ extraction run's payload contained only the previous run's dispatch and ack,
222
+ the nudge always found pending interactions, and a session could loop
223
+ forever on dispatch → ack → nudge (observed live in an oink session,
224
+ September 2026 - the looped agent answered every turn with "Extractor
225
+ dispatched and acknowledged. Stopping per the nudge" and never resumed the
226
+ user's work; only a human message broke the cycle). The fix unwraps execute
227
+ calls (`unwrapExecuteThatchCalls` in `src/extraction.ts`) and runs the
228
+ wrapped tool's hook semantics instead of buffering. Lesson for sibling
229
+ hooks: when a filter reasons about tool identity, an aggregator tool
230
+ (arbitrary code execution over other tools) defeats name-based matching -
231
+ unwrap the aggregation before classifying.
@@ -24,8 +24,9 @@ This document maps the feature parity and documents the remaining gaps.
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
25
25
  | **Wrap-up commands** (`/thatch/compact`, `/thatch/exit`) | Command markdown synced to `$XDG_CONFIG_HOME/opencode/command/thatch/` at plugin init; `command.execute.before` arms the session and the next idle resolves the greenlight token into a TUI action (`session.compact` via `executeCommand`, `app.exit` via `publish`) | No equivalent — no plugin host to arm the check or trigger the action | No equivalent | **Gapped** — needs the plugin's SDK client and TUI control routes |
26
26
  | **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
27
- | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-thatch, non-skill, non-task tool calls in-process; **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
27
+ | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-meta tool calls in-process (`isMetaToolName` in `src/extraction.ts`: thatch_*, skill, task, agent, subagent — execute calls unwrapping only thatch tools are also excluded); **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
28
28
  | **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
29
+ | **Automatic alerts** (notify on LLM pause / turn end) | Plugin event hook drives the `alerts.ts` state machine: `question.asked`/`permission.asked` → pause alert; `session.status` idle with a real-work round → done alert; unrecovered errors → needs-attention. Delivered via the shared `notify.ts` dispatcher, channel per event from the `alerts` config section | No event stream — only `thatch_notify_user` when the agent chooses | No event stream — only `thatch_notify_user` when the agent chooses | **Gapped** — MCP hosts have no plugin event hook to watch; Claude Code's `Notification`/`Stop` hooks could feed the same classifier later |
29
30
  | **Prompt-aware recall nudge** | `chat.message` hook embeds the prompt with the in-process warm model, searches `db.search()`, pushes a nudge part if matches ≥ threshold | `UserPromptSubmit` hook → `thatch flush-tools` connects to the MCP server's sideband socket; the warm server embeds + searches; hook prints the nudge or falls back to the write nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — sideband socket gives cold hook processes access to the warm MCP server's model |
30
31
  | **Prediction auto-fire** (user decision model) | `chat.message` hook scores the prompt embedding against prediction matchers; injects a `[thatch] User decision model` nudge alongside the recall nudge (separate synthetic part, same embedding call) | `UserPromptSubmit` hook → `thatch flush-tools` fires prediction query via the sideband socket's `predictions` method in parallel with the recall nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — same `scorePredictionNudge` entry point in both paths |
31
32
  | **Behavior auto-fire** (self-discipline rules) | `chat.message` hook scores the prompt embedding against behavior matchers; injects a `[thatch] Situational behaviors` nudge (separate synthetic part, same embedding call) | `UserPromptSubmit` hook → `thatch flush-tools` fires behavior query via the sideband socket's `behaviors` method in parallel with recall and predictions | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — same `scoreBehaviorNudge` entry point in both paths |
@@ -72,8 +73,8 @@ and the nudge (fallback):
72
73
 
73
74
  **Direct extraction (primary path):**
74
75
 
75
- 1. `tool.execute.after` buffers every non-thatch, non-skill, non-task tool
76
- call into a per-session in-memory array (max 20 interactions).
76
+ 1. `tool.execute.after` buffers every non-meta tool
77
+ call (`isMetaToolName` in `src/extraction.ts`) into a per-session in-memory array (max 20 interactions).
77
78
  2. When the parent session goes idle (`session.status` idle event) with pending
78
79
  buffer entries, the plugin calls `triggerExtraction`: creates a child session
79
80
  via `client.session.create` (with `parentID`), then prompts it via
@@ -12,7 +12,12 @@ No `setup` command — opencode loads the plugin from `opencode.json`:
12
12
  { "plugin": ["@jeffober/thatch"] }
13
13
  ```
14
14
 
15
- The plugin entry (`src/index.ts`) registers these hooks in code:
15
+ The plugin supports both opencode lines: v1 (1.18.x) via the `experimental.*`
16
+ hook surface below, and v2 (2.x) via the promise-context domains
17
+ (`session.hook("prompt"/"context"/"compaction")`, `tool.transform`). Both
18
+ adapters delegate to the shared runtime (`src/runtime.ts`); the hooks below
19
+ describe the v1 shape the v2 mappings mirror. The v1 hooks, registered in
20
+ code (`src/opencode/v1.ts`):
16
21
 
17
22
  | Hook | Input | Output | Behavior |
18
23
  |------|-------|--------|----------|