@jeffober/thatch 0.2.3 → 0.2.5

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 (54) hide show
  1. package/README.md +8 -1
  2. package/artifacts/skills/thatch-clear-writing.md +103 -5
  3. package/artifacts/skills/thatch-coordination.md +69 -0
  4. package/artifacts/skills/thatch-pr-description.md +21 -13
  5. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  6. package/bin/thatch +95 -44
  7. package/docs/assets/deepwiki-badge.svg +1 -0
  8. package/docs/dev/README.md +7 -2
  9. package/docs/dev/features/cli.md +2 -0
  10. package/docs/dev/features/cross-session-chat.md +118 -11
  11. package/docs/dev/features/notifications.md +1 -2
  12. package/docs/dev/features/opencode-plugin.md +18 -3
  13. package/docs/dev/features/prediction-engine.md +44 -5
  14. package/docs/dev/features/session-tabs.md +155 -0
  15. package/docs/dev/features/setup.md +12 -0
  16. package/docs/dev/features/watchers.md +62 -6
  17. package/docs/dev/gotchas.md +68 -0
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +7 -2
  20. package/docs/dev/skills.md +1 -1
  21. package/docs/plans/generalized-session-heartbeat.md +212 -0
  22. package/docs/plans/prediction-consolidation.md +88 -68
  23. package/docs/user/README.md +12 -3
  24. package/docs/user/cli.md +5 -1
  25. package/docs/user/commands.md +11 -4
  26. package/docs/user/cross-session-chat.md +31 -8
  27. package/docs/user/prediction-engine.md +11 -0
  28. package/docs/user/setup.md +29 -0
  29. package/docs/user/skills.md +1 -1
  30. package/docs/user/watchers.md +22 -7
  31. package/package.json +10 -2
  32. package/src/alerts.ts +1 -1
  33. package/src/chat.ts +320 -27
  34. package/src/commands.ts +14 -0
  35. package/src/db.ts +51 -1
  36. package/src/git.ts +3 -1
  37. package/src/hygiene.ts +5 -0
  38. package/src/mcp.ts +95 -7
  39. package/src/opencode/tui-plugin.ts +70 -0
  40. package/src/opencode/v2.ts +197 -4
  41. package/src/prediction.ts +90 -0
  42. package/src/prompts.ts +110 -23
  43. package/src/runtime.ts +187 -9
  44. package/src/session-db.ts +4 -4
  45. package/src/session-tab-shared.ts +180 -0
  46. package/src/setup.ts +59 -3
  47. package/src/skills.ts +1 -0
  48. package/src/tool-defs.ts +412 -55
  49. package/src/tools.ts +2 -0
  50. package/src/watchers.ts +263 -26
  51. package/docs/plans/intuition-drives.md +0 -147
  52. package/docs/plans/llm-alerts.md +0 -287
  53. package/docs/plans/thatch-actions.md +0 -179
  54. package/docs/plans/worktree-deletion-recovery.md +0 -246
package/src/watchers.ts CHANGED
@@ -47,7 +47,18 @@ export type BranchWatcherEventType = "branch_commit" | "branch_ci" | "branch_wor
47
47
  /** Event type for source "command": the watched shell command exited 0. */
48
48
  export type CommandWatcherEventType = "command_success";
49
49
 
50
- export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType | CommandWatcherEventType;
50
+ /**
51
+ * Lifecycle event the registry itself emits, not tied to a source: a watch
52
+ * passed its TTL and was dropped. Delivered to the owning session instead of
53
+ * vanishing silently.
54
+ */
55
+ export type LifecycleWatcherEventType = "watch_expired";
56
+
57
+ export type WatcherEventType =
58
+ | PrWatcherEventType
59
+ | BranchWatcherEventType
60
+ | CommandWatcherEventType
61
+ | LifecycleWatcherEventType;
51
62
 
52
63
  export const PR_EVENT_TYPES: PrWatcherEventType[] = [
53
64
  "pr_comment",
@@ -68,8 +79,13 @@ export const BRANCH_EVENT_TYPES: BranchWatcherEventType[] = [
68
79
 
69
80
  export const COMMAND_EVENT_TYPES: CommandWatcherEventType[] = ["command_success"];
70
81
 
71
- /** Every event type across all sources - for diagnostics and tests. */
72
- export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES, ...COMMAND_EVENT_TYPES];
82
+ /** Every event type across all sources plus registry lifecycle events - for diagnostics and tests. */
83
+ export const WATCHER_EVENT_TYPES: WatcherEventType[] = [
84
+ ...PR_EVENT_TYPES,
85
+ ...BRANCH_EVENT_TYPES,
86
+ ...COMMAND_EVENT_TYPES,
87
+ "watch_expired",
88
+ ];
73
89
 
74
90
  /**
75
91
  * A single detected change, ready for delivery. Pointer data plus machine
@@ -258,6 +274,13 @@ export function withCwdFallback(
258
274
  }
259
275
 
260
276
  export interface WatcherRegistryOptions {
277
+ /**
278
+ * The constructing instance's location directory. Scopes the per-process
279
+ * live handoff: a new registry stops only a same-directory predecessor,
280
+ * so a second per-location instance (a worktree subordinate's boot) never
281
+ * stops the coordinator's poller. Omitted by tests and non-plugin hosts.
282
+ */
283
+ directory?: string;
261
284
  /** Delivers a batch of events for a session. Injected so tests never spawn. */
262
285
  deliver: (sessionID: string, events: WatcherEvent[]) => Promise<void>;
263
286
  /**
@@ -280,6 +303,48 @@ export interface WatcherRegistryOptions {
280
303
  maxPerSession?: number;
281
304
  /** Persistence hook: called after membership changes with the session's watcher list. */
282
305
  journal?: (sessionID: string, watchers: Watcher[]) => void;
306
+ /**
307
+ * Persistence hook for the PENDING event queue (the reload-loss fix):
308
+ * called whenever a session's queue changes. undefined clears the
309
+ * session's row. Without this, a plugin reload between detection and
310
+ * delivery silently eats the notification (observed 2026-10-07:
311
+ * watch_qn449jio's CI notice lost in the 09b805f reload window).
312
+ */
313
+ journalPending?: (sessionID: string, pending: { event: WatcherEvent; queuedAt: number }[] | undefined) => void;
314
+ /**
315
+ * Death callback: the registry declares a session dead (pending queue
316
+ * aged out with consecutive thrown deliveries, 404-class re-check
317
+ * passed) and has already cancelled its watchers and dropped its queue.
318
+ * The runtime removes the chat hosting state and tells live same-project
319
+ * sessions. A pending `watcher_death` row is the runtime's durable
320
+ * fallback for sessions that were busy at death time.
321
+ */
322
+ onSessionDeath?: (sessionID: string, death: { chatName: string | null; targets: string[] }) => void;
323
+ /** Eligibility for death detection: only sessions this process hosts are eligible (sibling v1 processes and MCP sessions never qualify). */
324
+ isHostedSession?: (sessionID: string) => boolean;
325
+ /** Pending-age threshold for death detection in minutes; defaults to THATCH_WATCH_DEATH_MINUTES or 120. */
326
+ deathMinutes?: number;
327
+ /** Clock for the pending-age test; injectable for tests. */
328
+ now?: () => number;
329
+ }
330
+
331
+ /**
332
+ * Minimum consecutive THROWN deliveries before the death scan may fire -
333
+ * a lone 404 (a transient blip mid-outage) must not kill watches.
334
+ */
335
+ const MIN_DEATH_THROWS = 5;
336
+
337
+ /**
338
+ * Whether a delivery error is the "session's route is gone" class - the
339
+ * closed-tab signature (verified live 2026-10-07: prompt_async to a closed
340
+ * v2 tab returns 404 while the session row persists). 5xx loops are a
341
+ * wedged server - an outage must never mass-cancel watches.
342
+ */
343
+ export function isNotFoundClass(err: unknown): boolean {
344
+ if (!err || typeof err !== "object") return false;
345
+ const e = err as { statusCode?: number; status?: number; message?: string };
346
+ if (e.statusCode === 404 || e.status === 404) return true;
347
+ return /404|not found/i.test(String(e.message ?? ""));
283
348
  }
284
349
 
285
350
  // ---------------------------------------------------------------------------
@@ -868,9 +933,14 @@ export class WatcherRegistry {
868
933
  #watchers = new Map<string, Watcher>();
869
934
  /** sessionID -> events detected but not yet delivered. */
870
935
  #pending = new Map<string, WatcherEvent[]>();
936
+ /** sessionID -> queuedAt of the oldest still-pending event (death-test clock). */
937
+ #firstPendingAt = new Map<string, number>();
938
+ /** sessionID -> consecutive THROWN delivery attempts; skips and successes reset. */
939
+ #consecutiveDeliveryThrows = new Map<string, number>();
871
940
  #timer: ReturnType<typeof setInterval> | null = null;
872
941
  #delivering = false;
873
942
  #polling = false;
943
+ #now: () => number;
874
944
  /**
875
945
  * Persistence hook (optional): called after every membership change with
876
946
  * the session's current watcher list, so the runtime can journal the
@@ -878,38 +948,59 @@ export class WatcherRegistry {
878
948
  * (tests, MCP) keeps the registry in-memory only.
879
949
  */
880
950
  #journal?: (sessionID: string, watchers: Watcher[]) => void;
951
+ /** Persistence hook for the pending queue - see WatcherRegistryOptions. */
952
+ #journalPending?: WatcherRegistryOptions["journalPending"];
953
+ /** Death callback - see WatcherRegistryOptions. */
954
+ #onSessionDeath?: WatcherRegistryOptions["onSessionDeath"];
955
+ /** Hosting eligibility - see WatcherRegistryOptions. */
956
+ #isHostedSession?: WatcherRegistryOptions["isHostedSession"];
881
957
  readonly #opts: WatcherRegistryOptions & {
882
958
  pollIntervalMs: number;
883
959
  ttlMinutes: number;
884
960
  maxPerSession: number;
885
961
  commandRunner: CommandRunner;
886
962
  commandTimeoutMs: number;
963
+ deathThresholdMs: number;
887
964
  };
888
965
  /**
889
- * The most recently constructed instance in this process. opencode v2
890
- * re-runs plugin setup() when the session's directory changes
966
+ * The most recently constructed instance per location directory. opencode
967
+ * v2 re-runs plugin setup() when the session's directory changes
891
968
  * (session_move between directories of the same repo) or on plugin-file
892
969
  * changes; each setup() built a fresh registry while the OLD instance's
893
970
  * poller timer kept running - the watch tools then queried an empty
894
971
  * registry ("No active watchers" / "No watcher in this session") while
895
972
  * delivery kept flowing from the orphaned poller. The handoff stops the
896
- * predecessor's poller at construction so at most one registry polls per
897
- * process; the watch tools' journal reconcile covers the state transfer.
973
+ * predecessor at construction so at most one registry polls PER LOCATION:
974
+ * a reload (same directory) hands off as before, while a second
975
+ * per-location instance (a worktree subordinate's boot) must NOT stop the
976
+ * coordinator's poller - one serve process hosts one instance per
977
+ * location, and each polls only its own directory's watchers. The watch
978
+ * tools' journal reconcile covers the state transfer.
898
979
  */
899
- static #live: WatcherRegistry | undefined;
980
+ static #live = new Map<string, WatcherRegistry>();
981
+ /** This registry's live-map key: the instance directory, or "" when the
982
+ * constructing test/host passes no directory. */
983
+ readonly #liveKey: string;
900
984
 
901
985
  constructor(options: WatcherRegistryOptions) {
986
+ const envDeathMinutes = Number(process.env.THATCH_WATCH_DEATH_MINUTES ?? 0) || 120;
902
987
  this.#opts = {
903
988
  pollIntervalMs: Number(process.env.THATCH_WATCH_POLL_SECONDS ?? 0) * 1000 || 60_000,
904
989
  ttlMinutes: Number(process.env.THATCH_WATCH_TTL_MINUTES ?? 0) || 480,
905
990
  maxPerSession: Number(process.env.THATCH_WATCH_MAX_PER_SESSION ?? 0) || 5,
906
991
  commandRunner: runWatchedCommand,
907
992
  commandTimeoutMs: defaultCommandTimeoutMs(),
993
+ deathThresholdMs: (options.deathMinutes ?? envDeathMinutes) * 60_000,
908
994
  ...options,
909
995
  };
996
+ this.#now = options.now ?? Date.now;
910
997
  this.#journal = options.journal;
911
- WatcherRegistry.#live?.stop();
912
- WatcherRegistry.#live = this;
998
+ this.#journalPending = options.journalPending;
999
+ this.#onSessionDeath = options.onSessionDeath;
1000
+ this.#isHostedSession = options.isHostedSession;
1001
+ this.#liveKey = options.directory ?? "";
1002
+ WatcherRegistry.#live.get(this.#liveKey)?.stop();
1003
+ WatcherRegistry.#live.set(this.#liveKey, this);
913
1004
  }
914
1005
 
915
1006
  // -- Lifecycle -----------------------------------------------------------
@@ -947,8 +1038,13 @@ export class WatcherRegistry {
947
1038
  /** Stops polling and drops all state. Called from the plugin's dispose. */
948
1039
  dispose(): void {
949
1040
  this.stop();
1041
+ // Only clear the live slot if THIS instance still owns it - a reload's
1042
+ // replacement registered under the same key after this one.
1043
+ if (WatcherRegistry.#live.get(this.#liveKey) === this) WatcherRegistry.#live.delete(this.#liveKey);
950
1044
  this.#watchers.clear();
951
1045
  this.#pending.clear();
1046
+ this.#firstPendingAt.clear();
1047
+ this.#consecutiveDeliveryThrows.clear();
952
1048
  }
953
1049
 
954
1050
  // -- CRUD ----------------------------------------------------------------
@@ -1181,6 +1277,9 @@ export class WatcherRegistry {
1181
1277
  if (w.sessionID === sessionID) this.#watchers.delete(id);
1182
1278
  }
1183
1279
  this.#pending.delete(sessionID);
1280
+ this.#firstPendingAt.delete(sessionID);
1281
+ this.#consecutiveDeliveryThrows.delete(sessionID);
1282
+ this.#journalPending?.(sessionID, undefined);
1184
1283
  this.#emit(sessionID);
1185
1284
  }
1186
1285
 
@@ -1201,15 +1300,31 @@ export class WatcherRegistry {
1201
1300
  for (const [id, watcher] of this.#watchers) {
1202
1301
  if (now > watcher.expiresAt) {
1203
1302
  this.#watchers.delete(id);
1303
+ // Report the expiry instead of dropping the watch silently: a
1304
+ // notification that just stops arriving looks identical to a
1305
+ // broken poller, and a session waiting on a watch that gated
1306
+ // greenlit work must be told the wait ended without the event.
1307
+ const minutes = Math.max(1, Math.round((watcher.expiresAt - watcher.createdAt) / 60_000));
1308
+ const target = watcherTarget(watcher);
1309
+ const queue = this.#pending.get(watcher.sessionID) ?? [];
1310
+ queue.push({
1311
+ type: "watch_expired",
1312
+ target,
1313
+ summary: `watch on ${target} expired after ${minutes} min - no further notifications will arrive from it`,
1314
+ url: this.#watchUrl(watcher),
1315
+ });
1316
+ this.#pending.set(watcher.sessionID, queue);
1317
+ this.#journalPendingNow(watcher.sessionID);
1204
1318
  this.#emit(watcher.sessionID);
1205
1319
  continue;
1206
1320
  }
1207
1321
  try {
1208
- const events = await this.#pollOne(watcher);
1322
+ const { events, stateChanged } = await this.#pollOne(watcher);
1209
1323
  if (events.length > 0) {
1210
1324
  const queue = this.#pending.get(watcher.sessionID) ?? [];
1211
1325
  queue.push(...events);
1212
1326
  this.#pending.set(watcher.sessionID, queue);
1327
+ this.#journalPendingNow(watcher.sessionID);
1213
1328
  // One-shot watchers end at first detection, not first delivery:
1214
1329
  // the queued events deliver through the normal pending path even
1215
1330
  // if the session is busy. Cancellation at detection time keeps
@@ -1218,8 +1333,15 @@ export class WatcherRegistry {
1218
1333
  if (watcher.once) {
1219
1334
  this.#watchers.delete(id);
1220
1335
  this.#emit(watcher.sessionID);
1336
+ continue;
1221
1337
  }
1222
1338
  }
1339
+ // Journal on state change, not just membership change: the poller
1340
+ // advances watcher.state in memory, and registry rebuilds (v2
1341
+ // reload rehydration, the watch tools' journal reconcile) hydrate
1342
+ // the journaled state back in verbatim. A journal that lags the
1343
+ // poller replays already-delivered events on the next diff.
1344
+ if (stateChanged) this.#emit(watcher.sessionID);
1223
1345
  } catch (err) {
1224
1346
  console.error(`[thatch] watcher ${id} poll failed: ${err}`);
1225
1347
  }
@@ -1230,14 +1352,20 @@ export class WatcherRegistry {
1230
1352
  }
1231
1353
  }
1232
1354
 
1233
- /** Fetches and diffs one watcher, filtered to its watched event types. */
1234
- async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
1355
+ /**
1356
+ * Fetches and diffs one watcher, filtered to its watched event types. Also
1357
+ * reports whether the fetched snapshot differed from the last-seen state -
1358
+ * the caller journals on change so a rebuild rehydrates a current baseline
1359
+ * instead of replaying old transitions.
1360
+ */
1361
+ async #pollOne(watcher: Watcher): Promise<{ events: WatcherEvent[]; stateChanged: boolean }> {
1235
1362
  if (watcher.source === "pr") {
1236
1363
  const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
1237
1364
  const events = diffPrState(watcher.state, after, watcherTarget(watcher), this.#prUrl(watcher))
1238
1365
  .filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
1366
+ const stateChanged = JSON.stringify(watcher.state) !== JSON.stringify(after);
1239
1367
  watcher.state = after;
1240
- return events;
1368
+ return { events, stateChanged };
1241
1369
  }
1242
1370
  if (watcher.source === "command") {
1243
1371
  const result = await this.#opts.commandRunner(watcher.command, watcher.cwd, watcher.timeoutMs);
@@ -1246,17 +1374,22 @@ export class WatcherRegistry {
1246
1374
  // transition guard: the baseline refusal keeps lastExit non-zero at
1247
1375
  // registration, and the one-shot cancel in poll() removes the watcher
1248
1376
  // the moment this event fires.
1377
+ const stateChanged = !result.timedOut && watcher.state.lastExit !== result.exitCode;
1249
1378
  if (!result.timedOut) watcher.state = { lastExit: result.exitCode };
1250
- if (result.timedOut || result.exitCode !== 0) return [];
1251
- return [{
1252
- type: "command_success",
1253
- target: watcherTarget(watcher),
1254
- summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
1255
- url: "",
1256
- }];
1379
+ if (result.timedOut || result.exitCode !== 0) return { events: [], stateChanged };
1380
+ return {
1381
+ events: [{
1382
+ type: "command_success",
1383
+ target: watcherTarget(watcher),
1384
+ summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
1385
+ url: "",
1386
+ }],
1387
+ stateChanged,
1388
+ };
1257
1389
  }
1258
1390
  const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
1259
1391
  const all = diffBranchState(watcher.state, after, watcherTarget(watcher), watcher.repo);
1392
+ const stateChanged = JSON.stringify(watcher.state) !== JSON.stringify(after);
1260
1393
  watcher.state = after;
1261
1394
  // The workflow-name filter narrows branch_workflow events only -
1262
1395
  // commits and check runs pass through unfiltered, or a watch filtered
@@ -1265,14 +1398,20 @@ export class WatcherRegistry {
1265
1398
  e.type !== "branch_workflow" ||
1266
1399
  watcher.workflows.length === 0 ||
1267
1400
  watcher.workflows.some((wf) => e.summary.toLowerCase().includes(wf.toLowerCase()));
1268
- return all
1269
- .filter((e) => watcher.events.includes(e.type as BranchWatcherEventType) && matchesFilter(e));
1401
+ return {
1402
+ events: all
1403
+ .filter((e) => watcher.events.includes(e.type as BranchWatcherEventType) && matchesFilter(e)),
1404
+ stateChanged,
1405
+ };
1270
1406
  }
1271
1407
 
1272
1408
  /**
1273
1409
  * Delivers pending events for every session that can accept a prompt.
1274
1410
  * Failed deliveries stay pending and retry on the next cycle or the next
1275
- * idle event.
1411
+ * idle event. A THROWN delivery is death evidence: consecutive throws
1412
+ * with the pending queue aged past the threshold (and a 404-class error)
1413
+ * declare the session dead. A skipped attempt (busy/compacting) is the
1414
+ * OPPOSITE - alive by definition - and resets the throw chain.
1276
1415
  */
1277
1416
  async deliverPending(): Promise<void> {
1278
1417
  if (this.#delivering) return;
@@ -1289,12 +1428,21 @@ export class WatcherRegistry {
1289
1428
  } catch (err) {
1290
1429
  console.error(`[thatch] canDeliver gate failed for ${sessionID}: ${err}`);
1291
1430
  }
1292
- if (!deliverable) continue;
1431
+ if (!deliverable) {
1432
+ // A skipped attempt is never death evidence - a busy or
1433
+ // compacting session is alive by definition.
1434
+ this.#consecutiveDeliveryThrows.delete(sessionID);
1435
+ continue;
1436
+ }
1293
1437
  try {
1294
1438
  await this.#opts.deliver(sessionID, events);
1295
1439
  this.#pending.delete(sessionID);
1440
+ this.#firstPendingAt.delete(sessionID);
1441
+ this.#consecutiveDeliveryThrows.delete(sessionID);
1442
+ this.#journalPending?.(sessionID, undefined);
1296
1443
  } catch (err) {
1297
1444
  console.error(`[thatch] watcher delivery to ${sessionID} failed: ${err}`);
1445
+ this.#maybeDeclareDeath(sessionID, err);
1298
1446
  }
1299
1447
  }
1300
1448
  } finally {
@@ -1302,12 +1450,101 @@ export class WatcherRegistry {
1302
1450
  }
1303
1451
  }
1304
1452
 
1453
+ /**
1454
+ * Death scan for one failed delivery. Death requires ALL of: the
1455
+ * session's pending queue aged past the threshold, a run of CONSECUTIVE
1456
+ * thrown attempts, a 404-class throw (a wedged server's 5xx loop is not
1457
+ * death - an outage must not mass-cancel watches), and hosting
1458
+ * eligibility (sibling v1 processes and MCP sessions are never declared
1459
+ * dead here). On death the watchers and queue are dropped and the
1460
+ * runtime's death callback carries the notice.
1461
+ */
1462
+ #maybeDeclareDeath(sessionID: string, err: unknown): void {
1463
+ const firstAt = this.#firstPendingAt.get(sessionID);
1464
+ const throws = (this.#consecutiveDeliveryThrows.get(sessionID) ?? 0) + 1;
1465
+ this.#consecutiveDeliveryThrows.set(sessionID, throws);
1466
+ if (!firstAt || this.#now() - firstAt < this.#opts.deathThresholdMs) return;
1467
+ if (throws < MIN_DEATH_THROWS) return;
1468
+ if (this.#isHostedSession && !this.#isHostedSession(sessionID)) return;
1469
+ if (!isNotFoundClass(err)) return;
1470
+ this.sessionDied(sessionID);
1471
+ }
1472
+
1473
+ /**
1474
+ * Declares a session dead: cancels its watchers, drops its pending
1475
+ * queue, and hands the runtime the death summary. Two callers: the
1476
+ * delivery-persistence heuristic (above) and the session-tab
1477
+ * `tab-closed` event (a CONFIRMED close skips the heuristic entirely -
1478
+ * instant path).
1479
+ */
1480
+ sessionDied(sessionID: string, death: { chatName?: string | null } = {}): void {
1481
+ const watchers = this.listForSession(sessionID);
1482
+ const pending = this.#pending.get(sessionID) ?? [];
1483
+ for (const w of watchers) this.#watchers.delete(w.id);
1484
+ this.#pending.delete(sessionID);
1485
+ this.#firstPendingAt.delete(sessionID);
1486
+ this.#consecutiveDeliveryThrows.delete(sessionID);
1487
+ this.#emit(sessionID); // empty list - the runtime's journal callback deletes the row
1488
+ this.#journalPending?.(sessionID, undefined);
1489
+ const targets = [...new Set([...watchers.map(watcherTarget), ...pending.map((e) => e.target)])];
1490
+ this.#onSessionDeath?.(sessionID, { chatName: death.chatName ?? null, targets });
1491
+ }
1492
+
1493
+ /**
1494
+ * Restores persisted pending events (reload rehydration, or a resumed
1495
+ * session's dormant scan rehydrating its own queue). Dedups by event
1496
+ * fingerprint and keeps the OLDEST queuedAt so the death-test clock
1497
+ * stays honest.
1498
+ */
1499
+ hydratePending(sessionID: string, pending: { event: WatcherEvent; queuedAt: number }[]): void {
1500
+ if (!pending || pending.length === 0) return;
1501
+ const queue = this.#pending.get(sessionID) ?? [];
1502
+ const seen = new Set(queue.map((e) => `${e.type}:${e.target}:${e.summary}`));
1503
+ for (const { event, queuedAt } of pending) {
1504
+ if (seen.has(`${event.type}:${event.target}:${event.summary}`)) continue;
1505
+ queue.push(event);
1506
+ const existing = this.#firstPendingAt.get(sessionID);
1507
+ if (existing === undefined || queuedAt < existing) this.#firstPendingAt.set(sessionID, queuedAt);
1508
+ }
1509
+ this.#pending.set(sessionID, queue);
1510
+ // Re-journal the hydrated queue. Hydration consumers delete the durable
1511
+ // row as they hand it over (the rehydrate loop, the dormant scan), and
1512
+ // delivery may not run before the NEXT reload - the queue must be
1513
+ // durable again the moment it is back in memory, or a second reload
1514
+ // loses the events.
1515
+ this.#journalPendingNow(sessionID);
1516
+ }
1517
+
1518
+ /** Journals the session's current pending queue, or clears the row when empty. */
1519
+ #journalPendingNow(sessionID: string): void {
1520
+ const queue = this.#pending.get(sessionID);
1521
+ if (!queue || queue.length === 0) {
1522
+ this.#journalPending?.(sessionID, undefined);
1523
+ return;
1524
+ }
1525
+ if (!this.#firstPendingAt.has(sessionID)) this.#firstPendingAt.set(sessionID, this.#now());
1526
+ const queuedAt = this.#firstPendingAt.get(sessionID)!;
1527
+ this.#journalPending?.(sessionID, queue.map((event) => ({ event, queuedAt })));
1528
+ }
1529
+
1530
+ /** Snapshot of the events waiting for a session, oldest first. */
1531
+ pendingEvents(sessionID: string): WatcherEvent[] {
1532
+ return [...(this.#pending.get(sessionID) ?? [])];
1533
+ }
1534
+
1305
1535
  /** Diagnostic: number of events waiting for a session. */
1306
1536
  pendingCount(sessionID: string): number {
1307
- return (this.#pending.get(sessionID) ?? []).length;
1537
+ return this.pendingEvents(sessionID).length;
1308
1538
  }
1309
1539
 
1310
1540
  #prUrl(watcher: PrWatcher): string {
1311
1541
  return `https://github.com/${watcher.repo}/pull/${watcher.pr}`;
1312
1542
  }
1543
+
1544
+ /** Best pointer for a lifecycle event about a watch: the watched target itself. */
1545
+ #watchUrl(watcher: Watcher): string {
1546
+ if (watcher.source === "pr") return this.#prUrl(watcher);
1547
+ if (watcher.source === "branch") return `https://github.com/${watcher.repo}/actions`;
1548
+ return "";
1549
+ }
1313
1550
  }
@@ -1,147 +0,0 @@
1
- # Intuition Drives
2
-
3
- ## Synopsis
4
-
5
- A pre-response deliberation layer that runs on the first message of a new
6
- opencode session: perception classifies the prompt, parallel drive sub-agents
7
- react from their motivational lenses, and a synthesis pass distills them into a
8
- directive injected into the main agent's context. opencode-only; requires
9
- parallel sub-agent dispatch via Task.
10
-
11
- ## Background
12
-
13
- Fnord runs as a CLI escript where every user invocation carries significant
14
- intent because it requires a full command-line invocation. Its coordinator runs
15
- an "intuition" step before responding: a perception pass classifies the prompt,
16
- ten parallel "drives" (curiosity, skepticism, optimization, modularity,
17
- convention, laziness, empathy, standing, pragmatism, stewardship) each react
18
- from their lens, and a synthesis pass distills them into a directive injected
19
- into the conscious agent's context.
20
-
21
- In opencode's chat TUI, user messages have far less friction. Running the full
22
- intuition pipeline on every message would burn tokens on "ok" and "yes." This
23
- feature is gated to the first message of a new session only.
24
-
25
- ## Session detection
26
-
27
- This feature depends on knowing "is this the first message of a new session."
28
- The opencode plugin hook surface provides this:
29
-
30
- - `session.created` fires ONLY when a brand-new session object is constructed
31
- (fresh creation or fork). It does NOT fire when a user resumes or continues an
32
- existing conversation (`--continue`/`--session` without `--fork` reuses the
33
- existing session ID).
34
- - `chat.message` fires on every incoming user message but carries no turn
35
- number or first-message flag in its input or output.
36
-
37
- Detection mechanism:
38
- 1. Track `session.created` events for top-level sessions (no `parentID`) in a
39
- `Set<string>`.
40
- 2. First `chat.message` for a session ID in that set = first message. Remove
41
- from set, trigger the feature.
42
- 3. Continued/resumed conversations never fire `session.created`, so they are
43
- never in the set. Second messages also don't trigger (already removed).
44
-
45
- Edge cases:
46
- - `--fork` fires `session.created` (correct: a fork is a new conversation).
47
- - Plugin not running when the session was created (e.g., `--continue` of a
48
- session from a prior run): no `session.created`, no trigger (correct: it is a
49
- continued conversation, not new).
50
- - Child sessions (sub-agents) fire `session.created` with a `parentID` —
51
- filtered out by the `parentID` check.
52
-
53
- ## What it does
54
-
55
- On the first message of a new session, before the main agent responds, a
56
- deliberation layer runs:
57
-
58
- 1. **Perception** — classify the prompt (interface, codebase, correction,
59
- continuation, meta, ambiguous) and produce a short first-person interpretation.
60
- 2. **Drive reactions** — parallel sub-agents, each arguing from a specific
61
- motivational lens. Drives are the same as fnord's: curiosity, skepticism,
62
- optimization, modularity, convention, laziness, empathy, standing, pragmatism,
63
- stewardship.
64
- 3. **Synthesis** — distill the drive reactions into a single directive injected
65
- into the main agent's context.
66
-
67
- ## Why port it
68
-
69
- No existing skill does pre-response deliberation. The drives carry real
70
- personality and map to Jeff's stance (stewardship = "correctness over comfort";
71
- skepticism = "no reassurance without verification"). The parallel sub-agent
72
- dispatch is natural for opencode's Task tool.
73
-
74
- ## Constraints
75
-
76
- - **opencode-only.** Requires parallel sub-agent dispatch via Task. Claude Code
77
- agents running skills cannot spawn sub-agents.
78
- - **First-message-only.** Gated by the session-detection mechanism above.
79
- Running on every message would burn tokens on trivial follow-ups.
80
- - **Prompt injection, not a tool.** The synthesis output is injected as a
81
- synthetic text part into the first `chat.message`, like the recall nudge. The
82
- main agent sees it as context, not as a tool result.
83
- - **No pre-response hook exists.** The synthesis must complete inside the
84
- `chat.message` hook (before the LLM loop runs) or be injected as a system
85
- message via `experimental.chat.system.transform`. The hook fires before the
86
- message is persisted and before the LLM loop starts, so a synchronous
87
- sub-agent dispatch inside `chat.message` would block the turn. The
88
- `experimental.chat.system.transform` hook is a better injection point because
89
- it runs on every turn and can append to the system prompt — but it does not
90
- have access to the user's message text. A hybrid: compute the synthesis in
91
- `chat.message` (which has the prompt text), cache it in a per-session map, and
92
- inject it in `experimental.chat.system.transform` on the same turn.
93
-
94
- ## Open questions
95
-
96
- 1. **Latency.** Ten parallel sub-agent calls add latency to the first turn. Is
97
- the value worth it? Options: reduce to 3-4 drives, run drives with a fast
98
- model, or make it opt-in.
99
- 2. **Injection point.** The `chat.message` hook fires before the LLM loop but
100
- injecting a synthetic part there adds it to the user message, not the system
101
- prompt. `experimental.chat.system.transform` can append to the system prompt
102
- but fires on every turn and lacks the user message text. The hybrid
103
- (compute-in-chat.message, inject-in-system.transform) requires a per-session
104
- cache with one-turn TTL.
105
- 3. **Drive selection.** Fnord runs all 10 drives. A smaller set (stewardship,
106
- skepticism, pragmatism, empathy) might capture 80% of the value at 40% of the
107
- cost.
108
-
109
- ## Decisions
110
-
111
- | Decision | Rationale |
112
- |----------|-----------|
113
- | opencode-only | Requires parallel sub-agent dispatch via Task |
114
- | First-message-only | Chat TUI has low message friction; running on every message wastes tokens |
115
- | Detection via session.created + chat.message | session.created fires only on new sessions; chat.message provides the first-message trigger |
116
- | Parallel drives via Task sub-agents | Matches fnord's architecture; drives are independent and parallelizable |
117
- | Injection via system.transform with chat.message cache | chat.message has the prompt text; system.transform can append to the system prompt; hybrid avoids blocking the turn |
118
-
119
- ## Architecture
120
-
121
- ```
122
- session.created (top-level, no parentID)
123
- → add sessionID to newSessions Set
124
-
125
- chat.message (first message for sessionID in newSessions)
126
- → remove from newSessions
127
- → perception + parallel drives + synthesis → cache in Map<sessionID, directive>
128
-
129
- experimental.chat.system.transform
130
- → if directive cached: append directive, then clear cache (one-shot)
131
-
132
- session.deleted
133
- → delete from directive cache, newSessions set
134
- ```
135
-
136
- ## Dependencies
137
-
138
- ### Runtime
139
-
140
- - Fast model for perception and drive reactions (parallel sub-agents)
141
- - Task tool for parallel drive dispatch (opencode sub-agent support)
142
-
143
- ### Built-in
144
-
145
- - Plugin hooks: `event` (session.created, session.deleted),
146
- `chat.message`, `experimental.chat.system.transform`
147
- - In-process `Map` for per-session caching (directive, newSessions set)