@jeffober/thatch 0.1.40 → 0.1.41
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 +10 -3
- package/artifacts/skills/thatch-code-review.md +4 -4
- package/artifacts/skills/thatch-review-synthesizer.md +8 -8
- package/artifacts/skills/{thatch-review-mentorship.md → thatch-review-technique.md} +9 -10
- package/bin/thatch +160 -16
- package/docs/dev/README.md +26 -14
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +22 -4
- package/docs/dev/features/cross-session-chat.md +256 -0
- package/docs/dev/features/database.md +52 -4
- package/docs/dev/features/multi-host.md +17 -9
- package/docs/dev/features/setup.md +12 -5
- package/docs/dev/gotchas.md +7 -4
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +5 -4
- package/docs/dev/skills.md +5 -4
- package/docs/user/README.md +23 -5
- package/docs/user/cli.md +30 -0
- package/docs/user/code-review.md +1 -1
- package/docs/user/cross-session-chat.md +102 -0
- package/docs/user/setup.md +16 -2
- package/docs/user/skills.md +7 -6
- package/package.json +1 -1
- package/src/chat-names.ts +121 -0
- package/src/chat.ts +879 -0
- package/src/db.ts +179 -0
- package/src/index.ts +113 -5
- package/src/prompts.ts +133 -8
- package/src/setup.ts +83 -29
- package/src/skills.ts +47 -17
- package/src/tool-defs.ts +346 -0
package/src/chat.ts
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
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
|
+
export type ChatResult = { ok: true; topic: string | null } | { ok: false; error: string };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Derives the synthetic session ID for an MCP-host chat registration: a
|
|
84
|
+
* pure function of the claimed name, so identity survives the ephemeral
|
|
85
|
+
* sessions those hosts run (a later conversation claiming the same name
|
|
86
|
+
* finds the same row). The underscore keeps the ID outside NAME_CHARSET,
|
|
87
|
+
* preserving the name-cannot-shadow-ID invariant that find() relies on.
|
|
88
|
+
*/
|
|
89
|
+
export function mcpSessionID(name: string): string {
|
|
90
|
+
const hash = Bun.SHA256.hash(name.toLowerCase(), "hex").slice(0, 12);
|
|
91
|
+
return `mcp_${hash}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Which kind of harness hosts a chat session: opencode rows are
|
|
95
|
+
* poller-driven (liveness = process alive), mcp rows are turn-driven
|
|
96
|
+
* (liveness = a turn ran recently; mail is read at the next prompt). */
|
|
97
|
+
export type ChatHostKind = "opencode" | "mcp";
|
|
98
|
+
|
|
99
|
+
// Display names appear inside wake prompts and chat_list output, so they are
|
|
100
|
+
// capped tight. Bodies are capped so a runaway sender cannot turn a nudge
|
|
101
|
+
// into a context bomb; the reader fetches full content via chat_read anyway.
|
|
102
|
+
const MAX_NAME_LEN = 40;
|
|
103
|
+
const MAX_BODY_LEN = 10_000;
|
|
104
|
+
|
|
105
|
+
// Custom names share the pool's charset: letters, numbers, spaces,
|
|
106
|
+
// apostrophes, hyphens, periods. Parens would truncate the transcript
|
|
107
|
+
// echo's name parse; newlines or tabs would break chat_list's one-line
|
|
108
|
+
// roster format. Exported so the pool conformance test can pin that every
|
|
109
|
+
// baked-in name satisfies it.
|
|
110
|
+
export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
|
|
111
|
+
|
|
112
|
+
// Topics are free text (unlike names) but must stay one line for the
|
|
113
|
+
// chat_list roster, so whitespace runs collapse and the value is capped.
|
|
114
|
+
const MAX_TOPIC_LEN = 80;
|
|
115
|
+
|
|
116
|
+
// Timing defaults, shared by the poller and the chat_list staleness display
|
|
117
|
+
// so both agree on what "stale" means.
|
|
118
|
+
export const CHAT_POLL_INTERVAL_MS = 30_000;
|
|
119
|
+
export const CHAT_STALE_MINUTES = 10;
|
|
120
|
+
export const CHAT_RENUDGE_MINUTES = 15;
|
|
121
|
+
export const CHAT_MAX_NUDGES_PER_HOUR = 6;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
125
|
+
* Database handle; the tables are created by ThatchDB's schema init.
|
|
126
|
+
*/
|
|
127
|
+
export class ChatStore {
|
|
128
|
+
#db: Database;
|
|
129
|
+
|
|
130
|
+
constructor(db: Database) {
|
|
131
|
+
this.#db = db;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Joins the directory with a pool name: picks uniformly at random from the
|
|
135
|
+
* names no other session has claimed (case-insensitively). Idempotent for
|
|
136
|
+
* an already-registered session - it keeps its current name (drawn:
|
|
137
|
+
* false). Fails when every pool name is taken, or when concurrent
|
|
138
|
+
* registrations win every draw attempt - see the redraw loop below.
|
|
139
|
+
*/
|
|
140
|
+
assign(
|
|
141
|
+
sessionID: string,
|
|
142
|
+
project: string | null,
|
|
143
|
+
topic: string | null,
|
|
144
|
+
kind: ChatHostKind,
|
|
145
|
+
): { ok: true; name: string; drawn: boolean; topic: string | null } | { ok: false; error: string } {
|
|
146
|
+
const existing = this.#find(sessionID);
|
|
147
|
+
if (existing) {
|
|
148
|
+
this.#touch(sessionID);
|
|
149
|
+
const cleaned = this.#cleanTopic(topic);
|
|
150
|
+
this.#maybeUpdateTopic(sessionID, existing.topic, cleaned);
|
|
151
|
+
return { ok: true, name: existing.name, drawn: false, topic: cleaned ?? existing.topic };
|
|
152
|
+
}
|
|
153
|
+
// Two processes can snapshot the same free list and draw the same name;
|
|
154
|
+
// the loser's INSERT hits the constraint. Redrawing from the names
|
|
155
|
+
// still free (the taken set is re-read each attempt) keeps the pool
|
|
156
|
+
// path's cannot-collide promise even under concurrent registration.
|
|
157
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
158
|
+
const taken = new Set(this.list().map((r) => r.name.toLowerCase()));
|
|
159
|
+
const free = CHAT_NAME_POOL.filter((n) => !taken.has(n.toLowerCase()));
|
|
160
|
+
if (free.length === 0) {
|
|
161
|
+
return { ok: false, error: "Name pool exhausted - pass a custom name." };
|
|
162
|
+
}
|
|
163
|
+
const name = free[Math.floor(Math.random() * free.length)];
|
|
164
|
+
const cleanTopic = this.#cleanTopic(topic);
|
|
165
|
+
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind);
|
|
166
|
+
if (claimed.ok) return { ok: true, name, drawn: true, topic: cleanTopic };
|
|
167
|
+
}
|
|
168
|
+
return { ok: false, error: "Could not claim a pool name after several attempts - pass a custom name." };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Joins the directory, or refreshes an existing registration. Re-registering
|
|
173
|
+
* with the same session ID renames the session (including changing only
|
|
174
|
+
* the casing of its own name); the new name must not be claimed by a
|
|
175
|
+
* different session. Uniqueness is case-insensitive ("Landru" and "landru"
|
|
176
|
+
* are the same name), so lookups and claims agree no matter what casing the
|
|
177
|
+
* model or user types. Both paths stamp last_seen.
|
|
178
|
+
*/
|
|
179
|
+
register(sessionID: string, rawName: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
|
|
180
|
+
const name = rawName.trim();
|
|
181
|
+
const invalid = this.#invalidName(name);
|
|
182
|
+
if (invalid) return { ok: false, error: invalid };
|
|
183
|
+
const cleanTopic = this.#cleanTopic(topic);
|
|
184
|
+
const existing = this.#find(sessionID);
|
|
185
|
+
if (existing) {
|
|
186
|
+
if (existing.name === name) {
|
|
187
|
+
this.#touch(sessionID);
|
|
188
|
+
this.#maybeUpdateTopic(sessionID, existing.topic, cleanTopic);
|
|
189
|
+
// The kept topic: omitted keeps existing, "" clears to null (the
|
|
190
|
+
// read-side normalization this return path mirrors).
|
|
191
|
+
return { ok: true, topic: cleanTopic ?? existing.topic };
|
|
192
|
+
}
|
|
193
|
+
// A NOCASE hit is only a collision when it belongs to a different
|
|
194
|
+
// session - otherwise this is the caller recasing its own name.
|
|
195
|
+
const clash = this.#findByName(name);
|
|
196
|
+
if (clash && clash.session_id !== sessionID) {
|
|
197
|
+
return this.#nameTaken(name);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
// An omitted topic (null) keeps the existing one, same as the
|
|
201
|
+
// same-name path below - a rename is a re-register, and the tool
|
|
202
|
+
// treats name and topic updates as independent.
|
|
203
|
+
const nextTopic = cleanTopic ?? existing.topic;
|
|
204
|
+
this.#db.run("UPDATE chat_sessions SET name = ?, topic = ?, last_seen = ? WHERE session_id = ?", [
|
|
205
|
+
name,
|
|
206
|
+
nextTopic,
|
|
207
|
+
nowIso(),
|
|
208
|
+
sessionID,
|
|
209
|
+
]);
|
|
210
|
+
return { ok: true, topic: nextTopic };
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (this.#isConstraintError(err)) {
|
|
213
|
+
return this.#nameTaken(name);
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (this.#findByName(name)) {
|
|
219
|
+
return this.#nameTaken(name);
|
|
220
|
+
}
|
|
221
|
+
const inserted = this.#insertSession(sessionID, name, project, cleanTopic, kind);
|
|
222
|
+
if (inserted.ok) return { ok: true, topic: cleanTopic };
|
|
223
|
+
return inserted;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Leaves the directory. Messages already sent to or from the session are
|
|
227
|
+
* kept as history; departed senders show as unknown names to readers. */
|
|
228
|
+
unregister(sessionID: string): boolean {
|
|
229
|
+
const existing = this.#find(sessionID);
|
|
230
|
+
if (!existing) return false;
|
|
231
|
+
this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
list(): ChatSessionRow[] {
|
|
236
|
+
return (this.#db
|
|
237
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
|
|
238
|
+
.all() as any[]).map(rowFromSession);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Resolves a recipient by display name (case-insensitive) or session ID.
|
|
242
|
+
* Name-first is safe only because opencode session IDs contain
|
|
243
|
+
* underscores and NAME_CHARSET excludes them, so no legal display name
|
|
244
|
+
* can shadow an ID; if either format ever changes, resolve IDs first. */
|
|
245
|
+
find(nameOrID: string): ChatSessionRow | null {
|
|
246
|
+
return this.#findByName(nameOrID) ?? this.#find(nameOrID);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Posts a message. Both endpoints must be registered; a session cannot
|
|
251
|
+
* message itself (nothing is coordinated: the message would sit unread
|
|
252
|
+
* until the sender drains its own inbox, and the wake prompt would be the
|
|
253
|
+
* sender nudging itself). Returns the resolved recipient so callers never
|
|
254
|
+
* need a second lookup - a re-lookup could race a concurrent unregister
|
|
255
|
+
* and fail after the message already landed.
|
|
256
|
+
*/
|
|
257
|
+
send(
|
|
258
|
+
fromSession: string,
|
|
259
|
+
toNameOrID: string,
|
|
260
|
+
body: string,
|
|
261
|
+
): { ok: true; recipient: { session_id: string; name: string } } | { ok: false; error: string } {
|
|
262
|
+
const trimmed = body.trim();
|
|
263
|
+
if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
|
|
264
|
+
if (trimmed.length > MAX_BODY_LEN) {
|
|
265
|
+
return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
|
|
266
|
+
}
|
|
267
|
+
const sender = this.#find(fromSession);
|
|
268
|
+
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
269
|
+
const recipient = this.find(toNameOrID);
|
|
270
|
+
if (!recipient) {
|
|
271
|
+
return { ok: false, error: `No registered session named "${toNameOrID}". Use chat_list to see who is available.` };
|
|
272
|
+
}
|
|
273
|
+
if (recipient.session_id === fromSession) {
|
|
274
|
+
return { ok: false, error: "You cannot message yourself." };
|
|
275
|
+
}
|
|
276
|
+
this.#db.run(
|
|
277
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 0)",
|
|
278
|
+
[fromSession, recipient.session_id, trimmed, nowIso()],
|
|
279
|
+
);
|
|
280
|
+
return { ok: true, recipient: { session_id: recipient.session_id, name: recipient.name } };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Posts a message to every other registered session at once. Stale
|
|
285
|
+
* sessions are skipped, not messaged - a host process that has stopped
|
|
286
|
+
* heartbeat-ing will never read the mail, and a broadcast is for reaching
|
|
287
|
+
* live agents. Each recipient gets its own inbox row, so the existing
|
|
288
|
+
* wake machinery (grouping, gating, rate cap) treats the broadcast as
|
|
289
|
+
* ordinary per-recipient mail.
|
|
290
|
+
*/
|
|
291
|
+
broadcast(
|
|
292
|
+
fromSession: string,
|
|
293
|
+
body: string,
|
|
294
|
+
): { ok: true; recipients: string[]; skipped: string[] } | { ok: false; error: string } {
|
|
295
|
+
const trimmed = body.trim();
|
|
296
|
+
if (!trimmed) return { ok: false, error: "Message body cannot be empty." };
|
|
297
|
+
if (trimmed.length > MAX_BODY_LEN) {
|
|
298
|
+
return { ok: false, error: `Message too long (max ${MAX_BODY_LEN} characters).` };
|
|
299
|
+
}
|
|
300
|
+
const sender = this.#find(fromSession);
|
|
301
|
+
if (!sender) return { ok: false, error: "You are not registered - call chat_register first." };
|
|
302
|
+
const recipients: string[] = [];
|
|
303
|
+
const skipped: string[] = [];
|
|
304
|
+
// One transaction: a crash mid-loop rolls the whole fan-out back
|
|
305
|
+
// instead of leaving a partial broadcast where some recipients got the
|
|
306
|
+
// message and others silently did not.
|
|
307
|
+
this.#db.transaction(() => {
|
|
308
|
+
for (const row of this.list()) {
|
|
309
|
+
if (row.session_id === fromSession) continue;
|
|
310
|
+
// Staleness means "host process gone" only for opencode rows; an
|
|
311
|
+
// mcp row between turns reads its mail at the next prompt, so it
|
|
312
|
+
// always receives.
|
|
313
|
+
if (row.host_kind === "opencode" && isStale(row, CHAT_STALE_MINUTES)) {
|
|
314
|
+
skipped.push(row.name);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
this.#db.run(
|
|
318
|
+
"INSERT INTO chat_messages (from_session, to_session, body, created_at, via_broadcast) VALUES (?, ?, ?, ?, 1)",
|
|
319
|
+
[fromSession, row.session_id, trimmed, nowIso()],
|
|
320
|
+
);
|
|
321
|
+
recipients.push(row.name);
|
|
322
|
+
}
|
|
323
|
+
})();
|
|
324
|
+
return { ok: true, recipients, skipped };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Drains the calling session's inbox: returns unread messages oldest-first
|
|
329
|
+
* and stamps exactly those rows read. The stamp is scoped to the selected
|
|
330
|
+
* ids, not re-derived - a message arriving from another process between
|
|
331
|
+
* the SELECT and the UPDATE must stay unread so the poller re-nudges it;
|
|
332
|
+
* an unscoped stamp would swallow it silently.
|
|
333
|
+
*/
|
|
334
|
+
read(sessionID: string): ChatInboxItem[] {
|
|
335
|
+
const rows = this.#db
|
|
336
|
+
.query(
|
|
337
|
+
`SELECT m.id, m.from_session, s.name AS from_name, m.body, m.created_at
|
|
338
|
+
FROM chat_messages m
|
|
339
|
+
LEFT JOIN chat_sessions s ON s.session_id = m.from_session
|
|
340
|
+
WHERE m.to_session = ? AND m.read_at IS NULL
|
|
341
|
+
ORDER BY m.id`,
|
|
342
|
+
)
|
|
343
|
+
.all(sessionID) as any[];
|
|
344
|
+
if (rows.length === 0) return [];
|
|
345
|
+
const marks = rows.map(() => "?").join(",");
|
|
346
|
+
this.#db.run(`UPDATE chat_messages SET read_at = ? WHERE id IN (${marks})`, [
|
|
347
|
+
nowIso(),
|
|
348
|
+
...rows.map((r) => r.id),
|
|
349
|
+
]);
|
|
350
|
+
return rows.map((r) => ({
|
|
351
|
+
id: r.id,
|
|
352
|
+
from_session: r.from_session,
|
|
353
|
+
from_name: r.from_name ?? null,
|
|
354
|
+
body: r.body,
|
|
355
|
+
created_at: r.created_at,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Unread message count for a session, for chat_list's self row. */
|
|
360
|
+
unreadCount(sessionID: string): number {
|
|
361
|
+
const row = this.#db
|
|
362
|
+
.query("SELECT COUNT(*) AS n FROM chat_messages WHERE to_session = ? AND read_at IS NULL")
|
|
363
|
+
.get(sessionID) as any;
|
|
364
|
+
return row.n;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The caller's mailbox summary for chat_status: whether a directory row
|
|
369
|
+
* exists, and pending/total counts. Touches last_seen on use, which is
|
|
370
|
+
* what keeps an active MCP session's roster row fresh between prompt-time
|
|
371
|
+
* checks. An unregistered caller gets registered: false - the explicit,
|
|
372
|
+
* quiet answer that replaces any nudge.
|
|
373
|
+
*/
|
|
374
|
+
status(sessionID: string): { registered: true; name: string; pending: number; total: number } | { registered: false } {
|
|
375
|
+
const row = this.#find(sessionID);
|
|
376
|
+
if (!row) return { registered: false };
|
|
377
|
+
this.#touch(sessionID);
|
|
378
|
+
const counts = this.#db
|
|
379
|
+
.query(
|
|
380
|
+
`SELECT COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS pending
|
|
381
|
+
FROM chat_messages WHERE to_session = ?`,
|
|
382
|
+
)
|
|
383
|
+
.get(sessionID) as any;
|
|
384
|
+
return {
|
|
385
|
+
registered: true,
|
|
386
|
+
name: row.name,
|
|
387
|
+
pending: counts.pending ?? 0,
|
|
388
|
+
total: counts.total ?? 0,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Pending-mail summary for the hook channel (flush-tools / reminder):
|
|
394
|
+
* every registered session in the project with unread mail, with its
|
|
395
|
+
* display name and unread count. The hook prints this only when
|
|
396
|
+
* non-empty, and names the addressee - a session that never registered
|
|
397
|
+
* never sees a chat line at all.
|
|
398
|
+
*/
|
|
399
|
+
pendingByProject(project: string): Array<{ name: string; pending: number }> {
|
|
400
|
+
return (
|
|
401
|
+
this.#db
|
|
402
|
+
.query(
|
|
403
|
+
`SELECT cs.name AS name, COUNT(*) AS pending
|
|
404
|
+
FROM chat_messages m
|
|
405
|
+
JOIN chat_sessions cs ON cs.session_id = m.to_session
|
|
406
|
+
WHERE m.read_at IS NULL AND cs.project = ?
|
|
407
|
+
GROUP BY cs.name
|
|
408
|
+
ORDER BY cs.name`,
|
|
409
|
+
)
|
|
410
|
+
.all(project) as any[]
|
|
411
|
+
).map((r) => ({ name: r.name, pending: r.pending }));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Refreshes last_seen for the given sessions. Sessions that never
|
|
415
|
+
* registered are absent from chat_sessions, so the UPDATE is a no-op for
|
|
416
|
+
* them - no filtering needed on the caller's side. */
|
|
417
|
+
heartbeat(sessionIDs: string[]): void {
|
|
418
|
+
if (sessionIDs.length === 0) return;
|
|
419
|
+
const marks = sessionIDs.map(() => "?").join(",");
|
|
420
|
+
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
|
|
421
|
+
nowIso(),
|
|
422
|
+
...sessionIDs,
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The full message feed for `thatch chat tail`: every row with sender and
|
|
428
|
+
* recipient display names resolved (departed senders degrade to unknown),
|
|
429
|
+
* oldest first. The CLI diffs consecutive snapshots to emit sent and
|
|
430
|
+
* read events; the table is machine-scale, so a full scan per poll is
|
|
431
|
+
* cheap.
|
|
432
|
+
*/
|
|
433
|
+
messageFeed(): Array<{
|
|
434
|
+
id: number;
|
|
435
|
+
from: string | null;
|
|
436
|
+
to: string | null;
|
|
437
|
+
viaBroadcast: boolean;
|
|
438
|
+
body: string;
|
|
439
|
+
created_at: string;
|
|
440
|
+
read_at: string | null;
|
|
441
|
+
}> {
|
|
442
|
+
return (
|
|
443
|
+
this.#db
|
|
444
|
+
.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
|
|
446
|
+
FROM chat_messages m
|
|
447
|
+
LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
|
|
448
|
+
LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
|
|
449
|
+
ORDER BY m.id`,
|
|
450
|
+
)
|
|
451
|
+
.all() as any[]
|
|
452
|
+
).map((r) => ({
|
|
453
|
+
id: r.id,
|
|
454
|
+
from: renderChatParticipant(r.from_name, r.from_session),
|
|
455
|
+
to: renderChatParticipant(r.to_name, r.to_session),
|
|
456
|
+
viaBroadcast: r.via_broadcast === 1,
|
|
457
|
+
body: r.body,
|
|
458
|
+
created_at: r.created_at,
|
|
459
|
+
read_at: r.read_at ?? null,
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Messages that need a wake prompt for the given sessions: unread and
|
|
465
|
+
* either never delivered, or delivered so long ago that a re-nudge is due.
|
|
466
|
+
* Only registered recipients are selected - unregistering must stop wake
|
|
467
|
+
* prompts for kept-but-unread mail, which is exactly what the
|
|
468
|
+
* chat_unregister tool promises. The cutoff is passed by the poller (its
|
|
469
|
+
* renudge window, as an ISO timestamp); delivered_at comparison is string
|
|
470
|
+
* comparison, which works because every timestamp in these tables uses
|
|
471
|
+
* the same second-resolution format.
|
|
472
|
+
*/
|
|
473
|
+
pendingNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[] {
|
|
474
|
+
if (sessionIDs.length === 0) return [];
|
|
475
|
+
const marks = sessionIDs.map(() => "?").join(",");
|
|
476
|
+
return (
|
|
477
|
+
this.#db
|
|
478
|
+
.query(
|
|
479
|
+
`SELECT m.id, m.to_session, m.from_session, s.name AS from_name
|
|
480
|
+
FROM chat_messages m
|
|
481
|
+
LEFT JOIN chat_sessions s ON s.session_id = m.from_session
|
|
482
|
+
WHERE m.read_at IS NULL
|
|
483
|
+
AND m.to_session IN (${marks})
|
|
484
|
+
AND (m.delivered_at IS NULL OR m.delivered_at <= ?)
|
|
485
|
+
AND EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.session_id = m.to_session)
|
|
486
|
+
ORDER BY m.id`,
|
|
487
|
+
)
|
|
488
|
+
.all(...sessionIDs, renudgeCutoff) as any[]
|
|
489
|
+
).map((r) => ({
|
|
490
|
+
id: r.id,
|
|
491
|
+
to_session: r.to_session,
|
|
492
|
+
from_session: r.from_session,
|
|
493
|
+
from_name: r.from_name ?? null,
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Stamps delivered_at on the given messages. Called after a wake prompt
|
|
498
|
+
* was accepted; re-stamping on later nudges resets the re-nudge timer. */
|
|
499
|
+
markDelivered(ids: number[]): void {
|
|
500
|
+
if (ids.length === 0) return;
|
|
501
|
+
const marks = ids.map(() => "?").join(",");
|
|
502
|
+
this.#db.run(`UPDATE chat_messages SET delivered_at = ? WHERE id IN (${marks})`, [nowIso(), ...ids]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#find(sessionID: string): ChatSessionRow | null {
|
|
506
|
+
const row = this.#db
|
|
507
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
|
|
508
|
+
.get(sessionID) as any;
|
|
509
|
+
return row ? rowFromSession(row) : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#findByName(name: string): ChatSessionRow | null {
|
|
513
|
+
const row = this.#db
|
|
514
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
|
|
515
|
+
.get(name) as any;
|
|
516
|
+
return row ? rowFromSession(row) : null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#touch(sessionID: string): void {
|
|
520
|
+
this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
|
|
521
|
+
}
|
|
522
|
+
|
|
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
|
+
/** Collapses whitespace runs (topics must stay one roster line) and caps
|
|
533
|
+
* the length. The null/empty distinction is load-bearing: null means
|
|
534
|
+
* "omit on re-register, keep the existing topic", while empty or
|
|
535
|
+
* whitespace-only means "clear it" - so this never turns one into the
|
|
536
|
+
* other. Oversized topics are capped, never an error: a topic is
|
|
537
|
+
* advisory. */
|
|
538
|
+
#cleanTopic(topic: string | null): string | null {
|
|
539
|
+
if (topic === null) return null;
|
|
540
|
+
return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Refreshes the topic on re-registration when it changed. Omitting the
|
|
544
|
+
* topic (null) keeps the existing one; passing an empty topic clears it. */
|
|
545
|
+
#maybeUpdateTopic(sessionID: string, current: string | null, next: string | null): void {
|
|
546
|
+
if (next === null || next === current) return;
|
|
547
|
+
this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ?", [next, sessionID]);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* INSERTs a directory row, translating a UNIQUE-constraint violation into
|
|
552
|
+
* the friendly taken message. The pre-checks are advisory only against
|
|
553
|
+
* other processes: two sessions claiming one free name in the same
|
|
554
|
+
* instant both pass the check, and the loser's INSERT must not escape the
|
|
555
|
+
* { ok, error } contract as a raw SQLite error. Same pattern as
|
|
556
|
+
* remember()'s slug-collision handling in db.ts.
|
|
557
|
+
*/
|
|
558
|
+
#insertSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
|
|
559
|
+
try {
|
|
560
|
+
this.#db.run(
|
|
561
|
+
"INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
562
|
+
[sessionID, name, topic, project, kind, nowIso(), nowIso()],
|
|
563
|
+
);
|
|
564
|
+
return { ok: true, topic };
|
|
565
|
+
} catch (err) {
|
|
566
|
+
// The two constraints fail differently: a UNIQUE hit on name means
|
|
567
|
+
// someone else claimed it; a PK hit on session_id means this session
|
|
568
|
+
// is somehow registered twice, which is an identity event, not a
|
|
569
|
+
// naming one.
|
|
570
|
+
if (this.#isConstraintError(err)) {
|
|
571
|
+
return this.#alreadyRegistered();
|
|
572
|
+
}
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#isConstraintError(err: unknown): boolean {
|
|
578
|
+
return String((err as any)?.code ?? err).includes("CONSTRAINT");
|
|
579
|
+
}
|
|
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
|
+
}
|
|
599
|
+
|
|
600
|
+
function rowFromSession(r: any): ChatSessionRow {
|
|
601
|
+
return {
|
|
602
|
+
session_id: r.session_id,
|
|
603
|
+
name: r.name,
|
|
604
|
+
// An empty-string topic (explicit clear) reads back as no topic, so the
|
|
605
|
+
// API never leaks the sentinel.
|
|
606
|
+
topic: r.topic || null,
|
|
607
|
+
host_kind: r.host_kind === "mcp" ? "mcp" : "opencode",
|
|
608
|
+
project: r.project,
|
|
609
|
+
registered_at: r.registered_at,
|
|
610
|
+
last_seen: r.last_seen,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Renders a chat participant for user-visible surfaces (wake prompts, the
|
|
616
|
+
* tail feed, chat_read): the display name when the directory row still
|
|
617
|
+
* exists, otherwise the shared unknown-departed convention. One helper
|
|
618
|
+
* because the exact string is a cross-surface convention - a reword here
|
|
619
|
+
* must reach every surface or the reader sees two names for the same
|
|
620
|
+
* condition.
|
|
621
|
+
*/
|
|
622
|
+
export function renderChatParticipant(name: string | null, sessionID: string | null | undefined): string {
|
|
623
|
+
if (name) return name;
|
|
624
|
+
return `unknown (${String(sessionID ?? "").slice(0, 12)}, departed)`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// Tail events (pure logic behind `thatch chat tail`)
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
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 };
|
|
634
|
+
|
|
635
|
+
/** One message row as the tail diff consumes it. */
|
|
636
|
+
export interface ChatTailRow {
|
|
637
|
+
id: number;
|
|
638
|
+
from: string | null;
|
|
639
|
+
to: string | null;
|
|
640
|
+
viaBroadcast: boolean;
|
|
641
|
+
body: string;
|
|
642
|
+
created_at: string;
|
|
643
|
+
read_at: string | null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Diffs one feed snapshot against the previous one, emitting sent events
|
|
648
|
+
* for rows the poller has not seen and read events for rows whose read_at
|
|
649
|
+
* appeared since the last poll. Pure: the caller owns the state map (start
|
|
650
|
+
* empty for a fresh tail) and updates it from the returned state. A message
|
|
651
|
+
* inserted and read between two polls emits only its sent line - the sent
|
|
652
|
+
* event is the primary record, and the read of a message nobody saw as sent
|
|
653
|
+
* adds nothing.
|
|
654
|
+
*/
|
|
655
|
+
export function chatTailDiff(
|
|
656
|
+
prev: Map<number, string | null>,
|
|
657
|
+
rows: Array<ChatTailRow>,
|
|
658
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null> } {
|
|
659
|
+
const events: ChatTailEvent[] = [];
|
|
660
|
+
for (const r of rows) {
|
|
661
|
+
const isNew = !prev.has(r.id);
|
|
662
|
+
if (isNew) {
|
|
663
|
+
events.push({
|
|
664
|
+
kind: "sent",
|
|
665
|
+
timestamp: r.created_at,
|
|
666
|
+
from: r.from ?? "unknown",
|
|
667
|
+
to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
|
|
668
|
+
body: r.body,
|
|
669
|
+
});
|
|
670
|
+
} else {
|
|
671
|
+
const prevRead = prev.get(r.id) ?? null;
|
|
672
|
+
if (r.read_at !== null && prevRead === null) {
|
|
673
|
+
events.push({
|
|
674
|
+
kind: "read",
|
|
675
|
+
timestamp: r.read_at,
|
|
676
|
+
reader: r.to ?? "unknown",
|
|
677
|
+
from: r.from ?? "unknown",
|
|
678
|
+
body: r.body,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
prev.set(r.id, r.read_at);
|
|
683
|
+
}
|
|
684
|
+
return { events, state: prev };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Renders one tail event in the CLI's line format. */
|
|
688
|
+
export function formatChatTailEvent(event: ChatTailEvent): string {
|
|
689
|
+
if (event.kind === "sent") {
|
|
690
|
+
return `[${event.timestamp}] ${event.from} -> ${event.to}: ${event.body}`;
|
|
691
|
+
}
|
|
692
|
+
const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
|
|
693
|
+
return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
// Poller
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
/** The poller's narrow view of the store: the three chat methods it needs.
|
|
701
|
+
* ThatchDB satisfies this structurally through its delegated chat methods.
|
|
702
|
+
* The interface pins the poller to exactly these dependencies and removes
|
|
703
|
+
* chat.ts's need to import db.ts at all - db.ts already value-imports
|
|
704
|
+
* ChatStore, so a reverse import would form a runtime module cycle. A
|
|
705
|
+
* test that wants a fake store can pass a plain object, though the suite
|
|
706
|
+
* currently uses real temp-dir databases. */
|
|
707
|
+
export interface ChatPollerStore {
|
|
708
|
+
heartbeatChatSessions(sessionIDs: string[]): void;
|
|
709
|
+
pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
|
|
710
|
+
markChatDelivered(ids: number[]): void;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export interface ChatPollerOptions {
|
|
714
|
+
store: ChatPollerStore;
|
|
715
|
+
/** Session IDs this process has seen events for. Only sessions hosted by
|
|
716
|
+
* this process can be delivered to - the recipient's host is the single
|
|
717
|
+
* deliverer, by design. */
|
|
718
|
+
hostedSessions: () => string[];
|
|
719
|
+
/** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
|
|
720
|
+
deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
|
|
721
|
+
/** Gate for delivery: true only when the session can accept a proactive
|
|
722
|
+
* prompt right now (idle, not compacting). Undeliverable messages stay
|
|
723
|
+
* pending and retry on later cycles. */
|
|
724
|
+
canDeliver: (sessionID: string) => boolean;
|
|
725
|
+
/** Poll interval. Default 30s - frequent enough that wake prompts feel
|
|
726
|
+
* prompt, cheap enough that the shared DB sees only a couple of light
|
|
727
|
+
* statements per cycle per process. */
|
|
728
|
+
pollIntervalMs?: number;
|
|
729
|
+
/** A delivered-but-unread message re-queues a nudge after this long.
|
|
730
|
+
* Default 15 minutes. */
|
|
731
|
+
renudgeMinutes?: number;
|
|
732
|
+
/** Hard brake on wake cycles: maximum wake prompts per recipient per hour.
|
|
733
|
+
* Default 6. In-memory on purpose - it is a loop guard, not accounting. */
|
|
734
|
+
maxNudgesPerHour?: number;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Background poller: heartbeats hosted sessions, delivers wake prompts for
|
|
739
|
+
* pending chat messages. Mirrors the watcher registry's shape (start/stop/
|
|
740
|
+
* dispose, re-entrant guards, pending-retry semantics) but its pending state
|
|
741
|
+
* lives in SQLite rather than memory, because the sender may be a different
|
|
742
|
+
* process than the deliverer.
|
|
743
|
+
*/
|
|
744
|
+
export class ChatPoller {
|
|
745
|
+
#opts: Required<ChatPollerOptions>;
|
|
746
|
+
#timer: ReturnType<typeof setInterval> | null = null;
|
|
747
|
+
#polling = false;
|
|
748
|
+
#delivering = false;
|
|
749
|
+
/** Per-recipient wake-prompt timestamps inside the last hour. */
|
|
750
|
+
#nudges = new Map<string, number[]>();
|
|
751
|
+
|
|
752
|
+
constructor(opts: ChatPollerOptions) {
|
|
753
|
+
this.#opts = {
|
|
754
|
+
pollIntervalMs: CHAT_POLL_INTERVAL_MS,
|
|
755
|
+
renudgeMinutes: CHAT_RENUDGE_MINUTES,
|
|
756
|
+
maxNudgesPerHour: CHAT_MAX_NUDGES_PER_HOUR,
|
|
757
|
+
...opts,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** True while the poller timer is armed. */
|
|
762
|
+
get running(): boolean {
|
|
763
|
+
return this.#timer !== null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
start(): void {
|
|
767
|
+
if (this.#timer !== null) return;
|
|
768
|
+
this.#timer = setInterval(() => void this.poll(), this.#opts.pollIntervalMs);
|
|
769
|
+
// Never keep the host process alive just for the poller - matches the
|
|
770
|
+
// watcher registry's timer.
|
|
771
|
+
this.#timer.unref?.();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
stop(): void {
|
|
775
|
+
if (this.#timer === null) return;
|
|
776
|
+
clearInterval(this.#timer);
|
|
777
|
+
this.#timer = null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Stops polling and drops the nudge counters. The inbox itself is shared
|
|
781
|
+
* SQLite state and outlives this process by design. */
|
|
782
|
+
dispose(): void {
|
|
783
|
+
this.stop();
|
|
784
|
+
this.#nudges.clear();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* One poll cycle: heartbeat hosted sessions, then deliver whatever is
|
|
789
|
+
* pending for sessions that can accept a prompt. Re-entrant calls are
|
|
790
|
+
* dropped, matching the watcher registry - a slow cycle overlapping the
|
|
791
|
+
* next would double-deliver the same messages. Store failures are caught
|
|
792
|
+
* here, not just deliver failures: the timer discards the returned
|
|
793
|
+
* promise, so an escaping SQLite error would surface as an unhandled
|
|
794
|
+
* rejection in the plugin host process. A failed cycle logs and retries
|
|
795
|
+
* on the next tick.
|
|
796
|
+
*/
|
|
797
|
+
async poll(): Promise<void> {
|
|
798
|
+
if (this.#polling) return;
|
|
799
|
+
this.#polling = true;
|
|
800
|
+
try {
|
|
801
|
+
const hosted = this.#opts.hostedSessions();
|
|
802
|
+
this.#opts.store.heartbeatChatSessions(hosted);
|
|
803
|
+
await this.deliverPending();
|
|
804
|
+
} catch (err) {
|
|
805
|
+
console.error(`[thatch] chat poll failed: ${err}`);
|
|
806
|
+
} finally {
|
|
807
|
+
this.#polling = false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Delivers pending messages for every hosted session that can accept a
|
|
813
|
+
* prompt. Sessions that are busy keep their messages pending; the idle
|
|
814
|
+
* event handler calls this directly so mail lands promptly instead of
|
|
815
|
+
* waiting for the next cycle. Failures stay pending and retry. Delivery
|
|
816
|
+
* is at-least-once: a crash after the wake prompt but before
|
|
817
|
+
* markChatDelivered re-nudges the same batch on the next cycle.
|
|
818
|
+
*/
|
|
819
|
+
async deliverPending(): Promise<void> {
|
|
820
|
+
if (this.#delivering) return;
|
|
821
|
+
this.#delivering = true;
|
|
822
|
+
try {
|
|
823
|
+
const hosted = this.#opts.hostedSessions();
|
|
824
|
+
const cutoff = isoMinutesAgo(this.#opts.renudgeMinutes);
|
|
825
|
+
const pending = this.#opts.store.pendingChatNotifications(hosted, cutoff);
|
|
826
|
+
if (pending.length === 0) return;
|
|
827
|
+
|
|
828
|
+
const byRecipient = new Map<string, ChatNotificationRow[]>();
|
|
829
|
+
for (const msg of pending) {
|
|
830
|
+
const queue = byRecipient.get(msg.to_session) ?? [];
|
|
831
|
+
queue.push(msg);
|
|
832
|
+
byRecipient.set(msg.to_session, queue);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
for (const [recipient, messages] of byRecipient) {
|
|
836
|
+
if (!this.#opts.canDeliver(recipient)) continue;
|
|
837
|
+
if (this.#nudgeBudget(recipient) <= 0) continue;
|
|
838
|
+
try {
|
|
839
|
+
// "unknown (id, departed)" matches chat_read's rendering of a
|
|
840
|
+
// sender whose directory row is gone, so both surfaces use one
|
|
841
|
+
// convention for the same condition.
|
|
842
|
+
const senders = [...new Set(messages.map((m) => renderChatParticipant(m.from_name, m.from_session)))];
|
|
843
|
+
await this.#opts.deliver(recipient, senders, messages.length);
|
|
844
|
+
// Count the nudge before the stamp: the cap must bind on prompts
|
|
845
|
+
// actually sent, not on the DB write succeeding. A failing stamp
|
|
846
|
+
// leaves the mail pending, and an uncounted re-delivery every
|
|
847
|
+
// cycle would be the ping-pong the cap exists to stop.
|
|
848
|
+
this.#recordNudge(recipient);
|
|
849
|
+
this.#opts.store.markChatDelivered(messages.map((m) => m.id));
|
|
850
|
+
} catch (err) {
|
|
851
|
+
console.error(`[thatch] chat delivery to ${recipient} failed: ${err}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} finally {
|
|
855
|
+
this.#delivering = false;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Remaining wake prompts for this recipient in the current hour. */
|
|
860
|
+
#nudgeBudget(recipient: string): number {
|
|
861
|
+
const now = Date.now();
|
|
862
|
+
const window = this.#nudges.get(recipient) ?? [];
|
|
863
|
+
const fresh = window.filter((t) => now - t < 3_600_000);
|
|
864
|
+
this.#nudges.set(recipient, fresh);
|
|
865
|
+
return this.#opts.maxNudgesPerHour - fresh.length;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
#recordNudge(recipient: string): void {
|
|
869
|
+
const window = this.#nudges.get(recipient) ?? [];
|
|
870
|
+
window.push(Date.now());
|
|
871
|
+
this.#nudges.set(recipient, window);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** True when a session row's last_seen is older than the staleness
|
|
876
|
+
* threshold. Used by chat_list to mark ghosts. */
|
|
877
|
+
export function isStale(row: ChatSessionRow, staleMinutes: number, now = Date.now()): boolean {
|
|
878
|
+
return now - Date.parse(row.last_seen) > staleMinutes * 60_000;
|
|
879
|
+
}
|