An OpenCode v2 plugin wrapper for context-mode.
Upstream context-mode ships an OpenCode v1-style plugin adapter. OpenCode v2 requires plugins that export a default definition via Plugin.define({ id, setup }), so loading the v1 adapter directly fails with:
Plugin must export a default definition with an id and an effect or setup function
This wrapper calls the upstream adapter factory and re-registers everything on the v2 API:
- ctx_* tools (
ctx_batch_execute,ctx_search,ctx_execute,ctx_stats, ...) registered natively viactx.tool.transform, with input schemas derived from the upstream zod v3 schemas converted to JSON Schema (zod v4toJSONSchema). - PreToolUse routing enforcement (
tool.execute.before) viactx.tool.hook("execute.before"), plus the MCP readiness sentinel upstream requires before any redirect fires (see below). - PostToolUse session event capture (
tool.execute.after) viactx.tool.hook("execute.after"). - User prompt capture (
chat.message) viactx.session.hook("prompt"). - PreCompact resume snapshot (
experimental.session.compacting) viactx.session.hook("compaction"). - System prompt transform (
experimental.chat.system.transform) viactx.session.hook("context"). - Token/cost usage capture (
message.updated) viactx.event.subscribe.
The plugin installs its own dependencies. OpenCode does not run npm install for local plugin directories, so index.js bootstraps context-mode into the plugin's own directory the first time it is loaded.
mkdir -p ~/.config/opencode/plugins/context-mode-opencode-v2
cp index.js ~/.config/opencode/plugins/context-mode-opencode-v2/index.jsThat is the whole setup: on first load the wrapper writes a package.json if none exists, runs npm install, and registers the ctx tools.
opencode plugin listgit clone https://github.com/aifeifei798/context-mode-opencode-v2
cd context-mode-opencode-v2
npm installnpm install is optional here too — the bootstrap runs it if node_modules/context-mode is missing.
opencode plugin add github:aifeifei798/context-mode-opencode-v2OpenCode installs package plugins and their dependencies, and opencode plugin update upgrades them.
Notes:
- Requires OpenCode v2.x and Node 18+.
better-sqlite3is only a dependency of context-mode 1.x; 2.x uses Node's built-innode:sqlite. For 1.x installs, npm 12+ blocks install scripts unlessallowScriptsis declared in package.json (the.npmrcform is no longer recognised). It is declared here, and the wrapper checks for the compiled binding (node_modules/better-sqlite3/build/Release/better_sqlite3.node) and heals it withnpm rebuild better-sqlite3if it is missing. The check is skipped entirely on 2.x.- Relative plugin paths resolve from the config file that contains the entry, so
"./plugins/context-mode-opencode-v2"in~/.config/opencode/opencode.jsonmeans~/.config/opencode/plugins/context-mode-opencode-v2.
package.json declares context-mode with a caret range, and the wrapper checks npm for a newer release once per day in the background (never blocking plugin load). The upgraded version is picked up on the next plugin reload.
Disable it with either:
{ "plugins": [{ "package": "./plugins/context-mode-opencode-v2", "options": { "autoUpdate": false } }] }or CONTEXT_MODE_AUTO_UPDATE=0 in the environment.
If a future upstream release changes the adapter export shape or hook names, the wrapper fails loudly with an actionable message instead of silently registering zero tools.
Upstream's OpenCode docs tell you to copy the routing rules into your project:
cp node_modules/context-mode/configs/opencode/AGENTS.md AGENTS.mdThat step is optional here. The adapter injects the same rules into the system prompt per session (experimental.chat.system.transform), and the wrapper maps that to ctx.session.hook("context"). PreToolUse enforcement (tool.execute.before) also runs: curl/wget and inline HTTP are rewritten, and raw-output-heavy commands get a think-in-code nudge.
Two things the wrapper fixes around that injection:
- Marker false positive. The adapter skips injection when the system prompt already contains a quorum of
ROUTING_MARKERS(<context_window_protection>,ctx_search,ctx_index). Harnesses that merely list ctx tool names in the tool catalog (Code Mode, MCP tool listings) trip that check, so the rules never reach the prompt even though no rules are present. The wrapper detects this and injects the upstream routing block itself, built fromcontext-mode/hooks/routing-block.mjswith the tool names actually registered. - Tool-name mismatch. Upstream guidance text uses the MCP-style OpenCode convention (
context-mode_ctx_execute), but native plugin tools are registered bare (ctx_execute). The wrapper rewrites guidance to the registered names, so the model is pointed at tools that exist.
That enforcement is gated upstream: mcpRedirect() returns null unless isMCPReady() finds a live context-mode-mcp-ready-<pid> sentinel in /tmp (or one touched within 90 s), and only the MCP server ever wrote it. Under OpenCode v2 the ctx tools are native plugin tools and no MCP server runs, so the gate was permanently closed and every redirect silently no-op'd. The wrapper writes the sentinel for its own PID at setup and refreshes it every 30 s; ctx_routing reports the result as PreToolUse readiness: sentinel active (<path>). Override the directory with CONTEXT_MODE_MCP_SENTINEL_DIR if /tmp is not writable — otherwise setup warns and runs with redirects disabled.
Check what is actually active:
ctx_routing({ verbose: true })
Routing status
context-mode 2.1.1 · wrapper 1.4.0
ctx tools registered: 9 (ctx_execute, ctx_batch, ctx_index, ...)
PreToolUse enforcement: 2 decision(s) observed (modify 2, context 0, deny 0)
PreToolUse readiness: sentinel active (/tmp/context-mode-mcp-ready-12345)
routing block: injected by wrapper — adapter skipped it because the tool catalog tripped its marker quorum
project rules: AGENTS.md: 0 routing marker(s)
If a session in this process has a stale tool registry, the report also prints a stale tool registry: line — see Troubleshooting.
Turn the wrapper-side injection off with "options": { "routingBlock": false } or CONTEXT_MODE_ROUTING_BLOCK=0. If you do copy configs/opencode/AGENTS.md into your project, the wrapper sees the real rules and does not duplicate them.
This package is a thin adapter, not a fork. Everything except index.js (~150 lines) comes from upstream:
| Layer | What it is | Size | License |
|---|---|---|---|
index.js (this repo) |
v1 → v2 hook/tool adapter + dependency bootstrap | ~150 lines | MIT |
context-mode/build/adapters/opencode/ |
upstream OpenCode adapter | ~1,000 lines | Elastic License 2.0 |
context-mode core engine |
indexing, search, session storage, bundles | ~10,000 lines / ~2.4 MB | Elastic License 2.0 |
better-sqlite3 |
native SQLite binding (context-mode 1.x only; 2.x uses Node's node:sqlite) |
native | MIT |
Runtime data (per-session SQLite databases, indexed content) lives in ~/.config/opencode/context-mode/{sessions,content} and is owned by the upstream engine, not by this wrapper.
Because the core is Elastic License 2.0, this wrapper may not be redistributed as a hosted or managed service that runs context-mode for third parties. Self-hosted use, local installs, and npm/GitHub distribution of the adapter with attribution to mksglu/context-mode are fine.
npm publishing is not required. OpenCode's plugin add accepts a Git package specifier, so the repository itself is the distribution channel:
opencode plugin add github:aifeifei798/context-mode-opencode-v2 # installs from the repo
opencode plugin check # reports outdated package plugins
opencode plugin update # re-resolves the repoVerified on OpenCode v2.0.30: the Git path caches the repo under ~/.cache/opencode/npm/git-<name>-<hash>/, installs context-mode as a dependency, and (on context-mode 1.x) the wrapper heals the missing better-sqlite3 binding on first load (OpenCode's package install leaves node_modules/better-sqlite3/build empty). context-mode 2.x needs no native binding.
If you do have an npm account, the same package can be published for the bare-name path:
npm login
npm publish # package.json `files` limits the payload to index.js, README, LICENSE, AGENTS.mdBefore releasing, run the offline smoke test (no OpenCode process required):
npm run check # node --check index.js
npm run smoke # stubs @opencode/plugin, calls setup(), asserts the 11 ctx tools + ctx_routing, the routing hooks, the MCP readiness sentinel, and the ctx_stats version stampTwo discovery paths exist, and mixing them loads the plugin twice:
| Path | Loaded automatically? |
|---|---|
~/.config/opencode/plugins/<dir>/index.js (global) |
Yes — direct .js/.ts files and immediate plugin package directories are discovered |
.opencode/plugins/<dir>/ in a project |
Yes |
plugins/ next to a project-root opencode.json(c) |
No — must be listed in plugins, or moved under .opencode/ |
If you previously dropped a copy into ~/.config/opencode/plugins/ and now also list the directory in plugins, both instances load. Keep exactly one:
# move the stale copy out of the discovered plugins directory
mv ~/.config/opencode/plugins/context-mode-v2 ~/.config/opencode/.backup/context-mode-v2-oldPoint the global config at the checkout so edits are picked up without re-copying files:
// ~/.config/opencode/opencode.json
{
"plugins": ["/absolute/path/to/context-mode-opencode-v2"]
}Then:
cd /absolute/path/to/context-mode-opencode-v2
npm install
npm run reload # node --check + smoke test + opencode service restartReload behaviour (verified on OpenCode v2.0.30, re-checked on v2.1.2):
- Changes under watched config directories (
~/.config/opencode/,.opencode/) reload automatically. - A local plugin directory outside those watched directories is not watched: editing
index.jsin the checkout does not apply to the running process by itself. The server reloads its plugin set when it is asked to enumerate plugins —opencode plugin listwas enough on v2.1.2 (verified: a probe line added toctx_routingstayed invisible until the command ran), andopencode service restartalways works. npm run reloadis the one-liner for the edit → verify → restart loop.@opencode/pluginis provided by OpenCode's plugin runtime; it is intentionally not a dependency of this package.
opencode plugin list
# ID VERSION SOURCE
# context-mode.v2 local /absolute/path/to/context-mode-opencode-v2/index.jsVERSION says local for every path-loaded plugin — OpenCode's Plugin.Info only carries a version for package sources (packages/schema/src/plugin.ts), and Plugin.define has no version field. The wrapper's own version therefore lives where it is actually visible:
await tools.ctx_stats() // footer: upstream version line + "wrapper v1.4.0"
await tools.ctx_routing() // header: "context-mode 2.1.1 · wrapper 1.4.0"Only a package install (opencode plugin add github:... or npm) would populate the VERSION column, at the cost of the hot-edit loop.
The server log should show the plugin being loaded without errors:
grep -i "loading plugin" ~/.local/share/opencode/log/opencode.log | tail -3Then smoke-test the tools through Code Mode:
await tools.ctx_stats()Rollback: delete the plugins entry from opencode.json and move the backup copy back into ~/.config/opencode/plugins/.
OpenCode builds a session's tool registry when the session is loaded into a server process. A session that was created before the plugin registered its ctx tools keeps the old registry until it is reloaded. The routing rules are injected per request, so they still reach that session's system prompt — the model dutifully calls ctx_* and every call fails.
Two things to know:
- Indexed events are not proof the tools are callable. The hooks (event capture, resume snapshot) can run normally while the tool registry is stale, so
~/.config/opencode/context-mode/sessions/<session>.dbwill happily show hundreds of captured events with zero working ctx calls. ctx.session.hook("context")receives anevent.toolsmap that only contains OpenCode's built-in tools, not plugin-registered ones, so that map is not a usable per-session signal.
The wrapper detects the stale registry two ways and warns once per session:
-
Tool catalog check. The harness renders the Code Mode tool catalog from the session's own registry. If that catalog lists built-ins but none of the registered ctx tools, the wrapper prints a highlighted warning to stderr:
[context-mode-v2] Detected active session ses_… without ctx tools in its tool registry. Run "opencode service restart" or create a new session.and appends a short
<context_mode_stale_registry>note to that session's system prompt so the model stops burning turns on calls that cannot resolve. -
Failed-call check. Any tool result containing
No tool named "ctx_*" is currently availableis counted, warned about, and surfaced byctx_routing:stale tool registry: 1 session(s) in this process cannot call ctx tools (last failed call: ctx_execute, 4 failure(s)) — run "opencode service restart" or start a new session
Fix: start a new session, or restart the service so the old session is reloaded into a process that has the plugin:
opencode service restartVerified on a session that had four failed ctx calls: after reload, ctx_stats returns, the PreToolUse curl/wget rewrite fires in the same session (see PreToolUse readiness in ctx_routing), and no stale-registry warning is printed.
Symptom: curl/wget/inline-HTTP commands run as-is, ctx_routing shows PreToolUse enforcement: 0 decision(s), and the smoke test fails with curl redirect rewritten: false.
Cause: upstream only rewrites commands when isMCPReady() is true, which means a live context-mode-mcp-ready-<pid> file in /tmp. Every redirect was written for the MCP server deployment; a native-plugin deployment has no MCP server. The wrapper writes that sentinel itself, so this should only happen when the write failed — /tmp read-only, or a sandbox with an unusual tmp root.
Check and fix:
ctx_routing({ verbose: true }) # look at "PreToolUse readiness"
ls -la /tmp/context-mode-mcp-ready-* # pid must be the OpenCode service process
If the report says the sentinel is missing, point it at a writable directory:
CONTEXT_MODE_MCP_SENTINEL_DIR=~/.cache/context-mode opencode service restartDo not hand-create a sentinel for a dead PID and expect it to stick: upstream probes the PID and deletes markers older than 90 s that it cannot see.
No action needed. Upstream opens every session database with PRAGMA journal_mode = WAL, PRAGMA synchronous = NORMAL, PRAGMA busy_timeout = 30000, and wraps writes in withRetry() (exponential backoff on SQLITE_BUSY / database is locked, [100, 500, 2000] ms). Confirmed on live session DBs:
sqlite3 ~/.config/opencode/context-mode/sessions/<session>.db 'PRAGMA journal_mode; PRAGMA busy_timeout;'
# wal
# 30000Concurrent agents in the same process share those DBs safely; the *-wal / *-shm side files in ~/.config/opencode/context-mode/sessions/ are the WAL mode in action.
- The wrapper is tested against context-mode 2.1.1 (and still supports 1.x) and declares it with a caret range. If upstream changes the adapter export shape (
server/ContextModePlugin), the tool result shape or hook names, the wrapper throws with an actionable message rather than silently loading nothing. - On 1.x the bootstrap also checks for
better-sqlite3's native binding (node_modules/better-sqlite3/build/Release/better_sqlite3.node). If install scripts were blocked, it reportsnative-binding-missinginstead of failing later at runtime. 2.x needs no native binding. - ctx tools are exposed through Code Mode's
executecatalog; long-running commands insidectx_batchare bounded by thetimeoutargument you pass, not by a platform default.
MIT for this wrapper. The upstream context-mode engine it depends on is Elastic License 2.0 — see Dependency boundary.