spiracha 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/AGENTS.md +3 -0
  2. package/README.md +14 -0
  3. package/apps/ui/dist/client/assets/{analytics-BjYaHqXk.js → analytics-CqWZmyV6.js} +1 -1
  4. package/apps/ui/dist/client/assets/{checkbox-wPoGG3of.js → checkbox-DXM4lkJq.js} +1 -1
  5. package/apps/ui/dist/client/assets/{data-table-6yDgAdtf.js → data-table-DnPYMPCD.js} +1 -1
  6. package/apps/ui/dist/client/assets/{delete-confirm-dialog-DJUAk7ha.js → delete-confirm-dialog-CcZaRX33.js} +1 -1
  7. package/apps/ui/dist/client/assets/{download-BhWd-Pm5.js → download-DOwxk-cG.js} +1 -1
  8. package/apps/ui/dist/client/assets/{es2015-BlyMI4CF.js → es2015-Bm0kEzx2.js} +1 -1
  9. package/apps/ui/dist/client/assets/{formatters-BxjZwWSE.js → formatters-C12LmYaa.js} +1 -1
  10. package/apps/ui/dist/client/assets/{index-T01rPkb4.js → index-DdJ7ahIt.js} +3 -3
  11. package/apps/ui/dist/client/assets/{input-B3YN8gzg.js → input-CEsI7EpI.js} +1 -1
  12. package/apps/ui/dist/client/assets/{metric-card-BWW7TWER.js → metric-card-9jwBF7rG.js} +1 -1
  13. package/apps/ui/dist/client/assets/{page-header-BZ8Gnxgs.js → page-header-Dr_h1CVv.js} +1 -1
  14. package/apps/ui/dist/client/assets/projects._project-uyNGnpjH.js +1 -0
  15. package/apps/ui/dist/client/assets/{projects._project-EfBhCHPY.js → projects._project-zoM8d2nH.js} +1 -1
  16. package/apps/ui/dist/client/assets/projects.index-D1CWVN-O.js +1 -0
  17. package/apps/ui/dist/client/assets/{projects.index-DzEZ4pAJ.js → projects.index-DukMuny6.js} +1 -1
  18. package/apps/ui/dist/client/assets/{routes-CWCCZykE.js → routes-Gr2Wwh83.js} +1 -1
  19. package/apps/ui/dist/client/assets/{select-DLXGsyZ4.js → select-CFim44gT.js} +1 -1
  20. package/apps/ui/dist/client/assets/{settings-b0Xthfae.js → settings-DqhyDxo2.js} +1 -1
  21. package/apps/ui/dist/client/assets/styles-CMrP9Jb4.css +1 -0
  22. package/apps/ui/dist/client/assets/{threads._threadId-CgtoCqTb.js → threads._threadId-DT75NiBa.js} +1 -1
  23. package/apps/ui/dist/client/assets/{threads._threadId-DBiDb38K.js → threads._threadId-Df5VXIuZ.js} +3 -3
  24. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-C0V305Nt.js +99 -0
  25. package/apps/ui/dist/server/assets/{analytics-Br_fZB6a.js → analytics-BMxW_bZL.js} +1 -1
  26. package/apps/ui/dist/server/assets/{codex-server-Cqh0hb93.js → codex-server-BFZq2Y2O.js} +129 -62
  27. package/apps/ui/dist/server/assets/{download-CzHmFWGk.js → download-C5rkk_Bo.js} +3 -0
  28. package/apps/ui/dist/server/assets/formatters-FJaGZgJk.js +91 -0
  29. package/apps/ui/dist/server/assets/model-label-B1NWGc65.js +13 -0
  30. package/apps/ui/dist/server/assets/{path-transforms-DD1e7rhY.js → path-transforms-DL2IwtYd.js} +1 -1
  31. package/apps/ui/dist/server/assets/{projects._project-DdVSdfPe.js → projects._project-CJ7l0ynC.js} +1 -1
  32. package/apps/ui/dist/server/assets/{projects._project-Bwf6iJC-.js → projects._project-CcJLp_A8.js} +5 -3
  33. package/apps/ui/dist/server/assets/{projects.index-DKeVeqUZ.js → projects.index-srtogpuF.js} +2 -1
  34. package/apps/ui/dist/server/assets/{router-ve2Hrl2Y.js → router-C_w-haH6.js} +6 -6
  35. package/apps/ui/dist/server/assets/{routes-BJyx5OmO.js → routes-BhbxvJE7.js} +1 -1
  36. package/apps/ui/dist/server/assets/{routes-pkOwjjYc.js → routes-CPe-ppmC.js} +3 -2
  37. package/apps/ui/dist/server/assets/{start-BAvbjjfs.js → start-HeKLHD9b.js} +1 -1
  38. package/apps/ui/dist/server/assets/{threads._threadId-D3PYZIwl.js → threads._threadId-Ba7vv6-K.js} +1 -1
  39. package/apps/ui/dist/server/assets/{threads._threadId-D3xaWM86.js → threads._threadId-euyNckhj.js} +31 -9
  40. package/apps/ui/dist/server/server.js +15 -15
  41. package/package.json +7 -1
  42. package/src/export-chats.ts +3 -4
  43. package/src/lib/claude-exporter.ts +1 -1
  44. package/src/lib/codex-browser-db.ts +146 -59
  45. package/src/lib/codex-browser-export.ts +13 -2
  46. package/src/lib/codex-exporter-cli.ts +1 -1
  47. package/src/lib/codex-exporter-db.ts +19 -20
  48. package/src/lib/codex-exporter-transcript.ts +38 -25
  49. package/src/lib/interactive-cli.ts +6 -13
  50. package/src/lib/model-label.ts +24 -0
  51. package/src/lib/path-transforms.ts +1 -0
  52. package/src/lib/shared.ts +2 -24
  53. package/src/lib/sqlite-retry.ts +15 -1
  54. package/src/spiracha.ts +3 -4
  55. package/apps/ui/dist/client/assets/projects._project-B7XcpoLt.js +0 -1
  56. package/apps/ui/dist/client/assets/projects.index-4vfIwLjw.js +0 -1
  57. package/apps/ui/dist/client/assets/styles-8Wtc8YJw.css +0 -1
  58. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-BjsXNYgm.js +0 -99
  59. package/apps/ui/dist/server/assets/formatters-B6o5pTY9.js +0 -72
@@ -1,15 +1,15 @@
1
- import { t as applyPathTransforms$1 } from "./path-transforms-DD1e7rhY.js";
1
+ import { t as applyPathTransforms$1 } from "./path-transforms-DL2IwtYd.js";
2
2
  import { t as cn } from "./utils-C_uf36nf.js";
3
3
  import { t as Button } from "./button-CmTDnzOn.js";
4
4
  import { n as useSettings } from "./settings-store-DpEJEQ7M.js";
5
5
  import { a as threadSnapshotQueryOptions, c as deleteThreadFn, o as threadTranscriptQueryOptions, u as exportThreadFn } from "./codex-queries-CAF6HYiG.js";
6
- import { t as Route } from "./threads._threadId-D3PYZIwl.js";
6
+ import { t as Route } from "./threads._threadId-Ba7vv6-K.js";
7
7
  import { t as Checkbox$1 } from "./checkbox-C0hovF41.js";
8
8
  import { t as MetricCard } from "./metric-card-ByEeLu0r.js";
9
9
  import { t as PageHeader } from "./page-header-CxdZM86z.js";
10
- import { a as formatModelLabel, i as formatList, n as formatBytes, r as formatDateTime, s as formatTokens, t as formatBooleanLabel } from "./formatters-B6o5pTY9.js";
10
+ import { a as formatModelLabel, i as formatList, n as formatBytes, r as formatDateTime, s as formatTokens, t as formatBooleanLabel } from "./formatters-FJaGZgJk.js";
11
11
  import { t as DeleteConfirmDialog } from "./delete-confirm-dialog-CWqcTXTF.js";
12
- import { n as downloadUrlFile, r as ExportDialog, t as downloadTextFile } from "./download-CzHmFWGk.js";
12
+ import { n as downloadUrlFile, r as ExportDialog, t as downloadTextFile } from "./download-C5rkk_Bo.js";
13
13
  import { useEffect, useRef, useState } from "react";
14
14
  import { Link, useNavigate } from "@tanstack/react-router";
15
15
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -235,6 +235,7 @@ function TranscriptEventCard({ assistantModel, copied, event, isSelected, showRa
235
235
  ]
236
236
  }), event.timestamp ? /* @__PURE__ */ jsx("p", {
237
237
  className: "shrink-0 text-[var(--muted-foreground)] text-xs",
238
+ suppressHydrationWarning: true,
238
239
  children: formatDateTime(event.timestamp)
239
240
  }) : null]
240
241
  }),
@@ -294,6 +295,12 @@ function TranscriptView({ assistantModel, events, projectPath, showCommentary, s
294
295
  timeoutIdsRef.current = [];
295
296
  };
296
297
  }, []);
298
+ useEffect(() => {
299
+ setSelectedEventKeys((current) => {
300
+ const next = current.filter((key) => visibleEventKeySet.has(key));
301
+ return next.length === current.length ? current : next;
302
+ });
303
+ }, [visibleEventKeySet]);
297
304
  const scheduleTimeout = (callback, delayMs) => {
298
305
  const timeoutId = window.setTimeout(() => {
299
306
  timeoutIdsRef.current = timeoutIdsRef.current.filter((entry) => entry !== timeoutId);
@@ -497,15 +504,24 @@ var buildThreadItems = (snapshot) => {
497
504
  },
498
505
  {
499
506
  label: "Created",
500
- value: formatDateTime(snapshot.thread.created_at_ms ?? snapshot.thread.created_at * 1e3)
507
+ value: /* @__PURE__ */ jsx("span", {
508
+ suppressHydrationWarning: true,
509
+ children: formatDateTime(snapshot.thread.created_at_ms ?? snapshot.thread.created_at * 1e3)
510
+ })
501
511
  },
502
512
  {
503
513
  label: "Updated",
504
- value: formatDateTime(snapshot.thread.updated_at_ms ?? snapshot.thread.updated_at * 1e3)
514
+ value: /* @__PURE__ */ jsx("span", {
515
+ suppressHydrationWarning: true,
516
+ children: formatDateTime(snapshot.thread.updated_at_ms ?? snapshot.thread.updated_at * 1e3)
517
+ })
505
518
  },
506
519
  {
507
520
  label: "Session started",
508
- value: formatDateTime(snapshot.transcript?.sessionMeta.timestamp ?? null)
521
+ value: /* @__PURE__ */ jsx("span", {
522
+ suppressHydrationWarning: true,
523
+ children: formatDateTime(snapshot.transcript?.sessionMeta.timestamp ?? null)
524
+ })
509
525
  },
510
526
  {
511
527
  label: "Rollout size",
@@ -517,7 +533,10 @@ var buildThreadItems = (snapshot) => {
517
533
  },
518
534
  {
519
535
  label: "Archived at",
520
- value: formatDateTime(snapshot.thread.archived_at ? snapshot.thread.archived_at * 1e3 : null)
536
+ value: /* @__PURE__ */ jsx("span", {
537
+ suppressHydrationWarning: true,
538
+ children: formatDateTime(snapshot.thread.archived_at ? snapshot.thread.archived_at * 1e3 : null)
539
+ })
521
540
  }
522
541
  ];
523
542
  };
@@ -934,7 +953,10 @@ function ThreadDetailPage() {
934
953
  }),
935
954
  /* @__PURE__ */ jsx(MetricCard, {
936
955
  label: "Updated",
937
- value: formatDateTime(snapshot.thread.updated_at_ms ?? snapshot.thread.updated_at * 1e3)
956
+ value: /* @__PURE__ */ jsx("span", {
957
+ suppressHydrationWarning: true,
958
+ children: formatDateTime(snapshot.thread.updated_at_ms ?? snapshot.thread.updated_at * 1e3)
959
+ })
938
960
  }),
939
961
  /* @__PURE__ */ jsx(MetricCard, {
940
962
  label: "Thread source",
@@ -3471,7 +3471,7 @@ var defaultSerovalPlugins = [
3471
3471
  * the dev styles URL for route-scoped CSS collection.
3472
3472
  */
3473
3473
  async function getStartManifest(matchedRoutes) {
3474
- const { tsrStartManifest } = await import("./assets/_tanstack-start-manifest_v-BjsXNYgm.js");
3474
+ const { tsrStartManifest } = await import("./assets/_tanstack-start-manifest_v-C0V305Nt.js");
3475
3475
  const startManifest = tsrStartManifest();
3476
3476
  let routes = startManifest.routes;
3477
3477
  routes[rootRouteId];
@@ -3498,47 +3498,47 @@ async function getStartManifest(matchedRoutes) {
3498
3498
  var manifest = {
3499
3499
  "0814663c3bdc58135f97d210d145ef0be5ca54ef9a5f1e3030be9b1bfc901e30": {
3500
3500
  functionName: "exportThreadFn_createServerFn_handler",
3501
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3501
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3502
3502
  },
3503
3503
  "164ee82cdd565ed96591a64312f0f7bd961040baf066a89d9f5636330d11360b": {
3504
3504
  functionName: "deleteProjectFn_createServerFn_handler",
3505
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3505
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3506
3506
  },
3507
3507
  "29727b7ad5b8fe42e83817376653e064d9fe8888799f056b2e59296b3396568b": {
3508
3508
  functionName: "deleteThreadFn_createServerFn_handler",
3509
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3509
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3510
3510
  },
3511
3511
  "4712520da0f07bbd1f0907e5a162fe518516ff4caca3fd23876cc65539d87d7a": {
3512
3512
  functionName: "getAnalyticsFn_createServerFn_handler",
3513
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3513
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3514
3514
  },
3515
3515
  "59fb2cb4d60c8e7d47e0afcc914ee6f9d9f4bf076c8e66eab1693066753655b3": {
3516
3516
  functionName: "listProjectThreadsFn_createServerFn_handler",
3517
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3517
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3518
3518
  },
3519
3519
  "5da27045f7e28ded6353bc16aace284af7ef1b4010ef04d0186a6feadb466497": {
3520
3520
  functionName: "getThreadTranscriptFn_createServerFn_handler",
3521
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3521
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3522
3522
  },
3523
3523
  "72991e2b6e0adbf8d63bb8b139dad88a00b77b7030ec28ceac36c3cce7846b4c": {
3524
3524
  functionName: "getThreadSnapshotFn_createServerFn_handler",
3525
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3525
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3526
3526
  },
3527
3527
  "792690638a3b10035a5b7368c3d98bdc70cbfe1e36a4aa5f45b1c49b8b1025b0": {
3528
3528
  functionName: "getDashboardSummaryFn_createServerFn_handler",
3529
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3529
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3530
3530
  },
3531
3531
  "96aa60bf7dd9b5bde415bcf3ad6f6955a975eecd9aa0516cf401cc39bebebe6c": {
3532
3532
  functionName: "deleteThreadsFn_createServerFn_handler",
3533
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3533
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3534
3534
  },
3535
3535
  "b4e15c006e9a277470958bb008f89b5b0acc7256109581de44cf17d587d174a5": {
3536
3536
  functionName: "exportThreadsFn_createServerFn_handler",
3537
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3537
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3538
3538
  },
3539
3539
  "ccefccb816ba13508f23db4e31067b3403e750225257592d3ae11071ffc3fd6f": {
3540
3540
  functionName: "listProjectsFn_createServerFn_handler",
3541
- importer: () => import("./assets/codex-server-Cqh0hb93.js")
3541
+ importer: () => import("./assets/codex-server-BFZq2Y2O.js")
3542
3542
  }
3543
3543
  };
3544
3544
  async function getServerFnById(id, access) {
@@ -5328,8 +5328,8 @@ var getBaseManifest = getProdBaseManifest;
5328
5328
  var createEarlyHintsForRequest = createEarlyHintsCollector;
5329
5329
  async function loadEntries() {
5330
5330
  const [routerEntry, startEntry, pluginAdapters] = await Promise.all([
5331
- import("./assets/router-ve2Hrl2Y.js"),
5332
- import("./assets/start-BAvbjjfs.js"),
5331
+ import("./assets/router-C_w-haH6.js"),
5332
+ import("./assets/start-HeKLHD9b.js"),
5333
5333
  import("./assets/__23tanstack-start-plugin-adapters-BzCA6dXo.js")
5334
5334
  ]);
5335
5335
  return {
@@ -5666,7 +5666,7 @@ async function handleServerRoutes({ getRouter, request, url, executeRouter, cont
5666
5666
  return ctx.response;
5667
5667
  }
5668
5668
  //#endregion
5669
- //#region ../../node_modules/.bun/@tanstack+react-start@1.168.11+1cc525de2aa52ed1/node_modules/@tanstack/react-start/dist/plugin/default-entry/server.ts
5669
+ //#region ../../node_modules/.bun/@tanstack+react-start@1.168.11+5cefe02f681c7619/node_modules/@tanstack/react-start/dist/plugin/default-entry/server.ts
5670
5670
  var fetch$1 = createStartHandler(defaultStreamHandler);
5671
5671
  function createServerEntry(entry) {
5672
5672
  return { async fetch(...args) {
package/package.json CHANGED
@@ -21,6 +21,7 @@
21
21
  "@tanstack/react-virtual": "3.13.25",
22
22
  "class-variance-authority": "0.7.1",
23
23
  "clsx": "2.1.1",
24
+ "iconv-lite": "^0.7.2",
24
25
  "lucide-react": "1.16.0",
25
26
  "radix-ui": "1.4.3",
26
27
  "react": "19.2.6",
@@ -61,6 +62,7 @@
61
62
  "src/lib/ui-export-files.ts",
62
63
  "src/lib/ui-cache.ts",
63
64
  "src/lib/interactive-cli.ts",
65
+ "src/lib/model-label.ts",
64
66
  "src/lib/native-open.ts",
65
67
  "src/lib/shared.ts",
66
68
  "apps/ui/dist/client/assets/**/*",
@@ -91,10 +93,14 @@
91
93
  },
92
94
  "scripts": {
93
95
  "build": "bun run typecheck && bun run --cwd apps/ui build",
96
+ "coverage": "bun run coverage:root && bun run coverage:ui",
97
+ "coverage:root": "bun test --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run ./src/coverage-check.ts root",
98
+ "coverage:ui": "bun run --cwd apps/ui coverage && bun run ./src/coverage-check.ts ui",
94
99
  "export:claude": "bun run ./src/export-claude.ts",
95
100
  "format": "biome check . --write && biome lint . --write",
96
101
  "lint": "biome check .",
97
102
  "mcp": "bun run ./src/mcp-server.ts",
103
+ "smoke:package-ui": "bun run ./src/package-ui-smoke.ts",
98
104
  "start": "bun run ./src/export-chats.ts",
99
105
  "typecheck": "bun run typecheck:root && bun run typecheck:ui",
100
106
  "typecheck:root": "bunx tsc --noEmit",
@@ -103,7 +109,7 @@
103
109
  "ui:preview": "bun run --cwd apps/ui preview"
104
110
  },
105
111
  "type": "module",
106
- "version": "1.1.0",
112
+ "version": "1.1.1",
107
113
  "workspaces": [
108
114
  "apps/*"
109
115
  ]
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { stdin as input, stdout as output } from 'node:process';
6
6
  import { createInterface } from 'node:readline/promises';
7
7
  import { getCodexHelpText, parseCodexCliArgs, runCodexExport } from './lib/codex-exporter';
8
- import { runInteractiveExport } from './lib/interactive-cli';
8
+ import type { InteractiveExportResult } from './lib/interactive-cli';
9
9
  import { openPathNatively } from './lib/native-open';
10
10
  import { CliUsageError } from './lib/shared';
11
11
 
@@ -44,6 +44,7 @@ const shouldRunInteractive = (argv: string[]): boolean => {
44
44
  };
45
45
 
46
46
  const runInteractiveCliFlow = async (): Promise<void> => {
47
+ const { runInteractiveExport } = await import('./lib/interactive-cli');
47
48
  const result = await runInteractiveExport();
48
49
  const targetFolder = await printInteractiveExportResult(result);
49
50
  await maybeOpenExportFolder(targetFolder);
@@ -55,9 +56,7 @@ const runCodexCliFlow = async (argv: string[]): Promise<void> => {
55
56
  printCodexExportResult(result);
56
57
  };
57
58
 
58
- const printInteractiveExportResult = async (
59
- result: Awaited<ReturnType<typeof runInteractiveExport>>,
60
- ): Promise<string> => {
59
+ const printInteractiveExportResult = async (result: InteractiveExportResult): Promise<string> => {
61
60
  if (result.mode === 'claude') {
62
61
  console.log(`Exported ${result.sourcePath} -> ${result.outputPath}`);
63
62
  return resolveExportFolder(result.outputPath);
@@ -848,7 +848,7 @@ const fileExists = async (targetPath: string): Promise<boolean> => {
848
848
  };
849
849
 
850
850
  const requireValue = (value: string | undefined, flag: string): string => {
851
- if (!value || value.startsWith('--')) {
851
+ if (!value || (value.startsWith('-') && value !== '-')) {
852
852
  throw new CliUsageError(`Missing value for ${flag}`);
853
853
  }
854
854
 
@@ -26,6 +26,75 @@ type DeleteProjectOptions = {
26
26
  deleteSessionFiles?: boolean;
27
27
  };
28
28
 
29
+ const SQLITE_DELETE_BATCH_SIZE = 400;
30
+ const SESSION_FILE_DELETE_CONCURRENCY = 16;
31
+ const THREAD_LIST_IO_CONCURRENCY = 8;
32
+
33
+ const chunkValues = <T>(values: T[], chunkSize: number) => {
34
+ const chunks: T[][] = [];
35
+
36
+ for (let index = 0; index < values.length; index += chunkSize) {
37
+ chunks.push(values.slice(index, index + chunkSize));
38
+ }
39
+
40
+ return chunks;
41
+ };
42
+
43
+ const isPromiseLike = (value: unknown): value is PromiseLike<unknown> => {
44
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
45
+ return false;
46
+ }
47
+
48
+ return 'then' in value && typeof value.then === 'function';
49
+ };
50
+
51
+ const mapWithConcurrency = async <T, TResult>(
52
+ values: T[],
53
+ limit: number,
54
+ mapper: (value: T, index: number) => Promise<TResult>,
55
+ ) => {
56
+ const results = new Array<TResult>(values.length);
57
+ let nextIndex = 0;
58
+
59
+ const worker = async () => {
60
+ while (true) {
61
+ const currentIndex = nextIndex;
62
+ nextIndex += 1;
63
+
64
+ if (currentIndex >= values.length) {
65
+ return;
66
+ }
67
+
68
+ results[currentIndex] = await mapper(values[currentIndex]!, currentIndex);
69
+ }
70
+ };
71
+
72
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => worker()));
73
+ return results;
74
+ };
75
+
76
+ const openReadonlyDb = (dbPath: string, busyTimeoutMs: number) => {
77
+ const db = new Database(dbPath, { readonly: true });
78
+ try {
79
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
80
+ return db;
81
+ } catch (error) {
82
+ db.close();
83
+ throw error;
84
+ }
85
+ };
86
+
87
+ const openWritableDb = (dbPath: string, busyTimeoutMs: number) => {
88
+ const db = new Database(dbPath);
89
+ try {
90
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
91
+ return db;
92
+ } catch (error) {
93
+ db.close();
94
+ throw error;
95
+ }
96
+ };
97
+
29
98
  const toTimestampMs = (thread: ThreadRow) => {
30
99
  return thread.updated_at_ms ?? thread.updated_at * 1000;
31
100
  };
@@ -54,13 +123,17 @@ const parseJsonSafely = (value: string | null) => {
54
123
  }
55
124
  };
56
125
 
57
- const withReadonlyDb = <T>(dbPath: string, callback: (db: Database) => T): T => {
126
+ export const withReadonlyDb = <T>(dbPath: string, callback: (db: Database) => T): T => {
58
127
  return runWithSqliteRetry({
59
128
  action: () => {
60
- const db = new Database(dbPath, { readonly: true });
61
- db.exec('PRAGMA busy_timeout = 5000');
129
+ const db = openReadonlyDb(dbPath, 5000);
62
130
  try {
63
- return callback(db);
131
+ const result = callback(db);
132
+ if (isPromiseLike(result)) {
133
+ throw new Error('Database callbacks must be synchronous');
134
+ }
135
+
136
+ return result;
64
137
  } finally {
65
138
  db.close();
66
139
  }
@@ -71,13 +144,16 @@ const withReadonlyDb = <T>(dbPath: string, callback: (db: Database) => T): T =>
71
144
  const withWritableDb = <T>(dbPath: string, callback: (db: Database) => T): T => {
72
145
  const db = runWithSqliteRetry({
73
146
  action: () => {
74
- const connection = new Database(dbPath);
75
- connection.exec('PRAGMA busy_timeout = 5000');
76
- return connection;
147
+ return openWritableDb(dbPath, 5000);
77
148
  },
78
149
  });
79
150
  try {
80
- return callback(db);
151
+ const result = callback(db);
152
+ if (isPromiseLike(result)) {
153
+ throw new Error('Database callbacks must be synchronous');
154
+ }
155
+
156
+ return result;
81
157
  } finally {
82
158
  db.close();
83
159
  }
@@ -99,9 +175,7 @@ export const resolveCodexThreadDbPath = () => {
99
175
  try {
100
176
  const db = runWithSqliteRetry({
101
177
  action: () => {
102
- const connection = new Database(candidate, { readonly: true });
103
- connection.exec('PRAGMA busy_timeout = 1500');
104
- return connection;
178
+ return openReadonlyDb(candidate, 1500);
105
179
  },
106
180
  });
107
181
  db.close();
@@ -236,11 +310,21 @@ const getThreadDeleteTargets = (db: Database, threadIds: string[]) => {
236
310
  return [];
237
311
  }
238
312
 
239
- const placeholders = threadIds.map(() => '?').join(', ');
240
- return db.query(`SELECT id, rollout_path FROM threads WHERE id IN (${placeholders})`).all(...threadIds) as Array<{
241
- id: string;
242
- rollout_path: string;
243
- }>;
313
+ const targets: Array<{ id: string; rollout_path: string }> = [];
314
+
315
+ for (const threadIdChunk of chunkValues(threadIds, SQLITE_DELETE_BATCH_SIZE)) {
316
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
317
+ targets.push(
318
+ ...(db
319
+ .query(`SELECT id, rollout_path FROM threads WHERE id IN (${placeholders})`)
320
+ .all(...threadIdChunk) as Array<{
321
+ id: string;
322
+ rollout_path: string;
323
+ }>),
324
+ );
325
+ }
326
+
327
+ return targets;
244
328
  };
245
329
 
246
330
  const deleteThreadIds = (db: Database, threadIds: string[]): DeleteThreadsResult => {
@@ -263,28 +347,30 @@ const deleteThreadIds = (db: Database, threadIds: string[]): DeleteThreadsResult
263
347
  }
264
348
 
265
349
  const deleteMany = db.transaction((ids: string[]) => {
266
- const placeholders = ids.map(() => '?').join(', ');
350
+ for (const threadIdChunk of chunkValues(ids, SQLITE_DELETE_BATCH_SIZE)) {
351
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
267
352
 
268
- // Codex schema differs across versions, so only touch dependent tables that actually exist.
269
- if (existingTableNames.has('thread_dynamic_tools')) {
270
- db.query(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`).run(...ids);
271
- }
353
+ // Codex schema differs across versions, so only touch dependent tables that actually exist.
354
+ if (existingTableNames.has('thread_dynamic_tools')) {
355
+ db.query(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
356
+ }
272
357
 
273
- if (existingTableNames.has('thread_goals')) {
274
- db.query(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`).run(...ids);
275
- }
358
+ if (existingTableNames.has('thread_goals')) {
359
+ db.query(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
360
+ }
276
361
 
277
- if (existingTableNames.has('stage1_outputs')) {
278
- db.query(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`).run(...ids);
279
- }
362
+ if (existingTableNames.has('stage1_outputs')) {
363
+ db.query(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
364
+ }
280
365
 
281
- if (existingTableNames.has('thread_spawn_edges')) {
282
- db.query(
283
- `DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
284
- ).run(...ids, ...ids);
285
- }
366
+ if (existingTableNames.has('thread_spawn_edges')) {
367
+ db.query(
368
+ `DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
369
+ ).run(...threadIdChunk, ...threadIdChunk);
370
+ }
286
371
 
287
- db.query(`DELETE FROM threads WHERE id IN (${placeholders})`).run(...ids);
372
+ db.query(`DELETE FROM threads WHERE id IN (${placeholders})`).run(...threadIdChunk);
373
+ }
288
374
  });
289
375
 
290
376
  deleteMany(existingIds);
@@ -297,7 +383,10 @@ const deleteThreadIds = (db: Database, threadIds: string[]): DeleteThreadsResult
297
383
 
298
384
  const deleteThreadSessionFiles = async (sessionFiles: string[]) => {
299
385
  const uniqueSessionFiles = [...new Set(sessionFiles)];
300
- await Promise.all(uniqueSessionFiles.map((sessionFile) => rm(sessionFile, { force: true })));
386
+ await mapWithConcurrency(uniqueSessionFiles, SESSION_FILE_DELETE_CONCURRENCY, async (sessionFile) => {
387
+ await rm(sessionFile, { force: true });
388
+ return sessionFile;
389
+ });
301
390
  return uniqueSessionFiles;
302
391
  };
303
392
 
@@ -323,39 +412,37 @@ export const listProjectThreads = async (
323
412
  options: ListProjectThreadsOptions = {},
324
413
  ): Promise<ThreadListEntry[]> => {
325
414
  const threads = filterThreadsByProject(readAllThreads(dbPath), projectName);
326
- const entries = await Promise.all(
327
- threads.map(async (thread): Promise<ThreadListEntry> => {
328
- const rollout = await getThreadRolloutLoadState(thread.rollout_path, options.largeTranscriptThresholdBytes);
329
-
330
- if (rollout.shouldDeferTranscriptLoad) {
331
- return {
332
- project: projectName,
333
- rolloutSizeBytes: rollout.fileSizeBytes,
334
- stats: {
335
- deferred: true,
336
- execCommandCount: 0,
337
- toolCallCount: 0,
338
- webSearchEventCount: 0,
339
- },
340
- thread: compactThreadListRow(thread),
341
- };
342
- }
343
-
344
- const transcript = await getCachedParsedCodexTranscript(thread.rollout_path);
415
+ const entries = await mapWithConcurrency(threads, THREAD_LIST_IO_CONCURRENCY, async (thread) => {
416
+ const rollout = await getThreadRolloutLoadState(thread.rollout_path, options.largeTranscriptThresholdBytes);
345
417
 
418
+ if (rollout.shouldDeferTranscriptLoad) {
346
419
  return {
347
420
  project: projectName,
348
421
  rolloutSizeBytes: rollout.fileSizeBytes,
349
422
  stats: {
350
- deferred: false,
351
- execCommandCount: transcript.stats.execCommandCount,
352
- toolCallCount: transcript.stats.toolCallCount,
353
- webSearchEventCount: transcript.stats.webSearchEventCount,
423
+ deferred: true,
424
+ execCommandCount: 0,
425
+ toolCallCount: 0,
426
+ webSearchEventCount: 0,
354
427
  },
355
428
  thread: compactThreadListRow(thread),
356
429
  };
357
- }),
358
- );
430
+ }
431
+
432
+ const transcript = await getCachedParsedCodexTranscript(thread.rollout_path);
433
+
434
+ return {
435
+ project: projectName,
436
+ rolloutSizeBytes: rollout.fileSizeBytes,
437
+ stats: {
438
+ deferred: false,
439
+ execCommandCount: transcript.stats.execCommandCount,
440
+ toolCallCount: transcript.stats.toolCallCount,
441
+ webSearchEventCount: transcript.stats.webSearchEventCount,
442
+ },
443
+ thread: compactThreadListRow(thread),
444
+ };
445
+ });
359
446
 
360
447
  return entries.sort((left, right) => toTimestampMs(right.thread) - toTimestampMs(left.thread));
361
448
  };
@@ -153,6 +153,17 @@ const logRolloutChangeIfDetected = (
153
153
  });
154
154
  };
155
155
 
156
+ const cleanupExportWorkspace = async (workspacePath: string) => {
157
+ try {
158
+ await rm(workspacePath, { force: true, recursive: true });
159
+ } catch (error) {
160
+ logExportEvent('warn', 'workspace_cleanup_failed', {
161
+ error: error instanceof Error ? error.message : String(error),
162
+ workspacePath,
163
+ });
164
+ }
165
+ };
166
+
156
167
  const zipExportFile = async (sourcePath: string, zipPath: string) => {
157
168
  const proc = Bun.spawn(['zip', '-9', '-j', zipPath, sourcePath], {
158
169
  stderr: 'pipe',
@@ -240,7 +251,7 @@ export const renderCodexThreadDownload = async (
240
251
 
241
252
  await zipExportFile(savedPath, zipPath);
242
253
  } finally {
243
- await rm(workspaceDir, { force: true, recursive: true });
254
+ await cleanupExportWorkspace(workspaceDir);
244
255
  }
245
256
 
246
257
  const rolloutSnapshotAfter = await getRolloutSnapshot(browseData.thread.rollout_path);
@@ -395,7 +406,7 @@ export const renderCodexThreadsDownload = async (
395
406
  });
396
407
  throw error;
397
408
  } finally {
398
- await rm(bundleDirectory, { force: true, recursive: true });
409
+ await cleanupExportWorkspace(bundleDirectory);
399
410
  }
400
411
 
401
412
  const zipStat = await Bun.file(zipPath).stat();
@@ -261,7 +261,7 @@ export const resolveDefaultOutputDir = (cwdFilter: string | null): string => {
261
261
  };
262
262
 
263
263
  const requireValue = (value: string | undefined, flag: string): string => {
264
- if (!value || value.startsWith('--')) {
264
+ if (!value || (value.startsWith('-') && value !== '-')) {
265
265
  throw new CliUsageError(`Missing value for ${flag}`);
266
266
  }
267
267
 
@@ -1,6 +1,6 @@
1
- import { Database } from 'bun:sqlite';
2
1
  import { readdir } from 'node:fs/promises';
3
2
  import path from 'node:path';
3
+ import { withReadonlyDb } from './codex-browser-db';
4
4
  import {
5
5
  type CodexCliOptions,
6
6
  DEFAULT_CODEX_DIR,
@@ -17,33 +17,29 @@ export const loadThreadData = (dbPath: string, options: CodexCliOptions): Thread
17
17
  const parentByChildId = new Map<string, SpawnEdgeRow>();
18
18
  const childEdgesByParentId = new Map<string, SpawnEdgeRow[]>();
19
19
 
20
- let db: Database | null = null;
21
-
22
20
  try {
23
- db = new Database(dbPath, { readonly: true });
24
-
25
- const threadQuery = buildThreadQuery(options);
26
- const threadRows = db.query(threadQuery.sql).all(...threadQuery.params) as ThreadRow[];
21
+ withReadonlyDb(dbPath, (db) => {
22
+ const threadQuery = buildThreadQuery(options);
23
+ const threadRows = db.query(threadQuery.sql).all(...threadQuery.params) as ThreadRow[];
27
24
 
28
- for (const row of threadRows) {
29
- threadsById.set(row.id, row);
30
- }
25
+ for (const row of threadRows) {
26
+ threadsById.set(row.id, row);
27
+ }
31
28
 
32
- const edgeQuery = buildSpawnEdgeQuery([...threadsById.keys()], options);
33
- const edgeRows = db.query(edgeQuery.sql).all(...edgeQuery.params) as SpawnEdgeRow[];
29
+ const edgeQuery = buildSpawnEdgeQuery([...threadsById.keys()], options);
30
+ const edgeRows = db.query(edgeQuery.sql).all(...edgeQuery.params) as SpawnEdgeRow[];
34
31
 
35
- for (const row of edgeRows) {
36
- parentByChildId.set(row.child_thread_id, row);
32
+ for (const row of edgeRows) {
33
+ parentByChildId.set(row.child_thread_id, row);
37
34
 
38
- const existing = childEdgesByParentId.get(row.parent_thread_id) ?? [];
39
- existing.push(row);
40
- childEdgesByParentId.set(row.parent_thread_id, existing);
41
- }
35
+ const existing = childEdgesByParentId.get(row.parent_thread_id) ?? [];
36
+ existing.push(row);
37
+ childEdgesByParentId.set(row.parent_thread_id, existing);
38
+ }
39
+ });
42
40
  } catch (error) {
43
41
  const message = error instanceof Error ? error.message : String(error);
44
42
  throw new Error(`Failed to read thread database at ${dbPath}: ${message}`);
45
- } finally {
46
- db?.close();
47
43
  }
48
44
 
49
45
  return {
@@ -210,14 +206,17 @@ export const toOutputRelativePath = (
210
206
  return flatName;
211
207
  }
212
208
 
209
+ // Prefer preserving the input sessions tree when the rollout lives under the configured input root.
213
210
  if (normalized.startsWith(`${inputRoot}${path.sep}`)) {
214
211
  return path.relative(inputRoot, normalized).replace(/\.jsonl$/i, extension);
215
212
  }
216
213
 
214
+ // Fall back to a stable Codex-relative path when the file is under ~/.codex.
217
215
  if (normalized.startsWith(`${codexRoot}${path.sep}`)) {
218
216
  return path.relative(codexRoot, normalized).replace(/\.jsonl$/i, extension);
219
217
  }
220
218
 
219
+ // Otherwise collapse to the basename so ad hoc session files cannot escape the output directory.
221
220
  return path.basename(normalized).replace(/\.jsonl$/i, extension);
222
221
  };
223
222