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