From 099be2c0403e294121d14fe84ce2ff523b54c4ec Mon Sep 17 00:00:00 2001 From: RuihaoZhang Date: Sat, 29 Aug 2026 09:59:36 +0800 Subject: [PATCH 1/5] feat: bundle the OpenCode CLI in per-target release packages The plugin no longer requires OpenCode to be installed on PATH. Each release now ships one .orax per target triple with the CLI inside it at assets/bin/opencode[.exe], and the host resolves that package-relative path through the new `packageCommand` form of ora/childprocess/spawn. A plugin cannot reliably compute a host path of its own: it is told none, and a relative program combined with a cwd resolves against different directories per platform while the child's cwd has to be the workspace anyway. Asking the host to join the path is what removes that whole class of failure. Because the CLI now ships with the package, "absent from this machine" stops being a reachable state. AGENT_NOT_INSTALLED (-32001) therefore narrows to the ORA_OPENCODE_BIN override only: a bundled binary that will not resolve means a broken or wrong-target package, which fails identically on every retry and must surface as a failing agent rather than be retried quietly forever. handlers/models.ts stops shelling out through Deno.Command. It was already bypassing host process management, and its not-found classification could never match a Deno error, so the failure was silently degrading the model list to the curated fallback. With a bundled CLI it would have broken outright. Release packaging is split so the CI half stays reusable: bundle.config.ts the only plugin-specific part: upstream repo + asset per target scripts/package.ts generic engine; names no CLI, meant to be copied verbatim release.yml checkout / build / package / release, nothing plugin-specific scripts/package.ts reads and writes archives in-process (gh is the only external tool) so a maintainer can produce and inspect real packages on any machine, not just on a CI runner with zip and unzip. src/services/bundled-binary.ts is the single source of truth for the in-package binary path, shared by the runtime spawn and the packaging step so the two cannot drift into a mismatch that would only surface as a broken install. Requires @ora-space/plugin-sdk 0.5.0 for the packageCommand spawn form, and an Ora build whose extractor preserves the execute bit and whose plugin install budget admits a package this size. --- .github/workflows/release.yml | 20 ++- .gitignore | 20 +++ README.md | 67 ++++++-- bundle.config.ts | 21 +++ deno.json | 15 +- deno.lock | 52 +++++- scripts/package.ts | 291 ++++++++++++++++++++++++++++++++ src/handlers/models.ts | 37 ++-- src/main.ts | 11 +- src/services/bundled-binary.ts | 34 ++++ src/services/command.ts | 101 ++++------- src/services/opencode-client.ts | 39 +++-- tests/host-simulator.ts | 18 +- 13 files changed, 590 insertions(+), 136 deletions(-) create mode 100644 bundle.config.ts create mode 100644 scripts/package.ts create mode 100644 src/services/bundled-binary.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28845f8..d730d41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +# Deliberately generic: every fact about which CLI is bundled and which upstream asset serves +# each target lives in `bundle.config.ts`, and the packaging itself in `scripts/package.ts`. This +# file should be copyable to another agent plugin unchanged. + on: push: tags: @@ -24,19 +28,19 @@ jobs: - name: Build run: deno task build - - name: Package .orax archive - run: | - set -euo pipefail - NAME=$(grep -m1 '^identifier *=' orax.toml | sed -E 's/^identifier *= *"(.*)"/\1/') - FILE="${NAME}-${GITHUB_REF_NAME}.orax" - zip -j "$FILE" orax.toml dist/main.js logo.svg README.md - echo "ORAX_FILE=$FILE" >> "$GITHUB_ENV" + # Writes dist/packages/*.orax (one per declared target) and dist/manifest.toml. + - name: Package + env: + GH_TOKEN: ${{ github.token }} + run: deno task package --tag "${{ github.ref_name }}" --repo "${{ github.repository }}" - name: Create Release if: github.ref_type == 'tag' env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${GITHUB_REF_NAME}" "${ORAX_FILE}" \ + set -euo pipefail + gh release create "${GITHUB_REF_NAME}" \ + dist/packages/*.orax dist/manifest.toml \ --title "${GITHUB_REF_NAME}" \ --generate-notes diff --git a/.gitignore b/.gitignore index 849ddff..297d888 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,21 @@ +# Build output: the bundled entrypoint, the staging tree, downloaded upstream archives, and the +# .orax packages themselves. All reproducible with `deno task build && deno task package`. dist/ + +# The bundled OpenCode CLI, staged here only to run `deno task simulate` against a real binary. +# Released packages never take it from the repository: `scripts/package.ts` downloads it per +# target at package time. Scoped to `bin/` so genuine static assets stay committable. +assets/bin/ + +# package.json is the legacy Ora manifest and declares no dependencies, but its presence makes a +# stray `npm install` plausible. +node_modules/ +package-lock.json + +# Local-only Deno config, for overriding an unpublished @ora-space/plugin-sdk with `links`. +deno.local.json + +# OS and editor noise +.DS_Store +Thumbs.db +*.swp diff --git a/README.md b/README.md index c830535..2a67cff 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,15 @@ agent provider. Deleting the directory removes the agent. ## Contract mapping -| Host requirement | Implementation | -| ------------------------------------ | ------------------------------------------------------------------------ | -| `ora/register` with methods + emits | `runAgentPlugin` → SDK `defineAgent`: 3 methods, `agent/acp` emit | -| `agent/start` | `handlers/lifecycle.ts` spawns `opencode acp --cwd ` | -| `agent/stop` | kills the CLI, keeps this process alive so a later start can respawn it | -| `agent/listModels` | `handlers/models.ts`: `opencode models`, cached, curated fallback | -| `agent/acp` (both directions) | `handlers/acp.ts` + `services/opencode-client.ts`, payload never parsed | -| CLI absent → `-32001` | `services/command.ts` throws `PluginMethodError(AGENT_NOT_INSTALLED, …)` | -| one plugin = one agent = one process | a single plugin instance owning a single `OpenCodeClient` | +| Host requirement | Implementation | +| ------------------------------------ | -------------------------------------------------------------------------- | +| `ora/register` with methods + emits | `runAgentPlugin` → SDK `defineAgent`: 3 methods, `agent/acp` emit | +| `agent/start` | `handlers/lifecycle.ts` spawns `opencode acp --cwd ` | +| `agent/stop` | kills the CLI, keeps this process alive so a later start can respawn it | +| `agent/listModels` | `handlers/models.ts`: `opencode models`, cached, curated fallback | +| `agent/acp` (both directions) | `handlers/acp.ts` + `services/opencode-client.ts`, payload never parsed | +| `ORA_OPENCODE_BIN` absent → `-32001` | `services/command.ts`; a broken _bundled_ binary is a real failure instead | +| one plugin = one agent = one process | a single plugin instance owning a single `OpenCodeClient` | ## API registration architecture @@ -71,12 +71,32 @@ src/ models.ts agent/listModels acp.ts agent/acp (host → CLI) services/ - opencode-client.ts spawns and owns `opencode acp`, both stdio pumps - command.ts binary resolution, spawn candidates, not-found mapping + opencode-client.ts owns the `opencode acp` child, both stdio pumps + command.ts which program to spawn, and how a failure is classified + bundled-binary.ts where the bundled CLI lives inside the package ndjson.ts NDJSON line codec for the CLI's stdio +bundle.config.ts which upstream CLI is bundled, and its asset per target +scripts/package.ts builds one .orax per target; plugin-agnostic tests/host-simulator.ts drives this plugin the way the Ora host does ``` +`bundle.config.ts` is the only plugin-specific half of the release pipeline. +`scripts/package.ts` and `.github/workflows/release.yml` name no CLI and are +meant to be copied to another agent plugin unchanged, with only that config +rewritten. `src/services/bundled-binary.ts` is the single source of truth for +the in-package binary path, shared by the runtime spawn and the packaging step +so the two cannot drift. + +Run the packaging locally with: + +``` +deno task build +deno task package --tag v0.2.4 --repo ora-space/opencode-agent +``` + +`gh` is the only external tool required — archives are read and written +in-process, so this works the same on a maintainer's machine as it does in CI. + Every module imports `@ora-space/plugin-sdk` as a fully qualified `jsr:@ora-space/plugin-sdk@0.1.3` specifier rather than a bare one, because Ora launches the plugin with `deno run --no-prompt` and no import map: a bare @@ -87,15 +107,26 @@ in every import together when the SDK changes. ## Requirements -- The OpenCode CLI on PATH (`opencode`, or the `opencode.cmd` shim npm installs - on Windows). Pin an explicit binary with `ORA_OPENCODE_BIN`. +- Nothing. The OpenCode CLI ships **inside** this package under + `assets/bin/opencode[.exe]`, so there is no separate install step and no PATH + lookup. Releases are built one `.orax` per target triple, and the package + self-declares its target in `[artifact]` so Ora refuses a package built for + another machine rather than landing a binary that cannot run. - Deno, which Ora provides for plugin processes. -Ora launches agent plugins with -`--allow-run --allow-read --allow-env ---allow-net`; this plugin needs all four -(spawn the CLI, resolve it, read `ORA_OPENCODE_BIN`, let the CLI reach its -providers). +`ORA_OPENCODE_BIN` still overrides the bundled binary with an explicit path, +which is how you drive a locally built OpenCode: a developer's own build has no +way into an installed package. Only that path reports `agent_not_installed` when +it is missing — a bundled binary that will not resolve means the package is +broken or was built for the wrong target, which fails identically on every retry +and so must surface rather than be retried quietly. + +The plugin itself no longer spawns anything: it asks the host to spawn the +bundled CLI by package-relative path (`packageCommand`), because a plugin is +told no host path and cannot reliably compute one. Ora launches agent plugins +with `--allow-run --allow-read --allow-env --allow-net`; of those this plugin +now only uses `--allow-env` (`ORA_OPENCODE_BIN`) and leaves the CLI's own +network access to the CLI. ## Installation and discovery diff --git a/bundle.config.ts b/bundle.config.ts new file mode 100644 index 0000000..d1d40af --- /dev/null +++ b/bundle.config.ts @@ -0,0 +1,21 @@ +import type { BundleConfig } from "./scripts/package.ts"; + +/** + * Declares the upstream CLI this plugin bundles, and which release asset serves each target. + * + * This is the only plugin-specific half of the release pipeline: `scripts/package.ts` and + * `.github/workflows/release.yml` know nothing about OpenCode and are meant to be copied to + * another agent plugin unchanged, with only this file rewritten. + * + * Every entry produces one `.orax`. A target absent here is simply not published: Ora refuses to + * install a package built for another triple, so an unlisted host gets no package rather than a + * binary it cannot run. + */ +export default { + upstream: "anomalyco/opencode", + assets: { + "aarch64-apple-darwin": "opencode-darwin-arm64.zip", + "x86_64-unknown-linux-gnu": "opencode-linux-x64.tar.gz", + "x86_64-pc-windows-msvc": "opencode-windows-x64.zip", + }, +} satisfies BundleConfig; diff --git a/deno.json b/deno.json index 843c6b8..b8fe91e 100644 --- a/deno.json +++ b/deno.json @@ -4,15 +4,20 @@ "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { - "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.4.0" + "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.5.0", + "@std/cli": "jsr:@std/cli@^1.0.32", + "@std/path": "jsr:@std/path@^1.1.6", + "@std/tar": "jsr:@std/tar@^0.1.10", + "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts tests/host-simulator.ts", - "lint": "deno lint src tests", - "format": "deno fmt src tests deno.json package.json README.md", + "check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts", + "lint": "deno lint src scripts tests bundle.config.ts", + "format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md", "simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts", "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", - "build": "deno bundle src/main.ts -o dist/main.js" + "build": "deno bundle src/main.ts -o dist/main.js", + "package": "deno run --allow-run --allow-read --allow-write --allow-env scripts/package.ts" }, "compilerOptions": { "strict": true, diff --git a/deno.lock b/deno.lock index dd5e01d..59df66f 100644 --- a/deno.lock +++ b/deno.lock @@ -1,9 +1,57 @@ { "version": "5", + "specifiers": { + "jsr:@std/cli@^1.0.32": "1.0.32", + "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.6": "1.1.6", + "jsr:@std/streams@^1.0.17": "1.1.2", + "jsr:@std/tar@~0.1.10": "0.1.10", + "jsr:@zip-js/zip-js@^2.8.61": "2.8.61" + }, + "jsr": { + "@std/cli@1.0.32": { + "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/internal" + ] + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/streams@1.1.2": { + "integrity": "0249bf9b78a999f57032ca4d8e7ccaa57579090242640b35ddc948fbb745d3af" + }, + "@std/tar@0.1.10": { + "integrity": "6bf907f3a4bc8bfef42973ba132d946756a6161ef6b914a9e1c06debe664db17", + "dependencies": [ + "jsr:@std/streams" + ] + }, + "@zip-js/zip-js@2.8.61": { + "integrity": "8da37318aeefa76faa915058722ab8d1ee51e068edc8be69ea0810e01d724955" + } + }, "workspace": { + "dependencies": [ + "jsr:@std/cli@^1.0.32", + "jsr:@std/path@^1.1.6", + "jsr:@std/tar@~0.1.10", + "jsr:@zip-js/zip-js@^2.8.61" + ], "links": { - "jsr:@ora-space/plugin-sdk@0.4.0": {}, - "npm:@ora-space/plugin-sdk@0.4.0": {} + "jsr:@ora-space/plugin-sdk@0.5.0": {}, + "npm:@ora-space/plugin-sdk@0.5.0": {} } } } diff --git a/scripts/package.ts b/scripts/package.ts new file mode 100644 index 0000000..1da4113 --- /dev/null +++ b/scripts/package.ts @@ -0,0 +1,291 @@ +/** + * Builds one `.orax` per target for an agent plugin that bundles an upstream CLI. + * + * Nothing here names a particular plugin or CLI: what to download and which asset serves each + * target comes from `bundle.config.ts`, and where the binary lands inside the package comes from + * the plugin's own `bundledBinaryPath`. That is what lets this script and the release workflow be + * copied to another agent plugin unchanged. + * + * Usage: + * deno task package --tag v1.2.3 --repo owner/name + * + * Produces `dist/packages/--.orax` plus `dist/manifest.toml`, the release + * form of the manifest the marketplace index needs. `gh` is the only external tool required: + * archives are read and written in-process so a maintainer can run this anywhere CI can. + */ +import { parseArgs } from "@std/cli/parse-args"; +import { basename, dirname, join, relative } from "@std/path"; +import { UntarStream } from "@std/tar"; +import { BlobReader, ZipReader, ZipWriter } from "@zip-js/zip-js"; +import bundle from "../bundle.config.ts"; +import { + bundledBinaryPath, + type TargetOs, +} from "../src/services/bundled-binary.ts"; + +/** What a plugin declares about the upstream CLI it bundles. */ +export interface BundleConfig { + /** GitHub `owner/name` the CLI is released from. */ + upstream: string; + /** Release asset serving each canonical Rust target triple. */ + assets: Record; +} + +/** One target's resolved packaging inputs. */ +interface TargetPlan { + triple: string; + asset: string; + /** Package-relative path the binary is staged at, and the plugin later asks the host to spawn. */ + binaryPath: string; +} + +const DIST = "dist"; +const PACKAGES_DIR = join(DIST, "packages"); +const DOWNLOAD_DIR = join(DIST, "download"); +const STAGE_DIR = join(DIST, "stage"); + +/** Runs one command, failing loudly rather than letting a broken package be published. */ +async function run(command: string, ...args: string[]): Promise { + const { code, stdout, stderr } = await new Deno.Command(command, { + args, + stdout: "piped", + stderr: "piped", + }).output(); + if (code !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${code}: ${ + new TextDecoder().decode(stderr) + }`, + ); + } + return new TextDecoder().decode(stdout).trim(); +} + +/** + * Derives the operating system a canonical Rust target triple runs on. + * + * Only the OS is needed, and only to name the binary: the architecture never changes the package + * layout, because one package serves exactly one triple. + */ +function osOfTriple(triple: string): TargetOs { + if (triple.includes("-windows-")) return "windows"; + if (triple.includes("-apple-")) return "darwin"; + if (triple.includes("-linux-")) return "linux"; + throw new Error(`cannot derive an operating system from target ${triple}`); +} + +/** Reads one required field out of the installed manifest this repository ships. */ +async function manifestField(field: string): Promise { + const source = await Deno.readTextFile("orax.toml"); + const match = source.match(new RegExp(`^${field}\\s*=\\s*"(.*)"`, "m")); + if (match === null) { + throw new Error(`orax.toml declares no ${field}`); + } + return match[1]; +} + +/** + * Reads the one file named `entry` out of an upstream archive and writes it to `destination`. + * + * Archives are read in-process rather than by shelling out to `tar`/`unzip` so this script runs + * the same way on a maintainer's machine as it does in CI, whatever that machine is. Only the + * bytes are taken: the upstream mode is not consulted, because the staged file is chmod'ed to a + * known-good mode below regardless of what upstream happened to record. + */ +async function extractEntry( + archive: string, + entry: string, + destination: string, +): Promise { + if (archive.endsWith(".tar.gz") || archive.endsWith(".tgz")) { + const stream = (await Deno.open(archive)).readable + .pipeThrough(new DecompressionStream("gzip")) + .pipeThrough(new UntarStream()); + for await (const item of stream) { + if (item.path !== entry || item.readable === undefined) { + await item.readable?.cancel(); + continue; + } + await item.readable.pipeTo((await Deno.create(destination)).writable); + return; + } + throw new Error(`${archive} does not contain ${entry}`); + } + if (archive.endsWith(".zip")) { + const reader = new ZipReader(new BlobReader(await openBlob(archive))); + try { + for (const item of await reader.getEntries()) { + // A directory entry carries no reader; only the named file is of interest anyway. + if (item.filename !== entry || item.directory) continue; + const file = await Deno.create(destination); + await item.getData!(file.writable); + return; + } + } finally { + await reader.close(); + } + throw new Error(`${archive} does not contain ${entry}`); + } + throw new Error(`unsupported upstream archive format: ${archive}`); +} + +/** Reads one file as a Blob, which is what `zip-js` takes as a random-access source. */ +async function openBlob(path: string): Promise { + return new Blob([await Deno.readFile(path)]); +} + +/** + * Writes one staged directory tree into a `.orax`, recording the execute bit on `executable`. + * + * The execute bit is what makes the bundled CLI spawnable after Ora extracts the package, and a + * ZIP carries it in the upper 16 bits of the external file attributes. A fixed `0o100755` is + * written rather than whatever upstream recorded, so the package can never install a setuid or + * otherwise surprising mode. + */ +async function writeOrax( + stageDir: string, + destination: string, + executable: string, +): Promise { + const file = await Deno.create(destination); + const writer = new ZipWriter(file.writable); + for await (const entry of walk(stageDir)) { + const relative = relativeSlashPath(stageDir, entry); + await writer.add(relative, new BlobReader(await openBlob(entry)), { + externalFileAttribute: relative === executable + ? (0o100_755 << 16) >>> 0 + : (0o100_644 << 16) >>> 0, + }); + } + await writer.close(); +} + +/** Yields every ordinary file under `root`, depth first. */ +async function* walk(root: string): AsyncGenerator { + for await (const item of Deno.readDir(root)) { + const path = join(root, item.name); + if (item.isDirectory) { + yield* walk(path); + } else if (item.isFile) { + yield path; + } + } +} + +/** Renders one path below `root` as the slash-separated name a ZIP entry carries. */ +function relativeSlashPath(root: string, path: string): string { + return relative(root, path).replaceAll("\\", "/"); +} + +/** Returns the lowercase hex SHA-256 of one file, the spelling `sha256` takes in a manifest. */ +async function sha256Hex(path: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + await Deno.readFile(path), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** Stages one target's package tree and zips it into a `.orax`. */ +async function buildPackage( + plan: TargetPlan, + upstreamTag: string, + fileName: string, +): Promise { + await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {}); + const staged = join(STAGE_DIR, plan.binaryPath); + await Deno.mkdir(dirname(staged), { recursive: true }); + + const archive = join(DOWNLOAD_DIR, plan.asset); + await run( + "gh", + "release", + "download", + upstreamTag, + "--repo", + bundle.upstream, + "--pattern", + plan.asset, + "--dir", + DOWNLOAD_DIR, + "--clobber", + ); + // Upstream ships the CLI as the sole entry at the archive root, under the same name this + // package uses for it. + await extractEntry(archive, basename(plan.binaryPath), staged); + + await Deno.copyFile(join(DIST, "main.js"), join(STAGE_DIR, "main.js")); + for (const extra of ["logo.svg", "README.md"]) { + await Deno.copyFile(extra, join(STAGE_DIR, extra)).catch(() => {}); + } + // The self-declared target is what lets Ora verify, after extraction, that the package it + // downloaded is really the one built for this machine. + const manifest = await Deno.readTextFile("orax.toml"); + await Deno.writeTextFile( + join(STAGE_DIR, "orax.toml"), + `${manifest.trimEnd()}\n\n[artifact]\ntarget = "${plan.triple}"\n`, + ); + + await writeOrax(STAGE_DIR, join(PACKAGES_DIR, fileName), plan.binaryPath); + await Deno.remove(STAGE_DIR, { recursive: true }); + await Deno.remove(archive).catch(() => {}); +} + +async function main(): Promise { + const flags = parseArgs(Deno.args, { string: ["tag", "repo"] }); + const tag = flags.tag ?? Deno.env.get("GITHUB_REF_NAME"); + const repo = flags.repo ?? Deno.env.get("GITHUB_REPOSITORY"); + if (tag === undefined || repo === undefined) { + throw new Error("both --tag and --repo are required"); + } + + const identifier = await manifestField("identifier"); + await Deno.mkdir(PACKAGES_DIR, { recursive: true }); + await Deno.mkdir(DOWNLOAD_DIR, { recursive: true }); + + // Resolved once so every package in this release bundles the same CLI build: one "latest" + // lookup per target could straddle an upstream release and ship a version skew that only some + // platforms would ever see. + const upstreamTag = await run( + "gh", + "release", + "view", + "--repo", + bundle.upstream, + "--json", + "tagName", + "--jq", + ".tagName", + ); + console.log(`Bundling ${bundle.upstream} ${upstreamTag}`); + + const base = `https://github.com/${repo}/releases/download/${tag}`; + let manifest = (await Deno.readTextFile("orax.toml")).trimEnd(); + for (const [triple, asset] of Object.entries(bundle.assets)) { + const plan: TargetPlan = { + triple, + asset, + binaryPath: bundledBinaryPath(osOfTriple(triple)), + }; + const fileName = `${identifier}-${tag}-${triple}.orax`; + await buildPackage(plan, upstreamTag, fileName); + + const digest = await sha256Hex(join(PACKAGES_DIR, fileName)); + manifest += + `\n\n[[targets]]\ntarget = "${triple}"\nurl = "${base}/${fileName}"\nsha256 = "${digest}"`; + console.log(`packaged ${fileName}`); + } + + // The marketplace index needs the release form of the manifest, which carries the per-target + // download URLs and digests. It is only knowable once the packages exist, so it is generated + // here rather than committed. + await Deno.writeTextFile(join(DIST, "manifest.toml"), `${manifest}\n`); + await Deno.remove(DOWNLOAD_DIR, { recursive: true }).catch(() => {}); + console.log(`\nUpstream CLI: ${upstreamTag}`); +} + +if (import.meta.main) { + await main(); +} diff --git a/src/handlers/models.ts b/src/handlers/models.ts index 0348527..d0ac8f8 100644 --- a/src/handlers/models.ts +++ b/src/handlers/models.ts @@ -1,5 +1,5 @@ -import type { AgentModel } from "@ora-space/plugin-sdk"; -import { tryEachCandidate } from "../services/command.ts"; +import type { AgentModel, HostProcesses } from "@ora-space/plugin-sdk"; +import { resolveOpenCodeProgram } from "../services/command.ts"; /** Reads the raw model id list from OpenCode; injectable so the cache can be exercised. */ export type ModelIdSource = () => Promise; @@ -28,7 +28,8 @@ let cache: Promise | undefined; * changes because the user logged into a new provider therefore needs a plugin restart. */ export function listOpenCodeModels( - source: ModelIdSource = runOpenCodeModels, + processes: HostProcesses, + source: ModelIdSource = () => runOpenCodeModels(processes), ): Promise { if (cache === undefined) { cache = discoverModels(source); @@ -62,20 +63,24 @@ function displayNameFor(id: string): string { return slash === -1 ? id : id.slice(slash + 1); } -/** Runs `opencode models` and returns the non-empty id lines it prints. */ -async function runOpenCodeModels(): Promise { - const output = await tryEachCandidate(async ({ command, extraArgs }) => { - const { code, stdout } = await new Deno.Command(command, { - args: [...extraArgs, "models"], - stdin: "null", - stdout: "piped", - stderr: "piped", - }).output(); - if (code !== 0) { - throw new Error(`opencode models exited with code ${code}`); - } - return new TextDecoder().decode(stdout); +/** + * Runs `opencode models` through the host and returns the non-empty id lines it prints. + * + * This goes through the host rather than `Deno.Command` for the same reason the ACP server does: + * the bundled CLI's path is only resolvable by the host, and a process the host owns is torn down + * with this plugin generation instead of outliving it. + */ +async function runOpenCodeModels(processes: HostProcesses): Promise { + const child = await processes.spawn({ + ...resolveOpenCodeProgram(), + args: ["models"], }); + await child.closeStdin(); + const output = await new Response(child.stdout).text(); + const { code } = await child.exited; + if (code !== 0) { + throw new Error(`opencode models exited with code ${code}`); + } const ids = output .split(/\r?\n/) diff --git a/src/main.ts b/src/main.ts index d7e71af..c9bf8ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import type { AcpSender, AgentModel, AgentStartContext, + HostProcesses, JsonValue, } from "@ora-space/plugin-sdk"; import { @@ -30,6 +31,8 @@ class OpenCodeAgentPlugin extends AgentPlugin { #send: AcpSender | undefined; /** The workspace root the CLI is running against; also what a Skill Effect restart respawns into. */ #cwd: string | undefined; + /** Set by `onActivate`, which the base class runs before the host can call anything. */ + #processes: HostProcesses | undefined; readonly #client = new OpenCodeClient({ onAcpFrame: (frame) => { @@ -53,6 +56,7 @@ class OpenCodeAgentPlugin extends AgentPlugin { override onActivate(context: PluginContext): void { console.info(`${context.pluginId} activated`); + this.#processes = context.processes; this.#client.attachProcesses(context.processes); } @@ -67,7 +71,12 @@ class OpenCodeAgentPlugin extends AgentPlugin { override onStop = (): Promise => stopOpenCode(this.#client); - override onListModels = (): Promise => listOpenCodeModels(); + override onListModels = (): Promise => { + if (this.#processes === undefined) { + throw new Error("agent/listModels was called before activation"); + } + return listOpenCodeModels(this.#processes); + }; override onAcp = (frame: JsonValue): Promise | void => forwardAcpFrame(this.#client, this.#effects, frame); diff --git a/src/services/bundled-binary.ts b/src/services/bundled-binary.ts new file mode 100644 index 0000000..90ac37d --- /dev/null +++ b/src/services/bundled-binary.ts @@ -0,0 +1,34 @@ +/** + * The one place that decides where the bundled CLI lives inside the package. + * + * Two very different callers depend on this agreeing: `command.ts` asks the host to spawn this + * path at runtime, and the release packaging script stages the upstream binary at it. A mismatch + * would only surface as a broken install, so both derive it from here rather than each spelling + * the path out. + */ + +/** Directory inside the package that holds the bundled CLI. */ +export const BUNDLED_BIN_DIR = "assets/bin"; + +/** Operating systems a package can be built for, as `Deno.build.os` spells them. */ +export type TargetOs = typeof Deno.build.os; + +/** + * Names the bundled CLI for one target operating system. + * + * Windows decides executability by extension at spawn time, so the suffix is not cosmetic: a + * `.exe`-less binary there exists but cannot be started. + */ +export function bundledBinaryName(os: TargetOs): string { + return os === "windows" ? "opencode.exe" : "opencode"; +} + +/** + * Returns the package-relative path of the bundled CLI for one target operating system. + * + * The path carries no architecture segment: releases are built one package per target, so the + * binary that reaches a machine is already the right one. + */ +export function bundledBinaryPath(os: TargetOs = Deno.build.os): string { + return `${BUNDLED_BIN_DIR}/${bundledBinaryName(os)}`; +} diff --git a/src/services/command.ts b/src/services/command.ts index e2a17f3..a13e4de 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -3,91 +3,54 @@ import { HostRequestError, PluginMethodError, } from "@ora-space/plugin-sdk"; - -/** Names one concrete way to launch the OpenCode CLI. */ -export interface OpenCodeCommand { - command: string; - extraArgs: string[]; -} +import { bundledBinaryPath } from "./bundled-binary.ts"; /** - * Resolves the command that launches OpenCode. + * Names the program that launches OpenCode, in the shape `HostProcesses.spawn` accepts. * - * `ORA_OPENCODE_BIN` pins an explicit binary path, which matters on Windows where npm only - * exposes a `.cmd` shim on PATH. Otherwise the platform default is used and expanded by - * {@link spawnCandidates}. + * The two forms decide who resolves the path. `packageCommand` is package-relative and resolved + * by the host against this plugin's install root; `command` is handed to the operating system. */ -export function resolveOpenCodeCommand(): OpenCodeCommand { - const explicit = readEnv("ORA_OPENCODE_BIN"); - if (explicit !== undefined && explicit.trim() !== "") { - return { command: explicit.trim(), extraArgs: [] }; - } - return Deno.build.os === "windows" - ? { command: "opencode.cmd", extraArgs: [] } - : { command: "opencode", extraArgs: [] }; -} +export type OpenCodeProgram = + | { packageCommand: string; command?: never } + | { command: string; packageCommand?: never }; /** - * Expands one resolved command into spawn candidates in priority order. + * Resolves the program that launches OpenCode. * - * npm installs only a `.cmd` shim on Windows while scoop and choco expose `opencode.exe`; trying - * both keeps either installation style working with no user configuration. + * The bundled binary is the normal answer, and this plugin never learns — or computes — the host + * path it lives at. `ORA_OPENCODE_BIN` stays as the one escape hatch: a developer running a + * locally built OpenCode has no way to get that binary into an installed package. */ -export function spawnCandidates(command: string): string[] { - if (Deno.build.os !== "windows") { - return [command]; +export function resolveOpenCodeProgram(): OpenCodeProgram { + const explicit = readEnv("ORA_OPENCODE_BIN"); + if (explicit !== undefined && explicit.trim() !== "") { + return { command: explicit.trim() }; } - const fallback = command.toLowerCase().endsWith(".cmd") - ? "opencode" - : "opencode.cmd"; - return command === fallback ? [command] : [command, fallback]; + return { packageCommand: bundledBinaryPath() }; } /** - * Classifies a spawn failure as a missing binary. + * Rethrows one spawn failure under the classification the host should act on. * - * The host spawns the process now, so this is the host's own classification - * (`program_not_found` means the OS could not resolve the executable) rather than sniffing - * platform-specific error text. + * A missing `ORA_OPENCODE_BIN` target is local configuration, which Ora retries quietly. A bundled + * binary that will not resolve is a broken or wrong-target package: it fails identically on every + * retry, so reporting it as `agent_not_installed` would bury it under an infinite silent retry + * loop instead of surfacing the agent as failing. */ -export function isCommandNotFound(error: unknown): boolean { - return error instanceof HostRequestError && +export function rethrowSpawnFailure( + program: OpenCodeProgram, + error: unknown, +): never { + const notFound = error instanceof HostRequestError && error.kind === "program_not_found"; -} - -/** - * Runs `attempt` against every candidate for the resolved OpenCode command. - * - * The first candidate that does not throw wins. Failures are classified on the way out: a - * failure that is not "binary missing" is the real startup fault and is rethrown as-is, while an - * exhausted candidate list means OpenCode is simply absent, which Ora retries quietly. - */ -export async function tryEachCandidate( - attempt: (resolved: OpenCodeCommand) => T | Promise, -): Promise { - const resolved = resolveOpenCodeCommand(); - const candidates = spawnCandidates(resolved.command); - const failures: unknown[] = []; - for (const command of candidates) { - try { - return await attempt({ command, extraArgs: resolved.extraArgs }); - } catch (error) { - failures.push(error); - } - } - - const realFailure = failures.find((error) => !isCommandNotFound(error)); - if (realFailure !== undefined) { - throw realFailure instanceof Error - ? realFailure - : new Error(String(realFailure)); + if (notFound && program.command !== undefined) { + throw new PluginMethodError( + AGENT_NOT_INSTALLED, + `ORA_OPENCODE_BIN points at ${program.command}, which does not exist`, + ); } - throw new PluginMethodError( - AGENT_NOT_INSTALLED, - `OpenCode is not installed or not on PATH (tried: ${ - candidates.join(", ") - }); install it from https://opencode.ai/docs/ or set ORA_OPENCODE_BIN`, - ); + throw error instanceof Error ? error : new Error(String(error)); } /** Reads an env var, treating a missing read permission as an unset value. */ diff --git a/src/services/opencode-client.ts b/src/services/opencode-client.ts index d29f514..fdd86c8 100644 --- a/src/services/opencode-client.ts +++ b/src/services/opencode-client.ts @@ -1,5 +1,9 @@ import type { HostProcesses, JsonValue } from "@ora-space/plugin-sdk"; -import { tryEachCandidate } from "./command.ts"; +import { + type OpenCodeProgram, + resolveOpenCodeProgram, + rethrowSpawnFailure, +} from "./command.ts"; import { decodeLines, encodeLine } from "./ndjson.ts"; /** The subset of a spawned child process this bridge depends on, so tests can substitute one. */ @@ -14,7 +18,11 @@ export interface SpawnedProcess { export interface OpenCodeClientOptions { /** Overrides process spawning; injected by tests. Production spawns through `attachProcesses`. */ - spawn?: (command: string, args: string[], cwd: string) => SpawnedProcess; + spawn?: ( + program: OpenCodeProgram, + args: string[], + cwd: string, + ) => SpawnedProcess; /** Receives every ACP frame emitted by the CLI, in output order. */ onAcpFrame?: (frame: JsonValue) => void; /** Invoked after the CLI exits on its own, never after an explicit stop. */ @@ -36,7 +44,7 @@ interface RunningProcess { */ export class OpenCodeClient { readonly #spawn: ( - command: string, + program: OpenCodeProgram, args: string[], cwd: string, ) => SpawnedProcess | Promise; @@ -49,7 +57,7 @@ export class OpenCodeClient { constructor(options: OpenCodeClientOptions = {}) { this.#spawn = options.spawn ?? - ((command, args, cwd) => this.#spawnViaHost(command, args, cwd)); + ((program, args, cwd) => this.#spawnViaHost(program, args, cwd)); this.#onAcpFrame = options.onAcpFrame ?? (() => {}); this.#onExited = options.onExited ?? (() => {}); } @@ -79,16 +87,15 @@ export class OpenCodeClient { await this.stop(); this.#expectedExit = false; - await tryEachCandidate(async ({ command, extraArgs }) => { - const process = await this.#spawn( - command, - [...extraArgs, "acp", "--cwd", cwd], - cwd, - ); - this.#running = { process, stdinWriter: process.stdin.getWriter() }; - this.#attach(process); - return process; - }); + const program = resolveOpenCodeProgram(); + let process: SpawnedProcess; + try { + process = await this.#spawn(program, ["acp", "--cwd", cwd], cwd); + } catch (error) { + rethrowSpawnFailure(program, error); + } + this.#running = { process, stdinWriter: process.stdin.getWriter() }; + this.#attach(process); } /** @@ -193,7 +200,7 @@ export class OpenCodeClient { * `SpawnedProcess` so every other method above stays unaware of who owns the OS process. */ async #spawnViaHost( - command: string, + program: OpenCodeProgram, args: string[], cwd: string, ): Promise { @@ -202,7 +209,7 @@ export class OpenCodeClient { "OpenCodeClient cannot spawn before attachProcesses() runs", ); } - const child = await this.#processes.spawn({ command, args, cwd }); + const child = await this.#processes.spawn({ ...program, args, cwd }); return { stdin: new WritableStream({ write: (chunk) => child.write(chunk), diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index dc0daa5..e6f4aa6 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -5,6 +5,7 @@ * deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts */ import type { JsonValue } from "@ora-space/plugin-sdk"; +import { fromFileUrl } from "@std/path"; const JSON_RPC_FRAME_TYPE = 0x01; const MAX_FRAME_LENGTH = 16 * 1024 * 1024; @@ -172,13 +173,28 @@ async function handleChildProcessRequest( } } +/** + * Resolves one spawn request's program the way the real host does. + * + * `packageCommand` is joined onto the package root — this repository, since a simulated run has + * no installed package — so the simulator exercises the same two-form contract Ora enforces. + */ +function resolveSimulatedProgram(params: Record): string { + const packageCommand = params.packageCommand as string | undefined; + if (packageCommand === undefined) { + return params.command as string; + } + const packageRoot = new URL("../", import.meta.url); + return fromFileUrl(new URL(packageCommand, packageRoot)); +} + async function dispatchChildProcessMethod( method: string, params: Record, ): Promise { switch (method) { case "ora/childprocess/spawn": { - const command = params.command as string; + const command = resolveSimulatedProgram(params); const args = (params.args as string[] | undefined) ?? []; const cwd = (params.cwd as string | null | undefined) ?? undefined; const child = new Deno.Command(command, { From 8c84d8fc99df7e71c5cf3610b161567fd4fb5a89 Mon Sep 17 00:00:00 2001 From: RuihaoZhang Date: Sat, 29 Aug 2026 10:33:57 +0800 Subject: [PATCH 2/5] docs: rewrite README around purpose and installation, not architecture --- README.md | 199 +++++++++++++----------------------------------------- 1 file changed, 46 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 2a67cff..f15d537 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,68 @@ # ora-space.opencode -An **agent plugin** for Ora that publishes [OpenCode](https://opencode.ai) as a -selectable agent. The plugin runs `opencode acp` (OpenCode's native -[Agent Client Protocol](https://agentclientprotocol.com) mode) as a child -process and bridges it to Ora as a pure ACP pipe. +An **agent plugin** for [Ora](https://github.com/ora-space) that adds +[OpenCode](https://opencode.ai) as a selectable agent. Once installed, OpenCode +shows up in Ora's agent picker like any other agent — pick it, and your +conversation runs against the OpenCode CLI through its native +[Agent Client Protocol](https://agentclientprotocol.com) mode (`opencode acp`). + +## What it does + +- Publishes OpenCode as an agent inside Ora, alongside any other agent plugins + you have installed. +- Starts and stops the OpenCode CLI automatically as you switch agents — + nothing to run by hand. +- Streams OpenCode's models, sessions, and responses straight through to Ora's + UI via ACP, including the in-session model picker. +- Ships with the OpenCode CLI bundled inside the package, so there is nothing + else to install. -Nothing in Ora is hardcoded for this plugin: it is discovered from the installed -plugin directory, validated from `package.json`, and launched as an ordinary -agent provider. Deleting the directory removes the agent. - -``` -┌────────────────────────── Ora host (Rust) ───────────────────────────┐ -│ agent_runtime → plugin_agent │ -│ invoke : agent/start · agent/stop · agent/listModels │ -│ notify : agent/acp (bidirectional, payload never parsed) │ -└────────────────────────────┬─────────────────────────────────────────┘ - │ stdio, 4-byte length + 0x01 + JSON-RPC - v -┌──────────────────── this plugin (Deno process) ──────────────────────┐ -│ src/main.ts OpenCodeAgentPlugin extends AgentPlugin │ -│ src/handlers/* one module per registered API │ -│ src/services/* OpenCode CLI ownership, NDJSON framing │ -└────────────────────────────┬─────────────────────────────────────────┘ - │ NDJSON, one JSON-RPC object per line - v - opencode acp (ACP protocolVersion 1) -``` - -## Contract mapping - -| Host requirement | Implementation | -| ------------------------------------ | -------------------------------------------------------------------------- | -| `ora/register` with methods + emits | `runAgentPlugin` → SDK `defineAgent`: 3 methods, `agent/acp` emit | -| `agent/start` | `handlers/lifecycle.ts` spawns `opencode acp --cwd ` | -| `agent/stop` | kills the CLI, keeps this process alive so a later start can respawn it | -| `agent/listModels` | `handlers/models.ts`: `opencode models`, cached, curated fallback | -| `agent/acp` (both directions) | `handlers/acp.ts` + `services/opencode-client.ts`, payload never parsed | -| `ORA_OPENCODE_BIN` absent → `-32001` | `services/command.ts`; a broken _bundled_ binary is a real failure instead | -| one plugin = one agent = one process | a single plugin instance owning a single `OpenCodeClient` | - -## API registration architecture - -The plugin follows the class-based organization from Ora's API registration -guide: every registered API is a method on a base class, and the entrypoint only -mounts handler modules onto it. - -- `src/base/agent-plugin.ts` declares `abstract class AgentPlugin`. Required - APIs are `abstract`, so an incomplete plugin fails to compile; optional APIs - (`onStop`, `onActivate`, `onDeactivate`) ship default implementations. -- `runAgentPlugin` flattens the instance into a wire-name keyed dispatch table - by walking its prototype chain, so dispatch is a single map lookup and a - handler mounted as a field (`override onStart = …`) is found exactly like a - method. -- `AGENT_METHOD_ROUTES` / `AGENT_NOTIFICATION_ROUTES` hold the class-method → - JSON-RPC name mapping explicitly, because the host contract fixes the wire - names and deriving them from method names would silently break on a rename. -- Adding an API later means adding one method to the base class, one route - entry, and one handler module — the entrypoint does not grow. - -## Layout - -``` -package.json Ora manifest (ora.kind = "agent", ora.contributes.agent) -deno.json developer tasks only; Ora never reads it -src/ - main.ts entrypoint: mounts handlers onto the base class - base/agent-plugin.ts AgentPlugin base class + dispatch table + runAgentPlugin - handlers/ - lifecycle.ts agent/start, agent/stop - models.ts agent/listModels - acp.ts agent/acp (host → CLI) - services/ - opencode-client.ts owns the `opencode acp` child, both stdio pumps - command.ts which program to spawn, and how a failure is classified - bundled-binary.ts where the bundled CLI lives inside the package - ndjson.ts NDJSON line codec for the CLI's stdio -bundle.config.ts which upstream CLI is bundled, and its asset per target -scripts/package.ts builds one .orax per target; plugin-agnostic -tests/host-simulator.ts drives this plugin the way the Ora host does -``` - -`bundle.config.ts` is the only plugin-specific half of the release pipeline. -`scripts/package.ts` and `.github/workflows/release.yml` name no CLI and are -meant to be copied to another agent plugin unchanged, with only that config -rewritten. `src/services/bundled-binary.ts` is the single source of truth for -the in-package binary path, shared by the runtime spawn and the packaging step -so the two cannot drift. - -Run the packaging locally with: - -``` -deno task build -deno task package --tag v0.2.4 --repo ora-space/opencode-agent -``` +## Requirements -`gh` is the only external tool required — archives are read and written -in-process, so this works the same on a maintainer's machine as it does in CI. +- Nothing beyond Ora itself. The OpenCode CLI is bundled inside this package + under `assets/bin/opencode[.exe]` — no separate install, no `PATH` lookup. + Each release is built per platform, and the package refuses to run on a + machine it wasn't built for rather than risk launching a binary that can't + execute. +- If you'd rather run your own build of OpenCode instead of the bundled one, + set `ORA_OPENCODE_BIN` to its full path. -Every module imports `@ora-space/plugin-sdk` as a fully qualified -`jsr:@ora-space/plugin-sdk@0.1.3` specifier rather than a bare one, because Ora -launches the plugin with `deno run --no-prompt` and no import map: a bare -specifier would have nothing to resolve it against. A `jsr:` specifier needs no -import map — Deno resolves it directly against the JSR registry and caches it -locally — so this still works under Ora's launch flags. Bump the pinned version -in every import together when the SDK changes. +## Installing -## Requirements +Grab the latest `.orax` package from this repository's +[Releases](../../releases) page, or build one yourself (see below), and drop it +into Ora's plugins directory (`/plugins/`) — Ora discovers any +folder there with a `package.json` automatically. Deleting the folder removes +the agent again; there's no other install step. -- Nothing. The OpenCode CLI ships **inside** this package under - `assets/bin/opencode[.exe]`, so there is no separate install step and no PATH - lookup. Releases are built one `.orax` per target triple, and the package - self-declares its target in `[artifact]` so Ora refuses a package built for - another machine rather than landing a binary that cannot run. -- Deno, which Ora provides for plugin processes. - -`ORA_OPENCODE_BIN` still overrides the bundled binary with an explicit path, -which is how you drive a locally built OpenCode: a developer's own build has no -way into an installed package. Only that path reports `agent_not_installed` when -it is missing — a bundled binary that will not resolve means the package is -broken or was built for the wrong target, which fails identically on every retry -and so must surface rather than be retried quietly. - -The plugin itself no longer spawns anything: it asks the host to spawn the -bundled CLI by package-relative path (`packageCommand`), because a plugin is -told no host path and cannot reliably compute one. Ora launches agent plugins -with `--allow-run --allow-read --allow-env --allow-net`; of those this plugin -now only uses `--allow-env` (`ORA_OPENCODE_BIN`) and leaves the CLI's own -network access to the CLI. - -## Installation and discovery - -Ora discovers plugin packages as the direct children of -`/plugins/`, so that directory must resolve to the folder holding -this package. On Windows, a junction keeps the packages in one place: +On Windows, if you keep your installed plugins elsewhere, a junction can point +Ora's plugins directory at them: ```powershell cmd /c mklink /J "\plugins" "%USERPROFILE%\.ora\plugins\installed" ``` -Development runs (`task run:desktop`) set `ORA_DATA_DIR` to the repository's -`.data` directory, so the junction goes at `/.data/plugins`. A packaged -build uses Tauri's application data directory instead. +## Building from source -Every direct child of that directory must be a valid package: a folder without a -`package.json` is reported as a discovery issue. +``` +deno task build +deno task package --tag v0.2.4 --repo ora-space/opencode-agent +``` -## Verification +This produces one `.orax` package per target platform, with the matching +OpenCode CLI bundled inside. `gh` is the only external tool required. -`deno task simulate` runs `tests/host-simulator.ts`, which speaks Ora's binary -frame protocol to a freshly launched plugin process against the real CLI: +To verify a build works end-to-end against a locally installed OpenCode CLI: ``` -ok: register {"methods":["agent/start","agent/stop","agent/listModels"],"emits":["agent/acp"]} -ok: agent/start {"protocol":"acp","acpVersion":1} -ok: initialize {"protocolVersion":1,"agentCapabilities":{…}} -ok: session/new {"sessionId":"ses_…","configOptions":[…]} -[host] << {"method":"agent/acp",…} # streamed session/update forwarded back -ok: listModels 11 models, first {"id":"opencode/big-pickle",…} -ok: agent/stop -plugin exited with code 0 +deno task simulate ``` -`deno task check` type checks and `deno task lint` lints the same sources. +`deno task check` type checks and `deno task lint` lints the sources. ## Known limits -- `agent/start` receives the host's home directory as `cwd`; per-session working - directories travel in ACP `session/new`, which this plugin passes through. -- The model list is cached for the process lifetime, so models that appear after - a provider login need a plugin restart. -- When the CLI exits on its own the plugin logs it and lets the host observe a - stalled connection; the contract has no `agent/exited` notification yet. -- Killing the CLI on `agent/stop` is best effort; Ora retains process-tree - reaping. +- The model list is cached for the process lifetime, so models that appear + after a provider login need a plugin restart. +- Killing the CLI on agent stop is best effort; Ora retains process-tree + reaping as a backstop. From 7e9f3ad77983f760482ab2ea3a81714941d226ea Mon Sep 17 00:00:00 2001 From: RuihaoZhang Date: Sat, 29 Aug 2026 10:49:13 +0800 Subject: [PATCH 3/5] fix: remove local-dev residue and make a stuck simulation diagnosable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to the bundled-CLI change, none of which block on the SDK release. `deno.lock` carried a `links` block naming `@ora-space/plugin-sdk@0.5.0`. That is local link state, written while overriding the unpublished SDK with a checkout, and it has no business in the repository: CI's `deno.json` declares no `links` at all. Both entries were `{}` besides, so it recorded no integrity hash either. Removing it leaves the lockfile with no reference to the SDK, which is the honest state until 0.5.0 is published and `deno install` can resolve it. `host-simulator.ts` had no timeout on any step. A bundled binary that starts but never speaks ACP — a wrong-target package, or anything that is not really the CLI — left the run hanging forever with no output at all, which is a bad way to find out. Each wait is now bounded and names the step it was waiting on: timed out after 90s waiting for ACP initialize; the CLI started but never answered The bound is generous because a cold CLI start is genuinely slow; the point is that it ends. The README told readers `deno task simulate` verifies a build "against a locally installed OpenCode CLI". That has been false since the CLI moved into the package: the simulator resolves `packageCommand` against the repository root, so it runs `assets/bin/` and never anything on PATH. It also omitted that the binary has to be staged there first, so following it led straight into the hang above. It now says which binary runs and how to put it there. --- README.md | 30 +++++++++++++++++++++--------- deno.lock | 6 +----- tests/host-simulator.ts | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f15d537..51cfc6b 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ conversation runs against the OpenCode CLI through its native - Publishes OpenCode as an agent inside Ora, alongside any other agent plugins you have installed. -- Starts and stops the OpenCode CLI automatically as you switch agents — - nothing to run by hand. +- Starts and stops the OpenCode CLI automatically as you switch agents — nothing + to run by hand. - Streams OpenCode's models, sessions, and responses straight through to Ora's UI via ACP, including the in-session model picker. - Ships with the OpenCode CLI bundled inside the package, so there is nothing @@ -24,8 +24,8 @@ conversation runs against the OpenCode CLI through its native Each release is built per platform, and the package refuses to run on a machine it wasn't built for rather than risk launching a binary that can't execute. -- If you'd rather run your own build of OpenCode instead of the bundled one, - set `ORA_OPENCODE_BIN` to its full path. +- If you'd rather run your own build of OpenCode instead of the bundled one, set + `ORA_OPENCODE_BIN` to its full path. ## Installing @@ -52,17 +52,29 @@ deno task package --tag v0.2.4 --repo ora-space/opencode-agent This produces one `.orax` package per target platform, with the matching OpenCode CLI bundled inside. `gh` is the only external tool required. -To verify a build works end-to-end against a locally installed OpenCode CLI: +To drive the plugin end-to-end the way Ora's host does, stage the bundled CLI +where an installed package would have it, then run the simulator: ``` +# macOS / Linux +unzip -o dist/packages/*-.orax 'assets/bin/*' -d . +chmod +x assets/bin/opencode + +# Windows +unzip -o dist/packages/*-x86_64-pc-windows-msvc.orax 'assets/bin/*' -d . + deno task simulate ``` +The simulator resolves `packageCommand` against the repository root, so it runs +the binary under `assets/bin/` — never one on your `PATH`. That directory is +git-ignored and is only needed for this. + `deno task check` type checks and `deno task lint` lints the sources. ## Known limits -- The model list is cached for the process lifetime, so models that appear - after a provider login need a plugin restart. -- Killing the CLI on agent stop is best effort; Ora retains process-tree - reaping as a backstop. +- The model list is cached for the process lifetime, so models that appear after + a provider login need a plugin restart. +- Killing the CLI on agent stop is best effort; Ora retains process-tree reaping + as a backstop. diff --git a/deno.lock b/deno.lock index 59df66f..2a0d6a8 100644 --- a/deno.lock +++ b/deno.lock @@ -48,10 +48,6 @@ "jsr:@std/path@^1.1.6", "jsr:@std/tar@~0.1.10", "jsr:@zip-js/zip-js@^2.8.61" - ], - "links": { - "jsr:@ora-space/plugin-sdk@0.5.0": {}, - "npm:@ora-space/plugin-sdk@0.5.0": {} - } + ] } } diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index e6f4aa6..c6327e6 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -97,13 +97,22 @@ const writer = child.stdin.getWriter(); const send = (message: JsonValue) => writer.write(encodeFrame(message)); const inbound = decodeFrames(child.stdout)[Symbol.asyncIterator](); +/** + * How long any one step may wait before the run is declared stuck. + * + * Generous because a cold CLI start is genuinely slow, but bounded: without it a step that never + * gets an answer hangs the whole run with no output at all, which is exactly what a bundled binary + * that starts but does not speak ACP produces. + */ +const STEP_TIMEOUT_MS = 90_000; + /** Reads frames until one satisfies `match`, so streamed notifications never desynchronize. */ async function waitFor( match: (message: Record) => boolean, label: string, ): Promise> { while (true) { - const next = await inbound.next(); + const next = await withStepTimeout(inbound.next(), label); if (next.done) { throw new Error(`plugin closed stdout while waiting for ${label}`); } @@ -124,6 +133,32 @@ async function waitFor( } } +/** + * Fails a pending read with the step it was waiting on instead of hanging forever. + * + * The abandoned read is not cancelled: this only ever fires on the way to exiting, and naming the + * stuck step is worth more here than tidily unwinding the stream. + */ +function withStepTimeout(pending: Promise, label: string): Promise { + let timer: number | undefined; + const expiry = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `timed out after ${ + STEP_TIMEOUT_MS / 1000 + }s waiting for ${label}; the CLI started but never answered`, + ), + ), + STEP_TIMEOUT_MS, + ); + }); + return Promise.race([pending, expiry]).finally(() => + clearTimeout(timer) + ) as Promise; +} + /** One subprocess this simulator, playing the host, spawned on the plugin's behalf. */ interface SimulatedChildProcess { child: Deno.ChildProcess; From b0ff92e955fe75df3196eda00473e4c6d0dc48ad Mon Sep 17 00:00:00 2001 From: RuihaoZhang Date: Sat, 29 Aug 2026 10:51:42 +0800 Subject: [PATCH 4/5] ci: type check and lint on pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository had only a release workflow, so nothing validated a pull request and `deno task check` / `deno task lint` were enforced by nobody but the person running them locally. The job runs the repository's own tasks rather than spelling out commands, for the same reason `release.yml` delegates to `scripts/package.ts`: a plugin scaffolded from this one gets working CI by copying the file, without inheriting this plugin's file layout. Type checking will fail until @ora-space/plugin-sdk 0.5.0 is published, because resolving the module graph is what fetches it. That is left visible on purpose — it is the same blocker that stops this branch from cutting a release, and a gate that stayed green through it would be worth nothing. Lint resolves nothing and passes today, so it keeps working in the meantime. --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..16d35da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +# Deliberately generic, like release.yml: it runs the repository's own tasks rather than spelling +# out commands, so a plugin scaffolded from this one gets working CI by copying the file. + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + # Type checking resolves the whole module graph, so it is also what catches a dependency + # that cannot be fetched — including an @ora-space/plugin-sdk version that is not published + # yet. That failure is real and should stay visible rather than be worked around here. + - name: Type check + run: deno task check + + # Lint is purely syntactic and resolves nothing, so it stays meaningful even while the + # step above cannot run. + - name: Lint + run: deno task lint From 2e7406aea9d8e3c74052728bd0c7db3ca7c8b71b Mon Sep 17 00:00:00 2001 From: RuihaoZhang Date: Sat, 29 Aug 2026 10:59:56 +0800 Subject: [PATCH 5/5] chore: lock @ora-space/plugin-sdk 0.5.0 0.5.0 is published, so the specifier the repository already declared now resolves and the lockfile can record it with a real integrity hash instead of carrying no reference to the SDK at all. This is the last thing that was blocking the branch: `deno task check`, `deno task build`, and therefore the CI workflow and the release workflow all run now. --- deno.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deno.lock b/deno.lock index 2a0d6a8..9ba08bc 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,7 @@ { "version": "5", "specifiers": { + "jsr:@ora-space/plugin-sdk@0.5.0": "0.5.0", "jsr:@std/cli@^1.0.32": "1.0.32", "jsr:@std/fmt@^1.0.10": "1.0.10", "jsr:@std/internal@^1.0.14": "1.0.14", @@ -10,6 +11,9 @@ "jsr:@zip-js/zip-js@^2.8.61": "2.8.61" }, "jsr": { + "@ora-space/plugin-sdk@0.5.0": { + "integrity": "421c79eaefef92b087bd93ff9a12e48222a599fecd8c49cfcf5d74df4c48eef3" + }, "@std/cli@1.0.32": { "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", "dependencies": [ @@ -44,6 +48,7 @@ }, "workspace": { "dependencies": [ + "jsr:@ora-space/plugin-sdk@0.5.0", "jsr:@std/cli@^1.0.32", "jsr:@std/path@^1.1.6", "jsr:@std/tar@~0.1.10",