pi-blackhole 0.4.2 → 0.4.3

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 (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
package/src/om/cleanup.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * session JSONL files to find orphaned entries safe to delete.
4
4
  *
5
5
  * Per-session pending files under ~/.pi/agent/pi-blackhole/ accumulate when:
6
- * - compaction is set to "manual" (noAutoCompact legacy) — OM outputs are
6
+ * - compaction is set to "manual" — OM outputs are
7
7
  * buffered rather than appended to the session
8
8
  * - sessions are forked, abandoned, or deleted — the pending files remain
9
9
  * - stale backup files (-pending.stale.json) persist after write-safe renames
@@ -11,30 +11,36 @@
11
11
  * Safety invariant: a pending file is ONLY orphaned if its sessionId does NOT
12
12
  * appear in ANY session JSONL file across all known session directories.
13
13
  */
14
- import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
14
+ import {
15
+ existsSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ statSync,
19
+ unlinkSync,
20
+ } from "node:fs";
15
21
  import { join, resolve, sep } from "node:path";
16
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
23
 
18
24
  // ── Types ───────────────────────────────────────────────────────────────────
19
25
 
20
26
  export interface PendingFile {
21
- sessionId: string;
22
- filename: string;
23
- path: string;
24
- /** True for -pending.stale.json (backup of previous write). */
25
- isStale: boolean;
26
- sizeBytes: number;
27
- /** Last modified timestamp (epoch ms). */
28
- mtimeMs: number;
27
+ sessionId: string;
28
+ filename: string;
29
+ path: string;
30
+ /** True for -pending.stale.json (backup of previous write). */
31
+ isStale: boolean;
32
+ sizeBytes: number;
33
+ /** Last modified timestamp (epoch ms). */
34
+ mtimeMs: number;
29
35
  }
30
36
 
31
37
  export interface CleanupReport {
32
- /** All pending files found (both pending and stale). */
33
- all: PendingFile[];
34
- /** Files whose sessionId does not appear in any session JSONL. */
35
- orphaned: PendingFile[];
36
- /** Files whose sessionId was matched to an active session. */
37
- active: PendingFile[];
38
+ /** All pending files found (both pending and stale). */
39
+ all: PendingFile[];
40
+ /** Files whose sessionId does not appear in any session JSONL. */
41
+ orphaned: PendingFile[];
42
+ /** Files whose sessionId was matched to an active session. */
43
+ active: PendingFile[];
38
44
  }
39
45
 
40
46
  // ── Constants ───────────────────────────────────────────────────────────────
@@ -46,13 +52,13 @@ const STALE_SUFFIX = "-pending.stale.json";
46
52
  // ── Helper: extract session ID from filename ────────────────────────────────
47
53
 
48
54
  function extractSessionId(filename: string): string | null {
49
- if (filename.endsWith(STALE_SUFFIX)) {
50
- return filename.slice(0, -STALE_SUFFIX.length) || null;
51
- }
52
- if (filename.endsWith(PENDING_SUFFIX)) {
53
- return filename.slice(0, -PENDING_SUFFIX.length) || null;
54
- }
55
- return null;
55
+ if (filename.endsWith(STALE_SUFFIX)) {
56
+ return filename.slice(0, -STALE_SUFFIX.length) || null;
57
+ }
58
+ if (filename.endsWith(PENDING_SUFFIX)) {
59
+ return filename.slice(0, -PENDING_SUFFIX.length) || null;
60
+ }
61
+ return null;
56
62
  }
57
63
 
58
64
  // ── Scan pending files ──────────────────────────────────────────────────────
@@ -62,42 +68,42 @@ function extractSessionId(filename: string): string | null {
62
68
  * files. Returns file metadata sorted by mtime (newest first).
63
69
  */
64
70
  function scanPendingFiles(agentDir: string = getAgentDir()): PendingFile[] {
65
- const dir = join(agentDir, PENDING_DIR);
66
- if (!existsSync(dir)) return [];
67
-
68
- const results: PendingFile[] = [];
69
- let entries: string[];
70
- try {
71
- entries = readdirSync(dir);
72
- } catch {
73
- return [];
74
- }
75
-
76
- for (const filename of entries) {
77
- const sessionId = extractSessionId(filename);
78
- if (!sessionId) continue;
79
-
80
- const filePath = join(dir, filename);
81
- let stat;
82
- try {
83
- stat = statSync(filePath);
84
- } catch {
85
- continue; // file disappeared between readdir and stat
86
- }
87
-
88
- results.push({
89
- sessionId,
90
- filename,
91
- path: filePath,
92
- isStale: filename.endsWith(STALE_SUFFIX),
93
- sizeBytes: stat.size,
94
- mtimeMs: stat.mtimeMs,
95
- });
96
- }
97
-
98
- // Sort: newest first
99
- results.sort((a, b) => b.mtimeMs - a.mtimeMs);
100
- return results;
71
+ const dir = join(agentDir, PENDING_DIR);
72
+ if (!existsSync(dir)) return [];
73
+
74
+ const results: PendingFile[] = [];
75
+ let entries: string[];
76
+ try {
77
+ entries = readdirSync(dir);
78
+ } catch {
79
+ return [];
80
+ }
81
+
82
+ for (const filename of entries) {
83
+ const sessionId = extractSessionId(filename);
84
+ if (!sessionId) continue;
85
+
86
+ const filePath = join(dir, filename);
87
+ let stat;
88
+ try {
89
+ stat = statSync(filePath);
90
+ } catch {
91
+ continue; // file disappeared between readdir and stat
92
+ }
93
+
94
+ results.push({
95
+ sessionId,
96
+ filename,
97
+ path: filePath,
98
+ isStale: filename.endsWith(STALE_SUFFIX),
99
+ sizeBytes: stat.size,
100
+ mtimeMs: stat.mtimeMs,
101
+ });
102
+ }
103
+
104
+ // Sort: newest first
105
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
106
+ return results;
101
107
  }
102
108
 
103
109
  // ── Scan session IDs from JSONL files ───────────────────────────────────────
@@ -107,47 +113,51 @@ function scanPendingFiles(agentDir: string = getAgentDir()): PendingFile[] {
107
113
  * their header line (first line: {"type":"session","id":"<uuid>",...}).
108
114
  */
109
115
  function scanSessionDir(dir: string): Set<string> {
110
- const ids = new Set<string>();
111
- if (!existsSync(dir)) return ids;
112
-
113
- const stack: string[] = [dir];
114
- while (stack.length > 0) {
115
- const current = stack.pop()!;
116
- let names: string[];
117
- try {
118
- names = readdirSync(current);
119
- } catch {
120
- continue;
121
- }
122
-
123
- for (const name of names) {
124
- const fullPath = join(current, name);
125
- let st;
126
- try {
127
- st = statSync(fullPath);
128
- } catch {
129
- continue;
130
- }
131
-
132
- if (st.isDirectory()) {
133
- stack.push(fullPath);
134
- } else if (st.isFile() && name.endsWith(".jsonl")) {
135
- try {
136
- const fd = readFileSync(fullPath, "utf-8");
137
- const newlineIdx = fd.indexOf("\n");
138
- const firstLine = newlineIdx >= 0 ? fd.slice(0, newlineIdx) : fd;
139
- const header = JSON.parse(firstLine) as Record<string, unknown>;
140
- if (header.type === "session" && typeof header.id === "string" && header.id.length > 0) {
141
- ids.add(header.id);
142
- }
143
- } catch {
144
- // Corrupt or unreadable file — skip
145
- }
146
- }
147
- }
148
- }
149
-
150
- return ids;
116
+ const ids = new Set<string>();
117
+ if (!existsSync(dir)) return ids;
118
+
119
+ const stack: string[] = [dir];
120
+ while (stack.length > 0) {
121
+ const current = stack.pop()!;
122
+ let names: string[];
123
+ try {
124
+ names = readdirSync(current);
125
+ } catch {
126
+ continue;
127
+ }
128
+
129
+ for (const name of names) {
130
+ const fullPath = join(current, name);
131
+ let st;
132
+ try {
133
+ st = statSync(fullPath);
134
+ } catch {
135
+ continue;
136
+ }
137
+
138
+ if (st.isDirectory()) {
139
+ stack.push(fullPath);
140
+ } else if (st.isFile() && name.endsWith(".jsonl")) {
141
+ try {
142
+ const fd = readFileSync(fullPath, "utf-8");
143
+ const newlineIdx = fd.indexOf("\n");
144
+ const firstLine = newlineIdx >= 0 ? fd.slice(0, newlineIdx) : fd;
145
+ const header = JSON.parse(firstLine) as Record<string, unknown>;
146
+ if (
147
+ header.type === "session" &&
148
+ typeof header.id === "string" &&
149
+ header.id.length > 0
150
+ ) {
151
+ ids.add(header.id);
152
+ }
153
+ } catch {
154
+ // Corrupt or unreadable file — skip
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ return ids;
151
161
  }
152
162
 
153
163
  /**
@@ -155,20 +165,25 @@ function scanSessionDir(dir: string): Set<string> {
155
165
  * Returns the resolved absolute path, or undefined if not set or unreadable.
156
166
  */
157
167
  function readSettingsSessionDir(): string | undefined {
158
- try {
159
- const settingsPath = join(getAgentDir(), "settings.json");
160
- if (!existsSync(settingsPath)) return undefined;
161
- const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
162
- const dir = raw.sessionDir;
163
- if (typeof dir === "string" && dir.trim().length > 0) {
164
- // Expand ~ if present
165
- const expanded = dir.startsWith("~") ? join(process.env.HOME ?? "/home", dir.slice(2)) : dir;
166
- return resolve(expanded);
167
- }
168
- } catch {
169
- // Unreadable settings use default
170
- }
171
- return undefined;
168
+ try {
169
+ const settingsPath = join(getAgentDir(), "settings.json");
170
+ if (!existsSync(settingsPath)) return undefined;
171
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<
172
+ string,
173
+ unknown
174
+ >;
175
+ const dir = raw.sessionDir;
176
+ if (typeof dir === "string" && dir.trim().length > 0) {
177
+ // Expand ~ if present
178
+ const expanded = dir.startsWith("~")
179
+ ? join(process.env.HOME ?? "/home", dir.slice(2))
180
+ : dir;
181
+ return resolve(expanded);
182
+ }
183
+ } catch {
184
+ // Unreadable settings — use default
185
+ }
186
+ return undefined;
172
187
  }
173
188
 
174
189
  /**
@@ -178,20 +193,20 @@ function readSettingsSessionDir(): string | undefined {
178
193
  */
179
194
  /** Get the default sessions directory. */
180
195
  function getDefaultSessionsDir(): string {
181
- return join(getAgentDir(), "sessions");
196
+ return join(getAgentDir(), "sessions");
182
197
  }
183
198
 
184
199
  function findSessionDirs(): string[] {
185
- const dirs: string[] = [];
186
- const default_ = getDefaultSessionsDir();
187
- dirs.push(default_);
200
+ const dirs: string[] = [];
201
+ const default_ = getDefaultSessionsDir();
202
+ dirs.push(default_);
188
203
 
189
- const custom = readSettingsSessionDir();
190
- if (custom && custom !== default_ && existsSync(custom)) {
191
- dirs.push(custom);
192
- }
204
+ const custom = readSettingsSessionDir();
205
+ if (custom && custom !== default_ && existsSync(custom)) {
206
+ dirs.push(custom);
207
+ }
193
208
 
194
- return dirs;
209
+ return dirs;
195
210
  }
196
211
 
197
212
  /**
@@ -199,13 +214,13 @@ function findSessionDirs(): string[] {
199
214
  * Returns a Set of session UUIDs found in JSONL headers.
200
215
  */
201
216
  function collectAllSessionIds(sessionDirs?: string[]): Set<string> {
202
- const dirs = sessionDirs ?? findSessionDirs();
203
- const allIds = new Set<string>();
204
- for (const dir of dirs) {
205
- const ids = scanSessionDir(dir);
206
- for (const id of ids) allIds.add(id);
207
- }
208
- return allIds;
217
+ const dirs = sessionDirs ?? findSessionDirs();
218
+ const allIds = new Set<string>();
219
+ for (const dir of dirs) {
220
+ const ids = scanSessionDir(dir);
221
+ for (const id of ids) allIds.add(id);
222
+ }
223
+ return allIds;
209
224
  }
210
225
 
211
226
  // ── Cross-reference: find orphaned files ────────────────────────────────────
@@ -219,34 +234,37 @@ function collectAllSessionIds(sessionDirs?: string[]): Set<string> {
219
234
  * Returns the full report with all files classified.
220
235
  */
221
236
  function crossReference(
222
- pending: PendingFile[],
223
- sessionIds: Set<string>,
237
+ pending: PendingFile[],
238
+ sessionIds: Set<string>,
224
239
  ): CleanupReport {
225
- const orphaned: PendingFile[] = [];
226
- const active: PendingFile[] = [];
227
-
228
- for (const pf of pending) {
229
- if (sessionIds.has(pf.sessionId)) {
230
- active.push(pf);
231
- } else {
232
- orphaned.push(pf);
233
- }
234
- }
235
-
236
- return { all: pending, orphaned, active };
240
+ const orphaned: PendingFile[] = [];
241
+ const active: PendingFile[] = [];
242
+
243
+ for (const pf of pending) {
244
+ if (sessionIds.has(pf.sessionId)) {
245
+ active.push(pf);
246
+ } else {
247
+ orphaned.push(pf);
248
+ }
249
+ }
250
+
251
+ return { all: pending, orphaned, active };
237
252
  }
238
253
 
239
254
  /**
240
255
  * Full pipeline: scan pending files, collect session IDs, cross-reference.
241
256
  * Returns the cleanup report.
242
257
  */
243
- export function analyzeOrphaned(agentDir?: string, sessionDirs?: string[]): CleanupReport {
244
- const pending = scanPendingFiles(agentDir);
245
- if (pending.length === 0) {
246
- return { all: [], orphaned: [], active: [] };
247
- }
248
- const sessionIds = collectAllSessionIds(sessionDirs);
249
- return crossReference(pending, sessionIds);
258
+ export function analyzeOrphaned(
259
+ agentDir?: string,
260
+ sessionDirs?: string[],
261
+ ): CleanupReport {
262
+ const pending = scanPendingFiles(agentDir);
263
+ if (pending.length === 0) {
264
+ return { all: [], orphaned: [], active: [] };
265
+ }
266
+ const sessionIds = collectAllSessionIds(sessionDirs);
267
+ return crossReference(pending, sessionIds);
250
268
  }
251
269
 
252
270
  // ── Deletion ────────────────────────────────────────────────────────────────
@@ -259,25 +277,25 @@ const SAFE_SESSION_ID_RE = /^[a-zA-Z0-9][-a-zA-Z0-9]*$/;
259
277
  * pi-blackhole directory. Must be called before any unlink.
260
278
  */
261
279
  function validateDeletionPaths(
262
- sessionId: string,
263
- pendingDir: string,
280
+ sessionId: string,
281
+ pendingDir: string,
264
282
  ): { ok: true; pendingPath: string; stalePath: string } | { ok: false } {
265
- // sessionId must be non-empty and contain only safe filesystem characters
266
- if (!sessionId || typeof sessionId !== "string") return { ok: false };
267
- if (!SAFE_SESSION_ID_RE.test(sessionId)) return { ok: false };
283
+ // sessionId must be non-empty and contain only safe filesystem characters
284
+ if (!sessionId || typeof sessionId !== "string") return { ok: false };
285
+ if (!SAFE_SESSION_ID_RE.test(sessionId)) return { ok: false };
268
286
 
269
- const resolvedDir = resolve(pendingDir);
270
- const pendingPath = join(resolvedDir, `${sessionId}${PENDING_SUFFIX}`);
271
- const stalePath = join(resolvedDir, `${sessionId}${STALE_SUFFIX}`);
287
+ const resolvedDir = resolve(pendingDir);
288
+ const pendingPath = join(resolvedDir, `${sessionId}${PENDING_SUFFIX}`);
289
+ const stalePath = join(resolvedDir, `${sessionId}${STALE_SUFFIX}`);
272
290
 
273
- // Resolve to absolute and verify containment within pi-blackhole/
274
- const resolvedPending = resolve(pendingPath);
275
- const resolvedStale = resolve(stalePath);
291
+ // Resolve to absolute and verify containment within pi-blackhole/
292
+ const resolvedPending = resolve(pendingPath);
293
+ const resolvedStale = resolve(stalePath);
276
294
 
277
- if (!resolvedPending.startsWith(resolvedDir + sep)) return { ok: false };
278
- if (!resolvedStale.startsWith(resolvedDir + sep)) return { ok: false };
295
+ if (!resolvedPending.startsWith(resolvedDir + sep)) return { ok: false };
296
+ if (!resolvedStale.startsWith(resolvedDir + sep)) return { ok: false };
279
297
 
280
- return { ok: true, pendingPath: resolvedPending, stalePath: resolvedStale };
298
+ return { ok: true, pendingPath: resolvedPending, stalePath: resolvedStale };
281
299
  }
282
300
 
283
301
  /**
@@ -289,34 +307,37 @@ function validateDeletionPaths(
289
307
  * Returns true if at least one file was deleted, false if no files existed
290
308
  * or if the paths failed containment validation.
291
309
  */
292
- export function deletePendingFiles(sessionId: string, agentDir?: string): boolean {
293
- const dir = join(agentDir ?? getAgentDir(), PENDING_DIR);
294
- const valid = validateDeletionPaths(sessionId, dir);
295
- if (!valid.ok) return false;
296
-
297
- const { pendingPath, stalePath } = valid;
298
-
299
- let deleted = false;
300
-
301
- try {
302
- if (existsSync(pendingPath)) {
303
- unlinkSync(pendingPath);
304
- deleted = true;
305
- }
306
- } catch {
307
- // Best-effort — file may be locked or already gone
308
- }
309
-
310
- try {
311
- if (existsSync(stalePath)) {
312
- unlinkSync(stalePath);
313
- deleted = true;
314
- }
315
- } catch {
316
- // Best-effort
317
- }
318
-
319
- return deleted;
310
+ export function deletePendingFiles(
311
+ sessionId: string,
312
+ agentDir?: string,
313
+ ): boolean {
314
+ const dir = join(agentDir ?? getAgentDir(), PENDING_DIR);
315
+ const valid = validateDeletionPaths(sessionId, dir);
316
+ if (!valid.ok) return false;
317
+
318
+ const { pendingPath, stalePath } = valid;
319
+
320
+ let deleted = false;
321
+
322
+ try {
323
+ if (existsSync(pendingPath)) {
324
+ unlinkSync(pendingPath);
325
+ deleted = true;
326
+ }
327
+ } catch {
328
+ // Best-effort — file may be locked or already gone
329
+ }
330
+
331
+ try {
332
+ if (existsSync(stalePath)) {
333
+ unlinkSync(stalePath);
334
+ deleted = true;
335
+ }
336
+ } catch {
337
+ // Best-effort
338
+ }
339
+
340
+ return deleted;
320
341
  }
321
342
 
322
343
  /**
@@ -324,44 +345,44 @@ export function deletePendingFiles(sessionId: string, agentDir?: string): boolea
324
345
  * Returns the count of successfully deleted file sets.
325
346
  */
326
347
  export function deleteOrphanedBatch(
327
- orphaned: PendingFile[],
328
- agentDir?: string,
348
+ orphaned: PendingFile[],
349
+ agentDir?: string,
329
350
  ): number {
330
- let count = 0;
331
- for (const pf of orphaned) {
332
- if (deletePendingFiles(pf.sessionId, agentDir)) {
333
- count++;
334
- }
335
- }
336
- return count;
351
+ let count = 0;
352
+ for (const pf of orphaned) {
353
+ if (deletePendingFiles(pf.sessionId, agentDir)) {
354
+ count++;
355
+ }
356
+ }
357
+ return count;
337
358
  }
338
359
 
339
360
  // ── Formatting helpers ──────────────────────────────────────────────────────
340
361
 
341
362
  /** Human-readable file size. */
342
363
  function formatSize(bytes: number): string {
343
- if (bytes < 1024) return `${bytes} B`;
344
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
345
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
364
+ if (bytes < 1024) return `${bytes} B`;
365
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
366
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
346
367
  }
347
368
 
348
369
  /** Human-readable age from epoch ms. */
349
370
  function formatAge(mtimeMs: number, nowMs: number = Date.now()): string {
350
- const diffMs = nowMs - mtimeMs;
351
- const seconds = Math.floor(diffMs / 1000);
352
- if (seconds < 60) return `${seconds}s ago`;
353
- const minutes = Math.floor(seconds / 60);
354
- if (minutes < 60) return `${minutes}m ago`;
355
- const hours = Math.floor(minutes / 60);
356
- if (hours < 24) return `${hours}h ago`;
357
- const days = Math.floor(hours / 24);
358
- if (days < 365) return `${days}d ago`;
359
- const years = Math.floor(days / 365);
360
- return `${years}y ago`;
371
+ const diffMs = nowMs - mtimeMs;
372
+ const seconds = Math.floor(diffMs / 1000);
373
+ if (seconds < 60) return `${seconds}s ago`;
374
+ const minutes = Math.floor(seconds / 60);
375
+ if (minutes < 60) return `${minutes}m ago`;
376
+ const hours = Math.floor(minutes / 60);
377
+ if (hours < 24) return `${hours}h ago`;
378
+ const days = Math.floor(hours / 24);
379
+ if (days < 365) return `${days}d ago`;
380
+ const years = Math.floor(days / 365);
381
+ return `${years}y ago`;
361
382
  }
362
383
 
363
384
  /** Summary line for a pending file. */
364
385
  export function describeFile(pf: PendingFile, nowMs?: number): string {
365
- const label = pf.isStale ? "stale" : "pending";
366
- return `${pf.sessionId.slice(0, 8)}… ${label} ${formatSize(pf.sizeBytes)} ${formatAge(pf.mtimeMs, nowMs)}`;
386
+ const label = pf.isStale ? "stale" : "pending";
387
+ return `${pf.sessionId.slice(0, 8)}… ${label} ${formatSize(pf.sizeBytes)} ${formatAge(pf.mtimeMs, nowMs)}`;
367
388
  }