@jeffober/thatch 0.1.47 → 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.
- package/README.md +43 -4
- package/artifacts/skills/thatch-plan-refinement.md +2 -0
- package/bin/thatch +31 -16
- package/docs/dev/README.md +28 -6
- package/docs/dev/features/behavior-engine.md +3 -2
- package/docs/dev/features/cicd.md +1 -1
- package/docs/dev/features/compaction-recovery.md +1 -1
- package/docs/dev/features/cross-session-chat.md +4 -2
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/multi-host.md +4 -2
- package/docs/dev/features/notifications.md +121 -4
- package/docs/dev/features/nudge-pipeline.md +4 -2
- package/docs/dev/features/opencode-plugin.md +133 -0
- package/docs/dev/features/qa-system.md +43 -0
- package/docs/dev/features/repo-identity.md +1 -1
- package/docs/dev/features/session-lifecycle.md +5 -2
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +36 -3
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +6 -1
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +7 -3
- package/docs/user/notifications.md +63 -4
- package/docs/user/setup.md +4 -0
- package/docs/user/watchers.md +15 -7
- package/package.json +3 -1
- package/src/alerts.ts +360 -0
- package/src/capabilities.ts +174 -0
- package/src/chat.ts +63 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +80 -0
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +124 -0
- package/src/index.ts +38 -1345
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +561 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +77 -1
- package/src/runtime.ts +1815 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +185 -19
- package/src/tools.ts +16 -35
- 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/
|
|
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/
|
|
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/
|
|
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
|
|
46
|
-
created them loses its conversational context too - a
|
|
47
|
-
into a session that no longer knows why it is being
|
|
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/
|
|
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
|
package/docs/dev/gotchas.md
CHANGED
|
@@ -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 (`
|
|
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
|
|
84
|
-
|
|
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.
|
package/docs/dev/mcp-parity.md
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
|
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
|
|------|-------|--------|----------|
|