@opencode-compat/pi-bridge
v0.4.1
Published
Dynamically run unmodified OpenCode aisdk-type plugins as providers on pi or oh-my-pi (omp)
Maintainers
Readme
@opencode-compat/pi-bridge
Runs unmodified OpenCode aisdk-type plugins as providers on either
Pi-family host — pi and
oh-my-pi (omp), which is a fork of it.
Neither host is OpenCode, and neither has an @opencode-ai/plugin-shaped native
package, so the facade→native-plugin delegation OCP uses for OpenCode forks
(MiMo, Kilo) doesn't apply. Instead this package drives each host's own
extension seam, pi.registerProvider(...), and translates between AI-SDK's
doStream protocol and the host's Context / AssistantMessageEvent stream.
Adding a provider
{ "providers": [{ "package": "cursor-opencode-provider" }] }That's the whole configuration. Nothing else is required, and the provider package needs no changes, because every remaining detail is discovered from conventions OpenCode plugins already implement:
| Discovered | From the plugin's own… |
|---|---|
| provider id | auth.provider (else the package name) |
| model catalog | config hook — config.provider[id].models, models.dev entry shape |
| OAuth login | auth.methods[] where type: "oauth" — authorize() → {url, callback()} |
| API-key login | auth.methods[] where type: "api" — its prompts[] are driven through the host's own onPrompt |
| token refresh | auth.loader — plugins renew in-place and persist via client.auth.set, which this bridge captures |
| streaming | its createXxx() AI-SDK factory |
| model variants | its variants map + entry options — see below |
Because these are conventions rather than anything provider-specific, adding a different OpenCode provider plugin is the same one-line entry.
A provider whose model catalog requires authentication may show no models
before login. After a successful host login, Pi/OMP automatically refreshes
that provider's catalog; the bridge supplies the newly resolved credential to
the plugin's auth.loader before re-running its config hook. The same path
reconstructs the loader credential from the host's stored key after restart.
Pi performs a cache-only catalog refresh while restoring a session; the bridge
returns Pi's stored catalog during that phase instead of replacing it with an
empty list. Successful network refreshes are persisted through Pi's model
store, so a provider model selected in the previous session can be restored
before the background refresh completes.
At session start, the bridge also activates Pi's optional built-in find,
grep, and ls tools when the host has them in its allowed tool registry. The
provider sees Pi's find as OpenCode's canonical glob; calls are translated
back to the host executor. An explicit Pi/OMP tool allowlist remains
authoritative. Arbitrary extension and MCP tools are not enabled implicitly.
Model variants (effort / fast / …)
An OpenCode model entry may declare variants: display label → options object.
Cursor's grok-4.6, for instance, enumerates six — the cross product of
effort (low/medium/high) and fast (true/false). Neither host has a "variant"
concept, but both have a native thinking-level picker, so variants are split
along two axes:
- an effort-like dimension (
effort,reasoning, …) maps onto the host's own level picker (thinking.effortson omp,thinkingLevelMapon pi), delivered back at call time asoptions.reasoning. Provider values are aliased to host levels (extra-high→xhigh); values that name no intensity (none) are simply not offered; - a splitting dimension — by default only
fast— becomes a separate model entry; - every other varying dimension collapses to one preferred value (
truefor boolean-ish ones, so Claude keeps itsthinkingvariant); - a constant dimension never reaches the model id: context tiers already
arrive as separate OpenCode entries, so
contextadds nothing.
Between the split dimension and OpenCode's own context-tier entries, a family reaches at most four entries — {fast, non-fast} × {1M, non-1M} — each with its own effort picker:
gpt-5.6-sol low | medium | high | xhigh | max
gpt-5.6-sol-fast low | medium | high | xhigh | max
gpt-5.6-sol-1m low | medium | high | xhigh | max
gpt-5.6-sol-1m-fast (only when Cursor lists fast variants for that tier)Picking xhigh on gpt-5.6-sol-fast sends reasoning=xhigh, fast=true, while
languageModel() receives the base id the plugin knows (gpt-5.6-sol) — the
provider resolves the rest from those parameters. Set splitDimensions on a
provider entry to change which dimensions split ([] collapses everything).
This stays generic: the parameter list is located structurally (not by key
name), the selected variant's options object is forwarded verbatim, and the
entry's own options — e.g. the wire model id on Cursor's 1M-context entries —
are merged in on every call under the provider id the plugin declares.
effort is the single convention-sensitive name, and it degrades safely: an
unrecognized dimension simply becomes separate entries instead.
Subagents
The bridge also normalizes the Pi family's subagent tools to the OpenCode
task contract seen by an unmodified provider:
| Host | Live host tool | Host input | Plugin-facing input |
|---|---|---|---|
| oh-my-pi | built-in task | {agent, task} | task({description, prompt, subagent_type}) |
| pi | optional subagent extension | {agent, task} | task({description, prompt, subagent_type}) |
Translation covers the live tool catalog, emitted calls, named tool choice,
and prior call/result history. Call ids and unknown custom agent names remain
unchanged. omp's general uses its live default spawn policy and explore
maps to scout; pi's reference extension maps them to worker and scout.
No mapping activates when the tool is disabled or absent. If pi also advertises
an unrelated tool named task, the bridge exposes that host tool under the
collision-safe name pi_host_task while reserving canonical task for the
subagent executor.
OMP's hub is a built-in host tool, not an MCP server. Every task call
starts a new agent; results auto-deliver, while status and follow-up use
hub jobs, hub wait, or hub send. OMP also requires subagents to finish by
calling its hidden yield tool. OpenCode-style providers normally finish with
assistant text instead, so the bridge converts a terminal text response to the
live yield contract. It also disables OMP specialists' structured-output
schemas for canonical OpenCode task calls, preserving OpenCode's unstructured
result contract. Both behaviors are gated by the live host tools/profile and do
not affect pi or ordinary main-agent responses.
OMP's hub schema is strict and names its operation discriminator op. The
bridge accepts the common provider spelling action and translates it before
host validation, so {action: "jobs"} executes as {op: "jobs"}. If both are
present, the explicit host-native op value wins.
Interactive prompts (ask ↔ question)
omp advertises interactive multi-choice prompts as ask with
{questions:[{id, question, options, multi?}]}. OpenCode plugins expect
question with {questions:[{question, header, options[{label,description}],
multiple?}]}. When the omp profile's tools.question role is live (ask in
the catalog), the bridge remaps the catalog, calls, named tool choice, and
history — synthesizing missing id values and mapping multiple ↔ multi.
Plain pi has no question role by default.
Cursor SwitchMode / CreatePlan / GenerateImage tools
When pi-bridge.json includes cursor-opencode-provider, the extension
registers the tools that provider already bridges on:
| Tool | Host | Behavior |
|---|---|---|
| plan_enter / plan_exit | omp only | Drive native omp plan mode (ACP-shaped session APIs via AgentRegistry) |
| cursor_plan_stage | omp only | Stage Cursor CreatePlan markdown under omp's session-local local:// root and wait on omp's plan-review overlay |
| cursor_image_save | omp and pi | Commit staged Cursor image bytes (image_id only) |
cursor_plan_stage writes the session-local plan and does not return until
omp's plan-review overlay is answered (plan mode).
Approve and execute succeeds and queues implementation. Refine or dismissing
the overlay is an error, so Cursor keeps planning. Returning before that
choice let the model call plan_exit and continue with no review. plan_exit
leaves plan mode; it is not the submit.
Plain pi has no plan mode, so SwitchMode stays refused there. Image save
works on both hosts when the Cursor provider is loaded in-process. OCP loads
the save export from the same configured provider installation as the model;
its staged image ids are valid only in that module instance. Force registration
in tests with PI_BRIDGE_CURSOR_HOST_TOOLS=1.
Cursor's billed input can include several tool steps from one held Run. The
bridge preserves those raw totals for cost, and separately forwards Cursor's
checkpoint usedTokens as Pi's contextTokens. OMP uses contextTokens for
compaction timing and its history cut point. The billable input beyond the
occupied prompt is recorded as Pi orchestration usage; otherwise OMP's
overflow check mistakes a successful held Run for a prompt larger than the
model window. With snapcompact, OMP may turn
text history into image frames even when the user supplied no images; those
frames can cause one image-save call per frame on the next Cursor Run.
These tools are registered in the host's global tool registry, but OCP exposes them only to the Cursor provider call. For every other configured provider, OCP removes them from the live catalog and replay history, then rejects any provider-emitted call outside that call's advertised catalog before it reaches the host. Devin and generic models therefore cannot inherit Cursor's private mode, staging, or image lifecycle even when both providers are configured.
Path bridge
On extension load, the bridge installs Symbol.for("opencode.host.path-bridge")
for the detected host so unmodified providers resolve project/global config,
durable data, and cache under .omp / .pi (agent roots via
PI_CODING_AGENT_DIR / PI_CONFIG_DIR) instead of inventing .opencode. The
bridge exposes globalConfigDirs, globalDataDir, globalCacheDir,
projectConfigDirs, and configFileNames; provider auth, plan files,
conversation snapshots, and model caches follow those roots. A legacy
opencode.compat.path-bridge key is set to the same object for older provider
releases.
OpenCode plugins also emit camelCase essential-tool args (filePath,
oldString, workdir). Pi-family hosts validate against path / cwd /
snake_case schemas and drop unrecognized keys when more than one string field
is required — so a write of {filePath, content} arrives as {content} and
fails. The bridge remaps those aliases for live read / write / edit /
bash tools (including write to xd://… MCP devices) before host
validation. Pi's edit additionally requires edits: [{ oldText, newText }],
so a flat { oldString, newString } call is converted at that same boundary.
Pi rejects an oldText that matches more than once and has no replace-all
mode, so the provider-facing edit contract omits replaceAll rather than
advertising an option the host cannot execute. Because that contract differs
from Pi's own schema, stored edit calls are translated back to the flat shape
when replayed as history, so the model never sees a prior call in a shape its
catalog does not declare; a stored multi-edit call keeps Pi's shape, since the
flat contract cannot express more than one replacement.
OMP's read has a different pagination contract: it accepts only path and
embeds ranges as path:raw:150-229. The bridge advertises OpenCode's
{filePath, offset, limit} shape and folds explicit ranges into that selector.
The raw: prefix is required: bare :N-M gets omp's ranged-read context
padding (+1 leading / +3 trailing), which would widen OpenCode's exact window.
Plain Pi keeps its native separate offset / limit arguments unchanged.
Pi calls OpenCode's glob operation find; when find is active, the bridge
advertises it to the provider as glob and translates calls/results back to
find. Host-native keys already present win.
OMP's todo is ops-based (op: init|start|done|…). The bridge advertises it as
OpenCode todowrite / todoread and folds Cursor-style
{todos:[{content,status}]} snapshots into host ops: open-only snapshots stay
a single init (or rm when empty); snapshots that mark work completed or
cancelled fan out init → done/drop → start under derived call ids so
statuses actually land on the host (a lone init of remaining open items would
drop completions). Native {op:…} calls still pass through. Fanned-out history
is folded back into one canonical todowrite on the next provider turn.
OMP's edit is different again: it advertises a different schema per resolved
edit mode (model override, then PI_EDIT_VARIANT, then the edit.mode setting,
then the default hashline). The provider always sees OpenCode
{filePath, oldString, newString}. When the live host tool is hashline, the
bridge remaps those calls onto omp's replace-mode editor (the same
old_string/new_string contract Cursor's pi_edit frame uses) and keeps
hashline patches on the separate hashline tool. Parallel hashline calls that
share the same [path#tag] are coalesced into one multi-section host apply
(anchors still refer to the original snapshot) so the host's short per-path
snapshot history is not burned by one turn of disjoint hunks. Mode-independent
rules still apply in every mode.
When a result auto-delivers, OMP represents the custom completion notice as a
developer message with attribution: "agent". The bridge promotes only the
matching background-job completion envelope to a provider-facing user turn;
other agent-attributed developer messages remain system context. This makes the
completion—not the original request—the live request on the resumed parent turn, so providers that
split conversation history from the latest user message do not restart the
original workflow or spawn duplicate agents. Ordinary developer messages remain
system context.
The host's stable provider sessionId is also forwarded as the namespaced
x-opencode-session (unless the caller already supplied an OpenCode
session-affinity header). This
lets stateful OpenCode providers retain their conversation/checkpoint across
ordinary Pi tool turns and asynchronous parent resumptions.
omp needs no extra installation. In pi, subagents are an optional host example,
not a core tool; install its packages/coding-agent/examples/extensions/subagent
extension and agent definitions first if you want the canonical task surface.
The provider bridge continues to work normally without it.
Development helper scripts
From the OCP repository root, use the same local|npm workflow as the
MiMo/Kilo development helpers while retaining the Pi family's separate runtime
mechanism:
./scripts/ocp-dev.sh run pi
./scripts/ocp-dev.sh run pi --mode npm
./scripts/ocp-dev.sh run omp
./scripts/ocp-dev.sh run omp --mode npmlocal installs locked dependencies, builds this checkout plus the local
provider checkout, registers the bridge using the selected host's native
installer, links that host's own pi-ai runtime into the real bridge checkout,
and writes the provider's absolute dist/index.js to pi-bridge.json. npm
installs both published packages and switches the same config entry back to the
provider's bare package name. Other provider entries and optional fields on the
selected entry are preserved.
The local provider defaults to a sibling cursor-opencode-provider checkout.
Set OCP_DEV_PROVIDER_PATH and
OCP_DEV_PLUGIN for another provider. npm versions default to latest; pin
them with OCP_DEV_BRIDGE_VERSION and OCP_DEV_PLUGIN_VERSION. Set
PI_BRIDGE_CONFIG for a non-default config file, or PI_CODING_AGENT_DIR for
a non-default host agent directory.
These scripts never call ocp setup: Pi-family hosts use their native
extension loaders, and the package manifest selects an explicit Pi or OMP
entrypoint so a checkout containing both development dependencies cannot be
misdetected.
Manual local install
Two separate concerns — don't conflate them:
1. Make the host discover this extension.
omp plugin install <path-to>/opencode-plugin-compat/packages/pi-bridge # oh-my-pi
pi install <path-to>/opencode-plugin-compat/packages/pi-bridge # pi2. Point config directly at the built provider entry. This avoids relying on a sibling package being resolvable from the real bridge checkout:
# pi-bridge.json
{
"providers": [
{ "package": "/absolute/path/to/cursor-opencode-provider/dist/index.js" }
]
}3. Make the host's pi-ai resolvable from the real checkout. The helper
scripts do this portably for either host. For manual setup, link the matching
package from the installed host CLI's dependency tree into
packages/pi-bridge/node_modules.
For repeatable switching and portable host-runtime discovery, prefer
./scripts/ocp-dev.sh run pi / run omp over performing these steps by hand.
Config reference
Config lives at $PI_BRIDGE_CONFIG, else $PI_CODING_AGENT_DIR/pi-bridge.json,
else pi-bridge.json in the running host's agent dir (~/.omp/agent on
omp, ~/.pi/agent then ~/.pi on pi), then the other host. Both locations
stay in the search list so one file can still work on either, but a live
PI_BRIDGE_HOST will not pick the other host's file first. No providers
register without a config file.
Only package is required:
| Field | Purpose |
|---|---|
| package | npm specifier, path, or file:// URL of the plugin |
| providerName | Override the discovered provider id |
| api | Override the custom wire-api id (default <providerName>-bridge) |
| apiKey | Env-var name (or literal) for key auth; rendered into each host's own syntax |
| createOptions | Options for the AI-SDK factory; "$apiKey" anywhere is replaced with the resolved key |
| models | Static model list, bypassing the plugin's config hook |
| disableOAuth | Ignore the plugin's auth hook (e.g. env-var key only) |
| preferAuthMethod | "oauth" or "api" when a plugin offers both |
| factoryExport / pluginExport | Disambiguate exports when auto-detection can't |
| splitDimensions | Variant dimensions that become separate host model ids (default ["fast"]) |
Provider-id collisions. A plugin declares its own id, which may match one
the host ships natively — cursor-opencode-provider declares "cursor", and
omp has a built-in cursor provider. Since registerProvider has no collision
guard, a derived id that would shadow a host built-in is suffixed
(cursor → cursor-opencode, devin → devin-opencode on omp) with a logged explanation. An explicit
providerName is always used as-is.
Host differences handled for you
Both hosts share an ancestor, so their surfaces are alike but not identical.
Each delta below was read from host source and lives as data in
host/profile.ts:
| | oh-my-pi | pi |
|---|---|---|
| pi-ai package | @oh-my-pi/pi-ai | @earendil-works/pi-ai |
| dynamic models | fetchDynamicModels(apiKey) | refreshModels(ctx) (transactional) |
| oauth.refreshToken / getApiKey | optional | required |
| apiKey syntax | bare env-var name | $VAR / !cmd template |
| Context.systemPrompt | string[] | string |
| extra stream event | image_end | deferred done-reason |
| subagent executor | built-in task | optional subagent extension |
Normal host installation selects an explicit Pi or OMP manifest entrypoint.
Generic/programmatic loading falls back to probing which pi-ai package
resolves, and PI_BRIDGE_HOST=omp|pi remains an explicit override. Only the
event variants both hosts share are emitted, so the translation layer is
identical on each.
Provider-native MCP resource operations
The bridge can translate only AI-SDK tool calls emitted by doStream; it never
parses a consumer provider's private wire protocol. Providers that speak their
own native MCP resource protocol must decode and reply to it themselves, before
the AI-SDK stream boundary. Errors naming list_mcp_resources_exec_args or
read_mcp_resource_exec_args mean the provider's build does not settle those
native requests: upgrade to a provider version that handles its own MCP
resource operations internally (current cursor-opencode-provider releases do;
older ones must be upgraded). No resource URI or correlated result channel
reaches pi-bridge, so the bridge cannot safely reconstruct that call — it
must not vendor provider-specific wire parsing. Ordinary advertised host tools,
including an explicit read_mcp_resource tool, continue to pass through and
are translated like any other tool.
Programmatic use
import { registerOpenCodePlugin } from "@opencode-compat/pi-bridge"
export default async function (pi) {
await registerOpenCodePlugin(pi, { package: "cursor-opencode-provider" })
}registerAiSdkProvider (bridge.ts) remains available for registering an
AI-SDK provider that isn't an OpenCode plugin at all.
License: MPL-2.0 — see the monorepo README.
