@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,227 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+ import { mkdtemp, mkdir, readFile, writeFile, rm, symlink } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import plugin from "../src/source/opencode2/native-plugin.js"
7
+ import { parseLoopArgs } from "../src/source/core/args.js"
8
+ import { presetDefaults } from "../src/source/core/jobs.js"
9
+ import { normalizeLoopScheduleArgs } from "../src/source/core/schedule-syntax.js"
10
+ import { readState } from "../src/source/core/state.js"
11
+
12
+ const PRESETS = ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"]
13
+ const SESSION = "ses_native_presets"
14
+ async function fixture({ compact = true } = {}) {
15
+ const temp = await mkdtemp(path.join(os.tmpdir(), "loop-native-presets-"))
16
+ const directory = path.join(temp, "project with spaces")
17
+ await mkdir(directory)
18
+ const commands = new Map(), prompts = [], shellCalls = []
19
+ let dispose
20
+ try {
21
+ dispose = await plugin.setup({
22
+ location: { directory }, options: {}, app: { version: "2.0.18" },
23
+ session: {
24
+ get: async ({ sessionID }) => sessionID === SESSION ? { id: SESSION, location: { directory } } : null,
25
+ hook: async () => {},
26
+ prompt: async request => { prompts.push(request); return { id: request.id || "msg_ack" } },
27
+ command: async () => { throw new Error("a deferred/dry-run preset must not execute a slash command") },
28
+ wait: async () => {},
29
+ shell: async request => { shellCalls.push(request); return { id: request.id, status: "exited", exit: 0 } },
30
+ ...(compact ? { compact: async () => { throw new Error("a deferred/dry-run preset must not compact") } } : {}),
31
+ },
32
+ command: { transform: async edit => edit({ add(definition) {
33
+ assert.equal(commands.has(definition.name), false, `duplicate command ${definition.name}`)
34
+ commands.set(definition.name, definition)
35
+ } }) },
36
+ event: { subscribe() { return (async function* () {})() } },
37
+ })
38
+ } catch (error) { await rm(temp, { recursive: true, force: true }); throw error }
39
+ return {
40
+ temp, directory, commands, prompts, shellCalls,
41
+ async execute(name, text = "") {
42
+ assert.ok(commands.has(name), `native command missing: ${name}`)
43
+ return commands.get(name).execute({ sessionID: SESSION, prompt: { text } })
44
+ },
45
+ state: () => readState(directory, SESSION),
46
+ async dispose() { await dispose(); await rm(temp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) },
47
+ }
48
+ }
49
+ const fields = ["name", "action", "kind", "intervalMs", "immediate", "safe", "askNever", "noOverlap", "checkpointOnly", "gitCheckpoint", "batch", "progressFile", "verifyCommand", "testCommand", "maxRuns", "dryRun"]
50
+ for (const name of PRESETS) {
51
+ test(`native ${name} preserves the shared V1 preset parser contract`, async () => {
52
+ const host = await fixture()
53
+ try {
54
+ const action = ["loop-command", "loop-cmd"].includes(name) ? "/review project"
55
+ : name === "loop-shell" ? "npm test"
56
+ : ["loop-prompt", "loop-ask"].includes(name) ? "did you run tests and tsc --noEmit?" : ""
57
+ const text = name === "loop-compact" ? "--dry-run" : `every 1h --dry-run --max-runs 2 ${action}`.trim()
58
+ const normalized = normalizeLoopScheduleArgs(text, presetDefaults(name))
59
+ assert.equal(normalized.ok, true, normalized.error)
60
+ const expected = parseLoopArgs(normalized.args, normalized.defaults)
61
+ assert.equal(expected.ok, true, expected.error)
62
+ const response = await host.execute(name, text)
63
+ assert.equal(response.accepted, true)
64
+ const state = await host.state()
65
+ assert.equal(state.jobs.length, 1)
66
+ for (const field of fields) assert.deepEqual(state.jobs[0][field], expected.job[field], `${name}: ${field}`)
67
+ assert.equal(state.jobs[0].intervalMs, name === "loop-compact" ? 12000000 : 3600000, "explicit duration cannot remain part of the task text")
68
+ assert.equal(state.jobs[0].immediate, false)
69
+ assert.equal(state.jobs[0].scheduleMode, normalized.scheduleMode)
70
+ assert.equal(state.jobs[0].scheduleSyntax, normalized.scheduleSyntax)
71
+ assert.equal(state.jobs[0].runCount, 0, "deferred presets must not dispatch on registration")
72
+ assert.deepEqual(host.prompts, [])
73
+ assert.deepEqual(host.shellCalls, [])
74
+ } finally { await host.dispose() }
75
+ })
76
+ }
77
+
78
+ test("native preset arguments need no duration token and retain leading flags", async () => {
79
+ const host = await fixture()
80
+ try {
81
+ const output = await host.execute("loop-shell", "--no-now --dry-run npm test")
82
+ assert.equal(output.job.action, "npm test")
83
+ assert.equal(output.job.kind, "shell")
84
+ assert.equal(output.job.immediate, false)
85
+ } finally { await host.dispose() }
86
+ })
87
+
88
+ test("an immediate native development preset enters the existing dry-run safety path", async () => {
89
+ const host = await fixture()
90
+ try {
91
+ await host.execute("loop-dev", "--dry-run")
92
+ const job = (await host.state()).jobs[0]
93
+ assert.equal(job.name, "dev")
94
+ assert.equal(job.paused, true)
95
+ assert.equal(job.pauseReason, "dry-run")
96
+ assert.equal(job.runCount, 0)
97
+ assert.deepEqual(host.prompts, [])
98
+ } finally { await host.dispose() }
99
+ })
100
+
101
+ test("zero-delay deferred shell preset is scheduled without an unrelated model turn", async () => {
102
+ const host = await fixture()
103
+ try {
104
+ await host.execute("loop-shell", "0s echo safe --max-runs 1")
105
+ const deadline = Date.now() + 3000
106
+ while (Date.now() < deadline && (await host.state()).jobs[0]?.v2Run) await new Promise(resolve => setTimeout(resolve, 10))
107
+ while (Date.now() < deadline && (await host.state()).jobs[0]?.runCount !== 1) await new Promise(resolve => setTimeout(resolve, 10))
108
+ assert.equal((await host.state()).jobs[0].runCount, 1)
109
+ assert.equal(host.shellCalls.length, 1)
110
+ assert.deepEqual(host.prompts, [])
111
+ } finally { await host.dispose() }
112
+ })
113
+
114
+ test("unavailable native compaction rejects its preset before job persistence", async () => {
115
+ const host = await fixture({ compact: false })
116
+ try {
117
+ await assert.rejects(host.execute("loop-compact", "--dry-run"), /native compaction capability/)
118
+ assert.deepEqual((await host.state()).jobs, [])
119
+ } finally { await host.dispose() }
120
+ })
121
+
122
+ test("native loop-init creates progress without a model turn or a Loop job", async () => {
123
+ const host = await fixture()
124
+ try {
125
+ const response = await host.execute("loop-init")
126
+ assert.equal(response.created, true)
127
+ assert.match(await readFile(path.join(host.directory, "progress.md"), "utf8"), /^# Progress\n/)
128
+ assert.deepEqual((await host.state()).jobs, [])
129
+ assert.equal(host.prompts.length, 1)
130
+ assert.equal(host.prompts[0].resume, false)
131
+ } finally { await host.dispose() }
132
+ })
133
+
134
+ test("native loop-init preserves existing bytes, including concurrent initialization", async () => {
135
+ const host = await fixture()
136
+ try {
137
+ const results = await Promise.all([host.execute("loop-init"), host.execute("loop-init")])
138
+ assert.equal(results.filter(result => result.created).length, 1)
139
+ const file = path.join(host.directory, "progress.md")
140
+ const original = Buffer.from([0xef, 0xbb, 0xbf, 0, 255, 65])
141
+ await writeFile(file, original)
142
+ assert.equal((await host.execute("loop-init")).created, false)
143
+ assert.deepEqual(await readFile(file), original)
144
+ await mkdir(path.join(host.directory, "notes"))
145
+ assert.equal((await host.execute("loop-init", "notes/custom progress.md")).created, true)
146
+ } finally { await host.dispose() }
147
+ })
148
+
149
+ test("native loop-init rejects traversal, control-plane paths and symlink escapes", async () => {
150
+ const host = await fixture()
151
+ try {
152
+ for (const target of ["../outside.md", ".", ".git/HEAD", ".opencode/opencode-loop/forged.json"]) {
153
+ await assert.rejects(host.execute("loop-init", target), /inside the project|control-plane/)
154
+ }
155
+ const outside = path.join(host.temp, "outside")
156
+ await mkdir(outside)
157
+ await symlink(outside, path.join(host.directory, "escape"), process.platform === "win32" ? "junction" : "dir")
158
+ await assert.rejects(host.execute("loop-init", "escape/progress.md"), /inside the project/)
159
+ await assert.rejects(readFile(path.join(outside, "progress.md")), { code: "ENOENT" })
160
+ assert.deepEqual(host.prompts, [])
161
+ assert.deepEqual((await host.state()).jobs, [])
162
+ } finally { await host.dispose() }
163
+ })
164
+
165
+
166
+ const schedules = [
167
+ { text: "continue the project --dry-run", action: "continue the project", interval: 0, immediate: true, mode: "idle", syntax: "idle-shorthand", maxRuns: 0 },
168
+ { text: "idle continue --dry-run", action: "continue", interval: 0, immediate: true, mode: "idle", syntax: "idle", maxRuns: 0 },
169
+ { text: "every 1h continue --dry-run", action: "continue", interval: 3600000, immediate: false, mode: "interval", syntax: "every", maxRuns: 0 },
170
+ { text: "after 1h continue --now --max-runs 9 --dry-run", action: "continue", interval: 3600000, immediate: false, mode: "once", syntax: "after", maxRuns: 1 },
171
+ { text: "in 1h continue --dry-run", action: "continue", interval: 3600000, immediate: false, mode: "once", syntax: "after", maxRuns: 1 },
172
+ { text: "1h continue --dry-run", action: "continue", interval: 3600000, immediate: true, mode: "interval", syntax: "legacy", maxRuns: 0 },
173
+ { text: "1h --no-now continue --dry-run", action: "continue", interval: 3600000, immediate: false, mode: "interval", syntax: "legacy", maxRuns: 0 },
174
+ { text: "every 1h --now continue --dry-run", action: "continue", interval: 3600000, immediate: true, mode: "interval", syntax: "every", maxRuns: 0 },
175
+ { text: "--safe --ask-never continue --dry-run", action: "continue", interval: 0, immediate: true, mode: "idle", syntax: "idle-shorthand", maxRuns: 0 },
176
+ ]
177
+ for (const example of schedules) {
178
+ test(`native schedule grammar: ${example.text}`, async () => {
179
+ const host = await fixture()
180
+ try {
181
+ await host.execute("loop", example.text)
182
+ const job = (await host.state()).jobs[0]
183
+ assert.equal(job.action, example.action)
184
+ assert.equal(job.intervalMs, example.interval)
185
+ assert.equal(job.immediate, example.immediate)
186
+ assert.equal(job.maxRuns, example.maxRuns)
187
+ assert.equal(job.scheduleMode, example.mode)
188
+ assert.equal(job.scheduleSyntax, example.syntax)
189
+ assert.equal(job.runCount, 0)
190
+ assert.deepEqual(host.prompts, [])
191
+ assert.deepEqual(host.shellCalls, [])
192
+ } finally { await host.dispose() }
193
+ })
194
+ }
195
+
196
+ test("invalid native schedules and unsupported Goal overlap are rejected before persistence", async () => {
197
+ const host = await fixture()
198
+ try {
199
+ for (const text of ["every later continue", "after later continue", "in later continue", "0s --allow-goal-overlap continue"]) {
200
+ await assert.rejects(host.execute("loop", text), /Invalid.*schedule|Goal.*reservation/)
201
+ assert.deepEqual((await host.state()).jobs, [])
202
+ }
203
+ assert.deepEqual(host.prompts, [])
204
+ } finally { await host.dispose() }
205
+ })
206
+
207
+ for (const example of [
208
+ { args: "", verify: "npm test", action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" },
209
+ { args: "npm run check", verify: "npm run check", action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm run check" },
210
+ { args: '--verify "npm run lint"', verify: "npm run lint", action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm run lint" },
211
+ { args: 'fix parser tests --verify "npm run lint"', verify: "npm run lint", action: "fix parser tests" },
212
+ ]) {
213
+ test(`native testfix command semantics: ${example.args || "default"}`, async () => {
214
+ const host = await fixture()
215
+ try {
216
+ await host.execute("loop-testfix", `every 1h --dry-run ${example.args}`)
217
+ const job = (await host.state()).jobs[0]
218
+ assert.equal(job.verifyCommand, example.verify)
219
+ assert.equal(job.action, example.action)
220
+ assert.equal(job.intervalMs, 3600000)
221
+ assert.equal(job.immediate, false)
222
+ assert.equal(job.safe, true)
223
+ assert.equal(job.askNever, true)
224
+ assert.deepEqual(host.prompts, [])
225
+ } finally { await host.dispose() }
226
+ })
227
+ }
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { setTimeout as delay } from "node:timers/promises"
6
+
7
+ // The descendant stays in the command's process group but closes every pipe.
8
+ // Killing the shell therefore emits close before this descendant has stopped.
9
+ export async function processTreeFixture(mode = "timeout") {
10
+ const directory = await mkdtemp(path.join(tmpdir(), "opencode-native-process-tree-"))
11
+ await writeFile(path.join(directory, "descendant.cjs"), `
12
+ const fs = require("node:fs")
13
+ process.on("SIGTERM", () => {})
14
+ let n = 0
15
+ const beat = () => fs.writeFileSync("heartbeat.txt", String(++n))
16
+ beat()
17
+ fs.writeFileSync("descendant.pid", String(process.pid))
18
+ setInterval(beat, 20)
19
+ setTimeout(() => process.exit(0), 20000)
20
+ `)
21
+ await writeFile(path.join(directory, "parent.cjs"), `
22
+ const fs = require("node:fs")
23
+ const { spawn } = require("node:child_process")
24
+ fs.writeFileSync("parent.pid", String(process.pid))
25
+ spawn(process.execPath, ["descendant.cjs"], { stdio: "ignore" })
26
+ const ready = setInterval(() => {
27
+ if (!fs.existsSync("descendant.pid")) return
28
+ clearInterval(ready)
29
+ fs.writeFileSync("ready.txt", "ready")
30
+ if (${JSON.stringify(mode)} === "overflow") process.stdout.write("A".repeat(20000))
31
+ }, 10)
32
+ setInterval(() => {}, 1000)
33
+ `)
34
+ return {
35
+ directory,
36
+ command: `"${process.execPath}" parent.cjs`,
37
+ async ready() {
38
+ const deadline = Date.now() + 5000
39
+ while (Date.now() < deadline) {
40
+ try { await readFile(path.join(directory, "ready.txt")); return }
41
+ catch (error) { if (error.code !== "ENOENT") throw error }
42
+ await delay(20)
43
+ }
44
+ throw new Error("descendant fixture never became ready")
45
+ },
46
+ async assertStopped() {
47
+ await readFile(path.join(directory, "ready.txt"))
48
+ // Let any in-flight filesystem write finish, then detect continued work.
49
+ await delay(60)
50
+ const before = await readFile(path.join(directory, "heartbeat.txt"), "utf8")
51
+ await delay(180)
52
+ assert.equal(await readFile(path.join(directory, "heartbeat.txt"), "utf8"), before,
53
+ "terminal result must not precede stopping a pipe-detached descendant")
54
+ },
55
+ async cleanup() {
56
+ for (const file of ["descendant.pid", "parent.pid"]) {
57
+ try { process.kill(Number(await readFile(path.join(directory, file), "utf8")), "SIGKILL") } catch {}
58
+ }
59
+ await delay(80)
60
+ await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
61
+ },
62
+ }
63
+ }
64
+
65
+ export async function bounded(promise, milliseconds = 12000) {
66
+ let timer
67
+ try {
68
+ return await Promise.race([promise, new Promise((_, reject) => {
69
+ timer = setTimeout(() => reject(new Error("native process cleanup never settled")), milliseconds)
70
+ })])
71
+ } finally { clearTimeout(timer) }
72
+ }
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict"
2
+ import { createNativeShellHost } from "../src/source/opencode2/native-shell.js"
3
+ import { processTreeFixture, bounded } from "./native-process-tree-fixture.mjs"
4
+
5
+ for (const mode of ["timeout", "dispose", "concurrent-dispose"]) {
6
+ const fixture = await processTreeFixture()
7
+ const events = []
8
+ let terminal
9
+ const ended = new Promise((resolve) => { terminal = resolve })
10
+ const host = createNativeShellHost({
11
+ directory: fixture.directory,
12
+ onTerminal: (event) => { events.push(event); terminal(event) },
13
+ })
14
+ try {
15
+ await host.dispatch({ id: "tree-command", sessionID: "tree-session", command: fixture.command,
16
+ timeoutMs: mode === "timeout" ? 2000 : 10000 })
17
+ await fixture.ready()
18
+ if (mode === "dispose") await bounded(host.dispose())
19
+ if (mode === "concurrent-dispose") {
20
+ const first = host.dispose()
21
+ const second = host.dispose()
22
+ // Every caller must wait for the same cleanup, not return on a boolean.
23
+ await bounded(second)
24
+ assert.equal(host.activeCount(), 0, "second dispose returned before cleanup")
25
+ await fixture.assertStopped()
26
+ await bounded(first)
27
+ }
28
+ const event = await bounded(ended)
29
+ assert.equal(event.status, mode === "timeout" ? "timeout" : "killed")
30
+ assert.equal(event.sessionID, "tree-session")
31
+ assert.equal(event.shellID, "tree-command")
32
+ assert.equal(events.length, 1)
33
+ assert.equal(host.activeCount(), 0)
34
+ await fixture.assertStopped()
35
+ console.log(`Native process-tree regression passed: ${mode}`)
36
+ } finally {
37
+ await fixture.cleanup()
38
+ await bounded(host.dispose())
39
+ }
40
+ }
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict"
2
+ import { readFile, appendFile } from "node:fs/promises"
3
+ import { pathToFileURL } from "node:url"
4
+ import { setTimeout as delay } from "node:timers/promises"
5
+
6
+ export function validateRelease(request, pkg, env) {
7
+ assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
8
+ assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
9
+ assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
10
+ assert.equal(request?.version, "0.6.0", "release version is not authorized")
11
+ assert.equal(request?.name, "@bybrawe/opencode-loop")
12
+ assert.equal(pkg.name, request.name)
13
+ assert.equal(pkg.version, request.version)
14
+ assert.equal(request.predecessor, "0.5.38")
15
+ assert.equal(request.goal?.version, "1.3.39")
16
+ assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
17
+ return request
18
+ }
19
+
20
+ export function assertPublishedSource(manifest, name, version, sha) {
21
+ assert.equal(manifest?.name, name, "published package name differs")
22
+ assert.equal(manifest?.version, version, "published package version differs")
23
+ assert.equal(manifest?.gitHead, sha, "immutable npm version belongs to different source; use a new version")
24
+ }
25
+
26
+ export function releaseDecision(status, manifest, request, sha, latest) {
27
+ if (status === 200) {
28
+ assertPublishedSource(manifest, request.name, request.version, sha)
29
+ return false
30
+ }
31
+ assert.equal(status, 404, "registry failure is not proof that a version is unpublished")
32
+ assert.equal(latest, request.predecessor, "npm latest differs from the predecessor; refuse out-of-order publication")
33
+ return true
34
+ }
35
+
36
+ export async function waitForPublishedGoal(goal, options = {}) {
37
+ const request = options.request ?? fetch
38
+ const sleep = options.sleep ?? delay
39
+ const attempts = options.attempts ?? 60
40
+ for (let attempt = 0; attempt < attempts; attempt++) {
41
+ const response = await request("https://registry.npmjs.org/" + encodeURIComponent("@bybrawe/opencode-goal") + "/" + goal.version, { signal: AbortSignal.timeout(20000) })
42
+ if (response.ok) {
43
+ const manifest = await response.json()
44
+ assertPublishedSource(manifest, "@bybrawe/opencode-goal", goal.version, goal.sha)
45
+ return manifest
46
+ }
47
+ assert.equal(response.status, 404, "Goal registry transport/auth errors cannot authorize the joint release")
48
+ if (attempt + 1 < attempts) await sleep(10000)
49
+ }
50
+ throw new Error("Declared Goal release is not published; Loop publication refused")
51
+ }
52
+
53
+ async function json(url, headers = {}) {
54
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(20000) })
55
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`)
56
+ return response.json()
57
+ }
58
+
59
+ async function main() {
60
+ const request = JSON.parse(await readFile(new URL("../.github/npm-release.json", import.meta.url), "utf8"))
61
+ const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"))
62
+ validateRelease(request, pkg, process.env)
63
+ const mode = process.argv[2]
64
+ const sha = process.env.GITHUB_SHA
65
+ const base = "https://registry.npmjs.org/" + encodeURIComponent(pkg.name)
66
+ if (mode === "validate") {
67
+ console.log(JSON.stringify({ release: request, source: sha }))
68
+ return
69
+ }
70
+ if (mode === "ci") {
71
+ const headers = { Accept: "application/vnd.github+json" }
72
+ if (process.env.GH_TOKEN) headers.Authorization = `Bearer ${process.env.GH_TOKEN}`
73
+ const deadline = Date.now() + 15 * 60_000
74
+ while (Date.now() < deadline) {
75
+ const runs = []
76
+ for (let page = 1; page <= 10; page++) {
77
+ const result = await json(`https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/runs?head_sha=${sha}&per_page=100&page=${page}`, headers)
78
+ runs.push(...result.workflow_runs)
79
+ if (runs.length >= result.total_count || result.workflow_runs.length < 100) break
80
+ if (page === 10) throw new Error("Cannot safely paginate release checks")
81
+ }
82
+ const gates = ["CI", "Native V2 CI"].map((name) => {
83
+ const run = runs.filter((item) => item.name === name && item.head_sha === sha && item.head_branch === "main" && item.event === "push" && item.repository?.full_name === process.env.GITHUB_REPOSITORY).sort((a, b) => Number(b.id) - Number(a.id))[0]
84
+ return { name, id: run?.id, status: run?.status, conclusion: run?.conclusion }
85
+ })
86
+ console.log(JSON.stringify({ sha, gates }))
87
+ if (gates.some((gate) => gate.status === "completed" && gate.conclusion !== "success")) throw new Error("Exact-main CI failed; npm publication refused")
88
+ if (gates.every((gate) => gate.status === "completed" && gate.conclusion === "success")) return
89
+ await delay(15000)
90
+ }
91
+ throw new Error("Timed out waiting for exact-main CI")
92
+ }
93
+ if (mode === "decide") {
94
+ // Wait only for an absent, concurrently publishing companion. A different
95
+ // immutable source or an actual registry error fails immediately.
96
+ const goal = await waitForPublishedGoal(request.goal)
97
+ const exact = await fetch(base + "/" + request.version, { signal: AbortSignal.timeout(20000) })
98
+ const existing = exact.ok ? await exact.json() : null
99
+ const latest = exact.status === 404 ? await json(base + "/latest") : null
100
+ const publish = releaseDecision(exact.status, existing, request, sha, latest?.version)
101
+ await appendFile(process.env.GITHUB_OUTPUT, `publish=${publish}\n`)
102
+ console.log(JSON.stringify({ publish, loop: request.version, goal: goal.version, goalSHA: goal.gitHead }))
103
+ return
104
+ }
105
+ if (mode === "verify") {
106
+ const exact = await json(base + "/" + request.version)
107
+ const latest = await json(base + "/latest")
108
+ assertPublishedSource(exact, pkg.name, request.version, sha)
109
+ assert.equal(latest.version, request.version, "latest must resolve to this release")
110
+ assert.deepEqual(exact.exports, pkg.exports)
111
+ for (const [name, file] of Object.entries(pkg.bin)) assert.equal(exact.bin?.[name]?.replace(/^\.\//, ""), file.replace(/^\.\//, ""))
112
+ assert.ok(exact.dist?.integrity)
113
+ console.log("REGISTRY_VERIFIED " + JSON.stringify({ name: exact.name, version: exact.version, gitHead: exact.gitHead, integrity: exact.dist.integrity, latest: latest.version, bin: exact.bin }))
114
+ return
115
+ }
116
+ throw new Error("Expected validate, ci, decide or verify")
117
+ }
118
+
119
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
120
+ main().catch((error) => { console.error(error); process.exitCode = 1 })
121
+ }
@@ -0,0 +1,86 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, access, readFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+ import plugin from '../src/source/opencode2/native-plugin.js'
7
+ import { createNativeLoopRuntime } from '../src/source/opencode2/native-runtime.js'
8
+
9
+ async function fixture() {
10
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'loop-location-'))
11
+ const foreign = await mkdtemp(path.join(os.tmpdir(), 'loop-foreign-'))
12
+ let session = { id: 'ses_scope', location: { directory } }
13
+ const commands = new Map(), hooks = new Map(), prompts = []
14
+ const ctx = {
15
+ location: { directory }, options: {}, app: { version: '2.0.18' },
16
+ session: {
17
+ get: async () => session,
18
+ prompt: async (input) => { prompts.push(input); return { id: input.id || 'msg_status' } },
19
+ hook: async (name, fn) => { hooks.set(name, fn); return { dispose() { hooks.delete(name) } } },
20
+ },
21
+ command: { transform: async (fn) => { fn({ add(def) { commands.set(def.name, def) } }); return { dispose() {} } } },
22
+ event: { subscribe({ signal }) { return (async function* () { await new Promise((resolve) => { if (signal.aborted) resolve(); else signal.addEventListener('abort', resolve, { once: true }) }) })() } },
23
+ }
24
+ const close = await plugin.setup(ctx)
25
+ return {
26
+ directory, foreign, ctx, commands, hooks, prompts,
27
+ setSession(value) { session = value },
28
+ command(name, text = '') { return commands.get(name).execute({ sessionID: 'ses_scope', prompt: { text } }) },
29
+ async close() { await close(); await rm(directory, { recursive: true, force: true }); await rm(foreign, { recursive: true, force: true }) },
30
+ }
31
+ }
32
+ test('foreign-worktree command cannot create progress in the plugin directory', async () => {
33
+ const f = await fixture()
34
+ try {
35
+ f.setSession({ id: 'ses_scope', location: { directory: f.foreign } })
36
+ await assert.rejects(f.command('loop-init', 'progress.md'), /session.*location|project.*worktree/i)
37
+ await assert.rejects(access(path.join(f.directory, 'progress.md')))
38
+ await assert.rejects(access(path.join(f.foreign, 'progress.md')))
39
+ assert.equal(f.prompts.length, 0)
40
+ } finally { await f.close() }
41
+ })
42
+ test('native command still initializes a confirmed local session', async () => {
43
+ const f = await fixture()
44
+ try {
45
+ await f.command('loop-init', 'progress.md')
46
+ assert.ok((await readFile(path.join(f.directory, 'progress.md'), 'utf8')).length)
47
+ assert.equal(f.prompts.length, 1)
48
+ } finally { await f.close() }
49
+ })
50
+ test('missing, mismatched and unreadable session identity fail closed', async () => {
51
+ const f = await fixture()
52
+ try {
53
+ for (const value of [null, { id: 'ses_other', location: { directory: f.directory } }, { id: 'ses_scope' }, { id: 'ses_scope', location: { directory: f.directory, workspaceID: 'other' } }]) {
54
+ f.setSession(value)
55
+ await assert.rejects(f.command('loop-init'), /session.*location|project.*worktree/i)
56
+ }
57
+ f.ctx.session.get = async () => { throw new Error('session unavailable') }
58
+ await assert.rejects(f.command('loop-init'), /session.*location|project.*worktree/i)
59
+ await assert.rejects(access(path.join(f.directory, 'progress.md')))
60
+ } finally { await f.close() }
61
+ })
62
+ test('scoped runtime refuses persistent commands and timer dispatch when ownership changes', async () => {
63
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'loop-runtime-location-'))
64
+ let allowed = false
65
+ const prompts = [], timers = []
66
+ const runtime = createNativeLoopRuntime({ directory,
67
+ scopeAllowed: async () => allowed,
68
+ prompt: async (input) => { prompts.push(input); return { id: input.id } },
69
+ setTimer(fn) { const timer = { fn }; timers.push(timer); return timer }, clearTimer(timer) { timer.cancelled = true },
70
+ })
71
+ const scope = { directory, sessionID: 'ses_runtime' }
72
+ const command = () => runtime.onEvent({ ...scope, kind: 'command', action: 'executed', name: 'loop', arguments: '1h scoped task --defer' })
73
+ try {
74
+ assert.equal((await command()).accepted, false)
75
+ await assert.rejects(access(path.join(directory, '.opencode')))
76
+ allowed = true
77
+ assert.equal((await command()).accepted, true)
78
+ const file = path.join(directory, '.opencode', 'opencode-loop', 'ses_runtime.json')
79
+ const before = await readFile(file, 'utf8')
80
+ allowed = false
81
+ for (const timer of timers.filter((t) => !t.cancelled)) await timer.fn()
82
+ assert.equal((await runtime.wake(scope)).accepted, false)
83
+ assert.equal(await readFile(file, 'utf8'), before)
84
+ assert.equal(prompts.length, 0)
85
+ } finally { await runtime.dispose(); await rm(directory, { recursive: true, force: true }) }
86
+ })
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, writeFile, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { createNativeShellHost } from "../src/source/opencode2/native-shell.js"
6
+
7
+ let cases = 0
8
+ async function scenario(source, check, options = {}) {
9
+ const directory = await mkdtemp(path.join(tmpdir(), "loop-native-shell-"))
10
+ const events = [], errors = []
11
+ let terminal
12
+ const ended = new Promise((resolve) => { terminal = resolve })
13
+ const host = createNativeShellHost({ directory, onTerminal: (event) => { events.push(event); terminal(event) }, onError: (error) => errors.push(error) })
14
+ try {
15
+ await writeFile(path.join(directory, "command.cjs"), source)
16
+ const admitted = await host.dispatch({ id: "owned-shell", sessionID: "owned-session", command: `"${process.execPath}" command.cjs`, timeoutMs: 5000, ...options })
17
+ assert.equal(admitted.id, "owned-shell")
18
+ assert.equal(admitted.status, "running")
19
+ if (options.dispose) await host.dispose()
20
+ const event = await Promise.race([ended, new Promise((_, reject) => { const t = setTimeout(() => reject(new Error("shell never settled")), 10_000); t.unref() })])
21
+ assert.equal(event.sessionID, "owned-session")
22
+ assert.equal(event.shellID, "owned-shell")
23
+ assert.equal(events.length, 1)
24
+ assert.equal(host.activeCount(), 0)
25
+ assert.equal(errors.length, 0)
26
+ await check(event, host)
27
+ cases++
28
+ } finally { await host.dispose(); await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }
29
+ }
30
+ await scenario('process.stdout.write("SUCCESS")', (event) => { assert.equal(event.code, 0); assert.equal(event.status, "exited"); assert.equal(event.stdout, "SUCCESS") })
31
+ await scenario('process.stderr.write("FAILED"); process.exitCode=7', (event) => { assert.equal(event.code, 7); assert.equal(event.stderr, "FAILED") })
32
+ await scenario('setInterval(()=>{},1000)', (event) => { assert.equal(event.code, 124); assert.equal(event.status, "timeout") }, { timeoutMs: 100 })
33
+ await scenario('setInterval(()=>{},1000)', (event) => { assert.equal(event.status, "killed"); assert.notEqual(event.code, 0) }, { dispose: true })
34
+ await scenario('process.stdout.write("A".repeat(100000))', (event) => { assert.equal(event.stdout.length, 64000); assert.equal(event.code, 0) })
35
+ await scenario('process.exitCode=0', async (_, host) => { await host.dispose(); await assert.rejects(host.dispatch({ sessionID: "s", command: "echo nope" }), /disposed/) })
36
+ assert.throws(() => createNativeShellHost({}), /requires directory/)
37
+ console.log(`Native local shell lifecycle: ${cases + 1} scenarios passed`)
38
+ await import("./native-process-tree-test.mjs")