@jeffober/thatch 0.2.0 → 0.2.2

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 (37) hide show
  1. package/README.md +4 -2
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/artifacts/skills/thatch-plan-refinement.md +3 -2
  5. package/artifacts/skills/thatch-refine-infra.md +66 -0
  6. package/artifacts/skills/thatch-refine-oss-contribution.md +68 -0
  7. package/artifacts/skills/thatch-refine-personal.md +61 -0
  8. package/artifacts/skills/thatch-refine-shared-lib.md +69 -0
  9. package/artifacts/skills/thatch-refine-spike.md +53 -0
  10. package/artifacts/skills/thatch-refine-team-app.md +69 -0
  11. package/artifacts/skills/thatch-refine.md +84 -0
  12. package/docs/dev/README.md +11 -7
  13. package/docs/dev/features/cross-session-chat.md +8 -1
  14. package/docs/dev/features/memory-store.md +1 -1
  15. package/docs/dev/features/nudge-pipeline.md +12 -0
  16. package/docs/dev/features/opencode-plugin.md +1 -1
  17. package/docs/dev/features/session-lifecycle.md +1 -1
  18. package/docs/dev/gotchas.md +71 -1
  19. package/docs/dev/mcp-parity.md +1 -1
  20. package/docs/dev/setup-and-hooks.md +1 -1
  21. package/docs/dev/skills.md +8 -1
  22. package/docs/user/README.md +8 -1
  23. package/docs/user/commands.md +8 -0
  24. package/docs/user/memory.md +1 -1
  25. package/docs/user/skills.md +12 -0
  26. package/package.json +1 -1
  27. package/src/chat.ts +17 -0
  28. package/src/commands.ts +23 -3
  29. package/src/db.ts +77 -12
  30. package/src/extraction.ts +89 -3
  31. package/src/index.ts +45 -0
  32. package/src/opencode/v2.ts +48 -4
  33. package/src/prompts.ts +118 -30
  34. package/src/runtime.ts +271 -72
  35. package/src/skills.ts +7 -0
  36. package/src/tool-defs.ts +156 -28
  37. package/src/watchers.ts +14 -0
package/src/runtime.ts CHANGED
@@ -28,7 +28,7 @@ import { seedDefaultBehaviors } from "./seed-behaviors";
28
28
  import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
29
29
  import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, watcherTarget, withCwdFallback, type Watcher } from "./watchers";
30
30
  import { watcherNotificationNudge, watcherRearmNotice, watcherDeathNotice, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
31
- import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle } from "./chat";
31
+ import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle, unexpiredGraceRehosts } from "./chat";
32
32
  import { chatEnabled, chatAutoRegister, loadConfig, alertMode, notificationDefaults } from "./config";
33
33
  import { sendNotification } from "./notify";
34
34
  import { osProcessArgs, startupSessionId, continuesLastSessionFromArgv, continuesLastSessionId } from "./os-args";
@@ -51,6 +51,17 @@ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55
51
51
  // returns to days later.
52
52
  const DORMANT_WATCHER_GRACE_MS = 24 * 60 * 60 * 1000;
53
53
 
54
+ // Grace rehosting after a RESTART (see the hosted-row branch in the
55
+ // rehydration loop): the dead daemon's hosted set re-hosts for this window.
56
+ // Open TUI tabs reconnect to the new daemon but emit no events until the
57
+ // user types, so without the grace they go falsely stale ("presumed gone")
58
+ // while alive-idle, and mail stops waking them. Sessions that emit activity
59
+ // graduate to permanent hosting via the event stream; still-silent ones
60
+ // (closed tabs) age out at the grace and go stale again. Matches
61
+ // DORMANT_WATCHER_GRACE_MS - same resumability shape, same trade.
62
+ const CHAT_REHOST_GRACE_MS =
63
+ (Number(process.env.THATCH_CHAT_REHOST_GRACE_MINUTES ?? 0) * 60_000) || 24 * 60 * 60 * 1000;
64
+
54
65
  /**
55
66
  * True when every definition in a dormant watcher row is past its TTL plus
56
67
  * the dormant grace period - the row is a corpse no resume can usefully
@@ -142,6 +153,14 @@ export async function createRuntime(input: {
142
153
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
143
154
  const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
144
155
 
156
+ // Set by dispose(). Delayed async work (the child-reconcile timer, the
157
+ // extraction pipeline's queued finalization, queued event handlers) can
158
+ // fire after the db is closed; anything running past this flag must not
159
+ // touch the db - and journal writes must no-op BEFORE the lazy reopen
160
+ // accessor (src/db.ts) would hand them a fresh connection, because after
161
+ // a v2 plugin reload the RELOADED instance owns those rows.
162
+ let disposed = false;
163
+
145
164
  // The DB must exist before identity detection: detectRepo consults the
146
165
  // repo_paths cache when the session directory is gone.
147
166
  const db = new ThatchDB(dbPath);
@@ -161,8 +180,13 @@ export async function createRuntime(input: {
161
180
  const releaseModel = () => sharedModels.release(dbPath);
162
181
 
163
182
  // Extraction buffer, journaled to runtime_state so a v2 plugin reload
164
- // (same process, graph rebuilt) can rehydrate it.
183
+ // (same process, graph rebuilt) can rehydrate it. The journal callback
184
+ // checks the disposed flag FIRST: a finalization delayed past dispose
185
+ // (await points inside the pipeline) must not write through the lazy
186
+ // reopen accessor after the reload - the reloaded instance owns those
187
+ // rows. Same ownership rule as journalChild below.
165
188
  const extraction = new ExtractionPipeline((kind, sessionID, value) => {
189
+ if (disposed) return;
166
190
  if (value === undefined) db.runtimeStateDelete(kind, sessionID);
167
191
  else db.runtimeStatePut(kind, sessionID, value, directory);
168
192
  });
@@ -267,7 +291,13 @@ export async function createRuntime(input: {
267
291
  const watchers = new WatcherRegistry({
268
292
  // Journal watcher definitions so a v2 plugin reload (same process) can
269
293
  // re-arm them; the registry journals after every membership change.
294
+ // Disposed check FIRST: a poll cycle suspended at an await can outlive
295
+ // watchers.dispose() and reach this callback after db.close(); with the
296
+ // lazy reopen it would otherwise hand a stale writer a fresh connection
297
+ // and resurrect an expired watcher after the reload. Same ownership rule
298
+ // as the extraction callback and journalChild.
270
299
  journal: (sessionID, sessionWatchers) => {
300
+ if (disposed) return;
271
301
  if (sessionWatchers.length === 0) db.runtimeStateDelete("watchers", sessionID);
272
302
  else db.runtimeStatePut("watchers", sessionID, sessionWatchers, directory);
273
303
  },
@@ -332,16 +362,27 @@ export async function createRuntime(input: {
332
362
  const chatPoller = new ChatPoller({
333
363
  store: db,
334
364
  hostedSessions: () => {
365
+ const now = Date.now();
335
366
  const hosted = hostedSessionIds({
336
367
  statusKeys: sessionStatus.keys(),
337
368
  resumedSessions: resumedSessions,
338
- rehostedSessions: rehostedSessions,
369
+ rehostedSessions: [...rehostedSessions, ...unexpiredGraceRehosts(rehostGrace, now)],
339
370
  exclude: childToParent.keys(),
340
371
  });
341
- // Journal the set so the NEXT reload (same pid + directory) re-hosts
342
- // these sessions and the poller can wake them with pending mail
343
- // instead of waiting for the user to type.
344
- db.runtimeStatePut("hosted", directory, hosted, directory);
372
+ // The journal NEVER SHRINKS within a process lifetime: a plugin
373
+ // reload rebuilds the hosted set empty (sessionStatus is in-memory),
374
+ // so a session that was idle across a reload would otherwise be
375
+ // dropped from the set and never re-hosted - falsely stale while its
376
+ // tab is open. The union means the next reload re-graces everything
377
+ // this process ever hosted (bounded by the 24h grace decay);
378
+ // shrinking happens only through session.deleted, which rewrites the
379
+ // row.
380
+ const prevRow = db
381
+ .runtimeStateAll()
382
+ .find((r) => r.kind === "hosted" && r.sessionID === directory);
383
+ const prevIds = prevRow && Array.isArray(prevRow.value) ? (prevRow.value as string[]) : [];
384
+ const merged = [...new Set([...prevIds, ...hosted])];
385
+ db.runtimeStatePut("hosted", directory, merged, directory);
345
386
  return hosted;
346
387
  },
347
388
  deliver: async (sessionID, senders, count) => {
@@ -470,6 +511,16 @@ export async function createRuntime(input: {
470
511
  // Populated from session.created events that carry a parentID.
471
512
  const childToParent = new Map<string, string>();
472
513
 
514
+ // Per-child kind ("extraction" | "task"), recorded at session.created from
515
+ // the live extractionChildren set and at restore from the journal record.
516
+ // The parent-state mutations a child's lifecycle triggers (snapshot drain
517
+ // on memory write, whole-set requeue on error/deletion) are only correct
518
+ // for extraction children - a task sub-agent saving its own findings never
519
+ // processed the parent's buffer, and treating its lifecycle as extraction
520
+ // evidence silently dropped in-flight or pending entries (the v2
521
+ // all-children-linked loss vector, September 2026).
522
+ const childKinds = new Map<string, "extraction" | "task">();
523
+
473
524
  // Snapshot of the parent's buffer at the moment each child was dispatched.
474
525
  // When the child writes a memory, consumeSnapshot drains only these entries
475
526
  // (by reference identity), preserving interleaved-turn entries that arrived
@@ -494,9 +545,10 @@ export async function createRuntime(input: {
494
545
  // children from task-dispatched sub-agents (code review specialists, the
495
546
  // nudge-path fact-extractor, any model-dispatched task). Only extraction
496
547
  // children get the full cleanup: buffer drain, session deletion, toast.
497
- // Non-extraction children get the old behavior (completeAccepted +
498
- // missedNudges.reset) so their sessions are not deleted out from under the
499
- // task tool that dispatched them.
548
+ // Non-extraction children get claim-scoped completion (a no-op for a
549
+ // child that never fetched a payload) + missedNudges.reset so their
550
+ // sessions are not deleted out from under the task tool that dispatched
551
+ // them.
500
552
  const extractionChildren = new Set<string>();
501
553
 
502
554
  // Per-child-session extraction metrics for the toast notification. When the
@@ -507,14 +559,21 @@ export async function createRuntime(input: {
507
559
 
508
560
  // Journal the child bookkeeping (parent link, buffer snapshot, metrics) so
509
561
  // a v2 plugin reload can rehydrate an in-flight extraction instead of
510
- // orphaning it. A child with no parent entry journals a delete.
562
+ // orphaning it. A child with no parent entry journals a delete. The kind
563
+ // marker distinguishes extraction children from task-dispatched sub-agents
564
+ // (the live source of truth is the extractionChildren set): restoring a
565
+ // task sub-agent as an extraction child let its idle take the extraction
566
+ // branch - consumeSnapshot silently dropped the parent's buffered entries
567
+ // without extraction, and the task tool's session got a delete call.
511
568
  const journalChild = (childId: string) => {
569
+ if (disposed) return;
512
570
  const parentID = childToParent.get(childId);
513
571
  if (!parentID) {
514
572
  db.runtimeStateDelete("child", childId);
515
573
  return;
516
574
  }
517
575
  db.runtimeStatePut("child", childId, {
576
+ kind: extractionChildren.has(childId) ? "extraction" : "task",
518
577
  parentID,
519
578
  snapshot: parentSnapshots.get(childId) ?? [],
520
579
  metrics: childMetrics.get(childId) ?? { new: 0, updated: 0, deleted: 0 },
@@ -551,6 +610,11 @@ export async function createRuntime(input: {
551
610
  // (same pid + same directory). Written through by the poller's hosted-set
552
611
  // read, so the next reload picks up the latest set.
553
612
  const rehostedSessions = new Set<string>();
613
+ // Foreign-pid hosted rows (restart): the dead daemon's hosted set,
614
+ // re-hosted under a grace window instead of dropped - open TUI tabs
615
+ // reconnect but emit no events until the user types. id -> grace
616
+ // deadline (epoch ms).
617
+ const rehostGrace = new Map<string, number>();
554
618
  const rehydrated: Record<string, number> = {};
555
619
  for (const row of db.runtimeStateAll()) {
556
620
  const samePid = row.pid === process.pid;
@@ -569,6 +633,21 @@ export async function createRuntime(input: {
569
633
  if (row.kind === "watchers" && !dormantWatchersStale((row.value as Watcher[] | null) ?? [])) {
570
634
  continue;
571
635
  }
636
+ // A foreign-pid HOSTED row re-hosts under a grace window instead of
637
+ // being pruned: open TUI tabs reconnect to the new daemon but emit
638
+ // no events until the user types, so dropping the set made them
639
+ // falsely stale ("presumed gone") while alive-idle, and mail to
640
+ // them stopped waking the tab. Sessions that emit activity graduate
641
+ // to permanent hosting via the event stream; still-silent ones
642
+ // (closed tabs) age out at the grace and go stale. The row itself
643
+ // stays: the poller's next hosted-set journal rewrites it, and a
644
+ // further restart re-graces whatever it then holds.
645
+ if (row.kind === "hosted") {
646
+ for (const id of (row.value ?? []) as string[]) {
647
+ rehostGrace.set(id, Date.now() + CHAT_REHOST_GRACE_MS);
648
+ }
649
+ continue;
650
+ }
572
651
  db.runtimeStateDelete(row.kind, row.sessionID);
573
652
  continue;
574
653
  }
@@ -586,26 +665,32 @@ export async function createRuntime(input: {
586
665
  } else if (row.kind === "watchers") {
587
666
  watchers.hydrate((row.value ?? []) as Watcher[]);
588
667
  } else if (row.kind === "hosted") {
589
- // Rehydrate only on a same-process reload. A foreign-pid hosted row
590
- // (restart) would re-host sessions whose harnesses died - dropping
591
- // it is the point: the resumed session re-hosts itself through the
592
- // event stream instead.
593
- if (!samePid) {
594
- db.runtimeStateDelete(row.kind, row.sessionID);
595
- continue;
596
- }
668
+ // Same-pid reload: re-host permanently (the plugin re-ran in place;
669
+ // the sessions may still be running). Foreign-pid rows were graced
670
+ // in the restart branch above.
597
671
  for (const id of (row.value ?? []) as string[]) rehostedSessions.add(id);
598
672
  } else if (row.kind === "child") {
599
- const rec = row.value as { parentID?: string; snapshot?: ToolInteraction[]; metrics?: { new: number; updated: number; deleted: number } };
673
+ const rec = row.value as { kind?: "extraction" | "task"; parentID?: string; snapshot?: ToolInteraction[]; metrics?: { new: number; updated: number; deleted: number } };
600
674
  if (samePid && rec?.parentID) {
601
675
  // Reload: the child may still be running - restore its live
602
- // bookkeeping so its idle event finds the maps populated.
676
+ // bookkeeping so its idle event finds the maps populated. The
677
+ // default covers rows written before the marker existed; it errs
678
+ // toward extraction (defaulting to task would strand a legacy
679
+ // in-flight extraction child with extracting never restored,
680
+ // suppressing both extraction paths for the session's life), at
681
+ // the cost of resurrecting the restore-time misclassification for
682
+ // the rarer pre-upgrade in-flight task child - a one-window trade,
683
+ // self-healing as rows are rewritten with kinds.
684
+ const kind = rec.kind ?? "extraction";
685
+ childKinds.set(row.sessionID, kind);
603
686
  childToParent.set(row.sessionID, rec.parentID);
604
687
  parentSnapshots.set(row.sessionID, rec.snapshot ?? []);
605
- extractionChildren.add(row.sessionID);
606
- extracting.add(rec.parentID);
607
688
  if (rec.metrics) childMetrics.set(row.sessionID, rec.metrics);
608
- restoredChildren.set(row.sessionID, rec.parentID);
689
+ if (kind === "extraction") {
690
+ extractionChildren.add(row.sessionID);
691
+ extracting.add(rec.parentID);
692
+ restoredChildren.set(row.sessionID, rec.parentID);
693
+ }
609
694
  } else {
610
695
  // Restart: the child ran in the dead process and no execution event
611
696
  // will ever arrive for it. Recover the snapshot as plain pending
@@ -755,17 +840,14 @@ export async function createRuntime(input: {
755
840
  // the reload window.
756
841
  const finishExtractionChild = async (sessionID: string, parentID: string) => {
757
842
  if (extractionChildren.has(sessionID)) {
758
- // Drain the parent's snapshot entries from the pending buffer. If the
759
- // child wrote memories, consumeSnapshot already ran in
760
- // tool.execute.after and the snapshot is gone - nothing to drain. If
761
- // the child did a no-save run, the snapshot entries are still in the
762
- // buffer and need to be drained here so they don't replay as a nudge
763
- // on the next chat.message. Never drain the entire buffer -
764
- // interleaved-turn entries must survive.
765
- const snapshot = parentSnapshots.get(sessionID);
766
- if (snapshot) {
767
- extraction.consumeSnapshot(parentID, snapshot);
768
- }
843
+ // The child's claimed delivery is consumed by completeClaimed below -
844
+ // the fetch recorded exactly what the child received, so nothing else
845
+ // is dropped here. Entries the child never received (post-fetch
846
+ // arrivals, or a never-fetched payload) stay pending and re-nudge;
847
+ // the old snapshot drain here would have silently dropped the
848
+ // never-fetched case without extraction. (Snapshot drain removed
849
+ // September 2026 - claim semantics subsume it. The journal's
850
+ // snapshot field remains for restart recovery.)
769
851
 
770
852
  // Fire a toast with the extraction metrics. Only show a toast when
771
853
  // memories were actually written - no toast for no-save runs to avoid
@@ -789,9 +871,14 @@ export async function createRuntime(input: {
789
871
  }
790
872
  }
791
873
 
792
- extraction.completeAccepted(parentID);
874
+ // Scope the completion to THIS child's claimed delivery. A fetcher
875
+ // that never fetched (a sibling sub-agent going idle, an extractor
876
+ // that crashed before its payload call) has no claim and must not
877
+ // drop the accepted set - another extractor may hold it in flight.
878
+ extraction.completeClaimed(sessionID);
793
879
  missedNudges.delete(parentID);
794
880
  extracting.delete(parentID);
881
+ childKinds.delete(sessionID);
795
882
  childToParent.delete(sessionID);
796
883
  parentSnapshots.delete(sessionID);
797
884
  childMetrics.delete(sessionID);
@@ -805,10 +892,13 @@ export async function createRuntime(input: {
805
892
  // Best-effort - the child is idle and harmless if not deleted.
806
893
  }
807
894
  } else {
808
- // Task-dispatched sub-agent went idle. Complete the parent's accepted
809
- // entries (from the nudge-path extraction_done accept) and reset
810
- // missedNudges. Retained for the nudge fallback path.
811
- extraction.completeAccepted(parentID);
895
+ // Task-dispatched sub-agent went idle. Complete only what THIS child
896
+ // claimed via its payload fetch - sibling specialists going idle have
897
+ // no claim and must be a no-op, or their idle signal drops another
898
+ // extractor's in-flight accepted payload. An extractor that never
899
+ // fetched leaves its entries held; requeueStaleAccepted bounds the
900
+ // linger.
901
+ extraction.completeClaimed(sessionID);
812
902
  missedNudges.delete(parentID);
813
903
  }
814
904
  };
@@ -826,6 +916,10 @@ export async function createRuntime(input: {
826
916
  const t = setTimeout(resolve, 10_000);
827
917
  t.unref?.();
828
918
  });
919
+ // Dispose (a v2 plugin reload or process exit) may have closed the db
920
+ // during the 10s window; finalizing into it would throw on the journal
921
+ // write. The reloaded instance's own reconciler owns the rows now.
922
+ if (disposed) return;
829
923
  let live: Record<string, { type: string }> = {};
830
924
  try {
831
925
  live = (await caps.fetchStatuses()) ?? {};
@@ -915,6 +1009,7 @@ export async function createRuntime(input: {
915
1009
  const cleanupChild = () => {
916
1010
  extracting.delete(parentID);
917
1011
  extractionChildren.delete(childId);
1012
+ childKinds.delete(childId);
918
1013
  childToParent.delete(childId);
919
1014
  parentSnapshots.delete(childId);
920
1015
  childMetrics.delete(childId);
@@ -966,6 +1061,10 @@ export async function createRuntime(input: {
966
1061
  const toolMemoryRemember = async (sessionID: string, overwrite: unknown) => {
967
1062
  extraction.consume(sessionID);
968
1063
  missedNudges.delete(sessionID);
1064
+ // A session that fetched its own payload (direct parent-side fetch or a
1065
+ // child's payload call) completes its claim when it writes a memory -
1066
+ // the write proves the delivered entries were processed.
1067
+ extraction.completeClaimed(sessionID);
969
1068
  // Track extraction metrics for toast display. Counted for any
970
1069
  // child session (both direct-extraction and nudge-path sub-agents)
971
1070
  // since childToParent covers both. The toast only fires for
@@ -978,18 +1077,18 @@ export async function createRuntime(input: {
978
1077
  childMetrics.set(sessionID, metrics);
979
1078
  journalChild(sessionID);
980
1079
  // Complete the parent's accepted entries (the extractor confirmed
981
- // it is alive and saving) and drain the parent's snapshot entries
982
- // from the pending buffer. If no snapshot was recorded (unreachable
983
- // when childToParent has the entry, since both are set together in
984
- // session.created), skip the drain rather than dropping the entire
985
- // buffer - interleaved-turn entries that arrived while the child
986
- // was running must survive for the next extraction cycle.
987
- extraction.completeAccepted(parentID);
988
- const snapshot = parentSnapshots.get(sessionID);
989
- if (snapshot) {
990
- extraction.consumeSnapshot(parentID, snapshot);
991
- parentSnapshots.delete(sessionID);
992
- }
1080
+ // it is alive and saving). Completion is scoped to THIS child's
1081
+ // claimed delivery - the fetch IS the delivery record, so whatever
1082
+ // the child received is consumed here and nothing else. Pending
1083
+ // entries the child never received (post-fetch arrivals, or a child
1084
+ // that never fetched) stay pending and re-nudge: no silent loss.
1085
+ // (The old dispatch-time snapshot drain was either a no-op under
1086
+ // claim semantics or a silent-loss vector for children that never
1087
+ // fetched - removed September 2026. The journal's snapshot field
1088
+ // remains for restart recovery.)
1089
+ extraction.completeClaimed(sessionID);
1090
+ journalChild(sessionID);
1091
+ missedNudges.delete(parentID);
993
1092
  journalChild(sessionID);
994
1093
  missedNudges.delete(parentID);
995
1094
  }
@@ -997,7 +1096,14 @@ export async function createRuntime(input: {
997
1096
  const toolExtractionDone = (sessionID: string, targetID?: string) => {
998
1097
  const parentID = childToParent.get(sessionID);
999
1098
  if (parentID) {
1000
- extraction.completeAccepted(parentID);
1099
+ // The child's completion consumes its own claimed delivery. The
1100
+ // whole-set fallback covers a child that acks without ever fetching
1101
+ // (its payload call errored) - rare, and the ack is a deliberate
1102
+ // processed signal, unlike the automatic idle path which is
1103
+ // claim-scoped only.
1104
+ if (!extraction.completeClaimed(sessionID)) {
1105
+ extraction.completeAccepted(parentID);
1106
+ }
1001
1107
  missedNudges.delete(parentID);
1002
1108
  extraction.consume(sessionID);
1003
1109
  } else if (targetID && targetID !== sessionID) {
@@ -1007,8 +1113,13 @@ export async function createRuntime(input: {
1007
1113
  // relationship and the explicit session_id is the only signal that
1008
1114
  // the target's accepted entries are accounted for. Without this
1009
1115
  // branch the accepted queue lingers forever and a tool-dense session
1010
- // re-nudges every idle on never-draining exhaust.
1011
- extraction.completeAccepted(targetID);
1116
+ // re-nudges every idle on never-draining exhaust. Completion is
1117
+ // scoped to the child's claimed delivery; the whole-set fallback
1118
+ // covers the no-claim case (a fetch that errored, or the MCP path
1119
+ // where the file-backed queue was drained instead).
1120
+ if (!extraction.completeClaimed(sessionID)) {
1121
+ extraction.completeAccepted(targetID);
1122
+ }
1012
1123
  missedNudges.delete(targetID);
1013
1124
  } else {
1014
1125
  extraction.accept(sessionID);
@@ -1023,10 +1134,15 @@ export async function createRuntime(input: {
1023
1134
 
1024
1135
  return {
1025
1136
  coreContext: buildCoreContext(db, model, repo, {
1026
- extractionPayloadProvider: (sessionID: string): string | null => {
1027
- const interactions = extraction.peek(sessionID);
1028
- const accepted = extraction.peekAccepted(sessionID);
1029
- const all = [...accepted, ...interactions];
1137
+ extractionPayloadProvider: (targetID: string, fetcherID?: string): string | null => {
1138
+ // A fetcher identity turns the fetch into the delivery record: the
1139
+ // claimed entries stay accepted, and THAT fetcher's completion
1140
+ // signal consumes only them (claim semantics). Without a fetcher
1141
+ // (no host session context) the fetch is a pure peek - legacy
1142
+ // behavior.
1143
+ const all = fetcherID
1144
+ ? extraction.claim(targetID, fetcherID)
1145
+ : [...extraction.peekAccepted(targetID), ...extraction.peek(targetID)];
1030
1146
  if (all.length === 0) return null;
1031
1147
  return extraction.buildPayload(all, repo);
1032
1148
  },
@@ -1076,6 +1192,13 @@ export async function createRuntime(input: {
1076
1192
  // extraction_done call in a child session of that parent (the
1077
1193
  // extractor confirming it processed them), or the child going idle
1078
1194
  // (a no-save run that never acks still counts as processed).
1195
+ // Completion is CLAIM-SCOPED: the pipeline records which fetcher
1196
+ // received the payload (get_extraction_payload), and a completion
1197
+ // signal consumes only that fetcher's delivery. A sibling
1198
+ // sub-agent going idle - a code-review specialist, a slow child
1199
+ // from a previous round - has no claim and is a no-op, so it can
1200
+ // no longer silently drop another extractor's in-flight payload
1201
+ // (the accept-then-racing-completion loss, fixed September 2026).
1079
1202
  // - REQUEUE returns the held entries to pending. Signals: the child
1080
1203
  // session errors or is deleted before completing.
1081
1204
  //
@@ -1219,7 +1342,12 @@ export async function createRuntime(input: {
1219
1342
  if (wrapUp) {
1220
1343
  pendingWrapUp.set(input.sessionID, wrapUp);
1221
1344
  // Journaled so a v2 plugin reload before the session's next idle
1222
- // does not silently drop the armed wrap-up.
1345
+ // does not silently drop the armed wrap-up. Disposed check FIRST:
1346
+ // a command execute still in flight when the reload's cleanup runs
1347
+ // must not land a stale wrap-up row through the lazy reopen (the
1348
+ // reloaded instance owns the journal rows; same rule as the
1349
+ // extraction and watcher journal callbacks).
1350
+ if (disposed) return;
1223
1351
  db.runtimeStatePut("wrapup", input.sessionID, wrapUp, directory);
1224
1352
  }
1225
1353
  },
@@ -1352,6 +1480,23 @@ export async function createRuntime(input: {
1352
1480
  // requeue here so they are honestly re-extracted instead of lingering
1353
1481
  // as silent loss.
1354
1482
  extraction.requeueStaleAccepted();
1483
+ // Nudges skip injected prompts. A background-task completion, chat
1484
+ // notification, or watcher notification arrives as a prompt whose
1485
+ // parts are all synthetic - it is not user input, and nudging on it
1486
+ // feeds the pipeline its own exhaust: an extractor sub-agent's
1487
+ // completion would otherwise fire a fresh extraction nudge for the
1488
+ // entries the handling turn itself queued (the circular-nudge
1489
+ // pattern). Real user messages carry at least one non-synthetic
1490
+ // part, and an empty-parts message keeps nudging (unchanged
1491
+ // behavior).
1492
+ if (output.parts.length > 0 && (output.parts as any[]).every((p) => p.synthetic)) return;
1493
+ // Task-dispatched sub-agent sessions may have restricted tool lists
1494
+ // that exclude the thatch tools, and even where the tools exist the
1495
+ // child is driven by its dispatch prompt, so nudges only burn "No
1496
+ // tool named ..." error rounds or add noise there. Extraction
1497
+ // children are excluded: they run with full tools and are driven by
1498
+ // extractionDirectPrompt.
1499
+ if (childToParent.has(input.sessionID) && !extractionChildren.has(input.sessionID)) return;
1355
1500
  if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
1356
1501
  const batch = extraction.peek(input.sessionID);
1357
1502
  const missed = missedNudges.get(input.sessionID) ?? 0;
@@ -1511,6 +1656,7 @@ export async function createRuntime(input: {
1511
1656
  const info = event.properties.info;
1512
1657
  if (info.parentID) {
1513
1658
  childToParent.set(info.id, info.parentID);
1659
+ childKinds.set(info.id, extractionChildren.has(info.id) ? "extraction" : "task");
1514
1660
  parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
1515
1661
  journalChild(info.id);
1516
1662
  // Child sessions don't need the session-start reminder - only
@@ -1533,8 +1679,17 @@ export async function createRuntime(input: {
1533
1679
  }
1534
1680
  const parentID = childID ? childToParent.get(childID) : undefined;
1535
1681
  if (parentID && childID) {
1536
- extraction.requeueAccepted(parentID);
1682
+ // Requeue what THIS child actually held: its claimed delivery if
1683
+ // it fetched. ERROR is a terminal death signal, so a claim-less
1684
+ // child (died before fetching - boot failures land here) falls
1685
+ // back to whole-set requeue for immediate recovery, regardless
1686
+ // of kind: the worst case is a benign duplicate extraction, never
1687
+ // silent loss.
1688
+ if (!extraction.requeueClaimed(childID)) {
1689
+ extraction.requeueAccepted(parentID);
1690
+ }
1537
1691
  extracting.delete(parentID);
1692
+ childKinds.delete(childID);
1538
1693
  childToParent.delete(childID);
1539
1694
  parentSnapshots.delete(childID);
1540
1695
  childMetrics.delete(childID);
@@ -1647,8 +1802,18 @@ export async function createRuntime(input: {
1647
1802
  // the topic column instead (refreshed on every idle), so the name
1648
1803
  // and the description stay separate identities. Fire-and-forget: a
1649
1804
  // failed registration or title fetch must never block event
1650
- // delivery.
1651
- if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
1805
+ // delivery. TOP-LEVEL ONLY: child sessions (extraction sub-agents,
1806
+ // task sub-agents) are machinery, not chat participants - without
1807
+ // this check every fact-extractor run registered a roster row
1808
+ // (the "thatch-extraction" corpse flood, September 2026). The
1809
+ // prompt-register path above has the same check.
1810
+ if (
1811
+ chatOn &&
1812
+ chatAutoRegister(loadConfig(dbPath).config) &&
1813
+ sessionID &&
1814
+ !childToParent.has(sessionID) &&
1815
+ !db.hasChatLeaveTombstone(sessionID)
1816
+ ) {
1652
1817
  void (async () => {
1653
1818
  try {
1654
1819
  const data = await caps.sessionGet(sessionID);
@@ -1730,13 +1895,41 @@ export async function createRuntime(input: {
1730
1895
  // ChatStore.unregister), and the tombstone is cleared only by an
1731
1896
  // explicit chat_register rejoin, so a genuinely new session is
1732
1897
  // unaffected.
1733
- // A child deleted before completing never processed its payload.
1898
+ // A child deleted before completing never processed its payload -
1899
+ // unless it was a task-kind child: task sub-agents finish and get
1900
+ // deleted ROUTINELY, and a finished task child's later deletion
1901
+ // must not requeue the parent's extraction state (it would yank an
1902
+ // in-flight extractor's accepted set back to pending for duplicate
1903
+ // extraction). Requeue what the child actually held: its claim, or
1904
+ // for a claim-less extraction child the whole accepted set.
1734
1905
  const parentID = childToParent.get(id);
1906
+ const childKind = childKinds.get(id) ?? "extraction";
1735
1907
  if (parentID) {
1736
- extraction.requeueAccepted(parentID);
1737
- extracting.delete(parentID);
1908
+ if (!extraction.requeueClaimed(id) && childKind === "extraction") {
1909
+ extraction.requeueAccepted(parentID);
1910
+ }
1911
+ if (childKind === "extraction") extracting.delete(parentID);
1738
1912
  }
1913
+ extraction.dropClaim(id);
1914
+ childKinds.delete(id);
1739
1915
  childToParent.delete(id);
1916
+ // A deleted session's grace rehost ends immediately - the user
1917
+ // closed it deliberately; the roster must not keep it fresh. The
1918
+ // hosted-row journal is pruned too: the union never shrinks on its
1919
+ // own, and a deleted session must not re-host into the grace on
1920
+ // the next reload.
1921
+ rehostGrace.delete(id);
1922
+ const hostedRow = db
1923
+ .runtimeStateAll()
1924
+ .find((r) => r.kind === "hosted" && r.sessionID === directory);
1925
+ if (hostedRow && Array.isArray(hostedRow.value)) {
1926
+ db.runtimeStatePut(
1927
+ "hosted",
1928
+ directory,
1929
+ (hostedRow.value as string[]).filter((sid) => sid !== id),
1930
+ directory,
1931
+ );
1932
+ }
1740
1933
  parentSnapshots.delete(id);
1741
1934
  childMetrics.delete(id);
1742
1935
  extractionChildren.delete(id);
@@ -1747,6 +1940,7 @@ export async function createRuntime(input: {
1747
1940
  // watchers die with it - the session that would receive their
1748
1941
  // notifications no longer exists.
1749
1942
  extraction.completeAccepted(id);
1943
+ extraction.dropClaim(id);
1750
1944
  extracting.delete(id);
1751
1945
  watchers.cancelSession(id);
1752
1946
  // Leaving the chat directory is the graceful-exit fast path; a
@@ -1801,15 +1995,20 @@ export async function createRuntime(input: {
1801
1995
  },
1802
1996
 
1803
1997
  dispose: async () => {
1998
+ disposed = true;
1804
1999
  stopVersionChecker();
1805
2000
  watchers.dispose();
1806
2001
  chatPoller.dispose();
1807
- await releaseModel();
1808
- // Note: the runtime_state journal is deliberately NOT cleared here.
1809
- // Disposal happens before every v2 plugin reload, and the reloaded
1810
- // setup() rehydrates from it; stale rows from dead processes are
1811
- // pruned by the setup-time partition instead.
1812
- db.close();
2002
+ try {
2003
+ await releaseModel();
2004
+ } finally {
2005
+ // In finally so a releaseModel rejection cannot leak the connection.
2006
+ // Note: the runtime_state journal is deliberately NOT cleared here.
2007
+ // Disposal happens before every v2 plugin reload, and the reloaded
2008
+ // setup() rehydrates from it; stale rows from dead processes are
2009
+ // pruned by the setup-time partition instead.
2010
+ db.close();
2011
+ }
1813
2012
  },
1814
2013
  };
1815
2014
  }
package/src/skills.ts CHANGED
@@ -71,6 +71,13 @@ function loadSharedSkills(): SkillDef[] {
71
71
  "thatch-session-reflection",
72
72
  "thatch-coding-workflow",
73
73
  "thatch-plan-refinement",
74
+ "thatch-refine",
75
+ "thatch-refine-team-app",
76
+ "thatch-refine-personal",
77
+ "thatch-refine-shared-lib",
78
+ "thatch-refine-spike",
79
+ "thatch-refine-infra",
80
+ "thatch-refine-oss-contribution",
74
81
  "thatch-pr-description",
75
82
  "thatch-ticket-description",
76
83
  "thatch-split-overlarge-pr",