@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +31 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -8
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +19 -10
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +105 -58
  13. package/scripts/install-test.mjs +6 -1
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +107 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/network-recovery-test.mjs +38 -1
  36. package/scripts/opencode-host.mjs +47 -0
  37. package/scripts/opencode2-loop-canary.mjs +119 -10
  38. package/scripts/package-native-smoke.mjs +31 -0
  39. package/scripts/publish-workflow-test.mjs +60 -14
  40. package/scripts/pulse-check.mjs +246 -0
  41. package/scripts/run-finalization-test.mjs +33 -0
  42. package/scripts/server-entry-test.mjs +15 -0
  43. package/scripts/session-error-recovery-test.mjs +186 -0
  44. package/scripts/session-status-test.mjs +42 -0
  45. package/scripts/v2-command-adapter-test.mjs +14 -2
  46. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  47. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  48. package/src/index.js +227 -53
  49. package/src/native.js +3135 -0
  50. package/src/server.js +4055 -0
  51. package/src/source/core/continuation.js +23 -0
  52. package/src/source/core/jobs.js +17 -0
  53. package/src/source/core/process.js +6 -4
  54. package/src/source/core/progress.js +58 -0
  55. package/src/source/legacy-v1.js +14 -3
  56. package/src/source/native.js +1 -0
  57. package/src/source/opencode/host.js +1 -0
  58. package/src/source/opencode/loop-commands.js +1 -27
  59. package/src/source/opencode/loop-registration.js +2 -12
  60. package/src/source/opencode/messages.js +1 -1
  61. package/src/source/opencode2/capabilities.js +1 -0
  62. package/src/source/opencode2/commands.js +52 -11
  63. package/src/source/opencode2/event-bridge.js +15 -3
  64. package/src/source/opencode2/events.js +31 -0
  65. package/src/source/opencode2/experimental.js +53 -7
  66. package/src/source/opencode2/native-companion.js +24 -0
  67. package/src/source/opencode2/native-plugin.js +153 -0
  68. package/src/source/opencode2/native-policy.js +106 -0
  69. package/src/source/opencode2/native-runtime.js +464 -0
  70. package/src/source/opencode2/native-shell.js +81 -0
  71. package/src/source/opencode2/prompt-runtime.js +38 -6
  72. package/src/source/opencode2/runtime-adapter.js +15 -2
  73. package/src/source/opencode2/session-location.js +16 -0
  74. package/src/source/runtime/executor.js +83 -0
  75. package/src/source/runtime/goal-steering.js +17 -3
  76. package/src/source/runtime/job-workspace.js +11 -2
  77. package/src/source/runtime/run-finalization.js +10 -0
  78. package/src/source/runtime/session-status.js +16 -2
  79. package/src/source/runtime/terminal-guard.js +32 -7
  80. package/src/source/server.js +26 -0
  81. 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`)
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict"
2
- import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
2
+ import { isCompletionBoundedContinuation, isTerminalNoWorkReply, isWaitingUserReply } from "../src/source/core/continuation.js"
3
3
  import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
4
4
  import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
5
5
  import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
@@ -90,6 +90,10 @@ assert.equal(isCompletionBoundedContinuation("devam et"), false)
90
90
  assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
91
91
  assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
92
92
  assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
93
+ assert.equal(isWaitingUserReply("Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi."), true)
94
+ assert.equal(isWaitingUserReply("Waiting for your approval before I can push the release."), true)
95
+ assert.equal(isWaitingUserReply("Onay gerekmiyor; sıradaki iş build doğrulaması."), false)
96
+ assert.equal(isWaitingUserReply("Deployment needs your approval, but next step is local verification."), false)
93
97
 
94
98
  {
95
99
  const terminalMessages = [{
@@ -122,6 +126,39 @@ assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build do
122
126
  const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
123
127
  assert.equal(untouched.terminal, false)
124
128
  assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
129
+
130
+ const waitingMessages = [{
131
+ info: { role: "assistant", time: { created: 2_200, completed: 2_300 } },
132
+ parts: [{ type: "text", text: "Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi." }],
133
+ }]
134
+ const waitingClient = { session: { messages: async () => ({ data: waitingMessages }) } }
135
+ const waiting = {
136
+ id: "waiting",
137
+ action: "devam et",
138
+ scheduleMode: "idle",
139
+ lastRunAt: 1_500,
140
+ paused: false,
141
+ }
142
+ let waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waiting)
143
+ assert.equal(waitingResult.waitingUser, true)
144
+ assert.equal(waitingResult.waitingUserPausedNow, false)
145
+ assert.equal(waitingResult.job.waitingUserCount, 1)
146
+ assert.equal(waitingResult.job.paused, false)
147
+ waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waitingResult.job)
148
+ assert.equal(waitingResult.waitingUserPausedNow, true, "two explicit user-dependency replies should pause plain continuation")
149
+ assert.equal(waitingResult.job.paused, true)
150
+ assert.equal(waitingResult.job.lastFailureReason, "waiting_user")
151
+
152
+ const productiveMessages = [{
153
+ info: { role: "assistant", time: { created: 2_400, completed: 2_500 } },
154
+ parts: [{ type: "text", text: "Deployment needs your approval, but next step is local verification." }],
155
+ }]
156
+ const productiveClient = { session: { messages: async () => ({ data: productiveMessages }) } }
157
+ const productive = { ...waitingResult.job, paused: false, waitingUserCount: 1 }
158
+ const productiveResult = await applyTerminalContinuationGuard("/repo", productiveClient, "s", productive)
159
+ assert.equal(productiveResult.waitingUser, false)
160
+ assert.equal(productiveResult.job.waitingUserCount, 0, "a reply with autonomous next work must clear waiting-user streak")
161
+ assert.equal(productiveResult.job.paused, false)
125
162
  }
126
163
 
127
164
  {
@@ -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
+ }
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
2
2
  import { createServer } from "node:http"
3
3
  import net from "node:net"
4
4
  import { execFileSync, spawn } from "node:child_process"
5
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
5
+ import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
6
6
  import os from "node:os"
7
7
  import path from "node:path"
8
8
  import process from "node:process"
@@ -11,9 +11,12 @@ 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"
18
+ const OPENCODE_BINARY = process.env.OPENCODE2_BINARY || "opencode2"
19
+ const CURRENT_COMMAND_FIELDS = process.env.OPENCODE2_CURRENT_COMMAND_FIELDS === "1"
17
20
 
18
21
  function appendLog(current, chunk, limit = 100_000) {
19
22
  return (current + String(chunk)).slice(-limit)
@@ -161,16 +164,42 @@ function startProvider() {
161
164
  }
162
165
 
163
166
  function bridgePluginSource() {
164
- return `import { writeFile } from "node:fs/promises"
167
+ return `import { appendFile, writeFile } from "node:fs/promises"
165
168
 
166
169
  export default {
167
170
  id: "bybrawe.opencode-loop.v2.loop-canary-bridge",
168
171
  async setup(ctx) {
169
172
  const module = await import(process.env.OPENCODE_LOOP_V2_PLUGIN_URL)
170
- const cleanup = await module.default.setup(ctx)
173
+ const traceFile = process.env.OPENCODE_LOOP_V2_EVENT_TRACE
174
+ const pluginTraceFile = process.env.OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE
175
+ const pluginContext = typeof ctx?.event?.subscribe === "function"
176
+ ? {
177
+ ...ctx,
178
+ event: {
179
+ ...ctx.event,
180
+ subscribe(options) {
181
+ const source = ctx.event.subscribe(options)
182
+ return {
183
+ async *[Symbol.asyncIterator]() {
184
+ for await (const event of source) {
185
+ const line = JSON.stringify(String(event?.type || "").startsWith("session.") ? event : { id: event?.id, type: event?.type }) + "\\n"
186
+ if (traceFile) await appendFile(traceFile, line, "utf8")
187
+ if (pluginTraceFile) await appendFile(pluginTraceFile, line, "utf8")
188
+ yield event
189
+ }
190
+ },
191
+ }
192
+ },
193
+ },
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
198
+ const cleanup = await module.default.setup(pluginContext)
171
199
  await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
172
200
  return async () => {
173
201
  if (typeof cleanup === "function") await cleanup()
202
+ if (typeof goalCleanup === "function") await goalCleanup()
174
203
  }
175
204
  },
176
205
  }
@@ -187,8 +216,10 @@ async function main() {
187
216
 
188
217
  const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
189
218
  const home = path.join(workspace, ".home")
190
- const pluginDir = path.join(workspace, ".opencode", "plugins")
219
+ const pluginDir = path.join(home, ".config", "opencode", "plugins")
191
220
  const marker = path.join(workspace, "v2-real-adapter-marker.json")
221
+ const eventTrace = path.join(workspace, "v2-native-events.jsonl")
222
+ const pluginEventTrace = path.join(workspace, "v2-plugin-events.jsonl")
192
223
  const provider = startProvider()
193
224
  const providerPort = await provider.listen()
194
225
  let server
@@ -225,6 +256,7 @@ async function main() {
225
256
  },
226
257
  }, null, 2)}\n`)
227
258
 
259
+ await writeFile(path.join(workspace, ".gitignore"), ".home/\n.opencode/\n*v2*events.jsonl\n")
228
260
  execFileSync("git", ["init", "--quiet", workspace], { stdio: "ignore" })
229
261
  execFileSync("git", ["-C", workspace, "config", "user.email", "opencode-loop-ci@example.invalid"], { stdio: "ignore" })
230
262
  execFileSync("git", ["-C", workspace, "config", "user.name", "OpenCode Loop CI"], { stdio: "ignore" })
@@ -239,8 +271,10 @@ async function main() {
239
271
  XDG_DATA_HOME: path.join(home, ".local", "share"),
240
272
  XDG_STATE_HOME: path.join(home, ".local", "state"),
241
273
  XDG_CACHE_HOME: path.join(home, ".cache"),
242
- 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,
243
275
  OPENCODE_LOOP_V2_MARKER: marker,
276
+ OPENCODE_LOOP_V2_EVENT_TRACE: eventTrace,
277
+ OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE: pluginEventTrace,
244
278
  OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
245
279
  OPENCODE_SERVER_PASSWORD: SERVER_PASSWORD,
246
280
  OPENCODE_DISABLE_AUTOUPDATE: "true",
@@ -253,6 +287,10 @@ async function main() {
253
287
  if (sessionID) {
254
288
  try { state = await readFile(path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8") } catch {}
255
289
  }
290
+ let nativeEvents = "missing"
291
+ let pluginEvents = "missing"
292
+ try { nativeEvents = await readFile(eventTrace, "utf8") } catch {}
293
+ try { pluginEvents = await readFile(pluginEventTrace, "utf8") } catch {}
256
294
  return [
257
295
  `apiPrefix=${String(apiPrefix)}`,
258
296
  `commands=${JSON.stringify([...latestCommands])}`,
@@ -260,14 +298,18 @@ async function main() {
260
298
  `sessionID=${sessionID || "none"}`,
261
299
  `commandError=${String(commandError ?? "none")}`,
262
300
  `state=${state}`,
301
+ `binary=${OPENCODE_BINARY}`,
302
+ `currentCommandFields=${CURRENT_COMMAND_FIELDS}`,
263
303
  `serverExit=${server?.exitCode}`,
304
+ `nativeEvents=${nativeEvents.slice(-40_000)}`,
305
+ `pluginEvents=${pluginEvents.slice(-40_000)}`,
264
306
  `serverLog=${serverLog}`,
265
307
  ].join("\n")
266
308
  }
267
309
 
268
310
  try {
269
311
  const port = await reservePort()
270
- server = spawn("opencode2", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
312
+ server = spawn(OPENCODE_BINARY, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
271
313
  cwd: workspace,
272
314
  env,
273
315
  windowsHide: true,
@@ -327,6 +369,8 @@ async function main() {
327
369
  return EXPECTED_COMMANDS.every((name) => latestCommands.has(name))
328
370
  }, "plugin-registered Loop commands to enter the real V2 registry", diagnostics, 30_000)
329
371
 
372
+ if (process.env.OPENCODE_GOAL_V2_PLUGIN_URL) assert.ok(latestCommands.has("goal"), "both native plugin command registries must load")
373
+
330
374
  const createdResponse = await request(`${apiPrefix}/session`, {
331
375
  method: "POST",
332
376
  body: JSON.stringify({ title: "OpenCode 2 Loop canary" }),
@@ -336,9 +380,13 @@ async function main() {
336
380
  sessionID = String(session?.id ?? "")
337
381
  assert.ok(sessionID, `OpenCode 2 did not create a session: ${createdResponse.text}\n${await diagnostics()}`)
338
382
 
383
+ const commandBody = (command, argumentsText = "") => CURRENT_COMMAND_FIELDS
384
+ ? { name: command, text: argumentsText }
385
+ : { command, arguments: argumentsText }
386
+
339
387
  const commandPromise = request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
340
388
  method: "POST",
341
- body: JSON.stringify({ command: "loop", arguments: `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}` }),
389
+ body: JSON.stringify(commandBody("loop", `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}`)),
342
390
  }, 120_000).catch((error) => {
343
391
  commandError = error
344
392
  return null
@@ -371,7 +419,7 @@ async function main() {
371
419
  const sendControl = async (command, argumentsText = "") => {
372
420
  const response = await request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
373
421
  method: "POST",
374
- body: JSON.stringify({ command, arguments: argumentsText }),
422
+ body: JSON.stringify(commandBody(command, argumentsText)),
375
423
  }, 120_000)
376
424
  if (!response.ok) throw new Error(`${command} failed: HTTP ${response.status} ${response.text}\n${await diagnostics()}`)
377
425
  return response
@@ -383,7 +431,7 @@ async function main() {
383
431
  await sendControl("loop-resume", "default")
384
432
  await waitFor(async () => {
385
433
  const job = await jobNamed("default")
386
- return job?.paused === false && job?.lastRunAt === 0
434
+ return job?.paused === false && (job?.lastRunAt === 0 || job?.runNowRequestedAt > 0)
387
435
  }, "real V2 loop-resume state mutation", diagnostics, 30_000)
388
436
 
389
437
  await sendControl("loop-stop", "default")
@@ -409,6 +457,67 @@ async function main() {
409
457
  assert.equal(provider.stats.loopRequests, EXPECTED_TURNS + 2, `control lifecycle should add exactly two autonomous Loop turns\n${await diagnostics()}`)
410
458
  assert.equal(server.exitCode, null, `OpenCode 2 server exited during control lifecycle canary\n${await diagnostics()}`)
411
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
+
412
521
  console.log(JSON.stringify({
413
522
  ok: true,
414
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
+ }