@jeffober/thatch 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chat.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import { openSync, readSync, closeSync, readdirSync, statSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
2
4
  import { CHAT_NAME_POOL } from "./chat-names";
3
5
 
4
6
  /**
@@ -98,8 +100,112 @@ export function mcpSessionID(hostSessionID: string): string {
98
100
  return `mcp_${hash}`;
99
101
  }
100
102
 
103
+ /**
104
+ * Reads a Claude Code transcript's last line and returns the session id the
105
+ * conversation continued INTO, when the file ends with a continued-in record
106
+ * (Claude Code writes it when a conversation is resumed or forked into a new
107
+ * session id). Returns null otherwise. This is the link that lets a hook
108
+ * follow a continuation backwards: the NEW session id is all the hook knows,
109
+ * and the OLD transcript names it.
110
+ */
111
+ export function continuedInTarget(transcriptPath: string): string | null {
112
+ let raw: string;
113
+ try {
114
+ // The marker is the file's final record; reading the tail avoids
115
+ // loading potentially large transcripts.
116
+ const stat = statSync(transcriptPath);
117
+ const tailLen = Math.min(stat.size, 4096);
118
+ const buf = Buffer.alloc(tailLen);
119
+ const fd = openSync(transcriptPath, "r");
120
+ try {
121
+ readSync(fd, buf, 0, tailLen, stat.size - tailLen);
122
+ } finally {
123
+ closeSync(fd);
124
+ }
125
+ raw = buf.toString("utf8");
126
+ } catch {
127
+ return null;
128
+ }
129
+ const lines = raw.trimEnd().split("\n").filter((l) => l.trim());
130
+ const last = lines[lines.length - 1];
131
+ if (!last) return null;
132
+ try {
133
+ const record = JSON.parse(last) as { type?: string; continuedInSessionId?: string };
134
+ if (record.type !== "continued-in" || !record.continuedInSessionId) return null;
135
+ return record.continuedInSessionId;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Finds the immediate predecessor of a continued session by scanning the
143
+ * transcript directory for a sibling whose tail hands off TO this session
144
+ * (see continuedInTarget). Most recently modified wins. Returns the
145
+ * predecessor's raw session id (transcript file stem), or null.
146
+ */
147
+ export function scanPredecessorTranscript(
148
+ transcriptPath: string,
149
+ sessionID: string,
150
+ maxFiles = 200,
151
+ ): string | null {
152
+ const dir = dirname(transcriptPath);
153
+ let files: string[];
154
+ try {
155
+ files = readdirSync(dir)
156
+ .filter((f) => f.endsWith(".jsonl") && f !== basename(transcriptPath))
157
+ .map((f) => {
158
+ try {
159
+ return { f, m: statSync(join(dir, f)).mtimeMs };
160
+ } catch {
161
+ return null;
162
+ }
163
+ })
164
+ .filter((e): e is { f: string; m: number } => e !== null)
165
+ .sort((a, b) => b.m - a.m)
166
+ .slice(0, maxFiles)
167
+ .map((e) => e.f);
168
+ } catch {
169
+ return null;
170
+ }
171
+ for (const f of files) {
172
+ if (continuedInTarget(join(dir, f)) === sessionID) {
173
+ return f.slice(0, -".jsonl".length);
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Walks a continuation chain back to the nearest session that has a chat
181
+ * directory row. Claude Code sessions fork on resume (new session id, same
182
+ * conversation), so a hook registering an unknown session id can adopt the
183
+ * predecessor's identity - same name, same mailbox - instead of stranding
184
+ * it. `isRegistered` maps a raw host session id to its chat session id when
185
+ * that row exists, else null. Bounded by maxHops; multi-hop forks resolve
186
+ * to the oldest registered ancestor.
187
+ */
188
+ export function resolveRegisteredPredecessor(
189
+ transcriptPath: string,
190
+ sessionID: string,
191
+ isRegistered: (rawSessionID: string) => string | null,
192
+ maxHops = 10,
193
+ ): string | null {
194
+ let current = sessionID;
195
+ let currentPath = transcriptPath;
196
+ for (let hop = 0; hop < maxHops; hop++) {
197
+ const pred = scanPredecessorTranscript(currentPath, current);
198
+ if (!pred) return null;
199
+ const registered = isRegistered(pred);
200
+ if (registered) return registered;
201
+ current = pred;
202
+ currentPath = join(dirname(transcriptPath), `${pred}.jsonl`);
203
+ }
204
+ return null;
205
+ }
206
+
101
207
  /** Which kind of harness hosts a chat session: opencode rows are
102
- * poller-driven (liveness = process alive), mcp rows are turn-driven
208
+ * poller-driven (liveness = heartbeat fresh), mcp rows are turn-driven
103
209
  * (liveness = a turn ran recently; mail is read at the next prompt). */
104
210
  export type ChatHostKind = "opencode" | "mcp";
105
211
 
@@ -108,10 +214,11 @@ export type ChatHostKind = "opencode" | "mcp";
108
214
  // into a context bomb; the reader fetches full content via chat_read anyway.
109
215
  const MAX_BODY_LEN = 10_000;
110
216
 
111
- // Assigned names are lowercase slugs with a numeric counter suffix
112
- // ("fix-auth-bug-00001"), which satisfies NAME_CHARSET. The charset stays
113
- // exported for the pool conformance test. Slugs never contain underscores,
114
- // so the name-cannot-shadow-an-ID invariant in find() still holds.
217
+ // Assigned names are lowercase slugs of a pool name with a numeric counter
218
+ // suffix ("al-go-rithm-00001"), which satisfies NAME_CHARSET. The charset
219
+ // stays exported for the pool conformance test. Slugs never contain
220
+ // underscores, so the name-cannot-shadow-an-ID invariant in find() still
221
+ // holds.
115
222
  export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
116
223
 
117
224
  // Topics are free text (unlike names) but must stay one line for the
@@ -122,7 +229,7 @@ const MAX_TOPIC_LEN = 80;
122
229
  // many days are pruned from the directory. Counters never decrement, so a
123
230
  // pruned session's name is never reissued - pruning cannot create identity
124
231
  // confusion, only roster silence.
125
- export const CHAT_AUTO_TTL_DAYS = 7;
232
+ const CHAT_AUTO_TTL_DAYS = 7;
126
233
 
127
234
  /**
128
235
  * Slugifies a session title into a name base: lowercase, letter/number runs
@@ -156,12 +263,19 @@ export function isDefaultSessionTitle(title: string): boolean {
156
263
  return /^New session - /.test(title) || /^Child session - /.test(title);
157
264
  }
158
265
 
159
- // Timing defaults, shared by the poller and the chat_list staleness display
160
- // so both agree on what "stale" means.
266
+ // Poller cadence. A hosting harness beats each of its sessions once per
267
+ // interval and delivers their mail on the same tick.
161
268
  export const CHAT_POLL_INTERVAL_MS = 30_000;
162
- export const CHAT_STALE_MINUTES = 10;
269
+ // Staleness threshold, shared by the poller, chat_list, chat_send, and the
270
+ // broadcast skip so every surface agrees on what "stale" means: a row that
271
+ // has missed two consecutive beats belongs to a harness that stopped
272
+ // (crash, kill, machine asleep). Two beats rather than one so a single
273
+ // late poll cycle does not flap the roster.
274
+ export const CHAT_STALE_MS = 2 * CHAT_POLL_INTERVAL_MS;
275
+ // Wake re-nudge window and the per-recipient hourly nudge cap (the anti-loop
276
+ // hard brake); see ChatPoller.
163
277
  export const CHAT_RENUDGE_MINUTES = 15;
164
- export const CHAT_MAX_NUDGES_PER_HOUR = 6;
278
+ const CHAT_MAX_NUDGES_PER_HOUR = 6;
165
279
 
166
280
  /** Human-readable age of an ISO timestamp: "45s ago", "5m ago", "3h ago",
167
281
  * "2d ago". Shared by the CLI roster and the chat_list tool so both
@@ -176,6 +290,96 @@ export function humanAge(iso: string, now = Date.now()): string {
176
290
  return `${Math.floor(minutes / (24 * 60))}d ago`;
177
291
  }
178
292
 
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" {
302
+ const stale = isStale(row, now);
303
+ if (row.host_kind === "mcp") return stale ? "idle" : "active";
304
+ return stale ? "stale" : "fresh";
305
+ }
306
+
307
+ /** Orders roster rows for display: project first (so a shared-tree reader
308
+ * sees each repo's sessions together; rows with no project sort last),
309
+ * then by heartbeat, most recently seen first, so the recoverable rows
310
+ * lead and the long-dead trail at the bottom. Pure ordering - it never
311
+ * drops rows; the stale display cap is splitChatRoster's job. */
312
+ export function sortChatRoster(rows: ChatSessionRow[]): ChatSessionRow[] {
313
+ return [...rows].sort((a, b) => {
314
+ const projectA = a.project ?? "\uffff";
315
+ const projectB = b.project ?? "\uffff";
316
+ if (projectA !== projectB) return projectA.localeCompare(projectB);
317
+ return Date.parse(b.last_seen) - Date.parse(a.last_seen);
318
+ });
319
+ }
320
+
321
+ /** Splits the roster for the two-section display: Active (wake-able or
322
+ * reachable - fresh opencode rows and every MCP row, which read mail at
323
+ * their next prompt) and Stale (opencode rows whose harness has stopped
324
+ * reporting; mail to them waits until they resume).
325
+ * staleCapMs bounds how old a stale row the roster displays, so
326
+ * long-dead sessions cannot bury the fresh signal; hidden rows come
327
+ * back only as the staleHidden count, so the CLI can say what it left
328
+ * out. null (the default) shows every stale row - the tool surface has
329
+ * no cap, only the CLI applies one. */
330
+ export function splitChatRoster(rows: ChatSessionRow[], now = Date.now(), staleCapMs: number | null = null): { active: ChatSessionRow[]; stale: ChatSessionRow[]; staleHidden: number } {
331
+ const active: ChatSessionRow[] = [];
332
+ const stale: ChatSessionRow[] = [];
333
+ let staleHidden = 0;
334
+ for (const row of rows) {
335
+ if (chatLiveness(row, now) === "stale") {
336
+ if (staleCapMs !== null && now - Date.parse(row.last_seen) > staleCapMs) {
337
+ staleHidden++;
338
+ } else {
339
+ stale.push(row);
340
+ }
341
+ } else {
342
+ active.push(row);
343
+ }
344
+ }
345
+ return { active, stale, staleHidden };
346
+ }
347
+
348
+ /**
349
+ * The wake-delivery gate, shared by the chat poller and the watcher
350
+ * registry: can this session be woken with a prompt right now? Three
351
+ * layers - the compacting set, the event-fed status map (cheap pre-filter
352
+ * for KNOWN busy/retry states), and the server's authoritative live status
353
+ * map. ABSENCE from the event-fed map is not evidence of busy: the `-s`
354
+ * startup session is hosted from init, before it has emitted any status
355
+ * event, so absence falls through to the live check. A gate that rejected
356
+ * on absence would leave that session mail-deaf - beating fine, never
357
+ * woken - until its first turn. The live
358
+ * map only carries ACTIVE sessions - the server deletes idle entries - so
359
+ * absent means idle there; busy/retry fails closed, as does an unreachable
360
+ * server (the mail stays pending and retries).
361
+ */
362
+ export function createWakeGate(deps: {
363
+ isCompacting: (sessionID: string) => boolean;
364
+ mappedStatus: (sessionID: string) => string | undefined;
365
+ fetchStatuses: () => Promise<Record<string, { type?: string } | undefined>>;
366
+ onStatusError?: (sessionID: string, err: unknown) => void;
367
+ }): (sessionID: string) => Promise<boolean> {
368
+ return async (sessionID: string) => {
369
+ if (deps.isCompacting(sessionID)) return false;
370
+ const mapped = deps.mappedStatus(sessionID);
371
+ if (mapped === "busy" || mapped === "retry") return false;
372
+ try {
373
+ const statuses = await deps.fetchStatuses();
374
+ const live = statuses[sessionID];
375
+ return !live || live.type === "idle";
376
+ } catch (err) {
377
+ deps.onStatusError?.(sessionID, err);
378
+ return false;
379
+ }
380
+ };
381
+ }
382
+
179
383
  /**
180
384
  * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
181
385
  * Database handle; the tables are created by ThatchDB's schema init.
@@ -190,17 +394,20 @@ export class ChatStore {
190
394
  /**
191
395
  * Joins the directory, or refreshes an existing registration. Names are
192
396
  * assigned by the system, never claimed: a new session gets
193
- * "<slug>-<counter>" (e.g. "fix-auth-bug-00001"), where the slug comes
194
- * from the session title (or a pool-draw slug when there is none) and the
195
- * per-base counter only ever increments - so a name is minted exactly
196
- * once, machine-wide, and pruning an old session can never reissue its
197
- * name to someone else. Idempotent for an already-registered session: it
198
- * keeps its name and just refreshes liveness.
397
+ * "<slug>-<counter>" (e.g. "al-go-rithm-00001"), where the slug is a
398
+ * random pool name (chat-names.ts) and the per-base counter only ever
399
+ * increments - so a name is minted exactly once, machine-wide, and
400
+ * pruning an old session can never reissue its name to someone else. The
401
+ * session title never feeds the name; it rides along as the topic, so a
402
+ * placeholder or later-edited title cannot leave a misleading name
403
+ * behind. Idempotent for an already-registered session: it keeps its
404
+ * name and just refreshes liveness.
199
405
  *
200
- * `nameBase` is the slugified title for a new registration (null draws a
201
- * pool slug instead). `topic` is the live title for auto-registrations;
202
- * null leaves the topic unset. Every row minted here is marked auto=1 -
203
- * rows with auto=0 exist only as legacy data from the claimed-names era.
406
+ * `nameBase` overrides the pool draw with a fixed base (tests only; every
407
+ * production caller passes null). `topic` is the live title for
408
+ * auto-registrations; null leaves the topic unset. Every row minted here
409
+ * is marked auto=1 - rows with auto=0 exist only as legacy data from the
410
+ * claimed-names era.
204
411
  */
205
412
  register(
206
413
  sessionID: string,
@@ -209,9 +416,13 @@ export class ChatStore {
209
416
  kind: ChatHostKind,
210
417
  nameBase: string | null = null,
211
418
  worktree: ChatWorktreeKind = null,
212
- ): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
419
+ ): { ok: true; name: string; topic: string | null; created: boolean } | { ok: false; error: string } {
213
420
  const existing = this.#find(sessionID);
214
421
  if (existing) {
422
+ // Reclaim: registration is keyed by session id, so a session
423
+ // continued in a new harness process (`opencode -s <id>`) reclaims
424
+ // its row and name by re-registering. Touching last_seen is the
425
+ // whole of ownership: the harness that beats a row is its host.
215
426
  this.#touch(sessionID);
216
427
  // An explicit registration clears the leave tombstone for its own
217
428
  // session ID: "I want back in" is the opposite of "I left". This is
@@ -222,7 +433,7 @@ export class ChatStore {
222
433
  // session.deleted suppresses exactly that session.
223
434
  const gate = this.#tombstoneGate(sessionID, kind);
224
435
  if (gate) return gate;
225
- return { ok: true, name: existing.name, topic: existing.topic };
436
+ return { ok: true, name: existing.name, topic: existing.topic, created: false };
226
437
  }
227
438
  const gate = this.#tombstoneGate(sessionID, kind);
228
439
  if (gate) return gate;
@@ -239,12 +450,12 @@ export class ChatStore {
239
450
  const next = this.#bumpCounter(base);
240
451
  const name = `${base}-${String(next).padStart(5, "0")}`;
241
452
  const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
242
- if (claimed.ok) return { ok: true, name, topic: cleanTopic };
453
+ if (claimed.ok) return { ok: true, name, topic: cleanTopic, created: true };
243
454
  if (claimed.reason === "already-registered") {
244
455
  // A concurrent registration of the same session ID won the race;
245
456
  // report its row rather than erroring.
246
457
  const winner = this.#find(sessionID);
247
- if (winner) return { ok: true, name: winner.name, topic: winner.topic };
458
+ if (winner) return { ok: true, name: winner.name, topic: winner.topic, created: false };
248
459
  }
249
460
  }
250
461
  return { ok: false, error: "Could not assign a unique name after several attempts." };
@@ -433,7 +644,7 @@ export class ChatStore {
433
644
  // Staleness means "host process gone" only for opencode rows; an
434
645
  // mcp row between turns reads its mail at the next prompt, so it
435
646
  // always receives.
436
- if (row.host_kind === "opencode" && isStale(row, CHAT_STALE_MINUTES)) {
647
+ if (row.host_kind === "opencode" && isStale(row)) {
437
648
  skipped.push(row.name);
438
649
  continue;
439
650
  }
@@ -487,6 +698,65 @@ export class ChatStore {
487
698
  return row.n;
488
699
  }
489
700
 
701
+ /**
702
+ * Records (or refreshes) the hook-parent-process -> session mapping: the
703
+ * identity anchor that closes the caller-claimed `as` hole on Claude Code.
704
+ * Hooks receive the true session id from the host and run as children of
705
+ * the same Claude Code process that spawned the MCP server, so the server
706
+ * can resolve its own parent pid against this mapping - a fact the model
707
+ * cannot influence. Recorded only from payloads that carried Claude Code's
708
+ * `session_id`; Cursor's hooks come from a shared extension host with
709
+ * per-workspace MCP servers, so ppid is ambiguous there and `as` stays the
710
+ * identity source.
711
+ */
712
+ recordHostPid(ppid: number, sessionID: string): void {
713
+ this.#db.run(
714
+ `INSERT INTO chat_host_pids (ppid, session_id, seen_at) VALUES (?, ?, ?)
715
+ ON CONFLICT(ppid) DO UPDATE SET session_id = excluded.session_id, seen_at = excluded.seen_at`,
716
+ [ppid, sessionID, nowIso()],
717
+ );
718
+ }
719
+
720
+ /**
721
+ * Resolves a host process id to a chat session id, when a hook recorded
722
+ * the mapping recently and the session still exists in the directory.
723
+ * Freshness bounds the pid-reuse hazard: a dead host's pid can be
724
+ * reassigned by the OS, and a stale mapping must not hand the new
725
+ * occupant the old conversation's identity.
726
+ */
727
+ findSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
728
+ const cutoff = isoMinutesAgo(Math.ceil(maxAgeSeconds / 60));
729
+ const row = this.#db
730
+ .query(
731
+ `SELECT p.session_id FROM chat_host_pids p
732
+ JOIN chat_sessions s ON s.session_id = p.session_id
733
+ WHERE p.ppid = ? AND p.seen_at >= ?`,
734
+ )
735
+ .get(ppid, cutoff) as { session_id: string } | undefined;
736
+ return row?.session_id ?? null;
737
+ }
738
+
739
+ /**
740
+ * Migrates a chat identity onto a continued session id. Claude Code forks
741
+ * the session id on resume/fork while the conversation (name, mailbox,
742
+ * leave state) belongs to the human-level conversation, so the old row's
743
+ * key moves forward and everything referencing the old key follows:
744
+ * message sender/recipient columns (no foreign keys by design - the
745
+ * departed-sender degradation must not apply to a continued conversation),
746
+ * hook-recorded host-pid anchors, and leave tombstones. No-op when the
747
+ * old row does not exist.
748
+ */
749
+ continueSession(oldSessionID: string, newSessionID: string): void {
750
+ if (oldSessionID === newSessionID) return;
751
+ const row = this.#db.query("SELECT session_id FROM chat_sessions WHERE session_id = ?").get(oldSessionID);
752
+ if (!row) return;
753
+ this.#db.run("UPDATE chat_sessions SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
754
+ this.#db.run("UPDATE chat_messages SET from_session = ? WHERE from_session = ?", [newSessionID, oldSessionID]);
755
+ this.#db.run("UPDATE chat_messages SET to_session = ? WHERE to_session = ?", [newSessionID, oldSessionID]);
756
+ this.#db.run("UPDATE chat_host_pids SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
757
+ this.#db.run("UPDATE chat_leave_tombstones SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
758
+ }
759
+
490
760
  /**
491
761
  * The caller's mailbox summary for chat_status: whether a directory row
492
762
  * exists, and pending/total counts. Touches last_seen on use, which is
@@ -534,18 +804,15 @@ export class ChatStore {
534
804
  ).map((r) => ({ name: r.name, pending: r.pending }));
535
805
  }
536
806
 
537
- /** Refreshes last_seen for the given sessions. Sessions that never
538
- * registered are absent from chat_sessions, so the UPDATE is a no-op for
539
- * them - no filtering needed on the caller's side. */
807
+ /** Refreshes last_seen for the given sessions - the heartbeat that keeps
808
+ * them out of the stale section. Sessions that never registered are absent from
809
+ * chat_sessions, so the UPDATE is a no-op for them - no filtering needed
810
+ * on the caller's side. */
540
811
  heartbeat(sessionIDs: string[]): void {
541
812
  if (sessionIDs.length === 0) return;
542
813
  const marks = sessionIDs.map(() => "?").join(",");
543
- this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
544
- nowIso(),
545
- ...sessionIDs,
546
- ]);
814
+ this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [nowIso(), ...sessionIDs]);
547
815
  }
548
-
549
816
  /**
550
817
  * The full message feed for `thatch chat tail`: every row with sender and
551
818
  * recipient display names resolved (departed senders degrade to unknown),
@@ -772,9 +1039,27 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
772
1039
  // Tail events (pure logic behind `thatch chat tail`)
773
1040
  // ---------------------------------------------------------------------------
774
1041
 
1042
+ /**
1043
+ * One line of the `thatch chat tail` event log. Sending a message and
1044
+ * reading it are two events, each on its own line, linked by `id` (the
1045
+ * message row id). Broadcast fan-out is one message row per recipient, so
1046
+ * a broadcast to N sessions is N sent events with `broadcast: true`, each
1047
+ * naming its real recipient and tracking its own read. Timestamps are the
1048
+ * database's ISO-8601 UTC strings, untouched.
1049
+ */
775
1050
  export type ChatTailEvent =
776
- | { kind: "sent"; timestamp: string; from: string; to: string; fromTopic: string | null; toTopic: string | null; body: string }
777
- | { kind: "read"; timestamp: string; reader: string; from: string; fromTopic: string | null; toTopic: string | null; body: string };
1051
+ | {
1052
+ event: "sent";
1053
+ at: string;
1054
+ id: number;
1055
+ from: string;
1056
+ from_topic: string | null;
1057
+ to: string;
1058
+ to_topic: string | null;
1059
+ broadcast: boolean;
1060
+ body: string;
1061
+ }
1062
+ | { event: "read"; at: string; id: number; reader: string; reader_topic: string | null; from: string; from_topic: string | null };
778
1063
 
779
1064
  /** One message row as the tail diff consumes it. */
780
1065
  export interface ChatTailRow {
@@ -807,25 +1092,27 @@ export function chatTailDiff(
807
1092
  const isNew = !prev.has(r.id);
808
1093
  if (isNew) {
809
1094
  events.push({
810
- kind: "sent",
811
- timestamp: r.created_at,
1095
+ event: "sent",
1096
+ at: r.created_at,
1097
+ id: r.id,
812
1098
  from: r.from ?? "unknown",
813
- to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
814
- fromTopic: r.fromTopic,
815
- toTopic: r.viaBroadcast ? null : r.toTopic,
1099
+ from_topic: r.fromTopic,
1100
+ to: r.to ?? "unknown",
1101
+ to_topic: r.toTopic,
1102
+ broadcast: r.viaBroadcast,
816
1103
  body: r.body,
817
1104
  });
818
1105
  } else {
819
1106
  const prevRead = prev.get(r.id) ?? null;
820
1107
  if (r.read_at !== null && prevRead === null) {
821
1108
  events.push({
822
- kind: "read",
823
- timestamp: r.read_at,
1109
+ event: "read",
1110
+ at: r.read_at,
1111
+ id: r.id,
824
1112
  reader: r.to ?? "unknown",
1113
+ reader_topic: r.toTopic,
825
1114
  from: r.from ?? "unknown",
826
- fromTopic: r.fromTopic,
827
- toTopic: r.toTopic,
828
- body: r.body,
1115
+ from_topic: r.fromTopic,
829
1116
  });
830
1117
  }
831
1118
  }
@@ -835,7 +1122,7 @@ export function chatTailDiff(
835
1122
  }
836
1123
 
837
1124
  /**
838
- * The default number of backlog cards `thatch chat tail` renders before
1125
+ * The default number of backlog messages `thatch chat tail` prints before
839
1126
  * follow mode takes over. The message table is machine-scale but the
840
1127
  * history still grows forever, so an unbounded default would dump the
841
1128
  * entire database on every run.
@@ -844,7 +1131,8 @@ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
844
1131
 
845
1132
  /**
846
1133
  * Parses a tail time-bound value into epoch milliseconds, interpreted in
847
- * the terminal's local timezone (the same clock the When headers print).
1134
+ * the terminal's local timezone (event timestamps print in UTC; the bounds
1135
+ * are typed by a human at a terminal, so they read as local time).
848
1136
  * Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
849
1137
  * space or a T between the date and the time. The component round-trip
850
1138
  * rejects impossible calendar values like 2026-09-31 and 25:99, which
@@ -884,11 +1172,10 @@ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi:
884
1172
  * test the message body; `fromSubstr`/`toSubstr` are case-insensitive
885
1173
  * substrings of the rendered participant names (so a departed sender can
886
1174
  * only match as "unknown (...)", the string the reader actually sees).
887
- * One deliberate asymmetry: broadcast rows keep the real recipient in
888
- * `to`, so `--to <realname>` matches fan-out rows even though their cards
889
- * say "broadcast", and `--to broadcast` matches nothing. `sinceMs`/
890
- * `untilMs` bound created_at in a since-inclusive, until-exclusive
891
- * window. An empty field is no constraint.
1175
+ * Broadcast fan-out rows carry their real recipient in `to`, so `--to
1176
+ * <name>` matches them like any direct message. `sinceMs`/`untilMs`
1177
+ * bound created_at in a since-inclusive, until-exclusive window. An empty
1178
+ * field is no constraint.
892
1179
  *
893
1180
  * Name inputs are not stable across polls: they are re-resolved from the
894
1181
  * live directory on every feed snapshot, so an unregister or rename
@@ -922,16 +1209,18 @@ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFil
922
1209
  /**
923
1210
  * Prepares the tail's first render. The diff state seeds from EVERY row in
924
1211
  * the feed - filtered or not, rendered or not - for two reasons. First,
925
- * the limit hides cards, not history: a state seeded only from the
926
- * rendered slice would re-emit the elided history as new sent events on
1212
+ * the limit hides messages, not history: a state seeded only from the
1213
+ * printed slice would re-emit the elided history as new sent events on
927
1214
  * the first follow poll. Second, the name filters test JOIN-resolved
928
1215
  * participant names that can flip mid-follow (a peer unregisters and its
929
1216
  * history starts rendering as "unknown (... departed)", or it re-registers
930
1217
  * under a new name); seeding everything means an old row can never resurface
931
1218
  * as a sent event no matter how its rendered name changes. The last
932
- * `limit` filtered rows (null = all) shape into sent events via
933
- * chatTailDiff, so card rendering keeps a single source. `elided` counts
934
- * matching rows the limit hid, for the CLI's summary line.
1219
+ * `limit` filtered rows (null = all) shape into sent events, plus a read
1220
+ * event for each row already read, via chatTailDiff, so event shaping
1221
+ * keeps a single source. `shown` and `elided` count MESSAGES (not
1222
+ * events: a shown message contributes one or two lines) for the CLI's
1223
+ * summary line.
935
1224
  *
936
1225
  * The caller must re-apply the same filter to every follow poll's feed
937
1226
  * BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
@@ -942,135 +1231,31 @@ export function chatTailBacklog(
942
1231
  rows: Array<ChatTailRow>,
943
1232
  filter: ChatTailFilter,
944
1233
  limit: number | null,
945
- ): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
1234
+ ): { events: ChatTailEvent[]; state: Map<number, string | null>; shown: number; elided: number } {
946
1235
  const state = new Map(rows.map((r) => [r.id, r.read_at]));
947
1236
  const filtered = filterChatTailRows(rows, filter);
948
1237
  const shown = limit === null ? filtered : filtered.slice(-limit);
949
- const events = chatTailDiff(new Map(), shown).events;
950
- return { events, state, elided: filtered.length - shown.length };
951
- }
952
-
953
- /** Renders one tail event in the CLI's line format. */
954
- export function formatChatTailEvent(event: ChatTailEvent): string {
955
- if (event.kind === "sent") {
956
- return `[${event.timestamp}] ${event.from} -> ${event.to}: ${event.body}`;
957
- }
958
- const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
959
- return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
960
- }
961
-
962
- /** "2026-09-12 19:46 MT" - the UTC timestamp converted to the terminal's
963
- * local timezone (abbreviation from the locale, fallback "UTC"), one line
964
- * for the card header. */
965
- function localWhen(timestamp: string): string {
966
- const date = new Date(timestamp);
967
- if (Number.isNaN(date.getTime())) return timestamp;
968
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
969
- const pad = (n: number) => String(n).padStart(2, "0");
970
- return (
971
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
972
- `${pad(date.getHours())}:${pad(date.getMinutes())} ${tz}`
973
- );
974
- }
975
-
976
- /**
977
- * Renders one tail event as a chat card: header block (From / To / When),
978
- * blank line, full body, then a separator line. This is the human format
979
- * for `thatch chat tail` - the single-line formatChatTailEvent remains for
980
- * tests and compact contexts. Read events use the same card shape with the
981
- * reader in the From slot; broadcast events show "broadcast" as the
982
- * recipient. The separator is between cards, so the caller joins cards
983
- * with it and never gets a trailing rule.
984
- */
985
- // ANSI styling for the tail cards: black-on-green field-label chips, green
986
- // values, dim for the timestamps and the "read" marker, and a drawn rule
987
- // for the separator. Bare ESC sequences rather than a color library - the
988
- // tail is a terminal surface, and the codes are trivial.
989
- const ANSI = {
990
- // Label chips are padded to a fixed width (5 + padding = 7 columns) so
991
- // every line's content starts at the same column - To's 2-char label
992
- // misaligned against 4-char labels without this.
993
- labelBg: (s: string) => `\x1b[30;42m ${s.padEnd(5)} \x1b[0m`,
994
- valueFg: (s: string) => `\x1b[32m${s}\x1b[0m`, // green (field values)
995
- dim: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim ("read" marker)
996
- rule: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim (the drawn separator)
997
- // Session-of-origin annotation: italic + bright-black (muted gray).
998
- originLabel: (s: string) => `\x1b[3;90m<${s}>\x1b[0m`,
999
- };
1000
-
1001
- const CARD_INDENT = ""; // cards sit flush with the separator (no extra indent)
1002
-
1003
- /**
1004
- * Renders one tail event as a card. renderBody lets the CLI re-render the
1005
- * sent body (markdown-to-ANSI via an external renderer); the read-event
1006
- * clip is never rendered - a 60-char truncated fragment is not a document.
1007
- */
1008
- export function formatChatTailCard(event: ChatTailEvent, renderBody: (body: string) => string = (b) => b): string {
1009
- const lines: string[] = [];
1010
- const when = ANSI.dim(localWhen(event.timestamp));
1011
- // The session-of-origin annotation: the participant's registered topic,
1012
- // italic + muted bright-black, after the name. Omitted for participants
1013
- // without a topic (and for the broadcast pseudo-recipient, whose topic
1014
- // is null by construction).
1015
- const origin = (topic: string | null) => (topic ? ` ${ANSI.originLabel(topic)}` : "");
1016
- const chip = (label: string, value: string, topic: string | null) =>
1017
- `${CARD_INDENT}${ANSI.labelBg(label)} ${ANSI.valueFg(value)}${origin(topic)}`;
1018
- if (event.kind === "sent") {
1019
- lines.push(chip("From", event.from, event.fromTopic));
1020
- lines.push(chip("To", event.to, event.toTopic));
1021
- lines.push(chip("When", when, null));
1022
- lines.push("");
1023
- lines.push(renderBody(event.body));
1024
- } else {
1025
- const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
1026
- lines.push(chip("From", event.reader, event.toTopic));
1027
- lines.push(`${CARD_INDENT}${ANSI.dim("read")} ${ANSI.valueFg(event.from)}${origin(event.fromTopic)}`);
1028
- lines.push(chip("When", when, null));
1029
- lines.push("");
1030
- lines.push(clipped);
1238
+ // A snapshot has the full history of each shown message: its send, and
1239
+ // its read if read_at is set. Emit both, in time order, so `--once`
1240
+ // prints the same event log a follow would have accumulated. Reads of
1241
+ // messages the limit hid stay hidden with them.
1242
+ const events: ChatTailEvent[] = [];
1243
+ for (const r of shown) {
1244
+ events.push(...chatTailDiff(new Map(), [r]).events);
1245
+ if (r.read_at !== null) events.push(...chatTailDiff(new Map([[r.id, null]]), [r]).events);
1031
1246
  }
1032
- return lines.join("\n");
1247
+ events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.event === b.event ? 0 : a.event === "sent" ? -1 : 1));
1248
+ return { events, state, shown: shown.length, elided: filtered.length - shown.length };
1033
1249
  }
1034
1250
 
1035
- export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
1036
-
1037
1251
  /**
1038
- * Chooses the markdown-to-ANSI renderer for chat tail bodies, in preference
1039
- * order: glow first, then gum format. Returns the spawn argv (the body
1040
- * arrives on stdin), or null when neither tool is installed. Pure so tests
1041
- * can pin the order; the caller owns PATH detection and the spawn itself.
1042
- *
1043
- * -w 0: no wrapping - bodies overflow like they do unrendered, and
1044
- * wrapping inside cards would rag the layout. -s dark: glow picks its
1045
- * style from ITS stdout, which is always a pipe here, so the style is
1046
- * forced; notty would keep literal "#" and "**" markers, which is not
1047
- * rendering.
1252
+ * Renders one tail event as a JSON line. Plain JSON.stringify of the event
1253
+ * object: no ANSI, no markdown rendering, no local-time conversion. The
1254
+ * tail is a log, and a log is for grep and jq; a terminal reader who wants
1255
+ * color pipes through their own tool.
1048
1256
  */
1049
- export function selectChatBodyRenderer(glowPath: string | null, gumPath: string | null): string[] | null {
1050
- if (glowPath) return [glowPath, "-", "-w", "0", "-s", "dark"];
1051
- if (gumPath) return [gumPath, "format", "-t", "markdown"];
1052
- return null;
1053
- }
1054
-
1055
- /**
1056
- * Normalizes a rendered markdown body: external renderers pad documents
1057
- * with a uniform leading margin and surrounding blank lines, which would
1058
- * shift the card layout and change plain-text bodies. Stripping the
1059
- * common indent plus the blank lines makes a plain body round-trip
1060
- * unchanged through its renderer.
1061
- */
1062
- export function cleanRenderedBody(out: string): string {
1063
- // Blank-line strip, not trim(): trim() would also eat the first body
1064
- // line's own leading whitespace and leave it flush while later lines
1065
- // stay indented.
1066
- const lines = out.replace(/^\n+/, "").replace(/\n+$/, "").split("\n");
1067
- let indent = Infinity;
1068
- for (const line of lines) {
1069
- if (!line.trim()) continue;
1070
- indent = Math.min(indent, line.length - line.trimStart().length);
1071
- }
1072
- if (!Number.isFinite(indent) || indent === 0) return lines.join("\n");
1073
- return lines.map((line) => line.slice(indent)).join("\n");
1257
+ export function formatChatTailJsonl(event: ChatTailEvent): string {
1258
+ return JSON.stringify(event);
1074
1259
  }
1075
1260
 
1076
1261
  // ---------------------------------------------------------------------------
@@ -1095,9 +1280,10 @@ export interface ChatPollerStore {
1095
1280
 
1096
1281
  export interface ChatPollerOptions {
1097
1282
  store: ChatPollerStore;
1098
- /** Session IDs this process has seen events for. Only sessions hosted by
1099
- * this process can be delivered to - the recipient's host is the single
1100
- * deliverer, by design. */
1283
+ /** Session IDs this process hosts: those it has seen events for plus the
1284
+ * `-s` startup session. Only sessions hosted by this process can be
1285
+ * delivered to - the recipient's host is the single deliverer, by
1286
+ * design. */
1101
1287
  hostedSessions: () => string[];
1102
1288
  /** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
1103
1289
  deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
@@ -1273,8 +1459,9 @@ export class ChatPoller {
1273
1459
  }
1274
1460
  }
1275
1461
 
1276
- /** True when a session row's last_seen is older than the staleness
1277
- * threshold. Used by chat_list to mark ghosts. */
1278
- export function isStale(row: ChatSessionRow, staleMinutes: number, now = Date.now()): boolean {
1279
- return now - Date.parse(row.last_seen) > staleMinutes * 60_000;
1462
+ /** True when a session row has missed two heartbeats (see CHAT_STALE_MS).
1463
+ * The one staleness rule: chat_list, chat_send's stale note, and the
1464
+ * broadcast skip all go through here so they can never disagree. */
1465
+ export function isStale(row: ChatSessionRow, now = Date.now()): boolean {
1466
+ return now - Date.parse(row.last_seen) > CHAT_STALE_MS;
1280
1467
  }