@jeffober/thatch 0.1.41 → 0.1.43
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 +28 -11
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/release +4 -1
- package/bin/thatch +268 -55
- package/docs/dev/README.md +7 -4
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +31 -11
- package/docs/dev/features/cross-session-chat.md +141 -67
- package/docs/dev/features/multi-host.md +4 -3
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/features/watchers.md +113 -23
- package/docs/dev/gotchas.md +9 -0
- package/docs/dev/mcp-parity.md +2 -1
- package/docs/dev/skills.md +1 -0
- package/docs/user/README.md +19 -3
- package/docs/user/cli.md +68 -14
- package/docs/user/cross-session-chat.md +80 -27
- package/docs/user/skills.md +1 -0
- package/docs/user/watchers.md +61 -16
- package/package.json +2 -1
- package/src/chat-names.ts +6 -7
- package/src/chat.ts +553 -152
- package/src/commands.ts +97 -0
- package/src/config.ts +34 -1
- package/src/db.ts +67 -7
- package/src/git.ts +22 -0
- package/src/index.ts +198 -25
- package/src/prompts.ts +142 -50
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +301 -98
- package/src/tools.ts +2 -0
- package/src/watchers.ts +358 -23
package/src/chat.ts
CHANGED
|
@@ -46,6 +46,13 @@ export function isoMinutesAgo(minutes: number): string {
|
|
|
46
46
|
return new Date(Date.now() - minutes * 60_000).toISOString().slice(0, 19) + "Z";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Where the session's host process is running its checkout: "root" for the
|
|
50
|
+
* project's main checkout, "worktree" for a linked git worktree, null when
|
|
51
|
+
* undetected (e.g. an MCP host with no directory context). Displayed in
|
|
52
|
+
* the roster so sessions coordinating on a shared tree can tell who sits
|
|
53
|
+
* where. */
|
|
54
|
+
export type ChatWorktreeKind = "root" | "worktree" | null;
|
|
55
|
+
|
|
49
56
|
export interface ChatSessionRow {
|
|
50
57
|
session_id: string;
|
|
51
58
|
name: string;
|
|
@@ -57,6 +64,7 @@ export interface ChatSessionRow {
|
|
|
57
64
|
host_kind: ChatHostKind;
|
|
58
65
|
registered_at: string;
|
|
59
66
|
last_seen: string;
|
|
67
|
+
worktree: ChatWorktreeKind;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/** One message selected for wake-up delivery, with the sender's display name
|
|
@@ -77,17 +85,16 @@ export interface ChatInboxItem {
|
|
|
77
85
|
created_at: string;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
export type ChatResult = { ok: true; topic: string | null } | { ok: false; error: string };
|
|
81
|
-
|
|
82
88
|
/**
|
|
83
89
|
* Derives the synthetic session ID for an MCP-host chat registration: a
|
|
84
|
-
* pure function of the
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* preserving the name-cannot-shadow-ID invariant
|
|
90
|
+
* pure function of the host's own stable session identifier (Claude Code
|
|
91
|
+
* and Cursor pass one in their hook payloads), so identity survives the
|
|
92
|
+
* ephemeral conversations those hosts run. The underscore keeps the ID
|
|
93
|
+
* outside NAME_CHARSET, preserving the name-cannot-shadow-ID invariant
|
|
94
|
+
* that find() relies on.
|
|
88
95
|
*/
|
|
89
|
-
export function mcpSessionID(
|
|
90
|
-
const hash = Bun.SHA256.hash(
|
|
96
|
+
export function mcpSessionID(hostSessionID: string): string {
|
|
97
|
+
const hash = Bun.SHA256.hash(hostSessionID.toLowerCase(), "hex").slice(0, 12);
|
|
91
98
|
return `mcp_${hash}`;
|
|
92
99
|
}
|
|
93
100
|
|
|
@@ -99,20 +106,56 @@ export type ChatHostKind = "opencode" | "mcp";
|
|
|
99
106
|
// Display names appear inside wake prompts and chat_list output, so they are
|
|
100
107
|
// capped tight. Bodies are capped so a runaway sender cannot turn a nudge
|
|
101
108
|
// into a context bomb; the reader fetches full content via chat_read anyway.
|
|
102
|
-
const MAX_NAME_LEN = 40;
|
|
103
109
|
const MAX_BODY_LEN = 10_000;
|
|
104
110
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// baked-in name satisfies it.
|
|
111
|
+
// Assigned names are lowercase slugs with a numeric counter suffix
|
|
112
|
+
// ("fix-auth-bug-00001"), which satisfies NAME_CHARSET. The charset stays
|
|
113
|
+
// exported for the pool conformance test. Slugs never contain underscores,
|
|
114
|
+
// so the name-cannot-shadow-an-ID invariant in find() still holds.
|
|
110
115
|
export const NAME_CHARSET = /^[\p{L}\p{N} '.\-]+$/u;
|
|
111
116
|
|
|
112
117
|
// Topics are free text (unlike names) but must stay one line for the
|
|
113
118
|
// chat_list roster, so whitespace runs collapse and the value is capped.
|
|
114
119
|
const MAX_TOPIC_LEN = 80;
|
|
115
120
|
|
|
121
|
+
// Auto-registered sessions whose host has not heartbeat-ed them for this
|
|
122
|
+
// many days are pruned from the directory. Counters never decrement, so a
|
|
123
|
+
// pruned session's name is never reissued - pruning cannot create identity
|
|
124
|
+
// confusion, only roster silence.
|
|
125
|
+
export const CHAT_AUTO_TTL_DAYS = 7;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Slugifies a session title into a name base: lowercase, letter/number runs
|
|
129
|
+
* joined by single hyphens, capped at 32 characters (leaving room for the
|
|
130
|
+
* 6-character counter suffix). Returns null for titles with no usable
|
|
131
|
+
* characters.
|
|
132
|
+
*
|
|
133
|
+
* Deliberately NOT the same as ThatchDB.slugify (src/db.ts): chat names
|
|
134
|
+
* must never contain underscores (find() resolves name-first, and opencode
|
|
135
|
+
* session IDs contain underscores - a legal display name could shadow an
|
|
136
|
+
* ID), so non-alphanumeric runs all become hyphens and empty input returns
|
|
137
|
+
* null rather than a hash fallback.
|
|
138
|
+
*/
|
|
139
|
+
export function slugifyTitle(title: string): string | null {
|
|
140
|
+
const slug = title
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
143
|
+
.replace(/^-+|-+$/g, "")
|
|
144
|
+
.slice(0, 32)
|
|
145
|
+
.replace(/-+$/g, "");
|
|
146
|
+
return slug || null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a session title is opencode's pre-autotitle placeholder
|
|
151
|
+
* ("New session - <timestamp>" / "Child session - <timestamp>"). These must
|
|
152
|
+
* not become names or topics: they carry no information about the work, and
|
|
153
|
+
* the auto-titler usually replaces them within the first turn or two.
|
|
154
|
+
*/
|
|
155
|
+
export function isDefaultSessionTitle(title: string): boolean {
|
|
156
|
+
return /^New session - /.test(title) || /^Child session - /.test(title);
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
// Timing defaults, shared by the poller and the chat_list staleness display
|
|
117
160
|
// so both agree on what "stale" means.
|
|
118
161
|
export const CHAT_POLL_INTERVAL_MS = 30_000;
|
|
@@ -120,6 +163,19 @@ export const CHAT_STALE_MINUTES = 10;
|
|
|
120
163
|
export const CHAT_RENUDGE_MINUTES = 15;
|
|
121
164
|
export const CHAT_MAX_NUDGES_PER_HOUR = 6;
|
|
122
165
|
|
|
166
|
+
/** Human-readable age of an ISO timestamp: "45s ago", "5m ago", "3h ago",
|
|
167
|
+
* "2d ago". Shared by the CLI roster and the chat_list tool so both
|
|
168
|
+
* surfaces render the same last-check-in wording. */
|
|
169
|
+
export function humanAge(iso: string, now = Date.now()): string {
|
|
170
|
+
const seconds = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
|
|
171
|
+
if (Number.isNaN(seconds)) return "unknown age";
|
|
172
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
173
|
+
const minutes = Math.floor(seconds / 60);
|
|
174
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
175
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ago`;
|
|
176
|
+
return `${Math.floor(minutes / (24 * 60))}d ago`;
|
|
177
|
+
}
|
|
178
|
+
|
|
123
179
|
/**
|
|
124
180
|
* SQLite CRUD for the chat tables. Constructed by ThatchDB with the shared
|
|
125
181
|
* Database handle; the tables are created by ThatchDB's schema init.
|
|
@@ -131,96 +187,67 @@ export class ChatStore {
|
|
|
131
187
|
this.#db = db;
|
|
132
188
|
}
|
|
133
189
|
|
|
134
|
-
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
190
|
+
/**
|
|
191
|
+
* Joins the directory, or refreshes an existing registration. Names are
|
|
192
|
+
* assigned by the system, never claimed: a new session gets
|
|
193
|
+
* "<slug>-<counter>" (e.g. "fix-auth-bug-00001"), where the slug comes
|
|
194
|
+
* from the session title (or a pool-draw slug when there is none) and the
|
|
195
|
+
* per-base counter only ever increments - so a name is minted exactly
|
|
196
|
+
* once, machine-wide, and pruning an old session can never reissue its
|
|
197
|
+
* name to someone else. Idempotent for an already-registered session: it
|
|
198
|
+
* keeps its name and just refreshes liveness.
|
|
199
|
+
*
|
|
200
|
+
* `nameBase` is the slugified title for a new registration (null draws a
|
|
201
|
+
* pool slug instead). `topic` is the live title for auto-registrations;
|
|
202
|
+
* null leaves the topic unset. Every row minted here is marked auto=1 -
|
|
203
|
+
* rows with auto=0 exist only as legacy data from the claimed-names era.
|
|
139
204
|
*/
|
|
140
|
-
|
|
205
|
+
register(
|
|
141
206
|
sessionID: string,
|
|
142
207
|
project: string | null,
|
|
143
208
|
topic: string | null,
|
|
144
209
|
kind: ChatHostKind,
|
|
145
|
-
|
|
210
|
+
nameBase: string | null = null,
|
|
211
|
+
worktree: ChatWorktreeKind = null,
|
|
212
|
+
): { ok: true; name: string; topic: string | null } | { ok: false; error: string } {
|
|
146
213
|
const existing = this.#find(sessionID);
|
|
147
214
|
if (existing) {
|
|
148
215
|
this.#touch(sessionID);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const free = CHAT_NAME_POOL.filter((n) => !taken.has(n.toLowerCase()));
|
|
160
|
-
if (free.length === 0) {
|
|
161
|
-
return { ok: false, error: "Name pool exhausted - pass a custom name." };
|
|
162
|
-
}
|
|
163
|
-
const name = free[Math.floor(Math.random() * free.length)];
|
|
164
|
-
const cleanTopic = this.#cleanTopic(topic);
|
|
165
|
-
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind);
|
|
166
|
-
if (claimed.ok) return { ok: true, name, drawn: true, topic: cleanTopic };
|
|
216
|
+
// An explicit registration clears the leave tombstone for its own
|
|
217
|
+
// session ID: "I want back in" is the opposite of "I left". This is
|
|
218
|
+
// also what closes the in-flight race - an auto-register IIFE whose
|
|
219
|
+
// session was deleted mid-await finds the tombstone here and stops.
|
|
220
|
+
// The caller distinction is by ID: the idle path re-checks with the
|
|
221
|
+
// session's own ID, so a tombstone set by chat_unregister or
|
|
222
|
+
// session.deleted suppresses exactly that session.
|
|
223
|
+
const gate = this.#tombstoneGate(sessionID, kind);
|
|
224
|
+
if (gate) return gate;
|
|
225
|
+
return { ok: true, name: existing.name, topic: existing.topic };
|
|
167
226
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Joins the directory, or refreshes an existing registration. Re-registering
|
|
173
|
-
* with the same session ID renames the session (including changing only
|
|
174
|
-
* the casing of its own name); the new name must not be claimed by a
|
|
175
|
-
* different session. Uniqueness is case-insensitive ("Landru" and "landru"
|
|
176
|
-
* are the same name), so lookups and claims agree no matter what casing the
|
|
177
|
-
* model or user types. Both paths stamp last_seen.
|
|
178
|
-
*/
|
|
179
|
-
register(sessionID: string, rawName: string, project: string | null, topic: string | null, kind: ChatHostKind): ChatResult {
|
|
180
|
-
const name = rawName.trim();
|
|
181
|
-
const invalid = this.#invalidName(name);
|
|
182
|
-
if (invalid) return { ok: false, error: invalid };
|
|
227
|
+
const gate = this.#tombstoneGate(sessionID, kind);
|
|
228
|
+
if (gate) return gate;
|
|
183
229
|
const cleanTopic = this.#cleanTopic(topic);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// treats name and topic updates as independent.
|
|
203
|
-
const nextTopic = cleanTopic ?? existing.topic;
|
|
204
|
-
this.#db.run("UPDATE chat_sessions SET name = ?, topic = ?, last_seen = ? WHERE session_id = ?", [
|
|
205
|
-
name,
|
|
206
|
-
nextTopic,
|
|
207
|
-
nowIso(),
|
|
208
|
-
sessionID,
|
|
209
|
-
]);
|
|
210
|
-
return { ok: true, topic: nextTopic };
|
|
211
|
-
} catch (err) {
|
|
212
|
-
if (this.#isConstraintError(err)) {
|
|
213
|
-
return this.#nameTaken(name);
|
|
214
|
-
}
|
|
215
|
-
throw err;
|
|
230
|
+
const base =
|
|
231
|
+
nameBase ??
|
|
232
|
+
slugifyTitle(CHAT_NAME_POOL[Math.floor(Math.random() * CHAT_NAME_POOL.length)]) ??
|
|
233
|
+
"session";
|
|
234
|
+
// The counter draw is atomic, but the name INSERT can still lose to a
|
|
235
|
+
// legacy row that already holds that exact name (custom claims from
|
|
236
|
+
// before names were assigned). Each retry bumps the counter, so the
|
|
237
|
+
// loop always makes forward progress and terminates.
|
|
238
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
239
|
+
const next = this.#bumpCounter(base);
|
|
240
|
+
const name = `${base}-${String(next).padStart(5, "0")}`;
|
|
241
|
+
const claimed = this.#insertSession(sessionID, name, project, cleanTopic, kind, worktree);
|
|
242
|
+
if (claimed.ok) return { ok: true, name, topic: cleanTopic };
|
|
243
|
+
if (claimed.reason === "already-registered") {
|
|
244
|
+
// A concurrent registration of the same session ID won the race;
|
|
245
|
+
// report its row rather than erroring.
|
|
246
|
+
const winner = this.#find(sessionID);
|
|
247
|
+
if (winner) return { ok: true, name: winner.name, topic: winner.topic };
|
|
216
248
|
}
|
|
217
249
|
}
|
|
218
|
-
|
|
219
|
-
return this.#nameTaken(name);
|
|
220
|
-
}
|
|
221
|
-
const inserted = this.#insertSession(sessionID, name, project, cleanTopic, kind);
|
|
222
|
-
if (inserted.ok) return { ok: true, topic: cleanTopic };
|
|
223
|
-
return inserted;
|
|
250
|
+
return { ok: false, error: "Could not assign a unique name after several attempts." };
|
|
224
251
|
}
|
|
225
252
|
|
|
226
253
|
/** Leaves the directory. Messages already sent to or from the session are
|
|
@@ -228,13 +255,109 @@ export class ChatStore {
|
|
|
228
255
|
unregister(sessionID: string): boolean {
|
|
229
256
|
const existing = this.#find(sessionID);
|
|
230
257
|
if (!existing) return false;
|
|
231
|
-
this.#db.
|
|
258
|
+
this.#db.transaction(() => {
|
|
259
|
+
// Tombstone before delete: auto-registration keys on the row being
|
|
260
|
+
// absent, so without a marker the next idle event would silently
|
|
261
|
+
// re-register the session under a brand-new name and the user's
|
|
262
|
+
// "leave the directory" would last until the turn ended. The
|
|
263
|
+
// tombstone survives the delete and suppresses auto-registration
|
|
264
|
+
// until the session explicitly rejoins via chat_register (which
|
|
265
|
+
// clears it). Both leave paths come through here - chat_unregister
|
|
266
|
+
// AND session.deleted (a deleted TUI session is an exit too; without
|
|
267
|
+
// the tombstone an in-flight auto-register could resurrect it).
|
|
268
|
+
this.#db.run("INSERT OR REPLACE INTO chat_leave_tombstones (session_id, left_at) VALUES (?, ?)", [
|
|
269
|
+
sessionID,
|
|
270
|
+
nowIso(),
|
|
271
|
+
]);
|
|
272
|
+
this.#db.run("DELETE FROM chat_sessions WHERE session_id = ?", [sessionID]);
|
|
273
|
+
})();
|
|
232
274
|
return true;
|
|
233
275
|
}
|
|
234
276
|
|
|
277
|
+
/** Whether the session explicitly left the directory and has not
|
|
278
|
+
* rejoined. Auto-registration consults this before inserting; manual
|
|
279
|
+
* registration (chat_register) clears the tombstone on join. */
|
|
280
|
+
hasLeaveTombstone(sessionID: string): boolean {
|
|
281
|
+
const row = this.#db
|
|
282
|
+
.query("SELECT 1 FROM chat_leave_tombstones WHERE session_id = ?")
|
|
283
|
+
.get(sessionID);
|
|
284
|
+
return row !== null && row !== undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Clears the leave tombstone (chat_register rejoin path). */
|
|
288
|
+
clearLeaveTombstone(sessionID: string): void {
|
|
289
|
+
this.#db.run("DELETE FROM chat_leave_tombstones WHERE session_id = ?", [sessionID]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Whether the leave tombstone for `sessionID` has been superseded: a
|
|
294
|
+
* newer registration exists in the same project (registered after the
|
|
295
|
+
* leave). The hook uses this to stop announcing a stale leave once the
|
|
296
|
+
* model has rejoined under a fresh identity - the tombstoned identity
|
|
297
|
+
* and the fresh one cannot be linked, but a newer join in the same
|
|
298
|
+
* project is evidence the conversation moved on.
|
|
299
|
+
*/
|
|
300
|
+
leaveSuperseded(sessionID: string, project: string | null): boolean {
|
|
301
|
+
if (!project) return false;
|
|
302
|
+
const row = this.#db
|
|
303
|
+
.query(
|
|
304
|
+
`SELECT 1 FROM chat_leave_tombstones t
|
|
305
|
+
JOIN chat_sessions cs ON cs.project = ? AND cs.registered_at > t.left_at
|
|
306
|
+
WHERE t.session_id = ?
|
|
307
|
+
LIMIT 1`,
|
|
308
|
+
)
|
|
309
|
+
.get(project, sessionID);
|
|
310
|
+
return row !== null && row !== undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Refreshes an auto-registered session's topic from its current title.
|
|
315
|
+
* opencode's auto-titler usually lands a real title within the first turn
|
|
316
|
+
* or two, so this converges the roster annotation to the actual work.
|
|
317
|
+
* Manual (legacy) rows are never touched - the topic column on those is
|
|
318
|
+
* user/model-set, not title-derived.
|
|
319
|
+
*/
|
|
320
|
+
refreshAutoTopic(sessionID: string, title: string): void {
|
|
321
|
+
const clean = this.#cleanTopic(title);
|
|
322
|
+
if (!clean) return;
|
|
323
|
+
this.#db.run("UPDATE chat_sessions SET topic = ? WHERE session_id = ? AND auto = 1 AND topic IS NOT ?", [
|
|
324
|
+
clean,
|
|
325
|
+
sessionID,
|
|
326
|
+
clean,
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Removes auto-registered directory rows whose host has not heartbeat-ed
|
|
332
|
+
* them since `cutoff` (ISO), plus unread mail addressed to sessions no
|
|
333
|
+
* longer in the directory. The mail delete is bounded by the same
|
|
334
|
+
* cutoff: unread mail newer than the cutoff survives this sweep and is
|
|
335
|
+
* removed by a later one once it ages past the moving cutoff - the
|
|
336
|
+
* cutoff is a conservative bound, not an exact age match. Delivered/read
|
|
337
|
+
* history and mail from pruned senders to live recipients are kept -
|
|
338
|
+
* departed senders already render as unknown names. Counters are NOT
|
|
339
|
+
* touched: never decrementing is what makes reissued names impossible.
|
|
340
|
+
* Returns the number of directory rows pruned.
|
|
341
|
+
*/
|
|
342
|
+
pruneStaleAuto(cutoff: string): number {
|
|
343
|
+
const result = this.#db.run("DELETE FROM chat_sessions WHERE auto = 1 AND last_seen < ?", [cutoff]);
|
|
344
|
+
this.#db.run(
|
|
345
|
+
"DELETE FROM chat_messages WHERE read_at IS NULL AND to_session NOT IN (SELECT session_id FROM chat_sessions) AND created_at < ?",
|
|
346
|
+
[cutoff],
|
|
347
|
+
);
|
|
348
|
+
// Tombstones age out too: they are leave markers, not identity records.
|
|
349
|
+
// A resumed opencode session whose tombstone expired simply
|
|
350
|
+
// auto-registers under a fresh name - the expiry window is the
|
|
351
|
+
// grace period for that. Growth is bounded (one row per distinct
|
|
352
|
+
// session), but the sweep keeps the table from growing monotonically
|
|
353
|
+
// forever.
|
|
354
|
+
this.#db.run("DELETE FROM chat_leave_tombstones WHERE left_at < ?", [cutoff]);
|
|
355
|
+
return result.changes;
|
|
356
|
+
}
|
|
357
|
+
|
|
235
358
|
list(): ChatSessionRow[] {
|
|
236
359
|
return (this.#db
|
|
237
|
-
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions ORDER BY name")
|
|
360
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions ORDER BY name")
|
|
238
361
|
.all() as any[]).map(rowFromSession);
|
|
239
362
|
}
|
|
240
363
|
|
|
@@ -434,6 +557,8 @@ export class ChatStore {
|
|
|
434
557
|
id: number;
|
|
435
558
|
from: string | null;
|
|
436
559
|
to: string | null;
|
|
560
|
+
fromTopic: string | null;
|
|
561
|
+
toTopic: string | null;
|
|
437
562
|
viaBroadcast: boolean;
|
|
438
563
|
body: string;
|
|
439
564
|
created_at: string;
|
|
@@ -442,7 +567,7 @@ export class ChatStore {
|
|
|
442
567
|
return (
|
|
443
568
|
this.#db
|
|
444
569
|
.query(
|
|
445
|
-
`SELECT m.id, m.from_session, m.to_session, fs.name AS from_name, ts.name AS to_name, m.via_broadcast, m.body, m.created_at, m.read_at
|
|
570
|
+
`SELECT m.id, m.from_session, m.to_session, fs.name AS from_name, ts.name AS to_name, fs.topic AS from_topic, ts.topic AS to_topic, m.via_broadcast, m.body, m.created_at, m.read_at
|
|
446
571
|
FROM chat_messages m
|
|
447
572
|
LEFT JOIN chat_sessions fs ON fs.session_id = m.from_session
|
|
448
573
|
LEFT JOIN chat_sessions ts ON ts.session_id = m.to_session
|
|
@@ -453,6 +578,8 @@ export class ChatStore {
|
|
|
453
578
|
id: r.id,
|
|
454
579
|
from: renderChatParticipant(r.from_name, r.from_session),
|
|
455
580
|
to: renderChatParticipant(r.to_name, r.to_session),
|
|
581
|
+
fromTopic: r.from_topic ?? null,
|
|
582
|
+
toTopic: r.to_topic ?? null,
|
|
456
583
|
viaBroadcast: r.via_broadcast === 1,
|
|
457
584
|
body: r.body,
|
|
458
585
|
created_at: r.created_at,
|
|
@@ -504,14 +631,14 @@ export class ChatStore {
|
|
|
504
631
|
|
|
505
632
|
#find(sessionID: string): ChatSessionRow | null {
|
|
506
633
|
const row = this.#db
|
|
507
|
-
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE session_id = ?")
|
|
634
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE session_id = ?")
|
|
508
635
|
.get(sessionID) as any;
|
|
509
636
|
return row ? rowFromSession(row) : null;
|
|
510
637
|
}
|
|
511
638
|
|
|
512
639
|
#findByName(name: string): ChatSessionRow | null {
|
|
513
640
|
const row = this.#db
|
|
514
|
-
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen FROM chat_sessions WHERE name = ? COLLATE NOCASE")
|
|
641
|
+
.query("SELECT session_id, name, topic, project, host_kind, registered_at, last_seen, worktree FROM chat_sessions WHERE name = ? COLLATE NOCASE")
|
|
515
642
|
.get(name) as any;
|
|
516
643
|
return row ? rowFromSession(row) : null;
|
|
517
644
|
}
|
|
@@ -520,15 +647,6 @@ export class ChatStore {
|
|
|
520
647
|
this.#db.run("UPDATE chat_sessions SET last_seen = ? WHERE session_id = ?", [nowIso(), sessionID]);
|
|
521
648
|
}
|
|
522
649
|
|
|
523
|
-
#invalidName(name: string): string | null {
|
|
524
|
-
if (!name) return "Name cannot be empty.";
|
|
525
|
-
if (name.length > MAX_NAME_LEN) return `Name too long (max ${MAX_NAME_LEN} characters).`;
|
|
526
|
-
if (!NAME_CHARSET.test(name)) {
|
|
527
|
-
return "Names may contain letters, numbers, spaces, apostrophes, hyphens, and periods.";
|
|
528
|
-
}
|
|
529
|
-
return null;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
650
|
/** Collapses whitespace runs (topics must stay one roster line) and caps
|
|
533
651
|
* the length. The null/empty distinction is load-bearing: null means
|
|
534
652
|
* "omit on re-register, keep the existing topic", while empty or
|
|
@@ -540,61 +658,84 @@ export class ChatStore {
|
|
|
540
658
|
return topic.replace(/\s+/g, " ").trim().slice(0, MAX_TOPIC_LEN);
|
|
541
659
|
}
|
|
542
660
|
|
|
543
|
-
/**
|
|
544
|
-
*
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
661
|
+
/**
|
|
662
|
+
* The leave-tombstone gate, applied on both register() paths (existing
|
|
663
|
+
* row and fresh mint). Policy: an opencode registration is the plugin's
|
|
664
|
+
* auto-registration, so a tombstone suppresses it outright - the in-
|
|
665
|
+
* flight IIFE whose session was deleted mid-await lands here and stops
|
|
666
|
+
* instead of resurrecting a dead session as a roster corpse. An MCP
|
|
667
|
+
* registration over the hook-anchored ID is a genuine rejoin (the
|
|
668
|
+
* derived session ID matches the tombstone), so the gate clears it and
|
|
669
|
+
* lets the registration proceed. Defense in depth: no current
|
|
670
|
+
* production caller reaches the MCP branch with a tombstoned ID (the
|
|
671
|
+
* tool's rejoin clears at its own layer first); UC-101 pins the DB
|
|
672
|
+
* contract. Returns the failure result when the gate refuses, or null
|
|
673
|
+
* to proceed.
|
|
674
|
+
*/
|
|
675
|
+
#tombstoneGate(sessionID: string, kind: ChatHostKind): { ok: false; error: string } | null {
|
|
676
|
+
if (!this.hasLeaveTombstone(sessionID)) return null;
|
|
677
|
+
if (kind === "opencode") {
|
|
678
|
+
return { ok: false, error: "leave tombstone active" };
|
|
679
|
+
}
|
|
680
|
+
this.clearLeaveTombstone(sessionID);
|
|
681
|
+
return null;
|
|
548
682
|
}
|
|
549
683
|
|
|
550
684
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
685
|
+
* Atomically draws the next counter value for a name base: the INSERT
|
|
686
|
+
* ... ON CONFLICT upsert seeds the counter at 2 on first draw and
|
|
687
|
+
* increments it on every later draw, returning the drawn value in one
|
|
688
|
+
* statement - two processes racing the same base can never draw the same
|
|
689
|
+
* number. Counters only ever increment (prune included), which is what
|
|
690
|
+
* makes assigned names immortal.
|
|
557
691
|
*/
|
|
558
|
-
#
|
|
692
|
+
#bumpCounter(base: string): number {
|
|
693
|
+
// The insert seeds the counter at 2 (1 is being handed out now) so the
|
|
694
|
+
// stored value always reads as the next unassigned number; the RETURNING
|
|
695
|
+
// hands out the drawn value. One statement, race-safe.
|
|
696
|
+
const row = this.#db
|
|
697
|
+
.query(
|
|
698
|
+
"INSERT INTO chat_name_counters (base, next) VALUES (?, 2) ON CONFLICT(base) DO UPDATE SET next = next + 1 RETURNING next - 1 AS drawn",
|
|
699
|
+
)
|
|
700
|
+
.get(base) as { drawn: number };
|
|
701
|
+
return row.drawn;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* INSERTs a directory row as auto=1 (the only rows this class mints;
|
|
706
|
+
* auto=0 rows are legacy data). Constraint violations are translated
|
|
707
|
+
* into structured outcomes rather than raw SQLite errors: a UNIQUE hit
|
|
708
|
+
* on name means the counter draw collided with a legacy row holding that
|
|
709
|
+
* exact name (the caller bumps and retries), and a PK hit on session_id
|
|
710
|
+
* means the session row already exists (a concurrent registration won
|
|
711
|
+
* the race).
|
|
712
|
+
*/
|
|
713
|
+
#insertSession(
|
|
714
|
+
sessionID: string,
|
|
715
|
+
name: string,
|
|
716
|
+
project: string | null,
|
|
717
|
+
topic: string | null,
|
|
718
|
+
kind: ChatHostKind,
|
|
719
|
+
worktree: ChatWorktreeKind,
|
|
720
|
+
): { ok: true; topic: string | null } | { ok: false; reason: "name-taken" | "already-registered" } {
|
|
559
721
|
try {
|
|
560
722
|
this.#db.run(
|
|
561
|
-
"INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
562
|
-
[sessionID, name, topic, project, kind, nowIso(), nowIso()],
|
|
723
|
+
"INSERT INTO chat_sessions (session_id, name, topic, project, host_kind, registered_at, last_seen, auto, worktree) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
|
724
|
+
[sessionID, name, topic, project, kind, nowIso(), nowIso(), worktree ?? ""],
|
|
563
725
|
);
|
|
564
726
|
return { ok: true, topic };
|
|
565
727
|
} catch (err) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return this.#alreadyRegistered();
|
|
572
|
-
}
|
|
573
|
-
throw err;
|
|
728
|
+
if (!this.#isConstraintError(err)) throw err;
|
|
729
|
+
const msg = String((err as any)?.message ?? err);
|
|
730
|
+
return msg.includes("chat_sessions.name")
|
|
731
|
+
? { ok: false, reason: "name-taken" }
|
|
732
|
+
: { ok: false, reason: "already-registered" };
|
|
574
733
|
}
|
|
575
734
|
}
|
|
576
735
|
|
|
577
736
|
#isConstraintError(err: unknown): boolean {
|
|
578
737
|
return String((err as any)?.code ?? err).includes("CONSTRAINT");
|
|
579
738
|
}
|
|
580
|
-
|
|
581
|
-
/** The one taken-message every claim path returns, so a rewording cannot
|
|
582
|
-
* drift between the pre-checks and the race catches. Points at the
|
|
583
|
-
* guaranteed-success recovery (a pool draw) like every other failure
|
|
584
|
-
* path in this feature. */
|
|
585
|
-
#nameTaken(name: string): ChatResult {
|
|
586
|
-
return {
|
|
587
|
-
ok: false,
|
|
588
|
-
error: `Name "${name}" is taken by another session - pick another name, or omit the name to draw one from the pool.`,
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** The one already-registered message for a session-ID PRIMARY KEY hit -
|
|
593
|
-
* a constraint collision that is not a taken name (the caller's own
|
|
594
|
-
* session row already exists under a different identity path). */
|
|
595
|
-
#alreadyRegistered(): ChatResult {
|
|
596
|
-
return { ok: false, error: "You are already registered - call chat_list to see your current name." };
|
|
597
|
-
}
|
|
598
739
|
}
|
|
599
740
|
|
|
600
741
|
function rowFromSession(r: any): ChatSessionRow {
|
|
@@ -608,6 +749,9 @@ function rowFromSession(r: any): ChatSessionRow {
|
|
|
608
749
|
project: r.project,
|
|
609
750
|
registered_at: r.registered_at,
|
|
610
751
|
last_seen: r.last_seen,
|
|
752
|
+
// Pre-migration rows carry the empty-string default; read back as
|
|
753
|
+
// undetected so the roster omits the token instead of printing a blank.
|
|
754
|
+
worktree: r.worktree === "root" || r.worktree === "worktree" ? r.worktree : null,
|
|
611
755
|
};
|
|
612
756
|
}
|
|
613
757
|
|
|
@@ -629,14 +773,16 @@ export function renderChatParticipant(name: string | null, sessionID: string | n
|
|
|
629
773
|
// ---------------------------------------------------------------------------
|
|
630
774
|
|
|
631
775
|
export type ChatTailEvent =
|
|
632
|
-
| { kind: "sent"; timestamp: string; from: string; to: string; body: string }
|
|
633
|
-
| { kind: "read"; timestamp: string; reader: string; from: string; body: string };
|
|
776
|
+
| { kind: "sent"; timestamp: string; from: string; to: string; fromTopic: string | null; toTopic: string | null; body: string }
|
|
777
|
+
| { kind: "read"; timestamp: string; reader: string; from: string; fromTopic: string | null; toTopic: string | null; body: string };
|
|
634
778
|
|
|
635
779
|
/** One message row as the tail diff consumes it. */
|
|
636
780
|
export interface ChatTailRow {
|
|
637
781
|
id: number;
|
|
638
782
|
from: string | null;
|
|
639
783
|
to: string | null;
|
|
784
|
+
fromTopic: string | null;
|
|
785
|
+
toTopic: string | null;
|
|
640
786
|
viaBroadcast: boolean;
|
|
641
787
|
body: string;
|
|
642
788
|
created_at: string;
|
|
@@ -665,6 +811,8 @@ export function chatTailDiff(
|
|
|
665
811
|
timestamp: r.created_at,
|
|
666
812
|
from: r.from ?? "unknown",
|
|
667
813
|
to: r.viaBroadcast ? "broadcast" : r.to ?? "unknown",
|
|
814
|
+
fromTopic: r.fromTopic,
|
|
815
|
+
toTopic: r.viaBroadcast ? null : r.toTopic,
|
|
668
816
|
body: r.body,
|
|
669
817
|
});
|
|
670
818
|
} else {
|
|
@@ -675,6 +823,8 @@ export function chatTailDiff(
|
|
|
675
823
|
timestamp: r.read_at,
|
|
676
824
|
reader: r.to ?? "unknown",
|
|
677
825
|
from: r.from ?? "unknown",
|
|
826
|
+
fromTopic: r.fromTopic,
|
|
827
|
+
toTopic: r.toTopic,
|
|
678
828
|
body: r.body,
|
|
679
829
|
});
|
|
680
830
|
}
|
|
@@ -684,6 +834,122 @@ export function chatTailDiff(
|
|
|
684
834
|
return { events, state: prev };
|
|
685
835
|
}
|
|
686
836
|
|
|
837
|
+
/**
|
|
838
|
+
* The default number of backlog cards `thatch chat tail` renders before
|
|
839
|
+
* follow mode takes over. The message table is machine-scale but the
|
|
840
|
+
* history still grows forever, so an unbounded default would dump the
|
|
841
|
+
* entire database on every run.
|
|
842
|
+
*/
|
|
843
|
+
export const CHAT_TAIL_DEFAULT_LIMIT = 20;
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Parses a tail time-bound value into epoch milliseconds, interpreted in
|
|
847
|
+
* the terminal's local timezone (the same clock the When headers print).
|
|
848
|
+
* Accepted: "YYYY-MM-DD" (midnight) or "YYYY-MM-DD HH:MM", with either a
|
|
849
|
+
* space or a T between the date and the time. The component round-trip
|
|
850
|
+
* rejects impossible calendar values like 2026-09-31 and 25:99, which
|
|
851
|
+
* JavaScript's Date would otherwise silently normalize.
|
|
852
|
+
*/
|
|
853
|
+
export function parseChatTimeBound(value: string, flag: string): number {
|
|
854
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?$/.exec(value.trim());
|
|
855
|
+
if (!m) {
|
|
856
|
+
throw new Error(`${flag} expects "YYYY-MM-DD" or "YYYY-MM-DD HH:MM", got "${value}"`);
|
|
857
|
+
}
|
|
858
|
+
const y = Number(m[1]);
|
|
859
|
+
const mo = Number(m[2]);
|
|
860
|
+
const d = Number(m[3]);
|
|
861
|
+
const h = Number(m[4] ?? 0);
|
|
862
|
+
const mi = Number(m[5] ?? 0);
|
|
863
|
+
const date = new Date(y, mo - 1, d, h, mi);
|
|
864
|
+
// Date normalizes out-of-range components (month 13, hour 25); the
|
|
865
|
+
// round-trip is the check that the value was real.
|
|
866
|
+
if (!roundTrip(date, y, mo, d, h, mi)) {
|
|
867
|
+
throw new Error(`${flag} is not a real date or time: "${value}"`);
|
|
868
|
+
}
|
|
869
|
+
return date.getTime();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function roundTrip(date: Date, y: number, mo: number, d: number, h: number, mi: number): boolean {
|
|
873
|
+
return (
|
|
874
|
+
date.getFullYear() === y &&
|
|
875
|
+
date.getMonth() === mo - 1 &&
|
|
876
|
+
date.getDate() === d &&
|
|
877
|
+
date.getHours() === h &&
|
|
878
|
+
date.getMinutes() === mi
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Everything the tail can narrow a feed by, all ANDed together. `matches`
|
|
884
|
+
* test the message body; `fromSubstr`/`toSubstr` are case-insensitive
|
|
885
|
+
* substrings of the rendered participant names (so a departed sender can
|
|
886
|
+
* only match as "unknown (...)", the string the reader actually sees).
|
|
887
|
+
* One deliberate asymmetry: broadcast rows keep the real recipient in
|
|
888
|
+
* `to`, so `--to <realname>` matches fan-out rows even though their cards
|
|
889
|
+
* say "broadcast", and `--to broadcast` matches nothing. `sinceMs`/
|
|
890
|
+
* `untilMs` bound created_at in a since-inclusive, until-exclusive
|
|
891
|
+
* window. An empty field is no constraint.
|
|
892
|
+
*
|
|
893
|
+
* Name inputs are not stable across polls: they are re-resolved from the
|
|
894
|
+
* live directory on every feed snapshot, so an unregister or rename
|
|
895
|
+
* flips which rows match. chatTailBacklog's full-feed state seeding is
|
|
896
|
+
* what keeps such flips from resurfacing old rows as sent events; reads
|
|
897
|
+
* of rows whose name stopped matching simply go quiet.
|
|
898
|
+
*/
|
|
899
|
+
export interface ChatTailFilter {
|
|
900
|
+
matches: RegExp[];
|
|
901
|
+
fromSubstr: string[];
|
|
902
|
+
toSubstr: string[];
|
|
903
|
+
sinceMs: number | null;
|
|
904
|
+
untilMs: number | null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Filters a feed snapshot down to the rows the tail should consider. */
|
|
908
|
+
export function filterChatTailRows(rows: Array<ChatTailRow>, filter: ChatTailFilter): Array<ChatTailRow> {
|
|
909
|
+
const froms = filter.fromSubstr.map((s) => s.toLowerCase());
|
|
910
|
+
const tos = filter.toSubstr.map((s) => s.toLowerCase());
|
|
911
|
+
return rows.filter((r) => {
|
|
912
|
+
const t = Date.parse(r.created_at);
|
|
913
|
+
if (filter.sinceMs !== null && t < filter.sinceMs) return false;
|
|
914
|
+
if (filter.untilMs !== null && t >= filter.untilMs) return false;
|
|
915
|
+
if (!filter.matches.every((re) => re.test(r.body))) return false;
|
|
916
|
+
if (froms.some((s) => !(r.from ?? "").toLowerCase().includes(s))) return false;
|
|
917
|
+
if (tos.some((s) => !(r.to ?? "").toLowerCase().includes(s))) return false;
|
|
918
|
+
return true;
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Prepares the tail's first render. The diff state seeds from EVERY row in
|
|
924
|
+
* the feed - filtered or not, rendered or not - for two reasons. First,
|
|
925
|
+
* the limit hides cards, not history: a state seeded only from the
|
|
926
|
+
* rendered slice would re-emit the elided history as new sent events on
|
|
927
|
+
* the first follow poll. Second, the name filters test JOIN-resolved
|
|
928
|
+
* participant names that can flip mid-follow (a peer unregisters and its
|
|
929
|
+
* history starts rendering as "unknown (... departed)", or it re-registers
|
|
930
|
+
* under a new name); seeding everything means an old row can never resurface
|
|
931
|
+
* as a sent event no matter how its rendered name changes. The last
|
|
932
|
+
* `limit` filtered rows (null = all) shape into sent events via
|
|
933
|
+
* chatTailDiff, so card rendering keeps a single source. `elided` counts
|
|
934
|
+
* matching rows the limit hid, for the CLI's summary line.
|
|
935
|
+
*
|
|
936
|
+
* The caller must re-apply the same filter to every follow poll's feed
|
|
937
|
+
* BEFORE the diff (filterChatTailRows, then chatTailDiff with the state
|
|
938
|
+
* returned here). Skipping the poll filter resurrects non-matching
|
|
939
|
+
* history; mutating the state between polls corrupts the read diff.
|
|
940
|
+
*/
|
|
941
|
+
export function chatTailBacklog(
|
|
942
|
+
rows: Array<ChatTailRow>,
|
|
943
|
+
filter: ChatTailFilter,
|
|
944
|
+
limit: number | null,
|
|
945
|
+
): { events: ChatTailEvent[]; state: Map<number, string | null>; elided: number } {
|
|
946
|
+
const state = new Map(rows.map((r) => [r.id, r.read_at]));
|
|
947
|
+
const filtered = filterChatTailRows(rows, filter);
|
|
948
|
+
const shown = limit === null ? filtered : filtered.slice(-limit);
|
|
949
|
+
const events = chatTailDiff(new Map(), shown).events;
|
|
950
|
+
return { events, state, elided: filtered.length - shown.length };
|
|
951
|
+
}
|
|
952
|
+
|
|
687
953
|
/** Renders one tail event in the CLI's line format. */
|
|
688
954
|
export function formatChatTailEvent(event: ChatTailEvent): string {
|
|
689
955
|
if (event.kind === "sent") {
|
|
@@ -693,6 +959,120 @@ export function formatChatTailEvent(event: ChatTailEvent): string {
|
|
|
693
959
|
return `[${event.timestamp}] ${event.reader} read a message from ${event.from}: ${clipped}`;
|
|
694
960
|
}
|
|
695
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);
|
|
1031
|
+
}
|
|
1032
|
+
return lines.join("\n");
|
|
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;
|
|
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");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
696
1076
|
// ---------------------------------------------------------------------------
|
|
697
1077
|
// Poller
|
|
698
1078
|
// ---------------------------------------------------------------------------
|
|
@@ -708,6 +1088,9 @@ export interface ChatPollerStore {
|
|
|
708
1088
|
heartbeatChatSessions(sessionIDs: string[]): void;
|
|
709
1089
|
pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string): ChatNotificationRow[];
|
|
710
1090
|
markChatDelivered(ids: number[]): void;
|
|
1091
|
+
/** Hourly TTL prune of auto-registered directory rows (and the mail that
|
|
1092
|
+
* can no longer reach a reader). Optional so test fakes stay minimal. */
|
|
1093
|
+
pruneStaleChatAuto?(cutoff: string): number;
|
|
711
1094
|
}
|
|
712
1095
|
|
|
713
1096
|
export interface ChatPollerOptions {
|
|
@@ -719,9 +1102,12 @@ export interface ChatPollerOptions {
|
|
|
719
1102
|
/** Delivers a wake prompt for a recipient. Injected so tests never spawn. */
|
|
720
1103
|
deliver: (sessionID: string, senders: string[], count: number) => Promise<void>;
|
|
721
1104
|
/** Gate for delivery: true only when the session can accept a proactive
|
|
722
|
-
* prompt right now (idle, not compacting).
|
|
723
|
-
*
|
|
724
|
-
|
|
1105
|
+
* prompt right now (idle, not compacting). May be async - the plugin's
|
|
1106
|
+
* gate verifies against the server's live status, because the event-fed
|
|
1107
|
+
* map can be stale and a wake injected into a running turn is mid-turn
|
|
1108
|
+
* context injection. Undeliverable messages stay pending and retry on
|
|
1109
|
+
* later cycles. */
|
|
1110
|
+
canDeliver: (sessionID: string) => boolean | Promise<boolean>;
|
|
725
1111
|
/** Poll interval. Default 30s - frequent enough that wake prompts feel
|
|
726
1112
|
* prompt, cheap enough that the shared DB sees only a couple of light
|
|
727
1113
|
* statements per cycle per process. */
|
|
@@ -748,6 +1134,9 @@ export class ChatPoller {
|
|
|
748
1134
|
#delivering = false;
|
|
749
1135
|
/** Per-recipient wake-prompt timestamps inside the last hour. */
|
|
750
1136
|
#nudges = new Map<string, number[]>();
|
|
1137
|
+
/** Last TTL prune epoch ms. Auto-registered rows are pruned hourly, not
|
|
1138
|
+
* every cycle - the prune is a maintenance sweep, not delivery. */
|
|
1139
|
+
#lastPrune = 0;
|
|
751
1140
|
|
|
752
1141
|
constructor(opts: ChatPollerOptions) {
|
|
753
1142
|
this.#opts = {
|
|
@@ -801,6 +1190,18 @@ export class ChatPoller {
|
|
|
801
1190
|
const hosted = this.#opts.hostedSessions();
|
|
802
1191
|
this.#opts.store.heartbeatChatSessions(hosted);
|
|
803
1192
|
await this.deliverPending();
|
|
1193
|
+
// Hourly sweep: drop auto-registered rows whose host died more than
|
|
1194
|
+
// CHAT_AUTO_TTL_DAYS ago, plus the unread mail addressed to sessions
|
|
1195
|
+
// no longer in the directory. Automatic registration makes one-shots
|
|
1196
|
+
// and crashed sessions routine, so without this the roster fills
|
|
1197
|
+
// with corpses; counters never decrement, so pruning cannot reissue
|
|
1198
|
+
// a name. The stamp lands before the call, so a throwing prune
|
|
1199
|
+
// retries on the next hourly sweep, not the next cycle.
|
|
1200
|
+
const now = Date.now();
|
|
1201
|
+
if (now - this.#lastPrune > 3_600_000 && this.#opts.store.pruneStaleChatAuto) {
|
|
1202
|
+
this.#lastPrune = now;
|
|
1203
|
+
this.#opts.store.pruneStaleChatAuto(isoMinutesAgo(CHAT_AUTO_TTL_DAYS * 24 * 60));
|
|
1204
|
+
}
|
|
804
1205
|
} catch (err) {
|
|
805
1206
|
console.error(`[thatch] chat poll failed: ${err}`);
|
|
806
1207
|
} finally {
|
|
@@ -833,7 +1234,7 @@ export class ChatPoller {
|
|
|
833
1234
|
}
|
|
834
1235
|
|
|
835
1236
|
for (const [recipient, messages] of byRecipient) {
|
|
836
|
-
if (!this.#opts.canDeliver(recipient)) continue;
|
|
1237
|
+
if (!(await this.#opts.canDeliver(recipient))) continue;
|
|
837
1238
|
if (this.#nudgeBudget(recipient) <= 0) continue;
|
|
838
1239
|
try {
|
|
839
1240
|
// "unknown (id, departed)" matches chat_read's rendering of a
|