diff --git a/CHANGELOG.md b/CHANGELOG.md
index c93852a1..8ce517fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Antigravity CLI joins the provider list. Install it with `curl -fsSL https://antigravity.google/cli/install.sh | bash`, then run `agy` to sign in. MonoCode uses its official streaming protocol for live sessions, model discovery, persistent conversations, skills, and staged image, video, audio, document, and file attachments.
- Settings → General → Diff view: Editor or Unified. Unified stacks every working-tree change in one **Changes** tab — GitHub-style review, editor syntax colours, sticky file headers and line numbers, and a single horizontal scroll that stops at the end of the line. Editor keeps the previous per-file working-tree tabs.
+### Fixed
+
+- Long Antigravity turns can run for up to 30 minutes instead of being cut off by the CLI's five-minute print-mode default, and a failed turn is shown only once in the transcript.
+
## [0.1.29] - 2026-09-02
### Added
diff --git a/NOTICE b/NOTICE
index cda0c48f..4ab1e0f3 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,4 +1,4 @@
MonoCode is not affiliated with, endorsed by, or sponsored by the makers of the
agent harnesses it can drive.
-Provider marks that appear in the UI (including Claude, Codex, Cursor, Grok, OpenCode, Pi, omp, and fx) are trademarks of their respective owners and are used only to identify those products.
+Provider marks that appear in the UI (including Claude, Codex, Cursor, Grok, Antigravity, OpenCode, Pi, omp, and fx) are trademarks of their respective owners and are used only to identify those products.
diff --git a/README.md b/README.md
index ddd6d39a..323f164b 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Pi, omp, and fx. If they’re installed and logged in, MonoCode can run them. Tabs are sessions. The composer is the input. MonoCode does not sell tokens.
+Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Antigravity, OpenCode, Pi, omp, and fx. If they’re installed and logged in, MonoCode can run them. Tabs are sessions. The composer is the input. MonoCode does not sell tokens.
## Install
@@ -22,6 +22,7 @@ Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCod
> - [Codex](https://developers.openai.com/codex/cli) - `codex login`
> - [Cursor CLI](https://cursor.com/cli) - `agent login`
> - [Grok Build](https://docs.x.ai/build/overview) - `curl -fsSL https://x.ai/cli/install.sh | bash` then `grok login`
+> - [Antigravity CLI](https://antigravity.google/docs/cli/overview) - `curl -fsSL https://antigravity.google/cli/install.sh | bash` then run `agy` to sign in
> - [OpenCode](https://opencode.ai) - `opencode auth login`
> - [Pi](https://pi.dev/) - `npm install -g @earendil-works/pi-coding-agent`
> - [omp](https://omp.sh) - `curl -fsSL https://omp.sh/install | sh`
diff --git a/src-tauri/src/harness.rs b/src-tauri/src/harness.rs
index 66f885d3..794b5efe 100644
--- a/src-tauri/src/harness.rs
+++ b/src-tauri/src/harness.rs
@@ -304,6 +304,19 @@ pub fn harness_resolve_grok() -> Result {
})
}
+/// Resolve the Google Antigravity CLI (`agy`).
+#[tauri::command(async)]
+pub fn harness_resolve_antigravity() -> Result {
+ resolve_antigravity()
+ .map(|path| CursorBinary {
+ path: path.to_string_lossy().into_owned(),
+ })
+ .ok_or_else(|| {
+ "Antigravity CLI not found. Install it with `curl -fsSL https://antigravity.google/cli/install.sh | bash` and run `agy login`, then retry."
+ .into()
+ })
+}
+
/// Bind an ephemeral loopback port for `opencode serve`.
#[tauri::command]
pub fn harness_free_port() -> Result {
@@ -634,6 +647,10 @@ const EXEC_ALLOWED_ARGS: &[&[&str]] = &[
&["models"],
&["status", "--json"],
&["agent", "list"],
+ &["--output-format", "json", "models"],
+ &["--output-format", "json", "agents"],
+ &["--output-format", "json", "-p", "/usage"],
+ &["-p", "/usage"],
];
fn exec_args_allowed(args: &[String]) -> bool {
@@ -655,6 +672,7 @@ fn is_resolved_harness_binary(command: &str) -> bool {
resolve_omp(),
resolve_fx(),
resolve_grok(),
+ resolve_antigravity(),
]
.into_iter()
.flatten()
@@ -688,11 +706,15 @@ fn exec_capture(command: &str, args: &[String], cwd: Option<&str>) -> Result) -> Result {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if output.status.success() || !stdout.trim().is_empty() {
@@ -935,6 +957,7 @@ fn is_harness_argv_token(part: &str) -> bool {
| "codex"
| "opencode"
| "grok"
+ | "agy"
| "omp"
| "fx"
| "pi"
@@ -1353,6 +1376,51 @@ fn resolve_grok() -> Option {
candidates.into_iter().find(|path| is_grok_agent(path))
}
+fn resolve_antigravity() -> Option {
+ let home = dirs_home().map(PathBuf::from);
+ let mut candidates: Vec = Vec::new();
+
+ // The official installer uses ~/.local/bin. Keep this stable shim ahead
+ // of PATH so upgrades do not turn into a new macOS TCC identity.
+ if let Some(home) = &home {
+ for name in antigravity_binary_names() {
+ candidates.push(home.join(".local/bin").join(name));
+ candidates.push(home.join(".gemini/bin").join(name));
+ candidates.push(home.join(".antigravity/bin").join(name));
+ }
+ #[cfg(target_os = "windows")]
+ for name in antigravity_binary_names() {
+ candidates.push(home.join("AppData/Local/Antigravity/bin").join(name));
+ }
+ }
+ #[cfg(target_os = "macos")]
+ for name in antigravity_binary_names() {
+ candidates.push(PathBuf::from("/opt/homebrew/bin").join(name));
+ }
+ for name in antigravity_binary_names() {
+ candidates.push(PathBuf::from("/usr/local/bin").join(name));
+ candidates.push(PathBuf::from("/usr/bin").join(name));
+ candidates.push(PathBuf::from("/snap/bin").join(name));
+ }
+ for name in antigravity_binary_names() {
+ if let Some(from_shell) = which_via_login_shell(name) {
+ candidates.push(from_shell);
+ }
+ }
+
+ candidates.into_iter().find(|path| path.is_file())
+}
+
+#[cfg(target_os = "windows")]
+fn antigravity_binary_names() -> &'static [&'static str] {
+ &["agy.exe", "agy"]
+}
+
+#[cfg(not(target_os = "windows"))]
+fn antigravity_binary_names() -> &'static [&'static str] {
+ &["agy"]
+}
+
fn is_pi_coding_agent(path: &Path) -> bool {
if !path.is_file() {
return false;
@@ -2308,6 +2376,11 @@ mod tests {
assert_eq!(command_basename("/Users/me/.grok/bin/grok"), "grok");
}
+ #[test]
+ fn antigravity_uses_the_official_cli_name() {
+ assert_eq!(antigravity_binary_names(), &["agy"]);
+ }
+
#[test]
fn passwd_identity_resolves_the_current_user() {
let id = passwd_identity().expect("passwd");
@@ -2333,6 +2406,16 @@ mod exec_allowlist_tests {
assert!(exec_args_allowed(&args(&["models"])));
assert!(exec_args_allowed(&args(&["status", "--json"])));
assert!(exec_args_allowed(&args(&["agent", "list"])));
+ assert!(exec_args_allowed(&args(&[
+ "--output-format",
+ "json",
+ "models"
+ ])));
+ assert!(exec_args_allowed(&args(&[
+ "--output-format",
+ "json",
+ "agents"
+ ])));
}
#[test]
@@ -2449,6 +2532,9 @@ mod reap_logic_tests {
"/opt/homebrew/bin/node /Users/n/.local/share/cursor-agent/versions/x/index.js worker-server"
));
assert!(looks_like_harness_argv("/Users/n/.local/bin/claude --help"));
+ assert!(looks_like_harness_argv(
+ "/Users/n/.local/bin/agy --input-format stream-json"
+ ));
assert!(!looks_like_harness_argv("tmux new -s work"));
assert!(!looks_like_harness_argv("npm start"));
assert!(!looks_like_harness_argv(
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7c6c1e25..88fb7c99 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -225,6 +225,7 @@ pub fn run() {
harness::harness_resolve_pi,
harness::harness_resolve_fx,
harness::harness_resolve_grok,
+ harness::harness_resolve_antigravity,
harness::harness_free_port,
harness::harness_spawn,
harness::harness_write,
diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs
index 846e210f..d47f3136 100644
--- a/src-tauri/src/skills.rs
+++ b/src-tauri/src/skills.rs
@@ -50,8 +50,10 @@ pub(crate) fn list_skills_from(project: &Path, home: Option<&Path>) -> Vec) -> Vec) -> Vec session.harness)),
];
- void refreshHarnessCatalogs(harnesses).then(() => {
+ void refreshHarnessCatalogs(harnesses, projectCwd).then(() => {
setSessions((prev) =>
prev.map((session) => {
if (!isLiveHarness(session.harness)) return session;
@@ -700,7 +700,7 @@ export default function App({
}),
);
});
- }, []);
+ }, [projectCwd]);
const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0];
const active =
@@ -756,7 +756,11 @@ export default function App({
const busySessionIds = busySessionIdsRef.current;
const usageProviders = useMemo(() => {
- if (active?.harness === "claude" || active?.harness === "codex") {
+ if (
+ active?.harness === "claude" ||
+ active?.harness === "codex" ||
+ active?.harness === "antigravity"
+ ) {
return [active.harness];
}
return [];
diff --git a/src/assets/providers/antigravity.svg b/src/assets/providers/antigravity.svg
new file mode 100644
index 00000000..1f08a929
--- /dev/null
+++ b/src/assets/providers/antigravity.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx
index 04a79a4f..5b084520 100644
--- a/src/chrome/Composer.tsx
+++ b/src/chrome/Composer.tsx
@@ -970,6 +970,7 @@ export function Composer({
ref.current?.focus()}
diff --git a/src/chrome/HarnessIcon.tsx b/src/chrome/HarnessIcon.tsx
index 73f978a9..f83e7cb8 100644
--- a/src/chrome/HarnessIcon.tsx
+++ b/src/chrome/HarnessIcon.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import antigravity from "../assets/providers/antigravity.svg";
import claude from "../assets/providers/claude.svg";
import codex from "../assets/providers/codex.svg";
import cursor from "../assets/providers/cursor.svg";
@@ -14,6 +15,7 @@ export const HARNESS_ICONS: Record = {
codex,
cursor,
grok,
+ antigravity,
opencode,
pi,
omp,
diff --git a/src/chrome/ModelPicker.tsx b/src/chrome/ModelPicker.tsx
index 7f0baf9b..446d3633 100644
--- a/src/chrome/ModelPicker.tsx
+++ b/src/chrome/ModelPicker.tsx
@@ -49,6 +49,7 @@ import { MOD } from "../lib/platform";
type Props = {
harness: HarnessId;
model: string;
+ cwd?: string;
hotkeys?: boolean;
onChange: (harness: HarnessId, model: string) => void;
onClose?: () => void;
@@ -61,6 +62,7 @@ const MENU_MAX_HEIGHT = 340;
export function ModelPicker({
harness,
model,
+ cwd,
hotkeys = false,
onChange,
onClose,
@@ -143,8 +145,8 @@ export function ModelPicker({
useEffect(() => {
if (!open || visibleTab === "favorites") return;
- void refreshHarnessCatalogs([visibleTab]);
- }, [open, visibleTab]);
+ void refreshHarnessCatalogs([visibleTab], cwd);
+ }, [cwd, open, visibleTab]);
useEffect(() => {
const inBlockingUi = (target: EventTarget | null) => {
diff --git a/src/chrome/SecondOpinionButton.tsx b/src/chrome/SecondOpinionButton.tsx
index b0e99cdb..747fddd4 100644
--- a/src/chrome/SecondOpinionButton.tsx
+++ b/src/chrome/SecondOpinionButton.tsx
@@ -40,6 +40,7 @@ import { Popover } from "./Popover";
type Props = {
from: HarnessId;
onPick: (harness: HarnessId, model: string) => void;
+ cwd?: string;
icon?: IconComponent;
title?: string;
disabledTitle?: string;
@@ -55,11 +56,16 @@ const SUBMENU_OVERLAP = -4;
/** Neither menu is inside the other, so a click in one is not a click away. */
const SELF = "[data-provider-target]";
-export function HandoffButton({ from, onPick }: Pick) {
+export function HandoffButton({
+ from,
+ onPick,
+ cwd,
+}: Pick) {
return (
)
export function SecondOpinionButton({
from,
onPick,
+ cwd,
icon: Icon = MessageMultiple,
title = "Second opinion",
disabledTitle = "Install another provider for a second opinion",
@@ -127,8 +134,8 @@ export function SecondOpinionButton({
useEffect(() => {
if (!open || !activeHarness) return;
- void refreshHarnessCatalogs([activeHarness]);
- }, [open, activeHarness]);
+ void refreshHarnessCatalogs([activeHarness], cwd);
+ }, [cwd, open, activeHarness]);
useEffect(() => {
setActive(0);
diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx
index a25d142a..0b33640a 100644
--- a/src/chrome/UsageFooter.tsx
+++ b/src/chrome/UsageFooter.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { HarnessIcon } from "./HarnessIcon";
import { Popover } from "./Popover";
import {
+ fetchAntigravityRateLimits,
fetchClaudeRateLimits,
fetchCodexRateLimits,
} from "../lib/rateLimitsFetch";
@@ -46,19 +47,25 @@ export function UsageFooter({
}) {
const wantClaude = providers.includes("claude");
const wantCodex = providers.includes("codex");
+ const wantAntigravity = providers.includes("antigravity");
const [claude, setClaude] = useState(() =>
idleRateLimits("claude"),
);
const [codex, setCodex] = useState(() =>
idleRateLimits("codex"),
);
+ const [antigravity, setAntigravity] = useState(() =>
+ idleRateLimits("antigravity"),
+ );
const [now, setNow] = useState(() => Date.now());
const [refreshing, setRefreshing] = useState(false);
const inflight = useRef | null>(null);
const claudeRef = useRef(claude);
const codexRef = useRef(codex);
+ const antigravityRef = useRef(antigravity);
claudeRef.current = claude;
codexRef.current = codex;
+ antigravityRef.current = antigravity;
const refresh = useCallback((force = false) => {
if (inflight.current) return inflight.current;
@@ -69,7 +76,10 @@ export function UsageFooter({
const fetchCodex =
wantCodex &&
shouldFetchProvider(codexRef.current, { force, visible });
- if (!fetchClaude && !fetchCodex) return;
+ const fetchAntigravity =
+ wantAntigravity &&
+ shouldFetchProvider(antigravityRef.current, { force, visible });
+ if (!fetchClaude && !fetchCodex && !fetchAntigravity) return;
if (force) setRefreshing(true);
const jobs: Promise[] = [];
if (fetchClaude) {
@@ -88,6 +98,14 @@ export function UsageFooter({
}),
);
}
+ if (fetchAntigravity) {
+ setAntigravity((current) => fetchingRateLimits("antigravity", current));
+ jobs.push(
+ fetchAntigravityRateLimits().then((value) => {
+ setAntigravity(value);
+ }),
+ );
+ }
const run = Promise.allSettled(jobs)
.then(() => undefined)
.finally(() => {
@@ -96,7 +114,7 @@ export function UsageFooter({
});
inflight.current = run;
return run;
- }, [wantClaude, wantCodex]);
+ }, [wantClaude, wantCodex, wantAntigravity]);
useEffect(() => {
void refresh();
@@ -116,7 +134,7 @@ export function UsageFooter({
return () => window.clearInterval(timer);
}, []);
- const showUsage = wantClaude || wantCodex;
+ const showUsage = wantClaude || wantCodex || wantAntigravity;
const showTerminals = terminals.length > 0;
const showRight = showUsage || showTerminals;
const ariaLabel = showUsage
@@ -136,6 +154,9 @@ export function UsageFooter({
<>
{wantClaude ? : null}
{wantCodex ? : null}
+ {wantAntigravity ? (
+
+ ) : null}
>
) : session ? (
diff --git a/src/chrome/useComposerSkills.ts b/src/chrome/useComposerSkills.ts
index 4ff136ab..0f5e37bf 100644
--- a/src/chrome/useComposerSkills.ts
+++ b/src/chrome/useComposerSkills.ts
@@ -6,6 +6,7 @@ import {
useState,
} from "react";
import {
+ ANTIGRAVITY_NATIVE_SKILLS,
loadSkills,
mergeCatalog,
peekSkills,
@@ -59,7 +60,12 @@ export function useComposerSkills(input: {
);
const contextKey = skillCatalogKey(context);
const fallback = useMemo(
- () => (input.harness === "pi" ? [] : mergeCatalog([])),
+ () =>
+ input.harness === "pi"
+ ? []
+ : input.harness === "antigravity"
+ ? mergeCatalog([], ANTIGRAVITY_NATIVE_SKILLS)
+ : mergeCatalog([]),
[input.harness],
);
const currentToken = useRef(null);
diff --git a/src/lib/fs.ts b/src/lib/fs.ts
index 18af476b..faf5856b 100644
--- a/src/lib/fs.ts
+++ b/src/lib/fs.ts
@@ -34,6 +34,7 @@ export type DiscoveredSkill = {
| "omp"
| "fx"
| "grok"
+ | "antigravity"
| "monocode";
};
diff --git a/src/lib/harness/antigravity.ts b/src/lib/harness/antigravity.ts
new file mode 100644
index 00000000..f9617233
--- /dev/null
+++ b/src/lib/harness/antigravity.ts
@@ -0,0 +1,509 @@
+import { invoke } from "@tauri-apps/api/core";
+import { modelContextWindow } from "../models";
+import type { Attachment } from "../session";
+import {
+ killChild,
+ resolveAntigravityBinary,
+ spawnChild,
+ unwatchChild,
+ watchChild,
+ writeChild,
+} from "./child";
+import {
+ antigravityContextWindow,
+ attachmentDirs,
+ buildAntigravitySpawnArgs,
+ buildAntigravityUserMessage,
+ effectiveAntigravitySettings,
+ mapAntigravityLine,
+ parseAntigravityLine,
+} from "./antigravityProtocol";
+import type {
+ ApprovalDecision,
+ HarnessEvent,
+ SendTurnInput,
+ SteerTurnInput,
+} from "./types";
+
+type Live = {
+ cwd: string;
+ settingsKey: string;
+ conversationId: string;
+ contextWindow?: number;
+ onEvent: (event: HarnessEvent) => void;
+ turns: Promise;
+ initialized: boolean;
+ initDone: (() => void) | null;
+ initFailed: ((error: Error) => void) | null;
+ turnDone: (() => void) | null;
+ turnFailed: ((error: Error) => void) | null;
+ activeTurn: boolean;
+ cancelled: boolean;
+ muteUpdates: boolean;
+ emittedAssistant: string;
+ emittedThinking: string;
+ exitError: Error | null;
+ addedDirs: string[];
+};
+
+import {
+ type UserQuestion,
+ type UserQuestionReply,
+ selectedAnswerLabels,
+} from "../userQuestion";
+
+type Resume = { conversationId: string; cwd: string };
+
+const INIT_TIMEOUT_MS = 90_000;
+const liveByThread = new Map();
+const resumeByThread = new Map();
+const cancelledThreads = new Set();
+const stagedAttachmentPaths = new Map();
+const pendingQuestionsByThread = new Map>();
+
+let resolveAntigravityBinaryImpl: () => Promise<{ path: string }> =
+ resolveAntigravityBinary;
+
+/** Test seam. */
+export function setAntigravityBinaryResolver(
+ fn: () => Promise<{ path: string }>,
+): void {
+ resolveAntigravityBinaryImpl = fn;
+}
+
+export async function sendAntigravityTurn(input: SendTurnInput): Promise {
+ // stream-json only accepts text. Browser-pasted images are base64-only until
+ // we materialize them; file references then make them available to agy.
+ const attachments = await materializeAttachments(
+ input.sessionId,
+ input.attachments,
+ );
+ const prepared =
+ attachments === input.attachments ? input : { ...input, attachments };
+ let live: Live;
+ try {
+ live = await ensureLive(prepared);
+ } catch (error) {
+ cancelledThreads.delete(input.sessionId);
+ throw error;
+ }
+ if (cancelledThreads.delete(input.sessionId)) return;
+
+ live.onEvent = prepared.onEvent;
+ live.turns = live.turns
+ .catch(() => undefined)
+ .then(async () => {
+ live.cancelled = false;
+ live.muteUpdates = false;
+ await runTurn(live, prepared);
+ });
+ try {
+ await live.turns;
+ } catch (error) {
+ if (liveByThread.get(input.sessionId) === live) {
+ await stopAntigravitySession(input.sessionId);
+ }
+ throw error;
+ }
+}
+
+export async function steerAntigravityTurn(input: SteerTurnInput): Promise {
+ const live = liveByThread.get(input.sessionId);
+ if (!live?.activeTurn) throw new Error("No active turn to steer");
+
+ const attachments = await materializeAttachments(
+ input.sessionId,
+ input.attachments,
+ );
+ const message = buildAntigravityUserMessage({
+ text: input.text,
+ cwd: live.cwd,
+ attachments,
+ });
+ if (!message) return;
+
+ await writeChild(input.sessionId, JSON.stringify(message));
+}
+
+/** Headless stream-json has no approval response channel. */
+export function respondAntigravityApproval(
+ _sessionId: string,
+ _requestId: number,
+ _decision: ApprovalDecision,
+): void {}
+
+export function respondAntigravityQuestion(
+ sessionId: string,
+ requestId: number,
+ reply: UserQuestionReply,
+): void {
+ const live = liveByThread.get(sessionId);
+ if (!live) return;
+
+ const questions = pendingQuestionsByThread.get(sessionId)?.get(requestId);
+ pendingQuestionsByThread.get(sessionId)?.delete(requestId);
+
+ live.onEvent({
+ type: "question.resolved",
+ requestId,
+ decision: reply.kind === "skipped" ? "skipped" : "answered",
+ });
+
+ if (reply.kind === "skipped") {
+ const message = buildAntigravityUserMessage({
+ text: "The user skipped answering the clarifying question(s). Please proceed using your best judgment.",
+ cwd: live.cwd,
+ });
+ if (message) {
+ void writeChild(sessionId, JSON.stringify(message)).catch(() => undefined);
+ }
+ return;
+ }
+
+ const answerLines: string[] = [];
+ for (const q of questions ?? []) {
+ const labels = selectedAnswerLabels(q, reply);
+ if (labels.length > 0) {
+ answerLines.push(`- ${q.prompt}: ${labels.join(", ")}`);
+ }
+ }
+
+ const answerText =
+ answerLines.length > 0
+ ? `User response to clarifying questions:\n${answerLines.join("\n")}`
+ : "User answered clarifying question.";
+
+ const message = buildAntigravityUserMessage({
+ text: answerText,
+ cwd: live.cwd,
+ });
+ if (message) {
+ void writeChild(sessionId, JSON.stringify(message)).catch(() => undefined);
+ }
+}
+
+export async function cancelAntigravityTurn(sessionId: string): Promise {
+ const live = liveByThread.get(sessionId);
+ if (!live) {
+ cancelledThreads.add(sessionId);
+ return;
+ }
+ live.cancelled = true;
+ live.muteUpdates = true;
+ live.activeTurn = false;
+ live.onEvent({ type: "message.completed" });
+ live.onEvent({ type: "reasoning.completed" });
+ live.turnDone?.();
+ live.turnDone = null;
+ live.turnFailed = null;
+ // Closing stdin waits for the active turn. Kill immediately and preserve the
+ // conversation id so the next send can resume it in a fresh process.
+ liveByThread.delete(sessionId);
+ await killChild(sessionId).catch(() => undefined);
+}
+
+export async function stopAntigravitySession(sessionId: string): Promise {
+ cancelledThreads.delete(sessionId);
+ pendingQuestionsByThread.delete(sessionId);
+ const live = liveByThread.get(sessionId);
+ liveByThread.delete(sessionId);
+ if (live) {
+ live.muteUpdates = true;
+ live.activeTurn = false;
+ live.turnDone?.();
+ live.initDone?.();
+ live.turnDone = null;
+ live.turnFailed = null;
+ live.initDone = null;
+ live.initFailed = null;
+ }
+ unwatchChild(sessionId);
+ await killChild(sessionId).catch(() => undefined);
+}
+
+export async function forgetAntigravitySession(
+ sessionId: string,
+): Promise {
+ resumeByThread.delete(sessionId);
+ for (const key of stagedAttachmentPaths.keys()) {
+ if (key.startsWith(`${sessionId}:`)) stagedAttachmentPaths.delete(key);
+ }
+ await stopAntigravitySession(sessionId);
+}
+
+export function bindAntigravitySession(
+ threadId: string,
+ providerSessionId: string,
+ cwd: string,
+): void {
+ const conversationId = providerSessionId.trim();
+ if (!threadId || !conversationId || !cwd.trim()) return;
+ resumeByThread.set(threadId, { conversationId, cwd });
+}
+
+async function ensureLive(input: SendTurnInput): Promise {
+ const settingsKey = settingsKeyFor(input);
+ const existing = liveByThread.get(input.sessionId);
+ if (
+ existing &&
+ existing.cwd === input.cwd &&
+ existing.settingsKey === settingsKey
+ ) {
+ const currentAttachmentDirs = attachmentDirs(input.attachments);
+ const hasUnaddedDir = currentAttachmentDirs.some(
+ (dir) => !existing.addedDirs.includes(dir),
+ );
+ if (!hasUnaddedDir) {
+ existing.onEvent = input.onEvent;
+ return existing;
+ }
+ }
+ const priorDirs = existing?.addedDirs ?? [];
+ if (existing) await stopAntigravitySession(input.sessionId);
+
+ const saved = resumeByThread.get(input.sessionId);
+ const resume = saved?.cwd === input.cwd ? saved.conversationId : undefined;
+ if (saved && !resume) resumeByThread.delete(input.sessionId);
+ const { path } = await resolveAntigravityBinaryImpl();
+ const contextWindow =
+ modelContextWindow(input.model) ?? antigravityContextWindow(input.model);
+ const liveRef: { current: Live | null } = { current: null };
+ const allDirs = [
+ ...new Set([...priorDirs, ...attachmentDirs(input.attachments)]),
+ ];
+ const live: Live = {
+ cwd: input.cwd,
+ settingsKey,
+ conversationId: resume ?? "",
+ contextWindow,
+ onEvent: input.onEvent,
+ turns: Promise.resolve(),
+ initialized: false,
+ initDone: null,
+ initFailed: null,
+ turnDone: null,
+ turnFailed: null,
+ activeTurn: false,
+ cancelled: false,
+ muteUpdates: false,
+ emittedAssistant: "",
+ emittedThinking: "",
+ exitError: null,
+ addedDirs: allDirs,
+ };
+ liveRef.current = live;
+
+ watchChild(
+ input.sessionId,
+ (line) => {
+ const current = liveRef.current;
+ if (current) handleLine(input.sessionId, current, line);
+ },
+ (code) => {
+ const current = liveRef.current;
+ liveByThread.delete(input.sessionId);
+ if (!current?.muteUpdates)
+ current?.onEvent({ type: "session.ended", code });
+ const error = new Error(
+ "Antigravity CLI exited before completing the turn",
+ );
+ if (current) current.exitError = error;
+ current?.initFailed?.(error);
+ if (!current?.cancelled) current?.turnFailed?.(error);
+ if (current) {
+ current.initDone = null;
+ current.initFailed = null;
+ current.turnDone = null;
+ current.turnFailed = null;
+ }
+ },
+ (line) => console.debug("[monocode] antigravity stderr", line),
+ );
+
+ await spawnChild(
+ input.sessionId,
+ path,
+ buildAntigravitySpawnArgs({
+ model: input.model,
+ modelSettings: input.modelSettings,
+ runtimeMode: input.runtimeMode,
+ resume,
+ cwd: input.cwd,
+ addDirs: allDirs,
+ prompt: input.text,
+ }),
+ input.cwd,
+ );
+ liveByThread.set(input.sessionId, live);
+ try {
+ await waitForInit(live);
+ if (!live.conversationId)
+ throw new Error("Antigravity CLI started without a conversation id");
+ resumeByThread.set(input.sessionId, {
+ conversationId: live.conversationId,
+ cwd: input.cwd,
+ });
+ live.onEvent({
+ type: "session.providerBound",
+ providerSessionId: live.conversationId,
+ });
+ live.onEvent({ type: "session.started" });
+ return live;
+ } catch (error) {
+ await stopAntigravitySession(input.sessionId);
+ throw error;
+ }
+}
+
+async function runTurn(live: Live, input: SendTurnInput): Promise {
+ const message = buildAntigravityUserMessage({
+ text: input.text,
+ cwd: input.cwd,
+ attachments: input.attachments,
+ });
+ if (!message) return;
+ live.emittedAssistant = "";
+ live.emittedThinking = "";
+ const done = new Promise((resolve, reject) => {
+ live.turnDone = resolve;
+ live.turnFailed = reject;
+ });
+ live.activeTurn = true;
+ try {
+ await writeChild(input.sessionId, JSON.stringify(message));
+ await done;
+ } catch (error) {
+ if (live.cancelled) return;
+ const message = error instanceof Error ? error.message : String(error);
+ live.onEvent({ type: "session.error", message });
+ throw error;
+ } finally {
+ live.activeTurn = false;
+ live.turnDone = null;
+ live.turnFailed = null;
+ }
+}
+
+function handleLine(sessionId: string, live: Live, line: string): void {
+ const parsed = parseAntigravityLine(line);
+ if (!parsed) return;
+ const mapped = mapAntigravityLine(
+ parsed,
+ live.emittedAssistant,
+ live.contextWindow,
+ live.emittedThinking,
+ );
+ if (
+ mapped.providerSessionId &&
+ mapped.providerSessionId !== live.conversationId
+ ) {
+ live.conversationId = mapped.providerSessionId;
+ resumeByThread.set(sessionId, {
+ conversationId: mapped.providerSessionId,
+ cwd: live.cwd,
+ });
+ }
+ if (mapped.initialized !== undefined) {
+ live.initialized = true;
+ if (live.contextWindow) {
+ live.onEvent({ type: "context", window: live.contextWindow });
+ }
+ live.initDone?.();
+ live.initDone = null;
+ live.initFailed = null;
+ }
+ if (!live.muteUpdates) {
+ for (const event of mapped.events) {
+ if (event.type === "message.delta") live.emittedAssistant += event.text;
+ if (event.type === "reasoning.delta") live.emittedThinking += event.text;
+ if (event.type === "question.asked") {
+ let threadQuestions = pendingQuestionsByThread.get(sessionId);
+ if (!threadQuestions) {
+ threadQuestions = new Map();
+ pendingQuestionsByThread.set(sessionId, threadQuestions);
+ }
+ threadQuestions.set(event.requestId, event.questions);
+ }
+ live.onEvent(event);
+ }
+ }
+ if (!mapped.turnCompleted) return;
+ if (mapped.turnCompleted.ok) {
+ live.turnDone?.();
+ } else {
+ live.turnFailed?.(
+ new Error(mapped.turnCompleted.error ?? "Antigravity CLI turn failed"),
+ );
+ }
+}
+
+function waitForInit(live: Live): Promise {
+ if (live.initialized) return Promise.resolve();
+ if (live.exitError) return Promise.reject(live.exitError);
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ live.initDone = null;
+ live.initFailed = null;
+ reject(new Error("Antigravity CLI did not initialize in time"));
+ }, INIT_TIMEOUT_MS);
+ live.initDone = () => {
+ clearTimeout(timer);
+ resolve();
+ };
+ live.initFailed = (error) => {
+ clearTimeout(timer);
+ reject(error);
+ };
+ });
+}
+
+function settingsKeyFor(input: SendTurnInput): string {
+ const effective = effectiveAntigravitySettings({
+ model: input.model,
+ modelSettings: input.modelSettings,
+ runtimeMode: input.runtimeMode,
+ prompt: input.text,
+ });
+ return JSON.stringify({
+ cwd: input.cwd,
+ model: effective.model,
+ effort: effective.effort ?? "",
+ agent: effective.agent ?? "",
+ mode: effective.mode ?? input.runtimeMode,
+ });
+}
+
+/** Test-only cleanup. */
+export async function resetAntigravityLiveForTests(): Promise {
+ const ids = [...new Set([...liveByThread.keys(), ...resumeByThread.keys()])];
+ liveByThread.clear();
+ resumeByThread.clear();
+ cancelledThreads.clear();
+ stagedAttachmentPaths.clear();
+ await Promise.all(ids.map((id) => killChild(id).catch(() => undefined)));
+ resolveAntigravityBinaryImpl = resolveAntigravityBinary;
+}
+
+async function materializeAttachments(
+ sessionId: string,
+ attachments: Attachment[] | undefined,
+): Promise {
+ if (!attachments?.some((attachment) => !attachment.path && attachment.data)) {
+ return attachments;
+ }
+ return Promise.all(
+ attachments.map(async (attachment) => {
+ if (attachment.path || !attachment.data) return attachment;
+ const key = `${sessionId}:${attachment.id}`;
+ let path = stagedAttachmentPaths.get(key);
+ if (!path) {
+ path = await invoke("write_attachment", {
+ name: attachment.name,
+ data: attachment.data,
+ });
+ stagedAttachmentPaths.set(key, path);
+ }
+ return { ...attachment, path };
+ }),
+ );
+}
diff --git a/src/lib/harness/antigravityAdapter.ts b/src/lib/harness/antigravityAdapter.ts
new file mode 100644
index 00000000..77dcc42d
--- /dev/null
+++ b/src/lib/harness/antigravityAdapter.ts
@@ -0,0 +1,47 @@
+import {
+ bindAntigravitySession,
+ cancelAntigravityTurn,
+ forgetAntigravitySession,
+ respondAntigravityApproval,
+ respondAntigravityQuestion,
+ sendAntigravityTurn,
+ steerAntigravityTurn,
+ stopAntigravitySession,
+} from "./antigravity";
+import { refreshAntigravityCatalog } from "./antigravityCatalog";
+import {
+ generateAntigravityBranchName,
+ generateAntigravityCommitMessage,
+ generateAntigravityPrContent,
+} from "./antigravityGit";
+import { generateAntigravitySessionTitle } from "./antigravityTitle";
+import { warmupAntigravityText } from "./antigravityText";
+import { registerHarness, type HarnessAdapter } from "./registry";
+
+export const antigravityAdapter: HarnessAdapter = {
+ id: "antigravity",
+ live: true,
+ canSteer: true,
+ sendTurn: sendAntigravityTurn,
+ steerTurn: steerAntigravityTurn,
+ cancelTurn: cancelAntigravityTurn,
+ respondApproval: respondAntigravityApproval,
+ respondQuestion: respondAntigravityQuestion,
+ stopSession: stopAntigravitySession,
+ forgetSession: forgetAntigravitySession,
+ bindSession: bindAntigravitySession,
+ refreshCatalog: refreshAntigravityCatalog,
+ generateTitle: generateAntigravitySessionTitle,
+ generateCommitMessage: generateAntigravityCommitMessage,
+ generatePrContent: generateAntigravityPrContent,
+ generateBranchName: generateAntigravityBranchName,
+ warmupText: warmupAntigravityText,
+};
+
+let registered = false;
+
+export function ensureAntigravityRegistered(): void {
+ if (registered) return;
+ registerHarness(antigravityAdapter);
+ registered = true;
+}
diff --git a/src/lib/harness/antigravityCatalog.test.ts b/src/lib/harness/antigravityCatalog.test.ts
new file mode 100644
index 00000000..3510c9f1
--- /dev/null
+++ b/src/lib/harness/antigravityCatalog.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it, vi } from "vitest";
+
+const execChild = vi.fn(async () =>
+ JSON.stringify({
+ command: {
+ data: {
+ models: [
+ { id: "gemini-3.7-flash-high", label: "Gemini 3.7 Flash (High)" },
+ { id: "gemini-3.7-flash-medium", label: "Gemini 3.7 Flash (Medium)" },
+ { id: "gemini-3.7-flash-low", label: "Gemini 3.7 Flash (Low)" },
+ ],
+ },
+ },
+ }),
+);
+
+vi.mock("./child", () => ({
+ resolveAntigravityBinary: async () => ({ path: "/fake/agy" }),
+ execChild,
+}));
+
+const { discoverAntigravityModels } = await import("./antigravityCatalog");
+
+describe("Antigravity model catalog", () => {
+ it("uses the structured models command result and unifies reasoning variants", async () => {
+ await expect(discoverAntigravityModels("/repo")).resolves.toEqual([
+ {
+ id: "antigravity:gemini-3.7-flash",
+ harness: "antigravity",
+ name: "Gemini 3.7 Flash",
+ nativeId: "gemini-3.7-flash",
+ contextWindow: 1_000_000,
+ settings: [
+ {
+ id: "effort",
+ label: "Reasoning",
+ kind: "select",
+ value: "high",
+ options: [
+ { label: "High", value: "high" },
+ { label: "Medium", value: "medium" },
+ { label: "Low", value: "low" },
+ ],
+ },
+ ],
+ },
+ ]);
+ expect(execChild).toHaveBeenCalledWith(
+ "/fake/agy",
+ ["--output-format", "json", "models"],
+ "/repo",
+ );
+ });
+
+ it("does not start a workspace-less probe", async () => {
+ const callsBefore = execChild.mock.calls.length;
+ await expect(discoverAntigravityModels()).resolves.toEqual([]);
+ expect(execChild).toHaveBeenCalledTimes(callsBefore);
+ });
+});
diff --git a/src/lib/harness/antigravityCatalog.ts b/src/lib/harness/antigravityCatalog.ts
new file mode 100644
index 00000000..ff1628fa
--- /dev/null
+++ b/src/lib/harness/antigravityCatalog.ts
@@ -0,0 +1,44 @@
+import { setHarnessModels, type AgentModel } from "../models";
+import { execChild, resolveAntigravityBinary } from "./child";
+import {
+ antigravityContextWindow,
+ parseAntigravityModels,
+ unifyAntigravityCatalogModels,
+} from "./antigravityProtocol";
+
+let inflight: Promise | null = null;
+
+/** Fetch the CLI's authenticated model catalog without starting an agent session. */
+export function refreshAntigravityCatalog(cwd?: string): Promise {
+ if (inflight) return inflight;
+ inflight = discoverAntigravityModels(cwd)
+ .then((models) => {
+ if (models.length) setHarnessModels("antigravity", models);
+ })
+ .catch((error: unknown) => {
+ console.debug("[monocode] antigravity catalog", error);
+ })
+ .finally(() => {
+ inflight = null;
+ });
+ return inflight;
+}
+
+export const antigravityContextWindowForModel = antigravityContextWindow;
+
+export async function discoverAntigravityModels(
+ cwd?: string,
+): Promise {
+ const workingDirectory = cwd?.trim();
+ if (!workingDirectory || workingDirectory === "~") return [];
+ const { path } = await resolveAntigravityBinary();
+ // Even metadata-only commands start an agy backend. Keep that backend in
+ // the active project instead of inheriting the desktop app's process cwd
+ // (which is commonly `/` when launched from Finder).
+ const output = await execChild(
+ path,
+ ["--output-format", "json", "models"],
+ workingDirectory,
+ );
+ return unifyAntigravityCatalogModels(parseAntigravityModels(output));
+}
diff --git a/src/lib/harness/antigravityGit.test.ts b/src/lib/harness/antigravityGit.test.ts
new file mode 100644
index 00000000..94371237
--- /dev/null
+++ b/src/lib/harness/antigravityGit.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("../fs", () => ({
+ gitStagedContext: async () => ({
+ branch: "main",
+ summary: "1 file changed, 10 insertions(+)",
+ patch: "diff --git a/src/index.ts b/src/index.ts\n+console.log('antigravity');",
+ }),
+ gitRangeContext: async () => ({
+ base: "main",
+ head: "feat/antigravity",
+ commitSummary: "Add Antigravity CLI support",
+ diffSummary: "2 files changed",
+ diffPatch: "+antigravity",
+ }),
+}));
+
+vi.mock("./antigravityText", () => ({
+ runAntigravityTextPrompt: async ({ prompt }: { prompt: string }) => {
+ if (prompt.includes("git commit messages")) {
+ return JSON.stringify({
+ subject: "feat: add antigravity support",
+ body: "Integrate Antigravity CLI into MonoCode",
+ });
+ }
+ if (prompt.includes("source control change request content")) {
+ return JSON.stringify({
+ title: "feat: add Antigravity integration",
+ body: "## Summary\n- Adds official Antigravity support.\n\n## Testing\n- Not run",
+ });
+ }
+ if (prompt.includes("git branch names")) {
+ return JSON.stringify({
+ branch: "feat/antigravity-support",
+ });
+ }
+ return "ok";
+ },
+}));
+
+const {
+ generateAntigravityCommitMessage,
+ generateAntigravityPrContent,
+ generateAntigravityBranchName,
+} = await import("./antigravityGit");
+
+describe("Antigravity git helpers", () => {
+ it("generates a formatted commit message", async () => {
+ await expect(generateAntigravityCommitMessage("/fake/repo")).resolves.toBe(
+ "feat: add antigravity support\n\nIntegrate Antigravity CLI into MonoCode",
+ );
+ });
+
+ it("generates pull request content", async () => {
+ await expect(generateAntigravityPrContent("/fake/repo")).resolves.toEqual({
+ title: "feat: add Antigravity integration",
+ body: "## Summary\n- Adds official Antigravity support.\n\n## Testing\n- Not run",
+ base: "main",
+ head: "feat/antigravity",
+ });
+ });
+
+ it("generates a branch name", async () => {
+ await expect(
+ generateAntigravityBranchName("/fake/repo", "add antigravity support"),
+ ).resolves.toBe("feat/antigravity-support");
+ });
+});
diff --git a/src/lib/harness/antigravityGit.ts b/src/lib/harness/antigravityGit.ts
new file mode 100644
index 00000000..1a950e61
--- /dev/null
+++ b/src/lib/harness/antigravityGit.ts
@@ -0,0 +1,87 @@
+import { gitRangeContext, gitStagedContext } from "../fs";
+import {
+ buildBranchNamePrompt,
+ buildCommitMessagePrompt,
+ buildPrContentPrompt,
+ formatCommitMessage,
+ parseBranchName,
+ parseCommitMessage,
+ parsePrContent,
+ type PrContent,
+} from "../gitText";
+import { runAntigravityTextPrompt } from "./antigravityText";
+
+const GIT_TIMEOUT_MS = 90_000;
+
+export async function generateAntigravityCommitMessage(
+ cwd: string,
+): Promise {
+ const context = await gitStagedContext(cwd);
+ const output = await runAntigravityTextPrompt({
+ cwd,
+ prompt: buildCommitMessagePrompt({
+ branch: context.branch,
+ stagedSummary: context.summary,
+ stagedPatch: context.patch,
+ }),
+ timeoutMs: GIT_TIMEOUT_MS,
+ });
+ const parsed = parseCommitMessage(output);
+ if (parsed) return formatCommitMessage(parsed);
+ const snippet = output.trim().replace(/\s+/g, " ").slice(0, 240);
+ throw new Error(
+ snippet
+ ? `Could not generate a commit message. Model replied: ${snippet}`
+ : "Could not generate a commit message. Antigravity CLI returned no text.",
+ );
+}
+
+export async function generateAntigravityPrContent(
+ cwd: string,
+): Promise<(PrContent & { base: string; head: string }) | null> {
+ const range = await gitRangeContext(cwd);
+ let parsed: PrContent | null = null;
+ try {
+ const output = await runAntigravityTextPrompt({
+ cwd,
+ prompt: buildPrContentPrompt({
+ baseBranch: range.base,
+ headBranch: range.head,
+ commitSummary: range.commitSummary,
+ diffSummary: range.diffSummary,
+ diffPatch: range.diffPatch,
+ }),
+ timeoutMs: GIT_TIMEOUT_MS,
+ });
+ parsed = parsePrContent(output);
+ } catch (error) {
+ console.debug("[monocode] pr content", error);
+ }
+ const title =
+ parsed?.title ||
+ range.commitSummary.split(/\r?\n/)[0]?.trim() ||
+ `Update ${range.head}`;
+ return {
+ title,
+ body: parsed?.body || range.commitSummary.trim(),
+ base: range.base,
+ head: range.head,
+ };
+}
+
+export async function generateAntigravityBranchName(
+ cwd: string,
+ message: string,
+): Promise {
+ try {
+ const output = await runAntigravityTextPrompt({
+ cwd,
+ prompt: buildBranchNamePrompt(message),
+ timeoutMs: GIT_TIMEOUT_MS,
+ });
+ return parseBranchName(output);
+ } catch (error) {
+ console.debug("[monocode] branch name", error);
+ return null;
+ }
+}
diff --git a/src/lib/harness/antigravityLive.test.ts b/src/lib/harness/antigravityLive.test.ts
new file mode 100644
index 00000000..d0186b6b
--- /dev/null
+++ b/src/lib/harness/antigravityLive.test.ts
@@ -0,0 +1,481 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const sent: string[] = [];
+const spawns: Array<{ args: string[]; cwd: string }> = [];
+let onLine: ((line: string) => void) | undefined;
+let onExit: ((code: number | null) => void) | undefined;
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: async (command: string) => {
+ if (command === "write_attachment")
+ return "/tmp/monocode-attachments/pasted.png";
+ throw new Error(`unexpected invoke: ${command}`);
+ },
+}));
+
+vi.mock("./child", () => ({
+ resolveAntigravityBinary: async () => ({ path: "/fake/agy" }),
+ spawnChild: async (
+ _id: string,
+ _path: string,
+ args: string[],
+ cwd: string,
+ ) => {
+ spawns.push({ args, cwd });
+ },
+ killChild: async () => undefined,
+ unwatchChild: () => undefined,
+ watchChild: (
+ _id: string,
+ line: (line: string) => void,
+ exit: (code: number | null) => void,
+ ) => {
+ onLine = line;
+ onExit = exit;
+ },
+ writeChild: async (_id: string, line: string) => {
+ sent.push(line);
+ },
+}));
+
+const {
+ sendAntigravityTurn,
+ steerAntigravityTurn,
+ cancelAntigravityTurn,
+ respondAntigravityQuestion,
+ stopAntigravitySession,
+ resetAntigravityLiveForTests,
+} = await import("./antigravity");
+import type { HarnessEvent } from "./types";
+
+const waitFor = async (predicate: () => boolean) => {
+ for (let i = 0; i < 100; i += 1) {
+ if (predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 2));
+ }
+ throw new Error("timed out");
+};
+
+function emit(event: Record) {
+ onLine?.(JSON.stringify(event));
+}
+
+function input(events: HarnessEvent[], text = "hello") {
+ return {
+ sessionId: "a1",
+ cwd: "/repo",
+ model: "antigravity:gemini-3.7-flash-high",
+ modelSettings: {},
+ runtimeMode: "supervised" as const,
+ text,
+ attachments: [],
+ onEvent: (event: HarnessEvent) => events.push(event),
+ };
+}
+
+beforeEach(() => {
+ sent.length = 0;
+ spawns.length = 0;
+ onLine = undefined;
+ onExit = undefined;
+});
+
+afterEach(async () => {
+ await resetAntigravityLiveForTests();
+});
+
+describe("Antigravity live adapter", () => {
+ it("waits for init before sending a text-only user event", async () => {
+ const events: HarnessEvent[] = [];
+ const turn = sendAntigravityTurn(input(events));
+ await waitFor(() => spawns.length === 1);
+ expect(spawns[0].cwd).toBe("/repo");
+ expect(sent).toEqual([]);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ expect(JSON.parse(sent[0])).toEqual({
+ event: "user",
+ message: { content: "hello" },
+ });
+ emit({
+ event: "step_update",
+ step_update: {
+ conversation_id: "conversation-1",
+ step_type: "agent_response",
+ state: "DONE",
+ text_delta: "hi",
+ },
+ });
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "hi",
+ },
+ });
+ await turn;
+ expect(events).toEqual(
+ expect.arrayContaining([
+ { type: "session.providerBound", providerSessionId: "conversation-1" },
+ { type: "session.started" },
+ { type: "message.delta", text: "hi" },
+ { type: "message.completed" },
+ ]),
+ );
+ });
+
+ it("keeps a child warm for follow-up turns", async () => {
+ const events: HarnessEvent[] = [];
+ const first = sendAntigravityTurn(input(events, "one"));
+ await waitFor(() => spawns.length === 1);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "one",
+ },
+ });
+ await first;
+ const second = sendAntigravityTurn(input(events, "two"));
+ await waitFor(() => sent.length === 2);
+ expect(spawns).toHaveLength(1);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "two",
+ },
+ });
+ await second;
+ });
+
+ it("cancels immediately and resumes on a later turn", async () => {
+ const events: HarnessEvent[] = [];
+ const first = sendAntigravityTurn(input(events));
+ await waitFor(() => spawns.length === 1);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ await cancelAntigravityTurn("a1");
+ await first;
+ const second = sendAntigravityTurn(input(events, "again"));
+ await waitFor(() => spawns.length === 2);
+ expect(spawns[1].args).toEqual(
+ expect.arrayContaining(["--conversation", "conversation-1"]),
+ );
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 2);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "done",
+ },
+ });
+ await second;
+ });
+
+ it("materializes pasted image data then sends a text @ reference", async () => {
+ const events: HarnessEvent[] = [];
+ const turn = sendAntigravityTurn({
+ ...input(events),
+ attachments: [
+ {
+ id: "pasted",
+ name: "pasted.png",
+ mimeType: "image/png",
+ kind: "image",
+ size: 3,
+ data: "AQID",
+ },
+ ],
+ });
+ await waitFor(() => spawns.length === 1);
+ expect(spawns[0].args).toEqual(
+ expect.arrayContaining(["--add-dir", "/tmp/monocode-attachments"]),
+ );
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ expect(JSON.parse(sent[0])).toEqual({
+ event: "user",
+ message: {
+ content:
+ "Attachments to inspect:\n@[/tmp/monocode-attachments/pasted.png]\n\nhello",
+ },
+ });
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "done",
+ },
+ });
+ await turn;
+ });
+
+ it("steers an in-flight turn by writing a user message to child stdin", async () => {
+ const events: HarnessEvent[] = [];
+ const turn = sendAntigravityTurn(input(events));
+ await waitFor(() => spawns.length === 1);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+
+ await steerAntigravityTurn({
+ sessionId: "a1",
+ cwd: "/repo",
+ text: "please focus on tests",
+ });
+
+ await waitFor(() => sent.length === 2);
+ expect(JSON.parse(sent[1])).toEqual({
+ event: "user",
+ message: { content: "please focus on tests" },
+ });
+
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "focused on tests",
+ },
+ });
+ await turn;
+ });
+
+ it("fails an in-flight turn when the child exits", async () => {
+ const events: HarnessEvent[] = [];
+ const turn = sendAntigravityTurn(input(events));
+ await waitFor(() => spawns.length === 1);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ onExit?.(1);
+ await expect(turn).rejects.toThrow("exited before completing");
+ });
+
+ it("handles clarifying questions and responds with user answer via stdin", async () => {
+ const events: HarnessEvent[] = [];
+ const turn = sendAntigravityTurn(input(events));
+ await waitFor(() => spawns.length === 1);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+
+ emit({
+ event: "step_update",
+ step_update: {
+ step_type: "tool",
+ tool_name: "ask_question",
+ step_index: 3,
+ state: "ACTIVE",
+ tool_info: {
+ parameters: {
+ questions: [
+ {
+ id: "q1",
+ question: "Do you want tests?",
+ options: ["Yes", "No"],
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ const asked = events.find((e) => e.type === "question.asked");
+ expect(asked).toBeDefined();
+
+ respondAntigravityQuestion("a1", 3, {
+ kind: "answered",
+ answers: { q1: ["Yes"] },
+ });
+
+ await waitFor(() => sent.length === 2);
+ expect(JSON.parse(sent[1])).toEqual({
+ event: "user",
+ message: {
+ content: "User response to clarifying questions:\n- Do you want tests?: Yes",
+ },
+ });
+
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "done with tests",
+ },
+ });
+ await turn;
+ });
+
+ it("handles user skipping clarifying question by sending skipped notification over stdin", async () => {
+ const events: HarnessEvent[] = [];
+ const turn = sendAntigravityTurn(input(events));
+ await waitFor(() => spawns.length === 1);
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+
+ emit({
+ event: "step_update",
+ step_update: {
+ step_type: "tool",
+ tool_name: "ask_question",
+ step_index: 4,
+ state: "ACTIVE",
+ tool_info: {
+ parameters: {
+ questions: [
+ {
+ id: "q1",
+ question: "Which database?",
+ options: ["PostgreSQL", "SQLite"],
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ const asked = events.find((e) => e.type === "question.asked");
+ expect(asked).toBeDefined();
+
+ respondAntigravityQuestion("a1", 4, {
+ kind: "skipped",
+ });
+
+ await waitFor(() => sent.length === 2);
+ expect(JSON.parse(sent[1])).toEqual({
+ event: "user",
+ message: {
+ content:
+ "The user skipped answering the clarifying question(s). Please proceed using your best judgment.",
+ },
+ });
+
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "decided autonomously",
+ },
+ });
+ await turn;
+ });
+
+ it("re-spawns with --mode plan when follow-up turn includes /plan", async () => {
+ const events: HarnessEvent[] = [];
+ const first = sendAntigravityTurn(input(events, "turn one"));
+ await waitFor(() => spawns.length === 1);
+ expect(spawns[0].args).not.toEqual(expect.arrayContaining(["--mode", "plan"]));
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "turn one done",
+ },
+ });
+ await first;
+
+ const second = sendAntigravityTurn(input(events, "turn two /plan"));
+ await waitFor(() => spawns.length === 2);
+ expect(spawns[1].args).toEqual(
+ expect.arrayContaining([
+ "--conversation",
+ "conversation-1",
+ "--mode",
+ "plan",
+ ]),
+ );
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 2);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "plan ready",
+ },
+ });
+ await second;
+ });
+
+ it("re-spawns with cumulative --add-dir arguments when follow-up turn introduces a new attachment directory", async () => {
+ const events: HarnessEvent[] = [];
+ const first = sendAntigravityTurn({
+ ...input(events, "first"),
+ attachments: [
+ {
+ id: "att1",
+ name: "one.png",
+ path: "/tmp/dir1/one.png",
+ kind: "image",
+ mimeType: "image/png",
+ size: 10,
+ },
+ ],
+ });
+ await waitFor(() => spawns.length === 1);
+ expect(spawns[0].args).toEqual(
+ expect.arrayContaining(["--add-dir", "/tmp/dir1"]),
+ );
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 1);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "got first",
+ },
+ });
+ await first;
+
+ // Follow-up turn with an attachment from a new directory
+ const second = sendAntigravityTurn({
+ ...input(events, "second"),
+ attachments: [
+ {
+ id: "att2",
+ name: "two.png",
+ path: "/tmp/dir2/two.png",
+ kind: "image",
+ mimeType: "image/png",
+ size: 10,
+ },
+ ],
+ });
+ await waitFor(() => spawns.length === 2);
+ expect(spawns[1].args).toEqual(
+ expect.arrayContaining([
+ "--conversation",
+ "conversation-1",
+ "--add-dir",
+ "/tmp/dir1",
+ "--add-dir",
+ "/tmp/dir2",
+ ]),
+ );
+ emit({ event: "init", conversation_id: "conversation-1", init: {} });
+ await waitFor(() => sent.length === 2);
+ emit({
+ event: "result",
+ result: {
+ conversation_id: "conversation-1",
+ status: "SUCCESS",
+ response: "got second",
+ },
+ });
+ await second;
+ });
+});
+
diff --git a/src/lib/harness/antigravityProtocol.test.ts b/src/lib/harness/antigravityProtocol.test.ts
new file mode 100644
index 00000000..937f4f45
--- /dev/null
+++ b/src/lib/harness/antigravityProtocol.test.ts
@@ -0,0 +1,541 @@
+import { describe, expect, it } from "vitest";
+import {
+ antigravityMode,
+ antigravityToolKind,
+ attachmentDirs,
+ buildAntigravitySpawnArgs,
+ buildAntigravityUserMessage,
+ effectiveAntigravitySettings,
+ mapAntigravityLine,
+ parseAntigravityModels,
+ resolveAntigravityModelWithEffort,
+ unifyAntigravityCatalogModels,
+} from "./antigravityProtocol";
+
+describe("Antigravity stream-json protocol", () => {
+ it("builds a documented long-lived stream invocation", () => {
+ const args = buildAntigravitySpawnArgs({
+ model: "antigravity:gemini-3.7-flash-high",
+ modelSettings: { effort: "high", agent: "reviewer" },
+ runtimeMode: "full-access",
+ resume: "conversation-1",
+ addDirs: ["/tmp/a", "/tmp/a", "/tmp/b"],
+ });
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--input-format",
+ "stream-json",
+ "--output-format",
+ "stream-json",
+ "--print-timeout",
+ "30m",
+ "--disable-slash-commands",
+ "--model",
+ "gemini-3.7-flash-high",
+ "--effort",
+ "high",
+ "--agent",
+ "reviewer",
+ "--conversation",
+ "conversation-1",
+ "--dangerously-skip-permissions",
+ ]),
+ );
+ expect(args.filter((value) => value === "--add-dir")).toHaveLength(2);
+ });
+
+ it("includes working directory in --add-dir arguments", () => {
+ const args = buildAntigravitySpawnArgs({
+ model: "antigravity:default",
+ runtimeMode: "supervised",
+ cwd: "/repo/workspace",
+ addDirs: ["/repo/workspace", "/other/dir"],
+ });
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--add-dir",
+ "/repo/workspace",
+ "--add-dir",
+ "/other/dir",
+ ]),
+ );
+ expect(args.filter((value) => value === "--add-dir")).toHaveLength(2);
+ });
+
+ it("maps only supported execution modes", () => {
+ expect(antigravityMode("supervised")).toBeUndefined();
+ expect(antigravityMode("auto")).toBeUndefined();
+ expect(antigravityMode("auto-accept-edits")).toBe("accept-edits");
+ });
+
+ it("sandboxes normal sessions and leaves full access explicit", () => {
+ expect(
+ buildAntigravitySpawnArgs({
+ model: "antigravity:default",
+ runtimeMode: "supervised",
+ }),
+ ).toEqual(expect.arrayContaining(["--sandbox"]));
+ expect(
+ buildAntigravitySpawnArgs({
+ model: "antigravity:default",
+ runtimeMode: "full-access",
+ }),
+ ).toEqual(expect.arrayContaining(["--dangerously-skip-permissions"]));
+ expect(
+ buildAntigravitySpawnArgs({
+ model: "antigravity:default",
+ runtimeMode: "full-access",
+ }),
+ ).not.toEqual(expect.arrayContaining(["--sandbox"]));
+ });
+
+ it("uses text-only @ path references for attachments and resolves relative paths", () => {
+ const attachments = [
+ {
+ id: "image1",
+ name: "diagram.png",
+ mimeType: "image/png" as const,
+ kind: "image" as const,
+ size: 5,
+ path: "/tmp/attachments/diagram.png",
+ data: "not-sent",
+ },
+ {
+ id: "image2",
+ name: "screenshot.jpg",
+ mimeType: "image/jpeg" as const,
+ kind: "image" as const,
+ size: 10,
+ path: "docs/screenshot.jpg",
+ },
+ ];
+ expect(
+ buildAntigravityUserMessage({
+ text: "inspect",
+ cwd: "/Users/dev/project",
+ attachments,
+ }),
+ ).toEqual({
+ event: "user",
+ message: {
+ content:
+ "Attachments to inspect:\n@[/tmp/attachments/diagram.png]\n@[/Users/dev/project/docs/screenshot.jpg]\n\ninspect",
+ },
+ });
+ expect(attachmentDirs(attachments)).toEqual([
+ "/tmp/attachments",
+ "docs",
+ ]);
+ });
+
+ it("parses init, deltas, tool outcomes and a terminal result with context usage", () => {
+ expect(
+ mapAntigravityLine({ event: "init", conversation_id: "c1", init: {} }),
+ ).toMatchObject({ initialized: "c1", providerSessionId: "c1" });
+ expect(
+ mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ conversation_id: "c1",
+ step_index: 1,
+ state: "ACTIVE",
+ step_type: "agent_response",
+ thinking_delta: "pondering...",
+ text_delta: "hello",
+ },
+ }).events,
+ ).toEqual([
+ { type: "reasoning.delta", text: "pondering..." },
+ { type: "message.delta", text: "hello" },
+ ]);
+ const tool = mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ conversation_id: "c1",
+ step_index: 3,
+ state: "DONE",
+ step_type: "tool",
+ tool_name: "run_command",
+ tool_info: {
+ parameters: { CommandLine: "git status" },
+ output: "clean",
+ },
+ },
+ }).events[0];
+ expect(tool).toMatchObject({
+ type: "tool.updated",
+ callId: "c1:3",
+ kind: "execute",
+ status: "completed",
+ title: "git status",
+ });
+
+ const editTool = mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ conversation_id: "c1",
+ step_index: 4,
+ state: "DONE",
+ step_type: "tool",
+ tool_name: "replace_file_content",
+ tool_info: {
+ parameters: {
+ TargetFile: "/repo/src/index.ts",
+ TargetContent: "const a = 1;",
+ ReplacementContent: "const a = 2;",
+ },
+ output: "ok",
+ },
+ },
+ }).events[0];
+ expect(editTool).toMatchObject({
+ type: "tool.updated",
+ callId: "c1:4",
+ kind: "edit",
+ status: "completed",
+ title: "Edit /repo/src/index.ts",
+ preview: {
+ kind: "write",
+ fileName: "index.ts",
+ path: "/repo/src/index.ts",
+ additions: 1,
+ deletions: 1,
+ },
+ });
+
+ const result = mapAntigravityLine(
+ {
+ event: "result",
+ result: {
+ conversation_id: "c1",
+ status: "SUCCESS",
+ response: "hello world",
+ usage: {
+ total_tokens: 1250,
+ input_tokens: 1000,
+ output_tokens: 250,
+ },
+ },
+ },
+ "hello",
+ );
+ expect(result.events).toEqual(
+ expect.arrayContaining([
+ { type: "message.delta", text: " world" },
+ { type: "message.completed" },
+ { type: "reasoning.completed" },
+ { type: "context", used: 1250, window: 1_000_000 },
+ ]),
+ );
+ expect(result.turnCompleted).toEqual({ ok: true, response: "hello world" });
+ });
+
+ it("retains errors and does not emit error text as message delta", () => {
+ const result = mapAntigravityLine({
+ event: "result",
+ result: {
+ status: "ERROR",
+ error: "timeout waiting for response",
+ response: "timeout waiting for response",
+ },
+ });
+ // Crucial: do not emit message.delta on error so error is not duplicated
+ expect(result.events.find((e) => e.type === "message.delta")).toBeUndefined();
+ expect(result.turnCompleted).toEqual({
+ ok: false,
+ error: "timeout waiting for response",
+ response: "timeout waiting for response",
+ });
+ expect(antigravityToolKind("write_to_file")).toBe("edit");
+ expect(antigravityToolKind("grep_search")).toBe("search");
+ expect(antigravityToolKind("invoke_subagent")).toBe("agent");
+ });
+
+ it("reads the structured model catalog even when multi-line output is returned", () => {
+ const multiLineOutput = `Fetching available models...\n${JSON.stringify({
+ command: {
+ data: {
+ models: [
+ {
+ id: "gemini-3.7-flash-high",
+ label: "Gemini 3.7 Flash (High)",
+ },
+ {
+ id: "claude-sonnet-4-6",
+ label: "Claude Sonnet 4.6 (Thinking)",
+ },
+ ],
+ },
+ },
+ })}`;
+ expect(parseAntigravityModels(multiLineOutput)).toEqual([
+ { id: "gemini-3.7-flash-high", label: "Gemini 3.7 Flash (High)" },
+ { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Thinking)" },
+ ]);
+ });
+
+ it("extracts thinking and content snapshots and uses toolAction for weak tool names", () => {
+ // 1. Thinking snapshot
+ const thoughtStep = mapAntigravityLine(
+ {
+ event: "step_update",
+ step_update: {
+ step_type: "thought",
+ thinking: "Initial investigation of the codebase...",
+ },
+ },
+ "",
+ undefined,
+ "",
+ );
+ expect(thoughtStep.events).toEqual([
+ {
+ type: "reasoning.delta",
+ text: "Initial investigation of the codebase...",
+ },
+ ]);
+
+ // 2. Content snapshot without prior delta
+ const contentStep = mapAntigravityLine(
+ {
+ event: "step_update",
+ step_update: {
+ step_type: "agent_response",
+ content: "I will now check the git status.",
+ },
+ },
+ "",
+ undefined,
+ "",
+ );
+ expect(contentStep.events).toEqual([
+ { type: "message.delta", text: "I will now check the git status." },
+ ]);
+
+ // 3. Tool step with toolAction replaces weak tool title
+ const toolStep = mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ step_type: "tool",
+ tool_name: "list_dir",
+ step_index: 2,
+ state: "ACTIVE",
+ tool_info: {
+ parameters: {
+ DirectoryPath: "/projects/my-app",
+ toolAction: "Listing project files",
+ toolSummary: "List files",
+ },
+ },
+ },
+ });
+ expect(toolStep.events).toEqual([
+ expect.objectContaining({
+ type: "tool.started",
+ title: "Listing project files",
+ kind: "read",
+ }),
+ ]);
+ });
+
+ it("resolves unified models with reasoning effort picker values", () => {
+ // Gemini 3.8 Flash
+ expect(resolveAntigravityModelWithEffort("antigravity:gemini-3.8-flash", "high")).toEqual({
+ model: "gemini-3.8-flash-high",
+ effort: "high",
+ });
+ expect(resolveAntigravityModelWithEffort("antigravity:gemini-3.8-flash", "medium")).toEqual({
+ model: "gemini-3.8-flash-medium",
+ effort: "medium",
+ });
+ expect(resolveAntigravityModelWithEffort("antigravity:gemini-3.8-flash", "low")).toEqual({
+ model: "gemini-3.8-flash-low",
+ effort: "low",
+ });
+
+ // Gemini 3.1 Pro
+ expect(resolveAntigravityModelWithEffort("antigravity:gemini-3.1-pro", "high")).toEqual({
+ model: "gemini-3.1-pro-high",
+ effort: "high",
+ });
+ expect(resolveAntigravityModelWithEffort("antigravity:gemini-3.1-pro", "low")).toEqual({
+ model: "gemini-3.1-pro-low",
+ effort: "low",
+ });
+
+ // Default
+ expect(resolveAntigravityModelWithEffort("antigravity:default")).toEqual({
+ model: "",
+ effort: undefined,
+ });
+ });
+
+ it("activates high effort when /boost is present in prompt", () => {
+ const args = buildAntigravitySpawnArgs({
+ model: "antigravity:gemini-3.8-flash",
+ runtimeMode: "supervised",
+ prompt: "Refactor database /boost",
+ });
+ expect(args).toEqual(
+ expect.arrayContaining(["--model", "gemini-3.8-flash-high", "--effort", "high"]),
+ );
+ });
+
+ it("activates --mode plan when /plan is present in prompt", () => {
+ const args = buildAntigravitySpawnArgs({
+ model: "antigravity:gemini-3.8-flash",
+ runtimeMode: "supervised",
+ prompt: "Architect the auth system /plan",
+ });
+ expect(args).toEqual(
+ expect.arrayContaining(["--mode", "plan"]),
+ );
+ });
+
+ it("extracts effective settings with prompt overrides for multi-turn validation", () => {
+ const normal = effectiveAntigravitySettings({
+ model: "antigravity:gemini-3.8-flash",
+ runtimeMode: "supervised",
+ prompt: "normal prompt",
+ });
+ expect(normal).toEqual({
+ model: "gemini-3.8-flash-high",
+ effort: "high",
+ agent: undefined,
+ mode: undefined,
+ });
+
+ const boostedAndPlanned = effectiveAntigravitySettings({
+ model: "antigravity:gemini-3.8-flash",
+ runtimeMode: "supervised",
+ prompt: "plan and boost /plan /boost",
+ });
+ expect(boostedAndPlanned).toEqual({
+ model: "gemini-3.8-flash-high",
+ effort: "high",
+ agent: undefined,
+ mode: "plan",
+ });
+ });
+
+ it("maps ask_question tool steps to question.asked and question.resolved", () => {
+ const asked = mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ step_type: "tool",
+ tool_name: "ask_question",
+ step_index: 5,
+ state: "ACTIVE",
+ tool_info: {
+ parameters: {
+ questions: [
+ {
+ question: "Which database engine would you prefer?",
+ options: ["PostgreSQL", "SQLite"],
+ is_multi_select: false,
+ },
+ ],
+ },
+ },
+ },
+ });
+ expect(asked.events).toEqual([
+ expect.objectContaining({
+ type: "question.asked",
+ requestId: 5,
+ questions: expect.arrayContaining([
+ expect.objectContaining({
+ prompt: "Which database engine would you prefer?",
+ options: expect.arrayContaining([
+ expect.objectContaining({ label: "PostgreSQL" }),
+ expect.objectContaining({ label: "SQLite" }),
+ ]),
+ }),
+ ]),
+ }),
+ expect.objectContaining({
+ type: "tool.started",
+ kind: "other",
+ }),
+ ]);
+
+ const resolved = mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ step_type: "tool",
+ tool_name: "ask_question",
+ step_index: 5,
+ state: "DONE",
+ tool_info: {
+ parameters: {
+ questions: [
+ {
+ question: "Which database engine would you prefer?",
+ options: ["PostgreSQL", "SQLite"],
+ },
+ ],
+ },
+ },
+ },
+ });
+ expect(resolved.events).toEqual([
+ expect.objectContaining({
+ type: "question.resolved",
+ requestId: 5,
+ decision: "answered",
+ }),
+ expect.objectContaining({
+ type: "tool.updated",
+ status: "completed",
+ }),
+ ]);
+ });
+
+ it("maps plan artifact generation to plan event", () => {
+ const planStep = mapAntigravityLine({
+ event: "step_update",
+ step_update: {
+ step_type: "tool",
+ tool_name: "write_to_file",
+ step_index: 2,
+ state: "ACTIVE",
+ tool_info: {
+ parameters: {
+ TargetFile: "/repo/plan.md",
+ CodeContent: "# Implementation Plan\n\n1. Setup\n2. Build",
+ },
+ },
+ },
+ });
+ expect(planStep.events).toEqual(
+ expect.arrayContaining([
+ {
+ type: "plan",
+ text: "# Implementation Plan\n\n1. Setup\n2. Build",
+ },
+ ]),
+ );
+ });
+
+ it("unifies catalog models by family and groups reasoning efforts", () => {
+ const raw = [
+ { id: "gemini-3.8-flash-high", label: "Gemini 3.8 Flash (High)" },
+ { id: "gemini-3.8-flash-medium", label: "Gemini 3.8 Flash (Medium)" },
+ { id: "gemini-3.8-flash-low", label: "Gemini 3.8 Flash (Low)" },
+ { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Thinking)" },
+ ];
+ const unified = unifyAntigravityCatalogModels(raw);
+ expect(unified).toHaveLength(2);
+ const flash = unified.find((m) => m.id === "antigravity:gemini-3.8-flash");
+ expect(flash).toBeDefined();
+ expect(flash?.name).toBe("Gemini 3.8 Flash");
+ expect(flash?.settings?.[0]?.options).toEqual([
+ { label: "High", value: "high" },
+ { label: "Medium", value: "medium" },
+ { label: "Low", value: "low" },
+ ]);
+ const claude = unified.find((m) => m.id === "antigravity:claude-sonnet-4-6");
+ expect(claude).toBeDefined();
+ expect(claude?.settings).toBeUndefined();
+ });
+});
diff --git a/src/lib/harness/antigravityProtocol.ts b/src/lib/harness/antigravityProtocol.ts
new file mode 100644
index 00000000..7b9889d3
--- /dev/null
+++ b/src/lib/harness/antigravityProtocol.ts
@@ -0,0 +1,672 @@
+import { nativeModelId } from "../models";
+import type { Attachment, RuntimeMode, ToolPreview } from "../session";
+import { extractToolPreview, titleFromToolInput } from "./preview";
+import { snapshotRemainder } from "./streamText";
+import type { HarnessEvent } from "./types";
+
+/** The documented streaming protocol is available in agy 1.1.15 and later. */
+export const ANTIGRAVITY_MINIMUM_VERSION = "1.1.24";
+
+// agy's five-minute print-mode default applies to each stream-json turn too.
+// Match the other long-running harnesses so active tool work is not cut short.
+const ANTIGRAVITY_TURN_TIMEOUT = "30m";
+
+type RecordValue = Record;
+
+export function asRecord(value: unknown): RecordValue | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as RecordValue)
+ : null;
+}
+
+export function stringField(
+ value: RecordValue | null | undefined,
+ key: string,
+): string | undefined {
+ const found = value?.[key];
+ return typeof found === "string" && found.trim() ? found : undefined;
+}
+
+export function parseAntigravityLine(line: string): RecordValue | null {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("{")) return null;
+ try {
+ return asRecord(JSON.parse(trimmed));
+ } catch {
+ return null;
+ }
+}
+
+export function antigravityMode(mode: RuntimeMode): string | undefined {
+ // Antigravity has no equivalent to MonoCode's reviewer-driven Auto mode.
+ // `accept-edits` is its documented non-interactive edit approval mode.
+ return mode === "auto-accept-edits" ? "accept-edits" : undefined;
+}
+
+import { questionsFromUnknown } from "../userQuestion";
+import type { AgentModel } from "../models";
+
+export function resolveAntigravityModelWithEffort(
+ rawModel: string,
+ effort?: string,
+): { model: string; effort?: string } {
+ const model = nativeModelId(rawModel).trim();
+ if (!model || model === "default") {
+ return {
+ model: "",
+ effort: effort && ["low", "medium", "high"].includes(effort) ? effort : undefined,
+ };
+ }
+
+ const selectedEffort = effort?.trim().toLowerCase();
+ const eff =
+ selectedEffort && ["low", "medium", "high"].includes(selectedEffort)
+ ? selectedEffort
+ : undefined;
+
+ // Check if model already has a reasoning suffix (e.g. gemini-3.8-flash-high)
+ const match = model.match(/^(gemini-[\d.]+(?:-(?:flash|pro)))-(high|medium|low)$/);
+ if (match) {
+ const base = match[1];
+ const finalEffort = eff ?? match[2];
+ return { model: `${base}-${finalEffort}`, effort: finalEffort };
+ }
+
+ if (model.startsWith("gemini-")) {
+ const finalEff = eff ?? "high";
+ if (model === "gemini-3.1-pro") {
+ const proEff = finalEff === "medium" ? "high" : finalEff;
+ return { model: `gemini-3.1-pro-${proEff}`, effort: proEff };
+ }
+ return { model: `${model}-${finalEff}`, effort: finalEff };
+ }
+
+ return {
+ model,
+ effort: eff,
+ };
+}
+
+export function effectiveAntigravitySettings(input: {
+ model: string;
+ modelSettings?: Record;
+ runtimeMode: RuntimeMode;
+ prompt?: string;
+}): {
+ model: string;
+ effort?: string;
+ agent?: string;
+ mode?: string;
+} {
+ const isBoosted = input.prompt?.includes("/boost");
+ const requestedEffort =
+ input.modelSettings?.effort?.trim() || (isBoosted ? "high" : undefined);
+ const resolved = resolveAntigravityModelWithEffort(input.model, requestedEffort);
+ const agent = input.modelSettings?.agent?.trim() || undefined;
+ const isPlanning =
+ input.prompt?.includes("/plan") ||
+ input.modelSettings?.mode === "plan" ||
+ (input.runtimeMode as string) === "plan";
+ const mode = isPlanning ? "plan" : antigravityMode(input.runtimeMode);
+
+ return {
+ model: resolved.model,
+ effort: resolved.effort,
+ agent,
+ mode,
+ };
+}
+
+export function buildAntigravitySpawnArgs(input: {
+ model: string;
+ modelSettings?: Record;
+ runtimeMode: RuntimeMode;
+ resume?: string;
+ cwd?: string;
+ addDirs?: string[];
+ prompt?: string;
+}): string[] {
+ const args = [
+ "--input-format",
+ "stream-json",
+ "--output-format",
+ "stream-json",
+ "--print-timeout",
+ ANTIGRAVITY_TURN_TIMEOUT,
+ "--disable-slash-commands",
+ ];
+ const effective = effectiveAntigravitySettings({
+ model: input.model,
+ modelSettings: input.modelSettings,
+ runtimeMode: input.runtimeMode,
+ prompt: input.prompt,
+ });
+
+ if (effective.model) args.push("--model", effective.model);
+ if (effective.effort) args.push("--effort", effective.effort);
+ if (effective.agent) args.push("--agent", effective.agent);
+ if (effective.mode) args.push("--mode", effective.mode);
+
+ if (input.runtimeMode === "full-access") {
+ args.push("--dangerously-skip-permissions");
+ } else {
+ // Keep the CLI's native OS containment enabled for normal sessions. The
+ // working directory identifies the workspace; sandboxing makes that
+ // boundary meaningful for agent-launched processes as well.
+ args.push("--sandbox");
+ }
+ if (input.resume?.trim()) args.push("--conversation", input.resume.trim());
+ const allDirs = [
+ ...(input.cwd?.trim() ? [input.cwd.trim()] : []),
+ ...(input.addDirs ?? []),
+ ];
+ for (const dir of uniqueDirs(allDirs))
+ args.push("--add-dir", dir);
+ return args;
+}
+
+function uniqueDirs(dirs: string[]): string[] {
+ return [
+ ...new Set(
+ dirs
+ .map((dir) => dir.trim().replace(/[/\\]+$/, ""))
+ .filter(Boolean),
+ ),
+ ];
+}
+
+function isAbsolutePath(path: string): boolean {
+ return (
+ path.startsWith("/") ||
+ /^[A-Za-z]:[/\\]/.test(path) ||
+ path.startsWith("\\\\")
+ );
+}
+
+function joinPath(base: string, rel: string): string {
+ const cleanBase = base.replace(/[/\\]+$/, "");
+ const cleanRel = rel.replace(/^[/\\]+/, "");
+ return `${cleanBase}/${cleanRel}`;
+}
+
+/** `stream-json` accepts only text blocks. Files are made visible through @refs. */
+export function buildAntigravityUserMessage(input: {
+ text: string;
+ cwd?: string;
+ attachments?: Attachment[];
+}): RecordValue | null {
+ const references = (input.attachments ?? []).flatMap((attachment) => {
+ let path = attachment.path?.trim();
+ if (path && input.cwd && !isAbsolutePath(path)) {
+ path = joinPath(input.cwd, path);
+ }
+ return path ? [`@[${path}]`] : [];
+ });
+ const text = [
+ references.length
+ ? "Attachments to inspect:\n" + references.join("\n")
+ : "",
+ input.text.trim(),
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+ if (!text) return null;
+ return { event: "user", message: { content: text } };
+}
+
+export function attachmentDirs(
+ attachments: Attachment[] | undefined,
+): string[] {
+ return [
+ ...new Set(
+ (attachments ?? [])
+ .flatMap((attachment) => {
+ const path = attachment.path?.trim();
+ return path ? [parentDir(path)] : [];
+ })
+ .filter(Boolean),
+ ),
+ ];
+}
+
+function parentDir(path: string): string {
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
+ return slash > 0 ? path.slice(0, slash) : "";
+}
+
+export type AntigravityMappedLine = {
+ initialized?: string;
+ providerSessionId?: string;
+ events: HarnessEvent[];
+ turnCompleted?: { ok: boolean; error?: string; response?: string };
+};
+
+export function antigravityContextWindow(modelId?: string): number {
+ const lower = modelId?.toLowerCase() ?? "";
+ if (
+ lower.includes("claude") ||
+ lower.includes("sonnet") ||
+ lower.includes("opus")
+ ) {
+ return 200_000;
+ }
+ if (lower.includes("gpt") || lower.includes("oss")) {
+ return 128_000;
+ }
+ return 1_000_000;
+}
+
+/** Translate a documented NDJSON event into MonoCode's provider-neutral events. */
+export function mapAntigravityLine(
+ line: RecordValue,
+ emittedAssistant = "",
+ contextWindow?: number,
+ emittedThinking = "",
+): AntigravityMappedLine {
+ const event = stringField(line, "event");
+ if (event === "init") {
+ const conversationId = stringField(line, "conversation_id");
+ return {
+ initialized: conversationId ?? "",
+ providerSessionId: conversationId,
+ events: [],
+ };
+ }
+ if (event === "step_update") {
+ return mapStepUpdate(
+ asRecord(line.step_update),
+ emittedAssistant,
+ emittedThinking,
+ );
+ }
+ if (event === "result") {
+ const result = asRecord(line.result);
+ const status = stringField(result, "status")?.toUpperCase();
+ const response = stringField(result, "response") ?? "";
+ const error = stringField(result, "error");
+ const ok = status === "SUCCESS";
+ const events: HarnessEvent[] = [];
+ // Only emit text delta for successful turns; error details are delivered via turnCompleted/session.error
+ // to avoid duplicating the error message in the chat stream.
+ if (ok) {
+ const remainder = snapshotRemainder(emittedAssistant, response);
+ if (remainder) events.push({ type: "message.delta", text: remainder });
+ }
+ events.push({ type: "message.completed" }, { type: "reasoning.completed" });
+ const usage = asRecord(result?.usage);
+ const used =
+ typeof usage?.total_tokens === "number" && usage.total_tokens > 0
+ ? usage.total_tokens
+ : typeof usage?.input_tokens === "number" &&
+ typeof usage?.output_tokens === "number"
+ ? usage.input_tokens + usage.output_tokens
+ : undefined;
+ if (used && used > 0) {
+ const window =
+ contextWindow && contextWindow > 0
+ ? contextWindow
+ : antigravityContextWindow();
+ events.push({ type: "context", used, window });
+ }
+ const finalError = !ok
+ ? error || (response.trim() ? response.trim() : undefined)
+ : undefined;
+ return {
+ providerSessionId: stringField(result, "conversation_id"),
+ events,
+ turnCompleted: {
+ ok,
+ ...(finalError ? { error: finalError } : {}),
+ response,
+ },
+ };
+ }
+ return { events: [] };
+}
+
+function mapStepUpdate(
+ step: RecordValue | null,
+ emittedAssistant = "",
+ emittedThinking = "",
+): AntigravityMappedLine {
+ if (!step) return { events: [] };
+ const type = stringField(step, "step_type")?.toLowerCase();
+ const events: HarnessEvent[] = [];
+
+ // 1. Thinking / reasoning from agent_response, thought, thinking, or any step carrying thoughts
+ const rawThinkingDelta =
+ stringField(step, "thinking_delta") ??
+ stringField(step, "thought_delta") ??
+ stringField(step, "delta_thinking") ??
+ stringField(step, "delta_raw_thinking") ??
+ stringField(step, "reasoning_delta");
+ const rawThinkingSnapshot =
+ stringField(step, "thinking") ??
+ stringField(step, "thought") ??
+ stringField(step, "raw_thinking") ??
+ stringField(step, "raw_thought") ??
+ stringField(step, "model_thinking") ??
+ stringField(step, "reasoning");
+ const thinkingText =
+ rawThinkingDelta ??
+ (rawThinkingSnapshot
+ ? snapshotRemainder(emittedThinking, rawThinkingSnapshot)
+ : undefined);
+ if (thinkingText) {
+ events.push({ type: "reasoning.delta", text: thinkingText });
+ }
+
+ // 2. Prose / message text from agent_response, or any step carrying text
+ const rawTextDelta =
+ stringField(step, "text_delta") ??
+ stringField(step, "delta_text") ??
+ stringField(step, "delta");
+ const rawTextSnapshot =
+ stringField(step, "content") ??
+ stringField(step, "text") ??
+ stringField(step, "message_text") ??
+ stringField(step, "message_content") ??
+ stringField(step, "display_text");
+ const messageText =
+ rawTextDelta ??
+ (rawTextSnapshot
+ ? snapshotRemainder(emittedAssistant, rawTextSnapshot)
+ : undefined);
+ if (messageText) {
+ events.push({ type: "message.delta", text: messageText });
+ }
+
+ // If this was not a tool step, return the thinking/message events
+ if (type !== "tool") {
+ return { events };
+ }
+
+ // 3. Tool step
+ const name =
+ stringField(step, "tool_name") ??
+ stringField(asRecord(step.tool_info), "name") ??
+ "Tool";
+ const index = typeof step.step_index === "number" ? step.step_index : 0;
+ const conversationId = stringField(step, "conversation_id") ?? "session";
+ const callId = `${conversationId}:${index}`;
+ const toolInfo = asRecord(step.tool_info) ?? {};
+ const parameters = asRecord(toolInfo.parameters) ?? {};
+
+ const toolAction =
+ stringField(parameters, "toolAction") ??
+ stringField(parameters, "tool_action") ??
+ stringField(toolInfo, "tool_action") ??
+ stringField(toolInfo, "toolAction") ??
+ stringField(parameters, "toolSummary") ??
+ stringField(parameters, "tool_summary") ??
+ stringField(toolInfo, "tool_summary") ??
+ stringField(toolInfo, "toolSummary") ??
+ stringField(parameters, "Description") ??
+ stringField(parameters, "description");
+
+ const title =
+ toolAction || titleFromToolInput(name, antigravityToolKind(name), parameters);
+ const preview = antigravityToolPreview(name, parameters, toolInfo);
+ const state = stringField(step, "state")?.toUpperCase();
+ const toolError = toolErrorMessage(toolInfo);
+ const detail = toolError || toolAction || undefined;
+
+ if (name.toLowerCase() === "ask_question") {
+ const questions = questionsFromUnknown(parameters);
+ if (questions.length > 0) {
+ if (state === "ACTIVE") {
+ events.push({
+ type: "question.asked",
+ requestId: index,
+ title: title || "Clarifying question",
+ questions,
+ callId,
+ });
+ } else {
+ events.push({
+ type: "question.resolved",
+ requestId: index,
+ decision: "answered",
+ });
+ }
+ }
+ }
+
+ const targetFile =
+ stringField(parameters, "TargetFile") ??
+ stringField(parameters, "targetFile") ??
+ "";
+ const codeContent =
+ stringField(parameters, "CodeContent") ??
+ stringField(parameters, "codeContent");
+ const metadata = asRecord(parameters.ArtifactMetadata);
+ if (
+ (targetFile.toLowerCase().includes("plan") || metadata?.RequestFeedback === true) &&
+ codeContent
+ ) {
+ events.push({ type: "plan", text: codeContent });
+ }
+
+ const stepPlan = stringField(step, "plan");
+ if (stepPlan) {
+ events.push({ type: "plan", text: stepPlan });
+ }
+
+ if (state === "ACTIVE") {
+ events.push({
+ type: "tool.started",
+ callId,
+ title,
+ kind: antigravityToolKind(name),
+ status: "in_progress",
+ preview,
+ });
+ return { events };
+ }
+
+ events.push({
+ type: "tool.updated",
+ callId,
+ title,
+ kind: antigravityToolKind(name),
+ status: toolError ? "failed" : "completed",
+ ...(detail ? { detail } : {}),
+ preview,
+ });
+ return { events };
+}
+
+export function antigravityToolKind(name: string): string {
+ const key = name.toLowerCase();
+ if (key === "run_command" || key.includes("command")) return "execute";
+ if (key.includes("write") || key.includes("replace") || key.includes("edit"))
+ return "edit";
+ if (key.includes("grep") || key.includes("search") || key.includes("find"))
+ return "search";
+ if (
+ key.includes("view") ||
+ key.includes("read") ||
+ key.includes("list_dir") ||
+ key.includes("sed_file")
+ )
+ return "read";
+ if (key.includes("url") || key.includes("browser") || key.includes("web"))
+ return "fetch";
+ if (key.includes("subagent") || key.includes("agent")) return "agent";
+ return "other";
+}
+
+function antigravityToolPreview(
+ name: string,
+ parameters: RecordValue,
+ info: RecordValue,
+): ToolPreview | undefined {
+ const content = buildAntigravityToolContent(name, parameters);
+ return extractToolPreview(
+ {
+ name,
+ title: name,
+ kind: antigravityToolKind(name),
+ input: parameters,
+ rawInput: parameters,
+ ...(content ? { content } : {}),
+ },
+ {
+ name,
+ title: name,
+ kind: antigravityToolKind(name),
+ input: parameters,
+ rawInput: parameters,
+ output: info.output,
+ ...(content ? { content } : {}),
+ },
+ );
+}
+
+function buildAntigravityToolContent(
+ name: string,
+ parameters: RecordValue,
+): RecordValue[] | undefined {
+ const key = name.toLowerCase();
+ if (key === "replace_file_content") {
+ const targetFile =
+ stringField(parameters, "TargetFile") ??
+ stringField(parameters, "targetFile") ??
+ stringField(parameters, "target_file");
+ const oldText =
+ stringField(parameters, "TargetContent") ??
+ stringField(parameters, "targetContent");
+ const newText =
+ stringField(parameters, "ReplacementContent") ??
+ stringField(parameters, "replacementContent");
+ if (targetFile && (oldText != null || newText != null)) {
+ return [
+ {
+ type: "diff",
+ path: targetFile,
+ oldText: oldText ?? "",
+ newText: newText ?? "",
+ },
+ ];
+ }
+ }
+ if (key === "write_to_file") {
+ const targetFile =
+ stringField(parameters, "TargetFile") ??
+ stringField(parameters, "targetFile");
+ const newText =
+ stringField(parameters, "CodeContent") ??
+ stringField(parameters, "codeContent");
+ if (targetFile && newText != null) {
+ return [
+ {
+ type: "diff",
+ path: targetFile,
+ oldText: "",
+ newText,
+ },
+ ];
+ }
+ }
+ return undefined;
+}
+
+function toolErrorMessage(info: RecordValue): string | undefined {
+ const error = info.error;
+ if (typeof error === "string" && error.trim()) return error;
+ const rec = asRecord(error);
+ return stringField(rec, "message") ?? stringField(rec, "type");
+}
+
+export function parseAntigravityModels(
+ output: string,
+): Array<{ id: string; label: string }> {
+ for (const line of output.split(/\r?\n/)) {
+ const rec = parseAntigravityLine(line);
+ const command = asRecord(rec?.command);
+ const data = asRecord(command?.data);
+ const models = Array.isArray(data?.models) ? data.models : [];
+ if (models.length) {
+ return models.flatMap((model) => {
+ const value = asRecord(model);
+ const id = stringField(value, "id");
+ const label = stringField(value, "label");
+ return id ? [{ id, label: label ?? id }] : [];
+ });
+ }
+ }
+ return [];
+}
+
+export function unifyAntigravityCatalogModels(
+ rawModels: Array<{ id: string; label: string }>,
+): AgentModel[] {
+ const groups = new Map<
+ string,
+ { label: string; efforts: Set; baseId: string }
+ >();
+ const standalone: AgentModel[] = [];
+
+ for (const { id, label } of rawModels) {
+ const match = id.match(
+ /^(gemini-[\d.]+(?:-(?:flash|pro)))-(high|medium|low)$/,
+ );
+ if (match) {
+ const baseId = match[1];
+ const effort = match[2];
+ const cleanLabel = label
+ .replace(/\s*\((?:High|Medium|Low)\)\s*$/i, "")
+ .trim();
+ const existing = groups.get(baseId);
+ if (existing) {
+ existing.efforts.add(effort);
+ } else {
+ groups.set(baseId, {
+ label: cleanLabel || baseId,
+ efforts: new Set([effort]),
+ baseId,
+ });
+ }
+ } else {
+ standalone.push({
+ id: `antigravity:${id}`,
+ harness: "antigravity",
+ name: label,
+ nativeId: id,
+ contextWindow: antigravityContextWindow(id),
+ });
+ }
+ }
+
+ const unified: AgentModel[] = [];
+ for (const [baseId, { label, efforts }] of groups.entries()) {
+ const effortOrder = ["high", "medium", "low"].filter((eff) =>
+ efforts.has(eff),
+ );
+ const effortOptions = (
+ effortOrder.length ? effortOrder : ["high", "medium", "low"]
+ ).map((eff) => ({
+ value: eff,
+ label: eff.charAt(0).toUpperCase() + eff.slice(1),
+ }));
+
+ unified.push({
+ id: `antigravity:${baseId}`,
+ harness: "antigravity",
+ name: label,
+ nativeId: baseId,
+ contextWindow: antigravityContextWindow(baseId),
+ settings: [
+ {
+ id: "effort",
+ label: "Reasoning",
+ kind: "select",
+ value: "high",
+ options: effortOptions,
+ },
+ ],
+ });
+ }
+
+ return [...unified, ...standalone];
+}
diff --git a/src/lib/harness/antigravityText.ts b/src/lib/harness/antigravityText.ts
new file mode 100644
index 00000000..b5d5741e
--- /dev/null
+++ b/src/lib/harness/antigravityText.ts
@@ -0,0 +1,245 @@
+import { modelsFor } from "../models";
+import {
+ killChild,
+ resolveAntigravityBinary,
+ spawnChild,
+ unwatchChild,
+ watchChild,
+ writeChild,
+} from "./child";
+import {
+ buildAntigravitySpawnArgs,
+ buildAntigravityUserMessage,
+ parseAntigravityLine,
+ stringField,
+ asRecord,
+} from "./antigravityProtocol";
+import { mergeStream } from "./streamText";
+
+const TEXT_CHILD_ID = "monocode-antigravity-text";
+const INIT_TIMEOUT_MS = 90_000;
+const REQUEST_TIMEOUT_MS = 120_000;
+const TEXT_MODEL = "gemini-3.8-flash-high";
+
+type LiveText = {
+ cwd: string;
+ collecting: boolean;
+ output: string;
+ closed: boolean;
+ ready: boolean;
+ turnDone: (() => void) | null;
+ turnFailed: ((error: Error) => void) | null;
+ readyDone: (() => void) | null;
+};
+
+let live: LiveText | null = null;
+let turns: Promise = Promise.resolve();
+
+function pickTextModel(): string {
+ const models = modelsFor("antigravity");
+ const flash = models.find((model) =>
+ /flash/i.test(`${model.nativeId ?? ""} ${model.name} ${model.id}`),
+ );
+ return flash?.nativeId ?? TEXT_MODEL;
+}
+
+export async function stopAntigravityTextPrompt(): Promise {
+ await dropLive();
+}
+
+export function warmupAntigravityText(cwd: string): Promise {
+ if (!cwd || cwd === "~") return Promise.resolve();
+ const run = turns.catch(() => undefined).then(async () => {
+ await ensureLive(cwd);
+ });
+ turns = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run.catch(() => undefined);
+}
+
+export async function runAntigravityTextPrompt(input: {
+ cwd: string;
+ prompt: string;
+ timeoutMs?: number;
+}): Promise {
+ const run = turns.catch(() => undefined).then(() => promptOnLive(input));
+ turns = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+}
+
+async function promptOnLive(input: {
+ cwd: string;
+ prompt: string;
+ timeoutMs?: number;
+}): Promise {
+ const session = await ensureLive(input.cwd);
+ session.output = "";
+ session.collecting = true;
+ const timeoutMs = input.timeoutMs ?? REQUEST_TIMEOUT_MS;
+
+ try {
+ const turnPromise = new Promise((resolve, reject) => {
+ session.turnDone = resolve;
+ session.turnFailed = reject;
+ });
+
+ const message = buildAntigravityUserMessage({ text: input.prompt });
+ if (!message) throw new Error("Empty prompt for Antigravity text generator");
+
+ await writeChild(TEXT_CHILD_ID, JSON.stringify(message));
+
+ await Promise.race([
+ turnPromise,
+ new Promise((_, reject) => {
+ setTimeout(
+ () => reject(new Error("Antigravity text generation timed out")),
+ timeoutMs,
+ );
+ }),
+ ]);
+
+ const output = session.output.trim();
+ if (!output) throw new Error("Antigravity returned empty output.");
+ return output;
+ } catch (error) {
+ if (session.closed) await dropLive();
+ throw error;
+ } finally {
+ session.collecting = false;
+ session.turnDone = null;
+ session.turnFailed = null;
+ await dropLive();
+ }
+}
+
+async function ensureLive(cwd: string): Promise {
+ if (live && !live.closed && live.cwd === cwd) return live;
+ await dropLive();
+ return startLive(cwd);
+}
+
+async function startLive(cwd: string): Promise {
+ const { path } = await resolveAntigravityBinary();
+ const session: LiveText = {
+ cwd,
+ collecting: false,
+ output: "",
+ closed: false,
+ ready: false,
+ turnDone: null,
+ turnFailed: null,
+ readyDone: null,
+ };
+
+ watchChild(
+ TEXT_CHILD_ID,
+ (line) => handleLine(session, line),
+ () => {
+ session.closed = true;
+ if (live === session) live = null;
+ session.turnFailed?.(new Error("Antigravity text generator exited"));
+ session.readyDone?.();
+ session.turnDone = null;
+ session.turnFailed = null;
+ session.readyDone = null;
+ },
+ );
+
+ try {
+ await spawnChild(
+ TEXT_CHILD_ID,
+ path,
+ buildAntigravitySpawnArgs({
+ model: pickTextModel(),
+ runtimeMode: "supervised",
+ cwd,
+ }),
+ cwd,
+ );
+ live = session;
+ await waitForReady(session, INIT_TIMEOUT_MS);
+ return session;
+ } catch (error) {
+ session.closed = true;
+ unwatchChild(TEXT_CHILD_ID);
+ await killChild(TEXT_CHILD_ID).catch(() => undefined);
+ throw error;
+ }
+}
+
+async function dropLive(): Promise {
+ const current = live;
+ live = null;
+ if (current) {
+ current.closed = true;
+ current.readyDone?.();
+ current.turnFailed?.(new Error("Antigravity text generator stopped"));
+ current.turnDone = null;
+ current.turnFailed = null;
+ current.readyDone = null;
+ }
+ unwatchChild(TEXT_CHILD_ID);
+ await killChild(TEXT_CHILD_ID).catch(() => undefined);
+}
+
+function handleLine(session: LiveText, line: string): void {
+ const rec = parseAntigravityLine(line);
+ if (!rec) return;
+ const event = stringField(rec, "event");
+ if (event === "init") {
+ session.ready = true;
+ session.readyDone?.();
+ session.readyDone = null;
+ }
+ if (!session.collecting) return;
+ if (event === "step_update") {
+ const step = asRecord(rec.step_update);
+ const type = stringField(step, "step_type");
+ if (type === "agent_response") {
+ const delta = stringField(step, "text_delta");
+ if (delta) session.output = mergeStream(session.output, delta);
+ }
+ return;
+ }
+ if (event === "result") {
+ const result = asRecord(rec.result);
+ const status = stringField(result, "status")?.toUpperCase();
+ const response = stringField(result, "response");
+ const error = stringField(result, "error");
+ if (response && !session.output.trim()) {
+ session.output = response;
+ }
+ if (status === "SUCCESS") {
+ session.turnDone?.();
+ } else {
+ session.turnFailed?.(
+ new Error(error ?? "Antigravity text generation turn failed"),
+ );
+ }
+ session.turnDone = null;
+ session.turnFailed = null;
+ }
+}
+
+function waitForReady(session: LiveText, timeoutMs: number): Promise {
+ if (session.ready) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ session.readyDone = null;
+ resolve();
+ }, timeoutMs);
+ session.readyDone = () => {
+ clearTimeout(timer);
+ if (session.closed) {
+ reject(new Error("Antigravity text generator exited"));
+ return;
+ }
+ resolve();
+ };
+ });
+}
diff --git a/src/lib/harness/antigravityTitle.ts b/src/lib/harness/antigravityTitle.ts
new file mode 100644
index 00000000..8be5bcf8
--- /dev/null
+++ b/src/lib/harness/antigravityTitle.ts
@@ -0,0 +1,25 @@
+import {
+ buildThreadTitlePrompt,
+ parseGeneratedThreadTitle,
+} from "../sessionTitle";
+import { runAntigravityTextPrompt } from "./antigravityText";
+
+const TITLE_TIMEOUT_MS = 60_000;
+
+export async function generateAntigravitySessionTitle(input: {
+ sessionId: string;
+ cwd: string;
+ message: string;
+}): Promise {
+ try {
+ const output = await runAntigravityTextPrompt({
+ cwd: input.cwd,
+ prompt: buildThreadTitlePrompt(input.message),
+ timeoutMs: TITLE_TIMEOUT_MS,
+ });
+ return parseGeneratedThreadTitle(output);
+ } catch (error) {
+ console.debug("[monocode] session title", error);
+ return null;
+ }
+}
diff --git a/src/lib/harness/apply.test.ts b/src/lib/harness/apply.test.ts
index 9f523f49..a7d849f2 100644
--- a/src/lib/harness/apply.test.ts
+++ b/src/lib/harness/apply.test.ts
@@ -151,6 +151,27 @@ describe("status blocks", () => {
});
});
+describe("error blocks", () => {
+ it("does not append the same turn error twice", () => {
+ let session = appendUser(newSession("antigravity", "/tmp"), "go");
+ session = applyHarnessEvent(session, {
+ type: "session.error",
+ message: "timeout waiting for response",
+ });
+ session = applyHarnessEvent(session, {
+ type: "session.error",
+ message: "timeout waiting for response",
+ });
+
+ const errors = session.blocks.filter(
+ (block) =>
+ block.role === "system" &&
+ block.text === "timeout waiting for response",
+ );
+ expect(errors).toHaveLength(1);
+ });
+});
+
describe("applyHarnessEvent context", () => {
it("tracks the newest level instead of summing turns", () => {
let session = newSession("claude", "/repo");
diff --git a/src/lib/harness/apply.ts b/src/lib/harness/apply.ts
index 655d89ad..e438e6a9 100644
--- a/src/lib/harness/apply.ts
+++ b/src/lib/harness/apply.ts
@@ -84,11 +84,7 @@ export function applyHarnessEvent(
text: event.text,
});
case "session.error":
- return appendBlock(stopStreaming(session), {
- id: crypto.randomUUID(),
- role: "system",
- text: event.message,
- });
+ return appendError(stopStreaming(session), event.message);
case "session.providerBound":
return { ...session, providerSessionId: event.providerSessionId };
case "status":
@@ -199,6 +195,17 @@ function appendStatus(session: Session, text: string): Session {
});
}
+/** Both an adapter and its caller may report the same rejected turn. */
+function appendError(session: Session, text: string): Session {
+ const last = session.blocks[session.blocks.length - 1];
+ if (last?.role === "system" && last.text === text) return session;
+ return appendBlock(session, {
+ id: crypto.randomUUID(),
+ role: "system",
+ text,
+ });
+}
+
function appendBlock(session: Session, block: Block): Session {
return { ...session, blocks: [...sealLastStream(session.blocks), block] };
}
diff --git a/src/lib/harness/availability.ts b/src/lib/harness/availability.ts
index f7d5df20..ada438a4 100644
--- a/src/lib/harness/availability.ts
+++ b/src/lib/harness/availability.ts
@@ -2,6 +2,7 @@ import type { HarnessId } from "../session";
import { HARNESSES } from "../session";
import {
resolveClaudeBinary,
+ resolveAntigravityBinary,
resolveCodexBinary,
resolveCursorBinary,
resolveFxBinary,
@@ -26,6 +27,10 @@ const CLI: Record = {
name: "Grok Build CLI",
install: "curl -fsSL https://x.ai/cli/install.sh | bash",
},
+ antigravity: {
+ name: "Antigravity CLI",
+ install: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
+ },
opencode: { name: "OpenCode CLI" },
pi: { name: "Pi CLI", install: "npm i -g @earendil-works/pi-coding-agent" },
omp: { name: "omp CLI", install: "curl -fsSL https://omp.sh/install | sh" },
@@ -37,6 +42,7 @@ let availability: HarnessAvailability = {
codex: false,
cursor: false,
grok: false,
+ antigravity: false,
opencode: false,
pi: false,
omp: false,
@@ -60,7 +66,9 @@ function emit() {
for (const listener of listeners) listener();
}
-export function subscribeHarnessAvailability(onStoreChange: () => void): () => void {
+export function subscribeHarnessAvailability(
+ onStoreChange: () => void,
+): () => void {
listeners.add(onStoreChange);
return () => {
listeners.delete(onStoreChange);
@@ -85,9 +93,9 @@ export function harnessUnavailableHint(id: HarnessId): string {
return `${name} not found${how}. Install it, or restart MonoCode if it is already installed.`;
}
-export function probeHarnessAvailability(
- options?: { force?: boolean },
-): Promise {
+export function probeHarnessAvailability(options?: {
+ force?: boolean;
+}): Promise {
if (inflight) return inflight;
if (!options?.force && probedAt > 0 && Date.now() - probedAt < PROBE_TTL_MS) {
return Promise.resolve();
@@ -159,6 +167,14 @@ export function probeHarnessAvailability(
return [id, false] as const;
}
}
+ if (id === "antigravity") {
+ try {
+ await resolveAntigravityBinary();
+ return [id, true] as const;
+ } catch {
+ return [id, false] as const;
+ }
+ }
return [id, false] as const;
}),
)
diff --git a/src/lib/harness/child.ts b/src/lib/harness/child.ts
index 240a5525..34f0c36e 100644
--- a/src/lib/harness/child.ts
+++ b/src/lib/harness/child.ts
@@ -147,9 +147,7 @@ function teardownBridge() {
sseBuffer.clear();
livePid.clear();
pendingExit.clear();
- void pending
- ?.then((fns) => fns.forEach((fn) => fn()))
- .catch(() => undefined);
+ void pending?.then((fns) => fns.forEach((fn) => fn())).catch(() => undefined);
}
export function startHarnessBridge(): () => void {
@@ -313,6 +311,10 @@ export function resolveGrokBinary(): Promise<{ path: string }> {
return invoke("harness_resolve_grok");
}
+export function resolveAntigravityBinary(): Promise<{ path: string }> {
+ return invoke("harness_resolve_antigravity");
+}
+
export function freeHarnessPort(): Promise {
return invoke("harness_free_port");
}
diff --git a/src/lib/harness/index.ts b/src/lib/harness/index.ts
index a53b6f05..b62f1e0f 100644
--- a/src/lib/harness/index.ts
+++ b/src/lib/harness/index.ts
@@ -1,5 +1,18 @@
export { startHarnessBridge, killAllChildren } from "./child";
-export { applyHarnessEvent, appendUser, appendSteerUser, stopStreaming } from "./apply";
+export {
+ sendAntigravityTurn,
+ cancelAntigravityTurn,
+ respondAntigravityApproval,
+ stopAntigravitySession,
+ forgetAntigravitySession,
+ bindAntigravitySession,
+} from "./antigravity";
+export {
+ applyHarnessEvent,
+ appendUser,
+ appendSteerUser,
+ stopStreaming,
+} from "./apply";
export {
sendCursorTurn,
cancelCursorTurn,
@@ -64,39 +77,42 @@ export {
forgetGrokSession,
bindGrokSession,
} from "./grok";
+export { generateAntigravitySessionTitle } from "./antigravityTitle";
export { generateCursorSessionTitle } from "./cursorTitle";
export { generateCodexSessionTitle } from "./codexTitle";
export { generateOpenCodeSessionTitle } from "./opencodeTitle";
export { generateClaudeSessionTitle } from "./claudeTitle";
export { generatePiSessionTitle, generateOmpSessionTitle } from "./piTitle";
export { generateGrokSessionTitle } from "./grokTitle";
+export {
+ generateAntigravityCommitMessage,
+ generateAntigravityPrContent,
+ generateAntigravityBranchName,
+} from "./antigravityGit";
export {
generateCursorCommitMessage,
generateCursorPrContent,
stopCursorGitText,
} from "./cursorGit";
-export {
- generateCodexCommitMessage,
- generateCodexPrContent,
-} from "./codexGit";
+export { generateCodexCommitMessage, generateCodexPrContent } from "./codexGit";
export {
generateOpenCodeCommitMessage,
generateOpenCodePrContent,
+ generateOpenCodeBranchName,
} from "./opencodeGit";
export {
generateClaudeCommitMessage,
generateClaudePrContent,
} from "./claudeGit";
-export {
- generateGrokCommitMessage,
- generateGrokPrContent,
-} from "./grokGit";
+export { generateGrokCommitMessage, generateGrokPrContent } from "./grokGit";
export {
generateCommitMessage,
generatePrContent,
+ generateBranchName,
pickTextHarness,
warmupText,
} from "./textHarness";
+export { warmupAntigravityText, stopAntigravityTextPrompt } from "./antigravityText";
export { warmupCursorText } from "./cursorText";
export { warmupOpenCodeText } from "./opencodeText";
export { warmupClaudeText } from "./claudeText";
@@ -109,6 +125,7 @@ export { refreshClaudeCatalog } from "./claudeCatalog";
export { refreshPiCatalog, refreshOmpCatalog } from "./piCatalog";
export { refreshFxCatalog } from "./fxCatalog";
export { refreshGrokCatalog } from "./grokCatalog";
+export { refreshAntigravityCatalog } from "./antigravityCatalog";
export { registerBuiltinHarnesses } from "./register";
export {
getHarnessAvailabilitySnapshot,
@@ -137,5 +154,9 @@ export {
generateHarnessPrContent,
} from "./registry";
export type { ApprovalDecision, HarnessEvent, SteerTurnInput } from "./types";
-export type { UserQuestion, UserQuestionPrompt, UserQuestionReply } from "../userQuestion";
+export type {
+ UserQuestion,
+ UserQuestionPrompt,
+ UserQuestionReply,
+} from "../userQuestion";
export type { HarnessAdapter } from "./registry";
diff --git a/src/lib/harness/preview.ts b/src/lib/harness/preview.ts
index 8e8b95d9..47f1ceea 100644
--- a/src/lib/harness/preview.ts
+++ b/src/lib/harness/preview.ts
@@ -38,6 +38,8 @@ export function extractToolPreview(
locationLine(update.locations ?? update.location) ??
locationLine(tool.locations ?? tool.location) ??
firstNumber(inputs, "line") ??
+ firstNumber(inputs, "StartLine") ??
+ firstNumber(inputs, "startLine") ??
firstNumber(inputs, "offset");
const kind = previewKind(rawKind, title, !!diff, !!path);
const query = extractSearchQuery(inputs);
@@ -183,7 +185,7 @@ export function isExecuteTool(kind?: string, title?: string): boolean {
/** The argv / script a shell tool is about to run, if the harness sent it. */
export function extractShellCommand(...values: unknown[]): string | undefined {
for (const raw of inputRecords(...values)) {
- for (const key of ["command", "cmd", "script"]) {
+ for (const key of ["command", "cmd", "script", "commandLine", "CommandLine"]) {
const found = commandField(raw[key]);
if (found) return found;
}
@@ -264,13 +266,16 @@ export function formatAgentType(value: string): string {
export function extractSearchQuery(value: unknown): string | undefined {
const keys = [
"pattern",
+ "Pattern",
"query",
+ "Query",
"glob",
"glob_pattern",
"globPattern",
"search_term",
"searchTerm",
"regex",
+ "Regex",
];
for (const raw of inputRecords(value)) {
for (const key of keys) {
@@ -394,6 +399,22 @@ export function composeToolTitle(opts: {
return "Find";
}
+ if (previewKind === "write" || isEditTool(kind, title, undefined)) {
+ if (/^(?:edit|write|update|modify|create|replace)(?:ed|ing)?\s+\S/i.test(title)) {
+ return title;
+ }
+ const verb = /write|create|new/i.test(title) ? "Write" : "Edit";
+ if (path) return `${verb} ${path}`;
+ const rest = title
+ .replace(
+ /^(?:edit|write|update|modify|create|replace)(?:ing)?(?:\s+file)?\b\s*/i,
+ "",
+ )
+ .trim();
+ if (rest && !isWeakToolTitle(rest)) return `${verb} ${rest}`;
+ return verb;
+ }
+
return title;
}
@@ -409,7 +430,7 @@ export function stubFilePreview(
}
export function isWeakToolTitle(value: string): boolean {
- return /^(tool|shell|bash|execute|command|skill|read|edit|search|find|grep|glob|fetch|other|write|delete|move|think|run|list|working|reading|editing|searching|writing|running|listing|fetching|thinking|deleting|moving|mcp:\s*tool|read file|edit file|write file|run command|ran command|unnamed)$/i.test(
+ return /^(tool|shell|bash|execute|command|skill|read|edit|search|find|grep|glob|fetch|other|write|delete|move|think|run|list|working|reading|editing|searching|writing|running|listing|fetching|thinking|deleting|moving|mcp:\s*tool|read file|edit file|write file|run command|ran command|unnamed|run_command|replace_file_content|write_to_file|view_file|grep_search|find_by_name)$/i.test(
value.trim(),
);
}
@@ -539,12 +560,16 @@ function inputPath(rawInput: Record): string | undefined {
"filePath",
"file_path",
"targetFile",
+ "TargetFile",
"target_file",
"relative_workspace_path",
"relativeWorkspacePath",
"uri",
"file",
"absolutePath",
+ "AbsolutePath",
+ "searchDirectory",
+ "SearchDirectory",
];
for (const key of keys) {
const value = coerceString(rawInput[key]);
diff --git a/src/lib/harness/register.ts b/src/lib/harness/register.ts
index c8fbfe86..81fcf557 100644
--- a/src/lib/harness/register.ts
+++ b/src/lib/harness/register.ts
@@ -1,4 +1,5 @@
import { ensureClaudeRegistered } from "./claudeAdapter";
+import { ensureAntigravityRegistered } from "./antigravityAdapter";
import { ensureCodexRegistered } from "./codexAdapter";
import { ensureCursorRegistered } from "./cursorAdapter";
import { ensureFxRegistered } from "./fxAdapter";
@@ -9,6 +10,7 @@ import { ensurePiRegistered } from "./piAdapter";
/** Register all known live harness adapters. Idempotent. */
export function registerBuiltinHarnesses(): void {
+ ensureAntigravityRegistered();
ensureClaudeRegistered();
ensureCursorRegistered();
ensureCodexRegistered();
diff --git a/src/lib/harness/registry.test.ts b/src/lib/harness/registry.test.ts
index 8194b467..dad5717a 100644
--- a/src/lib/harness/registry.test.ts
+++ b/src/lib/harness/registry.test.ts
@@ -61,9 +61,10 @@ describe("harness registry", () => {
registerHarness(stub("pi", { refreshCatalog: pi }));
registerHarness(stub("claude", { refreshCatalog: claude }));
- await refreshHarnessCatalogs(["claude"]);
+ await refreshHarnessCatalogs(["claude"], "/repo");
expect(claude).toHaveBeenCalledOnce();
+ expect(claude).toHaveBeenCalledWith("/repo");
expect(pi).not.toHaveBeenCalled();
});
diff --git a/src/lib/harness/registry.ts b/src/lib/harness/registry.ts
index d116182d..87388763 100644
--- a/src/lib/harness/registry.ts
+++ b/src/lib/harness/registry.ts
@@ -44,7 +44,7 @@ export type HarnessAdapter = {
cwd: string,
): void;
/** Refresh the model catalog overlay when supported. */
- refreshCatalog?(): Promise;
+ refreshCatalog?(cwd?: string): Promise;
/** Optional LLM tab title for the first turn. */
generateTitle?(input: TitleInput): Promise;
/** Optional LLM commit message from staged changes. */
@@ -210,6 +210,7 @@ export function bindHarnessSession(
*/
export async function refreshHarnessCatalogs(
ids: Iterable,
+ cwd?: string,
): Promise {
const wanted = new Set(ids);
if (wanted.size === 0) return;
@@ -218,7 +219,7 @@ export async function refreshHarnessCatalogs(
.filter((adapter) => wanted.has(adapter.id))
.map(async (adapter) => {
if (!adapter.refreshCatalog || hasLiveCatalog(adapter.id)) return;
- await adapter.refreshCatalog().catch((error: unknown) => {
+ await adapter.refreshCatalog(cwd).catch((error: unknown) => {
console.debug(`[monocode] ${adapter.id} catalog`, error);
});
}),
diff --git a/src/lib/harness/textHarness.ts b/src/lib/harness/textHarness.ts
index a50e9107..2e6c5e07 100644
--- a/src/lib/harness/textHarness.ts
+++ b/src/lib/harness/textHarness.ts
@@ -2,6 +2,7 @@ import type { HarnessId } from "../session";
import { isHarnessAvailable } from "./availability";
import type { PrContent } from "../gitText";
import {
+ generateHarnessBranchName,
generateHarnessCommitMessage,
generateHarnessPrContent,
warmupHarnessText,
@@ -12,6 +13,7 @@ const TEXT_HARNESSES: HarnessId[] = [
"cursor",
"codex",
"grok",
+ "antigravity",
"opencode",
];
@@ -44,3 +46,11 @@ export function generatePrContent(
): Promise<(PrContent & { base: string; head: string }) | null> {
return generateHarnessPrContent(pickTextHarness(preferred), cwd);
}
+
+export function generateBranchName(
+ cwd: string,
+ message: string,
+ preferred?: HarnessId,
+): Promise {
+ return generateHarnessBranchName(pickTextHarness(preferred), cwd, message);
+}
diff --git a/src/lib/markdownSource.test.ts b/src/lib/markdownSource.test.ts
index ae3a6c00..b933d3f1 100644
--- a/src/lib/markdownSource.test.ts
+++ b/src/lib/markdownSource.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { isAtxHeadingLine } from "./markdownSource";
+import { isAtxHeadingLine, normalizeFileLinks } from "./markdownSource";
describe("isAtxHeadingLine", () => {
it("matches ATX headings", () => {
@@ -21,3 +21,56 @@ describe("isAtxHeadingLine", () => {
expect(isAtxHeadingLine("####### seven")).toBe(false);
});
});
+
+describe("normalizeFileLinks", () => {
+ it("converts file:// URI links to direct path links", () => {
+ expect(
+ normalizeFileLinks(
+ "Check [protocol](file:///Users/dev/project/src/lib/protocol.ts) for details.",
+ ),
+ ).toBe(
+ "Check [protocol](/Users/dev/project/src/lib/protocol.ts) for details.",
+ );
+ });
+
+ it("preserves line range hashes and code inside link text", () => {
+ expect(
+ normalizeFileLinks(
+ "See [`buildArgs()`](file:///Users/dev/project/src/lib/protocol.ts#L41-L81).",
+ ),
+ ).toBe(
+ "See [`buildArgs()`](/Users/dev/project/src/lib/protocol.ts#L41-L81).",
+ );
+ });
+
+ it("handles angle bracket URLs and URL-encoded characters", () => {
+ expect(
+ normalizeFileLinks(
+ "File: [readme]()",
+ ),
+ ).toBe("File: [readme](/Users/dev/my app/README.md#L5)");
+
+ expect(
+ normalizeFileLinks(""),
+ ).toBe("");
+ });
+
+ it("handles Windows drive letter file URIs", () => {
+ expect(
+ normalizeFileLinks(
+ "See [win](file:///C:/Users/dev/project/src/main.rs#L10).",
+ ),
+ ).toBe("See [win](C:/Users/dev/project/src/main.rs#L10).");
+
+ expect(
+ normalizeFileLinks("See [win](file://C:/Users/dev/project/src/main.rs)."),
+ ).toBe("See [win](C:/Users/dev/project/src/main.rs).");
+ });
+
+ it("leaves non-file links untouched", () => {
+ expect(
+ normalizeFileLinks("Visit [Google](https://google.com) or `/local/path`"),
+ ).toBe("Visit [Google](https://google.com) or `/local/path`");
+ });
+});
+
diff --git a/src/lib/markdownSource.ts b/src/lib/markdownSource.ts
index ad041f57..ef0a2062 100644
--- a/src/lib/markdownSource.ts
+++ b/src/lib/markdownSource.ts
@@ -2,3 +2,45 @@
export function isAtxHeadingLine(line: string): boolean {
return /^\s{0,3}#{1,6}(?:\s|$)/.test(line);
}
+
+/**
+ * Rewrite `file://` link targets to direct filesystem paths so Markdown
+ * sanitizers (e.g. `rehype-harden`) preserve them and `MarkdownLink` can open
+ * them in the editor.
+ */
+export function normalizeFileLinks(text: string): string {
+ if (!text || !text.includes("file:")) return text;
+ const cleanPath = (raw: string) => {
+ let path = raw.replace(/^file:\/\/(?:localhost)?\/?/i, "");
+ if (/^[A-Za-z]:[/\\]/.test(path)) {
+ path = path.replace(/\\/g, "/");
+ } else {
+ path = `/${path.replace(/\\/g, "/")}`;
+ }
+ try {
+ path = decodeURI(path);
+ } catch {}
+ return path;
+ };
+
+ return text
+ .replace(/\]\(<(file:\/\/[^>]+)>\)/g, (_, url) => {
+ const hashMatch = url.match(/(#[^>]*)$/);
+ const hash = hashMatch ? hashMatch[1] : "";
+ const withoutHash = url.slice(0, url.length - hash.length);
+ return `](${cleanPath(withoutHash)}${hash})`;
+ })
+ .replace(/\]\((file:\/\/[^)]+)\)/g, (_, url) => {
+ const hashMatch = url.match(/(#[^)]*)$/);
+ const hash = hashMatch ? hashMatch[1] : "";
+ const withoutHash = url.slice(0, url.length - hash.length);
+ return `](${cleanPath(withoutHash)}${hash})`;
+ })
+ .replace(/<(file:\/\/[^>]+)>/g, (_, url) => {
+ const hashMatch = url.match(/(#[^>]*)$/);
+ const hash = hashMatch ? hashMatch[1] : "";
+ const withoutHash = url.slice(0, url.length - hash.length);
+ return `<${cleanPath(withoutHash)}${hash}>`;
+ });
+}
+
diff --git a/src/lib/models.test.ts b/src/lib/models.test.ts
index b47edd13..41611331 100644
--- a/src/lib/models.test.ts
+++ b/src/lib/models.test.ts
@@ -221,17 +221,26 @@ describe("provider defaults", () => {
model: defaultModelId("cursor"),
});
});
+
+ it("has a usable Antigravity fallback before its live catalog arrives", () => {
+ expect(defaultModelId("antigravity")).toBe("antigravity:default");
+ expect(preferredModelId("antigravity")).toBe("antigravity:default");
+ });
});
describe("model picker tabs", () => {
const available = (id: HarnessId) =>
- id === "claude" || id === "fx" || id === "cursor";
+ id === "claude" ||
+ id === "fx" ||
+ id === "cursor" ||
+ id === "antigravity";
it("starts with favorites then installed providers", () => {
expect(modelPickerTabs(available)).toEqual([
"favorites",
"claude",
"cursor",
+ "antigravity",
"fx",
]);
});
@@ -239,6 +248,7 @@ describe("model picker tabs", () => {
it("wraps left and right across favorites and providers", () => {
expect(stepModelPickerTab("favorites", 1, available)).toBe("claude");
expect(stepModelPickerTab("claude", 1, available)).toBe("cursor");
+ expect(stepModelPickerTab("cursor", 1, available)).toBe("antigravity");
expect(stepModelPickerTab("fx", 1, available)).toBe("favorites");
expect(stepModelPickerTab("favorites", -1, available)).toBe("fx");
});
diff --git a/src/lib/models.ts b/src/lib/models.ts
index a5a9ef1d..47063e49 100644
--- a/src/lib/models.ts
+++ b/src/lib/models.ts
@@ -25,6 +25,35 @@ export type AgentModel = {
contextWindow?: number;
};
+export const ANTIGRAVITY_REASONING_SETTINGS_3: ModelSetting[] = [
+ {
+ id: "effort",
+ label: "Reasoning",
+ kind: "select",
+ value: "high",
+ options: [
+ { value: "high", label: "High" },
+ { value: "medium", label: "Medium" },
+ { value: "low", label: "Low" },
+ ],
+ },
+];
+
+export const ANTIGRAVITY_REASONING_SETTINGS_2: ModelSetting[] = [
+ {
+ id: "effort",
+ label: "Reasoning",
+ kind: "select",
+ value: "high",
+ options: [
+ { value: "high", label: "High" },
+ { value: "low", label: "Low" },
+ ],
+ },
+];
+
+export const ANTIGRAVITY_MODEL_SETTINGS = ANTIGRAVITY_REASONING_SETTINGS_3;
+
export const MODELS: AgentModel[] = [
{
id: "claude:sonnet-5",
@@ -136,6 +165,67 @@ export const MODELS: AgentModel[] = [
],
},
+ {
+ id: "antigravity:default",
+ harness: "antigravity",
+ name: "Default",
+ nativeId: "",
+ contextWindow: 1_000_000,
+ },
+ {
+ id: "antigravity:gemini-3.8-flash",
+ harness: "antigravity",
+ name: "Gemini 3.8 Flash",
+ nativeId: "gemini-3.8-flash",
+ contextWindow: 1_000_000,
+ settings: ANTIGRAVITY_REASONING_SETTINGS_3,
+ },
+ {
+ id: "antigravity:gemini-3.7-flash",
+ harness: "antigravity",
+ name: "Gemini 3.7 Flash",
+ nativeId: "gemini-3.7-flash",
+ contextWindow: 1_000_000,
+ settings: ANTIGRAVITY_REASONING_SETTINGS_3,
+ },
+ {
+ id: "antigravity:gemini-3.6-flash",
+ harness: "antigravity",
+ name: "Gemini 3.6 Flash",
+ nativeId: "gemini-3.6-flash",
+ contextWindow: 1_000_000,
+ settings: ANTIGRAVITY_REASONING_SETTINGS_3,
+ },
+ {
+ id: "antigravity:gemini-3.1-pro",
+ harness: "antigravity",
+ name: "Gemini 3.1 Pro",
+ nativeId: "gemini-3.1-pro",
+ contextWindow: 1_000_000,
+ settings: ANTIGRAVITY_REASONING_SETTINGS_2,
+ },
+ {
+ id: "antigravity:claude-sonnet-4-6",
+ harness: "antigravity",
+ name: "Claude Sonnet 4.6 (Thinking)",
+ nativeId: "claude-sonnet-4-6",
+ contextWindow: 200_000,
+ },
+ {
+ id: "antigravity:claude-opus-4-6-thinking",
+ harness: "antigravity",
+ name: "Claude Opus 4.6 (Thinking)",
+ nativeId: "claude-opus-4-6-thinking",
+ contextWindow: 200_000,
+ },
+ {
+ id: "antigravity:gpt-oss-120b-medium",
+ harness: "antigravity",
+ name: "GPT-OSS 120B (Medium)",
+ nativeId: "gpt-oss-120b-medium",
+ contextWindow: 128_000,
+ },
+
{ id: "opencode:glm-5", harness: "opencode", name: "GLM 5" },
{ id: "opencode:minimax-m2.5", harness: "opencode", name: "MiniMax M2.5" },
{ id: "opencode:kimi-k2.5", harness: "opencode", name: "Kimi K2.5" },
@@ -177,6 +267,7 @@ export const DEFAULT_MODEL_ID: Record = {
codex: "",
cursor: "cursor:composer-2.5",
grok: "grok:grok-4.6",
+ antigravity: "antigravity:default",
opencode: "opencode:glm-5",
pi: "pi:default",
omp: "omp:default",
@@ -202,6 +293,7 @@ const HARNESS_ORDER: HarnessId[] = [
"codex",
"cursor",
"grok",
+ "antigravity",
"opencode",
"pi",
"omp",
@@ -295,7 +387,13 @@ export function findModel(id: string): AgentModel | undefined {
}
indexById = index;
}
- return indexById.get(id);
+ const exact = indexById.get(id);
+ if (exact) return exact;
+ if (id.startsWith("antigravity:")) {
+ const stripped = id.replace(/-(high|medium|low)$/, "");
+ if (stripped !== id) return indexById.get(stripped);
+ }
+ return undefined;
}
export function resolveModel(harness: HarnessId, id?: string): AgentModel {
@@ -709,6 +807,15 @@ function pickDefaultId(harness: HarnessId, models: AgentModel[]): string {
DEFAULT_MODEL_ID.fx
);
}
+ if (harness === "antigravity") {
+ return (
+ models.find((model) => model.id === DEFAULT_MODEL_ID.antigravity)?.id ??
+ models.find((model) => model.nativeId === "gemini-3.8-flash-high")?.id ??
+ models.find((model) => model.nativeId === "gemini-3.7-flash-high")?.id ??
+ models[0]?.id ??
+ DEFAULT_MODEL_ID.antigravity
+ );
+ }
return (
models.find((model) => model.id === DEFAULT_MODEL_ID[harness])?.id ??
models[0]?.id ??
diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts
index 86fe2e4c..6169a199 100644
--- a/src/lib/rateLimits.test.ts
+++ b/src/lib/rateLimits.test.ts
@@ -9,6 +9,7 @@ import {
idleRateLimits,
isRateLimitSnapshotStale,
mapUsageWindow,
+ parseAntigravityRateLimits,
parseClaudeOAuthUsage,
parseCodexRateLimits,
parseResetTimestamp,
@@ -180,6 +181,60 @@ describe("parseCodexRateLimits", () => {
});
});
+describe("parseAntigravityRateLimits", () => {
+ it("parses 5h and weekly limits from agy /usage JSON output", () => {
+ const jsonOutput = JSON.stringify({
+ command: {
+ name: "usage",
+ data: {
+ groups: [
+ {
+ name: "Gemini Models",
+ buckets: [
+ {
+ id: "gemini-weekly",
+ name: "Weekly Limit Remaining",
+ window: "weekly",
+ remaining_fraction: 0.86,
+ reset_time: "2026-09-06T08:00:00Z",
+ },
+ {
+ id: "gemini-5h",
+ name: "Five Hour Limit Remaining",
+ window: "5h",
+ remaining_fraction: 0.6,
+ reset_time: "2026-09-02T15:00:00Z",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+
+ const limits = parseAntigravityRateLimits(jsonOutput);
+ expect(limits.provider).toBe("antigravity");
+ expect(limits.status).toBe("ok");
+ expect(limits.session?.usedPercent).toBe(40);
+ expect(limits.session?.windowMinutes).toBe(300);
+ expect(limits.session?.resetsAt).toBe(Date.parse("2026-09-02T15:00:00Z"));
+ expect(limits.weekly?.usedPercent).toBe(14);
+ expect(limits.weekly?.windowMinutes).toBe(10_080);
+ expect(limits.weekly?.resetsAt).toBe(Date.parse("2026-09-06T08:00:00Z"));
+ });
+
+ it("handles empty or invalid JSON gracefully", () => {
+ const empty = parseAntigravityRateLimits("{}");
+ expect(empty.provider).toBe("antigravity");
+ expect(empty.session).toBeNull();
+ expect(empty.weekly).toBeNull();
+
+ const invalid = parseAntigravityRateLimits("not json");
+ expect(invalid.status).toBe("error");
+ });
+});
+
+
describe("rateLimitWindowTooltip", () => {
it("includes used percent and remaining time", () => {
const now = Date.parse("2026-08-27T08:00:00Z");
diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts
index f242dea4..8873c85d 100644
--- a/src/lib/rateLimits.ts
+++ b/src/lib/rateLimits.ts
@@ -1,6 +1,6 @@
import { asRecord } from "./harness/codexProtocol";
-export type RateLimitProvider = "claude" | "codex";
+export type RateLimitProvider = "claude" | "codex" | "antigravity";
export type RateLimitStatus =
"idle" | "fetching" | "ok" | "error" | "unavailable";
@@ -57,11 +57,13 @@ export function shouldFetchRateLimits(input: {
visible: boolean;
claude: ProviderRateLimits;
codex: ProviderRateLimits;
+ antigravity?: ProviderRateLimits;
now?: number;
}): boolean {
return (
shouldFetchProvider(input.claude, input) ||
- shouldFetchProvider(input.codex, input)
+ shouldFetchProvider(input.codex, input) ||
+ (input.antigravity ? shouldFetchProvider(input.antigravity, input) : false)
);
}
@@ -300,6 +302,94 @@ export function parseCodexRateLimits(result: unknown): ProviderRateLimits {
};
}
+export function parseAntigravityRateLimits(result: unknown): ProviderRateLimits {
+ let parsed: unknown = result;
+ if (typeof result === "string") {
+ try {
+ parsed = JSON.parse(result);
+ } catch {
+ const start = result.indexOf("{");
+ const end = result.lastIndexOf("}");
+ if (start !== -1 && end > start) {
+ try {
+ parsed = JSON.parse(result.slice(start, end + 1));
+ } catch {
+ return errorRateLimits("antigravity", "Antigravity usage response was not JSON");
+ }
+ } else {
+ return errorRateLimits("antigravity", "Antigravity usage response was not JSON");
+ }
+ }
+ }
+ const rec = asRecord(parsed);
+ if (!rec) {
+ return errorRateLimits("antigravity", "Antigravity usage response was empty");
+ }
+ const command = asRecord(rec.command);
+ const data = asRecord(command?.data) ?? asRecord(rec.data) ?? rec;
+ const groups = Array.isArray(data.groups) ? (data.groups as unknown[]) : [];
+
+ let session: RateLimitWindow | null = null;
+ let weekly: RateLimitWindow | null = null;
+
+ for (const rawGroup of groups) {
+ const group = asRecord(rawGroup);
+ const buckets = Array.isArray(group?.buckets) ? (group.buckets as unknown[]) : [];
+ for (const rawBucket of buckets) {
+ const bucket = asRecord(rawBucket);
+ if (!bucket) continue;
+ const windowType = typeof bucket.window === "string" ? bucket.window.toLowerCase() : "";
+ const bucketId = typeof bucket.id === "string" ? bucket.id.toLowerCase() : "";
+ const remaining = typeof bucket.remaining_fraction === "number" ? bucket.remaining_fraction : null;
+ if (remaining == null) continue;
+ const usedPercent = Math.round(clampUsedPercent((1 - remaining) * 100) * 100) / 100;
+ const nameLower = typeof bucket.name === "string" ? bucket.name.toLowerCase() : "";
+ const is5h =
+ windowType === "5h" ||
+ windowType === "five_hour" ||
+ windowType === "5_hour" ||
+ bucketId.includes("5h") ||
+ bucketId.includes("five_hour") ||
+ bucketId.includes("5-hour") ||
+ nameLower.includes("five hour") ||
+ nameLower.includes("5-hour");
+ const isWeekly =
+ windowType === "weekly" ||
+ bucketId.includes("weekly") ||
+ nameLower.includes("weekly");
+
+ const resetsAt = parseResetTimestamp(bucket.reset_time ?? bucket.resetTime);
+
+ if (is5h) {
+ if (!session || usedPercent > session.usedPercent) {
+ session = {
+ usedPercent,
+ windowMinutes: SESSION_WINDOW_MINUTES,
+ resetsAt,
+ };
+ }
+ } else if (isWeekly) {
+ if (!weekly || usedPercent > weekly.usedPercent) {
+ weekly = {
+ usedPercent,
+ windowMinutes: WEEKLY_WINDOW_MINUTES,
+ resetsAt,
+ };
+ }
+ }
+ }
+ }
+
+ return {
+ provider: "antigravity",
+ session,
+ weekly,
+ updatedAt: Date.now(),
+ error: null,
+ status: "ok",
+ };
+}
+
function snapshotFrom(
rec: Record | null,
): CodexWindowSnapshot | null {
diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts
index 275f7526..b0e26182 100644
--- a/src/lib/rateLimitsFetch.ts
+++ b/src/lib/rateLimitsFetch.ts
@@ -2,13 +2,16 @@ import { invoke } from "@tauri-apps/api/core";
import { homeDir } from "./fs";
import {
errorRateLimits,
+ parseAntigravityRateLimits,
parseClaudeOAuthUsage,
parseCodexRateLimits,
unavailableRateLimits,
type ProviderRateLimits,
} from "./rateLimits";
import {
+ execChild,
killChild,
+ resolveAntigravityBinary,
resolveCodexBinary,
spawnChild,
unwatchChild,
@@ -144,6 +147,40 @@ export async function fetchCodexRateLimits(): Promise {
}
}
+export async function fetchAntigravityRateLimits(): Promise {
+ let path: string;
+ try {
+ path = (await resolveAntigravityBinary()).path;
+ } catch {
+ return unavailableRateLimits("antigravity", "Antigravity CLI not found");
+ }
+
+ const cwd = await homeDir();
+ try {
+ const output = await execChild(
+ path,
+ ["--output-format", "json", "-p", "/usage"],
+ cwd,
+ );
+ const parsed = parseAntigravityRateLimits(output);
+ if (parsed.session || parsed.weekly) return parsed;
+ return unavailableRateLimits("antigravity", "No Antigravity usage data");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (
+ /not signed in|authentication required|not authenticated|login/i.test(
+ message,
+ )
+ ) {
+ return unavailableRateLimits("antigravity", "Antigravity not signed in");
+ }
+ if (/ENOENT|not found|could not run/i.test(message)) {
+ return unavailableRateLimits("antigravity", "Antigravity CLI not found");
+ }
+ return errorRateLimits("antigravity", message);
+ }
+}
+
async function withTimeout(
ms: number,
work: () => Promise,
diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts
new file mode 100644
index 00000000..55d0a9ee
--- /dev/null
+++ b/src/lib/session.test.ts
@@ -0,0 +1,8 @@
+import { describe, expect, it } from "vitest";
+import { harnessSupportsAttachments } from "./session";
+
+describe("provider capabilities", () => {
+ it("keeps Antigravity attachments enabled for staged file references", () => {
+ expect(harnessSupportsAttachments("antigravity")).toBe(true);
+ });
+});
diff --git a/src/lib/session.ts b/src/lib/session.ts
index 7f3eb074..614f144a 100644
--- a/src/lib/session.ts
+++ b/src/lib/session.ts
@@ -11,13 +11,22 @@ import {
} from "./models";
export type HarnessId =
- "claude" | "codex" | "cursor" | "grok" | "opencode" | "pi" | "omp" | "fx";
+ | "claude"
+ | "codex"
+ | "cursor"
+ | "grok"
+ | "antigravity"
+ | "opencode"
+ | "pi"
+ | "omp"
+ | "fx";
export const HARNESSES: HarnessId[] = [
"claude",
"codex",
"cursor",
"grok",
+ "antigravity",
"opencode",
"pi",
"omp",
@@ -202,6 +211,7 @@ export const HARNESS_LABEL: Record = {
codex: "codex",
cursor: "cursor",
grok: "grok",
+ antigravity: "antigravity",
opencode: "opencode",
pi: "pi",
omp: "omp",
@@ -213,6 +223,7 @@ export const HARNESS_TITLE: Record = {
codex: "Codex",
cursor: "Cursor",
grok: "Grok Build",
+ antigravity: "Antigravity CLI",
opencode: "OpenCode",
pi: "Pi",
omp: "omp",
diff --git a/src/lib/skills.test.ts b/src/lib/skills.test.ts
index 5a36f48f..fbc6856a 100644
--- a/src/lib/skills.test.ts
+++ b/src/lib/skills.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_CREATE_SKILL,
+ ANTIGRAVITY_NATIVE_SKILLS,
applySkillsToTurn,
blankSkillMarkdown,
injectSkillPrompt,
@@ -287,3 +288,35 @@ describe("skill names", () => {
expect(md).toContain("# Review Pr");
});
});
+
+describe("Antigravity native skills", () => {
+ it("includes /boost, /plan, /goal and other native commands", () => {
+ const names = ANTIGRAVITY_NATIVE_SKILLS.map((s) => s.name);
+ expect(names).toContain("boost");
+ expect(names).toContain("plan");
+ expect(names).toContain("goal");
+ expect(names).toContain("grill-me");
+ expect(names).toContain("teamwork-preview");
+ expect(names).toContain("browser");
+ expect(names).toContain("learn");
+ expect(names).toContain("schedule");
+ });
+
+ it("applies /boost skill prompt to turn", async () => {
+ const prompt = await applySkillsToTurn("Please refactor the auth system /boost", {
+ harness: "antigravity",
+ cwd: "/repo",
+ });
+ expect(prompt).toContain("## /boost");
+ expect(prompt).toContain("Activate boosted reasoning");
+ expect(prompt).toContain("Please refactor the auth system /boost");
+ });
+
+ it("merges Antigravity native skills into catalog", () => {
+ const catalog = mergeCatalog([], ANTIGRAVITY_NATIVE_SKILLS);
+ const boost = catalog.find((s) => s.name === "boost");
+ expect(boost).toBeDefined();
+ expect(boost?.kind).toBe("native");
+ expect(boost?.source).toBe("antigravity");
+ });
+});
diff --git a/src/lib/skills.ts b/src/lib/skills.ts
index 404d1c11..e4ff5529 100644
--- a/src/lib/skills.ts
+++ b/src/lib/skills.ts
@@ -30,6 +30,7 @@ export type SkillSource =
| "omp"
| "fx"
| "grok"
+ | "antigravity"
| "monocode";
type SkillCommon = {
@@ -53,7 +54,98 @@ export type BuiltinSkill = SkillCommon & {
export type NativeSkill = SkillCommon & {
kind: "native";
- source: "pi";
+ source: "pi" | "antigravity";
+};
+
+export const ANTIGRAVITY_NATIVE_SKILLS: NativeSkill[] = [
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "boost",
+ description: "Deep thinking, strategic planning, multiple perspectives, and rigorous verification.",
+ invocation: "boost",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "plan",
+ description: "Step-by-step architectural and implementation planning before making changes.",
+ invocation: "plan",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "goal",
+ description: "Autonomous execution mode: persists until the objective and verification pass.",
+ invocation: "goal",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "grill-me",
+ description: "Interactive interview to resolve trade-offs, ambiguities, and design decisions.",
+ invocation: "grill-me",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "teamwork-preview",
+ description: "Orchestrate subagents concurrently to divide and conquer large tasks.",
+ invocation: "teamwork-preview",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "browser",
+ description: "Web research, live documentation lookups, and web application testing.",
+ invocation: "browser",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "learn",
+ description: "Persist lessons, corrections, and codebase rules into memory.",
+ invocation: "learn",
+ },
+ {
+ kind: "native",
+ source: "antigravity",
+ name: "schedule",
+ description: "Set recurring background tasks, crons, or one-time timers.",
+ invocation: "schedule",
+ },
+];
+
+export const ANTIGRAVITY_SKILL_BODIES: Record = {
+ boost: `Activate boosted reasoning, deep thinking, strategic planning, multiple perspectives, and rigorous verification:
+1. **Explore Perspectives**: Analyze the problem through multiple design and architecture viewpoints before choosing a path. Identify subtle failure modes, edge cases, and backward-compatibility risks.
+2. **Formulate a Strategic Plan**: Outline a clear, step-by-step hypothesis and execution plan. Break complex operations into isolated, verifiable milestones.
+3. **Execute with Precision**: Implement clean, well-factored code adhering strictly to existing codebase conventions. Preserve all unrelated comments and docstrings.
+4. **Rigorous Verification**: Run relevant test suites and verify all touched areas thoroughly. Formulate and run new tests to prove correctness if coverage is missing.`,
+ plan: `Create a comprehensive, step-by-step implementation plan before modifying any code:
+1. Summarize the user's intent and scope.
+2. Detail the architecture decisions, files to modify/create, and potential risks or alternatives.
+3. Provide a clear checklist of actionable implementation steps.
+4. Stop after delivering the plan and wait for the user's confirmation before executing code changes.`,
+ goal: `Operate in autonomous goal-driven mode:
+1. Formulate a comprehensive roadmap to achieve the specified goal.
+2. Persistently execute each stage, run tests, diagnose errors, and fix issues autonomously.
+3. Do not stop until all acceptance criteria are fully met and verified.`,
+ "grill-me": `Conduct an interactive clarifying interview with the user:
+1. Analyze the request to identify trade-offs, underspecified requirements, and architectural forks.
+2. Ask targeted, multiple-choice questions to resolve ambiguities before implementing any solution.`,
+ "teamwork-preview": `Orchestrate a team of subagents to execute this task:
+1. Deconstruct the task into modular, parallel work streams.
+2. Launch subagents (using invoke_subagent) with dedicated roles (e.g. research, frontend, backend, test).
+3. Coordinate findings and integrate the final deliverable.`,
+ browser: `Focus on web research, live documentation inspection, and web application interactions:
+1. Fetch authoritative URLs and read online references to ensure solutions use the latest APIs.
+2. Validate findings against official documentation.`,
+ learn: `Extract durable patterns, rules, and preferences from this conversation:
+1. Identify corrections, project conventions, and user preferences demonstrated in this session.
+2. Propose or write updated rules to document these practices for future sessions.`,
+ schedule: `Configure and manage scheduled tasks or background timers:
+1. Help the user schedule recurring cron jobs or one-shot reminders using the available scheduling tools.`,
};
export type Skill = FileSkill | BuiltinSkill | NativeSkill;
@@ -205,7 +297,10 @@ function startCatalogLoad(
entry.retryAt = Date.now() + PI_SKILL_RETRY_MS;
return entry.skills ?? [];
}
- const fallback = mergeCatalog([]);
+ const fallback =
+ context.harness === "antigravity"
+ ? mergeCatalog([], ANTIGRAVITY_NATIVE_SKILLS)
+ : mergeCatalog([]);
entry.skills = fallback;
entry.loadedAt = Date.now();
return fallback;
@@ -231,15 +326,25 @@ async function loadCatalog(context: SkillCatalogContext): Promise {
...command,
}));
}
- return mergeCatalog(await listSkills(context.cwd));
+ const discovered = await listSkills(context.cwd).catch(() => []);
+ if (context.harness === "antigravity") {
+ return mergeCatalog(discovered, ANTIGRAVITY_NATIVE_SKILLS);
+ }
+ return mergeCatalog(discovered);
}
-export function mergeCatalog(discovered: DiscoveredSkill[]): Skill[] {
+export function mergeCatalog(
+ discovered: DiscoveredSkill[],
+ extraSkills: Skill[] = [],
+): Skill[] {
const out = new Map();
const add = (skill: Skill) => {
if (!skill.name || out.has(skill.name)) return;
out.set(skill.name, skill);
};
+ for (const skill of extraSkills) {
+ add(skill);
+ }
for (const skill of discovered) {
if (skill.source === "agents") add(asSkill(skill));
}
@@ -430,10 +535,14 @@ export async function applySkillsToTurn(
const names = skillNamesInText(text);
if (names.length === 0) return text;
const catalog = await loadSkills(context);
- const picked: Array = [];
+ const picked: Skill[] = [];
for (const name of names) {
const skill = catalog.find((item) => item.name === name);
- if (skill?.kind === "file" || skill?.kind === "builtin") {
+ if (
+ skill?.kind === "file" ||
+ skill?.kind === "builtin" ||
+ (skill?.kind === "native" && skill.source === "antigravity")
+ ) {
picked.push(skill);
}
}
@@ -458,9 +567,12 @@ export function warmPiSkills(
}
export async function readSkillBody(
- skill: FileSkill | BuiltinSkill,
+ skill: Skill,
): Promise {
if (skill.kind === "builtin") return CREATE_SKILL_BODY;
+ if (skill.kind === "native") {
+ return ANTIGRAVITY_SKILL_BODIES[skill.name] ?? "";
+ }
try {
return await readTextFile(skill.path);
} catch {
diff --git a/src/lib/userQuestion.ts b/src/lib/userQuestion.ts
index 01aa3444..f082019c 100644
--- a/src/lib/userQuestion.ts
+++ b/src/lib/userQuestion.ts
@@ -158,7 +158,9 @@ function questionFromUnknown(
multiSelect:
rec.multiSelect === true ||
rec.allowMultiple === true ||
- rec.multiple === true,
+ rec.multiple === true ||
+ rec.is_multi_select === true ||
+ rec.isMultiSelect === true,
allowCustom,
options,
};
diff --git a/src/surfaces/AgentMarkdown.tsx b/src/surfaces/AgentMarkdown.tsx
index f1a7a601..96a4f3fe 100644
--- a/src/surfaces/AgentMarkdown.tsx
+++ b/src/surfaces/AgentMarkdown.tsx
@@ -22,7 +22,7 @@ import type { PluggableList } from "unified";
import { FileTypeIcon } from "../chrome/FileTypeIcon";
import { createLazyMermaidPlugin } from "./mermaidPlugin";
import { resolveWorkspacePath } from "../lib/paths";
-import { isAtxHeadingLine } from "../lib/markdownSource";
+import { isAtxHeadingLine, normalizeFileLinks } from "../lib/markdownSource";
import { useColorScheme } from "../hooks/useColorScheme";
import { useLockOverscroll } from "../hooks/useLockOverscroll";
@@ -51,6 +51,7 @@ const MARKDOWN_REHYPE_PLUGINS: PluggableList = [
allowedLinkPrefixes: ["*"],
allowDataImages: true,
imageBlockPolicy: "remove" as const,
+ linkBlockPolicy: "text-only" as const,
},
],
];
@@ -245,6 +246,7 @@ export const AgentMarkdown = memo(function AgentMarkdown({
cwd?: string;
onOpenFile?: (path: string) => void;
}) {
+ const normalizedText = useMemo(() => normalizeFileLinks(text), [text]);
const fileOpen = useMemo(() => ({ cwd, onOpenFile }), [cwd, onOpenFile]);
return (
@@ -256,7 +258,7 @@ export const AgentMarkdown = memo(function AgentMarkdown({
plugins={MARKDOWN_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
>
- {text}
+ {normalizedText}
);
diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx
index ee6a2f1a..350cf307 100644
--- a/src/surfaces/AgentTranscript.tsx
+++ b/src/surfaces/AgentTranscript.tsx
@@ -378,6 +378,7 @@ export function AgentTranscript({
}
copyText={turnCopyText(turn)}
onSaveNote={onSaveNote}
+ cwd={cwd}
fromHarness={
harness ? harnessForTurn(blocks, turn, harness) : undefined
}
@@ -451,6 +452,7 @@ function TurnDuration({
completedAt,
copyText: output,
onSaveNote,
+ cwd,
fromHarness,
onSecondOpinion,
onHandoff,
@@ -464,6 +466,7 @@ function TurnDuration({
completedAt?: number;
copyText?: string;
onSaveNote?: (text: string) => void;
+ cwd?: string;
fromHarness?: HarnessId;
onSecondOpinion?: (harness: HarnessId, model: string) => void;
onHandoff?: (harness: HarnessId, model: string) => void;
@@ -505,10 +508,14 @@ function TurnDuration({
)}
{fromHarness && onHandoff ? (
-
+
) : null}
{fromHarness && onSecondOpinion ? (
-
+
) : null}
) : (
diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx
index 3cec537f..63081c15 100644
--- a/src/surfaces/SettingsView.tsx
+++ b/src/surfaces/SettingsView.tsx
@@ -234,7 +234,7 @@ export function SettingsView({
) : null}
{section === "keybindings" ? : null}
- {section === "providers" ? : null}
+ {section === "providers" ? : null}
{section === "archive" ? (
void;
@@ -959,8 +962,8 @@ function ProviderRow({
useEffect(() => {
if (!available || models.length > 0) return;
- void refreshHarnessCatalogs([harness]);
- }, [available, harness, models.length]);
+ void refreshHarnessCatalogs([harness], cwd);
+ }, [available, cwd, harness, models.length]);
const onPickerVisible = (visible: boolean) => {
savePickerProviderVisible(harness, visible);