feat(agent-hooks): on-disk endpoint discovery for surviving PTYs (v2) (#1196)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-04-27 21:49:18 -07:00
committed by GitHub
co-authored by Orca
parent 812ca5488b
commit 4279fc8075
10 changed files with 658 additions and 22 deletions
+57
View File
@@ -34,6 +34,63 @@ describe('OpenCode hook plugin source', () => {
expect(source).toContain('export const OrcaOpenCodeStatusPlugin = async (_ctx) => {')
expect(source).toContain('const client = _ctx?.client;')
})
it('resolves hook coords from the endpoint file before falling back to process.env', () => {
// Why: a long-running OpenCode session was fork()ed with the prior Orca's
// PORT/TOKEN frozen into process.env. The plugin must prefer the on-disk
// endpoint file (rewritten on every Orca start()) over env, otherwise it
// keeps posting to a dead port after an Orca restart.
const source = _internals.getOpenCodePluginSource()
expect(source).toContain('function readEndpointFile()')
expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT')
// Parser accepts both `KEY=VALUE` (Unix) and `set KEY=VALUE` (Windows):
expect(source).toContain('/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/')
expect(source).toContain('function resolveHookCoords()')
// File takes precedence over env — the whole point of v2:
expect(source).toContain(
'port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT'
)
expect(source).toContain(
'token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN'
)
// post() uses the resolved coords, not a cached-at-startup url:
expect(source).toContain('const coords = resolveHookCoords();')
expect(source).toContain('`http://127.0.0.1:${coords.port}/hook/opencode`')
expect(source).toContain('"X-Orca-Agent-Hook-Token": coords.token')
})
it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => {
// Why: message.part.updated fires many times per second during a streaming
// assistant reply. Each post() calls resolveHookCoords() which reads the
// endpoint file — without the cache we'd readFileSync + parse on every
// streamed Part. The cache key combines mtime + size + inode so renameSync
// (writeEndpointFile's atomic swap) invalidates the cache via the ino
// change even when mtime resolution is coarse and size happens to match.
const source = _internals.getOpenCodePluginSource()
expect(source).toContain('let cachedEndpointKey = "";')
expect(source).toContain('let cachedEndpointValues = null;')
expect(source).toContain('const stat = fs.statSync(path);')
expect(source).toContain('const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;')
expect(source).toContain('if (cacheKey === cachedEndpointKey && cachedEndpointValues) {')
expect(source).toContain('return cachedEndpointValues;')
// Stat failure must invalidate the cache, not lock in stale values:
expect(source).toContain('cachedEndpointKey = "";')
expect(source).toContain('cachedEndpointValues = null;')
})
it('guards endpoint-file parse warnings with a process-lifetime latch', () => {
// Why: ENOENT is the normal pre-install case and must stay silent, but a
// malformed/unreadable file (EACCES, EIO, parse error) would otherwise
// spam stderr once per hook post. The latch keeps the warning to once per
// OpenCode process — mirrors server.ts's warnedVersions/warnedEnvs intent.
const source = _internals.getOpenCodePluginSource()
expect(source).toContain('let warnedBadEndpoint = false;')
expect(source).toContain('err.code !== "ENOENT"')
expect(source).toContain('warnedBadEndpoint = true;')
})
})
describe('OpenCode id safety guard', () => {
+98 -9
View File
@@ -1,3 +1,7 @@
/* 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 { mkdirSync, writeFileSync, rmSync } from 'fs'
@@ -44,9 +48,90 @@ function getOpenCodePluginSource(): string {
// mapping is done plugin-side (SessionBusy / SessionIdle / PermissionRequest)
// so the server-side normalizer can keep its one-event-per-case switch shape.
return [
'function getHookUrl() {',
' const port = process.env.ORCA_AGENT_HOOK_PORT;',
' return port ? `http://127.0.0.1:${port}/hook/opencode` : null;',
'// 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) {',
@@ -98,16 +183,20 @@ function getOpenCodePluginSource(): string {
'}',
'',
'async function post(hookEventName, extraProperties) {',
' const url = getHookUrl();',
' const token = process.env.ORCA_AGENT_HOOK_TOKEN;',
' // 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 (!url || !token || !paneKey) return;',
' 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: process.env.ORCA_AGENT_HOOK_ENV || "",',
' version: process.env.ORCA_AGENT_HOOK_VERSION || "",',
' env: coords.env,',
' version: coords.version,',
' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },',
' });',
' try {',
@@ -115,7 +204,7 @@ function getOpenCodePluginSource(): string {
' method: "POST",',
' headers: {',
' "Content-Type": "application/json",',
' "X-Orca-Agent-Hook-Token": token,',
' "X-Orca-Agent-Hook-Token": coords.token,',
' },',
' body,',
' });',