@jeffober/thatch 0.2.6 → 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/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;
package/src/tool-defs.ts CHANGED
@@ -2139,7 +2139,10 @@ const chatRegisterDef: ToolDef = {
2139
2139
 
2140
2140
  /** Lists registered sessions with liveness. Stale means the session's host
2141
2141
  * process has not heartbeat-ed it recently - it is gone or hung. Fresh
2142
- * rows split into open (a tab is open now) and recent (no known tab). */
2142
+ * rows split into open (a tab is open now) and recent (no known tab).
2143
+ * The three sections render unguarded by design, unlike the CLI's
2144
+ * --recent/--stale guards: an agent choosing a recipient always needs
2145
+ * the full directory, including the tab-less rows it may still reach. */
2143
2146
  const chatListDef: ToolDef = {
2144
2147
  name: "chat_list",
2145
2148
  description:
@@ -2159,7 +2162,7 @@ const chatListDef: ToolDef = {
2159
2162
  }
2160
2163
  const unread = ctx.db.unreadChatCount(identity.sessionID);
2161
2164
  const openTabs = ctx.openTabs?.() ?? new Set<string>();
2162
- const { active, stale } = splitChatRoster(sortChatRoster(sessions));
2165
+ const { active, recent, stale } = splitChatRoster(sortChatRoster(sessions), Date.now(), null, openTabs);
2163
2166
  const line = (s: (typeof sessions)[number]) => {
2164
2167
  const self = s.session_id === identity.sessionID;
2165
2168
  const liveness = chatLiveness(s, Date.now(), openTabs);
@@ -2177,6 +2180,15 @@ const chatListDef: ToolDef = {
2177
2180
  if (active.length > 0) {
2178
2181
  sections.push(`# Active Sessions\n\n${active.map(line).join("\n")}`);
2179
2182
  }
2183
+ if (recent.length > 0) {
2184
+ sections.push(
2185
+ `# Recent Sessions\n\n` +
2186
+ `_These sessions are alive but have no open tab: a wake may not reach them ` +
2187
+ `until their tab reopens, though headless subordinates may still read mail ` +
2188
+ `on their own turns._\n\n` +
2189
+ `${recent.map(line).join("\n")}`,
2190
+ );
2191
+ }
2180
2192
  if (stale.length > 0) {
2181
2193
  sections.push(
2182
2194
  `# Stale Sessions\n\n` +