From 6bf69c363871688bafb3c1f736f3158cfbcc986e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Fri, 29 May 2026 20:26:58 -0600 Subject: [PATCH] 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 --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- config/tsconfig.cli.json | 1 + .../managed-agent-hook-controls.ts | 4 + .../remote-hook-service-installers.test.ts | 37 +- .../remote-managed-hook-installers.ts | 2 + src/main/agent-hooks/server.test.ts | 358 +++++++++++++++++ src/main/amp/hook-service.test.ts | 137 +++++++ src/main/amp/hook-service.ts | 367 ++++++++++++++++++ src/main/ipc/agent-hooks.test.ts | 14 + src/main/ipc/agent-hooks.ts | 15 + src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + src/renderer/src/lib/agent-status.ts | 1 + src/renderer/src/web/web-preload-api.ts | 2 + src/shared/agent-hook-listener.ts | 215 +++++++++- src/shared/agent-hook-relay.ts | 1 + src/shared/agent-hook-types.ts | 1 + src/shared/agent-status-types.ts | 1 + 17 files changed, 1145 insertions(+), 13 deletions(-) create mode 100644 src/main/amp/hook-service.test.ts create mode 100644 src/main/amp/hook-service.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 111ac7a86..6347e646c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 98df61b8a..710cf7b94 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -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()], diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 86eb35a9f..47322d0c3 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 @@ -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' + ) + }) }) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 7100bec01..944e522a0 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -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)], diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 926f24d89..5901c084f 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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', () => { diff --git a/src/main/amp/hook-service.test.ts b/src/main/amp/hook-service.test.ts new file mode 100644 index 000000000..9c83074d9 --- /dev/null +++ b/src/main/amp/hook-service.test.ts @@ -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 + 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') + }) +}) diff --git a/src/main/amp/hook-service.ts b/src/main/amp/hook-service.ts new file mode 100644 index 000000000..93b0e066f --- /dev/null +++ b/src/main/amp/hook-service.ts @@ -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 = {}', + ' 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): Promise {', + ' 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): 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 { + 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 +} diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 679fe8d56..c15d5425b 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -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') diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 11e05618e..2287db0ea 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -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() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6081437dd..2742bde3d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1383,6 +1383,7 @@ export type PreloadApi = { codexStatus: () => Promise geminiStatus: () => Promise antigravityStatus: () => Promise + ampStatus: () => Promise cursorStatus: () => Promise droidStatus: () => Promise commandCodeStatus: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 589bb6e41..8f8d72056 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1471,6 +1471,7 @@ const api = { ipcRenderer.invoke('agentHooks:geminiStatus'), antigravityStatus: (): Promise => ipcRenderer.invoke('agentHooks:antigravityStatus'), + ampStatus: (): Promise => ipcRenderer.invoke('agentHooks:ampStatus'), cursorStatus: (): Promise => ipcRenderer.invoke('agentHooks:cursorStatus'), droidStatus: (): Promise => diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 812fa6ff4..17ada953a 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -114,6 +114,7 @@ const WELL_KNOWN_LABELS: Record = { codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', + amp: 'Amp', copilot: 'GitHub Copilot', opencode: 'OpenCode', cursor: 'Cursor', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index b17139cfa..aa60e9691 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1788,6 +1788,7 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { | 'codex' | 'gemini' | 'antigravity' + | 'amp' | 'cursor' | 'droid' | 'command-code' @@ -1807,6 +1808,7 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { codexStatus: () => status('codex'), geminiStatus: () => status('gemini'), antigravityStatus: () => status('antigravity'), + ampStatus: () => status('amp'), cursorStatus: () => status('cursor'), droidStatus: () => status('droid'), commandCodeStatus: () => status('command-code'), diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 619f50c1f..3d79cd9cd 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -59,6 +59,7 @@ export type HookListenerState = { lastToolByPaneKey: Map lastStatusByPaneKey: Map antigravityCompletedTranscriptByPaneKey: Map + ampCompletedCacheKeys: Set } 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, 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, 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 +): 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 @@ -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 +): 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 { + 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 +): 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> '/hook/codex': 'codex', '/hook/gemini': 'gemini', '/hook/antigravity': 'antigravity', + '/hook/amp': 'amp', '/hook/opencode': 'opencode', '/hook/cursor': 'cursor', '/hook/pi': 'pi', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 12cc21440..9c81a3e4b 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -35,6 +35,7 @@ export type AgentHookSource = | 'codex' | 'gemini' | 'antigravity' + | 'amp' | 'opencode' | 'cursor' | 'pi' diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 911c5672f..7511eaf39 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -8,6 +8,7 @@ export const AGENT_HOOK_TARGETS = [ 'codex', 'gemini', 'antigravity', + 'amp', 'cursor', 'droid', 'command-code', diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 489c7a5cc..05f0d8c6b 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -16,6 +16,7 @@ export type WellKnownAgentType = | 'codex' | 'gemini' | 'antigravity' + | 'amp' | 'opencode' | 'cursor' | 'copilot'