// Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and // forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook` // JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a // reattaching Orca only needs each pane's current status, never its history, and the bound keeps a // long-lived relay from growing with every event. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, getEndpointFileName, HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, readRequestBody, resolveCachedClaudeCompactOwnership, resolveHookSource, writeEndpointFile, type AgentHookEventPayload, type HookListenerState } from '../shared/agent-hook-listener' import { createHookTransportInterferenceTracker, describeHookTransportInterference, isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' import { REMOTE_AGENT_HOOK_ENV, type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void // Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 export type RelayHookServerOptions = { /** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */ endpointDir?: string /** Env tag forwarded into hook payloads. Defaults to "remote", which main excludes from dev-vs-prod mismatch warnings. */ env?: string /** Fixed auth token. WSL relay passes the host-issued token (already in guest env via WSLENV) so unmodified hook clients authenticate. Defaults to a fresh UUID. */ token?: string /** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */ preferredPort?: number /** Called once per parsed payload; the relay wires this to `dispatcher.notify('agent.hook', envelope)`. */ forward: RelayHookForward } export type RelayHookServerStartOptions = { publishEndpoint?: boolean } export class RelayAgentHookServer { private server: ReturnType | null = null private port = 0 private token = '' private env: string private endpointDir: string private endpointFilePath: string private endpointFileWritten = false private state: HookListenerState = createHookListenerState() private transportInterference = createHookTransportInterferenceTracker((report) => { process.stderr.write(`${describeHookTransportInterference(report)}\n`) }) // Why: retain envelope metadata so replays match live POSTs. // Invariant: keys mirror state.lastStatusByPaneKey, populated/cleared in lockstep. private lastEnvelopeMetaByPaneKey = new Map< string, { source: AgentHookSource; env?: string; version?: string } >() private forward: RelayHookForward private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false private retryScheduler: AgentHookResultRetryScheduler constructor(options: RelayHookServerOptions) { this.env = options.env ?? REMOTE_AGENT_HOOK_ENV this.endpointDir = options.endpointDir ?? defaultEndpointDir() this.endpointFilePath = join(this.endpointDir, getEndpointFileName()) this.fixedToken = options.token this.preferredPort = options.preferredPort ?? 0 this.forward = options.forward this.retryScheduler = new AgentHookResultRetryScheduler({ state: this.state, env: this.env, isListening: () => this.server !== null, applyEvent: (event, source, env, version) => { this.applyEvent(event, source, env, version) } }) } async start(options: RelayHookServerStartOptions = {}): Promise { if (this.server) { return } this.token = this.fixedToken ?? randomUUID() this.endpointFileWritten = false this.portFallbackApplied = false try { await this.listenOn(this.preferredPort) } catch (err) { // Why: fall back to an ephemeral port on EADDRINUSE; clients use the endpoint file. if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') { this.portFallbackApplied = true await this.listenOn(0) } else { throw err } } if (options.publishEndpoint !== false) { this.publishEndpointFile() } } /** True when the preferred port was occupied and the server fell back to an ephemeral bind. */ get usedPortFallback(): boolean { return this.portFallbackApplied } private listenOn(port: number): Promise { this.server = createServer((req, res) => this.handleRequest(req, res)) return new Promise((resolve, reject) => { const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) // Why: clear failed server refs so later start() calls can retry. this.server = null reject(err) } const onListening = (): void => { this.server?.off('error', onStartupError) this.server?.on('error', (err) => { process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) }) const address = this.server!.address() if (address && typeof address === 'object') { this.port = address.port } resolve() } this.server!.once('error', onStartupError) // Why: loopback only — reachable by the in-box agent CLI (127.0.0.1), not from outside the box. this.server!.listen(port, '127.0.0.1', onListening) }) } publishEndpointFile(): boolean { if (this.port <= 0 || !this.token) { this.endpointFileWritten = false return false } this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, { port: this.port, token: this.token, env: this.env, version: ORCA_HOOK_PROTOCOL_VERSION }) return this.endpointFileWritten } stop(): void { this.server?.close() this.server = null this.port = 0 this.token = '' this.endpointFileWritten = false this.retryScheduler.clearAll() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() } /** Request-driven replay: re-forwards each cached paneKey payload as a fresh notification. Forwards are * issued before the request handler returns, so the response trails all replayed notifications. */ replayCachedPayloadsForPanes(): number { let count = 0 for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) { const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) // Why: invariant — status-cache keys always have meta; if it drifted, skip rather than guess a source that mis-tags downstream. if (!meta) { continue } this.forward( buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true }) ) count++ } return count } /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.retryScheduler.clearAssistantMessageRetry(paneKey) this.retryScheduler.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.lastEnvelopeMetaByPaneKey.delete(paneKey) } /** Env vars to inject into relay-spawned PTYs so the hook script/plugin POSTs back to this loopback server. */ buildPtyEnv(): Record { return buildRelayHookPtyEnv({ port: this.port, token: this.token, env: this.env, endpointFilePath: this.endpointFilePath, endpointFileWritten: this.endpointFileWritten }) } /** Test-only / diagnostics accessor. */ getCoordinates(): { port: number; token: string; endpointFilePath: string } { return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath } } // ─── Private ────────────────────────────────────────────────────── private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { if (req.method !== 'POST') { res.writeHead(404) res.end() return } if (req.headers['x-orca-agent-hook-token'] !== this.token) { res.writeHead(403) res.end() return } // Why: track our own destroy so the slowloris cap can't be misread as outside interference. let destroyedBySlowlorisCap = false req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { destroyedBySlowlorisCap = true req.destroy() }) try { const body = await readRequestBody(req) const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname const source = resolveHookSource(pathname) if (!source) { res.writeHead(404) res.end() return } const event = normalizeHookPayload(this.state, source, body, this.env, { allowUnanchoredPreCompact: true, allowUnanchoredPostCompact: true }) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. const env = hookBodyEnv(body) const version = hookBodyVersion(body) this.applyEvent(event, source, env, version) this.retryScheduler.scheduleAssistantMessageRetry(source, body, event, env, version) this.retryScheduler.scheduleCodexSubagentPoll(source, body, event, env, version) } res.writeHead(204) res.end() } catch (err) { // Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH // relay reports the cause instead of an anonymous "hook request failed". if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) { this.transportInterference.record({ source: null, error: err }) } // Why: hooks fail open (204 on any error) so a buggy agent never blocks the run; still log so the 204 doesn't mask bugs. process.stderr.write( `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n` ) res.writeHead(204) res.end() } } private applyEvent( event: AgentHookEventPayload, source: AgentHookSource, env?: string, version?: string ): void { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) } const previous = this.state.lastStatusByPaneKey.get(event.paneKey) const cachedEvent = resolveCachedClaudeCompactOwnership(previous, event) // Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane. this.state.lastStatusByPaneKey.delete(event.paneKey) this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent) this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) { const oldest = this.state.lastStatusByPaneKey.keys().next().value if (oldest === undefined) { break } this.clearPaneState(oldest) } this.forward(buildRelayHookEnvelope(event, source, env, version)) } }