@bybrawe/opencode-loop 0.5.26 → 0.5.28

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 (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +9 -3
  3. package/package.json +8 -4
  4. package/scripts/comprehensive-test.mjs +49 -4
  5. package/scripts/comprehensive-watchdog.mjs +32 -0
  6. package/scripts/host-adapter-contract-test.mjs +147 -0
  7. package/scripts/host-bridge-parity-test.mjs +22 -0
  8. package/scripts/host-loop-canary.mjs +357 -0
  9. package/scripts/loopd-test.mjs +45 -18
  10. package/scripts/loopd.mjs +92 -22
  11. package/scripts/opencode2-host-canary.mjs +194 -0
  12. package/scripts/runtime-contract-test.mjs +59 -0
  13. package/scripts/runtime-event-test.mjs +78 -0
  14. package/scripts/runtime-scope-test.mjs +46 -0
  15. package/scripts/session-manager-test.mjs +48 -0
  16. package/scripts/v2-event-bridge-test.mjs +72 -0
  17. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  18. package/scripts/v2-host-contract-test.mjs +55 -0
  19. package/scripts/v2-plugin-sentinel-test.mjs +87 -0
  20. package/src/index.js +2304 -1864
  21. package/src/opencode/v1.js +1 -0
  22. package/src/source/core/args.js +281 -0
  23. package/src/source/core/jobs.js +80 -0
  24. package/src/source/core/process.js +51 -0
  25. package/src/source/core/state.js +211 -0
  26. package/src/source/legacy-v1.js +1944 -0
  27. package/src/source/opencode/adapter.js +21 -0
  28. package/src/source/opencode/host.js +134 -0
  29. package/src/source/opencode/sdk.js +38 -0
  30. package/src/source/opencode/session-context.js +50 -0
  31. package/src/source/opencode2/capabilities.js +69 -0
  32. package/src/source/opencode2/event-bridge.js +156 -0
  33. package/src/source/opencode2/experimental.js +16 -0
  34. package/src/source/opencode2/host-contract.js +68 -0
  35. package/src/source/runtime/events.js +113 -0
  36. package/src/source/runtime/observer.js +23 -0
  37. package/src/source/runtime/scope.js +56 -0
  38. package/src/source/runtime/session-manager.js +100 -0
  39. package/src/source/runtime/session-registry.js +58 -0
  40. package/src/source/runtime/timers.js +45 -0
  41. package/src/source/v1.js +69 -0
@@ -0,0 +1,194 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import process from "node:process"
6
+ import { fileURLToPath, pathToFileURL } from "node:url"
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
9
+ const pluginID = "bybrawe.opencode-loop.v2.experimental"
10
+ const sentinelID = "bybrawe.opencode-loop.v2-canary-sentinel"
11
+
12
+ function run(command, args, { cwd, env, allowFailure = false, timeout = 60_000 } = {}) {
13
+ const result = spawnSync(command, args, {
14
+ cwd,
15
+ env,
16
+ encoding: "utf8",
17
+ maxBuffer: 8 * 1024 * 1024,
18
+ timeout,
19
+ windowsHide: true,
20
+ })
21
+ if (result.error) throw result.error
22
+ if (!allowFailure && result.status !== 0) {
23
+ throw new Error([
24
+ `command failed (${result.status}): ${command} ${args.join(" ")}`,
25
+ String(result.stdout ?? ""),
26
+ String(result.stderr ?? ""),
27
+ ].filter(Boolean).join("\n"))
28
+ }
29
+ return result
30
+ }
31
+
32
+ function output(result) {
33
+ return `${String(result.stdout ?? "")}\n${String(result.stderr ?? "")}`.trim()
34
+ }
35
+
36
+ function parseJSONOutput(result, label) {
37
+ const text = String(result.stdout ?? "").trim()
38
+ try {
39
+ return JSON.parse(text)
40
+ } catch {
41
+ throw new Error(`${label} did not return JSON on stdout.\nstdout:\n${text}\nstderr:\n${String(result.stderr ?? "")}`)
42
+ }
43
+ }
44
+
45
+ function collectPluginIDs(value) {
46
+ if (Array.isArray(value)) return value.flatMap(collectPluginIDs)
47
+ if (typeof value === "string") return [value]
48
+ if (!value || typeof value !== "object") return []
49
+ const direct = [value.id, value.pluginID, value.name].filter((item) => typeof item === "string")
50
+ const nested = [value.data, value.plugin, value.plugins, value.items].flatMap((item) => collectPluginIDs(item))
51
+ return [...direct, ...nested]
52
+ }
53
+
54
+ function uniquePluginIDs(value) {
55
+ return [...new Set(collectPluginIDs(value))]
56
+ }
57
+
58
+ async function failureLog(env) {
59
+ const candidates = [
60
+ path.join(env.XDG_DATA_HOME, "opencode", "log", "opencode.log"),
61
+ path.join(env.XDG_STATE_HOME, "opencode", "log", "opencode.log"),
62
+ ]
63
+ for (const file of candidates) {
64
+ try {
65
+ const raw = await readFile(file, "utf8")
66
+ return raw.slice(-30_000)
67
+ } catch {}
68
+ }
69
+ return ""
70
+ }
71
+
72
+ async function main() {
73
+ const temp = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-host-"))
74
+ const project = path.join(temp, "project")
75
+ const home = path.join(temp, "home")
76
+ const config = path.join(home, ".config")
77
+ const data = path.join(home, ".local", "share")
78
+ const state = path.join(home, ".local", "state")
79
+ const pluginDirectory = path.join(project, ".opencode", "plugins")
80
+ const pluginFile = path.join(root, "src", "source", "opencode2", "experimental.js")
81
+ const adapterBridge = path.join(pluginDirectory, "opencode-loop-v2-canary.js")
82
+ const sentinelFile = path.join(pluginDirectory, "opencode-loop-v2-sentinel.js")
83
+ const sentinelURL = pathToFileURL(sentinelFile).href
84
+ const adapterURL = pathToFileURL(adapterBridge).href
85
+
86
+ await Promise.all([
87
+ mkdir(pluginDirectory, { recursive: true }),
88
+ mkdir(config, { recursive: true }),
89
+ mkdir(data, { recursive: true }),
90
+ mkdir(state, { recursive: true }),
91
+ ])
92
+
93
+ await writeFile(sentinelFile, `export default { id: ${JSON.stringify(sentinelID)}, server: async () => ({ id: ${JSON.stringify(sentinelID)} }) }\n`)
94
+ await writeFile(adapterBridge, `export { default } from ${JSON.stringify(pathToFileURL(pluginFile).href)}\n`)
95
+ await writeFile(path.join(project, "README.md"), "# OpenCode Loop V2 host canary\n")
96
+ await writeFile(path.join(project, "opencode.json"), `${JSON.stringify({
97
+ $schema: "https://opencode.ai/config.json",
98
+ plugin: [
99
+ "./.opencode/plugins/opencode-loop-v2-sentinel.js",
100
+ "./.opencode/plugins/opencode-loop-v2-canary.js",
101
+ ],
102
+ }, null, 2)}\n`)
103
+
104
+ const env = {
105
+ ...process.env,
106
+ HOME: home,
107
+ USERPROFILE: home,
108
+ XDG_CONFIG_HOME: config,
109
+ XDG_DATA_HOME: data,
110
+ XDG_STATE_HOME: state,
111
+ OPENCODE_DB: path.join(data, "opencode", "opencode-loop-v2-canary.db"),
112
+ OPENCODE_LOG_LEVEL: "DEBUG",
113
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [sentinelURL, adapterURL] }),
114
+ CI: "true",
115
+ }
116
+
117
+ run("git", ["init", "-q"], { cwd: project, env })
118
+ run("git", ["config", "user.name", "OpenCode Loop Canary"], { cwd: project, env })
119
+ run("git", ["config", "user.email", "opencode-loop-canary@example.invalid"], { cwd: project, env })
120
+ run("git", ["add", "."], { cwd: project, env })
121
+ run("git", ["commit", "-q", "-m", "initialize canary workspace"], { cwd: project, env })
122
+
123
+ try {
124
+ run("opencode2", ["service", "stop"], { cwd: project, env, allowFailure: true, timeout: 15_000 })
125
+
126
+ const version = output(run("opencode2", ["--version"], { cwd: project, env, timeout: 30_000 }))
127
+ if (!version) throw new Error("opencode2 --version returned no output")
128
+
129
+ const health = output(run("opencode2", ["api", "get", "/api/health"], { cwd: project, env }))
130
+ if (!health) throw new Error("OpenCode 2 health API returned no output")
131
+
132
+ const plainPluginResult = run("opencode2", ["api", "get", "/api/plugin"], { cwd: project, env })
133
+ const plainResponse = parseJSONOutput(plainPluginResult, "GET /api/plugin from project cwd")
134
+ const plainIDs = uniquePluginIDs(plainResponse)
135
+
136
+ const pluginPath = `/api/plugin?location%5Bdirectory%5D=${encodeURIComponent(project)}`
137
+ const scopedPluginResult = run("opencode2", ["api", "get", pluginPath], { cwd: project, env })
138
+ const scopedResponse = parseJSONOutput(scopedPluginResult, "GET /api/plugin at project Location")
139
+
140
+ if (scopedResponse?._tag) {
141
+ throw new Error(`project-scoped /api/plugin rejected the Location: ${JSON.stringify(scopedResponse)}`)
142
+ }
143
+ if (scopedResponse?.location?.directory !== project) {
144
+ throw new Error(`OpenCode 2 resolved the wrong Location: expected ${project}, got ${String(scopedResponse?.location?.directory)}`)
145
+ }
146
+
147
+ const scopedIDs = uniquePluginIDs(scopedResponse)
148
+ const ids = [...new Set([...plainIDs, ...scopedIDs])]
149
+ if (!ids.includes(sentinelID)) {
150
+ throw new Error([
151
+ `OpenCode 2 did not activate the current-loader sentinel. Active IDs: ${JSON.stringify(ids)}`,
152
+ `Plain /api/plugin IDs: ${JSON.stringify(plainIDs)}`,
153
+ `Scoped /api/plugin IDs: ${JSON.stringify(scopedIDs)}`,
154
+ `Plain response: ${String(plainPluginResult.stdout ?? "")}`,
155
+ `Scoped response: ${String(scopedPluginResult.stdout ?? "")}`,
156
+ ].join("\n"))
157
+ }
158
+ if (!ids.includes(pluginID)) {
159
+ throw new Error([
160
+ `OpenCode 2 activated the current-loader sentinel but not ${pluginID}. Active IDs: ${JSON.stringify(ids)}`,
161
+ `Plain /api/plugin IDs: ${JSON.stringify(plainIDs)}`,
162
+ `Scoped /api/plugin IDs: ${JSON.stringify(scopedIDs)}`,
163
+ `Plain response: ${String(plainPluginResult.stdout ?? "")}`,
164
+ `Scoped response: ${String(scopedPluginResult.stdout ?? "")}`,
165
+ ].join("\n"))
166
+ }
167
+
168
+ console.log(JSON.stringify({
169
+ ok: true,
170
+ platform: process.platform,
171
+ node: process.version,
172
+ opencode2Version: version,
173
+ health,
174
+ projectDirectory: scopedResponse.location.directory,
175
+ sentinelID,
176
+ pluginID,
177
+ plainPluginIDs: plainIDs,
178
+ scopedPluginIDs: scopedIDs,
179
+ activePluginIDs: ids,
180
+ }, null, 2))
181
+ } catch (error) {
182
+ const logs = await failureLog(env)
183
+ if (logs) console.error(`OpenCode 2 server log tail:\n${logs}`)
184
+ throw error
185
+ } finally {
186
+ run("opencode2", ["service", "stop"], { cwd: project, env, allowFailure: true, timeout: 15_000 })
187
+ await rm(temp, { recursive: true, force: true })
188
+ }
189
+ }
190
+
191
+ main().catch((error) => {
192
+ console.error(error?.stack || error)
193
+ process.exitCode = 1
194
+ })
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
3
+ import { createSessionRegistry } from "../src/source/runtime/session-registry.js"
4
+
5
+ let clock = 1000
6
+ const registry = createSessionRegistry({ now: () => clock, staleAfterMs: 100 })
7
+ const first = {}
8
+ const second = {}
9
+
10
+ registry.observeExternal("ses_a", first)
11
+ clock = 1050
12
+ assert.equal(registry.peek("ses_a").seenAt, 1000)
13
+ assert.equal(registry.entries()[0].seenAt, 1000)
14
+ assert.deepEqual(registry.pruneStale(), [])
15
+
16
+ registry.observeExternal("ses_a", first)
17
+ assert.equal(registry.peek("ses_a").seenAt, 1050)
18
+ clock = 1060
19
+ registry.observeExternal("ses_a", second)
20
+ assert.equal(registry.remove("ses_a", first), false)
21
+ assert.equal(registry.peek("ses_a").runtime, second)
22
+
23
+ clock = 1159
24
+ assert.deepEqual(registry.pruneStale(), [])
25
+ clock = 1160
26
+ assert.deepEqual(registry.pruneStale(), ["ses_a"])
27
+ assert.equal(registry.peek("ses_a"), undefined)
28
+
29
+ {
30
+ const factoryCalls = []
31
+ const timerAPI = {}
32
+ const manager = createSessionRuntimeManager({
33
+ timerAPI,
34
+ runtimeFactory({ sessionID, timerAPI: injectedTimerAPI }) {
35
+ let active = true
36
+ const runtime = {
37
+ sessionID,
38
+ scope: { isActive: () => active },
39
+ dispose() { active = false; return true },
40
+ }
41
+ factoryCalls.push({ injectedTimerAPI, runtime })
42
+ return runtime
43
+ },
44
+ })
45
+
46
+ assert.throws(() => manager.observeExternal(" "), /session ID/)
47
+ assert.equal(factoryCalls.length, 0)
48
+ const runtime = manager.observeExternal(" ses_custom ")
49
+ assert.equal(runtime.sessionID, "ses_custom")
50
+ assert.equal(factoryCalls[0].injectedTimerAPI, timerAPI)
51
+ assert.equal(manager.observeExternal("ses_custom"), runtime)
52
+ assert.equal(factoryCalls.length, 1)
53
+ runtime.dispose()
54
+ assert.notEqual(manager.observeExternal("ses_custom"), runtime)
55
+ assert.equal(factoryCalls.length, 2)
56
+ assert.equal(manager.dispose(), true)
57
+ }
58
+
59
+ console.log("OpenCode runtime contract test passed")
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict"
2
+ import { normalizeOpenCodeEvent } from "../src/source/runtime/events.js"
3
+
4
+ assert.deepEqual(
5
+ normalizeOpenCodeEvent({ type: "session.idle", properties: { sessionID: "ses_idle" } }),
6
+ { kind: "session", action: "idle", sessionID: "ses_idle", directory: undefined },
7
+ )
8
+
9
+ assert.deepEqual(
10
+ normalizeOpenCodeEvent({
11
+ directory: "/workspace",
12
+ payload: { type: "session.status", properties: { sessionID: "ses_busy", status: { type: "busy" } } },
13
+ }),
14
+ { kind: "session", action: "status", sessionID: "ses_busy", directory: "/workspace", status: "busy" },
15
+ )
16
+
17
+ assert.deepEqual(
18
+ normalizeOpenCodeEvent({
19
+ type: "session.created",
20
+ properties: { info: { id: "ses_child", directory: "/project", parentID: "ses_parent" } },
21
+ }),
22
+ { kind: "session", action: "created", sessionID: "ses_child", directory: "/project", parentID: "ses_parent" },
23
+ )
24
+
25
+ assert.deepEqual(
26
+ normalizeOpenCodeEvent({
27
+ directory: "/workspace",
28
+ payload: {
29
+ type: "message.updated",
30
+ properties: {
31
+ info: {
32
+ id: "msg_done",
33
+ sessionID: "ses_message",
34
+ role: "assistant",
35
+ time: { created: 10, completed: 20 },
36
+ finish: "stop",
37
+ },
38
+ },
39
+ },
40
+ }),
41
+ {
42
+ kind: "message",
43
+ action: "updated",
44
+ sessionID: "ses_message",
45
+ directory: "/workspace",
46
+ messageID: "msg_done",
47
+ role: "assistant",
48
+ completedAt: 20,
49
+ finish: "stop",
50
+ },
51
+ )
52
+
53
+ assert.deepEqual(
54
+ normalizeOpenCodeEvent({
55
+ type: "command.executed",
56
+ properties: { name: "loop-status", sessionID: "ses_command", arguments: "--json", messageID: "msg_command" },
57
+ }),
58
+ {
59
+ kind: "command",
60
+ action: "executed",
61
+ sessionID: "ses_command",
62
+ directory: undefined,
63
+ name: "loop-status",
64
+ arguments: "--json",
65
+ messageID: "msg_command",
66
+ },
67
+ )
68
+
69
+ assert.deepEqual(
70
+ normalizeOpenCodeEvent({ type: "server.instance.disposed", properties: { directory: "/old" } }),
71
+ { kind: "server", action: "disposed", directory: "/old" },
72
+ )
73
+
74
+ assert.equal(normalizeOpenCodeEvent({ type: "session.idle", properties: {} }), undefined)
75
+ assert.equal(normalizeOpenCodeEvent({ payload: { properties: {} } }), undefined)
76
+ assert.equal(normalizeOpenCodeEvent({ type: "file.edited", properties: { file: "x.txt" } }), undefined)
77
+
78
+ console.log("OpenCode runtime event normalization test passed")
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict"
2
+ import { createRuntimeScope } from "../src/source/runtime/scope.js"
3
+ import { createRuntimeTimers } from "../src/source/runtime/timers.js"
4
+
5
+ const scope = createRuntimeScope()
6
+ const events = []
7
+ scope.track(() => events.push("first"))
8
+ const release = scope.track(() => events.push("released"))
9
+ scope.track(() => events.push("last"))
10
+ assert.equal(release(), true)
11
+
12
+ let calls = 0
13
+ const guarded = scope.guard(() => ++calls)
14
+ assert.equal(guarded(), 1)
15
+
16
+ const cleared = []
17
+ const api = {
18
+ setTimeout(callback) { return { callback } },
19
+ clearTimeout(handle) { cleared.push(handle) },
20
+ setInterval(callback) { return { callback } },
21
+ clearInterval(handle) { cleared.push(handle) },
22
+ }
23
+ const timers = createRuntimeTimers(scope, api)
24
+ const repeating = timers.interval(() => ++calls, 1)
25
+ repeating.handle.callback()
26
+ assert.equal(calls, 2)
27
+ assert.equal(repeating.cancel(), true)
28
+ repeating.handle.callback()
29
+ assert.equal(calls, 2)
30
+ const pending = timers.interval(() => ++calls, 1)
31
+
32
+ assert.equal(scope.dispose("done"), true)
33
+ assert.deepEqual(events, ["last", "first"])
34
+ assert.equal(scope.signal.aborted, true)
35
+ assert.equal(scope.isActive(), false)
36
+ pending.handle.callback()
37
+ assert.equal(calls, 2)
38
+ assert.ok(cleared.includes(pending.handle))
39
+ assert.equal(timers.timeout(() => {}, 1), undefined)
40
+ assert.equal(scope.dispose(), false)
41
+ assert.equal(guarded(), undefined)
42
+
43
+ scope.track(() => events.push("late"))
44
+ assert.deepEqual(events, ["last", "first", "late"])
45
+
46
+ console.log("OpenCode runtime scope test passed")
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
3
+
4
+ let clock = 1000
5
+ const cleared = []
6
+ const timerAPI = {
7
+ setTimeout(callback) { return { callback } },
8
+ clearTimeout(handle) { cleared.push(handle) },
9
+ setInterval(callback) { return { callback } },
10
+ clearInterval(handle) { cleared.push(handle) },
11
+ }
12
+ const manager = createSessionRuntimeManager({ now: () => clock, staleAfterMs: 100, timerAPI })
13
+
14
+ const first = manager.observeExternal("ses_a")
15
+ assert.equal(manager.entries()[0].seenAt, 1000)
16
+ clock = 1050
17
+ assert.equal(manager.peek("ses_a"), first)
18
+ assert.equal(manager.entries()[0].seenAt, 1000, "reads must not refresh external activity")
19
+ assert.equal(manager.observeExternal("ses_a"), first)
20
+ assert.equal(manager.entries()[0].seenAt, 1050)
21
+
22
+ first.dispose("replace")
23
+ clock = 1060
24
+ const second = manager.observeExternal("ses_a")
25
+ assert.notEqual(second, first)
26
+ assert.equal(manager.remove("ses_a", { expectedRuntime: first }), false)
27
+ assert.equal(manager.peek("ses_a"), second)
28
+
29
+ let calls = 0
30
+ const pending = second.timers.interval(() => ++calls, 10)
31
+ clock = 1159
32
+ assert.deepEqual(manager.pruneStale(), [])
33
+ clock = 1160
34
+ assert.deepEqual(manager.pruneStale(), ["ses_a"])
35
+ assert.equal(manager.peek("ses_a"), undefined)
36
+ assert.ok(cleared.includes(pending.handle))
37
+ pending.handle.callback()
38
+ assert.equal(calls, 0)
39
+
40
+ const third = manager.observeExternal("ses_a")
41
+ assert.notEqual(third, second)
42
+ assert.equal(manager.remove("ses_a", { expectedRuntime: third, reason: "done" }), true)
43
+ assert.equal(third.scope.isActive(), false)
44
+ assert.equal(manager.dispose(), true)
45
+ assert.equal(manager.dispose(), false)
46
+ assert.throws(() => manager.observeExternal("ses_b"), /disposed/)
47
+
48
+ console.log("OpenCode session runtime manager test passed")
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
3
+
4
+ const seen = []
5
+ let listener
6
+ let unsubscribeCalls = 0
7
+
8
+ const bridge = createOpenCode2EventBridge({
9
+ directory: "/project",
10
+ onEvent: async (event, runtime) => seen.push({ event, runtime }),
11
+ })
12
+
13
+ await bridge.attach(async (callback) => {
14
+ listener = callback
15
+ return { unsubscribe: async () => { unsubscribeCalls += 1 } }
16
+ })
17
+
18
+ await listener({
19
+ directory: "/project",
20
+ payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } },
21
+ })
22
+ const first = bridge.runtimeManager.peek("ses_a")
23
+ assert.ok(first)
24
+ assert.equal(first.scope.isActive(), true)
25
+
26
+ await listener({
27
+ directory: "/project",
28
+ payload: { type: "session.status", properties: { sessionID: "ses_a", status: { type: "busy" } } },
29
+ })
30
+ assert.equal(bridge.runtimeManager.peek("ses_a"), first)
31
+
32
+ await listener({
33
+ directory: "/project",
34
+ payload: { type: "session.created", properties: { info: { id: "ses_b", directory: "/project" } } },
35
+ })
36
+ const second = bridge.runtimeManager.peek("ses_b")
37
+ assert.ok(second)
38
+ assert.notEqual(second, first)
39
+
40
+ await listener({
41
+ directory: "/other-project",
42
+ payload: { type: "session.status", properties: { sessionID: "ses_other", status: { type: "busy" } } },
43
+ })
44
+ assert.equal(bridge.runtimeManager.peek("ses_other"), undefined)
45
+
46
+ await listener({
47
+ directory: "/project",
48
+ payload: { type: "session.deleted", properties: { info: { id: "ses_a", directory: "/project" } } },
49
+ })
50
+ assert.equal(bridge.runtimeManager.peek("ses_a"), undefined)
51
+ assert.equal(first.scope.isActive(), false)
52
+ assert.equal(second.scope.isActive(), true)
53
+
54
+ await listener({
55
+ directory: "/project",
56
+ payload: { type: "server.instance.disposed", properties: { directory: "/project" } },
57
+ })
58
+ assert.equal(second.scope.isActive(), false)
59
+ assert.deepEqual(bridge.runtimeManager.entries(), [])
60
+
61
+ const seenCount = seen.length
62
+ await listener({
63
+ directory: "/project",
64
+ payload: { type: "session.status", properties: { sessionID: "ses_late", status: { type: "busy" } } },
65
+ })
66
+ assert.equal(seen.length, seenCount)
67
+
68
+ assert.equal(await bridge.dispose(), true)
69
+ assert.equal(await bridge.dispose(), false)
70
+ assert.equal(unsubscribeCalls, 1)
71
+
72
+ console.log("OpenCode 2 event bridge lifecycle passed")
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
3
+
4
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
5
+ const seen = []
6
+ let subscribeArgs = -1
7
+ let cleanupCalls = 0
8
+
9
+ async function* stream() {
10
+ yield {
11
+ directory: "/project",
12
+ payload: {
13
+ type: "session.created",
14
+ properties: { info: { id: "ses_stream", directory: "/project" } },
15
+ },
16
+ }
17
+ }
18
+
19
+ const bridge = createOpenCode2EventBridge({
20
+ directory: "/project",
21
+ onEvent: async (event) => seen.push(event),
22
+ })
23
+
24
+ await bridge.attach(async (...args) => {
25
+ subscribeArgs = args.length
26
+ return { stream: stream(), dispose: async () => { cleanupCalls += 1 } }
27
+ })
28
+
29
+ for (let attempt = 0; attempt < 50 && seen.length === 0; attempt++) await delay(5)
30
+ assert.equal(subscribeArgs, 0)
31
+ assert.equal(seen[0]?.action, "created")
32
+ assert.ok(bridge.runtimeManager.peek("ses_stream"))
33
+
34
+ assert.equal(await bridge.dispose(), true)
35
+ assert.equal(cleanupCalls, 1)
36
+ assert.equal(bridge.runtimeManager.peek("ses_stream"), undefined)
37
+
38
+ console.log("OpenCode 2 event stream contract passed")
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js"
3
+
4
+ let listener
5
+ let unsubscribeCalls = 0
6
+ const prompts = []
7
+ const events = []
8
+ const host = createOpenCode2HostContract({
9
+ directory: "/project",
10
+ subscribe: async (callback) => {
11
+ listener = callback
12
+ return { unsubscribe: async () => { unsubscribeCalls += 1 } }
13
+ },
14
+ sendPrompt: async (request) => {
15
+ prompts.push(request)
16
+ return { accepted: true }
17
+ },
18
+ onEvent: async (event, runtime) => events.push({ event, runtime }),
19
+ })
20
+
21
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/)
22
+ assert.equal(await host.start(), true)
23
+ assert.equal(await host.start(), false)
24
+ assert.equal(typeof listener, "function")
25
+
26
+ await listener({
27
+ directory: "/project",
28
+ payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } },
29
+ })
30
+ const runtime = host.runtimeManager.peek("ses_a")
31
+ assert.ok(runtime)
32
+
33
+ const result = await host.prompt({ sessionID: "ses_a", text: "continue" })
34
+ assert.deepEqual(result, { accepted: true })
35
+ assert.equal(prompts.length, 1)
36
+ assert.equal(prompts[0].sessionID, "ses_a")
37
+ assert.equal(prompts[0].text, "continue")
38
+ assert.equal(prompts[0].runtime, runtime)
39
+
40
+ await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/)
41
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/)
42
+
43
+ await listener({
44
+ directory: "/project",
45
+ payload: { type: "server.instance.disposed", properties: { directory: "/project" } },
46
+ })
47
+ assert.equal(host.isHostDisposed(), true)
48
+ assert.deepEqual(host.runtimeManager.entries(), [])
49
+ assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed"))
50
+ await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/)
51
+
52
+ assert.equal(await host.dispose(), true)
53
+ assert.equal(await host.dispose(), false)
54
+ assert.equal(unsubscribeCalls, 1)
55
+ console.log("OpenCode 2 host contract passed")
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict"
2
+ import plugin, { OPENCODE_LOOP_V2_PLUGIN_ID } from "../src/source/opencode2/experimental.js"
3
+ import {
4
+ OPENCODE_LOOP_V2_COMMAND_SOURCE,
5
+ OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
6
+ OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED,
7
+ OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS,
8
+ inspectOpenCode2CommandDraft,
9
+ inspectOpenCode2Context,
10
+ openCode2LoopRuntimeStatus,
11
+ } from "../src/source/opencode2/capabilities.js"
12
+
13
+ assert.equal(plugin.id, OPENCODE_LOOP_V2_PLUGIN_ID)
14
+ assert.equal(plugin.id, "bybrawe.opencode-loop.v2.experimental")
15
+ assert.equal(typeof plugin.setup, "function")
16
+ assert.equal(OPENCODE_LOOP_V2_COMMAND_SOURCE, "file-definitions")
17
+ assert.equal(OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED, false)
18
+ assert.deepEqual(OPENCODE_LOOP_V2_HOST_REQUIREMENTS, ["event.subscribe", "session.prompt"])
19
+ assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["event.subscribe", "session.prompt", "runtime.adapter"])
20
+
21
+ let transforms = 0
22
+ let registered
23
+ const currentContext = {
24
+ command: {
25
+ transform: async (callback) => {
26
+ transforms += 1
27
+ registered = callback
28
+ },
29
+ },
30
+ }
31
+ const setupResult = await plugin.setup(currentContext)
32
+ assert.equal(setupResult, undefined)
33
+ assert.equal(transforms, 1)
34
+ assert.equal(typeof registered, "function")
35
+
36
+ const currentDraft = {
37
+ list: () => [],
38
+ get: () => undefined,
39
+ update: () => {},
40
+ remove: () => {},
41
+ }
42
+ await registered(currentDraft)
43
+
44
+ assert.deepEqual(inspectOpenCode2Context(currentContext), {
45
+ commandTransform: true,
46
+ eventSubscribe: false,
47
+ sessionHook: false,
48
+ sessionPrompt: false,
49
+ toolTransform: false,
50
+ toolHook: false,
51
+ })
52
+ assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
53
+ list: true,
54
+ get: true,
55
+ update: true,
56
+ remove: true,
57
+ })
58
+
59
+ const currentStatus = openCode2LoopRuntimeStatus(currentContext, currentDraft)
60
+ assert.deepEqual(currentStatus.hostBlockers, ["event.subscribe", "session.prompt"])
61
+ assert.deepEqual(currentStatus.blockers, ["event.subscribe", "session.prompt", "runtime.adapter"])
62
+ assert.equal(currentStatus.hostReady, false)
63
+ assert.equal(currentStatus.implementationReady, false)
64
+ assert.equal(currentStatus.ready, false)
65
+ assert.equal(currentStatus.commandSource, "file-definitions")
66
+
67
+ const futureContext = {
68
+ command: { transform: async () => {} },
69
+ event: { subscribe: async () => {} },
70
+ session: { prompt: async () => ({}) },
71
+ }
72
+ const futureStatus = openCode2LoopRuntimeStatus(futureContext, currentDraft)
73
+ assert.deepEqual(futureStatus.hostBlockers, [])
74
+ assert.deepEqual(futureStatus.blockers, ["runtime.adapter"])
75
+ assert.equal(futureStatus.hostReady, true)
76
+ assert.equal(futureStatus.implementationReady, false)
77
+ assert.equal(futureStatus.ready, false)
78
+
79
+ let missingCapabilityFailed = false
80
+ try {
81
+ await plugin.setup({})
82
+ } catch (error) {
83
+ missingCapabilityFailed = /command\.transform capability is unavailable/.test(String(error))
84
+ }
85
+ assert.equal(missingCapabilityFailed, true)
86
+
87
+ console.log("OpenCode 2 plugin sentinel contract passed")