@bybrawe/opencode-loop 0.6.6 → 0.6.8

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.
@@ -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,218 @@ 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
+ // An exclusive-file fallback publishes an empty lock briefly before
72
+ // writing its owner. Allow a bounded read retry; stale empty locks still
73
+ // fail closed rather than being taken over.
74
+ if (raw.length === 0) {
75
+ for (let attempt = 0; attempt < 8 && raw.length === 0; attempt++) {
76
+ await delay(10 * (attempt + 1))
77
+ try { raw = await fs.readFile(file, "utf8") }
78
+ catch (error) {
79
+ if (error?.code === "ENOENT") return null
80
+ throw error
81
+ }
82
+ }
83
+ }
84
+ let owner
85
+ try { owner = JSON.parse(raw) }
86
+ catch { throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`) }
87
+ if (!stateProcessLockOwnerValid(owner)) throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`)
88
+ return owner
89
+ }
90
+
91
+ function stateProcessAlive(pid) {
92
+ if (pid === process.pid) return true
93
+ try { process.kill(pid, 0); return true }
94
+ catch (error) { return error?.code !== "ESRCH" }
95
+ }
96
+
97
+ async function removeStateProcessFile(file) {
98
+ for (let attempt = 0; ; attempt++) {
99
+ try { await fs.rm(file, { force: true }); return }
100
+ catch (error) {
101
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
102
+ await delay(10 * (attempt + 1))
103
+ }
104
+ }
105
+ }
106
+
107
+ // Keep atomic hard links on capable volumes. FAT/exFAT and some remote volumes
108
+ // reject them, so fall back to exclusive creation (O_EXCL) without overwriting
109
+ // any existing live lock or stale-owner cleanup election.
110
+ async function claimStateProcessLock(candidate, canonical) {
111
+ for (let attempt = 0; ; attempt++) {
112
+ try { await fs.link(candidate, canonical); return true }
113
+ catch (error) {
114
+ if (error?.code === "EEXIST") return false
115
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
116
+ const contents = await fs.readFile(candidate)
117
+ let handle
118
+ try { handle = await fs.open(canonical, "wx", 0o600) }
119
+ catch (failure) {
120
+ if (failure?.code === "EEXIST") return false
121
+ throw failure
122
+ }
123
+ try { await handle.writeFile(contents) }
124
+ catch (failure) {
125
+ await handle.close().catch(() => {})
126
+ await removeStateProcessFile(canonical).catch(() => {})
127
+ throw failure
128
+ }
129
+ await handle.close()
130
+ return true
131
+ }
132
+ if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
133
+ await delay(10 * (attempt + 1))
134
+ }
135
+ }
136
+ }
137
+
138
+ async function createStateLockCandidate(directory, owner) {
139
+ const candidate = path.join(stateDir(directory), owner.candidateName)
140
+ await assertStatePathSafe(directory, candidate)
141
+ await fs.writeFile(candidate, `${JSON.stringify(owner)}\n`, { flag: "wx", mode: 0o600 })
142
+ return candidate
143
+ }
144
+
145
+ // A cleanup-election hard link prevents two processes from deleting a newer
146
+ // lock while reclaiming a dead owner. Never reclaim a live or corrupt owner.
147
+ async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
148
+ const cleanupFile = `${canonical}.cleanup`
149
+ const cleanupOwner = stateProcessLockOwner()
150
+ const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner)
151
+ let elected = false
152
+ try {
153
+ await assertStatePathSafe(directory, cleanupFile)
154
+ elected = await claimStateProcessLock(cleanupCandidate, cleanupFile)
155
+ if (!elected) {
156
+ // The previous cleanup winner may itself have crashed. Reclaim that
157
+ // dead election through another hard-linked, owner-verified election.
158
+ // Never blindly unlink the election path: a live winner may replace it.
159
+ const existing = await readStateProcessOwner(cleanupFile)
160
+ if (existing && !stateProcessAlive(existing.pid)) {
161
+ if (depth >= 6) throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`)
162
+ await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1)
163
+ }
164
+ return false
165
+ }
166
+ const current = await readStateProcessOwner(canonical)
167
+ if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid)) return false
168
+ await assertStatePathSafe(directory, canonical)
169
+ await removeStateProcessFile(canonical)
170
+ const staleCandidate = path.join(stateDir(directory), current.candidateName)
171
+ await assertStatePathSafe(directory, staleCandidate)
172
+ await removeStateProcessFile(staleCandidate).catch(() => {})
173
+ return true
174
+ } finally {
175
+ if (elected) {
176
+ const current = await readStateProcessOwner(cleanupFile).catch(() => null)
177
+ if (current?.token === cleanupOwner.token) await removeStateProcessFile(cleanupFile)
178
+ }
179
+ await removeStateProcessFile(cleanupCandidate).catch(() => {})
180
+ }
181
+ }
182
+
183
+ async function acquireStateProcessLock(directory, sessionID) {
184
+ const targetDirectory = stateDir(directory)
185
+ await assertStatePathSafe(directory, targetDirectory)
186
+ await ensureDir(targetDirectory)
187
+ await assertStatePathSafe(directory, targetDirectory)
188
+ const target = statePath(directory, sessionID)
189
+ const canonical = `${target}.lock`
190
+ await assertStatePathSafe(directory, target)
191
+ await assertStatePathSafe(directory, canonical)
192
+ const owner = stateProcessLockOwner()
193
+ const candidate = await createStateLockCandidate(directory, owner)
194
+ const started = Date.now()
195
+ let acquired = false
196
+ try {
197
+ while (true) {
198
+ await assertStatePathSafe(directory, canonical)
199
+ if (await claimStateProcessLock(candidate, canonical)) {
200
+ acquired = true
201
+ break
202
+ }
203
+ const current = await readStateProcessOwner(canonical)
204
+ if (current && !stateProcessAlive(current.pid)) await recoverDeadStateProcessLock(directory, canonical, current)
205
+ if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
206
+ throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`)
207
+ }
208
+ await delay(STATE_PROCESS_LOCK_POLL_MS)
209
+ }
210
+ return async () => {
211
+ const current = await readStateProcessOwner(canonical)
212
+ if (current?.token !== owner.token || current.pid !== owner.pid) {
213
+ throw stateLockError("lost", `session ${sessionID} lock ownership changed`)
214
+ }
215
+ await assertStatePathSafe(directory, canonical)
216
+ await removeStateProcessFile(canonical)
217
+ await removeStateProcessFile(candidate)
218
+ }
219
+ } catch (error) {
220
+ if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
221
+ await removeStateProcessFile(canonical).catch(() => {})
222
+ }
223
+ await removeStateProcessFile(candidate).catch(() => {})
224
+ throw error
225
+ }
226
+ }
227
+
15
228
  function stateLockKey(directory, sessionID) {
16
229
  return `${path.resolve(directory)}:${safeID(sessionID)}`
17
230
  }
@@ -24,21 +237,30 @@ async function withStateWriteLock(directory, sessionID, fn) {
24
237
  const next = previous.catch(() => {}).then(() => current)
25
238
  stateWriteLocks.set(key, next)
26
239
  await previous.catch(() => {})
240
+ let unlock
27
241
  try {
242
+ unlock = await acquireStateProcessLock(directory, sessionID)
28
243
  return await fn()
29
244
  } finally {
30
- release()
31
- if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
245
+ try { if (unlock) await unlock() }
246
+ finally {
247
+ release()
248
+ if (stateWriteLocks.get(key) === next) stateWriteLocks.delete(key)
249
+ }
32
250
  }
33
251
  }
34
252
 
35
253
  async function readStateFile(directory, sessionID) {
36
254
  const target = statePath(directory, sessionID)
255
+ await assertStatePathSafe(directory, target)
37
256
  const attempts = 5
38
257
  for (let attempt = 0; attempt < attempts; attempt++) {
39
258
  try {
40
259
  const parsed = JSON.parse(await fs.readFile(target, "utf8"))
41
- return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
260
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
261
+ throw new SyntaxError("invalid Loop state schema: jobs must be an array")
262
+ }
263
+ return { version: 4, jobs: parsed.jobs }
42
264
  } catch (error) {
43
265
  if (error?.code === "ENOENT") return { version: 4, jobs: [] }
44
266
  const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
@@ -50,7 +272,7 @@ async function readStateFile(directory, sessionID) {
50
272
  await ensureDir(stateDir(directory))
51
273
  await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
52
274
  } catch {}
53
- return { version: 4, jobs: [] }
275
+ throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`)
54
276
  }
55
277
 
56
278
  export async function readState(directory, sessionID) {
@@ -189,23 +411,28 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
189
411
 
190
412
  export async function writeState(directory, sessionID, state) {
191
413
  await withStateWriteLock(directory, sessionID, async () => {
192
- await ensureDir(stateDir(directory))
193
414
  const target = statePath(directory, sessionID)
415
+ await assertStatePathSafe(directory, target)
416
+ const current = await readStateFile(directory, sessionID)
194
417
  const baseline = state?.[STATE_BASELINE]
195
418
  let jobs = structuredClone(state.jobs || [])
196
419
  if (Array.isArray(baseline)) {
197
- const current = await readStateFile(directory, sessionID)
198
420
  jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
199
- state.jobs = structuredClone(jobs)
200
- state[STATE_BASELINE] = structuredClone(jobs)
201
421
  }
202
422
  const payload = JSON.stringify({ version: 4, jobs }, null, 2)
203
423
  await writeFileAtomically(target, payload)
424
+ // A failed disk write must not advance the in-memory snapshot baseline.
425
+ state.jobs = structuredClone(jobs)
426
+ if (Array.isArray(baseline)) state[STATE_BASELINE] = structuredClone(jobs)
204
427
  })
205
428
  }
206
429
 
207
430
  export async function removeState(directory, sessionID) {
208
431
  await withStateWriteLock(directory, sessionID, async () => {
209
- try { await fs.unlink(statePath(directory, sessionID)) } catch {}
432
+ const target = statePath(directory, sessionID)
433
+ await assertStatePathSafe(directory, target)
434
+ try { await fs.unlink(target) }
435
+ catch (error) { if (error?.code !== "ENOENT") throw error }
210
436
  })
211
437
  }
438
+
@@ -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(() => {}) },
@@ -134,7 +134,7 @@ export function createNativeLoopRuntime(options = {}) {
134
134
  async function finish(scope) {
135
135
  const run = scope.active
136
136
  if (!run || scope.compaction || scope.busy) return result({ reason: "no-terminal-boundary" })
137
- if ((run.kind === "prompt" && !run.delivered) || (run.kind === "command" && !run.commandFinished) || (run.kind === "shell" && !run.shellFinished) || (["compact", "cadence"].includes(run.kind) && !run.compactFinished)) {
137
+ 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))) {
138
138
  return result({ reason: "awaiting-owned-completion" })
139
139
  }
140
140
  clear(scope, "deadline")
@@ -368,6 +368,14 @@ export function createNativeLoopRuntime(options = {}) {
368
368
  }
369
369
  if (event.kind === "compaction" && event.action === "started") {
370
370
  scope.compaction ||= { ended: false, terminal: false }
371
+ // Host versions may differ in compaction inbox-delivery events. The
372
+ // authoritative boundary is the published manual-compaction inputID
373
+ // matching our admitted durable item, not delivery alone.
374
+ if (
375
+ ["compact", "cadence"].includes(scope.active?.kind)
376
+ && event.reason === "manual"
377
+ && event.inputID && event.inputID === scope.active.inboxID
378
+ ) scope.active.compactionStarted = true
371
379
  scope.busy = true
372
380
  clear(scope, "timer")
373
381
  return result({ reason: "compacting" })
@@ -420,10 +428,28 @@ export function createNativeLoopRuntime(options = {}) {
420
428
  return finish(scope)
421
429
  }
422
430
  if ((event.kind === "session" && ["error", "failed", "interrupted"].includes(event.action)) || (event.kind === "compaction" && event.action === "failed")) {
431
+ const run = scope.active
432
+ // Unowned compaction failures must not pause unrelated scheduled Loop
433
+ // work. An automated host summary can fail when no Loop job owns it,
434
+ // and a different manual inputID cannot settle our pending request.
435
+ if (event.kind === "compaction" && event.action === "failed") {
436
+ const compactRun = ["compact", "cadence"].includes(run?.kind)
437
+ const owned = compactRun && event.reason === "manual" &&
438
+ ((event.inputID && event.inputID === run.inboxID) ||
439
+ (!event.inputID && run.compactionStarted))
440
+ if (!run || (compactRun && !owned)) {
441
+ delete scope.compaction
442
+ scope.busy = false
443
+ return result({ reason: "unowned-compaction-failed" })
444
+ }
445
+ }
423
446
  delete scope.compaction
424
447
  scope.busy = false
425
- const run = scope.active
426
- const paused = await pauseActive(scope, event.reason || `${event.kind}-${event.action}`)
448
+ const detail = String(event.error || "").replace(/\s+/g, " ").trim().slice(0, 160)
449
+ const reason = event.kind === "compaction"
450
+ ? `compaction-failed${detail ? `: ${detail}` : ""}`
451
+ : event.reason || `${event.kind}-${event.action}`
452
+ const paused = await pauseActive(scope, reason)
427
453
  if (run && (run.delivered || ["compact", "cadence", "shell"].includes(run.kind))) {
428
454
  const state = await read(scope)
429
455
  const job = (state.jobs || []).find((entry) => entry.id === run.jobID)
@@ -435,7 +461,7 @@ export function createNativeLoopRuntime(options = {}) {
435
461
  if (event.kind === "compaction" && event.action === "ended") {
436
462
  scope.compaction ||= { ended: false, terminal: false }
437
463
  scope.compaction.ended = true
438
- if (["compact", "cadence"].includes(scope.active?.kind)) scope.active.compactFinished = true
464
+ if (["compact", "cadence"].includes(scope.active?.kind) && scope.active.compactionStarted && event.reason === "manual") scope.active.compactFinished = true
439
465
  if (!scope.compaction.terminal) return result({ reason: "awaiting-execution-terminal" })
440
466
  } else if (event.kind === "session" && event.action === "idle") {
441
467
  if (scope.compaction) { scope.compaction.terminal = true; if (!scope.compaction.ended) return result({ reason: "awaiting-compaction-end" }) }