@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 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 fire = async () => {
535
- let data;
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
- data = JSON.parse(fs.readFileSync(respPath, "utf8"));
547
+ fs.renameSync(respPath, claimPath);
538
548
  } catch {
549
+ closeWatcher();
539
550
  return;
540
551
  }
552
+ let data;
541
553
  try {
542
- fs.unlinkSync(respPath);
543
- } catch {}
544
- const w = activeWatchers.get(id);
545
- if (w) {
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
- w.close();
566
+ fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`));
548
567
  } catch {}
549
- activeWatchers.delete(id);
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
- await client.app.log({ body: { service: "learn", level: ok ? "info" : "error", message: ok ? `injected into ${effectiveSessionID}` : `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
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, (_e, filename) => {
602
- if (filename === `response-${id}.json` && fs.existsSync(respPath))
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
- for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
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
- fs.writeFileSync(path.join(pDir, `classify-${props.request.id}.json`), JSON.stringify(pendingClassify), "utf8");
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
- fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8");
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
- try {
2686
- fs.writeFileSync(respPath, JSON.stringify({
2687
- id: data.id,
2688
- type: data.type,
2689
- result,
2690
- sessionID: data.sessionID,
2691
- at: Date.now()
2692
- }), "utf8");
2693
- } catch {}
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
- try {
2793
- fs.writeFileSync(respPath, JSON.stringify({
2794
- id: data.id,
2795
- type: data.type,
2796
- cancelled: true,
2797
- sessionID: data.sessionID,
2798
- at: Date.now()
2799
- }), "utf8");
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.3",
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",
@@ -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
- fs.writeFileSync(path.join(pDir, `classify-${props.request.id}.json`), JSON.stringify(pendingClassify), "utf8")
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
- fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8")
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
- 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) as Array<{f: string, j: any}>
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
- try { fs.writeFileSync(respPath, JSON.stringify({ id: data!.id, type: data!.type, result, sessionID: (data as any).sessionID, at: Date.now() }), "utf8") } catch {}
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
- try { fs.writeFileSync(respPath, JSON.stringify({ id: data!.id, type: data!.type, cancelled: true, sessionID: (data as any).sessionID, at: Date.now() }), "utf8") } catch {}
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 fire = async () => {
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 { data = JSON.parse(fs.readFileSync(respPath, "utf8")) } catch { return }
488
- try { fs.unlinkSync(respPath) } catch {}
489
- const w = activeWatchers.get(id); if (w) { try { w.close() } catch {}; activeWatchers.delete(id) }
490
- slog("watchAndInject fire", id, JSON.stringify(data).slice(0,400))
491
- const effectiveSessionID = (data as any)?.sessionID || sessionID
492
- const text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result)
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: ok ? "info" : "error", message: ok ? `injected into ${effectiveSessionID}` : `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
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
- const w = fs.watch(dir, (_e, filename) => { if (filename === `response-${id}.json` && fs.existsSync(respPath)) void fire() })
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
- for (const f of fs.readdirSync(dir).filter(f => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
768
- void processClassify(f)
769
- }
770
- } catch {}
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