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)." } 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 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 diff --git a/README.md b/README.md index 32927546..4bed4674 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. @@ -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. 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. 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", diff --git a/scripts/native-coexistence-test.mjs b/scripts/native-coexistence-test.mjs index 27d0abde..c5d70a5e 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, 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-")) @@ -31,6 +31,104 @@ 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.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") + 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) + + 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() + } + + 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) + + 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" + 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() 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 } diff --git a/scripts/publish-workflow-test.mjs b/scripts/publish-workflow-test.mjs index 25e76788..48a3b504 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 } diff --git a/src/native.js b/src/native.js index 21554799..44b044b2 100644 --- a/src/native.js +++ b/src/native.js @@ -1551,6 +1551,72 @@ 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) { + 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 true; + 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 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 { + return true; + } + } +} +async function byBraweGoalPluginInstalled(ctx) { + if (typeof ctx?.plugin?.list !== "function") + return true; + try { + 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@")); + } catch { + return true; + } +} // src/source/opencode2/native-runtime.js init_jobs(); @@ -2100,6 +2166,26 @@ function createNativeLoopRuntime(options = {}) { options.onError?.(error); } catch {} }; + async function goalReserves(scope) { + try { + 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) { + 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 +2351,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 +2448,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 +3738,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 +3795,8 @@ var OpenCodeLoopNativePlugin = Object.freeze({ const runtime = createNativeLoopRuntime({ 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, diff --git a/src/server.js b/src/server.js index 1e08cb71..8ffad94e 100644 --- a/src/server.js +++ b/src/server.js @@ -1551,6 +1551,72 @@ 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) { + 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 true; + 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 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 { + return true; + } + } +} +async function byBraweGoalPluginInstalled(ctx) { + if (typeof ctx?.plugin?.list !== "function") + return true; + try { + 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@")); + } catch { + return true; + } +} // src/source/opencode2/native-runtime.js init_jobs(); @@ -2100,6 +2166,26 @@ function createNativeLoopRuntime(options = {}) { options.onError?.(error); } catch {} }; + async function goalReserves(scope) { + try { + 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) { + 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 +2351,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 +2448,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 +3738,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 +3795,8 @@ var OpenCodeLoopNativePlugin = Object.freeze({ const runtime = createNativeLoopRuntime({ 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, diff --git a/src/source/opencode2/native-companion.js b/src/source/opencode2/native-companion.js index 23b07667..d1c8286b 100644 --- a/src/source/opencode2/native-companion.js +++ b/src/source/opencode2/native-companion.js @@ -22,3 +22,79 @@ 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.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 + 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 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 { + return true + } + } +} + +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 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@")) + } catch { + // An unreadable inventory is not proof of a clean removal. + return true + } +} diff --git a/src/source/opencode2/native-plugin.js b/src/source/opencode2/native-plugin.js index f81830ba..b3a5aca0 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, 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" @@ -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,8 @@ export const OpenCodeLoopNativePlugin = Object.freeze({ const runtime = createNativeLoopRuntime({ 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, diff --git a/src/source/opencode2/native-runtime.js b/src/source/opencode2/native-runtime.js index d443d699..2c8258a8 100644 --- a/src/source/opencode2/native-runtime.js +++ b/src/source/opencode2/native-runtime.js @@ -35,6 +35,30 @@ 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 { + // 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 + } 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 +194,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 +278,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