Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 12 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
20 changes: 20 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
174 changes: 55 additions & 119 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,144 +1,80 @@
# 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.

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 <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` |

## 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 spawns and owns `opencode acp`, both stdio pumps
command.ts binary resolution, spawn candidates, not-found mapping
ndjson.ts NDJSON line codec for the CLI's stdio
tests/host-simulator.ts drives this plugin the way the Ora host does
```

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.
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.

## Requirements

- The OpenCode CLI on PATH (`opencode`, or the `opencode.cmd` shim npm installs
on Windows). Pin an explicit binary with `ORA_OPENCODE_BIN`.
- Deno, which Ora provides for plugin processes.
- 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.

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).
## Installing

## Installation and discovery
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 (`<ORA_DATA_DIR>/plugins/`) — Ora discovers any
folder there with a `package.json` automatically. Deleting the folder removes
the agent again; there's no other install step.

Ora discovers plugin packages as the direct children of
`<ORA_DATA_DIR>/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 "<ORA_DATA_DIR>\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 `<repo>/.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 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:

```
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
# macOS / Linux
unzip -o dist/packages/*-<your-triple>.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
```

`deno task check` type checks and `deno task lint` lints the same sources.
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

- `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.
- Killing the CLI on agent stop is best effort; Ora retains process-tree reaping
as a backstop.
21 changes: 21 additions & 0 deletions bundle.config.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 10 additions & 5 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading