From 2ae7c00e847e2cfdfa44bdfffa1d343ee1ca6686 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:09:13 -0700 Subject: [PATCH] fix(opencode): keep OpenCode 2 panes Working across plugin reloads (#23700) * fix(opencode): keep OpenCode 2 panes Working across plugin reloads OpenCode 2 disposes and re-sets-up every plugin whenever its plugins dir changes, while sessions keep running. The status plugin published a final Idle on dispose, so a pane read Done mid-turn. Orca also rewrote the plugin file on every PTY spawn, so opening any terminal triggered that reload. Dispose now releases the factory's bookkeeping without publishing a verdict; the next lifecycle event settles the pane, and Orca's ended-process reconciliation still retires panes whose agent exited. The plugin file is written only when its bytes differ. * fix(opencode): skip rewriting an unchanged plugin in the SSH relay install too The relay's canonical-config install still unlinked and rewrote the status plugin on every OpenCode launch over SSH, which restarts every plugin in a remote OpenCode 2 server. Share one install-currency check (lstat + the existing byte comparison) between the local and relay writers, and pin write-if-changed with mtime so the tests also fail on filesystems that reuse a freed inode. * fix(opencode): keep the final Idle when OpenCode 1 tears its instance down OpenCode 1 disposes a plugin only when it tears the instance down, and that teardown cancels every running session, so the Idle published on dispose is true there; the cancelled run's own idle may never reach the plugin. Only OpenCode 2 disposes on a hot reload while turns keep running. The generated module serves both hosts, so the OpenCode 2 setup() entry point now tells the shared factory that sessions outlive disposal; the server() path keeps the previous disposal behaviour, including the hand-off to a surviving factory. * fix(opencode): compare a symlinked plugin by its target before rewriting OpenCode 2 loads plugins through file-level symlinks and reads the revision from the target's mtime, so a user whose Orca plugin file is a symlink (per-file dotfile managers) failed the regular-file check and got a write through the link, and a reload, on every spawn. The config-dir and relay installs now skip the write when the resolved target already has Orca's bytes; when stale they behave as before. Only the per-source overlay keeps the regular-file check, since a link there mirrors a user entry. Installers also skip the write inside a guarded block rather than returning early, so later install steps still run. * test(opencode): skip the plugin symlink tests on Windows like their neighbours Creating a file symlink on Windows needs Developer Mode or admin rights. * test(opencode): stub fetch without a type assertion in the dispose host test --- .../opencode/hook-plugin-dispose-host.test.ts | 165 ++++++++++++++++++ src/main/opencode/hook-service.test.ts | 62 ++++++- src/main/opencode/hook-service.ts | 23 ++- .../opencode/status-plugin-factory-source.ts | 16 +- .../opencode2/status-plugin-setup-source.ts | 3 +- src/relay/opencode-canonical-config.ts | 13 +- src/relay/plugin-overlay.test.ts | 47 +++++ src/shared/opencode-installed-plugin.test.ts | 47 +++++ src/shared/opencode-installed-plugin.ts | 22 +++ 9 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 src/main/opencode/hook-plugin-dispose-host.test.ts create mode 100644 src/shared/opencode-installed-plugin.test.ts create mode 100644 src/shared/opencode-installed-plugin.ts diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts new file mode 100644 index 00000000000..179714d8c83 --- /dev/null +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -0,0 +1,165 @@ +/** + * Executes the generated plugin through each OpenCode host entry point, because + * what disposal means differs by host: OpenCode 1 disposes only on instance + * teardown (which cancels every run), OpenCode 2 also on a plugin hot reload. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type PluginHooks = { + event: (input: { event: unknown }) => Promise + dispose?: () => Promise +} +type HostEvent = { type: string; data: Record } +type PluginModule = { + default?: { + server?: (ctx: unknown) => Promise + setup?: (ctx: unknown) => Promise<() => Promise> + } +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' +] as const + +// A live OpenCode 2 event bus: stays open until the subscriber aborts. +function createEventBus(): { + push: (event: HostEvent) => void + subscribe: (input: { signal: AbortSignal }) => AsyncGenerator +} { + const queue: HostEvent[] = [] + let wake: (() => void) | null = null + return { + push(event) { + queue.push(event) + wake?.() + }, + async *subscribe({ signal }) { + while (!signal.aborted) { + const next = queue.shift() + if (next) { + yield next + continue + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => resolve(), { once: true }) + }) + wake = null + } + } + } +} + +describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', (agent) => { + let tempDir: string + let savedEnv: Record + let names: string[] + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-dispose-host-')) + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + 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' + names = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + names.push(String(JSON.parse(String(init?.body)).payload?.hook_event_name)) + return new Response('{}', { status: 200 }) + }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPluginModule(): Promise { + const pluginPath = join(tempDir, `orca-${agent}-status.mjs`) + writeFileSync( + pluginPath, + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this shape. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + function setupContext(bus: ReturnType): unknown { + return { + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ id: sessionID }), + hook: async () => ({ dispose: async () => {} }) + }, + event: { subscribe: bus.subscribe } + } + } + + it('publishes a final Idle when the OpenCode 1 instance tears down mid-turn', async () => { + const module = await loadPluginModule() + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await hooks?.dispose?.() + + expect(names).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('keeps the pane Working when OpenCode 2 reloads the plugin mid-turn', async () => { + const module = await loadPluginModule() + const firstBus = createEventBus() + const firstCleanup = await module.default?.setup?.(setupContext(firstBus)) + firstBus.push({ type: 'session.execution.started', data: { sessionID: 'ses_root' } }) + await vi.waitFor(() => expect(names).toEqual(['SessionBusy'])) + + await firstCleanup?.() + expect(names).toEqual(['SessionBusy']) + + // The turn kept running; its end reaches the reloaded plugin. + const secondBus = createEventBus() + const secondCleanup = await module.default?.setup?.(setupContext(secondBus)) + secondBus.push({ type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } }) + await vi.waitFor(() => expect(names).toEqual(['SessionBusy', 'SessionIdle'])) + await secondCleanup?.() + expect(names).toEqual(['SessionBusy', 'SessionIdle']) + }) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 66fb3c86a47..e99550c59ea 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -97,11 +97,11 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - 'c81893814f08bfb2f9b5c133c5355b53d485982402d89e92ff15fc4e7d543079' + '655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('2267e2ab6e854e71c9bae12afed97e464f93b2b14f3e25dca133ca6666c98752') + ).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e') }) it('filters child sessions via parentID lookup before forwarding events', () => { @@ -294,6 +294,51 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(pluginSource).toContain('messageID: part.messageID') }) + // Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn. + it('leaves a current installed plugin untouched and replaces a stale one', () => { + const service = new OpenCodeHookService() + service.buildPtyEnv(daemonSessionId) + const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js') + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + service.buildPtyEnv(daemonSessionId) + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + + writeFileSync(pluginPath, 'stale plugin') + service.buildPtyEnv(daemonSessionId) + expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + }) + + // Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target. + it.skipIf(process.platform === 'win32')( + 'compares a symlinked plugin by its target and writes through only when stale', + () => { + const service = new OpenCodeHookService() + const pluginPath = join( + resolveOpenCodeConfigDirectory(), + 'plugins', + 'orca-opencode-status.js' + ) + const targetPath = join(userDataDir, 'dotfiles-orca-opencode-status.js') + writeFileSync(targetPath, _internals.getOpenCodePluginSource()) + rmSync(pluginPath, { force: true }) + symlinkSync(targetPath, pluginPath) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(targetPath, past, past) + + service.buildPtyEnv(daemonSessionId) + expect(statSync(targetPath).mtimeMs).toBe(past.getTime()) + + writeFileSync(targetPath, 'stale plugin') + service.buildPtyEnv(daemonSessionId) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) + expect(readFileSync(targetPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + rmSync(pluginPath, { force: true }) + rmSync(targetPath, { force: true }) + } + ) + // Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader // rejects a default export that only has server(). Asserting the emitted *source* is // not enough; the installed file is what the v2 server validates, so load it. @@ -700,6 +745,19 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) + it('leaves a current overlay plugin file in place across spawns', () => { + const service = new OpenCodeHookService() + const overlayDir = service.buildPtyEnv(ptyId, userConfigDir).OPENCODE_CONFIG_DIR! + const pluginPath = join(overlayDir, 'plugins', 'orca-opencode-status.js') + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + service.buildPtyEnv(ptyId, userConfigDir) + + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource()) + }) + it('reconciles stale mirrored entries while preserving OpenCode runtime files', () => { const service = new OpenCodeHookService() const firstEnv = service.buildPtyEnv(ptyId, userConfigDir) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index e7ec0cc8b2f..74f24577c6e 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -28,6 +28,10 @@ import { OPENCODE2_LEGACY_HOOKS_DIR, OPENCODE_LEGACY_HOOKS_DIR } from './legacy-shared-config-dir' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' @@ -278,18 +282,25 @@ export class OpenCodeHookService { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) - try { - unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + const source = this.pluginSource() + if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { + try { + unlinkSync(pluginPath) + } catch { + // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. + } + writeFileSync(pluginPath, source) } - writeFileSync(pluginPath, this.pluginSource()) } private writePluginToConfigDir(configDir: string): void { const pluginsDir = join(configDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) - writeFileSync(join(pluginsDir, this.pluginFileName), this.pluginSource()) + const pluginPath = join(pluginsDir, this.pluginFileName) + const source = this.pluginSource() + if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { + writeFileSync(pluginPath, source) + } } } diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 8348cc968eb..218d15c3e41 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -23,6 +23,7 @@ export function getStatusPluginFactorySource(options: { 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', ` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`, ' const client = _ctx?.client;', + ' const sessionsOutliveDispose = _ctx?.sessionsOutliveDispose === true;', ' const factoryID = ++nextFactoryID;', ' activeFactoryIDs.add(factoryID);', ' let disposed = false;', @@ -242,7 +243,14 @@ export function getStatusPluginFactorySource(options: { ' pendingAssistantPart = null;', ' }', ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', - ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', + ' // Why: OpenCode 1 disposes only on instance teardown, which cancels every run, so a final', + ' // Idle is true. OpenCode 2 also disposes on a hot reload mid-turn, so it publishes nothing.', + ' if (sessionsOutliveDispose) {', + ' if (desiredFactoryID === factoryID) {', + ' clearStatusRetry();', + ' statusRevision += 1;', + ' }', + ' } else if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', ' clearStatusRetry();', ' statusRevision += 1;', ' // A MessagePart may have changed the listener to Working after the', @@ -250,17 +258,13 @@ export function getStatusPluginFactorySource(options: { ' statusDeliveryDirty = ownsDeliveredMessagePart;', ' busyRecoveryUsed = false;', ' busyRecoveryEndpointKey = "";', - ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', - ' (id) => id !== factoryID', - ' );', + ' const fallbackFactoryID = Array.from(activeFactoryIDs).find((id) => id !== factoryID);', ' if (fallbackFactoryID !== undefined) {', ' await publishAggregateStatus(', ' fallbackFactoryID,', ' desiredStatusProperties?.sessionID', ' );', ' } else {', - ' // Why: Instance disposal can happen while the PTY stays alive;', - ' // publish a final idle so Orca does not retain a dead owner.', ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', ' await setStatus(', ' "idle",', diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index c68c15cbda2..7b825056ecd 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -24,7 +24,8 @@ async function setupOpenCode2Status(ctx) { // Without it, resolveRootSessionID returns null for every session and a // subagent's work publishes as if it were the root's. const client = { session: { get: async (input, options) => { const result = await ctx.session.get(input, options); return result && typeof result.id === "string" ? { data: result } : result; } } }; - hooks = await OrcaOpenCodeStatusPlugin({ client }); + // Why: this host disposes plugins on a hot reload while turns keep running. + hooks = await OrcaOpenCodeStatusPlugin({ client, sessionsOutliveDispose: true }); if (!hooks || typeof hooks.event !== "function") return noop; const promptRegistration = await ctx.session.hook("prompt", async (properties) => { await hooks.event({ event: { type: "session.next.prompt.admitted", properties } }); diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 4bcb9f3e151..38682d3f274 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,6 +1,7 @@ import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' +import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' const RELAY_HOOKS_DIR = '.orca-relay' @@ -18,12 +19,14 @@ export function installOpenCodePluginInCanonicalConfig( agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js' const pluginPath = join(configDir, 'plugins', pluginFileName) mkdirSync(join(configDir, 'plugins'), { recursive: true }) - try { - unlinkSync(pluginPath) - } catch { - // The file may not exist on the first install. + if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { + try { + unlinkSync(pluginPath) + } catch { + // The file may not exist on the first install. + } + writeFileSync(pluginPath, source) } - writeFileSync(pluginPath, source) return true } catch (err) { process.stderr.write( diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 14feb631876..d4d38a31fba 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -1,12 +1,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -74,6 +77,50 @@ describe('PluginOverlayManager', () => { ).toBe('v2 plugin') }) + // Why: OpenCode 2 reloads a plugin whose file mtime changed, even with unchanged bytes. + it('leaves a current canonical plugin untouched and replaces a stale one', () => { + const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') } + const pluginPath = join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode2-status.js') + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + manager.installOpenCodePlugin('opencode2', env) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(pluginPath, past, past) + + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(statSync(pluginPath).mtimeMs).toBe(past.getTime()) + + manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') + }) + + // Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target. + it.skipIf(process.platform === 'win32')( + 'leaves a symlinked canonical plugin with current bytes untouched', + () => { + const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') } + const pluginsDir = join(homeDir, 'xdg', 'opencode', 'plugins') + const pluginPath = join(pluginsDir, 'orca-opencode2-status.js') + const targetPath = join(homeDir, 'dotfiles-orca-opencode2-status.js') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(targetPath, 'v2 plugin') + symlinkSync(targetPath, pluginPath) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(targetPath, past, past) + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) + expect(statSync(targetPath).mtimeMs).toBe(past.getTime()) + + manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) + expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) + expect(lstatSync(pluginPath).isFile()).toBe(true) + expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') + expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin') + } + ) + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { const userConfigDir = join(homeDir, 'company-opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) diff --git a/src/shared/opencode-installed-plugin.test.ts b/src/shared/opencode-installed-plugin.test.ts new file mode 100644 index 00000000000..d7814c1229b --- /dev/null +++ b/src/shared/opencode-installed-plugin.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from './opencode-installed-plugin' + +describe('installed OpenCode plugin currency', () => { + let dir: string + let pluginPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-installed-plugin-')) + pluginPath = join(dir, 'orca-opencode-status.js') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('treats a missing or different file as stale', () => { + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + writeFileSync(pluginPath, 'older plugin') + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + }) + + it('treats a regular file with the same bytes as current', () => { + writeFileSync(pluginPath, 'plugin') + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true) + }) + + it.skipIf(process.platform === 'win32')( + 'follows a symlink except in an overlay, where a link is a mirrored user entry', + () => { + const targetPath = join(dir, 'target.js') + writeFileSync(targetPath, 'plugin') + symlinkSync(targetPath, pluginPath) + expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true) + expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false) + } + ) +}) diff --git a/src/shared/opencode-installed-plugin.ts b/src/shared/opencode-installed-plugin.ts new file mode 100644 index 00000000000..33a80f4d168 --- /dev/null +++ b/src/shared/opencode-installed-plugin.ts @@ -0,0 +1,22 @@ +import { lstatSync } from 'node:fs' +import { nodeFileContentsEqualSync } from './node-file-content-equality' + +// Why: OpenCode 2 reloads a plugin (and every plugin loaded after it) when its file's mtime changes, +// even with identical bytes, so installers must skip the write when Orca's plugin is already current. +// Follows symlinks: OpenCode 2 loads through them, so a linked file with Orca's bytes is current. +export function isInstalledOpenCodePluginCurrent(pluginPath: string, source: string): boolean { + try { + return nodeFileContentsEqualSync(pluginPath, source) + } catch { + return false + } +} + +// Overlay variant: a symlink there mirrors a user entry, never Orca's file, even when the bytes match. +export function isOverlayOpenCodePluginCurrent(pluginPath: string, source: string): boolean { + try { + return lstatSync(pluginPath).isFile() && nodeFileContentsEqualSync(pluginPath, source) + } catch { + return false + } +}