// Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed // extension files installed into the remote agent homes. Host paths from the // renderer are meaningless on SSH targets, so the relay performs the remote // filesystem work itself. // // Plugin source strings ship over the JSON-RPC channel at session-ready // (commit #7) — they are NOT bundled with the relay binary because the // relay is versioned independently from Orca and the plugin source changes // frequently as new agent events get added (see docs/design/agent-status- // over-ssh.md §4 "Why ship the plugin source over the wire"). // // We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService // directly: those modules import `electron` and ride on Orca's userData // path. The relay's electron-free constraint forces a thin parallel // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' const RELAY_HOOKS_DIR = '.orca-relay' const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays' const PI_OVERLAY_SUBDIR_BY_KIND: Record = { pi: 'pi-overlays', omp: 'omp-overlays' } const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' // Why: bare-shell OMP still needs ORCA_OMP_STATUS_EXTENSION without mkdir ~/.omp. // Mirror local userData/omp-managed-status-extension under the relay home root. const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' function withOrcaManagedPiExtensionMarker(source: string): string { return source.includes(ORCA_MANAGED_EXTENSION_MARKER) ? source : `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}` } // Why: source-dir resolution is keyed off the launching agent (Pi or OMP). // Both consume `PI_CODING_AGENT_DIR` but default to different `~/./agent` // paths on the remote disk. The renderer-chosen launch command flows in via // the relay PtyEnvAugmenter ctx; never derived from disk presence (a // cross-agent fallback shadows the other agent's user extensions when both // are installed). const PI_AGENT_HOME_DIR_NAME: Record = { pi: '.pi', omp: '.omp' } function safeDirName(input: string): string { // Why: paneKey embeds tabId:paneId where tabId may itself contain // filesystem-unsafe characters in some Orca builds. Hash to a fixed-width // hex name so any input produces a portable directory name. return createHash('sha256').update(input).digest('hex').slice(0, 32) } function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { /** Source body of `orca-opencode-status.js` to drop into /plugins/. */ opencodePluginSource?: string /** Source body of Pi's `orca-agent-status.ts` to drop into /extensions/. */ piExtensionSource?: string /** Source body of OMP's `orca-agent-status.ts` to drop into /extensions/. */ ompExtensionSource?: string } /** Result of installing Pi/OMP status into a real agent home or OMP fallback path. */ export type MaterializePiResult = { /** Real agent dir when extensions were installed there. Absent for OMP status-only fallback. */ sourceAgentDir?: string /** Absolute path to orca-agent-status.ts (real home or relay-managed fallback). */ statusExtensionPath?: string } /** Presence of this file is what makes an overlay usable — a rebuild that failed * after the wipe leaves the dir itself present but the plugin missing. */ export function getRelayOpenCodePluginPath(overlayDir: string): string { return join(overlayDir, 'plugins', OPENCODE_PLUGIN_FILE) } export class PluginOverlayManager { private opencodePluginSource: string | null = null private piExtensionSources: Record = { pi: null, omp: null } private homeDir: string private opencodeRoot: string private piRoots: Record constructor(opts?: { homeDir?: string }) { const home = opts?.homeDir ?? homedir() this.homeDir = home this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR) this.piRoots = { pi: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.pi), omp: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.omp) } } /** Replace the cached source bodies. Called from relay.ts when Orca sends * `agent_hook.installPlugins`. The first install enables the augmenter * output; subsequent installs (e.g. Orca version upgrade in flight) refresh * the cached source so future spawns see the new strings. * Note: existing running agents keep whatever source they loaded at * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } if (typeof sources.piExtensionSource === 'string') { this.piExtensionSources.pi = withOrcaManagedPiExtensionMarker(sources.piExtensionSource) } if (typeof sources.ompExtensionSource === 'string') { this.piExtensionSources.omp = withOrcaManagedPiExtensionMarker(sources.ompExtensionSource) } } hasOpenCodeSource(): boolean { return this.opencodePluginSource !== null } hasPiSource(kind?: PiAgentKind): boolean { if (kind) { return this.getPiExtensionSource(kind) !== null } return this.piExtensionSources.pi !== null || this.piExtensionSources.omp !== null } private getPiExtensionSource(kind: PiAgentKind): string | null { return this.piExtensionSources[kind] ?? this.piExtensionSources.pi } private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) if (entry.name === 'plugins') { const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false if (isSymlink) { try { isLinkPointingToDir = statSync(sourcePath).isDirectory() } catch { isLinkPointingToDir = false } } if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath const overlayPluginsDir = join(overlayDir, 'plugins') mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { if (pluginEntry.name === OPENCODE_PLUGIN_FILE) { continue } mirrorEntry( join(resolvedSource, pluginEntry.name), join(overlayPluginsDir, pluginEntry.name) ) } continue } } mirrorEntry(sourcePath, join(overlayDir, entry.name)) } } private writeOpenCodePlugin(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE) try { unlinkSync(pluginPath) } catch { // Fresh overlay or no same-named stale symlink. } writeFileSync(pluginPath, this.opencodePluginSource!) } /** Materialize the OpenCode plugin overlay for `id` (typically the * renderer-supplied paneKey or, fallback, the relay-internal pty-id) and * return the directory path. Returns null when no source is cached or * the overlay write fails — caller falls back to no plugin (the agent * CLI runs without status reporting), which is the existing fail-open * behavior on the local side. */ materializeOpenCode(id: string, existingConfigDir?: string): string | null { if (!this.opencodePluginSource || !isUsableId(id)) { return null } const dir = join(this.opencodeRoot, safeDirName(id)) try { safeRemoveOverlay(dir, this.opencodeRoot) mkdirSync(dir, { recursive: true }) if (existingConfigDir) { if (!existsSync(existingConfigDir)) { return null } // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. this.mirrorOpenCodeConfig(existingConfigDir, dir) } this.writeOpenCodePlugin(dir) return dir } catch (err) { process.stderr.write( `[plugin-overlay] failed to materialize OpenCode overlay: ${err instanceof Error ? err.message : String(err)}\n` ) return null } } private getDefaultPiAgentDir(kind: PiAgentKind): string { return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } private canOverwritePiExtension(path: string): boolean { try { return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER) } catch { return true } } private writeOmpManagedStatusExtension(extensionSource: string): string | null { const fallbackDir = join(this.homeDir, RELAY_HOOKS_DIR, OMP_MANAGED_STATUS_EXTENSION_DIR) try { mkdirSync(fallbackDir, { recursive: true }) const fallbackPath = join(fallbackDir, PI_EXTENSION_FILE) if (!this.canOverwritePiExtension(fallbackPath)) { return null } writeFileSync(fallbackPath, extensionSource) return fallbackPath } catch (err) { process.stderr.write( `[plugin-overlay] failed to write OMP managed status extension: ${err instanceof Error ? err.message : String(err)}\n` ) return null } } /** Install the Pi/OMP status extension into the remote real agent dir. * `kind` selects which Pi-compatible agent's default dir to use when * `existingAgentDir` is not supplied. * * When `materializeDefaultHome` is false (bare shells), missing default * homes are left alone so unused agents do not recreate `~/.` (#10196). * For OMP, a relay-owned status file is still written so bare shells can * export ORCA_OMP_STATUS_EXTENSION without ORCA_OMP_SOURCE_AGENT_DIR. */ materializePi( id: string, existingAgentDir?: string, kind: PiAgentKind = 'pi', options?: { materializeDefaultHome?: boolean } ): MaterializePiResult | null { const extensionSource = this.getPiExtensionSource(kind) if (!extensionSource || !isUsableId(id)) { return null } try { const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir(kind) if (existingAgentDir && !existsSync(existingAgentDir)) { return null } const materializeDefaultHome = options?.materializeDefaultHome !== false if (!existingAgentDir && !existsSync(sourceAgentDir) && !materializeDefaultHome) { // Why: match local titlebar-extension-service bare-shell OMP policy — // status wrapper only, never mkdir ~/.omp for unused agents. if (kind === 'omp') { const statusExtensionPath = this.writeOmpManagedStatusExtension(extensionSource) return statusExtensionPath ? { statusExtensionPath } : null } return null } const extensionsDir = join(sourceAgentDir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) const extensionPath = join(extensionsDir, PI_EXTENSION_FILE) if (!this.canOverwritePiExtension(extensionPath)) { return null } writeFileSync(extensionPath, extensionSource) return { sourceAgentDir, statusExtensionPath: extensionPath } } catch (err) { process.stderr.write( `[plugin-overlay] failed to install ${kind} extension: ${err instanceof Error ? err.message : String(err)}\n` ) return null } } /** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a * recursive tree may fail on exotic filesystems but the worst-case * outcome is unbounded growth on a long-lived relay, which the per-pane * caches alone do not bound. */ clearOverlay(id: string): void { if (!isUsableId(id)) { return } const safe = safeDirName(id) // Why: sweep all overlay roots (OpenCode + each Pi-kind) because PTY exit // doesn't know which kind materialized this id. Per-root scoping inside // safeRemoveOverlay keeps each call bounded to its own tree. for (const root of [this.opencodeRoot, ...Object.values(this.piRoots)]) { try { safeRemoveOverlay(join(root, safe), root) } catch (err) { // Why: log the failed cleanup so a permission/IO error is observable. // The leak is the failure mode the per-pane cache eviction exists to // prevent - silent swallows would let it accumulate invisibly on // long-running relays. process.stderr.write( `[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n` ) } } } }