@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.
- package/docs/dev/features/cli.md +2 -0
- package/docs/dev/features/cross-session-chat.md +6 -3
- package/docs/dev/features/notifications.md +1 -1
- package/docs/dev/features/opencode-plugin.md +6 -4
- package/docs/dev/features/session-lifecycle.md +35 -0
- package/docs/dev/features/session-tabs.md +3 -2
- package/docs/dev/features/watchers.md +4 -2
- package/package.json +1 -1
- package/src/capabilities.ts +36 -0
- package/src/opencode/v2.ts +15 -0
- package/src/runtime.ts +191 -1
- package/src/session-db.ts +99 -7
package/docs/dev/features/cli.md
CHANGED
|
@@ -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`
|
|
593
|
-
|
|
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
|
|
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
|
|
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'
|
|
151
|
-
the `makeContext` double with `session.move` + the `rpc`
|
|
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
|
|
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.
|
|
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",
|
package/src/capabilities.ts
CHANGED
|
@@ -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
|
package/src/opencode/v2.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
64
|
-
*
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|