OpenCode is one of the swappable agentic backends the os-server can run behind
its agent gateway. The brain is pluggable (CLAUDE.md): os-server talks to
whatever backend config.agent_runtime selects through the single
domain.AgentGateway interface, so the rest of the pipeline (HAL TTS, [HW:/…]
hardware markers, Flow Monitor SSE, sensing drain, Telegram fan-out) never knows
which brain is active.
openclaw(default): persistent WebSocket to the OpenClaw daemon. Seedocs/os-server.md+runtimes/openclaw.hermes: HTTP + SSE client against a local Hermes API server. Seedocs/agentic/hermes.md+runtimes/hermes.picoclaw: persistent WebSocket client against a local PicoClaw runtime. Seedocs/agentic/picoclaw.md+runtimes/picoclaw.codex: the OpenAI Codex CLI behind a local WS bridge. Seedocs/agentic/codex.md+runtimes/codex.claudecode: the Claude Code CLI behind a local WS bridge. Seedocs/agentic/claudecode.md+runtimes/claudecode.opencode: the opencode CLI (open-source AI coding agent) as the device brain, behind a local WS bridge. This doc. Code:runtimes/opencode/.
Source of truth is the code. This documents
runtimes/opencode/as implemented; keep it in sync on change (EN: this file, VI:docs/vi/agentic/opencode_vi.md).
Agentic-backend docs:
adding-agent-runtime.md(generic contract + how to add one) ·hermes.md·picoclaw.md·codex.md·claudecode.md· this file (OpenCode).Status: device-verified (2026-07-23,
intern-v2on opencode 1.18.4). The switch flow runs end-to-end — install → presync → gatewayd → per-turnopencode run→ reply delivered. The on-device shakeout corrected four assumptions from the initial build (all now fixed in the code + this doc): the installer's dir override, the campaign-api wire (Responses API, not chat completions), the permission flag (--auto), and the terminal event (step_finish, no session.idle). See §10.
The opencode CLI is driven per turn (like codex), so the device runs a thin
local WS bridge: the opencode.service systemd unit runs os-server opencode-gatewayd — the bridge is compiled into the os-server binary
(runtimes/opencode/gatewayd; no separate process to materialize, no Python).
The bridge exposes ws://127.0.0.1:18793/opencode/ws/ (bearer token
autonomous_opencode_token) and spawns one subprocess per turn:
opencode run --format json --auto --dir /root/.opencode/workspace [--session <id>] [--file <img>…] <prompt>
resuming the session id persisted in /root/.opencode/session.json (--session <id> — a plain flag, not a subcommand, so there is no codex-style flag-ordering
trap). Turns are strictly serialized (buffered queue + single worker). The
permissive flag is deliberate: an appliance running as root must never block on an
approval prompt — --auto auto-approves permissions not explicitly denied (the
shipped 1.18.4 flag; the dev-branch --dangerously-skip-permissions is not in
released builds). Model/provider come from opencode.json (presync-owned, §1.2)
— the bridge never passes --model.
agent_runtime in config.json picks the backend; resolution lives in
system/agent/factory.go ProvideGateway() — "opencode" →
opencode.ProvideService, anything unknown falls back to OpenClaw. On startup an
AGENT BACKEND ACTIVE → OPENCODE banner prints ws_url + conversation.
Wire constants (runtimes/opencode/constants.go, no per-unit config):
| Const | Default | Meaning |
|---|---|---|
WSURL |
ws://127.0.0.1:18793/opencode/ws/ |
Local bridge WebSocket endpoint |
Token |
autonomous_opencode_token |
Bearer token on connect; the bridge reads the same value from /root/.opencode/.env (OPENCODE_WS_TOKEN, presync-owned) |
Conversation |
device-main |
Label only — opencode owns its session ids (§4) |
State layout: the bridge's device-local state lives under /root/.opencode/
(.env, session.json, workspace/, attachments/, install.log); the
opencode CLI's own config/data live under XDG — ~/.config/opencode/
(opencode.json, AGENTS.md, skills/) and ~/.local/share/opencode/
(auth.json, sessions), with HOME=/root.
An opencode.setup switch runs the generic system/device/switch_runtime.sh,
which materializes OpenCode's embedded scripts. install.sh (one-time, self-
sufficient — a direct bash install.sh fully configures AND starts the backend):
- prerequisites
jq+curl+tar; - installs the opencode CLI via the official pinned installer
(
curl -fsSL https://opencode.ai/install | OPENCODE_INSTALL_DIR=/usr/local/bin bash -s -- --version <OPENCODE_VERSION>) — it handles arch detection (linux arm64/x64), the.tar.gzasset + extraction, and is idempotent. Two device- learned gotchas are handled: the env var must prefixbash(inVAR=x curl | bashit binds to curl, not the piped bash), and the installer still put the binary in its default~/.opencode/binon the test device — so a belt-and-suspenders step copies whatever the installer produced into/usr/local/bin/opencode(the path the unit +verifyhook use).OPENCODE_VERSIONis pinned (currently1.18.4) — the baseline for a freshly flashed image only: devices in the field update viamake upload-opencode <bare-semver>+make promote-opencode, which the bootstrap worker applies assoftware-update opencode(docs/bootstrap-ota.md§5); - runs the presync hook once (
/usr/local/bin/runtime-opencode-presync, materialized by os-server BEFORE the installer — §1.2); - writes + enables
opencode.service(ExecStart=/usr/local/bin/os-server opencode-gatewayd,EnvironmentFile=/root/.opencode/.env,HOME=/root,Restart=always) — nothing to materialize for the bridge, it ships inside os-server; then drops a cheap offlineverifyhook (command -v opencode+ os-server binary present) for switch-runtime self-heal.
Unit name == runtime name (opencode.service), so no os-runtimes/opencode/service
declaration file is needed. Install logs go to /root/.opencode/install.log
(persistent rootfs — /var/log is volatile zram on these boards).
presync.sh is embedded in os-server and materialized to
/usr/local/bin/runtime-opencode-presync. It runs before every opencode start
(switch-runtime), once at the end of install, and on every os-server boot /
config change via EnsureOnboarding (hermes pattern): EnsureOnboarding hashes
the presync-owned files (opencode.json + .env) around the run and restarts the
gateway only on a real change. It owns everything stateful:
- §1 MIGRATE — one-time persona/memory/skills copy from the openclaw
workspace, gated on the marker
/root/.opencode/.openclaw-migrated. Stops openclaw first (3 retries, non-fatal), then copiesIDENTITY.md,SOUL.md,KNOWLEDGE.md,HEARTBEAT.md,MEMORY.md,USER.mdandAGENTS.mdverbatim (opencode readsAGENTS.mdnatively — zero-translation persona slot; the Go onboarding re-injects the OS block anyway), plusmemory/into the workspace andskills/into~/.config/opencode/skills(opencode's global discovery root) only when absent. The marker is written only after a clean copy, so a failed migrate retries next run; a factory reset wiping/root/.opencodeclears it so migrate re-runs on the next switch. - §2 CONFIG — regenerates
~/.config/opencode/opencode.jsonfrom config.json viajq. It writes a top-levelmodel=campaign/<llm_model>(fallbackAuto-AI) and a custom providerprovider.campaignusing the@ai-sdk/openainpm adapter withoptions.baseURLfromllm_base_url(fallbackhttps://campaign-api.autonomous.ai/api/v1/ai/v1) andoptions.apiKey= the reference"{env:LLM_API_KEY}"(resolved from.envat launch — the real key never enters the JSON).@ai-sdk/openai(not@ai-sdk/openai-compatible) because campaign-api speaks the OpenAI Responses API, not chat completions — device-verified:{base}/chat/completions404s,{base}/responsesworks, and opencode routes to the Responses API via@ai-sdk/openai(per opencode's provider docs). The existing"mcp"object is preserved verbatim — os-server'smcp.goowns those entries (§7), so the two owners never collide. - §3 ENV — writes
/root/.opencode/.env(systemd EnvironmentFile, mode 0600):OPENCODE_WS_TOKEN(must equalconstants.goToken),OPENCODE_PORT=18793,OPENCODE_WORKSPACE=/root/.opencode/workspace, andLLM_API_KEYfromllm_api_key.
Presync also writes /etc/profile.d/agent-cli-env.sh (interactive login shells
source the active runtime's .env, so a bare opencode in an SSH/web-CLI shell
reuses the campaign key — resolved live from config.json so it stays correct
across switches).
On top of the presync run, EnsureOnboarding (onboarding.go) does the same
workspace reconcile the other backends get: seeds KNOWLEDGE.md from the embedded
template only if absent, injects the OS-managed <!-- OS DO NOT REMOVE --> blocks
into SOUL.md / AGENTS.md / HEARTBEAT.md, refreshes the global user
AGENTS.md block (~/.config/opencode/AGENTS.md), and capability-gates skills.
Markdown-only changes never restart the gateway — each opencode run re-reads the
workspace; only a presync config change or a unit self-heal restarts it.
Known gap — no heartbeat driver. The
HEAopencodeBEAT.mdblock (shared verbatim with openclaw/picoclaw) assumes a periodic gateway heartbeat poll that runs knowledge synthesis and theKNOWLEDGE.md→USER.mdpeople sync. opencode has no heartbeat driver (nothing inruntimes/opencode/readsHEAopencodeBEAT.mdon a schedule), so that block is inert here: dailymemory/*.mdis not distilled intoKNOWLEDGE.mdand people facts reachUSER.mdonly when the agent writes them during a turn.
Persona inline block (AGENTS.md). opencode auto-loads AGENTS.md into context
(project AGENTS.md in the --dir workspace + the global
~/.config/opencode/AGENTS.md). Like codex, the persona is inlined INTO the
workspace AGENTS.md via an idempotent OS block (generated from SOUL.md +
IDENTITY.md), rebuilt on every EnsureOnboarding and right after a rename
(UpdateIdentityName) so the very next turn sees the new name.
Device skills live in ~/.config/opencode/skills/<name>/SKILL.md —
opencode's global discovery root (it also honors .opencode/skills/ in the
project dir and ~/.claude/skills/ for Claude compatibility). All producers
target that XDG path: presync.sh §1 (openclaw migration), skill_watcher.go
(CDN download + the skill-change notify), and pruneUnsupportedSkills
(capability gate). Factory reset wipes ~/.config/opencode, so the set is
re-migrated from openclaw on the next EnsureOnboarding.
EnsureOnboarding also refreshes every supported skill from the CDN on boot or
config reconciliation, self-healing a local skill that was stale before the watcher
started. It sends the skill-change notification after a possible gateway restart.
The watcher logs each successful metadata poll as skill watcher: checked; a ZIP
download or extraction failure leaves that skill's version pending for retry on the
next poll.
client.go holds one persistent WebSocket to the bridge (picoclaw-shaped: bearer
token, no pairing handshake, 25s ping keepalive → pong, reconnect with backoff,
StateAgentDown LED on drop). chat.go sendChat writes one frame and returns;
the reply arrives on the read loop:
{ "type": "message.send", "id": "<reqID>", "run_id": "<runID>", "payload": { "content": "<text>",
"attachments": [{ "type": "image", "url": "data:image/jpeg;base64,…" }] } }The bridge saves attachments to /root/.opencode/attachments and passes them via
opencode run --file <path>. A {"type":"session.new"} frame makes the bridge
drop the persisted session id (§4). The bridge serializes turns and adds
request_id/run_id to turn frames, including resume retries and errors. The
adapter retains a FIFO of pending pairs and prefers explicit tags; older untagged
bridges use FIFO. Completed or interleaved frames are ignored before session updates.
A full queue returns bridge.rejected for that request/run only, preserving the
current turn and busy state. Terminal callbacks do not idle while transmitted
turns remain. Reconnect drains only unsent local events, preserving run IDs
and speaker gating. Offline callbacks retain the queue; disappearance before
any write restores its unsent tail. A failed attempted write has uncertain
delivery and is never replayed. Disconnect clears transmitted correlation,
which is never used as a replay source.
The bridge forwards the opencode run --format json JSONL event content, adding correlation fields to turn frames
(plus its own bridge.status / bridge.error / pong frames); every opencode
line carries a sessionID. The Go translator maps them onto the same
domain.WSEvent shape the OpenClaw handler consumes:
| Inbound event | Emitted domain.WSEvent |
|---|---|
first line carrying sessionID |
capture session key |
step_start |
agent lifecycle phase:start (once per turn) |
text |
buffered as the reply (device shape: part.text; flat text accepted as fallback); no token delta stream. A newer part demotes the previous to stream:thinking (see Preambles below) |
reasoning |
(ignored — thinking, not content) |
tool_use |
agent tool phase:start + phase:end pair |
step_finish / message.updated |
capture per-turn token usage (part.tokens / info.tokens) |
session.idle (synthesized by the gatewayd on clean exit) |
agent stream:assistant (whole reply as one delta) + chat state:final role:assistant + lifecycle phase:end with usage — ends the turn |
session.error / error / bridge.error |
agent lifecycle phase:error — ends the turn |
bridge.rejected |
lifecycle.error for the rejected request/run only; current turn preserved |
bridge.status / pong |
(logged / ignored) |
Terminal event (device-verified 1.18.4). opencode run --format json does
not emit a session.idle/turn.completed — a turn ends with a step_finish
whose part.reason == "stop", then the process exits. Since opencode run is a
per-turn subprocess, a clean exit (rc=0) is the turn boundary: the gatewayd
(turn.go) marks the turn ended and synthesizes a {"type":"session.idle"}
frame so the translator finalizes exactly once. The buffered text is
surfaced there as a single assistant delta before chat.final / lifecycle.end
— the N=1 case of the streaming contract, which lets the shared consumer flush TTS
[HW:/…]hardware markers atlifecycle.end.
Preambles. opencode narrates before it calls a tool, as its own text part
("Using the sensing skill for this presence event."). Joining every part would
speak that whole trail — the same leak fixed in codex (see
codex.md). So only the last text part of a turn is the reply:
each earlier one is demoted to stream:thinking (Flow Monitor only, never TTS
or a channel reply) as soon as a newer part proves it was not the reply.
Exception: a non-final part carrying a [HW:/…] marker is a real hardware
action and stays in the reply. Prompt wording cannot suppress preambles
reliably — this is the enforcement point.
Usage: token counts ride step_finish under part.tokens.{input,output,cache.read}
(also read from message.updated info.tokens when present). The translator
stashes the latest (captureUsage → lastUsage) and reads it at the synthesized
session.idle. opencode reports Anthropic-style — input EXCLUDES the cached
prefix — so the fields stay separate: input → InputTokens, cache.read → CacheReadTokens, cache.write → CacheWriteTokens, output → OutputTokens,
TotalTokens = input + cache.read + output. Folding cache read into
InputTokens (the previous mapping) made a cache-hit turn read as if it had
re-sent the whole context and hid the R figure on the turn card.
opencode owns the session: the sessionID is present on every JSONL line,
captured by the bridge and persisted in /root/.opencode/session.json, then
replayed via opencode run --session <id> (history lives under
~/.local/share/opencode/ — process exit ≠ session loss). A resumed run whose
session no longer exists is retried fresh (the bridge's resumeErrHints catch the
missing-session error). NewSession sends a session.new frame → the bridge
drops the session id → the next turn is fresh.
ShouldRotateSession is a 150k-token safety net for runaway sessions. Per
adding-agent-runtime.md §4 "No fake success",
CompactSession, UpdatePrimaryModel, and RefreshModelsConfig return
domain.ErrNotSupportedByRuntime — never nil (an llm change still applies live:
the caller falls back to EnsureOnboarding, whose presync re-reads llm_* and the
hash gate restarts the gateway). Unlike codex, GetConfigJSON does real work:
opencode's config IS JSON, so it returns ~/.config/opencode/opencode.json
verbatim (safe — the provider apiKey is a {env:LLM_API_KEY} reference; the real
secret lives only in .env).
Telegram, Slack and Discord are device-owned under OpenCode — identical to
codex (SupportedChannels() → ["telegram", "slack", "discord"]). os-server runs
the receive loops itself, driven by config.json tokens read fresh on each use, so
there is nothing runtime-side to write and no restart needed. The full behavior
(receive loop, sender-metadata prefix, silent-run tracking, stripForChannel
cleanup, typing keepers, reply fan-out at session.idle) mirrors
codex.md §5 1:1 — see runtimes/opencode/{telegram_poll,slack,discord}.go.
AddChannel / RefreshChannelConfig are honest no-op successes for the supported
channels and return domain.ErrChannelNotSupported for anything else (whatsapp).
A Telegram chat can start a folder-scoped opencode coding turn and continue it
from the phone, separate from the device-main persona turn. Each accepted turn
spawns a fresh opencode run --format json --auto --dir <folder> [--session <id>] <prompt> in os-server directly (independent of the
persistent gatewayd child); the reply is parsed from the opencode JSONL
(parseOpenCodeResult: sessionID → id, text → reply, session.idle → done)
and DMed chunked at Telegram's 4000-char limit. The exec env asserts HOME=/root
- the presync
.envpairs (there is noOPENCODE_HOME— opencode uses XDG under HOME), so the coding child resolves the sameopencode.json+ auth the gatewayd uses. A per-folder mutex serializes turns.
⚠️ Cross-folder session discovery is intentionally degraded in this pass. codex enumerated resumable threads by parsing its on-disk "rollout" JSONL store; opencode stores sessions internally under~/.local/share/opencode/andopencode session listis not confirmed to expose the working directory needed to resume in-folder across all projects. SoallCodingSessions()returns empty (aTODO(opencode-coding-sessions)incoding_sessions.go):/new <folder>and per-turn--sessionresume work, but the/resume//sessionslist shows nothing until this is wired to a verifiedopencode session list --json(or a direct read of the session store) on-device (§10). Only the allowlistedtelegram_user_idreaches any of this; the run is unsandboxed, so the allowlist is the security boundary.
opencode ships no hooks loader, so OpenClaw's emotion-acknowledge hook is
reproduced natively in Go (runtimes/opencode/emotion_ack.go, mirroring
codex/hermes): on each user-visible turn, sendChat fires {emotion:"thinking"}
to HAL — same skip prefixes, same intensity, same capability gate
(skills.SupportedHooks) as the TS handler. The companion turn-gate hook is
intentionally not mirrored (sendChat already marks the turn busy). runtimes/openclaw/hooks/emotion-acknowledge/handler.ts and the
sibling emotion_ack.go in hermes/picoclaw/codex/claudecode.
WriteMCPEntry / RemoveMCPEntry (the connector.set MQTT flow) edit the
top-level "mcp" object of ~/.config/opencode/opencode.json via
encoding/json, atomically (temp + rename) under mcpMu, then restart the gateway
so the next opencode run picks the server up. Shape translation from the
canonical OpenClaw-shaped entry: an http entry → {type:"remote", url, headers, enabled:true}; a stdio entry → {type:"local", command:[cmd, args…], environment:env, enabled:true} (opencode wants a single merged command
array and names the env map environment). RemoveMCPEntry is idempotent
(removed=false, no restart, when absent). presync regenerates only the
provider/model head and preserves the "mcp" object (§1.2), so entries survive
every sync. A switch into opencode also clones the previous runtime's MCP
servers via MCPReconcile (the write path).
ResetAgent (called by server/system/factoryreset.go on the active gateway)
preserves nothing — opencode.json/.env are regenerated by presync on the next
switch: stop opencode.service (+ verify inactive, 5s poll), disable it
(reboot resolves the device default — f_r_default_agent, else ROBOT.md
gateway.default, e.g. hermes on a Lamp — openclaw only if neither is set), wipe the bridge state dir /root/.opencode
and opencode's XDG dirs ~/.config/opencode (opencode.json, AGENTS.md,
skills/) + ~/.local/share/opencode (auth.json, sessions) and the
.openclaw-migrated marker (so presync §1 re-migrates on the next switch) — then
recreate the baseline workspace/ + attachments/ dirs (the CLI recreates its own
XDG state on first run). /root/config/agent_state.json is wiped in lockstep with
config.json by the platform reset (per adding-agent-runtime.md §7).
- Persona/memory (
system/agent/migrate_persona/runtime_opencode.go): one read + one write adapter over the opencode workspace, layout-identical to OpenClaw's (presync seeds it as a verbatim copy). Registered in theadaptersmap, so opencode migrates both ways with every other runtime. SOUL → SOUL.md, identity → its own IDENTITY.md, MEMORY + daily + KNOWLEDGE + USER to their native slots;Overwrite=truefor SOUL.rebrandToOpenCodemaps other runtimes' brand names onto OpenCode, andreOpenCodeis consumed by the openclaw/hermes/picoclaw rebrand functions for the reverse. - LLM config (
system/agent/migrate_config/runtime_opencode.go): reads/writesprovider.campaign.options.baseURLin opencode.json +LLM_API_KEYin.env, mirroring the codex adapter. - Version uplink:
opencode --versionis probed at startup (runtime.go→GetOpenCodeVersion) and reported asopencode_versionon the MQTTinfomessage (domain.MQTTInfoResponse). The cache is aversioncache.Cache:GetOpenCodeVersionre-probes whenever the binary's size/mtime changes, so an update applied under a running os-server shows up without restarting os-server. - Switch triggers: MQTT
opencode.setup(KindOpenCodeSetup), HTTPPOST /api/device/agent-runtime, and the web Settings Runtime dropdown (AgentRuntimeSection.tsx).domain.AgentRuntimesincludesopencode, so the generic switch/validate paths accept it with no per-runtime code. - Logs: the Flow Monitor "openclaw"/"openclaw-service" log tabs resolve to
journal:opencode.servicewhile opencode is active (server/logs.go).
Verified working end-to-end; four fixes came out of it (all landed):
- ✅ Installer dir — the official installer ignored
OPENCODE_INSTALL_DIR(dropped the binary in~/.opencode/bin); the env var must prefixbashand a fallback copies the binary into/usr/local/bin/opencode(§1.1). - ✅ Permission flag — the shipped
opencode runflag is--auto, not the dev-branch--dangerously-skip-permissions. - ✅ Provider wire — campaign-api serves the Responses API (
/responses), not chat completions (/chat/completions404s) → provider is@ai-sdk/openai(§1.2). - ✅ Terminal event —
opencode runemitstext(part.text) thenstep_finish(part.reason:"stop",part.tokens) and exits; nosession.idlefrom the CLI → the gatewayd synthesizes one on clean exit so the translator finalizes the reply (§3).
Still open:
OPENCODE_VERSIONis pinned to1.18.4— bump asanomalyco/opencodereleases move.- Coding-session discovery is degraded (§5) — wire it to a verified
opencode session list --json(or a direct session-store read) that exposes the working directory, then drop theTODO(opencode-coding-sessions). - Deploy note: the gatewayd runs as a separate process
(
os-server opencode-gatewaydunderopencode.service); a binary-only update needssystemctl restart opencode.servicetoo — restartingos-server.servicealone leaves the old gatewayd running (an OTA that also bumps presync config triggers the hash-gated gateway restart automatically).
The OpenCode runtime bridge selects at most one eligible skill with Jev before forwarding a text-only user request. It reads eligible static skills from the supported native roots and adds the complete selected skill and its directory to that request. This is a runtime adapter, not the hardware intent router and not a plugin installed into a standalone upstream CLI. OS-managed voice, Web/MQTT chat and ordinary messaging-channel conversations share this bridge. Standalone CLI sessions and Telegram coding-session mode run outside it and keep their normal native skill discovery.
Selection uses the shared system/lib/jevskills implementation with a three-second budget and fails open: abstention, missing credentials, invalid responses and load errors preserve the original request. System messages, slash commands and attachments skip preloading. For CLI execution, if the complete preload plus request would reach Linux’s 128 KiB single-argument limit, the bridge drops the preload, logs reason=argument_budget, and sends the original request; it never truncates a skill. Each prepared request reuses its context on a missing-session retry; no context is written to AGENTS.md or a global system prompt. The upstream runtime may retain the supplied turn in normal conversation history; the preload explicitly applies to the current request only.
Jev is disabled by default for this runtime pending native validation. The build switch is const jevEnabled = false in runtimes/opencode/gatewayd/jev.go. Enabling it after validation requires changing the Go switch, rebuilding and restarting through runtime management; environment variables cannot enable Jev. JEV_CONFIG_PATH defaults to /root/config/config.json; credentials are read from that file, never embedded in the preload. Skills with unsupported execution metadata are excluded. Malformed native settings, alternate config-root environment overrides and custom skill or permission controls leave selection to the native loader. Ancestor settings are still checked, and skill directories outside the supported discovery scope also cause abstention. It does not grant permission to execute a selected skill or change existing tool approvals. Unsupported JSONC syntax also causes abstention.
The catalog includes $HOME/.config/opencode/skills, $HOME/.claude/skills, $HOME/.agents/skills, and project .opencode/skills, .claude/skills, and .agents/skills directories. Project discovery walks from the bridge working directory to the nearest ancestor with a .git entry (repository or worktree root); without one, only the working directory is included. The presence of a project catalog inside this scope no longer disables Jev. Duplicate parsed skill names make the catalog ambiguous and skip preloading, even when one copy has unsupported execution metadata; the adapter does not invent precedence. Native plugin caches and features such as dynamic expansion, tool grants or subagent execution remain the responsibility of the native loader, not this static catalog.
There is no 32-skill limit. The complete serialized decision request must fit within 256 KiB; an oversized catalog logs reason=catalog_budget, sends no request and does not start a service-error cooldown. The decision timeout remains three seconds and the response limit remains 64 KiB. With Jev disabled, the bridge performs no catalog scan or selector request.
Local verification covers request isolation, non-user and attachment bypass, native-policy abstention and rendered child input. Mock selectors and fake runtime processes are used; live Jev, native model behavior and on-device delivery require separate integration testing.