opencode-sesh 2.1.6 → 2.1.8

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
@@ -182,7 +182,10 @@ when opencode exits, the picker reopens with the query preserved.
182
182
  scrolling inside; the picker remains the place for transcript search.
183
183
  Sessions waiting on the user (unanswered questions, stuck runs) pin a
184
184
  **Needs input** group above the directories, with the count in the heading —
185
- same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`. The query runs in
185
+ same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`. Message payloads are
186
+ scanned only for in-flight root sessions (`time_suspended` set, `time_busy` newer
187
+ than `time_idle`); databases lacking those columns (checked via
188
+ `pragma_table_info`) fall back to the full scan. The query runs in
186
189
  a blob Worker (`createDbClient`; failure means "local store unavailable"),
187
190
  off the TUI thread but still costing worker CPU every poll, so the shared
188
191
  poll runs it only while the sidebar is subscribed
@@ -197,7 +200,16 @@ when opencode exits, the picker reopens with the query preserved.
197
200
  - **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
198
201
  at `SESSION_MAX` and reported when truncated) rather than a fixed-size
199
202
  window, then indexes transcript text for **every** session in batches
200
- (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
203
+ (`TRANSCRIPT_BATCH`, also capped by `TRANSCRIPT_BATCH_BYTES` of message
204
+ payload; a single larger session forms its own batch) with bounded remote
205
+ concurrency (`REMOTE_CONCURRENCY`). Transcript text is cached in memory for
206
+ the plugin lifetime (never on disk, cleared on dispose); a worker signature
207
+ query (session timestamp, message count, latest message timestamp, payload
208
+ size; chunked by `SIGNATURE_CHUNK`) lets reopened pickers reuse unchanged
209
+ sessions, extract only new or changed ones, and prune vanished ones. Sessions
210
+ indexed through the remote path are not cached. If the signature query fails
211
+ (`octet_length` needs SQLite ≥ 3.43; macOS Bun uses the system SQLite),
212
+ extraction still runs locally, batched by count only, and caches nothing.
201
213
  Local extraction is one SQL statement per batch in the DB worker
202
214
  (`transcriptTextQuery`, JSON-guarded) that returns only `{sid, text}` rows,
203
215
  so tool payloads never reach the UI thread; if the worker rejects, sessions
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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.8] - 2026-11-02
9
+
10
+ ### Changed
11
+
12
+ - **tui:** Prefilter needs-input candidates by in-flight session state (#38)
13
+
14
+ ## [2.1.7] - 2026-11-02
15
+
16
+ ### Changed
17
+
18
+ - **tui:** Reuse unchanged transcript text and bound index batches by bytes (#37)
19
+
20
+ ### Fixed
21
+
22
+ - **tui:** Stop the DB worker and sidebar polls on plugin dispose (#36)
23
+
8
24
  ## [2.1.6] - 2026-11-02
9
25
 
10
26
  ### Changed
@@ -548,6 +564,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
548
564
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
549
565
  CI.
550
566
 
567
+ [2.1.8]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.7...v2.1.8
568
+ [2.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.6...v2.1.7
551
569
  [2.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.5...v2.1.6
552
570
  [2.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.4...v2.1.5
553
571
  [2.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.3...v2.1.4
@@ -15,6 +15,9 @@
15
15
  # (a long-running subagent/shell tool with recent activity is not stuck;
16
16
  # V2 resumes interrupted runs server-side, so `running` with no activity
17
17
  # means orphaned or awaiting approval).
18
+ # Only a turn in flight can be waiting (time_suspended is set while a turn
19
+ # runs), so message payloads are scanned only for those candidate sessions;
20
+ # databases without the in-flight columns scan every assistant message.
18
21
  #
19
22
  # Usage: sesh-waiting.sh [--json]
20
23
  # default prints a human table; --json prints [{id,reason,title,directory,updated}]
@@ -57,8 +60,23 @@ NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null ||
57
60
  STUCK_BEFORE=$((NOW_MS - 600000))
58
61
 
59
62
  # NEEDS_INPUT_SQL (shared heuristic — keep in sync with tui-plugins/sesh-panel/tui.tsx).
63
+ CANDIDATES=''
64
+ PREFILTER=''
65
+ HAS_COLUMNS=0
66
+ if [ -n "$SQLITE_BIN" ]; then
67
+ HAS_COLUMNS=$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT count(*) = 3 FROM pragma_table_info('session_v2') WHERE name IN ('time_busy', 'time_idle', 'time_suspended');" 2>/dev/null || echo 0)
68
+ fi
69
+ if [ "$HAS_COLUMNS" = 1 ]; then
70
+ CANDIDATES="cand AS (
71
+ SELECT id FROM session_v2
72
+ WHERE time_suspended IS NOT NULL AND COALESCE(parent_id, '') = '' AND COALESCE(time_archived, 0) = 0
73
+ AND time_busy > COALESCE(time_idle, 0)
74
+ ), "
75
+ PREFILTER="AND m.session_id IN (SELECT id FROM cand)"
76
+ fi
77
+
60
78
  read -r -d '' QUERY <<SQL || true
61
- WITH live AS (
79
+ WITH ${CANDIDATES}live AS (
62
80
  SELECT m.session_id AS session_id, m.seq AS seq,
63
81
  json_extract(c.value, '\$.name') AS name,
64
82
  json_extract(c.value, '\$.state.status') AS status,
@@ -68,6 +86,7 @@ WITH live AS (
68
86
  AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
69
87
  AND json_extract(c.value, '\$.type') = 'tool'
70
88
  AND json_extract(c.value, '\$.state.status') IN ('pending', 'running')
89
+ $PREFILTER
71
90
  )
72
91
  SELECT s.id AS id, s.directory AS directory, s.title AS title, s.time_updated AS updated,
73
92
  CASE WHEN EXISTS (
package/dist/tui.js CHANGED
@@ -605,6 +605,8 @@ async function fetchEntries(api, active = () => true) {
605
605
  }
606
606
  const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/;
607
607
  const TRANSCRIPT_BATCH = 64;
608
+ const TRANSCRIPT_BATCH_BYTES = 8 * 1024 * 1024;
609
+ const SIGNATURE_CHUNK = 500;
608
610
  const REMOTE_CONCURRENCY = 8;
609
611
  const nextTick = () => new Promise(resolve => setTimeout(resolve, 0));
610
612
  let resolvedDbPath;
@@ -663,9 +665,11 @@ function createDbWorker() {
663
665
  // Read-only queries against the local store, served by one lazily started
664
666
  // worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
665
667
  // a timeout or worker error discards the worker so the next call starts afresh.
668
+ // `close` is final: later calls reject and never start a worker.
666
669
  function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
667
670
  let worker;
668
671
  let nextId = 0;
672
+ let closed = false;
669
673
  const pending = new Map();
670
674
  const reset = error => {
671
675
  const dead = worker;
@@ -702,6 +706,10 @@ function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIME
702
706
  return created;
703
707
  };
704
708
  const run = (sql, params = []) => new Promise((resolve, reject) => {
709
+ if (closed) {
710
+ reject(new Error("local store closed"));
711
+ return;
712
+ }
705
713
  try {
706
714
  worker ??= start();
707
715
  } catch (error) {
@@ -729,10 +737,16 @@ function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIME
729
737
  });
730
738
  return {
731
739
  run,
732
- close: () => reset(new Error("local store closed"))
740
+ close: () => {
741
+ closed = true;
742
+ reset(new Error("local store closed"));
743
+ }
733
744
  };
734
745
  }
735
- const dbClient = createDbClient();
746
+
747
+ // Each plugin setup installs its own client and closes only that one on
748
+ // dispose: a reload reuses this module instance.
749
+ let dbClient = createDbClient();
736
750
  function createSessionPoll(api, refreshPins) {
737
751
  // Listener -> whether it reads `snapshot.waiting`. The needs-input query
738
752
  // costs worker CPU on every poll, so it only runs while such a listener exists.
@@ -850,9 +864,19 @@ ORDER BY lifetime_cost DESC`;
850
864
  // orphaned or awaiting approval). Server-independent on purpose: the TUI
851
865
  // sync layer only sees permission/question state for sessions owned by the
852
866
  // current server, which is invisible cross-project.
867
+ // Only a turn in flight can be waiting (time_suspended is set while a turn runs
868
+ // and a question/permission prompt keeps it running), so message payloads are
869
+ // scanned only for those candidate sessions. Databases without the in-flight
870
+ // columns scan every assistant message.
853
871
  const STUCK_AFTER_MS = 600000;
854
- const LIVE_TOOLS_CTE = sessionFilter => `
855
- WITH live AS (
872
+ const IN_FLIGHT_COLUMNS = ["time_busy", "time_idle", "time_suspended"];
873
+ const CANDIDATES_CTE = `cand AS (
874
+ SELECT id FROM session_v2
875
+ WHERE time_suspended IS NOT NULL AND parent_id IS NULL AND COALESCE(time_archived, 0) = 0
876
+ AND time_busy > COALESCE(time_idle, 0)
877
+ ), `;
878
+ const LIVE_TOOLS_CTE = (sessionFilter, prefilter = false) => `
879
+ WITH ${prefilter ? CANDIDATES_CTE : ""}live AS (
856
880
  SELECT m.session_id AS session_id, m.seq AS seq,
857
881
  json_extract(c.value, '$.name') AS name,
858
882
  json_extract(c.value, '$.state.status') AS status,
@@ -861,7 +885,7 @@ WITH live AS (
861
885
  WHERE m.type = 'assistant'
862
886
  AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
863
887
  AND json_extract(c.value, '$.type') = 'tool'
864
- AND json_extract(c.value, '$.state.status') IN ('pending', 'running')${sessionFilter}
888
+ AND json_extract(c.value, '$.state.status') IN ('pending', 'running')${sessionFilter}${prefilter ? "\n AND m.session_id IN (SELECT id FROM cand)" : ""}
865
889
  )`;
866
890
  const QUESTION_OPEN = `live.name = 'question'
867
891
  AND NOT EXISTS (
@@ -870,7 +894,7 @@ const QUESTION_OPEN = `live.name = 'question'
870
894
  const NO_RECENT_ACTIVITY = (sessionIdExpr, stuckBefore) => `NOT EXISTS (
871
895
  SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
872
896
  WHERE (f.id = ${sessionIdExpr} OR f.parent_id = ${sessionIdExpr}) AND r.time_updated >= ${stuckBefore})`;
873
- const NEEDS_INPUT_SQL = stuckBefore => `${LIVE_TOOLS_CTE("")}
897
+ const NEEDS_INPUT_SQL = (stuckBefore, prefilter = false) => `${LIVE_TOOLS_CTE("", prefilter)}
874
898
  SELECT s.id AS id,
875
899
  CASE WHEN EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
876
900
  THEN 'question' ELSE 'stuck' END AS reason
@@ -883,8 +907,23 @@ WHERE COALESCE(s.time_archived, 0) = 0
883
907
  WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
884
908
  AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
885
909
  ORDER BY s.time_updated DESC`;
910
+ const inFlightSupport = new WeakMap();
911
+ function hasInFlightColumns(run) {
912
+ let known = inFlightSupport.get(run);
913
+ if (!known) {
914
+ known = run("SELECT name FROM pragma_table_info('session_v2')").then(rows => {
915
+ const names = new Set(rows.map(row => row?.name));
916
+ return IN_FLIGHT_COLUMNS.every(name => names.has(name));
917
+ }, () => {
918
+ inFlightSupport.delete(run);
919
+ return false;
920
+ });
921
+ inFlightSupport.set(run, known);
922
+ }
923
+ return known;
924
+ }
886
925
  async function queryWaitingIds(run) {
887
- const rows = await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS));
926
+ const rows = await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS, await hasInFlightColumns(run)));
888
927
  const out = new Map();
889
928
  for (const row of rows) {
890
929
  if (typeof row?.id === "string" && SESSION_ID_PATTERN.test(row.id) && (row.reason === "question" || row.reason === "stuck")) {
@@ -948,11 +987,33 @@ function transcriptTextQuery(count) {
948
987
  SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`;
949
988
  }
950
989
 
990
+ // Transcript text per session for the plugin lifetime; memory only, so
991
+ // conversation text is never written anywhere. `sig` identifies the extracted
992
+ // state of the session (see transcriptSignatureQuery).
993
+ const transcriptCache = new Map();
994
+ const clearTranscriptCache = () => transcriptCache.clear();
995
+
996
+ // Cheap change detector mirroring the fzf extraction cache: message count and
997
+ // latest message timestamp, plus the payload size that bounds extraction
998
+ // batches. octet_length() reads the stored size, never the payload; length()
999
+ // would read every payload to count characters.
1000
+ function transcriptSignatureQuery(count) {
1001
+ const placeholders = Array.from({
1002
+ length: count
1003
+ }, () => "?").join(",");
1004
+ return `SELECT session_id AS sid, COUNT(*) AS n, MAX(time_updated) AS m, SUM(octet_length(data)) AS bytes
1005
+ FROM session_message
1006
+ WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
1007
+ GROUP BY session_id`;
1008
+ }
1009
+
951
1010
  // Index titles/directories immediately, then add transcript text progressively
952
1011
  // in batches so the render thread can breathe. Covers every entry, not a fixed
953
1012
  // 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) {
1013
+ // runs through `run` (the worker client) and returns text only; sessions whose
1014
+ // signature is unchanged reuse their cached text, and extraction batches are
1015
+ // bounded by session count and payload bytes.
1016
+ async function buildSearchIndex(api, entries, onProgress, active = () => true, run = dbClient.run, cache = transcriptCache) {
956
1017
  const index = new Map();
957
1018
  for (const entry of entries) {
958
1019
  index.set(entry.id, `${entry.title} ${entry.dir}`);
@@ -971,9 +1032,54 @@ async function buildSearchIndex(api, entries, onProgress, active = () => true, r
971
1032
  report(false);
972
1033
  if (!active()) return index;
973
1034
  try {
974
- for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
1035
+ // `entries` must be the full session list: sessions missing from it are pruned.
1036
+ const idSet = new Set(ids);
1037
+ for (const sid of cache.keys()) if (!idSet.has(sid)) cache.delete(sid);
1038
+ // Undefined when the signature query fails (octet_length needs SQLite
1039
+ // >= 3.43, and macOS Bun uses the system SQLite): extract everything
1040
+ // locally, batched by count only, and cache nothing.
1041
+ let signatures = new Map();
1042
+ try {
1043
+ for (let i = 0; i < ids.length; i += SIGNATURE_CHUNK) {
1044
+ const chunk = ids.slice(i, i + SIGNATURE_CHUNK);
1045
+ const rows = await run(transcriptSignatureQuery(chunk.length), chunk);
1046
+ if (!active()) return index;
1047
+ for (const row of rows) signatures.set(row.sid, {
1048
+ sig: `${row.n}:${row.m}:${row.bytes}`,
1049
+ bytes: row.bytes ?? 0
1050
+ });
1051
+ }
1052
+ } catch {
1053
+ if (!active()) return index;
1054
+ signatures = undefined;
1055
+ }
1056
+ const sigs = signatures && new Map(entries.map(entry => [entry.id, `${entry.updated}:${signatures.get(entry.id)?.sig ?? "0:0:0"}`]));
1057
+ const stale = [];
1058
+ for (const id of ids) {
1059
+ const hit = cache.get(id);
1060
+ if (hit && sigs && hit.sig === sigs.get(id)) {
1061
+ if (hit.text) index.set(id, `${index.get(id) ?? ""} ${hit.text}`.trim());
1062
+ remaining.delete(id);
1063
+ indexed += 1;
1064
+ } else stale.push(id);
1065
+ }
1066
+ if (indexed > 0) report(false);
1067
+ const batches = [];
1068
+ let current = [];
1069
+ let currentBytes = 0;
1070
+ for (const id of stale) {
1071
+ const bytes = signatures?.get(id)?.bytes ?? 0;
1072
+ if (current.length > 0 && (current.length >= TRANSCRIPT_BATCH || currentBytes + bytes > TRANSCRIPT_BATCH_BYTES)) {
1073
+ batches.push(current);
1074
+ current = [];
1075
+ currentBytes = 0;
1076
+ }
1077
+ current.push(id);
1078
+ currentBytes += bytes;
1079
+ }
1080
+ if (current.length > 0) batches.push(current);
1081
+ for (const batch of batches) {
975
1082
  if (!active()) break;
976
- const batch = ids.slice(i, i + TRANSCRIPT_BATCH);
977
1083
  const rows = await run(transcriptTextQuery(batch.length), batch);
978
1084
  if (!active()) break;
979
1085
  const texts = new Map();
@@ -981,8 +1087,15 @@ async function buildSearchIndex(api, entries, onProgress, active = () => true, r
981
1087
  const list = texts.get(row.sid);
982
1088
  if (list) list.push(row.text);else texts.set(row.sid, [row.text]);
983
1089
  }
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);
1090
+ for (const id of batch) {
1091
+ const list = texts.get(id) ?? [];
1092
+ if (sigs) cache.set(id, {
1093
+ sig: sigs.get(id),
1094
+ text: list.join(" ").trim()
1095
+ });
1096
+ if (list.length > 0) index.set(id, [index.get(id) ?? "", ...list].join(" ").trim());
1097
+ remaining.delete(id);
1098
+ }
986
1099
  indexed += batch.length;
987
1100
  report(false);
988
1101
  await nextTick();
@@ -4306,7 +4419,11 @@ function createApi(ctx) {
4306
4419
  const [dialogOpen, setDialogOpen] = createSignal(false);
4307
4420
  let sourceTheme;
4308
4421
  let currentTheme;
4422
+ const slotDisposers = [];
4309
4423
  return {
4424
+ dispose() {
4425
+ for (const dispose of slotDisposers.splice(0)) dispose();
4426
+ },
4310
4427
  theme: {
4311
4428
  get current() {
4312
4429
  if (sourceTheme !== ctx.theme) {
@@ -4441,20 +4558,20 @@ function createApi(ctx) {
4441
4558
  slots: {
4442
4559
  register(cfg) {
4443
4560
  if (cfg.slots.sidebar_content && ctx.options?.showSidebar !== false) {
4444
- ctx.ui.slot({
4561
+ slotDisposers.push(ctx.ui.slot({
4445
4562
  ...(ctx.options?.sidebarPlacement === "prepend" ? {
4446
4563
  prepend: "sidebar.content"
4447
4564
  } : {
4448
4565
  append: "sidebar.content"
4449
4566
  }),
4450
4567
  render: cfg.slots.sidebar_content
4451
- });
4568
+ }));
4452
4569
  }
4453
4570
  if (cfg.slots.home_bottom) {
4454
- ctx.ui.slot({
4571
+ slotDisposers.push(ctx.ui.slot({
4455
4572
  prepend: "home.footer",
4456
4573
  render: cfg.slots.home_bottom
4457
- });
4574
+ }));
4458
4575
  }
4459
4576
  }
4460
4577
  },
@@ -4568,6 +4685,7 @@ function createApi(ctx) {
4568
4685
  export default Plugin.define({
4569
4686
  id: "sesh-panel",
4570
4687
  async setup(ctx) {
4688
+ const client = dbClient = createDbClient();
4571
4689
  const api = createApi(ctx);
4572
4690
  let initialized = false;
4573
4691
  const disposeSlot = ctx.ui.slot({
@@ -4584,8 +4702,10 @@ export default Plugin.define({
4584
4702
  return () => {
4585
4703
  if (api.ui.dialog.open) api.ui.dialog.clear();
4586
4704
  disposeSlot();
4705
+ api.dispose();
4587
4706
  removeRootMouse?.();
4588
- dbClient.close();
4707
+ clearTranscriptCache();
4708
+ client.close();
4589
4709
  };
4590
4710
  }
4591
4711
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
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",
@@ -503,6 +503,8 @@ async function fetchEntries(api: TuiPluginApi, active = () => true): Promise<Ent
503
503
 
504
504
  const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
505
505
  const TRANSCRIPT_BATCH = 64
506
+ const TRANSCRIPT_BATCH_BYTES = 8 * 1024 * 1024
507
+ const SIGNATURE_CHUNK = 500
506
508
  const REMOTE_CONCURRENCY = 8
507
509
 
508
510
  type IndexProgress = { indexed: number; total: number; complete: boolean }
@@ -565,10 +567,12 @@ function createDbWorker(): any {
565
567
  // Read-only queries against the local store, served by one lazily started
566
568
  // worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
567
569
  // a timeout or worker error discards the worker so the next call starts afresh.
570
+ // `close` is final: later calls reject and never start a worker.
568
571
  function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
569
572
  type Pending = { resolve: (rows: unknown[]) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
570
573
  let worker: any
571
574
  let nextId = 0
575
+ let closed = false
572
576
  const pending = new Map<number, Pending>()
573
577
  const reset = (error: Error) => {
574
578
  const dead = worker
@@ -601,6 +605,10 @@ function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB
601
605
  }
602
606
  const run: DbRun = (sql, params = []) =>
603
607
  new Promise((resolve, reject) => {
608
+ if (closed) {
609
+ reject(new Error("local store closed"))
610
+ return
611
+ }
604
612
  try {
605
613
  worker ??= start()
606
614
  } catch (error) {
@@ -617,10 +625,12 @@ function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB
617
625
  reset(error as Error)
618
626
  }
619
627
  })
620
- return { run, close: () => reset(new Error("local store closed")) }
628
+ return { run, close: () => { closed = true; reset(new Error("local store closed")) } }
621
629
  }
622
630
 
623
- const dbClient = createDbClient()
631
+ // Each plugin setup installs its own client and closes only that one on
632
+ // dispose: a reload reuses this module instance.
633
+ let dbClient = createDbClient()
624
634
 
625
635
  type SessionSnapshot = { entries: Entry[]; waiting: Map<string, string>; failed: boolean }
626
636
 
@@ -723,9 +733,19 @@ ORDER BY lifetime_cost DESC`
723
733
  // orphaned or awaiting approval). Server-independent on purpose: the TUI
724
734
  // sync layer only sees permission/question state for sessions owned by the
725
735
  // current server, which is invisible cross-project.
736
+ // Only a turn in flight can be waiting (time_suspended is set while a turn runs
737
+ // and a question/permission prompt keeps it running), so message payloads are
738
+ // scanned only for those candidate sessions. Databases without the in-flight
739
+ // columns scan every assistant message.
726
740
  const STUCK_AFTER_MS = 600000
727
- const LIVE_TOOLS_CTE = (sessionFilter: string) => `
728
- WITH live AS (
741
+ const IN_FLIGHT_COLUMNS = ["time_busy", "time_idle", "time_suspended"]
742
+ const CANDIDATES_CTE = `cand AS (
743
+ SELECT id FROM session_v2
744
+ WHERE time_suspended IS NOT NULL AND parent_id IS NULL AND COALESCE(time_archived, 0) = 0
745
+ AND time_busy > COALESCE(time_idle, 0)
746
+ ), `
747
+ const LIVE_TOOLS_CTE = (sessionFilter: string, prefilter = false) => `
748
+ WITH ${prefilter ? CANDIDATES_CTE : ""}live AS (
729
749
  SELECT m.session_id AS session_id, m.seq AS seq,
730
750
  json_extract(c.value, '$.name') AS name,
731
751
  json_extract(c.value, '$.state.status') AS status,
@@ -734,7 +754,9 @@ WITH live AS (
734
754
  WHERE m.type = 'assistant'
735
755
  AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
736
756
  AND json_extract(c.value, '$.type') = 'tool'
737
- AND json_extract(c.value, '$.state.status') IN ('pending', 'running')${sessionFilter}
757
+ AND json_extract(c.value, '$.state.status') IN ('pending', 'running')${sessionFilter}${
758
+ prefilter ? "\n AND m.session_id IN (SELECT id FROM cand)" : ""
759
+ }
738
760
  )`
739
761
  const QUESTION_OPEN = `live.name = 'question'
740
762
  AND NOT EXISTS (
@@ -743,7 +765,7 @@ const QUESTION_OPEN = `live.name = 'question'
743
765
  const NO_RECENT_ACTIVITY = (sessionIdExpr: string, stuckBefore: number) => `NOT EXISTS (
744
766
  SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
745
767
  WHERE (f.id = ${sessionIdExpr} OR f.parent_id = ${sessionIdExpr}) AND r.time_updated >= ${stuckBefore})`
746
- const NEEDS_INPUT_SQL = (stuckBefore: number) => `${LIVE_TOOLS_CTE("")}
768
+ const NEEDS_INPUT_SQL = (stuckBefore: number, prefilter = false) => `${LIVE_TOOLS_CTE("", prefilter)}
747
769
  SELECT s.id AS id,
748
770
  CASE WHEN EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
749
771
  THEN 'question' ELSE 'stuck' END AS reason
@@ -757,8 +779,27 @@ WHERE COALESCE(s.time_archived, 0) = 0
757
779
  AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
758
780
  ORDER BY s.time_updated DESC`
759
781
 
782
+ const inFlightSupport = new WeakMap<DbRun, Promise<boolean>>()
783
+ function hasInFlightColumns(run: DbRun): Promise<boolean> {
784
+ let known = inFlightSupport.get(run)
785
+ if (!known) {
786
+ known = run("SELECT name FROM pragma_table_info('session_v2')").then(
787
+ (rows) => {
788
+ const names = new Set((rows as { name?: unknown }[]).map((row) => row?.name))
789
+ return IN_FLIGHT_COLUMNS.every((name) => names.has(name))
790
+ },
791
+ () => {
792
+ inFlightSupport.delete(run)
793
+ return false
794
+ },
795
+ )
796
+ inFlightSupport.set(run, known)
797
+ }
798
+ return known
799
+ }
800
+
760
801
  async function queryWaitingIds(run: DbRun): Promise<Map<string, string>> {
761
- const rows = (await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS))) as {
802
+ const rows = (await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS, await hasInFlightColumns(run)))) as {
762
803
  id: unknown
763
804
  reason: unknown
764
805
  }[]
@@ -830,16 +871,37 @@ function transcriptTextQuery(count: number): string {
830
871
  SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`
831
872
  }
832
873
 
874
+ // Transcript text per session for the plugin lifetime; memory only, so
875
+ // conversation text is never written anywhere. `sig` identifies the extracted
876
+ // state of the session (see transcriptSignatureQuery).
877
+ const transcriptCache = new Map<string, { sig: string; text: string }>()
878
+ const clearTranscriptCache = () => transcriptCache.clear()
879
+
880
+ // Cheap change detector mirroring the fzf extraction cache: message count and
881
+ // latest message timestamp, plus the payload size that bounds extraction
882
+ // batches. octet_length() reads the stored size, never the payload; length()
883
+ // would read every payload to count characters.
884
+ function transcriptSignatureQuery(count: number): string {
885
+ const placeholders = Array.from({ length: count }, () => "?").join(",")
886
+ return `SELECT session_id AS sid, COUNT(*) AS n, MAX(time_updated) AS m, SUM(octet_length(data)) AS bytes
887
+ FROM session_message
888
+ WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
889
+ GROUP BY session_id`
890
+ }
891
+
833
892
  // Index titles/directories immediately, then add transcript text progressively
834
893
  // in batches so the render thread can breathe. Covers every entry, not a fixed
835
894
  // newest window; `onProgress` lets the picker show coverage. Local extraction
836
- // runs through `run` (the worker client) and returns text only.
895
+ // runs through `run` (the worker client) and returns text only; sessions whose
896
+ // signature is unchanged reuse their cached text, and extraction batches are
897
+ // bounded by session count and payload bytes.
837
898
  async function buildSearchIndex(
838
899
  api: TuiPluginApi,
839
900
  entries: Entry[],
840
901
  onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
841
902
  active = () => true,
842
903
  run: DbRun = dbClient.run,
904
+ cache = transcriptCache,
843
905
  ): Promise<Map<string, string>> {
844
906
  const index = new Map<string, string>()
845
907
  for (const entry of entries) {
@@ -854,9 +916,53 @@ async function buildSearchIndex(
854
916
 
855
917
  if (!active()) return index
856
918
  try {
857
- for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
919
+ // `entries` must be the full session list: sessions missing from it are pruned.
920
+ const idSet = new Set(ids)
921
+ for (const sid of cache.keys()) if (!idSet.has(sid)) cache.delete(sid)
922
+ // Undefined when the signature query fails (octet_length needs SQLite
923
+ // >= 3.43, and macOS Bun uses the system SQLite): extract everything
924
+ // locally, batched by count only, and cache nothing.
925
+ let signatures: Map<string, { sig: string; bytes: number }> | undefined = new Map()
926
+ try {
927
+ for (let i = 0; i < ids.length; i += SIGNATURE_CHUNK) {
928
+ const chunk = ids.slice(i, i + SIGNATURE_CHUNK)
929
+ const rows = (await run(transcriptSignatureQuery(chunk.length), chunk)) as { sid: string; n: number; m: number; bytes: number }[]
930
+ if (!active()) return index
931
+ for (const row of rows) signatures.set(row.sid, { sig: `${row.n}:${row.m}:${row.bytes}`, bytes: row.bytes ?? 0 })
932
+ }
933
+ } catch {
934
+ if (!active()) return index
935
+ signatures = undefined
936
+ }
937
+ const sigs = signatures && new Map(entries.map((entry) => [entry.id, `${entry.updated}:${signatures!.get(entry.id)?.sig ?? "0:0:0"}`]))
938
+ const stale: string[] = []
939
+ for (const id of ids) {
940
+ const hit = cache.get(id)
941
+ if (hit && sigs && hit.sig === sigs.get(id)) {
942
+ if (hit.text) index.set(id, `${index.get(id) ?? ""} ${hit.text}`.trim())
943
+ remaining.delete(id)
944
+ indexed += 1
945
+ } else stale.push(id)
946
+ }
947
+ if (indexed > 0) report(false)
948
+
949
+ const batches: string[][] = []
950
+ let current: string[] = []
951
+ let currentBytes = 0
952
+ for (const id of stale) {
953
+ const bytes = signatures?.get(id)?.bytes ?? 0
954
+ if (current.length > 0 && (current.length >= TRANSCRIPT_BATCH || currentBytes + bytes > TRANSCRIPT_BATCH_BYTES)) {
955
+ batches.push(current)
956
+ current = []
957
+ currentBytes = 0
958
+ }
959
+ current.push(id)
960
+ currentBytes += bytes
961
+ }
962
+ if (current.length > 0) batches.push(current)
963
+
964
+ for (const batch of batches) {
858
965
  if (!active()) break
859
- const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
860
966
  const rows = (await run(transcriptTextQuery(batch.length), batch)) as { sid: string; text: string }[]
861
967
  if (!active()) break
862
968
  const texts = new Map<string, string[]>()
@@ -865,8 +971,12 @@ async function buildSearchIndex(
865
971
  if (list) list.push(row.text)
866
972
  else texts.set(row.sid, [row.text])
867
973
  }
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)
974
+ for (const id of batch) {
975
+ const list = texts.get(id) ?? []
976
+ if (sigs) cache.set(id, { sig: sigs.get(id)!, text: list.join(" ").trim() })
977
+ if (list.length > 0) index.set(id, [index.get(id) ?? "", ...list].join(" ").trim())
978
+ remaining.delete(id)
979
+ }
870
980
  indexed += batch.length
871
981
  report(false)
872
982
  await nextTick()
@@ -3352,7 +3462,11 @@ function createApi(ctx: Plugin.Context) {
3352
3462
  const [dialogOpen, setDialogOpen] = createSignal(false)
3353
3463
  let sourceTheme: Plugin.Context["theme"] | undefined
3354
3464
  let currentTheme: ReturnType<typeof adaptTheme>
3465
+ const slotDisposers: Array<() => void> = []
3355
3466
  return {
3467
+ dispose() {
3468
+ for (const dispose of slotDisposers.splice(0)) dispose()
3469
+ },
3356
3470
  theme: {
3357
3471
  get current() {
3358
3472
  if (sourceTheme !== ctx.theme) {
@@ -3476,18 +3590,18 @@ function createApi(ctx: Plugin.Context) {
3476
3590
  slots: {
3477
3591
  register(cfg: { order?: number; slots: Record<string, () => any> }) {
3478
3592
  if (cfg.slots.sidebar_content && ctx.options?.showSidebar !== false) {
3479
- ctx.ui.slot({
3593
+ slotDisposers.push(ctx.ui.slot({
3480
3594
  ...(ctx.options?.sidebarPlacement === "prepend"
3481
3595
  ? { prepend: "sidebar.content" as const }
3482
3596
  : { append: "sidebar.content" as const }),
3483
3597
  render: cfg.slots.sidebar_content as any,
3484
- })
3598
+ }))
3485
3599
  }
3486
3600
  if (cfg.slots.home_bottom) {
3487
- ctx.ui.slot({
3601
+ slotDisposers.push(ctx.ui.slot({
3488
3602
  prepend: "home.footer",
3489
3603
  render: cfg.slots.home_bottom as any,
3490
- })
3604
+ }))
3491
3605
  }
3492
3606
  },
3493
3607
  },
@@ -3581,6 +3695,7 @@ function createApi(ctx: Plugin.Context) {
3581
3695
  export default Plugin.define({
3582
3696
  id: "sesh-panel",
3583
3697
  async setup(ctx) {
3698
+ const client = dbClient = createDbClient()
3584
3699
  const api = createApi(ctx)
3585
3700
  let initialized = false
3586
3701
  const disposeSlot = ctx.ui.slot({
@@ -3597,8 +3712,10 @@ export default Plugin.define({
3597
3712
  return () => {
3598
3713
  if (api.ui.dialog.open) api.ui.dialog.clear()
3599
3714
  disposeSlot()
3715
+ api.dispose()
3600
3716
  removeRootMouse?.()
3601
- dbClient.close()
3717
+ clearTranscriptCache()
3718
+ client.close()
3602
3719
  }
3603
3720
  },
3604
3721
  })