@oh-my-pi/pi-coding-agent 16.1.21 → 16.1.23

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 (46) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/cli.js +3659 -3517
  3. package/dist/types/cli/gc-cli.d.ts +58 -0
  4. package/dist/types/commands/gc.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +54 -0
  6. package/dist/types/config/settings.d.ts +11 -0
  7. package/dist/types/mcp/transports/stdio.d.ts +25 -1
  8. package/dist/types/mcp/transports/stdio.test.d.ts +1 -0
  9. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
  10. package/dist/types/modes/theme/theme.d.ts +1 -0
  11. package/dist/types/session/session-listing.d.ts +10 -1
  12. package/dist/types/system-prompt.d.ts +2 -0
  13. package/dist/types/tools/plan-mode-guard.d.ts +7 -0
  14. package/package.json +12 -12
  15. package/scripts/bench-guard.ts +1 -1
  16. package/src/cli/gc-cli.ts +939 -0
  17. package/src/cli-commands.ts +1 -0
  18. package/src/commands/gc.ts +46 -0
  19. package/src/config/settings-schema.ts +45 -0
  20. package/src/config/settings.ts +44 -6
  21. package/src/edit/hashline/filesystem.ts +11 -6
  22. package/src/eval/__tests__/julia-prelude.test.ts +18 -0
  23. package/src/eval/jl/runner.jl +7 -1
  24. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
  25. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +6 -0
  26. package/src/internal-urls/docs-index.generated.txt +1 -1
  27. package/src/lsp/index.ts +8 -1
  28. package/src/mcp/oauth-discovery.ts +62 -15
  29. package/src/mcp/transports/stdio.test.ts +45 -0
  30. package/src/mcp/transports/stdio.ts +46 -12
  31. package/src/modes/controllers/input-controller.ts +2 -2
  32. package/src/modes/controllers/selector-controller.ts +10 -0
  33. package/src/modes/interactive-mode.ts +26 -9
  34. package/src/modes/theme/mermaid-rendering.test.ts +53 -0
  35. package/src/modes/theme/theme.ts +33 -14
  36. package/src/prompts/system/plan-mode-active.md +9 -0
  37. package/src/prompts/system/system-prompt.md +2 -0
  38. package/src/sdk.ts +43 -4
  39. package/src/session/agent-session.ts +323 -62
  40. package/src/session/session-listing.ts +35 -2
  41. package/src/slash-commands/builtin-registry.ts +20 -2
  42. package/src/system-prompt.ts +4 -0
  43. package/src/tools/acp-bridge.ts +6 -1
  44. package/src/tools/plan-mode-guard.ts +26 -13
  45. package/src/utils/edit-mode.ts +19 -2
  46. package/src/utils/shell-snapshot.ts +1 -1
@@ -0,0 +1,939 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { gunzipSync, gzipSync } from "node:zlib";
5
+ import { getAgentDir, getBlobsDir, getHistoryDbPath, getModelDbPath, getSessionsDir } from "@oh-my-pi/pi-utils";
6
+ import { Settings } from "../config/settings";
7
+ import { getDefault } from "../config/settings-schema";
8
+ import { listSessionsReadOnly, type SessionInfo, type SessionStatus } from "../session/session-listing";
9
+ import { FileSessionStorage } from "../session/session-storage";
10
+
11
+ const HASH_RE = /^[a-f0-9]{64}$/;
12
+ const BLOB_FILE_RE = /^([a-f0-9]{64})(?:\.[A-Za-z0-9][A-Za-z0-9._-]{0,31})?$/;
13
+ const BLOB_REF_RE = /\bblob:sha256:([a-f0-9]{64})\b/gi;
14
+ const JSONL_GLOB = new Bun.Glob("**/*.jsonl");
15
+ const JSONL_GZ_GLOB = new Bun.Glob("**/*.jsonl.gz");
16
+ const JSONL_BACKUP_GLOB = new Bun.Glob("**/*.jsonl.*.bak");
17
+ const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set(["pending", "interrupted", "unknown"]);
18
+ const DAY_MS = 86_400_000;
19
+ const GC_WRITE_GRACE_MS = 5 * 60_000;
20
+ const SESSION_SUFFIX = ".jsonl";
21
+ const COMPRESSED_SESSION_SUFFIX = ".jsonl.gz";
22
+ const GC_LOCK_BREAKER_SUFFIX = ".break";
23
+
24
+ export interface GcCommandFlags {
25
+ apply?: boolean;
26
+ json?: boolean;
27
+ agentDir?: string;
28
+ blobs?: boolean;
29
+ archive?: boolean;
30
+ wal?: boolean;
31
+ coldArchiveAfterDays?: number;
32
+ retainNewestGlobal?: number;
33
+ retainNewestPerCwd?: number;
34
+ }
35
+
36
+ export interface GcCommandArgs {
37
+ flags: GcCommandFlags;
38
+ }
39
+
40
+ export interface BlobGcResult {
41
+ referenced: number;
42
+ candidates: number;
43
+ wouldDelete: number;
44
+ deleted: number;
45
+ bytes: number;
46
+ errors: string[];
47
+ }
48
+
49
+ export interface ArchiveGcResult {
50
+ scanned: number;
51
+ skippedActive: number;
52
+ keptNewestGlobal: number;
53
+ keptNewestPerCwd: number;
54
+ wouldArchive: number;
55
+ archived: number;
56
+ historyRowsDeleted: number;
57
+ ftsRebuilt: boolean;
58
+ errors: string[];
59
+ }
60
+
61
+ export interface WalCheckpointResult {
62
+ dbPath: string;
63
+ walBytes: number;
64
+ wouldCheckpoint: boolean;
65
+ checkpointed: boolean;
66
+ busy: number;
67
+ log: number;
68
+ checkpointedFrames: number;
69
+ }
70
+
71
+ export interface WalGcResult {
72
+ databases: WalCheckpointResult[];
73
+ walBytes: number;
74
+ wouldCheckpoint: boolean;
75
+ checkpointed: boolean;
76
+ }
77
+
78
+ export interface GcResult {
79
+ agentDir: string;
80
+ apply: boolean;
81
+ blobs?: BlobGcResult;
82
+ archive?: ArchiveGcResult;
83
+ wal?: WalGcResult;
84
+ lockPath: string;
85
+ }
86
+
87
+ interface BlobCandidate {
88
+ hash: string;
89
+ paths: string[];
90
+ bytes: number;
91
+ mtimeMs: number;
92
+ }
93
+
94
+ interface ArchiveCandidate {
95
+ session: SessionInfo;
96
+ relativePath: string;
97
+ destinationPath: string;
98
+ }
99
+
100
+ interface ResolvedGcOptions {
101
+ apply: boolean;
102
+ json: boolean;
103
+ agentDir: string;
104
+ runBlobs: boolean;
105
+ runArchive: boolean;
106
+ runWal: boolean;
107
+ coldArchiveAfterDays: number;
108
+ retainNewestGlobal: number;
109
+ retainNewestPerCwd: number;
110
+ }
111
+
112
+ interface SqliteRunResult {
113
+ changes?: number | bigint;
114
+ }
115
+
116
+ interface WalCheckpointRow {
117
+ busy?: number | bigint | null;
118
+ log?: number | bigint | null;
119
+ checkpointed?: number | bigint | null;
120
+ }
121
+
122
+ interface GcLockSnapshot {
123
+ dev: number;
124
+ ino: number;
125
+ size: number;
126
+ mtimeMs: number;
127
+ ctimeMs: number;
128
+ text: string;
129
+ }
130
+
131
+ function normalizeNumberSetting(value: unknown, defaultValue: number): number {
132
+ if (typeof value !== "number" || !Number.isFinite(value)) return defaultValue;
133
+ return Math.max(0, Math.floor(value));
134
+ }
135
+
136
+ function numberSetting(value: number | undefined, fallback: unknown, defaultValue: number): number {
137
+ if (value !== undefined && Number.isFinite(value)) return Math.max(0, Math.floor(value));
138
+ return normalizeNumberSetting(fallback, defaultValue);
139
+ }
140
+
141
+ async function resolveOptions(flags: GcCommandFlags): Promise<ResolvedGcOptions> {
142
+ const agentDir = path.resolve(flags.agentDir ?? getAgentDir());
143
+ const selected = flags.blobs === true || flags.archive === true || flags.wal === true;
144
+ const settings =
145
+ flags.apply === true ? await Settings.loadIsolated({ agentDir }) : await Settings.loadReadOnly({ agentDir });
146
+ const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey);
147
+ const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") =>
148
+ settings.get(pathKey);
149
+ return {
150
+ apply: flags.apply === true,
151
+ json: flags.json === true,
152
+ agentDir,
153
+ runBlobs: selected ? flags.blobs === true : getBoolean("gc.blobs"),
154
+ runArchive: selected ? flags.archive === true : getBoolean("gc.archive"),
155
+ runWal: selected ? flags.wal === true : getBoolean("gc.wal"),
156
+ coldArchiveAfterDays: numberSetting(
157
+ flags.coldArchiveAfterDays,
158
+ getNumber("gc.coldArchiveAfterDays"),
159
+ getDefault("gc.coldArchiveAfterDays"),
160
+ ),
161
+ retainNewestGlobal: numberSetting(
162
+ flags.retainNewestGlobal,
163
+ getNumber("gc.retainNewestGlobal"),
164
+ getDefault("gc.retainNewestGlobal"),
165
+ ),
166
+ retainNewestPerCwd: numberSetting(
167
+ flags.retainNewestPerCwd,
168
+ getNumber("gc.retainNewestPerCwd"),
169
+ getDefault("gc.retainNewestPerCwd"),
170
+ ),
171
+ };
172
+ }
173
+
174
+ export function collectGcErrors(result: GcResult): string[] {
175
+ return [
176
+ ...(result.blobs?.errors ?? []).map(error => `blobs: ${error}`),
177
+ ...(result.archive?.errors ?? []).map(error => `archive: ${error}`),
178
+ ];
179
+ }
180
+
181
+ function getArchivedSessionsDir(agentDir: string): string {
182
+ return path.join(path.dirname(getSessionsDir(agentDir)), "archive", "sessions");
183
+ }
184
+
185
+ function errorMessage(error: unknown): string {
186
+ return error instanceof Error ? error.message : String(error);
187
+ }
188
+
189
+ function codeOf(error: unknown): string | undefined {
190
+ return typeof error === "object" && error !== null && "code" in error
191
+ ? String((error as { code?: unknown }).code)
192
+ : undefined;
193
+ }
194
+
195
+ async function pathExists(target: string): Promise<boolean> {
196
+ try {
197
+ await fs.stat(target);
198
+ return true;
199
+ } catch (error) {
200
+ if (codeOf(error) === "ENOENT") return false;
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ async function statIfPresent(target: string) {
206
+ try {
207
+ return await fs.stat(target);
208
+ } catch (error) {
209
+ if (codeOf(error) === "ENOENT") return null;
210
+ throw error;
211
+ }
212
+ }
213
+
214
+ async function readTextIfPresent(file: string): Promise<string> {
215
+ try {
216
+ if (file.endsWith(COMPRESSED_SESSION_SUFFIX)) {
217
+ return new TextDecoder().decode(gunzipSync(await Bun.file(file).bytes()));
218
+ }
219
+ return await Bun.file(file).text();
220
+ } catch (error) {
221
+ if (codeOf(error) === "ENOENT") return "";
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ async function collectJsonlFiles(root: string): Promise<string[]> {
227
+ try {
228
+ const files = await Array.fromAsync(JSONL_GLOB.scan(root), name => path.join(root, name));
229
+ files.sort();
230
+ return files;
231
+ } catch (error) {
232
+ if (codeOf(error) === "ENOENT") return [];
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ async function collectCompressedJsonlFiles(root: string): Promise<string[]> {
238
+ try {
239
+ const files = await Array.fromAsync(JSONL_GZ_GLOB.scan(root), name => path.join(root, name));
240
+ files.sort();
241
+ return files;
242
+ } catch (error) {
243
+ if (codeOf(error) === "ENOENT") return [];
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ async function collectBackupJsonlFiles(root: string): Promise<string[]> {
249
+ try {
250
+ const files = await Array.fromAsync(JSONL_BACKUP_GLOB.scan(root), name => path.join(root, name));
251
+ files.sort();
252
+ return files;
253
+ } catch (error) {
254
+ if (codeOf(error) === "ENOENT") return [];
255
+ throw error;
256
+ }
257
+ }
258
+
259
+ async function collectReferencedBlobHashes(sessionRoots: string[]): Promise<Set<string>> {
260
+ const hashes = new Set<string>();
261
+ for (const root of sessionRoots) {
262
+ const files = [
263
+ ...(await collectJsonlFiles(root)),
264
+ ...(await collectCompressedJsonlFiles(root)),
265
+ ...(await collectBackupJsonlFiles(root)),
266
+ ];
267
+ for (const file of files) {
268
+ const text = await readTextIfPresent(file);
269
+ for (const match of text.matchAll(BLOB_REF_RE)) {
270
+ const hash = match[1]?.toLowerCase();
271
+ if (hash && HASH_RE.test(hash)) hashes.add(hash);
272
+ }
273
+ }
274
+ }
275
+ return hashes;
276
+ }
277
+
278
+ async function collectBlobCandidates(blobDir: string): Promise<BlobCandidate[]> {
279
+ let entries: string[];
280
+ try {
281
+ entries = await fs.readdir(blobDir);
282
+ } catch (error) {
283
+ if (codeOf(error) === "ENOENT") return [];
284
+ throw error;
285
+ }
286
+
287
+ const byHash = new Map<string, BlobCandidate>();
288
+ for (const entry of entries) {
289
+ const match = entry.match(BLOB_FILE_RE);
290
+ const hash = match?.[1];
291
+ if (!hash) continue;
292
+ const file = path.join(blobDir, entry);
293
+ const stat = await statIfPresent(file);
294
+ if (!stat) continue;
295
+ if (!stat.isFile()) continue;
296
+ const candidate = byHash.get(hash) ?? { hash, paths: [], bytes: 0, mtimeMs: stat.mtimeMs };
297
+ candidate.paths.push(file);
298
+ candidate.bytes += stat.size;
299
+ candidate.mtimeMs = Math.max(candidate.mtimeMs, stat.mtimeMs);
300
+ byHash.set(hash, candidate);
301
+ }
302
+ return [...byHash.values()].sort((a, b) => a.hash.localeCompare(b.hash));
303
+ }
304
+
305
+ async function runBlobGc(options: ResolvedGcOptions, archiveSessionsRoot: string): Promise<BlobGcResult> {
306
+ const blobDir = getBlobsDir(options.agentDir);
307
+ const sessionsRoot = getSessionsDir(options.agentDir);
308
+ const referenced = await collectReferencedBlobHashes([sessionsRoot, archiveSessionsRoot]);
309
+ const candidates = await collectBlobCandidates(blobDir);
310
+ const result: BlobGcResult = {
311
+ referenced: referenced.size,
312
+ candidates: candidates.length,
313
+ wouldDelete: 0,
314
+ deleted: 0,
315
+ bytes: 0,
316
+ errors: [],
317
+ };
318
+
319
+ const deleteBeforeMs = Date.now() - GC_WRITE_GRACE_MS;
320
+ for (const candidate of candidates) {
321
+ if (referenced.has(candidate.hash)) continue;
322
+ if (candidate.mtimeMs > deleteBeforeMs) continue;
323
+ result.wouldDelete += candidate.paths.length;
324
+ result.bytes += candidate.bytes;
325
+ if (!options.apply) continue;
326
+ for (const file of candidate.paths) {
327
+ try {
328
+ await fs.unlink(file);
329
+ result.deleted += 1;
330
+ } catch (error) {
331
+ if (codeOf(error) === "ENOENT") continue;
332
+ result.errors.push(`${file}: ${errorMessage(error)}`);
333
+ }
334
+ }
335
+ }
336
+ return result;
337
+ }
338
+
339
+ async function listActiveSessions(sessionsRoot: string): Promise<SessionInfo[]> {
340
+ let entries: Array<{ name: string; isDirectory(): boolean }>;
341
+ try {
342
+ entries = await fs.readdir(sessionsRoot, { withFileTypes: true });
343
+ } catch (error) {
344
+ if (codeOf(error) === "ENOENT") return [];
345
+ throw error;
346
+ }
347
+
348
+ const storage = new FileSessionStorage();
349
+ const sessions: SessionInfo[] = [];
350
+ for (const entry of entries) {
351
+ if (!entry.isDirectory()) continue;
352
+ sessions.push(...(await listSessionsReadOnly(path.join(sessionsRoot, entry.name), storage)));
353
+ }
354
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
355
+ return sessions;
356
+ }
357
+
358
+ async function listNestedSessionsReadOnly(artifactsRoot: string): Promise<SessionInfo[]> {
359
+ const files = await collectJsonlFiles(artifactsRoot);
360
+ const dirs = [...new Set(files.map(file => path.dirname(file)))].sort();
361
+ const storage = new FileSessionStorage();
362
+ const sessions: SessionInfo[] = [];
363
+ for (const dir of dirs) sessions.push(...(await listSessionsReadOnly(dir, storage)));
364
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
365
+ return sessions;
366
+ }
367
+
368
+ async function hasLiveNestedSessions(session: SessionInfo, archiveBeforeMs: number): Promise<boolean> {
369
+ for (const nested of await listNestedSessionsReadOnly(sessionArtifactsPath(session.path))) {
370
+ if (nested.status && ACTIVE_STATUSES.has(nested.status)) return true;
371
+ if (nested.modified.getTime() > archiveBeforeMs) return true;
372
+ }
373
+ return false;
374
+ }
375
+
376
+ function archiveDestination(
377
+ archiveRoot: string,
378
+ sessionsRoot: string,
379
+ session: SessionInfo,
380
+ ): Omit<ArchiveCandidate, "session"> | null {
381
+ const sessionPath = session.path;
382
+ const relativePath = path.relative(sessionsRoot, sessionPath);
383
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return null;
384
+ if (!relativePath.endsWith(SESSION_SUFFIX)) return null;
385
+ return {
386
+ relativePath,
387
+ destinationPath: path.join(archiveRoot, `${relativePath}.gz`),
388
+ };
389
+ }
390
+
391
+ function sessionCwdKey(sessionsRoot: string, session: SessionInfo): string {
392
+ const relativePath = path.relative(sessionsRoot, session.path);
393
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return session.cwd || ".";
394
+ const dirname = path.dirname(relativePath);
395
+ return dirname === "." ? session.cwd || "." : dirname;
396
+ }
397
+
398
+ async function movePath(source: string, destination: string): Promise<void> {
399
+ await fs.mkdir(path.dirname(destination), { recursive: true });
400
+ try {
401
+ await fs.rename(source, destination);
402
+ return;
403
+ } catch (error) {
404
+ if (codeOf(error) !== "EXDEV") throw error;
405
+ }
406
+ const stat = await fs.stat(source);
407
+ if (stat.isDirectory()) {
408
+ await fs.cp(source, destination, { recursive: true });
409
+ await fs.rm(source, { recursive: true, force: true });
410
+ return;
411
+ }
412
+ await fs.copyFile(source, destination);
413
+ await fs.unlink(source);
414
+ }
415
+
416
+ function sessionArtifactsPath(sessionPath: string): string {
417
+ if (sessionPath.endsWith(COMPRESSED_SESSION_SUFFIX)) {
418
+ return sessionPath.slice(0, -COMPRESSED_SESSION_SUFFIX.length);
419
+ }
420
+ return sessionPath.slice(0, -SESSION_SUFFIX.length);
421
+ }
422
+
423
+ function sessionIdFromSessionPath(sessionPath: string): string | undefined {
424
+ const basename = path.basename(sessionPath);
425
+ if (basename.endsWith(COMPRESSED_SESSION_SUFFIX)) {
426
+ const id = basename.slice(0, -COMPRESSED_SESSION_SUFFIX.length);
427
+ return id || undefined;
428
+ }
429
+ if (basename.endsWith(SESSION_SUFFIX)) {
430
+ const id = basename.slice(0, -SESSION_SUFFIX.length);
431
+ return id || undefined;
432
+ }
433
+ return undefined;
434
+ }
435
+
436
+ function sessionIdFromSessionText(text: string): string | undefined {
437
+ const line = text
438
+ .split(/\r?\n/)
439
+ .find(candidate => candidate.trim().length > 0)
440
+ ?.trim();
441
+ if (!line) return undefined;
442
+ try {
443
+ const record = JSON.parse(line) as { type?: unknown; id?: unknown };
444
+ return record.type === "session" && typeof record.id === "string" && record.id.length > 0 ? record.id : undefined;
445
+ } catch {
446
+ return undefined;
447
+ }
448
+ }
449
+
450
+ async function archivedSessionIdFromFile(file: string): Promise<string | undefined> {
451
+ return sessionIdFromSessionText(await readTextIfPresent(file)) ?? sessionIdFromSessionPath(file);
452
+ }
453
+
454
+ async function gzipSessionFile(source: string, destination: string): Promise<void> {
455
+ await fs.mkdir(path.dirname(destination), { recursive: true });
456
+ const tempPath = `${destination}.${process.pid}.${Date.now()}.tmp`;
457
+ let renamed = false;
458
+ try {
459
+ const compressed = gzipSync(await Bun.file(source).bytes(), { level: 9 });
460
+ await Bun.write(tempPath, compressed);
461
+ await fs.rename(tempPath, destination);
462
+ renamed = true;
463
+ await fs.unlink(source);
464
+ } catch (error) {
465
+ await fs.rm(tempPath, { force: true });
466
+ if (renamed) await fs.rm(destination, { force: true });
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ async function restoreGzipSessionFile(source: string, destination: string): Promise<void> {
472
+ await fs.mkdir(path.dirname(destination), { recursive: true });
473
+ const decompressed = gunzipSync(await Bun.file(source).bytes());
474
+ await Bun.write(destination, decompressed);
475
+ await fs.unlink(source);
476
+ }
477
+
478
+ async function moveSessionWithArtifacts(candidate: ArchiveCandidate): Promise<void> {
479
+ const sourceSession = candidate.session.path;
480
+ const destSession = candidate.destinationPath;
481
+ const legacyDestSession = destSession.endsWith(".gz") ? destSession.slice(0, -".gz".length) : `${destSession}.gz`;
482
+ const sourceArtifacts = sessionArtifactsPath(sourceSession);
483
+ const destArtifacts = sessionArtifactsPath(destSession);
484
+ if (await pathExists(destSession)) throw new Error(`archive destination exists: ${destSession}`);
485
+ if (await pathExists(legacyDestSession)) throw new Error(`archive destination exists: ${legacyDestSession}`);
486
+ if ((await pathExists(sourceArtifacts)) && (await pathExists(destArtifacts))) {
487
+ throw new Error(`archive artifacts destination exists: ${destArtifacts}`);
488
+ }
489
+
490
+ const moved: Array<{ source: string; destination: string; compressed?: boolean }> = [];
491
+ try {
492
+ await gzipSessionFile(sourceSession, destSession);
493
+ moved.push({ source: sourceSession, destination: destSession, compressed: true });
494
+ if (await pathExists(sourceArtifacts)) {
495
+ await movePath(sourceArtifacts, destArtifacts);
496
+ moved.push({ source: sourceArtifacts, destination: destArtifacts });
497
+ }
498
+ } catch (error) {
499
+ for (const move of moved.reverse()) {
500
+ try {
501
+ if (move.compressed) {
502
+ await restoreGzipSessionFile(move.destination, move.source);
503
+ } else {
504
+ await movePath(move.destination, move.source);
505
+ }
506
+ } catch {
507
+ // Preserve the original failure; rollback failure is reported by the next scan.
508
+ }
509
+ }
510
+ throw error;
511
+ }
512
+ }
513
+
514
+ function sqliteNumber(value: number | bigint | null | undefined): number {
515
+ if (typeof value === "bigint") return Number(value);
516
+ if (typeof value === "number") return value;
517
+ return 0;
518
+ }
519
+
520
+ function tableExists(db: Database, table: string): boolean {
521
+ const row = db
522
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type IN ('table','view') AND name = ?")
523
+ .get(table) as { present?: number } | null;
524
+ return row?.present === 1;
525
+ }
526
+
527
+ function historyHasSessionId(db: Database): boolean {
528
+ const rows = db.prepare("PRAGMA table_info(history)").all() as Array<{ name?: string | null }>;
529
+ return rows.some(row => row.name === "session_id");
530
+ }
531
+
532
+ function deleteHistoryRowsForSessions(dbPath: string, sessionIds: string[]): { deleted: number; ftsRebuilt: boolean } {
533
+ if (sessionIds.length === 0) return { deleted: 0, ftsRebuilt: false };
534
+ const db = new Database(dbPath);
535
+ try {
536
+ db.run("PRAGMA busy_timeout = 5000");
537
+ if (!tableExists(db, "history")) return { deleted: 0, ftsRebuilt: false };
538
+ if (!historyHasSessionId(db)) return { deleted: 0, ftsRebuilt: false };
539
+ const hasFts = tableExists(db, "history_fts");
540
+ const deleteStmt = db.prepare("DELETE FROM history WHERE session_id = ?");
541
+ let deleted = 0;
542
+ const tx = db.transaction((ids: string[]) => {
543
+ for (const id of ids) {
544
+ const result = deleteStmt.run(id) as SqliteRunResult;
545
+ deleted += sqliteNumber(result.changes);
546
+ }
547
+ if (deleted > 0 && hasFts) db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')");
548
+ });
549
+ tx(sessionIds);
550
+ return { deleted, ftsRebuilt: deleted > 0 && hasFts };
551
+ } finally {
552
+ db.close();
553
+ }
554
+ }
555
+
556
+ async function collectArchivedSessionIds(archiveRoot: string): Promise<string[]> {
557
+ const ids = new Set<string>();
558
+ for (const file of await collectCompressedJsonlFiles(archiveRoot)) {
559
+ const id = await archivedSessionIdFromFile(file);
560
+ if (id) ids.add(id);
561
+ }
562
+ return [...ids].sort();
563
+ }
564
+
565
+ async function cleanupHistoryRowsForArchivedSessions(
566
+ options: ResolvedGcOptions,
567
+ archiveRoot: string,
568
+ archivedSessionIds: string[],
569
+ result: ArchiveGcResult,
570
+ ): Promise<void> {
571
+ const dbPath = getHistoryDbPath(options.agentDir);
572
+ if (!(await pathExists(dbPath))) return;
573
+
574
+ const cleanupIds = new Set(archivedSessionIds);
575
+ try {
576
+ for (const id of await collectArchivedSessionIds(archiveRoot)) cleanupIds.add(id);
577
+ } catch (error) {
578
+ result.errors.push(`history cleanup scan: ${errorMessage(error)}`);
579
+ }
580
+
581
+ try {
582
+ const cleanup = deleteHistoryRowsForSessions(dbPath, [...cleanupIds]);
583
+ result.historyRowsDeleted = cleanup.deleted;
584
+ result.ftsRebuilt = cleanup.ftsRebuilt;
585
+ } catch (error) {
586
+ result.errors.push(`history cleanup: ${errorMessage(error)}`);
587
+ }
588
+ }
589
+
590
+ async function runArchiveGc(options: ResolvedGcOptions, archiveRoot: string): Promise<ArchiveGcResult> {
591
+ const sessionsRoot = getSessionsDir(options.agentDir);
592
+ const sessions = await listActiveSessions(sessionsRoot);
593
+ const cutoffMs = Date.now() - options.coldArchiveAfterDays * DAY_MS;
594
+ const result: ArchiveGcResult = {
595
+ scanned: sessions.length,
596
+ skippedActive: 0,
597
+ keptNewestGlobal: 0,
598
+ keptNewestPerCwd: 0,
599
+ wouldArchive: 0,
600
+ archived: 0,
601
+ historyRowsDeleted: 0,
602
+ ftsRebuilt: false,
603
+ errors: [],
604
+ };
605
+ const candidates: ArchiveCandidate[] = [];
606
+ let inactiveSeen = 0;
607
+ const inactiveSeenByCwd = new Map<string, number>();
608
+ const archiveBeforeMs = Date.now() - GC_WRITE_GRACE_MS;
609
+
610
+ for (const session of sessions) {
611
+ if (session.status && ACTIVE_STATUSES.has(session.status)) {
612
+ result.skippedActive += 1;
613
+ continue;
614
+ }
615
+ if (session.modified.getTime() > archiveBeforeMs) {
616
+ result.skippedActive += 1;
617
+ continue;
618
+ }
619
+ if (await hasLiveNestedSessions(session, archiveBeforeMs)) {
620
+ result.skippedActive += 1;
621
+ continue;
622
+ }
623
+ const cwdKey = sessionCwdKey(sessionsRoot, session);
624
+ const cwdSeen = inactiveSeenByCwd.get(cwdKey) ?? 0;
625
+ const keepGlobal = inactiveSeen < options.retainNewestGlobal;
626
+ const keepPerCwd = cwdSeen < options.retainNewestPerCwd;
627
+ inactiveSeen += 1;
628
+ inactiveSeenByCwd.set(cwdKey, cwdSeen + 1);
629
+ if (keepGlobal) {
630
+ result.keptNewestGlobal += 1;
631
+ continue;
632
+ }
633
+ if (keepPerCwd) {
634
+ result.keptNewestPerCwd += 1;
635
+ continue;
636
+ }
637
+ if (options.coldArchiveAfterDays > 0 && session.modified.getTime() > cutoffMs) continue;
638
+ const destination = archiveDestination(archiveRoot, sessionsRoot, session);
639
+ if (!destination) continue;
640
+ candidates.push({ ...destination, session });
641
+ }
642
+
643
+ result.wouldArchive = candidates.length;
644
+ if (!options.apply) return result;
645
+
646
+ const archivedSessionIds: string[] = [];
647
+ for (const candidate of candidates) {
648
+ try {
649
+ await moveSessionWithArtifacts(candidate);
650
+ result.archived += 1;
651
+ archivedSessionIds.push(candidate.session.id);
652
+ } catch (error) {
653
+ result.errors.push(`${candidate.session.path}: ${errorMessage(error)}`);
654
+ }
655
+ }
656
+
657
+ await cleanupHistoryRowsForArchivedSessions(options, archiveRoot, archivedSessionIds, result);
658
+ return result;
659
+ }
660
+
661
+ async function checkpointWal(dbPath: string, apply: boolean): Promise<WalCheckpointResult> {
662
+ const walPath = `${dbPath}-wal`;
663
+ let walBytes = 0;
664
+ try {
665
+ walBytes = (await fs.stat(walPath)).size;
666
+ } catch (error) {
667
+ if (codeOf(error) !== "ENOENT") throw error;
668
+ }
669
+ const result: WalCheckpointResult = {
670
+ dbPath,
671
+ walBytes,
672
+ wouldCheckpoint: walBytes > 0,
673
+ checkpointed: false,
674
+ busy: 0,
675
+ log: 0,
676
+ checkpointedFrames: 0,
677
+ };
678
+ if (!apply || !(await pathExists(dbPath))) return result;
679
+
680
+ const db = new Database(dbPath);
681
+ let checkpointAttempted = false;
682
+ try {
683
+ db.run("PRAGMA busy_timeout = 5000");
684
+ const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get() as WalCheckpointRow | null;
685
+ checkpointAttempted = true;
686
+ result.busy = sqliteNumber(row?.busy);
687
+ result.log = sqliteNumber(row?.log);
688
+ result.checkpointedFrames = sqliteNumber(row?.checkpointed);
689
+ } finally {
690
+ db.close();
691
+ }
692
+ try {
693
+ result.walBytes = (await fs.stat(walPath)).size;
694
+ } catch (error) {
695
+ if (codeOf(error) !== "ENOENT") throw error;
696
+ result.walBytes = 0;
697
+ }
698
+ if (checkpointAttempted && (result.busy > 0 || result.walBytes > 0)) {
699
+ throw new Error(`WAL checkpoint failed for ${dbPath}: busy=${result.busy}, walBytes=${result.walBytes}`);
700
+ }
701
+ result.checkpointed = checkpointAttempted;
702
+ return result;
703
+ }
704
+
705
+ async function runWalGc(options: ResolvedGcOptions): Promise<WalGcResult> {
706
+ const databases = await Promise.all(
707
+ [getHistoryDbPath(options.agentDir), getModelDbPath(options.agentDir)].map(dbPath =>
708
+ checkpointWal(dbPath, options.apply),
709
+ ),
710
+ );
711
+ return {
712
+ databases,
713
+ walBytes: databases.reduce((total, db) => total + db.walBytes, 0),
714
+ wouldCheckpoint: databases.some(db => db.wouldCheckpoint),
715
+ checkpointed: databases.some(db => db.checkpointed),
716
+ };
717
+ }
718
+
719
+ function gcLockPid(lockText: string): number | undefined {
720
+ const pid = Number.parseInt(lockText.split(/\r?\n/, 1)[0] ?? "", 10);
721
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
722
+ }
723
+
724
+ function processExists(pid: number): boolean {
725
+ try {
726
+ process.kill(pid, 0);
727
+ return true;
728
+ } catch (error) {
729
+ const code = codeOf(error);
730
+ if (code === "ESRCH" || code === "EINVAL") return false;
731
+ return true;
732
+ }
733
+ }
734
+
735
+ function gcLockStatSnapshot(stat: {
736
+ dev: number;
737
+ ino: number;
738
+ size: number;
739
+ mtimeMs: number;
740
+ ctimeMs: number;
741
+ }): Omit<GcLockSnapshot, "text"> {
742
+ return {
743
+ dev: stat.dev,
744
+ ino: stat.ino,
745
+ size: stat.size,
746
+ mtimeMs: stat.mtimeMs,
747
+ ctimeMs: stat.ctimeMs,
748
+ };
749
+ }
750
+
751
+ function sameGcLockStat(left: Omit<GcLockSnapshot, "text">, right: Omit<GcLockSnapshot, "text">): boolean {
752
+ return (
753
+ left.dev === right.dev &&
754
+ left.ino === right.ino &&
755
+ left.size === right.size &&
756
+ left.mtimeMs === right.mtimeMs &&
757
+ left.ctimeMs === right.ctimeMs
758
+ );
759
+ }
760
+
761
+ async function readGcLockSnapshot(lockPath: string): Promise<GcLockSnapshot | null> {
762
+ const stat = await statIfPresent(lockPath);
763
+ if (!stat) return null;
764
+
765
+ let lockText = "";
766
+ try {
767
+ lockText = await Bun.file(lockPath).text();
768
+ } catch (error) {
769
+ if (codeOf(error) === "ENOENT") return null;
770
+ throw error;
771
+ }
772
+
773
+ const afterStat = await statIfPresent(lockPath);
774
+ if (!afterStat) return null;
775
+ const before = gcLockStatSnapshot(stat);
776
+ const after = gcLockStatSnapshot(afterStat);
777
+ if (!sameGcLockStat(before, after)) return null;
778
+ return { ...after, text: lockText };
779
+ }
780
+
781
+ async function gcLockSnapshotStillCurrent(lockPath: string, snapshot: GcLockSnapshot): Promise<boolean> {
782
+ const stat = await statIfPresent(lockPath);
783
+ return stat ? sameGcLockStat(snapshot, gcLockStatSnapshot(stat)) : false;
784
+ }
785
+
786
+ function shouldBreakGcLock(snapshot: GcLockSnapshot): boolean {
787
+ const pid = gcLockPid(snapshot.text);
788
+ if (pid) return !processExists(pid);
789
+
790
+ const createdAtMs = Date.parse(snapshot.text.split(/\r?\n/, 2)[1] ?? "");
791
+ const ageFromMs = Number.isFinite(createdAtMs) ? createdAtMs : snapshot.mtimeMs;
792
+ return Date.now() - ageFromMs > GC_WRITE_GRACE_MS;
793
+ }
794
+
795
+ async function removeStaleGcLock(lockPath: string): Promise<boolean> {
796
+ const snapshot = await readGcLockSnapshot(lockPath);
797
+ if (!snapshot) return false;
798
+ if (!shouldBreakGcLock(snapshot)) return false;
799
+ if (!(await gcLockSnapshotStillCurrent(lockPath, snapshot))) return false;
800
+ try {
801
+ await fs.unlink(lockPath);
802
+ return true;
803
+ } catch (error) {
804
+ if (codeOf(error) === "ENOENT") return false;
805
+ throw error;
806
+ }
807
+ }
808
+
809
+ async function openNewGcLock(lockPath: string): Promise<fs.FileHandle | null> {
810
+ try {
811
+ return await fs.open(lockPath, "wx");
812
+ } catch (error) {
813
+ if (codeOf(error) === "EEXIST") return null;
814
+ throw error;
815
+ }
816
+ }
817
+
818
+ async function releaseGcLockFile(lockPath: string, handle: fs.FileHandle): Promise<void> {
819
+ try {
820
+ await handle.close();
821
+ } catch {
822
+ // Best effort: stale sidecar locks are recoverable by PID/timestamp.
823
+ }
824
+ try {
825
+ await fs.unlink(lockPath);
826
+ } catch (error) {
827
+ if (codeOf(error) === "ENOENT") return;
828
+ }
829
+ }
830
+
831
+ async function openGcBreakerLock(lockPath: string): Promise<{ path: string; handle: fs.FileHandle }> {
832
+ const breakerPath = `${lockPath}${GC_LOCK_BREAKER_SUFFIX}`;
833
+ for (let attempt = 0; attempt < 2; attempt += 1) {
834
+ const handle = await openNewGcLock(breakerPath);
835
+ if (handle) {
836
+ try {
837
+ await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
838
+ return { path: breakerPath, handle };
839
+ } catch (error) {
840
+ await releaseGcLockFile(breakerPath, handle);
841
+ throw error;
842
+ }
843
+ }
844
+ if (!(await removeStaleGcLock(breakerPath))) throw new Error(`GC already running: ${lockPath}`);
845
+ }
846
+ throw new Error(`GC already running: ${lockPath}`);
847
+ }
848
+
849
+ async function openGcLock(lockPath: string): Promise<fs.FileHandle> {
850
+ const direct = await openNewGcLock(lockPath);
851
+ if (direct) return direct;
852
+
853
+ const breaker = await openGcBreakerLock(lockPath);
854
+ try {
855
+ const raced = await openNewGcLock(lockPath);
856
+ if (raced) return raced;
857
+ if (!(await removeStaleGcLock(lockPath))) throw new Error(`GC already running: ${lockPath}`);
858
+ const takeover = await openNewGcLock(lockPath);
859
+ if (takeover) return takeover;
860
+ throw new Error(`GC already running: ${lockPath}`);
861
+ } finally {
862
+ await releaseGcLockFile(breaker.path, breaker.handle);
863
+ }
864
+ }
865
+
866
+ async function withGcLock<T>(agentDir: string, fn: (lockPath: string) => Promise<T>): Promise<T> {
867
+ const lockPath = path.join(agentDir, "gc.lock");
868
+ await fs.mkdir(agentDir, { recursive: true });
869
+ const handle = await openGcLock(lockPath);
870
+ let result: T | undefined;
871
+ let runError: unknown;
872
+ try {
873
+ await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
874
+ result = await fn(lockPath);
875
+ } catch (error) {
876
+ runError = error;
877
+ }
878
+ let closeError: unknown;
879
+ try {
880
+ await handle.close();
881
+ } catch (error) {
882
+ closeError = error;
883
+ }
884
+ let unlinkError: unknown;
885
+ try {
886
+ await fs.unlink(lockPath);
887
+ } catch (error) {
888
+ if (codeOf(error) !== "ENOENT") unlinkError = error;
889
+ }
890
+ if (runError) throw runError;
891
+ if (closeError) throw closeError;
892
+ if (unlinkError) throw unlinkError;
893
+ return result as T;
894
+ }
895
+
896
+ function formatBytes(bytes: number): string {
897
+ if (bytes < 1024) return `${bytes} B`;
898
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
899
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
900
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
901
+ }
902
+
903
+ function renderText(result: GcResult): string {
904
+ const lines = [`GC ${result.apply ? "applied" : "dry-run"} (${result.agentDir})`];
905
+ if (result.blobs) {
906
+ lines.push(
907
+ `blobs: ${result.blobs.deleted}/${result.blobs.wouldDelete} files, ${formatBytes(result.blobs.bytes)}, ${result.blobs.referenced} refs`,
908
+ );
909
+ if (result.blobs.errors.length > 0) lines.push(`blob errors: ${result.blobs.errors.length}`);
910
+ }
911
+ if (result.archive) {
912
+ lines.push(
913
+ `sessions: ${result.archive.archived}/${result.archive.wouldArchive} archived, ${result.archive.historyRowsDeleted} history rows removed`,
914
+ );
915
+ if (result.archive.skippedActive > 0) lines.push(`sessions skipped active: ${result.archive.skippedActive}`);
916
+ if (result.archive.errors.length > 0) lines.push(`session errors: ${result.archive.errors.length}`);
917
+ }
918
+ if (result.wal) {
919
+ const state = result.wal.checkpointed ? "checkpointed" : "checkpoint dry-run";
920
+ lines.push(`wal: ${state}, ${formatBytes(result.wal.walBytes)} across ${result.wal.databases.length} dbs`);
921
+ }
922
+ return `${lines.join("\n")}\n`;
923
+ }
924
+
925
+ export async function runGcCommand(args: GcCommandArgs): Promise<GcResult> {
926
+ const options = await resolveOptions(args.flags);
927
+ const archiveRoot = getArchivedSessionsDir(options.agentDir);
928
+ const result = await withGcLock(options.agentDir, async lockPath => {
929
+ const next: GcResult = { agentDir: options.agentDir, apply: options.apply, lockPath };
930
+ if (options.runBlobs) next.blobs = await runBlobGc(options, archiveRoot);
931
+ if (options.runArchive) next.archive = await runArchiveGc(options, archiveRoot);
932
+ if (options.runWal) next.wal = await runWalGc(options);
933
+ return next;
934
+ });
935
+
936
+ const output = options.json ? `${JSON.stringify(result, null, 2)}\n` : renderText(result);
937
+ process.stdout.write(output);
938
+ return result;
939
+ }