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