mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
feat(pi): wire pi panes into the unified agent-hooks pipeline (#1501)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type WellKnownAgentType =
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'aider'
|
||||
| 'pi'
|
||||
| 'unknown'
|
||||
export type AgentType = WellKnownAgentType | (string & {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user