@bojackduy/opencode-learn 1.2.3 → 1.2.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/dist/server.js +113 -31
- package/dist/tui.js +47 -21
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +32 -6
- package/plugins/learn.ts +96 -18
package/dist/server.js
CHANGED
|
@@ -531,26 +531,57 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
531
531
|
}
|
|
532
532
|
const dir = pendingDir(directory);
|
|
533
533
|
const respPath = path.join(dir, `response-${id}.json`);
|
|
534
|
-
const
|
|
535
|
-
|
|
534
|
+
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
|
|
535
|
+
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
|
|
536
|
+
const closeWatcher = () => {
|
|
537
|
+
const w = activeWatchers.get(id);
|
|
538
|
+
if (w) {
|
|
539
|
+
try {
|
|
540
|
+
w.close();
|
|
541
|
+
} catch {}
|
|
542
|
+
activeWatchers.delete(id);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const fire = async (attempt = 0) => {
|
|
536
546
|
try {
|
|
537
|
-
|
|
547
|
+
fs.renameSync(respPath, claimPath);
|
|
538
548
|
} catch {
|
|
549
|
+
closeWatcher();
|
|
539
550
|
return;
|
|
540
551
|
}
|
|
552
|
+
let data;
|
|
541
553
|
try {
|
|
542
|
-
fs.
|
|
543
|
-
} catch {
|
|
544
|
-
|
|
545
|
-
|
|
554
|
+
data = JSON.parse(fs.readFileSync(claimPath, "utf8"));
|
|
555
|
+
} catch (e) {
|
|
556
|
+
if (attempt < 5) {
|
|
557
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
558
|
+
try {
|
|
559
|
+
fs.renameSync(claimPath, respPath);
|
|
560
|
+
} catch {}
|
|
561
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
562
|
+
return fire(attempt + 1);
|
|
563
|
+
}
|
|
564
|
+
slog("watchAndInject UNREADABLE response, parked", id, String(e).slice(0, 160));
|
|
546
565
|
try {
|
|
547
|
-
|
|
566
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`));
|
|
548
567
|
} catch {}
|
|
549
|
-
|
|
568
|
+
closeWatcher();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
let text;
|
|
572
|
+
let effectiveSessionID;
|
|
573
|
+
try {
|
|
574
|
+
slog("watchAndInject fire", id, JSON.stringify(data).slice(0, 400));
|
|
575
|
+
effectiveSessionID = data?.sessionID || sessionID;
|
|
576
|
+
text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
slog("watchAndInject buildText ERROR, parked", id, String(e).slice(0, 200));
|
|
579
|
+
try {
|
|
580
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`));
|
|
581
|
+
} catch {}
|
|
582
|
+
closeWatcher();
|
|
583
|
+
return;
|
|
550
584
|
}
|
|
551
|
-
slog("watchAndInject fire", id, JSON.stringify(data).slice(0, 400));
|
|
552
|
-
const effectiveSessionID = data?.sessionID || sessionID;
|
|
553
|
-
const text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result);
|
|
554
585
|
const sdkCall = async (method, ...argsList) => {
|
|
555
586
|
let firstErr;
|
|
556
587
|
for (const args of argsList) {
|
|
@@ -576,35 +607,59 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
576
607
|
];
|
|
577
608
|
slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0, 300));
|
|
578
609
|
let ok = false;
|
|
610
|
+
let firstErr;
|
|
579
611
|
if (client?.session?.promptAsync) {
|
|
580
612
|
try {
|
|
581
613
|
await sdkCall(client.session.promptAsync.bind(client.session), ...shapes);
|
|
582
614
|
ok = true;
|
|
583
|
-
} catch {
|
|
615
|
+
} catch (e) {
|
|
616
|
+
firstErr = e;
|
|
617
|
+
}
|
|
584
618
|
}
|
|
585
619
|
if (!ok && client?.session?.prompt) {
|
|
586
620
|
try {
|
|
587
621
|
await sdkCall(client.session.prompt.bind(client.session), ...shapes);
|
|
588
622
|
ok = true;
|
|
623
|
+
} catch (e) {
|
|
624
|
+
firstErr = firstErr || e;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!ok) {
|
|
628
|
+
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0, 200));
|
|
629
|
+
try {
|
|
630
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`));
|
|
631
|
+
} catch {}
|
|
632
|
+
try {
|
|
633
|
+
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
|
|
589
634
|
} catch {}
|
|
635
|
+
closeWatcher();
|
|
636
|
+
return;
|
|
590
637
|
}
|
|
591
638
|
try {
|
|
592
|
-
|
|
639
|
+
fs.unlinkSync(claimPath);
|
|
640
|
+
} catch {}
|
|
641
|
+
for (const p of pendingCandidates)
|
|
642
|
+
try {
|
|
643
|
+
fs.unlinkSync(p);
|
|
644
|
+
} catch {}
|
|
645
|
+
closeWatcher();
|
|
646
|
+
slog("watchAndInject consumed", id, effectiveSessionID);
|
|
647
|
+
try {
|
|
648
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `injected into ${effectiveSessionID}`, extra: { id } } });
|
|
593
649
|
} catch {}
|
|
594
650
|
};
|
|
595
|
-
if (fs.existsSync(respPath)) {
|
|
596
|
-
slog("watchAndInject fast-path", id);
|
|
597
|
-
fire();
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
651
|
try {
|
|
601
|
-
const w = fs.watch(dir, (
|
|
602
|
-
|
|
603
|
-
fire();
|
|
652
|
+
const w = fs.watch(dir, () => {
|
|
653
|
+
fire();
|
|
604
654
|
});
|
|
605
655
|
w.on("error", () => {});
|
|
606
656
|
activeWatchers.set(id, w);
|
|
607
657
|
} catch {}
|
|
658
|
+
if (fs.existsSync(respPath)) {
|
|
659
|
+
slog("watchAndInject fast-path", id);
|
|
660
|
+
fire();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
608
663
|
}
|
|
609
664
|
var server = async ({ client, directory }) => {
|
|
610
665
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json");
|
|
@@ -845,16 +900,16 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
845
900
|
slog("classify response written", data.id, inferred.join(","));
|
|
846
901
|
} catch {}
|
|
847
902
|
};
|
|
903
|
+
const sweep = () => {
|
|
904
|
+
try {
|
|
905
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
906
|
+
processClassify(f);
|
|
907
|
+
}
|
|
908
|
+
} catch {}
|
|
909
|
+
};
|
|
910
|
+
sweep();
|
|
848
911
|
try {
|
|
849
|
-
|
|
850
|
-
processClassify(f);
|
|
851
|
-
}
|
|
852
|
-
} catch {}
|
|
853
|
-
try {
|
|
854
|
-
const w = fs.watch(dir, (_e, filename) => {
|
|
855
|
-
if (filename)
|
|
856
|
-
processClassify(filename);
|
|
857
|
-
});
|
|
912
|
+
const w = fs.watch(dir, () => sweep());
|
|
858
913
|
w.on("error", () => {});
|
|
859
914
|
} catch {}
|
|
860
915
|
}
|
|
@@ -862,6 +917,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
862
917
|
try {
|
|
863
918
|
const dir = pendingDir(directory);
|
|
864
919
|
if (fs.existsSync(dir)) {
|
|
920
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.includes(".claim-") && x.endsWith(".json"))) {
|
|
921
|
+
try {
|
|
922
|
+
const m = f.match(/^response-(.+)\.claim-.*\.json$/);
|
|
923
|
+
if (!m)
|
|
924
|
+
continue;
|
|
925
|
+
const rid = m[1];
|
|
926
|
+
const target = path.join(dir, `response-${rid}.json`);
|
|
927
|
+
const age = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
|
|
928
|
+
if (age > 30000 && !fs.existsSync(target)) {
|
|
929
|
+
fs.renameSync(path.join(dir, f), target);
|
|
930
|
+
slog("watchAndInject requeued stale claim", rid);
|
|
931
|
+
}
|
|
932
|
+
} catch {}
|
|
933
|
+
}
|
|
865
934
|
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
866
935
|
try {
|
|
867
936
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
@@ -918,6 +987,19 @@ Explanation: ${j.explanation}${note}`;
|
|
|
918
987
|
}
|
|
919
988
|
} catch {}
|
|
920
989
|
}
|
|
990
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("response-") && x.endsWith(".json") && !x.includes(".claim-") && !x.includes(".poisoned") && !x.includes(".failed") && !x.includes(".orphaned"))) {
|
|
991
|
+
try {
|
|
992
|
+
const m = f.match(/^response-(.+)\.json$/);
|
|
993
|
+
if (!m)
|
|
994
|
+
continue;
|
|
995
|
+
const rid = m[1];
|
|
996
|
+
const hasPending = fs.existsSync(path.join(dir, `quiz-${rid}.json`)) || fs.existsSync(path.join(dir, `quiz_batch-${rid}.json`));
|
|
997
|
+
if (!hasPending) {
|
|
998
|
+
fs.renameSync(path.join(dir, f), path.join(dir, `response-${rid}.orphaned-${Date.now()}.json`));
|
|
999
|
+
slog("watchAndInject orphaned response parked (no pending context)", rid);
|
|
1000
|
+
}
|
|
1001
|
+
} catch {}
|
|
1002
|
+
}
|
|
921
1003
|
}
|
|
922
1004
|
} catch {}
|
|
923
1005
|
return {
|
package/dist/tui.js
CHANGED
|
@@ -237,6 +237,17 @@ function ensureDir(dir) {
|
|
|
237
237
|
});
|
|
238
238
|
} catch {}
|
|
239
239
|
}
|
|
240
|
+
function writeJsonAtomic(filePath, data) {
|
|
241
|
+
try {
|
|
242
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
243
|
+
fs.writeFileSync(tmp, JSON.stringify(data), "utf8");
|
|
244
|
+
fs.renameSync(tmp, filePath);
|
|
245
|
+
} catch {
|
|
246
|
+
try {
|
|
247
|
+
fs.writeFileSync(filePath, JSON.stringify(data), "utf8");
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
240
251
|
function decodeQuizText(s) {
|
|
241
252
|
if (!s || typeof s !== "string")
|
|
242
253
|
return s;
|
|
@@ -391,7 +402,7 @@ function QuizDialog(props) {
|
|
|
391
402
|
timestamp: Date.now(),
|
|
392
403
|
sessionID: props.request.sessionID || routeSessionID
|
|
393
404
|
};
|
|
394
|
-
|
|
405
|
+
writeJsonAtomic(path.join(pDir, `classify-${props.request.id}.json`), pendingClassify);
|
|
395
406
|
tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50));
|
|
396
407
|
const respPath = path.join(pDir, `classify-response-${props.request.id}.json`);
|
|
397
408
|
let attempts = 0;
|
|
@@ -1673,7 +1684,7 @@ function QuizBatchDialog(props) {
|
|
|
1673
1684
|
timestamp: Date.now(),
|
|
1674
1685
|
sessionID: props.request.sessionID || routeSessionID
|
|
1675
1686
|
};
|
|
1676
|
-
|
|
1687
|
+
writeJsonAtomic(path.join(pDir, `classify-${cid}.json`), pendingClassify);
|
|
1677
1688
|
tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50));
|
|
1678
1689
|
const respPath = path.join(pDir, `classify-response-${cid}.json`);
|
|
1679
1690
|
let attempts = 0;
|
|
@@ -2642,7 +2653,13 @@ var tui = async (api) => {
|
|
|
2642
2653
|
} catch {
|
|
2643
2654
|
return null;
|
|
2644
2655
|
}
|
|
2645
|
-
}).filter(Boolean)
|
|
2656
|
+
}).filter(Boolean).filter((x) => {
|
|
2657
|
+
try {
|
|
2658
|
+
return !fs.existsSync(path.join(pendingDir, `response-${x.j.id}.json`));
|
|
2659
|
+
} catch {
|
|
2660
|
+
return true;
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2646
2663
|
const pick = matching.find((x) => x.j.sessionID === curSid) || matching.find((x) => !x.j.sessionID);
|
|
2647
2664
|
if (!pick)
|
|
2648
2665
|
return;
|
|
@@ -2682,15 +2699,15 @@ var tui = async (api) => {
|
|
|
2682
2699
|
currentBySession.set(curSid, current);
|
|
2683
2700
|
const done = async (result) => {
|
|
2684
2701
|
const respPath = path.join(pendingDir, `response-${data.id}.json`);
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
}
|
|
2702
|
+
const answerId = data.id;
|
|
2703
|
+
tlog("learn-tui answered", data.type, answerId, JSON.stringify(result).slice(0, 160));
|
|
2704
|
+
writeJsonAtomic(respPath, {
|
|
2705
|
+
id: data.id,
|
|
2706
|
+
type: data.type,
|
|
2707
|
+
result,
|
|
2708
|
+
sessionID: data.sessionID,
|
|
2709
|
+
at: Date.now()
|
|
2710
|
+
});
|
|
2694
2711
|
try {
|
|
2695
2712
|
const sessionID = data.sessionID;
|
|
2696
2713
|
let injectText = "";
|
|
@@ -2786,18 +2803,27 @@ var tui = async (api) => {
|
|
|
2786
2803
|
api.ui.dialog.clear();
|
|
2787
2804
|
currentBySession.delete(curSid);
|
|
2788
2805
|
setTimeout(processPending, 150);
|
|
2806
|
+
setTimeout(() => {
|
|
2807
|
+
try {
|
|
2808
|
+
if (fs.existsSync(respPath)) {
|
|
2809
|
+
tlog("learn-tui response NOT consumed, re-touching", answerId);
|
|
2810
|
+
try {
|
|
2811
|
+
writeJsonAtomic(respPath, JSON.parse(fs.readFileSync(respPath, "utf8")));
|
|
2812
|
+
} catch {}
|
|
2813
|
+
}
|
|
2814
|
+
} catch {}
|
|
2815
|
+
}, 8000);
|
|
2789
2816
|
};
|
|
2790
2817
|
const cancel = async () => {
|
|
2791
2818
|
const respPath = path.join(pendingDir, `response-${data.id}.json`);
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
} catch {}
|
|
2819
|
+
tlog("learn-tui cancelled", data.type, data.id);
|
|
2820
|
+
writeJsonAtomic(respPath, {
|
|
2821
|
+
id: data.id,
|
|
2822
|
+
type: data.type,
|
|
2823
|
+
cancelled: true,
|
|
2824
|
+
sessionID: data.sessionID,
|
|
2825
|
+
at: Date.now()
|
|
2826
|
+
});
|
|
2801
2827
|
try {
|
|
2802
2828
|
const sid = data.sessionID;
|
|
2803
2829
|
if (sid) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-learn",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.5",
|
|
5
5
|
"description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "AGPL-3.0-or-later",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -53,6 +53,14 @@ import { tmpdir } from "node:os"
|
|
|
53
53
|
const TUI_LOG = path.join(tmpdir(), "learn-tui.log")
|
|
54
54
|
function tlog(...a: any[]) { try { fs.appendFileSync(TUI_LOG, `[${new Date().toISOString()}] ${a.map(x=> typeof x==="string"? x : JSON.stringify(x)).join(" ")}\n`) } catch {} }
|
|
55
55
|
function ensureDir(dir: string) { try { fs.mkdirSync(dir, { recursive: true }) } catch {} }
|
|
56
|
+
function writeJsonAtomic(filePath: string, data: any) {
|
|
57
|
+
// Atomic handoff: readers never observe partial JSON (tmp + rename is atomic on POSIX)
|
|
58
|
+
try {
|
|
59
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
61
|
+
fs.renameSync(tmp, filePath)
|
|
62
|
+
} catch { try { fs.writeFileSync(filePath, JSON.stringify(data), "utf8") } catch {} }
|
|
63
|
+
}
|
|
56
64
|
function decodeQuizText(s: string): string {
|
|
57
65
|
if (!s || typeof s !== "string") return s
|
|
58
66
|
if (!s.includes("\\")) return s
|
|
@@ -184,7 +192,7 @@ function QuizDialog(props: {
|
|
|
184
192
|
const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
|
|
185
193
|
const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
|
|
186
194
|
const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
|
|
187
|
-
|
|
195
|
+
writeJsonAtomic(path.join(pDir, `classify-${props.request.id}.json`), pendingClassify)
|
|
188
196
|
tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50))
|
|
189
197
|
// Poll for classify-response
|
|
190
198
|
const respPath = path.join(pDir, `classify-response-${props.request.id}.json`)
|
|
@@ -609,7 +617,7 @@ function QuizBatchDialog(props: {
|
|
|
609
617
|
const cid = `${props.request.id}-${idx()}`
|
|
610
618
|
const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
|
|
611
619
|
const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
|
|
612
|
-
|
|
620
|
+
writeJsonAtomic(path.join(pDir, `classify-${cid}.json`), pendingClassify)
|
|
613
621
|
tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
|
|
614
622
|
const respPath = path.join(pDir, `classify-response-${cid}.json`)
|
|
615
623
|
let attempts = 0
|
|
@@ -778,8 +786,11 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
778
786
|
if (api.ui.dialog.open) return
|
|
779
787
|
let files: string[] = []
|
|
780
788
|
try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
|
|
781
|
-
// Session-distinct: only show pending for current session
|
|
782
|
-
|
|
789
|
+
// Session-distinct: only show pending for current session.
|
|
790
|
+
// Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
|
|
791
|
+
const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean).filter(x => {
|
|
792
|
+
try { return !fs.existsSync(path.join(pendingDir, `response-${x!.j.id}.json`)) } catch { return true }
|
|
793
|
+
}) as Array<{f: string, j: any}>
|
|
783
794
|
const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
|
|
784
795
|
if (!pick) return
|
|
785
796
|
const file = pick.f
|
|
@@ -803,7 +814,9 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
803
814
|
currentBySession.set(curSid, current)
|
|
804
815
|
const done = async (result: any) => {
|
|
805
816
|
const respPath = path.join(pendingDir, `response-${data!.id}.json`)
|
|
806
|
-
|
|
817
|
+
const answerId = data!.id
|
|
818
|
+
tlog("learn-tui answered", (data as any).type, answerId, JSON.stringify(result).slice(0, 160))
|
|
819
|
+
writeJsonAtomic(respPath, { id: data!.id, type: data!.type, result, sessionID: (data as any).sessionID, at: Date.now() })
|
|
807
820
|
// Non-blocking wake: inject answer as new user prompt so agent continues (no timeout, no polling waste)
|
|
808
821
|
try {
|
|
809
822
|
const sessionID = (data as any).sessionID
|
|
@@ -862,14 +875,27 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
862
875
|
}
|
|
863
876
|
}
|
|
864
877
|
} catch {}
|
|
878
|
+
// TUI owns dialog lifecycle — always delete pending + close immediately so Enter never appears to "do nothing",
|
|
879
|
+
// regardless of whether another (possibly stale/old-code) opencode process is also running.
|
|
865
880
|
try { fs.unlinkSync(full) } catch {}
|
|
866
881
|
api.ui.dialog.clear()
|
|
867
882
|
currentBySession.delete(curSid)
|
|
868
883
|
setTimeout(processPending, 150)
|
|
884
|
+
// Watchdog: if the response is still unconsumed (server didn't fire) after 8s, re-touch to retrigger the watch + log loudly.
|
|
885
|
+
// Safe: server claims via atomic rename, so a rewrite can never cause double-inject.
|
|
886
|
+
setTimeout(() => {
|
|
887
|
+
try {
|
|
888
|
+
if (fs.existsSync(respPath)) {
|
|
889
|
+
tlog("learn-tui response NOT consumed, re-touching", answerId)
|
|
890
|
+
try { writeJsonAtomic(respPath, JSON.parse(fs.readFileSync(respPath, "utf8"))) } catch {}
|
|
891
|
+
}
|
|
892
|
+
} catch {}
|
|
893
|
+
}, 8000)
|
|
869
894
|
}
|
|
870
895
|
const cancel = async () => {
|
|
871
896
|
const respPath = path.join(pendingDir, `response-${data!.id}.json`)
|
|
872
|
-
|
|
897
|
+
tlog("learn-tui cancelled", (data as any).type, data!.id)
|
|
898
|
+
writeJsonAtomic(respPath, { id: data!.id, type: data!.type, cancelled: true, sessionID: (data as any).sessionID, at: Date.now() })
|
|
873
899
|
try {
|
|
874
900
|
const sid = (data as any).sessionID
|
|
875
901
|
if (sid) {
|
package/plugins/learn.ts
CHANGED
|
@@ -482,14 +482,39 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
482
482
|
if (!sessionID) { slog("watchAndInject no sessionID", id); return }
|
|
483
483
|
const dir = pendingDir(directory)
|
|
484
484
|
const respPath = path.join(dir, `response-${id}.json`)
|
|
485
|
-
const
|
|
485
|
+
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
|
|
486
|
+
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
|
|
487
|
+
const closeWatcher = () => { const w = activeWatchers.get(id); if (w) { try { w.close() } catch {}; activeWatchers.delete(id) } }
|
|
488
|
+
const fire = async (attempt = 0): Promise<void> => {
|
|
489
|
+
// Atomic single-consumer claim: exactly one process proceeds (fixes double-inject across processes)
|
|
490
|
+
try { fs.renameSync(respPath, claimPath) } catch { closeWatcher(); return } // already claimed/consumed → stand down
|
|
486
491
|
let data: any
|
|
487
|
-
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
492
|
+
try {
|
|
493
|
+
data = JSON.parse(fs.readFileSync(claimPath, "utf8"))
|
|
494
|
+
} catch (e) {
|
|
495
|
+
if (attempt < 5) {
|
|
496
|
+
await new Promise(r => setTimeout(r, 120))
|
|
497
|
+
try { fs.renameSync(claimPath, respPath) } catch {}
|
|
498
|
+
await new Promise(r => setTimeout(r, 60))
|
|
499
|
+
return fire(attempt + 1)
|
|
500
|
+
}
|
|
501
|
+
slog("watchAndInject UNREADABLE response, parked", id, String(e).slice(0,160))
|
|
502
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`)) } catch {}
|
|
503
|
+
closeWatcher()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
let text: string
|
|
507
|
+
let effectiveSessionID: string
|
|
508
|
+
try {
|
|
509
|
+
slog("watchAndInject fire", id, JSON.stringify(data).slice(0,400))
|
|
510
|
+
effectiveSessionID = (data as any)?.sessionID || sessionID
|
|
511
|
+
text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result)
|
|
512
|
+
} catch (e) {
|
|
513
|
+
slog("watchAndInject buildText ERROR, parked", id, String(e).slice(0,200))
|
|
514
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`)) } catch {}
|
|
515
|
+
closeWatcher()
|
|
516
|
+
return
|
|
517
|
+
}
|
|
493
518
|
// opencode-loop sdk.js:24 — SDK returns {data,error}, it does NOT throw. Must inspect .error.
|
|
494
519
|
const sdkCall = async (method: any, ...argsList: any[]) => {
|
|
495
520
|
let firstErr: any
|
|
@@ -512,23 +537,42 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
512
537
|
]
|
|
513
538
|
slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0,300))
|
|
514
539
|
let ok = false
|
|
540
|
+
let firstErr: any
|
|
515
541
|
// loopd host-adapter.ts:100 — promptAsync wakes the session (fire-and-forget turn)
|
|
516
542
|
if (client?.session?.promptAsync) {
|
|
517
|
-
try { await sdkCall(client.session.promptAsync.bind(client.session), ...shapes); ok = true } catch {}
|
|
543
|
+
try { await sdkCall(client.session.promptAsync.bind(client.session), ...shapes); ok = true } catch (e) { firstErr = e }
|
|
518
544
|
}
|
|
519
545
|
if (!ok && client?.session?.prompt) {
|
|
520
|
-
try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch {}
|
|
546
|
+
try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch (e) { firstErr = firstErr || e }
|
|
521
547
|
}
|
|
548
|
+
if (!ok) {
|
|
549
|
+
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0,200))
|
|
550
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
|
|
551
|
+
try {
|
|
552
|
+
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
|
|
553
|
+
} catch {}
|
|
554
|
+
closeWatcher()
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
// Success: consume claim + defensively try pending too (TUI is the primary owner of pending deletion)
|
|
558
|
+
try { fs.unlinkSync(claimPath) } catch {}
|
|
559
|
+
for (const p of pendingCandidates) try { fs.unlinkSync(p) } catch {}
|
|
560
|
+
closeWatcher()
|
|
561
|
+
slog("watchAndInject consumed", id, effectiveSessionID)
|
|
522
562
|
try {
|
|
523
|
-
await client.app.log({ body: { service: "learn", level:
|
|
563
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `injected into ${effectiveSessionID}`, extra: { id } } })
|
|
524
564
|
} catch {}
|
|
525
565
|
}
|
|
526
|
-
if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
|
|
527
566
|
try {
|
|
528
|
-
|
|
567
|
+
// Fire on ANY directory event, not just exact filename match — atomic tmp+rename writes can report
|
|
568
|
+
// a different filename (or no filename) on some fs.watch backends (notably macOS FSEvents). The claim
|
|
569
|
+
// rename inside fire() makes this safe to call spuriously: if respPath doesn't exist yet, it just no-ops.
|
|
570
|
+
const w = fs.watch(dir, () => { void fire() })
|
|
529
571
|
w.on("error", () => {})
|
|
530
572
|
activeWatchers.set(id, w)
|
|
531
573
|
} catch {}
|
|
574
|
+
// Recheck after arming to close the event gap (claim makes double-fire safe)
|
|
575
|
+
if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
|
|
532
576
|
}
|
|
533
577
|
|
|
534
578
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -762,14 +806,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
762
806
|
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, isIDK, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
|
|
763
807
|
try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
|
|
764
808
|
}
|
|
809
|
+
const sweep = () => {
|
|
810
|
+
try {
|
|
811
|
+
for (const f of fs.readdirSync(dir).filter(f => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
812
|
+
void processClassify(f)
|
|
813
|
+
}
|
|
814
|
+
} catch {}
|
|
815
|
+
}
|
|
765
816
|
// Initial sweep
|
|
817
|
+
sweep()
|
|
766
818
|
try {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
try {
|
|
772
|
-
const w = fs.watch(dir, (_e, filename) => { if (filename) void processClassify(filename) })
|
|
819
|
+
// Re-scan on ANY directory event rather than trusting the reported filename — atomic tmp+rename
|
|
820
|
+
// writes (used by the TUI) can report a different filename (or none) on some fs.watch backends
|
|
821
|
+
// (notably macOS FSEvents), which would otherwise silently drop classify requests.
|
|
822
|
+
const w = fs.watch(dir, () => sweep())
|
|
773
823
|
w.on("error", () => {})
|
|
774
824
|
// Keep watcher alive; store to avoid GC? No need.
|
|
775
825
|
} catch {}
|
|
@@ -780,6 +830,21 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
780
830
|
try {
|
|
781
831
|
const dir = pendingDir(directory)
|
|
782
832
|
if (fs.existsSync(dir)) {
|
|
833
|
+
// 1) Requeue stale claims from consumers that died mid-flight (>30s old, no live response)
|
|
834
|
+
for (const f of fs.readdirSync(dir).filter(x => x.includes(".claim-") && x.endsWith(".json"))) {
|
|
835
|
+
try {
|
|
836
|
+
const m = f.match(/^response-(.+)\.claim-.*\.json$/)
|
|
837
|
+
if (!m) continue
|
|
838
|
+
const rid = m[1]!
|
|
839
|
+
const target = path.join(dir, `response-${rid}.json`)
|
|
840
|
+
const age = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs
|
|
841
|
+
if (age > 30000 && !fs.existsSync(target)) {
|
|
842
|
+
fs.renameSync(path.join(dir, f), target)
|
|
843
|
+
slog("watchAndInject requeued stale claim", rid)
|
|
844
|
+
}
|
|
845
|
+
} catch {}
|
|
846
|
+
}
|
|
847
|
+
// 2) Re-arm pending quizzes (fast-path inside watchAndInject consumes any waiting response)
|
|
783
848
|
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
784
849
|
try {
|
|
785
850
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
|
|
@@ -829,6 +894,19 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
829
894
|
}
|
|
830
895
|
} catch {}
|
|
831
896
|
}
|
|
897
|
+
// 3) Park legacy orphans: response with no pending context (pre-fix leftovers) — can't rebuild inject text, park loudly instead of rotting silently
|
|
898
|
+
for (const f of fs.readdirSync(dir).filter(x => x.startsWith("response-") && x.endsWith(".json") && !x.includes(".claim-") && !x.includes(".poisoned") && !x.includes(".failed") && !x.includes(".orphaned"))) {
|
|
899
|
+
try {
|
|
900
|
+
const m = f.match(/^response-(.+)\.json$/)
|
|
901
|
+
if (!m) continue
|
|
902
|
+
const rid = m[1]!
|
|
903
|
+
const hasPending = fs.existsSync(path.join(dir, `quiz-${rid}.json`)) || fs.existsSync(path.join(dir, `quiz_batch-${rid}.json`))
|
|
904
|
+
if (!hasPending) {
|
|
905
|
+
fs.renameSync(path.join(dir, f), path.join(dir, `response-${rid}.orphaned-${Date.now()}.json`))
|
|
906
|
+
slog("watchAndInject orphaned response parked (no pending context)", rid)
|
|
907
|
+
}
|
|
908
|
+
} catch {}
|
|
909
|
+
}
|
|
832
910
|
}
|
|
833
911
|
} catch {}
|
|
834
912
|
|