@saifymatteo/pi-session-retention 0.1.5 → 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/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 { createReadStream } from "node:fs";
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, parse, relative, resolve } from "node:path";
7
- import { createInterface } from "node:readline";
8
- import { RETENTION_MS, RetentionStore, pathKey, type Guard, type TrashBatch } from "./core.ts";
9
- import { maintenanceGuard } from "./safety.ts";
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
- async function withLock<T>(trash: string, work: () => Promise<T>): Promise<T> {
67
- await safeDirectory(trash);
68
- const path = join(trash, ".maintenance.lock");
69
- let file;
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 locked<T>(ctx: ExtensionContext, trash: string, label: string, work: (signal: AbortSignal) => Promise<T>): Promise<T> {
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 = withLock(trash, async () => { signal.throwIfAborted(); return work(signal); });
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.sessionManager.getSessionFile();
152
- if (active && paths.some((path) => pathKey(path) === pathKey(active))) throw new Error("Refusing to move or delete the current session.");
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 = (_event: unknown, ctx: ExtensionContext) => {
159
- if (!maintaining) return;
160
- ctx.ui.notify("Finish or cancel session maintenance before switching sessions.", "warning");
161
- return { cancel: true };
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
- const current = ctx.sessionManager.getSessionFile();
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: current ? [current] : [] });
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. The current session's family is protected.",
207
- "Close other Pi windows and do not launch another process during maintenance.",
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: checking other Pi processes…");
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
- const choices: TrashChoice[] = [];
318
- for (const batch of batches.sort((a, b) => b.manifest.trashedAt - a.manifest.trashedAt)) {
319
- status(ctx, `Session Trash: loading families ${choices.length + 1}/${batches.length}…`);
320
- const text = await trashSearchText(paths.trash, batch);
321
- signal.throwIfAborted();
322
- choices.push({ value: batch.manifest.id, search: text.search, label: text.title || batch.manifest.files[0].original,
323
- description: `${batch.manifest.files.length} sessions · ${batch.manifest.ready ? "trashed" : "incomplete — recoverable"} · ${new Date(batch.manifest.trashedAt).toISOString().slice(0, 10)} · ${batch.manifest.id.slice(0, 8)}` });
324
- }
325
- status(ctx, "Session Trash: choose a family to restore");
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
- closeDialog = () => done(undefined);
330
- const { SessionTrashDialog } = await import("./dialog.ts");
331
- signal.throwIfAborted();
332
- return new SessionTrashDialog(tui, theme, keybindings, choices, cleanText(args), done);
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 { closeDialog = undefined; }
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
- // Older Pi processes have no retention leases. Conservatively defer ALL maintenance
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]), name: match[2].split("/").pop()!, command: match[3] }] : [];
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 = ensureSinglePiProcess, clock = () => performance.now()) {
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 ensureSinglePiProcess(): Promise<void> {
48
- const others = otherPiProcesses(await processSnapshot(), process.pid);
49
- if (others.length) throw new Error(`Session maintenance deferred: ${others.length} other Pi process(es) are open. Close them and retry /session-cleanup.`);
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
  }