diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json index 2c5f51e5..969d40ed 100644 --- a/packages/opencode-plugin/package.json +++ b/packages/opencode-plugin/package.json @@ -29,18 +29,23 @@ "devDependencies": { "@codemcp/workflows-core": "workspace:*", "@codemcp/workflows-server": "workspace:*", + "@opencode-ai/plugin": "*", "rimraf": "^6.0.1", "tsup": "^8.0.0", "vitest": "4.0.18" }, "peerDependencies": { "@anthropic-ai/sdk": "*", + "@opencode-ai/plugin": "*", "zod": ">=4.1.8" }, "peerDependenciesMeta": { "@anthropic-ai/sdk": { "optional": true }, + "@opencode-ai/plugin": { + "optional": false + }, "zod": { "optional": false } diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 1a1649d1..50355b81 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -35,6 +35,46 @@ import { } from './server-context.js'; import { stripWhatsNextReferences } from './utils.js'; +// --------------------------------------------------------------------------- +// Monkey-patch resilience: ToolContext.ask return-type detection +// +// opencode has changed ToolContext.ask's return type between SDK releases: +// • SDK ≤ some pre-April-2026 version → Promise +// • SDK after PR #21986 (Apr 10 2026) → Effect.Effect +// • SDK 1.15.x (current, Jun 2026) → Promise ← reverted again +// +// Rather than chasing each flip, we inspect the actual return value at +// runtime and dispatch accordingly. An Effect object carries the property +// key "~effect/Effect" (its TypeId), which is stable across Effect 3.x and +// 4.x. A plain Promise does not have that key and is always thenable. +// +// This is intentionally a monkey-patch: it compensates for an upstream API +// that has been unstable across SDK versions. If the SDK stabilises on one +// form, this helper can be simplified, but it is cheap enough to keep. +// --------------------------------------------------------------------------- + +const EFFECT_TYPE_ID = '~effect/Effect'; + +/** + * Execute the result of `ToolContext.ask()`, regardless of whether the SDK + * version returns a `Promise` or an `Effect.Effect`. + */ +async function runAsk( + askResult: Promise | Effect.Effect +): Promise { + if ( + askResult !== null && + typeof askResult === 'object' && + EFFECT_TYPE_ID in askResult + ) { + // SDK returned an Effect — bridge it into the async/await world. + await Effect.runPromise(askResult as Effect.Effect); + } else { + // SDK returned a Promise (current behaviour as of SDK 1.15.x). + await (askResult as Promise); + } +} + /** * Buffered instructions from proceed_to_phase or start_development tools. * Consumed (and cleared) by the next chat.message hook invocation. @@ -749,7 +789,7 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin ); } - await Effect.runPromise( + await runAsk( ctx.ask({ permission: toolName, patterns: buildPermissionPatterns( @@ -779,7 +819,7 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin createProceedToPhaseTool( getServerContext, setBufferedInstructions, - input.client as OpenCodeClient, + input.client as unknown as OpenCodeClient, () => lastKnownModel ) ), diff --git a/packages/opencode-plugin/src/tool-handlers/tool-helper.ts b/packages/opencode-plugin/src/tool-handlers/tool-helper.ts index 93c7c8b6..195ba0ca 100644 --- a/packages/opencode-plugin/src/tool-handlers/tool-helper.ts +++ b/packages/opencode-plugin/src/tool-handlers/tool-helper.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; -import type { ToolDefinition, ToolContext } from '../types.js'; +import type { ToolContext } from '../types.js'; +import type { ToolDefinition } from '@opencode-ai/plugin'; /** * Tool definition helper diff --git a/packages/opencode-plugin/src/types.ts b/packages/opencode-plugin/src/types.ts index 99d00367..a9998131 100644 --- a/packages/opencode-plugin/src/types.ts +++ b/packages/opencode-plugin/src/types.ts @@ -1,229 +1,48 @@ /** * OpenCode Plugin Types * - * Minimal type definitions needed for the plugin. - * Based on @opencode-ai/plugin package types. + * Re-exports the types we need directly from the official @opencode-ai/plugin + * SDK so that any change to the SDK's type signatures (e.g. ToolContext.ask) + * is caught at compile time rather than silently breaking at runtime. + * + * Only types that are NOT exported by the SDK are defined here. */ -import { z } from 'zod'; - -// Simplified types from opencode SDK -export type Part = { - type: 'text' | 'image' | 'file' | 'tool_use' | 'tool_result'; - text?: string; - [key: string]: unknown; -}; - -export type UserMessage = { - id: string; - sessionID: string; - role: 'user'; - [key: string]: unknown; -}; - -export type Message = { - id: string; - sessionID: string; - role: 'user' | 'assistant'; - [key: string]: unknown; -}; +// --------------------------------------------------------------------------- +// Re-export everything from the official SDK +// --------------------------------------------------------------------------- + +export type { + PluginInput, + Plugin, + PluginModule, + Hooks, + ToolDefinition, + ToolContext, +} from '@opencode-ai/plugin'; + +// --------------------------------------------------------------------------- +// BusEvent subtypes +// +// The SDK exports a single opaque `Event` type from @opencode-ai/sdk, but the +// plugin needs to narrow on specific event types (session.compacted, +// session.idle) that are not individually exported. These local types stay +// here until the SDK exposes them directly. +// --------------------------------------------------------------------------- -export type Model = { - providerID: string; - modelID: string; - [key: string]: unknown; -}; - -export type Project = { - id: string; - path: string; - [key: string]: unknown; -}; - -// Plugin input provided by opencode -export type PluginInput = { - client: unknown; // SDK client - project: Project; - directory: string; - worktree: string; - serverUrl: URL; - $: unknown; // BunShell -}; - -// Tool context for custom tools -import type { Effect } from 'effect'; - -export type ToolContext = { - sessionID: string; - messageID: string; - agent: string; - directory: string; - worktree: string; - abort: AbortSignal; - metadata(input: { title?: string; metadata?: Record }): void; - ask(input: { - permission: string; - patterns: string[]; - always: string[]; - metadata: Record; - }): Effect.Effect; -}; - -// Tool definition -export type ToolDefinition = { - description: string; - args: z.ZodRawShape; - execute(args: unknown, context: ToolContext): Promise; -}; - -// Minimal Event types from @opencode-ai/sdk needed for the event hook export type SessionCompactedEvent = { type: 'session.compacted'; properties: { sessionID: string }; }; + export type SessionIdleEvent = { type: 'session.idle'; properties: { sessionID: string }; }; + export type OtherEvent = { type: string; properties: Record; }; -export type BusEvent = SessionCompactedEvent | SessionIdleEvent | OtherEvent; - -// All available hooks -export interface Hooks { - event?: (input: { event: BusEvent }) => Promise; - config?: (input: unknown) => Promise; - tool?: { - [key: string]: ToolDefinition; - }; - auth?: unknown; - - /** - * Called when a new message is received - */ - 'chat.message'?: ( - input: { - sessionID: string; - agent?: string; - model?: { providerID: string; modelID: string }; - messageID?: string; - variant?: string; - }, - output: { message: UserMessage; parts: Part[] } - ) => Promise; - - /** - * Modify parameters sent to LLM - */ - 'chat.params'?: ( - input: { - sessionID: string; - agent: string; - model: Model; - provider: unknown; - message: UserMessage; - }, - output: { - temperature: number; - topP: number; - topK: number; - options: Record; - } - ) => Promise; - - 'chat.headers'?: ( - input: { - sessionID: string; - agent: string; - model: Model; - provider: unknown; - message: UserMessage; - }, - output: { headers: Record } - ) => Promise; - - 'permission.ask'?: ( - input: unknown, - output: { status: 'ask' | 'deny' | 'allow' } - ) => Promise; - - 'command.execute.before'?: ( - input: { command: string; sessionID: string; arguments: string }, - output: { parts: Part[] } - ) => Promise; - - 'tool.execute.before'?: ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ) => Promise; - - 'shell.env'?: ( - input: { cwd: string; sessionID?: string; callID?: string }, - output: { env: Record } - ) => Promise; - 'tool.execute.after'?: ( - input: { - tool: string; - sessionID: string; - callID: string; - args: unknown; - }, - output: { - title: string; - output: string; - metadata: unknown; - } - ) => Promise; - - 'experimental.chat.messages.transform'?: ( - input: Record, - output: { - messages: { - info: Message; - parts: Part[]; - }[]; - } - ) => Promise; - - 'experimental.chat.system.transform'?: ( - input: { sessionID?: string; model: Model }, - output: { - system: string[]; - } - ) => Promise; - - /** - * Called before session compaction starts. Allows plugins to customize - * the compaction prompt. - */ - 'experimental.session.compacting'?: ( - input: { sessionID: string }, - output: { context: string[]; prompt?: string } - ) => Promise; - - 'experimental.text.complete'?: ( - input: { sessionID: string; messageID: string; partID: string }, - output: { text: string } - ) => Promise; - - /** - * Modify tool definitions (description and parameters) sent to LLM - */ - 'tool.definition'?: ( - input: { toolID: string }, - output: { description: string; parameters: unknown } - ) => Promise; -} - -// Plugin function signature -export type Plugin = (input: PluginInput) => Promise; - -// Plugin module structure expected by opencode -export type PluginModule = { - id?: string; - server: Plugin; - tui?: never; -}; +export type BusEvent = SessionCompactedEvent | SessionIdleEvent | OtherEvent; diff --git a/packages/opencode-plugin/test/e2e/plugin.test.ts b/packages/opencode-plugin/test/e2e/plugin.test.ts index d0864cb2..42fbd537 100644 --- a/packages/opencode-plugin/test/e2e/plugin.test.ts +++ b/packages/opencode-plugin/test/e2e/plugin.test.ts @@ -6,7 +6,6 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { Effect } from 'effect'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; @@ -64,7 +63,10 @@ function createMockToolContext(overrides: Record = {}) { worktree: '', abort: new AbortController().signal, metadata: vi.fn(), - ask: vi.fn().mockReturnValue(Effect.void), + // Return a plain Promise to match the current SDK (1.15.x). + // The plugin's runAsk() helper handles both Promise and Effect return + // values, so either form works here — but we match the real SDK default. + ask: vi.fn().mockResolvedValue(undefined), ...overrides, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f06ab44e..5fca3f4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@codemcp/workflows-server': specifier: workspace:* version: link:../mcp-server + '@opencode-ai/plugin': + specifier: '*' + version: 1.3.12(@opentui/core@0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10)) rimraf: specifier: ^6.0.1 version: 6.1.2 @@ -3937,6 +3940,9 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + solid-js@1.9.12: resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} @@ -5582,6 +5588,14 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@opencode-ai/plugin@1.3.12(@opentui/core@0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@opencode-ai/sdk': 1.3.12 + zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opencode-ai/plugin@1.3.12(@opentui/core@0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.1.94(solid-js@1.9.12)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))': dependencies: '@opencode-ai/sdk': 1.3.12 @@ -5633,6 +5647,23 @@ snapshots: - stage-js - typescript + '@opentui/solid@0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.10(@babel/core@7.28.0)(solid-js@1.9.11) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.11 + transitivePeerDependencies: + - stage-js + - supports-color + - typescript + - web-tree-sitter + optional: true + '@opentui/solid@0.1.94(solid-js@1.9.12)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10)': dependencies: '@babel/core': 7.28.0 @@ -6593,6 +6624,14 @@ snapshots: reselect: 4.1.8 resolve: 1.22.11 + babel-preset-solid@1.9.10(@babel/core@7.28.0)(solid-js@1.9.11): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.6(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.11 + optional: true + babel-preset-solid@1.9.10(@babel/core@7.28.0)(solid-js@1.9.12): dependencies: '@babel/core': 7.28.0 @@ -8364,6 +8403,13 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + optional: true + solid-js@1.9.12: dependencies: csstype: 3.2.3