@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/artifacts/skills/thatch-clear-writing.md +11 -0
- package/artifacts/skills/thatch-code-review.md +10 -8
- package/artifacts/skills/thatch-review-clarity.md +101 -0
- package/artifacts/skills/thatch-review-followup.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +1 -1
- package/bin/thatch +90 -29
- package/docs/dev/features/cli.md +24 -7
- package/docs/dev/features/commands.md +2 -2
- package/docs/dev/features/cross-session-chat.md +21 -10
- 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/docs/dev/skills.md +2 -1
- package/docs/user/README.md +3 -2
- package/docs/user/cli.md +28 -16
- package/docs/user/code-review.md +4 -3
- package/docs/user/cross-session-chat.md +5 -4
- package/docs/user/skills.md +1 -0
- package/package.json +1 -1
- package/src/capabilities.ts +36 -0
- package/src/chat.ts +17 -7
- package/src/commands.ts +1 -1
- package/src/opencode/v2.ts +15 -0
- package/src/prompts.ts +1 -0
- package/src/runtime.ts +191 -1
- package/src/session-db.ts +99 -7
- package/src/tool-defs.ts +14 -2
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;
|
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` +
|