Browse Source

feat(v2): support OpenCode v2 (opencode2) alongside v1

One published package now installs and runs on both OpenCode v1
(`opencode`) and v2 (`opencode2`). The default export becomes
`{ id, server, setup }`: v1's loader calls `.server` (the existing
plugin factory, unchanged); v2's loader decodes `{ id, setup }` and
calls `.setup` (a new adapter).

v2 adapter (src/v2/):
- wraps the v1 factory to reuse all build logic, then bridges the
  returned v1 Hooks into v2 registrations: agent/tool/command
  transforms, session.hook("context") for system+message transforms,
  tool execute hooks, and the event stream
- task->subagent prompt rewrite; v2-permissive permission base with
  v1 overlay (findLast-aware); v2 execute.after result mapped back to
  v1 output; registration health check
- typed locally (no v2 plugin package build-time dependency)

Build:
- build:v2 emits a self-contained dist/server.js (bundles zod etc.;
  only @ast-grep/napi + jsdom external) exposed via the ./server export
  subpath that v2's loader tries first

Tests: src/v2/adapters.test.ts covers parseModelRef, adaptPermissions,
rewritePromptForV2, applyAgentToDraft. 1697 tests pass.

Docs: docs/opencode-v2-compatibility.md (feature matrix, MCP config
snippet, v2-API limitations).

v1 is unaffected: the v1 loader handles the {server} object form via
its primary path; the bare-function default is no longer used.
GoldJohnKing 5 days ago
parent
commit
822c7d180a
13 changed files with 1266 additions and 4 deletions
  1. 28 0
      README.md
  2. 1 0
      codemap.md
  3. 178 0
      docs/opencode-v2-compatibility.md
  4. 5 1
      package.json
  5. 1 1
      src/index.test.ts
  6. 7 2
      src/index.ts
  7. 187 0
      src/v2/adapters.test.ts
  8. 212 0
      src/v2/adapters.ts
  9. 70 0
      src/v2/client-shim.ts
  10. 65 0
      src/v2/codemap.md
  11. 27 0
      src/v2/index.ts
  12. 385 0
      src/v2/setup.ts
  13. 100 0
      src/v2/types.ts

+ 28 - 0
README.md

@@ -132,6 +132,34 @@ bun install
 bun run build
 ```
 
+### OpenCode v2 (`opencode2`) Compatibility
+
+The plugin is **dual-compatible**: the same published package installs and runs
+on both OpenCode v1 (`opencode`) and OpenCode v2 (`opencode2`).
+
+- The package default export is `{ id, server, setup }`. v1 loads `server` (the
+  classic plugin function); v2 loads `setup` (the v2 promise-plugin adapter).
+- v2 loads the self-contained `./server` build (`dist/server.js`) via the
+  `server` export subpath, so no extra dependencies need to be resolvable on the
+  v2 host (except the optional native `@ast-grep/napi` and `jsdom` for the
+  ast-grep / webfetch tools).
+
+To use it with `opencode2`, add the package to your v2 config
+(`~/.config/opencode2/opencode.json`):
+
+```json
+{
+  "plugin": ["oh-my-opencode-slim@latest"]
+}
+```
+
+Then run `opencode2`. The orchestrator + specialist agents, tools, slash
+commands (`/deepwork`, `/reflect`, `/loop`), and the system-prompt / message
+transforms all work on v2. Configure agent models and any MCP servers in your
+v2 `opencode.json` (v2 has no programmatic MCP-registration hook, so built-in
+MCPs must be declared in config). See `docs/opencode-v2-compatibility.md` for the full
+feature matrix and limitations.
+
 ### Getting Started
 
 The installer generates both OpenAI and OpenCode Go presets, with OpenAI active by default.

+ 1 - 0
codemap.md

@@ -55,6 +55,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/tools/ast-grep/` | AST-grep binary management and AST-aware search/replace tool flow. | [View Map](src/tools/ast-grep/codemap.md) |
 | `src/tools/smartfetch/` | Fetch/extract/cache pipeline for web content and secondary-model summarization. | [View Map](src/tools/smartfetch/codemap.md) |
 | `src/utils/` | Cross-cutting helpers for logging, session metadata, resumable task aliases, system-message normalization, environment, and runtime operations. | [View Map](src/utils/codemap.md) |
+| `src/v2/` | OpenCode v2 (`opencode2`) adapter: bridges the v1 plugin factory into v2's promise-plugin transform/runtime-hook API. Loaded via `default.setup`; v1 uses `default.server` unchanged. | [View Map](src/v2/codemap.md) |
 | `scripts/` | Build/release validation and generated-artifact maintenance scripts. | [View Map](scripts/codemap.md) |
 
 ## Runtime Control Flow

+ 178 - 0
docs/opencode-v2-compatibility.md

@@ -0,0 +1,178 @@
+# OpenCode v2 (`opencode2`) Compatibility
+
+oh-my-opencode-slim installs and runs on **both** OpenCode v1 (`opencode`) and
+OpenCode v2 (`opencode2`) from a single published package. This document
+explains how the dual-compatibility works and what is supported on each host.
+
+## How it works
+
+The package's default export is an object:
+
+```ts
+export default {
+  id: 'oh-my-opencode-slim',
+  server: OhMyOpenCodeLite, // v1 plugin function (PluginInput) => Promise<Hooks>
+  setup: createV2Setup(),   // v2 promise-plugin setup (ctx) => Promise<cleanup>
+};
+```
+
+- **v1 loader** (`readV1Plugin` in `packages/opencode/src/plugin/shared.ts`)
+  detects an object with a `server` field and calls `plugin.server(input)`.
+  This is the original, unchanged v1 code path — v1 behavior is identical to
+  previous releases.
+- **v2 loader** (`PluginModule` schema in
+  `packages/core/src/plugin/supervisor.ts`) decodes `default` as
+  `{ id, setup }` (Effect Schema 4 rejects function defaults) and calls
+  `setup(ctx)` via the promise-plugin bridge.
+
+Two builds are produced:
+
+| Export | File | Build | Externals |
+|---|---|---|---|
+| `.` (main) | `dist/index.js` | `build:plugin` | zod, jsdom, @ast-grep/napi, @opencode-ai/* (shared with v1 host) |
+| `./server` | `dist/server.js` | `build:v2` | @ast-grep/napi, jsdom only (self-contained for v2) |
+
+v2's plugin resolver tries the `server` subpath first
+(`subpaths: ["server", ""]`), so a v2 package install loads the self-contained
+`dist/server.js`. v1 uses the main entry.
+
+## The v2 adapter (`src/v2/setup.ts`)
+
+`setup(ctx)` wraps the existing v1 factory rather than reimplementing it:
+
+1. Builds a v1-shaped `PluginInput` from the v2 context (`process.cwd()` for
+   `directory`; a shim `client` that delegates `session.abort/prompt/messages`,
+   `app.log`, and `tui.showToast` to the v2 context or graceful no-ops).
+2. Invokes `OhMyOpenCodeLite(pluginInput)` to reuse **all** existing build
+   logic (config, agents, tools, hooks, job board, multiplexer, companion).
+3. Runs the v1 `config()` hook against a synthesized config to resolve agent
+   models and the slash commands.
+4. Bridges the returned v1 `Hooks` into v2 registrations:
+   - `agent` → `ctx.agent.transform` (model/prompt/permission adaptation +
+     `subagent`/`execute` permission mapping + prompt rewrite `task`→`subagent`)
+   - `tool` → `ctx.tool.transform` (zod shape → JSON schema; execute shimmed)
+   - `command` → `ctx.command.transform` (deepwork/reflect/loop)
+   - `experimental.chat.system.transform` +
+     `experimental.chat.messages.transform` → `ctx.session.hook("context")`
+     (SystemPart[]/Message.content shape conversion)
+   - `tool.execute.before/after` → `ctx.tool.hook`
+   - `event` → `ctx.event.subscribe()` loop
+   - `dispose` → returned cleanup
+
+Each bridge is independently try/catch-guarded so one failure cannot disable
+the rest.
+
+## Feature matrix
+
+| Capability | v1 (`opencode`) | v2 (`opencode2`) | Notes |
+|---|---|---|---|
+| Orchestrator + specialist agents | ✅ | ✅ | |
+| Agent prompts / system injection | ✅ | ✅ | via `session.hook("context")` |
+| Delegation to subagents | ✅ `task` | ✅ `subagent` | prompts rewritten for v2 |
+| Tools (ast-grep, webfetch, cancel_task, wait_for_user, acp_run) | ✅ | ✅* | `*` ast-grep/webfetch need `@ast-grep/napi`/`jsdom` resolvable |
+| Slash commands `/deepwork` `/reflect` `/loop` | ✅ | ✅ | |
+| Message transforms (phase reminder, skills filter, image routing, display-name rewrite) | ✅ | ✅ | |
+| Event handling (session tracking, lifecycle) | ✅ | ✅ | |
+| Tool execute hooks (apply-patch recovery, task-session, json-recovery) | ✅ | ✅ | |
+| Built-in MCPs (context7, grep.app) | ✅ | ⚠️ config-only | v2 has no programmatic MCP hook; add 2 lines to `opencode.json` — see [below](#restoring-built-in-mcps-on-v2) |
+| `/preset` (interactive switcher) | ✅ | ❌ at load only | the switcher is a v1-TUI 3-level UI; on v2 set `"preset"` in the config file (applies at load) |
+| Foreground model fallback (rate-limit failover) | ✅ | ❌ | v2 locks the model at session creation; the plugin API has no per-prompt model override, session model-setter, or `/model` command, so mid-flight switching is impossible |
+| Multiplexer (tmux/zellij/herdr/cmux panes) | ✅ | ❌ | v1-TUI-pane integration; v2 renders subagents natively instead |
+| Companion app | ✅ | ⚠️ unverified | independent desktop app; test separately against v2 |
+
+## Installing on v2
+
+Add to `~/.config/opencode2/opencode.json`:
+
+```json
+{
+  "plugin": ["oh-my-opencode-slim@latest"]
+}
+```
+
+For local development, point at the built `dist/server.js` directly:
+
+```json
+{
+  "plugin": ["/path/to/oh-my-opencode-slim/dist/server.js"]
+}
+```
+
+Then build:
+
+```bash
+bun install
+bun run build   # produces dist/index.js (v1) AND dist/server.js (v2)
+```
+
+Verify with `opencode2 run "list your specialist agents" --standalone` — the
+orchestrator should name explorer, librarian, oracle, designer, fixer.
+
+## Configuring models on v2
+
+Agent models are resolved the same way as v1 (per-agent `model` in
+`oh-my-opencode-slim.json`, or inherited from the session/host default). On v2,
+set a working provider+model in your v2 config or the plugin's config file so
+delegated subagents can run.
+
+> **Rate-limit fallback is not available on v2.** v2 locks a session's model at
+> creation; the plugin context exposes no per-prompt model override, no
+> session-level model setter, and no `/model` command. If you hit a 429/rate
+> limit, switch the model manually (start a new session or change the configured
+> model) — the plugin cannot do this automatically on v2.
+
+## Restoring built-in MCPs on v2
+
+v2 has no programmatic MCP-registration hook, so the plugin's two built-in
+remote MCPs are not auto-registered. They are plain remote URLs — copy this into
+your `~/.config/opencode2/opencode.json` to restore them:
+
+```json
+{
+  "mcp": {
+    "context7": {
+      "type": "remote",
+      "url": "https://mcp.context7.com/mcp",
+      "headers": { "CONTEXT7_API_KEY": "$CONTEXT7_API_KEY" }
+    },
+    "gh_grep": { "type": "remote", "url": "https://mcp.grep.app" }
+  }
+}
+```
+
+(`context7` needs `CONTEXT7_API_KEY`; `gh_grep` needs nothing. Drop either key
+if unused.) The librarian agent uses these for library-docs lookup and
+GitHub-wide code search; without them it still works via `webfetch`.
+
+## Limitations
+
+These are **v2 API constraints**, not adapter gaps — they cannot be fixed in the
+plugin without v2 adding the corresponding capability:
+
+- **Foreground model fallback impossible.** v2's `SessionPromptInput` has no
+  `model` field, the plugin `SessionDomain` exposes only
+  `create/get/prompt/generate/command/synthetic/interrupt` (no model setter),
+  and there is no `/model` command. A session's model is fixed at creation, so
+  the plugin cannot switch models on a rate-limited foreground session.
+  v1-only.
+- **Interactive `/preset` switcher impossible.** The switcher is a three-level
+  v1-TUI UI (`@opentui/solid`). v2 slash commands are template-only (no
+  interactive UI, no execute handler). **Workaround:** set `"preset"` in
+  `oh-my-opencode-slim.json` — it applies at plugin load and resolves all agent
+  models correctly.
+- **No programmatic MCP registration.** v2's plugin context has no MCP domain.
+  Declare MCPs in `opencode.json` (snippet above).
+- **Multiplexer panes.** tmux/zellij/herdr integration is a v1-TUI feature; v2
+  renders subagents natively, so this is intentionally not ported.
+
+These are adapter/environment caveats that can be worked around:
+
+- **Path-based dev loading.** When v2 loads the plugin by absolute file path it
+  appends a `?mtime=` cache-busting query, which can break resolution of
+  externalized bare imports (`@ast-grep/napi`, `jsdom`) from the plugin's
+  `node_modules`. The plugin still loads (these are lazy-imported only by the
+  ast-grep and webfetch tools); install as a package or ensure the externals are
+  resolvable to enable those tools locally.
+- **directory source.** v2's plugin context does not expose the project
+  directory, so the adapter uses `process.cwd()`. Run `opencode2` from your
+  project root (or use `--standalone`, which sets cwd to the project).

+ 5 - 1
package.json

@@ -9,6 +9,9 @@
       "import": "./dist/index.js",
       "types": "./dist/index.d.ts"
     },
+    "./server": {
+      "import": "./dist/server.js"
+    },
     "./tui": {
       "import": "./dist/tui.js",
       "types": "./dist/tui.d.ts"
@@ -52,8 +55,9 @@
   "scripts": {
     "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
     "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external @opentui/core --external @opentui/solid --external jsdom --external zod",
+    "build:v2": "bun build src/index.ts --outfile dist/server.js --target node --format esm --external @ast-grep/napi --external jsdom",
     "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external jsdom --external zod",
-    "build": "bun run clean:dist && bun run build:plugin && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",
+    "build": "bun run clean:dist && bun run build:plugin && bun run build:v2 && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",
     "prepare": "bun run build",
     "contributors:add": "all-contributors add",
     "contributors:check": "all-contributors check",

+ 1 - 1
src/index.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 import { mkdtemp, rm } from 'node:fs/promises';
-import plugin from './index';
+import { OhMyOpenCodeLite as plugin } from './index';
 
 function createPluginClient(
   noop: () => Promise<unknown>,

+ 7 - 2
src/index.ts

@@ -78,6 +78,7 @@ import { isPluginDisabledByEnv } from './utils/env';
 import { initLogger, log } from './utils/logger';
 import { SessionMetadataStore } from './utils/session-metadata';
 import { collapseSystemInPlace } from './utils/system-collapse';
+import { createV2Setup } from './v2';
 
 /**
  * Best-effort log to opencode's app logger.
@@ -126,7 +127,7 @@ async function probeJSDOM(): Promise<string | null> {
 // re-runs, it checks this variable and applies the runtime preset instead
 // of the config file's preset. State lives in config/runtime-preset.ts.
 
-const OhMyOpenCodeLite: Plugin = async (ctx) => {
+export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
   initLogger(sessionId);
 
@@ -1329,7 +1330,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   };
 };
 
-export default OhMyOpenCodeLite;
+export default {
+  id: 'oh-my-opencode-slim',
+  server: OhMyOpenCodeLite,
+  setup: createV2Setup(),
+};
 
 export type {
   AgentName,

+ 187 - 0
src/v2/adapters.test.ts

@@ -0,0 +1,187 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  adaptPermissions,
+  applyAgentToDraft,
+  parseModelRef,
+  rewritePromptForV2,
+} from './adapters';
+import type { V2AgentDraft } from './types';
+
+describe('parseModelRef', () => {
+  test('parses provider/model', () => {
+    expect(parseModelRef('anthropic/claude-3.5')).toEqual({
+      providerID: 'anthropic',
+      id: 'claude-3.5',
+    });
+  });
+
+  test('undefined for non-string', () => {
+    expect(parseModelRef(undefined)).toBeUndefined();
+    expect(parseModelRef(42)).toBeUndefined();
+  });
+
+  test('undefined when no provider separator', () => {
+    expect(parseModelRef('claude')).toBeUndefined();
+  });
+
+  test('undefined for degenerate slashes', () => {
+    expect(parseModelRef('/claude')).toBeUndefined(); // empty provider
+    expect(parseModelRef('anthropic/')).toBeUndefined(); // empty id
+  });
+});
+
+describe('adaptPermissions', () => {
+  test('returns the v2 permissive base for no permission', () => {
+    const rules = adaptPermissions(undefined);
+    // Must include the broad allow so v2-native tools (subagent, execute) work.
+    expect(rules).toContainEqual({
+      action: '*',
+      resource: '*',
+      effect: 'allow',
+    });
+    expect(rules.length).toBeGreaterThanOrEqual(5);
+  });
+
+  test('shorthand string applies to everything', () => {
+    const rules = adaptPermissions('ask');
+    expect(rules.at(-1)).toEqual({ action: '*', resource: '*', effect: 'ask' });
+  });
+
+  test('maps v1 task -> v2 subagent', () => {
+    const rules = adaptPermissions({ task: 'allow' });
+    expect(rules).toContainEqual({
+      action: 'subagent',
+      resource: '*',
+      effect: 'allow',
+    });
+  });
+
+  test('maps v1 bash -> v2 execute and bash', () => {
+    const rules = adaptPermissions({ bash: 'deny' });
+    expect(rules).toContainEqual({
+      action: '*',
+      resource: 'execute',
+      effect: 'deny',
+    });
+    expect(rules).toContainEqual({
+      action: '*',
+      resource: 'bash',
+      effect: 'deny',
+    });
+  });
+
+  test('nested permission object becomes action/resource rule', () => {
+    const rules = adaptPermissions({ skill: { codemap: 'allow' } });
+    expect(rules).toContainEqual({
+      action: 'codemap',
+      resource: 'skill',
+      effect: 'allow',
+    });
+  });
+
+  test('explicit deny is appended after the permissive base (last-wins)', () => {
+    // v2 evaluates with findLast, so a deny must come after the base * * allow
+    // to actually deny.
+    const rules = adaptPermissions({ webfetch: 'deny' });
+    const denyIdx = rules.findIndex(
+      (r) => r.resource === 'webfetch' && r.effect === 'deny',
+    );
+    const broadAllowIdx = rules.findIndex(
+      (r) => r.action === '*' && r.resource === '*' && r.effect === 'allow',
+    );
+    expect(denyIdx).toBeGreaterThan(broadAllowIdx);
+  });
+});
+
+describe('rewritePromptForV2', () => {
+  test('rewrites delegation call + param', () => {
+    expect(
+      rewritePromptForV2(
+        "task(subagent_type='explorer', description='x', prompt='y')",
+      ),
+    ).toBe("subagent(agent='explorer', description='x', prompt='y')");
+  });
+
+  test('passes through non-strings unchanged', () => {
+    expect(rewritePromptForV2(undefined)).toBeUndefined();
+    expect(rewritePromptForV2(42)).toBe(42);
+  });
+
+  test('rewrites every occurrence', () => {
+    expect(rewritePromptForV2('task(a)\ntask(b)')).toBe(
+      'subagent(a)\nsubagent(b)',
+    );
+  });
+});
+
+describe('applyAgentToDraft', () => {
+  function recorder(): {
+    draft: V2AgentDraft;
+    calls: Array<{ id: string; agent: Record<string, unknown> }>;
+  } {
+    const calls: Array<{ id: string; agent: Record<string, unknown> }> = [];
+    const draft: V2AgentDraft = {
+      list: () => [],
+      get: () => undefined,
+      default: () => {},
+      remove: () => {},
+      update: (id, update) => {
+        const agent: Record<string, unknown> = {};
+        update(agent);
+        calls.push({ id, agent });
+      },
+    };
+    return { draft, calls };
+  }
+
+  test('sets id/name/mode and rewrites the prompt into system', () => {
+    const { draft, calls } = recorder();
+    applyAgentToDraft(draft, 'explorer', {
+      description: 'recon',
+      prompt: "Delegate via task(subagent_type='x')",
+      mode: 'subagent',
+    });
+    expect(calls).toHaveLength(1);
+    expect(calls[0].id).toBe('explorer');
+    expect(calls[0].agent).toMatchObject({
+      id: 'explorer',
+      name: 'explorer',
+      mode: 'subagent',
+      description: 'recon',
+      system: "Delegate via subagent(agent='x')",
+    });
+  });
+
+  test('defaults orchestrator to primary mode', () => {
+    const { draft, calls } = recorder();
+    applyAgentToDraft(draft, 'orchestrator', {});
+    expect(calls[0].agent.mode).toBe('primary');
+  });
+
+  test('parses model into a Model.Ref', () => {
+    const { draft, calls } = recorder();
+    applyAgentToDraft(draft, 'a', { model: 'anthropic/claude' });
+    expect(calls[0].agent.model).toEqual({
+      id: 'claude',
+      providerID: 'anthropic',
+    });
+  });
+
+  test('permission deny beats tools-list allow (tools first, last-wins)', () => {
+    const { draft, calls } = recorder();
+    applyAgentToDraft(draft, 'a', {
+      tools: ['webfetch'],
+      permission: { webfetch: 'deny' },
+    });
+    const rules = calls[0].agent.permissions as Array<Record<string, unknown>>;
+    const toolsAllowIdx = rules.findIndex(
+      (r) =>
+        r.resource === 'webfetch' && r.effect === 'allow' && r.action === '*',
+    );
+    const denyIdx = rules.findIndex(
+      (r) => r.resource === 'webfetch' && r.effect === 'deny',
+    );
+    expect(toolsAllowIdx).toBeGreaterThanOrEqual(0);
+    expect(denyIdx).toBeGreaterThan(toolsAllowIdx); // deny wins under findLast
+  });
+});

+ 212 - 0
src/v2/adapters.ts

@@ -0,0 +1,212 @@
+/**
+ * Shape adapters: convert v1 plugin objects into v2 registration shapes.
+ *
+ * - `parseModelRef`: "provider/model" string → v2 Model.Ref.
+ * - `adaptPermissions`: v1 permission map → v2 Rule[] (with v2 permissive base +
+ *   `task`→`subagent`, `bash`→`execute` mapping).
+ * - `rewritePromptForV2`: rewrite v1 delegation syntax in agent/system prompts.
+ * - `adaptTool`: v1 ToolDefinition ({description,args,execute}) → v2 Tool.Info.
+ * - `applyAgentToDraft`: mutate a v2 agent draft entry from a v1 agent config.
+ */
+
+import { log } from '../utils/logger';
+import type { ModelRef, V2AgentDraft } from './types';
+
+/** Parse a v1 "provider/model" string into a v2 Model.Ref. */
+export function parseModelRef(model: unknown): ModelRef | undefined {
+  if (typeof model !== 'string') return undefined;
+  const slash = model.indexOf('/');
+  if (slash <= 0 || slash >= model.length - 1) {
+    // No provider separator; better to leave model undefined than guess a
+    // provider. Many configs use bare ids; these resolve via the host default.
+    return undefined;
+  }
+  return {
+    providerID: model.slice(0, slash),
+    id: model.slice(slash + 1),
+  };
+}
+
+/** v1 lists only EXPLICIT permission entries; unlisted tools fall through to
+ * opencode's implicit default-allow. v2 has no implicit default, so we start
+ * from v2's standard permissive base ruleset (mirrors Agent.Info.default) and
+ * overlay the v1 entries. Without this base, v2 would deny every v2-native tool
+ * the v1 permission map never heard of (subagent, execute, read, edit, ...). */
+const V2_DEFAULT_PERMISSIONS = [
+  { action: '*', resource: '*', effect: 'allow' },
+  { action: 'external_directory', resource: '*', effect: 'ask' },
+  { action: 'read', resource: '*.env', effect: 'ask' },
+  { action: 'read', resource: '*.env.*', effect: 'ask' },
+  { action: 'read', resource: '*.env.example', effect: 'allow' },
+];
+
+/** Map v1 permission keys to v2 (action, resource). v1 `task` is v2 `subagent`;
+ * v1 `bash` is v2 `execute`. */
+function v1PermKeyToV2(
+  key: string,
+): Array<{ action: string; resource: string }> {
+  if (key === 'task') return [{ action: 'subagent', resource: '*' }];
+  if (key === 'bash')
+    return [
+      { action: '*', resource: 'execute' },
+      { action: '*', resource: 'bash' },
+    ];
+  return [{ action: '*', resource: key }];
+}
+
+/** Convert a v1 permission map (or shorthand string) into v2 permission rules. */
+export function adaptPermissions(
+  perm: unknown,
+): Array<{ action: string; resource: string; effect: string }> {
+  const rules: Array<{ action: string; resource: string; effect: string }> = [
+    ...V2_DEFAULT_PERMISSIONS,
+  ];
+  if (typeof perm === 'string') {
+    rules.push({ action: '*', resource: '*', effect: perm });
+    return rules;
+  }
+  if (perm && typeof perm === 'object') {
+    for (const [resource, effect] of Object.entries(
+      perm as Record<string, unknown>,
+    )) {
+      if (typeof effect === 'string') {
+        for (const target of v1PermKeyToV2(resource)) {
+          rules.push({ ...target, effect });
+        }
+      } else if (effect && typeof effect === 'object') {
+        // nested {tool: {pattern: effect}}
+        for (const [sub, subEffect] of Object.entries(
+          effect as Record<string, unknown>,
+        )) {
+          if (typeof subEffect === 'string') {
+            rules.push({ action: sub, resource, effect: subEffect });
+          }
+        }
+      }
+    }
+  }
+  return rules;
+}
+
+/** Rewrite v1 delegation syntax to v2. v2 renamed `task` → `subagent` and
+ * `subagent_type` → `agent`. Applied to agent prompts at registration and to
+ * the runtime system prompt so the orchestrator emits valid v2 tool calls. */
+export function rewritePromptForV2(text: unknown): unknown {
+  if (typeof text !== 'string') return text;
+  return text
+    .replace(/\bsubagent_type\b/g, 'agent')
+    .replace(/\btask\s*\(/g, 'subagent(');
+}
+
+/** Adapt a v1 tool definition ({description, args, execute}) to a v2 tool. */
+export function adaptTool(
+  name: string,
+  v1Tool: Record<string, unknown>,
+  directory: string,
+  inputSchema: unknown,
+): Record<string, unknown> {
+  const description =
+    (v1Tool.description as string | undefined) ?? `Tool ${name}`;
+
+  const execute = v1Tool.execute as
+    | ((args: unknown, ctx: unknown) => Promise<unknown>)
+    | undefined;
+
+  return {
+    name,
+    description,
+    input: inputSchema,
+    execute: async (input: unknown, context: unknown) => {
+      if (!execute) return { output: {} };
+      const ctx = context as {
+        sessionID?: string;
+        messageID?: string;
+        agent?: string;
+        progress?: (m: unknown) => unknown;
+      };
+      const v1Ctx = {
+        sessionID: ctx?.sessionID ?? '',
+        messageID: ctx?.messageID ?? '',
+        agent: ctx?.agent ?? 'orchestrator',
+        directory,
+        worktree: directory,
+        abort: new AbortController().signal,
+        metadata(m: unknown) {
+          log('[v2][tool] metadata (no-op)', { tool: name, m });
+        },
+        async ask(_m: unknown) {
+          /* permission deferred to v2 model */
+        },
+      };
+      const result = await execute(input, v1Ctx);
+      if (typeof result === 'string') {
+        return { content: result };
+      }
+      if (result && typeof result === 'object') {
+        const r = result as {
+          output?: string;
+          title?: string;
+          metadata?: Record<string, unknown>;
+          attachments?: unknown[];
+        };
+        return {
+          content: typeof r.output === 'string' ? r.output : '',
+          metadata: {
+            ...(r.metadata ?? {}),
+            ...(r.title ? { title: r.title } : {}),
+          },
+        };
+      }
+      return { content: String(result ?? '') };
+    },
+  };
+}
+
+/** Mutate a v2 agent draft entry from a v1 agent config. */
+export function applyAgentToDraft(
+  draft: V2AgentDraft,
+  name: string,
+  v1: Record<string, unknown>,
+): void {
+  const model = parseModelRef(v1.model);
+  draft.update(name, (agent) => {
+    agent.id = name;
+    agent.name = name;
+    agent.mode =
+      (v1.mode as string) ?? (name === 'orchestrator' ? 'primary' : 'subagent');
+    agent.hidden = v1.hidden === true;
+    if (typeof v1.description === 'string') agent.description = v1.description;
+    if (typeof v1.prompt === 'string')
+      agent.system = rewritePromptForV2(v1.prompt);
+    if (model) {
+      agent.model = {
+        id: model.id,
+        providerID: model.providerID,
+        ...(v1.variant ? { variant: v1.variant } : {}),
+      };
+    }
+    const request: Record<string, unknown> = {
+      settings: {},
+      headers: {},
+      body: {},
+    };
+    if (typeof v1.temperature === 'number') {
+      (request.settings as Record<string, unknown>).temperature =
+        v1.temperature;
+    }
+    agent.request = request;
+    // v2 permission evaluation is last-match-wins (findLast). v1 `tools` lists
+    // which tools an agent MAY use (implicit allow); the `permission` map holds
+    // explicit allow/deny. Place tools-allow FIRST so an explicit permission
+    // deny later in the array wins, matching v1 precedence.
+    const toolsAllow: Array<Record<string, unknown>> = [];
+    if (Array.isArray(v1.tools)) {
+      for (const t of v1.tools as unknown[]) {
+        if (typeof t === 'string') {
+          toolsAllow.push({ action: '*', resource: t, effect: 'allow' });
+        }
+      }
+    }
+    agent.permissions = [...toolsAllow, ...adaptPermissions(v1.permission)];
+  });
+}

+ 70 - 0
src/v2/client-shim.ts

@@ -0,0 +1,70 @@
+/**
+ * v1 PluginInput shim.
+ *
+ * The v1 plugin factory expects a `PluginInput` with an HTTP `client`, project
+ * metadata, and a shell. v2's plugin context exposes none of these, so this
+ * shim builds a v1-shaped input whose `client` delegates the few methods the
+ * plugin actually uses (session.abort/prompt/messages, app.log, tui.showToast)
+ * to graceful no-ops or the v2 context. `directory` comes from `process.cwd()`
+ * (v2 does not expose the project directory).
+ */
+
+import { log } from '../utils/logger';
+
+/** Build a v1-compatible PluginInput from the v2 context. */
+export function buildPluginInput(directory: string): Record<string, unknown> {
+  const client = {
+    session: {
+      // Accept both Hono-style ({path:{id}}) and flat ({sessionID}) calls.
+      abort: async (args: Record<string, unknown>) => {
+        const id =
+          (args?.path as { id?: string } | undefined)?.id ??
+          (args?.sessionID as string | undefined);
+        log('[v2][shim] session.abort (no-op on v2)', { id });
+      },
+      prompt: async (args: Record<string, unknown>) => {
+        log('[v2][shim] session.prompt ignored (v2 manages sessions)', {
+          id: args?.sessionID,
+        });
+        return {};
+      },
+      promptAsync: async (args: Record<string, unknown>) => {
+        log('[v2][shim] session.promptAsync ignored', { id: args?.sessionID });
+        return {};
+      },
+      messages: async (_args: Record<string, unknown>) => ({ data: [] }),
+      status: async (_args: Record<string, unknown>) => ({ data: [] }),
+      list: async () => ({ data: [] }),
+    },
+    app: {
+      log: async (args?: Record<string, unknown>) => {
+        const body = (args?.body ?? args) as
+          | { level?: string; message?: string }
+          | undefined;
+        const level = body?.level ?? 'info';
+        log(`[v2][host-log] ${level}: ${body?.message ?? ''}`);
+      },
+    },
+    tui: {
+      showToast: async (args?: Record<string, unknown>) => {
+        const body = (args?.body ?? args) as { message?: string } | undefined;
+        log('[v2][shim] tui.showToast (no-op on v2)', {
+          message: body?.message,
+        });
+      },
+    },
+    // Misc methods the plugin may touch; all graceful no-ops.
+    model: { list: async () => ({ data: [] }) },
+    provider: { list: async () => ({ data: [] }) },
+  };
+
+  return {
+    client,
+    project: { id: 'global', directory },
+    directory,
+    worktree: directory,
+    experimental_workspace: { register() {} },
+    serverUrl: new URL('http://localhost:4096'),
+    $: typeof Bun !== 'undefined' ? Bun.$ : undefined,
+  };
+}

+ 65 - 0
src/v2/codemap.md

@@ -0,0 +1,65 @@
+# Directory Map: `src/v2/`
+
+## Responsibility
+
+OpenCode v2 (`opencode2`) host adapter. Bridges the existing v1 plugin factory
+into v2's promise-plugin transform/runtime-hook API so a single published
+package runs on both hosts.
+
+v2 loads `default.setup(ctx)` (v1 loads `default.server`). `setup` wraps the v1
+factory to reuse all build logic, then translates the returned v1 `Hooks` into
+v2 registrations. v1 behavior is unchanged.
+
+## Entry Points
+
+| Path | Role |
+|---|---|
+| `index.ts` | Barrel: re-exports `createV2Setup` and the v2 context types. Imported by `src/index.ts` for the dual `default` export. |
+| `setup.ts` | `createV2Setup()` → the `setup(ctx)` orchestrator v2 calls. |
+| `types.ts` | v2 plugin context surface (`V2Context` + draft/event types), mirrored locally (v2 plugin package is not a build-time dependency). |
+| `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` (shimmed `client`, `process.cwd()` directory) for the v1 factory. |
+| `adapters.ts` | Shape adapters: `parseModelRef`, `adaptPermissions` (v1 map → v2 Rule[] + v2 permissive base + `task`→`subagent`/`bash`→`execute` mapping), `rewritePromptForV2` (`task(`→`subagent(`), `adaptTool`, `applyAgentToDraft`. |
+
+## Data Flow
+
+1. v2 supervisor decodes `default` as `{ id, setup }` and calls `setup(ctx)`.
+2. `setup` builds a v1 `PluginInput` (`client-shim`) and invokes the v1 factory
+   `OhMyOpenCodeLite` → receives v1 `Hooks`.
+3. Runs the v1 `config()` hook against a synthesized config to resolve agent
+   models and slash commands.
+4. Registers into v2 domains:
+   - `agent` → `ctx.agent.transform` (via `applyAgentToDraft`)
+   - `tool` → `ctx.tool.transform` (via `adaptTool`, zod shape → JSON schema)
+   - `command` → `ctx.command.transform`
+   - system/message transforms → `ctx.session.hook("context")` (SystemPart[]/
+     Message.content ↔ v1 `{info,parts}` conversion + `rewritePromptForV2`)
+   - `tool.execute.before/after` → `ctx.tool.hook`
+   - `event` → `ctx.event.subscribe()` loop
+5. Returns a cleanup that disposes every v2 registration + the v1 `dispose`.
+
+Each bridge in step 4 is independently try/catch-guarded so one failure cannot
+disable the rest.
+
+## Key Decisions
+
+- **No v2 type imports.** The v2 plugin package is not a build-time dependency
+  (v1 host must load the main build). `types.ts` mirrors the consumed subset.
+- **Wrap, don't reimplement.** The v1 factory owns all subsystem wiring
+  (agents, hooks, job board, multiplexer, companion); the adapter only
+  translates at the boundary.
+- **Permission base.** v1 permission maps list only explicit entries (unlisted
+  → implicit default-allow); v2 has no implicit default, so `adaptPermissions`
+  prepends v2's standard permissive base before overlaying v1 entries.
+
+## Integration Points
+
+- `src/index.ts`: imports `createV2Setup` for the dual `default` export and
+  exports `OhMyOpenCodeLite` (named) for the adapter to wrap.
+- Build: `build:v2` bundles `src/index.ts` (which pulls in `src/v2/`) into
+  `dist/server.js` (self-contained except `@ast-grep/napi` + `jsdom`).
+
+## Limitations (see `docs/opencode-v2-compatibility.md`)
+
+Built-in MCPs are config-only on v2 (no programmatic MCP hook); runtime
+`/preset` live-reload, multiplexer, companion, and foreground-fallback run
+best-effort via the shimmed client; `directory` comes from `process.cwd()`.

+ 27 - 0
src/v2/index.ts

@@ -0,0 +1,27 @@
+/**
+ * OpenCode v2 (`opencode2`) plugin adapter.
+ *
+ * v2 loads the plugin's `default.setup(ctx)`. This package's default export is
+ * `{ id, server, setup }`: v1 calls `.server` (the unchanged v1 factory); v2
+ * calls `.setup` (the adapter exported here).
+ *
+ * The adapter wraps the existing v1 factory to reuse ALL build logic, then
+ * translates the returned v1 `Hooks` into v2 registrations (agent/tool/command
+ * transforms, session/tool runtime hooks, event stream). Shape conversion and
+ * the v1→v2 semantic mappings (task→subagent, permission base, etc.) live in
+ * the peer modules. See `codemap.md`.
+ */
+
+export { createV2Setup } from './setup';
+export type {
+  ModelRef,
+  V2AgentDraft,
+  V2Cleanup,
+  V2CommandDraft,
+  V2Context,
+  V2Registration,
+  V2SessionContextEvent,
+  V2ToolAfterEvent,
+  V2ToolBeforeEvent,
+  V2ToolDraft,
+} from './types';

+ 385 - 0
src/v2/setup.ts

@@ -0,0 +1,385 @@
+/**
+ * v2 setup orchestration.
+ *
+ * Returns the `setup(ctx)` function v2 calls via `default.setup`. The setup
+ * wraps the existing v1 factory (reusing ALL build logic) and translates the
+ * returned v1 `Hooks` into v2 registrations: agent/tool/command transforms,
+ * the session context hook (system + message transforms), tool execute hooks,
+ * and the event stream. Each bridge is independently try/catch-guarded.
+ */
+
+import { OhMyOpenCodeLite } from '../index';
+import { initLogger, log } from '../utils/logger';
+import { adaptTool, applyAgentToDraft } from './adapters';
+import { buildPluginInput } from './client-shim';
+import type {
+  V2Cleanup,
+  V2Context,
+  V2ToolAfterEvent,
+  V2ToolBeforeEvent,
+} from './types';
+
+export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
+  return async (ctx: V2Context): Promise<V2Cleanup> => {
+    const sessionId = new Date()
+      .toISOString()
+      .replace(/[-:]/g, '')
+      .slice(0, 15);
+    initLogger(sessionId);
+    log('[v2] setup invoked', { app: ctx.app, cwd: process.cwd() });
+
+    const directory = process.cwd();
+    const disposers: Array<() => Promise<void> | void> = [];
+    let v1Hooks: Record<string, unknown> | undefined;
+
+    try {
+      log('[v2] importing v1 factory...');
+      const pluginInput = buildPluginInput(directory);
+      log('[v2] calling OhMyOpenCodeLite...');
+      v1Hooks = (await OhMyOpenCodeLite(
+        pluginInput as never,
+      )) as unknown as Record<string, unknown>;
+      log('[v2] v1 factory initialized', {
+        agents: Object.keys((v1Hooks as { agent?: object }).agent ?? {}).length,
+        tools: Object.keys((v1Hooks as { tool?: object }).tool ?? {}).length,
+      });
+    } catch (err) {
+      log('[v2] FATAL: v1 factory init failed', String(err));
+      console.error('[oh-my-opencode-slim][v2] factory init failed:', err);
+      // Don't hard-fail the whole plugin; register nothing and stay loaded.
+      return async () => {};
+    }
+
+    if (!v1Hooks) return async () => {};
+
+    // Resolve agents/commands via the v1 config() hook (model resolution etc.).
+    let resolvedAgents: Record<string, Record<string, unknown>> | undefined;
+    let synthCommands:
+      | Record<string, { template?: string; description?: string }>
+      | undefined;
+    try {
+      const synth: Record<string, unknown> = {};
+      const configFn = v1Hooks.config as
+        | ((c: Record<string, unknown>) => Promise<void>)
+        | undefined;
+      if (configFn) {
+        await configFn(synth);
+        if (synth.agent && typeof synth.agent === 'object') {
+          resolvedAgents = synth.agent as Record<
+            string,
+            Record<string, unknown>
+          >;
+        }
+        const cmd = synth.command as
+          | Record<string, { template?: string; description?: string }>
+          | undefined;
+        if (cmd) synthCommands = cmd;
+      }
+    } catch (err) {
+      log(
+        '[v2] config() hook failed (continuing with raw agents)',
+        String(err),
+      );
+    }
+    if (!resolvedAgents) {
+      resolvedAgents =
+        (v1Hooks.agent as Record<string, Record<string, unknown>>) ?? {};
+    }
+
+    // ── Agents ──
+    try {
+      const reg = await ctx.agent.transform((draft) => {
+        for (const [name, cfg] of Object.entries(resolvedAgents ?? {})) {
+          try {
+            applyAgentToDraft(draft, name, cfg);
+          } catch (err) {
+            log('[v2] agent adapt failed', { name, err: String(err) });
+          }
+        }
+        // Make orchestrator the default primary agent.
+        if (resolvedAgents?.orchestrator) {
+          try {
+            draft.default('orchestrator');
+          } catch {
+            /* default() optional */
+          }
+        }
+      });
+      disposers.push(() => reg.dispose());
+      log('[v2] agents registered', {
+        count: Object.keys(resolvedAgents ?? {}).length,
+      });
+    } catch (err) {
+      log('[v2] agent.transform failed', String(err));
+    }
+
+    // ── Tools ──
+    try {
+      const tools = (v1Hooks.tool ?? {}) as Record<
+        string,
+        Record<string, unknown>
+      >;
+      const toolEntries = Object.entries(tools);
+      if (toolEntries.length > 0) {
+        // Precompute JSON schemas from zod shapes (zod is bundled in v2 build).
+        const zod = (await import('zod')) as unknown as {
+          object?: (s: unknown) => unknown;
+          toJSONSchema?: (s: unknown) => unknown;
+        };
+        const schemaFor = (def: Record<string, unknown>): unknown => {
+          const args = def.args;
+          if (!args || typeof args !== 'object') {
+            return { type: 'object', properties: {} };
+          }
+          try {
+            const obj = zod.object?.(args);
+            if (zod.toJSONSchema && obj) return zod.toJSONSchema(obj);
+          } catch {
+            /* fall through */
+          }
+          return { type: 'object', properties: {} };
+        };
+
+        const reg = await ctx.tool.transform((draft) => {
+          for (const [name, def] of toolEntries) {
+            try {
+              draft.add(adaptTool(name, def, directory, schemaFor(def)));
+            } catch (err) {
+              log('[v2] tool adapt failed', { name, err: String(err) });
+            }
+          }
+        });
+        disposers.push(() => reg.dispose());
+        log('[v2] tools registered', { count: toolEntries.length });
+      }
+    } catch (err) {
+      log('[v2] tool.transform failed', String(err));
+    }
+
+    // ── Commands (deepwork / reflect / loop slash commands) ──
+    try {
+      const entries = Object.entries(synthCommands ?? {});
+      if (entries.length > 0) {
+        const reg = await ctx.command.transform((draft) => {
+          for (const [name, cmd] of entries) {
+            try {
+              draft.update(name, (c) => {
+                c.name = name;
+                if (typeof cmd.template === 'string') c.template = cmd.template;
+                if (typeof cmd.description === 'string')
+                  c.description = cmd.description;
+              });
+            } catch (err) {
+              log('[v2] command adapt failed', { name, err: String(err) });
+            }
+          }
+        });
+        disposers.push(() => reg.dispose());
+        log('[v2] commands registered', { count: entries.length });
+      }
+    } catch (err) {
+      log('[v2] command.transform failed', String(err));
+    }
+
+    // ── System + messages transforms (session context hook) ──
+    try {
+      const systemTransform = v1Hooks['experimental.chat.system.transform'] as
+        | ((i: unknown, o: { system: string[] }) => Promise<void>)
+        | undefined;
+      const messagesTransform = v1Hooks[
+        'experimental.chat.messages.transform'
+      ] as
+        | ((
+            i: unknown,
+            o: {
+              messages: Array<{ info: { role: string }; parts: unknown[] }>;
+            },
+          ) => Promise<void>)
+        | undefined;
+      const chatMessage = v1Hooks['chat.message'] as
+        | ((
+            i: { sessionID: string; agent?: string },
+            o: unknown,
+          ) => Promise<void>)
+        | undefined;
+
+      if (systemTransform || messagesTransform || chatMessage) {
+        const reg = await ctx.session.hook('context', async (event) => {
+          // Agent tracking (chat.message equivalent).
+          if (chatMessage) {
+            try {
+              await chatMessage(
+                { sessionID: event.sessionID, agent: event.agent },
+                undefined,
+              );
+            } catch (err) {
+              log('[v2] chat.message bridge failed', String(err));
+            }
+          }
+          // System transform: v2 SystemPart[] -> v1 string[] -> mutate -> back.
+          if (systemTransform && Array.isArray(event.system)) {
+            try {
+              const sysStrings = event.system.map((s) => s.text ?? '');
+              await systemTransform(
+                { sessionID: event.sessionID },
+                { system: sysStrings },
+              );
+              event.system = sysStrings.map((text) => ({
+                type: 'text' as const,
+                text,
+              }));
+            } catch (err) {
+              log('[v2] system transform bridge failed', String(err));
+            }
+          }
+          // Messages transform: v2 Message.content -> v1 {info, parts} -> back.
+          if (messagesTransform && Array.isArray(event.messages)) {
+            try {
+              const v1messages = event.messages.map((m) => ({
+                info: { role: m.role },
+                parts: m.content,
+              }));
+              await messagesTransform({}, { messages: v1messages });
+              event.messages.forEach((m, i) => {
+                m.content = (v1messages[i]?.parts ?? []) as Array<
+                  Record<string, unknown>
+                >;
+              });
+            } catch (err) {
+              log('[v2] messages transform bridge failed', String(err));
+            }
+          }
+        });
+        disposers.push(() => reg.dispose());
+        log('[v2] session context hook registered');
+      }
+    } catch (err) {
+      log('[v2] session.hook(context) failed', String(err));
+    }
+
+    // ── Tool execute hooks ──
+    try {
+      const before = v1Hooks['tool.execute.before'] as
+        | ((
+            i: { tool: string; sessionID: string; callID: string },
+            o: { args: unknown },
+          ) => Promise<void>)
+        | undefined;
+      const after = v1Hooks['tool.execute.after'] as
+        | ((i: unknown, o: unknown) => Promise<void>)
+        | undefined;
+      if (before) {
+        const reg = await ctx.tool.hook('execute.before', async (event) => {
+          const e = event as V2ToolBeforeEvent;
+          try {
+            await before(
+              { tool: e.tool, sessionID: e.sessionID, callID: e.id },
+              { args: e.input },
+            );
+          } catch (err) {
+            log('[v2] tool.execute.before bridge failed', String(err));
+          }
+        });
+        disposers.push(() => reg.dispose());
+      }
+      if (after) {
+        const reg = await ctx.tool.hook('execute.after', async (event) => {
+          const e = event as V2ToolAfterEvent;
+          // Map v2 Tool.Result.content (string | Content[]) -> v1 output.output
+          // string; the v1 after-hooks (postFileToolNudge, jsonErrorRecovery,
+          // taskSessionManagerAfter) read output.output to decide nudges.
+          const result = e.result as
+            | {
+                content?: unknown;
+                metadata?: Record<string, unknown>;
+              }
+            | undefined;
+          const rawContent = result?.content;
+          const content =
+            typeof rawContent === 'string'
+              ? rawContent
+              : Array.isArray(rawContent)
+                ? (rawContent as Array<{ type?: string; text?: string }>)
+                    .filter((p) => p?.type === 'text')
+                    .map((p) => p.text ?? '')
+                    .join('')
+                : '';
+          try {
+            await after(
+              {
+                tool: e.tool,
+                sessionID: e.sessionID,
+                callID: e.id,
+                args: e.input,
+              },
+              { output: content, title: '', metadata: result?.metadata ?? {} },
+            );
+          } catch (err) {
+            log('[v2] tool.execute.after bridge failed', String(err));
+          }
+        });
+        disposers.push(() => reg.dispose());
+      }
+      log('[v2] tool hooks registered', { before: !!before, after: !!after });
+    } catch (err) {
+      log('[v2] tool.hook registration failed', String(err));
+    }
+
+    // ── Event stream ──
+    try {
+      const eventHook = v1Hooks.event as
+        | ((i: { event: Record<string, unknown> }) => Promise<void>)
+        | undefined;
+      if (eventHook) {
+        const iter = ctx.event.subscribe();
+        void (async () => {
+          try {
+            for await (const ev of iter) {
+              try {
+                await eventHook({ event: ev });
+              } catch (err) {
+                log('[v2] event handler failed', String(err));
+              }
+            }
+          } catch (err) {
+            log('[v2] event stream ended', String(err));
+          }
+        })();
+        log('[v2] event stream subscribed');
+      }
+    } catch (err) {
+      log('[v2] event.subscribe failed', String(err));
+    }
+
+    // ── Health check: surface silent zero-registration failures ──
+    // Every bridge is fail-soft; without this, a fully broken registration
+    // would look like a successful load with an empty session.
+    if (disposers.length === 0) {
+      console.error(
+        '[oh-my-opencode-slim][v2] WARNING: no bridges registered — ' +
+          'the plugin loaded but registered nothing. Check the plugin log.',
+      );
+      log('[v2] health check: zero bridges registered');
+    } else {
+      log('[v2] health check passed', { bridges: disposers.length });
+    }
+
+    const dispose = v1Hooks.dispose as (() => Promise<void>) | undefined;
+
+    return async () => {
+      log('[v2] dispose invoked');
+      for (const d of disposers) {
+        try {
+          await d();
+        } catch (err) {
+          log('[v2] disposer failed', String(err));
+        }
+      }
+      try {
+        await dispose?.();
+      } catch (err) {
+        log('[v2] v1 dispose failed', String(err));
+      }
+    };
+  };
+}

+ 100 - 0
src/v2/types.ts

@@ -0,0 +1,100 @@
+/**
+ * v2 plugin context surface.
+ *
+ * These interfaces mirror the subset of the v2 promise-plugin Context
+ * (`@opencode-ai/plugin`) this adapter consumes. They are defined locally
+ * because the v2 plugin package is not a build-time dependency (the v1 host
+ * must be able to load the main build without v2 types installed).
+ */
+
+export interface V2AgentDraft {
+  list(): Array<Record<string, unknown>>;
+  get(id: string): Record<string, unknown> | undefined;
+  default(id: string | undefined): void;
+  update(id: string, update: (agent: Record<string, unknown>) => void): void;
+  remove(id: string): void;
+}
+export interface V2ToolDraft {
+  add(tool: Record<string, unknown>): void;
+}
+export interface V2CommandDraft {
+  list(): Array<Record<string, unknown>>;
+  get(name: string): Record<string, unknown> | undefined;
+  update(
+    name: string,
+    update: (command: Record<string, unknown>) => void,
+  ): void;
+  remove(name: string): void;
+}
+export interface V2SessionContextEvent {
+  readonly sessionID: string;
+  readonly agent: string;
+  readonly model: Record<string, unknown>;
+  system: Array<{ type: 'text'; text: string }>;
+  messages: Array<{
+    id?: string;
+    role: string;
+    content: Array<Record<string, unknown>>;
+  }>;
+  tools: Record<string, unknown>;
+}
+export interface V2ToolBeforeEvent {
+  readonly tool: string;
+  readonly sessionID: string;
+  readonly agent: string;
+  readonly messageID: string;
+  readonly id: string;
+  input: unknown;
+}
+export interface V2ToolAfterEvent {
+  readonly tool: string;
+  readonly sessionID: string;
+  readonly agent: string;
+  readonly messageID: string;
+  readonly id: string;
+  readonly input: unknown;
+  readonly status: 'completed' | 'error';
+  result?: unknown;
+  error?: unknown;
+}
+export interface V2Registration {
+  dispose(): Promise<void> | void;
+}
+export interface V2Context {
+  readonly app: { readonly name: string; readonly version: string };
+  readonly options: Record<string, unknown>;
+  agent: {
+    transform(cb: (draft: V2AgentDraft) => void): Promise<V2Registration>;
+    reload(): Promise<unknown>;
+    list(): Promise<unknown>;
+  };
+  tool: {
+    transform(cb: (draft: V2ToolDraft) => void): Promise<V2Registration>;
+    hook(
+      name: 'execute.before' | 'execute.after',
+      cb: (event: V2ToolBeforeEvent | V2ToolAfterEvent) => Promise<void>,
+    ): Promise<V2Registration>;
+  };
+  command: {
+    transform(cb: (draft: V2CommandDraft) => void): Promise<V2Registration>;
+    list(): Promise<unknown>;
+  };
+  session: {
+    hook(
+      name: 'context',
+      cb: (event: V2SessionContextEvent) => Promise<void>,
+    ): Promise<V2Registration>;
+  };
+  event: {
+    subscribe(): AsyncIterable<Record<string, unknown>>;
+  };
+}
+
+export type V2Cleanup = () => Promise<void> | void;
+
+/** Parsed v2 Model.Ref derived from a v1 "provider/model" string. */
+export interface ModelRef {
+  providerID: string;
+  id: string;
+  variant?: string;
+}