feat: add amp agent status hook integration (#2864)

* Add Amp agent hook service and /hook/amp status pipeline

- Register Amp across managed and remote hook installers so it installs, removes, and reports like other agents.
- Add a dedicated Amp plugin service that writes a managed plugin file, preserves user-authored plugins, and exposes consistent status detection.
- Wire Amp endpoints and payload normalization through relay/listener, including agent/start, tool call/result, and end/cancel handling.
- Extend IPC/preload/web/renderer contracts with ampStatus plus UI catalog label/icon support.
- Add tests for plugin installation/removal/status, remote installer behavior, and server acceptance/normalization of Amp hook events.

* Fix Amp hook ordering and status normalization

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Leynier Gutiérrez González 2026-05-29 20:26:58 -06:00 committed by GitHub
parent 0ca3b1403c
commit 6bf69c3638
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1145 additions and 13 deletions

View File

@ -6,6 +6,7 @@
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",

View File

@ -1,6 +1,7 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import type { GlobalSettings } from '../../shared/types'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
@ -21,6 +22,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
@ -34,6 +36,7 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
@ -47,6 +50,7 @@ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],

View File

@ -13,6 +13,7 @@ import { CursorHookService } from '../cursor/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { AntigravityHookService } from '../antigravity/hook-service'
import { AmpHookService } from '../amp/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
import { GrokHookService } from '../grok/hook-service'
import { CopilotHookService } from '../copilot/hook-service'
@ -135,6 +136,10 @@ describe('remote hook service installers', () => {
install: (sftp: SFTPWrapper) =>
new AntigravityHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.config/amp/plugins/orca-agent-status.ts',
install: (sftp: SFTPWrapper) => new AmpHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
@ -159,7 +164,12 @@ describe('remote hook service installers', () => {
const status = await install(sftp)
expect(status.state).toBe('installed')
const script = fs.files.get(path)
expect(script).toMatch(/^#!\/bin\/sh\n/)
if (path.includes('/.config/amp/plugins/')) {
expect(script).toContain('/hook/amp')
expect(script).toContain("amp.on('agent.start'")
} else {
expect(script).toMatch(/^#!\/bin\/sh\n/)
}
expect(script).not.toContain('@echo off')
expect(script).not.toContain('powershell -NoProfile')
}
@ -215,12 +225,14 @@ describe('remote hook service installers', () => {
it('installs remote Gemini, Antigravity, Cursor, Command Code, and Grok configs using their CLI-specific schemas', async () => {
const gemini = createFakeSftp()
const antigravity = createFakeSftp()
const amp = createFakeSftp()
const cursor = createFakeSftp()
const commandCode = createFakeSftp()
const grok = createFakeSftp()
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
await new AntigravityHookService().installRemote(antigravity.sftp, '/home/dev')
await new AmpHookService().installRemote(amp.sftp, '/home/dev')
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
await new CommandCodeHookService().installRemote(commandCode.sftp, '/home/dev')
await new GrokHookService().installRemote(grok.sftp, '/home/dev')
@ -257,6 +269,11 @@ describe('remote hook service installers', () => {
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
}
const ampPlugin = amp.fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')
expect(ampPlugin).toContain('/hook/amp')
expect(ampPlugin).toContain("amp.on('tool.call'")
expect(ampPlugin).toContain('return { action: "allow" }')
const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as {
version: number
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
@ -473,4 +490,22 @@ describe('remote hook service installers', () => {
)
expect(fs.files.get('/home/dev/.hermes/config.yaml')).toContain('orca-status')
})
it('does not overwrite a remote user-authored Amp plugin file', async () => {
const { sftp, fs } = createFakeSftp({
'/home/dev/.config/amp/plugins/orca-agent-status.ts':
'export default function userPlugin() {}\n'
})
const status = await new AmpHookService().installRemote(sftp, '/home/dev/')
expect(status).toMatchObject({
agent: 'amp',
state: 'partial',
managedHooksPresent: false
})
expect(fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')).toBe(
'export default function userPlugin() {}\n'
)
})
})

View File

@ -1,5 +1,6 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { ampHookService } from '../amp/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
@ -19,6 +20,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)],
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],

View File

@ -2176,6 +2176,364 @@ describe('AgentHookServer listener replay', () => {
server.stop()
}
})
it('accepts Amp plugin hook posts on /hook/amp', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const listener = vi.fn()
server.setListener(listener)
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/amp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody({
hook_event_name: 'agent.start',
message: 'verify Amp route'
})
)
})
expect(response.status).toBe(204)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload: expect.objectContaining({
state: 'working',
prompt: 'verify Amp route',
agentType: 'amp'
})
})
)
} finally {
server.stop()
}
})
})
describe('Amp hook normalization', () => {
it('maps agent lifecycle events to working and done states', () => {
const start = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'wire Amp hooks'
}),
'production'
)
expect(start?.payload).toMatchObject({
state: 'working',
prompt: 'wire Amp hooks',
agentType: 'amp'
})
const done = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
message: 'wire Amp hooks',
status: 'done'
}),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'wire Amp hooks',
agentType: 'amp'
})
})
it('surfaces Amp tool call and result context while preserving the prompt', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'run tests'
}),
'production'
)
const toolCall = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.call',
tool: 'shell_command',
input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' }
}),
'production'
)
expect(toolCall?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
toolName: 'shell_command',
toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts'
})
const result = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
tool: 'shell_command',
input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' },
status: 'done',
output: 'tests passed'
}),
'production'
)
expect(result?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
toolName: 'shell_command',
toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts',
lastAssistantMessage: 'tests passed'
})
})
it('does not let Amp tool result messages overwrite the cached prompt', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'run tests'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
tool: 'shell_command',
input: { command: 'pnpm test' },
message: 'tests passed'
}),
'production'
)
expect(result?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
lastAssistantMessage: 'tests passed'
})
const done = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
status: 'done'
}),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'run tests',
agentType: 'amp'
})
})
it('keeps Amp prompt and tool caches isolated by thread id within one pane', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-a',
message: 'first task'
}),
'production'
)
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-b',
message: 'second task'
}),
'production'
)
const threadAResult = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
threadId: 'thread-a',
tool: 'shell_command',
input: { command: 'pnpm test:a' },
output: 'first done'
}),
'production'
)
expect(threadAResult?.payload).toMatchObject({
state: 'working',
prompt: 'first task',
agentType: 'amp',
toolName: 'shell_command',
toolInput: 'pnpm test:a',
lastAssistantMessage: 'first done'
})
const threadBDone = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
threadId: 'thread-b',
status: 'done'
}),
'production'
)
expect(threadBDone?.payload).toMatchObject({
state: 'done',
prompt: 'second task',
agentType: 'amp'
})
})
it('drops stale Amp tool events that arrive after the thread ended', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-a',
message: 'run tests'
}),
'production'
)
const done = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
threadId: 'thread-a',
status: 'done'
}),
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'run tests',
agentType: 'amp'
})
const staleToolResult = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
threadId: 'thread-a',
tool: 'shell_command',
input: { command: 'pnpm test' },
message: 'tests passed'
}),
'production'
)
expect(staleToolResult).toBeNull()
})
it('does not mark Amp tool result messages as explicit prompts', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
threadId: 'thread-a',
message: 'run tests'
}),
'production'
)
const result = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.result',
threadId: 'thread-a',
tool: 'shell_command',
input: { command: 'pnpm test' },
message: 'tests passed'
}),
'production'
)
expect(result?.payload).toMatchObject({
state: 'working',
prompt: 'run tests',
agentType: 'amp',
lastAssistantMessage: 'tests passed'
})
expect(result?.hasExplicitPrompt).toBeUndefined()
})
it('marks cancelled Amp turns as interrupted done states', () => {
const cancelled = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.end',
message: 'stop this run',
status: 'cancelled'
}),
'production'
)
expect(cancelled?.payload).toMatchObject({
state: 'done',
prompt: 'stop this run',
agentType: 'amp',
interrupted: true
})
})
it('treats session.start as cache reset without creating a visible row', () => {
_internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'agent.start',
message: 'old prompt'
}),
'production'
)
const sessionStart = _internals.normalizeHookPayload(
'amp',
buildBody({ hook_event_name: 'session.start', threadId: 'thread-1' }),
'production'
)
expect(sessionStart).toBeNull()
const nextTool = _internals.normalizeHookPayload(
'amp',
buildBody({
hook_event_name: 'tool.call',
tool: 'Read',
input: { file_path: '/tmp/file.ts' }
}),
'production'
)
expect(nextTool?.payload).toMatchObject({
state: 'working',
prompt: '',
agentType: 'amp',
toolName: 'Read',
toolInput: '/tmp/file.ts'
})
})
})
describe('AgentHookServer prompt-sent telemetry', () => {

View File

@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('os', async () => {
const actual = (await vi.importActual('os')) as Record<string, unknown>
return {
...actual,
homedir: homedirMock
}
})
import { AmpHookService, _internals } from './hook-service'
describe('AmpHookService', () => {
let homeDir: string
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'orca-amp-home-'))
homedirMock.mockReturnValue(homeDir)
})
afterEach(() => {
vi.clearAllMocks()
rmSync(homeDir, { recursive: true, force: true })
})
it('installs an Orca-managed Amp system plugin', () => {
const status = new AmpHookService().install()
expect(status).toMatchObject({
agent: 'amp',
state: 'installed',
configPath: join(homeDir, '.config', 'amp', 'plugins', _internals.AMP_PLUGIN_FILE),
managedHooksPresent: true,
detail: null
})
const source = readFileSync(status.configPath, 'utf-8')
expect(source).toContain(_internals.AMP_PLUGIN_MARKER)
expect(source).toContain('/hook/amp')
expect(source).toContain("amp.on('session.start'")
expect(source).toContain("amp.on('agent.start'")
expect(source).toContain("amp.on('tool.call'")
expect(source).toContain("amp.on('tool.result'")
expect(source).toContain("amp.on('agent.end'")
expect(source).toContain('return { action: "allow" }')
expect(source).toContain('let postQueue = Promise.resolve()')
expect(source).toContain('function enqueuePost')
expect(source).toContain('enqueuePost("tool.call"')
expect(source).not.toContain('await post("tool.call"')
expect(source).toContain('process.env.ORCA_PANE_KEY')
expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT')
})
it('does not overwrite an existing user-authored Amp plugin file', () => {
const pluginPath = _internals.getPluginPath()
mkdirSync(dirname(pluginPath), { recursive: true })
writeFileSync(pluginPath, 'export default function userPlugin() {}\n', 'utf-8')
const status = new AmpHookService().install()
expect(status).toMatchObject({
agent: 'amp',
state: 'partial',
managedHooksPresent: false
})
expect(readFileSync(pluginPath, 'utf-8')).toBe('export default function userPlugin() {}\n')
})
it('removes only Orca-managed Amp plugin files', () => {
const service = new AmpHookService()
const installed = service.install()
expect(existsSync(installed.configPath)).toBe(true)
const removed = service.remove()
expect(removed.state).toBe('not_installed')
expect(existsSync(installed.configPath)).toBe(false)
const pluginPath = _internals.getPluginPath()
mkdirSync(dirname(pluginPath), { recursive: true })
writeFileSync(pluginPath, 'export default function userPlugin() {}\n', 'utf-8')
const skipped = service.remove()
expect(skipped.state).toBe('partial')
expect(existsSync(pluginPath)).toBe(true)
})
it('reports partial for stale managed plugin content missing required handlers', () => {
const pluginPath = _internals.getPluginPath()
mkdirSync(dirname(pluginPath), { recursive: true })
writeFileSync(pluginPath, `// ${_internals.AMP_PLUGIN_MARKER}\n`, 'utf-8')
const status = new AmpHookService().getStatus()
expect(status).toMatchObject({
agent: 'amp',
state: 'partial',
managedHooksPresent: true
})
expect(status.detail).toContain('missing required handlers')
})
it('reports partial when a stale managed plugin is missing the session reset handler', () => {
const pluginPath = _internals.getPluginPath()
mkdirSync(dirname(pluginPath), { recursive: true })
writeFileSync(
pluginPath,
[
`// ${_internals.AMP_PLUGIN_MARKER}`,
"amp.on('agent.start', () => {})",
"amp.on('tool.call', () => {})",
"amp.on('tool.result', () => {})",
"amp.on('agent.end', () => {})",
'/hook/amp',
''
].join('\n'),
'utf-8'
)
const status = new AmpHookService().getStatus()
expect(status).toMatchObject({
agent: 'amp',
state: 'partial',
managedHooksPresent: true
})
expect(status.detail).toContain('missing required handlers')
})
})

View File

@ -0,0 +1,367 @@
/* eslint-disable max-lines -- Why: install/status/remove must share the exact
managed Amp plugin source and ownership marker. Splitting would make the
emitted plugin bytes drift from the installer checks that protect user
plugin files from being overwritten. */
import { randomUUID } from 'crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { dirname, join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
readTextFileRemote,
writeTextFileRemoteAtomic
} from '../agent-hooks/installer-utils-remote'
const AMP_PLUGIN_FILE = 'orca-agent-status.ts'
const AMP_PLUGIN_MARKER = 'Managed by Orca. Do not edit; changes may be overwritten.'
type PluginFileState =
| { kind: 'absent' }
| { kind: 'managed'; complete: boolean }
| { kind: 'unmanaged' }
| { kind: 'error'; detail: string }
function getPluginPath(): string {
return join(homedir(), '.config', 'amp', 'plugins', AMP_PLUGIN_FILE)
}
function getRemotePluginPath(remoteHome: string): string {
const home = remoteHome.replace(/\/$/, '')
return `${home}/.config/amp/plugins/${AMP_PLUGIN_FILE}`
}
function isManagedPlugin(content: string): boolean {
return content.includes(AMP_PLUGIN_MARKER)
}
function isCompleteManagedPlugin(content: string): boolean {
return (
isManagedPlugin(content) &&
content.includes('/hook/amp') &&
content.includes("amp.on('session.start'") &&
content.includes("amp.on('agent.start'") &&
content.includes("amp.on('tool.call'") &&
content.includes("amp.on('tool.result'") &&
content.includes("amp.on('agent.end'")
)
}
function readLocalPluginState(pluginPath: string): PluginFileState {
if (!existsSync(pluginPath)) {
return { kind: 'absent' }
}
try {
const content = readFileSync(pluginPath, 'utf-8')
if (!isManagedPlugin(content)) {
return { kind: 'unmanaged' }
}
return { kind: 'managed', complete: isCompleteManagedPlugin(content) }
} catch (error) {
return { kind: 'error', detail: error instanceof Error ? error.message : String(error) }
}
}
function statusFromState(pluginPath: string, state: PluginFileState): AgentHookInstallStatus {
switch (state.kind) {
case 'absent':
return {
agent: 'amp',
state: 'not_installed',
configPath: pluginPath,
managedHooksPresent: false,
detail: null
}
case 'managed':
return {
agent: 'amp',
state: state.complete ? 'installed' : 'partial',
configPath: pluginPath,
managedHooksPresent: true,
detail: state.complete ? null : 'Managed Amp plugin is missing required handlers'
}
case 'unmanaged':
return {
agent: 'amp',
state: 'partial',
configPath: pluginPath,
managedHooksPresent: false,
detail: 'Amp Orca status plugin exists but is not Orca-managed'
}
case 'error':
return {
agent: 'amp',
state: 'error',
configPath: pluginPath,
managedHooksPresent: false,
detail: state.detail
}
}
}
function writeTextFileAtomic(filePath: string, content: string): void {
const dir = dirname(filePath)
mkdirSync(dir, { recursive: true })
if (existsSync(filePath)) {
try {
if (readFileSync(filePath, 'utf-8') === content) {
return
}
} catch {
// Fall through to the atomic write path.
}
}
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
try {
writeFileSync(tmpPath, content, 'utf-8')
renameSync(tmpPath, filePath)
} finally {
if (existsSync(tmpPath)) {
try {
unlinkSync(tmpPath)
} catch {
// best effort
}
}
}
}
function getAmpPluginSource(): string {
return [
"import { readFileSync, statSync } from 'fs'",
"import type { PluginAPI } from '@ampcode/plugin'",
'',
`// ${AMP_PLUGIN_MARKER}`,
'type HookCoords = { port?: string; token?: string; env?: string; version?: string }',
'',
'let warnedBadEndpoint = false',
"let cachedEndpointKey = ''",
'let cachedEndpointValues: HookCoords | null = null',
'',
'function readEndpointFile(): HookCoords | null {',
' const endpointPath = process.env.ORCA_AGENT_HOOK_ENDPOINT',
' if (!endpointPath) return null',
' try {',
' const stat = statSync(endpointPath)',
' const cacheKey = `${stat.mtimeMs}:${stat.size}:${stat.ino}`',
' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {',
' return cachedEndpointValues',
' }',
" const contents = readFileSync(endpointPath, 'utf8')",
' const out: HookCoords = {}',
' for (const line of contents.split(/\\r?\\n/)) {',
' const match = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/)',
' if (!match) continue',
' const value = match[2].replace(/\\r$/, "")',
" if (match[1] === 'ORCA_AGENT_HOOK_PORT') out.port = value",
" if (match[1] === 'ORCA_AGENT_HOOK_TOKEN') out.token = value",
" if (match[1] === 'ORCA_AGENT_HOOK_ENV') out.env = value",
" if (match[1] === 'ORCA_AGENT_HOOK_VERSION') out.version = value",
' }',
' cachedEndpointKey = cacheKey',
' cachedEndpointValues = out',
' return out',
' } catch (error) {',
" cachedEndpointKey = ''",
' cachedEndpointValues = null',
' if ((error as { code?: unknown })?.code !== "ENOENT" && !warnedBadEndpoint) {',
' warnedBadEndpoint = true',
" console.warn('[orca-hook] failed to parse Amp endpoint file:', (error as Error).message)",
' }',
' return null',
' }',
'}',
'',
'function resolveHookCoords(): HookCoords {',
' // Why: Amp sessions can outlive an Orca restart; the endpoint file is',
' // rewritten on each start, so read it per event before falling back to env.',
' const fileEnv = readEndpointFile() ?? {}',
' return {',
' port: fileEnv.port || process.env.ORCA_AGENT_HOOK_PORT,',
' token: fileEnv.token || process.env.ORCA_AGENT_HOOK_TOKEN,',
' env: fileEnv.env || process.env.ORCA_AGENT_HOOK_ENV || "",',
' version: fileEnv.version || process.env.ORCA_AGENT_HOOK_VERSION || ""',
' }',
'}',
'',
'function previewValue(value: unknown, maxLength = 4000): string | undefined {',
' if (typeof value === "string") return value.slice(0, maxLength)',
' if (value === null || value === undefined) return undefined',
' try {',
' return JSON.stringify(value).slice(0, maxLength)',
' } catch {',
' return String(value).slice(0, maxLength)',
' }',
'}',
'',
'function jsonSafe(value: unknown, depth = 0): unknown {',
' if (value === null || value === undefined) return value',
' if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {',
' return value',
' }',
' if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") {',
' return String(value)',
' }',
' if (depth >= 4) return previewValue(value)',
' if (Array.isArray(value)) return value.slice(0, 20).map((item) => jsonSafe(item, depth + 1))',
' if (typeof value === "object") {',
' const out: Record<string, unknown> = {}',
' for (const [key, child] of Object.entries(value).slice(0, 20)) {',
' out[key] = jsonSafe(child, depth + 1)',
' }',
' return out',
' }',
' return String(value)',
'}',
'',
'async function post(hookEventName: string, payload: Record<string, unknown>): Promise<void> {',
' const coords = resolveHookCoords()',
' const paneKey = process.env.ORCA_PANE_KEY',
' if (!coords.port || !coords.token || !paneKey) return',
' const controller = new AbortController()',
' const timeout = setTimeout(() => controller.abort(), 1000)',
' try {',
' await fetch(`http://127.0.0.1:${coords.port}/hook/amp`, {',
' method: "POST",',
' signal: controller.signal,',
' headers: {',
' "Content-Type": "application/json",',
' "X-Orca-Agent-Hook-Token": coords.token',
' },',
' body: JSON.stringify({',
' paneKey,',
' tabId: process.env.ORCA_TAB_ID || "",',
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
' env: coords.env,',
' version: coords.version,',
' hook_event_name: hookEventName,',
' payload: { hook_event_name: hookEventName, ...payload }',
' })',
' })',
' } catch {',
' // Why: Orca status reporting must never affect the Amp run.',
' } finally {',
' clearTimeout(timeout)',
' }',
'}',
'',
'let postQueue = Promise.resolve()',
'function enqueuePost(hookEventName: string, payload: Record<string, unknown>): void {',
' // Why: keep hook callbacks non-blocking while preserving Amp event order.',
' postQueue = postQueue.then(() => post(hookEventName, payload), () => post(hookEventName, payload))',
' void postQueue.catch(() => {})',
'}',
'',
'export default function (amp: PluginAPI) {',
" amp.on('session.start', (event) => {",
' enqueuePost("session.start", { threadId: event.thread.id })',
' })',
'',
" amp.on('agent.start', (event) => {",
' enqueuePost("agent.start", {',
' threadId: event.thread.id,',
' id: event.id,',
' message: event.message',
' })',
' })',
'',
" amp.on('tool.call', (event) => {",
' enqueuePost("tool.call", {',
' threadId: event.thread.id,',
' toolUseId: event.toolUseID,',
' tool: event.tool,',
' input: jsonSafe(event.input)',
' })',
' return { action: "allow" }',
' })',
'',
" amp.on('tool.result', (event) => {",
' enqueuePost("tool.result", {',
' threadId: event.thread.id,',
' toolUseId: event.toolUseID,',
' tool: event.tool,',
' input: jsonSafe(event.input),',
' status: event.status,',
' error: event.error,',
' output: previewValue(event.output)',
' })',
' })',
'',
" amp.on('agent.end', (event) => {",
' enqueuePost("agent.end", {',
' threadId: event.thread.id,',
' id: event.id,',
' message: event.message,',
' status: event.status',
' })',
' })',
'}',
''
].join('\n')
}
export class AmpHookService {
getStatus(): AgentHookInstallStatus {
const pluginPath = getPluginPath()
return statusFromState(pluginPath, readLocalPluginState(pluginPath))
}
install(): AgentHookInstallStatus {
const pluginPath = getPluginPath()
const state = readLocalPluginState(pluginPath)
if (state.kind === 'unmanaged' || state.kind === 'error') {
return statusFromState(pluginPath, state)
}
writeTextFileAtomic(pluginPath, getAmpPluginSource())
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remotePluginPath = getRemotePluginPath(remoteHome)
try {
const existing = await readTextFileRemote(sftp, remotePluginPath)
if (existing !== null && !isManagedPlugin(existing)) {
return statusFromState(remotePluginPath, { kind: 'unmanaged' })
}
await writeTextFileRemoteAtomic(sftp, remotePluginPath, getAmpPluginSource())
return {
agent: 'amp',
state: 'installed',
configPath: remotePluginPath,
managedHooksPresent: true,
detail: null
}
} catch (error) {
return {
agent: 'amp',
state: 'error',
configPath: remotePluginPath,
managedHooksPresent: false,
detail: error instanceof Error ? error.message : String(error)
}
}
}
remove(): AgentHookInstallStatus {
const pluginPath = getPluginPath()
const state = readLocalPluginState(pluginPath)
if (state.kind === 'managed') {
unlinkSync(pluginPath)
return this.getStatus()
}
return statusFromState(pluginPath, state)
}
}
export const ampHookService = new AmpHookService()
export const _internals = {
AMP_PLUGIN_FILE,
AMP_PLUGIN_MARKER,
getAmpPluginSource,
getPluginPath,
getRemotePluginPath,
isManagedPlugin
}

View File

@ -55,6 +55,9 @@ vi.mock('../gemini/hook-service', () => ({
vi.mock('../antigravity/hook-service', () => ({
antigravityHookService: { getStatus: vi.fn(() => ({ agent: 'antigravity', state: 'absent' })) }
}))
vi.mock('../amp/hook-service', () => ({
ampHookService: { getStatus: vi.fn(() => ({ agent: 'amp', state: 'absent' })) }
}))
vi.mock('../cursor/hook-service', () => ({
cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) }
}))
@ -121,6 +124,17 @@ describe('agentHooks:antigravityStatus IPC', () => {
})
})
describe('agentHooks:ampStatus IPC', () => {
it('returns Amp hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:ampStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'amp', state: 'absent' })
})
})
describe('agentHooks:commandCodeStatus IPC', () => {
it('returns Command Code hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')

View File

@ -6,6 +6,7 @@ import type {
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import { ampHookService } from '../amp/hook-service'
import {
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot
@ -36,6 +37,7 @@ export function registerAgentHookHandlers(): void {
ipcMain.removeHandler('agentHooks:codexStatus')
ipcMain.removeHandler('agentHooks:geminiStatus')
ipcMain.removeHandler('agentHooks:antigravityStatus')
ipcMain.removeHandler('agentHooks:ampStatus')
ipcMain.removeHandler('agentHooks:cursorStatus')
ipcMain.removeHandler('agentHooks:droidStatus')
ipcMain.removeHandler('agentHooks:commandCodeStatus')
@ -138,6 +140,19 @@ export function registerAgentHookHandlers(): void {
}
}
})
ipcMain.handle('agentHooks:ampStatus', (): AgentHookInstallStatus => {
try {
return ampHookService.getStatus()
} catch (err) {
return {
agent: 'amp',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:cursorStatus', (): AgentHookInstallStatus => {
try {
return cursorHookService.getStatus()

View File

@ -1383,6 +1383,7 @@ export type PreloadApi = {
codexStatus: () => Promise<AgentHookInstallStatus>
geminiStatus: () => Promise<AgentHookInstallStatus>
antigravityStatus: () => Promise<AgentHookInstallStatus>
ampStatus: () => Promise<AgentHookInstallStatus>
cursorStatus: () => Promise<AgentHookInstallStatus>
droidStatus: () => Promise<AgentHookInstallStatus>
commandCodeStatus: () => Promise<AgentHookInstallStatus>

View File

@ -1471,6 +1471,7 @@ const api = {
ipcRenderer.invoke('agentHooks:geminiStatus'),
antigravityStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:antigravityStatus'),
ampStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:ampStatus'),
cursorStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:cursorStatus'),
droidStatus: (): Promise<AgentHookInstallStatus> =>

View File

@ -114,6 +114,7 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
codex: 'Codex',
gemini: 'Gemini',
antigravity: 'Antigravity',
amp: 'Amp',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
cursor: 'Cursor',

View File

@ -1788,6 +1788,7 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
| 'codex'
| 'gemini'
| 'antigravity'
| 'amp'
| 'cursor'
| 'droid'
| 'command-code'
@ -1807,6 +1808,7 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
codexStatus: () => status('codex'),
geminiStatus: () => status('gemini'),
antigravityStatus: () => status('antigravity'),
ampStatus: () => status('amp'),
cursorStatus: () => status('cursor'),
droidStatus: () => status('droid'),
commandCodeStatus: () => status('command-code'),

View File

@ -59,6 +59,7 @@ export type HookListenerState = {
lastToolByPaneKey: Map<string, ToolSnapshot>
lastStatusByPaneKey: Map<string, AgentHookEventPayload>
antigravityCompletedTranscriptByPaneKey: Map<string, string>
ampCompletedCacheKeys: Set<string>
}
export function createHookListenerState(): HookListenerState {
@ -68,21 +69,44 @@ export function createHookListenerState(): HookListenerState {
lastPromptByPaneKey: new Map(),
lastToolByPaneKey: new Map(),
lastStatusByPaneKey: new Map(),
antigravityCompletedTranscriptByPaneKey: new Map()
antigravityCompletedTranscriptByPaneKey: new Map(),
ampCompletedCacheKeys: new Set()
}
}
export function clearPaneCacheState(state: HookListenerState, paneKey: string): void {
state.lastPromptByPaneKey.delete(paneKey)
state.lastToolByPaneKey.delete(paneKey)
state.lastStatusByPaneKey.delete(paneKey)
state.antigravityCompletedTranscriptByPaneKey.delete(paneKey)
deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey)
deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey)
deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey)
deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey)
deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey)
}
function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void {
state.lastPromptByPaneKey.delete(paneKey)
state.lastToolByPaneKey.delete(paneKey)
state.antigravityCompletedTranscriptByPaneKey.delete(paneKey)
state.ampCompletedCacheKeys.delete(paneKey)
}
function deletePaneScopedCacheEntry(map: Map<string, unknown>, paneKey: string): void {
map.delete(paneKey)
const scopedPrefix = `${paneKey}\0`
for (const key of map.keys()) {
if (key.startsWith(scopedPrefix)) {
map.delete(key)
}
}
}
function deletePaneScopedSetEntry(set: Set<string>, paneKey: string): void {
set.delete(paneKey)
const scopedPrefix = `${paneKey}\0`
for (const key of set) {
if (key.startsWith(scopedPrefix)) {
set.delete(key)
}
}
}
export function clearAllListenerCaches(state: HookListenerState): void {
@ -90,6 +114,7 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.lastToolByPaneKey.clear()
state.lastStatusByPaneKey.clear()
state.antigravityCompletedTranscriptByPaneKey.clear()
state.ampCompletedCacheKeys.clear()
state.warnedVersions.clear()
state.warnedEnvs.clear()
}
@ -567,6 +592,7 @@ function extractToolResponseText(toolResponse: unknown): string | undefined {
const TRANSCRIPT_CHUNK_BYTES = 64 * 1024
const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024
const AMP_THREAD_ID_MAX_LENGTH = 256
const GROK_SESSION_ID_MAX_LENGTH = 128
const GROK_SESSION_CWD_MAX_LENGTH = 4096
@ -1116,6 +1142,42 @@ function extractAntigravityToolFields(
return {}
}
function extractAmpToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'tool.call' || eventName === 'tool.result') {
const toolName =
readString(hookPayload, 'tool') ??
readString(hookPayload, 'toolName') ??
readString(hookPayload, 'name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.input) ??
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
deriveToolInputPreview(toolName, hookPayload.arguments) ??
// Why: Amp plugin tools can have arbitrary names, so fall back to the
// obvious argument fields instead of rendering an empty tool preview.
deriveFallbackToolInputPreview(hookPayload.input) ??
deriveFallbackToolInputPreview(hookPayload.tool_input) ??
deriveFallbackToolInputPreview(hookPayload.arguments)
const update: ToolSnapshot = toolUpdate(
{ toolName, toolInput },
{ hasToolInputField: hasAnyOwnField(hookPayload, ['input', 'tool_input', 'arguments']) }
)
if (eventName === 'tool.result') {
const responseText =
readFirstString(hookPayload, ['error', 'output', 'result', 'message']) ??
extractToolResponseText(hookPayload.output) ??
extractToolResponseText(hookPayload.result)
if (responseText) {
update.lastAssistantMessage = responseText
}
}
return update
}
return {}
}
function extractOpenCodeToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
@ -1714,6 +1776,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
return eventName === 'BeforeAgent'
case 'antigravity':
return eventName === 'PreInvocation'
case 'amp':
return eventName === 'agent.start'
case 'opencode':
return false
case 'cursor':
@ -1803,6 +1867,8 @@ function extractToolFields(
return extractGeminiToolFields(eventName, hookPayload)
case 'antigravity':
return extractAntigravityToolFields(eventName, hookPayload)
case 'amp':
return extractAmpToolFields(eventName, hookPayload)
case 'opencode':
return extractOpenCodeToolFields(eventName, hookPayload)
case 'cursor':
@ -2003,6 +2069,122 @@ function normalizeAntigravityEvent(
return payload
}
function normalizeAmpEvent(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const ampCacheKey = getAmpCacheKey(paneKey, hookPayload)
if (eventName === 'session.start') {
clearPaneTurnCacheState(state, ampCacheKey)
if (ampCacheKey !== paneKey) {
clearPaneTurnCacheState(state, paneKey)
}
return null
}
const stateName =
eventName === 'agent.start' || eventName === 'tool.call' || eventName === 'tool.result'
? 'working'
: eventName === 'agent.end'
? 'done'
: null
if (!stateName) {
return null
}
if (eventName === 'agent.start') {
state.ampCompletedCacheKeys.delete(ampCacheKey)
} else if (
(eventName === 'tool.call' || eventName === 'tool.result') &&
state.ampCompletedCacheKeys.has(ampCacheKey)
) {
// Why: Amp status posts are fire-and-forget so tool requests cannot block
// the agent. Drop stale tool events that arrive after the thread ended.
return null
}
const snapshot = resolveToolState(
state,
ampCacheKey,
extractToolFields('amp', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('amp', eventName) }
)
const interrupted =
eventName === 'agent.end' && hookPayload.status === 'cancelled' ? true : undefined
const explicitPrompt = readFirstString(hookPayload, [
'prompt',
'user_prompt',
'userPrompt',
'initial_prompt',
'initialPrompt',
'user_message'
])
const canUseMessageAsPrompt =
eventName === 'agent.start' ||
(eventName === 'agent.end' && !state.lastPromptByPaneKey.has(ampCacheKey))
const ampPromptText = explicitPrompt ?? (canUseMessageAsPrompt ? promptText : '')
const normalized = parseAgentStatusPayload(
JSON.stringify({
state: stateName,
// Why: Amp tool/result events may use `message` for tool output; only
// lifecycle events may treat it as the turn prompt.
prompt: resolvePrompt(state, ampCacheKey, ampPromptText, {
resetOnNewTurn: isNewTurnEvent('amp', eventName)
}),
agentType: 'amp',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage,
interrupted
})
)
if (normalized && eventName === 'agent.end') {
state.ampCompletedCacheKeys.add(ampCacheKey)
}
return normalized
}
function getAmpCacheKey(paneKey: string, hookPayload: Record<string, unknown>): string {
const threadId = readBoundedString(
hookPayload,
['threadId', 'threadID', 'thread_id'],
AMP_THREAD_ID_MAX_LENGTH
)
// Why: Amp plugin processes can emit events for multiple threads in one
// pane. Cache by thread internally while keeping the visible paneKey stable.
return threadId ? `${paneKey}\0amp:${threadId}` : paneKey
}
function hasExplicitPromptForSource(
source: AgentHookSource,
eventName: unknown,
promptText: string,
hookPayload: Record<string, unknown>
): boolean {
if (source !== 'amp') {
return promptText.length > 0
}
if (
readFirstString(hookPayload, [
'prompt',
'user_prompt',
'userPrompt',
'initial_prompt',
'initialPrompt',
'user_message'
])
) {
return true
}
// Why: Amp tool/result `message` is output text, not a user prompt.
return eventName === 'agent.start' && promptText.length > 0
}
function normalizeCodexEvent(
state: HookListenerState,
eventName: unknown,
@ -2573,6 +2755,9 @@ export function normalizeHookPayload(
}
payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'amp':
payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'opencode':
if (extractedPrompt.source === 'role_user_text') {
const messageId = readFirstString(hookPayloadRecord, [
@ -2656,13 +2841,18 @@ export function normalizeHookPayload(
tabId,
worktreeId,
connectionId: null,
hasExplicitPrompt: hasExplicitUserPrompt(
source,
eventName,
extractedPrompt,
resolvedPromptText,
hasTranscriptPromptEvidence
),
hasExplicitPrompt:
source === 'amp'
? hasExplicitPromptForSource(source, eventName, promptText, hookPayloadRecord)
? true
: undefined
: hasExplicitUserPrompt(
source,
eventName,
extractedPrompt,
resolvedPromptText,
hasTranscriptPromptEvidence
),
promptInteractionKey,
hookEventName: typeof eventName === 'string' ? eventName : undefined,
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
@ -2680,6 +2870,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
'/hook/codex': 'codex',
'/hook/gemini': 'gemini',
'/hook/antigravity': 'antigravity',
'/hook/amp': 'amp',
'/hook/opencode': 'opencode',
'/hook/cursor': 'cursor',
'/hook/pi': 'pi',

View File

@ -35,6 +35,7 @@ export type AgentHookSource =
| 'codex'
| 'gemini'
| 'antigravity'
| 'amp'
| 'opencode'
| 'cursor'
| 'pi'

View File

@ -8,6 +8,7 @@ export const AGENT_HOOK_TARGETS = [
'codex',
'gemini',
'antigravity',
'amp',
'cursor',
'droid',
'command-code',

View File

@ -16,6 +16,7 @@ export type WellKnownAgentType =
| 'codex'
| 'gemini'
| 'antigravity'
| 'amp'
| 'opencode'
| 'cursor'
| 'copilot'