@bybrawe/opencode-loop 0.5.37 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- 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 ||
|
|
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("
|
|
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
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
255
|
+
const host = await selectHost(project, options)
|
|
208
256
|
const explicitSessionID = options.session ?? arg("--session")
|
|
209
|
-
let sessionID = explicitSessionID || await resolveLatestSessionID(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
--
|
|
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 (
|
|
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
|
+
}
|