@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 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.21",
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()
@@ -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
- .then(() => sdkCall(method, ...argsList))
515
- .catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
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
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
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 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.
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