Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers-antigravity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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 AntigravityHooksJson {
[hookName: string]: unknown;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

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.
* - `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.
*/
function buildSupersetHookBlock(
hookScriptPath: string,
): Record<string, unknown> {
const startCommand: AntigravityHookHandler = {
type: "command",
command: `"${hookScriptPath}" Start`,
};
const stopCommand: AntigravityHookHandler = {
type: "command",
command: `"${hookScriptPath}" Stop`,
};

// 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],
PostInvocation: [startCommand],
Stop: [stopCommand],
};
}

/**
* 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 | null {
const globalPath = getAntigravityHooksJsonPath();

let existing: 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;
}
existing = parsed;
}

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);
if (content === null) return;

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`,
);
}
126 changes: 126 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,13 @@ mock.module("node:os", () => ({

const {
AMP_PLUGIN_MARKER,
ANTIGRAVITY_HOOK_NAME,
createAmpPlugin,
createAmpWrapper,
createAntigravityHooksJson,
createAntigravityWrapper,
getAntigravityHooksJsonContent,
getAntigravityHooksJsonPath,
buildCodexWrapperExecLine,
buildCopilotWrapperExecLine,
buildWrapperScript,
Expand Down Expand Up @@ -1742,3 +1747,124 @@ 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<string, Record<string, unknown[]>>;
const block = parsed[ANTIGRAVITY_HOOK_NAME];

// 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` },
]);
});

it("never registers PreToolUse, whose contract requires a decision field", () => {
const block = JSON.parse(getAntigravityHooksJsonContent(hookPath) ?? "")[
ANTIGRAVITY_HOOK_NAME
] as Record<string, unknown>;
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<string, unknown>;
expect(block.PostToolUse).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<string, unknown>;
expect(parsed["lint-checker"]).toBeDefined();
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);
});
}
});
12 changes: 12 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {
cleanupGlobalOpenCodePlugin,
createAmpPlugin,
createAmpWrapper,
createAntigravityHookScript,
createAntigravityHooksJson,
createAntigravityWrapper,
createClaudeSettingsJson,
createClaudeWrapper,
createCodexHooksJson,
Expand Down Expand Up @@ -52,6 +55,9 @@ const DESKTOP_AGENT_SETUP_RUNNERS: Record<DesktopAgentSetupAction, () => 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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading