Files
orca/src/main/agent-hooks/server.ts
T
NeilandOrca 9eec0de34d feat(pi): wire pi panes into the unified agent-hooks pipeline
Pi previously fell back to terminal-title heuristics on the dashboard
because it has no settings.json hook surface — its extensibility is the
in-process TypeScript extension API. Add a bundled extension that POSTs
pi lifecycle events to the existing agent-hooks server, modeled on the
OpenCode plugin pattern.

- Add 'pi' source to AgentHookServer with /hook/pi route, normalizePiEvent,
  extractPiToolFields, and isNewTurnEvent('pi', 'before_agent_start')
- Map pi events: before_agent_start | agent_start | tool_call |
  tool_execution_start/_end | message_end -> working;
  agent_end | session_shutdown -> done
- Ship orca-agent-status.ts via PiTitlebarExtensionService's per-PTY overlay
  alongside the existing titlebar-spinner and prefill extensions; the
  extension reads the same ORCA_AGENT_HOOK_* endpoint file the OpenCode
  plugin uses (survives Orca restart for already-spawned PTYs)
- Add pi tool names (bash, read, write, edit, grep, web_search, fetch_content)
  to TOOL_INPUT_KEYS_BY_TOOL so deriveToolInputPreview produces useful row
  previews; glob is shared with Gemini's existing entry
- Add 'pi' to WellKnownAgentType + 'Pi' label
- Tests: 12 new pi normalization tests in agent-hooks/server.test.ts

Co-authored-by: Orca <help@stably.ai>
2026-05-06 01:50:57 -07:00

1549 lines
60 KiB
TypeScript

/* eslint-disable max-lines -- Why: the hook server owns the full HTTP ingest surface (routing, body parsing, per-CLI normalization, transcript scan, pane dispatch) in one place so the contract with Claude/Codex/Gemini hooks stays consistent and doesn't drift across files. */
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
import { randomUUID } from 'crypto'
import {
chmodSync,
closeSync,
mkdirSync,
openSync,
readdirSync,
readSync,
renameSync,
statSync,
unlinkSync,
writeFileSync
} from 'fs'
import { join } from 'path'
import {
parseAgentStatusPayload,
type ParsedAgentStatusPayload
} from '../../shared/agent-status-types'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
// Why: Pi rides this server via a bundled extension (see
// pi/agent-status-extension-source) that fetch()es /hook/pi from inside
// the pi Node process. Like OpenCode, pi has no settings.json hook
// surface — its extensibility is the in-process TypeScript extension API
// (pi.on('agent_start'), 'tool_call', etc.), so the extension pre-maps
// pi's event names to the same hook_event_name vocabulary used here
// before POSTing. See normalizePiEvent below for the mapping.
//
// OpenCode rides this server via a bundled plugin (see opencode/hook-service)
// that fetch()es /hook/opencode from inside the OpenCode process. Unlike
// Claude/Codex/Gemini, OpenCode's event names are in-process plugin events
// (session.status, session.idle, permission.asked) rather than settings.json
// hook names, so the plugin pre-maps them to our hook_event_name vocabulary
// before POSTing. See normalizeOpenCodeEvent below for the mapping.
//
// Cursor (cursor-agent) exposes a declarative hooks.json surface that is
// conceptually similar to Claude's settings.json hooks but uses camelCase
// event names (beforeSubmitPrompt, preToolUse, postToolUse, stop, etc.) per
// https://cursor.com/docs/hooks. See normalizeCursorEvent below.
type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi'
type AgentHookEventPayload = {
paneKey: string
tabId?: string
worktreeId?: string
payload: ParsedAgentStatusPayload
}
// Why: only log a given version/env mismatch once per process so a stale hook
// script that fires on every keystroke doesn't flood the logs.
const warnedVersions = new Set<string>()
const warnedEnvs = new Set<string>()
// Why: cap the warning Sets so a buggy or malicious local client that varies
// its `version`/`env` fields per request cannot grow these Sets without bound
// for the process lifetime. Once saturated, we drop further warnings (and skip
// inserting the new key) — the diagnostic value of "warn once" is preserved
// for the common case while memory stays bounded against untrusted input.
const MAX_WARNED_KEYS = 32
// Why: hook events can arrive while Orca is windowless (common on macOS when
// the user closes the window but leaves the app running). Retain the latest
// normalized status per pane so reopening the window can replay current agent
// state instead of showing nothing until the next hook event happens.
const lastStatusByPaneKey = new Map<string, AgentHookEventPayload>()
// Why: Claude documents `prompt` on UserPromptSubmit; other agents may use
// different field names. Probe a small allowlist so we can surface the real
// user prompt in the dashboard regardless of which agent is reporting.
function extractPromptText(hookPayload: Record<string, unknown>): string {
const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message']
for (const key of candidateKeys) {
const value = hookPayload[key]
if (typeof value === 'string' && value.trim().length > 0) {
return value
}
}
// Why: OpenCode's plugin sends MessagePart events with { role, text }. When
// role === 'user', the text *is* the prompt — surface it so the dashboard
// shows the user's most recent input even though OpenCode has no dedicated
// UserPromptSubmit event we can hook into.
if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') {
const trimmed = hookPayload.text.trim()
if (trimmed.length > 0) {
return hookPayload.text
}
}
return ''
}
function parseFormEncodedBody(body: string): Record<string, string> {
const params = new URLSearchParams(body)
const parsed: Record<string, string> = {}
for (const [key, value] of params.entries()) {
parsed[key] = value
}
return parsed
}
function readRequestBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let byteLength = 0
let settled = false
req.on('data', (chunk: Buffer) => {
if (settled) {
return
}
// Why: check size in bytes (not UTF-16 code units) and stop accumulating
// after rejection so a malicious client cannot push memory past the
// advertised 1 MB cap.
if (byteLength + chunk.length > 1_000_000) {
settled = true
reject(new Error('payload too large'))
req.destroy()
return
}
byteLength += chunk.length
chunks.push(chunk)
})
req.on('end', () => {
if (settled) {
return
}
settled = true
try {
// Why: decode once via Buffer.concat so multi-byte UTF-8 characters that
// straddle a chunk boundary are reassembled correctly. Per-chunk
// `.toString('utf8')` would corrupt emoji or non-ASCII inside assistant
// messages.
const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : ''
const contentType = req.headers['content-type'] ?? ''
if (typeof contentType === 'string' && contentType.includes('application/json')) {
resolve(body ? JSON.parse(body) : {})
return
}
if (
typeof contentType === 'string' &&
contentType.includes('application/x-www-form-urlencoded')
) {
resolve(parseFormEncodedBody(body))
return
}
// Why: existing managed scripts POST JSON and the updated Unix scripts
// POST form-encoded data. Default to JSON for unknown/missing content
// types so legacy callers that omit the header still behave as before.
resolve(body ? JSON.parse(body) : {})
} catch (error) {
reject(error)
}
})
req.on('error', (err) => {
if (settled) {
return
}
settled = true
reject(err)
})
// Why: req.destroy() (called by the slowloris setTimeout in the route
// handler) emits 'close'/'aborted' but not 'end' or 'error'. Without this
// handler the promise would never settle and the chunk buffers would be
// retained for the process lifetime, letting a slow client that holds a
// valid token accumulate pending closures indefinitely.
req.on('close', () => {
if (settled) {
return
}
settled = true
reject(new Error('aborted'))
})
})
}
// Why: only UserPromptSubmit carries the user's prompt. Subsequent events in
// the same turn (PostToolUse, PermissionRequest, Stop, …) arrive with no
// prompt, so we cache the last prompt per pane and reuse it until a new
// prompt arrives. The cache survives across `done` so the user can still see
// what finished; it's reset on the next UserPromptSubmit.
const lastPromptByPaneKey = new Map<string, string>()
function resolvePrompt(
paneKey: string,
promptText: string,
options?: { resetOnNewTurn?: boolean }
): string {
if (options?.resetOnNewTurn) {
// Why: some turn-boundary events (e.g. Codex SessionStart, OpenCode
// SessionBusy) do not carry the new prompt yet. Clearing here prevents the
// previous turn's prompt from leaking into the new working state until a
// later prompt-bearing event arrives.
lastPromptByPaneKey.delete(paneKey)
}
if (promptText) {
lastPromptByPaneKey.set(paneKey, promptText)
return promptText
}
return lastPromptByPaneKey.get(paneKey) ?? ''
}
type ToolSnapshot = {
toolName?: string
toolInput?: string
lastAssistantMessage?: string
}
// Why: mirrors `lastPromptByPaneKey`. Tool + assistant metadata arrives
// piecemeal (PreToolUse gives name+input; PostToolUse gives response;
// Stop gives the final message), and later events typically omit fields
// the earlier ones provided. Caching per-pane lets the renderer show a
// coherent snapshot instead of blinking whenever a field is missing.
const lastToolByPaneKey = new Map<string, ToolSnapshot>()
function resolveToolState(
paneKey: string,
update: ToolSnapshot,
options: { resetOnNewTurn: boolean }
): ToolSnapshot {
if (options.resetOnNewTurn) {
// Why: a fresh user turn shouldn't inherit the previous turn's
// tool/assistant state — it would look like the agent is still on
// the old step until the first new tool event lands.
lastToolByPaneKey.delete(paneKey)
}
const previous = lastToolByPaneKey.get(paneKey) ?? {}
const merged: ToolSnapshot = {
toolName: update.toolName ?? previous.toolName,
toolInput: update.toolInput ?? previous.toolInput,
lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage
}
lastToolByPaneKey.set(paneKey, merged)
return merged
}
// Why: per-tool allowlist (noqa style) — explicit mapping from tool name to
// the single input field worth surfacing. Tools that aren't listed render
// name-only. This avoids noisy fallbacks (e.g. "TaskUpdate 3" from the
// task_id field) and keeps the preview honest: if we don't know how to
// describe a tool's input meaningfully, we show nothing rather than guess.
//
// Ordering matters when a tool sends multiple well-known keys (e.g. Grep
// sends both `pattern` and `path`); the first match wins.
const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
// Claude tools (PascalCase).
Read: ['file_path', 'filePath', 'path'],
Write: ['file_path', 'filePath', 'path'],
Edit: ['file_path', 'filePath', 'path'],
MultiEdit: ['file_path', 'filePath', 'path'],
NotebookEdit: ['file_path', 'filePath', 'path'],
Bash: ['command'],
Glob: ['pattern'],
Grep: ['pattern'],
WebFetch: ['url'],
WebSearch: ['query'],
// Gemini tools (snake_case).
read_file: ['file_path', 'path'],
write_file: ['file_path', 'path'],
read_many_files: ['file_path', 'paths', 'path'],
edit_file: ['file_path', 'path'],
replace: ['file_path', 'path'],
run_shell_command: ['command'],
glob: ['pattern'],
search_file_content: ['pattern'],
web_fetch: ['url'],
google_web_search: ['query'],
// Codex tools. `exec_command` and `shell_command` both carry their command
// text under `cmd` (the Rust payload) or `command` (some wrappers); list
// both so whichever field is populated wins. `apply_patch` surfaces the
// touched path. `view_image` is path-only. `write_stdin` gets nothing
// meaningful — intentionally omitted so the row stays name-only.
exec_command: ['cmd', 'command'],
shell_command: ['cmd', 'command'],
apply_patch: ['path', 'file_path'],
view_image: ['path', 'file_path'],
// Pi tools (lowercase names matching pi's built-in tool registry).
// Why: pi's tool_call event forwards the raw input object; surface the
// canonical preview field per tool so dashboard rows show useful context
// (file path, command, search pattern) without the receiver knowing
// anything pi-specific beyond these key names. `glob` is shared with
// Gemini (same shape: { pattern }), so no separate entry is needed.
bash: ['command'],
read: ['path', 'file_path'],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
grep: ['pattern'],
web_search: ['query'],
fetch_content: ['url']
}
function deriveToolInputPreview(
toolName: string | undefined,
toolInput: unknown
): string | undefined {
if (typeof toolInput === 'string') {
return toolInput
}
if (typeof toolInput !== 'object' || toolInput === null) {
return undefined
}
if (!toolName) {
return undefined
}
const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName]
if (!keys) {
return undefined
}
const record = toolInput as Record<string, unknown>
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value.trim().length > 0) {
return value
}
}
return undefined
}
function readString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
// Why: Claude `tool_response` can be a string, or an object with a `content`
// array shaped like `[{type: 'text', text: '...'}]`. Surface the first text
// block so PostToolUse for Task/Agent subagents carries something useful into
// the `lastAssistantMessage` slot.
function extractToolResponseText(toolResponse: unknown): string | undefined {
if (typeof toolResponse === 'string' && toolResponse.length > 0) {
return toolResponse
}
if (typeof toolResponse !== 'object' || toolResponse === null) {
return undefined
}
const record = toolResponse as Record<string, unknown>
const content = record.content
if (Array.isArray(content)) {
for (const part of content) {
if (typeof part === 'object' && part !== null) {
const text = (part as Record<string, unknown>).text
if (typeof text === 'string' && text.trim().length > 0) {
return text
}
}
}
}
const text = record.text
if (typeof text === 'string' && text.trim().length > 0) {
return text
}
return undefined
}
// Why: Claude's Stop event carries `transcript_path` to a JSONL transcript.
// Reading the last assistant message gives us the "what did the agent just
// say" preview without needing to buffer tool_response text across PostToolUse
// events. We scan backward from the end of the file in chunks, stopping as
// soon as we find an assistant text entry — bounded work in the common case
// (one chunk) even when transcripts grow to hundreds of MB.
const TRANSCRIPT_CHUNK_BYTES = 64 * 1024
// Why: ultimate safety cap so a malformed transcript (or a turn with
// pathologically many tool calls and no assistant text) cannot stall the Stop
// handler. 4 MB easily accommodates dozens of tool rounds before the final
// reply; past that, we give up rather than block the hook response.
const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024
function extractAssistantTextFromLine(line: string): string | undefined {
let entry: unknown
try {
entry = JSON.parse(line)
} catch {
return undefined
}
if (typeof entry !== 'object' || entry === null) {
return undefined
}
const record = entry as Record<string, unknown>
const nestedMessage = record.message as Record<string, unknown> | undefined
const role = record.role ?? nestedMessage?.role
if (role !== 'assistant') {
return undefined
}
const content = (nestedMessage ?? record).content
if (typeof content === 'string' && content.trim().length > 0) {
return content
}
// Why: assistant entries can be pure tool_use turns with no text parts.
// Return undefined so the caller keeps scanning backward for the most
// recent entry that actually contains assistant text.
if (Array.isArray(content)) {
for (const part of content) {
if (typeof part === 'object' && part !== null) {
const text = (part as Record<string, unknown>).text
if (typeof text === 'string' && text.trim().length > 0) {
return text
}
}
}
}
return undefined
}
function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined {
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
}
try {
const stats = statSync(transcriptPath)
const size = stats.size
if (size <= 0) {
return undefined
}
const fd = openSync(transcriptPath, 'r')
try {
// Why: track unhandled leading bytes as a raw Buffer across iterations
// so multi-byte UTF-8 codepoints that straddle chunk boundaries are not
// corrupted. A previous implementation decoded the combined chunk then
// re-encoded `lines[0]` back to UTF-8 for the carry; when a chunk
// started mid-codepoint the decode produced U+FFFD replacement chars
// and the re-encode baked those replacements into the carry bytes
// permanently, mis-joining every subsequent chunk. Splitting on \n at
// the byte level (0x0a) and only decoding complete-line regions keeps
// the carry byte-exact.
let carryBytes: Buffer = Buffer.alloc(0)
let bytesRead = 0
while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES)
const position = size - bytesRead - chunkSize
const buffer = Buffer.alloc(chunkSize)
// Why: readSync may return fewer bytes than requested (short reads).
// Loop until the full window is read (or EOF) before processing so the
// backward scan windows stay aligned — a bare `bytesRead += n` would
// advance from the last read's tail and either re-read overlapping
// bytes on the next iteration or miss lines entirely if short reads
// accumulate. Rare on local regular files but fs quirks exist.
let filled = 0
while (filled < chunkSize) {
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
const n = filled
bytesRead += n
if (n === 0) {
break
}
// Why: the newly-read chunk is earlier in the file than `carryBytes`
// (which came from the *previous* iteration's partial first line),
// so concatenation order is chunk first, carry second.
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
const atStart = bytesRead >= size
// Find the first newline in the raw bytes. Everything before it is
// a (still) potentially-partial line when we haven't reached SOF;
// everything from it onward is a sequence of complete lines we can
// decode safely.
const firstNewline = combined.indexOf(0x0a)
let completeRegion: Buffer
let nextCarry: Buffer
if (atStart) {
// At start-of-file there is no earlier chunk; every line is complete.
completeRegion = combined
nextCarry = Buffer.alloc(0)
} else if (firstNewline === -1) {
// No newline in the combined bytes: the entire region is one
// potentially-partial line — carry all of it forward.
completeRegion = Buffer.alloc(0)
nextCarry = combined
} else {
// Bytes [0, firstNewline) are the partial-line carry; bytes
// [firstNewline+1, end) are the complete-line region. Dropping the
// newline itself avoids an empty leading "" line after split.
nextCarry = combined.subarray(0, firstNewline)
completeRegion = combined.subarray(firstNewline + 1)
}
if (completeRegion.length > 0) {
const lines = completeRegion.toString('utf8').split('\n')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (line.length === 0) {
continue
}
const extracted = extractAssistantTextFromLine(line)
if (extracted !== undefined) {
return extracted
}
}
}
carryBytes = nextCarry
}
return undefined
} finally {
closeSync(fd)
}
} catch {
return undefined
}
}
function extractClaudeToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
const update: ToolSnapshot = {}
if (
eventName === 'PreToolUse' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure'
) {
const toolName = readString(hookPayload, 'tool_name')
update.toolName = toolName
update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input)
}
if (eventName === 'PostToolUse') {
const responseText = extractToolResponseText(hookPayload.tool_response)
if (responseText) {
update.lastAssistantMessage = responseText
}
}
if (eventName === 'PostToolUseFailure') {
const errorText =
extractToolResponseText(hookPayload.tool_response) ??
readString(hookPayload, 'error') ??
readString(hookPayload, 'message')
if (errorText) {
update.lastAssistantMessage = errorText
}
}
if (eventName === 'Stop') {
// Why: newer Claude versions include `last_assistant_message` directly on
// the Stop payload, which is both cheaper and more reliable than reading
// the JSONL transcript. Prefer it when present; fall back to transcript
// scanning for older Claude versions that omit the field.
const direct = readString(hookPayload, 'last_assistant_message')
if (direct) {
update.lastAssistantMessage = direct
} else {
const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path)
if (lastFromTranscript) {
update.lastAssistantMessage = lastFromTranscript
}
}
}
return update
}
function extractCodexToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'PreToolUse' || eventName === 'PostToolUse') {
// Why: Codex emits tool metadata under `tool_name` + `tool_input`
// (matching Claude's shape). We surface both so the dashboard row can
// show what the agent is currently doing during the otherwise-silent
// gap between UserPromptSubmit and Stop. See TOOL_INPUT_KEYS_BY_TOOL
// for which input field is previewed per Codex tool name.
const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
deriveToolInputPreview(toolName, hookPayload.input) ??
deriveToolInputPreview(toolName, hookPayload.arguments)
return { toolName, toolInput }
}
if (eventName === 'Stop') {
// Why: Codex documents `last_assistant_message` on Stop.
const message = readString(hookPayload, 'last_assistant_message')
if (message) {
return { lastAssistantMessage: message }
}
}
return {}
}
function extractGeminiToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') {
const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
deriveToolInputPreview(toolName, hookPayload.args) ??
deriveToolInputPreview(toolName, hookPayload.input)
return { toolName, toolInput }
}
if (eventName === 'AfterAgent') {
// Why: Gemini's AfterAgent payload carries the final reply under
// `prompt_response` (per geminicli.com/docs/hooks/reference). This is
// Gemini's analogue of Claude/Codex's `last_assistant_message` on Stop;
// surfacing it lets the dashboard show the agent's response on done.
const message = readString(hookPayload, 'prompt_response')
if (message) {
return { lastAssistantMessage: message }
}
}
return {}
}
function extractOpenCodeToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'MessagePart' && hookPayload.role === 'assistant') {
// Why: OpenCode streams the assistant's reply via repeated MessagePart
// events (one per text delta flush). Each event carries the cumulative
// text-so-far for that TextPart, so the latest one we see is the most
// complete snapshot to surface on `done`. We do NOT gate on SessionIdle
// because the plugin emits parts *before* session.idle fires, and gating
// would lose them.
const text = readString(hookPayload, 'text')
if (text) {
return { lastAssistantMessage: text }
}
}
return {}
}
// Why: Cursor's preToolUse / postToolUse / postToolUseFailure payloads carry
// `tool_name` + `tool_input` (same shape as Claude). beforeShellExecution /
// beforeMCPExecution carry a `command` field directly — surface that via a
// synthetic "Shell" / "MCP" tool name so the dashboard row can show the
// pending command while cursor-agent is blocked on approval.
// afterAgentResponse carries a `text` field that is cursor's analogue of
// Claude's last_assistant_message (the final composed reply for the turn).
function extractCursorToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (
eventName === 'preToolUse' ||
eventName === 'postToolUse' ||
eventName === 'postToolUseFailure'
) {
const toolName = readString(hookPayload, 'tool_name')
const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input)
const update: ToolSnapshot = { toolName, toolInput }
if (eventName === 'postToolUse') {
const responseText = extractToolResponseText(hookPayload.tool_output)
if (responseText) {
update.lastAssistantMessage = responseText
}
}
if (eventName === 'postToolUseFailure') {
const errorText =
extractToolResponseText(hookPayload.tool_output) ??
readString(hookPayload, 'error_message') ??
readString(hookPayload, 'error')
if (errorText) {
update.lastAssistantMessage = errorText
}
}
return update
}
if (eventName === 'beforeShellExecution') {
const command = readString(hookPayload, 'command')
return { toolName: 'Shell', toolInput: command }
}
if (eventName === 'beforeMCPExecution') {
const toolName = readString(hookPayload, 'tool_name') ?? 'MCP'
const toolInput =
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
readString(hookPayload, 'command') ??
readString(hookPayload, 'url')
return { toolName, toolInput }
}
if (eventName === 'afterAgentResponse') {
const text = readString(hookPayload, 'text')
if (text) {
return { lastAssistantMessage: text }
}
}
return {}
}
// Why: pi's tool_call / tool_execution_start / tool_execution_end events all
// carry `tool_name` + `tool_input` in the same shape, so they share one
// extraction branch; resolveToolState merges across events last-write-wins so
// the most recent one wins naturally. message_end (assistant role) carries
// `text` — pi's analogue of Claude's last_assistant_message on Stop.
function extractPiToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (
eventName === 'tool_call' ||
eventName === 'tool_execution_start' ||
eventName === 'tool_execution_end'
) {
const toolName = readString(hookPayload, 'tool_name')
const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input)
return { toolName, toolInput }
}
if (eventName === 'message_end' && hookPayload.role === 'assistant') {
const text = readString(hookPayload, 'text')
if (text) {
return { lastAssistantMessage: text }
}
}
return {}
}
function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
if (source === 'claude') {
return eventName === 'UserPromptSubmit'
}
if (source === 'codex') {
// Why: Codex fires SessionStart at resume AND startup. Both mark the
// boundary of a fresh interactive turn from the hook's perspective, so
// clear the tool cache on either one.
return eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
}
if (source === 'gemini') {
return eventName === 'BeforeAgent'
}
if (source === 'cursor') {
// Why: Cursor's beforeSubmitPrompt is the new-turn boundary (it carries
// the fresh prompt). sessionStart also begins a fresh session and should
// not inherit any cached tool state from whatever was left on disk.
return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart'
}
if (source === 'pi') {
// Why: pi fires before_agent_start at the start of every user turn,
// carrying the fresh prompt. Reset cached tool state then so a previous
// turn's tool preview does not bleed into the new one.
return eventName === 'before_agent_start'
}
// Why: OpenCode has no UserPromptSubmit analogue, AND the plugin emits the
// user's MessagePart *before* SessionBusy (message.updated fires on prompt
// submission; session.status goes busy only once OpenCode begins processing).
// So resetting on SessionBusy would clobber the user prompt that was just
// cached. The role=user MessagePart itself naturally overwrites the cache
// with each new turn, so no separate reset is needed here.
return false
}
function extractToolFields(
source: AgentHookSource,
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (source === 'claude') {
return extractClaudeToolFields(eventName, hookPayload)
}
if (source === 'codex') {
return extractCodexToolFields(eventName, hookPayload)
}
if (source === 'gemini') {
return extractGeminiToolFields(eventName, hookPayload)
}
if (source === 'cursor') {
return extractCursorToolFields(eventName, hookPayload)
}
if (source === 'pi') {
return extractPiToolFields(eventName, hookPayload)
}
return extractOpenCodeToolFields(eventName, hookPayload)
}
function normalizeClaudeEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'UserPromptSubmit' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure'
? 'working'
: eventName === 'PermissionRequest'
? 'waiting'
: eventName === 'Stop'
? 'done'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(paneKey, extractToolFields('claude', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('claude', eventName)
})
// Why: Claude Code's `Stop` hook sets `is_interrupt: true` when the turn
// ended because the user hit ESC / Ctrl+C rather than completing normally.
// This is the authoritative signal (the agent itself reports it), so we
// forward it through only on Stop — other hook events don't carry it.
const interrupted =
eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('claude', eventName)
}),
agentType: 'claude',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage,
interrupted
})
)
}
// Why: Gemini CLI exposes BeforeAgent/AfterAgent/AfterTool hooks. BeforeAgent
// fires at turn start and AfterTool resumes the working state after a tool
// call completes; AfterAgent fires when the agent becomes idle. Gemini has no
// permission-prompt hook, so we cannot surface a waiting state for Gemini.
function normalizeGeminiEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'BeforeAgent' ||
eventName === 'AfterTool' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse'
? 'working'
: eventName === 'AfterAgent'
? 'done'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(paneKey, extractToolFields('gemini', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('gemini', eventName)
})
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('gemini', eventName)
}),
agentType: 'gemini',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
// Why: we deliberately do NOT map Codex `PreToolUse` to `waiting`. That event
// fires for every tool call, not just ones that actually need approval, so
// mapping it would flicker the dashboard. Instead we keep it at `working`
// (same as Claude) and use it only to update tool-name / tool-input previews
// so a running Codex turn has visible progress between UserPromptSubmit and
// Stop. Real approval signals travel through Codex's separate `notify`
// callback (different install surface); wiring that up is deferred.
function normalizeCodexEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'SessionStart' ||
eventName === 'UserPromptSubmit' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse'
? 'working'
: eventName === 'Stop'
? 'done'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(paneKey, extractToolFields('codex', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('codex', eventName)
})
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('codex', eventName)
}),
agentType: 'codex',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
// Why: OpenCode has no declarative hook surface — it exposes in-process plugin
// events (session.status busy/idle, session.idle, permission.asked,
// question.asked, message.updated, message.part.updated). The bundled plugin
// (see opencode/hook-service) pre-maps those to our stable hook_event_name
// vocabulary before POSTing so this normalizer can share the same switch
// shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle =
// turn finished, PermissionRequest = blocked on user approval, AskUserQuestion =
// blocked on user reply to an ask-the-user tool (both map to `waiting` so the
// sidebar renders the red "needs attention" indicator), MessagePart =
// incremental text from user prompt or assistant reply (stays in `working`
// because streaming chunks must not flip the row to done mid-turn).
function normalizeOpenCodeEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'SessionBusy' || eventName === 'MessagePart'
? 'working'
: eventName === 'SessionIdle'
? 'done'
: eventName === 'PermissionRequest' || eventName === 'AskUserQuestion'
? 'waiting'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(
paneKey,
extractToolFields('opencode', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('opencode', eventName) }
)
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('opencode', eventName)
}),
agentType: 'opencode',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
// Why: Cursor (cursor-agent) installs hooks via ~/.cursor/hooks.json with
// camelCase event names, per https://cursor.com/docs/hooks. The CLI fires
// stdin-JSON payloads for each subscribed event; we subscribe to the subset
// that marks turn boundaries and produces meaningful working/done/waiting
// transitions for the Orca sidebar. afterAgentResponse carries the final
// assistant reply text, which is cursor's analogue of Claude's Stop
// last_assistant_message — we keep the row in `working` there because the
// true turn-end signal is `stop`.
function normalizeCursorEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'beforeSubmitPrompt' ||
eventName === 'sessionStart' ||
eventName === 'preToolUse' ||
eventName === 'postToolUse' ||
eventName === 'postToolUseFailure' ||
eventName === 'afterAgentResponse'
? 'working'
: eventName === 'stop' || eventName === 'sessionEnd'
? 'done'
: eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution'
? 'waiting'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(paneKey, extractToolFields('cursor', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('cursor', eventName)
})
// Why: cursor-agent reports turn interrupts via `stop` with status !==
// "completed" (e.g. "cancelled", "stopped"). Forward the boolean so the
// sidebar can render the interrupted-turn treatment that Claude uses.
const interrupted =
eventName === 'stop' &&
typeof hookPayload.status === 'string' &&
hookPayload.status !== 'completed'
? true
: undefined
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('cursor', eventName)
}),
agentType: 'cursor',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage,
interrupted
})
)
}
// Why: pi exposes an in-process TypeScript extension API (no settings.json
// hook surface). The bundled orca-agent-status extension installed into the
// per-PTY pi overlay (PiTitlebarExtensionService) translates pi's lifecycle
// events into the hook_event_name vocabulary used here so the dashboard
// row sees the same working/done shape as Claude/Codex/Gemini.
//
// Mapping:
// before_agent_start | agent_start | tool_call | tool_execution_start |
// tool_execution_end | message_end → working
// agent_end | session_shutdown → done
//
// pi has no permission-prompt event we can hook today (tool_call CAN block
// via { block: true, reason } but that's a synchronous return, not a
// separate event), so there is no `waiting` state for pi yet. message_end
// stays in `working` because the true turn-end signal is agent_end — the
// assistant message body just updates lastAssistantMessage on the
// already-active turn.
function normalizePiEvent(
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const state =
eventName === 'before_agent_start' ||
eventName === 'agent_start' ||
eventName === 'tool_call' ||
eventName === 'tool_execution_start' ||
eventName === 'tool_execution_end' ||
eventName === 'message_end'
? 'working'
: eventName === 'agent_end' || eventName === 'session_shutdown'
? 'done'
: null
if (!state) {
return null
}
const snapshot = resolveToolState(paneKey, extractToolFields('pi', eventName, hookPayload), {
resetOnNewTurn: isNewTurnEvent('pi', eventName)
})
return parseAgentStatusPayload(
JSON.stringify({
state,
prompt: resolvePrompt(paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('pi', eventName)
}),
agentType: 'pi',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
function normalizeHookPayload(
source: AgentHookSource,
body: unknown,
expectedEnv: string
): AgentHookEventPayload | null {
if (typeof body !== 'object' || body === null) {
return null
}
const record = body as Record<string, unknown>
const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
const rawPayload = record.payload
const hookPayload =
typeof rawPayload === 'string'
? (() => {
try {
return JSON.parse(rawPayload)
} catch {
return null
}
})()
: rawPayload
// Why: paneKey comes from an authenticated-but-potentially-malicious local
// client; bound its size so pathological clients cannot blow up the
// per-pane caches (lastPromptByPaneKey / lastToolByPaneKey) with multi-MB
// keys. 200 chars is well above any legitimate `${tabId}:${paneId}` value.
const MAX_PANE_KEY_LEN = 200
if (
!paneKey ||
paneKey.length > MAX_PANE_KEY_LEN ||
typeof hookPayload !== 'object' ||
hookPayload === null
) {
return null
}
// Why: scripts installed by an older app build may send a different shape.
// We accept the request (fail-open) but log once so stale installs are
// diagnosable instead of silently degrading.
const version = readStringField(record, 'version')
if (
version &&
version !== ORCA_HOOK_PROTOCOL_VERSION &&
!warnedVersions.has(version) &&
warnedVersions.size < MAX_WARNED_KEYS
) {
warnedVersions.add(version)
console.warn(
`[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` +
'Reinstall agent hooks from Settings to upgrade the managed script.'
)
}
// Why: detects dev-vs-prod cross-talk. A hook installed by a dev build but
// triggered inside a prod terminal (or vice versa) still points at whichever
// loopback port the shell env captured, so the *other* instance may receive
// it. Logging the mismatch lets a user know their terminals are wired to the
// wrong Orca.
const clientEnv = readStringField(record, 'env')
if (clientEnv && clientEnv !== expectedEnv) {
const key = `${clientEnv}->${expectedEnv}`
if (!warnedEnvs.has(key) && warnedEnvs.size < MAX_WARNED_KEYS) {
warnedEnvs.add(key)
console.warn(
`[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` +
'Likely a stale terminal from another Orca install.'
)
}
}
const tabId = readStringField(record, 'tabId')
const worktreeId = readStringField(record, 'worktreeId')
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
const hookPayloadRecord = hookPayload as Record<string, unknown>
const payload =
source === 'claude'
? normalizeClaudeEvent(eventName, promptText, paneKey, hookPayloadRecord)
: source === 'codex'
? normalizeCodexEvent(eventName, promptText, paneKey, hookPayloadRecord)
: source === 'gemini'
? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord)
: source === 'cursor'
? normalizeCursorEvent(eventName, promptText, paneKey, hookPayloadRecord)
: source === 'pi'
? normalizePiEvent(eventName, promptText, paneKey, hookPayloadRecord)
: normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord)
return payload ? { paneKey, tabId, worktreeId, payload } : null
}
// Why: the endpoint file lives under userData so each Orca install (dev vs.
// packaged) has its own path and the two cannot clobber each other. Using a
// per-platform extension (`.env` on POSIX, `.cmd` on Windows) lets the hook
// scripts source the file with their platform-native syntax (`.` on POSIX,
// `call` on Windows); the OpenCode plugin's regex accepts both shapes so no
// platform detection is needed inside the plugin source either.
function getEndpointFileName(): string {
return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env'
}
// Why: every value in the endpoint file is sourced as shell. Reject any
// value that contains shell/cmd metacharacters so a future field whose
// value is not shell-safe-by-construction cannot command-inject via the
// sourced file. Keep to a conservative allowlist of common printable
// chars plus hyphen/dot/slash/colon/underscore — sufficient for ports,
// UUIDs, version strings, and env names.
// Rejects empty values (`+` quantifier) as defense-in-depth for future
// callers — an empty sourced `KEY=` would silently clear the env var in
// the sourcing shell, masking whatever legitimate value was previously set.
function isShellSafeEndpointValue(value: string): boolean {
return /^[A-Za-z0-9._:/-]+$/.test(value)
}
export class AgentHookServer {
private server: ReturnType<typeof createServer> | null = null
private port = 0
private token = ''
// Why: identifies this Orca instance so hook scripts can stamp requests and
// the server can detect dev vs. prod cross-talk. Set at start() from the
// caller's knowledge of whether this is a packaged build.
private env = 'production'
private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null
// Why: directory that holds the on-disk endpoint file. Set via start()'s
// `userDataPath` option so the class has no direct Electron dependency
// (keeps it mockable in the vitest node environment). When unset, we skip
// the endpoint-file write entirely — hooks still work via PTY env, just
// without survive-a-restart semantics.
private endpointDir: string | null = null
private endpointFilePathCache: string | null = null
// Why: tracks whether writeEndpointFile() succeeded for the *current*
// start(). Without this flag, buildPtyEnv() would expose
// ORCA_AGENT_HOOK_ENDPOINT pointing at a path that may hold stale
// coordinates from a prior crashed instance — hook scripts would source
// those stale coords and silently post to a dead server. Gating the
// ENDPOINT env var on a successful write preserves the
// fail-open-to-fresh-env guarantee.
private endpointFileWritten = false
setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void {
this.onAgentStatus = listener
if (!listener) {
return
}
// Why: replay is best-effort per pane so one throwing listener call can't
// starve subsequent panes from being replayed.
for (const payload of lastStatusByPaneKey.values()) {
try {
listener(payload)
} catch (err) {
console.error('[agent-hooks] replay listener threw', err)
}
}
}
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
if (this.server) {
return
}
if (options?.env) {
this.env = options.env
}
if (options?.userDataPath) {
this.endpointDir = join(options.userDataPath, 'agent-hooks')
this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName())
}
this.token = randomUUID()
this.endpointFileWritten = false
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
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: bound request time so a slow/stalled client cannot hold a socket
// open indefinitely (slowloris-style). The hook endpoints are local and
// should complete in well under a second.
req.setTimeout(5000, () => {
req.destroy()
})
try {
const body = await readRequestBody(req)
// Why: match on pathname only so a future debugging addition of a
// query string or trailing slash from a hook sender does not silently
// 404 a valid, token-authenticated request.
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
const source: AgentHookSource | null =
pathname === '/hook/claude'
? 'claude'
: pathname === '/hook/codex'
? 'codex'
: pathname === '/hook/gemini'
? 'gemini'
: pathname === '/hook/opencode'
? 'opencode'
: pathname === '/hook/cursor'
? 'cursor'
: pathname === '/hook/pi'
? 'pi'
: null
if (!source) {
res.writeHead(404)
res.end()
return
}
const payload = normalizeHookPayload(source, body, this.env)
if (payload) {
lastStatusByPaneKey.set(payload.paneKey, payload)
this.onAgentStatus?.(payload)
}
res.writeHead(204)
res.end()
} catch {
// Why: agent hooks must fail open. The receiver returns success for
// malformed payloads so a newer or broken hook never blocks the agent.
res.writeHead(204)
res.end()
}
})
await new Promise<void>((resolve, reject) => {
// Why: the startup error handler must only reject the start() promise for
// errors that happen before 'listening'. Without swapping it out on
// success, any later runtime error (e.g. EADDRINUSE during rebind,
// socket errors) would call reject() on an already-settled promise and,
// more importantly, leaving it as the only 'error' listener means node
// treats runtime errors as unhandled and crashes the main process.
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
reject(err)
}
const onListening = (): void => {
this.server?.off('error', onStartupError)
this.server?.on('error', (err) => {
console.error('[agent-hooks] server error', err)
})
const address = this.server!.address()
if (address && typeof address === 'object') {
this.port = address.port
}
// Why: the endpoint file is the core of the survives-Orca-restart
// design. Write it *after* we have a concrete port — hooks that source
// the file must see a usable coordinate set, not a stale one left over
// from a previous process (e.g. one that crashed before getting here).
this.writeEndpointFile()
resolve()
}
this.server!.once('error', onStartupError)
this.server!.listen(0, '127.0.0.1', onListening)
})
}
stop(): void {
this.server?.close()
this.server = null
this.port = 0
this.token = ''
this.env = 'production'
this.onAgentStatus = null
// Why: intentionally do NOT delete the endpoint file on stop(). A stale
// file points at a dead port, which matches the fail-open policy (hook
// POSTs silently fail → same as pre-endpoint-file behavior). Attempting to unlink
// introduces a TOCTOU race: a concurrent Orca instance sharing userData
// could rewrite the file between our token check and unlink, and we'd
// delete their live endpoint file. The next successful start() overwrites
// the file atomically; the tmp-file sweep inside writeEndpointFile()
// handles orphan hygiene.
this.endpointDir = null
this.endpointFilePathCache = null
this.endpointFileWritten = false
// Why: drop all per-pane cache entries on shutdown so a subsequent start()
// in the same process (e.g. during tests or a settings-driven restart)
// does not inherit stale prompt/tool state from the previous run.
lastPromptByPaneKey.clear()
lastToolByPaneKey.clear()
lastStatusByPaneKey.clear()
// Why: across stop()/start() cycles the warn-once Sets would otherwise
// suppress legitimate new warnings after a restart.
warnedVersions.clear()
warnedEnvs.clear()
}
clearPaneState(paneKey: string): void {
// Why: callers invoke this on PTY teardown so the per-pane caches do not
// accumulate entries for dead panes over the process lifetime. Without
// this, every closed pane leaves its prompt + tool snapshot pinned in
// memory for the life of the main process.
lastPromptByPaneKey.delete(paneKey)
lastToolByPaneKey.delete(paneKey)
lastStatusByPaneKey.delete(paneKey)
}
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
return {}
}
// Why: ORCA_AGENT_HOOK_ENDPOINT is the key that lets a surviving PTY reach
// the *current* Orca after a restart. The other four variables are retained
// for back-compat so pre-endpoint-file hook scripts (which do not know to
// source the endpoint file) continue to work on freshly spawned PTYs, and
// so the current script can fall through to env if the file is
// missing/unreadable for any reason.
const env: Record<string, string> = {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
if (this.endpointFileWritten && this.endpointFilePathCache) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
}
return env
}
// Why: exposed as a read-only getter so tests (and any future main-process
// caller that needs the path for diagnostics) do not have to reconstruct
// the path convention.
get endpointFilePath(): string | null {
return this.endpointFilePathCache
}
// Why: writes the four coordinates atomically via a tmp-then-rename so a
// hook reading concurrently either sees the old file or the new one, never
// a half-written one. Fail-open: on EACCES / ENOSPC / etc. we log and move
// on — start() remains usable via PTY env for freshly-spawned PTYs. Only
// survivors lose the endpoint-file path, matching the hook-payload
// fail-open policy already enforced on the receiving end.
private writeEndpointFile(): void {
if (!this.endpointDir || !this.endpointFilePathCache) {
return
}
// Why: defensive reset — buildPtyEnv() must not see a stale `true` from
// a previous start() if this write fails before reaching the success
// assignment below.
this.endpointFileWritten = false
const finalPath = this.endpointFilePathCache
// Why: unique-per-call tmp name (mirrors persistence.ts / installer-utils.ts); prevents cross-process collision if two writers race on the same endpoint dir.
const tmpPath = join(this.endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`)
const prefix = process.platform === 'win32' ? 'set ' : ''
// Why: every value written here is sourced as shell (`. "$file"` on
// POSIX, `call "%file%"` on Windows) — the file format IS shell, not
// key=value data. The current four inputs are shell-safe by
// construction: PORT is a number from listen(), TOKEN is randomUUID()
// output (hex + dashes only), VERSION is a compile-time string
// constant, and ENV is a fixed 'production' / 'development' literal
// passed from index.ts. Any future change that relaxes these
// invariants (user-supplied env name, persisted token, arbitrary
// free-form field) MUST add escaping or a safe-character validator
// before the write — otherwise a value like `foo&malicious` on Windows
// would command-inject via `call`, and a newline in any value would
// corrupt the POSIX sourceable output. The isShellSafeEndpointValue
// check below enforces this contract at runtime.
const valuesToWrite: [string, string][] = [
['ORCA_AGENT_HOOK_PORT', String(this.port)],
['ORCA_AGENT_HOOK_TOKEN', this.token],
['ORCA_AGENT_HOOK_ENV', this.env],
['ORCA_AGENT_HOOK_VERSION', ORCA_HOOK_PROTOCOL_VERSION]
]
for (const [key, value] of valuesToWrite) {
if (!isShellSafeEndpointValue(value)) {
console.error(
`[agent-hooks] refusing to write endpoint file: ${key} contains ` +
'characters unsafe for shell sourcing. Falling back to PTY env.'
)
return
}
}
const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), '']
let tmpWritten = false
try {
// Why: mode 0o700 — match the file's owner-only policy so the
// agent-hooks/ directory itself does not leak the existence of this
// Orca install (or the presence of the endpoint file) to other local
// users on a multi-user POSIX host. Default umask would otherwise
// leave the dir at 0o755 even though the file inside is 0o600.
mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 })
if (process.platform !== 'win32') {
// Why: mkdirSync's `mode` only applies when the dir is newly created —
// a pre-existing agent-hooks/ dir (from an earlier build or user
// intervention) keeps its original permissions. Re-chmod on every
// start() so the directory matches the 0600 file inside it. POSIX
// only; chmod semantics differ on Windows and the filesystem-level
// ACL model makes this check meaningless there.
try {
chmodSync(this.endpointDir, 0o700)
} catch {
// Why: best-effort — a chmod failure (exotic fs, read-only mount)
// must not block the endpoint-file write itself.
}
}
// Why: a crash between writeFileSync and renameSync leaves stale
// `.endpoint-<pid>-<uuid>.tmp` in this directory. Sweep older-than-5-min
// orphans so the dir does not grow unboundedly. Fresh tmps are left
// alone so a legitimate concurrent instance is not disturbed.
try {
const entries = readdirSync(this.endpointDir)
const cutoff = Date.now() - 5 * 60 * 1000
for (const entry of entries) {
if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) {
continue
}
const entryPath = join(this.endpointDir, entry)
try {
if (statSync(entryPath).mtimeMs < cutoff) {
unlinkSync(entryPath)
}
} catch {
// best-effort sweep
}
}
} catch {
// readdirSync can fail on exotic filesystems; never block the write
}
// Why: 0o600 — the token is a loopback bearer credential and must not
// be readable by other local users. Parity with PTY env exposure via
// /proc/<pid>/environ (owner-only on modern Linux).
// Why: `.cmd` files require CRLF for consistent `set` parsing across
// Windows versions — LF-only terminators are silently mis-parsed by
// some cmd.exe versions, which would break hook coord refresh (exactly
// the bug this file exists to fix). POSIX stays LF.
const separator = process.platform === 'win32' ? '\r\n' : '\n'
writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 })
tmpWritten = true
renameSync(tmpPath, finalPath)
this.endpointFileWritten = true
} catch (err) {
console.error('[agent-hooks] failed to write endpoint file:', err)
// Why: clean up tmp; never nuke the prior finalPath when we cannot
// guarantee we have replaced it. Stale finalPath → dead port → silent
// fail on hook POST matches the fail-open policy documented on the
// receiver side. Destroying the prior file would strand surviving PTYs
// that *could* have continued to fail silently against a dead port —
// strictly worse than leaving the prior coords in place until the next
// successful start() overwrites them.
if (tmpWritten) {
try {
unlinkSync(tmpPath)
} catch {
// Why: tmp may already be gone (rename partially succeeded, or an
// external process cleaned it). Nothing to do.
}
}
}
}
}
export const agentHookServer = new AgentHookServer()
// Why: exported for test coverage of the per-agent field extractors. The
// `normalizeHookPayload` function wraps these with the cache + routing logic
// the tests need to exercise end-to-end; making it test-visible avoids
// having to spin up a real HTTP server just to assert field shaping.
export const _internals = {
normalizeHookPayload,
parseFormEncodedBody,
resetCachesForTests: (): void => {
lastPromptByPaneKey.clear()
lastToolByPaneKey.clear()
lastStatusByPaneKey.clear()
// Why: across test runs the warn-once Sets would otherwise suppress
// legitimate new warnings that a later test expects to observe.
warnedVersions.clear()
warnedEnvs.clear()
}
}