@saifymatteo/pi-extensions 0.0.0-stage → 0.1.1

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.
Files changed (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,311 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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";
5
+ 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";
10
+ import type { TrashChoice } from "./dialog.ts";
11
+ import { DIALOG_OVERLAY } from "./lib/dialog.ts";
12
+
13
+ const DAY = 24 * 60 * 60 * 1000;
14
+ type State = { approved: boolean; paused: boolean; lastRun: number };
15
+ const freshState = (): State => ({ approved: false, paused: false, lastRun: 0 });
16
+ const cleanText = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
17
+ const message = (error: unknown) => error instanceof Error ? error.message : String(error);
18
+
19
+ export function storePaths(ctx: Pick<ExtensionContext, "sessionManager">, env = process.env) {
20
+ const agent = resolve(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"));
21
+ const defaults = join(agent, "sessions");
22
+ const actual = resolve(ctx.sessionManager.getSessionDir());
23
+ const cwd = resolve(ctx.sessionManager.getCwd());
24
+ const expected = join(defaults, `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
25
+ // A custom --session-dir / setting is a flat store. Never broaden it to its parent.
26
+ const explicit = process.argv.some((arg) => arg === "--session-dir" || arg.startsWith("--session-dir="));
27
+ const root = pathKey(actual) === pathKey(expected) && !env.PI_CODING_AGENT_SESSION_DIR && !explicit ? defaults : actual;
28
+ if (dirname(root) === root || pathKey(root).split(/[/\\]/).includes(".trash")) throw new Error("Refusing cleanup of a filesystem root or Trash directory.");
29
+ const key = createHash("sha256").update(pathKey(root)).digest("hex").slice(0, 12);
30
+ return { root, trash: join(dirname(root), ".trash", `${basename(root)}-${key}`) };
31
+ }
32
+
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
+ async function loadState(trash: string): Promise<State> {
45
+ const path = join(trash, "settings.json");
46
+ try {
47
+ const info = await lstat(path);
48
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe retention settings file.");
49
+ const state = JSON.parse(await readFile(path, "utf8"));
50
+ if (typeof state.approved !== "boolean" || typeof state.paused !== "boolean" || !Number.isFinite(state.lastRun) || state.lastRun < 0) throw new Error("Invalid retention settings; no cleanup performed.");
51
+ return state;
52
+ } catch (error: any) { if (error.code === "ENOENT") return freshState(); throw error; }
53
+ }
54
+
55
+ async function saveState(trash: string, state: State) {
56
+ const path = join(trash, `settings-${randomUUID()}.tmp`);
57
+ const file = await open(path, "wx", 0o600);
58
+ try {
59
+ await file.writeFile(JSON.stringify(state));
60
+ await file.sync();
61
+ await file.close();
62
+ await rename(path, join(trash, "settings.json"));
63
+ } finally { await file.close(); await unlink(path).catch((error) => { if (error.code !== "ENOENT") throw error; }); }
64
+ }
65
+
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 } = {}) {
122
+ const now = options.now ?? Date.now;
123
+ let started = false;
124
+ let maintaining = false;
125
+ let lifecycle = new AbortController();
126
+ let operation: Promise<unknown> | undefined;
127
+ let closeDialog: (() => void) | undefined;
128
+ function status(ctx: ExtensionContext, text: string | undefined) {
129
+ try { ctx.ui.setStatus("session-retention", text); } catch { /* A replaced UI must not interrupt file safety/lock cleanup. */ }
130
+ }
131
+ async function locked<T>(ctx: ExtensionContext, trash: string, label: string, work: (signal: AbortSignal) => Promise<T>): Promise<T> {
132
+ const signal = lifecycle.signal;
133
+ signal.throwIfAborted();
134
+ if (maintaining) throw new Error("Session maintenance is already running.");
135
+ maintaining = true;
136
+ try {
137
+ status(ctx, label);
138
+ const running = withLock(trash, async () => { signal.throwIfAborted(); return work(signal); });
139
+ operation = running;
140
+ return await running;
141
+ } finally { operation = undefined; maintaining = false; if (!signal.aborted) status(ctx, undefined); }
142
+ }
143
+ function mutationGuard(ctx: ExtensionContext): Guard {
144
+ const probe = maintenanceGuard(options.guard);
145
+ const signal = lifecycle.signal;
146
+ return async (_operation, paths) => {
147
+ signal.throwIfAborted();
148
+ const active = ctx.sessionManager.getSessionFile();
149
+ if (active && paths.some((path) => pathKey(path) === pathKey(active))) throw new Error("Refusing to move or delete the current session.");
150
+ const allowed = await probe();
151
+ signal.throwIfAborted();
152
+ return allowed;
153
+ };
154
+ }
155
+ const preventSwitch = (_event: unknown, ctx: ExtensionContext) => {
156
+ if (!maintaining) return;
157
+ ctx.ui.notify("Finish or cancel session maintenance before switching sessions.", "warning");
158
+ return { cancel: true };
159
+ };
160
+ pi.on("session_before_switch", preventSwitch);
161
+ pi.on("session_before_fork", preventSwitch);
162
+ pi.on("session_shutdown", async (_event, ctx) => {
163
+ status(ctx, undefined);
164
+ lifecycle.abort();
165
+ closeDialog?.();
166
+ // Never release a lock while a file operation can still be using it.
167
+ await operation?.catch(() => {});
168
+ started = false;
169
+ });
170
+
171
+ async function cleanup(ctx: ExtensionContext, manual: boolean, previewOnly = false) {
172
+ if (ctx.mode !== "tui") return;
173
+ const paths = storePaths(ctx);
174
+ await locked(ctx, paths.trash, "Session cleanup: checking schedule…", async (signal) => {
175
+ const state = await loadState(paths.trash);
176
+ signal.throwIfAborted();
177
+ const time = now();
178
+ if (!manual && (state.paused || time - state.lastRun < DAY)) return;
179
+ const store = new RetentionStore(paths.root, paths.trash);
180
+ const current = ctx.sessionManager.getSessionFile();
181
+ status(ctx, "Session cleanup: scanning sessions…");
182
+ const scan = await store.scan({ now: time, protectedPaths: current ? [current] : [] });
183
+ if (scan.aborted) throw new Error(`Session cleanup deferred: ${scan.issues.join("; ")}`);
184
+ signal.throwIfAborted();
185
+ const trash = await store.listTrash();
186
+ signal.throwIfAborted();
187
+ const expired = trash.batches.filter((batch) => batch.manifest.ready && !batch.missing.length && time - batch.manifest.trashedAt > RETENTION_MS);
188
+ if (trash.issues.length) ctx.ui.notify(`Some Trash records were skipped: ${trash.issues.join("; ")}`, "warning");
189
+ const count = scan.candidates.reduce((sum, family) => sum + family.paths.length, 0);
190
+ const summary = [
191
+ `Store: ${paths.root}`,
192
+ `${scan.candidates.length} inactive families (${count} sessions) → Trash for 30 days.`,
193
+ `${expired.length} expired Trash families → permanent deletion.`,
194
+ "A family is eligible only after its newest file has been inactive for 30 days.",
195
+ "Named sessions follow the same rule. The current session's family is protected.",
196
+ "Close other Pi windows and do not launch another process during maintenance.",
197
+ ...scan.candidates.slice(0, 8).map((family) => cleanText(`• ${relative(paths.root, family.paths[0])} (${family.paths.length} sessions)`)),
198
+ ...(scan.candidates.length > 8 ? [`… and ${scan.candidates.length - 8} more families.`] : []),
199
+ ].join("\n");
200
+ if (previewOnly) { status(ctx, "Session cleanup: preview — no changes"); await ctx.ui.confirm("Preview only — nothing will be changed", summary, { signal }); return; }
201
+ if (!state.approved || state.paused) {
202
+ if (!manual && !count && !expired.length) { state.lastRun = time; await saveState(paths.trash, state); return; }
203
+ status(ctx, "Session cleanup: awaiting approval…");
204
+ const approved = await ctx.ui.confirm("Enable daily session cleanup?", `${summary}\n\nApprove this cleanup and future automatic cleanup at startup (at most daily)?`, { signal });
205
+ signal.throwIfAborted();
206
+ if (!approved) {
207
+ state.lastRun = time; await saveState(paths.trash, state); return;
208
+ }
209
+ state.approved = true;
210
+ state.paused = false;
211
+ }
212
+ const guard = mutationGuard(ctx);
213
+ status(ctx, "Session cleanup: checking other Pi processes…");
214
+ await guard("trash", []);
215
+ if (scan.candidates.length) {
216
+ status(ctx, `Session cleanup: moving ${count} sessions to Trash…`);
217
+ await store.trashFamilies(scan.candidates, { now: time, guard });
218
+ }
219
+ signal.throwIfAborted();
220
+ status(ctx, "Session cleanup: purging expired Trash…");
221
+ const purged = await store.purgeExpired({ now: time, guard });
222
+ signal.throwIfAborted();
223
+ state.lastRun = time;
224
+ await saveState(paths.trash, state);
225
+ signal.throwIfAborted();
226
+ if (count || purged.purged.length || manual) ctx.ui.notify(`Session cleanup: ${count} sessions moved to Trash; ${purged.purged.length} families permanently deleted. /session-trash restores families.`, "info");
227
+ if (purged.issues.length) ctx.ui.notify(purged.issues.join("; "), "warning");
228
+ });
229
+ }
230
+
231
+ pi.on("session_start", async (_event, ctx) => {
232
+ if (lifecycle.signal.aborted) lifecycle = new AbortController();
233
+ if (started || ctx.mode !== "tui" || !ctx.sessionManager.getSessionFile()) return;
234
+ started = true;
235
+ const signal = lifecycle.signal;
236
+ try { await cleanup(ctx, false); } catch (error) { if (!signal.aborted) ctx.ui.notify(message(error), "warning"); }
237
+ });
238
+
239
+ pi.registerCommand("session-cleanup", {
240
+ description: "Clean inactive session families [preview|pause]",
241
+ getArgumentCompletions: (prefix) => {
242
+ const items = [
243
+ { value: "preview", label: "preview — inspect without changing sessions" },
244
+ { value: "pause", label: "pause — disable automatic cleanup" },
245
+ ].filter((item) => item.value.startsWith(prefix));
246
+ return items.length ? items : null;
247
+ },
248
+ handler: async (args, ctx) => {
249
+ if (ctx.mode !== "tui") return ctx.ui.notify("Session maintenance requires interactive mode.", "warning");
250
+ const signal = lifecycle.signal;
251
+ const action = args.trim();
252
+ if (!["", "preview", "pause"].includes(action)) return ctx.ui.notify("Usage: /session-cleanup [preview|pause]", "info");
253
+ try {
254
+ if (action === "pause") {
255
+ const paths = storePaths(ctx);
256
+ 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); });
257
+ signal.throwIfAborted();
258
+ ctx.ui.notify("Automatic cleanup paused. Run /session-cleanup to review and re-enable it.", "info");
259
+ } else { await ctx.waitForIdle(); signal.throwIfAborted(); await cleanup(ctx, true, action === "preview"); }
260
+ } catch (error) { if (!signal.aborted) ctx.ui.notify(message(error), "error"); }
261
+ },
262
+ });
263
+
264
+ pi.registerCommand("session-trash", {
265
+ description: "Search Trash by names, first prompts, IDs or paths; restore a whole family",
266
+ handler: async (args, ctx) => {
267
+ if (ctx.mode !== "tui") return ctx.ui.notify("Session Trash requires interactive mode.", "warning");
268
+ const signal = lifecycle.signal;
269
+ try {
270
+ await ctx.waitForIdle();
271
+ signal.throwIfAborted();
272
+ const paths = storePaths(ctx);
273
+ await locked(ctx, paths.trash, "Session Trash: loading families…", async (signal) => {
274
+ const store = new RetentionStore(paths.root, paths.trash);
275
+ const { batches, issues } = await store.listTrash();
276
+ signal.throwIfAborted();
277
+ if (issues.length) ctx.ui.notify(issues.join("; "), "warning");
278
+ const choices: TrashChoice[] = [];
279
+ for (const batch of batches.sort((a, b) => b.manifest.trashedAt - a.manifest.trashedAt)) {
280
+ status(ctx, `Session Trash: loading families ${choices.length + 1}/${batches.length}…`);
281
+ const text = await trashSearchText(paths.trash, batch);
282
+ signal.throwIfAborted();
283
+ choices.push({ value: batch.manifest.id, search: text.search, label: text.title || batch.manifest.files[0].original,
284
+ 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)}` });
285
+ }
286
+ status(ctx, "Session Trash: choose a family to restore");
287
+ let selected: string | undefined;
288
+ try {
289
+ selected = await ctx.ui.custom<string | undefined>(async (tui, theme, keybindings, done) => {
290
+ closeDialog = () => done(undefined);
291
+ const { SessionTrashDialog } = await import("./dialog.ts");
292
+ signal.throwIfAborted();
293
+ return new SessionTrashDialog(tui, theme, keybindings, choices, cleanText(args), done);
294
+ }, DIALOG_OVERLAY);
295
+ } finally { closeDialog = undefined; }
296
+ signal.throwIfAborted();
297
+ const match = batches.find((batch) => batch.manifest.id === selected);
298
+ if (!match) return;
299
+ status(ctx, "Session Trash: confirming restore…");
300
+ 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 });
301
+ signal.throwIfAborted();
302
+ if (!approved) return;
303
+ status(ctx, `Session Trash: restoring ${match.manifest.files.length} sessions…`);
304
+ await store.restoreFamily(match.manifest.id, { now: now(), guard: mutationGuard(ctx) });
305
+ signal.throwIfAborted();
306
+ ctx.ui.notify("Session family restored. It is available in normal session search again.", "info");
307
+ });
308
+ } catch (error) { if (!signal.aborted) ctx.ui.notify(message(error), "error"); }
309
+ },
310
+ });
311
+ }
@@ -0,0 +1,31 @@
1
+ // One size for every overlay dialog in this package. Change DIALOG_HEIGHT here and every dialog follows:
2
+ // the overlay is capped at that share of the terminal and each dialog pads its list to fill it.
3
+
4
+ /** Share of the terminal height every dialog fills. */
5
+ export const DIALOG_HEIGHT = 0.8
6
+
7
+ /** `ctx.ui.custom(factory, DIALOG_OVERLAY)`: centered, 92% wide, DIALOG_HEIGHT tall. */
8
+ export const DIALOG_OVERLAY = {
9
+ overlay: true,
10
+ overlayOptions: { anchor: 'center', width: '92%', maxHeight: `${DIALOG_HEIGHT * 100}%`, margin: 1 },
11
+ } as const
12
+
13
+ /**
14
+ * Scrolling rows that fit once `chrome` fixed lines (borders, search, header, footer) are drawn.
15
+ * Same floor as the overlay's own percentage, so dialog and overlay agree; read it in render() so a
16
+ * resize takes effect on the next frame.
17
+ */
18
+ export const dialogRows = (tui: { terminal?: { rows: number } }, chrome: number, min = 1): number =>
19
+ Math.max(min, Math.floor((tui.terminal?.rows ?? 24) * DIALOG_HEIGHT) - chrome)
20
+
21
+ /**
22
+ * Mouse wheel → ↑/↓ key presses, so a dialog reuses its own keyboard handling (clamping, render).
23
+ * Use as `handleMouse(e) { return wheelAsArrows(e, (k) => this.handleInput(k)) }`. Fullscreen mode only:
24
+ * regular mode leaves the mouse to the terminal. Handling the wheel also stops it scrolling the transcript behind.
25
+ */
26
+ export function wheelAsArrows(event: { type: string; wheelDelta?: number }, handleInput: (data: string) => void): { handled: true } | undefined {
27
+ if (event.type !== 'wheel' || !event.wheelDelta) return undefined
28
+ const key = event.wheelDelta < 0 ? '\x1b[A' : '\x1b[B'
29
+ for (let i = 0; i < Math.min(Math.abs(event.wheelDelta), 20); i++) handleInput(key)
30
+ return { handled: true }
31
+ }
@@ -0,0 +1,50 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ export type ProcessInfo = { pid: number; name: string; command: string };
5
+ const exec = promisify(execFile);
6
+
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.
9
+ export function otherPiProcesses(processes: ProcessInfo[], ownPid: number): ProcessInfo[] {
10
+ if (!processes.some((item) => item.pid === ownPid)) throw new Error("Cannot verify the current Pi process; cleanup deferred.");
11
+ return processes.filter(({ pid, name, command }) => pid !== ownPid && (
12
+ /^pi(?:-bolt)?(?:\.exe)?$/i.test(name) ||
13
+ (/^(?:node|bun)(?:\.exe)?$/i.test(name) && /(?:[/\\]pi(?:-bolt)?(?:\.[cm]?js)?(?:[\s"']|$)|pi-coding-agent[/\\]|packages[/\\]coding-agent[/\\])/i.test(command))
14
+ ));
15
+ }
16
+
17
+ export async function processSnapshot(): Promise<ProcessInfo[]> {
18
+ if (process.platform === "win32") {
19
+ 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"],
21
+ { timeout: 15_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true });
22
+ const items = JSON.parse(stdout.replace(/^\uFEFF/, ""));
23
+ 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 ?? "" }));
25
+ }
26
+ const { stdout } = await exec("ps", ["-axo", "pid=,comm=,args="], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 });
27
+ 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
+ });
31
+ }
32
+
33
+ // A process scan starts PowerShell on Windows. Reuse a successful inventory for
34
+ // at most one second within ONE maintenance transaction, not once per file.
35
+ // This remains a sampled safeguard, not an OS lock honored by external SDK hosts.
36
+ export function maintenanceGuard(probe = ensureSinglePiProcess, clock = () => performance.now()) {
37
+ let checkedUntil = -Infinity;
38
+ return async () => {
39
+ if (clock() >= checkedUntil) {
40
+ await probe();
41
+ checkedUntil = clock() + 1000;
42
+ }
43
+ return true;
44
+ };
45
+ }
46
+
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.`);
50
+ }
@@ -0,0 +1,42 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile, utimes, access, rm, realpath } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { RetentionStore, RETENTION_MS } from '../src/core.ts';
7
+ import { otherPiProcesses, maintenanceGuard } from '../src/safety.ts';
8
+
9
+ test('retention round trip protects current families, requires guard and restores without overwrite', async () => {
10
+ const dir = await mkdtemp(join(await realpath(tmpdir()), 'pi-retention-package-'));
11
+ try {
12
+ const root = join(dir, 'sessions'), trash = join(dir, 'trash');
13
+ await mkdir(root);
14
+ const file = join(root, 'old.jsonl'), now = Date.now(), old = now - RETENTION_MS - 60000;
15
+ await writeFile(file, JSON.stringify({ type: 'session', id: 'old', timestamp: new Date(old).toISOString() }) + '\n');
16
+ await utimes(file, new Date(old), new Date(old));
17
+ const store = new RetentionStore(root, trash);
18
+ assert.equal((await store.scan({ now, protectedPaths: [file] })).candidates.length, 0);
19
+ const scan = await store.scan({ now });
20
+ assert.equal(scan.candidates.length, 1);
21
+ await assert.rejects(store.trashFamily(scan.candidates[0], { now, guard: async () => false }), /guard refused/);
22
+ const manifest = await store.trashFamily(scan.candidates[0], { now, guard: async () => true });
23
+ await assert.rejects(access(file), { code: 'ENOENT' });
24
+ assert.equal((await store.listTrash()).batches.length, 1);
25
+ await writeFile(file, 'collision');
26
+ await assert.rejects(store.restoreFamily(manifest.id, { now, guard: async () => true }), /Restore collision/);
27
+ await rm(file);
28
+ await store.restoreFamily(manifest.id, { now, guard: async () => true });
29
+ await access(file);
30
+ assert.equal((await store.listTrash()).batches.length, 0);
31
+ assert.equal((await store.scan({ now })).candidates.length, 0);
32
+ } finally { await rm(dir, { recursive: true, force: true }); }
33
+ });
34
+
35
+ test('maintenance inventory fails closed and caches probes only within a second', async () => {
36
+ assert.throws(() => otherPiProcesses([], 1), /Cannot verify/);
37
+ assert.equal(otherPiProcesses([{ pid: 1, name: 'pi-bolt', command: '' }, { pid: 2, name: 'pi', command: '' }], 1).length, 1);
38
+ let clock = 0, calls = 0;
39
+ const guard = maintenanceGuard(async () => { calls++; }, () => clock);
40
+ await guard(); await guard(); assert.equal(calls, 1);
41
+ clock = 1000; await guard(); assert.equal(calls, 2);
42
+ });