@jeffober/thatch 0.1.48 → 0.2.1

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 (50) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/bin/thatch +31 -16
  5. package/docs/dev/README.md +39 -13
  6. package/docs/dev/features/behavior-engine.md +3 -2
  7. package/docs/dev/features/cicd.md +1 -1
  8. package/docs/dev/features/compaction-recovery.md +1 -1
  9. package/docs/dev/features/cross-session-chat.md +12 -3
  10. package/docs/dev/features/extraction.md +10 -2
  11. package/docs/dev/features/hygiene.md +3 -1
  12. package/docs/dev/features/memory-store.md +1 -1
  13. package/docs/dev/features/multi-host.md +4 -2
  14. package/docs/dev/features/notifications.md +121 -4
  15. package/docs/dev/features/nudge-pipeline.md +16 -2
  16. package/docs/dev/features/opencode-plugin.md +133 -0
  17. package/docs/dev/features/qa-system.md +43 -0
  18. package/docs/dev/features/repo-identity.md +1 -1
  19. package/docs/dev/features/session-lifecycle.md +6 -3
  20. package/docs/dev/features/watchers.md +12 -5
  21. package/docs/dev/gotchas.md +94 -4
  22. package/docs/dev/mcp-parity.md +4 -3
  23. package/docs/dev/setup-and-hooks.md +7 -2
  24. package/docs/plans/llm-alerts.md +287 -0
  25. package/docs/plans/prediction-consolidation.md +3 -3
  26. package/docs/user/README.md +8 -4
  27. package/docs/user/memory.md +1 -1
  28. package/docs/user/notifications.md +63 -4
  29. package/docs/user/setup.md +4 -0
  30. package/docs/user/watchers.md +15 -7
  31. package/package.json +3 -1
  32. package/src/alerts.ts +360 -0
  33. package/src/capabilities.ts +174 -0
  34. package/src/chat.ts +80 -5
  35. package/src/commands.ts +44 -5
  36. package/src/config.ts +32 -1
  37. package/src/db.ts +157 -12
  38. package/src/embeddings.ts +43 -0
  39. package/src/extraction.ts +210 -0
  40. package/src/index.ts +77 -1339
  41. package/src/opencode/v1.ts +54 -0
  42. package/src/opencode/v2.ts +605 -0
  43. package/src/os-args.ts +99 -0
  44. package/src/prompts.ts +181 -31
  45. package/src/runtime.ts +2004 -0
  46. package/src/seed-behaviors.ts +1 -1
  47. package/src/session-db.ts +32 -0
  48. package/src/tool-defs.ts +340 -46
  49. package/src/tools.ts +16 -35
  50. package/src/watchers.ts +109 -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,
@@ -18,7 +18,7 @@ The extraction nudge or dispatch prompt will give you a session ID. Call `get_ex
18
18
  4. Apply the durability test before saving: will this still be true and useful in a future session that knows nothing about this branch, commit, or session? If not, skip it. Do not store point-in-time values (migration indices, line numbers, file counts), commit hashes or branch history narratives (git is the source of truth), or anything re-derivable from the codebase faster than recalling it.
19
19
  5. Write for a future session with zero current context. No "we", "our session", "just now".
20
20
  6. Call `extraction_done` (with `session_id` set to the parent session's ID from the nudge) to mark the run complete. Do this even when nothing was worth saving: the parent session holds the buffered interactions until a completion signal arrives, and a memory write is not one on a no-save run.
21
- 7. Your final message must be exactly: "Extraction complete." — do not list or summarize what you saved. The memories are in the store; the parent session does not need a report.
21
+ 7. Your final message must be exactly: "Extraction complete. This is the extractor's final report - a background-task completion, not an extraction nudge; the buffer is drained. Never re-dispatch an extractor because a completion arrived - a new extraction nudge is the only dispatch signal." - do not list or summarize what you saved. The memories are in the store; the parent session does not need a report. The frame rides with the completion on purpose: a bare "Extraction complete." was indistinguishable from nudge traffic mid-loop, and the parent model misread it as new work (the September 2026 re-dispatch loop).
22
22
 
23
23
  ## What to extract
24
24
 
@@ -20,7 +20,7 @@ This skill is the catch.
20
20
 
21
21
  Call `thatch_memory_show` with the label (or accept the memory content directly if dispatched by a coordinator that already read it).
22
22
 
23
- Note the `created_at` and `updated_at` dates. A memory that was last updated recently is less likely to be stale than one that has not been touched in months.
23
+ Note the age line ("This memory was last updated N ago"). A memory that was last updated recently is less likely to be stale than one that has not been touched in months.
24
24
 
25
25
  ## Step 2: Classify Claims
26
26
 
@@ -111,6 +111,6 @@ If no corrections were needed, report "all claims verified, no updates needed."
111
111
 
112
112
  # Proactive Use
113
113
 
114
- When `thatch_memory_show` or `thatch_memory_recall` returns a memory with an old `updated_at` date (more than a few weeks), consider loading this skill to fact-check it before relying on its code-related claims. The `updated_at` date is included in the memory output for this purpose.
114
+ When `thatch_memory_show` or `thatch_memory_recall` returns a memory whose age line shows it has not been updated in a while (more than a few weeks), consider loading this skill to fact-check it before relying on its code-related claims. The age prose ("This memory was last updated N ago") is included in the memory output for this purpose.
115
115
 
116
116
  This is not mandatory for every old memory. Use judgment: a memory about a stable architectural decision may be fine for months, while a memory about file locations or function names is more likely to drift.
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
  |------|-------------|
@@ -105,7 +127,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
105
127
  | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
106
128
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
107
129
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
108
- | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
130
+ | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
109
131
  | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher and chat delivery gates; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events and pending chat mail (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, cancels the session's watchers, and unregisters it from the chat directory (the crash path — a dead process fires no event — is covered by heartbeat staleness). `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
110
132
  | `dispose` | Stops the version checker, the watcher poller, and the chat poller, closes the DB. |
111
133
 
@@ -157,11 +179,15 @@ Two of these hooks were dead for weeks because failures were invisible.
157
179
  10. **Background completion narration is suppressed, not prevented.** When a
158
180
  background sub-agent completes, opencode injects a `<task_result>` block
159
181
  into the parent session and triggers a full model generation. Thatch
160
- cannot cancel this turn (no pre-response hook). Two mitigations: the
161
- fact-extractor skill's return value is constrained to the literal
162
- "Extraction complete." so the injected block has no narratable content,
163
- and the system prompt's "Background Task Completions" section instructs
164
- the model not to narrate completions or treat them as approval to act.
182
+ cannot cancel this turn (no pre-response hook). The mitigations: the
183
+ fact-extractor skill's return value is a fixed string so the injected
184
+ block has nothing to narrate AND the string self-identifies as a
185
+ completion, not an extraction nudge (a bare "Extraction complete." was
186
+ misread mid-loop as new nudge traffic and re-dispatched - the September
187
+ 2026 re-dispatch loop); the system prompt's "Background Task
188
+ Completions" section instructs the model not to narrate completions or
189
+ treat them as approval to act, and that a completion is never a
190
+ re-dispatch signal.
165
191
  11. **Prediction engine is a statistical model, not an LLM call.** The query
166
192
  (embed prompt, cosine-match against matchers, score linked predictions)
167
193
  is mechanical — same shape as `thatch_memory_recall` but against different
@@ -187,7 +213,7 @@ Two of these hooks were dead for weeks because failures were invisible.
187
213
  ## Data flow
188
214
 
189
215
  ```text
190
- thatch_memory_remember(label, content)
216
+ thatch_memory_remember(label?, content)
191
217
  → model.passageEmbed("# label\n\ncontent") → Float32Array
192
218
  → db.findSimilar(store, embedding) — write-time collision check (no telemetry)
193
219
  → db.remember(store, label, content, embedding, model.name, opts)
@@ -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
@@ -189,7 +191,14 @@ rather than one so a single late poll cycle does not flap the roster.
189
191
  Heartbeat age is the ONLY liveness signal, by design. A host process id
190
192
  cannot serve: the same session is re-hosted by a new process on every
191
193
  `-s` resume, so a pid stamped on the row lies as soon as a different
192
- harness beats it (it says dead while the heartbeat proves alive). `isStale`
194
+ harness beats it (it says dead while the heartbeat proves alive). RESTART
195
+ grace: a new daemon re-hosts the dead one's hosted set under a bounded
196
+ window (`CHAT_REHOST_GRACE_MS`, 24h, `THATCH_CHAT_REHOST_GRACE_MINUTES`
197
+ to override) - open TUI tabs reconnect but emit no events until typed
198
+ into, so without the grace they would read stale while alive-idle and
199
+ mail to them would not wake the tab. Sessions that emit activity
200
+ graduate to permanent hosting; still-silent ones (closed tabs) age out
201
+ at the grace and go stale. `isStale`
193
202
  (src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
194
203
  and the broadcast skip all call it, so they cannot disagree. `chat_list`
195
204
  groups the result into Active and Stale sections - the stale section's
@@ -394,8 +403,8 @@ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
394
403
  chat_name_counters and the auto column), the NOCASE collation migration
395
404
  and topic column migration, delegated methods
396
405
  - `src/tool-defs.ts` - the chat tool definitions
397
- - `src/index.ts` - poller construction, delivery closure, `-s` startup
398
- registration (`osProcessArgs`, `startupSessionId`), idle
406
+ - `src/runtime.ts` - poller construction, delivery closure, `-s` startup
407
+ registration (`osProcessArgs`, `startupSessionId` from `src/os-args.ts`), idle
399
408
  auto-registration + topic refresh, idle flush,
400
409
  session.deleted unregister, dispose, transcript echo in
401
410
  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
@@ -58,7 +58,7 @@ The "global" store always exists — it is inserted at schema init (`INSERT OR I
58
58
  ### Write path (memory_remember)
59
59
 
60
60
  ```text
61
- agent calls memory_remember(label, content, opts)
61
+ agent calls memory_remember(label?, content, opts) - label is derived from the content's first heading or line when omitted
62
62
  → model.passageEmbed("# label\n\ncontent") → Float32Array
63
63
  → db.findSimilar(store, embedding, { excludeSlug }) → write-time collision check
64
64
  → db.remember(store, label, content, embedding, model.name, opts)
@@ -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()` |