@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/README.md
CHANGED
|
@@ -99,8 +99,10 @@ Thatch gives your agent:
|
|
|
99
99
|
(opencode, Claude Code, and Cursor; same machine). opencode sessions
|
|
100
100
|
join the directory automatically under an assigned, never-reused name
|
|
101
101
|
(a slug of the session title plus a counter); others see the roster, and
|
|
102
|
-
messages land in inboxes -- opencode sessions get woken with a
|
|
103
|
-
|
|
102
|
+
messages land in inboxes -- idle opencode sessions get woken with a
|
|
103
|
+
prompt, and Cursor and Claude Code wake when a turn ends with unread
|
|
104
|
+
mail (Cursor also at prompt time; Claude Code also at startup and
|
|
105
|
+
resume). Loop-safe:
|
|
104
106
|
messages are informational to the receiving agent, and wake prompts are
|
|
105
107
|
rate-capped. Delivered messages are framed as untrusted content so a
|
|
106
108
|
hostile message cannot impersonate your instructions. Disable the whole
|
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, 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,14 @@ 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 Registered chat sessions
|
|
52
|
+
thatch chat list Registered chat sessions, grouped Active/Stale:
|
|
53
|
+
name, age, status, project, topic
|
|
50
54
|
thatch chat tail [--once] [--limit N|all] [--match RE]
|
|
51
55
|
[--from NAME] [--to NAME] [--since DT] [--until DT]
|
|
52
|
-
Follow cross-session chat
|
|
53
|
-
|
|
56
|
+
Follow cross-session chat as a JSONL
|
|
57
|
+
event log: one line per event, "sent"
|
|
58
|
+
and "read" as separate events linked by
|
|
59
|
+
message id. The backlog shows the last
|
|
54
60
|
${CHAT_TAIL_DEFAULT_LIMIT} messages
|
|
55
61
|
(--limit N or --limit all to override).
|
|
56
62
|
--match filters message bodies
|
|
@@ -81,7 +87,7 @@ async function defaultStore(): Promise<string> {
|
|
|
81
87
|
* session ID it keeps the old shape: a line only when pending mail exists
|
|
82
88
|
* for registered sessions in the project, naming the addressees.
|
|
83
89
|
*/
|
|
84
|
-
async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
90
|
+
async function chatHookLine(hookSessionID?: string, transcriptPath?: string): Promise<string> {
|
|
85
91
|
// Disabled chat prints nothing: the hook line must not advertise a
|
|
86
92
|
// feature the user turned off (the tools refuse, the poller never
|
|
87
93
|
// starts, the prompt section omits chat).
|
|
@@ -99,6 +105,25 @@ async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
|
99
105
|
// the MCP identity anchor - the session ID is stable per conversation,
|
|
100
106
|
// so re-registering finds the same row and name.
|
|
101
107
|
const sessionID = mcpSessionID(hookSessionID);
|
|
108
|
+
// Continuation adoption: Claude Code forks the session id on resume
|
|
109
|
+
// (and has been observed to fork spontaneously), stranding the old
|
|
110
|
+
// identity and mailbox. When this session is unknown, look for a
|
|
111
|
+
// predecessor transcript handing off to it; adopting keeps the name
|
|
112
|
+
// and mail following the human-level conversation. Runs before the
|
|
113
|
+
// ensure so the migrated row is the one re-registered.
|
|
114
|
+
if (!db.findChatSession(sessionID) && transcriptPath) {
|
|
115
|
+
try {
|
|
116
|
+
const pred = resolveRegisteredPredecessor(transcriptPath, hookSessionID, (raw) =>
|
|
117
|
+
db.findChatSession(mcpSessionID(raw))?.session_id ?? null,
|
|
118
|
+
);
|
|
119
|
+
if (pred) {
|
|
120
|
+
db.continueChatSession(pred, sessionID);
|
|
121
|
+
hookDebug("hook:adoption", `continued chat identity: ${pred} -> ${sessionID}`);
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`[thatch] chat continuation adoption failed: ${err}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
102
127
|
// The leave tombstone blocks this ensure: chat_unregister must outlive
|
|
103
128
|
// the turn that issued it, on MCP hosts as on opencode. The line is
|
|
104
129
|
// the standing signal - it repeats every prompt until the model
|
|
@@ -129,6 +154,61 @@ async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
|
129
154
|
return `[thatch] chat: unread message(s) for ${parts.join(", ")} - the addressee should call chat_read`;
|
|
130
155
|
}
|
|
131
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Reads the hook session ID from stdin without hanging when no payload is
|
|
159
|
+
* coming. Interactive runs skip the read outright (isTTY); a piped or
|
|
160
|
+
* inherited stdin that never closes (bare spawns) gives up after a short
|
|
161
|
+
* grace period. Hook hosts write the payload immediately at spawn, so the
|
|
162
|
+
* grace window costs nothing on the hot path. Malformed payloads resolve to
|
|
163
|
+
* undefined rather than failing the reminder.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Reads the hook's stdin JSON without hanging when no payload is coming.
|
|
167
|
+
* Interactive runs skip the read outright (isTTY); a piped or inherited
|
|
168
|
+
* stdin that never closes (bare spawns) gives up after a short grace
|
|
169
|
+
* period and destroys the stream, because a pending stdin read keeps
|
|
170
|
+
* the process alive after main finishes. Hook hosts write the payload
|
|
171
|
+
* immediately at spawn, so the grace window costs nothing on the hot path.
|
|
172
|
+
* Malformed payloads resolve to undefined rather than failing the command.
|
|
173
|
+
* Uses the Node stream API instead of Bun.stdin.text(): a pending
|
|
174
|
+
* Bun.stdin read cannot be cancelled and pins the process forever.
|
|
175
|
+
*/
|
|
176
|
+
function readHookInput(tag: string): Promise<Record<string, unknown> | undefined> {
|
|
177
|
+
if (process.stdin.isTTY) return Promise.resolve(undefined);
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const chunks: Buffer[] = [];
|
|
180
|
+
const timer = setTimeout(() => done(undefined), 500);
|
|
181
|
+
const done = (value: string | undefined) => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
process.stdin.destroy();
|
|
184
|
+
try {
|
|
185
|
+
if (!value) return resolve(undefined);
|
|
186
|
+
logHookPayload(tag, value);
|
|
187
|
+
resolve(JSON.parse(value) as Record<string, unknown>);
|
|
188
|
+
} catch {
|
|
189
|
+
resolve(undefined);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
193
|
+
process.stdin.on("end", () => done(Buffer.concat(chunks).toString()));
|
|
194
|
+
process.stdin.on("error", () => done(undefined));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const hookDebug = createDebugLog(dbPath);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Debug-logs one hook invocation's raw stdin payload (tag hook:<command>,
|
|
202
|
+
* payload truncated). Answers "did the host fire the hook at all, and what
|
|
203
|
+
* did it send?" - the chat DB cannot distinguish a hook that never fired
|
|
204
|
+
* from one whose payload carried no session id, and host hook contracts
|
|
205
|
+
* are sometimes documented wrong.
|
|
206
|
+
*/
|
|
207
|
+
function logHookPayload(command: string, raw: string): void {
|
|
208
|
+
const clipped = raw.length > 2000 ? `${raw.slice(0, 2000)}...` : raw;
|
|
209
|
+
hookDebug(`hook:${command}`, `stdin: ${JSON.stringify(clipped)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
132
212
|
/**
|
|
133
213
|
* Emits the nudge output, prepending a version warning if present. The
|
|
134
214
|
* warning goes first so the LLM sees it before the nudge text. Used by
|
|
@@ -328,80 +408,52 @@ async function sessionCommand(rest: string[]): Promise<void> {
|
|
|
328
408
|
const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
|
|
329
409
|
|
|
330
410
|
/**
|
|
331
|
-
* Renders the `chat list` roster as
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
411
|
+
* Renders the `chat list` roster as two sections - Active and Stale - each
|
|
412
|
+
* an aligned-column table under a header row. Section headers are bold,
|
|
413
|
+
* the stale explainer is muted italic; rows use green names, dim age,
|
|
414
|
+
* green/red status, and a muted italic topic.
|
|
415
|
+
* Color is applied only when stdout is an interactive terminal (and
|
|
416
|
+
* NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
|
|
417
|
+
* clean plain text.
|
|
337
418
|
*/
|
|
338
419
|
function formatChatRoster(sessions: ChatSessionRow[]): string {
|
|
339
420
|
const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
340
421
|
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;
|
|
422
|
+
const { active, stale } = splitChatRoster(sessions);
|
|
423
|
+
const renderTable = (rows: ChatSessionRow[]) => {
|
|
424
|
+
const cells = (r: ChatSessionRow) => [
|
|
425
|
+
r.name,
|
|
426
|
+
humanAge(r.last_seen),
|
|
427
|
+
chatLiveness(r),
|
|
428
|
+
r.project ?? "unknown",
|
|
429
|
+
r.topic ?? "",
|
|
430
|
+
];
|
|
431
|
+
// Widths come from the PLAIN cell text; color is applied after padding
|
|
432
|
+
// so zero-width escape codes never skew the alignment.
|
|
433
|
+
const widths = CHAT_ROSTER_HEADER.map((h, i) => Math.max(h.length, ...rows.map((r) => cells(r)[i].length)));
|
|
434
|
+
const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
|
|
435
|
+
const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
|
|
436
|
+
const body = rows.map((r) => {
|
|
437
|
+
const [name, age, status, project, topic] = align(cells(r));
|
|
438
|
+
return [
|
|
439
|
+
paint("32", name),
|
|
440
|
+
paint("2", age),
|
|
441
|
+
paint(status === "stale" ? "31" : "32", status),
|
|
442
|
+
project,
|
|
443
|
+
topic ? paint("3;90", topic) : "",
|
|
444
|
+
].join(" ").trimEnd();
|
|
445
|
+
});
|
|
446
|
+
return [header, ...body].join("\n");
|
|
447
|
+
};
|
|
448
|
+
const parts: string[] = [];
|
|
449
|
+
if (active.length > 0) {
|
|
450
|
+
parts.push([paint("1", "# Active Sessions"), renderTable(active)].join("\n"));
|
|
392
451
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return renderChatBody;
|
|
452
|
+
if (stale.length > 0) {
|
|
453
|
+
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.`;
|
|
454
|
+
parts.push([paint("1", "# Stale Sessions"), paint("3;90", explainer), renderTable(stale)].join("\n"));
|
|
397
455
|
}
|
|
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;
|
|
456
|
+
return parts.join("\n\n");
|
|
405
457
|
}
|
|
406
458
|
|
|
407
459
|
async function chatCommand(rest: string[]): Promise<void> {
|
|
@@ -421,7 +473,7 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
421
473
|
case "tail": {
|
|
422
474
|
const onceOnly = restArgs.includes("--once");
|
|
423
475
|
// Parse and validate everything up front: a bad regex or date must
|
|
424
|
-
// fail before any
|
|
476
|
+
// fail before any lines print, not mid-follow.
|
|
425
477
|
const matches = flagValues(restArgs, "--match").map((pattern) => {
|
|
426
478
|
try {
|
|
427
479
|
return new RegExp(pattern);
|
|
@@ -472,16 +524,15 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
472
524
|
// each poll emits only new messages and newly-read rows. The diff
|
|
473
525
|
// and backlog logic lives in src/chat.ts (chatTailDiff,
|
|
474
526
|
// chatTailBacklog), unit-tested there. The backlog seeds the state
|
|
475
|
-
// from every feed row, so the limit hides
|
|
527
|
+
// from every feed row, so the limit hides lines, not history.
|
|
476
528
|
let state: Map<number, string | null>;
|
|
477
|
-
let firstFollowEvent = true;
|
|
478
529
|
|
|
479
530
|
// Follow mode holds the handle open until Ctrl-C. The finally below
|
|
480
531
|
// closes it on every exit path except the SIGINT handler's, which
|
|
481
532
|
// is why the handler closes it itself: process.exit does not unwind
|
|
482
533
|
// finally blocks (closing twice is harmless).
|
|
483
534
|
try {
|
|
484
|
-
// First pass:
|
|
535
|
+
// First pass: print the backlog, then follow unless
|
|
485
536
|
// --once or the --until window has already closed (a closed
|
|
486
537
|
// window has no future events to follow). The backlog fetch gets
|
|
487
538
|
// the same friendly failure treatment as follow polls: a locked
|
|
@@ -496,13 +547,13 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
496
547
|
}
|
|
497
548
|
state = backlog.state;
|
|
498
549
|
if (backlog.elided > 0) {
|
|
499
|
-
// The elision note goes to stderr so stdout stays
|
|
500
|
-
//
|
|
550
|
+
// The elision note goes to stderr so stdout stays pure JSONL
|
|
551
|
+
// for pipes.
|
|
501
552
|
console.error(
|
|
502
|
-
`[thatch chat tail] showing last ${backlog.
|
|
553
|
+
`[thatch chat tail] showing last ${backlog.shown} of ${backlog.elided + backlog.shown} messages (--limit N, --limit all)`,
|
|
503
554
|
);
|
|
504
555
|
}
|
|
505
|
-
|
|
556
|
+
for (const event of backlog.events) console.log(formatChatTailJsonl(event));
|
|
506
557
|
if (onceOnly || (filter.untilMs !== null && filter.untilMs <= Date.now())) break;
|
|
507
558
|
|
|
508
559
|
process.on("SIGINT", () => {
|
|
@@ -515,10 +566,7 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
515
566
|
// Follow polls re-filter the live feed: matching messages
|
|
516
567
|
// surface as they arrive, non-matching ones stay invisible.
|
|
517
568
|
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()));
|
|
569
|
+
console.log(formatChatTailJsonl(event));
|
|
522
570
|
}
|
|
523
571
|
} catch (err) {
|
|
524
572
|
// The surface's whole job is watching a shared, mutable file;
|
|
@@ -654,10 +702,28 @@ switch (command) {
|
|
|
654
702
|
} catch (err) {
|
|
655
703
|
console.error(`[thatch] hygiene report failed: ${err}`);
|
|
656
704
|
}
|
|
657
|
-
// Chat mail check.
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
705
|
+
// Chat mail check. SessionStart DOES pass a session id in the hook's
|
|
706
|
+
// stdin JSON (session_id for Claude Code, conversation_id for Cursor),
|
|
707
|
+
// so a hooked session learns its identity at session start, not only at
|
|
708
|
+
// its first prompt. When no payload is coming (manual runs, bare
|
|
709
|
+
// spawns) the read is skipped and the line keeps the old shape: a line
|
|
710
|
+
// only when pending mail exists for registered sessions in this project.
|
|
711
|
+
const hookInput = await readHookInput("reminder");
|
|
712
|
+
const hookSessionID = (hookInput?.session_id as string | undefined) ?? (hookInput?.conversation_id as string | undefined);
|
|
713
|
+
const chatLine = await chatHookLine(hookSessionID, hookInput?.transcript_path as string | undefined);
|
|
714
|
+
// Claude Code identity anchor: hooks and the MCP server are children of
|
|
715
|
+
// the same claude process, so recording (parent pid -> session) lets the
|
|
716
|
+
// server resolve the caller without trusting the model's `as`. Recorded
|
|
717
|
+
// only for payloads with session_id (Claude Code) - Cursor's hooks come
|
|
718
|
+
// from a shared extension host where ppid is ambiguous. CLAUDE_CODE_
|
|
719
|
+
// SESSION_ID env in the server process is the spawn-time fallback.
|
|
720
|
+
if (hookInput?.session_id && chatLine) {
|
|
721
|
+
try {
|
|
722
|
+
db.recordChatHostPid(process.ppid, mcpSessionID(String(hookInput.session_id)));
|
|
723
|
+
} catch (err) {
|
|
724
|
+
console.error(`[thatch] chat host-pid anchor failed: ${err}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
661
727
|
const reminder = claudeSessionStartReminder(repo, hygiene) + (chatLine ? `\n\n${chatLine}` : "");
|
|
662
728
|
if (isJson) {
|
|
663
729
|
console.log(JSON.stringify({ additional_context: reminder }));
|
|
@@ -767,8 +833,20 @@ switch (command) {
|
|
|
767
833
|
printSkills(result);
|
|
768
834
|
console.log();
|
|
769
835
|
if (result.mcpAddCommand) {
|
|
770
|
-
|
|
771
|
-
|
|
836
|
+
// Global setup cannot write ~/.claude.json directly, so run the
|
|
837
|
+
// registration on the user's behalf. Idempotent: an existing
|
|
838
|
+
// registration is left alone; a missing or failing claude CLI
|
|
839
|
+
// falls back to printing the manual command.
|
|
840
|
+
const reg = await registerClaudeMcpServer(thatchBin);
|
|
841
|
+
if (reg.status === "registered") {
|
|
842
|
+
console.log(` MCP server: ${reg.detail}`);
|
|
843
|
+
} else if (reg.status === "already-registered") {
|
|
844
|
+
console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
|
|
845
|
+
} else {
|
|
846
|
+
console.log("To register the MCP server globally, run:");
|
|
847
|
+
console.log(` ${result.mcpAddCommand}`);
|
|
848
|
+
console.log(` (${reg.detail})`);
|
|
849
|
+
}
|
|
772
850
|
console.log();
|
|
773
851
|
}
|
|
774
852
|
console.log("Restart Claude Code for the changes to take effect.");
|
|
@@ -806,6 +884,7 @@ switch (command) {
|
|
|
806
884
|
// is not delayed waiting for an exit code-driven message.
|
|
807
885
|
try {
|
|
808
886
|
const raw = await Bun.stdin.text();
|
|
887
|
+
logHookPayload("buffer-batch", raw);
|
|
809
888
|
const data = JSON.parse(raw) as { session_id?: string; tool_calls?: unknown[] };
|
|
810
889
|
if (data.session_id && Array.isArray(data.tool_calls)) {
|
|
811
890
|
const { appendBatch } = await import("../src/extract-queue");
|
|
@@ -826,6 +905,7 @@ switch (command) {
|
|
|
826
905
|
// so we try the most likely candidates.
|
|
827
906
|
try {
|
|
828
907
|
const raw = await Bun.stdin.text();
|
|
908
|
+
logHookPayload("buffer-tool", raw);
|
|
829
909
|
const data = JSON.parse(raw) as {
|
|
830
910
|
conversation_id?: string;
|
|
831
911
|
session_id?: string;
|
|
@@ -870,9 +950,11 @@ switch (command) {
|
|
|
870
950
|
const isJson = args.includes("--json");
|
|
871
951
|
try {
|
|
872
952
|
const raw = await Bun.stdin.text();
|
|
953
|
+
logHookPayload("flush-tools", raw);
|
|
873
954
|
const data = JSON.parse(raw) as {
|
|
874
955
|
session_id?: string;
|
|
875
956
|
conversation_id?: string;
|
|
957
|
+
transcript_path?: string;
|
|
876
958
|
prompt?: string;
|
|
877
959
|
message?: string;
|
|
878
960
|
text?: string;
|
|
@@ -901,7 +983,17 @@ switch (command) {
|
|
|
901
983
|
// Chat check: with a hook session ID this always prints the caller's
|
|
902
984
|
// assigned identity (and its unread count), so the model knows who it
|
|
903
985
|
// is before any chat tool call; with no mail the line says so.
|
|
904
|
-
const chatLine = await chatHookLine(sessionID);
|
|
986
|
+
const chatLine = await chatHookLine(sessionID, data.transcript_path);
|
|
987
|
+
// Claude Code identity anchor (see reminder case): record the
|
|
988
|
+
// parent-process mapping for session_id-bearing (Claude Code)
|
|
989
|
+
// payloads; Cursor's conversation_id stays on the `as` path.
|
|
990
|
+
if (data.session_id && chatLine && sessionID) {
|
|
991
|
+
try {
|
|
992
|
+
db.recordChatHostPid(process.ppid, mcpSessionID(sessionID));
|
|
993
|
+
} catch (err) {
|
|
994
|
+
console.error(`[thatch] chat host-pid anchor failed: ${err}`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
905
997
|
|
|
906
998
|
// Tier 1: extraction nudge. Peek (don't drain) - the queue persists
|
|
907
999
|
// until the agent calls memory_remember, which triggers consumeQueue.
|
|
@@ -973,6 +1065,91 @@ switch (command) {
|
|
|
973
1065
|
break;
|
|
974
1066
|
}
|
|
975
1067
|
|
|
1068
|
+
case "chat-notify": {
|
|
1069
|
+
// Stop hook for BOTH MCP hosts; the payload shape tells them apart:
|
|
1070
|
+
// Claude Code sends session_id (+ stop_hook_active, background_tasks),
|
|
1071
|
+
// Cursor sends conversation_id (+ status, loop_count).
|
|
1072
|
+
//
|
|
1073
|
+
// Output shapes: Cursor consumes { followup_message } (auto-submitted
|
|
1074
|
+
// as the next user message); Claude Code consumes hookSpecificOutput.
|
|
1075
|
+
// additionalContext on Stop - a reminder that continues the turn,
|
|
1076
|
+
// rendered as hook feedback rather than a hook error. Pointer-only:
|
|
1077
|
+
// sender names and a count, never bodies (bodies flow only through
|
|
1078
|
+
// the framed chat_read). The nudge text carries the system-
|
|
1079
|
+
// notification framing because a follow-up arrives as user input.
|
|
1080
|
+
try {
|
|
1081
|
+
const hookInput = await readHookInput("chat-notify");
|
|
1082
|
+
if (!chatEnabled(loadConfig(dbPath).config)) {
|
|
1083
|
+
console.log("{}");
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
const claudeID = typeof hookInput?.session_id === "string" && hookInput.session_id ? String(hookInput.session_id) : null;
|
|
1087
|
+
const cursorID = typeof hookInput?.conversation_id === "string" && hookInput.conversation_id ? String(hookInput.conversation_id) : null;
|
|
1088
|
+
|
|
1089
|
+
// Host-specific loop guards.
|
|
1090
|
+
if (claudeID) {
|
|
1091
|
+
// Never fight the host's loop protections: a stop-hook continuation
|
|
1092
|
+
// already in flight ends the turn, and a session paused on
|
|
1093
|
+
// background work gets its wake when that work's turn ends -
|
|
1094
|
+
// injecting now would interject mid-flight (the mid-turn injection
|
|
1095
|
+
// incident class).
|
|
1096
|
+
if (hookInput?.stop_hook_active === true) {
|
|
1097
|
+
console.log("{}");
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
if (Array.isArray(hookInput?.background_tasks) && hookInput.background_tasks.length > 0) {
|
|
1101
|
+
console.log("{}");
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
} else if (cursorID) {
|
|
1105
|
+
// Cursor: never auto-continue an aborted or errored turn (the
|
|
1106
|
+
// user's stop key wins).
|
|
1107
|
+
if (hookInput?.status !== "completed") {
|
|
1108
|
+
console.log("{}");
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
} else {
|
|
1112
|
+
console.log("{}");
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Lookup, not ensure-register: registration exists by the time stop
|
|
1117
|
+
// fires (the prompt hooks anchored the conversation), and minting
|
|
1118
|
+
// rows from contexts we do not understand is how the throwaway
|
|
1119
|
+
// sessionStart identities happened.
|
|
1120
|
+
const anchor = mcpSessionID(claudeID ?? cursorID!);
|
|
1121
|
+
if (!db.findChatSession(anchor)) {
|
|
1122
|
+
console.log("{}");
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
const pending = db
|
|
1126
|
+
.pendingChatNotifications([anchor], isoMinutesAgo(CHAT_RENUDGE_MINUTES))
|
|
1127
|
+
.filter((m) => m.to_session === anchor);
|
|
1128
|
+
if (pending.length === 0) {
|
|
1129
|
+
console.log("{}");
|
|
1130
|
+
break;
|
|
1131
|
+
}
|
|
1132
|
+
const senders = [
|
|
1133
|
+
...new Set(pending.map((m) => renderChatParticipant(m.from_name, m.from_session))),
|
|
1134
|
+
];
|
|
1135
|
+
// Stamp before emitting: the re-nudge window (not the model's
|
|
1136
|
+
// obedience) bounds repeats when the mail goes ignored.
|
|
1137
|
+
db.markChatDelivered(pending.map((m) => m.id));
|
|
1138
|
+
const nudge = chatNotificationNudge(senders, pending.length);
|
|
1139
|
+
console.log(
|
|
1140
|
+
JSON.stringify(
|
|
1141
|
+
claudeID
|
|
1142
|
+
? { hookSpecificOutput: { hookEventName: "Stop", additionalContext: nudge } }
|
|
1143
|
+
: { followup_message: nudge },
|
|
1144
|
+
),
|
|
1145
|
+
);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
console.error(`[thatch chat-notify] failed: ${err}`);
|
|
1148
|
+
console.log("{}");
|
|
1149
|
+
}
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
976
1153
|
case "flush-predictions": {
|
|
977
1154
|
// UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor) hook.
|
|
978
1155
|
// Standalone prediction fire: queries the sideband for scored
|
package/docs/dev/README.md
CHANGED
|
@@ -90,6 +90,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
90
90
|
| `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
|
|
91
91
|
| `seed-behaviors.ts` | Default behavior seeding on first run. Populates starter self-discipline rules. |
|
|
92
92
|
| `vector-math.ts` | `blobToVector` and `cosineSimilarity` helpers used across the codebase. |
|
|
93
|
+
| `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
94
|
|
|
94
95
|
## Plugin hooks
|
|
95
96
|
|
|
@@ -338,7 +339,7 @@ behavior cycle (agent-driven, self-graded)
|
|
|
338
339
|
`behaviors(id PK, store, statement, rationale, embedding BLOB, model, confidence REAL, confirm_count REAL, disconfirm_count REAL, created_at, updated_at)`,
|
|
339
340
|
`behavior_edges(matcher_id, behavior_id, weight REAL, PK(matcher_id, behavior_id), FK CASCADE)`,
|
|
340
341
|
`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)`,
|
|
342
|
+
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto, worktree)`,
|
|
342
343
|
`chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at, via_broadcast)`,
|
|
343
344
|
`chat_name_counters(base PK, next)` - per-base counters for assigned chat names; only ever increments.
|
|
344
345
|
`chat_leave_tombstones(session_id PK, left_at)` - explicit leaves; suppresses auto-registration until chat_register rejoins.
|
|
@@ -382,6 +383,12 @@ alone to check docs without the test suite. Run `mise run typecheck` for
|
|
|
382
383
|
`tsc` alone (uses `tsconfig.check.json`, which includes the test files that
|
|
383
384
|
the build's `tsconfig.json` excludes).
|
|
384
385
|
|
|
386
|
+
To watch what a live harness's plugin actually sees at init, launch opencode
|
|
387
|
+
with `THATCH_DEBUG=chat` (or `=1` for every tag) and tail
|
|
388
|
+
`~/.config/thatch/debug.log`. The env var must be set on the `opencode`
|
|
389
|
+
process itself: the plugin runs in a worker thread that inherits the
|
|
390
|
+
process environment but not its argv.
|
|
391
|
+
|
|
385
392
|
## Release
|
|
386
393
|
|
|
387
394
|
```bash
|
package/docs/dev/features/cli.md
CHANGED
|
@@ -49,24 +49,22 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
|
|
|
49
49
|
- `chat list` renders the roster as aligned columns under a header row
|
|
50
50
|
(NAME/AGE/STATUS/PROJECT/TOPIC), colorized only when stdout is a TTY
|
|
51
51
|
(`formatChatRoster()` in `bin/thatch`; pipes and QA runners get plain
|
|
52
|
-
text).
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
and the
|
|
61
|
-
`
|
|
62
|
-
|
|
63
|
-
`
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
events against.
|
|
67
|
-
- The backlog renders only the last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages;
|
|
52
|
+
text). Rows are split into Active and Stale sections by
|
|
53
|
+
`splitChatRoster()`; the status column is `chatLiveness()`'s verdict
|
|
54
|
+
(`fresh`/`stale` for opencode rows, `active`/`idle` for MCP rows).
|
|
55
|
+
- `chat tail` follows the message stream as JSONL: one `ChatTailEvent`
|
|
56
|
+
per line via `formatChatTailJsonl()` (a bare `JSON.stringify`; no ANSI,
|
|
57
|
+
no markdown rendering, no local-time conversion - the tail is a log for
|
|
58
|
+
`jq` and `grep`). `sent` and `read` are separate events linked by the
|
|
59
|
+
message `id`; broadcast fan-out is one `sent` per recipient with
|
|
60
|
+
`broadcast: true` and the real `to`. Event shaping is `chatTailDiff()`
|
|
61
|
+
in `src/chat.ts`, unit-tested there. `--once` prints one snapshot and
|
|
62
|
+
exits; the snapshot includes a `read` event for every shown message
|
|
63
|
+
whose `read_at` is set, sorted by time, so it is the same log a follow
|
|
64
|
+
would have accumulated.
|
|
65
|
+
- The backlog prints only the last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages;
|
|
68
66
|
`chatTailBacklog()` in `src/chat.ts` filters the feed, seeds the diff
|
|
69
|
-
state from every feed row (so the limit hides
|
|
67
|
+
state from every feed row (so the limit hides lines, not history, and a
|
|
70
68
|
mid-follow rename or unregister cannot resurface old rows as sent
|
|
71
69
|
events), and reports the elided count for
|
|
72
70
|
the CLI's stderr note. `filterChatTailRows()` ANDs body regexes
|