@jeffober/thatch 0.1.40 → 0.1.42

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
@@ -10,6 +10,8 @@ import { hygieneReport } from "../src/hygiene";
10
10
  import { claudeSessionStartReminder } from "../src/prompts";
11
11
  import { setupClaudeCode, setupCursor } 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, mcpSessionID, type ChatTailFilter } from "../src/chat";
14
+ import { chatEnabled, loadConfig } from "../src/config";
13
15
 
14
16
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
15
17
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
@@ -33,7 +35,7 @@ function usage(): never {
33
35
  thatch buffer-tool Append a single postToolUse interaction to the queue (Cursor hook only)
34
36
  thatch flush-tools [--json] Peek the queue + print an extraction nudge (hook only)
35
37
  thatch flush-predictions [--json] Query predictions for the prompt (hook only)
36
- thatch setup --claude [--cursor] [--global]
38
+ thatch setup --claude [--cursor] [--local|--global]
37
39
  Install config + instructions + hooks + skills
38
40
  thatch session list -s <id> [--after T] [--before T]
39
41
  JSONL timeline of a session's parts (one line per part)
@@ -44,6 +46,18 @@ function usage(): never {
44
46
  Full conversation as OpenAI chat-completions JSONL
45
47
  thatch session search <query> [--session <id>] [--regex] [--limit N]
46
48
  JSONL of matching parts across sessions (decoded content)
49
+ thatch chat list Registered chat sessions: name, age, project, topic
50
+ thatch chat tail [--once] [--limit N|all] [--match RE]
51
+ [--from NAME] [--to NAME] [--since DT] [--until DT]
52
+ Follow cross-session chat: sent and read
53
+ events. The backlog shows the last
54
+ ${CHAT_TAIL_DEFAULT_LIMIT} messages
55
+ (--limit N or --limit all to override).
56
+ --match filters message bodies
57
+ (repeatable, ANDed); --from/--to filter
58
+ participant names; --since/--until bound
59
+ the window ("YYYY-MM-DD [HH:MM]" local
60
+ time). Filters apply to follow mode too.
47
61
 
48
62
  Stores default to the repo detected from git remote. Use "global" for the
49
63
  global store. Use "all" with search to search project + global together.`);
@@ -58,13 +72,75 @@ async function defaultStore(): Promise<string> {
58
72
  }
59
73
  }
60
74
 
75
+ /**
76
+ * Builds the chat-mail hook line for the given project. With a hook
77
+ * session ID (Claude Code/Cursor pass one in the hook's stdin JSON), it
78
+ * first ensures the caller is registered - the host's assigned identity -
79
+ * and prints that identity plus the caller's own unread count, so the
80
+ * model learns who it is on every prompt without a tool call. Without a
81
+ * session ID it keeps the old shape: a line only when pending mail exists
82
+ * for registered sessions in the project, naming the addressees.
83
+ */
84
+ async function chatHookLine(hookSessionID?: string): Promise<string> {
85
+ // Disabled chat prints nothing: the hook line must not advertise a
86
+ // feature the user turned off (the tools refuse, the poller never
87
+ // starts, the prompt section omits chat).
88
+ if (!chatEnabled(loadConfig(dbPath).config)) return "";
89
+ let repo: string | null = null;
90
+ try {
91
+ repo = await detectRepo();
92
+ } catch {
93
+ return "";
94
+ }
95
+
96
+ if (hookSessionID) {
97
+ // Ensure-register: idempotent, so every prompt re-touches liveness and
98
+ // the first prompt of a conversation mints the assigned name. This is
99
+ // the MCP identity anchor - the session ID is stable per conversation,
100
+ // so re-registering finds the same row and name.
101
+ const sessionID = mcpSessionID(hookSessionID);
102
+ // The leave tombstone blocks this ensure: chat_unregister must outlive
103
+ // the turn that issued it, on MCP hosts as on opencode. The line is
104
+ // the standing signal - it repeats every prompt until the model
105
+ // rejoins, and it says plainly that rejoining mints a fresh identity,
106
+ // because the old one is closed for good (assigned names are never
107
+ // reused, so a tombstoned identity cannot be resurrected by a later
108
+ // register).
109
+ if (db.hasChatLeaveTombstone(sessionID) && !db.chatLeaveSuperseded(sessionID, repo)) {
110
+ return `[thatch] chat: this conversation left the chat directory (chat_unregister) and will not receive mail. If the user asks to rejoin, call thatch_chat_register with no arguments - that mints a fresh identity; the old one stays closed.`;
111
+ }
112
+ db.registerChatSession(sessionID, repo, null, "mcp");
113
+ const me = db.findChatSession(sessionID);
114
+ if (!me) return "";
115
+ const mine = db.unreadChatCount(sessionID);
116
+ const pendingOthers = db
117
+ .chatPendingByProject(repo)
118
+ .filter((p) => p.name !== me.name);
119
+ const others = pendingOthers.length > 0
120
+ ? ` Others in this project with unread mail: ${pendingOthers.map((p) => `${p.name} (${p.pending})`).join(", ")}.`
121
+ : "";
122
+ const mailbox = mine > 0 ? `${mine} unread message(s) for you` : "no unread mail for you";
123
+ return `[thatch] chat: you are ${me.name} (${mailbox}).${others}`;
124
+ }
125
+
126
+ const pending = db.chatPendingByProject(repo);
127
+ if (pending.length === 0) return "";
128
+ const parts = pending.map((p) => `${p.name} (${p.pending})`);
129
+ return `[thatch] chat: unread message(s) for ${parts.join(", ")} - the addressee should call chat_read`;
130
+ }
131
+
61
132
  /**
62
133
  * Emits the nudge output, prepending a version warning if present. The
63
134
  * warning goes first so the LLM sees it before the nudge text. Used by
64
135
  * flush-tools to surface version skew and update-available notices on
65
- * every UserPromptSubmit, regardless of which tier fired.
136
+ * every UserPromptSubmit, regardless of which tier fired. The chat hook
137
+ * line, when present, is appended last (the identity/mail check is the
138
+ * last thing the model reads before the nudge body).
66
139
  */
67
- function emit(output: string, versionWarning: string | null, isJson: boolean): void {
140
+ function emit(output: string, versionWarning: string | null, isJson: boolean, chatLine = ""): void {
141
+ if (chatLine) {
142
+ output = `${output}\n\n${chatLine}`;
143
+ }
68
144
  if (versionWarning) {
69
145
  output = `[thatch] ${versionWarning}\n\n${output}`;
70
146
  }
@@ -93,17 +169,42 @@ function parseTimeBound(value: string, flag: string): number {
93
169
  return parsed;
94
170
  }
95
171
 
96
- /** Reads a flag's value (-flag value or --flag=value forms). */
172
+ /** Reads a flag's value (-flag value or --flag=value forms). A flag given
173
+ * with no value is a typo, not a default - it dies loudly so `--until`
174
+ * at the end of a command line cannot silently disable the bound. */
97
175
  function flagValue(flags: string[], ...names: string[]): string | undefined {
98
176
  for (let i = 0; i < flags.length; i++) {
99
177
  for (const name of names) {
100
- if (flags[i] === name) return flags[i + 1];
178
+ if (flags[i] === name) {
179
+ if (flags[i + 1] === undefined) {
180
+ console.error(`error: ${name} expects a value`);
181
+ process.exit(1);
182
+ }
183
+ return flags[i + 1];
184
+ }
101
185
  if (flags[i]?.startsWith(`${name}=`)) return flags[i]!.slice(name.length + 1);
102
186
  }
103
187
  }
104
188
  return undefined;
105
189
  }
106
190
 
191
+ /** Collects every occurrence of a repeatable flag, dying on a dangling one. */
192
+ function flagValues(flags: string[], ...names: string[]): string[] {
193
+ const out: string[] = [];
194
+ for (let i = 0; i < flags.length; i++) {
195
+ for (const name of names) {
196
+ if (flags[i] === name) {
197
+ if (flags[i + 1] === undefined) {
198
+ console.error(`error: ${name} expects a value`);
199
+ process.exit(1);
200
+ }
201
+ out.push(flags[i + 1]);
202
+ } else if (flags[i]?.startsWith(`${name}=`)) out.push(flags[i]!.slice(name.length + 1));
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
107
208
  function openSessionDb(): SessionDB {
108
209
  const path = resolveOpencodeDbPath();
109
210
  if (!path) {
@@ -219,6 +320,175 @@ async function sessionCommand(rest: string[]): Promise<void> {
219
320
  }
220
321
  }
221
322
 
323
+ // ---------------------------------------------------------------------------
324
+ // chat subcommand group - read-only window on the cross-session chat
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /** "2m ago", "3h ago", "5d ago" - human-readable age for the chat roster. */
328
+ function humanAge(iso: string, now = Date.now()): string {
329
+ const seconds = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
330
+ if (Number.isNaN(seconds)) return "unknown age";
331
+ if (seconds < 60) return `${seconds}s ago`;
332
+ const minutes = Math.floor(seconds / 60);
333
+ if (minutes < 60) return `${minutes}m ago`;
334
+ if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ago`;
335
+ return `${Math.floor(minutes / (24 * 60))}d ago`;
336
+ }
337
+
338
+ async function chatCommand(rest: string[]): Promise<void> {
339
+ const [sub, ...restArgs] = rest;
340
+
341
+ switch (sub) {
342
+ case "list": {
343
+ const sessions = db.listChatSessions();
344
+ if (sessions.length === 0) {
345
+ console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
346
+ break;
347
+ }
348
+ for (const s of sessions) {
349
+ // Same staleness convention as the chat_list tool: an age past the
350
+ // threshold means the session's host process is probably gone.
351
+ const liveness = isStale(s, CHAT_STALE_MINUTES) ? "STALE" : "fresh";
352
+ const parts = [
353
+ s.name,
354
+ `(${humanAge(s.last_seen)})`,
355
+ liveness,
356
+ `project:${s.project ?? "unknown"}`,
357
+ ];
358
+ if (s.topic) parts.push(s.topic);
359
+ console.log(parts.join(" "));
360
+ }
361
+ break;
362
+ }
363
+
364
+ case "tail": {
365
+ const onceOnly = restArgs.includes("--once");
366
+ // Parse and validate everything up front: a bad regex or date must
367
+ // fail before any cards print, not mid-follow.
368
+ const matches = flagValues(restArgs, "--match").map((pattern) => {
369
+ try {
370
+ return new RegExp(pattern);
371
+ } catch {
372
+ console.error(`error: --match is not a valid regular expression: "${pattern}"`);
373
+ process.exit(1);
374
+ }
375
+ });
376
+ const limitArg = flagValue(restArgs, "--limit");
377
+ let limit: number | null = CHAT_TAIL_DEFAULT_LIMIT;
378
+ if (limitArg !== undefined) {
379
+ if (limitArg === "all") limit = null;
380
+ else if (!/^\d+$/.test(limitArg)) {
381
+ console.error(`error: --limit expects a positive integer or "all", got "${limitArg}"`);
382
+ process.exit(1);
383
+ } else limit = Number(limitArg);
384
+ if (limit !== null && limit < 1) {
385
+ console.error(`error: --limit expects a positive integer or "all", got "${limitArg}"`);
386
+ process.exit(1);
387
+ }
388
+ }
389
+ let sinceMs: number | null = null;
390
+ let untilMs: number | null = null;
391
+ try {
392
+ const since = flagValue(restArgs, "--since");
393
+ const until = flagValue(restArgs, "--until");
394
+ if (since !== undefined) sinceMs = parseChatTimeBound(since, "--since");
395
+ if (until !== undefined) untilMs = parseChatTimeBound(until, "--until");
396
+ } catch (err) {
397
+ console.error(`error: ${(err as Error).message}`);
398
+ process.exit(1);
399
+ }
400
+ if (sinceMs !== null && untilMs !== null && sinceMs >= untilMs) {
401
+ console.error("error: --since is not before --until; the window is empty");
402
+ process.exit(1);
403
+ }
404
+ const fromName = flagValue(restArgs, "--from");
405
+ const toName = flagValue(restArgs, "--to");
406
+ const filter: ChatTailFilter = {
407
+ matches,
408
+ fromSubstr: fromName !== undefined ? [fromName] : [],
409
+ toSubstr: toName !== undefined ? [toName] : [],
410
+ sinceMs,
411
+ untilMs,
412
+ };
413
+
414
+ // Diff state: one entry per seen message id holding its read_at, so
415
+ // each poll emits only new messages and newly-read rows. The diff
416
+ // and backlog logic lives in src/chat.ts (chatTailDiff,
417
+ // chatTailBacklog), unit-tested there. The backlog seeds the state
418
+ // from every feed row, so the limit hides cards, not history.
419
+ let state: Map<number, string | null>;
420
+ let firstFollowEvent = true;
421
+
422
+ // Follow mode holds the handle open until Ctrl-C. The finally below
423
+ // closes it on every exit path except the SIGINT handler's, which
424
+ // is why the handler closes it itself: process.exit does not unwind
425
+ // finally blocks (closing twice is harmless).
426
+ try {
427
+ // First pass: render the backlog as cards, then follow unless
428
+ // --once or the --until window has already closed (a closed
429
+ // window has no future events to follow). The backlog fetch gets
430
+ // the same friendly failure treatment as follow polls: a locked
431
+ // or missing database should say so, not die with a stack trace.
432
+ let backlog;
433
+ try {
434
+ backlog = chatTailBacklog(db.chatMessageFeed(), filter, limit);
435
+ } catch (err) {
436
+ console.error(`[thatch chat tail] feed query failed: ${err}`);
437
+ console.error("The database may be locked or missing; exiting.");
438
+ return;
439
+ }
440
+ state = backlog.state;
441
+ if (backlog.elided > 0) {
442
+ // The elision note goes to stderr so stdout stays a clean card
443
+ // stream for pipes.
444
+ console.error(
445
+ `[thatch chat tail] showing last ${backlog.events.length} of ${backlog.elided + backlog.events.length} messages (--limit N, --limit all)`,
446
+ );
447
+ }
448
+ console.log(backlog.events.map(formatChatTailCard).join(`\n\n${CHAT_TAIL_SEPARATOR}\n\n`));
449
+ if (onceOnly || (filter.untilMs !== null && filter.untilMs <= Date.now())) break;
450
+
451
+ process.on("SIGINT", () => {
452
+ db.close();
453
+ process.exit(0);
454
+ });
455
+ for (;;) {
456
+ await Bun.sleep(2000);
457
+ try {
458
+ // Follow polls re-filter the live feed: matching messages
459
+ // surface as they arrive, non-matching ones stay invisible.
460
+ for (const event of chatTailDiff(state, filterChatTailRows(db.chatMessageFeed(), filter)).events) {
461
+ // Separate follow-mode cards from whatever came before.
462
+ if (firstFollowEvent && backlog.events.length > 0) console.log(`\n${CHAT_TAIL_SEPARATOR}\n`);
463
+ firstFollowEvent = false;
464
+ console.log(formatChatTailCard(event));
465
+ }
466
+ } catch (err) {
467
+ // The surface's whole job is watching a shared, mutable file;
468
+ // a feed failure should say so, not die with a stack trace.
469
+ console.error(`[thatch chat tail] feed query failed: ${err}`);
470
+ console.error("The database may be locked or missing; exiting.");
471
+ return;
472
+ }
473
+ // The window check runs after the poll, so a message created
474
+ // inside the window but landed during the final sleep is still
475
+ // displayed; anything inserted after this poll has a created_at
476
+ // past the bound and is filtered by the window anyway.
477
+ if (filter.untilMs !== null && Date.now() >= filter.untilMs) break;
478
+ }
479
+ } finally {
480
+ db.close();
481
+ // The script tail also closes; closing twice is harmless and the
482
+ // early-return paths need this one.
483
+ }
484
+ break;
485
+ }
486
+
487
+ default:
488
+ usage();
489
+ }
490
+ }
491
+
222
492
  switch (command) {
223
493
  case "--version":
224
494
  case "-v":
@@ -327,7 +597,11 @@ switch (command) {
327
597
  } catch (err) {
328
598
  console.error(`[thatch] hygiene report failed: ${err}`);
329
599
  }
330
- const reminder = claudeSessionStartReminder(repo, hygiene);
600
+ // Chat mail check. Without a hook session ID (SessionStart does not
601
+ // read stdin) the line keeps the old shape: a line only when pending
602
+ // mail exists for registered sessions in this project.
603
+ const chatLine = await chatHookLine();
604
+ const reminder = claudeSessionStartReminder(repo, hygiene) + (chatLine ? `\n\n${chatLine}` : "");
331
605
  if (isJson) {
332
606
  console.log(JSON.stringify({ additional_context: reminder }));
333
607
  } else {
@@ -383,12 +657,19 @@ switch (command) {
383
657
 
384
658
  case "setup": {
385
659
  const isGlobal = args.includes("--global");
660
+ // --local is the default scope; accepted explicitly so the flag the
661
+ // user types always matches what the command does.
662
+ const isLocal = args.includes("--local");
386
663
  const isClaude = args.includes("--claude");
387
664
  const isCursor = args.includes("--cursor");
388
665
 
666
+ if (isGlobal && isLocal) {
667
+ console.error("Choose one scope: --local or --global.");
668
+ process.exit(1);
669
+ }
389
670
  if (!isClaude && !isCursor) {
390
671
  console.error("Specify at least one host: --claude and/or --cursor.");
391
- console.error("Usage: thatch setup --claude [--cursor] [--global]");
672
+ console.error("Usage: thatch setup --claude [--cursor] [--local|--global]");
392
673
  process.exit(1);
393
674
  }
394
675
 
@@ -400,6 +681,22 @@ switch (command) {
400
681
  const thatchBin = thatchOnPath ?? resolve(process.argv[1] ?? process.execPath);
401
682
  const dir = projectDir();
402
683
 
684
+ // Shared reporting: where skills went and what the pass did, so an
685
+ // idempotent re-run still tells the user something true.
686
+ const printSkills = (result: { skills: { dir: string; added: unknown[]; updated: unknown[]; removed: string[]; unchanged: string[] }; otherScopeSkills: { dir: string; count: number } | null; global: boolean }) => {
687
+ const { added, updated, removed, unchanged } = result.skills;
688
+ console.log(` Skills: ${result.skills.dir}`);
689
+ console.log(` ${added.length} added, ${updated.length} updated, ${removed.length} removed, ${unchanged.length} unchanged`);
690
+ if (result.otherScopeSkills) {
691
+ const { dir: otherDir, count } = result.otherScopeSkills;
692
+ if (result.global) {
693
+ console.log(` Note: ${count} thatch skills exist in ${otherDir} (project scope); --global does not update them.`);
694
+ } else {
695
+ console.log(` Note: ${count} thatch skills remain in ${otherDir} (user scope; not updated by this run). Run setup with --global to refresh them, or remove them if this repo now owns its skills.`);
696
+ }
697
+ }
698
+ };
699
+
403
700
  if (isClaude) {
404
701
  const result = setupClaudeCode(thatchBin, isGlobal, dir);
405
702
 
@@ -410,10 +707,7 @@ switch (command) {
410
707
  }
411
708
  console.log(` Instructions: ${result.claudeMd}`);
412
709
  console.log(` Hooks: ${result.settings}`);
413
- const skillsDir = result.skills[0]?.path
414
- ? result.skills[0].path.split("/").slice(0, -1).join("/")
415
- : "(no skills installed)";
416
- console.log(` Skills: ${skillsDir} (${result.skills.length} files)`);
710
+ printSkills(result);
417
711
  console.log();
418
712
  if (result.mcpAddCommand) {
419
713
  console.log("To register the MCP server globally, run:");
@@ -432,10 +726,7 @@ switch (command) {
432
726
  console.log(` MCP config: ${result.mcpConfig}`);
433
727
  console.log(` Instructions: ${result.agentsMd}`);
434
728
  console.log(` Hooks: ${result.hooks}`);
435
- const skillsDir = result.skills[0]?.path
436
- ? result.skills[0].path.split("/").slice(0, -1).join("/")
437
- : "(no skills installed)";
438
- console.log(` Skills: ${skillsDir} (${result.skills.length} files)`);
729
+ printSkills(result);
439
730
  console.log();
440
731
  console.log("Restart Cursor for the changes to take effect.");
441
732
  }
@@ -447,6 +738,11 @@ switch (command) {
447
738
  break;
448
739
  }
449
740
 
741
+ case "chat": {
742
+ await chatCommand(args);
743
+ break;
744
+ }
745
+
450
746
  case "buffer-batch": {
451
747
  // PostToolBatch hook: append the batch's tool_calls to the session's
452
748
  // JSONL queue. Silent on success - no stdout - so the next model call
@@ -545,6 +841,10 @@ switch (command) {
545
841
  // flush-tools fires on every prompt, including conversational turns
546
842
  // with no tool calls.
547
843
  const versionWarning = detectSkew(dbPath) ?? readNpmCacheForUpdate(dbPath);
844
+ // Chat check: with a hook session ID this always prints the caller's
845
+ // assigned identity (and its unread count), so the model knows who it
846
+ // is before any chat tool call; with no mail the line says so.
847
+ const chatLine = await chatHookLine(sessionID);
548
848
 
549
849
  // Tier 1: extraction nudge. Peek (don't drain) - the queue persists
550
850
  // until the agent calls memory_remember, which triggers consumeQueue.
@@ -556,7 +856,7 @@ switch (command) {
556
856
  const missed = sessionID ? getMissedCount(sessionID) : 0;
557
857
  if (sessionID) incrementMissedCount(sessionID);
558
858
  const output = extractionNudge(interactions.length, missed, "mcp__thatch__memory_remember", sessionID ?? "");
559
- emit(output, versionWarning, isJson);
859
+ emit(output, versionWarning, isJson, chatLine);
560
860
  break;
561
861
  }
562
862
 
@@ -590,14 +890,14 @@ switch (command) {
590
890
 
591
891
  if (parts.length > 0) {
592
892
  const output = parts.join("\n\n");
593
- emit(output, versionWarning, isJson);
893
+ emit(output, versionWarning, isJson, chatLine);
594
894
  break;
595
895
  }
596
896
  }
597
897
 
598
898
  // Tier 3: static write nudge.
599
899
  const output = claudeWriteNudge();
600
- emit(output, versionWarning, isJson);
900
+ emit(output, versionWarning, isJson, chatLine);
601
901
  } catch (err) {
602
902
  console.error(`[thatch flush-tools] failed: ${err}`);
603
903
  try {
@@ -40,8 +40,11 @@ OpenCode plugin path
40
40
  ├── index.ts → plugin entry: wires DB/model/extraction, registers tools + hooks
41
41
  ├── tools.ts → thin opencode tool() wrappers over tool-defs
42
42
  ├── extraction.ts → in-memory ring buffer + shared payload builders
43
- └── watchers.ts → in-memory watcher registry + poller (opencode-only,
44
- event-driven PR notifications delivered as prompts)
43
+ ├── watchers.ts → in-memory watcher registry + poller (opencode-only,
44
+ │ event-driven PR notifications delivered as prompts)
45
+ └── chat.ts → cross-session chat: shared SQLite directory + inbox
46
+ (ChatStore) and the per-process wake-up poller
47
+ (ChatPoller, opencode-only)
45
48
 
46
49
  MCP server path
47
50
  ├── mcp.ts → stdio JSON-RPC server: z.toJSONSchema() for tools/list,
@@ -54,7 +57,8 @@ MCP server path
54
57
  CLAUDE.md / AGENTS.md, settings/hooks JSON, installs skills
55
58
 
56
59
  bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygiene|
57
- prime|buffer-batch|buffer-tool|flush-tools|flush-predictions|setup
60
+ prime|buffer-batch|buffer-tool|flush-tools|flush-predictions|setup|
61
+ session (archaeology)|chat (list|tail)
58
62
  ```
59
63
 
60
64
  ## Module responsibilities
@@ -75,9 +79,11 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
75
79
  | `config.ts` | User config file (`~/.config/thatch/config.json`, beside the DB): Zod section schemas (strict — unknown keys fail validation), load with missing/invalid fallback to defaults, field-level merge, atomic save (temp + rename). Backs the `config_get`/`config_set` tools. |
76
80
  | `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool. |
77
81
  | `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
78
- | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches GitHub PRs and branches via `gh api` (injected `GhRunner`), diffs against last-seen state, queues pointer-only events per session, and delivers them through a plugin-supplied callback when the session is idle. Process-lifetime state by design - no SQLite. |
82
+ | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Three sources: GitHub PRs and branches via `gh api` (injected `GhRunner`), and local shell commands via `bash -c` (injected `CommandRunner`) run as condition variables - only the exit code is read, never the command's output. Diffs against last-seen state, queues events (pointer data plus machine status like check conclusions and exit codes, never external content) per session, and delivers them through a plugin-supplied callback when the session is idle. The `pollSeconds` getter surfaces the poll cadence to tool output and notifications. Process-lifetime state by design - no SQLite. |
83
+ | `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
+ | `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. |
79
85
  | `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. |
80
- | `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`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
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. |
81
87
  | `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]`. |
82
88
  | `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. |
83
89
  | `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
@@ -94,10 +100,10 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
94
100
  | `experimental.chat.system.transform` | Appends the thatch system prompt (store names, usage rules). |
95
101
  | `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
96
102
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
97
- | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
98
- | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
99
- | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher delivery gate; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, and cancels the session's watchers. `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
100
- | `dispose` | Stops the version checker and the watcher poller, closes the DB. |
103
+ | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
104
+ | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
105
+ | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher and chat delivery gates; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events and pending chat mail (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, cancels the session's watchers, and unregisters it from the chat directory (the crash path — a dead process fires no event — is covered by heartbeat staleness). `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
106
+ | `dispose` | Stops the version checker, the watcher poller, and the chat poller, closes the DB. |
101
107
 
102
108
  Hook failures are logged with a `[thatch]` prefix — never swallowed silently.
103
109
  Two of these hooks were dead for weeks because failures were invisible.
@@ -119,10 +125,12 @@ Two of these hooks were dead for weeks because failures were invisible.
119
125
  5. **Store creation is implicit.** First `remember` to a new store creates it.
120
126
  6. **Default recall scope is repo + global.** The tool layer hardcodes this.
121
127
  7. **Skills are plugin-owned files.** Installed to
122
- `$XDG_CONFIG_HOME/opencode/skills` (opencode), `~/.claude/skills/`
123
- (Claude Code), or `~/.cursor/skills/` (Cursor) — never into the
124
- worktree; drifted content is overwritten on plugin init or re-running
125
- `thatch setup`. Skills are split into `SHARED_SKILLS` (work on all
128
+ `$XDG_CONFIG_HOME/opencode/skills` (opencode); for MCP hosts,
129
+ scope-dependent — the repo's `.claude/skills/` or `.cursor/skills/`
130
+ for project-local `thatch setup`, `~/.claude/skills/` or
131
+ `~/.cursor/skills/` for `--global`. Drifted content is overwritten
132
+ on plugin init or re-running `thatch setup`. Skills are split into
133
+ `SHARED_SKILLS` (work on all
126
134
  three hosts) and `OPENCODE_ONLY_SKILLS` (require sub-agent support,
127
135
  not installed for Claude Code or Cursor). The opencode plugin
128
136
  installs both arrays; `thatch setup --claude` and `--cursor` install
@@ -328,12 +336,18 @@ behavior cycle (agent-driven, self-graded)
328
336
  `behavior_matchers(id PK, store, description, embedding BLOB, model, created_at, updated_at)`,
329
337
  `behaviors(id PK, store, statement, rationale, embedding BLOB, model, confidence REAL, confirm_count REAL, disconfirm_count REAL, created_at, updated_at)`,
330
338
  `behavior_edges(matcher_id, behavior_id, weight REAL, PK(matcher_id, behavior_id), FK CASCADE)`,
331
- `behavior_provenance(id PK, behavior_id, signal, detail, created_at, FK CASCADE)`.
339
+ `behavior_provenance(id PK, behavior_id, signal, detail, created_at, FK CASCADE)`,
340
+ `chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto)`,
341
+ `chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at, via_broadcast)`,
342
+ `chat_name_counters(base PK, next)` - per-base counters for assigned chat names; only ever increments.
343
+ `chat_leave_tombstones(session_id PK, left_at)` - explicit leaves; suppresses auto-registration until chat_register rejoins.
332
344
  - `recall_count`, `last_recalled_at`, and `archived` are added to pre-existing
333
345
  databases by an idempotent column migration at init (`PRAGMA table_info` +
334
346
  `ALTER TABLE`). The `archived` column is `INTEGER NOT NULL DEFAULT 0` (0 =
335
347
  live, 1 = archived); search, dedup, and staleness queries all exclude
336
- archived entries by default.
348
+ archived entries by default. A second init migration rebuilds a
349
+ pre-NOCASE `chat_sessions` table to enforce case-insensitive name
350
+ uniqueness (see [features/database.md](features/database.md)).
337
351
  - Embeddings are raw Float32Array bytes. Serialization honors
338
352
  `byteOffset`/`byteLength` — transformers.js can return views into larger
339
353
  tensor buffers, and serializing the whole backing buffer corrupts vectors.
@@ -27,6 +27,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
27
27
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
28
28
  | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking) |
29
29
  | [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
30
+ | [cross-session-chat.md](cross-session-chat.md) | Cross-session chat (shared SQLite directory and inbox, per-process wake-up delivery) |
30
31
  | [notifications.md](notifications.md) | Out-of-band notification (banner + voice) and the user config file |
31
32
  | [setup.md](setup.md) | Setup system (markers, checkSetup, auto-refresh, binary resolution) |
32
33
  | [cli.md](cli.md) | CLI (subcommands, environment variables) |
@@ -1,11 +1,9 @@
1
1
  # CLI (bin/thatch)
2
2
 
3
- The thatch CLI is a Bun script at `bin/thatch`. It provides memory inspection, MCP server startup, hook commands for MCP hosts, and the setup installer.
3
+ The thatch CLI is a Bun script at `bin/thatch`. It provides memory inspection, MCP server startup, hook commands for MCP hosts, the setup installer, session archaeology over the opencode database, and a read-only window on the cross-session chat directory.
4
4
 
5
5
  ## Subcommands
6
6
 
7
- 15 subcommands:
8
-
9
7
  | Command | Args | Flags | Stdin | Purpose |
10
8
  |---------|------|-------|-------|---------|
11
9
  | `stores` | none | none | none | List all store names |
@@ -22,6 +20,9 @@ The thatch CLI is a Bun script at `bin/thatch`. It provides memory inspection, M
22
20
  | `flush-tools [--json]` | none | `--json` | JSON | Peek queue + extraction/recall/prediction/behavior/write nudge |
23
21
  | `flush-predictions [--json]` | none | `--json` | JSON | Standalone prediction-only nudge |
24
22
  | `setup --claude [--cursor] [--global]` | none | `--claude`, `--cursor`, `--global` | none | Install config + instructions + hooks + skills |
23
+ | `session list/get/transcript/search` | per subcommand | `-s/--session`, `--id`, `--after`, `--before`, `--regex`, `--limit` | none | Read-only archaeology on the opencode session database (JSONL output) |
24
+ | `chat list` | none | none | none | Registered chat sessions: name, human-readable age, project, topic |
25
+ | `chat tail` | none | `--once`, `--limit N\|all`, `--match RE` (repeatable), `--from NAME`, `--to NAME`, `--since DT`, `--until DT` | none | Follow cross-session chat: sent and read events. Backlog defaults to the last 20 messages; filters apply to follow mode too. |
25
26
  | (unknown) | none | none | none | Print usage, exit 1 |
26
27
 
27
28
  ## Global behavior
@@ -41,6 +42,32 @@ Runs the thatch-project-primer skill via an external CLI. Searches `PATH` in ord
41
42
 
42
43
  Inherits stdio, exits with the child's exit code. Errors and exits 1 if none found.
43
44
 
45
+ ## chat
46
+
47
+ Read-only window on the cross-session chat directory ([cross-session-chat.md](cross-session-chat.md)):
48
+
49
+ - `chat list` renders the roster: display name, human-readable age since the
50
+ last heartbeat, project, and topic when set.
51
+ - `chat tail` follows the message stream as styled cards: label chips,
52
+ colored names, dimmed timestamps, topic annotations after names, and a
53
+ drawn rule between cards. The card renderer is `formatChatTailCard()`
54
+ and the event shaping is `chatTailDiff()` in `src/chat.ts`, unit-tested
55
+ there; the single-line `formatChatTailEvent()` remains for tests and
56
+ compact contexts. `--once` prints one snapshot and exits, because a
57
+ snapshot has no previous state to diff read events against.
58
+ - The backlog renders only the last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages;
59
+ `chatTailBacklog()` in `src/chat.ts` filters the feed, seeds the diff
60
+ state from every feed row (so the limit hides cards, not history, and a
61
+ mid-follow rename or unregister cannot resurface old rows as sent
62
+ events), and reports the elided count for
63
+ the CLI's stderr note. `filterChatTailRows()` ANDs body regexes
64
+ (`--match`, repeatable), rendered-name substrings (`--from`/`--to`,
65
+ case-insensitive), and a half-open `--since`/`--until` window on
66
+ `created_at`; the same filter applies to follow polls, so non-matching
67
+ messages stay invisible (their read events too). A `--until` in the past
68
+ exits after the backlog; one in the future follows until the window
69
+ closes. `parseChatTimeBound()` accepts `YYYY-MM-DD [HH:MM]` local time.
70
+
44
71
  ## Environment variables
45
72
 
46
73
  | Variable | Purpose | Default |
@@ -59,7 +86,7 @@ Inherits stdio, exits with the child's exit code. Errors and exits 1 if none fou
59
86
 
60
87
  ## Source files
61
88
 
62
- - `bin/thatch` — the CLI (Bun script, 486 lines)
89
+ - `bin/thatch` — the CLI (Bun script)
63
90
  - `bin/release` — release helper (bash, separate from the main CLI)
64
91
 
65
92
  ## Interactions with other features
@@ -70,6 +97,8 @@ Inherits stdio, exits with the child's exit code. Errors and exits 1 if none fou
70
97
  - Extraction ([extraction.md](extraction.md)): `buffer-batch`, `buffer-tool`, `flush-tools` subcommands
71
98
  - Hygiene ([hygiene.md](hygiene.md)): `hygiene`, `reminder` subcommands
72
99
  - Multi-host ([multi-host.md](multi-host.md)): `mcp` subcommand starts the MCP server for Claude Code and Cursor
100
+ - Session archaeology: the `session` subcommand group (see the table above) reads the opencode session database via `src/session-db.ts`
101
+ - Cross-session chat ([cross-session-chat.md](cross-session-chat.md)): the `chat` subcommand group reads the shared chat directory and inbox
73
102
 
74
103
  ## Key invariants
75
104