@jeffober/thatch 0.1.41 → 0.1.43

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
@@ -46,6 +46,13 @@ export function isoMinutesAgo(minutes: number): string {
46
46
  return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19) + "Z";
47
47
  }
48
48
 
49
+ /** Where the session's host process is running its checkout: "root" for the
50
+ * project's main checkout, "worktree" for a linked git worktree, null when
51
+ * undetected (e.g. an MCP host with no directory context). Displayed in
52
+ * the roster so sessions coordinating on a shared tree can tell who sits
53
+ * where. */
54
+ export type ChatWorktreeKind = "root" | "worktree" | null;
55
+
49
56
  export interface ChatSessionRow {
50
57
  session_id: string;
51
58
  name: string;
@@ -57,6 +64,7 @@ export interface ChatSessionRow {
57
64
  host_kind: ChatHostKind;
58
65
  registered_at: string;
59
66
  last_seen: string;
67
+ worktree: ChatWorktreeKind;
60
68
  }
61
69
 
62
70
  /** One message selected for wake-up delivery, with the sender's display name
@@ -77,17 +85,16 @@ export interface ChatInboxItem {
77
85
  created_at: string;
78
86
  }
79
87
 
80
- export type ChatResult = { ok: true; topic: string | null } | { ok: false; error: string };
81
-
82
88
  /**
83
89
  * 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.
90
+ * pure function of the host's own stable session identifier (Claude Code
91
+ * and Cursor pass one in their hook payloads), so identity survives the
92
+ * ephemeral conversations those hosts run. The underscore keeps the ID
93
+ * outside NAME_CHARSET, preserving the name-cannot-shadow-ID invariant
94
+ * that find() relies on.
88
95
  */
89
- export function mcpSessionID(name: string): string {
90
- const hash = Bun.SHA256.hash(name.toLowerCase(), "hex").slice(0, 12);
96
+ export function mcpSessionID(hostSessionID: string): string {
97
+ const hash = Bun.SHA256.hash(hostSessionID.toLowerCase(), "hex").slice(0, 12);
91
98
  return `mcp_${hash}`;
92
99
  }
93
100
 
@@ -99,20 +106,56 @@ export type ChatHostKind = "opencode" | "mcp";
99
106
  // Display names appear inside wake prompts and chat_list output, so they are
100
107
  // capped tight. Bodies are capped so a runaway sender cannot turn a nudge
101
108
  // into a context bomb; the reader fetches full content via chat_read anyway.
102
- const MAX_NAME_LEN = 40;
103
109
  const MAX_BODY_LEN = 10_000;
104
110
 
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.
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.
110
115
  export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
111
116
 
112
117
  // Topics are free text (unlike names) but must stay one line for the
113
118
  // chat_list roster, so whitespace runs collapse and the value is capped.
114
119
  const MAX_TOPIC_LEN = 80;
115
120
 
121
+ // Auto-registered sessions whose host has not heartbeat-ed them for this
122
+ // many days are pruned from the directory. Counters never decrement, so a
123
+ // pruned session's name is never reissued - pruning cannot create identity
124
+ // confusion, only roster silence.
125
+ export const CHAT_AUTO_TTL_DAYS = 7;
126
+
127
+ /**
128
+ * Slugifies a session title into a name base: lowercase, letter/number runs
129
+ * joined by single hyphens, capped at 32 characters (leaving room for the
130
+ * 6-character counter suffix). Returns null for titles with no usable
131
+ * characters.
132
+ *
133
+ * Deliberately NOT the same as ThatchDB.slugify (src/db.ts): chat names
134
+ * must never contain underscores (find() resolves name-first, and opencode
135
+ * session IDs contain underscores - a legal display name could shadow an
136
+ * ID), so non-alphanumeric runs all become hyphens and empty input returns
137
+ * null rather than a hash fallback.
138
+ */
139
+ export function slugifyTitle(title: string): string | null {
140
+ const slug = title
141
+ .toLowerCase()
142
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
143
+ .replace(/^-+|-+$/g, "")
144
+ .slice(0, 32)
145
+ .replace(/-+$/g, "");
146
+ return slug || null;
147
+ }
148
+
149
+ /**
150
+ * Whether a session title is opencode's pre-autotitle placeholder
151
+ * ("New session - <timestamp>" / "Child session - <timestamp>"). These must
152
+ * not become names or topics: they carry no information about the work, and
153
+ * the auto-titler usually replaces them within the first turn or two.
154
+ */
155
+ export function isDefaultSessionTitle(title: string): boolean {
156
+ return /^New session - /.test(title) || /^Child session - /.test(title);
157
+ }
158
+
116
159
  // Timing defaults, shared by the poller and the chat_list staleness display
117
160
  // so both agree on what "stale" means.
118
161
  export const CHAT_POLL_INTERVAL_MS = 30_000;
@@ -120,6 +163,19 @@ export const CHAT_STALE_MINUTES = 10;
120
163
  export const CHAT_RENUDGE_MINUTES = 15;
121
164
  export const CHAT_MAX_NUDGES_PER_HOUR = 6;
122
165
 
166
+ /** Human-readable age of an ISO timestamp: "45s ago", "5m ago", "3h ago",
167
+ * "2d ago". Shared by the CLI roster and the chat_list tool so both
168
+ * surfaces render the same last-check-in wording. */
169
+ export function humanAge(iso: string, now = Date.now()): string {
170
+ const seconds = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
171
+ if (Number.isNaN(seconds)) return "unknown age";
172
+ if (seconds < 60) return `${seconds}s ago`;
173
+ const minutes = Math.floor(seconds / 60);
174
+ if (minutes < 60) return `${minutes}m ago`;
175
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ago`;
176
+ return `${Math.floor(minutes / (24 * 60))}d ago`;
177
+ }
178
+
123
179
  /**
124
180
  * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
125
181
  * Database handle; the tables are created by ThatchDB's schema init.
@@ -131,96 +187,67 @@ export class ChatStore {
131
187
  this.#db = db;
132
188
  }
133
189
 
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.
190
+ /**
191
+ * Joins the directory, or refreshes an existing registration. Names are
192
+ * 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.
199
+ *
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.
139
204
  */
140
- assign(
205
+ register(
141
206
  sessionID: string,
142
207
  project: string | null,
143
208
  topic: string | null,
144
209
  kind: ChatHostKind,
145
- ): { ok: true; name: string; drawn: boolean; topic: string | null } | { ok: false; error: string } {
210
+ nameBase: string | null = null,
211
+ worktree: ChatWorktreeKind = null,
212
+ ): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
146
213
  const existing = this.#find(sessionID);
147
214
  if (existing) {
148
215
  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 };
216
+ // An explicit registration clears the leave tombstone for its own
217
+ // session ID: "I want back in" is the opposite of "I left". This is
218
+ // also what closes the in-flight race - an auto-register IIFE whose
219
+ // session was deleted mid-await finds the tombstone here and stops.
220
+ // The caller distinction is by ID: the idle path re-checks with the
221
+ // session's own ID, so a tombstone set by chat_unregister or
222
+ // session.deleted suppresses exactly that session.
223
+ const gate = this.#tombstoneGate(sessionID, kind);
224
+ if (gate) return gate;
225
+ return { ok: true, name: existing.name, topic: existing.topic };
167
226
  }
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 };
227
+ const gate = this.#tombstoneGate(sessionID, kind);
228
+ if (gate) return gate;
183
229
  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;
230
+ const base =
231
+ nameBase ??
232
+ slugifyTitle(CHAT_NAME_POOL[Math.floor(Math.random() * CHAT_NAME_POOL.length)]) ??
233
+ "session";
234
+ // The counter draw is atomic, but the name INSERT can still lose to a
235
+ // legacy row that already holds that exact name (custom claims from
236
+ // before names were assigned). Each retry bumps the counter, so the
237
+ // loop always makes forward progress and terminates.
238
+ for (let attempt = 0; attempt < 10; attempt++) {
239
+ const next = this.#bumpCounter(base);
240
+ const name = `${base}-${String(next).padStart(5, "0")}`;
241
+ const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
242
+ if (claimed.ok) return { ok: true, name, topic: cleanTopic };
243
+ if (claimed.reason === "already-registered") {
244
+ // A concurrent registration of the same session ID won the race;
245
+ // report its row rather than erroring.
246
+ const winner = this.#find(sessionID);
247
+ if (winner) return { ok: true, name: winner.name, topic: winner.topic };
216
248
  }
217
249
  }
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;
250
+ return { ok: false, error: "Could not assign a unique name after several attempts." };
224
251
  }
225
252
 
226
253
  /** Leaves the directory. Messages already sent to or from the session are
@@ -228,13 +255,109 @@ export class ChatStore {
228
255
  unregister(sessionID: string): boolean {
229
256
  const existing = this.#find(sessionID);
230
257
  if (!existing) return false;
231
- this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
258
+ this.#db.transaction(() => {
259
+ // Tombstone before delete: auto-registration keys on the row being
260
+ // absent, so without a marker the next idle event would silently
261
+ // re-register the session under a brand-new name and the user's
262
+ // "leave the directory" would last until the turn ended. The
263
+ // tombstone survives the delete and suppresses auto-registration
264
+ // until the session explicitly rejoins via chat_register (which
265
+ // clears it). Both leave paths come through here - chat_unregister
266
+ // AND session.deleted (a deleted TUI session is an exit too; without
267
+ // the tombstone an in-flight auto-register could resurrect it).
268
+ this.#db.run("INSERT OR REPLACE INTO chat_leave_tombstones (session_id, left_at) VALUES (?, ?)", [
269
+ sessionID,
270
+ nowIso(),
271
+ ]);
272
+ this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
273
+ })();
232
274
  return true;
233
275
  }
234
276
 
277
+ /** Whether the session explicitly left the directory and has not
278
+ * rejoined. Auto-registration consults this before inserting; manual
279
+ * registration (chat_register) clears the tombstone on join. */
280
+ hasLeaveTombstone(sessionID: string): boolean {
281
+ const row = this.#db
282
+ .query("SELECT 1 FROM chat_leave_tombstones WHERE session_id = ?")
283
+ .get(sessionID);
284
+ return row !== null && row !== undefined;
285
+ }
286
+
287
+ /** Clears the leave tombstone (chat_register rejoin path). */
288
+ clearLeaveTombstone(sessionID: string): void {
289
+ this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
290
+ }
291
+
292
+ /**
293
+ * Whether the leave tombstone for `sessionID` has been superseded: a
294
+ * newer registration exists in the same project (registered after the
295
+ * leave). The hook uses this to stop announcing a stale leave once the
296
+ * model has rejoined under a fresh identity - the tombstoned identity
297
+ * and the fresh one cannot be linked, but a newer join in the same
298
+ * project is evidence the conversation moved on.
299
+ */
300
+ leaveSuperseded(sessionID: string, project: string | null): boolean {
301
+ if (!project) return false;
302
+ const row = this.#db
303
+ .query(
304
+ `SELECT 1 FROM chat_leave_tombstones t
305
+ JOIN chat_sessions cs ON cs.project = ? AND cs.registered_at > t.left_at
306
+ WHERE t.session_id = ?
307
+ LIMIT 1`,
308
+ )
309
+ .get(project, sessionID);
310
+ return row !== null && row !== undefined;
311
+ }
312
+
313
+ /**
314
+ * Refreshes an auto-registered session's topic from its current title.
315
+ * opencode's auto-titler usually lands a real title within the first turn
316
+ * or two, so this converges the roster annotation to the actual work.
317
+ * Manual (legacy) rows are never touched - the topic column on those is
318
+ * user/model-set, not title-derived.
319
+ */
320
+ refreshAutoTopic(sessionID: string, title: string): void {
321
+ const clean = this.#cleanTopic(title);
322
+ if (!clean) return;
323
+ this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ? AND auto = 1 AND topic IS NOT ?", [
324
+ clean,
325
+ sessionID,
326
+ clean,
327
+ ]);
328
+ }
329
+
330
+ /**
331
+ * Removes auto-registered directory rows whose host has not heartbeat-ed
332
+ * them since `cutoff` (ISO), plus unread mail addressed to sessions no
333
+ * longer in the directory. The mail delete is bounded by the same
334
+ * cutoff: unread mail newer than the cutoff survives this sweep and is
335
+ * removed by a later one once it ages past the moving cutoff - the
336
+ * cutoff is a conservative bound, not an exact age match. Delivered/read
337
+ * history and mail from pruned senders to live recipients are kept -
338
+ * departed senders already render as unknown names. Counters are NOT
339
+ * touched: never decrementing is what makes reissued names impossible.
340
+ * Returns the number of directory rows pruned.
341
+ */
342
+ pruneStaleAuto(cutoff: string): number {
343
+ const result = this.#db.run("DELETE FROM chat_sessions WHERE auto = 1 AND last_seen < ?", [cutoff]);
344
+ this.#db.run(
345
+ "DELETE FROM chat_messages WHERE read_at IS NULL AND to_session NOT IN (SELECT session_id FROM chat_sessions) AND created_at < ?",
346
+ [cutoff],
347
+ );
348
+ // Tombstones age out too: they are leave markers, not identity records.
349
+ // A resumed opencode session whose tombstone expired simply
350
+ // auto-registers under a fresh name - the expiry window is the
351
+ // grace period for that. Growth is bounded (one row per distinct
352
+ // session), but the sweep keeps the table from growing monotonically
353
+ // forever.
354
+ this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
355
+ return result.changes;
356
+ }
357
+
235
358
  list(): ChatSessionRow[] {
236
359
  return (this.#db
237
- .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
360
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions ORDER BY name")
238
361
  .all() as any[]).map(rowFromSession);
239
362
  }
240
363
 
@@ -434,6 +557,8 @@ export class ChatStore {
434
557
  id: number;
435
558
  from: string | null;
436
559
  to: string | null;
560
+ fromTopic: string | null;
561
+ toTopic: string | null;
437
562
  viaBroadcast: boolean;
438
563
  body: string;
439
564
  created_at: string;
@@ -442,7 +567,7 @@ export class ChatStore {
442
567
  return (
443
568
  this.#db
444
569
  .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
570
+ `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
571
  FROM chat_messages m
447
572
  LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
448
573
  LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
@@ -453,6 +578,8 @@ export class ChatStore {
453
578
  id: r.id,
454
579
  from: renderChatParticipant(r.from_name, r.from_session),
455
580
  to: renderChatParticipant(r.to_name, r.to_session),
581
+ fromTopic: r.from_topic ?? null,
582
+ toTopic: r.to_topic ?? null,
456
583
  viaBroadcast: r.via_broadcast === 1,
457
584
  body: r.body,
458
585
  created_at: r.created_at,
@@ -504,14 +631,14 @@ export class ChatStore {
504
631
 
505
632
  #find(sessionID: string): ChatSessionRow | null {
506
633
  const row = this.#db
507
- .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
634
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE session_id = ?")
508
635
  .get(sessionID) as any;
509
636
  return row ? rowFromSession(row) : null;
510
637
  }
511
638
 
512
639
  #findByName(name: string): ChatSessionRow | null {
513
640
  const row = this.#db
514
- .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
641
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE name = ? COLLATE NOCASE")
515
642
  .get(name) as any;
516
643
  return row ? rowFromSession(row) : null;
517
644
  }
@@ -520,15 +647,6 @@ export class ChatStore {
520
647
  this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
521
648
  }
522
649
 
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
650
  /** Collapses whitespace runs (topics must stay one roster line) and caps
533
651
  * the length. The null/empty distinction is load-bearing: null means
534
652
  * "omit on re-register, keep the existing topic", while empty or
@@ -540,61 +658,84 @@ export class ChatStore {
540
658
  return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
541
659
  }
542
660
 
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]);
661
+ /**
662
+ * The leave-tombstone gate, applied on both register() paths (existing
663
+ * row and fresh mint). Policy: an opencode registration is the plugin's
664
+ * auto-registration, so a tombstone suppresses it outright - the in-
665
+ * flight IIFE whose session was deleted mid-await lands here and stops
666
+ * instead of resurrecting a dead session as a roster corpse. An MCP
667
+ * registration over the hook-anchored ID is a genuine rejoin (the
668
+ * derived session ID matches the tombstone), so the gate clears it and
669
+ * lets the registration proceed. Defense in depth: no current
670
+ * production caller reaches the MCP branch with a tombstoned ID (the
671
+ * tool's rejoin clears at its own layer first); UC-101 pins the DB
672
+ * contract. Returns the failure result when the gate refuses, or null
673
+ * to proceed.
674
+ */
675
+ #tombstoneGate(sessionID: string, kind: ChatHostKind): { ok: false; error: string } | null {
676
+ if (!this.hasLeaveTombstone(sessionID)) return null;
677
+ if (kind === "opencode") {
678
+ return { ok: false, error: "leave tombstone active" };
679
+ }
680
+ this.clearLeaveTombstone(sessionID);
681
+ return null;
548
682
  }
549
683
 
550
684
  /**
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.
685
+ * Atomically draws the next counter value for a name base: the INSERT
686
+ * ... ON CONFLICT upsert seeds the counter at 2 on first draw and
687
+ * increments it on every later draw, returning the drawn value in one
688
+ * statement - two processes racing the same base can never draw the same
689
+ * number. Counters only ever increment (prune included), which is what
690
+ * makes assigned names immortal.
557
691
  */
558
- #insertSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
692
+ #bumpCounter(base: string): number {
693
+ // The insert seeds the counter at 2 (1 is being handed out now) so the
694
+ // stored value always reads as the next unassigned number; the RETURNING
695
+ // hands out the drawn value. One statement, race-safe.
696
+ const row = this.#db
697
+ .query(
698
+ "INSERT INTO chat_name_counters (base, next) VALUES (?, 2) ON CONFLICT(base) DO UPDATE SET next = next + 1 RETURNING next - 1 AS drawn",
699
+ )
700
+ .get(base) as { drawn: number };
701
+ return row.drawn;
702
+ }
703
+
704
+ /**
705
+ * INSERTs a directory row as auto=1 (the only rows this class mints;
706
+ * auto=0 rows are legacy data). Constraint violations are translated
707
+ * into structured outcomes rather than raw SQLite errors: a UNIQUE hit
708
+ * on name means the counter draw collided with a legacy row holding that
709
+ * exact name (the caller bumps and retries), and a PK hit on session_id
710
+ * means the session row already exists (a concurrent registration won
711
+ * the race).
712
+ */
713
+ #insertSession(
714
+ sessionID: string,
715
+ name: string,
716
+ project: string | null,
717
+ topic: string | null,
718
+ kind: ChatHostKind,
719
+ worktree: ChatWorktreeKind,
720
+ ): { ok: true; topic: string | null } | { ok: false; reason: "name-taken" | "already-registered" } {
559
721
  try {
560
722
  this.#db.run(
561
- "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
562
- [sessionID, name, topic, project, kind, nowIso(), nowIso()],
723
+ "INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto, worktree) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
724
+ [sessionID, name, topic, project, kind, nowIso(), nowIso(), worktree ?? ""],
563
725
  );
564
726
  return { ok: true, topic };
565
727
  } 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;
728
+ if (!this.#isConstraintError(err)) throw err;
729
+ const msg = String((err as any)?.message ?? err);
730
+ return msg.includes("chat_sessions.name")
731
+ ? { ok: false, reason: "name-taken" }
732
+ : { ok: false, reason: "already-registered" };
574
733
  }
575
734
  }
576
735
 
577
736
  #isConstraintError(err: unknown): boolean {
578
737
  return String((err as any)?.code ?? err).includes("CONSTRAINT");
579
738
  }
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
739
  }
599
740
 
600
741
  function rowFromSession(r: any): ChatSessionRow {
@@ -608,6 +749,9 @@ function rowFromSession(r: any): ChatSessionRow {
608
749
  project: r.project,
609
750
  registered_at: r.registered_at,
610
751
  last_seen: r.last_seen,
752
+ // Pre-migration rows carry the empty-string default; read back as
753
+ // undetected so the roster omits the token instead of printing a blank.
754
+ worktree: r.worktree === "root" || r.worktree === "worktree" ? r.worktree : null,
611
755
  };
612
756
  }
613
757
 
@@ -629,14 +773,16 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
629
773
  // ---------------------------------------------------------------------------
630
774
 
631
775
  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 };
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 };
634
778
 
635
779
  /** One message row as the tail diff consumes it. */
636
780
  export interface ChatTailRow {
637
781
  id: number;
638
782
  from: string | null;
639
783
  to: string | null;
784
+ fromTopic: string | null;
785
+ toTopic: string | null;
640
786
  viaBroadcast: boolean;
641
787
  body: string;
642
788
  created_at: string;
@@ -665,6 +811,8 @@ export function chatTailDiff(
665
811
  timestamp: r.created_at,
666
812
  from: r.from ?? "unknown",
667
813
  to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
814
+ fromTopic: r.fromTopic,
815
+ toTopic: r.viaBroadcast ? null : r.toTopic,
668
816
  body: r.body,
669
817
  });
670
818
  } else {
@@ -675,6 +823,8 @@ export function chatTailDiff(
675
823
  timestamp: r.read_at,
676
824
  reader: r.to ?? "unknown",
677
825
  from: r.from ?? "unknown",
826
+ fromTopic: r.fromTopic,
827
+ toTopic: r.toTopic,
678
828
  body: r.body,
679
829
  });
680
830
  }
@@ -684,6 +834,122 @@ export function chatTailDiff(
684
834
  return { events, state: prev };
685
835
  }
686
836
 
837
+ /**
838
+ * The default number of backlog cards `thatch chat tail` renders before
839
+ * follow mode takes over. The message table is machine-scale but the
840
+ * history still grows forever, so an unbounded default would dump the
841
+ * entire database on every run.
842
+ */
843
+ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
844
+
845
+ /**
846
+ * Parses a tail time-bound value into epoch milliseconds, interpreted in
847
+ * the terminal's local timezone (the same clock the When headers print).
848
+ * Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
849
+ * space or a T between the date and the time. The component round-trip
850
+ * rejects impossible calendar values like 2026-09-31 and 25:99, which
851
+ * JavaScript's Date would otherwise silently normalize.
852
+ */
853
+ export function parseChatTimeBound(value: string, flag: string): number {
854
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?$/.exec(value.trim());
855
+ if (!m) {
856
+ throw new Error(`${flag} expects "YYYY-MM-DD" or "YYYY-MM-DD HH:MM", got "${value}"`);
857
+ }
858
+ const y = Number(m[1]);
859
+ const mo = Number(m[2]);
860
+ const d = Number(m[3]);
861
+ const h = Number(m[4] ?? 0);
862
+ const mi = Number(m[5] ?? 0);
863
+ const date = new Date(y, mo - 1, d, h, mi);
864
+ // Date normalizes out-of-range components (month 13, hour 25); the
865
+ // round-trip is the check that the value was real.
866
+ if (!roundTrip(date, y, mo, d, h, mi)) {
867
+ throw new Error(`${flag} is not a real date or time: "${value}"`);
868
+ }
869
+ return date.getTime();
870
+ }
871
+
872
+ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi: number): boolean {
873
+ return (
874
+ date.getFullYear() === y &&
875
+ date.getMonth() === mo - 1 &&
876
+ date.getDate() === d &&
877
+ date.getHours() === h &&
878
+ date.getMinutes() === mi
879
+ );
880
+ }
881
+
882
+ /**
883
+ * Everything the tail can narrow a feed by, all ANDed together. `matches`
884
+ * test the message body; `fromSubstr`/`toSubstr` are case-insensitive
885
+ * substrings of the rendered participant names (so a departed sender can
886
+ * 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.
892
+ *
893
+ * Name inputs are not stable across polls: they are re-resolved from the
894
+ * live directory on every feed snapshot, so an unregister or rename
895
+ * flips which rows match. chatTailBacklog's full-feed state seeding is
896
+ * what keeps such flips from resurfacing old rows as sent events; reads
897
+ * of rows whose name stopped matching simply go quiet.
898
+ */
899
+ export interface ChatTailFilter {
900
+ matches: RegExp[];
901
+ fromSubstr: string[];
902
+ toSubstr: string[];
903
+ sinceMs: number | null;
904
+ untilMs: number | null;
905
+ }
906
+
907
+ /** Filters a feed snapshot down to the rows the tail should consider. */
908
+ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFilter): Array<ChatTailRow> {
909
+ const froms = filter.fromSubstr.map((s) => s.toLowerCase());
910
+ const tos = filter.toSubstr.map((s) => s.toLowerCase());
911
+ return rows.filter((r) => {
912
+ const t = Date.parse(r.created_at);
913
+ if (filter.sinceMs !== null && t < filter.sinceMs) return false;
914
+ if (filter.untilMs !== null && t >= filter.untilMs) return false;
915
+ if (!filter.matches.every((re) => re.test(r.body))) return false;
916
+ if (froms.some((s) => !(r.from ?? "").toLowerCase().includes(s))) return false;
917
+ if (tos.some((s) => !(r.to ?? "").toLowerCase().includes(s))) return false;
918
+ return true;
919
+ });
920
+ }
921
+
922
+ /**
923
+ * Prepares the tail's first render. The diff state seeds from EVERY row in
924
+ * 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
927
+ * the first follow poll. Second, the name filters test JOIN-resolved
928
+ * participant names that can flip mid-follow (a peer unregisters and its
929
+ * history starts rendering as "unknown (... departed)", or it re-registers
930
+ * under a new name); seeding everything means an old row can never resurface
931
+ * 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.
935
+ *
936
+ * The caller must re-apply the same filter to every follow poll's feed
937
+ * BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
938
+ * returned here). Skipping the poll filter resurrects non-matching
939
+ * history; mutating the state between polls corrupts the read diff.
940
+ */
941
+ export function chatTailBacklog(
942
+ rows: Array<ChatTailRow>,
943
+ filter: ChatTailFilter,
944
+ limit: number | null,
945
+ ): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
946
+ const state = new Map(rows.map((r) => [r.id, r.read_at]));
947
+ const filtered = filterChatTailRows(rows, filter);
948
+ 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
+
687
953
  /** Renders one tail event in the CLI's line format. */
688
954
  export function formatChatTailEvent(event: ChatTailEvent): string {
689
955
  if (event.kind === "sent") {
@@ -693,6 +959,120 @@ export function formatChatTailEvent(event: ChatTailEvent): string {
693
959
  return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
694
960
  }
695
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);
1031
+ }
1032
+ return lines.join("\n");
1033
+ }
1034
+
1035
+ export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
1036
+
1037
+ /**
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.
1048
+ */
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");
1074
+ }
1075
+
696
1076
  // ---------------------------------------------------------------------------
697
1077
  // Poller
698
1078
  // ---------------------------------------------------------------------------
@@ -708,6 +1088,9 @@ export interface ChatPollerStore {
708
1088
  heartbeatChatSessions(sessionIDs: string[]): void;
709
1089
  pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
710
1090
  markChatDelivered(ids: number[]): void;
1091
+ /** Hourly TTL prune of auto-registered directory rows (and the mail that
1092
+ * can no longer reach a reader). Optional so test fakes stay minimal. */
1093
+ pruneStaleChatAuto?(cutoff: string): number;
711
1094
  }
712
1095
 
713
1096
  export interface ChatPollerOptions {
@@ -719,9 +1102,12 @@ export interface ChatPollerOptions {
719
1102
  /** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
720
1103
  deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
721
1104
  /** 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;
1105
+ * prompt right now (idle, not compacting). May be async - the plugin's
1106
+ * gate verifies against the server's live status, because the event-fed
1107
+ * map can be stale and a wake injected into a running turn is mid-turn
1108
+ * context injection. Undeliverable messages stay pending and retry on
1109
+ * later cycles. */
1110
+ canDeliver: (sessionID: string) => boolean | Promise<boolean>;
725
1111
  /** Poll interval. Default 30s - frequent enough that wake prompts feel
726
1112
  * prompt, cheap enough that the shared DB sees only a couple of light
727
1113
  * statements per cycle per process. */
@@ -748,6 +1134,9 @@ export class ChatPoller {
748
1134
  #delivering = false;
749
1135
  /** Per-recipient wake-prompt timestamps inside the last hour. */
750
1136
  #nudges = new Map<string, number[]>();
1137
+ /** Last TTL prune epoch ms. Auto-registered rows are pruned hourly, not
1138
+ * every cycle - the prune is a maintenance sweep, not delivery. */
1139
+ #lastPrune = 0;
751
1140
 
752
1141
  constructor(opts: ChatPollerOptions) {
753
1142
  this.#opts = {
@@ -801,6 +1190,18 @@ export class ChatPoller {
801
1190
  const hosted = this.#opts.hostedSessions();
802
1191
  this.#opts.store.heartbeatChatSessions(hosted);
803
1192
  await this.deliverPending();
1193
+ // Hourly sweep: drop auto-registered rows whose host died more than
1194
+ // CHAT_AUTO_TTL_DAYS ago, plus the unread mail addressed to sessions
1195
+ // no longer in the directory. Automatic registration makes one-shots
1196
+ // and crashed sessions routine, so without this the roster fills
1197
+ // with corpses; counters never decrement, so pruning cannot reissue
1198
+ // a name. The stamp lands before the call, so a throwing prune
1199
+ // retries on the next hourly sweep, not the next cycle.
1200
+ const now = Date.now();
1201
+ if (now - this.#lastPrune > 3_600_000 && this.#opts.store.pruneStaleChatAuto) {
1202
+ this.#lastPrune = now;
1203
+ this.#opts.store.pruneStaleChatAuto(isoMinutesAgo(CHAT_AUTO_TTL_DAYS * 24 * 60));
1204
+ }
804
1205
  } catch (err) {
805
1206
  console.error(`[thatch] chat poll failed: ${err}`);
806
1207
  } finally {
@@ -833,7 +1234,7 @@ export class ChatPoller {
833
1234
  }
834
1235
 
835
1236
  for (const [recipient, messages] of byRecipient) {
836
- if (!this.#opts.canDeliver(recipient)) continue;
1237
+ if (!(await this.#opts.canDeliver(recipient))) continue;
837
1238
  if (this.#nudgeBudget(recipient) <= 0) continue;
838
1239
  try {
839
1240
  // "unknown (id, departed)" matches chat_read's rendering of a