@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.
Files changed (76) hide show
  1. package/README.md +56 -26
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +103 -5
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +16 -8
  6. package/artifacts/skills/thatch-coordination.md +69 -0
  7. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  8. package/artifacts/skills/thatch-pr-description.md +21 -13
  9. package/artifacts/skills/thatch-refine-personal.md +5 -0
  10. package/artifacts/skills/thatch-refine.md +7 -1
  11. package/artifacts/skills/thatch-review-context.md +55 -2
  12. package/artifacts/skills/thatch-review-followup.md +4 -4
  13. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  14. package/artifacts/skills/thatch-review-response.md +1 -1
  15. package/artifacts/skills/thatch-review-synthesizer.md +21 -1
  16. package/bin/thatch +48 -15
  17. package/docs/assets/deepwiki-badge.svg +1 -0
  18. package/docs/dev/README.md +18 -10
  19. package/docs/dev/features/README.md +1 -1
  20. package/docs/dev/features/behavior-engine.md +10 -0
  21. package/docs/dev/features/cli.md +4 -1
  22. package/docs/dev/features/commands.md +40 -9
  23. package/docs/dev/features/cross-session-chat.md +194 -23
  24. package/docs/dev/features/database.md +4 -4
  25. package/docs/dev/features/notifications.md +1 -2
  26. package/docs/dev/features/nudge-pipeline.md +14 -0
  27. package/docs/dev/features/opencode-plugin.md +56 -15
  28. package/docs/dev/features/prediction-engine.md +71 -6
  29. package/docs/dev/features/qa-system.md +19 -3
  30. package/docs/dev/features/session-lifecycle.md +6 -6
  31. package/docs/dev/features/session-tabs.md +161 -0
  32. package/docs/dev/features/setup.md +5 -0
  33. package/docs/dev/features/watchers.md +62 -6
  34. package/docs/dev/gotchas.md +89 -0
  35. package/docs/dev/mcp-parity.md +5 -5
  36. package/docs/dev/setup-and-hooks.md +7 -2
  37. package/docs/dev/skills.md +12 -9
  38. package/docs/plans/generalized-session-heartbeat.md +212 -0
  39. package/docs/plans/prediction-consolidation.md +88 -68
  40. package/docs/user/README.md +29 -13
  41. package/docs/user/behavior-engine.md +7 -1
  42. package/docs/user/cli.md +5 -1
  43. package/docs/user/code-review.md +43 -2
  44. package/docs/user/commands.md +37 -10
  45. package/docs/user/cross-session-chat.md +76 -26
  46. package/docs/user/prediction-engine.md +23 -1
  47. package/docs/user/setup.md +16 -0
  48. package/docs/user/skills.md +9 -7
  49. package/docs/user/watchers.md +22 -7
  50. package/package.json +10 -2
  51. package/src/alerts.ts +1 -1
  52. package/src/capabilities.ts +20 -6
  53. package/src/chat.ts +358 -43
  54. package/src/commands.ts +123 -18
  55. package/src/db.ts +74 -3
  56. package/src/git.ts +3 -1
  57. package/src/hygiene.ts +5 -0
  58. package/src/mcp.ts +101 -9
  59. package/src/opencode/tui-plugin.ts +90 -0
  60. package/src/opencode/v2.ts +440 -66
  61. package/src/prediction.ts +90 -0
  62. package/src/prompts.ts +164 -29
  63. package/src/runtime.ts +298 -15
  64. package/src/scoring-engine.ts +40 -9
  65. package/src/session-db.ts +4 -4
  66. package/src/session-tab-shared.ts +231 -0
  67. package/src/setup.ts +9 -3
  68. package/src/skills.ts +6 -9
  69. package/src/tab-state.ts +125 -0
  70. package/src/tool-defs.ts +454 -76
  71. package/src/tools.ts +2 -0
  72. package/src/watchers.ts +263 -26
  73. package/docs/plans/intuition-drives.md +0 -147
  74. package/docs/plans/llm-alerts.md +0 -287
  75. package/docs/plans/thatch-actions.md +0 -179
  76. 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;
@@ -290,18 +351,28 @@ export function humanAge(iso: string, now = Date.now()): string {
290
351
  return `${Math.floor(minutes / (24 * 60))}d ago`;
291
352
  }
292
353
 
293
- /** The liveness verdict for one roster row, from heartbeat age alone:
294
- * - "fresh"/"stale" for opencode rows (poller-driven heartbeat; stale means * the hosting harness has missed two beats and is presumed gone)
295
- * - "active"/"idle" for MCP rows (turn-driven; idle between prompts is the
296
- * NORMAL state, so an idle MCP row is reachable, not dead)
297
- * Heartbeat age is the only signal on purpose. A process id cannot serve:
298
- * the same session is re-hosted by a new process on every `-s` resume, so
299
- * a pid stamped on the row lies as soon as a different harness beats it.
300
- * One clock, one rule. */
301
- export function chatLiveness(row: ChatSessionRow, now = Date.now()): "fresh" | "stale" | "active" | "idle" {
354
+ /** The liveness verdict for one roster row, from heartbeat age plus (for
355
+ * opencode rows) the open-tab set:
356
+ * - "open" - fresh heartbeat AND a tab is open now (session presence)
357
+ * - "recent" - fresh heartbeat, no known tab: an instance that saw this
358
+ * session is alive and beating it, but the session itself may or may
359
+ * not be wakeable (a closed tab's wake can be swallowed; a daemon-
360
+ * resident subordinate runs headless and may still pick up mail)
361
+ * - "stale" - the hosting harness has missed two beats and is presumed
362
+ * gone
363
+ * - "active"/"idle" for MCP rows (turn-driven; idle between prompts is
364
+ * the NORMAL state, so an idle MCP row is reachable, not dead)
365
+ * Heartbeat age alone decides staleness - a process id cannot serve: the
366
+ * same session is re-hosted by a new process on every `-s` resume, so a
367
+ * pid stamped on the row lies as soon as a different harness beats it.
368
+ * The tab set is a READ-ONLY refinement (src/tab-state.ts) consumed at
369
+ * render/broadcast time; omitting it degrades every fresh opencode row
370
+ * to "recent" - honest, never a false "open". */
371
+ export function chatLiveness(row: ChatSessionRow, now = Date.now(), openTabs?: ReadonlySet<string>): "open" | "recent" | "stale" | "active" | "idle" {
302
372
  const stale = isStale(row, now);
303
373
  if (row.host_kind === "mcp") return stale ? "idle" : "active";
304
- return stale ? "stale" : "fresh";
374
+ if (stale) return "stale";
375
+ return openTabs?.has(row.session_id) ? "open" : "recent";
305
376
  }
306
377
 
307
378
  /** Orders roster rows for display: project first (so a shared-tree reader
@@ -380,6 +451,98 @@ export function createWakeGate(deps: {
380
451
  };
381
452
  }
382
453
 
454
+ // ---------------------------------------------------------------------------
455
+ // Body auto-split (shared by send and broadcast)
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /**
459
+ * Splits an oversized body into deliverable parts. Bodies over the part
460
+ * budget travel as several messages instead of being refused, so a caller
461
+ * never has to split by hand. Cuts prefer blank-line paragraph boundaries
462
+ * (a cut at "\n\n" cannot split a line, a word, or a surrogate pair), falling back to
463
+ * a hard cut that backs off a trailing high surrogate so emoji-heavy text
464
+ * never splits mid-character. Every chunk is capped at CHAT_SPLIT_PART_LEN
465
+ * UTF-16 units - the same unit .length counts - which leaves marker headroom
466
+ * inside MAX_BODY_LEN after decoration. A body within the limit comes back
467
+ * as the single, unmodified part. Returns [] for an empty input.
468
+ */
469
+ export function splitChatBody(body: string, partLen: number = CHAT_SPLIT_PART_LEN): string[] {
470
+ const text = body.trim();
471
+ if (text.length <= partLen) return text ? [text] : [];
472
+ const chunks: string[] = [];
473
+ let rest = text;
474
+ while (rest.length > partLen) {
475
+ const cut = nextSplitPoint(rest, partLen);
476
+ const part = rest.slice(0, cut).trim();
477
+ // The cut's own break characters (and any boundary whitespace) are
478
+ // consumed here; the next chunk starts at real content.
479
+ rest = rest.slice(cut).trim();
480
+ if (part) chunks.push(part);
481
+ }
482
+ if (rest) chunks.push(rest);
483
+ const total = chunks.length;
484
+ if (total <= 1) return chunks;
485
+ return chunks.map((chunk, i) => {
486
+ const withPrefix = i === 0 ? `(message 1 of ${total})\n\n${chunk}` : chunk;
487
+ return i < total - 1 ? `${withPrefix}\n\n${SPLIT_CONTINUED}` : withPrefix;
488
+ });
489
+ }
490
+
491
+ // Read-side clip budget. The chat_read drain clips any single message
492
+ // longer than this and stamps a clip marker (which names CHARS - never a
493
+ // bare number) with a deterministic paging pointer; chat_read's message +
494
+ // offset arguments page through the rest. Sized to the same worst-host
495
+ // budget as the split part length.
496
+ export const CHAT_READ_PAGE_LEN = 2000;
497
+
498
+ /** Renders one message body for the drain: full when it fits one read
499
+ * page, else the first page plus a clip marker that names the unit
500
+ * (characters) and the deterministic recovery path. */
501
+ export function chatReadClip(body: string, id: number, page: number = CHAT_READ_PAGE_LEN): string {
502
+ if (body.length <= page) return body;
503
+ return `${body.slice(0, page)}\n[clipped: showing ${page} of ${body.length} characters - continue with chat_read, message ${id}, offset ${page}]`;
504
+ }
505
+
506
+ /** One page of a paged single-message read (chat_read's message/offset
507
+ * arguments). Deterministic recovery: names the character window and
508
+ * either the next offset or "final page". Never stamps read - only the
509
+ * drain marks mail read. */
510
+ export function chatReadPage(
511
+ body: string,
512
+ id: number,
513
+ offset: number,
514
+ page: number = CHAT_READ_PAGE_LEN,
515
+ ): { ok: true; text: string } | { ok: false; error: string } {
516
+ if (offset < 0 || offset >= body.length) {
517
+ return {
518
+ ok: false,
519
+ error: `Offset ${offset} is outside message ${id} (${body.length} characters) - use an offset from 0 to ${Math.max(0, body.length - 1)}.`,
520
+ };
521
+ }
522
+ const end = Math.min(body.length, offset + page);
523
+ const pos =
524
+ end < body.length
525
+ ? `[showing characters ${offset}-${end} of ${body.length} - next page: chat_read, message ${id}, offset ${end}]`
526
+ : `[showing characters ${offset}-${end} of ${body.length} - final page]`;
527
+ return { ok: true, text: `${pos}\n${body.slice(offset, end)}` };
528
+ }
529
+
530
+ /** Where `text` (known to be longer than `partLen`) should be cut
531
+ * for its first chunk: the last blank-line boundary inside the part budget,
532
+ * else a surrogate-safe hard cut at the budget. */
533
+ function nextSplitPoint(text: string, partLen: number): number {
534
+ const paragraph = text.slice(0, partLen).lastIndexOf("\n\n");
535
+ if (paragraph > 0) return paragraph;
536
+ let cut = partLen;
537
+ // A hard cut between the two UTF-16 units of a surrogate pair would
538
+ // corrupt the character; back off one unit so the pair stays whole on
539
+ // the right side.
540
+ const high = text.charCodeAt(cut - 1);
541
+ const low = text.charCodeAt(cut);
542
+ if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) cut--;
543
+ return cut;
544
+ }
545
+
383
546
  /**
384
547
  * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
385
548
  * Database handle; the tables are created by ThatchDB's schema init.
@@ -447,7 +610,7 @@ export class ChatStore {
447
610
  // is somehow taken, fall through to the counter draw.
448
611
  const claim = this.#db
449
612
  .query("SELECT name FROM chat_name_claims WHERE session_id = ?")
450
- .get(sessionID) as { name: string } | undefined;
613
+ .get(sessionID) as { name: string } | null;
451
614
  if (claim) {
452
615
  const reclaimed = this.#insertSession(sessionID, claim.name, project, cleanTopic, kind, worktree);
453
616
  if (reclaimed.ok) {
@@ -518,6 +681,24 @@ export class ChatStore {
518
681
  this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
519
682
  }
520
683
 
684
+ /**
685
+ * Marks a plugin-created session as machinery (reason names the creator,
686
+ * e.g. "extraction"). Unlike the in-memory child maps, the marker survives
687
+ * plugin reloads and daemon restarts, so the auto-registration paths can
688
+ * refuse the session even when the maps were lost. Markers age out with
689
+ * the auto-row TTL in pruneStaleAuto - session ids are never reused, so an
690
+ * expired marker cannot resurrect a row for a live session.
691
+ */
692
+ markMachinery(sessionID: string, reason: string): void {
693
+ this.#db.run("INSERT OR IGNORE INTO chat_machinery (session_id, reason) VALUES (?, ?)", [sessionID, reason]);
694
+ }
695
+
696
+ /** Whether the session was marked machinery (see markMachinery). */
697
+ isMachinery(sessionID: string): boolean {
698
+ const row = this.#db.query("SELECT 1 FROM chat_machinery WHERE session_id = ?").get(sessionID);
699
+ return row !== null && row !== undefined;
700
+ }
701
+
521
702
  /**
522
703
  * Whether the leave tombstone for `sessionID` has been superseded: a
523
704
  * newer registration exists in the same project (registered after the
@@ -593,6 +774,11 @@ export class ChatStore {
593
774
  // session), but the sweep keeps the table from growing monotonically
594
775
  // forever.
595
776
  this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
777
+ // Machinery markers age out on the same TTL. They only need to outlive
778
+ // the marked session's possible events; ids are never reused, so an
779
+ // expired marker cannot let a row come back for a live session (the
780
+ // title guard in the registration paths covers the residue).
781
+ this.#db.run("DELETE FROM chat_machinery WHERE created_at < ?", [cutoff]);
596
782
  return result.changes;
597
783
  }
598
784
 
@@ -617,17 +803,20 @@ export class ChatStore {
617
803
  * sender nudging itself). Returns the resolved recipient so callers never
618
804
  * need a second lookup - a re-lookup could race a concurrent unregister
619
805
  * and fail after the message already landed.
806
+ *
807
+ * An oversized body is auto-split into parts (splitChatBody) delivered as
808
+ * several rows in ONE transaction, so a crash cannot leave a partial
809
+ * batch in the reader's inbox. `parts` reports how many rows the send
810
+ * produced (or would have produced, when deduped), and the tool layer
811
+ * surfaces it so a caller cannot mistake a split for a single delivery.
620
812
  */
621
813
  send(
622
814
  fromSession: string,
623
815
  toNameOrID: string,
624
816
  body: string,
625
- ): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
817
+ ): { ok: true; recipient: { session_id: string; name: string }; parts: number; deduped?: boolean } | { ok: false; error: string } {
626
818
  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
- }
819
+ if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
631
820
  const sender = this.#find(fromSession);
632
821
  if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
633
822
  const recipient = this.find(toNameOrID);
@@ -637,55 +826,154 @@ export class ChatStore {
637
826
  if (recipient.session_id === fromSession) {
638
827
  return { ok: false, error: "You cannot message yourself." };
639
828
  }
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 } };
829
+ // Acceptance ceiling, checked before any split: the whole body is
830
+ // capped in characters so a runaway sender cannot flood the inbox with
831
+ // parts. The attempted count rides the error so a caller can split
832
+ // deterministically instead of bisecting.
833
+ if (trimmed.length > CHAT_MAX_BODY_CHARS) {
834
+ return {
835
+ ok: false,
836
+ error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
837
+ };
838
+ }
839
+ const parts = splitChatBody(trimmed);
840
+ let deduped = false;
841
+ // One transaction: the whole batch lands or none of it - a crash
842
+ // mid-insert cannot deliver a partial message.
843
+ this.#db.transaction(() => {
844
+ // The batch's signature is its rows: the transactional insert makes
845
+ // the parts all-or-nothing, and the dedupe condition is READ STATE -
846
+ // any UNREAD part from a prior identical batch suppresses the retry
847
+ // (idempotent even while the recipient is mid-read), while a fully
848
+ // read batch lets an intentional repeat deliver. Checking parts
849
+ // rather than only the first also keeps two identical chunks inside
850
+ // one body from suppressing each other.
851
+ if (this.#isRecentDuplicate(fromSession, recipient.session_id, parts, 0)) {
852
+ deduped = true;
853
+ return;
854
+ }
855
+ for (const part of parts) {
856
+ this.#db.run(
857
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
858
+ [fromSession, recipient.session_id, part, nowIso()],
859
+ );
860
+ }
861
+ })();
862
+ return {
863
+ ok: true,
864
+ recipient: { session_id: recipient.session_id, name: recipient.name },
865
+ parts: parts.length,
866
+ ...(deduped ? { deduped: true } : {}),
867
+ };
645
868
  }
646
869
 
647
870
  /**
648
- * Posts a message to every other registered session at once. Stale
649
- * sessions are skipped, not messaged - a host process that has stopped
650
- * 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.
871
+ * Posts a message to every other registered session at once. Opencode
872
+ * rows are skipped unless they are OPEN - a fresh heartbeat AND a tab
873
+ * open right now. Both skip classes are honest: a STALE row's host
874
+ * process has stopped heartbeat-ing and will never read the mail, and a
875
+ * RECENT row (fresh heartbeat, no open tab) is not reliably wakeable -
876
+ * the wake can be swallowed without a tab to receive it, so the fan-out
877
+ * would spend a model turn on a delivery that likely never runs. MCP
878
+ * rows always receive (they read mail at their next prompt). Every skip
879
+ * is listed by name in `skipped` so the caller can see who was left
880
+ * out and why. Each recipient gets its own inbox row per part, so the
881
+ * existing wake machinery (grouping, gating, rate cap) treats the
882
+ * broadcast as ordinary per-recipient mail. An oversized body is
883
+ * auto-split (splitChatBody) and every recipient gets every part inside
884
+ * the same single transaction; `parts` reports how many rows each
885
+ * recipient received.
654
886
  */
655
887
  broadcast(
656
888
  fromSession: string,
657
889
  body: string,
658
- ): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
890
+ openTabs?: ReadonlySet<string>,
891
+ ): { ok: true; recipients: string[]; skipped: string[]; deduped: number; parts: number } | { ok: false; error: string } {
659
892
  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
- }
893
+ if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
664
894
  const sender = this.#find(fromSession);
665
895
  if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
896
+ // Same acceptance ceiling as send() - a broadcast fans out to every
897
+ // live session, so the cap matters even more here.
898
+ if (trimmed.length > CHAT_MAX_BODY_CHARS) {
899
+ return {
900
+ ok: false,
901
+ error: `Message too long: attempted ${trimmed.length} characters (max ${CHAT_MAX_BODY_CHARS}). Split the content across several messages.`,
902
+ };
903
+ }
904
+ const parts = splitChatBody(trimmed);
905
+ // No provider = no tab signal: every fresh opencode row degrades to
906
+ // RECENT (honest - an unknown tab state never reads as open).
907
+ const open = openTabs ?? new Set<string>();
666
908
  const recipients: string[] = [];
667
909
  const skipped: string[] = [];
910
+ let deduped = 0;
668
911
  // One transaction: a crash mid-loop rolls the whole fan-out back
669
912
  // instead of leaving a partial broadcast where some recipients got the
670
913
  // message and others silently did not.
671
914
  this.#db.transaction(() => {
672
915
  for (const row of this.list()) {
673
916
  if (row.session_id === fromSession) continue;
674
- // Staleness means "host process gone" only for opencode rows; an
675
- // mcp row between turns reads its mail at the next prompt, so it
917
+ // Two honest skip classes for opencode rows: STALE (host process
918
+ // gone - never reads the mail) and RECENT (fresh heartbeat, no
919
+ // open tab - the wake is not guaranteed to run a turn). An mcp
920
+ // row between turns reads its mail at the next prompt, so it
676
921
  // always receives.
677
- if (row.host_kind === "opencode" && isStale(row)) {
922
+ if (row.host_kind === "opencode" && (isStale(row) || !open.has(row.session_id))) {
678
923
  skipped.push(row.name);
679
924
  continue;
680
925
  }
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
- );
926
+ // A retried broadcast counts as delivered (the original batch
927
+ // stands) - the recipient list stays complete either way. Same
928
+ // read-state rule as send(): any unread part from the prior batch
929
+ // suppresses the retry for THIS recipient; a fully read batch
930
+ // delivers again.
931
+ if (this.#isRecentDuplicate(fromSession, row.session_id, parts, 1)) {
932
+ deduped++;
933
+ recipients.push(row.name);
934
+ continue;
935
+ }
936
+ for (const part of parts) {
937
+ this.#db.run(
938
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
939
+ [fromSession, row.session_id, part, nowIso()],
940
+ );
941
+ }
685
942
  recipients.push(row.name);
686
943
  }
687
944
  })();
688
- return { ok: true, recipients, skipped };
945
+ return { ok: true, recipients, skipped, deduped, parts: parts.length };
946
+ }
947
+
948
+ /**
949
+ * True when an UNREAD row from a prior send still exists inside the
950
+ * dedupe window - the signature of a client-side retry of a send that
951
+ * actually committed. The condition is READ STATE, not just recency:
952
+ * identical content is a duplicate only while the recipient has not
953
+ * consumed it (the mail is there; a retry adds nothing). Once every
954
+ * matching row was read, the same body delivers again - presumptively an
955
+ * intentional resend after the first was consumed ("ok" said twice on
956
+ * purpose). For a split batch, `bodies` is every part and ANY unread
957
+ * part suppresses the retry: idempotent even while the recipient is
958
+ * mid-read (the poller keeps nudging the unread parts), and only a fully
959
+ * read batch lets an identical repeat through. Timestamps are
960
+ * second-resolution ISO (nowIso's format), so string comparison works.
961
+ */
962
+ #isRecentDuplicate(fromSession: string, toSession: string, bodies: string[], viaBroadcast: 0 | 1): boolean {
963
+ const cutoff = new Date(Date.now() - CHAT_DEDUPE_WINDOW_MS).toISOString().replace(/\.\d{3}Z$/, "Z");
964
+ const marks = bodies.map(() => "?").join(",");
965
+ // bun:sqlite's .get() returns null (not undefined) when no row matches -
966
+ // a truthiness check, not an identity check, or every send dedupes.
967
+ return (
968
+ this.#db
969
+ .query(
970
+ `SELECT 1 AS hit FROM chat_messages
971
+ WHERE from_session = ? AND to_session = ? AND via_broadcast = ?
972
+ AND body IN (${marks}) AND read_at IS NULL AND created_at >= ?
973
+ LIMIT 1`,
974
+ )
975
+ .get(fromSession, toSession, viaBroadcast, ...bodies, cutoff) != null
976
+ );
689
977
  }
690
978
 
691
979
  /**
@@ -728,6 +1016,33 @@ export class ChatStore {
728
1016
  return row.n;
729
1017
  }
730
1018
 
1019
+ /**
1020
+ * One message from the caller's mailbox by row id - the paging path
1021
+ * behind chat_read's message/offset arguments. Scoped to the caller:
1022
+ * a session cannot page another session's mail. Deliberately NO read
1023
+ * stamp and no unread filter - paging works on read or unread rows and
1024
+ * is side-effect-free; only the drain marks mail read.
1025
+ */
1026
+ messageById(sessionID: string, id: number): ChatInboxItem | null {
1027
+ const r = this.#db
1028
+ .query(
1029
+ `SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
1030
+ FROM chat_messages m
1031
+ LEFT JOIN chat_sessions s ON s.session_id = m.from_session
1032
+ WHERE m.id = ? AND m.to_session = ?`,
1033
+ )
1034
+ .get(id, sessionID) as any;
1035
+ return r
1036
+ ? {
1037
+ id: r.id,
1038
+ from_session: r.from_session,
1039
+ from_name: r.from_name ?? null,
1040
+ body: r.body,
1041
+ created_at: r.created_at,
1042
+ }
1043
+ : null;
1044
+ }
1045
+
731
1046
  /**
732
1047
  * Records (or refreshes) the hook-parent-process -> session mapping: the
733
1048
  * identity anchor that closes the caller-claimed `as` hole on Claude Code.
@@ -762,7 +1077,7 @@ export class ChatStore {
762
1077
  JOIN chat_sessions s ON s.session_id = p.session_id
763
1078
  WHERE p.ppid = ? AND p.seen_at >= ?`,
764
1079
  )
765
- .get(ppid, cutoff) as { session_id: string } | undefined;
1080
+ .get(ppid, cutoff) as { session_id: string } | null;
766
1081
  return row?.session_id ?? null;
767
1082
  }
768
1083