@jeffober/thatch 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +8 -1
  2. package/artifacts/skills/thatch-clear-writing.md +103 -5
  3. package/artifacts/skills/thatch-coordination.md +69 -0
  4. package/artifacts/skills/thatch-pr-description.md +21 -13
  5. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  6. package/bin/thatch +95 -44
  7. package/docs/assets/deepwiki-badge.svg +1 -0
  8. package/docs/dev/README.md +7 -2
  9. package/docs/dev/features/cli.md +2 -0
  10. package/docs/dev/features/cross-session-chat.md +118 -11
  11. package/docs/dev/features/notifications.md +1 -2
  12. package/docs/dev/features/opencode-plugin.md +18 -3
  13. package/docs/dev/features/prediction-engine.md +44 -5
  14. package/docs/dev/features/session-tabs.md +155 -0
  15. package/docs/dev/features/setup.md +12 -0
  16. package/docs/dev/features/watchers.md +62 -6
  17. package/docs/dev/gotchas.md +68 -0
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +7 -2
  20. package/docs/dev/skills.md +1 -1
  21. package/docs/plans/generalized-session-heartbeat.md +212 -0
  22. package/docs/plans/prediction-consolidation.md +88 -68
  23. package/docs/user/README.md +12 -3
  24. package/docs/user/cli.md +5 -1
  25. package/docs/user/commands.md +11 -4
  26. package/docs/user/cross-session-chat.md +31 -8
  27. package/docs/user/prediction-engine.md +11 -0
  28. package/docs/user/setup.md +29 -0
  29. package/docs/user/skills.md +1 -1
  30. package/docs/user/watchers.md +22 -7
  31. package/package.json +10 -2
  32. package/src/alerts.ts +1 -1
  33. package/src/chat.ts +320 -27
  34. package/src/commands.ts +14 -0
  35. package/src/db.ts +51 -1
  36. package/src/git.ts +3 -1
  37. package/src/hygiene.ts +5 -0
  38. package/src/mcp.ts +95 -7
  39. package/src/opencode/tui-plugin.ts +70 -0
  40. package/src/opencode/v2.ts +197 -4
  41. package/src/prediction.ts +90 -0
  42. package/src/prompts.ts +110 -23
  43. package/src/runtime.ts +187 -9
  44. package/src/session-db.ts +4 -4
  45. package/src/session-tab-shared.ts +180 -0
  46. package/src/setup.ts +59 -3
  47. package/src/skills.ts +1 -0
  48. package/src/tool-defs.ts +412 -55
  49. package/src/tools.ts +2 -0
  50. package/src/watchers.ts +263 -26
  51. package/docs/plans/intuition-drives.md +0 -147
  52. package/docs/plans/llm-alerts.md +0 -287
  53. package/docs/plans/thatch-actions.md +0 -179
  54. package/docs/plans/worktree-deletion-recovery.md +0 -246
package/src/chat.ts CHANGED
@@ -210,10 +210,53 @@ export function resolveRegisteredPredecessor(
210
210
  export type ChatHostKind = "opencode" | "mcp";
211
211
 
212
212
  // Display names appear inside wake prompts and chat_list output, so they are
213
- // capped tight. Bodies are capped so a runaway sender cannot turn a nudge
214
- // into a context bomb; the reader fetches full content via chat_read anyway.
213
+ // capped tight. MAX_BODY_LEN remains the per-ROW bound: every stored row
214
+ // (a split part, or a body within the part budget) stays under it, so no
215
+ // single inbox row can turn a wake nudge into a context bomb - the WHOLE
216
+ // body is bounded separately by CHAT_MAX_BODY_CHARS.
215
217
  export const MAX_BODY_LEN = 10_000;
216
218
 
219
+ // Same-sender, same-recipient, same-body sends within this window are
220
+ // treated as one logical message: a harness that times out a chat_send that
221
+ // actually committed retries the call, and without this guard the retry
222
+ // lands a second identical row (observed 2026-10-07: two rows 8s apart, one
223
+ // delivery pass). The window is deliberately short and not env-tunable -
224
+ // two identical bodies this close together are a retry; outside it, they
225
+ // are two messages. Read state refines this: a duplicate is suppressed
226
+ // only while the earlier copy is still UNREAD (see #isRecentDuplicate).
227
+ export const CHAT_DEDUPE_WINDOW_MS = 10_000;
228
+
229
+ // Auto-split part budget. A body over the part budget is not refused; it
230
+ // travels as several messages. The DEFAULT is sized to the worst host we
231
+ // have observed: a version-skewed opencode install whose tool-result
232
+ // renderer clipped at exactly 2000 characters with no saved-output pointer,
233
+ // which cut each part off before the model saw it. Current opencode v2
234
+ // (pinned tag v2.0.24) passes 50 KiB + 2000 lines per tool result
235
+ // (packages/core/src/tool-output.ts MAX_LINES/MAX_BYTES, host-configurable
236
+ // via tool_output.max_lines/max_bytes), so hosts on a known-good version
237
+ // can safely raise THATCH_CHAT_SPLIT_PART_LEN.
238
+ //
239
+ // The markers: the FIRST part is prefixed "(message 1 of N)" so the reader
240
+ // knows more is coming and waits before acting, and every part but the last
241
+ // is suffixed "(continued in next message)" - no suffix means the batch is
242
+ // done.
243
+ function envChatInt(name: string, fallback: number, min: number, max: number): number {
244
+ const parsed = Number(process.env[name]);
245
+ if (!Number.isInteger(parsed)) return fallback;
246
+ return Math.min(max, Math.max(min, parsed));
247
+ }
248
+
249
+ export const CHAT_SPLIT_PART_LEN = envChatInt("THATCH_CHAT_SPLIT_PART_LEN", 2000, 500, MAX_BODY_LEN - 1000);
250
+ const SPLIT_CONTINUED = "(continued in next message)";
251
+
252
+ // Acceptance ceiling on the WHOLE body, in characters, checked up front -
253
+ // parts derive from it (ceil(total/partLen)). The grouped chat_read of a
254
+ // split batch renders as ONE tool result, so the ceiling keeps a worst-case
255
+ // batch read (all parts plus framing) comfortably inside the 50 KiB v2
256
+ // envelope while still bounding the context bomb the old 10k refusal used
257
+ // to block. At the 2000-char default part size that is at most 10 parts.
258
+ export const CHAT_MAX_BODY_CHARS = envChatInt("THATCH_CHAT_MAX_BODY_CHARS", 20_000, 1_000, 1_000_000);
259
+
217
260
  // Assigned names are lowercase slugs of a pool name with a numeric counter
218
261
  // suffix ("al-go-rithm-00001"), which satisfies NAME_CHARSET. The charset
219
262
  // stays exported for the pool conformance test. Slugs never contain
@@ -263,6 +306,24 @@ export function isDefaultSessionTitle(title: string): boolean {
263
306
  return /^New session - /.test(title) || /^Child session - /.test(title);
264
307
  }
265
308
 
309
+ // The title of child sessions the plugin itself creates for direct
310
+ // extraction (triggerExtraction). They are machinery, not chat participants:
311
+ // they must never auto-register. The title is the one DURABLE,
312
+ // host-owned signal - in-memory child maps are lost to plugin reloads and
313
+ // daemon restarts, which is exactly how the September/October 2026
314
+ // thatch-extraction corpse rows entered the registry (81 rows by 2026-10-07).
315
+ export const EXTRACTION_CHILD_TITLE = "thatch-extraction";
316
+
317
+ /**
318
+ * Whether a session title belongs to plugin machinery rather than a chat
319
+ * participant. Both auto-registration paths check this (the idle path has
320
+ * the live title in hand); explicit chat_register is unaffected - a model
321
+ * deliberately joining the directory is never machinery.
322
+ */
323
+ export function isMachinerySessionTitle(title: string): boolean {
324
+ return title === EXTRACTION_CHILD_TITLE;
325
+ }
326
+
266
327
  // Poller cadence. A hosting harness beats each of its sessions once per
267
328
  // interval and delivers their mail on the same tick.
268
329
  export const CHAT_POLL_INTERVAL_MS = 30_000;
@@ -380,6 +441,98 @@ export function createWakeGate(deps: {
380
441
  };
381
442
  }
382
443
 
444
+ // ---------------------------------------------------------------------------
445
+ // Body auto-split (shared by send and broadcast)
446
+ // ---------------------------------------------------------------------------
447
+
448
+ /**
449
+ * Splits an oversized body into deliverable parts. Bodies over the part
450
+ * budget travel as several messages instead of being refused, so a caller
451
+ * never has to split by hand. Cuts prefer blank-line paragraph boundaries
452
+ * (a cut at "\n\n" cannot split a line, a word, or a surrogate pair), falling back to
453
+ * a hard cut that backs off a trailing high surrogate so emoji-heavy text
454
+ * never splits mid-character. Every chunk is capped at CHAT_SPLIT_PART_LEN
455
+ * UTF-16 units - the same unit .length counts - which leaves marker headroom
456
+ * inside MAX_BODY_LEN after decoration. A body within the limit comes back
457
+ * as the single, unmodified part. Returns [] for an empty input.
458
+ */
459
+ export function splitChatBody(body: string, partLen: number = CHAT_SPLIT_PART_LEN): string[] {
460
+ const text = body.trim();
461
+ if (text.length <= partLen) return text ? [text] : [];
462
+ const chunks: string[] = [];
463
+ let rest = text;
464
+ while (rest.length > partLen) {
465
+ const cut = nextSplitPoint(rest, partLen);
466
+ const part = rest.slice(0, cut).trim();
467
+ // The cut's own break characters (and any boundary whitespace) are
468
+ // consumed here; the next chunk starts at real content.
469
+ rest = rest.slice(cut).trim();
470
+ if (part) chunks.push(part);
471
+ }
472
+ if (rest) chunks.push(rest);
473
+ const total = chunks.length;
474
+ if (total <= 1) return chunks;
475
+ return chunks.map((chunk, i) => {
476
+ const withPrefix = i === 0 ? `(message 1 of ${total})\n\n${chunk}` : chunk;
477
+ return i < total - 1 ? `${withPrefix}\n\n${SPLIT_CONTINUED}` : withPrefix;
478
+ });
479
+ }
480
+
481
+ // Read-side clip budget. The chat_read drain clips any single message
482
+ // longer than this and stamps a clip marker (which names CHARS - never a
483
+ // bare number) with a deterministic paging pointer; chat_read's message +
484
+ // offset arguments page through the rest. Sized to the same worst-host
485
+ // budget as the split part length.
486
+ export const CHAT_READ_PAGE_LEN = 2000;
487
+
488
+ /** Renders one message body for the drain: full when it fits one read
489
+ * page, else the first page plus a clip marker that names the unit
490
+ * (characters) and the deterministic recovery path. */
491
+ export function chatReadClip(body: string, id: number, page: number = CHAT_READ_PAGE_LEN): string {
492
+ if (body.length <= page) return body;
493
+ return `${body.slice(0, page)}\n[clipped: showing ${page} of ${body.length} characters - continue with chat_read, message ${id}, offset ${page}]`;
494
+ }
495
+
496
+ /** One page of a paged single-message read (chat_read's message/offset
497
+ * arguments). Deterministic recovery: names the character window and
498
+ * either the next offset or "final page". Never stamps read - only the
499
+ * drain marks mail read. */
500
+ export function chatReadPage(
501
+ body: string,
502
+ id: number,
503
+ offset: number,
504
+ page: number = CHAT_READ_PAGE_LEN,
505
+ ): { ok: true; text: string } | { ok: false; error: string } {
506
+ if (offset < 0 || offset >= body.length) {
507
+ return {
508
+ ok: false,
509
+ error: `Offset ${offset} is outside message ${id} (${body.length} characters) - use an offset from 0 to ${Math.max(0, body.length - 1)}.`,
510
+ };
511
+ }
512
+ const end = Math.min(body.length, offset + page);
513
+ const pos =
514
+ end < body.length
515
+ ? `[showing characters ${offset}-${end} of ${body.length} - next page: chat_read, message ${id}, offset ${end}]`
516
+ : `[showing characters ${offset}-${end} of ${body.length} - final page]`;
517
+ return { ok: true, text: `${pos}\n${body.slice(offset, end)}` };
518
+ }
519
+
520
+ /** Where `text` (known to be longer than `partLen`) should be cut
521
+ * for its first chunk: the last blank-line boundary inside the part budget,
522
+ * else a surrogate-safe hard cut at the budget. */
523
+ function nextSplitPoint(text: string, partLen: number): number {
524
+ const paragraph = text.slice(0, partLen).lastIndexOf("\n\n");
525
+ if (paragraph > 0) return paragraph;
526
+ let cut = partLen;
527
+ // A hard cut between the two UTF-16 units of a surrogate pair would
528
+ // corrupt the character; back off one unit so the pair stays whole on
529
+ // the right side.
530
+ const high = text.charCodeAt(cut - 1);
531
+ const low = text.charCodeAt(cut);
532
+ if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) cut--;
533
+ return cut;
534
+ }
535
+
383
536
  /**
384
537
  * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
385
538
  * Database handle; the tables are created by ThatchDB's schema init.
@@ -447,7 +600,7 @@ export class ChatStore {
447
600
  // is somehow taken, fall through to the counter draw.
448
601
  const claim = this.#db
449
602
  .query("SELECT name FROM chat_name_claims WHERE session_id = ?")
450
- .get(sessionID) as { name: string } | undefined;
603
+ .get(sessionID) as { name: string } | null;
451
604
  if (claim) {
452
605
  const reclaimed = this.#insertSession(sessionID, claim.name, project, cleanTopic, kind, worktree);
453
606
  if (reclaimed.ok) {
@@ -518,6 +671,24 @@ export class ChatStore {
518
671
  this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
519
672
  }
520
673
 
674
+ /**
675
+ * Marks a plugin-created session as machinery (reason names the creator,
676
+ * e.g. "extraction"). Unlike the in-memory child maps, the marker survives
677
+ * plugin reloads and daemon restarts, so the auto-registration paths can
678
+ * refuse the session even when the maps were lost. Markers age out with
679
+ * the auto-row TTL in pruneStaleAuto - session ids are never reused, so an
680
+ * expired marker cannot resurrect a row for a live session.
681
+ */
682
+ markMachinery(sessionID: string, reason: string): void {
683
+ this.#db.run("INSERT OR IGNORE INTO chat_machinery (session_id, reason) VALUES (?, ?)", [sessionID, reason]);
684
+ }
685
+
686
+ /** Whether the session was marked machinery (see markMachinery). */
687
+ isMachinery(sessionID: string): boolean {
688
+ const row = this.#db.query("SELECT 1 FROM chat_machinery WHERE session_id = ?").get(sessionID);
689
+ return row !== null && row !== undefined;
690
+ }
691
+
521
692
  /**
522
693
  * Whether the leave tombstone for `sessionID` has been superseded: a
523
694
  * newer registration exists in the same project (registered after the
@@ -593,6 +764,11 @@ export class ChatStore {
593
764
  // session), but the sweep keeps the table from growing monotonically
594
765
  // forever.
595
766
  this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
767
+ // Machinery markers age out on the same TTL. They only need to outlive
768
+ // the marked session's possible events; ids are never reused, so an
769
+ // expired marker cannot let a row come back for a live session (the
770
+ // title guard in the registration paths covers the residue).
771
+ this.#db.run("DELETE FROM chat_machinery WHERE created_at < ?", [cutoff]);
596
772
  return result.changes;
597
773
  }
598
774
 
@@ -617,17 +793,20 @@ export class ChatStore {
617
793
  * sender nudging itself). Returns the resolved recipient so callers never
618
794
  * need a second lookup - a re-lookup could race a concurrent unregister
619
795
  * and fail after the message already landed.
796
+ *
797
+ * An oversized body is auto-split into parts (splitChatBody) delivered as
798
+ * several rows in ONE transaction, so a crash cannot leave a partial
799
+ * batch in the reader's inbox. `parts` reports how many rows the send
800
+ * produced (or would have produced, when deduped), and the tool layer
801
+ * surfaces it so a caller cannot mistake a split for a single delivery.
620
802
  */
621
803
  send(
622
804
  fromSession: string,
623
805
  toNameOrID: string,
624
806
  body: string,
625
- ): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
807
+ ): { ok: true; recipient: { session_id: string; name: string }; parts: number; deduped?: boolean } | { ok: false; error: string } {
626
808
  const trimmed = body.trim();
627
- if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
628
- if (trimmed.length > MAX_BODY_LEN) {
629
- return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
630
- }
809
+ if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
631
810
  const sender = this.#find(fromSession);
632
811
  if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
633
812
  const recipient = this.find(toNameOrID);
@@ -637,34 +816,78 @@ export class ChatStore {
637
816
  if (recipient.session_id === fromSession) {
638
817
  return { ok: false, error: "You cannot message yourself." };
639
818
  }
640
- this.#db.run(
641
- "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
642
- [fromSession, recipient.session_id, trimmed, nowIso()],
643
- );
644
- return { ok: true, recipient: { session_id: recipient.session_id, name: recipient.name } };
819
+ // Acceptance ceiling, checked before any split: the whole body is
820
+ // capped in characters so a runaway sender cannot flood the inbox with
821
+ // parts. The attempted count rides the error so a caller can split
822
+ // deterministically instead of bisecting.
823
+ if (trimmed.length > CHAT_MAX_BODY_CHARS) {
824
+ return {
825
+ ok: false,
826
+ error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
827
+ };
828
+ }
829
+ const parts = splitChatBody(trimmed);
830
+ let deduped = false;
831
+ // One transaction: the whole batch lands or none of it - a crash
832
+ // mid-insert cannot deliver a partial message.
833
+ this.#db.transaction(() => {
834
+ // The batch's signature is its rows: the transactional insert makes
835
+ // the parts all-or-nothing, and the dedupe condition is READ STATE -
836
+ // any UNREAD part from a prior identical batch suppresses the retry
837
+ // (idempotent even while the recipient is mid-read), while a fully
838
+ // read batch lets an intentional repeat deliver. Checking parts
839
+ // rather than only the first also keeps two identical chunks inside
840
+ // one body from suppressing each other.
841
+ if (this.#isRecentDuplicate(fromSession, recipient.session_id, parts, 0)) {
842
+ deduped = true;
843
+ return;
844
+ }
845
+ for (const part of parts) {
846
+ this.#db.run(
847
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
848
+ [fromSession, recipient.session_id, part, nowIso()],
849
+ );
850
+ }
851
+ })();
852
+ return {
853
+ ok: true,
854
+ recipient: { session_id: recipient.session_id, name: recipient.name },
855
+ parts: parts.length,
856
+ ...(deduped ? { deduped: true } : {}),
857
+ };
645
858
  }
646
859
 
647
860
  /**
648
861
  * Posts a message to every other registered session at once. Stale
649
862
  * sessions are skipped, not messaged - a host process that has stopped
650
863
  * heartbeat-ing will never read the mail, and a broadcast is for reaching
651
- * live agents. Each recipient gets its own inbox row, so the existing
652
- * wake machinery (grouping, gating, rate cap) treats the broadcast as
653
- * ordinary per-recipient mail.
864
+ * live agents. Each recipient gets its own inbox row per part, so the
865
+ * existing wake machinery (grouping, gating, rate cap) treats the
866
+ * broadcast as ordinary per-recipient mail. An oversized body is
867
+ * auto-split (splitChatBody) and every recipient gets every part inside
868
+ * the same single transaction; `parts` reports how many rows each
869
+ * recipient received.
654
870
  */
655
871
  broadcast(
656
872
  fromSession: string,
657
873
  body: string,
658
- ): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
874
+ ): { ok: true; recipients: string[]; skipped: string[]; deduped: number; parts: number } | { ok: false; error: string } {
659
875
  const trimmed = body.trim();
660
- if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
661
- if (trimmed.length > MAX_BODY_LEN) {
662
- return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
663
- }
876
+ if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
664
877
  const sender = this.#find(fromSession);
665
878
  if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
879
+ // Same acceptance ceiling as send() - a broadcast fans out to every
880
+ // live session, so the cap matters even more here.
881
+ if (trimmed.length > CHAT_MAX_BODY_CHARS) {
882
+ return {
883
+ ok: false,
884
+ error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
885
+ };
886
+ }
887
+ const parts = splitChatBody(trimmed);
666
888
  const recipients: string[] = [];
667
889
  const skipped: string[] = [];
890
+ let deduped = 0;
668
891
  // One transaction: a crash mid-loop rolls the whole fan-out back
669
892
  // instead of leaving a partial broadcast where some recipients got the
670
893
  // message and others silently did not.
@@ -678,14 +901,57 @@ export class ChatStore {
678
901
  skipped.push(row.name);
679
902
  continue;
680
903
  }
681
- this.#db.run(
682
- "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
683
- [fromSession, row.session_id, trimmed, nowIso()],
684
- );
904
+ // A retried broadcast counts as delivered (the original batch
905
+ // stands) - the recipient list stays complete either way. Same
906
+ // read-state rule as send(): any unread part from the prior batch
907
+ // suppresses the retry for THIS recipient; a fully read batch
908
+ // delivers again.
909
+ if (this.#isRecentDuplicate(fromSession, row.session_id, parts, 1)) {
910
+ deduped++;
911
+ recipients.push(row.name);
912
+ continue;
913
+ }
914
+ for (const part of parts) {
915
+ this.#db.run(
916
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
917
+ [fromSession, row.session_id, part, nowIso()],
918
+ );
919
+ }
685
920
  recipients.push(row.name);
686
921
  }
687
922
  })();
688
- return { ok: true, recipients, skipped };
923
+ return { ok: true, recipients, skipped, deduped, parts: parts.length };
924
+ }
925
+
926
+ /**
927
+ * True when an UNREAD row from a prior send still exists inside the
928
+ * dedupe window - the signature of a client-side retry of a send that
929
+ * actually committed. The condition is READ STATE, not just recency:
930
+ * identical content is a duplicate only while the recipient has not
931
+ * consumed it (the mail is there; a retry adds nothing). Once every
932
+ * matching row was read, the same body delivers again - presumptively an
933
+ * intentional resend after the first was consumed ("ok" said twice on
934
+ * purpose). For a split batch, `bodies` is every part and ANY unread
935
+ * part suppresses the retry: idempotent even while the recipient is
936
+ * mid-read (the poller keeps nudging the unread parts), and only a fully
937
+ * read batch lets an identical repeat through. Timestamps are
938
+ * second-resolution ISO (nowIso's format), so string comparison works.
939
+ */
940
+ #isRecentDuplicate(fromSession: string, toSession: string, bodies: string[], viaBroadcast: 0 | 1): boolean {
941
+ const cutoff = new Date(Date.now() - CHAT_DEDUPE_WINDOW_MS).toISOString().replace(/\.\d{3}Z$/, "Z");
942
+ const marks = bodies.map(() => "?").join(",");
943
+ // bun:sqlite's .get() returns null (not undefined) when no row matches -
944
+ // a truthiness check, not an identity check, or every send dedupes.
945
+ return (
946
+ this.#db
947
+ .query(
948
+ `SELECT 1 AS hit FROM chat_messages
949
+ WHERE from_session = ? AND to_session = ? AND via_broadcast = ?
950
+ AND body IN (${marks}) AND read_at IS NULL AND created_at >= ?
951
+ LIMIT 1`,
952
+ )
953
+ .get(fromSession, toSession, viaBroadcast, ...bodies, cutoff) != null
954
+ );
689
955
  }
690
956
 
691
957
  /**
@@ -728,6 +994,33 @@ export class ChatStore {
728
994
  return row.n;
729
995
  }
730
996
 
997
+ /**
998
+ * One message from the caller's mailbox by row id - the paging path
999
+ * behind chat_read's message/offset arguments. Scoped to the caller:
1000
+ * a session cannot page another session's mail. Deliberately NO read
1001
+ * stamp and no unread filter - paging works on read or unread rows and
1002
+ * is side-effect-free; only the drain marks mail read.
1003
+ */
1004
+ messageById(sessionID: string, id: number): ChatInboxItem | null {
1005
+ const r = this.#db
1006
+ .query(
1007
+ `SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
1008
+ FROM chat_messages m
1009
+ LEFT JOIN chat_sessions s ON s.session_id = m.from_session
1010
+ WHERE m.id = ? AND m.to_session = ?`,
1011
+ )
1012
+ .get(id, sessionID) as any;
1013
+ return r
1014
+ ? {
1015
+ id: r.id,
1016
+ from_session: r.from_session,
1017
+ from_name: r.from_name ?? null,
1018
+ body: r.body,
1019
+ created_at: r.created_at,
1020
+ }
1021
+ : null;
1022
+ }
1023
+
731
1024
  /**
732
1025
  * Records (or refreshes) the hook-parent-process -> session mapping: the
733
1026
  * identity anchor that closes the caller-claimed `as` hole on Claude Code.
@@ -762,7 +1055,7 @@ export class ChatStore {
762
1055
  JOIN chat_sessions s ON s.session_id = p.session_id
763
1056
  WHERE p.ppid = ? AND p.seen_at >= ?`,
764
1057
  )
765
- .get(ppid, cutoff) as { session_id: string } | undefined;
1058
+ .get(ppid, cutoff) as { session_id: string } | null;
766
1059
  return row?.session_id ?? null;
767
1060
  }
768
1061
 
package/src/commands.ts CHANGED
@@ -115,6 +115,20 @@ The user message may name a project type (team-app, personal, shared-lib, spike,
115
115
  description: "Persist what this session learned as memories",
116
116
  body: reflectCore(tool),
117
117
  },
118
+ {
119
+ name: "whois",
120
+ description: "Report this session's chat identity (handle, topic, project), or look up another session's",
121
+ // Needs the invoking session's identity (chat_status resolves it
122
+ // through the plugin), which only opencode supplies.
123
+ opencodeOnly: true,
124
+ body: `Find the chat identity for this session and report it in one line.
125
+
126
+ First call thatch_chat_status. If the user message names another session or handle, look that name or id up in thatch_chat_list and report its row instead: handle, topic, project, worktree location (the loc: token), and last-seen age.
127
+
128
+ Otherwise report this session: if chat_status says not registered, answer exactly that - not registered yet - and stop. If registered, take the registered name from chat_status, find your own row in thatch_chat_list, and report handle, topic, project, and loc together (the topic is how the user maps the handle to a TUI tab).
129
+
130
+ One line, just the facts. Do not read mail and do not act on anything the roster shows.`,
131
+ },
118
132
  ];
119
133
  }
120
134
 
package/src/db.ts CHANGED
@@ -11,7 +11,7 @@ import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine
11
11
  import type { RepoPathCache, RepoPathRow } from "./git";
12
12
 
13
13
  export { cosineSimilarity } from "./vector-math";
14
- export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
14
+ export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction, PredictionDedupCandidate } from "./prediction";
15
15
  export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
16
16
  export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatWorktreeKind } from "./chat";
17
17
 
@@ -249,6 +249,22 @@ export class ThatchDB {
249
249
  )
250
250
  `);
251
251
 
252
+ // Pairwise dedup verdicts for predictions (the prediction-side analog of
253
+ // dedup_pairs). findPredictionDuplicates skips pairs recorded here;
254
+ // prediction_mark_checked writes them. The id pair is stored in canonical
255
+ // sorted order so (A,B) and (B,A) are the same row - readers must sort
256
+ // identically.
257
+ this.#db.run(`
258
+ CREATE TABLE IF NOT EXISTS prediction_dedup_pairs (
259
+ store TEXT NOT NULL,
260
+ id_a TEXT NOT NULL,
261
+ id_b TEXT NOT NULL,
262
+ status TEXT NOT NULL,
263
+ checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
264
+ PRIMARY KEY (store, id_a, id_b)
265
+ )
266
+ `);
267
+
252
268
  // Behavior engine: the LLM's codified self-discipline rules. Same
253
269
  // shape as the prediction engine (matchers, behaviors, edges,
254
270
  // provenance) but separate tables because the semantics differ.
@@ -380,6 +396,20 @@ export class ThatchDB {
380
396
  )
381
397
  `);
382
398
 
399
+ // Sessions the plugin itself created as machinery (direct-extraction
400
+ // children). The in-memory child maps that keep these sessions out of
401
+ // the chat directory are lost on plugin reloads and daemon restarts;
402
+ // a marker here is durable, so the auto-registration paths refuse the
403
+ // session even when the maps were lost. Markers age out with the
404
+ // auto-row TTL (session ids are never reused).
405
+ this.#db.run(`
406
+ CREATE TABLE IF NOT EXISTS chat_machinery (
407
+ session_id TEXT PRIMARY KEY,
408
+ reason TEXT NOT NULL,
409
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
410
+ )
411
+ `);
412
+
383
413
  // Hook-parent-process -> session mapping (Claude Code identity anchor):
384
414
  // hooks learn the true session id from the host's hook payload and run
385
415
  // as children of the same process that spawned the MCP server, so the
@@ -907,6 +937,14 @@ export class ThatchDB {
907
937
  return this.#predictions.scorePredictionNudge(stores, embedding, threshold, limit);
908
938
  }
909
939
 
940
+ findPredictionDuplicates(store: string, threshold = 0.70) {
941
+ return this.#predictions.findPredictionDuplicates(store, threshold);
942
+ }
943
+
944
+ markPredictionPairChecked(store: string, idA: string, idB: string, status: string): void {
945
+ this.#predictions.markPairChecked(store, idA, idB, status);
946
+ }
947
+
910
948
  findNearestMatcher(store: string, embedding: Float32Array, threshold: number) {
911
949
  return this.#predictions.findNearestMatcher(store, embedding, threshold);
912
950
  }
@@ -1035,6 +1073,14 @@ export class ThatchDB {
1035
1073
  return this.#chat.clearLeaveTombstone(sessionID);
1036
1074
  }
1037
1075
 
1076
+ markChatMachinery(sessionID: string, reason: string) {
1077
+ return this.#chat.markMachinery(sessionID, reason);
1078
+ }
1079
+
1080
+ isChatMachinery(sessionID: string) {
1081
+ return this.#chat.isMachinery(sessionID);
1082
+ }
1083
+
1038
1084
  chatLeaveSuperseded(sessionID: string, project: string | null) {
1039
1085
  return this.#chat.leaveSuperseded(sessionID, project);
1040
1086
  }
@@ -1120,6 +1166,10 @@ export class ThatchDB {
1120
1166
  return this.#chat.read(sessionID);
1121
1167
  }
1122
1168
 
1169
+ chatMessageById(sessionID: string, id: number) {
1170
+ return this.#chat.messageById(sessionID, id);
1171
+ }
1172
+
1123
1173
  unreadChatCount(sessionID: string) {
1124
1174
  return this.#chat.unreadCount(sessionID);
1125
1175
  }
package/src/git.ts CHANGED
@@ -102,7 +102,9 @@ function canonicalWorktreeKeys(dir: string): string[] {
102
102
  return [...new Set(keys)];
103
103
  }
104
104
 
105
- function pathExists(dir: string): boolean {
105
+ /** Existence check shared by git helpers and tool arg validation (the
106
+ * session-tab tool validates its directory/worktree args with it). */
107
+ export function pathExists(dir: string): boolean {
106
108
  try {
107
109
  statSync(dir);
108
110
  return true;
package/src/hygiene.ts CHANGED
@@ -24,6 +24,11 @@ export async function hygieneReport(
24
24
  parts.push(`${dupes} duplicate-candidate pair${dupes === 1 ? "" : "s"} pending review`);
25
25
  }
26
26
 
27
+ const predDupes = db.findPredictionDuplicates(repo).length;
28
+ if (predDupes > 0) {
29
+ parts.push(`${predDupes} prediction duplicate pair${predDupes === 1 ? "" : "s"} pending review`);
30
+ }
31
+
27
32
  const cutoff = new Date(Date.now() - STALE_DAYS * 86_400_000).toISOString();
28
33
  const stale = db.staleEntryCount(repo, cutoff);
29
34
  if (stale > 0) {