opencode-sesh 2.1.3 → 2.1.5

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,7 +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`.
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.
185
190
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
186
191
  project/directory, recency-ordered, resume via
187
192
  `api.route.navigate("session", …)`. `option+o` and the command palette open it;
@@ -266,6 +271,9 @@ when opencode exits, the picker reopens with the query preserved.
266
271
  and Python 3).
267
272
  - `bun run test:tui-home` — real OpenTUI render regressions for the home list
268
273
  (also run by `bun run test`).
274
+ - `bun run test:tui-worker` — starts the real blob Worker against a bun:sqlite
275
+ fixture; asserts results round-trip and the main thread keeps ticking during
276
+ a slow query (also run by `bun run test`).
269
277
  - `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
270
278
  - `bun run lint:sh` — `bash -n` on every script.
271
279
  - `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.5] - 2026-11-02
9
+
10
+ ### Fixed
11
+
12
+ - **tui:** Run needs-input SQLite reads in a worker (#34)
13
+
14
+ ## [2.1.4] - 2026-11-02
15
+
16
+ ### Fixed
17
+
18
+ - **tui:** Skip the needs-input query when no sidebar consumes it (#33)
19
+
8
20
  ## [2.1.3] - 2026-11-02
9
21
 
10
22
  ### Fixed
@@ -530,6 +542,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
530
542
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
531
543
  CI.
532
544
 
545
+ [2.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.4...v2.1.5
546
+ [2.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.3...v2.1.4
533
547
  [2.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.2...v2.1.3
534
548
  [2.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.1...v2.1.2
535
549
  [2.1.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.0...v2.1.1
package/dist/tui.js CHANGED
@@ -636,8 +636,107 @@ 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
- const listeners = new Set();
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.
739
+ const listeners = new Map();
641
740
  let snapshot = {
642
741
  entries: [],
643
742
  waiting: new Map(),
@@ -645,7 +744,11 @@ function createSessionPoll(api, refreshPins) {
645
744
  };
646
745
  let clock;
647
746
  let inFlight;
747
+ // Whether `snapshot.waiting` came from a poll that ran the query; a poll
748
+ // without waiting listeners clears it so a later subscriber never sees a stale set.
749
+ let waitingLoaded = false;
648
750
  const active = () => listeners.size > 0;
751
+ const wantsWaiting = () => [...listeners.values()].some(Boolean);
649
752
  const load = () => {
650
753
  if (inFlight || !active()) return;
651
754
  inFlight = (async () => {
@@ -671,30 +774,40 @@ function createSessionPoll(api, refreshPins) {
671
774
  };
672
775
  }
673
776
  if (!active()) return;
674
- const db = await openTranscriptDb();
675
- if (db) {
777
+ if (wantsWaiting()) {
676
778
  try {
677
- if (active()) snapshot = {
678
- ...snapshot,
679
- waiting: await queryWaitingIds(db)
680
- };
779
+ const waiting = await queryWaitingIds(dbClient.run);
780
+ if (wantsWaiting()) {
781
+ snapshot = {
782
+ ...snapshot,
783
+ waiting
784
+ };
785
+ waitingLoaded = true;
786
+ }
681
787
  } catch {
682
788
  // Keep the previous waiting set when the local store is unavailable.
683
- } finally {
684
- db.close();
685
789
  }
790
+ } else if (!wantsWaiting()) {
791
+ snapshot = {
792
+ ...snapshot,
793
+ waiting: new Map()
794
+ };
795
+ waitingLoaded = false;
686
796
  }
687
- for (const listener of listeners) listener(snapshot);
797
+ for (const listener of [...listeners.keys()]) listener(snapshot);
688
798
  })().finally(() => {
689
799
  inFlight = undefined;
690
800
  });
691
801
  };
692
- return listener => {
693
- listeners.add(listener);
802
+ return (listener, options = {}) => {
803
+ const needsWaiting = options.waiting === true;
804
+ listeners.set(listener, needsWaiting);
694
805
  listener(snapshot);
695
806
  if (!clock) {
696
807
  load();
697
808
  clock = setInterval(load, POLL_MS);
809
+ } else if (needsWaiting && !waitingLoaded) {
810
+ load();
698
811
  }
699
812
  return () => {
700
813
  listeners.delete(listener);
@@ -770,8 +883,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
770
883
  WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
771
884
  AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
772
885
  ORDER BY s.time_updated DESC`;
773
- async function queryWaitingIds(db) {
774
- 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));
775
888
  const out = new Map();
776
889
  for (const row of rows) {
777
890
  if (typeof row?.id === "string" && SESSION_ID_PATTERN.test(row.id) && (row.reason === "question" || row.reason === "stuck")) {
@@ -783,8 +896,8 @@ async function queryWaitingIds(db) {
783
896
  // The shared needs-input query decides *which* sessions are waiting. Read the
784
897
  // oldest still-unanswered question or stuck tool for each result so triage can
785
898
  // show how long it has waited, rather than its last (unrelated) update time.
786
- async function queryWaitingDetails(db) {
787
- const reasons = await queryWaitingIds(db);
899
+ async function queryWaitingDetails(run) {
900
+ const reasons = await queryWaitingIds(run);
788
901
  const details = new Map([...reasons].map(([id, reason]) => [id, {
789
902
  reason: reason
790
903
  }]));
@@ -792,12 +905,12 @@ async function queryWaitingDetails(db) {
792
905
  const cutoff = Date.now() - STUCK_AFTER_MS;
793
906
  for (let i = 0; i < ids.length; i += 200) {
794
907
  const batch = ids.slice(i, i + 200);
795
- 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(",")})`)}
796
909
  SELECT live.session_id AS id,
797
910
  MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
798
911
  MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
799
912
  FROM live
800
- GROUP BY live.session_id`).all(...batch) ?? [];
913
+ GROUP BY live.session_id`, batch);
801
914
  for (const row of rows) {
802
915
  const detail = details.get(row.id);
803
916
  if (!detail) continue;
@@ -1212,6 +1325,8 @@ function SidebarSessions(props) {
1212
1325
  setEntries(snapshot.entries);
1213
1326
  setLoadFailed(snapshot.failed);
1214
1327
  setWaiting(snapshot.waiting);
1328
+ }, {
1329
+ waiting: true
1215
1330
  })));
1216
1331
  const filteredEntries = createMemo(() => {
1217
1332
  const q = query().trim().toLowerCase();
@@ -2729,21 +2844,14 @@ const tui = async api => {
2729
2844
  const [waitingCoverage, setWaitingCoverage] = createSignal("loading");
2730
2845
  let alive = true;
2731
2846
  void (async () => {
2732
- const db = await openTranscriptDb();
2733
- if (!db) {
2734
- if (alive) setWaitingCoverage("unavailable");
2735
- return;
2736
- }
2737
2847
  try {
2738
- const ids = await queryWaitingIds(db);
2848
+ const ids = await queryWaitingIds(dbClient.run);
2739
2849
  if (alive) {
2740
2850
  setWaitingIds(new Set(ids.keys()));
2741
2851
  setWaitingCoverage("ready");
2742
2852
  }
2743
2853
  } catch {
2744
2854
  if (alive) setWaitingCoverage("unavailable");
2745
- } finally {
2746
- db.close();
2747
2855
  }
2748
2856
  })();
2749
2857
  const [pendingDelete, setPendingDelete] = createSignal();
@@ -3904,20 +4012,16 @@ const tui = async api => {
3904
4012
  const {
3905
4013
  entries
3906
4014
  } = await fetchEntries(api);
3907
- const db = await openTranscriptDb();
3908
- if (!db) {
4015
+ let waiting;
4016
+ try {
4017
+ waiting = await queryWaitingDetails(dbClient.run);
4018
+ } catch {
3909
4019
  api.ui.toast({
3910
4020
  message: "Needs-input data unavailable",
3911
4021
  variant: "error"
3912
4022
  });
3913
4023
  return;
3914
4024
  }
3915
- let waiting;
3916
- try {
3917
- waiting = await queryWaitingDetails(db);
3918
- } finally {
3919
- db.close();
3920
- }
3921
4025
  items = entries.filter(entry => waiting.has(entry.id)).map(entry => ({
3922
4026
  entry,
3923
4027
  reason: waiting.get(entry.id)?.reason === "question" ? "awaiting answer" : "run stuck",
@@ -4471,6 +4575,7 @@ export default Plugin.define({
4471
4575
  if (api.ui.dialog.open) api.ui.dialog.clear();
4472
4576
  disposeSlot();
4473
4577
  removeRootMouse?.();
4578
+ dbClient.close();
4474
4579
  };
4475
4580
  }
4476
4581
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
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,14 +537,105 @@ 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
- const listeners = new Set<(snapshot: SessionSnapshot) => void>()
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.
630
+ const listeners = new Map<(snapshot: SessionSnapshot) => void, boolean>()
544
631
  let snapshot: SessionSnapshot = { entries: [], waiting: new Map(), failed: false }
545
632
  let clock: ReturnType<typeof setInterval> | undefined
546
633
  let inFlight: Promise<void> | undefined
634
+ // Whether `snapshot.waiting` came from a poll that ran the query; a poll
635
+ // without waiting listeners clears it so a later subscriber never sees a stale set.
636
+ let waitingLoaded = false
547
637
  const active = () => listeners.size > 0
638
+ const wantsWaiting = () => [...listeners.values()].some(Boolean)
548
639
  const load = () => {
549
640
  if (inFlight || !active()) return
550
641
  inFlight = (async () => {
@@ -563,25 +654,32 @@ function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
563
654
  snapshot = { ...snapshot, failed: true }
564
655
  }
565
656
  if (!active()) return
566
- const db = await openTranscriptDb()
567
- if (db) {
657
+ if (wantsWaiting()) {
568
658
  try {
569
- if (active()) snapshot = { ...snapshot, waiting: await queryWaitingIds(db) }
659
+ const waiting = await queryWaitingIds(dbClient.run)
660
+ if (wantsWaiting()) {
661
+ snapshot = { ...snapshot, waiting }
662
+ waitingLoaded = true
663
+ }
570
664
  } catch {
571
665
  // Keep the previous waiting set when the local store is unavailable.
572
- } finally {
573
- db.close()
574
666
  }
667
+ } else if (!wantsWaiting()) {
668
+ snapshot = { ...snapshot, waiting: new Map() }
669
+ waitingLoaded = false
575
670
  }
576
- for (const listener of listeners) listener(snapshot)
671
+ for (const listener of [...listeners.keys()]) listener(snapshot)
577
672
  })().finally(() => { inFlight = undefined })
578
673
  }
579
- return (listener: (snapshot: SessionSnapshot) => void) => {
580
- listeners.add(listener)
674
+ return (listener: (snapshot: SessionSnapshot) => void, options: { waiting?: boolean } = {}) => {
675
+ const needsWaiting = options.waiting === true
676
+ listeners.set(listener, needsWaiting)
581
677
  listener(snapshot)
582
678
  if (!clock) {
583
679
  load()
584
680
  clock = setInterval(load, POLL_MS)
681
+ } else if (needsWaiting && !waitingLoaded) {
682
+ load()
585
683
  }
586
684
  return () => {
587
685
  listeners.delete(listener)
@@ -659,8 +757,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
659
757
  AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
660
758
  ORDER BY s.time_updated DESC`
661
759
 
662
- async function queryWaitingIds(db: any): Promise<Map<string, string>> {
663
- 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 {
664
762
  id: unknown
665
763
  reason: unknown
666
764
  }[]
@@ -682,8 +780,8 @@ type WaitingDetail = { reason: "question" | "stuck"; since?: number }
682
780
  // The shared needs-input query decides *which* sessions are waiting. Read the
683
781
  // oldest still-unanswered question or stuck tool for each result so triage can
684
782
  // show how long it has waited, rather than its last (unrelated) update time.
685
- async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>> {
686
- const reasons = await queryWaitingIds(db)
783
+ async function queryWaitingDetails(run: DbRun): Promise<Map<string, WaitingDetail>> {
784
+ const reasons = await queryWaitingIds(run)
687
785
  const details = new Map<string, WaitingDetail>(
688
786
  [...reasons].map(([id, reason]) => [id, { reason: reason as WaitingDetail["reason"] }]),
689
787
  )
@@ -691,12 +789,12 @@ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>>
691
789
  const cutoff = Date.now() - STUCK_AFTER_MS
692
790
  for (let i = 0; i < ids.length; i += 200) {
693
791
  const batch = ids.slice(i, i + 200)
694
- 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(",")})`)}
695
793
  SELECT live.session_id AS id,
696
794
  MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
697
795
  MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
698
796
  FROM live
699
- 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 }[]
700
798
  for (const row of rows) {
701
799
  const detail = details.get(row.id)
702
800
  if (!detail) continue
@@ -1098,7 +1196,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1098
1196
  setEntries(snapshot.entries)
1099
1197
  setLoadFailed(snapshot.failed)
1100
1198
  setWaiting(snapshot.waiting)
1101
- })))
1199
+ }, { waiting: true })))
1102
1200
 
1103
1201
  const filteredEntries = createMemo(() => {
1104
1202
  const q = query().trim().toLowerCase()
@@ -2212,21 +2310,14 @@ const tui: TuiPlugin = async (api) => {
2212
2310
  const [waitingCoverage, setWaitingCoverage] = createSignal<"loading" | "ready" | "unavailable">("loading")
2213
2311
  let alive = true
2214
2312
  void (async () => {
2215
- const db = await openTranscriptDb()
2216
- if (!db) {
2217
- if (alive) setWaitingCoverage("unavailable")
2218
- return
2219
- }
2220
2313
  try {
2221
- const ids = await queryWaitingIds(db)
2314
+ const ids = await queryWaitingIds(dbClient.run)
2222
2315
  if (alive) {
2223
2316
  setWaitingIds(new Set(ids.keys()))
2224
2317
  setWaitingCoverage("ready")
2225
2318
  }
2226
2319
  } catch {
2227
2320
  if (alive) setWaitingCoverage("unavailable")
2228
- } finally {
2229
- db.close()
2230
2321
  }
2231
2322
  })()
2232
2323
  const [pendingDelete, setPendingDelete] = createSignal<Entry>()
@@ -3044,16 +3135,12 @@ const tui: TuiPlugin = async (api) => {
3044
3135
  let items: { entry: Entry; reason: string; since?: number }[] = []
3045
3136
  try {
3046
3137
  const { entries } = await fetchEntries(api)
3047
- const db = await openTranscriptDb()
3048
- if (!db) {
3049
- api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
3050
- return
3051
- }
3052
3138
  let waiting: Map<string, WaitingDetail>
3053
3139
  try {
3054
- waiting = await queryWaitingDetails(db)
3055
- } finally {
3056
- db.close()
3140
+ waiting = await queryWaitingDetails(dbClient.run)
3141
+ } catch {
3142
+ api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
3143
+ return
3057
3144
  }
3058
3145
  items = entries
3059
3146
  .filter((entry) => waiting.has(entry.id))
@@ -3506,6 +3593,7 @@ export default Plugin.define({
3506
3593
  if (api.ui.dialog.open) api.ui.dialog.clear()
3507
3594
  disposeSlot()
3508
3595
  removeRootMouse?.()
3596
+ dbClient.close()
3509
3597
  }
3510
3598
  },
3511
3599
  })