@bojackduy/opencode-learn 1.2.2 → 1.2.4

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
@@ -317,6 +317,20 @@ function answerCalloutQuiz(details) {
317
317
  }
318
318
  return callout(type, title, body);
319
319
  }
320
+ function answerCalloutQuestion(questions, answers) {
321
+ const qs = Array.isArray(questions) ? questions : [];
322
+ const ans = Array.isArray(answers) ? answers : [];
323
+ if (!qs.length) {
324
+ const flat = ans.map((a) => Array.isArray(a) ? a.join(", ") : String(a ?? "")).filter(Boolean);
325
+ return callout("example", "Answer", flat.length ? flat : ["(no answer)"]);
326
+ }
327
+ const body = qs.map((q, i) => {
328
+ const header = q?.header || `Q${i + 1}`;
329
+ const sel = Array.isArray(ans[i]) ? ans[i] : [];
330
+ return `${header}: ${sel.length ? sel.join(", ") : "(no answer)"}`;
331
+ });
332
+ return callout("example", "Answer", body);
333
+ }
320
334
  function answerCalloutAsk(details) {
321
335
  const status = details?.status;
322
336
  if (status === "cancelled")
@@ -324,11 +338,17 @@ function answerCalloutAsk(details) {
324
338
  if (status === "unavailable")
325
339
  return callout("warning", "Question \u2014 unavailable", [details?.message || ""]);
326
340
  const answers = details?.answers || [];
341
+ if (answers.length && answers.every((a) => Array.isArray(a))) {
342
+ const questions = details?.questions || [];
343
+ return answerCalloutQuestion(questions, answers);
344
+ }
327
345
  const body = answers.map((a) => {
328
346
  if (a.type === "other")
329
347
  return `Other: ${a.label}`;
330
348
  if (a.type === "text")
331
349
  return a.label;
350
+ if (typeof a === "string")
351
+ return a;
332
352
  return `${a.index}. ${a.label}`;
333
353
  });
334
354
  if (body.length === 0)
@@ -401,6 +421,25 @@ async function backfillMdLog(client, sessionID, directory) {
401
421
  }
402
422
  continue;
403
423
  }
424
+ if (toolName === "question" && Array.isArray(input.questions)) {
425
+ const qs = input.questions;
426
+ if (st.status === "pending" || st.status === "running") {
427
+ qs.forEach((q, i) => {
428
+ if (!q?.question)
429
+ return;
430
+ blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []));
431
+ });
432
+ } else if (st.status === "completed") {
433
+ qs.forEach((q, i) => {
434
+ if (!q?.question)
435
+ return;
436
+ blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []));
437
+ });
438
+ const ans = Array.isArray(meta.answers) ? meta.answers : [];
439
+ blocks.push(answerCalloutAsk({ answers: ans, questions: qs, status: "completed" }));
440
+ }
441
+ continue;
442
+ }
404
443
  if (st.status === "pending" || st.status === "running") {
405
444
  if (input.question) {
406
445
  const opts = Array.isArray(input.options) ? input.options : [];
@@ -492,26 +531,57 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
492
531
  }
493
532
  const dir = pendingDir(directory);
494
533
  const respPath = path.join(dir, `response-${id}.json`);
495
- const fire = async () => {
496
- 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) => {
497
546
  try {
498
- data = JSON.parse(fs.readFileSync(respPath, "utf8"));
547
+ fs.renameSync(respPath, claimPath);
499
548
  } catch {
549
+ closeWatcher();
500
550
  return;
501
551
  }
552
+ let data;
502
553
  try {
503
- fs.unlinkSync(respPath);
504
- } catch {}
505
- const w = activeWatchers.get(id);
506
- 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));
507
565
  try {
508
- w.close();
566
+ fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`));
509
567
  } catch {}
510
- 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;
511
584
  }
512
- slog("watchAndInject fire", id, JSON.stringify(data).slice(0, 400));
513
- const effectiveSessionID = data?.sessionID || sessionID;
514
- const text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result);
515
585
  const sdkCall = async (method, ...argsList) => {
516
586
  let firstErr;
517
587
  for (const args of argsList) {
@@ -537,35 +607,60 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
537
607
  ];
538
608
  slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0, 300));
539
609
  let ok = false;
610
+ let firstErr;
540
611
  if (client?.session?.promptAsync) {
541
612
  try {
542
613
  await sdkCall(client.session.promptAsync.bind(client.session), ...shapes);
543
614
  ok = true;
544
- } catch {}
615
+ } catch (e) {
616
+ firstErr = e;
617
+ }
545
618
  }
546
619
  if (!ok && client?.session?.prompt) {
547
620
  try {
548
621
  await sdkCall(client.session.prompt.bind(client.session), ...shapes);
549
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 } } });
550
634
  } catch {}
635
+ closeWatcher();
636
+ return;
551
637
  }
552
638
  try {
553
- 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 } } });
554
649
  } catch {}
555
650
  };
556
- if (fs.existsSync(respPath)) {
557
- slog("watchAndInject fast-path", id);
558
- fire();
559
- return;
560
- }
561
651
  try {
562
652
  const w = fs.watch(dir, (_e, filename) => {
563
- if (filename === `response-${id}.json` && fs.existsSync(respPath))
653
+ if (filename === `response-${id}.json`)
564
654
  fire();
565
655
  });
566
656
  w.on("error", () => {});
567
657
  activeWatchers.set(id, w);
568
658
  } catch {}
659
+ if (fs.existsSync(respPath)) {
660
+ slog("watchAndInject fast-path", id);
661
+ fire();
662
+ return;
663
+ }
569
664
  }
570
665
  var server = async ({ client, directory }) => {
571
666
  const markerPath = path.join(directory, ".opencode", "learn-md-log.json");
@@ -823,6 +918,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
823
918
  try {
824
919
  const dir = pendingDir(directory);
825
920
  if (fs.existsSync(dir)) {
921
+ for (const f of fs.readdirSync(dir).filter((x) => x.includes(".claim-") && x.endsWith(".json"))) {
922
+ try {
923
+ const m = f.match(/^response-(.+)\.claim-.*\.json$/);
924
+ if (!m)
925
+ continue;
926
+ const rid = m[1];
927
+ const target = path.join(dir, `response-${rid}.json`);
928
+ const age = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
929
+ if (age > 30000 && !fs.existsSync(target)) {
930
+ fs.renameSync(path.join(dir, f), target);
931
+ slog("watchAndInject requeued stale claim", rid);
932
+ }
933
+ } catch {}
934
+ }
826
935
  for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
827
936
  try {
828
937
  const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
@@ -879,6 +988,19 @@ Explanation: ${j.explanation}${note}`;
879
988
  }
880
989
  } catch {}
881
990
  }
991
+ 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"))) {
992
+ try {
993
+ const m = f.match(/^response-(.+)\.json$/);
994
+ if (!m)
995
+ continue;
996
+ const rid = m[1];
997
+ const hasPending = fs.existsSync(path.join(dir, `quiz-${rid}.json`)) || fs.existsSync(path.join(dir, `quiz_batch-${rid}.json`));
998
+ if (!hasPending) {
999
+ fs.renameSync(path.join(dir, f), path.join(dir, `response-${rid}.orphaned-${Date.now()}.json`));
1000
+ slog("watchAndInject orphaned response parked (no pending context)", rid);
1001
+ }
1002
+ } catch {}
1003
+ }
882
1004
  }
883
1005
  } catch {}
884
1006
  return {
@@ -943,26 +1065,29 @@ Explanation: ${j.explanation}${note}`;
943
1065
  await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))));
944
1066
  } catch {}
945
1067
  },
946
- "tool.execute.before": async (input) => {
1068
+ "tool.execute.before": async (input, output) => {
947
1069
  try {
948
1070
  const ses = extractHookSessionID(input?.sessionID);
949
1071
  const mdFile = getMdFile(ses);
950
1072
  if (!mdFile || !ses)
951
1073
  return;
952
1074
  const toolName = input.tool;
953
- const args = input.args ?? {};
1075
+ const args = output?.args ?? input.args ?? {};
954
1076
  if (toolName === "question") {
955
- const q = args.question || args.header || "";
956
- const ctx2 = args.details?.trim() || undefined;
957
- const opts = Array.isArray(args.options) ? args.options : [];
958
1077
  const callID = input.callID;
959
1078
  const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined;
960
1079
  if (qkey && loggedToolCallIds.has(qkey))
961
1080
  return;
962
1081
  if (qkey)
963
1082
  loggedToolCallIds.add(qkey);
964
- if (q)
965
- await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout("Question", q, ctx2, opts)));
1083
+ const qs = Array.isArray(args.questions) ? args.questions : args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [];
1084
+ for (let i = 0;i < qs.length; i++) {
1085
+ const q = qs[i];
1086
+ if (!q?.question)
1087
+ continue;
1088
+ const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question");
1089
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])));
1090
+ }
966
1091
  }
967
1092
  } catch {}
968
1093
  },
@@ -978,11 +1103,21 @@ Explanation: ${j.explanation}${note}`;
978
1103
  if (akey && loggedToolCallIds.has(akey))
979
1104
  return;
980
1105
  if (toolName === "question") {
981
- const meta = output.metadata ?? {};
982
- let answers = meta.answers ?? [];
983
- if (!answers.length && output.output)
1106
+ const meta = output?.metadata ?? {};
1107
+ const inArgs = input?.args ?? {};
1108
+ const qs = Array.isArray(inArgs.questions) ? inArgs.questions : [];
1109
+ let answers = meta.answers;
1110
+ if (!Array.isArray(answers) || !answers.length) {
1111
+ const outText = typeof output?.output === "string" ? output.output.trim() : "";
1112
+ if (outText) {
1113
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])));
1114
+ if (akey)
1115
+ loggedToolCallIds.add(akey);
1116
+ return;
1117
+ }
984
1118
  answers = [];
985
- const details = { answers, status: "completed" };
1119
+ }
1120
+ const details = { answers, questions: qs, status: "completed" };
986
1121
  await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)));
987
1122
  if (akey)
988
1123
  loggedToolCallIds.add(akey);
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 = "";
@@ -2780,24 +2797,30 @@ var tui = async (api) => {
2780
2797
  }
2781
2798
  }
2782
2799
  } catch {}
2783
- try {
2784
- fs.unlinkSync(full);
2785
- } catch {}
2786
2800
  api.ui.dialog.clear();
2787
2801
  currentBySession.delete(curSid);
2788
2802
  setTimeout(processPending, 150);
2803
+ setTimeout(() => {
2804
+ try {
2805
+ if (fs.existsSync(respPath)) {
2806
+ tlog("learn-tui response NOT consumed, re-touching", answerId);
2807
+ try {
2808
+ writeJsonAtomic(respPath, JSON.parse(fs.readFileSync(respPath, "utf8")));
2809
+ } catch {}
2810
+ }
2811
+ } catch {}
2812
+ }, 8000);
2789
2813
  };
2790
2814
  const cancel = async () => {
2791
2815
  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 {}
2816
+ tlog("learn-tui cancelled", data.type, data.id);
2817
+ writeJsonAtomic(respPath, {
2818
+ id: data.id,
2819
+ type: data.type,
2820
+ cancelled: true,
2821
+ sessionID: data.sessionID,
2822
+ at: Date.now()
2823
+ });
2801
2824
  try {
2802
2825
  const sid = data.sessionID;
2803
2826
  if (sid) {
@@ -2833,9 +2856,6 @@ var tui = async (api) => {
2833
2856
  } catch {}
2834
2857
  }
2835
2858
  } catch {}
2836
- try {
2837
- fs.unlinkSync(full);
2838
- } catch {}
2839
2859
  api.ui.dialog.clear();
2840
2860
  currentBySession.delete(curSid);
2841
2861
  setTimeout(processPending, 150);
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.2",
4
+ "version": "1.2.4",
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,26 @@ export const tui: TuiPlugin = async (api) => {
862
875
  }
863
876
  }
864
877
  } catch {}
865
- try { fs.unlinkSync(full) } catch {}
878
+ // Server owns lifecycle: it unlinks the pending file after successful inject.
879
+ // Do NOT delete pending here — deleting it destroys recovery context if the server hasn't consumed the response yet.
866
880
  api.ui.dialog.clear()
867
881
  currentBySession.delete(curSid)
868
882
  setTimeout(processPending, 150)
883
+ // Watchdog: if the response is still unconsumed after 8s, re-touch to retrigger the server watch + log loudly.
884
+ // Safe: server claims via atomic rename, so a rewrite can never cause double-inject.
885
+ setTimeout(() => {
886
+ try {
887
+ if (fs.existsSync(respPath)) {
888
+ tlog("learn-tui response NOT consumed, re-touching", answerId)
889
+ try { writeJsonAtomic(respPath, JSON.parse(fs.readFileSync(respPath, "utf8"))) } catch {}
890
+ }
891
+ } catch {}
892
+ }, 8000)
869
893
  }
870
894
  const cancel = async () => {
871
895
  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 {}
896
+ tlog("learn-tui cancelled", (data as any).type, data!.id)
897
+ writeJsonAtomic(respPath, { id: data!.id, type: data!.type, cancelled: true, sessionID: (data as any).sessionID, at: Date.now() })
873
898
  try {
874
899
  const sid = (data as any).sessionID
875
900
  if (sid) {
@@ -885,7 +910,7 @@ export const tui: TuiPlugin = async (api) => {
885
910
  } catch {}
886
911
  }
887
912
  } catch {}
888
- try { fs.unlinkSync(full) } catch {}
913
+ // Server owns lifecycle: it unlinks the pending file after consuming the cancelled response.
889
914
  api.ui.dialog.clear()
890
915
  currentBySession.delete(curSid)
891
916
  setTimeout(processPending, 150)
package/plugins/learn.ts CHANGED
@@ -244,12 +244,31 @@ function answerCalloutQuiz(details: any): string {
244
244
  if (details?.explanation) { body.push(""); for (const line of String(details.explanation).split("\n")) body.push(line) }
245
245
  return callout(type, title, body)
246
246
  }
247
+ function answerCalloutQuestion(questions: any[], answers: string[][]): string {
248
+ const qs = Array.isArray(questions) ? questions : []
249
+ const ans = Array.isArray(answers) ? answers : []
250
+ if (!qs.length) {
251
+ const flat = ans.map(a => Array.isArray(a) ? a.join(", ") : String(a ?? "")).filter(Boolean)
252
+ return callout("example", "Answer", flat.length ? flat : ["(no answer)"])
253
+ }
254
+ const body = qs.map((q: any, i: number) => {
255
+ const header = q?.header || `Q${i + 1}`
256
+ const sel: string[] = Array.isArray(ans[i]) ? ans[i] as string[] : []
257
+ return `${header}: ${sel.length ? sel.join(", ") : "(no answer)"}`
258
+ })
259
+ return callout("example", "Answer", body)
260
+ }
247
261
  function answerCalloutAsk(details: any): string {
248
262
  const status = details?.status
249
263
  if (status === "cancelled") return callout("warning", "Question — cancelled", ["(user skipped)"])
250
264
  if (status === "unavailable") return callout("warning", "Question — unavailable", [details?.message || ""])
251
265
  const answers: any[] = details?.answers || []
252
- const body: string[] = answers.map((a) => { if (a.type === "other") return `Other: ${a.label}`; if (a.type === "text") return a.label; return `${a.index}. ${a.label}` })
266
+ // Native opencode shape: string[][] (per-question selected labels)
267
+ if (answers.length && (answers as any[]).every(a => Array.isArray(a))) {
268
+ const questions: any[] = details?.questions || []
269
+ return answerCalloutQuestion(questions, answers as string[][])
270
+ }
271
+ const body: string[] = answers.map((a) => { if (a.type === "other") return `Other: ${a.label}`; if (a.type === "text") return a.label; if (typeof a === "string") return a; return `${a.index}. ${a.label}` })
253
272
  if (body.length === 0) body.push("(no answer)")
254
273
  return callout("example", "Answer", body)
255
274
  }
@@ -310,6 +329,23 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
310
329
  }
311
330
  continue
312
331
  }
332
+ if (toolName === "question" && Array.isArray((input as any).questions)) {
333
+ const qs: any[] = (input as any).questions
334
+ if (st.status === "pending" || st.status === "running") {
335
+ qs.forEach((q: any, i: number) => {
336
+ if (!q?.question) return
337
+ blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []))
338
+ })
339
+ } else if (st.status === "completed") {
340
+ qs.forEach((q: any, i: number) => {
341
+ if (!q?.question) return
342
+ blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []))
343
+ })
344
+ const ans = Array.isArray(meta.answers) ? meta.answers : []
345
+ blocks.push(answerCalloutAsk({ answers: ans, questions: qs, status: "completed" }))
346
+ }
347
+ continue
348
+ }
313
349
  if (st.status === "pending" || st.status === "running") {
314
350
  if (input.question) {
315
351
  const opts = Array.isArray(input.options) ? input.options : []
@@ -446,14 +482,39 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
446
482
  if (!sessionID) { slog("watchAndInject no sessionID", id); return }
447
483
  const dir = pendingDir(directory)
448
484
  const respPath = path.join(dir, `response-${id}.json`)
449
- 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
450
491
  let data: any
451
- try { data = JSON.parse(fs.readFileSync(respPath, "utf8")) } catch { return }
452
- try { fs.unlinkSync(respPath) } catch {}
453
- const w = activeWatchers.get(id); if (w) { try { w.close() } catch {}; activeWatchers.delete(id) }
454
- slog("watchAndInject fire", id, JSON.stringify(data).slice(0,400))
455
- const effectiveSessionID = (data as any)?.sessionID || sessionID
456
- 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
+ }
457
518
  // opencode-loop sdk.js:24 — SDK returns {data,error}, it does NOT throw. Must inspect .error.
458
519
  const sdkCall = async (method: any, ...argsList: any[]) => {
459
520
  let firstErr: any
@@ -476,23 +537,39 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
476
537
  ]
477
538
  slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0,300))
478
539
  let ok = false
540
+ let firstErr: any
479
541
  // loopd host-adapter.ts:100 — promptAsync wakes the session (fire-and-forget turn)
480
542
  if (client?.session?.promptAsync) {
481
- 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 }
482
544
  }
483
545
  if (!ok && client?.session?.prompt) {
484
- 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 }
485
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: server owns lifecycle — consume claim + pending (TUI no longer deletes pending)
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)
486
562
  try {
487
- 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 } } })
488
564
  } catch {}
489
565
  }
490
- if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
491
566
  try {
492
- const w = fs.watch(dir, (_e, filename) => { if (filename === `response-${id}.json` && fs.existsSync(respPath)) void fire() })
567
+ const w = fs.watch(dir, (_e, filename) => { if (filename === `response-${id}.json`) void fire() })
493
568
  w.on("error", () => {})
494
569
  activeWatchers.set(id, w)
495
570
  } catch {}
571
+ // Recheck after arming to close the event gap (claim makes double-fire safe)
572
+ if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
496
573
  }
497
574
 
498
575
  // ────────────────────────────────────────────────────────────────────────────
@@ -744,6 +821,21 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
744
821
  try {
745
822
  const dir = pendingDir(directory)
746
823
  if (fs.existsSync(dir)) {
824
+ // 1) Requeue stale claims from consumers that died mid-flight (>30s old, no live response)
825
+ for (const f of fs.readdirSync(dir).filter(x => x.includes(".claim-") && x.endsWith(".json"))) {
826
+ try {
827
+ const m = f.match(/^response-(.+)\.claim-.*\.json$/)
828
+ if (!m) continue
829
+ const rid = m[1]!
830
+ const target = path.join(dir, `response-${rid}.json`)
831
+ const age = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs
832
+ if (age > 30000 && !fs.existsSync(target)) {
833
+ fs.renameSync(path.join(dir, f), target)
834
+ slog("watchAndInject requeued stale claim", rid)
835
+ }
836
+ } catch {}
837
+ }
838
+ // 2) Re-arm pending quizzes (fast-path inside watchAndInject consumes any waiting response)
747
839
  for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
748
840
  try {
749
841
  const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
@@ -793,6 +885,19 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
793
885
  }
794
886
  } catch {}
795
887
  }
888
+ // 3) Park legacy orphans: response with no pending context (pre-fix leftovers) — can't rebuild inject text, park loudly instead of rotting silently
889
+ 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"))) {
890
+ try {
891
+ const m = f.match(/^response-(.+)\.json$/)
892
+ if (!m) continue
893
+ const rid = m[1]!
894
+ const hasPending = fs.existsSync(path.join(dir, `quiz-${rid}.json`)) || fs.existsSync(path.join(dir, `quiz_batch-${rid}.json`))
895
+ if (!hasPending) {
896
+ fs.renameSync(path.join(dir, f), path.join(dir, `response-${rid}.orphaned-${Date.now()}.json`))
897
+ slog("watchAndInject orphaned response parked (no pending context)", rid)
898
+ }
899
+ } catch {}
900
+ }
796
901
  }
797
902
  } catch {}
798
903
 
@@ -849,24 +954,28 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
849
954
  await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
850
955
  } catch {}
851
956
  },
852
- "tool.execute.before": async (input) => {
957
+ "tool.execute.before": async (input, output) => {
853
958
  try {
854
959
  const ses = extractHookSessionID((input as any)?.sessionID)
855
960
  const mdFile = getMdFile(ses)
856
961
  if (!mdFile || !ses) return
857
962
  const toolName = (input as any).tool
858
- const args = (input as any).args ?? {}
859
- // Built-in `question` tool is used as fallback when TUI not alive; mirror it.
860
- // `ask_user_question` is already mirrored inside its own execute (with correct TUI handling), so skip to avoid duplicate.
963
+ // NOTE: per Hooks type, before-hook args live in output.args (input only has tool/sessionID/callID).
964
+ const args = (output as any)?.args ?? (input as any).args ?? {}
965
+ // Native `question` tool shape: {questions: [{question, header, options, multiple}]}
861
966
  if (toolName === "question") {
862
- const q = args.question || args.header || ""
863
- const ctx2 = args.details?.trim() || undefined
864
- const opts = Array.isArray(args.options) ? args.options : []
865
967
  const callID = (input as any).callID
866
968
  const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined
867
969
  if (qkey && loggedToolCallIds.has(qkey)) return
868
970
  if (qkey) loggedToolCallIds.add(qkey)
869
- if (q) await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout("Question", q, ctx2, opts)))
971
+ const qs: any[] = Array.isArray(args.questions) ? args.questions
972
+ : (args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [])
973
+ for (let i = 0; i < qs.length; i++) {
974
+ const q = qs[i]
975
+ if (!q?.question) continue
976
+ const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question")
977
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])))
978
+ }
870
979
  }
871
980
  } catch {}
872
981
  },
@@ -880,10 +989,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
880
989
  const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined
881
990
  if (akey && loggedToolCallIds.has(akey)) return
882
991
  if (toolName === "question") {
883
- const meta: any = (output as any).metadata ?? {}
884
- let answers: any[] = meta.answers ?? []
885
- if (!answers.length && (output as any).output) answers = []
886
- const details: any = { answers, status: "completed" }
992
+ const meta: any = (output as any)?.metadata ?? {}
993
+ const inArgs: any = (input as any)?.args ?? {}
994
+ const qs: any[] = Array.isArray(inArgs.questions) ? inArgs.questions : []
995
+ let answers: any = meta.answers
996
+ if (!Array.isArray(answers) || !answers.length) {
997
+ const outText = typeof (output as any)?.output === "string" ? ((output as any).output as string).trim() : ""
998
+ if (outText) {
999
+ await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])))
1000
+ if (akey) loggedToolCallIds.add(akey)
1001
+ return
1002
+ }
1003
+ answers = []
1004
+ }
1005
+ const details: any = { answers, questions: qs, status: "completed" }
887
1006
  await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)))
888
1007
  if (akey) loggedToolCallIds.add(akey)
889
1008
  }