From 1a3f4e88c0f88781f52ce6f98859dabcb5589e66 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:29:04 -0700 Subject: [PATCH] fix(opencode): support v2 plugins under plain executable name Supports OpenCode 2 installed as opencode, including plugin loading and quick-command submission. --- .../hook-plugin-opencode2-setup.test.ts | 41 +++++++++++++++++-- src/main/opencode/hook-service.test.ts | 2 +- src/main/opencode/hook-service.ts | 13 +++++- .../opencode/status-plugin-factory-source.ts | 3 +- .../opencode2/status-plugin-setup-source.ts | 1 + .../lib/run-quick-command-in-new-tab.test.ts | 25 +++++++++++ .../src/lib/run-quick-command-in-new-tab.ts | 4 +- 7 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index b4d2cda8a59..5763687d315 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -17,7 +17,7 @@ vi.mock('electron', () => ({ import { _internals } from './hook-service' // Execute the generated module against legacy and current plugin contracts. -describe('OpenCode 2 setup and prompt ordering', () => { +describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (agent) => { type PostBody = { payload?: unknown } function record(value: unknown): Record | undefined { @@ -48,6 +48,7 @@ describe('OpenCode 2 setup and prompt ordering', () => { // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -64,6 +65,7 @@ describe('OpenCode 2 setup and prompt ordering', () => { for (const key of ENV_KEYS) { savedEnv[key] = process.env[key] } + process.env.ORCA_OPENCODE_AGENT = agent delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' @@ -94,6 +96,21 @@ describe('OpenCode 2 setup and prompt ordering', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('does not register hooks for the other pane variant', async () => { + process.env.ORCA_OPENCODE_AGENT = agent === 'opencode' ? 'opencode2' : 'opencode' + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + const hook = vi.fn() + const subscribe = vi.fn() + const cleanup = await module.default?.setup?.({ session: { hook }, event: { subscribe } }) + expect(hook).not.toHaveBeenCalled() + expect(subscribe).not.toHaveBeenCalled() + await cleanup?.() + }) + it('subscribes through the OpenCode 2 setup API and disposes its registrations', async () => { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const posts: unknown[] = [] @@ -103,7 +120,11 @@ describe('OpenCode 2 setup and prompt ordering', () => { }) const dispose = vi.fn() let subscriptionSignal: AbortSignal | undefined - const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) expect(module.default?.setup).toBeTypeOf('function') const cleanup = await module.default?.setup?.({ session: { @@ -137,6 +158,10 @@ describe('OpenCode 2 setup and prompt ordering', () => { ]) ) }) + expect(globalThis.fetch).toHaveBeenCalledWith( + `http://127.0.0.1:59999/hook/${agent}`, + expect.objectContaining({ method: 'POST' }) + ) await cleanup?.() expect(dispose).toHaveBeenCalledOnce() expect(subscriptionSignal?.aborted).toBe(true) @@ -149,7 +174,11 @@ describe('OpenCode 2 setup and prompt ordering', () => { posts.push({ body: record(JSON.parse(String(init?.body))) ?? {} }) return new Response('{}', { status: 200 }) }) - const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) const cleanup = await module.default?.setup?.({ session: { get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }), @@ -239,7 +268,11 @@ describe('OpenCode 2 setup and prompt ordering', () => { const lookup = new Promise<{ data: { id: string } }>((resolve) => { releaseLookup = resolve }) - const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) const hooks = await module.default?.server?.({ client: { session: { get: () => lookup } } }) expect(hooks).toBeDefined() const prompt = hooks?.event({ diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index c33208c2b89..5cc0d089a59 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -91,7 +91,7 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - '938867eae97b7ae4a7193a07755ee74a77c7a7352edcc28b59543b1b00713612' + '51ae4f9fbf7a85e3e33db961d0d83b89395ed026335502569e4f3179927aa629' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 36b92c9a96d..f46667ed1b8 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -51,8 +51,13 @@ function toSafeDirName(id: string): string { return createHash('sha256').update(id).digest('hex').slice(0, 32) } +// Both major versions install as `opencode`; let the loader choose server() or setup(). export function getOpenCodePluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true }) + return getOpenCodeFamilyPluginSource('/hook/opencode', { + emitSessionStart: true, + emitNextEvents: true, + expectedAgent: 'opencode' + }) } export function getOpenCode2PluginSource(): string { @@ -64,7 +69,11 @@ export function getOpenCode2PluginSource(): string { export function getOpenCodeFamilyPluginSource( hookPathname: string, - options: { emitSessionStart: boolean; emitNextEvents?: boolean } + options: { + emitSessionStart: boolean + emitNextEvents?: boolean + expectedAgent?: 'opencode' | 'opencode2' + } ): string { // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 871692639fe..fbd6d71a6d0 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -6,8 +6,9 @@ import { export function getStatusPluginFactorySource(options: { emitSessionStart: boolean emitNextEvents?: boolean + expectedAgent?: 'opencode' | 'opencode2' }): string[] { - const expectedAgent = options.emitNextEvents ? 'opencode2' : 'opencode' + const expectedAgent = options.expectedAgent ?? (options.emitNextEvents ? 'opencode2' : 'opencode') return [ ...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []), '// Why: accept the factory argument as an optional opaque parameter instead', diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index c33385ef8d6..cd2a428d101 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -4,6 +4,7 @@ async function setupOpenCode2Status(ctx) { const controller = new AbortController(); const client = { session: { get: (input, options) => ctx.session.get(input, options) } }; const hooks = await OrcaOpenCodeStatusPlugin({ client }); + if (!hooks.event) return async () => {}; const promptRegistration = await ctx.session.hook("prompt", async (properties) => { await hooks.event({ event: { type: "session.next.prompt.admitted", properties } }); }); diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts index c87478973d5..8ed19648702 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts @@ -183,6 +183,31 @@ describe('runQuickCommandInNewTab', () => { }) }) + it('uses the same ready-state path for plain OpenCode installs', () => { + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-terminal', tabId: 'tab-opencode' } + }) + mockState.unifiedTabsByWorktree['repo::worktree'] = [ + { entityId: 'tab-opencode', contentType: 'terminal', groupId: 'group-1' } + ] + + runQuickCommandInNewTab({ + command: { + id: 'agent-opencode', + label: 'OpenCode review', + action: 'agent-prompt', + agent: 'opencode', + prompt: 'Review this diff' + }, + worktreeId: 'repo::worktree', + groupId: 'group-1' + }) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ agent: 'opencode', promptDelivery: 'submit-after-ready' }) + ) + }) + it('falls back to the active group when context-menu group resolution is missing', () => { mockState.activeGroupIdByWorktree['repo::worktree'] = 'active-group' mocks.launchAgentInNewTab.mockReturnValue({ diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.ts index 3245fa10532..6ca148ee198 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.ts @@ -69,7 +69,9 @@ export function runQuickCommandInNewTab({ prompt: command.prompt, worktreeId, groupId: targetGroupId, - ...(command.agent === 'opencode2' ? { promptDelivery: 'submit-after-ready' as const } : {}), + ...(command.agent === 'opencode' || command.agent === 'opencode2' + ? { promptDelivery: 'submit-after-ready' as const } + : {}), launchSource: 'quick_command', quickCommandLabel: command.label })