@bojackduy/opencode-learn 1.4.3 → 1.4.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/README.md CHANGED
@@ -58,8 +58,14 @@ Add to **both** configs (opencode needs server + TUI):
58
58
  ```
59
59
  **`~/.config/opencode/tui.json`** — TUI (`learn-tui`):
60
60
  ```jsonc
61
- { "plugin": ["@bojackduy/opencode-learn/tui"] }
61
+ { "plugin": ["@bojackduy/opencode-learn"] }
62
62
  ```
63
+ Use the **bare package name** in both files, not `@bojackduy/opencode-learn/tui`/`/server` —
64
+ opencode already picks the right export (`./tui` vs `./server`) based on which host loads it.
65
+ A scoped-package spec with a `/tui` or `/server` suffix is silently broken: `npm-package-arg`
66
+ parses the second slash as a local directory reference instead of a package+subpath reference,
67
+ so the plugin never resolves or activates, with **no error logged anywhere**.
68
+
63
69
  Restart OpenCode. Verify `/md_log`, `quiz`, `write_mermaid` appear in tool list.
64
70
 
65
71
  Local checkout:
package/dist/server.js CHANGED
@@ -540,40 +540,6 @@ function randomId() {
540
540
  } catch {}
541
541
  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
542
542
  }
543
- function ownerLockPath(dir, id) {
544
- return path.join(dir, `owner-${id}.lock`);
545
- }
546
- function acquireOwnerLock(dir, id) {
547
- const p = ownerLockPath(dir, id);
548
- const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
549
- try {
550
- fs.writeFileSync(p, payload, { flag: "wx" });
551
- return true;
552
- } catch {}
553
- try {
554
- const age = Date.now() - fs.statSync(p).mtimeMs;
555
- if (age > 15000) {
556
- fs.writeFileSync(p, payload, "utf8");
557
- return true;
558
- }
559
- } catch {
560
- try {
561
- fs.writeFileSync(p, payload, { flag: "wx" });
562
- return true;
563
- } catch {}
564
- }
565
- return false;
566
- }
567
- function refreshOwnerLock(dir, id) {
568
- try {
569
- fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8");
570
- } catch {}
571
- }
572
- function releaseOwnerLock(dir, id) {
573
- try {
574
- fs.unlinkSync(ownerLockPath(dir, id));
575
- } catch {}
576
- }
577
543
  var PENDING_TTL_MS = 24 * 60 * 60 * 1000;
578
544
  function isPendingExpired(j) {
579
545
  try {
@@ -604,16 +570,15 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
604
570
  }
605
571
  activeWatchers.get(id)?.();
606
572
  const dir = pendingDir(directory);
607
- if (!acquireOwnerLock(dir, id)) {
608
- slog("watchAndInject lock busy, another process owns this id", id);
609
- return;
610
- }
611
573
  const respPath = path.join(dir, `response-${id}.json`);
612
574
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
613
575
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
614
576
  let watcher;
615
577
  let pollTimer;
616
578
  let closed = false;
579
+ let failCount = 0;
580
+ let nextAllowedAt = 0;
581
+ let lastSeenMtime = 0;
617
582
  const closeWatcher = () => {
618
583
  if (closed)
619
584
  return;
@@ -625,10 +590,23 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
625
590
  clearInterval(pollTimer);
626
591
  if (activeWatchers.get(id) === closeWatcher)
627
592
  activeWatchers.delete(id);
628
- releaseOwnerLock(dir, id);
629
593
  };
630
594
  activeWatchers.set(id, closeWatcher);
631
595
  const fire = async (attempt = 0) => {
596
+ if (closed)
597
+ return;
598
+ try {
599
+ const mtime = fs.statSync(respPath).mtimeMs;
600
+ if (mtime !== lastSeenMtime) {
601
+ lastSeenMtime = mtime;
602
+ failCount = 0;
603
+ nextAllowedAt = 0;
604
+ }
605
+ } catch {
606
+ return;
607
+ }
608
+ if (Date.now() < nextAllowedAt)
609
+ return;
632
610
  try {
633
611
  fs.renameSync(respPath, claimPath);
634
612
  } catch {
@@ -710,8 +688,9 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
710
688
  }
711
689
  }
712
690
  if (!ok) {
713
- slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0, 200));
714
- closeWatcher();
691
+ failCount++;
692
+ nextAllowedAt = Date.now() + Math.min(30000, 1000 * 2 ** Math.min(failCount, 5));
693
+ slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, `retry in ~${Math.round((nextAllowedAt - Date.now()) / 1000)}s`, String(firstErr).slice(0, 200));
715
694
  try {
716
695
  fs.renameSync(claimPath, respPath);
717
696
  } catch {
@@ -720,7 +699,7 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
720
699
  } catch {}
721
700
  }
722
701
  try {
723
- await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
702
+ await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID}), will retry`, extra: { id } } });
724
703
  } catch {}
725
704
  return;
726
705
  }
@@ -744,7 +723,8 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
744
723
  watcher.on("error", () => {});
745
724
  } catch {}
746
725
  pollTimer = setInterval(() => {
747
- refreshOwnerLock(dir, id);
726
+ if (closed)
727
+ return;
748
728
  if (fs.existsSync(respPath)) {
749
729
  fire();
750
730
  return;
@@ -1014,6 +994,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1014
994
  try {
1015
995
  const dir = pendingDir(directory);
1016
996
  if (fs.existsSync(dir)) {
997
+ for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("owner-") && x.endsWith(".lock"))) {
998
+ try {
999
+ fs.unlinkSync(path.join(dir, f));
1000
+ } catch {}
1001
+ }
1017
1002
  for (const f of fs.readdirSync(dir).filter((x) => x.includes(".claim-") && x.endsWith(".json"))) {
1018
1003
  try {
1019
1004
  const m = f.match(/^response-(.+)\.claim-.*\.json$/);
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.4.3",
4
+ "version": "1.4.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",
@@ -82,9 +82,9 @@
82
82
  "dependencies": {
83
83
  "@mermaid-js/mermaid-cli": "^11.4.2",
84
84
  "@opencode-ai/plugin": "1.18.25",
85
- "@opentui/core": "^0.5.9",
86
- "@opentui/solid": "^0.5.9",
87
- "solid-js": "^1.9.15"
85
+ "@opentui/core": "0.5.11",
86
+ "@opentui/solid": "0.5.11",
87
+ "solid-js": "1.9.12"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@types/node": "^26.4.0",
package/plugins/learn.ts CHANGED
@@ -496,26 +496,14 @@ async function waitForResponse(directory: string, id: string, abort: AbortSignal
496
496
  })
497
497
  }
498
498
 
499
- // Cross-process single-writer lock: multiple opencode processes (separate windows/tabs) can be
500
- // attached to the same project directory, and each independently re-arms durable pending quizzes
501
- // on its own startup. Without this, every process spins up its own fs.watch/poll for the same id,
502
- // which is wasteful and combined with the TUI-side popup lock below is what caused a stray
503
- // duplicate answer to go unwatched (the "second recovered quiz never injects" bug).
504
- function ownerLockPath(dir: string, id: string) { return path.join(dir, `owner-${id}.lock`) }
505
- function acquireOwnerLock(dir: string, id: string): boolean {
506
- const p = ownerLockPath(dir, id)
507
- const payload = JSON.stringify({ pid: process.pid, at: Date.now() })
508
- try { fs.writeFileSync(p, payload, { flag: "wx" }); return true } catch {}
509
- try {
510
- const age = Date.now() - fs.statSync(p).mtimeMs
511
- if (age > 15000) { fs.writeFileSync(p, payload, "utf8"); return true }
512
- } catch {
513
- try { fs.writeFileSync(p, payload, { flag: "wx" }); return true } catch {}
514
- }
515
- return false
516
- }
517
- function refreshOwnerLock(dir: string, id: string) { try { fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8") } catch {} }
518
- function releaseOwnerLock(dir: string, id: string) { try { fs.unlinkSync(ownerLockPath(dir, id)) } catch {} }
499
+ // Single-consumer arbitration lives ENTIRELY in fire()'s atomic claim rename
500
+ // (response-<id>.json -> response-<id>.claim-<pid>.json): exactly one process can win it,
501
+ // so every live process may supervise every pending id with no coordination. An earlier
502
+ // revision gated supervision behind an owner lock file; that created an abandonment race
503
+ // (startup loser bailed permanently, winner died mid-watch, inject failure closed the only
504
+ // watcher) that silently dropped answers after restarts. The lock is deliberately gone —
505
+ // fewer mechanisms, fewer races. TUI-side popup ownership (opening-<id>.lock) is unaffected:
506
+ // it prevents double-ANSWERS, a different mechanism from double-inject.
519
507
 
520
508
  // Pending quizzes answered via the no-TUI fallback (native question / manual chat) leave a
521
509
  // pending file nobody will ever answer through the popup. Without expiry these rot forever:
@@ -548,25 +536,34 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
548
536
  if (!sessionID) { slog("watchAndInject no sessionID", id); return }
549
537
  activeWatchers.get(id)?.()
550
538
  const dir = pendingDir(directory)
551
- if (!acquireOwnerLock(dir, id)) { slog("watchAndInject lock busy, another process owns this id", id); return }
552
539
  const respPath = path.join(dir, `response-${id}.json`)
553
540
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
554
541
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
555
542
  let watcher: fs.FSWatcher | undefined
556
543
  let pollTimer: ReturnType<typeof setInterval> | undefined
557
544
  let closed = false
545
+ // Consecutive inject-failure backoff: never hot-loop the SDK while preserving the answer.
546
+ // A rewritten response file (TUI watchdog re-touch) resets the streak for an early retry.
547
+ let failCount = 0
548
+ let nextAllowedAt = 0
549
+ let lastSeenMtime = 0
558
550
  const closeWatcher = () => {
559
551
  if (closed) return
560
552
  closed = true
561
553
  try { watcher?.close() } catch {}
562
554
  if (pollTimer) clearInterval(pollTimer)
563
555
  if (activeWatchers.get(id) === closeWatcher) activeWatchers.delete(id)
564
- releaseOwnerLock(dir, id)
565
556
  }
566
557
  activeWatchers.set(id, closeWatcher)
567
558
  const fire = async (attempt = 0): Promise<void> => {
559
+ if (closed) return
560
+ try {
561
+ const mtime = fs.statSync(respPath).mtimeMs
562
+ if (mtime !== lastSeenMtime) { lastSeenMtime = mtime; failCount = 0; nextAllowedAt = 0 }
563
+ } catch { return } // no answer yet
564
+ if (Date.now() < nextAllowedAt) return // backing off after inject failure; response stays put
568
565
  // Atomic single-consumer claim: exactly one process proceeds (fixes double-inject across processes)
569
- try { fs.renameSync(respPath, claimPath) } catch { return } // no answer yet, or another process claimed it
566
+ try { fs.renameSync(respPath, claimPath) } catch { return } // another process claimed it first
570
567
  let data: any
571
568
  try {
572
569
  data = JSON.parse(fs.readFileSync(claimPath, "utf8"))
@@ -625,13 +622,16 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
625
622
  try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch (e) { firstErr = firstErr || e }
626
623
  }
627
624
  if (!ok) {
628
- slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0,200))
629
- closeWatcher()
625
+ // NEVER abandon: keep supervising so a later retry (or another live process) delivers.
626
+ // Closing the watcher here used to strand the preserved response with nobody watching.
627
+ failCount++
628
+ nextAllowedAt = Date.now() + Math.min(30000, 1000 * 2 ** Math.min(failCount, 5))
629
+ slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, `retry in ~${Math.round((nextAllowedAt - Date.now()) / 1000)}s`, String(firstErr).slice(0,200))
630
630
  try { fs.renameSync(claimPath, respPath) } catch {
631
631
  try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
632
632
  }
633
633
  try {
634
- await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
634
+ await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID}), will retry`, extra: { id } } })
635
635
  } catch {}
636
636
  return
637
637
  }
@@ -654,7 +654,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
654
654
  // fs.watch is lossy by design. Polling keeps persisted answers moving after a TUI/server restart
655
655
  // even when the filesystem event is dropped.
656
656
  pollTimer = setInterval(() => {
657
- refreshOwnerLock(dir, id)
657
+ if (closed) return
658
658
  if (fs.existsSync(respPath)) { void fire(); return }
659
659
  if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath)) closeWatcher()
660
660
  }, 500)
@@ -917,6 +917,12 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
917
917
  try {
918
918
  const dir = pendingDir(directory)
919
919
  if (fs.existsSync(dir)) {
920
+ // 0) Retired mechanism cleanup: owner-*.lock files belong to the removed owner-lock
921
+ // gate (single-consumer arbitration is the atomic claim rename now). Delete them so a
922
+ // dead owner's leftovers can never confuse future logic.
923
+ for (const f of fs.readdirSync(dir).filter(x => x.startsWith("owner-") && x.endsWith(".lock"))) {
924
+ try { fs.unlinkSync(path.join(dir, f)) } catch {}
925
+ }
920
926
  // 1) Requeue stale claims from consumers that died mid-flight (>30s old, no live response)
921
927
  for (const f of fs.readdirSync(dir).filter(x => x.includes(".claim-") && x.endsWith(".json"))) {
922
928
  try {
@@ -101,10 +101,13 @@ async function configurePlugins(isUninstall) {
101
101
  } else {
102
102
  // Keep non-learn plugins, add/update learn
103
103
  next = plugins.filter(v => !isLearnPluginSpec(v))
104
- // Determine if this is tui.json vs opencode.json
105
- const isTui = name.startsWith("tui.")
106
- if (isTui) next.push(`${packageName}/tui`)
107
- else next.push(packageName)
104
+ // Both tui.json and opencode.json use the bare package name: "@scope/name/tui" is
105
+ // silently broken for SCOPED packages — npm-package-arg parses a scoped spec with a
106
+ // second slash as a local "directory" reference instead of a package+subpath
107
+ // reference, so it never resolves and the plugin never activates (no error logged).
108
+ // The loader already picks the "./tui" vs "./server" export from package.json
109
+ // automatically based on which host (tui vs server) loads it.
110
+ next.push(packageName)
108
111
  // Deduplicate
109
112
  next = [...new Set(next)]
110
113
  }
@@ -113,8 +116,7 @@ async function configurePlugins(isUninstall) {
113
116
  if (!findRootProperty(source, "plugin") && !isUninstall) {
114
117
  const eol = source.includes("\r\n") ? "\r\n" : "\n"
115
118
  const indent = " "
116
- const isTui = name.startsWith("tui.")
117
- const spec = isTui ? `${packageName}/tui` : packageName
119
+ const spec = packageName
118
120
  const pluginStr = `,\n${indent}"plugin": ${formatPluginArray([spec], indent, eol)}`
119
121
  // Insert before final }
120
122
  const lastBrace = source.lastIndexOf("}")
@@ -127,8 +129,7 @@ async function configurePlugins(isUninstall) {
127
129
  if (e?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`)
128
130
  if (!isUninstall) {
129
131
  // Create new config file if it doesn't exist
130
- const isTui = name.startsWith("tui.")
131
- const spec = isTui ? `${packageName}/tui` : packageName
132
+ const spec = packageName
132
133
  // Only create opencode.jsonc and tui.jsonc by default
133
134
  if ((name === "opencode.jsonc" || name === "tui.jsonc") && !isUninstall) {
134
135
  const content = `{\n "plugin": ["${spec}"]\n}\n`
@@ -219,7 +220,7 @@ async function installOrUpdate() {
219
220
  console.log(` Agents: ${agentsCount} (researcher, mermaid-maker, svg-maker, classify)`)
220
221
  console.log(` Skills: ${skillsCount} (teach, visualize, marker-pdf-parser, notebooklm-lecture-notes)`)
221
222
  if (commandsCount) console.log(` Commands: ${commandsCount}`)
222
- console.log(` Plugin: ${packageName} (server) + ${packageName}/tui (TUI)`)
223
+ console.log(` Plugin: ${packageName} (server + TUI)`)
223
224
  console.log("\nRestart OpenCode to load plugins.")
224
225
  console.log(" /md_log <file> — mirror to Obsidian")
225
226
  console.log(" quiz / quiz_batch — graded checks")