/* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ import { app } from 'electron' import { join } from 'node:path' import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { createHash } from 'node: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: session IDs may contain path separators and are hashed downstream; cap pathological input. function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } function toSafeDirName(id: string): string { // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). return createHash('sha256').update(id).digest('hex').slice(0, 32) } export function getOpenCodePluginSource(): string { return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true }) } export function getOpenCodeFamilyPluginSource( hookPathname: string, options: { emitSessionStart: boolean } ): string { // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. 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 hookEndpointKey() {', ' const coords = resolveHookCoords();', ' return [coords.port || "", coords.token || "", coords.env, coords.version].join("\\u0000");', '}', '', 'function getStatusType(event) {', ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', '}', '', 'const HOOK_POST_TIMEOUT_MS = 2000;', 'const SESSION_LOOKUP_TIMEOUT_MS = 2000;', 'const MAX_SESSION_ANCESTRY_DEPTH = 32;', 'const STATUS_RETRY_BASE_MS = 500;', 'const STATUS_RETRY_MAX_MS = 30000;', 'let desiredStatus = "idle";', 'let desiredHookEventName = "SessionIdle";', 'let desiredStatusKey = "idle:";', 'let desiredStatusProperties = {};', 'let desiredFactoryID = null;', 'let deliveredStatusKey = "idle:";', 'let deliveredEndpointKey = "";', 'let statusDeliveryDirty = false;', 'let statusRevision = 0;', 'let statusRetryAttempt = 0;', 'let statusRetryTimer = null;', 'let lifecycleQueue = Promise.resolve();', 'let busyRecoveryQueued = false;', 'let busyRecoveryUsed = false;', 'let busyRecoveryEndpointKey = "";', 'let stateArrivalRevision = 0;', '// Why: OpenCode can create directory-scoped factories and concurrent root', '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', 'let nextFactoryID = 0;', 'const activeFactoryIDs = new Set();', 'const disposingFactoryIDs = new Set();', 'const busyRootOwnerBySessionID = new Map();', '// Why: a matching Idle must retire fail-open Busy even when the SDK client', '// is unavailable, without granting an unrelated unknown Idle authority.', 'const provisionalBusyByKey = new Map();', '// Why: a background child outlives the root turn that spawned it, so the', '// pane must stay Busy on its behalf until its own exact Idle (or its', "// factory's disposal) retires it — otherwise the root Idle completes a task", '// tree that is still working.', 'const busyChildRootByKey = new Map();', 'const pendingAttentionByKey = new Map();', 'const rootSessionById = new Map();', 'const rootSessionLookupById = new Map();', '', '// Why: message.part.updated re-sends the FULL accumulated text of the part', '// after every streamed append, so posting each event forwards O(n^2) bytes', '// per turn through Orca (loopback HTTP -> main JSON parse -> status compare', '// -> IPC -> renderer store update -> React commit). On Windows that flood', '// saturated both event loops and froze the whole UI a few seconds into a', '// streaming reply. The dashboard only needs a bounded preview at a human', '// cadence: cap the text and trailing-edge coalesce assistant parts.', 'const MESSAGE_PART_THROTTLE_MS = 250;', 'const MESSAGE_PART_MAX_CHARS = 4000;', 'let pendingAssistantPart = null;', 'let assistantPartFlushTimer = null;', 'let messagePartPostInFlight = null;', 'let deliveredMessagePartFactoryID = null;', 'let lastAssistantPartPostAt = 0;', '', 'function capMessagePartText(text) {', ' return text.length > MESSAGE_PART_MAX_CHARS ? text.slice(0, MESSAGE_PART_MAX_CHARS) : text;', '}', '', 'async function postMessagePart(properties, factoryID) {', ' while (messagePartPostInFlight) await messagePartPostInFlight;', ' const delivery = post("MessagePart", properties);', ' messagePartPostInFlight = delivery;', ' try {', ' const delivered = await delivery;', ' if (delivered) deliveredMessagePartFactoryID = factoryID;', ' } finally {', ' if (messagePartPostInFlight === delivery) messagePartPostInFlight = null;', ' }', '}', '', 'async function flushPendingAssistantPart(force = false) {', ' if (assistantPartFlushTimer) {', ' clearTimeout(assistantPartFlushTimer);', ' assistantPartFlushTimer = null;', ' }', ' // Why: an idle/waiting transition must wait for every older preview;', ' // keep one post in flight while later snapshots coalesce in memory.', ' while (messagePartPostInFlight) await messagePartPostInFlight;', ' const pending = pendingAssistantPart;', ' pendingAssistantPart = null;', ' if (!pending) return;', ' if (', ' !activeFactoryIDs.has(pending.factoryID) ||', ' disposingFactoryIDs.has(pending.factoryID)', ' ) return;', ' if (!force && pending.authorityRevision !== stateArrivalRevision) return;', ' lastAssistantPartPostAt = Date.now();', ' await postMessagePart({', ' role: pending.role,', ' text: capMessagePartText(pending.text),', ' messageID: pending.messageID,', ' sessionID: pending.sessionID,', ' }, pending.factoryID);', '}', '', 'function queueAssistantPart(part) {', ' // Why: keep only the latest snapshot — each event already contains the', ' // full accumulated text, so intermediate snapshots are pure waste.', ' pendingAssistantPart = part;', ' const sinceLastPost = Date.now() - lastAssistantPartPostAt;', ' if (sinceLastPost >= MESSAGE_PART_THROTTLE_MS) {', ' void flushPendingAssistantPart();', ' return;', ' }', ' if (!assistantPartFlushTimer) {', ' assistantPartFlushTimer = setTimeout(() => {', ' void flushPendingAssistantPart();', ' }, MESSAGE_PART_THROTTLE_MS - sinceLastPost);', ' if (assistantPartFlushTimer.unref) assistantPartFlushTimer.unref();', ' }', '}', '', '// 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.', '// Resolve the full parentID chain so descendant attention can be attributed', '// to the root while child completion and previews remain non-authoritative.', 'async function resolveRootSessionID(client, sessionID) {', ' if (!sessionID) return null;', ' if (rootSessionById.has(sessionID)) return rootSessionById.get(sessionID);', ' if (!client?.session?.get && !client?.session?.list) return null;', ' if (rootSessionLookupById.has(sessionID)) return rootSessionLookupById.get(sessionID);', ' const lookup = lookupRootSessionID(client, sessionID);', ' rootSessionLookupById.set(sessionID, lookup);', ' try {', ' return await lookup;', ' } finally {', ' if (rootSessionLookupById.get(sessionID) === lookup) {', ' rootSessionLookupById.delete(sessionID);', ' }', ' }', '}', '', 'async function isChildSession(client, sessionID) {', ' const rootSessionID = await resolveRootSessionID(client, sessionID);', ' return rootSessionID === null ? null : rootSessionID !== sessionID;', '}', '', 'function rememberSessionRoot(sessionID, rootSessionID) {', ' if (rootSessionById.size >= 128 && !rootSessionById.has(sessionID)) {', ' const first = rootSessionById.keys().next().value;', ' if (first !== undefined) rootSessionById.delete(first);', ' }', ' rootSessionById.set(sessionID, rootSessionID);', '}', '', 'async function lookupRootSessionID(client, sessionID) {', ' const controller = new AbortController();', ' let timeout;', ' const deadline = new Promise((_, reject) => {', ' timeout = setTimeout(() => {', ' controller.abort();', ' reject(new Error("session lookup timed out"));', ' }, SESSION_LOOKUP_TIMEOUT_MS);', ' if (timeout.unref) timeout.unref();', ' });', ' try {', ' const rootSessionID = await Promise.race([', ' walkSessionParents(client, sessionID, controller.signal),', ' deadline,', ' ]);', ' return rootSessionID;', ' } catch {', ' return null;', ' } finally {', ' clearTimeout(timeout);', ' }', '}', '', 'async function walkSessionParents(client, sessionID, signal) {', ' const lineage = [];', ' let currentSessionID = sessionID;', ' // Why: malformed or unexpectedly deep ancestry must not monopolize the', ' // lifecycle FIFO even when every individual SDK lookup succeeds.', ' while (lineage.length < MAX_SESSION_ANCESTRY_DEPTH) {', ' const cachedRoot = rootSessionById.get(currentSessionID);', ' if (cachedRoot) {', ' for (const id of lineage) rememberSessionRoot(id, cachedRoot);', ' return cachedRoot;', ' }', ' if (lineage.includes(currentSessionID)) return null;', ' lineage.push(currentSessionID);', ' const sessions = await lookupSessionList(client, currentSessionID, signal);', ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', ' const session = list.find((entry) => entry?.id === currentSessionID);', ' if (!session) return null;', ' if (!session.parentID) {', ' for (const id of lineage) rememberSessionRoot(id, currentSessionID);', ' return currentSessionID;', ' }', ' currentSessionID = session.parentID;', ' }', ' return null;', '}', '', 'async function lookupSessionList(client, sessionID, signal) {', ' // Why: point lookup avoids the SDK list page dropping older children;', ' // current SDKs put AbortSignal in a second options argument, while legacy', ' // generated clients accept one request-options object.', ' if (client?.session?.get) {', ' const calls = client.session.get.length >= 2', ' ? [', ' [{ sessionID }, { signal }],', ' [{ path: { id: sessionID }, signal }],', ' ]', ' : [[{ path: { id: sessionID }, signal }]];', ' for (const args of calls) {', ' try {', ' const result = await client.session.get(...args);', ' if (result?.data?.id === sessionID) return { data: [result.data] };', ' } catch {', ' if (signal.aborted) throw new Error("session lookup aborted");', ' // Try the other supported SDK generation, then list fallback.', ' }', ' }', ' }', ' if (!client?.session?.list) return { data: [] };', ' if (client.session.list.length >= 2) {', ' return client.session.list({}, { signal });', ' }', ' return client.session.list({ signal });', '}', '', '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 false;', ` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`, ' const body = JSON.stringify({', ' paneKey,', ' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",', ' tabId: process.env.ORCA_TAB_ID || "",', ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', ' env: coords.env,', ' version: coords.version,', ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', ' });', ' const controller = new AbortController();', ' const timeout = setTimeout(() => controller.abort(), HOOK_POST_TIMEOUT_MS);', ' if (timeout.unref) timeout.unref();', ' try {', ' const response = await fetch(url, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', ' "X-Orca-Agent-Hook-Token": coords.token,', ' },', ' body,', ' signal: controller.signal,', ' });', ' return response.ok;', ' } catch {', ' // Why: OpenCode session events must never fail the agent run just', ' // because Orca is unavailable or the local loopback request failed.', ' return false;', ' } finally {', ' clearTimeout(timeout);', ' }', '}', '', 'function enqueueLifecycle(task) {', ' // Why: OpenCode intentionally fire-and-forgets hook promises, so async', ' // lookups and posts need their own FIFO to preserve event order.', ' const run = lifecycleQueue.then(async () => {', ' try {', ' await task();', ' } catch {', ' // Hook delivery must never reject into OpenCode.', ' }', ' });', ' lifecycleQueue = run;', ' return run;', '}', '', 'function clearStatusRetry() {', ' if (statusRetryTimer) clearTimeout(statusRetryTimer);', ' statusRetryTimer = null;', '}', '', 'function scheduleStatusRetry(revision) {', ' if (', ' statusRetryTimer ||', ' revision !== statusRevision ||', ' !statusDeliveryDirty ||', ' !activeFactoryIDs.has(desiredFactoryID)', ' ) return;', ' const delay = Math.min(', ' STATUS_RETRY_BASE_MS * Math.pow(2, Math.min(statusRetryAttempt, 6)),', ' STATUS_RETRY_MAX_MS', ' );', ' statusRetryAttempt = Math.min(statusRetryAttempt + 1, 7);', ' statusRetryTimer = setTimeout(() => {', ' statusRetryTimer = null;', ' void enqueueLifecycle(async () => {', ' if (', ' revision !== statusRevision ||', ' !statusDeliveryDirty ||', ' !activeFactoryIDs.has(desiredFactoryID)', ' ) return;', ' await publishDesiredStatus(revision);', ' });', ' }, delay);', ' if (statusRetryTimer.unref) statusRetryTimer.unref();', '}', '', 'async function publishDesiredStatus(revision) {', ' if (revision !== statusRevision) return;', ' if (!activeFactoryIDs.has(desiredFactoryID)) return;', ' const endpointKey = hookEndpointKey();', ' if (', ' !statusDeliveryDirty &&', ' deliveredStatusKey === desiredStatusKey &&', ' deliveredEndpointKey === endpointKey &&', ' deliveredMessagePartFactoryID === null', ' ) return;', ' const delivered = await post(desiredHookEventName, desiredStatusProperties);', ' if (revision !== statusRevision) return;', ' if (!delivered) {', ' statusDeliveryDirty = true;', ' scheduleStatusRetry(revision);', ' return;', ' }', ' clearStatusRetry();', ' statusRetryAttempt = 0;', ' deliveredStatusKey = desiredStatusKey;', ' deliveredEndpointKey = endpointKey;', ' deliveredMessagePartFactoryID = null;', ' statusDeliveryDirty = false;', '}', '', 'async function setDeliveryTarget(', ' next,', ' nextKey,', ' hookEventName,', ' extraProperties,', ' factoryID', ') {', ' const endpointChanged = deliveredEndpointKey !== hookEndpointKey();', ' if (', ' nextKey === desiredStatusKey &&', ' nextKey === deliveredStatusKey &&', ' desiredFactoryID === factoryID &&', ' !statusDeliveryDirty &&', ' !endpointChanged &&', ' deliveredMessagePartFactoryID === null', ' ) return;', ' const targetChanged = nextKey !== desiredStatusKey || desiredFactoryID !== factoryID;', ' clearStatusRetry();', ' if (targetChanged) {', ' statusRetryAttempt = 0;', ' busyRecoveryUsed = false;', ' busyRecoveryEndpointKey = "";', ' }', ' desiredStatus = next;', ' desiredHookEventName = hookEventName;', ' desiredStatusKey = nextKey;', ' desiredStatusProperties = extraProperties || {};', ' desiredFactoryID = factoryID;', ' statusDeliveryDirty =', ' statusDeliveryDirty ||', ' deliveredMessagePartFactoryID !== null ||', ' deliveredStatusKey !== nextKey ||', ' endpointChanged;', ' const revision = ++statusRevision;', ' await publishDesiredStatus(revision);', '}', '', 'async function setStatus(next, extraProperties, factoryID) {', ' const nextKey = next + ":" + (extraProperties?.sessionID || "");', ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', ' await setDeliveryTarget(next, nextKey, hookEventName, extraProperties, factoryID);', '}', '', 'async function setAttention(hookEventName, properties, factoryID, sourceSessionID) {', ' const requestID = properties?.id || properties?.sessionID || "";', ' const requestKey = attentionKey(factoryID, hookEventName, requestID, sourceSessionID);', ' await flushPendingAssistantPart(true);', ' await setDeliveryTarget(', ' "waiting",', ' "waiting:" + requestKey,', ' hookEventName,', ' properties,', ' factoryID', ' );', '}', '', 'function recoverBusyFromDelta(client, sessionID, factoryID) {', ' if (busyRecoveryQueued) return lifecycleQueue;', ' if (', ' busyRecoveryUsed &&', ' busyRecoveryEndpointKey === hookEndpointKey()', ' ) return lifecycleQueue;', ' busyRecoveryQueued = true;', ' return enqueueLifecycle(async () => {', ' try {', ' if (!activeFactoryIDs.has(factoryID)) return;', ' if (sessionID && (await isChildSession(client, sessionID)) === true) return;', ' if (!activeFactoryIDs.has(factoryID)) return;', ' const endpointKey = hookEndpointKey();', ' const endpointChanged = deliveredEndpointKey !== endpointKey;', ' if (desiredStatus !== "busy" || (!statusDeliveryDirty && !endpointChanged)) return;', ' if (busyRecoveryUsed && !endpointChanged) return;', ' busyRecoveryUsed = true;', ' busyRecoveryEndpointKey = endpointKey;', ' clearStatusRetry();', ' statusDeliveryDirty = true;', ' const revision = ++statusRevision;', ' await publishDesiredStatus(revision);', ' } finally {', ' busyRecoveryQueued = false;', ' }', ' });', '}', '', 'function currentAttention() {', ' let latestQuestion = null;', ' for (const attention of pendingAttentionByKey.values()) {', ' if (attention.hookEventName === "PermissionRequest") return attention;', ' latestQuestion = attention;', ' }', ' return latestQuestion;', '}', '', 'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {', ' // Why: custom plugins may reuse request IDs across sessions or factories;', ' // JSON tuple identity prevents one owner from hiding another blocker.', ' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);', '}', '', 'function clearAttentionForSession(sessionID, factoryID) {', ' let rootSessionID = null;', ' for (const [key, attention] of pendingAttentionByKey) {', ' if (attention.sourceSessionID === sessionID && attention.factoryID === factoryID) {', ' pendingAttentionByKey.delete(key);', ' rootSessionID = attention.properties?.sessionID || sessionID;', ' }', ' }', ' return rootSessionID;', '}', '', 'function clearQuestionForToolPart(part, sessionID, factoryID) {', ' if (', ' part?.type !== "tool" ||', ' part.tool !== "question" ||', ' (part.state?.status !== "completed" && part.state?.status !== "error")', ' ) return null;', ' let rootSessionID = null;', ' for (const [key, attention] of pendingAttentionByKey) {', ' const tool = attention.properties?.tool;', ' if (', ' attention.hookEventName === "AskUserQuestion" &&', ' attention.sourceSessionID === sessionID &&', ' attention.factoryID === factoryID &&', ' tool?.messageID === part.messageID &&', ' tool?.callID === part.callID', ' ) {', ' pendingAttentionByKey.delete(key);', ' rootSessionID = attention.properties?.sessionID || sessionID;', ' }', ' }', ' return rootSessionID;', '}', '', 'function clearAttentionForResolution(event, sessionID, factoryID) {', ' const hookEventName =', ' event.type === "permission.replied" ? "PermissionRequest" : "AskUserQuestion";', ' const requestID = event.properties?.requestID || "";', ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', ' const attention = pendingAttentionByKey.get(key);', ' if (!attention || attention.sourceSessionID !== sessionID) return null;', ' pendingAttentionByKey.delete(key);', ' return attention.properties?.sessionID || sessionID;', '}', '', 'function provisionalBusyKey(factoryID, sessionID) {', ' return JSON.stringify([factoryID, sessionID || ""]);', '}', '', 'function rememberProvisionalBusy(sessionID, factoryID) {', ' const key = provisionalBusyKey(factoryID, sessionID);', ' // Why: active ownership cannot be LRU-evicted without allowing false', ' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.', ' provisionalBusyByKey.delete(key);', ' provisionalBusyByKey.set(key, { sessionID, factoryID });', '}', '', 'function clearProvisionalBusy(sessionID, factoryID) {', ' return provisionalBusyByKey.delete(provisionalBusyKey(factoryID, sessionID));', '}', '', 'function busyChildKey(factoryID, sessionID) {', ' return JSON.stringify([factoryID, sessionID || ""]);', '}', '', 'function rememberBusyChild(sessionID, rootSessionID, factoryID) {', ' const key = busyChildKey(factoryID, sessionID);', ' // Why: repeated Busy for the same child is not a state change; report one', ' // only when the pane actually gains a running descendant.', ' if (busyChildRootByKey.get(key)?.sessionID === rootSessionID) return false;', ' busyChildRootByKey.delete(key);', ' busyChildRootByKey.set(key, { sessionID: rootSessionID, factoryID });', ' return true;', '}', '', 'function clearBusyChild(sessionID, factoryID) {', ' return busyChildRootByKey.delete(busyChildKey(factoryID, sessionID));', '}', '', 'function clearKnownBusyRoot(sessionID, factoryID) {', ' if (busyRootOwnerBySessionID.get(sessionID) !== factoryID) return false;', ' busyRootOwnerBySessionID.delete(sessionID);', ' return true;', '}', '', 'function latestBusyOwner() {', ' let latest = null;', ' // Why: a running descendant is reported under its ROOT id, and ranks', ' // below a directly busy root so an ongoing turn keeps labelling the pane.', ' for (const busyChild of busyChildRootByKey.values()) {', ' latest = busyChild;', ' }', ' for (const [sessionID, factoryID] of busyRootOwnerBySessionID) {', ' latest = { sessionID, factoryID };', ' }', ' for (const provisional of provisionalBusyByKey.values()) {', ' latest = provisional;', ' }', ' return latest;', '}', '', 'async function publishAggregateStatus(fallbackFactoryID, preferredSessionID) {', ' const attention = currentAttention();', ' if (attention) {', ' await setAttention(', ' attention.hookEventName,', ' attention.properties,', ' attention.factoryID,', ' attention.sourceSessionID', ' );', ' return;', ' }', ' const busyOwner = latestBusyOwner();', ' if (busyOwner) {', ' await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);', ' return;', ' }', ' await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);', '}', '', 'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {', ' stateArrivalRevision += 1;', ' // Why: exact blocker/Busy retirement is authoritative even if ancestry', ' // lookup failed; every older preview must settle before its replacement.', ' await flushPendingAssistantPart(true);', ' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);', '}', '', 'async function handleLifecycleEvent(client, event, factoryID) {', ' const sessionID = event.properties?.sessionID;', ' const statusType = getStatusType(event);', ' const isResolutionEvent =', ' event.type === "permission.replied" ||', ' event.type === "question.replied" ||', ' event.type === "question.rejected";', ' if (isResolutionEvent) {', ' // Why: the stored owner already identifies the root, so replies clear', ' // immediately even when OpenCode session lookup is slow or unavailable.', ' const rootSessionID = clearAttentionForResolution(event, sessionID, factoryID);', ' if (rootSessionID) await publishOwnershipChange(factoryID, rootSessionID);', ' return;', ' }', ' const isAttentionEvent =', ' event.type === "permission.asked" ||', ' event.type === "question.asked";', " // Why: attention without OpenCode's required sessionID cannot be", ' // correlated to a later reply/Idle, so it must not become UI authority.', ' if (isAttentionEvent && !sessionID) return;', ' const canFailOpen =', ' statusType === "busy" || statusType === "retry" || isAttentionEvent;', ' const rootSessionID = sessionID ? await resolveRootSessionID(client, sessionID) : null;', ' const childState = rootSessionID === null ? null : rootSessionID !== sessionID;', ' const isIdleEvent = event.type === "session.idle" || statusType === "idle";', ' const resolvedProvisionalBusy =', ' childState === false || (childState === true && isIdleEvent)', ' ? clearProvisionalBusy(sessionID, factoryID)', ' : false;', ' if (resolvedProvisionalBusy && childState === false) {', ' busyRootOwnerBySessionID.delete(sessionID);', ' busyRootOwnerBySessionID.set(sessionID, factoryID);', ' }', ' // Why: child work rolls up to the pane; ignore its normal lifecycle noise,', ' // but preserve blockers that still require the pane owner to respond.', ' if (childState === true && !isAttentionEvent) {', ' let attentionRootSessionID = null;', ' let childBusyChanged = false;', ' if (isIdleEvent) {', ' attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', ' childBusyChanged = clearBusyChild(sessionID, factoryID);', ' } else if (statusType === "busy" || statusType === "retry") {', ' childBusyChanged = rememberBusyChild(sessionID, rootSessionID, factoryID);', ' }', ' if (resolvedProvisionalBusy || attentionRootSessionID || childBusyChanged) {', ' await publishOwnershipChange(factoryID, attentionRootSessionID || rootSessionID);', ' }', ' return;', ' }', ' if (childState === null && !canFailOpen) {', ' if (isIdleEvent) {', ' // Why: recorded ownership can safely retire a blocker during an SDK', ' // outage without granting unknown child Idle authority over root state.', ' const attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', ' const clearedProvisionalBusy = clearProvisionalBusy(sessionID, factoryID);', ' const clearedKnownBusyRoot = clearKnownBusyRoot(sessionID, factoryID);', ' // Why: exact-session cleanup, so a child whose finishing Idle lands', ' // during an SDK outage cannot pin the pane Busy forever.', ' const clearedBusyChild = clearBusyChild(sessionID, factoryID);', ' if (', ' attentionRootSessionID ||', ' clearedProvisionalBusy ||', ' clearedKnownBusyRoot ||', ' clearedBusyChild', ' ) {', ' await publishOwnershipChange(factoryID, attentionRootSessionID || sessionID);', ' }', ' }', ' return;', ' }', ' if (childState === null && (statusType === "busy" || statusType === "retry")) {', ' // Unknown lineage may be child work, so keep exact provisional ownership', ' // only until matching Idle/disposal; other blockers still take priority.', ' clearAttentionForSession(sessionID, factoryID);', ' rememberProvisionalBusy(sessionID, factoryID);', ' await publishAggregateStatus(factoryID, sessionID);', ' return;', ' }', ' if (event.type === "permission.asked" || event.type === "question.asked") {', ' stateArrivalRevision += 1;', ' // Why: attention must share the lifecycle FIFO and retry target so a', ' // delayed Busy post cannot overwrite a newer human blocker.', ' const hookEventName =', ' event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion";', ' // Why: show the blocker on the root turn while retaining its real child', ' // owner for exact reply, tool-completion, and disposal cleanup.', ' const properties = { ...(event.properties || {}), sessionID: rootSessionID || sessionID };', ' const requestID = properties.id || sessionID || "";', ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', ' // Why: unresolved blockers are live UI authority and cannot be evicted;', ' // reply, exact Idle, tool completion, or factory disposal retires them.', ' pendingAttentionByKey.set(key, {', ' hookEventName,', ' properties,', ' factoryID,', ' sourceSessionID: sessionID,', ' });', ' await publishAggregateStatus(factoryID, rootSessionID || sessionID);', ' return;', ' }', ' if (isIdleEvent) {', ' stateArrivalRevision += 1;', ' const idleKey = "idle:" + (sessionID || "");', ' // Why: current OpenCode emits canonical idle followed by deprecated', ' // session.idle; a failed canonical post should keep its backoff.', ' if (', ' event.type === "session.idle" &&', ' desiredStatusKey === idleKey &&', ' statusDeliveryDirty &&', ' statusRetryTimer', ' ) return;', ' // Why: flush the coalesced final reply snapshot before the idle', ' // transition so the done-state preview shows the completed message.', ' await flushPendingAssistantPart(true);', ' clearAttentionForSession(sessionID, factoryID);', ' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {', ' busyRootOwnerBySessionID.delete(sessionID);', ' }', ' await publishAggregateStatus(factoryID, sessionID);', ' return;', ' }', ' // Why: recoverable compaction failures emit session.error and continue;', ' // canonical session.status is the authority for actual completion.', ' if (event.type === "session.error") return;', ' if (statusType === "busy" || statusType === "retry") {', ' clearAttentionForSession(sessionID, factoryID);', ' busyRootOwnerBySessionID.delete(sessionID);', ' busyRootOwnerBySessionID.set(sessionID, factoryID);', ' await publishAggregateStatus(factoryID, sessionID);', ' }', '}', '', '// 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;', ' const factoryID = ++nextFactoryID;', ' activeFactoryIDs.add(factoryID);', ' let disposed = false;', ' return {', ' event: async ({ event }) => {', ' if (disposed || !event?.type) return;', ' const authorityRevision = stateArrivalRevision;', ' const statusType = getStatusType(event);', '', ' // 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;', ' const updatedPart = event.properties?.part;', ...(options.emitSessionStart ? [ ' if (event.type === "session.created") {', ' const info = event.properties?.info;', ' if (!info?.id || info.parentID) return;', ' rememberSessionRoot(info.id, info.id);', ' await enqueueLifecycle(() =>', ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', ' );', ' return;', ' }', '' ] : []), ' if (', ' event.type === "message.part.updated" &&', ' updatedPart?.type === "tool" &&', ' updatedPart.tool === "question" &&', ' (updatedPart.state?.status === "completed" || updatedPart.state?.status === "error")', ' ) {', ' await enqueueLifecycle(async () => {', ' if (disposed) return;', ' // Why: stored ownership clears child questions without waiting on', ' // ancestry lookup even though ordinary child message parts stay hidden.', ' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);', ' if (!rootSessionID) return;', ' await publishOwnershipChange(factoryID, rootSessionID);', ' });', ' return;', ' }', '', ' if (', ' event.type === "session.status" ||', ' event.type === "session.idle" ||', ' event.type === "session.error" ||', ' event.type === "permission.asked" ||', ' event.type === "question.asked" ||', ' event.type === "permission.replied" ||', ' event.type === "question.replied" ||', ' event.type === "question.rejected"', ' ) {', ' await enqueueLifecycle(() =>', ' disposed ? undefined : handleLifecycleEvent(client, event, factoryID)', ' );', ' return;', ' }', '', ' if (event.type === "message.part.delta") {', ' const properties = event.properties || {};', ' if (', ' properties.field === "text" &&', ' typeof properties.delta === "string" &&', ' properties.delta.length > 0', ' ) {', ' await recoverBusyFromDelta(client, sessionID, factoryID);', ' }', ' return;', ' }', '', ' if (sessionID && (await isChildSession(client, sessionID)) !== false) {', ' return;', ' }', ' if (disposed) return;', ' if (authorityRevision !== stateArrivalRevision) return;', ' if (desiredStatus === "waiting") 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;', ' // Why: OpenCode injects a finished background task back into the', ' // parent turn as a synthetic `` text part. It is machinery,', ' // not what the human typed or the agent replied — OpenCode hides it', ' // from its own prompt too — so it must not replace the pane preview.', ' if (part.synthetic === true) return;', ' const role = messageRoleById.get(part.messageID);', ' if (!role) return;', ' if (role === "user") {', ' // Why: user prompts arrive as a single event, not a stream — post', ' // immediately (still capped) so the throttle slot stays free for', ' // the assistant reply that follows within the same window.', ' await postMessagePart(', ' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },', ' factoryID', ' );', ' return;', ' }', ' queueAssistantPart({', ' role,', ' text: part.text,', ' messageID: part.messageID,', ' sessionID,', ' authorityRevision,', ' factoryID,', ' });', ' return;', ' }', '', ' },', ' dispose: async () => {', ' if (disposed) return;', ' disposed = true;', ' disposingFactoryIDs.add(factoryID);', ' await enqueueLifecycle(async () => {', ' // An older MessagePart must settle before disposal publishes the', ' // replacement state, or its late Working update could win.', ' while (messagePartPostInFlight) await messagePartPostInFlight;', ' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {', ' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);', ' }', ' for (const [key, provisional] of provisionalBusyByKey) {', ' if (provisional.factoryID === factoryID) provisionalBusyByKey.delete(key);', ' }', ' for (const [key, busyChild] of busyChildRootByKey) {', ' if (busyChild.factoryID === factoryID) busyChildRootByKey.delete(key);', ' }', ' for (const [key, attention] of pendingAttentionByKey) {', ' if (attention.factoryID === factoryID) pendingAttentionByKey.delete(key);', ' }', ' if (pendingAssistantPart?.factoryID === factoryID) {', ' if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer);', ' assistantPartFlushTimer = null;', ' pendingAssistantPart = null;', ' }', ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', ' clearStatusRetry();', ' statusRevision += 1;', ' // A MessagePart may have changed the listener to Working after the', ' // same lifecycle key was delivered; force that key to be reasserted.', ' statusDeliveryDirty = ownsDeliveredMessagePart;', ' busyRecoveryUsed = false;', ' busyRecoveryEndpointKey = "";', ' 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",', ' { sessionID: desiredStatusProperties?.sessionID },', ' factoryID', ' );', ' }', ' clearStatusRetry();', ' desiredStatus = "idle";', ' desiredHookEventName = "SessionIdle";', ' desiredStatusKey = "idle:";', ' desiredStatusProperties = {};', ' desiredFactoryID = null;', ' }', ' }', ' activeFactoryIDs.delete(factoryID);', ' disposingFactoryIDs.delete(factoryID);', ' });', ' },', ' };', '};', '', '// Why: OpenCode also resolves plugins through the module default export, and that', '// loader rejects the module unless the default exposes `server()` ("must default', '// export an object with server()"). `setup()` does not satisfy it. Keep the named', '// export so the factory-based loader still finds the same instance.', 'export default {', ' id: "orca-opencode-status",', ' server: OrcaOpenCodeStatusPlugin,', '};', '' ].join('\n') } // Why: installs the plugin into OPENCODE_CONFIG_DIR so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini (the old loopback-IPC path never reached agentStatusByPaneKey). export class OpenCodeHookService { clearPty(_ptyId: string): void { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} } if (!existingConfigDir) { // Why: share one config root so OpenCode's plugin deps don't churn node_modules per terminal. const configDir = this.writeSharedPluginConfig() if (!configDir) { return {} } return { OPENCODE_CONFIG_DIR: configDir } } // Why: don't mkdir the user's (possibly typoed) path — that's the config-replacement failure mode in docs/opencode-config-dir-collision.md; let OpenCode surface it. 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: best-effort — symlink creation needs Windows developer mode (else EPERM) and userData may be read-only; preserve the user's config over dropping their 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: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { const previousManifest = this.readOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (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 reports both, and the symlink branch must win. const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false if (isSymlink) { try { isLinkPointingToDir = statSync(sourcePath).isDirectory() } catch { // Why: broken/inaccessible symlink — mirror the dangling link verbatim instead of resolving through it. isLinkPointingToDir = false } } if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { // Why: resolve a symlinked plugins/ to its real target so /plugins stays a real dir and writePluginIntoOverlay can't write through the user's link. 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 plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. 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: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. 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 { // File may not exist on a fresh overlay; a real failure surfaces on 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: userData can be locked on Windows (EPERM/EBUSY); plugin is non-critical, so spawn without it. return null } return configDir } } export const openCodeHookService = new OpenCodeHookService() export const _internals = { getOpenCodePluginSource, isUsableId, toSafeDirName }