@bybrawe/opencode-loop 0.5.21 → 0.5.23
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 +15 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/comprehensive-test.mjs +31 -0
- package/scripts/smoke-test.mjs +1 -0
- package/src/index.js +51 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.23
|
|
4
|
+
|
|
5
|
+
- Track non-blocking `session.prompt` and `session.shell` dispatch promises instead of treating every fire-and-forget request as successfully started.
|
|
6
|
+
- Recover a rejected scheduler dispatch immediately: clear only the matching active run token, invalidate cached busy status, persist the failure, honor `--max-failures`, and reschedule through the normal scheduler path.
|
|
7
|
+
- Never replay a rejected prompt automatically; this avoids duplicate turns when OpenCode status/events are delayed or multiple instances are involved.
|
|
8
|
+
- Added a deterministic regression test for delayed prompt rejection and verified the error remains observable in plugin logs.
|
|
9
|
+
|
|
10
|
+
## 0.5.22
|
|
11
|
+
|
|
12
|
+
- Hardened compatibility with current OpenCode 1.18.15 while preserving the existing `>=1.4.0` peer range.
|
|
13
|
+
- Handled loop commands now also set `output.noReply = true`; OpenCode 1.18.x safely ignores the unknown field, while hosts that add the proposed `noReply` hook support can skip the acknowledgement model turn automatically.
|
|
14
|
+
- Added Bun runtime loading coverage so CI exercises the runtime OpenCode actually uses to load plugins, not only Node syntax/tests.
|
|
15
|
+
- Added explicit minimum-peer coverage against `@opencode-ai/plugin@1.4.0` and scheduled testing against the latest published OpenCode plugin package.
|
|
16
|
+
- Kept the v0.5.21 server-plugin acknowledgement fallback for current OpenCode versions where `command.execute.before` cannot cancel the prompt turn.
|
|
17
|
+
|
|
3
18
|
## 0.5.21
|
|
4
19
|
|
|
5
20
|
- Verified server-plugin compatibility against OpenCode 1.18.15 and updated the development plugin dependency accordingly.
|
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.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.
|
|
19
|
+
**v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.** **v0.5.22 adds forward-compatible OpenCode command handling plus Bun and peer-range compatibility CI.** **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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.23",
|
|
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",
|
|
@@ -75,6 +75,10 @@ async function createHarness(options = {}) {
|
|
|
75
75
|
assert.equal(args?.path?.id, sessionID)
|
|
76
76
|
const text = args.body.parts.map((part) => part.text || "").join("\n")
|
|
77
77
|
records.prompts.push({ text, noReply: args.body.noReply === true, agent: args.body.agent, model: args.body.model })
|
|
78
|
+
if (options.failPrompt) {
|
|
79
|
+
await delay(options.promptFailureDelayMs ?? 20)
|
|
80
|
+
throw new Error(options.promptFailureMessage || "simulated prompt dispatch failure")
|
|
81
|
+
}
|
|
78
82
|
return { data: true }
|
|
79
83
|
},
|
|
80
84
|
shell: async (args) => {
|
|
@@ -374,6 +378,32 @@ async function testActionRoutingAndSafety() {
|
|
|
374
378
|
}
|
|
375
379
|
}
|
|
376
380
|
|
|
381
|
+
async function testPromptDispatchFailureRecovery() {
|
|
382
|
+
const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 })
|
|
383
|
+
try {
|
|
384
|
+
await h.command("loop", "0s --no-now --name dispatch-failure --max-failures 1 recover from a rejected prompt dispatch")
|
|
385
|
+
await h.command("loop-now", "dispatch-failure")
|
|
386
|
+
|
|
387
|
+
let state
|
|
388
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
389
|
+
state = await h.readState()
|
|
390
|
+
if (state.jobs[0]?.lastFailureReason === "dispatch_failed") break
|
|
391
|
+
await delay(25)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const job = state.jobs[0]
|
|
395
|
+
assert.equal(job.failureCount, 1, "a rejected prompt dispatch must count as a scheduler failure")
|
|
396
|
+
assert.equal(job.paused, true, "--max-failures must pause after a rejected prompt dispatch")
|
|
397
|
+
assert.equal(job.lastFailureReason, "dispatch_failed")
|
|
398
|
+
assert.match(job.lastDispatchFailure, /simulated prompt dispatch failure/)
|
|
399
|
+
assert.ok(job.lastDispatchFailureAt > 0)
|
|
400
|
+
assert.equal(h.actionTexts().length, 1, "dispatch recovery must never replay the prompt automatically")
|
|
401
|
+
assert.ok(h.records.logs.some((entry) => entry.message === "session.prompt failed"), "the SDK rejection must remain observable")
|
|
402
|
+
} finally {
|
|
403
|
+
await h.cleanup()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
377
407
|
async function testStopsPreflightAndGoalLifecycle() {
|
|
378
408
|
let h = await createHarness()
|
|
379
409
|
try {
|
|
@@ -563,6 +593,7 @@ await testParserAndPresets()
|
|
|
563
593
|
await testLifecycleAndCommandDedupe()
|
|
564
594
|
await testWatchScheduling()
|
|
565
595
|
await testActionRoutingAndSafety()
|
|
596
|
+
await testPromptDispatchFailureRecovery()
|
|
566
597
|
await testStopsPreflightAndGoalLifecycle()
|
|
567
598
|
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
|
568
599
|
await testInitializationDoesNotWaitForLocalApi()
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -94,6 +94,7 @@ try {
|
|
|
94
94
|
}, output)
|
|
95
95
|
assert.equal(output.parts.length, 1, "a locally handled slash command must keep a valid acknowledgement prompt")
|
|
96
96
|
assert.equal(output.parts[0].text, "original command body")
|
|
97
|
+
assert.equal(output.noReply, true, "handled commands should request noReply for compatible OpenCode hosts")
|
|
97
98
|
|
|
98
99
|
await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
|
|
99
100
|
assert.equal(
|
package/src/index.js
CHANGED
|
@@ -510,9 +510,11 @@ async function sdkCall(method, ...argsList) {
|
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
function fireSdk(client, label, method, ...argsList) {
|
|
513
|
-
Promise.resolve()
|
|
514
|
-
|
|
515
|
-
|
|
513
|
+
const pending = Promise.resolve().then(() => sdkCall(method, ...argsList))
|
|
514
|
+
void pending.catch((error) => {
|
|
515
|
+
log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {})
|
|
516
|
+
})
|
|
517
|
+
return pending
|
|
516
518
|
}
|
|
517
519
|
|
|
518
520
|
async function executeTuiCommand(client, command) {
|
|
@@ -1449,6 +1451,36 @@ function clearActiveRun(sessionID) {
|
|
|
1449
1451
|
activeRuns.delete(sessionID)
|
|
1450
1452
|
}
|
|
1451
1453
|
|
|
1454
|
+
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
1455
|
+
const active = activeRuns.get(sessionID)
|
|
1456
|
+
if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
|
|
1457
|
+
|
|
1458
|
+
clearActiveRun(sessionID)
|
|
1459
|
+
sessionStatuses.delete(sessionID)
|
|
1460
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
1461
|
+
|
|
1462
|
+
const message = sdkErrorMessage(error)
|
|
1463
|
+
const state = await readState(directory, sessionID)
|
|
1464
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
|
|
1465
|
+
if (job) {
|
|
1466
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1467
|
+
job.lastFailureReason = "dispatch_failed"
|
|
1468
|
+
job.lastDispatchFailure = message.slice(0, 4000)
|
|
1469
|
+
job.lastDispatchFailureAt = now()
|
|
1470
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1471
|
+
job.paused = true
|
|
1472
|
+
await notifyJob(directory, job, "dispatch_failed")
|
|
1473
|
+
}
|
|
1474
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1475
|
+
await writeState(directory, sessionID, state)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
await appendLoopLog(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message })
|
|
1479
|
+
await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
|
|
1480
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
|
|
1481
|
+
return true
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1452
1484
|
function goalReportPath(directory, sessionID, job) {
|
|
1453
1485
|
return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
|
|
1454
1486
|
}
|
|
@@ -1786,7 +1818,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1786
1818
|
guardLoopOwnedUserMessage(sessionID)
|
|
1787
1819
|
const shellBody = { command, agent }
|
|
1788
1820
|
if (model) shellBody.model = model
|
|
1789
|
-
fireSdk(
|
|
1821
|
+
const dispatch = fireSdk(
|
|
1790
1822
|
client,
|
|
1791
1823
|
"session.shell",
|
|
1792
1824
|
client.session.shell.bind(client.session),
|
|
@@ -1794,7 +1826,7 @@ async function fireAction(directory, client, sessionID, job) {
|
|
|
1794
1826
|
{ path: { sessionID }, body: shellBody },
|
|
1795
1827
|
{ sessionID, ...shellBody },
|
|
1796
1828
|
)
|
|
1797
|
-
return { startsAssistantTurn: true }
|
|
1829
|
+
return { startsAssistantTurn: true, dispatch }
|
|
1798
1830
|
}
|
|
1799
1831
|
const prompt = await buildPrompt(directory, job)
|
|
1800
1832
|
const prefix = kind === "goal"
|
|
@@ -1806,7 +1838,7 @@ ${prompt}`
|
|
|
1806
1838
|
guardLoopOwnedUserMessage(sessionID)
|
|
1807
1839
|
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
1808
1840
|
if (model) promptBody.model = model
|
|
1809
|
-
fireSdk(
|
|
1841
|
+
const dispatch = fireSdk(
|
|
1810
1842
|
client,
|
|
1811
1843
|
"session.prompt",
|
|
1812
1844
|
client.session.prompt.bind(client.session),
|
|
@@ -1814,7 +1846,7 @@ ${prompt}`
|
|
|
1814
1846
|
{ path: { sessionID }, body: promptBody },
|
|
1815
1847
|
{ sessionID, ...promptBody },
|
|
1816
1848
|
)
|
|
1817
|
-
return { startsAssistantTurn: true }
|
|
1849
|
+
return { startsAssistantTurn: true, dispatch }
|
|
1818
1850
|
}
|
|
1819
1851
|
|
|
1820
1852
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
@@ -1939,7 +1971,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
|
1939
1971
|
}
|
|
1940
1972
|
let timer
|
|
1941
1973
|
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1942
|
-
|
|
1974
|
+
const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
|
|
1975
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken })
|
|
1976
|
+
if (result.dispatch) {
|
|
1977
|
+
void result.dispatch.catch((error) => {
|
|
1978
|
+
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
|
|
1979
|
+
.catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: sdkErrorMessage(recoveryError) }))
|
|
1980
|
+
})
|
|
1981
|
+
}
|
|
1943
1982
|
sessionStatuses.set(sessionID, "busy")
|
|
1944
1983
|
sessionStatusSeenAt.set(sessionID, now())
|
|
1945
1984
|
await reschedule(BUSY_RETRY_MS)
|
|
@@ -2220,9 +2259,10 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
2220
2259
|
if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
|
|
2221
2260
|
|
|
2222
2261
|
const handled = () => {
|
|
2223
|
-
// OpenCode
|
|
2224
|
-
//
|
|
2225
|
-
//
|
|
2262
|
+
// OpenCode 1.18.x ignores unknown hook output fields, while proposed/newer
|
|
2263
|
+
// hosts can honor noReply to skip the otherwise unavoidable model turn.
|
|
2264
|
+
// Keep acknowledgement parts intact as the compatibility fallback.
|
|
2265
|
+
if (output && typeof output === "object") output.noReply = true
|
|
2226
2266
|
return true
|
|
2227
2267
|
}
|
|
2228
2268
|
|