@jeffober/thatch 0.2.4 → 0.2.6
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 +56 -26
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +16 -8
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +21 -1
- package/bin/thatch +48 -15
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +18 -10
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +4 -1
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +194 -23
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +56 -15
- package/docs/dev/features/prediction-engine.md +71 -6
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +161 -0
- package/docs/dev/features/setup.md +5 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +89 -0
- package/docs/dev/mcp-parity.md +5 -5
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +12 -9
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +29 -13
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +5 -1
- package/docs/user/code-review.md +43 -2
- package/docs/user/commands.md +37 -10
- package/docs/user/cross-session-chat.md +76 -26
- package/docs/user/prediction-engine.md +23 -1
- package/docs/user/setup.md +16 -0
- package/docs/user/skills.md +9 -7
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +358 -43
- package/src/commands.ts +123 -18
- package/src/db.ts +74 -3
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +101 -9
- package/src/opencode/tui-plugin.ts +90 -0
- package/src/opencode/v2.ts +440 -66
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +164 -29
- package/src/runtime.ts +298 -15
- package/src/scoring-engine.ts +40 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +231 -0
- package/src/setup.ts +9 -3
- package/src/skills.ts +6 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +454 -76
- 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/runtime.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { join } from "node:path";
|
|
2
|
-
import { createDebugLog } from "./debug";
|
|
3
|
+
import { createDebugLog, type DebugLog } from "./debug";
|
|
3
4
|
import { ThatchDB, repoPathCache } from "./db";
|
|
5
|
+
import { chatOpenTabs as chatOpenTabsProvider } from "./tab-state";
|
|
4
6
|
import { SharedModelPool } from "./embeddings";
|
|
5
7
|
import { detectRepo, detectWorktreeKind, resolveSpawnCwd } from "./git";
|
|
6
8
|
import { buildCoreContext } from "./tool-defs";
|
|
@@ -25,9 +27,9 @@ import { installOpencodeCommands, opencodeActionCommandDefs, removeWrapUpCommand
|
|
|
25
27
|
import { hygieneReport } from "./hygiene";
|
|
26
28
|
import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
27
29
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
28
|
-
import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, watcherTarget, withCwdFallback, type Watcher } from "./watchers";
|
|
30
|
+
import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, watcherTarget, withCwdFallback, type Watcher, type WatcherEvent } from "./watchers";
|
|
29
31
|
import { watcherNotificationNudge, watcherRearmNotice, watcherDeathNotice, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
30
|
-
import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle, unexpiredGraceRehosts } from "./chat";
|
|
32
|
+
import { ChatPoller, createWakeGate, EXTRACTION_CHILD_TITLE, hostedSessionIds, isDefaultSessionTitle, isMachinerySessionTitle, nowIso, unexpiredGraceRehosts } from "./chat";
|
|
31
33
|
import { chatEnabled, chatAutoRegister, loadConfig, alertMode, notificationDefaults } from "./config";
|
|
32
34
|
import { sendNotification } from "./notify";
|
|
33
35
|
import { osProcessArgs, startupSessionId, continuesLastSessionFromArgv, continuesLastSessionId } from "./os-args";
|
|
@@ -134,12 +136,60 @@ export interface ThatchRuntime {
|
|
|
134
136
|
dispose(): Promise<void>;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Whether a wake delivery's target directory can host the attempt right
|
|
141
|
+
* now. existsSync cannot say WHY a path is absent - deleted worktree,
|
|
142
|
+
* broken symlink, permission on a parent - and none of those states can
|
|
143
|
+
* host a wake attempt, so one false answer covers the class (deliberate:
|
|
144
|
+
* the alternative, surfacing non-ENOENT errors through the attempt, buys
|
|
145
|
+
* a toast back for no recovery the mail does not already get from its
|
|
146
|
+
* pending-and-retry cycle). Synchronous on purpose: fire-and-forget
|
|
147
|
+
* callers and test harnesses observe promptSession timing with no added
|
|
148
|
+
* microtask between the gate and the wrapped call.
|
|
149
|
+
*/
|
|
150
|
+
export function directoryAvailable(path: string): boolean {
|
|
151
|
+
return existsSync(path);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The delivery guard: wraps the host's promptSession so a wake attempt
|
|
156
|
+
* never runs against a session whose directory has been deleted. The
|
|
157
|
+
* attempt itself would error, and opencode surfaces that failure to the
|
|
158
|
+
* user as a toast. Skipping instead leaves the pending mail queued - the
|
|
159
|
+
* check runs fresh on every attempt, so a worktree recreated at the same
|
|
160
|
+
* path (or a session revived in a live directory) resumes delivery with
|
|
161
|
+
* nothing lost. This covers every wake site at once because chat nudges,
|
|
162
|
+
* watcher notifications, watcher death notices, rehydrate pings, chat
|
|
163
|
+
* echoes, and extraction-child dispatch all funnel through promptSession,
|
|
164
|
+
* and a host instance is bound to its sessions' own directory.
|
|
165
|
+
*/
|
|
166
|
+
export function guardDelivery(
|
|
167
|
+
promptSession: HostCapabilities["promptSession"],
|
|
168
|
+
directory: string,
|
|
169
|
+
debug: DebugLog,
|
|
170
|
+
): HostCapabilities["promptSession"] {
|
|
171
|
+
return (sessionID, body, mode) => {
|
|
172
|
+
if (!directoryAvailable(directory)) {
|
|
173
|
+
debug("runtime:delivery", `skipped wake to ${sessionID}: directory ${directory} no longer exists`);
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
}
|
|
176
|
+
return promptSession(sessionID, body, mode);
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
137
180
|
export async function createRuntime(input: {
|
|
138
181
|
capabilities: HostCapabilities;
|
|
139
182
|
directory: string;
|
|
140
183
|
worktree: string;
|
|
184
|
+
/**
|
|
185
|
+
* True on the opencode v2 adapter: the system prompt's Tools list then
|
|
186
|
+
* includes the v2-only session-tab tool. v1 and MCP hosts omit it - the
|
|
187
|
+
* tool does not exist there, and a prompt listing a nonexistent tool is
|
|
188
|
+
* the first-call-mistake class.
|
|
189
|
+
*/
|
|
190
|
+
v2Tools?: boolean;
|
|
141
191
|
}): Promise<ThatchRuntime> {
|
|
142
|
-
const { capabilities
|
|
192
|
+
const { capabilities, directory, worktree, v2Tools } = input;
|
|
143
193
|
// The opencode server's cwd is wherever the server happened to start;
|
|
144
194
|
// `worktree` is the project this plugin instance actually serves.
|
|
145
195
|
// `directory` is the session's own directory - on resume after the
|
|
@@ -162,7 +212,10 @@ export async function createRuntime(input: {
|
|
|
162
212
|
|
|
163
213
|
// The DB must exist before identity detection: detectRepo consults the
|
|
164
214
|
// repo_paths cache when the session directory is gone.
|
|
165
|
-
|
|
215
|
+
// chatOpenTabs composes the tab-state reader (tabs.json across channels,
|
|
216
|
+
// minus confirmed closes) lazily per call - never cached - so roster
|
|
217
|
+
// rendering and broadcast targeting see the current tab state.
|
|
218
|
+
const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
|
|
166
219
|
const repoCache = repoPathCache(db);
|
|
167
220
|
const repo = await detectRepo(directory ?? worktree, repoCache);
|
|
168
221
|
|
|
@@ -174,6 +227,16 @@ export async function createRuntime(input: {
|
|
|
174
227
|
|
|
175
228
|
const debug = createDebugLog(dbPath);
|
|
176
229
|
|
|
230
|
+
// The delivery guard wraps the host's promptSession once, so no wake
|
|
231
|
+
// attempt in this instance runs against a deleted directory (see
|
|
232
|
+
// guardDelivery). The v2 wrap-up command in opencode/v2.ts calls
|
|
233
|
+
// capabilities.promptSession directly and deliberately bypasses this
|
|
234
|
+
// wrapper: it is user-invoked in a live tab, not a wake.
|
|
235
|
+
const caps: HostCapabilities = {
|
|
236
|
+
...capabilities,
|
|
237
|
+
promptSession: guardDelivery(capabilities.promptSession, directory ?? worktree, debug),
|
|
238
|
+
};
|
|
239
|
+
|
|
177
240
|
// Shared embedding model: refcounted per db path (see SharedModelPool).
|
|
178
241
|
const model = sharedModels.acquire(dbPath, modelName);
|
|
179
242
|
const releaseModel = () => sharedModels.release(dbPath);
|
|
@@ -221,7 +284,7 @@ export async function createRuntime(input: {
|
|
|
221
284
|
// process may deliver chat mail to - the map's keys).
|
|
222
285
|
const sessionStatus = new Map<string, string>();
|
|
223
286
|
|
|
224
|
-
// LLM alerts (docs/
|
|
287
|
+
// LLM alerts (docs/dev/features/notifications.md): notify when the LLM pauses for
|
|
225
288
|
// human input or finishes a round of real work. Plain in-memory state -
|
|
226
289
|
// a v2 reload mid-turn loses that turn's transition and stays silent
|
|
227
290
|
// (accepted). Delivery reuses the notify_user dispatcher; the alerts
|
|
@@ -287,7 +350,56 @@ export async function createRuntime(input: {
|
|
|
287
350
|
console.error(`[thatch] project directory ${from} deleted; watcher falling back to main checkout ${to}`);
|
|
288
351
|
});
|
|
289
352
|
|
|
353
|
+
/** Tells one live same-project session about a dead session's watchers
|
|
354
|
+
* (promptAsync, the watcher-notification path) and clears the durable
|
|
355
|
+
* death row on success. No live peer = the row waits for the next
|
|
356
|
+
* prompt-time surfacing pass. The dead session's project is the one
|
|
357
|
+
* captured at death time (onSessionDeath), not a re-read of the chat
|
|
358
|
+
* roster: the wrap-up exit unregisters the row between the death record
|
|
359
|
+
* and this delivery, and a re-read would strand a placed death as
|
|
360
|
+
* unplaceable. */
|
|
361
|
+
const deliverWatcherDeathNotices = async (
|
|
362
|
+
deadSessionID: string,
|
|
363
|
+
death: { chatName: string | null; targets: string[] },
|
|
364
|
+
deadProject: string | null,
|
|
365
|
+
) => {
|
|
366
|
+
if (death.targets.length === 0) {
|
|
367
|
+
// A watchless death is nobody's news, and onSessionDeath never
|
|
368
|
+
// writes a row for one - so there is nothing to deliver or clean
|
|
369
|
+
// here. Deleting unconditionally would erase an earlier placed row
|
|
370
|
+
// for the same session (a reopened session's second greenlit
|
|
371
|
+
// /thatch/exit); the surfacing pass cleans any legacy empty row.
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!deadProject) return; // unplaceable death - the row waits for surfacing
|
|
375
|
+
for (const peerID of sessionStatus.keys()) {
|
|
376
|
+
if (peerID === deadSessionID) continue;
|
|
377
|
+
const peerRow = db.findChatSession(peerID);
|
|
378
|
+
if (!peerRow || peerRow.project !== deadProject) continue;
|
|
379
|
+
if (!(await canPromptSession(peerID))) continue;
|
|
380
|
+
try {
|
|
381
|
+
await caps.promptSession(
|
|
382
|
+
peerID,
|
|
383
|
+
{ parts: [{ type: "text", text: watcherDeathNotice(death.targets, { name: death.chatName, sessionID: deadSessionID }, { rearmsOnResume: false }), synthetic: true }] },
|
|
384
|
+
"async",
|
|
385
|
+
);
|
|
386
|
+
// Delayed journal writer (post-await): the reload ownership rule
|
|
387
|
+
// applies - a disposed runtime's rows belong to the reloaded
|
|
388
|
+
// instance (docs/dev/gotchas.md).
|
|
389
|
+
if (disposed) return;
|
|
390
|
+
db.runtimeStateDelete("watcher_death", deadSessionID);
|
|
391
|
+
return;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error(`[thatch] death-notice delivery to ${peerID} failed: ${err}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
290
398
|
const watchers = new WatcherRegistry({
|
|
399
|
+
// Scope the per-process live handoff to THIS location: a worktree
|
|
400
|
+
// subordinate's instance boots in the same serve process and must not
|
|
401
|
+
// stop this coordinator's poller (one registry polls per directory).
|
|
402
|
+
directory,
|
|
291
403
|
// Journal watcher definitions so a v2 plugin reload (same process) can
|
|
292
404
|
// re-arm them; the registry journals after every membership change.
|
|
293
405
|
// Disposed check FIRST: a poll cycle suspended at an await can outlive
|
|
@@ -333,6 +445,43 @@ export async function createRuntime(input: {
|
|
|
333
445
|
canDeliver: canPromptSession,
|
|
334
446
|
ghRunner: ghApiRun,
|
|
335
447
|
commandRunner: watchedCommandRunner,
|
|
448
|
+
journalPending: (sessionID, pending) => {
|
|
449
|
+
if (pending === undefined) db.runtimeStateDelete("watcher_pending", sessionID);
|
|
450
|
+
else db.runtimeStatePut("watcher_pending", sessionID, pending, directory);
|
|
451
|
+
},
|
|
452
|
+
isHostedSession: (sessionID) =>
|
|
453
|
+
hostedSessionIds({
|
|
454
|
+
statusKeys: sessionStatus.keys(),
|
|
455
|
+
resumedSessions,
|
|
456
|
+
rehostedSessions: [...rehostedSessions, ...unexpiredGraceRehosts(rehostGrace, Date.now())],
|
|
457
|
+
exclude: childToParent.keys(),
|
|
458
|
+
}).includes(sessionID),
|
|
459
|
+
onSessionDeath: (deadSessionID, death) => {
|
|
460
|
+
// The dead tab's chat-hosting state dies with it - the session.deleted
|
|
461
|
+
// cleanup minus the chat unregister (the session is not deleted, only
|
|
462
|
+
// its tab: unregistering would tombstone a legitimate resume, and the
|
|
463
|
+
// roster row ages out through the reaper).
|
|
464
|
+
sessionStatus.delete(deadSessionID);
|
|
465
|
+
rehostGrace.delete(deadSessionID);
|
|
466
|
+
const hostedRow = db.runtimeStateAll().find((r) => r.kind === "hosted" && r.sessionID === directory);
|
|
467
|
+
if (hostedRow && Array.isArray(hostedRow.value)) {
|
|
468
|
+
db.runtimeStatePut("hosted", directory, (hostedRow.value as string[]).filter((sid) => sid !== deadSessionID), directory);
|
|
469
|
+
}
|
|
470
|
+
// Durable death record first, then best-effort live delivery - the
|
|
471
|
+
// row is deleted when a live same-project session has the news. The
|
|
472
|
+
// project is captured HERE (while the chat row still places it): the
|
|
473
|
+
// wrap-up exit unregisters the row right after recording, so the
|
|
474
|
+
// project cannot be re-derived at delivery time. A watchless death
|
|
475
|
+
// writes no row at all: runtimeStatePut upserts, so an empty-targets
|
|
476
|
+
// write would erase an earlier placed row for the same session (a
|
|
477
|
+
// reopened session that greenlights /thatch/exit a second time).
|
|
478
|
+
const deadRow = db.findChatSession(deadSessionID);
|
|
479
|
+
const deadProject = deadRow?.project ?? null;
|
|
480
|
+
if (death.targets.length > 0) {
|
|
481
|
+
db.runtimeStatePut("watcher_death", deadSessionID, { name: death.chatName, targets: death.targets, project: deadProject, at: nowIso() }, directory);
|
|
482
|
+
}
|
|
483
|
+
void deliverWatcherDeathNotices(deadSessionID, death, deadProject);
|
|
484
|
+
},
|
|
336
485
|
});
|
|
337
486
|
// gh presence decides whether watch_create and watch_branch_create work;
|
|
338
487
|
// checked lazily by the tools, but log once at startup so misconfiguration
|
|
@@ -638,6 +787,13 @@ export async function createRuntime(input: {
|
|
|
638
787
|
}
|
|
639
788
|
continue;
|
|
640
789
|
}
|
|
790
|
+
if (row.kind === "watcher_pending" || row.kind === "watcher_death") {
|
|
791
|
+
// Durable pending events and death records survive restarts: the
|
|
792
|
+
// pending queue rides a resumed session's rearm, the death record
|
|
793
|
+
// surfaces at a live session's next prompt. Both are deleted on
|
|
794
|
+
// consumption.
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
641
797
|
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
642
798
|
continue;
|
|
643
799
|
}
|
|
@@ -705,6 +861,28 @@ export async function createRuntime(input: {
|
|
|
705
861
|
continue;
|
|
706
862
|
}
|
|
707
863
|
pendingWrapUp.set(row.sessionID, row.value as { token: string; kind: "compact" | "exit" });
|
|
864
|
+
} else if (row.kind === "watcher_pending") {
|
|
865
|
+
// Journaled pending watcher events (the reload-loss fix). On a
|
|
866
|
+
// same-process reload the plugin re-ran in place and its sessions are
|
|
867
|
+
// still live, so hand the events straight back to the rehydrated
|
|
868
|
+
// registry - before this branch they fell into "unknown kind" and
|
|
869
|
+
// notifications queued while a session was busy were stranded until a
|
|
870
|
+
// full process restart. The startup-resume case rides along: the
|
|
871
|
+
// registry rehydrated this session's watcher definitions above, and
|
|
872
|
+
// its queued events belong with them. hydratePending re-journals the
|
|
873
|
+
// queue, so the events stay durable until delivery even across
|
|
874
|
+
// repeated reloads. (Foreign-pid non-startup rows took the dormant
|
|
875
|
+
// path above; scanDormantWatchers hydrates those at the resumed
|
|
876
|
+
// session's first message.)
|
|
877
|
+
db.runtimeStateDelete("watcher_pending", row.sessionID);
|
|
878
|
+
if (Array.isArray(row.value) && row.value.length > 0) {
|
|
879
|
+
watchers.hydratePending(row.sessionID, row.value as { event: WatcherEvent; queuedAt: number }[]);
|
|
880
|
+
}
|
|
881
|
+
} else if (row.kind === "watcher_death") {
|
|
882
|
+
// Death records are surfaced at the live session's next prompt
|
|
883
|
+
// (surfaceWatcherDeaths), not through registry hydration - leave the
|
|
884
|
+
// row for that consumption to delete.
|
|
885
|
+
continue;
|
|
708
886
|
} else {
|
|
709
887
|
continue; // unknown kind - leave it for a future version
|
|
710
888
|
}
|
|
@@ -776,6 +954,34 @@ export async function createRuntime(input: {
|
|
|
776
954
|
return (err as NodeJS.ErrnoException).code !== "ESRCH";
|
|
777
955
|
}
|
|
778
956
|
};
|
|
957
|
+
/** Surfaces persisted watcher-death rows to this session (same project
|
|
958
|
+
* only), deleting each row on surfacing. Runs per prompt - deaths are
|
|
959
|
+
* rare and the row read is cheap - so the once-per-process
|
|
960
|
+
* dormantScanned guard deliberately does NOT apply: a death row
|
|
961
|
+
* persisted after this session's first prompt must still surface. */
|
|
962
|
+
const surfaceWatcherDeaths = (forSessionID: string): string[] => {
|
|
963
|
+
const ownRow = db.findChatSession(forSessionID);
|
|
964
|
+
const project = ownRow?.project ?? null;
|
|
965
|
+
if (!project) return [];
|
|
966
|
+
const notices: string[] = [];
|
|
967
|
+
for (const row of db.runtimeStateAll()) {
|
|
968
|
+
if (row.kind !== "watcher_death" || row.directory !== directory) continue;
|
|
969
|
+
const death = (row.value ?? {}) as { name?: string | null; targets?: string[]; project?: string | null };
|
|
970
|
+
if (!Array.isArray(death.targets) || death.targets.length === 0) {
|
|
971
|
+
// Malformed or fully-consumed row - clean it up.
|
|
972
|
+
db.runtimeStateDelete("watcher_death", row.sessionID);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
// Only the dead session's own project hears the death. The "global"
|
|
976
|
+
// project matches globally - the same accepted class as the identity
|
|
977
|
+
// binding's unknown-to-global mapping.
|
|
978
|
+
if ((death.project ?? "global") !== project) continue;
|
|
979
|
+
notices.push(watcherDeathNotice(death.targets, { name: death.name ?? null, sessionID: row.sessionID }, { rearmsOnResume: false }));
|
|
980
|
+
db.runtimeStateDelete("watcher_death", row.sessionID);
|
|
981
|
+
}
|
|
982
|
+
return notices;
|
|
983
|
+
};
|
|
984
|
+
|
|
779
985
|
const scanDormantWatchers = async (sessionID: string): Promise<string[]> => {
|
|
780
986
|
if (dormantScanned.has(sessionID)) return [];
|
|
781
987
|
dormantScanned.add(sessionID);
|
|
@@ -807,6 +1013,17 @@ export async function createRuntime(input: {
|
|
|
807
1013
|
const notices: string[] = [];
|
|
808
1014
|
if (own.length > 0) {
|
|
809
1015
|
const { rearmed, expired, failed } = await watchers.rearm(sessionID, own);
|
|
1016
|
+
// Durable pending events ride along: the reload-loss fix journals
|
|
1017
|
+
// the pending queue separately from the definitions, and a resumed
|
|
1018
|
+
// session's undelivered notifications must still deliver.
|
|
1019
|
+
const pendingRow = db.runtimeStateAll().find((r) => r.kind === "watcher_pending" && r.sessionID === sessionID);
|
|
1020
|
+
if (pendingRow && Array.isArray(pendingRow.value) && pendingRow.value.length > 0) {
|
|
1021
|
+
// Delete-then-hydrate: hydratePending re-journals the queue, so
|
|
1022
|
+
// the events are durable again until delivery even across a
|
|
1023
|
+
// second reload (hydrating without the re-journal lost them).
|
|
1024
|
+
db.runtimeStateDelete("watcher_pending", sessionID);
|
|
1025
|
+
watchers.hydratePending(sessionID, pendingRow.value as { event: WatcherEvent; queuedAt: number }[]);
|
|
1026
|
+
}
|
|
810
1027
|
// This session came back to life: its own previous death notice is
|
|
811
1028
|
// obsolete - a future death deserves a fresh one.
|
|
812
1029
|
notifiedWatcherDeaths.delete(sessionID);
|
|
@@ -954,7 +1171,7 @@ export async function createRuntime(input: {
|
|
|
954
1171
|
console.error(`[thatch] command install failed: ${err}`);
|
|
955
1172
|
}
|
|
956
1173
|
|
|
957
|
-
const sys = systemPrompt(repo, chatOn);
|
|
1174
|
+
const sys = systemPrompt(repo, chatOn, v2Tools);
|
|
958
1175
|
const compact = compactionContext(repo);
|
|
959
1176
|
|
|
960
1177
|
// Direct extraction: create a child session linked to the parent and prompt
|
|
@@ -982,13 +1199,22 @@ export async function createRuntime(input: {
|
|
|
982
1199
|
|
|
983
1200
|
const result = await caps.sessionCreate({
|
|
984
1201
|
parentID,
|
|
985
|
-
title:
|
|
1202
|
+
title: EXTRACTION_CHILD_TITLE,
|
|
986
1203
|
});
|
|
987
1204
|
// session.created event fires here, setting childToParent and
|
|
988
1205
|
// parentSnapshots (snapshot of the full pending buffer, since we
|
|
989
1206
|
// have not called accept).
|
|
990
1207
|
const childId = result.id;
|
|
991
1208
|
extractionChildren.add(childId);
|
|
1209
|
+
// Durable machinery marker: the in-memory maps below are lost to a
|
|
1210
|
+
// plugin reload or daemon restart, and without a durable signal the
|
|
1211
|
+
// chat auto-registration paths would register the child as a chat
|
|
1212
|
+
// participant the moment its events land in an instance that lost the
|
|
1213
|
+
// mapping (the thatch-extraction corpse rows, 81 by 2026-10-07). The
|
|
1214
|
+
// marker is never removed on child cleanup - ids are never reused, so
|
|
1215
|
+
// it can only ever refer to this machinery session, and it ages out
|
|
1216
|
+
// with the auto-row TTL.
|
|
1217
|
+
db.markChatMachinery(childId, "extraction");
|
|
992
1218
|
// v2's session API cannot create a CHILD session (no parentID in its
|
|
993
1219
|
// create input), so the adapter returns a top-level session and no
|
|
994
1220
|
// session.created event carries the parent mapping. Set both here
|
|
@@ -1133,6 +1359,7 @@ export async function createRuntime(input: {
|
|
|
1133
1359
|
|
|
1134
1360
|
return {
|
|
1135
1361
|
coreContext: buildCoreContext(db, model, repo, {
|
|
1362
|
+
openTabs: chatOpenTabsProvider(() => db.chatTabClosures()),
|
|
1136
1363
|
extractionPayloadProvider: (targetID: string, fetcherID?: string): string | null => {
|
|
1137
1364
|
// A fetcher identity turns the fetch into the delivery record: the
|
|
1138
1365
|
// claimed entries stay accepted, and THAT fetcher's completion
|
|
@@ -1377,8 +1604,12 @@ export async function createRuntime(input: {
|
|
|
1377
1604
|
// nudges take, so the model sees them on every call of the turn -
|
|
1378
1605
|
// plus a best-effort toast for the human.
|
|
1379
1606
|
const rearmNotices = !childToParent.has(input.sessionID) ? await scanDormantWatchers(input.sessionID) : [];
|
|
1380
|
-
|
|
1381
|
-
|
|
1607
|
+
// Death records surface per prompt, not once-per-process: a death
|
|
1608
|
+
// row persisted after this session's first prompt must still reach
|
|
1609
|
+
// it.
|
|
1610
|
+
const deathNotices = surfaceWatcherDeaths(input.sessionID);
|
|
1611
|
+
if (rearmNotices.length + deathNotices.length > 0) {
|
|
1612
|
+
for (const text of [...rearmNotices, ...deathNotices]) {
|
|
1382
1613
|
output.parts.push({
|
|
1383
1614
|
id: `prt_thatch_watch_${Math.random().toString(36).slice(2)}`,
|
|
1384
1615
|
sessionID: input.sessionID,
|
|
@@ -1409,6 +1640,7 @@ export async function createRuntime(input: {
|
|
|
1409
1640
|
chatAutoRegister(loadConfig(dbPath).config) &&
|
|
1410
1641
|
input.sessionID &&
|
|
1411
1642
|
!childToParent.has(input.sessionID) &&
|
|
1643
|
+
!db.isChatMachinery(input.sessionID) &&
|
|
1412
1644
|
!db.hasChatLeaveTombstone(input.sessionID)
|
|
1413
1645
|
) {
|
|
1414
1646
|
try {
|
|
@@ -1695,6 +1927,23 @@ export async function createRuntime(input: {
|
|
|
1695
1927
|
}
|
|
1696
1928
|
return;
|
|
1697
1929
|
}
|
|
1930
|
+
if (event.type === "session.tab_closed") {
|
|
1931
|
+
// The session-tab tool's TUI plugin confirmed a tab close (tool-
|
|
1932
|
+
// initiated only). CONFIRMED death: skip the delivery-persistence
|
|
1933
|
+
// heuristic entirely and run the death path now.
|
|
1934
|
+
const props = event.properties as { sessionID?: string; chatName?: string | null };
|
|
1935
|
+
if (props.sessionID) {
|
|
1936
|
+
// Roster demotion record: a confirmed close lets every reader
|
|
1937
|
+
// (roster render, broadcast targeting) distrust a residue tab
|
|
1938
|
+
// row for this session instantly, instead of waiting for
|
|
1939
|
+
// tabs.json to agree or the heartbeat to age out. Self-expiring
|
|
1940
|
+
// (CHAT_TAB_CLOSED_OVERRIDE_MS) and superseded by any later
|
|
1941
|
+
// heartbeat, so a session that regains a tab reads OPEN again.
|
|
1942
|
+
db.runtimeStatePut("chat_tab_closed", props.sessionID, { at: nowIso() }, directory);
|
|
1943
|
+
watchers.sessionDied(props.sessionID, { chatName: props.chatName ?? null });
|
|
1944
|
+
}
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1698
1947
|
if (event.type === "session.status") {
|
|
1699
1948
|
const sessionID = event.properties.sessionID;
|
|
1700
1949
|
const statusType = event.properties.status?.type;
|
|
@@ -1736,6 +1985,13 @@ export async function createRuntime(input: {
|
|
|
1736
1985
|
const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
|
|
1737
1986
|
if (wrapUp) {
|
|
1738
1987
|
pendingWrapUp.delete(sessionID);
|
|
1988
|
+
// Reload ownership rule (docs/dev/gotchas.md): after a v2 plugin
|
|
1989
|
+
// reload this instance's journal rows belong to the reloaded
|
|
1990
|
+
// instance - resolve nothing further once disposed. Safe today
|
|
1991
|
+
// because the only producer of this branch (the v2 pump) is
|
|
1992
|
+
// settled before dispose, but the guard makes that invariant
|
|
1993
|
+
// local instead of transitive.
|
|
1994
|
+
if (disposed) return;
|
|
1739
1995
|
db.runtimeStateDelete("wrapup", sessionID);
|
|
1740
1996
|
let ready = false;
|
|
1741
1997
|
try {
|
|
@@ -1751,13 +2007,24 @@ export async function createRuntime(input: {
|
|
|
1751
2007
|
console.error(`[thatch] wrap-up message fetch failed: ${err}`);
|
|
1752
2008
|
}
|
|
1753
2009
|
if (ready) {
|
|
1754
|
-
// An exit-greenlit session is
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
2010
|
+
// An exit-greenlit session is closing out: leave the chat
|
|
2011
|
+
// directory too, so other sessions stop addressing mail to a
|
|
2012
|
+
// roster entry whose host is about to vanish. The unregister
|
|
1757
2013
|
// tombstone also stops any straggler auto-register from
|
|
1758
2014
|
// resurrecting the row during shutdown. Compact keeps the
|
|
1759
2015
|
// session alive, so only the exit path does this.
|
|
1760
2016
|
if (wrapUp.kind === "exit") {
|
|
2017
|
+
// Watcher deaths run BEFORE the unregister: onSessionDeath
|
|
2018
|
+
// places the death row by the session's chat row, and the
|
|
2019
|
+
// unregister deletes that row. The v2 exit action's emit is
|
|
2020
|
+
// TUI-only (the pump never translates it into a second
|
|
2021
|
+
// death pass, which would erase the placed row), so this is
|
|
2022
|
+
// the exit's only death recording.
|
|
2023
|
+
try {
|
|
2024
|
+
watchers.sessionDied(sessionID, { chatName: db.findChatSession(sessionID)?.name ?? null });
|
|
2025
|
+
} catch (err) {
|
|
2026
|
+
console.error(`[thatch] watcher death recording on exit failed: ${err}`);
|
|
2027
|
+
}
|
|
1761
2028
|
try {
|
|
1762
2029
|
db.unregisterChatSession(sessionID);
|
|
1763
2030
|
} catch (err) {
|
|
@@ -1768,7 +2035,7 @@ export async function createRuntime(input: {
|
|
|
1768
2035
|
if (wrapUp.kind === "compact") {
|
|
1769
2036
|
await caps.compactSession(sessionID);
|
|
1770
2037
|
} else {
|
|
1771
|
-
await caps.exitHost();
|
|
2038
|
+
await caps.exitHost(sessionID);
|
|
1772
2039
|
}
|
|
1773
2040
|
} catch (err) {
|
|
1774
2041
|
console.error(`[thatch] wrap-up action failed: ${err}`);
|
|
@@ -1804,7 +2071,10 @@ export async function createRuntime(input: {
|
|
|
1804
2071
|
// task sub-agents) are machinery, not chat participants - without
|
|
1805
2072
|
// this check every fact-extractor run registered a roster row
|
|
1806
2073
|
// (the "thatch-extraction" corpse flood, September 2026). The
|
|
1807
|
-
// prompt-register path above has the same check.
|
|
2074
|
+
// prompt-register path above has the same check. The childToParent
|
|
2075
|
+
// map alone is not enough: it is in-memory and lost to a plugin
|
|
2076
|
+
// reload or daemon restart, so the IIFE below also checks the
|
|
2077
|
+
// durable chat_machinery marker and the fetched title.
|
|
1808
2078
|
if (
|
|
1809
2079
|
chatOn &&
|
|
1810
2080
|
chatAutoRegister(loadConfig(dbPath).config) &&
|
|
@@ -1814,10 +2084,23 @@ export async function createRuntime(input: {
|
|
|
1814
2084
|
) {
|
|
1815
2085
|
void (async () => {
|
|
1816
2086
|
try {
|
|
2087
|
+
// Machinery marker check first: a plugin-created extraction
|
|
2088
|
+
// child whose in-memory mapping was lost (reload/restart
|
|
2089
|
+
// window) must not register - and a corpse row an earlier
|
|
2090
|
+
// race did register is deleted here (self-healing).
|
|
2091
|
+
if (db.isChatMachinery(sessionID)) {
|
|
2092
|
+
db.unregisterChatSession(sessionID);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
1817
2095
|
const data = await caps.sessionGet(sessionID);
|
|
1818
2096
|
const title = data?.title ?? "";
|
|
1819
2097
|
// Placeholder titles never become topics (the real one
|
|
1820
2098
|
// converges on a later idle via refreshChatTopic).
|
|
2099
|
+
// Machinery titles (the extraction child) never register at
|
|
2100
|
+
// all - the durable marker above is the primary guard; this
|
|
2101
|
+
// title check covers a marked session whose marker aged out
|
|
2102
|
+
// or was never written.
|
|
2103
|
+
if (isMachinerySessionTitle(title)) return;
|
|
1821
2104
|
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
1822
2105
|
const res = db.registerChatSession(sessionID, repo, topic, "opencode", null, detectWorktreeKind(worktree));
|
|
1823
2106
|
// The topic converges as the auto-titler lands a real title:
|
package/src/scoring-engine.ts
CHANGED
|
@@ -288,13 +288,29 @@ export class ScoringEngine {
|
|
|
288
288
|
|
|
289
289
|
createEdge(matcherId: string, itemId: string, weight: number): void {
|
|
290
290
|
const now = new Date().toISOString();
|
|
291
|
-
const { edgesTable, itemForeignKey } = this.#cfg;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
291
|
+
const { edgesTable, itemForeignKey, matchersTable } = this.#cfg;
|
|
292
|
+
// Insert and bump run atomically: an edge that landed without its
|
|
293
|
+
// updated_at bump would read as older than it is. bun:sqlite nests
|
|
294
|
+
// transaction functions via savepoints, so this composes with a
|
|
295
|
+
// caller's outer transaction (prediction_update's, for one).
|
|
296
|
+
// Bump only when the insert landed - a re-link to an existing edge
|
|
297
|
+
// changes nothing.
|
|
298
|
+
// A matcher's updated_at means "last change to the matcher row or its
|
|
299
|
+
// edge set" (not "matcher text written"): re-linking a matcher to a
|
|
300
|
+
// corrected item is graph activity the planned staleness audit must
|
|
301
|
+
// see. No reader consumes matcher updated_at yet; the bump keeps the
|
|
302
|
+
// timestamp truthful for when that audit lands.
|
|
303
|
+
this.#db.transaction(() => {
|
|
304
|
+
const result = this.#db.run(
|
|
305
|
+
`INSERT INTO ${edgesTable} (matcher_id, ${itemForeignKey}, weight, created_at)
|
|
306
|
+
VALUES (?, ?, ?, ?)
|
|
307
|
+
ON CONFLICT(matcher_id, ${itemForeignKey}) DO NOTHING`,
|
|
308
|
+
[matcherId, itemId, weight, now] as any,
|
|
309
|
+
);
|
|
310
|
+
if (result.changes > 0) {
|
|
311
|
+
this.#db.run(`UPDATE ${matchersTable} SET updated_at = ? WHERE id = ?`, [now, matcherId] as any);
|
|
312
|
+
}
|
|
313
|
+
})();
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
adjustConfidence(itemId: string, signal: "confirm" | "disconfirm" | "soft"): void {
|
|
@@ -349,8 +365,23 @@ export class ScoringEngine {
|
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
deleteItem(itemId: string): boolean {
|
|
352
|
-
const
|
|
353
|
-
|
|
368
|
+
const { itemsTable, edgesTable, itemForeignKey, matchersTable } = this.#cfg;
|
|
369
|
+
// Bump the matchers whose edges are about to cascade away with the
|
|
370
|
+
// item (edge rows have ON DELETE CASCADE, so the DELETE below removes
|
|
371
|
+
// them implicitly). Same updated_at meaning as createEdge: last change
|
|
372
|
+
// to the matcher row or its edge set. Bump and delete run atomically
|
|
373
|
+
// (bun:sqlite nests via savepoints): a bump whose delete then failed
|
|
374
|
+
// would record graph activity that never happened.
|
|
375
|
+
return this.#db.transaction(() => {
|
|
376
|
+
const now = new Date().toISOString();
|
|
377
|
+
this.#db.run(
|
|
378
|
+
`UPDATE ${matchersTable} SET updated_at = ?
|
|
379
|
+
WHERE id IN (SELECT matcher_id FROM ${edgesTable} WHERE ${itemForeignKey} = ?)`,
|
|
380
|
+
[now, itemId] as any,
|
|
381
|
+
);
|
|
382
|
+
const result = this.#db.run(`DELETE FROM ${itemsTable} WHERE id = ?`, [itemId]);
|
|
383
|
+
return result.changes > 0;
|
|
384
|
+
})();
|
|
354
385
|
}
|
|
355
386
|
|
|
356
387
|
listItems(store: string): {
|
package/src/session-db.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function mostRecentTopLevelSessionId(directory: string): string | null {
|
|
|
77
77
|
.query(
|
|
78
78
|
"SELECT id FROM session WHERE directory = ? AND (parent_id IS NULL OR parent_id = '') ORDER BY time_updated DESC LIMIT 1",
|
|
79
79
|
)
|
|
80
|
-
.get(directory) as { id: string } |
|
|
80
|
+
.get(directory) as { id: string } | null;
|
|
81
81
|
return row?.id ?? null;
|
|
82
82
|
} catch {
|
|
83
83
|
return null;
|
|
@@ -152,7 +152,7 @@ export class SessionDB {
|
|
|
152
152
|
)
|
|
153
153
|
.get(sessionID) as
|
|
154
154
|
| { id: string; title: string; directory: string; agent: string | null; model: string | null; time_created: number; time_updated: number }
|
|
155
|
-
|
|
|
155
|
+
| null;
|
|
156
156
|
if (!row) return null;
|
|
157
157
|
return {
|
|
158
158
|
id: row.id,
|
|
@@ -221,7 +221,7 @@ export class SessionDB {
|
|
|
221
221
|
)
|
|
222
222
|
.get(partID) as
|
|
223
223
|
| { part_id: string; message_id: string; session_id: string; time_created: number; part_data: string; message_data: string }
|
|
224
|
-
|
|
|
224
|
+
| null;
|
|
225
225
|
if (!row) return null;
|
|
226
226
|
const partData = JSON.parse(row.part_data) as Record<string, unknown>;
|
|
227
227
|
const messageData = JSON.parse(row.message_data) as Record<string, unknown>;
|
|
@@ -240,7 +240,7 @@ export class SessionDB {
|
|
|
240
240
|
getMessage(messageID: string): { messageId: string; sessionId: string; timeCreated: number; role: MessageRole; data: Record<string, unknown>; parts: SessionPart[] } | null {
|
|
241
241
|
const row = this.#db
|
|
242
242
|
.query("SELECT id, session_id, time_created, data FROM message WHERE id = ?")
|
|
243
|
-
.get(messageID) as { id: string; session_id: string; time_created: number; data: string } |
|
|
243
|
+
.get(messageID) as { id: string; session_id: string; time_created: number; data: string } | null;
|
|
244
244
|
if (!row) return null;
|
|
245
245
|
const decoded = JSON.parse(row.data) as Record<string, unknown>;
|
|
246
246
|
const parts = this.listParts(row.session_id).filter((p) => p.messageId === messageID);
|