From 9b9ff7e63f82439cdb59d183a5ff6cf8e3c1f146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:22:29 +0300 Subject: [PATCH 01/30] fix(v2): probe built-in OpenCode Goal ownership via read-only RPC (#195) --- src/source/opencode2/native-companion.js | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/source/opencode2/native-companion.js b/src/source/opencode2/native-companion.js index 23b07667..795b90f7 100644 --- a/src/source/opencode2/native-companion.js +++ b/src/source/opencode2/native-companion.js @@ -22,3 +22,55 @@ export async function nativeGoalReservesSession(directory, sessionID) { return error?.code !== "ENOENT" } } + +// This is OpenCode's BUILT-IN Goal contract, not ByBrawe Goal's filesystem +// contract. Cross-plugin storage is private. Use the public, read-only RPC. +export const nativeGoalRPC = Object.freeze({ + id: "opencode.goal", + methods: { + get: { + input: { + type: "object", + properties: { sessionID: { type: "string" } }, + required: ["sessionID"], + additionalProperties: false, + }, + output: { anyOf: [{ type: "null" }, { type: "object" }] }, + }, + }, + events: {}, +}) + +function expectsBuiltInGoal(version) { + const match = String(version || "").match(/(?:^|[^0-9])(\d+)\.(\d+)\.(\d+)(?:$|[^0-9])/) + if (!match) return false + const major = Number(match[1]), minor = Number(match[2]), patch = Number(match[3]) + return major > 2 || (major === 2 && (minor > 2 || (minor === 2 && patch >= 1))) +} + +export async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { + // OpenCode 2.0/2.1 never shipped built-in Goal. A modern host missing the + // public RPC cannot safely prove that no native Goal owns this session. + if (typeof ctx?.rpc !== "function") return expectsBuiltInGoal(ctx?.app?.version) + try { + const goal = await ctx.rpc(nativeGoalRPC).get({ sessionID }) + if (goal === null) return false + if (!goal || typeof goal !== "object" || Array.isArray(goal)) return true + // Paused Goals still reserve the session; only an explicit completion + // releases it to Loop. An unknown state must never authorize takeover. + return goal.status !== "complete" + } catch (error) { + const type = error?.type || error?.data?.type || error?.error?.type + if (type !== "rpc.unavailable") return true + // The host may intentionally disable its built-in Goal. In that case + // the RPC is absent, and Loop is allowed to run normally. + if (typeof ctx?.plugin?.list !== "function") return expectsBuiltInGoal(ctx?.app?.version) + try { + const plugins = await ctx.plugin.list() + if (!Array.isArray(plugins)) return true + return plugins.some((plugin) => plugin?.id === "opencode.goal") + } catch { + return true + } + } +} From c61bddc2cae2ebd5432695e43a043b0d983da34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:22:59 +0300 Subject: [PATCH 02/30] fix(v2): gate every Loop admission on native and ByBrawe Goal session ownership (#195) --- src/source/opencode2/native-runtime.js | 39 ++++++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/source/opencode2/native-runtime.js b/src/source/opencode2/native-runtime.js index d443d699..e7153f58 100644 --- a/src/source/opencode2/native-runtime.js +++ b/src/source/opencode2/native-runtime.js @@ -35,6 +35,27 @@ export function createNativeLoopRuntime(options = {}) { const read = (scope) => readState(scope.directory, scope.sessionID) const save = (scope, state) => writeState(scope.directory, scope.sessionID, state) const report = (error) => { try { options.onError?.(error) } catch {} } + async function goalReserves(scope) { + try { + if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) return true + return typeof options.nativeGoalReservesSession === "function" + ? Boolean(await options.nativeGoalReservesSession(scope.sessionID)) + : false + } catch (error) { + // An uncertain ownership query is not permission to enqueue a turn. + report(error) + return true + } + } + function deferForGoal(scope) { + clear(scope, "companionTimer") + if (!current(scope)) return + scope.companionTimer = setTimer(() => { + delete scope.companionTimer + return enqueue(scope, () => advance(scope)).catch(report) + }, 1000) + scope.companionTimer?.unref?.() + } function enqueue(scope, task) { const pending = scope.queue.catch(() => {}).then(async () => { if (!current(scope)) return result({ reason: "disposed" }) @@ -170,6 +191,12 @@ export function createNativeLoopRuntime(options = {}) { request = { sessionID: scope.sessionID, command: name, arguments: text || "", delivery: "queue" } } if (!isCurrent()) return result({ reason: "foreground-or-compaction" }) + // Recheck after asynchronous prompt construction and preflight. A native + // /goal may have started while Loop was preparing this dispatch. + if (await goalReserves(scope)) { + deferForGoal(scope) + return result({ reason: "goal-owns-session" }) + } if (job.dryRun) { policy.pause(job, "dry-run") await save(scope, state) @@ -248,15 +275,9 @@ export function createNativeLoopRuntime(options = {}) { const state = await read(scope) clear(scope, "companionTimer") if (!(state.jobs || []).some(eligible)) return result() - if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) { - if (current(scope)) { - scope.companionTimer = setTimer(() => { - delete scope.companionTimer - return enqueue(scope, () => advance(scope)).catch(report) - }, 1000) - scope.companionTimer?.unref?.() - } - return result({ reason: "dedicated-goal-owns-session" }) + if (await goalReserves(scope)) { + deferForGoal(scope) + return result({ reason: "goal-owns-session" }) } for (const job of state.jobs || []) { if (!eligible(job)) continue From 0f66fa43566a8fbada83ee87369b4ecc2b37bf13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:23:08 +0300 Subject: [PATCH 03/30] fix(v2): wire native OpenCode Goal RPC into standalone Loop setup (#195) --- src/source/opencode2/native-plugin.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/source/opencode2/native-plugin.js b/src/source/opencode2/native-plugin.js index f81830ba..1be70b44 100644 --- a/src/source/opencode2/native-plugin.js +++ b/src/source/opencode2/native-plugin.js @@ -2,6 +2,7 @@ import path from "node:path" import { createNativeSessionLocationGuard } from "./session-location.js" import { createNativeShellHost } from "./native-shell.js" import { createNativeLoopRuntime } from "./native-runtime.js" +import { nativeOpenCodeGoalReservesSession } from "./native-companion.js" import { createOpenCode2EventBridge } from "./event-bridge.js" import { registerOpenCode2LoopCommands, OPENCODE_LOOP_V2_PRESET_NAMES, commandArguments } from "./commands.js" import { registerLoopStatusRpc } from "./status-rpc.js" @@ -26,11 +27,11 @@ Preflight, postrun, notifications, stop files, completion markers, runtime/failu Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it. Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method. Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned. -An unfinished dedicated Goal reserves its session; Loop will not override it. +An unfinished native OpenCode /goal or separately installed ByBrawe Goal reserves its session; Loop will not override it. --timeout pauses future iterations without aborting the current native model/tool/compaction operation. --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks. Native sessions stay serialized; --allow-overlap is rejected. -Use @bybrawe/opencode-goal and /goal for native Goal contracts. Legacy Loop Goal state is not silently migrated. +OpenCode 2.2.x provides native /goal; ByBrawe Goal is optional and separately managed. Legacy Loop Goal state is not silently migrated. An uncertain admission after a restart is paused for review instead of being replayed automatically.` async function cleanup(value) { @@ -79,6 +80,7 @@ export const OpenCodeLoopNativePlugin = Object.freeze({ const runtime = createNativeLoopRuntime({ directory, scopeAllowed: (scope) => ownsSession(scope.sessionID), + nativeGoalReservesSession: (sessionID) => nativeOpenCodeGoalReservesSession(ctx, sessionID), prompt, command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined, wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined, From f0505ecebd6a3033ca8b4648aad32b4148024cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:23:33 +0300 Subject: [PATCH 04/30] test(v2): native Goal RPC ownership, disabled host, pause and admission race (#195) --- scripts/native-coexistence-test.mjs | 70 ++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/scripts/native-coexistence-test.mjs b/scripts/native-coexistence-test.mjs index 27d0abde..694cd07c 100644 --- a/scripts/native-coexistence-test.mjs +++ b/scripts/native-coexistence-test.mjs @@ -3,7 +3,7 @@ import { createHash } from "node:crypto" import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises" import path from "node:path" import os from "node:os" -import { nativeGoalReservesSession } from "../src/source/opencode2/native-companion.js" +import { nativeGoalReservesSession, nativeOpenCodeGoalReservesSession, nativeGoalRPC } from "../src/source/opencode2/native-companion.js" import { createNativeLoopRuntime } from "../src/source/opencode2/native-runtime.js" const directory = await mkdtemp(path.join(os.tmpdir(), "native-coexist-")) @@ -31,6 +31,74 @@ try { await runtime.wake(scope) assert.equal(prompts, 1, "completed Goal can release continuation to an explicitly configured Loop") assert.equal(await nativeGoalReservesSession(directory, "another-session"), false) + + const nativeSessionID = "native-built-in-goal-session" + let nativeStatus = "active" + let nativeReads = 0 + const rpc = (definition) => { + assert.equal(definition.id, "opencode.goal") + assert.equal(definition.methods.get.input.properties.sessionID.type, "string") + return { + get: async (input) => { + assert.equal(input.sessionID, nativeSessionID) + nativeReads++ + return nativeStatus === null ? null : { id: "native-goal", status: nativeStatus } + }, + } + } + const ctx = { app: { version: "2.2.4" }, rpc } + assert.equal(nativeGoalRPC.id, "opencode.goal") + for (const [status, expected] of [[null, false], ["active", true], ["paused", true], ["complete", false], ["invalid", true]]) { + nativeStatus = status + assert.equal(await nativeOpenCodeGoalReservesSession(ctx, nativeSessionID), expected, String(status)) + } + assert.equal(nativeReads, 5) + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.0.18" } }, nativeSessionID), false, "old V2 hosts never had native Goal") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" } }, nativeSessionID), true, "modern host without Goal RPC must fail closed") + const unavailable = () => ({ get: async () => { throw { type: "rpc.unavailable" } } }) + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => [] } }, nativeSessionID), false, "disabled native Goal should not block Loop") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => [{ id: "opencode.goal" }] } }, nativeSessionID), true, "Goal registered but RPC unavailable must block") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => { throw Error("offline") } } }, nativeSessionID), true, "unverified Goal state fails closed") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: () => ({ get: async () => { throw Error("transient error") } }) }, nativeSessionID), true) + + let nativePrompts = 0 + const nativeScope = { directory, sessionID: nativeSessionID } + const nativeLoop = createNativeLoopRuntime({ + directory, + nativeGoalReservesSession: (id) => nativeOpenCodeGoalReservesSession(ctx, id), + prompt: async (request) => { nativePrompts++; return { id: request.id } }, + }) + try { + nativeStatus = "active" + await nativeLoop.onEvent({ ...nativeScope, kind: "command", action: "executed", name: "loop", arguments: "0s separate task --max-runs 1" }) + await nativeLoop.wake(nativeScope) + assert.equal(nativePrompts, 0, "active built-in Goal must stop Loop turn admission") + nativeStatus = "paused" + await nativeLoop.wake(nativeScope) + assert.equal(nativePrompts, 0, "paused built-in Goal still reserves the session") + nativeStatus = "complete" + await nativeLoop.wake(nativeScope) + assert.equal(nativePrompts, 1, "completed native Goal releases independently scheduled Loop") + } finally { + await nativeLoop.dispose() + } + + let checks = 0 + let racedPrompts = 0 + const racedSessionID = "native-goal-race-session" + const raced = createNativeLoopRuntime({ + directory, + nativeGoalReservesSession: async () => ++checks >= 2, + prompt: async (request) => { racedPrompts++; return { id: request.id } }, + }) + try { + await raced.onEvent({ directory, sessionID: racedSessionID, kind: "command", action: "executed", name: "loop", arguments: "0s task --max-runs 1" }) + await raced.wake({ directory, sessionID: racedSessionID }) + assert.ok(checks >= 2, "ownership must be rechecked just before dispatch") + assert.equal(racedPrompts, 0, "a newly armed native Goal must win the last admission check") + } finally { + await raced.dispose() + } console.log("Native Goal/Loop ownership tests passed") } finally { await runtime.dispose() From 658bf96310c74b9e2490af5fa3fdfd88d64ab52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:25:28 +0300 Subject: [PATCH 05/30] build(v2): sync deterministic Bun src/native.js bundle for native Goal ownership (#195) --- src/native.js | 89 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/src/native.js b/src/native.js index 21554799..6acf7b8f 100644 --- a/src/native.js +++ b/src/native.js @@ -1551,6 +1551,54 @@ async function nativeGoalReservesSession(directory, sessionID) { return error?.code !== "ENOENT"; } } +var nativeGoalRPC = Object.freeze({ + id: "opencode.goal", + methods: { + get: { + input: { + type: "object", + properties: { sessionID: { type: "string" } }, + required: ["sessionID"], + additionalProperties: false + }, + output: { anyOf: [{ type: "null" }, { type: "object" }] } + } + }, + events: {} +}); +function expectsBuiltInGoal(version) { + const match = String(version || "").match(/(?:^|[^0-9])(\d+)\.(\d+)\.(\d+)(?:$|[^0-9])/); + if (!match) + return false; + const major = Number(match[1]), minor = Number(match[2]), patch = Number(match[3]); + return major > 2 || major === 2 && (minor > 2 || minor === 2 && patch >= 1); +} +async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { + if (typeof ctx?.rpc !== "function") + return expectsBuiltInGoal(ctx?.app?.version); + try { + const goal = await ctx.rpc(nativeGoalRPC).get({ sessionID }); + if (goal === null) + return false; + if (!goal || typeof goal !== "object" || Array.isArray(goal)) + return true; + return goal.status !== "complete"; + } catch (error) { + const type = error?.type || error?.data?.type || error?.error?.type; + if (type !== "rpc.unavailable") + return true; + if (typeof ctx?.plugin?.list !== "function") + return expectsBuiltInGoal(ctx?.app?.version); + try { + const plugins = await ctx.plugin.list(); + if (!Array.isArray(plugins)) + return true; + return plugins.some((plugin) => plugin?.id === "opencode.goal"); + } catch { + return true; + } + } +} // src/source/opencode2/native-runtime.js init_jobs(); @@ -2100,6 +2148,26 @@ function createNativeLoopRuntime(options = {}) { options.onError?.(error); } catch {} }; + async function goalReserves(scope) { + try { + if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) + return true; + return typeof options.nativeGoalReservesSession === "function" ? Boolean(await options.nativeGoalReservesSession(scope.sessionID)) : false; + } catch (error) { + report(error); + return true; + } + } + function deferForGoal(scope) { + clear(scope, "companionTimer"); + if (!current(scope)) + return; + scope.companionTimer = setTimer(() => { + delete scope.companionTimer; + return enqueue(scope, () => advance(scope)).catch(report); + }, 1000); + scope.companionTimer?.unref?.(); + } function enqueue(scope, task) { const pending = scope.queue.catch(() => {}).then(async () => { if (!current(scope)) @@ -2265,6 +2333,10 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata: } if (!isCurrent()) return result({ reason: "foreground-or-compaction" }); + if (await goalReserves(scope)) { + deferForGoal(scope); + return result({ reason: "goal-owns-session" }); + } if (job.dryRun) { policy.pause(job, "dry-run"); await save(scope, state); @@ -2358,15 +2430,9 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata: clear(scope, "companionTimer"); if (!(state.jobs || []).some(eligible)) return result(); - if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) { - if (current(scope)) { - scope.companionTimer = setTimer(() => { - delete scope.companionTimer; - return enqueue(scope, () => advance(scope)).catch(report); - }, 1000); - scope.companionTimer?.unref?.(); - } - return result({ reason: "dedicated-goal-owns-session" }); + if (await goalReserves(scope)) { + deferForGoal(scope); + return result({ reason: "goal-owns-session" }); } for (const job of state.jobs || []) { if (!eligible(job)) @@ -3654,11 +3720,11 @@ Preflight, postrun, notifications, stop files, completion markers, runtime/failu Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it. Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method. Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned. -An unfinished dedicated Goal reserves its session; Loop will not override it. +An unfinished native OpenCode /goal or separately installed ByBrawe Goal reserves its session; Loop will not override it. --timeout pauses future iterations without aborting the current native model/tool/compaction operation. --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks. Native sessions stay serialized; --allow-overlap is rejected. -Use @bybrawe/opencode-goal and /goal for native Goal contracts. Legacy Loop Goal state is not silently migrated. +OpenCode 2.2.x provides native /goal; ByBrawe Goal is optional and separately managed. Legacy Loop Goal state is not silently migrated. An uncertain admission after a restart is paused for review instead of being replayed automatically.`; async function cleanup(value) { if (typeof value === "function") @@ -3711,6 +3777,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({ const runtime = createNativeLoopRuntime({ directory, scopeAllowed: (scope) => ownsSession(scope.sessionID), + nativeGoalReservesSession: (sessionID) => nativeOpenCodeGoalReservesSession(ctx, sessionID), prompt, command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined, wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined, From 62d81627fb7b3cb4ba06befbbb29775a9ea97056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:25:32 +0300 Subject: [PATCH 06/30] build(v2): sync deterministic Bun src/server.js bundle for native Goal ownership (#195) --- src/server.js | 89 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/src/server.js b/src/server.js index 1e08cb71..e93a0639 100644 --- a/src/server.js +++ b/src/server.js @@ -1551,6 +1551,54 @@ async function nativeGoalReservesSession(directory, sessionID) { return error?.code !== "ENOENT"; } } +var nativeGoalRPC = Object.freeze({ + id: "opencode.goal", + methods: { + get: { + input: { + type: "object", + properties: { sessionID: { type: "string" } }, + required: ["sessionID"], + additionalProperties: false + }, + output: { anyOf: [{ type: "null" }, { type: "object" }] } + } + }, + events: {} +}); +function expectsBuiltInGoal(version) { + const match = String(version || "").match(/(?:^|[^0-9])(\d+)\.(\d+)\.(\d+)(?:$|[^0-9])/); + if (!match) + return false; + const major = Number(match[1]), minor = Number(match[2]), patch = Number(match[3]); + return major > 2 || major === 2 && (minor > 2 || minor === 2 && patch >= 1); +} +async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { + if (typeof ctx?.rpc !== "function") + return expectsBuiltInGoal(ctx?.app?.version); + try { + const goal = await ctx.rpc(nativeGoalRPC).get({ sessionID }); + if (goal === null) + return false; + if (!goal || typeof goal !== "object" || Array.isArray(goal)) + return true; + return goal.status !== "complete"; + } catch (error) { + const type = error?.type || error?.data?.type || error?.error?.type; + if (type !== "rpc.unavailable") + return true; + if (typeof ctx?.plugin?.list !== "function") + return expectsBuiltInGoal(ctx?.app?.version); + try { + const plugins = await ctx.plugin.list(); + if (!Array.isArray(plugins)) + return true; + return plugins.some((plugin) => plugin?.id === "opencode.goal"); + } catch { + return true; + } + } +} // src/source/opencode2/native-runtime.js init_jobs(); @@ -2100,6 +2148,26 @@ function createNativeLoopRuntime(options = {}) { options.onError?.(error); } catch {} }; + async function goalReserves(scope) { + try { + if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) + return true; + return typeof options.nativeGoalReservesSession === "function" ? Boolean(await options.nativeGoalReservesSession(scope.sessionID)) : false; + } catch (error) { + report(error); + return true; + } + } + function deferForGoal(scope) { + clear(scope, "companionTimer"); + if (!current(scope)) + return; + scope.companionTimer = setTimer(() => { + delete scope.companionTimer; + return enqueue(scope, () => advance(scope)).catch(report); + }, 1000); + scope.companionTimer?.unref?.(); + } function enqueue(scope, task) { const pending = scope.queue.catch(() => {}).then(async () => { if (!current(scope)) @@ -2265,6 +2333,10 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata: } if (!isCurrent()) return result({ reason: "foreground-or-compaction" }); + if (await goalReserves(scope)) { + deferForGoal(scope); + return result({ reason: "goal-owns-session" }); + } if (job.dryRun) { policy.pause(job, "dry-run"); await save(scope, state); @@ -2358,15 +2430,9 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata: clear(scope, "companionTimer"); if (!(state.jobs || []).some(eligible)) return result(); - if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) { - if (current(scope)) { - scope.companionTimer = setTimer(() => { - delete scope.companionTimer; - return enqueue(scope, () => advance(scope)).catch(report); - }, 1000); - scope.companionTimer?.unref?.(); - } - return result({ reason: "dedicated-goal-owns-session" }); + if (await goalReserves(scope)) { + deferForGoal(scope); + return result({ reason: "goal-owns-session" }); } for (const job of state.jobs || []) { if (!eligible(job)) @@ -3654,11 +3720,11 @@ Preflight, postrun, notifications, stop files, completion markers, runtime/failu Invoke /loop-stop or /loop-clear as native USER commands. Having an assistant print the slash command does not execute it. Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method. Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned. -An unfinished dedicated Goal reserves its session; Loop will not override it. +An unfinished native OpenCode /goal or separately installed ByBrawe Goal reserves its session; Loop will not override it. --timeout pauses future iterations without aborting the current native model/tool/compaction operation. --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks. Native sessions stay serialized; --allow-overlap is rejected. -Use @bybrawe/opencode-goal and /goal for native Goal contracts. Legacy Loop Goal state is not silently migrated. +OpenCode 2.2.x provides native /goal; ByBrawe Goal is optional and separately managed. Legacy Loop Goal state is not silently migrated. An uncertain admission after a restart is paused for review instead of being replayed automatically.`; async function cleanup(value) { if (typeof value === "function") @@ -3711,6 +3777,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({ const runtime = createNativeLoopRuntime({ directory, scopeAllowed: (scope) => ownsSession(scope.sessionID), + nativeGoalReservesSession: (sessionID) => nativeOpenCodeGoalReservesSession(ctx, sessionID), prompt, command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined, wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined, From c572892ce2475db99f78101ec5872cd072fac5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:28:06 +0300 Subject: [PATCH 07/30] fix(v2): ignore orphaned ByBrawe Goal files after verified standalone uninstall (#195) --- src/source/opencode2/native-companion.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/source/opencode2/native-companion.js b/src/source/opencode2/native-companion.js index 795b90f7..e27b08fb 100644 --- a/src/source/opencode2/native-companion.js +++ b/src/source/opencode2/native-companion.js @@ -74,3 +74,21 @@ export async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { } } } + +export async function byBraweGoalPluginInstalled(ctx) { + // Old hosts lack a plugin inventory; retain the conservative filesystem + // reservation there. A known-empty modern inventory makes orphaned files + // harmless after the separate Goal plugin has been uninstalled. + if (typeof ctx?.plugin?.list !== "function") return true + try { + const plugins = await ctx.plugin.list() + if (!Array.isArray(plugins)) return true + return plugins.some((plugin) => + plugin?.id === "@bybrawe/opencode-goal" || + plugin?.source?.target === "@bybrawe/opencode-goal" || + String(plugin?.source?.target || "").startsWith("@bybrawe/opencode-goal@")) + } catch { + // An unreadable inventory is not proof of a clean removal. + return true + } +} From 0adbd18f6f6f520fa23641fc595ae959c3e783d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:28:17 +0300 Subject: [PATCH 08/30] fix(v2): only reserve legacy Goal sessions while its plugin is installed (#195) --- src/source/opencode2/native-runtime.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/source/opencode2/native-runtime.js b/src/source/opencode2/native-runtime.js index e7153f58..2c8258a8 100644 --- a/src/source/opencode2/native-runtime.js +++ b/src/source/opencode2/native-runtime.js @@ -37,7 +37,10 @@ export function createNativeLoopRuntime(options = {}) { const report = (error) => { try { options.onError?.(error) } catch {} } async function goalReserves(scope) { try { - if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) return true + // A removed third-party Goal must not reserve sessions forever through + // orphaned .opencode/goals files. Unknown inventories keep the old guard. + if ((typeof options.dedicatedGoalInstalled !== "function" || await options.dedicatedGoalInstalled()) && + await nativeGoalReservesSession(scope.directory, scope.sessionID)) return true return typeof options.nativeGoalReservesSession === "function" ? Boolean(await options.nativeGoalReservesSession(scope.sessionID)) : false From 6f6be7c12181c6b99a03d082ff6fbe5aed798ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:28:21 +0300 Subject: [PATCH 09/30] fix(v2): only reserve legacy Goal sessions while its plugin is installed (#195) --- src/source/opencode2/native-plugin.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/source/opencode2/native-plugin.js b/src/source/opencode2/native-plugin.js index 1be70b44..b3a5aca0 100644 --- a/src/source/opencode2/native-plugin.js +++ b/src/source/opencode2/native-plugin.js @@ -2,7 +2,7 @@ import path from "node:path" import { createNativeSessionLocationGuard } from "./session-location.js" import { createNativeShellHost } from "./native-shell.js" import { createNativeLoopRuntime } from "./native-runtime.js" -import { nativeOpenCodeGoalReservesSession } from "./native-companion.js" +import { nativeOpenCodeGoalReservesSession, byBraweGoalPluginInstalled } from "./native-companion.js" import { createOpenCode2EventBridge } from "./event-bridge.js" import { registerOpenCode2LoopCommands, OPENCODE_LOOP_V2_PRESET_NAMES, commandArguments } from "./commands.js" import { registerLoopStatusRpc } from "./status-rpc.js" @@ -81,6 +81,7 @@ export const OpenCodeLoopNativePlugin = Object.freeze({ directory, scopeAllowed: (scope) => ownsSession(scope.sessionID), nativeGoalReservesSession: (sessionID) => nativeOpenCodeGoalReservesSession(ctx, sessionID), + dedicatedGoalInstalled: () => byBraweGoalPluginInstalled(ctx), prompt, command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined, wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined, From 271617492ea92f86d68b38b94ca5b3fc983d516a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:28:36 +0300 Subject: [PATCH 10/30] test(v2): removed Goal's orphaned shard cannot block standalone Loop (#195) --- scripts/native-coexistence-test.mjs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/native-coexistence-test.mjs b/scripts/native-coexistence-test.mjs index 694cd07c..dd66415f 100644 --- a/scripts/native-coexistence-test.mjs +++ b/scripts/native-coexistence-test.mjs @@ -3,7 +3,7 @@ import { createHash } from "node:crypto" import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises" import path from "node:path" import os from "node:os" -import { nativeGoalReservesSession, nativeOpenCodeGoalReservesSession, nativeGoalRPC } from "../src/source/opencode2/native-companion.js" +import { nativeGoalReservesSession, nativeOpenCodeGoalReservesSession, byBraweGoalPluginInstalled, nativeGoalRPC } from "../src/source/opencode2/native-companion.js" import { createNativeLoopRuntime } from "../src/source/opencode2/native-runtime.js" const directory = await mkdtemp(path.join(os.tmpdir(), "native-coexist-")) @@ -83,6 +83,31 @@ try { await nativeLoop.dispose() } + const absentGoal = { plugin: { list: async () => [] } } + assert.equal(await byBraweGoalPluginInstalled(absentGoal), false, "standalone Loop has no separate Goal plugin") + assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => [{ id: "@bybrawe/opencode-goal" }] } }), true) + assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => [{ source: { target: "@bybrawe/opencode-goal@1.3.49" } }] } }), true) + assert.equal(await byBraweGoalPluginInstalled({}), true, "older hosts retain conservative disk guard") + assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => { throw Error("catalog offline") } } }), true) + + const orphanSessionID = "orphaned-bybrawe-goal-shard" + const orphanPath = path.join(root, `${createHash("sha256").update(orphanSessionID).digest("hex").slice(0, 32)}.json`) + await writeFile(orphanPath, JSON.stringify({ schemaVersion: 1, id: "retired-goal", sessionID: orphanSessionID, status: "paused" })) + let orphanPrompts = 0 + const orphan = createNativeLoopRuntime({ + directory, + dedicatedGoalInstalled: () => byBraweGoalPluginInstalled(absentGoal), + nativeGoalReservesSession: async () => false, + prompt: async (request) => { orphanPrompts++; return { id: request.id } }, + }) + try { + await orphan.onEvent({ directory, sessionID: orphanSessionID, kind: "command", action: "executed", name: "loop", arguments: "0s independent task --max-runs 1" }) + await orphan.wake({ directory, sessionID: orphanSessionID }) + assert.equal(orphanPrompts, 1, "a removed Goal must not block independent Loop via orphaned state") + } finally { + await orphan.dispose() + } + let checks = 0 let racedPrompts = 0 const racedSessionID = "native-goal-race-session" From db814988a3d40e6f27a7a4c57052bdb67e31b5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:29:41 +0300 Subject: [PATCH 11/30] build(v2): update reproducible native Goal ownership bundles (#195) --- src/native.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/native.js b/src/native.js index 6acf7b8f..733d6b04 100644 --- a/src/native.js +++ b/src/native.js @@ -1599,6 +1599,18 @@ async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { } } } +async function byBraweGoalPluginInstalled(ctx) { + if (typeof ctx?.plugin?.list !== "function") + return true; + try { + const plugins = await ctx.plugin.list(); + if (!Array.isArray(plugins)) + return true; + return plugins.some((plugin) => plugin?.id === "@bybrawe/opencode-goal" || plugin?.source?.target === "@bybrawe/opencode-goal" || String(plugin?.source?.target || "").startsWith("@bybrawe/opencode-goal@")); + } catch { + return true; + } +} // src/source/opencode2/native-runtime.js init_jobs(); @@ -2150,7 +2162,7 @@ function createNativeLoopRuntime(options = {}) { }; async function goalReserves(scope) { try { - if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) + if ((typeof options.dedicatedGoalInstalled !== "function" || await options.dedicatedGoalInstalled()) && await nativeGoalReservesSession(scope.directory, scope.sessionID)) return true; return typeof options.nativeGoalReservesSession === "function" ? Boolean(await options.nativeGoalReservesSession(scope.sessionID)) : false; } catch (error) { @@ -3778,6 +3790,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({ directory, scopeAllowed: (scope) => ownsSession(scope.sessionID), nativeGoalReservesSession: (sessionID) => nativeOpenCodeGoalReservesSession(ctx, sessionID), + dedicatedGoalInstalled: () => byBraweGoalPluginInstalled(ctx), prompt, command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined, wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined, From dfb33b0b51edfd9d3c1aa4fca679d3986a6dd9a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:29:45 +0300 Subject: [PATCH 12/30] build(v2): update reproducible native Goal ownership bundles (#195) --- src/server.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server.js b/src/server.js index e93a0639..6941b20b 100644 --- a/src/server.js +++ b/src/server.js @@ -1599,6 +1599,18 @@ async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { } } } +async function byBraweGoalPluginInstalled(ctx) { + if (typeof ctx?.plugin?.list !== "function") + return true; + try { + const plugins = await ctx.plugin.list(); + if (!Array.isArray(plugins)) + return true; + return plugins.some((plugin) => plugin?.id === "@bybrawe/opencode-goal" || plugin?.source?.target === "@bybrawe/opencode-goal" || String(plugin?.source?.target || "").startsWith("@bybrawe/opencode-goal@")); + } catch { + return true; + } +} // src/source/opencode2/native-runtime.js init_jobs(); @@ -2150,7 +2162,7 @@ function createNativeLoopRuntime(options = {}) { }; async function goalReserves(scope) { try { - if (await nativeGoalReservesSession(scope.directory, scope.sessionID)) + if ((typeof options.dedicatedGoalInstalled !== "function" || await options.dedicatedGoalInstalled()) && await nativeGoalReservesSession(scope.directory, scope.sessionID)) return true; return typeof options.nativeGoalReservesSession === "function" ? Boolean(await options.nativeGoalReservesSession(scope.sessionID)) : false; } catch (error) { @@ -3778,6 +3790,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({ directory, scopeAllowed: (scope) => ownsSession(scope.sessionID), nativeGoalReservesSession: (sessionID) => nativeOpenCodeGoalReservesSession(ctx, sessionID), + dedicatedGoalInstalled: () => byBraweGoalPluginInstalled(ctx), prompt, command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined, wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined, From 4070bf5ff49e5c3ce9d57d0a954a66d7f2f05449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:29:59 +0300 Subject: [PATCH 13/30] docs(v2): document read-only built-in Goal ownership and standalone orphan cleanup --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 32927546..50a96692 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ For work that must continue after the TUI/session closes, use `opencode-loopd`. ## Loop and optional Goal features -Loop works by itself. It does not install or manage other Goal plugins. **Do not run OpenCode 2.2.x's built-in `/goal` and a prompt-producing `/loop` in the same session yet.** The existing Loop reservation guard covers the separately installed ByBrawe Goal's on-disk state, **not** native OpenCode Goal's private storage/RPC. Native Goal overlap protection is tracked in [#195](https://github.com/ByBrawe/opencode-loop/issues/195). Recommended split: +Loop works by itself. It does not install or manage other Goal plugins. **Use separate sessions for native `/goal` and prompt-producing `/loop` whenever practical.** On OpenCode 2.2.x, Loop checks the built-in Goal's **public, read-only `opencode.goal` RPC** before admitting an autonomous turn and again immediately before dispatch; an active/paused native Goal reserves the session. A separately installed ByBrawe Goal keeps its own guard, but orphaned ByBrawe Goal files no longer block Loop when that package is confirmed uninstalled. Unavailable/unrecognized ownership states fail closed. This is a safety guard, not a claim that two independently scheduled agents can run atomically without races; host-level integration remains tracked in [#195](https://github.com/ByBrawe/opencode-loop/issues/195). Recommended split: - use OpenCode's native **`/goal`** for a built-in objective workflow, or install ByBrawe Goal separately if its stronger evidence contracts are needed; - use **`/loop`**, `/loop-command`, `/loop-shell`, and `opencode-loopd` for scheduling/repetition/background infrastructure. From 939926b13804c0212adcb79c584b9b9c51313f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:31:42 +0300 Subject: [PATCH 14/30] release(loop): authorize independently verifiable v0.6.10 (package.json) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 94f01d96..c8f96848 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bybrawe/opencode-loop", - "version": "0.6.9", + "version": "0.6.10", "description": "Idle-safe /loop scheduling and background automation for OpenCode; standalone installation without Goal package dependencies.", "type": "module", "main": "src/plugin.js", From 0688a7db3c43fe27b66c182b9caf6ba805529919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:32:26 +0300 Subject: [PATCH 15/30] release: request independent Loop 0.6.10 --- .github/npm-release.json | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/npm-release.json b/.github/npm-release.json index 81e382a5..c3b22561 100644 --- a/.github/npm-release.json +++ b/.github/npm-release.json @@ -1,11 +1,7 @@ { "name": "@bybrawe/opencode-loop", - "version": "0.6.9", - "predecessor": "0.6.8", - "goal": { - "version": "1.3.49", - "sha": "0c834b06843d4f55aa3c6a1489d992adc70a5be4" - }, - "requestedAt": "2026-10-27", - "reason": "Host-verified Markdown checklist completion on native OpenCode 2 and V1 compatibility, source/build parity, Goal 1.3.49 independently installed, strict Windows/Linux and real-host consumer gates. Preserve existing unlimited defaults, completion evidence integrity, plugin ownership and Desktop GUI uncertainty. Verified Windows strict package consumer retry on a fresh tree after ETIMEDOUT (#189) and bounded async stop-file test (#188) are on main; release remains blocked unless all exact-main gates succeed. Experimental @zhek TUI sidebar remains in PR #190 for the next release." + "version": "0.6.10", + "predecessor": "0.6.9", + "requestedAt": "2026-10-30", + "reason": "Final standalone Loop stability release after #194, #195/#196, with native read-only TUI contributed by @zhek (#165/#190). Keep Goal separately managed; require source/bundle parity, exact-main CI, Windows/Linux/macOS and clean npm consumer gates. Windows Desktop GUI and remote split-cache TUI discovery remain unverified (#169/#191)." } From 14011f77424aa0c00c203fcb83afba2aa0063bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:32:35 +0300 Subject: [PATCH 16/30] release(loop): authorize 0.6.10 scripts/native-release.mjs --- scripts/native-release.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/native-release.mjs b/scripts/native-release.mjs index 0ab10a19..0ca1a3bf 100644 --- a/scripts/native-release.mjs +++ b/scripts/native-release.mjs @@ -7,11 +7,11 @@ export function validateRelease(request, pkg, env) { assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository") assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main") assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required") - assert.equal(request?.version, "0.6.9", "release version is not authorized") + assert.equal(request?.version, "0.6.10", "release version is not authorized") assert.equal(request?.name, "@bybrawe/opencode-loop") assert.equal(pkg.name, request.name) assert.equal(pkg.version, request.version) - assert.equal(request.predecessor, "0.6.8") + assert.equal(request.predecessor, "0.6.9") return request } From 6a1a0144c9966424b2c15e4a295fda27f9466a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:32:39 +0300 Subject: [PATCH 17/30] release(loop): authorize 0.6.10 scripts/publish-workflow-test.mjs --- scripts/publish-workflow-test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/publish-workflow-test.mjs b/scripts/publish-workflow-test.mjs index 25e76788..0a3f8834 100644 --- a/scripts/publish-workflow-test.mjs +++ b/scripts/publish-workflow-test.mjs @@ -47,14 +47,14 @@ assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$att assert.ok(workflow.includes("Published Loop standalone clean-install validation did not converge"), "clean Loop consumer must remain a hard release gate") const sha = "a".repeat(40) -const request = { name: "@bybrawe/opencode-loop", version: "0.6.9", predecessor: "0.6.8" } +const request = { name: "@bybrawe/opencode-loop", version: "0.6.10", predecessor: "0.6.9" } const pkg = { name: request.name, version: request.version } const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha } assert.strictEqual(validateRelease(request, pkg, env), request) for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change })) assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env)) assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env)) -assert.equal(releaseDecision(404, null, request, sha, "0.6.8"), true) +assert.equal(releaseDecision(404, null, request, sha, "0.6.9", true) for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0")) assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0")) const manifest = { ...pkg, gitHead: sha } From 14d35ed89ba19f8f8aaaf4026dda84d92fdc7179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:32:42 +0300 Subject: [PATCH 18/30] release(loop): authorize 0.6.10 .github/workflows/publish-npm.yml --- .github/workflows/publish-npm.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 52546d45..b47a9ab0 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -62,7 +62,7 @@ jobs: run: | mkdir -p "$RUNNER_TEMP/packed" "$RUNNER_TEMP/consumer" npm pack --pack-destination "$RUNNER_TEMP/packed" - (cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null && npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP/packed/bybrawe-opencode-loop-0.6.9.tgz") + (cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null && npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP/packed/bybrawe-opencode-loop-0.6.10.tgz") npm install --prefix "$RUNNER_TEMP/native-host" --package-lock=false @opencode/cli@2.0.18 - name: Real OpenCode with standalone Loop commands and daemon shell: bash @@ -133,7 +133,7 @@ jobs: (cd "$VERIFY_DIR" && npm init -y >/dev/null) # A released version may reach registry metadata before tarballs converge. for attempt in {1..12}; do - if (cd "$VERIFY_DIR" && npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --prefer-online --cache "$VERIFY_DIR/npm-cache-$attempt" @bybrawe/opencode-loop@0.6.9); then + if (cd "$VERIFY_DIR" && npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --prefer-online --cache "$VERIFY_DIR/npm-cache-$attempt" @bybrawe/opencode-loop@0.6.10); then break fi if [ "$attempt" -eq 12 ]; then @@ -148,7 +148,7 @@ jobs: export OPENCODE2_CURRENT_COMMAND_FIELDS=1 OPENCODE2_NATIVE_REQUIRED=1 node "$VERIFY_DIR/node_modules/@bybrawe/opencode-loop/scripts/opencode2-loop-canary.mjs" node "$VERIFY_DIR/node_modules/@bybrawe/opencode-loop/scripts/native-cli-host-canary.mjs" - echo 'PUBLISHED_NATIVE_LOOP_PASS Loop 0.6.9 on OpenCode 2.0.18' + echo 'PUBLISHED_NATIVE_LOOP_PASS Loop 0.6.10 on OpenCode 2.0.18' github-release: name: Create tag and GitHub release @@ -173,7 +173,7 @@ jobs: import assert from 'node:assert/strict' const base = 'https://api.github.com/repos/' + process.env.GITHUB_REPOSITORY const headers = { Authorization: `Bearer ${process.env.GH_TOKEN}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json' } - const response = await fetch(base + '/git/ref/tags/v0.6.9', { headers }) + const response = await fetch(base + '/git/ref/tags/v0.6.10', { headers }) if (response.ok) { let object = (await response.json()).object if (object.type === 'tag') { @@ -184,11 +184,11 @@ jobs: assert.equal(object.sha, process.env.GITHUB_SHA, 'Existing release tag belongs to another source') } else { assert.equal(response.status, 404) - const created = await fetch(base + '/git/refs', { method: 'POST', headers, body: JSON.stringify({ ref: 'refs/tags/v0.6.9', sha: process.env.GITHUB_SHA }) }) + const created = await fetch(base + '/git/refs', { method: 'POST', headers, body: JSON.stringify({ ref: 'refs/tags/v0.6.10', sha: process.env.GITHUB_SHA }) }) assert.equal(created.status, 201) } JS - if ! gh release view v0.6.9 >/dev/null 2>&1; then + if ! gh release view v0.6.10 >/dev/null 2>&1; then git archive --format=zip --output=opencode-loop.zip "$COMMIT_SHA" - gh release create v0.6.9 opencode-loop.zip --title v0.6.9 --notes-file examples/OPENCODE2-0.6.9.md --verify-tag + gh release create v0.6.10 opencode-loop.zip --title v0.6.10 --notes-file examples/OPENCODE2-0.6.10.md --verify-tag fi From 09dc787f8d74e84b697d7d7713741e8f89eb0c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:33:06 +0300 Subject: [PATCH 19/30] docs(release): record standalone Loop 0.6.10 and thank @zhek --- CHANGELOG.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2f33ec..bba3f13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # Changelog -## Unreleased (main only) - -- Integrate native OpenCode 2 Loop TUI sidebar and read-only status RPC (contributed by [@zhek](https://github.com/zhek), [PR #165](https://github.com/ByBrawe/opencode-loop/pull/165)); source-only until OpenTUI 0.5/0.7 and Loop+Goal paired consumer CI pass. This is not part of npm 0.6.9. +## 0.6.10 — 2026-10-30 + +- Ship the read-only native OpenCode 2 Loop **TUI sidebar** and status RPC, originally contributed by [@zhek](https://github.com/zhek) ([#165](https://github.com/ByBrawe/opencode-loop/pull/165)), integrated with attribution in [#190](https://github.com/ByBrawe/opencode-loop/pull/190). Tests cover OpenTUI 0.5.14 and 0.7.0 rendering, session isolation and cleanup. Real remote/split-cache TUI auto-discovery remains unverified ([#191](https://github.com/ByBrawe/opencode-loop/issues/191)). +- Make Loop **independently installable and releasable**: remove the old `--with-goals` and `--with-loop-goals` coupled modes; leave separately installed ByBrawe Goal untouched; eliminate Goal package dependency from Loop's npm release gates ([#194](https://github.com/ByBrawe/opencode-loop/pull/194)). +- Fix Loop uninstallation for explicitly selected `OPENCODE_CONFIG` files, preserve unrelated plugins/JSONC comments and keep project Loop history. Validate the actual shipped npm CLI's `--uninstall` behavior. +- On OpenCode 2.2+, use the built-in native Goal's read-only `opencode.goal` RPC to reserve active/paused sessions before Loop prompt admission. Preserve safety on unreadable Goal state; ignore orphaned ByBrawe Goal history when its plugin is confirmed absent ([#195](https://github.com/ByBrawe/opencode-loop/issues/195), [#196](https://github.com/ByBrawe/opencode-loop/pull/196)). Continue to recommend separate sessions for concurrent workflows. +- Remove unneeded V1 SDK *npm peer* from the standalone native V2 package, preventing dependency-resolution conflicts in clean npm installs; V1 compatibility remains explicitly tested through dev dependencies. +- **Scope:** Windows Desktop/Web GUI slash-command discovery ([#169](https://github.com/ByBrawe/opencode-loop/issues/169)), remote OpenTUI split-cache discovery (#191), and fully atomic native Goal/Loop scheduling are **not claimed resolved**. ## 0.6.9 - 2026-10-27 From 4df600484a10f2de657dcdec6c54f6aa7da7e148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:33:25 +0300 Subject: [PATCH 20/30] docs(release): ship 0.6.10 independent Loop notes and contributor credit --- examples/OPENCODE2-0.6.10.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 examples/OPENCODE2-0.6.10.md diff --git a/examples/OPENCODE2-0.6.10.md b/examples/OPENCODE2-0.6.10.md new file mode 100644 index 00000000..718f66db --- /dev/null +++ b/examples/OPENCODE2-0.6.10.md @@ -0,0 +1,28 @@ +# OpenCode Loop 0.6.10 — standalone native OpenCode 2 release + +Released after exact-main CI and registry verification only. Source merge or a passing PR is **not** proof that npm publication completed. + +## What's new +- **Independent Loop**: `npx -y @bybrawe/opencode-loop@0.6.10` installs Loop only, and `--uninstall` unregisters Loop only. `--with-goals` and `--with-loop-goals` now return an error before changing config. The separate `@bybrawe/opencode-goal` package is not installed, updated or removed by Loop. +- **Native OpenCode Goal 2.2.x safety**: when a built-in `/goal` is active or paused, Loop uses the host's public, read-only `opencode.goal` RPC to defer autonomous Loop admission in that session. Unknown states fail closed. The previous ByBrawe Goal's on-disk guard remains for installed clients; old files do not reserve sessions after that package is confirmed absent. These checks mitigate overlap but do not constitute an atomic scheduling guarantee; use separate sessions for separate autonomous jobs. +- **Native read-only TUI sidebar**: status RPC, session switching, safe read-only status rendering. Thanks to **[@zhek](https://github.com/zhek)** for the original [#165](https://github.com/ByBrawe/opencode-loop/pull/165) contribution, integrated as [#190](https://github.com/ByBrawe/opencode-loop/pull/190). +- **Cleaner installation**: explicit `OPENCODE_CONFIG` override recognized on removal; JSONC user comments and unrelated plugins preserved; dropped unnecessary V1-SDK npm peer to prevent clean consumer resolver conflicts. + +## Install or remove + +```powershell +npx -y @bybrawe/opencode-loop@0.6.10 +# Later, if desired: +npx -y @bybrawe/opencode-loop@0.6.10 --uninstall +``` + +Quit and reopen the OpenCode backend, then check `/loop-doctor` and `/loop-status` in the same project/workspace. If an explicit config is used, set `OPENCODE_CONFIG` or `OPENCODE_CONFIG_DIR` accordingly when removing. Project history under `.opencode/opencode-loop` is preserved. + +OpenCode 2.2.x has a built-in `/goal`; seeing it after uninstalling our third-party Goal is expected and does not mean the removed package is still present. + +## Explicit limitations +- The Loop TUI sidebar has OpenTUI 0.5.14/0.7.0 render tests, but **remote/split-cache CLI auto-discovery** is still tracked upstream and in [#191](https://github.com/ByBrawe/opencode-loop/issues/191). Its absence must never stop the scheduler. +- Windows Desktop/Web GUI native slash-command discovery is **not proven** ([#169](https://github.com/ByBrawe/opencode-loop/issues/169)); the OpenTUI renderer is not the Desktop GUI. +- Multi-agent Goal/Loop scheduling is not atomic across independent plugins; avoid running both autonomous engines in the same session. +- Historical `v0.6.6` tag permission issue [#168](https://github.com/ByBrawe/opencode-loop/issues/168) is separate release bookkeeping and is not silently claimed fixed. +- Publication must pass strict clean npm consumer, package manifest, source-to-Bun-bundle parity, Windows/Linux/macOS host test and immutable npm registry identity gates. From bc24bbb4fa1ceed907636230af9405dbf90987ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:35:13 +0300 Subject: [PATCH 21/30] fix(release): retain old-OpenCode native-Goal compatibility regressions (#195) --- src/source/opencode2/native-companion.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/source/opencode2/native-companion.js b/src/source/opencode2/native-companion.js index e27b08fb..49facfca 100644 --- a/src/source/opencode2/native-companion.js +++ b/src/source/opencode2/native-companion.js @@ -49,9 +49,13 @@ function expectsBuiltInGoal(version) { } export async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { - // OpenCode 2.0/2.1 never shipped built-in Goal. A modern host missing the - // public RPC cannot safely prove that no native Goal owns this session. - if (typeof ctx?.rpc !== "function") return expectsBuiltInGoal(ctx?.app?.version) + // OpenCode <2.2.1 has no built-in Goal and some older hosts return generic + // RPC errors rather than rpc.unavailable. Never block a healthy older Loop. + const version = String(ctx?.app?.version || "") + const known = /(?:^|[^0-9])[0-9]+\.[0-9]+\.[0-9]+(?:$|[^0-9])/.test(version) + if (known && !expectsBuiltInGoal(version)) return false + // A newer host with missing/uncertain RPC is not proof of no owner. + if (typeof ctx?.rpc !== "function") return true try { const goal = await ctx.rpc(nativeGoalRPC).get({ sessionID }) if (goal === null) return false From ac8f7cf1754d2fa0b2284433cda621554df5fd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:35:16 +0300 Subject: [PATCH 22/30] fix(release): retain old-OpenCode native-Goal compatibility regressions (#195) --- scripts/native-coexistence-test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/native-coexistence-test.mjs b/scripts/native-coexistence-test.mjs index dd66415f..5a4cdd58 100644 --- a/scripts/native-coexistence-test.mjs +++ b/scripts/native-coexistence-test.mjs @@ -54,6 +54,8 @@ try { } assert.equal(nativeReads, 5) assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.0.18" } }, nativeSessionID), false, "old V2 hosts never had native Goal") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.1.4" }, rpc: () => ({ get: async () => { throw Error("legacy host RPC error") } }) }, nativeSessionID), false, "legacy RPC errors cannot block a pre-native-Goal host") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "opencode v2.0.11" }, rpc: () => ({ get: async () => { throw { type: "rpc.method_not_found" } } }) }, nativeSessionID), false, "old host must bypass Goal RPC entirely") assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" } }, nativeSessionID), true, "modern host without Goal RPC must fail closed") const unavailable = () => ({ get: async () => { throw { type: "rpc.unavailable" } } }) assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => [] } }, nativeSessionID), false, "disabled native Goal should not block Loop") From 531b77b80f57f567ee7ce61a9e931b9e4d3612e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:36:39 +0300 Subject: [PATCH 23/30] build(v2): sync verified Bun native Goal host-compat bundles (#195) --- src/native.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/native.js b/src/native.js index 733d6b04..84228e02 100644 --- a/src/native.js +++ b/src/native.js @@ -1574,8 +1574,12 @@ function expectsBuiltInGoal(version) { return major > 2 || major === 2 && (minor > 2 || minor === 2 && patch >= 1); } async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { + const version = String(ctx?.app?.version || ""); + const known = /(?:^|[^0-9])[0-9]+\.[0-9]+\.[0-9]+(?:$|[^0-9])/.test(version); + if (known && !expectsBuiltInGoal(version)) + return false; if (typeof ctx?.rpc !== "function") - return expectsBuiltInGoal(ctx?.app?.version); + return true; try { const goal = await ctx.rpc(nativeGoalRPC).get({ sessionID }); if (goal === null) From cf8682d99fa06592f312b6c7424a1f1dc16c4a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:36:44 +0300 Subject: [PATCH 24/30] build(v2): sync verified Bun native Goal host-compat bundles (#195) --- src/server.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server.js b/src/server.js index 6941b20b..a29e08c9 100644 --- a/src/server.js +++ b/src/server.js @@ -1574,8 +1574,12 @@ function expectsBuiltInGoal(version) { return major > 2 || major === 2 && (minor > 2 || minor === 2 && patch >= 1); } async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { + const version = String(ctx?.app?.version || ""); + const known = /(?:^|[^0-9])[0-9]+\.[0-9]+\.[0-9]+(?:$|[^0-9])/.test(version); + if (known && !expectsBuiltInGoal(version)) + return false; if (typeof ctx?.rpc !== "function") - return expectsBuiltInGoal(ctx?.app?.version); + return true; try { const goal = await ctx.rpc(nativeGoalRPC).get({ sessionID }); if (goal === null) From df0cd8d74511c63404b126bc4dfe670f911f34a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:38:28 +0300 Subject: [PATCH 25/30] docs(release): remove lingering mandatory Goal companion wording (#178) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50a96692..4bed4674 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,7 @@ For unattended work, avoid permanently allowing every shell command. Grant routi Native OpenCode 2 is the default supported package path for `0.6.9`. The production dependency is pinned to `@opencode/plugin@2.0.22`; the explicit legacy V1 compatibility path retains `@opencode-ai/plugin >=1.4.0 <2`. Do **not** mistake the legacy V1 peer range for the V2 runtime API version. -Automated release gates cover native V2 packages, Windows/Linux regressions and real OpenCode 2 host command registration/coexistence with the separately installed Goal plugin. **Windows Desktop GUI slash-command discovery is not yet proven equivalent to CLI TUI**; see [#169](https://github.com/ByBrawe/opencode-loop/issues/169) and the [diagnostic guide](docs/OPENCODE2-DESKTOP-WINDOWS.md). Platform gaps are tracked in [#177](https://github.com/ByBrawe/opencode-loop/issues/177). +Automated release gates cover standalone native V2 packaging, Windows/Linux regressions, and real OpenCode 2 host command registration. Separate legacy ByBrawe Goal coexistence remains an optional compatibility test: it is **not** a Loop runtime, installation, update, or npm publication dependency. Native OpenCode 2.2+ `/goal` is checked read-only via the public Goal RPC immediately before autonomous Loop admission. **Windows Desktop GUI slash-command discovery is not yet proven equivalent to CLI TUI**; see [#169](https://github.com/ByBrawe/opencode-loop/issues/169) and the [diagnostic guide](docs/OPENCODE2-DESKTOP-WINDOWS.md). Platform gaps are tracked in [#177](https://github.com/ByBrawe/opencode-loop/issues/177). See [docs/SCHEDULING.md](./docs/SCHEDULING.md) for the scheduling contract and [CHANGELOG.md](./CHANGELOG.md) for release history. From 89d49803bf1c62649236075e28155d5acdbc8296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:42:38 +0300 Subject: [PATCH 26/30] fix(release): restore assertion syntax in 0.6.10 release gate test --- scripts/publish-workflow-test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/publish-workflow-test.mjs b/scripts/publish-workflow-test.mjs index 0a3f8834..48a3b504 100644 --- a/scripts/publish-workflow-test.mjs +++ b/scripts/publish-workflow-test.mjs @@ -54,7 +54,7 @@ assert.strictEqual(validateRelease(request, pkg, env), request) for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change })) assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env)) assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env)) -assert.equal(releaseDecision(404, null, request, sha, "0.6.9", true) +assert.equal(releaseDecision(404, null, request, sha, "0.6.9"), true) for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0")) assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0")) const manifest = { ...pkg, gitHead: sha } From 2353ac32557386d14f437caf8dc72f7e6141f10b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:44:49 +0300 Subject: [PATCH 27/30] fix(v2): decode public OpenCode plugin.list wrapped data response (#195) --- src/source/opencode2/native-companion.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/source/opencode2/native-companion.js b/src/source/opencode2/native-companion.js index 49facfca..d1c8286b 100644 --- a/src/source/opencode2/native-companion.js +++ b/src/source/opencode2/native-companion.js @@ -70,7 +70,8 @@ export async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { // the RPC is absent, and Loop is allowed to run normally. if (typeof ctx?.plugin?.list !== "function") return expectsBuiltInGoal(ctx?.app?.version) try { - const plugins = await ctx.plugin.list() + const response = await ctx.plugin.list() + const plugins = Array.isArray(response) ? response : response?.data if (!Array.isArray(plugins)) return true return plugins.some((plugin) => plugin?.id === "opencode.goal") } catch { @@ -85,7 +86,8 @@ export async function byBraweGoalPluginInstalled(ctx) { // harmless after the separate Goal plugin has been uninstalled. if (typeof ctx?.plugin?.list !== "function") return true try { - const plugins = await ctx.plugin.list() + const response = await ctx.plugin.list() + const plugins = Array.isArray(response) ? response : response?.data if (!Array.isArray(plugins)) return true return plugins.some((plugin) => plugin?.id === "@bybrawe/opencode-goal" || From cc28b237c8ee4f208bda6896573e5eccf26ea164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:44:53 +0300 Subject: [PATCH 28/30] fix(v2): decode public OpenCode plugin.list wrapped data response (#195) --- scripts/native-coexistence-test.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/native-coexistence-test.mjs b/scripts/native-coexistence-test.mjs index 5a4cdd58..c5d70a5e 100644 --- a/scripts/native-coexistence-test.mjs +++ b/scripts/native-coexistence-test.mjs @@ -59,6 +59,7 @@ try { assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" } }, nativeSessionID), true, "modern host without Goal RPC must fail closed") const unavailable = () => ({ get: async () => { throw { type: "rpc.unavailable" } } }) assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => [] } }, nativeSessionID), false, "disabled native Goal should not block Loop") + assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => ({ location: { directory }, data: [] }) } }, nativeSessionID), false, "official wrapped plugin catalog with no native Goal must not block Loop") assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => [{ id: "opencode.goal" }] } }, nativeSessionID), true, "Goal registered but RPC unavailable must block") assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: unavailable, plugin: { list: async () => { throw Error("offline") } } }, nativeSessionID), true, "unverified Goal state fails closed") assert.equal(await nativeOpenCodeGoalReservesSession({ app: { version: "2.2.4" }, rpc: () => ({ get: async () => { throw Error("transient error") } }) }, nativeSessionID), true) @@ -87,7 +88,9 @@ try { const absentGoal = { plugin: { list: async () => [] } } assert.equal(await byBraweGoalPluginInstalled(absentGoal), false, "standalone Loop has no separate Goal plugin") + assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => ({ location: { directory }, data: [] }) } }), false, "official wrapped catalog must release orphaned ByBrawe Goal shards") assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => [{ id: "@bybrawe/opencode-goal" }] } }), true) + assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => ({ location: { directory }, data: [{ id: "@bybrawe/opencode-goal" }] }) } }), true) assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => [{ source: { target: "@bybrawe/opencode-goal@1.3.49" } }] } }), true) assert.equal(await byBraweGoalPluginInstalled({}), true, "older hosts retain conservative disk guard") assert.equal(await byBraweGoalPluginInstalled({ plugin: { list: async () => { throw Error("catalog offline") } } }), true) From 91e769682a30ea6fe9a7290698cb51f8912038e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:44:57 +0300 Subject: [PATCH 29/30] fix(v2): decode public OpenCode plugin.list wrapped data response (#195) --- src/native.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/native.js b/src/native.js index 84228e02..44b044b2 100644 --- a/src/native.js +++ b/src/native.js @@ -1594,7 +1594,8 @@ async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { if (typeof ctx?.plugin?.list !== "function") return expectsBuiltInGoal(ctx?.app?.version); try { - const plugins = await ctx.plugin.list(); + const response = await ctx.plugin.list(); + const plugins = Array.isArray(response) ? response : response?.data; if (!Array.isArray(plugins)) return true; return plugins.some((plugin) => plugin?.id === "opencode.goal"); @@ -1607,7 +1608,8 @@ async function byBraweGoalPluginInstalled(ctx) { if (typeof ctx?.plugin?.list !== "function") return true; try { - const plugins = await ctx.plugin.list(); + const response = await ctx.plugin.list(); + const plugins = Array.isArray(response) ? response : response?.data; if (!Array.isArray(plugins)) return true; return plugins.some((plugin) => plugin?.id === "@bybrawe/opencode-goal" || plugin?.source?.target === "@bybrawe/opencode-goal" || String(plugin?.source?.target || "").startsWith("@bybrawe/opencode-goal@")); From 473a2187e97d5428bc4d4c847703649dc8a2e09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 30 Oct 2026 23:45:01 +0300 Subject: [PATCH 30/30] fix(v2): decode public OpenCode plugin.list wrapped data response (#195) --- src/server.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server.js b/src/server.js index a29e08c9..8ffad94e 100644 --- a/src/server.js +++ b/src/server.js @@ -1594,7 +1594,8 @@ async function nativeOpenCodeGoalReservesSession(ctx, sessionID) { if (typeof ctx?.plugin?.list !== "function") return expectsBuiltInGoal(ctx?.app?.version); try { - const plugins = await ctx.plugin.list(); + const response = await ctx.plugin.list(); + const plugins = Array.isArray(response) ? response : response?.data; if (!Array.isArray(plugins)) return true; return plugins.some((plugin) => plugin?.id === "opencode.goal"); @@ -1607,7 +1608,8 @@ async function byBraweGoalPluginInstalled(ctx) { if (typeof ctx?.plugin?.list !== "function") return true; try { - const plugins = await ctx.plugin.list(); + const response = await ctx.plugin.list(); + const plugins = Array.isArray(response) ? response : response?.data; if (!Array.isArray(plugins)) return true; return plugins.some((plugin) => plugin?.id === "@bybrawe/opencode-goal" || plugin?.source?.target === "@bybrawe/opencode-goal" || String(plugin?.source?.target || "").startsWith("@bybrawe/opencode-goal@"));