@bybrawe/opencode-loop 0.5.26 → 0.5.27

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,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.27",
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,9 @@
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",
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 && node --check scripts/host-loop-canary.mjs",
22
22
  "test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
23
+ "canary:host": "node scripts/host-loop-canary.mjs",
23
24
  "install:global": "node scripts/install-node.mjs",
24
25
  "pack:zip": "node scripts/make-zip.mjs"
25
26
  },
@@ -54,7 +55,7 @@
54
55
  "url": "https://github.com/ByBrawe/opencode-loop/issues"
55
56
  },
56
57
  "peerDependencies": {
57
- "@opencode-ai/plugin": ">=1.4.0"
58
+ "@opencode-ai/plugin": ">=1.4.0 <2"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@opencode-ai/plugin": "^1.18.15"
@@ -705,6 +705,36 @@ async function testStateReadRetriesTransientPartialJson() {
705
705
  }
706
706
  }
707
707
 
708
+ async function testStateRebasePreservesConcurrentPause() {
709
+ const h = await createHarness()
710
+ const originalRename = fs.rename
711
+ try {
712
+ await h.command("loop", "10m --no-now --name race continue safely")
713
+ h.statuses.set(h.sessionID, "busy")
714
+ let delayed = false
715
+ fs.rename = async (source, target, ...rest) => {
716
+ if (!delayed && path.resolve(target) === path.resolve(h.stateFile)) {
717
+ delayed = true
718
+ await delay(120)
719
+ }
720
+ return await originalRename.call(fs, source, target, ...rest)
721
+ }
722
+
723
+ const pause = h.command("loop-pause", "race")
724
+ await delay(20)
725
+ const runNow = h.command("loop-now", "race")
726
+ await Promise.all([pause, runNow])
727
+
728
+ const state = await h.readState()
729
+ const job = state.jobs.find((item) => item.name === "race")
730
+ assert.ok(job, "the concurrent state update must keep the loop job")
731
+ assert.equal(job.paused, true, "a stale scheduler snapshot must not erase a concurrent user pause")
732
+ } finally {
733
+ fs.rename = originalRename
734
+ await h.cleanup()
735
+ }
736
+ }
737
+
708
738
  await testParserAndPresets()
709
739
  await testLifecycleAndCommandDedupe()
710
740
  await testWatchScheduling()
@@ -718,5 +748,6 @@ await testInitializationDoesNotWaitForLocalApi()
718
748
  await testWindowsSafeStatePersistence()
719
749
  await testWindowsStateRenameRetriesBeforeFallback()
720
750
  await testStateReadRetriesTransientPartialJson()
751
+ await testStateRebasePreservesConcurrentPause()
721
752
 
722
753
  console.log("OpenCode Loop comprehensive test passed")
@@ -0,0 +1,343 @@
1
+ import assert from "node:assert/strict"
2
+ import { existsSync } from "node:fs"
3
+ import { createServer } from "node:http"
4
+ import net from "node:net"
5
+ import { spawn } from "node:child_process"
6
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+ import { fileURLToPath, pathToFileURL } from "node:url"
10
+
11
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
+ const isWindows = process.platform === "win32"
13
+ const LOOP_OBJECTIVE = "real host loop canary"
14
+
15
+ function resolveOpenCodeBinary() {
16
+ if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
17
+ const candidates = [
18
+ path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"),
19
+ path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"),
20
+ path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"),
21
+ ]
22
+ const found = candidates.find((candidate) => existsSync(candidate))
23
+ if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`)
24
+ return found
25
+ }
26
+
27
+ const opencodeBin = resolveOpenCodeBinary()
28
+
29
+ function appendLog(current, chunk, limit = 80_000) {
30
+ return (current + String(chunk)).slice(-limit)
31
+ }
32
+
33
+ async function seedConfigDependencies(dir) {
34
+ await mkdir(path.join(dir, "node_modules"), { recursive: true })
35
+ const dependencies = { "@opencode-ai/plugin": "*" }
36
+ await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
37
+ await writeFile(
38
+ path.join(dir, "package-lock.json"),
39
+ `${JSON.stringify({
40
+ name: "opencode-loop-host-canary-config",
41
+ lockfileVersion: 3,
42
+ requires: true,
43
+ packages: { "": { dependencies } },
44
+ }, null, 2)}\n`,
45
+ )
46
+ await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n")
47
+ }
48
+
49
+ async function reservePort() {
50
+ return await new Promise((resolve, reject) => {
51
+ const server = net.createServer()
52
+ server.once("error", reject)
53
+ server.listen(0, "127.0.0.1", () => {
54
+ const address = server.address()
55
+ if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
56
+ server.close((error) => error ? reject(error) : resolve(address.port))
57
+ })
58
+ })
59
+ }
60
+
61
+ async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
62
+ const deadline = Date.now() + timeoutMs
63
+ while (Date.now() < deadline) {
64
+ if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`)
65
+ const connected = await new Promise((resolve) => {
66
+ const socket = net.createConnection({ host: "127.0.0.1", port })
67
+ socket.once("connect", () => { socket.destroy(); resolve(true) })
68
+ socket.once("error", () => resolve(false))
69
+ socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
70
+ })
71
+ if (connected) return
72
+ await new Promise((resolve) => setTimeout(resolve, 100))
73
+ }
74
+ throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`)
75
+ }
76
+
77
+ async function stopProcess(child, timeoutMs = 2_000) {
78
+ if (!child || child.exitCode !== null) return
79
+ child.kill()
80
+ await new Promise((resolve) => {
81
+ if (child.exitCode !== null) return resolve()
82
+ const timer = setTimeout(resolve, timeoutMs)
83
+ child.once("close", () => { clearTimeout(timer); resolve() })
84
+ })
85
+ }
86
+
87
+ function spawnOpenCode(args, options = {}) {
88
+ return spawn(opencodeBin, args, { ...options, windowsHide: true })
89
+ }
90
+
91
+ async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) {
92
+ return await new Promise((resolve, reject) => {
93
+ const child = spawnOpenCode(args, { cwd, env })
94
+ let stdout = ""
95
+ let stderr = ""
96
+ let settled = false
97
+ child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
98
+ child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })
99
+ const finish = (fn, value) => {
100
+ if (settled) return
101
+ settled = true
102
+ clearTimeout(timer)
103
+ fn(value)
104
+ }
105
+ const timer = setTimeout(() => {
106
+ void stopProcess(child)
107
+ finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
108
+ }, timeoutMs)
109
+ child.once("error", (error) => finish(reject, error))
110
+ child.once("close", (code) => {
111
+ if (code !== 0) return finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
112
+ finish(resolve, { stdout, stderr })
113
+ })
114
+ })
115
+ }
116
+
117
+ function contentText(content) {
118
+ if (typeof content === "string") return content
119
+ if (!Array.isArray(content)) return ""
120
+ return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
121
+ }
122
+
123
+ function allMessageText(body) {
124
+ return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n")
125
+ }
126
+
127
+ function streamHeaders(res) {
128
+ res.writeHead(200, {
129
+ "content-type": "text/event-stream; charset=utf-8",
130
+ "cache-control": "no-cache",
131
+ connection: "keep-alive",
132
+ })
133
+ }
134
+
135
+ function writeSse(res, value) {
136
+ res.write(`data: ${JSON.stringify(value)}\n\n`)
137
+ }
138
+
139
+ function streamText(res, content, sequence) {
140
+ const id = `chatcmpl-loop-${sequence}`
141
+ const created = Math.floor(Date.now() / 1000)
142
+ streamHeaders(res)
143
+ writeSse(res, {
144
+ id,
145
+ object: "chat.completion.chunk",
146
+ created,
147
+ model: "canary",
148
+ choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
149
+ })
150
+ writeSse(res, {
151
+ id,
152
+ object: "chat.completion.chunk",
153
+ created,
154
+ model: "canary",
155
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
156
+ usage: { prompt_tokens: 30, completion_tokens: 4, total_tokens: 34 },
157
+ })
158
+ res.end("data: [DONE]\n\n")
159
+ }
160
+
161
+ function startProvider() {
162
+ const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
163
+ const server = createServer(async (req, res) => {
164
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
165
+ stats.paths.push(`${req.method} ${url.pathname}`)
166
+ if (req.method === "GET" && url.pathname.endsWith("/models")) {
167
+ res.writeHead(200, { "content-type": "application/json" })
168
+ res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
169
+ return
170
+ }
171
+ if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
172
+ res.writeHead(404, { "content-type": "application/json" })
173
+ res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
174
+ return
175
+ }
176
+
177
+ let raw = ""
178
+ for await (const chunk of req) raw += String(chunk)
179
+ const body = raw ? JSON.parse(raw) : {}
180
+ stats.chatRequests += 1
181
+ const text = allMessageText(body)
182
+ if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(LOOP_OBJECTIVE)) {
183
+ stats.loopRequests += 1
184
+ streamText(res, `LOOP_TURN_${stats.loopRequests}`, stats.chatRequests)
185
+ return
186
+ }
187
+ streamText(res, "OK", stats.chatRequests)
188
+ })
189
+
190
+ return {
191
+ stats,
192
+ async listen() {
193
+ await new Promise((resolve, reject) => {
194
+ server.once("error", reject)
195
+ server.listen(0, "127.0.0.1", resolve)
196
+ })
197
+ const address = server.address()
198
+ if (!address || typeof address === "string") throw new Error("failed to start deterministic loop provider")
199
+ return address.port
200
+ },
201
+ async close() {
202
+ await new Promise((resolve) => server.close(() => resolve()))
203
+ },
204
+ }
205
+ }
206
+
207
+ async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) {
208
+ const deadline = Date.now() + timeoutMs
209
+ while (Date.now() < deadline) {
210
+ if (await predicate()) return
211
+ await new Promise((resolve) => setTimeout(resolve, 50))
212
+ }
213
+ throw new Error(`timed out waiting for ${description}\n${diagnostics()}`)
214
+ }
215
+
216
+ async function main() {
217
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-host-canary-"))
218
+ const home = path.join(workspace, ".home")
219
+ const projectConfig = path.join(workspace, ".opencode")
220
+ const globalConfig = path.join(home, ".config", "opencode")
221
+ const pluginDir = path.join(projectConfig, "plugins")
222
+ const commandDir = path.join(projectConfig, "commands")
223
+ const agentDir = path.join(projectConfig, "agents")
224
+ const provider = startProvider()
225
+ const providerPort = await provider.listen()
226
+ let server
227
+ let serverLog = ""
228
+ let commandError = null
229
+
230
+ await mkdir(pluginDir, { recursive: true })
231
+ await mkdir(commandDir, { recursive: true })
232
+ await mkdir(agentDir, { recursive: true })
233
+ await seedConfigDependencies(projectConfig)
234
+ await seedConfigDependencies(globalConfig)
235
+ const pluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
236
+ await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(pluginEntry)}\n`)
237
+ await writeFile(path.join(commandDir, "loop.md"), `---\ndescription: Start a host canary loop\nagent: opencode-loop-local\n---\n\nOpenCode Loop local command handled. Reply exactly: OK.\n`)
238
+ await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Loop command acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`)
239
+ await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
240
+ $schema: "https://opencode.ai/config.json",
241
+ model: "canary/canary",
242
+ small_model: "canary/canary",
243
+ provider: {
244
+ canary: {
245
+ npm: "@ai-sdk/openai-compatible",
246
+ name: "Deterministic Loop Canary",
247
+ options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
248
+ models: { canary: { name: "Deterministic Loop Canary", limit: { context: 100000, output: 4096 } } },
249
+ },
250
+ },
251
+ }, null, 2)}\n`)
252
+
253
+ const env = {
254
+ ...process.env,
255
+ HOME: home,
256
+ USERPROFILE: home,
257
+ XDG_CONFIG_HOME: path.join(home, ".config"),
258
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
259
+ XDG_CACHE_HOME: path.join(home, ".cache"),
260
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
261
+ OPENCODE_DB: ":memory:",
262
+ OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
263
+ CI: "true",
264
+ }
265
+
266
+ try {
267
+ await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
268
+ const port = await reservePort()
269
+ server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
270
+ server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
271
+ server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
272
+ await waitForTcp(port, server, () => serverLog)
273
+
274
+ const baseURL = `http://127.0.0.1:${port}`
275
+ const directoryQuery = `directory=${encodeURIComponent(workspace)}`
276
+ const api = async (pathname, init = {}) => {
277
+ const separator = pathname.includes("?") ? "&" : "?"
278
+ const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
279
+ ...init,
280
+ headers: { "content-type": "application/json", ...(init.headers ?? {}) },
281
+ signal: init.signal ?? AbortSignal.timeout(30_000),
282
+ })
283
+ const text = await response.text()
284
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
285
+ if (!text) return null
286
+ try { return JSON.parse(text) } catch { return text }
287
+ }
288
+
289
+ const sessionsBefore = await api("/session", { method: "GET", signal: AbortSignal.timeout(45_000) })
290
+ assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap did not return an array")
291
+ const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-loop host canary" }) })
292
+ const session = createdPayload?.data ?? createdPayload
293
+ const sessionID = String(session?.id ?? "")
294
+ assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
295
+
296
+ const command = api(`/session/${encodeURIComponent(sessionID)}/command`, {
297
+ method: "POST",
298
+ body: JSON.stringify({ agent: "build", model: "canary/canary", command: "loop", arguments: `0s --max-runs 3 ${LOOP_OBJECTIVE}` }),
299
+ signal: AbortSignal.timeout(90_000),
300
+ }).catch((error) => {
301
+ commandError = error
302
+ return null
303
+ })
304
+
305
+ const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
306
+ const diagnostics = async () => {
307
+ let state = "missing"
308
+ try { state = await readFile(stateFile, "utf8") } catch {}
309
+ return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nserver log:\n${serverLog}`
310
+ }
311
+
312
+ await waitFor(() => provider.stats.loopRequests >= 3, "three real autonomous Loop turns", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`)
313
+ await new Promise((resolve) => setTimeout(resolve, 1_500))
314
+ assert.equal(provider.stats.loopRequests, 3, `Loop must stop at --max-runs 3; got extra real-host turn(s)\n${await diagnostics()}`)
315
+ assert.equal(server.exitCode, null, `OpenCode server exited during canary\n${await diagnostics()}`)
316
+
317
+ let persisted = null
318
+ try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {}
319
+ if (persisted?.jobs?.length) {
320
+ const loop = persisted.jobs.find((item) => item.name === "default" || item.action === LOOP_OBJECTIVE)
321
+ if (loop) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`)
322
+ }
323
+
324
+ console.log(JSON.stringify({
325
+ ok: true,
326
+ platform: process.platform,
327
+ sessionID,
328
+ loopRequests: provider.stats.loopRequests,
329
+ chatRequests: provider.stats.chatRequests,
330
+ commandError: commandError ? String(commandError) : null,
331
+ }, null, 2))
332
+ void command
333
+ } finally {
334
+ await stopProcess(server)
335
+ await provider.close().catch(() => undefined)
336
+ await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
337
+ }
338
+ }
339
+
340
+ main().catch((error) => {
341
+ console.error(error?.stack || error)
342
+ process.exitCode = 1
343
+ })
@@ -32,16 +32,7 @@ function runCli(cliArgs, env = {}) {
32
32
  async function makeFakeCommand(name) {
33
33
  const script = path.join(temporaryRoot, `${name}.mjs`)
34
34
  const log = path.join(temporaryRoot, `${name}.jsonl`)
35
- await fs.writeFile(script, `
36
- import fs from "node:fs"
37
- const log = process.env.FAKE_COMMAND_LOG
38
- let previous = []
39
- try { previous = fs.readFileSync(log, "utf8").trim().split(/\\r?\\n/).filter(Boolean) } catch {}
40
- fs.appendFileSync(log, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + "\\n")
41
- const codes = String(process.env.FAKE_EXIT_CODES || "0").split(",").map((value) => Number(value.trim()))
42
- const code = codes[Math.min(previous.length, codes.length - 1)]
43
- process.exit(Number.isInteger(code) ? code : 0)
44
- `, "utf8")
35
+ await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
45
36
 
46
37
  if (process.platform === "win32") {
47
38
  const command = path.join(temporaryRoot, `${name}.cmd`)
@@ -83,14 +74,16 @@ try {
83
74
  })
84
75
  assert.equal(result.code, 0, result.stderr)
85
76
  let calls = await readLog(fakeOpenCode.log)
86
- assert.equal(calls.length, 1)
87
- assert.equal(path.resolve(calls[0].cwd), path.resolve(project))
88
- assert.deepEqual(calls[0].args, [
89
- "run", "--continue",
77
+ let runCalls = calls.filter((item) => item.args[0] === "run")
78
+ assert.equal(runCalls.length, 1)
79
+ assert.equal(path.resolve(runCalls[0].cwd), path.resolve(project))
80
+ assert.deepEqual(runCalls[0].args, [
81
+ "run", "--title", runCalls[0].args[2],
90
82
  "--model", "opencode/nemotron-3-ultra-free",
91
83
  "--agent", "build",
92
84
  inlinePrompt,
93
85
  ], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
86
+ assert.match(runCalls[0].args[2], /^OpenCode Loop daemon /)
94
87
 
95
88
  const promptFile = path.join(project, "goal prompt.md")
96
89
  await fs.writeFile(promptFile, "\uFEFFPrompt loaded from a BOM file.", "utf8")
@@ -105,7 +98,39 @@ try {
105
98
  })
106
99
  assert.equal(result.code, 0, result.stderr)
107
100
  calls = await readLog(fakeOpenCode.log)
108
- assert.equal(calls.at(-1).args.at(-1), "Prompt loaded from a BOM file.")
101
+ runCalls = calls.filter((item) => item.args[0] === "run")
102
+ assert.equal(runCalls.at(-1).args.at(-1), "Prompt loaded from a BOM file.")
103
+
104
+ const pinnedLog = path.join(temporaryRoot, "pinned.jsonl")
105
+ result = await runCli([
106
+ "--project", project,
107
+ "--every", "0s",
108
+ "--max-runs", "2",
109
+ "--prompt", "pinned session work",
110
+ ], {
111
+ OPENCODE_BIN: fakeOpenCode.command,
112
+ FAKE_COMMAND_LOG: pinnedLog,
113
+ FAKE_SESSION_LIST_JSON: JSON.stringify([{ id: "ses_pinned", title: "Pinned" }]),
114
+ })
115
+ assert.equal(result.code, 0, result.stderr)
116
+ const pinnedRuns = (await readLog(pinnedLog)).filter((item) => item.args[0] === "run")
117
+ assert.equal(pinnedRuns.length, 2)
118
+ assert.ok(pinnedRuns.every((item) => item.args[1] === "--session" && item.args[2] === "ses_pinned"), "every daemon iteration must stay pinned to the same session")
119
+
120
+ const timeoutLog = path.join(temporaryRoot, "timeout.jsonl")
121
+ result = await runCli([
122
+ "--project", project,
123
+ "--every", "0s",
124
+ "--max-runs", "1",
125
+ "--timeout", "50ms",
126
+ "--prompt", "hang",
127
+ ], {
128
+ OPENCODE_BIN: fakeOpenCode.command,
129
+ FAKE_COMMAND_LOG: timeoutLog,
130
+ FAKE_SLEEP_MS: "5000",
131
+ })
132
+ assert.equal(result.code, 124, "a timed-out daemon run must return exit code 124")
133
+ assert.match(result.stdout, /timed out after 50ms/)
109
134
 
110
135
  const failureLog = path.join(temporaryRoot, "failure.jsonl")
111
136
  result = await runCli([
@@ -169,6 +194,7 @@ try {
169
194
  assert.equal(taskConfig.prompt, "scheduled task prompt")
170
195
  assert.equal(taskConfig.model, "opencode/nemotron-3-ultra-free")
171
196
  assert.equal(taskConfig.agent, "build")
197
+ assert.equal(taskConfig.timeout, "30m")
172
198
  assert.equal(taskConfig.opencodeBin, fakeOpenCode.command)
173
199
 
174
200
  const taskRunLog = path.join(temporaryRoot, "task-run.jsonl")
@@ -176,9 +202,10 @@ try {
176
202
  FAKE_COMMAND_LOG: taskRunLog,
177
203
  })
178
204
  assert.equal(result.code, 0, result.stderr)
179
- const taskRunCall = (await readLog(taskRunLog))[0]
180
- assert.deepEqual(taskRunCall.args, [
181
- "run", "--continue",
205
+ const taskRunCalls = (await readLog(taskRunLog)).filter((item) => item.args[0] === "run")
206
+ assert.equal(taskRunCalls.length, 1)
207
+ assert.deepEqual(taskRunCalls[0].args, [
208
+ "run", "--title", taskRunCalls[0].args[2],
182
209
  "--model", "opencode/nemotron-3-ultra-free",
183
210
  "--agent", "build",
184
211
  "scheduled task prompt",
package/scripts/loopd.mjs CHANGED
@@ -61,13 +61,46 @@ function sleep(ms) {
61
61
  return new Promise((resolve) => setTimeout(resolve, ms))
62
62
  }
63
63
 
64
- function spawnOnce(command, commandArgs, cwd) {
64
+ function terminateChild(child) {
65
+ if (!child || child.exitCode !== null) return
66
+ try {
67
+ if (process.platform === "win32" && child.pid) {
68
+ spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
69
+ return
70
+ }
71
+ if (child.pid) process.kill(-child.pid, "SIGTERM")
72
+ else child.kill("SIGTERM")
73
+ const force = setTimeout(() => {
74
+ try {
75
+ if (child.exitCode !== null) return
76
+ if (child.pid) process.kill(-child.pid, "SIGKILL")
77
+ else child.kill("SIGKILL")
78
+ } catch {}
79
+ }, 1_000)
80
+ force.unref?.()
81
+ } catch {
82
+ try { child.kill("SIGTERM") } catch {}
83
+ }
84
+ }
85
+
86
+ function spawnOnce(command, commandArgs, cwd, options = {}) {
65
87
  return new Promise((resolve) => {
66
88
  let settled = false
89
+ let timedOut = false
90
+ const capture = options.capture === true
91
+ const stdout = []
92
+ const stderr = []
93
+ let timer
67
94
  const done = (result) => {
68
95
  if (settled) return
69
96
  settled = true
70
- resolve(result)
97
+ if (timer) clearTimeout(timer)
98
+ resolve({
99
+ ...result,
100
+ timedOut,
101
+ stdout: capture ? Buffer.concat(stdout).toString("utf8") : "",
102
+ stderr: capture ? Buffer.concat(stderr).toString("utf8") : "",
103
+ })
71
104
  }
72
105
 
73
106
  let child
@@ -75,17 +108,27 @@ function spawnOnce(command, commandArgs, cwd) {
75
108
  child = spawn(command, commandArgs, {
76
109
  cwd,
77
110
  shell: false,
78
- stdio: "inherit",
111
+ stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
79
112
  env: process.env,
80
113
  windowsHide: true,
114
+ detached: process.platform !== "win32",
81
115
  })
82
116
  } catch (error) {
83
117
  done({ code: -1, error })
84
118
  return
85
119
  }
86
120
 
121
+ child.stdout?.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
122
+ child.stderr?.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
123
+ if (Number(options.timeoutMs) > 0) {
124
+ timer = setTimeout(() => {
125
+ timedOut = true
126
+ terminateChild(child)
127
+ }, Number(options.timeoutMs))
128
+ timer.unref?.()
129
+ }
87
130
  child.on("error", (error) => done({ code: -1, error }))
88
- child.on("exit", (code, signal) => done({ code: code ?? (signal ? 1 : 0), signal }))
131
+ child.on("exit", (code, signal) => done({ code: timedOut ? 124 : (code ?? (signal ? 1 : 0)), signal }))
89
132
  })
90
133
  }
91
134
 
@@ -98,15 +141,25 @@ function stripBom(text) {
98
141
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
99
142
  }
100
143
 
101
- async function run(command, commandArgs, cwd) {
102
- const direct = await spawnOnce(command, commandArgs, cwd)
103
- if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct.code
144
+ async function run(command, commandArgs, cwd, options = {}) {
145
+ const direct = await spawnOnce(command, commandArgs, cwd, options)
146
+ if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct
104
147
 
105
- const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd)
148
+ const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd, options)
106
149
  if (fallback.code === -1) {
107
150
  console.error(`[opencode-loopd] failed to start ${command}: ${fallback.error?.message || direct.error?.message || "unknown error"}`)
108
151
  }
109
- return fallback.code
152
+ return fallback
153
+ }
154
+
155
+ async function resolveLatestSessionID(opencodeBin, project, preferredTitle) {
156
+ const result = await run(opencodeBin, ["session", "list", "--format", "json", "-n", "20"], project, { capture: true, timeoutMs: 15_000 })
157
+ if (result.code !== 0 || result.timedOut) return undefined
158
+ let parsed
159
+ try { parsed = JSON.parse(result.stdout || "[]") } catch { return undefined }
160
+ const sessions = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.data) ? parsed.data : []
161
+ const match = preferredTitle ? sessions.find((item) => item?.title === preferredTitle) : sessions[0]
162
+ return typeof match?.id === "string" && match.id ? match.id : undefined
110
163
  }
111
164
 
112
165
  function runSync(command, commandArgs) {
@@ -145,50 +198,63 @@ async function daemon(options = {}) {
145
198
  const every = options.every ?? arg("--every", "0s")
146
199
  const delay = parseMs(every)
147
200
  const maxRuns = parseMaxRuns(options.maxRuns ?? arg("--max-runs", "0"))
201
+ const timeoutText = options.timeout ?? arg("--timeout", "30m")
202
+ const timeoutMs = parseMs(timeoutText)
148
203
  const sleepFirst = options.sleepFirst ?? has("--sleep-first")
149
204
  const prompt = readPrompt(project, options)
150
205
  const model = options.model ?? arg("--model")
151
206
  const agent = options.agent ?? arg("--agent")
152
207
  const opencodeBin = options.opencodeBin || OPENCODE_BIN
208
+ const explicitSessionID = options.session ?? arg("--session")
209
+ let sessionID = explicitSessionID || await resolveLatestSessionID(opencodeBin, project)
210
+ const sessionTitle = `OpenCode Loop daemon ${process.pid}-${Date.now()}`
153
211
 
154
212
  console.log("OpenCode Loop daemon")
155
213
  console.log(`project: ${project}`)
156
214
  console.log(`every: ${every}`)
157
215
  console.log(`maxRuns: ${maxRuns || "unlimited"}`)
216
+ console.log(`timeout: ${timeoutText}`)
217
+ if (sessionID) console.log(`session: ${sessionID} (pinned)`)
158
218
 
159
219
  let count = 0
160
220
 
161
- if (sleepFirst && delay > 0) {
162
- await sleep(delay)
163
- }
221
+ if (sleepFirst && delay > 0) await sleep(delay)
164
222
 
165
223
  while (true) {
166
224
  count += 1
167
-
168
225
  console.log("")
169
226
  console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
170
227
 
171
- const runArgs = ["run", "--continue"]
228
+ const runArgs = ["run"]
229
+ if (sessionID) runArgs.push("--session", sessionID)
230
+ else runArgs.push("--title", sessionTitle)
172
231
  if (model) runArgs.push("--model", model)
173
232
  if (agent) runArgs.push("--agent", agent)
174
233
  runArgs.push(prompt)
175
- const code = await run(opencodeBin, runArgs, project)
176
234
 
177
- if (code !== 0) {
178
- console.log(`[opencode-loopd] opencode exited with code ${code}`)
179
- if (delay === 0) {
180
- await sleep(FAILED_RUN_RETRY_MS)
235
+ const result = await run(opencodeBin, runArgs, project, { timeoutMs })
236
+ const code = result.timedOut ? 124 : result.code
237
+ const moreRunsRemain = maxRuns === 0 || count < maxRuns
238
+
239
+ if (!sessionID && code === 0 && moreRunsRemain) {
240
+ sessionID = await resolveLatestSessionID(opencodeBin, project, sessionTitle)
241
+ if (!sessionID) {
242
+ console.error("[opencode-loopd] could not resolve the newly created session; refusing to continue unpinned")
243
+ return 1
181
244
  }
245
+ console.log(`[opencode-loopd] pinned session ${sessionID}`)
182
246
  }
183
247
 
248
+ if (result.timedOut) console.log(`[opencode-loopd] opencode run timed out after ${timeoutText}`)
249
+ else if (code !== 0) console.log(`[opencode-loopd] opencode exited with code ${code}`)
250
+ if (code !== 0 && delay === 0 && moreRunsRemain) await sleep(FAILED_RUN_RETRY_MS)
251
+
184
252
  if (maxRuns > 0 && count >= maxRuns) {
185
253
  console.log("[opencode-loopd] max runs reached")
186
254
  return Number.isInteger(code) && code > 0 ? code : code < 0 ? 1 : 0
187
255
  }
188
256
 
189
- if (delay > 0) {
190
- await sleep(delay)
191
- }
257
+ if (delay > 0) await sleep(delay)
192
258
  }
193
259
  }
194
260
 
@@ -227,6 +293,7 @@ function installTask() {
227
293
  const promptArg = arg("--prompt")
228
294
  const model = arg("--model")
229
295
  const agent = arg("--agent")
296
+ const timeout = arg("--timeout", "30m")
230
297
  const node = process.execPath
231
298
  const script = fileURLToPath(import.meta.url)
232
299
  const artifacts = taskArtifacts(name)
@@ -239,6 +306,7 @@ function installTask() {
239
306
  prompt: promptArg || undefined,
240
307
  model: model || undefined,
241
308
  agent: agent || undefined,
309
+ timeout,
242
310
  opencodeBin: OPENCODE_BIN,
243
311
  }
244
312
  fs.writeFileSync(artifacts.config, JSON.stringify(taskConfig, null, 2) + "\n", "utf8")
@@ -296,6 +364,8 @@ Options:
296
364
  --prompt-file <file> Read prompt from file relative to the project
297
365
  --model <provider/id> OpenCode model used for each run
298
366
  --agent <name> OpenCode agent used for each run
367
+ --session <id> Pin an existing OpenCode session (auto-pinned otherwise)
368
+ --timeout <duration> Max time per OpenCode run (default: 30m, 0s disables)
299
369
  --max-runs <n> Stop after n runs
300
370
  --sleep-first Wait before first run
301
371
  `)
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
23
23
  const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
24
24
  const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
25
25
  const LOCAL_COMMAND_AGENT = "opencode-loop-local"
26
+ const STATE_BASELINE = Symbol("opencode-loop-state-baseline")
26
27
 
27
28
  const activeRuns = new Map()
28
29
  const handledCommands = new Map()
@@ -373,7 +374,7 @@ async function withStateWriteLock(directory, sessionID, fn) {
373
374
  }
374
375
  }
375
376
 
376
- async function readState(directory, sessionID) {
377
+ async function readStateFile(directory, sessionID) {
377
378
  const target = statePath(directory, sessionID)
378
379
  const attempts = 5
379
380
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -394,6 +395,17 @@ async function readState(directory, sessionID) {
394
395
  return { version: 4, jobs: [] }
395
396
  }
396
397
 
398
+ async function readState(directory, sessionID) {
399
+ const state = await readStateFile(directory, sessionID)
400
+ Object.defineProperty(state, STATE_BASELINE, {
401
+ value: structuredClone(state.jobs || []),
402
+ enumerable: false,
403
+ configurable: false,
404
+ writable: true,
405
+ })
406
+ return state
407
+ }
408
+
397
409
  function isRetriableStateWriteError(error) {
398
410
  const code = error?.code
399
411
  return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
@@ -457,11 +469,79 @@ async function writeFileAtomically(target, contents, options = {}) {
457
469
  }
458
470
  }
459
471
 
472
+ function stateValuesEqual(left, right) {
473
+ if (Object.is(left, right)) return true
474
+ try { return JSON.stringify(left) === JSON.stringify(right) } catch { return false }
475
+ }
476
+
477
+ function mergeStateJob(baseJob, intendedJob, currentJob) {
478
+ const merged = structuredClone(currentJob || {})
479
+ const keys = new Set([
480
+ ...Object.keys(baseJob || {}),
481
+ ...Object.keys(intendedJob || {}),
482
+ ])
483
+ for (const key of keys) {
484
+ const baseHas = Object.prototype.hasOwnProperty.call(baseJob || {}, key)
485
+ const intendedHas = Object.prototype.hasOwnProperty.call(intendedJob || {}, key)
486
+ const currentHas = Object.prototype.hasOwnProperty.call(currentJob || {}, key)
487
+ const intendedChanged = baseHas !== intendedHas || !stateValuesEqual(baseJob?.[key], intendedJob?.[key])
488
+ if (!intendedChanged) continue
489
+ const currentChanged = baseHas !== currentHas || !stateValuesEqual(baseJob?.[key], currentJob?.[key])
490
+ const sameResult = intendedHas === currentHas && stateValuesEqual(intendedJob?.[key], currentJob?.[key])
491
+ // First committed writer wins a true same-field conflict. A stale snapshot
492
+ // can still apply changes to unrelated fields without erasing newer state.
493
+ if (currentChanged && !sameResult) continue
494
+ if (intendedHas) merged[key] = structuredClone(intendedJob[key])
495
+ else delete merged[key]
496
+ }
497
+ return merged
498
+ }
499
+
500
+ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
501
+ const byID = (jobs) => new Map((jobs || []).filter((job) => job?.id).map((job) => [job.id, job]))
502
+ const base = byID(baseJobs)
503
+ const intended = byID(intendedJobs)
504
+ const current = byID(currentJobs)
505
+ const merged = []
506
+
507
+ for (const currentJob of currentJobs || []) {
508
+ const id = currentJob?.id
509
+ if (!id || !base.has(id)) {
510
+ merged.push(structuredClone(currentJob))
511
+ continue
512
+ }
513
+ const baseJob = base.get(id)
514
+ const intendedJob = intended.get(id)
515
+ if (!intendedJob) {
516
+ // A deletion based on an old snapshot must not erase a job that changed
517
+ // after that snapshot was read.
518
+ if (!stateValuesEqual(baseJob, currentJob)) merged.push(structuredClone(currentJob))
519
+ continue
520
+ }
521
+ merged.push(mergeStateJob(baseJob, intendedJob, currentJob))
522
+ }
523
+
524
+ for (const intendedJob of intendedJobs || []) {
525
+ const id = intendedJob?.id
526
+ if (!id || base.has(id) || current.has(id)) continue
527
+ merged.push(structuredClone(intendedJob))
528
+ }
529
+ return merged
530
+ }
531
+
460
532
  async function writeState(directory, sessionID, state) {
461
533
  await withStateWriteLock(directory, sessionID, async () => {
462
534
  await ensureDir(stateDir(directory))
463
535
  const target = statePath(directory, sessionID)
464
- const payload = JSON.stringify({ version: 4, jobs: state.jobs || [] }, null, 2)
536
+ const baseline = state?.[STATE_BASELINE]
537
+ let jobs = structuredClone(state.jobs || [])
538
+ if (Array.isArray(baseline)) {
539
+ const current = await readStateFile(directory, sessionID)
540
+ jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
541
+ state.jobs = structuredClone(jobs)
542
+ state[STATE_BASELINE] = structuredClone(jobs)
543
+ }
544
+ const payload = JSON.stringify({ version: 4, jobs }, null, 2)
465
545
  await writeFileAtomically(target, payload)
466
546
  })
467
547
  }