@jeffober/thatch 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,18 +46,25 @@ Inherits stdio, exits with the child's exit code. Errors and exits 1 if none fou
46
46
 
47
47
  Read-only window on the cross-session chat directory ([cross-session-chat.md](cross-session-chat.md)):
48
48
 
49
- - `chat list` renders the roster: display name, human-readable age since the
50
- last heartbeat, project, and topic when set.
51
- - `chat tail` follows the message stream as styled cards: label chips,
52
- colored names, dimmed timestamps, topic annotations after names, and a
53
- drawn rule between cards. The card renderer is `formatChatTailCard()`
54
- and the event shaping is `chatTailDiff()` in `src/chat.ts`, unit-tested
55
- there; the single-line `formatChatTailEvent()` remains for tests and
56
- compact contexts. `--once` prints one snapshot and exits, because a
57
- snapshot has no previous state to diff read events against.
58
- - The backlog renders only the last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages;
49
+ - `chat list` renders the roster as aligned columns under a header row
50
+ (NAME/AGE/STATUS/PROJECT/TOPIC), colorized only when stdout is a TTY
51
+ (`formatChatRoster()` in `bin/thatch`; pipes and QA runners get plain
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;
59
66
  `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
67
+ state from every feed row (so the limit hides lines, not history, and a
61
68
  mid-follow rename or unregister cannot resurface old rows as sent
62
69
  events), and reports the elided count for
63
70
  the CLI's stderr note. `filterChatTailRows()` ANDs body regexes
@@ -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
@@ -58,18 +59,47 @@ outlives a process, so liveness needs a signal beyond process lifetime.
58
59
  ### Registration and identity
59
60
 
60
61
  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
62
+ idle event for a top-level session inserts its directory row, and a
63
+ session continued via `-s <id>` registers at harness start (below);
64
+ both paths are skipped when `chat.autoRegister: false` or when the
65
+ session left explicitly (a leave tombstone, see below). Names are assigned, never
66
+ claimed: a pool draw (`CHAT_NAME_POOL`, src/chat-names.ts) plus a
67
+ per-base counter row (`chat_name_counters`, src/db.ts) that only ever
68
+ increments - drawn atomically via INSERT ... ON CONFLICT ... RETURNING.
69
+ Pool names are deliberately short: they appear in every wake prompt and
70
+ roster line, and the session's descriptive identity lives in the topic
71
+ column instead (the live session title, refreshed on every idle by
72
+ `refreshAutoTopic` - auto rows only; legacy rows keep their old model-set
73
+ topics; placeholder titles never become topics or names). A name is
68
74
  minted exactly once per machine: pruning an auto-registered row (host
69
75
  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.
76
+ never reissue its name, which is what makes the prune safe.
77
+ Registration also captures the checkout kind (`worktree` column):
78
+ `detectWorktreeKind` (src/git.ts) classifies the session's serving
79
+ directory once at registration - a `.git` file means a linked git
80
+ worktree, a `.git` directory means the project root, no `.git` reads as
81
+ undetected. The roster shows it as a `loc:` token so sessions coordinating
82
+ on a shared tree can tell who sits where.
83
+
84
+ ### Startup registration and reclaim
85
+
86
+ A session continued via `opencode -s <id>` gets no event when it comes
87
+ back up (resume writes nothing session-scoped), so the plugin reads the
88
+ session id from the harness's command line at init and registers it then.
89
+ The plugin does not see the CLI flags in `process.argv`: opencode hosts
90
+ the server in a worker thread whose argv is just the worker script. The
91
+ thread shares the process pid, so the plugin reads the OS-level command
92
+ line for its own pid instead (`/proc/self/cmdline` on Linux, `ps -o
93
+ args=` on macOS) and parses `-s`/`--session` from that.
94
+ Registration is keyed by session id: the row already exists, so the name
95
+ is RECLAIMED (names are owned by the session id, minted once, never
96
+ renamed) and the row's heartbeat is refreshed - hosting is the
97
+ heartbeat, so the serving harness beats and delivers for the row again. `-c/--continue` resolves no id on the
98
+ command line; that
99
+ session registers on its first idle like any other. Asleep-mail: the
100
+ startup path kicks a delivery pass when it finishes, so the continued
101
+ session learns what it missed at startup instead of waiting out the
102
+ first poll cycle.
73
103
 
74
104
  `chat_register` (no arguments) is the explicit path: idempotent ensure for
75
105
  opencode (identity is the host session ID, which the model cannot know or
@@ -82,7 +112,11 @@ mechanical barrier, a documented trust posture.
82
112
 
83
113
  ### Leave tombstones
84
114
 
85
- Every exit path - `chat_unregister` and `session.deleted` alike - writes a
115
+ Every exit path - `chat_unregister`, `session.deleted`, and a greenlit
116
+ `/thatch/exit` (the plugin unregisters the exiting session before
117
+ publishing `app.exit`, so the roster stops advertising a host that is
118
+ about to vanish; the exit template's checklist step asks the model to do
119
+ the same earlier) - writes a
86
120
  `chat_leave_tombstones` row in the same transaction that deletes the
87
121
  directory row. The tombstone is what makes a leave stick: both
88
122
  registration paths consult it (the plugin's idle auto-registerer is
@@ -136,12 +170,25 @@ behavior drastically on a parameter value is two functions.
136
170
 
137
171
  ### Polling, heartbeat, staleness
138
172
 
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.
173
+ A `setInterval` loop (default 30s) runs one cycle per process: heartbeat
174
+ the HOSTED sessions (those seen as events in this harness plus the `-s`
175
+ startup session - idle sessions keep beating, so a session stays fresh
176
+ while its harness lives), then deliver their mail. A crashed process
177
+ fires no `session.deleted`, so its rows would linger - staleness catches
178
+ them: a row that has missed two consecutive beats (`CHAT_STALE_MS`, 60s
179
+ at the default interval) is stale, meaning its harness stopped. Two beats
180
+ rather than one so a single late poll cycle does not flap the roster.
181
+ Heartbeat age is the ONLY liveness signal, by design. A host process id
182
+ cannot serve: the same session is re-hosted by a new process on every
183
+ `-s` resume, so a pid stamped on the row lies as soon as a different
184
+ harness beats it (it says dead while the heartbeat proves alive). `isStale`
185
+ (src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
186
+ and the broadcast skip all call it, so they cannot disagree. `chat_list`
187
+ groups the result into Active and Stale sections - the stale section's
188
+ explainer says what stale means - and `chat_send` states the recipient's
189
+ liveness at send time, so a sender mailing a ghost learns it immediately
190
+ instead of waiting on a wake that will never fire.
191
+ `session.deleted` is the graceful-exit fast path that unregisters immediately.
145
192
 
146
193
  ### Delivery, re-nudge, and the rate cap
147
194
 
@@ -208,18 +255,21 @@ injection-hygiene layers.
208
255
  ### CLI access
209
256
 
210
257
  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
258
+ renders the roster as aligned columns under a header row (TTY-gated
259
+ color; `formatChatRoster()` in `bin/thatch`), and `thatch chat tail`
260
+ follows the message stream as JSONL: one event per line, `sent` and
261
+ `read` as separate events linked by message id, bodies verbatim.
262
+ The backlog prints only the
213
263
  last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
214
264
  `filterChatTailRows()` narrows every feed snapshot (backlog and follow
215
265
  polls alike) by body regexes, participant-name substrings, and a
216
266
  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
267
+ in `tests/chat.test.ts`): sent events on first view, then new sends and
218
268
  newly-read messages per poll, with the diff state seeded from the full
219
269
  feed so neither the limit nor a mid-follow rename/unregister can
220
270
  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
271
+ fan-out rows so each copy's `sent` event carries `broadcast: true`
272
+ alongside its real recipient. The
223
273
  CLI takes no chat write actions (the wake machinery owns those paths);
224
274
  the only writes it can trigger are the schema migrations that run
225
275
  whenever any thatch process opens the database.
@@ -242,7 +292,8 @@ whenever any thatch process opens the database.
242
292
  - Multi-host ([multi-host.md](multi-host.md)): the chat tools are shared -
243
293
  MCP hosts get their identity from the thatch hook, which ensure-registers
244
294
  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
295
+ and prints the assigned name plus unread count at session start and on
296
+ every prompt; chat
246
297
  tools take that name as `as`. Wake-up delivery remains opencode-only: no
247
298
  MCP server can start a turn in the client's conversation.
248
299
 
@@ -263,53 +314,86 @@ inbox, and MCP hosts cannot hold connections across turns, so both
263
314
  degenerate to the turn-granularity polling the tools already provide. The
264
315
  wake analog is the existing hook channel.
265
316
 
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 |
317
+ | Host | Send/receive | New-mail signal | Echo bubbles |
318
+ |-------------|--------------|--------------------------------------------------------|--------------|
319
+ | opencode | yes | promptAsync wake (idle-gated) | yes |
320
+ | claude code | yes | Stop-hook reminder at turn end + flush-tools hook line | no |
321
+ | cursor | yes | flush-tools hook line + chat-notify follow-up at turn end | no |
271
322
 
272
323
  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
324
+ `bin/thatch` ensure-registers `mcp_<sha256(host session id)[0:12]>` at
325
+ session start (the reminder reads the hook's stdin) and on every prompt,
326
+ and prints the assigned name plus unread count, so the
275
327
  identity is stable across the ephemeral conversations those harnesses run
276
328
  (and a conversation without the hook gets a fresh per-conversation
277
329
  identity from `chat_register`). `chat_status` is the quiet check
330
+
331
+ **Anti-spoofing layer (Claude Code).** MCP tool calls carry no session
332
+ context, so without help the caller-claimed `as` would be the only
333
+ identity source. On Claude Code the hooks record `(parent pid -> session)`
334
+ into `chat_host_pids` from payloads the model cannot influence, and the
335
+ MCP server - a child of the same Claude Code process - resolves its own
336
+ parent pid against that table (`chatDerivedIdentity` on CoreContext,
337
+ freshness-capped at 600s to bound pid reuse). A second model-proof layer
338
+ is `CLAUDE_CODE_SESSION_ID`, which the host sets in the stdio server's
339
+ environment at spawn. `resolveChatIdentity` priority: opencode host
340
+ context > ppid mapping > env id > claimed `as`. Cursor's hooks and MCP
341
+ servers share one workspace process (ambiguous ppid, no per-session env),
342
+ so Cursor keeps the claimed-`as` path, documented in the user doc's
343
+ security model.
344
+
345
+ **Continuation adoption (Claude Code).** Claude Code forks the session id
346
+ on resume - and has been observed to fork spontaneously (agent-team
347
+ conversion, self-update, config reload) - which would strand the old
348
+ identity and mailbox. When a hook registers an UNKNOWN session id,
349
+ `resolveRegisteredPredecessor` reads the hook's `transcript_path` and
350
+ scans sibling transcripts' tails for a `continued-in` record naming the
351
+ new id (the chain walk adopts the nearest registered ancestor, bounded at
352
+ 10 hops); `ChatStore.continueSession` then migrates the old row's key to
353
+ the new session id - name, mail (sender and recipient columns),
354
+ host-pid anchors, and leave tombstones all follow.
355
+
356
+ `chat_status` is the quiet check
278
357
  (registered flag + pending/total); the flush-tools hook line names the
279
358
  caller's identity and mailbox, and is absent entirely when chat is
280
359
  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
360
+ Staleness semantics differ by kind: an opencode row's stale age means its
361
+ harness stopped beating (broadcast skips it), while an mcp row's age only means
283
362
  "between turns" (broadcast always delivers).
284
363
 
285
364
  ## Defaults
286
365
 
287
366
  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
367
+ threshold two missed beats (60s), re-nudge window 15 minutes, nudge cap 6 per recipient
289
368
  per hour, auto-row TTL 7 days. There are no environment overrides yet; add
290
369
  them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
291
370
 
292
371
  ## Source files
293
372
 
294
373
  - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
295
- slugify, counter bump, insert, prune, topic refresh), ChatPoller
374
+ pool pick, slugify, counter bump, insert, prune, topic refresh), ChatPoller
296
375
  (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
297
376
  staleness helper
298
377
  - `src/chat-names.ts` - the static display-name pool (nomenclater style),
299
- the fallback base for title-less registrations
378
+ the base for every assigned name
300
379
  - `src/db.ts` - the chat tables in schema init (including
301
380
  chat_name_counters and the auto column), the NOCASE collation migration
302
381
  and topic column migration, delegated methods
303
382
  - `src/tool-defs.ts` - the chat tool definitions
304
- - `src/index.ts` - poller construction, delivery closure, idle
383
+ - `src/index.ts` - poller construction, delivery closure, `-s` startup
384
+ registration (`osProcessArgs`, `startupSessionId`), idle
305
385
  auto-registration + topic refresh, idle flush,
306
386
  session.deleted unregister, dispose, transcript echo in
307
387
  tool.execute.after
388
+ - `src/debug.ts` - the `THATCH_DEBUG` diagnostic log; `chat:startup` is
389
+ the chat consumer
308
390
  - `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
309
391
  `isChatEchoParts()`, system prompt Cross-Session Chat section, MCP
310
392
  absent-tools note
311
- - `tests/chat.test.ts` - store, pool, migration, echo-text, and poller unit
312
- tests (temp-dir SQLite, injected delivery)
393
+ - `tests/chat.test.ts` - store, pool, migration, echo-text, liveness, tail
394
+ diff/backlog, and poller unit tests (temp-dir SQLite, injected delivery)
313
395
  - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
396
+ - `tests/qa/auto/uc-099-chat-cli.ts` - `chat list` roster and `chat tail`
397
+ JSONL contract over a seeded DB
314
398
  - `tests/qa/live/uc-098-chat-cross-session.ts` - two real sessions exchange
315
399
  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.
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  # Session Lifecycle Management (opencode)
2
2
 
3
- opencode emits bus events for session lifecycle changes. Thatch subscribes to these events to manage the extraction pipeline, send session-start reminders, and handle child session cleanup.
3
+ opencode emits bus events for session lifecycle changes. Thatch subscribes to these events to manage the extraction pipeline, send session-start reminders, handle child session cleanup, and resolve wrap-up commands.
4
4
 
5
5
  ## What it does
6
6
 
@@ -11,6 +11,7 @@ opencode emits bus events for session lifecycle changes. Thatch subscribes to th
11
11
  - Sub-agent lifecycle: task-dispatched sub-agents complete accepted entries but are not deleted
12
12
  - Session error recovery: child errors requeue parent's accepted entries
13
13
  - Session deletion recovery: child deletion requeues; parent deletion completes accepted entries
14
+ - Wrap-up commands: `/thatch/compact` and `/thatch/exit` arm a greenlight check resolved on the next idle
14
15
 
15
16
  ## How it works
16
17
 
@@ -56,9 +57,29 @@ When a task-dispatched sub-agent (not created by `triggerExtraction`) goes idle:
56
57
 
57
58
  When a parent session (no parentID) goes idle:
58
59
 
60
+ - Wrap-up resolution first (below): a pending `/thatch/compact` or `/thatch/exit` resolves here, and a greenlit action returns early
59
61
  - If not compacting, not already extracting, and buffer has pending interactions: `triggerExtraction(sessionID)`
60
62
  - On failure: log error, clear `extracting` flag (nudge path takes over as fallback on next chat.message)
61
63
 
64
+ ### Wrap-up commands (`/thatch/compact`, `/thatch/exit`)
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 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
+
68
+ 1. `command.execute.before` arms the session in `pendingWrapUp` when the command runs
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)
70
+ 3. Token present: trigger the TUI action and return early -- compaction is starting (the checklist drained the buffer) or the process is exiting
71
+ - compact: `client.tui.executeCommand({ body: { command: "session_compact" } })`. The execute-command route only accepts legacy alias names; `session_compact` maps to the TUI's `session.compact` action, the same thing the built-in `/compact` runs
72
+ - exit: unregister the session from the chat directory first (a greenlit exit's host is about to vanish; other sessions must stop addressing mail to it), then `client.tui.publish({ body: { type: "tui.command.execute", properties: { command: "app.exit" } } })`. No exit alias exists, so the TUI keymap command is published directly. The exit template's checklist also asks the model to call `thatch_chat_unregister` as its last persistence step; the plugin-side unregister is the deterministic backstop. Compaction deliberately does NOT unregister - the session continues
73
+ 4. Token absent: warning toast pointing at the blockers in the response, then fall through -- the model may have buffered tool interactions that still need extraction
74
+
75
+ ### Command file install
76
+
77
+ `installOpencodeCommands` syncs the command markdown into
78
+ `$XDG_CONFIG_HOME/opencode/command/thatch/` on every plugin load, writing only
79
+ files whose on-disk content differs (template updates self-heal). opencode
80
+ loads config -- including command discovery -- before plugins, so a
81
+ first-ever install is invisible until the next server start.
82
+
62
83
  ### session.error (child session)
63
84
 
64
85
  - `requeueAccepted(parentID)` -- move parent's accepted entries back to pending (the extractor never processed them)
@@ -73,6 +94,7 @@ When a parent session (no parentID) goes idle:
73
94
 
74
95
  - `completeAccepted(id)` -- a deleted parent takes its accepted entries with it
75
96
  - Clear `extracting` for the parent
97
+ - Clear `pendingWrapUp` for the parent
76
98
 
77
99
  ### session.compacted
78
100
 
@@ -87,6 +109,7 @@ When a parent session (no parentID) goes idle:
87
109
  - **extracting**: `Set<parentID>` -- parent IDs with an active direct-extraction child (suppresses nudge)
88
110
  - **extractionChildren**: `Set<childID>` -- distinguishes extraction children from task-dispatched sub-agents
89
111
  - **compacting**: `Set<sessionID>` -- sessions currently being compacted (suppresses nudges)
112
+ - **pendingWrapUp**: `Map<sessionID, {token, kind}>` -- wrap-up commands awaiting their greenlight check; armed by `command.execute.before`, resolved on the next idle, cleared on session deletion
90
113
  - **missedNudges**: `Map<sessionID, number>` -- extraction nudge escalation counter
91
114
 
92
115
  ## Interactions with other features
@@ -51,6 +51,15 @@ here first. These are the things that have already cost time.
51
51
  the hook object returned by `server`. Moving it into the `event` handler
52
52
  (where `session.created` lives) silently never fires it — the event bus has
53
53
  no such event. This was dead for weeks because the failure was invisible.
54
+ - **`client.tui.executeCommand` only accepts legacy alias names**
55
+ (`session_compact`, `agent_cycle`, ...). Unknown commands publish
56
+ `{command: undefined}` and silently no-op — no error surfaces. For TUI
57
+ commands without an alias (e.g. `app.exit`), publish
58
+ `tui.command.execute` directly via `client.tui.publish`.
59
+ - **Command markdown written by the plugin is invisible until the next server
60
+ start.** opencode loads config (including command discovery) before
61
+ plugins, so `installOpencodeCommands` self-heals on every load but a
62
+ first-ever install only lands after a restart.
54
63
  - **`tool.execute.after` excludes `skill` and `task` tools**, not just
55
64
  `thatch_*`. Buffering them creates a feedback loop: the nudge triggers a
56
65
  skill load, which gets buffered, which triggers another nudge on the next
@@ -167,3 +176,15 @@ here first. These are the things that have already cost time.
167
176
  discovered by bun on their own. UC-095 sat out of the suite for a release
168
177
  cycle and rotted (asserted 3 watch tools when 4 existed) because nobody
169
178
  noticed it never ran.
179
+
180
+ ## Instruction-block markers must be sentinels, not prose
181
+
182
+ The thatch instructions block in `CLAUDE.md` / `AGENTS.md` is delimited by
183
+ `<!-- thatch:begin -->` / `<!-- thatch:end -->` (src/setup.ts). Before
184
+ September 2026 the delimiters were prose sentences from the instructions
185
+ themselves - and agents edit those files. An editing agent normalized
186
+ punctuation (hyphens to em dashes) in both of Jeff's instruction files, the
187
+ prose end marker stopped matching, and setup could never update the block
188
+ again while `checkSetup` reported `markers-broken` on every session. Rule:
189
+ never use file content as its own delimiter; agents will reword it. The
190
+ legacy-prose detection constants exist only to migrate old installs.
@@ -22,6 +22,7 @@ This document maps the feature parity and documents the remaining gaps.
22
22
  | **System prompt** (store names, usage rules) | `experimental.chat.system.transform` — dynamic, repo baked in at runtime | CLAUDE.md static text appended by `thatch setup`; repo auto-detected by the MCP server at startup | AGENTS.md static text appended by `thatch setup`; repo auto-detected at startup | **Approximate** — static text persists through compaction, so the agent retains the usage instructions, but the dynamic per-turn refresh is lost |
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
+ | **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 |
25
26
  | **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 |
26
27
  | **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 |
27
28
  | **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 |
@@ -143,6 +144,7 @@ Claude Code / Cursor session
143
144
  ├── PostToolBatch → thatch buffer-batch (Claude Code)
144
145
  │ └── postToolUse → thatch buffer-tool (Cursor)
145
146
  └── SessionStart / sessionStart hook → thatch reminder [--json]
147
+ └── reads the hook's stdin session id: chat identity + mail line
146
148
  ```
147
149
 
148
150
  The socket path is derived from a SHA-256 hash of the DB path — both the MCP
@@ -63,16 +63,18 @@ project and every contributor gets them).
63
63
  "hooks": {
64
64
  "SessionStart": { "hooks": [{ "type": "command", "command": "<bin> reminder" }] },
65
65
  "PostToolBatch": { "hooks": [{ "type": "command", "command": "<bin> buffer-batch" }] },
66
- "UserPromptSubmit": { "hooks": [{ "type": "command", "command": "<bin> flush-tools" }] }
66
+ "UserPromptSubmit": { "hooks": [{ "type": "command", "command": "<bin> flush-tools" }] },
67
+ "Stop": { "hooks": [{ "type": "command", "command": "<bin> chat-notify" }] }
67
68
  }
68
69
  }
69
70
  ```
70
71
 
71
72
  | Event | Command | Output | Role |
72
73
  |-------|---------|--------|------|
73
- | `SessionStart` | `thatch reminder` | plain text to stdout (becomes context) | Recall instructions + hygiene heartbeat |
74
+ | `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
75
  | `PostToolBatch` | `thatch buffer-batch` | **silent** (no stdout) | Appends a batch of tool calls to the file-backed JSONL queue |
75
76
  | `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 |
77
+ | `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
78
 
77
79
  `PostToolBatch` is silent so the agent loop is not delayed; the buffered
78
80
  content is invisible until `UserPromptSubmit` peeks it.
@@ -103,16 +105,18 @@ is honored for symmetry and forward-compatibility. Cursor has no equivalent of
103
105
  "hooks": {
104
106
  "sessionStart": [{ "command": "<bin> reminder --json" }],
105
107
  "postToolUse": [{ "command": "<bin> buffer-tool" }],
106
- "beforeSubmitPrompt": [{ "command": "<bin> flush-tools --json" }]
108
+ "beforeSubmitPrompt": [{ "command": "<bin> flush-tools --json" }],
109
+ "stop": [{ "command": "<bin> chat-notify", "loop_limit": 3 }]
107
110
  }
108
111
  }
109
112
  ```
110
113
 
111
114
  | Event | Command | Output | Role |
112
115
  |-------|---------|--------|------|
113
- | `sessionStart` | `thatch reminder --json` | `{ additional_context: "..." }` | Recall + heartbeat, JSON-wrapped for Cursor |
116
+ | `sessionStart` | `thatch reminder --json` | `{ additional_context: "..." }` | Recall + heartbeat + chat identity/mail line, JSON-wrapped for Cursor |
114
117
  | `postToolUse` | `thatch buffer-tool` | **silent** | Appends a **single** tool call to the file-backed queue |
115
118
  | `beforeSubmitPrompt` | `thatch flush-tools --json` | JSON `additional_context` | Peeks queue, else recall, prediction, and behavior via sideband, else write nudge |
119
+ | `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
120
 
117
121
  Differences from Claude Code:
118
122
 
@@ -126,9 +130,15 @@ Differences from Claude Code:
126
130
 
127
131
  All setup operations are idempotent:
128
132
 
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.
133
+ - **Instructions**: `appendBlock` wraps the block in sentinel comments
134
+ (`<!-- thatch:begin -->` / `<!-- thatch:end -->`) and replaces the block
135
+ between them, so re-running setup updates drifted content without
136
+ clobbering surrounding text. No marker found → append. Pre-sentinel
137
+ installs used prose sentences from the instructions themselves as
138
+ delimiters; `appendBlock` migrates intact legacy blocks and heals the
139
+ broken kind (an agent edit normalizing punctuation - hyphens to em
140
+ dashes - breaks the prose end marker) when the file tail still looks
141
+ like the instructions; anything else is left for manual repair.
132
142
  - **Hooks**: `replaceThatchHooks` / `replaceCursorThatchHooks` filter out any
133
143
  hook group whose command contains `thatch`, then add the current ones. A
134
144
  legacy `thatch echo` hook is replaced with `flush-tools`. Non-thatch hooks are
@@ -61,6 +61,7 @@ description: Extract durable project facts ... Use when ...
61
61
  | `thatch-review-response` | Author-side review response: triage findings, fix bugs one by one, reply on each thread, post a top-level summary comment. |
62
62
  | `thatch-memory-verify` | Fact-check a single memory against the current codebase and correct stale claims. Uses git archaeology to preserve historical context when changes were intentional. |
63
63
  | `thatch-knowledge-export` | Compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories via `thatch-memory-verify`. |
64
+ | `thatch-clear-writing` | Prose rules for any human-facing text not covered by a dedicated writing skill: PR and ticket comments, documentation, plans, reports, and chat replies. Clarity over compression; dedicated skills win when loaded. |
64
65
 
65
66
  **opencode-only** — the coordinator needs sub-agent support:
66
67