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,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
}
}