@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.
- package/README.md +8 -1
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/bin/thatch +95 -44
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +7 -2
- package/docs/dev/features/cli.md +2 -0
- package/docs/dev/features/cross-session-chat.md +118 -11
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/opencode-plugin.md +18 -3
- package/docs/dev/features/prediction-engine.md +44 -5
- package/docs/dev/features/session-tabs.md +155 -0
- package/docs/dev/features/setup.md +12 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +68 -0
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +1 -1
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +12 -3
- package/docs/user/cli.md +5 -1
- package/docs/user/commands.md +11 -4
- package/docs/user/cross-session-chat.md +31 -8
- package/docs/user/prediction-engine.md +11 -0
- package/docs/user/setup.md +29 -0
- package/docs/user/skills.md +1 -1
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/chat.ts +320 -27
- package/src/commands.ts +14 -0
- package/src/db.ts +51 -1
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +95 -7
- package/src/opencode/tui-plugin.ts +70 -0
- package/src/opencode/v2.ts +197 -4
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +110 -23
- package/src/runtime.ts +187 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +180 -0
- package/src/setup.ts +59 -3
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +412 -55
- package/src/tools.ts +2 -0
- package/src/watchers.ts +263 -26
- package/docs/plans/intuition-drives.md +0 -147
- package/docs/plans/llm-alerts.md +0 -287
- package/docs/plans/thatch-actions.md +0 -179
- 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
|
-
|
|
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[] = [
|
|
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
|
|
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
|
|
897
|
-
*
|
|
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
|
|
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
|
-
|
|
912
|
-
|
|
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
|
-
/**
|
|
1234
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
|
1269
|
-
|
|
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)
|
|
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
|
|
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)
|