@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 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 prompt
103
- when idle, other hosts see pending mail at their next prompt. Loop-safe:
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, 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, 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: name, age, project, topic
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: sent and read
53
- events. The backlog shows the last
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 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.
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 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;
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
- const argv = selectChatBodyRenderer(Bun.which("glow"), Bun.which("gum"));
394
- if (!argv) {
395
- renderChatBody = (body) => body;
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
- // 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;
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 cards print, not mid-follow.
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 cards, not history.
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: render the backlog as cards, then follow unless
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 a clean card
500
- // stream for pipes.
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.events.length} of ${backlog.elided + backlog.events.length} messages (--limit N, --limit all)`,
553
+ `[thatch chat tail] showing last ${backlog.shown} of ${backlog.elided + backlog.shown} messages (--limit N, --limit all)`,
503
554
  );
504
555
  }
505
- console.log(backlog.events.map((e) => formatChatTailCard(e, markdownBodyRenderer())).join(`\n\n${CHAT_TAIL_SEPARATOR}\n\n`));
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
- // 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()));
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. 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();
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
- console.log("To register the MCP server globally, run:");
771
- console.log(` ${result.mcpAddCommand}`);
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
@@ -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
@@ -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). Status reuses the `isStale()` staleness convention.
53
- - `chat tail` follows the message stream as styled cards: label chips,
54
- colored names, dimmed timestamps, topic annotations after names, and a
55
- drawn rule between cards. On a TTY, sent bodies render as markdown via
56
- an external renderer (`glow`, then `gum format`; plain fallback):
57
- selection (`selectChatBodyRenderer()`) and body cleanup
58
- (`cleanRenderedBody()`) live in `src/chat.ts`, unit-tested there;
59
- `bin/thatch` owns the TTY/NO_COLOR gate, the once-per-process cache,
60
- and the spawn. The card renderer is
61
- `formatChatTailCard()` (optional `renderBody` hook; the read-event
62
- clip is never rendered) and the event shaping is `chatTailDiff()` in
63
- `src/chat.ts`, unit-tested there; the single-line `formatChatTailEvent()`
64
- remains for tests and compact contexts. `--once` prints one snapshot
65
- and exits, because a snapshot has no previous state to diff read
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 cards, not history, and a
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