/* eslint-disable max-lines -- Why: this file contains a multi-line inline JS plugin source emitted into OpenCode's plugins directory as a single file; splitting the plugin source across TS modules would obscure the runtime artifact and scatter tightly coupled string-template logic. */ import { app } from 'electron' import { join } from 'path' import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'fs' import { createHash } from 'crypto' import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' const OPENCODE_SHARED_CONFIG_DIR = 'shared' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' type OpenCodeOverlayManifest = { topLevelEntries: string[] pluginEntries: string[] } // Why: the id passed in by pty.ts's daemon path is a sessionId shaped like // "@@" where worktreeId itself contains "::" and a // filesystem path (slashes, colons). Earlier the id was a simple numeric // counter, so rejecting anything with "/" or ":" was a safe guard against // path traversal. After the daemon-parity refactor (#1148) the sessionId // shape changed, and the old regex silently rejected every legitimate id, // leaving OPENCODE_CONFIG_DIR unset and the plugin never loading. // // Keep an input-bounds guard (non-empty, bounded length) for defense in // depth, and derive the on-disk directory name via hash so any caller's id — // including ones containing path separators — produces a short, stable, // filesystem-safe name. Hashing also eliminates path-traversal risk at the // source: the directory name is always 32 hex chars, never a prefix/suffix // of the caller's input. // Why: 1024 is a generous sanity cap — daemon-shaped ids embed a worktree // filesystem path plus "@@", and this bound prevents pathological inputs // from burning CPU in the SHA-256 step. Since the id is hashed anyway, 1024 // is decoupled from PATH_MAX. function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } function toSafeDirName(id: string): string { // Why: SHA-256 truncated to 32 hex chars (128 bits) is ample for a // per-session directory name — collisions require ~2^64 concurrent sessions // to become likely, far beyond any real workload. Hex keeps the name // portable across all filesystems (no base64 padding, no `/`). return createHash('sha256').update(id).digest('hex').slice(0, 32) } function getOpenCodePluginSource(): string { // Why: the plugin runs inside the OpenCode Node process and POSTs to the // unified agent-hooks server shared with Claude/Codex/Gemini. It reads the // same ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID / ORCA_AGENT_HOOK_* // env vars that Orca injects into every PTY, so OpenCode panes flow into // agentStatusByPaneKey via the same IPC path as every other agent. Event // mapping is done plugin-side (SessionBusy / SessionIdle / PermissionRequest) // so the server-side normalizer can keep its one-event-per-case switch shape. return [ '// Why: process-lifetime guard so a recurring parse error on a malformed', "// endpoint file does not spam OpenCode's stderr once per hook post.", '// This guard lives inside the plugin source because the plugin runs in', "// OpenCode's Node process (not Orca's) and has no access to server.ts's", '// equivalent warnedVersions / warnedEnvs Sets.', 'let warnedBadEndpoint = false;', '', '// Why: message.part.updated can fire many times per second during a', '// streaming assistant reply, and each post() calls resolveHookCoords()', '// which reads the endpoint file. The file only changes on Orca restart', '// (rare), so a stat+mtime check is substantially cheaper than a full', '// readFileSync+parse on every streamed part. On stat error we fall', '// through to parse so the fail-open behavior is preserved.', 'let cachedEndpointKey = "";', 'let cachedEndpointValues = null;', '', 'function readEndpointFile() {', ' const path = process.env.ORCA_AGENT_HOOK_ENDPOINT;', ' if (!path) return null;', ' try {', ' const fs = require("fs");', ' try {', ' const stat = fs.statSync(path);', ' // Why: cache key combines mtime + size + inode. renameSync (used by', ' // writeEndpointFile on the Orca side) allocates a fresh inode on', ' // POSIX and a new Windows file ID on NTFS, so ino changes on every', ' // legitimate rewrite even when mtimeMs resolution is coarse and size', ' // happens to match.', ' const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;', ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', ' return cachedEndpointValues;', ' }', ' const contents = fs.readFileSync(path, "utf8");', ' const out = {};', ' for (const line of contents.split(/\\r?\\n/)) {', ' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env', ' // uses `KEY=VALUE`. Making `set ` optional lets the same parser', ' // handle both without platform detection in the plugin. Allow', ' // digits in the key for forward-compat with future ORCA_AGENT_HOOK_*', ' // names that may contain numerics, and strip a trailing CR so', ' // mixed-EOL files with lone `\\r` do not leak CR into the value.', ' const m = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/);', ' if (m) out[m[1]] = m[2].replace(/\\r$/, "");', ' }', ' cachedEndpointKey = cacheKey;', ' cachedEndpointValues = out;', ' return out;', ' } catch (ioErr) {', ' // Why: any stat or read failure (file yanked mid-read, permission', ' // race, unlink between stat and readFileSync) must invalidate the', ' // cache so a transient failure does not lock in a stale parse for', ' // the remaining process lifetime; rethrow to the outer catch.', ' cachedEndpointKey = "";', ' cachedEndpointValues = null;', ' throw ioErr;', ' }', ' } catch (err) {', ' // Why: warn once per process if the file exists but is unreadable or', ' // malformed — a persistent, silently-swallowed parse error would', ' // otherwise leave the plugin falling back to stale process.env on', ' // every post with no signal. ENOENT / missing env var is the normal', ' // pre-install case; stay silent for it.', ' if (err && err.code !== "ENOENT" && !warnedBadEndpoint) {', ' warnedBadEndpoint = true;', ' console.warn("[orca-hook] failed to parse endpoint file:", err.message);', ' }', ' return null;', ' }', '}', '', 'function resolveHookCoords() {', ' // Why: prefer the on-disk endpoint file over process.env because env was', ' // frozen when OpenCode was fork()ed — stale after an Orca restart. The', ' // file is rewritten on every Orca start(), so sourcing it per post lets', ' // a long-running OpenCode session reach the current server. Falls back', ' // to process.env when the file is absent (first-run / pre-endpoint-file / Orca', ' // never started writing the file).', ' const fileEnv = readEndpointFile() || {};', ' return {', ' port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,', ' token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,', ' env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || "",', ' version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || "",', ' };', '}', '', 'function getStatusType(event) {', ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', '}', '', 'let lastStatus = "idle";', 'const childSessionById = new Map();', '', '// Why: message.part.updated fires for every Part (text, tool, reasoning)', '// but does not include the message role — that lives on the parent', '// message.updated event. Cache the role per messageID so the plugin can', '// tag a TextPart as user vs assistant when POSTing. Capped at 128 entries', '// so long-running sessions do not grow this map unboundedly.', 'const messageRoleById = new Map();', 'function rememberMessageRole(messageID, role) {', ' if (!messageID || !role) return;', ' if (messageRoleById.size >= 128) {', ' const first = messageRoleById.keys().next().value;', ' if (first !== undefined) messageRoleById.delete(first);', ' }', ' messageRoleById.set(messageID, role);', '}', '', '// Why: oh-my-opencode style tools spawn child sessions that emit their', '// own session.idle / message events. Those child completions must not', '// flip the root Orca pane to done or overwrite the parent turn preview.', '// Detect child sessions by checking `parentID` via client.session.list(),', '// cache the result per session, and fail closed (assume child) on lookup errors', '// so a transient SDK failure cannot create false "done" transitions.', 'async function isChildSession(client, sessionID) {', ' if (!sessionID) return true;', ' if (childSessionById.has(sessionID)) return childSessionById.get(sessionID);', ' if (!client?.session?.list) return true;', ' try {', ' const sessions = await client.session.list();', ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', ' const session = list.find((entry) => entry?.id === sessionID);', ' const isChild = !!session?.parentID;', ' if (childSessionById.size >= 128) {', ' const first = childSessionById.keys().next().value;', ' if (first !== undefined) childSessionById.delete(first);', ' }', ' childSessionById.set(sessionID, isChild);', ' return isChild;', ' } catch {', ' return true;', ' }', '}', '', 'async function post(hookEventName, extraProperties) {', ' // Why: resolve coords per post — the endpoint file may have been', ' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs', ' // stay on process.env because they are per-PTY (stable for the life of', ' // the OpenCode process), not per-Orca-instance.', ' const coords = resolveHookCoords();', ' const paneKey = process.env.ORCA_PANE_KEY;', ' if (!coords.port || !coords.token || !paneKey) return;', ' const url = `http://127.0.0.1:${coords.port}/hook/opencode`;', ' const body = JSON.stringify({', ' paneKey,', ' tabId: process.env.ORCA_TAB_ID || "",', ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', ' env: coords.env,', ' version: coords.version,', ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', ' });', ' try {', ' await fetch(url, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', ' "X-Orca-Agent-Hook-Token": coords.token,', ' },', ' body,', ' });', ' } catch {', ' // Why: OpenCode session events must never fail the agent run just', ' // because Orca is unavailable or the local loopback request failed.', ' }', '}', '', 'async function setStatus(next, extraProperties) {', ' // Why: dedupe so a flurry of session.status idle events after a turn', ' // does not spam the dashboard with redundant done transitions.', ' if (lastStatus === next) return;', ' lastStatus = next;', ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', ' await post(hookEventName, extraProperties);', '}', '', '// Why: accept the factory argument as an optional opaque parameter instead', '// of destructuring (`async ({ client }) => …`). OpenCode can invoke the', '// plugin factory with undefined during startup, which makes the', '// destructuring form throw synchronously and crash OpenCode with an opaque', '// UnknownError before any event is ever dispatched.', 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', ' const client = _ctx?.client;', ' return {', ' event: async ({ event }) => {', ' if (!event?.type) return;', '', ' // Why: cache the message role BEFORE the async isChildSession check.', ' // OpenCode fires message.updated (user) and message.part.updated (text)', ' // back-to-back; if we awaited isChildSession first, the part.updated', ' // handler could reach messageRoleById.get(...) while the user message.updated', ' // is still suspended on that await — so the part would see an empty cache', ' // and drop the user prompt. Caching is a cheap Map.set with bounded size,', ' // safe to run even for child sessions (the part POST still filters them).', ' if (event.type === "message.updated") {', ' const info = event.properties && event.properties.info;', ' rememberMessageRole(info && info.id, info && info.role);', ' }', '', ' const sessionID = event.properties?.sessionID;', ' if (sessionID && (await isChildSession(client, sessionID))) {', ' return;', ' }', '', ' if (event.type === "permission.asked") {', ' // Why: permission asks are not a session state transition — emit', ' // without mutating lastStatus so the next SessionBusy/SessionIdle', ' // still fires. The server maps PermissionRequest to `waiting`.', ' await post("PermissionRequest", event.properties || {});', ' return;', ' }', '', ' if (event.type === "question.asked") {', ' // Why: question.asked fires when OpenCode uses an ask-the-user tool', ' // (distinct from permission.asked, which blocks on tool approval).', ' // The agent is idle-but-waiting on a human reply, not running, so we', ' // must flip the pane to the same red "needs attention" state used for', ' // permission requests. Like permission.asked, do not touch lastStatus', ' // so the next SessionBusy/SessionIdle after the user answers still', ' // fires and restores the normal working/done flow.', ' await post("AskUserQuestion", event.properties || {});', ' return;', ' }', '', ' if (event.type === "message.updated") {', ' // Why: role is already cached above the isChildSession await so the', ' // back-to-back message.part.updated for the same messageID is not', ' // racing against this handler. Nothing more to do here — return to', ' // avoid falling through to the part/session handlers below.', ' return;', ' }', '', ' if (event.type === "message.part.updated") {', ' // Why: a TextPart carries the actual user prompt or assistant reply', ' // text. Skip non-text parts (tool, reasoning, file, …) so we only', ' // forward what the dashboard renders. Role came from the earlier', ' // message.updated event; if we never saw one (e.g. plugin loaded', ' // mid-turn) the role is unknown, and mislabeling the part — a user', ' // prompt displayed as the assistant reply, or vice versa — is worse', ' // than silently dropping a single in-flight text chunk. The next', ' // message.updated event will re-seed the role cache, so subsequent', ' // parts in the same session flow normally.', ' const part = event.properties && event.properties.part;', ' if (!part || part.type !== "text" || !part.text) return;', ' const role = messageRoleById.get(part.messageID);', ' if (!role) return;', ' await post("MessagePart", { role, text: part.text, messageID: part.messageID });', ' return;', ' }', '', ' if (event.type === "session.idle" || event.type === "session.error") {', ' await setStatus("idle");', ' return;', ' }', '', ' if (event.type === "session.status") {', ' const statusType = getStatusType(event);', ' if (statusType === "busy" || statusType === "retry") {', ' await setStatus("busy");', ' return;', ' }', ' if (statusType === "idle") {', ' await setStatus("idle");', ' }', ' }', ' },', ' };', '};', '' ].join('\n') } // Why: OpenCode hooks used to run their own loopback HTTP server + IPC // channel (pty:opencode-status). That pathway produced a synthetic terminal // title but never entered agentStatusByPaneKey, so the unified dashboard // never saw OpenCode sessions. The service now only installs the plugin // file into OPENCODE_CONFIG_DIR — the plugin POSTs directly to the shared // agent-hooks server (/hook/opencode), so OpenCode rides the same status // pipeline as Claude/Codex/Gemini. export class OpenCodeHookService { clearPty(_ptyId: string): void { // Why: OpenCode can materialize thousands of plugin runtime files under // OPENCODE_CONFIG_DIR. This teardown runs on Electron's main process hot // path, so recursive deletion here can freeze the whole app on Windows // while Node, antivirus, or indexing still holds file handles. // // Current builds use app/source-scoped config dirs, not PTY-scoped dirs, // so there is no live PTY-owned OpenCode filesystem state to remove. } buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { // Why: defense-in-depth. If the id fails the bounds guard, a user-set // OPENCODE_CONFIG_DIR should still be preserved so OpenCode loads the // user's own config — only the Orca status plugin is forfeited. return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} } if (!existingConfigDir) { // Why: OpenCode may install plugin dependencies under this root. Sharing // it prevents per-terminal node_modules churn and teardown freezes. const configDir = this.writeSharedPluginConfig() if (!configDir) { return {} } return { OPENCODE_CONFIG_DIR: configDir } } // Why: do NOT `mkdir -p` the user's typoed path — overriding it with an // Orca-owned dir is the exact config-replacement failure mode documented in // docs/opencode-config-dir-collision.md. Let OpenCode surface the typo on // its own; we only forfeit our status plugin for this pane. if (!existsSync(existingConfigDir)) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { mkdirSync(overlayDir, { recursive: true }) this.mirrorUserConfig(existingConfigDir, overlayDir) this.writePluginIntoOverlay(overlayDir) } catch { // Why: overlay creation is best-effort. Symlink-creation can fail on // Windows without developer mode (EPERM), userData can be read-only on // locked-down corporate machines, etc. In every case, preserve the // user's OPENCODE_CONFIG_DIR — a missing status plugin is a vastly // smaller harm than silently dropping the user's auth/models/keymap. return { OPENCODE_CONFIG_DIR: existingConfigDir } } return { OPENCODE_CONFIG_DIR: overlayDir } } private getOverlayRoot(): string { return join(app.getPath('userData'), OPENCODE_OVERLAY_DIR) } private getSourceOverlayDir(sourceConfigDir: string): string { return join(this.getOverlayRoot(), toSafeDirName(`source:${sourceConfigDir}`)) } private getSharedConfigDir(): string { return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, OPENCODE_SHARED_CONFIG_DIR) } private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { try { const parsed = JSON.parse( readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') ) as Partial return { topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] } } catch { return { topLevelEntries: [], pluginEntries: [] } } } private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { writeFileSync( join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n` ) } private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { for (const entryName of manifest.topLevelEntries) { safeRemoveTree(join(overlayDir, entryName)) } const overlayPluginsDir = join(overlayDir, 'plugins') for (const entryName of manifest.pluginEntries) { if (entryName === ORCA_OPENCODE_PLUGIN_FILE) { continue } safeRemoveTree(join(overlayPluginsDir, entryName)) } } // Why: walks the user's OPENCODE_CONFIG_DIR top-level entries. The // `plugins/` subdirectory gets created as a real directory in the overlay // so Orca can drop a sibling file alongside the user's plugins; everything // else (opencode.json, auth.json, themes/, etc.) is mirrored as a single // top-level entry via symlink/junction so user edits propagate live on // POSIX (and on Windows-with-developer-mode) without copying files. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { const previousManifest = this.readOverlayManifest(overlayDir) // Why: source-scoped overlays persist across terminals. Only remove paths // Orca previously mirrored, so deleted/replaced user config cannot stay // stale while OpenCode-owned runtime dirs such as node_modules survive. this.clearManifestEntries(overlayDir, previousManifest) const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) if (entry.name === 'plugins') { // Why: check isSymbolicLink BEFORE isDirectory — a Windows junction // can report both as true on a Dirent, and we must take the symlink // branch so the per-entry mirroring (not a single mirrorEntry call // that would create a symlink at /plugins) handles it. const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false if (isSymlink) { try { isLinkPointingToDir = statSync(sourcePath).isDirectory() } catch { // Why: broken symlink (target missing) or permission error — fall // through to the default mirrorEntry path so the dangling link is // mirrored verbatim rather than write-through-resolved. isLinkPointingToDir = false } } if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { // Why: when the user's plugins/ is a symlink-to-dir, resolve to the // real target so readdir returns the actual entries and child paths // join against the resolved root. mirrorEntry then creates symlinks // pointing into the resolved real plugins (not back through the // user's link), and /plugins itself stays a real dir so // writePluginIntoOverlay can never write through to the user's FS. const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath const overlayPluginsDir = join(overlayDir, 'plugins') mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { // Why: skip a user file with the same filename as Orca's plugin — // mirroring it here would either resolve a same-named target via // symlink (writePluginIntoOverlay then clobbers the user's file // through the link) or collide on Windows with the directory entry // about to be created by writePluginIntoOverlay. Either way the // user's plugin would be lost. Skipping yields the desired // semantics: Orca's status plugin runs and the user's same-named // plugin is shadowed for this PTY only — their source file on disk // is untouched. if (pluginEntry.name === ORCA_OPENCODE_PLUGIN_FILE) { continue } mirrorEntry( join(resolvedSource, pluginEntry.name), join(overlayPluginsDir, pluginEntry.name) ) nextManifest.pluginEntries.push(pluginEntry.name) } continue } } mirrorEntry(sourcePath, join(overlayDir, entry.name)) nextManifest.topLevelEntries.push(entry.name) } this.writeOverlayManifest(overlayDir, nextManifest) } // Why: write Orca's status plugin into the overlay's plugins/ dir. The // pre-write unlink is the load-bearing part — POSIX writeFileSync over a // symlink writes through to the link target, so without it a user-owned // plugin with this filename would be clobbered through a mirrored link. // Skipping the same-named user file in mirrorUserConfig already prevents // the link from being created, but the unlink keeps this function safe // even if a stale overlay slips through with the link still in place. private writePluginIntoOverlay(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE) try { unlinkSync(pluginPath) } catch { // No-op: file may not exist on a fresh overlay. Any persistent failure // (e.g. permissions) will surface on the writeFileSync below. } writeFileSync(pluginPath, getOpenCodePluginSource()) } private writeSharedPluginConfig(): string | null { const configDir = this.getSharedConfigDir() const pluginsDir = join(configDir, 'plugins') try { mkdirSync(pluginsDir, { recursive: true }) writeFileSync(join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE), getOpenCodePluginSource()) } catch { // Why: on Windows, userData directories can be locked by antivirus or // indexers (EPERM/EBUSY). Plugin config is non-critical — the PTY should // still spawn without the OpenCode status plugin. return null } return configDir } } export const openCodeHookService = new OpenCodeHookService() export const _internals = { getOpenCodePluginSource, isUsableId, toSafeDirName }