@jeffober/thatch 0.1.43 → 0.1.45
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 +84 -9
- package/bin/thatch +307 -95
- package/docs/dev/README.md +12 -3
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/commands.md +74 -0
- package/docs/dev/features/cross-session-chat.md +128 -45
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/extraction.md +5 -0
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +20 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +20 -8
- package/docs/plans/thatch-actions.md +179 -0
- package/docs/user/README.md +10 -4
- package/docs/user/cli.md +45 -44
- package/docs/user/commands.md +50 -0
- package/docs/user/cross-session-chat.md +94 -22
- package/docs/user/setup.md +9 -3
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +372 -185
- package/src/commands.ts +138 -19
- package/src/db.ts +46 -0
- package/src/debug.ts +44 -0
- package/src/embeddings.ts +42 -1
- package/src/index.ts +262 -39
- package/src/mcp.ts +78 -3
- package/src/prompts.ts +57 -3
- package/src/setup.ts +212 -33
- package/src/tool-defs.ts +115 -30
- package/src/watchers.ts +51 -29
package/src/chat.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { openSync, readSync, closeSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
2
4
|
import { CHAT_NAME_POOL } from "./chat-names";
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -98,8 +100,112 @@ export function mcpSessionID(hostSessionID: string): string {
|
|
|
98
100
|
return `mcp_${hash}`;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Reads a Claude Code transcript's last line and returns the session id the
|
|
105
|
+
* conversation continued INTO, when the file ends with a continued-in record
|
|
106
|
+
* (Claude Code writes it when a conversation is resumed or forked into a new
|
|
107
|
+
* session id). Returns null otherwise. This is the link that lets a hook
|
|
108
|
+
* follow a continuation backwards: the NEW session id is all the hook knows,
|
|
109
|
+
* and the OLD transcript names it.
|
|
110
|
+
*/
|
|
111
|
+
export function continuedInTarget(transcriptPath: string): string | null {
|
|
112
|
+
let raw: string;
|
|
113
|
+
try {
|
|
114
|
+
// The marker is the file's final record; reading the tail avoids
|
|
115
|
+
// loading potentially large transcripts.
|
|
116
|
+
const stat = statSync(transcriptPath);
|
|
117
|
+
const tailLen = Math.min(stat.size, 4096);
|
|
118
|
+
const buf = Buffer.alloc(tailLen);
|
|
119
|
+
const fd = openSync(transcriptPath, "r");
|
|
120
|
+
try {
|
|
121
|
+
readSync(fd, buf, 0, tailLen, stat.size - tailLen);
|
|
122
|
+
} finally {
|
|
123
|
+
closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
raw = buf.toString("utf8");
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const lines = raw.trimEnd().split("\n").filter((l) => l.trim());
|
|
130
|
+
const last = lines[lines.length - 1];
|
|
131
|
+
if (!last) return null;
|
|
132
|
+
try {
|
|
133
|
+
const record = JSON.parse(last) as { type?: string; continuedInSessionId?: string };
|
|
134
|
+
if (record.type !== "continued-in" || !record.continuedInSessionId) return null;
|
|
135
|
+
return record.continuedInSessionId;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Finds the immediate predecessor of a continued session by scanning the
|
|
143
|
+
* transcript directory for a sibling whose tail hands off TO this session
|
|
144
|
+
* (see continuedInTarget). Most recently modified wins. Returns the
|
|
145
|
+
* predecessor's raw session id (transcript file stem), or null.
|
|
146
|
+
*/
|
|
147
|
+
export function scanPredecessorTranscript(
|
|
148
|
+
transcriptPath: string,
|
|
149
|
+
sessionID: string,
|
|
150
|
+
maxFiles = 200,
|
|
151
|
+
): string | null {
|
|
152
|
+
const dir = dirname(transcriptPath);
|
|
153
|
+
let files: string[];
|
|
154
|
+
try {
|
|
155
|
+
files = readdirSync(dir)
|
|
156
|
+
.filter((f) => f.endsWith(".jsonl") && f !== basename(transcriptPath))
|
|
157
|
+
.map((f) => {
|
|
158
|
+
try {
|
|
159
|
+
return { f, m: statSync(join(dir, f)).mtimeMs };
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.filter((e): e is { f: string; m: number } => e !== null)
|
|
165
|
+
.sort((a, b) => b.m - a.m)
|
|
166
|
+
.slice(0, maxFiles)
|
|
167
|
+
.map((e) => e.f);
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
for (const f of files) {
|
|
172
|
+
if (continuedInTarget(join(dir, f)) === sessionID) {
|
|
173
|
+
return f.slice(0, -".jsonl".length);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Walks a continuation chain back to the nearest session that has a chat
|
|
181
|
+
* directory row. Claude Code sessions fork on resume (new session id, same
|
|
182
|
+
* conversation), so a hook registering an unknown session id can adopt the
|
|
183
|
+
* predecessor's identity - same name, same mailbox - instead of stranding
|
|
184
|
+
* it. `isRegistered` maps a raw host session id to its chat session id when
|
|
185
|
+
* that row exists, else null. Bounded by maxHops; multi-hop forks resolve
|
|
186
|
+
* to the oldest registered ancestor.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveRegisteredPredecessor(
|
|
189
|
+
transcriptPath: string,
|
|
190
|
+
sessionID: string,
|
|
191
|
+
isRegistered: (rawSessionID: string) => string | null,
|
|
192
|
+
maxHops = 10,
|
|
193
|
+
): string | null {
|
|
194
|
+
let current = sessionID;
|
|
195
|
+
let currentPath = transcriptPath;
|
|
196
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
197
|
+
const pred = scanPredecessorTranscript(currentPath, current);
|
|
198
|
+
if (!pred) return null;
|
|
199
|
+
const registered = isRegistered(pred);
|
|
200
|
+
if (registered) return registered;
|
|
201
|
+
current = pred;
|
|
202
|
+
currentPath = join(dirname(transcriptPath), `${pred}.jsonl`);
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
101
207
|
/** Which kind of harness hosts a chat session: opencode rows are
|
|
102
|
-
* poller-driven (liveness =
|
|
208
|
+
* poller-driven (liveness = heartbeat fresh), mcp rows are turn-driven
|
|
103
209
|
* (liveness = a turn ran recently; mail is read at the next prompt). */
|
|
104
210
|
export type ChatHostKind = "opencode" | "mcp";
|
|
105
211
|
|
|
@@ -108,10 +214,11 @@ export type ChatHostKind = "opencode" | "mcp";
|
|
|
108
214
|
// into a context bomb; the reader fetches full content via chat_read anyway.
|
|
109
215
|
const MAX_BODY_LEN = 10_000;
|
|
110
216
|
|
|
111
|
-
// Assigned names are lowercase slugs with a numeric counter
|
|
112
|
-
// ("
|
|
113
|
-
// exported for the pool conformance test. Slugs never contain
|
|
114
|
-
// so the name-cannot-shadow-an-ID invariant in find() still
|
|
217
|
+
// Assigned names are lowercase slugs of a pool name with a numeric counter
|
|
218
|
+
// suffix ("al-go-rithm-00001"), which satisfies NAME_CHARSET. The charset
|
|
219
|
+
// stays exported for the pool conformance test. Slugs never contain
|
|
220
|
+
// underscores, so the name-cannot-shadow-an-ID invariant in find() still
|
|
221
|
+
// holds.
|
|
115
222
|
export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
|
|
116
223
|
|
|
117
224
|
// Topics are free text (unlike names) but must stay one line for the
|
|
@@ -122,7 +229,7 @@ const MAX_TOPIC_LEN = 80;
|
|
|
122
229
|
// many days are pruned from the directory. Counters never decrement, so a
|
|
123
230
|
// pruned session's name is never reissued - pruning cannot create identity
|
|
124
231
|
// confusion, only roster silence.
|
|
125
|
-
|
|
232
|
+
const CHAT_AUTO_TTL_DAYS = 7;
|
|
126
233
|
|
|
127
234
|
/**
|
|
128
235
|
* Slugifies a session title into a name base: lowercase, letter/number runs
|
|
@@ -156,12 +263,19 @@ export function isDefaultSessionTitle(title: string): boolean {
|
|
|
156
263
|
return /^New session - /.test(title) || /^Child session - /.test(title);
|
|
157
264
|
}
|
|
158
265
|
|
|
159
|
-
//
|
|
160
|
-
//
|
|
266
|
+
// Poller cadence. A hosting harness beats each of its sessions once per
|
|
267
|
+
// interval and delivers their mail on the same tick.
|
|
161
268
|
export const CHAT_POLL_INTERVAL_MS = 30_000;
|
|
162
|
-
|
|
269
|
+
// Staleness threshold, shared by the poller, chat_list, chat_send, and the
|
|
270
|
+
// broadcast skip so every surface agrees on what "stale" means: a row that
|
|
271
|
+
// has missed two consecutive beats belongs to a harness that stopped
|
|
272
|
+
// (crash, kill, machine asleep). Two beats rather than one so a single
|
|
273
|
+
// late poll cycle does not flap the roster.
|
|
274
|
+
export const CHAT_STALE_MS = 2 * CHAT_POLL_INTERVAL_MS;
|
|
275
|
+
// Wake re-nudge window and the per-recipient hourly nudge cap (the anti-loop
|
|
276
|
+
// hard brake); see ChatPoller.
|
|
163
277
|
export const CHAT_RENUDGE_MINUTES = 15;
|
|
164
|
-
|
|
278
|
+
const CHAT_MAX_NUDGES_PER_HOUR = 6;
|
|
165
279
|
|
|
166
280
|
/** Human-readable age of an ISO timestamp: "45s ago", "5m ago", "3h ago",
|
|
167
281
|
* "2d ago". Shared by the CLI roster and the chat_list tool so both
|
|
@@ -176,6 +290,96 @@ export function humanAge(iso: string, now = Date.now()): string {
|
|
|
176
290
|
return `${Math.floor(minutes / (24 * 60))}d ago`;
|
|
177
291
|
}
|
|
178
292
|
|
|
293
|
+
/** The liveness verdict for one roster row, from heartbeat age alone:
|
|
294
|
+
* - "fresh"/"stale" for opencode rows (poller-driven heartbeat; stale means * the hosting harness has missed two beats and is presumed gone)
|
|
295
|
+
* - "active"/"idle" for MCP rows (turn-driven; idle between prompts is the
|
|
296
|
+
* NORMAL state, so an idle MCP row is reachable, not dead)
|
|
297
|
+
* Heartbeat age is the only signal on purpose. A process id cannot serve:
|
|
298
|
+
* the same session is re-hosted by a new process on every `-s` resume, so
|
|
299
|
+
* a pid stamped on the row lies as soon as a different harness beats it.
|
|
300
|
+
* One clock, one rule. */
|
|
301
|
+
export function chatLiveness(row: ChatSessionRow, now = Date.now()): "fresh" | "stale" | "active" | "idle" {
|
|
302
|
+
const stale = isStale(row, now);
|
|
303
|
+
if (row.host_kind === "mcp") return stale ? "idle" : "active";
|
|
304
|
+
return stale ? "stale" : "fresh";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Orders roster rows for display: project first (so a shared-tree reader
|
|
308
|
+
* sees each repo's sessions together; rows with no project sort last),
|
|
309
|
+
* then by heartbeat, most recently seen first, so the recoverable rows
|
|
310
|
+
* lead and the long-dead trail at the bottom. Pure ordering - it never
|
|
311
|
+
* drops rows; the stale display cap is splitChatRoster's job. */
|
|
312
|
+
export function sortChatRoster(rows: ChatSessionRow[]): ChatSessionRow[] {
|
|
313
|
+
return [...rows].sort((a, b) => {
|
|
314
|
+
const projectA = a.project ?? "\uffff";
|
|
315
|
+
const projectB = b.project ?? "\uffff";
|
|
316
|
+
if (projectA !== projectB) return projectA.localeCompare(projectB);
|
|
317
|
+
return Date.parse(b.last_seen) - Date.parse(a.last_seen);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Splits the roster for the two-section display: Active (wake-able or
|
|
322
|
+
* reachable - fresh opencode rows and every MCP row, which read mail at
|
|
323
|
+
* their next prompt) and Stale (opencode rows whose harness has stopped
|
|
324
|
+
* reporting; mail to them waits until they resume).
|
|
325
|
+
* staleCapMs bounds how old a stale row the roster displays, so
|
|
326
|
+
* long-dead sessions cannot bury the fresh signal; hidden rows come
|
|
327
|
+
* back only as the staleHidden count, so the CLI can say what it left
|
|
328
|
+
* out. null (the default) shows every stale row - the tool surface has
|
|
329
|
+
* no cap, only the CLI applies one. */
|
|
330
|
+
export function splitChatRoster(rows: ChatSessionRow[], now = Date.now(), staleCapMs: number | null = null): { active: ChatSessionRow[]; stale: ChatSessionRow[]; staleHidden: number } {
|
|
331
|
+
const active: ChatSessionRow[] = [];
|
|
332
|
+
const stale: ChatSessionRow[] = [];
|
|
333
|
+
let staleHidden = 0;
|
|
334
|
+
for (const row of rows) {
|
|
335
|
+
if (chatLiveness(row, now) === "stale") {
|
|
336
|
+
if (staleCapMs !== null && now - Date.parse(row.last_seen) > staleCapMs) {
|
|
337
|
+
staleHidden++;
|
|
338
|
+
} else {
|
|
339
|
+
stale.push(row);
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
active.push(row);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { active, stale, staleHidden };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The wake-delivery gate, shared by the chat poller and the watcher
|
|
350
|
+
* registry: can this session be woken with a prompt right now? Three
|
|
351
|
+
* layers - the compacting set, the event-fed status map (cheap pre-filter
|
|
352
|
+
* for KNOWN busy/retry states), and the server's authoritative live status
|
|
353
|
+
* map. ABSENCE from the event-fed map is not evidence of busy: the `-s`
|
|
354
|
+
* startup session is hosted from init, before it has emitted any status
|
|
355
|
+
* event, so absence falls through to the live check. A gate that rejected
|
|
356
|
+
* on absence would leave that session mail-deaf - beating fine, never
|
|
357
|
+
* woken - until its first turn. The live
|
|
358
|
+
* map only carries ACTIVE sessions - the server deletes idle entries - so
|
|
359
|
+
* absent means idle there; busy/retry fails closed, as does an unreachable
|
|
360
|
+
* server (the mail stays pending and retries).
|
|
361
|
+
*/
|
|
362
|
+
export function createWakeGate(deps: {
|
|
363
|
+
isCompacting: (sessionID: string) => boolean;
|
|
364
|
+
mappedStatus: (sessionID: string) => string | undefined;
|
|
365
|
+
fetchStatuses: () => Promise<Record<string, { type?: string } | undefined>>;
|
|
366
|
+
onStatusError?: (sessionID: string, err: unknown) => void;
|
|
367
|
+
}): (sessionID: string) => Promise<boolean> {
|
|
368
|
+
return async (sessionID: string) => {
|
|
369
|
+
if (deps.isCompacting(sessionID)) return false;
|
|
370
|
+
const mapped = deps.mappedStatus(sessionID);
|
|
371
|
+
if (mapped === "busy" || mapped === "retry") return false;
|
|
372
|
+
try {
|
|
373
|
+
const statuses = await deps.fetchStatuses();
|
|
374
|
+
const live = statuses[sessionID];
|
|
375
|
+
return !live || live.type === "idle";
|
|
376
|
+
} catch (err) {
|
|
377
|
+
deps.onStatusError?.(sessionID, err);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
179
383
|
/**
|
|
180
384
|
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
181
385
|
* Database handle; the tables are created by ThatchDB's schema init.
|
|
@@ -190,17 +394,20 @@ export class ChatStore {
|
|
|
190
394
|
/**
|
|
191
395
|
* Joins the directory, or refreshes an existing registration. Names are
|
|
192
396
|
* assigned by the system, never claimed: a new session gets
|
|
193
|
-
* "<slug>-<counter>" (e.g. "
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* name
|
|
198
|
-
*
|
|
397
|
+
* "<slug>-<counter>" (e.g. "al-go-rithm-00001"), where the slug is a
|
|
398
|
+
* random pool name (chat-names.ts) and the per-base counter only ever
|
|
399
|
+
* increments - so a name is minted exactly once, machine-wide, and
|
|
400
|
+
* pruning an old session can never reissue its name to someone else. The
|
|
401
|
+
* session title never feeds the name; it rides along as the topic, so a
|
|
402
|
+
* placeholder or later-edited title cannot leave a misleading name
|
|
403
|
+
* behind. Idempotent for an already-registered session: it keeps its
|
|
404
|
+
* name and just refreshes liveness.
|
|
199
405
|
*
|
|
200
|
-
* `nameBase`
|
|
201
|
-
*
|
|
202
|
-
* null leaves the topic unset. Every row minted here
|
|
203
|
-
* rows with auto=0 exist only as legacy data from the
|
|
406
|
+
* `nameBase` overrides the pool draw with a fixed base (tests only; every
|
|
407
|
+
* production caller passes null). `topic` is the live title for
|
|
408
|
+
* auto-registrations; null leaves the topic unset. Every row minted here
|
|
409
|
+
* is marked auto=1 - rows with auto=0 exist only as legacy data from the
|
|
410
|
+
* claimed-names era.
|
|
204
411
|
*/
|
|
205
412
|
register(
|
|
206
413
|
sessionID: string,
|
|
@@ -209,9 +416,13 @@ export class ChatStore {
|
|
|
209
416
|
kind: ChatHostKind,
|
|
210
417
|
nameBase: string | null = null,
|
|
211
418
|
worktree: ChatWorktreeKind = null,
|
|
212
|
-
): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
|
|
419
|
+
): { ok: true; name: string; topic: string | null; created: boolean } | { ok: false; error: string } {
|
|
213
420
|
const existing = this.#find(sessionID);
|
|
214
421
|
if (existing) {
|
|
422
|
+
// Reclaim: registration is keyed by session id, so a session
|
|
423
|
+
// continued in a new harness process (`opencode -s <id>`) reclaims
|
|
424
|
+
// its row and name by re-registering. Touching last_seen is the
|
|
425
|
+
// whole of ownership: the harness that beats a row is its host.
|
|
215
426
|
this.#touch(sessionID);
|
|
216
427
|
// An explicit registration clears the leave tombstone for its own
|
|
217
428
|
// session ID: "I want back in" is the opposite of "I left". This is
|
|
@@ -222,7 +433,7 @@ export class ChatStore {
|
|
|
222
433
|
// session.deleted suppresses exactly that session.
|
|
223
434
|
const gate = this.#tombstoneGate(sessionID, kind);
|
|
224
435
|
if (gate) return gate;
|
|
225
|
-
return { ok: true, name: existing.name, topic: existing.topic };
|
|
436
|
+
return { ok: true, name: existing.name, topic: existing.topic, created: false };
|
|
226
437
|
}
|
|
227
438
|
const gate = this.#tombstoneGate(sessionID, kind);
|
|
228
439
|
if (gate) return gate;
|
|
@@ -239,12 +450,12 @@ export class ChatStore {
|
|
|
239
450
|
const next = this.#bumpCounter(base);
|
|
240
451
|
const name = `${base}-${String(next).padStart(5, "0")}`;
|
|
241
452
|
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
|
|
242
|
-
if (claimed.ok) return { ok: true, name, topic: cleanTopic };
|
|
453
|
+
if (claimed.ok) return { ok: true, name, topic: cleanTopic, created: true };
|
|
243
454
|
if (claimed.reason === "already-registered") {
|
|
244
455
|
// A concurrent registration of the same session ID won the race;
|
|
245
456
|
// report its row rather than erroring.
|
|
246
457
|
const winner = this.#find(sessionID);
|
|
247
|
-
if (winner) return { ok: true, name: winner.name, topic: winner.topic };
|
|
458
|
+
if (winner) return { ok: true, name: winner.name, topic: winner.topic, created: false };
|
|
248
459
|
}
|
|
249
460
|
}
|
|
250
461
|
return { ok: false, error: "Could not assign a unique name after several attempts." };
|
|
@@ -433,7 +644,7 @@ export class ChatStore {
|
|
|
433
644
|
// Staleness means "host process gone" only for opencode rows; an
|
|
434
645
|
// mcp row between turns reads its mail at the next prompt, so it
|
|
435
646
|
// always receives.
|
|
436
|
-
if (row.host_kind === "opencode" && isStale(row
|
|
647
|
+
if (row.host_kind === "opencode" && isStale(row)) {
|
|
437
648
|
skipped.push(row.name);
|
|
438
649
|
continue;
|
|
439
650
|
}
|
|
@@ -487,6 +698,65 @@ export class ChatStore {
|
|
|
487
698
|
return row.n;
|
|
488
699
|
}
|
|
489
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Records (or refreshes) the hook-parent-process -> session mapping: the
|
|
703
|
+
* identity anchor that closes the caller-claimed `as` hole on Claude Code.
|
|
704
|
+
* Hooks receive the true session id from the host and run as children of
|
|
705
|
+
* the same Claude Code process that spawned the MCP server, so the server
|
|
706
|
+
* can resolve its own parent pid against this mapping - a fact the model
|
|
707
|
+
* cannot influence. Recorded only from payloads that carried Claude Code's
|
|
708
|
+
* `session_id`; Cursor's hooks come from a shared extension host with
|
|
709
|
+
* per-workspace MCP servers, so ppid is ambiguous there and `as` stays the
|
|
710
|
+
* identity source.
|
|
711
|
+
*/
|
|
712
|
+
recordHostPid(ppid: number, sessionID: string): void {
|
|
713
|
+
this.#db.run(
|
|
714
|
+
`INSERT INTO chat_host_pids (ppid, session_id, seen_at) VALUES (?, ?, ?)
|
|
715
|
+
ON CONFLICT(ppid) DO UPDATE SET session_id = excluded.session_id, seen_at = excluded.seen_at`,
|
|
716
|
+
[ppid, sessionID, nowIso()],
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Resolves a host process id to a chat session id, when a hook recorded
|
|
722
|
+
* the mapping recently and the session still exists in the directory.
|
|
723
|
+
* Freshness bounds the pid-reuse hazard: a dead host's pid can be
|
|
724
|
+
* reassigned by the OS, and a stale mapping must not hand the new
|
|
725
|
+
* occupant the old conversation's identity.
|
|
726
|
+
*/
|
|
727
|
+
findSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
|
|
728
|
+
const cutoff = isoMinutesAgo(Math.ceil(maxAgeSeconds / 60));
|
|
729
|
+
const row = this.#db
|
|
730
|
+
.query(
|
|
731
|
+
`SELECT p.session_id FROM chat_host_pids p
|
|
732
|
+
JOIN chat_sessions s ON s.session_id = p.session_id
|
|
733
|
+
WHERE p.ppid = ? AND p.seen_at >= ?`,
|
|
734
|
+
)
|
|
735
|
+
.get(ppid, cutoff) as { session_id: string } | undefined;
|
|
736
|
+
return row?.session_id ?? null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Migrates a chat identity onto a continued session id. Claude Code forks
|
|
741
|
+
* the session id on resume/fork while the conversation (name, mailbox,
|
|
742
|
+
* leave state) belongs to the human-level conversation, so the old row's
|
|
743
|
+
* key moves forward and everything referencing the old key follows:
|
|
744
|
+
* message sender/recipient columns (no foreign keys by design - the
|
|
745
|
+
* departed-sender degradation must not apply to a continued conversation),
|
|
746
|
+
* hook-recorded host-pid anchors, and leave tombstones. No-op when the
|
|
747
|
+
* old row does not exist.
|
|
748
|
+
*/
|
|
749
|
+
continueSession(oldSessionID: string, newSessionID: string): void {
|
|
750
|
+
if (oldSessionID === newSessionID) return;
|
|
751
|
+
const row = this.#db.query("SELECT session_id FROM chat_sessions WHERE session_id = ?").get(oldSessionID);
|
|
752
|
+
if (!row) return;
|
|
753
|
+
this.#db.run("UPDATE chat_sessions SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
|
|
754
|
+
this.#db.run("UPDATE chat_messages SET from_session = ? WHERE from_session = ?", [newSessionID, oldSessionID]);
|
|
755
|
+
this.#db.run("UPDATE chat_messages SET to_session = ? WHERE to_session = ?", [newSessionID, oldSessionID]);
|
|
756
|
+
this.#db.run("UPDATE chat_host_pids SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
|
|
757
|
+
this.#db.run("UPDATE chat_leave_tombstones SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
|
|
758
|
+
}
|
|
759
|
+
|
|
490
760
|
/**
|
|
491
761
|
* The caller's mailbox summary for chat_status: whether a directory row
|
|
492
762
|
* exists, and pending/total counts. Touches last_seen on use, which is
|
|
@@ -534,18 +804,15 @@ export class ChatStore {
|
|
|
534
804
|
).map((r) => ({ name: r.name, pending: r.pending }));
|
|
535
805
|
}
|
|
536
806
|
|
|
537
|
-
/** Refreshes last_seen for the given sessions
|
|
538
|
-
*
|
|
539
|
-
* them - no filtering needed
|
|
807
|
+
/** Refreshes last_seen for the given sessions - the heartbeat that keeps
|
|
808
|
+
* them out of the stale section. Sessions that never registered are absent from
|
|
809
|
+
* chat_sessions, so the UPDATE is a no-op for them - no filtering needed
|
|
810
|
+
* on the caller's side. */
|
|
540
811
|
heartbeat(sessionIDs: string[]): void {
|
|
541
812
|
if (sessionIDs.length === 0) return;
|
|
542
813
|
const marks = sessionIDs.map(() => "?").join(",");
|
|
543
|
-
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
|
|
544
|
-
nowIso(),
|
|
545
|
-
...sessionIDs,
|
|
546
|
-
]);
|
|
814
|
+
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [nowIso(), ...sessionIDs]);
|
|
547
815
|
}
|
|
548
|
-
|
|
549
816
|
/**
|
|
550
817
|
* The full message feed for `thatch chat tail`: every row with sender and
|
|
551
818
|
* recipient display names resolved (departed senders degrade to unknown),
|
|
@@ -772,9 +1039,27 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
|
|
|
772
1039
|
// Tail events (pure logic behind `thatch chat tail`)
|
|
773
1040
|
// ---------------------------------------------------------------------------
|
|
774
1041
|
|
|
1042
|
+
/**
|
|
1043
|
+
* One line of the `thatch chat tail` event log. Sending a message and
|
|
1044
|
+
* reading it are two events, each on its own line, linked by `id` (the
|
|
1045
|
+
* message row id). Broadcast fan-out is one message row per recipient, so
|
|
1046
|
+
* a broadcast to N sessions is N sent events with `broadcast: true`, each
|
|
1047
|
+
* naming its real recipient and tracking its own read. Timestamps are the
|
|
1048
|
+
* database's ISO-8601 UTC strings, untouched.
|
|
1049
|
+
*/
|
|
775
1050
|
export type ChatTailEvent =
|
|
776
|
-
| {
|
|
777
|
-
|
|
1051
|
+
| {
|
|
1052
|
+
event: "sent";
|
|
1053
|
+
at: string;
|
|
1054
|
+
id: number;
|
|
1055
|
+
from: string;
|
|
1056
|
+
from_topic: string | null;
|
|
1057
|
+
to: string;
|
|
1058
|
+
to_topic: string | null;
|
|
1059
|
+
broadcast: boolean;
|
|
1060
|
+
body: string;
|
|
1061
|
+
}
|
|
1062
|
+
| { event: "read"; at: string; id: number; reader: string; reader_topic: string | null; from: string; from_topic: string | null };
|
|
778
1063
|
|
|
779
1064
|
/** One message row as the tail diff consumes it. */
|
|
780
1065
|
export interface ChatTailRow {
|
|
@@ -807,25 +1092,27 @@ export function chatTailDiff(
|
|
|
807
1092
|
const isNew = !prev.has(r.id);
|
|
808
1093
|
if (isNew) {
|
|
809
1094
|
events.push({
|
|
810
|
-
|
|
811
|
-
|
|
1095
|
+
event: "sent",
|
|
1096
|
+
at: r.created_at,
|
|
1097
|
+
id: r.id,
|
|
812
1098
|
from: r.from ?? "unknown",
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1099
|
+
from_topic: r.fromTopic,
|
|
1100
|
+
to: r.to ?? "unknown",
|
|
1101
|
+
to_topic: r.toTopic,
|
|
1102
|
+
broadcast: r.viaBroadcast,
|
|
816
1103
|
body: r.body,
|
|
817
1104
|
});
|
|
818
1105
|
} else {
|
|
819
1106
|
const prevRead = prev.get(r.id) ?? null;
|
|
820
1107
|
if (r.read_at !== null && prevRead === null) {
|
|
821
1108
|
events.push({
|
|
822
|
-
|
|
823
|
-
|
|
1109
|
+
event: "read",
|
|
1110
|
+
at: r.read_at,
|
|
1111
|
+
id: r.id,
|
|
824
1112
|
reader: r.to ?? "unknown",
|
|
1113
|
+
reader_topic: r.toTopic,
|
|
825
1114
|
from: r.from ?? "unknown",
|
|
826
|
-
|
|
827
|
-
toTopic: r.toTopic,
|
|
828
|
-
body: r.body,
|
|
1115
|
+
from_topic: r.fromTopic,
|
|
829
1116
|
});
|
|
830
1117
|
}
|
|
831
1118
|
}
|
|
@@ -835,7 +1122,7 @@ export function chatTailDiff(
|
|
|
835
1122
|
}
|
|
836
1123
|
|
|
837
1124
|
/**
|
|
838
|
-
* The default number of backlog
|
|
1125
|
+
* The default number of backlog messages `thatch chat tail` prints before
|
|
839
1126
|
* follow mode takes over. The message table is machine-scale but the
|
|
840
1127
|
* history still grows forever, so an unbounded default would dump the
|
|
841
1128
|
* entire database on every run.
|
|
@@ -844,7 +1131,8 @@ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
|
|
|
844
1131
|
|
|
845
1132
|
/**
|
|
846
1133
|
* Parses a tail time-bound value into epoch milliseconds, interpreted in
|
|
847
|
-
* the terminal's local timezone (
|
|
1134
|
+
* the terminal's local timezone (event timestamps print in UTC; the bounds
|
|
1135
|
+
* are typed by a human at a terminal, so they read as local time).
|
|
848
1136
|
* Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
|
|
849
1137
|
* space or a T between the date and the time. The component round-trip
|
|
850
1138
|
* rejects impossible calendar values like 2026-09-31 and 25:99, which
|
|
@@ -884,11 +1172,10 @@ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi:
|
|
|
884
1172
|
* test the message body; `fromSubstr`/`toSubstr` are case-insensitive
|
|
885
1173
|
* substrings of the rendered participant names (so a departed sender can
|
|
886
1174
|
* only match as "unknown (...)", the string the reader actually sees).
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
* window. An empty field is no constraint.
|
|
1175
|
+
* Broadcast fan-out rows carry their real recipient in `to`, so `--to
|
|
1176
|
+
* <name>` matches them like any direct message. `sinceMs`/`untilMs`
|
|
1177
|
+
* bound created_at in a since-inclusive, until-exclusive window. An empty
|
|
1178
|
+
* field is no constraint.
|
|
892
1179
|
*
|
|
893
1180
|
* Name inputs are not stable across polls: they are re-resolved from the
|
|
894
1181
|
* live directory on every feed snapshot, so an unregister or rename
|
|
@@ -922,16 +1209,18 @@ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFil
|
|
|
922
1209
|
/**
|
|
923
1210
|
* Prepares the tail's first render. The diff state seeds from EVERY row in
|
|
924
1211
|
* the feed - filtered or not, rendered or not - for two reasons. First,
|
|
925
|
-
* the limit hides
|
|
926
|
-
*
|
|
1212
|
+
* the limit hides messages, not history: a state seeded only from the
|
|
1213
|
+
* printed slice would re-emit the elided history as new sent events on
|
|
927
1214
|
* the first follow poll. Second, the name filters test JOIN-resolved
|
|
928
1215
|
* participant names that can flip mid-follow (a peer unregisters and its
|
|
929
1216
|
* history starts rendering as "unknown (... departed)", or it re-registers
|
|
930
1217
|
* under a new name); seeding everything means an old row can never resurface
|
|
931
1218
|
* as a sent event no matter how its rendered name changes. The last
|
|
932
|
-
* `limit` filtered rows (null = all) shape into sent events
|
|
933
|
-
*
|
|
934
|
-
*
|
|
1219
|
+
* `limit` filtered rows (null = all) shape into sent events, plus a read
|
|
1220
|
+
* event for each row already read, via chatTailDiff, so event shaping
|
|
1221
|
+
* keeps a single source. `shown` and `elided` count MESSAGES (not
|
|
1222
|
+
* events: a shown message contributes one or two lines) for the CLI's
|
|
1223
|
+
* summary line.
|
|
935
1224
|
*
|
|
936
1225
|
* The caller must re-apply the same filter to every follow poll's feed
|
|
937
1226
|
* BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
|
|
@@ -942,135 +1231,31 @@ export function chatTailBacklog(
|
|
|
942
1231
|
rows: Array<ChatTailRow>,
|
|
943
1232
|
filter: ChatTailFilter,
|
|
944
1233
|
limit: number | null,
|
|
945
|
-
): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
|
|
1234
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null>; shown: number; elided: number } {
|
|
946
1235
|
const state = new Map(rows.map((r) => [r.id, r.read_at]));
|
|
947
1236
|
const filtered = filterChatTailRows(rows, filter);
|
|
948
1237
|
const shown = limit === null ? filtered : filtered.slice(-limit);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
}
|
|
958
|
-
const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
|
|
959
|
-
return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/** "2026-09-12 19:46 MT" - the UTC timestamp converted to the terminal's
|
|
963
|
-
* local timezone (abbreviation from the locale, fallback "UTC"), one line
|
|
964
|
-
* for the card header. */
|
|
965
|
-
function localWhen(timestamp: string): string {
|
|
966
|
-
const date = new Date(timestamp);
|
|
967
|
-
if (Number.isNaN(date.getTime())) return timestamp;
|
|
968
|
-
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
969
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
970
|
-
return (
|
|
971
|
-
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
972
|
-
`${pad(date.getHours())}:${pad(date.getMinutes())} ${tz}`
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Renders one tail event as a chat card: header block (From / To / When),
|
|
978
|
-
* blank line, full body, then a separator line. This is the human format
|
|
979
|
-
* for `thatch chat tail` - the single-line formatChatTailEvent remains for
|
|
980
|
-
* tests and compact contexts. Read events use the same card shape with the
|
|
981
|
-
* reader in the From slot; broadcast events show "broadcast" as the
|
|
982
|
-
* recipient. The separator is between cards, so the caller joins cards
|
|
983
|
-
* with it and never gets a trailing rule.
|
|
984
|
-
*/
|
|
985
|
-
// ANSI styling for the tail cards: black-on-green field-label chips, green
|
|
986
|
-
// values, dim for the timestamps and the "read" marker, and a drawn rule
|
|
987
|
-
// for the separator. Bare ESC sequences rather than a color library - the
|
|
988
|
-
// tail is a terminal surface, and the codes are trivial.
|
|
989
|
-
const ANSI = {
|
|
990
|
-
// Label chips are padded to a fixed width (5 + padding = 7 columns) so
|
|
991
|
-
// every line's content starts at the same column - To's 2-char label
|
|
992
|
-
// misaligned against 4-char labels without this.
|
|
993
|
-
labelBg: (s: string) => `\x1b[30;42m ${s.padEnd(5)} \x1b[0m`,
|
|
994
|
-
valueFg: (s: string) => `\x1b[32m${s}\x1b[0m`, // green (field values)
|
|
995
|
-
dim: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim ("read" marker)
|
|
996
|
-
rule: (s: string) => `\x1b[2m${s}\x1b[0m`, // dim (the drawn separator)
|
|
997
|
-
// Session-of-origin annotation: italic + bright-black (muted gray).
|
|
998
|
-
originLabel: (s: string) => `\x1b[3;90m<${s}>\x1b[0m`,
|
|
999
|
-
};
|
|
1000
|
-
|
|
1001
|
-
const CARD_INDENT = ""; // cards sit flush with the separator (no extra indent)
|
|
1002
|
-
|
|
1003
|
-
/**
|
|
1004
|
-
* Renders one tail event as a card. renderBody lets the CLI re-render the
|
|
1005
|
-
* sent body (markdown-to-ANSI via an external renderer); the read-event
|
|
1006
|
-
* clip is never rendered - a 60-char truncated fragment is not a document.
|
|
1007
|
-
*/
|
|
1008
|
-
export function formatChatTailCard(event: ChatTailEvent, renderBody: (body: string) => string = (b) => b): string {
|
|
1009
|
-
const lines: string[] = [];
|
|
1010
|
-
const when = ANSI.dim(localWhen(event.timestamp));
|
|
1011
|
-
// The session-of-origin annotation: the participant's registered topic,
|
|
1012
|
-
// italic + muted bright-black, after the name. Omitted for participants
|
|
1013
|
-
// without a topic (and for the broadcast pseudo-recipient, whose topic
|
|
1014
|
-
// is null by construction).
|
|
1015
|
-
const origin = (topic: string | null) => (topic ? ` ${ANSI.originLabel(topic)}` : "");
|
|
1016
|
-
const chip = (label: string, value: string, topic: string | null) =>
|
|
1017
|
-
`${CARD_INDENT}${ANSI.labelBg(label)} ${ANSI.valueFg(value)}${origin(topic)}`;
|
|
1018
|
-
if (event.kind === "sent") {
|
|
1019
|
-
lines.push(chip("From", event.from, event.fromTopic));
|
|
1020
|
-
lines.push(chip("To", event.to, event.toTopic));
|
|
1021
|
-
lines.push(chip("When", when, null));
|
|
1022
|
-
lines.push("");
|
|
1023
|
-
lines.push(renderBody(event.body));
|
|
1024
|
-
} else {
|
|
1025
|
-
const clipped = event.body.length > 60 ? event.body.slice(0, 60) + "..." : event.body;
|
|
1026
|
-
lines.push(chip("From", event.reader, event.toTopic));
|
|
1027
|
-
lines.push(`${CARD_INDENT}${ANSI.dim("read")} ${ANSI.valueFg(event.from)}${origin(event.fromTopic)}`);
|
|
1028
|
-
lines.push(chip("When", when, null));
|
|
1029
|
-
lines.push("");
|
|
1030
|
-
lines.push(clipped);
|
|
1238
|
+
// A snapshot has the full history of each shown message: its send, and
|
|
1239
|
+
// its read if read_at is set. Emit both, in time order, so `--once`
|
|
1240
|
+
// prints the same event log a follow would have accumulated. Reads of
|
|
1241
|
+
// messages the limit hid stay hidden with them.
|
|
1242
|
+
const events: ChatTailEvent[] = [];
|
|
1243
|
+
for (const r of shown) {
|
|
1244
|
+
events.push(...chatTailDiff(new Map(), [r]).events);
|
|
1245
|
+
if (r.read_at !== null) events.push(...chatTailDiff(new Map([[r.id, null]]), [r]).events);
|
|
1031
1246
|
}
|
|
1032
|
-
|
|
1247
|
+
events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.event === b.event ? 0 : a.event === "sent" ? -1 : 1));
|
|
1248
|
+
return { events, state, shown: shown.length, elided: filtered.length - shown.length };
|
|
1033
1249
|
}
|
|
1034
1250
|
|
|
1035
|
-
export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
|
|
1036
|
-
|
|
1037
1251
|
/**
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
* -w 0: no wrapping - bodies overflow like they do unrendered, and
|
|
1044
|
-
* wrapping inside cards would rag the layout. -s dark: glow picks its
|
|
1045
|
-
* style from ITS stdout, which is always a pipe here, so the style is
|
|
1046
|
-
* forced; notty would keep literal "#" and "**" markers, which is not
|
|
1047
|
-
* rendering.
|
|
1252
|
+
* Renders one tail event as a JSON line. Plain JSON.stringify of the event
|
|
1253
|
+
* object: no ANSI, no markdown rendering, no local-time conversion. The
|
|
1254
|
+
* tail is a log, and a log is for grep and jq; a terminal reader who wants
|
|
1255
|
+
* color pipes through their own tool.
|
|
1048
1256
|
*/
|
|
1049
|
-
export function
|
|
1050
|
-
|
|
1051
|
-
if (gumPath) return [gumPath, "format", "-t", "markdown"];
|
|
1052
|
-
return null;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Normalizes a rendered markdown body: external renderers pad documents
|
|
1057
|
-
* with a uniform leading margin and surrounding blank lines, which would
|
|
1058
|
-
* shift the card layout and change plain-text bodies. Stripping the
|
|
1059
|
-
* common indent plus the blank lines makes a plain body round-trip
|
|
1060
|
-
* unchanged through its renderer.
|
|
1061
|
-
*/
|
|
1062
|
-
export function cleanRenderedBody(out: string): string {
|
|
1063
|
-
// Blank-line strip, not trim(): trim() would also eat the first body
|
|
1064
|
-
// line's own leading whitespace and leave it flush while later lines
|
|
1065
|
-
// stay indented.
|
|
1066
|
-
const lines = out.replace(/^\n+/, "").replace(/\n+$/, "").split("\n");
|
|
1067
|
-
let indent = Infinity;
|
|
1068
|
-
for (const line of lines) {
|
|
1069
|
-
if (!line.trim()) continue;
|
|
1070
|
-
indent = Math.min(indent, line.length - line.trimStart().length);
|
|
1071
|
-
}
|
|
1072
|
-
if (!Number.isFinite(indent) || indent === 0) return lines.join("\n");
|
|
1073
|
-
return lines.map((line) => line.slice(indent)).join("\n");
|
|
1257
|
+
export function formatChatTailJsonl(event: ChatTailEvent): string {
|
|
1258
|
+
return JSON.stringify(event);
|
|
1074
1259
|
}
|
|
1075
1260
|
|
|
1076
1261
|
// ---------------------------------------------------------------------------
|
|
@@ -1095,9 +1280,10 @@ export interface ChatPollerStore {
|
|
|
1095
1280
|
|
|
1096
1281
|
export interface ChatPollerOptions {
|
|
1097
1282
|
store: ChatPollerStore;
|
|
1098
|
-
/** Session IDs this process has seen events for
|
|
1099
|
-
*
|
|
1100
|
-
* deliverer, by
|
|
1283
|
+
/** Session IDs this process hosts: those it has seen events for plus the
|
|
1284
|
+
* `-s` startup session. Only sessions hosted by this process can be
|
|
1285
|
+
* delivered to - the recipient's host is the single deliverer, by
|
|
1286
|
+
* design. */
|
|
1101
1287
|
hostedSessions: () => string[];
|
|
1102
1288
|
/** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
|
|
1103
1289
|
deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
|
|
@@ -1273,8 +1459,9 @@ export class ChatPoller {
|
|
|
1273
1459
|
}
|
|
1274
1460
|
}
|
|
1275
1461
|
|
|
1276
|
-
/** True when a session row
|
|
1277
|
-
*
|
|
1278
|
-
|
|
1279
|
-
|
|
1462
|
+
/** True when a session row has missed two heartbeats (see CHAT_STALE_MS).
|
|
1463
|
+
* The one staleness rule: chat_list, chat_send's stale note, and the
|
|
1464
|
+
* broadcast skip all go through here so they can never disagree. */
|
|
1465
|
+
export function isStale(row: ChatSessionRow, now = Date.now()): boolean {
|
|
1466
|
+
return now - Date.parse(row.last_seen) > CHAT_STALE_MS;
|
|
1280
1467
|
}
|