@jeffober/thatch 0.1.41 → 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/README.md CHANGED
@@ -89,16 +89,23 @@ Thatch gives your agent:
89
89
  `session_get`, opencode only) and as CLI subcommands (`thatch session
90
90
  list/get/transcript/search`, JSONL output designed for piping to `jq`).
91
91
  - **Watchers** -- event-driven notifications from external sources (opencode
92
- only). The agent registers a watch on a GitHub PR or branch (main); thatch polls it in the
93
- background and prompts the session when comments, commits, CI results, or
94
- status changes arrive. Notifications carry pointer data only.
92
+ only). The agent registers a watch on a GitHub PR, a branch (main), or a
93
+ local shell command; thatch polls it in the
94
+ background and prompts the session when comments, commits, CI results, status changes, or a
95
+ watched command's exit-0 condition arrive. Notifications carry pointer
96
+ data plus machine status (check conclusions, exit codes), never external
97
+ content.
95
98
  - **Cross-session chat** -- your agent sessions can message each other
96
- (opencode, Claude Code, and Cursor; same machine). A session registers
97
- under a unique name and topic, others see it in the directory, and
98
- messages land in its inbox -- opencode sessions get woken with a prompt
99
+ (opencode, Claude Code, and Cursor; same machine). opencode sessions
100
+ join the directory automatically under an assigned, never-reused name
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
99
103
  when idle, other hosts see pending mail at their next prompt. Loop-safe:
100
104
  messages are informational to the receiving agent, and wake prompts are
101
- rate-capped.
105
+ rate-capped. Delivered messages are framed as untrusted content so a
106
+ hostile message cannot impersonate your instructions. Disable the whole
107
+ feature with `chat.enabled: false` in the thatch config (or just the
108
+ auto-joining with `chat.autoRegister: false`).
102
109
  - **Notifications + user config** -- the agent can ping you out-of-band when a
103
110
  long-running outcome lands: a desktop banner, a spoken voice
104
111
  announcement, or both (macOS and Linux). Preferences live in a
@@ -120,10 +127,13 @@ host.
120
127
  Everything is local. The embedding model downloads once from Hugging Face Hub
121
128
  and is cached. No data leaves your machine.
122
129
 
123
- One exception: watchers (opencode only) call the GitHub API through the `gh`
124
- CLI to poll PRs you explicitly asked to watch, using your existing gh
125
- authentication. Thatch never sees or stores the token, and notifications
126
- carry pointer data only - GitHub comment text never enters your context
130
+ One exception: PR and branch watchers (opencode only) call the GitHub API
131
+ through the `gh` CLI to poll targets you explicitly asked to watch, using
132
+ your existing gh authentication; command watchers run local shell commands
133
+ in the project directory, reading only their exit codes. Thatch never sees
134
+ or stores the gh token, and notifications
135
+ carry pointer data plus machine status (check conclusions, exit codes),
136
+ never external content - GitHub comment text never enters your context
127
137
  unless the agent fetches it.
128
138
 
129
139
  ## Development
package/bin/release CHANGED
@@ -90,7 +90,10 @@ echo
90
90
 
91
91
  echo "▸ pushing..."
92
92
  git push
93
- git push --tags
93
+ # Push only this release's tag. `git push --tags` would also push every
94
+ # other local tag, and a history rewrite (filter-repo) recreates old tags
95
+ # on new SHAs - which the remote correctly refuses to move.
96
+ git push origin "v$next"
94
97
  echo " ✓ pushed"
95
98
  echo
96
99
 
package/bin/thatch CHANGED
@@ -10,7 +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, formatChatTailEvent, isStale, CHAT_STALE_MINUTES } from "../src/chat";
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";
14
15
 
15
16
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
16
17
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
@@ -46,8 +47,17 @@ function usage(): never {
46
47
  thatch session search <query> [--session <id>] [--regex] [--limit N]
47
48
  JSONL of matching parts across sessions (decoded content)
48
49
  thatch chat list Registered chat sessions: name, age, project, topic
49
- thatch chat tail [--once] Follow cross-session chat: sent and read events
50
- (--once prints current state and exits)
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.
51
61
 
52
62
  Stores default to the repo detected from git remote. Use "global" for the
53
63
  global store. Use "all" with search to search project + global together.`);
@@ -63,20 +73,56 @@ async function defaultStore(): Promise<string> {
63
73
  }
64
74
 
65
75
  /**
66
- * Builds the chat-mail hook line for the given project: one line when
67
- * unread mail is waiting for registered sessions, naming the addressee
68
- * sessions. Empty string when the project has no chat state or no pending
69
- * mail - a session that never registered never sees a chat line at all,
70
- * and the line is about the addressee, not about the reader, so there is
71
- * nothing for an unregistered model to hallucinate.
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.
72
83
  */
73
- async function chatHookLine(): Promise<string> {
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 "";
74
89
  let repo: string | null = null;
75
90
  try {
76
91
  repo = await detectRepo();
77
92
  } catch {
78
93
  return "";
79
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
+
80
126
  const pending = db.chatPendingByProject(repo);
81
127
  if (pending.length === 0) return "";
82
128
  const parts = pending.map((p) => `${p.name} (${p.pending})`);
@@ -88,8 +134,8 @@ async function chatHookLine(): Promise<string> {
88
134
  * warning goes first so the LLM sees it before the nudge text. Used by
89
135
  * flush-tools to surface version skew and update-available notices on
90
136
  * every UserPromptSubmit, regardless of which tier fired. The chat hook
91
- * line, when present, is appended last (the mail check is the last thing
92
- * the model reads before the nudge body).
137
+ * line, when present, is appended last (the identity/mail check is the
138
+ * last thing the model reads before the nudge body).
93
139
  */
94
140
  function emit(output: string, versionWarning: string | null, isJson: boolean, chatLine = ""): void {
95
141
  if (chatLine) {
@@ -123,17 +169,42 @@ function parseTimeBound(value: string, flag: string): number {
123
169
  return parsed;
124
170
  }
125
171
 
126
- /** 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. */
127
175
  function flagValue(flags: string[], ...names: string[]): string | undefined {
128
176
  for (let i = 0; i < flags.length; i++) {
129
177
  for (const name of names) {
130
- 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
+ }
131
185
  if (flags[i]?.startsWith(`${name}=`)) return flags[i]!.slice(name.length + 1);
132
186
  }
133
187
  }
134
188
  return undefined;
135
189
  }
136
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
+
137
208
  function openSessionDb(): SessionDB {
138
209
  const path = resolveOpencodeDbPath();
139
210
  if (!path) {
@@ -271,7 +342,7 @@ async function chatCommand(rest: string[]): Promise<void> {
271
342
  case "list": {
272
343
  const sessions = db.listChatSessions();
273
344
  if (sessions.length === 0) {
274
- console.log("No sessions registered. Sessions join with thatch_chat_register from opencode.");
345
+ console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
275
346
  break;
276
347
  }
277
348
  for (const s of sessions) {
@@ -292,27 +363,105 @@ async function chatCommand(rest: string[]): Promise<void> {
292
363
 
293
364
  case "tail": {
294
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
+
295
414
  // Diff state: one entry per seen message id holding its read_at, so
296
415
  // each poll emits only new messages and newly-read rows. The diff
297
- // logic lives in src/chat.ts (chatTailDiff), unit-tested there.
298
- const state = new Map<number, string | null>();
299
-
300
- // Follow mode holds the handle open until Ctrl-C; the finally below
301
- // closes it on every exit path, including the SIGINT handler's.
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).
302
426
  try {
303
- // First pass: print the backlog (every existing row as its sent
304
- // line), then follow unless --once.
305
- for (const event of chatTailDiff(state, db.chatMessageFeed()).events) {
306
- if (event.kind === "sent") console.log(formatChatTailEvent(event));
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
+ );
307
447
  }
308
- if (onceOnly) break;
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;
309
450
 
310
- process.on("SIGINT", () => process.exit(0));
451
+ process.on("SIGINT", () => {
452
+ db.close();
453
+ process.exit(0);
454
+ });
311
455
  for (;;) {
312
456
  await Bun.sleep(2000);
313
457
  try {
314
- for (const event of chatTailDiff(state, db.chatMessageFeed()).events) {
315
- console.log(formatChatTailEvent(event));
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));
316
465
  }
317
466
  } catch (err) {
318
467
  // The surface's whole job is watching a shared, mutable file;
@@ -321,6 +470,11 @@ async function chatCommand(rest: string[]): Promise<void> {
321
470
  console.error("The database may be locked or missing; exiting.");
322
471
  return;
323
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;
324
478
  }
325
479
  } finally {
326
480
  db.close();
@@ -443,8 +597,9 @@ switch (command) {
443
597
  } catch (err) {
444
598
  console.error(`[thatch] hygiene report failed: ${err}`);
445
599
  }
446
- // Chat mail check: same gating as flush-tools - a line only when
447
- // pending mail exists for registered sessions in this project.
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.
448
603
  const chatLine = await chatHookLine();
449
604
  const reminder = claudeSessionStartReminder(repo, hygiene) + (chatLine ? `\n\n${chatLine}` : "");
450
605
  if (isJson) {
@@ -686,9 +841,10 @@ switch (command) {
686
841
  // flush-tools fires on every prompt, including conversational turns
687
842
  // with no tool calls.
688
843
  const versionWarning = detectSkew(dbPath) ?? readNpmCacheForUpdate(dbPath);
689
- // Chat mail check: one line when pending mail exists for registered
690
- // sessions in this project, empty string otherwise (see chatHookLine).
691
- const chatLine = await chatHookLine();
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);
692
848
 
693
849
  // Tier 1: extraction nudge. Peek (don't drain) - the queue persists
694
850
  // until the agent calls memory_remember, which triggers consumeQueue.
@@ -79,9 +79,9 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
79
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. |
80
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. |
81
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. |
82
- | `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
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. `chat_register` without a name draws from it. |
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. |
85
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. |
86
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
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]`. |
@@ -337,8 +337,10 @@ behavior cycle (agent-driven, self-graded)
337
337
  `behaviors(id PK, store, statement, rationale, embedding BLOB, model, confidence REAL, confirm_count REAL, disconfirm_count REAL, created_at, updated_at)`,
338
338
  `behavior_edges(matcher_id, behavior_id, weight REAL, PK(matcher_id, behavior_id), FK CASCADE)`,
339
339
  `behavior_provenance(id PK, behavior_id, signal, detail, created_at, FK CASCADE)`,
340
- `chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, project, registered_at, last_seen)`,
341
- `chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at)`.
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.
342
344
  - `recall_count`, `last_recalled_at`, and `archived` are added to pre-existing
343
345
  databases by an idempotent column migration at init (`PRAGMA table_info` +
344
346
  `ALTER TABLE`). The `archived` column is `INTEGER NOT NULL DEFAULT 0` (0 =
@@ -22,7 +22,7 @@ The thatch CLI is a Bun script at `bin/thatch`. It provides memory inspection, M
22
22
  | `setup --claude [--cursor] [--global]` | none | `--claude`, `--cursor`, `--global` | none | Install config + instructions + hooks + skills |
23
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
24
  | `chat list` | none | none | none | Registered chat sessions: name, human-readable age, project, topic |
25
- | `chat tail [--once]` | none | `--once` | none | Follow cross-session chat: sent and read events (`--once` prints the backlog and exits) |
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. |
26
26
  | (unknown) | none | none | none | Print usage, exit 1 |
27
27
 
28
28
  ## Global behavior
@@ -48,14 +48,25 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
48
48
 
49
49
  - `chat list` renders the roster: display name, human-readable age since the
50
50
  last heartbeat, project, and topic when set.
51
- - `chat tail [--once]` follows the message stream. Sent lines have the shape
52
- `[timestamp] from -> to: body` (broadcast rows render `broadcast` via the
53
- `via_broadcast` marker); in follow mode, an inbox drain emits a read line
54
- (`[timestamp] ${reader} read a message from ${sender}: preview` — the
55
- reader is the recipient draining its inbox). The diff logic is
56
- `chatTailDiff()`/`formatChatTailEvent()` in `src/chat.ts`, unit-tested
57
- there; `--once` prints one snapshot and exits, because a snapshot has no
58
- previous state to diff read events against.
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.
59
70
 
60
71
  ## Environment variables
61
72