@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/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, formatChatTailCard, CHAT_TAIL_SEPARATOR, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, isStale, CHAT_STALE_MINUTES, humanAge, mcpSessionID, selectChatBodyRenderer, cleanRenderedBody, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
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 Registered chat sessions: name, age, project, topic
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: sent and read
53
- events. The backlog shows the last
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 aligned columns under a header row.
332
- * Columns size to the widest cell so names, ages, and projects line up
333
- * across sessions. Color is applied only when stdout is an interactive
334
- * terminal (and NO_COLOR is unset), so piped output - grep, redirect,
335
- * QA runners - stays clean plain text. Palette matches the chat tail
336
- * cards: green values, dim age, red STALE, muted italic topic.
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 rows = sessions.map((s) => ({
342
- name: s.name,
343
- age: humanAge(s.last_seen),
344
- stale: isStale(s, CHAT_STALE_MINUTES),
345
- project: s.project ?? "unknown",
346
- topic: s.topic ?? "",
347
- }));
348
- const cells = (r: (typeof rows)[number]) => [r.name, r.age, r.stale ? "STALE" : "fresh", r.project, r.topic];
349
- // Widths come from the PLAIN cell text; color is applied after padding so
350
- // zero-width escape codes never skew the alignment.
351
- const widths = CHAT_ROSTER_HEADER.map((h, i) => Math.max(h.length, ...rows.map((r) => cells(r)[i].length)));
352
- const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
353
- const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
354
- const body = rows.map((r) => {
355
- const [name, age, status, project, topic] = align(cells(r));
356
- return [
357
- paint("32", name),
358
- paint("2", age),
359
- paint(r.stale ? "31" : "32", status),
360
- project,
361
- topic ? paint("3;90", topic) : "",
362
- ].join(" ").trimEnd();
363
- });
364
- return [header, ...body].join("\n");
365
- }
366
-
367
- /**
368
- * Markdown-to-ANSI renderer for chat tail bodies: prefers glow, then
369
- * `gum format`, and degrades to the plain body when neither is installed.
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
- const argv = selectChatBodyRenderer(Bun.which("glow"), Bun.which("gum"));
394
- if (!argv) {
395
- renderChatBody = (body) => body;
396
- return renderChatBody;
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
- // spawnSync stdin needs a Response (the `input` option is a no-op in
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 cards print, not mid-follow.
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 cards, not history.
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: render the backlog as cards, then follow unless
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 a clean card
500
- // stream for pipes.
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.events.length} of ${backlog.elided + backlog.events.length} messages (--limit N, --limit all)`,
585
+ `[thatch chat tail] showing last ${backlog.shown} of ${backlog.elided + backlog.shown} messages (--limit N, --limit all)`,
503
586
  );
504
587
  }
505
- console.log(backlog.events.map((e) => formatChatTailCard(e, markdownBodyRenderer())).join(`\n\n${CHAT_TAIL_SEPARATOR}\n\n`));
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
- // Separate follow-mode cards from whatever came before.
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. Without a hook session ID (SessionStart does not
658
- // read stdin) the line keeps the old shape: a line only when pending
659
- // mail exists for registered sessions in this project.
660
- const chatLine = await chatHookLine();
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
- console.log("To register the MCP server globally, run:");
771
- console.log(` ${result.mcpAddCommand}`);
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
@@ -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) |