@jeffober/thatch 0.1.41 → 0.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,63 @@ 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
+
74
+ `chat_register` (no arguments) is the explicit path: idempotent ensure for
75
+ opencode (identity is the host session ID, which the model cannot know or
76
+ forge), a fresh identity per conversation on MCP hosts, or a reclaim of an
77
+ existing name via `as`. Re-registering keeps the name. Unregistered
78
+ sessions are invisible and unmessageable in both directions. Only
79
+ top-level sessions should register - sub-agent children are ephemeral -
80
+ which the tool descriptions and system prompt state; there is no
81
+ mechanical barrier, a documented trust posture.
82
+
83
+ ### Leave tombstones
84
+
85
+ Every exit path - `chat_unregister` and `session.deleted` alike - writes a
86
+ `chat_leave_tombstones` row in the same transaction that deletes the
87
+ directory row. The tombstone is what makes a leave stick: both
88
+ registration paths consult it (the plugin's idle auto-registerer is
89
+ suppressed outright; the MCP hook's ensure-register prints the leave line
90
+ instead of rejoining), so a leave cannot be silently undone by the next
91
+ idle moment. The gate lives in `ChatStore.register` via `#tombstoneGate`
92
+ (src/chat.ts), which also closes the in-flight race: an auto-register
93
+ IIFE whose session was deleted during its title-fetch await finds the
94
+ tombstone and stops instead of resurrecting a dead session. An explicit
95
+ `chat_register` clears the tombstone before re-registering (fresh name -
96
+ assigned identities are never reused), and tombstones age out in the
97
+ hourly prune (7-day TTL; a resumed session after expiry simply
98
+ auto-registers under a fresh name).
99
+
100
+ Because names are system-minted and never recycled, the impersonation
101
+ surface shrinks to whatever the operator runs: every agent on this machine
78
102
  is the operator's agent.
79
103
 
80
104
  ### Topics
81
105
 
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
106
+ The topic is the roster's "who is working on what" signal. For
107
+ auto-registered sessions it is the live session title, refreshed on every
108
+ idle event - the auto-titler usually lands a real title within a turn or
109
+ two, so the roster converges to the actual work with nobody filling in
110
+ forms. Legacy rows (from the claimed-names era) keep whatever topic was
111
+ set at their registration: free text, sanitized to one roster line
86
112
  (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.
113
+ rather than errors). There is no tool path to set a topic anymore - topics
114
+ are title-derived for everything the current code mints. Broadcast senders
115
+ benefit most: `chat_broadcast` answers "which of you is working on X?"
116
+ with the roster already in hand.
91
117
 
92
118
  ### Messages
93
119
 
@@ -167,19 +193,36 @@ turn reads. The accepted edge: a real user message whose every visible
167
193
  part starts with the prefix is skipped the same way, losing that one turn's
168
194
  advisory nudges.
169
195
 
196
+ ### The untrusted-content frame
197
+
198
+ Message bodies are other agents' text delivered verbatim into the reading
199
+ model's context - a prompt-injection surface by construction. The watcher
200
+ privacy model solves the same surface with pointer-only notifications, but
201
+ chat's payload IS the body, so `chat_read` frames the boundary instead:
202
+ `chatInboxFrame()` (src/prompts.ts) wraps the message lines in
203
+ begin/end fences with an explicit do-not-follow warning. The frame exists only in
204
+ the tool output - persisted rows are untouched - and pairs with the wake
205
+ nudge's anti-loop rule and the isChatEchoParts skip as the feature's three
206
+ injection-hygiene layers.
207
+
170
208
  ### CLI access
171
209
 
172
210
  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.
211
+ renders the roster with human-readable heartbeat ages, and `thatch chat
212
+ tail` follows the message stream as cards. The backlog renders only the
213
+ last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
214
+ `filterChatTailRows()` narrows every feed snapshot (backlog and follow
215
+ polls alike) by body regexes, participant-name substrings, and a
216
+ half-open time window. The diff itself is `chatTailDiff()` (unit-tested
217
+ in `tests/chat.test.ts`): sent cards on first view, then new sends and
218
+ newly-read messages per poll, with the diff state seeded from the full
219
+ feed so neither the limit nor a mid-follow rename/unregister can
220
+ resurface old rows as sent events. `chat_messages.via_broadcast` marks
221
+ fan-out rows so the tail renders one `-> broadcast` card per recipient
222
+ instead of what would otherwise look like identical direct sends. The
223
+ CLI takes no chat write actions (the wake machinery owns those paths);
224
+ the only writes it can trigger are the schema migrations that run
225
+ whenever any thatch process opens the database.
183
226
 
184
227
  ## Interactions with other features
185
228
 
@@ -197,14 +240,23 @@ process opens the database.
197
240
  non-thatch tool calls made during a chat notification turn are buffered
198
241
  and extracted like any other turn's.
199
242
  - 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.
243
+ MCP hosts get their identity from the thatch hook, which ensure-registers
244
+ the conversation's stable session ID (`mcp_<hash of host session id>`)
245
+ and prints the assigned name plus unread count on every prompt; chat
246
+ tools take that name as `as`. Wake-up delivery remains opencode-only: no
247
+ MCP server can start a turn in the client's conversation.
205
248
 
206
249
  ## Multi-host delivery tiers
207
250
 
251
+ Chat works on every host, and can be disabled entirely: `chat.enabled:
252
+ false` in the thatch config makes every chat tool refuse (a `[disabled]`
253
+ reply naming the re-enable path), stops the poller at init, omits the
254
+ system prompt's chat section, and silences the hook lines. The tools
255
+ re-read the config per call, so a toggle applies immediately; the poller
256
+ gate is read once at init and needs a restart. The hook lines gate on the
257
+ same config, so a disabled feature never advertises itself through any
258
+ surface.
259
+
208
260
  Chat works on every host. Earlier designs for the MCP path - a JSONL
209
261
  temp-file inbox, a socket or daemon - were rejected: the DB already is the
210
262
  inbox, and MCP hosts cannot hold connections across turns, so both
@@ -217,12 +269,15 @@ wake analog is the existing hook channel.
217
269
  | claude code | yes | flush-tools hook line | no |
218
270
  | cursor | yes | flush-tools hook line | no |
219
271
 
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.
272
+ MCP identity is anchored by the host hook: `chatHookLine(sessionID)` in
273
+ `bin/thatch` ensure-registers `mcp_<sha256(host session id)[0:12]>` on
274
+ every prompt and prints the assigned name plus unread count, so the
275
+ identity is stable across the ephemeral conversations those harnesses run
276
+ (and a conversation without the hook gets a fresh per-conversation
277
+ identity from `chat_register`). `chat_status` is the quiet check
278
+ (registered flag + pending/total); the flush-tools hook line names the
279
+ caller's identity and mailbox, and is absent entirely when chat is
280
+ disabled - absence is silent by construction.
226
281
  Staleness semantics differ by kind: an opencode row's stale age means the
227
282
  process is gone (broadcast skips it), while an mcp row's age only means
228
283
  "between turns" (broadcast always delivers).
@@ -231,19 +286,23 @@ process is gone (broadcast skips it), while an mcp row's age only means
231
286
 
232
287
  Timing constants live in `src/chat.ts`: poll interval 30s, staleness
233
288
  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.
289
+ per hour, auto-row TTL 7 days. There are no environment overrides yet; add
290
+ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
236
291
 
237
292
  ## Source files
238
293
 
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
294
+ - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
295
+ slugify, counter bump, insert, prune, topic refresh), ChatPoller
296
+ (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
297
+ staleness helper
298
+ - `src/chat-names.ts` - the static display-name pool (nomenclater style),
299
+ the fallback base for title-less registrations
300
+ - `src/db.ts` - the chat tables in schema init (including
301
+ chat_name_counters and the auto column), the NOCASE collation migration
302
+ and topic column migration, delegated methods
245
303
  - `src/tool-defs.ts` - the chat tool definitions
246
- - `src/index.ts` - poller construction, delivery closure, idle flush,
304
+ - `src/index.ts` - poller construction, delivery closure, idle
305
+ auto-registration + topic refresh, idle flush,
247
306
  session.deleted unregister, dispose, transcript echo in
248
307
  tool.execute.after
249
308
  - `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.
@@ -3,10 +3,11 @@
3
3
  Watchers are the general mechanism for event-driven notifications from
4
4
  external sources: the model registers a watch on something outside the
5
5
  session, thatch polls it in the background, and the plugin prompts the
6
- session when a watched event happens. Two sources ship: GitHub pull
7
- requests (source `pr`) and GitHub branches (source `branch`, for
8
- watching CI and workflow runs on main). The registry, poller, and
9
- delivery are source-agnostic - a new source adds a fetch-and-diff
6
+ session when a watched event happens. Three sources ship: GitHub pull
7
+ requests (source `pr`), GitHub branches (source `branch`, for watching
8
+ CI and workflow runs on main), and local shell commands (source
9
+ `command`, for waiting on arbitrary conditions). The registry, poller,
10
+ and delivery are source-agnostic - a new source adds a fetch-and-diff
10
11
  function pair and event types.
11
12
 
12
13
  User-facing behavior is documented in [docs/user/watchers.md](../../user/watchers.md).
@@ -14,13 +15,16 @@ User-facing behavior is documented in [docs/user/watchers.md](../../user/watcher
14
15
  ## What it does
15
16
 
16
17
  - `thatch_watch_create` (PRs) / `thatch_watch_branch_create` (branches) /
17
- `thatch_watch_list` / `thatch_watch_cancel` tools (all opencode-only)
18
+ `thatch_watch_command_create` (commands) / `thatch_watch_list` /
19
+ `thatch_watch_cancel` tools (all opencode-only)
18
20
  - A background poller in the plugin process diffs each watcher's target
19
21
  against its last-seen state
20
22
  - Delivery prompts the session with a synthetic part - the same
21
23
  mechanism opencode uses for background task completions
22
- - Notifications carry pointer data only (author, URL, counts); the
23
- model fetches content on demand with `gh`
24
+ - Notifications carry pointer data plus machine status (author, URL,
25
+ check names and conclusions, exit codes); external content is fetched
26
+ on demand with `gh` or by re-running a watched command, never
27
+ delivered
24
28
 
25
29
  ## The process-lifetime decision
26
30
 
@@ -72,6 +76,12 @@ attempt delivery. Poll errors are per-watcher; one bad PR never
72
76
  blocks the others. The diff is a pure function, so it is unit-tested
73
77
  without any network access.
74
78
 
79
+ The interval is surfaced to the model through the registry's
80
+ `pollSeconds` getter: watch-creation output states when the first
81
+ poll lands, and notifications carry a cadence line, so a model
82
+ choosing between a watcher and an in-turn poll compares real numbers
83
+ even when `THATCH_WATCH_POLL_SECONDS` overrides the default.
84
+
75
85
  Event detection:
76
86
 
77
87
  - comments by id monotonicity (fetch is sorted descending)
@@ -89,6 +99,57 @@ Event detection:
89
99
  A diff emits at most 10 events per watcher per cycle, so comment
90
100
  floods collapse into one batch.
91
101
 
102
+ ### One-shot watches
103
+
104
+ `watch_create` and `watch_branch_create` accept `once: true`. The
105
+ watcher cancels itself when its first matching event is detected, not
106
+ when it is delivered - the event still queues and delivers through the
107
+ normal pending path, so a session busy at fire time does not lose the
108
+ notification. Cancellation at detection time is what keeps a "tell me
109
+ when this run finishes" request from leaving a standing watch polling
110
+ a target nobody is waiting on. `watch_list` marks one-shot watchers
111
+ with a `[once]` tag, and the registration output states the mode.
112
+
113
+ ### Command watches
114
+
115
+ The command source stretches the fetch/diff recipe in two ways:
116
+
117
+ 1. **The fetch has side effects.** `runWatchedCommand()` executes the
118
+ watched command via `bash -c` in the project directory each cycle.
119
+ This is the point - the command IS the condition - but it means
120
+ command watchers must not block: the tool description and system
121
+ prompt state the contract (fast, idempotent status check; never
122
+ `gh run watch` or `tail -f`), and a per-run kill timeout
123
+ (default 30s, `THATCH_WATCH_COMMAND_TIMEOUT_SECONDS`) bounds a
124
+ hung command's cost to one partial poll cycle - other watchers
125
+ wait out the kill rather than stalling, but a blocking command
126
+ still burns its timeout slice every cycle. A timed-out run means
127
+ "not done yet": the last-seen exit is left untouched and the watch
128
+ keeps polling.
129
+ 2. **The diff is a trivial exit-code check.** The command is a
130
+ condition variable, not a data pipe: stdout is drained (a full
131
+ pipe buffer would deadlock the child) but discarded, and stderr is
132
+ kept only for registration-time error messages. The notification
133
+ carries the exit code and duration; command output can be external
134
+ content, so it never rides a notification. This is the strictest
135
+ application of the notification content rule. The pipe drains are also
136
+ raced against a hard deadline, because a
137
+ command that backgrounds a long-lived child leaves a grandchild
138
+ holding the pipe write-ends past the kill - without the race, the
139
+ drains never resolve and the shared poll cycle stalls forever.
140
+
141
+ `createCommand()` runs the command once at registration as baseline
142
+ validation, like `createPr()`'s baseline fetch: exit 0 at baseline
143
+ means the condition is already met (refused, so the caller proceeds
144
+ now), exit 127 means the command does not exist (refused with the
145
+ stderr tail), and a spawn failure fails registration with a real
146
+ error. A baseline run that times out records the 124 sentinel as the
147
+ last-seen exit - the killed process may have exited 0 while dying, and
148
+ the watcher's first real exit 0 must fire. Registered command
149
+ watchers carry `once: true` inherently - "run until it returns 0,
150
+ then notify, then stop" is the whole concept. The `CommandRunner` is
151
+ injectable like `GhRunner`, so tests never spawn processes.
152
+
92
153
  ### Delivery
93
154
 
94
155
  Events queue in an in-memory pending map keyed by session. Delivery
@@ -101,9 +162,15 @@ on the session with a synthetic text part built by
101
162
  `watcherNotificationNudge()`. This triggers a full model turn - the
102
163
  model reads the notification and decides whether to act. The
103
164
  notification text borrows the background-task-completion framing
104
- (system event, not user input, not approval to advance other work),
105
- following the same load-bearing wording discipline as the extraction
106
- nudge.
165
+ (system event, not user input, not approval to advance other work)
166
+ with one carve-out: a watch registered to gate work the user already
167
+ greenlit (for example "wait for CI, then merge") makes the
168
+ notification the continuation signal for exactly that work. The
169
+ carve-out lives in the nudge itself, not only the system prompt,
170
+ because the nudge is what the model reads at the act-or-wait decision
171
+ point. The wrapper also states that check-run and workflow
172
+ conclusions are already in the event summaries, so gh fetches are
173
+ needed only for logs and bodies.
107
174
 
108
175
  Failed or gated deliveries stay pending and retry on the next poll
109
176
  cycle or when the session next goes idle (the event hook calls
@@ -114,7 +181,10 @@ cycle or when the session next goes idle (the event hook calls
114
181
  `pr_comment`, `pr_review_comment`, `pr_review_reply`,
115
182
  `pr_review_resolved`, `pr_commit`, `pr_status`, `pr_description`,
116
183
  `pr_ci`. The tool's `events` argument selects a subset; the default is
117
- all eight.
184
+ all eight. Branch watchers add `branch_commit`, `branch_ci`, and
185
+ `branch_workflow` (the full vocabulary is `WATCHER_EVENT_TYPES` in
186
+ src/watchers.ts, test-enforced); command watchers register the single
187
+ `command_success` type.
118
188
 
119
189
  ## Interactions with other features
120
190
 
@@ -134,28 +204,48 @@ all eight.
134
204
  - `THATCH_WATCH_POLL_SECONDS` - poll interval (default 60)
135
205
  - `THATCH_WATCH_TTL_MINUTES` - watcher time-to-live (default 480)
136
206
  - `THATCH_WATCH_MAX_PER_SESSION` - active watchers per session
137
- (default 5)
207
+ (default 5; all sources share the budget)
208
+ - `THATCH_WATCH_COMMAND_TIMEOUT_SECONDS` - per-run kill timeout for
209
+ watched commands (default 30)
138
210
 
139
211
  ## Source files
140
212
 
141
- - `src/watchers.ts` - registry, poller, gh CLI runner, diff functions
142
- - `src/tool-defs.ts` - the three watch tool definitions
213
+ - `src/watchers.ts` - registry, poller, gh CLI runner, command runner,
214
+ diff functions
215
+ - `src/tool-defs.ts` - the watch tool definitions (see `TOOL_DEFS`)
143
216
  - `src/index.ts` - registry construction, delivery closure,
144
217
  session.status tracking, session.deleted cleanup, dispose
145
218
  - `src/prompts.ts` - `watcherNotificationNudge()`, system prompt
146
219
  Watchers section
147
220
  - `tests/watchers.test.ts` - registry, poll, and diff unit tests
148
- (mocked gh, no network)
221
+ (mocked gh and command runners, no network)
222
+ - `tests/tool-defs.test.ts` - watch tool execute-function tests
149
223
  - `tests/qa/auto/uc-095-watchers.ts` - end-to-end lifecycle against a
150
224
  mocked gh runner
225
+ - `tests/qa/auto/uc-101-command-watchers.ts` - command-watch lifecycle
226
+ against a mocked command runner
151
227
 
152
228
  ## Adding a new source type
153
229
 
154
- The registry is a discriminated union over sources: `PrWatcher` and
155
- `BranchWatcher` implement the same lifecycle (id, session, repo,
156
- events, expiry, snapshot), and `poll()` dispatches to the source's
157
- fetch/diff pair. Adding a third source means: new event types, a
158
- `fetchXState`/`diffXState` pair, a new arm of the `Watcher` union, a
159
- `#pollOne` branch, and the tool surface for registering it. Keep the
160
- pointer-only notification rule: external content enters the context on
161
- explicit fetch, never via notification.
230
+ The registry is a discriminated union over sources: `PrWatcher`,
231
+ `BranchWatcher`, and `CommandWatcher` implement the same lifecycle
232
+ (id, session, events, expiry, snapshot), and `poll()` dispatches to
233
+ the source's fetch/diff pair. Adding a new source means: new event
234
+ types, a fetch function and diff (or a trivial inline diff like the
235
+ command source's), a new arm of the `Watcher` union, a `#pollOne`
236
+ branch, and the tool surface for registering it. Keep the
237
+ notification content rule: external content enters the context on
238
+ explicit fetch, never via notification. Machine status fields (names,
239
+ conclusions, counts, exit codes) come from the API rather than
240
+ user-written text and belong in event summaries.
241
+
242
+ Two caveats from the command source, worth carrying to future
243
+ sources:
244
+
245
+ - If the source's fetch does anything beyond reading an API, inject a
246
+ runner (like `GhRunner`/`CommandRunner`) so tests never spawn real
247
+ work, and bound it with a timeout so a hung fetch cannot stall the
248
+ shared sequential poll cycle.
249
+ - If the source has no natural URL (a local command has none), emit
250
+ an empty `url` in the event; `watcherNotificationNudge()` omits
251
+ empty URLs rather than rendering a dangling space.
@@ -18,7 +18,7 @@ This document maps the feature parity and documents the remaining gaps.
18
18
 
19
19
  | Feature | OpenCode plugin | Claude Code MCP + hooks | Cursor MCP + hooks | Parity |
20
20
  |---------|----------------|------------------------|---------------------|--------|
21
- | **Tools** (single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | **Full** for the shared tools; `get_session_info` (session identity) and `session_search`/`session_get` (conversation archaeology) are opencode-only — MCP hosts have no session concept. The `thatch session` CLI subcommands cover the same archaeology on any host with an opencode.db file. The `watch_*` tools (PR and branch watchers) are opencode-only — MCP hosts have no poller or proactive-prompt channel. The `chat_*` tools (cross-session chat) are opencode-only — no host session identity and no wake-up channel |
21
+ | **Tools** (single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | **Full** for the shared tools; `get_session_info` (session identity) and `session_search`/`session_get` (conversation archaeology) are opencode-only — MCP hosts have no session concept. The `thatch session` CLI subcommands cover the same archaeology on any host with an opencode.db file. The `watch_*` tools (PR, branch, and command watchers) are opencode-only — MCP hosts have no poller or proactive-prompt channel. The `chat_*` tools (cross-session chat) are opencode-only — no host session identity and no wake-up channel |
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 |
@@ -11,7 +11,7 @@ This README is the overview. Each feature has its own guide:
11
11
 
12
12
  - [memory.md](memory.md): persistent memory store
13
13
  - [extraction.md](extraction.md): automatic fact extraction from tool calls
14
- - [watchers.md](watchers.md): event-driven notifications (PR and branch watching)
14
+ - [watchers.md](watchers.md): event-driven notifications (PR, branch, and command watching)
15
15
  - [cross-session-chat.md](cross-session-chat.md): sessions messaging each other on one machine
16
16
  - [notifications.md](notifications.md): banner and voice notifications, user config
17
17
  - [prediction-engine.md](prediction-engine.md): user decision model
@@ -179,6 +179,7 @@ search` - JSONL output designed for piping to `jq` or `grep`.
179
179
  |------|-------------|
180
180
  | `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, thread resolutions, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
181
181
  | `thatch_watch_branch_create` | Watch a GitHub branch (typically main) for commit landings, CI check-run completions, and workflow runs, with an optional workflow-name filter. |
182
+ | `thatch_watch_command_create` | Wait on any local condition via a shell command that exits 0 when the wait is over. One-shot: one notification on the first exit 0, then it cancels itself. |
182
183
  | `thatch_watch_list` | List this session's active watchers. |
183
184
  | `thatch_watch_cancel` | Cancel one of this session's watchers by id. |
184
185
 
@@ -189,7 +190,7 @@ requirements.
189
190
 
190
191
  | Tool | What it does |
191
192
  |------|-------------|
192
- | `thatch_chat_register` | Join the cross-session chat directory, so other opencode sessions on this machine can message you. Without a name, one is drawn from the built-in pool (recommended - cannot collide); with a name, it is claimed case-insensitively. Pass a topic (one line about what the session is working on) so the roster tells others who to talk to. Safe to call again - same name is a no-op, a new name renames, a new topic updates. |
193
+ | `thatch_chat_register` | Join the cross-session chat directory, so other sessions on this machine can message you. Display names are assigned by thatch, never chosen (a slug plus a never-reused counter), so a name always refers to the same session. Call with no arguments to join, rejoin after unregistering, or check your name. On hosts without session context (Claude Code, Cursor), pass the name your thatch hook printed as `as`. |
193
194
  | `thatch_chat_list` | List registered sessions with a liveness marker (fresh = its process is alive, stale = likely gone), each session's project and topic, and your unread count. |
194
195
  | `thatch_chat_send` | Send a message to another registered session, by name or session id. The recipient is nudged when its session is idle. |
195
196
  | `thatch_chat_read` | Drain your inbox: all unread messages oldest-first, marked read. Senders identified by display name. |
package/docs/user/cli.md CHANGED
@@ -47,7 +47,11 @@ Read-only access to the chat directory your opencode sessions share
47
47
  ```bash
48
48
  thatch chat list # who is registered: name, age, project, topic
49
49
  thatch chat tail # follow sent and read events, live
50
- thatch chat tail --once # print the messages sent so far and exit
50
+ thatch chat tail --once # print the last 20 messages and exit
51
+ thatch chat tail --once --limit 50 # a longer backlog (or --limit all)
52
+ thatch chat tail --match "rebase" --match "payments" # body must match both
53
+ thatch chat tail --from al --to bob # substring match on names
54
+ thatch chat tail --since 2026-09-12 --until "2026-09-13 09:00" # a window
51
55
  ```
52
56
 
53
57
  `chat list` shows every registered session with how long since its last
@@ -55,19 +59,63 @@ heartbeat (a large age means the session's process is probably gone), its
55
59
  project, and the topic it registered with - the fastest way to answer
56
60
  "which session should I talk to about X?"
57
61
 
58
- `chat tail` prints the conversation as it happens:
62
+ `chat tail` prints the conversation as styled chat cards (labels on a
63
+ colored background, names in color, timestamps dimmed; a drawn rule
64
+ separates cards, with blank lines on either side). After each name, the
65
+ session's registered topic appears in muted italics - so the tail tells
66
+ you not just who is talking but what they are working on:
59
67
 
60
68
  ```text
61
- [2026-09-12T14:02:11Z] Kurn the Typechecker -> Marlowe the Cherry Picker: CI is green on main
62
- [2026-09-12T21:07:52Z] Marlowe the Cherry Picker -> broadcast: rebasing payments, hold off
63
- [2026-09-12T21:08:03Z] Marlowe the Cherry Picker read a message from Kurn the Typechecker: direct ping
69
+ From Al Go Rithm <cross-session messaging between opencode sessions>
70
+ To Brute the Dream Farrier <diagnosing watcher underuse>
71
+ When 2026-09-12 14:02 America/Denver
72
+
73
+ CI is green on main
74
+ ____________________________________________________________
75
+
76
+ To broadcast
77
+ When 2026-09-12 21:07 America/Denver
78
+
79
+ rebasing payments, hold off
80
+ ____________________________________________________________
81
+
82
+ From Kurn the Typechecker <release QA>
83
+ read Marlowe the Cherry Picker <payments refactor>
84
+ When 2026-09-12 21:08 America/Denver
85
+
86
+ direct ping
64
87
  ```
65
88
 
66
89
  Follow mode runs until Ctrl-C. Read events appear only in follow mode
67
- (they fire when an inbox is drained after the tail started). The chat
68
- itself flows through the agents (the CLI never sends, reads, or
69
- registers on a session's behalf; that happens from opencode sessions
70
- via the `thatch_chat_*` tools).
90
+ (they fire when an inbox is drained after the tail started). When
91
+ timestamps are in your local timezone. The chat itself flows through the
92
+ agents (the CLI never sends, reads, or registers on a session's behalf;
93
+ that happens from opencode sessions via the `thatch_chat_*` tools).
94
+
95
+ The backlog shows the last 20 messages before follow mode takes over.
96
+ When messages are hidden, tail prints a one-line note on stderr saying
97
+ how many there were; `--limit N` raises or lowers the backlog and
98
+ `--limit all` shows everything. All the filters apply to live events
99
+ too, so a filtered tail keeps watching the same way:
100
+
101
+ - `--match PATTERN` filters message bodies. Repeat it to AND patterns
102
+ together: every one must match the message body.
103
+ - `--from NAME` and `--to NAME` match participant names (case doesn't
104
+ matter). A departed sender can only be found as the `unknown`
105
+ rendering the tail shows. Broadcast cards say `broadcast` in the To
106
+ slot, but the row underneath keeps the real recipient: `--to <name>`
107
+ finds the fan-out rows that reached them, while `--to broadcast`
108
+ matches nothing.
109
+ - `--since DT` and `--until DT` bound the window; a message counts if
110
+ it was sent at or after `--since` and before `--until`. The date
111
+ format is `YYYY-MM-DD` with an optional `HH:MM` in your local
112
+ timezone.
113
+
114
+ A `--until` already in the past exits after the backlog (a closed
115
+ window has nothing left to follow); one in the future follows until
116
+ the window closes. Note that the limit hides sent cards, not their
117
+ reads: a hidden message that gets read during follow mode still emits
118
+ its read card.
71
119
 
72
120
  ## Priming a new project
73
121