From 743d8b92ac10e9381a1d8951a32d9b237a893201 Mon Sep 17 00:00:00 2001 From: thebrownproject <173673575+thebrownproject@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:40:25 +1000 Subject: [PATCH 1/3] feat(desktop): add Antigravity CLI (agy) as a first-class agent Google deprecated the Gemini CLI (Homebrew disables the formula on 2026-12-18, and the individual-account client is already rejected at sign-in) and points users at the Antigravity CLI. This registers Antigravity as its own builtin terminal agent so Superset's lifecycle notifications work with it. The existing gemini entry is left untouched: enterprise Code Assist users can still run it until Google pulls it. Two things differ from the other agent integrations: - The agent id is `agy`, not `antigravity`. SUPERSET_MANAGED_BINARIES derives the shell shim name directly from the agent id, so the id has to match the binary that Antigravity actually installs. - Antigravity's hook payload carries no event-name field (the common fields are conversationId/workspacePaths/transcriptPath/modelName), so hooks.json passes the Superset event as argv rather than the hook deriving it from stdin. Hook config lives at ~/.gemini/config/hooks.json -- Antigravity's global customization root -- not the ~/.gemini/settings.json the Gemini CLI used. Hooks are namespaced by name, so we own a single "superset" key and leave user- and plugin-defined hooks alone. PreInvocation and PostToolUse map to Start, Stop maps to Stop. PreToolUse is deliberately not registered: its contract requires a `decision` field, and emitting an empty object risks gating the user's own tool calls. --- .../agent-setup/agent-wrappers-antigravity.ts | 159 ++++++++++++++++++ .../lib/agent-setup/agent-wrappers.test.ts | 110 ++++++++++++ .../main/lib/agent-setup/agent-wrappers.ts | 12 ++ .../agent-setup/desktop-agent-capabilities.ts | 12 ++ .../lib/agent-setup/desktop-agent-setup.ts | 6 + .../main/lib/agent-setup/notify-hook.test.ts | 1 + .../templates/antigravity-hook.template.sh | 72 ++++++++ .../AgentIconPicker/agent-icon-options.ts | 1 + .../shared/src/builtin-terminal-agents.ts | 13 ++ .../assets/icons/preset-icons/antigravity.svg | 12 ++ .../ui/src/assets/icons/preset-icons/index.ts | 4 + 11 files changed, 402 insertions(+) create mode 100644 apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts create mode 100644 apps/desktop/src/main/lib/agent-setup/templates/antigravity-hook.template.sh create mode 100644 packages/ui/src/assets/icons/preset-icons/antigravity.svg diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts new file mode 100644 index 00000000000..dd6e13c94ba --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { env } from "shared/env.shared"; +import { + buildWrapperScript, + createWrapper, + writeFileIfChanged, +} from "./agent-wrappers-common"; +import { HOOKS_DIR } from "./paths"; + +export const ANTIGRAVITY_HOOK_SCRIPT_NAME = "antigravity-hook.sh"; + +const ANTIGRAVITY_HOOK_SIGNATURE = "# Superset antigravity hook"; +const ANTIGRAVITY_HOOK_VERSION = "v1"; +export const ANTIGRAVITY_HOOK_MARKER = `${ANTIGRAVITY_HOOK_SIGNATURE} ${ANTIGRAVITY_HOOK_VERSION}`; + +/** + * Top-level key we own in hooks.json. Antigravity namespaces hooks by name and + * merges every named block for a given event, so scoping our handlers under a + * single key leaves user-defined hooks untouched. + */ +export const ANTIGRAVITY_HOOK_NAME = "superset"; + +const ANTIGRAVITY_HOOK_TEMPLATE_PATH = path.join( + __dirname, + "templates", + "antigravity-hook.template.sh", +); + +interface AntigravityHookHandler { + type?: string; + command: string; + timeout?: number; + [key: string]: unknown; +} + +interface AntigravityHookGroup { + matcher?: string; + hooks: AntigravityHookHandler[]; + [key: string]: unknown; +} + +interface AntigravityHooksJson { + [hookName: string]: unknown; +} + +export function getAntigravityHookScriptPath(): string { + return path.join(HOOKS_DIR, ANTIGRAVITY_HOOK_SCRIPT_NAME); +} + +/** + * Antigravity's global customization root is `~/.gemini/config/` (shared with + * Antigravity 2.0 and the IDE), not `~/.gemini/` itself -- that is where the + * deprecated Gemini CLI kept settings.json. + */ +export function getAntigravityHooksJsonPath(): string { + return path.join(os.homedir(), ".gemini", "config", "hooks.json"); +} + +export function getAntigravityHookScriptContent(): string { + const template = fs.readFileSync(ANTIGRAVITY_HOOK_TEMPLATE_PATH, "utf-8"); + return template + .replace("{{MARKER}}", ANTIGRAVITY_HOOK_MARKER) + .replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT)); +} + +/** + * Antigravity hook payloads carry no event-name field, so the Superset event is + * passed as argv instead of being derived from stdin. + * + * Event choice is deliberate: + * - `PreInvocation` fires before each model call -> Start. + * - `PostToolUse` keeps the terminal marked busy across long tool loops -> Start. + * - `Stop` fires when the execution loop terminates -> Stop. + * + * `PreToolUse` is intentionally not registered: its contract requires a + * `decision` field ("allow" | "deny" | "ask" | "force_ask"), and emitting an + * empty object there risks gating the user's tool calls. + */ +function buildSupersetHookBlock( + hookScriptPath: string, +): Record { + const startCommand: AntigravityHookHandler = { + type: "command", + command: `"${hookScriptPath}" Start`, + }; + const stopCommand: AntigravityHookHandler = { + type: "command", + command: `"${hookScriptPath}" Stop`, + }; + const postToolUseGroup: AntigravityHookGroup = { + matcher: "*", + hooks: [startCommand], + }; + + // PreInvocation/Stop take a flat handler list; PostToolUse is tool-matched + // and must be wrapped in a matcher group. + return { + PreInvocation: [startCommand], + PostToolUse: [postToolUseGroup], + Stop: [stopCommand], + }; +} + +/** + * Reads existing ~/.gemini/config/hooks.json, replaces our own named block, and + * preserves every other hook the user (or a plugin) defined. + */ +export function getAntigravityHooksJsonContent(hookScriptPath: string): string { + const globalPath = getAntigravityHooksJsonPath(); + + let existing: AntigravityHooksJson = {}; + try { + if (fs.existsSync(globalPath)) { + const parsed: unknown = JSON.parse(fs.readFileSync(globalPath, "utf-8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + existing = parsed as AntigravityHooksJson; + } + } + } catch { + console.warn( + "[agent-setup] Could not parse existing ~/.gemini/config/hooks.json, rewriting Superset block only", + ); + } + + existing[ANTIGRAVITY_HOOK_NAME] = buildSupersetHookBlock(hookScriptPath); + + return JSON.stringify(existing, null, 2); +} + +export function createAntigravityHookScript(): void { + const scriptPath = getAntigravityHookScriptPath(); + const content = getAntigravityHookScriptContent(); + const changed = writeFileIfChanged(scriptPath, content, 0o755); + console.log( + `[agent-setup] ${changed ? "Updated" : "Verified"} Antigravity hook script`, + ); +} + +export function createAntigravityWrapper(): void { + const script = buildWrapperScript("agy", `exec "$REAL_BIN" "$@"`, { + agentId: "agy", + }); + createWrapper("agy", script); +} + +export function createAntigravityHooksJson(): void { + const hookScriptPath = getAntigravityHookScriptPath(); + const globalPath = getAntigravityHooksJsonPath(); + const content = getAntigravityHooksJsonContent(hookScriptPath); + + const dir = path.dirname(globalPath); + fs.mkdirSync(dir, { recursive: true }); + const changed = writeFileIfChanged(globalPath, content, 0o644); + console.log( + `[agent-setup] ${changed ? "Updated" : "Verified"} Antigravity hooks.json`, + ); +} diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index dcb53fba6b1..e0f30d4bf63 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -58,8 +58,12 @@ mock.module("node:os", () => ({ const { AMP_PLUGIN_MARKER, + ANTIGRAVITY_HOOK_NAME, createAmpPlugin, createAmpWrapper, + createAntigravityWrapper, + getAntigravityHooksJsonContent, + getAntigravityHooksJsonPath, buildCodexWrapperExecLine, buildCopilotWrapperExecLine, buildWrapperScript, @@ -1742,3 +1746,109 @@ describe("agent-wrappers pi", () => { expect(installed).toContain("export default function"); }); }); + +describe("antigravity wrapper", () => { + beforeEach(() => { + mockedHomeDir = path.join(TEST_ROOT, "home"); + mkdirSync(TEST_BIN_DIR, { recursive: true }); + mkdirSync(TEST_HOOKS_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it("wraps the agy binary and stamps the agent id", () => { + createAntigravityWrapper(); + const script = readFileSync(path.join(TEST_BIN_DIR, "agy"), "utf-8"); + expect(script).toContain('export SUPERSET_AGENT_ID="agy"'); + expect(script).toContain('find_real_binary "agy"'); + expect(script).toContain('exec "$REAL_BIN" "$@"'); + }); +}); + +describe("antigravity hooks.json", () => { + const hookPath = "/tmp/.superset/hooks/antigravity-hook.sh"; + + beforeEach(() => { + mockedHomeDir = path.join(TEST_ROOT, "home"); + mkdirSync(TEST_HOOKS_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_ROOT, { recursive: true, force: true }); + }); + + it("targets the ~/.gemini/config customization root", () => { + expect(getAntigravityHooksJsonPath()).toBe( + path.join(mockedHomeDir, ".gemini", "config", "hooks.json"), + ); + }); + + it("passes the Superset event as argv, since the payload omits it", () => { + const parsed = JSON.parse( + getAntigravityHooksJsonContent(hookPath), + ) as Record>; + const block = parsed[ANTIGRAVITY_HOOK_NAME]; + + // PreInvocation/Stop take a flat handler list. + expect(block.PreInvocation).toEqual([ + { type: "command", command: `"${hookPath}" Start` }, + ]); + expect(block.Stop).toEqual([ + { type: "command", command: `"${hookPath}" Stop` }, + ]); + // PostToolUse is tool-matched and must be wrapped in a matcher group. + expect(block.PostToolUse).toEqual([ + { + matcher: "*", + hooks: [{ type: "command", command: `"${hookPath}" Start` }], + }, + ]); + }); + + it("never registers PreToolUse, whose contract requires a decision field", () => { + const block = JSON.parse(getAntigravityHooksJsonContent(hookPath))[ + ANTIGRAVITY_HOOK_NAME + ] as Record; + expect(block.PreToolUse).toBeUndefined(); + }); + + it("preserves foreign hook blocks and is idempotent", () => { + const hooksPath = getAntigravityHooksJsonPath(); + mkdirSync(path.dirname(hooksPath), { recursive: true }); + writeFileSync( + hooksPath, + JSON.stringify({ + "lint-checker": { + PostToolUse: [ + { + matcher: "run_command", + hooks: [{ type: "command", command: "./scripts/lint.sh" }], + }, + ], + }, + }), + ); + + const once = getAntigravityHooksJsonContent(hookPath); + writeFileSync(hooksPath, once); + const twice = getAntigravityHooksJsonContent(hookPath); + + expect(twice).toBe(once); + const parsed = JSON.parse(twice) as Record; + expect(parsed["lint-checker"]).toBeDefined(); + expect(parsed[ANTIGRAVITY_HOOK_NAME]).toBeDefined(); + }); + + it("rewrites only our block when the existing file is unparseable", () => { + const hooksPath = getAntigravityHooksJsonPath(); + mkdirSync(path.dirname(hooksPath), { recursive: true }); + writeFileSync(hooksPath, "{ not json"); + + const parsed = JSON.parse( + getAntigravityHooksJsonContent(hookPath), + ) as Record; + expect(parsed[ANTIGRAVITY_HOOK_NAME]).toBeDefined(); + }); +}); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 628cb08e52c..f59096bdaa1 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -6,6 +6,18 @@ export { getAmpGlobalPluginPath, getAmpPluginContent, } from "./agent-wrappers-amp"; +export { + ANTIGRAVITY_HOOK_MARKER, + ANTIGRAVITY_HOOK_NAME, + ANTIGRAVITY_HOOK_SCRIPT_NAME, + createAntigravityHookScript, + createAntigravityHooksJson, + createAntigravityWrapper, + getAntigravityHookScriptContent, + getAntigravityHookScriptPath, + getAntigravityHooksJsonContent, + getAntigravityHooksJsonPath, +} from "./agent-wrappers-antigravity"; export { buildCodexWrapperExecLine, cleanupGlobalOpenCodePlugin, diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts index feb7fc51bf1..e4c669b80dd 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts @@ -22,6 +22,9 @@ export const DESKTOP_AGENT_SETUP_ACTIONS = [ "gemini-hook-script", "gemini-wrapper", "gemini-settings-json", + "antigravity-hook-script", + "antigravity-wrapper", + "antigravity-hooks-json", "mastra-wrapper", "mastra-hooks-json", "copilot-hook-script", @@ -91,6 +94,15 @@ export const DESKTOP_AGENT_SETUP_TARGETS = [ ], managedBinary: true, }, + { + id: "agy", + setupActions: [ + "antigravity-hook-script", + "antigravity-wrapper", + "antigravity-hooks-json", + ], + managedBinary: true, + }, { id: "mastracode", setupActions: ["mastra-wrapper", "mastra-hooks-json"], diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts index 13dcfb29635..c3ab3bb6241 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts @@ -2,6 +2,9 @@ import { cleanupGlobalOpenCodePlugin, createAmpPlugin, createAmpWrapper, + createAntigravityHookScript, + createAntigravityHooksJson, + createAntigravityWrapper, createClaudeSettingsJson, createClaudeWrapper, createCodexHooksJson, @@ -52,6 +55,9 @@ const DESKTOP_AGENT_SETUP_RUNNERS: Record void> = "gemini-hook-script": createGeminiHookScript, "gemini-wrapper": createGeminiWrapper, "gemini-settings-json": createGeminiSettingsJson, + "antigravity-hook-script": createAntigravityHookScript, + "antigravity-wrapper": createAntigravityWrapper, + "antigravity-hooks-json": createAntigravityHooksJson, "mastra-wrapper": createMastraWrapper, "mastra-hooks-json": createMastraHooksJson, "copilot-hook-script": createCopilotHookScript, diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index b4dc0e9496b..70a56b61aa4 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -63,6 +63,7 @@ describe("per-agent hook scripts dispatch to v2", () => { "cursor-hook.template.sh", "copilot-hook.template.sh", "gemini-hook.template.sh", + "antigravity-hook.template.sh", ]) { it(`${template} posts v2 first and falls back to v1`, () => { const script = readFileSync( diff --git a/apps/desktop/src/main/lib/agent-setup/templates/antigravity-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/antigravity-hook.template.sh new file mode 100644 index 00000000000..43c479be2b5 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/antigravity-hook.template.sh @@ -0,0 +1,72 @@ +#!/bin/bash +{{MARKER}} +# Antigravity CLI (`agy`) lifecycle hook. JSON in via stdin; MUST print valid +# JSON to stdout before exit or the agent loop stalls -- Antigravity runs hooks +# synchronously and blocks on them. +# +# Unlike other agents, Antigravity's hook payload carries no event-name field +# (common fields are conversationId/workspacePaths/transcriptPath/modelName +# only), so hooks.json passes the Superset event as argv $1. + +EVENT_TYPE="$1" + +case "$EVENT_TYPE" in + Start|Stop) ;; + *) + printf '{}\n' + exit 0 + ;; +esac + +INPUT=$(cat) + +# Payload keys are camelCase (protojson). conversationId is Antigravity's +# session identifier. +HOOK_SESSION_ID=$(printf '%s' "$INPUT" | grep -oE '"conversationId"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') + +# Emit before dispatching. An empty object is the no-op result for every event +# we register: PreInvocation/PostInvocation treat injectSteps as optional, +# PostToolUse expects exactly {}, and Stop only blocks termination when +# decision is "continue". +printf '{}\n' + +# Antigravity is already invoked with Superset's own event names, so no +# remapping is needed -- the alias keeps the v1 payload shape identical to the +# other agent hook templates. +V1_EVENT_TYPE="$EVENT_TYPE" + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$HOOK_SESSION_ID")\"}}}" + + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 2 --max-time 5 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + + case "$STATUS_CODE" in + 2*) exit 0 ;; + *) echo "[antigravity-hook] host-service dispatch failed status=$STATUS_CODE; falling back to v1" >&2 ;; + esac +fi + +[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 + +curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ + --connect-timeout 1 --max-time 2 \ + --data-urlencode "paneId=$SUPERSET_PANE_ID" \ + --data-urlencode "tabId=$SUPERSET_TAB_ID" \ + --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "terminalId=$SUPERSET_TERMINAL_ID" \ + --data-urlencode "sessionId=$HOOK_SESSION_ID" \ + --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ + --data-urlencode "eventType=$V1_EVENT_TYPE" \ + --data-urlencode "env=$SUPERSET_ENV" \ + --data-urlencode "version=$SUPERSET_HOOK_VERSION" \ + > /dev/null 2>&1 + +exit 0 diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIconPicker/agent-icon-options.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIconPicker/agent-icon-options.ts index 29094642d7a..9a6a8b7c502 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIconPicker/agent-icon-options.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentIconPicker/agent-icon-options.ts @@ -13,6 +13,7 @@ export const AGENT_ICON_OPTIONS: readonly AgentIconOption[] = [ { id: "codex", label: "Codex" }, { id: "cursor-agent", label: "Cursor" }, { id: "gemini", label: "Gemini" }, + { id: "agy", label: "Antigravity" }, { id: "copilot", label: "Copilot" }, { id: "amp", label: "Amp" }, { id: "opencode", label: "OpenCode" }, diff --git a/packages/shared/src/builtin-terminal-agents.ts b/packages/shared/src/builtin-terminal-agents.ts index e96d8de8d24..1e2e27cd281 100644 --- a/packages/shared/src/builtin-terminal-agents.ts +++ b/packages/shared/src/builtin-terminal-agents.ts @@ -96,6 +96,19 @@ export const BUILTIN_TERMINAL_AGENTS = [ nonInteractiveCommand: "gemini --skip-trust -p", includeInDefaultTerminalPresets: true, }), + createBuiltinTerminalAgent({ + // Google's replacement for the deprecated Gemini CLI. The id matches the + // binary name (`agy`) because SUPERSET_MANAGED_BINARIES derives the shell + // shim name from the agent id. + id: "agy", + label: "Antigravity", + description: + "Google's terminal agent harness for reasoning, editing, and running code from the terminal.", + command: "agy --mode accept-edits", + promptCommand: "agy --mode accept-edits", + nonInteractiveCommand: "agy -p", + includeInDefaultTerminalPresets: true, + }), createBuiltinTerminalAgent({ id: "mastracode", label: "Mastracode", diff --git a/packages/ui/src/assets/icons/preset-icons/antigravity.svg b/packages/ui/src/assets/icons/preset-icons/antigravity.svg new file mode 100644 index 00000000000..e7e8233f3e4 --- /dev/null +++ b/packages/ui/src/assets/icons/preset-icons/antigravity.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/preset-icons/index.ts b/packages/ui/src/assets/icons/preset-icons/index.ts index 16b98dfe414..f8a435ed529 100644 --- a/packages/ui/src/assets/icons/preset-icons/index.ts +++ b/packages/ui/src/assets/icons/preset-icons/index.ts @@ -1,4 +1,5 @@ import ampIcon from "./amp.svg"; +import antigravityIcon from "./antigravity.svg"; import claudeIcon from "./claude.svg"; import codexIcon from "./codex.svg"; import codexWhiteIcon from "./codex-white.svg"; @@ -26,6 +27,8 @@ export interface PresetIconSet { export const PRESET_ICONS: Record = { amp: { light: ampIcon, dark: ampIcon }, + // Keyed by agent id: the Antigravity CLI binary is `agy`. + agy: { light: antigravityIcon, dark: antigravityIcon }, claude: { light: claudeIcon, dark: claudeIcon }, codex: { light: codexIcon, dark: codexWhiteIcon }, copilot: { light: copilotIcon, dark: copilotWhiteIcon }, @@ -61,6 +64,7 @@ export function getPresetIcon( export { ampIcon, + antigravityIcon, claudeIcon, codexIcon, codexWhiteIcon, From 88721a8770d934087a2ec50d2d2fc65117d676a4 Mon Sep 17 00:00:00 2001 From: thebrownproject <173673575+thebrownproject@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:40:44 +1000 Subject: [PATCH 2/3] docs(terminal-presets): list the Antigravity CLI preset Also flag the gemini preset as deprecated upstream so users know why a second Google agent is listed. --- apps/docs/content/docs/terminal-presets.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/terminal-presets.mdx b/apps/docs/content/docs/terminal-presets.mdx index 04e024b4914..b2862862238 100644 --- a/apps/docs/content/docs/terminal-presets.mdx +++ b/apps/docs/content/docs/terminal-presets.mdx @@ -46,7 +46,8 @@ Pre-configured presets for popular AI agents. Defaults mirror the bundled Supers - **amp** - `amp` (built-in permission rules auto-deny destructive ops) - **claude** - `claude --dangerously-skip-permissions` - **codex** - `codex --dangerously-bypass-approvals-and-sandbox` (most permissive) -- **gemini** - `gemini --approval-mode=auto_edit` +- **gemini** - `gemini --approval-mode=auto_edit` (deprecated upstream; Google is retiring the Gemini CLI in favour of Antigravity) +- **agy** - `agy --mode accept-edits` (Google's Antigravity CLI, the Gemini CLI's replacement) - **copilot** - `copilot --allow-tool=write` - **cursor-agent** - `cursor-agent` (prompts for every action) - **mastracode** - Mastra's coding agent (opt-in: auto-approves all actions by default at the CLI; no startup flag to restrict) From 2929c5b3a3fedcde9cdc9b27b5222e689726ea2e Mon Sep 17 00:00:00 2001 From: thebrownproject <173673575+thebrownproject@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:26:58 +1000 Subject: [PATCH 3/3] fix(desktop): correct Antigravity hook events and guard hooks.json writes Two defects found reviewing the preceding commit. 1. Register PostInvocation, not PostToolUse. Antigravity dispatches a trailing PostToolUse for the final step roughly a second AFTER the Stop hook on any turn that ends without a tool call. Since PostToolUse mapped to Start, the last event Superset received for such a turn was Start, and terminal status is last-write-wins with no staleness gate on the Start branch -- so the pane stayed pinned to "working" indefinitely after the agent had finished replying, with only the manual clear-status escape hatch to recover. Reproduced against agy 1.1.4 with a probe hook logging argv and epoch. A no-tool turn emits: 307.589 PreInvocation 307.630 PostToolUse stepIdx:1 toolCall:null 309.117 PostInvocation 309.157 Stop terminationReason:NO_TOOL_CALL 310.026 PostToolUse stepIdx:3 toolCall:null <- 0.87s after Stop PostInvocation fires once per loop iteration and always precedes Stop, so it serves the same "stay busy across long tool loops" purpose without the trailing Start. Two incidental corrections to the previous reasoning: matcher "*" matches every step type rather than only tool calls (tool names are derived from the step type with the CORTEX_STEP_TYPE_ prefix stripped), and those trailing steps carry toolCall:null -- so PostToolUse was never really a tool-loop keepalive to begin with. 2. Never rewrite a hooks.json we could not read. getAntigravityHooksJsonContent seeded an empty object and fell through on parse failure, so an unreadable or non-object hooks.json was replaced wholesale by our block alone, discarding the user's and any plugin's hooks. ~/.gemini/config/ is shared by the CLI, Antigravity 2.0 and the IDE, so that took out every surface at once. It now returns null and the writer skips, matching the readExistingClaudeSettings / readExistingCodexHooks / readExistingDroidSettings contract the newer sibling integrations already use. The unparseable-file test asserted only that our own key existed, so it passed either way; it now covers unparseable, top-level-array and null files and asserts the file is left byte-identical. --- .../agent-setup/agent-wrappers-antigravity.ts | 69 ++++++++++++------- .../lib/agent-setup/agent-wrappers.test.ts | 60 ++++++++++------ 2 files changed, 82 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts index dd6e13c94ba..b3e398b0fb1 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts @@ -35,16 +35,14 @@ interface AntigravityHookHandler { [key: string]: unknown; } -interface AntigravityHookGroup { - matcher?: string; - hooks: AntigravityHookHandler[]; - [key: string]: unknown; -} - interface AntigravityHooksJson { [hookName: string]: unknown; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function getAntigravityHookScriptPath(): string { return path.join(HOOKS_DIR, ANTIGRAVITY_HOOK_SCRIPT_NAME); } @@ -71,9 +69,19 @@ export function getAntigravityHookScriptContent(): string { * * Event choice is deliberate: * - `PreInvocation` fires before each model call -> Start. - * - `PostToolUse` keeps the terminal marked busy across long tool loops -> Start. + * - `PostInvocation` fires after each loop iteration -> Start, keeping the + * terminal marked busy across long tool loops. * - `Stop` fires when the execution loop terminates -> Stop. * + * `PostToolUse` is deliberately NOT used as the busy keepalive even though it + * looks like the natural fit. Antigravity dispatches a trailing `PostToolUse` + * for the final step ~1s AFTER `Stop` on any turn that ends without a tool + * call, so registering it makes `Start` the last event Superset sees and pins + * the terminal to "working" forever (status is last-write-wins and the Start + * branch has no staleness gate). `PostInvocation` always precedes `Stop`. + * Note also that `matcher: "*"` matches every step type, not just tool calls -- + * those trailing steps carry `toolCall: null`. + * * `PreToolUse` is intentionally not registered: its contract requires a * `decision` field ("allow" | "deny" | "ask" | "force_ask"), and emitting an * empty object there risks gating the user's tool calls. @@ -89,16 +97,12 @@ function buildSupersetHookBlock( type: "command", command: `"${hookScriptPath}" Stop`, }; - const postToolUseGroup: AntigravityHookGroup = { - matcher: "*", - hooks: [startCommand], - }; - // PreInvocation/Stop take a flat handler list; PostToolUse is tool-matched - // and must be wrapped in a matcher group. + // Every event we register takes a flat handler list; only the tool-matched + // events (PreToolUse/PostToolUse) would need a `matcher` group wrapper. return { PreInvocation: [startCommand], - PostToolUse: [postToolUseGroup], + PostInvocation: [startCommand], Stop: [stopCommand], }; } @@ -106,22 +110,36 @@ function buildSupersetHookBlock( /** * Reads existing ~/.gemini/config/hooks.json, replaces our own named block, and * preserves every other hook the user (or a plugin) defined. + * + * Returns `null` when the existing file cannot be parsed as a JSON object, so + * the caller skips the write rather than clobbering hooks it failed to read. + * This root is shared by the CLI, Antigravity 2.0 and the IDE, so a blind + * rewrite would take out every surface's hooks at once. */ -export function getAntigravityHooksJsonContent(hookScriptPath: string): string { +export function getAntigravityHooksJsonContent( + hookScriptPath: string, +): string | null { const globalPath = getAntigravityHooksJsonPath(); let existing: AntigravityHooksJson = {}; - try { - if (fs.existsSync(globalPath)) { - const parsed: unknown = JSON.parse(fs.readFileSync(globalPath, "utf-8")); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - existing = parsed as AntigravityHooksJson; - } + if (fs.existsSync(globalPath)) { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(globalPath, "utf-8")); + } catch (error) { + console.warn( + "[agent-setup] Could not parse existing ~/.gemini/config/hooks.json; skipping Antigravity hook merge:", + error, + ); + return null; + } + if (!isPlainObject(parsed)) { + console.warn( + "[agent-setup] Expected ~/.gemini/config/hooks.json to contain a JSON object; skipping Antigravity hook merge", + ); + return null; } - } catch { - console.warn( - "[agent-setup] Could not parse existing ~/.gemini/config/hooks.json, rewriting Superset block only", - ); + existing = parsed; } existing[ANTIGRAVITY_HOOK_NAME] = buildSupersetHookBlock(hookScriptPath); @@ -149,6 +167,7 @@ export function createAntigravityHooksJson(): void { const hookScriptPath = getAntigravityHookScriptPath(); const globalPath = getAntigravityHooksJsonPath(); const content = getAntigravityHooksJsonContent(hookScriptPath); + if (content === null) return; const dir = path.dirname(globalPath); fs.mkdirSync(dir, { recursive: true }); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index e0f30d4bf63..a7fdef435b6 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -61,6 +61,7 @@ const { ANTIGRAVITY_HOOK_NAME, createAmpPlugin, createAmpWrapper, + createAntigravityHooksJson, createAntigravityWrapper, getAntigravityHooksJsonContent, getAntigravityHooksJsonPath, @@ -1787,33 +1788,39 @@ describe("antigravity hooks.json", () => { it("passes the Superset event as argv, since the payload omits it", () => { const parsed = JSON.parse( - getAntigravityHooksJsonContent(hookPath), + getAntigravityHooksJsonContent(hookPath) ?? "", ) as Record>; const block = parsed[ANTIGRAVITY_HOOK_NAME]; - // PreInvocation/Stop take a flat handler list. + // Every registered event takes a flat handler list. expect(block.PreInvocation).toEqual([ { type: "command", command: `"${hookPath}" Start` }, ]); + expect(block.PostInvocation).toEqual([ + { type: "command", command: `"${hookPath}" Start` }, + ]); expect(block.Stop).toEqual([ { type: "command", command: `"${hookPath}" Stop` }, ]); - // PostToolUse is tool-matched and must be wrapped in a matcher group. - expect(block.PostToolUse).toEqual([ - { - matcher: "*", - hooks: [{ type: "command", command: `"${hookPath}" Start` }], - }, - ]); }); it("never registers PreToolUse, whose contract requires a decision field", () => { - const block = JSON.parse(getAntigravityHooksJsonContent(hookPath))[ + const block = JSON.parse(getAntigravityHooksJsonContent(hookPath) ?? "")[ ANTIGRAVITY_HOOK_NAME ] as Record; expect(block.PreToolUse).toBeUndefined(); }); + // Antigravity dispatches a trailing PostToolUse ~1s AFTER Stop on turns that + // end without a tool call. Registering it would make Start the last event + // Superset sees and pin the terminal to "working" forever. + it("never registers PostToolUse, which can fire after Stop", () => { + const block = JSON.parse(getAntigravityHooksJsonContent(hookPath) ?? "")[ + ANTIGRAVITY_HOOK_NAME + ] as Record; + expect(block.PostToolUse).toBeUndefined(); + }); + it("preserves foreign hook blocks and is idempotent", () => { const hooksPath = getAntigravityHooksJsonPath(); mkdirSync(path.dirname(hooksPath), { recursive: true }); @@ -1832,23 +1839,32 @@ describe("antigravity hooks.json", () => { ); const once = getAntigravityHooksJsonContent(hookPath); - writeFileSync(hooksPath, once); + writeFileSync(hooksPath, once ?? ""); const twice = getAntigravityHooksJsonContent(hookPath); expect(twice).toBe(once); - const parsed = JSON.parse(twice) as Record; + const parsed = JSON.parse(twice ?? "") as Record; expect(parsed["lint-checker"]).toBeDefined(); expect(parsed[ANTIGRAVITY_HOOK_NAME]).toBeDefined(); }); - it("rewrites only our block when the existing file is unparseable", () => { - const hooksPath = getAntigravityHooksJsonPath(); - mkdirSync(path.dirname(hooksPath), { recursive: true }); - writeFileSync(hooksPath, "{ not json"); - - const parsed = JSON.parse( - getAntigravityHooksJsonContent(hookPath), - ) as Record; - expect(parsed[ANTIGRAVITY_HOOK_NAME]).toBeDefined(); - }); + // ~/.gemini/config/ is shared by the CLI, Antigravity 2.0 and the IDE, so + // rewriting a file we could not read would take out every surface's hooks. + for (const [label, contents] of [ + ["unparseable", "{ not json"], + ["a top-level array", '[{"nope": true}]'], + ["JSON null", "null"], + ] as const) { + it(`leaves hooks.json untouched when it contains ${label}`, () => { + const hooksPath = getAntigravityHooksJsonPath(); + mkdirSync(path.dirname(hooksPath), { recursive: true }); + writeFileSync(hooksPath, contents); + + expect(getAntigravityHooksJsonContent(hookPath)).toBeNull(); + + // The writer must skip entirely rather than clobber the file. + createAntigravityHooksJson(); + expect(readFileSync(hooksPath, "utf-8")).toBe(contents); + }); + } });