Add Antigravity agent support (#2389)
This commit is contained in:
parent
3299635013
commit
8afdbea256
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<p align="center">
|
||||
<strong>The AI Orchestrator for 100x builders.</strong><br/>
|
||||
Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
|
||||
Run Claude Code, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
|
||||
Available for <strong>macOS, Windows, and Linux</strong>.
|
||||
</p>
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
|
|||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
|
|
@ -59,7 +60,7 @@ Orca supports any CLI agent (*not just this list*).
|
|||
|
||||
## Features
|
||||
|
||||
- **No login required** — Bring your own Claude Code, Codex, or Grok subscription.
|
||||
- **No login required** — Bring your own Claude Code, Codex, Grok, or Antigravity subscription.
|
||||
- **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly.
|
||||
- **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance.
|
||||
- **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- Why: this fixture verifies the shared remote hook installer fake across every managed agent so SSH regressions are caught together. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ vi.mock('electron', () => ({
|
|||
import { CodexHookService } from '../codex/hook-service'
|
||||
import { CursorHookService } from '../cursor/hook-service'
|
||||
import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { AntigravityHookService } from '../antigravity/hook-service'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
import { GrokHookService } from '../grok/hook-service'
|
||||
import { CopilotHookService } from '../copilot/hook-service'
|
||||
|
|
@ -124,6 +126,11 @@ describe('remote hook service installers', () => {
|
|||
path: '/home/dev/.orca/agent-hooks/gemini-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/antigravity-hook.sh',
|
||||
install: (sftp: SFTPWrapper) =>
|
||||
new AntigravityHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
|
||||
|
|
@ -196,12 +203,14 @@ describe('remote hook service installers', () => {
|
|||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
})
|
||||
|
||||
it('installs remote Gemini, Cursor, and Grok configs using their CLI-specific schemas', async () => {
|
||||
it('installs remote Gemini, Antigravity, Cursor, and Grok configs using their CLI-specific schemas', async () => {
|
||||
const gemini = createFakeSftp()
|
||||
const antigravity = createFakeSftp()
|
||||
const cursor = createFakeSftp()
|
||||
const grok = createFakeSftp()
|
||||
|
||||
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
|
||||
await new AntigravityHookService().installRemote(antigravity.sftp, '/home/dev')
|
||||
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
|
||||
await new GrokHookService().installRemote(grok.sftp, '/home/dev')
|
||||
|
||||
|
|
@ -214,6 +223,27 @@ describe('remote hook service installers', () => {
|
|||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
|
||||
const antigravityConfig = JSON.parse(
|
||||
antigravity.fs.files.get('/home/dev/.gemini/config/hooks.json')!
|
||||
) as {
|
||||
'orca-status': Record<
|
||||
string,
|
||||
{ matcher?: string; command?: string; hooks?: { command: string }[] }[]
|
||||
>
|
||||
}
|
||||
for (const eventName of ['PreInvocation', 'PostInvocation', 'Stop']) {
|
||||
const command = antigravityConfig['orca-status'][eventName]?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
}
|
||||
for (const eventName of ['PreToolUse', 'PostToolUse']) {
|
||||
const definition = antigravityConfig['orca-status'][eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(definition?.matcher).toBe('*')
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
}
|
||||
|
||||
const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as {
|
||||
version: number
|
||||
hooks: Record<string, { command?: string; hooks?: unknown[] }[]>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
|||
import { claudeHookService } from '../claude/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import { geminiHookService } from '../gemini/hook-service'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
|
|
@ -16,6 +17,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
|
|||
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
|
||||
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
|
||||
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
|
||||
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
|
||||
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
|
||||
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],
|
||||
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { 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 { AntigravityHookService } from './hook-service'
|
||||
|
||||
describe('AntigravityHookService', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-antigravity-home-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs Antigravity global hooks.json bundle and managed script', () => {
|
||||
const status = new AntigravityHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe(join(homeDir, '.gemini', 'config', 'hooks.json'))
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(homeDir, '.gemini', 'config', 'hooks.json'), 'utf8')
|
||||
) as {
|
||||
'orca-status': Record<
|
||||
string,
|
||||
{ matcher?: string; command?: string; hooks?: { command: string }[] }[]
|
||||
>
|
||||
}
|
||||
expect(Object.keys(config['orca-status']).sort()).toEqual(
|
||||
['PostInvocation', 'PostToolUse', 'PreInvocation', 'PreToolUse', 'Stop'].sort()
|
||||
)
|
||||
expect(config['orca-status'].PreToolUse[0].matcher).toBe('*')
|
||||
expect(config['orca-status'].PostToolUse[0].matcher).toBe('*')
|
||||
expect(config['orca-status'].PreInvocation[0].command).toContain('antigravity-hook')
|
||||
expect(config['orca-status'].PreInvocation[0].command).toContain(
|
||||
"ORCA_ANTIGRAVITY_EVENT='PreInvocation'"
|
||||
)
|
||||
expect(config['orca-status'].Stop[0].command).toContain("ORCA_ANTIGRAVITY_EVENT='Stop'")
|
||||
|
||||
const script = readFileSync(
|
||||
join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.sh'),
|
||||
'utf8'
|
||||
)
|
||||
expect(script).toContain('/hook/antigravity')
|
||||
expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}')
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain('{"decision":""}')
|
||||
})
|
||||
|
||||
it('preserves user-authored hook bundles and entries in Orca bundle', () => {
|
||||
const configPath = join(homeDir, '.gemini', 'config', 'hooks.json')
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
'user-hook': {
|
||||
PreInvocation: [{ type: 'command', command: '/usr/local/bin/user-hook' }]
|
||||
},
|
||||
'orca-status': {
|
||||
PreInvocation: [{ type: 'command', command: '/usr/local/bin/orca-extra' }]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
|
||||
new AntigravityHookService().install()
|
||||
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
'user-hook': { PreInvocation: { command: string }[] }
|
||||
'orca-status': { PreInvocation: { command: string }[] }
|
||||
}
|
||||
expect(config['user-hook'].PreInvocation[0].command).toBe('/usr/local/bin/user-hook')
|
||||
const commands = config['orca-status'].PreInvocation.map((entry) => entry.command)
|
||||
expect(commands).toContain('/usr/local/bin/orca-extra')
|
||||
expect(commands.some((command) => command.includes('antigravity-hook.sh'))).toBe(true)
|
||||
})
|
||||
|
||||
it('removes stale managed Antigravity hook entries from retired events', () => {
|
||||
const configPath = join(homeDir, '.gemini', 'config', 'hooks.json')
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
'orca-status': {
|
||||
OldEvent: [
|
||||
{
|
||||
type: 'command',
|
||||
command: '/tmp/old/agent-hooks/antigravity-hook.sh'
|
||||
}
|
||||
],
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: '/tmp/old/agent-hooks/antigravity-hook.sh' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
|
||||
new AntigravityHookService().install()
|
||||
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
'orca-status': Record<string, { command?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
expect(config['orca-status'].OldEvent).toBeUndefined()
|
||||
const commands = config['orca-status'].PreToolUse.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
expect(commands).toHaveLength(1)
|
||||
expect(commands[0]).toContain(join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.sh'))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
|
||||
const ANTIGRAVITY_HOOK_BUNDLE_NAME = 'orca-status'
|
||||
|
||||
const ANTIGRAVITY_EVENTS = [
|
||||
{ eventName: 'PreInvocation', schema: 'direct' },
|
||||
{ eventName: 'PostInvocation', schema: 'direct' },
|
||||
{ eventName: 'Stop', schema: 'direct' },
|
||||
{ eventName: 'PreToolUse', schema: 'tool' },
|
||||
{ eventName: 'PostToolUse', schema: 'tool' }
|
||||
] as const
|
||||
|
||||
type AntigravityEvent = (typeof ANTIGRAVITY_EVENTS)[number]
|
||||
|
||||
function getConfigPath(): string {
|
||||
// Why: Antigravity's hook docs define global hooks in ~/.gemini/config/hooks.json,
|
||||
// not in the CLI settings file used by Gemini CLI.
|
||||
return join(homedir(), '.gemini', 'config', 'hooks.json')
|
||||
}
|
||||
|
||||
function getManagedScriptFileName(): string {
|
||||
return process.platform === 'win32' ? 'antigravity-hook.cmd' : 'antigravity-hook.sh'
|
||||
}
|
||||
|
||||
function getManagedScriptPath(): string {
|
||||
return getSharedManagedScriptPath(getManagedScriptFileName())
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string, eventName: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
return `cmd /d /s /c "set "ORCA_ANTIGRAVITY_EVENT=${eventName}" && call "${scriptPath}""`
|
||||
}
|
||||
return wrapPosixHookCommand(scriptPath, { ORCA_ANTIGRAVITY_EVENT: eventName })
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if /I "%ORCA_ANTIGRAVITY_EVENT%"=="Stop" (',
|
||||
' echo {"decision":""}',
|
||||
') else (',
|
||||
' echo {}',
|
||||
')',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
buildWindowsAntigravityHookPostCommand(),
|
||||
'exit /b 0',
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
'case "$ORCA_ANTIGRAVITY_EVENT" in',
|
||||
' Stop)',
|
||||
' printf \'{"decision":""}\\n\'',
|
||||
' ;;',
|
||||
' *)',
|
||||
// Why: Antigravity accepts an empty JSON object for passive status hooks;
|
||||
// returning allow/ask/deny from PreToolUse would change the user's tool
|
||||
// permission policy.
|
||||
' printf "{}\\n"',
|
||||
' ;;',
|
||||
'esac',
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'payload=$(cat)',
|
||||
'if [ -z "$payload" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildWindowsAntigravityHookPostCommand(): string {
|
||||
return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes | Out-Null } catch {}"`
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getBundle(config: HooksConfig): Record<string, unknown> {
|
||||
const existing = config[ANTIGRAVITY_HOOK_BUNDLE_NAME]
|
||||
return isRecord(existing) ? { ...existing } : {}
|
||||
}
|
||||
|
||||
function hasManagedCommand(definitions: HookDefinition[], command: string): boolean {
|
||||
return definitions.some(
|
||||
(definition) =>
|
||||
definition.command === command ||
|
||||
(Array.isArray(definition.hooks) && definition.hooks.some((hook) => hook.command === command))
|
||||
)
|
||||
}
|
||||
|
||||
function buildEventDefinition(event: AntigravityEvent, command: string): HookDefinition {
|
||||
if (event.schema === 'tool') {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command }]
|
||||
}
|
||||
}
|
||||
return { type: 'command', command }
|
||||
}
|
||||
|
||||
function removeManagedCommandsFromBundle(
|
||||
bundle: Record<string, unknown>,
|
||||
isManagedCommand: (command: string | undefined) => boolean
|
||||
): Record<string, unknown> {
|
||||
const next = { ...bundle }
|
||||
for (const [eventName, definitions] of Object.entries(next)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions as HookDefinition[], isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete next[eventName]
|
||||
} else {
|
||||
next[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function buildInstalledConfig(
|
||||
config: HooksConfig,
|
||||
commandForEvent: (eventName: string) => string,
|
||||
scriptFileName: string
|
||||
): void {
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
const bundle = removeManagedCommandsFromBundle(getBundle(config), isManagedCommand)
|
||||
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const current = Array.isArray(bundle[event.eventName])
|
||||
? (bundle[event.eventName] as HookDefinition[])
|
||||
: []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
bundle[event.eventName] = [
|
||||
...cleaned,
|
||||
buildEventDefinition(event, commandForEvent(event.eventName))
|
||||
]
|
||||
}
|
||||
|
||||
config[ANTIGRAVITY_HOOK_BUNDLE_NAME] = bundle
|
||||
}
|
||||
|
||||
function removeInstalledConfig(config: HooksConfig, scriptFileName: string): void {
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
const bundle = removeManagedCommandsFromBundle(getBundle(config), isManagedCommand)
|
||||
if (Object.keys(bundle).length === 0) {
|
||||
delete config[ANTIGRAVITY_HOOK_BUNDLE_NAME]
|
||||
return
|
||||
}
|
||||
config[ANTIGRAVITY_HOOK_BUNDLE_NAME] = bundle
|
||||
}
|
||||
|
||||
export class AntigravityHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
const bundle = getBundle(config)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const definitions = Array.isArray(bundle[event.eventName])
|
||||
? (bundle[event.eventName] as HookDefinition[])
|
||||
: []
|
||||
if (hasManagedCommand(definitions, getManagedCommand(scriptPath, event.eventName))) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'antigravity', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
buildInstalledConfig(
|
||||
config,
|
||||
(eventName) => getManagedCommand(scriptPath, eventName),
|
||||
getManagedScriptFileName()
|
||||
)
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const remoteConfigPath = `${home}/.gemini/config/hooks.json`
|
||||
const remoteScriptPath = `${home}/.orca/agent-hooks/antigravity-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Antigravity hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
buildInstalledConfig(
|
||||
config,
|
||||
(eventName) =>
|
||||
wrapPosixHookCommand(remoteScriptPath, { ORCA_ANTIGRAVITY_EVENT: eventName }),
|
||||
'antigravity-hook.sh'
|
||||
)
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
removeInstalledConfig(config, getManagedScriptFileName())
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
}
|
||||
|
||||
export const antigravityHookService = new AntigravityHookService()
|
||||
|
|
@ -57,6 +57,7 @@ import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsu
|
|||
import { claudeHookService } from './claude/hook-service'
|
||||
import { codexHookService } from './codex/hook-service'
|
||||
import { geminiHookService } from './gemini/hook-service'
|
||||
import { antigravityHookService } from './antigravity/hook-service'
|
||||
import { cursorHookService } from './cursor/hook-service'
|
||||
import { droidHookService } from './droid/hook-service'
|
||||
import { grokHookService } from './grok/hook-service'
|
||||
|
|
@ -899,6 +900,7 @@ app.whenReady().then(async () => {
|
|||
['claude', () => claudeHookService.install()],
|
||||
['codex', () => codexHookService.install()],
|
||||
['gemini', () => geminiHookService.install()],
|
||||
['antigravity', () => antigravityHookService.install()],
|
||||
['cursor', () => cursorHookService.install()],
|
||||
['droid', () => droidHookService.install()],
|
||||
['grok', () => grokHookService.install()],
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ vi.mock('../codex/hook-service', () => ({
|
|||
vi.mock('../gemini/hook-service', () => ({
|
||||
geminiHookService: { getStatus: vi.fn(() => ({ agent: 'gemini', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../antigravity/hook-service', () => ({
|
||||
antigravityHookService: { getStatus: vi.fn(() => ({ agent: 'antigravity', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../cursor/hook-service', () => ({
|
||||
cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) }
|
||||
}))
|
||||
|
|
@ -101,6 +104,17 @@ describe('agentStatus:getSnapshot IPC', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('agentHooks:antigravityStatus IPC', () => {
|
||||
it('returns Antigravity hook installation status', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:antigravityStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'antigravity', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentStatus:inferInterrupt IPC', () => {
|
||||
it('forwards valid inference requests to the hook server', async () => {
|
||||
inferInterrupt.mockReturnValue(true)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { claudeHookService } from '../claude/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import { geminiHookService } from '../gemini/hook-service'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
|
|
@ -33,6 +34,7 @@ export function registerAgentHookHandlers(): void {
|
|||
ipcMain.removeHandler('agentHooks:claudeStatus')
|
||||
ipcMain.removeHandler('agentHooks:codexStatus')
|
||||
ipcMain.removeHandler('agentHooks:geminiStatus')
|
||||
ipcMain.removeHandler('agentHooks:antigravityStatus')
|
||||
ipcMain.removeHandler('agentHooks:cursorStatus')
|
||||
ipcMain.removeHandler('agentHooks:droidStatus')
|
||||
ipcMain.removeHandler('agentHooks:grokStatus')
|
||||
|
|
@ -121,6 +123,19 @@ export function registerAgentHookHandlers(): void {
|
|||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:antigravityStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return antigravityHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:cursorStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return cursorHookService.getStatus()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const AGENT_TYPE_LABELS: Readonly<Record<string, string>> = {
|
|||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
opencode: 'OpenCode',
|
||||
cursor: 'Cursor',
|
||||
aider: 'Aider',
|
||||
|
|
|
|||
|
|
@ -1214,6 +1214,7 @@ export type PreloadApi = {
|
|||
claudeStatus: () => Promise<AgentHookInstallStatus>
|
||||
codexStatus: () => Promise<AgentHookInstallStatus>
|
||||
geminiStatus: () => Promise<AgentHookInstallStatus>
|
||||
antigravityStatus: () => Promise<AgentHookInstallStatus>
|
||||
cursorStatus: () => Promise<AgentHookInstallStatus>
|
||||
droidStatus: () => Promise<AgentHookInstallStatus>
|
||||
grokStatus: () => Promise<AgentHookInstallStatus>
|
||||
|
|
|
|||
|
|
@ -1255,6 +1255,8 @@ const api = {
|
|||
ipcRenderer.invoke('agentHooks:codexStatus'),
|
||||
geminiStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:geminiStatus'),
|
||||
antigravityStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:antigravityStatus'),
|
||||
cursorStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:cursorStatus'),
|
||||
droidStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from '../../../../shared/agent-detection'
|
||||
|
||||
const TITLE_AGENT_TOKEN_RE =
|
||||
/(?<![\w./\\-])(claude|codex|gemini|opencode|openclaw|aider|copilot|cursor-agent|cursor|droid|hermes|grok|pi)(?![\w./\\-])/i
|
||||
/(?<![\w./\\-])(claude|codex|gemini|antigravity|agy|opencode|openclaw|aider|copilot|cursor-agent|cursor|droid|hermes|grok|pi)(?![\w./\\-])/i
|
||||
|
||||
export function titleHasExplicitAgentIdentity(title: string): boolean {
|
||||
if (!title) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
|
|||
faviconDomain: 'gemini.google.com',
|
||||
homepageUrl: 'https://github.com/google-gemini/gemini-cli'
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
cmd: 'agy',
|
||||
faviconDomain: 'antigravity.google',
|
||||
homepageUrl: 'https://antigravity.google/docs/cli-overview'
|
||||
},
|
||||
{
|
||||
id: 'aider',
|
||||
label: 'Aider',
|
||||
|
|
|
|||
|
|
@ -389,6 +389,8 @@ describe('getAgentLabel', () => {
|
|||
expect(getAgentLabel('✦ Gemini CLI')).toBe('Gemini CLI')
|
||||
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')
|
||||
expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex')
|
||||
expect(getAgentLabel('Antigravity running')).toBe('Antigravity')
|
||||
expect(getAgentLabel('agy working')).toBe('Antigravity')
|
||||
expect(getAgentLabel('Grok running')).toBe('Grok')
|
||||
expect(getAgentLabel('⠋ Droid')).toBe('Droid')
|
||||
expect(getAgentLabel('Droid ready')).toBe('Droid')
|
||||
|
|
@ -703,6 +705,10 @@ describe('formatAgentTypeLabel', () => {
|
|||
expect(formatAgentTypeLabel('gemini')).toBe('Gemini')
|
||||
})
|
||||
|
||||
it("maps 'antigravity' to 'Antigravity'", () => {
|
||||
expect(formatAgentTypeLabel('antigravity')).toBe('Antigravity')
|
||||
})
|
||||
|
||||
it("maps 'cursor' to 'Cursor'", () => {
|
||||
expect(formatAgentTypeLabel('cursor')).toBe('Cursor')
|
||||
})
|
||||
|
|
@ -731,6 +737,7 @@ describe('agentTypeToIconAgent', () => {
|
|||
|
||||
it("round-trips iconable agent types like 'claude'", () => {
|
||||
expect(agentTypeToIconAgent('claude')).toBe('claude')
|
||||
expect(agentTypeToIconAgent('antigravity')).toBe('antigravity')
|
||||
})
|
||||
|
||||
it('returns null for arbitrary non-iconable strings', () => {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
|
|||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
copilot: 'GitHub Copilot',
|
||||
opencode: 'OpenCode',
|
||||
cursor: 'Cursor',
|
||||
|
|
@ -151,6 +152,7 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
|
|||
opencode: true,
|
||||
pi: true,
|
||||
gemini: true,
|
||||
antigravity: true,
|
||||
aider: true,
|
||||
goose: true,
|
||||
amp: true,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,22 @@ describe('buildAgentStartupPlan', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('uses Antigravity interactive prompt mode with the agy binary', () => {
|
||||
expect(
|
||||
buildAgentStartupPlan({
|
||||
agent: 'antigravity',
|
||||
prompt: 'Investigate this regression',
|
||||
cmdOverrides: {},
|
||||
platform: 'linux'
|
||||
})
|
||||
).toEqual({
|
||||
agent: 'antigravity',
|
||||
launchCommand: "agy --prompt-interactive 'Investigate this regression'",
|
||||
expectedProcess: 'agy',
|
||||
followupPrompt: null
|
||||
})
|
||||
})
|
||||
|
||||
it('launches aider first and injects the draft prompt after startup', () => {
|
||||
expect(
|
||||
buildAgentStartupPlan({
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ const SHELL_PROCESS_NAMES = new Set([
|
|||
const AGENT_PROCESS_NAMES = new Set([
|
||||
'aider',
|
||||
'amp',
|
||||
'agy',
|
||||
'claude',
|
||||
'claude-code',
|
||||
'codex',
|
||||
|
|
|
|||
|
|
@ -957,7 +957,16 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
|
|||
|
||||
function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
||||
const status = (
|
||||
agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok' | 'copilot' | 'hermes'
|
||||
agent:
|
||||
| 'claude'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'cursor'
|
||||
| 'droid'
|
||||
| 'grok'
|
||||
| 'copilot'
|
||||
| 'hermes'
|
||||
) =>
|
||||
Promise.resolve({
|
||||
agent,
|
||||
|
|
@ -970,6 +979,7 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
|||
claudeStatus: () => status('claude'),
|
||||
codexStatus: () => status('codex'),
|
||||
geminiStatus: () => status('gemini'),
|
||||
antigravityStatus: () => status('antigravity'),
|
||||
cursorStatus: () => status('cursor'),
|
||||
droidStatus: () => status('droid'),
|
||||
grokStatus: () => status('grok'),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const AGENT_NAMES = [
|
|||
'copilot',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'opencode',
|
||||
'openclaw',
|
||||
'aider',
|
||||
|
|
@ -41,6 +42,7 @@ const DROID_AGENT_NAME_RE = /(?<![\w./\\-])droid(?![\w./\\-])/i
|
|||
// substring list because cwd/path titles like `~/hermes/working` would
|
||||
// otherwise count as agent activity.
|
||||
const HERMES_AGENT_NAME_RE = /(?<![\w./\\-])hermes(?![\w./\\-])/i
|
||||
const AGY_AGENT_NAME_RE = /(?<![\w./\\-])agy(?![\w./\\-])/i
|
||||
|
||||
// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles
|
||||
// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'.
|
||||
|
|
@ -174,6 +176,7 @@ function containsLegacyAgentName(title: string): boolean {
|
|||
function containsAgentName(title: string): boolean {
|
||||
return (
|
||||
containsLegacyAgentName(title) ||
|
||||
AGY_AGENT_NAME_RE.test(title) ||
|
||||
DROID_AGENT_NAME_RE.test(title) ||
|
||||
HERMES_AGENT_NAME_RE.test(title)
|
||||
)
|
||||
|
|
@ -367,6 +370,9 @@ export function getAgentLabel(title: string): string | null {
|
|||
if (lower.includes('grok')) {
|
||||
return 'Grok'
|
||||
}
|
||||
if (lower.includes('antigravity') || AGY_AGENT_NAME_RE.test(title)) {
|
||||
return 'Antigravity'
|
||||
}
|
||||
if (lower.includes('opencode')) {
|
||||
return 'OpenCode'
|
||||
}
|
||||
|
|
@ -446,8 +452,9 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
|||
|
||||
const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title)
|
||||
const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title)
|
||||
const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title)
|
||||
const hasLegacyAgentName = containsLegacyAgentName(title)
|
||||
if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName) {
|
||||
if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) {
|
||||
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
|
||||
return 'permission'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ describe('shared agent-hook-listener', () => {
|
|||
it('routes pathnames to a known source or null', () => {
|
||||
expect(resolveHookSource('/hook/claude')).toBe('claude')
|
||||
expect(resolveHookSource('/hook/cursor')).toBe('cursor')
|
||||
expect(resolveHookSource('/hook/antigravity')).toBe('antigravity')
|
||||
expect(resolveHookSource('/hook/grok')).toBe('grok')
|
||||
expect(resolveHookSource('/hook/hermes')).toBe('hermes')
|
||||
expect(resolveHookSource('/hook/unknown')).toBeNull()
|
||||
|
|
@ -131,6 +132,203 @@ describe('shared agent-hook-listener', () => {
|
|||
expect(event!.payload.prompt).toBe('')
|
||||
})
|
||||
|
||||
it('normalizes Antigravity invocation and tool hooks', () => {
|
||||
const started = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt',
|
||||
hook_event_name: 'PreInvocation',
|
||||
payload: { prompt: 'run tests' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(started?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'run tests',
|
||||
agentType: 'antigravity'
|
||||
})
|
||||
|
||||
const tool = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
hook_event_name: 'PreToolUse',
|
||||
payload: {
|
||||
toolCall: {
|
||||
name: 'run_command',
|
||||
args: { CommandLine: 'pnpm test' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(tool?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'run tests',
|
||||
agentType: 'antigravity',
|
||||
toolName: 'run_command',
|
||||
toolInput: 'pnpm test'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps Antigravity feedback tools to waiting state', () => {
|
||||
const question = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
hook_event_name: 'PreToolUse',
|
||||
payload: {
|
||||
toolCall: {
|
||||
name: 'ask_question',
|
||||
args: { Prompt: 'Which path should I use?' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(question?.payload).toMatchObject({
|
||||
state: 'waiting',
|
||||
agentType: 'antigravity',
|
||||
toolName: 'ask_question',
|
||||
toolInput: 'Which path should I use?'
|
||||
})
|
||||
|
||||
const permission = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
hook_event_name: 'PreToolUse',
|
||||
payload: {
|
||||
toolCall: {
|
||||
name: 'ask_permission',
|
||||
args: { Action: 'run command', Target: 'pnpm lint' }
|
||||
}
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(permission?.payload).toMatchObject({
|
||||
state: 'waiting',
|
||||
agentType: 'antigravity',
|
||||
toolName: 'ask_permission',
|
||||
toolInput: 'run command'
|
||||
})
|
||||
})
|
||||
|
||||
it('resets Antigravity tool state on a new invocation', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
hook_event_name: 'PreToolUse',
|
||||
payload: {
|
||||
toolCall: { name: 'run_command', args: { CommandLine: 'pnpm test' } }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
const nextTurn = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
hook_event_name: 'PreInvocation',
|
||||
payload: { prompt: 'new task' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(nextTurn?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'new task',
|
||||
agentType: 'antigravity'
|
||||
})
|
||||
expect(nextTurn?.payload.toolName).toBeUndefined()
|
||||
expect(nextTurn?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes Antigravity Stop hooks and reads final text from the transcript', () => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-antigravity-transcript-'))
|
||||
const transcriptPath = join(tmpDir, 'transcript.jsonl')
|
||||
try {
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
`${[
|
||||
JSON.stringify({ source: 'USER', type: 'REQUEST', content: 'hi' }),
|
||||
JSON.stringify({
|
||||
source: 'MODEL',
|
||||
type: 'PLANNER_RESPONSE',
|
||||
content: 'Antigravity is wired up.'
|
||||
})
|
||||
].join('\n')}\n`
|
||||
)
|
||||
|
||||
const done = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
hook_event_name: 'Stop',
|
||||
payload: { fullyIdle: true, transcriptPath }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(done?.payload).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'antigravity',
|
||||
lastAssistantMessage: 'Antigravity is wired up.'
|
||||
})
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Antigravity working when Stop reports the agent is not fully idle', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'antigravity',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
hook_event_name: 'Stop',
|
||||
payload: { fullyIdle: false }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
|
||||
expect(event?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
agentType: 'antigravity'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats Antigravity Stop transcripts as pending result text', () => {
|
||||
expect(
|
||||
hasPendingAgentResultText('antigravity', {
|
||||
hook_event_name: 'Stop',
|
||||
payload: { transcriptPath: '/tmp/antigravity-transcript.jsonl' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasPendingAgentResultText('antigravity', {
|
||||
hook_event_name: 'Stop',
|
||||
payload: {
|
||||
transcriptPath: '/tmp/antigravity-transcript.jsonl',
|
||||
last_assistant_message: 'done'
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes Grok hookEventName payloads and keeps prompt across tool events', () => {
|
||||
const prompt = normalizeHookPayload(
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
|
|||
edit_file: ['file_path', 'path'],
|
||||
replace: ['file_path', 'path'],
|
||||
run_shell_command: ['command'],
|
||||
run_command: ['CommandLine', 'command', 'cmd'],
|
||||
glob: ['pattern'],
|
||||
search_file_content: ['pattern'],
|
||||
web_fetch: ['url'],
|
||||
|
|
@ -353,7 +354,20 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
|
|||
browser_type: ['text', 'target', 'selector'],
|
||||
session_search: ['query'],
|
||||
skill_manage: ['action', 'name', 'file_path'],
|
||||
delegate_task: ['task', 'prompt', 'description']
|
||||
delegate_task: ['task', 'prompt', 'description'],
|
||||
view_file: ['AbsolutePath', 'path', 'file_path'],
|
||||
write_to_file: ['TargetFile', 'path', 'file_path'],
|
||||
replace_file_content: ['TargetFile', 'path', 'file_path'],
|
||||
multi_replace_file_content: ['TargetFile', 'path', 'file_path'],
|
||||
list_dir: ['DirectoryPath', 'path'],
|
||||
find_by_name: ['SearchDirectory', 'Pattern', 'query'],
|
||||
grep_search: ['SearchPath', 'Query', 'query', 'pattern'],
|
||||
search_web: ['query'],
|
||||
read_url_content: ['Url', 'url'],
|
||||
manage_task: ['TaskId', 'Action'],
|
||||
schedule: ['Prompt', 'DurationSeconds', 'CronExpression'],
|
||||
ask_question: ['question', 'questions'],
|
||||
ask_permission: ['Action', 'Target', 'Reason']
|
||||
}
|
||||
|
||||
const FALLBACK_TOOL_INPUT_KEYS = [
|
||||
|
|
@ -371,7 +385,15 @@ const FALLBACK_TOOL_INPUT_KEYS = [
|
|||
'text',
|
||||
'action',
|
||||
'name',
|
||||
'description'
|
||||
'description',
|
||||
'CommandLine',
|
||||
'AbsolutePath',
|
||||
'TargetFile',
|
||||
'DirectoryPath',
|
||||
'SearchPath',
|
||||
'Query',
|
||||
'Url',
|
||||
'Prompt'
|
||||
] as const
|
||||
|
||||
function deriveToolInputPreview(
|
||||
|
|
@ -501,6 +523,14 @@ function extractAssistantTextFromLine(line: string): string | undefined {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.source === 'MODEL' &&
|
||||
record.type === 'PLANNER_RESPONSE' &&
|
||||
typeof record.content === 'string' &&
|
||||
record.content.trim().length > 0
|
||||
) {
|
||||
return record.content
|
||||
}
|
||||
const nestedMessage = record.message as Record<string, unknown> | undefined
|
||||
const role =
|
||||
record.role ?? nestedMessage?.role ?? (record.type === 'assistant' ? 'assistant' : undefined)
|
||||
|
|
@ -603,6 +633,8 @@ function readLastAssistantFromGrokChatHistory(
|
|||
}
|
||||
|
||||
export function hasPendingAgentResultText(source: AgentHookSource, body: unknown): boolean {
|
||||
const envelope =
|
||||
typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : null
|
||||
const record = parseHookBodyPayloadRecord(body)
|
||||
if (!record) {
|
||||
return false
|
||||
|
|
@ -616,6 +648,15 @@ export function hasPendingAgentResultText(source: AgentHookSource, body: unknown
|
|||
const transcriptPath = record.transcript_path ?? record.transcriptPath
|
||||
return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0
|
||||
}
|
||||
const eventName =
|
||||
envelope?.hook_event_name ??
|
||||
envelope?.hookEventName ??
|
||||
record.hook_event_name ??
|
||||
record.hookEventName
|
||||
if (source === 'antigravity' && eventName === 'Stop') {
|
||||
const transcriptPath = record.transcriptPath ?? record.transcript_path
|
||||
return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0
|
||||
}
|
||||
if (
|
||||
source === 'grok' &&
|
||||
isGrokEvent(record.hookEventName ?? record.hook_event_name, 'stop', 'session_end')
|
||||
|
|
@ -781,6 +822,44 @@ function extractGeminiToolFields(
|
|||
return {}
|
||||
}
|
||||
|
||||
function readAntigravityToolCall(hookPayload: Record<string, unknown>): {
|
||||
toolName?: string
|
||||
toolInputSource?: unknown
|
||||
} {
|
||||
const toolCall = hookPayload.toolCall
|
||||
if (typeof toolCall !== 'object' || toolCall === null) {
|
||||
return {}
|
||||
}
|
||||
const record = toolCall as Record<string, unknown>
|
||||
return {
|
||||
toolName: readFirstString(record, ['name', 'toolName', 'tool_name']),
|
||||
toolInputSource: record.args
|
||||
}
|
||||
}
|
||||
|
||||
function extractAntigravityToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ToolSnapshot {
|
||||
if (eventName === 'PreToolUse' || eventName === 'PostToolUse') {
|
||||
const toolCall = readAntigravityToolCall(hookPayload)
|
||||
const toolName = toolCall.toolName
|
||||
const toolInput =
|
||||
deriveToolInputPreview(toolName, toolCall.toolInputSource) ??
|
||||
deriveFallbackToolInputPreview(toolCall.toolInputSource)
|
||||
return { toolName, toolInput }
|
||||
}
|
||||
if (eventName === 'Stop') {
|
||||
const message =
|
||||
readString(hookPayload, 'last_assistant_message') ??
|
||||
readLastAssistantFromTranscript(hookPayload.transcriptPath ?? hookPayload.transcript_path)
|
||||
if (message) {
|
||||
return { lastAssistantMessage: message }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function extractOpenCodeToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
|
|
@ -1286,6 +1365,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
|||
return eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
|
||||
case 'gemini':
|
||||
return eventName === 'BeforeAgent'
|
||||
case 'antigravity':
|
||||
return eventName === 'PreInvocation'
|
||||
case 'opencode':
|
||||
return false
|
||||
case 'cursor':
|
||||
|
|
@ -1324,6 +1405,8 @@ function extractToolFields(
|
|||
return extractCodexToolFields(eventName, hookPayload)
|
||||
case 'gemini':
|
||||
return extractGeminiToolFields(eventName, hookPayload)
|
||||
case 'antigravity':
|
||||
return extractAntigravityToolFields(eventName, hookPayload)
|
||||
case 'opencode':
|
||||
return extractOpenCodeToolFields(eventName, hookPayload)
|
||||
case 'cursor':
|
||||
|
|
@ -1436,6 +1519,57 @@ function normalizeGeminiEvent(
|
|||
)
|
||||
}
|
||||
|
||||
function isAntigravityFeedbackTool(toolName: string | undefined): boolean {
|
||||
return toolName === 'ask_question' || toolName === 'ask_permission'
|
||||
}
|
||||
|
||||
function normalizeAntigravityEvent(
|
||||
state: HookListenerState,
|
||||
eventName: unknown,
|
||||
promptText: string,
|
||||
paneKey: string,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ParsedAgentStatusPayload | null {
|
||||
const toolName = readAntigravityToolCall(hookPayload).toolName
|
||||
const stateName =
|
||||
eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName)
|
||||
? 'waiting'
|
||||
: eventName === 'Stop'
|
||||
? hookPayload.fullyIdle === false
|
||||
? 'working'
|
||||
: 'done'
|
||||
: eventName === 'PreInvocation' ||
|
||||
eventName === 'PostInvocation' ||
|
||||
eventName === 'PreToolUse' ||
|
||||
eventName === 'PostToolUse'
|
||||
? 'working'
|
||||
: null
|
||||
|
||||
if (!stateName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = resolveToolState(
|
||||
state,
|
||||
paneKey,
|
||||
extractToolFields('antigravity', eventName, hookPayload),
|
||||
{ resetOnNewTurn: isNewTurnEvent('antigravity', eventName) }
|
||||
)
|
||||
|
||||
return parseAgentStatusPayload(
|
||||
JSON.stringify({
|
||||
state: stateName,
|
||||
prompt: resolvePrompt(state, paneKey, promptText, {
|
||||
resetOnNewTurn: isNewTurnEvent('antigravity', eventName)
|
||||
}),
|
||||
agentType: 'antigravity',
|
||||
toolName: snapshot.toolName,
|
||||
toolInput: snapshot.toolInput,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCodexEvent(
|
||||
state: HookListenerState,
|
||||
eventName: unknown,
|
||||
|
|
@ -1952,6 +2086,9 @@ export function normalizeHookPayload(
|
|||
case 'gemini':
|
||||
payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
case 'antigravity':
|
||||
payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
case 'opencode':
|
||||
payload = normalizeOpenCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
|
|
@ -2002,6 +2139,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
|
|||
'/hook/claude': 'claude',
|
||||
'/hook/codex': 'codex',
|
||||
'/hook/gemini': 'gemini',
|
||||
'/hook/antigravity': 'antigravity',
|
||||
'/hook/opencode': 'opencode',
|
||||
'/hook/cursor': 'cursor',
|
||||
'/hook/pi': 'pi',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export type AgentHookSource =
|
|||
| 'claude'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'pi'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const AGENT_HOOK_TARGETS = [
|
|||
'claude',
|
||||
'codex',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'cursor',
|
||||
'droid',
|
||||
'grok',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
|
|||
opencode: 'opencode',
|
||||
pi: 'pi',
|
||||
gemini: 'gemini',
|
||||
antigravity: 'antigravity',
|
||||
aider: 'aider',
|
||||
goose: 'goose',
|
||||
amp: 'amp',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type WellKnownAgentType =
|
|||
| 'claude'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'copilot'
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const AGENT_KIND_VALUES = [
|
|||
'opencode',
|
||||
'pi',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'aider',
|
||||
'goose',
|
||||
'amp',
|
||||
|
|
|
|||
|
|
@ -112,6 +112,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
|||
expectedProcess: 'gemini',
|
||||
promptInjectionMode: 'flag-prompt-interactive'
|
||||
},
|
||||
antigravity: {
|
||||
detectCmd: 'agy',
|
||||
launchCmd: 'agy',
|
||||
expectedProcess: 'agy',
|
||||
promptInjectionMode: 'flag-prompt-interactive'
|
||||
},
|
||||
aider: {
|
||||
detectCmd: 'aider',
|
||||
launchCmd: 'aider',
|
||||
|
|
|
|||
|
|
@ -1406,6 +1406,7 @@ export type TuiAgent =
|
|||
| 'opencode' // OpenCode
|
||||
| 'pi' // Pi (pi.dev)
|
||||
| 'gemini' // Gemini CLI
|
||||
| 'antigravity' // Google Antigravity CLI
|
||||
| 'aider' // Aider
|
||||
| 'goose' // Goose
|
||||
| 'amp' // Amp
|
||||
|
|
|
|||
Loading…
Reference in New Issue