@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
package/README.md CHANGED
@@ -21,7 +21,10 @@ everything runs on your machine.
21
21
  ```
22
22
 
23
23
  On next start, OpenCode npm-installs thatch and its tools are available
24
- immediately. For async extraction (child sessions run in the background):
24
+ immediately. Works with both opencode 1.x and 2.x (the same package supports
25
+ both plugin APIs). Until the next release ships, npm's `latest` still
26
+ targets 1.x only. For async extraction (child sessions run in the
27
+ background):
25
28
 
26
29
  ```bash
27
30
  export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
@@ -30,6 +33,39 @@ export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
30
33
  Without this env var, extraction still works - the child session runs
31
34
  synchronously (fire-and-forget) instead of asynchronously.
32
35
 
36
+ ### What works in opencode 2.x
37
+
38
+ The v2 plugin API is missing a few surfaces the v1 API had. On 2.x:
39
+
40
+ - **No toasts.** Toasts are dropped on v2 (no publish surface). The
41
+ model-facing nudges they accompanied - watcher and chat wake nudges -
42
+ still arrive; the extraction-result and other TUI-only toasts have no
43
+ replacement.
44
+ - **Plugin state survives reloads.** Editing the plugin (or `opencode
45
+ plugin update`) reloads it, but extraction buffers, watcher
46
+ registrations, and armed wrap-ups are journaled and restored; a process
47
+ restart restores the state of a resumed (`-c`/`-s`) session. Other
48
+ sessions' watcher registrations survive as dormant definitions: resuming
49
+ such a session re-arms its watches on the first message (expired ones
50
+ are dropped and reported), and a live session in the same project gets a
51
+ one-line notice that a dead session's watch will re-arm if resumed.
52
+ - **Extraction children are visible.** The fact-extractor child sessions are
53
+ top-level sessions the host cannot delete, so they accumulate in the
54
+ session picker, and "continue last session" (`-c`) can land in one after
55
+ any session that triggered extraction.
56
+ - **`/thatch/compact` does not auto-compact.** The checklist and memory
57
+ flush run, but the compaction itself cannot be triggered from the plugin
58
+ API - run `/compact` yourself after the wrap-up completes. `/thatch/exit`
59
+ cannot auto-exit for the same reason.
60
+ - **No `-c` session listing.** The chat resume listing degrades.
61
+
62
+ Everything else - tools, memory, nudges, chat, watchers - behaves the
63
+ same on both versions, with one caveat: v2 has no turn-free delivery
64
+ (noReply), so the features that lean on it - the chat transcript echo
65
+ bubble, the session-start reminder, and the post-reload re-attach
66
+ notice - do not render on 2.x. Their model-facing content still
67
+ arrives; only the bubble rendering is lost.
68
+
33
69
  Then **prime your project memory** by running `thatch prime` in your project directory.
34
70
  This launches an `opencode` session to build an initial map of the code base
35
71
  and seed the memory.
@@ -169,10 +205,13 @@ Thatch gives your agent:
169
205
  the agent lists them and nothing fires -- you decide when to retry.
170
206
  - **Notifications + user config** -- the agent can ping you out-of-band when a
171
207
  long-running outcome lands: a desktop banner, a spoken voice
172
- announcement, or both (macOS and Linux). Preferences live in a
208
+ announcement, or both (macOS and Linux). On opencode, thatch also watches
209
+ the session and alerts you automatically when the LLM pauses for your
210
+ input (a question or permission prompt) or finishes a round of real work
211
+ -- bookkeeping rounds stay silent. Preferences live in a
173
212
  hand-editable config file (`~/.config/thatch/config.json`) that the agent
174
- manages through `config_get`/`config_set` -- ask it to change your voice
175
- or quiet notifications entirely.
213
+ manages through `config_get`/`config_set` -- ask it to change your voice,
214
+ quiet notifications entirely, or tune which alerts fire.
176
215
 
177
216
  Plus **skills** for memory workflows, structured multi-specialist code
178
217
  review, review response, plan refinement, change and feature walkthroughs,
package/bin/thatch CHANGED
@@ -49,11 +49,13 @@ function usage(): never {
49
49
  Full conversation as OpenAI chat-completions JSONL
50
50
  thatch session search <query> [--session <id>] [--regex] [--limit N]
51
51
  JSONL of matching parts across sessions (decoded content)
52
- thatch chat list [--stale N|all] Registered chat sessions, grouped Active/Stale:
53
- name, age, status, project, topic.
54
- --stale N (days) hides stale sessions
55
- older than N; default 1. --stale all
56
- shows every stale session.
52
+ thatch chat list [--stale N|all] Registered chat sessions. Default shows
53
+ only active (fresh-heartbeat) sessions:
54
+ name, session id, age, status, project,
55
+ topic. --stale adds the stale section:
56
+ N bounds it to sessions seen in the
57
+ last N days (default 1); all shows
58
+ every stale session.
57
59
  thatch chat tail [--once] [--limit N|all] [--match RE]
58
60
  [--from NAME] [--to NAME] [--since DT] [--until DT]
59
61
  Follow cross-session chat as a JSONL
@@ -415,7 +417,7 @@ async function sessionCommand(rest: string[]): Promise<void> {
415
417
  // ---------------------------------------------------------------------------
416
418
 
417
419
  /** Column headers for the `chat list` roster, in display order. */
418
- const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
420
+ const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "TOPIC"];
419
421
 
420
422
  /**
421
423
  * Renders the `chat list` roster as two sections - Active and Stale - each
@@ -436,6 +438,7 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
436
438
  const renderTable = (rows: ChatSessionRow[]) => {
437
439
  const cells = (r: ChatSessionRow) => [
438
440
  r.name,
441
+ r.session_id,
439
442
  humanAge(r.last_seen),
440
443
  chatLiveness(r),
441
444
  r.project ?? "unknown",
@@ -447,9 +450,10 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
447
450
  const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
448
451
  const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
449
452
  const body = rows.map((r) => {
450
- const [name, age, status, project, topic] = align(cells(r));
453
+ const [name, sessionID, age, status, project, topic] = align(cells(r));
451
454
  return [
452
455
  paint("32", name),
456
+ paint("2", sessionID),
453
457
  paint("2", age),
454
458
  paint(status === "stale" ? "31" : "32", status),
455
459
  project,
@@ -491,21 +495,32 @@ async function chatCommand(rest: string[]): Promise<void> {
491
495
  // sessions from burying the fresh signal. --stale all restores the
492
496
  // unbounded view. A bad value dies loudly rather than silently
493
497
  // shrinking the roster.
494
- const staleArg = flagValue(restArgs, "--stale");
498
+ // Default: active sessions only. --stale [N|all] opts into the
499
+ // stale section - bare --stale keeps the one-day display cap, N
500
+ // bounds it, all removes it. The value is parsed by hand (not
501
+ // flagValue) because bare --stale is meaningful.
502
+ const showStale = restArgs.includes("--stale");
503
+ const staleIdx = restArgs.indexOf("--stale");
504
+ const staleNext = showStale ? restArgs[staleIdx + 1] : undefined;
495
505
  let staleCapMs: number | null = 24 * 60 * 60 * 1000;
496
- if (staleArg !== undefined) {
497
- if (staleArg === "all") staleCapMs = null;
498
- else if (!/^\d+(\.\d+)?$/.test(staleArg) || Number(staleArg) <= 0) {
499
- console.error(`error: --stale expects a positive number of days or "all", got "${staleArg}"`);
506
+ if (staleNext !== undefined) {
507
+ if (staleNext === "all") staleCapMs = null;
508
+ else if (!/^\d+(\.\d+)?$/.test(staleNext) || Number(staleNext) <= 0) {
509
+ console.error(`error: --stale expects a positive number of days or "all", got "${staleNext}"`);
500
510
  process.exit(1);
501
- } else staleCapMs = Number(staleArg) * 24 * 60 * 60 * 1000;
511
+ } else staleCapMs = Number(staleNext) * 24 * 60 * 60 * 1000;
502
512
  }
503
- const sessions = db.listChatSessions();
513
+ let sessions = db.listChatSessions();
514
+ if (!showStale) sessions = sessions.filter((r) => chatLiveness(r) === "fresh");
504
515
  if (sessions.length === 0) {
505
- console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
516
+ console.log(
517
+ showStale
518
+ ? "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register."
519
+ : "No active sessions. (Stale sessions are hidden; run with --stale to include them.)",
520
+ );
506
521
  break;
507
522
  }
508
- console.log(formatChatRoster(sessions, staleCapMs));
523
+ console.log(formatChatRoster(sessions, showStale ? staleCapMs : null));
509
524
  break;
510
525
  }
511
526
 
@@ -1,5 +1,7 @@
1
1
  # Development
2
2
 
3
+ Feature docs describe behavior in opencode v1 `client.*` terms; the opencode v2 equivalents and degrades are catalogued in [features/opencode-plugin.md](features/opencode-plugin.md).
4
+
3
5
  ## Architecture
4
6
 
5
7
  Thatch has three integration paths sharing a common core:
@@ -38,8 +40,21 @@ Shared core
38
40
  └── vector-math.ts → blobToVector + cosineSimilarity helpers
39
41
 
40
42
  OpenCode plugin path
41
- ├── index.ts → plugin entry: wires DB/model/extraction, registers tools + hooks
42
- ├── tools.ts → thin opencode tool() wrappers over tool-defs
43
+ ├── index.ts → dual-shape plugin entry: merged default export
44
+ │ ({id, setup, server}) loads on opencode v1 AND v2;
45
+ │ lazy-imports the adapter matching the host
46
+ ├── opencode/v1.ts → v1 adapter: @opencode-ai/plugin hooks object
47
+ ├── opencode/v2.ts → v2 adapter: @opencode/plugin promise-context domains
48
+ ├── capabilities.ts → HostCapabilities seam: the host operations the
49
+ │ runtime needs; capabilitiesFromClient bridges the
50
+ │ v1 SDK client
51
+ ├── runtime.ts → shared plugin runtime: nudges, system prompt
52
+ │ injection, session events, extraction triggers,
53
+ │ child-session bookkeeping (host-agnostic)
54
+ ├── os-args.ts → pure argv/OS-command-line helpers (startup session
55
+ │ resolution; SDK-free)
56
+ ├── tools.ts → thin opencode tool() wrappers (v1); tool-defs.ts
57
+ │ owns buildCoreContext (shared per-call context)
43
58
  ├── extraction.ts → in-memory ring buffer + shared payload builders
44
59
  ├── watchers.ts → in-memory watcher registry + poller (opencode-only,
45
60
  │ event-driven PR notifications delivered as prompts)
@@ -67,9 +82,13 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
67
82
  | Module | Responsibility |
68
83
  |--------|---------------|
69
84
  | `tool-defs.ts` | **Single source of truth** for all tools. Each tool has a name, description, zod schema (args), and execute function. Framework-agnostic — neither opencode nor MCP specific. |
70
- | `tools.ts` | Thin opencode wrappers. Imports tool-defs, wraps each in opencode's `tool()` with a `thatch_` prefix. |
85
+ | `tools.ts` | Thin opencode wrappers. `buildCoreContext` constructs the shared per-call context; `createTools` wraps each tool-def in opencode's `tool()` with a `thatch_` prefix (v1 only). |
71
86
  | `mcp.ts` | Stdio JSON-RPC 2.0 server. Compiles zod schemas to JSON Schema via `z.toJSONSchema()` for `tools/list`. Validates args via `z.object().parse()` in `tools/call`. All logging to stderr (stdout is the transport). |
72
- | `index.ts` | OpenCode plugin entry. Wires DB, model, extraction; registers tools and hooks; installs skills. Internal state beyond the extraction pipeline: `extracting` set (parent IDs with an active direct-extraction child), `childMetrics` map (new/updated/deleted counts per child session), and `triggerExtraction(parentID)` (creates a child session via the SDK client and prompts it with the extraction payload). |
87
+ | `index.ts` | Dual-shape OpenCode plugin entry: the merged default export (`{ id, setup, server }`) loads on opencode v1 (reads `default.server`) and v2 (reads `default.setup`, strips excess keys). Lazy-imports the host adapter so each host's SDK resolves only under its own runtime. |
88
+ | `opencode/v1.ts` + `opencode/v2.ts` | Host adapters. v1 builds HostCapabilities from the PluginInput client and returns the hooks object; v2 registers through the promise-context domains (ToolEditor, session.hook, event.subscribe) and degrades the surfaces v2 lacks. |
89
+ | `capabilities.ts` | The `HostCapabilities` seam: the host operations the shared runtime needs (session create/prompt/get/list/messages/delete, toast, compaction trigger, app exit). `capabilitiesFromClient` bridges the v1 SDK client. |
90
+ | `runtime.ts` | Shared plugin runtime: wires DB, model, extraction; the nudge tiers, system prompt injection, session event handling, child-session bookkeeping, wrap-up resolution. Host-agnostic: consumed by both adapters. |
91
+ | `os-args.ts` | Pure argv / OS-command-line helpers for startup session resolution (`-s`/`-c`). SDK-free. |
73
92
  | `setup.ts` | `thatch setup --claude` / `--cursor` installer. Writes MCP config (`.mcp.json` / `.cursor/mcp.json`), appends to CLAUDE.md / AGENTS.md (idempotent), installs hooks in settings.json / hooks.json, installs skills. |
74
93
  | `hygiene.ts` | Hygiene report: pending dedup pairs, stale count, orphaned branch memories. Shared by the plugin's session-start hook and the CLI's `thatch reminder` command. |
75
94
  | `git.ts` | Parse `owner/repo` from git remote. Worktree-safe fallback chain, plus recovery from the `repo_paths` cache when the directory is deleted, and the spawn-cwd fallback. See [features/repo-identity.md](features/repo-identity.md). |
@@ -78,7 +97,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
78
97
  | `extraction.ts` | Per-session in-memory ring buffer (cap 20) that buffers non-thatch tool interactions and serializes them into the JSON payload the `get_extraction_payload` tool returns. The in-memory pipeline is opencode-only, but the payload builders (`buildExtractionPayload`, `deriveTitle`) are shared by both paths — `extract-queue.ts` imports `deriveTitle`, `mcp.ts` imports `buildExtractionPayload` for the extraction payload provider. `summarizeArgs` is used internally by `buildExtractionPayload`. |
79
98
  | `extract-queue.ts` | File-backed per-session JSONL queue (caps 20, oldest dropped). Shared by the Claude Code and Cursor hook paths, which fire one-shot per event with no cross-call state. This is the MCP-side equivalent of `extraction.ts`. |
80
99
  | `config.ts` | User config file (`~/.config/thatch/config.json`, beside the DB): Zod section schemas (strict — unknown keys fail validation), load with missing/invalid fallback to defaults, field-level merge, atomic save (temp + rename). Backs the `config_get`/`config_set` tools. |
81
- | `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool. |
100
+ | `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool and the alert state machine. |
101
+ | `alerts.ts` | LLM alert state machine (opencode-only): pause/done/error decisions from session events, per-session dedup (no timer-based debounce - one alert per busy->idle transition, per request id for pauses). Fed by `runtime.ts`'s event hook; delivers through `notify.ts`. Plain memory by design (a reload loses at most one transition). See [../plans/llm-alerts.md](../plans/llm-alerts.md). |
82
102
  | `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
83
103
  | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Three sources: GitHub PRs and branches via `gh api` (injected `GhRunner`), and local shell commands via `bash -c` (injected `CommandRunner`) run as condition variables - only the exit code is read, never the command's output. Diffs against last-seen state, queues events (pointer data plus machine status like check conclusions and exit codes, never external content) per session, and delivers them through a plugin-supplied callback when the session is idle. The `pollSeconds` getter surfaces the poll cadence to tool output and notifications. Process-lifetime state by design - no SQLite. |
84
104
  | `chat.ts` | Cross-session chat between opencode sessions on one machine (opencode-only). `ChatStore` holds the shared directory and inbox in SQLite so any process can read/write them; `ChatPoller` heartbeats hosted sessions and delivers wake prompts through an injected callback when the recipient is idle - only the recipient's host process delivers, so there is no cross-process double-delivery. Heartbeat staleness flags crashed sessions; a per-recipient nudge rate cap is the anti-loop hard brake. See [features/cross-session-chat.md](features/cross-session-chat.md). |
@@ -96,7 +116,9 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
96
116
 
97
117
  ## Plugin hooks
98
118
 
99
- `index.ts` registers these opencode integration points:
119
+ The v1 adapter (`src/opencode/v1.ts`) registers these opencode integration
120
+ points; the v2 adapter routes its domain hooks into the same shared runtime
121
+ methods. The v1 hook shapes:
100
122
 
101
123
  | Hook | What it does |
102
124
  |------|-------------|
@@ -128,8 +128,9 @@ Parameters: `behavior` (statement, semantic match at cosine >= 0.85),
128
128
 
129
129
  ### Default behavior seeding (`src/seed-behaviors.ts`)
130
130
 
131
- Runs at startup in both the opencode plugin (`src/index.ts`) and the MCP
132
- server (`src/mcp.ts`), after DB and model initialization.
131
+ Runs at startup in both the opencode plugin (`src/runtime.ts`, shared by
132
+ the v1 and v2 adapters) and the MCP server (`src/mcp.ts`), after DB and
133
+ model initialization.
133
134
 
134
135
  Seeds multiple default behaviors into the global store — session wrap-up
135
136
  (check for uncommitted changes, untracked files, stale artifacts), work
@@ -13,7 +13,7 @@ The CI gate is the never-merge-broken guard before a release. All three must pas
13
13
 
14
14
  ## Publish pipeline (.github/workflows/publish.yml)
15
15
 
16
- Triggered by pushing a `v*` tag. Publishes to npm via OIDC trusted publishing — no stored npm token.
16
+ Triggered by pushing a `v*` tag. Publishes to npm via OIDC trusted publishing — no stored npm token. The tag push does not trigger ci.yml, so the publish workflow re-runs the full CI gate (typecheck, tests, markdownlint) across the same dual-Bun matrix as its own release gate.
17
17
 
18
18
  Key details:
19
19
  - `id-token: write` permission lets npm authenticate through GitHub's OIDC exchange
@@ -37,7 +37,7 @@ During summary generation, tools are blocked. A nudge that instructs the agent t
37
37
 
38
38
  ## Source files
39
39
 
40
- - `src/index.ts` -- all four hooks/events (experimental.session.compacting, experimental.compaction.autocontinue, session.compacted event, chat.message compaction guard)
40
+ - `src/runtime.ts` -- all four hooks/events (experimental.session.compacting, experimental.compaction.autocontinue, session.compacted event, chat.message compaction guard on v1; session.hook("compaction")/("prompt") on v2), shared by both adapters
41
41
  - `src/prompts.ts` -- compactionContext function
42
42
 
43
43
  ## Key invariants
@@ -1,5 +1,7 @@
1
1
  # Cross-Session Chat
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
  Cross-session chat lets independent opencode sessions on one machine message
4
6
  each other through thatch: sessions join a shared directory (automatically,
5
7
  under assigned names), see each other in the list, and messages land in
@@ -394,8 +396,8 @@ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
394
396
  chat_name_counters and the auto column), the NOCASE collation migration
395
397
  and topic column migration, delegated methods
396
398
  - `src/tool-defs.ts` - the chat tool definitions
397
- - `src/index.ts` - poller construction, delivery closure, `-s` startup
398
- registration (`osProcessArgs`, `startupSessionId`), idle
399
+ - `src/runtime.ts` - poller construction, delivery closure, `-s` startup
400
+ registration (`osProcessArgs`, `startupSessionId` from `src/os-args.ts`), idle
399
401
  auto-registration + topic refresh, idle flush,
400
402
  session.deleted unregister, dispose, transcript echo in
401
403
  tool.execute.after
@@ -36,7 +36,7 @@ directly for conversation-derived knowledge.
36
36
  After every tool execution, non-`thatch_*`, non-`skill`, non-`task` tool
37
37
  calls are buffered for later extraction.
38
38
 
39
- **opencode** — `tool.execute.after` hook in `src/index.ts`:
39
+ **opencode** — `tool.execute.after` hook (v1) / `tool.hook` (v2) in `src/runtime.ts`:
40
40
 
41
41
  - In-memory ring buffer per session (`ExtractionPipeline` in
42
42
  `src/extraction.ts`)
@@ -50,6 +50,14 @@ calls are buffered for later extraction.
50
50
  itself; `skill`/`task` meta-tools would create a feedback loop (extraction
51
51
  triggers a skill load, which gets buffered, which triggers another
52
52
  extraction)
53
+ - Code Mode `execute` calls are **unwrapped before filtering**
54
+ (`unwrapExecuteThatchCalls` in `src/extraction.ts`): when the call's code
55
+ invokes `tools.thatch_*` tools, the wrapped tools' hook semantics run
56
+ (ack, drain, metrics) and the call itself is never buffered. Matching on
57
+ the outer tool name alone re-queued the pipeline's own dispatch/ack
58
+ traffic every cycle — each extraction run queued the next one, producing
59
+ an infinite dispatch/ack loop (observed live, September 2026). Execute
60
+ calls that touch no thatch tools buffer as normal.
53
61
 
54
62
  **MCP hosts** — `bin/thatch`, `src/extract-queue.ts`:
55
63
 
@@ -234,7 +242,7 @@ continues working while the child extracts.
234
242
  |------|----------------|
235
243
  | `src/extraction.ts` | In-memory ring buffer (`ExtractionPipeline`), shared payload builders (`buildExtractionPayload`, `deriveTitle`, `summarizeArgs`) |
236
244
  | `src/extract-queue.ts` | File-backed JSONL queue for MCP hosts |
237
- | `src/index.ts` | opencode hooks: `tool.execute.after`, `session.status` idle, `session.created`, `session.error`, `session.deleted`, `chat.message` (nudge tier 1) |
245
+ | `src/runtime.ts` | opencode hooks: `tool.execute.after`, `session.status` idle, `session.created`, `session.error`, `session.deleted`, `chat.message` (nudge tier 1) -- shared by the v1/v2 adapters |
238
246
  | `bin/thatch` | `buffer-batch`, `buffer-tool`, `flush-tools` subcommands |
239
247
  | `src/prompts.ts` | `extractionNudge` (with escalation), `extractionDirectPrompt` |
240
248
 
@@ -1,5 +1,7 @@
1
1
  # Hygiene System
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
  Store maintenance signals surfaced at session start. The plugin never deletes
4
6
  memories itself — it surfaces problems and lets the agent tend the store.
5
7
 
@@ -52,7 +54,7 @@ instructions for the agent to tend the store when convenient.
52
54
 
53
55
  `hygieneReport(db, repo, worktree)` in `src/hygiene.ts` is shared by:
54
56
 
55
- - The opencode plugin's session-start hook (`src/index.ts`)
57
+ - The opencode plugin's session-start hook (`src/runtime.ts`)
56
58
  - The CLI's `thatch reminder` and `thatch hygiene` subcommands (`bin/thatch`)
57
59
 
58
60
  ## Interactions with other features
@@ -32,7 +32,7 @@ wrapping---prefixing, transport, session hooks---lives outside the core.
32
32
 
33
33
  ## The three paths
34
34
 
35
- ### opencode plugin (`src/index.ts`)
35
+ ### opencode plugin (`src/opencode/v1.ts` + `src/opencode/v2.ts`)
36
36
 
37
37
  Runs inside opencode's Bun runtime as a loaded plugin. No setup command is
38
38
  needed---opencode discovers the plugin from `opencode.json` and loads it at
@@ -228,7 +228,9 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
228
228
 
229
229
  | File | Role |
230
230
  |------|------|
231
- | `src/index.ts` | opencode plugin entry---hooks, system prompt injection, session events |
231
+ | `src/index.ts` | dual-shape plugin entry (merged default export loads on opencode v1 and v2) |
232
+ | `src/opencode/v1.ts` + `src/opencode/v2.ts` | host adapters: hooks (v1) vs promise-context domains (v2) |
233
+ | `src/runtime.ts` | shared plugin runtime: nudges, system prompt injection, session events |
232
234
  | `src/mcp.ts` | MCP server (shared by Claude Code and Cursor)---stdio JSON-RPC, tool dispatch |
233
235
  | `src/setup.ts` | Setup installer for Claude Code and Cursor---markers, hooks, skills |
234
236
  | `src/prompts.ts` | All three system prompt variants---`systemPrompt()`, `claudeInstructions()`, `cursorInstructions()` |
@@ -5,19 +5,28 @@ hand-editable user config file. Both are plain child processes and file I/O,
5
5
  so the feature works identically on every host -- opencode, Claude Code, and
6
6
  Cursor -- with no host-specific plumbing and no new npm dependencies.
7
7
 
8
+ Two delivery paths share the dispatcher and the config file:
9
+
10
+ - **Agent-initiated**: the LLM calls `notify_user`. Works on every host.
11
+ - **Automatic alerts** (opencode-only): the plugin runtime watches session
12
+ events and notifies when the LLM pauses for human input or finishes a
13
+ round of real work. See the Alerts section below and
14
+ `docs/plans/llm-alerts.md` for the design decisions.
15
+
8
16
  ## Source files
9
17
 
10
18
  | File | Role |
11
19
  |------|------|
12
- | `src/config.ts` | Zod section schemas, config file load/save/merge, platform defaults. |
20
+ | `src/config.ts` | Zod section schemas, config file load/save/merge, platform defaults, `alertMode`. |
13
21
  | `src/notify.ts` | Per-platform dispatch for banner + voice, with an injectable spawner. |
14
22
  | `src/tool-defs.ts` | `config_get`, `config_set`, `notify_user` tool definitions (shared registry, non-opencodeOnly). |
23
+ | `src/alerts.ts` | Alert state machine: pause/done/error decisions from session events, per-session dedup (no timer-based debounce - one alert per busy->idle transition, per request id for pauses). |
15
24
 
16
25
  ## Config design
17
26
 
18
27
  The config is one JSON file at `~/.config/thatch/config.json`, placed next to
19
28
  `thatch.db` by deriving the path from `THATCH_DB_PATH` (or the XDG default the
20
- same way `index.ts` and `mcp.ts` derive the DB path). Tests pass an explicit
29
+ same way `runtime.ts` and `mcp.ts` derive the DB path). Tests pass an explicit
21
30
  dbPath to keep writes inside a tempdir.
22
31
 
23
32
  Key decisions:
@@ -81,13 +90,110 @@ Details that matter:
81
90
  reporting stays honest. There is no debounce; polling agents notify once at
82
91
  the end.
83
92
 
93
+ ## Alerts: the automatic notification path (opencode only)
94
+
95
+ `src/alerts.ts` holds an in-memory state machine per session. The plugin
96
+ runtime (both the v1 and v2 adapters feed it the same event stream) drives it
97
+ from the shared `onEvent` hook; delivery reuses `sendNotification` with the
98
+ `alerts` config section choosing the channel per event (`alertMode`, default
99
+ `banner`). Delivery is fire-and-forget (a spoken alert awaits the sentence
100
+ for seconds and must never stall the event pump); the machine owns the
101
+ per-event `none` gate and the per-delivery config re-read is what makes
102
+ config changes apply without a restart.
103
+
104
+ Events consumed:
105
+
106
+ | Event | Machine input |
107
+ |-------|---------------|
108
+ | `question.asked` / `permission.asked` | Pause alert, deduped per request id; cleared by the matching replied/rejected event. |
109
+ | `session.status` busy | Marks the session active and resets the round. |
110
+ | `session.status` retry (v1 only) | Clears a recorded error -- opencode recovered on its own -- but preserves the real-work flag (the tool calls before the retry happened). |
111
+ | `session.error` / `session.execution.failed` | Records the error name; on v2 the adapter emits this paired with the idle event, so the verdict always follows. |
112
+ | `session.status` idle (from active) | The verdict: error alert, done alert, or silence. A recorded error delivers its verdict WITHOUT the message fetch (the fetch rides the same host API whose failure plausibly accompanied the failure). |
113
+ | `session.deleted` | Drops the state. |
114
+
115
+ The verdict combines two sources, because neither alone can answer "was
116
+ this a real round of work the user asked for?":
117
+
118
+ - **The tool side is live bookkeeping, not message archaeology.** The
119
+ runtime calls `sessionRealWork` at exactly the extraction buffer's push
120
+ site -- the buffer's filter is the one classification of "this call was
121
+ not bookkeeping" (`isMetaToolName`: `thatch_*`, `skill`, `task`,
122
+ `agent`, `subagent`; execute-wrapped thatch calls are unwrapped and never
123
+ reach the push). Message parts do not carry tool arguments, so the
124
+ execute-unwrap cannot be redone from messages. `todowrite` and `question`
125
+ reach the push (the buffer wants their facts) but are excluded from the
126
+ alert signal -- see `isNoWorkTool` in `src/alerts.ts`; they deliberately
127
+ do NOT live in `isMetaToolName`, and moving them there would silently
128
+ stop the buffer from queueing them.
129
+ - **The trigger side comes from the message list** via the pure
130
+ `deriveRoundShape` (exported from `src/alerts.ts`, fed
131
+ `HostCapabilities.sessionMessages` output). The round delimiter is v2's
132
+ own idle marker: v2 appends a `{type: "idle"}` marker message at every
133
+ turn end (`SessionMessage.Idle`), so the round is everything after the
134
+ previous marker -- the host's turn definition, immune to mid-round
135
+ steers. V1 has no markers and falls back to "newest non-assistant message
136
+ triggered the round" (a mid-round steer under-classifies there; accepted
137
+ v1 limitation).
138
+
139
+ Silence rules, in verdict order:
140
+
141
+ - **Unknown shape** -- a failed or empty message fetch stays silent.
142
+ Alerts are best-effort: a spurious banner on every broken fetch costs
143
+ more than a rare missed completion, and the same fetch failing also
144
+ breaks the wrap-up greenlight check, so the session degrades
145
+ consistently. This rule also keeps adapter-level tests (whose client
146
+ mocks have no message surface) from ever spawning a real banner.
147
+ - **Synthetic-trigger silence** -- nudges, background-task completions, and
148
+ watcher wake-ups are synthetic deliveries (v2: their own message kind; v1:
149
+ all-synthetic text parts on the user message). A round they triggered
150
+ never fires the DONE alert, even when it does real work. It is a silence
151
+ on the done verdict only: a synthetic round that ends in a real error
152
+ still banners the error. The rule exists because async thatch activity
153
+ would otherwise produce a banner every time.
154
+ - **Abort and shutdown silence** -- an error name matching
155
+ /abort|shutdown|interrupt/i means the user interrupted the turn
156
+ themselves (`MessageAbortedError`, or v2's user-reason interrupt
157
+ translation), the process is leaving (`SessionShutdownError`), or the
158
+ execution was superseded by a newer one (the catch-all for unknown
159
+ interrupt reasons). Sources: the event-level error, the message-level
160
+ error on the last assistant message, and v2's interrupted-reason
161
+ translation.
162
+ - **Error alert** -- any other recorded error (event-level, or message-level
163
+ on the last assistant message) notifies needs-attention with the error
164
+ name in the message, instead of done. Notably `SessionInactivityError`:
165
+ v2's location-activity sweeper interrupts still-executing sessions when a
166
+ project directory goes quiet for an hour -- the one banner most likely to
167
+ reach Jeff away from the terminal.
168
+ - **No real work** -- a round whose only tool calls were bookkeeping (or
169
+ that made none, or whose tool executions all crashed at the host layer)
170
+ stays silent. A round delegated entirely to subagents also stays silent:
171
+ dispatch tools are meta, and child sessions never alert.
172
+
173
+ State is plain memory, deliberately not journaled to `runtime_state`: a v2
174
+ reload mid-turn (plugin save, upgrade) loses that turn's busy->idle
175
+ transition and stays silent. Accepted by design -- the PR #16 journaling
176
+ exists because extraction and wrap-up state corrupt sessions when lost, and
177
+ an alert cannot corrupt anything.
178
+
179
+ Subagent child sessions never alert: the runtime's idle branch handles
180
+ children (extraction completion) and returns before the alert verdict, and
181
+ child errors requeue extraction instead. Pause asks from children still
182
+ notify -- a subagent blocking on a question blocks the parent too.
183
+
184
+ Deduplication across TUIs is structural: the runtime is one instance per
185
+ project directory in the shared server regardless of how many TUIs are
186
+ attached, so per-session state is exact by construction. Delivering from the
187
+ TUI side (opencode's attention API) would fire once per attached TUI and was
188
+ rejected for that reason (plus v1 has no TUI plugin surface at all).
189
+
84
190
  ## Prompt surface
85
191
 
86
192
  - `systemPrompt()` and `mcpInstructions()` list the three new tools and carry
87
193
  a short "Notifications" section: when to notify, the source-label rule, and
88
194
  the config_get-before-config_set instruction.
89
195
  - `notify_user` is not `opencodeOnly`: it needs no host capabilities, so MCP
90
- hosts get it too. Nothing was added to `src/tools.ts` or `src/index.ts`.
196
+ hosts get it too. Nothing was added to `src/tools.ts` or the plugin runtime.
91
197
 
92
198
  ## Tests
93
199
 
@@ -99,8 +205,19 @@ Details that matter:
99
205
  platform, plus tool-level tests (merge-no-wipe, echo, `mode: none` no-op,
100
206
  configured voice honored) with `THATCH_DB_PATH` pointed at a tempdir.
101
207
  - `tests/tool-defs.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
208
+ - `tests/alerts.test.ts` -- the alert state machine: `deriveRoundShape`
209
+ (idle-marker delimiter, v1 fallback, synthetic trigger detection), the
210
+ real-work flag, pause dedup and re-arm, abort/shutdown silence,
211
+ error-with-name, retry recovery, `mode: none` per event, config
212
+ round-trip (isolated tempdir).
102
213
 
103
214
  ## QA coverage
104
215
 
105
216
  `tests/qa/auto/uc-059-tool-prefixing.ts` asserts the full bare-name list; the
106
- three new names are in its `expected` array.
217
+ three new names are in its `expected` array. `uc-108-llm-alerts.ts` drives
218
+ the full alert event flow against a spy notifier, including the adapter
219
+ seam: raw v2 bus events through `translateEvent` and real-shaped
220
+ `session.context` lists through `mapSessionContextMessages` +
221
+ `deriveRoundShape` (the seam where the idle-marker bug hid). A live
222
+ banner-by-ear check remains a manual verification against a running
223
+ session.
@@ -1,5 +1,7 @@
1
1
  # Per-Message Nudge Pipeline
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
  Runs on every user message. Four priority tiers share one embedding
4
6
  computation. The pipeline injects *synthetic parts*---text the model sees in
5
7
  the conversation but the user does not see in the TUI. This is the inverse of
@@ -35,7 +37,7 @@ opencode path runs in-process with direct event hooks. The MCP path runs via
35
37
  external CLI hook processes that communicate with the long-lived MCP server
36
38
  through the sideband socket.
37
39
 
38
- ### opencode path (`src/index.ts`, `chat.message` hook)
40
+ ### opencode path (`src/runtime.ts`, `onChatMessage` - v1 `chat.message` hook, v2 `session.hook("prompt")`)
39
41
 
40
42
  #### Compaction guard
41
43
 
@@ -265,7 +267,7 @@ TUI display. The opencode framework's history serializer filters on
265
267
 
266
268
  | File | Role |
267
269
  |------|------|
268
- | `src/index.ts` | opencode: `chat.message` hook (all 4 tiers), compaction guard, extraction fallback |
270
+ | `src/runtime.ts` | opencode: per-message nudges (all 4 tiers), compaction guard, extraction fallback |
269
271
  | `bin/thatch` | MCP: `flush-tools` and `flush-predictions` subcommands |
270
272
  | `src/sideband.ts` | MCP: sideband client helpers (`sidebandMatch`, `sidebandPredictions`, `sidebandBehaviors`) |
271
273
  | `src/prompts.ts` | All nudge formatting functions (`recallNudge`, `claudeRecallNudge`, `predictionNudge`, `behaviorNudge`, `extractionNudge`, `claudeWriteNudge`) |