@bybrawe/opencode-loop 0.6.6 → 0.6.7

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/src/server.js CHANGED
@@ -547,6 +547,7 @@ var init_jobs = __esm(() => {
547
547
 
548
548
  // src/source/core/state.js
549
549
  import { promises as fs } from "fs";
550
+ import { randomUUID as stateLockRandomUUID } from "crypto";
550
551
  import os from "os";
551
552
  import path3 from "path";
552
553
  function stateDir(directory) {
@@ -558,6 +559,192 @@ function statePath(directory, sessionID) {
558
559
  async function ensureDir(directory) {
559
560
  await fs.mkdir(directory, { recursive: true });
560
561
  }
562
+ function stateLockError(kind, detail) {
563
+ const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`);
564
+ error.code = "OPENCODE_LOOP_STATE_LOCK";
565
+ return error;
566
+ }
567
+ async function assertStatePathSafe(directory, target) {
568
+ const root = path3.resolve(directory);
569
+ const relative = path3.relative(root, path3.resolve(target));
570
+ if (relative === ".." || relative.startsWith(`..${path3.sep}`) || path3.isAbsolute(relative)) {
571
+ throw stateLockError("unsafe_path", `path escapes project: ${target}`);
572
+ }
573
+ let current = root;
574
+ for (const part of relative.split(path3.sep).filter(Boolean)) {
575
+ current = path3.join(current, part);
576
+ let info;
577
+ try {
578
+ info = await fs.lstat(current);
579
+ } catch (error) {
580
+ if (error?.code === "ENOENT")
581
+ return;
582
+ throw error;
583
+ }
584
+ if (info.isSymbolicLink())
585
+ throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`);
586
+ }
587
+ }
588
+ function stateProcessLockOwner() {
589
+ const token = stateLockRandomUUID();
590
+ return {
591
+ schemaVersion: 1,
592
+ pid: process.pid,
593
+ token,
594
+ acquiredAt: Date.now(),
595
+ candidateName: `.loop-lock-${process.pid}-${token}.json`
596
+ };
597
+ }
598
+ function stateProcessLockOwnerValid(owner) {
599
+ return owner?.schemaVersion === 1 && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token) && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0 && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json` && path3.basename(owner.candidateName) === owner.candidateName;
600
+ }
601
+ async function readStateProcessOwner(file) {
602
+ let raw;
603
+ for (let attempt = 0;; attempt++) {
604
+ try {
605
+ raw = await fs.readFile(file, "utf8");
606
+ break;
607
+ } catch (error) {
608
+ if (error?.code === "ENOENT")
609
+ return null;
610
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
611
+ throw error;
612
+ await delay(10 * (attempt + 1));
613
+ }
614
+ }
615
+ let owner;
616
+ try {
617
+ owner = JSON.parse(raw);
618
+ } catch {
619
+ throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`);
620
+ }
621
+ if (!stateProcessLockOwnerValid(owner))
622
+ throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`);
623
+ return owner;
624
+ }
625
+ function stateProcessAlive(pid) {
626
+ if (pid === process.pid)
627
+ return true;
628
+ try {
629
+ process.kill(pid, 0);
630
+ return true;
631
+ } catch (error) {
632
+ return error?.code !== "ESRCH";
633
+ }
634
+ }
635
+ async function removeStateProcessFile(file) {
636
+ for (let attempt = 0;; attempt++) {
637
+ try {
638
+ await fs.rm(file, { force: true });
639
+ return;
640
+ } catch (error) {
641
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
642
+ throw error;
643
+ await delay(10 * (attempt + 1));
644
+ }
645
+ }
646
+ }
647
+ async function claimStateProcessLock(candidate, canonical) {
648
+ for (let attempt = 0;; attempt++) {
649
+ try {
650
+ await fs.link(candidate, canonical);
651
+ return true;
652
+ } catch (error) {
653
+ if (error?.code === "EEXIST")
654
+ return false;
655
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
656
+ throw error;
657
+ await delay(10 * (attempt + 1));
658
+ }
659
+ }
660
+ }
661
+ async function createStateLockCandidate(directory, owner) {
662
+ const candidate = path3.join(stateDir(directory), owner.candidateName);
663
+ await assertStatePathSafe(directory, candidate);
664
+ await fs.writeFile(candidate, `${JSON.stringify(owner)}
665
+ `, { flag: "wx", mode: 384 });
666
+ return candidate;
667
+ }
668
+ async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
669
+ const cleanupFile = `${canonical}.cleanup`;
670
+ const cleanupOwner = stateProcessLockOwner();
671
+ const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner);
672
+ let elected = false;
673
+ try {
674
+ await assertStatePathSafe(directory, cleanupFile);
675
+ elected = await claimStateProcessLock(cleanupCandidate, cleanupFile);
676
+ if (!elected) {
677
+ const existing = await readStateProcessOwner(cleanupFile);
678
+ if (existing && !stateProcessAlive(existing.pid)) {
679
+ if (depth >= 6)
680
+ throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`);
681
+ await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1);
682
+ }
683
+ return false;
684
+ }
685
+ const current = await readStateProcessOwner(canonical);
686
+ if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid))
687
+ return false;
688
+ await assertStatePathSafe(directory, canonical);
689
+ await removeStateProcessFile(canonical);
690
+ const staleCandidate = path3.join(stateDir(directory), current.candidateName);
691
+ await assertStatePathSafe(directory, staleCandidate);
692
+ await removeStateProcessFile(staleCandidate).catch(() => {});
693
+ return true;
694
+ } finally {
695
+ if (elected) {
696
+ const current = await readStateProcessOwner(cleanupFile).catch(() => null);
697
+ if (current?.token === cleanupOwner.token)
698
+ await removeStateProcessFile(cleanupFile);
699
+ }
700
+ await removeStateProcessFile(cleanupCandidate).catch(() => {});
701
+ }
702
+ }
703
+ async function acquireStateProcessLock(directory, sessionID) {
704
+ const targetDirectory = stateDir(directory);
705
+ await assertStatePathSafe(directory, targetDirectory);
706
+ await ensureDir(targetDirectory);
707
+ await assertStatePathSafe(directory, targetDirectory);
708
+ const target = statePath(directory, sessionID);
709
+ const canonical = `${target}.lock`;
710
+ await assertStatePathSafe(directory, target);
711
+ await assertStatePathSafe(directory, canonical);
712
+ const owner = stateProcessLockOwner();
713
+ const candidate = await createStateLockCandidate(directory, owner);
714
+ const started = Date.now();
715
+ let acquired = false;
716
+ try {
717
+ while (true) {
718
+ await assertStatePathSafe(directory, canonical);
719
+ if (await claimStateProcessLock(candidate, canonical)) {
720
+ acquired = true;
721
+ break;
722
+ }
723
+ const current = await readStateProcessOwner(canonical);
724
+ if (current && !stateProcessAlive(current.pid))
725
+ await recoverDeadStateProcessLock(directory, canonical, current);
726
+ if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
727
+ throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`);
728
+ }
729
+ await delay(STATE_PROCESS_LOCK_POLL_MS);
730
+ }
731
+ return async () => {
732
+ const current = await readStateProcessOwner(canonical);
733
+ if (current?.token !== owner.token || current.pid !== owner.pid) {
734
+ throw stateLockError("lost", `session ${sessionID} lock ownership changed`);
735
+ }
736
+ await assertStatePathSafe(directory, canonical);
737
+ await removeStateProcessFile(canonical);
738
+ await removeStateProcessFile(candidate);
739
+ };
740
+ } catch (error) {
741
+ if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
742
+ await removeStateProcessFile(canonical).catch(() => {});
743
+ }
744
+ await removeStateProcessFile(candidate).catch(() => {});
745
+ throw error;
746
+ }
747
+ }
561
748
  function stateLockKey(directory, sessionID) {
562
749
  return `${path3.resolve(directory)}:${safeID(sessionID)}`;
563
750
  }
@@ -571,21 +758,32 @@ async function withStateWriteLock(directory, sessionID, fn) {
571
758
  const next = previous.catch(() => {}).then(() => current);
572
759
  stateWriteLocks.set(key, next);
573
760
  await previous.catch(() => {});
761
+ let unlock;
574
762
  try {
763
+ unlock = await acquireStateProcessLock(directory, sessionID);
575
764
  return await fn();
576
765
  } finally {
577
- release();
578
- if (stateWriteLocks.get(key) === next)
579
- stateWriteLocks.delete(key);
766
+ try {
767
+ if (unlock)
768
+ await unlock();
769
+ } finally {
770
+ release();
771
+ if (stateWriteLocks.get(key) === next)
772
+ stateWriteLocks.delete(key);
773
+ }
580
774
  }
581
775
  }
582
776
  async function readStateFile(directory, sessionID) {
583
777
  const target = statePath(directory, sessionID);
778
+ await assertStatePathSafe(directory, target);
584
779
  const attempts = 5;
585
780
  for (let attempt = 0;attempt < attempts; attempt++) {
586
781
  try {
587
782
  const parsed = JSON.parse(await fs.readFile(target, "utf8"));
588
- return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] };
783
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
784
+ throw new SyntaxError("invalid Loop state schema: jobs must be an array");
785
+ }
786
+ return { version: 4, jobs: parsed.jobs };
589
787
  } catch (error) {
590
788
  if (error?.code === "ENOENT")
591
789
  return { version: 4, jobs: [] };
@@ -599,7 +797,7 @@ async function readStateFile(directory, sessionID) {
599
797
  await ensureDir(stateDir(directory));
600
798
  await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
601
799
  } catch {}
602
- return { version: 4, jobs: [] };
800
+ throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`);
603
801
  }
604
802
  async function readState(directory, sessionID) {
605
803
  const state = await readStateFile(directory, sessionID);
@@ -729,28 +927,34 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
729
927
  }
730
928
  async function writeState(directory, sessionID, state) {
731
929
  await withStateWriteLock(directory, sessionID, async () => {
732
- await ensureDir(stateDir(directory));
733
930
  const target = statePath(directory, sessionID);
931
+ await assertStatePathSafe(directory, target);
932
+ const current = await readStateFile(directory, sessionID);
734
933
  const baseline = state?.[STATE_BASELINE];
735
934
  let jobs = structuredClone(state.jobs || []);
736
935
  if (Array.isArray(baseline)) {
737
- const current = await readStateFile(directory, sessionID);
738
936
  jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
739
- state.jobs = structuredClone(jobs);
740
- state[STATE_BASELINE] = structuredClone(jobs);
741
937
  }
742
938
  const payload = JSON.stringify({ version: 4, jobs }, null, 2);
743
939
  await writeFileAtomically(target, payload);
940
+ state.jobs = structuredClone(jobs);
941
+ if (Array.isArray(baseline))
942
+ state[STATE_BASELINE] = structuredClone(jobs);
744
943
  });
745
944
  }
746
945
  async function removeState(directory, sessionID) {
747
946
  await withStateWriteLock(directory, sessionID, async () => {
947
+ const target = statePath(directory, sessionID);
948
+ await assertStatePathSafe(directory, target);
748
949
  try {
749
- await fs.unlink(statePath(directory, sessionID));
750
- } catch {}
950
+ await fs.unlink(target);
951
+ } catch (error) {
952
+ if (error?.code !== "ENOENT")
953
+ throw error;
954
+ }
751
955
  });
752
956
  }
753
- var STATE_DIR = ".opencode/opencode-loop", STATE_BASELINE, stateWriteLocks;
957
+ var STATE_DIR = ".opencode/opencode-loop", STATE_BASELINE, stateWriteLocks, STATE_PROCESS_LOCK_TIMEOUT_MS = 5000, STATE_PROCESS_LOCK_POLL_MS = 20;
754
958
  var init_state = __esm(() => {
755
959
  STATE_BASELINE = Symbol("opencode-loop-state-baseline");
756
960
  stateWriteLocks = new Map;
@@ -1824,7 +2028,7 @@ function createNativeLoopRuntime(options = {}) {
1824
2028
  const run = scope.active;
1825
2029
  if (!run || scope.compaction || scope.busy)
1826
2030
  return result({ reason: "no-terminal-boundary" });
1827
- if (run.kind === "prompt" && !run.delivered || run.kind === "command" && !run.commandFinished || run.kind === "shell" && !run.shellFinished || ["compact", "cadence"].includes(run.kind) && !run.compactFinished) {
2031
+ if (run.kind === "prompt" && !run.delivered || run.kind === "command" && !run.commandFinished || run.kind === "shell" && !run.shellFinished || ["compact", "cadence"].includes(run.kind) && (!run.compactionStarted || !run.compactFinished)) {
1828
2032
  return result({ reason: "awaiting-owned-completion" });
1829
2033
  }
1830
2034
  clear(scope, "deadline");
@@ -2100,6 +2304,8 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
2100
2304
  }
2101
2305
  if (event.kind === "compaction" && event.action === "started") {
2102
2306
  scope.compaction ||= { ended: false, terminal: false };
2307
+ if (["compact", "cadence"].includes(scope.active?.kind) && event.reason === "manual" && event.inputID && event.inputID === scope.active.inboxID)
2308
+ scope.active.compactionStarted = true;
2103
2309
  scope.busy = true;
2104
2310
  clear(scope, "timer");
2105
2311
  return result({ reason: "compacting" });
@@ -2160,10 +2366,21 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
2160
2366
  return finish(scope);
2161
2367
  }
2162
2368
  if (event.kind === "session" && ["error", "failed", "interrupted"].includes(event.action) || event.kind === "compaction" && event.action === "failed") {
2369
+ const run = scope.active;
2370
+ if (event.kind === "compaction" && event.action === "failed") {
2371
+ const compactRun = ["compact", "cadence"].includes(run?.kind);
2372
+ const owned = compactRun && event.reason === "manual" && (event.inputID && event.inputID === run.inboxID || !event.inputID && run.compactionStarted);
2373
+ if (!run || compactRun && !owned) {
2374
+ delete scope.compaction;
2375
+ scope.busy = false;
2376
+ return result({ reason: "unowned-compaction-failed" });
2377
+ }
2378
+ }
2163
2379
  delete scope.compaction;
2164
2380
  scope.busy = false;
2165
- const run = scope.active;
2166
- const paused = await pauseActive(scope, event.reason || `${event.kind}-${event.action}`);
2381
+ const detail = String(event.error || "").replace(/\s+/g, " ").trim().slice(0, 160);
2382
+ const reason = event.kind === "compaction" ? `compaction-failed${detail ? `: ${detail}` : ""}` : event.reason || `${event.kind}-${event.action}`;
2383
+ const paused = await pauseActive(scope, reason);
2167
2384
  if (run && (run.delivered || ["compact", "cadence", "shell"].includes(run.kind))) {
2168
2385
  const state = await read(scope);
2169
2386
  const job = (state.jobs || []).find((entry) => entry.id === run.jobID);
@@ -2179,7 +2396,7 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
2179
2396
  if (event.kind === "compaction" && event.action === "ended") {
2180
2397
  scope.compaction ||= { ended: false, terminal: false };
2181
2398
  scope.compaction.ended = true;
2182
- if (["compact", "cadence"].includes(scope.active?.kind))
2399
+ if (["compact", "cadence"].includes(scope.active?.kind) && scope.active.compactionStarted && event.reason === "manual")
2183
2400
  scope.active.compactFinished = true;
2184
2401
  if (!scope.compaction.terminal)
2185
2402
  return result({ reason: "awaiting-execution-terminal" });
@@ -2663,7 +2880,7 @@ function normalizeOpenCode2NativeEvent(raw) {
2663
2880
  if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
2664
2881
  if (!sessionID)
2665
2882
  return;
2666
- return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory });
2883
+ return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory, reason: text2(data.reason), inputID: text2(data.inputID), error: text2(record2(data.error)?.message) });
2667
2884
  }
2668
2885
  if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
2669
2886
  if (!sessionID)
@@ -2964,7 +3181,7 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
2964
3181
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
2965
3182
  Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
2966
3183
  Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
2967
- The public OpenCode 2 plugin API does not expose manual session compaction. /loop-compact, --compact and --compact-every are rejected before job creation on V2; native automatic compaction remains host-owned and is observed through the compaction hook.
3184
+ Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
2968
3185
  An unfinished dedicated Goal reserves its session; Loop will not override it.
2969
3186
  --timeout pauses future iterations without aborting the current native model/tool/compaction operation.
2970
3187
  --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
@@ -3010,7 +3227,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
3010
3227
  prompt,
3011
3228
  command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
3012
3229
  wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
3013
- compact: undefined,
3230
+ compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact(request) : undefined,
3014
3231
  shell: (request) => shellHost.dispatch(request),
3015
3232
  cancel: undefined,
3016
3233
  onError: (error) => {
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs"
2
+ import { randomUUID as stateLockRandomUUID } from "node:crypto"
2
3
  import os from "node:os"
3
4
  import path from "node:path"
4
5
  import { safeID } from "./args.js"
@@ -12,6 +13,185 @@ function statePath(directory, sessionID) { return path.join(stateDir(directory),
12
13
  export async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
13
14
  export async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
14
15
 
16
+ const STATE_PROCESS_LOCK_TIMEOUT_MS = 5_000
17
+ const STATE_PROCESS_LOCK_POLL_MS = 20
18
+
19
+ function stateLockError(kind, detail) {
20
+ const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`)
21
+ error.code = "OPENCODE_LOOP_STATE_LOCK"
22
+ return error
23
+ }
24
+
25
+ async function assertStatePathSafe(directory, target) {
26
+ const root = path.resolve(directory)
27
+ const relative = path.relative(root, path.resolve(target))
28
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
29
+ throw stateLockError("unsafe_path", `path escapes project: ${target}`)
30
+ }
31
+ let current = root
32
+ for (const part of relative.split(path.sep).filter(Boolean)) {
33
+ current = path.join(current, part)
34
+ let info
35
+ try { info = await fs.lstat(current) }
36
+ catch (error) {
37
+ if (error?.code === "ENOENT") return
38
+ throw error
39
+ }
40
+ if (info.isSymbolicLink()) throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`)
41
+ }
42
+ }
43
+
44
+ function stateProcessLockOwner() {
45
+ const token = stateLockRandomUUID()
46
+ return {
47
+ schemaVersion: 1, pid: process.pid, token, acquiredAt: Date.now(),
48
+ candidateName: `.loop-lock-${process.pid}-${token}.json`,
49
+ }
50
+ }
51
+
52
+ function stateProcessLockOwnerValid(owner) {
53
+ return owner?.schemaVersion === 1
54
+ && Number.isSafeInteger(owner.pid) && owner.pid > 0
55
+ && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token)
56
+ && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0
57
+ && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json`
58
+ && path.basename(owner.candidateName) === owner.candidateName
59
+ }
60
+
61
+ async function readStateProcessOwner(file) {
62
+ let raw
63
+ for (let attempt = 0; ; attempt++) {
64
+ try { raw = await fs.readFile(file, "utf8"); break }
65
+ catch (error) {
66
+ if (error?.code === "ENOENT") return null
67
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
68
+ await delay(10 * (attempt + 1))
69
+ }
70
+ }
71
+ let owner
72
+ try { owner = JSON.parse(raw) }
73
+ catch { throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`) }
74
+ if (!stateProcessLockOwnerValid(owner)) throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`)
75
+ return owner
76
+ }
77
+
78
+ function stateProcessAlive(pid) {
79
+ if (pid === process.pid) return true
80
+ try { process.kill(pid, 0); return true }
81
+ catch (error) { return error?.code !== "ESRCH" }
82
+ }
83
+
84
+ async function removeStateProcessFile(file) {
85
+ for (let attempt = 0; ; attempt++) {
86
+ try { await fs.rm(file, { force: true }); return }
87
+ catch (error) {
88
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
89
+ await delay(10 * (attempt + 1))
90
+ }
91
+ }
92
+ }
93
+
94
+ async function claimStateProcessLock(candidate, canonical) {
95
+ for (let attempt = 0; ; attempt++) {
96
+ try { await fs.link(candidate, canonical); return true }
97
+ catch (error) {
98
+ if (error?.code === "EEXIST") return false
99
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
100
+ await delay(10 * (attempt + 1))
101
+ }
102
+ }
103
+ }
104
+
105
+ async function createStateLockCandidate(directory, owner) {
106
+ const candidate = path.join(stateDir(directory), owner.candidateName)
107
+ await assertStatePathSafe(directory, candidate)
108
+ await fs.writeFile(candidate, `${JSON.stringify(owner)}\n`, { flag: "wx", mode: 0o600 })
109
+ return candidate
110
+ }
111
+
112
+ // A cleanup-election hard link prevents two processes from deleting a newer
113
+ // lock while reclaiming a dead owner. Never reclaim a live or corrupt owner.
114
+ async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
115
+ const cleanupFile = `${canonical}.cleanup`
116
+ const cleanupOwner = stateProcessLockOwner()
117
+ const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner)
118
+ let elected = false
119
+ try {
120
+ await assertStatePathSafe(directory, cleanupFile)
121
+ elected = await claimStateProcessLock(cleanupCandidate, cleanupFile)
122
+ if (!elected) {
123
+ // The previous cleanup winner may itself have crashed. Reclaim that
124
+ // dead election through another hard-linked, owner-verified election.
125
+ // Never blindly unlink the election path: a live winner may replace it.
126
+ const existing = await readStateProcessOwner(cleanupFile)
127
+ if (existing && !stateProcessAlive(existing.pid)) {
128
+ if (depth >= 6) throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`)
129
+ await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1)
130
+ }
131
+ return false
132
+ }
133
+ const current = await readStateProcessOwner(canonical)
134
+ if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid)) return false
135
+ await assertStatePathSafe(directory, canonical)
136
+ await removeStateProcessFile(canonical)
137
+ const staleCandidate = path.join(stateDir(directory), current.candidateName)
138
+ await assertStatePathSafe(directory, staleCandidate)
139
+ await removeStateProcessFile(staleCandidate).catch(() => {})
140
+ return true
141
+ } finally {
142
+ if (elected) {
143
+ const current = await readStateProcessOwner(cleanupFile).catch(() => null)
144
+ if (current?.token === cleanupOwner.token) await removeStateProcessFile(cleanupFile)
145
+ }
146
+ await removeStateProcessFile(cleanupCandidate).catch(() => {})
147
+ }
148
+ }
149
+
150
+ async function acquireStateProcessLock(directory, sessionID) {
151
+ const targetDirectory = stateDir(directory)
152
+ await assertStatePathSafe(directory, targetDirectory)
153
+ await ensureDir(targetDirectory)
154
+ await assertStatePathSafe(directory, targetDirectory)
155
+ const target = statePath(directory, sessionID)
156
+ const canonical = `${target}.lock`
157
+ await assertStatePathSafe(directory, target)
158
+ await assertStatePathSafe(directory, canonical)
159
+ const owner = stateProcessLockOwner()
160
+ const candidate = await createStateLockCandidate(directory, owner)
161
+ const started = Date.now()
162
+ let acquired = false
163
+ try {
164
+ while (true) {
165
+ await assertStatePathSafe(directory, canonical)
166
+ if (await claimStateProcessLock(candidate, canonical)) {
167
+ acquired = true
168
+ break
169
+ }
170
+ const current = await readStateProcessOwner(canonical)
171
+ if (current && !stateProcessAlive(current.pid)) await recoverDeadStateProcessLock(directory, canonical, current)
172
+ if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
173
+ throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`)
174
+ }
175
+ await delay(STATE_PROCESS_LOCK_POLL_MS)
176
+ }
177
+ return async () => {
178
+ const current = await readStateProcessOwner(canonical)
179
+ if (current?.token !== owner.token || current.pid !== owner.pid) {
180
+ throw stateLockError("lost", `session ${sessionID} lock ownership changed`)
181
+ }
182
+ await assertStatePathSafe(directory, canonical)
183
+ await removeStateProcessFile(canonical)
184
+ await removeStateProcessFile(candidate)
185
+ }
186
+ } catch (error) {
187
+ if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
188
+ await removeStateProcessFile(canonical).catch(() => {})
189
+ }
190
+ await removeStateProcessFile(candidate).catch(() => {})
191
+ throw error
192
+ }
193
+ }
194
+
15
195
  function stateLockKey(directory, sessionID) {
16
196
  return `${path.resolve(directory)}:${safeID(sessionID)}`
17
197
  }
@@ -24,21 +204,30 @@ async function withStateWriteLock(directory, sessionID, fn) {
24
204
  const next = previous.catch(() => {}).then(() => current)
25
205
  stateWriteLocks.set(key, next)
26
206
  await previous.catch(() => {})
207
+ let unlock
27
208
  try {
209
+ unlock = await acquireStateProcessLock(directory, sessionID)
28
210
  return await fn()
29
211
  } finally {
30
- release()
31
- if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
212
+ try { if (unlock) await unlock() }
213
+ finally {
214
+ release()
215
+ if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
216
+ }
32
217
  }
33
218
  }
34
219
 
35
220
  async function readStateFile(directory, sessionID) {
36
221
  const target = statePath(directory, sessionID)
222
+ await assertStatePathSafe(directory, target)
37
223
  const attempts = 5
38
224
  for (let attempt = 0; attempt < attempts; attempt++) {
39
225
  try {
40
226
  const parsed = JSON.parse(await fs.readFile(target, "utf8"))
41
- return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
227
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
228
+ throw new SyntaxError("invalid Loop state schema: jobs must be an array")
229
+ }
230
+ return { version: 4, jobs: parsed.jobs }
42
231
  } catch (error) {
43
232
  if (error?.code === "ENOENT") return { version: 4, jobs: [] }
44
233
  const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
@@ -50,7 +239,7 @@ async function readStateFile(directory, sessionID) {
50
239
  await ensureDir(stateDir(directory))
51
240
  await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
52
241
  } catch {}
53
- return { version: 4, jobs: [] }
242
+ throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`)
54
243
  }
55
244
 
56
245
  export async function readState(directory, sessionID) {
@@ -189,23 +378,28 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
189
378
 
190
379
  export async function writeState(directory, sessionID, state) {
191
380
  await withStateWriteLock(directory, sessionID, async () => {
192
- await ensureDir(stateDir(directory))
193
381
  const target = statePath(directory, sessionID)
382
+ await assertStatePathSafe(directory, target)
383
+ const current = await readStateFile(directory, sessionID)
194
384
  const baseline = state?.[STATE_BASELINE]
195
385
  let jobs = structuredClone(state.jobs || [])
196
386
  if (Array.isArray(baseline)) {
197
- const current = await readStateFile(directory, sessionID)
198
387
  jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
199
- state.jobs = structuredClone(jobs)
200
- state[STATE_BASELINE] = structuredClone(jobs)
201
388
  }
202
389
  const payload = JSON.stringify({ version: 4, jobs }, null, 2)
203
390
  await writeFileAtomically(target, payload)
391
+ // A failed disk write must not advance the in-memory snapshot baseline.
392
+ state.jobs = structuredClone(jobs)
393
+ if (Array.isArray(baseline)) state[STATE_BASELINE] = structuredClone(jobs)
204
394
  })
205
395
  }
206
396
 
207
397
  export async function removeState(directory, sessionID) {
208
398
  await withStateWriteLock(directory, sessionID, async () => {
209
- try { await fs.unlink(statePath(directory, sessionID)) } catch {}
399
+ const target = statePath(directory, sessionID)
400
+ await assertStatePathSafe(directory, target)
401
+ try { await fs.unlink(target) }
402
+ catch (error) { if (error?.code !== "ENOENT") throw error }
210
403
  })
211
404
  }
405
+
@@ -67,7 +67,7 @@ export function normalizeOpenCode2NativeEvent(raw) {
67
67
  }
68
68
  if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
69
69
  if (!sessionID) return undefined
70
- return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory })
70
+ return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory, reason: text(data.reason), inputID: text(data.inputID), error: text(record(data.error)?.message) })
71
71
  }
72
72
  if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
73
73
  if (!sessionID) return undefined
@@ -21,7 +21,7 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
21
21
  Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
22
22
  Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
23
23
  Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
24
- The public OpenCode 2 plugin API does not expose manual session compaction. /loop-compact, --compact and --compact-every are rejected before job creation on V2; native automatic compaction remains host-owned and is observed through the compaction hook.
24
+ Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
25
25
  An unfinished dedicated Goal reserves its session; Loop will not override it.
26
26
  --timeout pauses future iterations without aborting the current native model/tool/compaction operation.
27
27
  --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
@@ -61,11 +61,9 @@ export const OpenCodeLoopNativePlugin = Object.freeze({
61
61
  prompt,
62
62
  command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
63
63
  wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
64
- // OpenCode 2 exposes ctx.shell as a hook domain, not an execution method,
65
- // and SessionDomain has no public compact/inbox-cancel action. Keep those
66
- // undocumented capabilities out of the V2 runtime instead of probing
67
- // private host fields that may disappear between releases.
68
- compact: undefined,
64
+ // Current V2 exposes public session.compact; older hosts may not.
65
+ // Shell execution and inbox cancellation remain separate from SessionDomain.
66
+ compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact(request) : undefined,
69
67
  shell: (request) => shellHost.dispatch(request),
70
68
  cancel: undefined,
71
69
  onError: (error) => { void appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {}) },