@jeffober/thatch 0.1.40 → 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 ADDED
@@ -0,0 +1,1210 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { CHAT_NAME_POOL } from "./chat-names";
3
+
4
+ /**
5
+ * Cross-session chat: opt-in messaging between live opencode sessions on one
6
+ * machine, routed through the shared thatch.db.
7
+ *
8
+ * The design splits cleanly into two halves. ChatStore is the shared state:
9
+ * the session directory and the message inbox live in SQLite so any opencode
10
+ * process on the machine can read and write them. ChatPoller is the local
11
+ * delivery: each process polls the inbox for messages addressed to sessions
12
+ * it hosts and wake-prompts them through its own SDK client. A sender never
13
+ * prompts a session in another process - only the recipient's host delivers.
14
+ * This crosses process boundaries while preserving the watcher rationale's core
15
+ * property: no process ever prompts a session it does not host, so there is
16
+ * exactly one possible deliverer per message and no cross-process double
17
+ * delivery. Within a host process, delivery is at-least-once: a crash between
18
+ * the wake prompt and its delivered_at stamp re-nudges the same mail after
19
+ * restart, which is the safe direction for prompts that only point at the
20
+ * inbox.
21
+ *
22
+ * Liveness is heartbeat-based. A crashed process fires no session.deleted
23
+ * event, so the only reliable offline signal is a stale last_seen value: each
24
+ * host process refreshes last_seen for its registered sessions on every poll
25
+ * cycle, and chat_list marks anything older than the staleness threshold as
26
+ * stale instead of hiding it.
27
+ *
28
+ * Loop control has a soft and a hard brake. The prompt guidance tells agents
29
+ * not to auto-reply reflexively (two polite agents acknowledging each other
30
+ * forever is an infinite wake cycle); the poller's per-recipient nudge cap
31
+ * stops it mechanically regardless of what the models decide to do.
32
+ */
33
+
34
+ /** ISO-8601 UTC timestamp with second resolution, matching the house
35
+ * strftime('%Y-%m-%dT%H:%M:%SZ','now') default so string comparison between
36
+ * SQLite-generated and JS-generated timestamps stays consistent. */
37
+ export function nowIso(): string {
38
+ return isoMinutesAgo(0);
39
+ }
40
+
41
+ /** An ISO timestamp `minutes` in the past, in the same second-resolution
42
+ * format. The poller passes this shape as the re-nudge cutoff; tests use
43
+ * it for the same purpose, so the format expression lives in exactly one
44
+ * place. */
45
+ export function isoMinutesAgo(minutes: number): string {
46
+ return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19) + "Z";
47
+ }
48
+
49
+ export interface ChatSessionRow {
50
+ session_id: string;
51
+ name: string;
52
+ topic: string | null;
53
+ project: string | null;
54
+ /** Which harness hosts this session: "opencode" (poller-driven) or "mcp"
55
+ * (turn-driven). Determines how staleness is interpreted and whether
56
+ * wake delivery is possible. */
57
+ host_kind: ChatHostKind;
58
+ registered_at: string;
59
+ last_seen: string;
60
+ }
61
+
62
+ /** One message selected for wake-up delivery, with the sender's display name
63
+ * resolved for the nudge text. */
64
+ export interface ChatNotificationRow {
65
+ id: number;
66
+ to_session: string;
67
+ from_session: string;
68
+ from_name: string | null;
69
+ }
70
+
71
+ /** A message as returned to the reading agent. */
72
+ export interface ChatInboxItem {
73
+ id: number;
74
+ from_session: string;
75
+ from_name: string | null;
76
+ body: string;
77
+ created_at: string;
78
+ }
79
+
80
+ /**
81
+ * Derives the synthetic session ID for an MCP-host chat registration: a
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.
87
+ */
88
+ export function mcpSessionID(hostSessionID: string): string {
89
+ const hash = Bun.SHA256.hash(hostSessionID.toLowerCase(), "hex").slice(0, 12);
90
+ return `mcp_${hash}`;
91
+ }
92
+
93
+ /** Which kind of harness hosts a chat session: opencode rows are
94
+ * poller-driven (liveness = process alive), mcp rows are turn-driven
95
+ * (liveness = a turn ran recently; mail is read at the next prompt). */
96
+ export type ChatHostKind = "opencode" | "mcp";
97
+
98
+ // Display names appear inside wake prompts and chat_list output, so they are
99
+ // capped tight. Bodies are capped so a runaway sender cannot turn a nudge
100
+ // into a context bomb; the reader fetches full content via chat_read anyway.
101
+ const MAX_BODY_LEN = 10_000;
102
+
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.
107
+ export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
108
+
109
+ // Topics are free text (unlike names) but must stay one line for the
110
+ // chat_list roster, so whitespace runs collapse and the value is capped.
111
+ const MAX_TOPIC_LEN = 80;
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
+
151
+ // Timing defaults, shared by the poller and the chat_list staleness display
152
+ // so both agree on what "stale" means.
153
+ export const CHAT_POLL_INTERVAL_MS = 30_000;
154
+ export const CHAT_STALE_MINUTES = 10;
155
+ export const CHAT_RENUDGE_MINUTES = 15;
156
+ export const CHAT_MAX_NUDGES_PER_HOUR = 6;
157
+
158
+ /**
159
+ * SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
160
+ * Database handle; the tables are created by ThatchDB's schema init.
161
+ */
162
+ export class ChatStore {
163
+ #db: Database;
164
+
165
+ constructor(db: Database) {
166
+ this.#db = db;
167
+ }
168
+
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.
183
+ */
184
+ register(
185
+ sessionID: string,
186
+ project: string | null,
187
+ topic: string | null,
188
+ kind: ChatHostKind,
189
+ nameBase: string | null = null,
190
+ ): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
191
+ const existing = this.#find(sessionID);
192
+ if (existing) {
193
+ this.#touch(sessionID);
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 };
204
+ }
205
+ const gate = this.#tombstoneGate(sessionID, kind);
206
+ if (gate) return gate;
207
+ const cleanTopic = this.#cleanTopic(topic);
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 };
226
+ }
227
+ }
228
+ return { ok: false, error: "Could not assign a unique name after several attempts." };
229
+ }
230
+
231
+ /** Leaves the directory. Messages already sent to or from the session are
232
+ * kept as history; departed senders show as unknown names to readers. */
233
+ unregister(sessionID: string): boolean {
234
+ const existing = this.#find(sessionID);
235
+ if (!existing) return false;
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
+ })();
252
+ return true;
253
+ }
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
+
336
+ list(): ChatSessionRow[] {
337
+ return (this.#db
338
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
339
+ .all() as any[]).map(rowFromSession);
340
+ }
341
+
342
+ /** Resolves a recipient by display name (case-insensitive) or session ID.
343
+ * Name-first is safe only because opencode session IDs contain
344
+ * underscores and NAME_CHARSET excludes them, so no legal display name
345
+ * can shadow an ID; if either format ever changes, resolve IDs first. */
346
+ find(nameOrID: string): ChatSessionRow | null {
347
+ return this.#findByName(nameOrID) ?? this.#find(nameOrID);
348
+ }
349
+
350
+ /**
351
+ * Posts a message. Both endpoints must be registered; a session cannot
352
+ * message itself (nothing is coordinated: the message would sit unread
353
+ * until the sender drains its own inbox, and the wake prompt would be the
354
+ * sender nudging itself). Returns the resolved recipient so callers never
355
+ * need a second lookup - a re-lookup could race a concurrent unregister
356
+ * and fail after the message already landed.
357
+ */
358
+ send(
359
+ fromSession: string,
360
+ toNameOrID: string,
361
+ body: string,
362
+ ): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
363
+ const trimmed = body.trim();
364
+ if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
365
+ if (trimmed.length > MAX_BODY_LEN) {
366
+ return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
367
+ }
368
+ const sender = this.#find(fromSession);
369
+ if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
370
+ const recipient = this.find(toNameOrID);
371
+ if (!recipient) {
372
+ return { ok: false, error: `No registered session named "${toNameOrID}". Use chat_list to see who is available.` };
373
+ }
374
+ if (recipient.session_id === fromSession) {
375
+ return { ok: false, error: "You cannot message yourself." };
376
+ }
377
+ this.#db.run(
378
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
379
+ [fromSession, recipient.session_id, trimmed, nowIso()],
380
+ );
381
+ return { ok: true, recipient: { session_id: recipient.session_id, name: recipient.name } };
382
+ }
383
+
384
+ /**
385
+ * Posts a message to every other registered session at once. Stale
386
+ * sessions are skipped, not messaged - a host process that has stopped
387
+ * heartbeat-ing will never read the mail, and a broadcast is for reaching
388
+ * live agents. Each recipient gets its own inbox row, so the existing
389
+ * wake machinery (grouping, gating, rate cap) treats the broadcast as
390
+ * ordinary per-recipient mail.
391
+ */
392
+ broadcast(
393
+ fromSession: string,
394
+ body: string,
395
+ ): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
396
+ const trimmed = body.trim();
397
+ if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
398
+ if (trimmed.length > MAX_BODY_LEN) {
399
+ return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
400
+ }
401
+ const sender = this.#find(fromSession);
402
+ if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
403
+ const recipients: string[] = [];
404
+ const skipped: string[] = [];
405
+ // One transaction: a crash mid-loop rolls the whole fan-out back
406
+ // instead of leaving a partial broadcast where some recipients got the
407
+ // message and others silently did not.
408
+ this.#db.transaction(() => {
409
+ for (const row of this.list()) {
410
+ if (row.session_id === fromSession) continue;
411
+ // Staleness means "host process gone" only for opencode rows; an
412
+ // mcp row between turns reads its mail at the next prompt, so it
413
+ // always receives.
414
+ if (row.host_kind === "opencode" && isStale(row, CHAT_STALE_MINUTES)) {
415
+ skipped.push(row.name);
416
+ continue;
417
+ }
418
+ this.#db.run(
419
+ "INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
420
+ [fromSession, row.session_id, trimmed, nowIso()],
421
+ );
422
+ recipients.push(row.name);
423
+ }
424
+ })();
425
+ return { ok: true, recipients, skipped };
426
+ }
427
+
428
+ /**
429
+ * Drains the calling session's inbox: returns unread messages oldest-first
430
+ * and stamps exactly those rows read. The stamp is scoped to the selected
431
+ * ids, not re-derived - a message arriving from another process between
432
+ * the SELECT and the UPDATE must stay unread so the poller re-nudges it;
433
+ * an unscoped stamp would swallow it silently.
434
+ */
435
+ read(sessionID: string): ChatInboxItem[] {
436
+ const rows = this.#db
437
+ .query(
438
+ `SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
439
+ FROM chat_messages m
440
+ LEFT JOIN chat_sessions s ON s.session_id = m.from_session
441
+ WHERE m.to_session = ? AND m.read_at IS NULL
442
+ ORDER BY m.id`,
443
+ )
444
+ .all(sessionID) as any[];
445
+ if (rows.length === 0) return [];
446
+ const marks = rows.map(() => "?").join(",");
447
+ this.#db.run(`UPDATE chat_messages SET read_at = ? WHERE id IN (${marks})`, [
448
+ nowIso(),
449
+ ...rows.map((r) => r.id),
450
+ ]);
451
+ return rows.map((r) => ({
452
+ id: r.id,
453
+ from_session: r.from_session,
454
+ from_name: r.from_name ?? null,
455
+ body: r.body,
456
+ created_at: r.created_at,
457
+ }));
458
+ }
459
+
460
+ /** Unread message count for a session, for chat_list's self row. */
461
+ unreadCount(sessionID: string): number {
462
+ const row = this.#db
463
+ .query("SELECT COUNT(*) AS n FROM chat_messages WHERE to_session = ? AND read_at IS NULL")
464
+ .get(sessionID) as any;
465
+ return row.n;
466
+ }
467
+
468
+ /**
469
+ * The caller's mailbox summary for chat_status: whether a directory row
470
+ * exists, and pending/total counts. Touches last_seen on use, which is
471
+ * what keeps an active MCP session's roster row fresh between prompt-time
472
+ * checks. An unregistered caller gets registered: false - the explicit,
473
+ * quiet answer that replaces any nudge.
474
+ */
475
+ status(sessionID: string): { registered: true; name: string; pending: number; total: number } | { registered: false } {
476
+ const row = this.#find(sessionID);
477
+ if (!row) return { registered: false };
478
+ this.#touch(sessionID);
479
+ const counts = this.#db
480
+ .query(
481
+ `SELECT COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS pending
482
+ FROM chat_messages WHERE to_session = ?`,
483
+ )
484
+ .get(sessionID) as any;
485
+ return {
486
+ registered: true,
487
+ name: row.name,
488
+ pending: counts.pending ?? 0,
489
+ total: counts.total ?? 0,
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Pending-mail summary for the hook channel (flush-tools / reminder):
495
+ * every registered session in the project with unread mail, with its
496
+ * display name and unread count. The hook prints this only when
497
+ * non-empty, and names the addressee - a session that never registered
498
+ * never sees a chat line at all.
499
+ */
500
+ pendingByProject(project: string): Array<{ name: string; pending: number }> {
501
+ return (
502
+ this.#db
503
+ .query(
504
+ `SELECT cs.name AS name, COUNT(*) AS pending
505
+ FROM chat_messages m
506
+ JOIN chat_sessions cs ON cs.session_id = m.to_session
507
+ WHERE m.read_at IS NULL AND cs.project = ?
508
+ GROUP BY cs.name
509
+ ORDER BY cs.name`,
510
+ )
511
+ .all(project) as any[]
512
+ ).map((r) => ({ name: r.name, pending: r.pending }));
513
+ }
514
+
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. */
518
+ heartbeat(sessionIDs: string[]): void {
519
+ if (sessionIDs.length === 0) return;
520
+ 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
+ ]);
525
+ }
526
+
527
+ /**
528
+ * The full message feed for `thatch chat tail`: every row with sender and
529
+ * recipient display names resolved (departed senders degrade to unknown),
530
+ * oldest first. The CLI diffs consecutive snapshots to emit sent and
531
+ * read events; the table is machine-scale, so a full scan per poll is
532
+ * cheap.
533
+ */
534
+ messageFeed(): Array<{
535
+ id: number;
536
+ from: string | null;
537
+ to: string | null;
538
+ fromTopic: string | null;
539
+ toTopic: string | null;
540
+ viaBroadcast: boolean;
541
+ body: string;
542
+ created_at: string;
543
+ read_at: string | null;
544
+ }> {
545
+ return (
546
+ this.#db
547
+ .query(
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
549
+ FROM chat_messages m
550
+ LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
551
+ LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
552
+ ORDER BY m.id`,
553
+ )
554
+ .all() as any[]
555
+ ).map((r) => ({
556
+ id: r.id,
557
+ from: renderChatParticipant(r.from_name, r.from_session),
558
+ to: renderChatParticipant(r.to_name, r.to_session),
559
+ fromTopic: r.from_topic ?? null,
560
+ toTopic: r.to_topic ?? null,
561
+ viaBroadcast: r.via_broadcast === 1,
562
+ body: r.body,
563
+ created_at: r.created_at,
564
+ read_at: r.read_at ?? null,
565
+ }));
566
+ }
567
+
568
+ /**
569
+ * Messages that need a wake prompt for the given sessions: unread and
570
+ * either never delivered, or delivered so long ago that a re-nudge is due.
571
+ * Only registered recipients are selected - unregistering must stop wake
572
+ * prompts for kept-but-unread mail, which is exactly what the
573
+ * chat_unregister tool promises. The cutoff is passed by the poller (its
574
+ * renudge window, as an ISO timestamp); delivered_at comparison is string
575
+ * comparison, which works because every timestamp in these tables uses
576
+ * the same second-resolution format.
577
+ */
578
+ pendingNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[] {
579
+ if (sessionIDs.length === 0) return [];
580
+ const marks = sessionIDs.map(() => "?").join(",");
581
+ return (
582
+ this.#db
583
+ .query(
584
+ `SELECT m.id, m.to_session, m.from_session, s.name AS from_name
585
+ FROM chat_messages m
586
+ LEFT JOIN chat_sessions s ON s.session_id = m.from_session
587
+ WHERE m.read_at IS NULL
588
+ AND m.to_session IN (${marks})
589
+ AND (m.delivered_at IS NULL OR m.delivered_at <= ?)
590
+ AND EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.session_id = m.to_session)
591
+ ORDER BY m.id`,
592
+ )
593
+ .all(...sessionIDs, renudgeCutoff) as any[]
594
+ ).map((r) => ({
595
+ id: r.id,
596
+ to_session: r.to_session,
597
+ from_session: r.from_session,
598
+ from_name: r.from_name ?? null,
599
+ }));
600
+ }
601
+
602
+ /** Stamps delivered_at on the given messages. Called after a wake prompt
603
+ * was accepted; re-stamping on later nudges resets the re-nudge timer. */
604
+ markDelivered(ids: number[]): void {
605
+ if (ids.length === 0) return;
606
+ const marks = ids.map(() => "?").join(",");
607
+ this.#db.run(`UPDATE chat_messages SET delivered_at = ? WHERE id IN (${marks})`, [nowIso(), ...ids]);
608
+ }
609
+
610
+ #find(sessionID: string): ChatSessionRow | null {
611
+ const row = this.#db
612
+ .query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
613
+ .get(sessionID) as any;
614
+ return row ? rowFromSession(row) : null;
615
+ }
616
+
617
+ #findByName(name: string): ChatSessionRow | null {
618
+ 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")
620
+ .get(name) as any;
621
+ return row ? rowFromSession(row) : null;
622
+ }
623
+
624
+ #touch(sessionID: string): void {
625
+ this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
626
+ }
627
+
628
+ /** Collapses whitespace runs (topics must stay one roster line) and caps
629
+ * the length. The null/empty distinction is load-bearing: null means
630
+ * "omit on re-register, keep the existing topic", while empty or
631
+ * whitespace-only means "clear it" - so this never turns one into the
632
+ * other. Oversized topics are capped, never an error: a topic is
633
+ * advisory. */
634
+ #cleanTopic(topic: string | null): string | null {
635
+ if (topic === null) return null;
636
+ return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
637
+ }
638
+
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;
680
+ }
681
+
682
+ /**
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).
690
+ */
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" } {
698
+ try {
699
+ 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()],
702
+ );
703
+ return { ok: true, topic };
704
+ } catch (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" };
710
+ }
711
+ }
712
+
713
+ #isConstraintError(err: unknown): boolean {
714
+ return String((err as any)?.code ?? err).includes("CONSTRAINT");
715
+ }
716
+ }
717
+
718
+ function rowFromSession(r: any): ChatSessionRow {
719
+ return {
720
+ session_id: r.session_id,
721
+ name: r.name,
722
+ // An empty-string topic (explicit clear) reads back as no topic, so the
723
+ // API never leaks the sentinel.
724
+ topic: r.topic || null,
725
+ host_kind: r.host_kind === "mcp" ? "mcp" : "opencode",
726
+ project: r.project,
727
+ registered_at: r.registered_at,
728
+ last_seen: r.last_seen,
729
+ };
730
+ }
731
+
732
+ /**
733
+ * Renders a chat participant for user-visible surfaces (wake prompts, the
734
+ * tail feed, chat_read): the display name when the directory row still
735
+ * exists, otherwise the shared unknown-departed convention. One helper
736
+ * because the exact string is a cross-surface convention - a reword here
737
+ * must reach every surface or the reader sees two names for the same
738
+ * condition.
739
+ */
740
+ export function renderChatParticipant(name: string | null, sessionID: string | null | undefined): string {
741
+ if (name) return name;
742
+ return `unknown (${String(sessionID ?? "").slice(0, 12)}, departed)`;
743
+ }
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // Tail events (pure logic behind `thatch chat tail`)
747
+ // ---------------------------------------------------------------------------
748
+
749
+ 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 };
752
+
753
+ /** One message row as the tail diff consumes it. */
754
+ export interface ChatTailRow {
755
+ id: number;
756
+ from: string | null;
757
+ to: string | null;
758
+ fromTopic: string | null;
759
+ toTopic: string | null;
760
+ viaBroadcast: boolean;
761
+ body: string;
762
+ created_at: string;
763
+ read_at: string | null;
764
+ }
765
+
766
+ /**
767
+ * Diffs one feed snapshot against the previous one, emitting sent events
768
+ * for rows the poller has not seen and read events for rows whose read_at
769
+ * appeared since the last poll. Pure: the caller owns the state map (start
770
+ * empty for a fresh tail) and updates it from the returned state. A message
771
+ * inserted and read between two polls emits only its sent line - the sent
772
+ * event is the primary record, and the read of a message nobody saw as sent
773
+ * adds nothing.
774
+ */
775
+ export function chatTailDiff(
776
+ prev: Map<number, string | null>,
777
+ rows: Array<ChatTailRow>,
778
+ ): { events: ChatTailEvent[]; state: Map<number, string | null> } {
779
+ const events: ChatTailEvent[] = [];
780
+ for (const r of rows) {
781
+ const isNew = !prev.has(r.id);
782
+ if (isNew) {
783
+ events.push({
784
+ kind: "sent",
785
+ timestamp: r.created_at,
786
+ from: r.from ?? "unknown",
787
+ to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
788
+ fromTopic: r.fromTopic,
789
+ toTopic: r.viaBroadcast ? null : r.toTopic,
790
+ body: r.body,
791
+ });
792
+ } else {
793
+ const prevRead = prev.get(r.id) ?? null;
794
+ if (r.read_at !== null && prevRead === null) {
795
+ events.push({
796
+ kind: "read",
797
+ timestamp: r.read_at,
798
+ reader: r.to ?? "unknown",
799
+ from: r.from ?? "unknown",
800
+ fromTopic: r.fromTopic,
801
+ toTopic: r.toTopic,
802
+ body: r.body,
803
+ });
804
+ }
805
+ }
806
+ prev.set(r.id, r.read_at);
807
+ }
808
+ return { events, state: prev };
809
+ }
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
+
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}`;
931
+ }
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
+ );
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
+
1006
+ // ---------------------------------------------------------------------------
1007
+ // Poller
1008
+ // ---------------------------------------------------------------------------
1009
+
1010
+ /** The poller's narrow view of the store: the three chat methods it needs.
1011
+ * ThatchDB satisfies this structurally through its delegated chat methods.
1012
+ * The interface pins the poller to exactly these dependencies and removes
1013
+ * chat.ts's need to import db.ts at all - db.ts already value-imports
1014
+ * ChatStore, so a reverse import would form a runtime module cycle. A
1015
+ * test that wants a fake store can pass a plain object, though the suite
1016
+ * currently uses real temp-dir databases. */
1017
+ export interface ChatPollerStore {
1018
+ heartbeatChatSessions(sessionIDs: string[]): void;
1019
+ pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
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;
1024
+ }
1025
+
1026
+ export interface ChatPollerOptions {
1027
+ 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. */
1031
+ hostedSessions: () => string[];
1032
+ /** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
1033
+ deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
1034
+ /** Gate for delivery: true only when the session can accept a proactive
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>;
1041
+ /** Poll interval. Default 30s - frequent enough that wake prompts feel
1042
+ * prompt, cheap enough that the shared DB sees only a couple of light
1043
+ * statements per cycle per process. */
1044
+ pollIntervalMs?: number;
1045
+ /** A delivered-but-unread message re-queues a nudge after this long.
1046
+ * Default 15 minutes. */
1047
+ renudgeMinutes?: number;
1048
+ /** Hard brake on wake cycles: maximum wake prompts per recipient per hour.
1049
+ * Default 6. In-memory on purpose - it is a loop guard, not accounting. */
1050
+ maxNudgesPerHour?: number;
1051
+ }
1052
+
1053
+ /**
1054
+ * Background poller: heartbeats hosted sessions, delivers wake prompts for
1055
+ * pending chat messages. Mirrors the watcher registry's shape (start/stop/
1056
+ * dispose, re-entrant guards, pending-retry semantics) but its pending state
1057
+ * lives in SQLite rather than memory, because the sender may be a different
1058
+ * process than the deliverer.
1059
+ */
1060
+ export class ChatPoller {
1061
+ #opts: Required<ChatPollerOptions>;
1062
+ #timer: ReturnType<typeof setInterval> | null = null;
1063
+ #polling = false;
1064
+ #delivering = false;
1065
+ /** Per-recipient wake-prompt timestamps inside the last hour. */
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;
1070
+
1071
+ constructor(opts: ChatPollerOptions) {
1072
+ this.#opts = {
1073
+ pollIntervalMs: CHAT_POLL_INTERVAL_MS,
1074
+ renudgeMinutes: CHAT_RENUDGE_MINUTES,
1075
+ maxNudgesPerHour: CHAT_MAX_NUDGES_PER_HOUR,
1076
+ ...opts,
1077
+ };
1078
+ }
1079
+
1080
+ /** True while the poller timer is armed. */
1081
+ get running(): boolean {
1082
+ return this.#timer !== null;
1083
+ }
1084
+
1085
+ start(): void {
1086
+ if (this.#timer !== null) return;
1087
+ this.#timer = setInterval(() => void this.poll(), this.#opts.pollIntervalMs);
1088
+ // Never keep the host process alive just for the poller - matches the
1089
+ // watcher registry's timer.
1090
+ this.#timer.unref?.();
1091
+ }
1092
+
1093
+ stop(): void {
1094
+ if (this.#timer === null) return;
1095
+ clearInterval(this.#timer);
1096
+ this.#timer = null;
1097
+ }
1098
+
1099
+ /** Stops polling and drops the nudge counters. The inbox itself is shared
1100
+ * SQLite state and outlives this process by design. */
1101
+ dispose(): void {
1102
+ this.stop();
1103
+ this.#nudges.clear();
1104
+ }
1105
+
1106
+ /**
1107
+ * One poll cycle: heartbeat hosted sessions, then deliver whatever is
1108
+ * pending for sessions that can accept a prompt. Re-entrant calls are
1109
+ * dropped, matching the watcher registry - a slow cycle overlapping the
1110
+ * next would double-deliver the same messages. Store failures are caught
1111
+ * here, not just deliver failures: the timer discards the returned
1112
+ * promise, so an escaping SQLite error would surface as an unhandled
1113
+ * rejection in the plugin host process. A failed cycle logs and retries
1114
+ * on the next tick.
1115
+ */
1116
+ async poll(): Promise<void> {
1117
+ if (this.#polling) return;
1118
+ this.#polling = true;
1119
+ try {
1120
+ const hosted = this.#opts.hostedSessions();
1121
+ this.#opts.store.heartbeatChatSessions(hosted);
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
+ }
1135
+ } catch (err) {
1136
+ console.error(`[thatch] chat poll failed: ${err}`);
1137
+ } finally {
1138
+ this.#polling = false;
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Delivers pending messages for every hosted session that can accept a
1144
+ * prompt. Sessions that are busy keep their messages pending; the idle
1145
+ * event handler calls this directly so mail lands promptly instead of
1146
+ * waiting for the next cycle. Failures stay pending and retry. Delivery
1147
+ * is at-least-once: a crash after the wake prompt but before
1148
+ * markChatDelivered re-nudges the same batch on the next cycle.
1149
+ */
1150
+ async deliverPending(): Promise<void> {
1151
+ if (this.#delivering) return;
1152
+ this.#delivering = true;
1153
+ try {
1154
+ const hosted = this.#opts.hostedSessions();
1155
+ const cutoff = isoMinutesAgo(this.#opts.renudgeMinutes);
1156
+ const pending = this.#opts.store.pendingChatNotifications(hosted, cutoff);
1157
+ if (pending.length === 0) return;
1158
+
1159
+ const byRecipient = new Map<string, ChatNotificationRow[]>();
1160
+ for (const msg of pending) {
1161
+ const queue = byRecipient.get(msg.to_session) ?? [];
1162
+ queue.push(msg);
1163
+ byRecipient.set(msg.to_session, queue);
1164
+ }
1165
+
1166
+ for (const [recipient, messages] of byRecipient) {
1167
+ if (!(await this.#opts.canDeliver(recipient))) continue;
1168
+ if (this.#nudgeBudget(recipient) <= 0) continue;
1169
+ try {
1170
+ // "unknown (id, departed)" matches chat_read's rendering of a
1171
+ // sender whose directory row is gone, so both surfaces use one
1172
+ // convention for the same condition.
1173
+ const senders = [...new Set(messages.map((m) => renderChatParticipant(m.from_name, m.from_session)))];
1174
+ await this.#opts.deliver(recipient, senders, messages.length);
1175
+ // Count the nudge before the stamp: the cap must bind on prompts
1176
+ // actually sent, not on the DB write succeeding. A failing stamp
1177
+ // leaves the mail pending, and an uncounted re-delivery every
1178
+ // cycle would be the ping-pong the cap exists to stop.
1179
+ this.#recordNudge(recipient);
1180
+ this.#opts.store.markChatDelivered(messages.map((m) => m.id));
1181
+ } catch (err) {
1182
+ console.error(`[thatch] chat delivery to ${recipient} failed: ${err}`);
1183
+ }
1184
+ }
1185
+ } finally {
1186
+ this.#delivering = false;
1187
+ }
1188
+ }
1189
+
1190
+ /** Remaining wake prompts for this recipient in the current hour. */
1191
+ #nudgeBudget(recipient: string): number {
1192
+ const now = Date.now();
1193
+ const window = this.#nudges.get(recipient) ?? [];
1194
+ const fresh = window.filter((t) => now - t < 3_600_000);
1195
+ this.#nudges.set(recipient, fresh);
1196
+ return this.#opts.maxNudgesPerHour - fresh.length;
1197
+ }
1198
+
1199
+ #recordNudge(recipient: string): void {
1200
+ const window = this.#nudges.get(recipient) ?? [];
1201
+ window.push(Date.now());
1202
+ this.#nudges.set(recipient, window);
1203
+ }
1204
+ }
1205
+
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;
1210
+ }