diff --git a/README.md b/README.md
index 8ec101387f67..b62e4ffb7eba 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
+Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them.
## "Wait, what are you selling me?"
@@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation
> [!WARNING]
-> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
+> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Google Antigravity. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
+> - Antigravity: install [Antigravity CLI](https://antigravity.google/docs/cli/install/) and run `agy` to sign in
### Try it out (install-free)
@@ -83,6 +84,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
+- Provider setup: [Antigravity](./docs/user/providers-antigravity.md)
- Linux: [run T3 Code as a background service](./docs/user/background-service.md)
Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md).
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index 5eb69627f58d..a4f90531180b 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -12,6 +12,17 @@ export function ProviderIcon(props: ProviderIconProps) {
const size = props.size ?? 16;
const mono = isDarkMode ? "#e5e5e5" : "#171717";
+ if (props.provider === "agy") {
+ return (
+
+
+
+ );
+ }
+
if (props.provider === "claudeAgent") {
return (
diff --git a/apps/server/src/provider/AgyModelSelection.ts b/apps/server/src/provider/AgyModelSelection.ts
new file mode 100644
index 000000000000..02a89411c34e
--- /dev/null
+++ b/apps/server/src/provider/AgyModelSelection.ts
@@ -0,0 +1,11 @@
+const AGY_REASONING_EFFORT_SUFFIX = /^(.*)-(low|medium|high)$/;
+
+export function resolveAgyModelForEffort(
+ model: string | undefined,
+ effort: string | undefined,
+): string | undefined {
+ if (!model || !effort) return model;
+
+ const match = AGY_REASONING_EFFORT_SUFFIX.exec(model);
+ return match?.[1] ? `${match[1]}-${effort}` : model;
+}
diff --git a/apps/server/src/provider/Drivers/AgyDriver.ts b/apps/server/src/provider/Drivers/AgyDriver.ts
new file mode 100644
index 000000000000..b60536bbe4ad
--- /dev/null
+++ b/apps/server/src/provider/Drivers/AgyDriver.ts
@@ -0,0 +1,148 @@
+import { AgySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { HttpClient } from "effect/unstable/http";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
+import { ServerSettingsService } from "../../serverSettings.ts";
+import { makeAgyTextGeneration } from "../../textGeneration/AgyTextGeneration.ts";
+import { ProviderDriverError } from "../Errors.ts";
+import { makeAgyAdapter } from "../Layers/AgyAdapter.ts";
+import { buildInitialAgyProviderSnapshot, checkAgyProviderStatus } from "../Layers/AgyProvider.ts";
+import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
+import {
+ defaultProviderContinuationIdentity,
+ type ProviderDriver,
+ type ProviderInstance,
+} from "../ProviderDriver.ts";
+import type { ServerProviderDraft } from "../providerSnapshot.ts";
+import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
+import {
+ makeManualOnlyProviderMaintenanceCapabilities,
+ makeStaticProviderMaintenanceResolver,
+ resolveProviderMaintenanceCapabilitiesEffect,
+} from "../providerMaintenance.ts";
+import {
+ haveProviderSnapshotSettingsChanged,
+ makeProviderSnapshotSettingsSource,
+ type ProviderSnapshotSettings,
+} from "../providerUpdateSettings.ts";
+
+const decodeAgySettings = Schema.decodeSync(AgySettings);
+
+const DRIVER_KIND = ProviderDriverKind.make("agy");
+
+const UPDATE = makeStaticProviderMaintenanceResolver(
+ makeManualOnlyProviderMaintenanceCapabilities({
+ provider: DRIVER_KIND,
+ packageName: null,
+ }),
+);
+
+export type AgyDriverEnv =
+ | BackgroundPolicy.BackgroundPolicy
+ | ChildProcessSpawner.ChildProcessSpawner
+ | Crypto.Crypto
+ | FileSystem.FileSystem
+ | HttpClient.HttpClient
+ | Path.Path
+ | ProviderEventLoggers
+ | ServerSettingsService;
+
+const withInstanceIdentity =
+ (input: {
+ readonly instanceId: ProviderInstance["instanceId"];
+ readonly displayName: string | undefined;
+ readonly accentColor: string | undefined;
+ readonly continuationGroupKey: string;
+ }) =>
+ (snapshot: ServerProviderDraft): ServerProvider => ({
+ ...snapshot,
+ instanceId: input.instanceId,
+ driver: DRIVER_KIND,
+ ...(input.displayName ? { displayName: input.displayName } : {}),
+ ...(input.accentColor ? { accentColor: input.accentColor } : {}),
+ continuation: { groupKey: input.continuationGroupKey },
+ });
+
+export const AgyDriver: ProviderDriver = {
+ driverKind: DRIVER_KIND,
+ metadata: {
+ displayName: "Antigravity",
+ supportsMultipleInstances: true,
+ },
+ configSchema: AgySettings,
+ defaultConfig: (): AgySettings => decodeAgySettings({}),
+ create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const serverSettings = yield* ServerSettingsService;
+ const eventLoggers = yield* ProviderEventLoggers;
+ const processEnv = mergeProviderInstanceEnvironment(environment);
+ const continuationIdentity = defaultProviderContinuationIdentity({
+ driverKind: DRIVER_KIND,
+ instanceId,
+ });
+ const stampIdentity = withInstanceIdentity({
+ instanceId,
+ displayName,
+ accentColor,
+ continuationGroupKey: continuationIdentity.continuationKey,
+ });
+ const effectiveConfig = { ...config, enabled } satisfies AgySettings;
+ const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
+ binaryPath: effectiveConfig.binaryPath,
+ env: processEnv,
+ });
+
+ const adapter = yield* makeAgyAdapter(effectiveConfig, {
+ environment: processEnv,
+ ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
+ instanceId,
+ });
+ const textGeneration = yield* makeAgyTextGeneration(effectiveConfig, processEnv);
+
+ const checkProvider = checkAgyProviderStatus(effectiveConfig, processEnv).pipe(
+ Effect.map(stampIdentity),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ );
+
+ const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
+ const snapshot = yield* makeManagedServerProvider>({
+ maintenanceCapabilities,
+ getSettings: snapshotSettings.getSettings,
+ streamSettings: snapshotSettings.streamSettings,
+ haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ initialSnapshot: (settings) =>
+ buildInitialAgyProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
+ checkProvider,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build the Antigravity provider snapshot.",
+ cause,
+ }),
+ ),
+ );
+
+ return {
+ instanceId,
+ driverKind: DRIVER_KIND,
+ continuationIdentity,
+ displayName,
+ accentColor,
+ enabled,
+ snapshot,
+ adapter,
+ textGeneration,
+ } satisfies ProviderInstance;
+ }),
+};
diff --git a/apps/server/src/provider/Layers/AgyAdapter.test.ts b/apps/server/src/provider/Layers/AgyAdapter.test.ts
new file mode 100644
index 000000000000..e9b51f59e21b
--- /dev/null
+++ b/apps/server/src/provider/Layers/AgyAdapter.test.ts
@@ -0,0 +1,384 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodePath from "node:path";
+import * as NodeOS from "node:os";
+import * as NodeFSP from "node:fs/promises";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
+
+import {
+ AgySettings,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+ type ProviderRuntimeEvent,
+} from "@t3tools/contracts";
+
+import { ServerConfig } from "../../config.ts";
+import { makeAgyAdapter } from "./AgyAdapter.ts";
+
+const decodeAgySettings = Schema.decodeSync(AgySettings);
+
+async function makeMockAgyWrapper(options?: { readonly emitInit?: boolean }) {
+ const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "agy-mock-"));
+ const wrapperPath = NodePath.join(dir, "fake-agy.mjs");
+ const script = `
+import readline from "node:readline";
+
+const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
+
+console.log("malformed native event");
+if (${JSON.stringify(options?.emitInit !== false)}) {
+ console.log(JSON.stringify({
+ event: "init",
+ conversation_id: "conv-12345",
+ init: { cwd: process.cwd(), tools: ["run_command", "write_to_file"] }
+ }));
+}
+
+rl.on("line", (line) => {
+ if (!line.trim()) return;
+ const msg = JSON.parse(line);
+ if (msg.event === "user") {
+ if (msg.message.content === "hang") return;
+ if (msg.message.content === "crash") {
+ process.exit(7);
+ return;
+ }
+ // Emit step updates for user input, assistant response, and tool call
+ console.log(JSON.stringify({
+ event: "step_update",
+ step_update: { step_index: 0, state: "DONE", step_type: "user_input" }
+ }));
+ console.log(JSON.stringify({
+ event: "step_update",
+ step_update: {
+ step_index: 1,
+ state: "ACTIVE",
+ step_type: "agent_response",
+ text_delta: "Hello from "
+ }
+ }));
+ console.log(JSON.stringify({
+ event: "step_update",
+ step_update: {
+ step_index: 2,
+ state: "ACTIVE",
+ step_type: "tool",
+ tool_name: "run_command",
+ tool_info: { name: "run_command", parameters: { command: "pwd" } }
+ }
+ }));
+ console.log(JSON.stringify({
+ event: "step_update",
+ step_update: {
+ step_index: 2,
+ state: "DONE",
+ step_type: "tool",
+ tool_name: "run_command",
+ tool_info: { name: "run_command" },
+ output: "/tmp/project"
+ }
+ }));
+ console.log(JSON.stringify({
+ event: "step_update",
+ step_update: {
+ step_index: 1,
+ state: "DONE",
+ step_type: "agent_response",
+ text_delta: "mock agy!"
+ }
+ }));
+ console.log(JSON.stringify({
+ event: "result",
+ result: {
+ conversation_id: "conv-12345",
+ status: "SUCCESS",
+ response: "Hello from mock agy!",
+ num_turns: 1,
+ usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }
+ }
+ }));
+ }
+});
+`;
+ await NodeFSP.writeFile(wrapperPath, script, "utf8");
+
+ const shPath = NodePath.join(dir, "fake-agy.sh");
+ const argsPath = NodePath.join(dir, "args.log");
+ await NodeFSP.writeFile(
+ shPath,
+ [
+ "#!/bin/sh",
+ `printf '%s\\n' "$*" >> ${JSON.stringify(argsPath)}`,
+ `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(wrapperPath)} "$@"`,
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ await NodeFSP.chmod(shPath, 0o755);
+ return { binaryPath: shPath, argsPath, dir };
+}
+
+const agyAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3code-agy-adapter-test-",
+}).pipe(Layer.provideMerge(NodeServices.layer));
+
+describe("AgyAdapter", () => {
+ it.layer(agyAdapterTestLayer)(
+ "starts a session, sends a turn, and receives streamed events",
+ (it) => {
+ it.effect("completes a full turn cycle", () =>
+ Effect.gen(function* () {
+ const mock = yield* Effect.promise(() => makeMockAgyWrapper());
+ const adapter = yield* makeAgyAdapter(
+ decodeAgySettings({ binaryPath: mock.binaryPath, launchArgs: "--agent reviewer" }),
+ );
+
+ const threadId = ThreadId.make("thread-1");
+
+ const receivedEvents: Array = [];
+ const turnCompletions =
+ yield* Queue.unbounded>();
+
+ const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.sync(() => {
+ receivedEvents.push(event);
+ }).pipe(
+ Effect.andThen(
+ event.type === "turn.completed" ? Queue.offer(turnCompletions, event) : Effect.void,
+ ),
+ ),
+ ).pipe(Effect.forkChild);
+
+ const session = yield* adapter.startSession({
+ threadId,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("agy"),
+ model: "gemini-3.7-flash-high",
+ options: [{ id: "reasoningEffort", value: "low" }],
+ },
+ });
+
+ expect(session.threadId).toBe(threadId);
+ expect(session.provider).toBe(ProviderDriverKind.make("agy"));
+ expect(yield* adapter.hasSession(threadId)).toBe(true);
+
+ const result = yield* adapter.sendTurn({
+ threadId,
+ input: "Say hello",
+ });
+
+ expect(result.threadId).toBe(threadId);
+ expect(result.turnId).toBeTruthy();
+ expect(result.resumeCursor).toEqual({
+ schemaVersion: 1,
+ conversationId: "conv-12345",
+ });
+
+ yield* Queue.take(turnCompletions);
+
+ const turnCompletedEvent = receivedEvents.find((e) => e.type === "turn.completed");
+ expect(turnCompletedEvent).toBeDefined();
+ if (turnCompletedEvent && turnCompletedEvent.type === "turn.completed") {
+ expect(turnCompletedEvent.payload.state).toBe("completed");
+ expect(turnCompletedEvent.payload.usage).toEqual({
+ inputTokens: 10,
+ outputTokens: 5,
+ totalTokens: 15,
+ });
+ }
+
+ const deltaEvents = receivedEvents.filter((e) => e.type === "content.delta");
+ expect(deltaEvents.length).toBeGreaterThan(0);
+ expect(
+ receivedEvents.some(
+ (event) =>
+ event.type === "item.completed" &&
+ event.payload.itemType === "command_execution" &&
+ event.payload.status === "completed",
+ ),
+ ).toBe(true);
+
+ const secondResult = yield* adapter.sendTurn({
+ threadId,
+ input: "Say hello again",
+ interactionMode: "plan",
+ });
+ yield* Queue.take(turnCompletions);
+ expect(secondResult.turnId).not.toBe(result.turnId);
+
+ const switchedResult = yield* adapter.sendTurn({
+ threadId,
+ input: "Use the medium model",
+ interactionMode: "plan",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("agy"),
+ model: "gemini-3.7-flash-high",
+ options: [{ id: "reasoningEffort", value: "medium" }],
+ },
+ });
+ yield* Queue.take(turnCompletions);
+ expect(switchedResult.turnId).not.toBe(secondResult.turnId);
+
+ yield* adapter.sendTurn({
+ threadId,
+ input: "Use the default model",
+ interactionMode: "plan",
+ modelSelection: { instanceId: ProviderInstanceId.make("agy"), model: "" },
+ });
+ yield* Queue.take(turnCompletions);
+ expect((yield* adapter.listSessions())[0]?.model).toBeUndefined();
+
+ const snapshot = yield* adapter.readThread(threadId);
+ expect(snapshot.threadId).toBe(threadId);
+ expect(snapshot.turns).toHaveLength(4);
+ expect(snapshot.turns.every((turn) => turn.items.length > 0)).toBe(true);
+ const rollbackError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip);
+ expect(rollbackError.message).toContain("do not support provider-side rollback");
+
+ const invocations = yield* Effect.promise(() => NodeFSP.readFile(mock.argsPath, "utf8"));
+ expect(invocations).toContain("--dangerously-skip-permissions");
+ expect(invocations).toContain("--mode accept-edits");
+ expect(invocations).toContain("--mode plan");
+ expect(invocations).toContain("--agent reviewer");
+ expect(invocations).toContain("--conversation conv-12345");
+ expect(invocations).toContain("--model gemini-3.7-flash-low --effort low");
+ expect(invocations).toContain("--model gemini-3.7-flash-medium --effort medium");
+ expect(adapter.capabilities.sessionModelSwitch).toBe("in-session");
+
+ yield* adapter.stopSession(threadId);
+ expect(yield* adapter.hasSession(threadId)).toBe(false);
+
+ yield* Fiber.interrupt(eventFiber);
+ yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true }));
+ }),
+ );
+
+ it.effect("uses the sandbox for non-full-access sessions", () =>
+ Effect.gen(function* () {
+ const mock = yield* Effect.promise(() => makeMockAgyWrapper());
+ const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath }));
+ const threadId = ThreadId.make("thread-sandbox");
+
+ yield* adapter.startSession({
+ threadId,
+ cwd: process.cwd(),
+ runtimeMode: "approval-required",
+ });
+ yield* adapter.sendTurn({ threadId, input: "Inspect safely" });
+
+ const invocation = yield* Effect.promise(() => NodeFSP.readFile(mock.argsPath, "utf8"));
+ expect(invocation).toContain("--sandbox");
+ expect(invocation).not.toContain("--dangerously-skip-permissions");
+
+ yield* adapter.stopSession(threadId);
+ yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true }));
+ }),
+ );
+
+ it.effect("cleans local session state when initialization times out", () =>
+ Effect.gen(function* () {
+ const mock = yield* Effect.promise(() => makeMockAgyWrapper({ emitInit: false }));
+ const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath }));
+ const threadId = ThreadId.make("thread-init-timeout");
+
+ yield* adapter.startSession({
+ threadId,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ resumeCursor: { schemaVersion: 1, conversationId: "conv-resumed" },
+ });
+ const sendFiber = yield* adapter
+ .sendTurn({ threadId, input: "Wait for initialization" })
+ .pipe(Effect.flip, Effect.forkChild);
+ yield* TestClock.adjust("10 seconds");
+ const error = yield* Fiber.join(sendFiber);
+
+ expect(error.message).toContain("did not initialize");
+ const session = (yield* adapter.listSessions())[0];
+ expect(session?.status).toBe("ready");
+ expect(session?.activeTurnId).toBeUndefined();
+
+ yield* adapter.stopSession(threadId);
+ yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true }));
+ }),
+ );
+
+ it.effect("interrupts an active turn and keeps the session ready", () =>
+ Effect.gen(function* () {
+ const mock = yield* Effect.promise(() => makeMockAgyWrapper());
+ const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath }));
+ const threadId = ThreadId.make("thread-interrupt");
+ const completions =
+ yield* Queue.unbounded>();
+ const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ event.type === "turn.completed" ? Queue.offer(completions, event) : Effect.void,
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const turn = yield* adapter.sendTurn({ threadId, input: "hang" });
+ yield* adapter.interruptTurn(threadId, turn.turnId);
+
+ expect((yield* Queue.take(completions)).payload.state).toBe("interrupted");
+ expect((yield* adapter.listSessions())[0]?.status).toBe("ready");
+
+ const activeTurn = yield* adapter.sendTurn({ threadId, input: "hang" });
+ yield* adapter.interruptTurn(threadId, turn.turnId);
+ expect((yield* adapter.listSessions())[0]?.status).toBe("running");
+ expect((yield* adapter.listSessions())[0]?.activeTurnId).toBe(activeTurn.turnId);
+
+ yield* adapter.interruptTurn(threadId, activeTurn.turnId);
+ expect((yield* Queue.take(completions)).payload.state).toBe("interrupted");
+ expect((yield* adapter.listSessions())[0]?.status).toBe("ready");
+
+ yield* adapter.stopSession(threadId);
+ yield* Fiber.interrupt(eventFiber);
+ yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true }));
+ }),
+ );
+
+ it.effect("fails the active turn when the CLI exits unexpectedly", () =>
+ Effect.gen(function* () {
+ const mock = yield* Effect.promise(() => makeMockAgyWrapper());
+ const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath }));
+ const threadId = ThreadId.make("thread-crash");
+ const completions =
+ yield* Queue.unbounded>();
+ const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ event.type === "turn.completed" ? Queue.offer(completions, event) : Effect.void,
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "crash" });
+
+ const completed = yield* Queue.take(completions);
+ expect(completed.payload.state).toBe("failed");
+ expect(completed.payload.errorMessage).toContain("exited before the turn completed");
+ expect((yield* adapter.listSessions())[0]?.status).toBe("error");
+
+ yield* adapter.stopSession(threadId);
+ yield* Fiber.interrupt(eventFiber);
+ yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true }));
+ }),
+ );
+ },
+ );
+});
diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts
new file mode 100644
index 000000000000..50df9dfa429c
--- /dev/null
+++ b/apps/server/src/provider/Layers/AgyAdapter.ts
@@ -0,0 +1,1053 @@
+/**
+ * AgyAdapter — provider adapter for the Google Antigravity CLI (agy).
+ *
+ * Drives the `agy` CLI in headless streaming JSON mode (`--input-format stream-json --output-format stream-json`)
+ * and translates output events into T3 Code provider runtime events.
+ *
+ * @module AgyAdapter
+ */
+import {
+ type AgySettings,
+ type ModelSelection,
+ EventId,
+ type ProviderInteractionMode,
+ ProviderInstanceId,
+ type ProviderRuntimeEvent,
+ type ProviderSession,
+ type ProviderSessionStartInput,
+ type ProviderSendTurnInput,
+ type ProviderTurnStartResult,
+ type ThreadId,
+ TurnId,
+ ProviderDriverKind,
+ RuntimeItemId,
+} from "@t3tools/contracts";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
+import * as Option from "effect/Option";
+import * as PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import * as Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+import type * as ChildProcessSpawnerTypes from "effect/unstable/process/ChildProcessSpawner";
+import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import { tokenizeCliArgs } from "@t3tools/shared/cliArgs";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+
+import { resolveAgyModelForEffort } from "../AgyModelSelection.ts";
+import {
+ ProviderAdapterProcessError,
+ ProviderAdapterRequestError,
+ ProviderAdapterSessionNotFoundError,
+ ProviderAdapterValidationError,
+} from "../Errors.ts";
+import { type AgyAdapterShape } from "../Services/AgyAdapter.ts";
+import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
+
+const PROVIDER = ProviderDriverKind.make("agy");
+const AGY_RESUME_VERSION = 1 as const;
+const AGY_INIT_TIMEOUT_MS = 10_000;
+
+const AgyToolInfo = Schema.Struct({
+ name: Schema.optional(Schema.String),
+ parameters: Schema.optional(Schema.Unknown),
+ output: Schema.optional(Schema.Unknown),
+ error: Schema.optional(Schema.Unknown),
+});
+
+const AgyNativeEvent = Schema.Union([
+ Schema.Struct({
+ event: Schema.Literal("init"),
+ conversation_id: Schema.optional(Schema.String),
+ init: Schema.optional(Schema.Struct({ conversation_id: Schema.optional(Schema.String) })),
+ }),
+ Schema.Struct({
+ event: Schema.Literal("step_update"),
+ step_update: Schema.Struct({
+ step_index: Schema.optional(Schema.Number),
+ state: Schema.optional(Schema.String),
+ step_type: Schema.optional(Schema.String),
+ text_delta: Schema.optional(Schema.String),
+ tool_name: Schema.optional(Schema.String),
+ tool_info: Schema.optional(AgyToolInfo),
+ output: Schema.optional(Schema.Unknown),
+ error: Schema.optional(Schema.Unknown),
+ }),
+ }),
+ Schema.Struct({
+ event: Schema.Literal("result"),
+ result: Schema.optional(
+ Schema.Struct({
+ status: Schema.optional(Schema.String),
+ error: Schema.optional(Schema.String),
+ usage: Schema.optional(
+ Schema.Struct({
+ input_tokens: Schema.optional(Schema.Number),
+ output_tokens: Schema.optional(Schema.Number),
+ total_tokens: Schema.optional(Schema.Number),
+ }),
+ ),
+ }),
+ ),
+ }),
+]);
+
+const AgyUserMessage = Schema.Struct({
+ event: Schema.Literal("user"),
+ message: Schema.Struct({ content: Schema.String }),
+});
+
+const decodeAgyNativeEventExit = Schema.decodeUnknownExit(Schema.fromJsonString(AgyNativeEvent));
+const encodeAgyUserMessage = Schema.encodeEffect(Schema.fromJsonString(AgyUserMessage));
+const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
+
+function encodeJsonForDisplay(value: unknown): string | undefined {
+ const result = encodeUnknownJsonStringExit(value);
+ return Exit.isSuccess(result) ? result.value : undefined;
+}
+
+export interface AgyAdapterLiveOptions {
+ readonly environment?: NodeJS.ProcessEnv;
+ readonly nativeEventLogPath?: string;
+ readonly nativeEventLogger?: EventNdjsonLogger;
+ readonly instanceId?: ProviderInstanceId;
+}
+
+interface EventBaseInput {
+ readonly threadId: ThreadId;
+ readonly turnId?: TurnId | undefined;
+ readonly itemId?: RuntimeItemId | undefined;
+ readonly createdAt?: string | undefined;
+}
+
+interface AgyTurnState {
+ readonly turnId: TurnId;
+ readonly items: Array;
+ assistantItemId: RuntimeItemId | undefined;
+ toolItemIds: Map;
+}
+
+interface AgySessionContext {
+ readonly threadId: ThreadId;
+ session: ProviderSession;
+ cwd: string;
+ conversationId: string | undefined;
+ conversationReady: Deferred.Deferred;
+ activeTurnId: TurnId | undefined;
+ currentTurnState: AgyTurnState | undefined;
+ childProcess: ChildProcessSpawnerTypes.ChildProcessHandle | undefined;
+ stdinQueue: Queue.Queue | undefined;
+ stdinFiber: Fiber.Fiber | undefined;
+ readFiber: Fiber.Fiber | undefined;
+ readonly scope: Scope.Closeable;
+ readonly turns: Array<{ id: TurnId; items: Array }>;
+ threadStarted: boolean;
+ stopped: boolean;
+ modelSelection: ModelSelection | undefined;
+ interactionMode: ProviderInteractionMode;
+}
+
+function parseAgyResume(raw: unknown): { conversationId: string } | undefined {
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
+ const record = raw as Record;
+ if (record.schemaVersion !== AGY_RESUME_VERSION) return undefined;
+ if (typeof record.conversationId !== "string" || !record.conversationId.trim()) return undefined;
+ return { conversationId: record.conversationId.trim() };
+}
+
+export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* (
+ agySettings: AgySettings,
+ options?: AgyAdapterLiveOptions,
+) {
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const crypto = yield* Crypto.Crypto;
+ const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("agy");
+ const events = yield* PubSub.unbounded();
+ const sessions = new Map();
+ const environment = options?.environment ?? process.env;
+ const nativeLogger =
+ options?.nativeEventLogger ??
+ (options?.nativeEventLogPath !== undefined
+ ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" })
+ : undefined);
+ const managedNativeLogger = options?.nativeEventLogger === undefined ? nativeLogger : undefined;
+
+ const randomUUIDv4 = crypto.randomUUIDv4.pipe(Effect.orDie);
+ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+
+ const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid);
+
+ const buildEventBase = (input: EventBaseInput) =>
+ Effect.gen(function* () {
+ const eventId = EventId.make(yield* randomUUIDv4);
+ const createdAt = input.createdAt ?? (yield* nowIso);
+ return {
+ eventId,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ createdAt,
+ threadId: input.threadId,
+ ...(input.turnId ? { turnId: input.turnId } : {}),
+ ...(input.itemId ? { itemId: input.itemId } : {}),
+ };
+ });
+
+ const ensureSessionContext = (threadId: ThreadId) =>
+ Effect.gen(function* () {
+ const context = sessions.get(threadId);
+ if (!context || context.stopped) {
+ return yield* new ProviderAdapterSessionNotFoundError({
+ provider: PROVIDER,
+ threadId,
+ });
+ }
+ return context;
+ });
+
+ const stopChildProcess = (context: AgySessionContext) =>
+ Effect.gen(function* () {
+ if (context.stdinQueue) {
+ yield* Queue.shutdown(context.stdinQueue);
+ context.stdinQueue = undefined;
+ }
+ if (context.stdinFiber) {
+ yield* Fiber.interrupt(context.stdinFiber);
+ context.stdinFiber = undefined;
+ }
+ if (context.readFiber) {
+ yield* Fiber.interrupt(context.readFiber);
+ context.readFiber = undefined;
+ }
+ if (context.childProcess) {
+ const childProcess = context.childProcess;
+ context.childProcess = undefined;
+ yield* childProcess.kill().pipe(Effect.ignore);
+ }
+ });
+
+ const handleNdjsonLine = (context: AgySessionContext, line: string) =>
+ Effect.gen(function* () {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+
+ const decoded = decodeAgyNativeEventExit(trimmed);
+ if (!Exit.isSuccess(decoded)) {
+ yield* Effect.logWarning("Ignoring malformed Antigravity event.", {
+ threadId: context.threadId,
+ });
+ return;
+ }
+ const parsed = decoded.value;
+ if (nativeLogger) {
+ yield* nativeLogger.write(parsed, context.threadId);
+ }
+ const eventType = parsed.event;
+
+ if (eventType === "init") {
+ const conversationId = parsed.conversation_id ?? parsed.init?.conversation_id;
+ if (typeof conversationId === "string" && conversationId.trim()) {
+ context.conversationId = conversationId.trim();
+ context.session = {
+ ...context.session,
+ resumeCursor: {
+ schemaVersion: AGY_RESUME_VERSION,
+ conversationId: context.conversationId,
+ },
+ updatedAt: yield* nowIso,
+ };
+ yield* Deferred.succeed(context.conversationReady, context.conversationId);
+ if (!context.threadStarted) {
+ context.threadStarted = true;
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: context.threadId })),
+ type: "thread.started",
+ payload: { providerThreadId: context.conversationId },
+ });
+ }
+ }
+ return;
+ }
+
+ if (eventType === "step_update") {
+ const stepUpdate = parsed.step_update;
+ if (!stepUpdate) return;
+
+ const turnId = context.activeTurnId;
+ if (!turnId) return;
+ context.currentTurnState?.items.push(parsed);
+
+ const stepType = stepUpdate.step_type;
+ const stepState = stepUpdate.state; // "ACTIVE" | "DONE"
+ const stepIndex = typeof stepUpdate.step_index === "number" ? stepUpdate.step_index : 0;
+
+ if (stepType === "agent_response") {
+ const textDelta = stepUpdate.text_delta;
+ if (typeof textDelta === "string" && textDelta.length > 0) {
+ if (!context.currentTurnState?.assistantItemId) {
+ const assistantItemId = RuntimeItemId.make(yield* randomUUIDv4);
+ if (context.currentTurnState) {
+ context.currentTurnState.assistantItemId = assistantItemId;
+ }
+ yield* emit({
+ ...(yield* buildEventBase({
+ threadId: context.threadId,
+ turnId,
+ itemId: assistantItemId,
+ })),
+ type: "item.started",
+ payload: {
+ itemType: "assistant_message",
+ status: "inProgress",
+ },
+ });
+ }
+
+ const itemId = context.currentTurnState?.assistantItemId;
+ if (itemId) {
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: context.threadId, turnId, itemId })),
+ type: "content.delta",
+ payload: {
+ streamKind: "assistant_text",
+ delta: textDelta,
+ },
+ });
+ }
+ }
+
+ if (stepState === "DONE" && context.currentTurnState?.assistantItemId) {
+ const itemId = context.currentTurnState.assistantItemId;
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: context.threadId, turnId, itemId })),
+ type: "item.completed",
+ payload: {
+ itemType: "assistant_message",
+ status: "completed",
+ },
+ });
+ context.currentTurnState.assistantItemId = undefined;
+ }
+ return;
+ }
+
+ if (stepType === "tool") {
+ const toolName = stepUpdate.tool_name ?? stepUpdate.tool_info?.name ?? "tool";
+ const canonicalItemType =
+ toolName === "run_command"
+ ? "command_execution"
+ : toolName === "write_to_file" ||
+ toolName === "replace_file_content" ||
+ toolName === "multi_replace_file_content"
+ ? "file_change"
+ : "dynamic_tool_call";
+ let toolItemId = context.currentTurnState?.toolItemIds.get(stepIndex);
+
+ if (stepState === "ACTIVE" && !toolItemId) {
+ toolItemId = RuntimeItemId.make(yield* randomUUIDv4);
+ context.currentTurnState?.toolItemIds.set(stepIndex, toolItemId);
+
+ yield* emit({
+ ...(yield* buildEventBase({
+ threadId: context.threadId,
+ turnId,
+ itemId: toolItemId,
+ })),
+ type: "item.started",
+ payload: {
+ itemType: canonicalItemType,
+ status: "inProgress",
+ title: toolName,
+ data: stepUpdate.tool_info?.parameters,
+ },
+ });
+ }
+
+ if (stepState === "DONE") {
+ if (!toolItemId) {
+ toolItemId = RuntimeItemId.make(yield* randomUUIDv4);
+ context.currentTurnState?.toolItemIds.set(stepIndex, toolItemId);
+ yield* emit({
+ ...(yield* buildEventBase({
+ threadId: context.threadId,
+ turnId,
+ itemId: toolItemId,
+ })),
+ type: "item.started",
+ payload: {
+ itemType: canonicalItemType,
+ status: "inProgress",
+ title: toolName,
+ },
+ });
+ }
+
+ const output = stepUpdate.output ?? stepUpdate.tool_info?.output;
+ const error = stepUpdate.error ?? stepUpdate.tool_info?.error;
+
+ if (output !== undefined) {
+ const delta = typeof output === "string" ? output : encodeJsonForDisplay(output);
+ if (delta !== undefined) {
+ yield* emit({
+ ...(yield* buildEventBase({
+ threadId: context.threadId,
+ turnId,
+ itemId: toolItemId,
+ })),
+ type: "content.delta",
+ payload: {
+ streamKind:
+ canonicalItemType === "command_execution"
+ ? "command_output"
+ : canonicalItemType === "file_change"
+ ? "file_change_output"
+ : "unknown",
+ delta,
+ },
+ });
+ }
+ }
+
+ yield* emit({
+ ...(yield* buildEventBase({
+ threadId: context.threadId,
+ turnId,
+ itemId: toolItemId,
+ })),
+ type: "item.completed",
+ payload: {
+ itemType: canonicalItemType,
+ status: error ? "failed" : "completed",
+ ...(error
+ ? {
+ detail:
+ typeof error === "string"
+ ? error
+ : (encodeJsonForDisplay(error) ?? "Antigravity tool failed."),
+ }
+ : {}),
+ },
+ });
+ context.currentTurnState?.toolItemIds.delete(stepIndex);
+ }
+ return;
+ }
+
+ return;
+ }
+
+ if (eventType === "result") {
+ const result = parsed.result;
+ const turnId = context.activeTurnId;
+ if (!turnId) return;
+ context.currentTurnState?.items.push(parsed);
+
+ const status = result?.status;
+ const state =
+ status === "SUCCESS"
+ ? "completed"
+ : status === "CANCELED"
+ ? "cancelled"
+ : status === "INTERRUPTED"
+ ? "interrupted"
+ : "failed";
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: context.threadId, turnId })),
+ type: "turn.completed",
+ payload: {
+ state,
+ ...(state === "failed"
+ ? {
+ errorMessage:
+ result?.error ?? `Antigravity turn ended with status ${status ?? "UNKNOWN"}.`,
+ }
+ : {}),
+ usage: {
+ inputTokens: result?.usage?.input_tokens ?? 0,
+ outputTokens: result?.usage?.output_tokens ?? 0,
+ totalTokens: result?.usage?.total_tokens ?? 0,
+ },
+ },
+ });
+
+ context.activeTurnId = undefined;
+ context.currentTurnState = undefined;
+ context.session = {
+ ...context.session,
+ status: state === "failed" ? "error" : "ready",
+ activeTurnId: undefined,
+ updatedAt: yield* nowIso,
+ ...(state === "failed"
+ ? {
+ lastError:
+ result?.error ?? `Antigravity turn ended with status ${status ?? "UNKNOWN"}.`,
+ }
+ : {}),
+ };
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: context.threadId })),
+ type: "session.state.changed",
+ payload: {
+ state: state === "failed" ? "error" : "ready",
+ ...(state === "failed"
+ ? {
+ reason:
+ result?.error ?? `Antigravity turn ended with status ${status ?? "UNKNOWN"}.`,
+ }
+ : {}),
+ },
+ });
+ }
+ });
+
+ const spawnAgyProcess = (
+ context: AgySessionContext,
+ modelSelection: ModelSelection | undefined,
+ interactionMode: ProviderInteractionMode | undefined,
+ ) =>
+ Effect.gen(function* () {
+ yield* stopChildProcess(context);
+ context.conversationReady = yield* Deferred.make();
+
+ const commandName = agySettings.binaryPath || "agy";
+ const effort = modelSelection
+ ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort")
+ : undefined;
+ const model = resolveAgyModelForEffort(modelSelection?.model, effort);
+ const mode = interactionMode === "plan" ? "plan" : "accept-edits";
+
+ const args = [
+ "--input-format",
+ "stream-json",
+ "--output-format",
+ "stream-json",
+ ...(context.session.runtimeMode === "full-access"
+ ? ["--dangerously-skip-permissions"]
+ : ["--sandbox"]),
+ ...(context.conversationId ? ["--conversation", context.conversationId] : []),
+ ...(model ? ["--model", model] : []),
+ ...(effort ? ["--effort", effort] : []),
+ "--mode",
+ mode,
+ ...tokenizeCliArgs(agySettings.launchArgs),
+ ];
+
+ const spawnCommand = yield* resolveSpawnCommand(commandName, args, {
+ env: environment,
+ });
+
+ const childCommand = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: environment,
+ cwd: context.cwd,
+ shell: spawnCommand.shell,
+ });
+
+ const spawned = yield* childProcessSpawner.spawn(childCommand).pipe(
+ Effect.provideService(Scope.Scope, context.scope),
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId: context.threadId,
+ detail: `Failed to spawn Antigravity CLI (${commandName})`,
+ cause,
+ }),
+ ),
+ );
+
+ context.childProcess = spawned;
+ const stdinQueue = yield* Queue.unbounded();
+ const stdinFiber = yield* Stream.fromQueue(stdinQueue).pipe(
+ Stream.run(spawned.stdin),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Failed to write to Antigravity stdin stream.", {
+ threadId: context.threadId,
+ cause,
+ }),
+ ),
+ Effect.forkIn(context.scope),
+ );
+ context.stdinQueue = stdinQueue;
+ context.stdinFiber = stdinFiber;
+
+ const readFiber = yield* spawned.stdout.pipe(
+ Stream.decodeText(),
+ Stream.splitLines,
+ Stream.runForEach((line) => handleNdjsonLine(context, line)),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Error in Antigravity stdout stream", {
+ threadId: context.threadId,
+ cause,
+ }),
+ ),
+ Effect.forkIn(context.scope),
+ );
+
+ context.readFiber = readFiber;
+
+ yield* spawned.stderr.pipe(
+ Stream.decodeText(),
+ Stream.runForEach((message) =>
+ message.trim().length > 0
+ ? Effect.logWarning("Antigravity CLI stderr", {
+ threadId: context.threadId,
+ message: message.trim(),
+ })
+ : Effect.void,
+ ),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Failed to read Antigravity stderr stream.", {
+ threadId: context.threadId,
+ cause,
+ }),
+ ),
+ Effect.forkIn(context.scope),
+ );
+
+ yield* spawned.exitCode.pipe(
+ Effect.andThen(Fiber.join(readFiber)),
+ Effect.flatMap(() =>
+ Effect.gen(function* () {
+ if (context.childProcess !== spawned || context.stopped) {
+ return;
+ }
+
+ context.childProcess = undefined;
+ context.readFiber = undefined;
+ const activeTurnId = context.activeTurnId;
+ if (!activeTurnId) {
+ return;
+ }
+ context.activeTurnId = undefined;
+ context.currentTurnState = undefined;
+ context.session = {
+ ...context.session,
+ status: "error",
+ activeTurnId: undefined,
+ updatedAt: yield* nowIso,
+ lastError: "Antigravity CLI exited before the turn completed.",
+ };
+
+ yield* emit({
+ ...(yield* buildEventBase({
+ threadId: context.threadId,
+ turnId: activeTurnId,
+ })),
+ type: "turn.completed",
+ payload: {
+ state: "failed",
+ errorMessage: "Antigravity CLI exited before the turn completed.",
+ },
+ });
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: context.threadId })),
+ type: "session.state.changed",
+ payload: {
+ state: "error",
+ reason: "Antigravity CLI exited before the turn completed.",
+ },
+ });
+ }),
+ ),
+ Effect.catchCause((cause) =>
+ Effect.logWarning("Failed to monitor Antigravity process exit.", {
+ threadId: context.threadId,
+ cause,
+ }),
+ ),
+ Effect.forkIn(context.scope),
+ );
+ });
+
+ const startSession: AgyAdapterShape["startSession"] = Effect.fn("startSession")(function* (
+ input: ProviderSessionStartInput,
+ ) {
+ if (input.provider !== undefined && input.provider !== PROVIDER) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "startSession",
+ issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
+ });
+ }
+
+ const existing = sessions.get(input.threadId);
+ if (existing) {
+ existing.stopped = true;
+ yield* stopChildProcess(existing);
+ yield* Scope.close(existing.scope, Exit.void);
+ sessions.delete(input.threadId);
+ }
+
+ const startedAt = yield* nowIso;
+ const scope = yield* Scope.make();
+ const conversationReady = yield* Deferred.make();
+ const resumeInfo = parseAgyResume(input.resumeCursor);
+ const conversationId = resumeInfo?.conversationId;
+ const modelSelection =
+ input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
+ const session: ProviderSession = {
+ threadId: input.threadId,
+ provider: PROVIDER,
+ providerInstanceId: boundInstanceId,
+ status: "ready",
+ runtimeMode: input.runtimeMode,
+ createdAt: startedAt,
+ updatedAt: startedAt,
+ ...(input.cwd ? { cwd: input.cwd } : {}),
+ ...(modelSelection?.model ? { model: modelSelection.model } : {}),
+ ...(conversationId
+ ? { resumeCursor: { schemaVersion: AGY_RESUME_VERSION, conversationId } }
+ : {}),
+ };
+
+ const context: AgySessionContext = {
+ threadId: input.threadId,
+ session,
+ cwd: input.cwd ?? process.cwd(),
+ conversationId,
+ conversationReady,
+ activeTurnId: undefined,
+ currentTurnState: undefined,
+ childProcess: undefined,
+ stdinQueue: undefined,
+ stdinFiber: undefined,
+ readFiber: undefined,
+ scope,
+ turns: [],
+ threadStarted: conversationId !== undefined,
+ stopped: false,
+ modelSelection,
+ interactionMode: "default",
+ };
+
+ sessions.set(input.threadId, context);
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: input.threadId })),
+ type: "session.started",
+ payload: session.resumeCursor !== undefined ? { resume: session.resumeCursor } : {},
+ });
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: input.threadId })),
+ type: "session.state.changed",
+ payload: {
+ state: "ready",
+ },
+ });
+ if (conversationId !== undefined) {
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: input.threadId })),
+ type: "thread.started",
+ payload: { providerThreadId: conversationId },
+ });
+ }
+
+ return session;
+ });
+
+ const sendTurn: AgyAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (
+ input: ProviderSendTurnInput,
+ ) {
+ const context = yield* ensureSessionContext(input.threadId);
+ if (context.session.status === "error") {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "sendTurn",
+ detail: "Antigravity session is in an error state and must be restarted.",
+ });
+ }
+ if (context.activeTurnId !== undefined) {
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "sendTurn",
+ detail: "Antigravity does not accept a new prompt while a turn is running.",
+ });
+ }
+ const turnId = TurnId.make(yield* randomUUIDv4);
+ const interactionMode = input.interactionMode ?? context.interactionMode;
+ const modelSelection =
+ input.modelSelection?.instanceId === boundInstanceId
+ ? input.modelSelection
+ : context.modelSelection;
+
+ const currentEffort = context.modelSelection
+ ? getModelSelectionStringOptionValue(context.modelSelection, "reasoningEffort")
+ : undefined;
+ const nextEffort = modelSelection
+ ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort")
+ : undefined;
+ const modelSelectionChanged =
+ resolveAgyModelForEffort(context.modelSelection?.model, currentEffort) !==
+ resolveAgyModelForEffort(modelSelection?.model, nextEffort) || currentEffort !== nextEffort;
+
+ if (
+ context.childProcess &&
+ (interactionMode !== context.interactionMode || modelSelectionChanged)
+ ) {
+ yield* stopChildProcess(context);
+ }
+
+ if (!context.childProcess) {
+ yield* spawnAgyProcess(context, modelSelection, interactionMode);
+ }
+ context.interactionMode = interactionMode;
+ context.modelSelection = modelSelection;
+
+ context.activeTurnId = turnId;
+ const turnItems: Array = [];
+ context.currentTurnState = {
+ turnId,
+ items: turnItems,
+ assistantItemId: undefined,
+ toolItemIds: new Map(),
+ };
+ context.turns.push({ id: turnId, items: turnItems });
+ context.session = {
+ ...context.session,
+ status: "running",
+ activeTurnId: turnId,
+ updatedAt: yield* nowIso,
+ ...(modelSelection?.model ? { model: modelSelection.model } : { model: undefined }),
+ };
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
+ type: "turn.started",
+ payload: modelSelection?.model ? { model: modelSelection.model } : {},
+ });
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId: input.threadId })),
+ type: "session.state.changed",
+ payload: {
+ state: "running",
+ },
+ });
+
+ const userMessagePayload = yield* encodeAgyUserMessage({
+ event: "user",
+ message: {
+ content: input.input ?? "",
+ },
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "sendTurn",
+ detail: "Failed to encode Antigravity prompt.",
+ cause,
+ }),
+ ),
+ );
+
+ if (context.stdinQueue) {
+ const bytes = new TextEncoder().encode(`${userMessagePayload}\n`);
+ yield* Queue.offer(context.stdinQueue, bytes).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterProcessError({
+ provider: PROVIDER,
+ threadId: input.threadId,
+ detail: "Failed to write prompt to Antigravity CLI stdin",
+ cause,
+ }),
+ ),
+ );
+ }
+
+ const initialized = yield* Deferred.await(context.conversationReady).pipe(
+ Effect.timeoutOption(AGY_INIT_TIMEOUT_MS),
+ );
+ if (Option.isNone(initialized)) {
+ yield* stopChildProcess(context);
+ context.activeTurnId = undefined;
+ context.currentTurnState = undefined;
+ context.session = {
+ ...context.session,
+ status: "ready",
+ activeTurnId: undefined,
+ updatedAt: yield* nowIso,
+ };
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "sendTurn",
+ detail: "Antigravity CLI did not initialize a conversation.",
+ });
+ }
+
+ return {
+ threadId: input.threadId,
+ turnId,
+ ...(context.session.resumeCursor !== undefined
+ ? { resumeCursor: context.session.resumeCursor }
+ : {}),
+ } satisfies ProviderTurnStartResult;
+ });
+
+ const interruptTurn: AgyAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(function* (
+ threadId: ThreadId,
+ turnId?: TurnId,
+ ) {
+ const context = yield* ensureSessionContext(threadId);
+ const activeTurnId = turnId ?? context.activeTurnId;
+ if (turnId !== undefined && turnId !== context.activeTurnId) return;
+
+ yield* stopChildProcess(context);
+
+ if (activeTurnId) {
+ yield* emit({
+ ...(yield* buildEventBase({ threadId, turnId: activeTurnId })),
+ type: "turn.completed",
+ payload: {
+ state: "interrupted",
+ },
+ });
+ }
+
+ context.activeTurnId = undefined;
+ context.currentTurnState = undefined;
+ context.session = {
+ ...context.session,
+ status: "ready",
+ activeTurnId: undefined,
+ updatedAt: yield* nowIso,
+ };
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId })),
+ type: "session.state.changed",
+ payload: {
+ state: "ready",
+ },
+ });
+ });
+
+ const stopSession: AgyAdapterShape["stopSession"] = Effect.fn("stopSession")(function* (
+ threadId: ThreadId,
+ ) {
+ const context = sessions.get(threadId);
+ if (!context) {
+ return yield* new ProviderAdapterSessionNotFoundError({
+ provider: PROVIDER,
+ threadId,
+ });
+ }
+
+ context.stopped = true;
+ yield* stopChildProcess(context);
+ yield* Scope.close(context.scope, Exit.void);
+ sessions.delete(threadId);
+
+ yield* emit({
+ ...(yield* buildEventBase({ threadId })),
+ type: "session.exited",
+ payload: {
+ reason: "Session stopped.",
+ recoverable: false,
+ exitKind: "graceful",
+ },
+ });
+ });
+
+ const respondToRequest: AgyAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")(
+ function* (threadId) {
+ yield* ensureSessionContext(threadId);
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "respondToRequest",
+ detail: "Antigravity streaming sessions do not expose interactive approval requests.",
+ });
+ },
+ );
+
+ const respondToUserInput: AgyAdapterShape["respondToUserInput"] = Effect.fn("respondToUserInput")(
+ function* (threadId) {
+ yield* ensureSessionContext(threadId);
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "respondToUserInput",
+ detail: "Antigravity streaming sessions do not expose structured user-input requests.",
+ });
+ },
+ );
+
+ const listSessions: AgyAdapterShape["listSessions"] = () =>
+ Effect.sync(() => [...sessions.values()].map((c) => c.session));
+
+ const hasSession: AgyAdapterShape["hasSession"] = (threadId: ThreadId) =>
+ Effect.sync(() => sessions.has(threadId));
+
+ const readThread: AgyAdapterShape["readThread"] = Effect.fn("readThread")(function* (
+ threadId: ThreadId,
+ ) {
+ const context = yield* ensureSessionContext(threadId);
+ return {
+ threadId,
+ turns: context.turns,
+ };
+ });
+
+ const rollbackThread: AgyAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(function* (
+ threadId: ThreadId,
+ numTurns: number,
+ ) {
+ yield* ensureSessionContext(threadId);
+ if (!Number.isInteger(numTurns) || numTurns < 1) {
+ return yield* new ProviderAdapterValidationError({
+ provider: PROVIDER,
+ operation: "rollbackThread",
+ issue: "numTurns must be an integer >= 1.",
+ });
+ }
+ return yield* new ProviderAdapterRequestError({
+ provider: PROVIDER,
+ method: "thread/rollback",
+ detail: "Antigravity streaming sessions do not support provider-side rollback.",
+ });
+ });
+
+ const stopAll: AgyAdapterShape["stopAll"] = Effect.fn("stopAll")(function* () {
+ for (const context of sessions.values()) {
+ context.stopped = true;
+ yield* stopChildProcess(context);
+ yield* Scope.close(context.scope, Exit.void);
+ }
+ sessions.clear();
+ });
+
+ const streamEvents: AgyAdapterShape["streamEvents"] = Stream.fromPubSub(events);
+
+ yield* Effect.addFinalizer(() =>
+ Effect.ignore(stopAll()).pipe(
+ Effect.tap(() => PubSub.shutdown(events)),
+ Effect.tap(() => managedNativeLogger?.close() ?? Effect.void),
+ ),
+ );
+
+ return {
+ provider: PROVIDER,
+ capabilities: {
+ sessionModelSwitch: "in-session",
+ },
+ startSession,
+ sendTurn,
+ interruptTurn,
+ respondToRequest,
+ respondToUserInput,
+ stopSession,
+ listSessions,
+ hasSession,
+ readThread,
+ rollbackThread,
+ stopAll,
+ streamEvents,
+ } satisfies AgyAdapterShape;
+});
diff --git a/apps/server/src/provider/Layers/AgyProvider.test.ts b/apps/server/src/provider/Layers/AgyProvider.test.ts
new file mode 100644
index 000000000000..5e2bd1edad53
--- /dev/null
+++ b/apps/server/src/provider/Layers/AgyProvider.test.ts
@@ -0,0 +1,187 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { AgySettings } from "@t3tools/contracts";
+
+import {
+ agyModelsFromSettings,
+ buildInitialAgyProviderSnapshot,
+ checkAgyProviderStatus,
+ parseAgyModelsOutput,
+} from "./AgyProvider.ts";
+
+const decodeAgySettings = Schema.decodeSync(AgySettings);
+
+describe("buildInitialAgyProviderSnapshot", () => {
+ it.effect("returns a disabled snapshot when settings.enabled is false", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* buildInitialAgyProviderSnapshot(
+ decodeAgySettings({ enabled: false }),
+ );
+ expect(snapshot.enabled).toBe(false);
+ expect(snapshot.status).toBe("disabled");
+ expect(snapshot.installed).toBe(false);
+ expect(snapshot.message).toContain("disabled");
+ }),
+ );
+
+ it.effect("returns a pending snapshot by default — Antigravity is enabled by default", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* buildInitialAgyProviderSnapshot(decodeAgySettings({}));
+ expect(snapshot.enabled).toBe(true);
+ expect(snapshot.installed).toBe(true);
+ expect(snapshot.status).toBe("warning");
+ expect(snapshot.version).toBeNull();
+ expect(snapshot.message).toContain("Checking Antigravity");
+ expect(snapshot.requiresNewThreadForModelChange).toBeUndefined();
+ }),
+ );
+});
+
+describe("parseAgyModelsOutput", () => {
+ it("parses the tab-separated model catalog", () => {
+ const models = parseAgyModelsOutput(
+ [
+ "Fetching available models...",
+ "gemini-3.7-flash-high\tGemini 3.7 Flash (High)",
+ "gemini-3.7-flash-medium\tGemini 3.7 Flash (Medium)",
+ "gemini-3.7-flash-low\tGemini 3.7 Flash (Low)",
+ "claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)",
+ ].join("\n"),
+ );
+
+ expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([
+ { slug: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash" },
+ { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" },
+ ]);
+ expect(models.find((model) => model.slug === "gemini-3.7-flash-high")?.isLegacy).toBeFalsy();
+ const claude = models.find((model) => model.slug === "claude-sonnet-4-6");
+ expect(claude?.isLegacy).toBe(true);
+ expect(claude?.capabilities).toBeNull();
+ const capabilities = models[0]?.capabilities;
+ expect(capabilities).not.toBeNull();
+ expect(capabilities?.optionDescriptors?.[0]).toMatchObject({
+ id: "reasoningEffort",
+ currentValue: "high",
+ options: [{ id: "low" }, { id: "medium" }, { id: "high", isDefault: true }],
+ });
+ });
+
+ it("parses the escaped tab delimiter emitted by agy 1.1.x", () => {
+ const models = parseAgyModelsOutput("gemini-3.7-flash-high\\tGemini 3.7 Flash (High)\n");
+
+ expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([
+ { slug: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash" },
+ ]);
+ });
+});
+
+describe("agyModelsFromSettings", () => {
+ it("does not append reasoning variants as custom models", () => {
+ const models = agyModelsFromSettings([
+ "gemini-3.7-flash-low",
+ "gemini-3.7-flash-medium",
+ "my-custom-model",
+ ]);
+
+ expect(models.map((model) => model.slug)).not.toContain("gemini-3.7-flash-low");
+ expect(models.map((model) => model.slug)).not.toContain("gemini-3.7-flash-medium");
+ expect(models.map((model) => model.slug)).toContain("my-custom-model");
+ });
+});
+
+it.layer(NodeServices.layer)("checkAgyProviderStatus", (it) => {
+ it.effect("reports the binary as missing when the binary path does not resolve", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* checkAgyProviderStatus(
+ decodeAgySettings({
+ enabled: true,
+ binaryPath: "/definitely/not/installed/agy-binary",
+ }),
+ );
+ expect(snapshot.enabled).toBe(true);
+ expect(snapshot.installed).toBe(false);
+ expect(snapshot.status).toBe("error");
+ expect(snapshot.message).toMatch(/not found on PATH|Failed to execute/);
+ }),
+ );
+
+ it.effect("reports an installed CLI as ready when --version succeeds", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-agy-version-" });
+ const agyPath = path.join(dir, "agy");
+ yield* fs.writeFileString(
+ agyPath,
+ [
+ "#!/bin/sh",
+ 'if [ "$1" = "models" ]; then',
+ ' printf "gemini-test\\tGemini Test\\n"',
+ "else",
+ ' echo "agy version 1.1.19"',
+ "fi",
+ "exit 0",
+ "",
+ ].join("\n"),
+ );
+ yield* fs.chmod(agyPath, 0o755);
+
+ return yield* checkAgyProviderStatus(
+ decodeAgySettings({ enabled: true, binaryPath: agyPath }),
+ );
+ }),
+ );
+
+ expect(snapshot.enabled).toBe(true);
+ expect(snapshot.installed).toBe(true);
+ expect(snapshot.status).toBe("ready");
+ expect(snapshot.auth.status).toBe("authenticated");
+ expect(snapshot.version).toBe("1.1.19");
+ expect(snapshot.models.length).toBeGreaterThan(0);
+ expect(snapshot.models[0]?.slug).toBe("gemini-test");
+ }),
+ );
+
+ it.effect("reports a signed-out CLI as unauthenticated", () =>
+ Effect.gen(function* () {
+ const snapshot = yield* Effect.scoped(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-agy-auth-" });
+ const agyPath = path.join(dir, "agy");
+ yield* fs.writeFileString(
+ agyPath,
+ [
+ "#!/bin/sh",
+ 'if [ "$1" = "models" ]; then',
+ ' echo "Error: Please sign in to view available models. Launch the CLI without arguments to sign in." >&2',
+ " exit 1",
+ "fi",
+ 'echo "agy version 1.1.19"',
+ "",
+ ].join("\n"),
+ );
+ yield* fs.chmod(agyPath, 0o755);
+
+ return yield* checkAgyProviderStatus(
+ decodeAgySettings({ enabled: true, binaryPath: agyPath }),
+ );
+ }),
+ );
+
+ expect(snapshot.installed).toBe(true);
+ expect(snapshot.status).toBe("error");
+ expect(snapshot.auth.status).toBe("unauthenticated");
+ expect(snapshot.message).toBe(
+ "Antigravity CLI is not authenticated. Launch `agy` to sign in.",
+ );
+ }),
+ );
+});
diff --git a/apps/server/src/provider/Layers/AgyProvider.ts b/apps/server/src/provider/Layers/AgyProvider.ts
new file mode 100644
index 000000000000..1e66d4ae61b7
--- /dev/null
+++ b/apps/server/src/provider/Layers/AgyProvider.ts
@@ -0,0 +1,510 @@
+import {
+ type AgySettings,
+ type ModelCapabilities,
+ type ServerProviderModel,
+} from "@t3tools/contracts";
+import type { ServerProviderDraft } from "../providerSnapshot.ts";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Result from "effect/Result";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+import { createModelCapabilities } from "@t3tools/shared/model";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+
+import {
+ buildSelectOptionDescriptor,
+ buildServerProvider,
+ isCommandMissingCause,
+ parseGenericCliVersion,
+ providerModelsFromSettings,
+ spawnAndCollect,
+} from "../providerSnapshot.ts";
+
+export const AGY_PRESENTATION = {
+ displayName: "Antigravity",
+ showInteractionModeToggle: true,
+} as const;
+
+const AGY_REASONING_EFFORTS = ["low", "medium", "high"] as const;
+type AgyReasoningEffort = (typeof AGY_REASONING_EFFORTS)[number];
+
+const agyModelCapabilities = (
+ supportedEfforts: ReadonlyArray,
+): ModelCapabilities => {
+ const defaultEffort = supportedEfforts.includes("high")
+ ? "high"
+ : supportedEfforts.includes("medium")
+ ? "medium"
+ : supportedEfforts[0];
+ return createModelCapabilities({
+ optionDescriptors: [
+ buildSelectOptionDescriptor({
+ id: "reasoningEffort",
+ label: "Reasoning",
+ options: AGY_REASONING_EFFORTS.filter((effort) => supportedEfforts.includes(effort)).map(
+ (effort) => ({
+ value: effort,
+ label: `${effort.slice(0, 1).toUpperCase()}${effort.slice(1)}`,
+ ...(effort === defaultEffort ? { isDefault: true } : {}),
+ }),
+ ),
+ }),
+ ],
+ });
+};
+
+export const DEFAULT_AGY_MODEL_CAPABILITIES: ModelCapabilities =
+ agyModelCapabilities(AGY_REASONING_EFFORTS);
+
+const AGY_BUILT_IN_MODEL_VARIANTS: ReadonlyArray = [
+ {
+ slug: "gemini-3.7-flash-high",
+ name: "Gemini 3.7 Flash (High)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.7-flash-medium",
+ name: "Gemini 3.7 Flash (Medium)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.7-flash-low",
+ name: "Gemini 3.7 Flash (Low)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.6-flash-high",
+ name: "Gemini 3.6 Flash (High)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.6-flash-medium",
+ name: "Gemini 3.6 Flash (Medium)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.6-flash-low",
+ name: "Gemini 3.6 Flash (Low)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.5-flash-high",
+ name: "Gemini 3.5 Flash (High)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.5-flash-medium",
+ name: "Gemini 3.5 Flash (Medium)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.5-flash-low",
+ name: "Gemini 3.5 Flash (Low)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.1-pro-high",
+ name: "Gemini 3.1 Pro (High)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gemini-3.1-pro-low",
+ name: "Gemini 3.1 Pro (Low)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6 (Thinking)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "claude-opus-4-6-thinking",
+ name: "Claude Opus 4.6 (Thinking)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+ {
+ slug: "gpt-oss-120b-medium",
+ name: "GPT-OSS 120B (Medium)",
+ isCustom: false,
+ capabilities: DEFAULT_AGY_MODEL_CAPABILITIES,
+ },
+];
+
+export const AGY_BUILT_IN_MODELS: ReadonlyArray = parseAgyModelsOutput(
+ AGY_BUILT_IN_MODEL_VARIANTS.map(({ slug, name }) => `${slug}\t${name}`).join("\n"),
+);
+
+const DEFAULT_TIMEOUT_MS = 10_000;
+const AGY_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
+const AGY_UNAUTHENTICATED_MESSAGE =
+ "Antigravity CLI is not authenticated. Launch `agy` to sign in.";
+
+function isAgyAuthenticationError(stdout: string, stderr: string): boolean {
+ const output = `${stdout}\n${stderr}`.toLowerCase();
+ return (
+ output.includes("please sign in") ||
+ output.includes("authentication required") ||
+ output.includes("not authenticated") ||
+ output.includes("login required")
+ );
+}
+
+export function agyModelsFromSettings(
+ customModels: ReadonlyArray | undefined,
+ builtInModels: ReadonlyArray = AGY_BUILT_IN_MODELS,
+): ReadonlyArray {
+ const groupedVariantSlugs = new Set();
+ for (const model of builtInModels) {
+ const match = /^(.*)-(low|medium|high)$/.exec(model.slug);
+ const reasoning = model.capabilities?.optionDescriptors?.find(
+ (descriptor) => descriptor.id === "reasoningEffort" && descriptor.type === "select",
+ );
+ if (!match?.[1] || !reasoning || reasoning.type !== "select") continue;
+ for (const option of reasoning.options) {
+ groupedVariantSlugs.add(`${match[1]}-${option.id}`);
+ }
+ }
+ return providerModelsFromSettings(
+ builtInModels,
+ (customModels ?? []).filter((slug) => !groupedVariantSlugs.has(slug.trim())),
+ DEFAULT_AGY_MODEL_CAPABILITIES,
+ );
+}
+
+export function parseAgyModelsOutput(output: string): ReadonlyArray {
+ const parsedModels = output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0 && line !== "Fetching available models...")
+ .flatMap((line) => {
+ // agy 1.1.x prints the delimiter as the two literal characters `\\t`.
+ // Accept a real tab as well for compatibility with scripts and future CLI versions.
+ const [slug, ...nameParts] = line.split(/\\t|\t/);
+ const normalizedSlug = slug?.trim();
+ const name = nameParts.join("\t").trim();
+ return normalizedSlug && name
+ ? [
+ {
+ slug: normalizedSlug,
+ name,
+ isCustom: false,
+ ...(!normalizedSlug.startsWith("gemini-") ? { isLegacy: true } : {}),
+ capabilities: null,
+ } satisfies ServerProviderModel,
+ ]
+ : [];
+ });
+
+ const grouped = new Map<
+ string,
+ {
+ readonly name: string;
+ readonly variants: Map;
+ }
+ >();
+ const orderedModels: Array<
+ | { readonly type: "group"; readonly slug: string }
+ | { readonly type: "standalone"; readonly model: ServerProviderModel }
+ > = [];
+
+ for (const model of parsedModels) {
+ const slugMatch = /^(.*)-(low|medium|high)$/.exec(model.slug);
+ const nameMatch = /^(.*) \((Low|Medium|High)\)$/.exec(model.name);
+ if (!slugMatch || !nameMatch) {
+ orderedModels.push({ type: "standalone", model });
+ continue;
+ }
+
+ const baseSlug = slugMatch[1];
+ const effort = slugMatch[2] as AgyReasoningEffort;
+ const baseName = nameMatch[1];
+ if (!baseSlug || !baseName || nameMatch[2]?.toLowerCase() !== effort) {
+ orderedModels.push({ type: "standalone", model });
+ continue;
+ }
+
+ const existing = grouped.get(baseSlug);
+ const entry = existing ?? { name: baseName, variants: new Map() };
+ if (!existing) {
+ orderedModels.push({ type: "group", slug: baseSlug });
+ }
+ entry.variants.set(effort, model);
+ grouped.set(baseSlug, entry);
+ }
+
+ return orderedModels.map((orderedModel) => {
+ if (orderedModel.type === "standalone") return orderedModel.model;
+
+ const group = grouped.get(orderedModel.slug);
+ if (!group) {
+ throw new Error(`Missing grouped Antigravity model: ${orderedModel.slug}`);
+ }
+ const { name, variants } = group;
+ const efforts = AGY_REASONING_EFFORTS.filter((effort) => variants.has(effort));
+ const defaultEffort = efforts.includes("high")
+ ? "high"
+ : efforts.includes("medium")
+ ? "medium"
+ : efforts[0];
+ const defaultVariant = defaultEffort ? variants.get(defaultEffort) : undefined;
+ return {
+ slug: defaultVariant?.slug ?? [...variants.values()][0]!.slug,
+ name,
+ isCustom: false,
+ ...(defaultVariant?.isLegacy ? { isLegacy: true } : {}),
+ capabilities: agyModelCapabilities(efforts),
+ } satisfies ServerProviderModel;
+ });
+}
+
+export function buildInitialAgyProviderSnapshot(
+ agySettings: AgySettings,
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
+ const models = agyModelsFromSettings(agySettings.customModels);
+
+ if (!agySettings.enabled) {
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: false,
+ checkedAt,
+ models,
+ probe: {
+ installed: false,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Antigravity is disabled in T3 Code settings.",
+ },
+ });
+ }
+
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: true,
+ checkedAt,
+ models,
+ probe: {
+ installed: true,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Checking Antigravity CLI availability...",
+ },
+ });
+ });
+}
+
+const runAgyCommand = (
+ agySettings: AgySettings,
+ args: ReadonlyArray,
+ environment: NodeJS.ProcessEnv = process.env,
+) =>
+ Effect.gen(function* () {
+ const command = agySettings.binaryPath || "agy";
+ const spawnCommand = yield* resolveSpawnCommand(command, args, {
+ env: environment,
+ });
+ const childCommand = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: environment,
+ shell: spawnCommand.shell,
+ stdin: "ignore",
+ });
+ return yield* spawnAndCollect(agySettings.binaryPath, childCommand);
+ });
+
+export const checkAgyProviderStatus = Effect.fn("checkAgyProviderStatus")(function* (
+ agySettings: AgySettings,
+ environment?: NodeJS.ProcessEnv,
+): Effect.fn.Return {
+ const resolvedEnvironment = environment ?? process.env;
+ const checkedAt = DateTime.formatIso(yield* DateTime.now);
+ const allModels = agyModelsFromSettings(agySettings.customModels);
+
+ if (!agySettings.enabled) {
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: false,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: false,
+ version: null,
+ status: "warning",
+ auth: { status: "unknown" },
+ message: "Antigravity is disabled in T3 Code settings.",
+ },
+ });
+ }
+
+ const versionProbe = yield* runAgyCommand(agySettings, ["--version"], resolvedEnvironment).pipe(
+ Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
+ Effect.result,
+ );
+
+ if (Result.isFailure(versionProbe)) {
+ const error = versionProbe.failure;
+ yield* Effect.logWarning("Antigravity CLI health check failed.", {
+ errorTag: error._tag,
+ });
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: !isCommandMissingCause(error),
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: isCommandMissingCause(error)
+ ? "Antigravity CLI (`agy`) was not found on PATH."
+ : "Failed to execute Antigravity CLI health check.",
+ },
+ });
+ }
+
+ if (Option.isNone(versionProbe.success)) {
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: true,
+ version: null,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "Antigravity CLI is installed but failed to run. Timed out while running command.",
+ },
+ });
+ }
+
+ const version = versionProbe.success.value;
+ const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
+ if (version.code !== 0) {
+ yield* Effect.logWarning("Antigravity CLI version probe exited with non-zero status.", {
+ exitCode: version.code,
+ stdoutLength: version.stdout.length,
+ stderrLength: version.stderr.length,
+ });
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: "error",
+ auth: { status: "unknown" },
+ message: "Antigravity CLI is installed but failed to run.",
+ },
+ });
+ }
+
+ const modelsProbe = yield* runAgyCommand(agySettings, ["models"], resolvedEnvironment).pipe(
+ Effect.timeoutOption(AGY_MODEL_DISCOVERY_TIMEOUT_MS),
+ Effect.result,
+ );
+ if (Result.isFailure(modelsProbe)) {
+ yield* Effect.logWarning("Antigravity model discovery failed", {
+ errorTag: modelsProbe.failure._tag,
+ });
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: "error",
+ auth: { status: "unknown" },
+ message:
+ "Antigravity CLI is installed but model discovery failed. Check server logs for details.",
+ },
+ });
+ }
+
+ if (Option.isNone(modelsProbe.success)) {
+ yield* Effect.logWarning(
+ `Antigravity model discovery timed out after ${AGY_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,
+ );
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: "error",
+ auth: { status: "unknown" },
+ message: `Antigravity CLI is installed but model discovery timed out after ${AGY_MODEL_DISCOVERY_TIMEOUT_MS}ms.`,
+ },
+ });
+ }
+
+ if (modelsProbe.success.value.code !== 0) {
+ const authenticationRequired = isAgyAuthenticationError(
+ modelsProbe.success.value.stdout,
+ modelsProbe.success.value.stderr,
+ );
+ yield* Effect.logWarning("Antigravity model discovery exited with non-zero status", {
+ exitCode: modelsProbe.success.value.code,
+ stdoutLength: modelsProbe.success.value.stdout.length,
+ stderrLength: modelsProbe.success.value.stderr.length,
+ authenticationRequired,
+ });
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models: allModels,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: "error",
+ auth: { status: authenticationRequired ? "unauthenticated" : "unknown" },
+ message: authenticationRequired
+ ? AGY_UNAUTHENTICATED_MESSAGE
+ : "Antigravity CLI is installed but model discovery failed. Check server logs for details.",
+ },
+ });
+ }
+
+ const discoveredModels = parseAgyModelsOutput(modelsProbe.success.value.stdout);
+ const models =
+ discoveredModels.length > 0
+ ? agyModelsFromSettings(agySettings.customModels, discoveredModels)
+ : allModels;
+
+ return buildServerProvider({
+ presentation: AGY_PRESENTATION,
+ enabled: agySettings.enabled,
+ checkedAt,
+ models,
+ probe: {
+ installed: true,
+ version: parsedVersion,
+ status: "ready",
+ auth: { status: "authenticated" },
+ },
+ });
+});
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
index f7ae95d8a927..69b5462992b1 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -1552,7 +1552,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
);
assert.strictEqual(initialCodex?.status, "error");
assert.strictEqual(initialCodex?.installed, false);
- assert.deepStrictEqual(spawnedCommands, [firstMissing]);
+ assert.deepStrictEqual(spawnedCommands, [firstMissing, "agy"]);
// Drive a settings change. The Hydration layer's
// `SettingsWatcherLive` consumes this via `streamChanges`,
@@ -1589,7 +1589,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
});
const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex");
- assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);
+ assert.deepStrictEqual(spawnedCommands, [firstMissing, "agy", secondMissing]);
assert.strictEqual(reprobedCodex?.status, "error");
assert.strictEqual(reprobedCodex?.installed, false);
}).pipe(Effect.provide(runtimeServices));
@@ -1741,6 +1741,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
);
assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [
+ "agy",
"claudeAgent",
"codex",
"cursor",
diff --git a/apps/server/src/provider/Services/AgyAdapter.ts b/apps/server/src/provider/Services/AgyAdapter.ts
new file mode 100644
index 000000000000..4a17daa937f2
--- /dev/null
+++ b/apps/server/src/provider/Services/AgyAdapter.ts
@@ -0,0 +1,16 @@
+/**
+ * AgyAdapter — shape type for the Antigravity (agy) provider adapter.
+ *
+ * The driver model ({@link ../Drivers/AgyDriver}) bundles one adapter per
+ * instance as a captured closure, so this module only retains the shape
+ * interface as a naming anchor for the driver bundle.
+ *
+ * @module AgyAdapter
+ */
+import type { ProviderAdapterError } from "../Errors.ts";
+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
+
+/**
+ * AgyAdapterShape — per-instance Antigravity adapter contract.
+ */
+export interface AgyAdapterShape extends ProviderAdapterShape {}
diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts
index 791a96e1da3c..85228bea55da 100644
--- a/apps/server/src/provider/builtInDrivers.ts
+++ b/apps/server/src/provider/builtInDrivers.ts
@@ -20,6 +20,7 @@
*
* @module provider/builtInDrivers
*/
+import { AgyDriver, type AgyDriverEnv } from "./Drivers/AgyDriver.ts";
import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
@@ -33,6 +34,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts";
* layer must provide every service in this union.
*/
export type BuiltInDriversEnv =
+ | AgyDriverEnv
| ClaudeDriverEnv
| CodexDriverEnv
| CursorDriverEnv
@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray {
+ it.effect("generates structured output with the selected model and effort", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const fake = yield* makeFakeAgy();
+ const textGeneration = yield* makeAgyTextGeneration(
+ decodeAgySettings({ binaryPath: fake.binaryPath }),
+ {
+ ...process.env,
+ T3_FAKE_AGY_ARGS_PATH: fake.argsPath,
+ T3_FAKE_AGY_OUTPUT: encodeJsonString({
+ status: "SUCCESS",
+ structured_output: {
+ subject: "Add Antigravity provider",
+ body: "Wire the agy harness into T3 Code.",
+ },
+ }),
+ },
+ );
+
+ const generated = yield* textGeneration.generateCommitMessage({
+ cwd: process.cwd(),
+ branch: "feat/antigravity-provider",
+ stagedSummary: "Add the agy provider",
+ stagedPatch: "diff --git a/agy b/agy",
+ modelSelection: createModelSelection(
+ ProviderInstanceId.make("agy"),
+ "gemini-3.7-flash-high",
+ [{ id: "reasoningEffort", value: "medium" }],
+ ),
+ });
+
+ expect(generated).toEqual({
+ subject: "Add Antigravity provider",
+ body: "Wire the agy harness into T3 Code.",
+ });
+ const args = yield* fileSystem.readFileString(fake.argsPath);
+ expect(args).toContain("--output-format json");
+ expect(args).toContain("--json-schema");
+ expect(args).toContain("--model gemini-3.7-flash-medium");
+ expect(args).toContain("--effort medium");
+ expect(args).toContain("--dangerously-skip-permissions");
+ }),
+ ),
+ );
+
+ it.effect("reports non-zero CLI exits as text-generation errors", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const fake = yield* makeFakeAgy();
+ const textGeneration = yield* makeAgyTextGeneration(
+ decodeAgySettings({ binaryPath: fake.binaryPath }),
+ {
+ ...process.env,
+ T3_FAKE_AGY_ARGS_PATH: fake.argsPath,
+ T3_FAKE_AGY_STDERR: "authentication required",
+ T3_FAKE_AGY_EXIT_CODE: "1",
+ },
+ );
+
+ const error = yield* textGeneration
+ .generateThreadTitle({
+ cwd: process.cwd(),
+ message: "Name this thread",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("agy"),
+ model: "gemini-3.7-flash-high",
+ },
+ })
+ .pipe(Effect.flip);
+
+ expect(error.detail).toContain("authentication required");
+ }),
+ ),
+ );
+});
diff --git a/apps/server/src/textGeneration/AgyTextGeneration.ts b/apps/server/src/textGeneration/AgyTextGeneration.ts
new file mode 100644
index 000000000000..12836d521549
--- /dev/null
+++ b/apps/server/src/textGeneration/AgyTextGeneration.ts
@@ -0,0 +1,326 @@
+/**
+ * AgyTextGeneration – Text generation layer using the Antigravity CLI (agy).
+ *
+ * Implements the TextGeneration service contract by delegating to
+ * `agy -p --output-format json --json-schema ...`.
+ *
+ * @module AgyTextGeneration
+ */
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+import { type AgySettings, type ModelSelection } from "@t3tools/contracts";
+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import { extractJsonObject } from "@t3tools/shared/schemaJson";
+
+import { TextGenerationError } from "@t3tools/contracts";
+import type { ProviderInstance } from "../provider/ProviderDriver.ts";
+import { resolveAgyModelForEffort } from "../provider/AgyModelSelection.ts";
+import * as TextGeneration from "./TextGeneration.ts";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+ buildThreadTitlePrompt,
+} from "./TextGenerationPrompts.ts";
+import {
+ normalizeCliError,
+ sanitizeCommitSubject,
+ sanitizePrTitle,
+ sanitizeThreadTitle,
+ toJsonSchemaObject,
+} from "./TextGenerationUtils.ts";
+
+const AGY_TIMEOUT_MS = 180_000;
+
+const AgyOutputEnvelope = Schema.Struct({
+ status: Schema.optional(Schema.String),
+ structured_output: Schema.optional(Schema.Unknown),
+ response: Schema.optional(Schema.String),
+ error: Schema.optional(Schema.String),
+});
+
+const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
+const decodeAgyOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(AgyOutputEnvelope));
+
+export const makeAgyTextGeneration = Effect.fn("makeAgyTextGeneration")(function* (
+ agySettings: AgySettings,
+ environment: NodeJS.ProcessEnv = process.env,
+) {
+ const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+
+ const readStreamAsString = (
+ operation: string,
+ stream: Stream.Stream,
+ ): Effect.Effect =>
+ stream.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (acc, chunk) => acc + chunk,
+ ),
+ Effect.mapError((cause) =>
+ normalizeCliError("agy", operation, cause, "Failed to collect process output"),
+ ),
+ );
+
+ const encodeJsonForOperation = (
+ operation:
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle",
+ value: unknown,
+ detail: string,
+ ): Effect.Effect =>
+ encodeJsonString(value).pipe(
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation,
+ detail,
+ cause,
+ }),
+ ),
+ );
+
+ const runAgyJson = Effect.fn("runAgyJson")(function* ({
+ operation,
+ cwd,
+ prompt,
+ outputSchemaJson,
+ modelSelection,
+ }: {
+ operation:
+ | "generateCommitMessage"
+ | "generatePrContent"
+ | "generateBranchName"
+ | "generateThreadTitle";
+ cwd: string;
+ prompt: string;
+ outputSchemaJson: S;
+ modelSelection: ModelSelection;
+ }): Effect.fn.Return {
+ const jsonSchemaStr = yield* encodeJsonForOperation(
+ operation,
+ toJsonSchemaObject(outputSchemaJson),
+ "Failed to encode structured output schema.",
+ );
+ const effort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort");
+ const model = resolveAgyModelForEffort(modelSelection.model, effort);
+
+ const runAgyCommand = Effect.fn("runAgyJson.runAgyCommand")(function* () {
+ const spawnCommand = yield* resolveSpawnCommand(
+ agySettings.binaryPath || "agy",
+ [
+ "-p",
+ prompt,
+ "--output-format",
+ "json",
+ "--json-schema",
+ jsonSchemaStr,
+ ...(model ? ["--model", model] : []),
+ ...(effort ? ["--effort", effort] : []),
+ "--dangerously-skip-permissions",
+ ],
+ { env: environment },
+ );
+ const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ env: environment,
+ cwd,
+ shell: spawnCommand.shell,
+ });
+
+ const spawned = yield* commandSpawner
+ .spawn(command)
+ .pipe(
+ Effect.mapError((cause) =>
+ normalizeCliError("agy", operation, cause, "Failed to spawn Antigravity CLI"),
+ ),
+ );
+
+ const outputFiber = yield* Effect.all(
+ [
+ readStreamAsString(operation, spawned.stdout),
+ readStreamAsString(operation, spawned.stderr),
+ ],
+ { concurrency: "unbounded" },
+ ).pipe(Effect.forkChild);
+ const exitCode = yield* spawned.exitCode.pipe(
+ Effect.mapError((cause) =>
+ normalizeCliError("agy", operation, cause, "Failed to wait for process exit"),
+ ),
+ );
+ const [stdout, stderr] = yield* Fiber.join(outputFiber);
+
+ return {
+ exitCode,
+ stdout,
+ stderr,
+ };
+ });
+
+ const result = yield* runAgyCommand().pipe(
+ Effect.timeoutOption(AGY_TIMEOUT_MS),
+ Effect.flatMap((timedOut) =>
+ Option.match(timedOut, {
+ onNone: () =>
+ Effect.fail(
+ new TextGenerationError({
+ operation,
+ detail: "Timed out waiting for Antigravity CLI to complete.",
+ }),
+ ),
+ onSome: Effect.succeed,
+ }),
+ ),
+ Effect.scoped,
+ );
+
+ if (result.exitCode !== 0) {
+ return yield* new TextGenerationError({
+ operation,
+ detail:
+ result.stderr.trim() ||
+ result.stdout.trim() ||
+ `Antigravity CLI process exited with code ${result.exitCode}`,
+ });
+ }
+
+ const envelope = yield* decodeAgyOutputEnvelope(result.stdout).pipe(
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation,
+ detail: "Antigravity CLI returned unexpected output format.",
+ cause,
+ }),
+ ),
+ );
+
+ const payload = envelope.structured_output ?? extractJsonObject(envelope.response ?? "");
+ if (!payload) {
+ return yield* new TextGenerationError({
+ operation,
+ detail: envelope.error || "Antigravity CLI returned empty structured output.",
+ });
+ }
+
+ return yield* Schema.decodeUnknownEffect(outputSchemaJson)(payload).pipe(
+ Effect.mapError(
+ (cause) =>
+ new TextGenerationError({
+ operation,
+ detail: "Antigravity CLI response did not match expected schema.",
+ cause,
+ }),
+ ),
+ );
+ });
+
+ const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
+ Effect.fn("AgyTextGeneration.generateCommitMessage")(function* (input) {
+ const { prompt, outputSchema } = buildCommitMessagePrompt({
+ branch: input.branch,
+ stagedSummary: input.stagedSummary,
+ stagedPatch: input.stagedPatch,
+ includeBranch: input.includeBranch === true,
+ policy: input.policy,
+ });
+ const generated = yield* runAgyJson({
+ operation: "generateCommitMessage",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ const branch =
+ "branch" in generated && typeof generated.branch === "string"
+ ? sanitizeBranchFragment(generated.branch)
+ : undefined;
+
+ return {
+ subject: sanitizeCommitSubject(generated.subject),
+ body: generated.body?.trim() ?? "",
+ ...(branch ? { branch: sanitizeFeatureBranchName(branch) } : {}),
+ };
+ });
+
+ const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
+ Effect.fn("AgyTextGeneration.generatePrContent")(function* (input) {
+ const { prompt, outputSchema } = buildPrContentPrompt({
+ baseBranch: input.baseBranch,
+ headBranch: input.headBranch,
+ commitSummary: input.commitSummary,
+ diffSummary: input.diffSummary,
+ diffPatch: input.diffPatch,
+ policy: input.policy,
+ changeRequestTemplate: input.changeRequestTemplate,
+ });
+ const generated = yield* runAgyJson({
+ operation: "generatePrContent",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ title: sanitizePrTitle(generated.title),
+ body: generated.body.trim(),
+ };
+ });
+
+ const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
+ Effect.fn("AgyTextGeneration.generateBranchName")(function* (input) {
+ const { prompt, outputSchema } = buildBranchNamePrompt({
+ message: input.message,
+ attachments: input.attachments,
+ });
+ const generated = yield* runAgyJson({
+ operation: "generateBranchName",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ branch: sanitizeBranchFragment(generated.branch),
+ };
+ });
+
+ const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
+ Effect.fn("AgyTextGeneration.generateThreadTitle")(function* (input) {
+ const { prompt, outputSchema } = buildThreadTitlePrompt({
+ message: input.message,
+ previousTitle: input.previousTitle,
+ attachments: input.attachments,
+ });
+ const generated = yield* runAgyJson({
+ operation: "generateThreadTitle",
+ cwd: input.cwd,
+ prompt,
+ outputSchemaJson: outputSchema,
+ modelSelection: input.modelSelection,
+ });
+
+ return {
+ title: sanitizeThreadTitle(generated.title),
+ };
+ });
+
+ return {
+ generateCommitMessage,
+ generatePrContent,
+ generateBranchName,
+ generateThreadTitle,
+ } satisfies ProviderInstance["textGeneration"];
+});
diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts
index 66b7ccd465f1..202e260a1946 100644
--- a/apps/server/src/textGeneration/TextGeneration.ts
+++ b/apps/server/src/textGeneration/TextGeneration.ts
@@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance
import type { ProviderInstance } from "../provider/ProviderDriver.ts";
import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
-export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
+export type TextGenerationProvider =
+ | "codex"
+ | "claudeAgent"
+ | "cursor"
+ | "grok"
+ | "opencode"
+ | "agy";
export interface CommitMessageGenerationInput {
cwd: string;
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index cd0854e176b7..a3ff3b48f6ab 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -641,12 +641,14 @@ export const Gemini: Icon = (props) => (
);
-const ANTIGRAVITY_ICON_DATA_URL =
- "data:image/png;base64,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";
-
-export const AntigravityIcon: Icon = (props) => (
-
-
+export const AntigravityIcon: Icon = ({ className, ...props }) => (
+
+
);
diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index 842c616fe1fe..91975610e973 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -1,5 +1,13 @@
import { ProviderDriverKind } from "@t3tools/contracts";
-import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ AntigravityIcon,
+ ClaudeAI,
+ CursorIcon,
+ GrokIcon,
+ Icon,
+ OpenAI,
+ OpenCodeIcon,
+} from "../Icons";
import { PROVIDER_OPTIONS } from "../../session-logic";
export const PROVIDER_ICON_BY_PROVIDER: Partial> = {
@@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
+ [ProviderDriverKind.make("agy")]: AntigravityIcon,
};
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts
index 051045b030c3..e3ec63dc2f88 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts
+++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vite-plus/test";
-import type { ServerProviderModel } from "@t3tools/contracts";
+import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard";
@@ -33,4 +33,43 @@ describe("deriveProviderModelsForDisplay", () => {
}).map((model) => model.slug),
).toEqual(["server-model", "kept-custom"]);
});
+
+ it("shows one grouped Antigravity row and suppresses its raw effort variants", () => {
+ const grouped: ServerProviderModel = {
+ slug: "gemini-3.7-flash-high",
+ name: "Gemini 3.7 Flash",
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ options: [
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High", isDefault: true },
+ ],
+ currentValue: "high",
+ },
+ ],
+ },
+ };
+
+ expect(
+ deriveProviderModelsForDisplay({
+ driverKind: ProviderDriverKind.make("agy"),
+ liveModels: [
+ grouped,
+ {
+ slug: "gemini-3.7-flash-medium",
+ name: "Gemini 3.7 Flash (Medium)",
+ isCustom: false,
+ capabilities: null,
+ },
+ ],
+ customModels: ["gemini-3.7-flash-low", "my-custom-model"],
+ }).map((model) => model.slug),
+ ).toEqual(["gemini-3.7-flash-high", "my-custom-model"]);
+ });
});
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index a663aa90990d..16a41ba534a6 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -15,11 +15,11 @@ import * as Result from "effect/Result";
import { useState, type ReactNode } from "react";
import {
isProviderDriverKind,
+ type ProviderDriverKind,
resolveProviderInstanceEnabled,
type ProviderInstanceConfig,
type ProviderInstanceEnvironmentVariable,
type ProviderInstanceId,
- type ProviderDriverKind,
type ServerProvider,
type ServerProviderModel,
} from "@t3tools/contracts";
@@ -27,6 +27,10 @@ import {
import { cn } from "../../lib/utils";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { normalizeProviderAccentColor } from "../../providerInstances";
+import {
+ getGroupedProviderModelVariantSlugs,
+ getVisibleProviderModels,
+} from "../../providerModels";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
@@ -113,22 +117,32 @@ function nextConfigBlobWithValue(
export function deriveProviderModelsForDisplay(input: {
readonly liveModels: ReadonlyArray | undefined;
readonly customModels: ReadonlyArray;
+ readonly driverKind?: ProviderDriverKind | null;
}): ReadonlyArray {
const liveCustomModelsBySlug = new Map(
Arr.filterMap(input.liveModels ?? [], (model) =>
model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid,
),
);
- const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
- const customModels = input.customModels.map(
- (slug) =>
- liveCustomModelsBySlug.get(slug) ?? {
- slug,
- name: slug,
- isCustom: true,
- capabilities: null,
- },
+ const rawServerModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
+ const groupedVariantSlugs = getGroupedProviderModelVariantSlugs(
+ rawServerModels,
+ input.driverKind,
);
+ const serverModels = input.driverKind
+ ? getVisibleProviderModels(rawServerModels, input.driverKind)
+ : rawServerModels;
+ const customModels = input.customModels
+ .filter((slug) => !groupedVariantSlugs.has(slug))
+ .map(
+ (slug) =>
+ liveCustomModelsBySlug.get(slug) ?? {
+ slug,
+ name: slug,
+ isCustom: true,
+ capabilities: null,
+ },
+ );
return [...serverModels, ...customModels];
}
@@ -449,7 +463,12 @@ export function ProviderInstanceCard({
const modelsForDisplay = deriveProviderModelsForDisplay({
liveModels: liveProvider?.models,
customModels,
+ driverKind,
});
+ const groupedVariantSlugs = getGroupedProviderModelVariantSlugs(
+ liveProvider?.models ?? [],
+ driverKind,
+ );
const updateDisplayName = (value: string) => {
const trimmed = value.trim();
@@ -777,6 +796,7 @@ export function ProviderInstanceCard({
instanceId={instanceId}
driverKind={driverKind}
models={modelsForDisplay}
+ groupedVariantSlugs={groupedVariantSlugs}
customModels={customModels}
hiddenModels={hiddenModels}
favoriteModels={favoriteModels}
diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx
index 9a42961d13ee..8d6b52edf3b5 100644
--- a/apps/web/src/components/settings/ProviderModelsSection.tsx
+++ b/apps/web/src/components/settings/ProviderModelsSection.tsx
@@ -50,6 +50,8 @@ interface ProviderModelsSectionProps {
* and custom entries, distinguished by `isCustom`.
*/
readonly models: ReadonlyArray;
+ /** Built-in effort variants represented by a grouped model row. */
+ readonly groupedVariantSlugs: ReadonlySet;
/**
* The persisted custom-model slug list for this instance. Drives dedup,
* and is the array we hand back verbatim (with the new slug appended /
@@ -88,6 +90,7 @@ export function ProviderModelsSection({
instanceId,
driverKind,
models,
+ groupedVariantSlugs,
customModels,
hiddenModels,
favoriteModels,
@@ -116,7 +119,10 @@ export function ProviderModelsSection({
setError("Enter a model slug.");
return;
}
- if (models.some((model) => !model.isCustom && model.slug === normalized)) {
+ if (
+ groupedVariantSlugs.has(normalized) ||
+ models.some((model) => !model.isCustom && model.slug === normalized)
+ ) {
setError("That model is already built in.");
return;
}
diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts
index bfee6a8d6807..eee04be8d40a 100644
--- a/apps/web/src/components/settings/providerDriverMeta.ts
+++ b/apps/web/src/components/settings/providerDriverMeta.ts
@@ -1,4 +1,5 @@
import {
+ AgySettings,
ClaudeSettings,
CodexSettings,
CursorSettings,
@@ -7,7 +8,15 @@ import {
ProviderDriverKind,
} from "@t3tools/contracts";
import type * as Schema from "effect/Schema";
-import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ AntigravityIcon,
+ ClaudeAI,
+ CursorIcon,
+ GrokIcon,
+ type Icon,
+ OpenAI,
+ OpenCodeIcon,
+} from "../Icons";
type ProviderSettingsSchema = {
readonly fields: Readonly>;
@@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: OpenCodeIcon,
settingsSchema: OpenCodeSettings,
},
+ {
+ value: ProviderDriverKind.make("agy"),
+ label: "Antigravity",
+ icon: AntigravityIcon,
+ settingsSchema: AgySettings,
+ },
];
export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts
index 405366d9fcbe..49e1adca04ec 100644
--- a/apps/web/src/modelSelection.test.ts
+++ b/apps/web/src/modelSelection.test.ts
@@ -176,6 +176,69 @@ describe("instance-scoped model selection", () => {
);
});
+ it("does not append grouped Antigravity reasoning variants as custom models", () => {
+ const base = provider({
+ provider: ProviderDriverKind.make("agy"),
+ instanceId: "agy",
+ models: ["gemini-3.7-flash-high"],
+ });
+ const providers: ServerProvider[] = [
+ {
+ ...base,
+ models: [
+ {
+ ...base.models[0]!,
+ name: "Gemini 3.7 Flash",
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ options: [
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High", isDefault: true },
+ ],
+ currentValue: "high",
+ },
+ ],
+ },
+ },
+ {
+ slug: "gemini-3.7-flash-medium",
+ name: "Gemini 3.7 Flash (Medium)",
+ isCustom: true,
+ capabilities: {},
+ },
+ {
+ slug: "gemini-3.7-flash-low",
+ name: "Gemini 3.7 Flash (Low)",
+ isCustom: false,
+ capabilities: {},
+ },
+ ],
+ },
+ ];
+ const settings: UnifiedSettings = {
+ ...settingsWithProviderInstances(),
+ providerInstances: {
+ [ProviderInstanceId.make("agy")]: {
+ driver: ProviderDriverKind.make("agy"),
+ config: {
+ customModels: ["gemini-3.7-flash-low", "gemini-3.7-flash-medium", "my-custom-model"],
+ },
+ },
+ },
+ };
+ const agy = deriveProviderInstanceEntries(providers)[0]!;
+
+ expect(getAppModelOptionsForInstance(settings, agy).map((option) => option.slug)).toEqual([
+ "gemini-3.7-flash-high",
+ "my-custom-model",
+ ]);
+ });
+
it("does not inject an unknown selected slug into the stock instance list", () => {
const providers = [
provider({
diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts
index ccdffdda1004..1724013fd296 100644
--- a/apps/web/src/modelSelection.ts
+++ b/apps/web/src/modelSelection.ts
@@ -15,11 +15,11 @@ import {
} from "@t3tools/shared/model";
import { getComposerProviderState } from "./components/chat/composerProviderState";
import { UnifiedSettings } from "@t3tools/contracts/settings";
-import * as Arr from "effect/Array";
-import * as Result from "effect/Result";
import {
+ getBuiltInProviderModelSlugs,
getDefaultServerModel,
getProviderModels,
+ getVisibleProviderModels,
resolveSelectableProvider,
} from "./providerModels";
import { ModelEsque } from "./components/chat/providerIconUtils";
@@ -153,13 +153,10 @@ export function getAppModelOptions(
provider: ProviderDriverKind,
_selectedModel?: string | null,
): AppModelOption[] {
- const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption);
+ const providerModels = getVisibleProviderModels(getProviderModels(providers, provider), provider);
+ const options: AppModelOption[] = providerModels.map(toAppModelOption);
const seen = new Set(options.map((option) => option.slug));
- const builtInModelSlugs = new Set(
- Arr.filterMap(getProviderModels(providers, provider), (model) =>
- model.isCustom ? Result.failVoid : Result.succeed(model.slug),
- ),
- );
+ const builtInModelSlugs = getBuiltInProviderModelSlugs(providerModels, provider);
// Read from the default instance's config first (that's where edits
// now land), falling back to the legacy per-kind bucket so unmigrated
@@ -201,13 +198,10 @@ export function getAppModelOptionsForInstance(
settings: UnifiedSettings,
entry: ProviderInstanceEntry,
): AppModelOption[] {
- const options: AppModelOption[] = entry.models.map(toAppModelOption);
+ const providerModels = getVisibleProviderModels(entry.models, entry.driverKind);
+ const options: AppModelOption[] = providerModels.map(toAppModelOption);
const seen = new Set(options.map((option) => option.slug));
- const builtInModelSlugs = new Set(
- Arr.filterMap(entry.models, (model) =>
- model.isCustom ? Result.failVoid : Result.succeed(model.slug),
- ),
- );
+ const builtInModelSlugs = getBuiltInProviderModelSlugs(providerModels, entry.driverKind);
const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts
index b64a5e25d508..3bf630e439ee 100644
--- a/apps/web/src/providerInstances.test.ts
+++ b/apps/web/src/providerInstances.test.ts
@@ -10,6 +10,7 @@ import {
resolveDefaultProviderModelSelection,
resolveSelectableProviderInstance,
resolveProviderDriverKindForInstanceSelection,
+ sortProviderInstanceEntries,
} from "./providerInstances";
function provider(input: {
@@ -135,6 +136,28 @@ describe("deriveProviderInstanceEntries", () => {
});
});
+describe("sortProviderInstanceEntries", () => {
+ it("uses canonical provider order even when Antigravity is emitted first", () => {
+ const entries = deriveProviderInstanceEntries([
+ provider({ provider: ProviderDriverKind.make("agy"), instanceId: "agy" }),
+ provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }),
+ provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
+ provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }),
+ provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" }),
+ provider({ provider: ProviderDriverKind.make("cursor"), instanceId: "cursor" }),
+ ]);
+
+ expect(sortProviderInstanceEntries(entries).map((entry) => entry.driverKind)).toEqual([
+ "codex",
+ "claudeAgent",
+ "cursor",
+ "grok",
+ "opencode",
+ "agy",
+ ]);
+ });
+});
+
describe("deriveProviderEntriesByEnvironment", () => {
it("keeps same-id default instances distinct per environment", () => {
const byEnvironment = deriveProviderEntriesByEnvironment([
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index ef60d554dd78..6cbb6ad56257 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -18,7 +18,7 @@ import {
PROVIDER_DISPLAY_NAMES,
resolveProviderInstanceEnabled,
type ModelSelection,
- type ProviderDriverKind,
+ ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type ServerProviderModel,
@@ -28,6 +28,15 @@ import {
import { formatProviderDriverKindLabel } from "./providerModels";
+const BUILT_IN_PROVIDER_ORDER: ReadonlyArray = [
+ "codex",
+ "claudeAgent",
+ "cursor",
+ "grok",
+ "opencode",
+ "agy",
+].map((driverKind) => ProviderDriverKind.make(driverKind));
+
/**
* Local-only placeholder used while a draft has no provider it can safely
* target. It must never be persisted or dispatched; the composer disables
@@ -260,8 +269,8 @@ export function applyProviderInstanceSettings(
* Sort instance entries so the default instance of each driver kind appears
* before any custom instances of the same kind. Within a kind, custom
* instances keep their settings-author order (which is how the server
- * emits them). Stable across kinds: entries retain the server's
- * cross-driver ordering.
+ * emits them). Built-in kinds follow the explicit provider display order;
+ * unknown fork kinds retain their first-seen server order afterward.
*/
export function sortProviderInstanceEntries(
entries: ReadonlyArray,
@@ -279,8 +288,20 @@ export function sortProviderInstanceEntries(
byKind.set(entry.driverKind, [entry]);
}
}
+ const driverOrder = new Map(
+ BUILT_IN_PROVIDER_ORDER.map((driverKind, index) => [driverKind, index] as const),
+ );
+ const firstSeenOrder = new Map(
+ [...byKind.keys()].map((driverKind, index) => [driverKind, index] as const),
+ );
+ const orderedKinds = [...byKind.keys()].toSorted((left, right) => {
+ const leftRank = driverOrder.get(left) ?? Number.POSITIVE_INFINITY;
+ const rightRank = driverOrder.get(right) ?? Number.POSITIVE_INFINITY;
+ return leftRank - rightRank || firstSeenOrder.get(left)! - firstSeenOrder.get(right)!;
+ });
const sorted: ProviderInstanceEntry[] = [];
- for (const bucket of byKind.values()) {
+ for (const driverKind of orderedKinds) {
+ const bucket = byKind.get(driverKind)!;
const defaults = bucket.filter((entry) => entry.isDefault);
const customs = bucket.filter((entry) => !entry.isDefault);
sorted.push(...defaults, ...customs);
diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts
index 6fc8b5e122a9..e2fd440588ff 100644
--- a/apps/web/src/providerModels.ts
+++ b/apps/web/src/providerModels.ts
@@ -14,6 +14,57 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
optionDescriptors: [],
});
const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
+const AGY_DRIVER_KIND = ProviderDriverKind.make("agy");
+
+function getGroupedAgyModelVariants(models: ReadonlyArray): {
+ readonly canonicalSlugs: ReadonlySet;
+ readonly variantSlugs: ReadonlySet;
+} {
+ const canonicalSlugs = new Set();
+ const variantSlugs = new Set();
+ for (const model of models) {
+ const match = /^(.*)-(low|medium|high)$/.exec(model.slug);
+ const isIndividualVariant = / \((Low|Medium|High)\)$/.test(model.name);
+ const reasoning = model.capabilities?.optionDescriptors?.find(
+ (descriptor) => descriptor.id === "reasoningEffort" && descriptor.type === "select",
+ );
+ if (isIndividualVariant || !match?.[1] || !reasoning || reasoning.type !== "select") continue;
+
+ canonicalSlugs.add(model.slug);
+ for (const option of reasoning.options) {
+ variantSlugs.add(`${match[1]}-${option.id}`);
+ }
+ }
+ return { canonicalSlugs, variantSlugs };
+}
+
+export function getGroupedProviderModelVariantSlugs(
+ models: ReadonlyArray,
+ provider: ProviderDriverKind | null | undefined,
+): ReadonlySet {
+ return provider === AGY_DRIVER_KIND ? getGroupedAgyModelVariants(models).variantSlugs : new Set();
+}
+
+export function getVisibleProviderModels(
+ models: ReadonlyArray,
+ provider: ProviderDriverKind,
+): ReadonlyArray {
+ if (provider !== AGY_DRIVER_KIND) return models;
+
+ const { canonicalSlugs, variantSlugs } = getGroupedAgyModelVariants(models);
+ return models.filter((model) => !variantSlugs.has(model.slug) || canonicalSlugs.has(model.slug));
+}
+
+export function getBuiltInProviderModelSlugs(
+ models: ReadonlyArray,
+ provider: ProviderDriverKind,
+): ReadonlySet {
+ const slugs = new Set(models.filter((model) => !model.isCustom).map((model) => model.slug));
+ for (const slug of getGroupedProviderModelVariantSlugs(models, provider)) {
+ slugs.add(slug);
+ }
+ return slugs;
+}
export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string {
return provider
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index ff06ad57dfe8..dd9c8fbf63f1 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{
available: true,
pickerSidebarBadge: "new",
},
+ {
+ value: ProviderDriverKind.make("agy"),
+ label: "Antigravity",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
];
export type WorkLogToolLifecycleStatus =
diff --git a/docs/user/install.md b/docs/user/install.md
index 15f96e00d4f3..5e4cd65e7701 100644
--- a/docs/user/install.md
+++ b/docs/user/install.md
@@ -54,15 +54,16 @@ yay -S t3code-nightly-bin
T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want
to use, then authenticate it.
-| Provider | CLI | Default binary | Log in with |
-| ---------- | ----------------------------------------------------- | -------------- | --------------------- |
-| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
-| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
-| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
-| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
-| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
-
-Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn
+| Provider | CLI | Default binary | Log in with |
+| ----------- | --------------------------------------------------------------- | -------------- | --------------------- |
+| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
+| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
+| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
+| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
+| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
+| Antigravity | [Antigravity CLI](https://antigravity.google/docs/cli/install/) | `agy` | `agy` |
+
+Codex, Claude, Cursor, and Antigravity are on by default. Grok Build and OpenCode are off by default; turn
them on in **Settings** → the provider's card when you want to use them.
Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
@@ -86,6 +87,7 @@ authenticated shows its status in **Settings** and fails at session start with t
to run.
For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md).
+For Antigravity setup and behavior, see [Antigravity](./providers-antigravity.md).
## Next Steps
diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md
new file mode 100644
index 000000000000..82f5af4feda7
--- /dev/null
+++ b/docs/user/providers-antigravity.md
@@ -0,0 +1,50 @@
+# Antigravity
+
+T3 Code can run Google Antigravity CLI sessions through the `agy` harness.
+
+## Install And Sign In
+
+Install Antigravity CLI using the
+[official installation guide](https://antigravity.google/docs/cli/install/), then launch it once on
+the machine running the T3 Code server:
+
+```bash
+agy
+```
+
+Complete the sign-in flow, then confirm the CLI can load its model catalog:
+
+```bash
+agy models
+```
+
+T3 Code uses that command to check provider readiness and populate the model picker.
+
+## Configure T3 Code
+
+Open **Settings**, select **Antigravity**, and refresh the provider status. T3 Code finds `agy` on
+the server's `PATH` by default. Set **Binary path** when the CLI is installed somewhere the server
+cannot discover.
+
+Use **Launch arguments** only for additional Antigravity CLI flags that should apply to every turn.
+T3 Code supplies the streaming format, model, reasoning effort, execution mode, continuation, and
+permission flags itself.
+
+## Sessions And Models
+
+Antigravity sessions preserve the CLI conversation ID, so later turns and restored T3 Code threads
+continue the same Antigravity conversation. Changing models between turns restarts the CLI process
+with the same conversation ID and the newly selected model.
+
+The standard T3 Code Plan toggle maps to Antigravity's plan mode. Other turns use accept-edits mode.
+Full-access sessions auto-approve Antigravity tool permissions; other T3 Code permission modes run
+the CLI sandboxed.
+
+## Troubleshooting
+
+If Antigravity is unavailable in the model picker:
+
+1. Run `agy --version` on the T3 Code server.
+2. Run `agy models` and complete sign-in if requested.
+3. Check **Settings** → **Antigravity** → **Binary path**.
+4. Refresh the provider status.
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 9fcd0d266dd6..ce36e40955aa 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
+const AGY_DRIVER_KIND = ProviderDriverKind.make("agy");
export const DEFAULT_MODEL = "gpt-5.6-sol";
@@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [AGY_DRIVER_KIND]: "Antigravity",
};
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 55023bcc48e7..35dd484951aa 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -203,12 +203,14 @@ describe("provider enabled defaults", () => {
expect(decoded.providers.cursor.enabled).toBe(true);
expect(decoded.providers.grok.enabled).toBe(false);
expect(decoded.providers.opencode.enabled).toBe(false);
+ expect(decoded.providers.agy.enabled).toBe(true);
});
it("derives per-driver defaults from the settings schemas", () => {
expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true);
expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true);
expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false);
+ expect(defaultEnabledForDriver(ProviderDriverKind.make("agy"))).toBe(true);
// Unknown fork drivers stay enabled; their own build decides otherwise.
expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true);
});
@@ -305,6 +307,28 @@ describe("ServerSettingsPatch.providerInstances", () => {
});
});
+describe("ServerSettingsPatch.providers", () => {
+ it("preserves Antigravity provider updates", () => {
+ const patch = decodeServerSettingsPatch({
+ providers: {
+ agy: {
+ enabled: false,
+ binaryPath: " /usr/local/bin/agy ",
+ customModels: ["gemini-custom"],
+ launchArgs: " --agent reviewer ",
+ },
+ },
+ });
+
+ expect(patch.providers?.agy).toEqual({
+ enabled: false,
+ binaryPath: "/usr/local/bin/agy",
+ customModels: ["gemini-custom"],
+ launchArgs: "--agent reviewer",
+ });
+ });
+});
+
describe("ServerSettingsPatch string normalization", () => {
it("trims string settings while decoding patches", () => {
const patch = decodeServerSettingsPatch({
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 80e03b8c879e..ea1b9b394cc3 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -531,6 +531,41 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
);
export type OpenCodeSettings = typeof OpenCodeSettings.Type;
+export const AgySettings = makeProviderSettingsSchema(
+ {
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(true)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("agy").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the Antigravity (agy) binary used by this instance.",
+ providerSettingsForm: { placeholder: "agy", clearWhenEmpty: "omit" },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ launchArgs: Schema.String.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Launch arguments",
+ description: "Additional CLI arguments passed on session start.",
+ providerSettingsForm: {
+ placeholder: "e.g. --agent reviewer",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ },
+ {
+ order: ["binaryPath", "launchArgs"],
+ },
+);
+export type AgySettings = typeof AgySettings.Type;
+
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -673,6 +708,7 @@ export const ServerSettings = Schema.Struct({
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ agy: AgySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
// are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -820,6 +856,13 @@ const OpenCodeSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const AgySettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+ launchArgs: Schema.optionalKey(TrimmedString),
+});
+
export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
@@ -861,6 +904,7 @@ export const ServerSettingsPatch = Schema.Struct({
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
+ agy: Schema.optionalKey(AgySettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual