@bybrawe/opencode-loop 0.5.26 → 0.5.28

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +9 -3
  3. package/package.json +8 -4
  4. package/scripts/comprehensive-test.mjs +49 -4
  5. package/scripts/comprehensive-watchdog.mjs +32 -0
  6. package/scripts/host-adapter-contract-test.mjs +147 -0
  7. package/scripts/host-bridge-parity-test.mjs +22 -0
  8. package/scripts/host-loop-canary.mjs +357 -0
  9. package/scripts/loopd-test.mjs +45 -18
  10. package/scripts/loopd.mjs +92 -22
  11. package/scripts/opencode2-host-canary.mjs +194 -0
  12. package/scripts/runtime-contract-test.mjs +59 -0
  13. package/scripts/runtime-event-test.mjs +78 -0
  14. package/scripts/runtime-scope-test.mjs +46 -0
  15. package/scripts/session-manager-test.mjs +48 -0
  16. package/scripts/v2-event-bridge-test.mjs +72 -0
  17. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  18. package/scripts/v2-host-contract-test.mjs +55 -0
  19. package/scripts/v2-plugin-sentinel-test.mjs +87 -0
  20. package/src/index.js +2304 -1864
  21. package/src/opencode/v1.js +1 -0
  22. package/src/source/core/args.js +281 -0
  23. package/src/source/core/jobs.js +80 -0
  24. package/src/source/core/process.js +51 -0
  25. package/src/source/core/state.js +211 -0
  26. package/src/source/legacy-v1.js +1944 -0
  27. package/src/source/opencode/adapter.js +21 -0
  28. package/src/source/opencode/host.js +134 -0
  29. package/src/source/opencode/sdk.js +38 -0
  30. package/src/source/opencode/session-context.js +50 -0
  31. package/src/source/opencode2/capabilities.js +69 -0
  32. package/src/source/opencode2/event-bridge.js +156 -0
  33. package/src/source/opencode2/experimental.js +16 -0
  34. package/src/source/opencode2/host-contract.js +68 -0
  35. package/src/source/runtime/events.js +113 -0
  36. package/src/source/runtime/observer.js +23 -0
  37. package/src/source/runtime/scope.js +56 -0
  38. package/src/source/runtime/session-manager.js +100 -0
  39. package/src/source/runtime/session-registry.js +58 -0
  40. package/src/source/runtime/timers.js +45 -0
  41. package/src/source/v1.js +69 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.27
4
+
5
+ - Rebase stale Loop state snapshots against the latest persisted job state before writing, preventing scheduler bookkeeping from erasing unrelated user pause/resume or other state changes.
6
+ - Add a deterministic concurrency regression that forces overlapping state reads/writes and proves a concurrent user pause survives a stale scheduler update.
7
+ - Harden `opencode-loopd` by pinning one exact OpenCode session across iterations instead of resolving `--continue` on every run; a fresh daemon session is created and then pinned when no existing session is available.
8
+ - Add per-run daemon timeouts (30 minutes by default, configurable with `--timeout`; `0s` disables) and timeout exit code 124, including descendant-process termination.
9
+ - Add daemon regressions for session pinning, timeout termination, and scheduled-task timeout propagation.
10
+ - Add a real OpenCode host canary that proves a three-run `/loop` performs exactly three autonomous turns on Ubuntu and Windows.
11
+ - Narrow the OpenCode plugin peer range to `>=1.4.0 <2` so a future breaking 2.x release is not treated as automatically compatible.
12
+
3
13
  ## 0.5.26
4
14
 
5
15
  - Added an explicit npm-based OpenCode installation path: `npm install -g @bybrawe/opencode-loop@latest` followed by `opencode-loop`.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon.
6
6
 
7
- > **Current release: `0.5.26`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
7
+ > **Current release: `0.5.27`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
8
8
 
9
9
  ## Install or update
10
10
 
@@ -304,7 +304,7 @@ Do not use a normal prompt loop when you mean to run `/compact`. Prefer:
304
304
 
305
305
  ## Background daemon
306
306
 
307
- The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background.
307
+ The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background. `opencode-loopd` resolves the session once at startup and pins that exact session for later iterations, so a newer unrelated session cannot steal the daemon. If no session exists, the daemon creates one and pins it before the second iteration. Each daemon run is bounded by `--timeout` (30 minutes by default; use `--timeout 0s` to disable).
308
308
 
309
309
  For long-running background jobs use:
310
310
 
@@ -321,7 +321,13 @@ opencode-loopd --project . --every 0s --prompt "continue from progress.md and im
321
321
  Select a model and agent:
322
322
 
323
323
  ```bash
324
- opencode-loopd --project . --every 0s --max-runs 1 --model provider/model --agent build --prompt-file loop-prompt.md
324
+ opencode-loopd --project . --every 0s --max-runs 1 --timeout 30m --model provider/model --agent build --prompt-file loop-prompt.md
325
+ ```
326
+
327
+ Pin a specific existing session when needed:
328
+
329
+ ```bash
330
+ opencode-loopd --project . --session ses_xxx --every 5m --prompt-file loop-prompt.md
325
331
  ```
326
332
 
327
333
  Limit total runs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.26",
3
+ "version": "0.5.28",
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",
@@ -18,8 +18,12 @@
18
18
  "CHANGELOG.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs",
22
- "test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
21
+ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
+ "build:plugin:npm": "npm run build:plugin",
23
+ "prepack": "node --check src/index.js",
24
+ "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs",
25
+ "test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
26
+ "canary:host": "node scripts/host-loop-canary.mjs",
23
27
  "install:global": "node scripts/install-node.mjs",
24
28
  "pack:zip": "node scripts/make-zip.mjs"
25
29
  },
@@ -54,7 +58,7 @@
54
58
  "url": "https://github.com/ByBrawe/opencode-loop/issues"
55
59
  },
56
60
  "peerDependencies": {
57
- "@opencode-ai/plugin": ">=1.4.0"
61
+ "@opencode-ai/plugin": ">=1.4.0 <2"
58
62
  },
59
63
  "devDependencies": {
60
64
  "@opencode-ai/plugin": "^1.18.15"
@@ -6,6 +6,18 @@ import path from "node:path"
6
6
  import OpenCodeLoopPlugin from "../src/index.js"
7
7
 
8
8
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
9
+
10
+ async function waitForValue(read, timeoutMs = 1_000, intervalMs = 10) {
11
+ const deadline = Date.now() + timeoutMs
12
+ let value
13
+ while (Date.now() < deadline) {
14
+ value = await read()
15
+ if (value) return value
16
+ await delay(intervalMs)
17
+ }
18
+ return await read()
19
+ }
20
+
9
21
  let harnessNumber = 0
10
22
 
11
23
  async function createHarness(options = {}) {
@@ -405,9 +417,11 @@ async function testNativeCompactionLifecycleAndFallback() {
405
417
  await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
406
418
  assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
407
419
  await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
408
- await delay(20)
409
- const state = await h.readState()
410
- assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
420
+ const state = await waitForValue(async () => {
421
+ const candidate = await h.readState()
422
+ return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
423
+ })
424
+ assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
411
425
  } finally {
412
426
  await h.cleanup()
413
427
  }
@@ -705,6 +719,36 @@ async function testStateReadRetriesTransientPartialJson() {
705
719
  }
706
720
  }
707
721
 
722
+ async function testStateRebasePreservesConcurrentPause() {
723
+ const h = await createHarness()
724
+ const originalRename = fs.rename
725
+ try {
726
+ await h.command("loop", "10m --no-now --name race continue safely")
727
+ h.statuses.set(h.sessionID, "busy")
728
+ let delayed = false
729
+ fs.rename = async (source, target, ...rest) => {
730
+ if (!delayed && path.resolve(target) === path.resolve(h.stateFile)) {
731
+ delayed = true
732
+ await delay(120)
733
+ }
734
+ return await originalRename.call(fs, source, target, ...rest)
735
+ }
736
+
737
+ const pause = h.command("loop-pause", "race")
738
+ await delay(20)
739
+ const runNow = h.command("loop-now", "race")
740
+ await Promise.all([pause, runNow])
741
+
742
+ const state = await h.readState()
743
+ const job = state.jobs.find((item) => item.name === "race")
744
+ assert.ok(job, "the concurrent state update must keep the loop job")
745
+ assert.equal(job.paused, true, "a stale scheduler snapshot must not erase a concurrent user pause")
746
+ } finally {
747
+ fs.rename = originalRename
748
+ await h.cleanup()
749
+ }
750
+ }
751
+
708
752
  await testParserAndPresets()
709
753
  await testLifecycleAndCommandDedupe()
710
754
  await testWatchScheduling()
@@ -718,5 +762,6 @@ await testInitializationDoesNotWaitForLocalApi()
718
762
  await testWindowsSafeStatePersistence()
719
763
  await testWindowsStateRenameRetriesBeforeFallback()
720
764
  await testStateReadRetriesTransientPartialJson()
765
+ await testStateRebasePreservesConcurrentPause()
721
766
 
722
- console.log("OpenCode Loop comprehensive test passed")
767
+ console.log("OpenCode Loop comprehensive test passed")
@@ -0,0 +1,32 @@
1
+ import { promises as fs } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+
6
+ const scriptsDir = path.dirname(fileURLToPath(import.meta.url))
7
+ const rootDir = path.resolve(scriptsDir, "..")
8
+ const sourcePath = path.join(scriptsDir, "comprehensive-test.mjs")
9
+ const timeoutMs = Math.max(5_000, Number(process.env.OPENCODE_LOOP_COMPREHENSIVE_TIMEOUT_MS) || 60_000)
10
+ const tempPath = path.join(os.tmpdir(), `opencode-loop-comprehensive-watchdog-${process.pid}-${Date.now()}.mjs`)
11
+
12
+ const source = await fs.readFile(sourcePath, "utf8")
13
+ const pluginURL = pathToFileURL(path.join(rootDir, "src", "index.js")).href
14
+ let instrumented = source.replace('import OpenCodeLoopPlugin from "../src/index.js"', `import OpenCodeLoopPlugin from ${JSON.stringify(pluginURL)}`)
15
+ instrumented = instrumented.replace(
16
+ /^await (test[A-Za-z0-9_]+)\(\)$/gm,
17
+ 'globalThis.__opencodeLoopActiveCase = "$1"; await $1(); globalThis.__opencodeLoopActiveCase = undefined',
18
+ )
19
+ if (instrumented === source) throw new Error("failed to instrument comprehensive test")
20
+
21
+ await fs.writeFile(tempPath, instrumented, "utf8")
22
+ const watchdog = setTimeout(() => {
23
+ const active = globalThis.__opencodeLoopActiveCase
24
+ throw new Error(`OpenCode Loop comprehensive test timed out after ${timeoutMs}ms${active ? ` while running ${active}` : ""}`)
25
+ }, timeoutMs)
26
+
27
+ try {
28
+ await import(`${pathToFileURL(tempPath).href}?run=${Date.now()}`)
29
+ } finally {
30
+ clearTimeout(watchdog)
31
+ try { await fs.rm(tempPath, { force: true }) } catch {}
32
+ }
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCodeHostAdapter } from "../src/source/opencode/adapter.js"
3
+ import {
4
+ activeRunCompletionFromMessages,
5
+ compactSession,
6
+ compactTuiCommandName,
7
+ executeTuiCommand,
8
+ orderedSessionMessages,
9
+ readRecentSessionMessages,
10
+ resolveCompactionModel,
11
+ } from "../src/source/opencode/host.js"
12
+ import {
13
+ deleteSessionExecutionContext,
14
+ getSessionExecutionContext,
15
+ } from "../src/source/opencode/session-context.js"
16
+
17
+ function messagesClient(messages) {
18
+ return { session: { messages: async () => ({ data: structuredClone(messages) }) } }
19
+ }
20
+
21
+ assert.equal(compactTuiCommandName("/compact"), "session_compact")
22
+ assert.equal(compactTuiCommandName("summarize"), "session_compact")
23
+ assert.equal(compactTuiCommandName("other"), undefined)
24
+
25
+ {
26
+ const calls = []
27
+ const client = { tui: { executeCommand: async (args) => {
28
+ calls.push(args)
29
+ if (args?.body) return { error: { message: "body shape rejected" } }
30
+ return { data: args.command }
31
+ } } }
32
+ assert.equal(await executeTuiCommand(client, "session_compact"), "session_compact")
33
+ assert.deepEqual(calls, [
34
+ { body: { command: "session_compact" } },
35
+ { command: "session_compact" },
36
+ ])
37
+ }
38
+
39
+ {
40
+ const calls = []
41
+ const client = { session: { messages: async (args) => {
42
+ calls.push(args)
43
+ if (args?.path?.id) throw new Error("id shape rejected")
44
+ return { data: [{ info: { id: "m1", role: "assistant" } }] }
45
+ } } }
46
+ const result = await readRecentSessionMessages(client, "ses_messages", "/tmp/project", 7)
47
+ assert.equal(result[0].info.id, "m1")
48
+ assert.equal(calls.length, 2)
49
+ }
50
+
51
+ {
52
+ const ordered = orderedSessionMessages([
53
+ { info: { id: "b", time: { created: 20 } } },
54
+ { info: { id: "a", time: { created: 10 } } },
55
+ { info: { id: "c", time: { created: 20 } } },
56
+ ])
57
+ assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"])
58
+
59
+ assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown")
60
+ assert.equal(await activeRunCompletionFromMessages(
61
+ "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
62
+ ), "incomplete")
63
+ assert.equal(await activeRunCompletionFromMessages(
64
+ "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
65
+ ), "completed")
66
+ }
67
+
68
+ {
69
+ const sessionID = "ses_message_model_contract"
70
+ deleteSessionExecutionContext(sessionID)
71
+ const client = messagesClient([
72
+ { info: { role: "assistant", time: { created: 10 }, model: { providerID: "old", modelID: "old" } } },
73
+ { info: { role: "assistant", time: { created: 20 }, model: { providerID: "provider", modelID: "model" } } },
74
+ ])
75
+ assert.deepEqual(await resolveCompactionModel("/tmp", client, sessionID), { providerID: "provider", modelID: "model" })
76
+ assert.deepEqual(getSessionExecutionContext(sessionID)?.model, { providerID: "provider", modelID: "model" })
77
+ deleteSessionExecutionContext(sessionID)
78
+ }
79
+
80
+ {
81
+ const tuiCalls = []
82
+ const client = {
83
+ app: { log: async () => ({ data: true }) },
84
+ tui: { executeCommand: async (args) => {
85
+ const command = args?.body?.command || args?.command
86
+ tuiCalls.push(command)
87
+ if (command === "session.compact") return { error: { message: "unsupported" } }
88
+ return { data: true }
89
+ } },
90
+ }
91
+ assert.equal(await compactSession("/tmp", client, "ses_tui"), true)
92
+ assert.ok(tuiCalls.includes("session.compact"))
93
+ assert.ok(tuiCalls.includes("session_compact"))
94
+ }
95
+
96
+ {
97
+ const summaries = []
98
+ const client = {
99
+ app: { log: async () => ({ data: true }) },
100
+ tui: { showToast: async () => ({ data: true }) },
101
+ session: { summarize: async (args) => { summaries.push(args); return { data: true } } },
102
+ }
103
+ assert.equal(await compactSession("/tmp", client, "ses_summary", "provider/model"), true)
104
+ assert.deepEqual(summaries[0], {
105
+ path: { id: "ses_summary" },
106
+ body: { providerID: "provider", modelID: "model", auto: false },
107
+ })
108
+ }
109
+
110
+ {
111
+ const calls = []
112
+ const client = {
113
+ app: { log: async (args) => { calls.push(["log", args]); return { data: true } } },
114
+ tui: {
115
+ executeCommand: async (args) => { calls.push(["command", args]); return { data: true } },
116
+ showToast: async (args) => { calls.push(["toast", args]); return { data: true } },
117
+ },
118
+ session: {
119
+ messages: async (args) => {
120
+ calls.push(["messages", args])
121
+ return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }
122
+ },
123
+ },
124
+ }
125
+ const adapter = createOpenCodeHostAdapter(client, "/bound/project")
126
+ assert.equal(Object.isFrozen(adapter), true)
127
+ assert.deepEqual(Object.keys(adapter).sort(), [
128
+ "activeRunCompletion",
129
+ "compactSession",
130
+ "executeTuiCommand",
131
+ "log",
132
+ "readRecentMessages",
133
+ "resolveCompactionModel",
134
+ "toast",
135
+ ])
136
+ await adapter.executeTuiCommand("session_compact")
137
+ await adapter.readRecentMessages("ses_adapter", 7)
138
+ assert.equal(await adapter.activeRunCompletion("ses_adapter", { startedAt: 5 }), "completed")
139
+ assert.deepEqual(await adapter.resolveCompactionModel("ses_adapter", "provider/model"), { providerID: "provider", modelID: "model" })
140
+ await adapter.log("info", "adapter log")
141
+ await adapter.toast("adapter toast", "warning")
142
+ const messageCall = calls.find(([kind]) => kind === "messages")?.[1]
143
+ assert.equal(messageCall?.query?.directory, "/bound/project")
144
+ assert.equal(messageCall?.query?.limit, 7)
145
+ }
146
+
147
+ console.log("OpenCode host adapter contract test passed")
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict"
2
+ import { promises as fs } from "node:fs"
3
+
4
+ const legacy = await fs.readFile("src/source/legacy-v1.js", "utf8")
5
+ const host = await fs.readFile("src/source/opencode/host.js", "utf8")
6
+
7
+ const legacyStart = legacy.indexOf("function fireSdk(")
8
+ const legacyEnd = legacy.indexOf("function guardLoopOwnedUserMessage(")
9
+ const hostStart = host.indexOf("export function fireSdk(")
10
+ assert.ok(legacyStart >= 0 && legacyEnd > legacyStart, "legacy host bridge block not found")
11
+ assert.ok(hostStart >= 0, "host bridge module block not found")
12
+
13
+ const normalize = (text) => text
14
+ .replace(/\bexport\s+(?=(?:async\s+)?function\b)/g, "")
15
+ .replace(/\r\n/g, "\n")
16
+ .trim()
17
+
18
+ const legacyBlock = normalize(legacy.slice(legacyStart, legacyEnd))
19
+ const hostBlock = normalize(host.slice(hostStart))
20
+ assert.equal(hostBlock, legacyBlock, "OpenCode host bridge drifted from the stable V1 implementation")
21
+
22
+ console.log("host bridge parity test passed")