claude-dev-env 8.28.1 → 8.29.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,36 @@
1
+ ---
2
+ name: codex-cleanse
3
+ description: Preview or archive local Codex sessions after seven days without activity. Use for an on-demand session cleanup or a local scheduled archive run across a selected Codex home.
4
+ ---
5
+
6
+ # Codex cleanse
7
+
8
+ Run `scripts/cleanse.mjs` with Node.js from this skill directory. Select the account with `--codex-home <absolute path>`. Without that flag, the command uses `CODEX_HOME`, then the current user's `.codex` directory. Use `--codex-path <absolute executable path>` when Codex is outside the supported native install locations.
9
+
10
+ Preview first:
11
+
12
+ ```text
13
+ node scripts/cleanse.mjs --stdio --codex-home <absolute path>
14
+ ```
15
+
16
+ Archive the eligible sessions after reviewing the preview:
17
+
18
+ ```text
19
+ node scripts/cleanse.mjs --stdio --codex-home <absolute path> --apply
20
+ ```
21
+
22
+ The default cutoff is seven days. `--inactive-days <number>` changes it. When the local Codex app's task listing is available, repeat `--exclude-thread-id <UUID>` for the current task and each active desktop task. Run the command on demand when the user requests archiving. The command starts a separate local app-server process and cannot see other clients' runtime status. It checks the stored activity time and rollout file modification time before each archive request.
23
+
24
+ The command lists indexed sessions through `thread/list` and discovers other session IDs from rollout filenames under the selected home's `sessions` directory.
25
+ It reads their metadata through `thread/read` without opening conversation content.
26
+ It re-reads each candidate, checks its activity time, rollout file modification time, exclusions, and unarchived descendants, then calls `thread/archive` for eligible leaves before parents.
27
+ It confirms archived IDs through a final archived listing or a native read of the archived path for sessions omitted from that listing.
28
+ After option parsing succeeds, the command writes one JSON report to stdout and progress to stderr. Option-parsing errors produce a stderr diagnostic and exit with code 1 before starting the Codex server.
29
+ Zero-byte rollout files count as `emptyRollout` skips because the native API cannot read their metadata.
30
+ Discovery failures stop archive writes and exit with a nonzero status.
31
+ An archive candidate failure stops later archive requests. Earlier requests still receive readback.
32
+ Archive and readback failures remain in the report and produce a nonzero exit status.
33
+
34
+ After a successful apply run archives sessions, refresh the active task exclusions and run again. Stop when a successful run reports zero archives.
35
+
36
+ Run the archive task on the local machine with the app and selected account available. A cloud task cannot read this machine's Codex home. Codex session archiving leaves managed worktrees in place. The desktop setting at Settings > Worktrees > General > Automatically delete old worktrees controls separate worktree cleanup by retained count.
@@ -0,0 +1,11 @@
1
+ export const DEFAULT_INACTIVE_DAYS = 7;
2
+ export const MILLISECONDS_PER_DAY = 86_400_000;
3
+ export const MILLISECONDS_PER_SECOND = 1_000;
4
+ export const PAGE_SIZE = 1_000;
5
+ export const REQUEST_TIMEOUT_MILLISECONDS = 30_000;
6
+ export const SOURCE_KINDS = [
7
+ 'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview',
8
+ 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther', 'unknown',
9
+ ];
10
+ export const THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ export const ROLLOUT_FILENAME_PATTERN = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
@@ -0,0 +1,42 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ DEFAULT_INACTIVE_DAYS,
6
+ MILLISECONDS_PER_DAY,
7
+ MILLISECONDS_PER_SECOND,
8
+ PAGE_SIZE,
9
+ REQUEST_TIMEOUT_MILLISECONDS,
10
+ ROLLOUT_FILENAME_PATTERN,
11
+ SOURCE_KINDS,
12
+ THREAD_ID_PATTERN,
13
+ } from './constants.mjs';
14
+
15
+ test('cleanse defaults to a seven-day cutoff and bounded requests', () => {
16
+ assert.equal(DEFAULT_INACTIVE_DAYS, 7);
17
+ assert.equal(MILLISECONDS_PER_DAY, 86_400_000);
18
+ assert.equal(MILLISECONDS_PER_SECOND, 1_000);
19
+ assert.equal(PAGE_SIZE, 1_000);
20
+ assert.equal(REQUEST_TIMEOUT_MILLISECONDS, 30_000);
21
+ });
22
+
23
+ test('session listing includes every supported source kind', () => {
24
+ assert.deepEqual(SOURCE_KINDS, [
25
+ 'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview',
26
+ 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther', 'unknown',
27
+ ]);
28
+ });
29
+
30
+ test('thread IDs accept UUID spelling and reject malformed IDs', () => {
31
+ assert.equal(THREAD_ID_PATTERN.test('12345678-1234-ABCD-1234-123456789ABC'), true);
32
+ assert.equal(THREAD_ID_PATTERN.test('12345678-1234-ABCD-1234-123456789AB'), false);
33
+ assert.equal(THREAD_ID_PATTERN.test('prefix-12345678-1234-ABCD-1234-123456789ABC'), false);
34
+ });
35
+
36
+ test('rollout filenames yield the thread ID and reject other files', () => {
37
+ const threadId = '12345678-1234-ABCD-1234-123456789ABC';
38
+ const filename = `rollout-2026-09-30T12-34-56-${threadId}.jsonl`;
39
+ assert.equal(ROLLOUT_FILENAME_PATTERN.exec(filename)?.[1], threadId);
40
+ assert.equal(ROLLOUT_FILENAME_PATTERN.test(`${filename}.bak`), false);
41
+ assert.equal(ROLLOUT_FILENAME_PATTERN.test(`other-${filename}`), false);
42
+ });
@@ -0,0 +1,458 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { accessSync, constants, existsSync, lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import {
9
+ DEFAULT_INACTIVE_DAYS,
10
+ MILLISECONDS_PER_DAY,
11
+ MILLISECONDS_PER_SECOND,
12
+ PAGE_SIZE,
13
+ REQUEST_TIMEOUT_MILLISECONDS,
14
+ ROLLOUT_FILENAME_PATTERN,
15
+ SOURCE_KINDS,
16
+ THREAD_ID_PATTERN,
17
+ } from '../config/constants.mjs';
18
+
19
+ const scriptPath = fileURLToPath(import.meta.url);
20
+
21
+ export function parseOptions(allArguments, environment = process.env) {
22
+ const options = {
23
+ apply: false,
24
+ inactiveDays: DEFAULT_INACTIVE_DAYS,
25
+ home: resolve(environment.CODEX_HOME || join(homedir(), '.codex')),
26
+ codexPath: null,
27
+ excludedThreadIds: new Set(),
28
+ };
29
+ for (let index = 0; index < allArguments.length; index += 1) {
30
+ const argument = allArguments[index];
31
+ if (argument === '--apply') {
32
+ options.apply = true;
33
+ continue;
34
+ }
35
+ if (argument === '--stdio') {
36
+ continue;
37
+ }
38
+ const argumentValue = allArguments[++index];
39
+ if (!argumentValue || argumentValue.startsWith('--')) throw new Error(`Missing value for ${argument}`);
40
+ if (argument === '--inactive-days') options.inactiveDays = Number(argumentValue);
41
+ else if (argument === '--codex-home') options.home = resolve(argumentValue);
42
+ else if (argument === '--codex-path') options.codexPath = resolve(argumentValue);
43
+ else if (argument === '--exclude-thread-id') options.excludedThreadIds.add(argumentValue.toLowerCase());
44
+ else throw new Error(`Unknown option: ${argument}`);
45
+ }
46
+ if (!Number.isFinite(options.inactiveDays) || options.inactiveDays < 0) {
47
+ throw new Error('--inactive-days must be a nonnegative number');
48
+ }
49
+ if (Number.isNaN(new Date(Date.now() - options.inactiveDays * MILLISECONDS_PER_DAY).getTime())) {
50
+ throw new Error('--inactive-days must produce a valid cutoff date');
51
+ }
52
+ for (const eachThreadId of options.excludedThreadIds) {
53
+ if (!THREAD_ID_PATTERN.test(eachThreadId)) throw new Error('Invalid excluded thread ID');
54
+ }
55
+ return options;
56
+ }
57
+
58
+ export function assertSelectedHome(serverHome, selectedHome) {
59
+ try {
60
+ if (serverHome && isSameCanonicalPath(serverHome, selectedHome)) return;
61
+ } catch (error) {
62
+ throw new Error('Codex server home differs from selected home', { cause: error });
63
+ }
64
+ throw new Error('Codex server home differs from selected home');
65
+ }
66
+
67
+ function isExecutableFile(executablePath) {
68
+ try {
69
+ if (!statSync(executablePath).isFile()) return false;
70
+ accessSync(executablePath, constants.X_OK);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ export function findCodexPath(options, environment = process.env) {
78
+ if (options.codexPath) {
79
+ if (!existsSync(options.codexPath)) throw new Error(`Codex executable not found: ${options.codexPath}`);
80
+ return options.codexPath;
81
+ }
82
+ const executableName = process.platform === 'win32' ? 'codex.exe' : 'codex';
83
+ const allCandidates = [
84
+ environment.APPDATA && join(environment.APPDATA, 'npm', 'node_modules', '@openai', 'codex',
85
+ 'node_modules', '@openai', 'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin', executableName),
86
+ join(options.home, 'packages', 'standalone', 'current', 'bin', executableName),
87
+ environment.LOCALAPPDATA && join(environment.LOCALAPPDATA, 'Programs', 'OpenAI', 'Codex', 'bin', executableName),
88
+ ].filter(Boolean);
89
+ const pathKey = process.platform === 'win32'
90
+ ? Object.keys(environment).find(eachKey => eachKey.toUpperCase() === 'PATH') : 'PATH';
91
+ for (const eachDirectory of (environment[pathKey] || '').split(delimiter).filter(Boolean)) {
92
+ allCandidates.push(join(eachDirectory, executableName));
93
+ if (process.platform === 'win32') {
94
+ allCandidates.push(join(eachDirectory, 'node_modules', '@openai', 'codex',
95
+ 'node_modules', '@openai', 'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin', executableName));
96
+ }
97
+ }
98
+ const codexPath = allCandidates.find(isExecutableFile);
99
+ if (!codexPath) throw new Error('Codex executable not found; pass --codex-path');
100
+ return codexPath;
101
+ }
102
+
103
+ export function openDaemon(options, environment = process.env) {
104
+ const codexPath = findCodexPath(options, environment);
105
+ const child = spawn(codexPath, ['app-server'], {
106
+ env: { ...environment, CODEX_HOME: options.home },
107
+ stdio: ['pipe', 'pipe', 'pipe'],
108
+ windowsHide: true,
109
+ });
110
+ const pendingById = new Map();
111
+ let nextId = 1;
112
+ let transportFailure = null;
113
+ let stderrTail = '';
114
+ const failPending = error => {
115
+ transportFailure = error;
116
+ for (const { reject, timer } of pendingById.values()) {
117
+ clearTimeout(timer);
118
+ reject(error);
119
+ }
120
+ pendingById.clear();
121
+ };
122
+ createInterface({ input: child.stdout }).on('line', line => {
123
+ let message;
124
+ try { message = JSON.parse(line); } catch { return; }
125
+ const pending = pendingById.get(message.id);
126
+ if (!pending) return;
127
+ clearTimeout(pending.timer);
128
+ pendingById.delete(message.id);
129
+ if (message.error) pending.reject(new Error(JSON.stringify(message.error)));
130
+ else pending.resolve(message.result);
131
+ });
132
+ child.stderr.on('data', chunk => { stderrTail = `${stderrTail}${chunk}`.slice(-2_000); });
133
+ child.stdin.on('error', failPending);
134
+ child.on('error', failPending);
135
+ child.on('exit', code => failPending(new Error(`Codex daemon proxy exited with code ${code}`)));
136
+ const request = (method, params = {}) => {
137
+ if (transportFailure) return Promise.reject(transportFailure);
138
+ const id = nextId++;
139
+ return new Promise((resolveRequest, rejectRequest) => {
140
+ const timer = setTimeout(() => {
141
+ failPending(new Error(`Codex daemon request timed out: ${method}; ${stderrTail.trim()}`));
142
+ }, REQUEST_TIMEOUT_MILLISECONDS);
143
+ pendingById.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
144
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
145
+ });
146
+ };
147
+ return {
148
+ request,
149
+ async initialize() {
150
+ const handshake = await request('initialize', {
151
+ clientInfo: { name: 'codex_cleanse', title: 'Codex Cleanse', version: '1.0.0' },
152
+ capabilities: { experimentalApi: true },
153
+ });
154
+ assertSelectedHome(handshake?.codexHome, options.home);
155
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} })}\n`);
156
+ },
157
+ close() {
158
+ child.stdin.end();
159
+ setTimeout(() => { if (child.exitCode === null) child.kill(); }, 2_000).unref();
160
+ },
161
+ isHealthy() { return !transportFailure; },
162
+ };
163
+ }
164
+
165
+ async function listThreads(request, archived, filters = {}) {
166
+ const allThreads = [];
167
+ const allCursors = new Set();
168
+ let cursor = null;
169
+ let pageCount = 0;
170
+ do {
171
+ const page = await request('thread/list', {
172
+ limit: PAGE_SIZE,
173
+ archived,
174
+ useStateDbOnly: true,
175
+ sourceKinds: SOURCE_KINDS,
176
+ sortKey: 'updated_at',
177
+ sortDirection: 'desc',
178
+ ...filters,
179
+ ...(cursor && { cursor }),
180
+ });
181
+ if (!Array.isArray(page?.data)) throw new Error('Invalid thread/list reply');
182
+ allThreads.push(...page.data);
183
+ pageCount += 1;
184
+ if (pageCount % 10 === 0) process.stderr.write(`Listed ${allThreads.length} threads\n`);
185
+ cursor = page.nextCursor;
186
+ if (cursor && allCursors.has(cursor)) throw new Error('Repeated thread/list cursor');
187
+ if (cursor) allCursors.add(cursor);
188
+ } while (cursor);
189
+ return allThreads;
190
+ }
191
+
192
+ function discoverRolloutPaths(home) {
193
+ const sessionsDirectory = join(home, 'sessions');
194
+ const pathById = new Map();
195
+ const failures = [];
196
+ const allDirectories = [sessionsDirectory];
197
+ try {
198
+ if (!lstatSync(sessionsDirectory).isDirectory()) throw new Error('Sessions path is not a directory');
199
+ } catch (error) {
200
+ if (error.code === 'ENOENT') return { pathById, failures };
201
+ return { pathById, failures: [{ id: null, error: String(error.message || error) }] };
202
+ }
203
+ while (allDirectories.length) {
204
+ const directory = allDirectories.pop();
205
+ let allEntries;
206
+ try { allEntries = readdirSync(directory, { withFileTypes: true }); }
207
+ catch (error) {
208
+ failures.push({ id: null, error: String(error.message || error) });
209
+ continue;
210
+ }
211
+ for (const eachEntry of allEntries) {
212
+ if (eachEntry.isSymbolicLink()) continue;
213
+ const entryPath = join(directory, eachEntry.name);
214
+ if (eachEntry.isDirectory()) allDirectories.push(entryPath);
215
+ if (!eachEntry.isFile()) continue;
216
+ const threadId = ROLLOUT_FILENAME_PATTERN.exec(eachEntry.name)?.[1]?.toLowerCase();
217
+ if (!threadId) continue;
218
+ if (pathById.has(threadId)) {
219
+ failures.push({ id: threadId, error: 'Duplicate rollout ID under sessions' });
220
+ continue;
221
+ }
222
+ pathById.set(threadId, entryPath);
223
+ }
224
+ }
225
+ return { pathById, failures };
226
+ }
227
+
228
+ function isSameCanonicalPath(leftPath, rightPath) {
229
+ const left = realpathSync.native(leftPath);
230
+ const right = realpathSync.native(rightPath);
231
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
232
+ }
233
+
234
+ async function discoverHiddenThreads(request, home, listedThreads, isHealthy) {
235
+ const { pathById, failures } = discoverRolloutPaths(home);
236
+ const listedIds = new Set(listedThreads.map(eachThread => eachThread.id.toLowerCase()));
237
+ const hiddenThreads = [];
238
+ let emptyRolloutCount = 0;
239
+ for (const [eachThreadId, rolloutPath] of pathById) {
240
+ if (listedIds.has(eachThreadId)) continue;
241
+ try {
242
+ if (statSync(rolloutPath).size === 0) {
243
+ emptyRolloutCount += 1;
244
+ continue;
245
+ }
246
+ const thread = (await request('thread/read', { threadId: eachThreadId, includeTurns: false })).thread;
247
+ if (thread?.id?.toLowerCase() !== eachThreadId || !thread.path
248
+ || !isSameCanonicalPath(thread.path, rolloutPath)) {
249
+ throw new Error('Native thread metadata differs from discovered rollout');
250
+ }
251
+ hiddenThreads.push(thread);
252
+ } catch (error) {
253
+ failures.push({ id: eachThreadId, error: String(error.message || error) });
254
+ if (!isHealthy()) break;
255
+ }
256
+ }
257
+ return { hiddenThreads, failures, emptyRolloutCount };
258
+ }
259
+
260
+ function isArchivedReadback(thread, threadId, home) {
261
+ if (thread?.id !== threadId || !thread.path) return false;
262
+ if (ROLLOUT_FILENAME_PATTERN.exec(basename(thread.path))?.[1]?.toLowerCase() !== threadId.toLowerCase()) return false;
263
+ try {
264
+ const archiveDirectory = realpathSync.native(join(home, 'archived_sessions'));
265
+ const archivedPath = realpathSync.native(thread.path);
266
+ const pathWithinArchive = relative(archiveDirectory, archivedPath);
267
+ return pathWithinArchive !== '' && pathWithinArchive !== '..'
268
+ && !pathWithinArchive.startsWith(`..${sep}`) && !isAbsolute(pathWithinArchive)
269
+ && statSync(archivedPath).isFile();
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ function ineligibilityReason(thread, cutoffSeconds, excludedThreadIds) {
276
+ if (!THREAD_ID_PATTERN.test(thread?.id)) return 'invalidId';
277
+ if (excludedThreadIds.has(thread.id.toLowerCase())) return 'excluded';
278
+ if (thread.ephemeral) return 'ephemeral';
279
+ if (!Number.isSafeInteger(thread.updatedAt) || thread.updatedAt <= 0) return 'unknownActivity';
280
+ if (thread.updatedAt > cutoffSeconds) return 'recent';
281
+ if (thread.status?.type === 'active') return 'active';
282
+ if (!['idle', 'notLoaded'].includes(thread.status?.type)) return 'unknownStatus';
283
+ return null;
284
+ }
285
+
286
+ function rolloutWriteReason(thread, cutoffMilliseconds) {
287
+ if (!thread.path) return null;
288
+ try {
289
+ const fileStatus = statSync(thread.path);
290
+ if (fileStatus.size === 0) return 'emptyRollout';
291
+ return fileStatus.mtimeMs > cutoffMilliseconds ? 'recentRolloutWrite' : null;
292
+ } catch {
293
+ return 'missingRollout';
294
+ }
295
+ }
296
+
297
+ function candidateReason(thread, cutoffSeconds, excludedThreadIds) {
298
+ return ineligibilityReason(thread, cutoffSeconds, excludedThreadIds)
299
+ || rolloutWriteReason(thread, cutoffSeconds * MILLISECONDS_PER_SECOND);
300
+ }
301
+
302
+ function parentThreadId(thread) {
303
+ return thread.parentThreadId || thread.source?.subAgent?.thread_spawn?.parent_thread_id;
304
+ }
305
+
306
+ function sortChildrenBeforeParents(allThreads) {
307
+ const threadById = new Map(allThreads.map(eachThread => [eachThread.id, eachThread]));
308
+ const depthById = new Map();
309
+ const depthOf = (thread, visited = new Set()) => {
310
+ if (depthById.has(thread.id)) return depthById.get(thread.id);
311
+ if (visited.has(thread.id)) throw new Error('Thread parent cycle');
312
+ visited.add(thread.id);
313
+ const parent = threadById.get(parentThreadId(thread));
314
+ const depth = parent ? depthOf(parent, visited) + 1 : 0;
315
+ depthById.set(thread.id, depth);
316
+ visited.delete(thread.id);
317
+ return depth;
318
+ };
319
+ return [...allThreads].sort((left, right) => depthOf(right) - depthOf(left));
320
+ }
321
+
322
+ function findProtectedAncestors(allThreads, cutoffSeconds, excludedThreadIds) {
323
+ const threadById = new Map(allThreads.map(eachThread => [eachThread.id, eachThread]));
324
+ const protectedIds = new Set();
325
+ for (const eachThread of allThreads) {
326
+ if (!candidateReason(eachThread, cutoffSeconds, excludedThreadIds)) continue;
327
+ let parentId = parentThreadId(eachThread);
328
+ while (parentId && !protectedIds.has(parentId)) {
329
+ protectedIds.add(parentId);
330
+ const parent = threadById.get(parentId);
331
+ parentId = parent && parentThreadId(parent);
332
+ }
333
+ }
334
+ return protectedIds;
335
+ }
336
+
337
+ function createReport(options, cutoffSeconds) {
338
+ return {
339
+ home: options.home,
340
+ cutoff: new Date(cutoffSeconds * MILLISECONDS_PER_SECOND).toISOString(),
341
+ scanned: 0,
342
+ eligible: 0,
343
+ archived: 0,
344
+ skipped: 0,
345
+ skippedReasons: {},
346
+ failed: [],
347
+ };
348
+ }
349
+
350
+ export async function cleanseSessions(request, options, now = Date.now(), isHealthy = () => true) {
351
+ const cutoffSeconds = Math.floor((now - options.inactiveDays * MILLISECONDS_PER_DAY) / MILLISECONDS_PER_SECOND);
352
+ const report = createReport(options, cutoffSeconds);
353
+ let listedThreads;
354
+ try {
355
+ listedThreads = await listThreads(request, false);
356
+ } catch (error) {
357
+ report.failed.push({ id: null, error: String(error.message || error) });
358
+ return report;
359
+ }
360
+ const { hiddenThreads, failures, emptyRolloutCount } = await discoverHiddenThreads(
361
+ request, options.home, listedThreads, isHealthy);
362
+ const allThreads = [...hiddenThreads, ...listedThreads];
363
+ report.scanned = allThreads.length + emptyRolloutCount;
364
+ report.skipped = emptyRolloutCount;
365
+ report.skippedReasons = emptyRolloutCount ? { emptyRollout: emptyRolloutCount } : {};
366
+ report.failed = failures;
367
+ const recordSkip = reason => {
368
+ report.skipped += 1;
369
+ report.skippedReasons[reason] = (report.skippedReasons[reason] || 0) + 1;
370
+ };
371
+ const protectedIds = findProtectedAncestors(allThreads, cutoffSeconds, options.excludedThreadIds);
372
+ if (!options.apply) {
373
+ for (const eachThread of allThreads) {
374
+ const reason = candidateReason(eachThread, cutoffSeconds, options.excludedThreadIds);
375
+ if (reason || protectedIds.has(eachThread.id)) recordSkip(reason || 'descendant');
376
+ else report.eligible += 1;
377
+ }
378
+ return report;
379
+ }
380
+ if (report.failed.length) return report;
381
+ const allAttemptedArchiveIds = [];
382
+ for (const eachThread of sortChildrenBeforeParents(allThreads)) {
383
+ if (report.failed.length) break;
384
+ const reason = candidateReason(eachThread, cutoffSeconds, options.excludedThreadIds);
385
+ if (reason || protectedIds.has(eachThread.id)) {
386
+ recordSkip(reason || 'descendant');
387
+ continue;
388
+ }
389
+ try {
390
+ const freshThread = (await request('thread/read', { threadId: eachThread.id, includeTurns: false })).thread;
391
+ const freshReason = ineligibilityReason(freshThread, cutoffSeconds, options.excludedThreadIds);
392
+ const fileReason = rolloutWriteReason(freshThread, cutoffSeconds * MILLISECONDS_PER_SECOND);
393
+ if (freshReason || fileReason) {
394
+ recordSkip(freshReason || fileReason);
395
+ continue;
396
+ }
397
+ const allDescendants = await listThreads(request, false, { ancestorThreadId: eachThread.id });
398
+ if (allDescendants.length) {
399
+ recordSkip('descendant');
400
+ continue;
401
+ }
402
+ report.eligible += 1;
403
+ allAttemptedArchiveIds.push(eachThread.id);
404
+ await request('thread/archive', { threadId: eachThread.id });
405
+ if (allAttemptedArchiveIds.length % 250 === 0) {
406
+ process.stderr.write(`Sent ${allAttemptedArchiveIds.length} archive requests\n`);
407
+ }
408
+ } catch (error) {
409
+ report.failed.push({ id: eachThread.id, error: String(error.message || error) });
410
+ }
411
+ }
412
+ if (allAttemptedArchiveIds.length) {
413
+ const archivedIds = new Set();
414
+ try {
415
+ for (const eachThread of await listThreads(request, true)) archivedIds.add(eachThread.id);
416
+ } catch (error) {
417
+ report.failed.push({ id: null, error: String(error.message || error) });
418
+ }
419
+ for (const eachThreadId of allAttemptedArchiveIds) {
420
+ if (archivedIds.has(eachThreadId)) report.archived += 1;
421
+ else {
422
+ try {
423
+ const thread = (await request('thread/read', { threadId: eachThreadId, includeTurns: false })).thread;
424
+ if (isArchivedReadback(thread, eachThreadId, options.home)) report.archived += 1;
425
+ else report.failed.push({ id: eachThreadId, error: 'Archive readback missing' });
426
+ } catch (error) {
427
+ report.failed.push({ id: eachThreadId, error: String(error.message || error) });
428
+ }
429
+ }
430
+ }
431
+ }
432
+ return report;
433
+ }
434
+
435
+ async function main() {
436
+ const options = parseOptions(process.argv.slice(2));
437
+ const now = Date.now();
438
+ let daemon;
439
+ let report = createReport(options, Math.floor((now - options.inactiveDays * MILLISECONDS_PER_DAY) / MILLISECONDS_PER_SECOND));
440
+ try {
441
+ daemon = openDaemon(options);
442
+ await daemon.initialize();
443
+ report = await cleanseSessions(daemon.request, options, now, daemon.isHealthy);
444
+ } catch (error) {
445
+ report.failed.push({ id: null, error: String(error.message || error) });
446
+ } finally {
447
+ daemon?.close();
448
+ }
449
+ process.stdout.write(`${JSON.stringify(report)}\n`);
450
+ if (report.failed.length) process.exitCode = 1;
451
+ }
452
+
453
+ if (process.argv[1] && existsSync(process.argv[1]) && isSameCanonicalPath(process.argv[1], scriptPath)) {
454
+ main().catch(error => {
455
+ process.stderr.write(`${error.message || error}\n`);
456
+ process.exitCode = 1;
457
+ });
458
+ }
@@ -0,0 +1,708 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { assertSelectedHome, cleanseSessions, parseOptions } from './cleanse.mjs';
10
+ import * as cleanse from './cleanse.mjs';
11
+
12
+ const now = Date.parse('2026-09-30T12:00:00.000Z');
13
+ const cutoff = Math.floor((now - 7 * 86_400_000) / 1_000);
14
+ const threadId = number => `00000000-0000-4000-8000-${String(number).padStart(12, '0')}`;
15
+
16
+ test('CLI starts through a directory link and reports a missing executable', () => {
17
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-entry-'));
18
+ try {
19
+ const linkedDirectory = join(home, 'scripts');
20
+ symlinkSync(dirname(fileURLToPath(import.meta.url)), linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir');
21
+ const execution = spawnSync(process.execPath, [join(linkedDirectory, 'cleanse.mjs'),
22
+ '--codex-home', home, '--codex-path', join(home, 'missing')], { encoding: 'utf8', timeout: 10_000 });
23
+ assert.equal(execution.status, 1, execution.stderr);
24
+ const report = JSON.parse(execution.stdout);
25
+ assert.equal(report.archived, 0);
26
+ assert.equal(report.scanned, 0);
27
+ assert.match(report.failed[0].error, /executable not found/);
28
+ } finally {
29
+ rmSync(home, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ function makeThread(number, updatedAt, overrides = {}) {
34
+ return {
35
+ id: threadId(number),
36
+ updatedAt,
37
+ status: { type: 'idle' },
38
+ parentThreadId: null,
39
+ ephemeral: false,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function makeOptions(overrides = {}) {
45
+ return {
46
+ home: join(tmpdir(), 'account-a'),
47
+ inactiveDays: 7,
48
+ apply: false,
49
+ excludedThreadIds: new Set(),
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ function makeRequest(allThreads, archiveFailures = new Set(), missingReadback = new Set()) {
55
+ const archivedIds = new Set();
56
+ const allCalls = [];
57
+ const request = async (method, params) => {
58
+ allCalls.push({ method, params });
59
+ if (method === 'thread/list') {
60
+ const selectedThreads = allThreads.filter(eachThread =>
61
+ params.archived === archivedIds.has(eachThread.id)
62
+ && (!params.ancestorThreadId || eachThread.parentThreadId === params.ancestorThreadId)
63
+ && (!params.archived || !missingReadback.has(eachThread.id)));
64
+ const offset = Number(params.cursor || 0);
65
+ return { data: selectedThreads.slice(offset, offset + 2), nextCursor: offset + 2 < selectedThreads.length ? String(offset + 2) : null };
66
+ }
67
+ if (method === 'thread/read') {
68
+ return { thread: allThreads.find(eachThread => eachThread.id === params.threadId) };
69
+ }
70
+ if (method === 'thread/archive') {
71
+ if (archiveFailures.has(params.threadId)) throw new Error('archive rejected');
72
+ archivedIds.add(params.threadId);
73
+ return {};
74
+ }
75
+ throw new Error(`Unexpected method: ${method}`);
76
+ };
77
+ return { request, allCalls, archivedIds };
78
+ }
79
+
80
+ function makeRollout(home, number) {
81
+ const directory = join(home, 'sessions', '2026', '07', '01');
82
+ mkdirSync(directory, { recursive: true });
83
+ const rolloutPath = join(directory, `rollout-2026-07-01T00-00-00-${threadId(number)}.jsonl`);
84
+ writeFileSync(rolloutPath, 'session metadata');
85
+ utimesSync(rolloutPath, new Date(0), new Date(0));
86
+ return rolloutPath;
87
+ }
88
+
89
+ function makeHiddenRequest(home, allThreads, visibleIds, archiveDirectory = join(home, 'archived_sessions')) {
90
+ const archivedIds = new Set();
91
+ const allCalls = [];
92
+ const request = async (method, params) => {
93
+ allCalls.push({ method, params });
94
+ if (method === 'thread/read') {
95
+ return { thread: allThreads.find(eachThread => eachThread.id === params.threadId) };
96
+ }
97
+ if (method === 'thread/archive') {
98
+ const thread = allThreads.find(eachThread => eachThread.id === params.threadId);
99
+ mkdirSync(archiveDirectory, { recursive: true });
100
+ const archivedPath = join(archiveDirectory, basename(thread.path));
101
+ renameSync(thread.path, archivedPath);
102
+ thread.path = archivedPath;
103
+ archivedIds.add(thread.id);
104
+ return {};
105
+ }
106
+ const matchingThreads = allThreads.filter(eachThread =>
107
+ archivedIds.has(eachThread.id) === params.archived
108
+ && (params.ancestorThreadId
109
+ ? eachThread.parentThreadId === params.ancestorThreadId
110
+ : visibleIds.has(eachThread.id)));
111
+ return { data: matchingThreads, nextCursor: null };
112
+ };
113
+ return { request, allCalls, archivedIds };
114
+ }
115
+
116
+ test('preview counts every page and source without archiving', async () => {
117
+ const allThreads = [
118
+ makeThread(1, cutoff),
119
+ makeThread(2, cutoff - 1),
120
+ makeThread(3, cutoff + 1),
121
+ ];
122
+ const fixture = makeRequest(allThreads);
123
+ const report = await cleanseSessions(fixture.request, makeOptions(), now);
124
+ assert.deepEqual([report.scanned, report.eligible, report.skipped, report.archived], [3, 2, 1, 0]);
125
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/list').length, 2);
126
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
127
+ });
128
+
129
+ test('preview protects running sessions, excluded IDs, and parents with recent children', async () => {
130
+ const allThreads = [
131
+ makeThread(1, cutoff - 1, { status: { type: 'active' } }),
132
+ makeThread(2, cutoff - 1),
133
+ makeThread(3, cutoff - 1),
134
+ makeThread(4, cutoff + 1, { parentThreadId: threadId(3) }),
135
+ makeThread(5, cutoff - 1, { updatedAt: 0 }),
136
+ ];
137
+ const fixture = makeRequest(allThreads);
138
+ const options = makeOptions({ excludedThreadIds: new Set([threadId(2)]) });
139
+ const report = await cleanseSessions(fixture.request, options, now);
140
+ assert.equal(report.eligible, 0);
141
+ assert.equal(report.skipped, 5);
142
+ });
143
+
144
+ for (const eachProtection of ['recent', 'excluded']) {
145
+ test(`preview protects ancestors of a ${eachProtection} child with a source parent link`, async () => {
146
+ const allThreads = [makeThread(1, cutoff - 1),
147
+ makeThread(2, cutoff - 1, { source: { subAgent: { thread_spawn: { parent_thread_id: threadId(1) } } } }),
148
+ makeThread(3, eachProtection === 'recent' ? cutoff + 1 : cutoff - 1,
149
+ { source: { subAgent: { thread_spawn: { parent_thread_id: threadId(2) } } } })];
150
+ const fixture = makeRequest(allThreads);
151
+ const excludedThreadIds = new Set(eachProtection === 'excluded' ? [threadId(3)] : []);
152
+ const report = await cleanseSessions(fixture.request, makeOptions({ excludedThreadIds }), now);
153
+ assert.equal(report.eligible, 0);
154
+ assert.equal(report.skippedReasons.descendant, 2);
155
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
156
+ });
157
+ }
158
+
159
+ test('apply orders a stale child with a source parent link before its parent', async () => {
160
+ const fixture = makeRequest([makeThread(1, cutoff - 1), makeThread(2, cutoff - 1,
161
+ { source: { subAgent: { thread_spawn: { parent_thread_id: threadId(1) } } } })]);
162
+ const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
163
+ assert.equal(report.archived, 2);
164
+ assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
165
+ .map(eachCall => eachCall.params.threadId), [threadId(2), threadId(1)]);
166
+ });
167
+
168
+ test('preview prefers the top-level parent over a conflicting source parent link', async () => {
169
+ const fixture = makeRequest([makeThread(1, cutoff - 1), makeThread(2, cutoff - 1),
170
+ makeThread(3, cutoff + 1, { parentThreadId: threadId(1),
171
+ source: { subAgent: { thread_spawn: { parent_thread_id: threadId(2) } } } })]);
172
+ const report = await cleanseSessions(fixture.request, makeOptions(), now);
173
+ assert.equal(report.eligible, 1);
174
+ assert.equal(report.skippedReasons.descendant, 1);
175
+ });
176
+
177
+ test('apply archives a stale leaf and confirms archived listing', async () => {
178
+ const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
179
+ try {
180
+ const rolloutPath = join(directory, 'rollout.jsonl');
181
+ writeFileSync(rolloutPath, 'session metadata');
182
+ utimesSync(rolloutPath, new Date(0), new Date(0));
183
+ const fixture = makeRequest([makeThread(1, cutoff - 1, { path: rolloutPath })]);
184
+ const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
185
+ assert.equal(report.archived, 1);
186
+ assert.deepEqual(report.failed, []);
187
+ assert.deepEqual([...fixture.archivedIds], [threadId(1)]);
188
+ } finally {
189
+ rmSync(directory, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test('apply archives a stale database-backed thread with no rollout path', async () => {
194
+ const fixture = makeRequest([makeThread(1, cutoff - 1, { path: null })]);
195
+ const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
196
+ assert.equal(report.archived, 1);
197
+ assert.deepEqual(report.failed, []);
198
+ });
199
+
200
+ test('apply reads back earlier archives and stops after a rejected archive', async () => {
201
+ const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
202
+ try {
203
+ const rolloutPath = join(directory, 'rollout.jsonl');
204
+ writeFileSync(rolloutPath, 'session metadata');
205
+ utimesSync(rolloutPath, new Date(0), new Date(0));
206
+ const allThreads = [1, 2, 3].map(number => makeThread(number, cutoff - 1, { path: rolloutPath }));
207
+ const fixture = makeRequest(allThreads, new Set([threadId(2)]), new Set([threadId(1)]));
208
+ const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
209
+ assert.equal(report.archived, 0);
210
+ assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [threadId(2), threadId(1), threadId(2)]);
211
+ assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
212
+ .map(eachCall => eachCall.params.threadId), [threadId(1), threadId(2)]);
213
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/read'
214
+ && eachCall.params.threadId === threadId(1)).length, 2);
215
+ } finally {
216
+ rmSync(directory, { recursive: true, force: true });
217
+ }
218
+ });
219
+
220
+ test('apply skips a rollout file changed after the cutoff', async () => {
221
+ const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
222
+ try {
223
+ const rolloutPath = join(directory, 'rollout.jsonl');
224
+ writeFileSync(rolloutPath, 'new activity');
225
+ const fixture = makeRequest([makeThread(1, cutoff - 1, { path: rolloutPath })]);
226
+ const report = await cleanseSessions(fixture.request, makeOptions({ apply: true }), now);
227
+ assert.equal(report.archived, 0);
228
+ assert.equal(report.skippedReasons.recentRolloutWrite, 1);
229
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
230
+ } finally {
231
+ rmSync(directory, { recursive: true, force: true });
232
+ }
233
+ });
234
+
235
+ test('apply protects a parent when the daemon finds a recent descendant absent from parent metadata', async () => {
236
+ const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
237
+ try {
238
+ const rolloutPath = join(directory, 'rollout.jsonl');
239
+ writeFileSync(rolloutPath, 'session metadata');
240
+ utimesSync(rolloutPath, new Date(0), new Date(0));
241
+ const parent = makeThread(1, cutoff - 1, { path: rolloutPath });
242
+ const child = makeThread(2, cutoff + 1);
243
+ const fixture = makeRequest([parent, child]);
244
+ const request = async (method, params) => {
245
+ if (method === 'thread/list' && params.ancestorThreadId === parent.id) {
246
+ return { data: [child], nextCursor: null };
247
+ }
248
+ return fixture.request(method, params);
249
+ };
250
+ const report = await cleanseSessions(request, makeOptions({ apply: true }), now);
251
+ assert.equal(report.archived, 0);
252
+ assert.equal(report.skippedReasons.descendant, 1);
253
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
254
+ } finally {
255
+ rmSync(directory, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test('selected Codex home follows the flag before the environment and rejects a mismatched server', () => {
260
+ const homeFromEnvironment = join(tmpdir(), 'account-a');
261
+ const homeFromFlag = join(tmpdir(), 'account-b');
262
+ const options = parseOptions(['--codex-home', homeFromFlag], { CODEX_HOME: homeFromEnvironment });
263
+ assert.equal(options.home, homeFromFlag);
264
+ assert.throws(() => assertSelectedHome(homeFromEnvironment, options.home), /differs from selected home/);
265
+ });
266
+
267
+ test('selected home accepts a directory alias and rejects missing or different homes', () => {
268
+ const directory = mkdtempSync(join(tmpdir(), 'codex-cleanse-home-'));
269
+ try {
270
+ const home = join(directory, 'account');
271
+ const alias = join(directory, 'alias');
272
+ const otherHome = join(directory, 'other');
273
+ mkdirSync(home);
274
+ mkdirSync(otherHome);
275
+ symlinkSync(home, alias, process.platform === 'win32' ? 'junction' : 'dir');
276
+ assert.doesNotThrow(() => assertSelectedHome(home, alias));
277
+ assert.doesNotThrow(() => assertSelectedHome(alias, home));
278
+ if (process.platform === 'win32') {
279
+ assert.doesNotThrow(() => assertSelectedHome(home.toUpperCase(), alias));
280
+ }
281
+ assert.throws(() => assertSelectedHome(otherHome, alias), /differs from selected home/);
282
+ assert.throws(() => assertSelectedHome(undefined, home));
283
+ assert.throws(() => assertSelectedHome(join(directory, 'missing'), home));
284
+ } finally {
285
+ rmSync(directory, { recursive: true, force: true });
286
+ }
287
+ });
288
+
289
+ test('transport loss preserves acknowledged archives as unverified and stops later writes', async () => {
290
+ const fixture = makeRequest([1, 2, 3].map(number => makeThread(number, cutoff - 1)));
291
+ const allAttempts = [];
292
+ let isHealthy = true;
293
+ const request = async (method, params) => {
294
+ allAttempts.push({ method, params });
295
+ if (method === 'thread/archive' && params.threadId === threadId(2)) isHealthy = false;
296
+ if (!isHealthy) throw new Error('daemon exited');
297
+ return fixture.request(method, params);
298
+ };
299
+ const report = await cleanseSessions(request, makeOptions({ apply: true }), now, () => isHealthy);
300
+ assert.deepEqual([report.scanned, report.eligible, report.archived], [3, 2, 0]);
301
+ assert.ok(report.failed.some(eachFailure => eachFailure.id === threadId(2) && /daemon exited/.test(eachFailure.error)));
302
+ assert.ok(report.failed.some(eachFailure => eachFailure.id === threadId(1) && /daemon exited/.test(eachFailure.error)));
303
+ assert.deepEqual(allAttempts.filter(eachCall => eachCall.method === 'thread/archive')
304
+ .map(eachCall => eachCall.params.threadId), [threadId(1), threadId(2)]);
305
+ assert.ok(allAttempts.some(eachCall => eachCall.method === 'thread/list' && eachCall.params.archived));
306
+ assert.deepEqual(allAttempts.filter(eachCall => eachCall.method === 'thread/read')
307
+ .map(eachCall => eachCall.params.threadId), [threadId(1), threadId(2), threadId(1), threadId(2)]);
308
+ });
309
+
310
+ test('archived listing failure retains its error and verifies available per-thread readback', async () => {
311
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-readback-'));
312
+ try {
313
+ const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
314
+ const fixture = makeHiddenRequest(home, [orphan], new Set());
315
+ const request = async (method, params) => {
316
+ if (method === 'thread/list' && params.archived) throw new Error('archived listing failed');
317
+ return fixture.request(method, params);
318
+ };
319
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
320
+ assert.equal(report.archived, 1);
321
+ assert.deepEqual(report.failed, [{ id: null, error: 'archived listing failed' }]);
322
+ } finally {
323
+ rmSync(home, { recursive: true, force: true });
324
+ }
325
+ });
326
+
327
+ function assertCliTransportFailure(failureMethod) {
328
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-cli-'));
329
+ try {
330
+ const scriptUrl = new URL('./cleanse.mjs', import.meta.url).href;
331
+ const program = `
332
+ import childProcess from 'node:child_process';
333
+ import { EventEmitter } from 'node:events';
334
+ import { syncBuiltinESMExports } from 'node:module';
335
+ import { PassThrough, Writable } from 'node:stream';
336
+ import { fileURLToPath } from 'node:url';
337
+ const allThreads = ${JSON.stringify([1, 2, 3].map(number => makeThread(number, 1)))};
338
+ childProcess.spawn = () => {
339
+ const child = new EventEmitter();
340
+ child.stdout = new PassThrough();
341
+ child.stderr = new PassThrough();
342
+ child.exitCode = null;
343
+ child.stdin = new Writable({ write(chunk, encoding, done) {
344
+ const message = JSON.parse(chunk.toString());
345
+ if (!message.id) return done();
346
+ if (message.method === ${JSON.stringify(failureMethod)} && (message.method !== 'thread/archive' || message.params.threadId === allThreads[1].id)) {
347
+ child.exitCode = 7;
348
+ child.emit('exit', 7);
349
+ return done();
350
+ }
351
+ let reply = {};
352
+ if (message.method === 'initialize') reply = { codexHome: ${JSON.stringify(failureMethod === 'wrong-home' ? tmpdir() : home)} };
353
+ if (message.method === 'thread/archive') process.stderr.write('archive attempted\\n');
354
+ if (message.method === 'thread/list') reply = { data: message.params.ancestorThreadId ? [] : allThreads };
355
+ if (message.method === 'thread/read') reply = { thread: allThreads.find(eachThread => eachThread.id === message.params.threadId) };
356
+ child.stdout.write(JSON.stringify({ id: message.id, result: reply }) + '\\n');
357
+ done();
358
+ }});
359
+ child.stdin.on('finish', () => process.stderr.write('daemon closed\\n'));
360
+ return child;
361
+ };
362
+ syncBuiltinESMExports();
363
+ process.argv = [process.execPath, fileURLToPath(${JSON.stringify(scriptUrl)}), '--apply', '--codex-home', ${JSON.stringify(home)}, '--codex-path', ${failureMethod === 'missing-binary' ? JSON.stringify(join(home, 'missing')) : 'process.execPath'}];
364
+ await import(${JSON.stringify(scriptUrl)});
365
+ `;
366
+ const execution = spawnSync(process.execPath, ['--input-type=module', '--eval', program], { encoding: 'utf8', timeout: 10_000 });
367
+ assert.equal(execution.status, 1, execution.stderr);
368
+ const allLines = execution.stdout.trim().split('\n');
369
+ assert.equal(allLines.length, 1);
370
+ const report = JSON.parse(allLines[0]);
371
+ const isArchiveFailure = failureMethod === 'thread/archive';
372
+ assert.deepEqual([report.scanned, report.eligible, report.archived], isArchiveFailure ? [3, 2, 0] : [0, 0, 0]);
373
+ assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), isArchiveFailure ? [threadId(2), null, threadId(1), threadId(2)] : [null]);
374
+ const expectedError = failureMethod === 'wrong-home' ? /differs from selected home/ : failureMethod === 'missing-binary' ? /executable not found/ : /exited with code 7/;
375
+ assert.ok(report.failed.every(eachFailure => expectedError.test(eachFailure.error)));
376
+ if (!isArchiveFailure) assert.doesNotMatch(execution.stderr, /archive attempted/);
377
+ if (failureMethod !== 'missing-binary') assert.match(execution.stderr, /daemon closed/);
378
+ } finally {
379
+ rmSync(home, { recursive: true, force: true });
380
+ }
381
+ }
382
+
383
+ for (const eachFailureMethod of ['thread/archive', 'thread/list', 'initialize', 'wrong-home', 'missing-binary']) {
384
+ test(`CLI emits one JSON report and exits unsuccessfully after ${eachFailureMethod} transport loss`, () => {
385
+ assertCliTransportFailure(eachFailureMethod);
386
+ });
387
+ }
388
+
389
+ test('native PATH discovery keeps explicit overrides and rejects missing explicit executables', () => {
390
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-path-'));
391
+ try {
392
+ const executableName = process.platform === 'win32' ? 'codex.exe' : 'codex';
393
+ const binaryPath = join(home, executableName);
394
+ writeFileSync(binaryPath, 'fixture');
395
+ chmodSync(binaryPath, 0o755);
396
+ const environment = { [process.platform === 'win32' ? 'Path' : 'PATH']: home };
397
+ assert.equal(cleanse.findCodexPath(makeOptions({ home }), environment), binaryPath);
398
+ const standalonePath = join(home, 'packages', 'standalone', 'current', 'bin', executableName);
399
+ mkdirSync(dirname(standalonePath), { recursive: true });
400
+ writeFileSync(standalonePath, 'fixture');
401
+ chmodSync(standalonePath, 0o755);
402
+ assert.equal(cleanse.findCodexPath(makeOptions({ home }), environment), standalonePath);
403
+ rmSync(standalonePath);
404
+ assert.equal(cleanse.findCodexPath(makeOptions({ home, codexPath: process.execPath }), environment), process.execPath);
405
+ assert.throws(() => cleanse.findCodexPath(makeOptions({ home, codexPath: join(home, 'missing') }), environment), /executable not found/);
406
+ if (process.platform !== 'win32') {
407
+ chmodSync(binaryPath, 0o644);
408
+ assert.throws(() => cleanse.findCodexPath(makeOptions({ home }), environment), /executable not found/);
409
+ }
410
+ rmSync(binaryPath);
411
+ writeFileSync(join(home, 'codex.cmd'), 'fixture');
412
+ assert.throws(() => cleanse.findCodexPath(makeOptions({ home }), environment), /executable not found/);
413
+ if (process.platform === 'win32') {
414
+ const vendorPath = join(home, 'node_modules', '@openai', 'codex', 'node_modules', '@openai',
415
+ 'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin', executableName);
416
+ mkdirSync(dirname(vendorPath), { recursive: true });
417
+ writeFileSync(vendorPath, 'fixture');
418
+ assert.equal(cleanse.findCodexPath(makeOptions({ home }), environment), vendorPath);
419
+ }
420
+ } finally {
421
+ rmSync(home, { recursive: true, force: true });
422
+ }
423
+ });
424
+
425
+ test('initial listing failure returns a report without archive requests', async () => {
426
+ const allMethods = [];
427
+ const request = async method => {
428
+ allMethods.push(method);
429
+ throw new Error('initial listing failed');
430
+ };
431
+ const report = await cleanseSessions(request, makeOptions({ apply: true }), now);
432
+ assert.deepEqual([report.scanned, report.eligible, report.archived], [0, 0, 0]);
433
+ assert.deepEqual(report.failed, [{ id: null, error: 'initial listing failed' }]);
434
+ assert.deepEqual(allMethods, ['thread/list']);
435
+ });
436
+
437
+ test('lost archive reply still verifies the moved rollout and stops later writes', async () => {
438
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-lost-reply-'));
439
+ try {
440
+ const allThreads = [1, 2].map(number => makeThread(number, cutoff - 1, { path: makeRollout(home, number) }));
441
+ const fixture = makeHiddenRequest(home, allThreads, new Set());
442
+ const request = async (method, params) => {
443
+ const reply = await fixture.request(method, params);
444
+ if (method === 'thread/archive') throw new Error('archive reply lost');
445
+ return reply;
446
+ };
447
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
448
+ assert.equal(report.archived, 1);
449
+ assert.deepEqual(report.failed, [{ id: threadId(1), error: 'archive reply lost' }]);
450
+ assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
451
+ .map(eachCall => eachCall.params.threadId), [threadId(1)]);
452
+ } finally {
453
+ rmSync(home, { recursive: true, force: true });
454
+ }
455
+ });
456
+
457
+ test('hidden discovery transport failure retains the listed count and returns a failure report', async () => {
458
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-discovery-'));
459
+ try {
460
+ makeRollout(home, 1);
461
+ const allMethods = [];
462
+ const request = async method => {
463
+ allMethods.push(method);
464
+ if (method === 'thread/read') throw new Error('discovery transport lost');
465
+ return { data: [makeThread(2, cutoff - 1)] };
466
+ };
467
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now, () => false);
468
+ assert.equal(report.scanned, 1);
469
+ assert.equal(report.archived, 0);
470
+ assert.deepEqual(report.failed, [{ id: threadId(1), error: 'discovery transport lost' }]);
471
+ assert.deepEqual(allMethods, ['thread/list', 'thread/read']);
472
+ } finally {
473
+ rmSync(home, { recursive: true, force: true });
474
+ }
475
+ });
476
+
477
+ test('inactivity days reject cutoffs outside the supported date range', () => {
478
+ for (const eachInactiveDays of ['1e308', '1e9']) {
479
+ assert.throws(() => parseOptions(['--inactive-days', eachInactiveDays]), /valid cutoff date/);
480
+ }
481
+ assert.equal(parseOptions(['--inactive-days', '7']).inactiveDays, 7);
482
+ assert.equal(parseOptions(['--inactive-days', '0']).inactiveDays, 0);
483
+ });
484
+
485
+ test('every value-taking option rejects a following flag or end of arguments', () => {
486
+ const allValueTakingFlags = ['--inactive-days', '--codex-home', '--codex-path', '--exclude-thread-id'];
487
+ for (const eachFlag of allValueTakingFlags) {
488
+ assert.throws(() => parseOptions([eachFlag, '--apply']), new RegExp(`Missing value for ${eachFlag}`));
489
+ assert.throws(() => parseOptions([eachFlag]), new RegExp(`Missing value for ${eachFlag}`));
490
+ }
491
+ });
492
+
493
+ test('uppercase CLI exclusion is stored as a lowercase UUID', () => {
494
+ const uppercaseId = 'ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF';
495
+ const options = parseOptions(['--exclude-thread-id', uppercaseId]);
496
+ assert.deepEqual([...options.excludedThreadIds], [uppercaseId.toLowerCase()]);
497
+ });
498
+
499
+ test('lowercase exclusion protects a native thread with an uppercase UUID', async () => {
500
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
501
+ try {
502
+ const uppercaseId = 'ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF';
503
+ const options = parseOptions(['--codex-home', home, '--exclude-thread-id', uppercaseId.toLowerCase(), '--apply']);
504
+ const fixture = makeRequest([makeThread(1, cutoff - 1, { id: uppercaseId })]);
505
+ const report = await cleanseSessions(fixture.request, options, now);
506
+ assert.equal(report.skippedReasons.excluded, 1);
507
+ assert.equal(report.archived, 0);
508
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
509
+ } finally {
510
+ rmSync(home, { recursive: true, force: true });
511
+ }
512
+ });
513
+
514
+ test('apply finds an old hidden child and archives it before its visible parent', async () => {
515
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
516
+ try {
517
+ const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
518
+ const child = makeThread(2, cutoff - 1, { path: makeRollout(home, 2), parentThreadId: parent.id });
519
+ const fixture = makeHiddenRequest(home, [parent, child], new Set([parent.id]));
520
+ const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
521
+ assert.equal(report.scanned, 2);
522
+ assert.equal(report.archived, 2);
523
+ assert.deepEqual(report.failed, []);
524
+ assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
525
+ .map(eachCall => eachCall.params.threadId), [child.id, parent.id]);
526
+ } finally {
527
+ rmSync(home, { recursive: true, force: true });
528
+ }
529
+ });
530
+
531
+ test('apply archives an old hidden orphan from the selected home only', async () => {
532
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
533
+ const otherHome = mkdtempSync(join(tmpdir(), 'codex-cleanse-other-'));
534
+ try {
535
+ const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
536
+ writeFileSync(join(home, 'sessions', 'notes.jsonl'), 'ignored');
537
+ makeRollout(otherHome, 2);
538
+ const fixture = makeHiddenRequest(home, [orphan], new Set());
539
+ const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
540
+ assert.equal(report.scanned, 1);
541
+ assert.equal(report.archived, 1);
542
+ assert.deepEqual(report.failed, []);
543
+ } finally {
544
+ rmSync(home, { recursive: true, force: true });
545
+ rmSync(otherHome, { recursive: true, force: true });
546
+ }
547
+ });
548
+
549
+ test('a recent hidden child keeps its old parent unarchived', async () => {
550
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
551
+ try {
552
+ const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
553
+ const child = makeThread(2, cutoff + 1, { path: makeRollout(home, 2), parentThreadId: parent.id });
554
+ const fixture = makeHiddenRequest(home, [parent, child], new Set([parent.id]));
555
+ const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
556
+ assert.equal(report.archived, 0);
557
+ assert.equal(report.skippedReasons.descendant, 1);
558
+ assert.equal(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
559
+ } finally {
560
+ rmSync(home, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ test('hidden discovery reports native ID and path mismatches', async () => {
565
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
566
+ try {
567
+ const rolloutPath = makeRollout(home, 1);
568
+ const wrongPath = makeRollout(home, 2);
569
+ for (const thread of [makeThread(2, cutoff - 1, { path: rolloutPath }),
570
+ makeThread(1, cutoff - 1, { path: wrongPath })]) {
571
+ const request = async (method, params) => method === 'thread/read'
572
+ ? { thread }
573
+ : { data: [], nextCursor: null };
574
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
575
+ assert.equal(report.archived, 0);
576
+ assert.ok(report.failed.some(eachFailure => eachFailure.id === threadId(1)));
577
+ }
578
+ } finally {
579
+ rmSync(home, { recursive: true, force: true });
580
+ }
581
+ });
582
+
583
+ test('hidden discovery reports a native read failure', async () => {
584
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
585
+ try {
586
+ makeRollout(home, 1);
587
+ const request = async (method, params) => {
588
+ if (method === 'thread/read') throw new Error('native read failed');
589
+ return { data: [], nextCursor: null };
590
+ };
591
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
592
+ assert.equal(report.archived, 0);
593
+ assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [threadId(1)]);
594
+ } finally {
595
+ rmSync(home, { recursive: true, force: true });
596
+ }
597
+ });
598
+
599
+ test('a hidden child read failure stops its listed parent before archive', async () => {
600
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
601
+ try {
602
+ const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
603
+ makeRollout(home, 2);
604
+ const allCalls = [];
605
+ const request = async (method, params) => {
606
+ allCalls.push({ method, params });
607
+ if (method === 'thread/list') {
608
+ return { data: params.ancestorThreadId ? [] : [parent], nextCursor: null };
609
+ }
610
+ if (method === 'thread/read' && params.threadId === threadId(2)) {
611
+ throw new Error('Hidden child metadata unreadable');
612
+ }
613
+ if (method === 'thread/read') return { thread: parent };
614
+ return {};
615
+ };
616
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
617
+ assert.equal(report.archived, 0);
618
+ assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [threadId(2)]);
619
+ assert.equal(allCalls.filter(eachCall => eachCall.method === 'thread/archive').length, 0);
620
+ } finally {
621
+ rmSync(home, { recursive: true, force: true });
622
+ }
623
+ });
624
+
625
+ test('a hidden child archive failure stops its listed parent with an incomplete ancestor listing', async () => {
626
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
627
+ try {
628
+ const parent = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
629
+ const child = makeThread(2, cutoff - 1, { path: makeRollout(home, 2), parentThreadId: parent.id });
630
+ const fixture = makeHiddenRequest(home, [parent, child], new Set([parent.id]));
631
+ const request = async (method, params) => {
632
+ if (method === 'thread/list' && params.ancestorThreadId) {
633
+ return { data: [], nextCursor: null };
634
+ }
635
+ if (method === 'thread/archive' && params.threadId === child.id) {
636
+ fixture.allCalls.push({ method, params });
637
+ throw new Error('child archive rejected');
638
+ }
639
+ return fixture.request(method, params);
640
+ };
641
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
642
+ assert.equal(report.archived, 0);
643
+ assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [child.id, child.id]);
644
+ assert.deepEqual(fixture.allCalls.filter(eachCall => eachCall.method === 'thread/archive')
645
+ .map(eachCall => eachCall.params.threadId), [child.id]);
646
+ } finally {
647
+ rmSync(home, { recursive: true, force: true });
648
+ }
649
+ });
650
+
651
+ test('empty rollout stubs skip native read and archive', async () => {
652
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
653
+ try {
654
+ const rolloutPath = makeRollout(home, 1);
655
+ writeFileSync(rolloutPath, '');
656
+ const allMethods = [];
657
+ const request = async method => {
658
+ allMethods.push(method);
659
+ if (method !== 'thread/list') throw new Error('Empty rollout reached native session API');
660
+ return { data: [], nextCursor: null };
661
+ };
662
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
663
+ assert.equal(report.scanned, 1);
664
+ assert.equal(report.skippedReasons.emptyRollout, 1);
665
+ assert.equal(report.archived, 0);
666
+ assert.deepEqual(report.failed, []);
667
+ assert.deepEqual(allMethods, ['thread/list']);
668
+ } finally {
669
+ rmSync(home, { recursive: true, force: true });
670
+ }
671
+ });
672
+
673
+ test('hidden archive readback rejects a sibling directory with the archived name prefix', async () => {
674
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
675
+ try {
676
+ const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
677
+ const siblingDirectory = join(home, 'archived_sessions-copy');
678
+ const fixture = makeHiddenRequest(home, [orphan], new Set(), siblingDirectory);
679
+ const report = await cleanseSessions(fixture.request, makeOptions({ home, apply: true }), now);
680
+ assert.equal(report.archived, 0);
681
+ assert.deepEqual(report.failed.map(eachFailure => eachFailure.id), [orphan.id]);
682
+ } finally {
683
+ rmSync(home, { recursive: true, force: true });
684
+ }
685
+ });
686
+
687
+ test('hidden discovery and readback accept paths that resolve to the same rollout file', async () => {
688
+ const home = mkdtempSync(join(tmpdir(), 'codex-cleanse-'));
689
+ try {
690
+ const orphan = makeThread(1, cutoff - 1, { path: makeRollout(home, 1) });
691
+ orphan.path = process.platform === 'win32'
692
+ ? `\\\\?\\${orphan.path}`
693
+ : join(dirname(orphan.path), '..', '01', basename(orphan.path));
694
+ const fixture = makeHiddenRequest(home, [orphan], new Set());
695
+ const request = async (method, params) => {
696
+ const reply = await fixture.request(method, params);
697
+ if (method === 'thread/archive' && process.platform === 'win32') {
698
+ orphan.path = `\\\\?\\${orphan.path}`;
699
+ }
700
+ return reply;
701
+ };
702
+ const report = await cleanseSessions(request, makeOptions({ home, apply: true }), now);
703
+ assert.equal(report.archived, 1);
704
+ assert.deepEqual(report.failed, []);
705
+ } finally {
706
+ rmSync(home, { recursive: true, force: true });
707
+ }
708
+ });
@@ -17,6 +17,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
17
17
  'comments',
18
18
  'condensing-instructions',
19
19
  'codex-review',
20
+ 'codex-cleanse',
20
21
  'context7-mcp',
21
22
  'copilot-finding-triage',
22
23
  'copilot-review',
package/bin/install.mjs CHANGED
@@ -197,6 +197,7 @@ export const CORE_INCLUDE_DIRECTORIES = [
197
197
  export const CORE_SKILLS = [
198
198
  'orchestrator', 'orchestrator-refresh', 'team-advisor',
199
199
  'grok-spawn',
200
+ 'codex-cleanse',
200
201
  'everything-search',
201
202
  'test-runner',
202
203
  'privacy-hygiene',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.28.1",
3
+ "version": "8.29.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {