@jeffober/thatch 0.1.41 → 0.1.43

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.
@@ -25,7 +25,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
25
25
  | [deduplication.md](deduplication.md) | Deduplication system (find pairs, verdict lifecycle) |
26
26
  | [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
27
27
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
28
- | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking) |
28
+ | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking, wrap-up commands) |
29
29
  | [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
30
30
  | [cross-session-chat.md](cross-session-chat.md) | Cross-session chat (shared SQLite directory and inbox, per-process wake-up delivery) |
31
31
  | [notifications.md](notifications.md) | Out-of-band notification (banner + voice) and the user config file |
@@ -22,7 +22,7 @@ The thatch CLI is a Bun script at `bin/thatch`. It provides memory inspection, M
22
22
  | `setup --claude [--cursor] [--global]` | none | `--claude`, `--cursor`, `--global` | none | Install config + instructions + hooks + skills |
23
23
  | `session list/get/transcript/search` | per subcommand | `-s/--session`, `--id`, `--after`, `--before`, `--regex`, `--limit` | none | Read-only archaeology on the opencode session database (JSONL output) |
24
24
  | `chat list` | none | none | none | Registered chat sessions: name, human-readable age, project, topic |
25
- | `chat tail [--once]` | none | `--once` | none | Follow cross-session chat: sent and read events (`--once` prints the backlog and exits) |
25
+ | `chat tail` | none | `--once`, `--limit N\|all`, `--match RE` (repeatable), `--from NAME`, `--to NAME`, `--since DT`, `--until DT` | none | Follow cross-session chat: sent and read events. Backlog defaults to the last 20 messages; filters apply to follow mode too. |
26
26
  | (unknown) | none | none | none | Print usage, exit 1 |
27
27
 
28
28
  ## Global behavior
@@ -46,16 +46,36 @@ 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 [--once]` follows the message stream. Sent lines have the shape
52
- `[timestamp] from -> to: body` (broadcast rows render `broadcast` via the
53
- `via_broadcast` marker); in follow mode, an inbox drain emits a read line
54
- (`[timestamp] ${reader} read a message from ${sender}: preview` — the
55
- reader is the recipient draining its inbox). The diff logic is
56
- `chatTailDiff()`/`formatChatTailEvent()` in `src/chat.ts`, unit-tested
57
- there; `--once` prints one snapshot and exits, because a snapshot has no
58
- previous state to diff read events against.
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). 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;
68
+ `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
70
+ mid-follow rename or unregister cannot resurface old rows as sent
71
+ events), and reports the elided count for
72
+ the CLI's stderr note. `filterChatTailRows()` ANDs body regexes
73
+ (`--match`, repeatable), rendered-name substrings (`--from`/`--to`,
74
+ case-insensitive), and a half-open `--since`/`--until` window on
75
+ `created_at`; the same filter applies to follow polls, so non-matching
76
+ messages stay invisible (their read events too). A `--until` in the past
77
+ exits after the backlog; one in the future follows until the window
78
+ closes. `parseChatTimeBound()` accepts `YYYY-MM-DD [HH:MM]` local time.
59
79
 
60
80
  ## Environment variables
61
81
 
@@ -1,11 +1,11 @@
1
1
  # Cross-Session Chat
2
2
 
3
3
  Cross-session chat lets independent opencode sessions on one machine message
4
- each other through thatch: a session registers in a shared directory, other
5
- sessions see it in the list, and messages land in its inbox. An idle
6
- recipient is woken with a prompt when mail arrives, so coordination does not
7
- route through the human ("ask the other session whether the release is
8
- green" instead of "Jeff, go ask").
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
9
 
10
10
  User-facing behavior is documented in
11
11
  [docs/user/cross-session-chat.md](../../user/cross-session-chat.md).
@@ -14,8 +14,11 @@ User-facing behavior is documented in
14
14
 
15
15
  - `thatch_chat_register` / `thatch_chat_list` / `thatch_chat_send` /
16
16
  `thatch_chat_read` / `thatch_chat_unregister` / `thatch_chat_broadcast`
17
- tools (shared across hosts; opencode injects identity, MCP hosts
18
- self-declare with the as argument)
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)
19
22
  - A shared SQLite directory and inbox in thatch.db, writable by any opencode
20
23
  process on the machine
21
24
  - A per-process poller that heartbeats hosted sessions and delivers wake
@@ -54,40 +57,73 @@ outlives a process, so liveness needs a signal beyond process lifetime.
54
57
 
55
58
  ### Registration and identity
56
59
 
57
- `chat_register` joins the directory. Without a name it draws one at random
58
- from the built-in pool (`src/chat-names.ts`): whimsical geek-culture names
59
- in the style of fnord's Nomenclater, statically baked in so assignment never
60
- costs a model call. Pool draws redraw on the rare collision a concurrent
61
- registration can cause, and skip any name a session already claimed by
62
- custom registration - so the pool is the recommended path. With a name,
63
- the session claims it as a custom name -
64
- uniqueness is case-insensitive ("Landru" and "landru" are one name), so two
65
- visually identical identities cannot coexist, and message addressing follows
66
- the same rule. Databases created before the case-insensitive constraint are
67
- rebuilt at schema init (first row wins per case group; message history
68
- survives because the endpoints are not foreign keys).
69
-
70
- Identity is the host session ID, which the model cannot know or forge.
71
- Re-registering renames. Unregistered sessions are invisible and
72
- unmessageable in both directions. Only top-level sessions should register -
73
- sub-agent children are ephemeral - which the tool descriptions and system
74
- prompt state; there is no mechanical barrier, a documented trust posture.
75
-
76
- Names are claimable by any session (no impersonation defense). The trust
77
- model is the same as the shared memory stores: every agent on this machine
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
+ Registration also captures the checkout kind (`worktree` column):
74
+ `detectWorktreeKind` (src/git.ts) classifies the session's serving
75
+ directory once at registration - a `.git` file means a linked git
76
+ worktree, a `.git` directory means the project root, no `.git` reads as
77
+ undetected. The roster shows it as a `loc:` token so sessions coordinating
78
+ on a shared tree can tell who sits where.
79
+
80
+ `chat_register` (no arguments) is the explicit path: idempotent ensure for
81
+ opencode (identity is the host session ID, which the model cannot know or
82
+ forge), a fresh identity per conversation on MCP hosts, or a reclaim of an
83
+ existing name via `as`. Re-registering keeps the name. Unregistered
84
+ sessions are invisible and unmessageable in both directions. Only
85
+ top-level sessions should register - sub-agent children are ephemeral -
86
+ which the tool descriptions and system prompt state; there is no
87
+ mechanical barrier, a documented trust posture.
88
+
89
+ ### Leave tombstones
90
+
91
+ Every exit path - `chat_unregister`, `session.deleted`, and a greenlit
92
+ `/thatch/exit` (the plugin unregisters the exiting session before
93
+ publishing `app.exit`, so the roster stops advertising a host that is
94
+ about to vanish; the exit template's checklist step asks the model to do
95
+ the same earlier) - writes a
96
+ `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
99
+ suppressed outright; the MCP hook's ensure-register prints the leave line
100
+ instead of rejoining), so a leave cannot be silently undone by the next
101
+ idle moment. The gate lives in `ChatStore.register` via `#tombstoneGate`
102
+ (src/chat.ts), which also closes the in-flight race: an auto-register
103
+ IIFE whose session was deleted during its title-fetch await finds the
104
+ tombstone and stops instead of resurrecting a dead session. An explicit
105
+ `chat_register` clears the tombstone before re-registering (fresh name -
106
+ assigned identities are never reused), and tombstones age out in the
107
+ hourly prune (7-day TTL; a resumed session after expiry simply
108
+ auto-registers under a fresh name).
109
+
110
+ Because names are system-minted and never recycled, the impersonation
111
+ surface shrinks to whatever the operator runs: every agent on this machine
78
112
  is the operator's agent.
79
113
 
80
114
  ### Topics
81
115
 
82
- The optional topic is the roster's "who is working on what" signal: with a
83
- dozen registered sessions, a name like "Kurn the Typechecker" says nothing,
84
- but `topic:QAing the release` in `chat_list` tells a coordinator which
85
- session to address. Topics are free text, sanitized to one roster line
116
+ The topic is the roster's "who is working on what" signal. For
117
+ auto-registered sessions it is the live session title, refreshed on every
118
+ idle event - the auto-titler usually lands a real title within a turn or
119
+ two, so the roster converges to the actual work with nobody filling in
120
+ forms. Legacy rows (from the claimed-names era) keep whatever topic was
121
+ set at their registration: free text, sanitized to one roster line
86
122
  (whitespace runs collapse, 80-character cap, advisory - oversizing degrades
87
- rather than errors). The null/empty distinction is deliberate: omitting the
88
- topic on re-register keeps the existing one; an empty topic clears it.
89
- Broadcast senders benefit most: `chat_broadcast` answers "which of you is
90
- working on X?" with the roster already in hand.
123
+ rather than errors). There is no tool path to set a topic anymore - topics
124
+ are title-derived for everything the current code mints. Broadcast senders
125
+ benefit most: `chat_broadcast` answers "which of you is working on X?"
126
+ with the roster already in hand.
91
127
 
92
128
  ### Messages
93
129
 
@@ -114,7 +150,9 @@ A `setInterval` loop (default 30s) runs one cycle per process: heartbeat the
114
150
  hosted sessions' `last_seen`, then deliver. A crashed process fires no
115
151
  `session.deleted`, so `chat_list` marks sessions whose `last_seen` is older
116
152
  than the staleness threshold (default 10 minutes) as stale rather than
117
- hiding them - the operator decides what to do with ghosts. `session.deleted`
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`
118
156
  is the graceful-exit fast path that unregisters immediately.
119
157
 
120
158
  ### Delivery, re-nudge, and the rate cap
@@ -167,19 +205,39 @@ turn reads. The accepted edge: a real user message whose every visible
167
205
  part starts with the prefix is skipped the same way, losing that one turn's
168
206
  advisory nudges.
169
207
 
208
+ ### The untrusted-content frame
209
+
210
+ Message bodies are other agents' text delivered verbatim into the reading
211
+ model's context - a prompt-injection surface by construction. The watcher
212
+ privacy model solves the same surface with pointer-only notifications, but
213
+ chat's payload IS the body, so `chat_read` frames the boundary instead:
214
+ `chatInboxFrame()` (src/prompts.ts) wraps the message lines in
215
+ begin/end fences with an explicit do-not-follow warning. The frame exists only in
216
+ the tool output - persisted rows are untouched - and pairs with the wake
217
+ nudge's anti-loop rule and the isChatEchoParts skip as the feature's three
218
+ injection-hygiene layers.
219
+
170
220
  ### CLI access
171
221
 
172
222
  The user watches the conversation without an agent: `thatch chat list`
173
- renders the roster with human-readable heartbeat ages, and
174
- `thatch chat tail [--once]` follows the message stream. The tail diffs
175
- `ChatStore.messageFeed()` snapshots via `chatTailDiff()` (unit-tested in
176
- `tests/chat.test.ts`): sent lines on first view, then new sends and newly-
177
- read messages per poll. `chat_messages.via_broadcast` marks fan-out rows so
178
- the tail renders one `-> broadcast` line per recipient instead of what
179
- would otherwise look like identical direct sends. The CLI takes no chat
180
- write actions (the wake machinery owns those paths); the only writes it
181
- can trigger are the schema migrations that run whenever any thatch
182
- process opens the database.
223
+ renders the roster as aligned columns under a header row (TTY-gated
224
+ 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
228
+ last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
229
+ `filterChatTailRows()` narrows every feed snapshot (backlog and follow
230
+ polls alike) by body regexes, participant-name substrings, and a
231
+ 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
233
+ newly-read messages per poll, with the diff state seeded from the full
234
+ feed so neither the limit nor a mid-follow rename/unregister can
235
+ 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
238
+ CLI takes no chat write actions (the wake machinery owns those paths);
239
+ the only writes it can trigger are the schema migrations that run
240
+ whenever any thatch process opens the database.
183
241
 
184
242
  ## Interactions with other features
185
243
 
@@ -197,14 +255,23 @@ process opens the database.
197
255
  non-thatch tool calls made during a chat notification turn are buffered
198
256
  and extracted like any other turn's.
199
257
  - Multi-host ([multi-host.md](multi-host.md)): the chat tools are shared -
200
- MCP hosts register with a self-declared identity (the `as` argument;
201
- the stored session ID is synthetic, `mcp_<hash of name>`), and pending
202
- mail surfaces at prompt time through the flush-tools hook line and
203
- `chat_status`. Wake-up delivery remains opencode-only: no MCP server
204
- can start a turn in the client's conversation.
258
+ MCP hosts get their identity from the thatch hook, which ensure-registers
259
+ 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
261
+ tools take that name as `as`. Wake-up delivery remains opencode-only: no
262
+ MCP server can start a turn in the client's conversation.
205
263
 
206
264
  ## Multi-host delivery tiers
207
265
 
266
+ Chat works on every host, and can be disabled entirely: `chat.enabled:
267
+ false` in the thatch config makes every chat tool refuse (a `[disabled]`
268
+ reply naming the re-enable path), stops the poller at init, omits the
269
+ system prompt's chat section, and silences the hook lines. The tools
270
+ re-read the config per call, so a toggle applies immediately; the poller
271
+ gate is read once at init and needs a restart. The hook lines gate on the
272
+ same config, so a disabled feature never advertises itself through any
273
+ surface.
274
+
208
275
  Chat works on every host. Earlier designs for the MCP path - a JSONL
209
276
  temp-file inbox, a socket or daemon - were rejected: the DB already is the
210
277
  inbox, and MCP hosts cannot hold connections across turns, so both
@@ -217,12 +284,15 @@ wake analog is the existing hook channel.
217
284
  | claude code | yes | flush-tools hook line | no |
218
285
  | cursor | yes | flush-tools hook line | no |
219
286
 
220
- MCP identity is the registered name: `chat_register` without a host
221
- session stores a synthetic `mcp_<sha256(name)[0:12]>` session ID, so the
222
- identity is stable across the ephemeral sessions those harnesses run.
223
- `chat_status` is the quiet check (registered flag + pending/total); the
224
- flush-tools hook line names the addressee sessions and prints nothing
225
- when the project has no pending chat - absence is silent by construction.
287
+ 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
290
+ identity is stable across the ephemeral conversations those harnesses run
291
+ (and a conversation without the hook gets a fresh per-conversation
292
+ identity from `chat_register`). `chat_status` is the quiet check
293
+ (registered flag + pending/total); the flush-tools hook line names the
294
+ caller's identity and mailbox, and is absent entirely when chat is
295
+ disabled - absence is silent by construction.
226
296
  Staleness semantics differ by kind: an opencode row's stale age means the
227
297
  process is gone (broadcast skips it), while an mcp row's age only means
228
298
  "between turns" (broadcast always delivers).
@@ -231,19 +301,23 @@ process is gone (broadcast skips it), while an mcp row's age only means
231
301
 
232
302
  Timing constants live in `src/chat.ts`: poll interval 30s, staleness
233
303
  threshold 10 minutes, re-nudge window 15 minutes, nudge cap 6 per recipient
234
- per hour. There are no environment overrides yet; add them the way
235
- `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
304
+ per hour, auto-row TTL 7 days. There are no environment overrides yet; add
305
+ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
236
306
 
237
307
  ## Source files
238
308
 
239
- - `src/chat.ts` - ChatStore (directory + inbox SQL, pool assignment),
240
- ChatPoller (heartbeat, gated delivery, re-nudge, rate cap), staleness
241
- helper
242
- - `src/chat-names.ts` - the static display-name pool (nomenclater style)
243
- - `src/db.ts` - the chat tables in schema init, the NOCASE collation
244
- migration and topic column migration, delegated methods
309
+ - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
310
+ slugify, counter bump, insert, prune, topic refresh), ChatPoller
311
+ (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
312
+ staleness helper
313
+ - `src/chat-names.ts` - the static display-name pool (nomenclater style),
314
+ the fallback base for title-less registrations
315
+ - `src/db.ts` - the chat tables in schema init (including
316
+ chat_name_counters and the auto column), the NOCASE collation migration
317
+ and topic column migration, delegated methods
245
318
  - `src/tool-defs.ts` - the chat tool definitions
246
- - `src/index.ts` - poller construction, delivery closure, idle flush,
319
+ - `src/index.ts` - poller construction, delivery closure, idle
320
+ auto-registration + topic refresh, idle flush,
247
321
  session.deleted unregister, dispose, transcript echo in
248
322
  tool.execute.after
249
323
  - `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
@@ -21,7 +21,8 @@ host-agnostic tools plus the opencode-only tools, which the MCP server
21
21
  filters out. The opencode-only set covers host-session capabilities MCP
22
22
  hosts lack: `get_session_info`, `session_search`/`session_get` (no session
23
23
  concept or session database), the `watch_create`/`watch_branch_create`/
24
- `watch_list`/`watch_cancel` watcher tools (no poller or proactive-prompt
24
+ `watch_command_create`/`watch_list`/`watch_cancel` watcher tools (no
25
+ poller or proactive-prompt
25
26
  channel), and the `chat_register`/`chat_list`/`chat_send`/`chat_read`/
26
27
  `chat_unregister`/`chat_broadcast` chat tools (no session identity or
27
28
  wake-up channel). The
@@ -188,7 +189,7 @@ behavior, with some features having no MCP counterpart.
188
189
  | Tool batching | N/A (in-process) | PostToolBatch (batch) | postToolUse (per-tool) |
189
190
  | `get_session_info` (session identity) | Yes | No | No |
190
191
  | `session_search` / `session_get` (conversation archaeology) | Yes (tools) | CLI only (`thatch session ...`) | CLI only (`thatch session ...`) |
191
- | `watch_*` (PR and branch watchers) | Yes (tools) | No | No |
192
+ | `watch_*` (PR, branch, and command watchers) | Yes (tools) | No | No |
192
193
  | `chat_*` (cross-session chat) | Yes | No | No |
193
194
 
194
195
  For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
@@ -239,4 +240,4 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
239
240
  - The MCP server always exposes bare names. The `mcp__thatch__` prefix is applied by the MCP client, not thatch.
240
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.
241
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.
242
- - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts, `get_session_info`, `session_search`/`session_get`, PR watchers, cross-session chat) 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.
@@ -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 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.
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