@jeffober/thatch 0.1.41 → 0.1.42

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
@@ -77,17 +77,16 @@ export interface ChatInboxItem {
77
77
  created_at: string;
78
78
  }
79
79
 
80
- export type ChatResult = { ok: true; topic: string | null } | { ok: false; error: string };
81
-
82
80
  /**
83
81
  * Derives the synthetic session ID for an MCP-host chat registration: a
84
- * pure function of the claimed name, so identity survives the ephemeral
85
- * sessions those hosts run (a later conversation claiming the same name
86
- * finds the same row). The underscore keeps the ID outside NAME_CHARSET,
87
- * preserving the name-cannot-shadow-ID invariant that find() relies on.
82
+ * pure function of the host's own stable session identifier (Claude Code
83
+ * and Cursor pass one in their hook payloads), so identity survives the
84
+ * ephemeral conversations those hosts run. The underscore keeps the ID
85
+ * outside NAME_CHARSET, preserving the name-cannot-shadow-ID invariant
86
+ * that find() relies on.
88
87
  */
89
- export function mcpSessionID(name: string): string {
90
- const hash = Bun.SHA256.hash(name.toLowerCase(), "hex").slice(0, 12);
88
+ export function mcpSessionID(hostSessionID: string): string {
89
+ const hash = Bun.SHA256.hash(hostSessionID.toLowerCase(), "hex").slice(0, 12);
91
90
  return `mcp_${hash}`;
92
91
  }
93
92
 
@@ -99,20 +98,56 @@ export type ChatHostKind = "opencode" | "mcp";
99
98
  // Display names appear inside wake prompts and chat_list output, so they are
100
99
  // capped tight. Bodies are capped so a runaway sender cannot turn a nudge
101
100
  // into a context bomb; the reader fetches full content via chat_read anyway.
102
- const MAX_NAME_LEN = 40;
103
101
  const MAX_BODY_LEN = 10_000;
104
102
 
105
- // Custom names share the pool's charset: letters, numbers, spaces,
106
- // apostrophes, hyphens, periods. Parens would truncate the transcript
107
- // echo's name parse; newlines or tabs would break chat_list's one-line
108
- // roster format. Exported so the pool conformance test can pin that every
109
- // baked-in name satisfies it.
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.
110
107
  export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
111
108
 
112
109
  // Topics are free text (unlike names) but must stay one line for the
113
110
  // chat_list roster, so whitespace runs collapse and the value is capped.
114
111
  const MAX_TOPIC_LEN = 80;
115
112
 
113
+ // Auto-registered sessions whose host has not heartbeat-ed them for this
114
+ // many days are pruned from the directory. Counters never decrement, so a
115
+ // pruned session's name is never reissued - pruning cannot create identity
116
+ // confusion, only roster silence.
117
+ export const CHAT_AUTO_TTL_DAYS = 7;
118
+
119
+ /**
120
+ * Slugifies a session title into a name base: lowercase, letter/number runs
121
+ * joined by single hyphens, capped at 32 characters (leaving room for the
122
+ * 6-character counter suffix). Returns null for titles with no usable
123
+ * characters.
124
+ *
125
+ * Deliberately NOT the same as ThatchDB.slugify (src/db.ts): chat names
126
+ * must never contain underscores (find() resolves name-first, and opencode
127
+ * session IDs contain underscores - a legal display name could shadow an
128
+ * ID), so non-alphanumeric runs all become hyphens and empty input returns
129
+ * null rather than a hash fallback.
130
+ */
131
+ export function slugifyTitle(title: string): string | null {
132
+ const slug = title
133
+ .toLowerCase()
134
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
135
+ .replace(/^-+|-+$/g, "")
136
+ .slice(0, 32)
137
+ .replace(/-+$/g, "");
138
+ return slug || null;
139
+ }
140
+
141
+ /**
142
+ * Whether a session title is opencode's pre-autotitle placeholder
143
+ * ("New session - <timestamp>" / "Child session - <timestamp>"). These must
144
+ * not become names or topics: they carry no information about the work, and
145
+ * the auto-titler usually replaces them within the first turn or two.
146
+ */
147
+ export function isDefaultSessionTitle(title: string): boolean {
148
+ return /^New session - /.test(title) || /^Child session - /.test(title);
149
+ }
150
+
116
151
  // Timing defaults, shared by the poller and the chat_list staleness display
117
152
  // so both agree on what "stale" means.
118
153
  export const CHAT_POLL_INTERVAL_MS = 30_000;
@@ -131,96 +166,66 @@ export class ChatStore {
131
166
  this.#db = db;
132
167
  }
133
168
 
134
- /** Joins the directory with a pool name: picks uniformly at random from the
135
- * names no other session has claimed (case-insensitively). Idempotent for
136
- * an already-registered session - it keeps its current name (drawn:
137
- * false). Fails when every pool name is taken, or when concurrent
138
- * registrations win every draw attempt - see the redraw loop below.
169
+ /**
170
+ * Joins the directory, or refreshes an existing registration. Names are
171
+ * 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.
178
+ *
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.
139
183
  */
140
- assign(
184
+ register(
141
185
  sessionID: string,
142
186
  project: string | null,
143
187
  topic: string | null,
144
188
  kind: ChatHostKind,
145
- ): { ok: true; name: string; drawn: boolean; topic: string | null } | { ok: false; error: string } {
189
+ nameBase: string | null = null,
190
+ ): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
146
191
  const existing = this.#find(sessionID);
147
192
  if (existing) {
148
193
  this.#touch(sessionID);
149
- const cleaned = this.#cleanTopic(topic);
150
- this.#maybeUpdateTopic(sessionID, existing.topic, cleaned);
151
- return { ok: true, name: existing.name, drawn: false, topic: cleaned ?? existing.topic };
152
- }
153
- // Two processes can snapshot the same free list and draw the same name;
154
- // the loser's INSERT hits the constraint. Redrawing from the names
155
- // still free (the taken set is re-read each attempt) keeps the pool
156
- // path's cannot-collide promise even under concurrent registration.
157
- for (let attempt = 0; attempt < 5; attempt++) {
158
- const taken = new Set(this.list().map((r) => r.name.toLowerCase()));
159
- const free = CHAT_NAME_POOL.filter((n) => !taken.has(n.toLowerCase()));
160
- if (free.length === 0) {
161
- return { ok: false, error: "Name pool exhausted - pass a custom name." };
162
- }
163
- const name = free[Math.floor(Math.random() * free.length)];
164
- const cleanTopic = this.#cleanTopic(topic);
165
- const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind);
166
- if (claimed.ok) return { ok: true, name, drawn: true, topic: cleanTopic };
194
+ // An explicit registration clears the leave tombstone for its own
195
+ // session ID: "I want back in" is the opposite of "I left". This is
196
+ // also what closes the in-flight race - an auto-register IIFE whose
197
+ // session was deleted mid-await finds the tombstone here and stops.
198
+ // The caller distinction is by ID: the idle path re-checks with the
199
+ // session's own ID, so a tombstone set by chat_unregister or
200
+ // session.deleted suppresses exactly that session.
201
+ const gate = this.#tombstoneGate(sessionID, kind);
202
+ if (gate) return gate;
203
+ return { ok: true, name: existing.name, topic: existing.topic };
167
204
  }
168
- return { ok: false, error: "Could not claim a pool name after several attempts - pass a custom name." };
169
- }
170
-
171
- /**
172
- * Joins the directory, or refreshes an existing registration. Re-registering
173
- * with the same session ID renames the session (including changing only
174
- * the casing of its own name); the new name must not be claimed by a
175
- * different session. Uniqueness is case-insensitive ("Landru" and "landru"
176
- * are the same name), so lookups and claims agree no matter what casing the
177
- * model or user types. Both paths stamp last_seen.
178
- */
179
- register(sessionID: string, rawName: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
180
- const name = rawName.trim();
181
- const invalid = this.#invalidName(name);
182
- if (invalid) return { ok: false, error: invalid };
205
+ const gate = this.#tombstoneGate(sessionID, kind);
206
+ if (gate) return gate;
183
207
  const cleanTopic = this.#cleanTopic(topic);
184
- const existing = this.#find(sessionID);
185
- if (existing) {
186
- if (existing.name === name) {
187
- this.#touch(sessionID);
188
- this.#maybeUpdateTopic(sessionID, existing.topic, cleanTopic);
189
- // The kept topic: omitted keeps existing, "" clears to null (the
190
- // read-side normalization this return path mirrors).
191
- return { ok: true, topic: cleanTopic ?? existing.topic };
192
- }
193
- // A NOCASE hit is only a collision when it belongs to a different
194
- // session - otherwise this is the caller recasing its own name.
195
- const clash = this.#findByName(name);
196
- if (clash && clash.session_id !== sessionID) {
197
- return this.#nameTaken(name);
198
- }
199
- try {
200
- // An omitted topic (null) keeps the existing one, same as the
201
- // same-name path below - a rename is a re-register, and the tool
202
- // treats name and topic updates as independent.
203
- const nextTopic = cleanTopic ?? existing.topic;
204
- this.#db.run("UPDATE chat_sessions SET name = ?, topic = ?, last_seen = ? WHERE session_id = ?", [
205
- name,
206
- nextTopic,
207
- nowIso(),
208
- sessionID,
209
- ]);
210
- return { ok: true, topic: nextTopic };
211
- } catch (err) {
212
- if (this.#isConstraintError(err)) {
213
- return this.#nameTaken(name);
214
- }
215
- throw err;
208
+ const base =
209
+ nameBase ??
210
+ slugifyTitle(CHAT_NAME_POOL[Math.floor(Math.random() * CHAT_NAME_POOL.length)]) ??
211
+ "session";
212
+ // The counter draw is atomic, but the name INSERT can still lose to a
213
+ // legacy row that already holds that exact name (custom claims from
214
+ // before names were assigned). Each retry bumps the counter, so the
215
+ // loop always makes forward progress and terminates.
216
+ for (let attempt = 0; attempt < 10; attempt++) {
217
+ const next = this.#bumpCounter(base);
218
+ 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 };
221
+ if (claimed.reason === "already-registered") {
222
+ // A concurrent registration of the same session ID won the race;
223
+ // report its row rather than erroring.
224
+ const winner = this.#find(sessionID);
225
+ if (winner) return { ok: true, name: winner.name, topic: winner.topic };
216
226
  }
217
227
  }
218
- if (this.#findByName(name)) {
219
- return this.#nameTaken(name);
220
- }
221
- const inserted = this.#insertSession(sessionID, name, project, cleanTopic, kind);
222
- if (inserted.ok) return { ok: true, topic: cleanTopic };
223
- return inserted;
228
+ return { ok: false, error: "Could not assign a unique name after several attempts." };
224
229
  }
225
230
 
226
231
  /** Leaves the directory. Messages already sent to or from the session are
@@ -228,10 +233,106 @@ export class ChatStore {
228
233
  unregister(sessionID: string): boolean {
229
234
  const existing = this.#find(sessionID);
230
235
  if (!existing) return false;
231
- this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
236
+ this.#db.transaction(() => {
237
+ // Tombstone before delete: auto-registration keys on the row being
238
+ // absent, so without a marker the next idle event would silently
239
+ // re-register the session under a brand-new name and the user's
240
+ // "leave the directory" would last until the turn ended. The
241
+ // tombstone survives the delete and suppresses auto-registration
242
+ // until the session explicitly rejoins via chat_register (which
243
+ // clears it). Both leave paths come through here - chat_unregister
244
+ // AND session.deleted (a deleted TUI session is an exit too; without
245
+ // the tombstone an in-flight auto-register could resurrect it).
246
+ this.#db.run("INSERT OR REPLACE INTO chat_leave_tombstones (session_id, left_at) VALUES (?, ?)", [
247
+ sessionID,
248
+ nowIso(),
249
+ ]);
250
+ this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
251
+ })();
232
252
  return true;
233
253
  }
234
254
 
255
+ /** Whether the session explicitly left the directory and has not
256
+ * rejoined. Auto-registration consults this before inserting; manual
257
+ * registration (chat_register) clears the tombstone on join. */
258
+ hasLeaveTombstone(sessionID: string): boolean {
259
+ const row = this.#db
260
+ .query("SELECT 1 FROM chat_leave_tombstones WHERE session_id = ?")
261
+ .get(sessionID);
262
+ return row !== null && row !== undefined;
263
+ }
264
+
265
+ /** Clears the leave tombstone (chat_register rejoin path). */
266
+ clearLeaveTombstone(sessionID: string): void {
267
+ this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
268
+ }
269
+
270
+ /**
271
+ * Whether the leave tombstone for `sessionID` has been superseded: a
272
+ * newer registration exists in the same project (registered after the
273
+ * leave). The hook uses this to stop announcing a stale leave once the
274
+ * model has rejoined under a fresh identity - the tombstoned identity
275
+ * and the fresh one cannot be linked, but a newer join in the same
276
+ * project is evidence the conversation moved on.
277
+ */
278
+ leaveSuperseded(sessionID: string, project: string | null): boolean {
279
+ if (!project) return false;
280
+ const row = this.#db
281
+ .query(
282
+ `SELECT 1 FROM chat_leave_tombstones t
283
+ JOIN chat_sessions cs ON cs.project = ? AND cs.registered_at > t.left_at
284
+ WHERE t.session_id = ?
285
+ LIMIT 1`,
286
+ )
287
+ .get(project, sessionID);
288
+ return row !== null && row !== undefined;
289
+ }
290
+
291
+ /**
292
+ * Refreshes an auto-registered session's topic from its current title.
293
+ * opencode's auto-titler usually lands a real title within the first turn
294
+ * or two, so this converges the roster annotation to the actual work.
295
+ * Manual (legacy) rows are never touched - the topic column on those is
296
+ * user/model-set, not title-derived.
297
+ */
298
+ refreshAutoTopic(sessionID: string, title: string): void {
299
+ const clean = this.#cleanTopic(title);
300
+ if (!clean) return;
301
+ this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ? AND auto = 1 AND topic IS NOT ?", [
302
+ clean,
303
+ sessionID,
304
+ clean,
305
+ ]);
306
+ }
307
+
308
+ /**
309
+ * Removes auto-registered directory rows whose host has not heartbeat-ed
310
+ * them since `cutoff` (ISO), plus unread mail addressed to sessions no
311
+ * longer in the directory. The mail delete is bounded by the same
312
+ * cutoff: unread mail newer than the cutoff survives this sweep and is
313
+ * removed by a later one once it ages past the moving cutoff - the
314
+ * cutoff is a conservative bound, not an exact age match. Delivered/read
315
+ * history and mail from pruned senders to live recipients are kept -
316
+ * departed senders already render as unknown names. Counters are NOT
317
+ * touched: never decrementing is what makes reissued names impossible.
318
+ * Returns the number of directory rows pruned.
319
+ */
320
+ pruneStaleAuto(cutoff: string): number {
321
+ const result = this.#db.run("DELETE FROM chat_sessions WHERE auto = 1 AND last_seen < ?", [cutoff]);
322
+ this.#db.run(
323
+ "DELETE FROM chat_messages WHERE read_at IS NULL AND to_session NOT IN (SELECT session_id FROM chat_sessions) AND created_at < ?",
324
+ [cutoff],
325
+ );
326
+ // Tombstones age out too: they are leave markers, not identity records.
327
+ // A resumed opencode session whose tombstone expired simply
328
+ // auto-registers under a fresh name - the expiry window is the
329
+ // grace period for that. Growth is bounded (one row per distinct
330
+ // session), but the sweep keeps the table from growing monotonically
331
+ // forever.
332
+ this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
333
+ return result.changes;
334
+ }
335
+
235
336
  list(): ChatSessionRow[] {
236
337
  return (this.#db
237
338
  .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
@@ -434,6 +535,8 @@ export class ChatStore {
434
535
  id: number;
435
536
  from: string | null;
436
537
  to: string | null;
538
+ fromTopic: string | null;
539
+ toTopic: string | null;
437
540
  viaBroadcast: boolean;
438
541
  body: string;
439
542
  created_at: string;
@@ -442,7 +545,7 @@ export class ChatStore {
442
545
  return (
443
546
  this.#db
444
547
  .query(
445
- `SELECT m.id, m.from_session, m.to_session, fs.name AS from_name, ts.name AS to_name, m.via_broadcast, m.body, m.created_at, m.read_at
548
+ `SELECT m.id, m.from_session, m.to_session, fs.name AS from_name, ts.name AS to_name, fs.topic AS from_topic, ts.topic AS to_topic, m.via_broadcast, m.body, m.created_at, m.read_at
446
549
  FROM chat_messages m
447
550
  LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
448
551
  LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
@@ -453,6 +556,8 @@ export class ChatStore {
453
556
  id: r.id,
454
557
  from: renderChatParticipant(r.from_name, r.from_session),
455
558
  to: renderChatParticipant(r.to_name, r.to_session),
559
+ fromTopic: r.from_topic ?? null,
560
+ toTopic: r.to_topic ?? null,
456
561
  viaBroadcast: r.via_broadcast === 1,
457
562
  body: r.body,
458
563
  created_at: r.created_at,
@@ -520,15 +625,6 @@ export class ChatStore {
520
625
  this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
521
626
  }
522
627
 
523
- #invalidName(name: string): string | null {
524
- if (!name) return "Name cannot be empty.";
525
- if (name.length > MAX_NAME_LEN) return `Name too long (max ${MAX_NAME_LEN} characters).`;
526
- if (!NAME_CHARSET.test(name)) {
527
- return "Names may contain letters, numbers, spaces, apostrophes, hyphens, and periods.";
528
- }
529
- return null;
530
- }
531
-
532
628
  /** Collapses whitespace runs (topics must stay one roster line) and caps
533
629
  * the length. The null/empty distinction is load-bearing: null means
534
630
  * "omit on re-register, keep the existing topic", while empty or
@@ -540,61 +636,83 @@ export class ChatStore {
540
636
  return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
541
637
  }
542
638
 
543
- /** Refreshes the topic on re-registration when it changed. Omitting the
544
- * topic (null) keeps the existing one; passing an empty topic clears it. */
545
- #maybeUpdateTopic(sessionID: string, current: string | null, next: string | null): void {
546
- if (next === null || next === current) return;
547
- this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ?", [next, sessionID]);
639
+ /**
640
+ * The leave-tombstone gate, applied on both register() paths (existing
641
+ * row and fresh mint). Policy: an opencode registration is the plugin's
642
+ * auto-registration, so a tombstone suppresses it outright - the in-
643
+ * flight IIFE whose session was deleted mid-await lands here and stops
644
+ * instead of resurrecting a dead session as a roster corpse. An MCP
645
+ * registration over the hook-anchored ID is a genuine rejoin (the
646
+ * derived session ID matches the tombstone), so the gate clears it and
647
+ * lets the registration proceed. Defense in depth: no current
648
+ * production caller reaches the MCP branch with a tombstoned ID (the
649
+ * tool's rejoin clears at its own layer first); UC-101 pins the DB
650
+ * contract. Returns the failure result when the gate refuses, or null
651
+ * to proceed.
652
+ */
653
+ #tombstoneGate(sessionID: string, kind: ChatHostKind): { ok: false; error: string } | null {
654
+ if (!this.hasLeaveTombstone(sessionID)) return null;
655
+ if (kind === "opencode") {
656
+ return { ok: false, error: "leave tombstone active" };
657
+ }
658
+ this.clearLeaveTombstone(sessionID);
659
+ return null;
660
+ }
661
+
662
+ /**
663
+ * Atomically draws the next counter value for a name base: the INSERT
664
+ * ... ON CONFLICT upsert seeds the counter at 2 on first draw and
665
+ * increments it on every later draw, returning the drawn value in one
666
+ * statement - two processes racing the same base can never draw the same
667
+ * number. Counters only ever increment (prune included), which is what
668
+ * makes assigned names immortal.
669
+ */
670
+ #bumpCounter(base: string): number {
671
+ // The insert seeds the counter at 2 (1 is being handed out now) so the
672
+ // stored value always reads as the next unassigned number; the RETURNING
673
+ // hands out the drawn value. One statement, race-safe.
674
+ const row = this.#db
675
+ .query(
676
+ "INSERT INTO chat_name_counters (base, next) VALUES (?, 2) ON CONFLICT(base) DO UPDATE SET next = next + 1 RETURNING next - 1 AS drawn",
677
+ )
678
+ .get(base) as { drawn: number };
679
+ return row.drawn;
548
680
  }
549
681
 
550
682
  /**
551
- * INSERTs a directory row, translating a UNIQUE-constraint violation into
552
- * the friendly taken message. The pre-checks are advisory only against
553
- * other processes: two sessions claiming one free name in the same
554
- * instant both pass the check, and the loser's INSERT must not escape the
555
- * { ok, error } contract as a raw SQLite error. Same pattern as
556
- * remember()'s slug-collision handling in db.ts.
683
+ * INSERTs a directory row as auto=1 (the only rows this class mints;
684
+ * auto=0 rows are legacy data). Constraint violations are translated
685
+ * into structured outcomes rather than raw SQLite errors: a UNIQUE hit
686
+ * on name means the counter draw collided with a legacy row holding that
687
+ * exact name (the caller bumps and retries), and a PK hit on session_id
688
+ * means the session row already exists (a concurrent registration won
689
+ * the race).
557
690
  */
558
- #insertSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
691
+ #insertSession(
692
+ sessionID: string,
693
+ name: string,
694
+ project: string | null,
695
+ topic: string | null,
696
+ kind: ChatHostKind,
697
+ ): { ok: true; topic: string | null } | { ok: false; reason: "name-taken" | "already-registered" } {
559
698
  try {
560
699
  this.#db.run(
561
- "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
700
+ "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
562
701
  [sessionID, name, topic, project, kind, nowIso(), nowIso()],
563
702
  );
564
703
  return { ok: true, topic };
565
704
  } catch (err) {
566
- // The two constraints fail differently: a UNIQUE hit on name means
567
- // someone else claimed it; a PK hit on session_id means this session
568
- // is somehow registered twice, which is an identity event, not a
569
- // naming one.
570
- if (this.#isConstraintError(err)) {
571
- return this.#alreadyRegistered();
572
- }
573
- throw err;
705
+ if (!this.#isConstraintError(err)) throw err;
706
+ const msg = String((err as any)?.message ?? err);
707
+ return msg.includes("chat_sessions.name")
708
+ ? { ok: false, reason: "name-taken" }
709
+ : { ok: false, reason: "already-registered" };
574
710
  }
575
711
  }
576
712
 
577
713
  #isConstraintError(err: unknown): boolean {
578
714
  return String((err as any)?.code ?? err).includes("CONSTRAINT");
579
715
  }
580
-
581
- /** The one taken-message every claim path returns, so a rewording cannot
582
- * drift between the pre-checks and the race catches. Points at the
583
- * guaranteed-success recovery (a pool draw) like every other failure
584
- * path in this feature. */
585
- #nameTaken(name: string): ChatResult {
586
- return {
587
- ok: false,
588
- error: `Name "${name}" is taken by another session - pick another name, or omit the name to draw one from the pool.`,
589
- };
590
- }
591
-
592
- /** The one already-registered message for a session-ID PRIMARY KEY hit -
593
- * a constraint collision that is not a taken name (the caller's own
594
- * session row already exists under a different identity path). */
595
- #alreadyRegistered(): ChatResult {
596
- return { ok: false, error: "You are already registered - call chat_list to see your current name." };
597
- }
598
716
  }
599
717
 
600
718
  function rowFromSession(r: any): ChatSessionRow {
@@ -629,14 +747,16 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
629
747
  // ---------------------------------------------------------------------------
630
748
 
631
749
  export type ChatTailEvent =
632
- | { kind: "sent"; timestamp: string; from: string; to: string; body: string }
633
- | { kind: "read"; timestamp: string; reader: string; from: string; body: string };
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 };
634
752
 
635
753
  /** One message row as the tail diff consumes it. */
636
754
  export interface ChatTailRow {
637
755
  id: number;
638
756
  from: string | null;
639
757
  to: string | null;
758
+ fromTopic: string | null;
759
+ toTopic: string | null;
640
760
  viaBroadcast: boolean;
641
761
  body: string;
642
762
  created_at: string;
@@ -665,6 +785,8 @@ export function chatTailDiff(
665
785
  timestamp: r.created_at,
666
786
  from: r.from ?? "unknown",
667
787
  to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
788
+ fromTopic: r.fromTopic,
789
+ toTopic: r.viaBroadcast ? null : r.toTopic,
668
790
  body: r.body,
669
791
  });
670
792
  } else {
@@ -675,6 +797,8 @@ export function chatTailDiff(
675
797
  timestamp: r.read_at,
676
798
  reader: r.to ?? "unknown",
677
799
  from: r.from ?? "unknown",
800
+ fromTopic: r.fromTopic,
801
+ toTopic: r.toTopic,
678
802
  body: r.body,
679
803
  });
680
804
  }
@@ -684,6 +808,122 @@ export function chatTailDiff(
684
808
  return { events, state: prev };
685
809
  }
686
810
 
811
+ /**
812
+ * The default number of backlog cards `thatch chat tail` renders before
813
+ * follow mode takes over. The message table is machine-scale but the
814
+ * history still grows forever, so an unbounded default would dump the
815
+ * entire database on every run.
816
+ */
817
+ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
818
+
819
+ /**
820
+ * Parses a tail time-bound value into epoch milliseconds, interpreted in
821
+ * the terminal's local timezone (the same clock the When headers print).
822
+ * Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
823
+ * space or a T between the date and the time. The component round-trip
824
+ * rejects impossible calendar values like 2026-09-31 and 25:99, which
825
+ * JavaScript's Date would otherwise silently normalize.
826
+ */
827
+ export function parseChatTimeBound(value: string, flag: string): number {
828
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?$/.exec(value.trim());
829
+ if (!m) {
830
+ throw new Error(`${flag} expects "YYYY-MM-DD" or "YYYY-MM-DD HH:MM", got "${value}"`);
831
+ }
832
+ const y = Number(m[1]);
833
+ const mo = Number(m[2]);
834
+ const d = Number(m[3]);
835
+ const h = Number(m[4] ?? 0);
836
+ const mi = Number(m[5] ?? 0);
837
+ const date = new Date(y, mo - 1, d, h, mi);
838
+ // Date normalizes out-of-range components (month 13, hour 25); the
839
+ // round-trip is the check that the value was real.
840
+ if (!roundTrip(date, y, mo, d, h, mi)) {
841
+ throw new Error(`${flag} is not a real date or time: "${value}"`);
842
+ }
843
+ return date.getTime();
844
+ }
845
+
846
+ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi: number): boolean {
847
+ return (
848
+ date.getFullYear() === y &&
849
+ date.getMonth() === mo - 1 &&
850
+ date.getDate() === d &&
851
+ date.getHours() === h &&
852
+ date.getMinutes() === mi
853
+ );
854
+ }
855
+
856
+ /**
857
+ * Everything the tail can narrow a feed by, all ANDed together. `matches`
858
+ * test the message body; `fromSubstr`/`toSubstr` are case-insensitive
859
+ * substrings of the rendered participant names (so a departed sender can
860
+ * 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.
866
+ *
867
+ * Name inputs are not stable across polls: they are re-resolved from the
868
+ * live directory on every feed snapshot, so an unregister or rename
869
+ * flips which rows match. chatTailBacklog's full-feed state seeding is
870
+ * what keeps such flips from resurfacing old rows as sent events; reads
871
+ * of rows whose name stopped matching simply go quiet.
872
+ */
873
+ export interface ChatTailFilter {
874
+ matches: RegExp[];
875
+ fromSubstr: string[];
876
+ toSubstr: string[];
877
+ sinceMs: number | null;
878
+ untilMs: number | null;
879
+ }
880
+
881
+ /** Filters a feed snapshot down to the rows the tail should consider. */
882
+ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFilter): Array<ChatTailRow> {
883
+ const froms = filter.fromSubstr.map((s) => s.toLowerCase());
884
+ const tos = filter.toSubstr.map((s) => s.toLowerCase());
885
+ return rows.filter((r) => {
886
+ const t = Date.parse(r.created_at);
887
+ if (filter.sinceMs !== null && t < filter.sinceMs) return false;
888
+ if (filter.untilMs !== null && t >= filter.untilMs) return false;
889
+ if (!filter.matches.every((re) => re.test(r.body))) return false;
890
+ if (froms.some((s) => !(r.from ?? "").toLowerCase().includes(s))) return false;
891
+ if (tos.some((s) => !(r.to ?? "").toLowerCase().includes(s))) return false;
892
+ return true;
893
+ });
894
+ }
895
+
896
+ /**
897
+ * Prepares the tail's first render. The diff state seeds from EVERY row in
898
+ * 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
901
+ * the first follow poll. Second, the name filters test JOIN-resolved
902
+ * participant names that can flip mid-follow (a peer unregisters and its
903
+ * history starts rendering as "unknown (... departed)", or it re-registers
904
+ * under a new name); seeding everything means an old row can never resurface
905
+ * 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.
909
+ *
910
+ * The caller must re-apply the same filter to every follow poll's feed
911
+ * BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
912
+ * returned here). Skipping the poll filter resurrects non-matching
913
+ * history; mutating the state between polls corrupts the read diff.
914
+ */
915
+ export function chatTailBacklog(
916
+ rows: Array<ChatTailRow>,
917
+ filter: ChatTailFilter,
918
+ limit: number | null,
919
+ ): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
920
+ const state = new Map(rows.map((r) => [r.id, r.read_at]));
921
+ const filtered = filterChatTailRows(rows, filter);
922
+ 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
+
687
927
  /** Renders one tail event in the CLI's line format. */
688
928
  export function formatChatTailEvent(event: ChatTailEvent): string {
689
929
  if (event.kind === "sent") {
@@ -693,6 +933,76 @@ export function formatChatTailEvent(event: ChatTailEvent): string {
693
933
  return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
694
934
  }
695
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
+ );
948
+ }
949
+
950
+ /**
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.
958
+ */
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");
1002
+ }
1003
+
1004
+ export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
1005
+
696
1006
  // ---------------------------------------------------------------------------
697
1007
  // Poller
698
1008
  // ---------------------------------------------------------------------------
@@ -708,6 +1018,9 @@ export interface ChatPollerStore {
708
1018
  heartbeatChatSessions(sessionIDs: string[]): void;
709
1019
  pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
710
1020
  markChatDelivered(ids: number[]): void;
1021
+ /** Hourly TTL prune of auto-registered directory rows (and the mail that
1022
+ * can no longer reach a reader). Optional so test fakes stay minimal. */
1023
+ pruneStaleChatAuto?(cutoff: string): number;
711
1024
  }
712
1025
 
713
1026
  export interface ChatPollerOptions {
@@ -719,9 +1032,12 @@ export interface ChatPollerOptions {
719
1032
  /** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
720
1033
  deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
721
1034
  /** Gate for delivery: true only when the session can accept a proactive
722
- * prompt right now (idle, not compacting). Undeliverable messages stay
723
- * pending and retry on later cycles. */
724
- canDeliver: (sessionID: string) => boolean;
1035
+ * prompt right now (idle, not compacting). May be async - the plugin's
1036
+ * gate verifies against the server's live status, because the event-fed
1037
+ * map can be stale and a wake injected into a running turn is mid-turn
1038
+ * context injection. Undeliverable messages stay pending and retry on
1039
+ * later cycles. */
1040
+ canDeliver: (sessionID: string) => boolean | Promise<boolean>;
725
1041
  /** Poll interval. Default 30s - frequent enough that wake prompts feel
726
1042
  * prompt, cheap enough that the shared DB sees only a couple of light
727
1043
  * statements per cycle per process. */
@@ -748,6 +1064,9 @@ export class ChatPoller {
748
1064
  #delivering = false;
749
1065
  /** Per-recipient wake-prompt timestamps inside the last hour. */
750
1066
  #nudges = new Map<string, number[]>();
1067
+ /** Last TTL prune epoch ms. Auto-registered rows are pruned hourly, not
1068
+ * every cycle - the prune is a maintenance sweep, not delivery. */
1069
+ #lastPrune = 0;
751
1070
 
752
1071
  constructor(opts: ChatPollerOptions) {
753
1072
  this.#opts = {
@@ -801,6 +1120,18 @@ export class ChatPoller {
801
1120
  const hosted = this.#opts.hostedSessions();
802
1121
  this.#opts.store.heartbeatChatSessions(hosted);
803
1122
  await this.deliverPending();
1123
+ // Hourly sweep: drop auto-registered rows whose host died more than
1124
+ // CHAT_AUTO_TTL_DAYS ago, plus the unread mail addressed to sessions
1125
+ // no longer in the directory. Automatic registration makes one-shots
1126
+ // and crashed sessions routine, so without this the roster fills
1127
+ // with corpses; counters never decrement, so pruning cannot reissue
1128
+ // a name. The stamp lands before the call, so a throwing prune
1129
+ // retries on the next hourly sweep, not the next cycle.
1130
+ const now = Date.now();
1131
+ if (now - this.#lastPrune > 3_600_000 && this.#opts.store.pruneStaleChatAuto) {
1132
+ this.#lastPrune = now;
1133
+ this.#opts.store.pruneStaleChatAuto(isoMinutesAgo(CHAT_AUTO_TTL_DAYS * 24 * 60));
1134
+ }
804
1135
  } catch (err) {
805
1136
  console.error(`[thatch] chat poll failed: ${err}`);
806
1137
  } finally {
@@ -833,7 +1164,7 @@ export class ChatPoller {
833
1164
  }
834
1165
 
835
1166
  for (const [recipient, messages] of byRecipient) {
836
- if (!this.#opts.canDeliver(recipient)) continue;
1167
+ if (!(await this.#opts.canDeliver(recipient))) continue;
837
1168
  if (this.#nudgeBudget(recipient) <= 0) continue;
838
1169
  try {
839
1170
  // "unknown (id, departed)" matches chat_read's rendering of a