From 0e1a84b04823e99b0a97809b0b9a25adf11e629f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:15:06 +0300 Subject: [PATCH 1/9] chore: seed V2 host contract --- src/source/opencode2/host-contract.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/source/opencode2/host-contract.js diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js new file mode 100644 index 0000000..4f0e2bd --- /dev/null +++ b/src/source/opencode2/host-contract.js @@ -0,0 +1 @@ +export const OPENCODE_LOOP_V2_HOST_CONTRACT = "experimental" From c40474225c5ed92c97ba39f528e046ff1a88a2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:15:46 +0300 Subject: [PATCH 2/9] refactor: expand V2 host contract --- src/source/opencode2/host-contract.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js index 4f0e2bd..4aa0b48 100644 --- a/src/source/opencode2/host-contract.js +++ b/src/source/opencode2/host-contract.js @@ -1 +1,4 @@ +import { createOpenCode2EventBridge } from "./event-bridge.js" + export const OPENCODE_LOOP_V2_HOST_CONTRACT = "experimental" +export { createOpenCode2EventBridge } From bf177c763027d406bfbee720d32f162e5790d63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:16:03 +0300 Subject: [PATCH 3/9] feat: define V2 host lifecycle contract --- src/source/opencode2/host-contract.js | 35 ++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js index 4aa0b48..c8833d7 100644 --- a/src/source/opencode2/host-contract.js +++ b/src/source/opencode2/host-contract.js @@ -1,4 +1,37 @@ import { createOpenCode2EventBridge } from "./event-bridge.js" export const OPENCODE_LOOP_V2_HOST_CONTRACT = "experimental" -export { createOpenCode2EventBridge } + +export function createOpenCode2HostContract(options = {}) { + const bridge = createOpenCode2EventBridge({ + directory: options.directory, + onEvent: options.onEvent, + onError: options.onError, + }) + let started = false + let disposed = false + + async function start() { + if (disposed) throw new Error("OpenCode 2 host contract is disposed") + if (started) return false + if (typeof options.subscribe !== "function") throw new TypeError("subscribe must be a function") + await bridge.attach(options.subscribe) + started = true + return true + } + + async function dispose(reason = "host-contract-disposed") { + if (disposed) return false + disposed = true + await bridge.dispose(reason) + return true + } + + return Object.freeze({ + start, + dispose, + runtimeManager: bridge.runtimeManager, + isStarted: () => started, + isDisposed: () => disposed, + }) +} From df3824800eb0c1849f1336e4966bf1e2851fc126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:16:16 +0300 Subject: [PATCH 4/9] feat: add V2 prompt dispatch contract --- src/source/opencode2/host-contract.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js index c8833d7..6929a77 100644 --- a/src/source/opencode2/host-contract.js +++ b/src/source/opencode2/host-contract.js @@ -2,6 +2,20 @@ import { createOpenCode2EventBridge } from "./event-bridge.js" export const OPENCODE_LOOP_V2_HOST_CONTRACT = "experimental" +function normalizePrompt(input) { + const sessionID = String(input?.sessionID || "").trim() + const text = String(input?.text || "") + if (!sessionID) throw new TypeError("prompt requires a session ID") + if (!text.trim()) throw new TypeError("prompt requires text") + return Object.freeze({ + sessionID, + text, + agent: input?.agent, + model: input?.model, + noReply: input?.noReply === true, + }) +} + export function createOpenCode2HostContract(options = {}) { const bridge = createOpenCode2EventBridge({ directory: options.directory, @@ -10,16 +24,26 @@ export function createOpenCode2HostContract(options = {}) { }) let started = false let disposed = false + let hostDisposed = false async function start() { if (disposed) throw new Error("OpenCode 2 host contract is disposed") if (started) return false if (typeof options.subscribe !== "function") throw new TypeError("subscribe must be a function") + if (typeof options.sendPrompt !== "function") throw new TypeError("sendPrompt must be a function") await bridge.attach(options.subscribe) started = true return true } + async function prompt(input) { + if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable") + if (!started) throw new Error("OpenCode 2 host contract is not started") + const request = normalizePrompt(input) + const runtime = bridge.runtimeManager.observeExternal(request.sessionID) + return await options.sendPrompt(Object.freeze({ ...request, runtime })) + } + async function dispose(reason = "host-contract-disposed") { if (disposed) return false disposed = true @@ -29,9 +53,12 @@ export function createOpenCode2HostContract(options = {}) { return Object.freeze({ start, + prompt, dispose, runtimeManager: bridge.runtimeManager, + markHostDisposed: () => { hostDisposed = true }, isStarted: () => started, isDisposed: () => disposed, + isHostDisposed: () => hostDisposed, }) } From 70e1e419697e4292316040ff249ea220efbba5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:16:32 +0300 Subject: [PATCH 5/9] fix: derive V2 host disposal from event lifecycle --- src/source/opencode2/host-contract.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js index 6929a77..3173e3f 100644 --- a/src/source/opencode2/host-contract.js +++ b/src/source/opencode2/host-contract.js @@ -17,14 +17,19 @@ function normalizePrompt(input) { } export function createOpenCode2HostContract(options = {}) { + const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {} + let started = false + let disposed = false + let hostDisposed = false + const bridge = createOpenCode2EventBridge({ directory: options.directory, - onEvent: options.onEvent, onError: options.onError, + onEvent: async (event, runtime) => { + if (event.kind === "server" && event.action === "disposed") hostDisposed = true + await onEvent(event, runtime) + }, }) - let started = false - let disposed = false - let hostDisposed = false async function start() { if (disposed) throw new Error("OpenCode 2 host contract is disposed") @@ -56,7 +61,6 @@ export function createOpenCode2HostContract(options = {}) { prompt, dispose, runtimeManager: bridge.runtimeManager, - markHostDisposed: () => { hostDisposed = true }, isStarted: () => started, isDisposed: () => disposed, isHostDisposed: () => hostDisposed, From 44d16116aadbdc34fa2f5f4d102298a63dde830a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:16:55 +0300 Subject: [PATCH 6/9] chore: seed V2 host contract test --- scripts/v2-host-contract-test.mjs | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 scripts/v2-host-contract-test.mjs diff --git a/scripts/v2-host-contract-test.mjs b/scripts/v2-host-contract-test.mjs new file mode 100644 index 0000000..882e31f --- /dev/null +++ b/scripts/v2-host-contract-test.mjs @@ -0,0 +1,4 @@ +import assert from "node:assert/strict" +import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js" + +assert.equal(typeof createOpenCode2HostContract, "function") From cc8991bc28e05cce82fe258c83f0b7857f2a0b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:17:15 +0300 Subject: [PATCH 7/9] test: verify V2 host prompt contract --- scripts/v2-host-contract-test.mjs | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/v2-host-contract-test.mjs b/scripts/v2-host-contract-test.mjs index 882e31f..de05948 100644 --- a/scripts/v2-host-contract-test.mjs +++ b/scripts/v2-host-contract-test.mjs @@ -1,4 +1,38 @@ import assert from "node:assert/strict" import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js" -assert.equal(typeof createOpenCode2HostContract, "function") +let listener +const prompts = [] +const host = createOpenCode2HostContract({ + directory: "/project", + subscribe: async (callback) => { + listener = callback + return { unsubscribe: async () => {} } + }, + sendPrompt: async (request) => { + prompts.push(request) + return { accepted: true } + }, +}) + +assert.equal(await host.start(), true) +assert.equal(await host.start(), false) +assert.equal(typeof listener, "function") + +await listener({ + directory: "/project", + payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } }, +}) +const runtime = host.runtimeManager.peek("ses_a") +assert.ok(runtime) + +const result = await host.prompt({ sessionID: "ses_a", text: "continue" }) +assert.deepEqual(result, { accepted: true }) +assert.equal(prompts.length, 1) +assert.equal(prompts[0].sessionID, "ses_a") +assert.equal(prompts[0].text, "continue") +assert.equal(prompts[0].runtime, runtime) + +assert.equal(await host.dispose(), true) +assert.equal(await host.dispose(), false) +console.log("OpenCode 2 host contract passed") From 1f4a2f11afa3b255158cbe79dcc0266b1c6a442a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:17:29 +0300 Subject: [PATCH 8/9] test: cover V2 host teardown boundaries --- scripts/v2-host-contract-test.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/v2-host-contract-test.mjs b/scripts/v2-host-contract-test.mjs index de05948..f89fe0b 100644 --- a/scripts/v2-host-contract-test.mjs +++ b/scripts/v2-host-contract-test.mjs @@ -2,19 +2,23 @@ import assert from "node:assert/strict" import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js" let listener +let unsubscribeCalls = 0 const prompts = [] +const events = [] const host = createOpenCode2HostContract({ directory: "/project", subscribe: async (callback) => { listener = callback - return { unsubscribe: async () => {} } + return { unsubscribe: async () => { unsubscribeCalls += 1 } } }, sendPrompt: async (request) => { prompts.push(request) return { accepted: true } }, + onEvent: async (event, runtime) => events.push({ event, runtime }), }) +await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/) assert.equal(await host.start(), true) assert.equal(await host.start(), false) assert.equal(typeof listener, "function") @@ -33,6 +37,19 @@ assert.equal(prompts[0].sessionID, "ses_a") assert.equal(prompts[0].text, "continue") assert.equal(prompts[0].runtime, runtime) +await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/) +await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/) + +await listener({ + directory: "/project", + payload: { type: "server.instance.disposed", properties: { directory: "/project" } }, +}) +assert.equal(host.isHostDisposed(), true) +assert.deepEqual(host.runtimeManager.entries(), []) +assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed")) +await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/) + assert.equal(await host.dispose(), true) assert.equal(await host.dispose(), false) +assert.equal(unsubscribeCalls, 1) console.log("OpenCode 2 host contract passed") From 5204ce964727a08b909f74cc120a607c9b384712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 03:17:48 +0300 Subject: [PATCH 9/9] ci: verify V2 host contract --- .github/workflows/v2-lifecycle-contract.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/v2-lifecycle-contract.yml b/.github/workflows/v2-lifecycle-contract.yml index bc13910..132ecef 100644 --- a/.github/workflows/v2-lifecycle-contract.yml +++ b/.github/workflows/v2-lifecycle-contract.yml @@ -6,6 +6,7 @@ on: - "src/source/opencode2/**" - "src/source/runtime/**" - "scripts/v2-event-bridge-test.mjs" + - "scripts/v2-host-contract-test.mjs" - "scripts/v2-plugin-sentinel-test.mjs" - ".github/workflows/v2-lifecycle-contract.yml" push: @@ -14,6 +15,7 @@ on: - "src/source/opencode2/**" - "src/source/runtime/**" - "scripts/v2-event-bridge-test.mjs" + - "scripts/v2-host-contract-test.mjs" - "scripts/v2-plugin-sentinel-test.mjs" - ".github/workflows/v2-lifecycle-contract.yml" @@ -38,7 +40,10 @@ jobs: cache: npm - run: npm ci - run: node --check src/source/opencode2/event-bridge.js + - run: node --check src/source/opencode2/host-contract.js - run: node --check src/source/opencode2/experimental.js - run: node --check scripts/v2-event-bridge-test.mjs + - run: node --check scripts/v2-host-contract-test.mjs - run: node scripts/v2-event-bridge-test.mjs + - run: node scripts/v2-host-contract-test.mjs - run: node scripts/v2-plugin-sentinel-test.mjs