spiracha 1.4.0 → 1.5.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.
Files changed (159) hide show
  1. package/AGENTS.md +48 -5
  2. package/README.md +40 -5
  3. package/apps/ui/AGENTS.md +23 -4
  4. package/apps/ui/README.md +49 -2
  5. package/apps/ui/dist/client/assets/analytics-DOy1b6Sh.js +1 -0
  6. package/apps/ui/dist/client/assets/{antigravity-conversations._conversationId-DmD0sN8X.js → antigravity-conversations._conversationId-DqySVO2K.js} +2 -2
  7. package/apps/ui/dist/client/assets/antigravity-keychain-panel-B77qJz6f.js +1 -0
  8. package/apps/ui/dist/client/assets/antigravity._workspaceKey-B3NM6gRH.js +1 -0
  9. package/apps/ui/dist/client/assets/{antigravity.index-DEEkjlZa.js → antigravity.index-DP9JC2P9.js} +1 -1
  10. package/apps/ui/dist/client/assets/{badge-DfWvPfKF.js → badge-C3i4OKs8.js} +1 -1
  11. package/apps/ui/dist/client/assets/claude-code-sessions._sessionId-8pQ55ci2.js +1 -0
  12. package/apps/ui/dist/client/assets/claude-code-sessions._sessionId-DVz5UmtU.js +1 -0
  13. package/apps/ui/dist/client/assets/claude-code._workspaceKey-BBKskSum.js +1 -0
  14. package/apps/ui/dist/client/assets/claude-code._workspaceKey-OkikMUFs.js +1 -0
  15. package/apps/ui/dist/client/assets/claude-code.index-Cwu76dbb.js +1 -0
  16. package/apps/ui/dist/client/assets/claude-code.index-D6eMd08t.js +1 -0
  17. package/apps/ui/dist/client/assets/createServerFn-B-KkwmHU.js +3 -0
  18. package/apps/ui/dist/client/assets/cursor-threads._composerId-hBWlP1D-.js +1 -0
  19. package/apps/ui/dist/client/assets/cursor._workspaceKey-BFPkIRi-.js +1 -0
  20. package/apps/ui/dist/client/assets/cursor.index-8y4o20kb.js +2 -0
  21. package/apps/ui/dist/client/assets/delete-confirm-dialog-Bxs07u-j.js +7 -0
  22. package/apps/ui/dist/client/assets/dist-BrJhN2A0.js +5 -0
  23. package/apps/ui/dist/client/assets/{dist-BhZOxAPP.js → dist-Cmd3AIfD.js} +1 -1
  24. package/apps/ui/dist/client/assets/dropdown-menu-D5cvPaZt.js +1 -0
  25. package/apps/ui/dist/client/assets/{es2015-BkCWttyM.js → es2015-ad6040dw.js} +1 -1
  26. package/apps/ui/dist/client/assets/export-dialog-Dr8d2MqD.js +1 -0
  27. package/apps/ui/dist/client/assets/{formatters-BM9kB7ed.js → formatters-BtqyrX_c.js} +1 -1
  28. package/apps/ui/dist/client/assets/index-C-STgIH4.js +166 -0
  29. package/apps/ui/dist/client/assets/kiro-sessions._sessionId-CKgfJ7GR.js +1 -0
  30. package/apps/ui/dist/client/assets/kiro-sessions._sessionId-zvIJHtQF.js +1 -0
  31. package/apps/ui/dist/client/assets/kiro._workspaceKey-DhVuJuZB.js +1 -0
  32. package/apps/ui/dist/client/assets/kiro._workspaceKey-QArsjkTY.js +1 -0
  33. package/apps/ui/dist/client/assets/kiro.index-C-fr4m7i.js +1 -0
  34. package/apps/ui/dist/client/assets/kiro.index-OYgAdtZd.js +1 -0
  35. package/apps/ui/dist/client/assets/opencode-sessions._sessionId-DLjiTst-.js +1 -0
  36. package/apps/ui/dist/client/assets/opencode-sessions._sessionId-biBwrRbt.js +8 -0
  37. package/apps/ui/dist/client/assets/opencode._workspaceKey-C7OIzMOJ.js +1 -0
  38. package/apps/ui/dist/client/assets/opencode._workspaceKey-sBakX0oI.js +1 -0
  39. package/apps/ui/dist/client/assets/opencode.index-Bl_kNs9N.js +1 -0
  40. package/apps/ui/dist/client/assets/opencode.index-CN53bVmb.js +1 -0
  41. package/apps/ui/dist/client/assets/preload-helper-B8fFrdxC.js +1 -0
  42. package/apps/ui/dist/client/assets/projects._project-D8WloxQN.js +1 -0
  43. package/apps/ui/dist/client/assets/projects.index-5xRtuJdX.js +3 -0
  44. package/apps/ui/dist/client/assets/routes-DnzF_9CX.js +1 -0
  45. package/apps/ui/dist/client/assets/select-CYF6pF37.js +1 -0
  46. package/apps/ui/dist/client/assets/settings-Dx70jgP_.js +1 -0
  47. package/apps/ui/dist/client/assets/styles-B7Zi9EFA.css +1 -0
  48. package/apps/ui/dist/client/assets/tabs-CmuhkT-c.js +7 -0
  49. package/apps/ui/dist/client/assets/thread-transcript-stats-BUNz6u7F.js +1 -0
  50. package/apps/ui/dist/client/assets/threads._threadId-BMRSFow3.js +1 -0
  51. package/apps/ui/dist/client/assets/useMutation-BSFult2i.js +1 -0
  52. package/apps/ui/dist/client/assets/useQuery-CjINKZf1.js +1 -0
  53. package/apps/ui/dist/client/icon.svg +2 -1
  54. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CQ8Jjs3B.js +387 -0
  55. package/apps/ui/dist/server/assets/{antigravity-conversations._conversationId-BtexWY-K.js → antigravity-conversations._conversationId-BKOyL2T6.js} +2 -2
  56. package/apps/ui/dist/server/assets/{antigravity-conversations._conversationId-zMrR6v6R.js → antigravity-conversations._conversationId-eS87Dx7g.js} +1 -1
  57. package/apps/ui/dist/server/assets/claude-code-db-Ik3VStKZ.js +361 -0
  58. package/apps/ui/dist/server/assets/claude-code-queries-Cgzi4K-e.js +37 -0
  59. package/apps/ui/dist/server/assets/claude-code-server-PfraClXS.js +72 -0
  60. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-C55oe6zj.js +18 -0
  61. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-D1RYYi3j.js +473 -0
  62. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-DSvPLEUL.js +11 -0
  63. package/apps/ui/dist/server/assets/claude-code-transcript-C-bxh3cS.js +139 -0
  64. package/apps/ui/dist/server/assets/claude-code-transcript-phase-DmUMc8VU.js +10 -0
  65. package/apps/ui/dist/server/assets/claude-code._workspaceKey-B5Xg_-tE.js +28 -0
  66. package/apps/ui/dist/server/assets/claude-code._workspaceKey-BPlzyXPa.js +11 -0
  67. package/apps/ui/dist/server/assets/claude-code._workspaceKey-DqDve2wl.js +191 -0
  68. package/apps/ui/dist/server/assets/claude-code.index-B7yYR7dD.js +92 -0
  69. package/apps/ui/dist/server/assets/claude-code.index-BUHThG1W.js +11 -0
  70. package/apps/ui/dist/server/assets/{codex-server-CNXSJuc2.js → codex-server-CGXU4H8-.js} +319 -29
  71. package/apps/ui/dist/server/assets/{cursor-db-cYZEU3WQ.js → cursor-db-CZnYy7aT.js} +3 -3
  72. package/apps/ui/dist/server/assets/{cursor-recovery-nq-kR62j.js → cursor-recovery-CXpO8KcD.js} +2 -2
  73. package/apps/ui/dist/server/assets/{cursor-server-C3q7hrp-.js → cursor-server-DNu52A3K.js} +13 -13
  74. package/apps/ui/dist/server/assets/{cursor-threads._composerId-BkMMTQ3v.js → cursor-threads._composerId-Ckn6ItHI.js} +3 -3
  75. package/apps/ui/dist/server/assets/{cursor-threads._composerId-BPJFWfJj.js → cursor-threads._composerId-p9-ARAzc.js} +1 -1
  76. package/apps/ui/dist/server/assets/{cursor-transcript-2iL3KFSK.js → cursor-transcript-B7tGnqtn.js} +1 -1
  77. package/apps/ui/dist/server/assets/{cursor._workspaceKey-C5PdTaKB.js → cursor._workspaceKey-CZuohIUL.js} +1 -1
  78. package/apps/ui/dist/server/assets/{cursor._workspaceKey-Ckj7apKI.js → cursor._workspaceKey-DPp5JSjy.js} +2 -2
  79. package/apps/ui/dist/server/assets/{export-dialog-BItjWgkZ.js → export-dialog-D88ze9Gy.js} +2 -2
  80. package/apps/ui/dist/server/assets/kiro-db--fXrED_-.js +552 -0
  81. package/apps/ui/dist/server/assets/kiro-queries-CzNlM2ok.js +37 -0
  82. package/apps/ui/dist/server/assets/kiro-server-9WRxg2Au.js +72 -0
  83. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-CRY2c7YO.js +11 -0
  84. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-F1UlwxmR.js +449 -0
  85. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-bvTulT7C.js +18 -0
  86. package/apps/ui/dist/server/assets/kiro-transcript-DoekQBaH.js +112 -0
  87. package/apps/ui/dist/server/assets/kiro-transcript-phase-CJG40nQg.js +27 -0
  88. package/apps/ui/dist/server/assets/kiro._workspaceKey-BDe9sQwo.js +11 -0
  89. package/apps/ui/dist/server/assets/kiro._workspaceKey-CJsjgNwO.js +28 -0
  90. package/apps/ui/dist/server/assets/kiro._workspaceKey-D_-4Qb7y.js +193 -0
  91. package/apps/ui/dist/server/assets/kiro.index-C__-UROk.js +11 -0
  92. package/apps/ui/dist/server/assets/kiro.index-CmTbqeYj.js +93 -0
  93. package/apps/ui/dist/server/assets/opencode-db-BM7KjOzc.js +397 -0
  94. package/apps/ui/dist/server/assets/opencode-queries-BeNYXnQv.js +50 -0
  95. package/apps/ui/dist/server/assets/opencode-server-BpQ_kRze.js +72 -0
  96. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-CGQ-t0vD.js +518 -0
  97. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-D8Q7in_Z.js +18 -0
  98. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-DKOvi0lV.js +11 -0
  99. package/apps/ui/dist/server/assets/opencode-think-tags-CAoD-EcZ.js +87 -0
  100. package/apps/ui/dist/server/assets/opencode-transcript-BNTcGNym.js +141 -0
  101. package/apps/ui/dist/server/assets/opencode-transcript-phase-OqLkiQD0.js +32 -0
  102. package/apps/ui/dist/server/assets/opencode._workspaceKey-BdqcWHw1.js +11 -0
  103. package/apps/ui/dist/server/assets/opencode._workspaceKey-CfH96HTT.js +28 -0
  104. package/apps/ui/dist/server/assets/opencode._workspaceKey-DdbHPDrO.js +207 -0
  105. package/apps/ui/dist/server/assets/opencode.index-BbVzkJXP.js +11 -0
  106. package/apps/ui/dist/server/assets/opencode.index-Vh_Gq_1L.js +93 -0
  107. package/apps/ui/dist/server/assets/{projects._project-Bpbc3C-L.js → projects._project-4io5LO0E.js} +1 -1
  108. package/apps/ui/dist/server/assets/{projects._project-CvZho6EQ.js → projects._project-D6ZoGhJ8.js} +2 -2
  109. package/apps/ui/dist/server/assets/{router-6m-ihwqA.js → router-Sac2DGk6.js} +157 -49
  110. package/apps/ui/dist/server/assets/{shared-CPRNYIql.js → shared-DyhChtHf.js} +4 -1
  111. package/apps/ui/dist/server/assets/source-session-export-server-DNutCawb.js +38 -0
  112. package/apps/ui/dist/server/assets/{start-CI_0bSiY.js → start-B_HFwkkk.js} +1 -1
  113. package/apps/ui/dist/server/assets/{tabs-CGA13IZM.js → tabs-DkaChNVg.js} +0 -1
  114. package/apps/ui/dist/server/assets/thread-transcript-stats-Dh34mt2u.js +45 -0
  115. package/apps/ui/dist/server/assets/{threads._threadId-D2GYkVn6.js → threads._threadId-BeXEc4Lo.js} +15 -6
  116. package/apps/ui/dist/server/assets/{threads._threadId-Bs0yuqO9.js → threads._threadId-Ci0S4mKU.js} +1 -1
  117. package/apps/ui/dist/server/server.js +72 -24
  118. package/package.json +21 -4
  119. package/src/lib/claude-code-db.ts +655 -0
  120. package/src/lib/claude-code-exporter-types.ts +110 -0
  121. package/src/lib/claude-code-transcript-phase.ts +25 -0
  122. package/src/lib/claude-code-transcript.ts +180 -0
  123. package/src/lib/codex-browser-db.ts +490 -31
  124. package/src/lib/codex-browser-export.ts +18 -2
  125. package/src/lib/codex-thread-cache.ts +17 -1
  126. package/src/lib/concurrency.ts +25 -0
  127. package/src/lib/kiro-db.ts +949 -0
  128. package/src/lib/kiro-exporter-types.ts +100 -0
  129. package/src/lib/kiro-transcript-phase.ts +41 -0
  130. package/src/lib/kiro-transcript.ts +115 -0
  131. package/src/lib/opencode-db.ts +610 -0
  132. package/src/lib/opencode-exporter-types.ts +129 -0
  133. package/src/lib/opencode-think-tags.ts +126 -0
  134. package/src/lib/opencode-transcript-phase.ts +50 -0
  135. package/src/lib/opencode-transcript.ts +194 -0
  136. package/src/lib/shared.ts +25 -0
  137. package/src/lib/ui-export-archive.ts +61 -0
  138. package/apps/ui/dist/client/assets/analytics-DK2jdvmI.js +0 -1
  139. package/apps/ui/dist/client/assets/antigravity-keychain-panel-BRHVjFAM.js +0 -1
  140. package/apps/ui/dist/client/assets/antigravity._workspaceKey-CGV7amas.js +0 -1
  141. package/apps/ui/dist/client/assets/createServerFn-CgRRVpBH.js +0 -3
  142. package/apps/ui/dist/client/assets/cursor-threads._composerId-Ti2gTm63.js +0 -1
  143. package/apps/ui/dist/client/assets/cursor._workspaceKey-DzD_c6j9.js +0 -1
  144. package/apps/ui/dist/client/assets/cursor.index-PkCFVcl4.js +0 -2
  145. package/apps/ui/dist/client/assets/delete-confirm-dialog-BHRmyLHr.js +0 -11
  146. package/apps/ui/dist/client/assets/dropdown-menu-Dy5cLzTy.js +0 -1
  147. package/apps/ui/dist/client/assets/export-dialog-C86SYHJn.js +0 -1
  148. package/apps/ui/dist/client/assets/index-CPvAP-jk.js +0 -149
  149. package/apps/ui/dist/client/assets/projects._project-Ckf6muWZ.js +0 -1
  150. package/apps/ui/dist/client/assets/projects.index-V5ibNZII.js +0 -3
  151. package/apps/ui/dist/client/assets/routes-Df2tgpyd.js +0 -1
  152. package/apps/ui/dist/client/assets/select-Bf2GwfPR.js +0 -1
  153. package/apps/ui/dist/client/assets/settings-5VQIzME0.js +0 -1
  154. package/apps/ui/dist/client/assets/styles-BhRkXgwB.css +0 -1
  155. package/apps/ui/dist/client/assets/tabs-OO4VU5KR.js +0 -7
  156. package/apps/ui/dist/client/assets/threads._threadId-BgDhFj2I.js +0 -1
  157. package/apps/ui/dist/client/assets/useMutation-CC_B7uy5.js +0 -1
  158. package/apps/ui/dist/client/assets/useQuery-C5By6WKU.js +0 -1
  159. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CRW2kqj0.js +0 -232
@@ -1,7 +1,10 @@
1
- import { Database } from 'bun:sqlite';
1
+ import { constants, Database } from 'bun:sqlite';
2
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
2
3
  import { rm } from 'node:fs/promises';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { pathToFileURL } from 'node:url';
5
8
  import type {
6
9
  DashboardSummary,
7
10
  DeleteProjectResult,
@@ -30,6 +33,44 @@ type DeleteProjectOptions = {
30
33
  const SQLITE_DELETE_BATCH_SIZE = 400;
31
34
  const SESSION_FILE_DELETE_CONCURRENCY = 16;
32
35
  const THREAD_LIST_IO_CONCURRENCY = 8;
36
+ const CODEX_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
37
+ const JSONL_READ_CHUNK_BYTES = 64 * 1024;
38
+ const SESSION_META_READ_LIMIT_BYTES = 4 * 1024 * 1024;
39
+ const THREAD_ID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/iu;
40
+
41
+ type SessionIndexEntry = {
42
+ id: string;
43
+ thread_name?: string;
44
+ updated_at?: string;
45
+ };
46
+
47
+ type FallbackSessionMeta = {
48
+ agent_nickname?: string;
49
+ agent_path?: string;
50
+ agent_role?: string;
51
+ cli_version?: string;
52
+ cwd?: string;
53
+ forked_from_id?: string;
54
+ id?: string;
55
+ model_provider?: string;
56
+ parent_thread_id?: string;
57
+ source?: unknown;
58
+ thread_source?: string;
59
+ timestamp?: string;
60
+ };
61
+
62
+ type ReadFallbackThreadRowsOptions = {
63
+ includeSubagents?: boolean;
64
+ };
65
+
66
+ type FallbackRolloutStats = {
67
+ model: string | null;
68
+ tokensUsed: number;
69
+ };
70
+
71
+ const isSqliteCantOpenError = (error: unknown) => {
72
+ return (error as { code?: unknown }).code === 'SQLITE_CANTOPEN';
73
+ };
33
74
 
34
75
  const chunkValues = <T>(values: T[], chunkSize: number) => {
35
76
  const chunks: T[][] = [];
@@ -49,15 +90,20 @@ const isPromiseLike = (value: unknown): value is PromiseLike<unknown> => {
49
90
  return 'then' in value && typeof value.then === 'function';
50
91
  };
51
92
 
52
- const openReadonlyDb = (dbPath: string, busyTimeoutMs: number) => {
93
+ const openReadonlyDb = (dbPath: string) => {
53
94
  const db = new Database(dbPath, { readonly: true });
54
95
  try {
55
- db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
96
+ db.query('SELECT name FROM sqlite_master LIMIT 1').get();
56
97
  return db;
57
98
  } catch (error) {
58
99
  db.close();
59
- throw error;
100
+ if (!isSqliteCantOpenError(error)) {
101
+ throw error;
102
+ }
60
103
  }
104
+
105
+ // Codex uses WAL mode; immutable URI reads keep Bun usable after clean shutdown removes sidecar files.
106
+ return new Database(`${pathToFileURL(dbPath).href}?immutable=1`, CODEX_READONLY_DB_OPEN_FLAGS);
61
107
  };
62
108
 
63
109
  const openWritableDb = (dbPath: string, busyTimeoutMs: number) => {
@@ -102,7 +148,7 @@ const parseJsonSafely = (value: string | null) => {
102
148
  export const withReadonlyDb = <T>(dbPath: string, callback: (db: Database) => T): T => {
103
149
  return runWithSqliteRetry({
104
150
  action: () => {
105
- const db = openReadonlyDb(dbPath, 5000);
151
+ const db = openReadonlyDb(dbPath);
106
152
  try {
107
153
  const result = callback(db);
108
154
  if (isPromiseLike(result)) {
@@ -149,12 +195,10 @@ export const resolveCodexThreadDbPath = () => {
149
195
 
150
196
  for (const candidate of candidates) {
151
197
  try {
152
- const db = runWithSqliteRetry({
153
- action: () => {
154
- return openReadonlyDb(candidate, 1500);
155
- },
156
- });
157
- db.close();
198
+ // Avoid opening candidates as a probe: Bun can make later read-only opens fail on Codex WAL databases.
199
+ if (!statSync(candidate).isFile()) {
200
+ continue;
201
+ }
158
202
  return candidate;
159
203
  } catch {}
160
204
  }
@@ -170,6 +214,399 @@ const readAllThreads = (dbPath: string): ThreadRow[] => {
170
214
  });
171
215
  };
172
216
 
217
+ const resolveCodexDirFromDbPath = (dbPath: string) => {
218
+ const dbDir = path.dirname(dbPath);
219
+ return path.basename(dbDir) === 'sqlite' ? path.dirname(dbDir) : dbDir;
220
+ };
221
+
222
+ const parseJsonlObject = <T>(line: string): T | null => {
223
+ try {
224
+ return JSON.parse(line) as T;
225
+ } catch {
226
+ return null;
227
+ }
228
+ };
229
+
230
+ const emitJsonlLine = <T>(line: string, onRecord: (record: T) => void) => {
231
+ const trimmed = line.trim();
232
+ const parsed = trimmed ? parseJsonlObject<T>(trimmed) : null;
233
+ if (parsed) {
234
+ onRecord(parsed);
235
+ }
236
+ };
237
+
238
+ const emitCompleteJsonlLines = <T>(text: string, onRecord: (record: T) => void): string => {
239
+ const lines = text.split(/\r?\n/u);
240
+ const pending = lines.pop() ?? '';
241
+ for (const line of lines) {
242
+ emitJsonlLine(line, onRecord);
243
+ }
244
+ return pending;
245
+ };
246
+
247
+ const readJsonlObjects = <T>(filePath: string, onRecord: (record: T) => void) => {
248
+ let descriptor: number | null = null;
249
+ try {
250
+ const stats = statSync(filePath);
251
+ if (!stats.isFile()) {
252
+ return;
253
+ }
254
+
255
+ descriptor = openSync(filePath, 'r');
256
+ const buffer = Buffer.alloc(JSONL_READ_CHUNK_BYTES);
257
+ const decoder = new StringDecoder('utf8');
258
+ let position = 0;
259
+ let pending = '';
260
+
261
+ while (true) {
262
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, position);
263
+ if (bytesRead === 0) {
264
+ break;
265
+ }
266
+
267
+ position += bytesRead;
268
+ pending += decoder.write(buffer.subarray(0, bytesRead));
269
+ pending = emitCompleteJsonlLines(pending, onRecord);
270
+ }
271
+
272
+ emitJsonlLine(pending + decoder.end(), onRecord);
273
+ } catch {
274
+ return;
275
+ } finally {
276
+ if (descriptor !== null) {
277
+ closeSync(descriptor);
278
+ }
279
+ }
280
+ };
281
+
282
+ const collectJsonlObjects = <T>(filePath: string): T[] => {
283
+ const records: T[] = [];
284
+ readJsonlObjects<T>(filePath, (record) => {
285
+ records.push(record);
286
+ });
287
+ return records;
288
+ };
289
+
290
+ const readSessionIndexEntries = (codexDir: string): SessionIndexEntry[] => {
291
+ return collectJsonlObjects<SessionIndexEntry>(path.join(codexDir, 'session_index.jsonl')).filter(
292
+ (entry) => typeof entry.id === 'string' && entry.id.length > 0,
293
+ );
294
+ };
295
+
296
+ const collectSessionFilesByThreadId = (sessionsDir: string): Map<string, string> => {
297
+ const sessionFiles = new Map<string, string>();
298
+ const visit = (directory: string) => {
299
+ const entries = (() => {
300
+ try {
301
+ return readdirSync(directory, { withFileTypes: true });
302
+ } catch {
303
+ return null;
304
+ }
305
+ })();
306
+ if (!entries) {
307
+ return;
308
+ }
309
+
310
+ for (const entry of entries) {
311
+ const entryPath = path.join(directory, entry.name);
312
+ if (entry.isDirectory()) {
313
+ visit(entryPath);
314
+ continue;
315
+ }
316
+
317
+ if (!entry.isFile()) {
318
+ continue;
319
+ }
320
+
321
+ const threadId = THREAD_ID_PATTERN.exec(entry.name)?.[1];
322
+ if (threadId && !sessionFiles.has(threadId)) {
323
+ sessionFiles.set(threadId, entryPath);
324
+ }
325
+ }
326
+ };
327
+
328
+ visit(sessionsDir);
329
+ return sessionFiles;
330
+ };
331
+
332
+ const readSessionMetaLine = (sessionFile: string): string | null => {
333
+ let descriptor: number | null = null;
334
+ try {
335
+ descriptor = openSync(sessionFile, 'r');
336
+ const chunks: Buffer[] = [];
337
+ let totalBytes = 0;
338
+
339
+ while (totalBytes < SESSION_META_READ_LIMIT_BYTES) {
340
+ const buffer = Buffer.alloc(Math.min(64 * 1024, SESSION_META_READ_LIMIT_BYTES - totalBytes));
341
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, totalBytes);
342
+ if (bytesRead === 0) {
343
+ break;
344
+ }
345
+
346
+ chunks.push(buffer.subarray(0, bytesRead));
347
+ totalBytes += bytesRead;
348
+ if (buffer.subarray(0, bytesRead).includes(10)) {
349
+ break;
350
+ }
351
+ }
352
+
353
+ const firstLine = Buffer.concat(chunks).toString('utf8').split(/\r?\n/u)[0]?.trim();
354
+ return firstLine || null;
355
+ } catch {
356
+ return null;
357
+ } finally {
358
+ if (descriptor !== null) {
359
+ closeSync(descriptor);
360
+ }
361
+ }
362
+ };
363
+
364
+ const readFallbackSessionMeta = (sessionFile: string): FallbackSessionMeta | null => {
365
+ const line = readSessionMetaLine(sessionFile);
366
+ if (!line) {
367
+ return null;
368
+ }
369
+
370
+ try {
371
+ const record = JSON.parse(line) as { payload?: FallbackSessionMeta; type?: string };
372
+ return record.type === 'session_meta' && record.payload ? record.payload : null;
373
+ } catch {
374
+ return null;
375
+ }
376
+ };
377
+
378
+ const parseIsoMs = (value: string | undefined, fallback: number) => {
379
+ if (!value) {
380
+ return fallback;
381
+ }
382
+
383
+ const parsed = Date.parse(value);
384
+ return Number.isFinite(parsed) ? parsed : fallback;
385
+ };
386
+
387
+ const stringOrNull = (value: unknown) => (typeof value === 'string' && value.length > 0 ? value : null);
388
+
389
+ const numberOrNull = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
390
+
391
+ const objectOrNull = (value: unknown) => {
392
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
393
+ ? (value as Record<string, unknown>)
394
+ : null;
395
+ };
396
+
397
+ const isFallbackSubagent = (sessionMeta: FallbackSessionMeta) => {
398
+ return Boolean(
399
+ sessionMeta.thread_source === 'subagent' ||
400
+ stringOrNull(sessionMeta.parent_thread_id) ||
401
+ stringOrNull(sessionMeta.forked_from_id),
402
+ );
403
+ };
404
+
405
+ const readFallbackRolloutStats = (sessionFile: string): FallbackRolloutStats => {
406
+ let model: string | null = null;
407
+ let tokensUsed = 0;
408
+
409
+ readJsonlObjects<Record<string, unknown>>(sessionFile, (record) => {
410
+ const payload = objectOrNull(record.payload);
411
+ if (!payload) {
412
+ return;
413
+ }
414
+
415
+ if (record.type === 'turn_context') {
416
+ model = stringOrNull(payload.model) ?? model;
417
+ return;
418
+ }
419
+
420
+ const payloadType = stringOrNull(payload.type);
421
+ if (payloadType === 'message' || payloadType === 'agent_message') {
422
+ model = stringOrNull(payload.model) ?? model;
423
+ return;
424
+ }
425
+
426
+ if (payloadType !== 'token_count') {
427
+ return;
428
+ }
429
+
430
+ const info = objectOrNull(payload.info);
431
+ const totalTokenUsage = objectOrNull(info?.total_token_usage);
432
+ tokensUsed = numberOrNull(totalTokenUsage?.total_tokens) ?? tokensUsed;
433
+ });
434
+
435
+ return {
436
+ model,
437
+ tokensUsed,
438
+ };
439
+ };
440
+
441
+ const buildFallbackThreadRow = (
442
+ entry: SessionIndexEntry,
443
+ sessionFile: string,
444
+ sessionMeta: FallbackSessionMeta,
445
+ rolloutStats: FallbackRolloutStats,
446
+ ): ThreadRow | null => {
447
+ const cwd = stringOrNull(sessionMeta.cwd);
448
+ if (!cwd) {
449
+ return null;
450
+ }
451
+
452
+ let mtimeMs = Date.now();
453
+ try {
454
+ mtimeMs = statSync(sessionFile).mtimeMs;
455
+ } catch {}
456
+
457
+ const updatedAtMs = parseIsoMs(entry.updated_at, mtimeMs);
458
+ const createdAtMs = parseIsoMs(sessionMeta.timestamp, updatedAtMs);
459
+ const title = entry.thread_name?.trim() || path.basename(sessionFile, '.jsonl');
460
+ const source = stringOrNull(sessionMeta.source) ?? 'session_file';
461
+
462
+ return {
463
+ agent_nickname: sessionMeta.agent_nickname ?? null,
464
+ agent_path: sessionMeta.agent_path ?? null,
465
+ agent_role: sessionMeta.agent_role ?? null,
466
+ approval_mode: 'unknown',
467
+ archived: 0,
468
+ archived_at: null,
469
+ cli_version: sessionMeta.cli_version ?? '',
470
+ created_at: Math.floor(createdAtMs / 1000),
471
+ created_at_ms: Math.floor(createdAtMs),
472
+ cwd,
473
+ first_user_message: title,
474
+ git_branch: null,
475
+ git_origin_url: null,
476
+ git_sha: null,
477
+ has_user_event: sessionMeta.thread_source === 'user' ? 1 : 0,
478
+ id: entry.id,
479
+ memory_mode: 'enabled',
480
+ model: rolloutStats.model,
481
+ model_provider: sessionMeta.model_provider ?? 'unknown',
482
+ preview: title,
483
+ reasoning_effort: null,
484
+ rollout_path: sessionFile,
485
+ sandbox_policy: '{}',
486
+ source,
487
+ thread_source: sessionMeta.thread_source ?? null,
488
+ title,
489
+ tokens_used: rolloutStats.tokensUsed,
490
+ updated_at: Math.floor(updatedAtMs / 1000),
491
+ updated_at_ms: Math.floor(updatedAtMs),
492
+ };
493
+ };
494
+
495
+ const readFallbackThreadRows = (
496
+ dbPath: string,
497
+ existingThreadIds: Set<string>,
498
+ projectName: string | null = null,
499
+ options: ReadFallbackThreadRowsOptions = {},
500
+ ): ThreadRow[] => {
501
+ const codexDir = resolveCodexDirFromDbPath(dbPath);
502
+ const sessionFilesByThreadId = collectSessionFilesByThreadId(path.join(codexDir, 'sessions'));
503
+ const fallbackThreads: ThreadRow[] = [];
504
+
505
+ for (const entry of readSessionIndexEntries(codexDir)) {
506
+ if (existingThreadIds.has(entry.id)) {
507
+ continue;
508
+ }
509
+
510
+ const sessionFile = sessionFilesByThreadId.get(entry.id);
511
+ if (!sessionFile) {
512
+ continue;
513
+ }
514
+
515
+ const sessionMeta = readFallbackSessionMeta(sessionFile);
516
+ if (!sessionMeta) {
517
+ continue;
518
+ }
519
+
520
+ if (!options.includeSubagents && isFallbackSubagent(sessionMeta)) {
521
+ continue;
522
+ }
523
+
524
+ const fallbackThread = buildFallbackThreadRow(
525
+ entry,
526
+ sessionFile,
527
+ sessionMeta,
528
+ readFallbackRolloutStats(sessionFile),
529
+ );
530
+ if (!fallbackThread || (projectName && getPortablePathBasename(fallbackThread.cwd) !== projectName)) {
531
+ continue;
532
+ }
533
+
534
+ fallbackThreads.push(fallbackThread);
535
+ }
536
+
537
+ return fallbackThreads;
538
+ };
539
+
540
+ const mergeFallbackThreadRows = (dbPath: string, threads: ThreadRow[], projectName: string | null = null) => {
541
+ const threadIds = new Set(threads.map((thread) => thread.id));
542
+ return [...threads, ...readFallbackThreadRows(dbPath, threadIds, projectName)].sort((left, right) => {
543
+ const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
544
+ if (updatedDifference !== 0) {
545
+ return updatedDifference;
546
+ }
547
+
548
+ return right.id.localeCompare(left.id);
549
+ });
550
+ };
551
+
552
+ const applyRolloutActivityTimestamps = (threads: ThreadRow[]) => {
553
+ return threads
554
+ .map((thread) => {
555
+ let rolloutUpdatedAtMs = toTimestampMs(thread);
556
+ try {
557
+ rolloutUpdatedAtMs = Math.max(rolloutUpdatedAtMs, statSync(thread.rollout_path).mtimeMs);
558
+ } catch {}
559
+
560
+ if (rolloutUpdatedAtMs <= toTimestampMs(thread)) {
561
+ return thread;
562
+ }
563
+
564
+ return {
565
+ ...thread,
566
+ updated_at: Math.floor(rolloutUpdatedAtMs / 1000),
567
+ updated_at_ms: Math.floor(rolloutUpdatedAtMs),
568
+ };
569
+ })
570
+ .sort((left, right) => {
571
+ const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
572
+ if (updatedDifference !== 0) {
573
+ return updatedDifference;
574
+ }
575
+
576
+ return right.id.localeCompare(left.id);
577
+ });
578
+ };
579
+
580
+ const buildDashboardRecentThreads = (threads: ThreadRow[]) => {
581
+ const bestThreadByProject = new Map<string, ThreadRow>();
582
+ for (const thread of threads) {
583
+ const project = getPortablePathBasename(thread.cwd);
584
+ if (!project) {
585
+ continue;
586
+ }
587
+
588
+ const current = bestThreadByProject.get(project);
589
+ if (!current || toTimestampMs(thread) > toTimestampMs(current)) {
590
+ bestThreadByProject.set(project, thread);
591
+ }
592
+ }
593
+
594
+ return [...bestThreadByProject.values()]
595
+ .sort((left, right) => {
596
+ const updatedDifference = toTimestampMs(right) - toTimestampMs(left);
597
+ if (updatedDifference !== 0) {
598
+ return updatedDifference;
599
+ }
600
+
601
+ return right.id.localeCompare(left.id);
602
+ })
603
+ .slice(0, 5)
604
+ .map((thread) => ({
605
+ project: getPortablePathBasename(thread.cwd),
606
+ thread: compactThreadListRow(thread),
607
+ }));
608
+ };
609
+
173
610
  const filterThreadsByProject = (threads: ThreadRow[], projectName: string | null) => {
174
611
  if (!projectName) {
175
612
  return threads;
@@ -367,7 +804,9 @@ const deleteThreadSessionFiles = async (sessionFiles: string[]) => {
367
804
  };
368
805
 
369
806
  export const listCodexProjects = (dbPath: string): ProjectSummary[] => {
370
- return mapProjectSummaries(buildProjectSummaryMap(readAllThreads(dbPath)));
807
+ return mapProjectSummaries(
808
+ buildProjectSummaryMap(applyRolloutActivityTimestamps(mergeFallbackThreadRows(dbPath, readAllThreads(dbPath)))),
809
+ );
371
810
  };
372
811
 
373
812
  type ListProjectThreadsOptions = {
@@ -387,10 +826,29 @@ export const listProjectThreads = async (
387
826
  projectName: string,
388
827
  options: ListProjectThreadsOptions = {},
389
828
  ): Promise<ThreadListEntry[]> => {
390
- const threads = filterThreadsByProject(readAllThreads(dbPath), projectName);
391
- const entries = await mapWithConcurrency(threads, THREAD_LIST_IO_CONCURRENCY, async (thread) => {
829
+ const threads = mergeFallbackThreadRows(
830
+ dbPath,
831
+ filterThreadsByProject(readAllThreads(dbPath), projectName),
832
+ projectName,
833
+ );
834
+ const activeThreads = applyRolloutActivityTimestamps(threads);
835
+ const entries = await mapWithConcurrency(activeThreads, THREAD_LIST_IO_CONCURRENCY, async (thread) => {
392
836
  const rollout = await getThreadRolloutLoadState(thread.rollout_path, options.largeTranscriptThresholdBytes);
393
837
 
838
+ if (rollout.fileSizeBytes === null) {
839
+ return {
840
+ project: projectName,
841
+ rolloutSizeBytes: null,
842
+ stats: {
843
+ deferred: false,
844
+ execCommandCount: 0,
845
+ toolCallCount: 0,
846
+ webSearchEventCount: 0,
847
+ },
848
+ thread: compactThreadListRow(thread),
849
+ };
850
+ }
851
+
394
852
  if (rollout.shouldDeferTranscriptLoad) {
395
853
  return {
396
854
  project: projectName,
@@ -426,18 +884,25 @@ export const listProjectThreads = async (
426
884
  export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBrowseData => {
427
885
  return withReadonlyDb(dbPath, (db) => {
428
886
  const existingTableNames = getExistingTableNames(db);
429
- const thread = db.query('SELECT * FROM threads WHERE id = ? LIMIT 1').get(threadId) as ThreadRow | null;
887
+ const dbThread = db.query('SELECT * FROM threads WHERE id = ? LIMIT 1').get(threadId) as ThreadRow | null;
888
+ const thread =
889
+ dbThread ??
890
+ readFallbackThreadRows(dbPath, new Set(), null, { includeSubagents: true }).find(
891
+ (fallbackThread) => fallbackThread.id === threadId,
892
+ ) ??
893
+ null;
430
894
  if (!thread) {
431
895
  throw new Error(`Thread not found: ${threadId}`);
432
896
  }
433
897
 
434
- const dynamicTools = existingTableNames.has('thread_dynamic_tools')
435
- ? (db
436
- .query(
437
- 'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
438
- )
439
- .all(threadId) as Array<Record<string, number | string | null>>)
440
- : [];
898
+ const dynamicTools =
899
+ dbThread && existingTableNames.has('thread_dynamic_tools')
900
+ ? (db
901
+ .query(
902
+ 'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
903
+ )
904
+ .all(threadId) as Array<Record<string, number | string | null>>)
905
+ : [];
441
906
 
442
907
  return {
443
908
  dynamicTools: dynamicTools.map((row) => parseDynamicToolRow(row)),
@@ -449,7 +914,7 @@ export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBro
449
914
  };
450
915
 
451
916
  export const getCodexDashboardSummary = (dbPath: string): DashboardSummary => {
452
- const threads = readAllThreads(dbPath);
917
+ const threads = applyRolloutActivityTimestamps(mergeFallbackThreadRows(dbPath, readAllThreads(dbPath)));
453
918
  const projects = mapProjectSummaries(buildProjectSummaryMap(threads));
454
919
  const threadsWithRelations = withReadonlyDb(dbPath, (db) => {
455
920
  if (!getExistingTableNames(db).has('thread_spawn_edges')) {
@@ -467,13 +932,7 @@ export const getCodexDashboardSummary = (dbPath: string): DashboardSummary => {
467
932
  return {
468
933
  activeThreads: threads.filter((thread) => !thread.archived).length,
469
934
  archivedThreads: threads.filter((thread) => Boolean(thread.archived)).length,
470
- recentThreads: threads
471
- .slice(0, 5)
472
- .filter((thread) => Boolean(getPortablePathBasename(thread.cwd)))
473
- .map((thread) => ({
474
- project: getPortablePathBasename(thread.cwd),
475
- thread: compactThreadListRow(thread),
476
- })),
935
+ recentThreads: buildDashboardRecentThreads(threads),
477
936
  threadsWithRelations,
478
937
  topProjectsByThreadCount: [...projects]
479
938
  .sort((left, right) => {
@@ -579,7 +1038,7 @@ export const deleteCodexProject = async (
579
1038
  };
580
1039
 
581
1040
  export const listScopedThreads = (dbPath: string, projectName: string | null): ThreadRow[] => {
582
- return filterThreadsByProject(readAllThreads(dbPath), projectName);
1041
+ return mergeFallbackThreadRows(dbPath, filterThreadsByProject(readAllThreads(dbPath), projectName), projectName);
583
1042
  };
584
1043
 
585
1044
  export const invalidateCodexUiCaches = async () => {
@@ -130,6 +130,22 @@ const getRolloutSnapshot = async (rolloutPath: string): Promise<RolloutSnapshot>
130
130
  };
131
131
  };
132
132
 
133
+ const isMissingFileError = (error: unknown) => {
134
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
135
+ };
136
+
137
+ const getExistingRolloutSnapshot = async (threadId: string, rolloutPath: string): Promise<RolloutSnapshot> => {
138
+ try {
139
+ return await getRolloutSnapshot(rolloutPath);
140
+ } catch (error) {
141
+ if (isMissingFileError(error)) {
142
+ throw new Error(`Thread ${threadId} rollout file is missing: ${rolloutPath}`, { cause: error });
143
+ }
144
+
145
+ throw error;
146
+ }
147
+ };
148
+
133
149
  const logExportEvent = (level: 'error' | 'info' | 'warn', event: string, details: Record<string, unknown>) => {
134
150
  console[level](`[spiracha:export] ${event}`, details);
135
151
  };
@@ -179,7 +195,7 @@ export const renderCodexThreadDownload = async (
179
195
  projectPath: browseData.thread.cwd,
180
196
  })
181
197
  : text;
182
- const rolloutSnapshotBefore = await getRolloutSnapshot(browseData.thread.rollout_path);
198
+ const rolloutSnapshotBefore = await getExistingRolloutSnapshot(input.threadId, browseData.thread.rollout_path);
183
199
 
184
200
  logExportEvent('info', 'single_start', {
185
201
  fileName,
@@ -307,7 +323,7 @@ export const renderCodexThreadsDownload = async (
307
323
 
308
324
  try {
309
325
  for (const entry of browseEntries) {
310
- const rolloutSnapshotBefore = await getRolloutSnapshot(entry.thread.rollout_path);
326
+ const rolloutSnapshotBefore = await getExistingRolloutSnapshot(entry.thread.id, entry.thread.rollout_path);
311
327
  const singleBaseName = buildExportBaseName(entry.thread);
312
328
  const uniqueBaseName = buildUniqueBatchEntryBaseName(
313
329
  singleBaseName,
@@ -7,6 +7,10 @@ import { getFileFingerprint, hashCacheKeyParts, withCachedJson } from './ui-cach
7
7
  export const LARGE_THREAD_SIZE_BYTES = 100 * 1024 * 1024;
8
8
  export const LARGE_THREAD_PREVIEW_EVENT_LIMIT = 200;
9
9
 
10
+ const isMissingFileError = (error: unknown) => {
11
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
12
+ };
13
+
10
14
  export const getCachedParsedCodexTranscript = async (sessionFile: string): Promise<ParsedCodexTranscript> => {
11
15
  const fingerprint = await getFileFingerprint(sessionFile);
12
16
  const key = `thread-${hashCacheKeyParts(path.basename(sessionFile), fingerprint)}`;
@@ -23,7 +27,19 @@ export const getThreadRolloutLoadState = async (
23
27
  sessionFile: string,
24
28
  largeTranscriptThresholdBytes = LARGE_THREAD_SIZE_BYTES,
25
29
  ) => {
26
- const metadata = await stat(sessionFile);
30
+ let metadata: Awaited<ReturnType<typeof stat>>;
31
+ try {
32
+ metadata = await stat(sessionFile);
33
+ } catch (error) {
34
+ if (isMissingFileError(error)) {
35
+ return {
36
+ fileSizeBytes: null,
37
+ shouldDeferTranscriptLoad: false,
38
+ };
39
+ }
40
+
41
+ throw error;
42
+ }
27
43
 
28
44
  return {
29
45
  fileSizeBytes: metadata.size,