@jeffober/thatch 0.1.42 → 0.1.44

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
  /**
@@ -46,6 +48,13 @@ export function isoMinutesAgo(minutes: number): string {
46
48
  return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19) + "Z";
47
49
  }
48
50
 
51
+ /** Where the session's host process is running its checkout: "root" for the
52
+ * project's main checkout, "worktree" for a linked git worktree, null when
53
+ * undetected (e.g. an MCP host with no directory context). Displayed in
54
+ * the roster so sessions coordinating on a shared tree can tell who sits
55
+ * where. */
56
+ export type ChatWorktreeKind = "root" | "worktree" | null;
57
+
49
58
  export interface ChatSessionRow {
50
59
  session_id: string;
51
60
  name: string;
@@ -57,6 +66,7 @@ export interface ChatSessionRow {
57
66
  host_kind: ChatHostKind;
58
67
  registered_at: string;
59
68
  last_seen: string;
69
+ worktree: ChatWorktreeKind;
60
70
  }
61
71
 
62
72
  /** One message selected for wake-up delivery, with the sender's display name
@@ -90,8 +100,112 @@ export function mcpSessionID(hostSessionID: string): string {
90
100
  return `mcp_${hash}`;
91
101
  }
92
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
+
93
207
  /** Which kind of harness hosts a chat session: opencode rows are
94
- * poller-driven (liveness = process alive), mcp rows are turn-driven
208
+ * poller-driven (liveness = heartbeat fresh), mcp rows are turn-driven
95
209
  * (liveness = a turn ran recently; mail is read at the next prompt). */
96
210
  export type ChatHostKind = "opencode" | "mcp";
97
211
 
@@ -100,10 +214,11 @@ export type ChatHostKind = "opencode" | "mcp";
100
214
  // into a context bomb; the reader fetches full content via chat_read anyway.
101
215
  const MAX_BODY_LEN = 10_000;
102
216
 
103
- // Assigned names are lowercase slugs with a numeric counter suffix
104
- // ("fix-auth-bug-00001"), which satisfies NAME_CHARSET. The charset stays
105
- // exported for the pool conformance test. Slugs never contain underscores,
106
- // 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.
107
222
  export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
108
223
 
109
224
  // Topics are free text (unlike names) but must stay one line for the
@@ -114,7 +229,7 @@ const MAX_TOPIC_LEN = 80;
114
229
  // many days are pruned from the directory. Counters never decrement, so a
115
230
  // pruned session's name is never reissued - pruning cannot create identity
116
231
  // confusion, only roster silence.
117
- export const CHAT_AUTO_TTL_DAYS = 7;
232
+ const CHAT_AUTO_TTL_DAYS = 7;
118
233
 
119
234
  /**
120
235
  * Slugifies a session title into a name base: lowercase, letter/number runs
@@ -148,12 +263,94 @@ export function isDefaultSessionTitle(title: string): boolean {
148
263
  return /^New session - /.test(title) || /^Child session - /.test(title);
149
264
  }
150
265
 
151
- // Timing defaults, shared by the poller and the chat_list staleness display
152
- // 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.
153
268
  export const CHAT_POLL_INTERVAL_MS = 30_000;
154
- 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.
155
277
  export const CHAT_RENUDGE_MINUTES = 15;
156
- export const CHAT_MAX_NUDGES_PER_HOUR = 6;
278
+ const CHAT_MAX_NUDGES_PER_HOUR = 6;
279
+
280
+ /** Human-readable age of an ISO timestamp: "45s ago", "5m ago", "3h ago",
281
+ * "2d ago". Shared by the CLI roster and the chat_list tool so both
282
+ * surfaces render the same last-check-in wording. */
283
+ export function humanAge(iso: string, now = Date.now()): string {
284
+ const seconds = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
285
+ if (Number.isNaN(seconds)) return "unknown age";
286
+ if (seconds < 60) return `${seconds}s ago`;
287
+ const minutes = Math.floor(seconds / 60);
288
+ if (minutes < 60) return `${minutes}m ago`;
289
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ago`;
290
+ return `${Math.floor(minutes / (24 * 60))}d ago`;
291
+ }
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
+ /** Splits the roster for the two-section display: Active (wake-able or
308
+ * reachable - fresh opencode rows and every MCP row, which read mail at
309
+ * their next prompt) and Stale (opencode rows whose harness has stopped
310
+ * reporting; mail to them waits until they resume). */
311
+ export function splitChatRoster(rows: ChatSessionRow[], now = Date.now()): { active: ChatSessionRow[]; stale: ChatSessionRow[] } {
312
+ const active: ChatSessionRow[] = [];
313
+ const stale: ChatSessionRow[] = [];
314
+ for (const row of rows) {
315
+ (chatLiveness(row, now) === "stale" ? stale : active).push(row);
316
+ }
317
+ return { active, stale };
318
+ }
319
+
320
+ /**
321
+ * The wake-delivery gate, shared by the chat poller and the watcher
322
+ * registry: can this session be woken with a prompt right now? Three
323
+ * layers - the compacting set, the event-fed status map (cheap pre-filter
324
+ * for KNOWN busy/retry states), and the server's authoritative live status
325
+ * map. ABSENCE from the event-fed map is not evidence of busy: the `-s`
326
+ * startup session is hosted from init, before it has emitted any status
327
+ * event, so absence falls through to the live check. A gate that rejected
328
+ * on absence would leave that session mail-deaf - beating fine, never
329
+ * woken - until its first turn. The live
330
+ * map only carries ACTIVE sessions - the server deletes idle entries - so
331
+ * absent means idle there; busy/retry fails closed, as does an unreachable
332
+ * server (the mail stays pending and retries).
333
+ */
334
+ export function createWakeGate(deps: {
335
+ isCompacting: (sessionID: string) => boolean;
336
+ mappedStatus: (sessionID: string) => string | undefined;
337
+ fetchStatuses: () => Promise<Record<string, { type?: string } | undefined>>;
338
+ onStatusError?: (sessionID: string, err: unknown) => void;
339
+ }): (sessionID: string) => Promise<boolean> {
340
+ return async (sessionID: string) => {
341
+ if (deps.isCompacting(sessionID)) return false;
342
+ const mapped = deps.mappedStatus(sessionID);
343
+ if (mapped === "busy" || mapped === "retry") return false;
344
+ try {
345
+ const statuses = await deps.fetchStatuses();
346
+ const live = statuses[sessionID];
347
+ return !live || live.type === "idle";
348
+ } catch (err) {
349
+ deps.onStatusError?.(sessionID, err);
350
+ return false;
351
+ }
352
+ };
353
+ }
157
354
 
158
355
  /**
159
356
  * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
@@ -169,17 +366,20 @@ export class ChatStore {
169
366
  /**
170
367
  * Joins the directory, or refreshes an existing registration. Names are
171
368
  * assigned by the system, never claimed: a new session gets
172
- * "<slug>-<counter>" (e.g. "fix-auth-bug-00001"), where the slug comes
173
- * from the session title (or a pool-draw slug when there is none) and the
174
- * per-base counter only ever increments - so a name is minted exactly
175
- * once, machine-wide, and pruning an old session can never reissue its
176
- * name to someone else. Idempotent for an already-registered session: it
177
- * keeps its name and just refreshes liveness.
369
+ * "<slug>-<counter>" (e.g. "al-go-rithm-00001"), where the slug is a
370
+ * random pool name (chat-names.ts) and the per-base counter only ever
371
+ * increments - so a name is minted exactly once, machine-wide, and
372
+ * pruning an old session can never reissue its name to someone else. The
373
+ * session title never feeds the name; it rides along as the topic, so a
374
+ * placeholder or later-edited title cannot leave a misleading name
375
+ * behind. Idempotent for an already-registered session: it keeps its
376
+ * name and just refreshes liveness.
178
377
  *
179
- * `nameBase` is the slugified title for a new registration (null draws a
180
- * pool slug instead). `topic` is the live title for auto-registrations;
181
- * null leaves the topic unset. Every row minted here is marked auto=1 -
182
- * rows with auto=0 exist only as legacy data from the claimed-names era.
378
+ * `nameBase` overrides the pool draw with a fixed base (tests only; every
379
+ * production caller passes null). `topic` is the live title for
380
+ * auto-registrations; null leaves the topic unset. Every row minted here
381
+ * is marked auto=1 - rows with auto=0 exist only as legacy data from the
382
+ * claimed-names era.
183
383
  */
184
384
  register(
185
385
  sessionID: string,
@@ -187,9 +387,14 @@ export class ChatStore {
187
387
  topic: string | null,
188
388
  kind: ChatHostKind,
189
389
  nameBase: string | null = null,
190
- ): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
390
+ worktree: ChatWorktreeKind = null,
391
+ ): { ok: true; name: string; topic: string | null; created: boolean } | { ok: false; error: string } {
191
392
  const existing = this.#find(sessionID);
192
393
  if (existing) {
394
+ // Reclaim: registration is keyed by session id, so a session
395
+ // continued in a new harness process (`opencode -s <id>`) reclaims
396
+ // its row and name by re-registering. Touching last_seen is the
397
+ // whole of ownership: the harness that beats a row is its host.
193
398
  this.#touch(sessionID);
194
399
  // An explicit registration clears the leave tombstone for its own
195
400
  // session ID: "I want back in" is the opposite of "I left". This is
@@ -200,7 +405,7 @@ export class ChatStore {
200
405
  // session.deleted suppresses exactly that session.
201
406
  const gate = this.#tombstoneGate(sessionID, kind);
202
407
  if (gate) return gate;
203
- return { ok: true, name: existing.name, topic: existing.topic };
408
+ return { ok: true, name: existing.name, topic: existing.topic, created: false };
204
409
  }
205
410
  const gate = this.#tombstoneGate(sessionID, kind);
206
411
  if (gate) return gate;
@@ -216,13 +421,13 @@ export class ChatStore {
216
421
  for (let attempt = 0; attempt < 10; attempt++) {
217
422
  const next = this.#bumpCounter(base);
218
423
  const name = `${base}-${String(next).padStart(5, "0")}`;
219
- const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind);
220
- if (claimed.ok) return { ok: true, name, topic: cleanTopic };
424
+ const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
425
+ if (claimed.ok) return { ok: true, name, topic: cleanTopic, created: true };
221
426
  if (claimed.reason === "already-registered") {
222
427
  // A concurrent registration of the same session ID won the race;
223
428
  // report its row rather than erroring.
224
429
  const winner = this.#find(sessionID);
225
- if (winner) return { ok: true, name: winner.name, topic: winner.topic };
430
+ if (winner) return { ok: true, name: winner.name, topic: winner.topic, created: false };
226
431
  }
227
432
  }
228
433
  return { ok: false, error: "Could not assign a unique name after several attempts." };
@@ -335,7 +540,7 @@ export class ChatStore {
335
540
 
336
541
  list(): ChatSessionRow[] {
337
542
  return (this.#db
338
- .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
543
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions ORDER BY name")
339
544
  .all() as any[]).map(rowFromSession);
340
545
  }
341
546
 
@@ -411,7 +616,7 @@ export class ChatStore {
411
616
  // Staleness means "host process gone" only for opencode rows; an
412
617
  // mcp row between turns reads its mail at the next prompt, so it
413
618
  // always receives.
414
- if (row.host_kind === "opencode" && isStale(row, CHAT_STALE_MINUTES)) {
619
+ if (row.host_kind === "opencode" && isStale(row)) {
415
620
  skipped.push(row.name);
416
621
  continue;
417
622
  }
@@ -465,6 +670,65 @@ export class ChatStore {
465
670
  return row.n;
466
671
  }
467
672
 
673
+ /**
674
+ * Records (or refreshes) the hook-parent-process -> session mapping: the
675
+ * identity anchor that closes the caller-claimed `as` hole on Claude Code.
676
+ * Hooks receive the true session id from the host and run as children of
677
+ * the same Claude Code process that spawned the MCP server, so the server
678
+ * can resolve its own parent pid against this mapping - a fact the model
679
+ * cannot influence. Recorded only from payloads that carried Claude Code's
680
+ * `session_id`; Cursor's hooks come from a shared extension host with
681
+ * per-workspace MCP servers, so ppid is ambiguous there and `as` stays the
682
+ * identity source.
683
+ */
684
+ recordHostPid(ppid: number, sessionID: string): void {
685
+ this.#db.run(
686
+ `INSERT INTO chat_host_pids (ppid, session_id, seen_at) VALUES (?, ?, ?)
687
+ ON CONFLICT(ppid) DO UPDATE SET session_id = excluded.session_id, seen_at = excluded.seen_at`,
688
+ [ppid, sessionID, nowIso()],
689
+ );
690
+ }
691
+
692
+ /**
693
+ * Resolves a host process id to a chat session id, when a hook recorded
694
+ * the mapping recently and the session still exists in the directory.
695
+ * Freshness bounds the pid-reuse hazard: a dead host's pid can be
696
+ * reassigned by the OS, and a stale mapping must not hand the new
697
+ * occupant the old conversation's identity.
698
+ */
699
+ findSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
700
+ const cutoff = isoMinutesAgo(Math.ceil(maxAgeSeconds / 60));
701
+ const row = this.#db
702
+ .query(
703
+ `SELECT p.session_id FROM chat_host_pids p
704
+ JOIN chat_sessions s ON s.session_id = p.session_id
705
+ WHERE p.ppid = ? AND p.seen_at >= ?`,
706
+ )
707
+ .get(ppid, cutoff) as { session_id: string } | undefined;
708
+ return row?.session_id ?? null;
709
+ }
710
+
711
+ /**
712
+ * Migrates a chat identity onto a continued session id. Claude Code forks
713
+ * the session id on resume/fork while the conversation (name, mailbox,
714
+ * leave state) belongs to the human-level conversation, so the old row's
715
+ * key moves forward and everything referencing the old key follows:
716
+ * message sender/recipient columns (no foreign keys by design - the
717
+ * departed-sender degradation must not apply to a continued conversation),
718
+ * hook-recorded host-pid anchors, and leave tombstones. No-op when the
719
+ * old row does not exist.
720
+ */
721
+ continueSession(oldSessionID: string, newSessionID: string): void {
722
+ if (oldSessionID === newSessionID) return;
723
+ const row = this.#db.query("SELECT session_id FROM chat_sessions WHERE session_id = ?").get(oldSessionID);
724
+ if (!row) return;
725
+ this.#db.run("UPDATE chat_sessions SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
726
+ this.#db.run("UPDATE chat_messages SET from_session = ? WHERE from_session = ?", [newSessionID, oldSessionID]);
727
+ this.#db.run("UPDATE chat_messages SET to_session = ? WHERE to_session = ?", [newSessionID, oldSessionID]);
728
+ this.#db.run("UPDATE chat_host_pids SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
729
+ this.#db.run("UPDATE chat_leave_tombstones SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
730
+ }
731
+
468
732
  /**
469
733
  * The caller's mailbox summary for chat_status: whether a directory row
470
734
  * exists, and pending/total counts. Touches last_seen on use, which is
@@ -512,18 +776,15 @@ export class ChatStore {
512
776
  ).map((r) => ({ name: r.name, pending: r.pending }));
513
777
  }
514
778
 
515
- /** Refreshes last_seen for the given sessions. Sessions that never
516
- * registered are absent from chat_sessions, so the UPDATE is a no-op for
517
- * them - no filtering needed on the caller's side. */
779
+ /** Refreshes last_seen for the given sessions - the heartbeat that keeps
780
+ * them out of the stale section. Sessions that never registered are absent from
781
+ * chat_sessions, so the UPDATE is a no-op for them - no filtering needed
782
+ * on the caller's side. */
518
783
  heartbeat(sessionIDs: string[]): void {
519
784
  if (sessionIDs.length === 0) return;
520
785
  const marks = sessionIDs.map(() => "?").join(",");
521
- this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
522
- nowIso(),
523
- ...sessionIDs,
524
- ]);
786
+ this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [nowIso(), ...sessionIDs]);
525
787
  }
526
-
527
788
  /**
528
789
  * The full message feed for `thatch chat tail`: every row with sender and
529
790
  * recipient display names resolved (departed senders degrade to unknown),
@@ -609,14 +870,14 @@ export class ChatStore {
609
870
 
610
871
  #find(sessionID: string): ChatSessionRow | null {
611
872
  const row = this.#db
612
- .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
873
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE session_id = ?")
613
874
  .get(sessionID) as any;
614
875
  return row ? rowFromSession(row) : null;
615
876
  }
616
877
 
617
878
  #findByName(name: string): ChatSessionRow | null {
618
879
  const row = this.#db
619
- .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
880
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE name = ? COLLATE NOCASE")
620
881
  .get(name) as any;
621
882
  return row ? rowFromSession(row) : null;
622
883
  }
@@ -694,11 +955,12 @@ export class ChatStore {
694
955
  project: string | null,
695
956
  topic: string | null,
696
957
  kind: ChatHostKind,
958
+ worktree: ChatWorktreeKind,
697
959
  ): { ok: true; topic: string | null } | { ok: false; reason: "name-taken" | "already-registered" } {
698
960
  try {
699
961
  this.#db.run(
700
- "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
701
- [sessionID, name, topic, project, kind, nowIso(), nowIso()],
962
+ "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto, worktree) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
963
+ [sessionID, name, topic, project, kind, nowIso(), nowIso(), worktree ?? ""],
702
964
  );
703
965
  return { ok: true, topic };
704
966
  } catch (err) {
@@ -726,6 +988,9 @@ function rowFromSession(r: any): ChatSessionRow {
726
988
  project: r.project,
727
989
  registered_at: r.registered_at,
728
990
  last_seen: r.last_seen,
991
+ // Pre-migration rows carry the empty-string default; read back as
992
+ // undetected so the roster omits the token instead of printing a blank.
993
+ worktree: r.worktree === "root" || r.worktree === "worktree" ? r.worktree : null,
729
994
  };
730
995
  }
731
996
 
@@ -746,9 +1011,27 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
746
1011
  // Tail events (pure logic behind `thatch chat tail`)
747
1012
  // ---------------------------------------------------------------------------
748
1013
 
1014
+ /**
1015
+ * One line of the `thatch chat tail` event log. Sending a message and
1016
+ * reading it are two events, each on its own line, linked by `id` (the
1017
+ * message row id). Broadcast fan-out is one message row per recipient, so
1018
+ * a broadcast to N sessions is N sent events with `broadcast: true`, each
1019
+ * naming its real recipient and tracking its own read. Timestamps are the
1020
+ * database's ISO-8601 UTC strings, untouched.
1021
+ */
749
1022
  export type ChatTailEvent =
750
- | { kind: "sent"; timestamp: string; from: string; to: string; fromTopic: string | null; toTopic: string | null; body: string }
751
- | { kind: "read"; timestamp: string; reader: string; from: string; fromTopic: string | null; toTopic: string | null; body: string };
1023
+ | {
1024
+ event: "sent";
1025
+ at: string;
1026
+ id: number;
1027
+ from: string;
1028
+ from_topic: string | null;
1029
+ to: string;
1030
+ to_topic: string | null;
1031
+ broadcast: boolean;
1032
+ body: string;
1033
+ }
1034
+ | { event: "read"; at: string; id: number; reader: string; reader_topic: string | null; from: string; from_topic: string | null };
752
1035
 
753
1036
  /** One message row as the tail diff consumes it. */
754
1037
  export interface ChatTailRow {
@@ -781,25 +1064,27 @@ export function chatTailDiff(
781
1064
  const isNew = !prev.has(r.id);
782
1065
  if (isNew) {
783
1066
  events.push({
784
- kind: "sent",
785
- timestamp: r.created_at,
1067
+ event: "sent",
1068
+ at: r.created_at,
1069
+ id: r.id,
786
1070
  from: r.from ?? "unknown",
787
- to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
788
- fromTopic: r.fromTopic,
789
- toTopic: r.viaBroadcast ? null : r.toTopic,
1071
+ from_topic: r.fromTopic,
1072
+ to: r.to ?? "unknown",
1073
+ to_topic: r.toTopic,
1074
+ broadcast: r.viaBroadcast,
790
1075
  body: r.body,
791
1076
  });
792
1077
  } else {
793
1078
  const prevRead = prev.get(r.id) ?? null;
794
1079
  if (r.read_at !== null && prevRead === null) {
795
1080
  events.push({
796
- kind: "read",
797
- timestamp: r.read_at,
1081
+ event: "read",
1082
+ at: r.read_at,
1083
+ id: r.id,
798
1084
  reader: r.to ?? "unknown",
1085
+ reader_topic: r.toTopic,
799
1086
  from: r.from ?? "unknown",
800
- fromTopic: r.fromTopic,
801
- toTopic: r.toTopic,
802
- body: r.body,
1087
+ from_topic: r.fromTopic,
803
1088
  });
804
1089
  }
805
1090
  }
@@ -809,7 +1094,7 @@ export function chatTailDiff(
809
1094
  }
810
1095
 
811
1096
  /**
812
- * The default number of backlog cards `thatch chat tail` renders before
1097
+ * The default number of backlog messages `thatch chat tail` prints before
813
1098
  * follow mode takes over. The message table is machine-scale but the
814
1099
  * history still grows forever, so an unbounded default would dump the
815
1100
  * entire database on every run.
@@ -818,7 +1103,8 @@ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
818
1103
 
819
1104
  /**
820
1105
  * Parses a tail time-bound value into epoch milliseconds, interpreted in
821
- * the terminal's local timezone (the same clock the When headers print).
1106
+ * the terminal's local timezone (event timestamps print in UTC; the bounds
1107
+ * are typed by a human at a terminal, so they read as local time).
822
1108
  * Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
823
1109
  * space or a T between the date and the time. The component round-trip
824
1110
  * rejects impossible calendar values like 2026-09-31 and 25:99, which
@@ -858,11 +1144,10 @@ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi:
858
1144
  * test the message body; `fromSubstr`/`toSubstr` are case-insensitive
859
1145
  * substrings of the rendered participant names (so a departed sender can
860
1146
  * only match as "unknown (...)", the string the reader actually sees).
861
- * One deliberate asymmetry: broadcast rows keep the real recipient in
862
- * `to`, so `--to <realname>` matches fan-out rows even though their cards
863
- * say "broadcast", and `--to broadcast` matches nothing. `sinceMs`/
864
- * `untilMs` bound created_at in a since-inclusive, until-exclusive
865
- * window. An empty field is no constraint.
1147
+ * Broadcast fan-out rows carry their real recipient in `to`, so `--to
1148
+ * <name>` matches them like any direct message. `sinceMs`/`untilMs`
1149
+ * bound created_at in a since-inclusive, until-exclusive window. An empty
1150
+ * field is no constraint.
866
1151
  *
867
1152
  * Name inputs are not stable across polls: they are re-resolved from the
868
1153
  * live directory on every feed snapshot, so an unregister or rename
@@ -896,16 +1181,18 @@ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFil
896
1181
  /**
897
1182
  * Prepares the tail's first render. The diff state seeds from EVERY row in
898
1183
  * the feed - filtered or not, rendered or not - for two reasons. First,
899
- * the limit hides cards, not history: a state seeded only from the
900
- * rendered slice would re-emit the elided history as new sent events on
1184
+ * the limit hides messages, not history: a state seeded only from the
1185
+ * printed slice would re-emit the elided history as new sent events on
901
1186
  * the first follow poll. Second, the name filters test JOIN-resolved
902
1187
  * participant names that can flip mid-follow (a peer unregisters and its
903
1188
  * history starts rendering as "unknown (... departed)", or it re-registers
904
1189
  * under a new name); seeding everything means an old row can never resurface
905
1190
  * as a sent event no matter how its rendered name changes. The last
906
- * `limit` filtered rows (null = all) shape into sent events via
907
- * chatTailDiff, so card rendering keeps a single source. `elided` counts
908
- * matching rows the limit hid, for the CLI's summary line.
1191
+ * `limit` filtered rows (null = all) shape into sent events, plus a read
1192
+ * event for each row already read, via chatTailDiff, so event shaping
1193
+ * keeps a single source. `shown` and `elided` count MESSAGES (not
1194
+ * events: a shown message contributes one or two lines) for the CLI's
1195
+ * summary line.
909
1196
  *
910
1197
  * The caller must re-apply the same filter to every follow poll's feed
911
1198
  * BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
@@ -916,93 +1203,33 @@ export function chatTailBacklog(
916
1203
  rows: Array<ChatTailRow>,
917
1204
  filter: ChatTailFilter,
918
1205
  limit: number | null,
919
- ): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
1206
+ ): { events: ChatTailEvent[]; state: Map<number, string | null>; shown: number; elided: number } {
920
1207
  const state = new Map(rows.map((r) => [r.id, r.read_at]));
921
1208
  const filtered = filterChatTailRows(rows, filter);
922
1209
  const shown = limit === null ? filtered : filtered.slice(-limit);
923
- const events = chatTailDiff(new Map(), shown).events;
924
- return { events, state, elided: filtered.length - shown.length };
925
- }
926
-
927
- /** Renders one tail event in the CLI's line format. */
928
- export function formatChatTailEvent(event: ChatTailEvent): string {
929
- if (event.kind === "sent") {
930
- return `[${event.timestamp}] ${event.from} -> ${event.to}: ${event.body}`;
1210
+ // A snapshot has the full history of each shown message: its send, and
1211
+ // its read if read_at is set. Emit both, in time order, so `--once`
1212
+ // prints the same event log a follow would have accumulated. Reads of
1213
+ // messages the limit hid stay hidden with them.
1214
+ const events: ChatTailEvent[] = [];
1215
+ for (const r of shown) {
1216
+ events.push(...chatTailDiff(new Map(), [r]).events);
1217
+ if (r.read_at !== null) events.push(...chatTailDiff(new Map([[r.id, null]]), [r]).events);
931
1218
  }
932
- const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
933
- return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
934
- }
935
-
936
- /** "2026-09-12 19:46 MT" - the UTC timestamp converted to the terminal's
937
- * local timezone (abbreviation from the locale, fallback "UTC"), one line
938
- * for the card header. */
939
- function localWhen(timestamp: string): string {
940
- const date = new Date(timestamp);
941
- if (Number.isNaN(date.getTime())) return timestamp;
942
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
943
- const pad = (n: number) => String(n).padStart(2, "0");
944
- return (
945
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
946
- `${pad(date.getHours())}:${pad(date.getMinutes())} ${tz}`
947
- );
1219
+ events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.event === b.event ? 0 : a.event === "sent" ? -1 : 1));
1220
+ return { events, state, shown: shown.length, elided: filtered.length - shown.length };
948
1221
  }
949
1222
 
950
1223
  /**
951
- * Renders one tail event as a chat card: header block (From / To / When),
952
- * blank line, full body, then a separator line. This is the human format
953
- * for `thatch chat tail` - the single-line formatChatTailEvent remains for
954
- * tests and compact contexts. Read events use the same card shape with the
955
- * reader in the From slot; broadcast events show "broadcast" as the
956
- * recipient. The separator is between cards, so the caller joins cards
957
- * with it and never gets a trailing rule.
1224
+ * Renders one tail event as a JSON line. Plain JSON.stringify of the event
1225
+ * object: no ANSI, no markdown rendering, no local-time conversion. The
1226
+ * tail is a log, and a log is for grep and jq; a terminal reader who wants
1227
+ * color pipes through their own tool.
958
1228
  */
959
- // ANSI styling for the tail cards: black-on-green field-label chips, green
960
- // values, dim for the timestamps and the "read" marker, and a drawn rule
961
- // for the separator. Bare ESC sequences rather than a color library - the
962
- // tail is a terminal surface, and the codes are trivial.
963
- const ANSI = {
964
- // Label chips are padded to a fixed width (5 + padding = 7 columns) so
965
- // every line's content starts at the same column - To's 2-char label
966
- // misaligned against 4-char labels without this.
967
- labelBg: (s: string) => `\x1b[30;42m ${s.padEnd(5)} \x1b[0m`,
968
- valueFg: (s: string) => `\x1b[32m${s}\x1b[0m`, // green (field values)
969
- dim: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim ("read" marker)
970
- rule: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim (the drawn separator)
971
- // Session-of-origin annotation: italic + bright-black (muted gray).
972
- originLabel: (s: string) => `\x1b[3;90m<${s}>\x1b[0m`,
973
- };
974
-
975
- const CARD_INDENT = ""; // cards sit flush with the separator (no extra indent)
976
-
977
- export function formatChatTailCard(event: ChatTailEvent): string {
978
- const lines: string[] = [];
979
- const when = ANSI.dim(localWhen(event.timestamp));
980
- // The session-of-origin annotation: the participant's registered topic,
981
- // italic + muted bright-black, after the name. Omitted for participants
982
- // without a topic (and for the broadcast pseudo-recipient, whose topic
983
- // is null by construction).
984
- const origin = (topic: string | null) => (topic ? ` ${ANSI.originLabel(topic)}` : "");
985
- const chip = (label: string, value: string, topic: string | null) =>
986
- `${CARD_INDENT}${ANSI.labelBg(label)} ${ANSI.valueFg(value)}${origin(topic)}`;
987
- if (event.kind === "sent") {
988
- lines.push(chip("From", event.from, event.fromTopic));
989
- lines.push(chip("To", event.to, event.toTopic));
990
- lines.push(chip("When", when, null));
991
- lines.push("");
992
- lines.push(event.body);
993
- } else {
994
- const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
995
- lines.push(chip("From", event.reader, event.toTopic));
996
- lines.push(`${CARD_INDENT}${ANSI.dim("read")} ${ANSI.valueFg(event.from)}${origin(event.fromTopic)}`);
997
- lines.push(chip("When", when, null));
998
- lines.push("");
999
- lines.push(clipped);
1000
- }
1001
- return lines.join("\n");
1229
+ export function formatChatTailJsonl(event: ChatTailEvent): string {
1230
+ return JSON.stringify(event);
1002
1231
  }
1003
1232
 
1004
- export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
1005
-
1006
1233
  // ---------------------------------------------------------------------------
1007
1234
  // Poller
1008
1235
  // ---------------------------------------------------------------------------
@@ -1025,9 +1252,10 @@ export interface ChatPollerStore {
1025
1252
 
1026
1253
  export interface ChatPollerOptions {
1027
1254
  store: ChatPollerStore;
1028
- /** Session IDs this process has seen events for. Only sessions hosted by
1029
- * this process can be delivered to - the recipient's host is the single
1030
- * deliverer, by design. */
1255
+ /** Session IDs this process hosts: those it has seen events for plus the
1256
+ * `-s` startup session. Only sessions hosted by this process can be
1257
+ * delivered to - the recipient's host is the single deliverer, by
1258
+ * design. */
1031
1259
  hostedSessions: () => string[];
1032
1260
  /** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
1033
1261
  deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
@@ -1203,8 +1431,9 @@ export class ChatPoller {
1203
1431
  }
1204
1432
  }
1205
1433
 
1206
- /** True when a session row's last_seen is older than the staleness
1207
- * threshold. Used by chat_list to mark ghosts. */
1208
- export function isStale(row: ChatSessionRow, staleMinutes: number, now = Date.now()): boolean {
1209
- return now - Date.parse(row.last_seen) > staleMinutes * 60_000;
1434
+ /** True when a session row has missed two heartbeats (see CHAT_STALE_MS).
1435
+ * The one staleness rule: chat_list, chat_send's stale note, and the
1436
+ * broadcast skip all go through here so they can never disagree. */
1437
+ export function isStale(row: ChatSessionRow, now = Date.now()): boolean {
1438
+ return now - Date.parse(row.last_seen) > CHAT_STALE_MS;
1210
1439
  }