session-steward 0.2.0 → 0.4.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.
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline";
7
7
 
8
+ import { measurePath } from "../../storage/files.mjs";
8
9
  import {
9
10
  inspectJsonlMatches,
10
11
  readJsonlEntries,
@@ -17,6 +18,13 @@ import {
17
18
  placeholders,
18
19
  queryRows,
19
20
  } from "../../storage/sqlite.mjs";
21
+ import {
22
+ allValidDatabases,
23
+ CODEX_DATABASE_PROFILE,
24
+ databaseFamilySummary,
25
+ invalidateCodexDatabaseResolution,
26
+ resolveCodexDatabases,
27
+ } from "./database-families.mjs";
20
28
 
21
29
  function expandHome(value) {
22
30
  if (!value || value === "~") {
@@ -42,6 +50,27 @@ function normalizeDisplayName(value) {
42
50
  return normalizeText(value);
43
51
  }
44
52
 
53
+ function cleanDerivedTitle(value) {
54
+ const original = normalizeDisplayName(value);
55
+
56
+ if (!original) {
57
+ return "";
58
+ }
59
+
60
+ let cleaned = original.replace(/^\[\d+\]\s+(?:user|assistant):\s*/u, "");
61
+ const nextRoleMarker = cleaned.search(/\s\[\d+\]\s+(?:user|assistant):\s*/u);
62
+
63
+ if (nextRoleMarker >= 0) {
64
+ cleaned = cleaned.slice(0, nextRoleMarker);
65
+ }
66
+
67
+ cleaned = normalizeDisplayName(
68
+ cleaned.replace(/\[([^\[\]]+?)\]\([^()]*?\)/gu, "$1"),
69
+ );
70
+
71
+ return cleaned && /[\p{L}\p{N}]/u.test(cleaned) ? cleaned : original;
72
+ }
73
+
45
74
  function toTimestampMs(value) {
46
75
  if (typeof value === "number" && Number.isFinite(value)) {
47
76
  return value;
@@ -368,7 +397,7 @@ function deriveDisplayName({
368
397
  if (sessionIndexEntry?.threadName && (!sqliteTitle || sqliteTitleLooksPrompt)) {
369
398
  return {
370
399
  source: "session_index",
371
- value: sessionIndexEntry.threadName,
400
+ value: cleanDerivedTitle(sessionIndexEntry.threadName),
372
401
  };
373
402
  }
374
403
 
@@ -378,21 +407,21 @@ function deriveDisplayName({
378
407
  ) {
379
408
  return {
380
409
  source: "transcript_thread_name",
381
- value: transcriptFallback.latestThreadName,
410
+ value: cleanDerivedTitle(transcriptFallback.latestThreadName),
382
411
  };
383
412
  }
384
413
 
385
414
  if (sqliteTitle) {
386
415
  return {
387
416
  source: "sqlite_title",
388
- value: sqliteTitle,
417
+ value: cleanDerivedTitle(sqliteTitle),
389
418
  };
390
419
  }
391
420
 
392
421
  if (sqliteFirstUserMessage) {
393
422
  return {
394
423
  source: "sqlite_first_user_message",
395
- value: sqliteFirstUserMessage,
424
+ value: cleanDerivedTitle(sqliteFirstUserMessage),
396
425
  };
397
426
  }
398
427
 
@@ -401,14 +430,14 @@ function deriveDisplayName({
401
430
  if (historyEntry?.text) {
402
431
  return {
403
432
  source: "history_first_user_message",
404
- value: historyEntry.text,
433
+ value: cleanDerivedTitle(historyEntry.text),
405
434
  };
406
435
  }
407
436
 
408
437
  if (transcriptFallback?.firstUserMessage) {
409
438
  return {
410
439
  source: "transcript_first_user_message",
411
- value: transcriptFallback.firstUserMessage,
440
+ value: cleanDerivedTitle(transcriptFallback.firstUserMessage),
412
441
  };
413
442
  }
414
443
 
@@ -418,23 +447,36 @@ function deriveDisplayName({
418
447
  };
419
448
  }
420
449
 
421
- function getCodexPaths(codexHomeInput) {
450
+ export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
422
451
  const codexHome = path.resolve(expandHome(codexHomeInput || "~/.codex"));
423
452
  const archivedSessionsDirectory = path.join(codexHome, "archived_sessions");
424
453
  const sessionsDirectory = path.join(codexHome, "sessions");
454
+ const resolution = resolveCodexDatabases(codexHome, { refresh });
455
+ const { goals, logs, memories, state } = resolution.families;
456
+ const stateDatabasePath = state.primary?.path ?? path.join(codexHome, "state_5.sqlite");
457
+ const logsDatabasePath = logs.primary?.path ?? path.join(codexHome, "logs_2.sqlite");
458
+ const memoryDatabasePath = memories.primary?.path ?? path.join(codexHome, "memories_1.sqlite");
459
+ const goalsDatabasePath = goals.primary?.path ?? path.join(codexHome, "goals_1.sqlite");
425
460
 
426
461
  return {
427
462
  archivedSessionsDirectory,
428
463
  codexHome,
429
464
  desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
430
465
  desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
431
- goalsDatabasePath: path.join(codexHome, "goals_1.sqlite"),
466
+ databaseFamilies: resolution.families,
467
+ goalsDatabasePath,
468
+ goalsDatabasePaths: allValidDatabases(goals).map((database) => database.path),
432
469
  historyPath: path.join(codexHome, "history.jsonl"),
433
- logsDatabasePath: path.join(codexHome, "logs_2.sqlite"),
434
- memoryDatabasePath: path.join(codexHome, "memories_1.sqlite"),
470
+ logsDatabasePath,
471
+ logsDatabasePaths: allValidDatabases(logs).map((database) => database.path),
472
+ memoryDatabasePath,
473
+ memoryDatabasePaths: allValidDatabases(memories).map((database) => database.path),
474
+ resolvedDatabases: databaseFamilySummary(resolution),
435
475
  sessionIndexPath: path.join(codexHome, "session_index.jsonl"),
436
476
  sessionsDirectory,
437
- stateDatabasePath: path.join(codexHome, "state_5.sqlite"),
477
+ stateDatabasePath,
478
+ stateDatabases: allValidDatabases(state),
479
+ stateDatabasePaths: allValidDatabases(state).map((database) => database.path),
438
480
  transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
439
481
  };
440
482
  }
@@ -448,27 +490,6 @@ const DESKTOP_THREAD_MAP_KEYS = [
448
490
 
449
491
  const DESKTOP_THREAD_ARRAY_KEYS = ["projectless-thread-ids"];
450
492
 
451
- const COMPATIBILITY_PROFILE = {
452
- id: "local-store-2026-07",
453
- builtFor: {
454
- chatgptDesktop: ["26.727.40816"],
455
- codexCli: ["0.144.1"],
456
- },
457
- };
458
-
459
- const SCHEMA_REQUIREMENTS = [
460
- {
461
- database: "state_5.sqlite",
462
- required: true,
463
- tables: [
464
- { name: "threads", columns: ["id", "rollout_path", "cwd", "title", "first_user_message", "agent_nickname", "agent_role", "archived", "is_pinned"] },
465
- { name: "thread_spawn_edges", columns: ["parent_thread_id", "child_thread_id", "status"] },
466
- ],
467
- },
468
- { database: "logs_2.sqlite", required: false, tables: [{ name: "logs", columns: ["thread_id"] }] },
469
- { database: "memories_1.sqlite", required: false, tables: [{ name: "stage1_outputs", columns: ["thread_id"] }] },
470
- { database: "goals_1.sqlite", required: false, tables: [{ name: "thread_goals", columns: ["thread_id"] }, { name: "thread_goal_continuation_deferrals", columns: ["thread_id"] }] },
471
- ];
472
493
 
473
494
  function getMatchingDesktopStateEntryCount(state, deletedIdSet) {
474
495
  if (!state || typeof state !== "object" || Array.isArray(state)) {
@@ -565,35 +586,22 @@ export async function loadSessionStore({ codexHome }) {
565
586
  pathExists(paths.logsDatabasePath),
566
587
  pathExists(paths.memoryDatabasePath),
567
588
  ]);
568
- const threadRows = queryRows(
569
- paths.stateDatabasePath,
570
- `
571
- select
572
- id,
573
- rollout_path,
574
- cwd,
575
- title,
576
- first_user_message,
577
- agent_nickname,
578
- agent_role,
579
- archived,
580
- is_pinned,
581
- coalesce(created_at_ms, created_at * 1000) as created_at_ms,
582
- coalesce(updated_at_ms, updated_at * 1000) as updated_at_ms
583
- from threads
584
- order by updated_at_ms desc, updated_at desc
585
- `,
586
- );
587
- const spawnEdges = queryRows(
588
- paths.stateDatabasePath,
589
- `
590
- select
591
- parent_thread_id,
592
- child_thread_id,
593
- status
594
- from thread_spawn_edges
595
- `,
596
- );
589
+ const threadRowsById = new Map();
590
+ const spawnEdgesByKey = new Map();
591
+ for (const database of paths.stateDatabases) {
592
+ for (const row of queryRows(database.path, `select ${sessionColumns(database)} from threads t`)) {
593
+ const id = String(row.id);
594
+ if (!threadRowsById.has(id)) threadRowsById.set(id, { ...row, _stateDatabasePath: database.path });
595
+ }
596
+ if (stateSchema(database).hasSpawnEdges) {
597
+ for (const edge of queryRows(database.path, "select parent_thread_id, child_thread_id, status from thread_spawn_edges")) {
598
+ const key = `${edge.parent_thread_id}\0${edge.child_thread_id}`;
599
+ if (!spawnEdgesByKey.has(key)) spawnEdgesByKey.set(key, edge);
600
+ }
601
+ }
602
+ }
603
+ const threadRows = [...threadRowsById.values()];
604
+ const spawnEdges = [...spawnEdgesByKey.values()];
597
605
  const transcriptHeaders = await indexTranscriptHeaders(paths.transcriptDirectories);
598
606
 
599
607
  const discoveryIds = new Set(threadRows.map((threadRow) => String(threadRow.id)));
@@ -651,7 +659,7 @@ export async function loadSessionStore({ codexHome }) {
651
659
  agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
652
660
  archived: Boolean(threadRow.archived),
653
661
  childThreadIds: childIdsByParentId.get(threadRow.id) ?? [],
654
- createdAtMs: toTimestampMs(threadRow.created_at_ms),
662
+ createdAtMs: toTimestampMs(threadRow.created_at_ms_resolved ?? threadRow.created_at_ms),
655
663
  cwd: threadRow.cwd ?? transcriptHeader?.cwd ?? "",
656
664
  displayName: "",
657
665
  firstUserMessage: threadRow.first_user_message ?? "",
@@ -669,7 +677,8 @@ export async function loadSessionStore({ codexHome }) {
669
677
  rolloutPath,
670
678
  title: threadRow.title ?? "",
671
679
  titleSource: "",
672
- updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
680
+ updatedAtMs: toTimestampMs(threadRow.updated_at_ms_resolved ?? threadRow.updated_at_ms),
681
+ _stateDatabasePath: threadRow._stateDatabasePath,
673
682
  };
674
683
 
675
684
  if (
@@ -776,6 +785,12 @@ export async function loadSessionStore({ codexHome }) {
776
785
  sessionIndexPath: paths.sessionIndexPath,
777
786
  spawnEdges,
778
787
  stateDatabasePath: paths.stateDatabasePath,
788
+ stateDatabasePaths: paths.stateDatabasePaths,
789
+ stateDatabases: paths.stateDatabases,
790
+ resolvedDatabases: paths.resolvedDatabases,
791
+ logsDatabasePaths: paths.logsDatabasePaths,
792
+ memoryDatabasePaths: paths.memoryDatabasePaths,
793
+ goalsDatabasePaths: paths.goalsDatabasePaths,
779
794
  transcriptHeaders,
780
795
  historyPath: paths.historyPath,
781
796
  };
@@ -802,51 +817,21 @@ function inspectSqliteTable(databasePath, tableName) {
802
817
  }
803
818
 
804
819
  export async function diagnoseStorageCompatibility({ codexHome }) {
805
- const paths = getCodexPaths(codexHome);
806
- const recognizedDatabases = new Set(SCHEMA_REQUIREMENTS.map((requirement) => requirement.database));
820
+ const paths = getCodexPaths(codexHome, { refresh: true });
807
821
  const missing = [];
808
822
  const changed = [];
809
823
  const available = [];
810
-
811
- for (const requirement of SCHEMA_REQUIREMENTS) {
812
- const databasePath = path.join(paths.codexHome, requirement.database);
813
-
814
- if (!(await pathExists(databasePath))) {
815
- if (requirement.required) {
816
- missing.push(`Required session database is missing: ${requirement.database}`);
817
- } else {
818
- available.push(`Not present: ${requirement.database}`);
819
- }
820
- continue;
824
+ for (const [familyName, family] of Object.entries(paths.databaseFamilies)) {
825
+ if (!family.primary && family.required) {
826
+ missing.push(`A supported ${familyName} session database was not found.`);
827
+ } else if (!family.primary) {
828
+ available.push(`Not present: ${familyName} database`);
821
829
  }
822
-
823
- let databaseChanged = false;
824
-
825
- for (const table of requirement.tables) {
826
- const inspection = inspectSqliteTable(databasePath, table.name);
827
-
828
- if (inspection.error) {
829
- changed.push(`Could not read ${requirement.database}.`);
830
- databaseChanged = true;
831
- continue;
832
- }
833
-
834
- if (!inspection.exists) {
835
- changed.push(`Expected table is missing in ${requirement.database}: ${table.name}.`);
836
- databaseChanged = true;
837
- continue;
838
- }
839
-
840
- const missingColumns = table.columns.filter((column) => !inspection.columns.includes(column));
841
-
842
- if (missingColumns.length > 0) {
843
- changed.push(`Expected fields changed in ${requirement.database}: ${table.name}.`);
844
- databaseChanged = true;
845
- }
830
+ for (const database of allValidDatabases(family)) {
831
+ available.push(`Supported: ${database.filename}`);
846
832
  }
847
-
848
- if (!databaseChanged) {
849
- available.push(`Supported: ${requirement.database}`);
833
+ for (const database of family.invalid) {
834
+ changed.push(`Could not use ${database.filename}: ${database.reason}`);
850
835
  }
851
836
  }
852
837
 
@@ -857,23 +842,28 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
857
842
  missing.push("The local Codex folder could not be read.");
858
843
  }
859
844
 
845
+ const recognizedDatabases = new Set(Object.values(paths.databaseFamilies).flatMap((family) => [
846
+ ...allValidDatabases(family),
847
+ ...family.invalid,
848
+ ]).map((database) => database.filename));
860
849
  const newlyDiscovered = entries
861
850
  .filter((entry) => entry.isFile() && entry.name.endsWith(".sqlite") && !recognizedDatabases.has(entry.name))
862
851
  .map((entry) => `Other local database found: ${entry.name}`)
863
852
  .sort();
864
853
  const status = missing.length > 0 || changed.length > 0
865
- ? "update-needed"
854
+ ? "unsupported"
866
855
  : newlyDiscovered.length > 0
867
- ? "newer-version"
856
+ ? "partial"
868
857
  : "ready";
869
858
 
870
859
  return {
871
860
  available,
872
- builtFor: COMPATIBILITY_PROFILE.builtFor,
861
+ builtFor: CODEX_DATABASE_PROFILE.builtFor,
873
862
  changed,
874
863
  missing,
875
864
  newlyDiscovered,
876
- profileId: COMPATIBILITY_PROFILE.id,
865
+ profileId: CODEX_DATABASE_PROFILE.id,
866
+ resolvedDatabases: paths.resolvedDatabases,
877
867
  status,
878
868
  };
879
869
  }
@@ -881,11 +871,7 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
881
871
  export async function assertDeepCleanupSupported({ codexHome }) {
882
872
  const diagnostic = await diagnoseStorageCompatibility({ codexHome });
883
873
 
884
- if (diagnostic.status === "newer-version") {
885
- throw new Error("Deep cleanup is paused because unrecognized Codex storage was found.");
886
- }
887
-
888
- if (diagnostic.status !== "ready") {
874
+ if (diagnostic.status === "unsupported") {
889
875
  throw new Error("Deep cleanup is paused because this Codex storage layout is not supported.");
890
876
  }
891
877
 
@@ -895,23 +881,181 @@ export async function assertDeepCleanupSupported({ codexHome }) {
895
881
  const DEFAULT_PAGE_SIZE = 25;
896
882
  const MAX_PAGE_SIZE = 100;
897
883
  const OVERVIEW_STAT_BATCH_SIZE = 24;
884
+ const SESSION_SIZE_CACHE_TTL_MS = 45 * 1000;
885
+ const SESSION_SIZE_STAT_CONCURRENCY = 64;
898
886
  const SUPPORTING_THREAD_PREFIX = "The following is the Codex agent history whose request action you are assessing";
899
887
  const SESSION_CREATED_SQL = "coalesce(nullif(t.created_at_ms, 0), nullif(t.created_at, 0) * 1000, 0)";
900
888
  const SESSION_UPDATED_SQL = "coalesce(nullif(t.updated_at_ms, 0), nullif(t.updated_at, 0) * 1000, 0)";
901
889
  const SESSION_ACTIVITY_SQL = `coalesce(nullif(${SESSION_UPDATED_SQL}, 0), nullif(${SESSION_CREATED_SQL}, 0), 0)`;
890
+ const SESSION_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
891
+ const sessionSizeCache = new Map();
892
+ const stateSchemaCache = new WeakMap();
893
+
894
+ const OPTIONAL_THREAD_COLUMNS = [
895
+ "agent_nickname", "agent_role", "archived", "cwd", "first_user_message",
896
+ "is_pinned", "title",
897
+ ];
898
+
899
+ function stateSchema(database) {
900
+ const known = stateSchemaCache.get(database);
901
+ if (known) return known;
902
+ const cached = database.tables?.threads;
903
+ const columns = cached?.columns?.size
904
+ ? cached.columns
905
+ : new Set(inspectSqliteTable(database.path, "threads").columns ?? []);
906
+ const spawn = database.tables?.thread_spawn_edges?.exists !== undefined
907
+ ? database.tables.thread_spawn_edges
908
+ : inspectSqliteTable(database.path, "thread_spawn_edges");
909
+ const schema = { columns, hasSpawnEdges: Boolean(spawn?.exists) };
910
+ stateSchemaCache.set(database, schema);
911
+ return schema;
912
+ }
913
+
914
+ function columnExpression(columns, column, fallback = "null") {
915
+ return columns.has(column) ? `t.${column}` : fallback;
916
+ }
917
+
918
+ function timestampExpression(columns, prefix) {
919
+ const expressions = [];
920
+ if (columns.has(`${prefix}_at_ms`)) expressions.push(`nullif(t.${prefix}_at_ms, 0)`);
921
+ if (columns.has(`${prefix}_at`)) expressions.push(`nullif(t.${prefix}_at, 0) * 1000`);
922
+ return expressions.length ? `coalesce(${expressions.join(", ")}, 0)` : "0";
923
+ }
924
+
925
+ function stateExpressions(database) {
926
+ const schema = stateSchema(database);
927
+ const created = timestampExpression(schema.columns, "created");
928
+ const updated = timestampExpression(schema.columns, "updated");
929
+ return { ...schema, activity: `coalesce(nullif(${updated}, 0), nullif(${created}, 0), 0)`, created, updated };
930
+ }
931
+
932
+ async function buildSessionSizeIndex(paths) {
933
+ let rows;
934
+ if (paths.stateDatabases.length === 1) {
935
+ rows = queryRows(paths.stateDatabases[0].path, "select id, rollout_path from threads");
936
+ } else {
937
+ const rowsById = new Map();
938
+ for (const database of paths.stateDatabases) {
939
+ for (const row of queryRows(database.path, "select id, rollout_path from threads")) {
940
+ const id = String(row.id);
941
+ if (!rowsById.has(id)) rowsById.set(id, row);
942
+ }
943
+ }
944
+ rows = [...rowsById.values()];
945
+ }
946
+ const sizes = new Map();
947
+ let nextIndex = 0;
902
948
 
903
- function getSessionOrder(sort) {
904
- const name = "lower(coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), t.id))";
949
+ const measureNext = async () => {
950
+ while (nextIndex < rows.length) {
951
+ const row = rows[nextIndex];
952
+ nextIndex += 1;
953
+ const id = String(row.id);
954
+
955
+ if (!row.rollout_path) {
956
+ sizes.set(id, null);
957
+ continue;
958
+ }
959
+
960
+ try {
961
+ const stats = await fs.stat(row.rollout_path);
962
+ sizes.set(id, stats.isFile() ? stats.size : null);
963
+ } catch {
964
+ sizes.set(id, null);
965
+ }
966
+ }
967
+ };
968
+
969
+ await Promise.all(
970
+ Array.from(
971
+ { length: Math.min(SESSION_SIZE_STAT_CONCURRENCY, rows.length) },
972
+ measureNext,
973
+ ),
974
+ );
975
+ return sizes;
976
+ }
977
+
978
+ async function getSessionSizeIndex(paths, { refresh = false } = {}) {
979
+ const cached = sessionSizeCache.get(paths.codexHome);
980
+
981
+ if (!refresh && cached?.expiresAtMs > Date.now()) {
982
+ return cached.promise;
983
+ }
984
+
985
+ const promise = buildSessionSizeIndex(paths).catch((error) => {
986
+ if (sessionSizeCache.get(paths.codexHome)?.promise === promise) {
987
+ sessionSizeCache.delete(paths.codexHome);
988
+ }
989
+ throw error;
990
+ });
991
+ sessionSizeCache.set(paths.codexHome, {
992
+ expiresAtMs: Date.now() + SESSION_SIZE_CACHE_TTL_MS,
993
+ promise,
994
+ });
995
+ return promise;
996
+ }
997
+
998
+ export function invalidateSessionCache({ codexHome }) {
999
+ const resolvedHome = path.resolve(expandHome(codexHome || "~/.codex"));
1000
+ sessionSizeCache.delete(resolvedHome);
1001
+ invalidateCodexDatabaseResolution(resolvedHome);
1002
+ }
1003
+
1004
+ function compareSessionIdsBySize(leftId, rightId, sizes) {
1005
+ const leftSize = sizes.get(leftId);
1006
+ const rightSize = sizes.get(rightId);
1007
+ const leftKnown = Number.isFinite(leftSize);
1008
+ const rightKnown = Number.isFinite(rightSize);
1009
+
1010
+ if (leftKnown !== rightKnown) {
1011
+ return leftKnown ? -1 : 1;
1012
+ }
1013
+
1014
+ return (rightSize ?? 0) - (leftSize ?? 0) || leftId.localeCompare(rightId);
1015
+ }
1016
+
1017
+ function getSessionSort(sort, database) {
1018
+ const { activity, columns, created } = stateExpressions(database);
1019
+ const nameParts = ["t.title", "t.first_user_message"].filter((column) => columns.has(column.slice(2)))
1020
+ .map((column) => `nullif(trim(${column}), '')`);
1021
+ const name = `lower(coalesce(${[...nameParts, "t.id"].join(", ")}))`;
1022
+ const cwd = columns.has("cwd") ? "lower(coalesce(t.cwd, ''))" : "''";
905
1023
 
906
1024
  return {
907
- created: `${SESSION_CREATED_SQL} desc, t.id asc`,
908
- cwd: `lower(coalesce(t.cwd, '')) asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
909
- name: `${name} asc, ${SESSION_ACTIVITY_SQL} desc, t.id asc`,
910
- updated: `${SESSION_ACTIVITY_SQL} desc, t.id asc`,
911
- }[sort] ?? `${SESSION_ACTIVITY_SQL} desc, t.id asc`;
1025
+ created: {
1026
+ compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
1027
+ order: `${created} desc, t.id asc`,
1028
+ selection: `${created} as sort_number`,
1029
+ },
1030
+ cwd: {
1031
+ compare: (left, right) => String(left.sort_text).localeCompare(String(right.sort_text))
1032
+ || Number(right.sort_activity) - Number(left.sort_activity),
1033
+ order: `${cwd} asc, ${activity} desc, t.id asc`,
1034
+ selection: `${cwd} as sort_text, ${activity} as sort_activity`,
1035
+ },
1036
+ name: {
1037
+ compare: (left, right) => String(left.sort_text).localeCompare(String(right.sort_text))
1038
+ || Number(right.sort_activity) - Number(left.sort_activity),
1039
+ order: `${name} asc, ${activity} desc, t.id asc`,
1040
+ selection: `${name} as sort_text, ${activity} as sort_activity`,
1041
+ },
1042
+ updated: {
1043
+ compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
1044
+ order: `${activity} desc, t.id asc`,
1045
+ selection: `${activity} as sort_number`,
1046
+ },
1047
+ }[sort] ?? {
1048
+ compare: (left, right) => Number(right.sort_number) - Number(left.sort_number),
1049
+ order: `${activity} desc, t.id asc`,
1050
+ selection: `${activity} as sort_number`,
1051
+ };
1052
+ }
1053
+
1054
+ function getSessionOrder(sort, database) {
1055
+ return getSessionSort(sort, database).order;
912
1056
  }
913
1057
 
914
- function getSessionConditions({
1058
+ function getSessionConditions(database, {
915
1059
  archiveStatus,
916
1060
  inactiveBeforeMs,
917
1061
  includeInternals,
@@ -919,32 +1063,34 @@ function getSessionConditions({
919
1063
  search,
920
1064
  workspace,
921
1065
  }) {
1066
+ const { activity, columns, hasSpawnEdges } = stateExpressions(database);
922
1067
  const conditions = [];
923
1068
  const parameters = [];
924
1069
 
925
- if (archiveStatus === "active") {
1070
+ if (archiveStatus === "active" && columns.has("archived")) {
926
1071
  conditions.push("coalesce(t.archived, 0) = 0");
927
1072
  } else if (archiveStatus === "archived") {
928
- conditions.push("coalesce(t.archived, 0) <> 0");
1073
+ conditions.push(columns.has("archived") ? "coalesce(t.archived, 0) <> 0" : "0 = 1");
929
1074
  }
930
1075
 
931
- if (!includeInternals) {
1076
+ if (!includeInternals && hasSpawnEdges) {
932
1077
  conditions.push(`not exists (
933
1078
  select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id
934
1079
  )`);
935
1080
  }
936
1081
 
937
- if (!includeSupporting) {
938
- conditions.push("coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') not like ?");
1082
+ const supportingColumns = ["title", "first_user_message"].filter((column) => columns.has(column));
1083
+ if (!includeSupporting && supportingColumns.length > 0) {
1084
+ conditions.push(`coalesce(${supportingColumns.map((column) => `nullif(trim(t.${column}), '')`).join(", ")}, '') not like ?`);
939
1085
  parameters.push(`${SUPPORTING_THREAD_PREFIX}%`);
940
1086
  }
941
1087
 
942
1088
  if (Number.isFinite(inactiveBeforeMs) && inactiveBeforeMs > 0) {
943
- conditions.push(`${SESSION_ACTIVITY_SQL} > 0 and ${SESSION_ACTIVITY_SQL} <= ?`);
1089
+ conditions.push(`${activity} > 0 and ${activity} <= ?`);
944
1090
  parameters.push(Math.trunc(inactiveBeforeMs));
945
1091
  }
946
1092
 
947
- if (typeof workspace === "string") {
1093
+ if (typeof workspace === "string" && columns.has("cwd")) {
948
1094
  conditions.push("coalesce(t.cwd, '') = ?");
949
1095
  parameters.push(workspace);
950
1096
  }
@@ -952,14 +1098,10 @@ function getSessionConditions({
952
1098
  const normalizedSearch = normalizeText(search).toLowerCase();
953
1099
 
954
1100
  if (normalizedSearch) {
955
- conditions.push(`(
956
- instr(lower(t.id), ?) > 0
957
- or instr(lower(coalesce(t.title, '')), ?) > 0
958
- or instr(lower(coalesce(t.first_user_message, '')), ?) > 0
959
- or instr(lower(coalesce(t.cwd, '')), ?) > 0
960
- or instr(lower(coalesce(t.rollout_path, '')), ?) > 0
961
- )`);
962
- parameters.push(...Array(5).fill(normalizedSearch));
1101
+ const searchColumns = ["id", "title", "first_user_message", "cwd", "rollout_path"]
1102
+ .filter((column) => column === "id" || columns.has(column));
1103
+ conditions.push(`(${searchColumns.map((column) => `instr(lower(coalesce(t.${column}, '')), ?) > 0`).join(" or ")})`);
1104
+ parameters.push(...Array(searchColumns.length).fill(normalizedSearch));
963
1105
  }
964
1106
 
965
1107
  return {
@@ -968,30 +1110,25 @@ function getSessionConditions({
968
1110
  };
969
1111
  }
970
1112
 
971
- const SESSION_COLUMNS = `
972
- t.id,
973
- t.rollout_path,
974
- t.cwd,
975
- t.title,
976
- t.first_user_message,
977
- t.agent_nickname,
978
- t.agent_role,
979
- t.archived,
980
- t.is_pinned,
981
- ${SESSION_CREATED_SQL} as created_at_ms,
982
- ${SESSION_ACTIVITY_SQL} as updated_at_ms,
983
- (
984
- select edge.parent_thread_id
985
- from thread_spawn_edges edge
986
- where edge.child_thread_id = t.id
987
- limit 1
988
- ) as parent_thread_id
989
- `;
990
-
991
- async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1113
+ function sessionColumns(database) {
1114
+ const { activity, columns, created, hasSpawnEdges } = stateExpressions(database);
1115
+ const optional = OPTIONAL_THREAD_COLUMNS.map((column) =>
1116
+ `${columnExpression(columns, column)} as ${column}`,
1117
+ );
1118
+ const parent = hasSpawnEdges
1119
+ ? `(select edge.parent_thread_id from thread_spawn_edges edge where edge.child_thread_id = t.id limit 1)`
1120
+ : "null";
1121
+ return `t.id, t.rollout_path, ${optional.join(", ")}, ${created} as created_at_ms_resolved, ${activity} as updated_at_ms_resolved, ${parent} as parent_thread_id`;
1122
+ }
1123
+
1124
+ async function formatPagedThreadRows(stateDatabase, threadRows) {
1125
+ const stateDatabasePath = typeof stateDatabase === "string" ? stateDatabase : stateDatabase.path;
1126
+ const hasSpawnEdges = typeof stateDatabase === "string"
1127
+ ? inspectSqliteTable(stateDatabasePath, "thread_spawn_edges").exists
1128
+ : stateSchema(stateDatabase).hasSpawnEdges;
992
1129
  const childIdsByParentId = new Map();
993
1130
 
994
- if (threadRows.length > 0) {
1131
+ if (threadRows.length > 0 && hasSpawnEdges) {
995
1132
  const ids = threadRows.map((row) => String(row.id));
996
1133
 
997
1134
  for (const idBatch of batches(ids)) {
@@ -1014,18 +1151,22 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1014
1151
  const records = [];
1015
1152
 
1016
1153
  for (const threadRow of threadRows) {
1017
- const title = normalizeDisplayName(threadRow.title ?? "");
1018
- const firstUserMessage = getMeaningfulUserText(threadRow.first_user_message ?? "");
1154
+ const title = cleanDerivedTitle(threadRow.title ?? "");
1155
+ const firstUserMessage = cleanDerivedTitle(
1156
+ getMeaningfulUserText(threadRow.first_user_message ?? ""),
1157
+ );
1019
1158
  const displayName = title || firstUserMessage || `Untitled ${String(threadRow.id).slice(0, 8)}`;
1020
1159
  const rolloutPath = threadRow.rollout_path ?? "";
1021
1160
  let transcriptHeader = null;
1161
+ let transcriptBytes = null;
1022
1162
 
1023
1163
  if (rolloutPath) {
1024
- try {
1025
- transcriptHeader = await parseTranscriptHeader(rolloutPath);
1026
- } catch {
1027
- transcriptHeader = null;
1028
- }
1164
+ const [header, stats] = await Promise.all([
1165
+ parseTranscriptHeader(rolloutPath).catch(() => null),
1166
+ fs.stat(rolloutPath).catch(() => null),
1167
+ ]);
1168
+ transcriptHeader = header;
1169
+ transcriptBytes = stats?.isFile() ? stats.size : null;
1029
1170
  }
1030
1171
 
1031
1172
  const parentThreadId = threadRow.parent_thread_id ?? transcriptHeader?.parentThreadId ?? null;
@@ -1035,7 +1176,7 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1035
1176
  agentRole: threadRow.agent_role ?? transcriptHeader?.agentRole ?? null,
1036
1177
  archived: Boolean(threadRow.archived),
1037
1178
  childThreadIds: [...(childIdsByParentId.get(threadRow.id) ?? [])].sort(),
1038
- createdAtMs: toTimestampMs(threadRow.created_at_ms),
1179
+ createdAtMs: toTimestampMs(threadRow.created_at_ms_resolved ?? threadRow.created_at_ms),
1039
1180
  cwd: threadRow.cwd ?? "",
1040
1181
  displayName,
1041
1182
  firstUserMessage: threadRow.first_user_message ?? "",
@@ -1051,7 +1192,9 @@ async function formatPagedThreadRows(stateDatabasePath, threadRows) {
1051
1192
  rolloutPath,
1052
1193
  title: threadRow.title ?? "",
1053
1194
  titleSource: title ? "sqlite_title" : firstUserMessage ? "sqlite_first_user_message" : "fallback",
1054
- updatedAtMs: toTimestampMs(threadRow.updated_at_ms),
1195
+ transcriptBytes,
1196
+ updatedAtMs: toTimestampMs(threadRow.updated_at_ms_resolved ?? threadRow.updated_at_ms),
1197
+ _stateDatabasePath: stateDatabasePath,
1055
1198
  });
1056
1199
  }
1057
1200
 
@@ -1068,9 +1211,9 @@ async function getStoreAvailability(paths) {
1068
1211
  ] = await Promise.all([
1069
1212
  pathExists(paths.desktopStatePath),
1070
1213
  pathExists(paths.desktopStateBackupPath),
1071
- pathExists(paths.goalsDatabasePath),
1072
- pathExists(paths.logsDatabasePath),
1073
- pathExists(paths.memoryDatabasePath),
1214
+ Promise.resolve(paths.goalsDatabasePaths.length > 0),
1215
+ Promise.resolve(paths.logsDatabasePaths.length > 0),
1216
+ Promise.resolve(paths.memoryDatabasePaths.length > 0),
1074
1217
  ]);
1075
1218
 
1076
1219
  return {
@@ -1156,13 +1299,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1156
1299
 
1157
1300
  for (let offset = 0; offset < pendingIds.length; offset += 400) {
1158
1301
  const idBatch = pendingIds.slice(offset, offset + 400);
1159
- const stateChildren = queryRows(
1160
- paths.stateDatabasePath,
1161
- `select parent_thread_id, child_thread_id
1162
- from thread_spawn_edges
1163
- where parent_thread_id in (${placeholders(idBatch)})`,
1164
- idBatch,
1165
- );
1302
+ const stateChildren = paths.stateDatabases.flatMap((database) => stateSchema(database).hasSpawnEdges
1303
+ ? queryRows(
1304
+ database.path,
1305
+ `select parent_thread_id, child_thread_id from thread_spawn_edges where parent_thread_id in (${placeholders(idBatch)})`,
1306
+ idBatch,
1307
+ )
1308
+ : []);
1166
1309
 
1167
1310
  for (const edge of stateChildren) {
1168
1311
  const childId = String(edge.child_thread_id);
@@ -1183,19 +1326,29 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1183
1326
  }
1184
1327
 
1185
1328
  const ids = [...selectedIds];
1186
- const threadRows = [];
1187
-
1188
- for (const idBatch of batches(ids)) {
1189
- threadRows.push(...queryRows(
1190
- paths.stateDatabasePath,
1191
- `select ${SESSION_COLUMNS}
1192
- from threads t
1193
- where t.id in (${placeholders(idBatch)})`,
1194
- idBatch,
1195
- ));
1329
+ const threadRowsById = new Map();
1330
+ for (const database of paths.stateDatabases) {
1331
+ for (const idBatch of batches(ids)) {
1332
+ for (const row of queryRows(
1333
+ database.path,
1334
+ `select ${sessionColumns(database)} from threads t where t.id in (${placeholders(idBatch)})`,
1335
+ idBatch,
1336
+ )) {
1337
+ const id = String(row.id);
1338
+ if (!threadRowsById.has(id)) threadRowsById.set(id, { ...row, _database: database });
1339
+ }
1340
+ }
1196
1341
  }
1197
-
1198
- const spawnEdges = queryRelatedSpawnEdges(paths.stateDatabasePath, ids);
1342
+ const threadRows = [...threadRowsById.values()];
1343
+ const spawnEdgesByKey = new Map();
1344
+ for (const database of paths.stateDatabases) {
1345
+ if (!stateSchema(database).hasSpawnEdges) continue;
1346
+ for (const edge of queryRelatedSpawnEdges(database.path, ids)) {
1347
+ const key = `${edge.parent_thread_id}\0${edge.child_thread_id}`;
1348
+ if (!spawnEdgesByKey.has(key)) spawnEdgesByKey.set(key, edge);
1349
+ }
1350
+ }
1351
+ const spawnEdges = [...spawnEdgesByKey.values()];
1199
1352
  const childIdsByParentId = new Map();
1200
1353
  const parentIdsByChildId = new Map();
1201
1354
 
@@ -1220,7 +1373,13 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1220
1373
  }
1221
1374
  }
1222
1375
 
1223
- const formattedRows = await formatPagedThreadRows(paths.stateDatabasePath, threadRows);
1376
+ const formattedRows = [];
1377
+ for (const database of paths.stateDatabases) {
1378
+ formattedRows.push(...await formatPagedThreadRows(
1379
+ database,
1380
+ threadRows.filter((row) => row._database === database),
1381
+ ));
1382
+ }
1224
1383
  const recordsById = new Map();
1225
1384
 
1226
1385
  for (const record of formattedRows) {
@@ -1322,6 +1481,12 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1322
1481
  sessionIndexPath: paths.sessionIndexPath,
1323
1482
  spawnEdges,
1324
1483
  stateDatabasePath: paths.stateDatabasePath,
1484
+ stateDatabasePaths: paths.stateDatabasePaths,
1485
+ stateDatabases: paths.stateDatabases,
1486
+ resolvedDatabases: paths.resolvedDatabases,
1487
+ logsDatabasePaths: paths.logsDatabasePaths,
1488
+ memoryDatabasePaths: paths.memoryDatabasePaths,
1489
+ goalsDatabasePaths: paths.goalsDatabasePaths,
1325
1490
  transcriptHeaders: new Map(
1326
1491
  [...transcriptHeaders].filter(([id]) => relevantIds.has(id)),
1327
1492
  ),
@@ -1336,46 +1501,113 @@ export async function listSessions({
1336
1501
  includeSupporting = false,
1337
1502
  page = 1,
1338
1503
  pageSize = DEFAULT_PAGE_SIZE,
1504
+ refresh = false,
1339
1505
  search = "",
1340
1506
  sort = "updated",
1341
1507
  workspace,
1508
+ forceUnion = false,
1342
1509
  }) {
1343
- const paths = getCodexPaths(codexHome);
1510
+ const paths = getCodexPaths(codexHome, { refresh });
1344
1511
  const boundedPageSize = Number.isFinite(pageSize)
1345
1512
  ? Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(pageSize)))
1346
1513
  : DEFAULT_PAGE_SIZE;
1347
1514
  const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
1348
- const conditions = getSessionConditions({
1349
- archiveStatus,
1350
- inactiveBeforeMs,
1351
- includeInternals,
1352
- includeSupporting,
1353
- search,
1354
- workspace,
1355
- });
1356
- const countRow = queryRows(
1357
- paths.stateDatabasePath,
1358
- `select count(*) as count from threads t ${conditions.sql}`,
1359
- conditions.parameters,
1360
- )[0];
1361
- const total = Number(countRow?.count ?? 0);
1515
+ const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
1516
+ if (paths.stateDatabases.length === 1 && resolvedSort !== "size" && !forceUnion) {
1517
+ const database = paths.stateDatabases[0];
1518
+ const conditions = getSessionConditions(database, {
1519
+ archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
1520
+ });
1521
+ const countRow = queryRows(
1522
+ database.path,
1523
+ `select count(*) as count from threads t ${conditions.sql}`,
1524
+ conditions.parameters,
1525
+ )[0];
1526
+ const total = Number(countRow?.count ?? 0);
1527
+ const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
1528
+ const currentPage = Math.min(requestedPage, pageCount);
1529
+ const rows = queryRows(
1530
+ database.path,
1531
+ `select ${sessionColumns(database)}
1532
+ from threads t
1533
+ ${conditions.sql}
1534
+ order by ${getSessionOrder(resolvedSort, database)}
1535
+ limit ? offset ?`,
1536
+ [...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
1537
+ );
1538
+
1539
+ return {
1540
+ page: currentPage,
1541
+ pageCount,
1542
+ pageSize: boundedPageSize,
1543
+ records: await formatPagedThreadRows(database, rows),
1544
+ total,
1545
+ };
1546
+ }
1547
+
1548
+ const ordered = [];
1549
+ const seenIds = paths.stateDatabases.length > 1 ? new Set() : null;
1550
+ const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1;
1551
+ let compareSortRows = null;
1552
+ for (const database of paths.stateDatabases) {
1553
+ const conditions = getSessionConditions(database, {
1554
+ archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
1555
+ });
1556
+ const sessionSort = resolvedSort === "size" ? null : getSessionSort(resolvedSort, database);
1557
+ compareSortRows ??= sessionSort?.compare ?? null;
1558
+ const rows = queryRows(
1559
+ database.path,
1560
+ `select t.id${sessionSort ? `, ${sessionSort.selection}` : ""} from threads t ${conditions.sql}`,
1561
+ conditions.parameters,
1562
+ );
1563
+ for (const row of rows) {
1564
+ const id = String(row.id);
1565
+ if (seenIds?.has(id)) continue;
1566
+ seenIds?.add(id);
1567
+ if (compactSizeIds) {
1568
+ ordered.push(id);
1569
+ continue;
1570
+ }
1571
+ row.database = database;
1572
+ row.id = id;
1573
+ ordered.push(row);
1574
+ }
1575
+ }
1576
+ if (resolvedSort === "size") {
1577
+ const sizes = await getSessionSizeIndex(paths, { refresh });
1578
+ ordered.sort((left, right) => compareSessionIdsBySize(
1579
+ compactSizeIds ? left : left.id,
1580
+ compactSizeIds ? right : right.id,
1581
+ sizes,
1582
+ ));
1583
+ } else {
1584
+ ordered.sort((left, right) => compareSortRows(left, right) || left.id.localeCompare(right.id));
1585
+ }
1586
+ const total = ordered.length;
1362
1587
  const pageCount = Math.max(1, Math.ceil(total / boundedPageSize));
1363
1588
  const currentPage = Math.min(requestedPage, pageCount);
1364
- const threadRows = queryRows(
1365
- paths.stateDatabasePath,
1366
- `select ${SESSION_COLUMNS}
1367
- from threads t
1368
- ${conditions.sql}
1369
- order by ${getSessionOrder(sort)}
1370
- limit ? offset ?`,
1371
- [...conditions.parameters, boundedPageSize, (currentPage - 1) * boundedPageSize],
1372
- );
1589
+ const pageItems = ordered.slice((currentPage - 1) * boundedPageSize, currentPage * boundedPageSize);
1590
+ const recordsById = new Map();
1591
+ const itemsByDatabase = new Map();
1592
+ if (compactSizeIds) {
1593
+ itemsByDatabase.set(paths.stateDatabases[0], pageItems);
1594
+ } else {
1595
+ for (const item of pageItems) {
1596
+ const values = itemsByDatabase.get(item.database) ?? [];
1597
+ values.push(item.id);
1598
+ itemsByDatabase.set(item.database, values);
1599
+ }
1600
+ }
1601
+ for (const [database, ids] of itemsByDatabase) {
1602
+ const rows = queryRows(database.path, `select ${sessionColumns(database)} from threads t where t.id in (${placeholders(ids)})`, ids);
1603
+ for (const record of await formatPagedThreadRows(database, rows)) recordsById.set(record.id, record);
1604
+ }
1373
1605
 
1374
1606
  return {
1375
1607
  page: currentPage,
1376
1608
  pageCount,
1377
1609
  pageSize: boundedPageSize,
1378
- records: await formatPagedThreadRows(paths.stateDatabasePath, threadRows),
1610
+ records: pageItems.map((item) => recordsById.get(compactSizeIds ? item : item.id)).filter(Boolean),
1379
1611
  total,
1380
1612
  };
1381
1613
  }
@@ -1429,75 +1661,83 @@ async function measureTranscriptStorage(transcriptDirectories) {
1429
1661
  };
1430
1662
  }
1431
1663
 
1432
- export async function getSessionOverview({ codexHome }) {
1433
- const paths = getCodexPaths(codexHome);
1434
- const supportingPattern = `${SUPPORTING_THREAD_PREFIX}%`;
1435
- const overviewRow = queryRows(
1436
- paths.stateDatabasePath,
1437
- `select
1438
- count(*) as total,
1439
- coalesce(sum(case when coalesce(t.archived, 0) <> 0 then 1 else 0 end), 0) as archived_count,
1440
- coalesce(sum(case when coalesce(t.archived, 0) = 0 then 1 else 0 end), 0) as active_count,
1441
- count(children.child_thread_id) as subagent_count,
1442
- coalesce(sum(case
1443
- when children.child_thread_id is null
1444
- and not (coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ?)
1445
- then 1
1446
- else 0
1447
- end), 0) as primary_session_count,
1448
- coalesce(sum(case
1449
- when coalesce(nullif(trim(t.title), ''), nullif(trim(t.first_user_message), ''), '') like ? then 1
1450
- else 0
1451
- end), 0) as supporting_count,
1452
- coalesce(sum(case when ${SESSION_ACTIVITY_SQL} = 0 then 1 else 0 end), 0) as unknown_activity_count
1453
- from threads t
1454
- left join (
1455
- select distinct child_thread_id from thread_spawn_edges
1456
- ) children on children.child_thread_id = t.id`,
1457
- [supportingPattern, supportingPattern],
1458
- )[0] ?? {};
1459
- const workspaceRows = queryRows(
1460
- paths.stateDatabasePath,
1461
- `select
1462
- coalesce(t.cwd, '') as path,
1463
- count(*) as session_count,
1464
- max(${SESSION_ACTIVITY_SQL}) as last_activity_at_ms
1465
- from threads t
1466
- group by coalesce(t.cwd, '')
1467
- order by
1468
- case when coalesce(t.cwd, '') = '' then 1 else 0 end,
1469
- last_activity_at_ms desc,
1470
- lower(coalesce(t.cwd, '')) asc`,
1471
- );
1472
- const storage = await measureTranscriptStorage(paths.transcriptDirectories);
1664
+ export async function getSessionOverview({ codexHome, refresh = false }) {
1665
+ const paths = getCodexPaths(codexHome, { refresh });
1666
+ const sessions = new Map();
1667
+ for (const database of paths.stateDatabases) {
1668
+ const { activity, columns, hasSpawnEdges } = stateExpressions(database);
1669
+ const projection = [
1670
+ "t.id",
1671
+ `${columns.has("cwd") ? "coalesce(t.cwd, '')" : "''"} as path`,
1672
+ `${columns.has("archived") ? "coalesce(t.archived, 0)" : "0"} as archived`,
1673
+ `${columns.has("title") ? "coalesce(t.title, '')" : "''"} as title`,
1674
+ `${columns.has("first_user_message") ? "coalesce(t.first_user_message, '')" : "''"} as first_user_message`,
1675
+ `${activity} as last_activity_at_ms`,
1676
+ `${hasSpawnEdges ? "exists(select 1 from thread_spawn_edges edge where edge.child_thread_id = t.id)" : "0"} as is_subagent`,
1677
+ ].join(", ");
1678
+ for (const row of queryRows(database.path, `select ${projection} from threads t`)) {
1679
+ const id = String(row.id);
1680
+ if (!sessions.has(id)) sessions.set(id, row);
1681
+ }
1682
+ }
1683
+ const workspaceSessionRows = [...sessions.values()];
1684
+ const [sizes, storage] = await Promise.all([
1685
+ getSessionSizeIndex(paths, { refresh }),
1686
+ measureTranscriptStorage(paths.transcriptDirectories),
1687
+ ]);
1688
+ const workspaces = new Map();
1689
+ let archivedSessionCount = 0;
1690
+ let subagentCount = 0;
1691
+ let supportingCount = 0;
1692
+ let unknownActivityCount = 0;
1693
+
1694
+ for (const row of workspaceSessionRows) {
1695
+ const supporting = `${row.title || row.first_user_message || ""}`.startsWith(SUPPORTING_THREAD_PREFIX);
1696
+ archivedSessionCount += row.archived ? 1 : 0;
1697
+ subagentCount += row.is_subagent ? 1 : 0;
1698
+ supportingCount += supporting ? 1 : 0;
1699
+ unknownActivityCount += Number(row.last_activity_at_ms) ? 0 : 1;
1700
+ const workspacePath = row.path ?? "";
1701
+ const current = workspaces.get(workspacePath) ?? {
1702
+ lastActivityAtMs: 0,
1703
+ path: workspacePath,
1704
+ sessionCount: 0,
1705
+ transcriptBytes: 0,
1706
+ };
1707
+ current.lastActivityAtMs = Math.max(
1708
+ current.lastActivityAtMs,
1709
+ toTimestampMs(row.last_activity_at_ms),
1710
+ );
1711
+ current.sessionCount += 1;
1712
+ const transcriptBytes = sizes.get(String(row.id));
1713
+ if (Number.isFinite(transcriptBytes)) current.transcriptBytes += transcriptBytes;
1714
+ workspaces.set(workspacePath, current);
1715
+ }
1473
1716
 
1474
1717
  return {
1475
- activeSessionCount: Number(overviewRow.active_count ?? 0),
1476
- archivedSessionCount: Number(overviewRow.archived_count ?? 0),
1718
+ activeSessionCount: sessions.size - archivedSessionCount,
1719
+ archivedSessionCount,
1477
1720
  calculatedAtMs: Date.now(),
1478
- primarySessionCount: Number(overviewRow.primary_session_count ?? 0),
1479
- sessionCount: Number(overviewRow.total ?? 0),
1480
- subagentCount: Number(overviewRow.subagent_count ?? 0),
1481
- supportingCount: Number(overviewRow.supporting_count ?? 0),
1482
- unknownActivityCount: Number(overviewRow.unknown_activity_count ?? 0),
1483
- workspaces: workspaceRows.map((row) => ({
1484
- lastActivityAtMs: toTimestampMs(row.last_activity_at_ms),
1485
- path: row.path ?? "",
1486
- sessionCount: Number(row.session_count ?? 0),
1487
- })),
1721
+ primarySessionCount: sessions.size - subagentCount - supportingCount,
1722
+ sessionCount: sessions.size,
1723
+ subagentCount,
1724
+ supportingCount,
1725
+ unknownActivityCount,
1726
+ workspaces: [...workspaces.values()].sort((left, right) =>
1727
+ Number(!left.path) - Number(!right.path)
1728
+ || right.lastActivityAtMs - left.lastActivityAtMs
1729
+ || left.path.localeCompare(right.path)),
1488
1730
  ...storage,
1489
1731
  };
1490
1732
  }
1491
1733
 
1492
1734
  export async function getSessionRecord({ codexHome, id }) {
1493
1735
  const paths = getCodexPaths(codexHome);
1494
- const rows = queryRows(
1495
- paths.stateDatabasePath,
1496
- `select ${SESSION_COLUMNS} from threads t where t.id = ? limit 1`,
1497
- [id],
1498
- );
1499
- const records = await formatPagedThreadRows(paths.stateDatabasePath, rows);
1500
- return records[0] ?? null;
1736
+ for (const database of paths.stateDatabases) {
1737
+ const rows = queryRows(database.path, `select ${sessionColumns(database)} from threads t where t.id = ? limit 1`, [id]);
1738
+ if (rows.length) return (await formatPagedThreadRows(database, rows))[0] ?? null;
1739
+ }
1740
+ return null;
1501
1741
  }
1502
1742
 
1503
1743
  export function filterAndSortSessions({
@@ -1545,6 +1785,12 @@ export function filterAndSortSessions({
1545
1785
  name: (left, right) =>
1546
1786
  left.displayName.localeCompare(right.displayName) ||
1547
1787
  right.updatedAtMs - left.updatedAtMs,
1788
+ size: (left, right) => {
1789
+ const leftKnown = Number.isFinite(left.transcriptBytes);
1790
+ const rightKnown = Number.isFinite(right.transcriptBytes);
1791
+ if (leftKnown !== rightKnown) return leftKnown ? -1 : 1;
1792
+ return (right.transcriptBytes ?? 0) - (left.transcriptBytes ?? 0);
1793
+ },
1548
1794
  updated: (left, right) =>
1549
1795
  right.updatedAtMs - left.updatedAtMs || left.displayName.localeCompare(right.displayName),
1550
1796
  };
@@ -1588,6 +1834,18 @@ function findRowsForIds(databasePath, tableName, columnName, ids, { limitOne = f
1588
1834
  return rows;
1589
1835
  }
1590
1836
 
1837
+ function fingerprintRowsForIds(hash, databasePath, tableName, columnName, ids) {
1838
+ for (const idBatch of batches(ids)) {
1839
+ const rows = queryRows(
1840
+ databasePath,
1841
+ `select * from ${tableName} where ${columnName} in (${placeholders(idBatch)})`,
1842
+ idBatch,
1843
+ );
1844
+ rows.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
1845
+ hash.update(`${databasePath}\0${tableName}\0${JSON.stringify(rows)}\0`);
1846
+ }
1847
+ }
1848
+
1591
1849
  function* deleteStatements(tableName, columnName, ids) {
1592
1850
  for (const idBatch of batches(ids)) {
1593
1851
  yield {
@@ -1677,15 +1935,16 @@ export async function planSessionDeletion({ recordIds, store }) {
1677
1935
 
1678
1936
  return count;
1679
1937
  }, 0);
1680
- const logRowCount = store.hasLogsDatabase
1681
- ? countRowsForIds(store.logsDatabasePath, "logs", "thread_id", deletionIds)
1682
- : 0;
1683
- const memoryRowCount = store.hasMemoryDatabase
1684
- ? countRowsForIds(store.memoryDatabasePath, "stage1_outputs", "thread_id", deletionIds)
1685
- : 0;
1686
- const goalRowCount = store.hasGoalsDatabase
1687
- ? countRowsForIds(store.goalsDatabasePath, "thread_goals", "thread_id", deletionIds)
1688
- : 0;
1938
+ const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
1939
+ .reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
1940
+ const memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
1941
+ .reduce((total, databasePath) => total + countRowsForIds(databasePath, "stage1_outputs", "thread_id", deletionIds), 0);
1942
+ const goalRowCount = (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean))
1943
+ .reduce((total, databasePath) => total + countRowsForIds(databasePath, "thread_goals", "thread_id", deletionIds), 0);
1944
+ const stateTargets = (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({
1945
+ database,
1946
+ ids: findRowsForIds(database.path, "threads", "id", deletionIds).map((row) => String(row.id)),
1947
+ }));
1689
1948
 
1690
1949
  return {
1691
1950
  childCount: Math.max(0, deletionIds.length - recordIds.length),
@@ -1704,6 +1963,7 @@ export async function planSessionDeletion({ recordIds, store }) {
1704
1963
  records: selectedRecords,
1705
1964
  sessionIndexMatchCount: sessionIndexMatches.count,
1706
1965
  spawnEdgeCount,
1966
+ stateTargets,
1707
1967
  transcriptBytes,
1708
1968
  transcriptFileCount,
1709
1969
  transcriptPaths,
@@ -1713,19 +1973,19 @@ export async function planSessionDeletion({ recordIds, store }) {
1713
1973
  const BACKUP_MINIMUM_RESERVE_BYTES = 1024 * 1024;
1714
1974
  const BACKUP_RESERVE_RATIO = 0.05;
1715
1975
 
1716
- function getBackupSourcePaths({ plan, store }) {
1976
+ function getBackupSourcePaths({ plan, scope, store }) {
1717
1977
  const databasePaths = [
1718
- store.stateDatabasePath,
1719
- store.hasLogsDatabase ? store.logsDatabasePath : null,
1720
- store.hasMemoryDatabase ? store.memoryDatabasePath : null,
1721
- store.hasGoalsDatabase ? store.goalsDatabasePath : null,
1978
+ ...(store.stateDatabasePaths ?? [store.stateDatabasePath]),
1979
+ ...(store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)),
1980
+ ...(scope === "deep" ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) : []),
1981
+ ...(scope === "deep" ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) : []),
1722
1982
  ].filter(Boolean);
1723
1983
 
1724
1984
  return [
1725
1985
  store.historyPath,
1726
1986
  store.sessionIndexPath,
1727
- store.hasDesktopState ? store.desktopStatePath : null,
1728
- store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
1987
+ scope === "deep" && store.hasDesktopState ? store.desktopStatePath : null,
1988
+ scope === "deep" && store.hasDesktopStateBackup ? store.desktopStateBackupPath : null,
1729
1989
  ...plan.transcriptPaths,
1730
1990
  ...databasePaths.flatMap((databasePath) => [
1731
1991
  databasePath,
@@ -1772,6 +2032,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1772
2032
  store.hasMemoryDatabase,
1773
2033
  ] : []),
1774
2034
  ].map(Number).join("\0")}\0`);
2035
+ hash.update(`resolved-databases\0${JSON.stringify(store.resolvedDatabases ?? {})}\0`);
1775
2036
 
1776
2037
  for (const record of [...plan.records].sort((left, right) => left.id.localeCompare(right.id))) {
1777
2038
  hash.update([
@@ -1790,6 +2051,26 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1790
2051
  hash.update(`id\0${id}\0`);
1791
2052
  }
1792
2053
 
2054
+ for (const database of store.stateDatabases ?? [{ path: store.stateDatabasePath }]) {
2055
+ fingerprintRowsForIds(hash, database.path, "threads", "id", plan.ids);
2056
+ if (stateSchema(database).hasSpawnEdges) {
2057
+ fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "parent_thread_id", plan.ids);
2058
+ fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "child_thread_id", plan.ids);
2059
+ }
2060
+ }
2061
+ for (const databasePath of store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)) {
2062
+ fingerprintRowsForIds(hash, databasePath, "logs", "thread_id", plan.ids);
2063
+ }
2064
+ if (scope === "deep") {
2065
+ for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) {
2066
+ fingerprintRowsForIds(hash, databasePath, "stage1_outputs", "thread_id", plan.ids);
2067
+ }
2068
+ for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) {
2069
+ fingerprintRowsForIds(hash, databasePath, "thread_goals", "thread_id", plan.ids);
2070
+ fingerprintRowsForIds(hash, databasePath, "thread_goal_continuation_deferrals", "thread_id", plan.ids);
2071
+ }
2072
+ }
2073
+
1793
2074
  for (const filePath of [...plan.missingTranscriptPaths].sort()) {
1794
2075
  hash.update(`missing-transcript\0${filePath}\0`);
1795
2076
  }
@@ -1811,9 +2092,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
1811
2092
  return hash.digest("hex");
1812
2093
  }
1813
2094
 
1814
- async function estimateBackupBytes({ plan, store }) {
2095
+ async function estimateBackupBytes({ plan, scope, store }) {
1815
2096
  let sourceBytes = 0;
1816
- const uniquePaths = new Set(getBackupSourcePaths({ plan, store }));
2097
+ const uniquePaths = new Set(getBackupSourcePaths({ plan, scope, store }));
1817
2098
 
1818
2099
  for (const sourcePath of uniquePaths) {
1819
2100
  try {
@@ -1869,7 +2150,7 @@ function formatBytes(bytes) {
1869
2150
  return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`;
1870
2151
  }
1871
2152
 
1872
- export async function preflightSessionDeletion({ availableDiskBytes, plan, store }) {
2153
+ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope = "deep", store }) {
1873
2154
  const requiredPaths = [store.stateDatabasePath, store.sessionIndexPath, store.historyPath];
1874
2155
  const missingRequiredPaths = [];
1875
2156
 
@@ -1889,7 +2170,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, store
1889
2170
  store.hasDesktopStateBackup ? readJsonFile(store.desktopStateBackupPath) : null,
1890
2171
  ]);
1891
2172
  const [desktopState, desktopStateBackup] = desktopStates;
1892
- const backupEstimate = await estimateBackupBytes({ plan, store });
2173
+ const backupEstimate = await estimateBackupBytes({ plan, scope, store });
1893
2174
  const diskCapacityBytes = availableDiskBytes ?? await getAvailableDiskBytes(store.codexHome);
1894
2175
 
1895
2176
  if (diskCapacityBytes < backupEstimate.estimatedBackupBytes) {
@@ -1958,10 +2239,10 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
1958
2239
  transcriptNameCounts.set(name, (transcriptNameCounts.get(name) ?? 0) + 1);
1959
2240
  }
1960
2241
  const snapshotCandidates = [
1961
- [store.stateDatabasePath, "state_5.sqlite", true],
1962
- [store.hasLogsDatabase ? store.logsDatabasePath : null, "logs_2.sqlite", true],
1963
- [store.hasMemoryDatabase ? store.memoryDatabasePath : null, "memories_1.sqlite", scope === "deep"],
1964
- [store.hasGoalsDatabase ? store.goalsDatabasePath : null, "goals_1.sqlite", scope === "deep"],
2242
+ ...(store.stateDatabasePaths ?? [store.stateDatabasePath]).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2243
+ ...(store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean)).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2244
+ ...(scope === "deep" ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean)) : []).map((databasePath) => [databasePath, path.basename(databasePath), true]),
2245
+ ...(scope === "deep" ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath].filter(Boolean)) : []).map((databasePath) => [databasePath, path.basename(databasePath), true]),
1965
2246
  ];
1966
2247
  const totalItems = backupFiles.length + plan.transcriptPaths.length + snapshotCandidates.length;
1967
2248
  let completedItems = 0;
@@ -2034,7 +2315,18 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
2034
2315
 
2035
2316
  await atomicWriteFile(
2036
2317
  path.join(backupDirectory, "operation.json"),
2037
- `${JSON.stringify({ version: 2, ids: plan.ids, scope, createdAtMs: Date.now(), copiedFiles, databaseSnapshots, files }, null, 2)}\n`,
2318
+ `${JSON.stringify({
2319
+ version: 3,
2320
+ ids: plan.ids,
2321
+ scope,
2322
+ createdAtMs: Date.now(),
2323
+ copiedFiles,
2324
+ databaseSnapshots,
2325
+ files,
2326
+ profileId: CODEX_DATABASE_PROFILE.id,
2327
+ resolvedDatabases: store.resolvedDatabases ?? {},
2328
+ compatibilityStatus: (await diagnoseStorageCompatibility({ codexHome: store.codexHome })).status,
2329
+ }, null, 2)}\n`,
2038
2330
  );
2039
2331
 
2040
2332
  return backupDirectory;
@@ -2079,7 +2371,7 @@ export async function executeSessionDeletion({
2079
2371
  });
2080
2372
  if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
2081
2373
  const deletedIdSet = new Set(plan.ids);
2082
- const preflight = await preflightSessionDeletion({ plan, store });
2374
+ const preflight = await preflightSessionDeletion({ plan, scope, store });
2083
2375
  if (cancellationRequested(shouldCancel)) throw cleanupCancelled();
2084
2376
  const backupDirectory = await createOperationBackup({
2085
2377
  onProgress,
@@ -2101,39 +2393,40 @@ export async function executeSessionDeletion({
2101
2393
 
2102
2394
  try {
2103
2395
  if (scope === "deep" && store.hasMemoryDatabase) {
2104
- executeTransaction(
2105
- store.memoryDatabasePath,
2106
- deleteStatements("stage1_outputs", "thread_id", plan.ids),
2107
- );
2396
+ for (const databasePath of store.memoryDatabasePaths ?? [store.memoryDatabasePath]) {
2397
+ executeTransaction(databasePath, deleteStatements("stage1_outputs", "thread_id", plan.ids));
2398
+ }
2108
2399
  }
2109
2400
  if (scope === "deep" && store.hasGoalsDatabase) {
2110
- executeTransaction(
2111
- store.goalsDatabasePath,
2112
- deleteStatements("thread_goals", "thread_id", plan.ids),
2113
- );
2401
+ for (const databasePath of store.goalsDatabasePaths ?? [store.goalsDatabasePath]) {
2402
+ executeTransaction(databasePath, [
2403
+ ...deleteStatements("thread_goal_continuation_deferrals", "thread_id", plan.ids),
2404
+ ...deleteStatements("thread_goals", "thread_id", plan.ids),
2405
+ ]);
2406
+ }
2114
2407
  }
2115
2408
 
2116
2409
  if (store.hasLogsDatabase) {
2117
- executeTransaction(
2118
- store.logsDatabasePath,
2119
- deleteStatements("logs", "thread_id", plan.ids),
2120
- );
2410
+ for (const databasePath of store.logsDatabasePaths ?? [store.logsDatabasePath]) {
2411
+ executeTransaction(databasePath, deleteStatements("logs", "thread_id", plan.ids));
2412
+ }
2121
2413
  }
2122
- const stateStatements = [];
2123
-
2124
- for (const idBatch of batches(plan.ids)) {
2125
- const idPlaceholders = placeholders(idBatch);
2126
- stateStatements.push({
2127
- parameters: [...idBatch, ...idBatch],
2128
- sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
2129
- });
2130
- stateStatements.push({
2131
- parameters: idBatch,
2132
- sql: `delete from threads where id in (${idPlaceholders})`,
2133
- });
2414
+ for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
2415
+ const stateStatements = [];
2416
+ const hasSpawnEdges = stateSchema(database).hasSpawnEdges;
2417
+ for (const idBatch of batches(ids)) {
2418
+ const idPlaceholders = placeholders(idBatch);
2419
+ if (hasSpawnEdges) stateStatements.push({
2420
+ parameters: [...idBatch, ...idBatch],
2421
+ sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
2422
+ });
2423
+ stateStatements.push({
2424
+ parameters: idBatch,
2425
+ sql: `delete from threads where id in (${idPlaceholders})`,
2426
+ });
2427
+ }
2428
+ executeTransaction(database.path, stateStatements);
2134
2429
  }
2135
-
2136
- executeTransaction(store.stateDatabasePath, stateStatements);
2137
2430
  await reportProgress(onProgress, {
2138
2431
  canCancel: false,
2139
2432
  message: "Updating session records",
@@ -2241,6 +2534,55 @@ export async function deleteSessionDeletionBackup({ backupDirectory, codexHome }
2241
2534
  return { backupDirectory: resolvedBackupDirectory };
2242
2535
  }
2243
2536
 
2537
+ export async function listSessionDeletionBackups({ codexHome }) {
2538
+ const backupRoot = path.join(path.resolve(codexHome), "session-steward-backups");
2539
+ let entries;
2540
+
2541
+ try {
2542
+ entries = await fs.readdir(backupRoot, { withFileTypes: true });
2543
+ } catch (error) {
2544
+ if (error?.code === "ENOENT") return [];
2545
+ throw error;
2546
+ }
2547
+
2548
+ const backups = [];
2549
+
2550
+ for (const entry of entries) {
2551
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
2552
+ const backupDirectory = path.join(backupRoot, entry.name);
2553
+ let operation = null;
2554
+
2555
+ try {
2556
+ operation = await readJsonFile(path.join(backupDirectory, "operation.json"));
2557
+ } catch (error) {
2558
+ if (!(error instanceof SyntaxError)) throw error;
2559
+ }
2560
+
2561
+ const [stats, measured] = await Promise.all([
2562
+ fs.stat(backupDirectory),
2563
+ measurePath(backupDirectory),
2564
+ ]);
2565
+ const restorable = [2, 3].includes(operation?.version) &&
2566
+ Array.isArray(operation.files) &&
2567
+ operation.files.length > 0;
2568
+
2569
+ backups.push({
2570
+ backupDirectory,
2571
+ bytes: measured.bytes,
2572
+ createdAtMs: toTimestampMs(operation?.createdAtMs) || stats.mtimeMs,
2573
+ fileCount: measured.fileCount,
2574
+ id: entry.name,
2575
+ itemCount: Array.isArray(operation?.files) ? operation.files.length : measured.fileCount,
2576
+ providerId: "codex",
2577
+ restorable,
2578
+ scope: operation?.scope === "core" || operation?.scope === "deep" ? operation.scope : null,
2579
+ sessionCount: Array.isArray(operation?.ids) ? operation.ids.length : null,
2580
+ });
2581
+ }
2582
+
2583
+ return backups.sort((left, right) => right.createdAtMs - left.createdAtMs || left.id.localeCompare(right.id));
2584
+ }
2585
+
2244
2586
  async function atomicCopyFile(sourcePath, destinationPath) {
2245
2587
  await fs.mkdir(path.dirname(destinationPath), { recursive: true });
2246
2588
  const temporaryPath = `${destinationPath}.session-steward-${process.pid}-${Date.now()}.tmp`;
@@ -2270,9 +2612,10 @@ export async function restoreSessionDeletionBackup({
2270
2612
  const operationPath = path.join(resolvedBackupDirectory, "operation.json");
2271
2613
  const operation = await readJsonFile(operationPath);
2272
2614
 
2273
- if (operation?.version !== 2 || !Array.isArray(operation.files) || operation.files.length === 0) {
2615
+ if (![2, 3].includes(operation?.version) || !Array.isArray(operation.files) || operation.files.length === 0) {
2274
2616
  throw new Error("This backup cannot be restored automatically. Its files are still available for manual recovery.");
2275
2617
  }
2618
+ const currentDatabasesBeforeRestore = getCodexPaths(codexHome, { refresh: true }).resolvedDatabases;
2276
2619
 
2277
2620
  const files = operation.files.map((entry) => {
2278
2621
  if (typeof entry?.backupPath !== "string" || typeof entry?.originalPath !== "string") {
@@ -2385,7 +2728,10 @@ export async function restoreSessionDeletionBackup({
2385
2728
  progress: 100,
2386
2729
  });
2387
2730
 
2731
+ const layoutChanged = operation.version === 3
2732
+ && JSON.stringify(operation.resolvedDatabases ?? {}) !== JSON.stringify(currentDatabasesBeforeRestore);
2388
2733
  return {
2734
+ note: layoutChanged ? "The Codex storage layout changed after this backup was created. The original files were restored to their recorded locations." : null,
2389
2735
  restoredFileCount: files.length,
2390
2736
  safetyBackupDirectory,
2391
2737
  };
@@ -2401,17 +2747,22 @@ export async function restoreSessionDeletionBackup({
2401
2747
 
2402
2748
  export async function verifySessionDeletion({ plan, scope = "deep", store }) {
2403
2749
  const deletedIdSet = new Set(plan.ids);
2404
- const remainingThreads = findRowsForIds(
2405
- store.stateDatabasePath, "threads", "id", plan.ids,
2406
- );
2750
+ const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
2751
+ .flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
2407
2752
  const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
2408
- ? findRowsForIds(store.memoryDatabasePath, "stage1_outputs", "thread_id", plan.ids)
2753
+ ? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
2754
+ findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
2409
2755
  : [];
2410
2756
  const remainingGoalRecords = scope === "deep" && store.hasGoalsDatabase
2411
- ? findRowsForIds(store.goalsDatabasePath, "thread_goals", "thread_id", plan.ids)
2757
+ ? (store.goalsDatabasePaths ?? [store.goalsDatabasePath]).flatMap((databasePath) =>
2758
+ [
2759
+ ...findRowsForIds(databasePath, "thread_goals", "thread_id", plan.ids),
2760
+ ...findRowsForIds(databasePath, "thread_goal_continuation_deferrals", "thread_id", plan.ids),
2761
+ ])
2412
2762
  : [];
2413
2763
  const remainingLogRecords = store.hasLogsDatabase
2414
- ? findRowsForIds(store.logsDatabasePath, "logs", "thread_id", plan.ids, { limitOne: true })
2764
+ ? (store.logsDatabasePaths ?? [store.logsDatabasePath]).flatMap((databasePath) =>
2765
+ findRowsForIds(databasePath, "logs", "thread_id", plan.ids, { limitOne: true }))
2415
2766
  : [];
2416
2767
  const [sessionIndexMatches, historyMatches] = await Promise.all([
2417
2768
  inspectJsonlMatches(
@@ -2493,6 +2844,7 @@ export function formatSessionForJson(sessionRecord) {
2493
2844
  rolloutMissing: sessionRecord.rolloutMissing,
2494
2845
  rolloutPath: sessionRecord.rolloutPath,
2495
2846
  titleSource: sessionRecord.titleSource,
2847
+ transcriptBytes: sessionRecord.transcriptBytes,
2496
2848
  updatedAtMs: sessionRecord.updatedAtMs,
2497
2849
  };
2498
2850
  }