@jeffober/thatch 0.1.43 → 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 +4 -2
- package/bin/thatch +270 -93
- package/docs/dev/README.md +8 -1
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/cross-session-chat.md +111 -42
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +12 -0
- package/docs/dev/mcp-parity.md +1 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +6 -4
- package/docs/user/cli.md +35 -43
- package/docs/user/cross-session-chat.md +89 -21
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +344 -185
- package/src/commands.ts +16 -6
- package/src/db.ts +37 -0
- package/src/debug.ts +44 -0
- package/src/index.ts +153 -39
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +2 -2
- package/src/setup.ts +203 -33
- package/src/tool-defs.ts +98 -24
- 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,68 @@ 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
|
+
/** 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
|
+
}
|
|
354
|
+
|
|
179
355
|
/**
|
|
180
356
|
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
181
357
|
* Database handle; the tables are created by ThatchDB's schema init.
|
|
@@ -190,17 +366,20 @@ export class ChatStore {
|
|
|
190
366
|
/**
|
|
191
367
|
* Joins the directory, or refreshes an existing registration. Names are
|
|
192
368
|
* assigned by the system, never claimed: a new session gets
|
|
193
|
-
* "<slug>-<counter>" (e.g. "
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* name
|
|
198
|
-
*
|
|
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.
|
|
199
377
|
*
|
|
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
|
|
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.
|
|
204
383
|
*/
|
|
205
384
|
register(
|
|
206
385
|
sessionID: string,
|
|
@@ -209,9 +388,13 @@ export class ChatStore {
|
|
|
209
388
|
kind: ChatHostKind,
|
|
210
389
|
nameBase: string | null = null,
|
|
211
390
|
worktree: ChatWorktreeKind = null,
|
|
212
|
-
): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
|
|
391
|
+
): { ok: true; name: string; topic: string | null; created: boolean } | { ok: false; error: string } {
|
|
213
392
|
const existing = this.#find(sessionID);
|
|
214
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.
|
|
215
398
|
this.#touch(sessionID);
|
|
216
399
|
// An explicit registration clears the leave tombstone for its own
|
|
217
400
|
// session ID: "I want back in" is the opposite of "I left". This is
|
|
@@ -222,7 +405,7 @@ export class ChatStore {
|
|
|
222
405
|
// session.deleted suppresses exactly that session.
|
|
223
406
|
const gate = this.#tombstoneGate(sessionID, kind);
|
|
224
407
|
if (gate) return gate;
|
|
225
|
-
return { ok: true, name: existing.name, topic: existing.topic };
|
|
408
|
+
return { ok: true, name: existing.name, topic: existing.topic, created: false };
|
|
226
409
|
}
|
|
227
410
|
const gate = this.#tombstoneGate(sessionID, kind);
|
|
228
411
|
if (gate) return gate;
|
|
@@ -239,12 +422,12 @@ export class ChatStore {
|
|
|
239
422
|
const next = this.#bumpCounter(base);
|
|
240
423
|
const name = `${base}-${String(next).padStart(5, "0")}`;
|
|
241
424
|
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
|
|
242
|
-
if (claimed.ok) return { ok: true, name, topic: cleanTopic };
|
|
425
|
+
if (claimed.ok) return { ok: true, name, topic: cleanTopic, created: true };
|
|
243
426
|
if (claimed.reason === "already-registered") {
|
|
244
427
|
// A concurrent registration of the same session ID won the race;
|
|
245
428
|
// report its row rather than erroring.
|
|
246
429
|
const winner = this.#find(sessionID);
|
|
247
|
-
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 };
|
|
248
431
|
}
|
|
249
432
|
}
|
|
250
433
|
return { ok: false, error: "Could not assign a unique name after several attempts." };
|
|
@@ -433,7 +616,7 @@ export class ChatStore {
|
|
|
433
616
|
// Staleness means "host process gone" only for opencode rows; an
|
|
434
617
|
// mcp row between turns reads its mail at the next prompt, so it
|
|
435
618
|
// always receives.
|
|
436
|
-
if (row.host_kind === "opencode" && isStale(row
|
|
619
|
+
if (row.host_kind === "opencode" && isStale(row)) {
|
|
437
620
|
skipped.push(row.name);
|
|
438
621
|
continue;
|
|
439
622
|
}
|
|
@@ -487,6 +670,65 @@ export class ChatStore {
|
|
|
487
670
|
return row.n;
|
|
488
671
|
}
|
|
489
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
|
+
|
|
490
732
|
/**
|
|
491
733
|
* The caller's mailbox summary for chat_status: whether a directory row
|
|
492
734
|
* exists, and pending/total counts. Touches last_seen on use, which is
|
|
@@ -534,18 +776,15 @@ export class ChatStore {
|
|
|
534
776
|
).map((r) => ({ name: r.name, pending: r.pending }));
|
|
535
777
|
}
|
|
536
778
|
|
|
537
|
-
/** Refreshes last_seen for the given sessions
|
|
538
|
-
*
|
|
539
|
-
* 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. */
|
|
540
783
|
heartbeat(sessionIDs: string[]): void {
|
|
541
784
|
if (sessionIDs.length === 0) return;
|
|
542
785
|
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
|
-
]);
|
|
786
|
+
this.#db.run(`UPDATE chat_sessions SET last_seen = ? WHERE session_id IN (${marks})`, [nowIso(), ...sessionIDs]);
|
|
547
787
|
}
|
|
548
|
-
|
|
549
788
|
/**
|
|
550
789
|
* The full message feed for `thatch chat tail`: every row with sender and
|
|
551
790
|
* recipient display names resolved (departed senders degrade to unknown),
|
|
@@ -772,9 +1011,27 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
|
|
|
772
1011
|
// Tail events (pure logic behind `thatch chat tail`)
|
|
773
1012
|
// ---------------------------------------------------------------------------
|
|
774
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
|
+
*/
|
|
775
1022
|
export type ChatTailEvent =
|
|
776
|
-
| {
|
|
777
|
-
|
|
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 };
|
|
778
1035
|
|
|
779
1036
|
/** One message row as the tail diff consumes it. */
|
|
780
1037
|
export interface ChatTailRow {
|
|
@@ -807,25 +1064,27 @@ export function chatTailDiff(
|
|
|
807
1064
|
const isNew = !prev.has(r.id);
|
|
808
1065
|
if (isNew) {
|
|
809
1066
|
events.push({
|
|
810
|
-
|
|
811
|
-
|
|
1067
|
+
event: "sent",
|
|
1068
|
+
at: r.created_at,
|
|
1069
|
+
id: r.id,
|
|
812
1070
|
from: r.from ?? "unknown",
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1071
|
+
from_topic: r.fromTopic,
|
|
1072
|
+
to: r.to ?? "unknown",
|
|
1073
|
+
to_topic: r.toTopic,
|
|
1074
|
+
broadcast: r.viaBroadcast,
|
|
816
1075
|
body: r.body,
|
|
817
1076
|
});
|
|
818
1077
|
} else {
|
|
819
1078
|
const prevRead = prev.get(r.id) ?? null;
|
|
820
1079
|
if (r.read_at !== null && prevRead === null) {
|
|
821
1080
|
events.push({
|
|
822
|
-
|
|
823
|
-
|
|
1081
|
+
event: "read",
|
|
1082
|
+
at: r.read_at,
|
|
1083
|
+
id: r.id,
|
|
824
1084
|
reader: r.to ?? "unknown",
|
|
1085
|
+
reader_topic: r.toTopic,
|
|
825
1086
|
from: r.from ?? "unknown",
|
|
826
|
-
|
|
827
|
-
toTopic: r.toTopic,
|
|
828
|
-
body: r.body,
|
|
1087
|
+
from_topic: r.fromTopic,
|
|
829
1088
|
});
|
|
830
1089
|
}
|
|
831
1090
|
}
|
|
@@ -835,7 +1094,7 @@ export function chatTailDiff(
|
|
|
835
1094
|
}
|
|
836
1095
|
|
|
837
1096
|
/**
|
|
838
|
-
* The default number of backlog
|
|
1097
|
+
* The default number of backlog messages `thatch chat tail` prints before
|
|
839
1098
|
* follow mode takes over. The message table is machine-scale but the
|
|
840
1099
|
* history still grows forever, so an unbounded default would dump the
|
|
841
1100
|
* entire database on every run.
|
|
@@ -844,7 +1103,8 @@ export const CHAT_TAIL_DEFAULT_LIMIT = 20;
|
|
|
844
1103
|
|
|
845
1104
|
/**
|
|
846
1105
|
* Parses a tail time-bound value into epoch milliseconds, interpreted in
|
|
847
|
-
* 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).
|
|
848
1108
|
* Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
|
|
849
1109
|
* space or a T between the date and the time. The component round-trip
|
|
850
1110
|
* rejects impossible calendar values like 2026-09-31 and 25:99, which
|
|
@@ -884,11 +1144,10 @@ function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi:
|
|
|
884
1144
|
* test the message body; `fromSubstr`/`toSubstr` are case-insensitive
|
|
885
1145
|
* substrings of the rendered participant names (so a departed sender can
|
|
886
1146
|
* only match as "unknown (...)", the string the reader actually sees).
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
* 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.
|
|
892
1151
|
*
|
|
893
1152
|
* Name inputs are not stable across polls: they are re-resolved from the
|
|
894
1153
|
* live directory on every feed snapshot, so an unregister or rename
|
|
@@ -922,16 +1181,18 @@ export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFil
|
|
|
922
1181
|
/**
|
|
923
1182
|
* Prepares the tail's first render. The diff state seeds from EVERY row in
|
|
924
1183
|
* the feed - filtered or not, rendered or not - for two reasons. First,
|
|
925
|
-
* the limit hides
|
|
926
|
-
*
|
|
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
|
|
927
1186
|
* the first follow poll. Second, the name filters test JOIN-resolved
|
|
928
1187
|
* participant names that can flip mid-follow (a peer unregisters and its
|
|
929
1188
|
* history starts rendering as "unknown (... departed)", or it re-registers
|
|
930
1189
|
* under a new name); seeding everything means an old row can never resurface
|
|
931
1190
|
* 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
|
-
*
|
|
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.
|
|
935
1196
|
*
|
|
936
1197
|
* The caller must re-apply the same filter to every follow poll's feed
|
|
937
1198
|
* BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
|
|
@@ -942,135 +1203,31 @@ export function chatTailBacklog(
|
|
|
942
1203
|
rows: Array<ChatTailRow>,
|
|
943
1204
|
filter: ChatTailFilter,
|
|
944
1205
|
limit: number | null,
|
|
945
|
-
): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
|
|
1206
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null>; shown: number; elided: number } {
|
|
946
1207
|
const state = new Map(rows.map((r) => [r.id, r.read_at]));
|
|
947
1208
|
const filtered = filterChatTailRows(rows, filter);
|
|
948
1209
|
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);
|
|
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);
|
|
1031
1218
|
}
|
|
1032
|
-
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
export const CHAT_TAIL_SEPARATOR = "_".repeat(60);
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
1038
|
-
* Chooses the markdown-to-ANSI renderer for chat tail bodies, in preference
|
|
1039
|
-
* order: glow first, then gum format. Returns the spawn argv (the body
|
|
1040
|
-
* arrives on stdin), or null when neither tool is installed. Pure so tests
|
|
1041
|
-
* can pin the order; the caller owns PATH detection and the spawn itself.
|
|
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.
|
|
1048
|
-
*/
|
|
1049
|
-
export function selectChatBodyRenderer(glowPath: string | null, gumPath: string | null): string[] | null {
|
|
1050
|
-
if (glowPath) return [glowPath, "-", "-w", "0", "-s", "dark"];
|
|
1051
|
-
if (gumPath) return [gumPath, "format", "-t", "markdown"];
|
|
1052
|
-
return null;
|
|
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 };
|
|
1053
1221
|
}
|
|
1054
1222
|
|
|
1055
1223
|
/**
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
* unchanged through its renderer.
|
|
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.
|
|
1061
1228
|
*/
|
|
1062
|
-
export function
|
|
1063
|
-
|
|
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");
|
|
1229
|
+
export function formatChatTailJsonl(event: ChatTailEvent): string {
|
|
1230
|
+
return JSON.stringify(event);
|
|
1074
1231
|
}
|
|
1075
1232
|
|
|
1076
1233
|
// ---------------------------------------------------------------------------
|
|
@@ -1095,9 +1252,10 @@ export interface ChatPollerStore {
|
|
|
1095
1252
|
|
|
1096
1253
|
export interface ChatPollerOptions {
|
|
1097
1254
|
store: ChatPollerStore;
|
|
1098
|
-
/** Session IDs this process has seen events for
|
|
1099
|
-
*
|
|
1100
|
-
* 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. */
|
|
1101
1259
|
hostedSessions: () => string[];
|
|
1102
1260
|
/** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
|
|
1103
1261
|
deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
|
|
@@ -1273,8 +1431,9 @@ export class ChatPoller {
|
|
|
1273
1431
|
}
|
|
1274
1432
|
}
|
|
1275
1433
|
|
|
1276
|
-
/** True when a session row
|
|
1277
|
-
*
|
|
1278
|
-
|
|
1279
|
-
|
|
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;
|
|
1280
1439
|
}
|