claude-dev-env 8.28.0 → 8.29.0

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,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
+ }