@bybrawe/opencode-loop 0.5.19 → 0.5.21

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.21
4
+
5
+ - Verified server-plugin compatibility against OpenCode 1.18.15 and updated the development plugin dependency accordingly.
6
+ - Stopped clearing `command.execute.before` output parts. Current OpenCode still creates a command prompt turn for server-plugin slash commands, so control commands keep the valid tool-denied `opencode-loop-local` acknowledgement instead of producing an empty message.
7
+ - Prefer the current `session.compact` TUI command value while retaining `session_compact` and `session.summarize` as compatibility fallbacks.
8
+ - Fixed the comprehensive preflight failure test to use a cross-platform shell-safe Node expression.
9
+ - Added Ubuntu and Windows pull-request CI, and hardened npm publishing with full tests, tag/version verification, and `npm pack --dry-run`.
10
+ - Retained the v0.5.20 Windows state-write retry hardening and deterministic EPERM/partial-read regressions.
11
+
12
+ ## 0.5.20
13
+
14
+ - Fixed Windows TUI loop state writes failing with `EPERM` / `EEXIST` when renaming a project-local `*.tmp` over an existing session state file. Heartbeat and due-timer updates no longer drop jobs when antivirus, IDE indexers, or OpenCode snapshots briefly lock the destination.
15
+ - Write atomic state payloads under the OS temp directory first, then replace the target with rename plus a copy/unlink fallback and short retries, so project trees no longer accumulate `opencode-loop/*.tmp` files that OpenCode tried to git-snapshot.
16
+
3
17
  ## 0.5.19
4
18
 
5
19
  - Made the asynchronous goal prompt smoke test wait for the observable SDK call instead of assuming a single event-loop tick is always sufficient under load.
package/README.md CHANGED
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
16
16
 
17
17
  ## Current status
18
18
 
19
- **v0.5.19 hardens Goal Mode, package updates, and the background daemon.** Package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load.
19
+ **v0.5.21 targets current OpenCode 1.18.x compatibility and safer releases.** Server-plugin control commands keep their locked-down acknowledgement prompt instead of creating an empty command message, `/compact` prefers the current `session.compact` TUI command, and CI now covers Ubuntu and Windows before publishing. **v0.5.20 fixes Windows TUI state writes.** Session job state is written through the OS temp directory with rename plus copy/unlink fallback and short retries, so antivirus locks and OpenCode snapshots no longer drop `/loop` jobs with `EPERM` on rename. **v0.5.19** hardens Goal Mode, package updates, and the background daemon: package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load.
20
20
 
21
21
  The known update-related symptoms from older builds are fixed:
22
22
 
@@ -1087,6 +1087,8 @@ Recent plugin events are appended to:
1087
1087
  .opencode/opencode-loop/loop.log
1088
1088
  ```
1089
1089
 
1090
+ Add `.opencode/opencode-loop/` to project `.gitignore` when possible. Runtime state is rewritten often, and on Windows file locks from scanners or snapshot tools can interrupt in-place renames. Since v0.5.20 the plugin writes state payloads via the OS temp directory with a rename/copy fallback so jobs survive those locks; gitignoring the directory still keeps noise out of commits.
1091
+
1090
1092
  ## Example progress.md
1091
1093
 
1092
1094
  Create one with:
@@ -1130,6 +1132,11 @@ Improve the application in small safe steps.
1130
1132
 
1131
1133
  ## Changelog highlights
1132
1134
 
1135
+ ### v0.5.20
1136
+
1137
+ - Fixed Windows `EPERM` / `EEXIST` failures when rewriting `.opencode/opencode-loop/ses_*.json` during heartbeat and due-timer updates.
1138
+ - Atomic state payloads are staged outside the project so OpenCode no longer git-snapshots `opencode-loop/*.tmp` pathspecs.
1139
+
1133
1140
  ### v0.5.17
1134
1141
 
1135
1142
  - Added a tool-denied local command agent and restored the normal coding agent/model for scheduled iterations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -57,7 +57,7 @@
57
57
  "@opencode-ai/plugin": ">=1.4.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@opencode-ai/plugin": "^1.4.0"
60
+ "@opencode-ai/plugin": "^1.18.15"
61
61
  },
62
62
  "bin": {
63
63
  "opencode-loop": "scripts/install-node.mjs",
@@ -106,6 +106,7 @@ async function createHarness(options = {}) {
106
106
  hooks,
107
107
  records,
108
108
  sessionID,
109
+ stateFile,
109
110
  statuses,
110
111
  async command(command, argumentsText = "", output = { parts: [] }) {
111
112
  await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
@@ -242,7 +243,10 @@ async function testLifecycleAndCommandDedupe() {
242
243
  assert.equal(state.jobs.find((item) => item.name === "same").paused, false)
243
244
 
244
245
  const beforeReports = h.reportTexts().length
245
- await h.command("loop-status")
246
+ const statusOutput = { parts: [{ type: "text", text: "OpenCode Loop status command handled locally. Reply exactly: OK." }] }
247
+ await h.command("loop-status", "", statusOutput)
248
+ assert.equal(statusOutput.parts.length, 1, "handled commands must keep a valid acknowledgement prompt")
249
+ assert.match(statusOutput.parts[0].text, /Reply exactly: OK/)
246
250
  await h.commandEvent("loop-status", "", "msg_status_1")
247
251
  assert.equal(h.reportTexts().length, beforeReports + 1, "command.executed must not duplicate the before hook")
248
252
  await h.command("loop-status")
@@ -383,7 +387,7 @@ async function testStopsPreflightAndGoalLifecycle() {
383
387
 
384
388
  h = await createHarness()
385
389
  try {
386
- await h.command("loop", "5m --no-now --preflight \"node -e process.exit(7)\" continue")
390
+ await h.command("loop", "5m --no-now --preflight \"node -e process.exitCode=7\" continue")
387
391
  await h.command("loop-now")
388
392
  const state = await h.readState()
389
393
  assert.equal(state.jobs[0].paused, true)
@@ -475,6 +479,86 @@ async function testInitializationDoesNotWaitForLocalApi() {
475
479
  }
476
480
  }
477
481
 
482
+ async function testWindowsSafeStatePersistence() {
483
+ const h = await createHarness()
484
+ try {
485
+ const stateDir = path.join(h.directory, ".opencode", "opencode-loop")
486
+
487
+ // Rapid replacements exercise atomic overwrite of an existing state file.
488
+ // On Windows this used to fail with EPERM when rename targeted a locked file
489
+ // next to a project-local *.tmp, leaving jobs empty and the heartbeat stuck.
490
+ for (let index = 0; index < 25; index++) {
491
+ await h.command("loop", `10m --no-now --name sticky action-${index}`)
492
+ }
493
+
494
+ const state = await h.readState()
495
+ assert.equal(state.jobs.length, 1)
496
+ assert.equal(state.jobs[0].name, "sticky")
497
+ assert.equal(state.jobs[0].action, "action-24")
498
+
499
+ // Temp payloads must live outside the project so OpenCode git snapshots and
500
+ // Windows file locks do not see opencode-loop/*.tmp pathspecs.
501
+ const leftovers = (await fs.readdir(stateDir)).filter((name) => name.endsWith(".tmp"))
502
+ assert.deepEqual(leftovers, [], "state writes must not leave project-local temp files")
503
+
504
+ await h.command("loop-clear")
505
+ const cleared = await h.readState()
506
+ assert.equal(cleared.jobs.length, 0)
507
+ } finally {
508
+ await h.cleanup()
509
+ }
510
+ }
511
+
512
+ async function testWindowsStateRenameRetriesBeforeFallback() {
513
+ const h = await createHarness()
514
+ const originalRename = fs.rename
515
+ const originalCopyFile = fs.copyFile
516
+ let renameAttempts = 0
517
+ let fallbackCopies = 0
518
+ fs.rename = async (source, target) => {
519
+ if (target === h.stateFile && renameAttempts++ < 2) {
520
+ const error = new Error("simulated Windows destination lock")
521
+ error.code = "EPERM"
522
+ throw error
523
+ }
524
+ return await originalRename(source, target)
525
+ }
526
+ fs.copyFile = async (source, target, ...rest) => {
527
+ if (target === h.stateFile) fallbackCopies++
528
+ return await originalCopyFile(source, target, ...rest)
529
+ }
530
+ try {
531
+ await h.command("loop", "10m --no-now --name retry-safe keep-state")
532
+ const state = await h.readState()
533
+ assert.equal(state.jobs[0]?.name, "retry-safe")
534
+ assert.equal(renameAttempts, 3, "transient EPERM should retry rename")
535
+ assert.equal(fallbackCopies, 0, "successful rename retry should avoid non-atomic copy fallback")
536
+ } finally {
537
+ fs.rename = originalRename
538
+ fs.copyFile = originalCopyFile
539
+ await h.cleanup()
540
+ }
541
+ }
542
+
543
+ async function testStateReadRetriesTransientPartialJson() {
544
+ const h = await createHarness()
545
+ const originalReadFile = fs.readFile
546
+ try {
547
+ await h.command("loop", "10m --no-now --name sticky survive-partial-read")
548
+ let partialReads = 0
549
+ fs.readFile = async (filePath, ...rest) => {
550
+ if (filePath === h.stateFile && partialReads++ < 2) return "{"
551
+ return await originalReadFile(filePath, ...rest)
552
+ }
553
+ await h.command("loop-status")
554
+ assert.equal(partialReads, 3, "partial JSON should be retried before treating state as corrupt")
555
+ assert.equal(h.records.toasts.at(-1)?.message, "1 loop job(s).")
556
+ } finally {
557
+ fs.readFile = originalReadFile
558
+ await h.cleanup()
559
+ }
560
+ }
561
+
478
562
  await testParserAndPresets()
479
563
  await testLifecycleAndCommandDedupe()
480
564
  await testWatchScheduling()
@@ -482,5 +566,8 @@ await testActionRoutingAndSafety()
482
566
  await testStopsPreflightAndGoalLifecycle()
483
567
  await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
484
568
  await testInitializationDoesNotWaitForLocalApi()
569
+ await testWindowsSafeStatePersistence()
570
+ await testWindowsStateRenameRetriesBeforeFallback()
571
+ await testStateReadRetriesTransientPartialJson()
485
572
 
486
573
  console.log("OpenCode Loop comprehensive test passed")
@@ -92,7 +92,8 @@ try {
92
92
  sessionID,
93
93
  arguments: "Create proof.txt and verify it --max-turns 3",
94
94
  }, output)
95
- assert.equal(output.parts.length, 0, "a locally handled slash command must not start a placeholder model turn")
95
+ assert.equal(output.parts.length, 1, "a locally handled slash command must keep a valid acknowledgement prompt")
96
+ assert.equal(output.parts[0].text, "original command body")
96
97
 
97
98
  await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
98
99
  assert.equal(
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs"
2
+ import os from "node:os"
2
3
  import path from "node:path"
3
4
  import { spawn } from "node:child_process"
4
5
  import { tool } from "@opencode-ai/plugin/tool"
@@ -373,17 +374,85 @@ async function withStateWriteLock(directory, sessionID, fn) {
373
374
 
374
375
  async function readState(directory, sessionID) {
375
376
  const target = statePath(directory, sessionID)
377
+ const attempts = 5
378
+ for (let attempt = 0; attempt < attempts; attempt++) {
379
+ try {
380
+ const parsed = JSON.parse(await fs.readFile(target, "utf8"))
381
+ return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
382
+ } catch (error) {
383
+ if (error?.code === "ENOENT") return { version: 4, jobs: [] }
384
+ const transient = error instanceof SyntaxError || isRetriableStateWriteError(error)
385
+ if (!transient || attempt === attempts - 1) break
386
+ await delay(25 * (attempt + 1))
387
+ }
388
+ }
376
389
  try {
377
- const parsed = JSON.parse(await fs.readFile(target, "utf8"))
378
- return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
379
- } catch (error) {
380
- if (error?.code !== "ENOENT") {
390
+ await ensureDir(stateDir(directory))
391
+ await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
392
+ } catch {}
393
+ return { version: 4, jobs: [] }
394
+ }
395
+
396
+ function isRetriableStateWriteError(error) {
397
+ const code = error?.code
398
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
399
+ }
400
+
401
+ async function delay(ms) {
402
+ await new Promise((resolve) => setTimeout(resolve, ms))
403
+ }
404
+
405
+ // Windows often fails POSIX-style "write temp next to target, then rename over it":
406
+ // existing destinations can return EPERM/EEXIST while antivirus, IDE indexers, or
407
+ // OpenCode's own snapshotter briefly hold the state file. Temp files next to the
408
+ // target also show up in project git snapshots as *.tmp pathspec noise.
409
+ //
410
+ // Write the payload outside the project first, then replace the target with
411
+ // rename when possible and a copy/unlink fallback with short retries.
412
+ async function writeFileAtomically(target, contents, options = {}) {
413
+ const encoding = options.encoding || "utf8"
414
+ const attempts = Math.max(1, Number(options.attempts) || 5)
415
+ const temp = path.join(
416
+ os.tmpdir(),
417
+ `opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
418
+ )
419
+ await fs.writeFile(temp, contents, encoding)
420
+ try {
421
+ let lastError
422
+ // Prefer an atomic rename and retry transient Windows destination locks
423
+ // before falling back to a non-atomic copy/overwrite.
424
+ for (let attempt = 0; attempt < attempts; attempt++) {
381
425
  try {
382
- await ensureDir(stateDir(directory))
383
- await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
384
- } catch {}
426
+ await fs.rename(temp, target)
427
+ return
428
+ } catch (error) {
429
+ lastError = error
430
+ if (error?.code === "EXDEV") break
431
+ if (!isRetriableStateWriteError(error)) throw error
432
+ if (attempt < attempts - 1) await delay(25 * (attempt + 1))
433
+ }
434
+ }
435
+
436
+ for (let attempt = 0; attempt < attempts; attempt++) {
437
+ try {
438
+ await fs.copyFile(temp, target)
439
+ return
440
+ } catch (error) {
441
+ lastError = error
442
+ if (!isRetriableStateWriteError(error)) throw error
443
+ if (attempt < attempts - 1) await delay(25 * (attempt + 1))
444
+ }
385
445
  }
386
- return { version: 4, jobs: [] }
446
+
447
+ try {
448
+ await fs.writeFile(target, contents, encoding)
449
+ return
450
+ } catch (error) {
451
+ if (lastError && !error.cause) error.cause = lastError
452
+ throw error
453
+ }
454
+ } finally {
455
+ try { await fs.rm(temp, { force: true }) } catch {}
387
456
  }
388
457
  }
389
458
 
@@ -391,13 +460,8 @@ async function writeState(directory, sessionID, state) {
391
460
  await withStateWriteLock(directory, sessionID, async () => {
392
461
  await ensureDir(stateDir(directory))
393
462
  const target = statePath(directory, sessionID)
394
- const temp = `${target}.${process.pid}.${Date.now()}.tmp`
395
- try {
396
- await fs.writeFile(temp, JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2))
397
- await fs.rename(temp, target)
398
- } finally {
399
- try { await fs.rm(temp, { force: true }) } catch {}
400
- }
463
+ const payload = JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2)
464
+ await writeFileAtomically(target, payload)
401
465
  })
402
466
  }
403
467
 
@@ -471,7 +535,7 @@ async function compactSession(client, sessionID) {
471
535
  // current builds, while some older docs/examples mention the event value
472
536
  // (session.compact). Try the alias first, then the event value, then the
473
537
  // session summarize endpoint as a last resort.
474
- for (const command of ["session_compact", "session.compact"]) {
538
+ for (const command of ["session.compact", "session_compact"]) {
475
539
  try {
476
540
  await executeTuiCommand(client, command)
477
541
  return true
@@ -2156,12 +2220,9 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
2156
2220
  if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
2157
2221
 
2158
2222
  const handled = () => {
2159
- if (output && Array.isArray(output.parts)) {
2160
- // The command has already been completed through toasts/noReply prompts
2161
- // and local state. Leaving a placeholder prompt starts an unnecessary
2162
- // model turn; weaker agents may even call tools or spawn subagents.
2163
- output.parts.length = 0
2164
- }
2223
+ // OpenCode server plugins cannot currently cancel the command prompt turn.
2224
+ // Keep the command markdown acknowledgement intact so opencode-loop-local
2225
+ // receives a valid, tool-denied message instead of an empty parts array.
2165
2226
  return true
2166
2227
  }
2167
2228