opencode-sesh 2.1.4 → 2.1.6

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.
package/AGENTS.md CHANGED
@@ -31,6 +31,7 @@ bun run test # build + fixture-DB regression suite — must stay green
31
31
  bun run test:changelog # release-tooling regressions (scripts/changelog.sh)
32
32
  bun run test:picker # PTY picker suite
33
33
  bun run test:tui-home # real OpenTUI home layout regressions (also in bun run test)
34
+ bun run test:tui-worker # real blob Worker + bun:sqlite regressions (also in bun run test)
34
35
  bun run typecheck # tsc over tui-plugins/ and plugins/
35
36
  bun run lint:sh # bash -n on every script
36
37
  HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.config)
@@ -181,10 +182,11 @@ when opencode exits, the picker reopens with the query preserved.
181
182
  scrolling inside; the picker remains the place for transcript search.
182
183
  Sessions waiting on the user (unanswered questions, stuck runs) pin a
183
184
  **Needs input** group above the directories, with the count in the heading —
184
- same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`. The query is
185
- synchronous on the TUI thread, so the shared poll runs it only while the
186
- sidebar is subscribed (`poll(listener, { waiting: true })`); the home list
187
- never triggers it.
185
+ same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`. The query runs in
186
+ a blob Worker (`createDbClient`; failure means "local store unavailable"),
187
+ off the TUI thread but still costing worker CPU every poll, so the shared
188
+ poll runs it only while the sidebar is subscribed
189
+ (`poll(listener, { waiting: true })`); the home list never triggers it.
188
190
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
189
191
  project/directory, recency-ordered, resume via
190
192
  `api.route.navigate("session", …)`. `option+o` and the command palette open it;
@@ -196,7 +198,10 @@ when opencode exits, the picker reopens with the query preserved.
196
198
  at `SESSION_MAX` and reported when truncated) rather than a fixed-size
197
199
  window, then indexes transcript text for **every** session in batches
198
200
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
199
- The header shows indexing progress/coverage. Visible transcript hits get
201
+ Local extraction is one SQL statement per batch in the DB worker
202
+ (`transcriptTextQuery`, JSON-guarded) that returns only `{sid, text}` rows,
203
+ so tool payloads never reach the UI thread; if the worker rejects, sessions
204
+ not yet indexed fall back to the remote path. The header shows indexing progress/coverage. Visible transcript hits get
200
205
  short excerpts from the text-only index (never tool or reasoning payloads).
201
206
  Project, needs-input (Option-W), and pinned-session (Option-S) filters compose
202
207
  with the query; the picker remembers query/scope/filters until plugin restart.
@@ -269,6 +274,10 @@ when opencode exits, the picker reopens with the query preserved.
269
274
  and Python 3).
270
275
  - `bun run test:tui-home` — real OpenTUI render regressions for the home list
271
276
  (also run by `bun run test`).
277
+ - `bun run test:tui-worker` — starts the real blob Worker against a bun:sqlite
278
+ fixture; asserts results round-trip and the main thread keeps ticking during
279
+ a slow query and during transcript indexing of large tool payloads (also run
280
+ by `bun run test`).
272
281
  - `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
273
282
  - `bun run lint:sh` — `bash -n` on every script.
274
283
  - `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.1.6] - 2026-11-02
9
+
10
+ ### Changed
11
+
12
+ - **tui:** Extract transcript text in the DB worker (#35)
13
+
14
+ ## [2.1.5] - 2026-11-02
15
+
16
+ ### Fixed
17
+
18
+ - **tui:** Run needs-input SQLite reads in a worker (#34)
19
+
8
20
  ## [2.1.4] - 2026-11-02
9
21
 
10
22
  ### Fixed
@@ -536,6 +548,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
536
548
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
537
549
  CI.
538
550
 
551
+ [2.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.5...v2.1.6
552
+ [2.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.4...v2.1.5
539
553
  [2.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.3...v2.1.4
540
554
  [2.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.2...v2.1.3
541
555
  [2.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.1...v2.1.2
package/dist/tui.js CHANGED
@@ -636,9 +636,106 @@ async function openTranscriptDb() {
636
636
  return undefined;
637
637
  }
638
638
  }
639
+
640
+ // Runs in a blob Worker so slow queries never block the TUI thread. The
641
+ // connection opens on the first request and stays open; `loadSqlite` is
642
+ // injected only by tests.
643
+ const DB_WORKER_SOURCE = `
644
+ let opening
645
+ const load = typeof loadSqlite === "function" ? loadSqlite : () => import("bun:sqlite")
646
+ const open = (path) => opening ??= load().then((sqlite) => new sqlite.Database(path, { readonly: true }), (error) => { opening = undefined; throw error })
647
+ self.onmessage = async (event) => {
648
+ const { id, path, sql, params } = event.data
649
+ try {
650
+ const db = await open(path)
651
+ self.postMessage({ id, rows: db.query(sql).all(...(params ?? [])) })
652
+ } catch (error) {
653
+ self.postMessage({ id, error: String(error?.message ?? error) })
654
+ }
655
+ }`;
656
+ const DB_QUERY_TIMEOUT_MS = 30000;
657
+ function createDbWorker() {
658
+ return new globalThis.Worker(URL.createObjectURL(new Blob([DB_WORKER_SOURCE], {
659
+ type: "text/javascript"
660
+ })));
661
+ }
662
+
663
+ // Read-only queries against the local store, served by one lazily started
664
+ // worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
665
+ // a timeout or worker error discards the worker so the next call starts afresh.
666
+ function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
667
+ let worker;
668
+ let nextId = 0;
669
+ const pending = new Map();
670
+ const reset = error => {
671
+ const dead = worker;
672
+ worker = undefined;
673
+ try {
674
+ dead?.terminate();
675
+ } catch {}
676
+ const requests = [...pending.values()];
677
+ pending.clear();
678
+ for (const request of requests) {
679
+ clearTimeout(request.timer);
680
+ request.reject(error);
681
+ }
682
+ };
683
+ const start = () => {
684
+ const created = createWorker();
685
+ created.onmessage = event => {
686
+ const {
687
+ id,
688
+ rows,
689
+ error
690
+ } = event?.data ?? {};
691
+ const request = pending.get(id);
692
+ if (!request) return;
693
+ pending.delete(id);
694
+ clearTimeout(request.timer);
695
+ if (typeof error === "string") request.reject(new Error(error));else request.resolve(Array.isArray(rows) ? rows : []);
696
+ };
697
+ created.onerror = event => {
698
+ event?.preventDefault?.();
699
+ if (worker === created) reset(new Error(String(event?.message ?? "local store worker failed")));
700
+ };
701
+ created.unref?.();
702
+ return created;
703
+ };
704
+ const run = (sql, params = []) => new Promise((resolve, reject) => {
705
+ try {
706
+ worker ??= start();
707
+ } catch (error) {
708
+ reject(error);
709
+ return;
710
+ }
711
+ const id = ++nextId;
712
+ const timer = setTimeout(() => reset(new Error("local store query timed out")), timeoutMs);
713
+ timer.unref?.();
714
+ pending.set(id, {
715
+ resolve,
716
+ reject,
717
+ timer
718
+ });
719
+ try {
720
+ worker.postMessage({
721
+ id,
722
+ path: resolveDbPath(),
723
+ sql,
724
+ params
725
+ });
726
+ } catch (error) {
727
+ reset(error);
728
+ }
729
+ });
730
+ return {
731
+ run,
732
+ close: () => reset(new Error("local store closed"))
733
+ };
734
+ }
735
+ const dbClient = createDbClient();
639
736
  function createSessionPoll(api, refreshPins) {
640
- // Listener -> whether it reads `snapshot.waiting`. The needs-input query is
641
- // synchronous on the TUI thread, so it only runs while such a listener exists.
737
+ // Listener -> whether it reads `snapshot.waiting`. The needs-input query
738
+ // costs worker CPU on every poll, so it only runs while such a listener exists.
642
739
  const listeners = new Map();
643
740
  let snapshot = {
644
741
  entries: [],
@@ -677,20 +774,18 @@ function createSessionPoll(api, refreshPins) {
677
774
  };
678
775
  }
679
776
  if (!active()) return;
680
- const db = wantsWaiting() ? await openTranscriptDb() : undefined;
681
- if (db) {
777
+ if (wantsWaiting()) {
682
778
  try {
779
+ const waiting = await queryWaitingIds(dbClient.run);
683
780
  if (wantsWaiting()) {
684
781
  snapshot = {
685
782
  ...snapshot,
686
- waiting: await queryWaitingIds(db)
783
+ waiting
687
784
  };
688
785
  waitingLoaded = true;
689
786
  }
690
787
  } catch {
691
788
  // Keep the previous waiting set when the local store is unavailable.
692
- } finally {
693
- db.close();
694
789
  }
695
790
  } else if (!wantsWaiting()) {
696
791
  snapshot = {
@@ -788,8 +883,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
788
883
  WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
789
884
  AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
790
885
  ORDER BY s.time_updated DESC`;
791
- async function queryWaitingIds(db) {
792
- const rows = db.query(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS)).all() ?? [];
886
+ async function queryWaitingIds(run) {
887
+ const rows = await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS));
793
888
  const out = new Map();
794
889
  for (const row of rows) {
795
890
  if (typeof row?.id === "string" && SESSION_ID_PATTERN.test(row.id) && (row.reason === "question" || row.reason === "stuck")) {
@@ -801,8 +896,8 @@ async function queryWaitingIds(db) {
801
896
  // The shared needs-input query decides *which* sessions are waiting. Read the
802
897
  // oldest still-unanswered question or stuck tool for each result so triage can
803
898
  // show how long it has waited, rather than its last (unrelated) update time.
804
- async function queryWaitingDetails(db) {
805
- const reasons = await queryWaitingIds(db);
899
+ async function queryWaitingDetails(run) {
900
+ const reasons = await queryWaitingIds(run);
806
901
  const details = new Map([...reasons].map(([id, reason]) => [id, {
807
902
  reason: reason
808
903
  }]));
@@ -810,12 +905,12 @@ async function queryWaitingDetails(db) {
810
905
  const cutoff = Date.now() - STUCK_AFTER_MS;
811
906
  for (let i = 0; i < ids.length; i += 200) {
812
907
  const batch = ids.slice(i, i + 200);
813
- const rows = db.query(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
908
+ const rows = await run(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
814
909
  SELECT live.session_id AS id,
815
910
  MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
816
911
  MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
817
912
  FROM live
818
- GROUP BY live.session_id`).all(...batch) ?? [];
913
+ GROUP BY live.session_id`, batch);
819
914
  for (const row of rows) {
820
915
  const detail = details.get(row.id);
821
916
  if (!detail) continue;
@@ -825,29 +920,39 @@ GROUP BY live.session_id`).all(...batch) ?? [];
825
920
  }
826
921
  return details;
827
922
  }
828
- function addTranscript(index, sid, data) {
829
- try {
830
- const message = JSON.parse(data);
831
- const texts = [];
832
- if (typeof message?.text === "string") texts.push(message.text);
833
- if (Array.isArray(message?.content)) {
834
- for (const part of message.content) {
835
- if (part?.type === "text" && typeof part.text === "string") texts.push(part.text);
836
- }
837
- }
838
- for (const text of texts) {
839
- const base = index.get(sid) ?? "";
840
- index.set(sid, `${base} ${text}`.trim());
841
- }
842
- } catch {
843
- // ignore malformed messages
844
- }
923
+
924
+ // Text parts only, extracted inside SQLite so tool and reasoning payloads never
925
+ // reach the caller. The CASE guards keep json_extract away from malformed rows
926
+ // and non-object parts, which SQL AND ordering alone would not.
927
+ function transcriptTextQuery(count) {
928
+ const placeholders = Array.from({
929
+ length: count
930
+ }, () => "?").join(",");
931
+ return `WITH msg AS (
932
+ SELECT session_id AS sid, seq, rowid AS rid, CASE WHEN json_valid(data) THEN data END AS doc
933
+ FROM session_message
934
+ WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
935
+ ), parts AS (
936
+ SELECT sid, seq, rid, 0 AS part,
937
+ CASE WHEN json_type(doc, '$.text') = 'text' THEN json_extract(doc, '$.text') END AS text
938
+ FROM msg
939
+ UNION ALL
940
+ SELECT msg.sid, msg.seq, msg.rid, 1 + item.key AS part,
941
+ CASE WHEN item.type = 'object' THEN
942
+ CASE WHEN json_extract(item.value, '$.type') = 'text' AND json_type(item.value, '$.text') = 'text'
943
+ THEN json_extract(item.value, '$.text') END
944
+ END AS text
945
+ FROM msg, json_each(msg.doc, '$.content') AS item
946
+ WHERE json_type(msg.doc, '$.content') = 'array'
947
+ )
948
+ SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`;
845
949
  }
846
950
 
847
951
  // Index titles/directories immediately, then add transcript text progressively
848
952
  // in batches so the render thread can breathe. Covers every entry, not a fixed
849
- // newest window; `onProgress` lets the picker show coverage.
850
- async function buildSearchIndex(api, entries, onProgress, active = () => true) {
953
+ // newest window; `onProgress` lets the picker show coverage. Local extraction
954
+ // runs through `run` (the worker client) and returns text only.
955
+ async function buildSearchIndex(api, entries, onProgress, active = () => true, run = dbClient.run) {
851
956
  const index = new Map();
852
957
  for (const entry of entries) {
853
958
  index.set(entry.id, `${entry.title} ${entry.dir}`);
@@ -865,25 +970,25 @@ async function buildSearchIndex(api, entries, onProgress, active = () => true) {
865
970
  };
866
971
  report(false);
867
972
  if (!active()) return index;
868
- const db = await openTranscriptDb();
869
- if (db) {
870
- try {
871
- for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
872
- if (!active()) break;
873
- const batch = ids.slice(i, i + TRANSCRIPT_BATCH);
874
- const placeholders = batch.map(() => "?").join(",");
875
- const rows = db.query(`SELECT session_id AS sid, data FROM session_message WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant') ORDER BY seq`).all(...batch) ?? [];
876
- for (const row of rows) addTranscript(index, row.sid, row.data);
877
- for (const id of batch) remaining.delete(id);
878
- indexed += batch.length;
879
- report(false);
880
- await nextTick();
973
+ try {
974
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
975
+ if (!active()) break;
976
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH);
977
+ const rows = await run(transcriptTextQuery(batch.length), batch);
978
+ if (!active()) break;
979
+ const texts = new Map();
980
+ for (const row of rows) {
981
+ const list = texts.get(row.sid);
982
+ if (list) list.push(row.text);else texts.set(row.sid, [row.text]);
881
983
  }
882
- } catch {
883
- // local store unavailable mid-walk; fall back to the remote path below
884
- } finally {
885
- db.close();
984
+ for (const [sid, list] of texts) index.set(sid, [index.get(sid) ?? "", ...list].join(" ").trim());
985
+ for (const id of batch) remaining.delete(id);
986
+ indexed += batch.length;
987
+ report(false);
988
+ await nextTick();
886
989
  }
990
+ } catch {
991
+ // local store unavailable mid-walk; fall back to the remote path below
887
992
  }
888
993
  if (active() && remaining.size > 0) {
889
994
  await buildSearchIndexRemote(api, [...remaining], index, () => {
@@ -2749,21 +2854,14 @@ const tui = async api => {
2749
2854
  const [waitingCoverage, setWaitingCoverage] = createSignal("loading");
2750
2855
  let alive = true;
2751
2856
  void (async () => {
2752
- const db = await openTranscriptDb();
2753
- if (!db) {
2754
- if (alive) setWaitingCoverage("unavailable");
2755
- return;
2756
- }
2757
2857
  try {
2758
- const ids = await queryWaitingIds(db);
2858
+ const ids = await queryWaitingIds(dbClient.run);
2759
2859
  if (alive) {
2760
2860
  setWaitingIds(new Set(ids.keys()));
2761
2861
  setWaitingCoverage("ready");
2762
2862
  }
2763
2863
  } catch {
2764
2864
  if (alive) setWaitingCoverage("unavailable");
2765
- } finally {
2766
- db.close();
2767
2865
  }
2768
2866
  })();
2769
2867
  const [pendingDelete, setPendingDelete] = createSignal();
@@ -3924,20 +4022,16 @@ const tui = async api => {
3924
4022
  const {
3925
4023
  entries
3926
4024
  } = await fetchEntries(api);
3927
- const db = await openTranscriptDb();
3928
- if (!db) {
4025
+ let waiting;
4026
+ try {
4027
+ waiting = await queryWaitingDetails(dbClient.run);
4028
+ } catch {
3929
4029
  api.ui.toast({
3930
4030
  message: "Needs-input data unavailable",
3931
4031
  variant: "error"
3932
4032
  });
3933
4033
  return;
3934
4034
  }
3935
- let waiting;
3936
- try {
3937
- waiting = await queryWaitingDetails(db);
3938
- } finally {
3939
- db.close();
3940
- }
3941
4035
  items = entries.filter(entry => waiting.has(entry.id)).map(entry => ({
3942
4036
  entry,
3943
4037
  reason: waiting.get(entry.id)?.reason === "question" ? "awaiting answer" : "run stuck",
@@ -4491,6 +4585,7 @@ export default Plugin.define({
4491
4585
  if (api.ui.dialog.open) api.ui.dialog.clear();
4492
4586
  disposeSlot();
4493
4587
  removeRootMouse?.();
4588
+ dbClient.close();
4494
4589
  };
4495
4590
  }
4496
4591
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "OpenCode session browser: search, preview, and resume every session across every project from the terminal or TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,8 +57,9 @@
57
57
  "build": "node scripts/build-tui.mjs",
58
58
  "prepack": "node scripts/build-tui.mjs",
59
59
  "test": "bun run test:unit && bash tests/changelog.sh",
60
- "test:unit": "node scripts/build-tui.mjs && bun run test:tui-home && bash tests/sesh.sh && bash tests/install.sh",
60
+ "test:unit": "node scripts/build-tui.mjs && bun run test:tui-home && bun run test:tui-worker && bash tests/sesh.sh && bash tests/install.sh",
61
61
  "test:tui-home": "bun --conditions=browser tests/tui-home.mjs",
62
+ "test:tui-worker": "bun tests/tui-worker.mjs",
62
63
  "test:changelog": "bash tests/changelog.sh",
63
64
  "test:picker": "python3 tests/picker.py",
64
65
  "typecheck": "tsc --noEmit",
@@ -537,11 +537,96 @@ async function openTranscriptDb(): Promise<any | undefined> {
537
537
  }
538
538
  }
539
539
 
540
+ // Runs in a blob Worker so slow queries never block the TUI thread. The
541
+ // connection opens on the first request and stays open; `loadSqlite` is
542
+ // injected only by tests.
543
+ const DB_WORKER_SOURCE = `
544
+ let opening
545
+ const load = typeof loadSqlite === "function" ? loadSqlite : () => import("bun:sqlite")
546
+ const open = (path) => opening ??= load().then((sqlite) => new sqlite.Database(path, { readonly: true }), (error) => { opening = undefined; throw error })
547
+ self.onmessage = async (event) => {
548
+ const { id, path, sql, params } = event.data
549
+ try {
550
+ const db = await open(path)
551
+ self.postMessage({ id, rows: db.query(sql).all(...(params ?? [])) })
552
+ } catch (error) {
553
+ self.postMessage({ id, error: String(error?.message ?? error) })
554
+ }
555
+ }`
556
+
557
+ const DB_QUERY_TIMEOUT_MS = 30000
558
+
559
+ type DbRun = (sql: string, params?: unknown[]) => Promise<unknown[]>
560
+
561
+ function createDbWorker(): any {
562
+ return new (globalThis as any).Worker(URL.createObjectURL(new Blob([DB_WORKER_SOURCE], { type: "text/javascript" })))
563
+ }
564
+
565
+ // Read-only queries against the local store, served by one lazily started
566
+ // worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
567
+ // a timeout or worker error discards the worker so the next call starts afresh.
568
+ function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
569
+ type Pending = { resolve: (rows: unknown[]) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
570
+ let worker: any
571
+ let nextId = 0
572
+ const pending = new Map<number, Pending>()
573
+ const reset = (error: Error) => {
574
+ const dead = worker
575
+ worker = undefined
576
+ try { dead?.terminate() } catch {}
577
+ const requests = [...pending.values()]
578
+ pending.clear()
579
+ for (const request of requests) {
580
+ clearTimeout(request.timer)
581
+ request.reject(error)
582
+ }
583
+ }
584
+ const start = () => {
585
+ const created = createWorker()
586
+ created.onmessage = (event: any) => {
587
+ const { id, rows, error } = event?.data ?? {}
588
+ const request = pending.get(id)
589
+ if (!request) return
590
+ pending.delete(id)
591
+ clearTimeout(request.timer)
592
+ if (typeof error === "string") request.reject(new Error(error))
593
+ else request.resolve(Array.isArray(rows) ? rows : [])
594
+ }
595
+ created.onerror = (event: any) => {
596
+ event?.preventDefault?.()
597
+ if (worker === created) reset(new Error(String(event?.message ?? "local store worker failed")))
598
+ }
599
+ created.unref?.()
600
+ return created
601
+ }
602
+ const run: DbRun = (sql, params = []) =>
603
+ new Promise((resolve, reject) => {
604
+ try {
605
+ worker ??= start()
606
+ } catch (error) {
607
+ reject(error)
608
+ return
609
+ }
610
+ const id = ++nextId
611
+ const timer = setTimeout(() => reset(new Error("local store query timed out")), timeoutMs)
612
+ ;(timer as any).unref?.()
613
+ pending.set(id, { resolve, reject, timer })
614
+ try {
615
+ worker.postMessage({ id, path: resolveDbPath(), sql, params })
616
+ } catch (error) {
617
+ reset(error as Error)
618
+ }
619
+ })
620
+ return { run, close: () => reset(new Error("local store closed")) }
621
+ }
622
+
623
+ const dbClient = createDbClient()
624
+
540
625
  type SessionSnapshot = { entries: Entry[]; waiting: Map<string, string>; failed: boolean }
541
626
 
542
627
  function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
543
- // Listener -> whether it reads `snapshot.waiting`. The needs-input query is
544
- // synchronous on the TUI thread, so it only runs while such a listener exists.
628
+ // Listener -> whether it reads `snapshot.waiting`. The needs-input query
629
+ // costs worker CPU on every poll, so it only runs while such a listener exists.
545
630
  const listeners = new Map<(snapshot: SessionSnapshot) => void, boolean>()
546
631
  let snapshot: SessionSnapshot = { entries: [], waiting: new Map(), failed: false }
547
632
  let clock: ReturnType<typeof setInterval> | undefined
@@ -569,17 +654,15 @@ function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
569
654
  snapshot = { ...snapshot, failed: true }
570
655
  }
571
656
  if (!active()) return
572
- const db = wantsWaiting() ? await openTranscriptDb() : undefined
573
- if (db) {
657
+ if (wantsWaiting()) {
574
658
  try {
659
+ const waiting = await queryWaitingIds(dbClient.run)
575
660
  if (wantsWaiting()) {
576
- snapshot = { ...snapshot, waiting: await queryWaitingIds(db) }
661
+ snapshot = { ...snapshot, waiting }
577
662
  waitingLoaded = true
578
663
  }
579
664
  } catch {
580
665
  // Keep the previous waiting set when the local store is unavailable.
581
- } finally {
582
- db.close()
583
666
  }
584
667
  } else if (!wantsWaiting()) {
585
668
  snapshot = { ...snapshot, waiting: new Map() }
@@ -674,8 +757,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
674
757
  AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
675
758
  ORDER BY s.time_updated DESC`
676
759
 
677
- async function queryWaitingIds(db: any): Promise<Map<string, string>> {
678
- const rows = (db.query(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS)).all() ?? []) as {
760
+ async function queryWaitingIds(run: DbRun): Promise<Map<string, string>> {
761
+ const rows = (await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS))) as {
679
762
  id: unknown
680
763
  reason: unknown
681
764
  }[]
@@ -697,8 +780,8 @@ type WaitingDetail = { reason: "question" | "stuck"; since?: number }
697
780
  // The shared needs-input query decides *which* sessions are waiting. Read the
698
781
  // oldest still-unanswered question or stuck tool for each result so triage can
699
782
  // show how long it has waited, rather than its last (unrelated) update time.
700
- async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>> {
701
- const reasons = await queryWaitingIds(db)
783
+ async function queryWaitingDetails(run: DbRun): Promise<Map<string, WaitingDetail>> {
784
+ const reasons = await queryWaitingIds(run)
702
785
  const details = new Map<string, WaitingDetail>(
703
786
  [...reasons].map(([id, reason]) => [id, { reason: reason as WaitingDetail["reason"] }]),
704
787
  )
@@ -706,12 +789,12 @@ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>>
706
789
  const cutoff = Date.now() - STUCK_AFTER_MS
707
790
  for (let i = 0; i < ids.length; i += 200) {
708
791
  const batch = ids.slice(i, i + 200)
709
- const rows = (db.query(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
792
+ const rows = (await run(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
710
793
  SELECT live.session_id AS id,
711
794
  MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
712
795
  MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
713
796
  FROM live
714
- GROUP BY live.session_id`).all(...batch) ?? []) as { id: string; question_since: number | null; stuck_since: number | null }[]
797
+ GROUP BY live.session_id`, batch)) as { id: string; question_since: number | null; stuck_since: number | null }[]
715
798
  for (const row of rows) {
716
799
  const detail = details.get(row.id)
717
800
  if (!detail) continue
@@ -722,33 +805,41 @@ GROUP BY live.session_id`).all(...batch) ?? []) as { id: string; question_since:
722
805
  return details
723
806
  }
724
807
 
725
- function addTranscript(index: Map<string, string>, sid: string, data: string): void {
726
- try {
727
- const message = JSON.parse(data) as { text?: unknown; content?: unknown }
728
- const texts: string[] = []
729
- if (typeof message?.text === "string") texts.push(message.text)
730
- if (Array.isArray(message?.content)) {
731
- for (const part of message.content as { type?: unknown; text?: unknown }[]) {
732
- if (part?.type === "text" && typeof part.text === "string") texts.push(part.text)
733
- }
734
- }
735
- for (const text of texts) {
736
- const base = index.get(sid) ?? ""
737
- index.set(sid, `${base} ${text}`.trim())
738
- }
739
- } catch {
740
- // ignore malformed messages
741
- }
808
+ // Text parts only, extracted inside SQLite so tool and reasoning payloads never
809
+ // reach the caller. The CASE guards keep json_extract away from malformed rows
810
+ // and non-object parts, which SQL AND ordering alone would not.
811
+ function transcriptTextQuery(count: number): string {
812
+ const placeholders = Array.from({ length: count }, () => "?").join(",")
813
+ return `WITH msg AS (
814
+ SELECT session_id AS sid, seq, rowid AS rid, CASE WHEN json_valid(data) THEN data END AS doc
815
+ FROM session_message
816
+ WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
817
+ ), parts AS (
818
+ SELECT sid, seq, rid, 0 AS part,
819
+ CASE WHEN json_type(doc, '$.text') = 'text' THEN json_extract(doc, '$.text') END AS text
820
+ FROM msg
821
+ UNION ALL
822
+ SELECT msg.sid, msg.seq, msg.rid, 1 + item.key AS part,
823
+ CASE WHEN item.type = 'object' THEN
824
+ CASE WHEN json_extract(item.value, '$.type') = 'text' AND json_type(item.value, '$.text') = 'text'
825
+ THEN json_extract(item.value, '$.text') END
826
+ END AS text
827
+ FROM msg, json_each(msg.doc, '$.content') AS item
828
+ WHERE json_type(msg.doc, '$.content') = 'array'
829
+ )
830
+ SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`
742
831
  }
743
832
 
744
833
  // Index titles/directories immediately, then add transcript text progressively
745
834
  // in batches so the render thread can breathe. Covers every entry, not a fixed
746
- // newest window; `onProgress` lets the picker show coverage.
835
+ // newest window; `onProgress` lets the picker show coverage. Local extraction
836
+ // runs through `run` (the worker client) and returns text only.
747
837
  async function buildSearchIndex(
748
838
  api: TuiPluginApi,
749
839
  entries: Entry[],
750
840
  onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
751
841
  active = () => true,
842
+ run: DbRun = dbClient.run,
752
843
  ): Promise<Map<string, string>> {
753
844
  const index = new Map<string, string>()
754
845
  for (const entry of entries) {
@@ -762,29 +853,26 @@ async function buildSearchIndex(
762
853
  report(false)
763
854
 
764
855
  if (!active()) return index
765
- const db = await openTranscriptDb()
766
- if (db) {
767
- try {
768
- for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
769
- if (!active()) break
770
- const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
771
- const placeholders = batch.map(() => "?").join(",")
772
- const rows = (db
773
- .query(
774
- `SELECT session_id AS sid, data FROM session_message WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant') ORDER BY seq`,
775
- )
776
- .all(...batch) ?? []) as { sid: string; data: string }[]
777
- for (const row of rows) addTranscript(index, row.sid, row.data)
778
- for (const id of batch) remaining.delete(id)
779
- indexed += batch.length
780
- report(false)
781
- await nextTick()
856
+ try {
857
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
858
+ if (!active()) break
859
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
860
+ const rows = (await run(transcriptTextQuery(batch.length), batch)) as { sid: string; text: string }[]
861
+ if (!active()) break
862
+ const texts = new Map<string, string[]>()
863
+ for (const row of rows) {
864
+ const list = texts.get(row.sid)
865
+ if (list) list.push(row.text)
866
+ else texts.set(row.sid, [row.text])
782
867
  }
783
- } catch {
784
- // local store unavailable mid-walk; fall back to the remote path below
785
- } finally {
786
- db.close()
868
+ for (const [sid, list] of texts) index.set(sid, [index.get(sid) ?? "", ...list].join(" ").trim())
869
+ for (const id of batch) remaining.delete(id)
870
+ indexed += batch.length
871
+ report(false)
872
+ await nextTick()
787
873
  }
874
+ } catch {
875
+ // local store unavailable mid-walk; fall back to the remote path below
788
876
  }
789
877
 
790
878
  if (active() && remaining.size > 0) {
@@ -2227,21 +2315,14 @@ const tui: TuiPlugin = async (api) => {
2227
2315
  const [waitingCoverage, setWaitingCoverage] = createSignal<"loading" | "ready" | "unavailable">("loading")
2228
2316
  let alive = true
2229
2317
  void (async () => {
2230
- const db = await openTranscriptDb()
2231
- if (!db) {
2232
- if (alive) setWaitingCoverage("unavailable")
2233
- return
2234
- }
2235
2318
  try {
2236
- const ids = await queryWaitingIds(db)
2319
+ const ids = await queryWaitingIds(dbClient.run)
2237
2320
  if (alive) {
2238
2321
  setWaitingIds(new Set(ids.keys()))
2239
2322
  setWaitingCoverage("ready")
2240
2323
  }
2241
2324
  } catch {
2242
2325
  if (alive) setWaitingCoverage("unavailable")
2243
- } finally {
2244
- db.close()
2245
2326
  }
2246
2327
  })()
2247
2328
  const [pendingDelete, setPendingDelete] = createSignal<Entry>()
@@ -3059,16 +3140,12 @@ const tui: TuiPlugin = async (api) => {
3059
3140
  let items: { entry: Entry; reason: string; since?: number }[] = []
3060
3141
  try {
3061
3142
  const { entries } = await fetchEntries(api)
3062
- const db = await openTranscriptDb()
3063
- if (!db) {
3064
- api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
3065
- return
3066
- }
3067
3143
  let waiting: Map<string, WaitingDetail>
3068
3144
  try {
3069
- waiting = await queryWaitingDetails(db)
3070
- } finally {
3071
- db.close()
3145
+ waiting = await queryWaitingDetails(dbClient.run)
3146
+ } catch {
3147
+ api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
3148
+ return
3072
3149
  }
3073
3150
  items = entries
3074
3151
  .filter((entry) => waiting.has(entry.id))
@@ -3521,6 +3598,7 @@ export default Plugin.define({
3521
3598
  if (api.ui.dialog.open) api.ui.dialog.clear()
3522
3599
  disposeSlot()
3523
3600
  removeRootMouse?.()
3601
+ dbClient.close()
3524
3602
  }
3525
3603
  },
3526
3604
  })