@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
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { createNativeLoopRuntime } from "../src/source/opencode2/native-runtime.js"
6
+ import { normalizeOpenCode2NativeEvent } from "../src/source/opencode2/events.js"
7
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
8
+ import { readState } from "../src/source/core/state.js"
9
+
10
+ let cases = 0
11
+ async function fixture(extra = {}) {
12
+ const directory = await mkdtemp(path.join(tmpdir(), "loop-native-"))
13
+ const scope = { directory, sessionID: "ses-native-test" }
14
+ let clock = 10_000
15
+ const prompts = [], compacts = [], shells = [], commands = [], hooks = [], timers = []
16
+ const runtime = createNativeLoopRuntime({
17
+ directory, now: () => clock,
18
+ prompt: async (request) => { if (request.resume !== false) prompts.push(request); return { id: request.id } },
19
+ compact: async (request) => { compacts.push(request); return { id: request.id } },
20
+ shell: async (request) => { shells.push(request); return { id: request.id, status: "running" } },
21
+ command: async (request) => { commands.push(request) },
22
+ wait: async () => {},
23
+ runShellCommand: async (command) => { hooks.push(command); return { code: 0, stdout: "ok", stderr: "" } },
24
+ setTimer: (fn, delay) => { const timer = { fn, delay }; timers.push(timer); return timer },
25
+ clearTimer: (timer) => { timer.cancelled = true },
26
+ ...extra,
27
+ })
28
+ const event = (kind, action, data = {}) => runtime.onEvent({ ...scope, kind, action, ...data })
29
+ return {
30
+ runtime, scope, directory, prompts, compacts, shells, commands, hooks, timers, event,
31
+ add: (args) => event("command", "executed", { name: "loop", arguments: args }),
32
+ control: (name, args = "") => event("command", "executed", { name, arguments: args }),
33
+ wake: () => runtime.wake(scope),
34
+ state: () => readState(directory, scope.sessionID),
35
+ complete: async (index = prompts.length - 1) => {
36
+ await event("inbox", "delivered", { inboxID: prompts[index].id })
37
+ await event("session", "status", { status: "busy" })
38
+ return event("session", "idle")
39
+ },
40
+ tick: async (ms) => { clock += ms; const timer = timers.find((entry) => !entry.cancelled); assert.ok(timer, "expected a timer"); timer.cancelled = true; return timer.fn() },
41
+ close: async () => { await runtime.dispose(); await rm(directory, { recursive: true, force: true }) },
42
+ }
43
+ }
44
+ async function test(name, run, extra) {
45
+ const f = await fixture(extra)
46
+ try { await run(f); cases++; console.log(`ok ${cases}: ${name}`) }
47
+ finally { await f.close() }
48
+ }
49
+
50
+ await test("one durable prompt under concurrent idle notifications", async (f) => {
51
+ assert.equal((await f.add("0s keep working --max-runs 2")).accepted, true)
52
+ await Promise.all(Array.from({ length: 12 }, () => f.wake()))
53
+ assert.equal(f.prompts.length, 1)
54
+ assert.equal(f.prompts[0].delivery, "queue")
55
+ assert.equal(f.prompts[0].metadata.opencode_loop_v2, true)
56
+ await f.event("session", "idle")
57
+ assert.equal(f.prompts.length, 1, "an unrelated terminal cannot finish an undelivered inbox")
58
+ await f.complete()
59
+ assert.equal(f.prompts.length, 2)
60
+ await f.complete()
61
+ const job = (await f.state()).jobs[0]
62
+ assert.equal(job.runCount, 2)
63
+ assert.equal(job.enabled, false)
64
+ assert.equal(job.v2Run, undefined)
65
+ })
66
+
67
+ for (const order of ["compaction-first", "execution-first"]) {
68
+ await test(`compaction barrier: ${order}`, async (f) => {
69
+ await f.add('0s continue --max-runs 2 --verify "node verify.js"')
70
+ await f.wake()
71
+ await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
72
+ await f.event("compaction", "started")
73
+ await f.event("message", "updated", { role: "user", messageID: "core-marker" })
74
+ const first = order === "compaction-first" ? ["compaction", "ended"] : ["session", "idle"]
75
+ const second = order === "compaction-first" ? ["session", "idle"] : ["compaction", "ended"]
76
+ await f.event(...first)
77
+ assert.equal(f.prompts.length, 1)
78
+ assert.equal(f.hooks.length, 0)
79
+ await f.event(...second)
80
+ assert.equal(f.hooks.length, 1)
81
+ assert.equal(f.prompts.length, 2)
82
+ })
83
+ }
84
+
85
+ await test("rejected or uncertain admission never charges a run or spins", async (f) => {
86
+ await f.add("0s task --max-runs 1")
87
+ await f.wake()
88
+ const job = (await f.state()).jobs[0]
89
+ assert.equal(job.runCount, 0)
90
+ assert.equal(job.enabled, true)
91
+ assert.equal(job.paused, true)
92
+ assert.equal(job.pauseReason, "admission-uncertain")
93
+ assert.ok(job.v2Run.id, "retain the identity of a potentially admitted request")
94
+ await f.event("session", "idle")
95
+ assert.equal((await f.state()).jobs[0].failureCount, 1)
96
+ }, { prompt: async () => { throw new Error("lost response") } })
97
+
98
+ await test("confirmed pending cancellation refunds only the unexecuted run", async (f) => {
99
+ await f.add("0s task --max-runs 1")
100
+ await f.wake()
101
+ await f.control("loop-pause")
102
+ await f.event("inbox", "cancelled", { inboxID: f.prompts[0].id })
103
+ const job = (await f.state()).jobs[0]
104
+ assert.equal(job.runCount, 0)
105
+ assert.equal(job.enabled, true)
106
+ assert.equal(job.paused, true)
107
+ assert.equal(job.v2Run, undefined)
108
+ await f.control("loop-resume")
109
+ await f.wake()
110
+ assert.equal(f.prompts.length, 2)
111
+ })
112
+
113
+ await test("foreground admission suppresses verification and continuation", async (f) => {
114
+ await f.add('0s task --max-runs 1 --verify "node verify.js"')
115
+ await f.wake()
116
+ await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
117
+ await f.event("foreground", "admitted")
118
+ await f.event("session", "idle")
119
+ assert.equal(f.hooks.length, 0)
120
+ assert.equal(f.prompts.length, 1)
121
+ })
122
+
123
+ await test("soft timeout cannot abort native compaction", async (f) => {
124
+ await f.add("0s task --max-runs 2 --timeout 10ms")
125
+ await f.wake()
126
+ await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
127
+ await f.event("compaction", "started")
128
+ await f.tick(11)
129
+ assert.equal((await f.state()).jobs[0].pauseReason, "timeout-waiting-for-host-boundary")
130
+ await f.event("compaction", "ended")
131
+ await f.event("session", "idle")
132
+ assert.equal(f.prompts.length, 1)
133
+ })
134
+
135
+ await test("prompt and include files use the native prompt", async (f) => {
136
+ await writeFile(path.join(f.directory, "instructions.md"), "UNIQUE_NATIVE_INSTRUCTIONS")
137
+ await writeFile(path.join(f.directory, "progress.md"), "UNIQUE_NATIVE_CONTEXT")
138
+ await f.add("0s task --prompt-file instructions.md --include-file progress.md --max-runs 1")
139
+ await f.wake()
140
+ assert.match(f.prompts[0].text, /UNIQUE_NATIVE_INSTRUCTIONS/)
141
+ assert.match(f.prompts[0].text, /UNIQUE_NATIVE_CONTEXT/)
142
+ })
143
+
144
+ await test("watch baseline does not trigger until the file changes", async (f) => {
145
+ await writeFile(path.join(f.directory, "progress.md"), "initial")
146
+ await f.add("--watch progress.md task --max-runs 1")
147
+ await f.wake()
148
+ assert.equal(f.prompts.length, 0)
149
+ await writeFile(path.join(f.directory, "progress.md"), "changed and longer")
150
+ await f.tick(1000)
151
+ assert.equal(f.prompts.length, 1)
152
+ })
153
+
154
+ await test("control state is not --until completion evidence", async (f) => {
155
+ await f.add("0s task --until UNIQUE_COMPLETION_MARKER --max-runs 1")
156
+ await f.wake()
157
+ assert.equal(f.prompts.length, 1)
158
+ })
159
+
160
+ await test("stop-file is enforced before a future interval is due", async (f) => {
161
+ await f.add("1h task --no-now --stop-file STOP")
162
+ await writeFile(path.join(f.directory, "STOP"), "stop")
163
+ await f.tick(1000)
164
+ const job = (await f.state()).jobs[0]
165
+ assert.equal(job.enabled, false)
166
+ assert.equal(job.pauseReason, "stop-file")
167
+ assert.equal(f.prompts.length, 0)
168
+ })
169
+
170
+ await test("maximum runtime is enforced without waiting for the next interval", async (f) => {
171
+ await f.add("1h task --no-now --max-runtime 5s")
172
+ await f.tick(5001)
173
+ assert.equal((await f.state()).jobs[0].pauseReason, "max-runtime")
174
+ assert.equal(f.prompts.length, 0)
175
+ })
176
+
177
+ await test("preflight failure does not consume a run", async (f) => {
178
+ await f.add('0s task --preflight "node check.js" --max-runs 1')
179
+ await f.wake()
180
+ const job = (await f.state()).jobs[0]
181
+ assert.equal(job.runCount, 0)
182
+ assert.equal(job.pauseReason, "preflight-failed")
183
+ assert.equal(f.prompts.length, 0)
184
+ }, { runShellCommand: async () => ({ code: 3, stdout: "", stderr: "not ready" }) })
185
+
186
+ await test("safe guard covers verification commands too", async (f) => {
187
+ await f.add('0s task --safe --verify "git reset --hard" --max-runs 1')
188
+ await f.wake()
189
+ await f.complete()
190
+ assert.equal(f.hooks.length, 0)
191
+ assert.equal((await f.state()).jobs[0].pauseReason, "verification-failed")
192
+ })
193
+
194
+ await test("dry-run never invokes preflight, shell, or the native model", async (f) => {
195
+ await f.add('0s --shell echo preview --dry-run --preflight "echo preflight" --git-checkpoint')
196
+ const out = await f.wake()
197
+ assert.equal(out.dryRun, true)
198
+ assert.equal(f.prompts.length + f.shells.length + f.hooks.length, 0)
199
+ assert.equal((await f.state()).jobs[0].runCount, 0)
200
+ })
201
+
202
+ await test("compaction cadence does not charge a logical run", async (f) => {
203
+ await f.add("0s task --max-runs 2 --compact-every 1")
204
+ await f.wake()
205
+ await f.complete()
206
+ assert.equal(f.compacts.length, 1)
207
+ assert.equal((await f.state()).jobs[0].runCount, 1)
208
+ await f.event("compaction", "started")
209
+ await f.event("compaction", "ended")
210
+ await f.event("session", "idle")
211
+ assert.equal(f.prompts.length, 2)
212
+ assert.equal((await f.state()).jobs[0].runCount, 2)
213
+ })
214
+
215
+ await test("a failed delivered execution is paused and can be explicitly resumed", async (f) => {
216
+ await f.add("0s task --max-runs 3")
217
+ await f.wake()
218
+ await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
219
+ await f.event("session", "error", { reason: "execution-failed" })
220
+ assert.equal((await f.state()).jobs[0].paused, true)
221
+ assert.equal((await f.state()).jobs[0].v2Run, undefined)
222
+ await f.control("loop-resume")
223
+ await f.wake()
224
+ assert.equal(f.prompts.length, 2)
225
+ })
226
+
227
+ await test("branch failure cannot run on the wrong branch", async (f) => {
228
+ await f.add("0s task --branch native-loop-test --max-runs 1")
229
+ await f.wake()
230
+ assert.equal(f.prompts.length, 0)
231
+ assert.equal((await f.state()).jobs[0].pauseReason, "branch-unavailable")
232
+ })
233
+
234
+ {
235
+ let resolveBuild, started
236
+ const building = new Promise((resolve) => { started = resolve })
237
+ const gate = new Promise((resolve) => { resolveBuild = resolve })
238
+ await test("foreground admission during file preparation prevents dispatch", async (f) => {
239
+ await f.add("0s task --max-runs 1")
240
+ const pending = f.wake()
241
+ await building
242
+ await f.event("foreground", "admitted")
243
+ resolveBuild()
244
+ await pending
245
+ assert.equal(f.prompts.length, 0)
246
+ assert.equal((await f.state()).jobs[0].runCount, 0)
247
+ }, { workspace: { untilReached: async () => false, buildPrompt: async () => { started(); await gate; return "prepared" }, createCheckpoint: async () => {} } })
248
+ }
249
+
250
+ {
251
+ const raw = { id: "event-native-duplicate", type: "session.inbox.delivered", location: { directory: "/workspace" }, data: { sessionID: "ses-native", inboxID: "msg-native" } }
252
+ assert.equal(normalizeOpenCode2NativeEvent(raw).inboxID, "msg-native")
253
+ let count = 0
254
+ const bridge = createOpenCode2EventBridge({ directory: "/workspace", onEvent: async () => { count++ } })
255
+ await bridge.dispatch(raw)
256
+ await bridge.dispatch(raw)
257
+ assert.equal(count, 1)
258
+ await bridge.dispose()
259
+ cases++
260
+ console.log(`ok ${cases}: duplicate native event IDs are processed once`)
261
+ }
262
+ console.log(`Native OpenCode 2 safety: ${cases} scenarios passed`)
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+
4
+ function versionFromOutput(output) {
5
+ for (const line of String(output || "").split(/\r?\n/)) {
6
+ const match = line.trim().match(/^(?:opencode(?:2)?\s+v?)?v?(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
7
+ if (match) return { major: Number(match[1]), version: line.trim() }
8
+ }
9
+ return undefined
10
+ }
11
+
12
+ // Probe once before any workload. An explicit binary never falls back to a
13
+ // different installation, and native mode never silently selects a V1 host.
14
+ export async function selectOpenCodeHost({ binary, legacyV1 = false, probe } = {}) {
15
+ if (typeof probe !== "function") throw new TypeError("OpenCode host selection requires a version probe")
16
+ const major = legacyV1 ? 1 : 2
17
+ const explicit = String(binary || "").trim()
18
+ const candidates = explicit ? [explicit] : legacyV1 ? ["opencode"] : ["opencode", "opencode2"]
19
+ for (const candidate of candidates) {
20
+ const result = await probe(candidate)
21
+ if (result?.code !== 0 || result.timedOut) continue
22
+ const observed = versionFromOutput(`${result.stdout || ""}\n${result.stderr || ""}`)
23
+ if (observed?.major === major) return { binary: candidate, ...observed }
24
+ }
25
+ throw new Error(`Could not select OpenCode ${major}.x${explicit ? ` from ${JSON.stringify(explicit)}` : ""}. Set OPENCODE_BIN to the matching executable.${legacyV1 ? "" : " V1 requires explicit --legacy-v1; no workload was dispatched."}`)
26
+ }
27
+
28
+ function canonicalDirectory(directory) {
29
+ const absolute = path.resolve(directory)
30
+ try { return fs.realpathSync.native(absolute) } catch { return absolute }
31
+ }
32
+
33
+ export function projectSessions(text, directory, native = true) {
34
+ let parsed
35
+ try { parsed = JSON.parse(String(text || "")) } catch { throw new Error("OpenCode session list returned invalid JSON") }
36
+ const sessions = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.data) ? parsed.data : undefined
37
+ if (!sessions) throw new Error("OpenCode session list did not return an array")
38
+ const project = canonicalDirectory(directory)
39
+ return sessions.filter((session) => {
40
+ if (typeof session?.id !== "string" || !session.id.trim()) return false
41
+ const location = session.directory ?? session.location?.directory
42
+ // V2 session list is project-scoped, not worktree-scoped. Never let its
43
+ // newest session pull a daemon into another worktree or an unknown path.
44
+ if (typeof location !== "string" || !location.trim()) return !native
45
+ return canonicalDirectory(location) === project
46
+ })
47
+ }
@@ -11,7 +11,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"
11
11
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
12
  const LOOP_OBJECTIVE = "real OpenCode 2 loop canary"
13
13
  const EXPECTED_TURNS = 2
14
- const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"]
14
+ const NATIVE_PRESETS = ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"]
15
+ const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear", ...(process.env.OPENCODE2_NATIVE_REQUIRED === "1" ? [...NATIVE_PRESETS, "loop-init"] : [])]
15
16
  const SERVER_USERNAME = "opencode"
16
17
  const SERVER_PASSWORD = "opencode-loop-v2-canary"
17
18
  const OPENCODE_BINARY = process.env.OPENCODE2_BINARY || "opencode2"
@@ -181,7 +182,7 @@ export default {
181
182
  return {
182
183
  async *[Symbol.asyncIterator]() {
183
184
  for await (const event of source) {
184
- const line = JSON.stringify(event) + "\\n"
185
+ const line = JSON.stringify(String(event?.type || "").startsWith("session.") ? event : { id: event?.id, type: event?.type }) + "\\n"
185
186
  if (traceFile) await appendFile(traceFile, line, "utf8")
186
187
  if (pluginTraceFile) await appendFile(pluginTraceFile, line, "utf8")
187
188
  yield event
@@ -192,10 +193,13 @@ export default {
192
193
  },
193
194
  }
194
195
  : ctx
196
+ const goalURL = process.env.OPENCODE_GOAL_V2_PLUGIN_URL
197
+ const goalCleanup = goalURL ? await (await import(goalURL)).default.setup(ctx) : undefined
195
198
  const cleanup = await module.default.setup(pluginContext)
196
199
  await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
197
200
  return async () => {
198
201
  if (typeof cleanup === "function") await cleanup()
202
+ if (typeof goalCleanup === "function") await goalCleanup()
199
203
  }
200
204
  },
201
205
  }
@@ -252,6 +256,7 @@ async function main() {
252
256
  },
253
257
  }, null, 2)}\n`)
254
258
 
259
+ await writeFile(path.join(workspace, ".gitignore"), ".home/\n.opencode/\n*v2*events.jsonl\n")
255
260
  execFileSync("git", ["init", "--quiet", workspace], { stdio: "ignore" })
256
261
  execFileSync("git", ["-C", workspace, "config", "user.email", "opencode-loop-ci@example.invalid"], { stdio: "ignore" })
257
262
  execFileSync("git", ["-C", workspace, "config", "user.name", "OpenCode Loop CI"], { stdio: "ignore" })
@@ -266,7 +271,7 @@ async function main() {
266
271
  XDG_DATA_HOME: path.join(home, ".local", "share"),
267
272
  XDG_STATE_HOME: path.join(home, ".local", "state"),
268
273
  XDG_CACHE_HOME: path.join(home, ".cache"),
269
- OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
274
+ OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", ...(process.env.OPENCODE2_NATIVE_REQUIRED === "1" ? ["native.js"] : ["source", "opencode2", "experimental.js"]))).href,
270
275
  OPENCODE_LOOP_V2_MARKER: marker,
271
276
  OPENCODE_LOOP_V2_EVENT_TRACE: eventTrace,
272
277
  OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE: pluginEventTrace,
@@ -364,6 +369,8 @@ async function main() {
364
369
  return EXPECTED_COMMANDS.every((name) => latestCommands.has(name))
365
370
  }, "plugin-registered Loop commands to enter the real V2 registry", diagnostics, 30_000)
366
371
 
372
+ if (process.env.OPENCODE_GOAL_V2_PLUGIN_URL) assert.ok(latestCommands.has("goal"), "both native plugin command registries must load")
373
+
367
374
  const createdResponse = await request(`${apiPrefix}/session`, {
368
375
  method: "POST",
369
376
  body: JSON.stringify({ title: "OpenCode 2 Loop canary" }),
@@ -424,7 +431,7 @@ async function main() {
424
431
  await sendControl("loop-resume", "default")
425
432
  await waitFor(async () => {
426
433
  const job = await jobNamed("default")
427
- return job?.paused === false && job?.lastRunAt === 0
434
+ return job?.paused === false && (job?.lastRunAt === 0 || job?.runNowRequestedAt > 0)
428
435
  }, "real V2 loop-resume state mutation", diagnostics, 30_000)
429
436
 
430
437
  await sendControl("loop-stop", "default")
@@ -450,6 +457,67 @@ async function main() {
450
457
  assert.equal(provider.stats.loopRequests, EXPECTED_TURNS + 2, `control lifecycle should add exactly two autonomous Loop turns\n${await diagnostics()}`)
451
458
  assert.equal(server.exitCode, null, `OpenCode 2 server exited during control lifecycle canary\n${await diagnostics()}`)
452
459
 
460
+ if (process.env.OPENCODE2_NATIVE_REQUIRED === "1") {
461
+ const nativeLog = await readFile(path.join(workspace, ".opencode/opencode-loop/loop.log"), "utf8")
462
+ assert.match(nativeLog, /v2-native-ready/, "the real host must load the native implementation")
463
+ assert.match(nativeLog, /v2-native-admitted/, "the native implementation must own the admissions")
464
+ await writeFile(path.join(workspace, "native-preflight.cjs"), 'require("node:fs").appendFileSync("native-preflight.txt", "P")')
465
+ await writeFile(path.join(workspace, "native-verify.cjs"), 'require("node:fs").appendFileSync("native-verify.txt", "V")')
466
+ await sendControl("loop", '0s native verified task --max-runs 1 --name verified --preflight "node native-preflight.cjs" --verify "node native-verify.cjs"')
467
+ await waitFor(async () => {
468
+ const job = await jobNamed("verified")
469
+ return job?.runCount === 1 && !job.v2Run && job.lastVerifyCode === 0
470
+ }, "native preflight and post-turn verification", diagnostics, 30_000)
471
+ assert.equal(await readFile(path.join(workspace, "native-preflight.txt"), "utf8"), "P")
472
+ assert.equal(await readFile(path.join(workspace, "native-verify.txt"), "utf8"), "V")
473
+ await sendControl("loop-clear")
474
+ await sendControl("loop-shell", '0s node native-preflight.cjs --max-runs 1 --name native-shell')
475
+ await waitFor(async () => {
476
+ const job = await jobNamed("native-shell")
477
+ return job?.runCount === 1 && !job.v2Run
478
+ }, "native shell terminal correlation", diagnostics, 20_000)
479
+ assert.equal(await readFile(path.join(workspace, "native-preflight.txt"), "utf8"), "PP")
480
+ await sendControl("loop-clear")
481
+ const beforePresets = provider.stats.loopRequests
482
+ await sendControl("loop-init")
483
+ assert.match(await readFile(path.join(workspace, "progress.md"), "utf8"), /^# Progress\n/)
484
+ await writeFile(path.join(workspace, "progress.md"), "user-owned progress\n")
485
+ await sendControl("loop-init")
486
+ assert.equal(await readFile(path.join(workspace, "progress.md"), "utf8"), "user-owned progress\n")
487
+ for (const name of NATIVE_PRESETS.filter(name => name !== "loop-compact")) {
488
+ const action = ["loop-command", "loop-cmd"].includes(name) ? "/review project"
489
+ : name === "loop-shell" ? "npm test"
490
+ : ["loop-prompt", "loop-ask"].includes(name) ? "did you run tests?" : ""
491
+ await sendControl(name, `every 1h --dry-run --name ${name} ${action}`.trim())
492
+ const saved = await jobNamed(name)
493
+ assert.ok(saved, `${name} must create a native job`)
494
+ assert.equal(saved.intervalMs, 3600000, `${name}: explicit every 1h must survive native registration` )
495
+ assert.equal(saved.immediate, false)
496
+ assert.equal(saved.runCount, 0)
497
+ assert.equal(saved.dryRun, true)
498
+ }
499
+
500
+ await sendControl("loop", "after 1h --now --max-runs 9 --dry-run --name native-once continue once")
501
+ const onceJob = await jobNamed("native-once")
502
+ assert.equal(onceJob.intervalMs, 3600000)
503
+ assert.equal(onceJob.maxRuns, 1, "after remains one-shot even with a conflicting max-runs flag")
504
+ assert.equal(onceJob.immediate, false, "after never dispatches immediately")
505
+ assert.equal(onceJob.action, "continue once")
506
+ await sendControl("loop-testfix", "every 1h --dry-run --name custom-testfix npm run check")
507
+ const testfix = await jobNamed("custom-testfix")
508
+ assert.equal(testfix.verifyCommand, "npm run check")
509
+ assert.match(testfix.action, /Test command hint: npm run check$/)
510
+ await sendControl("loop", "continue the project --dry-run --name native-shorthand")
511
+ const shorthand = await jobNamed("native-shorthand")
512
+ assert.equal(shorthand.intervalMs, 0)
513
+ assert.equal(shorthand.action, "continue the project")
514
+ assert.equal(shorthand.pauseReason, "dry-run")
515
+
516
+ assert.equal(provider.stats.loopRequests, beforePresets, "deferred presets and progress initialization cannot trigger model work")
517
+ await sendControl("loop-clear")
518
+
519
+ }
520
+
453
521
  console.log(JSON.stringify({
454
522
  ok: true,
455
523
  apiPrefix,
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawnSync } from "node:child_process"
3
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"
4
+ import { tmpdir } 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 npmCLI = process.env.npm_execpath
10
+ assert.ok(npmCLI, "run this through npm run package:smoke")
11
+ function run(command, args, cwd) {
12
+ const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: 180_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true })
13
+ if (result.error) throw result.error
14
+ assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n"))
15
+ return result.stdout
16
+ }
17
+ const temp = await mkdtemp(path.join(tmpdir(), "loop native package "))
18
+ try {
19
+ const packed = JSON.parse(run(process.execPath, [npmCLI, "pack", root, "--json", "--ignore-scripts"], temp))
20
+ assert.equal(packed.length, 1)
21
+ const files = new Set(packed[0].files.map((entry) => entry.path))
22
+ for (const entry of ["src/server.js", "src/native.js", "src/v1.js", "src/index.js"]) assert.ok(files.has(entry), `missing ${entry}`)
23
+ const consumer = path.join(temp, "consumer with spaces")
24
+ await mkdir(consumer)
25
+ await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
26
+ run(process.execPath, [npmCLI, "install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund", path.join(temp, packed[0].filename)], consumer)
27
+ console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
28
+ console.log("Production tarball native entry isolation and lazy legacy delegation PASS")
29
+ } finally {
30
+ await rm(temp, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 })
31
+ }
@@ -2,29 +2,70 @@ import assert from "node:assert/strict"
2
2
  import { readFile } from "node:fs/promises"
3
3
  import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
+ import { validateRelease, releaseDecision, assertPublishedSource, waitForPublishedGoal } from "./native-release.mjs"
5
6
 
6
7
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
7
- const workflowPath = path.join(repoRoot, ".github", "workflows", "publish-npm.yml")
8
- const workflow = await readFile(workflowPath, "utf8")
9
-
10
- assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "publish workflow must checkout the release trigger SHA")
11
- assert.doesNotMatch(workflow, /ref:\s*main\b/, "publish workflow must not checkout a moving main branch")
12
- assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/, "release source must be verified against main history")
13
- assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/, "release tag must target the immutable trigger SHA")
14
- assert.match(workflow, /group:\s*npm-release/, "npm publication must be serialized")
15
- assert.match(workflow, /cancel-in-progress:\s*false/, "a newer release must not cancel an in-flight publication")
16
-
17
- assert.match(workflow, /Decide whether npm publish is needed/, "release reruns must detect an already-published immutable version")
18
- assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/, "npm publish must be idempotently gated")
19
- assert.match(workflow, /for attempt in \{1\.\.60\}/, "registry verification must allow bounded multi-minute propagation")
20
- assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/, "registry checks must target the authoritative npm registry")
8
+ const workflow = await readFile(path.join(repoRoot, ".github/workflows/publish-npm.yml"), "utf8")
9
+ assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "checkout must use the immutable trigger SHA")
10
+ assert.doesNotMatch(workflow, /ref:\s*main\b/, "publication cannot checkout moving main")
11
+ assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/)
12
+ assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/)
13
+ assert.match(workflow, /group:\s*npm-release/)
14
+ assert.match(workflow, /cancel-in-progress:\s*false/)
15
+ assert.match(workflow, /branches:\s*\[main\]/)
16
+ assert.match(workflow, /paths:\s*\[\.github\/npm-release\.json\]/)
17
+ assert.doesNotMatch(workflow, /release\/npm-v|git push|Remove one-shot release branch/, "main-only publication must not create or delete branches")
18
+ assert.match(workflow, /needs:\s*\[native-regressions, real-native-pair\]/)
19
+ assert.match(workflow, /node scripts\/native-release\.mjs ci/)
20
+ assert.match(workflow, /git diff --exit-code -- src\/index.js src\/server.js src\/native.js/)
21
+ assert.match(workflow, /npm run package:smoke/)
22
+ assert.match(workflow, /windows-latest/)
23
+ assert.match(workflow, /Decide whether npm publish is needed/)
24
+ assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
25
+ assert.match(workflow, /for attempt in \{1\.\.60\}/)
26
+ assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/)
27
+ assert.match(workflow, /native-release\.mjs verify/)
28
+ assert.match(workflow, /node_modules\/@bybrawe\/opencode-loop\/scripts\/opencode2-loop-canary.mjs/)
29
+ assert.match(workflow, /node_modules\/@bybrawe\/opencode-goal\/dist\/server.js/)
30
+ assert.match(workflow, /ref: \$\{\{ steps.release.outputs.goal_sha \}\}/)
31
+ assert.doesNotMatch(workflow, /secrets\.NPM_TOKEN/)
21
32
 
22
33
  const publishIndex = workflow.indexOf("npm publish --access public")
23
34
  const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
24
35
  const tagIndex = workflow.indexOf("Create tag and GitHub release")
25
- const cleanupIndex = workflow.indexOf("Remove one-shot release branch")
26
- assert.ok(publishIndex >= 0 && verifyIndex > publishIndex, "npm publication must be verified after publish")
27
- assert.ok(tagIndex > verifyIndex, "tag/release creation must happen only after npm verification")
28
- assert.ok(cleanupIndex > tagIndex, "release branch cleanup must be the final release step")
36
+ assert.ok(publishIndex >= 0 && verifyIndex > publishIndex)
37
+ assert.ok(tagIndex > verifyIndex)
38
+ assert.match(workflow.slice(tagIndex), /needs:\s*publish/, "GitHub release must depend on verified publication")
39
+ for (const block of workflow.split(/- uses: actions\/checkout@/).slice(1)) assert.match(block.split(/\n\s*- (?:uses|name|run):/)[0], /persist-credentials:\s*false/)
40
+ const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.indexOf(" github-release:\n"))
41
+ assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
42
+
43
+ const sha = "a".repeat(40)
44
+ const request = { name: "@bybrawe/opencode-loop", version: "0.6.0", predecessor: "0.5.38", goal: { version: "1.3.39", sha: "b".repeat(40) } }
45
+ const pkg = { name: request.name, version: request.version }
46
+ const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
47
+ assert.strictEqual(validateRelease(request, pkg, env), request)
48
+ for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
49
+ assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
50
+ assert.throws(() => validateRelease(request, { ...pkg, version: "0.5.38" }, env))
51
+ assert.equal(releaseDecision(404, null, request, sha, "0.5.38"), true)
52
+ for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.5.38"))
53
+ assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
54
+ const manifest = { ...pkg, gitHead: sha }
55
+ assert.equal(releaseDecision(200, manifest, request, sha), false)
56
+ assert.throws(() => releaseDecision(200, { ...manifest, gitHead: "b".repeat(40) }, request, sha))
57
+ assert.throws(() => assertPublishedSource({ ...manifest, gitHead: undefined }, pkg.name, pkg.version, sha))
29
58
 
30
- console.log("publish workflow tests passed")
59
+ let calls = 0, sleeps = 0
60
+ const goalManifest = { name: "@bybrawe/opencode-goal", version: request.goal.version, gitHead: request.goal.sha }
61
+ const recovered = await waitForPublishedGoal(request.goal, {
62
+ attempts: 2, sleep: async () => { sleeps++ },
63
+ request: async () => ++calls === 1 ? { ok: false, status: 404 } : { ok: true, json: async () => goalManifest },
64
+ })
65
+ assert.deepEqual(recovered, goalManifest)
66
+ assert.equal(calls, 2)
67
+ assert.equal(sleeps, 1)
68
+ await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: false, status: 404 }) }), /not published/)
69
+ for (const status of [403, 429, 503]) await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: false, status }) }), /transport\/auth/)
70
+ await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: true, json: async () => ({ ...goalManifest, gitHead: sha }) }) }), /different source/)
71
+ console.log("main-only publish workflow, source identity and registry failure regressions passed")