@bojackduy/opencode-learn 1.4.0 → 1.4.2

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
@@ -210,7 +210,7 @@ function loadMdLinks(markerPath, directory) {
210
210
  for (const [ses, v] of Object.entries(links)) {
211
211
  const f = v?.file ?? (typeof v === "string" ? v : undefined);
212
212
  if (typeof ses === "string" && typeof f === "string" && fs.existsSync(f)) {
213
- mdLinks.set(ses, { file: f, directory: v?.directory || directory, linkedAt: v?.linkedAt || Date.now() });
213
+ mdLinks.set(ses, { file: f, directory: v?.directory || directory, linkedAt: v?.linkedAt || Date.now(), backfilledUntil: v?.backfilledUntil });
214
214
  n++;
215
215
  }
216
216
  }
@@ -355,19 +355,33 @@ function answerCalloutAsk(details) {
355
355
  body.push("(no answer)");
356
356
  return callout("example", "Answer", body);
357
357
  }
358
- async function backfillMdLog(client, sessionID, directory) {
358
+ async function backfillMdLog(client, sessionID, directory, markerPath) {
359
359
  const mdFile = getMdFile(sessionID);
360
360
  if (!mdFile || !sessionID)
361
361
  return 0;
362
+ const linkMeta = mdLinks.get(sessionID);
363
+ const already = linkMeta?.backfilledUntil;
362
364
  try {
363
365
  const res = await client.session.messages({ path: { id: sessionID }, query: { directory } });
364
366
  const data = res?.data ?? res;
365
- const entries = Array.isArray(data) ? data : [];
366
- if (!entries.length)
367
+ const allEntries = Array.isArray(data) ? data : [];
368
+ if (!allEntries.length)
367
369
  return 0;
370
+ let entries = allEntries;
371
+ if (already) {
372
+ const idx = allEntries.findIndex((e) => e?.info?.id === already);
373
+ if (idx < 0)
374
+ return 0;
375
+ entries = allEntries.slice(idx + 1);
376
+ if (!entries.length)
377
+ return 0;
378
+ }
368
379
  const blocks = [];
380
+ let lastID = already;
369
381
  for (const entry of entries) {
370
382
  const info = entry.info;
383
+ if (info?.id)
384
+ lastID = info.id;
371
385
  const parts = entry.parts ?? [];
372
386
  if (!info || !info.role)
373
387
  continue;
@@ -487,6 +501,10 @@ async function backfillMdLog(client, sessionID, directory) {
487
501
  `, "utf-8");
488
502
  }
489
503
  }
504
+ if (lastID && lastID !== already) {
505
+ mdLinks.set(sessionID, { ...linkMeta, file: mdFile, directory, backfilledUntil: lastID });
506
+ saveMdLinksForDirectory(markerPath, directory);
507
+ }
490
508
  return blocks.length;
491
509
  } catch (e) {
492
510
  slog("backfill failed", String(e));
@@ -522,6 +540,40 @@ function randomId() {
522
540
  } catch {}
523
541
  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
524
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
+ }
525
577
  var activeWatchers = new Map;
526
578
  function watchAndInject(client, directory, id, sessionID, buildText) {
527
579
  slog("watchAndInject start", id, sessionID);
@@ -531,6 +583,10 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
531
583
  }
532
584
  activeWatchers.get(id)?.();
533
585
  const dir = pendingDir(directory);
586
+ if (!acquireOwnerLock(dir, id)) {
587
+ slog("watchAndInject lock busy, another process owns this id", id);
588
+ return;
589
+ }
534
590
  const respPath = path.join(dir, `response-${id}.json`);
535
591
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
536
592
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
@@ -548,6 +604,7 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
548
604
  clearInterval(pollTimer);
549
605
  if (activeWatchers.get(id) === closeWatcher)
550
606
  activeWatchers.delete(id);
607
+ releaseOwnerLock(dir, id);
551
608
  };
552
609
  activeWatchers.set(id, closeWatcher);
553
610
  const fire = async (attempt = 0) => {
@@ -666,6 +723,7 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
666
723
  watcher.on("error", () => {});
667
724
  } catch {}
668
725
  pollTimer = setInterval(() => {
726
+ refreshOwnerLock(dir, id);
669
727
  if (fs.existsSync(respPath)) {
670
728
  fire();
671
729
  return;
@@ -1495,11 +1553,13 @@ Explanation: ${eFixed}`;
1495
1553
  return `File already linked to session ${ses.slice(0, 8)} \u2014 1-1-1 violation. Copy to a new file or md_unlog that session first.`;
1496
1554
  }
1497
1555
  }
1498
- mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() });
1556
+ const existingMeta = mdLinks.get(sessionID);
1557
+ const preservedWatermark = existingMeta?.file === resolved ? existingMeta.backfilledUntil : undefined;
1558
+ mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now(), backfilledUntil: preservedWatermark });
1499
1559
  saveMdLinksForDirectory(markerPath, directory);
1500
1560
  let backfilled = 0;
1501
1561
  try {
1502
- backfilled = await backfillMdLog(client, sessionID, directory);
1562
+ backfilled = await backfillMdLog(client, sessionID, directory, markerPath);
1503
1563
  } catch (e) {
1504
1564
  slog("backfill error", String(e));
1505
1565
  }
package/dist/tui.js CHANGED
@@ -2636,13 +2636,69 @@ var tui = async (api) => {
2636
2636
  return false;
2637
2637
  }
2638
2638
  };
2639
+ const popupLockPath = (id) => path.join(pendingDir, `opening-${id}.lock`);
2640
+ const tryClaimPopup = (id) => {
2641
+ const p = popupLockPath(id);
2642
+ try {
2643
+ fs.writeFileSync(p, String(process.pid), {
2644
+ flag: "wx"
2645
+ });
2646
+ return true;
2647
+ } catch {}
2648
+ try {
2649
+ const age = Date.now() - fs.statSync(p).mtimeMs;
2650
+ if (age > 20000) {
2651
+ fs.writeFileSync(p, String(process.pid), "utf8");
2652
+ return true;
2653
+ }
2654
+ } catch {
2655
+ try {
2656
+ fs.writeFileSync(p, String(process.pid), {
2657
+ flag: "wx"
2658
+ });
2659
+ return true;
2660
+ } catch {}
2661
+ }
2662
+ return false;
2663
+ };
2664
+ const refreshPopupClaim = (id) => {
2665
+ try {
2666
+ fs.writeFileSync(popupLockPath(id), String(process.pid), "utf8");
2667
+ } catch {}
2668
+ };
2669
+ const releasePopupClaim = (id) => {
2670
+ try {
2671
+ fs.unlinkSync(popupLockPath(id));
2672
+ } catch {}
2673
+ };
2639
2674
  const processPending = () => {
2640
2675
  const curSid = getCurrentSessionID();
2641
2676
  if (!curSid)
2642
2677
  return;
2643
2678
  let current = currentBySession.get(curSid);
2644
- if (current)
2645
- return;
2679
+ if (current) {
2680
+ const pendingStillExists = (() => {
2681
+ try {
2682
+ return fs.readdirSync(pendingDir).some((f) => f === `quiz-${current.id}.json` || f === `quiz_batch-${current.id}.json`);
2683
+ } catch {
2684
+ return true;
2685
+ }
2686
+ })();
2687
+ if (!pendingStillExists) {
2688
+ tlog("processPending stale current cleared (pending gone)", current.id);
2689
+ releasePopupClaim(current.id);
2690
+ currentBySession.delete(curSid);
2691
+ current = undefined;
2692
+ } else if (api.ui.dialog.open) {
2693
+ refreshPopupClaim(current.id);
2694
+ return;
2695
+ } else {
2696
+ tlog("processPending stale current cleared (dialog no longer open)", current.id);
2697
+ releasePopupClaim(current.id);
2698
+ currentBySession.delete(curSid);
2699
+ current = undefined;
2700
+ }
2701
+ }
2646
2702
  if (api.ui.dialog.open)
2647
2703
  return;
2648
2704
  let files = [];
@@ -2662,7 +2718,8 @@ var tui = async (api) => {
2662
2718
  return null;
2663
2719
  }
2664
2720
  }).filter(Boolean).filter((x) => !hasAnswerArtifact(x.j.id));
2665
- const pick = matching.find((x) => x.j.sessionID === curSid) || matching.find((x) => !x.j.sessionID);
2721
+ const byNewest = [...matching].sort((a, b) => (b.j?.timestamp || 0) - (a.j?.timestamp || 0));
2722
+ const pick = byNewest.find((x) => x.j.sessionID === curSid) || byNewest.find((x) => !x.j.sessionID);
2666
2723
  if (!pick)
2667
2724
  return;
2668
2725
  const file = pick.f;
@@ -2694,6 +2751,10 @@ var tui = async (api) => {
2694
2751
  data.sessionID = curSid;
2695
2752
  }
2696
2753
  } catch {}
2754
+ if (!tryClaimPopup(data.id)) {
2755
+ tlog("processPending popup already claimed elsewhere", data.id);
2756
+ return;
2757
+ }
2697
2758
  current = {
2698
2759
  id: data.id,
2699
2760
  type: data.type
@@ -2799,6 +2860,7 @@ var tui = async (api) => {
2799
2860
  }
2800
2861
  }
2801
2862
  } catch {}
2863
+ releasePopupClaim(answerId);
2802
2864
  api.ui.dialog.clear();
2803
2865
  currentBySession.delete(curSid);
2804
2866
  setTimeout(processPending, 150);
@@ -2858,6 +2920,7 @@ var tui = async (api) => {
2858
2920
  } catch {}
2859
2921
  }
2860
2922
  } catch {}
2923
+ releasePopupClaim(data.id);
2861
2924
  api.ui.dialog.clear();
2862
2925
  currentBySession.delete(curSid);
2863
2926
  setTimeout(processPending, 150);
@@ -2880,6 +2943,7 @@ var tui = async (api) => {
2880
2943
  }));
2881
2944
  } else {
2882
2945
  tlog("processPending unknown", data.type, data.id);
2946
+ releasePopupClaim(data.id);
2883
2947
  try {
2884
2948
  fs.unlinkSync(full);
2885
2949
  } catch {}
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.0",
4
+ "version": "1.4.2",
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",
@@ -787,18 +787,61 @@ export const tui: TuiPlugin = async (api) => {
787
787
  }
788
788
  }
789
789
 
790
+ // Cross-process single-popup lock: if the same session is open in more than one opencode
791
+ // window, each runs its own copy of this TUI plugin and independently polls the same
792
+ // pendingDir. Without this, two windows could both open the SAME quiz dialog and let the
793
+ // user answer twice — the second answer overwrites the (already-consumed) response file with
794
+ // nobody left watching it, so it silently rots as an orphan and never reaches the session
795
+ // ("the next quiz doesn't inject"). Only the window that wins this lock may show the dialog.
796
+ const popupLockPath = (id: string) => path.join(pendingDir, `opening-${id}.lock`)
797
+ const tryClaimPopup = (id: string): boolean => {
798
+ const p = popupLockPath(id)
799
+ try { fs.writeFileSync(p, String(process.pid), { flag: "wx" }); return true } catch {}
800
+ try {
801
+ const age = Date.now() - fs.statSync(p).mtimeMs
802
+ if (age > 20000) { fs.writeFileSync(p, String(process.pid), "utf8"); return true }
803
+ } catch {
804
+ try { fs.writeFileSync(p, String(process.pid), { flag: "wx" }); return true } catch {}
805
+ }
806
+ return false
807
+ }
808
+ const refreshPopupClaim = (id: string) => { try { fs.writeFileSync(popupLockPath(id), String(process.pid), "utf8") } catch {} }
809
+ const releasePopupClaim = (id: string) => { try { fs.unlinkSync(popupLockPath(id)) } catch {} }
810
+
790
811
  const processPending = () => {
791
812
  const curSid = getCurrentSessionID()
792
813
  if (!curSid) return
793
814
  let current = currentBySession.get(curSid) as { id: string; type: string } | undefined
794
- if (current) return
815
+ if (current) {
816
+ // Self-heal: the tracked dialog may have been dismissed through a path other than
817
+ // done()/cancel() (session/tab switch, TUI reconnect, dialog replaced externally),
818
+ // or its pending file may have been consumed externally. A stuck entry would block
819
+ // ALL future quizzes for this session forever — release it and rediscover below.
820
+ const pendingStillExists = (() => { try { return fs.readdirSync(pendingDir).some((f) => f === `quiz-${current!.id}.json` || f === `quiz_batch-${current!.id}.json`) } catch { return true } })()
821
+ if (!pendingStillExists) {
822
+ tlog("processPending stale current cleared (pending gone)", current.id)
823
+ releasePopupClaim(current.id)
824
+ currentBySession.delete(curSid)
825
+ current = undefined
826
+ } else if (api.ui.dialog.open) { refreshPopupClaim(current.id); return }
827
+ else {
828
+ tlog("processPending stale current cleared (dialog no longer open)", current.id)
829
+ releasePopupClaim(current.id)
830
+ currentBySession.delete(curSid)
831
+ current = undefined
832
+ }
833
+ }
795
834
  if (api.ui.dialog.open) return
796
835
  let files: string[] = []
797
836
  try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
798
837
  // Session-distinct: only show pending for current session.
799
838
  // Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
800
839
  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 => !hasAnswerArtifact(x!.j.id)) as Array<{f: string, j: any}>
801
- const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
840
+ // Newest-first: when several quizzes stack up for the same session (e.g. earlier ones
841
+ // answered manually in chat after a popup failure), the LATEST quiz is the live one the
842
+ // agent is waiting on. Alphabetical order would keep re-showing the oldest stuck quiz.
843
+ const byNewest = [...matching].sort((a, b) => (((b as any).j?.timestamp || 0) as number) - (((a as any).j?.timestamp || 0) as number))
844
+ const pick = byNewest.find(x => x.j.sessionID === curSid) || byNewest.find(x => !x.j.sessionID)
802
845
  if (!pick) return
803
846
  const file = pick.f
804
847
  const full = path.join(pendingDir, file)
@@ -817,6 +860,12 @@ export const tui: TuiPlugin = async (api) => {
817
860
  if (!exists) (data as any).sessionID = curSid
818
861
  }
819
862
  } catch {}
863
+ if (!tryClaimPopup(data.id)) {
864
+ // Another opencode window (same session open elsewhere) already owns this popup —
865
+ // don't show a second copy of it here, and don't answer it from this process.
866
+ tlog("processPending popup already claimed elsewhere", data.id)
867
+ return
868
+ }
820
869
  current = { id: data.id, type: data.type }
821
870
  currentBySession.set(curSid, current)
822
871
  const done = async (result: any) => {
@@ -884,6 +933,7 @@ export const tui: TuiPlugin = async (api) => {
884
933
  } catch {}
885
934
  // Keep the pending payload until the server confirms injection. It is the recovery context
886
935
  // needed to rebuild the prompt if either process exits after the answer is written.
936
+ releasePopupClaim(answerId)
887
937
  api.ui.dialog.clear()
888
938
  currentBySession.delete(curSid)
889
939
  setTimeout(processPending, 150)
@@ -918,13 +968,14 @@ export const tui: TuiPlugin = async (api) => {
918
968
  }
919
969
  } catch {}
920
970
  // Keep the pending payload until the server consumes the cancellation response.
971
+ releasePopupClaim(data!.id)
921
972
  api.ui.dialog.clear()
922
973
  currentBySession.delete(curSid)
923
974
  setTimeout(processPending, 150)
924
975
  }
925
976
  if (data.type === "quiz") { tlog("processPending quiz", data.id); api.ui.dialog.replace(() => <QuizDialog api={api} request={data as QuizPending} onSubmit={done} onCancel={cancel} />) }
926
977
  else if (data.type === "quiz_batch") { tlog("processPending quiz_batch", data.id, (data as QuizBatchPending).quizzes.length); api.ui.dialog.replace(() => <QuizBatchDialog api={api} request={data as QuizBatchPending} onSubmit={done} onCancel={cancel} />) }
927
- else { tlog("processPending unknown", (data as any).type, data.id); try { fs.unlinkSync(full) } catch {}; currentBySession.delete(curSid); return }
978
+ else { tlog("processPending unknown", (data as any).type, data.id); releasePopupClaim(data.id); try { fs.unlinkSync(full) } catch {}; currentBySession.delete(curSid); return }
928
979
  try { api.ui.dialog.setSize("large") } catch {}
929
980
  }
930
981
 
package/plugins/learn.ts CHANGED
@@ -138,7 +138,7 @@ function decodeQuizText(s: string | undefined): string | undefined {
138
138
  // md-log helpers (ported from .pi/extensions/md-log.ts)
139
139
  // 1-1-1 model: session : link : log file. No global file.
140
140
  // ────────────────────────────────────────────────────────────────────────────
141
- type MdLinkMeta = { file: string; directory: string; linkedAt: number }
141
+ type MdLinkMeta = { file: string; directory: string; linkedAt: number; backfilledUntil?: string }
142
142
  const mdLinks = new Map<string, MdLinkMeta>() // sessionID -> link
143
143
  const mdFileLocks = new Map<string, Promise<void>>()
144
144
  function withMdFileLock<T>(file: string, fn: () => T | Promise<T>): Promise<T> {
@@ -183,7 +183,7 @@ function loadMdLinks(markerPath: string, directory: string) {
183
183
  for (const [ses, v] of Object.entries<any>(links)) {
184
184
  const f = (v as any)?.file ?? (typeof v === "string" ? v : undefined)
185
185
  if (typeof ses === "string" && typeof f === "string" && fs.existsSync(f)) {
186
- mdLinks.set(ses, { file: f, directory: (v as any)?.directory || directory, linkedAt: (v as any)?.linkedAt || Date.now() })
186
+ mdLinks.set(ses, { file: f, directory: (v as any)?.directory || directory, linkedAt: (v as any)?.linkedAt || Date.now(), backfilledUntil: (v as any)?.backfilledUntil })
187
187
  n++
188
188
  }
189
189
  }
@@ -272,17 +272,32 @@ function answerCalloutAsk(details: any): string {
272
272
  if (body.length === 0) body.push("(no answer)")
273
273
  return callout("example", "Answer", body)
274
274
  }
275
- async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
275
+ async function backfillMdLog(client: any, sessionID: string, directory: string, markerPath: string): Promise<number> {
276
276
  const mdFile = getMdFile(sessionID)
277
277
  if (!mdFile || !sessionID) return 0
278
+ const linkMeta = mdLinks.get(sessionID)
279
+ const already = linkMeta?.backfilledUntil
278
280
  try {
279
281
  const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
280
282
  const data: any = res?.data ?? res
281
- const entries: any[] = Array.isArray(data) ? data : []
282
- if (!entries.length) return 0
283
+ const allEntries: any[] = Array.isArray(data) ? data : []
284
+ if (!allEntries.length) return 0
285
+ // Idempotency: skip everything up to (and including) the last message already backfilled,
286
+ // so re-linking the same file (md_log called again) never re-dumps history already written.
287
+ let entries = allEntries
288
+ if (already) {
289
+ const idx = allEntries.findIndex((e) => e?.info?.id === already)
290
+ // If the watermark message can't be found (pruned/compacted history), bail out rather
291
+ // than risk re-appending everything — live hooks still capture new messages going forward.
292
+ if (idx < 0) return 0
293
+ entries = allEntries.slice(idx + 1)
294
+ if (!entries.length) return 0
295
+ }
283
296
  const blocks: string[] = []
297
+ let lastID: string | undefined = already
284
298
  for (const entry of entries) {
285
299
  const info: any = entry.info
300
+ if (info?.id) lastID = info.id
286
301
  const parts: any[] = entry.parts ?? []
287
302
  if (!info || !info.role) continue
288
303
  if (info.role === "user") {
@@ -387,6 +402,12 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
387
402
  fs.writeFileSync(mdFile2, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
388
403
  }
389
404
  }
405
+ // Persist the watermark whenever we've examined new entries, even if none produced a block,
406
+ // so a repeated md_log call never re-scans (and never re-appends) the same history again.
407
+ if (lastID && lastID !== already) {
408
+ mdLinks.set(sessionID, { ...(linkMeta as MdLinkMeta), file: mdFile, directory, backfilledUntil: lastID })
409
+ saveMdLinksForDirectory(markerPath, directory)
410
+ }
390
411
  return blocks.length
391
412
  } catch (e) {
392
413
  slog("backfill failed", String(e))
@@ -475,6 +496,27 @@ async function waitForResponse(directory: string, id: string, abort: AbortSignal
475
496
  })
476
497
  }
477
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 {} }
519
+
478
520
  // Server-side inject (loopd pattern: host-adapter.ts:100 promptAsync + path.id + body.parts)
479
521
  const activeWatchers = new Map<string, () => void>()
480
522
  function watchAndInject(client: any, directory: string, id: string, sessionID: string, buildText: (result: any) => string) {
@@ -482,6 +524,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
482
524
  if (!sessionID) { slog("watchAndInject no sessionID", id); return }
483
525
  activeWatchers.get(id)?.()
484
526
  const dir = pendingDir(directory)
527
+ if (!acquireOwnerLock(dir, id)) { slog("watchAndInject lock busy, another process owns this id", id); return }
485
528
  const respPath = path.join(dir, `response-${id}.json`)
486
529
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
487
530
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
@@ -494,6 +537,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
494
537
  try { watcher?.close() } catch {}
495
538
  if (pollTimer) clearInterval(pollTimer)
496
539
  if (activeWatchers.get(id) === closeWatcher) activeWatchers.delete(id)
540
+ releaseOwnerLock(dir, id)
497
541
  }
498
542
  activeWatchers.set(id, closeWatcher)
499
543
  const fire = async (attempt = 0): Promise<void> => {
@@ -586,6 +630,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
586
630
  // fs.watch is lossy by design. Polling keeps persisted answers moving after a TUI/server restart
587
631
  // even when the filesystem event is dropped.
588
632
  pollTimer = setInterval(() => {
633
+ refreshOwnerLock(dir, id)
589
634
  if (fs.existsSync(respPath)) { void fire(); return }
590
635
  if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath)) closeWatcher()
591
636
  }, 500)
@@ -1330,11 +1375,16 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1330
1375
  return `File already linked to session ${ses.slice(0,8)} — 1-1-1 violation. Copy to a new file or md_unlog that session first.`
1331
1376
  }
1332
1377
  }
1333
- mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() })
1378
+ // Re-linking the SAME file for this session (e.g. md_log called again after a
1379
+ // restart/reconnect) must preserve the backfill watermark — otherwise every re-link
1380
+ // would re-dump the entire session history into the file a second time.
1381
+ const existingMeta = mdLinks.get(sessionID)
1382
+ const preservedWatermark = existingMeta?.file === resolved ? existingMeta.backfilledUntil : undefined
1383
+ mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now(), backfilledUntil: preservedWatermark })
1334
1384
  saveMdLinksForDirectory(markerPath, directory)
1335
1385
  // Backfill history for this session only
1336
1386
  let backfilled = 0
1337
- try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
1387
+ try { backfilled = await backfillMdLog(client, sessionID, directory, markerPath) } catch (e) { slog("backfill error", String(e)) }
1338
1388
  await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } })
1339
1389
  return `Linked: ${resolved} to session ${sessionID.slice(0,8)} — ${backfilled ? `${backfilled} entries backfilled — ` : ""}future messages for THIS session will be mirrored. Other sessions stay silent.`
1340
1390
  },