@jeffober/thatch 0.1.43 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -9
- package/bin/thatch +307 -95
- package/docs/dev/README.md +12 -3
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/commands.md +74 -0
- package/docs/dev/features/cross-session-chat.md +128 -45
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/extraction.md +5 -0
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +20 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +20 -8
- package/docs/plans/thatch-actions.md +179 -0
- package/docs/user/README.md +10 -4
- package/docs/user/cli.md +45 -44
- package/docs/user/commands.md +50 -0
- package/docs/user/cross-session-chat.md +94 -22
- package/docs/user/setup.md +9 -3
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +372 -185
- package/src/commands.ts +138 -19
- package/src/db.ts +46 -0
- package/src/debug.ts +44 -0
- package/src/embeddings.ts +42 -1
- package/src/index.ts +262 -39
- package/src/mcp.ts +78 -3
- package/src/prompts.ts +57 -3
- package/src/setup.ts +212 -33
- package/src/tool-defs.ts +115 -30
- package/src/watchers.ts +51 -29
package/bin/thatch
CHANGED
|
@@ -8,10 +8,12 @@ import { detectRepo } from "../src/git";
|
|
|
8
8
|
import { BgeEmbeddingModel } from "../src/embeddings";
|
|
9
9
|
import { hygieneReport } from "../src/hygiene";
|
|
10
10
|
import { claudeSessionStartReminder } from "../src/prompts";
|
|
11
|
-
import { setupClaudeCode, setupCursor } from "../src/setup";
|
|
11
|
+
import { setupClaudeCode, setupCursor, registerClaudeMcpServer } from "../src/setup";
|
|
12
12
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
|
|
13
|
-
import { chatTailDiff, chatTailBacklog, filterChatTailRows,
|
|
13
|
+
import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, sortChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
|
|
14
14
|
import { chatEnabled, loadConfig } from "../src/config";
|
|
15
|
+
import { chatNotificationNudge } from "../src/prompts";
|
|
16
|
+
import { createDebugLog } from "../src/debug";
|
|
15
17
|
|
|
16
18
|
const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
17
19
|
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
@@ -34,6 +36,7 @@ function usage(): never {
|
|
|
34
36
|
thatch buffer-batch Append a PostToolBatch payload to the queue (Claude Code hook only)
|
|
35
37
|
thatch buffer-tool Append a single postToolUse interaction to the queue (Cursor hook only)
|
|
36
38
|
thatch flush-tools [--json] Peek the queue + print an extraction nudge (hook only)
|
|
39
|
+
thatch chat-notify Print a chat follow-up message when mail is unread (Cursor stop hook)
|
|
37
40
|
thatch flush-predictions [--json] Query predictions for the prompt (hook only)
|
|
38
41
|
thatch setup --claude [--cursor] [--local|--global]
|
|
39
42
|
Install config + instructions + hooks + skills
|
|
@@ -46,11 +49,17 @@ function usage(): never {
|
|
|
46
49
|
Full conversation as OpenAI chat-completions JSONL
|
|
47
50
|
thatch session search <query> [--session <id>] [--regex] [--limit N]
|
|
48
51
|
JSONL of matching parts across sessions (decoded content)
|
|
49
|
-
thatch chat list
|
|
52
|
+
thatch chat list [--stale N|all] Registered chat sessions, grouped Active/Stale:
|
|
53
|
+
name, age, status, project, topic.
|
|
54
|
+
--stale N (days) hides stale sessions
|
|
55
|
+
older than N; default 1. --stale all
|
|
56
|
+
shows every stale session.
|
|
50
57
|
thatch chat tail [--once] [--limit N|all] [--match RE]
|
|
51
58
|
[--from NAME] [--to NAME] [--since DT] [--until DT]
|
|
52
|
-
Follow cross-session chat
|
|
53
|
-
|
|
59
|
+
Follow cross-session chat as a JSONL
|
|
60
|
+
event log: one line per event, "sent"
|
|
61
|
+
and "read" as separate events linked by
|
|
62
|
+
message id. The backlog shows the last
|
|
54
63
|
${CHAT_TAIL_DEFAULT_LIMIT} messages
|
|
55
64
|
(--limit N or --limit all to override).
|
|
56
65
|
--match filters message bodies
|
|
@@ -81,7 +90,7 @@ async function defaultStore(): Promise<string> {
|
|
|
81
90
|
* session ID it keeps the old shape: a line only when pending mail exists
|
|
82
91
|
* for registered sessions in the project, naming the addressees.
|
|
83
92
|
*/
|
|
84
|
-
async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
93
|
+
async function chatHookLine(hookSessionID?: string, transcriptPath?: string): Promise<string> {
|
|
85
94
|
// Disabled chat prints nothing: the hook line must not advertise a
|
|
86
95
|
// feature the user turned off (the tools refuse, the poller never
|
|
87
96
|
// starts, the prompt section omits chat).
|
|
@@ -99,6 +108,25 @@ async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
|
99
108
|
// the MCP identity anchor - the session ID is stable per conversation,
|
|
100
109
|
// so re-registering finds the same row and name.
|
|
101
110
|
const sessionID = mcpSessionID(hookSessionID);
|
|
111
|
+
// Continuation adoption: Claude Code forks the session id on resume
|
|
112
|
+
// (and has been observed to fork spontaneously), stranding the old
|
|
113
|
+
// identity and mailbox. When this session is unknown, look for a
|
|
114
|
+
// predecessor transcript handing off to it; adopting keeps the name
|
|
115
|
+
// and mail following the human-level conversation. Runs before the
|
|
116
|
+
// ensure so the migrated row is the one re-registered.
|
|
117
|
+
if (!db.findChatSession(sessionID) && transcriptPath) {
|
|
118
|
+
try {
|
|
119
|
+
const pred = resolveRegisteredPredecessor(transcriptPath, hookSessionID, (raw) =>
|
|
120
|
+
db.findChatSession(mcpSessionID(raw))?.session_id ?? null,
|
|
121
|
+
);
|
|
122
|
+
if (pred) {
|
|
123
|
+
db.continueChatSession(pred, sessionID);
|
|
124
|
+
hookDebug("hook:adoption", `continued chat identity: ${pred} -> ${sessionID}`);
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(`[thatch] chat continuation adoption failed: ${err}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
102
130
|
// The leave tombstone blocks this ensure: chat_unregister must outlive
|
|
103
131
|
// the turn that issued it, on MCP hosts as on opencode. The line is
|
|
104
132
|
// the standing signal - it repeats every prompt until the model
|
|
@@ -129,6 +157,61 @@ async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
|
129
157
|
return `[thatch] chat: unread message(s) for ${parts.join(", ")} - the addressee should call chat_read`;
|
|
130
158
|
}
|
|
131
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Reads the hook session ID from stdin without hanging when no payload is
|
|
162
|
+
* coming. Interactive runs skip the read outright (isTTY); a piped or
|
|
163
|
+
* inherited stdin that never closes (bare spawns) gives up after a short
|
|
164
|
+
* grace period. Hook hosts write the payload immediately at spawn, so the
|
|
165
|
+
* grace window costs nothing on the hot path. Malformed payloads resolve to
|
|
166
|
+
* undefined rather than failing the reminder.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Reads the hook's stdin JSON without hanging when no payload is coming.
|
|
170
|
+
* Interactive runs skip the read outright (isTTY); a piped or inherited
|
|
171
|
+
* stdin that never closes (bare spawns) gives up after a short grace
|
|
172
|
+
* period and destroys the stream, because a pending stdin read keeps
|
|
173
|
+
* the process alive after main finishes. Hook hosts write the payload
|
|
174
|
+
* immediately at spawn, so the grace window costs nothing on the hot path.
|
|
175
|
+
* Malformed payloads resolve to undefined rather than failing the command.
|
|
176
|
+
* Uses the Node stream API instead of Bun.stdin.text(): a pending
|
|
177
|
+
* Bun.stdin read cannot be cancelled and pins the process forever.
|
|
178
|
+
*/
|
|
179
|
+
function readHookInput(tag: string): Promise<Record<string, unknown> | undefined> {
|
|
180
|
+
if (process.stdin.isTTY) return Promise.resolve(undefined);
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const chunks: Buffer[] = [];
|
|
183
|
+
const timer = setTimeout(() => done(undefined), 500);
|
|
184
|
+
const done = (value: string | undefined) => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
process.stdin.destroy();
|
|
187
|
+
try {
|
|
188
|
+
if (!value) return resolve(undefined);
|
|
189
|
+
logHookPayload(tag, value);
|
|
190
|
+
resolve(JSON.parse(value) as Record<string, unknown>);
|
|
191
|
+
} catch {
|
|
192
|
+
resolve(undefined);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
196
|
+
process.stdin.on("end", () => done(Buffer.concat(chunks).toString()));
|
|
197
|
+
process.stdin.on("error", () => done(undefined));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const hookDebug = createDebugLog(dbPath);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Debug-logs one hook invocation's raw stdin payload (tag hook:<command>,
|
|
205
|
+
* payload truncated). Answers "did the host fire the hook at all, and what
|
|
206
|
+
* did it send?" - the chat DB cannot distinguish a hook that never fired
|
|
207
|
+
* from one whose payload carried no session id, and host hook contracts
|
|
208
|
+
* are sometimes documented wrong.
|
|
209
|
+
*/
|
|
210
|
+
function logHookPayload(command: string, raw: string): void {
|
|
211
|
+
const clipped = raw.length > 2000 ? `${raw.slice(0, 2000)}...` : raw;
|
|
212
|
+
hookDebug(`hook:${command}`, `stdin: ${JSON.stringify(clipped)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
132
215
|
/**
|
|
133
216
|
* Emits the nudge output, prepending a version warning if present. The
|
|
134
217
|
* warning goes first so the LLM sees it before the nudge text. Used by
|
|
@@ -328,80 +411,67 @@ async function sessionCommand(rest: string[]): Promise<void> {
|
|
|
328
411
|
const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
|
|
329
412
|
|
|
330
413
|
/**
|
|
331
|
-
* Renders the `chat list` roster as
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
414
|
+
* Renders the `chat list` roster as two sections - Active and Stale - each
|
|
415
|
+
* an aligned-column table under a header row. Section headers are bold,
|
|
416
|
+
* the stale explainer is muted italic; rows use green names, dim age,
|
|
417
|
+
* green/red status, and a muted italic topic.
|
|
418
|
+
* staleCapMs bounds how old a stale row is displayed (null = no bound);
|
|
419
|
+
* rows past the cap collapse into a muted hidden-count note so the reader
|
|
420
|
+
* knows rows exist that the window left out.
|
|
421
|
+
* Color is applied only when stdout is an interactive terminal (and
|
|
422
|
+
* NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
|
|
423
|
+
* clean plain text.
|
|
337
424
|
*/
|
|
338
|
-
function formatChatRoster(sessions: ChatSessionRow[]): string {
|
|
425
|
+
function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null): string {
|
|
339
426
|
const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
340
427
|
const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
* Detection runs once and only for interactive terminals - pipes (grep,
|
|
371
|
-
* QA runners) always get the raw body, and NO_COLOR opts out entirely.
|
|
372
|
-
* The renderer never fails open: a crashed or missing binary returns the
|
|
373
|
-
* body unchanged.
|
|
374
|
-
*/
|
|
375
|
-
let renderChatBody: (body: string) => string | null | undefined;
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Markdown-to-ANSI renderer for chat tail bodies: renderer selection
|
|
379
|
-
* (glow, then gum format) and body cleanup live in src/chat.ts
|
|
380
|
-
* (selectChatBodyRenderer, cleanRenderedBody, unit-tested there); this
|
|
381
|
-
* wrapper owns the TTY gate, the once-per-process cache, and the spawn.
|
|
382
|
-
* Detection runs only for interactive terminals - pipes (grep, QA
|
|
383
|
-
* runners) always get the raw body, and NO_COLOR opts out entirely.
|
|
384
|
-
* The renderer never fails open: a crashed or missing binary returns the
|
|
385
|
-
* body unchanged.
|
|
386
|
-
*/
|
|
387
|
-
function markdownBodyRenderer(): (body: string) => string {
|
|
388
|
-
if (renderChatBody !== undefined) return renderChatBody;
|
|
389
|
-
if (process.stdout.isTTY !== true || process.env.NO_COLOR) {
|
|
390
|
-
renderChatBody = (body) => body;
|
|
391
|
-
return renderChatBody;
|
|
428
|
+
const { active, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), Date.now(), staleCapMs);
|
|
429
|
+
const renderTable = (rows: ChatSessionRow[]) => {
|
|
430
|
+
const cells = (r: ChatSessionRow) => [
|
|
431
|
+
r.name,
|
|
432
|
+
humanAge(r.last_seen),
|
|
433
|
+
chatLiveness(r),
|
|
434
|
+
r.project ?? "unknown",
|
|
435
|
+
r.topic ?? "",
|
|
436
|
+
];
|
|
437
|
+
// Widths come from the PLAIN cell text; color is applied after padding
|
|
438
|
+
// so zero-width escape codes never skew the alignment.
|
|
439
|
+
const widths = CHAT_ROSTER_HEADER.map((h, i) => Math.max(h.length, ...rows.map((r) => cells(r)[i].length)));
|
|
440
|
+
const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
|
|
441
|
+
const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
|
|
442
|
+
const body = rows.map((r) => {
|
|
443
|
+
const [name, age, status, project, topic] = align(cells(r));
|
|
444
|
+
return [
|
|
445
|
+
paint("32", name),
|
|
446
|
+
paint("2", age),
|
|
447
|
+
paint(status === "stale" ? "31" : "32", status),
|
|
448
|
+
project,
|
|
449
|
+
topic ? paint("3;90", topic) : "",
|
|
450
|
+
].join(" ").trimEnd();
|
|
451
|
+
});
|
|
452
|
+
return [header, ...body].join("\n");
|
|
453
|
+
};
|
|
454
|
+
const parts: string[] = [];
|
|
455
|
+
if (active.length > 0) {
|
|
456
|
+
parts.push([paint("1", "# Active Sessions"), renderTable(active)].join("\n"));
|
|
392
457
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
458
|
+
if (stale.length > 0 || staleHidden > 0) {
|
|
459
|
+
const lines = [paint("1", "# Stale Sessions")];
|
|
460
|
+
if (stale.length > 0) {
|
|
461
|
+
const explainer = `These sessions have missed their heartbeat for over ${Math.round(CHAT_STALE_MS / 1000)} seconds. Their harnesses may no longer be running (stopped, or the machine slept); mail waits until they resume.`;
|
|
462
|
+
lines.push(paint("3;90", explainer), renderTable(stale));
|
|
463
|
+
}
|
|
464
|
+
if (staleHidden > 0) {
|
|
465
|
+
lines.push(
|
|
466
|
+
paint(
|
|
467
|
+
"3;90",
|
|
468
|
+
`${staleHidden} older stale session${staleHidden === 1 ? "" : "s"} hidden; --stale N widens the window to N days, --stale all shows everything.`,
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
parts.push(lines.join("\n"));
|
|
397
473
|
}
|
|
398
|
-
|
|
399
|
-
// Bun 1.3.14).
|
|
400
|
-
renderChatBody = (body) => {
|
|
401
|
-
const run = Bun.spawnSync(argv, { stdin: new Response(body), stdout: "pipe", stderr: "pipe" });
|
|
402
|
-
return run.exitCode === 0 ? cleanRenderedBody(run.stdout.toString()) : body;
|
|
403
|
-
};
|
|
404
|
-
return renderChatBody;
|
|
474
|
+
return parts.join("\n\n");
|
|
405
475
|
}
|
|
406
476
|
|
|
407
477
|
async function chatCommand(rest: string[]): Promise<void> {
|
|
@@ -409,19 +479,33 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
409
479
|
|
|
410
480
|
switch (sub) {
|
|
411
481
|
case "list": {
|
|
482
|
+
// --stale N bounds how old a stale row the roster displays, in days
|
|
483
|
+
// (fractions allowed); the default of one day keeps long-dead
|
|
484
|
+
// sessions from burying the fresh signal. --stale all restores the
|
|
485
|
+
// unbounded view. A bad value dies loudly rather than silently
|
|
486
|
+
// shrinking the roster.
|
|
487
|
+
const staleArg = flagValue(restArgs, "--stale");
|
|
488
|
+
let staleCapMs: number | null = 24 * 60 * 60 * 1000;
|
|
489
|
+
if (staleArg !== undefined) {
|
|
490
|
+
if (staleArg === "all") staleCapMs = null;
|
|
491
|
+
else if (!/^\d+(\.\d+)?$/.test(staleArg) || Number(staleArg) <= 0) {
|
|
492
|
+
console.error(`error: --stale expects a positive number of days or "all", got "${staleArg}"`);
|
|
493
|
+
process.exit(1);
|
|
494
|
+
} else staleCapMs = Number(staleArg) * 24 * 60 * 60 * 1000;
|
|
495
|
+
}
|
|
412
496
|
const sessions = db.listChatSessions();
|
|
413
497
|
if (sessions.length === 0) {
|
|
414
498
|
console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
|
|
415
499
|
break;
|
|
416
500
|
}
|
|
417
|
-
console.log(formatChatRoster(sessions));
|
|
501
|
+
console.log(formatChatRoster(sessions, staleCapMs));
|
|
418
502
|
break;
|
|
419
503
|
}
|
|
420
504
|
|
|
421
505
|
case "tail": {
|
|
422
506
|
const onceOnly = restArgs.includes("--once");
|
|
423
507
|
// Parse and validate everything up front: a bad regex or date must
|
|
424
|
-
// fail before any
|
|
508
|
+
// fail before any lines print, not mid-follow.
|
|
425
509
|
const matches = flagValues(restArgs, "--match").map((pattern) => {
|
|
426
510
|
try {
|
|
427
511
|
return new RegExp(pattern);
|
|
@@ -472,16 +556,15 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
472
556
|
// each poll emits only new messages and newly-read rows. The diff
|
|
473
557
|
// and backlog logic lives in src/chat.ts (chatTailDiff,
|
|
474
558
|
// chatTailBacklog), unit-tested there. The backlog seeds the state
|
|
475
|
-
// from every feed row, so the limit hides
|
|
559
|
+
// from every feed row, so the limit hides lines, not history.
|
|
476
560
|
let state: Map<number, string | null>;
|
|
477
|
-
let firstFollowEvent = true;
|
|
478
561
|
|
|
479
562
|
// Follow mode holds the handle open until Ctrl-C. The finally below
|
|
480
563
|
// closes it on every exit path except the SIGINT handler's, which
|
|
481
564
|
// is why the handler closes it itself: process.exit does not unwind
|
|
482
565
|
// finally blocks (closing twice is harmless).
|
|
483
566
|
try {
|
|
484
|
-
// First pass:
|
|
567
|
+
// First pass: print the backlog, then follow unless
|
|
485
568
|
// --once or the --until window has already closed (a closed
|
|
486
569
|
// window has no future events to follow). The backlog fetch gets
|
|
487
570
|
// the same friendly failure treatment as follow polls: a locked
|
|
@@ -496,13 +579,13 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
496
579
|
}
|
|
497
580
|
state = backlog.state;
|
|
498
581
|
if (backlog.elided > 0) {
|
|
499
|
-
// The elision note goes to stderr so stdout stays
|
|
500
|
-
//
|
|
582
|
+
// The elision note goes to stderr so stdout stays pure JSONL
|
|
583
|
+
// for pipes.
|
|
501
584
|
console.error(
|
|
502
|
-
`[thatch chat tail] showing last ${backlog.
|
|
585
|
+
`[thatch chat tail] showing last ${backlog.shown} of ${backlog.elided + backlog.shown} messages (--limit N, --limit all)`,
|
|
503
586
|
);
|
|
504
587
|
}
|
|
505
|
-
|
|
588
|
+
for (const event of backlog.events) console.log(formatChatTailJsonl(event));
|
|
506
589
|
if (onceOnly || (filter.untilMs !== null && filter.untilMs <= Date.now())) break;
|
|
507
590
|
|
|
508
591
|
process.on("SIGINT", () => {
|
|
@@ -515,10 +598,7 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
515
598
|
// Follow polls re-filter the live feed: matching messages
|
|
516
599
|
// surface as they arrive, non-matching ones stay invisible.
|
|
517
600
|
for (const event of chatTailDiff(state, filterChatTailRows(db.chatMessageFeed(), filter)).events) {
|
|
518
|
-
|
|
519
|
-
if (firstFollowEvent && backlog.events.length > 0) console.log(`\n${CHAT_TAIL_SEPARATOR}\n`);
|
|
520
|
-
firstFollowEvent = false;
|
|
521
|
-
console.log(formatChatTailCard(event, markdownBodyRenderer()));
|
|
601
|
+
console.log(formatChatTailJsonl(event));
|
|
522
602
|
}
|
|
523
603
|
} catch (err) {
|
|
524
604
|
// The surface's whole job is watching a shared, mutable file;
|
|
@@ -654,10 +734,28 @@ switch (command) {
|
|
|
654
734
|
} catch (err) {
|
|
655
735
|
console.error(`[thatch] hygiene report failed: ${err}`);
|
|
656
736
|
}
|
|
657
|
-
// Chat mail check.
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
737
|
+
// Chat mail check. SessionStart DOES pass a session id in the hook's
|
|
738
|
+
// stdin JSON (session_id for Claude Code, conversation_id for Cursor),
|
|
739
|
+
// so a hooked session learns its identity at session start, not only at
|
|
740
|
+
// its first prompt. When no payload is coming (manual runs, bare
|
|
741
|
+
// spawns) the read is skipped and the line keeps the old shape: a line
|
|
742
|
+
// only when pending mail exists for registered sessions in this project.
|
|
743
|
+
const hookInput = await readHookInput("reminder");
|
|
744
|
+
const hookSessionID = (hookInput?.session_id as string | undefined) ?? (hookInput?.conversation_id as string | undefined);
|
|
745
|
+
const chatLine = await chatHookLine(hookSessionID, hookInput?.transcript_path as string | undefined);
|
|
746
|
+
// Claude Code identity anchor: hooks and the MCP server are children of
|
|
747
|
+
// the same claude process, so recording (parent pid -> session) lets the
|
|
748
|
+
// server resolve the caller without trusting the model's `as`. Recorded
|
|
749
|
+
// only for payloads with session_id (Claude Code) - Cursor's hooks come
|
|
750
|
+
// from a shared extension host where ppid is ambiguous. CLAUDE_CODE_
|
|
751
|
+
// SESSION_ID env in the server process is the spawn-time fallback.
|
|
752
|
+
if (hookInput?.session_id && chatLine) {
|
|
753
|
+
try {
|
|
754
|
+
db.recordChatHostPid(process.ppid, mcpSessionID(String(hookInput.session_id)));
|
|
755
|
+
} catch (err) {
|
|
756
|
+
console.error(`[thatch] chat host-pid anchor failed: ${err}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
661
759
|
const reminder = claudeSessionStartReminder(repo, hygiene) + (chatLine ? `\n\n${chatLine}` : "");
|
|
662
760
|
if (isJson) {
|
|
663
761
|
console.log(JSON.stringify({ additional_context: reminder }));
|
|
@@ -765,10 +863,25 @@ switch (command) {
|
|
|
765
863
|
console.log(` Instructions: ${result.claudeMd}`);
|
|
766
864
|
console.log(` Hooks: ${result.settings}`);
|
|
767
865
|
printSkills(result);
|
|
866
|
+
if (result.commands.length > 0) {
|
|
867
|
+
console.log(` Commands: ${result.commands.length} written (${result.commands.map((p) => p.split("/").pop()).join(", ")})`);
|
|
868
|
+
}
|
|
768
869
|
console.log();
|
|
769
870
|
if (result.mcpAddCommand) {
|
|
770
|
-
|
|
771
|
-
|
|
871
|
+
// Global setup cannot write ~/.claude.json directly, so run the
|
|
872
|
+
// registration on the user's behalf. Idempotent: an existing
|
|
873
|
+
// registration is left alone; a missing or failing claude CLI
|
|
874
|
+
// falls back to printing the manual command.
|
|
875
|
+
const reg = await registerClaudeMcpServer(thatchBin);
|
|
876
|
+
if (reg.status === "registered") {
|
|
877
|
+
console.log(` MCP server: ${reg.detail}`);
|
|
878
|
+
} else if (reg.status === "already-registered") {
|
|
879
|
+
console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
|
|
880
|
+
} else {
|
|
881
|
+
console.log("To register the MCP server globally, run:");
|
|
882
|
+
console.log(` ${result.mcpAddCommand}`);
|
|
883
|
+
console.log(` (${reg.detail})`);
|
|
884
|
+
}
|
|
772
885
|
console.log();
|
|
773
886
|
}
|
|
774
887
|
console.log("Restart Claude Code for the changes to take effect.");
|
|
@@ -806,6 +919,7 @@ switch (command) {
|
|
|
806
919
|
// is not delayed waiting for an exit code-driven message.
|
|
807
920
|
try {
|
|
808
921
|
const raw = await Bun.stdin.text();
|
|
922
|
+
logHookPayload("buffer-batch", raw);
|
|
809
923
|
const data = JSON.parse(raw) as { session_id?: string; tool_calls?: unknown[] };
|
|
810
924
|
if (data.session_id && Array.isArray(data.tool_calls)) {
|
|
811
925
|
const { appendBatch } = await import("../src/extract-queue");
|
|
@@ -826,6 +940,7 @@ switch (command) {
|
|
|
826
940
|
// so we try the most likely candidates.
|
|
827
941
|
try {
|
|
828
942
|
const raw = await Bun.stdin.text();
|
|
943
|
+
logHookPayload("buffer-tool", raw);
|
|
829
944
|
const data = JSON.parse(raw) as {
|
|
830
945
|
conversation_id?: string;
|
|
831
946
|
session_id?: string;
|
|
@@ -870,9 +985,11 @@ switch (command) {
|
|
|
870
985
|
const isJson = args.includes("--json");
|
|
871
986
|
try {
|
|
872
987
|
const raw = await Bun.stdin.text();
|
|
988
|
+
logHookPayload("flush-tools", raw);
|
|
873
989
|
const data = JSON.parse(raw) as {
|
|
874
990
|
session_id?: string;
|
|
875
991
|
conversation_id?: string;
|
|
992
|
+
transcript_path?: string;
|
|
876
993
|
prompt?: string;
|
|
877
994
|
message?: string;
|
|
878
995
|
text?: string;
|
|
@@ -901,7 +1018,17 @@ switch (command) {
|
|
|
901
1018
|
// Chat check: with a hook session ID this always prints the caller's
|
|
902
1019
|
// assigned identity (and its unread count), so the model knows who it
|
|
903
1020
|
// is before any chat tool call; with no mail the line says so.
|
|
904
|
-
const chatLine = await chatHookLine(sessionID);
|
|
1021
|
+
const chatLine = await chatHookLine(sessionID, data.transcript_path);
|
|
1022
|
+
// Claude Code identity anchor (see reminder case): record the
|
|
1023
|
+
// parent-process mapping for session_id-bearing (Claude Code)
|
|
1024
|
+
// payloads; Cursor's conversation_id stays on the `as` path.
|
|
1025
|
+
if (data.session_id && chatLine && sessionID) {
|
|
1026
|
+
try {
|
|
1027
|
+
db.recordChatHostPid(process.ppid, mcpSessionID(sessionID));
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
console.error(`[thatch] chat host-pid anchor failed: ${err}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
905
1032
|
|
|
906
1033
|
// Tier 1: extraction nudge. Peek (don't drain) - the queue persists
|
|
907
1034
|
// until the agent calls memory_remember, which triggers consumeQueue.
|
|
@@ -973,6 +1100,91 @@ switch (command) {
|
|
|
973
1100
|
break;
|
|
974
1101
|
}
|
|
975
1102
|
|
|
1103
|
+
case "chat-notify": {
|
|
1104
|
+
// Stop hook for BOTH MCP hosts; the payload shape tells them apart:
|
|
1105
|
+
// Claude Code sends session_id (+ stop_hook_active, background_tasks),
|
|
1106
|
+
// Cursor sends conversation_id (+ status, loop_count).
|
|
1107
|
+
//
|
|
1108
|
+
// Output shapes: Cursor consumes { followup_message } (auto-submitted
|
|
1109
|
+
// as the next user message); Claude Code consumes hookSpecificOutput.
|
|
1110
|
+
// additionalContext on Stop - a reminder that continues the turn,
|
|
1111
|
+
// rendered as hook feedback rather than a hook error. Pointer-only:
|
|
1112
|
+
// sender names and a count, never bodies (bodies flow only through
|
|
1113
|
+
// the framed chat_read). The nudge text carries the system-
|
|
1114
|
+
// notification framing because a follow-up arrives as user input.
|
|
1115
|
+
try {
|
|
1116
|
+
const hookInput = await readHookInput("chat-notify");
|
|
1117
|
+
if (!chatEnabled(loadConfig(dbPath).config)) {
|
|
1118
|
+
console.log("{}");
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
const claudeID = typeof hookInput?.session_id === "string" && hookInput.session_id ? String(hookInput.session_id) : null;
|
|
1122
|
+
const cursorID = typeof hookInput?.conversation_id === "string" && hookInput.conversation_id ? String(hookInput.conversation_id) : null;
|
|
1123
|
+
|
|
1124
|
+
// Host-specific loop guards.
|
|
1125
|
+
if (claudeID) {
|
|
1126
|
+
// Never fight the host's loop protections: a stop-hook continuation
|
|
1127
|
+
// already in flight ends the turn, and a session paused on
|
|
1128
|
+
// background work gets its wake when that work's turn ends -
|
|
1129
|
+
// injecting now would interject mid-flight (the mid-turn injection
|
|
1130
|
+
// incident class).
|
|
1131
|
+
if (hookInput?.stop_hook_active === true) {
|
|
1132
|
+
console.log("{}");
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
if (Array.isArray(hookInput?.background_tasks) && hookInput.background_tasks.length > 0) {
|
|
1136
|
+
console.log("{}");
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
} else if (cursorID) {
|
|
1140
|
+
// Cursor: never auto-continue an aborted or errored turn (the
|
|
1141
|
+
// user's stop key wins).
|
|
1142
|
+
if (hookInput?.status !== "completed") {
|
|
1143
|
+
console.log("{}");
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
} else {
|
|
1147
|
+
console.log("{}");
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Lookup, not ensure-register: registration exists by the time stop
|
|
1152
|
+
// fires (the prompt hooks anchored the conversation), and minting
|
|
1153
|
+
// rows from contexts we do not understand is how the throwaway
|
|
1154
|
+
// sessionStart identities happened.
|
|
1155
|
+
const anchor = mcpSessionID(claudeID ?? cursorID!);
|
|
1156
|
+
if (!db.findChatSession(anchor)) {
|
|
1157
|
+
console.log("{}");
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
const pending = db
|
|
1161
|
+
.pendingChatNotifications([anchor], isoMinutesAgo(CHAT_RENUDGE_MINUTES))
|
|
1162
|
+
.filter((m) => m.to_session === anchor);
|
|
1163
|
+
if (pending.length === 0) {
|
|
1164
|
+
console.log("{}");
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1167
|
+
const senders = [
|
|
1168
|
+
...new Set(pending.map((m) => renderChatParticipant(m.from_name, m.from_session))),
|
|
1169
|
+
];
|
|
1170
|
+
// Stamp before emitting: the re-nudge window (not the model's
|
|
1171
|
+
// obedience) bounds repeats when the mail goes ignored.
|
|
1172
|
+
db.markChatDelivered(pending.map((m) => m.id));
|
|
1173
|
+
const nudge = chatNotificationNudge(senders, pending.length);
|
|
1174
|
+
console.log(
|
|
1175
|
+
JSON.stringify(
|
|
1176
|
+
claudeID
|
|
1177
|
+
? { hookSpecificOutput: { hookEventName: "Stop", additionalContext: nudge } }
|
|
1178
|
+
: { followup_message: nudge },
|
|
1179
|
+
),
|
|
1180
|
+
);
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
console.error(`[thatch chat-notify] failed: ${err}`);
|
|
1183
|
+
console.log("{}");
|
|
1184
|
+
}
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
976
1188
|
case "flush-predictions": {
|
|
977
1189
|
// UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor) hook.
|
|
978
1190
|
// Standalone prediction fire: queries the sideband for scored
|
package/docs/dev/README.md
CHANGED
|
@@ -24,7 +24,8 @@ Shared core
|
|
|
24
24
|
├── embeddings.ts → embedding model via transformers.js
|
|
25
25
|
├── git.ts → detect repo identity (store name)
|
|
26
26
|
├── hygiene.ts → hygiene report (pending dedups, stale, orphaned branches)
|
|
27
|
-
├── prompts.ts → system prompt, compaction, reminders, recall/prediction/behavior nudges, CLAUDE.md instructions
|
|
27
|
+
├── prompts.ts → system prompt, compaction, reminders, recall/prediction/behavior nudges, prompt cores, CLAUDE.md instructions
|
|
28
|
+
├── commands.ts → /thatch slash-command registry: actions from prompt cores + wrap-ups, per-host sync
|
|
28
29
|
├── sideband.ts → Unix socket server + client: warm-model semantic match for hook processes
|
|
29
30
|
├── skills.ts → SKILL.md content + installer (shared + opencode-only arrays)
|
|
30
31
|
├── scoring-engine.ts → generic four-table scoring base (shared by prediction + behavior engines)
|
|
@@ -83,13 +84,15 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
83
84
|
| `chat.ts` | Cross-session chat between opencode sessions on one machine (opencode-only). `ChatStore` holds the shared directory and inbox in SQLite so any process can read/write them; `ChatPoller` heartbeats hosted sessions and delivers wake prompts through an injected callback when the recipient is idle - only the recipient's host process delivers, so there is no cross-process double-delivery. Heartbeat staleness flags crashed sessions; a per-recipient nudge rate cap is the anti-loop hard brake. See [features/cross-session-chat.md](features/cross-session-chat.md). |
|
|
84
85
|
| `chat-names.ts` | The static display-name pool for the chat directory: whimsical geek-culture names in fnord's Nomenclater style, baked in so name assignment never costs a model call. The pool is the fallback base for registrations with no usable session title. |
|
|
85
86
|
| `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
|
|
86
|
-
| `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), watcher notification (`watcherNotificationNudge`), version warning (`versionWarningNudge`), chat wake notification (`chatNotificationNudge`), chat transcript echo text and echo recognizer (`chatEchoText` / `isChatEchoParts`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
|
|
87
|
+
| `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), watcher notification (`watcherNotificationNudge`), version warning (`versionWarningNudge`), chat wake notification (`chatNotificationNudge`), chat transcript echo text and echo recognizer (`chatEchoText` / `isChatEchoParts`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. Also the prompt cores (`defragCore`, `hygieneCore`, `reflectCore`, `extractionCore`) - host-agnostic behavior instruction bodies shared by nudges and `/thatch` commands, parameterized by a `ToolNamer` for per-host tool spellings. See [features/commands.md](features/commands.md). |
|
|
88
|
+
| `commands.ts` | Slash-command registry and sync. `actionDefs(tool)` renders the on-demand actions (defrag, extract, hygiene, reflect) from the prompt cores; the wrap-up commands (compact, exit) are bespoke greenlight-gated templates. Renderers produce full command files for opencode (`installOpencodeCommands`, synced by the plugin at init) and Claude Code (`installClaudeCommands`, synced by setup); `compilePrompts` in `mcp.ts` exposes the same actions as MCP prompts for Cursor. A parity-guard test pins the three sets to the documented exclusions. See [features/commands.md](features/commands.md). |
|
|
87
89
|
| `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (fact-extractor, dedup-classifier, project-primer, the review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
|
|
88
90
|
| `scoring-engine.ts` | Generic four-table scoring engine with Bayesian confidence. Shared base for prediction and behavior engines — each wraps it with table-specific names. |
|
|
89
91
|
| `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
|
|
90
92
|
| `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
|
|
91
93
|
| `seed-behaviors.ts` | Default behavior seeding on first run. Populates starter self-discipline rules. |
|
|
92
94
|
| `vector-math.ts` | `blobToVector` and `cosineSimilarity` helpers used across the codebase. |
|
|
95
|
+
| `debug.ts` | Opt-in diagnostic logging (`THATCH_DEBUG`) to `debug.log` beside the database, never to the host's stdout/stderr (invisible inside the TUI; would leak into the quality gate from tests). Lines are `<iso> [feature:aspect] msg`; the env var is `1`/`all`/`*` for everything or a comma-separated list of tag filters matching a whole tag or its feature part. Add a tagged line wherever a live harness might need to answer "what did the plugin actually see here?" - the first consumer is `chat:startup`, which records the resolved `-s` session and every registration guard. |
|
|
93
96
|
|
|
94
97
|
## Plugin hooks
|
|
95
98
|
|
|
@@ -338,7 +341,7 @@ behavior cycle (agent-driven, self-graded)
|
|
|
338
341
|
`behaviors(id PK, store, statement, rationale, embedding BLOB, model, confidence REAL, confirm_count REAL, disconfirm_count REAL, created_at, updated_at)`,
|
|
339
342
|
`behavior_edges(matcher_id, behavior_id, weight REAL, PK(matcher_id, behavior_id), FK CASCADE)`,
|
|
340
343
|
`behavior_provenance(id PK, behavior_id, signal, detail, created_at, FK CASCADE)`,
|
|
341
|
-
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto)`,
|
|
344
|
+
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto, worktree)`,
|
|
342
345
|
`chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at, via_broadcast)`,
|
|
343
346
|
`chat_name_counters(base PK, next)` - per-base counters for assigned chat names; only ever increments.
|
|
344
347
|
`chat_leave_tombstones(session_id PK, left_at)` - explicit leaves; suppresses auto-registration until chat_register rejoins.
|
|
@@ -382,6 +385,12 @@ alone to check docs without the test suite. Run `mise run typecheck` for
|
|
|
382
385
|
`tsc` alone (uses `tsconfig.check.json`, which includes the test files that
|
|
383
386
|
the build's `tsconfig.json` excludes).
|
|
384
387
|
|
|
388
|
+
To watch what a live harness's plugin actually sees at init, launch opencode
|
|
389
|
+
with `THATCH_DEBUG=chat` (or `=1` for every tag) and tail
|
|
390
|
+
`~/.config/thatch/debug.log`. The env var must be set on the `opencode`
|
|
391
|
+
process itself: the plugin runs in a worker thread that inherits the
|
|
392
|
+
process environment but not its argv.
|
|
393
|
+
|
|
385
394
|
## Release
|
|
386
395
|
|
|
387
396
|
```bash
|
|
@@ -23,6 +23,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
|
|
|
23
23
|
| [behavior-engine.md](behavior-engine.md) | Behavior engine (LLM self-discipline rules, ham/spam, auto-fire) |
|
|
24
24
|
| [hygiene.md](hygiene.md) | Hygiene system (duplicate, stale, orphaned branch signals) |
|
|
25
25
|
| [deduplication.md](deduplication.md) | Deduplication system (find pairs, verdict lifecycle) |
|
|
26
|
+
| [commands.md](commands.md) | Slash commands (on-demand actions from shared prompt cores, wrap-ups, host sync) |
|
|
26
27
|
| [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
|
|
27
28
|
| [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
|
|
28
29
|
| [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking, wrap-up commands) |
|