@jeffober/thatch 0.2.4 → 0.2.5

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.
Files changed (54) hide show
  1. package/README.md +8 -1
  2. package/artifacts/skills/thatch-clear-writing.md +103 -5
  3. package/artifacts/skills/thatch-coordination.md +69 -0
  4. package/artifacts/skills/thatch-pr-description.md +21 -13
  5. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  6. package/bin/thatch +27 -3
  7. package/docs/assets/deepwiki-badge.svg +1 -0
  8. package/docs/dev/README.md +7 -2
  9. package/docs/dev/features/cli.md +2 -0
  10. package/docs/dev/features/cross-session-chat.md +118 -11
  11. package/docs/dev/features/notifications.md +1 -2
  12. package/docs/dev/features/opencode-plugin.md +18 -3
  13. package/docs/dev/features/prediction-engine.md +44 -5
  14. package/docs/dev/features/session-tabs.md +155 -0
  15. package/docs/dev/features/setup.md +5 -0
  16. package/docs/dev/features/watchers.md +62 -6
  17. package/docs/dev/gotchas.md +68 -0
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +7 -2
  20. package/docs/dev/skills.md +1 -1
  21. package/docs/plans/generalized-session-heartbeat.md +212 -0
  22. package/docs/plans/prediction-consolidation.md +88 -68
  23. package/docs/user/README.md +12 -3
  24. package/docs/user/cli.md +3 -0
  25. package/docs/user/commands.md +11 -4
  26. package/docs/user/cross-session-chat.md +31 -8
  27. package/docs/user/prediction-engine.md +11 -0
  28. package/docs/user/setup.md +16 -0
  29. package/docs/user/skills.md +1 -1
  30. package/docs/user/watchers.md +22 -7
  31. package/package.json +10 -2
  32. package/src/alerts.ts +1 -1
  33. package/src/chat.ts +320 -27
  34. package/src/commands.ts +14 -0
  35. package/src/db.ts +51 -1
  36. package/src/git.ts +3 -1
  37. package/src/hygiene.ts +5 -0
  38. package/src/mcp.ts +95 -7
  39. package/src/opencode/tui-plugin.ts +70 -0
  40. package/src/opencode/v2.ts +197 -4
  41. package/src/prediction.ts +90 -0
  42. package/src/prompts.ts +110 -23
  43. package/src/runtime.ts +187 -9
  44. package/src/session-db.ts +4 -4
  45. package/src/session-tab-shared.ts +180 -0
  46. package/src/setup.ts +9 -3
  47. package/src/skills.ts +1 -0
  48. package/src/tool-defs.ts +412 -55
  49. package/src/tools.ts +2 -0
  50. package/src/watchers.ts +263 -26
  51. package/docs/plans/intuition-drives.md +0 -147
  52. package/docs/plans/llm-alerts.md +0 -287
  53. package/docs/plans/thatch-actions.md +0 -179
  54. package/docs/plans/worktree-deletion-recovery.md +0 -246
@@ -86,6 +86,29 @@ worktree, a `.git` directory means the project root, no `.git` reads as
86
86
  undetected. The roster shows it as a `loc:` token so sessions coordinating
87
87
  on a shared tree can tell who sits where.
88
88
 
89
+ ### Machinery sessions never auto-register
90
+
91
+ Sessions the plugin itself creates as machinery are not chat
92
+ participants and never join the directory: the direct-extraction child
93
+ (`triggerExtraction`, titled `thatch-extraction`) is the current case.
94
+ The primary guard is durable, not in-memory: child creation writes a
95
+ `chat_machinery` row (src/db.ts), and both auto-register paths (prompt
96
+ and idle) refuse a marked session. This matters because the in-memory
97
+ `childToParent` mapping that normally keeps children out is lost to a
98
+ plugin reload or daemon restart - an instance that processes the child's
99
+ events without the mapping would otherwise register it, and since v2
100
+ cannot delete sessions (see docs/dev/features/opencode-plugin.md), the
101
+ row never cleans up. The poller heartbeats every registered row of the
102
+ project it serves, so a machinery corpse stayed fresh forever and the
103
+ stale-row reaper could not reap it (81 corpse rows by 2026-10-07). The
104
+ idle path deletes any corpse row it finds for a marked session
105
+ (self-healing) and additionally refuses a session whose fetched title is
106
+ the machinery title (`isMachinerySessionTitle`, src/chat.ts) - the title
107
+ is the one signal that survives even a lost marker. Markers age out with
108
+ the 7-day auto-row TTL; session ids are never reused, so an expired
109
+ marker cannot resurrect a row. Explicit `chat_register` is unaffected:
110
+ a model deliberately joining the directory is never machinery.
111
+
89
112
  ### Startup registration and reclaim
90
113
 
91
114
  A session continued via `opencode -s <id>` gets no event when it comes
@@ -168,15 +191,73 @@ unregistration; a departed sender degrades to an unknown name in the
168
191
  reader's view (the endpoints are deliberately not foreign keys - leaving the
169
192
  directory must not be blocked by history).
170
193
 
194
+ Bodies over the part budget are not refused: `splitChatBody` (src/chat.ts)
195
+ auto-splits them into parts, and the first part is prefixed "(message 1 of
196
+ N)" while every part but the last is suffixed "(continued in next
197
+ message)", so a reader assembles the batch before acting on it. Cuts
198
+ prefer blank-line paragraph boundaries and back off a hard cut so a
199
+ surrogate pair never splits mid-character. The parts of one send are
200
+ inserted in ONE transaction - a crash cannot leave a partial batch - and
201
+ the tool output reports the split ("SENT AS N PARTS") so a caller cannot
202
+ mistake it for a single delivery.
203
+
204
+ Two budgets govern the split, and the docs state both honestly:
205
+
206
+ - The part budget is `CHAT_SPLIT_PART_LEN`, default 2000 UTF-16 units
207
+ (`THATCH_CHAT_SPLIT_PART_LEN` to override, clamped to 500..9000). The
208
+ DEFAULT is sized to the worst host we have observed: a version-skewed
209
+ opencode install whose tool-result renderer clipped at exactly 2000
210
+ characters with no saved-output pointer, cutting each part off before
211
+ the model saw it. Current opencode v2 (pinned tag v2.0.24) passes
212
+ 50 KiB and 2000 lines per tool result (packages/core/src/tool-output.ts
213
+ MAX_LINES/MAX_BYTES, host-configurable via
214
+ `tool_output.max_lines/max_bytes`), so a host on a known-good version
215
+ can safely raise the override.
216
+ - The whole body is capped by `CHAT_MAX_BODY_CHARS`, default 20,000
217
+ characters (`THATCH_CHAT_MAX_BODY_CHARS`), checked up front at accept; the
218
+ number of parts derives from it (ceil(total/partLen)). The refusal
219
+ reports the attempted count so a caller can split deterministically.
220
+ The grouped chat_read of a split batch renders as ONE tool result, so
221
+ the ceiling keeps a worst-case batch read (at most ten 2000-char parts
222
+ plus framing) comfortably inside the 50 KiB v2 envelope while still
223
+ bounding the context bomb the old 10k refusal used to block.
224
+
225
+ Read-side clipping: the chat_read drain shows each message up to
226
+ `CHAT_READ_PAGE_LEN` (2000) characters; longer rows are shown clipped
227
+ with a per-message marker that names the unit - "[clipped: showing 2000
228
+ of 9050 characters" - and the deterministic recovery path. Every drain
229
+ line carries the message id, and `chat_read` accepts `message` (a row id)
230
+ and `offset` (a character offset) to page through one message a page at a
231
+ time without draining or marking anything read. This is the recovery for
232
+ both a drain clip and a HOST-side tool-result clip (the version-skew host
233
+ above): even when a marker or a whole message is cut, the frame note at
234
+ the top of the result teaches the paging call, and ids make it exact.
235
+
171
236
  `chat_broadcast` posts one message to every other registered session at
172
- once, one inbox row per recipient - the existing wake machinery (grouping,
173
- gating, rate cap) treats each as ordinary mail. Stale sessions are skipped
174
- and reported rather than messaged: a host that has stopped heartbeat-ing
175
- will never read the mail, and dead mail to a dead process is just clutter.
176
- The sender is excluded. It is a separate tool rather than a magic
177
- `chat_send` recipient because "send to all" changes the behavior (fan-out,
178
- stale skipping, no address resolution), and a function that changes
179
- behavior drastically on a parameter value is two functions.
237
+ once, one inbox row per recipient per part - the existing wake machinery
238
+ (grouping, gating, rate cap) treats each as ordinary mail. Stale sessions
239
+ are skipped and reported rather than messaged: a host that has stopped
240
+ heartbeat-ing will never read the mail, and dead mail to a dead process is
241
+ just clutter. The sender is excluded. It is a separate tool rather than a
242
+ magic `chat_send` recipient because "send to all" changes the behavior
243
+ (fan-out, stale skipping, no address resolution), and a function that
244
+ changes behavior drastically on a parameter value is two functions.
245
+
246
+ Retries are idempotent by READ STATE within a short window: a send (or
247
+ per-recipient broadcast batch) whose content matches an existing row on
248
+ sender, recipient, body, and delivery kind within `CHAT_DEDUPE_WINDOW_MS`
249
+ (10s) succeeds without a second insert - but ONLY while a matching row is
250
+ still UNREAD in the recipient's inbox. The mail is there; a retry adds
251
+ nothing. Once every matching row was read, the identical body DELIVERS
252
+ again: presumptively an intentional resend after the first was consumed
253
+ (a repeated "ok", a resend the reader asked for). The tool output says
254
+ why it suppressed ("still UNREAD in the recipient's inbox"). This exists
255
+ because a harness that times out a chat_send which actually committed
256
+ retries the call, and the identical body otherwise lands twice (observed
257
+ 2026-10-07). The same body outside the window is always a new message.
258
+ For a split batch, ANY unread part suppresses the retry - idempotent even
259
+ while the recipient is mid-read (the poller keeps nudging the unread
260
+ parts), and only a fully read batch lets an intentional repeat through.
180
261
 
181
262
  ### Polling, heartbeat, staleness
182
263
 
@@ -360,11 +441,23 @@ parent pid against that table (`chatDerivedIdentity` on CoreContext,
360
441
  freshness-capped at 600s to bound pid reuse). A second model-proof layer
361
442
  is `CLAUDE_CODE_SESSION_ID`, which the host sets in the stdio server's
362
443
  environment at spawn. `resolveChatIdentity` priority: opencode host
363
- context > ppid mapping > env id > claimed `as`. Cursor's hooks and MCP
444
+ context > ppid mapping > env id > claimed `as`. The `as` fallback is also directory-bound: the
445
+ claimed identity must belong to the caller's project (the store slug
446
+ every registration writes as the row's project column) - a cross-project
447
+ or null-project claim is refused with a re-register escape hatch.
448
+ Cursor's hooks and MCP
364
449
  servers share one workspace process (ambiguous ppid, no per-session env),
365
450
  so Cursor keeps the claimed-`as` path, documented in the user doc's
366
451
  security model.
367
452
 
453
+ **Server project resolution.** The MCP server resolves its own project
454
+ directory as `THATCH_PROJECT_DIR ?? CURSOR_PROJECT_DIR ??
455
+ CLAUDE_PROJECT_DIR ?? cwd` (a superset of detectRepo's chain), maps
456
+ `unknown` to `global` exactly like the hook, and project-local setups
457
+ pin `THATCH_PROJECT_DIR` into the written MCP config so the resolution
458
+ is cwd-independent. Global-scope registrations are never pinned - one
459
+ entry serves every project.
460
+
368
461
  **Continuation adoption (Claude Code).** Claude Code forks the session id
369
462
  on resume - and has been observed to fork spontaneously (agent-team
370
463
  conversion, self-update, config reload) - which would strand the old
@@ -384,12 +477,23 @@ Staleness semantics differ by kind: an opencode row's stale age means its
384
477
  harness stopped beating (broadcast skips it), while an mcp row's age only means
385
478
  "between turns" (broadcast always delivers).
386
479
 
480
+ The `/thatch/whois` command (opencode-only action, src/commands.ts) is the
481
+ user-facing identity report: it relays `chat_status` for the invoking
482
+ session's handle, then joins the roster row for topic, project, and the
483
+ `loc:` worktree token - the topic being the live TUI session title, it is
484
+ how the operator maps a roster handle to a visible tab. An argument looks
485
+ up another session's row instead. It reports only; it never reads mail or
486
+ mutates anything.
487
+
387
488
  ## Defaults
388
489
 
389
490
  Timing constants live in `src/chat.ts`: poll interval 30s, staleness
390
491
  threshold two missed beats (60s), re-nudge window 15 minutes, nudge cap 6 per recipient
391
- per hour, auto-row TTL 7 days. There are no environment overrides yet; add
392
- them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
492
+ per hour, auto-row TTL 7 days. Environment overrides: `THATCH_CHAT_SPLIT_PART_LEN`
493
+ (auto-split part budget, default 2000, clamped 500..9000) and
494
+ `THATCH_CHAT_MAX_BODY_CHARS` (whole-body acceptance ceiling, default 20000);
495
+ both are resolved once at module load and sanitized to positive integers
496
+ within their clamps - invalid values silently fall back to the default.
393
497
 
394
498
  ## Source files
395
499
 
@@ -418,5 +522,8 @@ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
418
522
  - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
419
523
  - `tests/qa/auto/uc-099-chat-cli.ts` - `chat list` roster and `chat tail`
420
524
  JSONL contract over a seeded DB
525
+ - `tests/qa/auto/uc-116-chat-send-dedupe.ts` - a retried send is suppressed
526
+ - `tests/qa/auto/uc-119-chat-split-long-messages.ts` - an oversized body is
527
+ auto-split into marker-decorated parts
421
528
  - `tests/qa/live/uc-098-chat-cross-session.ts` - two real sessions exchange
422
529
  a message through the shared DB
@@ -10,8 +10,7 @@ Two delivery paths share the dispatcher and the config file:
10
10
  - **Agent-initiated**: the LLM calls `notify_user`. Works on every host.
11
11
  - **Automatic alerts** (opencode-only): the plugin runtime watches session
12
12
  events and notifies when the LLM pauses for human input or finishes a
13
- round of real work. See the Alerts section below and
14
- `docs/plans/llm-alerts.md` for the design decisions.
13
+ round of real work. See the Alerts section below for the design decisions.
15
14
 
16
15
  ## Source files
17
16
 
@@ -71,9 +71,10 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
71
71
  | session messages | `client.session.messages` | `session.context` | mapped into the v1 `{info: {role}, parts}` shape, so the wrap-up greenlight check works (the promise domain has no `message` accessor) |
72
72
  | session list | `client.session.list` | none | degrade (`-c` resume listing loses its data source) |
73
73
  | compaction trigger | `client.tui.executeCommand("session_compact")` | none | degrade (no `session.compact` on the promise domain's Pick, and the built-in `/compact` is a TUI palette action calling the server endpoint directly - the server command registry only knows config/plugin-registered names; the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
- | toasts | `client.tui.showToast` | none reachable | degrade (the `tui.toast.show` event has no producer surface from the promise context; a `tui` companion plugin entrypoint is the known lift candidate) |
75
- | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
76
- | tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
74
+ | toasts | `client.tui.showToast` | none reachable | degrade (the `tui.toast.show` event has no producer surface from the promise context; the `./tui` companion entrypoint now EXISTS for the session-tab feature - extending it to publish toasts is the remaining lift) |
75
+ | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `./tui` entrypoint's `ui.tabs.close` is the known lift candidate and the session-tab rpc bridge is the transport) |
76
+ | session tabs | n/a | rpc event bridge | NEW: `thatch_session_tab` (v2Only) spawns a detached subordinate session (create -> move -> chat-register -> emit `rpc.thatch-tabs.tab-opened` -> prompt); the `./tui` CLI plugin consumes the event and calls `ui.tabs.open` (open-without-focus, idempotent). The deferred `thatch_session_tab_close` will emit `rpc.thatch-tabs.tab-closed {sessionID, chatName?}` - the shape is reserved and consumed by the generalized-session-heartbeat plan |
77
+ | tab close | n/a | no session.deleted on v2 tab close | bounded 2026-10-07: watcher death detection cancels a closed tab's watchers once its pending events age past `THATCH_WATCH_DEATH_MINUTES` (default 120) while deliveries throw 404-class errors, and announces the death to live same-project sessions; the roster row itself still lingers until the reaper. NOTE: an earlier version of this row claimed deliveries "fail their idle gate" - wrong: the v2 gate opens (the status stub reads as idle) and the PROMPT fails; same leak, different link |
77
78
  | wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
78
79
  | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
79
80
 
@@ -112,15 +113,29 @@ key (`plugin` vs `plugins`) is auto-migrated by v2. The only user-owned file
112
113
  with a content change is the dev-session shim, and its new dual-shape content
113
114
  loads under both hosts.
114
115
 
116
+ The session-tab feature adds a second user-owned artifact: the TUI-side
117
+ plugin entry. A local FILE plugin shim carries a `server` entrypoint only
118
+ (v2's `plugin/module.ts`), and the TUI's plugin discovery loads directories
119
+ and symlinks, never plain files - so the dev-checkout install must be a
120
+ DIRECTORY shim: `thatch/index.ts` (the dual-shape re-export, unchanged) plus
121
+ `thatch/tui.ts` (re-exporting the TUI plugin). DELETE the old `thatch.ts`
122
+ file in the same step - a file and a directory shim coexisting dies with
123
+ `duplicate instance plugin ids: thatch` before any plugin code runs (the
124
+ duplicate check is in activation, so nothing inside the plugin can
125
+ self-heal it).
126
+
115
127
  ## Source files
116
128
 
117
129
  - `src/index.ts` -- dual entry, merged default export, lazy wrappers
118
130
  - `src/opencode/v1.ts` -- v1 hooks adapter
119
131
  - `src/opencode/v2.ts` -- v2 promise-context adapter + capability impl
132
+ - `src/opencode/tui-plugin.ts` -- v2 TUI CLI plugin (the `./tui` entrypoint: consumes the session-tab rpc events, drives `ui.tabs`)
133
+ - `src/session-tab-shared.ts` -- the cross-entry session-tab contract (rpc definition, prompt builder, validators)
120
134
  - `src/capabilities.ts` -- `HostCapabilities`, `capabilitiesFromClient`
121
135
  - `src/runtime.ts` -- the shared runtime (all behavioral logic)
122
136
  - `src/os-args.ts` -- pure argv helpers
123
137
  - `tests/opencode-v2.test.ts` -- v2 adapter contract tests (mocked context)
138
+ - `tests/session-tab-shared.test.ts` -- session-tab validators, rpc definition shape, TUI guard logic
124
139
  - `tests/plugin.test.ts` -- v1 adapter contract tests (mocked client)
125
140
 
126
141
  ## Interactions with other features
@@ -6,8 +6,9 @@ calls and updates it based on user feedback.
6
6
 
7
7
  ## What it does
8
8
 
9
- - Four tools: `prediction_query`, `prediction_update`, `prediction_list`,
10
- `prediction_delete`. All defined in `src/tool-defs.ts`.
9
+ - Tools: `prediction_query`, `prediction_update`, `prediction_list`,
10
+ `prediction_delete`, `prediction_mark_checked`. All defined in
11
+ `src/tool-defs.ts`.
11
12
  - Auto-fires at every user message: embeds the prompt, cosine-matches against
12
13
  matchers, injects scored predictions as a synthetic nudge part.
13
14
  - Bayesian confidence model — a graded score that is reinforced or weakened
@@ -101,6 +102,33 @@ Runs in a single DB transaction:
101
102
  Returns a status line with final confidence and confirm/disconfirm counts.
102
103
  Embeds both the matcher and prediction text.
103
104
 
105
+ ### Dedup pass (corpus-level consolidation)
106
+
107
+ Write-time dedup (0.85, both stores) catches near-identical statements at
108
+ creation. Wording-different duplicates — the same preference restated weeks
109
+ later — land below that floor and accumulate. The dedup pass finds them:
110
+
111
+ - `findPredictionDuplicates(store, threshold = 0.70)` in `src/prediction.ts`:
112
+ pairwise cosine over the store's predictions, skipping pairs already
113
+ adjudicated in `prediction_dedup_pairs`. The threshold is band placement:
114
+ [0.85, 1] is write-time territory, below 0.70 short-statement embeddings
115
+ are too noisy to call duplicates.
116
+ - Pairs are keyed on the canonical SORTED id pair — (A,B) and (B,A) are one
117
+ row, the same contract as the memory side's `dedup_pairs`.
118
+ - `prediction_mark_checked(id_a, id_b, status)` records the verdict:
119
+ `duplicate` (merged or treat as one) or `distinct` (both legitimate).
120
+ Distinct verdicts are the load-bearing case — a destroyed duplicate is
121
+ gone, but a judged-distinct pair re-surfaces every session start unless
122
+ recorded.
123
+ - The hygiene report surfaces `N prediction duplicate pairs pending review`;
124
+ the agent merges (fold the loser's matchers with `prediction_update`, then
125
+ `prediction_delete` the loser) or judges distinct, and marks the pair
126
+ either way. Scans are per-store: cross-store writes are edge-linked at
127
+ write time instead of duplicated.
128
+ - The co-fire refinement (behavioral evidence layered onto this method once
129
+ fire tracking exists) is designed in
130
+ [../../plans/prediction-consolidation.md](../../plans/prediction-consolidation.md).
131
+
104
132
  ### Auto-fire (opencode: `chat.message` hook)
105
133
 
106
134
  - Reuses the prompt embedding already computed for the recall nudge — no extra
@@ -190,11 +218,11 @@ that user preferences are stable, not time-sensitive.
190
218
 
191
219
  | File | Role |
192
220
  |------|------|
193
- | `src/prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names |
221
+ | `src/prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names, plus the corpus dedup pass (`findPredictionDuplicates`, pair verdicts) |
194
222
  | `src/scoring-engine.ts` | Generic four-table scoring engine with Bayesian confidence (shared base for prediction and behavior) |
195
223
  | `src/db.ts` | Prediction tables, `scorePredictionNudge` |
196
- | `src/tool-defs.ts` | Four prediction tools (`query`, `update`, `list`, `delete`) |
197
- | `src/prompts.ts` | `predictionNudge` formatting, verb selection |
224
+ | `src/tool-defs.ts` | Five prediction tools (`query`, `update`, `list`, `delete`, `mark_checked`) |
225
+ | `src/prompts.ts` | `predictionNudge` formatting, verb selection, dedup-merge guidance |
198
226
  | `src/sideband.ts` | `predictions` method for the MCP path |
199
227
 
200
228
  ## Database tables
@@ -241,6 +269,15 @@ prediction_provenance(
241
269
  created_at TEXT,
242
270
  FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
243
271
  )
272
+
273
+ prediction_dedup_pairs(
274
+ store TEXT,
275
+ id_a TEXT,
276
+ id_b TEXT,
277
+ status TEXT,
278
+ checked_at TEXT,
279
+ PRIMARY KEY (store, id_a, id_b)
280
+ )
244
281
  ```
245
282
 
246
283
  ## Key invariants
@@ -254,3 +291,5 @@ prediction_provenance(
254
291
  `chat.message` hook and MCP sideband) prevents scoring drift.
255
292
  5. **Matcher text is embedded raw.** No header prepend — unlike
256
293
  `memory_remember`, which prepends `# label\n\n` before embedding.
294
+ 6. **Adjudicated dedup pairs never re-surface.** Verdict rows are keyed on
295
+ the canonical sorted id pair; `findPredictionDuplicates` skips them.
@@ -0,0 +1,155 @@
1
+ # Session tabs (coordinated subordinates)
2
+
3
+ ## What it does
4
+
5
+ `thatch_session_tab` (opencode v2 only) lets a coordinating LLM spawn a
6
+ detached subordinate LLM session in a new TUI tab. The subordinate is a full
7
+ peer session: it runs its own agent loop across many turns, in a directory
8
+ of the coordinator's choosing, while the coordinator keeps working. The user
9
+ sees the subordinate's tab appear beside the coordinator's, unfocused.
10
+
11
+ This is NOT the `task` tool: task subagents run inside the dispatcher's turn
12
+ and return their result inline. A tab subordinate outlives the dispatching
13
+ turn and is supervised over cross-session chat.
14
+
15
+ ## How it works
16
+
17
+ ```text
18
+ coordinator session (LLM)
19
+ | tool call: thatch_session_tab {prompt, title, worktree|directory}
20
+ v
21
+ v2 adapter (src/opencode/v2.ts, buildSessionTabHost via CoreContext.sessionTabHost)
22
+ | validate (tool-defs: title, exactly-one location arg, fs existence,
23
+ | worktree same-main-checkout identity)
24
+ | 1. session.create({title, metadata: {thatch: {...}}, location})
25
+ | 2. worktree flow: session.move({directory: worktree}) (idle -> steer = immediate)
26
+ | 3. db.registerChatSession(subordinate) (participant, never machinery-marked)
27
+ | 4. rpc emit rpc.thatch-tabs.tab-opened {sessionID, directory}
28
+ | 5. session.prompt({text: preamble + task})
29
+ v
30
+ SSE event feed (isOpenCodeEvent passes rpc.*; fans out across all locations)
31
+ v
32
+ TUI CLI plugin (src/opencode/tui-plugin.ts, loaded from package exports "./tui")
33
+ | data.listen -> type filter -> directory guard (mirrors the built-in
34
+ | tui.* handlers: the event routes by the coordinator instance's
35
+ | directory) -> data.session.sync -> ui.tabs.open(sessionID)
36
+ v
37
+ Tab strip: subordinate tab appears, unfocused
38
+ ```
39
+
40
+ Key properties:
41
+
42
+ - **v2-only by construction.** The ToolDef carries `v2Only: true`; the v1
43
+ adapter (`src/tools.ts`) and the MCP server (`src/mcp.ts`) filter it. The
44
+ system prompt lists the tool only when the runtime is built with
45
+ `v2Tools: true` - a prompt listing a nonexistent tool is the
46
+ first-call-mistake class.
47
+ - **The event routes by the coordinator's location**, so the tab opens in
48
+ the coordinator's window under any `tabs.scope` - even when the session
49
+ lives in a worktree or temp dir. Tab ownership is by window, not by
50
+ session location.
51
+ - **The move precedes the emit**: a failed move leaves no stranded open tab
52
+ (placement is identical either way because routing is by coordinator
53
+ location).
54
+ - **No ack**: a headless run, a connecting TUI, or `tabs.mode: off` means
55
+ the event lands nowhere (harmlessly). The tool response says "tab
56
+ requested"; the session exists and is findable in the sessions list
57
+ regardless.
58
+ - **The subordinate is a chat participant**, deliberately never marked
59
+ machinery: pre-registration (idempotent, like an explicit chat_register)
60
+ gives the coordinator its chat name in the tool response, and the
61
+ auto-register converges on the same name. Gated on the chat-enabled
62
+ config like every other registration path: with chat off the
63
+ subordinate still spawns, but no roster row is written and the tool
64
+ response reports no chat name. `validateTitle` rejects the
65
+ machinery title so `isMachinerySessionTitle` can never misfire. A
66
+ mid-flow failure (create, move, emit, prompt) throws with the created
67
+ session id and the completed steps in the message - v2 has no session
68
+ delete, so the stranded session can at least be named and reported.
69
+ - **The worktree flow boots a second per-location thatch instance** (shared
70
+ db): the subordinate's pollers, nudges, extraction, wrap-up, and alerts
71
+ run in the worktree location's instance, and chat wake for the idle
72
+ subordinate depends on that instance being alive. The watcher poller
73
+ handoff is per location (`WatcherRegistry.#live` is keyed by directory):
74
+ the subordinate's instance booting never stops the coordinator's
75
+ poller - each location's instance polls only its own directory's
76
+ watchers. The pump caches the subordinate's location from the
77
+ tab-opened payload's directory field (its final, post-move directory),
78
+ never from the rpc envelope's location stamp (that is the publisher's
79
+ directory), so the subordinate's location-less execution events route
80
+ to the instance that owns it.
81
+
82
+ ## The cross-entry contract
83
+
84
+ `src/session-tab-shared.ts` (SDK-free, per the isolation rule in
85
+ `src/index.ts`) holds the rpc definition (`thatch-tabs`: `tab-opened`,
86
+ `tab-closed` - the latter emitted by the close tool (below) and consumed
87
+ by the generalized-session-heartbeat plan), the subordinate prompt builder,
88
+ and the validators. The TUI entrypoint may import only
89
+ `@opencode/plugin/tui` and this module.
90
+
91
+ ## Interactions
92
+
93
+ - **Chat**: the subordinate registers in its final directory's repo slug (a
94
+ worktree resolves to the main checkout's slug), so coordinator and
95
+ subordinate share a chat scope across the move.
96
+ - **Extraction/wrap-up/alerts**: the subordinate is a normal session owned
97
+ by its location's instance; the coordinator observes it via chat, not the
98
+ event pump (its post-move events fail the pump's directory filter
99
+ deliberately - the `childSessions` forwarding set is for extraction
100
+ children, and adding moved sessions would double-process their events).
101
+ - **Session metadata**: the created session carries
102
+ `metadata.thatch.{coordinatedBy, coordinatorSessionID, worktree?}`. The
103
+ host inherits parent metadata onto children - the subordinate's own
104
+ extraction children carry the marker too; treat inherited markers as
105
+ inherited, not first-party.
106
+
107
+ ## Closing a subordinate's tab
108
+
109
+ `thatch_session_tab_close {session}` (the target: the subordinate's chat
110
+ name or session id, resolved name-first through the chat roster) emits
111
+ `rpc.thatch-tabs.tab-closed {sessionID, chatName?}` over the same rpc
112
+ bridge. The TUI plugin consumes it symmetrically with open: the directory
113
+ guard applies, and `ui.tabs.close(sessionID)` closes the tab in every
114
+ window showing one (windows not showing it no-op). Close is TAB-LEVEL: the
115
+ session survives, the strip's reopen stack can restore it, and chat history
116
+ is untouched.
117
+
118
+ Two consumers ride the event: the death-detection machinery
119
+ (generalized-session-heartbeat) treats a tool-initiated close as a
120
+ CONFIRMED close - the closed session's watchers cancel immediately instead
121
+ of waiting on the 2h delivery-failure heuristic - and the coordinator's own
122
+ workflow closes dispatched subordinates once their work is verified. The
123
+ pump routes the close by the CLOSED session's own directory (resolved from
124
+ the pump's session-location cache, or live through the session API), not
125
+ by the event's location stamp: the stamp names the publishing coordinator
126
+ instance, while the watchers and status bookkeeping live in the instance
127
+ that owns the session - its post-move location.
128
+
129
+ ## Install requirement (the `./tui` entrypoint)
130
+
131
+ A local FILE plugin shim gets a `server` entrypoint only
132
+ (`packages/core/src/plugin/module.ts` at v2), and the TUI's plugin discovery
133
+ loads directories and symlinks, never plain files. The dev-checkout install
134
+ must therefore be a directory shim (`thatch/index.ts` + `thatch/tui.ts`),
135
+ and the old file shim must be DELETED in the same step - coexisting shims
136
+ die with `duplicate instance plugin ids: thatch` before any plugin code can
137
+ run. npm installs need the `exports` map to include `.` (the bare-specifier
138
+ path) alongside `./tui`, and `@opencode/plugin` is an optional peer
139
+ dependency for the TUI entry's runtime resolution.
140
+
141
+ ## Testing
142
+
143
+ - Unit: `tests/session-tab-shared.test.ts` (validators, definition shape,
144
+ guard logic), `tests/opencode-v2.test.ts` (the execute flows' ordering via
145
+ the `makeContext` double with `session.move` + the `rpc` domain).
146
+ - QA: uc-059 (tool list + MCP leak check), uc-014/uc-060 (skill counts),
147
+ uc-119 (the live spawn flow through the docker sandbox).
148
+ - Live smoke (recorded 2026-10-08, ALL SCENARIOS PASSED): headless legs -
149
+ directory variant, worktree variant (the move persisted in the session
150
+ row + metadata), headless no-tab; TUI-visual legs (a real container
151
+ attach) - the subordinate's tab appeared unfocused, the title rendered,
152
+ the subordinate responded, and the persisted strip state matched; kill +
153
+ re-attach restored both tabs (restart persistence). Sandbox note: mkdir
154
+ the /qa host dir BEFORE `docker run -v` - a missing host path is created
155
+ inside the Docker VM, invisible to the mac.
@@ -115,6 +115,11 @@ Cursor takes priority because Cursor also sets `CLAUDE_PROJECT_DIR` as an
115
115
  alias. Without the priority check, Cursor sessions would be misidentified as
116
116
  Claude Code.
117
117
 
118
+ Separately, the project directory for store/chat identity resolution
119
+ follows `THATCH_PROJECT_DIR ?? CURSOR_PROJECT_DIR ?? CLAUDE_PROJECT_DIR
120
+ ?? cwd` in the MCP server; project-local setups pin `THATCH_PROJECT_DIR`
121
+ into the written MCP config.
122
+
118
123
  `checkSetup` looks for start and end markers in the host's instructions file
119
124
  --- `CLAUDE.md` for Claude Code, `AGENTS.md` for Cursor. It checks local first,
120
125
  then global. Local takes priority.
@@ -88,9 +88,15 @@ A `setInterval` loop (unref'd - it never keeps the process alive)
88
88
  runs `poll()` every 60 seconds (configurable). Each cycle:
89
89
  fetch the current state, run `diffPrState()` against the watcher's
90
90
  last-seen state, filter events to the watched types, queue them, and
91
- attempt delivery. Poll errors are per-watcher; one bad PR never
92
- blocks the others. The diff is a pure function, so it is unit-tested
93
- without any network access.
91
+ attempt delivery. When the fetched snapshot differs from the last-seen
92
+ state, the cycle also rewrites the session's journal row: registry
93
+ rebuilds (v2 reload rehydration, the watch tools' journal reconcile)
94
+ hydrate the journaled state verbatim, so a journal that lags the
95
+ poller would make the next diff re-fire already-delivered events -
96
+ observed live in October 2026 as a watch re-delivering the same CI
97
+ completion on every reload. Poll errors are per-watcher; one bad PR
98
+ never blocks the others. The diff is a pure function, so it is
99
+ unit-tested without any network access.
94
100
 
95
101
  The interval is surfaced to the model through the registry's
96
102
  `pollSeconds` getter: watch-creation output states when the first
@@ -126,6 +132,41 @@ when this run finishes" request from leaving a standing watch polling
126
132
  a target nobody is waiting on. `watch_list` marks one-shot watchers
127
133
  with a `[once]` tag, and the registration output states the mode.
128
134
 
135
+ ### Expiry
136
+
137
+ A watch whose TTL passes is dropped at the next poll cycle, and the
138
+ owning session is told: a `watch_expired` event queues through the
139
+ normal pending path, naming the target and the watch's lifetime in
140
+ minutes. The nudge framing for an all-expiry delivery states that the
141
+ watched condition never occurred - deliberately without the
142
+ greenlit-gating carve-out, because "the watch ran out of clock" must
143
+ never read as "the wait is over". Watches that expire while a session
144
+ is away are a separate path: they are not re-armed, and the re-arm
145
+ notice reports the expired count.
146
+
147
+ ### Death detection (closed v2 tabs)
148
+
149
+ A closed opencode v2 tab fires no `session.deleted`, so its watchers
150
+ would poll until the TTL with deliveries queued forever. The registry
151
+ declares death when ALL of: the session's pending queue has been
152
+ non-empty longer than the threshold (`THATCH_WATCH_DEATH_MINUTES`,
153
+ default 120), its delivery attempts have THROWN consecutively (a skip -
154
+ busy or compacting - resets the chain; skips are never death evidence),
155
+ the throw is 404-class (verified live 2026-10-07: prompt_async to a
156
+ closed v2 tab returns 404 while the session row persists; a 5xx loop is
157
+ a wedged server, never death), and the session is hosted by this
158
+ process (sibling v1 processes and MCP sessions are never touched).
159
+ MCP-owner watches keep TTL-only death: their turn-driven heartbeat
160
+ cannot distinguish a closed Cursor tab from a parked one. On death:
161
+ watchers and pending drop, and live same-project sessions get a
162
+ `watcherDeathNotice` carrying the dead session's chat name and session
163
+ id (the thatch-coordination loop's respawn key); a durable
164
+ `watcher_death` runtime_state row covers sessions that were busy at
165
+ death time, surfaced and cleared at other sessions' next prompt. The
166
+ notice says the watches were CANCELLED and points at `watch_create` -
167
+ the re-arm-on-resume promise belongs only to the restart-dormant path,
168
+ where the definitions survive and resuming the session re-arms them.
169
+
129
170
  ### Command watches
130
171
 
131
172
  The command source stretches the fetch/diff recipe in two ways:
@@ -180,6 +221,11 @@ Events queue in an in-memory pending map keyed by session. Delivery
180
221
  is gated by a `canDeliver` predicate the plugin supplies: the session
181
222
  must be idle (tracked from `session.status` events) and not
182
223
  compacting. Proactive prompts are never injected into a running turn.
224
+ The queue is visible to the session itself: `watch_list` appends an
225
+ "N events detected, waiting for idle to deliver" line with a
226
+ per-target breakdown while events sit undelivered, so a busy session
227
+ with queued events is distinguishable from a watcher that fired
228
+ nothing.
183
229
 
184
230
  When the gate is open, the plugin calls `client.session.promptAsync`
185
231
  on the session with a synthetic text part built by
@@ -206,9 +252,11 @@ cycle or when the session next goes idle (the event hook calls
206
252
  `pr_review_resolved`, `pr_commit`, `pr_status`, `pr_description`,
207
253
  `pr_ci`. The tool's `events` argument selects a subset; the default is
208
254
  all eight. Branch watchers add `branch_commit`, `branch_ci`, and
209
- `branch_workflow` (the full vocabulary is `WATCHER_EVENT_TYPES` in
210
- src/watchers.ts, test-enforced); command watchers register the single
211
- `command_success` type.
255
+ `branch_workflow`; command watchers register the single
256
+ `command_success` type. The registry itself emits one lifecycle type
257
+ across all sources, `watch_expired`, which is not selectable via the
258
+ `events` argument (the full vocabulary is `WATCHER_EVENT_TYPES` in
259
+ src/watchers.ts, test-enforced).
212
260
 
213
261
  ## Interactions with other features
214
262
 
@@ -229,6 +277,8 @@ src/watchers.ts, test-enforced); command watchers register the single
229
277
  - `THATCH_WATCH_TTL_MINUTES` - watcher time-to-live (default 480)
230
278
  - `THATCH_WATCH_MAX_PER_SESSION` - active watchers per session
231
279
  (default 5; all sources share the budget)
280
+ - `THATCH_WATCH_DEATH_MINUTES` - pending-age threshold before a dead
281
+ session's watchers are cancelled and announced (default 120)
232
282
  - `THATCH_WATCH_COMMAND_TIMEOUT_SECONDS` - per-run kill timeout for
233
283
  watched commands (default 30)
234
284
 
@@ -248,6 +298,12 @@ src/watchers.ts, test-enforced); command watchers register the single
248
298
  mocked gh runner
249
299
  - `tests/qa/auto/uc-101-command-watchers.ts` - command-watch lifecycle
250
300
  against a mocked command runner
301
+ - `tests/qa/auto/uc-114-watcher-expiry-replay.ts` - expiry notification
302
+ and the no-replay guarantee across journal rehydration
303
+ - `tests/qa/auto/uc-118-watcher-death-detection.ts` - closed-tab death
304
+ detection (404-class delivery persistence) and the once-only notice
305
+ - `tests/qa/auto/uc-115-watch-list-pending-events.ts` - pending-event
306
+ visibility in watch_list while the session is busy
251
307
 
252
308
  ## Adding a new source type
253
309