@bybrawe/opencode-loop 0.5.38 → 0.6.0

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 (67) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +30 -0
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -9
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +12 -6
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/goal-compaction-safety-test.mjs +68 -0
  9. package/scripts/goal-companion-test.mjs +1 -1
  10. package/scripts/host-adapter-contract-test.mjs +1 -0
  11. package/scripts/install-node.mjs +105 -60
  12. package/scripts/install-test.mjs +3 -3
  13. package/scripts/install-with-goals.mjs +17 -4
  14. package/scripts/job-workspace-test.mjs +11 -1
  15. package/scripts/loopd-test.mjs +74 -4
  16. package/scripts/loopd.mjs +153 -25
  17. package/scripts/native-cli-host-canary.mjs +118 -0
  18. package/scripts/native-cli-io-test.mjs +66 -0
  19. package/scripts/native-cli-test.mjs +130 -0
  20. package/scripts/native-coexistence-test.mjs +38 -0
  21. package/scripts/native-entry-test.mjs +77 -0
  22. package/scripts/native-guide-test.mjs +45 -0
  23. package/scripts/native-install-pair-test.mjs +73 -0
  24. package/scripts/native-install-test.mjs +107 -0
  25. package/scripts/native-lifecycle-test.mjs +100 -0
  26. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  27. package/scripts/native-presets-test.mjs +227 -0
  28. package/scripts/native-process-tree-fixture.mjs +72 -0
  29. package/scripts/native-process-tree-test.mjs +40 -0
  30. package/scripts/native-release.mjs +121 -0
  31. package/scripts/native-session-location-test.mjs +86 -0
  32. package/scripts/native-shell-test.mjs +38 -0
  33. package/scripts/native-v2-test.mjs +262 -0
  34. package/scripts/opencode-host.mjs +47 -0
  35. package/scripts/opencode2-loop-canary.mjs +72 -4
  36. package/scripts/package-native-smoke.mjs +31 -0
  37. package/scripts/publish-workflow-test.mjs +60 -19
  38. package/scripts/pulse-check.mjs +246 -0
  39. package/scripts/session-status-test.mjs +12 -0
  40. package/src/index.js +87 -48
  41. package/src/native.js +3135 -0
  42. package/src/server.js +2164 -4560
  43. package/src/source/core/jobs.js +17 -0
  44. package/src/source/core/process.js +6 -4
  45. package/src/source/core/progress.js +58 -0
  46. package/src/source/legacy-v1.js +9 -3
  47. package/src/source/native.js +1 -0
  48. package/src/source/opencode/host.js +1 -0
  49. package/src/source/opencode/loop-commands.js +1 -27
  50. package/src/source/opencode/loop-registration.js +2 -12
  51. package/src/source/opencode/messages.js +1 -1
  52. package/src/source/opencode2/commands.js +9 -4
  53. package/src/source/opencode2/event-bridge.js +15 -3
  54. package/src/source/opencode2/events.js +20 -0
  55. package/src/source/opencode2/experimental.js +2 -0
  56. package/src/source/opencode2/native-companion.js +24 -0
  57. package/src/source/opencode2/native-plugin.js +153 -0
  58. package/src/source/opencode2/native-policy.js +106 -0
  59. package/src/source/opencode2/native-runtime.js +464 -0
  60. package/src/source/opencode2/native-shell.js +81 -0
  61. package/src/source/opencode2/prompt-runtime.js +2 -0
  62. package/src/source/opencode2/session-location.js +16 -0
  63. package/src/source/runtime/goal-steering.js +17 -3
  64. package/src/source/runtime/job-workspace.js +11 -2
  65. package/src/source/runtime/session-status.js +11 -1
  66. package/src/source/server.js +9 -1
  67. package/src/v1.js +1 -0
package/scripts/loopd.mjs CHANGED
@@ -5,11 +5,13 @@ import { spawn, spawnSync } from "node:child_process"
5
5
  import { createHash } from "node:crypto"
6
6
  import { homedir } from "node:os"
7
7
  import { fileURLToPath } from "node:url"
8
+ import { selectOpenCodeHost, projectSessions } from "./opencode-host.mjs"
9
+ import { checkGoalPulse, parsePulseDurationMs } from "./pulse-check.mjs"
8
10
 
9
11
  const args = process.argv.slice(2)
10
12
  const configuredRetryMs = Number(process.env.OPENCODE_LOOPD_FAILED_RUN_RETRY_MS)
11
13
  const FAILED_RUN_RETRY_MS = Number.isFinite(configuredRetryMs) && configuredRetryMs >= 0 ? configuredRetryMs : 5_000
12
- const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
14
+ const OPENCODE_BIN = process.env.OPENCODE_BIN || process.env.OPENCODE_BINARY || process.env.OPENCODE2_BINARY
13
15
  const SCHTASKS_BIN = process.env.SCHTASKS_BIN || "schtasks"
14
16
  const TASK_ROOT = process.env.OPENCODE_LOOPD_TASK_DIR || path.join(process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), "opencode-loop", "tasks")
15
17
 
@@ -51,6 +53,14 @@ function parseMaxRuns(value) {
51
53
  return parsed
52
54
  }
53
55
 
56
+ function parseMaxChecks(value) {
57
+ const text = String(value ?? "0").trim()
58
+ if (!/^\d+$/.test(text)) throw new Error(`Invalid --max-checks value: ${value}`)
59
+ const parsed = Number(text)
60
+ if (!Number.isSafeInteger(parsed)) throw new Error(`Invalid --max-checks value: ${value}`)
61
+ return parsed
62
+ }
63
+
54
64
  function validateProject(project) {
55
65
  let stat
56
66
  try { stat = fs.statSync(project) } catch { throw new Error(`Project directory does not exist: ${project}`) }
@@ -108,8 +118,8 @@ function spawnOnce(command, commandArgs, cwd, options = {}) {
108
118
  child = spawn(command, commandArgs, {
109
119
  cwd,
110
120
  shell: false,
111
- stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
112
- env: process.env,
121
+ stdio: capture ? ["ignore", "pipe", "pipe"] : options.closeStdin ? ["ignore", "inherit", "inherit"] : "inherit",
122
+ env: options.env || process.env,
113
123
  windowsHide: true,
114
124
  detached: process.platform !== "win32",
115
125
  })
@@ -128,7 +138,7 @@ function spawnOnce(command, commandArgs, cwd, options = {}) {
128
138
  timer.unref?.()
129
139
  }
130
140
  child.on("error", (error) => done({ code: -1, error }))
131
- child.on("exit", (code, signal) => done({ code: timedOut ? 124 : (code ?? (signal ? 1 : 0)), signal }))
141
+ child.on("close", (code, signal) => done({ code: timedOut ? 124 : (code ?? (signal ? 1 : 0)), signal }))
132
142
  })
133
143
  }
134
144
 
@@ -152,14 +162,52 @@ async function run(command, commandArgs, cwd, options = {}) {
152
162
  return fallback
153
163
  }
154
164
 
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
165
+ async function selectHost(project, options = {}) {
166
+ const legacyV1 = options.legacyV1 ?? has("--legacy-v1")
167
+ if (legacyV1 && has("--native-v2")) throw new Error("Use either --native-v2 or --legacy-v1, not both")
168
+ const server = options.server ?? arg("--server")
169
+ if (server) {
170
+ if (legacyV1) throw new Error("--server is a native OpenCode 2 option")
171
+ if (!["http:", "https:"].includes(new URL(server).protocol)) throw new Error("--server must be an HTTP(S) URL")
172
+ }
173
+ const host = await selectOpenCodeHost({
174
+ binary: options.opencodeBin || OPENCODE_BIN,
175
+ legacyV1,
176
+ probe: (binary) => run(binary, ["--version"], project, { capture: true, timeoutMs: 5_000 }),
177
+ })
178
+ return { ...host, server, legacyV1 }
179
+ }
180
+
181
+ async function listSessions(host, project, limit) {
182
+ const argv = ["session", "list", "--format", "json", "-n", String(limit)]
183
+ if (host.server) argv.push("--server", host.server)
184
+ const result = await run(host.binary, argv, project, {
185
+ capture: true, timeoutMs: 15_000,
186
+ ...(host.major === 2 ? { env: { ...process.env, PWD: project } } : {}),
187
+ })
188
+ if (result.code !== 0 || result.timedOut) throw new Error("OpenCode session list failed; refusing to guess a session")
189
+ return projectSessions(result.stdout, project, host.major === 2)
190
+ }
191
+
192
+ async function resolveLatestSessionID(host, project, preferredTitle) {
193
+ const sessions = await listSessions(host, project, 100)
194
+ const match = preferredTitle ? sessions.find((item) => item.title === preferredTitle) : sessions[0]
195
+ return match?.id
196
+ }
197
+
198
+ async function resolveSessionActivity(host, project) {
199
+ if (!host) return {}
200
+ let sessions
201
+ try { sessions = await listSessions(host, project, 100) } catch (error) {
202
+ console.warn(`[opencode-loopd] session telemetry unavailable: ${error.message}`)
203
+ return {}
204
+ }
205
+ const activity = {}
206
+ for (const session of sessions) {
207
+ const updated = Number(session.updated ?? session.time?.updated)
208
+ if (Number.isFinite(updated) && updated >= 0) activity[session.id] = updated
209
+ }
210
+ return activity
163
211
  }
164
212
 
165
213
  function runSync(command, commandArgs) {
@@ -204,12 +252,13 @@ async function daemon(options = {}) {
204
252
  const prompt = readPrompt(project, options)
205
253
  const model = options.model ?? arg("--model")
206
254
  const agent = options.agent ?? arg("--agent")
207
- const opencodeBin = options.opencodeBin || OPENCODE_BIN
255
+ const host = await selectHost(project, options)
208
256
  const explicitSessionID = options.session ?? arg("--session")
209
- let sessionID = explicitSessionID || await resolveLatestSessionID(opencodeBin, project)
257
+ let sessionID = explicitSessionID || await resolveLatestSessionID(host, project)
210
258
  const sessionTitle = `OpenCode Loop daemon ${process.pid}-${Date.now()}`
211
259
 
212
260
  console.log("OpenCode Loop daemon")
261
+ console.log(`host: ${host.version}`)
213
262
  console.log(`project: ${project}`)
214
263
  console.log(`every: ${every}`)
215
264
  console.log(`maxRuns: ${maxRuns || "unlimited"}`)
@@ -226,18 +275,32 @@ async function daemon(options = {}) {
226
275
  console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
227
276
 
228
277
  const runArgs = ["run"]
278
+ if (host.server) runArgs.push("--server", host.server)
229
279
  if (sessionID) runArgs.push("--session", sessionID)
230
280
  else runArgs.push("--title", sessionTitle)
231
281
  if (model) runArgs.push("--model", model)
232
282
  if (agent) runArgs.push("--agent", agent)
233
- runArgs.push(prompt)
234
-
235
- const result = await run(opencodeBin, runArgs, project, { timeoutMs })
283
+ runArgs.push("--", prompt)
284
+
285
+ // V2 reads PWD before cwd and reads stdin even with an argv prompt.
286
+ // Keep native runs project-bound and non-interactive without changing V1.
287
+ const result = await run(host.binary, runArgs, project, {
288
+ timeoutMs, closeStdin: host.major === 2,
289
+ ...(host.major === 2 ? { env: { ...process.env, PWD: project } } : {}),
290
+ })
236
291
  const code = result.timedOut ? 124 : result.code
237
292
  const moreRunsRemain = maxRuns === 0 || count < maxRuns
238
293
 
294
+ // V2 run is a client of a persistent native server. A failed/timed-out
295
+ // client is not proof that its admitted execution stopped. Replaying here
296
+ // could queue a duplicate turn while the original still runs.
297
+ if (host.major === 2 && code !== 0) {
298
+ console.error(`[opencode-loopd] native CLI ended with code ${code}; the server execution may still be active. Automatic replay stopped. Inspect the session before restarting.`)
299
+ return Number.isInteger(code) && code > 0 ? code : 1
300
+ }
301
+
239
302
  if (!sessionID && code === 0 && moreRunsRemain) {
240
- sessionID = await resolveLatestSessionID(opencodeBin, project, sessionTitle)
303
+ sessionID = await resolveLatestSessionID(host, project, sessionTitle)
241
304
  if (!sessionID) {
242
305
  console.error("[opencode-loopd] could not resolve the newly created session; refusing to continue unpinned")
243
306
  return 1
@@ -258,6 +321,47 @@ async function daemon(options = {}) {
258
321
  }
259
322
  }
260
323
 
324
+ async function pulseCheck(options = {}) {
325
+ const project = path.resolve(options.project ?? arg("--project", process.cwd()))
326
+ validateProject(project)
327
+ const staleAfterText = options.staleAfter ?? arg("--stale-after", "30m")
328
+ const staleAfterMs = parsePulseDurationMs(staleAfterText)
329
+ const every = options.every ?? arg("--every", "0s")
330
+ const delay = parseMs(every)
331
+ const maxChecks = parseMaxChecks(options.maxChecks ?? arg("--max-checks", "0"))
332
+ const target = options.target ?? arg("--goal")
333
+ let host
334
+ try { host = await selectHost(project, options) } catch (error) {
335
+ console.warn(`[opencode-loopd] ${error.message} Pulse-check will use persisted Goal timestamps only.`)
336
+ }
337
+
338
+ console.log("OpenCode Goal pulse-check")
339
+ console.log(`project: ${project}`)
340
+ console.log(`stale-after: ${staleAfterText}`)
341
+ console.log(`every: ${delay > 0 ? every : "one-shot"}`)
342
+ if (target) console.log(`goal: ${target}`)
343
+
344
+ let checks = 0
345
+ while (true) {
346
+ checks += 1
347
+ const sessionActivity = await resolveSessionActivity(host, project)
348
+ const result = await checkGoalPulse(project, {
349
+ staleAfterMs,
350
+ ...(target ? { target } : {}),
351
+ sessionActivity,
352
+ })
353
+
354
+ for (const alert of result.alerts) console.log(alert.message)
355
+ if (checks === 1) console.log(`state: ${result.stateFile}`)
356
+ if (target && checks === 1 && result.checked === 0) {
357
+ console.log(`[opencode-loopd] no dedicated Goal matched target=${target}`)
358
+ }
359
+
360
+ if (delay <= 0 || (maxChecks > 0 && checks >= maxChecks)) return 0
361
+ await sleep(delay)
362
+ }
363
+ }
364
+
261
365
  function taskArtifacts(name) {
262
366
  const id = createHash("sha256").update(String(name || "OpenCodeLoop")).digest("hex").slice(0, 16)
263
367
  return {
@@ -279,7 +383,7 @@ async function taskRun() {
279
383
  return await daemon(config)
280
384
  }
281
385
 
282
- function installTask() {
386
+ async function installTask() {
283
387
  if (process.platform !== "win32") {
284
388
  throw new Error("install-task is currently implemented for Windows Task Scheduler only. Use daemon mode on macOS/Linux.")
285
389
  }
@@ -294,6 +398,8 @@ function installTask() {
294
398
  const model = arg("--model")
295
399
  const agent = arg("--agent")
296
400
  const timeout = arg("--timeout", "30m")
401
+ parseMs(timeout)
402
+ const host = await selectHost(project)
297
403
  const node = process.execPath
298
404
  const script = fileURLToPath(import.meta.url)
299
405
  const artifacts = taskArtifacts(name)
@@ -307,7 +413,10 @@ function installTask() {
307
413
  model: model || undefined,
308
414
  agent: agent || undefined,
309
415
  timeout,
310
- opencodeBin: OPENCODE_BIN,
416
+ opencodeBin: host.binary,
417
+ legacyV1: host.legacyV1,
418
+ server: host.server || undefined,
419
+ session: arg("--session") || undefined,
311
420
  }
312
421
  fs.writeFileSync(artifacts.config, JSON.stringify(taskConfig, null, 2) + "\n", "utf8")
313
422
  const launcherCommand = [quoteWindowsArg(node), quoteWindowsArg(script), "task-run", "--config", quoteWindowsArg(artifacts.config)].join(" ")
@@ -354,6 +463,8 @@ OpenCode Loop daemon
354
463
  Usage:
355
464
  opencode-loopd --project . --every 5m --prompt-file loop-prompt.md
356
465
  opencode-loopd --project . --every 0s --prompt "continue from progress.md"
466
+ opencode-loopd pulse-check --project . --stale-after 30m
467
+ opencode-loopd pulse-check --project . --stale-after 30m --every 1m
357
468
  opencode-loopd install-task --project . --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop
358
469
  opencode-loopd uninstall-task --name OpenCodeLoop
359
470
 
@@ -365,9 +476,23 @@ Options:
365
476
  --model <provider/id> OpenCode model used for each run
366
477
  --agent <name> OpenCode agent used for each run
367
478
  --session <id> Pin an existing OpenCode session (auto-pinned otherwise)
368
- --timeout <duration> Max time per OpenCode run (default: 30m, 0s disables)
479
+ --server <url> Native V2 server used for both session lookup and runs
480
+ --legacy-v1 Explicitly select the legacy V1 CLI (default: native V2)
481
+ --timeout <duration> Max time per CLI client run (default: 30m, 0s disables)
369
482
  --max-runs <n> Stop after n runs
370
483
  --sleep-first Wait before first run
484
+
485
+ Host selection: OPENCODE_BIN (or OPENCODE_BINARY), otherwise opencode then
486
+ opencode2, requiring a 2.x version. V1 is never selected without --legacy-v1.
487
+ Native failures/timeouts stop the daemon without replay: the server execution
488
+ may still be running after its CLI client exits. Inspect it before restarting.
489
+ No automatic permission approval is added. Pulse-check never dispatches work.
490
+
491
+ Pulse-check options:
492
+ --stale-after <duration> Alert when an active dedicated Goal has not changed (default: 30m)
493
+ --goal <id|session> Watch one Goal by Goal ID or session ID (default: all dedicated Goals)
494
+ --every <duration> Repeat checks at this cadence; 0s is one-shot (default: 0s)
495
+ --max-checks <n> Stop a repeating pulse-check after n checks
371
496
  `)
372
497
  }
373
498
 
@@ -375,20 +500,23 @@ const command = args[0]
375
500
 
376
501
  try {
377
502
  let exitCode = 0
378
- if (command === "daemon" || command === "loopd") {
503
+ if (has("--help") || has("-h") || command === "help") {
504
+ help()
505
+ } else if (command === "daemon" || command === "loopd") {
379
506
  args.shift()
380
507
  exitCode = await daemon()
508
+ } else if (command === "pulse-check") {
509
+ args.shift()
510
+ exitCode = await pulseCheck()
381
511
  } else if (command === "install-task") {
382
512
  args.shift()
383
- exitCode = installTask()
513
+ exitCode = await installTask()
384
514
  } else if (command === "uninstall-task") {
385
515
  args.shift()
386
516
  exitCode = uninstallTask()
387
517
  } else if (command === "task-run") {
388
518
  args.shift()
389
519
  exitCode = await taskRun()
390
- } else if (has("--help") || has("-h") || command === "help") {
391
- help()
392
520
  } else {
393
521
  exitCode = await daemon()
394
522
  }
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn, execFileSync } from "node:child_process"
3
+ import { createServer } from "node:http"
4
+ import net from "node:net"
5
+ import { randomBytes } from "node:crypto"
6
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
+ const binary = process.env.OPENCODE2_BINARY || "opencode2"
13
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "native-loop-cli-host-"))
14
+ const home = path.join(workspace, ".home")
15
+ const password = randomBytes(24).toString("hex")
16
+ const redact = (value) => String(value).replaceAll(password, "[redacted]").replace(/server password[^\r\n]*/gi, "server password [redacted]")
17
+ const prompt = "NATIVE_CLI_CANARY: reply OK, do not use tools"
18
+ let turns = 0
19
+ let server, serverLog = ""
20
+ const provider = createServer(async (request, response) => {
21
+ try {
22
+ if (request.method !== "POST" || !request.url?.endsWith("/chat/completions")) {
23
+ response.writeHead(404); response.end(); return
24
+ }
25
+ let raw = ""
26
+ for await (const chunk of request) raw += chunk
27
+ const body = JSON.parse(raw)
28
+ const last = (body.messages || []).filter((message) => message.role === "user").at(-1)
29
+ const text = typeof last?.content === "string" ? last.content : (last?.content || []).map((part) => part.text || "").join("\n")
30
+ if (text.includes(prompt)) turns++
31
+ const base = { id: `cli-canary-${Date.now()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "canary" }
32
+ response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" })
33
+ response.write(`data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: "OK" }, finish_reason: null }] })}\n\n`)
34
+ response.write(`data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 20, completion_tokens: 2, total_tokens: 22 } })}\n\n`)
35
+ response.end("data: [DONE]\n\n")
36
+ } catch (error) { response.writeHead(500); response.end(String(error)) }
37
+ })
38
+ function run(command, args, env, timeout = 90_000) {
39
+ return new Promise((resolve, reject) => {
40
+ const child = spawn(command, args, { cwd: workspace, env, windowsHide: true })
41
+ let stdout = "", stderr = ""
42
+ const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(redact(`CLI deadline: ${args[0]}\n${stdout}\n${stderr}\n${serverLog}`))) }, timeout)
43
+ child.stdout.on("data", (data) => { stdout = (stdout + data).slice(-40_000) })
44
+ child.stderr.on("data", (data) => { stderr = (stderr + data).slice(-40_000) })
45
+ child.once("error", (error) => { clearTimeout(timer); reject(error) })
46
+ child.once("close", (code) => { clearTimeout(timer); resolve({ code, stdout: redact(stdout), stderr: redact(stderr) }) })
47
+ })
48
+ }
49
+ async function stop(child) {
50
+ if (!child || child.exitCode !== null) return
51
+ await new Promise((resolve) => {
52
+ const timer = setTimeout(() => { child.kill("SIGKILL"); resolve() }, 3_000)
53
+ child.once("close", () => { clearTimeout(timer); resolve() })
54
+ child.kill("SIGTERM")
55
+ })
56
+ }
57
+ try {
58
+ await mkdir(path.join(home, ".config/opencode"), { recursive: true })
59
+ await new Promise((resolve) => provider.listen(0, "127.0.0.1", resolve))
60
+ const providerPort = provider.address().port
61
+ await writeFile(path.join(workspace, "opencode.json"), JSON.stringify({
62
+ model: "canary/canary",
63
+ providers: { canary: {
64
+ name: "Local CLI compatibility canary", package: "@opencode-ai/ai/providers/openai-compatible",
65
+ settings: { baseURL: `http://127.0.0.1:${providerPort}/v1` },
66
+ models: { canary: { name: "Canary", capabilities: { tools: true, input: ["text"], output: ["text"] }, limit: { context: 100000, output: 4096 } } },
67
+ } },
68
+ }))
69
+ execFileSync("git", ["init", "--quiet"], { cwd: workspace })
70
+ const env = {
71
+ ...process.env, HOME: home, USERPROFILE: home, OPENCODE_CONFIG_DIR: path.join(home, ".config/opencode"),
72
+ XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(home, ".local/share"),
73
+ XDG_STATE_HOME: path.join(home, ".local/state"), XDG_CACHE_HOME: path.join(home, ".cache"),
74
+ // Both the CLI and the manually started V2 server consume this value.
75
+ // Empty legacy credentials do not disable auth: the server generates a password.
76
+ OPENCODE_PASSWORD: password, OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password,
77
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
78
+ OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_BIN: binary, CI: "true",
79
+ }
80
+ const version = await run(binary, ["--version"], env, 10_000)
81
+ assert.equal(version.code, 0, version.stderr)
82
+ assert.equal(version.stdout.trim(), "opencode v2.0.18", "this canary proves the exact pinned CLI")
83
+ const reservation = net.createServer()
84
+ await new Promise((resolve) => reservation.listen(0, "127.0.0.1", resolve))
85
+ const port = reservation.address().port
86
+ await new Promise((resolve) => reservation.close(resolve))
87
+ const url = `http://127.0.0.1:${port}`
88
+ server = spawn(binary, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
89
+ server.on("error", (error) => { serverLog += error.message })
90
+ server.stdout.on("data", (data) => { serverLog = (serverLog + data).slice(-40_000) })
91
+ server.stderr.on("data", (data) => { serverLog = (serverLog + data).slice(-40_000) })
92
+ const deadline = Date.now() + 30_000
93
+ let ready = false
94
+ while (!ready && Date.now() < deadline && server.exitCode === null) {
95
+ ready = await new Promise((resolve) => {
96
+ const socket = net.createConnection({ host: "127.0.0.1", port })
97
+ socket.once("connect", () => { socket.destroy(); resolve(true) })
98
+ socket.once("error", () => { socket.destroy(); resolve(false) })
99
+ })
100
+ if (!ready) await new Promise((resolve) => setTimeout(resolve, 100))
101
+ }
102
+ assert.ok(ready, redact(serverLog))
103
+ const result = await run(process.execPath, [path.join(root, "scripts/loopd.mjs"), "--project", workspace, "--server", url, "--max-runs", "2", "--timeout", "60s", "--model", "canary/canary", "--prompt", prompt], env, 150_000)
104
+ assert.equal(result.code, 0, redact(`${result.stdout}\n${result.stderr}\n${serverLog}`))
105
+ assert.match(result.stdout, /pinned session/)
106
+ assert.equal(turns, 2, redact(`expected exactly two daemon turns\n${result.stdout}\n${serverLog}`))
107
+ const listed = await run(binary, ["session", "list", "--format", "json", "-n", "100", "--server", url], env)
108
+ assert.equal(listed.code, 0, listed.stderr)
109
+ const sessions = JSON.parse(listed.stdout)
110
+ assert.equal(sessions.length, 1, "both daemon iterations must use the one created native session")
111
+ assert.ok(sessions[0].title.startsWith("OpenCode Loop daemon "))
112
+ console.log(JSON.stringify({ host: version.stdout.trim(), turns, sessions: sessions.length, sessionID: sessions[0].id, result: "native CLI daemon PASS" }))
113
+ } finally {
114
+ await stop(server)
115
+ provider.closeAllConnections()
116
+ await new Promise((resolve) => provider.close(resolve))
117
+ await rm(workspace, { recursive: true, force: true })
118
+ }
@@ -0,0 +1,66 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+ import { spawn } from "node:child_process"
4
+ import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
10
+ async function scenario(mode) {
11
+ const temp = await mkdtemp(path.join(os.tmpdir(), "native-cli-io-"))
12
+ const project = path.join(temp, "project with spaces")
13
+ await mkdir(project)
14
+ const script = path.join(temp, "host.cjs")
15
+ const log = path.join(temp, "calls.jsonl")
16
+ await writeFile(script, `const fs = require('node:fs');
17
+ const args = process.argv.slice(2);
18
+ fs.appendFileSync(process.env.IO_LOG, JSON.stringify({args, cwd:process.cwd(), pwd:process.env.PWD})+'\\n');
19
+ if(args[0]==='--version'){console.log(process.env.IO_MODE==='legacy'?'1.18.15':'opencode v2.0.18');process.exit(0)}
20
+ if(args[0]==='session'){console.log(JSON.stringify([{id:'ses_io',directory:process.cwd()}]));process.exit(0)}
21
+ if(process.env.IO_MODE==='pwd'){process.exit(process.env.PWD===process.cwd()?0:23)}
22
+ let input='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>input+=chunk);
23
+ process.stdin.on('end',()=>{console.log('INPUT='+input);process.exit(input===(process.env.IO_MODE==='legacy'?'legacy-input':'')?0:24)});
24
+ process.stdin.resume();
25
+ `)
26
+ const binary = path.join(temp, process.platform === "win32" ? "host.cmd" : "host")
27
+ await writeFile(binary, process.platform === "win32"
28
+ ? `@echo off\r\n"${process.execPath}" "${script}" %*\r\nexit /b %errorlevel%\r\n`
29
+ : `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`, { mode: 0o755 })
30
+ let child, timer
31
+ try {
32
+ const result = await new Promise((resolve, reject) => {
33
+ child = spawn(process.execPath, [path.join(root, "scripts/loopd.mjs"), "--project", project, "--max-runs", "1", "--timeout", "3s", "--prompt", "argument-only prompt", ...(mode === "legacy" ? ["--legacy-v1"] : [])], {
34
+ cwd: root, windowsHide: true,
35
+ env: { ...process.env, OPENCODE_BIN: binary, PWD: root, IO_MODE: mode, IO_LOG: log },
36
+ })
37
+ let stdout = "", stderr = ""
38
+ timer = setTimeout(() => { child.kill(); reject(new Error(`daemon deadline\n${stdout}\n${stderr}`)) }, 20_000)
39
+ child.stdout.on("data", data => { stdout += data })
40
+ child.stderr.on("data", data => { stderr += data })
41
+ child.stdin.on("error", error => { if (error.code !== "EPIPE") reject(error) })
42
+ child.once("error", reject)
43
+ child.once("close", code => resolve({ code, stdout, stderr }))
44
+ if (mode === "legacy") child.stdin.end("legacy-input")
45
+ else child.stdin.write("must-not-become-a-prompt") // Deliberately leave the parent's pipe open.
46
+ })
47
+ assert.equal(result.code, 0, `${mode}: ${result.stdout}\n${result.stderr}`)
48
+ const calls = (await readFile(log, "utf8")).trim().split(/\r?\n/).map(JSON.parse)
49
+ if (mode === "pwd") {
50
+ for (const call of calls.filter(call => ["session", "run"].includes(call.args[0]))) {
51
+ assert.equal(call.pwd, project, "native lookup and run must use the selected project, not the caller's PWD")
52
+ assert.equal(call.cwd, project)
53
+ }
54
+ }
55
+ if (mode === "stdin") assert.match(result.stdout, /INPUT=\r?\n/)
56
+ if (mode === "legacy") assert.match(result.stdout, /INPUT=legacy-input/)
57
+ } finally {
58
+ clearTimeout(timer)
59
+ if (child && child.exitCode === null) child.kill()
60
+ await rm(temp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
61
+ }
62
+ }
63
+
64
+ test("native CLI binds PWD to --project for lookup and run", () => scenario("pwd"))
65
+ test("native CLI receives EOF even while the daemon stdin stays open", () => scenario("stdin"))
66
+ test("explicit V1 retains inherited stdin", () => scenario("legacy"))
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn } from "node:child_process"
3
+ import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
9
+ const temp = await mkdtemp(path.join(os.tmpdir(), "loop-native-cli-"))
10
+ const project = path.join(temp, "project with spaces")
11
+ await mkdir(project)
12
+ const log = path.join(temp, "calls.jsonl")
13
+ const script = path.join(temp, "host.cjs")
14
+ await writeFile(script, `const fs = require('node:fs');
15
+ const args = process.argv.slice(2);
16
+ fs.appendFileSync(process.env.NATIVE_CLI_LOG, JSON.stringify({ args, cwd: process.cwd() }) + '\\n');
17
+ if (args[0] === '--version') { console.log(process.env.NATIVE_CLI_VERSION || 'opencode v2.0.18'); process.exit(0); }
18
+ if (args[0] !== '--version' && process.env.NATIVE_CLI_EXPECT_PASSWORD && process.env.OPENCODE_PASSWORD !== process.env.NATIVE_CLI_EXPECT_PASSWORD) {
19
+ console.error('test host rejected missing native password'); process.exit(19);
20
+ }
21
+ if (args[0] === 'session') {
22
+ if (process.env.NATIVE_CLI_BAD_LIST) { console.log('invalid-json'); process.exit(0); }
23
+ console.log(JSON.stringify([
24
+ { id: 'ses_foreign', directory: process.cwd() + '-other', title: 'Other worktree' },
25
+ { id: 'ses_local', directory: process.cwd(), title: 'Local session', updated: Date.now() }
26
+ ])); process.exit(0);
27
+ }
28
+ if (process.env.NATIVE_CLI_SLEEP) setTimeout(() => process.exit(0), 10000);
29
+ else process.exit(Number(process.env.NATIVE_CLI_EXIT || 0));
30
+ `)
31
+ const command = path.join(temp, process.platform === "win32" ? "host.cmd" : "host")
32
+ await writeFile(command, process.platform === "win32"
33
+ ? `@echo off\r\n"${process.execPath}" "${script}" %*\r\nexit /b %errorlevel%\r\n`
34
+ : `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`, { mode: 0o755 })
35
+ async function run(args = [], env = {}) {
36
+ await rm(log, { force: true })
37
+ return await new Promise((resolve, reject) => {
38
+ const child = spawn(process.execPath, [path.join(root, "scripts/loopd.mjs"), ...args], {
39
+ cwd: root, windowsHide: true,
40
+ env: { ...process.env, OPENCODE_BIN: command, OPENCODE_BINARY: "", NATIVE_CLI_LOG: log, OPENCODE_LOOPD_FAILED_RUN_RETRY_MS: "0", ...env },
41
+ })
42
+ let stdout = "", stderr = ""
43
+ const deadline = setTimeout(() => { child.kill(); reject(new Error(`CLI test hung: ${args.join(' ')}\n${stdout}\n${stderr}`)) }, 20_000)
44
+ child.stdout.on("data", (data) => { stdout += data })
45
+ child.stderr.on("data", (data) => { stderr += data })
46
+ child.once("error", (error) => { clearTimeout(deadline); reject(error) })
47
+ child.once("close", (code) => { clearTimeout(deadline); resolve({ code, stdout, stderr }) })
48
+ })
49
+ }
50
+ const calls = async () => (await readFile(log, "utf8").catch(() => "")).trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
51
+ const base = ["--project", project, "--max-runs", "2", "--prompt", "native task"]
52
+ let cases = 0
53
+ try {
54
+ let result = await run(base, { NATIVE_CLI_VERSION: "1.18.15" })
55
+ assert.notEqual(result.code, 0, "native daemon must reject V1 instead of silently dispatching there")
56
+ assert.equal((await calls()).filter((call) => call.args[0] === "run").length, 0)
57
+ cases++
58
+
59
+ result = await run([...base, "--server", "http://127.0.0.1:12345"])
60
+ assert.equal(result.code, 0, result.stderr)
61
+ const observed = await calls()
62
+ assert.equal(observed.filter((call) => call.args[0] === "--version").length, 1, "select the host once, not once per iteration")
63
+ const runs = observed.filter((call) => call.args[0] === "run")
64
+ assert.equal(runs.length, 2)
65
+ for (const call of runs) {
66
+ assert.equal(call.args[call.args.indexOf("--session") + 1], "ses_local", "never adopt another worktree's newest session")
67
+ assert.equal(call.args[call.args.indexOf("--server") + 1], "http://127.0.0.1:12345")
68
+ assert.deepEqual(call.args.slice(-2), ["--", "native task"])
69
+ }
70
+ assert.ok(observed.filter((call) => call.args[0] === "session").every((call) => call.args.includes("--server")))
71
+ cases++
72
+
73
+ result = await run(base, { NATIVE_CLI_EXIT: "7" })
74
+ assert.equal(result.code, 7, result.stderr)
75
+ assert.equal((await calls()).filter((call) => call.args[0] === "run").length, 1, "V2 dispatch errors are not replay-safe")
76
+ cases++
77
+
78
+ result = await run([...base, "--timeout", "200ms"], { NATIVE_CLI_SLEEP: "1" })
79
+ assert.equal(result.code, 124, result.stderr)
80
+ assert.equal((await calls()).filter((call) => call.args[0] === "run").length, 1, "killing a CLI client does not prove its native server execution ended")
81
+ cases++
82
+
83
+ result = await run(base, { NATIVE_CLI_BAD_LIST: "1" })
84
+ assert.notEqual(result.code, 0)
85
+ assert.equal((await calls()).filter((call) => call.args[0] === "run").length, 0, "invalid session output cannot be mistaken for an empty list")
86
+ cases++
87
+
88
+ for (const subcommand of ["daemon", "pulse-check", "install-task", "task-run"]) {
89
+ result = await run([subcommand, "--help"])
90
+ assert.equal(result.code, 0, result.stderr)
91
+ assert.match(result.stdout, /--legacy-v1/)
92
+ assert.deepEqual(await calls(), [], "help must not probe or start a host")
93
+ }
94
+ cases++
95
+
96
+ result = await run([...base, "--legacy-v1"], { NATIVE_CLI_VERSION: "1.18.15" })
97
+ assert.equal(result.code, 0, result.stderr)
98
+ assert.equal((await calls()).filter((call) => call.args[0] === "run").length, 2)
99
+ cases++
100
+
101
+ result = await run(["--project", project, "--max-runs", "1", "--prompt", "--server not-a-flag"])
102
+ assert.equal(result.code, 0, result.stderr)
103
+ assert.deepEqual((await calls()).find((call) => call.args[0] === "run").args.slice(-2), ["--", "--server not-a-flag"])
104
+ cases++
105
+
106
+ result = await run([...base, "--server", "http://127.0.0.1:12345"], {
107
+ OPENCODE_PASSWORD: "isolated-cli-test-password",
108
+ NATIVE_CLI_EXPECT_PASSWORD: "isolated-cli-test-password",
109
+ })
110
+ assert.equal(result.code, 0, result.stderr)
111
+ assert.equal((await calls()).filter((call) => call.args[0] === "run").length, 2)
112
+ assert.doesNotMatch(result.stdout + result.stderr + JSON.stringify(await calls()), /isolated-cli-test-password/, "native auth must stay in the environment, not argv or logs")
113
+ cases++
114
+
115
+ const { selectOpenCodeHost, projectSessions } = await import("./opencode-host.mjs")
116
+ const probed = []
117
+ const probe = async (binary) => { probed.push(binary); return { code: 0, stdout: binary === "opencode2" ? "opencode v2.0.18\n" : "1.18.15\n" } }
118
+ assert.equal((await selectOpenCodeHost({ probe })).binary, "opencode2")
119
+ assert.deepEqual(probed, ["opencode", "opencode2"])
120
+ assert.equal((await selectOpenCodeHost({ probe: async () => ({ code: 0, stdout: "opencode v2.0.18" }) })).binary, "opencode", "prefer the current canonical binary when both names work")
121
+ const fallback = await selectOpenCodeHost({ probe: async (binary) => binary === "opencode2" ? { code: -1 } : { code: 0, stdout: "opencode v2.0.18" } })
122
+ assert.equal(fallback.binary, "opencode")
123
+ await assert.rejects(selectOpenCodeHost({ binary: "custom", probe: async () => ({ code: 0, stdout: "warning 2.0.18" }) }), /OpenCode 2/)
124
+ await assert.rejects(selectOpenCodeHost({ probe: async () => ({ code: 0, stdout: "1.18.15" }) }), /legacy-v1/)
125
+ assert.equal((await selectOpenCodeHost({ legacyV1: true, probe })).major, 1)
126
+ assert.deepEqual(projectSessions(JSON.stringify([{ id: "a", location: { directory: project } }, { id: "b", directory: temp }]), project, true).map((item) => item.id), ["a"])
127
+ assert.throws(() => projectSessions("{}", project, true), /session list/)
128
+ cases++
129
+ console.log(`Native CLI: ${cases} scenarios passed`)
130
+ } finally { await rm(temp, { recursive: true, force: true }) }
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict"
2
+ import { createHash } from "node:crypto"
3
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"
4
+ import path from "node:path"
5
+ import os from "node:os"
6
+ import { nativeGoalReservesSession } from "../src/source/opencode2/native-companion.js"
7
+ import { createNativeLoopRuntime } from "../src/source/opencode2/native-runtime.js"
8
+
9
+ const directory = await mkdtemp(path.join(os.tmpdir(), "native-coexist-"))
10
+ const sessionID = "native-shared-session"
11
+ const root = path.join(directory, ".opencode", "goals")
12
+ const file = path.join(root, `${createHash("sha256").update(sessionID).digest("hex").slice(0, 32)}.json`)
13
+ const scope = { directory, sessionID }
14
+ let prompts = 0
15
+ const runtime = createNativeLoopRuntime({ directory, prompt: async (request) => { prompts++; return { id: request.id } } })
16
+ try {
17
+ assert.equal(await nativeGoalReservesSession(directory, sessionID), false)
18
+ await mkdir(root, { recursive: true })
19
+ for (const status of ["active", "paused", "waiting_user", "handoff_pending", "handed_off", "budget_limited"]) {
20
+ await writeFile(file, JSON.stringify({ schemaVersion: 1, id: "shared-goal", sessionID, status }))
21
+ assert.equal(await nativeGoalReservesSession(directory, sessionID), true, status)
22
+ }
23
+ await runtime.onEvent({ ...scope, kind: "command", action: "executed", name: "loop", arguments: "0s do work --max-runs 1" })
24
+ await runtime.wake(scope)
25
+ assert.equal(prompts, 0, "Loop must not take over a reserved Goal session")
26
+ await writeFile(file, "invalid json")
27
+ await runtime.wake(scope)
28
+ assert.equal(prompts, 0, "corrupt Goal storage must fail closed")
29
+ await writeFile(file, JSON.stringify({ schemaVersion: 1, id: "shared-goal", sessionID, status: "completed" }))
30
+ assert.equal(await nativeGoalReservesSession(directory, sessionID), false)
31
+ await runtime.wake(scope)
32
+ assert.equal(prompts, 1, "completed Goal can release continuation to an explicitly configured Loop")
33
+ assert.equal(await nativeGoalReservesSession(directory, "another-session"), false)
34
+ console.log("Native Goal/Loop ownership tests passed")
35
+ } finally {
36
+ await runtime.dispose()
37
+ await rm(directory, { recursive: true, force: true, maxRetries: 8, retryDelay: 50 })
38
+ }