@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.
@@ -0,0 +1,315 @@
1
+ # Cross-Session Chat
2
+
3
+ Cross-session chat lets independent opencode sessions on one machine message
4
+ each other through thatch: sessions join a shared directory (automatically,
5
+ under assigned names), see each other in the list, and messages land in
6
+ inboxes. An idle recipient is woken with a prompt when mail arrives, so
7
+ coordination does not route through the human ("ask the other session
8
+ whether the release is green" instead of "Jeff, go ask").
9
+
10
+ User-facing behavior is documented in
11
+ [docs/user/cross-session-chat.md](../../user/cross-session-chat.md).
12
+
13
+ ## What it does
14
+
15
+ - `thatch_chat_register` / `thatch_chat_list` / `thatch_chat_send` /
16
+ `thatch_chat_read` / `thatch_chat_unregister` / `thatch_chat_broadcast`
17
+ tools (shared across hosts; opencode injects identity, MCP hosts pass the
18
+ name their thatch hook printed as the `as` argument)
19
+ - Automatic registration: the plugin inserts a top-level session's
20
+ directory row on its first idle event, with an assigned never-reused
21
+ name (`chat.autoRegister: false` opts out)
22
+ - A shared SQLite directory and inbox in thatch.db, writable by any opencode
23
+ process on the machine
24
+ - A per-process poller that heartbeats hosted sessions and delivers wake
25
+ prompts via `client.session.promptAsync` with a synthetic part - the same
26
+ mechanism watchers use
27
+ - Anti-loop protection at two levels: prompt guidance (messages are
28
+ informational, no reflexive auto-reply) and a hard per-recipient nudge
29
+ rate cap
30
+
31
+ ## The shared-state, local-delivery decision
32
+
33
+ Watchers keep their registry in plugin memory, never SQLite, because the
34
+ process that registered a watcher is the only one that can deliver its
35
+ events (see [watchers.md](watchers.md)). Chat cannot work that way: the
36
+ sender's process and the recipient's process are usually different, so the
37
+ state they coordinate through must be shared.
38
+
39
+ The split:
40
+
41
+ - **State is shared.** `chat_sessions` and `chat_messages` live in thatch.db.
42
+ Every opencode process on the machine reads and writes the same tables.
43
+ Concurrent access is the already-solved case - multiple sessions share the
44
+ DB for memories today, under WAL plus a busy timeout.
45
+ - **Delivery is local.** Each process polls the inbox for messages addressed
46
+ to sessions it hosts (those it has seen `session.status` events for) and
47
+ prompts them through its own SDK client. A sender never prompts a session
48
+ in another process.
49
+
50
+ This preserves the watcher rationale's core property - no process prompts a
51
+ session it does not host - so each message has exactly one possible
52
+ deliverer. There is no claiming logic, no double-delivery race, and no
53
+ orphaned delivery state. The cost is the mirror of watchers': chat state
54
+ outlives a process, so liveness needs a signal beyond process lifetime.
55
+
56
+ ## How it works
57
+
58
+ ### Registration and identity
59
+
60
+ Sessions are auto-registered by the plugin: the first `session.status`
61
+ idle event for a top-level session inserts its directory row (unless
62
+ `chat.autoRegister: false`). Names are assigned, never claimed:
63
+ `<slug>-<counter>` where the slug is the session title lowercased and
64
+ hyphenated (pool-draw slug when the title is still opencode's placeholder)
65
+ and the counter comes from a per-base counter row
66
+ (`chat_name_counters`, src/db.ts) that only ever increments - drawn
67
+ atomically via INSERT ... ON CONFLICT ... RETURNING. A name is therefore
68
+ minted exactly once per machine: pruning an auto-registered row (host
69
+ silent for CHAT_AUTO_TTL_DAYS = 7 days, swept hourly by the poller) can
70
+ never reissue its name, which is what makes the prune safe. The session's
71
+ live title rides the topic column, refreshed on every idle by
72
+ `refreshAutoTopic` - auto rows only; legacy rows keep their old model-set topics.
73
+
74
+ `chat_register` (no arguments) is the explicit path: idempotent ensure for
75
+ opencode (identity is the host session ID, which the model cannot know or
76
+ forge), a fresh identity per conversation on MCP hosts, or a reclaim of an
77
+ existing name via `as`. Re-registering keeps the name. Unregistered
78
+ sessions are invisible and unmessageable in both directions. Only
79
+ top-level sessions should register - sub-agent children are ephemeral -
80
+ which the tool descriptions and system prompt state; there is no
81
+ mechanical barrier, a documented trust posture.
82
+
83
+ ### Leave tombstones
84
+
85
+ Every exit path - `chat_unregister` and `session.deleted` alike - writes a
86
+ `chat_leave_tombstones` row in the same transaction that deletes the
87
+ directory row. The tombstone is what makes a leave stick: both
88
+ registration paths consult it (the plugin's idle auto-registerer is
89
+ suppressed outright; the MCP hook's ensure-register prints the leave line
90
+ instead of rejoining), so a leave cannot be silently undone by the next
91
+ idle moment. The gate lives in `ChatStore.register` via `#tombstoneGate`
92
+ (src/chat.ts), which also closes the in-flight race: an auto-register
93
+ IIFE whose session was deleted during its title-fetch await finds the
94
+ tombstone and stops instead of resurrecting a dead session. An explicit
95
+ `chat_register` clears the tombstone before re-registering (fresh name -
96
+ assigned identities are never reused), and tombstones age out in the
97
+ hourly prune (7-day TTL; a resumed session after expiry simply
98
+ auto-registers under a fresh name).
99
+
100
+ Because names are system-minted and never recycled, the impersonation
101
+ surface shrinks to whatever the operator runs: every agent on this machine
102
+ is the operator's agent.
103
+
104
+ ### Topics
105
+
106
+ The topic is the roster's "who is working on what" signal. For
107
+ auto-registered sessions it is the live session title, refreshed on every
108
+ idle event - the auto-titler usually lands a real title within a turn or
109
+ two, so the roster converges to the actual work with nobody filling in
110
+ forms. Legacy rows (from the claimed-names era) keep whatever topic was
111
+ set at their registration: free text, sanitized to one roster line
112
+ (whitespace runs collapse, 80-character cap, advisory - oversizing degrades
113
+ rather than errors). There is no tool path to set a topic anymore - topics
114
+ are title-derived for everything the current code mints. Broadcast senders
115
+ benefit most: `chat_broadcast` answers "which of you is working on X?"
116
+ with the roster already in hand.
117
+
118
+ ### Messages
119
+
120
+ `chat_send` addresses a recipient by display name or session ID. Both
121
+ endpoints must be registered and distinct. `chat_read` drains the calling
122
+ session's inbox oldest-first and stamps rows read. Message history survives
123
+ unregistration; a departed sender degrades to an unknown name in the
124
+ reader's view (the endpoints are deliberately not foreign keys - leaving the
125
+ directory must not be blocked by history).
126
+
127
+ `chat_broadcast` posts one message to every other registered session at
128
+ once, one inbox row per recipient - the existing wake machinery (grouping,
129
+ gating, rate cap) treats each as ordinary mail. Stale sessions are skipped
130
+ and reported rather than messaged: a host that has stopped heartbeat-ing
131
+ will never read the mail, and dead mail to a dead process is just clutter.
132
+ The sender is excluded. It is a separate tool rather than a magic
133
+ `chat_send` recipient because "send to all" changes the behavior (fan-out,
134
+ stale skipping, no address resolution), and a function that changes
135
+ behavior drastically on a parameter value is two functions.
136
+
137
+ ### Polling, heartbeat, staleness
138
+
139
+ A `setInterval` loop (default 30s) runs one cycle per process: heartbeat the
140
+ hosted sessions' `last_seen`, then deliver. A crashed process fires no
141
+ `session.deleted`, so `chat_list` marks sessions whose `last_seen` is older
142
+ than the staleness threshold (default 10 minutes) as stale rather than
143
+ hiding them - the operator decides what to do with ghosts. `session.deleted`
144
+ is the graceful-exit fast path that unregisters immediately.
145
+
146
+ ### Delivery, re-nudge, and the rate cap
147
+
148
+ A message needs a wake prompt when it is unread and either never delivered
149
+ or delivered more than the re-nudge window ago (default 15 minutes).
150
+ Delivery is gated by the same `canDeliver` predicate watchers use - the
151
+ recipient must be idle and not compacting - and the idle event handler
152
+ flushes pending mail directly so it lands promptly instead of waiting for
153
+ the next cycle. Failed deliveries stay pending and retry. Only registered
154
+ recipients are ever selected: unregistering stops wake prompts for
155
+ kept-but-unread mail, which is the `chat_unregister` tool's promise.
156
+ Delivery is at-least-once - a crash after the wake prompt but before the
157
+ delivered_at stamp re-nudges the same batch on the next cycle.
158
+
159
+ One wake prompt covers all of a recipient's pending messages: sender names
160
+ and a count, pointer-only like watcher notifications, with the model calling
161
+ `chat_read` for content. On success the poller stamps `delivered_at`
162
+ (re-stamping on re-nudges resets the re-nudge timer).
163
+
164
+ The hard brake: at most a fixed number of wake prompts per recipient per
165
+ hour (default 6, in-memory on purpose - it is a loop guard, not accounting).
166
+ Without it, two agents politely acknowledging each other would ping-pong
167
+ forever, each wake triggering a reply that wakes the other.
168
+
169
+ ### Transcript echo
170
+
171
+ Plugin tools render in the opencode TUI as muted one-line generic entries,
172
+ with the output block behind a default-off toggle - so without help, a chat
173
+ exchange is invisible to the human watching the session. The plugin's
174
+ `tool.execute.after` hook builds a short echo for the conversational events
175
+ (`chatEchoText()` in `src/prompts.ts`: register, send, read, broadcast) and
176
+ delivers it as a non-synthetic, `noReply` promptAsync part: rendered as a
177
+ visible bubble in the transcript, no model turn started (the server's
178
+ prompt path returns before the completion loop). Failed calls never echo;
179
+ `chat_list` and `chat_unregister` stay on the muted tool line. Broadcasts
180
+ echo their fan-out count with the clipped body.
181
+
182
+ The trade-off: non-synthetic is what makes the TUI render the part, and it
183
+ also means later turns see the echo in context - a small duplication of the
184
+ tool call it mirrors, accepted for visibility. Echo bodies are clipped
185
+ (send: the body; read: the formatted inbox; broadcast: the body) so a
186
+ bubble stays cheap. Echo delivery is fire-and-forget: a failure must never
187
+ fail the tool call it follows. Because the opencode server fires the
188
+ `chat.message` hook for every prompt part before the `noReply`
189
+ early-return, the plugin's `chat.message` handler skips messages whose
190
+ visible text is entirely `[chat]`-prefixed bubbles (`isChatEchoParts`) -
191
+ otherwise every echo would run the nudge machinery for a message no model
192
+ turn reads. The accepted edge: a real user message whose every visible
193
+ part starts with the prefix is skipped the same way, losing that one turn's
194
+ advisory nudges.
195
+
196
+ ### The untrusted-content frame
197
+
198
+ Message bodies are other agents' text delivered verbatim into the reading
199
+ model's context - a prompt-injection surface by construction. The watcher
200
+ privacy model solves the same surface with pointer-only notifications, but
201
+ chat's payload IS the body, so `chat_read` frames the boundary instead:
202
+ `chatInboxFrame()` (src/prompts.ts) wraps the message lines in
203
+ begin/end fences with an explicit do-not-follow warning. The frame exists only in
204
+ the tool output - persisted rows are untouched - and pairs with the wake
205
+ nudge's anti-loop rule and the isChatEchoParts skip as the feature's three
206
+ injection-hygiene layers.
207
+
208
+ ### CLI access
209
+
210
+ The user watches the conversation without an agent: `thatch chat list`
211
+ renders the roster with human-readable heartbeat ages, and `thatch chat
212
+ tail` follows the message stream as cards. The backlog renders only the
213
+ last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
214
+ `filterChatTailRows()` narrows every feed snapshot (backlog and follow
215
+ polls alike) by body regexes, participant-name substrings, and a
216
+ half-open time window. The diff itself is `chatTailDiff()` (unit-tested
217
+ in `tests/chat.test.ts`): sent cards on first view, then new sends and
218
+ newly-read messages per poll, with the diff state seeded from the full
219
+ feed so neither the limit nor a mid-follow rename/unregister can
220
+ resurface old rows as sent events. `chat_messages.via_broadcast` marks
221
+ fan-out rows so the tail renders one `-> broadcast` card per recipient
222
+ instead of what would otherwise look like identical direct sends. The
223
+ CLI takes no chat write actions (the wake machinery owns those paths);
224
+ the only writes it can trigger are the schema migrations that run
225
+ whenever any thatch process opens the database.
226
+
227
+ ## Interactions with other features
228
+
229
+ - Watchers ([watchers.md](watchers.md)): chat reuses the delivery gate
230
+ (`canDeliver` over `sessionStatus` + `compacting`), the idle-flush
231
+ pattern, and the synthetic-part + toast notification shape. The state
232
+ model is the deliberate opposite - shared SQLite vs process memory - and
233
+ each feature's doc explains why its side of the split is right for its
234
+ problem.
235
+ - Session lifecycle ([session-lifecycle.md](session-lifecycle.md)):
236
+ `session.status` feeds the delivery gate and the hosted-session set;
237
+ `session.deleted` unregisters.
238
+ - Extraction pipeline ([extraction.md](extraction.md)): chat tool calls
239
+ are `thatch_*` tools and excluded from buffering like all thatch tools;
240
+ non-thatch tool calls made during a chat notification turn are buffered
241
+ and extracted like any other turn's.
242
+ - Multi-host ([multi-host.md](multi-host.md)): the chat tools are shared -
243
+ MCP hosts get their identity from the thatch hook, which ensure-registers
244
+ the conversation's stable session ID (`mcp_<hash of host session id>`)
245
+ and prints the assigned name plus unread count on every prompt; chat
246
+ tools take that name as `as`. Wake-up delivery remains opencode-only: no
247
+ MCP server can start a turn in the client's conversation.
248
+
249
+ ## Multi-host delivery tiers
250
+
251
+ Chat works on every host, and can be disabled entirely: `chat.enabled:
252
+ false` in the thatch config makes every chat tool refuse (a `[disabled]`
253
+ reply naming the re-enable path), stops the poller at init, omits the
254
+ system prompt's chat section, and silences the hook lines. The tools
255
+ re-read the config per call, so a toggle applies immediately; the poller
256
+ gate is read once at init and needs a restart. The hook lines gate on the
257
+ same config, so a disabled feature never advertises itself through any
258
+ surface.
259
+
260
+ Chat works on every host. Earlier designs for the MCP path - a JSONL
261
+ temp-file inbox, a socket or daemon - were rejected: the DB already is the
262
+ inbox, and MCP hosts cannot hold connections across turns, so both
263
+ degenerate to the turn-granularity polling the tools already provide. The
264
+ wake analog is the existing hook channel.
265
+
266
+ | Host | Send/receive | New-mail signal | Echo bubbles |
267
+ |-------------|--------------|------------------------------|--------------|
268
+ | opencode | yes | promptAsync wake (idle-gated)| yes |
269
+ | claude code | yes | flush-tools hook line | no |
270
+ | cursor | yes | flush-tools hook line | no |
271
+
272
+ MCP identity is anchored by the host hook: `chatHookLine(sessionID)` in
273
+ `bin/thatch` ensure-registers `mcp_<sha256(host session id)[0:12]>` on
274
+ every prompt and prints the assigned name plus unread count, so the
275
+ identity is stable across the ephemeral conversations those harnesses run
276
+ (and a conversation without the hook gets a fresh per-conversation
277
+ identity from `chat_register`). `chat_status` is the quiet check
278
+ (registered flag + pending/total); the flush-tools hook line names the
279
+ caller's identity and mailbox, and is absent entirely when chat is
280
+ disabled - absence is silent by construction.
281
+ Staleness semantics differ by kind: an opencode row's stale age means the
282
+ process is gone (broadcast skips it), while an mcp row's age only means
283
+ "between turns" (broadcast always delivers).
284
+
285
+ ## Defaults
286
+
287
+ Timing constants live in `src/chat.ts`: poll interval 30s, staleness
288
+ threshold 10 minutes, re-nudge window 15 minutes, nudge cap 6 per recipient
289
+ per hour, auto-row TTL 7 days. There are no environment overrides yet; add
290
+ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
291
+
292
+ ## Source files
293
+
294
+ - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
295
+ slugify, counter bump, insert, prune, topic refresh), ChatPoller
296
+ (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
297
+ staleness helper
298
+ - `src/chat-names.ts` - the static display-name pool (nomenclater style),
299
+ the fallback base for title-less registrations
300
+ - `src/db.ts` - the chat tables in schema init (including
301
+ chat_name_counters and the auto column), the NOCASE collation migration
302
+ and topic column migration, delegated methods
303
+ - `src/tool-defs.ts` - the chat tool definitions
304
+ - `src/index.ts` - poller construction, delivery closure, idle
305
+ auto-registration + topic refresh, idle flush,
306
+ session.deleted unregister, dispose, transcript echo in
307
+ tool.execute.after
308
+ - `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
309
+ `isChatEchoParts()`, system prompt Cross-Session Chat section, MCP
310
+ absent-tools note
311
+ - `tests/chat.test.ts` - store, pool, migration, echo-text, and poller unit
312
+ tests (temp-dir SQLite, injected delivery)
313
+ - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
314
+ - `tests/qa/live/uc-098-chat-cross-session.ts` - two real sessions exchange
315
+ a message through the shared DB
@@ -1,6 +1,6 @@
1
1
  # Database
2
2
 
3
- Thatch stores all data in a single SQLite file. The schema is organized around three concerns: memory entries, the prediction engine, and the behavior engine.
3
+ Thatch stores all data in a single SQLite file. The schema is organized around feature concerns: memory entries, the prediction engine, the behavior engine, and cross-session chat.
4
4
 
5
5
  ## Configuration
6
6
 
@@ -10,13 +10,14 @@ Thatch stores all data in a single SQLite file. The schema is organized around t
10
10
 
11
11
  ## Schema overview
12
12
 
13
- The tables fall into three groups:
13
+ The tables fall into these groups:
14
14
 
15
15
  | Group | Tables | Purpose |
16
16
  |-------|--------|---------|
17
17
  | Memory | `stores`, `entries`, `dedup_pairs` | Core memory CRUD + dedup verdicts |
18
18
  | Prediction engine | `prediction_matchers`, `predictions`, `prediction_edges`, `prediction_provenance` | User decision model |
19
19
  | Behavior engine | `behavior_matchers`, `behaviors`, `behavior_edges`, `behavior_provenance` | LLM self-discipline rules |
20
+ | Cross-session chat | `chat_sessions`, `chat_messages` | Session directory + message inbox (shared across opencode processes) |
20
21
 
21
22
  The prediction and behavior engines share the same four-table shape (matchers, items, edges, provenance) with different table names. They are separate because the semantics differ: predictions model what the user wants; behaviors model what the LLM should do.
22
23
 
@@ -213,6 +214,52 @@ behavior_provenance(
213
214
 
214
215
  - `signal`: "confirm" (ham), "disconfirm" (spam), or "codify".
215
216
 
217
+ ## Cross-session chat tables
218
+
219
+ ### chat_sessions
220
+
221
+ ```sql
222
+ chat_sessions(
223
+ session_id TEXT PRIMARY KEY,
224
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
225
+ topic TEXT,
226
+ project TEXT,
227
+ registered_at TEXT NOT NULL,
228
+ last_seen TEXT NOT NULL
229
+ )
230
+ ```
231
+
232
+ - The machine-wide session directory. `last_seen` is the heartbeat: each
233
+ host process refreshes it for its own sessions every poll cycle, and a
234
+ stale value marks a crashed or closed process. Name uniqueness is
235
+ case-insensitive; databases whose chat_sessions table predates the NOCASE
236
+ constraint are rebuilt at schema init (first row wins per case group).
237
+
238
+ ### chat_messages
239
+
240
+ ```sql
241
+ chat_messages(
242
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
243
+ from_session TEXT NOT NULL,
244
+ to_session TEXT NOT NULL,
245
+ body TEXT NOT NULL,
246
+ created_at TEXT NOT NULL,
247
+ delivered_at TEXT,
248
+ read_at TEXT,
249
+ via_broadcast INTEGER NOT NULL DEFAULT 0
250
+ )
251
+ ```
252
+
253
+ - `delivered_at` is the last wake-prompt stamp (restamped on re-nudges);
254
+ `read_at` is set when the recipient drains its inbox. `via_broadcast`
255
+ marks rows a chat_broadcast fan-out created (the CLI tail renders them
256
+ as `-> broadcast`). The endpoints are
257
+ plain columns, not foreign keys: unregistering a session must not be
258
+ blocked by message history, and a departed sender degrades to an unknown
259
+ name in the reader's view.
260
+
261
+ See [cross-session-chat.md](cross-session-chat.md) for the delivery model.
262
+
216
263
  ## Schema migration
217
264
 
218
265
  `recall_count`, `last_recalled_at`, and `archived` are added to pre-existing databases by an idempotent column migration at init. The migration uses `PRAGMA table_info` to check for the column's existence, then `ALTER TABLE ADD COLUMN` if missing. This handles databases created before these columns existed.
@@ -235,8 +282,9 @@ Embeddings are raw Float32Array bytes stored as BLOBs. Serialization honors `byt
235
282
 
236
283
  - Memory store ([memory-store.md](memory-store.md)): `entries` table is the core; `stores` table is the registry
237
284
  - Deduplication ([deduplication.md](deduplication.md)): `dedup_pairs` table tracks reviewed pairs
238
- - Prediction engine ([prediction-engine.md](prediction-engine.md)): 4 prediction tables
239
- - Behavior engine ([behavior-engine.md](behavior-engine.md)): 4 behavior tables
285
+ - Prediction engine ([prediction-engine.md](prediction-engine.md)): the prediction tables
286
+ - Behavior engine ([behavior-engine.md](behavior-engine.md)): the behavior tables
287
+ - Cross-session chat ([cross-session-chat.md](cross-session-chat.md)): the chat tables, shared across opencode processes
240
288
  - Hygiene ([hygiene.md](hygiene.md)): `staleEntryCount` uses `recall_count` and `last_recalled_at`; `branchesInStore` uses `branch` column
241
289
 
242
290
  ## Source files
@@ -16,11 +16,17 @@ Thatch supports three host agents from a single codebase:
16
16
  - **Cursor MCP**---same stdio MCP server, `--json` hook output format,
17
17
  `mcp__thatch__` tool prefix, system prompt in AGENTS.md
18
18
 
19
- All three paths share the tool definitions from `src/tool-defs.ts`: 18
20
- host-agnostic tools plus opencode-only tools (currently seven:
21
- `get_session_info`, `session_search`/`session_get`, and the
22
- `watch_create`/`watch_branch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
23
- filters out. The shared tools take args and a `CoreContext`
19
+ All three paths share the tool definitions from `src/tool-defs.ts`: the
20
+ host-agnostic tools plus the opencode-only tools, which the MCP server
21
+ filters out. The opencode-only set covers host-session capabilities MCP
22
+ hosts lack: `get_session_info`, `session_search`/`session_get` (no session
23
+ concept or session database), the `watch_create`/`watch_branch_create`/
24
+ `watch_command_create`/`watch_list`/`watch_cancel` watcher tools (no
25
+ poller or proactive-prompt
26
+ channel), and the `chat_register`/`chat_list`/`chat_send`/`chat_read`/
27
+ `chat_unregister`/`chat_broadcast` chat tools (no session identity or
28
+ wake-up channel). The
29
+ shared tools take args and a `CoreContext`
24
30
  (`{ db, model, defaultStore }`) and return a string. The host-specific
25
31
  wrapping---prefixing, transport, session hooks---lives outside the core.
26
32
 
@@ -74,9 +80,10 @@ at session start.
74
80
  so the agent sees `mcp__thatch__memory_remember`. Thatch itself does not apply
75
81
  this prefix.
76
82
 
77
- **Skills**: installed to `$CLAUDE_CONFIG_DIR/skills/`---shared skills only.
78
- The code-review coordinator skill is opencode-only because it requires
79
- sub-agent dispatch, which Claude Code does not support.
83
+ **Skills**: installed to the scope's skills dir (repo `.claude/skills/` for
84
+ project-local setup, `$CLAUDE_CONFIG_DIR/skills/` for `--global`)---shared
85
+ skills only. The code-review coordinator skill is opencode-only because it
86
+ requires sub-agent dispatch, which Claude Code does not support.
80
87
 
81
88
  **Session behavior**: driven by external CLI hook processes (`bin/thatch`).
82
89
  The hooks are registered in `.claude/settings.json` during setup. Each hook
@@ -182,6 +189,8 @@ behavior, with some features having no MCP counterpart.
182
189
  | Tool batching | N/A (in-process) | PostToolBatch (batch) | postToolUse (per-tool) |
183
190
  | `get_session_info` (session identity) | Yes | No | No |
184
191
  | `session_search` / `session_get` (conversation archaeology) | Yes (tools) | CLI only (`thatch session ...`) | CLI only (`thatch session ...`) |
192
+ | `watch_*` (PR, branch, and command watchers) | Yes (tools) | No | No |
193
+ | `chat_*` (cross-session chat) | Yes | No | No |
185
194
 
186
195
  For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
187
196
 
@@ -231,4 +240,4 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
231
240
  - The MCP server always exposes bare names. The `mcp__thatch__` prefix is applied by the MCP client, not thatch.
232
241
  - The three system prompt variants are independent string constants in `src/prompts.ts` with no shared template. Editing shared prose in one requires mirroring in the other two, or they drift.
233
242
  - The tool list line in all three prompts must match `TOOL_DEFS` in `src/tool-defs.ts` (opencode's list includes opencode-only tools; the MCP lists exclude them). The historical failure mode: a tool is added to `TOOL_DEFS` but only two of three prompts are updated.
234
- - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts, `get_session_info`, `session_search`/`session_get`, PR watchers) have no MCP counterpart. The `thatch session` CLI subcommands work anywhere - they only need an opencode.db file to read.
243
+ - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts, `get_session_info`, `session_search`/`session_get`, all watchers (PR, branch, and command), cross-session chat wake-up) have no MCP counterpart. The `thatch session` CLI subcommands work anywhere - they only need an opencode.db file to read.
@@ -52,8 +52,9 @@ Four artifacts, all idempotent:
52
52
  → `thatch reminder`, `PostToolBatch` → `thatch buffer-batch`,
53
53
  `UserPromptSubmit` → `thatch flush-tools`. Nested format:
54
54
  `{hooks:{Event:{hooks:[{type,command}]}}}`.
55
- 4. **Skills** --- installs to `$CLAUDE_CONFIG_DIR/skills/` (always
56
- user-scoped, even for project-local setup). `SHARED_SKILLS` only --- the
55
+ 4. **Skills** --- installs to the scope's skills dir: the repo's
56
+ `.claude/skills/` for project-local, `$CLAUDE_CONFIG_DIR/skills/` for
57
+ `--global`. `SHARED_SKILLS` only --- the
57
58
  code-review coordinator requires sub-agent dispatch, which Claude Code
58
59
  does not support.
59
60
 
@@ -73,7 +74,9 @@ Four artifacts, same idempotent pattern:
73
74
  `{version:1, hooks:{event:[{command}]}}`. Three hooks: `sessionStart`
74
75
  → `thatch reminder --json`, `postToolUse` → `thatch buffer-tool`,
75
76
  `beforeSubmitPrompt` → `thatch flush-tools --json`.
76
- 4. **Skills** --- installs to `$CURSOR_CONFIG_DIR/skills/` (shared only).
77
+ 4. **Skills** --- installs to the scope's skills dir: the repo's
78
+ `.cursor/skills/` for project-local, `$CURSOR_CONFIG_DIR/skills/` for
79
+ `--global` (shared only).
77
80
 
78
81
  ### Marker system (`appendBlock`)
79
82
 
@@ -184,8 +187,12 @@ example, an older `thatch echo` hook is replaced with the current
184
187
  1. **All operations are idempotent.** Re-running setup updates drifted content
185
188
  without clobbering. Instructions use markers, hooks filter by `thatch`
186
189
  string, MCP config preserves existing servers, skills diff before writing.
187
- 2. **Skills are always user-scoped**, even in project-local setup. Claude Code
188
- and Cursor load skills from the user config dir, not the project.
190
+ 2. **Skills follow the setup scope.** Project-local installs write skills to
191
+ the repo's `.claude/skills/` / `.cursor/skills/` (Claude Code and Cursor
192
+ both discover project skills there); `--global` writes to the user config
193
+ dirs. Setup reports the directory plus added/updated/removed/unchanged
194
+ counts, and notes (without touching) any thatch skills found in the
195
+ opposite scope.
189
196
  3. **`appendBlock` leaves content alone if markers do not parse.** A start
190
197
  marker without an end marker means the file was edited externally. Writing
191
198
  would risk clobbering content after the start marker.