codex-cleaner 0.0.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.
@@ -0,0 +1,2265 @@
1
+ import { execFile, spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import Database from "better-sqlite3";
7
+ const execFileAsync = promisify(execFile);
8
+ const THREAD_COLUMNS_TO_CAP = ["title", "preview", "first_user_message"];
9
+ const APP_SERVER_REQUEST_TIMEOUT_MS = 60_000;
10
+ const APP_SERVER_WINDOWS_TERMINATE_DELAY_MS = 1000;
11
+ const APP_SERVER_SHUTDOWN_TIMEOUT_MS = 3000;
12
+ const BACKUP_FILE_SUFFIXES = [".manifest.bak", ".bak.sqlite", ".bak"];
13
+ const STATE_VACUUM_MIN_FREE_MIB = 1;
14
+ const TUI_LOG_COPY_CHUNK_BYTES = 8 * 1024 * 1024;
15
+ const WINDOWS_BATCH_EXTENSIONS = new Set([".bat", ".cmd"]);
16
+ export async function requireStoppedOrReadonlyAllowed(args) {
17
+ const blockers = await findBlockingProcesses();
18
+ if (!blockers.length)
19
+ return;
20
+ if (!args.mutating && args.allowRunningReadonly)
21
+ return;
22
+ const details = blockers
23
+ .map((process) => ` - pid=${process.pid} name=${process.name} command=${process.commandLine.slice(0, 240)}`)
24
+ .join("\n");
25
+ throw new Error(`Refusing to run while Codex-related processes are active.\nStop Codex/App/TUI/node_repl first.\n${details}`);
26
+ }
27
+ export async function findBlockingProcesses() {
28
+ return process.platform === "win32" ? findWindowsBlockingProcesses() : findPosixBlockingProcesses();
29
+ }
30
+ async function findWindowsBlockingProcesses() {
31
+ const script = String.raw `
32
+ $rows = Get-CimInstance Win32_Process |
33
+ Where-Object {
34
+ $_.Name -ieq 'codex.exe' -or
35
+ $_.Name -ieq 'node_repl.exe' -or
36
+ ($_.Name -ieq 'node.exe' -and ($_.CommandLine -match '@openai[\\/]codex|app-server'))
37
+ } |
38
+ Select-Object ProcessId,Name,CommandLine
39
+ $rows | ConvertTo-Json -Compress
40
+ `;
41
+ const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
42
+ windowsHide: true,
43
+ });
44
+ const text = stdout.trim();
45
+ if (!text)
46
+ return [];
47
+ const parsed = JSON.parse(text);
48
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
49
+ return rows.map((row) => {
50
+ const value = row;
51
+ return {
52
+ pid: Number(value.ProcessId ?? 0),
53
+ name: String(value.Name ?? ""),
54
+ commandLine: String(value.CommandLine ?? ""),
55
+ };
56
+ });
57
+ }
58
+ async function findPosixBlockingProcesses() {
59
+ const { stdout } = await execFileAsync("ps", ["-axo", "pid=,comm=,args="]);
60
+ return stdout
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trim())
63
+ .filter(Boolean)
64
+ .map((line) => {
65
+ const match = line.match(/^(\d+)\s+(\S+)\s+(.*)$/);
66
+ if (!match)
67
+ return null;
68
+ return { pid: Number(match[1]), name: match[2] ?? "", commandLine: match[3] ?? "" };
69
+ })
70
+ .filter((row) => Boolean(row))
71
+ .filter((row) => {
72
+ const lowerName = row.name.toLowerCase();
73
+ const lowerCommand = row.commandLine.toLowerCase();
74
+ return (lowerName === "codex" ||
75
+ lowerName === "node_repl" ||
76
+ (lowerName === "node" && (lowerCommand.includes("@openai/codex") || lowerCommand.includes("app-server"))));
77
+ });
78
+ }
79
+ async function archiveThreadsViaCodexAppServer(threadIds, codexCommand, codexHome) {
80
+ const spawnCommand = await resolveCodexSpawnCommand(codexCommand, ["app-server", "--listen", "stdio://"]);
81
+ const child = spawn(spawnCommand.command, spawnCommand.args, {
82
+ env: { ...process.env, CODEX_HOME: codexHome },
83
+ stdio: ["pipe", "pipe", "pipe"],
84
+ windowsHide: true,
85
+ });
86
+ child.stdout.setEncoding("utf8");
87
+ child.stderr.setEncoding("utf8");
88
+ let nextId = 1;
89
+ let stdoutBuffer = "";
90
+ let stderrBuffer = "";
91
+ const pending = new Map();
92
+ child.stdout.on("data", (chunk) => {
93
+ stdoutBuffer += chunk;
94
+ let newline = stdoutBuffer.indexOf("\n");
95
+ while (newline >= 0) {
96
+ const line = stdoutBuffer.slice(0, newline).trim();
97
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
98
+ if (line)
99
+ handleAppServerLine(line, pending);
100
+ newline = stdoutBuffer.indexOf("\n");
101
+ }
102
+ });
103
+ child.stderr.on("data", (chunk) => {
104
+ stderrBuffer = `${stderrBuffer}${chunk}`.slice(-4000);
105
+ });
106
+ child.on("error", (error) => {
107
+ for (const pendingRequest of pending.values())
108
+ pendingRequest.reject(error);
109
+ pending.clear();
110
+ });
111
+ child.on("exit", (code) => {
112
+ if (pending.size && code !== 0) {
113
+ const error = new Error(`codex app-server exited with code ${String(code)}: ${stderrBuffer.trim()}`);
114
+ for (const pendingRequest of pending.values())
115
+ pendingRequest.reject(error);
116
+ pending.clear();
117
+ }
118
+ });
119
+ const request = (method, params) => {
120
+ const id = nextId;
121
+ nextId += 1;
122
+ const body = JSON.stringify({ id, method, params });
123
+ return new Promise((resolve, reject) => {
124
+ const timeout = setTimeout(() => {
125
+ pending.delete(id);
126
+ reject(new Error(`codex app-server request timed out: ${method}`));
127
+ }, APP_SERVER_REQUEST_TIMEOUT_MS);
128
+ pending.set(id, {
129
+ reject: (error) => {
130
+ clearTimeout(timeout);
131
+ reject(error);
132
+ },
133
+ resolve: (value) => {
134
+ clearTimeout(timeout);
135
+ resolve(value);
136
+ },
137
+ });
138
+ child.stdin.write(`${body}\n`, (error) => {
139
+ if (!error)
140
+ return;
141
+ pending.delete(id);
142
+ clearTimeout(timeout);
143
+ reject(error);
144
+ });
145
+ });
146
+ };
147
+ const notify = (method, params) => {
148
+ child.stdin.write(`${JSON.stringify({ method, params })}\n`);
149
+ };
150
+ const errors = [];
151
+ let succeeded = 0;
152
+ try {
153
+ await request("initialize", {
154
+ clientInfo: {
155
+ name: "codex_cleaner",
156
+ title: "Codex Cleaner",
157
+ version: "0.1.0",
158
+ },
159
+ });
160
+ notify("initialized", {});
161
+ for (const threadId of threadIds) {
162
+ try {
163
+ await request("thread/archive", { threadId });
164
+ succeeded += 1;
165
+ }
166
+ catch (error) {
167
+ errors.push({ threadId, error: error instanceof Error ? error.message : String(error) });
168
+ }
169
+ }
170
+ }
171
+ finally {
172
+ child.stdin.end();
173
+ await stopAppServer(child);
174
+ }
175
+ return {
176
+ requested: threadIds.length,
177
+ succeeded,
178
+ failed: errors.length,
179
+ errors: errors.slice(0, 20),
180
+ codexHome,
181
+ stderrTail: stderrBuffer.trim(),
182
+ };
183
+ }
184
+ export async function resolveCodexSpawnCommand(codexCommand, args, platform = process.platform, windowsMatches) {
185
+ if (platform !== "win32")
186
+ return { args, command: codexCommand };
187
+ const matches = windowsMatches ?? (await resolveWindowsCommandMatches(codexCommand));
188
+ let firstBatchMatch = null;
189
+ for (const match of matches) {
190
+ const npmScript = codexNpmScriptPath(match);
191
+ if (npmScript) {
192
+ return { args: [npmScript, ...args], command: process.execPath };
193
+ }
194
+ if (isWindowsExecutableMatch(match))
195
+ return { args, command: match };
196
+ if (WINDOWS_BATCH_EXTENSIONS.has(path.extname(match).toLowerCase())) {
197
+ firstBatchMatch ??= match;
198
+ }
199
+ }
200
+ if (firstBatchMatch) {
201
+ throw new Error(`Refusing to wrap a Windows batch Codex command because child app-server cleanup would not own the process tree: ${firstBatchMatch}`);
202
+ }
203
+ return { args, command: matches[0] ?? codexCommand };
204
+ }
205
+ async function resolveWindowsCommandMatches(command) {
206
+ if (hasPathSeparator(command))
207
+ return [path.resolve(command)];
208
+ const matches = await findWindowsCommandMatches(command);
209
+ return matches.length ? matches : [command];
210
+ }
211
+ async function findWindowsCommandMatches(command) {
212
+ try {
213
+ const { stdout } = await execFileAsync("where.exe", [command], { windowsHide: true });
214
+ return stdout
215
+ .split(/\r?\n/)
216
+ .map((line) => line.trim())
217
+ .filter(Boolean);
218
+ }
219
+ catch {
220
+ return [];
221
+ }
222
+ }
223
+ function codexNpmScriptPath(command) {
224
+ const basename = path.basename(command).toLowerCase();
225
+ if (!["codex", "codex.bat", "codex.cmd"].includes(basename))
226
+ return null;
227
+ const commandDir = path.dirname(command);
228
+ const candidates = [
229
+ path.join(commandDir, "node_modules", "@openai", "codex", "bin", "codex.js"),
230
+ path.join(commandDir, "..", "@openai", "codex", "bin", "codex.js"),
231
+ ];
232
+ return candidates.find((script) => fs.existsSync(script)) ?? null;
233
+ }
234
+ function hasPathSeparator(value) {
235
+ return value.includes("/") || value.includes("\\");
236
+ }
237
+ function isWindowsExecutableMatch(command) {
238
+ return [".com", ".exe"].includes(path.extname(command).toLowerCase());
239
+ }
240
+ function handleAppServerLine(line, pending) {
241
+ let message;
242
+ try {
243
+ message = JSON.parse(line);
244
+ }
245
+ catch {
246
+ return;
247
+ }
248
+ const id = typeof message.id === "number" ? message.id : null;
249
+ if (id == null || !pending.has(id))
250
+ return;
251
+ const request = pending.get(id);
252
+ pending.delete(id);
253
+ if (!request)
254
+ return;
255
+ if (message.error) {
256
+ const errorObject = asRecord(message.error);
257
+ request.reject(new Error(String(errorObject.message ?? JSON.stringify(message.error))));
258
+ return;
259
+ }
260
+ request.resolve(asRecord(message.result));
261
+ }
262
+ async function stopAppServer(child) {
263
+ if (child.exitCode != null || child.signalCode != null)
264
+ return;
265
+ await new Promise((resolve) => {
266
+ let settled = false;
267
+ const timers = [];
268
+ const done = () => {
269
+ if (settled)
270
+ return;
271
+ settled = true;
272
+ for (const timer of timers)
273
+ clearTimeout(timer);
274
+ resolve();
275
+ };
276
+ const terminate = () => {
277
+ try {
278
+ child.kill("SIGTERM");
279
+ }
280
+ catch {
281
+ // Process already exited.
282
+ }
283
+ };
284
+ if (process.platform === "win32") {
285
+ // Windows kill() is forceful; give stdin EOF a brief chance to flush SQLite first.
286
+ try {
287
+ child.stdin.end();
288
+ }
289
+ catch {
290
+ // Process may already be closed.
291
+ }
292
+ timers.push(setTimeout(terminate, APP_SERVER_WINDOWS_TERMINATE_DELAY_MS));
293
+ }
294
+ else {
295
+ terminate();
296
+ }
297
+ timers.push(setTimeout(() => {
298
+ try {
299
+ child.kill("SIGKILL");
300
+ }
301
+ catch {
302
+ // Process already exited.
303
+ }
304
+ done();
305
+ }, APP_SERVER_SHUTDOWN_TIMEOUT_MS));
306
+ child.once("exit", done);
307
+ });
308
+ }
309
+ export function buildScanReport(options) {
310
+ const codexHome = resolveCodexHome(options);
311
+ const stateDb = path.join(codexHome, "state_5.sqlite");
312
+ const logsDb = path.join(codexHome, "logs_2.sqlite");
313
+ const goalsDb = path.join(codexHome, "goals_1.sqlite");
314
+ const globalState = loadGlobalState(codexHome);
315
+ const protection = loadThreadProtection(codexHome, globalState);
316
+ const protectedIds = allProtectedIds(protection);
317
+ const cutoffMs = recentCutoffMs(options.keepRecentDays);
318
+ const report = {
319
+ codexHome,
320
+ generatedAt: new Date().toISOString(),
321
+ policy: {
322
+ archiveOrphanRollouts: options.archiveOrphanRollouts,
323
+ compactRecentMetadata: options.compactRecentMetadata,
324
+ keepLogDays: options.keepLogDays,
325
+ keepRecentDays: options.keepRecentDays,
326
+ keepTuiLogMib: options.keepTuiLogMib,
327
+ maxLogBodyChars: options.maxLogBodyChars,
328
+ maxChars: options.maxChars,
329
+ recentCutoffMs: cutoffMs,
330
+ recentCutoffUtc: millisToIso(cutoffMs),
331
+ },
332
+ files: {
333
+ "state_5.sqlite": fileTripletSizes(stateDb),
334
+ "logs_2.sqlite": fileTripletSizes(logsDb),
335
+ "goals_1.sqlite": fileTripletSizes(goalsDb),
336
+ "codex-tui.log": fileSize(path.join(codexHome, "log", "codex-tui.log")),
337
+ },
338
+ protection: {
339
+ pinnedThreads: protection.pinnedIds.size,
340
+ heartbeatThreads: protection.heartbeatIds.size,
341
+ activeGoalThreads: protection.activeGoalIds.size,
342
+ totalUniqueProtectedThreads: protectedIds.size,
343
+ activeWorkspaceRoots: Array.isArray(globalState["active-workspace-roots"])
344
+ ? globalState["active-workspace-roots"]
345
+ : [],
346
+ },
347
+ };
348
+ const db = openReadonlyDb(stateDb);
349
+ try {
350
+ report.databaseSpace = {
351
+ "state_5.sqlite": collectSqliteSpaceStats(db),
352
+ };
353
+ report.threads = collectThreadStats(db);
354
+ report.compactMetadataCandidates = collectCompactCandidateStats(db, {
355
+ archivedOnly: false,
356
+ cutoffMs,
357
+ maxChars: options.maxChars,
358
+ protectRecent: !options.compactRecentMetadata,
359
+ protectedIds,
360
+ });
361
+ report.compactMetadataCandidatesArchivedOnly = collectCompactCandidateStats(db, {
362
+ archivedOnly: true,
363
+ cutoffMs,
364
+ maxChars: options.maxChars,
365
+ protectRecent: !options.compactRecentMetadata,
366
+ protectedIds,
367
+ });
368
+ if (options.archiveStale) {
369
+ report.staleArchiveCandidates = collectStaleArchiveCandidateStats(db, codexHome, {
370
+ cutoffMs,
371
+ protectedIds,
372
+ statRollouts: options.includeRollouts,
373
+ });
374
+ }
375
+ if (options.archiveOrphanRollouts) {
376
+ report.orphanRolloutArchiveCandidates = collectOrphanRolloutArchiveStats(db, codexHome, { cutoffMs });
377
+ }
378
+ report.recentSample = queryAll(db, `
379
+ SELECT id, archived, updated_at, updated_at_ms, source, agent_role,
380
+ substr(cwd, 1, 140) AS cwd,
381
+ length(title) AS title_chars,
382
+ length(preview) AS preview_chars,
383
+ length(first_user_message) AS first_user_message_chars
384
+ FROM threads
385
+ ORDER BY updated_at_ms DESC, updated_at DESC
386
+ LIMIT 10
387
+ `);
388
+ }
389
+ finally {
390
+ db.close();
391
+ }
392
+ if ((options.includeLogs || options.pruneLogs) && fs.existsSync(logsDb)) {
393
+ const logs = openReadonlyDb(logsDb);
394
+ try {
395
+ report.logs = collectLogStats(logs);
396
+ report.logCleanupCandidates = collectLogCleanupStats(logs, options);
397
+ const databaseSpace = asRecord(report.databaseSpace);
398
+ databaseSpace["logs_2.sqlite"] = collectSqliteSpaceStats(logs);
399
+ report.databaseSpace = databaseSpace;
400
+ }
401
+ finally {
402
+ logs.close();
403
+ }
404
+ }
405
+ if (options.pruneTuiLog) {
406
+ report.tuiLogCleanupCandidates = collectTuiLogCleanupStats(path.join(codexHome, "log", "codex-tui.log"), options.keepTuiLogMib);
407
+ }
408
+ if (options.includeRollouts) {
409
+ const rolloutDb = openReadonlyDb(stateDb);
410
+ try {
411
+ report.rollouts = collectRolloutLinkage(rolloutDb, codexHome);
412
+ }
413
+ finally {
414
+ rolloutDb.close();
415
+ }
416
+ }
417
+ return report;
418
+ }
419
+ export async function compactMetadata(options) {
420
+ const codexHome = resolveCodexHome(options);
421
+ const stateDb = path.join(codexHome, "state_5.sqlite");
422
+ const globalState = loadGlobalState(codexHome);
423
+ const protection = loadThreadProtection(codexHome, globalState);
424
+ const protectedIds = allProtectedIds(protection);
425
+ const cutoffMs = recentCutoffMs(options.keepRecentDays);
426
+ const db = openWritableDb(stateDb);
427
+ let changedRows = 0;
428
+ let backupPath = null;
429
+ try {
430
+ const before = collectCompactCandidateStats(db, {
431
+ archivedOnly: options.archivedOnly,
432
+ cutoffMs,
433
+ maxChars: options.maxChars,
434
+ protectRecent: !options.compactRecentMetadata,
435
+ protectedIds,
436
+ });
437
+ if (options.apply && Number(before.rows) > 0 && !options.confirmLossyMetadata) {
438
+ throw new Error("--apply requires --confirm-lossy-metadata");
439
+ }
440
+ if (options.apply && Number(before.rows) > 0) {
441
+ backupPath = await backupSqliteDatabase(stateDb, resolveBackupDir(options, codexHome));
442
+ const where = compactWhere({
443
+ archivedOnly: options.archivedOnly,
444
+ cutoffMs,
445
+ maxChars: options.maxChars,
446
+ protectRecent: !options.compactRecentMetadata,
447
+ protectedIds,
448
+ });
449
+ const assignments = THREAD_COLUMNS_TO_CAP.map((column) => `${column} = CASE WHEN length(${column}) > @maxChars THEN substr(${column}, 1, @maxChars) ELSE ${column} END`).join(", ");
450
+ const update = db.prepare(`UPDATE threads SET ${assignments} WHERE ${where.sql}`);
451
+ const tx = db.transaction(() => update.run(where.params));
452
+ changedRows = Number(tx().changes);
453
+ }
454
+ const after = collectCompactCandidateStats(db, {
455
+ archivedOnly: options.archivedOnly,
456
+ cutoffMs,
457
+ maxChars: options.maxChars,
458
+ protectRecent: !options.compactRecentMetadata,
459
+ protectedIds,
460
+ });
461
+ return {
462
+ action: "compact-metadata",
463
+ mode: options.apply ? "apply" : "dry-run",
464
+ codexHome,
465
+ generatedAt: new Date().toISOString(),
466
+ policy: {
467
+ archivedOnly: options.archivedOnly,
468
+ compactRecentMetadata: options.compactRecentMetadata,
469
+ keepRecentDays: options.keepRecentDays,
470
+ maxChars: options.maxChars,
471
+ protectedThreads: protectedIds.size,
472
+ recentCutoffMs: cutoffMs,
473
+ recentCutoffUtc: millisToIso(cutoffMs),
474
+ },
475
+ before,
476
+ after,
477
+ changedRows,
478
+ backupPath,
479
+ };
480
+ }
481
+ finally {
482
+ db.close();
483
+ }
484
+ }
485
+ export async function cleanCodex(options) {
486
+ if (options.apply && options.archiveStale && !options.confirmArchiveStale) {
487
+ throw new Error("--apply with stale archiving requires --confirm-archive-stale");
488
+ }
489
+ if (options.apply && options.archiveOrphanRollouts && !options.confirmArchiveOrphanRollouts) {
490
+ throw new Error("--apply with orphan rollout archiving requires --confirm-archive-orphan-rollouts");
491
+ }
492
+ if (options.apply && options.pruneLogs && !options.confirmPruneLogs) {
493
+ throw new Error("--apply with log cleanup requires --confirm-prune-logs");
494
+ }
495
+ if (options.apply && options.pruneTuiLog && !options.confirmPruneTuiLog) {
496
+ throw new Error("--apply with TUI log cleanup requires --confirm-prune-tui-log");
497
+ }
498
+ const scan = buildScanReport({ ...options, apply: false });
499
+ const compactCandidateRows = Number(asRecord(scan.compactMetadataCandidates).rows ?? 0);
500
+ if (options.apply && compactCandidateRows > 0 && !options.confirmLossyMetadata) {
501
+ throw new Error("--apply requires --confirm-lossy-metadata");
502
+ }
503
+ if (!options.apply) {
504
+ return {
505
+ action: "clean",
506
+ mode: "dry-run",
507
+ ...scan,
508
+ };
509
+ }
510
+ const archive = options.archiveStale ? await archiveStaleThreads(options) : null;
511
+ const orphanRollouts = options.archiveOrphanRollouts ? archiveOrphanRollouts(options) : null;
512
+ const compact = await compactMetadata(options);
513
+ const hasStateBackup = Boolean(asRecord(archive).backupPath || asRecord(compact).backupPath);
514
+ const stateSpace = asRecord(asRecord(scan.databaseSpace)["state_5.sqlite"]);
515
+ const shouldVacuumState = Boolean(asRecord(archive).backupPath) ||
516
+ Number(asRecord(compact).changedRows ?? 0) > 0 ||
517
+ Number(stateSpace.free_mib ?? 0) >= STATE_VACUUM_MIN_FREE_MIB;
518
+ const vacuum = shouldVacuumState ? await vacuumStateDatabase(options, !hasStateBackup) : null;
519
+ const logs = options.pruneLogs ? await cleanLogs(options) : null;
520
+ const tuiLog = options.pruneTuiLog ? await cleanTuiLog(options) : null;
521
+ const checkpoint = await checkpointWal(options, !hasStateBackup);
522
+ return {
523
+ action: "clean",
524
+ mode: "apply",
525
+ codexHome: scan.codexHome,
526
+ generatedAt: new Date().toISOString(),
527
+ policy: scan.policy,
528
+ scan,
529
+ archive,
530
+ orphanRollouts,
531
+ compact,
532
+ vacuum,
533
+ logs,
534
+ tuiLog,
535
+ checkpoint,
536
+ };
537
+ }
538
+ export async function archiveStaleThreads(options) {
539
+ if (options.apply && !options.confirmArchiveStale) {
540
+ throw new Error("--apply requires --confirm-archive-stale");
541
+ }
542
+ const codexHome = resolveCodexHome(options);
543
+ const stateDb = path.join(codexHome, "state_5.sqlite");
544
+ const globalState = loadGlobalState(codexHome);
545
+ const protection = loadThreadProtection(codexHome, globalState);
546
+ const protectedIds = allProtectedIds(protection);
547
+ const cutoffMs = recentCutoffMs(options.keepRecentDays);
548
+ const beforeDb = openReadonlyDb(stateDb);
549
+ let beforePlan;
550
+ try {
551
+ beforePlan = buildStaleArchivePlan(beforeDb, codexHome, { cutoffMs, protectedIds, statRollouts: false });
552
+ }
553
+ finally {
554
+ beforeDb.close();
555
+ }
556
+ let backupPath = null;
557
+ let appServerResult = null;
558
+ if (options.apply && beforePlan.archiveCallIds.length) {
559
+ backupPath = await backupSqliteDatabase(stateDb, resolveBackupDir(options, codexHome));
560
+ appServerResult = await archiveThreadsViaCodexAppServer(beforePlan.archiveCallIds, options.codexCommand ?? "codex", codexHome);
561
+ }
562
+ const afterDb = openReadonlyDb(stateDb);
563
+ let afterPlan;
564
+ try {
565
+ afterPlan = buildStaleArchivePlan(afterDb, codexHome, { cutoffMs, protectedIds, statRollouts: false });
566
+ }
567
+ finally {
568
+ afterDb.close();
569
+ }
570
+ return {
571
+ action: "archive-stale",
572
+ mode: options.apply ? "apply" : "dry-run",
573
+ codexHome,
574
+ generatedAt: new Date().toISOString(),
575
+ policy: {
576
+ keepRecentDays: options.keepRecentDays,
577
+ protectedThreads: protectedIds.size,
578
+ recentCutoffMs: cutoffMs,
579
+ recentCutoffUtc: millisToIso(cutoffMs),
580
+ },
581
+ before: beforePlan.stats,
582
+ after: afterPlan.stats,
583
+ requestedArchiveCalls: beforePlan.archiveCallIds.length,
584
+ appServerResult,
585
+ backupPath,
586
+ };
587
+ }
588
+ export function archiveOrphanRollouts(options) {
589
+ if (options.apply && !options.confirmArchiveOrphanRollouts) {
590
+ throw new Error("--apply requires --confirm-archive-orphan-rollouts");
591
+ }
592
+ const codexHome = resolveCodexHome(options);
593
+ const stateDb = path.join(codexHome, "state_5.sqlite");
594
+ const cutoffMs = recentCutoffMs(options.keepRecentDays);
595
+ const beforeDb = openReadonlyDb(stateDb);
596
+ let beforePlan;
597
+ try {
598
+ beforePlan = buildOrphanRolloutPlan(beforeDb, codexHome, { cutoffMs });
599
+ }
600
+ finally {
601
+ beforeDb.close();
602
+ }
603
+ let manifestPath = null;
604
+ const moved = [];
605
+ const errors = [];
606
+ let prunedEmptyDirs = [];
607
+ if (options.apply && beforePlan.candidates.length) {
608
+ const backupDir = resolveBackupDir(options, codexHome);
609
+ manifestPath = nextOrphanRolloutManifestPath(backupDir);
610
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
611
+ writeJsonFile(manifestPath, {
612
+ action: "archive-orphan-rollouts",
613
+ generatedAt: new Date().toISOString(),
614
+ codexHome,
615
+ policy: {
616
+ keepRecentDays: options.keepRecentDays,
617
+ recentCutoffMs: cutoffMs,
618
+ recentCutoffUtc: millisToIso(cutoffMs),
619
+ },
620
+ candidates: beforePlan.candidates,
621
+ });
622
+ fs.mkdirSync(path.join(codexHome, "archived_sessions"), { recursive: true });
623
+ for (const move of beforePlan.candidates) {
624
+ try {
625
+ assertSafeRolloutMove(codexHome, move.path, move.destination);
626
+ if (fs.existsSync(move.destination)) {
627
+ errors.push({ error: "destination exists", path: move.path, destination: move.destination });
628
+ continue;
629
+ }
630
+ fs.renameSync(move.path, move.destination);
631
+ moved.push(move);
632
+ }
633
+ catch (error) {
634
+ errors.push({
635
+ error: error instanceof Error ? error.message : String(error),
636
+ path: move.path,
637
+ destination: move.destination,
638
+ });
639
+ }
640
+ }
641
+ prunedEmptyDirs = pruneEmptyDirectories(path.join(codexHome, "sessions"));
642
+ writeJsonFile(manifestPath, {
643
+ action: "archive-orphan-rollouts",
644
+ generatedAt: new Date().toISOString(),
645
+ codexHome,
646
+ policy: {
647
+ keepRecentDays: options.keepRecentDays,
648
+ recentCutoffMs: cutoffMs,
649
+ recentCutoffUtc: millisToIso(cutoffMs),
650
+ },
651
+ moved,
652
+ skipped: beforePlan.skipped,
653
+ prunedEmptyDirs,
654
+ errors,
655
+ });
656
+ }
657
+ const afterDb = openReadonlyDb(stateDb);
658
+ let afterPlan;
659
+ try {
660
+ afterPlan = buildOrphanRolloutPlan(afterDb, codexHome, { cutoffMs });
661
+ }
662
+ finally {
663
+ afterDb.close();
664
+ }
665
+ return {
666
+ action: "archive-orphan-rollouts",
667
+ mode: options.apply ? "apply" : "dry-run",
668
+ codexHome,
669
+ generatedAt: new Date().toISOString(),
670
+ policy: {
671
+ keepRecentDays: options.keepRecentDays,
672
+ recentCutoffMs: cutoffMs,
673
+ recentCutoffUtc: millisToIso(cutoffMs),
674
+ },
675
+ before: beforePlan.stats,
676
+ after: afterPlan.stats,
677
+ movedFiles: moved.length,
678
+ movedMib: fileMoveListSizeMib(moved),
679
+ prunedEmptyDirs: prunedEmptyDirs.length,
680
+ prunedEmptyDirSample: prunedEmptyDirs.slice(0, 10),
681
+ errors: errors.slice(0, 10),
682
+ manifestPath,
683
+ };
684
+ }
685
+ export async function checkpointWal(options, backupBeforeCheckpoint = true) {
686
+ const codexHome = resolveCodexHome(options);
687
+ const stateDb = path.join(codexHome, "state_5.sqlite");
688
+ const before = fileTripletSizes(stateDb);
689
+ let checkpointResult = null;
690
+ let backupPath = null;
691
+ if (options.apply) {
692
+ if (backupBeforeCheckpoint) {
693
+ backupPath = await backupSqliteDatabase(stateDb, resolveBackupDir(options, codexHome));
694
+ }
695
+ const db = openWritableDb(stateDb);
696
+ try {
697
+ checkpointResult = queryAll(db, "PRAGMA wal_checkpoint(TRUNCATE)");
698
+ }
699
+ finally {
700
+ db.close();
701
+ }
702
+ }
703
+ return {
704
+ action: "checkpoint-wal",
705
+ mode: options.apply ? "apply" : "dry-run",
706
+ codexHome,
707
+ generatedAt: new Date().toISOString(),
708
+ before,
709
+ after: fileTripletSizes(stateDb),
710
+ checkpointResult,
711
+ backupPath,
712
+ };
713
+ }
714
+ export async function vacuumStateDatabase(options, backupBeforeVacuum = true) {
715
+ const codexHome = resolveCodexHome(options);
716
+ const stateDb = path.join(codexHome, "state_5.sqlite");
717
+ return vacuumSqliteDatabase({
718
+ action: "vacuum-state",
719
+ apply: options.apply,
720
+ backupBeforeVacuum,
721
+ backupDir: resolveBackupDir(options, codexHome),
722
+ codexHome,
723
+ dbPath: stateDb,
724
+ });
725
+ }
726
+ export async function cleanLogs(options) {
727
+ if (options.apply && !options.confirmPruneLogs) {
728
+ throw new Error("--apply requires --confirm-prune-logs");
729
+ }
730
+ const codexHome = resolveCodexHome(options);
731
+ const logsDb = path.join(codexHome, "logs_2.sqlite");
732
+ if (!fs.existsSync(logsDb)) {
733
+ return {
734
+ action: "clean-logs",
735
+ mode: options.apply ? "apply" : "dry-run",
736
+ codexHome,
737
+ generatedAt: new Date().toISOString(),
738
+ exists: false,
739
+ };
740
+ }
741
+ const db = openWritableDb(logsDb);
742
+ let backupPath = null;
743
+ let cappedRows = 0;
744
+ let deletedRows = 0;
745
+ try {
746
+ const beforeFiles = fileTripletSizes(logsDb);
747
+ const before = collectLogCleanupStats(db, options);
748
+ const beforeSpace = collectSqliteSpaceStats(db);
749
+ const hasRowChanges = Number(before.cap_rows) > 0 || Number(before.delete_rows) > 0;
750
+ const shouldVacuum = Number(beforeSpace.freelist_count) > 0 || hasRowChanges;
751
+ if (options.apply && shouldVacuum) {
752
+ backupPath = await backupSqliteDatabase(logsDb, resolveBackupDir(options, codexHome));
753
+ const cutoffSeconds = logCutoffSeconds(options.keepLogDays);
754
+ if (hasRowChanges) {
755
+ const tx = db.transaction(() => {
756
+ deletedRows = Number(db.prepare("DELETE FROM logs WHERE ts < @cutoffSeconds").run({ cutoffSeconds }).changes);
757
+ cappedRows = Number(db
758
+ .prepare(`
759
+ UPDATE logs
760
+ SET estimated_bytes = max(0, estimated_bytes - (length(feedback_log_body) - @maxLogBodyChars)),
761
+ feedback_log_body = substr(feedback_log_body, 1, @maxLogBodyChars)
762
+ WHERE ts >= @cutoffSeconds
763
+ AND feedback_log_body IS NOT NULL
764
+ AND length(feedback_log_body) > @maxLogBodyChars
765
+ `)
766
+ .run({ cutoffSeconds, maxLogBodyChars: options.maxLogBodyChars }).changes);
767
+ });
768
+ tx();
769
+ }
770
+ db.exec("VACUUM");
771
+ queryAll(db, "PRAGMA wal_checkpoint(TRUNCATE)");
772
+ }
773
+ const after = collectLogCleanupStats(db, options);
774
+ const afterSpace = collectSqliteSpaceStats(db);
775
+ return {
776
+ action: "clean-logs",
777
+ mode: options.apply ? "apply" : "dry-run",
778
+ codexHome,
779
+ generatedAt: new Date().toISOString(),
780
+ policy: {
781
+ keepLogDays: options.keepLogDays,
782
+ maxLogBodyChars: options.maxLogBodyChars,
783
+ cutoffSeconds: logCutoffSeconds(options.keepLogDays),
784
+ cutoffUtc: secondsToIso(logCutoffSeconds(options.keepLogDays)),
785
+ },
786
+ beforeFiles,
787
+ afterFiles: fileTripletSizes(logsDb),
788
+ before,
789
+ after,
790
+ beforeSpace,
791
+ afterSpace,
792
+ cappedRows,
793
+ deletedRows,
794
+ backupPath,
795
+ };
796
+ }
797
+ finally {
798
+ db.close();
799
+ }
800
+ }
801
+ export async function cleanTuiLog(options) {
802
+ if (options.apply && !options.confirmPruneTuiLog) {
803
+ throw new Error("--apply requires --confirm-prune-tui-log");
804
+ }
805
+ const codexHome = resolveCodexHome(options);
806
+ const logPath = path.join(codexHome, "log", "codex-tui.log");
807
+ const before = collectTuiLogCleanupStats(logPath, options.keepTuiLogMib);
808
+ let backupPath = null;
809
+ let truncatedBytes = 0;
810
+ if (options.apply && before.exists && Number(before.reclaimable_bytes) > 0) {
811
+ backupPath = backupRegularFile(logPath, resolveBackupDir(options, codexHome));
812
+ truncateFileToTail(logPath, Number(before.keep_bytes));
813
+ truncatedBytes = Number(before.reclaimable_bytes);
814
+ }
815
+ return {
816
+ action: "clean-tui-log",
817
+ mode: options.apply ? "apply" : "dry-run",
818
+ codexHome,
819
+ generatedAt: new Date().toISOString(),
820
+ policy: {
821
+ keepTuiLogMib: options.keepTuiLogMib,
822
+ },
823
+ before,
824
+ after: collectTuiLogCleanupStats(logPath, options.keepTuiLogMib),
825
+ truncatedBytes,
826
+ truncatedMib: roundMib(truncatedBytes),
827
+ backupPath,
828
+ };
829
+ }
830
+ export function scanBackups(options) {
831
+ const plan = buildBackupPrunePlan(options);
832
+ return {
833
+ action: "backups-scan",
834
+ mode: "dry-run",
835
+ codexHome: plan.codexHome,
836
+ generatedAt: new Date().toISOString(),
837
+ policy: {
838
+ olderThanHours: options.olderThanHours,
839
+ cutoffMs: plan.cutoffMs,
840
+ cutoffUtc: millisToIso(plan.cutoffMs),
841
+ },
842
+ backupDir: plan.backupDir,
843
+ files: backupFileStats(plan.files),
844
+ pruneCandidates: backupFileStats(plan.candidates),
845
+ sample: plan.candidates.slice(0, 10),
846
+ };
847
+ }
848
+ export function pruneBackups(options) {
849
+ if (options.apply && !options.confirmDeleteBackups) {
850
+ throw new Error("--apply requires --confirm-delete-backups");
851
+ }
852
+ const before = buildBackupPrunePlan(options);
853
+ const deleted = [];
854
+ if (options.apply) {
855
+ for (const file of before.candidates) {
856
+ const resolved = path.resolve(file.path);
857
+ if (path.dirname(resolved) !== before.backupDir) {
858
+ throw new Error(`Refusing to delete backup outside backup dir: ${file.path}`);
859
+ }
860
+ fs.unlinkSync(resolved);
861
+ deleted.push(file);
862
+ }
863
+ }
864
+ const after = buildBackupPrunePlan(options);
865
+ return {
866
+ action: "backups-prune",
867
+ mode: options.apply ? "apply" : "dry-run",
868
+ codexHome: before.codexHome,
869
+ generatedAt: new Date().toISOString(),
870
+ policy: {
871
+ olderThanHours: options.olderThanHours,
872
+ cutoffMs: before.cutoffMs,
873
+ cutoffUtc: millisToIso(before.cutoffMs),
874
+ },
875
+ backupDir: before.backupDir,
876
+ before: backupFileStats(before.files),
877
+ candidates: backupFileStats(before.candidates),
878
+ after: backupFileStats(after.files),
879
+ deleted: backupFileStats(deleted),
880
+ };
881
+ }
882
+ export async function scheduleBackupPrune(options) {
883
+ if (options.apply && !options.confirmScheduleBackupPrune) {
884
+ throw new Error("--apply requires --confirm-schedule-backup-prune");
885
+ }
886
+ if (options.afterHours < options.olderThanHours) {
887
+ throw new Error("--after-hours must be greater than or equal to --older-than-hours");
888
+ }
889
+ const codexHome = resolveCodexHome(options);
890
+ const backupDir = resolveBackupDir(options, codexHome);
891
+ const runAt = new Date(Date.now() + options.afterHours * 60 * 60 * 1000);
892
+ const scheduled = await schedulePruneCommand({
893
+ afterHours: options.afterHours,
894
+ apply: options.apply,
895
+ backupDir,
896
+ codexHome,
897
+ olderThanHours: options.olderThanHours,
898
+ runAt,
899
+ });
900
+ return {
901
+ action: "backups-schedule-prune",
902
+ mode: options.apply ? "apply" : "dry-run",
903
+ codexHome,
904
+ generatedAt: new Date().toISOString(),
905
+ policy: {
906
+ afterHours: options.afterHours,
907
+ olderThanHours: options.olderThanHours,
908
+ runAtUtc: runAt.toISOString(),
909
+ },
910
+ backupDir,
911
+ ...scheduled,
912
+ };
913
+ }
914
+ export function compactWhere(args) {
915
+ const clauses = [
916
+ `(${THREAD_COLUMNS_TO_CAP.map((column) => `length(${column}) > @maxChars`).join(" OR ")})`,
917
+ ];
918
+ const params = {
919
+ cutoffMs: args.cutoffMs,
920
+ maxChars: args.maxChars,
921
+ };
922
+ if (args.archivedOnly) {
923
+ clauses.push("archived = 1");
924
+ }
925
+ if (args.protectRecent) {
926
+ clauses.push("(updated_at_ms < @cutoffMs OR (updated_at_ms IS NULL AND updated_at * 1000 < @cutoffMs))");
927
+ }
928
+ const protectedIds = [...args.protectedIds].sort();
929
+ if (protectedIds.length) {
930
+ const names = protectedIds.map((threadId, index) => {
931
+ const name = `protected${index}`;
932
+ params[name] = threadId;
933
+ return `@${name}`;
934
+ });
935
+ clauses.push(`id NOT IN (${names.join(", ")})`);
936
+ }
937
+ return { sql: clauses.join(" AND "), params };
938
+ }
939
+ export function collectCompactCandidateStats(db, args) {
940
+ const where = compactWhere(args);
941
+ const savingsExpr = THREAD_COLUMNS_TO_CAP.map((column) => `max(length(${column}) - @maxChars, 0)`).join(" + ");
942
+ const stats = queryOne(db, `
943
+ SELECT
944
+ count(*) AS rows,
945
+ coalesce(round(sum(${savingsExpr}) / 1048576.0, 2), 0) AS estimated_savings_mib,
946
+ max(max(length(title), length(preview), length(first_user_message))) AS max_field_chars,
947
+ min(coalesce(updated_at_ms, updated_at * 1000)) AS oldest_candidate_updated_at_ms,
948
+ max(coalesce(updated_at_ms, updated_at * 1000)) AS newest_candidate_updated_at_ms
949
+ FROM threads
950
+ WHERE ${where.sql}
951
+ `, where.params);
952
+ return {
953
+ ...stats,
954
+ newestCandidateUpdatedUtc: millisToIso(asNumberOrNull(stats.newest_candidate_updated_at_ms)),
955
+ oldestCandidateUpdatedUtc: millisToIso(asNumberOrNull(stats.oldest_candidate_updated_at_ms)),
956
+ };
957
+ }
958
+ export function collectStaleArchiveCandidateStats(db, codexHome, args) {
959
+ return buildStaleArchivePlan(db, codexHome, args).stats;
960
+ }
961
+ function buildStaleArchivePlan(db, codexHome, args) {
962
+ const rows = queryAll(db, `
963
+ SELECT id, archived, archived_at, rollout_path, updated_at, updated_at_ms,
964
+ substr(cwd, 1, 160) AS cwd,
965
+ substr(title, 1, 160) AS title
966
+ FROM threads
967
+ `);
968
+ const edgeResult = tryQueryAll(db, "SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges");
969
+ const edges = Array.isArray(edgeResult) ? edgeResult : [];
970
+ const rowById = new Map(rows.map((row) => [row.id, row]));
971
+ const childrenByParent = new Map();
972
+ const parentByChild = new Map();
973
+ for (const edge of edges) {
974
+ const children = childrenByParent.get(edge.parent_thread_id) ?? [];
975
+ children.push(edge.child_thread_id);
976
+ childrenByParent.set(edge.parent_thread_id, children);
977
+ parentByChild.set(edge.child_thread_id, edge.parent_thread_id);
978
+ }
979
+ const statRollouts = Boolean(args.statRollouts);
980
+ const fileInfo = new Map(rows.map((row) => [row.id, rolloutFileInfo(row.rollout_path, statRollouts)]));
981
+ const candidates = rows.filter((row) => row.archived !== 1 && !args.protectedIds.has(row.id) && threadUpdatedAtMs(row) < args.cutoffMs);
982
+ const candidateIds = new Set(candidates.map((row) => row.id));
983
+ const candidateRowsById = new Map(candidates.map((row) => [row.id, row]));
984
+ const unsafeDescendantIds = new Set();
985
+ const missingSubtreeFileIds = new Set();
986
+ const safeToCallIds = new Set();
987
+ for (const row of candidates) {
988
+ const ownFile = fileInfo.get(row.id);
989
+ if (ownFile?.exists === false) {
990
+ missingSubtreeFileIds.add(row.id);
991
+ continue;
992
+ }
993
+ let safe = true;
994
+ for (const descendantId of descendantThreadIds(row.id, childrenByParent)) {
995
+ const descendant = rowById.get(descendantId);
996
+ if (!descendant || descendant.archived === 1)
997
+ continue;
998
+ if (!candidateIds.has(descendantId)) {
999
+ unsafeDescendantIds.add(row.id);
1000
+ safe = false;
1001
+ continue;
1002
+ }
1003
+ if (fileInfo.get(descendantId)?.exists === false) {
1004
+ missingSubtreeFileIds.add(row.id);
1005
+ safe = false;
1006
+ }
1007
+ }
1008
+ if (safe)
1009
+ safeToCallIds.add(row.id);
1010
+ }
1011
+ const selectedCallIds = [];
1012
+ const selectedCallSet = new Set();
1013
+ const sortedCandidates = [...candidates].sort((left, right) => candidateDepth(left.id, parentByChild) - candidateDepth(right.id, parentByChild));
1014
+ for (const row of sortedCandidates) {
1015
+ if (!safeToCallIds.has(row.id))
1016
+ continue;
1017
+ if (hasSelectedCandidateAncestor(row.id, parentByChild, selectedCallSet))
1018
+ continue;
1019
+ selectedCallIds.push(row.id);
1020
+ selectedCallSet.add(row.id);
1021
+ }
1022
+ const expectedArchivedIds = new Set();
1023
+ for (const id of selectedCallIds) {
1024
+ expectedArchivedIds.add(id);
1025
+ for (const descendantId of descendantThreadIds(id, childrenByParent)) {
1026
+ if (candidateIds.has(descendantId))
1027
+ expectedArchivedIds.add(descendantId);
1028
+ }
1029
+ }
1030
+ const candidateFileInfos = candidates.map((row) => fileInfo.get(row.id)).filter(isDefined);
1031
+ const expectedFileInfos = [...expectedArchivedIds]
1032
+ .map((id) => fileInfo.get(id))
1033
+ .filter(isDefined)
1034
+ .filter((info) => info.exists !== false);
1035
+ const newest = maxNumberOrNull(candidates.map(threadUpdatedAtMs));
1036
+ const oldest = minNumberOrNull(candidates.map(threadUpdatedAtMs));
1037
+ return {
1038
+ archiveCallIds: selectedCallIds,
1039
+ stats: {
1040
+ rows: candidates.length,
1041
+ archive_call_rows: selectedCallIds.length,
1042
+ expected_archived_rows: expectedArchivedIds.size,
1043
+ rollout_size_mib: statRollouts ? fileInfoListSizeMib(candidateFileInfos) : null,
1044
+ expected_archived_rollout_size_mib: statRollouts ? fileInfoListSizeMib(expectedFileInfos) : null,
1045
+ missing_rollout_files: statRollouts
1046
+ ? candidates.filter((row) => fileInfo.get(row.id)?.exists === false).length
1047
+ : null,
1048
+ blocked_by_descendant_safety: unsafeDescendantIds.size,
1049
+ blocked_by_missing_subtree_files: missingSubtreeFileIds.size,
1050
+ descendant_edges: edges.length,
1051
+ newestCandidateUpdatedUtc: millisToIso(newest),
1052
+ oldestCandidateUpdatedUtc: millisToIso(oldest),
1053
+ sample: topArchiveSample(candidates, fileInfo),
1054
+ archiveCallSample: topArchiveSample(selectedCallIds.map((id) => candidateRowsById.get(id)).filter(isDefined), fileInfo),
1055
+ codexHome,
1056
+ },
1057
+ };
1058
+ }
1059
+ function collectThreadStats(db) {
1060
+ const stats = queryOne(db, `
1061
+ SELECT
1062
+ count(*) AS rows,
1063
+ sum(CASE WHEN archived = 1 THEN 1 ELSE 0 END) AS archived_rows,
1064
+ min(created_at) AS min_created_at,
1065
+ max(updated_at) AS max_updated_at
1066
+ FROM threads
1067
+ `);
1068
+ return {
1069
+ ...stats,
1070
+ createdRangeUtc: {
1071
+ maxUpdated: secondsToIso(asNumberOrNull(stats.max_updated_at)),
1072
+ min: secondsToIso(asNumberOrNull(stats.min_created_at)),
1073
+ },
1074
+ };
1075
+ }
1076
+ function collectLogStats(db) {
1077
+ const stats = queryOne(db, `
1078
+ SELECT count(*) AS rows,
1079
+ min(ts) AS min_ts,
1080
+ max(ts) AS max_ts,
1081
+ round(sum(estimated_bytes) / 1048576.0, 2) AS estimated_payload_mib,
1082
+ sum(CASE WHEN thread_id IS NULL THEN 1 ELSE 0 END) AS threadless_rows
1083
+ FROM logs
1084
+ `);
1085
+ return {
1086
+ ...stats,
1087
+ rangeUtc: {
1088
+ max: secondsToIso(asNumberOrNull(stats.max_ts)),
1089
+ min: secondsToIso(asNumberOrNull(stats.min_ts)),
1090
+ },
1091
+ topTargets: queryAll(db, `
1092
+ SELECT target, count(*) AS rows, round(sum(estimated_bytes) / 1048576.0, 2) AS estimated_payload_mib
1093
+ FROM logs
1094
+ GROUP BY target
1095
+ ORDER BY sum(estimated_bytes) DESC
1096
+ LIMIT 10
1097
+ `),
1098
+ };
1099
+ }
1100
+ export function collectLogCleanupStats(db, options) {
1101
+ const params = {
1102
+ cutoffSeconds: logCutoffSeconds(options.keepLogDays),
1103
+ maxLogBodyChars: options.maxLogBodyChars,
1104
+ };
1105
+ const stats = queryOne(db, `
1106
+ SELECT
1107
+ count(*) AS rows,
1108
+ coalesce(sum(CASE WHEN ts < @cutoffSeconds THEN 1 ELSE 0 END), 0) AS delete_rows,
1109
+ coalesce(round(sum(CASE WHEN ts < @cutoffSeconds THEN estimated_bytes ELSE 0 END) / 1048576.0, 2), 0)
1110
+ AS delete_estimated_payload_mib,
1111
+ coalesce(sum(
1112
+ CASE
1113
+ WHEN ts >= @cutoffSeconds
1114
+ AND feedback_log_body IS NOT NULL
1115
+ AND length(feedback_log_body) > @maxLogBodyChars
1116
+ THEN 1
1117
+ ELSE 0
1118
+ END
1119
+ ), 0) AS cap_rows,
1120
+ coalesce(round(sum(
1121
+ CASE
1122
+ WHEN ts >= @cutoffSeconds
1123
+ AND feedback_log_body IS NOT NULL
1124
+ AND length(feedback_log_body) > @maxLogBodyChars
1125
+ THEN length(feedback_log_body) - @maxLogBodyChars
1126
+ ELSE 0
1127
+ END
1128
+ ) / 1048576.0, 2), 0) AS cap_estimated_savings_mib,
1129
+ min(ts) AS min_ts,
1130
+ max(ts) AS max_ts
1131
+ FROM logs
1132
+ `, params);
1133
+ return {
1134
+ ...stats,
1135
+ cutoffUtc: secondsToIso(params.cutoffSeconds),
1136
+ rangeUtc: {
1137
+ max: secondsToIso(asNumberOrNull(stats.max_ts)),
1138
+ min: secondsToIso(asNumberOrNull(stats.min_ts)),
1139
+ },
1140
+ };
1141
+ }
1142
+ export function collectTuiLogCleanupStats(logPath, keepMib) {
1143
+ const size = fileSize(logPath);
1144
+ const currentBytes = Number(size.bytes ?? 0);
1145
+ const keepBytes = keepMib * 1024 * 1024;
1146
+ const reclaimableBytes = Math.max(0, currentBytes - keepBytes);
1147
+ return {
1148
+ current_bytes: currentBytes,
1149
+ current_mib: roundMib(currentBytes),
1150
+ exists: Boolean(size.exists),
1151
+ keep_bytes: keepBytes,
1152
+ keep_mib: keepMib,
1153
+ path: logPath,
1154
+ reclaimable_bytes: reclaimableBytes,
1155
+ reclaimable_mib: roundMib(reclaimableBytes),
1156
+ target_mib: roundMib(currentBytes - reclaimableBytes),
1157
+ };
1158
+ }
1159
+ export function collectOrphanRolloutArchiveStats(db, codexHome, args) {
1160
+ return buildOrphanRolloutPlan(db, codexHome, args).stats;
1161
+ }
1162
+ function buildOrphanRolloutPlan(db, codexHome, args) {
1163
+ const refs = queryAll(db, "SELECT rollout_path FROM threads WHERE rollout_path IS NOT NULL AND rollout_path != ''");
1164
+ const referencedPaths = new Set(refs.map((row) => normalizePath(String(row.rollout_path))));
1165
+ const sessionFiles = listRolloutFiles(path.join(codexHome, "sessions"));
1166
+ const sessionIndexIds = loadSessionIndexIds(codexHome);
1167
+ const archivedRoot = path.join(codexHome, "archived_sessions");
1168
+ const sessionsRoot = path.join(codexHome, "sessions");
1169
+ const candidates = [];
1170
+ const skipped = [];
1171
+ for (const file of sessionFiles) {
1172
+ if (referencedPaths.has(normalizePath(file)))
1173
+ continue;
1174
+ const stat = fs.statSync(file);
1175
+ const threadId = rolloutThreadId(file);
1176
+ const move = {
1177
+ destination: path.join(archivedRoot, path.basename(file)),
1178
+ indexed: sessionIndexIds.has(threadId ?? ""),
1179
+ modifiedMs: stat.mtimeMs,
1180
+ path: file,
1181
+ sizeBytes: stat.size,
1182
+ threadId,
1183
+ };
1184
+ if (stat.mtimeMs >= args.cutoffMs) {
1185
+ skipped.push({ ...move, reason: "recent" });
1186
+ continue;
1187
+ }
1188
+ if (move.indexed) {
1189
+ skipped.push({ ...move, reason: "session-indexed" });
1190
+ continue;
1191
+ }
1192
+ if (fs.existsSync(move.destination)) {
1193
+ skipped.push({ ...move, reason: "destination-exists" });
1194
+ continue;
1195
+ }
1196
+ candidates.push(move);
1197
+ }
1198
+ const candidatePaths = new Set(candidates.map((move) => normalizePath(move.path)));
1199
+ const emptyDirs = collectEmptyDirectories(sessionsRoot, candidatePaths);
1200
+ const indexed = candidates.filter((move) => move.indexed);
1201
+ const unindexed = candidates.filter((move) => !move.indexed);
1202
+ const modifiedValues = candidates.map((move) => move.modifiedMs);
1203
+ return {
1204
+ candidates,
1205
+ emptyDirs,
1206
+ skipped,
1207
+ stats: {
1208
+ empty_dir_candidates: emptyDirs.length,
1209
+ empty_dir_sample: emptyDirs.slice(0, 10),
1210
+ rows: candidates.length,
1211
+ files: candidates.length,
1212
+ size_mib: fileMoveListSizeMib(candidates),
1213
+ indexed_files: indexed.length,
1214
+ indexed_size_mib: fileMoveListSizeMib(indexed),
1215
+ unindexed_files: unindexed.length,
1216
+ unindexed_size_mib: fileMoveListSizeMib(unindexed),
1217
+ skipped_files: skipped.length,
1218
+ skipped_recent_files: skipped.filter((move) => move.reason === "recent").length,
1219
+ skipped_session_indexed_files: skipped.filter((move) => move.reason === "session-indexed").length,
1220
+ skipped_destination_exists_files: skipped.filter((move) => move.reason === "destination-exists").length,
1221
+ oldest_candidate_modified_utc: millisToIso(minNumberOrNull(modifiedValues)),
1222
+ newest_candidate_modified_utc: millisToIso(maxNumberOrNull(modifiedValues)),
1223
+ sample: topMoveSample(candidates),
1224
+ },
1225
+ };
1226
+ }
1227
+ function collectRolloutLinkage(db, codexHome) {
1228
+ const refs = queryAll(db, "SELECT id, archived, rollout_path FROM threads WHERE rollout_path IS NOT NULL AND rollout_path != ''");
1229
+ const referencedPaths = new Set(refs.map((row) => normalizePath(String(row.rollout_path))));
1230
+ const sessionFiles = listRolloutFiles(path.join(codexHome, "sessions"));
1231
+ const archivedFiles = listRolloutFiles(path.join(codexHome, "archived_sessions"));
1232
+ const diskFiles = [...sessionFiles, ...archivedFiles];
1233
+ const diskPaths = new Set(diskFiles.map((file) => normalizePath(file)));
1234
+ const missing = refs.filter((row) => !diskPaths.has(normalizePath(String(row.rollout_path))));
1235
+ const missingActive = missing.filter((row) => Number(row.archived ?? 0) !== 1);
1236
+ const missingArchived = missing.filter((row) => Number(row.archived ?? 0) === 1);
1237
+ const orphans = diskFiles.filter((file) => !referencedPaths.has(normalizePath(file)));
1238
+ const sessionOrphans = sessionFiles.filter((file) => !referencedPaths.has(normalizePath(file)));
1239
+ const archivedOrphans = archivedFiles.filter((file) => !referencedPaths.has(normalizePath(file)));
1240
+ const sessionIndexIds = loadSessionIndexIds(codexHome);
1241
+ const indexedOrphans = orphans.filter((file) => sessionIndexIds.has(rolloutThreadId(file) ?? ""));
1242
+ const unindexedOrphans = orphans.filter((file) => !sessionIndexIds.has(rolloutThreadId(file) ?? ""));
1243
+ const indexedSessionOrphans = sessionOrphans.filter((file) => sessionIndexIds.has(rolloutThreadId(file) ?? ""));
1244
+ const unindexedSessionOrphans = sessionOrphans.filter((file) => !sessionIndexIds.has(rolloutThreadId(file) ?? ""));
1245
+ return {
1246
+ archivedOrphanFiles: archivedOrphans.length,
1247
+ archivedOrphanSizeMib: fileListSizeMib(archivedOrphans),
1248
+ archivedRolloutFiles: archivedFiles.length,
1249
+ diskRolloutFiles: diskFiles.length,
1250
+ missingReferencedFiles: missing.length,
1251
+ missingActiveReferencedFiles: missingActive.length,
1252
+ missingArchivedReferencedFiles: missingArchived.length,
1253
+ missingActiveSample: missingActive.slice(0, 10),
1254
+ missingArchivedSample: missingArchived.slice(0, 10),
1255
+ missingSample: missing.slice(0, 10),
1256
+ orphanFiles: orphans.length,
1257
+ orphanIndexedFiles: indexedOrphans.length,
1258
+ orphanIndexedSizeMib: fileListSizeMib(indexedOrphans),
1259
+ orphanSizeMib: fileListSizeMib(orphans),
1260
+ orphanUnindexedFiles: unindexedOrphans.length,
1261
+ orphanUnindexedSizeMib: fileListSizeMib(unindexedOrphans),
1262
+ sessionOrphanFiles: sessionOrphans.length,
1263
+ sessionOrphanIndexedFiles: indexedSessionOrphans.length,
1264
+ sessionOrphanIndexedSizeMib: fileListSizeMib(indexedSessionOrphans),
1265
+ sessionOrphanSizeMib: fileListSizeMib(sessionOrphans),
1266
+ sessionOrphanUnindexedFiles: unindexedSessionOrphans.length,
1267
+ sessionOrphanUnindexedSizeMib: fileListSizeMib(unindexedSessionOrphans),
1268
+ sessionRolloutFiles: sessionFiles.length,
1269
+ threadRolloutRefs: refs.length,
1270
+ topOrphanSample: topFileSample(orphans),
1271
+ topSessionOrphanSample: topFileSample(sessionOrphans),
1272
+ topUnindexedOrphanSample: topFileSample(unindexedOrphans),
1273
+ };
1274
+ }
1275
+ function loadThreadProtection(codexHome, globalState) {
1276
+ const atom = asRecord(globalState["electron-persisted-atom-state"]);
1277
+ return {
1278
+ activeGoalIds: loadActiveGoalThreadIds(path.join(codexHome, "goals_1.sqlite")),
1279
+ heartbeatIds: new Set(Object.keys(asRecord(atom["heartbeat-thread-permissions-by-id"]))),
1280
+ pinnedIds: new Set(asStringArray(globalState["pinned-thread-ids"])),
1281
+ };
1282
+ }
1283
+ function loadGlobalState(codexHome) {
1284
+ const file = path.join(codexHome, ".codex-global-state.json");
1285
+ if (!fs.existsSync(file))
1286
+ return {};
1287
+ return JSON.parse(fs.readFileSync(file, "utf8"));
1288
+ }
1289
+ function loadActiveGoalThreadIds(file) {
1290
+ if (!fs.existsSync(file))
1291
+ return new Set();
1292
+ const db = openReadonlyDb(file);
1293
+ try {
1294
+ return new Set(queryAll(db, "SELECT thread_id FROM thread_goals WHERE status = 'active'").map((row) => String(row.thread_id)));
1295
+ }
1296
+ catch {
1297
+ return new Set();
1298
+ }
1299
+ finally {
1300
+ db.close();
1301
+ }
1302
+ }
1303
+ function loadSessionIndexIds(codexHome) {
1304
+ const file = path.join(codexHome, "session_index.jsonl");
1305
+ if (!fs.existsSync(file))
1306
+ return new Set();
1307
+ const ids = new Set();
1308
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
1309
+ if (!line.trim())
1310
+ continue;
1311
+ try {
1312
+ const value = JSON.parse(line);
1313
+ if (value.id)
1314
+ ids.add(String(value.id));
1315
+ }
1316
+ catch {
1317
+ // Ignore malformed historical lines.
1318
+ }
1319
+ }
1320
+ return ids;
1321
+ }
1322
+ function resolveBackupDir(options, codexHome) {
1323
+ return path.resolve(options.backupDir ?? path.join(codexHome, ".codex-cleanup-backups"));
1324
+ }
1325
+ function listBackupFiles(backupDir) {
1326
+ if (!fs.existsSync(backupDir))
1327
+ return [];
1328
+ const now = Date.now();
1329
+ return fs
1330
+ .readdirSync(backupDir, { withFileTypes: true })
1331
+ .filter((entry) => entry.isFile() && BACKUP_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix)))
1332
+ .map((entry) => {
1333
+ const filePath = path.resolve(backupDir, entry.name);
1334
+ const stat = fs.statSync(filePath);
1335
+ return {
1336
+ ageHours: Math.round(((now - stat.mtimeMs) / 60 / 60 / 1000) * 100) / 100,
1337
+ bytes: stat.size,
1338
+ lastModified: stat.mtime.toISOString(),
1339
+ mib: roundMib(stat.size),
1340
+ name: entry.name,
1341
+ path: filePath,
1342
+ };
1343
+ })
1344
+ .sort((left, right) => Date.parse(left.lastModified) - Date.parse(right.lastModified));
1345
+ }
1346
+ function buildBackupPrunePlan(options) {
1347
+ const codexHome = resolveCodexHome(options);
1348
+ const backupDir = resolveBackupDir(options, codexHome);
1349
+ const cutoffMs = Date.now() - options.olderThanHours * 60 * 60 * 1000;
1350
+ const files = listBackupFiles(backupDir);
1351
+ return {
1352
+ backupDir,
1353
+ candidates: files.filter((file) => Date.parse(file.lastModified) < cutoffMs),
1354
+ codexHome,
1355
+ cutoffMs,
1356
+ files,
1357
+ };
1358
+ }
1359
+ function backupFileStats(files) {
1360
+ const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
1361
+ return {
1362
+ count: files.length,
1363
+ files,
1364
+ newestUtc: files.length ? files[files.length - 1]?.lastModified : null,
1365
+ oldestUtc: files.length ? files[0]?.lastModified : null,
1366
+ totalBytes,
1367
+ totalMib: roundMib(totalBytes),
1368
+ };
1369
+ }
1370
+ async function backupSqliteDatabase(dbPath, backupDir) {
1371
+ fs.mkdirSync(backupDir, { recursive: true });
1372
+ const backupPath = nextBackupPath(dbPath, backupDir);
1373
+ const db = openWritableDb(dbPath);
1374
+ try {
1375
+ await db.backup(backupPath);
1376
+ }
1377
+ finally {
1378
+ db.close();
1379
+ }
1380
+ return backupPath;
1381
+ }
1382
+ function backupRegularFile(filePath, backupDir) {
1383
+ fs.mkdirSync(backupDir, { recursive: true });
1384
+ const backupPath = nextFileBackupPath(filePath, backupDir);
1385
+ fs.copyFileSync(filePath, backupPath);
1386
+ return backupPath;
1387
+ }
1388
+ async function vacuumSqliteDatabase(args) {
1389
+ const before = fileTripletSizes(args.dbPath);
1390
+ const db = openWritableDb(args.dbPath);
1391
+ let backupPath = null;
1392
+ try {
1393
+ const beforeSpace = collectSqliteSpaceStats(db);
1394
+ if (args.apply && Number(beforeSpace.freelist_count) > 0) {
1395
+ if (args.backupBeforeVacuum) {
1396
+ backupPath = await backupSqliteDatabase(args.dbPath, args.backupDir);
1397
+ }
1398
+ db.exec("VACUUM");
1399
+ queryAll(db, "PRAGMA wal_checkpoint(TRUNCATE)");
1400
+ }
1401
+ return {
1402
+ action: args.action,
1403
+ mode: args.apply ? "apply" : "dry-run",
1404
+ codexHome: args.codexHome,
1405
+ generatedAt: new Date().toISOString(),
1406
+ before,
1407
+ after: fileTripletSizes(args.dbPath),
1408
+ beforeSpace,
1409
+ afterSpace: collectSqliteSpaceStats(db),
1410
+ backupPath,
1411
+ };
1412
+ }
1413
+ finally {
1414
+ db.close();
1415
+ }
1416
+ }
1417
+ export function nextBackupPath(dbPath, backupDir, now = new Date()) {
1418
+ return nextTimestampedPath(backupDir, path.basename(dbPath), ".bak.sqlite", now);
1419
+ }
1420
+ export function nextFileBackupPath(filePath, backupDir, now = new Date()) {
1421
+ return nextTimestampedPath(backupDir, path.basename(filePath), ".bak", now);
1422
+ }
1423
+ function nextOrphanRolloutManifestPath(backupDir, now = new Date()) {
1424
+ return nextTimestampedPath(backupDir, "orphan-rollouts", ".manifest.bak", now);
1425
+ }
1426
+ function nextTimestampedPath(backupDir, name, suffix, now) {
1427
+ const stamp = now
1428
+ .toISOString()
1429
+ .replace(/[-:]/g, "")
1430
+ .replace(".", "_");
1431
+ const base = `${name}.${stamp}`;
1432
+ let candidate = path.join(backupDir, `${base}${suffix}`);
1433
+ let collision = 2;
1434
+ while (fs.existsSync(candidate)) {
1435
+ candidate = path.join(backupDir, `${base}.${String(collision)}${suffix}`);
1436
+ collision += 1;
1437
+ }
1438
+ return candidate;
1439
+ }
1440
+ function writeJsonFile(filePath, value) {
1441
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
1442
+ }
1443
+ export function truncateFileToTail(filePath, keepBytes) {
1444
+ const currentBytes = fs.statSync(filePath).size;
1445
+ if (currentBytes <= keepBytes)
1446
+ return;
1447
+ const fd = fs.openSync(filePath, "r+");
1448
+ try {
1449
+ const buffer = Buffer.allocUnsafe(Math.min(TUI_LOG_COPY_CHUNK_BYTES, keepBytes));
1450
+ let readOffset = currentBytes - keepBytes;
1451
+ let writeOffset = 0;
1452
+ let remaining = keepBytes;
1453
+ while (remaining > 0) {
1454
+ const wanted = Math.min(buffer.length, remaining);
1455
+ const bytesRead = fs.readSync(fd, buffer, 0, wanted, readOffset);
1456
+ if (bytesRead <= 0)
1457
+ throw new Error(`Could not read log tail from ${filePath}`);
1458
+ fs.writeSync(fd, buffer, 0, bytesRead, writeOffset);
1459
+ readOffset += bytesRead;
1460
+ writeOffset += bytesRead;
1461
+ remaining -= bytesRead;
1462
+ }
1463
+ fs.ftruncateSync(fd, keepBytes);
1464
+ }
1465
+ finally {
1466
+ fs.closeSync(fd);
1467
+ }
1468
+ }
1469
+ async function schedulePruneCommand(args) {
1470
+ const command = buildBackupPruneCommand(args.codexHome, args.backupDir, args.olderThanHours);
1471
+ const taskName = `codex-cleaner-prune-backups-${timestampForName(new Date())}`;
1472
+ if (process.platform === "win32") {
1473
+ const cancelCommand = `schtasks /Delete /TN ${quoteWindowsArgument(taskName)} /F`;
1474
+ if (!args.apply) {
1475
+ return {
1476
+ cancelCommand,
1477
+ command: windowsCommandLine(command),
1478
+ runAtUtc: args.runAt.toISOString(),
1479
+ scheduler: "windows-scheduled-task",
1480
+ scheduled: false,
1481
+ taskName,
1482
+ };
1483
+ }
1484
+ const script = [
1485
+ `$action = New-ScheduledTaskAction -Execute ${powershellSingleQuote(command.command)} -Argument ${powershellSingleQuote(command.args.map(quoteWindowsArgument).join(" "))}`,
1486
+ `$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddHours(${String(args.afterHours)})`,
1487
+ `Register-ScheduledTask -TaskName ${powershellSingleQuote(taskName)} -Action $action -Trigger $trigger -Description 'Delete old codex-cleaner backups by running codex-cleaner backups prune.' -Force | Out-Null`,
1488
+ ].join("\n");
1489
+ const result = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
1490
+ encoding: "utf8",
1491
+ windowsHide: true,
1492
+ });
1493
+ if (result.status !== 0) {
1494
+ throw new Error(`Failed to schedule backup cleanup: ${result.stderr || result.stdout}`);
1495
+ }
1496
+ return {
1497
+ cancelCommand,
1498
+ command: windowsCommandLine(command),
1499
+ runAtUtc: args.runAt.toISOString(),
1500
+ scheduler: "windows-scheduled-task",
1501
+ scheduled: true,
1502
+ taskName,
1503
+ };
1504
+ }
1505
+ const shellCommand = posixCommandLine(command);
1506
+ const atAvailable = spawnSync("sh", ["-c", "command -v at"], { encoding: "utf8" }).status === 0;
1507
+ if (!atAvailable) {
1508
+ throw new Error("Cannot schedule backup cleanup: POSIX `at` is not available.");
1509
+ }
1510
+ if (!args.apply) {
1511
+ return {
1512
+ cancelCommand: "Apply the schedule command to get an atrm <job-id> cancel command.",
1513
+ command: shellCommand,
1514
+ runAtUtc: args.runAt.toISOString(),
1515
+ scheduler: "at",
1516
+ scheduled: false,
1517
+ };
1518
+ }
1519
+ const result = spawnSync("at", ["now", "+", String(args.afterHours), "hours"], {
1520
+ encoding: "utf8",
1521
+ input: `${shellCommand}\n`,
1522
+ });
1523
+ const combined = `${result.stdout}\n${result.stderr}`;
1524
+ if (result.status !== 0) {
1525
+ throw new Error(`Failed to schedule backup cleanup: ${combined.trim()}`);
1526
+ }
1527
+ const jobId = combined.match(/\bjob\s+(\d+)\b/i)?.[1];
1528
+ if (!jobId) {
1529
+ throw new Error(`Scheduled backup cleanup, but could not parse a cancelable at job id: ${combined.trim()}`);
1530
+ }
1531
+ return {
1532
+ cancelCommand: `atrm ${jobId}`,
1533
+ command: shellCommand,
1534
+ jobId,
1535
+ runAtUtc: args.runAt.toISOString(),
1536
+ scheduler: "at",
1537
+ scheduled: true,
1538
+ };
1539
+ }
1540
+ function buildBackupPruneCommand(codexHome, backupDir, olderThanHours) {
1541
+ const npxCommand = {
1542
+ args: [
1543
+ "--yes",
1544
+ "codex-cleaner@latest",
1545
+ "backups",
1546
+ "prune",
1547
+ "--codex-home",
1548
+ codexHome,
1549
+ "--backup-dir",
1550
+ backupDir,
1551
+ "--older-than-hours",
1552
+ String(olderThanHours),
1553
+ "--apply",
1554
+ "--confirm-delete-backups",
1555
+ ],
1556
+ command: "npx",
1557
+ };
1558
+ return process.platform === "win32"
1559
+ ? { args: ["/d", "/s", "/c", windowsCommandLine(npxCommand)], command: "cmd.exe" }
1560
+ : npxCommand;
1561
+ }
1562
+ function posixCommandLine(command) {
1563
+ return [command.command, ...command.args].map(shellQuote).join(" ");
1564
+ }
1565
+ function windowsCommandLine(command) {
1566
+ return [command.command, ...command.args].map(quoteWindowsArgument).join(" ");
1567
+ }
1568
+ function shellQuote(value) {
1569
+ return `'${value.replace(/'/g, "'\\''")}'`;
1570
+ }
1571
+ function powershellSingleQuote(value) {
1572
+ return `'${value.replace(/'/g, "''")}'`;
1573
+ }
1574
+ function quoteWindowsArgument(value) {
1575
+ if (!/[ \t"]/.test(value))
1576
+ return value;
1577
+ let result = '"';
1578
+ let backslashes = 0;
1579
+ for (const char of value) {
1580
+ if (char === "\\") {
1581
+ backslashes += 1;
1582
+ continue;
1583
+ }
1584
+ if (char === '"') {
1585
+ result += "\\".repeat(backslashes * 2 + 1);
1586
+ result += '"';
1587
+ backslashes = 0;
1588
+ continue;
1589
+ }
1590
+ result += "\\".repeat(backslashes);
1591
+ result += char;
1592
+ backslashes = 0;
1593
+ }
1594
+ result += "\\".repeat(backslashes * 2);
1595
+ result += '"';
1596
+ return result;
1597
+ }
1598
+ function timestampForName(date) {
1599
+ return date
1600
+ .toISOString()
1601
+ .replace(/[-:]/g, "")
1602
+ .replace(/\.\d{3}Z$/, "Z");
1603
+ }
1604
+ function openReadonlyDb(file) {
1605
+ if (!fs.existsSync(file))
1606
+ throw new Error(`SQLite database not found: ${file}`);
1607
+ return new Database(file, { fileMustExist: true, readonly: true });
1608
+ }
1609
+ function openWritableDb(file) {
1610
+ if (!fs.existsSync(file))
1611
+ throw new Error(`SQLite database not found: ${file}`);
1612
+ const db = new Database(file, { fileMustExist: true });
1613
+ db.pragma("busy_timeout = 5000");
1614
+ db.pragma("foreign_keys = ON");
1615
+ return db;
1616
+ }
1617
+ function queryAll(db, sql, params) {
1618
+ return db.prepare(sql).all(params ?? {});
1619
+ }
1620
+ function queryOne(db, sql, params) {
1621
+ return db.prepare(sql).get(params ?? {}) ?? {};
1622
+ }
1623
+ function tryQueryAll(db, sql) {
1624
+ try {
1625
+ return queryAll(db, sql);
1626
+ }
1627
+ catch (error) {
1628
+ return { error: error instanceof Error ? error.message : String(error) };
1629
+ }
1630
+ }
1631
+ function resolveCodexHome(options) {
1632
+ return path.resolve(options.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"));
1633
+ }
1634
+ function allProtectedIds(protection) {
1635
+ return new Set([...protection.pinnedIds, ...protection.heartbeatIds, ...protection.activeGoalIds]);
1636
+ }
1637
+ function fileTripletSizes(dbPath) {
1638
+ return {
1639
+ main: fileSize(dbPath),
1640
+ path: dbPath,
1641
+ shm: fileSize(`${dbPath}-shm`),
1642
+ wal: fileSize(`${dbPath}-wal`),
1643
+ };
1644
+ }
1645
+ function collectSqliteSpaceStats(db) {
1646
+ const pageCount = Number(queryOne(db, "PRAGMA page_count").page_count ?? 0);
1647
+ const freelistCount = Number(queryOne(db, "PRAGMA freelist_count").freelist_count ?? 0);
1648
+ const pageSize = Number(queryOne(db, "PRAGMA page_size").page_size ?? 0);
1649
+ const freeBytes = freelistCount * pageSize;
1650
+ const totalBytes = pageCount * pageSize;
1651
+ return {
1652
+ free_mib: roundMib(freeBytes),
1653
+ freelist_count: freelistCount,
1654
+ page_count: pageCount,
1655
+ page_size: pageSize,
1656
+ total_mib: roundMib(totalBytes),
1657
+ used_mib: roundMib(totalBytes - freeBytes),
1658
+ };
1659
+ }
1660
+ function fileSize(file) {
1661
+ if (!fs.existsSync(file))
1662
+ return { bytes: 0, exists: false, mib: 0, path: file };
1663
+ const bytes = fs.statSync(file).size;
1664
+ return { bytes, exists: true, mib: roundMib(bytes), path: file };
1665
+ }
1666
+ function listRolloutFiles(root) {
1667
+ if (!fs.existsSync(root))
1668
+ return [];
1669
+ const output = [];
1670
+ const stack = [root];
1671
+ while (stack.length) {
1672
+ const current = stack.pop();
1673
+ if (!current)
1674
+ continue;
1675
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
1676
+ const full = path.join(current, entry.name);
1677
+ if (entry.isDirectory())
1678
+ stack.push(full);
1679
+ if (entry.isFile() && entry.name.endsWith(".jsonl"))
1680
+ output.push(full);
1681
+ }
1682
+ }
1683
+ return output;
1684
+ }
1685
+ function collectEmptyDirectories(root, removedFilePaths = new Set()) {
1686
+ if (!fs.existsSync(root))
1687
+ return [];
1688
+ const resolvedRoot = path.resolve(root);
1689
+ const output = [];
1690
+ const visit = (dir) => {
1691
+ let empty = true;
1692
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1693
+ const full = path.join(dir, entry.name);
1694
+ if (entry.isDirectory()) {
1695
+ if (!visit(full))
1696
+ empty = false;
1697
+ continue;
1698
+ }
1699
+ if (entry.isFile() && removedFilePaths.has(normalizePath(full))) {
1700
+ continue;
1701
+ }
1702
+ empty = false;
1703
+ }
1704
+ if (empty && path.resolve(dir) !== resolvedRoot) {
1705
+ output.push(dir);
1706
+ }
1707
+ return empty;
1708
+ };
1709
+ visit(resolvedRoot);
1710
+ return output;
1711
+ }
1712
+ function pruneEmptyDirectories(root) {
1713
+ const dirs = collectEmptyDirectories(root);
1714
+ const removed = [];
1715
+ for (const dir of dirs) {
1716
+ try {
1717
+ fs.rmdirSync(dir);
1718
+ removed.push(dir);
1719
+ }
1720
+ catch {
1721
+ // Directory may have been recreated or filled by a live process.
1722
+ }
1723
+ }
1724
+ return removed;
1725
+ }
1726
+ function topFileSample(files, limit = 10) {
1727
+ return [...files]
1728
+ .sort((left, right) => fs.statSync(right).size - fs.statSync(left).size)
1729
+ .slice(0, limit)
1730
+ .map((file) => ({ path: file, sizeMib: roundMib(fs.statSync(file).size), threadId: rolloutThreadId(file) }));
1731
+ }
1732
+ function topMoveSample(files, limit = 10) {
1733
+ return [...files]
1734
+ .sort((left, right) => right.sizeBytes - left.sizeBytes)
1735
+ .slice(0, limit)
1736
+ .map((file) => ({
1737
+ destination: file.destination,
1738
+ indexed: file.indexed,
1739
+ modifiedUtc: millisToIso(file.modifiedMs),
1740
+ path: file.path,
1741
+ sizeMib: roundMib(file.sizeBytes),
1742
+ threadId: file.threadId,
1743
+ }));
1744
+ }
1745
+ function rolloutFileInfo(file, statFile) {
1746
+ if (!statFile) {
1747
+ return { exists: null, path: file, sizeBytes: 0, sizeMib: null, threadId: rolloutThreadId(file) };
1748
+ }
1749
+ if (!file || !fs.existsSync(file)) {
1750
+ return { exists: false, path: file, sizeBytes: 0, sizeMib: 0, threadId: rolloutThreadId(file) };
1751
+ }
1752
+ const sizeBytes = fs.statSync(file).size;
1753
+ return {
1754
+ exists: true,
1755
+ path: file,
1756
+ sizeBytes,
1757
+ sizeMib: roundMib(sizeBytes),
1758
+ threadId: rolloutThreadId(file),
1759
+ };
1760
+ }
1761
+ function topArchiveSample(rows, fileInfo, limit = 10) {
1762
+ return [...rows]
1763
+ .sort((left, right) => (fileInfo.get(right.id)?.sizeBytes ?? 0) - (fileInfo.get(left.id)?.sizeBytes ?? 0))
1764
+ .slice(0, limit)
1765
+ .map((row) => ({
1766
+ cwd: row.cwd,
1767
+ id: row.id,
1768
+ rolloutPath: row.rollout_path,
1769
+ rolloutSizeMib: fileInfo.get(row.id)?.sizeMib ?? 0,
1770
+ title: row.title,
1771
+ updatedUtc: millisToIso(threadUpdatedAtMs(row)),
1772
+ }));
1773
+ }
1774
+ function fileListSizeMib(files) {
1775
+ return roundMib(files.reduce((total, file) => total + fs.statSync(file).size, 0));
1776
+ }
1777
+ function fileInfoListSizeMib(files) {
1778
+ return roundMib(files.reduce((total, file) => total + file.sizeBytes, 0));
1779
+ }
1780
+ function fileMoveListSizeMib(files) {
1781
+ return roundMib(files.reduce((total, file) => total + file.sizeBytes, 0));
1782
+ }
1783
+ function descendantThreadIds(threadId, childrenByParent) {
1784
+ const output = [];
1785
+ const seen = new Set();
1786
+ const stack = [...(childrenByParent.get(threadId) ?? [])];
1787
+ while (stack.length) {
1788
+ const current = stack.pop();
1789
+ if (!current || seen.has(current))
1790
+ continue;
1791
+ seen.add(current);
1792
+ output.push(current);
1793
+ stack.push(...(childrenByParent.get(current) ?? []));
1794
+ }
1795
+ return output;
1796
+ }
1797
+ function candidateDepth(threadId, parentByChild) {
1798
+ let depth = 0;
1799
+ let current = threadId;
1800
+ const seen = new Set();
1801
+ while (parentByChild.has(current) && !seen.has(current)) {
1802
+ seen.add(current);
1803
+ const parent = parentByChild.get(current);
1804
+ if (!parent)
1805
+ break;
1806
+ depth += 1;
1807
+ current = parent;
1808
+ }
1809
+ return depth;
1810
+ }
1811
+ function hasSelectedCandidateAncestor(threadId, parentByChild, selectedCallSet) {
1812
+ let current = threadId;
1813
+ const seen = new Set();
1814
+ while (parentByChild.has(current) && !seen.has(current)) {
1815
+ seen.add(current);
1816
+ const parent = parentByChild.get(current);
1817
+ if (!parent)
1818
+ return false;
1819
+ if (selectedCallSet.has(parent))
1820
+ return true;
1821
+ current = parent;
1822
+ }
1823
+ return false;
1824
+ }
1825
+ function threadUpdatedAtMs(row) {
1826
+ return row.updated_at_ms ?? row.updated_at * 1000;
1827
+ }
1828
+ export function rolloutThreadId(file) {
1829
+ const parsed = typeof file === "string" ? path.parse(file) : file;
1830
+ const parts = parsed.name.split("-");
1831
+ if (parts.length < 7)
1832
+ return null;
1833
+ const candidate = parts.slice(-5).join("-");
1834
+ return candidate.length === 36 ? candidate : null;
1835
+ }
1836
+ function normalizePath(file) {
1837
+ const withoutPrefix = file.startsWith("\\\\?\\") ? file.slice(4) : file;
1838
+ return path.resolve(withoutPrefix).toLowerCase();
1839
+ }
1840
+ function assertSafeRolloutMove(codexHome, source, destination) {
1841
+ const sessionsRoot = path.resolve(codexHome, "sessions");
1842
+ const archivedRoot = path.resolve(codexHome, "archived_sessions");
1843
+ const resolvedSource = path.resolve(source);
1844
+ const resolvedDestination = path.resolve(destination);
1845
+ if (!isPathInside(sessionsRoot, resolvedSource)) {
1846
+ throw new Error(`Refusing to move rollout outside sessions: ${source}`);
1847
+ }
1848
+ if (path.dirname(resolvedDestination) !== archivedRoot) {
1849
+ throw new Error(`Refusing to move rollout outside archived_sessions: ${destination}`);
1850
+ }
1851
+ }
1852
+ function isPathInside(root, target) {
1853
+ const relative = path.relative(root, target);
1854
+ return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
1855
+ }
1856
+ function asRecord(value) {
1857
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1858
+ }
1859
+ function asStringArray(value) {
1860
+ return Array.isArray(value) ? value.filter(Boolean).map(String) : [];
1861
+ }
1862
+ function asNumberOrNull(value) {
1863
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1864
+ }
1865
+ function isDefined(value) {
1866
+ return value != null;
1867
+ }
1868
+ function maxNumberOrNull(values) {
1869
+ const finite = values.filter(Number.isFinite);
1870
+ return finite.length ? Math.max(...finite) : null;
1871
+ }
1872
+ function minNumberOrNull(values) {
1873
+ const finite = values.filter(Number.isFinite);
1874
+ return finite.length ? Math.min(...finite) : null;
1875
+ }
1876
+ function recentCutoffMs(days) {
1877
+ return Date.now() - days * 24 * 60 * 60 * 1000;
1878
+ }
1879
+ function logCutoffSeconds(days) {
1880
+ return Math.floor(recentCutoffMs(days) / 1000);
1881
+ }
1882
+ function secondsToIso(seconds) {
1883
+ return seconds == null ? null : new Date(seconds * 1000).toISOString();
1884
+ }
1885
+ function millisToIso(millis) {
1886
+ return millis == null ? null : new Date(millis).toISOString();
1887
+ }
1888
+ function roundMib(bytes) {
1889
+ return Math.round((bytes / 1024 / 1024) * 100) / 100;
1890
+ }
1891
+ export function emitReport(report, asJson) {
1892
+ if (asJson) {
1893
+ console.log(JSON.stringify(report, null, 2));
1894
+ return;
1895
+ }
1896
+ printHumanReport(report);
1897
+ }
1898
+ function printHumanReport(report) {
1899
+ console.log(`codex-cleaner: ${String(report.action ?? "scan")} (${String(report.mode ?? "read-only")})`);
1900
+ console.log(`Codex home: ${String(report.codexHome)}`);
1901
+ console.log(`Generated: ${String(report.generatedAt)}`);
1902
+ if (report.policy)
1903
+ console.log(`Policy: ${JSON.stringify(report.policy)}`);
1904
+ if (String(report.action).startsWith("backups-")) {
1905
+ printBackupsReport(report);
1906
+ return;
1907
+ }
1908
+ if (report.action === "clean" && report.mode === "apply") {
1909
+ printCleanApplySummary(report);
1910
+ return;
1911
+ }
1912
+ if (report.action === "archive-orphan-rollouts") {
1913
+ printOrphanRolloutSummary(report);
1914
+ return;
1915
+ }
1916
+ const files = asRecord(report.files);
1917
+ if (Object.keys(files).length) {
1918
+ console.log("\nFiles:");
1919
+ for (const [name, value] of Object.entries(files)) {
1920
+ const entry = asRecord(value);
1921
+ const main = asRecord(entry.main);
1922
+ const wal = asRecord(entry.wal);
1923
+ if (main.mib !== undefined && wal.mib !== undefined) {
1924
+ console.log(` ${name}: main=${String(main.mib)} MiB wal=${String(wal.mib)} MiB`);
1925
+ }
1926
+ else {
1927
+ console.log(` ${name}: ${String(entry.mib)} MiB`);
1928
+ }
1929
+ }
1930
+ }
1931
+ printDatabaseSpace(report.databaseSpace);
1932
+ printObject("Protection", report.protection);
1933
+ printObject("Threads", report.threads, [
1934
+ "rows",
1935
+ "archived_rows",
1936
+ "title_mib",
1937
+ "preview_mib",
1938
+ "first_user_message_mib",
1939
+ "max_title_chars",
1940
+ "max_preview_chars",
1941
+ "max_first_user_message_chars",
1942
+ ]);
1943
+ printCandidate("Compact candidates", report.compactMetadataCandidates);
1944
+ printCandidate("Compact candidates archived-only", report.compactMetadataCandidatesArchivedOnly);
1945
+ printArchiveCandidate("Stale archive candidates", report.staleArchiveCandidates);
1946
+ printOrphanRolloutArchiveCandidate("Orphan rollout archive candidates", report.orphanRolloutArchiveCandidates);
1947
+ printLogCleanupCandidate("Log cleanup candidates", report.logCleanupCandidates);
1948
+ printTuiLogCleanupCandidate("TUI log cleanup candidate", report.tuiLogCleanupCandidates);
1949
+ if (report.action === "checkpoint-wal") {
1950
+ printWalCheckpoint(report);
1951
+ }
1952
+ else {
1953
+ printCandidate("Before", report.before);
1954
+ printCandidate("After", report.after);
1955
+ printArchiveCandidate("Before", report.before);
1956
+ printArchiveCandidate("After", report.after);
1957
+ printLogCleanupCandidate("Before", report.before);
1958
+ printLogCleanupCandidate("After", report.after);
1959
+ }
1960
+ if (report.changedRows !== undefined)
1961
+ console.log(`\nChanged rows: ${String(report.changedRows)}`);
1962
+ if (report.backupPath !== undefined)
1963
+ console.log(`Backup: ${String(report.backupPath)}`);
1964
+ const rollouts = asRecord(report.rollouts);
1965
+ if (Object.keys(rollouts).length) {
1966
+ console.log("\nRollouts:");
1967
+ for (const key of [
1968
+ "threadRolloutRefs",
1969
+ "diskRolloutFiles",
1970
+ "missingReferencedFiles",
1971
+ "missingActiveReferencedFiles",
1972
+ "missingArchivedReferencedFiles",
1973
+ "sessionRolloutFiles",
1974
+ "archivedRolloutFiles",
1975
+ "orphanFiles",
1976
+ "orphanIndexedFiles",
1977
+ "orphanUnindexedFiles",
1978
+ "orphanSizeMib",
1979
+ "orphanIndexedSizeMib",
1980
+ "orphanUnindexedSizeMib",
1981
+ "sessionOrphanFiles",
1982
+ "sessionOrphanIndexedFiles",
1983
+ "sessionOrphanUnindexedFiles",
1984
+ "sessionOrphanSizeMib",
1985
+ "archivedOrphanFiles",
1986
+ "archivedOrphanSizeMib",
1987
+ ]) {
1988
+ console.log(` ${key}: ${String(rollouts[key])}`);
1989
+ }
1990
+ }
1991
+ }
1992
+ function printCleanApplySummary(report) {
1993
+ const archive = asRecord(report.archive);
1994
+ const orphanRollouts = asRecord(report.orphanRollouts);
1995
+ const compact = asRecord(report.compact);
1996
+ const vacuum = asRecord(report.vacuum);
1997
+ const logs = asRecord(report.logs);
1998
+ const tuiLog = asRecord(report.tuiLog);
1999
+ const checkpoint = asRecord(report.checkpoint);
2000
+ printArchiveApplySummary(archive);
2001
+ printOrphanRolloutSummary(orphanRollouts);
2002
+ printCompactApplySummary(compact);
2003
+ printVacuumSummary("State vacuum", vacuum);
2004
+ printLogsApplySummary(logs);
2005
+ printTuiLogApplySummary(tuiLog);
2006
+ if (Object.keys(checkpoint).length)
2007
+ printWalCheckpoint(checkpoint);
2008
+ printBackupReminder([archive, compact, vacuum, logs, tuiLog]);
2009
+ }
2010
+ function printArchiveApplySummary(report) {
2011
+ if (!Object.keys(report).length)
2012
+ return;
2013
+ const before = asRecord(report.before);
2014
+ const after = asRecord(report.after);
2015
+ const appServer = asRecord(report.appServerResult);
2016
+ console.log("\nArchive apply:");
2017
+ console.log(` archive calls: requested=${String(report.requestedArchiveCalls ?? 0)} succeeded=${String(appServer.succeeded ?? 0)} failed=${String(appServer.failed ?? 0)}`);
2018
+ console.log(` stale rows: before=${String(before.rows ?? 0)} after=${String(after.rows ?? 0)}`);
2019
+ if (Number(appServer.failed ?? 0) > 0) {
2020
+ const errors = Array.isArray(appServer.errors) ? appServer.errors.slice(0, 5) : [];
2021
+ for (const error of errors) {
2022
+ const row = asRecord(error);
2023
+ console.log(` error: ${String(row.threadId)} ${String(row.error)}`);
2024
+ }
2025
+ }
2026
+ if (report.backupPath)
2027
+ console.log(` backup: ${String(report.backupPath)}`);
2028
+ }
2029
+ function printOrphanRolloutSummary(report) {
2030
+ if (!Object.keys(report).length)
2031
+ return;
2032
+ const before = asRecord(report.before);
2033
+ const after = asRecord(report.after);
2034
+ console.log("\nOrphan rollout archive:");
2035
+ console.log(` candidates: ${String(before.files ?? 0)} files (${String(before.size_mib ?? 0)} MiB)`);
2036
+ console.log(` indexed/unindexed: ${String(before.indexed_files ?? 0)}/${String(before.unindexed_files ?? 0)}`);
2037
+ console.log(` skipped recent: ${String(before.skipped_recent_files ?? 0)}`);
2038
+ console.log(` skipped destination exists: ${String(before.skipped_destination_exists_files ?? 0)}`);
2039
+ console.log(` empty dir candidates: ${String(before.empty_dir_candidates ?? 0)}`);
2040
+ if (report.mode === "apply") {
2041
+ console.log(` moved files: ${String(report.movedFiles ?? 0)} (${String(report.movedMib ?? 0)} MiB)`);
2042
+ console.log(` empty dirs removed: ${String(report.prunedEmptyDirs ?? 0)}`);
2043
+ console.log(` remaining candidates: ${String(after.files ?? 0)}`);
2044
+ }
2045
+ if (Number(report.errors?.length ?? 0) > 0) {
2046
+ console.log(` errors: ${String(report.errors.length)}`);
2047
+ }
2048
+ if (report.manifestPath)
2049
+ console.log(` manifest: ${String(report.manifestPath)}`);
2050
+ }
2051
+ function printCompactApplySummary(report) {
2052
+ if (!Object.keys(report).length)
2053
+ return;
2054
+ const before = asRecord(report.before);
2055
+ const after = asRecord(report.after);
2056
+ console.log("\nCompact apply:");
2057
+ console.log(` changed rows: ${String(report.changedRows ?? 0)}`);
2058
+ console.log(` candidates: before=${String(before.rows ?? 0)} after=${String(after.rows ?? 0)}`);
2059
+ console.log(` estimated payload reduction: ${String(before.estimated_savings_mib ?? 0)} MiB`);
2060
+ if (report.backupPath)
2061
+ console.log(` backup: ${String(report.backupPath)}`);
2062
+ }
2063
+ function printVacuumSummary(title, report) {
2064
+ if (!Object.keys(report).length)
2065
+ return;
2066
+ const before = asRecord(report.before);
2067
+ const after = asRecord(report.after);
2068
+ const beforeMain = asRecord(before.main);
2069
+ const afterMain = asRecord(after.main);
2070
+ const beforeSpace = asRecord(report.beforeSpace);
2071
+ const afterSpace = asRecord(report.afterSpace);
2072
+ console.log(`\n${title}:`);
2073
+ console.log(` main file: ${String(beforeMain.mib ?? 0)} MiB -> ${String(afterMain.mib ?? 0)} MiB`);
2074
+ console.log(` freelist: ${String(beforeSpace.free_mib ?? 0)} MiB -> ${String(afterSpace.free_mib ?? 0)} MiB`);
2075
+ if (report.backupPath)
2076
+ console.log(` backup: ${String(report.backupPath)}`);
2077
+ }
2078
+ function printLogsApplySummary(report) {
2079
+ if (!Object.keys(report).length)
2080
+ return;
2081
+ const before = asRecord(report.before);
2082
+ const after = asRecord(report.after);
2083
+ const beforeFiles = asRecord(report.beforeFiles);
2084
+ const afterFiles = asRecord(report.afterFiles);
2085
+ const beforeMain = asRecord(beforeFiles.main);
2086
+ const afterMain = asRecord(afterFiles.main);
2087
+ const beforeSpace = asRecord(report.beforeSpace);
2088
+ const afterSpace = asRecord(report.afterSpace);
2089
+ console.log("\nLogs cleanup:");
2090
+ console.log(` deleted rows: ${String(report.deletedRows ?? 0)}`);
2091
+ console.log(` capped rows: ${String(report.cappedRows ?? 0)}`);
2092
+ console.log(` remaining cleanup candidates: delete=${String(after.delete_rows ?? 0)} cap=${String(after.cap_rows ?? 0)}`);
2093
+ console.log(` estimated savings before apply: delete=${String(before.delete_estimated_payload_mib ?? 0)} MiB cap=${String(before.cap_estimated_savings_mib ?? 0)} MiB`);
2094
+ console.log(` logs file: ${String(beforeMain.mib ?? 0)} MiB -> ${String(afterMain.mib ?? 0)} MiB`);
2095
+ console.log(` logs freelist: ${String(beforeSpace.free_mib ?? 0)} MiB -> ${String(afterSpace.free_mib ?? 0)} MiB`);
2096
+ if (report.backupPath)
2097
+ console.log(` backup: ${String(report.backupPath)}`);
2098
+ }
2099
+ function printTuiLogApplySummary(report) {
2100
+ if (!Object.keys(report).length)
2101
+ return;
2102
+ const before = asRecord(report.before);
2103
+ const after = asRecord(report.after);
2104
+ console.log("\nTUI log cleanup:");
2105
+ console.log(` file: ${String(before.current_mib ?? 0)} MiB -> ${String(after.current_mib ?? 0)} MiB`);
2106
+ console.log(` truncated: ${String(report.truncatedMib ?? 0)} MiB`);
2107
+ if (report.backupPath)
2108
+ console.log(` backup: ${String(report.backupPath)}`);
2109
+ }
2110
+ function printWalCheckpoint(report) {
2111
+ const before = asRecord(report.before);
2112
+ const after = asRecord(report.after);
2113
+ const beforeWal = asRecord(before.wal);
2114
+ const afterWal = asRecord(after.wal);
2115
+ console.log("\nWAL checkpoint:");
2116
+ console.log(` wal: ${String(beforeWal.mib ?? 0)} MiB -> ${String(afterWal.mib ?? 0)} MiB`);
2117
+ console.log(` checkpointResult: ${JSON.stringify(report.checkpointResult)}`);
2118
+ }
2119
+ function printBackupReminder(reports) {
2120
+ const backupPaths = reports.map((report) => report.backupPath).filter((value) => typeof value === "string");
2121
+ if (!backupPaths.length)
2122
+ return;
2123
+ console.log(`\nBackups: ${path.dirname(String(backupPaths[0]))}`);
2124
+ console.log(" Keep them until Codex looks right, then delete them to reclaim disk.");
2125
+ }
2126
+ function printBackupsReport(report) {
2127
+ console.log(`Backup dir: ${String(report.backupDir)}`);
2128
+ if (report.action === "backups-schedule-prune") {
2129
+ console.log(`Scheduler: ${String(report.scheduler)}`);
2130
+ console.log(`Scheduled: ${String(report.scheduled)}`);
2131
+ console.log(`Command: ${String(report.command)}`);
2132
+ if (report.taskName)
2133
+ console.log(`Task: ${String(report.taskName)}`);
2134
+ if (report.jobId)
2135
+ console.log(`Job: ${String(report.jobId)}`);
2136
+ if (report.cancelCommand)
2137
+ console.log(`Cancel: ${String(report.cancelCommand)}`);
2138
+ return;
2139
+ }
2140
+ const files = asRecord(report.files ?? report.before);
2141
+ const candidates = asRecord(report.pruneCandidates ?? report.candidates);
2142
+ console.log("\nBackups:");
2143
+ console.log(` files: ${String(files.count ?? 0)}`);
2144
+ console.log(` size: ${String(files.totalMib ?? 0)} MiB`);
2145
+ console.log(` oldest: ${String(files.oldestUtc ?? null)}`);
2146
+ console.log(` newest: ${String(files.newestUtc ?? null)}`);
2147
+ console.log("\nPrune candidates:");
2148
+ console.log(` files: ${String(candidates.count ?? 0)}`);
2149
+ console.log(` size: ${String(candidates.totalMib ?? 0)} MiB`);
2150
+ if (report.action === "backups-prune") {
2151
+ const deleted = asRecord(report.deleted);
2152
+ console.log("\nDeleted:");
2153
+ console.log(` files: ${String(deleted.count ?? 0)}`);
2154
+ console.log(` size: ${String(deleted.totalMib ?? 0)} MiB`);
2155
+ }
2156
+ }
2157
+ function printDatabaseSpace(value) {
2158
+ const databases = asRecord(value);
2159
+ if (!Object.keys(databases).length)
2160
+ return;
2161
+ console.log("\nSQLite space:");
2162
+ for (const [name, stats] of Object.entries(databases)) {
2163
+ const record = asRecord(stats);
2164
+ console.log(` ${name}: total=${String(record.total_mib ?? 0)} MiB used=${String(record.used_mib ?? 0)} MiB free=${String(record.free_mib ?? 0)} MiB`);
2165
+ }
2166
+ }
2167
+ function printObject(title, value, keys) {
2168
+ const object = asRecord(value);
2169
+ if (!Object.keys(object).length)
2170
+ return;
2171
+ console.log(`\n${title}:`);
2172
+ for (const key of keys ?? Object.keys(object)) {
2173
+ if (object[key] === undefined)
2174
+ continue;
2175
+ console.log(` ${key}: ${JSON.stringify(object[key])}`);
2176
+ }
2177
+ }
2178
+ function printCandidate(title, value) {
2179
+ const object = asRecord(value);
2180
+ if (!Object.keys(object).length)
2181
+ return;
2182
+ console.log(`\n${title}:`);
2183
+ for (const key of [
2184
+ "rows",
2185
+ "estimated_savings_mib",
2186
+ "max_field_chars",
2187
+ "oldestCandidateUpdatedUtc",
2188
+ "newestCandidateUpdatedUtc",
2189
+ ]) {
2190
+ console.log(` ${key}: ${JSON.stringify(object[key])}`);
2191
+ }
2192
+ }
2193
+ function printArchiveCandidate(title, value) {
2194
+ const object = asRecord(value);
2195
+ if (!Object.keys(object).length)
2196
+ return;
2197
+ console.log(`\n${title}:`);
2198
+ for (const key of [
2199
+ "rows",
2200
+ "archive_call_rows",
2201
+ "expected_archived_rows",
2202
+ "rollout_size_mib",
2203
+ "expected_archived_rollout_size_mib",
2204
+ "missing_rollout_files",
2205
+ "blocked_by_descendant_safety",
2206
+ "blocked_by_missing_subtree_files",
2207
+ "oldestCandidateUpdatedUtc",
2208
+ "newestCandidateUpdatedUtc",
2209
+ ]) {
2210
+ if (object[key] !== undefined) {
2211
+ const value = object[key] === null && (key.includes("size_mib") || key.includes("rollout"))
2212
+ ? '"not scanned"'
2213
+ : JSON.stringify(object[key]);
2214
+ console.log(` ${key}: ${value}`);
2215
+ }
2216
+ }
2217
+ }
2218
+ function printOrphanRolloutArchiveCandidate(title, value) {
2219
+ const object = asRecord(value);
2220
+ if (!Object.keys(object).length)
2221
+ return;
2222
+ console.log(`\n${title}:`);
2223
+ for (const key of [
2224
+ "files",
2225
+ "size_mib",
2226
+ "indexed_files",
2227
+ "unindexed_files",
2228
+ "empty_dir_candidates",
2229
+ "skipped_recent_files",
2230
+ "skipped_session_indexed_files",
2231
+ "skipped_destination_exists_files",
2232
+ "oldest_candidate_modified_utc",
2233
+ "newest_candidate_modified_utc",
2234
+ ]) {
2235
+ if (object[key] !== undefined)
2236
+ console.log(` ${key}: ${JSON.stringify(object[key])}`);
2237
+ }
2238
+ }
2239
+ function printLogCleanupCandidate(title, value) {
2240
+ const object = asRecord(value);
2241
+ if (!Object.keys(object).length)
2242
+ return;
2243
+ console.log(`\n${title}:`);
2244
+ for (const key of [
2245
+ "rows",
2246
+ "delete_rows",
2247
+ "delete_estimated_payload_mib",
2248
+ "cap_rows",
2249
+ "cap_estimated_savings_mib",
2250
+ "cutoffUtc",
2251
+ ]) {
2252
+ if (object[key] !== undefined)
2253
+ console.log(` ${key}: ${JSON.stringify(object[key])}`);
2254
+ }
2255
+ }
2256
+ function printTuiLogCleanupCandidate(title, value) {
2257
+ const object = asRecord(value);
2258
+ if (!Object.keys(object).length)
2259
+ return;
2260
+ console.log(`\n${title}:`);
2261
+ for (const key of ["exists", "current_mib", "keep_mib", "reclaimable_mib", "path"]) {
2262
+ if (object[key] !== undefined)
2263
+ console.log(` ${key}: ${JSON.stringify(object[key])}`);
2264
+ }
2265
+ }