@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
@@ -0,0 +1,357 @@
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
+ function isFetchTimeout(error) {
217
+ return error?.name === "TimeoutError" || /aborted due to timeout/i.test(String(error?.message ?? error))
218
+ }
219
+
220
+ async function bootstrapSessions(api, diagnostics) {
221
+ try {
222
+ return await api("/session", { method: "GET", signal: AbortSignal.timeout(30_000) })
223
+ } catch (error) {
224
+ if (!isFetchTimeout(error)) throw error
225
+ console.error(`OpenCode session API was not ready after 30s; retrying once.\n${diagnostics()}`)
226
+ return await api("/session", { method: "GET", signal: AbortSignal.timeout(45_000) })
227
+ }
228
+ }
229
+
230
+ async function main() {
231
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-host-canary-"))
232
+ const home = path.join(workspace, ".home")
233
+ const projectConfig = path.join(workspace, ".opencode")
234
+ const globalConfig = path.join(home, ".config", "opencode")
235
+ const pluginDir = path.join(projectConfig, "plugins")
236
+ const commandDir = path.join(projectConfig, "commands")
237
+ const agentDir = path.join(projectConfig, "agents")
238
+ const provider = startProvider()
239
+ const providerPort = await provider.listen()
240
+ let server
241
+ let serverLog = ""
242
+ let commandError = null
243
+
244
+ await mkdir(pluginDir, { recursive: true })
245
+ await mkdir(commandDir, { recursive: true })
246
+ await mkdir(agentDir, { recursive: true })
247
+ await seedConfigDependencies(projectConfig)
248
+ await seedConfigDependencies(globalConfig)
249
+ const pluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
250
+ await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(pluginEntry)}\n`)
251
+ 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`)
252
+ 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`)
253
+ await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
254
+ $schema: "https://opencode.ai/config.json",
255
+ model: "canary/canary",
256
+ small_model: "canary/canary",
257
+ provider: {
258
+ canary: {
259
+ npm: "@ai-sdk/openai-compatible",
260
+ name: "Deterministic Loop Canary",
261
+ options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
262
+ models: { canary: { name: "Deterministic Loop Canary", limit: { context: 100000, output: 4096 } } },
263
+ },
264
+ },
265
+ }, null, 2)}\n`)
266
+
267
+ const env = {
268
+ ...process.env,
269
+ HOME: home,
270
+ USERPROFILE: home,
271
+ XDG_CONFIG_HOME: path.join(home, ".config"),
272
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
273
+ XDG_CACHE_HOME: path.join(home, ".cache"),
274
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
275
+ OPENCODE_DB: ":memory:",
276
+ OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
277
+ CI: "true",
278
+ }
279
+
280
+ try {
281
+ await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
282
+ const port = await reservePort()
283
+ server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
284
+ server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
285
+ server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
286
+ await waitForTcp(port, server, () => serverLog)
287
+
288
+ const baseURL = `http://127.0.0.1:${port}`
289
+ const directoryQuery = `directory=${encodeURIComponent(workspace)}`
290
+ const api = async (pathname, init = {}) => {
291
+ const separator = pathname.includes("?") ? "&" : "?"
292
+ const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
293
+ ...init,
294
+ headers: { "content-type": "application/json", ...(init.headers ?? {}) },
295
+ signal: init.signal ?? AbortSignal.timeout(30_000),
296
+ })
297
+ const text = await response.text()
298
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
299
+ if (!text) return null
300
+ try { return JSON.parse(text) } catch { return text }
301
+ }
302
+
303
+ const sessionsBefore = await bootstrapSessions(api, () => `server log:\n${serverLog}`)
304
+ assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap did not return an array")
305
+ const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-loop host canary" }) })
306
+ const session = createdPayload?.data ?? createdPayload
307
+ const sessionID = String(session?.id ?? "")
308
+ assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
309
+
310
+ const command = api(`/session/${encodeURIComponent(sessionID)}/command`, {
311
+ method: "POST",
312
+ body: JSON.stringify({ agent: "build", model: "canary/canary", command: "loop", arguments: `0s --max-runs 3 ${LOOP_OBJECTIVE}` }),
313
+ signal: AbortSignal.timeout(90_000),
314
+ }).catch((error) => {
315
+ commandError = error
316
+ return null
317
+ })
318
+
319
+ const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
320
+ const diagnostics = async () => {
321
+ let state = "missing"
322
+ try { state = await readFile(stateFile, "utf8") } catch {}
323
+ return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nserver log:\n${serverLog}`
324
+ }
325
+
326
+ await waitFor(() => provider.stats.loopRequests >= 3, "three real autonomous Loop turns", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`)
327
+ await new Promise((resolve) => setTimeout(resolve, 1_500))
328
+ assert.equal(provider.stats.loopRequests, 3, `Loop must stop at --max-runs 3; got extra real-host turn(s)\n${await diagnostics()}`)
329
+ assert.equal(server.exitCode, null, `OpenCode server exited during canary\n${await diagnostics()}`)
330
+
331
+ let persisted = null
332
+ try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {}
333
+ if (persisted?.jobs?.length) {
334
+ const loop = persisted.jobs.find((item) => item.name === "default" || item.action === LOOP_OBJECTIVE)
335
+ if (loop) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`)
336
+ }
337
+
338
+ console.log(JSON.stringify({
339
+ ok: true,
340
+ platform: process.platform,
341
+ sessionID,
342
+ loopRequests: provider.stats.loopRequests,
343
+ chatRequests: provider.stats.chatRequests,
344
+ commandError: commandError ? String(commandError) : null,
345
+ }, null, 2))
346
+ void command
347
+ } finally {
348
+ await stopProcess(server)
349
+ await provider.close().catch(() => undefined)
350
+ await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
351
+ }
352
+ }
353
+
354
+ main().catch((error) => {
355
+ console.error(error?.stack || error)
356
+ process.exitCode = 1
357
+ })
@@ -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
  `)