feat(pi): wire pi panes into the unified agent-hooks pipeline (#1501)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-05-06 17:26:50 -07:00
committed by GitHub
co-authored by Orca
parent 3e3e30d38a
commit 8d0b7b032a
7 changed files with 477 additions and 13 deletions
+148
View File
@@ -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
+121 -11
View File
@@ -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<string, readonly string[]> = {
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<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'
@@ -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<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') {
@@ -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()
@@ -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<string, string> | null = null',
'',
'function readEndpointFile(): Record<string, string> | 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<string, string> = {}',
' 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<string, unknown> = {}): Promise<void> {',
' 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')
}
@@ -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)
+12
View File
@@ -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
+2 -1
View File
@@ -98,7 +98,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
gemini: 'Gemini',
opencode: 'OpenCode',
cursor: 'Cursor',
aider: 'Aider'
aider: 'Aider',
pi: 'Pi'
}
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
+1
View File
@@ -16,6 +16,7 @@ export type WellKnownAgentType =
| 'opencode'
| 'cursor'
| 'aider'
| 'pi'
| 'unknown'
export type AgentType = WellKnownAgentType | (string & {})