session-steward 0.1.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,2300 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants, createReadStream } from "node:fs";
3
+ import { promises as fs } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import readline from "node:readline";
7
+
8
+ import {
9
+ inspectJsonlMatches,
10
+ readJsonlEntries,
11
+ rewriteJsonlFile,
12
+ } from "../../storage/jsonl.mjs";
13
+ import {
14
+ backupDatabase,
15
+ batches,
16
+ executeTransaction,
17
+ placeholders,
18
+ queryRows,
19
+ } from "../../storage/sqlite.mjs";
20
+
21
+ function expandHome(value) {
22
+ if (!value || value === "~") {
23
+ return os.homedir();
24
+ }
25
+
26
+ if (value.startsWith("~/")) {
27
+ return path.join(os.homedir(), value.slice(2));
28
+ }
29
+
30
+ return value;
31
+ }
32
+
33
+ function normalizeText(value) {
34
+ if (typeof value !== "string") {
35
+ return "";
36
+ }
37
+
38
+ return value.replace(/\s+/g, " ").trim();
39
+ }
40
+
41
+ function normalizeDisplayName(value) {
42
+ return normalizeText(value);
43
+ }
44
+
45
+ function toTimestampMs(value) {
46
+ if (typeof value === "number" && Number.isFinite(value)) {
47
+ return value;
48
+ }
49
+
50
+ if (typeof value === "string" && value.trim().length > 0) {
51
+ const parsedNumber = Number(value);
52
+
53
+ if (Number.isFinite(parsedNumber)) {
54
+ return parsedNumber;
55
+ }
56
+
57
+ const parsedDate = Date.parse(value);
58
+
59
+ return Number.isFinite(parsedDate) ? parsedDate : 0;
60
+ }
61
+
62
+ return 0;
63
+ }
64
+
65
+ function getMeaningfulUserText(value) {
66
+ const normalized = normalizeText(value);
67
+
68
+ if (!normalized) {
69
+ return "";
70
+ }
71
+
72
+ if (
73
+ normalized.startsWith("<environment_context>") ||
74
+ normalized.startsWith("<subagent_notification>") ||
75
+ normalized.startsWith("The following is the Codex agent history")
76
+ ) {
77
+ return "";
78
+ }
79
+
80
+ return normalized;
81
+ }
82
+
83
+ function extractUserTextFromTranscriptEntry(entry) {
84
+ if (
85
+ entry?.type !== "response_item" ||
86
+ entry?.payload?.type !== "message" ||
87
+ entry?.payload?.role !== "user"
88
+ ) {
89
+ return "";
90
+ }
91
+
92
+ const parts = Array.isArray(entry.payload.content) ? entry.payload.content : [];
93
+ const text = parts
94
+ .map((item) => {
95
+ if (item?.type === "input_text" || item?.type === "output_text") {
96
+ return item.text ?? "";
97
+ }
98
+
99
+ return "";
100
+ })
101
+ .join(" ");
102
+
103
+ return getMeaningfulUserText(text);
104
+ }
105
+
106
+ async function* findTranscriptFiles(rootDirectory) {
107
+ let directory;
108
+
109
+ try {
110
+ directory = await fs.opendir(rootDirectory);
111
+ } catch (error) {
112
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
113
+ return;
114
+ }
115
+
116
+ throw error;
117
+ }
118
+
119
+ for await (const entry of directory) {
120
+ const resolvedPath = path.join(rootDirectory, entry.name);
121
+
122
+ if (entry.isDirectory()) {
123
+ yield* findTranscriptFiles(resolvedPath);
124
+ continue;
125
+ }
126
+
127
+ if (entry.isFile() && /^rollout-.*\.jsonl$/u.test(entry.name)) {
128
+ yield resolvedPath;
129
+ }
130
+ }
131
+ }
132
+
133
+ async function pathExists(targetPath) {
134
+ try {
135
+ await fs.access(targetPath);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ async function readFirstLine(filePath) {
143
+ const stream = createReadStream(filePath, {
144
+ encoding: "utf8",
145
+ });
146
+ const interfaceHandle = readline.createInterface({
147
+ crlfDelay: Infinity,
148
+ input: stream,
149
+ });
150
+
151
+ try {
152
+ for await (const line of interfaceHandle) {
153
+ return line;
154
+ }
155
+ } finally {
156
+ interfaceHandle.close();
157
+ stream.destroy();
158
+ }
159
+
160
+ return "";
161
+ }
162
+
163
+ async function parseTranscriptHeader(filePath) {
164
+ const firstLine = await readFirstLine(filePath);
165
+
166
+ if (!firstLine) {
167
+ return null;
168
+ }
169
+
170
+ const parsed = JSON.parse(firstLine);
171
+
172
+ if (parsed?.type !== "session_meta" || !parsed?.payload?.id) {
173
+ return null;
174
+ }
175
+
176
+ const payload = parsed.payload;
177
+ const subagentParentId =
178
+ payload?.source?.subagent?.thread_spawn?.parent_thread_id ?? null;
179
+
180
+ return {
181
+ agentNickname: payload.agent_nickname ?? null,
182
+ agentRole: payload.agent_role ?? null,
183
+ cwd: payload.cwd ?? "",
184
+ filePath,
185
+ forkedFromId: payload.forked_from_id ?? null,
186
+ id: payload.id,
187
+ parentThreadId: subagentParentId,
188
+ timestampMs: toTimestampMs(payload.timestamp),
189
+ };
190
+ }
191
+
192
+ async function parseTranscriptFallback(filePath) {
193
+ const stream = createReadStream(filePath, {
194
+ encoding: "utf8",
195
+ });
196
+ const interfaceHandle = readline.createInterface({
197
+ crlfDelay: Infinity,
198
+ input: stream,
199
+ });
200
+ let firstUserMessage = "";
201
+ let latestThreadName = "";
202
+
203
+ try {
204
+ for await (const line of interfaceHandle) {
205
+ if (!line.trim()) {
206
+ continue;
207
+ }
208
+
209
+ let parsedLine = null;
210
+
211
+ try {
212
+ parsedLine = JSON.parse(line);
213
+ } catch {
214
+ continue;
215
+ }
216
+
217
+ if (
218
+ parsedLine?.type === "event_msg" &&
219
+ parsedLine?.payload?.type === "thread_name_updated"
220
+ ) {
221
+ const threadName = normalizeDisplayName(parsedLine.payload.thread_name ?? "");
222
+
223
+ if (threadName) {
224
+ latestThreadName = threadName;
225
+ }
226
+
227
+ continue;
228
+ }
229
+
230
+ if (!firstUserMessage) {
231
+ const userText = extractUserTextFromTranscriptEntry(parsedLine);
232
+
233
+ if (userText) {
234
+ firstUserMessage = userText;
235
+ }
236
+ }
237
+ }
238
+ } finally {
239
+ interfaceHandle.close();
240
+ stream.destroy();
241
+ }
242
+
243
+ return {
244
+ firstUserMessage,
245
+ latestThreadName,
246
+ };
247
+ }
248
+
249
+ async function readSessionIndexMap(filePath, sessionIds) {
250
+ const map = new Map();
251
+
252
+ if (sessionIds.size === 0) {
253
+ return map;
254
+ }
255
+
256
+ for await (const entry of readJsonlEntries(filePath)) {
257
+ if (!entry.parsed?.id) {
258
+ continue;
259
+ }
260
+
261
+ const id = String(entry.parsed.id);
262
+
263
+ if (!sessionIds.has(id)) {
264
+ continue;
265
+ }
266
+
267
+ const threadName = normalizeDisplayName(entry.parsed.thread_name ?? "");
268
+ const updatedAt = toTimestampMs(entry.parsed.updated_at);
269
+ const current = map.get(id);
270
+
271
+ if (!current || updatedAt >= current.updatedAt) {
272
+ map.set(id, {
273
+ threadName,
274
+ updatedAt,
275
+ });
276
+ }
277
+ }
278
+
279
+ return map;
280
+ }
281
+
282
+ async function readHistoryMap(filePath, sessionIds) {
283
+ const map = new Map();
284
+
285
+ if (sessionIds.size === 0) {
286
+ return map;
287
+ }
288
+
289
+ for await (const entry of readJsonlEntries(filePath)) {
290
+ if (!entry.parsed?.session_id) {
291
+ continue;
292
+ }
293
+
294
+ const sessionId = String(entry.parsed.session_id);
295
+
296
+ if (!sessionIds.has(sessionId)) {
297
+ continue;
298
+ }
299
+
300
+ const text = getMeaningfulUserText(entry.parsed.text ?? "");
301
+
302
+ if (!text) {
303
+ continue;
304
+ }
305
+
306
+ const timestamp = toTimestampMs(entry.parsed.ts);
307
+ const current = map.get(sessionId);
308
+
309
+ if (!current || timestamp < current.timestamp) {
310
+ map.set(sessionId, {
311
+ text,
312
+ timestamp,
313
+ });
314
+ }
315
+ }
316
+
317
+ return map;
318
+ }
319
+
320
+ function getHistoryCandidateIds(threadRows, transcriptHeaders, sessionIndexMap) {
321
+ const threadIds = new Set();
322
+ const sessionIds = new Set();
323
+
324
+ for (const threadRow of threadRows) {
325
+ const id = String(threadRow.id);
326
+ threadIds.add(id);
327
+
328
+ if (
329
+ !normalizeDisplayName(threadRow.title ?? "") &&
330
+ !getMeaningfulUserText(threadRow.first_user_message ?? "") &&
331
+ !sessionIndexMap.get(id)?.threadName
332
+ ) {
333
+ sessionIds.add(id);
334
+ }
335
+ }
336
+
337
+ for (const transcriptId of transcriptHeaders.keys()) {
338
+ if (!threadIds.has(transcriptId) && !sessionIndexMap.get(transcriptId)?.threadName) {
339
+ sessionIds.add(transcriptId);
340
+ }
341
+ }
342
+
343
+ return sessionIds;
344
+ }
345
+
346
+ function deriveDisplayName({
347
+ historyMap,
348
+ sessionIndexMap,
349
+ sessionRecord,
350
+ transcriptFallback,
351
+ }) {
352
+ const sqliteTitle = normalizeDisplayName(sessionRecord.title ?? "");
353
+ const sqliteFirstUserMessage = getMeaningfulUserText(
354
+ sessionRecord.firstUserMessage ?? "",
355
+ );
356
+ const sessionIndexEntry = sessionIndexMap.get(sessionRecord.id);
357
+ const sqliteTitleLooksPrompt =
358
+ Boolean(sqliteTitle) &&
359
+ Boolean(sqliteFirstUserMessage) &&
360
+ sqliteTitle === sqliteFirstUserMessage;
361
+
362
+ if (sessionIndexEntry?.threadName && (!sqliteTitle || sqliteTitleLooksPrompt)) {
363
+ return {
364
+ source: "session_index",
365
+ value: sessionIndexEntry.threadName,
366
+ };
367
+ }
368
+
369
+ if (
370
+ transcriptFallback?.latestThreadName &&
371
+ (!sqliteTitle || sqliteTitleLooksPrompt)
372
+ ) {
373
+ return {
374
+ source: "transcript_thread_name",
375
+ value: transcriptFallback.latestThreadName,
376
+ };
377
+ }
378
+
379
+ if (sqliteTitle) {
380
+ return {
381
+ source: "sqlite_title",
382
+ value: sqliteTitle,
383
+ };
384
+ }
385
+
386
+ if (sqliteFirstUserMessage) {
387
+ return {
388
+ source: "sqlite_first_user_message",
389
+ value: sqliteFirstUserMessage,
390
+ };
391
+ }
392
+
393
+ const historyEntry = historyMap.get(sessionRecord.id);
394
+
395
+ if (historyEntry?.text) {
396
+ return {
397
+ source: "history_first_user_message",
398
+ value: historyEntry.text,
399
+ };
400
+ }
401
+
402
+ if (transcriptFallback?.firstUserMessage) {
403
+ return {
404
+ source: "transcript_first_user_message",
405
+ value: transcriptFallback.firstUserMessage,
406
+ };
407
+ }
408
+
409
+ return {
410
+ source: "fallback",
411
+ value: `Untitled ${sessionRecord.id.slice(0, 8)}`,
412
+ };
413
+ }
414
+
415
+ function getCodexPaths(codexHomeInput) {
416
+ const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
417
+
418
+ return {
419
+ codexHome,
420
+ desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
421
+ desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
422
+ goalsDatabasePath: path.join(codexHome, "goals_1.sqlite"),
423
+ historyPath: path.join(codexHome, "history.jsonl"),
424
+ logsDatabasePath: path.join(codexHome, "logs_2.sqlite"),
425
+ memoryDatabasePath: path.join(codexHome, "memories_1.sqlite"),
426
+ sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
427
+ sessionsDirectory: path.join(codexHome, "sessions"),
428
+ stateDatabasePath: path.join(codexHome, "state_5.sqlite"),
429
+ };
430
+ }
431
+
432
+ const DESKTOP_THREAD_MAP_KEYS = [
433
+ "thread-project-assignments",
434
+ "thread-projectless-output-directories",
435
+ "thread-writable-roots",
436
+ "thread-workspace-root-hints",
437
+ ];
438
+
439
+ const DESKTOP_THREAD_ARRAY_KEYS = ["projectless-thread-ids"];
440
+
441
+ const COMPATIBILITY_PROFILE = {
442
+ id: "local-store-2026-07",
443
+ builtFor: {
444
+ chatgptDesktop: ["26.727.40816"],
445
+ codexCli: ["0.144.1"],
446
+ },
447
+ };
448
+
449
+ const SCHEMA_REQUIREMENTS = [
450
+ {
451
+ database: "state_5.sqlite",
452
+ required: true,
453
+ tables: [
454
+ { name: "threads", columns: ["id", "rollout_path", "cwd", "title", "first_user_message", "agent_nickname", "agent_role", "archived", "is_pinned"] },
455
+ { name: "thread_spawn_edges", columns: ["parent_thread_id", "child_thread_id", "status"] },
456
+ ],
457
+ },
458
+ { database: "logs_2.sqlite", required: false, tables: [{ name: "logs", columns: ["thread_id"] }] },
459
+ { database: "memories_1.sqlite", required: false, tables: [{ name: "stage1_outputs", columns: ["thread_id"] }] },
460
+ { database: "goals_1.sqlite", required: false, tables: [{ name: "thread_goals", columns: ["thread_id"] }, { name: "thread_goal_continuation_deferrals", columns: ["thread_id"] }] },
461
+ ];
462
+
463
+ function getMatchingDesktopStateEntryCount(state, deletedIdSet) {
464
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
465
+ return 0;
466
+ }
467
+
468
+ let count = 0;
469
+
470
+ for (const key of DESKTOP_THREAD_MAP_KEYS) {
471
+ const value = state[key];
472
+
473
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
474
+ continue;
475
+ }
476
+
477
+ for (const id of deletedIdSet) {
478
+ if (Object.hasOwn(value, id)) {
479
+ count += 1;
480
+ }
481
+ }
482
+ }
483
+
484
+ for (const key of DESKTOP_THREAD_ARRAY_KEYS) {
485
+ const value = state[key];
486
+
487
+ if (!Array.isArray(value)) {
488
+ continue;
489
+ }
490
+
491
+ count += value.filter((id) => deletedIdSet.has(String(id))).length;
492
+ }
493
+
494
+ return count;
495
+ }
496
+
497
+ async function readJsonFile(filePath) {
498
+ try {
499
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
500
+ } catch (error) {
501
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
502
+ return null;
503
+ }
504
+
505
+ throw error;
506
+ }
507
+ }
508
+
509
+ function removeDesktopStateEntries(state, deletedIdSet) {
510
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
511
+ throw new Error("Codex desktop state is not a JSON object.");
512
+ }
513
+
514
+ const updatedState = { ...state };
515
+
516
+ for (const key of DESKTOP_THREAD_MAP_KEYS) {
517
+ const value = updatedState[key];
518
+
519
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
520
+ continue;
521
+ }
522
+
523
+ const updatedValue = { ...value };
524
+
525
+ for (const id of deletedIdSet) {
526
+ delete updatedValue[id];
527
+ }
528
+
529
+ updatedState[key] = updatedValue;
530
+ }
531
+
532
+ for (const key of DESKTOP_THREAD_ARRAY_KEYS) {
533
+ const value = updatedState[key];
534
+
535
+ if (Array.isArray(value)) {
536
+ updatedState[key] = value.filter((id) => !deletedIdSet.has(String(id)));
537
+ }
538
+ }
539
+
540
+ return updatedState;
541
+ }
542
+
543
+ export async function loadSessionStore({ codexHome }) {
544
+ const paths = getCodexPaths(codexHome);
545
+ const [
546
+ hasDesktopState,
547
+ hasDesktopStateBackup,
548
+ hasGoalsDatabase,
549
+ hasLogsDatabase,
550
+ hasMemoryDatabase,
551
+ ] = await Promise.all([
552
+ pathExists(paths.desktopStatePath),
553
+ pathExists(paths.desktopStateBackupPath),
554
+ pathExists(paths.goalsDatabasePath),
555
+ pathExists(paths.logsDatabasePath),
556
+ pathExists(paths.memoryDatabasePath),
557
+ ]);
558
+ const threadRows = queryRows(
559
+ paths.stateDatabasePath,
560
+ `
561
+ select
562
+ id,
563
+ rollout_path,
564
+ cwd,
565
+ title,
566
+ first_user_message,
567
+ agent_nickname,
568
+ agent_role,
569
+ archived,
570
+ is_pinned,
571
+ coalesce(created_at_ms, created_at * 1000) as created_at_ms,
572
+ coalesce(updated_at_ms, updated_at * 1000) as updated_at_ms
573
+ from threads
574
+ order by updated_at_ms desc, updated_at desc
575
+ `,
576
+ );
577
+ const spawnEdges = queryRows(
578
+ paths.stateDatabasePath,
579
+ `
580
+ select
581
+ parent_thread_id,
582
+ child_thread_id,
583
+ status
584
+ from thread_spawn_edges
585
+ `,
586
+ );
587
+ const transcriptHeaders = new Map();
588
+
589
+ for await (const transcriptFile of findTranscriptFiles(paths.sessionsDirectory)) {
590
+ try {
591
+ const header = await parseTranscriptHeader(transcriptFile);
592
+
593
+ const current = header?.id ? transcriptHeaders.get(header.id) : null;
594
+
595
+ if (header?.id && (!current || header.filePath > current.filePath)) {
596
+ transcriptHeaders.set(header.id, header);
597
+ }
598
+ } catch {
599
+ continue;
600
+ }
601
+ }
602
+
603
+ const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
604
+
605
+ for (const transcriptId of transcriptHeaders.keys()) {
606
+ discoveryIds.add(transcriptId);
607
+ }
608
+
609
+ const sessionIndexMap = await readSessionIndexMap(paths.sessionIndexPath, discoveryIds);
610
+ const historyMap = await readHistoryMap(
611
+ paths.historyPath,
612
+ getHistoryCandidateIds(threadRows, transcriptHeaders, sessionIndexMap),
613
+ );
614
+ const childIdsByParentId = new Map();
615
+ const parentIdsByChildId = new Map();
616
+
617
+ for (const edge of spawnEdges) {
618
+ const parentId = edge.parent_thread_id;
619
+ const childId = edge.child_thread_id;
620
+
621
+ if (!childIdsByParentId.has(parentId)) {
622
+ childIdsByParentId.set(parentId, []);
623
+ }
624
+
625
+ childIdsByParentId.get(parentId).push(childId);
626
+ parentIdsByChildId.set(childId, parentId);
627
+ }
628
+
629
+ for (const [childId, transcriptHeader] of transcriptHeaders.entries()) {
630
+ const parentId = transcriptHeader.parentThreadId;
631
+
632
+ if (!parentId || parentIdsByChildId.has(childId)) {
633
+ continue;
634
+ }
635
+
636
+ const childIds = childIdsByParentId.get(parentId) ?? [];
637
+ childIds.push(childId);
638
+ childIdsByParentId.set(parentId, childIds);
639
+ parentIdsByChildId.set(childId, parentId);
640
+ }
641
+
642
+ const recordsById = new Map();
643
+ const fallbackIds = new Set();
644
+
645
+ for (const threadRow of threadRows) {
646
+ const transcriptHeader = transcriptHeaders.get(threadRow.id) ?? null;
647
+ const parentThreadId =
648
+ parentIdsByChildId.get(threadRow.id) ??
649
+ transcriptHeader?.parentThreadId ??
650
+ null;
651
+ const rolloutPath = threadRow.rollout_path ?? transcriptHeader?.filePath ?? "";
652
+ const record = {
653
+ agentNickname:
654
+ threadRow.agent_nickname ?? transcriptHeader?.agentNickname ?? null,
655
+ agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
656
+ archived: Boolean(threadRow.archived),
657
+ childThreadIds: childIdsByParentId.get(threadRow.id) ?? [],
658
+ createdAtMs: toTimestampMs(threadRow.created_at_ms),
659
+ cwd: threadRow.cwd ?? transcriptHeader?.cwd ?? "",
660
+ displayName: "",
661
+ firstUserMessage: threadRow.first_user_message ?? "",
662
+ forkedFromId: transcriptHeader?.forkedFromId ?? null,
663
+ id: threadRow.id,
664
+ isFork: Boolean(transcriptHeader?.forkedFromId),
665
+ isPinned: Boolean(threadRow.is_pinned),
666
+ isSubagent: Boolean(parentThreadId),
667
+ parentThreadId,
668
+ recordSource: "sqlite",
669
+ providerId: "codex",
670
+ rolloutMissing: rolloutPath
671
+ ? !transcriptHeaders.has(threadRow.id)
672
+ : true,
673
+ rolloutPath,
674
+ title: threadRow.title ?? "",
675
+ titleSource: "",
676
+ updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
677
+ };
678
+
679
+ if (
680
+ !normalizeDisplayName(record.title) &&
681
+ !getMeaningfulUserText(record.firstUserMessage) &&
682
+ !sessionIndexMap.get(record.id)?.threadName &&
683
+ !historyMap.get(record.id)?.text
684
+ ) {
685
+ fallbackIds.add(record.id);
686
+ }
687
+
688
+ recordsById.set(record.id, record);
689
+ }
690
+
691
+ for (const [transcriptId, transcriptHeader] of transcriptHeaders.entries()) {
692
+ if (recordsById.has(transcriptId)) {
693
+ continue;
694
+ }
695
+
696
+ const parentThreadId =
697
+ parentIdsByChildId.get(transcriptId) ?? transcriptHeader.parentThreadId ?? null;
698
+ const record = {
699
+ agentNickname: transcriptHeader.agentNickname,
700
+ agentRole: transcriptHeader.agentRole,
701
+ archived: false,
702
+ childThreadIds: childIdsByParentId.get(transcriptId) ?? [],
703
+ createdAtMs: transcriptHeader.timestampMs,
704
+ cwd: transcriptHeader.cwd,
705
+ displayName: "",
706
+ firstUserMessage: "",
707
+ forkedFromId: transcriptHeader.forkedFromId,
708
+ id: transcriptId,
709
+ isFork: Boolean(transcriptHeader.forkedFromId),
710
+ isPinned: false,
711
+ isSubagent: Boolean(parentThreadId),
712
+ parentThreadId,
713
+ recordSource: "transcript",
714
+ providerId: "codex",
715
+ rolloutMissing: false,
716
+ rolloutPath: transcriptHeader.filePath,
717
+ title: "",
718
+ titleSource: "",
719
+ updatedAtMs: 0,
720
+ };
721
+
722
+ fallbackIds.add(transcriptId);
723
+ recordsById.set(transcriptId, record);
724
+ }
725
+
726
+ const transcriptFallbackById = new Map();
727
+
728
+ for (const sessionId of fallbackIds) {
729
+ const record = recordsById.get(sessionId);
730
+
731
+ if (!record?.rolloutPath) {
732
+ continue;
733
+ }
734
+
735
+ try {
736
+ transcriptFallbackById.set(
737
+ sessionId,
738
+ await parseTranscriptFallback(record.rolloutPath),
739
+ );
740
+ } catch {
741
+ continue;
742
+ }
743
+ }
744
+
745
+ const records = [...recordsById.values()].map((record) => {
746
+ const derivedDisplayName = deriveDisplayName({
747
+ historyMap,
748
+ sessionIndexMap,
749
+ sessionRecord: record,
750
+ transcriptFallback: transcriptFallbackById.get(record.id),
751
+ });
752
+
753
+ return {
754
+ ...record,
755
+ childThreadIds: [...record.childThreadIds].sort(),
756
+ displayName: derivedDisplayName.value,
757
+ titleSource: derivedDisplayName.source,
758
+ updatedAtMs:
759
+ record.updatedAtMs ||
760
+ sessionIndexMap.get(record.id)?.updatedAt ||
761
+ record.createdAtMs,
762
+ };
763
+ });
764
+
765
+ return {
766
+ childIdsByParentId,
767
+ codexHome: paths.codexHome,
768
+ desktopStateBackupPath: paths.desktopStateBackupPath,
769
+ desktopStatePath: paths.desktopStatePath,
770
+ hasDesktopState,
771
+ hasDesktopStateBackup,
772
+ hasGoalsDatabase,
773
+ hasLogsDatabase,
774
+ hasMemoryDatabase,
775
+ goalsDatabasePath: paths.goalsDatabasePath,
776
+ logsDatabasePath: paths.logsDatabasePath,
777
+ memoryDatabasePath: paths.memoryDatabasePath,
778
+ records,
779
+ recordsById: new Map(records.map((record) => [record.id, record])),
780
+ sessionIndexPath: paths.sessionIndexPath,
781
+ spawnEdges,
782
+ stateDatabasePath: paths.stateDatabasePath,
783
+ transcriptHeaders,
784
+ historyPath: paths.historyPath,
785
+ };
786
+ }
787
+
788
+ function inspectSqliteTable(databasePath, tableName) {
789
+ try {
790
+ const tables = queryRows(
791
+ databasePath,
792
+ "select name from sqlite_master where type = 'table' and name = ?",
793
+ [tableName],
794
+ );
795
+
796
+ if (tables.length === 0) {
797
+ return { exists: false, columns: [] };
798
+ }
799
+
800
+ const columns = queryRows(databasePath, "select name from pragma_table_info(?)", [tableName])
801
+ .map((column) => String(column.name));
802
+ return { exists: true, columns };
803
+ } catch (error) {
804
+ return { error: error instanceof Error ? error.message : "Unable to inspect database." };
805
+ }
806
+ }
807
+
808
+ export async function diagnoseStorageCompatibility({ codexHome }) {
809
+ const paths = getCodexPaths(codexHome);
810
+ const recognizedDatabases = new Set(SCHEMA_REQUIREMENTS.map((requirement) => requirement.database));
811
+ const missing = [];
812
+ const changed = [];
813
+ const available = [];
814
+
815
+ for (const requirement of SCHEMA_REQUIREMENTS) {
816
+ const databasePath = path.join(paths.codexHome, requirement.database);
817
+
818
+ if (!(await pathExists(databasePath))) {
819
+ if (requirement.required) {
820
+ missing.push(`Required session database is missing: ${requirement.database}`);
821
+ } else {
822
+ available.push(`Not present: ${requirement.database}`);
823
+ }
824
+ continue;
825
+ }
826
+
827
+ let databaseChanged = false;
828
+
829
+ for (const table of requirement.tables) {
830
+ const inspection = inspectSqliteTable(databasePath, table.name);
831
+
832
+ if (inspection.error) {
833
+ changed.push(`Could not read ${requirement.database}.`);
834
+ databaseChanged = true;
835
+ continue;
836
+ }
837
+
838
+ if (!inspection.exists) {
839
+ changed.push(`Expected table is missing in ${requirement.database}: ${table.name}.`);
840
+ databaseChanged = true;
841
+ continue;
842
+ }
843
+
844
+ const missingColumns = table.columns.filter((column) => !inspection.columns.includes(column));
845
+
846
+ if (missingColumns.length > 0) {
847
+ changed.push(`Expected fields changed in ${requirement.database}: ${table.name}.`);
848
+ databaseChanged = true;
849
+ }
850
+ }
851
+
852
+ if (!databaseChanged) {
853
+ available.push(`Supported: ${requirement.database}`);
854
+ }
855
+ }
856
+
857
+ let entries = [];
858
+ try {
859
+ entries = await fs.readdir(paths.codexHome, { withFileTypes: true });
860
+ } catch {
861
+ missing.push("The local Codex folder could not be read.");
862
+ }
863
+
864
+ const newlyDiscovered = entries
865
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".sqlite") && !recognizedDatabases.has(entry.name))
866
+ .map((entry) => `Other local database found: ${entry.name}`)
867
+ .sort();
868
+ const status = missing.length > 0 || changed.length > 0
869
+ ? "update-needed"
870
+ : newlyDiscovered.length > 0
871
+ ? "newer-version"
872
+ : "ready";
873
+
874
+ return {
875
+ available,
876
+ builtFor: COMPATIBILITY_PROFILE.builtFor,
877
+ changed,
878
+ missing,
879
+ newlyDiscovered,
880
+ profileId: COMPATIBILITY_PROFILE.id,
881
+ status,
882
+ };
883
+ }
884
+
885
+ export async function assertDeepCleanupSupported({ codexHome }) {
886
+ const diagnostic = await diagnoseStorageCompatibility({ codexHome });
887
+
888
+ if (diagnostic.status === "newer-version") {
889
+ throw new Error("Deep cleanup is paused because unrecognized Codex storage was found.");
890
+ }
891
+
892
+ if (diagnostic.status !== "ready") {
893
+ throw new Error("Deep cleanup is paused because this Codex storage layout is not supported.");
894
+ }
895
+
896
+ return diagnostic;
897
+ }
898
+
899
+ const DEFAULT_PAGE_SIZE = 25;
900
+ const MAX_PAGE_SIZE = 100;
901
+ const SUPPORTING_THREAD_PREFIX = "The following is the Codex agent history whose request action you are assessing";
902
+
903
+ function getSessionOrder(sort) {
904
+ const updated = "coalesce(t.updated_at_ms, t.updated_at * 1000, 0)";
905
+ const created = "coalesce(t.created_at_ms, t.created_at * 1000, 0)";
906
+ const name = "lower(coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), t.id))";
907
+
908
+ return {
909
+ created: `${created} desc, t.id asc`,
910
+ cwd: `lower(coalesce(t.cwd, '')) asc, ${updated} desc, t.id asc`,
911
+ name: `${name} asc, ${updated} desc, t.id asc`,
912
+ updated: `${updated} desc, t.id asc`,
913
+ }[sort] ?? `${updated} desc, t.id asc`;
914
+ }
915
+
916
+ function getSessionConditions({ includeInternals, includeSupporting, search }) {
917
+ const conditions = [];
918
+ const parameters = [];
919
+
920
+ if (!includeInternals) {
921
+ conditions.push(`not exists (
922
+ select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
923
+ )`);
924
+ }
925
+
926
+ if (!includeSupporting) {
927
+ conditions.push("coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') not like ?");
928
+ parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
929
+ }
930
+
931
+ const normalizedSearch = normalizeText(search).toLowerCase();
932
+
933
+ if (normalizedSearch) {
934
+ conditions.push(`(
935
+ instr(lower(t.id), ?) > 0
936
+ or instr(lower(coalesce(t.title, '')), ?) > 0
937
+ or instr(lower(coalesce(t.first_user_message, '')), ?) > 0
938
+ or instr(lower(coalesce(t.cwd, '')), ?) > 0
939
+ or instr(lower(coalesce(t.rollout_path, '')), ?) > 0
940
+ )`);
941
+ parameters.push(...Array(5).fill(normalizedSearch));
942
+ }
943
+
944
+ return {
945
+ parameters,
946
+ sql: conditions.length > 0 ? `where ${conditions.join(" and ")}` : "",
947
+ };
948
+ }
949
+
950
+ const SESSION_COLUMNS = `
951
+ t.id,
952
+ t.rollout_path,
953
+ t.cwd,
954
+ t.title,
955
+ t.first_user_message,
956
+ t.agent_nickname,
957
+ t.agent_role,
958
+ t.archived,
959
+ t.is_pinned,
960
+ coalesce(t.created_at_ms, t.created_at * 1000, 0) as created_at_ms,
961
+ coalesce(t.updated_at_ms, t.updated_at * 1000, 0) as updated_at_ms,
962
+ (
963
+ select edge.parent_thread_id
964
+ from thread_spawn_edges edge
965
+ where edge.child_thread_id = t.id
966
+ limit 1
967
+ ) as parent_thread_id
968
+ `;
969
+
970
+ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
971
+ const childIdsByParentId = new Map();
972
+
973
+ if (threadRows.length > 0) {
974
+ const ids = threadRows.map((row) => String(row.id));
975
+
976
+ for (const idBatch of batches(ids)) {
977
+ const edges = queryRows(
978
+ stateDatabasePath,
979
+ `select parent_thread_id, child_thread_id
980
+ from thread_spawn_edges
981
+ where parent_thread_id in (${placeholders(idBatch)})`,
982
+ idBatch,
983
+ );
984
+
985
+ for (const edge of edges) {
986
+ const childIds = childIdsByParentId.get(edge.parent_thread_id) ?? [];
987
+ childIds.push(edge.child_thread_id);
988
+ childIdsByParentId.set(edge.parent_thread_id, childIds);
989
+ }
990
+ }
991
+ }
992
+
993
+ const records = [];
994
+
995
+ for (const threadRow of threadRows) {
996
+ const title = normalizeDisplayName(threadRow.title ?? "");
997
+ const firstUserMessage = getMeaningfulUserText(threadRow.first_user_message ?? "");
998
+ const displayName = title || firstUserMessage || `Untitled ${String(threadRow.id).slice(0, 8)}`;
999
+ const rolloutPath = threadRow.rollout_path ?? "";
1000
+ let transcriptHeader = null;
1001
+
1002
+ if (rolloutPath) {
1003
+ try {
1004
+ transcriptHeader = await parseTranscriptHeader(rolloutPath);
1005
+ } catch {
1006
+ transcriptHeader = null;
1007
+ }
1008
+ }
1009
+
1010
+ const parentThreadId = threadRow.parent_thread_id ?? transcriptHeader?.parentThreadId ?? null;
1011
+
1012
+ records.push({
1013
+ agentNickname: threadRow.agent_nickname ?? transcriptHeader?.agentNickname ?? null,
1014
+ agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
1015
+ archived: Boolean(threadRow.archived),
1016
+ childThreadIds: [...(childIdsByParentId.get(threadRow.id) ?? [])].sort(),
1017
+ createdAtMs: toTimestampMs(threadRow.created_at_ms),
1018
+ cwd: threadRow.cwd ?? "",
1019
+ displayName,
1020
+ firstUserMessage: threadRow.first_user_message ?? "",
1021
+ forkedFromId: transcriptHeader?.forkedFromId ?? null,
1022
+ id: String(threadRow.id),
1023
+ isFork: Boolean(transcriptHeader?.forkedFromId),
1024
+ isPinned: Boolean(threadRow.is_pinned),
1025
+ isSubagent: Boolean(parentThreadId),
1026
+ parentThreadId,
1027
+ providerId: "codex",
1028
+ recordSource: "sqlite",
1029
+ rolloutMissing: rolloutPath ? !transcriptHeader : true,
1030
+ rolloutPath,
1031
+ title: threadRow.title ?? "",
1032
+ titleSource: title ? "sqlite_title" : firstUserMessage ? "sqlite_first_user_message" : "fallback",
1033
+ updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
1034
+ });
1035
+ }
1036
+
1037
+ return records;
1038
+ }
1039
+
1040
+ async function getStoreAvailability(paths) {
1041
+ const [
1042
+ hasDesktopState,
1043
+ hasDesktopStateBackup,
1044
+ hasGoalsDatabase,
1045
+ hasLogsDatabase,
1046
+ hasMemoryDatabase,
1047
+ ] = await Promise.all([
1048
+ pathExists(paths.desktopStatePath),
1049
+ pathExists(paths.desktopStateBackupPath),
1050
+ pathExists(paths.goalsDatabasePath),
1051
+ pathExists(paths.logsDatabasePath),
1052
+ pathExists(paths.memoryDatabasePath),
1053
+ ]);
1054
+
1055
+ return {
1056
+ hasDesktopState,
1057
+ hasDesktopStateBackup,
1058
+ hasGoalsDatabase,
1059
+ hasLogsDatabase,
1060
+ hasMemoryDatabase,
1061
+ };
1062
+ }
1063
+
1064
+ async function indexTranscriptHeaders(sessionsDirectory) {
1065
+ const headersById = new Map();
1066
+
1067
+ for await (const transcriptFile of findTranscriptFiles(sessionsDirectory)) {
1068
+ try {
1069
+ const header = await parseTranscriptHeader(transcriptFile);
1070
+ const current = header?.id ? headersById.get(String(header.id)) : null;
1071
+
1072
+ if (header?.id && (!current || header.filePath > current.filePath)) {
1073
+ headersById.set(String(header.id), { ...header, id: String(header.id) });
1074
+ }
1075
+ } catch {
1076
+ continue;
1077
+ }
1078
+ }
1079
+
1080
+ return headersById;
1081
+ }
1082
+
1083
+ function getTranscriptChildrenByParentId(transcriptHeaders) {
1084
+ const childIdsByParentId = new Map();
1085
+
1086
+ for (const [childId, header] of transcriptHeaders.entries()) {
1087
+ if (!header.parentThreadId) continue;
1088
+ const parentId = String(header.parentThreadId);
1089
+ const childIds = childIdsByParentId.get(parentId) ?? [];
1090
+ childIds.push(childId);
1091
+ childIdsByParentId.set(parentId, childIds);
1092
+ }
1093
+
1094
+ return childIdsByParentId;
1095
+ }
1096
+
1097
+ function queryRelatedSpawnEdges(stateDatabasePath, ids) {
1098
+ const edgesByKey = new Map();
1099
+
1100
+ for (const idBatch of batches(ids)) {
1101
+ const idPlaceholders = placeholders(idBatch);
1102
+ const rows = queryRows(
1103
+ stateDatabasePath,
1104
+ `select parent_thread_id, child_thread_id, status
1105
+ from thread_spawn_edges
1106
+ where parent_thread_id in (${idPlaceholders})
1107
+ or child_thread_id in (${idPlaceholders})`,
1108
+ [...idBatch, ...idBatch],
1109
+ );
1110
+
1111
+ for (const edge of rows) {
1112
+ edgesByKey.set(`${edge.parent_thread_id}\0${edge.child_thread_id}`, edge);
1113
+ }
1114
+ }
1115
+
1116
+ return [...edgesByKey.values()];
1117
+ }
1118
+
1119
+ export async function loadDeletionStore({ codexHome, recordIds }) {
1120
+ const paths = getCodexPaths(codexHome);
1121
+ const selectedIds = new Set(recordIds.map(String));
1122
+
1123
+ if (selectedIds.size === 0) {
1124
+ throw new Error("Select at least one session.");
1125
+ }
1126
+
1127
+ const [availability, transcriptHeaders] = await Promise.all([
1128
+ getStoreAvailability(paths),
1129
+ indexTranscriptHeaders(paths.sessionsDirectory),
1130
+ ]);
1131
+ const transcriptChildrenByParentId = getTranscriptChildrenByParentId(transcriptHeaders);
1132
+ const pendingIds = [...selectedIds];
1133
+
1134
+ for (let offset = 0; offset < pendingIds.length; offset += 400) {
1135
+ const idBatch = pendingIds.slice(offset, offset + 400);
1136
+ const stateChildren = queryRows(
1137
+ paths.stateDatabasePath,
1138
+ `select parent_thread_id, child_thread_id
1139
+ from thread_spawn_edges
1140
+ where parent_thread_id in (${placeholders(idBatch)})`,
1141
+ idBatch,
1142
+ );
1143
+
1144
+ for (const edge of stateChildren) {
1145
+ const childId = String(edge.child_thread_id);
1146
+ if (!selectedIds.has(childId)) {
1147
+ selectedIds.add(childId);
1148
+ pendingIds.push(childId);
1149
+ }
1150
+ }
1151
+
1152
+ for (const parentId of idBatch) {
1153
+ for (const childId of transcriptChildrenByParentId.get(parentId) ?? []) {
1154
+ if (!selectedIds.has(childId)) {
1155
+ selectedIds.add(childId);
1156
+ pendingIds.push(childId);
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ const ids = [...selectedIds];
1163
+ const threadRows = [];
1164
+
1165
+ for (const idBatch of batches(ids)) {
1166
+ threadRows.push(...queryRows(
1167
+ paths.stateDatabasePath,
1168
+ `select ${SESSION_COLUMNS}
1169
+ from threads t
1170
+ where t.id in (${placeholders(idBatch)})`,
1171
+ idBatch,
1172
+ ));
1173
+ }
1174
+
1175
+ const spawnEdges = queryRelatedSpawnEdges(paths.stateDatabasePath, ids);
1176
+ const childIdsByParentId = new Map();
1177
+ const parentIdsByChildId = new Map();
1178
+
1179
+ for (const edge of spawnEdges) {
1180
+ const parentId = String(edge.parent_thread_id);
1181
+ const childId = String(edge.child_thread_id);
1182
+ if (selectedIds.has(parentId) && selectedIds.has(childId)) {
1183
+ const childIds = childIdsByParentId.get(parentId) ?? [];
1184
+ childIds.push(childId);
1185
+ childIdsByParentId.set(parentId, childIds);
1186
+ }
1187
+ parentIdsByChildId.set(childId, parentId);
1188
+ }
1189
+
1190
+ for (const parentId of ids) {
1191
+ for (const childId of transcriptChildrenByParentId.get(parentId) ?? []) {
1192
+ if (!selectedIds.has(childId)) continue;
1193
+ const childIds = childIdsByParentId.get(parentId) ?? [];
1194
+ if (!childIds.includes(childId)) childIds.push(childId);
1195
+ childIdsByParentId.set(parentId, childIds);
1196
+ if (!parentIdsByChildId.has(childId)) parentIdsByChildId.set(childId, parentId);
1197
+ }
1198
+ }
1199
+
1200
+ const formattedRows = await formatPagedThreadRows(paths.stateDatabasePath, threadRows);
1201
+ const recordsById = new Map();
1202
+
1203
+ for (const record of formattedRows) {
1204
+ const transcriptHeader = transcriptHeaders.get(record.id);
1205
+ recordsById.set(record.id, {
1206
+ ...record,
1207
+ childThreadIds: [...(childIdsByParentId.get(record.id) ?? [])].sort(),
1208
+ parentThreadId: parentIdsByChildId.get(record.id) ?? record.parentThreadId,
1209
+ rolloutMissing: record.rolloutPath ? !transcriptHeader : true,
1210
+ });
1211
+ }
1212
+
1213
+ for (const id of ids) {
1214
+ if (recordsById.has(id)) continue;
1215
+ const header = transcriptHeaders.get(id);
1216
+ if (!header) continue;
1217
+ const parentThreadId = parentIdsByChildId.get(id) ?? header.parentThreadId ?? null;
1218
+ recordsById.set(id, {
1219
+ agentNickname: header.agentNickname,
1220
+ agentRole: header.agentRole,
1221
+ archived: false,
1222
+ childThreadIds: [...(childIdsByParentId.get(id) ?? [])].sort(),
1223
+ createdAtMs: header.timestampMs,
1224
+ cwd: header.cwd,
1225
+ displayName: "",
1226
+ firstUserMessage: "",
1227
+ forkedFromId: header.forkedFromId,
1228
+ id,
1229
+ isFork: Boolean(header.forkedFromId),
1230
+ isPinned: false,
1231
+ isSubagent: Boolean(parentThreadId),
1232
+ parentThreadId,
1233
+ providerId: "codex",
1234
+ recordSource: "transcript",
1235
+ rolloutMissing: false,
1236
+ rolloutPath: header.filePath,
1237
+ title: "",
1238
+ titleSource: "",
1239
+ updatedAtMs: header.timestampMs,
1240
+ });
1241
+ }
1242
+
1243
+ for (const requestedId of recordIds.map(String)) {
1244
+ if (!recordsById.has(requestedId)) {
1245
+ throw new Error("One or more selected sessions are no longer available.");
1246
+ }
1247
+ }
1248
+
1249
+ const relevantIds = new Set(recordsById.keys());
1250
+ const sessionIndexMap = await readSessionIndexMap(paths.sessionIndexPath, relevantIds);
1251
+ const historyMap = await readHistoryMap(paths.historyPath, relevantIds);
1252
+ const transcriptFallbackById = new Map();
1253
+
1254
+ for (const record of recordsById.values()) {
1255
+ if (
1256
+ normalizeDisplayName(record.title) ||
1257
+ getMeaningfulUserText(record.firstUserMessage) ||
1258
+ sessionIndexMap.get(record.id)?.threadName ||
1259
+ historyMap.get(record.id)?.text ||
1260
+ !record.rolloutPath
1261
+ ) {
1262
+ continue;
1263
+ }
1264
+
1265
+ try {
1266
+ transcriptFallbackById.set(record.id, await parseTranscriptFallback(record.rolloutPath));
1267
+ } catch {
1268
+ continue;
1269
+ }
1270
+ }
1271
+
1272
+ const records = [...recordsById.values()].map((record) => {
1273
+ const display = deriveDisplayName({
1274
+ historyMap,
1275
+ sessionIndexMap,
1276
+ sessionRecord: record,
1277
+ transcriptFallback: transcriptFallbackById.get(record.id),
1278
+ });
1279
+ return {
1280
+ ...record,
1281
+ displayName: display.value,
1282
+ titleSource: display.source,
1283
+ updatedAtMs: record.updatedAtMs || sessionIndexMap.get(record.id)?.updatedAt || record.createdAtMs,
1284
+ };
1285
+ });
1286
+
1287
+ return {
1288
+ ...availability,
1289
+ childIdsByParentId,
1290
+ codexHome: paths.codexHome,
1291
+ desktopStateBackupPath: paths.desktopStateBackupPath,
1292
+ desktopStatePath: paths.desktopStatePath,
1293
+ goalsDatabasePath: paths.goalsDatabasePath,
1294
+ historyPath: paths.historyPath,
1295
+ logsDatabasePath: paths.logsDatabasePath,
1296
+ memoryDatabasePath: paths.memoryDatabasePath,
1297
+ records,
1298
+ recordsById: new Map(records.map((record) => [record.id, record])),
1299
+ sessionIndexPath: paths.sessionIndexPath,
1300
+ spawnEdges,
1301
+ stateDatabasePath: paths.stateDatabasePath,
1302
+ transcriptHeaders: new Map(
1303
+ [...transcriptHeaders].filter(([id]) => relevantIds.has(id)),
1304
+ ),
1305
+ };
1306
+ }
1307
+
1308
+ export async function listSessions({
1309
+ codexHome,
1310
+ includeInternals = false,
1311
+ includeSupporting = false,
1312
+ page = 1,
1313
+ pageSize = DEFAULT_PAGE_SIZE,
1314
+ search = "",
1315
+ sort = "updated",
1316
+ }) {
1317
+ const paths = getCodexPaths(codexHome);
1318
+ const boundedPageSize = Number.isFinite(pageSize)
1319
+ ? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
1320
+ : DEFAULT_PAGE_SIZE;
1321
+ const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
1322
+ const conditions = getSessionConditions({ includeInternals, includeSupporting, search });
1323
+ const countRow = queryRows(
1324
+ paths.stateDatabasePath,
1325
+ `select count(*) as count from threads t ${conditions.sql}`,
1326
+ conditions.parameters,
1327
+ )[0];
1328
+ const total = Number(countRow?.count ?? 0);
1329
+ const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
1330
+ const currentPage = Math.min(requestedPage, pageCount);
1331
+ const threadRows = queryRows(
1332
+ paths.stateDatabasePath,
1333
+ `select ${SESSION_COLUMNS}
1334
+ from threads t
1335
+ ${conditions.sql}
1336
+ order by ${getSessionOrder(sort)}
1337
+ limit ? offset ?`,
1338
+ [...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
1339
+ );
1340
+
1341
+ return {
1342
+ page: currentPage,
1343
+ pageCount,
1344
+ pageSize: boundedPageSize,
1345
+ records: await formatPagedThreadRows(paths.stateDatabasePath, threadRows),
1346
+ total,
1347
+ };
1348
+ }
1349
+
1350
+ export async function getSessionRecord({ codexHome, id }) {
1351
+ const paths = getCodexPaths(codexHome);
1352
+ const rows = queryRows(
1353
+ paths.stateDatabasePath,
1354
+ `select ${SESSION_COLUMNS} from threads t where t.id = ? limit 1`,
1355
+ [id],
1356
+ );
1357
+ const records = await formatPagedThreadRows(paths.stateDatabasePath, rows);
1358
+ return records[0] ?? null;
1359
+ }
1360
+
1361
+ export function filterAndSortSessions({
1362
+ includeInternals,
1363
+ records,
1364
+ search,
1365
+ sort,
1366
+ }) {
1367
+ const normalizedSearch = normalizeText(search).toLowerCase();
1368
+ const filteredRecords = records.filter((record) => {
1369
+ if (!includeInternals && record.isSubagent) {
1370
+ return false;
1371
+ }
1372
+
1373
+ if (!normalizedSearch) {
1374
+ return true;
1375
+ }
1376
+
1377
+ const haystack = [
1378
+ record.displayName,
1379
+ record.id,
1380
+ record.cwd,
1381
+ record.rolloutPath,
1382
+ ]
1383
+ .join(" ")
1384
+ .toLowerCase();
1385
+
1386
+ return haystack.includes(normalizedSearch);
1387
+ });
1388
+
1389
+ const compareBySort = {
1390
+ created: (left, right) =>
1391
+ right.createdAtMs - left.createdAtMs || left.displayName.localeCompare(right.displayName),
1392
+ cwd: (left, right) =>
1393
+ left.cwd.localeCompare(right.cwd) || right.updatedAtMs - left.updatedAtMs,
1394
+ name: (left, right) =>
1395
+ left.displayName.localeCompare(right.displayName) ||
1396
+ right.updatedAtMs - left.updatedAtMs,
1397
+ updated: (left, right) =>
1398
+ right.updatedAtMs - left.updatedAtMs || left.displayName.localeCompare(right.displayName),
1399
+ };
1400
+
1401
+ const comparer = compareBySort[sort] ?? compareBySort.updated;
1402
+
1403
+ return [...filteredRecords].sort(comparer);
1404
+ }
1405
+
1406
+ function countRowsForIds(databasePath, tableName, columnName, ids) {
1407
+ let count = 0;
1408
+
1409
+ for (const idBatch of batches(ids)) {
1410
+ const row = queryRows(
1411
+ databasePath,
1412
+ `select count(*) as count from ${tableName} where ${columnName} in (${placeholders(idBatch)})`,
1413
+ idBatch,
1414
+ )[0];
1415
+ count += Number(row?.count ?? 0);
1416
+ }
1417
+
1418
+ return count;
1419
+ }
1420
+
1421
+ function findRowsForIds(databasePath, tableName, columnName, ids, { limitOne = false } = {}) {
1422
+ const rows = [];
1423
+
1424
+ for (const idBatch of batches(ids)) {
1425
+ const matches = queryRows(
1426
+ databasePath,
1427
+ `select ${columnName} from ${tableName} where ${columnName} in (${placeholders(idBatch)})${limitOne ? " limit 1" : ""}`,
1428
+ idBatch,
1429
+ );
1430
+ rows.push(...matches);
1431
+
1432
+ if (limitOne && rows.length > 0) {
1433
+ break;
1434
+ }
1435
+ }
1436
+
1437
+ return rows;
1438
+ }
1439
+
1440
+ function* deleteStatements(tableName, columnName, ids) {
1441
+ for (const idBatch of batches(ids)) {
1442
+ yield {
1443
+ parameters: idBatch,
1444
+ sql: `delete from ${tableName} where ${columnName} in (${placeholders(idBatch)})`,
1445
+ };
1446
+ }
1447
+ }
1448
+
1449
+ export async function planSessionDeletion({ recordIds, store }) {
1450
+ const idsToDelete = new Set();
1451
+ const pendingIds = [...recordIds];
1452
+
1453
+ while (pendingIds.length > 0) {
1454
+ const currentId = pendingIds.shift();
1455
+
1456
+ if (idsToDelete.has(currentId)) {
1457
+ continue;
1458
+ }
1459
+
1460
+ idsToDelete.add(currentId);
1461
+
1462
+ for (const childId of store.childIdsByParentId.get(currentId) ?? []) {
1463
+ pendingIds.push(childId);
1464
+ }
1465
+ }
1466
+
1467
+ const deletionIds = [...idsToDelete];
1468
+ const selectedRecords = deletionIds
1469
+ .map((id) => store.recordsById.get(id))
1470
+ .filter(Boolean)
1471
+ .sort((left, right) => left.displayName.localeCompare(right.displayName));
1472
+ const transcriptPaths = selectedRecords
1473
+ .map((record) => record.rolloutPath)
1474
+ .filter(Boolean);
1475
+ const missingTranscriptPaths = selectedRecords
1476
+ .filter((record) => record.rolloutMissing)
1477
+ .map((record) => record.rolloutPath)
1478
+ .filter(Boolean);
1479
+ const deletionIdSet = new Set(deletionIds);
1480
+ const [historyMatches, sessionIndexMatches] = await Promise.all([
1481
+ inspectJsonlMatches(
1482
+ store.historyPath,
1483
+ (entry) => entry.parsed?.session_id && deletionIdSet.has(String(entry.parsed.session_id)),
1484
+ { sampleLimit: 0 },
1485
+ ),
1486
+ inspectJsonlMatches(
1487
+ store.sessionIndexPath,
1488
+ (entry) => entry.parsed?.id && deletionIdSet.has(String(entry.parsed.id)),
1489
+ { sampleLimit: 0 },
1490
+ ),
1491
+ ]);
1492
+ const spawnEdgeCount = store.spawnEdges.reduce((count, edge) => {
1493
+ if (
1494
+ deletionIdSet.has(edge.parent_thread_id) ||
1495
+ deletionIdSet.has(edge.child_thread_id)
1496
+ ) {
1497
+ return count + 1;
1498
+ }
1499
+
1500
+ return count;
1501
+ }, 0);
1502
+ const logRowCount = store.hasLogsDatabase
1503
+ ? countRowsForIds(store.logsDatabasePath, "logs", "thread_id", deletionIds)
1504
+ : 0;
1505
+ const memoryRowCount = store.hasMemoryDatabase
1506
+ ? countRowsForIds(store.memoryDatabasePath, "stage1_outputs", "thread_id", deletionIds)
1507
+ : 0;
1508
+ const goalRowCount = store.hasGoalsDatabase
1509
+ ? countRowsForIds(store.goalsDatabasePath, "thread_goals", "thread_id", deletionIds)
1510
+ : 0;
1511
+
1512
+ return {
1513
+ childCount: Math.max(0, deletionIds.length - recordIds.length),
1514
+ desktopStateMatchCount: 0,
1515
+ desktopStateSupport: {
1516
+ backup: store.hasDesktopStateBackup ? "pending" : "absent",
1517
+ current: store.hasDesktopState ? "pending" : "absent",
1518
+ },
1519
+ goalRowCount,
1520
+ historyMatchCount: historyMatches.count,
1521
+ ids: deletionIds,
1522
+ logRowCount,
1523
+ memoryRowCount,
1524
+ missingTranscriptPaths,
1525
+ records: selectedRecords,
1526
+ sessionIndexMatchCount: sessionIndexMatches.count,
1527
+ spawnEdgeCount,
1528
+ transcriptPaths,
1529
+ };
1530
+ }
1531
+
1532
+ const BACKUP_MINIMUM_RESERVE_BYTES = 1024 * 1024;
1533
+ const BACKUP_RESERVE_RATIO = 0.05;
1534
+
1535
+ function getBackupSourcePaths({ plan, store }) {
1536
+ const databasePaths = [
1537
+ store.stateDatabasePath,
1538
+ store.hasLogsDatabase ? store.logsDatabasePath : null,
1539
+ store.hasMemoryDatabase ? store.memoryDatabasePath : null,
1540
+ store.hasGoalsDatabase ? store.goalsDatabasePath : null,
1541
+ ].filter(Boolean);
1542
+
1543
+ return [
1544
+ store.historyPath,
1545
+ store.sessionIndexPath,
1546
+ store.hasDesktopState ? store.desktopStatePath : null,
1547
+ store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
1548
+ ...plan.transcriptPaths,
1549
+ ...databasePaths.flatMap((databasePath) => [
1550
+ databasePath,
1551
+ `${databasePath}-journal`,
1552
+ `${databasePath}-wal`,
1553
+ ]),
1554
+ ].filter(Boolean);
1555
+ }
1556
+
1557
+ async function getPathFingerprint(filePath) {
1558
+ try {
1559
+ const stats = await fs.stat(filePath, { bigint: true });
1560
+ return [
1561
+ filePath,
1562
+ stats.dev.toString(),
1563
+ stats.ino.toString(),
1564
+ stats.size.toString(),
1565
+ stats.mtimeNs.toString(),
1566
+ ].join("\0");
1567
+ } catch (error) {
1568
+ if (error?.code === "ENOENT") return `${filePath}\0missing`;
1569
+ throw error;
1570
+ }
1571
+ }
1572
+
1573
+ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1574
+ const hash = createHash("sha256");
1575
+ hash.update(`${store.codexHome}\0${scope}\0`);
1576
+
1577
+ const counts = [
1578
+ plan.historyMatchCount,
1579
+ plan.logRowCount,
1580
+ plan.sessionIndexMatchCount,
1581
+ plan.spawnEdgeCount,
1582
+ ...(scope === "deep" ? [plan.goalRowCount, plan.memoryRowCount] : []),
1583
+ ];
1584
+ hash.update(`counts\0${counts.join("\0")}\0`);
1585
+ hash.update(`stores\0${[
1586
+ store.hasLogsDatabase,
1587
+ ...(scope === "deep" ? [
1588
+ store.hasDesktopState,
1589
+ store.hasDesktopStateBackup,
1590
+ store.hasGoalsDatabase,
1591
+ store.hasMemoryDatabase,
1592
+ ] : []),
1593
+ ].map(Number).join("\0")}\0`);
1594
+
1595
+ for (const record of [...plan.records].sort((left, right) => left.id.localeCompare(right.id))) {
1596
+ hash.update([
1597
+ "record",
1598
+ record.id,
1599
+ record.parentThreadId ?? "",
1600
+ record.rolloutPath ?? "",
1601
+ String(record.rolloutMissing),
1602
+ String(record.updatedAtMs ?? ""),
1603
+ [...record.childThreadIds].sort().join("\0"),
1604
+ ].join("\0"));
1605
+ hash.update("\0");
1606
+ }
1607
+
1608
+ for (const id of [...plan.ids].sort()) {
1609
+ hash.update(`id\0${id}\0`);
1610
+ }
1611
+
1612
+ for (const filePath of [...plan.missingTranscriptPaths].sort()) {
1613
+ hash.update(`missing-transcript\0${filePath}\0`);
1614
+ }
1615
+
1616
+ for (const filePath of [...plan.transcriptPaths].sort()) {
1617
+ hash.update(`${await getPathFingerprint(filePath)}\0`);
1618
+ }
1619
+
1620
+ if (scope === "deep") {
1621
+ const deletedIdSet = new Set(plan.ids);
1622
+ const [desktopState, desktopStateBackup] = await Promise.all([
1623
+ store.hasDesktopState ? readJsonFile(store.desktopStatePath) : null,
1624
+ store.hasDesktopStateBackup ? readJsonFile(store.desktopStateBackupPath) : null,
1625
+ ]);
1626
+ hash.update(`desktop\0${getMatchingDesktopStateEntryCount(desktopState, deletedIdSet)}\0`);
1627
+ hash.update(`desktop-backup\0${getMatchingDesktopStateEntryCount(desktopStateBackup, deletedIdSet)}\0`);
1628
+ }
1629
+
1630
+ return hash.digest("hex");
1631
+ }
1632
+
1633
+ async function estimateBackupBytes({ plan, store }) {
1634
+ let sourceBytes = 0;
1635
+ const uniquePaths = new Set(getBackupSourcePaths({ plan, store }));
1636
+
1637
+ for (const sourcePath of uniquePaths) {
1638
+ try {
1639
+ const stats = await fs.stat(sourcePath);
1640
+
1641
+ if (stats.isFile()) {
1642
+ sourceBytes += stats.size;
1643
+ }
1644
+ } catch (error) {
1645
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
1646
+ continue;
1647
+ }
1648
+
1649
+ throw error;
1650
+ }
1651
+ }
1652
+
1653
+ const reserveBytes = Math.max(
1654
+ BACKUP_MINIMUM_RESERVE_BYTES,
1655
+ Math.ceil(sourceBytes * BACKUP_RESERVE_RATIO),
1656
+ );
1657
+
1658
+ return {
1659
+ estimatedBackupBytes: sourceBytes + reserveBytes,
1660
+ estimatedBackupSourceBytes: sourceBytes,
1661
+ reserveBytes,
1662
+ };
1663
+ }
1664
+
1665
+ async function getAvailableDiskBytes(directoryPath) {
1666
+ const stats = await fs.statfs(directoryPath);
1667
+ return stats.bavail * stats.bsize;
1668
+ }
1669
+
1670
+ function formatBytes(bytes) {
1671
+ if (bytes < 1024) {
1672
+ return `${bytes} bytes`;
1673
+ }
1674
+
1675
+ const units = ["KB", "MB", "GB", "TB"];
1676
+ let value = bytes;
1677
+ let unit = "bytes";
1678
+
1679
+ for (const nextUnit of units) {
1680
+ value /= 1024;
1681
+ unit = nextUnit;
1682
+
1683
+ if (value < 1024) {
1684
+ break;
1685
+ }
1686
+ }
1687
+
1688
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
1689
+ }
1690
+
1691
+ export async function preflightSessionDeletion({ availableDiskBytes, plan, store }) {
1692
+ const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
1693
+ const missingRequiredPaths = [];
1694
+
1695
+ for (const filePath of requiredPaths) {
1696
+ if (!(await pathExists(filePath))) {
1697
+ missingRequiredPaths.push(filePath);
1698
+ }
1699
+ }
1700
+
1701
+ if (missingRequiredPaths.length > 0) {
1702
+ throw new Error("The selected Codex home is missing a required session store.");
1703
+ }
1704
+
1705
+ const deletedIdSet = new Set(plan.ids);
1706
+ const desktopStates = await Promise.all([
1707
+ store.hasDesktopState ? readJsonFile(store.desktopStatePath) : null,
1708
+ store.hasDesktopStateBackup ? readJsonFile(store.desktopStateBackupPath) : null,
1709
+ ]);
1710
+ const [desktopState, desktopStateBackup] = desktopStates;
1711
+ const backupEstimate = await estimateBackupBytes({ plan, store });
1712
+ const diskCapacityBytes = availableDiskBytes ?? await getAvailableDiskBytes(store.codexHome);
1713
+
1714
+ if (diskCapacityBytes < backupEstimate.estimatedBackupBytes) {
1715
+ const error = new Error(
1716
+ `Not enough disk space to create a backup. About ${formatBytes(backupEstimate.estimatedBackupBytes)} is needed; ${formatBytes(diskCapacityBytes)} is available.`,
1717
+ );
1718
+ error.availableDiskBytes = diskCapacityBytes;
1719
+ error.estimatedBackupBytes = backupEstimate.estimatedBackupBytes;
1720
+ throw error;
1721
+ }
1722
+
1723
+ return {
1724
+ ...backupEstimate,
1725
+ availableDiskBytes: diskCapacityBytes,
1726
+ desktopStateMatchCount:
1727
+ getMatchingDesktopStateEntryCount(desktopState, deletedIdSet) +
1728
+ getMatchingDesktopStateEntryCount(desktopStateBackup, deletedIdSet),
1729
+ desktopStateSupport: {
1730
+ backup: store.hasDesktopStateBackup ? "supported" : "absent",
1731
+ current: store.hasDesktopState ? "supported" : "absent",
1732
+ },
1733
+ activeThreadDetection: "unavailable",
1734
+ missingRequiredPaths,
1735
+ };
1736
+ }
1737
+
1738
+ async function atomicWriteFile(filePath, content) {
1739
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
1740
+ await fs.writeFile(temporaryPath, content, "utf8");
1741
+ await fs.rename(temporaryPath, filePath);
1742
+ }
1743
+
1744
+ async function reportProgress(onProgress, update) {
1745
+ if (onProgress) await onProgress(update);
1746
+ }
1747
+
1748
+ function cancellationRequested(shouldCancel) {
1749
+ return Boolean(shouldCancel?.());
1750
+ }
1751
+
1752
+ function cleanupCancelled(backupDirectory = null) {
1753
+ const error = new Error("Cleanup was cancelled before session data changed.");
1754
+ error.cancelled = true;
1755
+ error.backupDirectory = backupDirectory;
1756
+ return error;
1757
+ }
1758
+
1759
+ async function createOperationBackup({ onProgress, plan, scope, store }) {
1760
+ const backupDirectory = path.join(
1761
+ store.codexHome,
1762
+ "session-steward-backups",
1763
+ `${Date.now()}-${process.pid}`,
1764
+ );
1765
+ await fs.mkdir(backupDirectory, { recursive: true });
1766
+ const backupFiles = [
1767
+ [store.historyPath, "history.jsonl", true],
1768
+ [store.sessionIndexPath, "session_index.jsonl", true],
1769
+ [store.desktopStatePath, ".codex-global-state.json", scope === "deep"],
1770
+ [store.desktopStateBackupPath, ".codex-global-state.json.bak", scope === "deep"],
1771
+ ];
1772
+ const copiedFiles = [];
1773
+ const files = [];
1774
+ const transcriptNameCounts = new Map();
1775
+ for (const transcriptPath of plan.transcriptPaths) {
1776
+ const name = path.basename(transcriptPath);
1777
+ transcriptNameCounts.set(name, (transcriptNameCounts.get(name) ?? 0) + 1);
1778
+ }
1779
+ const snapshotCandidates = [
1780
+ [store.stateDatabasePath, "state_5.sqlite", true],
1781
+ [store.hasLogsDatabase ? store.logsDatabasePath : null, "logs_2.sqlite", true],
1782
+ [store.hasMemoryDatabase ? store.memoryDatabasePath : null, "memories_1.sqlite", scope === "deep"],
1783
+ [store.hasGoalsDatabase ? store.goalsDatabasePath : null, "goals_1.sqlite", scope === "deep"],
1784
+ ];
1785
+ const totalItems = backupFiles.length + plan.transcriptPaths.length + snapshotCandidates.length;
1786
+ let completedItems = 0;
1787
+
1788
+ const itemComplete = async () => {
1789
+ completedItems += 1;
1790
+ await reportProgress(onProgress, {
1791
+ canCancel: true,
1792
+ message: "Creating a recovery backup",
1793
+ phase: "backup",
1794
+ progress: Math.min(45, 10 + Math.round((completedItems / Math.max(1, totalItems)) * 35)),
1795
+ });
1796
+ };
1797
+
1798
+ for (const [sourcePath, destinationName, restoreOnFailure] of backupFiles) {
1799
+ if (!(await pathExists(sourcePath))) {
1800
+ await itemComplete();
1801
+ continue;
1802
+ }
1803
+
1804
+ const destinationPath = path.join(backupDirectory, destinationName);
1805
+ await fs.copyFile(sourcePath, destinationPath);
1806
+ copiedFiles.push(sourcePath);
1807
+ if (restoreOnFailure) files.push({ backupPath: destinationName, originalPath: sourcePath });
1808
+ await itemComplete();
1809
+ }
1810
+
1811
+ const transcriptDirectory = path.join(backupDirectory, "transcripts");
1812
+ await fs.mkdir(transcriptDirectory, { recursive: true });
1813
+
1814
+ for (const transcriptPath of plan.transcriptPaths) {
1815
+ if (!(await pathExists(transcriptPath))) {
1816
+ await itemComplete();
1817
+ continue;
1818
+ }
1819
+
1820
+ const originalName = path.basename(transcriptPath);
1821
+ const backupName = transcriptNameCounts.get(originalName) === 1
1822
+ ? originalName
1823
+ : `${createHash("sha256").update(transcriptPath).digest("hex").slice(0, 16)}-${originalName}`;
1824
+ const relativeBackupPath = path.join("transcripts", backupName);
1825
+ await fs.copyFile(
1826
+ transcriptPath,
1827
+ path.join(backupDirectory, relativeBackupPath),
1828
+ fsConstants.COPYFILE_FICLONE,
1829
+ );
1830
+ copiedFiles.push(transcriptPath);
1831
+ files.push({ backupPath: relativeBackupPath, originalPath: transcriptPath });
1832
+ await itemComplete();
1833
+ }
1834
+
1835
+ const databaseDirectory = path.join(backupDirectory, "databases");
1836
+ await fs.mkdir(databaseDirectory, { recursive: true });
1837
+ const databaseSnapshots = {};
1838
+
1839
+ for (const [databasePath, backupName, restoreOnFailure] of snapshotCandidates) {
1840
+ if (!databasePath) {
1841
+ await itemComplete();
1842
+ continue;
1843
+ }
1844
+
1845
+ const destinationPath = path.join(databaseDirectory, backupName);
1846
+ await backupDatabase(databasePath, destinationPath);
1847
+ const relativeBackupPath = path.join("databases", backupName);
1848
+ databaseSnapshots[backupName] = relativeBackupPath;
1849
+ copiedFiles.push(databasePath);
1850
+ if (restoreOnFailure) files.push({ backupPath: relativeBackupPath, originalPath: databasePath });
1851
+ await itemComplete();
1852
+ }
1853
+
1854
+ await atomicWriteFile(
1855
+ path.join(backupDirectory, "operation.json"),
1856
+ `${JSON.stringify({ version: 2, ids: plan.ids, scope, createdAtMs: Date.now(), copiedFiles, databaseSnapshots, files }, null, 2)}\n`,
1857
+ );
1858
+
1859
+ return backupDirectory;
1860
+ }
1861
+
1862
+ function cleanupErrorWithBackup(error, backupDirectory) {
1863
+ const detail = error instanceof Error ? error.message : "The cleanup could not be completed.";
1864
+ const wrappedError = new Error(
1865
+ `Cleanup stopped after the backup was created. Backup: ${backupDirectory}. ${detail}`,
1866
+ { cause: error },
1867
+ );
1868
+ wrappedError.backupDirectory = backupDirectory;
1869
+ return wrappedError;
1870
+ }
1871
+
1872
+ export async function executeSessionDeletion({
1873
+ onProgress,
1874
+ plan,
1875
+ scope = "deep",
1876
+ shouldCancel,
1877
+ store,
1878
+ }) {
1879
+ if (plan.ids.length === 0) {
1880
+ return {
1881
+ deletedIds: [],
1882
+ };
1883
+ }
1884
+
1885
+ if (scope !== "core" && scope !== "deep") {
1886
+ throw new Error(`Unsupported deletion scope: ${scope}`);
1887
+ }
1888
+
1889
+ if (scope === "deep") {
1890
+ await assertDeepCleanupSupported({ codexHome: store.codexHome });
1891
+ }
1892
+
1893
+ await reportProgress(onProgress, {
1894
+ canCancel: true,
1895
+ message: "Checking the selected sessions",
1896
+ phase: "preflight",
1897
+ progress: 5,
1898
+ });
1899
+ if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
1900
+ const deletedIdSet = new Set(plan.ids);
1901
+ const preflight = await preflightSessionDeletion({ plan, store });
1902
+ if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
1903
+ const backupDirectory = await createOperationBackup({
1904
+ onProgress,
1905
+ plan,
1906
+ scope,
1907
+ store,
1908
+ });
1909
+
1910
+ if (cancellationRequested(shouldCancel)) {
1911
+ throw cleanupCancelled(backupDirectory);
1912
+ }
1913
+
1914
+ await reportProgress(onProgress, {
1915
+ canCancel: false,
1916
+ message: "Removing selected session data",
1917
+ phase: "cleanup",
1918
+ progress: 55,
1919
+ });
1920
+
1921
+ try {
1922
+ if (scope === "deep" && store.hasMemoryDatabase) {
1923
+ executeTransaction(
1924
+ store.memoryDatabasePath,
1925
+ deleteStatements("stage1_outputs", "thread_id", plan.ids),
1926
+ );
1927
+ }
1928
+ if (scope === "deep" && store.hasGoalsDatabase) {
1929
+ executeTransaction(
1930
+ store.goalsDatabasePath,
1931
+ deleteStatements("thread_goals", "thread_id", plan.ids),
1932
+ );
1933
+ }
1934
+
1935
+ if (store.hasLogsDatabase) {
1936
+ executeTransaction(
1937
+ store.logsDatabasePath,
1938
+ deleteStatements("logs", "thread_id", plan.ids),
1939
+ );
1940
+ }
1941
+ const stateStatements = [];
1942
+
1943
+ for (const idBatch of batches(plan.ids)) {
1944
+ const idPlaceholders = placeholders(idBatch);
1945
+ stateStatements.push({
1946
+ parameters: [...idBatch, ...idBatch],
1947
+ sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
1948
+ });
1949
+ stateStatements.push({
1950
+ parameters: idBatch,
1951
+ sql: `delete from threads where id in (${idPlaceholders})`,
1952
+ });
1953
+ }
1954
+
1955
+ executeTransaction(store.stateDatabasePath, stateStatements);
1956
+ await reportProgress(onProgress, {
1957
+ canCancel: false,
1958
+ message: "Updating session records",
1959
+ phase: "cleanup",
1960
+ progress: 70,
1961
+ });
1962
+
1963
+ await Promise.all([
1964
+ rewriteJsonlFile(
1965
+ store.sessionIndexPath,
1966
+ (entry) => !entry.parsed?.id || !deletedIdSet.has(String(entry.parsed.id)),
1967
+ ),
1968
+ rewriteJsonlFile(
1969
+ store.historyPath,
1970
+ (entry) => !entry.parsed?.session_id || !deletedIdSet.has(String(entry.parsed.session_id)),
1971
+ ),
1972
+ ]);
1973
+ await reportProgress(onProgress, {
1974
+ canCancel: false,
1975
+ message: "Finishing local cleanup",
1976
+ phase: "cleanup",
1977
+ progress: 82,
1978
+ });
1979
+
1980
+ if (scope === "deep") {
1981
+ const desktopStatePaths = [
1982
+ store.hasDesktopState ? store.desktopStatePath : null,
1983
+ store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
1984
+ ].filter(Boolean);
1985
+
1986
+ for (const desktopStatePath of desktopStatePaths) {
1987
+ const desktopState = await readJsonFile(desktopStatePath);
1988
+
1989
+ if (desktopState) {
1990
+ await atomicWriteFile(
1991
+ desktopStatePath,
1992
+ `${JSON.stringify(removeDesktopStateEntries(desktopState, deletedIdSet), null, 2)}\n`,
1993
+ );
1994
+ }
1995
+ }
1996
+ }
1997
+
1998
+ const deletedTranscriptPaths = [];
1999
+ const skippedTranscriptPaths = [];
2000
+
2001
+ for (const transcriptPath of plan.transcriptPaths) {
2002
+ try {
2003
+ await fs.unlink(transcriptPath);
2004
+ deletedTranscriptPaths.push(transcriptPath);
2005
+ } catch (error) {
2006
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
2007
+ skippedTranscriptPaths.push(transcriptPath);
2008
+ continue;
2009
+ }
2010
+
2011
+ throw error;
2012
+ }
2013
+ }
2014
+
2015
+ await reportProgress(onProgress, {
2016
+ canCancel: false,
2017
+ message: "Cleanup changes are complete",
2018
+ phase: "cleanup",
2019
+ progress: 90,
2020
+ });
2021
+
2022
+ return {
2023
+ backupDirectory,
2024
+ deletedIds: plan.ids,
2025
+ deletedTranscriptPaths,
2026
+ preflight,
2027
+ scope,
2028
+ skippedTranscriptPaths,
2029
+ };
2030
+ } catch (error) {
2031
+ throw cleanupErrorWithBackup(error, backupDirectory);
2032
+ }
2033
+ }
2034
+
2035
+ function resolveContainedPath(rootDirectory, candidatePath) {
2036
+ const root = path.resolve(rootDirectory);
2037
+ const resolved = path.resolve(candidatePath);
2038
+
2039
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
2040
+ throw new Error("This backup contains a path outside the selected Codex folder.");
2041
+ }
2042
+
2043
+ return resolved;
2044
+ }
2045
+
2046
+ async function atomicCopyFile(sourcePath, destinationPath) {
2047
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
2048
+ const temporaryPath = `${destinationPath}.session-steward-${process.pid}-${Date.now()}.tmp`;
2049
+
2050
+ try {
2051
+ await fs.copyFile(sourcePath, temporaryPath, fsConstants.COPYFILE_FICLONE);
2052
+ await fs.rename(temporaryPath, destinationPath);
2053
+ } catch (error) {
2054
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
2055
+ throw error;
2056
+ }
2057
+ }
2058
+
2059
+ function getSqliteSidecarPaths(filePath) {
2060
+ return filePath.endsWith(".sqlite")
2061
+ ? ["-wal", "-shm", "-journal"].map((suffix) => `${filePath}${suffix}`)
2062
+ : [];
2063
+ }
2064
+
2065
+ export async function restoreSessionDeletionBackup({
2066
+ backupDirectory,
2067
+ codexHome,
2068
+ onProgress,
2069
+ }) {
2070
+ const backupRoot = path.join(path.resolve(codexHome), "session-steward-backups");
2071
+ const resolvedBackupDirectory = resolveContainedPath(backupRoot, backupDirectory);
2072
+ const operationPath = path.join(resolvedBackupDirectory, "operation.json");
2073
+ const operation = await readJsonFile(operationPath);
2074
+
2075
+ if (operation?.version !== 2 || !Array.isArray(operation.files) || operation.files.length === 0) {
2076
+ throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
2077
+ }
2078
+
2079
+ const files = operation.files.map((entry) => {
2080
+ if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
2081
+ throw new Error("This backup is missing recovery information.");
2082
+ }
2083
+
2084
+ return {
2085
+ backupPath: resolveContainedPath(
2086
+ resolvedBackupDirectory,
2087
+ path.join(resolvedBackupDirectory, entry.backupPath),
2088
+ ),
2089
+ originalPath: resolveContainedPath(codexHome, entry.originalPath),
2090
+ };
2091
+ });
2092
+
2093
+ for (const file of files) {
2094
+ if (!(await pathExists(file.backupPath))) {
2095
+ throw new Error("This backup is incomplete and cannot be restored automatically.");
2096
+ }
2097
+ }
2098
+
2099
+ const restorePaths = new Set(files.flatMap((file) => [
2100
+ file.backupPath,
2101
+ file.originalPath,
2102
+ ...getSqliteSidecarPaths(file.originalPath),
2103
+ ]));
2104
+ let restoreSourceBytes = 0;
2105
+
2106
+ for (const filePath of restorePaths) {
2107
+ try {
2108
+ const stats = await fs.stat(filePath);
2109
+ if (stats.isFile()) restoreSourceBytes += stats.size;
2110
+ } catch (error) {
2111
+ if (error?.code !== "ENOENT") throw error;
2112
+ }
2113
+ }
2114
+
2115
+ const restoreReserveBytes = Math.max(
2116
+ BACKUP_MINIMUM_RESERVE_BYTES,
2117
+ Math.ceil(restoreSourceBytes * BACKUP_RESERVE_RATIO),
2118
+ );
2119
+ const restoreRequiredBytes = restoreSourceBytes + restoreReserveBytes;
2120
+ const restoreAvailableBytes = await getAvailableDiskBytes(resolvedBackupDirectory);
2121
+
2122
+ if (restoreAvailableBytes < restoreRequiredBytes) {
2123
+ throw new Error(
2124
+ `Not enough disk space to restore safely. About ${formatBytes(restoreRequiredBytes)} is needed; ${formatBytes(restoreAvailableBytes)} is available.`,
2125
+ );
2126
+ }
2127
+
2128
+ const safetyBackupDirectory = path.join(
2129
+ resolvedBackupDirectory,
2130
+ `before-restore-${Date.now()}`,
2131
+ );
2132
+ const safetyFiles = [];
2133
+ await fs.mkdir(safetyBackupDirectory, { recursive: true });
2134
+ await reportProgress(onProgress, {
2135
+ canCancel: false,
2136
+ message: "Saving the current files before restore",
2137
+ phase: "restore",
2138
+ progress: 10,
2139
+ });
2140
+
2141
+ for (const file of files) {
2142
+ for (const currentPath of [file.originalPath, ...getSqliteSidecarPaths(file.originalPath)]) {
2143
+ if (!(await pathExists(currentPath))) continue;
2144
+ const safetyName = createHash("sha256")
2145
+ .update(currentPath)
2146
+ .digest("hex");
2147
+ const safetyPath = path.join(safetyBackupDirectory, safetyName);
2148
+ await fs.copyFile(currentPath, safetyPath, fsConstants.COPYFILE_FICLONE);
2149
+ safetyFiles.push({ backupPath: safetyName, originalPath: currentPath });
2150
+ }
2151
+ }
2152
+
2153
+ await atomicWriteFile(
2154
+ path.join(safetyBackupDirectory, "operation.json"),
2155
+ `${JSON.stringify({ version: 1, createdAtMs: Date.now(), files: safetyFiles }, null, 2)}\n`,
2156
+ );
2157
+
2158
+ try {
2159
+ for (let index = 0; index < files.length; index += 1) {
2160
+ const file = files[index];
2161
+ for (const sidecarPath of getSqliteSidecarPaths(file.originalPath)) {
2162
+ await fs.rm(sidecarPath, { force: true });
2163
+ }
2164
+ await atomicCopyFile(file.backupPath, file.originalPath);
2165
+ await reportProgress(onProgress, {
2166
+ canCancel: false,
2167
+ message: "Restoring the recovery backup",
2168
+ phase: "restore",
2169
+ progress: 20 + Math.round(((index + 1) / files.length) * 75),
2170
+ });
2171
+ }
2172
+
2173
+ for (const file of files) {
2174
+ const [backupStats, restoredStats] = await Promise.all([
2175
+ fs.stat(file.backupPath),
2176
+ fs.stat(file.originalPath),
2177
+ ]);
2178
+ if (backupStats.size !== restoredStats.size) {
2179
+ throw new Error("A restored file did not match its backup.");
2180
+ }
2181
+ }
2182
+
2183
+ await reportProgress(onProgress, {
2184
+ canCancel: false,
2185
+ message: "Restore completed",
2186
+ phase: "restore",
2187
+ progress: 100,
2188
+ });
2189
+
2190
+ return {
2191
+ restoredFileCount: files.length,
2192
+ safetyBackupDirectory,
2193
+ };
2194
+ } catch (error) {
2195
+ const wrappedError = new Error(
2196
+ `Restore could not be completed. The files from before this restore are saved at ${safetyBackupDirectory}.`,
2197
+ { cause: error },
2198
+ );
2199
+ wrappedError.safetyBackupDirectory = safetyBackupDirectory;
2200
+ throw wrappedError;
2201
+ }
2202
+ }
2203
+
2204
+ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2205
+ const deletedIdSet = new Set(plan.ids);
2206
+ const remainingThreads = findRowsForIds(
2207
+ store.stateDatabasePath, "threads", "id", plan.ids,
2208
+ );
2209
+ const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
2210
+ ? findRowsForIds(store.memoryDatabasePath, "stage1_outputs", "thread_id", plan.ids)
2211
+ : [];
2212
+ const remainingGoalRecords = scope === "deep" && store.hasGoalsDatabase
2213
+ ? findRowsForIds(store.goalsDatabasePath, "thread_goals", "thread_id", plan.ids)
2214
+ : [];
2215
+ const remainingLogRecords = store.hasLogsDatabase
2216
+ ? findRowsForIds(store.logsDatabasePath, "logs", "thread_id", plan.ids, { limitOne: true })
2217
+ : [];
2218
+ const [sessionIndexMatches, historyMatches] = await Promise.all([
2219
+ inspectJsonlMatches(
2220
+ store.sessionIndexPath,
2221
+ (entry) => entry.parsed?.id && deletedIdSet.has(String(entry.parsed.id)),
2222
+ ),
2223
+ inspectJsonlMatches(
2224
+ store.historyPath,
2225
+ (entry) => entry.parsed?.session_id && deletedIdSet.has(String(entry.parsed.session_id)),
2226
+ ),
2227
+ ]);
2228
+ const remainingSessionIndexEntries = sessionIndexMatches.samples;
2229
+ const remainingHistoryEntries = historyMatches.samples;
2230
+ const remainingTranscriptPaths = [];
2231
+
2232
+ for (const transcriptPath of plan.transcriptPaths) {
2233
+ if (await pathExists(transcriptPath)) {
2234
+ remainingTranscriptPaths.push(transcriptPath);
2235
+ }
2236
+ }
2237
+
2238
+ const remainingDesktopStateReferences = [];
2239
+
2240
+ for (const desktopStatePath of scope === "deep"
2241
+ ? [store.desktopStatePath, store.desktopStateBackupPath]
2242
+ : []) {
2243
+ if (!(await pathExists(desktopStatePath))) {
2244
+ continue;
2245
+ }
2246
+
2247
+ const desktopState = await readJsonFile(desktopStatePath);
2248
+ const count = getMatchingDesktopStateEntryCount(desktopState, deletedIdSet);
2249
+
2250
+ if (count > 0) {
2251
+ remainingDesktopStateReferences.push({ count, path: desktopStatePath });
2252
+ }
2253
+ }
2254
+
2255
+ return {
2256
+ complete:
2257
+ remainingThreads.length === 0 &&
2258
+ remainingMemoryRecords.length === 0 &&
2259
+ remainingGoalRecords.length === 0 &&
2260
+ remainingLogRecords.length === 0 &&
2261
+ sessionIndexMatches.count === 0 &&
2262
+ historyMatches.count === 0 &&
2263
+ remainingTranscriptPaths.length === 0 &&
2264
+ remainingDesktopStateReferences.length === 0,
2265
+ remainingDesktopStateReferences,
2266
+ remainingGoalRecords,
2267
+ remainingHistoryEntries,
2268
+ remainingHistoryEntryCount: historyMatches.count,
2269
+ remainingLogRecords,
2270
+ remainingMemoryRecords,
2271
+ remainingSessionIndexEntries,
2272
+ remainingSessionIndexEntryCount: sessionIndexMatches.count,
2273
+ remainingThreads,
2274
+ remainingTranscriptPaths,
2275
+ };
2276
+ }
2277
+
2278
+ export function formatSessionForJson(sessionRecord) {
2279
+ return {
2280
+ agentNickname: sessionRecord.agentNickname,
2281
+ agentRole: sessionRecord.agentRole,
2282
+ archived: sessionRecord.archived,
2283
+ childThreadIds: sessionRecord.childThreadIds,
2284
+ createdAtMs: sessionRecord.createdAtMs,
2285
+ cwd: sessionRecord.cwd,
2286
+ displayName: sessionRecord.displayName,
2287
+ forkedFromId: sessionRecord.forkedFromId,
2288
+ id: sessionRecord.id,
2289
+ isFork: sessionRecord.isFork,
2290
+ isPinned: sessionRecord.isPinned,
2291
+ isSubagent: sessionRecord.isSubagent,
2292
+ parentThreadId: sessionRecord.parentThreadId,
2293
+ recordSource: sessionRecord.recordSource,
2294
+ providerId: sessionRecord.providerId,
2295
+ rolloutMissing: sessionRecord.rolloutMissing,
2296
+ rolloutPath: sessionRecord.rolloutPath,
2297
+ titleSource: sessionRecord.titleSource,
2298
+ updatedAtMs: sessionRecord.updatedAtMs,
2299
+ };
2300
+ }