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
This commit is contained in:
Brennan Benson
2026-09-28 21:09:13 -07:00
committed by GitHub
parent 3976ad4c59
commit 2ae7c00e84
9 changed files with 378 additions and 20 deletions
@@ -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<void>
dispose?: () => Promise<void>
}
type HostEvent = { type: string; data: Record<string, unknown> }
type PluginModule = {
default?: {
server?: (ctx: unknown) => Promise<PluginHooks>
setup?: (ctx: unknown) => Promise<() => Promise<void>>
}
}
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<HostEvent>
} {
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<void>((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<string, string | undefined>
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<PluginModule> {
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<typeof createEventBus>): 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'])
})
})
+60 -2
View File
@@ -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)
+17 -6
View File
@@ -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)
}
}
}
@@ -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",',
@@ -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 } });
+8 -5
View File
@@ -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(
+47
View File
@@ -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 })
@@ -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)
}
)
})
+22
View File
@@ -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
}
}