From 8afdbea256ee0bc7fd4a001857a14307069e6296 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 19 May 2026 20:24:06 -0700 Subject: [PATCH] Add Antigravity agent support (#2389) --- README.md | 5 +- .../remote-hook-service-installers.test.ts | 32 +- .../remote-managed-hook-installers.ts | 2 + src/main/antigravity/hook-service.test.ts | 139 ++++++++ src/main/antigravity/hook-service.ts | 326 ++++++++++++++++++ src/main/index.ts | 2 + src/main/ipc/agent-hooks.test.ts | 14 + src/main/ipc/agent-hooks.ts | 15 + src/main/ipc/notification-options.ts | 1 + src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + .../terminal-pane/title-agent-identity.ts | 2 +- src/renderer/src/lib/agent-catalog.tsx | 7 + src/renderer/src/lib/agent-status.test.ts | 7 + src/renderer/src/lib/agent-status.ts | 2 + .../src/lib/tui-agent-startup.test.ts | 16 + .../src/store/slices/workspace-cleanup.ts | 1 + src/renderer/src/web/web-preload-api.ts | 12 +- src/shared/agent-detection.ts | 9 +- src/shared/agent-hook-listener.test.ts | 198 +++++++++++ src/shared/agent-hook-listener.ts | 142 +++++++- src/shared/agent-hook-relay.ts | 1 + src/shared/agent-hook-types.ts | 1 + src/shared/agent-kind.ts | 1 + src/shared/agent-status-types.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/tui-agent-config.ts | 6 + src/shared/types.ts | 1 + 28 files changed, 939 insertions(+), 8 deletions(-) create mode 100644 src/main/antigravity/hook-service.test.ts create mode 100644 src/main/antigravity/hook-service.ts diff --git a/README.md b/README.md index c1a5c2233..8ea36370d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.

@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*). Codex   Grok   Gemini   + Antigravity   Pi   Hermes Agent   OpenCode   @@ -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. diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index eb1f51e03..23ed45841 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 8d13ef610..a8ef3d910 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -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)] diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts new file mode 100644 index 000000000..4a565f3a1 --- /dev/null +++ b/src/main/antigravity/hook-service.test.ts @@ -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 + 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 + } + 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')) + }) +}) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts new file mode 100644 index 000000000..259b36319 --- /dev/null +++ b/src/main/antigravity/hook-service.ts @@ -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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getBundle(config: HooksConfig): Record { + 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, + isManagedCommand: (command: string | undefined) => boolean +): Record { + 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 { + 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() diff --git a/src/main/index.ts b/src/main/index.ts index c8d3bd49b..8f486146a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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()], diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 72c89445f..61c2cc073 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -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) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 875430789..79246f38b 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -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() diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 60b059257..e15c58999 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -8,6 +8,7 @@ const AGENT_TYPE_LABELS: Readonly> = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', + antigravity: 'Antigravity', opencode: 'OpenCode', cursor: 'Cursor', aider: 'Aider', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 08a1efaf4..6ec72735a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1214,6 +1214,7 @@ export type PreloadApi = { claudeStatus: () => Promise codexStatus: () => Promise geminiStatus: () => Promise + antigravityStatus: () => Promise cursorStatus: () => Promise droidStatus: () => Promise grokStatus: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 31d1d6537..987c41296 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1255,6 +1255,8 @@ const api = { ipcRenderer.invoke('agentHooks:codexStatus'), geminiStatus: (): Promise => ipcRenderer.invoke('agentHooks:geminiStatus'), + antigravityStatus: (): Promise => + ipcRenderer.invoke('agentHooks:antigravityStatus'), cursorStatus: (): Promise => ipcRenderer.invoke('agentHooks:cursorStatus'), droidStatus: (): Promise => diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.ts index 82006f71a..ce29ab8fd 100644 --- a/src/renderer/src/components/terminal-pane/title-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.ts @@ -5,7 +5,7 @@ import { } from '../../../../shared/agent-detection' const TITLE_AGENT_TOKEN_RE = - /(? { 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', () => { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index cae3d4f24..fdaf3e9c9 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -113,6 +113,7 @@ const WELL_KNOWN_LABELS: Record = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', + antigravity: 'Antigravity', copilot: 'GitHub Copilot', opencode: 'OpenCode', cursor: 'Cursor', @@ -151,6 +152,7 @@ const ICONABLE_AGENT_TYPES: Record = { opencode: true, pi: true, gemini: true, + antigravity: true, aider: true, goose: true, amp: true, diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index aa634c5bf..fd0f0d0d2 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -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({ diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index e9f112e7b..859f646d0 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -78,6 +78,7 @@ const SHELL_PROCESS_NAMES = new Set([ const AGENT_PROCESS_NAMES = new Set([ 'aider', 'amp', + 'agy', 'claude', 'claude-code', 'codex', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 456d69ac9..13118f332 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -957,7 +957,16 @@ function createCliApi(): NonNullable['cli']> { function createAgentHooksApi(): NonNullable['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['agentHooks']> { claudeStatus: () => status('claude'), codexStatus: () => status('codex'), geminiStatus: () => status('gemini'), + antigravityStatus: () => status('antigravity'), cursorStatus: () => status('cursor'), droidStatus: () => status('droid'), grokStatus: () => status('grok'), diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index f37a4b168..c7b66452a 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -27,6 +27,7 @@ export const AGENT_NAMES = [ 'copilot', 'cursor', 'gemini', + 'antigravity', 'opencode', 'openclaw', 'aider', @@ -41,6 +42,7 @@ const DROID_AGENT_NAME_RE = /(? { 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, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 58a9aa85d..e9a69ae7b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -323,6 +323,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { 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 = { 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 | 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) : 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): { + toolName?: string + toolInputSource?: unknown +} { + const toolCall = hookPayload.toolCall + if (typeof toolCall !== 'object' || toolCall === null) { + return {} + } + const record = toolCall as Record + return { + toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), + toolInputSource: record.args + } +} + +function extractAntigravityToolFields( + eventName: unknown, + hookPayload: Record +): 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 @@ -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 +): 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> '/hook/claude': 'claude', '/hook/codex': 'codex', '/hook/gemini': 'gemini', + '/hook/antigravity': 'antigravity', '/hook/opencode': 'opencode', '/hook/cursor': 'cursor', '/hook/pi': 'pi', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 6e9af71cf..3c2f6e8f0 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -34,6 +34,7 @@ export type AgentHookSource = | 'claude' | 'codex' | 'gemini' + | 'antigravity' | 'opencode' | 'cursor' | 'pi' diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index da6221954..2cd290819 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -7,6 +7,7 @@ export const AGENT_HOOK_TARGETS = [ 'claude', 'codex', 'gemini', + 'antigravity', 'cursor', 'droid', 'grok', diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 33585d5a8..c0b774e88 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -20,6 +20,7 @@ const TUI_AGENT_KIND_BY_AGENT = { opencode: 'opencode', pi: 'pi', gemini: 'gemini', + antigravity: 'antigravity', aider: 'aider', goose: 'goose', amp: 'amp', diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index a5b8c92c8..afe6b2bf4 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -15,6 +15,7 @@ export type WellKnownAgentType = | 'claude' | 'codex' | 'gemini' + | 'antigravity' | 'opencode' | 'cursor' | 'copilot' diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 9e5b240c4..961727dc0 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -41,6 +41,7 @@ export const AGENT_KIND_VALUES = [ 'opencode', 'pi', 'gemini', + 'antigravity', 'aider', 'goose', 'amp', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 302f6c302..90c503dbb 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -112,6 +112,12 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'gemini', promptInjectionMode: 'flag-prompt-interactive' }, + antigravity: { + detectCmd: 'agy', + launchCmd: 'agy', + expectedProcess: 'agy', + promptInjectionMode: 'flag-prompt-interactive' + }, aider: { detectCmd: 'aider', launchCmd: 'aider', diff --git a/src/shared/types.ts b/src/shared/types.ts index 2911c5783..b02bbf0dc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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