diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index ba321dc1799..0e291d83e15 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -856,6 +856,154 @@ describe('Cursor hook normalization', () => { }) }) +describe('Pi hook normalization', () => { + it('before_agent_start maps to working and captures the prompt', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'rename this fn' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('pi') + expect(result?.payload.prompt).toBe('rename this fn') + }) + + it('agent_start without a prompt keeps the cached prompt from the current turn', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'first prompt' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_start' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('first prompt') + }) + + it('before_agent_start clears the previous turn’s tool cache', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'ls' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'next' }), + 'production' + ) + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('tool_call surfaces tool_name + tool_input preview', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'pnpm test' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('bash') + expect(result?.payload.toolInput).toBe('pnpm test') + }) + + it('tool_execution_start also populates the tool preview', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_execution_start', + tool_name: 'read', + tool_input: { path: 'src/main/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('read') + expect(result?.payload.toolInput).toBe('src/main/index.ts') + }) + + it('message_end (assistant) stays in working but captures lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'message_end', + role: 'assistant', + text: 'Done — I refactored the helper.' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('Done — I refactored the helper.') + }) + + it('message_end (user) is ignored', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }), + 'production' + ) + // Why: pi captures the user prompt via before_agent_start, not via + // message_end. A user-role message_end should not flip lastAssistantMessage. + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + + it('agent_end maps to done', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.agentType).toBe('pi') + }) + + it('session_shutdown maps to done', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'session_shutdown' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + }) + + it('done preserves the cached lastAssistantMessage from a prior message_end', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'message_end', + role: 'assistant', + text: 'final reply' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('final reply') + }) + + it('unknown event names are dropped', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'never_heard_of_it' }), + 'production' + ) + expect(result).toBeNull() + }) +}) + describe('Endpoint file lifecycle', () => { let userDataPath: string diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 92db2823b2b..e88d08ad9ae 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -20,13 +20,13 @@ import { } from '../../shared/agent-status-types' import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' -// Why: Pi is intentionally absent. Pi has no shell-command hook surface — -// its extensibility is an in-process TypeScript extension API (pi.on(...) -// with events like turn_start/turn_end/tool_execution_start), not a -// settings.json hook block that we could install alongside the Claude/Codex/ -// Gemini ones. Wiring Pi would require shipping a bundled Pi extension -// that POSTs to this server; until we do that, Pi panes fall back to -// terminal-title heuristics like any uninstrumented CLI. +// 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 @@ -39,7 +39,7 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' // 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' +type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' type AgentHookEventPayload = { paneKey: string @@ -270,7 +270,20 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { exec_command: ['cmd', 'command'], shell_command: ['cmd', 'command'], apply_patch: ['path', 'file_path'], - view_image: ['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( @@ -659,6 +672,33 @@ function extractCursorToolFields( 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 +): 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' @@ -678,6 +718,12 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // 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). @@ -704,6 +750,9 @@ function extractToolFields( if (source === 'cursor') { return extractCursorToolFields(eventName, hookPayload) } + if (source === 'pi') { + return extractPiToolFields(eventName, hookPayload) + } return extractOpenCodeToolFields(eventName, hookPayload) } @@ -953,6 +1002,63 @@ function normalizeCursorEvent( ) } +// 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 +): 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, key: string): string | undefined { const value = record[key] if (typeof value !== 'string') { @@ -1047,7 +1153,9 @@ function normalizeHookPayload( ? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord) : source === 'cursor' ? normalizeCursorEvent(eventName, promptText, paneKey, hookPayloadRecord) - : normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord) + : source === 'pi' + ? normalizePiEvent(eventName, promptText, paneKey, hookPayloadRecord) + : normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord) return payload ? { paneKey, tabId, worktreeId, payload } : null } @@ -1167,7 +1275,9 @@ export class AgentHookServer { ? 'opencode' : pathname === '/hook/cursor' ? 'cursor' - : null + : pathname === '/hook/pi' + ? 'pi' + : null if (!source) { res.writeHead(404) res.end() diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts new file mode 100644 index 00000000000..0affcf089bf --- /dev/null +++ b/src/main/pi/agent-status-extension-source.ts @@ -0,0 +1,187 @@ +// Why: pi has no settings.json hook surface — its extensibility is the +// in-process TypeScript extension API (pi.on('agent_start'), 'tool_call', +// etc.). To get pi panes into the unified agent-hooks pipeline alongside +// Claude/Codex/Gemini/OpenCode/Cursor, we ship a bundled extension into +// the per-PTY Pi overlay (PiTitlebarExtensionService) that POSTs to +// /hook/pi using the same ORCA_AGENT_HOOK_* + ORCA_PANE_KEY env that every +// PTY already receives from ipc/pty.ts. +// +// The overlay is per-PTY, so each pi process boots with its own copy of +// this extension and its own paneKey. Like the OpenCode plugin, the +// returned source is a string (loaded by jiti from disk inside the pi +// process), so we keep the source body in plain JS without TS types and +// avoid pulling pi or any Orca dep into the pi runtime. +export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' + +export function getPiAgentStatusExtensionSource(): string { + // Why: keep this string self-contained — it runs inside the pi process, + // so it cannot import from Orca's main bundle. fs/http coords come from + // the same endpoint file the OpenCode plugin reads (process.env is frozen + // at PTY spawn, so on Orca restart we have to re-read it from disk). + return [ + "import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'", + '', + '// Why: warn-once so a recurring parse error on a malformed endpoint', + '// file does not spam stderr inside the pi TUI on every event.', + 'let warnedBadEndpoint = false', + '', + '// Why: re-reading the endpoint file on every event is cheap (small file,', + '// rare changes) but stat+mtime caching avoids re-parsing on every event', + '// during streaming tool execution. Mirrors the OpenCode plugin cache shape.', + "let cachedEndpointKey = ''", + 'let cachedEndpointValues: Record | null = null', + '', + 'function readEndpointFile(): Record | null {', + ' const path = process.env.ORCA_AGENT_HOOK_ENDPOINT', + ' if (!path) return null', + ' try {', + " const fs = require('fs')", + ' try {', + ' const stat = fs.statSync(path)', + " const cacheKey = stat.mtimeMs + ':' + stat.size + ':' + stat.ino", + ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', + ' return cachedEndpointValues', + ' }', + " const contents: string = fs.readFileSync(path, 'utf8')", + ' const out: Record = {}', + ' for (const line of contents.split(/\\r?\\n/)) {', + ' // Why: parse `KEY=VALUE` (POSIX endpoint.env) and `set KEY=VALUE`', + ' // (Windows endpoint.cmd) with one regex; strip a trailing CR so', + ' // mixed-EOL files do not leak \\r 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) {', + " cachedEndpointKey = ''", + ' cachedEndpointValues = null', + ' throw ioErr', + ' }', + ' } catch (err: unknown) {', + ' const code = (err as { code?: string } | null)?.code', + " if (err && code !== 'ENOENT' && !warnedBadEndpoint) {", + ' warnedBadEndpoint = true', + " console.warn('[orca-pi-status] failed to parse endpoint file:', (err as Error).message)", + ' }', + ' return null', + ' }', + '}', + '', + 'function resolveHookCoords() {', + ' 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 || '',", + ' }', + '}', + '', + 'async function post(hookEventName: string, extra: Record = {}): Promise {', + ' 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/pi`', + ' 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, ...extra },', + ' })', + ' try {', + ' await fetch(url, {', + " method: 'POST',", + ' headers: {', + " 'Content-Type': 'application/json',", + " 'X-Orca-Agent-Hook-Token': coords.token,", + ' },', + ' body,', + ' })', + ' } catch {', + ' // Why: status reporting must never fail the pi run just because Orca', + ' // is unavailable or the loopback request failed (e.g. Orca restart).', + ' }', + '}', + '', + '// Why: pi assistant messages carry content as an array of parts', + "// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only", + "// surface the concatenated text parts as the visible 'last assistant", + "// message' for the dashboard preview — tool_use / reasoning would be", + '// noise (the dashboard already shows the active tool name + input).', + 'function extractAssistantText(message: unknown): string {', + " if (!message || typeof message !== 'object') return ''", + ' const content = (message as { content?: unknown }).content', + " if (typeof content === 'string') return content", + " if (!Array.isArray(content)) return ''", + " let out = ''", + ' for (const part of content) {', + " if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {", + ' const text = (part as { text?: unknown }).text', + " if (typeof text === 'string') out += text", + ' }', + ' }', + ' return out', + '}', + '', + "// Why: pi's tool_call event input shape is tool-specific (event.input is", + '// the raw args object). The agent-hooks server already runs', + '// deriveToolInputPreview(toolName, input) to render a friendly preview', + "// for known tool names ('bash' → command, 'read'/'write'/'edit' → path,", + '// etc.), so we forward the raw object verbatim under the same field', + '// names Claude uses (tool_name / tool_input) and let the server pick the', + '// preview. Keeps tool-name knowledge centralized on the receiver side.', + 'export default function (pi: ExtensionAPI): void {', + " pi.on('before_agent_start', async (event) => {", + " await post('before_agent_start', { prompt: event.prompt ?? '' })", + ' })', + '', + " pi.on('agent_start', async () => {", + " await post('agent_start')", + ' })', + '', + " pi.on('tool_execution_start', async (event) => {", + " await post('tool_execution_start', {", + ' tool_name: event.toolName,', + ' tool_input: event.args,', + ' })', + ' })', + '', + " pi.on('tool_call', async (event) => {", + " await post('tool_call', {", + ' tool_name: event.toolName,', + ' tool_input: event.input,', + ' })', + ' })', + '', + " pi.on('tool_execution_end', async (event) => {", + " await post('tool_execution_end', {", + ' tool_name: event.toolName,', + ' })', + ' })', + '', + " // Why: capture the assistant's final text on each completed message", + ' // so the dashboard preview reflects the most recent reply even before', + ' // agent_end fires. message_end is the right hook because pi guarantees', + ' // it fires after the message is finalized (post-streaming).', + " pi.on('message_end', async (event) => {", + " if (event.message?.role !== 'assistant') return", + ' const text = extractAssistantText(event.message)', + ' if (!text) return', + " await post('message_end', { role: 'assistant', text })", + ' })', + '', + " pi.on('agent_end', async () => {", + " await post('agent_end')", + ' })', + '', + " pi.on('session_shutdown', async () => {", + " await post('session_shutdown')", + ' })', + '}', + '' + ].join('\n') +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index b0715df7cd5..92ae6da24d6 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -71,7 +71,12 @@ describe('PiTitlebarExtensionService', () => { expect(env.PI_CODING_AGENT_DIR).toBe(join(userDataDir, 'pi-agent-overlays', 'pty-1')) // Orca's titlebar extension is added alongside user extensions, not replacing them. const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort() - expect(overlayExtensions).toEqual(['orca-prefill.ts', 'orca-titlebar-spinner.ts', 'user-ext']) + expect(overlayExtensions).toEqual([ + 'orca-agent-status.ts', + 'orca-prefill.ts', + 'orca-titlebar-spinner.ts', + 'user-ext' + ]) // User's top-level resources are reachable via the overlay. expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(true) expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(true) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 05832b126e1..845a987cd3f 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -13,6 +13,10 @@ import { import { homedir } from 'os' import { basename, join, relative, resolve, sep } from 'path' import { app } from 'electron' +import { + ORCA_PI_AGENT_STATUS_EXTENSION_FILE, + getPiAgentStatusExtensionSource +} from './agent-status-extension-source' const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' @@ -310,6 +314,14 @@ export class PiTitlebarExtensionService { join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), getPiPrefillExtensionSource() ) + // Why: bundled status extension that bridges pi's in-process event API + // to the unified /hook/pi endpoint. Without this, pi panes would have + // no entry in agentStatusByPaneKey and the dashboard would fall back + // to terminal-title heuristics like any uninstrumented CLI. + writeFileSync( + join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE), + getPiAgentStatusExtensionSource() + ) } catch { // Why: overlay creation is best-effort — permission errors (EPERM/EACCES) // on Windows can occur when the userData directory is restricted or when diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index ab93962b3d0..b0ea35ecdd3 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -98,7 +98,8 @@ const WELL_KNOWN_LABELS: Record = { gemini: 'Gemini', opencode: 'OpenCode', cursor: 'Cursor', - aider: 'Aider' + aider: 'Aider', + pi: 'Pi' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 9c5f0c25ffd..7fd9c48afbd 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -16,6 +16,7 @@ export type WellKnownAgentType = | 'opencode' | 'cursor' | 'aider' + | 'pi' | 'unknown' export type AgentType = WellKnownAgentType | (string & {})