@jeffober/thatch 0.2.7 → 0.2.8

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.
@@ -102,6 +102,8 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
102
102
  | `THATCH_PREDICTION_THRESHOLD` | Cutoff for prediction auto-fire | `0.60` |
103
103
  | `THATCH_BEHAVIOR_THRESHOLD` | Cutoff for behavior auto-fire | `0.60` |
104
104
  | `THATCH_CHILD_RECONCILE_MS` | Reload window before a restored extraction child is judged finished (test knob) | `10000` |
105
+ | `THATCH_STRAND_SWEEP_MS` | Interval for the stranded-session self-heal sweep | `21600000` (6h) |
106
+ | `THATCH_STRAND_SWEEP` | Set to `off` (or `0`/`false`) to disarm the strand sweep (test knob) | falls through (sweep on) |
105
107
  | `CLAUDE_PROJECT_DIR` | Claude Code project dir | `process.cwd()` |
106
108
  | `CURSOR_PROJECT_DIR` | Cursor project dir | falls through |
107
109
  | `THATCH_WATCH_DEATH_MINUTES` | Pending-age threshold for watcher death detection | `120` |
@@ -429,7 +429,7 @@ last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
429
429
  `filterChatTailRows()` narrows every feed snapshot (backlog and follow
430
430
  polls alike) by body regexes, participant-name substrings, and a
431
431
  half-open time window. The diff itself is `chatTailDiff()` (unit-tested
432
- in `tests/chat.test.ts`): sent events on first view, then new sends and
432
+ in `tests/chat-tail.test.ts`): sent events on first view, then new sends and
433
433
  newly-read messages per poll, with the diff state seeded from the full
434
434
  feed so neither the limit nor a mid-follow rename/unregister can
435
435
  resurface old rows as sent events. `chat_messages.via_broadcast` marks
@@ -589,8 +589,11 @@ within their clamps - invalid values silently fall back to the default.
589
589
  - `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
590
590
  `isChatEchoParts()`, system prompt Cross-Session Chat section, MCP
591
591
  absent-tools note
592
- - `tests/chat.test.ts` - store, pool, migration, echo-text, liveness, tail
593
- diff/backlog, and poller unit tests (temp-dir SQLite, injected delivery)
592
+ - `tests/chat-store.test.ts`, `tests/chat-poller.test.ts`,
593
+ `tests/chat-tail.test.ts`, `tests/chat-echo.test.ts` - store, pool,
594
+ migration, echo-text, liveness, tail diff/backlog, and poller unit tests
595
+ (temp-dir SQLite, injected delivery; shared fixtures in
596
+ `tests/mocks/chat-harness.ts`)
594
597
  - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
595
598
  - `tests/qa/auto/uc-099-chat-cli.ts` - `chat list` roster and `chat tail`
596
599
  JSONL contract over a seeded DB
@@ -203,7 +203,7 @@ rejected for that reason (plus v1 has no TUI plugin surface at all).
203
203
  records argv), AppleScript escaping, partial vs total failure, unsupported
204
204
  platform, plus tool-level tests (merge-no-wipe, echo, `mode: none` no-op,
205
205
  configured voice honored) with `THATCH_DB_PATH` pointed at a tempdir.
206
- - `tests/tool-defs.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
206
+ - `tests/tool-defs-catalog-session.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
207
207
  - `tests/alerts.test.ts` -- the alert state machine: `deriveRoundShape`
208
208
  (idle-marker delimiter, v1 fallback, synthetic trigger detection), the
209
209
  real-work flag, pause dedup and re-arm, abort/shutdown silence,
@@ -66,17 +66,19 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
66
66
  | tool buffering | `tool.execute.after` hook | `tool.hook("execute.after")` | wired: feeds the same extraction buffer; `Tool.Result.content` is flattened (string passes through, content-part arrays contribute their text parts); the title is derived from the tool name and args (`deriveTitle`), since v2's `Tool.Result.output` is a typed output value, not a title |
67
67
  | noReply deliveries | `promptAsync` with `noReply` | none | gated off via `HostCapabilities.noReplyDelivery` -- chat echoes and the session-start reminder are skipped on v2 (delivering them would start real model turns: a feedback loop) |
68
68
  | synthetic wake deliveries | `promptAsync` with `synthetic` parts | `session.synthetic` endpoint | watcher + chat wake nudges route to v2's synthetic endpoint (TUI-hidden), matching v1 |
69
- | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete rides `session.remove` (upstream #52387, in v2.0.22), runtime-guarded like compact -- an older host keeps the documented gap (the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
69
+ | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete rides `session.remove` (upstream #52387, in v2.0.22), runtime-guarded like compact -- an older host keeps the documented gap (the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction); the strand sweep deletes such corpses opportunistically and moves stranded real sessions to their project's canonical checkout (see session-lifecycle.md) |
70
70
  | session status | `client.session.status` | none | returns `{}`; the wake gate treats unknown as idle and the event-fed status map does the gating |
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
+ | session enumeration (strand sweep) | `client.session.list` | readonly read of opencode.db | v2's promise domain has no session list, and the sweep needs machine-wide rows (dead-worktree sessions belong to no live instance), so the v2 adapter reads `session_v2` + `project` directly -- the same readonly pattern as the `-c` inheritance resolution; null on schema drift, which the sweep treats as "nothing known, do nothing" |
74
+ | session move | none | `session.move` (promise domain) | the strand sweep's self-heal for real sessions stranded in a deleted worktree; v1 has no move endpoint, so the adapter omits it and the sweep degrades to machinery-only deletion |
73
75
  | compaction trigger | `client.tui.executeCommand("session_compact")` | `session.compact({sessionID})` | wired behind a runtime guard: upstream [#52385](https://github.com/anomalyco/opencode/pull/52385) exposed `session.compact` on the promise domain (in the installed v2.0.25), newer than the pinned dev types - an older host degrades to a logged no-op (the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
76
  | toasts | `client.tui.showToast` | toast rpc event | wired: every runtime toast call site (alerts, extraction metrics, chat registration, blocked wrap-ups, watcher rearm notices) emits the bridge's `toast` event, which the `./tui` plugin shows via `ui.toast.show`; the pump never translates it (TUI-only by nature). Best-effort by contract: a TUI-less environment or an rpc-less SDK floor shows nothing, silently |
75
77
  | wrap-up exit | `client.tui.publish` | exit-tab-closed rpc event | the wrap-up exit records the session's watcher deaths synchronously (the runtime, before the chat unregister - `sessionDied` places the death row by the chat row, so the order is load-bearing) and then emits `rpc.thatch-tabs.exit-tab-closed`, a TUI-ONLY close of the session's OWN tab (the `./tui` plugin's `ui.tabs.close`): the tab-scoped semantic upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984) asks for, since the daemon hosts every tab and `app.exit` would take down unrelated sessions. The pump deliberately never translates this event (a second death pass would erase the durable death row the first one wrote; the session_tab_close tool's `tab-closed` is the only death-translating event). Degrades to a logged no-op without the rpc surface; a TUI with no tab for the session (headless run, tabs off) never acts - the event is ephemeral |
76
78
  | session tabs | n/a | rpc event bridge | `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). `thatch_session_tab_close` emits `rpc.thatch-tabs.tab-closed {sessionID, chatName}` - the pump translates it into the runtime's confirmed-death path. The registration's third event, `exit-tab-closed` (the wrap-up exit's), is TUI-only - see the wrap-up exit row |
77
79
  | 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 |
78
80
  | 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 |
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 |
81
+ | 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); a dead process's journaled extraction children are also DELETED through the host before the prune (the opencode session row is global and would otherwise outlive the journal as a picker corpse - the restart-window leak). 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 |
80
82
 
81
83
  ## Live verification and remaining unknowns
82
84
 
@@ -160,9 +162,9 @@ self-heal it).
160
162
  - `src/capabilities.ts` -- `HostCapabilities`, `capabilitiesFromClient`
161
163
  - `src/runtime.ts` -- the shared runtime (all behavioral logic)
162
164
  - `src/os-args.ts` -- pure argv helpers
163
- - `tests/opencode-v2.test.ts` -- v2 adapter contract tests (mocked context)
165
+ - `tests/opencode-v2-*.test.ts` -- v2 adapter contract tests (mocked context; shared fixtures in `tests/mocks/opencode-v2-harness.ts`)
164
166
  - `tests/session-tab-shared.test.ts` -- session-tab validators, rpc definition shape, TUI guard logic
165
- - `tests/plugin.test.ts` -- v1 adapter contract tests (mocked client)
167
+ - `tests/plugin-*.test.ts` -- v1 adapter contract tests (mocked client; shared fixtures in `tests/mocks/plugin-harness.ts`)
166
168
 
167
169
  ## Interactions with other features
168
170
 
@@ -13,6 +13,7 @@ opencode emits bus events for session lifecycle changes. Thatch subscribes to th
13
13
  - Sub-agent lifecycle: task-dispatched sub-agents complete accepted entries but are not deleted
14
14
  - Session error recovery: child errors requeue parent's accepted entries
15
15
  - Session deletion recovery: child deletion requeues; parent deletion completes accepted entries
16
+ - Stranded-session self-heal: sessions whose recorded directory is gone (a merged + deleted worktree) are deleted when machinery (`thatch-extraction` corpses) and moved to the project's canonical checkout when real
16
17
  - Wrap-up commands: `/thatch/compact` and `/thatch/exit` arm a greenlight check resolved on the next idle
17
18
 
18
19
  ## How it works
@@ -118,6 +119,40 @@ first-ever install is invisible until the next server start.
118
119
  - Clear the `compacting` flag so chat.message nudges resume
119
120
  - See [compaction-recovery.md](compaction-recovery.md)
120
121
 
122
+ ### Stranded-session self-heal sweep
123
+
124
+ Why it exists: opencode v2 records a directory on every session row, and
125
+ opening a session whose directory is gone (a merged + deleted worktree) boots
126
+ a dead path and shows the "session location unavailable" recovery dialog.
127
+ Sessions get stranded there in two ways: extraction children leaked by hosts
128
+ older than v2.0.22 (whose session domain had no `remove`, so cleanup was a
129
+ silent no-op) and children whose idle event died with a daemon restart. The
130
+ sweep runs at setup and on an interval (`THATCH_STRAND_SWEEP_MS`), and the
131
+ rehydrate pass finalizes journaled extraction children through the host
132
+ before pruning their rows, so both the corpse population and the leak paths
133
+ are covered.
134
+
135
+ What it does, decided by `planStrandActions` (pure, unit-tested):
136
+
137
+ - Sessions in a live directory are never touched, whatever the row looks like
138
+ - Machinery (`thatch-extraction` title) in a dead directory: delete via the
139
+ host's session delete
140
+ - Real top-level sessions in a dead directory: MOVE to the project's
141
+ canonical checkout (`project.worktree` in opencode.db) -- the same outcome
142
+ as the manual recovery dialog, minus the picking; history is preserved and
143
+ user sessions are never deleted
144
+ - Host-created task children (parentId set): always skipped -- they are
145
+ opencode's own records of subagent work and deleting them would break the
146
+ parent transcript's subagent rendering
147
+ - Unresolvable canonical or no move endpoint: skipped and logged
148
+
149
+ The host supplies the enumeration (v1: `client.session.list`; v2: readonly
150
+ read of opencode.db, since the promise domain has no session list and the
151
+ sweep needs machine-wide rows) plus the move/delete endpoints; every leg
152
+ degrades silently. A summary toast fires only when something was healed. Set
153
+ `THATCH_STRAND_SWEEP=off` to disarm the sweep entirely (tests do, so it can
154
+ never act on a developer's real database through mocks).
155
+
121
156
  ### Internal state maps
122
157
 
123
158
  - **childToParent**: `Map<childID, parentID>` -- maps child sessions to their parents
@@ -147,8 +147,9 @@ dependency for the TUI entry's runtime resolution.
147
147
  ## Testing
148
148
 
149
149
  - Unit: `tests/session-tab-shared.test.ts` (validators, definition shape,
150
- guard logic), `tests/opencode-v2.test.ts` (the execute flows' ordering via
151
- the `makeContext` double with `session.move` + the `rpc` domain).
150
+ guard logic), `tests/opencode-v2-adapter-tabs.test.ts` (the execute flows'
151
+ ordering via the `makeContext` double with `session.move` + the `rpc`
152
+ domain).
152
153
  - QA: uc-059 (tool list + MCP leak check), uc-014/uc-060 (skill counts),
153
154
  uc-119 (the live spawn flow through the docker sandbox).
154
155
  - Live smoke (recorded 2026-10-08, ALL SCENARIOS PASSED): headless legs -
@@ -291,9 +291,11 @@ src/watchers.ts, test-enforced).
291
291
  session.status tracking, session.deleted cleanup, dispose
292
292
  - `src/prompts.ts` - `watcherNotificationNudge()`, system prompt
293
293
  Watchers section
294
- - `tests/watchers.test.ts` - registry, poll, and diff unit tests
294
+ - `tests/watchers-registry.test.ts` - registry, poll, and death-detection unit tests
295
+ - `tests/watchers-git-state.test.ts` - PR/branch diff and check-run description unit tests
296
+ - `tests/watchers-commands.test.ts` - command watcher and poll-resilience unit tests
295
297
  (mocked gh and command runners, no network)
296
- - `tests/tool-defs.test.ts` - watch tool execute-function tests
298
+ - `tests/tool-defs-watch-chat.test.ts` - watch tool execute-function tests
297
299
  - `tests/qa/auto/uc-095-watchers.ts` - end-to-end lifecycle against a
298
300
  mocked gh runner
299
301
  - `tests/qa/auto/uc-101-command-watchers.ts` - command-watch lifecycle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -77,6 +77,28 @@ export interface HostCapabilities {
77
77
  sessionGet(id: string): Promise<{ title?: string } | null>;
78
78
  /** List sessions in this directory. Null when unavailable (v2 degrade). */
79
79
  sessionList(): Promise<{ id: string; parentID?: string; time?: { updated?: number } }[] | null>;
80
+ /**
81
+ * Enumerate every session the host knows (id, title, directory, parent
82
+ * link, project link) plus each project's canonical checkout directory,
83
+ * for the runtime's stranded-session sweep. v1 maps client.session.list();
84
+ * v2 reads opencode.db readonly (its promise domain has no session list
85
+ * and the sweep needs machine-wide rows, not per-directory ones). Null
86
+ * when the host has no enumeration surface or the read fails - the sweep
87
+ * treats null as "nothing known, do nothing".
88
+ */
89
+ enumerateSessions(): Promise<{
90
+ sessions: { id: string; title: string; directory: string; parentId: string | null; projectId: string | null }[];
91
+ canonicalByProject: Record<string, string>;
92
+ } | null>;
93
+ /**
94
+ * Move a session to another directory. The strand sweep's self-heal for
95
+ * real sessions stranded in a deleted worktree (deletion is reserved for
96
+ * machinery sessions); moving is exactly what opencode's own
97
+ * "session location unavailable" recovery dialog does, minus the
98
+ * directory picking. Optional: the v1 SDK has no move endpoint, so the
99
+ * v1 adapter omits it and the sweep degrades to machinery-only deletion.
100
+ */
101
+ sessionMove?(id: string, directory: string): Promise<void>;
80
102
  /**
81
103
  * Fetch a session's message list (wrap-up greenlight check, alert round
82
104
  * classification). Null when unavailable. v2 reads them via
@@ -148,6 +170,20 @@ export function capabilitiesFromClient(client: PluginInput["client"]): HostCapab
148
170
  const { data } = await client.session.list();
149
171
  return data ?? null;
150
172
  },
173
+ enumerateSessions: async () => {
174
+ const { data } = await client.session.list();
175
+ if (!data) return null;
176
+ return {
177
+ sessions: data.map((s) => ({
178
+ id: s.id,
179
+ title: s.title ?? "",
180
+ directory: (s as { directory?: string }).directory ?? "",
181
+ parentId: (s as { parentID?: string }).parentID ?? null,
182
+ projectId: null,
183
+ })),
184
+ canonicalByProject: {},
185
+ };
186
+ },
151
187
  sessionMessages: async (id) => {
152
188
  const { data } = await client.session.messages({ path: { id } });
153
189
  // Map the message-level error (if any) to its name - the alert
@@ -5,6 +5,7 @@ import type { ToolContext as V2ToolContext } from "@opencode/plugin/promise/tool
5
5
  import type { ThatchDB } from "../db";
6
6
  import type { HostCapabilities, PromptPart, ToastInput } from "../capabilities";
7
7
  import { createRuntime } from "../runtime";
8
+ import { enumerateSweepSessions } from "../session-db";
8
9
  import { wrapUpCommandContent, wrapUpCommandDescription } from "../commands";
9
10
  import { deriveTitle } from "../extraction";
10
11
  import { detectRepo, detectWorktreeKind } from "../git";
@@ -929,6 +930,20 @@ function buildCapabilities(
929
930
  return (await session.get({ sessionID: id })) ?? null;
930
931
  },
931
932
  sessionList: async () => null,
933
+ enumerateSessions: async () => {
934
+ // The promise domain has no session list, and the strand sweep needs
935
+ // machine-wide rows (dead-worktree sessions belong to no live
936
+ // instance), so this reads opencode.db directly - the same readonly
937
+ // pattern the -c inheritance resolution already uses. Null on any
938
+ // schema drift; the sweep treats that as "nothing known".
939
+ return enumerateSweepSessions();
940
+ },
941
+ sessionMove: async (id, directory) => {
942
+ // The pinned dev types carry move on the SessionDomain pick (the
943
+ // session_tab spawn flow uses it), so this is a typed call, unlike
944
+ // remove/compact above.
945
+ await session.move({ sessionID: id, directory });
946
+ },
932
947
  sessionMessages: async (id) => {
933
948
  // The pinned promise domain has no message-list endpoint (no `message`
934
949
  // accessor; compact/remove exist on newer SDK builds but not here).
package/src/runtime.ts CHANGED
@@ -177,6 +177,55 @@ export function guardDelivery(
177
177
  };
178
178
  }
179
179
 
180
+ /** What the strand sweep should do, decided purely from enumerated session rows. */
181
+ export interface StrandPlan {
182
+ /** Machinery sessions (this plugin's extraction children) to delete via the host. */
183
+ remove: string[];
184
+ /** Real sessions to relocate out of a dead directory, to their project's canonical checkout. */
185
+ move: { id: string; to: string }[];
186
+ /** Rows the sweep saw but deliberately left alone. */
187
+ skipped: number;
188
+ }
189
+
190
+ /**
191
+ * Decides, per session row, whether a stranded session gets deleted
192
+ * (machinery), moved (a real session whose directory is gone - the same
193
+ * outcome as opencode's manual "session location unavailable" recovery, minus
194
+ * the directory picking), or left alone. Everything hinges on the directory
195
+ * being dead on disk: a session in a live directory is someone's working
196
+ * state, however it looks. Host-created task children (parentId set) are
197
+ * always skipped - they are opencode's own records of subagent work,
198
+ * invisible in top-level session lists, and deleting them would break the
199
+ * parent transcript's subagent rendering.
200
+ */
201
+ export function planStrandActions(input: {
202
+ sessions: { id: string; title: string; directory: string; parentId: string | null; projectId: string | null }[];
203
+ canonicalByProject: Record<string, string>;
204
+ pathExists: (path: string) => boolean;
205
+ /** When false (host has no move endpoint), real sessions count as skipped. */
206
+ canMove: boolean;
207
+ }): StrandPlan {
208
+ const plan: StrandPlan = { remove: [], move: [], skipped: 0 };
209
+ for (const s of input.sessions) {
210
+ if (input.pathExists(s.directory)) continue;
211
+ if (isMachinerySessionTitle(s.title)) {
212
+ plan.remove.push(s.id);
213
+ continue;
214
+ }
215
+ if (s.parentId) {
216
+ plan.skipped++;
217
+ continue;
218
+ }
219
+ const canonical = s.projectId ? input.canonicalByProject[s.projectId] : undefined;
220
+ if (input.canMove && canonical && canonical !== s.directory && input.pathExists(canonical)) {
221
+ plan.move.push({ id: s.id, to: canonical });
222
+ } else {
223
+ plan.skipped++;
224
+ }
225
+ }
226
+ return plan;
227
+ }
228
+
180
229
  export async function createRuntime(input: {
181
230
  capabilities: HostCapabilities;
182
231
  directory: string;
@@ -760,7 +809,25 @@ export async function createRuntime(input: {
760
809
  const ownDirectory = row.directory === directory;
761
810
  const isStartup = inheritSession !== undefined && row.sessionID === inheritSession;
762
811
  if (samePid && !ownDirectory) continue; // live sibling - untouched
763
- if (!samePid && !ownDirectory) continue; // another location's instance owns these rows - its own restart cleans them
812
+ if (!samePid && !ownDirectory) {
813
+ // Another location's instance owns these rows - its own restart
814
+ // cleans them. Exception: a directory that no longer exists (a merged
815
+ // and deleted worktree) will never boot an instance again, so its
816
+ // journaled extraction children are stranded forever - the opencode
817
+ // session row is global and outlives this journal. Finalize the child
818
+ // through the host and drop the journal row; the strand sweep (below)
819
+ // is the backstop for corpses this journal never knew about.
820
+ if (row.kind === "child" && !existsSync(row.directory)) {
821
+ const rec = row.value as { kind?: "extraction" | "task" };
822
+ if (rec?.kind === "extraction") {
823
+ void caps.sessionDelete(row.sessionID).catch((err) =>
824
+ debug("strand-sweep", `dead-directory finalize failed for ${row.sessionID}: ${err}`),
825
+ );
826
+ db.runtimeStateDelete(row.kind, row.sessionID);
827
+ }
828
+ }
829
+ continue;
830
+ }
764
831
  if (!samePid && !isStartup) {
765
832
  // Watcher definitions survive a restart as DORMANT rows instead of
766
833
  // being pruned: the defining session's harness died with the old
@@ -794,6 +861,20 @@ export async function createRuntime(input: {
794
861
  // consumption.
795
862
  continue;
796
863
  }
864
+ // Restart-window leak fix (same as the child-row branch below): the
865
+ // child's opencode session row is global and outlives this journal,
866
+ // so an extraction child whose idle event died with the old process
867
+ // must be finalized through the host before the row goes. Task
868
+ // children and kindless pre-marker rows are left to the strand
869
+ // sweep's title-based pass.
870
+ if (row.kind === "child") {
871
+ const rec = row.value as { kind?: "extraction" | "task" };
872
+ if (rec?.kind === "extraction") {
873
+ void caps.sessionDelete(row.sessionID).catch((err) =>
874
+ debug("strand-sweep", `restart finalize failed for ${row.sessionID}: ${err}`),
875
+ );
876
+ }
877
+ }
797
878
  db.runtimeStateDelete(row.kind, row.sessionID);
798
879
  continue;
799
880
  }
@@ -849,6 +930,18 @@ export async function createRuntime(input: {
849
930
  if (inheritSession !== undefined && rec?.parentID === inheritSession && Array.isArray(rec.snapshot)) {
850
931
  for (const interaction of rec.snapshot) extraction.push(interaction);
851
932
  }
933
+ // Restart-window leak fix: the child's opencode session row is
934
+ // global and outlives this journal, so an extraction child whose
935
+ // idle event died with the old process would sit in the session
936
+ // picker forever (and `-c` can land in it). Finalize through the
937
+ // host, best-effort. Kindless pre-marker rows are left to the
938
+ // strand sweep's title-based pass, which never touches
939
+ // host-created task children.
940
+ if (rec?.kind === "extraction") {
941
+ void caps.sessionDelete(row.sessionID).catch((err) =>
942
+ debug("strand-sweep", `restart finalize failed for ${row.sessionID}: ${err}`),
943
+ );
944
+ }
852
945
  db.runtimeStateDelete(row.kind, row.sessionID);
853
946
  continue;
854
947
  }
@@ -1146,6 +1239,102 @@ export async function createRuntime(input: {
1146
1239
  };
1147
1240
  void reconcileRestoredChildren();
1148
1241
 
1242
+ // Self-heal for sessions stranded in directories that no longer exist
1243
+ // (merged + deleted worktrees): opencode v2 records the directory on
1244
+ // every session, and opening a stranded one boots a dead path and shows
1245
+ // the "session location unavailable" recovery dialog. Machinery corpses
1246
+ // (this plugin's extraction children, leaked by pre-2.0.22 hosts or a
1247
+ // daemon restart mid-extraction) are deleted; REAL sessions are moved to
1248
+ // their project's canonical checkout - never deleted. Runs at setup and
1249
+ // on an interval, because worktrees die while the daemon lives on. The
1250
+ // host supplies the enumeration (v1: client list; v2: readonly
1251
+ // opencode.db read) and the move/delete endpoints; every leg degrades
1252
+ // silently. Concurrent instances each run their own sweep - the actions
1253
+ // are idempotent (a second delete of the same corpse throws NotFound
1254
+ // into the catch, a second move lands on an already-moved session).
1255
+ let sweepInFlight = false;
1256
+ const sweepStrandedSessions = async (trigger: string): Promise<void> => {
1257
+ // Kill switch read at call time: tests set THATCH_STRAND_SWEEP=off so
1258
+ // the boot sweep never acts on the developer's real opencode.db through
1259
+ // test mocks, and an operator can disarm the sweep without a rebuild.
1260
+ const gate = process.env.THATCH_STRAND_SWEEP;
1261
+ if (gate === "0" || gate === "false" || gate === "off") return;
1262
+ if (disposed || sweepInFlight) return;
1263
+ sweepInFlight = true;
1264
+ try {
1265
+ let enumerated: Awaited<ReturnType<typeof caps.enumerateSessions>> = null;
1266
+ try {
1267
+ enumerated = await caps.enumerateSessions();
1268
+ } catch (err) {
1269
+ debug("strand-sweep", `enumerate failed (${trigger}): ${err}`);
1270
+ return;
1271
+ }
1272
+ if (!enumerated || disposed) return;
1273
+ debug(
1274
+ "strand-sweep",
1275
+ `enumerated ${enumerated.sessions.length} session row(s), ${Object.keys(enumerated.canonicalByProject).length} project(s) [${trigger}]`,
1276
+ );
1277
+ const plan = planStrandActions({
1278
+ sessions: enumerated.sessions,
1279
+ canonicalByProject: enumerated.canonicalByProject,
1280
+ pathExists: (p) => existsSync(p),
1281
+ canMove: typeof caps.sessionMove === "function",
1282
+ });
1283
+ let removed = 0;
1284
+ let moved = 0;
1285
+ for (const id of plan.remove) {
1286
+ if (disposed) return;
1287
+ try {
1288
+ await caps.sessionDelete(id);
1289
+ // The journal row may exist for restart-window children; a
1290
+ // historical corpse has none, and a no-op delete is fine.
1291
+ db.runtimeStateDelete("child", id);
1292
+ removed++;
1293
+ } catch (err) {
1294
+ debug("strand-sweep", `remove failed for ${id} (${trigger}): ${err}`);
1295
+ }
1296
+ }
1297
+ for (const m of plan.move) {
1298
+ if (disposed) return;
1299
+ try {
1300
+ await caps.sessionMove?.(m.id, m.to);
1301
+ moved++;
1302
+ } catch (err) {
1303
+ debug("strand-sweep", `move failed for ${m.id} (${trigger}): ${err}`);
1304
+ }
1305
+ }
1306
+ if (removed + moved > 0) {
1307
+ debug(
1308
+ "strand-sweep",
1309
+ `healed ${removed + moved} stranded session(s): ${moved} moved, ${removed} removed, ${plan.skipped} skipped [${trigger}]`,
1310
+ );
1311
+ try {
1312
+ await caps.showToast({
1313
+ message: `[thatch] healed ${removed + moved} stranded session${removed + moved === 1 ? "" : "s"} from dead worktrees (${moved} moved, ${removed} removed)`,
1314
+ variant: "info",
1315
+ duration: 6000,
1316
+ });
1317
+ } catch {
1318
+ // TUI may not be connected (headless mode).
1319
+ }
1320
+ }
1321
+ } finally {
1322
+ sweepInFlight = false;
1323
+ }
1324
+ };
1325
+ const STRAND_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
1326
+ let strandSweepTimer: ReturnType<typeof setInterval> | undefined;
1327
+ const armStrandSweep = () => {
1328
+ // Env override read at call time per house convention (test/ops knob).
1329
+ const ms = Number(process.env.THATCH_STRAND_SWEEP_MS) || STRAND_SWEEP_INTERVAL_MS;
1330
+ strandSweepTimer = setInterval(() => {
1331
+ void sweepStrandedSessions("interval");
1332
+ }, ms);
1333
+ strandSweepTimer.unref?.();
1334
+ };
1335
+ void sweepStrandedSessions("boot");
1336
+ armStrandSweep();
1337
+
1149
1338
  // Skills always install to the global opencode config - installing into the
1150
1339
  // worktree would mutate the user's repo (untracked files in git status).
1151
1340
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -2283,6 +2472,7 @@ export async function createRuntime(input: {
2283
2472
  dispose: async () => {
2284
2473
  disposed = true;
2285
2474
  stopVersionChecker();
2475
+ if (strandSweepTimer) clearInterval(strandSweepTimer);
2286
2476
  watchers.dispose();
2287
2477
  chatPoller.dispose();
2288
2478
  try {
package/src/session-db.ts CHANGED
@@ -60,8 +60,11 @@ export function resolveOpencodeDbPath(): string | null {
60
60
  * database - the same pick the TUI makes from client.session.list(), which
61
61
  * the v2 plugin context cannot call (its session domain has no list) and
62
62
  * which would arrive too late for the runtime's synchronous rehydration
63
- * partition regardless. Null when the db is unavailable or no top-level
64
- * session exists in the directory.
63
+ * partition regardless. Prefers the v2 `session_v2` table when the database
64
+ * has one: v2-era sessions live there and the legacy `session` table only
65
+ * holds pre-migration rows, so a v1-table read under a v2 daemon would
66
+ * resolve -c from years-old sessions. Null when the db is unavailable or no
67
+ * top-level session exists in the directory.
65
68
  */
66
69
  export function mostRecentTopLevelSessionId(directory: string): string | null {
67
70
  const dbPath = resolveOpencodeDbPath();
@@ -73,11 +76,18 @@ export function mostRecentTopLevelSessionId(directory: string): string | null {
73
76
  return null;
74
77
  }
75
78
  try {
76
- const row = db
77
- .query(
78
- "SELECT id FROM session WHERE directory = ? AND (parent_id IS NULL OR parent_id = '') ORDER BY time_updated DESC LIMIT 1",
79
- )
80
- .get(directory) as { id: string } | null;
79
+ const v2 = hasV2SessionTables(db);
80
+ const row = v2
81
+ ? (db
82
+ .query(
83
+ "SELECT id FROM session_v2 WHERE directory = ? AND (parent_id IS NULL OR parent_id = '') ORDER BY time_updated DESC LIMIT 1",
84
+ )
85
+ .get(directory) as { id: string } | null)
86
+ : (db
87
+ .query(
88
+ "SELECT id FROM session WHERE directory = ? AND (parent_id IS NULL OR parent_id = '') ORDER BY time_updated DESC LIMIT 1",
89
+ )
90
+ .get(directory) as { id: string } | null);
81
91
  return row?.id ?? null;
82
92
  } catch {
83
93
  return null;
@@ -86,6 +96,88 @@ export function mostRecentTopLevelSessionId(directory: string): string | null {
86
96
  }
87
97
  }
88
98
 
99
+ /** True when the database carries opencode v2's session tables. */
100
+ function hasV2SessionTables(db: Database): boolean {
101
+ const row = db
102
+ .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'")
103
+ .get() as { name: string } | undefined;
104
+ return row?.name === "session_v2";
105
+ }
106
+
107
+ /** One session row as the stranded-session sweep sees it (host-agnostic shape). */
108
+ export interface SweepSessionRow {
109
+ id: string;
110
+ title: string;
111
+ directory: string;
112
+ /** Host link for subagent children; null for top-level sessions. */
113
+ parentId: string | null;
114
+ /** v2 project id (joins to the project table's canonical worktree). Null on v1 rows. */
115
+ projectId: string | null;
116
+ }
117
+
118
+ /** Everything the strand sweep needs from one readonly pass over opencode.db. */
119
+ export interface SweepEnumeration {
120
+ sessions: SweepSessionRow[];
121
+ /** Project id -> canonical checkout directory. Empty on v1 (no project rows read). */
122
+ canonicalByProject: Record<string, string>;
123
+ }
124
+
125
+ /**
126
+ * Enumerates every session row opencode has, plus each project's canonical
127
+ * checkout directory, in one readonly pass. Prefers the v2 tables
128
+ * (session_v2 + project) and falls back to the v1 `session` table when the
129
+ * database predates them. Returns null when the db is missing, unreadable,
130
+ * or its schema has drifted past these column names - the strand sweep
131
+ * treats null as "nothing known, do nothing". The sweep's deletions and
132
+ * moves go through the host's own session endpoints; this read only ever
133
+ * NAMES candidates, so a stale or partial read can never corrupt anything.
134
+ */
135
+ export function enumerateSweepSessions(): SweepEnumeration | null {
136
+ const dbPath = resolveOpencodeDbPath();
137
+ if (!dbPath) return null;
138
+ let db: Database;
139
+ try {
140
+ db = new Database(dbPath, { readonly: true });
141
+ } catch {
142
+ return null;
143
+ }
144
+ try {
145
+ const v2 = hasV2SessionTables(db);
146
+ const sessions: SweepSessionRow[] = v2
147
+ ? (db
148
+ .query("SELECT id, title, directory, parent_id, project_id FROM session_v2")
149
+ .all() as { id: string; title: string | null; directory: string; parent_id: string | null; project_id: string | null }[]
150
+ ).map((r) => ({
151
+ id: r.id,
152
+ title: r.title ?? "",
153
+ directory: r.directory,
154
+ parentId: r.parent_id ?? null,
155
+ projectId: r.project_id ?? null,
156
+ }))
157
+ : (db
158
+ .query("SELECT id, title, directory, parent_id FROM session")
159
+ .all() as { id: string; title: string | null; directory: string; parent_id: string | null }[]).map((r) => ({
160
+ id: r.id,
161
+ title: r.title ?? "",
162
+ directory: r.directory,
163
+ parentId: r.parent_id ?? null,
164
+ projectId: null,
165
+ }));
166
+ const canonicalByProject: Record<string, string> = {};
167
+ if (v2) {
168
+ const projectRows = db
169
+ .query("SELECT id, worktree FROM project")
170
+ .all() as { id: string; worktree: string }[];
171
+ for (const r of projectRows) canonicalByProject[r.id] = r.worktree;
172
+ }
173
+ return { sessions, canonicalByProject };
174
+ } catch {
175
+ return null;
176
+ } finally {
177
+ db.close();
178
+ }
179
+ }
180
+
89
181
  export interface SessionRow {
90
182
  id: string;
91
183
  title: string;