From c5e80e9a66ad29a7a1393c6ab921121a074568be Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Sun, 17 May 2026 21:04:14 +0900 Subject: [PATCH] fix: restore opencode hook plugin channel and coalesce ACP reasoning chunks across all consumers (#631) --- .../backends/acp/AcpMessageHandler.test.ts | 150 +++++++++++-- .../agent/backends/acp/AcpMessageHandler.ts | 96 +++++++-- cli/src/agent/backends/acp/AcpSdkBackend.ts | 6 +- cli/src/opencode/utils/hookPlugin.test.ts | 200 ++++++++++++++++++ cli/src/opencode/utils/hookPlugin.ts | 104 ++++++++- 5 files changed, 516 insertions(+), 40 deletions(-) create mode 100644 cli/src/opencode/utils/hookPlugin.test.ts diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index d540f1d1..fbb6a7b8 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -658,7 +658,7 @@ describe('AcpMessageHandler', () => { expect((messages[0] as { text: string }).text).toMatch(/^Claude AI usage limit warning\|/); }); - it('forwards agent_thought_chunk as a reasoning message', () => { + it('forwards agent_thought_chunk as a reasoning message after flush', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); @@ -667,6 +667,9 @@ describe('AcpMessageHandler', () => { content: { type: 'text', text: 'thinking about the problem' } }); + // Chunks are buffered, not emitted inline. + expect(messages).toHaveLength(0); + handler.flushReasoning(); expect(messages).toHaveLength(1); expect(messages[0]).toEqual({ type: 'reasoning', text: 'thinking about the problem' }); }); @@ -697,16 +700,16 @@ describe('AcpMessageHandler', () => { content: { type: 'text', text: 'mid-stream thought' } }); + // The thought chunk must not flush the live text buffer — otherwise + // a single text segment would split across two messages. + handler.flushReasoning(); handler.flushText(); - // Both messages are delivered intact with no loss. Reasoning is - // emitted inline (see AcpMessageHandler) so it precedes the - // flushed text segment — this is an intentional contract to let - // thoughts and text interleave without splitting a live segment. expect(messages).toHaveLength(2); + // Reasoning was buffered separately and is now delivered as a single + // coalesced message. The text buffer survived the thought. expect(messages).toContainEqual({ type: 'reasoning', text: 'mid-stream thought' }); expect(messages).toContainEqual({ type: 'text', text: 'partial answer' }); - expect(messages[0]).toEqual({ type: 'reasoning', text: 'mid-stream thought' }); }); it('does not drop thought chunks annotated with a non-assistant audience', () => { @@ -721,32 +724,155 @@ describe('AcpMessageHandler', () => { annotations: { audience: ['user'] } } }); + handler.flushReasoning(); expect(messages).toHaveLength(1); expect(messages[0]).toEqual({ type: 'reasoning', text: 'private reasoning' }); }); - it('forwards sequential thought chunks in arrival order as separate reasoning messages', () => { + it('coalesces sequential thought chunks into a single reasoning message', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); handler.handleUpdate({ sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, - content: { type: 'text', text: 'first thought' } + content: { type: 'text', text: 'first thought ' } }); handler.handleUpdate({ sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, - content: { type: 'text', text: 'second thought' } + content: { type: 'text', text: 'second thought ' } }); handler.handleUpdate({ sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, content: { type: 'text', text: 'third thought' } }); + handler.flushReasoning(); + + // OpenCode/Zen streams thoughts at one chunk per token; emitting + // each chunk as its own reasoning message made the web reducer + // render one row per token. The handler now coalesces a thought + // segment into a single reasoning message. + expect(messages).toEqual([ + { type: 'reasoning', text: 'first thought second thought third thought' } + ]); + }); + + it('emits buffered reasoning before a tool_call boundary', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: 'I should call the tool. ' } + }); + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: 'Calling now.' } + }); + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'tc-1', + title: 'do_thing', + kind: 'execute', + rawInput: { foo: 1 }, + status: 'in_progress' + }); + + // Reasoning is coalesced and emitted before the tool call so the + // arrival order between thought and tool lifecycle is preserved. + expect(messages[0]).toEqual({ + type: 'reasoning', + text: 'I should call the tool. Calling now.' + }); + expect(messages[1]).toMatchObject({ type: 'tool_call', id: 'tc-1' }); + }); + + // Locks the flush-before-every-non-thought-boundary contract introduced + // in this fix: a future refactor that forgets to call flushReasoning() in + // one branch of handleUpdate would otherwise silently regress reasoning + // ordering for that update type. + it.each([ + [ + 'agentMessageChunk', + { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text: 'visible answer' } + } + ], + [ + 'toolCall', + { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'tc-x', + title: 'do_thing', + kind: 'execute', + rawInput: {}, + status: 'in_progress' + } + ], + [ + 'plan', + { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.plan, + entries: [{ content: 'Step 1', priority: 'high', status: 'pending' }] + } + ] + ])('flushes buffered reasoning before %s', (_label, boundaryUpdate) => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: 'thinking first' } + }); + handler.handleUpdate(boundaryUpdate); + handler.drainBuffers(); + + // Reasoning must arrive at index 0, before anything the boundary + // update produced. + expect(messages[0]).toEqual({ type: 'reasoning', text: 'thinking first' }); + expect(messages.length).toBeGreaterThanOrEqual(1); + }); + + it('drops whitespace-only buffered reasoning rather than emitting an empty bubble', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: ' ' } + }); + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: '\n\n' } + }); + handler.drainBuffers(); + + // A whitespace-only reasoning bubble in the web UI is visible only as + // empty space — drop it instead. + expect(messages.filter((m) => m.type === 'reasoning')).toEqual([]); + }); + + it('drainBuffers emits reasoning before any pending text', () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + // Build up text and reasoning together — text first, then thought + // interleaved (per the existing intra-segment contract). + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text: 'visible' } + }); + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: 'silent' } + }); + + handler.drainBuffers(); expect(messages).toEqual([ - { type: 'reasoning', text: 'first thought' }, - { type: 'reasoning', text: 'second thought' }, - { type: 'reasoning', text: 'third thought' } + { type: 'reasoning', text: 'silent' }, + { type: 'text', text: 'visible' } ]); }); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 3772c548..3f956eb2 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -273,6 +273,11 @@ function getSuffixPrefixOverlap(base: string, next: string): number { export class AcpMessageHandler { private readonly toolCalls = new Map(); private bufferedText = ''; + // Array buffer avoids the O(N²) string concatenation that per-token + // ACP streams (OpenCode/Zen emits one chunk per generated token) would + // otherwise incur — a 10k-token reasoning trace allocates 10k full-buffer + // copies if we use `+=`. + private bufferedReasoning: string[] = []; constructor(private readonly onMessage: (message: AgentMessage) => void) {} @@ -291,6 +296,50 @@ export class AcpMessageHandler { this.onMessage({ type: 'text', text }); } + /** + * Emits buffered thought chunks as a single reasoning message and clears + * the buffer. ACP agents (notably OpenCode/Zen) stream thoughts at the + * granularity of one chunk per token; emitting each chunk inline would + * make the web reducer render one row per token. Coalescing here keeps + * the reasoning block intact while preserving its position relative to + * adjacent text segments and tool events. + * + * Called automatically before every non-thought update inside + * `handleUpdate`, and externally at turn boundaries by `drainBuffers` + * from AcpSdkBackend. + * + * Whitespace-only buffers are dropped: a turn that happens to emit a + * single whitespace token would otherwise render an empty Reasoning row + * in the web UI. + */ + flushReasoning(): void { + if (this.bufferedReasoning.length === 0) { + return; + } + const text = this.bufferedReasoning.join(''); + this.bufferedReasoning = []; + if (text.trim().length === 0) { + return; + } + this.onMessage({ type: 'reasoning', text }); + } + + /** + * Single entry point for turn-boundary draining. Reasoning is always + * flushed before text so that, even when the agent streamed thoughts + * after a text segment had already opened, the final rendered turn + * shows the Reasoning block above the answer (matching the web UI + * component layout). This is a deliberate UX-driven ordering — not + * a preservation of the agent's arrival order, which could place + * text before reasoning within a single turn. Callers in + * `AcpSdkBackend` must use this rather than the individual flush + * methods to keep the order invariant enforced in one place. + */ + drainBuffers(): void { + this.flushReasoning(); + this.flushText(); + } + private appendTextChunk(text: string): void { if (!text) { return; @@ -331,6 +380,31 @@ export class AcpMessageHandler { const updateType = asString(update.sessionUpdate); if (!updateType) return; + if (updateType === ACP_SESSION_UPDATE_TYPES.agentThoughtChunk) { + // Thought chunks do not participate in intra-turn ordering and + // must not flush the text buffer (that would split a live text + // segment). Coalesce them into a single reasoning buffer so the + // web UI renders one Reasoning block per turn segment instead + // of one row per streaming token. + // + // We deliberately do not reuse `extractTextContent` here: that + // helper applies an assistant-audience filter which only makes + // sense for regular message chunks. Thought content has no + // meaningful audience — a non-assistant audience annotation + // should not cause the reasoning to be silently dropped. + const content = update.content; + if (isObject(content) && content.type === 'text' && typeof content.text === 'string' && content.text.length > 0) { + this.bufferedReasoning.push(content.text); + } + return; + } + + // Any non-thought update is a reasoning-segment boundary: emit the + // accumulated thought now so it arrives before the next event in + // the same arrival order that streamed in. Tool calls / plans + // additionally flush the text buffer below. + this.flushReasoning(); + if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) { const content = update.content; const text = extractTextContent(content); @@ -366,28 +440,6 @@ export class AcpMessageHandler { return; } - if (updateType === ACP_SESSION_UPDATE_TYPES.agentThoughtChunk) { - // Thought chunks do not participate in intra-turn ordering and - // must not flush the text buffer (that would split a live text - // segment). Forward as a reasoning message so the web UI can - // render the model's thinking in a collapsible block. - // - // Reasoning messages are emitted inline (never buffered), so they - // arrive before any still-pending text segment is flushed. Tests - // in this file rely on that contract. - // - // We deliberately do not reuse `extractTextContent` here: that - // helper applies an assistant-audience filter which only makes - // sense for regular message chunks. Thought content has no - // meaningful audience — a non-assistant audience annotation - // should not cause the reasoning to be silently dropped. - const content = update.content; - if (isObject(content) && content.type === 'text' && typeof content.text === 'string' && content.text.length > 0) { - this.onMessage({ type: 'reasoning', text: content.text }); - } - return; - } - if (updateType === ACP_SESSION_UPDATE_TYPES.toolCall) { // A new tool invocation closes the preceding text segment. // Flushing here preserves the arrival order between text and diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 48afdb15..c15074bd 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -210,7 +210,7 @@ export class AcpSdkBackend implements AgentBackend { AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS, AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS ); - this.messageHandler?.flushText(); + this.messageHandler?.drainBuffers(); this.messageHandler = null; await this.waitForSessionUpdateQuiet( AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS, @@ -235,7 +235,7 @@ export class AcpSdkBackend implements AgentBackend { AcpSdkBackend.UPDATE_QUIET_PERIOD_MS, AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS ); - this.messageHandler?.flushText(); + this.messageHandler?.drainBuffers(); try { if (stopReason) { onUpdate({ type: 'turn_complete', stopReason }); @@ -318,7 +318,7 @@ export class AcpSdkBackend implements AgentBackend { async disconnect(): Promise { if (!this.transport) return; - this.messageHandler?.flushText(); + this.messageHandler?.drainBuffers(); this.messageHandler = null; this.activeSessionId = null; this.isProcessingMessage = false; diff --git a/cli/src/opencode/utils/hookPlugin.test.ts b/cli/src/opencode/utils/hookPlugin.test.ts new file mode 100644 index 00000000..f430d79c --- /dev/null +++ b/cli/src/opencode/utils/hookPlugin.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Stub out `@/configuration` so the helper considers our tmpdir HAPI-managed. +// Tests that exercise the non-managed branch override happyHomeDir explicitly. +vi.mock('@/configuration', () => ({ + configuration: { + get happyHomeDir(): string { + return (globalThis as { __hapiHomeStub?: string }).__hapiHomeStub ?? tmpdir(); + } + } +})); + +// `@/ui/logger` reads `configuration.logsDir` at module load. We only need the +// .debug / .warn surface here, so substitute spy shims — tests can assert +// against `loggerMock.warn` to lock in the warn-on-failure contract. +// `vi.hoisted` ensures the mocks exist when the hoisted `vi.mock` factory runs. +const loggerMock = vi.hoisted(() => ({ + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn() +})); +vi.mock('@/ui/logger', () => ({ logger: loggerMock })); + +import { ensureOpencodeHookPlugin } from './hookPlugin'; + +function makeTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function setHapiHome(value: string): void { + (globalThis as { __hapiHomeStub?: string }).__hapiHomeStub = value; +} + +function resetHapiHome(): void { + delete (globalThis as { __hapiHomeStub?: string }).__hapiHomeStub; +} + +describe('buildPluginSource (via ensureOpencodeHookPlugin)', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = makeTempDir('hapi-hookplugin-src-'); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + resetHapiHome(); + }); + + it('emits real newlines, not the literal two-character escape', () => { + const pluginPath = ensureOpencodeHookPlugin(tempRoot, 'http://127.0.0.1:1/hook', 'tok'); + const source = readFileSync(pluginPath, 'utf-8'); + // Regression for the bug where `.join('\\n')` produced a single-line + // file riddled with literal `\n` sequences and was silently dropped by + // opencode's plugin loader as a syntax error. + expect(source).not.toMatch(/\\n/); + expect(source.split('\n').length).toBeGreaterThan(50); + }); + + it('encodes hook url and token without injection-via-quote', () => { + const evilToken = 'a"; process.exit(1); //'; + const pluginPath = ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', evilToken); + const source = readFileSync(pluginPath, 'utf-8'); + // The token must be JSON-escaped — embedding the raw string would let + // a malicious caller terminate the literal and inject JS into the + // generated plugin. + expect(source).toContain(JSON.stringify(evilToken)); + expect(source).not.toContain(`= "${evilToken}";`); + }); + + it('preserves the file unchanged when called with identical inputs', () => { + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + const first = readFileSync(join(tempRoot, 'plugins', 'hapi-hook.ts')); + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + const second = readFileSync(join(tempRoot, 'plugins', 'hapi-hook.ts')); + expect(second.equals(first)).toBe(true); + }); +}); + +describe('ensurePluginRuntime (via ensureOpencodeHookPlugin)', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = makeTempDir('hapi-hookplugin-rt-'); + loggerMock.warn.mockClear(); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + resetHapiHome(); + }); + + it('writes a minimal package.json pinned to a tested @opencode-ai/plugin version', () => { + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + + const pkg = JSON.parse(readFileSync(join(tempRoot, 'package.json'), 'utf-8')); + // Pinned (`^1.14.0`) rather than `*` — a wildcard would let a registry + // change pull a moving target into a code-execution-adjacent path. + const declared = pkg.dependencies['@opencode-ai/plugin']; + expect(declared).toMatch(/^[\^~]?\d/); + expect(declared).not.toBe('*'); + }); + + it('preserves an existing package.json that already declares @opencode-ai/plugin', () => { + const existing = JSON.stringify({ + dependencies: { '@opencode-ai/plugin': '1.14.30', somethingElse: '^2.0.0' } + }, null, 2) + '\n'; + writeFileSync(join(tempRoot, 'package.json'), existing, 'utf-8'); + + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + + const actual = readFileSync(join(tempRoot, 'package.json'), 'utf-8'); + expect(actual).toBe(existing); + }); + + it('overwrites an existing package.json that does NOT declare @opencode-ai/plugin', () => { + // Regression: short-circuiting on plain presence would silently re-create + // the broken state — package.json sits there but plugin discovery fails. + const unrelated = JSON.stringify({ + dependencies: { 'some-other-package': '^1.0.0' } + }, null, 2) + '\n'; + writeFileSync(join(tempRoot, 'package.json'), unrelated, 'utf-8'); + + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + + const pkg = JSON.parse(readFileSync(join(tempRoot, 'package.json'), 'utf-8')); + expect(pkg.dependencies['@opencode-ai/plugin']).toBeDefined(); + }); + + it('does NOT write to a non-HAPI-managed dir (user-supplied OPENCODE_CONFIG_DIR)', () => { + // Simulate a user pointing OPENCODE_CONFIG_DIR at their own ~/.config/opencode. + // Even though it's empty, HAPI must not pollute it with a placeholder. + const userOwned = makeTempDir('hapi-not-managed-'); + try { + setHapiHome(makeTempDir('hapi-home-elsewhere-')); + + ensureOpencodeHookPlugin(userOwned, 'http://h/hook', 't'); + + expect(existsSync(join(userOwned, 'package.json'))).toBe(false); + // Plugin file is still written — that's HAPI's contract — but the + // package.json side effect is gated. + expect(existsSync(join(userOwned, 'plugins', 'hapi-hook.ts'))).toBe(true); + } finally { + rmSync(userOwned, { recursive: true, force: true }); + resetHapiHome(); + } + }); + + it('does not touch node_modules or package-lock.json (opencode materializes them)', () => { + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + + expect(existsSync(join(tempRoot, 'node_modules'))).toBe(false); + expect(existsSync(join(tempRoot, 'package-lock.json'))).toBe(false); + }); + + it('preserves existing node_modules and package-lock.json across launches', () => { + // Real-world: opencode has run once, installed deps, and we are launching + // again. The previous install's artifacts must survive our write. + const placeholderNm = join(tempRoot, 'node_modules'); + const placeholderLock = join(tempRoot, 'package-lock.json'); + // Use directories/files we can checksum after the call. + writeFileSync(placeholderLock, '{"name":"sentinel"}', 'utf-8'); + const lockBefore = readFileSync(placeholderLock, 'utf-8'); + + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + + expect(readFileSync(placeholderLock, 'utf-8')).toBe(lockBefore); + // node_modules wasn't pre-staged in this case, so it should still be absent. + expect(existsSync(placeholderNm)).toBe(false); + }); + + it('emits a warn (not a throw) when package.json cannot be written', async () => { + // Pre-create a directory named `package.json` inside rootPath. The + // subsequent writeFileSync will throw EISDIR / EPERM on every + // platform. The launcher must keep going (scanner channel #589 is + // the documented fallback) and surface the failure via logger.warn. + const fs = await import('node:fs'); + fs.mkdirSync(join(tempRoot, 'package.json')); + + // Must not throw. + expect(() => ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't')).not.toThrow(); + + // Plugin file should still be written (that path is independent). + expect(existsSync(join(tempRoot, 'plugins', 'hapi-hook.ts'))).toBe(true); + + // The failure must be surfaced — silent failure was the pre-#589 + // bug shape we are explicitly *not* repeating. + expect(loggerMock.warn).toHaveBeenCalledTimes(1); + expect(loggerMock.warn.mock.calls[0][0]).toMatch(/Failed to materialize/); + }); + + it('does not warn on the happy path', () => { + ensureOpencodeHookPlugin(tempRoot, 'http://h/hook', 't'); + expect(loggerMock.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/opencode/utils/hookPlugin.ts b/cli/src/opencode/utils/hookPlugin.ts index 5916dd1a..fbf5b73e 100644 --- a/cli/src/opencode/utils/hookPlugin.ts +++ b/cli/src/opencode/utils/hookPlugin.ts @@ -1,7 +1,16 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative } from 'node:path'; +import { logger } from '@/ui/logger'; +import { configuration } from '@/configuration'; const PLUGIN_FILENAME = 'hapi-hook.ts'; +const PACKAGE_JSON_FILENAME = 'package.json'; +const PLUGIN_PACKAGE = '@opencode-ai/plugin'; +// Pinned to the major version HAPI has been validated against (opencode +// 1.14.x). Using '*' would let an unrelated registry change pull a moving +// target into a code-execution path (the plugin runtime executes inside +// opencode with the session hook URL + token in scope). +const PLUGIN_PACKAGE_VERSION = '^1.14.0'; function buildPluginSource(hookUrl: string, token: string): string { const escapedUrl = JSON.stringify(hookUrl); @@ -107,16 +116,105 @@ function buildPluginSource(hookUrl: string, token: string): string { ' };', '};', '' - ].join('\\n'); + ].join('\n'); } function resolvePluginDir(rootPath: string): string { return join(rootPath, 'plugins'); } +/** + * `rootPath` is a HAPI-managed directory only when it lives under + * `configuration.happyHomeDir` (the default `OPENCODE_CONFIG_DIR` we + * synthesize per-session). If a user has exported `OPENCODE_CONFIG_DIR` + * pointing at e.g. their own `~/.config/opencode`, we must not pollute it + * with a placeholder `package.json`. + */ +function isHapiManagedDir(rootPath: string): boolean { + const home = configuration.happyHomeDir; + if (!home) { + return false; + } + const rel = relative(home, rootPath); + // `relative` returns a path that does NOT start with '..' and is not + // absolute when rootPath is inside home. On Windows a cross-volume + // input (e.g. home=`C:\\hapi`, rootPath=`D:\\…`) makes `relative` + // return the absolute `D:\\…` verbatim — `startsWith('/')` would miss + // that, so use `isAbsolute` which covers both POSIX `/` and win32 + // `:\\`. + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +function hasDeclaredPluginPackage(packageJsonPath: string): boolean { + try { + const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + return Boolean(parsed.dependencies?.[PLUGIN_PACKAGE] ?? parsed.devDependencies?.[PLUGIN_PACKAGE]); + } catch { + return false; + } +} + +function buildMinimalPackageJson(): string { + return `${JSON.stringify({ + dependencies: { [PLUGIN_PACKAGE]: PLUGIN_PACKAGE_VERSION } + }, null, 2)}\n`; +} + +/** + * Ensure the opencode runtime can resolve `@opencode-ai/plugin` from our + * isolated config dir. Since opencode 1.14.x the plugin loader is a separate + * npm package, and `/plugins/*.ts` files are only evaluated when + * that package is resolvable from ``. HAPI's per-session + * OPENCODE_CONFIG_DIR is otherwise an empty directory, so plugin discovery + * never even attempts to load hapi-hook.ts. + * + * Writing a minimal `package.json` declaring the dependency is enough: + * opencode itself materializes `node_modules` and `package-lock.json` on + * the next launch (the same install path `opencode plugin install` + * follows). No symlinks, no install spawn, no cross-platform branches — + * just a one-line dependency declaration. + * + * Guards against three failure modes: + * - **Non-managed dir**: when the caller pointed OPENCODE_CONFIG_DIR at a + * directory outside `happyHomeDir` (e.g. the user's global opencode + * config) we leave it alone — that filesystem is not ours to mutate. + * - **Existing package.json missing our dep**: parse it and only short- + * circuit when `@opencode-ai/plugin` is already declared. Otherwise + * overwrite the placeholder so plugin discovery actually works. + * - **Write failure**: log and continue; the scanner channel restored in + * upstream #589 still carries messages, so a non-writable cfg dir is + * degraded but not fatal. + */ +function ensurePluginRuntime(rootPath: string): void { + if (!isHapiManagedDir(rootPath)) { + logger.debug(`[opencode-hook] Skipping plugin runtime materialization for non-HAPI dir: ${rootPath}`); + return; + } + + const packageJsonPath = join(rootPath, PACKAGE_JSON_FILENAME); + if (existsSync(packageJsonPath)) { + if (hasDeclaredPluginPackage(packageJsonPath)) { + // Existing file already declares the plugin (likely opencode- + // installed with a matching lock file). Leave it untouched. + return; + } + logger.debug(`[opencode-hook] package.json exists at ${packageJsonPath} but does not declare ${PLUGIN_PACKAGE}; overwriting placeholder.`); + } + + try { + writeFileSync(packageJsonPath, buildMinimalPackageJson(), 'utf-8'); + } catch (error) { + logger.warn(`[opencode-hook] Failed to materialize ${packageJsonPath}; the hook plugin channel may stay inert. Storage scanner remains as fallback. Error: ${(error as Error).message}`); + } +} + export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string { const pluginDir = resolvePluginDir(rootPath); mkdirSync(pluginDir, { recursive: true }); + ensurePluginRuntime(rootPath); const pluginPath = join(pluginDir, PLUGIN_FILENAME); const nextSource = buildPluginSource(hookUrl, token);