opencode-sesh 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -1
- package/CHANGELOG.md +14 -0
- package/dist/tui.js +139 -34
- package/package.json +3 -2
- package/tui-plugins/sesh-panel/tui.tsx +120 -32
package/AGENTS.md
CHANGED
|
@@ -31,6 +31,7 @@ bun run test # build + fixture-DB regression suite — must stay green
|
|
|
31
31
|
bun run test:changelog # release-tooling regressions (scripts/changelog.sh)
|
|
32
32
|
bun run test:picker # PTY picker suite
|
|
33
33
|
bun run test:tui-home # real OpenTUI home layout regressions (also in bun run test)
|
|
34
|
+
bun run test:tui-worker # real blob Worker + bun:sqlite regressions (also in bun run test)
|
|
34
35
|
bun run typecheck # tsc over tui-plugins/ and plugins/
|
|
35
36
|
bun run lint:sh # bash -n on every script
|
|
36
37
|
HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.config)
|
|
@@ -181,7 +182,11 @@ when opencode exits, the picker reopens with the query preserved.
|
|
|
181
182
|
scrolling inside; the picker remains the place for transcript search.
|
|
182
183
|
Sessions waiting on the user (unanswered questions, stuck runs) pin a
|
|
183
184
|
**Needs input** group above the directories, with the count in the heading —
|
|
184
|
-
same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
|
|
185
|
+
same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`. The query runs in
|
|
186
|
+
a blob Worker (`createDbClient`; failure means "local store unavailable"),
|
|
187
|
+
off the TUI thread but still costing worker CPU every poll, so the shared
|
|
188
|
+
poll runs it only while the sidebar is subscribed
|
|
189
|
+
(`poll(listener, { waiting: true })`); the home list never triggers it.
|
|
185
190
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
186
191
|
project/directory, recency-ordered, resume via
|
|
187
192
|
`api.route.navigate("session", …)`. `option+o` and the command palette open it;
|
|
@@ -266,6 +271,9 @@ when opencode exits, the picker reopens with the query preserved.
|
|
|
266
271
|
and Python 3).
|
|
267
272
|
- `bun run test:tui-home` — real OpenTUI render regressions for the home list
|
|
268
273
|
(also run by `bun run test`).
|
|
274
|
+
- `bun run test:tui-worker` — starts the real blob Worker against a bun:sqlite
|
|
275
|
+
fixture; asserts results round-trip and the main thread keeps ticking during
|
|
276
|
+
a slow query (also run by `bun run test`).
|
|
269
277
|
- `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
|
|
270
278
|
- `bun run lint:sh` — `bash -n` on every script.
|
|
271
279
|
- `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.1.5] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **tui:** Run needs-input SQLite reads in a worker (#34)
|
|
13
|
+
|
|
14
|
+
## [2.1.4] - 2026-11-02
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **tui:** Skip the needs-input query when no sidebar consumes it (#33)
|
|
19
|
+
|
|
8
20
|
## [2.1.3] - 2026-11-02
|
|
9
21
|
|
|
10
22
|
### Fixed
|
|
@@ -530,6 +542,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
530
542
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
531
543
|
CI.
|
|
532
544
|
|
|
545
|
+
[2.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.4...v2.1.5
|
|
546
|
+
[2.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.3...v2.1.4
|
|
533
547
|
[2.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.2...v2.1.3
|
|
534
548
|
[2.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.1...v2.1.2
|
|
535
549
|
[2.1.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.0...v2.1.1
|
package/dist/tui.js
CHANGED
|
@@ -636,8 +636,107 @@ async function openTranscriptDb() {
|
|
|
636
636
|
return undefined;
|
|
637
637
|
}
|
|
638
638
|
}
|
|
639
|
+
|
|
640
|
+
// Runs in a blob Worker so slow queries never block the TUI thread. The
|
|
641
|
+
// connection opens on the first request and stays open; `loadSqlite` is
|
|
642
|
+
// injected only by tests.
|
|
643
|
+
const DB_WORKER_SOURCE = `
|
|
644
|
+
let opening
|
|
645
|
+
const load = typeof loadSqlite === "function" ? loadSqlite : () => import("bun:sqlite")
|
|
646
|
+
const open = (path) => opening ??= load().then((sqlite) => new sqlite.Database(path, { readonly: true }), (error) => { opening = undefined; throw error })
|
|
647
|
+
self.onmessage = async (event) => {
|
|
648
|
+
const { id, path, sql, params } = event.data
|
|
649
|
+
try {
|
|
650
|
+
const db = await open(path)
|
|
651
|
+
self.postMessage({ id, rows: db.query(sql).all(...(params ?? [])) })
|
|
652
|
+
} catch (error) {
|
|
653
|
+
self.postMessage({ id, error: String(error?.message ?? error) })
|
|
654
|
+
}
|
|
655
|
+
}`;
|
|
656
|
+
const DB_QUERY_TIMEOUT_MS = 30000;
|
|
657
|
+
function createDbWorker() {
|
|
658
|
+
return new globalThis.Worker(URL.createObjectURL(new Blob([DB_WORKER_SOURCE], {
|
|
659
|
+
type: "text/javascript"
|
|
660
|
+
})));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Read-only queries against the local store, served by one lazily started
|
|
664
|
+
// worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
|
|
665
|
+
// a timeout or worker error discards the worker so the next call starts afresh.
|
|
666
|
+
function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
|
|
667
|
+
let worker;
|
|
668
|
+
let nextId = 0;
|
|
669
|
+
const pending = new Map();
|
|
670
|
+
const reset = error => {
|
|
671
|
+
const dead = worker;
|
|
672
|
+
worker = undefined;
|
|
673
|
+
try {
|
|
674
|
+
dead?.terminate();
|
|
675
|
+
} catch {}
|
|
676
|
+
const requests = [...pending.values()];
|
|
677
|
+
pending.clear();
|
|
678
|
+
for (const request of requests) {
|
|
679
|
+
clearTimeout(request.timer);
|
|
680
|
+
request.reject(error);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
const start = () => {
|
|
684
|
+
const created = createWorker();
|
|
685
|
+
created.onmessage = event => {
|
|
686
|
+
const {
|
|
687
|
+
id,
|
|
688
|
+
rows,
|
|
689
|
+
error
|
|
690
|
+
} = event?.data ?? {};
|
|
691
|
+
const request = pending.get(id);
|
|
692
|
+
if (!request) return;
|
|
693
|
+
pending.delete(id);
|
|
694
|
+
clearTimeout(request.timer);
|
|
695
|
+
if (typeof error === "string") request.reject(new Error(error));else request.resolve(Array.isArray(rows) ? rows : []);
|
|
696
|
+
};
|
|
697
|
+
created.onerror = event => {
|
|
698
|
+
event?.preventDefault?.();
|
|
699
|
+
if (worker === created) reset(new Error(String(event?.message ?? "local store worker failed")));
|
|
700
|
+
};
|
|
701
|
+
created.unref?.();
|
|
702
|
+
return created;
|
|
703
|
+
};
|
|
704
|
+
const run = (sql, params = []) => new Promise((resolve, reject) => {
|
|
705
|
+
try {
|
|
706
|
+
worker ??= start();
|
|
707
|
+
} catch (error) {
|
|
708
|
+
reject(error);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const id = ++nextId;
|
|
712
|
+
const timer = setTimeout(() => reset(new Error("local store query timed out")), timeoutMs);
|
|
713
|
+
timer.unref?.();
|
|
714
|
+
pending.set(id, {
|
|
715
|
+
resolve,
|
|
716
|
+
reject,
|
|
717
|
+
timer
|
|
718
|
+
});
|
|
719
|
+
try {
|
|
720
|
+
worker.postMessage({
|
|
721
|
+
id,
|
|
722
|
+
path: resolveDbPath(),
|
|
723
|
+
sql,
|
|
724
|
+
params
|
|
725
|
+
});
|
|
726
|
+
} catch (error) {
|
|
727
|
+
reset(error);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
return {
|
|
731
|
+
run,
|
|
732
|
+
close: () => reset(new Error("local store closed"))
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const dbClient = createDbClient();
|
|
639
736
|
function createSessionPoll(api, refreshPins) {
|
|
640
|
-
|
|
737
|
+
// Listener -> whether it reads `snapshot.waiting`. The needs-input query
|
|
738
|
+
// costs worker CPU on every poll, so it only runs while such a listener exists.
|
|
739
|
+
const listeners = new Map();
|
|
641
740
|
let snapshot = {
|
|
642
741
|
entries: [],
|
|
643
742
|
waiting: new Map(),
|
|
@@ -645,7 +744,11 @@ function createSessionPoll(api, refreshPins) {
|
|
|
645
744
|
};
|
|
646
745
|
let clock;
|
|
647
746
|
let inFlight;
|
|
747
|
+
// Whether `snapshot.waiting` came from a poll that ran the query; a poll
|
|
748
|
+
// without waiting listeners clears it so a later subscriber never sees a stale set.
|
|
749
|
+
let waitingLoaded = false;
|
|
648
750
|
const active = () => listeners.size > 0;
|
|
751
|
+
const wantsWaiting = () => [...listeners.values()].some(Boolean);
|
|
649
752
|
const load = () => {
|
|
650
753
|
if (inFlight || !active()) return;
|
|
651
754
|
inFlight = (async () => {
|
|
@@ -671,30 +774,40 @@ function createSessionPoll(api, refreshPins) {
|
|
|
671
774
|
};
|
|
672
775
|
}
|
|
673
776
|
if (!active()) return;
|
|
674
|
-
|
|
675
|
-
if (db) {
|
|
777
|
+
if (wantsWaiting()) {
|
|
676
778
|
try {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
779
|
+
const waiting = await queryWaitingIds(dbClient.run);
|
|
780
|
+
if (wantsWaiting()) {
|
|
781
|
+
snapshot = {
|
|
782
|
+
...snapshot,
|
|
783
|
+
waiting
|
|
784
|
+
};
|
|
785
|
+
waitingLoaded = true;
|
|
786
|
+
}
|
|
681
787
|
} catch {
|
|
682
788
|
// Keep the previous waiting set when the local store is unavailable.
|
|
683
|
-
} finally {
|
|
684
|
-
db.close();
|
|
685
789
|
}
|
|
790
|
+
} else if (!wantsWaiting()) {
|
|
791
|
+
snapshot = {
|
|
792
|
+
...snapshot,
|
|
793
|
+
waiting: new Map()
|
|
794
|
+
};
|
|
795
|
+
waitingLoaded = false;
|
|
686
796
|
}
|
|
687
|
-
for (const listener of listeners) listener(snapshot);
|
|
797
|
+
for (const listener of [...listeners.keys()]) listener(snapshot);
|
|
688
798
|
})().finally(() => {
|
|
689
799
|
inFlight = undefined;
|
|
690
800
|
});
|
|
691
801
|
};
|
|
692
|
-
return listener => {
|
|
693
|
-
|
|
802
|
+
return (listener, options = {}) => {
|
|
803
|
+
const needsWaiting = options.waiting === true;
|
|
804
|
+
listeners.set(listener, needsWaiting);
|
|
694
805
|
listener(snapshot);
|
|
695
806
|
if (!clock) {
|
|
696
807
|
load();
|
|
697
808
|
clock = setInterval(load, POLL_MS);
|
|
809
|
+
} else if (needsWaiting && !waitingLoaded) {
|
|
810
|
+
load();
|
|
698
811
|
}
|
|
699
812
|
return () => {
|
|
700
813
|
listeners.delete(listener);
|
|
@@ -770,8 +883,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
|
|
|
770
883
|
WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
|
|
771
884
|
AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
|
|
772
885
|
ORDER BY s.time_updated DESC`;
|
|
773
|
-
async function queryWaitingIds(
|
|
774
|
-
const rows =
|
|
886
|
+
async function queryWaitingIds(run) {
|
|
887
|
+
const rows = await run(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS));
|
|
775
888
|
const out = new Map();
|
|
776
889
|
for (const row of rows) {
|
|
777
890
|
if (typeof row?.id === "string" && SESSION_ID_PATTERN.test(row.id) && (row.reason === "question" || row.reason === "stuck")) {
|
|
@@ -783,8 +896,8 @@ async function queryWaitingIds(db) {
|
|
|
783
896
|
// The shared needs-input query decides *which* sessions are waiting. Read the
|
|
784
897
|
// oldest still-unanswered question or stuck tool for each result so triage can
|
|
785
898
|
// show how long it has waited, rather than its last (unrelated) update time.
|
|
786
|
-
async function queryWaitingDetails(
|
|
787
|
-
const reasons = await queryWaitingIds(
|
|
899
|
+
async function queryWaitingDetails(run) {
|
|
900
|
+
const reasons = await queryWaitingIds(run);
|
|
788
901
|
const details = new Map([...reasons].map(([id, reason]) => [id, {
|
|
789
902
|
reason: reason
|
|
790
903
|
}]));
|
|
@@ -792,12 +905,12 @@ async function queryWaitingDetails(db) {
|
|
|
792
905
|
const cutoff = Date.now() - STUCK_AFTER_MS;
|
|
793
906
|
for (let i = 0; i < ids.length; i += 200) {
|
|
794
907
|
const batch = ids.slice(i, i + 200);
|
|
795
|
-
const rows =
|
|
908
|
+
const rows = await run(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
|
|
796
909
|
SELECT live.session_id AS id,
|
|
797
910
|
MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
|
|
798
911
|
MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
|
|
799
912
|
FROM live
|
|
800
|
-
GROUP BY live.session_id
|
|
913
|
+
GROUP BY live.session_id`, batch);
|
|
801
914
|
for (const row of rows) {
|
|
802
915
|
const detail = details.get(row.id);
|
|
803
916
|
if (!detail) continue;
|
|
@@ -1212,6 +1325,8 @@ function SidebarSessions(props) {
|
|
|
1212
1325
|
setEntries(snapshot.entries);
|
|
1213
1326
|
setLoadFailed(snapshot.failed);
|
|
1214
1327
|
setWaiting(snapshot.waiting);
|
|
1328
|
+
}, {
|
|
1329
|
+
waiting: true
|
|
1215
1330
|
})));
|
|
1216
1331
|
const filteredEntries = createMemo(() => {
|
|
1217
1332
|
const q = query().trim().toLowerCase();
|
|
@@ -2729,21 +2844,14 @@ const tui = async api => {
|
|
|
2729
2844
|
const [waitingCoverage, setWaitingCoverage] = createSignal("loading");
|
|
2730
2845
|
let alive = true;
|
|
2731
2846
|
void (async () => {
|
|
2732
|
-
const db = await openTranscriptDb();
|
|
2733
|
-
if (!db) {
|
|
2734
|
-
if (alive) setWaitingCoverage("unavailable");
|
|
2735
|
-
return;
|
|
2736
|
-
}
|
|
2737
2847
|
try {
|
|
2738
|
-
const ids = await queryWaitingIds(
|
|
2848
|
+
const ids = await queryWaitingIds(dbClient.run);
|
|
2739
2849
|
if (alive) {
|
|
2740
2850
|
setWaitingIds(new Set(ids.keys()));
|
|
2741
2851
|
setWaitingCoverage("ready");
|
|
2742
2852
|
}
|
|
2743
2853
|
} catch {
|
|
2744
2854
|
if (alive) setWaitingCoverage("unavailable");
|
|
2745
|
-
} finally {
|
|
2746
|
-
db.close();
|
|
2747
2855
|
}
|
|
2748
2856
|
})();
|
|
2749
2857
|
const [pendingDelete, setPendingDelete] = createSignal();
|
|
@@ -3904,20 +4012,16 @@ const tui = async api => {
|
|
|
3904
4012
|
const {
|
|
3905
4013
|
entries
|
|
3906
4014
|
} = await fetchEntries(api);
|
|
3907
|
-
|
|
3908
|
-
|
|
4015
|
+
let waiting;
|
|
4016
|
+
try {
|
|
4017
|
+
waiting = await queryWaitingDetails(dbClient.run);
|
|
4018
|
+
} catch {
|
|
3909
4019
|
api.ui.toast({
|
|
3910
4020
|
message: "Needs-input data unavailable",
|
|
3911
4021
|
variant: "error"
|
|
3912
4022
|
});
|
|
3913
4023
|
return;
|
|
3914
4024
|
}
|
|
3915
|
-
let waiting;
|
|
3916
|
-
try {
|
|
3917
|
-
waiting = await queryWaitingDetails(db);
|
|
3918
|
-
} finally {
|
|
3919
|
-
db.close();
|
|
3920
|
-
}
|
|
3921
4025
|
items = entries.filter(entry => waiting.has(entry.id)).map(entry => ({
|
|
3922
4026
|
entry,
|
|
3923
4027
|
reason: waiting.get(entry.id)?.reason === "question" ? "awaiting answer" : "run stuck",
|
|
@@ -4471,6 +4575,7 @@ export default Plugin.define({
|
|
|
4471
4575
|
if (api.ui.dialog.open) api.ui.dialog.clear();
|
|
4472
4576
|
disposeSlot();
|
|
4473
4577
|
removeRootMouse?.();
|
|
4578
|
+
dbClient.close();
|
|
4474
4579
|
};
|
|
4475
4580
|
}
|
|
4476
4581
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.5",
|
|
4
4
|
"description": "OpenCode session browser: search, preview, and resume every session across every project from the terminal or TUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
"build": "node scripts/build-tui.mjs",
|
|
58
58
|
"prepack": "node scripts/build-tui.mjs",
|
|
59
59
|
"test": "bun run test:unit && bash tests/changelog.sh",
|
|
60
|
-
"test:unit": "node scripts/build-tui.mjs && bun run test:tui-home && bash tests/sesh.sh && bash tests/install.sh",
|
|
60
|
+
"test:unit": "node scripts/build-tui.mjs && bun run test:tui-home && bun run test:tui-worker && bash tests/sesh.sh && bash tests/install.sh",
|
|
61
61
|
"test:tui-home": "bun --conditions=browser tests/tui-home.mjs",
|
|
62
|
+
"test:tui-worker": "bun tests/tui-worker.mjs",
|
|
62
63
|
"test:changelog": "bash tests/changelog.sh",
|
|
63
64
|
"test:picker": "python3 tests/picker.py",
|
|
64
65
|
"typecheck": "tsc --noEmit",
|
|
@@ -537,14 +537,105 @@ async function openTranscriptDb(): Promise<any | undefined> {
|
|
|
537
537
|
}
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
// Runs in a blob Worker so slow queries never block the TUI thread. The
|
|
541
|
+
// connection opens on the first request and stays open; `loadSqlite` is
|
|
542
|
+
// injected only by tests.
|
|
543
|
+
const DB_WORKER_SOURCE = `
|
|
544
|
+
let opening
|
|
545
|
+
const load = typeof loadSqlite === "function" ? loadSqlite : () => import("bun:sqlite")
|
|
546
|
+
const open = (path) => opening ??= load().then((sqlite) => new sqlite.Database(path, { readonly: true }), (error) => { opening = undefined; throw error })
|
|
547
|
+
self.onmessage = async (event) => {
|
|
548
|
+
const { id, path, sql, params } = event.data
|
|
549
|
+
try {
|
|
550
|
+
const db = await open(path)
|
|
551
|
+
self.postMessage({ id, rows: db.query(sql).all(...(params ?? [])) })
|
|
552
|
+
} catch (error) {
|
|
553
|
+
self.postMessage({ id, error: String(error?.message ?? error) })
|
|
554
|
+
}
|
|
555
|
+
}`
|
|
556
|
+
|
|
557
|
+
const DB_QUERY_TIMEOUT_MS = 30000
|
|
558
|
+
|
|
559
|
+
type DbRun = (sql: string, params?: unknown[]) => Promise<unknown[]>
|
|
560
|
+
|
|
561
|
+
function createDbWorker(): any {
|
|
562
|
+
return new (globalThis as any).Worker(URL.createObjectURL(new Blob([DB_WORKER_SOURCE], { type: "text/javascript" })))
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Read-only queries against the local store, served by one lazily started
|
|
566
|
+
// worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
|
|
567
|
+
// a timeout or worker error discards the worker so the next call starts afresh.
|
|
568
|
+
function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
|
|
569
|
+
type Pending = { resolve: (rows: unknown[]) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
570
|
+
let worker: any
|
|
571
|
+
let nextId = 0
|
|
572
|
+
const pending = new Map<number, Pending>()
|
|
573
|
+
const reset = (error: Error) => {
|
|
574
|
+
const dead = worker
|
|
575
|
+
worker = undefined
|
|
576
|
+
try { dead?.terminate() } catch {}
|
|
577
|
+
const requests = [...pending.values()]
|
|
578
|
+
pending.clear()
|
|
579
|
+
for (const request of requests) {
|
|
580
|
+
clearTimeout(request.timer)
|
|
581
|
+
request.reject(error)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const start = () => {
|
|
585
|
+
const created = createWorker()
|
|
586
|
+
created.onmessage = (event: any) => {
|
|
587
|
+
const { id, rows, error } = event?.data ?? {}
|
|
588
|
+
const request = pending.get(id)
|
|
589
|
+
if (!request) return
|
|
590
|
+
pending.delete(id)
|
|
591
|
+
clearTimeout(request.timer)
|
|
592
|
+
if (typeof error === "string") request.reject(new Error(error))
|
|
593
|
+
else request.resolve(Array.isArray(rows) ? rows : [])
|
|
594
|
+
}
|
|
595
|
+
created.onerror = (event: any) => {
|
|
596
|
+
event?.preventDefault?.()
|
|
597
|
+
if (worker === created) reset(new Error(String(event?.message ?? "local store worker failed")))
|
|
598
|
+
}
|
|
599
|
+
created.unref?.()
|
|
600
|
+
return created
|
|
601
|
+
}
|
|
602
|
+
const run: DbRun = (sql, params = []) =>
|
|
603
|
+
new Promise((resolve, reject) => {
|
|
604
|
+
try {
|
|
605
|
+
worker ??= start()
|
|
606
|
+
} catch (error) {
|
|
607
|
+
reject(error)
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
const id = ++nextId
|
|
611
|
+
const timer = setTimeout(() => reset(new Error("local store query timed out")), timeoutMs)
|
|
612
|
+
;(timer as any).unref?.()
|
|
613
|
+
pending.set(id, { resolve, reject, timer })
|
|
614
|
+
try {
|
|
615
|
+
worker.postMessage({ id, path: resolveDbPath(), sql, params })
|
|
616
|
+
} catch (error) {
|
|
617
|
+
reset(error as Error)
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
return { run, close: () => reset(new Error("local store closed")) }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const dbClient = createDbClient()
|
|
624
|
+
|
|
540
625
|
type SessionSnapshot = { entries: Entry[]; waiting: Map<string, string>; failed: boolean }
|
|
541
626
|
|
|
542
627
|
function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
|
|
543
|
-
|
|
628
|
+
// Listener -> whether it reads `snapshot.waiting`. The needs-input query
|
|
629
|
+
// costs worker CPU on every poll, so it only runs while such a listener exists.
|
|
630
|
+
const listeners = new Map<(snapshot: SessionSnapshot) => void, boolean>()
|
|
544
631
|
let snapshot: SessionSnapshot = { entries: [], waiting: new Map(), failed: false }
|
|
545
632
|
let clock: ReturnType<typeof setInterval> | undefined
|
|
546
633
|
let inFlight: Promise<void> | undefined
|
|
634
|
+
// Whether `snapshot.waiting` came from a poll that ran the query; a poll
|
|
635
|
+
// without waiting listeners clears it so a later subscriber never sees a stale set.
|
|
636
|
+
let waitingLoaded = false
|
|
547
637
|
const active = () => listeners.size > 0
|
|
638
|
+
const wantsWaiting = () => [...listeners.values()].some(Boolean)
|
|
548
639
|
const load = () => {
|
|
549
640
|
if (inFlight || !active()) return
|
|
550
641
|
inFlight = (async () => {
|
|
@@ -563,25 +654,32 @@ function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
|
|
|
563
654
|
snapshot = { ...snapshot, failed: true }
|
|
564
655
|
}
|
|
565
656
|
if (!active()) return
|
|
566
|
-
|
|
567
|
-
if (db) {
|
|
657
|
+
if (wantsWaiting()) {
|
|
568
658
|
try {
|
|
569
|
-
|
|
659
|
+
const waiting = await queryWaitingIds(dbClient.run)
|
|
660
|
+
if (wantsWaiting()) {
|
|
661
|
+
snapshot = { ...snapshot, waiting }
|
|
662
|
+
waitingLoaded = true
|
|
663
|
+
}
|
|
570
664
|
} catch {
|
|
571
665
|
// Keep the previous waiting set when the local store is unavailable.
|
|
572
|
-
} finally {
|
|
573
|
-
db.close()
|
|
574
666
|
}
|
|
667
|
+
} else if (!wantsWaiting()) {
|
|
668
|
+
snapshot = { ...snapshot, waiting: new Map() }
|
|
669
|
+
waitingLoaded = false
|
|
575
670
|
}
|
|
576
|
-
for (const listener of listeners) listener(snapshot)
|
|
671
|
+
for (const listener of [...listeners.keys()]) listener(snapshot)
|
|
577
672
|
})().finally(() => { inFlight = undefined })
|
|
578
673
|
}
|
|
579
|
-
return (listener: (snapshot: SessionSnapshot) => void) => {
|
|
580
|
-
|
|
674
|
+
return (listener: (snapshot: SessionSnapshot) => void, options: { waiting?: boolean } = {}) => {
|
|
675
|
+
const needsWaiting = options.waiting === true
|
|
676
|
+
listeners.set(listener, needsWaiting)
|
|
581
677
|
listener(snapshot)
|
|
582
678
|
if (!clock) {
|
|
583
679
|
load()
|
|
584
680
|
clock = setInterval(load, POLL_MS)
|
|
681
|
+
} else if (needsWaiting && !waitingLoaded) {
|
|
682
|
+
load()
|
|
585
683
|
}
|
|
586
684
|
return () => {
|
|
587
685
|
listeners.delete(listener)
|
|
@@ -659,8 +757,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
|
|
|
659
757
|
AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
|
|
660
758
|
ORDER BY s.time_updated DESC`
|
|
661
759
|
|
|
662
|
-
async function queryWaitingIds(
|
|
663
|
-
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 {
|
|
664
762
|
id: unknown
|
|
665
763
|
reason: unknown
|
|
666
764
|
}[]
|
|
@@ -682,8 +780,8 @@ type WaitingDetail = { reason: "question" | "stuck"; since?: number }
|
|
|
682
780
|
// The shared needs-input query decides *which* sessions are waiting. Read the
|
|
683
781
|
// oldest still-unanswered question or stuck tool for each result so triage can
|
|
684
782
|
// show how long it has waited, rather than its last (unrelated) update time.
|
|
685
|
-
async function queryWaitingDetails(
|
|
686
|
-
const reasons = await queryWaitingIds(
|
|
783
|
+
async function queryWaitingDetails(run: DbRun): Promise<Map<string, WaitingDetail>> {
|
|
784
|
+
const reasons = await queryWaitingIds(run)
|
|
687
785
|
const details = new Map<string, WaitingDetail>(
|
|
688
786
|
[...reasons].map(([id, reason]) => [id, { reason: reason as WaitingDetail["reason"] }]),
|
|
689
787
|
)
|
|
@@ -691,12 +789,12 @@ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>>
|
|
|
691
789
|
const cutoff = Date.now() - STUCK_AFTER_MS
|
|
692
790
|
for (let i = 0; i < ids.length; i += 200) {
|
|
693
791
|
const batch = ids.slice(i, i + 200)
|
|
694
|
-
const rows = (
|
|
792
|
+
const rows = (await run(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
|
|
695
793
|
SELECT live.session_id AS id,
|
|
696
794
|
MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
|
|
697
795
|
MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
|
|
698
796
|
FROM live
|
|
699
|
-
GROUP BY live.session_id
|
|
797
|
+
GROUP BY live.session_id`, batch)) as { id: string; question_since: number | null; stuck_since: number | null }[]
|
|
700
798
|
for (const row of rows) {
|
|
701
799
|
const detail = details.get(row.id)
|
|
702
800
|
if (!detail) continue
|
|
@@ -1098,7 +1196,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1098
1196
|
setEntries(snapshot.entries)
|
|
1099
1197
|
setLoadFailed(snapshot.failed)
|
|
1100
1198
|
setWaiting(snapshot.waiting)
|
|
1101
|
-
})))
|
|
1199
|
+
}, { waiting: true })))
|
|
1102
1200
|
|
|
1103
1201
|
const filteredEntries = createMemo(() => {
|
|
1104
1202
|
const q = query().trim().toLowerCase()
|
|
@@ -2212,21 +2310,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2212
2310
|
const [waitingCoverage, setWaitingCoverage] = createSignal<"loading" | "ready" | "unavailable">("loading")
|
|
2213
2311
|
let alive = true
|
|
2214
2312
|
void (async () => {
|
|
2215
|
-
const db = await openTranscriptDb()
|
|
2216
|
-
if (!db) {
|
|
2217
|
-
if (alive) setWaitingCoverage("unavailable")
|
|
2218
|
-
return
|
|
2219
|
-
}
|
|
2220
2313
|
try {
|
|
2221
|
-
const ids = await queryWaitingIds(
|
|
2314
|
+
const ids = await queryWaitingIds(dbClient.run)
|
|
2222
2315
|
if (alive) {
|
|
2223
2316
|
setWaitingIds(new Set(ids.keys()))
|
|
2224
2317
|
setWaitingCoverage("ready")
|
|
2225
2318
|
}
|
|
2226
2319
|
} catch {
|
|
2227
2320
|
if (alive) setWaitingCoverage("unavailable")
|
|
2228
|
-
} finally {
|
|
2229
|
-
db.close()
|
|
2230
2321
|
}
|
|
2231
2322
|
})()
|
|
2232
2323
|
const [pendingDelete, setPendingDelete] = createSignal<Entry>()
|
|
@@ -3044,16 +3135,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
3044
3135
|
let items: { entry: Entry; reason: string; since?: number }[] = []
|
|
3045
3136
|
try {
|
|
3046
3137
|
const { entries } = await fetchEntries(api)
|
|
3047
|
-
const db = await openTranscriptDb()
|
|
3048
|
-
if (!db) {
|
|
3049
|
-
api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
|
|
3050
|
-
return
|
|
3051
|
-
}
|
|
3052
3138
|
let waiting: Map<string, WaitingDetail>
|
|
3053
3139
|
try {
|
|
3054
|
-
waiting = await queryWaitingDetails(
|
|
3055
|
-
}
|
|
3056
|
-
|
|
3140
|
+
waiting = await queryWaitingDetails(dbClient.run)
|
|
3141
|
+
} catch {
|
|
3142
|
+
api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
|
|
3143
|
+
return
|
|
3057
3144
|
}
|
|
3058
3145
|
items = entries
|
|
3059
3146
|
.filter((entry) => waiting.has(entry.id))
|
|
@@ -3506,6 +3593,7 @@ export default Plugin.define({
|
|
|
3506
3593
|
if (api.ui.dialog.open) api.ui.dialog.clear()
|
|
3507
3594
|
disposeSlot()
|
|
3508
3595
|
removeRootMouse?.()
|
|
3596
|
+
dbClient.close()
|
|
3509
3597
|
}
|
|
3510
3598
|
},
|
|
3511
3599
|
})
|