@jeffober/thatch 0.1.42 → 0.1.44
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 +11 -2
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/thatch +281 -47
- package/docs/dev/README.md +9 -1
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +18 -11
- package/docs/dev/features/cross-session-chat.md +124 -40
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/dev/skills.md +1 -0
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +19 -2
- package/docs/user/cli.md +35 -37
- package/docs/user/cross-session-chat.md +92 -19
- package/docs/user/skills.md +1 -0
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +376 -147
- package/src/commands.ts +107 -0
- package/src/db.ts +54 -5
- package/src/debug.ts +44 -0
- package/src/git.ts +22 -0
- package/src/index.ts +264 -40
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +30 -2
- package/src/setup.ts +203 -33
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +107 -27
- 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
|
/**
|
|
@@ -46,6 +48,13 @@ export function isoMinutesAgo(minutes: number): string {
|
|
|
46
48
|
return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19) + "Z";
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** Where the session's host process is running its checkout: "root" for the
|
|
52
|
+
* project's main checkout, "worktree" for a linked git worktree, null when
|
|
53
|
+
* undetected (e.g. an MCP host with no directory context). Displayed in
|
|
54
|
+
* the roster so sessions coordinating on a shared tree can tell who sits
|
|
55
|
+
* where. */
|
|
56
|
+
export type ChatWorktreeKind = "root" | "worktree" | null;
|
|
57
|
+
|
|
49
58
|
export interface ChatSessionRow {
|
|
50
59
|
session_id: string;
|
|
51
60
|
name: string;
|
|
@@ -57,6 +66,7 @@ export interface ChatSessionRow {
|
|
|
57
66
|
host_kind: ChatHostKind;
|
|
58
67
|
registered_at: string;
|
|
59
68
|
last_seen: string;
|
|
69
|
+
worktree: ChatWorktreeKind;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** One message selected for wake-up delivery, with the sender's display name
|
|
@@ -90,8 +100,112 @@ export function mcpSessionID(hostSessionID: string): string {
|
|
|
90
100
|
return `mcp_${hash}`;
|
|
91
101
|
}
|
|
92
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
|
+
|
|
93
207
|
/** Which kind of harness hosts a chat session: opencode rows are
|
|
94
|
-
* poller-driven (liveness =
|
|
208
|
+
* poller-driven (liveness = heartbeat fresh), mcp rows are turn-driven
|
|
95
209
|
* (liveness = a turn ran recently; mail is read at the next prompt). */
|
|
96
210
|
export type ChatHostKind = "opencode" | "mcp";
|
|
97
211
|
|
|
@@ -100,10 +214,11 @@ export type ChatHostKind = "opencode" | "mcp";
|
|
|
100
214
|
// into a context bomb; the reader fetches full content via chat_read anyway.
|
|
101
215
|
const MAX_BODY_LEN = 10_000;
|
|
102
216
|
|
|
103
|
-
// Assigned names are lowercase slugs with a numeric counter
|
|
104
|
-
// ("
|
|
105
|
-
// exported for the pool conformance test. Slugs never contain
|
|
106
|
-
// 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.
|
|
107
222
|
export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
|
|
108
223
|
|
|
109
224
|
// Topics are free text (unlike names) but must stay one line for the
|
|
@@ -114,7 +229,7 @@ const MAX_TOPIC_LEN = 80;
|
|
|
114
229
|
// many days are pruned from the directory. Counters never decrement, so a
|
|
115
230
|
// pruned session's name is never reissued - pruning cannot create identity
|
|
116
231
|
// confusion, only roster silence.
|
|
117
|
-
|
|
232
|
+
const CHAT_AUTO_TTL_DAYS = 7;
|
|
118
233
|
|
|
119
234
|
/**
|
|
120
235
|
* Slugifies a session title into a name base: lowercase, letter/number runs
|
|
@@ -148,12 +263,94 @@ export function isDefaultSessionTitle(title: string): boolean {
|
|
|
148
263
|
return /^New session - /.test(title) || /^Child session - /.test(title);
|
|
149
264
|
}
|
|
150
265
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
266
|
+
// Poller cadence. A hosting harness beats each of its sessions once per
|
|
267
|
+
// interval and delivers their mail on the same tick.
|
|
153
268
|
export const CHAT_POLL_INTERVAL_MS = 30_000;
|
|
154
|
-
|
|
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.
|
|
155
277
|
export const CHAT_RENUDGE_MINUTES = 15;
|
|
156
|
-
|
|
278
|
+
const CHAT_MAX_NUDGES_PER_HOUR = 6;
|
|
279
|
+
|
|
280
|
+
/** Human-readable age of an ISO timestamp: "45s ago", "5m ago", "3h ago",
|
|
281
|
+
* "2d ago". Shared by the CLI roster and the chat_list tool so both
|
|
282
|
+
* surfaces render the same last-check-in wording. */
|
|
283
|
+
export function humanAge(iso: string, now = Date.now()): string {
|
|
284
|
+
const seconds = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
|
|
285
|
+
if (Number.isNaN(seconds)) return "unknown age";
|
|
286
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
287
|
+
const minutes = Math.floor(seconds / 60);
|
|
288
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
289
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ago`;
|
|
290
|
+
return `${Math.floor(minutes / (24 * 60))}d ago`;
|
|
291
|
+
}
|
|
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
|
+
/** Splits the roster for the two-section display: Active (wake-able or
|
|
308
|
+
* reachable - fresh opencode rows and every MCP row, which read mail at
|
|
309
|
+
* their next prompt) and Stale (opencode rows whose harness has stopped
|
|
310
|
+
* reporting; mail to them waits until they resume). */
|
|
311
|
+
export function splitChatRoster(rows: ChatSessionRow[], now = Date.now()): { active: ChatSessionRow[]; stale: ChatSessionRow[] } {
|
|
312
|
+
const active: ChatSessionRow[] = [];
|
|
313
|
+
const stale: ChatSessionRow[] = [];
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
(chatLiveness(row, now) === "stale" ? stale : active).push(row);
|
|
316
|
+
}
|
|
317
|
+
return { active, stale };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The wake-delivery gate, shared by the chat poller and the watcher
|
|
322
|
+
* registry: can this session be woken with a prompt right now? Three
|
|
323
|
+
* layers - the compacting set, the event-fed status map (cheap pre-filter
|
|
324
|
+
* for KNOWN busy/retry states), and the server's authoritative live status
|
|
325
|
+
* map. ABSENCE from the event-fed map is not evidence of busy: the `-s`
|
|
326
|
+
* startup session is hosted from init, before it has emitted any status
|
|
327
|
+
* event, so absence falls through to the live check. A gate that rejected
|
|
328
|
+
* on absence would leave that session mail-deaf - beating fine, never
|
|
329
|
+
* woken - until its first turn. The live
|
|
330
|
+
* map only carries ACTIVE sessions - the server deletes idle entries - so
|
|
331
|
+
* absent means idle there; busy/retry fails closed, as does an unreachable
|
|
332
|
+
* server (the mail stays pending and retries).
|
|
333
|
+
*/
|
|
334
|
+
export function createWakeGate(deps: {
|
|
335
|
+
isCompacting: (sessionID: string) => boolean;
|
|
336
|
+
mappedStatus: (sessionID: string) => string | undefined;
|
|
337
|
+
fetchStatuses: () => Promise<Record<string, { type?: string } | undefined>>;
|
|
338
|
+
onStatusError?: (sessionID: string, err: unknown) => void;
|
|
339
|
+
}): (sessionID: string) => Promise<boolean> {
|
|
340
|
+
return async (sessionID: string) => {
|
|
341
|
+
if (deps.isCompacting(sessionID)) return false;
|
|
342
|
+
const mapped = deps.mappedStatus(sessionID);
|
|
343
|
+
if (mapped === "busy" || mapped === "retry") return false;
|
|
344
|
+
try {
|
|
345
|
+
const statuses = await deps.fetchStatuses();
|
|
346
|
+
const live = statuses[sessionID];
|
|
347
|
+
return !live || live.type === "idle";
|
|
348
|
+
} catch (err) {
|
|
349
|
+
deps.onStatusError?.(sessionID, err);
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
157
354
|
|
|
158
355
|
/**
|
|
159
356
|
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
@@ -169,17 +366,20 @@ export class ChatStore {
|
|
|
169
366
|
/**
|
|
170
367
|
* Joins the directory, or refreshes an existing registration. Names are
|
|
171
368
|
* assigned by the system, never claimed: a new session gets
|
|
172
|
-
* "<slug>-<counter>" (e.g. "
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* name
|
|
177
|
-
*
|
|
369
|
+
* "<slug>-<counter>" (e.g. "al-go-rithm-00001"), where the slug is a
|
|
370
|
+
* random pool name (chat-names.ts) and the per-base counter only ever
|
|
371
|
+
* increments - so a name is minted exactly once, machine-wide, and
|
|
372
|
+
* pruning an old session can never reissue its name to someone else. The
|
|
373
|
+
* session title never feeds the name; it rides along as the topic, so a
|
|
374
|
+
* placeholder or later-edited title cannot leave a misleading name
|
|
375
|
+
* behind. Idempotent for an already-registered session: it keeps its
|
|
376
|
+
* name and just refreshes liveness.
|
|
178
377
|
*
|
|
179
|
-
* `nameBase`
|
|
180
|
-
*
|
|
181
|
-
* null leaves the topic unset. Every row minted here
|
|
182
|
-
* rows with auto=0 exist only as legacy data from the
|
|
378
|
+
* `nameBase` overrides the pool draw with a fixed base (tests only; every
|
|
379
|
+
* production caller passes null). `topic` is the live title for
|
|
380
|
+
* auto-registrations; null leaves the topic unset. Every row minted here
|
|
381
|
+
* is marked auto=1 - rows with auto=0 exist only as legacy data from the
|
|
382
|
+
* claimed-names era.
|
|
183
383
|
*/
|
|
184
384
|
register(
|
|
185
385
|
sessionID: string,
|
|
@@ -187,9 +387,14 @@ export class ChatStore {
|
|
|
187
387
|
topic: string | null,
|
|
188
388
|
kind: ChatHostKind,
|
|
189
389
|
nameBase: string | null = null,
|
|
190
|
-
|
|
390
|
+
worktree: ChatWorktreeKind = null,
|
|
391
|
+
): { ok: true; name: string; topic: string | null; created: boolean } | { ok: false; error: string } {
|
|
191
392
|
const existing = this.#find(sessionID);
|
|
192
393
|
if (existing) {
|
|
394
|
+
// Reclaim: registration is keyed by session id, so a session
|
|
395
|
+
// continued in a new harness process (`opencode -s <id>`) reclaims
|
|
396
|
+
// its row and name by re-registering. Touching last_seen is the
|
|
397
|
+
// whole of ownership: the harness that beats a row is its host.
|
|
193
398
|
this.#touch(sessionID);
|
|
194
399
|
// An explicit registration clears the leave tombstone for its own
|
|
195
400
|
// session ID: "I want back in" is the opposite of "I left". This is
|
|
@@ -200,7 +405,7 @@ export class ChatStore {
|
|
|
200
405
|
// session.deleted suppresses exactly that session.
|
|
201
406
|
const gate = this.#tombstoneGate(sessionID, kind);
|
|
202
407
|
if (gate) return gate;
|
|
203
|
-
return { ok: true, name: existing.name, topic: existing.topic };
|
|
408
|
+
return { ok: true, name: existing.name, topic: existing.topic, created: false };
|
|
204
409
|
}
|
|
205
410
|
const gate = this.#tombstoneGate(sessionID, kind);
|
|
206
411
|
if (gate) return gate;
|
|
@@ -216,13 +421,13 @@ export class ChatStore {
|
|
|
216
421
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
217
422
|
const next = this.#bumpCounter(base);
|
|
218
423
|
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 };
|
|
424
|
+
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
|
|
425
|
+
if (claimed.ok) return { ok: true, name, topic: cleanTopic, created: true };
|
|
221
426
|
if (claimed.reason === "already-registered") {
|
|
222
427
|
// A concurrent registration of the same session ID won the race;
|
|
223
428
|
// report its row rather than erroring.
|
|
224
429
|
const winner = this.#find(sessionID);
|
|
225
|
-
if (winner) return { ok: true, name: winner.name, topic: winner.topic };
|
|
430
|
+
if (winner) return { ok: true, name: winner.name, topic: winner.topic, created: false };
|
|
226
431
|
}
|
|
227
432
|
}
|
|
228
433
|
return { ok: false, error: "Could not assign a unique name after several attempts." };
|
|
@@ -335,7 +540,7 @@ export class ChatStore {
|
|
|
335
540
|
|
|
336
541
|
list(): ChatSessionRow[] {
|
|
337
542
|
return (this.#db
|
|
338
|
-
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
|
|
543
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions ORDER BY name")
|
|
339
544
|
.all() as any[]).map(rowFromSession);
|
|
340
545
|
}
|
|
341
546
|
|
|
@@ -411,7 +616,7 @@ export class ChatStore {
|
|
|
411
616
|
// Staleness means "host process gone" only for opencode rows; an
|
|
412
617
|
// mcp row between turns reads its mail at the next prompt, so it
|
|
413
618
|
// always receives.
|
|
414
|
-
if (row.host_kind === "opencode" && isStale(row
|
|
619
|
+
if (row.host_kind === "opencode" && isStale(row)) {
|
|
415
620
|
skipped.push(row.name);
|
|
416
621
|
continue;
|
|
417
622
|
}
|
|
@@ -465,6 +670,65 @@ export class ChatStore {
|
|
|
465
670
|
return row.n;
|
|
466
671
|
}
|
|
467
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Records (or refreshes) the hook-parent-process -> session mapping: the
|
|
675
|
+
* identity anchor that closes the caller-claimed `as` hole on Claude Code.
|
|
676
|
+
* Hooks receive the true session id from the host and run as children of
|
|
677
|
+
* the same Claude Code process that spawned the MCP server, so the server
|
|
678
|
+
* can resolve its own parent pid against this mapping - a fact the model
|
|
679
|
+
* cannot influence. Recorded only from payloads that carried Claude Code's
|
|
680
|
+
* `session_id`; Cursor's hooks come from a shared extension host with
|
|
681
|
+
* per-workspace MCP servers, so ppid is ambiguous there and `as` stays the
|
|
682
|
+
* identity source.
|
|
683
|
+
*/
|
|
684
|
+
recordHostPid(ppid: number, sessionID: string): void {
|
|
685
|
+
this.#db.run(
|
|
686
|
+
`INSERT INTO chat_host_pids (ppid, session_id, seen_at) VALUES (?, ?, ?)
|
|
687
|
+
ON CONFLICT(ppid) DO UPDATE SET session_id = excluded.session_id, seen_at = excluded.seen_at`,
|
|
688
|
+
[ppid, sessionID, nowIso()],
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Resolves a host process id to a chat session id, when a hook recorded
|
|
694
|
+
* the mapping recently and the session still exists in the directory.
|
|
695
|
+
* Freshness bounds the pid-reuse hazard: a dead host's pid can be
|
|
696
|
+
* reassigned by the OS, and a stale mapping must not hand the new
|
|
697
|
+
* occupant the old conversation's identity.
|
|
698
|
+
*/
|
|
699
|
+
findSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
|
|
700
|
+
const cutoff = isoMinutesAgo(Math.ceil(maxAgeSeconds / 60));
|
|
701
|
+
const row = this.#db
|
|
702
|
+
.query(
|
|
703
|
+
`SELECT p.session_id FROM chat_host_pids p
|
|
704
|
+
JOIN chat_sessions s ON s.session_id = p.session_id
|
|
705
|
+
WHERE p.ppid = ? AND p.seen_at >= ?`,
|
|
706
|
+
)
|
|
707
|
+
.get(ppid, cutoff) as { session_id: string } | undefined;
|
|
708
|
+
return row?.session_id ?? null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Migrates a chat identity onto a continued session id. Claude Code forks
|
|
713
|
+
* the session id on resume/fork while the conversation (name, mailbox,
|
|
714
|
+
* leave state) belongs to the human-level conversation, so the old row's
|
|
715
|
+
* key moves forward and everything referencing the old key follows:
|
|
716
|
+
* message sender/recipient columns (no foreign keys by design - the
|
|
717
|
+
* departed-sender degradation must not apply to a continued conversation),
|
|
718
|
+
* hook-recorded host-pid anchors, and leave tombstones. No-op when the
|
|
719
|
+
* old row does not exist.
|
|
720
|
+
*/
|
|
721
|
+
continueSession(oldSessionID: string, newSessionID: string): void {
|
|
722
|
+
if (oldSessionID === newSessionID) return;
|
|
723
|
+
const row = this.#db.query("SELECT session_id FROM chat_sessions WHERE session_id = ?").get(oldSessionID);
|
|
724
|
+
if (!row) return;
|
|
725
|
+
this.#db.run("UPDATE chat_sessions SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
|
|
726
|
+
this.#db.run("UPDATE chat_messages SET from_session = ? WHERE from_session = ?", [newSessionID, oldSessionID]);
|
|
727
|
+
this.#db.run("UPDATE chat_messages SET to_session = ? WHERE to_session = ?", [newSessionID, oldSessionID]);
|
|
728
|
+
this.#db.run("UPDATE chat_host_pids SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
|
|
729
|
+
this.#db.run("UPDATE chat_leave_tombstones SET session_id = ? WHERE session_id = ?", [newSessionID, oldSessionID]);
|
|
730
|
+
}
|
|
731
|
+
|
|
468
732
|
/**
|
|
469
733
|
* The caller's mailbox summary for chat_status: whether a directory row
|
|
470
734
|
* exists, and pending/total counts. Touches last_seen on use, which is
|
|
@@ -512,18 +776,15 @@ export class ChatStore {
|
|
|
512
776
|
).map((r) => ({ name: r.name, pending: r.pending }));
|
|
513
777
|
}
|
|
514
778
|
|
|
515
|
-
/** Refreshes last_seen for the given sessions
|
|
516
|
-
*
|
|
517
|
-
* them - no filtering needed
|
|
779
|
+
/** Refreshes last_seen for the given sessions - the heartbeat that keeps
|
|
780
|
+
* them out of the stale section. Sessions that never registered are absent from
|
|
781
|
+
* chat_sessions, so the UPDATE is a no-op for them - no filtering needed
|
|
782
|
+
* on the caller's side. */
|
|
518
783
|
heartbeat(sessionIDs: string[]): void {
|
|
519
784
|
if (sessionIDs.length === 0) return;
|
|
520
785
|
const marks = sessionIDs.map(() => "?").join(",");
|
|
521
|
-
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [
|
|
522
|
-
nowIso(),
|
|
523
|
-
...sessionIDs,
|
|
524
|
-
]);
|
|
786
|
+
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [nowIso(), ...sessionIDs]);
|
|
525
787
|
}
|
|
526
|
-
|
|
527
788
|
/**
|
|
528
789
|
* The full message feed for `thatch chat tail`: every row with sender and
|
|
529
790
|
* recipient display names resolved (departed senders degrade to unknown),
|
|
@@ -609,14 +870,14 @@ export class ChatStore {
|
|
|
609
870
|
|
|
610
871
|
#find(sessionID: string): ChatSessionRow | null {
|
|
611
872
|
const row = this.#db
|
|
612
|
-
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
|
|
873
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE session_id = ?")
|
|
613
874
|
.get(sessionID) as any;
|
|
614
875
|
return row ? rowFromSession(row) : null;
|
|
615
876
|
}
|
|
616
877
|
|
|
617
878
|
#findByName(name: string): ChatSessionRow | null {
|
|
618
879
|
const row = this.#db
|
|
619
|
-
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
|
|
880
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE name = ? COLLATE NOCASE")
|
|
620
881
|
.get(name) as any;
|
|
621
882
|
return row ? rowFromSession(row) : null;
|
|
622
883
|
}
|
|
@@ -694,11 +955,12 @@ export class ChatStore {
|
|
|
694
955
|
project: string | null,
|
|
695
956
|
topic: string | null,
|
|
696
957
|
kind: ChatHostKind,
|
|
958
|
+
worktree: ChatWorktreeKind,
|
|
697
959
|
): { ok: true; topic: string | null } | { ok: false; reason: "name-taken" | "already-registered" } {
|
|
698
960
|
try {
|
|
699
961
|
this.#db.run(
|
|
700
|
-
"INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
|
|
701
|
-
[sessionID, name, topic, project, kind, nowIso(), nowIso()],
|
|
962
|
+
"INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto, worktree) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
|
963
|
+
[sessionID, name, topic, project, kind, nowIso(), nowIso(), worktree ?? ""],
|
|
702
964
|
);
|
|
703
965
|
return { ok: true, topic };
|
|
704
966
|
} catch (err) {
|
|
@@ -726,6 +988,9 @@ function rowFromSession(r: any): ChatSessionRow {
|
|
|
726
988
|
project: r.project,
|
|
727
989
|
registered_at: r.registered_at,
|
|
728
990
|
last_seen: r.last_seen,
|
|
991
|
+
// Pre-migration rows carry the empty-string default; read back as
|
|
992
|
+
// undetected so the roster omits the token instead of printing a blank.
|
|
993
|
+
worktree: r.worktree === "root" || r.worktree === "worktree" ? r.worktree : null,
|
|
729
994
|
};
|
|
730
995
|
}
|
|
731
996
|
|
|
@@ -746,9 +1011,27 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
|
|
|
746
1011
|
// Tail events (pure logic behind `thatch chat tail`)
|
|
747
1012
|
// ---------------------------------------------------------------------------
|
|
748
1013
|
|
|
1014
|
+
/**
|
|
1015
|
+
* One line of the `thatch chat tail` event log. Sending a message and
|
|
1016
|
+
* reading it are two events, each on its own line, linked by `id` (the
|
|
1017
|
+
* message row id). Broadcast fan-out is one message row per recipient, so
|
|
1018
|
+
* a broadcast to N sessions is N sent events with `broadcast: true`, each
|
|
1019
|
+
* naming its real recipient and tracking its own read. Timestamps are the
|
|
1020
|
+
* database's ISO-8601 UTC strings, untouched.
|
|
1021
|
+
*/
|
|
749
1022
|
export type ChatTailEvent =
|
|
750
|
-
| {
|
|
751
|
-
|
|
1023
|
+
| {
|
|
1024
|
+
event: "sent";
|
|
1025
|
+
at: string;
|
|
1026
|
+
id: number;
|
|
1027
|
+
from: string;
|
|
1028
|
+
from_topic: string | null;
|
|
1029
|
+
to: string;
|
|
1030
|
+
to_topic: string | null;
|
|
1031
|
+
broadcast: boolean;
|
|
1032
|
+
body: string;
|
|
1033
|
+
}
|
|
1034
|
+
| { event: "read"; at: string; id: number; reader: string; reader_topic: string | null; from: string; from_topic: string | null };
|
|
752
1035
|
|
|
753
1036
|
/** One message row as the tail diff consumes it. */
|
|
754
1037
|
export interface ChatTailRow {
|
|
@@ -781,25 +1064,27 @@ export function chatTailDiff(
|
|
|
781
1064
|
const isNew = !prev.has(r.id);
|
|
782
1065
|
if (isNew) {
|
|
783
1066
|
events.push({
|
|
784
|
-
|
|
785
|
-
|
|
1067
|
+
event: "sent",
|
|
1068
|
+
at: r.created_at,
|
|
1069
|
+
id: r.id,
|
|
786
1070
|
from: r.from ?? "unknown",
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1071
|
+
from_topic: r.fromTopic,
|
|
1072
|
+
to: r.to ?? "unknown",
|
|
1073
|
+
to_topic: r.toTopic,
|
|
1074
|
+
broadcast: r.viaBroadcast,
|
|
790
1075
|
body: r.body,
|
|
791
1076
|
});
|
|
792
1077
|
} else {
|
|
793
1078
|
const prevRead = prev.get(r.id) ?? null;
|
|
794
1079
|
if (r.read_at !== null && prevRead === null) {
|
|
795
1080
|
events.push({
|
|
796
|
-
|
|
797
|
-
|
|
1081
|
+
event: "read",
|
|
1082
|
+
at: r.read_at,
|
|
1083
|
+
id: r.id,
|
|
798
1084
|
reader: r.to ?? "unknown",
|
|
1085
|
+
reader_topic: r.toTopic,
|
|
799
1086
|
from: r.from ?? "unknown",
|
|
800
|
-
|
|
801
|
-
toTopic: r.toTopic,
|
|
802
|
-
body: r.body,
|
|
1087
|
+
from_topic: r.fromTopic,
|
|
803
1088
|
});
|
|
804
1089
|
}
|
|
805
1090
|
}
|
|
@@ -809,7 +1094,7 @@ export function chatTailDiff(
|
|
|
809
1094
|
}
|
|
810
1095
|
|
|
811
1096
|
/**
|
|
812
|
-
* The default number of backlog
|
|
1097
|
+
* The default number of backlog messages `thatch chat tail` prints before
|
|
813
1098
|
* follow mode takes over. The message table is machine-scale but the
|
|
814
1099
|
* history still grows forever, so an unbounded default would dump the
|
|
815
1100
|
* entire database on every run.
|
|
@@ -818,7 +1103,8 @@ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
|
|
|
818
1103
|
|
|
819
1104
|
/**
|
|
820
1105
|
* Parses a tail time-bound value into epoch milliseconds, interpreted in
|
|
821
|
-
* the terminal's local timezone (
|
|
1106
|
+
* the terminal's local timezone (event timestamps print in UTC; the bounds
|
|
1107
|
+
* are typed by a human at a terminal, so they read as local time).
|
|
822
1108
|
* Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
|
|
823
1109
|
* space or a T between the date and the time. The component round-trip
|
|
824
1110
|
* rejects impossible calendar values like 2026-09-31 and 25:99, which
|
|
@@ -858,11 +1144,10 @@ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi:
|
|
|
858
1144
|
* test the message body; `fromSubstr`/`toSubstr` are case-insensitive
|
|
859
1145
|
* substrings of the rendered participant names (so a departed sender can
|
|
860
1146
|
* only match as "unknown (...)", the string the reader actually sees).
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
* window. An empty field is no constraint.
|
|
1147
|
+
* Broadcast fan-out rows carry their real recipient in `to`, so `--to
|
|
1148
|
+
* <name>` matches them like any direct message. `sinceMs`/`untilMs`
|
|
1149
|
+
* bound created_at in a since-inclusive, until-exclusive window. An empty
|
|
1150
|
+
* field is no constraint.
|
|
866
1151
|
*
|
|
867
1152
|
* Name inputs are not stable across polls: they are re-resolved from the
|
|
868
1153
|
* live directory on every feed snapshot, so an unregister or rename
|
|
@@ -896,16 +1181,18 @@ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFil
|
|
|
896
1181
|
/**
|
|
897
1182
|
* Prepares the tail's first render. The diff state seeds from EVERY row in
|
|
898
1183
|
* the feed - filtered or not, rendered or not - for two reasons. First,
|
|
899
|
-
* the limit hides
|
|
900
|
-
*
|
|
1184
|
+
* the limit hides messages, not history: a state seeded only from the
|
|
1185
|
+
* printed slice would re-emit the elided history as new sent events on
|
|
901
1186
|
* the first follow poll. Second, the name filters test JOIN-resolved
|
|
902
1187
|
* participant names that can flip mid-follow (a peer unregisters and its
|
|
903
1188
|
* history starts rendering as "unknown (... departed)", or it re-registers
|
|
904
1189
|
* under a new name); seeding everything means an old row can never resurface
|
|
905
1190
|
* as a sent event no matter how its rendered name changes. The last
|
|
906
|
-
* `limit` filtered rows (null = all) shape into sent events
|
|
907
|
-
*
|
|
908
|
-
*
|
|
1191
|
+
* `limit` filtered rows (null = all) shape into sent events, plus a read
|
|
1192
|
+
* event for each row already read, via chatTailDiff, so event shaping
|
|
1193
|
+
* keeps a single source. `shown` and `elided` count MESSAGES (not
|
|
1194
|
+
* events: a shown message contributes one or two lines) for the CLI's
|
|
1195
|
+
* summary line.
|
|
909
1196
|
*
|
|
910
1197
|
* The caller must re-apply the same filter to every follow poll's feed
|
|
911
1198
|
* BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
|
|
@@ -916,93 +1203,33 @@ export function chatTailBacklog(
|
|
|
916
1203
|
rows: Array<ChatTailRow>,
|
|
917
1204
|
filter: ChatTailFilter,
|
|
918
1205
|
limit: number | null,
|
|
919
|
-
): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
|
|
1206
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null>; shown: number; elided: number } {
|
|
920
1207
|
const state = new Map(rows.map((r) => [r.id, r.read_at]));
|
|
921
1208
|
const filtered = filterChatTailRows(rows, filter);
|
|
922
1209
|
const shown = limit === null ? filtered : filtered.slice(-limit);
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1210
|
+
// A snapshot has the full history of each shown message: its send, and
|
|
1211
|
+
// its read if read_at is set. Emit both, in time order, so `--once`
|
|
1212
|
+
// prints the same event log a follow would have accumulated. Reads of
|
|
1213
|
+
// messages the limit hid stay hidden with them.
|
|
1214
|
+
const events: ChatTailEvent[] = [];
|
|
1215
|
+
for (const r of shown) {
|
|
1216
|
+
events.push(...chatTailDiff(new Map(), [r]).events);
|
|
1217
|
+
if (r.read_at !== null) events.push(...chatTailDiff(new Map([[r.id, null]]), [r]).events);
|
|
931
1218
|
}
|
|
932
|
-
|
|
933
|
-
return
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/** "2026-09-12 19:46 MT" - the UTC timestamp converted to the terminal's
|
|
937
|
-
* local timezone (abbreviation from the locale, fallback "UTC"), one line
|
|
938
|
-
* for the card header. */
|
|
939
|
-
function localWhen(timestamp: string): string {
|
|
940
|
-
const date = new Date(timestamp);
|
|
941
|
-
if (Number.isNaN(date.getTime())) return timestamp;
|
|
942
|
-
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
943
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
944
|
-
return (
|
|
945
|
-
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
946
|
-
`${pad(date.getHours())}:${pad(date.getMinutes())} ${tz}`
|
|
947
|
-
);
|
|
1219
|
+
events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.event === b.event ? 0 : a.event === "sent" ? -1 : 1));
|
|
1220
|
+
return { events, state, shown: shown.length, elided: filtered.length - shown.length };
|
|
948
1221
|
}
|
|
949
1222
|
|
|
950
1223
|
/**
|
|
951
|
-
* Renders one tail event as a
|
|
952
|
-
*
|
|
953
|
-
* for
|
|
954
|
-
*
|
|
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.
|
|
1224
|
+
* Renders one tail event as a JSON line. Plain JSON.stringify of the event
|
|
1225
|
+
* object: no ANSI, no markdown rendering, no local-time conversion. The
|
|
1226
|
+
* tail is a log, and a log is for grep and jq; a terminal reader who wants
|
|
1227
|
+
* color pipes through their own tool.
|
|
958
1228
|
*/
|
|
959
|
-
|
|
960
|
-
|
|
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");
|
|
1229
|
+
export function formatChatTailJsonl(event: ChatTailEvent): string {
|
|
1230
|
+
return JSON.stringify(event);
|
|
1002
1231
|
}
|
|
1003
1232
|
|
|
1004
|
-
export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
|
|
1005
|
-
|
|
1006
1233
|
// ---------------------------------------------------------------------------
|
|
1007
1234
|
// Poller
|
|
1008
1235
|
// ---------------------------------------------------------------------------
|
|
@@ -1025,9 +1252,10 @@ export interface ChatPollerStore {
|
|
|
1025
1252
|
|
|
1026
1253
|
export interface ChatPollerOptions {
|
|
1027
1254
|
store: ChatPollerStore;
|
|
1028
|
-
/** Session IDs this process has seen events for
|
|
1029
|
-
*
|
|
1030
|
-
* deliverer, by
|
|
1255
|
+
/** Session IDs this process hosts: those it has seen events for plus the
|
|
1256
|
+
* `-s` startup session. Only sessions hosted by this process can be
|
|
1257
|
+
* delivered to - the recipient's host is the single deliverer, by
|
|
1258
|
+
* design. */
|
|
1031
1259
|
hostedSessions: () => string[];
|
|
1032
1260
|
/** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
|
|
1033
1261
|
deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
|
|
@@ -1203,8 +1431,9 @@ export class ChatPoller {
|
|
|
1203
1431
|
}
|
|
1204
1432
|
}
|
|
1205
1433
|
|
|
1206
|
-
/** True when a session row
|
|
1207
|
-
*
|
|
1208
|
-
|
|
1209
|
-
|
|
1434
|
+
/** True when a session row has missed two heartbeats (see CHAT_STALE_MS).
|
|
1435
|
+
* The one staleness rule: chat_list, chat_send's stale note, and the
|
|
1436
|
+
* broadcast skip all go through here so they can never disagree. */
|
|
1437
|
+
export function isStale(row: ChatSessionRow, now = Date.now()): boolean {
|
|
1438
|
+
return now - Date.parse(row.last_seen) > CHAT_STALE_MS;
|
|
1210
1439
|
}
|