@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/src/commands.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
4
  defragCore,
@@ -161,12 +161,26 @@ export interface CommandDef {
161
161
  content: string;
162
162
  }
163
163
 
164
+ /**
165
+ * The wrap-up command's prompt body, for hosts that register commands in
166
+ * code (opencode v2 CommandEditor): the execute hook arms the greenlight
167
+ * check and delivers this same text as the session prompt.
168
+ */
169
+ export function wrapUpCommandContent(kind: "compact" | "exit"): string {
170
+ return kind === "compact" ? COMPACT_TEMPLATE : EXIT_TEMPLATE;
171
+ }
172
+
173
+ /** The action commands only - no wrap-ups (used where wrap-ups register in code). */
174
+ export function opencodeActionCommandDefs(): CommandDef[] {
175
+ return actionDefs(opencodeToolName).map((a) => ({ name: a.name, content: renderActionCommand(a) }));
176
+ }
177
+
164
178
  /** The opencode command set: wrap-ups plus every action, as full file contents. */
165
179
  export function opencodeCommandDefs(): CommandDef[] {
166
180
  return [
167
181
  { name: "compact", content: COMPACT_TEMPLATE },
168
182
  { name: "exit", content: EXIT_TEMPLATE },
169
- ...actionDefs(opencodeToolName).map((a) => ({ name: a.name, content: renderActionCommand(a) })),
183
+ ...opencodeActionCommandDefs(),
170
184
  ];
171
185
  }
172
186
 
@@ -205,9 +219,34 @@ function syncCommandFiles(dir: string, defs: CommandDef[]): string[] {
205
219
  return written;
206
220
  }
207
221
 
208
- /** Sync opencode's commands into <configHome>/opencode/command/thatch/. */
209
- export function installOpencodeCommands(configHome: string): string[] {
210
- return syncCommandFiles(join(configHome, "opencode", "command", "thatch"), opencodeCommandDefs());
222
+ /**
223
+ * Sync opencode's commands into <configHome>/opencode/command/thatch/.
224
+ * defs overrides the set (hosts whose plugin API registers wrap-up commands
225
+ * in code pass the action-only set - a registered command and a file with
226
+ * the same name would collide).
227
+ */
228
+ export function installOpencodeCommands(configHome: string, defs: CommandDef[] = opencodeCommandDefs()): string[] {
229
+ return syncCommandFiles(join(configHome, "opencode", "command", "thatch"), defs);
230
+ }
231
+
232
+ /**
233
+ * Removes wrap-up command files left in the opencode command dir. Hosts
234
+ * that register wrap-up commands in code (v2) pass the action-only set to
235
+ * installOpencodeCommands, but files written by earlier v1 runs persist -
236
+ * syncCommandFiles never removes - and a stale file collides with the
237
+ * registered command (both versions share the config dir). Call this
238
+ * before installing on such hosts. Idempotent: missing files are fine.
239
+ */
240
+ export function removeWrapUpCommandFiles(configHome: string): void {
241
+ const dir = join(configHome, "opencode", "command", "thatch");
242
+ for (const kind of ["compact", "exit"] as const) {
243
+ try {
244
+ unlinkSync(join(dir, `${kind}.md`));
245
+ } catch {
246
+ // ENOENT is the expected steady state; anything else is equally
247
+ // harmless for a best-effort cleanup.
248
+ }
249
+ }
211
250
  }
212
251
 
213
252
  /** Sync Claude Code's commands into <claudeDir>/commands/thatch/ (claudeDir is ~/.claude or <project>/.claude). */
package/src/config.ts CHANGED
@@ -21,17 +21,30 @@ export const chatPrefsSchema = z.strictObject({
21
21
  autoRegister: z.boolean().optional(),
22
22
  });
23
23
 
24
+ /** Channel preferences for one automatic alert event (pause / done / error). */
25
+ export const alertEventPrefsSchema = z.strictObject({
26
+ mode: z.enum(["both", "banner", "voice", "none"]).optional(),
27
+ });
28
+
29
+ export const alertsPrefsSchema = z.strictObject({
30
+ pause: alertEventPrefsSchema.optional(),
31
+ done: alertEventPrefsSchema.optional(),
32
+ error: alertEventPrefsSchema.optional(),
33
+ });
34
+
24
35
  export const configSchema = z.strictObject({
25
36
  notifications: notificationPrefsSchema.optional(),
26
37
  chat: chatPrefsSchema.optional(),
38
+ alerts: alertsPrefsSchema.optional(),
27
39
  });
28
40
 
29
41
  export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
30
42
  export type ChatPrefs = z.infer<typeof chatPrefsSchema>;
43
+ export type AlertsPrefs = z.infer<typeof alertsPrefsSchema>;
31
44
  export type Config = z.infer<typeof configSchema>;
32
45
 
33
46
  /** Sections in presentation order. config_get / config_set iterate this. */
34
- export const CONFIG_SECTIONS = ["notifications", "chat"] as const;
47
+ export const CONFIG_SECTIONS = ["notifications", "chat", "alerts"] as const;
35
48
  export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
36
49
 
37
50
  /**
@@ -102,6 +115,11 @@ export function mergeChatPrefs(current: ChatPrefs | undefined, patch: ChatPrefs)
102
115
  return { ...current, ...patch };
103
116
  }
104
117
 
118
+ /** Field-level merge for one section: omitted fields keep their values. */
119
+ export function mergeAlertsPrefs(current: AlertsPrefs | undefined, patch: AlertsPrefs): AlertsPrefs {
120
+ return { ...current, ...patch };
121
+ }
122
+
105
123
  /**
106
124
  * Whether cross-session chat is on. Unset means on - chat is the default,
107
125
  * and the toggle exists for users who want the feature entirely dark: every
@@ -134,3 +152,16 @@ export function notificationDefaults(): { mode: "both" | "banner" | "voice" | "n
134
152
  }
135
153
  return { mode: "both" };
136
154
  }
155
+
156
+ /** The automatic alert events, in the order config_get renders them. */
157
+ export type AlertEventKind = "pause" | "done" | "error";
158
+ export const ALERT_EVENT_KINDS: AlertEventKind[] = ["pause", "done", "error"];
159
+
160
+ /**
161
+ * Channel for one automatic alert event (pause / done / error). Unset means
162
+ * banner: the alerts exist to reach a user who walked away, so voice is
163
+ * opt-in per event rather than bundled with the notify_user default.
164
+ */
165
+ export function alertMode(config: Config, kind: AlertEventKind): "both" | "banner" | "voice" | "none" {
166
+ return config.alerts?.[kind]?.mode ?? "banner";
167
+ }
package/src/db.ts CHANGED
@@ -55,12 +55,67 @@ export interface DedupCandidate {
55
55
  * stored as BLOBs. Similarity search is brute-force cosine in JS.
56
56
  */
57
57
  export class ThatchDB {
58
- #db: Database;
59
- #predictions: PredictionEngine;
60
- #behaviors: BehaviorEngine;
61
- #chat: ChatStore;
58
+ #path: string;
59
+ #handle: Database | null = null;
60
+ #predictionsStore!: PredictionEngine;
61
+ #behaviorsStore!: BehaviorEngine;
62
+ #chatStore!: ChatStore;
62
63
 
63
64
  constructor(path: string) {
65
+ this.#path = path;
66
+ this.#open();
67
+ }
68
+
69
+ /**
70
+ * Connection accessor. Every read goes through here, so a handle closed by
71
+ * dispose() (a v2 plugin reload tears down the old activation while tool
72
+ * calls may still be in flight) transparently reopens on the next touch
73
+ * instead of surfacing bun:sqlite's "Cannot use a closed database". WAL
74
+ * journaling plus busy_timeout make the extra connection safe - the chat
75
+ * subsystem already runs as several concurrent connections by design.
76
+ *
77
+ * OWNERSHIP RULE: reopen serves READS and in-flight callers only. After a
78
+ * reload, the RELOADED instance owns the runtime_state journal rows, so
79
+ * post-dispose journal writes must no-op - they are guarded by the
80
+ * disposed flag at their call sites in src/runtime.ts, checked BEFORE any
81
+ * db access reaches this accessor. Do not move that guard in here: this
82
+ * method cannot distinguish a legitimate in-flight caller from a stale
83
+ * writer.
84
+ *
85
+ * Reopen caveat: a `:memory:` database has no file behind it, so a
86
+ * close-then-use cycle on one yields a brand-new EMPTY database. No
87
+ * production path does that (:memory: is test-only and tests never use the
88
+ * instance after close), but keep it that way - use a tmp-file db if a
89
+ * test needs close-then-use.
90
+ */
91
+ get #db(): Database {
92
+ if (this.#handle === null) this.#open();
93
+ return this.#handle!;
94
+ }
95
+
96
+ // The engine facades get the same treatment as #db: every accessor
97
+ // ensures an open connection first, so delegation routes through the
98
+ // facade REBUILT by the reopen rather than the stale one. Without this,
99
+ // a post-close chat call would reach the old ChatStore's captured handle
100
+ // and throw "Cannot use a closed database" even though ThatchDB itself
101
+ // reopened.
102
+ get #predictions(): PredictionEngine {
103
+ if (this.#handle === null) this.#open();
104
+ return this.#predictionsStore;
105
+ }
106
+
107
+ get #behaviors(): BehaviorEngine {
108
+ if (this.#handle === null) this.#open();
109
+ return this.#behaviorsStore;
110
+ }
111
+
112
+ get #chat(): ChatStore {
113
+ if (this.#handle === null) this.#open();
114
+ return this.#chatStore;
115
+ }
116
+
117
+ #open(): void {
118
+ const path = this.#path;
64
119
  // bun:sqlite creates the file but not its parent directory. On a fresh
65
120
  // machine the default path (~/.config/thatch/thatch.db) has no dir yet, so
66
121
  // create it here - otherwise every command fails with SQLITE_CANTOPEN.
@@ -68,18 +123,23 @@ export class ThatchDB {
68
123
  if (path !== ":memory:" && !path.startsWith("file:")) {
69
124
  mkdirSync(dirname(path), { recursive: true });
70
125
  }
71
- this.#db = new Database(path, { create: true });
72
- this.#db.run("PRAGMA journal_mode = WAL");
73
- this.#db.run("PRAGMA busy_timeout = 5000");
126
+ const db = new Database(path, { create: true });
127
+ db.run("PRAGMA journal_mode = WAL");
128
+ db.run("PRAGMA busy_timeout = 5000");
74
129
  // Enforce FK constraints per connection. The prediction tables use
75
130
  // ON DELETE CASCADE for edges and provenance; enabling this pragma
76
131
  // ensures those cascades fire. Also enforces the entries table's
77
132
  // existing FK to stores(name), which was declarative but unenforced
78
133
  // before this branch.
79
- this.#db.run("PRAGMA foreign_keys = ON");
80
- this.#predictions = new PredictionEngine(this.#db);
81
- this.#behaviors = new BehaviorEngine(this.#db);
82
- this.#chat = new ChatStore(this.#db);
134
+ db.run("PRAGMA foreign_keys = ON");
135
+ this.#handle = db;
136
+ // The engine facades hold the Database handle they were constructed
137
+ // with, so a reopened connection rebuilds them. They are stateless
138
+ // views over SQLite - all durable state lives in the tables - so
139
+ // reconstruction is lossless.
140
+ this.#predictionsStore = new PredictionEngine(db);
141
+ this.#behaviorsStore = new BehaviorEngine(db);
142
+ this.#chatStore = new ChatStore(db);
83
143
  this.#initSchema();
84
144
  }
85
145
 
@@ -297,6 +357,18 @@ export class ThatchDB {
297
357
  )
298
358
  `);
299
359
 
360
+ // Reaped auto-registered rows leave their name-to-session binding here,
361
+ // so the SAME session can reclaim its name when it re-registers (a
362
+ // crash-and-resume must not churn the identity). Names are still never
363
+ // reissued to a DIFFERENT session: a claim only ever resolves for the
364
+ // session id it was recorded with.
365
+ this.#db.run(`
366
+ CREATE TABLE IF NOT EXISTS chat_name_claims (
367
+ name TEXT PRIMARY KEY,
368
+ session_id TEXT NOT NULL
369
+ )
370
+ `);
371
+
300
372
  // Explicit leaves (chat_unregister). Suppresses auto-registration so
301
373
  // the next idle event cannot silently re-register the session; cleared
302
374
  // when the session explicitly rejoins via chat_register. Survives the
@@ -322,6 +394,24 @@ export class ThatchDB {
322
394
  )
323
395
  `);
324
396
 
397
+ // Volatile plugin-runtime state, persisted so a v2 plugin reload (the
398
+ // host rebuilds the location graph and re-runs setup) or a process
399
+ // restart can rehydrate instead of silently dropping it. The writer's
400
+ // pid is the discriminator: a row written by the CURRENT process means
401
+ // "reload - keep and rehydrate"; a row from another (dead) pid means
402
+ // "restart - prune unless the session was resumed".
403
+ this.#db.exec(`
404
+ CREATE TABLE IF NOT EXISTS runtime_state (
405
+ kind TEXT NOT NULL,
406
+ session_id TEXT NOT NULL,
407
+ directory TEXT NOT NULL DEFAULT '',
408
+ value TEXT NOT NULL,
409
+ pid INTEGER NOT NULL,
410
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
411
+ PRIMARY KEY (kind, session_id)
412
+ )
413
+ `);
414
+
325
415
  this.#migrateColumns();
326
416
  this.#migrateChatNameCollation();
327
417
  this.#migrateChatTopic();
@@ -330,6 +420,14 @@ export class ThatchDB {
330
420
  this.#migrateChatAuto();
331
421
  this.#migrateChatWorktree();
332
422
  this.#migrateChatHostPid();
423
+ this.#migrateRuntimeStateDirectory();
424
+ }
425
+
426
+ #migrateRuntimeStateDirectory(): void {
427
+ const cols = (this.#db.query("PRAGMA table_info(runtime_state)").all() as any[]).map((r) => r.name);
428
+ if (cols.length > 0 && !cols.includes("directory")) {
429
+ this.#db.run("ALTER TABLE runtime_state ADD COLUMN directory TEXT NOT NULL DEFAULT ''");
430
+ }
333
431
  }
334
432
 
335
433
  // chat_sessions tables created before the auto column lack it; the ALTER
@@ -961,6 +1059,48 @@ export class ThatchDB {
961
1059
  return this.#chat.unregister(sessionID);
962
1060
  }
963
1061
 
1062
+ /**
1063
+ * Persist one runtime-state record (upsert by kind+session). `directory`
1064
+ * is the writing instance's location: one v2 serve hosts one plugin
1065
+ * instance per directory sharing this db, and rehydration must be
1066
+ * instance-scoped, not process-scoped.
1067
+ */
1068
+ runtimeStatePut(kind: string, sessionId: string, value: unknown, directory: string, pid: number = process.pid) {
1069
+ this.#db
1070
+ .query(
1071
+ `INSERT INTO runtime_state (kind, session_id, directory, value, pid) VALUES (?, ?, ?, ?, ?)
1072
+ ON CONFLICT (kind, session_id) DO UPDATE SET directory = excluded.directory, value = excluded.value, pid = excluded.pid, created_at = excluded.created_at`,
1073
+ )
1074
+ .run(kind, sessionId ?? "", directory ?? "", JSON.stringify(value), pid);
1075
+ }
1076
+
1077
+ /** Drop one runtime-state record. */
1078
+ runtimeStateDelete(kind: string, sessionId: string) {
1079
+ this.#db.query(`DELETE FROM runtime_state WHERE kind = ? AND session_id = ?`).run(kind, sessionId);
1080
+ }
1081
+
1082
+ /** Every runtime-state record, oldest first (rehydration source). */
1083
+ runtimeStateAll(): { kind: string; sessionID: string; directory: string; value: unknown; pid: number }[] {
1084
+ return this.#db
1085
+ .query(`SELECT kind, session_id, directory, value, pid FROM runtime_state ORDER BY created_at, session_id`)
1086
+ .all()
1087
+ .map((row: any) => {
1088
+ let value: unknown;
1089
+ try {
1090
+ value = JSON.parse(row.value);
1091
+ } catch {
1092
+ value = null;
1093
+ }
1094
+ return {
1095
+ kind: row.kind as string,
1096
+ sessionID: row.session_id as string,
1097
+ directory: (row.directory ?? "") as string,
1098
+ value,
1099
+ pid: row.pid as number,
1100
+ };
1101
+ });
1102
+ }
1103
+
964
1104
  listChatSessions() {
965
1105
  return this.#chat.list();
966
1106
  }
@@ -1089,7 +1229,12 @@ export class ThatchDB {
1089
1229
  }
1090
1230
 
1091
1231
  close(): void {
1092
- this.#db.close();
1232
+ // Drops the current handle only. A later use reopens (see the #db
1233
+ // accessor) - the MCP server and CLI call this on process shutdown,
1234
+ // where nothing follows; the opencode plugin's dispose calls it on
1235
+ // reload, where straggler in-flight tool calls must survive.
1236
+ this.#handle?.close();
1237
+ this.#handle = null;
1093
1238
  }
1094
1239
  }
1095
1240
 
package/src/embeddings.ts CHANGED
@@ -159,6 +159,49 @@ export interface BgeEmbeddingModelOptions {
159
159
  idleTtlMs?: number;
160
160
  }
161
161
 
162
+ /**
163
+ * Process-wide model pool, keyed by db path. A v2 serve hosts one plugin
164
+ * instance per location and each would otherwise keep its own ONNX model
165
+ * resident; the pool refcounts so N instances share one model and it is
166
+ * disposed only when the last holder releases (the model's ONNX sessions
167
+ * must be explicitly closed before process exit - Bun's NAPI finalizers
168
+ * panic otherwise, see BgeEmbeddingModel.dispose).
169
+ */
170
+ export class SharedModelPool {
171
+ #entries = new Map<string, { model: BgeEmbeddingModel; refs: number }>();
172
+
173
+ acquire(dbPath: string, modelName: string): BgeEmbeddingModel {
174
+ const entry = this.#entries.get(dbPath);
175
+ if (entry) {
176
+ entry.refs += 1;
177
+ return entry.model;
178
+ }
179
+ const created = { model: new BgeEmbeddingModel(modelName), refs: 1 };
180
+ this.#entries.set(dbPath, created);
181
+ return created.model;
182
+ }
183
+
184
+ /**
185
+ * Release one reference. Resolves when the model's ONNX sessions are
186
+ * released (at zero refs) - the caller MUST await it before its process
187
+ * can exit, or Bun's NAPI finalizers panic on teardown.
188
+ */
189
+ async release(dbPath: string): Promise<void> {
190
+ const entry = this.#entries.get(dbPath);
191
+ if (!entry) return;
192
+ entry.refs -= 1;
193
+ if (entry.refs <= 0) {
194
+ this.#entries.delete(dbPath);
195
+ await entry.model.dispose();
196
+ }
197
+ }
198
+
199
+ /** Test/introspection: number of live (held) models. */
200
+ get size(): number {
201
+ return this.#entries.size;
202
+ }
203
+ }
204
+
162
205
  /**
163
206
  * Lazy-loads an embedding model via @huggingface/transformers.
164
207
  * Model files (~34 MB for the default) are downloaded once and cached by HF Hub.
package/src/extraction.ts CHANGED
@@ -59,6 +59,58 @@ function truncate(text: string, maxLen: number): string {
59
59
  return text.slice(0, maxLen) + "...";
60
60
  }
61
61
 
62
+ /** The scan result for a Code Mode `execute` call - see unwrapExecuteThatchCalls. */
63
+ export interface ExecuteUnwrap {
64
+ /** Distinct thatch_* tool names the code invokes, in first-appearance order. */
65
+ tools: string[];
66
+ /** Whether the code passes `overwrite: true` to a memory_remember call. */
67
+ overwrite: boolean;
68
+ /**
69
+ * The session_id a wrapped extraction_done call passes, when present -
70
+ * the sub-agent naming the parent whose accepted entries it completed.
71
+ */
72
+ sessionID?: string;
73
+ }
74
+
75
+ /**
76
+ * Meta tools: bookkeeping and dispatch, never real work. The extraction
77
+ * buffer excludes them because buffering the pipeline's own traffic feeds
78
+ * the nudge with its own exhaust (the dispatch/ack loops, September 2026);
79
+ * the alert classifier excludes them because a round made of only these
80
+ * did nothing the user asked for. "task" is v1's dispatch tool, "subagent"
81
+ * v2's, "agent" the MCP-path spelling - match all three, never one name.
82
+ */
83
+ export function isMetaToolName(tool: string): boolean {
84
+ const t = tool.toLowerCase();
85
+ return t.startsWith("thatch_") || t === "skill" || t === "task" || t === "agent" || t === "subagent";
86
+ }
87
+
88
+ /**
89
+ * Detect thatch_* tool invocations wrapped inside a Code Mode `execute`
90
+ * call's code (`tools.thatch_extraction_done({session_id: "..."})` - dot
91
+ * and bracket forms both count; the bracket form
92
+ * `tools["thatch_extraction_done"]` used to slip past the unwrap and
93
+ * re-open the dispatch/ack loop). The extraction buffer matches on TOOL
94
+ * NAME, so without unwrapping, the pipeline's own ack/drain traffic
95
+ * re-enters the buffer as extractable interactions: each extraction run
96
+ * queues the next one and the nudge never runs out of self-generated
97
+ * material (observed as an infinite dispatch/ack loop, September 2026).
98
+ * Returns an empty tool list when the args are not execute-shaped or the
99
+ * code invokes no thatch tools - such calls buffer as normal, because they
100
+ * did real non-thatch work.
101
+ */
102
+ export function unwrapExecuteThatchCalls(args: unknown): ExecuteUnwrap {
103
+ const code = (args as Record<string, unknown> | undefined)?.code;
104
+ if (typeof code !== "string" || !code.includes("thatch_")) return { tools: [], overwrite: false };
105
+ const tools: string[] = [];
106
+ for (const match of code.matchAll(/tools\.thatch_([a-z_]+)|tools\[\s*["']thatch_([a-z_]+)["']\s*\]/g)) {
107
+ const name = `thatch_${match[1] ?? match[2]}`;
108
+ if (!tools.includes(name)) tools.push(name);
109
+ }
110
+ const doneCall = code.match(/thatch_extraction_done["']?\]?\s*\(\s*\{[^}]*?\bsession_id:\s*["']([^"']+)["']/);
111
+ return { tools, overwrite: /\boverwrite\s*:\s*true\b/.test(code), sessionID: doneCall?.[1] };
112
+ }
113
+
62
114
  /**
63
115
  * Serialize a list of tool interactions into the JSON payload the
64
116
  * thatch-fact-extractor skill expects. Shared by the opencode plugin path
@@ -84,6 +136,27 @@ export function buildExtractionPayload(
84
136
  });
85
137
  }
86
138
 
139
+ /**
140
+ * Persistence hook: called after every buffer/accepted mutation with the
141
+ * session's current state (undefined = the list is gone - drop the record).
142
+ * The runtime wires this to the runtime_state journal so a v2 plugin reload
143
+ * or process restart can rehydrate the buffer instead of losing it.
144
+ */
145
+ export type ExtractionJournal = (
146
+ kind: "buffer" | "accepted",
147
+ sessionID: string,
148
+ value: ToolInteraction[] | undefined,
149
+ ) => void;
150
+
151
+ /**
152
+ * How long accepted entries wait for a completion signal before
153
+ * requeueStaleAccepted gives up waiting and requeues them. Generous - the
154
+ * extractor sub-agent can legitimately run for many minutes. The point is
155
+ * bounding the linger when the completion signal never comes: a crashed
156
+ * extractor, or a v2 dispatch whose completion ack never names the parent.
157
+ */
158
+ export const ACCEPTED_STALE_MS = 15 * 60_000;
159
+
87
160
  /**
88
161
  * Per-session in-memory ring buffer used by the opencode plugin path. Claude
89
162
  * Code's MCP server has no equivalent plugin lifecycle, so its CLI subcommands
@@ -92,13 +165,36 @@ export function buildExtractionPayload(
92
165
  export class ExtractionPipeline {
93
166
  #buffers = new Map<string, ToolInteraction[]>();
94
167
  #accepted = new Map<string, ToolInteraction[]>();
168
+ /** When the session's buffer was accepted - drives requeueStaleAccepted. */
169
+ #acceptedAt = new Map<string, number>();
95
170
  #maxBuffer = 20;
171
+ #journal?: ExtractionJournal;
172
+
173
+ constructor(journal?: ExtractionJournal) {
174
+ this.#journal = journal;
175
+ }
176
+
177
+ /** Restore persisted state (same-process reload rehydration). */
178
+ hydrate(buffers: ToolInteraction[], accepted: ToolInteraction[]): void {
179
+ for (const ix of buffers) {
180
+ const buf = this.#buffers.get(ix.sessionID) ?? [];
181
+ buf.push(ix);
182
+ this.#buffers.set(ix.sessionID, buf);
183
+ }
184
+ for (const ix of accepted) {
185
+ const buf = this.#accepted.get(ix.sessionID) ?? [];
186
+ buf.push(ix);
187
+ this.#accepted.set(ix.sessionID, buf);
188
+ if (!this.#acceptedAt.has(ix.sessionID)) this.#acceptedAt.set(ix.sessionID, Date.now());
189
+ }
190
+ }
96
191
 
97
192
  /** Record a tool execution for later extraction. */
98
193
  push(interaction: ToolInteraction): void {
99
194
  const buf = this.#buffers.get(interaction.sessionID) ?? [];
100
195
  buf.push(interaction);
101
196
  this.#buffers.set(interaction.sessionID, buf.slice(-this.#maxBuffer));
197
+ this.#journal?.("buffer", interaction.sessionID, this.#buffers.get(interaction.sessionID));
102
198
  }
103
199
 
104
200
  /** Returns the session's buffered interactions without clearing them. */
@@ -109,6 +205,7 @@ export class ExtractionPipeline {
109
205
  /** Clears the session's buffer. Called when the agent writes a memory. */
110
206
  consume(sessionID: string): void {
111
207
  this.#buffers.delete(sessionID);
208
+ this.#journal?.("buffer", sessionID, undefined);
112
209
  }
113
210
 
114
211
  /**
@@ -124,11 +221,103 @@ export class ExtractionPipeline {
124
221
  this.#buffers.delete(sessionID);
125
222
  const existing = this.#accepted.get(sessionID) ?? [];
126
223
  this.#accepted.set(sessionID, [...existing, ...buf]);
224
+ this.#acceptedAt.set(sessionID, Date.now());
225
+ this.#journal?.("buffer", sessionID, this.#buffers.get(sessionID));
226
+ this.#journal?.("accepted", sessionID, this.#accepted.get(sessionID));
127
227
  }
128
228
 
129
229
  /** Drop accepted entries: the extractor finished (save or no-save). */
130
230
  completeAccepted(sessionID: string): void {
131
231
  this.#accepted.delete(sessionID);
232
+ this.#acceptedAt.delete(sessionID);
233
+ this.#journal?.("accepted", sessionID, undefined);
234
+ }
235
+
236
+ #claims = new Map<string, { target: string; entries: ToolInteraction[] }>();
237
+
238
+ /**
239
+ * Claim the session's pending + previously-accepted entries for a fetcher
240
+ * (the extractor sub-agent fetching its payload). The entries stay in the
241
+ * accepted holding area; the claim records WHICH fetcher they were
242
+ * delivered to, so that fetcher's completion signal consumes only its own
243
+ * delivery. Without this, any child's completion dropped the whole
244
+ * accepted set - a sibling sub-agent going idle at the wrong moment
245
+ * silently discarded another extractor's in-flight payload (the
246
+ * accept-then-racing-completion loss, observed September 2026).
247
+ *
248
+ * Entries are claimed by reference identity: later accepts append new
249
+ * arrays, so a stale claim never reaches entries claimed after it.
250
+ */
251
+ claim(sessionID: string, fetcherID: string): ToolInteraction[] {
252
+ this.accept(sessionID);
253
+ const accepted = this.#accepted.get(sessionID) ?? [];
254
+ this.#claims.set(fetcherID, { target: sessionID, entries: accepted });
255
+ return accepted;
256
+ }
257
+
258
+ /**
259
+ * Consume only the fetcher's claimed entries. Returns false when the
260
+ * fetcher holds no claim - a sibling that never fetched, or a fetch that
261
+ * never happened. Callers must NOT fall back to whole-set completion on
262
+ * the idle path: a fetcher that never fetched processed nothing, and
263
+ * dropping the set would lose another extractor's claimed payload.
264
+ */
265
+ completeClaimed(fetcherID: string): boolean {
266
+ const claim = this.#claims.get(fetcherID);
267
+ if (!claim) return false;
268
+ this.#claims.delete(fetcherID);
269
+ const accepted = this.#accepted.get(claim.target);
270
+ if (accepted) {
271
+ const claimed = new Set(claim.entries);
272
+ const rest = accepted.filter((ix) => !claimed.has(ix));
273
+ if (rest.length > 0) {
274
+ this.#accepted.set(claim.target, rest);
275
+ this.#journal?.("accepted", claim.target, rest);
276
+ } else {
277
+ this.#accepted.delete(claim.target);
278
+ this.#acceptedAt.delete(claim.target);
279
+ this.#journal?.("accepted", claim.target, undefined);
280
+ }
281
+ }
282
+ return true;
283
+ }
284
+
285
+ /** Forget a fetcher's claim (its session was deleted or cleaned up). */
286
+ dropClaim(fetcherID: string): void {
287
+ this.#claims.delete(fetcherID);
288
+ }
289
+
290
+ /**
291
+ * Requeue only the fetcher's claimed entries back to pending: its session
292
+ * errored, so the delivery was never processed. Returns false when the
293
+ * fetcher holds no claim - a task-kind child that never fetched processed
294
+ * nothing, and requeueing the whole accepted set here would yank another
295
+ * extractor's in-flight claim back to pending for duplicate extraction.
296
+ * Callers decide whether a claim-less error falls back to whole-set
297
+ * requeue (extraction children: yes - the error is a strong immediate
298
+ * death signal; task children: no).
299
+ */
300
+ requeueClaimed(fetcherID: string): boolean {
301
+ const claim = this.#claims.get(fetcherID);
302
+ if (!claim) return false;
303
+ this.#claims.delete(fetcherID);
304
+ const accepted = this.#accepted.get(claim.target);
305
+ if (accepted) {
306
+ const claimed = new Set(claim.entries);
307
+ const rest = accepted.filter((ix) => !claimed.has(ix));
308
+ if (rest.length > 0) {
309
+ this.#accepted.set(claim.target, rest);
310
+ this.#journal?.("accepted", claim.target, rest);
311
+ } else {
312
+ this.#accepted.delete(claim.target);
313
+ this.#acceptedAt.delete(claim.target);
314
+ this.#journal?.("accepted", claim.target, undefined);
315
+ }
316
+ }
317
+ const buf = this.#buffers.get(claim.target) ?? [];
318
+ this.#buffers.set(claim.target, [...claim.entries, ...buf]);
319
+ this.#journal?.("buffer", claim.target, this.#buffers.get(claim.target));
320
+ return true;
132
321
  }
133
322
 
134
323
  /**
@@ -142,8 +331,27 @@ export class ExtractionPipeline {
142
331
  const accepted = this.#accepted.get(sessionID);
143
332
  if (!accepted || accepted.length === 0) return;
144
333
  this.#accepted.delete(sessionID);
334
+ this.#acceptedAt.delete(sessionID);
145
335
  const buf = this.#buffers.get(sessionID) ?? [];
146
336
  this.#buffers.set(sessionID, [...accepted, ...buf]);
337
+ this.#journal?.("buffer", sessionID, this.#buffers.get(sessionID));
338
+ this.#journal?.("accepted", sessionID, undefined);
339
+ }
340
+
341
+ /**
342
+ * Requeues accepted entries that have waited longer than maxAgeMs for a
343
+ * completion signal that never came: the extractor sub-agent crashed
344
+ * without acking, or the completion ack was lost. Without this, accepted
345
+ * entries linger unprocessed AND un-nudged - silent loss. Returns the session ids that had entries
346
+ * requeued; the regular nudge path picks them up from pending on the
347
+ * session's next message.
348
+ */
349
+ requeueStaleAccepted(maxAgeMs = ACCEPTED_STALE_MS): string[] {
350
+ const stale = [...this.#acceptedAt.entries()]
351
+ .filter(([, acceptedAt]) => Date.now() - acceptedAt > maxAgeMs)
352
+ .map(([sessionID]) => sessionID);
353
+ for (const sessionID of stale) this.requeueAccepted(sessionID);
354
+ return stale;
147
355
  }
148
356
 
149
357
  /** Returns the session's accepted (held) interactions. Test/introspection. */
@@ -166,8 +374,10 @@ export class ExtractionPipeline {
166
374
  const remaining = buf.filter((ix) => !old.has(ix));
167
375
  if (remaining.length === 0) {
168
376
  this.#buffers.delete(sessionID);
377
+ this.#journal?.("buffer", sessionID, undefined);
169
378
  } else {
170
379
  this.#buffers.set(sessionID, remaining);
380
+ this.#journal?.("buffer", sessionID, remaining);
171
381
  }
172
382
  }
173
383