@jeffober/thatch 0.1.47 → 0.2.0

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