@jeffober/thatch 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,24 +49,22 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
49
49
  - `chat list` renders the roster as aligned columns under a header row
50
50
  (NAME/AGE/STATUS/PROJECT/TOPIC), colorized only when stdout is a TTY
51
51
  (`formatChatRoster()` in `bin/thatch`; pipes and QA runners get plain
52
- text). Status reuses the `isStale()` staleness convention.
53
- - `chat tail` follows the message stream as styled cards: label chips,
54
- colored names, dimmed timestamps, topic annotations after names, and a
55
- drawn rule between cards. On a TTY, sent bodies render as markdown via
56
- an external renderer (`glow`, then `gum format`; plain fallback):
57
- selection (`selectChatBodyRenderer()`) and body cleanup
58
- (`cleanRenderedBody()`) live in `src/chat.ts`, unit-tested there;
59
- `bin/thatch` owns the TTY/NO_COLOR gate, the once-per-process cache,
60
- and the spawn. The card renderer is
61
- `formatChatTailCard()` (optional `renderBody` hook; the read-event
62
- clip is never rendered) and the event shaping is `chatTailDiff()` in
63
- `src/chat.ts`, unit-tested there; the single-line `formatChatTailEvent()`
64
- remains for tests and compact contexts. `--once` prints one snapshot
65
- and exits, because a snapshot has no previous state to diff read
66
- events against.
67
- - The backlog renders only the last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages;
52
+ text). Rows are split into Active and Stale sections by
53
+ `splitChatRoster()`; the status column is `chatLiveness()`'s verdict
54
+ (`fresh`/`stale` for opencode rows, `active`/`idle` for MCP rows).
55
+ - `chat tail` follows the message stream as JSONL: one `ChatTailEvent`
56
+ per line via `formatChatTailJsonl()` (a bare `JSON.stringify`; no ANSI,
57
+ no markdown rendering, no local-time conversion - the tail is a log for
58
+ `jq` and `grep`). `sent` and `read` are separate events linked by the
59
+ message `id`; broadcast fan-out is one `sent` per recipient with
60
+ `broadcast: true` and the real `to`. Event shaping is `chatTailDiff()`
61
+ in `src/chat.ts`, unit-tested there. `--once` prints one snapshot and
62
+ exits; the snapshot includes a `read` event for every shown message
63
+ whose `read_at` is set, sorted by time, so it is the same log a follow
64
+ would have accumulated.
65
+ - The backlog prints only the last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages;
68
66
  `chatTailBacklog()` in `src/chat.ts` filters the feed, seeds the diff
69
- state from every feed row (so the limit hides cards, not history, and a
67
+ state from every feed row (so the limit hides lines, not history, and a
70
68
  mid-follow rename or unregister cannot resurface old rows as sent
71
69
  events), and reports the elided count for
72
70
  the CLI's stderr note. `filterChatTailRows()` ANDs body regexes
@@ -0,0 +1,74 @@
1
+ # Slash commands: actions and wrap-ups
2
+
3
+ Thatch's `/thatch/*` commands come in two families:
4
+
5
+ - **Actions** (`defrag`, `extract`, `hygiene`, `reflect`) — user-invoked
6
+ wrappers around behaviors the nudges otherwise run on their own schedule.
7
+ - **Wrap-ups** (`compact`, `exit`) — greenlight-gated pre-compaction and
8
+ pre-exit checklists. opencode only: the greenlight check needs the
9
+ plugin's `command.execute.before` arming and TUI control routes.
10
+
11
+ User-facing guide: [../../user/commands.md](../../user/commands.md).
12
+
13
+ ## Prompt cores
14
+
15
+ Action bodies are **prompt cores**: constants in `src/prompts.ts`
16
+ (`defragCore`, `hygieneCore`, `reflectCore`, `extractionCore`) that hold the
17
+ host-agnostic instruction text for one behavior. The nudge envelopes wrap
18
+ the same cores with escalation tiers and the stop-and-wait ending; command
19
+ and prompt renderers wrap them with frontmatter and a user-message section.
20
+ The point is drift-proofing: a wording change lands in the nudge and the
21
+ command at once, and a test asserts each rendered file contains its core
22
+ verbatim.
23
+
24
+ The stop-and-wait ending ("do not advance pending work on this nudge's
25
+ account") is nudge-only by design. A `/thatch` command IS user input, so
26
+ the command envelope must not inherit it.
27
+
28
+ Cores that name memory tools take a `ToolNamer` because spellings differ
29
+ per host: `thatch_find_duplicates` on opencode, `mcp__thatch__find_duplicates`
30
+ on MCP hosts. Each host's renderer passes its own namer
31
+ (`opencodeToolName` / `mcpToolName`).
32
+
33
+ ## Rendering and sync
34
+
35
+ `src/commands.ts` is the single registry:
36
+
37
+ - `actionDefs(tool)` returns the action set with bodies rendered for one
38
+ host. Actions needing plugin-only capabilities are marked `opencodeOnly`
39
+ (`extract`: needs session identity via `get_session_info`).
40
+ - `opencodeCommandDefs()` = wrap-up templates + all actions. Synced by the
41
+ plugin at init into `$XDG_CONFIG_HOME/opencode/command/thatch/`
42
+ (`installOpencodeCommands`), content-compare, self-healing.
43
+ - `claudeCommandDefs()` = actions minus `opencodeOnly`. Synced by
44
+ `thatch setup --claude` (`installClaudeCommands`) into
45
+ `<claudeDir>/commands/thatch/`, reported in the setup output. There is no
46
+ plugin on this host to sync on load, so setup is the sync point.
47
+ - MCP prompts: `compilePrompts()` in `src/mcp.ts` exposes the same
48
+ non-opencodeOnly actions through `prompts/list` / `prompts/get`, which
49
+ Cursor surfaces as slash commands. The optional `focus` argument is
50
+ appended to the body as a `Focus:` line, standing in for the file
51
+ commands' `$ARGUMENTS`.
52
+
53
+ A parity-guard test asserts the three sets are the same actions minus the
54
+ documented exclusions: opencode = Claude Code set + `compact`, `exit`,
55
+ `extract`; MCP prompts = Claude Code's set.
56
+
57
+ ## The extract action and session IDs
58
+
59
+ `/thatch/extract` runs the extraction flow on demand: learn the session ID
60
+ from `thatch_get_session_info`, dispatch the extractor sub-agent with an
61
+ explicit `session_id`, acknowledge with `thatch_extraction_done`. The
62
+ explicit ID matters because the sub-agent's session differs from the
63
+ parent's — the same reason the extraction nudge passes it.
64
+
65
+ `get_extraction_payload` accepts an omitted `session_id`: on the opencode
66
+ path the wrapper supplies the invoking session's `HostToolContext.sessionID`.
67
+ The zod arg shape is shared between hosts, so optionality cannot be
68
+ per-host at the schema level; on MCP hosts (no session context) an omitted
69
+ `session_id` returns a clear "pass the parent's session_id" error. The rule
70
+ the model applies, stated in the arg descriptions and the dev docs:
71
+
72
+ > Omit `session_id` when acting on the session you are running in. Pass the
73
+ > parent session's `session_id` when running inside a sub-agent dispatched
74
+ > to process another session's extraction queue.
@@ -43,8 +43,9 @@ The split:
43
43
  Concurrent access is the already-solved case - multiple sessions share the
44
44
  DB for memories today, under WAL plus a busy timeout.
45
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
46
+ to sessions it hosts (those it has seen `session.status` events for,
47
+ plus the `-s` startup session) and prompts them through its own SDK
48
+ client. A sender never prompts a session
48
49
  in another process.
49
50
 
50
51
  This preserves the watcher rationale's core property - no process prompts a
@@ -57,19 +58,25 @@ outlives a process, so liveness needs a signal beyond process lifetime.
57
58
 
58
59
  ### Registration and identity
59
60
 
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
61
+ Sessions are auto-registered by the plugin: a top-level session's first
62
+ real user message inserts its directory row (the `chat.message` hook,
63
+ which fires before the model runs - so the session is addressable within
64
+ its very first turn), and the first `session.status` idle event remains a
65
+ backstop that also converges the topic once the auto-titler lands. A
66
+ session continued via `-s <id>` or `-c` registers at harness start
67
+ (below). All paths are skipped when `chat.autoRegister: false` or when the
68
+ session left explicitly (a leave tombstone, see below). Names are assigned, never
69
+ claimed: a pool draw (`CHAT_NAME_POOL`, src/chat-names.ts) plus a
70
+ per-base counter row (`chat_name_counters`, src/db.ts) that only ever
71
+ increments - drawn atomically via INSERT ... ON CONFLICT ... RETURNING.
72
+ Pool names are deliberately short: they appear in every wake prompt and
73
+ roster line, and the session's descriptive identity lives in the topic
74
+ column instead (the live session title, refreshed on every idle by
75
+ `refreshAutoTopic` - auto rows only; legacy rows keep their old model-set
76
+ topics; placeholder titles never become topics or names). A name is
68
77
  minted exactly once per machine: pruning an auto-registered row (host
69
78
  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.
79
+ never reissue its name, which is what makes the prune safe.
73
80
  Registration also captures the checkout kind (`worktree` column):
74
81
  `detectWorktreeKind` (src/git.ts) classifies the session's serving
75
82
  directory once at registration - a `.git` file means a linked git
@@ -77,6 +84,31 @@ worktree, a `.git` directory means the project root, no `.git` reads as
77
84
  undetected. The roster shows it as a `loc:` token so sessions coordinating
78
85
  on a shared tree can tell who sits where.
79
86
 
87
+ ### Startup registration and reclaim
88
+
89
+ A session continued via `opencode -s <id>` gets no event when it comes
90
+ back up (resume writes nothing session-scoped), so the plugin reads the
91
+ session id from the harness's command line at init and registers it then.
92
+ The plugin does not see the CLI flags in `process.argv`: opencode hosts
93
+ the server in a worker thread whose argv is just the worker script. The
94
+ thread shares the process pid, so the plugin reads the OS-level command
95
+ line for its own pid instead (`/proc/self/cmdline` on Linux, `ps -o
96
+ args=` on macOS) and parses `-s`/`--session` from that.
97
+ Registration is keyed by session id: the row already exists, so the name
98
+ is RECLAIMED (names are owned by the session id, minted once, never
99
+ renamed) and the row's heartbeat is refreshed - hosting is the
100
+ heartbeat, so the serving harness beats and delivers for the row again.
101
+ `-c/--continue` resolves no id on the command line, so the plugin
102
+ resolves the same target the harness does: `client.session.list()`
103
+ (directory-scoped, the plugin's client carries the directory), sorted by
104
+ `time.updated` descending like the TUI does, first row without a
105
+ `parentID`. That registration is not synchronous at init (the list call
106
+ is a server round trip), but it lands within a beat of startup - the
107
+ heartbeat is live long before the first prompt. Asleep-mail: the
108
+ startup path kicks a delivery pass when it finishes, so the continued
109
+ session learns what it missed at startup instead of waiting out the
110
+ first poll cycle.
111
+
80
112
  `chat_register` (no arguments) is the explicit path: idempotent ensure for
81
113
  opencode (identity is the host session ID, which the model cannot know or
82
114
  forge), a fresh identity per conversation on MCP hosts, or a reclaim of an
@@ -94,8 +126,8 @@ publishing `app.exit`, so the roster stops advertising a host that is
94
126
  about to vanish; the exit template's checklist step asks the model to do
95
127
  the same earlier) - writes a
96
128
  `chat_leave_tombstones` row in the same transaction that deletes the
97
- directory row. The tombstone is what makes a leave stick: both
98
- registration paths consult it (the plugin's idle auto-registerer is
129
+ directory row. The tombstone is what makes a leave stick: all
130
+ registration paths consult it (the plugin's idle and prompt auto-registerers are
99
131
  suppressed outright; the MCP hook's ensure-register prints the leave line
100
132
  instead of rejoining), so a leave cannot be silently undone by the next
101
133
  idle moment. The gate lives in `ChatStore.register` via `#tombstoneGate`
@@ -146,14 +178,31 @@ behavior drastically on a parameter value is two functions.
146
178
 
147
179
  ### Polling, heartbeat, staleness
148
180
 
149
- A `setInterval` loop (default 30s) runs one cycle per process: heartbeat the
150
- hosted sessions' `last_seen`, then deliver. A crashed process fires no
151
- `session.deleted`, so `chat_list` marks sessions whose `last_seen` is older
152
- than the staleness threshold (default 10 minutes) as stale rather than
153
- hiding them - the operator decides what to do with ghosts. Each roster row
154
- also renders a human-readable `last seen <age>` (shared `humanAge` helper,
155
- src/chat.ts, same wording the CLI roster's AGE column uses). `session.deleted`
156
- is the graceful-exit fast path that unregisters immediately.
181
+ A `setInterval` loop (default 30s) runs one cycle per process: heartbeat
182
+ the HOSTED sessions (those seen as events in this harness plus the `-s`
183
+ startup session - idle sessions keep beating, so a session stays fresh
184
+ while its harness lives), then deliver their mail. A crashed process
185
+ fires no `session.deleted`, so its rows would linger - staleness catches
186
+ them: a row that has missed two consecutive beats (`CHAT_STALE_MS`, 60s
187
+ at the default interval) is stale, meaning its harness stopped. Two beats
188
+ rather than one so a single late poll cycle does not flap the roster.
189
+ Heartbeat age is the ONLY liveness signal, by design. A host process id
190
+ cannot serve: the same session is re-hosted by a new process on every
191
+ `-s` resume, so a pid stamped on the row lies as soon as a different
192
+ harness beats it (it says dead while the heartbeat proves alive). `isStale`
193
+ (src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
194
+ and the broadcast skip all call it, so they cannot disagree. `chat_list`
195
+ groups the result into Active and Stale sections - the stale section's
196
+ explainer says what stale means - and `chat_send` states the recipient's
197
+ liveness at send time, so a sender mailing a ghost learns it immediately
198
+ instead of waiting on a wake that will never fire. Both roster surfaces
199
+ order rows with `sortChatRoster` (also src/chat.ts): project first,
200
+ alphabetical, with no-project rows last; most recently seen first within
201
+ a project. The CLI roster bounds
202
+ the stale display to one day by default (`--stale N` in days, `--stale all`
203
+ unbounded), collapsing older rows into a hidden-count note; the tool
204
+ surface stays unbounded so an agent always sees the full directory.
205
+ `session.deleted` is the graceful-exit fast path that unregisters immediately.
157
206
 
158
207
  ### Delivery, re-nudge, and the rate cap
159
208
 
@@ -222,19 +271,19 @@ injection-hygiene layers.
222
271
  The user watches the conversation without an agent: `thatch chat list`
223
272
  renders the roster as aligned columns under a header row (TTY-gated
224
273
  color; `formatChatRoster()` in `bin/thatch`), and `thatch chat tail`
225
- follows the message stream as cards, with sent bodies rendered as
226
- markdown on a TTY via `glow`/`gum` (plain fallback, never in pipes).
227
- The backlog renders only the
274
+ follows the message stream as JSONL: one event per line, `sent` and
275
+ `read` as separate events linked by message id, bodies verbatim.
276
+ The backlog prints only the
228
277
  last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
229
278
  `filterChatTailRows()` narrows every feed snapshot (backlog and follow
230
279
  polls alike) by body regexes, participant-name substrings, and a
231
280
  half-open time window. The diff itself is `chatTailDiff()` (unit-tested
232
- in `tests/chat.test.ts`): sent cards on first view, then new sends and
281
+ in `tests/chat.test.ts`): sent events on first view, then new sends and
233
282
  newly-read messages per poll, with the diff state seeded from the full
234
283
  feed so neither the limit nor a mid-follow rename/unregister can
235
284
  resurface old rows as sent events. `chat_messages.via_broadcast` marks
236
- fan-out rows so the tail renders one `-> broadcast` card per recipient
237
- instead of what would otherwise look like identical direct sends. The
285
+ fan-out rows so each copy's `sent` event carries `broadcast: true`
286
+ alongside its real recipient. The
238
287
  CLI takes no chat write actions (the wake machinery owns those paths);
239
288
  the only writes it can trigger are the schema migrations that run
240
289
  whenever any thatch process opens the database.
@@ -257,7 +306,8 @@ whenever any thatch process opens the database.
257
306
  - Multi-host ([multi-host.md](multi-host.md)): the chat tools are shared -
258
307
  MCP hosts get their identity from the thatch hook, which ensure-registers
259
308
  the conversation's stable session ID (`mcp_<hash of host session id>`)
260
- and prints the assigned name plus unread count on every prompt; chat
309
+ and prints the assigned name plus unread count at session start and on
310
+ every prompt; chat
261
311
  tools take that name as `as`. Wake-up delivery remains opencode-only: no
262
312
  MCP server can start a turn in the client's conversation.
263
313
 
@@ -278,53 +328,86 @@ inbox, and MCP hosts cannot hold connections across turns, so both
278
328
  degenerate to the turn-granularity polling the tools already provide. The
279
329
  wake analog is the existing hook channel.
280
330
 
281
- | Host | Send/receive | New-mail signal | Echo bubbles |
282
- |-------------|--------------|------------------------------|--------------|
283
- | opencode | yes | promptAsync wake (idle-gated)| yes |
284
- | claude code | yes | flush-tools hook line | no |
285
- | cursor | yes | flush-tools hook line | no |
331
+ | Host | Send/receive | New-mail signal | Echo bubbles |
332
+ |-------------|--------------|--------------------------------------------------------|--------------|
333
+ | opencode | yes | promptAsync wake (idle-gated) | yes |
334
+ | claude code | yes | Stop-hook reminder at turn end + flush-tools hook line | no |
335
+ | cursor | yes | flush-tools hook line + chat-notify follow-up at turn end | no |
286
336
 
287
337
  MCP identity is anchored by the host hook: `chatHookLine(sessionID)` in
288
- `bin/thatch` ensure-registers `mcp_<sha256(host session id)[0:12]>` on
289
- every prompt and prints the assigned name plus unread count, so the
338
+ `bin/thatch` ensure-registers `mcp_<sha256(host session id)[0:12]>` at
339
+ session start (the reminder reads the hook's stdin) and on every prompt,
340
+ and prints the assigned name plus unread count, so the
290
341
  identity is stable across the ephemeral conversations those harnesses run
291
342
  (and a conversation without the hook gets a fresh per-conversation
292
343
  identity from `chat_register`). `chat_status` is the quiet check
344
+
345
+ **Anti-spoofing layer (Claude Code).** MCP tool calls carry no session
346
+ context, so without help the caller-claimed `as` would be the only
347
+ identity source. On Claude Code the hooks record `(parent pid -> session)`
348
+ into `chat_host_pids` from payloads the model cannot influence, and the
349
+ MCP server - a child of the same Claude Code process - resolves its own
350
+ parent pid against that table (`chatDerivedIdentity` on CoreContext,
351
+ freshness-capped at 600s to bound pid reuse). A second model-proof layer
352
+ is `CLAUDE_CODE_SESSION_ID`, which the host sets in the stdio server's
353
+ environment at spawn. `resolveChatIdentity` priority: opencode host
354
+ context > ppid mapping > env id > claimed `as`. Cursor's hooks and MCP
355
+ servers share one workspace process (ambiguous ppid, no per-session env),
356
+ so Cursor keeps the claimed-`as` path, documented in the user doc's
357
+ security model.
358
+
359
+ **Continuation adoption (Claude Code).** Claude Code forks the session id
360
+ on resume - and has been observed to fork spontaneously (agent-team
361
+ conversion, self-update, config reload) - which would strand the old
362
+ identity and mailbox. When a hook registers an UNKNOWN session id,
363
+ `resolveRegisteredPredecessor` reads the hook's `transcript_path` and
364
+ scans sibling transcripts' tails for a `continued-in` record naming the
365
+ new id (the chain walk adopts the nearest registered ancestor, bounded at
366
+ 10 hops); `ChatStore.continueSession` then migrates the old row's key to
367
+ the new session id - name, mail (sender and recipient columns),
368
+ host-pid anchors, and leave tombstones all follow.
369
+
370
+ `chat_status` is the quiet check
293
371
  (registered flag + pending/total); the flush-tools hook line names the
294
372
  caller's identity and mailbox, and is absent entirely when chat is
295
373
  disabled - absence is silent by construction.
296
- Staleness semantics differ by kind: an opencode row's stale age means the
297
- process is gone (broadcast skips it), while an mcp row's age only means
374
+ Staleness semantics differ by kind: an opencode row's stale age means its
375
+ harness stopped beating (broadcast skips it), while an mcp row's age only means
298
376
  "between turns" (broadcast always delivers).
299
377
 
300
378
  ## Defaults
301
379
 
302
380
  Timing constants live in `src/chat.ts`: poll interval 30s, staleness
303
- threshold 10 minutes, re-nudge window 15 minutes, nudge cap 6 per recipient
381
+ threshold two missed beats (60s), re-nudge window 15 minutes, nudge cap 6 per recipient
304
382
  per hour, auto-row TTL 7 days. There are no environment overrides yet; add
305
383
  them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
306
384
 
307
385
  ## Source files
308
386
 
309
387
  - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
310
- slugify, counter bump, insert, prune, topic refresh), ChatPoller
388
+ pool pick, slugify, counter bump, insert, prune, topic refresh), ChatPoller
311
389
  (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
312
390
  staleness helper
313
391
  - `src/chat-names.ts` - the static display-name pool (nomenclater style),
314
- the fallback base for title-less registrations
392
+ the base for every assigned name
315
393
  - `src/db.ts` - the chat tables in schema init (including
316
394
  chat_name_counters and the auto column), the NOCASE collation migration
317
395
  and topic column migration, delegated methods
318
396
  - `src/tool-defs.ts` - the chat tool definitions
319
- - `src/index.ts` - poller construction, delivery closure, idle
397
+ - `src/index.ts` - poller construction, delivery closure, `-s` startup
398
+ registration (`osProcessArgs`, `startupSessionId`), idle
320
399
  auto-registration + topic refresh, idle flush,
321
400
  session.deleted unregister, dispose, transcript echo in
322
401
  tool.execute.after
402
+ - `src/debug.ts` - the `THATCH_DEBUG` diagnostic log; `chat:startup` is
403
+ the chat consumer
323
404
  - `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
324
405
  `isChatEchoParts()`, system prompt Cross-Session Chat section, MCP
325
406
  absent-tools note
326
- - `tests/chat.test.ts` - store, pool, migration, echo-text, and poller unit
327
- tests (temp-dir SQLite, injected delivery)
407
+ - `tests/chat.test.ts` - store, pool, migration, echo-text, liveness, tail
408
+ diff/backlog, and poller unit tests (temp-dir SQLite, injected delivery)
328
409
  - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
410
+ - `tests/qa/auto/uc-099-chat-cli.ts` - `chat list` roster and `chat tail`
411
+ JSONL contract over a seeded DB
329
412
  - `tests/qa/live/uc-098-chat-cross-session.ts` - two real sessions exchange
330
413
  a message through the shared DB
@@ -224,8 +224,11 @@ chat_sessions(
224
224
  name TEXT NOT NULL UNIQUE COLLATE NOCASE,
225
225
  topic TEXT,
226
226
  project TEXT,
227
+ host_kind TEXT NOT NULL DEFAULT 'opencode',
227
228
  registered_at TEXT NOT NULL,
228
- last_seen TEXT NOT NULL
229
+ last_seen TEXT NOT NULL,
230
+ auto INTEGER NOT NULL DEFAULT 0,
231
+ worktree TEXT NOT NULL DEFAULT ''
229
232
  )
230
233
  ```
231
234
 
@@ -252,8 +255,9 @@ chat_messages(
252
255
 
253
256
  - `delivered_at` is the last wake-prompt stamp (restamped on re-nudges);
254
257
  `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
258
+ marks rows a chat_broadcast fan-out created (the CLI tail's `sent`
259
+ events carry it as `broadcast: true` beside the real recipient). The
260
+ endpoints are
257
261
  plain columns, not foreign keys: unregistering a session must not be
258
262
  blocked by message history, and a departed sender degrades to an unknown
259
263
  name in the reader's view.
@@ -197,6 +197,11 @@ continues working while the child extracts.
197
197
  - **opencode**: peeks accepted + pending interactions, builds JSON via
198
198
  `buildExtractionPayload`
199
199
  - **MCP**: peeks the file-backed queue, builds the same JSON payload
200
+ - `session_id` is optional: omitted, it resolves to the invoking session
201
+ via `HostToolContext.sessionID` (opencode path). On MCP hosts there is no
202
+ session context, so an omitted `session_id` returns a "pass the parent
203
+ session's session_id" error. Explicit IDs always win - that is how a
204
+ sub-agent drains the parent's queue
200
205
  - Returns `null` when no interactions are queued
201
206
  - Read-only — it peeks the queue; it does not consume it. Consumption is
202
207
  `extraction_done`'s job
@@ -190,7 +190,7 @@ behavior, with some features having no MCP counterpart.
190
190
  | `get_session_info` (session identity) | Yes | No | No |
191
191
  | `session_search` / `session_get` (conversation archaeology) | Yes (tools) | CLI only (`thatch session ...`) | CLI only (`thatch session ...`) |
192
192
  | `watch_*` (PR, branch, and command watchers) | Yes (tools) | No | No |
193
- | `chat_*` (cross-session chat) | Yes | No | No |
193
+ | `chat_*` (cross-session chat) | Yes (wake on delivery) | Yes (mail at prompt time) | Yes (mail at prompt time) |
194
194
 
195
195
  For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
196
196
 
@@ -63,7 +63,7 @@ When a parent session (no parentID) goes idle:
63
63
 
64
64
  ### Wrap-up commands (`/thatch/compact`, `/thatch/exit`)
65
65
 
66
- User-invoked slash commands, shipped as command markdown synced by the plugin (see `src/commands.ts`). The template instructs the model to flush pending fact extraction (`thatch_get_extraction_payload` + `thatch_extraction_done`), finish promised memory writes, and surface unaddressed todos -- then end its response with a greenlight token (`THATCH_COMPACT_READY` / `THATCH_EXIT_READY`) only when safe to proceed. Text typed after the command is forwarded into the prompt: the templates substitute opencode's `$ARGUMENTS` ahead of the checklist, so `/thatch/exit nice work` reads as "nice work" followed by the wrap-up instructions.
66
+ User-invoked slash commands, shipped as command markdown synced by the plugin (see `src/commands.ts`). The template instructs the model to flush pending fact extraction (`thatch_get_extraction_payload` + `thatch_extraction_done`), finish promised memory writes, and surface unaddressed todos -- then end its response with a greenlight token (`THATCH_COMPACT_READY` / `THATCH_EXIT_READY`) only when safe to proceed. Text typed after the command is forwarded into a labeled `# User Message` section ahead of the `# Pre-*-wrap-up` checklist section (opencode's `$ARGUMENTS` substitution): the headers give the user's words provenance, so they cannot be misread as part of the wrap-up instructions. opencode's substitution is purely mechanical with no default-value form (verified against v1.18.30), so the template cannot switch on emptiness; instead the section always carries a fallback line telling the model to treat an empty section as n/a (bare command, no user text).
67
67
 
68
68
  1. `command.execute.before` arms the session in `pendingWrapUp` when the command runs
69
69
  2. On the session's next idle, the plugin fetches the session's messages via the SDK client and checks the final assistant message's trailing text for the token (trimmed, exact match)
@@ -146,6 +146,14 @@ here first. These are the things that have already cost time.
146
146
  - **The binary path is baked into installed hook commands.** `thatch setup`
147
147
  resolves `<bin>` from PATH (or the script's absolute path) and writes it into
148
148
  every hook command, so hooks survive after the setup session ends.
149
+ - **Tool-arg optionality cannot be per-host at the schema level.** The zod
150
+ arg shape in `TOOL_DEFS` is shared by the opencode plugin and the MCP
151
+ server, so making an arg optional (e.g. `get_extraction_payload`'s
152
+ `session_id`) makes it optional everywhere. Per-host enforcement has to
153
+ happen in the tool's `execute`: fall back to `HostToolContext.sessionID`
154
+ on the opencode path, return a "pass the parent's session_id" error on
155
+ MCP hosts, which have no session context. See
156
+ [features/commands.md](features/commands.md).
149
157
 
150
158
  ## Tests
151
159
 
@@ -176,3 +184,15 @@ here first. These are the things that have already cost time.
176
184
  discovered by bun on their own. UC-095 sat out of the suite for a release
177
185
  cycle and rotted (asserted 3 watch tools when 4 existed) because nobody
178
186
  noticed it never ran.
187
+
188
+ ## Instruction-block markers must be sentinels, not prose
189
+
190
+ The thatch instructions block in `CLAUDE.md` / `AGENTS.md` is delimited by
191
+ `<!-- thatch:begin -->` / `<!-- thatch:end -->` (src/setup.ts). Before
192
+ September 2026 the delimiters were prose sentences from the instructions
193
+ themselves - and agents edit those files. An editing agent normalized
194
+ punctuation (hyphens to em dashes) in both of Jeff's instruction files, the
195
+ prose end marker stopped matching, and setup could never update the block
196
+ again while `checkSetup` reported `markers-broken` on every session. Rule:
197
+ never use file content as its own delimiter; agents will reword it. The
198
+ legacy-prose detection constants exist only to migrate old installs.
@@ -23,6 +23,7 @@ This document maps the feature parity and documents the remaining gaps.
23
23
  | **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
25
25
  | **Wrap-up commands** (`/thatch/compact`, `/thatch/exit`) | Command markdown synced to `$XDG_CONFIG_HOME/opencode/command/thatch/` at plugin init; `command.execute.before` arms the session and the next idle resolves the greenlight token into a TUI action (`session.compact` via `executeCommand`, `app.exit` via `publish`) | No equivalent — no plugin host to arm the check or trigger the action | No equivalent | **Gapped** — needs the plugin's SDK client and TUI control routes |
26
+ | **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
26
27
  | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-thatch, non-skill, non-task tool calls in-process; **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
27
28
  | **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
28
29
  | **Prompt-aware recall nudge** | `chat.message` hook embeds the prompt with the in-process warm model, searches `db.search()`, pushes a nudge part if matches ≥ threshold | `UserPromptSubmit` hook → `thatch flush-tools` connects to the MCP server's sideband socket; the warm server embeds + searches; hook prints the nudge or falls back to the write nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — sideband socket gives cold hook processes access to the warm MCP server's model |
@@ -144,6 +145,7 @@ Claude Code / Cursor session
144
145
  ├── PostToolBatch → thatch buffer-batch (Claude Code)
145
146
  │ └── postToolUse → thatch buffer-tool (Cursor)
146
147
  └── SessionStart / sessionStart hook → thatch reminder [--json]
148
+ └── reads the hook's stdin session id: chat identity + mail line
147
149
  ```
148
150
 
149
151
  The socket path is derived from a SHA-256 hash of the DB path — both the MCP
@@ -50,10 +50,12 @@ the TUI is not connected (headless mode).
50
50
  | Instructions | `CLAUDE.md` (idempotent `appendBlock`) | `$CLAUDE_CONFIG_DIR/CLAUDE.md` |
51
51
  | Hooks | `.claude/settings.json` | `$CLAUDE_CONFIG_DIR/settings.json` |
52
52
  | Skills | `.claude/skills/` (in the repo) | `$CLAUDE_CONFIG_DIR/skills/` |
53
+ | Commands | `.claude/commands/thatch/` (in the repo) | `$CLAUDE_CONFIG_DIR/commands/thatch/` |
53
54
 
54
55
  `CLAUDE_CONFIG_DIR` overrides the default `~/.claude` for all user-scoped
55
56
  paths. Project-local keeps everything in the repo: `.mcp.json`, `CLAUDE.md`,
56
- `.claude/settings.json`, and `.claude/skills/` (so skills version with the
57
+ `.claude/settings.json`, `.claude/skills/`, and `.claude/commands/thatch/`
58
+ (so skills and commands version with the
57
59
  project and every contributor gets them).
58
60
 
59
61
  ### Hook events (nested `settings.json`)
@@ -63,16 +65,18 @@ project and every contributor gets them).
63
65
  "hooks": {
64
66
  "SessionStart": { "hooks": [{ "type": "command", "command": "<bin> reminder" }] },
65
67
  "PostToolBatch": { "hooks": [{ "type": "command", "command": "<bin> buffer-batch" }] },
66
- "UserPromptSubmit": { "hooks": [{ "type": "command", "command": "<bin> flush-tools" }] }
68
+ "UserPromptSubmit": { "hooks": [{ "type": "command", "command": "<bin> flush-tools" }] },
69
+ "Stop": { "hooks": [{ "type": "command", "command": "<bin> chat-notify" }] }
67
70
  }
68
71
  }
69
72
  ```
70
73
 
71
74
  | Event | Command | Output | Role |
72
75
  |-------|---------|--------|------|
73
- | `SessionStart` | `thatch reminder` | plain text to stdout (becomes context) | Recall instructions + hygiene heartbeat |
76
+ | `SessionStart` | `thatch reminder` | plain text to stdout (becomes context) | Recall instructions + hygiene heartbeat + chat identity/mail line (reads the hook's stdin `session_id`) |
74
77
  | `PostToolBatch` | `thatch buffer-batch` | **silent** (no stdout) | Appends a batch of tool calls to the file-backed JSONL queue |
75
78
  | `UserPromptSubmit` | `thatch flush-tools` | nudge text to stdout | Peeks queue (extraction nudge), else fires recall, prediction, and behavior nudges via sideband in parallel, else write nudge |
79
+ | `Stop` | `thatch chat-notify` | `{ hookSpecificOutput: { additionalContext } }` when chat mail is unread, else `{}` | Post-turn chat wake: the turn continues so the model reads its mail (Claude Code's analog of opencode's poller wake); host loop protections (`stop_hook_active`, 8-consecutive cap) plus the delivered stamp bound repeats |
76
80
 
77
81
  `PostToolBatch` is silent so the agent loop is not delayed; the buffered
78
82
  content is invisible until `UserPromptSubmit` peeks it.
@@ -103,16 +107,18 @@ is honored for symmetry and forward-compatibility. Cursor has no equivalent of
103
107
  "hooks": {
104
108
  "sessionStart": [{ "command": "<bin> reminder --json" }],
105
109
  "postToolUse": [{ "command": "<bin> buffer-tool" }],
106
- "beforeSubmitPrompt": [{ "command": "<bin> flush-tools --json" }]
110
+ "beforeSubmitPrompt": [{ "command": "<bin> flush-tools --json" }],
111
+ "stop": [{ "command": "<bin> chat-notify", "loop_limit": 3 }]
107
112
  }
108
113
  }
109
114
  ```
110
115
 
111
116
  | Event | Command | Output | Role |
112
117
  |-------|---------|--------|------|
113
- | `sessionStart` | `thatch reminder --json` | `{ additional_context: "..." }` | Recall + heartbeat, JSON-wrapped for Cursor |
118
+ | `sessionStart` | `thatch reminder --json` | `{ additional_context: "..." }` | Recall + heartbeat + chat identity/mail line, JSON-wrapped for Cursor |
114
119
  | `postToolUse` | `thatch buffer-tool` | **silent** | Appends a **single** tool call to the file-backed queue |
115
120
  | `beforeSubmitPrompt` | `thatch flush-tools --json` | JSON `additional_context` | Peeks queue, else recall, prediction, and behavior via sideband, else write nudge |
121
+ | `stop` | `thatch chat-notify` | `{ followup_message }` or `{}` | Post-turn chat wake: when chat mail is unread, Cursor auto-submits the follow-up as the next user message (Cursor's analog of opencode's poller wake); `loop_limit: 3` bounds consecutive follow-ups |
116
122
 
117
123
  Differences from Claude Code:
118
124
 
@@ -126,9 +132,15 @@ Differences from Claude Code:
126
132
 
127
133
  All setup operations are idempotent:
128
134
 
129
- - **Instructions**: `appendBlock` searches for start/end markers and replaces
130
- the block between them, so re-running setup updates drifted content without
131
- clobbering surrounding text. No marker found → append.
135
+ - **Instructions**: `appendBlock` wraps the block in sentinel comments
136
+ (`<!-- thatch:begin -->` / `<!-- thatch:end -->`) and replaces the block
137
+ between them, so re-running setup updates drifted content without
138
+ clobbering surrounding text. No marker found → append. Pre-sentinel
139
+ installs used prose sentences from the instructions themselves as
140
+ delimiters; `appendBlock` migrates intact legacy blocks and heals the
141
+ broken kind (an agent edit normalizing punctuation - hyphens to em
142
+ dashes - breaks the prose end marker) when the file tail still looks
143
+ like the instructions; anything else is left for manual repair.
132
144
  - **Hooks**: `replaceThatchHooks` / `replaceCursorThatchHooks` filter out any
133
145
  hook group whose command contains `thatch`, then add the current ones. A
134
146
  legacy `thatch echo` hook is replaced with `flush-tools`. Non-thatch hooks are