@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.
- package/CHANGELOG.md +10 -0
- package/README.md +9 -3
- package/package.json +8 -4
- package/scripts/comprehensive-test.mjs +49 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +357 -0
- package/scripts/loopd-test.mjs +45 -18
- package/scripts/loopd.mjs +92 -22
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2304 -1864
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- 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
|
+
})
|
package/scripts/loopd-test.mjs
CHANGED
|
@@ -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
|
-
|
|
87
|
-
assert.equal(
|
|
88
|
-
assert.
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
assert.
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
`)
|