@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/runtime.ts ADDED
@@ -0,0 +1,2004 @@
1
+ import { join } from "node:path";
2
+ import { createDebugLog } from "./debug";
3
+ import { ThatchDB, repoPathCache } from "./db";
4
+ import { SharedModelPool } from "./embeddings";
5
+ import { detectRepo, detectWorktreeKind, resolveSpawnCwd } from "./git";
6
+ import { buildCoreContext } from "./tool-defs";
7
+ import {
8
+ systemPrompt,
9
+ compactionContext,
10
+ sessionStartReminder,
11
+ recallNudge,
12
+ extractionNudge,
13
+ extractionDirectPrompt,
14
+ predictionNudge,
15
+ behaviorNudge,
16
+ versionWarningNudge,
17
+ skewWarningText,
18
+ type NudgeMatch,
19
+ } from "./prompts";
20
+ import { ExtractionPipeline, isMetaToolName, unwrapExecuteThatchCalls, type ToolInteraction } from "./extraction";
21
+ import { createAlerts, deriveRoundShape, isNoWorkTool, replyRequestID } from "./alerts";
22
+ import { mostRecentTopLevelSessionId } from "./session-db";
23
+ import type { CoreContext } from "./tool-defs";
24
+ import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
25
+ import { installOpencodeCommands, opencodeActionCommandDefs, removeWrapUpCommandFiles, COMPACT_READY_TOKEN, EXIT_READY_TOKEN } from "./commands";
26
+ import { hygieneReport } from "./hygiene";
27
+ import { seedDefaultBehaviors } from "./seed-behaviors";
28
+ import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
29
+ import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, watcherTarget, withCwdFallback, type Watcher } from "./watchers";
30
+ import { watcherNotificationNudge, watcherRearmNotice, watcherDeathNotice, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
31
+ import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle, unexpiredGraceRehosts } from "./chat";
32
+ import { chatEnabled, chatAutoRegister, loadConfig, alertMode, notificationDefaults } from "./config";
33
+ import { sendNotification } from "./notify";
34
+ import { osProcessArgs, startupSessionId, continuesLastSessionFromArgv, continuesLastSessionId } from "./os-args";
35
+ import type { HostCapabilities } from "./capabilities";
36
+ import pkg from "../package.json";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Shared plugin runtime - host-agnostic across opencode v1 and v2
40
+ // ---------------------------------------------------------------------------
41
+
42
+ // Minimum cosine score for the prompt-aware recall nudge. Lower than
43
+ // findDuplicates' 0.85 (near-dupes) because "relates to" is a weaker signal
44
+ // than "duplicate." Tunable via THATCH_RECALL_THRESHOLD.
45
+ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55");
46
+
47
+ // How long a dormant watcher journal row (a restart kept it because its
48
+ // session may yet be resumed) survives past its newest definition's TTL
49
+ // before aging out. The TTL alone would kill the row the morning after an
50
+ // overnight watch; the grace keeps recovery possible for sessions the user
51
+ // returns to days later.
52
+ const DORMANT_WATCHER_GRACE_MS = 24 * 60 * 60 * 1000;
53
+
54
+ // Grace rehosting after a RESTART (see the hosted-row branch in the
55
+ // rehydration loop): the dead daemon's hosted set re-hosts for this window.
56
+ // Open TUI tabs reconnect to the new daemon but emit no events until the
57
+ // user types, so without the grace they go falsely stale ("presumed gone")
58
+ // while alive-idle, and mail stops waking them. Sessions that emit activity
59
+ // graduate to permanent hosting via the event stream; still-silent ones
60
+ // (closed tabs) age out at the grace and go stale again. Matches
61
+ // DORMANT_WATCHER_GRACE_MS - same resumability shape, same trade.
62
+ const CHAT_REHOST_GRACE_MS =
63
+ (Number(process.env.THATCH_CHAT_REHOST_GRACE_MINUTES ?? 0) * 60_000) || 24 * 60 * 60 * 1000;
64
+
65
+ /**
66
+ * True when every definition in a dormant watcher row is past its TTL plus
67
+ * the dormant grace period - the row is a corpse no resume can usefully
68
+ * re-arm. An empty or malformed payload counts as stale.
69
+ */
70
+ function dormantWatchersStale(defs: Watcher[]): boolean {
71
+ if (!Array.isArray(defs) || defs.length === 0) return true;
72
+ const lastExpiry = Math.max(...defs.map((w) => Number((w as { expiresAt?: number })?.expiresAt ?? 0)));
73
+ return Date.now() > lastExpiry + DORMANT_WATCHER_GRACE_MS;
74
+ }
75
+
76
+ /** Process-wide pool for the shared embedding models (see SharedModelPool). */
77
+ const sharedModels = new SharedModelPool();
78
+
79
+ // Prompts shorter than this skip the recall nudge - trivially short prompts
80
+ // like "yes" or "ok" match too broadly to be useful.
81
+ const MIN_PROMPT_LEN = 10;
82
+
83
+ // Minimum cosine score for the prediction auto-fire. Higher than the
84
+ // recall nudge (0.55) because surfacing a user-preference nudge is more
85
+ // disruptive than surfacing a memory -- the agent may act on it or
86
+ // surface it to the user. 0.60 cuts noise in dense embedding spaces
87
+ // while still catching genuinely related contexts.
88
+ const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.60");
89
+
90
+ // Same threshold for behavior auto-fire. Same rationale: surfacing a
91
+ // self-discipline rule is disruptive and should only fire when the
92
+ // situation genuinely matches.
93
+ const BEHAVIOR_THRESHOLD = parseFloat(process.env.THATCH_BEHAVIOR_THRESHOLD ?? "0.60");
94
+
95
+ // Wrap-up command registry: command name as opencode derives it from the
96
+ // installed file path (~/.config/opencode/command/thatch/<name>.md), the
97
+ // greenlight token the command instructs the model to end its response with,
98
+ // and which TUI action the greenlight triggers.
99
+ const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit" }> = {
100
+ "thatch/compact": { token: COMPACT_READY_TOKEN, kind: "compact" },
101
+ "thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
102
+ };
103
+
104
+ // The handler-set shape both host adapters consume. The methods correspond
105
+ // to the v1 hook surface (plus the per-call coreContext and the
106
+ // host-agnostic debug log); the v2 adapter routes its domain hooks into
107
+ // these same methods. Part and message shapes are kept structural (the v1
108
+ // bodies already treat parts loosely) so neither adapter has to convert
109
+ // host SDK types.
110
+ export interface ThatchRuntime {
111
+ /** The shared per-call context both adapters register TOOL_DEFS with. */
112
+ coreContext: CoreContext;
113
+ /** The host-agnostic diagnostic log (THATCH_DEBUG; no-op when unset). */
114
+ debug(tag: string, message: string): void;
115
+ /**
116
+ * Session IDs with live child bookkeeping (in-flight extraction children).
117
+ * The v2 adapter seeds its event-forwarding set from this after a reload:
118
+ * the rehydrated child maps must be visible to the pump's directory
119
+ * filter, or a below-root launch drops the child's events again.
120
+ */
121
+ childSessionIds(): string[];
122
+ onSystemTransform(output: { system: string[] }): Promise<void>;
123
+ onSessionCompacting(input: { sessionID: string }, output: { context: string[] }): Promise<void>;
124
+ onCompactionAutocontinue(input: { sessionID: string }): Promise<void>;
125
+ onToolExecuteAfter(
126
+ input: { tool: string; sessionID: string; args?: unknown },
127
+ output: { title: string; output: string },
128
+ ): Promise<void>;
129
+ onCommandExecuteBefore(input: { command: string; sessionID: string }): Promise<void>;
130
+ onChatMessage(
131
+ input: { sessionID: string; messageID?: string },
132
+ output: { parts: any[]; message: { id: string } },
133
+ ): Promise<void>;
134
+ onEvent(event: any): Promise<void>;
135
+ dispose(): Promise<void>;
136
+ }
137
+
138
+ export async function createRuntime(input: {
139
+ capabilities: HostCapabilities;
140
+ directory: string;
141
+ worktree: string;
142
+ }): Promise<ThatchRuntime> {
143
+ const { capabilities: caps, directory, worktree } = input;
144
+ // The opencode server's cwd is wherever the server happened to start;
145
+ // `worktree` is the project this plugin instance actually serves.
146
+ // `directory` is the session's own directory - on resume after the
147
+ // worktree was deleted, opencode reassigns worktree to "/" (it walks up
148
+ // from the missing dir, finds no .git, and boots the global project) but
149
+ // still forwards the original path as directory, which is the only input
150
+ // the repo_paths cache can recover identity from.
151
+ const home = process.env.HOME ?? "/tmp";
152
+ const configHome = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
153
+ const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
154
+ const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
155
+
156
+ // Set by dispose(). Delayed async work (the child-reconcile timer, the
157
+ // extraction pipeline's queued finalization, queued event handlers) can
158
+ // fire after the db is closed; anything running past this flag must not
159
+ // touch the db - and journal writes must no-op BEFORE the lazy reopen
160
+ // accessor (src/db.ts) would hand them a fresh connection, because after
161
+ // a v2 plugin reload the RELOADED instance owns those rows.
162
+ let disposed = false;
163
+
164
+ // The DB must exist before identity detection: detectRepo consults the
165
+ // repo_paths cache when the session directory is gone.
166
+ const db = new ThatchDB(dbPath);
167
+ const repoCache = repoPathCache(db);
168
+ const repo = await detectRepo(directory ?? worktree, repoCache);
169
+
170
+ // Whether cross-session chat is on (chat.enabled, default on). Read once
171
+ // at init: it gates the chat poller and the prompt's chat section. The
172
+ // chat tools re-read the file per call, so a toggle takes effect there
173
+ // without a restart.
174
+ const chatOn = chatEnabled(loadConfig(dbPath).config);
175
+
176
+ const debug = createDebugLog(dbPath);
177
+
178
+ // Shared embedding model: refcounted per db path (see SharedModelPool).
179
+ const model = sharedModels.acquire(dbPath, modelName);
180
+ const releaseModel = () => sharedModels.release(dbPath);
181
+
182
+ // Extraction buffer, journaled to runtime_state so a v2 plugin reload
183
+ // (same process, graph rebuilt) can rehydrate it. The journal callback
184
+ // checks the disposed flag FIRST: a finalization delayed past dispose
185
+ // (await points inside the pipeline) must not write through the lazy
186
+ // reopen accessor after the reload - the reloaded instance owns those
187
+ // rows. Same ownership rule as journalChild below.
188
+ const extraction = new ExtractionPipeline((kind, sessionID, value) => {
189
+ if (disposed) return;
190
+ if (value === undefined) db.runtimeStateDelete(kind, sessionID);
191
+ else db.runtimeStatePut(kind, sessionID, value, directory);
192
+ });
193
+
194
+ // Seed default behaviors into the global store on first run, and
195
+ // update them when their content changes across releases. Idempotent:
196
+ // behaviors are matched by key stamp, not by cosine similarity, so
197
+ // user-codified behaviors are never overwritten. See seed-behaviors.ts
198
+ // for the full mechanism.
199
+ await seedDefaultBehaviors(db, model);
200
+
201
+ // Start background npm update polling. The checker caches the latest
202
+ // version in memory and to a file. Never blocks the plugin or tool calls.
203
+ // The opencode plugin path has no hook/server split, so version skew
204
+ // between hooks and server is not a concern here. But two opencode
205
+ // sessions on different versions sharing one DB can cause schema/data
206
+ // corruption. The npm update nudge covers the "please update" case.
207
+ // The on-disk version check (readOnDiskVersion) catches the case where
208
+ // the user ran `npm update` but hasn't restarted opencode yet.
209
+ startVersionChecker(dbPath);
210
+
211
+ // The running version, frozen at module load time. Compared against the
212
+ // on-disk package.json version on each chat.message to detect post-upgrade
213
+ // non-restart.
214
+ const runningVersion = pkg.version;
215
+
216
+ // Latest observed opencode session status per session ("busy" | "idle" |
217
+ // "retry"). Keys enter from session.status events and leave only in
218
+ // session.deleted (which shrinks the chat poller's hosted set). Readers:
219
+ // the watcher registry's and the chat poller's canDeliver gates
220
+ // (proactive prompts only fire into idle sessions, never into a running
221
+ // turn), and the chat poller's hostedSessions set (the sessions this
222
+ // process may deliver chat mail to - the map's keys).
223
+ const sessionStatus = new Map<string, string>();
224
+
225
+ // LLM alerts (docs/plans/llm-alerts.md): notify when the LLM pauses for
226
+ // human input or finishes a round of real work. Plain in-memory state -
227
+ // a v2 reload mid-turn loses that turn's transition and stays silent
228
+ // (accepted). Delivery reuses the notify_user dispatcher; the alerts
229
+ // config section picks the channel per event, defaulting to banner. The
230
+ // config is re-read per delivery so a config_set (or hand edit) applies
231
+ // without a restart, the same contract the chat tools' per-call config
232
+ // read gives the chat section.
233
+ const alerts = createAlerts({
234
+ config: () => loadConfig(dbPath).config,
235
+ roundShape: async (sessionID) =>
236
+ deriveRoundShape(await caps.sessionMessages(sessionID)),
237
+ sessionTitle: async (sessionID) => (await caps.sessionGet(sessionID))?.title,
238
+ notify: async ({ kind, title, message }) => {
239
+ const defaults = notificationDefaults();
240
+ await sendNotification({
241
+ message,
242
+ title,
243
+ source: title,
244
+ // The machine owns the per-event "none" gate and never calls here
245
+ // with one - this cast records that contract.
246
+ channel: alertMode(loadConfig(dbPath).config, kind) as "both" | "banner" | "voice",
247
+ voice: defaults.voice,
248
+ sound: defaults.sound,
249
+ });
250
+ },
251
+ });
252
+
253
+ // The proactive-prompt gate, shared by both delivery registries (watchers
254
+ // and chat). Two layers, in cost order: the compacting set and the
255
+ // event-fed sessionStatus map answer cheaply, and a final check verifies
256
+ // against the server's live status - the map is only as fresh as the
257
+ // event stream, and a stale "idle" turns a wake into mid-turn context
258
+ // injection (observed live: a session read mail mid-burst, contradicted
259
+ // its own in-flight plan, and looped).
260
+ const canPromptSession = createWakeGate({
261
+ isCompacting: (sessionID) => compacting.has(sessionID),
262
+ mappedStatus: (sessionID) => sessionStatus.get(sessionID),
263
+ fetchStatuses: async () => {
264
+ return (await caps.fetchStatuses()) ?? {};
265
+ },
266
+ onStatusError: (sessionID, err) => {
267
+ console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
268
+ },
269
+ });
270
+
271
+ // In-memory watcher registry for proactive event notifications (GitHub PR
272
+ // and branch watching, plus local command watches). Live state is
273
+ // process-scoped; definitions are journalled for reload rehydration and
274
+ // restart dormancy. See src/watchers.ts for the rationale.
275
+ //
276
+ // Delivery prompts the session with a synthetic part - the same mechanism
277
+ // opencode uses for background task completions - so a watched event
278
+ // triggers a model turn even when the user is away. Events carry pointer
279
+ // data plus machine status (check conclusions, exit codes); the model
280
+ // fetches logs and further details itself with gh.
281
+ // Watched-command runner: resolves the spawn cwd per poll. When the
282
+ // project directory is deleted mid-watch (worktree merged and cleaned up),
283
+ // the poll falls back to the repo_paths cache's main checkout instead of
284
+ // spinning ENOENT errors until the TTL. The wrapper logs the fallback
285
+ // once per dead directory - per-cycle error logging is the existing
286
+ // failure mode and it spams.
287
+ const watchedCommandRunner = withCwdFallback(runWatchedCommand, (cwd) => resolveSpawnCwd(cwd, repoCache), (from, to) => {
288
+ console.error(`[thatch] project directory ${from} deleted; watcher falling back to main checkout ${to}`);
289
+ });
290
+
291
+ const watchers = new WatcherRegistry({
292
+ // Journal watcher definitions so a v2 plugin reload (same process) can
293
+ // re-arm them; the registry journals after every membership change.
294
+ // Disposed check FIRST: a poll cycle suspended at an await can outlive
295
+ // watchers.dispose() and reach this callback after db.close(); with the
296
+ // lazy reopen it would otherwise hand a stale writer a fresh connection
297
+ // and resurrect an expired watcher after the reload. Same ownership rule
298
+ // as the extraction callback and journalChild.
299
+ journal: (sessionID, sessionWatchers) => {
300
+ if (disposed) return;
301
+ if (sessionWatchers.length === 0) db.runtimeStateDelete("watchers", sessionID);
302
+ else db.runtimeStatePut("watchers", sessionID, sessionWatchers, directory);
303
+ },
304
+ deliver: async (sessionID, events) => {
305
+ // Events carry their watch's target label from the registry, so the
306
+ // notification header is correct for every source (PRs, branches,
307
+ // commands, CI events whose URLs would not parse).
308
+ await caps.promptSession(
309
+ sessionID,
310
+ {
311
+ parts: [
312
+ { type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true },
313
+ ],
314
+ },
315
+ "async",
316
+ );
317
+ // Toast: the notification part is TUI-hidden, so without this the
318
+ // user watching the session sees the model wake up with no visible
319
+ // cause. Announce what fired. Failed checks/workflows surface as a
320
+ // warning variant; routine events as info. Best-effort - the TUI may
321
+ // not be connected (headless mode).
322
+ const failed = events.some((e) => e.summary.includes("failure"));
323
+ const more = events.length > 1 ? ` +${events.length - 1} more` : "";
324
+ try {
325
+ await caps.showToast({
326
+ message: `⏰ ${events[0]?.target ?? "watched target"}: ${events[0]?.summary ?? ""}${more}`,
327
+ variant: failed ? "warning" : "info",
328
+ duration: 5000,
329
+ });
330
+ } catch {
331
+ // TUI may not be connected. Best-effort.
332
+ }
333
+ },
334
+ canDeliver: canPromptSession,
335
+ ghRunner: ghApiRun,
336
+ commandRunner: watchedCommandRunner,
337
+ });
338
+ // gh presence decides whether watch_create and watch_branch_create work;
339
+ // checked lazily by the tools, but log once at startup so misconfiguration
340
+ // is visible in debug logs. watch_command_create needs only bash, not gh.
341
+ void ghAvailable().then((ok) => {
342
+ if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
343
+ });
344
+ watchers.start();
345
+
346
+ // The session this harness was launched to resume, resolved at startup:
347
+ // `-s <id>` names it on the command line, `-c` means "most recent in this
348
+ // directory" and resolves through the SDK. Either way the poller hosts it
349
+ // from the first beat, so the heartbeat and delivery are live before the
350
+ // user's first prompt.
351
+ // Startup-resumed chat sessions. A Set (not a scalar): one plugin
352
+ // instance can serve several sessions - shared-server tabs in the same
353
+ // directory each resolve their own -c/-s target.
354
+ const resumedSessions = new Set<string>();
355
+
356
+ // Chat names assigned by the startup resume paths (-s and -c) wait here
357
+ // until the session's first message or idle event, which toast them. At
358
+ // plugin-init time the TUI is not yet connected, so an immediate toast
359
+ // would be dropped silently.
360
+ const chatStartupNames = new Map<string, string>();
361
+
362
+ const chatPoller = new ChatPoller({
363
+ store: db,
364
+ hostedSessions: () => {
365
+ const now = Date.now();
366
+ const hosted = hostedSessionIds({
367
+ statusKeys: sessionStatus.keys(),
368
+ resumedSessions: resumedSessions,
369
+ rehostedSessions: [...rehostedSessions, ...unexpiredGraceRehosts(rehostGrace, now)],
370
+ exclude: childToParent.keys(),
371
+ });
372
+ // The journal NEVER SHRINKS within a process lifetime: a plugin
373
+ // reload rebuilds the hosted set empty (sessionStatus is in-memory),
374
+ // so a session that was idle across a reload would otherwise be
375
+ // dropped from the set and never re-hosted - falsely stale while its
376
+ // tab is open. The union means the next reload re-graces everything
377
+ // this process ever hosted (bounded by the 24h grace decay);
378
+ // shrinking happens only through session.deleted, which rewrites the
379
+ // row.
380
+ const prevRow = db
381
+ .runtimeStateAll()
382
+ .find((r) => r.kind === "hosted" && r.sessionID === directory);
383
+ const prevIds = prevRow && Array.isArray(prevRow.value) ? (prevRow.value as string[]) : [];
384
+ const merged = [...new Set([...prevIds, ...hosted])];
385
+ db.runtimeStatePut("hosted", directory, merged, directory);
386
+ return hosted;
387
+ },
388
+ deliver: async (sessionID, senders, count) => {
389
+ await caps.promptSession(
390
+ sessionID,
391
+ {
392
+ parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
393
+ },
394
+ "async",
395
+ );
396
+ try {
397
+ await caps.showToast({
398
+ message: `💬 chat: ${count} unread from ${senders.join(", ")}`,
399
+ variant: "info",
400
+ duration: 5000,
401
+ });
402
+ } catch {
403
+ // TUI may not be connected. Best-effort.
404
+ }
405
+ },
406
+ canDeliver: canPromptSession,
407
+ });
408
+ // With chat disabled the tools refuse, so the poller would only heartbeat
409
+ // sessions and burn wake budget against a feature the user turned off.
410
+ if (chatOn) chatPoller.start();
411
+
412
+ // Startup registration: `opencode -s <id>` continues a session whose
413
+ // directory row may predate this process (its harness died, or the
414
+ // machine restarted); `opencode -c` continues the most recent session in
415
+ // this directory. The framework passes no session id to plugins and
416
+ // resuming fires no events, so the command line is the source (see
417
+ // startupSessionId). Registering here RECLAIMS the row - the name is
418
+ // owned by the session id and never changes - and refreshes its
419
+ // heartbeat: synchronously for -s (local SQLite, it cannot fail on a
420
+ // server that is still starting); for -c after an SDK list call resolves
421
+ // the target. The async tail (title fetch, asleep-mail delivery) is
422
+ // best-effort - the poller retries whatever it misses, and the first
423
+ // idle converges the topic.
424
+ const reclaimTail = (sessionID: string) => {
425
+ void (async () => {
426
+ try {
427
+ const data = await caps.sessionGet(sessionID);
428
+ const title = data?.title ?? "";
429
+ const topic = title && !isDefaultSessionTitle(title) ? title : null;
430
+ if (topic) db.refreshChatTopic(sessionID, topic);
431
+ } catch {
432
+ // Server may still be starting; the topic converges on the
433
+ // session's first idle either way.
434
+ }
435
+ try {
436
+ // Asleep-mail: deliver what queued while this session was away,
437
+ // through the normal path (gate, nudge, toast, stamps).
438
+ await chatPoller.deliverPending();
439
+ } catch {
440
+ // The poller cycle retries delivery; nothing to do here.
441
+ }
442
+ })();
443
+ };
444
+ const osArgs = osProcessArgs();
445
+ const startupSession = startupSessionId(osArgs);
446
+ const continueLast = !startupSession && (continuesLastSessionFromArgv(process.argv) || continuesLastSessionFromArgv(osArgs));
447
+ // The session whose journalled runtime state a restart inherits: -s names
448
+ // it on the command line; -c means "most recent top-level session in this
449
+ // directory", resolved straight from the session database because the
450
+ // rehydration loop below is synchronous and the v2 plugin context has no
451
+ // session.list for the SDK route. Resolving it HERE is the point -
452
+ // after the loop, the inherited rows are already pruned.
453
+ const continueTarget = continueLast && startupSession === undefined
454
+ ? mostRecentTopLevelSessionId(directory ?? worktree)
455
+ : null;
456
+ const inheritSession = startupSession ?? continueTarget ?? undefined;
457
+ const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
458
+ const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
459
+ debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} continue=${continueLast} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
460
+ if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
461
+ resumedSessions.add(startupSession);
462
+ const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
463
+ debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
464
+ if (res.ok) {
465
+ chatStartupNames.set(startupSession, res.name);
466
+ reclaimTail(startupSession);
467
+ }
468
+ } else if (chatOn && autoRegisterOn && continueLast) {
469
+ void (async () => {
470
+ try {
471
+ const data = await caps.sessionList();
472
+ const target = continuesLastSessionId(data ?? []);
473
+ debug("chat:startup", `-c resolved: ${target ?? "none"}`);
474
+ if (!target || db.hasChatLeaveTombstone(target)) return;
475
+ resumedSessions.add(target);
476
+ const res = db.registerChatSession(target, repo, null, "opencode", null, detectWorktreeKind(worktree));
477
+ debug("chat:startup", `registration for ${target}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
478
+ if (res.ok) {
479
+ chatStartupNames.set(target, res.name);
480
+ reclaimTail(target);
481
+ }
482
+ } catch (err) {
483
+ console.error(`[thatch] chat -c startup registration failed: ${err}`);
484
+ }
485
+ })();
486
+ }
487
+
488
+
489
+ // Sessions currently being compacted. chat.message nudges are skipped while
490
+ // a session is in this set - the agent can't call tools during summary
491
+ // generation, so a recall or extraction nudge would cause a blocked-tool
492
+ // error. Cleared by experimental.compaction.autocontinue (success), the
493
+ // session.compacted event (redundant belt-and-suspenders), or chat.message
494
+ // itself when a non-compaction message arrives (compaction failure fallback
495
+ // - autocontinue never fired, so the next real user message clears the
496
+ // stale flag). If all three somehow miss, the flag leaks (graceful
497
+ // degradation: nudges stay off for that session, but no crash).
498
+ const compacting = new Set<string>();
499
+
500
+ // Per-session count of consecutive extraction nudges delivered without any
501
+ // memory_remember call in between. Drives nudge escalation: the agent gets
502
+ // a couple of polite chances, then the tone shifts to directive, then to
503
+ // all-caps shouting. Reset to 0 whenever the agent writes a memory.
504
+ const missedNudges = new Map<string, number>();
505
+
506
+ // Parent-child session mapping for cross-session buffer drain. opencode
507
+ // creates real child sessions with their own IDs for sub-agents; without
508
+ // this map, a memory_remember call in a child (e.g. a background
509
+ // fact-extractor task) drains the child's empty buffer but leaves the
510
+ // parent's buffer untouched, causing the nudge to replay indefinitely.
511
+ // Populated from session.created events that carry a parentID.
512
+ const childToParent = new Map<string, string>();
513
+
514
+ // Per-child kind ("extraction" | "task"), recorded at session.created from
515
+ // the live extractionChildren set and at restore from the journal record.
516
+ // The parent-state mutations a child's lifecycle triggers (snapshot drain
517
+ // on memory write, whole-set requeue on error/deletion) are only correct
518
+ // for extraction children - a task sub-agent saving its own findings never
519
+ // processed the parent's buffer, and treating its lifecycle as extraction
520
+ // evidence silently dropped in-flight or pending entries (the v2
521
+ // all-children-linked loss vector, September 2026).
522
+ const childKinds = new Map<string, "extraction" | "task">();
523
+
524
+ // Snapshot of the parent's buffer at the moment each child was dispatched.
525
+ // When the child writes a memory, consumeSnapshot drains only these entries
526
+ // (by reference identity), preserving interleaved-turn entries that arrived
527
+ // while the sub-agent was running. Without this, the child's memory_remember
528
+ // would drain the parent's ENTIRE buffer - silently dropping facts from
529
+ // any tool calls the parent made concurrently with the sub-agent.
530
+ const parentSnapshots = new Map<string, ToolInteraction[]>();
531
+
532
+ // Parent sessions with an active direct-extraction child. When the parent
533
+ // goes idle with pending tool interactions, the plugin creates a child
534
+ // session and prompts it directly through the host's prompt capability
535
+ // instead of injecting a nudge into the next user message. This set
536
+ // suppresses the nudge path
537
+ // while the child runs, and prevents re-triggering if the parent goes idle
538
+ // again before the child finishes. Cleared when the child goes idle, errors,
539
+ // or is deleted. If direct extraction fails, the set is cleared so the nudge
540
+ // path takes over as a fallback on the next chat.message.
541
+ const extracting = new Set<string>();
542
+
543
+ // Child session IDs created by triggerExtraction (direct extraction only).
544
+ // The child idle handler uses this to distinguish plugin-created extraction
545
+ // children from task-dispatched sub-agents (code review specialists, the
546
+ // nudge-path fact-extractor, any model-dispatched task). Only extraction
547
+ // children get the full cleanup: buffer drain, session deletion, toast.
548
+ // Non-extraction children get claim-scoped completion (a no-op for a
549
+ // child that never fetched a payload) + missedNudges.reset so their
550
+ // sessions are not deleted out from under the task tool that dispatched
551
+ // them.
552
+ const extractionChildren = new Set<string>();
553
+
554
+ // Per-child-session extraction metrics for the toast notification. When the
555
+ // child session goes idle, these counts are formatted into a toast that
556
+ // shows the user thatch is working without polluting the conversation.
557
+ // Keyed by child session ID. Cleaned up on child idle, error, or deletion.
558
+ const childMetrics = new Map<string, { new: number; updated: number; deleted: number }>();
559
+
560
+ // Journal the child bookkeeping (parent link, buffer snapshot, metrics) so
561
+ // a v2 plugin reload can rehydrate an in-flight extraction instead of
562
+ // orphaning it. A child with no parent entry journals a delete. The kind
563
+ // marker distinguishes extraction children from task-dispatched sub-agents
564
+ // (the live source of truth is the extractionChildren set): restoring a
565
+ // task sub-agent as an extraction child let its idle take the extraction
566
+ // branch - consumeSnapshot silently dropped the parent's buffered entries
567
+ // without extraction, and the task tool's session got a delete call.
568
+ const journalChild = (childId: string) => {
569
+ if (disposed) return;
570
+ const parentID = childToParent.get(childId);
571
+ if (!parentID) {
572
+ db.runtimeStateDelete("child", childId);
573
+ return;
574
+ }
575
+ db.runtimeStatePut("child", childId, {
576
+ kind: extractionChildren.has(childId) ? "extraction" : "task",
577
+ parentID,
578
+ snapshot: parentSnapshots.get(childId) ?? [],
579
+ metrics: childMetrics.get(childId) ?? { new: 0, updated: 0, deleted: 0 },
580
+ }, directory);
581
+ };
582
+
583
+ // Wrap-up slash commands (/thatch/compact, /thatch/exit) awaiting their
584
+ // greenlight check. command.execute.before marks the session when one of
585
+ // these commands runs; when the session next goes idle, the plugin reads
586
+ // the final assistant message for the ready token (the greenlight) and
587
+ // triggers the TUI action. Token absence means the model listed blockers
588
+ // instead, so nothing fires beyond a toast. Cleared on resolution or on
589
+ // session deletion.
590
+ const pendingWrapUp = new Map<string, { token: string; kind: "compact" | "exit" }>();
591
+
592
+ // Rehydrate persisted runtime state (docs/dev/features/opencode-plugin.md,
593
+ // the runtime_state row).
594
+ // Rows are instance-scoped (kind+session, tagged with the writer's pid and
595
+ // location directory):
596
+ // - same pid, same directory: a v2 plugin reload rebuilt THIS instance's
597
+ // graph - rehydrate everything.
598
+ // - same pid, other directory: a live sibling instance's rows (one serve
599
+ // hosts one instance per location) - leave them alone; hydrating them
600
+ // would double-poll watchers and re-deliver another instance's state.
601
+ // - foreign pid: a process restart, whose sessions died with their
602
+ // harness. Only a startup-resumed session (-s/-c) in this directory
603
+ // inherits recovery-safe state; everything else is pruned.
604
+ const rehydratedSessions = new Set<string>();
605
+ // Extraction children restored by a same-pid reload (childId -> parentId).
606
+ // Their idle event may have fired during the reload window and been lost;
607
+ // reconciled after setup (see reconcileRestoredChildren).
608
+ const restoredChildren = new Map<string, string>();
609
+ // Sessions this instance hosted, restored from the journal on a reload
610
+ // (same pid + same directory). Written through by the poller's hosted-set
611
+ // read, so the next reload picks up the latest set.
612
+ const rehostedSessions = new Set<string>();
613
+ // Foreign-pid hosted rows (restart): the dead daemon's hosted set,
614
+ // re-hosted under a grace window instead of dropped - open TUI tabs
615
+ // reconnect but emit no events until the user types. id -> grace
616
+ // deadline (epoch ms).
617
+ const rehostGrace = new Map<string, number>();
618
+ const rehydrated: Record<string, number> = {};
619
+ for (const row of db.runtimeStateAll()) {
620
+ const samePid = row.pid === process.pid;
621
+ const ownDirectory = row.directory === directory;
622
+ const isStartup = inheritSession !== undefined && row.sessionID === inheritSession;
623
+ if (samePid && !ownDirectory) continue; // live sibling - untouched
624
+ if (!samePid && !ownDirectory) continue; // another location's instance owns these rows - its own restart cleans them
625
+ if (!samePid && !isStartup) {
626
+ // Watcher definitions survive a restart as DORMANT rows instead of
627
+ // being pruned: the defining session's harness died with the old
628
+ // process, but the session itself is resumable (session picker, v2
629
+ // tab reopen), and resuming it re-arms the watches through
630
+ // scanDormantWatchers. Rows whose every definition outlived the
631
+ // watcher TTL plus the dormant grace period age out here instead -
632
+ // corpses of sessions that will probably never return.
633
+ if (row.kind === "watchers" && !dormantWatchersStale((row.value as Watcher[] | null) ?? [])) {
634
+ continue;
635
+ }
636
+ // A foreign-pid HOSTED row re-hosts under a grace window instead of
637
+ // being pruned: open TUI tabs reconnect to the new daemon but emit
638
+ // no events until the user types, so dropping the set made them
639
+ // falsely stale ("presumed gone") while alive-idle, and mail to
640
+ // them stopped waking the tab. Sessions that emit activity graduate
641
+ // to permanent hosting via the event stream; still-silent ones
642
+ // (closed tabs) age out at the grace and go stale. The row itself
643
+ // stays: the poller's next hosted-set journal rewrites it, and a
644
+ // further restart re-graces whatever it then holds.
645
+ if (row.kind === "hosted") {
646
+ for (const id of (row.value ?? []) as string[]) {
647
+ rehostGrace.set(id, Date.now() + CHAT_REHOST_GRACE_MS);
648
+ }
649
+ continue;
650
+ }
651
+ db.runtimeStateDelete(row.kind, row.sessionID);
652
+ continue;
653
+ }
654
+ if (row.kind === "buffer") {
655
+ extraction.hydrate(row.value as ToolInteraction[], []);
656
+ } else if (row.kind === "accepted") {
657
+ // On a RESTART the extractor child died with the old process - there
658
+ // is nobody left to complete the accepted queue, and the first
659
+ // unrelated completion signal would silently drop it. Requeue as
660
+ // pending instead: the resumed session's next nudge honestly
661
+ // re-extracts it. On a reload the child may still be running and
662
+ // stays accepted.
663
+ if (!samePid) extraction.hydrate(row.value as ToolInteraction[], []);
664
+ else extraction.hydrate([], row.value as ToolInteraction[]);
665
+ } else if (row.kind === "watchers") {
666
+ watchers.hydrate((row.value ?? []) as Watcher[]);
667
+ } else if (row.kind === "hosted") {
668
+ // Same-pid reload: re-host permanently (the plugin re-ran in place;
669
+ // the sessions may still be running). Foreign-pid rows were graced
670
+ // in the restart branch above.
671
+ for (const id of (row.value ?? []) as string[]) rehostedSessions.add(id);
672
+ } else if (row.kind === "child") {
673
+ const rec = row.value as { kind?: "extraction" | "task"; parentID?: string; snapshot?: ToolInteraction[]; metrics?: { new: number; updated: number; deleted: number } };
674
+ if (samePid && rec?.parentID) {
675
+ // Reload: the child may still be running - restore its live
676
+ // bookkeeping so its idle event finds the maps populated. The
677
+ // default covers rows written before the marker existed; it errs
678
+ // toward extraction (defaulting to task would strand a legacy
679
+ // in-flight extraction child with extracting never restored,
680
+ // suppressing both extraction paths for the session's life), at
681
+ // the cost of resurrecting the restore-time misclassification for
682
+ // the rarer pre-upgrade in-flight task child - a one-window trade,
683
+ // self-healing as rows are rewritten with kinds.
684
+ const kind = rec.kind ?? "extraction";
685
+ childKinds.set(row.sessionID, kind);
686
+ childToParent.set(row.sessionID, rec.parentID);
687
+ parentSnapshots.set(row.sessionID, rec.snapshot ?? []);
688
+ if (rec.metrics) childMetrics.set(row.sessionID, rec.metrics);
689
+ if (kind === "extraction") {
690
+ extractionChildren.add(row.sessionID);
691
+ extracting.add(rec.parentID);
692
+ restoredChildren.set(row.sessionID, rec.parentID);
693
+ }
694
+ } else {
695
+ // Restart: the child ran in the dead process and no execution event
696
+ // will ever arrive for it. Recover the snapshot as plain pending
697
+ // entries (extraction stays available) and drop the record - never
698
+ // restore `extracting`, which would suppress both extraction paths
699
+ // for the resumed session forever. The guard tests the child's
700
+ // PARENT against the inherited session, not the row's own session
701
+ // id: child rows are keyed by child id, so an isStartup-style check
702
+ // could never hold and the snapshot recovery was unreachable.
703
+ if (inheritSession !== undefined && rec?.parentID === inheritSession && Array.isArray(rec.snapshot)) {
704
+ for (const interaction of rec.snapshot) extraction.push(interaction);
705
+ }
706
+ db.runtimeStateDelete(row.kind, row.sessionID);
707
+ continue;
708
+ }
709
+ } else if (row.kind === "wrapup") {
710
+ if (!samePid) {
711
+ // An armed wrap-up inherited across a restart could auto-fire
712
+ // compact/exit on the resumed session's first idle if the final
713
+ // message already carries the token. Too dangerous to inherit.
714
+ db.runtimeStateDelete(row.kind, row.sessionID);
715
+ continue;
716
+ }
717
+ pendingWrapUp.set(row.sessionID, row.value as { token: string; kind: "compact" | "exit" });
718
+ } else {
719
+ continue; // unknown kind - leave it for a future version
720
+ }
721
+ rehydrated[row.kind] = (rehydrated[row.kind] ?? 0) + 1;
722
+ rehydratedSessions.add(row.sessionID);
723
+ }
724
+ if (Object.keys(rehydrated).length > 0) {
725
+ const counts = Object.entries(rehydrated)
726
+ .map(([kind, n]) => `${kind}=${n}`)
727
+ .join(" ");
728
+ debug("runtime:rehydrate", `restored ${counts}`);
729
+ // Ping the restored sessions directly: the reload/restart happened
730
+ // outside their event stream, so without this the sessions would not
731
+ // know the plugin is back. Synthetic + noReply on v1 renders a visible
732
+ // bubble and starts no model turn. On v2 there is no turn-free delivery
733
+ // (a noReply body falls through to session.prompt: a real message and a
734
+ // model turn in every restored session on every file save), so v2 skips
735
+ // the notice entirely - the re-hosted poller's mail delivery is the
736
+ // wake there.
737
+ if (chatOn && caps.noReplyDelivery) {
738
+ for (const sessionID of rehydratedSessions) {
739
+ void caps
740
+ .promptSession(
741
+ sessionID,
742
+ {
743
+ parts: [
744
+ {
745
+ type: "text",
746
+ text: "[thatch] plugin reloaded - persisted state restored (extraction buffers, watchers, wrap-ups). No action needed.",
747
+ synthetic: true,
748
+ },
749
+ ],
750
+ noReply: true,
751
+ },
752
+ "async",
753
+ )
754
+ .catch(() => {
755
+ // The session may be gone (restart prune edge) - the poller's
756
+ // normal gates handle delivery; nothing to do here.
757
+ });
758
+ }
759
+ }
760
+ }
761
+
762
+ // Dormant watcher recovery (watcher-continuity fast-follow). A restart
763
+ // keeps foreign-pid watcher journal rows instead of pruning them (see the
764
+ // rehydration loop): the defining session's harness died, but the session
765
+ // is resumable. A resumed session's first message scans the dormant rows:
766
+ // - the session's own rows re-arm into the live registry (no
767
+ // re-registration step, so stated watch policy survives reboots);
768
+ // - rows of other still-dead sessions in THIS directory earn the live
769
+ // session a one-line death notice - a watch that will never fire is
770
+ // at least heard about. Deduplicated per process per target, so one
771
+ // active session hears about each corpse once;
772
+ // - rows past the TTL plus the dormant grace age out silently.
773
+ // Death notices are gated on the owning pid actually being dead: a v1
774
+ // sibling process (separate TUI window, shared project db, different pid,
775
+ // still alive) owns live watchers that must never be reported dead.
776
+ const dormantScanned = new Set<string>();
777
+ const notifiedWatcherDeaths = new Set<string>();
778
+ const pidAlive = (pid: number): boolean => {
779
+ if (!Number.isInteger(pid) || pid <= 0) return false;
780
+ try {
781
+ process.kill(pid, 0);
782
+ return true;
783
+ } catch (err) {
784
+ // ESRCH: no such process. Anything else (e.g. EPERM on a foreign-user
785
+ // pid) means the pid exists - fail toward "alive" and stay silent.
786
+ return (err as NodeJS.ErrnoException).code !== "ESRCH";
787
+ }
788
+ };
789
+ const scanDormantWatchers = async (sessionID: string): Promise<string[]> => {
790
+ if (dormantScanned.has(sessionID)) return [];
791
+ dormantScanned.add(sessionID);
792
+ try {
793
+ const rows = db.runtimeStateAll().filter((r) => r.kind === "watchers" && r.pid !== process.pid);
794
+ const own: Watcher[] = [];
795
+ const deadSessions = new Map<string, string[]>();
796
+ for (const row of rows) {
797
+ const defs = Array.isArray(row.value) ? (row.value as Watcher[]) : [];
798
+ if (dormantWatchersStale(defs)) {
799
+ db.runtimeStateDelete("watchers", row.sessionID);
800
+ continue;
801
+ }
802
+ if (row.sessionID === sessionID) {
803
+ // The startup-resume case hydrated these live at setup already -
804
+ // re-arming is a no-op there and the notice would be redundant.
805
+ if (!rehydratedSessions.has(sessionID)) own.push(...defs);
806
+ } else if (row.directory === directory && !pidAlive(row.pid)) {
807
+ // Dedup per dead SESSION (not per target): a resumed-then-died-
808
+ // again cycle re-arms the same session under new ids, and its
809
+ // second death deserves a fresh notice. Its own re-arm clears
810
+ // the entry below.
811
+ if (!notifiedWatcherDeaths.has(row.sessionID)) {
812
+ deadSessions.set(row.sessionID, defs.map(watcherTarget));
813
+ }
814
+ }
815
+ }
816
+ for (const dead of deadSessions.keys()) notifiedWatcherDeaths.add(dead);
817
+ const notices: string[] = [];
818
+ if (own.length > 0) {
819
+ const { rearmed, expired, failed } = await watchers.rearm(sessionID, own);
820
+ // This session came back to life: its own previous death notice is
821
+ // obsolete - a future death deserves a fresh one.
822
+ notifiedWatcherDeaths.delete(sessionID);
823
+ if (rearmed.length > 0) notices.push(watcherRearmNotice(rearmed.map(watcherTarget), expired, failed));
824
+ }
825
+ if (deadSessions.size > 0) {
826
+ const targets = [...new Set([...deadSessions.values()].flat())];
827
+ notices.push(watcherDeathNotice(targets));
828
+ }
829
+ return notices;
830
+ } catch (err) {
831
+ // Recovery must never break the message that triggered it.
832
+ console.error(`[thatch] dormant watcher scan failed for ${sessionID}: ${err}`);
833
+ return [];
834
+ }
835
+ };
836
+
837
+ // Finalizes an extraction child that finished. The session.status idle
838
+ // handler calls it when the child's idle event arrives; the reload
839
+ // reconciler calls it for restored children whose idle event was lost to
840
+ // the reload window.
841
+ const finishExtractionChild = async (sessionID: string, parentID: string) => {
842
+ if (extractionChildren.has(sessionID)) {
843
+ // The child's claimed delivery is consumed by completeClaimed below -
844
+ // the fetch recorded exactly what the child received, so nothing else
845
+ // is dropped here. Entries the child never received (post-fetch
846
+ // arrivals, or a never-fetched payload) stay pending and re-nudge;
847
+ // the old snapshot drain here would have silently dropped the
848
+ // never-fetched case without extraction. (Snapshot drain removed
849
+ // September 2026 - claim semantics subsume it. The journal's
850
+ // snapshot field remains for restart recovery.)
851
+
852
+ // Fire a toast with the extraction metrics. Only show a toast when
853
+ // memories were actually written - no toast for no-save runs to avoid
854
+ // notification fatigue.
855
+ const metrics = childMetrics.get(sessionID);
856
+ const parts: string[] = [];
857
+ if (metrics) {
858
+ if (metrics.new > 0) parts.push(`new: ${metrics.new}`);
859
+ if (metrics.updated > 0) parts.push(`updated: ${metrics.updated}`);
860
+ if (metrics.deleted > 0) parts.push(`deleted: ${metrics.deleted}`);
861
+ }
862
+ if (parts.length > 0) {
863
+ try {
864
+ await caps.showToast({
865
+ message: `💭 ${parts.join(", ")}`,
866
+ variant: "success",
867
+ duration: 4000,
868
+ });
869
+ } catch {
870
+ // TUI may not be connected (e.g. headless mode).
871
+ }
872
+ }
873
+
874
+ // Scope the completion to THIS child's claimed delivery. A fetcher
875
+ // that never fetched (a sibling sub-agent going idle, an extractor
876
+ // that crashed before its payload call) has no claim and must not
877
+ // drop the accepted set - another extractor may hold it in flight.
878
+ extraction.completeClaimed(sessionID);
879
+ missedNudges.delete(parentID);
880
+ extracting.delete(parentID);
881
+ childKinds.delete(sessionID);
882
+ childToParent.delete(sessionID);
883
+ parentSnapshots.delete(sessionID);
884
+ childMetrics.delete(sessionID);
885
+ extractionChildren.delete(sessionID);
886
+ journalChild(sessionID);
887
+ extraction.consume(sessionID);
888
+ // Delete the child session to avoid clutter.
889
+ try {
890
+ await caps.sessionDelete(sessionID);
891
+ } catch {
892
+ // Best-effort - the child is idle and harmless if not deleted.
893
+ }
894
+ } else {
895
+ // Task-dispatched sub-agent went idle. Complete only what THIS child
896
+ // claimed via its payload fetch - sibling specialists going idle have
897
+ // no claim and must be a no-op, or their idle signal drops another
898
+ // extractor's in-flight accepted payload. An extractor that never
899
+ // fetched leaves its entries held; requeueStaleAccepted bounds the
900
+ // linger.
901
+ extraction.completeClaimed(sessionID);
902
+ missedNudges.delete(parentID);
903
+ }
904
+ };
905
+
906
+ // A restored child may have gone idle DURING the reload window - its idle
907
+ // event fired before the plugin's event pump resubscribed and was lost.
908
+ // Without reconciliation, `extracting` stays populated forever and BOTH
909
+ // extraction paths stay suppressed for the session's life. Wait a beat
910
+ // for the pump to repopulate the status map, then finalize any restored
911
+ // child that is no longer running. A child genuinely mid-turn keeps its
912
+ // own idle event, and the finalize is idempotent if both paths race.
913
+ const reconcileRestoredChildren = async () => {
914
+ if (restoredChildren.size === 0) return;
915
+ await new Promise<void>((resolve) => {
916
+ const t = setTimeout(resolve, 10_000);
917
+ t.unref?.();
918
+ });
919
+ // Dispose (a v2 plugin reload or process exit) may have closed the db
920
+ // during the 10s window; finalizing into it would throw on the journal
921
+ // write. The reloaded instance's own reconciler owns the rows now.
922
+ if (disposed) return;
923
+ let live: Record<string, { type: string }> = {};
924
+ try {
925
+ live = (await caps.fetchStatuses()) ?? {};
926
+ } catch {
927
+ // Map-only liveness then - the event-fed statuses are the fallback.
928
+ }
929
+ for (const [childId, parentId] of restoredChildren) {
930
+ const mapped = sessionStatus.get(childId);
931
+ const remote = live[childId]?.type;
932
+ if (remote === "busy" || remote === "retry" || mapped === "busy" || mapped === "retry") continue;
933
+ restoredChildren.delete(childId);
934
+ await finishExtractionChild(childId, parentId);
935
+ }
936
+ };
937
+ void reconcileRestoredChildren();
938
+
939
+ // Skills always install to the global opencode config - installing into the
940
+ // worktree would mutate the user's repo (untracked files in git status).
941
+ // A failed install degrades the nudge workflow but must not kill the plugin.
942
+ try {
943
+ installSkills(join(configHome, "opencode", "skills"), [
944
+ ...SHARED_SKILLS,
945
+ ...OPENCODE_ONLY_SKILLS,
946
+ ]);
947
+ } catch (err) {
948
+ console.error(`[thatch] skill install failed: ${err}`);
949
+ }
950
+
951
+ // Same for the wrap-up slash commands: synced into the global config dir on
952
+ // every load so template updates self-heal. Config is loaded before plugins
953
+ // during server startup, so a first-ever install lands on the next start.
954
+ // Hosts that register commands natively (v2) get the action commands as
955
+ // files only - the wrap-ups register in code (onCommandExecuteBefore), and a file with
956
+ // the same name as a registered command would collide.
957
+ try {
958
+ if (caps.nativeCommands) removeWrapUpCommandFiles(configHome);
959
+ installOpencodeCommands(configHome, caps.nativeCommands ? opencodeActionCommandDefs() : undefined);
960
+ } catch (err) {
961
+ console.error(`[thatch] command install failed: ${err}`);
962
+ }
963
+
964
+ const sys = systemPrompt(repo, chatOn);
965
+ const compact = compactionContext(repo);
966
+
967
+ // Direct extraction: create a child session linked to the parent and prompt
968
+ // it with the extraction payload. The child runs the fact-extractor skill,
969
+ // writes memories, and goes idle. The existing childToParent / snapshot
970
+ // drain machinery handles buffer cleanup. The nudge path is a fallback if
971
+ // this throws.
972
+ //
973
+ // Does NOT call extraction.accept - entries stay in pending so consumeSnapshot
974
+ // can drain them by reference identity when the child writes a memory. The
975
+ // extracting set (not accept) suppresses the nudge in chat.message.
976
+ //
977
+ // The child ID is added to extractionChildren so the idle handler can
978
+ // distinguish plugin-created extraction children from task-dispatched
979
+ // sub-agents. Only extraction children get the full cleanup (buffer drain,
980
+ // session deletion, toast).
981
+ async function triggerExtraction(parentID: string): Promise<void> {
982
+ extracting.add(parentID);
983
+
984
+ const batch = extraction.peek(parentID);
985
+ const promptText = extractionDirectPrompt(batch.length, parentID);
986
+
987
+ const result = await caps.sessionCreate({
988
+ parentID,
989
+ title: "thatch-extraction",
990
+ });
991
+ // session.created event fires here, setting childToParent and
992
+ // parentSnapshots (snapshot of the full pending buffer, since we
993
+ // have not called accept).
994
+ const childId = result.id;
995
+ extractionChildren.add(childId);
996
+ // v2's session API cannot create a CHILD session (no parentID in its
997
+ // create input), so the adapter returns a top-level session and no
998
+ // session.created event carries the parent mapping. Set both here
999
+ // eagerly; on v1 the event sets the same values (idempotent).
1000
+ if (!childToParent.has(childId)) {
1001
+ childToParent.set(childId, parentID);
1002
+ parentSnapshots.set(childId, [...extraction.peek(parentID)]);
1003
+ }
1004
+ journalChild(childId);
1005
+
1006
+ // Clean up the child session and all map entries if prompting fails.
1007
+ // Without this, the child exists on the server but was never prompted,
1008
+ // so it never goes idle and the maps leak.
1009
+ const cleanupChild = () => {
1010
+ extracting.delete(parentID);
1011
+ extractionChildren.delete(childId);
1012
+ childKinds.delete(childId);
1013
+ childToParent.delete(childId);
1014
+ parentSnapshots.delete(childId);
1015
+ childMetrics.delete(childId);
1016
+ journalChild(childId);
1017
+ try { void caps.sessionDelete(childId); } catch {}
1018
+ };
1019
+
1020
+ const bgEnabled =
1021
+ process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === "true" ||
1022
+ process.env.OPENCODE_EXPERIMENTAL === "true";
1023
+
1024
+ if (bgEnabled) {
1025
+ try {
1026
+ await caps.promptSession(
1027
+ childId,
1028
+ {
1029
+ parts: [{ type: "text", text: promptText }],
1030
+ },
1031
+ "async",
1032
+ );
1033
+ } catch (err) {
1034
+ console.error(`[thatch] extraction child prompt failed: ${err}`);
1035
+ cleanupChild();
1036
+ throw err;
1037
+ }
1038
+ } else {
1039
+ // Fire and forget - the parent is already idle, so blocking the event
1040
+ // handler would only delay other event processing. The child runs to
1041
+ // completion and its idle event triggers cleanup.
1042
+ caps
1043
+ .promptSession(
1044
+ childId,
1045
+ {
1046
+ parts: [{ type: "text", text: promptText }],
1047
+ },
1048
+ "sync",
1049
+ )
1050
+ .catch((err: unknown) => {
1051
+ console.error(`[thatch] extraction child failed: ${err}`);
1052
+ cleanupChild();
1053
+ });
1054
+ }
1055
+ }
1056
+
1057
+ // Hook-semantics handlers for the memory/extraction tools. Both direct
1058
+ // tool calls (input.tool match in onToolExecuteAfter) and Code Mode
1059
+ // execute calls that wrap them (unwrapExecuteThatchCalls) must run the
1060
+ // same logic, so the bodies live here instead of inline in the hook.
1061
+ const toolMemoryRemember = async (sessionID: string, overwrite: unknown) => {
1062
+ extraction.consume(sessionID);
1063
+ missedNudges.delete(sessionID);
1064
+ // A session that fetched its own payload (direct parent-side fetch or a
1065
+ // child's payload call) completes its claim when it writes a memory -
1066
+ // the write proves the delivered entries were processed.
1067
+ extraction.completeClaimed(sessionID);
1068
+ // Track extraction metrics for toast display. Counted for any
1069
+ // child session (both direct-extraction and nudge-path sub-agents)
1070
+ // since childToParent covers both. The toast only fires for
1071
+ // extraction children (extractionChildren set) on idle.
1072
+ const parentID = childToParent.get(sessionID);
1073
+ if (parentID) {
1074
+ const metrics = childMetrics.get(sessionID) ?? { new: 0, updated: 0, deleted: 0 };
1075
+ if (overwrite) metrics.updated++;
1076
+ else metrics.new++;
1077
+ childMetrics.set(sessionID, metrics);
1078
+ journalChild(sessionID);
1079
+ // Complete the parent's accepted entries (the extractor confirmed
1080
+ // it is alive and saving). Completion is scoped to THIS child's
1081
+ // claimed delivery - the fetch IS the delivery record, so whatever
1082
+ // the child received is consumed here and nothing else. Pending
1083
+ // entries the child never received (post-fetch arrivals, or a child
1084
+ // that never fetched) stay pending and re-nudge: no silent loss.
1085
+ // (The old dispatch-time snapshot drain was either a no-op under
1086
+ // claim semantics or a silent-loss vector for children that never
1087
+ // fetched - removed September 2026. The journal's snapshot field
1088
+ // remains for restart recovery.)
1089
+ extraction.completeClaimed(sessionID);
1090
+ journalChild(sessionID);
1091
+ missedNudges.delete(parentID);
1092
+ journalChild(sessionID);
1093
+ missedNudges.delete(parentID);
1094
+ }
1095
+ };
1096
+ const toolExtractionDone = (sessionID: string, targetID?: string) => {
1097
+ const parentID = childToParent.get(sessionID);
1098
+ if (parentID) {
1099
+ // The child's completion consumes its own claimed delivery. The
1100
+ // whole-set fallback covers a child that acks without ever fetching
1101
+ // (its payload call errored) - rare, and the ack is a deliberate
1102
+ // processed signal, unlike the automatic idle path which is
1103
+ // claim-scoped only.
1104
+ if (!extraction.completeClaimed(sessionID)) {
1105
+ extraction.completeAccepted(parentID);
1106
+ }
1107
+ missedNudges.delete(parentID);
1108
+ extraction.consume(sessionID);
1109
+ } else if (targetID && targetID !== sessionID) {
1110
+ // A completion ack for ANOTHER session - the fact-extractor sub-agent
1111
+ // naming its parent. v2 links no parentID on dispatched sessions
1112
+ // (SessionCreateInput has none), so childToParent cannot know this
1113
+ // relationship and the explicit session_id is the only signal that
1114
+ // the target's accepted entries are accounted for. Without this
1115
+ // branch the accepted queue lingers forever and a tool-dense session
1116
+ // re-nudges every idle on never-draining exhaust. Completion is
1117
+ // scoped to the child's claimed delivery; the whole-set fallback
1118
+ // covers the no-claim case (a fetch that errored, or the MCP path
1119
+ // where the file-backed queue was drained instead).
1120
+ if (!extraction.completeClaimed(sessionID)) {
1121
+ extraction.completeAccepted(targetID);
1122
+ }
1123
+ missedNudges.delete(targetID);
1124
+ } else {
1125
+ extraction.accept(sessionID);
1126
+ }
1127
+ missedNudges.delete(sessionID);
1128
+ };
1129
+ const toolMemoryForget = (sessionID: string) => {
1130
+ const metrics = childMetrics.get(sessionID) ?? { new: 0, updated: 0, deleted: 0 };
1131
+ metrics.deleted++;
1132
+ childMetrics.set(sessionID, metrics);
1133
+ };
1134
+
1135
+ return {
1136
+ coreContext: buildCoreContext(db, model, repo, {
1137
+ extractionPayloadProvider: (targetID: string, fetcherID?: string): string | null => {
1138
+ // A fetcher identity turns the fetch into the delivery record: the
1139
+ // claimed entries stay accepted, and THAT fetcher's completion
1140
+ // signal consumes only them (claim semantics). Without a fetcher
1141
+ // (no host session context) the fetch is a pure peek - legacy
1142
+ // behavior.
1143
+ const all = fetcherID
1144
+ ? extraction.claim(targetID, fetcherID)
1145
+ : [...extraction.peekAccepted(targetID), ...extraction.peek(targetID)];
1146
+ if (all.length === 0) return null;
1147
+ return extraction.buildPayload(all, repo);
1148
+ },
1149
+ watcherRegistry: watchers,
1150
+ projectDir: worktree,
1151
+ }),
1152
+ debug,
1153
+ childSessionIds: () => [...extractionChildren],
1154
+
1155
+ // 1. System prompt - always in context.
1156
+ onSystemTransform: async (output) => {
1157
+ output.system.push(sys);
1158
+ },
1159
+
1160
+ // 2. Compaction context - re-familiarizes after compaction. The flag
1161
+ // suppresses chat.message nudges during summary generation (tool
1162
+ // calls are blocked there).
1163
+ onSessionCompacting: async (input, output) => {
1164
+ compacting.add(input.sessionID);
1165
+ output.context.push(compact);
1166
+ },
1167
+
1168
+ // 2b. Clear the compacting flag after compaction succeeds so chat.message
1169
+ // nudges resume for the synthetic auto-continue turn and beyond.
1170
+ onCompactionAutocontinue: async (input) => {
1171
+ compacting.delete(input.sessionID);
1172
+ },
1173
+
1174
+ // 3. Tool buffering - feeds the extraction pipeline (direct extraction
1175
+ // via SDK or nudge fallback). Excluded tools:
1176
+ // - thatch_*: extracting facts from memory ops would echo the store
1177
+ // - skill/task: meta-tools that orchestrate agent behavior (loading
1178
+ // skills, dispatching sub-agents). Buffering them creates a feedback
1179
+ // loop - extraction triggers a skill load, which gets buffered, which
1180
+ // triggers another extraction on the next turn.
1181
+ // Memory writes consume the buffer and reset the missed-nudge counter.
1182
+ // The buffer is NOT drained on nudge delivery - it persists until the
1183
+ // agent writes a memory, so ignored nudges accumulate and the payload
1184
+ // grows with each missed cycle.
1185
+ //
1186
+ // Buffer lifecycle is AMQP-style accept/complete, so a failed
1187
+ // extractor does not silently drop unprocessed interactions:
1188
+ // - thatch_extraction_done in the parent ACCEPTS the buffer: it moves
1189
+ // to a holding area and the nudge quiets, but entries are not
1190
+ // dropped yet.
1191
+ // - COMPLETE drops the held entries. Signals: a memory_remember or
1192
+ // extraction_done call in a child session of that parent (the
1193
+ // extractor confirming it processed them), or the child going idle
1194
+ // (a no-save run that never acks still counts as processed).
1195
+ // Completion is CLAIM-SCOPED: the pipeline records which fetcher
1196
+ // received the payload (get_extraction_payload), and a completion
1197
+ // signal consumes only that fetcher's delivery. A sibling
1198
+ // sub-agent going idle - a code-review specialist, a slow child
1199
+ // from a previous round - has no claim and is a no-op, so it can
1200
+ // no longer silently drop another extractor's in-flight payload
1201
+ // (the accept-then-racing-completion loss, fixed September 2026).
1202
+ // - REQUEUE returns the held entries to pending. Signals: the child
1203
+ // session errors or is deleted before completing.
1204
+ //
1205
+ // A memory_remember call in a child session (sub-agent) also drains
1206
+ // the parent's pending buffer - but only the entries that existed at
1207
+ // dispatch time (the snapshot). Entries from interleaved turns survive
1208
+ // so their facts aren't silently dropped.
1209
+ //
1210
+ // Code Mode `execute` calls are unwrapped before this filter applies
1211
+ // (unwrapExecuteThatchCalls): an execute call whose code invokes
1212
+ // tools.thatch_* runs the wrapped tool's hook semantics and is never
1213
+ // buffered. Matching on the outer tool name alone would re-queue the
1214
+ // pipeline's own dispatch/ack traffic every cycle - the self-feeding
1215
+ // extraction loop this removes (September 2026, observed live in an
1216
+ // oink session).
1217
+ onToolExecuteAfter: async (input, output) => {
1218
+ const { tool, sessionID } = input;
1219
+ if (tool === "thatch_memory_remember") {
1220
+ await toolMemoryRemember(sessionID, (input.args as Record<string, unknown> | undefined)?.overwrite);
1221
+ return;
1222
+ }
1223
+ // thatch_extraction_done has two roles depending on the session:
1224
+ // - parent, after dispatching the fact-extractor: ACCEPT the buffer
1225
+ // (quiet the nudge, hold the entries for completion).
1226
+ // - child extractor, at the end of its run: COMPLETE the parent's
1227
+ // accepted entries, including no-save runs that write no memory,
1228
+ // and drop the child's own buffer (its work is done).
1229
+ if (tool === "thatch_extraction_done") {
1230
+ toolExtractionDone(sessionID, (input.args as { session_id?: string } | undefined)?.session_id);
1231
+ return;
1232
+ }
1233
+ // Track memory deletions in child sessions for the toast metrics.
1234
+ // Same scoping as the remember handler above - only child sessions,
1235
+ // not the parent or manual memory writes from the user's session.
1236
+ if (tool === "thatch_memory_forget" && childToParent.has(sessionID)) {
1237
+ toolMemoryForget(sessionID);
1238
+ return;
1239
+ }
1240
+ // Chat conversational events echo back into the transcript as visible
1241
+ // message parts. Plugin tools render as muted one-line entries with
1242
+ // the output hidden behind a default-off TUI toggle, so without this
1243
+ // the human watching a session never sees the conversation happen.
1244
+ // Delivery is a non-synthetic noReply promptAsync: the part renders
1245
+ // as a visible bubble and starts no model turn (the server's prompt
1246
+ // path returns before the completion loop). Non-synthetic also means
1247
+ // later turns see the echo in context - a small duplication of the
1248
+ // tool call it mirrors, accepted for visibility. Fire-and-forget: an
1249
+ // echo failure must never fail the tool call it follows.
1250
+ if (input.tool.startsWith("thatch_chat_")) {
1251
+ const echo = chatEchoText(
1252
+ input.tool,
1253
+ (input.args ?? {}) as Record<string, unknown>,
1254
+ typeof output.output === "string" ? output.output : "",
1255
+ );
1256
+ // noReply hosts only: on a host whose prompt endpoint starts a real
1257
+ // model turn, delivering the echo would create a turn that calls
1258
+ // chat tools, which echoes again - the loop the noReply flag exists
1259
+ // to prevent.
1260
+ if (echo && caps.noReplyDelivery) {
1261
+ void caps
1262
+ .promptSession(
1263
+ input.sessionID,
1264
+ {
1265
+ noReply: true,
1266
+ parts: [{ type: "text", text: echo }],
1267
+ },
1268
+ "async",
1269
+ )
1270
+ .catch((err) => {
1271
+ // The echo must never fail the tool call, but a silent catch
1272
+ // would hide a systemic promptAsync failure (bubbles would
1273
+ // stop appearing everywhere with no diagnostics).
1274
+ console.error(`[thatch] chat echo delivery failed: ${err}`);
1275
+ });
1276
+ }
1277
+ return;
1278
+ }
1279
+ // "task" is v1's dispatch tool and "subagent" is v2's - a dispatch
1280
+ // buffers itself otherwise, and the nudge then fires on the pipeline's
1281
+ // own exhaust (the subagent-dispatch loop, sibling of the
1282
+ // execute-wrapped-ack loop below). isMetaToolName owns the set.
1283
+ if (isMetaToolName(input.tool)) return;
1284
+ // Code Mode execute calls: when the code wraps thatch_* invocations,
1285
+ // run the wrapped tools' hook semantics and never buffer the call
1286
+ // itself - it is the pipeline's own traffic, and buffering it feeds
1287
+ // the extraction loop with its own exhaust (the September 2026
1288
+ // infinite dispatch/ack loop). Execute calls that do not touch thatch
1289
+ // tools fall through and buffer as normal: they did real work.
1290
+ if (input.tool === "execute") {
1291
+ const wrapped = unwrapExecuteThatchCalls(input.args);
1292
+ if (wrapped.tools.length > 0) {
1293
+ for (const name of wrapped.tools) {
1294
+ if (name === "thatch_memory_remember") await toolMemoryRemember(sessionID, wrapped.overwrite);
1295
+ else if (name === "thatch_extraction_done") toolExtractionDone(sessionID, wrapped.sessionID);
1296
+ else if (name === "thatch_memory_forget" && childToParent.has(sessionID)) toolMemoryForget(sessionID);
1297
+ // Other thatch_* tools (recall, show, list, ...) carry no hook
1298
+ // semantics - doing nothing with them is the whole point.
1299
+ }
1300
+ return;
1301
+ }
1302
+ }
1303
+ extraction.push({
1304
+ tool: input.tool,
1305
+ sessionID: input.sessionID,
1306
+ args: (input.args as Record<string, unknown>) ?? {},
1307
+ title: output.title,
1308
+ output: typeof output.output === "string" ? output.output : "",
1309
+ });
1310
+ // The push site is the single classification of "this call was real
1311
+ // work" - meta tools and execute-wrapped thatch calls never reach it -
1312
+ // so it is also the done-alert signal. todowrite/question reach it
1313
+ // (the buffer wants them) but are no-work for alerts - see
1314
+ // isNoWorkTool in src/alerts.ts for why they must not move into
1315
+ // isMetaToolName.
1316
+ if (!isNoWorkTool(input.tool)) {
1317
+ alerts.sessionRealWork(sessionID);
1318
+ }
1319
+ },
1320
+
1321
+ // 4. Per-message nudge - two priority tiers:
1322
+ // a. Extraction nudge: prior tool interactions are queued for fact
1323
+ // extraction (carries the JSON payload for thatch-fact-extractor).
1324
+ // b. Recall + prediction + behavior nudge: when no extraction is pending, embed
1325
+ // the user's prompt (shared embedding for all three) and:
1326
+ // - search memories by cosine (recall nudge)
1327
+ // - search matchers by cosine, score predictions (prediction fire)
1328
+ // - search behavior matchers by cosine, score behaviors (behavior fire)
1329
+ // All three fire independently; any subset may inject.
1330
+ // The auto-fires reuse the embedding already computed for recall,
1331
+ // adding only cosine scans against the matchers tables.
1332
+ // No extra model calls.
1333
+ //
1334
+ // Skipped during compaction: the agent can't call tools while generating
1335
+ // a summary, so a nudge that says "use thatch_memory_recall" triggers a
1336
+ // blocked-tool error.
1337
+ // Wrap-up commands mark their session here; the actual trigger happens
1338
+ // on the session's next idle event, once the model's greenlight response
1339
+ // is complete.
1340
+ onCommandExecuteBefore: async (input) => {
1341
+ const wrapUp = WRAPUP_COMMANDS[input.command];
1342
+ if (wrapUp) {
1343
+ pendingWrapUp.set(input.sessionID, wrapUp);
1344
+ // Journaled so a v2 plugin reload before the session's next idle
1345
+ // does not silently drop the armed wrap-up. Disposed check FIRST:
1346
+ // a command execute still in flight when the reload's cleanup runs
1347
+ // must not land a stale wrap-up row through the lazy reopen (the
1348
+ // reloaded instance owns the journal rows; same rule as the
1349
+ // extraction and watcher journal callbacks).
1350
+ if (disposed) return;
1351
+ db.runtimeStatePut("wrapup", input.sessionID, wrapUp, directory);
1352
+ }
1353
+ },
1354
+
1355
+ onChatMessage: async (input, output) => {
1356
+ // Chat transcript echoes are non-synthetic noReply parts, so they
1357
+ // arrive here like any user message - but no model turn ever reads
1358
+ // them, and the nudge machinery would embed and scan their text for
1359
+ // nothing (and fire spurious toasts when thresholds cross). Skip
1360
+ // them entirely.
1361
+ if (isChatEchoParts(output.parts)) return;
1362
+
1363
+ // Dormant watcher recovery: a resumed session's first message re-arms
1364
+ // its restarted watchers and hears about dead ones (see
1365
+ // scanDormantWatchers). Injected as synthetic parts - the same path
1366
+ // nudges take, so the model sees them on every call of the turn -
1367
+ // plus a best-effort toast for the human.
1368
+ const rearmNotices = !childToParent.has(input.sessionID) ? await scanDormantWatchers(input.sessionID) : [];
1369
+ if (rearmNotices.length > 0) {
1370
+ for (const text of rearmNotices) {
1371
+ output.parts.push({
1372
+ id: `prt_thatch_watch_${Math.random().toString(36).slice(2)}`,
1373
+ sessionID: input.sessionID,
1374
+ messageID: input.messageID ?? output.message.id,
1375
+ type: "text",
1376
+ text,
1377
+ synthetic: true,
1378
+ });
1379
+ }
1380
+ try {
1381
+ await caps.showToast({ message: rearmNotices[0].split("\n")[0].replace("[thatch] ", "⏰ "), variant: "info", duration: 5000 });
1382
+ } catch {
1383
+ // TUI may not be connected. Best-effort.
1384
+ }
1385
+ }
1386
+
1387
+ // Prompt-path registration: a brand-new session joins the chat
1388
+ // directory the moment its first real user message arrives - before
1389
+ // the model runs - so it is addressable within that first turn (the
1390
+ // first-idle path alone leaves a turn-long dark window). Local
1391
+ // SQLite, so it runs inline; on an already-registered session this
1392
+ // is a free mid-turn heartbeat touch. Sub-agent children are excluded
1393
+ // (they register only by mistake, and their deletion tombstones the
1394
+ // row), as are sessions that left via chat_unregister. The idle path
1395
+ // below stays as the backstop and converges the topic.
1396
+ if (
1397
+ chatOn &&
1398
+ chatAutoRegister(loadConfig(dbPath).config) &&
1399
+ input.sessionID &&
1400
+ !childToParent.has(input.sessionID) &&
1401
+ !db.hasChatLeaveTombstone(input.sessionID)
1402
+ ) {
1403
+ try {
1404
+ const res = db.registerChatSession(input.sessionID, repo, null, "opencode", null, detectWorktreeKind(worktree));
1405
+ // First registration - or a startup resume reclaiming its name,
1406
+ // announced here because the TUI was not connected at init - is
1407
+ // the one moment the user should notice: a quiet toast, not a
1408
+ // conversation message.
1409
+ if (res.ok && (res.created || chatStartupNames.has(input.sessionID))) {
1410
+ chatStartupNames.delete(input.sessionID);
1411
+ try {
1412
+ await caps.showToast({ message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 });
1413
+ } catch {
1414
+ // Headless or disconnected TUI - registration stands.
1415
+ }
1416
+ }
1417
+ } catch (err) {
1418
+ console.error(`[thatch] chat prompt-register failed for ${input.sessionID}: ${err}`);
1419
+ }
1420
+ }
1421
+
1422
+ if (compacting.has(input.sessionID)) {
1423
+ // The session is marked as compacting. If this message is the
1424
+ // compaction summary generation itself (has a compaction-type part),
1425
+ // suppress nudges - tools are blocked during summary generation and a
1426
+ // nudge would cause a blocked-tool error. If it is NOT a compaction
1427
+ // message, compaction has already failed (autocontinue never fired)
1428
+ // and the user is sending a new message. Clear the stale flag and
1429
+ // proceed normally - tools are available again.
1430
+ const isCompactionMsg = (output.parts as any[]).some((p) => p.type === "compaction");
1431
+ if (isCompactionMsg) return;
1432
+ compacting.delete(input.sessionID);
1433
+ }
1434
+
1435
+ // Version warning: check if the on-disk package.json version differs
1436
+ // from the running version (user upgraded but didn't restart opencode),
1437
+ // or if the npm poller found a newer version. Best-effort: any failure
1438
+ // silently skips. Injected as a synthetic part so the LLM sees it and
1439
+ // can tell the user to restart.
1440
+ try {
1441
+ const onDisk = readOnDiskVersion();
1442
+ const checker = getVersionChecker();
1443
+ const npmUpdate = checker?.getUpdateWarning() ?? null;
1444
+ const skewWarning = onDisk && compareSemver(runningVersion, onDisk) < 0
1445
+ ? skewWarningText(onDisk, runningVersion)
1446
+ : null;
1447
+ const warning = skewWarning ?? npmUpdate;
1448
+ if (warning) {
1449
+ output.parts.push({
1450
+ id: `prt_thatch_ver_${Math.random().toString(36).slice(2)}`,
1451
+ sessionID: input.sessionID,
1452
+ messageID: input.messageID ?? output.message.id,
1453
+ type: "text",
1454
+ text: versionWarningNudge(warning),
1455
+ synthetic: true,
1456
+ });
1457
+ try {
1458
+ const short = skewWarning
1459
+ ? `⚠️ thatch upgraded to v${onDisk} (running v${runningVersion})`
1460
+ : `⚠️ thatch v${checker?.getLatestVersion()} is available`;
1461
+ await caps.showToast({
1462
+ message: short,
1463
+ variant: "warning",
1464
+ duration: 5000,
1465
+ });
1466
+ } catch {
1467
+ // TUI may not be connected. Best-effort.
1468
+ }
1469
+ }
1470
+ } catch {
1471
+ // Best-effort. Version check failure must not block nudges.
1472
+ }
1473
+
1474
+ // Extraction nudge (fallback path). Skipped when the extracting set
1475
+ // is active - that means a direct-extraction child session is running
1476
+ // and the plugin is handling extraction via the SDK. The nudge fires
1477
+ // here only when direct extraction was never triggered or threw.
1478
+ // Accepted entries whose completion signal never came (crashed
1479
+ // extractor, or a v2 dispatch whose ack never named the parent)
1480
+ // requeue here so they are honestly re-extracted instead of lingering
1481
+ // as silent loss.
1482
+ extraction.requeueStaleAccepted();
1483
+ // Task-dispatched sub-agent sessions may have restricted tool lists
1484
+ // that exclude the thatch tools, and even where the tools exist the
1485
+ // child is driven by its dispatch prompt, so nudges only burn "No
1486
+ // tool named ..." error rounds or add noise there. Extraction
1487
+ // children are excluded: they run with full tools and are driven by
1488
+ // extractionDirectPrompt.
1489
+ if (childToParent.has(input.sessionID) && !extractionChildren.has(input.sessionID)) return;
1490
+ if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
1491
+ const batch = extraction.peek(input.sessionID);
1492
+ const missed = missedNudges.get(input.sessionID) ?? 0;
1493
+ const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
1494
+ missedNudges.set(input.sessionID, missed + 1);
1495
+
1496
+ output.parts.push({
1497
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
1498
+ sessionID: input.sessionID,
1499
+ messageID: input.messageID ?? output.message.id,
1500
+ type: "text",
1501
+ text,
1502
+ synthetic: true,
1503
+ });
1504
+ return;
1505
+ }
1506
+
1507
+ // No extraction pending - try the prompt-aware recall nudge. Extract
1508
+ // the user's prompt text from the message parts, embed it with the
1509
+ // warm in-process model, and search for matches. Best-effort: any
1510
+ // failure (no text, model not loaded, empty store) silently skips.
1511
+ try {
1512
+ const promptText = (output.parts as any[])
1513
+ .filter((p) => p.type === "text" && !p.synthetic)
1514
+ .map((p) => p.text)
1515
+ .join(" ");
1516
+ if (promptText.length < MIN_PROMPT_LEN) return;
1517
+
1518
+ const embedding = await model.queryEmbed(promptText);
1519
+
1520
+ // Recall nudge: separate try/catch so a memory-search failure
1521
+ // does not block the prediction fire (they share only the embedding).
1522
+ try {
1523
+ const results = db.search([repo, "global"], embedding, { limit: 5 });
1524
+ const matches: NudgeMatch[] = results
1525
+ .filter((r) => r._score >= RECALL_THRESHOLD)
1526
+ .map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
1527
+
1528
+ if (matches.length > 0) {
1529
+ output.parts.push({
1530
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
1531
+ sessionID: input.sessionID,
1532
+ messageID: input.messageID ?? output.message.id,
1533
+ type: "text",
1534
+ text: recallNudge(matches),
1535
+ synthetic: true,
1536
+ });
1537
+ try {
1538
+ await caps.showToast({
1539
+ message: `💭 recalled ${matches.length} memor${matches.length === 1 ? "y" : "ies"}`,
1540
+ variant: "info",
1541
+ duration: 3000,
1542
+ });
1543
+ } catch {
1544
+ // TUI may not be connected. Best-effort.
1545
+ }
1546
+ }
1547
+ } catch (err) {
1548
+ console.error(`[thatch] recall nudge failed: ${err}`);
1549
+ }
1550
+
1551
+ // Prediction fire: independent of recall; a failure here does
1552
+ // not affect the recall nudge that may have already been pushed.
1553
+ try {
1554
+ const predItems = db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD);
1555
+ if (predItems.length > 0) {
1556
+ output.parts.push({
1557
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
1558
+ sessionID: input.sessionID,
1559
+ messageID: input.messageID ?? output.message.id,
1560
+ type: "text",
1561
+ text: predictionNudge(predItems),
1562
+ synthetic: true,
1563
+ });
1564
+ try {
1565
+ await caps.showToast({
1566
+ message: `💭 ${predItems.length} prediction${predItems.length === 1 ? "" : "s"} surfaced`,
1567
+ variant: "info",
1568
+ duration: 3000,
1569
+ });
1570
+ } catch {
1571
+ // TUI may not be connected. Best-effort.
1572
+ }
1573
+ }
1574
+ } catch (err) {
1575
+ console.error(`[thatch] prediction nudge failed: ${err}`);
1576
+ }
1577
+
1578
+ // Behavior fire: independent of recall and prediction. Same
1579
+ // embedding, one more cosine scan against behavior_matchers.
1580
+ try {
1581
+ const behaviorItems = db.scoreBehaviorNudge([repo, "global"], embedding, BEHAVIOR_THRESHOLD);
1582
+ if (behaviorItems.length > 0) {
1583
+ output.parts.push({
1584
+ id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
1585
+ sessionID: input.sessionID,
1586
+ messageID: input.messageID ?? output.message.id,
1587
+ type: "text",
1588
+ text: behaviorNudge(behaviorItems),
1589
+ synthetic: true,
1590
+ });
1591
+ try {
1592
+ await caps.showToast({
1593
+ message: `💭 ${behaviorItems.length} behavior${behaviorItems.length === 1 ? "" : "s"} surfaced`,
1594
+ variant: "info",
1595
+ duration: 3000,
1596
+ });
1597
+ } catch {
1598
+ // TUI may not be connected. Best-effort.
1599
+ }
1600
+ }
1601
+ } catch (err) {
1602
+ console.error(`[thatch] behavior nudge failed: ${err}`);
1603
+ }
1604
+ } catch (err) {
1605
+ console.error(`[thatch] nudge hook failed: ${err}`);
1606
+ }
1607
+ },
1608
+
1609
+ // 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
1610
+ // best-effort: a failure there must not cost the reminder itself.
1611
+ //
1612
+ // Also tracks parent-child session relationships for cross-session buffer
1613
+ // drain. session.created with a parentID records the mapping AND
1614
+ // snapshots the parent's current buffer so the child's later
1615
+ // memory_remember drains only those snapshot entries, not the parent's
1616
+ // entire buffer (which may have grown from interleaved turns).
1617
+ //
1618
+ // Child lifecycle events: idle means the extractor finished; error or
1619
+ // deletion before completion means the facts were never extracted, so
1620
+ // entries go back to pending. Only extraction children (created by
1621
+ // triggerExtraction, tracked in extractionChildren) get the full cleanup
1622
+ // (buffer drain, session deletion, toast). Task-dispatched sub-agents
1623
+ // get the old behavior (completeAccepted + missedNudges.reset) so their
1624
+ // sessions are not deleted out from under the task tool.
1625
+ onEvent: async (event) => {
1626
+ // Pause alerts: the LLM blocked on a human decision. Both lines
1627
+ // publish these on the bus; v2's adapter translates them into the
1628
+ // same property names (requestID on all reply events).
1629
+ if (event.type === "question.asked") {
1630
+ await alerts.questionAsked(event.properties.sessionID, event.properties.id);
1631
+ return;
1632
+ }
1633
+ if (event.type === "question.replied" || event.type === "question.rejected") {
1634
+ alerts.questionResolved(event.properties.sessionID, replyRequestID(event.properties));
1635
+ return;
1636
+ }
1637
+ if (event.type === "permission.asked") {
1638
+ await alerts.permissionAsked(event.properties.sessionID, event.properties.id);
1639
+ return;
1640
+ }
1641
+ if (event.type === "permission.replied") {
1642
+ alerts.permissionResolved(event.properties.sessionID, replyRequestID(event.properties));
1643
+ return;
1644
+ }
1645
+ if (event.type === "session.created") {
1646
+ const info = event.properties.info;
1647
+ if (info.parentID) {
1648
+ childToParent.set(info.id, info.parentID);
1649
+ childKinds.set(info.id, extractionChildren.has(info.id) ? "extraction" : "task");
1650
+ parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
1651
+ journalChild(info.id);
1652
+ // Child sessions don't need the session-start reminder - only
1653
+ // top-level sessions do. Returning here prevents extraction
1654
+ // children from receiving the hygiene report and tool overview.
1655
+ return;
1656
+ }
1657
+ }
1658
+ if (event.type === "session.error") {
1659
+ const childID = event.properties.sessionID;
1660
+ // Alert bookkeeping first: the error name decides abort-silence vs
1661
+ // the needs-attention alert when the session then goes idle (on v2
1662
+ // the adapter emits session.error + idle as a pair for terminal
1663
+ // failures and interrupts, so the verdict always follows). Children
1664
+ // are excluded - their failure requeues extraction here and the
1665
+ // parent turn continues; the child session itself never alerts.
1666
+ const errorObj = event.properties.error as { name?: string; type?: string } | undefined;
1667
+ if (!childToParent.has(childID ?? "")) {
1668
+ alerts.sessionError(childID ?? "", errorObj?.name ?? errorObj?.type ?? null);
1669
+ }
1670
+ const parentID = childID ? childToParent.get(childID) : undefined;
1671
+ if (parentID && childID) {
1672
+ // Requeue what THIS child actually held: its claimed delivery if
1673
+ // it fetched. ERROR is a terminal death signal, so a claim-less
1674
+ // child (died before fetching - boot failures land here) falls
1675
+ // back to whole-set requeue for immediate recovery, regardless
1676
+ // of kind: the worst case is a benign duplicate extraction, never
1677
+ // silent loss.
1678
+ if (!extraction.requeueClaimed(childID)) {
1679
+ extraction.requeueAccepted(parentID);
1680
+ }
1681
+ extracting.delete(parentID);
1682
+ childKinds.delete(childID);
1683
+ childToParent.delete(childID);
1684
+ parentSnapshots.delete(childID);
1685
+ childMetrics.delete(childID);
1686
+ extractionChildren.delete(childID);
1687
+ journalChild(childID);
1688
+ }
1689
+ return;
1690
+ }
1691
+ if (event.type === "session.status") {
1692
+ const sessionID = event.properties.sessionID;
1693
+ const statusType = event.properties.status?.type;
1694
+ // Record the latest status so the watcher registry can gate
1695
+ // proactive prompt delivery on idle sessions.
1696
+ if (sessionID && statusType) sessionStatus.set(sessionID, statusType);
1697
+ // Busy resets the round; a retry (v1 only) clears a recorded error
1698
+ // but preserves the real-work flag - the tool calls before the
1699
+ // retry happened.
1700
+ if (sessionID && statusType === "busy") alerts.sessionBusy(sessionID);
1701
+ else if (sessionID && statusType === "retry") alerts.sessionRetry(sessionID);
1702
+ if (statusType !== "idle") return;
1703
+ const parentID = sessionID ? childToParent.get(sessionID) : undefined;
1704
+ if (parentID && sessionID) {
1705
+ // A child session went idle. Two cases:
1706
+ // - Extraction child (created by triggerExtraction, tracked in
1707
+ // extractionChildren): drain the parent's snapshot, fire a toast
1708
+ // with metrics, delete the child session, clean up all maps.
1709
+ // - Task-dispatched sub-agent (code review specialist, nudge-path
1710
+ // fact-extractor, any model-dispatched task): complete the
1711
+ // parent's accepted entries and reset missedNudges. Do NOT drain
1712
+ // the buffer or delete the session - the task tool that
1713
+ // dispatched the sub-agent needs to read its output.
1714
+ await finishExtractionChild(sessionID, parentID);
1715
+ return;
1716
+ }
1717
+ // Turn-end alert: fires only when the round did real work (the
1718
+ // classifier skips nudge/completion-driven bookkeeping rounds).
1719
+ // Runs before the wrap-up branches so a greenlit compact or exit
1720
+ // still ends with its completion alert.
1721
+ if (sessionID) await alerts.sessionIdle(sessionID);
1722
+ // Wrap-up command resolution: the session went idle right after
1723
+ // /thatch/compact or /thatch/exit. The final assistant message's
1724
+ // trailing token is the greenlight - the command instructs the model
1725
+ // to end with it only after flushing persistence and confirming no
1726
+ // loose ends. Token absence means the model listed blockers, so the
1727
+ // action never fires; a toast points the user at the blockers above.
1728
+ const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
1729
+ if (wrapUp) {
1730
+ pendingWrapUp.delete(sessionID);
1731
+ db.runtimeStateDelete("wrapup", sessionID);
1732
+ let ready = false;
1733
+ try {
1734
+ const data = await caps.sessionMessages(sessionID);
1735
+ const last = [...(data ?? [])].reverse().find((m) => m.info.role === "assistant");
1736
+ const text = (last?.parts ?? [])
1737
+ .filter((p) => p.type === "text")
1738
+ .map((p) => p.text ?? "")
1739
+ .join("\n")
1740
+ .trimEnd();
1741
+ ready = text.endsWith(wrapUp.token);
1742
+ } catch (err) {
1743
+ console.error(`[thatch] wrap-up message fetch failed: ${err}`);
1744
+ }
1745
+ if (ready) {
1746
+ // An exit-greenlit session is leaving the process: leave the
1747
+ // chat directory too, so other sessions stop addressing mail to
1748
+ // a roster entry whose host is about to vanish. The unregister
1749
+ // tombstone also stops any straggler auto-register from
1750
+ // resurrecting the row during shutdown. Compact keeps the
1751
+ // session alive, so only the exit path does this.
1752
+ if (wrapUp.kind === "exit") {
1753
+ try {
1754
+ db.unregisterChatSession(sessionID);
1755
+ } catch (err) {
1756
+ console.error(`[thatch] chat unregister on exit failed: ${err}`);
1757
+ }
1758
+ }
1759
+ try {
1760
+ if (wrapUp.kind === "compact") {
1761
+ await caps.compactSession(sessionID);
1762
+ } else {
1763
+ await caps.exitHost();
1764
+ }
1765
+ } catch (err) {
1766
+ console.error(`[thatch] wrap-up action failed: ${err}`);
1767
+ }
1768
+ // Skip extraction and pending deliveries: compaction is starting
1769
+ // (the checklist drained the buffer) or the process is exiting.
1770
+ return;
1771
+ }
1772
+ try {
1773
+ await caps.showToast({
1774
+ message:
1775
+ wrapUp.kind === "compact"
1776
+ ? "⏸ Not compacting - resolve the items listed above, then run /thatch/compact again"
1777
+ : "⏸ Not exiting - resolve the items listed above, then run /thatch/exit again",
1778
+ variant: "warning",
1779
+ duration: 8000,
1780
+ });
1781
+ } catch {
1782
+ // TUI may not be connected (e.g. headless mode).
1783
+ }
1784
+ // Blocked: fall through so extraction and pending deliveries still
1785
+ // run - the model may have buffered tool interactions to flush.
1786
+ }
1787
+ // Auto-registration: every top-level opencode session joins the
1788
+ // chat directory on its first idle event (unless the user turned
1789
+ // auto-registration off). The name is assigned, never chosen - a
1790
+ // pool draw plus a never-reused counter, kept short because it
1791
+ // appears in wake prompts and rosters. The live session title rides
1792
+ // the topic column instead (refreshed on every idle), so the name
1793
+ // and the description stay separate identities. Fire-and-forget: a
1794
+ // failed registration or title fetch must never block event
1795
+ // delivery. TOP-LEVEL ONLY: child sessions (extraction sub-agents,
1796
+ // task sub-agents) are machinery, not chat participants - without
1797
+ // this check every fact-extractor run registered a roster row
1798
+ // (the "thatch-extraction" corpse flood, September 2026). The
1799
+ // prompt-register path above has the same check.
1800
+ if (
1801
+ chatOn &&
1802
+ chatAutoRegister(loadConfig(dbPath).config) &&
1803
+ sessionID &&
1804
+ !childToParent.has(sessionID) &&
1805
+ !db.hasChatLeaveTombstone(sessionID)
1806
+ ) {
1807
+ void (async () => {
1808
+ try {
1809
+ const data = await caps.sessionGet(sessionID);
1810
+ const title = data?.title ?? "";
1811
+ // Placeholder titles never become topics (the real one
1812
+ // converges on a later idle via refreshChatTopic).
1813
+ const topic = title && !isDefaultSessionTitle(title) ? title : null;
1814
+ const res = db.registerChatSession(sessionID, repo, topic, "opencode", null, detectWorktreeKind(worktree));
1815
+ // The topic converges as the auto-titler lands a real title:
1816
+ // registration is once-per-session, so later idles must
1817
+ // refresh it explicitly.
1818
+ if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
1819
+ // First registration - or a startup resume reclaiming its
1820
+ // name, announced here because the TUI was not connected at
1821
+ // init - is the one moment the user should notice: a quiet
1822
+ // toast, not a conversation message.
1823
+ if (res.ok && (res.created || chatStartupNames.has(sessionID))) {
1824
+ chatStartupNames.delete(sessionID);
1825
+ try {
1826
+ await caps.showToast({ message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 });
1827
+ } catch {
1828
+ // Headless or disconnected TUI - registration stands.
1829
+ }
1830
+ }
1831
+ } catch (err) {
1832
+ console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
1833
+ }
1834
+ })();
1835
+ }
1836
+ // Parent went idle - trigger direct extraction if there are pending
1837
+ // tool interactions and no extraction is already running. Falls
1838
+ // back to the nudge path on the next chat.message if this throws.
1839
+ if (
1840
+ sessionID &&
1841
+ !compacting.has(sessionID) &&
1842
+ !extracting.has(sessionID) &&
1843
+ extraction.pending(sessionID)
1844
+ ) {
1845
+ try {
1846
+ await triggerExtraction(sessionID);
1847
+ } catch (err) {
1848
+ console.error(`[thatch] direct extraction trigger failed: ${err}`);
1849
+ debug("extraction", `direct extraction trigger failed for ${sessionID}: ${err}`);
1850
+ extracting.delete(sessionID);
1851
+ }
1852
+ }
1853
+ // The session just became idle, so watcher events queued while it
1854
+ // was busy can be delivered now - the poll interval may otherwise
1855
+ // hold them for up to a full cycle. Best-effort; failures stay
1856
+ // pending and retry on the next poll.
1857
+ try {
1858
+ await watchers.deliverPending();
1859
+ } catch (err) {
1860
+ console.error(`[thatch] watcher delivery on idle failed: ${err}`);
1861
+ }
1862
+ // Same for chat messages: mail that arrived mid-turn lands now
1863
+ // instead of waiting for the next poll cycle.
1864
+ try {
1865
+ await chatPoller.deliverPending();
1866
+ } catch (err) {
1867
+ console.error(`[thatch] chat delivery on idle failed: ${err}`);
1868
+ }
1869
+ return;
1870
+ }
1871
+ if (event.type === "session.deleted") {
1872
+ const id = event.properties.info.id;
1873
+ // Drop the session's alert state first - it has no reason to outlive
1874
+ // the session.
1875
+ alerts.sessionDeleted(id);
1876
+ // Shrink the chat poller's hosted set FIRST: the rest of this
1877
+ // branch runs extraction calls that could throw on a transient DB
1878
+ // error, and a leaked status key would keep heartbeat-ing a dead
1879
+ // session as permanently fresh.
1880
+ sessionStatus.delete(id);
1881
+ // Tombstone the auto-registerer: an idle IIFE already in flight
1882
+ // (title fetch pending) would otherwise re-insert a directory row
1883
+ // for a session the user just deleted - a week-long roster zombie
1884
+ // whose host is gone. The unregister below tombstones (see
1885
+ // ChatStore.unregister), and the tombstone is cleared only by an
1886
+ // explicit chat_register rejoin, so a genuinely new session is
1887
+ // unaffected.
1888
+ // A child deleted before completing never processed its payload -
1889
+ // unless it was a task-kind child: task sub-agents finish and get
1890
+ // deleted ROUTINELY, and a finished task child's later deletion
1891
+ // must not requeue the parent's extraction state (it would yank an
1892
+ // in-flight extractor's accepted set back to pending for duplicate
1893
+ // extraction). Requeue what the child actually held: its claim, or
1894
+ // for a claim-less extraction child the whole accepted set.
1895
+ const parentID = childToParent.get(id);
1896
+ const childKind = childKinds.get(id) ?? "extraction";
1897
+ if (parentID) {
1898
+ if (!extraction.requeueClaimed(id) && childKind === "extraction") {
1899
+ extraction.requeueAccepted(parentID);
1900
+ }
1901
+ if (childKind === "extraction") extracting.delete(parentID);
1902
+ }
1903
+ extraction.dropClaim(id);
1904
+ childKinds.delete(id);
1905
+ childToParent.delete(id);
1906
+ // A deleted session's grace rehost ends immediately - the user
1907
+ // closed it deliberately; the roster must not keep it fresh. The
1908
+ // hosted-row journal is pruned too: the union never shrinks on its
1909
+ // own, and a deleted session must not re-host into the grace on
1910
+ // the next reload.
1911
+ rehostGrace.delete(id);
1912
+ const hostedRow = db
1913
+ .runtimeStateAll()
1914
+ .find((r) => r.kind === "hosted" && r.sessionID === directory);
1915
+ if (hostedRow && Array.isArray(hostedRow.value)) {
1916
+ db.runtimeStatePut(
1917
+ "hosted",
1918
+ directory,
1919
+ (hostedRow.value as string[]).filter((sid) => sid !== id),
1920
+ directory,
1921
+ );
1922
+ }
1923
+ parentSnapshots.delete(id);
1924
+ childMetrics.delete(id);
1925
+ extractionChildren.delete(id);
1926
+ journalChild(id);
1927
+ pendingWrapUp.delete(id);
1928
+ db.runtimeStateDelete("wrapup", id);
1929
+ // A deleted parent takes its accepted entries with it, and its
1930
+ // watchers die with it - the session that would receive their
1931
+ // notifications no longer exists.
1932
+ extraction.completeAccepted(id);
1933
+ extraction.dropClaim(id);
1934
+ extracting.delete(id);
1935
+ watchers.cancelSession(id);
1936
+ // Leaving the chat directory is the graceful-exit fast path; a
1937
+ // crashed process never fires session.deleted, so the heartbeat
1938
+ // staleness in chat_list is the covering signal for that case.
1939
+ try {
1940
+ db.unregisterChatSession(id);
1941
+ } catch (err) {
1942
+ console.error(`[thatch] chat unregister on delete failed: ${err}`);
1943
+ }
1944
+ return;
1945
+ }
1946
+
1947
+ // session.compacted fires on successful compaction. Redundant with
1948
+ // experimental.compaction.autocontinue, but belt-and-suspenders: if
1949
+ // autocontinue didn't fire or wasn't installed, the event still clears
1950
+ // the compacting flag so nudges resume.
1951
+ if (event.type === "session.compacted") {
1952
+ compacting.delete(event.properties.sessionID);
1953
+ return;
1954
+ }
1955
+
1956
+ if (event.type !== "session.created") return;
1957
+ const id = event.properties.info.id;
1958
+
1959
+ // noReply hosts only: a host without noReply delivery would turn the
1960
+ // reminder into a real model turn replying to itself. The system
1961
+ // prompt (onSystemTransform) still carries the essentials everywhere.
1962
+ // The gate precedes the hygiene scan so v2 sessions (and any other
1963
+ // noReply-less host) do not pay a DB + repo scan for a skipped report.
1964
+ if (!caps.noReplyDelivery) return;
1965
+
1966
+ let hygiene: string | null = null;
1967
+ try {
1968
+ hygiene = await hygieneReport(db, repo, worktree, repoCache);
1969
+ } catch (err) {
1970
+ console.error(`[thatch] hygiene report failed: ${err}`);
1971
+ }
1972
+
1973
+ try {
1974
+ await caps.promptSession(
1975
+ id,
1976
+ {
1977
+ noReply: true,
1978
+ parts: [{ type: "text", text: sessionStartReminder(repo, hygiene), synthetic: true }],
1979
+ },
1980
+ "sync",
1981
+ );
1982
+ } catch (err) {
1983
+ console.error(`[thatch] session-start reminder failed: ${err}`);
1984
+ }
1985
+ },
1986
+
1987
+ dispose: async () => {
1988
+ disposed = true;
1989
+ stopVersionChecker();
1990
+ watchers.dispose();
1991
+ chatPoller.dispose();
1992
+ try {
1993
+ await releaseModel();
1994
+ } finally {
1995
+ // In finally so a releaseModel rejection cannot leak the connection.
1996
+ // Note: the runtime_state journal is deliberately NOT cleared here.
1997
+ // Disposal happens before every v2 plugin reload, and the reloaded
1998
+ // setup() rehydrates from it; stale rows from dead processes are
1999
+ // pruned by the setup-time partition instead.
2000
+ db.close();
2001
+ }
2002
+ },
2003
+ };
2004
+ }