@saifymatteo/pi-session-retention 0.1.4 → 0.1.6
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/CHANGELOG.md +41 -0
- package/README.md +3 -3
- package/package.json +37 -36
- package/src/README.md +3 -3
- package/src/core.ts +7 -7
- package/src/dialog.ts +276 -38
- package/src/index.ts +158 -107
- package/src/preview.ts +117 -0
- package/src/safety.ts +69 -12
- package/src/search-cache.ts +188 -0
- package/src/sessions.ts +98 -0
package/src/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, SessionBeforeSwitchEvent, SessionBeforeForkEvent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { randomUUID, createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { lstat, open, readFile, rename, unlink } from "node:fs/promises";
|
|
5
4
|
import { homedir } from "node:os";
|
|
6
|
-
import { basename, dirname, join,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { setImmediate } from "node:timers/promises";
|
|
7
|
+
import { TrashSearchCache } from "./search-cache.ts";
|
|
8
|
+
import { RETENTION_MS, RetentionStore, pathKey, type Guard } from "./core.ts";
|
|
9
|
+
import { maintenanceGuard, withLock } from "./safety.ts";
|
|
10
|
+
import { SessionRegistry } from "./sessions.ts";
|
|
10
11
|
import type { TrashChoice } from "./dialog.ts";
|
|
11
12
|
import { DIALOG_OVERLAY } from "./lib/dialog.ts";
|
|
12
13
|
|
|
@@ -30,17 +31,6 @@ export function storePaths(ctx: Pick<ExtensionContext, "sessionManager">, env =
|
|
|
30
31
|
return { root, trash: join(dirname(root), ".trash", `${basename(root)}-${key}`) };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
async function safeDirectory(path: string): Promise<void> {
|
|
34
|
-
const absolute = resolve(path);
|
|
35
|
-
let current = parse(absolute).root;
|
|
36
|
-
for (const part of relative(current, absolute).split(/[/\\]/).filter(Boolean)) {
|
|
37
|
-
current = join(current, part);
|
|
38
|
-
try { await mkdir(current); } catch (error: any) { if (error.code !== "EEXIST") throw error; }
|
|
39
|
-
const info = await lstat(current);
|
|
40
|
-
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe retention directory: ${current}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
34
|
async function loadState(trash: string): Promise<State> {
|
|
45
35
|
const path = join(trash, "settings.json");
|
|
46
36
|
try {
|
|
@@ -63,63 +53,21 @@ async function saveState(trash: string, state: State) {
|
|
|
63
53
|
} finally { await file.close(); await unlink(path).catch((error) => { if (error.code !== "ENOENT") throw error; }); }
|
|
64
54
|
}
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try { file = await open(path, "wx", 0o600); }
|
|
71
|
-
catch (error: any) {
|
|
72
|
-
if (error.code !== "EEXIST") throw error;
|
|
73
|
-
const info = await lstat(path);
|
|
74
|
-
if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe retention lock.");
|
|
75
|
-
const owner = JSON.parse(await readFile(path, "utf8"));
|
|
76
|
-
if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error("Invalid retention lock; cleanup deferred.");
|
|
77
|
-
try { process.kill(owner.pid, 0); }
|
|
78
|
-
catch (probe: any) {
|
|
79
|
-
if (probe.code !== "ESRCH") throw new Error("Cannot verify retention lock owner; cleanup deferred.");
|
|
80
|
-
throw new Error(`Stale session-maintenance lock: ${path}. Close other Pi processes before removing this lock and retrying.`);
|
|
81
|
-
}
|
|
82
|
-
throw new Error("Session maintenance is already running.");
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
await file.writeFile(JSON.stringify({ pid: process.pid }));
|
|
86
|
-
await file.sync();
|
|
87
|
-
return await work();
|
|
88
|
-
} finally {
|
|
89
|
-
try { await file.close(); } finally { await unlink(path); }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Only invoked by the explicit Trash command; startup scans never read full transcripts.
|
|
94
|
-
async function trashSearchText(trash: string, batch: TrashBatch): Promise<{ search: string; title: string }> {
|
|
95
|
-
const values = [batch.manifest.id, ...batch.manifest.files.map((file) => file.original)];
|
|
96
|
-
const labels: string[] = [];
|
|
97
|
-
for (const file of batch.manifest.files) {
|
|
98
|
-
const path = join(trash, batch.manifest.id, file.stored);
|
|
99
|
-
if (batch.missing.includes(file.stored)) continue;
|
|
100
|
-
const info = await lstat(path);
|
|
101
|
-
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe Trash file: ${path}`);
|
|
102
|
-
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
103
|
-
let name = "", firstPrompt: string | undefined;
|
|
104
|
-
for await (const line of lines) {
|
|
105
|
-
let entry;
|
|
106
|
-
try { entry = JSON.parse(line); } catch { continue; }
|
|
107
|
-
if (!entry || typeof entry !== "object") continue;
|
|
108
|
-
if (entry.type === "session") values.push(entry.id ?? "", entry.cwd ?? "");
|
|
109
|
-
if (entry.type === "session_info" && typeof entry.name === "string") name = entry.name;
|
|
110
|
-
if (firstPrompt === undefined && entry.type === "message" && entry.message?.role === "user") {
|
|
111
|
-
const content = entry.message.content;
|
|
112
|
-
firstPrompt = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join(" ") : "";
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
values.push(name, firstPrompt ?? "");
|
|
116
|
-
labels.push(name || firstPrompt || file.original);
|
|
117
|
-
}
|
|
118
|
-
return { search: cleanText(values.join(" ")), title: cleanText(labels.join(" / ")) };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export default function sessionRetention(pi: ExtensionAPI, options: { guard?: () => Promise<void>; now?: () => number } = {}) {
|
|
56
|
+
export default function sessionRetention(pi: ExtensionAPI, options: {
|
|
57
|
+
guard?: () => Promise<void>; now?: () => number; sessions?: SessionRegistry;
|
|
58
|
+
dialog?: typeof import("./dialog.ts").SessionTrashDialog;
|
|
59
|
+
} = {}) {
|
|
122
60
|
const now = options.now ?? Date.now;
|
|
61
|
+
const sessions = options.sessions ?? new SessionRegistry(join(resolve(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent")), "session-retention"));
|
|
62
|
+
let registration: Promise<void> | undefined;
|
|
63
|
+
const pendingPaths = new Set<string>();
|
|
64
|
+
const currentPaths = (ctx: ExtensionContext) => {
|
|
65
|
+
// New/forked sessions can have an allocated filename before the child header
|
|
66
|
+
// exists on disk. Protect the parent too so scanning can still find its family.
|
|
67
|
+
const path = ctx.sessionManager.getSessionFile();
|
|
68
|
+
const parent = ctx.sessionManager.getHeader?.()?.parentSession;
|
|
69
|
+
return [path, parent].filter((value): value is string => typeof value === "string");
|
|
70
|
+
};
|
|
123
71
|
let started = false;
|
|
124
72
|
let pendingAutomatic = false;
|
|
125
73
|
let automaticTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -131,34 +79,60 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
131
79
|
function status(ctx: ExtensionContext, text: string | undefined) {
|
|
132
80
|
try { ctx.ui.setStatus("session-retention", text); } catch { /* A replaced UI must not interrupt file safety/lock cleanup. */ }
|
|
133
81
|
}
|
|
134
|
-
async function
|
|
82
|
+
async function discardSearchCache(ctx: ExtensionContext, trash: string, ids: string[]) {
|
|
83
|
+
try { await new TrashSearchCache(trash).remove(ids); }
|
|
84
|
+
catch (error) { try { ctx.ui.notify(`Session search metadata could not be removed: ${message(error)}`, "warning"); } catch { /* Advisory metadata cannot undo a completed file operation. */ } }
|
|
85
|
+
}
|
|
86
|
+
async function locked<T>(ctx: ExtensionContext, trash: string, label: string, work: (signal: AbortSignal, activePaths: () => Promise<string[]>) => Promise<T>): Promise<T> {
|
|
135
87
|
const signal = lifecycle.signal;
|
|
136
88
|
signal.throwIfAborted();
|
|
137
89
|
if (maintaining) throw new Error("Session maintenance is already running.");
|
|
138
90
|
maintaining = true;
|
|
139
91
|
try {
|
|
140
92
|
status(ctx, label);
|
|
141
|
-
const running =
|
|
93
|
+
const running = (async () => {
|
|
94
|
+
await registration;
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
await sessions.register([...currentPaths(ctx), ...pendingPaths]);
|
|
97
|
+
signal.throwIfAborted();
|
|
98
|
+
return sessions.maintain(activePaths => withLock(trash, async () => { signal.throwIfAborted(); return work(signal, activePaths); }));
|
|
99
|
+
})();
|
|
142
100
|
operation = running;
|
|
143
101
|
return await running;
|
|
144
102
|
} finally { operation = undefined; maintaining = false; if (!signal.aborted) status(ctx, undefined); }
|
|
145
103
|
}
|
|
146
|
-
function mutationGuard(ctx: ExtensionContext): Guard {
|
|
147
|
-
const probe = maintenanceGuard(options.guard);
|
|
104
|
+
function mutationGuard(ctx: ExtensionContext, activePaths: () => Promise<string[]>): Guard {
|
|
105
|
+
const probe = maintenanceGuard(options.guard ?? (async () => {}));
|
|
148
106
|
const signal = lifecycle.signal;
|
|
149
107
|
return async (_operation, paths) => {
|
|
150
108
|
signal.throwIfAborted();
|
|
151
|
-
const active = ctx.
|
|
152
|
-
if (
|
|
109
|
+
const active = new Set([...currentPaths(ctx), ...await activePaths()].map(pathKey));
|
|
110
|
+
if (paths.some(path => active.has(pathKey(path)))) throw new Error("Refusing to move or delete an open session.");
|
|
153
111
|
const allowed = await probe();
|
|
154
112
|
signal.throwIfAborted();
|
|
155
113
|
return allowed;
|
|
156
114
|
};
|
|
157
115
|
}
|
|
158
|
-
const preventSwitch = (
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
116
|
+
const preventSwitch = async (event: SessionBeforeSwitchEvent | SessionBeforeForkEvent, ctx: ExtensionContext) => {
|
|
117
|
+
if (maintaining) {
|
|
118
|
+
ctx.ui.notify("Finish or cancel session maintenance before switching sessions.", "warning");
|
|
119
|
+
return { cancel: true };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await registration;
|
|
123
|
+
if (maintaining) {
|
|
124
|
+
ctx.ui.notify("Finish or cancel session maintenance before switching sessions.", "warning");
|
|
125
|
+
return { cancel: true };
|
|
126
|
+
}
|
|
127
|
+
// Retain BOTH paths if a later handler cancels. Never drop current protection
|
|
128
|
+
// until the replacement runtime registers itself in session_start.
|
|
129
|
+
const target = "targetSessionFile" in event ? event.targetSessionFile : undefined;
|
|
130
|
+
if (target) pendingPaths.add(target);
|
|
131
|
+
await sessions.register([...currentPaths(ctx), ...pendingPaths]);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
ctx.ui.notify(`${message(error)} Session change cancelled; retry after maintenance finishes.`, "warning");
|
|
134
|
+
return { cancel: true };
|
|
135
|
+
}
|
|
162
136
|
};
|
|
163
137
|
pi.on("session_before_switch", preventSwitch);
|
|
164
138
|
pi.on("session_before_fork", preventSwitch);
|
|
@@ -167,6 +141,12 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
167
141
|
if (automaticTimer !== undefined) clearTimeout(automaticTimer);
|
|
168
142
|
automaticTimer = undefined;
|
|
169
143
|
}
|
|
144
|
+
async function waitForAutomatic(ctx: ExtensionContext) {
|
|
145
|
+
const running = automatic;
|
|
146
|
+
if (!running) return;
|
|
147
|
+
status(ctx, "Session maintenance: waiting for automatic cleanup…");
|
|
148
|
+
await running;
|
|
149
|
+
}
|
|
170
150
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
171
151
|
cancelAutomatic();
|
|
172
152
|
status(ctx, undefined);
|
|
@@ -176,21 +156,28 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
176
156
|
await automatic?.catch(() => {});
|
|
177
157
|
// Never release a lock while a file operation can still be using it.
|
|
178
158
|
await operation?.catch(() => {});
|
|
159
|
+
await registration;
|
|
160
|
+
registration = undefined;
|
|
161
|
+
pendingPaths.clear();
|
|
162
|
+
// Keep the last protection through reload/replacement. Dead-PID records are
|
|
163
|
+
// ignored, and process start times prevent PID reuse from validating them.
|
|
179
164
|
started = false;
|
|
180
165
|
});
|
|
181
166
|
|
|
182
167
|
async function cleanup(ctx: ExtensionContext, manual: boolean, previewOnly = false) {
|
|
183
168
|
if (ctx.mode !== "tui") return;
|
|
184
169
|
const paths = storePaths(ctx);
|
|
185
|
-
await locked(ctx, paths.trash, "Session cleanup: checking schedule…", async (signal) => {
|
|
170
|
+
await locked(ctx, paths.trash, "Session cleanup: checking schedule…", async (signal, activePaths) => {
|
|
186
171
|
const state = await loadState(paths.trash);
|
|
187
172
|
signal.throwIfAborted();
|
|
188
173
|
const time = now();
|
|
189
174
|
if (!manual && (state.paused || time - state.lastRun < DAY)) return;
|
|
190
175
|
const store = new RetentionStore(paths.root, paths.trash);
|
|
191
|
-
|
|
176
|
+
status(ctx, "Session cleanup: checking open sessions…");
|
|
177
|
+
const protectedPaths = [...currentPaths(ctx), ...await activePaths()];
|
|
178
|
+
signal.throwIfAborted();
|
|
192
179
|
status(ctx, "Session cleanup: scanning sessions…");
|
|
193
|
-
const scan = await store.scan({ now: time, protectedPaths
|
|
180
|
+
const scan = await store.scan({ now: time, protectedPaths });
|
|
194
181
|
if (scan.aborted) throw new Error(`Session cleanup deferred: ${scan.issues.join("; ")}`);
|
|
195
182
|
signal.throwIfAborted();
|
|
196
183
|
const trash = await store.listTrash();
|
|
@@ -203,8 +190,9 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
203
190
|
`${scan.candidates.length} inactive families (${count} sessions) → Trash for 30 days.`,
|
|
204
191
|
`${expired.length} expired Trash families → permanent deletion.`,
|
|
205
192
|
"A family is eligible only after its newest file has been inactive for 30 days.",
|
|
206
|
-
"Named sessions follow the same rule.
|
|
207
|
-
"
|
|
193
|
+
"Named sessions follow the same rule. Every registered open session's family is protected.",
|
|
194
|
+
"Other updated Pi windows may stay open; session changes are blocked during maintenance.",
|
|
195
|
+
"Do not launch another Pi process during maintenance.",
|
|
208
196
|
...scan.candidates.slice(0, 8).map((family) => cleanText(`• ${relative(paths.root, family.paths[0])} (${family.paths.length} sessions)`)),
|
|
209
197
|
...(scan.candidates.length > 8 ? [`… and ${scan.candidates.length - 8} more families.`] : []),
|
|
210
198
|
].join("\n");
|
|
@@ -226,8 +214,8 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
226
214
|
state.approved = true;
|
|
227
215
|
state.paused = false;
|
|
228
216
|
}
|
|
229
|
-
const guard = mutationGuard(ctx);
|
|
230
|
-
status(ctx, "Session cleanup:
|
|
217
|
+
const guard = mutationGuard(ctx, activePaths);
|
|
218
|
+
status(ctx, "Session cleanup: verifying open sessions…");
|
|
231
219
|
await guard("trash", []);
|
|
232
220
|
if (scan.candidates.length) {
|
|
233
221
|
status(ctx, `Session cleanup: moving ${count} sessions to Trash…`);
|
|
@@ -236,6 +224,7 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
236
224
|
signal.throwIfAborted();
|
|
237
225
|
status(ctx, "Session cleanup: purging expired Trash…");
|
|
238
226
|
const purged = await store.purgeExpired({ now: time, guard });
|
|
227
|
+
await discardSearchCache(ctx, paths.trash, purged.purged);
|
|
239
228
|
signal.throwIfAborted();
|
|
240
229
|
state.lastRun = time;
|
|
241
230
|
await saveState(paths.trash, state);
|
|
@@ -266,6 +255,13 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
266
255
|
}
|
|
267
256
|
pi.on("session_start", (_event, ctx) => {
|
|
268
257
|
if (lifecycle.signal.aborted) lifecycle = new AbortController();
|
|
258
|
+
// Register every mode, but never await OS inventory on the startup path.
|
|
259
|
+
if (!registration) {
|
|
260
|
+
const signal = lifecycle.signal;
|
|
261
|
+
registration = sessions.register(currentPaths(ctx), signal).catch(error => {
|
|
262
|
+
if (!signal.aborted) { try { ctx.ui.notify(message(error), "warning"); } catch { /* Detached startup task. */ } }
|
|
263
|
+
});
|
|
264
|
+
}
|
|
269
265
|
if (started || ctx.mode !== "tui" || !ctx.sessionManager.getSessionFile()) return;
|
|
270
266
|
started = true;
|
|
271
267
|
pendingAutomatic = true;
|
|
@@ -289,6 +285,8 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
289
285
|
if (!["", "preview", "pause"].includes(action)) return ctx.ui.notify("Usage: /session-cleanup [preview|pause]", "info");
|
|
290
286
|
cancelAutomatic();
|
|
291
287
|
try {
|
|
288
|
+
await waitForAutomatic(ctx);
|
|
289
|
+
signal.throwIfAborted();
|
|
292
290
|
if (action === "pause") {
|
|
293
291
|
const paths = storePaths(ctx);
|
|
294
292
|
await locked(ctx, paths.trash, "Session cleanup: pausing…", async (signal) => { const state = await loadState(paths.trash); signal.throwIfAborted(); state.paused = true; await saveState(paths.trash, state); });
|
|
@@ -306,41 +304,94 @@ export default function sessionRetention(pi: ExtensionAPI, options: { guard?: ()
|
|
|
306
304
|
cancelAutomatic();
|
|
307
305
|
const signal = lifecycle.signal;
|
|
308
306
|
try {
|
|
307
|
+
await waitForAutomatic(ctx);
|
|
308
|
+
signal.throwIfAborted();
|
|
309
309
|
await ctx.waitForIdle();
|
|
310
310
|
signal.throwIfAborted();
|
|
311
311
|
const paths = storePaths(ctx);
|
|
312
|
-
await locked(ctx, paths.trash, "Session Trash: loading families…", async (signal) => {
|
|
312
|
+
await locked(ctx, paths.trash, "Session Trash: loading families…", async (signal, activePaths) => {
|
|
313
313
|
const store = new RetentionStore(paths.root, paths.trash);
|
|
314
314
|
const { batches, issues } = await store.listTrash();
|
|
315
315
|
signal.throwIfAborted();
|
|
316
316
|
if (issues.length) ctx.ui.notify(issues.join("; "), "warning");
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
317
|
+
batches.sort((a, b) => b.manifest.trashedAt - a.manifest.trashedAt);
|
|
318
|
+
const choices: TrashChoice[] = batches.map(({ manifest }) => ({
|
|
319
|
+
value: manifest.id, members: manifest.files.length, group: manifest.files[0]?.original.split("/")[0] ?? "", search: cleanText([manifest.id, ...manifest.files.map(file => file.original)].join(" ")),
|
|
320
|
+
label: cleanText(manifest.files.map(file => file.original).join(" / ")),
|
|
321
|
+
description: `${manifest.files.length} sessions · ${manifest.ready ? "trashed" : "incomplete — recoverable"} · ${new Date(manifest.trashedAt).toISOString().slice(0, 10)} · ${manifest.id.slice(0, 8)}`,
|
|
322
|
+
}));
|
|
323
|
+
const currentBatch = async (id: string) => {
|
|
324
|
+
const original = batches.find(batch => batch.manifest.id === id);
|
|
325
|
+
const fresh = await store.getTrashBatch(id);
|
|
326
|
+
if (!original || JSON.stringify(original.manifest) !== JSON.stringify(fresh.manifest)) throw new Error("Session family changed. Close and reopen /session-trash before previewing or restoring it.");
|
|
327
|
+
return fresh;
|
|
328
|
+
};
|
|
329
|
+
const cache = new TrashSearchCache(paths.trash);
|
|
330
|
+
const indexing = new AbortController();
|
|
331
|
+
const indexSignal = AbortSignal.any([signal, indexing.signal]);
|
|
332
|
+
let task: Promise<void> | undefined;
|
|
326
333
|
let selected: string | undefined;
|
|
334
|
+
let dialog: import("./dialog.ts").SessionTrashDialog | undefined;
|
|
335
|
+
status(ctx, "Session Trash: choose a family to restore");
|
|
327
336
|
try {
|
|
328
337
|
selected = await ctx.ui.custom<string | undefined>(async (tui, theme, keybindings, done) => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
338
|
+
const finish = (value: string | undefined) => { indexing.abort(); done(value); };
|
|
339
|
+
closeDialog = () => finish(undefined);
|
|
340
|
+
const Dialog = options.dialog ?? (await import("./dialog.ts")).SessionTrashDialog;
|
|
341
|
+
indexSignal.throwIfAborted();
|
|
342
|
+
dialog = new Dialog(tui, theme, keybindings, choices, cleanText(args), finish, async (id, member, previewSignal) => {
|
|
343
|
+
const combined = AbortSignal.any([signal, previewSignal]);
|
|
344
|
+
combined.throwIfAborted();
|
|
345
|
+
const fresh = await currentBatch(id);
|
|
346
|
+
combined.throwIfAborted();
|
|
347
|
+
const { loadTrashPreview } = await import("./preview.ts");
|
|
348
|
+
const excerpt = await loadTrashPreview(paths.trash, fresh, member, combined);
|
|
349
|
+
await currentBatch(id);
|
|
350
|
+
combined.throwIfAborted();
|
|
351
|
+
return excerpt;
|
|
352
|
+
});
|
|
353
|
+
const activeDialog = dialog;
|
|
354
|
+
let completed = 0, failed = 0;
|
|
355
|
+
dialog.update(choices, { completed, total: batches.length, failed });
|
|
356
|
+
task = (async () => {
|
|
357
|
+
// Mount the picker before starting even a cold transcript read.
|
|
358
|
+
await setImmediate(undefined, { signal: indexSignal });
|
|
359
|
+
for (const [i, batch] of batches.entries()) {
|
|
360
|
+
indexSignal.throwIfAborted();
|
|
361
|
+
try {
|
|
362
|
+
const text = await cache.read(batch, indexSignal);
|
|
363
|
+
indexSignal.throwIfAborted();
|
|
364
|
+
choices[i] = { ...choices[i], search: text.search, label: text.title || choices[i].label, ...(text.cwd ? { cwd: text.cwd } : {}) };
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (indexSignal.aborted) return;
|
|
367
|
+
failed++;
|
|
368
|
+
choices[i] = { ...choices[i], description: `${choices[i].description} · search metadata unavailable` };
|
|
369
|
+
}
|
|
370
|
+
if (indexSignal.aborted) return;
|
|
371
|
+
activeDialog.update([...choices], { completed: ++completed, total: batches.length, failed });
|
|
372
|
+
}
|
|
373
|
+
})().catch(error => {
|
|
374
|
+
if (!indexSignal.aborted) { try { ctx.ui.notify(message(error), "warning"); } catch { /* Detached indexing error. */ } }
|
|
375
|
+
});
|
|
376
|
+
return dialog;
|
|
333
377
|
}, DIALOG_OVERLAY);
|
|
334
|
-
} finally {
|
|
378
|
+
} finally {
|
|
379
|
+
indexing.abort();
|
|
380
|
+
try { await Promise.all([task, dialog?.drain?.()]); }
|
|
381
|
+
finally { closeDialog = undefined; }
|
|
382
|
+
}
|
|
335
383
|
signal.throwIfAborted();
|
|
336
384
|
const match = batches.find((batch) => batch.manifest.id === selected);
|
|
337
385
|
if (!match) return;
|
|
386
|
+
await currentBatch(match.manifest.id);
|
|
387
|
+
signal.throwIfAborted();
|
|
338
388
|
status(ctx, "Session Trash: confirming restore…");
|
|
339
389
|
const approved = await ctx.ui.confirm("Restore this session family?", `${match.manifest.files.length} sessions will return to their original locations with a fresh 30-day grace period. Existing files will not be overwritten.`, { signal });
|
|
340
390
|
signal.throwIfAborted();
|
|
341
391
|
if (!approved) return;
|
|
342
392
|
status(ctx, `Session Trash: restoring ${match.manifest.files.length} sessions…`);
|
|
343
|
-
await store.restoreFamily(match.manifest.id, { now: now(), guard: mutationGuard(ctx) });
|
|
393
|
+
await store.restoreFamily(match.manifest.id, { now: now(), guard: mutationGuard(ctx, activePaths) });
|
|
394
|
+
await discardSearchCache(ctx, paths.trash, [match.manifest.id]);
|
|
344
395
|
signal.throwIfAborted();
|
|
345
396
|
ctx.ui.notify("Session family restored. It is available in normal session search again.", "info");
|
|
346
397
|
});
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { finished } from "node:stream/promises";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
7
|
+
import type { TrashBatch } from "./core.ts";
|
|
8
|
+
import { fileStamp, stamp } from "./search-cache.ts";
|
|
9
|
+
|
|
10
|
+
export type TrashPreview = {
|
|
11
|
+
name: string; cwd: string; firstRequest: string;
|
|
12
|
+
recent: { role: "user" | "assistant"; text: string }[];
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
};
|
|
15
|
+
const TEXT_LIMIT = 4000, RECENT_LIMIT = 6, TREE_LIMIT = 100_000, ID_LIMIT = 2 * 1024 * 1024;
|
|
16
|
+
const changed = () => new Error("Session changed while reading its preview. Go back and retry.");
|
|
17
|
+
const clean = (text: string) => stripVTControlCharacters(text).replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
|
|
18
|
+
|
|
19
|
+
// Each pass has its own handle: destroying a FileHandle stream may close it even
|
|
20
|
+
// with autoClose:false. Verify before destruction, then always drain and close.
|
|
21
|
+
async function readPass(path: string, identity: string, signal: AbortSignal, visit: (entry: any) => void): Promise<void> {
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
if (await fileStamp(path) !== identity) throw changed();
|
|
24
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
25
|
+
try {
|
|
26
|
+
if (stamp(await handle.stat({ bigint: true })) !== identity) throw changed();
|
|
27
|
+
signal.throwIfAborted();
|
|
28
|
+
const stream = handle.createReadStream({ autoClose: false, signal });
|
|
29
|
+
let streamError: unknown;
|
|
30
|
+
const drained = finished(stream).catch(error => { streamError = error; });
|
|
31
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
32
|
+
try {
|
|
33
|
+
for await (const line of lines) {
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
if (line.length > 16 * 1024 * 1024) throw new Error("Preview unavailable: a transcript entry exceeds 16 MiB");
|
|
36
|
+
let entry;
|
|
37
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
38
|
+
if (entry && typeof entry === "object" && typeof entry.type === "string") visit(entry);
|
|
39
|
+
}
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
if (stamp(await handle.stat({ bigint: true })) !== identity || await fileStamp(path) !== identity) throw changed();
|
|
42
|
+
} finally { lines.close(); stream.destroy(); await drained; }
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
if (streamError) throw streamError;
|
|
45
|
+
} finally { await handle.close(); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Bounded excerpts from one selected member, never an active SessionManager. */
|
|
49
|
+
export async function loadTrashPreview(trashRoot: string, batch: TrashBatch, memberIndex: number, signal: AbortSignal): Promise<TrashPreview> {
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
const manifest = structuredClone(batch.manifest);
|
|
52
|
+
const file = manifest?.files?.[memberIndex];
|
|
53
|
+
if (!manifest || manifest.version !== 1 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(manifest.id) ||
|
|
54
|
+
!Number.isInteger(memberIndex) || memberIndex < 0 || !file || file.stored !== `${memberIndex}.jsonl` ||
|
|
55
|
+
typeof file.original !== "string" || !file.original.endsWith(".jsonl") || file.original.includes("\\") ||
|
|
56
|
+
file.original.split("/").some(part => !part || part === "." || part === ".." || part.includes(":"))) throw new Error("Invalid Trash preview path or member");
|
|
57
|
+
if (batch.missing.includes(file.stored)) throw new Error("Session file is missing from Trash");
|
|
58
|
+
const path = join(resolve(trashRoot), manifest.id, file.stored), identity = await fileStamp(path);
|
|
59
|
+
const result: TrashPreview = { name: "", cwd: "", firstRequest: "", recent: [], truncated: false };
|
|
60
|
+
const clip = (text: string) => {
|
|
61
|
+
if (text.length > TEXT_LIMIT) result.truncated = true;
|
|
62
|
+
const value = clean(text.slice(0, TEXT_LIMIT + 1));
|
|
63
|
+
if (value.length > TEXT_LIMIT) result.truncated = true;
|
|
64
|
+
return value.slice(0, TEXT_LIMIT).replace(/[\uD800-\uDBFF]$/, "");
|
|
65
|
+
};
|
|
66
|
+
const parents = new Map<string, string | null>();
|
|
67
|
+
let leaf: string | null = null, idSize = 0, tree = false, idlessMessages = false;
|
|
68
|
+
await readPass(path, identity, signal, entry => {
|
|
69
|
+
if (entry.type === "session") {
|
|
70
|
+
if (typeof entry.cwd === "string") result.cwd = clip(entry.cwd);
|
|
71
|
+
if (typeof entry.version === "number" && entry.version >= 2) tree = true;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (entry.type === "session_info" && typeof entry.name === "string") result.name = clip(entry.name);
|
|
75
|
+
if (typeof entry.id !== "string") {
|
|
76
|
+
if (entry.type === "message") idlessMessages = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
tree = true;
|
|
80
|
+
if (!entry.id || entry.id.length > 256 || (entry.parentId !== null && (typeof entry.parentId !== "string" || entry.parentId.length > 256)) || parents.has(entry.id)) throw new Error("Cannot verify the session preview branch");
|
|
81
|
+
idSize += entry.id.length + (entry.parentId?.length ?? 0);
|
|
82
|
+
if (parents.size >= TREE_LIMIT || idSize > ID_LIMIT) throw new Error("Preview unavailable: session tree exceeds the preview limit");
|
|
83
|
+
parents.set(entry.id, entry.parentId);
|
|
84
|
+
leaf = entry.id;
|
|
85
|
+
});
|
|
86
|
+
if (tree && idlessMessages) throw new Error("Cannot verify mixed linear/tree session entries");
|
|
87
|
+
const branch = new Set<string>();
|
|
88
|
+
while (leaf !== null) {
|
|
89
|
+
if (branch.has(leaf) || !parents.has(leaf)) throw new Error("Cannot verify the session preview branch");
|
|
90
|
+
branch.add(leaf);
|
|
91
|
+
leaf = parents.get(leaf)!;
|
|
92
|
+
}
|
|
93
|
+
let firstUser = false;
|
|
94
|
+
await readPass(path, identity, signal, entry => {
|
|
95
|
+
if (entry.type !== "message" || (tree && !branch.has(entry.id))) return;
|
|
96
|
+
const role = entry.message?.role;
|
|
97
|
+
if (role !== "user" && role !== "assistant") return;
|
|
98
|
+
const content = entry.message.content;
|
|
99
|
+
const parts = typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : [];
|
|
100
|
+
let text = "";
|
|
101
|
+
for (const [index, part] of parts.entries()) {
|
|
102
|
+
const value = part?.type === "text" && typeof part.text === "string" ? part.text : "";
|
|
103
|
+
if (!value) continue;
|
|
104
|
+
text = clip(text + (text ? "\n" : "") + clip(value));
|
|
105
|
+
if (text.length >= TEXT_LIMIT) {
|
|
106
|
+
if (parts.slice(index + 1).some(part => part?.type === "text" && part.text)) result.truncated = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (role === "user" && !firstUser) { result.firstRequest = text; firstUser = true; }
|
|
111
|
+
if (text.trim()) result.recent.push({ role, text });
|
|
112
|
+
if (result.recent.length > RECENT_LIMIT) { result.recent.shift(); result.truncated = true; }
|
|
113
|
+
});
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
if (await fileStamp(path) !== identity) throw changed();
|
|
116
|
+
return result;
|
|
117
|
+
}
|
package/src/safety.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { lstat, mkdir, open, readFile, link, unlink } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { join, parse, relative, resolve } from "node:path";
|
|
3
6
|
|
|
4
|
-
export type ProcessInfo = { pid: number; name: string; command: string };
|
|
7
|
+
export type ProcessInfo = { pid: number; name: string; command: string; started?: string };
|
|
5
8
|
const exec = promisify(execFile);
|
|
6
9
|
|
|
7
|
-
//
|
|
8
|
-
// while another CLI is running rather than pretending the current session is the only open one.
|
|
10
|
+
// Processes without a verified session registration still require a fail-closed fallback.
|
|
9
11
|
export function otherPiProcesses(processes: ProcessInfo[], ownPid: number): ProcessInfo[] {
|
|
10
12
|
if (!processes.some((item) => item.pid === ownPid)) throw new Error("Cannot verify the current Pi process; cleanup deferred.");
|
|
11
13
|
return processes.filter(({ pid, name, command }) => pid !== ownPid && (
|
|
@@ -17,23 +19,23 @@ export function otherPiProcesses(processes: ProcessInfo[], ownPid: number): Proc
|
|
|
17
19
|
export async function processSnapshot(): Promise<ProcessInfo[]> {
|
|
18
20
|
if (process.platform === "win32") {
|
|
19
21
|
const { stdout } = await exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
|
|
20
|
-
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); @(Get-CimInstance Win32_Process -ErrorAction Stop | Select-Object ProcessId,Name,CommandLine) | ConvertTo-Json -Compress"],
|
|
22
|
+
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); @(Get-CimInstance Win32_Process -ErrorAction Stop | Select-Object ProcessId,Name,CommandLine,@{Name='Started';Expression={if ($_.CreationDate) {$_.CreationDate.ToUniversalTime().Ticks.ToString()}}}) | ConvertTo-Json -Compress"],
|
|
21
23
|
{ timeout: 15_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true });
|
|
22
24
|
const items = JSON.parse(stdout.replace(/^\uFEFF/, ""));
|
|
23
25
|
if (!Array.isArray(items)) throw new Error("Cannot read process inventory; cleanup deferred.");
|
|
24
|
-
return items.map((item) => ({ pid: item.ProcessId, name: item.Name ?? "", command: item.CommandLine ?? "" }));
|
|
26
|
+
return items.map((item) => ({ pid: item.ProcessId, name: item.Name ?? "", command: item.CommandLine ?? "", started: item.Started }));
|
|
25
27
|
}
|
|
26
|
-
const { stdout } = await exec("ps", ["-axo", "pid=,comm=,args="], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 });
|
|
28
|
+
const { stdout } = await exec("ps", ["-axo", "pid=,lstart=,comm=,args="], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } });
|
|
27
29
|
return stdout.split("\n").flatMap((line) => {
|
|
28
|
-
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
|
29
|
-
return match ? [{ pid: Number(match[1]),
|
|
30
|
+
const match = line.trim().match(/^(\d+)\s+((?:\S+\s+){4}\S+)\s+(\S+)\s+(.*)$/);
|
|
31
|
+
return match ? [{ pid: Number(match[1]), started: match[2].replace(/\s+/g, " "), name: match[3].split("/").pop()!, command: match[4] }] : [];
|
|
30
32
|
});
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
// A process scan starts PowerShell on Windows. Reuse a successful inventory for
|
|
34
36
|
// at most one second within ONE maintenance transaction, not once per file.
|
|
35
37
|
// This remains a sampled safeguard, not an OS lock honored by external SDK hosts.
|
|
36
|
-
export function maintenanceGuard(probe
|
|
38
|
+
export function maintenanceGuard(probe: () => Promise<void>, clock = () => performance.now()) {
|
|
37
39
|
let checkedUntil = -Infinity;
|
|
38
40
|
return async () => {
|
|
39
41
|
if (clock() >= checkedUntil) {
|
|
@@ -44,7 +46,62 @@ export function maintenanceGuard(probe = ensureSinglePiProcess, clock = () => pe
|
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
export async function
|
|
48
|
-
const
|
|
49
|
-
|
|
49
|
+
export async function safeDirectory(path: string): Promise<void> {
|
|
50
|
+
const absolute = resolve(path);
|
|
51
|
+
let current = parse(absolute).root;
|
|
52
|
+
for (const part of relative(current, absolute).split(/[/\\]/).filter(Boolean)) {
|
|
53
|
+
current = join(current, part);
|
|
54
|
+
try { await mkdir(current); } catch (error: any) { if (error.code !== "EEXIST") throw error; }
|
|
55
|
+
const info = await lstat(current);
|
|
56
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe retention directory: ${current}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class RetentionBusyError extends Error {}
|
|
61
|
+
|
|
62
|
+
// Shared by store maintenance and the short registration/session-change critical section.
|
|
63
|
+
// Stale or unverifiable owners are deliberately never guessed away.
|
|
64
|
+
export async function withLock<T>(directory: string, work: () => Promise<T>, name = ".maintenance.lock"): Promise<T> {
|
|
65
|
+
await safeDirectory(directory);
|
|
66
|
+
const path = join(directory, name);
|
|
67
|
+
// Publish a fully written owner record exclusively. Creating the lock path
|
|
68
|
+
// before writing JSON exposes an empty/partial owner to concurrent startups.
|
|
69
|
+
const temp = join(directory, `.lock-${randomUUID()}.tmp`);
|
|
70
|
+
const file = await open(temp, "wx", 0o600);
|
|
71
|
+
try {
|
|
72
|
+
await file.writeFile(JSON.stringify({ pid: process.pid }));
|
|
73
|
+
await file.sync();
|
|
74
|
+
const created = await file.stat();
|
|
75
|
+
try { await link(temp, path); }
|
|
76
|
+
catch (error: any) {
|
|
77
|
+
if (error.code !== "EEXIST") throw error;
|
|
78
|
+
let owner;
|
|
79
|
+
try {
|
|
80
|
+
const info = await lstat(path);
|
|
81
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe retention lock.");
|
|
82
|
+
owner = JSON.parse(await readFile(path, "utf8"));
|
|
83
|
+
} catch (readError: any) {
|
|
84
|
+
// The previous owner can release the lock between link() and inspection.
|
|
85
|
+
if (readError.code === "ENOENT") throw new RetentionBusyError("Session maintenance just finished; retry.");
|
|
86
|
+
throw readError;
|
|
87
|
+
}
|
|
88
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error("Invalid retention lock; cleanup deferred.");
|
|
89
|
+
try { process.kill(owner.pid, 0); }
|
|
90
|
+
catch (probe: any) {
|
|
91
|
+
if (probe.code !== "ESRCH") throw new Error("Cannot verify retention lock owner; cleanup deferred.");
|
|
92
|
+
throw new Error(`Stale session-maintenance lock: ${path}. Close other Pi processes before removing this lock and retrying.`);
|
|
93
|
+
}
|
|
94
|
+
throw new RetentionBusyError("Session maintenance or registration is already running. Retry after it finishes.");
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await unlink(temp);
|
|
98
|
+
return await work();
|
|
99
|
+
} finally {
|
|
100
|
+
const current = await lstat(path);
|
|
101
|
+
if (!current.isFile() || current.isSymbolicLink() || current.ino !== created.ino || current.dev !== created.dev) throw new Error(`Retention lock changed; left untouched: ${path}`);
|
|
102
|
+
await unlink(path);
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
try { await file.close(); } finally { await unlink(temp).catch(error => { if (error.code !== "ENOENT") throw error; }); }
|
|
106
|
+
}
|
|
50
107
|
}
|