@bybrawe/opencode-loop 0.5.28 → 0.5.29
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 +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
|
@@ -0,0 +1,443 @@
|
|
|
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 GOAL_OBJECTIVE = "queued steering real host goal objective"
|
|
14
|
+
const USER_STEERING = "queued user steering must run before goal continuation"
|
|
15
|
+
const SESSION_BOOTSTRAP_FIRST_TIMEOUT_MS = 30_000
|
|
16
|
+
const SESSION_BOOTSTRAP_RETRY_TIMEOUT_MS = 60_000
|
|
17
|
+
|
|
18
|
+
function resolveOpenCodeBinary() {
|
|
19
|
+
if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
|
|
20
|
+
const candidates = [
|
|
21
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"),
|
|
22
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"),
|
|
23
|
+
path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"),
|
|
24
|
+
]
|
|
25
|
+
const found = candidates.find((candidate) => existsSync(candidate))
|
|
26
|
+
if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`)
|
|
27
|
+
return found
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const opencodeBin = resolveOpenCodeBinary()
|
|
31
|
+
|
|
32
|
+
function appendLog(current, chunk, limit = 80_000) {
|
|
33
|
+
return (current + String(chunk)).slice(-limit)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function seedConfigDependencies(dir) {
|
|
37
|
+
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
|
38
|
+
const dependencies = { "@opencode-ai/plugin": "*" }
|
|
39
|
+
await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
|
|
40
|
+
await writeFile(
|
|
41
|
+
path.join(dir, "package-lock.json"),
|
|
42
|
+
`${JSON.stringify({
|
|
43
|
+
name: "opencode-loop-goal-steering-canary-config",
|
|
44
|
+
lockfileVersion: 3,
|
|
45
|
+
requires: true,
|
|
46
|
+
packages: { "": { dependencies } },
|
|
47
|
+
}, null, 2)}\n`,
|
|
48
|
+
)
|
|
49
|
+
await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function reservePort() {
|
|
53
|
+
return await new Promise((resolve, reject) => {
|
|
54
|
+
const server = net.createServer()
|
|
55
|
+
server.once("error", reject)
|
|
56
|
+
server.listen(0, "127.0.0.1", () => {
|
|
57
|
+
const address = server.address()
|
|
58
|
+
if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
|
|
59
|
+
server.close((error) => error ? reject(error) : resolve(address.port))
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
|
|
65
|
+
const deadline = Date.now() + timeoutMs
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`)
|
|
68
|
+
const connected = await new Promise((resolve) => {
|
|
69
|
+
const socket = net.createConnection({ host: "127.0.0.1", port })
|
|
70
|
+
socket.once("connect", () => { socket.destroy(); resolve(true) })
|
|
71
|
+
socket.once("error", () => resolve(false))
|
|
72
|
+
socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
|
|
73
|
+
})
|
|
74
|
+
if (connected) return
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function stopProcess(child, timeoutMs = 2_000) {
|
|
81
|
+
if (!child || child.exitCode !== null) return
|
|
82
|
+
child.kill()
|
|
83
|
+
await new Promise((resolve) => {
|
|
84
|
+
if (child.exitCode !== null) return resolve()
|
|
85
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
86
|
+
child.once("close", () => { clearTimeout(timer); resolve() })
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function spawnOpenCode(args, options = {}) {
|
|
91
|
+
return spawn(opencodeBin, args, { ...options, windowsHide: true })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) {
|
|
95
|
+
return await new Promise((resolve, reject) => {
|
|
96
|
+
const child = spawnOpenCode(args, { cwd, env })
|
|
97
|
+
let stdout = ""
|
|
98
|
+
let stderr = ""
|
|
99
|
+
let settled = false
|
|
100
|
+
child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
|
|
101
|
+
child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })
|
|
102
|
+
const finish = (fn, value) => {
|
|
103
|
+
if (settled) return
|
|
104
|
+
settled = true
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
fn(value)
|
|
107
|
+
}
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
void stopProcess(child)
|
|
110
|
+
finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
111
|
+
}, timeoutMs)
|
|
112
|
+
child.once("error", (error) => finish(reject, error))
|
|
113
|
+
child.once("close", (code) => {
|
|
114
|
+
if (code !== 0) return finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
115
|
+
finish(resolve, { stdout, stderr })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function contentText(content) {
|
|
121
|
+
if (typeof content === "string") return content
|
|
122
|
+
if (!Array.isArray(content)) return ""
|
|
123
|
+
return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function lastUserText(body) {
|
|
127
|
+
const messages = Array.isArray(body.messages) ? body.messages : []
|
|
128
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
129
|
+
if (messages[index]?.role === "user") return contentText(messages[index]?.content)
|
|
130
|
+
}
|
|
131
|
+
return ""
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function streamHeaders(res) {
|
|
135
|
+
res.writeHead(200, {
|
|
136
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
137
|
+
"cache-control": "no-cache",
|
|
138
|
+
connection: "keep-alive",
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeSse(res, value) {
|
|
143
|
+
res.write(`data: ${JSON.stringify(value)}\n\n`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function streamText(res, content, sequence) {
|
|
147
|
+
const id = `chatcmpl-loop-goal-steering-${sequence}`
|
|
148
|
+
const created = Math.floor(Date.now() / 1000)
|
|
149
|
+
streamHeaders(res)
|
|
150
|
+
writeSse(res, {
|
|
151
|
+
id,
|
|
152
|
+
object: "chat.completion.chunk",
|
|
153
|
+
created,
|
|
154
|
+
model: "canary",
|
|
155
|
+
choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
|
|
156
|
+
})
|
|
157
|
+
writeSse(res, {
|
|
158
|
+
id,
|
|
159
|
+
object: "chat.completion.chunk",
|
|
160
|
+
created,
|
|
161
|
+
model: "canary",
|
|
162
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
163
|
+
usage: { prompt_tokens: 30, completion_tokens: 4, total_tokens: 34 },
|
|
164
|
+
})
|
|
165
|
+
res.end("data: [DONE]\n\n")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function startProvider() {
|
|
169
|
+
const stats = {
|
|
170
|
+
chatRequests: 0,
|
|
171
|
+
goalStarted: 0,
|
|
172
|
+
goalClosed: 0,
|
|
173
|
+
steeringStarted: 0,
|
|
174
|
+
paths: [],
|
|
175
|
+
}
|
|
176
|
+
const heldGoalResponses = new Set()
|
|
177
|
+
|
|
178
|
+
const server = createServer(async (req, res) => {
|
|
179
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
180
|
+
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
181
|
+
if (req.method === "GET" && url.pathname.endsWith("/models")) {
|
|
182
|
+
res.writeHead(200, { "content-type": "application/json" })
|
|
183
|
+
res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
|
|
187
|
+
res.writeHead(404, { "content-type": "application/json" })
|
|
188
|
+
res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let raw = ""
|
|
193
|
+
for await (const chunk of req) raw += String(chunk)
|
|
194
|
+
const body = raw ? JSON.parse(raw) : {}
|
|
195
|
+
stats.chatRequests += 1
|
|
196
|
+
const text = lastUserText(body)
|
|
197
|
+
|
|
198
|
+
if (text.includes(USER_STEERING)) {
|
|
199
|
+
stats.steeringStarted += 1
|
|
200
|
+
streamText(res, "USER_STEERING_ACK", stats.chatRequests)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (text.includes("EXPERIMENTAL GOAL MODE CONTINUATION") && text.includes(GOAL_OBJECTIVE)) {
|
|
205
|
+
stats.goalStarted += 1
|
|
206
|
+
const id = `chatcmpl-loop-goal-held-${stats.chatRequests}`
|
|
207
|
+
const created = Math.floor(Date.now() / 1000)
|
|
208
|
+
heldGoalResponses.add(res)
|
|
209
|
+
res.once("close", () => {
|
|
210
|
+
if (heldGoalResponses.delete(res)) stats.goalClosed += 1
|
|
211
|
+
})
|
|
212
|
+
streamHeaders(res)
|
|
213
|
+
writeSse(res, {
|
|
214
|
+
id,
|
|
215
|
+
object: "chat.completion.chunk",
|
|
216
|
+
created,
|
|
217
|
+
model: "canary",
|
|
218
|
+
choices: [{ index: 0, delta: { role: "assistant", content: `GOAL_WORK_${stats.goalStarted}` }, finish_reason: null }],
|
|
219
|
+
})
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
streamText(res, "OK", stats.chatRequests)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
stats,
|
|
228
|
+
async listen() {
|
|
229
|
+
await new Promise((resolve, reject) => {
|
|
230
|
+
server.once("error", reject)
|
|
231
|
+
server.listen(0, "127.0.0.1", resolve)
|
|
232
|
+
})
|
|
233
|
+
const address = server.address()
|
|
234
|
+
if (!address || typeof address === "string") throw new Error("failed to start deterministic Goal steering provider")
|
|
235
|
+
return address.port
|
|
236
|
+
},
|
|
237
|
+
async close() {
|
|
238
|
+
for (const response of heldGoalResponses) response.destroy()
|
|
239
|
+
await new Promise((resolve) => server.close(() => resolve()))
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) {
|
|
245
|
+
const deadline = Date.now() + timeoutMs
|
|
246
|
+
while (Date.now() < deadline) {
|
|
247
|
+
if (await predicate()) return
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`timed out waiting for ${description}\n${await diagnostics()}`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isFetchTimeout(error) {
|
|
254
|
+
return error?.name === "TimeoutError" || /aborted due to timeout/i.test(String(error?.message ?? error))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function bootstrapSessions(api, diagnostics) {
|
|
258
|
+
try {
|
|
259
|
+
return await api("/session", { method: "GET", signal: AbortSignal.timeout(SESSION_BOOTSTRAP_FIRST_TIMEOUT_MS) })
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!isFetchTimeout(error)) throw error
|
|
262
|
+
console.error(`OpenCode session API was not ready after ${SESSION_BOOTSTRAP_FIRST_TIMEOUT_MS}ms; retrying with a fresh request.\n${diagnostics()}`)
|
|
263
|
+
return await api("/session", { method: "GET", signal: AbortSignal.timeout(SESSION_BOOTSTRAP_RETRY_TIMEOUT_MS) })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function readGoalJob(stateFile) {
|
|
268
|
+
try {
|
|
269
|
+
const state = JSON.parse(await readFile(stateFile, "utf8"))
|
|
270
|
+
return (state.jobs || []).find((job) => String(job?.kind || "").toLowerCase() === "goal") || null
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error?.code === "ENOENT") return null
|
|
273
|
+
throw error
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function main() {
|
|
278
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-goal-steering-host-"))
|
|
279
|
+
const home = path.join(workspace, ".home")
|
|
280
|
+
const projectConfig = path.join(workspace, ".opencode")
|
|
281
|
+
const globalConfig = path.join(home, ".config", "opencode")
|
|
282
|
+
const pluginDir = path.join(projectConfig, "plugins")
|
|
283
|
+
const commandDir = path.join(projectConfig, "commands")
|
|
284
|
+
const agentDir = path.join(projectConfig, "agents")
|
|
285
|
+
const provider = startProvider()
|
|
286
|
+
const providerPort = await provider.listen()
|
|
287
|
+
let server
|
|
288
|
+
let serverLog = ""
|
|
289
|
+
let commandError = null
|
|
290
|
+
|
|
291
|
+
await mkdir(pluginDir, { recursive: true })
|
|
292
|
+
await mkdir(commandDir, { recursive: true })
|
|
293
|
+
await mkdir(agentDir, { recursive: true })
|
|
294
|
+
await seedConfigDependencies(projectConfig)
|
|
295
|
+
await seedConfigDependencies(globalConfig)
|
|
296
|
+
const pluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
|
|
297
|
+
await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(pluginEntry)}\n`)
|
|
298
|
+
await writeFile(path.join(commandDir, "loop-goal.md"), `---\ndescription: Start a Goal steering host canary\nagent: opencode-loop-local\n---\n\nOpenCode Loop Goal command handled. Reply exactly: OK.\n`)
|
|
299
|
+
await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Goal steering command acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`)
|
|
300
|
+
await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
|
|
301
|
+
$schema: "https://opencode.ai/config.json",
|
|
302
|
+
model: "canary/canary",
|
|
303
|
+
small_model: "canary/canary",
|
|
304
|
+
provider: {
|
|
305
|
+
canary: {
|
|
306
|
+
npm: "@ai-sdk/openai-compatible",
|
|
307
|
+
name: "Deterministic Loop Goal Steering Canary",
|
|
308
|
+
options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
|
|
309
|
+
models: { canary: { name: "Deterministic Loop Goal Steering Canary", limit: { context: 100000, output: 4096 } } },
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
}, null, 2)}\n`)
|
|
313
|
+
|
|
314
|
+
const env = {
|
|
315
|
+
...process.env,
|
|
316
|
+
HOME: home,
|
|
317
|
+
USERPROFILE: home,
|
|
318
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
319
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
320
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
321
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
322
|
+
OPENCODE_DB: ":memory:",
|
|
323
|
+
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
324
|
+
CI: "true",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
|
|
329
|
+
const port = await reservePort()
|
|
330
|
+
server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
|
|
331
|
+
server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
332
|
+
server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
333
|
+
await waitForTcp(port, server, () => serverLog)
|
|
334
|
+
|
|
335
|
+
const baseURL = `http://127.0.0.1:${port}`
|
|
336
|
+
const directoryQuery = `directory=${encodeURIComponent(workspace)}`
|
|
337
|
+
const api = async (pathname, init = {}) => {
|
|
338
|
+
const separator = pathname.includes("?") ? "&" : "?"
|
|
339
|
+
const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
|
|
340
|
+
...init,
|
|
341
|
+
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
|
342
|
+
signal: init.signal ?? AbortSignal.timeout(30_000),
|
|
343
|
+
})
|
|
344
|
+
const text = await response.text()
|
|
345
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
|
|
346
|
+
if (!text) return null
|
|
347
|
+
try { return JSON.parse(text) } catch { return text }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let sessionsPayload
|
|
351
|
+
try {
|
|
352
|
+
sessionsPayload = await bootstrapSessions(api, () => `server log:\n${serverLog}`)
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
355
|
+
const totalTimeoutMs = SESSION_BOOTSTRAP_FIRST_TIMEOUT_MS + SESSION_BOOTSTRAP_RETRY_TIMEOUT_MS
|
|
356
|
+
throw new Error(`OpenCode session bootstrap failed after two requests within ${totalTimeoutMs}ms: ${message}\nserver log:\n${serverLog}`)
|
|
357
|
+
}
|
|
358
|
+
assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
|
|
359
|
+
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-loop Goal steering canary" }) })
|
|
360
|
+
const session = createdPayload?.data ?? createdPayload
|
|
361
|
+
const sessionID = String(session?.id ?? "")
|
|
362
|
+
assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
|
|
363
|
+
|
|
364
|
+
const command = api(`/session/${encodeURIComponent(sessionID)}/command`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
body: JSON.stringify({ agent: "build", model: "canary/canary", command: "loop-goal", arguments: GOAL_OBJECTIVE }),
|
|
367
|
+
signal: AbortSignal.timeout(90_000),
|
|
368
|
+
}).catch((error) => {
|
|
369
|
+
commandError = error
|
|
370
|
+
return null
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
374
|
+
const diagnostics = async () => {
|
|
375
|
+
let state = "missing"
|
|
376
|
+
try { state = await readFile(stateFile, "utf8") } catch {}
|
|
377
|
+
return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nserver log:\n${serverLog}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await waitFor(
|
|
381
|
+
async () => {
|
|
382
|
+
const goal = await readGoalJob(stateFile)
|
|
383
|
+
return provider.stats.goalStarted === 1 && goal?.action === GOAL_OBJECTIVE && goal?.goalStatus === "active" && goal?.paused === false
|
|
384
|
+
},
|
|
385
|
+
"first autonomous Goal stream to become active",
|
|
386
|
+
diagnostics,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
await api(`/session/${encodeURIComponent(sessionID)}/prompt_async`, {
|
|
390
|
+
method: "POST",
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
agent: "build",
|
|
393
|
+
model: { providerID: "canary", modelID: "canary" },
|
|
394
|
+
parts: [{ type: "text", text: USER_STEERING }],
|
|
395
|
+
}),
|
|
396
|
+
signal: AbortSignal.timeout(20_000),
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
await waitFor(() => provider.stats.goalClosed >= 1, "active Goal provider stream to be cancelled for queued steering", diagnostics)
|
|
400
|
+
await waitFor(() => provider.stats.steeringStarted === 1, "queued user steering to reach the provider", diagnostics)
|
|
401
|
+
await waitFor(
|
|
402
|
+
async () => {
|
|
403
|
+
const goal = await readGoalJob(stateFile)
|
|
404
|
+
return goal?.action === GOAL_OBJECTIVE && goal?.goalStatus === "active" && goal?.paused === false
|
|
405
|
+
},
|
|
406
|
+
"Goal state to remain active and unchanged during steering",
|
|
407
|
+
diagnostics,
|
|
408
|
+
)
|
|
409
|
+
await waitFor(() => provider.stats.goalStarted >= 2, "Goal autonomous continuation to resume after steering", diagnostics)
|
|
410
|
+
|
|
411
|
+
const goal = await readGoalJob(stateFile)
|
|
412
|
+
assert.equal(provider.stats.goalStarted, 2, `Goal should start once before and once after queued steering\n${await diagnostics()}`)
|
|
413
|
+
assert.equal(provider.stats.goalClosed, 1, `only the pre-steering Goal stream should be cancelled before cleanup\n${await diagnostics()}`)
|
|
414
|
+
assert.equal(provider.stats.steeringStarted, 1, `queued steering must execute exactly one foreground model turn\n${await diagnostics()}`)
|
|
415
|
+
assert.equal(goal?.action, GOAL_OBJECTIVE)
|
|
416
|
+
assert.equal(goal?.goalStatus, "active")
|
|
417
|
+
assert.equal(goal?.paused, false)
|
|
418
|
+
assert.equal(server.exitCode, null, `OpenCode server exited during Goal steering canary\n${await diagnostics()}`)
|
|
419
|
+
|
|
420
|
+
console.log(JSON.stringify({
|
|
421
|
+
ok: true,
|
|
422
|
+
platform: process.platform,
|
|
423
|
+
sessionID,
|
|
424
|
+
goalStarted: provider.stats.goalStarted,
|
|
425
|
+
goalClosed: provider.stats.goalClosed,
|
|
426
|
+
steeringStarted: provider.stats.steeringStarted,
|
|
427
|
+
goalStatus: goal.goalStatus,
|
|
428
|
+
paused: goal.paused,
|
|
429
|
+
objective: goal.action,
|
|
430
|
+
commandError: commandError ? String(commandError) : null,
|
|
431
|
+
}, null, 2))
|
|
432
|
+
void command
|
|
433
|
+
} finally {
|
|
434
|
+
await stopProcess(server)
|
|
435
|
+
await provider.close().catch(() => undefined)
|
|
436
|
+
await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
main().catch((error) => {
|
|
441
|
+
console.error(error?.stack || error)
|
|
442
|
+
process.exitCode = 1
|
|
443
|
+
})
|
|
@@ -11,6 +11,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"
|
|
|
11
11
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
12
|
const isWindows = process.platform === "win32"
|
|
13
13
|
const LOOP_OBJECTIVE = "real host loop canary"
|
|
14
|
+
const RUN_NOW_NATURAL_OBJECTIVE = "real host loop-now natural canary"
|
|
15
|
+
const RUN_NOW_TARGET_OBJECTIVE = "real host loop-now target canary"
|
|
14
16
|
|
|
15
17
|
function resolveOpenCodeBinary() {
|
|
16
18
|
if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
|
|
@@ -120,8 +122,12 @@ function contentText(content) {
|
|
|
120
122
|
return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
+
function lastUserMessageText(body) {
|
|
126
|
+
const messages = Array.isArray(body?.messages) ? body.messages : []
|
|
127
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
128
|
+
if (messages[index]?.role === "user") return contentText(messages[index]?.content)
|
|
129
|
+
}
|
|
130
|
+
return contentText(messages.at(-1)?.content)
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
function streamHeaders(res) {
|
|
@@ -159,7 +165,7 @@ function streamText(res, content, sequence) {
|
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
function startProvider() {
|
|
162
|
-
const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
|
|
168
|
+
const stats = { chatRequests: 0, loopRequests: 0, runNowSequence: [], paths: [] }
|
|
163
169
|
const server = createServer(async (req, res) => {
|
|
164
170
|
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
165
171
|
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
@@ -178,12 +184,22 @@ function startProvider() {
|
|
|
178
184
|
for await (const chunk of req) raw += String(chunk)
|
|
179
185
|
const body = raw ? JSON.parse(raw) : {}
|
|
180
186
|
stats.chatRequests += 1
|
|
181
|
-
const text =
|
|
187
|
+
const text = lastUserMessageText(body)
|
|
182
188
|
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(LOOP_OBJECTIVE)) {
|
|
183
189
|
stats.loopRequests += 1
|
|
184
190
|
streamText(res, `LOOP_TURN_${stats.loopRequests}`, stats.chatRequests)
|
|
185
191
|
return
|
|
186
192
|
}
|
|
193
|
+
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(RUN_NOW_TARGET_OBJECTIVE)) {
|
|
194
|
+
stats.runNowSequence.push("target")
|
|
195
|
+
streamText(res, "RUN_NOW_TARGET", stats.chatRequests)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(RUN_NOW_NATURAL_OBJECTIVE)) {
|
|
199
|
+
stats.runNowSequence.push("natural")
|
|
200
|
+
streamText(res, "RUN_NOW_NATURAL", stats.chatRequests)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
187
203
|
streamText(res, "OK", stats.chatRequests)
|
|
188
204
|
})
|
|
189
205
|
|
|
@@ -210,7 +226,8 @@ async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000)
|
|
|
210
226
|
if (await predicate()) return
|
|
211
227
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
212
228
|
}
|
|
213
|
-
|
|
229
|
+
const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics
|
|
230
|
+
throw new Error(`timed out waiting for ${description}\n${detail}`)
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
function isFetchTimeout(error) {
|
|
@@ -249,6 +266,7 @@ async function main() {
|
|
|
249
266
|
const pluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
|
|
250
267
|
await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(pluginEntry)}\n`)
|
|
251
268
|
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`)
|
|
269
|
+
await writeFile(path.join(commandDir, "loop-now.md"), `---\ndescription: Run a host canary loop now\nagent: opencode-loop-local\n---\n\nOpenCode Loop run-now command handled locally. Reply exactly: OK.\n`)
|
|
252
270
|
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
271
|
await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
|
|
254
272
|
$schema: "https://opencode.ai/config.json",
|
|
@@ -307,11 +325,14 @@ async function main() {
|
|
|
307
325
|
const sessionID = String(session?.id ?? "")
|
|
308
326
|
assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
|
|
309
327
|
|
|
310
|
-
const
|
|
328
|
+
const commandPath = `/session/${encodeURIComponent(sessionID)}/command`
|
|
329
|
+
const sendCommand = async (name, argumentsText, timeoutMs = 90_000) => await api(commandPath, {
|
|
311
330
|
method: "POST",
|
|
312
|
-
body: JSON.stringify({ agent: "build", model: "canary/canary", command:
|
|
313
|
-
signal: AbortSignal.timeout(
|
|
314
|
-
})
|
|
331
|
+
body: JSON.stringify({ agent: "build", model: "canary/canary", command: name, arguments: argumentsText }),
|
|
332
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const command = sendCommand("loop", `0s --max-runs 3 ${LOOP_OBJECTIVE}`).catch((error) => {
|
|
315
336
|
commandError = error
|
|
316
337
|
return null
|
|
317
338
|
})
|
|
@@ -319,14 +340,17 @@ async function main() {
|
|
|
319
340
|
const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
320
341
|
const diagnostics = async () => {
|
|
321
342
|
let state = "missing"
|
|
343
|
+
let loopLog = "missing"
|
|
322
344
|
try { state = await readFile(stateFile, "utf8") } catch {}
|
|
323
|
-
|
|
345
|
+
try { loopLog = await readFile(path.join(workspace, ".opencode", "opencode-loop", "loop.log"), "utf8") } catch {}
|
|
346
|
+
return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nloop log:\n${loopLog}\nserver log:\n${serverLog}`
|
|
324
347
|
}
|
|
325
348
|
|
|
326
349
|
await waitFor(() => provider.stats.loopRequests >= 3, "three real autonomous Loop turns", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`)
|
|
327
350
|
await new Promise((resolve) => setTimeout(resolve, 1_500))
|
|
328
351
|
assert.equal(provider.stats.loopRequests, 3, `Loop must stop at --max-runs 3; got extra real-host turn(s)\n${await diagnostics()}`)
|
|
329
352
|
assert.equal(server.exitCode, null, `OpenCode server exited during canary\n${await diagnostics()}`)
|
|
353
|
+
await command
|
|
330
354
|
|
|
331
355
|
let persisted = null
|
|
332
356
|
try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {}
|
|
@@ -335,15 +359,33 @@ async function main() {
|
|
|
335
359
|
if (loop) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`)
|
|
336
360
|
}
|
|
337
361
|
|
|
362
|
+
await sendCommand("loop", `10m --no-now --name natural --multi --max-runs 1 ${RUN_NOW_NATURAL_OBJECTIVE}`)
|
|
363
|
+
await sendCommand("loop", `10m --no-now --name target --multi --max-runs 1 ${RUN_NOW_TARGET_OBJECTIVE}`)
|
|
364
|
+
await waitFor(async () => {
|
|
365
|
+
try {
|
|
366
|
+
const state = JSON.parse(await readFile(stateFile, "utf8"))
|
|
367
|
+
const names = new Set((state.jobs || []).map((job) => job.name))
|
|
368
|
+
return names.has("natural") && names.has("target")
|
|
369
|
+
} catch {
|
|
370
|
+
return false
|
|
371
|
+
}
|
|
372
|
+
}, "two delayed real-host Loop jobs", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`)
|
|
373
|
+
|
|
374
|
+
provider.stats.runNowSequence.length = 0
|
|
375
|
+
await sendCommand("loop-now", "target")
|
|
376
|
+
await waitFor(() => provider.stats.runNowSequence.length >= 1, "targeted real-host loop-now turn", diagnostics)
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 1_500))
|
|
378
|
+
assert.deepEqual(provider.stats.runNowSequence, ["target"], `loop-now target must not run the earlier delayed natural job\n${await diagnostics()}`)
|
|
379
|
+
|
|
338
380
|
console.log(JSON.stringify({
|
|
339
381
|
ok: true,
|
|
340
382
|
platform: process.platform,
|
|
341
383
|
sessionID,
|
|
342
384
|
loopRequests: provider.stats.loopRequests,
|
|
385
|
+
runNowSequence: provider.stats.runNowSequence,
|
|
343
386
|
chatRequests: provider.stats.chatRequests,
|
|
344
387
|
commandError: commandError ? String(commandError) : null,
|
|
345
388
|
}, null, 2))
|
|
346
|
-
void command
|
|
347
389
|
} finally {
|
|
348
390
|
await stopProcess(server)
|
|
349
391
|
await provider.close().catch(() => undefined)
|
|
@@ -354,4 +396,4 @@ async function main() {
|
|
|
354
396
|
main().catch((error) => {
|
|
355
397
|
console.error(error?.stack || error)
|
|
356
398
|
process.exitCode = 1
|
|
357
|
-
})
|
|
399
|
+
})
|