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