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.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildScanReport, archiveOrphanRollouts, checkpointWal, cleanCodex, compactMetadata, emitReport, pruneBackups, requireStoppedOrReadonlyAllowed, scanBackups, scheduleBackupPrune, } from "./cleaner.js";
4
+ import { runWizard } from "./wizard.js";
5
+ const USAGE = `
6
+ codex-cleaner [command] [options]
7
+
8
+ Commands:
9
+ (none) Guided TUI: choose settings, dry-run, then optionally apply
10
+ clean Unified dry-run/apply for metadata compaction and stale thread archiving
11
+ scan Read-only size, protection, candidate, and optional rollout-linkage report
12
+ compact-metadata Cap old threads.title/preview/first_user_message values
13
+ checkpoint-wal Run PRAGMA wal_checkpoint(TRUNCATE) for state_5.sqlite
14
+ archive-orphan-rollouts
15
+ Move old DB-unreferenced sessions JSONL into archived_sessions
16
+ backups scan Inspect codex-cleaner backup files
17
+ backups prune Delete codex-cleaner backup files after a dry-run
18
+ backups schedule-prune
19
+ Schedule a one-shot future backups prune job
20
+
21
+ Options:
22
+ --codex-home <path> Codex home path; defaults to CODEX_HOME or ~/.codex
23
+ --allow-running-readonly Allow read-only dry-runs while Codex processes are active
24
+ --allow-running-orphan-rollout-archive
25
+ Allow archive-orphan-rollouts --apply while Codex is active
26
+ --skip-archive-stale Do not include stale thread archiving in clean/TUI flow
27
+ --archive-orphan-rollouts clean: move old DB-unreferenced sessions JSONL into archived_sessions
28
+ --compact-recent-metadata Also cap recent unprotected metadata; pinned/open/active stay protected
29
+ --include-logs scan: include expensive logs_2.sqlite table stats
30
+ --include-rollouts scan: include sessions/archived_sessions linkage scan
31
+ --prune-logs clean: prune/cap logs_2.sqlite and vacuum it
32
+ --prune-tui-log clean: back up and truncate log/codex-tui.log
33
+ --keep-log-days <n> log rows to keep when --prune-logs is used; default 7
34
+ --keep-tui-log-mib <n> codex-tui.log tail to retain with --prune-tui-log; default 16
35
+ --older-than-hours <n> backups prune age threshold; default 48
36
+ --after-hours <n> backups schedule-prune delay; default 48
37
+ --max-log-body-chars <n> log feedback_log_body cap; default 4096
38
+ --max-chars <n> compact cap; default 1024
39
+ --keep-recent-days <n> protect recently updated threads; default 14
40
+ --archived-only compact only archived threads
41
+ --apply write changes; omitted means dry-run
42
+ --backup-dir <path> backup destination for mutating commands
43
+ --codex-command <cmd> codex executable/npm shim; arbitrary Windows batch wrappers are rejected
44
+ --confirm-archive-stale required with clean --apply when archiving stale threads
45
+ --confirm-archive-orphan-rollouts
46
+ required with clean --apply --archive-orphan-rollouts
47
+ --confirm-lossy-metadata required with compact-metadata --apply
48
+ --confirm-prune-logs required with clean --apply --prune-logs
49
+ --confirm-prune-tui-log required with clean --apply --prune-tui-log
50
+ --confirm-delete-backups required with backups prune --apply
51
+ --confirm-schedule-backup-prune required with backups schedule-prune --apply
52
+ --json emit JSON
53
+ --help show help
54
+ `;
55
+ const COMMANDS = ["scan", "clean", "compact-metadata", "checkpoint-wal", "archive-orphan-rollouts", "backups"];
56
+ const BACKUP_COMMANDS = ["scan", "prune", "schedule-prune"];
57
+ async function main(argv = process.argv.slice(2)) {
58
+ const parsed = parseArgs({
59
+ args: argv,
60
+ allowPositionals: true,
61
+ options: {
62
+ "allow-running-readonly": { type: "boolean", default: false },
63
+ "allow-running-orphan-rollout-archive": { type: "boolean", default: false },
64
+ "after-hours": { type: "string", default: "48" },
65
+ apply: { type: "boolean", default: false },
66
+ "archive-orphan-rollouts": { type: "boolean", default: false },
67
+ "archived-only": { type: "boolean", default: false },
68
+ "backup-dir": { type: "string" },
69
+ "codex-command": { type: "string" },
70
+ "codex-home": { type: "string" },
71
+ "compact-recent-metadata": { type: "boolean", default: false },
72
+ "confirm-archive-stale": { type: "boolean", default: false },
73
+ "confirm-archive-orphan-rollouts": { type: "boolean", default: false },
74
+ "confirm-delete-backups": { type: "boolean", default: false },
75
+ "confirm-lossy-metadata": { type: "boolean", default: false },
76
+ "confirm-prune-logs": { type: "boolean", default: false },
77
+ "confirm-prune-tui-log": { type: "boolean", default: false },
78
+ "confirm-schedule-backup-prune": { type: "boolean", default: false },
79
+ help: { type: "boolean", short: "h", default: false },
80
+ "include-rollouts": { type: "boolean", default: false },
81
+ "include-logs": { type: "boolean", default: false },
82
+ json: { type: "boolean", default: false },
83
+ "keep-log-days": { type: "string", default: "7" },
84
+ "keep-recent-days": { type: "string", default: "14" },
85
+ "keep-tui-log-mib": { type: "string", default: "16" },
86
+ "max-log-body-chars": { type: "string", default: "4096" },
87
+ "max-chars": { type: "string", default: "1024" },
88
+ "older-than-hours": { type: "string", default: "48" },
89
+ "prune-logs": { type: "boolean", default: false },
90
+ "prune-tui-log": { type: "boolean", default: false },
91
+ "skip-archive-stale": { type: "boolean", default: false },
92
+ },
93
+ });
94
+ if (parsed.values.help) {
95
+ console.log(USAGE.trim());
96
+ return 0;
97
+ }
98
+ const command = parsed.positionals[0];
99
+ if (command && !COMMANDS.includes(command)) {
100
+ console.error(USAGE.trim());
101
+ return 2;
102
+ }
103
+ const options = {
104
+ allowRunningReadonly: Boolean(parsed.values["allow-running-readonly"]),
105
+ allowRunningOrphanRolloutArchive: Boolean(parsed.values["allow-running-orphan-rollout-archive"]),
106
+ afterHours: parsePositiveInt(String(parsed.values["after-hours"]), "--after-hours"),
107
+ archiveOrphanRollouts: Boolean(parsed.values["archive-orphan-rollouts"]),
108
+ archiveStale: !parsed.values["skip-archive-stale"],
109
+ apply: Boolean(parsed.values.apply),
110
+ archivedOnly: Boolean(parsed.values["archived-only"]),
111
+ backupDir: parsed.values["backup-dir"],
112
+ codexCommand: parsed.values["codex-command"],
113
+ codexHome: parsed.values["codex-home"],
114
+ compactRecentMetadata: Boolean(parsed.values["compact-recent-metadata"]),
115
+ confirmArchiveStale: Boolean(parsed.values["confirm-archive-stale"]),
116
+ confirmArchiveOrphanRollouts: Boolean(parsed.values["confirm-archive-orphan-rollouts"]),
117
+ confirmDeleteBackups: Boolean(parsed.values["confirm-delete-backups"]),
118
+ confirmLossyMetadata: Boolean(parsed.values["confirm-lossy-metadata"]),
119
+ confirmPruneLogs: Boolean(parsed.values["confirm-prune-logs"]),
120
+ confirmPruneTuiLog: Boolean(parsed.values["confirm-prune-tui-log"]),
121
+ confirmScheduleBackupPrune: Boolean(parsed.values["confirm-schedule-backup-prune"]),
122
+ includeLogs: Boolean(parsed.values["include-logs"]),
123
+ includeRollouts: Boolean(parsed.values["include-rollouts"]),
124
+ json: Boolean(parsed.values.json),
125
+ keepLogDays: parsePositiveInt(String(parsed.values["keep-log-days"]), "--keep-log-days"),
126
+ keepRecentDays: parsePositiveInt(String(parsed.values["keep-recent-days"]), "--keep-recent-days"),
127
+ keepTuiLogMib: parsePositiveInt(String(parsed.values["keep-tui-log-mib"]), "--keep-tui-log-mib"),
128
+ maxLogBodyChars: parsePositiveInt(String(parsed.values["max-log-body-chars"]), "--max-log-body-chars"),
129
+ maxChars: parsePositiveInt(String(parsed.values["max-chars"]), "--max-chars"),
130
+ olderThanHours: parsePositiveInt(String(parsed.values["older-than-hours"]), "--older-than-hours"),
131
+ pruneLogs: Boolean(parsed.values["prune-logs"]),
132
+ pruneTuiLog: Boolean(parsed.values["prune-tui-log"]),
133
+ };
134
+ if (!command) {
135
+ return runWizard(options);
136
+ }
137
+ if (command !== "backups") {
138
+ const mutating = isMutating(command, options);
139
+ if (!allowsRunningFileOnlyMutation(command, options)) {
140
+ await requireStoppedOrReadonlyAllowed({
141
+ allowRunningReadonly: options.allowRunningReadonly,
142
+ mutating,
143
+ });
144
+ }
145
+ }
146
+ if (command === "backups") {
147
+ const backupCommand = parseBackupCommand(parsed.positionals[1]);
148
+ const report = backupCommand === "scan"
149
+ ? scanBackups(options)
150
+ : backupCommand === "prune"
151
+ ? pruneBackups(options)
152
+ : await scheduleBackupPrune(options);
153
+ emitReport(report, options.json);
154
+ return 0;
155
+ }
156
+ const report = command === "scan"
157
+ ? buildScanReport(options)
158
+ : command === "clean"
159
+ ? await cleanCodex(options)
160
+ : command === "compact-metadata"
161
+ ? await compactMetadata(options)
162
+ : command === "archive-orphan-rollouts"
163
+ ? archiveOrphanRollouts(options)
164
+ : await checkpointWal(options);
165
+ emitReport(report, options.json);
166
+ return 0;
167
+ }
168
+ function parseBackupCommand(value) {
169
+ const command = (value ?? "scan");
170
+ if (!BACKUP_COMMANDS.includes(command)) {
171
+ throw new Error(`Unknown backups command: ${String(value)}\n${USAGE.trim()}`);
172
+ }
173
+ return command;
174
+ }
175
+ function isMutating(command, options) {
176
+ return command !== "scan" && options.apply;
177
+ }
178
+ function allowsRunningFileOnlyMutation(command, options) {
179
+ return command === "archive-orphan-rollouts" && options.apply && options.allowRunningOrphanRolloutArchive;
180
+ }
181
+ function parsePositiveInt(raw, name) {
182
+ const value = Number(raw);
183
+ if (!Number.isInteger(value) || value <= 0) {
184
+ throw new Error(`${name} must be a positive integer`);
185
+ }
186
+ return value;
187
+ }
188
+ main().then((code) => {
189
+ process.exitCode = code;
190
+ }, (error) => {
191
+ console.error(error instanceof Error ? error.message : String(error));
192
+ process.exitCode = 1;
193
+ });
@@ -0,0 +1,47 @@
1
+ export type BackupCommand = "scan" | "prune" | "schedule-prune";
2
+ export type CleanerCommand = "scan" | "clean" | "compact-metadata" | "checkpoint-wal" | "archive-orphan-rollouts" | "backups";
3
+ export type CleanerOptions = {
4
+ allowRunningOrphanRolloutArchive: boolean;
5
+ allowRunningReadonly: boolean;
6
+ afterHours: number;
7
+ archiveOrphanRollouts: boolean;
8
+ archiveStale: boolean;
9
+ apply: boolean;
10
+ archivedOnly: boolean;
11
+ backupDir?: string;
12
+ codexCommand?: string;
13
+ codexHome?: string;
14
+ compactRecentMetadata: boolean;
15
+ confirmArchiveStale: boolean;
16
+ confirmArchiveOrphanRollouts: boolean;
17
+ confirmDeleteBackups: boolean;
18
+ confirmLossyMetadata: boolean;
19
+ confirmPruneLogs: boolean;
20
+ confirmPruneTuiLog: boolean;
21
+ confirmScheduleBackupPrune: boolean;
22
+ includeLogs: boolean;
23
+ includeRollouts: boolean;
24
+ json: boolean;
25
+ keepLogDays: number;
26
+ keepRecentDays: number;
27
+ keepTuiLogMib: number;
28
+ maxLogBodyChars: number;
29
+ maxChars: number;
30
+ olderThanHours: number;
31
+ pruneLogs: boolean;
32
+ pruneTuiLog: boolean;
33
+ };
34
+ export type BlockingProcess = {
35
+ pid: number;
36
+ name: string;
37
+ commandLine: string;
38
+ };
39
+ export type ThreadProtection = {
40
+ pinnedIds: Set<string>;
41
+ heartbeatIds: Set<string>;
42
+ activeGoalIds: Set<string>;
43
+ };
44
+ export type CompactWhere = {
45
+ sql: string;
46
+ params: Record<string, string | number>;
47
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { CleanerOptions } from "./types.js";
2
+ export declare function runWizard(options: CleanerOptions): Promise<number>;
package/dist/wizard.js ADDED
@@ -0,0 +1,412 @@
1
+ import { confirm, input, select } from "@inquirer/prompts";
2
+ import path from "node:path";
3
+ import pc from "picocolors";
4
+ import { buildScanReport, cleanCodex, findBlockingProcesses, requireStoppedOrReadonlyAllowed, scheduleBackupPrune, } from "./cleaner.js";
5
+ export async function runWizard(options) {
6
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
+ throw new Error("The guided TUI needs an interactive terminal. Use `codex-cleaner scan` for noninteractive runs.");
8
+ }
9
+ printIntro();
10
+ const initialBlockers = await findBlockingProcesses();
11
+ if (initialBlockers.length) {
12
+ console.log(pc.yellow(`Codex appears to be running (${initialBlockers.length} matching processes).`));
13
+ console.log(pc.dim("This wizard can still dry-run, but apply will be disabled until Codex is fully closed.\n"));
14
+ }
15
+ const wizardMode = await askWizardMode();
16
+ const keepRecentDays = wizardMode === "recommended" ? options.keepRecentDays : await askKeepRecentDays(options.keepRecentDays);
17
+ const maxChars = wizardMode === "recommended" ? options.maxChars : await askMaxChars(options.maxChars);
18
+ const compactRecentMetadata = wizardMode === "recommended" ? false : await askCompactRecentMetadata();
19
+ const archiveStale = wizardMode === "recommended" ? true : await askArchiveStale();
20
+ const archiveOrphanRollouts = wizardMode === "recommended" ? true : await askArchiveOrphanRollouts();
21
+ const pruneLogs = wizardMode === "recommended" ? true : await askPruneLogs();
22
+ const pruneTuiLog = wizardMode === "recommended" ? true : await askPruneTuiLog();
23
+ const includeRollouts = wizardMode === "recommended"
24
+ ? false
25
+ : await confirm({
26
+ default: false,
27
+ message: "Also scan orphan rollout files? This is slower and stays dry-run only.",
28
+ });
29
+ const runDryRun = await confirm({
30
+ default: true,
31
+ message: "Run the dry-run scan now?",
32
+ });
33
+ const dryRunOptions = {
34
+ ...options,
35
+ allowRunningReadonly: true,
36
+ archiveOrphanRollouts,
37
+ archiveStale,
38
+ apply: false,
39
+ archivedOnly: false,
40
+ compactRecentMetadata,
41
+ confirmArchiveStale: false,
42
+ confirmArchiveOrphanRollouts: false,
43
+ confirmLossyMetadata: false,
44
+ confirmPruneLogs: false,
45
+ confirmPruneTuiLog: false,
46
+ includeLogs: false,
47
+ includeRollouts,
48
+ pruneLogs,
49
+ pruneTuiLog,
50
+ keepRecentDays,
51
+ maxChars,
52
+ json: false,
53
+ };
54
+ if (!runDryRun) {
55
+ printDryRunCommand(dryRunOptions);
56
+ return 0;
57
+ }
58
+ console.log(pc.dim("\nScanning Codex metadata..."));
59
+ const report = buildScanReport(dryRunOptions);
60
+ printDryRunSummary(report);
61
+ const compactRows = numberAt(report, "compactMetadataCandidates", "rows");
62
+ const archiveRows = numberAt(report, "staleArchiveCandidates", "archive_call_rows");
63
+ const orphanRolloutRows = numberAt(report, "orphanRolloutArchiveCandidates", "files");
64
+ const logRows = numberAt(report, "logCleanupCandidates", "delete_rows") + numberAt(report, "logCleanupCandidates", "cap_rows");
65
+ const tuiLogMib = numberAt(report, "tuiLogCleanupCandidates", "reclaimable_mib");
66
+ const vacuumMib = numberAt(report, "databaseSpace", "state_5.sqlite", "free_mib");
67
+ if (!compactRows && !archiveRows && !orphanRolloutRows && !logRows && !tuiLogMib && vacuumMib < 1) {
68
+ console.log(pc.green("\nNo cleanup candidates found under this policy."));
69
+ return 0;
70
+ }
71
+ const proceed = await waitForApplyConfirmation();
72
+ if (!proceed) {
73
+ console.log(pc.dim("\nNo changes made. To apply later, run:"));
74
+ printApplyCommand(dryRunOptions);
75
+ return 0;
76
+ }
77
+ await requireStoppedOrReadonlyAllowed({ allowRunningReadonly: false, mutating: true });
78
+ console.log(pc.dim("\nApplying cleanup..."));
79
+ const applyReport = await cleanCodex({
80
+ ...dryRunOptions,
81
+ apply: true,
82
+ confirmArchiveStale: true,
83
+ confirmArchiveOrphanRollouts: true,
84
+ confirmLossyMetadata: true,
85
+ confirmPruneLogs: true,
86
+ confirmPruneTuiLog: true,
87
+ });
88
+ printApplySummary(recordAt(applyReport, "compact"), nullableRecordAt(applyReport, "archive"), nullableRecordAt(applyReport, "orphanRollouts"), recordAt(applyReport, "vacuum"), nullableRecordAt(applyReport, "logs"), nullableRecordAt(applyReport, "tuiLog"), recordAt(applyReport, "checkpoint"));
89
+ await offerScheduledBackupCleanup(dryRunOptions, applyReport);
90
+ return 0;
91
+ }
92
+ async function waitForApplyConfirmation() {
93
+ while (true) {
94
+ const applyNow = await confirm({
95
+ default: false,
96
+ message: "Apply this cleanup now? Backups will be created first.",
97
+ });
98
+ if (!applyNow)
99
+ return false;
100
+ const blockers = await findBlockingProcesses();
101
+ if (!blockers.length)
102
+ return true;
103
+ console.log(pc.yellow(`\nCodex is still running (${blockers.length} matching processes). No changes were applied.`));
104
+ console.log(pc.dim("Close Codex completely before applying DB or WAL cleanup."));
105
+ const retry = await confirm({
106
+ default: true,
107
+ message: "Check again after closing Codex?",
108
+ });
109
+ if (!retry)
110
+ return false;
111
+ }
112
+ }
113
+ async function askWizardMode() {
114
+ return select({
115
+ choices: [
116
+ {
117
+ description: "Use the normal safe defaults, then dry-run before apply.",
118
+ name: "Run recommended cleanup",
119
+ value: "recommended",
120
+ },
121
+ {
122
+ description: "Choose retention windows, metadata cap, log cleanup, and archive behavior.",
123
+ name: "Customize settings",
124
+ value: "custom",
125
+ },
126
+ ],
127
+ default: "recommended",
128
+ message: "How do you want to run codex-cleaner?",
129
+ });
130
+ }
131
+ async function askKeepRecentDays(currentDefault) {
132
+ console.log(pc.bold("\nRecent thread protection"));
133
+ console.log(pc.dim("Threads updated inside this window are always kept untouched. Pinned, heartbeat/app-permission, and active-goal threads are also protected."));
134
+ const choices = [
135
+ { name: "14 days (recommended)", value: 14 },
136
+ { name: "30 days", value: 30 },
137
+ { name: "7 days", value: 7 },
138
+ { name: `Current flag default (${currentDefault} days)`, value: currentDefault },
139
+ { name: "Custom", value: "custom" },
140
+ ];
141
+ const selected = await select({
142
+ choices: dedupeChoiceValues(choices),
143
+ default: choices.some((choice) => choice.value === currentDefault) ? currentDefault : 14,
144
+ message: "How far back should active/recent threads be protected?",
145
+ });
146
+ if (selected !== "custom")
147
+ return selected;
148
+ return askPositiveInteger("Days to protect", String(currentDefault));
149
+ }
150
+ async function askMaxChars(currentDefault) {
151
+ console.log(pc.bold("\nMetadata cap"));
152
+ console.log(pc.dim("This caps only old thread display/search metadata: title, preview, first_user_message. It does not delete threads or rollout JSONL used by CodexMeter."));
153
+ const choices = [
154
+ { name: "1024 chars (recommended)", value: 1024 },
155
+ { name: "2048 chars", value: 2048 },
156
+ { name: "4096 chars", value: 4096 },
157
+ { name: `Current flag default (${currentDefault} chars)`, value: currentDefault },
158
+ { name: "Custom", value: "custom" },
159
+ ];
160
+ const selected = await select({
161
+ choices: dedupeChoiceValues(choices),
162
+ default: choices.some((choice) => choice.value === currentDefault) ? currentDefault : 1024,
163
+ message: "How much old metadata should each field keep?",
164
+ });
165
+ if (selected !== "custom")
166
+ return selected;
167
+ return askPositiveInteger("Characters to keep per metadata field", String(currentDefault));
168
+ }
169
+ async function askCompactRecentMetadata() {
170
+ console.log(pc.bold("\nRecent metadata compaction"));
171
+ console.log(pc.dim("This also caps recent unprotected thread display/search metadata. Pinned, app/heartbeat, and active-goal threads still stay untouched, and rollout JSONL context is not changed."));
172
+ return confirm({
173
+ default: false,
174
+ message: "Also compact recent unprotected metadata?",
175
+ });
176
+ }
177
+ async function askArchiveStale() {
178
+ console.log(pc.bold("\nStale thread archiving"));
179
+ console.log(pc.dim("Old unpinned threads outside the recent window can be moved from sessions to archived_sessions using Codex's own archive API. Rollout JSONL is retained for history and CodexMeter."));
180
+ return confirm({
181
+ default: true,
182
+ message: "Include stale thread archiving in this cleanup?",
183
+ });
184
+ }
185
+ async function askArchiveOrphanRollouts() {
186
+ console.log(pc.bold("\nOrphan rollout archiving"));
187
+ console.log(pc.dim("Old rollout JSONL files in sessions that are not referenced by SQLite can be moved to archived_sessions. This keeps active session scans lean and does not delete the files."));
188
+ return confirm({
189
+ default: true,
190
+ message: "Move old DB-unreferenced rollout files out of sessions?",
191
+ });
192
+ }
193
+ async function askPruneLogs() {
194
+ console.log(pc.bold("\nLog cleanup"));
195
+ console.log(pc.dim("This prunes old logs_2.sqlite rows and caps giant feedback_log_body payloads. It does not touch threads or rollout JSONL."));
196
+ return confirm({
197
+ default: true,
198
+ message: "Include logs_2.sqlite cleanup in this cleanup?",
199
+ });
200
+ }
201
+ async function askPruneTuiLog() {
202
+ console.log(pc.bold("\nTUI log file cleanup"));
203
+ console.log(pc.dim("This backs up log/codex-tui.log, then keeps only the newest log tail. It does not touch SQLite, threads, or rollout JSONL."));
204
+ return confirm({
205
+ default: true,
206
+ message: "Trim codex-tui.log to the newest log tail?",
207
+ });
208
+ }
209
+ async function askPositiveInteger(message, defaultValue) {
210
+ const value = await input({
211
+ default: defaultValue,
212
+ message,
213
+ validate: (raw) => {
214
+ const parsed = Number(raw);
215
+ return Number.isInteger(parsed) && parsed > 0 ? true : "Enter a positive integer.";
216
+ },
217
+ });
218
+ return Number(value);
219
+ }
220
+ function printIntro() {
221
+ console.log(pc.bold("codex-cleaner"));
222
+ console.log("Guided dry-run first. Apply only after you see exactly what would change.\n");
223
+ }
224
+ function printDryRunSummary(report) {
225
+ console.log(pc.bold("\nDry-run summary"));
226
+ const policy = recordAt(report, "policy");
227
+ const files = recordAt(report, "files");
228
+ const stateFile = recordAt(files, "state_5.sqlite");
229
+ const stateMain = recordAt(stateFile, "main");
230
+ const stateWal = recordAt(stateFile, "wal");
231
+ const protection = recordAt(report, "protection");
232
+ const threads = recordAt(report, "threads");
233
+ const candidates = recordAt(report, "compactMetadataCandidates");
234
+ console.log(` Codex home: ${String(report.codexHome)}`);
235
+ console.log(` Recent window: ${String(policy.keepRecentDays)} days`);
236
+ console.log(` Metadata cap: ${String(policy.maxChars)} chars`);
237
+ console.log(` Compact recent metadata: ${policy.compactRecentMetadata ? "yes" : "no"}`);
238
+ console.log(` state_5.sqlite: ${formatMib(stateMain.mib)} main, ${formatMib(stateWal.mib)} WAL`);
239
+ console.log(` Protected threads: ${String(protection.totalUniqueProtectedThreads)} (${String(protection.pinnedThreads)} pinned, ${String(protection.heartbeatThreads)} app/heartbeat, ${String(protection.activeGoalThreads)} active goals)`);
240
+ console.log(` Thread rows: ${String(threads.rows)}`);
241
+ console.log(pc.green(` Recommended metadata compaction: ${String(candidates.rows)} rows, about ${formatMib(candidates.estimated_savings_mib)} old metadata payload reduction`));
242
+ const archive = recordAt(report, "staleArchiveCandidates");
243
+ if (Object.keys(archive).length) {
244
+ console.log(pc.green(` Stale archiving: ${String(archive.expected_archived_rows)} threads via ${String(archive.archive_call_rows)} Codex archive calls, moving about ${formatMib(archive.expected_archived_rollout_size_mib)} of rollout JSONL`));
245
+ if (Number(archive.blocked_by_descendant_safety) > 0) {
246
+ console.log(pc.yellow(` Skipped ${String(archive.blocked_by_descendant_safety)} stale roots because their spawned descendants are still recent or protected.`));
247
+ }
248
+ }
249
+ const orphanRollouts = recordAt(report, "orphanRolloutArchiveCandidates");
250
+ if (Object.keys(orphanRollouts).length) {
251
+ console.log(pc.green(` Orphan rollout archiving: move ${String(orphanRollouts.files)} old DB-unreferenced JSONL files (${formatMib(orphanRollouts.size_mib)}) from sessions to archived_sessions; remove ${String(orphanRollouts.empty_dir_candidates ?? 0)} empty dirs`));
252
+ if (Number(orphanRollouts.skipped_recent_files) > 0) {
253
+ console.log(pc.yellow(` Skipped ${String(orphanRollouts.skipped_recent_files)} orphan rollouts inside the recent window.`));
254
+ }
255
+ if (Number(orphanRollouts.skipped_session_indexed_files) > 0) {
256
+ console.log(pc.yellow(` Skipped ${String(orphanRollouts.skipped_session_indexed_files)} DB-orphaned rollouts still present in session_index.jsonl.`));
257
+ }
258
+ }
259
+ const stateSpace = recordAt(report, "databaseSpace", "state_5.sqlite");
260
+ if (Number(stateSpace.free_mib) >= 1) {
261
+ console.log(pc.green(` State vacuum: reclaim about ${formatMib(stateSpace.free_mib)} from SQLite freelist`));
262
+ }
263
+ const logCleanup = recordAt(report, "logCleanupCandidates");
264
+ if (Object.keys(logCleanup).length) {
265
+ console.log(pc.green(` Logs cleanup: delete ${String(logCleanup.delete_rows)} old rows and cap ${String(logCleanup.cap_rows)} oversized log payloads`));
266
+ }
267
+ const tuiLogCleanup = recordAt(report, "tuiLogCleanupCandidates");
268
+ if (Object.keys(tuiLogCleanup).length) {
269
+ console.log(pc.green(` TUI log cleanup: reclaim about ${formatMib(tuiLogCleanup.reclaimable_mib)} while keeping newest ${formatMib(tuiLogCleanup.keep_mib)}`));
270
+ }
271
+ console.log(pc.dim(" Preserves thread rows and rollout JSONL; orphan rollout files are moved, not deleted."));
272
+ const rollouts = recordAt(report, "rollouts");
273
+ if (Object.keys(rollouts).length) {
274
+ console.log(pc.dim(` Rollout scan: ${String(rollouts.orphanFiles)} orphan files (${formatMib(rollouts.orphanSizeMib)}); deletion is not part of this apply flow.`));
275
+ }
276
+ }
277
+ function printApplySummary(report, archiveReport, orphanRolloutsReport, vacuumReport, logsReport, tuiLogReport, checkpointReport) {
278
+ const before = recordAt(report, "before");
279
+ const after = recordAt(report, "after");
280
+ console.log(pc.bold("\nApply complete"));
281
+ if (archiveReport) {
282
+ const archiveBefore = recordAt(archiveReport, "before");
283
+ const appServer = recordAt(archiveReport, "appServerResult");
284
+ console.log(` Archived stale threads: ${String(appServer.succeeded ?? 0)} archive calls for up to ${String(archiveBefore.expected_archived_rows ?? 0)} threads`);
285
+ if (Number(appServer.failed ?? 0) > 0) {
286
+ console.log(pc.yellow(` Archive errors: ${String(appServer.failed)}; rerun dry-run to inspect remaining candidates.`));
287
+ }
288
+ console.log(` Archive backup: ${String(archiveReport.backupPath)}`);
289
+ }
290
+ if (orphanRolloutsReport) {
291
+ console.log(` Archived orphan rollouts: moved ${String(orphanRolloutsReport.movedFiles ?? 0)} files (${formatMib(orphanRolloutsReport.movedMib)})`);
292
+ if (orphanRolloutsReport.manifestPath) {
293
+ console.log(` Orphan rollout manifest: ${String(orphanRolloutsReport.manifestPath)}`);
294
+ }
295
+ console.log(` Empty session/archive dirs removed: ${String(orphanRolloutsReport.prunedEmptyDirs ?? 0)}`);
296
+ }
297
+ console.log(` Changed rows: ${String(report.changedRows)}`);
298
+ if (report.backupPath)
299
+ console.log(` Metadata backup: ${String(report.backupPath)}`);
300
+ console.log(` Remaining eligible rows: ${String(after.rows)} (was ${String(before.rows)})`);
301
+ if (Object.keys(vacuumReport).length) {
302
+ const vacuumBefore = recordAt(vacuumReport, "before", "main");
303
+ const vacuumAfter = recordAt(vacuumReport, "after", "main");
304
+ console.log(` State vacuum: ${formatMib(vacuumBefore.mib)} -> ${formatMib(vacuumAfter.mib)}`);
305
+ }
306
+ if (logsReport) {
307
+ console.log(` Logs cleanup: deleted ${String(logsReport.deletedRows ?? 0)} rows, capped ${String(logsReport.cappedRows ?? 0)} rows`);
308
+ }
309
+ if (tuiLogReport) {
310
+ const tuiBefore = recordAt(tuiLogReport, "before");
311
+ const tuiAfter = recordAt(tuiLogReport, "after");
312
+ console.log(` TUI log cleanup: ${formatMib(tuiBefore.current_mib)} -> ${formatMib(tuiAfter.current_mib)}`);
313
+ }
314
+ const checkpointBefore = recordAt(checkpointReport, "before", "wal");
315
+ const checkpointAfter = recordAt(checkpointReport, "after", "wal");
316
+ console.log(` WAL checkpoint: ${formatMib(checkpointBefore.mib)} -> ${formatMib(checkpointAfter.mib)}`);
317
+ const backupPaths = [archiveReport, vacuumReport, logsReport, tuiLogReport, report]
318
+ .map((entry) => entry?.backupPath)
319
+ .filter((value) => typeof value === "string");
320
+ if (backupPaths.length) {
321
+ console.log(pc.dim(` Backups are in ${path.dirname(String(backupPaths[0]))}. Keep them until Codex looks right, then delete them to reclaim disk.`));
322
+ }
323
+ }
324
+ async function offerScheduledBackupCleanup(options, applyReport) {
325
+ const backupPaths = [
326
+ nullableRecordAt(applyReport, "archive"),
327
+ recordAt(applyReport, "compact"),
328
+ recordAt(applyReport, "vacuum"),
329
+ nullableRecordAt(applyReport, "logs"),
330
+ nullableRecordAt(applyReport, "tuiLog"),
331
+ ]
332
+ .map((entry) => entry?.backupPath)
333
+ .filter((value) => typeof value === "string");
334
+ if (!backupPaths.length)
335
+ return;
336
+ const schedule = await confirm({
337
+ default: false,
338
+ message: `Schedule backup cleanup in ${options.afterHours} hours? You can cancel it before then.`,
339
+ });
340
+ if (!schedule)
341
+ return;
342
+ let report;
343
+ try {
344
+ report = await scheduleBackupPrune({
345
+ ...options,
346
+ apply: true,
347
+ confirmScheduleBackupPrune: true,
348
+ });
349
+ }
350
+ catch (error) {
351
+ console.log(pc.yellow(`Could not schedule backup cleanup: ${error instanceof Error ? error.message : String(error)}`));
352
+ return;
353
+ }
354
+ console.log(pc.bold("\nBackup cleanup scheduled"));
355
+ console.log(` Runs: ${String(recordAt(report, "policy").runAtUtc)}`);
356
+ console.log(` Command: ${String(report.command)}`);
357
+ if (report.taskName)
358
+ console.log(` Task: ${String(report.taskName)}`);
359
+ if (report.jobId)
360
+ console.log(` Job: ${String(report.jobId)}`);
361
+ console.log(` Cancel: ${String(report.cancelCommand)}`);
362
+ }
363
+ function printDryRunCommand(options) {
364
+ const archiveFlag = options.archiveStale ? "" : " --skip-archive-stale";
365
+ const orphanFlag = options.archiveOrphanRollouts ? " --archive-orphan-rollouts" : "";
366
+ const logsFlag = options.pruneLogs ? " --prune-logs" : "";
367
+ const tuiLogFlag = options.pruneTuiLog ? " --prune-tui-log" : "";
368
+ const recentFlag = options.compactRecentMetadata ? " --compact-recent-metadata" : "";
369
+ console.log(` npx codex-cleaner@latest --allow-running-readonly clean --max-chars ${options.maxChars} --keep-recent-days ${options.keepRecentDays}${archiveFlag}${orphanFlag}${logsFlag}${tuiLogFlag}${recentFlag}`);
370
+ }
371
+ function printApplyCommand(options) {
372
+ const archiveFlags = options.archiveStale ? " --confirm-archive-stale" : " --skip-archive-stale";
373
+ const orphanFlags = options.archiveOrphanRollouts
374
+ ? " --archive-orphan-rollouts --confirm-archive-orphan-rollouts"
375
+ : "";
376
+ const logsFlags = options.pruneLogs ? " --prune-logs --confirm-prune-logs" : "";
377
+ const tuiLogFlags = options.pruneTuiLog ? " --prune-tui-log --confirm-prune-tui-log" : "";
378
+ const recentFlag = options.compactRecentMetadata ? " --compact-recent-metadata" : "";
379
+ console.log(` npx codex-cleaner@latest clean --max-chars ${options.maxChars} --keep-recent-days ${options.keepRecentDays} --apply --confirm-lossy-metadata${archiveFlags}${orphanFlags}${logsFlags}${tuiLogFlags}${recentFlag}`);
380
+ }
381
+ function recordAt(source, ...keys) {
382
+ let current = source;
383
+ for (const key of keys) {
384
+ if (!current || typeof current !== "object" || Array.isArray(current))
385
+ return {};
386
+ current = current[key];
387
+ }
388
+ return current && typeof current === "object" && !Array.isArray(current) ? current : {};
389
+ }
390
+ function nullableRecordAt(source, ...keys) {
391
+ const value = recordAt(source, ...keys);
392
+ return Object.keys(value).length ? value : null;
393
+ }
394
+ function numberAt(source, ...keys) {
395
+ const parent = recordAt(source, ...keys.slice(0, -1));
396
+ const value = parent[keys[keys.length - 1] ?? ""];
397
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
398
+ }
399
+ function formatMib(value) {
400
+ return typeof value === "number" && Number.isFinite(value) ? `${value.toLocaleString()} MiB` : "unknown";
401
+ }
402
+ function dedupeChoiceValues(choices) {
403
+ const seen = new Set();
404
+ const result = [];
405
+ for (const choice of choices) {
406
+ if (seen.has(choice.value))
407
+ continue;
408
+ seen.add(choice.value);
409
+ result.push(choice);
410
+ }
411
+ return result;
412
+ }