Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(opencode): add v2 adapter with Dual Support (V1 + V2 in one pack…
…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.
  • Loading branch information
nathanpride committed Sep 22, 2026
commit 95859ea3140ade40782107fb0f03dd16c0f1f2f3
717 changes: 488 additions & 229 deletions cli.bundle.mjs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions configs/opencode/opencode-v2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"context-mode"
]
}
58 changes: 58 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,64 @@ When OpenCode triggers `experimental.session.compacting` (auto on context overfl

---

### OpenCode v2 (Dual Support)

**Status:** Fully supported (v2 adapter, shipped in the same npm package as v1)

context-mode ships **one package that serves both OpenCode V1 and V2 hosts**. The package's default export carries both entrypoints: V1 hosts call `server()`, V2 hosts call `setup()`. Nothing else changes for existing V1 users — the V1 mouth is byte-identical.

**Config key (V2):** the `plugins` array (plural), not the V1 `plugin` array.

```json
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["context-mode"]
}
```

Entries may be a bare string or a `{ "package": "...", "options": { ... } }` object. Template: `configs/opencode/opencode-v2.json`.

**Hook mapping (V1 → V2):**

| V1 hook | V2 hook |
| --- | --- |
| `tool` (register) | `ctx.tool.transform` (add) |
| `tool.execute.before` / `.after` | `ctx.tool.hook("execute.before" / "execute.after")` |
| `chat.message` | `ctx.session.hook("prompt")` |
| `experimental.session.compacting` | `ctx.session.hook("compaction")` |
| `experimental.chat.system.transform` | `ctx.session.hook("context")` |

**Tool namespace:** tools register under the `ctx` namespace. The host computes the effective name as `${namespace.replaceAll(".","_")}_${sanitize(name)}`, so a bare `execute` becomes `ctx_execute` — the same names V1 users see.

**Compaction ownership:** on `ctx.session.hook("compaction")`, context-mode replaces the host's model-generated summary with its own snapshot (`ev.result = { summary }`), skipping the extra model call. The guard only fires when the snapshot is non-empty. The host retains the recent tail (≤ `compaction.keep.tokens`, ~15k) — the old `autoBlock` lever is dropped.

**Routing block + resume pointer:** injected into `ev.system` in the `context` hook. The `context` hook fires only for `kind="primary"` requests, so it never pollutes the compaction-summary path. A quorum guard (`systemHasRoutingInstructions`) keeps the block from being added twice.

**Permission posture (honest — read this):**

V2's plugin-tool permission model is **coarser than V1's per-call hooks**. Be aware of what context-mode does and does not provide on V2:

- The host creates **no per-call permission request** for plugin tools. There is **no host `ask`** for `ctx_*` calls.
- **No granular-bash parity** and **no external-directory scoping** for sandboxed ctx tools — the sandbox runs with the plugin process's own filesystem/network reach, not a host-mediated grant.
- The **only** host-side lever is a blanket deny: a `permission` rule of `resource:"*"` + `effect:"deny"` (`whollyDisabled`) removes the tool entirely.
- context-mode's single in-plugin lever is `permission: "bash"` on `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`, which routes those through the host's bash permission bucket.
- **Trust boundary is plugin installation, not per-call.** Installing `context-mode` trusts it to run code in-process. Do not install it in a tree you do not trust.

**Destructive-tool policy (V2 only):** `ctx_purge` and `ctx_upgrade` are **refused by default**. They throw a context-mode-attributed error (`disabled by context-mode policy … not a host permission denial`). Set `CONTEXT_MODE_ALLOW_DESTRUCTIVE=1` (or `true`/`yes`) to enable them. The CLI (`context-mode upgrade`) remains the supported upgrade/purge path.

**Upgrade / key migration:** `context-mode upgrade` resolves the target in this order:
1. explicit `--v2` / `--opencode2` flag → V2
2. `opencode2` binary on `PATH` → V2
3. existing `plugins` key in config → V2
4. existing `plugin` key in config → V1
5. default → V1

When switching targets, the installer writes the correct key and **removes the stale opposite key** (a leftover `plugin` alongside `plugins` would double-register). A pure-V1 config is left byte-identical. `context-mode doctor` warns when a stale opposite-generation key is present.

**Not yet on V2:** usage/token capture (V2's `session.usage.updated` / `step.ended` shape differs from V1's `message.updated` parser) is deferred — it is out of scope for the v2 adapter change.

---

### Codex CLI

**Status:** Supported (MCP active, hooks require `[features].hooks = true`)
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"zod": "^3.25.0"
},
"devDependencies": {
"@opencode/plugin": "^2.0.12",
"@opencode/schema": "^2.0.12",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.11",
"@types/turndown": "^5.0.5",
Expand Down
337 changes: 169 additions & 168 deletions server.bundle.mjs

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions src/adapters/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,10 @@ export function detectPlatform(clientInfo?: { name: string; version?: string }):
* Get the adapter instance for a given platform.
* Lazily imports platform-specific adapter modules.
*/
export async function getAdapter(platform?: PlatformId): Promise<HookAdapter> {
export async function getAdapter(
platform?: PlatformId,
pluginTarget?: import("./opencode/index.js").AdapterTarget,
): Promise<HookAdapter> {
const ?? detectPlatform().platform;

switch (target) {
Expand All @@ -651,7 +654,7 @@ export async function getAdapter(platform?: PlatformId): Promise<HookAdapter> {
case "kilo":
case "opencode": {
const { OpenCodeAdapter } = await import("./opencode/index.js");
return new OpenCodeAdapter(target);
return new OpenCodeAdapter(target, pluginTarget ?? "v1");
}

case "openclaw": {
Expand Down
Loading