@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.
@@ -0,0 +1,188 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, readdir, rename, unlink } from "node:fs/promises";
3
+ import type { BigIntStats } from "node:fs";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { join, resolve } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { finished } from "node:stream/promises";
8
+ import { regular, safePath, type TrashBatch } from "./core.ts";
9
+ import { safeDirectory } from "./safety.ts";
10
+
11
+ export type TrashSearchText = { search: string; title: string; cwd?: string };
12
+ const VERSION = 1;
13
+ const CACHE_BYTES = 8 * 1024 * 1024;
14
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
+ const cleanText = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
16
+ const absent = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT";
17
+ export const stamp = (s: BigIntStats) => {
18
+ const values = [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs];
19
+ if (values.some(value => typeof value !== "bigint")) throw new Error("Cannot verify Trash file identity or timestamp precision");
20
+ return values.map(String).join(":");
21
+ };
22
+ const changed = () => new Error("Trash files changed while reading search metadata");
23
+
24
+ // regular checks every ancestor without creating anything. Use nanoseconds for
25
+ // the fingerprint, including ctime so restoring mtime cannot hide an edit.
26
+ export async function fileStamp(path: string): Promise<string> {
27
+ await regular(path);
28
+ const s = await lstat(path, { bigint: true });
29
+ if (!s.isFile() || s.isSymbolicLink()) throw new Error(`Unsafe Trash file: ${path}`);
30
+ return stamp(s);
31
+ }
32
+
33
+ export class TrashSearchCache {
34
+ private readonly root: string;
35
+ private readonly directory: string;
36
+ constructor(trashRoot: string) {
37
+ this.root = resolve(trashRoot);
38
+ this.directory = join(this.root, ".search-cache");
39
+ }
40
+
41
+ async read(batch: TrashBatch, signal: AbortSignal): Promise<TrashSearchText> {
42
+ signal.throwIfAborted();
43
+ // Snapshot caller-owned metadata before yielding. No cache-provided path is
44
+ // ever used to open a transcript, even when the cache is otherwise valid.
45
+ const manifest = structuredClone(batch.manifest), missing = [...batch.missing];
46
+ if (!manifest || manifest.version !== 1 || !uuid.test(manifest.id) || typeof manifest.ready !== "boolean" ||
47
+ !Number.isFinite(manifest.trashedAt) || manifest.trashedAt < 0 || !Array.isArray(manifest.files) || !manifest.files.length ||
48
+ missing.some(item => typeof item !== "string")) throw new Error("Invalid Trash manifest");
49
+ for (const [i, file] of manifest.files.entries()) {
50
+ if (!file || file.stored !== `${i}.jsonl` || typeof file.original !== "string" ||
51
+ !file.original.endsWith(".jsonl") || file.original.includes("\\") ||
52
+ file.original.split("/").some(part => !part || part === "." || part === ".." || part.includes(":"))) throw new Error("Invalid Trash path");
53
+ }
54
+ const missingFiles = new Set(missing);
55
+ const paths = manifest.files.map(file => join(this.root, manifest.id, file.stored));
56
+ const snapshot = async () => {
57
+ const stamps: (string | null)[] = [];
58
+ for (const [i, path] of paths.entries()) {
59
+ signal.throwIfAborted();
60
+ let value: string | null;
61
+ try { value = await fileStamp(path); } catch (error) { if (!absent(error)) throw error; value = null; }
62
+ if ((value === null) !== missingFiles.has(manifest.files[i].stored)) throw changed();
63
+ stamps.push(value);
64
+ }
65
+ return stamps;
66
+ };
67
+ const before = await snapshot();
68
+ const fingerprint = createHash("sha256").update(JSON.stringify({ manifest, missing, stamps: before })).digest("hex");
69
+ const verify = async () => {
70
+ if (JSON.stringify(await snapshot()) !== JSON.stringify(before)) throw changed();
71
+ signal.throwIfAborted();
72
+ };
73
+ const cached = await this.cached(manifest.id, fingerprint);
74
+ if (cached) { await verify(); return cached; }
75
+
76
+ const values = [manifest.id, ...manifest.files.map(file => file.original)], labels: string[] = [];
77
+ let cwd: string | undefined;
78
+ for (const [i, path] of paths.entries()) {
79
+ signal.throwIfAborted();
80
+ if (before[i] === null) continue;
81
+ if (await fileStamp(path) !== before[i]) throw changed();
82
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
83
+ try {
84
+ if (stamp(await handle.stat({ bigint: true })) !== before[i]) throw changed();
85
+ signal.throwIfAborted();
86
+ const stream = handle.createReadStream({ autoClose: false, signal });
87
+ // Attach the drain/error observer before abort can destroy the stream.
88
+ let streamError: unknown;
89
+ const drained = finished(stream).catch(error => { streamError = error; });
90
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
91
+ let name = "", firstPrompt: string | undefined, fileCwd: string | undefined;
92
+ try {
93
+ for await (const line of lines) {
94
+ signal.throwIfAborted();
95
+ let entry;
96
+ try { entry = JSON.parse(line); } catch { continue; }
97
+ if (!entry || typeof entry !== "object") continue;
98
+ if (entry.type === "session") { values.push(entry.id ?? "", entry.cwd ?? ""); if (fileCwd === undefined && typeof entry.cwd === "string") fileCwd = entry.cwd; }
99
+ if (entry.type === "session_info" && typeof entry.name === "string") name = entry.name;
100
+ if (firstPrompt === undefined && entry.type === "message" && entry.message?.role === "user") {
101
+ const content = entry.message.content;
102
+ 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(" ") : "";
103
+ }
104
+ }
105
+ signal.throwIfAborted();
106
+ if (stamp(await handle.stat({ bigint: true })) !== before[i] || await fileStamp(path) !== before[i]) throw changed();
107
+ } finally { lines.close(); stream.destroy(); await drained; }
108
+ signal.throwIfAborted();
109
+ if (streamError) throw streamError;
110
+ values.push(name, firstPrompt ?? "");
111
+ if (cwd === undefined && fileCwd !== undefined) cwd = fileCwd;
112
+ labels.push(name || firstPrompt || manifest.files[i].original);
113
+ } finally { await handle.close(); }
114
+ }
115
+ await verify();
116
+ const text: TrashSearchText = { search: cleanText(values.join(" ")), title: cleanText(labels.join(" / ")) };
117
+ if (cwd !== undefined) text.cwd = cleanText(cwd) || undefined;
118
+ await this.save(manifest.id, fingerprint, text, signal, verify);
119
+ await verify();
120
+ return text;
121
+ }
122
+
123
+ private async cached(id: string, fingerprint: string): Promise<TrashSearchText | undefined> {
124
+ try {
125
+ const path = join(this.directory, `${id}.json`), before = await fileStamp(path);
126
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
127
+ try {
128
+ const info = await handle.stat({ bigint: true });
129
+ if (!info.isFile() || info.size > BigInt(CACHE_BYTES) || stamp(info) !== before) return;
130
+ const buffer = Buffer.alloc(Number(info.size) + 1);
131
+ let size = 0;
132
+ while (size < buffer.length) {
133
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size);
134
+ if (!bytesRead) break;
135
+ size += bytesRead;
136
+ }
137
+ if (size !== Number(info.size) || stamp(await handle.stat({ bigint: true })) !== before || await fileStamp(path) !== before) return;
138
+ const value = JSON.parse(buffer.subarray(0, size).toString("utf8"));
139
+ if (value?.version !== VERSION || value.fingerprint !== fingerprint || typeof value.search !== "string" || typeof value.title !== "string" ||
140
+ cleanText(value.search) !== value.search || cleanText(value.title) !== value.title ||
141
+ (value.cwd !== undefined && (typeof value.cwd !== "string" || cleanText(value.cwd) !== value.cwd))) return;
142
+ const text: TrashSearchText = { search: value.search, title: value.title };
143
+ if (typeof value.cwd === "string") text.cwd = value.cwd;
144
+ return text;
145
+ } finally { await handle.close(); }
146
+ } catch { return; } // Advisory only: unsafe, corrupt and unreadable entries are misses.
147
+ }
148
+
149
+ private async save(id: string, fingerprint: string, text: TrashSearchText, signal: AbortSignal, verify: () => Promise<void>) {
150
+ const data = JSON.stringify({ version: VERSION, fingerprint, ...text });
151
+ if (Buffer.byteLength(data) > CACHE_BYTES) return; // Never truncate display/search results.
152
+ const path = join(this.directory, `${id}.json`), temp = join(this.directory, `${id}.${randomUUID()}.tmp`);
153
+ let created = false;
154
+ try {
155
+ signal.throwIfAborted();
156
+ await safeDirectory(this.directory);
157
+ try { await regular(path); } catch (error) { if (!absent(error)) return; }
158
+ const handle = await open(temp, "wx", 0o600);
159
+ created = true;
160
+ try { await handle.writeFile(data); await handle.sync(); } finally { await handle.close(); }
161
+ await verify();
162
+ await regular(temp);
163
+ try { await regular(path); } catch (error) { if (!absent(error)) return; }
164
+ signal.throwIfAborted();
165
+ await rename(temp, path);
166
+ } catch { /* A read-only cache must not prevent fresh metadata display. */ }
167
+ finally { if (created) { try { await regular(temp); await unlink(temp); } catch {} } }
168
+ }
169
+
170
+ async remove(ids: readonly string[]): Promise<void> {
171
+ const selected = new Set(ids.filter(id => uuid.test(id)));
172
+ if (!selected.size) return;
173
+ let names: string[];
174
+ try { await safePath(this.directory); names = await readdir(this.directory); }
175
+ catch (error) { if (absent(error)) return; throw error; }
176
+ let failure: unknown;
177
+ for (const name of names) {
178
+ const match = name.match(/^([0-9a-f-]{36})(?:\.json|\.([0-9a-f-]{36})\.tmp)$/);
179
+ if (!match || !selected.has(match[1]) || (match[2] && !uuid.test(match[2]))) continue;
180
+ const path = join(this.directory, name);
181
+ try { await regular(path); await unlink(path); }
182
+ catch (error) { if (!absent(error)) failure ??= error; }
183
+ }
184
+ // The adapter reports erasure failures after the completed session operation.
185
+ // Include abandoned atomic-write files, which can also contain prompt text.
186
+ if (failure) throw failure;
187
+ }
188
+ }
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, rename, unlink } from "node:fs/promises";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { otherPiProcesses, processSnapshot, withLock, RetentionBusyError, type ProcessInfo } from "./safety.ts";
7
+
8
+ type RegistryOptions = { pid?: number; snapshot?: () => Promise<ProcessInfo[]>; clock?: () => number };
9
+ /** All instances sharing an agent home cooperate through this registry, outside session stores. */
10
+ export class SessionRegistry {
11
+ readonly directory: string;
12
+ private options: RegistryOptions;
13
+ private identity?: Promise<string>;
14
+ constructor(directory: string, options: RegistryOptions = {}) { this.directory = directory; this.options = options; }
15
+ private get pid() { return this.options.pid ?? process.pid; }
16
+ private async inventory() {
17
+ const items = await (this.options.snapshot ?? processSnapshot)();
18
+ // PID 0 is a valid OS pseudo-process (e.g. Windows System Idle Process), not an invalid inventory entry.
19
+ if (items.some(item => !Number.isInteger(item.pid) || item.pid < 0) || new Set(items.map(item => item.pid)).size !== items.length) throw new Error("Invalid process inventory; cleanup deferred.");
20
+ // Also verifies that the inventory includes this process.
21
+ const peers = otherPiProcesses(items, this.pid);
22
+ return [items.find(item => item.pid === this.pid)!, ...peers];
23
+ }
24
+ private started() {
25
+ return this.identity ??= this.inventory().then(items => {
26
+ const started = items[0].started;
27
+ if (typeof started !== "string" || !started) throw new Error("Cannot verify Pi process start time; cleanup deferred.");
28
+ return started;
29
+ }).catch(error => { this.identity = undefined; throw error; });
30
+ }
31
+ async register(paths: readonly string[], signal?: AbortSignal) {
32
+ if (paths.some(path => !isAbsolute(path))) throw new Error("Session registration requires absolute paths.");
33
+ const started = await this.started();
34
+ const publish = async () => {
35
+ const target = join(this.directory, `${this.pid}.json`);
36
+ try {
37
+ const info = await lstat(target);
38
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe session registration.");
39
+ } catch (error: any) { if (error.code !== "ENOENT") throw error; }
40
+ const temp = join(this.directory, `${this.pid}-${randomUUID()}.tmp`);
41
+ const file = await open(temp, "wx", 0o600);
42
+ try {
43
+ await file.writeFile(JSON.stringify({ version: 1, pid: this.pid, started, paths: [...new Set(paths.map(path => resolve(path)))] }));
44
+ await file.sync();
45
+ await file.close();
46
+ await rename(temp, target);
47
+ } finally { await file.close(); await unlink(temp).catch(error => { if (error.code !== "ENOENT") throw error; }); }
48
+ };
49
+ for (;;) {
50
+ signal?.throwIfAborted();
51
+ try { return await withLock(this.directory, publish, ".registry.lock"); }
52
+ catch (error) {
53
+ // Startup retries contention in the background; session changes and explicit
54
+ // maintenance fail immediately instead of waiting behind an approval dialog.
55
+ if (!signal || !(error instanceof RetentionBusyError)) throw error;
56
+ await delay(100, undefined, { signal });
57
+ }
58
+ }
59
+ }
60
+ private async activePaths(): Promise<string[]> {
61
+ const paths: string[] = [];
62
+ for (const item of await this.inventory()) {
63
+ try {
64
+ if (typeof item.started !== "string" || !item.started) throw new Error("Missing process start time");
65
+ const path = join(this.directory, `${item.pid}.json`);
66
+ const before = await lstat(path);
67
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 64 * 1024) throw new Error("Unsafe session registration");
68
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
69
+ try {
70
+ const info = await file.stat();
71
+ if (!info.isFile() || info.ino !== before.ino || info.dev !== before.dev || info.size > 64 * 1024) throw new Error("Changed session registration");
72
+ const record = JSON.parse(await file.readFile("utf8"));
73
+ if (record.version !== 1 || record.pid !== item.pid || record.started !== item.started || !Array.isArray(record.paths) || record.paths.some((p: unknown) => typeof p !== "string" || !isAbsolute(p))) throw new Error("Invalid or stale session registration");
74
+ paths.push(...record.paths);
75
+ } finally { await file.close(); }
76
+ } catch {
77
+ throw new Error(`Session maintenance deferred: Pi process ${item.pid} has no verifiable session registration. Reload/update that instance, or close it and retry /session-cleanup. Instances using another agent home must be closed.`);
78
+ }
79
+ }
80
+ return paths;
81
+ }
82
+ async maintain<T>(work: (activePaths: () => Promise<string[]>) => Promise<T>): Promise<T> {
83
+ return withLock(this.directory, async () => {
84
+ // The gate freezes registrations/switch intents throughout discovery and mutation.
85
+ // Cache successful OS inventories for at most one second to detect newcomers.
86
+ let checkedUntil = -Infinity;
87
+ let paths: string[] = [];
88
+ const clock = this.options.clock ?? (() => performance.now());
89
+ return work(async () => {
90
+ if (clock() >= checkedUntil) {
91
+ paths = await this.activePaths();
92
+ checkedUntil = clock() + 1000;
93
+ }
94
+ return paths;
95
+ });
96
+ }, ".registry.lock");
97
+ }
98
+ }