@jeffober/thatch 0.1.43 → 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.
- package/README.md +4 -2
- package/bin/thatch +270 -93
- package/docs/dev/README.md +8 -1
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/cross-session-chat.md +111 -42
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +12 -0
- package/docs/dev/mcp-parity.md +1 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +6 -4
- package/docs/user/cli.md +35 -43
- package/docs/user/cross-session-chat.md +89 -21
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +344 -185
- package/src/commands.ts +16 -6
- package/src/db.ts +37 -0
- package/src/debug.ts +44 -0
- package/src/index.ts +153 -39
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +2 -2
- package/src/setup.ts +203 -33
- package/src/tool-defs.ts +98 -24
- package/src/watchers.ts +51 -29
|
@@ -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
|
|
47
|
-
prompts them through its own SDK
|
|
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,21 @@ 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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
(`chat_name_counters`, src/db.ts) that only ever
|
|
67
|
-
atomically via INSERT ... ON CONFLICT ... RETURNING.
|
|
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.
|
|
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.
|
|
73
77
|
Registration also captures the checkout kind (`worktree` column):
|
|
74
78
|
`detectWorktreeKind` (src/git.ts) classifies the session's serving
|
|
75
79
|
directory once at registration - a `.git` file means a linked git
|
|
@@ -77,6 +81,26 @@ worktree, a `.git` directory means the project root, no `.git` reads as
|
|
|
77
81
|
undetected. The roster shows it as a `loc:` token so sessions coordinating
|
|
78
82
|
on a shared tree can tell who sits where.
|
|
79
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.
|
|
103
|
+
|
|
80
104
|
`chat_register` (no arguments) is the explicit path: idempotent ensure for
|
|
81
105
|
opencode (identity is the host session ID, which the model cannot know or
|
|
82
106
|
forge), a fresh identity per conversation on MCP hosts, or a reclaim of an
|
|
@@ -146,14 +170,25 @@ behavior drastically on a parameter value is two functions.
|
|
|
146
170
|
|
|
147
171
|
### Polling, heartbeat, staleness
|
|
148
172
|
|
|
149
|
-
A `setInterval` loop (default 30s) runs one cycle per process: heartbeat
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
157
192
|
|
|
158
193
|
### Delivery, re-nudge, and the rate cap
|
|
159
194
|
|
|
@@ -222,19 +257,19 @@ injection-hygiene layers.
|
|
|
222
257
|
The user watches the conversation without an agent: `thatch chat list`
|
|
223
258
|
renders the roster as aligned columns under a header row (TTY-gated
|
|
224
259
|
color; `formatChatRoster()` in `bin/thatch`), and `thatch chat tail`
|
|
225
|
-
follows the message stream as
|
|
226
|
-
|
|
227
|
-
The backlog
|
|
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
|
|
228
263
|
last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
|
|
229
264
|
`filterChatTailRows()` narrows every feed snapshot (backlog and follow
|
|
230
265
|
polls alike) by body regexes, participant-name substrings, and a
|
|
231
266
|
half-open time window. The diff itself is `chatTailDiff()` (unit-tested
|
|
232
|
-
in `tests/chat.test.ts`): sent
|
|
267
|
+
in `tests/chat.test.ts`): sent events on first view, then new sends and
|
|
233
268
|
newly-read messages per poll, with the diff state seeded from the full
|
|
234
269
|
feed so neither the limit nor a mid-follow rename/unregister can
|
|
235
270
|
resurface old rows as sent events. `chat_messages.via_broadcast` marks
|
|
236
|
-
fan-out rows so
|
|
237
|
-
|
|
271
|
+
fan-out rows so each copy's `sent` event carries `broadcast: true`
|
|
272
|
+
alongside its real recipient. The
|
|
238
273
|
CLI takes no chat write actions (the wake machinery owns those paths);
|
|
239
274
|
the only writes it can trigger are the schema migrations that run
|
|
240
275
|
whenever any thatch process opens the database.
|
|
@@ -257,7 +292,8 @@ whenever any thatch process opens the database.
|
|
|
257
292
|
- Multi-host ([multi-host.md](multi-host.md)): the chat tools are shared -
|
|
258
293
|
MCP hosts get their identity from the thatch hook, which ensure-registers
|
|
259
294
|
the conversation's stable session ID (`mcp_<hash of host session id>`)
|
|
260
|
-
and prints the assigned name plus unread count
|
|
295
|
+
and prints the assigned name plus unread count at session start and on
|
|
296
|
+
every prompt; chat
|
|
261
297
|
tools take that name as `as`. Wake-up delivery remains opencode-only: no
|
|
262
298
|
MCP server can start a turn in the client's conversation.
|
|
263
299
|
|
|
@@ -278,53 +314,86 @@ inbox, and MCP hosts cannot hold connections across turns, so both
|
|
|
278
314
|
degenerate to the turn-granularity polling the tools already provide. The
|
|
279
315
|
wake analog is the existing hook channel.
|
|
280
316
|
|
|
281
|
-
| Host | Send/receive | New-mail signal
|
|
282
|
-
|
|
283
|
-
| opencode | yes | promptAsync wake (idle-gated)| yes |
|
|
284
|
-
| claude code | yes | flush-tools hook line
|
|
285
|
-
| cursor | yes | flush-tools hook line
|
|
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 |
|
|
286
322
|
|
|
287
323
|
MCP identity is anchored by the host hook: `chatHookLine(sessionID)` in
|
|
288
|
-
`bin/thatch` ensure-registers `mcp_<sha256(host session id)[0:12]>`
|
|
289
|
-
|
|
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
|
|
290
327
|
identity is stable across the ephemeral conversations those harnesses run
|
|
291
328
|
(and a conversation without the hook gets a fresh per-conversation
|
|
292
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
|
|
293
357
|
(registered flag + pending/total); the flush-tools hook line names the
|
|
294
358
|
caller's identity and mailbox, and is absent entirely when chat is
|
|
295
359
|
disabled - absence is silent by construction.
|
|
296
|
-
Staleness semantics differ by kind: an opencode row's stale age means
|
|
297
|
-
|
|
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
|
|
298
362
|
"between turns" (broadcast always delivers).
|
|
299
363
|
|
|
300
364
|
## Defaults
|
|
301
365
|
|
|
302
366
|
Timing constants live in `src/chat.ts`: poll interval 30s, staleness
|
|
303
|
-
threshold
|
|
367
|
+
threshold two missed beats (60s), re-nudge window 15 minutes, nudge cap 6 per recipient
|
|
304
368
|
per hour, auto-row TTL 7 days. There are no environment overrides yet; add
|
|
305
369
|
them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
|
|
306
370
|
|
|
307
371
|
## Source files
|
|
308
372
|
|
|
309
373
|
- `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
|
|
310
|
-
slugify, counter bump, insert, prune, topic refresh), ChatPoller
|
|
374
|
+
pool pick, slugify, counter bump, insert, prune, topic refresh), ChatPoller
|
|
311
375
|
(heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
|
|
312
376
|
staleness helper
|
|
313
377
|
- `src/chat-names.ts` - the static display-name pool (nomenclater style),
|
|
314
|
-
the
|
|
378
|
+
the base for every assigned name
|
|
315
379
|
- `src/db.ts` - the chat tables in schema init (including
|
|
316
380
|
chat_name_counters and the auto column), the NOCASE collation migration
|
|
317
381
|
and topic column migration, delegated methods
|
|
318
382
|
- `src/tool-defs.ts` - the chat tool definitions
|
|
319
|
-
- `src/index.ts` - poller construction, delivery closure,
|
|
383
|
+
- `src/index.ts` - poller construction, delivery closure, `-s` startup
|
|
384
|
+
registration (`osProcessArgs`, `startupSessionId`), idle
|
|
320
385
|
auto-registration + topic refresh, idle flush,
|
|
321
386
|
session.deleted unregister, dispose, transcript echo in
|
|
322
387
|
tool.execute.after
|
|
388
|
+
- `src/debug.ts` - the `THATCH_DEBUG` diagnostic log; `chat:startup` is
|
|
389
|
+
the chat consumer
|
|
323
390
|
- `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
|
|
324
391
|
`isChatEchoParts()`, system prompt Cross-Session Chat section, MCP
|
|
325
392
|
absent-tools note
|
|
326
|
-
- `tests/chat.test.ts` - store, pool, migration, echo-text,
|
|
327
|
-
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)
|
|
328
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
|
|
329
398
|
- `tests/qa/live/uc-098-chat-cross-session.ts` - two real sessions exchange
|
|
330
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
|
|
256
|
-
as
|
|
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 |
|
|
193
|
+
| `chat_*` (cross-session chat) | Yes (wake on delivery) | Yes (mail at prompt time) | Yes (mail at prompt time) |
|
|
194
194
|
|
|
195
195
|
For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
|
|
196
196
|
|
|
@@ -63,7 +63,7 @@ When a parent session (no parentID) goes idle:
|
|
|
63
63
|
|
|
64
64
|
### Wrap-up commands (`/thatch/compact`, `/thatch/exit`)
|
|
65
65
|
|
|
66
|
-
User-invoked slash commands, shipped as command markdown synced by the plugin (see `src/commands.ts`). The template instructs the model to flush pending fact extraction (`thatch_get_extraction_payload` + `thatch_extraction_done`), finish promised memory writes, and surface unaddressed todos -- then end its response with a greenlight token (`THATCH_COMPACT_READY` / `THATCH_EXIT_READY`) only when safe to proceed. Text typed after the command is forwarded into
|
|
66
|
+
User-invoked slash commands, shipped as command markdown synced by the plugin (see `src/commands.ts`). The template instructs the model to flush pending fact extraction (`thatch_get_extraction_payload` + `thatch_extraction_done`), finish promised memory writes, and surface unaddressed todos -- then end its response with a greenlight token (`THATCH_COMPACT_READY` / `THATCH_EXIT_READY`) only when safe to proceed. Text typed after the command is forwarded into a labeled `# User Message` section ahead of the `# Pre-*-wrap-up` checklist section (opencode's `$ARGUMENTS` substitution): the headers give the user's words provenance, so they cannot be misread as part of the wrap-up instructions. opencode's substitution is purely mechanical with no default-value form (verified against v1.18.30), so the template cannot switch on emptiness; instead the section always carries a fallback line telling the model to treat an empty section as n/a (bare command, no user text).
|
|
67
67
|
|
|
68
68
|
1. `command.execute.before` arms the session in `pendingWrapUp` when the command runs
|
|
69
69
|
2. On the session's next idle, the plugin fetches the session's messages via the SDK client and checks the final assistant message's trailing text for the token (trimmed, exact match)
|
package/docs/dev/gotchas.md
CHANGED
|
@@ -176,3 +176,15 @@ here first. These are the things that have already cost time.
|
|
|
176
176
|
discovered by bun on their own. UC-095 sat out of the suite for a release
|
|
177
177
|
cycle and rotted (asserted 3 watch tools when 4 existed) because nobody
|
|
178
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.
|
package/docs/dev/mcp-parity.md
CHANGED
|
@@ -144,6 +144,7 @@ Claude Code / Cursor session
|
|
|
144
144
|
├── PostToolBatch → thatch buffer-batch (Claude Code)
|
|
145
145
|
│ └── postToolUse → thatch buffer-tool (Cursor)
|
|
146
146
|
└── SessionStart / sessionStart hook → thatch reminder [--json]
|
|
147
|
+
└── reads the hook's stdin session id: chat identity + mail line
|
|
147
148
|
```
|
|
148
149
|
|
|
149
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`
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Thatch Actions: Prompt Cores Shared by Nudges and /thatch Commands
|
|
2
|
+
|
|
3
|
+
## Synopsis
|
|
4
|
+
|
|
5
|
+
Extract the behavior instructions currently embedded in nudge text into
|
|
6
|
+
prompt-core constants in `src/prompts.ts`, then build `/thatch/*` user-facing
|
|
7
|
+
commands on top of the same constants. The nudge and the command become two
|
|
8
|
+
envelopes around one instruction body: the nudge adds escalation tiers and
|
|
9
|
+
stop-and-wait framing, the command adds user-invocation framing. First
|
|
10
|
+
payoff is `/thatch/defrag`, but the structure covers any behavior the nudges
|
|
11
|
+
currently ask the agent to perform on demand.
|
|
12
|
+
|
|
13
|
+
## Background
|
|
14
|
+
|
|
15
|
+
Nudges are instruction-carrying messages injected by hooks. Several encode a
|
|
16
|
+
multi-step behavior the agent must execute (dispatch a sub-agent, fetch the
|
|
17
|
+
extraction payload, run a skill, ack). The same behaviors have no user-facing
|
|
18
|
+
invocation path: the user cannot say "drain the extraction queue now" or
|
|
19
|
+
"consolidate duplicate memories now" -- they can only wait for a nudge or
|
|
20
|
+
describe the desired behavior in prose and hope the agent maps it to the right
|
|
21
|
+
skill + tool sequence.
|
|
22
|
+
|
|
23
|
+
Two existing pieces make this cheap to add:
|
|
24
|
+
|
|
25
|
+
- `src/commands.ts` already ships `/thatch/compact` and `/thatch/exit` as
|
|
26
|
+
markdown command files synced into the opencode config dir, and already
|
|
27
|
+
composes them from shared fragments (`sharedChecklist`,
|
|
28
|
+
`userMessageSection`).
|
|
29
|
+
- The "Shared prose sync across prompt variants" problem is documented:
|
|
30
|
+
prompt prose lives in parallel functions with no single source of truth,
|
|
31
|
+
and drift in unasserted sections passes tests. This plan makes the shared
|
|
32
|
+
behavior text a true constant.
|
|
33
|
+
|
|
34
|
+
## Design: prompt cores and envelopes
|
|
35
|
+
|
|
36
|
+
A **prompt core** is a constant string in `src/prompts.ts` holding the
|
|
37
|
+
host-agnostic instruction body for one behavior: which tools to call, in what
|
|
38
|
+
order, with what arguments, and what "done" looks like.
|
|
39
|
+
|
|
40
|
+
An **envelope** wraps a core with delivery-specific framing:
|
|
41
|
+
|
|
42
|
+
- The nudge envelope adds: escalation tier wording, the load-bearing
|
|
43
|
+
stop-and-wait ending (see "Extraction nudge stop-and-wait wording is
|
|
44
|
+
load-bearing" -- the stop-and-wait text is nudge-only and must NOT leak
|
|
45
|
+
into action envelopes, because an action IS user input), and the
|
|
46
|
+
"system nudge, not user input" disclaimer.
|
|
47
|
+
- The action envelope adds: frontmatter description, the `# User Message`
|
|
48
|
+
section with `$ARGUMENTS`, and any greenlight-token mechanics (wrap-up
|
|
49
|
+
commands only).
|
|
50
|
+
|
|
51
|
+
Cores are plain string constants, not template functions, so both envelopes
|
|
52
|
+
interpolate them identically. Where a core needs host-specific tool names
|
|
53
|
+
(`thatch_memory_remember` vs `mcp__thatch__memory_remember`), the core takes
|
|
54
|
+
the opencode spelling and MCP consumers apply the documented token
|
|
55
|
+
substitution, matching the existing systemPrompt/mcpInstructions convention.
|
|
56
|
+
|
|
57
|
+
## Actions v1
|
|
58
|
+
|
|
59
|
+
| Command | Core behavior | Existing machinery |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `/thatch/defrag` | Run `thatch_find_duplicates`, classify each pair with the thatch-dedup-classifier skill, consolidate (remember/overwrite or forget), mark pairs checked | find_duplicates + dedup classifier skill |
|
|
62
|
+
| `/thatch/extract` | Fetch the extraction payload, run the fact-extractor skill, call `extraction_done` | get_extraction_payload + fact-extractor skill |
|
|
63
|
+
| `/thatch/hygiene` | Run the hygiene report, act on stale and orphaned branch-scoped memories | `hygieneReport()` + `/thatch hygiene` CLI |
|
|
64
|
+
| `/thatch/reflect` | Run the session-reflection skill over the current session | thatch-session-reflection skill |
|
|
65
|
+
|
|
66
|
+
The wrap-up commands (`compact`, `exit`) stay as-is; their checklist text
|
|
67
|
+
becomes a core reused by both templates (already true via `sharedChecklist`).
|
|
68
|
+
|
|
69
|
+
## Host parity
|
|
70
|
+
|
|
71
|
+
Commands are markdown or MCP prompts, synced per host. Behavior parity is the
|
|
72
|
+
goal; format differences are documented, not hidden.
|
|
73
|
+
|
|
74
|
+
- **opencode** (existing): `installOpencodeCommands` syncs markdown to
|
|
75
|
+
`~/.config/opencode/command/thatch/` on every plugin load.
|
|
76
|
+
- **Claude Code**: native user-level slash commands read markdown from
|
|
77
|
+
`~/.claude/commands/thatch/` with the same `$ARGUMENTS` substitution.
|
|
78
|
+
`thatch setup --claude` gains a sync step (idempotent content-compare, same
|
|
79
|
+
as the opencode installer). Differences: no plugin, so no
|
|
80
|
+
`command.execute.before` arming -- greenlight-token wrap-up commands do not
|
|
81
|
+
ship to Claude Code; and tool names use the `mcp__thatch__` prefix.
|
|
82
|
+
- **Cursor**: no file-based commands. The stdio MCP server (`mcp.ts`) gains
|
|
83
|
+
MCP Prompts support (`prompts/list`, `prompts/get`), which Cursor surfaces
|
|
84
|
+
as slash commands. Prompt arguments use MCP's
|
|
85
|
+
`{{argument}}`-style templating, so each action core gets an MCP-prompt
|
|
86
|
+
rendering alongside the markdown rendering.
|
|
87
|
+
|
|
88
|
+
README gains "What works in Claude Code" and "What works in Cursor" sections
|
|
89
|
+
listing per-host support without restating the shared docs.
|
|
90
|
+
|
|
91
|
+
## Session ID plumbing
|
|
92
|
+
|
|
93
|
+
`get_extraction_payload` and `extraction_done` take `session_id`. Today the
|
|
94
|
+
nudge carries the ID; an action has no way to know it.
|
|
95
|
+
|
|
96
|
+
- opencode path: the thin wrappers in `tools.ts` receive the invoking
|
|
97
|
+
session's ID from the host. `session_id` becomes optional there; omitted
|
|
98
|
+
means "the current session."
|
|
99
|
+
- MCP path: there is no session context in a tool call, so `session_id`
|
|
100
|
+
stays required. The tool description and docs state this plainly.
|
|
101
|
+
|
|
102
|
+
The rule the LLM must apply, stated everywhere the tools are described:
|
|
103
|
+
|
|
104
|
+
> Omit `session_id` when acting on the session you are running in. Pass the
|
|
105
|
+
> parent session's `session_id` when running inside a sub-agent that was
|
|
106
|
+
> dispatched to process another session's extraction queue.
|
|
107
|
+
|
|
108
|
+
This rule appears in: the tool descriptions in `tool-defs.ts`, the extraction
|
|
109
|
+
prompt core (used by both nudge and action envelopes), the fact-extractor
|
|
110
|
+
skill, and the dev docs. Tests cover both paths: omit-resolves-to-current and
|
|
111
|
+
explicit-parent-ID (the existing sub-agent drain bug's regression path).
|
|
112
|
+
|
|
113
|
+
## Decisions
|
|
114
|
+
|
|
115
|
+
- Prompt cores live in `src/prompts.ts`, not a new module; `commands.ts`
|
|
116
|
+
imports them. One prompt module, consistent with the existing module map.
|
|
117
|
+
- Actions are generated, not hand-written: each action has one core and a
|
|
118
|
+
per-host renderer, so a wording change cannot drift between nudge and
|
|
119
|
+
command.
|
|
120
|
+
- No `--dry-run` flag for defrag in v1; the dedup classifier skill already
|
|
121
|
+
asks for pair-by-pair verdicts, which is the interactive safety net.
|
|
122
|
+
- MCP Prompts for Cursor are additive to `mcp.ts` and do not change the
|
|
123
|
+
tools/list contract.
|
|
124
|
+
|
|
125
|
+
## Testing
|
|
126
|
+
|
|
127
|
+
- `tests/commands.test.ts` (or the existing plugin tests): command-file
|
|
128
|
+
content assertions per host, including that each action's markdown contains
|
|
129
|
+
its core text verbatim.
|
|
130
|
+
- MCP tests: `prompts/list` returns every action; `prompts/get` interpolates
|
|
131
|
+
arguments.
|
|
132
|
+
- Plugin tests: `get_extraction_payload` without `session_id` resolves to the
|
|
133
|
+
invoking session; with an explicit parent ID, drains the parent's queue.
|
|
134
|
+
- Parity guard: a test asserts every opencode command def has a
|
|
135
|
+
corresponding MCP prompt (and vice versa) except the documented exclusions
|
|
136
|
+
(wrap-up greenlight commands on non-opencode hosts).
|
|
137
|
+
|
|
138
|
+
## Docs
|
|
139
|
+
|
|
140
|
+
- `docs/dev/commands.md` (two-tier: dev doc + user-facing README section):
|
|
141
|
+
action list, host parity matrix, session ID rule.
|
|
142
|
+
- README: "What works in Claude Code" / "What works in Cursor" sections.
|
|
143
|
+
- QA use cases: one auto use case for `/thatch/defrag` end-to-end, one for
|
|
144
|
+
`/thatch/extract` with omitted session ID.
|
package/docs/user/README.md
CHANGED
|
@@ -190,8 +190,8 @@ requirements.
|
|
|
190
190
|
|
|
191
191
|
| Tool | What it does |
|
|
192
192
|
|------|-------------|
|
|
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
|
|
194
|
-
| `thatch_chat_list` | List registered sessions
|
|
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 pool name 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`. |
|
|
194
|
+
| `thatch_chat_list` | List registered sessions grouped into Active and Stale sections (stale = two missed heartbeats, about a minute; its host process has probably stopped), with how long ago each last checked in, each session's project and checkout kind (project root or linked worktree), its topic, and your unread count. |
|
|
195
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. |
|
|
196
196
|
| `thatch_chat_read` | Drain your inbox: all unread messages oldest-first, marked read. Senders identified by display name. |
|
|
197
197
|
| `thatch_chat_unregister` | Leave the chat directory. |
|
|
@@ -240,8 +240,9 @@ Beyond the tools, thatch hooks into opencode itself:
|
|
|
240
240
|
watches for the token and then runs the compaction or exits opencode.
|
|
241
241
|
Without the token nothing fires -- a toast points you at the items the
|
|
242
242
|
agent listed, and you re-run the command once they're handled. Text typed
|
|
243
|
-
after the command
|
|
244
|
-
goodbye or hand off context:
|
|
243
|
+
after the command reaches the agent first, labeled as a `User Message`
|
|
244
|
+
section ahead of the checklist (say goodbye or hand off context:
|
|
245
|
+
`/thatch/exit Good work - see you tomorrow`).
|
|
245
246
|
A greenlit `/thatch/exit` also unregisters the session from the chat
|
|
246
247
|
directory, so other sessions stop sending it mail. The commands
|
|
247
248
|
self-install into `~/.config/opencode/command/thatch/` at plugin startup;
|
|
@@ -445,6 +446,7 @@ Most configuration needs none. Two layers exist:
|
|
|
445
446
|
| `THATCH_DB_PATH` | `$XDG_CONFIG_HOME/thatch/thatch.db` | Override database location (the config file follows it) |
|
|
446
447
|
| `THATCH_MODEL` | `Xenova/bge-small-en-v1.5` | Override embedding model |
|
|
447
448
|
| `THATCH_EMBEDDING_BACKEND` | `wasm` | Set to `native` to run embeddings on onnxruntime-node (NAPI) instead of the wasm runtime |
|
|
449
|
+
| `THATCH_DEBUG` | unset (off) | Diagnostic logging to `debug.log` beside the database. `1` logs everything; or a comma-separated list of tags, e.g. `chat` or `chat:startup` |
|
|
448
450
|
|
|
449
451
|
Unchanged defaults: the database is created automatically, the embedding
|
|
450
452
|
model downloads once and is cached, the store name is auto-detected from
|