@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 +14 -0
- package/README.md +8 -1
- package/package.json +2 -2
- package/scripts/comprehensive-test.mjs +89 -2
- package/scripts/smoke-test.mjs +2 -1
- package/src/index.js +83 -22
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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")
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
} catch
|
|
380
|
-
|
|
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
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|
395
|
-
|
|
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 ["
|
|
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
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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
|
|