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 +14 -2
- package/CHANGELOG.md +18 -0
- package/bin/sesh-waiting.sh +20 -1
- package/dist/tui.js +138 -18
- package/package.json +1 -1
- package/tui-plugins/sesh-panel/tui.tsx +134 -17
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`.
|
|
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
|
|
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
|
package/bin/sesh-waiting.sh
CHANGED
|
@@ -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: () =>
|
|
740
|
+
close: () => {
|
|
741
|
+
closed = true;
|
|
742
|
+
reset(new Error("local store closed"));
|
|
743
|
+
}
|
|
733
744
|
};
|
|
734
745
|
}
|
|
735
|
-
|
|
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
|
|
855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
985
|
-
|
|
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
|
-
|
|
4707
|
+
clearTranscriptCache();
|
|
4708
|
+
client.close();
|
|
4589
4709
|
};
|
|
4590
4710
|
}
|
|
4591
4711
|
});
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
728
|
-
|
|
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
|
-
|
|
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
|
|
869
|
-
|
|
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
|
-
|
|
3717
|
+
clearTranscriptCache()
|
|
3718
|
+
client.close()
|
|
3602
3719
|
}
|
|
3603
3720
|
},
|
|
3604
3721
|
})
|