Skip to content

Opencode v2 compatibility (compaction within context-mode) - #1194

Open
nathanpride wants to merge 7 commits into
mksglu:nextfrom
nathanpride:feature/opencodev2
Open

nathanpride wants to merge 7 commits into
mksglu:nextfrom
nathanpride:feature/opencodev2

Conversation

@nathanpride

@nathanpride nathanpride commented Sep 22, 2026 •

Copy link
Copy Markdown

What / Why / How

What: OpenCode v2 support via Dual Support — one published package serves both OpenCode 1.x and 2.x hosts. Adds a v2 adapter (setup() entrypoint, tool registration, hooks, usage capture, readiness sentinel, destructive-tool policy) and — the headline — non-LLM compaction: context-mode supplies its own database-derived table-of-contents summary via SessionCompaction.result, so the compaction step completes with zero LLM calls (configurable own | passthrough).

Why: OpenCode 2's plugin loader ignores the V1 { id, server } shape — per OpenCode's migration guide, "V1 plugin implementations do not run in V2." Today context-mode silently fails to load on OpenCode 2: no ctx_* tools, no hooks, no indication anything is wrong.

Acknowledgements: This builds on the direction established by @Scratchydisk in #1171, which got this effort started. Credit where due — several pieces converge in both branches independently: the v2 dual-export shape, the full V1→V2 hook mapping, routing enforcement (deny/modify/context), per-turn usage capture via ctx.event.subscribe, and the pid-based readiness sentinel. The genuine deltas here:

  • Non-LLM compaction (own mode, the default): fix(opencode): support OpenCode 2 plugin API (V1/V2 dual export) #1171's compaction hook appends the TOC snapshot to event.system — the host's LLM still runs to narrate the summary. Here ctx.session.hook("compaction") sets SessionCompaction.result = { summary }, so the summarization model call never happens — compaction completes with zero LLM calls. Configurable via plugin options.compaction (own | passthrough), surfaced in ctx_doctor.
  • A fix for the failure mode fix(opencode): support OpenCode 2 plugin API (V1/V2 dual export) #1171 is vulnerable to: that branch builds the v2 shape via await import("@opencode/plugin") with a catch fallback that drops setup — if the import fails to resolve on a host (e.g. opencode plugin add skipping full dependency resolution), OpenCode 2 rejects the whole plugin ("Plugin must export a default definition with an id and an effect or setup function"). Here the merged default export is a plain object literal ({ id, setup, server }) that never depends on that import at runtime, and a regression test locks the invariant by simulating the import failure.
  • Destructive-tool policy (v2-only): ctx_purge/ctx_upgrade refuse by default; opt in via CONTEXT_MODE_ALLOW_DESTRUCTIVE=1. (v2 hosts create no per-call permission prompt for plugin tools, so the policy is enforced in the tool itself.)
  • v2-native usage capture: fix(opencode): support OpenCode 2 plugin API (V1/V2 dual export) #1171 listens on message.updated (the v1 event name) with the existing parseOpencodeUsage; this targets session.step.ended with session.step.started model correlation, native USD cost passthrough, and reasoning tokens folded into output per the gemini thoughtsTokenCount precedent.
  • Adapter target axis (v1/v2): CLI --v2 flag, config-as-ground-truth target detection, stale opposite-key migration (plugin → plugins) to prevent double registration — plus ctx_doctor reporting the effective target and compaction mode.

How:

  • Shared ContextModeCore (src/adapters/opencode/core.ts): SessionDB, capture/claim, routing islands, ctx tool handlers — memoized per platform::loadScopeDir. V1 and V2 are thin delivery shells over the same core.
  • Merged default export in plugin.ts: { id: "context-mode", setup: setupV2, server: createContextModePlugin } — V1 calls server(), V2 calls setup(), each ignores the other's key.
  • @opencode/plugin / @opencode/schema are type-only devDependencies (import type → erased); the runtime tree contains no @opencode/* import, so nothing new can fail to resolve on a host.
  • v2 tool input built with the existing zod3ShapeToV4 conversion ([Feature]: Plugin-only tool registration for ts-plugin adapters (OpenCode, KiloCode, OpenClaw) #574); CoreTool.run re-parses with the v3 schema for authoritative coercion and byte-identical "Invalid arguments for <name>: …" errors.
  • Tools register bare names + options.namespace: "ctx" → effective ctx_*; hooks receive the effective name.
  • Compaction: ctx.session.hook("compaction") sets ev.result = { summary } only in own mode (guard: non-empty snapshot). Routing block + resume pointer are pushed into ev.system for kind="primary" requests with a quorum guard.

Affected platforms

  • Claude Code
  • Cursor
  • VS Code Copilot (GitHub Copilot)
  • JetBrains Copilot
  • Gemini CLI
  • Qwen Code
  • OpenCode
  • KiloCode
  • Codex CLI
  • OpenClaw (Pi Agent)
  • Pi
  • Kiro
  • Antigravity
  • Zed
  • All platforms

Scope is the OpenCode adapter only. The V1 / KiloCode path is byte-identical — all 57 pre-existing v1 plugin tests pass unchanged.

Test plan

  • New v2 tests (merged into tests/adapters/opencode.test.ts per the guide's one-file-per-domain rule — 122 tests: 65 v1 + 57 v2): dual-export shape, tool registration under the ctx namespace, compaction own/passthrough/host-alias modes, routing-block quorum, usage capture (session.step.ended cost/token mapping + model correlation), destructive-policy refusal, doctor compaction-mode surfacing, and a regression guard that setup survives a simulated @opencode/plugin resolution failure (negative-verified: swapping in the buggy await-import+fallback pattern makes the test fail).
  • Full suite (rebased onto next): npx vitest run → 213 files, 4845 passed / 31 skipped / 0 failed.
  • npm run typecheck: EXIT 0.
  • Live verification against a real OpenCode 2 install (isolated OPENCODE_CONFIG_DIR + --standalone): plugin loads clean, ctx_execute runs sandboxed code end-to-end (4), ctx_stats reports from the real session DB, ctx_purge refused by default per the destructive policy.
  • Contribution-guide compliance pass (Three main rules): the one regex in shipped code (systemHasRoutingInstructions word-boundary check) was replaced with a structural delimiter walk (containsAsWord), and the two regex assertions added in tests were replaced with startsWith / indexOf containment checks. Shipped-code diff verified regex-free; rebuilt artifacts confirmed clean.
  • Untested paths, named explicitly (per "never a plausible sentence where the evidence is missing"): compaction ownership (own/passthrough) and routing-block-on-primary are exercised in the mock harness but not yet in a live long session — they require a session long enough to trigger a real compaction cycle. Tool execution, usage capture, destructive-policy refusal, and plugin load are live-verified.

Checklist

  • Tests added/updated (TDD: red → green)
  • npm test passes
  • npm run typecheck passes
  • Docs updated if needed (README, platform-support.md) — docs/platform-support.md gains an "OpenCode v2 (Dual Support)" section with the compaction-mode table; configs/opencode/opencode-v2.json template added
  • No Windows path regressions (forward slashes only)
  • Targets next branch — rebased onto next tip (e31360d); diff vs next is exactly this feature (17 files, +3461/−894 incl. rebuilt bundles)

…age)

Add an OpenCode v2 plugin mouth alongside the existing v1 one so a single
published package serves both host generations.

- core.ts: host-agnostic ContextModeCore (SessionDB, capture/claim, routing
  islands, ctx tool handlers); memoized per platform::loadScopeDir so plugin
  load is idempotent.
- plugin-v2.ts: setupV2 mouth — registers the 11 ctx tools under the `ctx`
  namespace (effective ctx_* names), routing enforcement, session/prompt
  capture, routing-block + resume-pointer injection on primary requests,
  compaction ownership (replaces the host summary with our snapshot), and a
  context-mode-owned destructive-tool policy (ctx_purge/ctx_upgrade refused
  by default; CONTEXT_MODE_ALLOW_DESTRUCTIVE=1 to enable).
- plugin.ts: merged default export {id, setup, server} — V1 hosts call
  server(), V2 hosts call setup(); ContextModePlugin named export preserved.
- index.ts: AdapterTarget (v1/v2) axis; pluginKey/oppositeKey; stale-key
  removal on target switch; doctor warns on a stale opposite key;
  detectTargetFromConfig.
- detect.ts: getAdapter(platform?, pluginTarget?) threads the target.
- cli.ts: --v2/--opencode2 flag + resolveUpgradeTarget (explicit -> PATH ->
  existing config -> v1).
- configs/opencode/opencode-v2.json: v2 `plugins`-key template.
- docs: honest v2 permission posture + trust boundary + upgrade path.

@opencode/plugin and @opencode/schema are type-only devDependencies
(import type -> erased at runtime). v1 behavior is byte-identical; the 57 v1
tests are unchanged. Full suite green (4760 passed). Live-verified against
OpenCode 2.0.11: the plugin loads via the `plugins` key and 11 ctx tools
register under the ctx namespace with the correct bash-permission bucket and
destructive set.
Port the MCP-readiness sentinel to the v2 plugin mouth and wire per-step
token/cost usage capture off the v2 event bus.

- plugin-v2: publish context-mode-mcp-ready-<pid> (refreshed every 30s,
  removed on dispose) so routing redirects no longer depend on a separate
  MCP stdio process in v2 native-tool mode. Reuses sentinelPathForPid so
  the sentinel dir/prefix and the CONTEXT_MODE_MCP_SENTINEL_DIR override
  stay consistent with the reader.
- plugin-v2: subscribe to ctx.event and correlate session.step.started
  (model keyed by assistantMessageID) with session.step.ended (cost +
  token buckets), writing one agent_usage row per step through the shared
  core. Subscription is torn down via AbortController on cleanup.
- extract: add parseOpencodeV2StepUsage — pure v2 step.ended -> counts
  mapper. Reasoning tokens are folded into output (gemini convention);
  the native USD cost is taken verbatim so the pricing catalog is bypassed.
- core: recordUsage gains an optional source label (v1 keeps
  "MessageUpdated"; v2 tags "StepEnded").

v1 behavior is unchanged. Adds 11 tests (5 pure-parse + 6 event-bus
wiring); full suite green.
The v2 host dies with "Tool result declared output without an output
schema" when a tool that declares no output schema returns an output key.
The ctx tool set declares none, so results must carry text in content.
Caught by live v2.0.11 host testing (ctx_stats); mock harness did not
enforce the contract. Adds a regression guard asserting the result has
content and no output key.

Verified live: ctx_stats, ctx_execute (sandbox), and the ctx_purge
destructive-policy refusal all behave correctly.
…octor surfacing

Add a configurable v2 compaction posture via the plugin's options.compaction:
- own (default): the DB table-of-contents is the summary; the model
  summarization call is skipped. Deterministic and model-cost-free.
- passthrough (alias host): result is left unset so the host model narrates;
  the TOC is still persisted for cross-session resume.

Surface the effective mode in the doctor under "Compaction mode" (v2 only).
Thread the v1/v2 target resolution (detectOpencodeTargetFromConfig,
config-as-ground-truth) into the CLI doctor, getDiagnosticAdapter, and the
MCP-init memo site so the check is reachable in both the CLI and the in-chat
ctx_doctor. A v1-only config never false-positives v2.

v1 behavior is unchanged; the check is gated to the v2 target.
… failure

Adds a regression test proving the merged default export still exposes a
working v2 setup() even when @opencode/plugin cannot be resolved on the
host (the mksglu#1171 failure mode: opencode plugin add skipping full dependency
resolution). Our default export is a plain object literal that never
depends on that import, so the test passes by construction — but it locks
the invariant against a future reintroduction of a dynamic-import-with-
fallback that would silently drop setup and make OpenCode 2 reject the
whole plugin.

Verified the guard is real by temporarily swapping in the buggy
await-import + fallback pattern: the test fails with
"expected 'undefined' to be 'function'", then reverted.

typecheck EXIT 0; v2 suite 57 pass.
Contrib-guide audit (Three main rules mksglu#1: no regex in shipped code or tests):

- core.ts systemHasRoutingInstructions: replace the RegExp word-boundary
  check with containsAsWord(), a structural delimiter walk over indexOf /
  charAt with explicit alphanumeric+underscore boundary classes. Same
  semantics (marker must not sit inside a longer word run), no regex.
- v2 routing test: toMatch(/^echo /) -> startsWith("echo ").
- CLI upgrade-target test: toMatch(/"--v2"[^\n]*"--opencode2"/) ->
  indexOf-based same-line containment check.

Rebuilt bundles (exports map loads the plugin from build/, bundles carry
the CLI/server; old regex confirmed absent from artifacts).

Affected suites 145 pass; build + assertions EXIT 0.
Contrib-guide compliance: 'Do NOT create new test files. Add your tests to
the existing file that covers the same domain' (adapters -> tests/
adapters/<platform>.test.ts).

Moves the 1,199-line v2 mock-harness suite into the existing OpenCode
adapter test file (now 2,115 lines, 122 tests: 65 v1 + 57 v2). Import
headers unified; the mock Plugin.Context harness, sentinel beforeEach/
afterEach, and all v2 describes carried over unchanged in behavior.

Merged file 122 pass; full suite 213 files, 4845 pass / 31 skip / 0 fail;
typecheck EXIT 0.
@nathanpride nathanpride changed the title Feature/opencodev2 Opencode v2 compatibility (compaction within context-mode) Sep 22, 2026
@nathanpride
nathanpride marked this pull request as ready for review September 22, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant