@jeffober/thatch 0.1.47 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-plan-refinement.md +2 -0
  3. package/bin/thatch +31 -16
  4. package/docs/dev/README.md +28 -6
  5. package/docs/dev/features/behavior-engine.md +3 -2
  6. package/docs/dev/features/cicd.md +1 -1
  7. package/docs/dev/features/compaction-recovery.md +1 -1
  8. package/docs/dev/features/cross-session-chat.md +4 -2
  9. package/docs/dev/features/extraction.md +10 -2
  10. package/docs/dev/features/hygiene.md +3 -1
  11. package/docs/dev/features/multi-host.md +4 -2
  12. package/docs/dev/features/notifications.md +121 -4
  13. package/docs/dev/features/nudge-pipeline.md +4 -2
  14. package/docs/dev/features/opencode-plugin.md +133 -0
  15. package/docs/dev/features/qa-system.md +43 -0
  16. package/docs/dev/features/repo-identity.md +1 -1
  17. package/docs/dev/features/session-lifecycle.md +5 -2
  18. package/docs/dev/features/watchers.md +12 -5
  19. package/docs/dev/gotchas.md +36 -3
  20. package/docs/dev/mcp-parity.md +4 -3
  21. package/docs/dev/setup-and-hooks.md +6 -1
  22. package/docs/plans/llm-alerts.md +287 -0
  23. package/docs/plans/prediction-consolidation.md +3 -3
  24. package/docs/user/README.md +7 -3
  25. package/docs/user/notifications.md +63 -4
  26. package/docs/user/setup.md +4 -0
  27. package/docs/user/watchers.md +15 -7
  28. package/package.json +3 -1
  29. package/src/alerts.ts +360 -0
  30. package/src/capabilities.ts +174 -0
  31. package/src/chat.ts +63 -5
  32. package/src/commands.ts +44 -5
  33. package/src/config.ts +32 -1
  34. package/src/db.ts +80 -0
  35. package/src/embeddings.ts +43 -0
  36. package/src/extraction.ts +124 -0
  37. package/src/index.ts +38 -1345
  38. package/src/opencode/v1.ts +54 -0
  39. package/src/opencode/v2.ts +561 -0
  40. package/src/os-args.ts +99 -0
  41. package/src/prompts.ts +77 -1
  42. package/src/runtime.ts +1815 -0
  43. package/src/seed-behaviors.ts +1 -1
  44. package/src/session-db.ts +32 -0
  45. package/src/tool-defs.ts +185 -19
  46. package/src/tools.ts +16 -35
  47. package/src/watchers.ts +95 -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
@@ -297,6 +297,18 @@ export class ThatchDB {
297
297
  )
298
298
  `);
299
299
 
300
+ // Reaped auto-registered rows leave their name-to-session binding here,
301
+ // so the SAME session can reclaim its name when it re-registers (a
302
+ // crash-and-resume must not churn the identity). Names are still never
303
+ // reissued to a DIFFERENT session: a claim only ever resolves for the
304
+ // session id it was recorded with.
305
+ this.#db.run(`
306
+ CREATE TABLE IF NOT EXISTS chat_name_claims (
307
+ name TEXT PRIMARY KEY,
308
+ session_id TEXT NOT NULL
309
+ )
310
+ `);
311
+
300
312
  // Explicit leaves (chat_unregister). Suppresses auto-registration so
301
313
  // the next idle event cannot silently re-register the session; cleared
302
314
  // when the session explicitly rejoins via chat_register. Survives the
@@ -322,6 +334,24 @@ export class ThatchDB {
322
334
  )
323
335
  `);
324
336
 
337
+ // Volatile plugin-runtime state, persisted so a v2 plugin reload (the
338
+ // host rebuilds the location graph and re-runs setup) or a process
339
+ // restart can rehydrate instead of silently dropping it. The writer's
340
+ // pid is the discriminator: a row written by the CURRENT process means
341
+ // "reload - keep and rehydrate"; a row from another (dead) pid means
342
+ // "restart - prune unless the session was resumed".
343
+ this.#db.exec(`
344
+ CREATE TABLE IF NOT EXISTS runtime_state (
345
+ kind TEXT NOT NULL,
346
+ session_id TEXT NOT NULL,
347
+ directory TEXT NOT NULL DEFAULT '',
348
+ value TEXT NOT NULL,
349
+ pid INTEGER NOT NULL,
350
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
351
+ PRIMARY KEY (kind, session_id)
352
+ )
353
+ `);
354
+
325
355
  this.#migrateColumns();
326
356
  this.#migrateChatNameCollation();
327
357
  this.#migrateChatTopic();
@@ -330,6 +360,14 @@ export class ThatchDB {
330
360
  this.#migrateChatAuto();
331
361
  this.#migrateChatWorktree();
332
362
  this.#migrateChatHostPid();
363
+ this.#migrateRuntimeStateDirectory();
364
+ }
365
+
366
+ #migrateRuntimeStateDirectory(): void {
367
+ const cols = (this.#db.query("PRAGMA table_info(runtime_state)").all() as any[]).map((r) => r.name);
368
+ if (cols.length > 0 && !cols.includes("directory")) {
369
+ this.#db.run("ALTER TABLE runtime_state ADD COLUMN directory TEXT NOT NULL DEFAULT ''");
370
+ }
333
371
  }
334
372
 
335
373
  // chat_sessions tables created before the auto column lack it; the ALTER
@@ -961,6 +999,48 @@ export class ThatchDB {
961
999
  return this.#chat.unregister(sessionID);
962
1000
  }
963
1001
 
1002
+ /**
1003
+ * Persist one runtime-state record (upsert by kind+session). `directory`
1004
+ * is the writing instance's location: one v2 serve hosts one plugin
1005
+ * instance per directory sharing this db, and rehydration must be
1006
+ * instance-scoped, not process-scoped.
1007
+ */
1008
+ runtimeStatePut(kind: string, sessionId: string, value: unknown, directory: string, pid: number = process.pid) {
1009
+ this.#db
1010
+ .query(
1011
+ `INSERT INTO runtime_state (kind, session_id, directory, value, pid) VALUES (?, ?, ?, ?, ?)
1012
+ ON CONFLICT (kind, session_id) DO UPDATE SET directory = excluded.directory, value = excluded.value, pid = excluded.pid, created_at = excluded.created_at`,
1013
+ )
1014
+ .run(kind, sessionId ?? "", directory ?? "", JSON.stringify(value), pid);
1015
+ }
1016
+
1017
+ /** Drop one runtime-state record. */
1018
+ runtimeStateDelete(kind: string, sessionId: string) {
1019
+ this.#db.query(`DELETE FROM runtime_state WHERE kind = ? AND session_id = ?`).run(kind, sessionId);
1020
+ }
1021
+
1022
+ /** Every runtime-state record, oldest first (rehydration source). */
1023
+ runtimeStateAll(): { kind: string; sessionID: string; directory: string; value: unknown; pid: number }[] {
1024
+ return this.#db
1025
+ .query(`SELECT kind, session_id, directory, value, pid FROM runtime_state ORDER BY created_at, session_id`)
1026
+ .all()
1027
+ .map((row: any) => {
1028
+ let value: unknown;
1029
+ try {
1030
+ value = JSON.parse(row.value);
1031
+ } catch {
1032
+ value = null;
1033
+ }
1034
+ return {
1035
+ kind: row.kind as string,
1036
+ sessionID: row.session_id as string,
1037
+ directory: (row.directory ?? "") as string,
1038
+ value,
1039
+ pid: row.pid as number,
1040
+ };
1041
+ });
1042
+ }
1043
+
964
1044
  listChatSessions() {
965
1045
  return this.#chat.list();
966
1046
  }
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,16 @@ 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);
132
234
  }
133
235
 
134
236
  /**
@@ -142,8 +244,28 @@ export class ExtractionPipeline {
142
244
  const accepted = this.#accepted.get(sessionID);
143
245
  if (!accepted || accepted.length === 0) return;
144
246
  this.#accepted.delete(sessionID);
247
+ this.#acceptedAt.delete(sessionID);
145
248
  const buf = this.#buffers.get(sessionID) ?? [];
146
249
  this.#buffers.set(sessionID, [...accepted, ...buf]);
250
+ this.#journal?.("buffer", sessionID, this.#buffers.get(sessionID));
251
+ this.#journal?.("accepted", sessionID, undefined);
252
+ }
253
+
254
+ /**
255
+ * Requeues accepted entries that have waited longer than maxAgeMs for a
256
+ * completion signal that never came: the extractor sub-agent crashed
257
+ * without acking, or (v2) completed without a parent linkage the plugin
258
+ * could see. Without this, accepted entries linger unprocessed AND
259
+ * un-nudged - silent loss. Returns the session ids that had entries
260
+ * requeued; the regular nudge path picks them up from pending on the
261
+ * session's next message.
262
+ */
263
+ requeueStaleAccepted(maxAgeMs = ACCEPTED_STALE_MS): string[] {
264
+ const stale = [...this.#acceptedAt.entries()]
265
+ .filter(([, acceptedAt]) => Date.now() - acceptedAt > maxAgeMs)
266
+ .map(([sessionID]) => sessionID);
267
+ for (const sessionID of stale) this.requeueAccepted(sessionID);
268
+ return stale;
147
269
  }
148
270
 
149
271
  /** Returns the session's accepted (held) interactions. Test/introspection. */
@@ -166,8 +288,10 @@ export class ExtractionPipeline {
166
288
  const remaining = buf.filter((ix) => !old.has(ix));
167
289
  if (remaining.length === 0) {
168
290
  this.#buffers.delete(sessionID);
291
+ this.#journal?.("buffer", sessionID, undefined);
169
292
  } else {
170
293
  this.#buffers.set(sessionID, remaining);
294
+ this.#journal?.("buffer", sessionID, remaining);
171
295
  }
172
296
  }
173
297