mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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.
1246 lines
55 KiB
TypeScript
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
|
|
}
|