Verdict: compatible as-is with OpenCode 2.2.2. No Forge source change was required for this release.
Forge declares a floor of OpenCode >=2.0.14 (engines.opencode) and is verified against the newest release at the time of writing, 2.2.2. The floor is a range, not a pin: a host on 2.0.x keeps working, but only 2.2.2 carries the evidence recorded here.
VERIFIED_OPENCODE_VERSION in src/constants/plugin.ts is the single source of truth for the verified version. test/constants/plugin.test.ts fails when it drifts from the @opencode/plugin and @opencode/client pins, from the README.md claim, or from this document.
| Package | Pin | Why |
|---|---|---|
@opencode/plugin |
2.2.2 (dependency) |
Types for define, Plugin.Context, shell.hook, TUI slots and keymaps. |
@opencode/client |
2.2.2 (devDependency) |
V2Event union consumed by src/host/v2-events.ts. |
@opentui/core |
0.7.0 (devDependency) |
Renderer the host provides to TUI plugins. |
@opentui/solid |
0.7.0 (devDependency) |
JSX runtime for src/tui.tsx. |
engines.opencode |
>=2.0.14 |
Supported floor, deliberately a range. |
peerDependencies on @opentui/* |
>=0.1.105 |
Published range, deliberately looser than the dev pin. |
@opencode/plugin@2.2.2 declares optional peers on @opentui/core and @opentui/solid pinned exactly to 0.7.0. Because they are optional peers, the looser published >=0.1.105 range still installs; the TUI surface is only verified at 0.7.0, which is what the host ships.
| Surface | What was checked | Evidence | Result |
|---|---|---|---|
Server plugin API (src/index.ts, src/host/v2.ts) |
define from @opencode/plugin/promise/plugin, plugin init through setupForgeV2, process-wide tool registration (registerForgeToolsV2), shell.hook('create.before') typing |
pnpm build + load probe: msg="loading plugin" for opencode-forge.js, five Forge agents in opencode debug agents, forge.db created, no level=ERROR/level=WARN |
Pass |
Event stream (src/host/v2-events.ts) |
Whether 2.2.2 publishes session.status / session.idle, and whether session.execution.* still brackets a turn |
Event probe: 0 session.status and 0 session.idle events; session.execution.started and session.execution.succeeded once per run |
Pass — the existing drops in normalizeV2Event are still required |
Per-location delivery (createV2SessionOwnership) |
Whether a location's plugin instance still receives another location's session events | Two locations in one server: the proj instance received session.execution.started/succeeded for the session created in the wt worktree |
Pass — per-location filtering is still required |
TUI plugin (src/tui.tsx, @opentui 0.7.0 provided by the host) |
cli.json directory entry resolution and TUI plugin setup |
TUI load probe: palette search for sandbox lists "Toggle sandbox" and "Build sandbox template" in group Forge; no error naming forge, @opentui, or solid |
Pass (load only; see Not verified) |
RPC (FORGE_RPC, src/client/v2-workspaces.ts) |
Plugin.Context RPC/workspace types used by the TUI client |
pnpm typecheck against @opencode/plugin@2.2.2; types only, no runtime contract |
Pass |
Client types (@opencode/client V2Event) |
Event union and location shape used by normalization and ownership |
pnpm typecheck; event probe confirmed every observed type is in the union |
Pass |
| Dashboard | Independent of the OpenCode version; no @opencode/* runtime import |
pnpm test --project dom test/dashboard/app-dom.test.ts |
Pass |
Sandbox (msb) |
Not exercised by these probes | msb runtime only; pnpm test covers the TypeScript facade |
Not exercised |
All probes run outside the repository. Command-line --standalone keeps every server private to the probe, and an isolated HOME/XDG tree keeps the real profile untouched.
export PROBE=$(mktemp -d)
export HOME=$PROBE/home \
XDG_CONFIG_HOME=$PROBE/config \
XDG_DATA_HOME=$PROBE/data \
XDG_STATE_HOME=$PROBE/state \
XDG_CACHE_HOME=$PROBE/cache
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME"The commands below use the Linux x64 binary; on macOS use @opencode/cli-darwin-arm64 and plain nohup instead of setsid.
Scratch repositories need a root commit (git commit --allow-empty), otherwise OpenCode cannot derive a project:
git -c user.email=probe@example.com -c user.name=probe commit --allow-empty -m initmkdir -p "$PROBE/cli" && cd "$PROBE/cli"
curl -sSL -o cli.tgz https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-2.2.2.tgz
tar xzf cli.tgz # macOS: @opencode/cli-darwin-arm64; Linux arm64: @opencode/cli-linux-arm64
./package/bin/opencode --version # opencode v2.2.2
export BIN=$PROBE/cli/package/bin/opencodeResult: opencode v2.2.2.
pnpm build && pnpm run setup --link -y # run in the Forge checkout
mkdir -p "$PROBE/proj" && cd "$PROBE/proj" && git init -q
git -c user.email=probe@example.com -c user.name=probe commit --allow-empty -qm init
$BIN debug agents --standaloneThe installer writes <XDG_CONFIG_HOME>/opencode/plugin/opencode-forge.js and the cli.json TUI entry pointing at the checkout's dist.
Results:
debug agents --standalonereturned 12 agents, includingarchitect,architect-auto,auditor,auditor-loopandcode.<XDG_DATA_HOME>/opencode/forge/forge.dbexists.<XDG_DATA_HOME>/opencode/log/opencode.logcontainsmsg="loading plugin" id=…/plugin/opencode-forge.js … role=serverand nolevel=ERRORorlevel=WARNline.
sudo apt-get install -y tmux
tmux new-session -d -s oc -x 200 -y 50 "$BIN --standalone $PROBE/proj"
sleep 20
tmux send-keys -t oc C-p
sleep 3
tmux send-keys -t oc "sandbox"
sleep 3
tmux capture-pane -p -t oc
tmux kill-session -t ocResult: the palette search lists Forge's commands, which only exist when the TUI plugin's setup ran:
Commands esc
sandbox
Toggle sandbox Run this session's agent shell, … Forge
Build sandbox template Build the sandbox template image and … Forge
The TUI surface writes no loading plugin line for cli.json entries at the default log level, so the palette is the load evidence for this surface. No log line mentions forge, @opentui, or solid as an error.
A separate profile, without Forge, records OpenCode's own event stream so the result describes the host, not Forge's reaction to it.
export HOME=$PROBE/ev/home \
XDG_CONFIG_HOME=$PROBE/ev/config \
XDG_DATA_HOME=$PROBE/ev/data \
XDG_STATE_HOME=$PROBE/ev/state \
XDG_CACHE_HOME=$PROBE/ev/cache
mkdir -p "$HOME" "$XDG_CONFIG_HOME/opencode/plugin" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME"$PROBE/ev/stub.py:
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = sys.argv[1]
def log(line):
with open(LOG, "a", encoding="utf-8") as fh:
fh.write(line + "\n")
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
log("%s %s" % (self.command, self.path))
def _json(self, payload):
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._json({"object": "list", "data": [{"id": "m", "object": "model"}]})
def do_POST(self):
length = int(self.headers.get("content-length") or 0)
raw = self.rfile.read(length).decode("utf-8", "replace")
log("POST %s %s" % (self.path, raw[:400]))
if not self.path.startswith("/v1/chat/completions"):
self._json({})
return
request = json.loads(raw) if raw else {}
model = request.get("model", "m")
if not request.get("stream", False):
self._json({
"id": "chatcmpl-probe",
"object": "chat.completion",
"created": 0,
"model": model,
"choices": [{"index": 0, "message": {"role": "assistant", "content": "pong"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
return
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.send_header("cache-control", "no-cache")
self.send_header("connection", "close")
self.end_headers()
self.close_connection = True
chunks = [
{
"id": "chatcmpl-probe",
"object": "chat.completion.chunk",
"created": 0,
"model": model,
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "pong"}, "finish_reason": None}],
},
{
"id": "chatcmpl-probe",
"object": "chat.completion.chunk",
"created": 0,
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
]
for chunk in chunks:
self.wfile.write(("data: " + json.dumps(chunk) + "\n\n").encode())
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
ThreadingHTTPServer(("127.0.0.1", 18765), Handler).serve_forever()<XDG_CONFIG_HOME>/opencode/opencode.json:
{
"provider": {
"stub": {
"npm": "@ai-sdk/openai-compatible",
"name": "Stub",
"options": { "baseURL": "http://127.0.0.1:18765/v1", "apiKey": "x" },
"models": { "m": { "name": "m" } }
}
},
"model": "stub/m"
}<XDG_CONFIG_HOME>/opencode/plugin/event-probe.js (replace <PROBE> with the absolute value of $PROBE when writing the file; the path must be absolute so both plugin copies write to one file):
const LOG = '<PROBE>/ev/events.log'
export default {
id: 'event-probe',
setup: async (ctx) => {
const { appendFileSync } = await import('node:fs')
const write = (line) => appendFileSync(LOG, JSON.stringify(line) + '\n')
void (async () => {
for await (const e of ctx.event.subscribe({})) {
write({ instance: ctx.location?.directory, type: e.type, sid: e.data?.sessionID ?? e.properties?.sessionID })
}
})()
return () => {}
},
}Run the stub in the background, then the single-location case:
setsid nohup python3 "$PROBE/ev/stub.py" "$PROBE/ev/stub.log" >/dev/null 2>&1 & echo $! > "$PROBE/ev/stub.pid"
mkdir -p "$PROBE/ev/proj" && cd "$PROBE/ev/proj" && git init -q
git -c user.email=probe@example.com -c user.name=probe commit --allow-empty -qm init
$BIN run --standalone --model stub/m "ping" # prints pong, exit 0Two locations in one server:
cd "$PROBE/ev/proj"
$BIN service set port 49911 # non-default so no other service collides
$BIN service start
git worktree add ../wt -q
: > "$PROBE/ev/events.log" # analyse only the two-location run
$BIN run --model stub/m "ping host"
cd "$PROBE/ev/wt"
$BIN run --model stub/m "ping wt"
$BIN service stop
kill "$(cat "$PROBE/ev/stub.pid")"Analysis of events.log (74 lines in the first run and 77 in a review re-run, 2 sessions; the exact count varies):
session.status: 0.session.idle: 0. Neither type is published by 2.2.2.session.execution.startedandsession.execution.succeeded: present once per session inside its own location, twice for the worktree session because both plugin instances received it.- Cross-location delivery: the
projinstance recordedsession.execution.startedandsession.execution.succeededfor the session created in thewtlocation, and every other session-scoped event for it too.createV2SessionOwnershipinsrc/host/v2-events.tsis what turns that into a single owner, so it remains required.
Both runs printed pong and exited 0.
A review re-run on 2026-10-29 reproduced probes 1–4 on 2.2.2 with the same results.
These need a real model or msb and remain owner manual checks:
- A full plan loop with sections: phase transitions, coder/auditor sessions, section audit, final audit, teardown commit and worktree removal.
- Patch-loop execution.
- Sandbox start,
exec, and network policy. - TUI sidebar rendering with live loops (only plugin load and palette commands were checked; the sidebar needs a loop row to render).
None. No Forge defect surfaced on OpenCode 2.2.2, so the probe run made no source change.