@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/scheduler-runtime-test.mjs +147 -0
  21. package/scripts/session-activity-test.mjs +95 -0
  22. package/scripts/session-status-test.mjs +220 -0
  23. package/scripts/v2-command-adapter-test.mjs +113 -0
  24. package/scripts/v2-command-runtime-test.mjs +98 -0
  25. package/scripts/v2-host-contract-test.mjs +27 -0
  26. package/scripts/v2-plugin-sentinel-test.mjs +57 -8
  27. package/scripts/v2-prompt-interval-test.mjs +171 -0
  28. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  29. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  30. package/src/index.js +2751 -1594
  31. package/src/source/legacy-v1.js +168 -1860
  32. package/src/source/opencode/command-router.js +103 -0
  33. package/src/source/opencode/commands.js +83 -0
  34. package/src/source/opencode/goal-commands.js +107 -0
  35. package/src/source/opencode/loop-commands.js +195 -0
  36. package/src/source/opencode/loop-registration.js +100 -0
  37. package/src/source/opencode/messages.js +55 -0
  38. package/src/source/opencode2/capabilities.js +3 -1
  39. package/src/source/opencode2/commands.js +60 -0
  40. package/src/source/opencode2/event-bridge.js +19 -1
  41. package/src/source/opencode2/events.js +64 -0
  42. package/src/source/opencode2/experimental.js +32 -1
  43. package/src/source/opencode2/host-contract.js +26 -0
  44. package/src/source/opencode2/prompt-runtime.js +381 -0
  45. package/src/source/opencode2/runtime-adapter.js +56 -0
  46. package/src/source/opencode2/status.js +40 -0
  47. package/src/source/runtime/compaction.js +122 -0
  48. package/src/source/runtime/executor.js +558 -0
  49. package/src/source/runtime/goal-policy.js +77 -0
  50. package/src/source/runtime/goal-runtime.js +231 -0
  51. package/src/source/runtime/goal-steering.js +198 -0
  52. package/src/source/runtime/job-workspace.js +148 -0
  53. package/src/source/runtime/scheduler.js +212 -0
  54. package/src/source/runtime/session-activity.js +112 -0
  55. package/src/source/runtime/session-status.js +197 -0
  56. 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 allMessageText(body) {
124
- return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n")
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 = allMessageText(body)
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
- throw new Error(`timed out waiting for ${description}\n${diagnostics()}`)
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 command = api(`/session/${encodeURIComponent(sessionID)}/command`, {
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: "loop", arguments: `0s --max-runs 3 ${LOOP_OBJECTIVE}` }),
313
- signal: AbortSignal.timeout(90_000),
314
- }).catch((error) => {
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
- return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nserver log:\n${serverLog}`
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
+ })