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