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:
parent
0ca3b1403c
commit
6bf69c3638
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type AgentHookSource =
|
|||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'amp'
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'pi'
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const AGENT_HOOK_TARGETS = [
|
|||
'codex',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'amp',
|
||||
'cursor',
|
||||
'droid',
|
||||
'command-code',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type WellKnownAgentType =
|
|||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'amp'
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'copilot'
|
||||
|
|
|
|||
Loading…
Reference in New Issue