Files
orca/src/main/opencode/hook-service.ts
T
Brennan Benson c9105ba4ba reland(opencode): session continuity without the command-finished deferral (STA-4557)
Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which
is what caused the revert.

## Why the original runtime change was wrong

`retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority
retirement behind an async foreground read, on the premise that OpenCode emits
`command-finished` while still in the foreground. Raw PTY capture disproves it:
OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one
`133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being
deferred past only ever fires at exit, which is exactly when authority should be
retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`.

## Why the deferral was unsafe

`confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but
its early return means "don't record an exit" — conservative. The deferral copied
that shape into a site where the early return means "don't revoke a secret". Same
code, inverted consequence: every guard failed open, so a stale or racing read
silently kept a finished session's authority alive, and the pane's persisted
`launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority
after an app restart.

## Why the deferral's guards could not have worked

`ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started
in that shell inherits it — both sessions in a reused pane post the same token. A
pane-lifetime bearer secret cannot be a session identity baseline, by
construction, and `incarnationId` tracks the PTY, not the agent. The only field
that separates sessions is the provider `sessionID`.

## What lands

- Status/session-boundary work from #14866: opencode emits `SessionStart` for
  root sessions (mimo-code does not), launch-token fencing, and `SessionStart`
  as an opencode turn boundary.
- The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a
  tokened `SessionStart`, and restore mimo-code's explicit-prompt restart
  boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes).
  #14941's re-poll hunk is dropped along with the code it patched.
- Five regression tests in `opencode-finished-session-authority.test.ts`. They
  pass here and all five go red if the deferral is re-added.
2026-08-19 16:17:53 -07:00

1246 lines
55 KiB
TypeScript

/* 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 `<task id=…>` 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<string, string> {
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<OpenCodeOverlayManifest>
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 <overlay>/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
}