diff --git a/AGENTS.md b/AGENTS.md index ef69591a340..86419209688 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,41 @@ - `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. - `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. +## Antigravity provider + +The `antigravity` driver wraps the Antigravity CLI (`agy`), which has **no native agent +protocol**. `apps/server/src/provider/acp/antigravity/agyBridge.ts` presents the ACP surface +`AcpSessionRuntime` needs and runs each turn through `agy --print`, shipping as hidden +subcommands of the server binary (`t3 agy-acp`, `t3 agy-hook`). + +A live event stream is reconstructed from two documented Antigravity sources that correlate on +step index (`stepIdx` in a hook == `step_index` in the transcript): + +- **Hooks** (`PreToolUse`/`PostToolUse`/`Stop`) give tool name, arguments, and status. The bridge + registers them via a throwaway `--add-dir` workspace so nothing is written into the user's repo. + Both the session cwd and that hook workspace must be passed — `--add-dir` replaces the workspace + rather than adding to cwd, so passing only the hook dir hides the project from the agent. +- **The trajectory transcript** (`transcript.jsonl`) gives assistant text and real tool output as + the turn runs. Its record format is undocumented, so parsing degrades to emitting nothing. + +Known constraints, all inherent to print mode: + +- `agy` always runs with `--dangerously-skip-permissions` because print mode cannot prompt. + Approvals instead come from the `PreToolUse` hook, which blocks the tool until T3 Code + answers — a `deny` decision genuinely stops it and is reported back to the model. Controlled + by the `requireToolApproval` setting, on by default; it fails closed on timeout, a lost + bridge, or any hook failure. Turn it off to let tools run unattended. +- Attachments are passed by path, not inline: the adapter sends `resource_link` blocks for + files the attachment store already wrote to disk, and the bridge names those paths in the + prompt and adds their directory to the workspace. `agy` has no attachment flag, so inline + image capability stays off. +- No session modes, and no provider-side rollback — a resumed conversation keeps its full + trajectory, so `rollbackThread` fails rather than silently truncating local state only. +- `--model` is a per-spawn flag that composes with `--conversation`, so a model switch keeps + the trajectory and applies from the next turn (`sessionModelSwitch: "in-session"`). +- Any `agy` subcommand spawned for a probe must set `stdin: "ignore"`. `agy` starts a language + server and will not emit output while stdin stays open, so the default `"pipe"` hangs it. + ## Reference Repos - Open-source Codex repo: https://github.com/openai/codex diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..f85a769409b 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; +import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -76,6 +77,19 @@ export function resolveAttachmentPath(input: { }); } +/** + * `file:` URL for a resolved attachment path. + * + * Providers that hand attachments to an external agent by reference need a URI + * rather than a bare path. `pathToFileURL` is used rather than string + * concatenation because it escapes `#` and `?` — which would otherwise + * truncate the path on the way back through `fileURLToPath` — and handles + * Windows drive letters. + */ +export function attachmentFileUrl(attachmentPath: string): string { + return NodeURL.pathToFileURL(attachmentPath).href; +} + export function resolveAttachmentPathById(input: { readonly attachmentsDir: string; readonly attachmentId: string; diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index a7767b50a15..3d08d53c963 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -7,6 +7,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; +import { agyAcpCommand, agyHookCommand } from "./cli/agy.ts"; import { authCommand } from "./cli/auth.ts"; import { connectCommand } from "./cli/connect.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -49,6 +50,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + agyAcpCommand, + agyHookCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/agy.ts b/apps/server/src/cli/agy.ts new file mode 100644 index 00000000000..78f10196eba --- /dev/null +++ b/apps/server/src/cli/agy.ts @@ -0,0 +1,31 @@ +/** + * Hidden CLI entry points for the Antigravity ACP bridge. + * + * Both are internal plumbing rather than user-facing commands: the provider + * spawns `t3 agy-acp` as an ACP agent, and that bridge registers `t3 agy-hook + * ` as Antigravity's tool-lifecycle hook. Shipping them as subcommands + * of the server binary keeps the bridge inside the same bundle. + * + * @module cli/agy + */ +import * as Effect from "effect/Effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { runAgyBridge, runAgyHook } from "../provider/acp/antigravity/agyBridge.ts"; + +export const agyAcpCommand = Command.make("agy-acp").pipe( + Command.withDescription("Run the Antigravity ACP compatibility bridge over stdio."), + Command.withHidden, + Command.withHandler(() => Effect.promise(() => runAgyBridge())), +); + +export const agyHookCommand = Command.make("agy-hook", { + event: Flag.string("event").pipe( + Flag.withDescription("Antigravity hook event name."), + Flag.withDefault("unknown"), + ), +}).pipe( + Command.withDescription("Handle one Antigravity tool-lifecycle hook event."), + Command.withHidden, + Command.withHandler(({ event }) => Effect.promise(() => runAgyHook(event))), +); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts new file mode 100644 index 00000000000..ddc8a7c8c90 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -0,0 +1,177 @@ +/** + * AntigravityDriver — built-in driver for the Antigravity CLI (`agy`). + * + * Sessions run through T3 Code's bundled ACP bridge rather than a native agent + * protocol; see `provider/acp/antigravity/agyBridge.ts`. + * + * @module Drivers/AntigravityDriver + */ +import { AntigravitySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAntigravityTextGeneration } from "../../textGeneration/AntigravityTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts"; +import { + buildInitialAntigravityProviderSnapshot, + checkAntigravityProviderStatus, + enrichAntigravitySnapshot, +} from "../Layers/AntigravityProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAntigravitySettings = Schema.decodeSync(AntigravitySettings); + +const DRIVER_KIND = ProviderDriverKind.make("antigravity"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +// Antigravity ships its own installer and updater (`agy update`), so T3 Code +// never manages the binary itself. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type AntigravityDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const AntigravityDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Antigravity", + supportsMultipleInstances: true, + }, + configSchema: AntigravitySettings, + defaultConfig: (): AntigravitySettings => decodeAntigravitySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AntigravitySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeAntigravityAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeAntigravityTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkAntigravityProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAntigravityProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichAntigravitySnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Antigravity snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts new file mode 100644 index 00000000000..3534e866b15 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -0,0 +1,1009 @@ +/** + * AntigravityAdapterLive — Antigravity CLI (`agy`) via the bundled ACP bridge. + * + * Antigravity has no native agent protocol, so the ACP peer this adapter talks + * to is T3 Code's own bridge (`t3 agy-acp`). That shapes three deliberate + * differences from the CLI-native ACP adapters: + * + * - **Approvals come from the hook, not the CLI.** `agy` always runs with + * `--dangerously-skip-permissions` because print mode cannot prompt. When + * `requireToolApproval` is set, the bridge's `PreToolUse` hook becomes the + * gate instead: it blocks the tool until this adapter answers, and a + * denial is reported back to the model. On by default: without it nothing + * stands between the model and an auto-approved tool. + * - **No session modes.** Print mode has no plan/ask distinction to switch. + * - **Attachments travel by reference.** `agy --print` has no attachment + * flag, so files are sent as `resource_link` blocks and the bridge stages + * them into a directory it grants the CLI access to for that turn. + * - **Model changes apply from the next turn.** `--model` is a per-spawn + * flag that composes with `--conversation`, so a switch keeps the + * trajectory rather than needing a new session. + * + * @module AntigravityAdapterLive + */ + +import { + type AntigravitySettings, + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { attachmentFileUrl, resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + makeAntigravityAcpRuntime, + resolveAntigravityBaseModelId, +} from "../acp/AntigravityAcpSupport.ts"; +import { type AntigravityAdapterShape } from "../Services/AntigravityAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("antigravity"); +/** + * How long this side waits for a tool-approval answer. + * + * Deliberately longer than the bridge's own wait: the bridge denies first and + * the blocked hook is released by that, so this only exists to stop an + * unanswered request pinning its entry and its UI prompt forever. + */ +const APPROVAL_WAIT_MS = 11 * 60 * 1000; +const ANTIGRAVITY_RESUME_VERSION = 1 as const; + +export interface AntigravityAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`antigravity`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver, used by tests that mutate + * `ServerSettingsService` mid-flight. Production leaves this undefined and + * relies on the hydration layer rebuilding the adapter on config change. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface AntigravitySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeTurnId: TurnId | undefined; + /** Model the bridge will pass to `agy --model` on the next turn. */ + currentModelId: string | undefined; + /** + * Bumped by every interrupt. A prompt records this when it is accepted and + * rechecks it immediately before submitting: the ACP runtime serializes + * prompts behind a semaphore, so a steer can still be waiting there when + * Stop is pressed and would otherwise reach the bridge *after* the cancel, + * capture the post-cancel state, and run anyway. + */ + cancelEpoch: number; + /** + * Turns for which `turn.started` has actually been published. Shared rather + * than per-call, so a steer cannot assume the prompt it folded into already + * announced the turn — that prompt may have failed during preflight. + */ + readonly startedTurnIds: Set; + /** + * Number of prompts in flight. >0 means a turn is running, so a new + * sendTurn steers the existing turn rather than opening a new one. + */ + promptsInFlight: number; + stopped: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseAntigravityResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== ANTIGRAVITY_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +/** + * Reasoning effort for `agy --effort`, read from the model option selections. + * Antigravity accepts only low/medium/high. + */ +function resolveEffortSelection( + options: + | ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> + | null + | undefined, +): string | undefined { + // Option values are a string/boolean union across providers; only a string + // effort is meaningful here. + const raw = options?.find((option) => option.id === "effort")?.value; + const effort = typeof raw === "string" ? raw.trim().toLowerCase() : undefined; + return effort === "low" || effort === "medium" || effort === "high" ? effort : undefined; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + return request.options.find((entry) => entry.kind === kind)?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +export function makeAntigravityAdapter( + antigravitySettings: AntigravitySettings, + options?: AntigravityAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("antigravity"); + const serverConfig = yield* Effect.service(ServerConfig); + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + /** + * Approvals awaiting a user decision, per thread. Held outside the session + * context because the permission callback is registered before the context + * exists, and `respondToRequest` resolves entries from a different call. + */ + const approvalsByThread = new Map>(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Antigravity runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Antigravity ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: AntigravitySessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + // Anything still waiting on a human is cancelled, which the bridge + // turns into a denial. Left pending, the hook would block its tool + // until its own timeout with no one able to answer. + const pending = approvalsByThread.get(ctx.threadId); + if (pending) { + for (const [, approval] of pending) { + // Ignored: an answer racing session stop may have settled this + // already, and that must not abort the rest of teardown. + yield* Effect.ignore(Deferred.succeed(approval.decision, "cancel")); + } + approvalsByThread.delete(ctx.threadId); + } + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: AntigravityAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: AntigravitySessionContext; + + const resumeSessionId = parseAntigravityResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const effectiveSettings = options?.resolveSettings + ? yield* options.resolveSettings + : antigravitySettings; + + // The model and effort are bound when the bridge spawns `agy`, so + // they must be resolved before the runtime is constructed rather + // than applied afterwards via session config. + const boundModel = resolveAntigravityBaseModelId(modelSelection?.model); + const boundEffort = resolveEffortSelection(modelSelection?.options); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeAntigravityAcpRuntime({ + antigravitySettings: effectiveSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + model: boundModel, + ...(boundEffort ? { effort: boundEffort } : {}), + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [{ name: "Authorization", value: mcpSession.authorizationHeader }], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + // With approvals enabled the bridge blocks each tool on this callback, + // so it must be registered before `start()` — the first tool call can + // arrive as soon as the first turn begins. + // Not published to `approvalsByThread` yet: if startup fails there is + // no session context for teardown to clean up, and repeated failures + // across thread ids would grow the map. Registered after `start()`. + const pendingApprovals = new Map(); + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + if (input.runtimeMode === "full-access") { + const autoApproved = selectAutoApprovedPermissionOption(params); + if (autoApproved !== undefined) { + return { outcome: { outcome: "selected" as const, optionId: autoApproved } }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const decision = yield* Deferred.make(); + const turnId = sessions.get(input.threadId)?.activeTurnId; + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + detail: permissionRequest.detail ?? "Antigravity tool call", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + // Raced against a deadline and cleaned up unconditionally: the + // bridge forgets its side of a timed-out request, so without + // this the handler would stay blocked and the entry retained. + const answered = yield* Deferred.await(decision).pipe( + Effect.timeoutOption(APPROVAL_WAIT_MS), + Effect.ensuring(Effect.sync(() => pendingApprovals.delete(requestId))), + ); + // An unanswered request denies, matching the bridge's own + // timeout: this gate must never resolve to "allow" by default. + const resolved: ProviderApprovalDecision = Option.isSome(answered) + ? answered.value + : "cancel"; + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + decision: resolved, + }), + ); + const optionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: optionId + ? { outcome: "selected" as const, optionId } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + + const started = yield* acp + .start() + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: boundModel, + threadId: input.threadId, + resumeCursor: { + schemaVersion: ANTIGRAVITY_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + turns: [], + activeTurnId: undefined, + currentModelId: boundModel, + cancelEpoch: 0, + startedTurnIds: new Set(), + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + // The bridge never changes modes; print mode has none. + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Antigravity runtime notification.", { cause }), + ), + Effect.forkChild, + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + // Published only now: teardown finds pending approvals through the + // session context, so registering earlier would leak the entry if + // startup were interrupted before the session existed. + approvalsByThread.set(input.threadId, pendingApprovals); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Antigravity ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: AntigravityAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + + // `agy --print` takes a single text prompt, so attachments travel as + // `resource_link` blocks (ACP baseline, no capability needed) pointing + // at the files the attachment store already wrote to disk. The bridge + // renders those paths into the prompt and grants `agy` read access to + // stages just those files into a per-turn directory it can grant `agy` + // access to. Nothing is re-encoded. + // + // This runs before any `turn.started` is offered — a rejected prompt + // that had already announced a turn would leave the UI with one that + // never completes. + const text = input.input?.trim(); + const attachmentParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + return { + type: "resource_link", + // `pathToFileURL` rather than hand-built escaping: it handles + // Windows drive letters and escapes `#`/`?`, which would + // otherwise truncate the path when the bridge parses it back. + uri: attachmentFileUrl(attachmentPath), + name: path.basename(attachmentPath), + ...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}), + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...attachmentParts, + ]; + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + // A sendTurn while a prompt is in flight is a steer: the new prompt + // folds into the ongoing work, so the active turn id is reused. + // + // The id is minted first, before anything is read, so that reading + // `promptsInFlight` and claiming it are one synchronous step. Yielding + // between the two — which generating the id here used to do — let two + // concurrent calls both observe zero and open rival turns over the + // same thread. + const freshTurnId = TurnId.make(yield* randomUUIDv4); + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? freshTurnId; + const acceptedEpoch = ctx.cancelEpoch; + // Claimed together, without an intervening yield: assigning the active + // turn id later let a concurrent call see `promptsInFlight > 0` with no + // id yet and open a rival turn. + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + // Terminal-event bookkeeping. Publication happens in exactly one place + // (the teardown below) so that the decision cannot race a steer. + let stopReason: string | null = null; + let promptSucceeded = false; + + return yield* Effect.gen(function* () { + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + // `agy` binds the model with a `--model` flag on each spawn, and that + // flag composes with `--conversation` — verified against the CLI: a + // resumed conversation answers on the new model with its history + // intact. Applied before `turn.started` so the announced model is the + // one the turn actually runs on. + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedModelId = turnModelSelection?.model + ? resolveAntigravityBaseModelId(turnModelSelection.model) + : undefined; + if (requestedModelId !== undefined && requestedModelId !== ctx.currentModelId) { + yield* ctx.acp + .setSessionModel(requestedModelId) + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + ), + ); + ctx.currentModelId = requestedModelId; + ctx.session = { ...ctx.session, model: turnModelSelection?.model ?? ctx.session.model }; + } + + // Claimed from shared state, not from "am I a steer": the prompt this + // one folded into may have failed before it announced anything, and + // emitting content for a turn that never started strands the UI. + if (!ctx.startedTurnIds.has(turnId)) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, + }); + // Marked only after the event is actually out, so a failed publish + // cannot let another prompt emit a completion for a turn nobody + // saw start. + ctx.startedTurnIds.add(turnId); + } + // Rechecked here rather than only at accept time: everything above + // yields, and an interrupt during any of it means this prompt must + // not reach the agent. + if (ctx.cancelEpoch !== acceptedEpoch) { + stopReason = "cancelled"; + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + const result = yield* ctx.acp + .prompt({ prompt: promptParts }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + promptSucceeded = true; + stopReason = result.stopReason ?? null; + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + // Decrement and the last-prompt test are one synchronous step, so + // a steer arriving mid-settlement cannot make two prompts both + // believe they own the turn and publish a terminal event each. + const remaining = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remaining; + if (remaining > 0) { + return; + } + // Cleared even when the turn never started — a model switch can + // fail after `activeTurnId` is installed, and leaving it set + // advertises a turn to `listSessions` and the reaper that no + // longer exists. Only the event below depends on having started. + if (ctx.activeTurnId === turnId) { + ctx.activeTurnId = undefined; + } + // Read from shared state rather than this call's local flag: the + // prompt that published `turn.started` may not be the one that + // settles the turn, and the settler still owes the completion. + const published = ctx.startedTurnIds.has(turnId); + ctx.startedTurnIds.delete(turnId); + // The public session field is what `listSessions` and the reaper + // read, so leaving it set would advertise a turn that has ended. + if (ctx.session.activeTurnId === turnId) { + const { activeTurnId: _endedTurnId, ...endedSession } = ctx.session; + ctx.session = { ...endedSession, status: "ready", updatedAt: yield* nowIso }; + } + // A prompt that failed or was interrupted after `turn.started` + // still owes consumers a terminal event; without one the turn + // renders as running forever even though sendTurn already + // returned an error. + if (!published) { + return; + } + const state = + stopReason === "cancelled" ? "cancelled" : promptSucceeded ? "completed" : "failed"; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state, stopReason }, + }); + // `catchCause` rather than `catch`: a defect while stamping or + // publishing would otherwise escape after `promptsInFlight` was + // already decremented, stranding the turn as running. + }).pipe(Effect.catchCause(() => Effect.void)), + ), + ); + }); + + const interruptTurn: AntigravityAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + // Recorded before anything else: prompts already accepted but still + // queued upstream compare against this and refuse to submit. + ctx.cancelEpoch += 1; + // Settled before the cancel goes out, as ACP requires: an outstanding + // permission request has a hook process blocked behind it, and the + // bridge turns "cancel" into a denial. Leaving it pending would hold + // that tool until the hook's own timeout. + const pending = approvalsByThread.get(threadId); + if (pending) { + for (const [, approval] of pending) { + yield* Effect.ignore(Deferred.succeed(approval.decision, "cancel")); + } + pending.clear(); + } + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + // `agy` itself always runs with permissions skipped — print mode cannot + // prompt — so when approvals are enabled the bridge's PreToolUse hook is + // the gate, and this resolves the decision it is blocked on. + const respondToRequest: AntigravityAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + yield* requireSession(threadId); + const pending = approvalsByThread.get(threadId)?.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: AntigravityAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + ) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/user_input", + detail: `Antigravity print mode cannot ask questions; unknown request: ${requestId}`, + }); + }); + + const readThread: AntigravityAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: AntigravityAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + // Truncating the local turn list would report success while leaving the + // Antigravity trajectory untouched: the next turn resumes the same + // `--conversation` and still sees the rolled-back exchanges, so the + // model would answer from history the user believes is gone. Print mode + // exposes no rewind primitive, so this fails loudly instead. + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Antigravity conversations do not support provider-side rollback.", + }); + }); + + const stopSession: AntigravityAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: AntigravityAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: AntigravityAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: AntigravityAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Antigravity session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + // `agy --model` is applied when the bridge spawns the CLI, so switching + // models mid-session is not possible. + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies AntigravityAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts new file mode 100644 index 00000000000..3a6ad6ac10d --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -0,0 +1,328 @@ +/** + * AntigravityProvider — health probe and model discovery for the Antigravity + * CLI (`agy`). + * + * Model discovery calls `agy models` directly rather than starting an ACP + * session as the Grok provider does: Antigravity has no agent protocol of its + * own, so spinning up the bridge just to enumerate models would spawn a + * print-mode process for no reason. + * + * @module AntigravityProvider + */ +import { + type AntigravitySettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { parseAntigravityModelList } from "../acp/AntigravityAcpSupport.ts"; + +const ANTIGRAVITY_PRESENTATION = { + displayName: "Antigravity", + badgeLabel: "Experimental", + showInteractionModeToggle: false, + // `--model` is a per-spawn flag that composes with `--conversation`, so the + // bridge applies a switch to the next turn without losing the trajectory. + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +/** + * Fallback list used before discovery completes or when `agy models` fails. + * Discovery replaces this with whatever the installed CLI actually offers. + */ +const ANTIGRAVITY_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro (High)", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +/** + * Turn an Antigravity model slug into a display name. + * + * Slugs encode the reasoning tier as a trailing `-high` / `-medium` / `-low`, + * which reads better as a parenthetical suffix. + */ +export function formatAntigravityModelName(slug: string): string { + const tierMatch = /^(.*)-(high|medium|low)$/.exec(slug); + const base = tierMatch?.[1] ?? slug; + const tier = tierMatch?.[2]; + // Vendor initialisms read wrong under plain title-casing. + const initialisms: Record = { gpt: "GPT", oss: "OSS", ai: "AI" }; + const pretty = base + .split("-") + .map((word) => { + const lower = word.toLowerCase(); + if (initialisms[lower]) { + return initialisms[lower]; + } + // Leave version and size fragments (3.1, 120b) as written. + return /^\d/.test(word) ? word : `${word.charAt(0).toUpperCase()}${word.slice(1)}`; + }) + .join(" "); + return tier ? `${pretty} (${tier.charAt(0).toUpperCase()}${tier.slice(1)})` : pretty; +} + +function antigravityModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = ANTIGRAVITY_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +export function buildInitialAntigravityProviderSnapshot( + settings: AntigravitySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = antigravityModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Antigravity CLI availability...", + }, + }); + }); +} + +const runAgyCommand = ( + settings: AntigravitySettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "agy"; + const spawnCommand = yield* resolveSpawnCommand(command, [...args], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + // `agy` starts a language server and will not emit `models` output + // until stdin closes. The default "pipe" leaves it open forever, so + // the probe would hang and time out with an empty list. + stdin: "ignore", + }), + ); + }); + +const discoverAntigravityModels = ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.map(runAgyCommand(settings, ["models"], environment), (output) => + output.code === 0 + ? parseAntigravityModelList(output.stdout).map( + (slug): ServerProviderModel => ({ + slug, + name: formatAntigravityModelName(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }), + ) + : [], + ); + +export const checkAntigravityProviderStatus = Effect.fn("checkAntigravityProviderStatus")( + function* ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, + ): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = antigravityModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runAgyCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Antigravity CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Antigravity CLI (`agy`) is not installed or not on PATH." + : "Failed to execute Antigravity CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but timed out while running `agy --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Antigravity CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + }); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but failed to run.", + }, + }); + } + + const discoveryResult = yield* discoverAntigravityModels(settings, environment).pipe( + Effect.timeoutOption(MODEL_DISCOVERY_TIMEOUT_MS), + Effect.result, + ); + const discoveredModels = + Result.isSuccess(discoveryResult) && Option.isSome(discoveryResult.success) + ? discoveryResult.success.value + : []; + if (discoveredModels.length === 0) { + // A CLI that runs but lists no models is almost always an unfinished + // Google sign-in, which `agy models` reports by printing nothing. + yield* Effect.logWarning("Antigravity model discovery returned no models."); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but listed no models. Run `agy` to sign in.", + }, + }); + } + + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: antigravityModelsFromSettings(settings.customModels, discoveredModels), + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); + }, +); + +export const enrichAntigravitySnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Antigravity version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f00d3cc662..8428357600d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1402,6 +1402,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + antigravity: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1513,6 +1514,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + antigravity: { enabled: false }, opencode: { enabled: false }, }, }), @@ -1626,6 +1628,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + antigravity: { enabled: false }, opencode: { enabled: false }, }, providerInstances: { @@ -1759,6 +1762,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "antigravity", "claudeAgent", "codex", "cursor", diff --git a/apps/server/src/provider/Services/AntigravityAdapter.ts b/apps/server/src/provider/Services/AntigravityAdapter.ts new file mode 100644 index 00000000000..10171fd2ff5 --- /dev/null +++ b/apps/server/src/provider/Services/AntigravityAdapter.ts @@ -0,0 +1,16 @@ +/** + * AntigravityAdapter — shape type for the Antigravity provider adapter. + * + * The driver model ({@link ../Drivers/AntigravityDriver}) bundles one adapter + * per instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module AntigravityAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * AntigravityAdapterShape — per-instance Antigravity adapter contract. + */ +export interface AntigravityAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts new file mode 100644 index 00000000000..9db6fbdaedc --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildAntigravityAcpSpawnInput, + parseAntigravityModelList, + resolveAntigravityBaseModelId, +} from "./AntigravityAcpSupport.ts"; +import { formatAntigravityModelName } from "../Layers/AntigravityProvider.ts"; + +describe("parseAntigravityModelList", () => { + it("parses the one-slug-per-line output of `agy models`", () => { + const models = parseAntigravityModelList( + "gemini-3.6-flash-high\ngemini-3.1-pro-high\nclaude-sonnet-4-6\n", + ); + + expect(models).toEqual(["gemini-3.6-flash-high", "gemini-3.1-pro-high", "claude-sonnet-4-6"]); + }); + + it("drops blank lines, duplicates, and prose", () => { + const models = parseAntigravityModelList( + "\ngemini-3.1-pro-high\n\ngemini-3.1-pro-high\nAvailable agents:\n gpt-oss-120b-medium \n", + ); + + expect(models).toEqual(["gemini-3.1-pro-high", "gpt-oss-120b-medium"]); + }); +}); + +describe("resolveAntigravityBaseModelId", () => { + it("resolves a bare family name to a concrete reasoning tier", () => { + expect(resolveAntigravityBaseModelId("gemini-3.1-pro")).toBe("gemini-3.1-pro-high"); + expect(resolveAntigravityBaseModelId("flash")).toBe("gemini-3.6-flash-medium"); + }); + + it("passes through a slug that already names a tier", () => { + expect(resolveAntigravityBaseModelId("gemini-3.6-flash-low")).toBe("gemini-3.6-flash-low"); + }); + + it("falls back to the default when nothing is selected", () => { + expect(resolveAntigravityBaseModelId(undefined)).toBe("gemini-3.1-pro-high"); + expect(resolveAntigravityBaseModelId(" ")).toBe("gemini-3.1-pro-high"); + }); +}); + +describe("buildAntigravityAcpSpawnInput", () => { + it("spawns this server binary's bridge subcommand rather than agy directly", () => { + const spawn = buildAntigravityAcpSpawnInput({ + antigravitySettings: null, + cwd: "/repo", + }); + + expect(spawn.command).toBe(process.execPath); + expect(spawn.args.at(-1)).toBe("agy-acp"); + expect(spawn.cwd).toBe("/repo"); + }); + + it("passes per-turn configuration to the bridge through the environment", () => { + const spawn = buildAntigravityAcpSpawnInput({ + antigravitySettings: { + binaryPath: "/opt/agy", + printTimeout: "30m", + appDataDir: "/data/agy", + } as never, + cwd: "/repo", + model: "gemini-3.1-pro-high", + effort: "high", + }); + + expect(spawn.env).toMatchObject({ + T3_AGY_COMMAND: "/opt/agy", + T3_AGY_PRINT_TIMEOUT: "30m", + T3_AGY_APP_DATA_DIR: "/data/agy", + T3_AGY_MODEL: "gemini-3.1-pro-high", + T3_AGY_EFFORT: "high", + }); + }); + + it("omits unset settings so the bridge keeps its own defaults", () => { + const spawn = buildAntigravityAcpSpawnInput({ + antigravitySettings: { binaryPath: "", printTimeout: "", appDataDir: "" } as never, + cwd: "/repo", + }); + + expect(spawn.env).not.toHaveProperty("T3_AGY_COMMAND"); + expect(spawn.env).not.toHaveProperty("T3_AGY_PRINT_TIMEOUT"); + expect(spawn.env).not.toHaveProperty("T3_AGY_MODEL"); + }); +}); + +describe("formatAntigravityModelName", () => { + it("renders the trailing reasoning tier as a suffix", () => { + expect(formatAntigravityModelName("gemini-3.1-pro-high")).toBe("Gemini 3.1 Pro (High)"); + expect(formatAntigravityModelName("gemini-3.6-flash-low")).toBe("Gemini 3.6 Flash (Low)"); + }); + + it("leaves a slug with no tier suffix alone", () => { + expect(formatAntigravityModelName("claude-sonnet-4-6")).toBe("Claude Sonnet 4 6"); + }); + + it("keeps vendor initialisms uppercase", () => { + expect(formatAntigravityModelName("gpt-oss-120b-medium")).toBe("GPT OSS 120b (Medium)"); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts new file mode 100644 index 00000000000..a041e23bb1d --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -0,0 +1,146 @@ +/** + * Spawn wiring for the Antigravity provider. + * + * Unlike Cursor and Grok — whose CLIs speak ACP natively — Antigravity has no + * agent protocol, so the "ACP agent" spawned here is T3 Code's own bridge + * (`t3 agy-acp`, see `antigravity/agyBridge.ts`). Running it as a subcommand of + * this same binary means the bridge always ships and versions with the server. + * + * @module provider/acp/AntigravityAcpSupport + */ +import { type AntigravitySettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); + +/** Antigravity manages its own Google sign-in, so there is nothing to select. */ +const ANTIGRAVITY_AUTH_METHOD = "none"; + +export const DEFAULT_ANTIGRAVITY_MODEL = "gemini-3.1-pro-high"; + +type AntigravityAcpRuntimeSettings = Pick< + AntigravitySettings, + "binaryPath" | "printTimeout" | "appDataDir" | "requireToolApproval" +>; + +interface AntigravityAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly antigravitySettings: AntigravityAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + /** Model the bridge should pass to `agy --model` for this session. */ + readonly model?: string | undefined; + /** Reasoning effort for `agy --effort`. */ + readonly effort?: string | undefined; +} + +/** + * Resolve how to re-invoke this server binary. + * + * Under `node src/bin.ts` the entry script must be passed explicitly; a packed + * single-file build is itself the executable. + */ +export function resolveBridgeCommand(): { command: string; args: ReadonlyArray } { + const entry = process.argv[1]; + return entry + ? { command: process.execPath, args: [entry, "agy-acp"] } + : { command: process.execPath, args: ["agy-acp"] }; +} + +export function buildAntigravityAcpSpawnInput(input: { + readonly antigravitySettings: AntigravityAcpRuntimeSettings | null | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + readonly model?: string | undefined; + readonly effort?: string | undefined; +}): AcpSessionRuntime.AcpSpawnInput { + const { command, args } = resolveBridgeCommand(); + const settings = input.antigravitySettings; + // The bridge reads its per-turn configuration from the environment so that + // `agy` invocation details stay entirely inside the bridge process. + const env: NodeJS.ProcessEnv = { ...input.environment }; + if (settings?.binaryPath?.trim()) { + env["T3_AGY_COMMAND"] = settings.binaryPath.trim(); + } + if (settings?.printTimeout?.trim()) { + env["T3_AGY_PRINT_TIMEOUT"] = settings.printTimeout.trim(); + } + if (settings?.appDataDir?.trim()) { + env["T3_AGY_APP_DATA_DIR"] = settings.appDataDir.trim(); + } + if (settings?.requireToolApproval) { + env["T3_AGY_REQUIRE_APPROVAL"] = "1"; + } + if (input.model?.trim()) { + env["T3_AGY_MODEL"] = input.model.trim(); + } + if (input.effort?.trim()) { + env["T3_AGY_EFFORT"] = input.effort.trim(); + } + + return { command, args: [...args], cwd: input.cwd, env }; +} + +export const makeAntigravityAcpRuntime = ( + input: AntigravityAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildAntigravityAcpSpawnInput({ + antigravitySettings: input.antigravitySettings, + cwd: input.cwd, + ...(input.environment ? { environment: input.environment } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.effort ? { effort: input.effort } : {}), + }), + authMethodId: ANTIGRAVITY_AUTH_METHOD, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveAntigravityBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ANTIGRAVITY_MODEL; + return normalizeModelSlug(base, ANTIGRAVITY_DRIVER_KIND) ?? DEFAULT_ANTIGRAVITY_MODEL; +} + +/** + * Parse `agy models` output. + * + * The command prints one slug per line with no header or decoration. + */ +export function parseAntigravityModelList(output: string): ReadonlyArray { + const seen = new Set(); + for (const line of output.split("\n")) { + const slug = line.trim(); + if (slug.length === 0 || slug.includes(" ")) { + continue; + } + seen.add(slug); + } + return [...seen]; +} diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts new file mode 100644 index 00000000000..8ceae275986 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -0,0 +1,1335 @@ +/** + * ACP compatibility bridge for the Antigravity CLI (`agy`). + * + * Antigravity exposes no native agent protocol. This module speaks the slice + * of ACP that `AcpSessionRuntime` uses over stdio and executes each turn + * through Antigravity's documented non-interactive `--print` mode, while + * reconstructing a live event stream from hooks and the trajectory transcript + * (see `agyEvents.ts` and `agyTranscript.ts`). + * + * Runs as a subcommand of the server binary (`t3 agy-acp`) so it ships inside + * the same bundle rather than as a loose script. + * + * @module provider/acp/antigravity/agyBridge + */ +// @effect-diagnostics nodeBuiltinImport:off - Standalone stdio bridge process, not an Effect runtime. +// @effect-diagnostics globalTimers:off - Polls Antigravity hook output outside any Effect runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeStringDecoder from "node:string_decoder"; +import * as NodeURL from "node:url"; + +import packageJson from "../../../../package.json" with { type: "json" }; +import { + agyHookResponse, + approvalOutcomeToDecision, + agyTargetPath, + agyToolCallId, + agyToolKind, + agyToolTitle, + hookSessionUpdate, + makeAgyTurnState, + type AgyHookDecision, + type AgyHookEvent, + type AgyHookPayload, + type AgySessionUpdate, + type AgyTurnState, +} from "./agyEvents.ts"; +import { + AgyTranscriptCursor, + dropPriorTurnRecords, + parseTranscriptLine, + transcriptRecordUpdates, +} from "./agyTranscript.ts"; + +const HOOK_DIR_ENV = "T3_AGY_HOOK_DIR"; +const REQUIRE_APPROVAL_ENV = "T3_AGY_REQUIRE_APPROVAL"; +/** Suffix of the file the bridge writes to answer a waiting `PreToolUse` hook. */ +const DECISION_SUFFIX = ".decision"; +/** + * How long a `PreToolUse` hook blocks waiting for the user. Antigravity's own + * hook timeout is set above this so the deny below is what actually lands, + * rather than the CLI abandoning the hook first. + */ +const APPROVAL_WAIT_MS = 10 * 60 * 1000; +const APPROVAL_HOOK_TIMEOUT_SECONDS = 11 * 60; +const APPROVAL_POLL_INTERVAL_MS = 50; +const HOOK_POLL_INTERVAL_MS = 50; +const DEFAULT_PRINT_TIMEOUT = "2h"; +const HOOKS_KEY = "t3code-antigravity-observer"; + +interface BridgeSession { + readonly cwd: string; + systemPrompt: string | undefined; + conversationId: string | undefined; + /** + * Model and effort for the next turn. Seeded from the spawn environment and + * replaced by `session/set_model`; both are command-line flags on every + * spawn, so a change takes effect on the following turn without disturbing + * the conversation it resumes. + */ + model: string | undefined; + effort: string | undefined; +} + +const sessions = new Map(); + +// ── Session id ⇄ Antigravity conversation id ────────────────────────── +// +// `session/new` must return an id before the first turn runs, which is before +// Antigravity has created a trajectory. The mapping is persisted so a later +// `session/load` — potentially in a fresh bridge process — can still resume +// the right conversation. + +/** + * Where Antigravity keeps a conversation's trajectory. + * + * Layout confirmed against real conversations: + * `/brain//.system_generated/logs/`. + */ +function transcriptDirFor(conversationId: string): string { + const appDataDir = + process.env["T3_AGY_APP_DATA_DIR"]?.trim() || + NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli"); + return NodePath.join(appDataDir, "brain", conversationId, ".system_generated", "logs"); +} + +/** + * Bytes already in a resumed conversation's transcript before this turn runs. + * + * A positive baseline, taken before `agy` is spawned, is what makes "records + * from earlier turns" precise. Inferring the boundary from the last + * `USER_INPUT` record cannot: the file always contains previous ones, so a + * poll landing before the new marker is written would treat an earlier turn's + * output as live. Returns 0 when the file cannot be measured, which falls back + * to the marker heuristic rather than skipping the turn's own output. + */ +function transcriptBaseline(conversationId: string | undefined): number { + if (!conversationId) { + return 0; + } + const dir = transcriptDirFor(conversationId); + for (const name of ["transcript.jsonl", "transcript_full.jsonl"]) { + try { + return NodeFS.statSync(NodePath.join(dir, name)).size; + } catch { + // Try the sibling, then give up. + } + } + return 0; +} + +function stateDirPath(): string { + const appDataDir = + process.env["T3_AGY_APP_DATA_DIR"]?.trim() || + NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli"); + return NodePath.join(appDataDir, "t3code-acp-sessions"); +} + +/** + * One file per session rather than a shared map. + * + * Several bridge processes can finish turns at once, and a shared map means a + * read-modify-write: two writers would each rebuild it from their own stale + * snapshot and the later rename would drop the other's entry, silently losing + * a thread's conversation. Independent files never contend. + * + * Returns undefined for a session id that is not a plain token, since the id + * becomes a filename and is supplied by the client. + */ +function sessionFilePath(sessionId: string): string | undefined { + return /^[A-Za-z0-9_-]{1,128}$/.test(sessionId) + ? NodePath.join(stateDirPath(), `${sessionId}.json`) + : undefined; +} + +function persistConversationId(sessionId: string, conversationId: string): void { + const target = sessionFilePath(sessionId); + if (!target) { + return; + } + try { + NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); + // Written aside and renamed so a reader never sees a partial file. + const staging = `${target}.${process.pid}.${NodeCrypto.randomUUID()}.tmp`; + NodeFS.writeFileSync(staging, JSON.stringify({ conversationId })); + NodeFS.renameSync(staging, target); + } catch { + // Losing the mapping costs conversation continuity on the next resume, + // which is not worth failing a turn over. + } +} + +/** + * Map a bridge session id to the Antigravity conversation it should resume. + * + * The persisted record is the only authority. Bridge session ids are random + * UUIDs themselves, so an id that merely looks like a conversation id is + * indistinguishable from one the bridge minted — falling back to the shape of + * the string would make `session/load` resume a conversation that never + * existed. Returning undefined starts a fresh one, which is recoverable. + */ +function lookupConversationId(sessionId: string): string | undefined { + const target = sessionFilePath(sessionId); + if (!target) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(NodeFS.readFileSync(target, "utf8")); + if (typeof parsed !== "object" || parsed === null) { + return undefined; + } + // Validated rather than cast: the bridge does not exclusively own this + // file, and a non-string value would throw and leave the request that read + // it unanswered. + const value = (parsed as { conversationId?: unknown }).conversationId; + return typeof value === "string" ? value.trim() || undefined : undefined; + } catch { + return undefined; + } +} + +// ── JSON-RPC plumbing ───────────────────────────────────────────────── + +function writeMessage(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function sendResult(id: unknown, result: unknown): void { + writeMessage({ jsonrpc: "2.0", id, result }); +} + +function sendError(id: unknown, code: number, message: string): void { + writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function sendSessionUpdate(sessionId: string, update: AgySessionUpdate): void { + writeMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId, update }, + }); +} + +// ── Outbound requests ───────────────────────────────────────────────── +// +// The bridge is the ACP agent, so asking the client to approve a tool means +// issuing a request in the other direction and correlating the reply. + +let nextOutboundId = 1; +const pendingOutbound = new Map< + number, + { readonly resolve: (value: unknown) => void; readonly reject: (error: Error) => void } +>(); + +function sendRequest(method: string, params: unknown, timeoutMs?: number): Promise { + const id = nextOutboundId; + nextOutboundId += 1; + return new Promise((resolve, reject) => { + pendingOutbound.set(id, { resolve, reject }); + if (timeoutMs !== undefined) { + // A client that never answers would otherwise pin this entry for the + // life of the bridge process. + const timer = setTimeout(() => { + if (pendingOutbound.delete(id)) { + reject(new Error(`${method} timed out`)); + } + }, timeoutMs); + timer.unref?.(); + } + writeMessage({ jsonrpc: "2.0", id, method, params }); + }); +} + +/** + * Settle an outbound request from a client reply. Returns false when the id is + * not one of ours, so the caller can treat the message as a request instead. + */ +function resolveOutbound(message: Record): boolean { + const id = message["id"]; + if (typeof id !== "number" || !pendingOutbound.has(id)) { + return false; + } + const pending = pendingOutbound.get(id); + pendingOutbound.delete(id); + if (!pending) { + return false; + } + const error = message["error"]; + if (isRecord(error)) { + const detail = typeof error["message"] === "string" ? error["message"] : "request failed"; + pending.reject(new Error(detail)); + } else { + pending.resolve(message["result"]); + } + return true; +} + +/** Reject everything still outstanding; used when the client goes away. */ +function failPendingOutbound(reason: string): void { + for (const [, pending] of pendingOutbound) { + pending.reject(new Error(reason)); + } + pendingOutbound.clear(); +} + +// ── Hook observer ───────────────────────────────────────────────────── + +/** + * Hook command the bridge registers with Antigravity. Re-invokes this same + * binary so the observer always matches the running bridge. + */ +function hookCommandFor(event: string): string { + const entry = process.argv[1]; + const base = entry + ? `${quoteArg(process.execPath)} ${quoteArg(entry)}` + : quoteArg(process.execPath); + return `${base} agy-hook --event ${event}`; +} + +function quoteArg(value: string): string { + return /[\s"']/.test(value) ? `"${value.replace(/(["\\$`])/g, "\\$1")}"` : value; +} + +/** + * Build a throwaway workspace directory whose only purpose is carrying + * `.agents/hooks.json`. Antigravity loads `.agents` from every `--add-dir` + * path, so the observer attaches without writing anything into the user's + * repository. + */ +function createHookWorkspace(): string { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hooks-")); + try { + return writeHookWorkspace(dir); + } catch (error) { + // The caller never received the path, so it cannot clean this up itself. + cleanupDir(dir); + throw error; + } +} + +function writeHookWorkspace(dir: string): string { + const agentsDir = NodePath.join(dir, ".agents"); + NodeFS.mkdirSync(agentsDir, { recursive: true }); + // A gating PreToolUse hook blocks while the user decides, so its timeout has + // to outlast the bridge's own wait; observation-only hooks stay short. + const preToolTimeout = approvalRequired() ? APPROVAL_HOOK_TIMEOUT_SECONDS : 10; + const toolHook = (event: string, timeout: number) => [ + { matcher: "*", hooks: [{ type: "command", command: hookCommandFor(event), timeout }] }, + ]; + NodeFS.writeFileSync( + NodePath.join(agentsDir, "hooks.json"), + JSON.stringify( + { + [HOOKS_KEY]: { + PreToolUse: toolHook("pre-tool-use", preToolTimeout), + PostToolUse: toolHook("post-tool-use", 10), + Stop: [{ type: "command", command: hookCommandFor("stop"), timeout: 10 }], + }, + }, + null, + 2, + ), + ); + return dir; +} + +/** A hook event plus the file it came from, which keys its decision reply. */ +interface ObservedHook { + readonly name: string; + readonly event: AgyHookEvent; +} + +function readHookEvents(hookDir: string, seen: Set): ReadonlyArray { + let entries: Array; + try { + entries = NodeFS.readdirSync(hookDir); + } catch { + return []; + } + const events: Array = []; + for (const name of entries.filter((n) => n.endsWith(".json")).sort()) { + if (seen.has(name)) { + continue; + } + seen.add(name); + try { + const raw = NodeFS.readFileSync(NodePath.join(hookDir, name), "utf8"); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null) { + events.push({ name, event: parsed as AgyHookEvent }); + } + } catch { + // A half-written hook file is picked up on the next poll. + seen.delete(name); + } + } + return events; +} + +// ── Tool approval ───────────────────────────────────────────────────── + +const APPROVE_OPTION = "allow"; +const APPROVE_SESSION_OPTION = "allow-session"; +const REJECT_OPTION = "reject"; +/** + * Sessions the user has approved wholesale via "always allow". + * + * Without an `allow_always` option the client maps that choice to no option id + * at all and the reply reads as cancelled — which this gate denies. Offering + * it and honouring it here is what makes the button mean what it says. + */ +const sessionWideApprovals = new Set(); + +function approvalRequired(): boolean { + return process.env[REQUIRE_APPROVAL_ENV]?.trim() === "1"; +} + +function decisionPath(hookDir: string, hookName: string): string { + return NodePath.join(hookDir, `${hookName}${DECISION_SUFFIX}`); +} + +function writeDecision(hookDir: string, hookName: string, decision: AgyHookDecision): void { + try { + const target = decisionPath(hookDir, hookName); + const staging = `${target}.tmp`; + NodeFS.writeFileSync(staging, JSON.stringify(decision)); + NodeFS.renameSync(staging, target); + } catch { + // The waiting hook falls back to denying when its deadline passes, which + // is the safe outcome for a permission gate. + } +} + +/** + * Ask the client to approve one tool call, then hand the answer to the waiting + * hook process through the shared directory. + * + * Fire-and-forget by design: `drain` runs on a timer and must not block the + * transcript reader while a human decides. + */ +function selectedOptionId(result: unknown): string | undefined { + const outcome = isRecord(result) ? result["outcome"] : undefined; + const optionId = isRecord(outcome) ? outcome["optionId"] : undefined; + return typeof optionId === "string" ? optionId : undefined; +} + +function requestToolApproval(input: { + readonly sessionId: string; + readonly hookDir: string; + readonly hookName: string; + readonly payload: AgyHookPayload; +}): void { + // Already approved for the whole session: answer without troubling the user. + if (sessionWideApprovals.has(input.sessionId)) { + writeDecision(input.hookDir, input.hookName, { decision: "allow" }); + return; + } + const toolCall = input.payload.toolCall; + const stepIdx = typeof input.payload.stepIdx === "number" ? input.payload.stepIdx : 0; + void sendRequest( + "session/request_permission", + { + sessionId: input.sessionId, + toolCall: { + toolCallId: agyToolCallId(input.payload.conversationId, stepIdx), + title: agyToolTitle(toolCall), + kind: agyToolKind(toolCall?.name), + status: "pending", + rawInput: toolCall?.args ?? null, + ...(agyTargetPath(toolCall) ? { locations: [{ path: agyTargetPath(toolCall) }] } : {}), + }, + options: [ + { optionId: APPROVE_OPTION, name: "Allow", kind: "allow_once" }, + { + optionId: APPROVE_SESSION_OPTION, + name: "Allow for this session", + kind: "allow_always", + }, + { optionId: REJECT_OPTION, name: "Reject", kind: "reject_once" }, + ], + }, + APPROVAL_WAIT_MS, + ) + .then((result) => { + const decision = approvalOutcomeToDecision(result, [APPROVE_OPTION, APPROVE_SESSION_OPTION]); + if (decision.decision === "allow" && selectedOptionId(result) === APPROVE_SESSION_OPTION) { + sessionWideApprovals.add(input.sessionId); + } + writeDecision(input.hookDir, input.hookName, decision); + }) + .catch(() => { + writeDecision(input.hookDir, input.hookName, { + decision: "deny", + reason: "T3 Code could not obtain approval for this tool call", + }); + }); +} + +/** + * Largest file the hook will inline into its event record. A diff of anything + * bigger is not worth the memory it would cost on both sides. + */ +const MAX_CAPTURED_FILE_BYTES = 2 * 1024 * 1024; + +function captureFileText(path: string | undefined): string | null { + if (!path) { + return null; + } + try { + const stats = NodeFS.statSync(path); + if (!stats.isFile() || stats.size > MAX_CAPTURED_FILE_BYTES) { + return null; + } + return NodeFS.readFileSync(path, "utf8"); + } catch { + // A new file has no prior contents; that is a valid diff with no oldText. + return null; + } +} + +/** Entry point for `t3 agy-hook `. */ +export async function runAgyHook(event: string): Promise { + let raw = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + raw += chunk; + } + + const hookDir = process.env[HOOK_DIR_ENV]?.trim(); + // When approvals are required this process is a security gate, not an + // observer. Any failure below — unparseable payload, unwritable hook + // directory, a bridge that never answers — must deny, because the CLI has + // already been told to skip its own permission prompts and nothing else + // stands between the model and the tool. + const gating = Boolean(hookDir) && approvalRequired() && event === "pre-tool-use"; + if (hookDir) { + try { + const payload = JSON.parse(raw) as AgyHookPayload; + const record: AgyHookEvent = { + event, + payload, + // Snapshot the file here, while the hook still brackets the tool call. + ...(agyToolKind(payload?.toolCall?.name) === "edit" + ? { capturedFileText: captureFileText(agyTargetPath(payload?.toolCall)) } + : {}), + }; + const name = `${process.hrtime.bigint().toString().padStart(24, "0")}-${event}.json`; + // Write then rename so the poller never observes a partial file. + const finalPath = NodePath.join(hookDir, name); + const tempPath = `${finalPath}.tmp`; + NodeFS.writeFileSync(tempPath, JSON.stringify(record)); + NodeFS.renameSync(tempPath, finalPath); + + // With approvals on, this process is the gate: block until the bridge + // reports the user's answer. `deny` here genuinely stops the tool — + // Antigravity reports the reason back to the model. + if (gating) { + // Every gating path answers here. A payload without `toolCall` used to + // fall through to the observation-only response, which allows — so a + // change in Antigravity's payload shape would have run tools unapproved. + const decision = payload?.toolCall + ? await awaitDecision(hookDir, name) + : ({ + decision: "deny", + reason: "T3 Code could not identify this tool call for approval", + } satisfies AgyHookDecision); + process.stdout.write(JSON.stringify(decision)); + return; + } + } catch { + // Observation is best-effort: a hook must never break a tool call — + // unless it is the approval gate, handled below. + if (gating) { + process.stdout.write( + JSON.stringify({ + decision: "deny", + reason: "T3 Code could not record this tool call for approval", + } satisfies AgyHookDecision), + ); + return; + } + } + } + + process.stdout.write(JSON.stringify(agyHookResponse(event, Boolean(hookDir)))); +} + +/** + * Wait for the bridge's answer to one `PreToolUse` hook. + * + * Fails closed: if the bridge dies, the client never answers, or the deadline + * passes, the tool is denied rather than quietly allowed. + */ +async function awaitDecision(hookDir: string, hookName: string): Promise { + const target = decisionPath(hookDir, hookName); + const deadline = Date.now() + APPROVAL_WAIT_MS; + while (Date.now() < deadline) { + try { + const raw = NodeFS.readFileSync(target, "utf8"); + const parsed: unknown = JSON.parse(raw); + if (isRecord(parsed) && (parsed["decision"] === "allow" || parsed["decision"] === "deny")) { + return parsed as unknown as AgyHookDecision; + } + } catch { + // Not written yet, or written partially; try again. + } + await new Promise((resolve) => setTimeout(resolve, APPROVAL_POLL_INTERVAL_MS)); + } + return { decision: "deny", reason: "Timed out waiting for approval in T3 Code" }; +} + +// ── Turn execution ──────────────────────────────────────────────────── + +function buildAgyArgs(input: { + readonly session: BridgeSession; + readonly hookWorkspace: string; + readonly attachmentDir: string | undefined; + readonly promptText: string; +}): Array { + const { session, hookWorkspace, attachmentDir } = input; + const args = [ + "--dangerously-skip-permissions", + "--print-timeout", + process.env["T3_AGY_PRINT_TIMEOUT"]?.trim() || DEFAULT_PRINT_TIMEOUT, + ]; + // Per session, not per process: `--model` applies to the turn being spawned + // and composes with `--conversation`, so the trajectory survives a switch. + const model = session.model?.trim(); + if (model) { + args.push("--model", model); + } + const effort = session.effort?.trim(); + if (effort) { + args.push("--effort", effort); + } + if (session.conversationId) { + args.push("--conversation", session.conversationId); + } + // Print mode does not infer workspace customizations from cwd alone. The + // session workspace is registered so its `.agents` skills and rules load; + // the hook workspace is registered so the observer attaches. + args.push("--add-dir", session.cwd, "--add-dir", hookWorkspace); + if (attachmentDir) { + args.push("--add-dir", attachmentDir); + } + args.push("--print", input.promptText); + return args; +} + +/** A local file referenced by a `resource_link` prompt block. */ +interface PromptAttachment { + readonly path: string; + readonly name: string; + readonly mimeType: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Extract local files from `resource_link` blocks. + * + * Only `file:` URIs are taken: a remote URL is left in the prompt text for the + * agent's own fetch tool, and passing one to `--add-dir` would be meaningless. + */ +function collectAttachments(promptBlocks: ReadonlyArray): { + readonly files: ReadonlyArray; + readonly remoteUris: ReadonlyArray; +} { + const files: Array = []; + const remoteUris: Array = []; + for (const block of promptBlocks) { + if (!isRecord(block) || block["type"] !== "resource_link") { + continue; + } + const uri = block["uri"]; + if (typeof uri !== "string" || uri.length === 0) { + continue; + } + if (!uri.startsWith("file://")) { + // `resource_link` is baseline ACP, so a remote link has to reach the + // agent: it goes into the prompt text for Antigravity's own fetch tool. + remoteUris.push(uri); + continue; + } + let filePath: string; + try { + filePath = NodeURL.fileURLToPath(uri); + } catch { + remoteUris.push(uri); + continue; + } + const name = typeof block["name"] === "string" ? block["name"] : NodePath.basename(filePath); + files.push({ + path: filePath, + name, + mimeType: typeof block["mimeType"] === "string" ? block["mimeType"] : undefined, + }); + } + return { files, remoteUris }; +} + +interface RenderedPrompt { + readonly baseText: string; + readonly attachments: ReadonlyArray; + readonly remoteUris: ReadonlyArray; +} + +function renderPrompt(session: BridgeSession, promptBlocks: unknown): RenderedPrompt | null { + if (!Array.isArray(promptBlocks)) { + return null; + } + const text = promptBlocks + .filter( + (block): block is { type: string; text: string } => + isRecord(block) && block["type"] === "text" && typeof block["text"] === "string", + ) + .map((block) => block.text) + .join("\n\n") + .trim(); + + const { files, remoteUris } = collectAttachments(promptBlocks); + if (text.length === 0 && files.length === 0 && remoteUris.length === 0) { + return null; + } + + const systemPrompt = session.systemPrompt?.trim(); + return { + baseText: systemPrompt ? `System instructions:\n${systemPrompt}\n\nRequest:\n${text}` : text, + attachments: files, + remoteUris, + }; +} + +/** + * Copy this turn's attachments into a throwaway directory. + * + * `agy --print` has no attachment flag, so files must be named by path with + * their directory registered via `--add-dir`. Registering the attachment store + * itself would hand an auto-approving agent read access to every thread's + * uploads, so only the files this turn references are staged, and the staging + * directory dies with the turn. + */ +function stageAttachments(attachments: ReadonlyArray): { + readonly dir: string | undefined; + readonly staged: ReadonlyArray; +} { + if (attachments.length === 0) { + return { dir: undefined, staged: [] }; + } + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-attach-")); + const staged: Array = []; + try { + stageInto(dir, attachments, staged); + } catch (error) { + // Copies that already succeeded are user uploads; leaving them in /tmp + // because a later one failed would outlive the turn that needed them. + cleanupDir(dir); + throw error; + } + return { dir, staged }; +} + +function stageInto( + dir: string, + attachments: ReadonlyArray, + staged: Array, +): void { + attachments.forEach((attachment, index) => { + // Index-prefixed and sanitised so two attachments sharing a basename cannot + // collide and a crafted name cannot escape the staging directory. + const safeName = `${index}-${NodePath.basename(attachment.name).replace(/[^\w.-]+/g, "_")}`; + const target = NodePath.join(dir, safeName); + // Not swallowed: silently dropping an attachment would run the turn + // against a prompt the user did not write, and an attachment-only request + // would become an empty prompt that still reports success. + NodeFS.copyFileSync(attachment.path, target); + staged.push({ path: target, name: safeName, mimeType: attachment.mimeType }); + }); +} + +function composePromptText( + baseText: string, + staged: ReadonlyArray, + remoteUris: ReadonlyArray, +): string { + const blocks: Array = []; + if (staged.length > 0) { + const list = staged.map((a) => `- ${a.path}${a.mimeType ? ` (${a.mimeType})` : ""}`).join("\n"); + blocks.push(`Attached files (read them from these paths):\n${list}`); + } + if (remoteUris.length > 0) { + blocks.push(`Attached links (fetch them):\n${remoteUris.map((u) => `- ${u}`).join("\n")}`); + } + return [baseText, ...blocks].filter((part) => part.length > 0).join("\n\n"); +} + +interface TurnOutcome { + readonly stopReason: "end_turn" | "cancelled"; + readonly failure?: string; +} + +let activeChild: NodeChildProcess.ChildProcess | null = null; +/** Session whose turn is currently running, if any. Gates `session/cancel`. */ +let activeTurnSessionId: string | null = null; +const cancelledSessions = new Set(); +/** + * Cancels seen per session, counted rather than flagged. + * + * A prompt records this value when it is queued. If the count has moved by the + * time the prompt reaches the front, a cancel arrived while it waited and the + * prompt must not run — turns are serialized, so a steer queued behind a long + * turn would otherwise start executing tools after the user pressed Stop. A + * counter rather than a flag is what keeps this from also swallowing a cancel + * that lands harmlessly between two turns. + */ +const cancelGenerations = new Map(); +/** Cancel generation captured when each queued prompt was accepted, by JSON-RPC id. */ +const queuedPromptGenerations = new Map(); + +function cancelGeneration(sessionId: string): number { + return cancelGenerations.get(sessionId) ?? 0; +} + +/** + * Drain everything Antigravity has produced so far and emit it as ACP updates. + * + * Hooks are read first so a tool call is always announced before the + * transcript record carrying its output is matched against it. + */ +function drain(input: { + readonly sessionId: string; + readonly hookDir: string; + readonly seenHooks: Set; + readonly state: AgyTurnState; + readonly cursor: AgyTranscriptCursor; + readonly decoder: NodeStringDecoder.StringDecoder; + readonly transcriptOffset: { value: number }; + readonly assistantText: { emitted: boolean }; + readonly final: boolean; +}): void { + for (const { name, event: hook } of readHookEvents(input.hookDir, input.seenHooks)) { + // Diffing the file contents each hook captured, rather than the arguments + // of the edit, keeps this correct across tools whose argument shapes + // differ (`replace_file_content` sends a fragment, `write_to_file` sends + // the whole file). + const fileText = hook.capturedFileText ?? undefined; + const update = hookSessionUpdate(hook, input.state, fileText); + if (update) { + const stepIdx = hook.payload?.stepIdx; + if ( + hook.event === "post-tool-use" && + typeof stepIdx === "number" && + !input.state.transcriptSeenSteps.has(stepIdx) + ) { + // Held back rather than sent: the transcript record carrying this + // step's real output is usually read later in this same pass, and a + // completed call can no longer take content. Once that record has been + // seen there is nothing left to wait for — and nothing to wait on, + // since the transcript is read once by byte offset. + input.state.pendingTerminal.set(stepIdx, update); + } else { + sendSessionUpdate(input.sessionId, update); + } + } + // The hook process is blocked until a decision file appears, so this has + // to be started for every announced tool call. + // Not during the final drain: the child has already exited, so no tool is + // waiting on the answer and the request would never be settled. + if ( + !input.final && + approvalRequired() && + hook.event === "pre-tool-use" && + hook.payload?.toolCall + ) { + requestToolApproval({ + sessionId: input.sessionId, + hookDir: input.hookDir, + hookName: name, + payload: hook.payload, + }); + } + } + + const transcriptPath = resolveTranscriptPath(input.state); + if (transcriptPath) { + let chunk = ""; + try { + const stats = NodeFS.statSync(transcriptPath); + if (stats.size > input.transcriptOffset.value) { + const fd = NodeFS.openSync(transcriptPath, "r"); + try { + const length = stats.size - input.transcriptOffset.value; + const buffer = Buffer.alloc(length); + NodeFS.readSync(fd, buffer, 0, length, input.transcriptOffset.value); + // Decoded through the turn's streaming decoder, not `toString`: a + // write can land mid-multibyte-character, and decoding each slice + // independently would replace the partial sequence with U+FFFD and + // advance past it, silently corrupting non-ASCII output. + chunk = input.decoder.write(buffer); + input.transcriptOffset.value = stats.size; + } finally { + NodeFS.closeSync(fd); + } + } + } catch { + chunk = ""; + } + + const lines = chunk.length > 0 ? input.cursor.push(chunk) : []; + let allLines = input.final ? [...lines, ...input.cursor.flush()] : lines; + // Reading always starts at byte 0, so the first batch of a resumed + // conversation carries every prior turn. Trim once, then stream. + if (!input.state.transcriptPrimed && allLines.length > 0) { + const trimmed = dropPriorTurnRecords(allLines); + if (trimmed.length === allLines.length && input.state.resumedConversation && !input.final) { + // Resuming, and this batch holds no `USER_INPUT` — so the current + // turn's opening record has not been written yet and everything here + // belongs to a previous turn. Emitting it would replay old output; + // hold off and re-examine once more has been appended. + input.cursor.retain(allLines); + allLines = []; + } else { + allLines = [...trimmed]; + input.state.transcriptPrimed = true; + } + } + for (const line of allLines) { + const record = parseTranscriptLine(line); + if (!record) { + continue; + } + const result = transcriptRecordUpdates(record, input.state); + for (const update of result.updates) { + sendSessionUpdate(input.sessionId, update); + } + if (result.emittedAssistantText) { + input.assistantText.emitted = true; + } + // This step's output is out; the call can be completed now. + const stepIndex = record.step_index; + if (typeof stepIndex === "number") { + flushTerminal(input.sessionId, input.state, stepIndex); + } + } + } + + // Nothing more will arrive, so anything still waiting on a transcript record + // that never came is completed without output rather than left spinning. + if (input.final) { + const remaining = [...input.state.pendingTerminal.values()]; + input.state.pendingTerminal.clear(); + for (const terminal of remaining) { + sendSessionUpdate(input.sessionId, terminal); + } + } +} + +function flushTerminal(sessionId: string, state: AgyTurnState, stepIdx: number): void { + const terminal = state.pendingTerminal.get(stepIdx); + if (!terminal) { + return; + } + state.pendingTerminal.delete(stepIdx); + sendSessionUpdate(sessionId, terminal); +} + +/** + * Hooks report `transcript_full.jsonl`; the sibling `transcript.jsonl` holds + * the same steps without internal model chatter and is the better stream to + * render. + * + * The choice is pinned for the rest of the turn. `transcriptOffset` and the + * line cursor are byte positions into whichever file was picked, so switching + * once the condensed file appears would resume reading at an offset that means + * nothing in the new file — skipping records, or re-emitting ones already + * streamed from the other one. + */ +function resolveTranscriptPath(state: AgyTurnState): string | undefined { + if (state.resolvedTranscriptPath) { + return state.resolvedTranscriptPath; + } + const reported = state.transcriptPath; + if (!reported) { + return undefined; + } + const condensed = reported.replace(/transcript_full\.jsonl$/, "transcript.jsonl"); + state.resolvedTranscriptPath = NodeFS.existsSync(condensed) ? condensed : reported; + return state.resolvedTranscriptPath; +} + +async function runTurn( + sessionId: string, + session: BridgeSession, + prompt: RenderedPrompt, +): Promise { + // A cancel that raced the end of an earlier turn must not decide this one. + cancelledSessions.delete(sessionId); + // Claimed before any setup work: spawning `agy` takes long enough that a + // cancel can land first, and cancels are only honoured for the session + // holding this claim. Leaving it unset until after the spawn would silently + // drop those, letting an auto-approving child run on past a cancelled turn. + activeTurnSessionId = sessionId; + // Measured before `agy` starts: whatever the transcript already holds + // belongs to earlier turns of the conversation being resumed. + const baseline = transcriptBaseline(session.conversationId); + let hookDir: string | undefined; + let hookWorkspace: string | undefined; + let attachmentDir: string | undefined; + let child: NodeChildProcess.ChildProcess; + try { + hookDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hookout-")); + hookWorkspace = createHookWorkspace(); + const attachments = stageAttachments(prompt.attachments); + attachmentDir = attachments.dir; + const command = process.env["T3_AGY_COMMAND"]?.trim() || "agy"; + child = NodeChildProcess.spawn( + command, + buildAgyArgs({ + session, + hookWorkspace, + attachmentDir, + promptText: composePromptText(prompt.baseText, attachments.staged, prompt.remoteUris), + }), + { + cwd: session.cwd, + env: { ...process.env, [HOOK_DIR_ENV]: hookDir }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (error) { + // Setup failed, so no turn is running: release the claim and reclaim the + // directories, or repeated failures would leak one set each time. + activeTurnSessionId = null; + cleanupDir(hookDir); + cleanupDir(hookWorkspace); + cleanupDir(attachmentDir); + throw error; + } + + const state = makeAgyTurnState(session.conversationId); + const seenHooks = new Set(); + const cursor = new AgyTranscriptCursor(); + const decoder = new NodeStringDecoder.StringDecoder("utf8"); + const transcriptOffset = { value: baseline }; + if (baseline > 0) { + // Reading starts past every earlier turn, so there is no prior-turn + // content left for the `USER_INPUT` heuristic to guess at. + state.transcriptPrimed = true; + } + const assistantText = { emitted: false }; + activeChild = child; + // A cancel during startup had no process to signal; deliver it now. + if (cancelledSessions.has(sessionId)) { + child.kill("SIGTERM"); + } + + let stdout = ""; + let stderr = ""; + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + + const poller = setInterval(() => { + drain({ + sessionId, + hookDir, + seenHooks, + state, + cursor, + decoder, + transcriptOffset, + assistantText, + final: false, + }); + }, HOOK_POLL_INTERVAL_MS); + + const exitCode = await new Promise((resolve) => { + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code)); + }); + + clearInterval(poller); + activeChild = null; + activeTurnSessionId = null; + drain({ + sessionId, + hookDir, + seenHooks, + state, + cursor, + decoder, + transcriptOffset, + assistantText, + final: true, + }); + + // Any tool still open at exit would otherwise render as spinning forever. + for (const [, call] of state.toolCalls) { + if (call.completed) { + continue; + } + sendSessionUpdate(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: call.toolCallId, + status: "failed", + rawOutput: { isError: true, error: "Antigravity exited before the tool reported completion" }, + }); + } + state.toolCalls.clear(); + + if (state.conversationId) { + session.conversationId = state.conversationId; + persistConversationId(sessionId, state.conversationId); + } + + cleanupDir(hookDir); + cleanupDir(hookWorkspace); + cleanupDir(attachmentDir); + + if (cancelledSessions.delete(sessionId)) { + return { stopReason: "cancelled" }; + } + if (exitCode !== 0) { + const detail = stderr.trim() || stdout.trim() || `agy exited with code ${exitCode}`; + return { stopReason: "end_turn", failure: detail }; + } + + // The transcript already streamed the assistant text. stdout is only used + // when transcript observation produced nothing, so the reply is never + // duplicated. + if (!assistantText.emitted && stdout.trim().length > 0) { + sendSessionUpdate(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: stdout.trim() }, + }); + } + return { stopReason: "end_turn" }; +} + +function cleanupDir(dir: string | undefined): void { + if (!dir) { + return; + } + try { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } catch { + // Temp directories are reclaimed by the OS. + } +} + +// ── Request dispatch ────────────────────────────────────────────────── + +async function handleRequest(message: Record): Promise { + const method = typeof message["method"] === "string" ? message["method"] : undefined; + const id = message["id"]; + const params = (message["params"] ?? {}) as Record; + + if (!method) { + return; + } + + switch (method) { + case "initialize": { + const requested = + typeof params["protocolVersion"] === "number" ? params["protocolVersion"] : 1; + sendResult(id, { + protocolVersion: Math.min(requested, 1), + agentCapabilities: { + loadSession: true, + // Images ride in as `resource_link` blocks (an ACP baseline type) + // rather than inline base64, so the `image` capability stays off. + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + mcpCapabilities: { http: false, sse: false }, + }, + authMethods: [], + // The bridge has no version of its own; it ships with the server, so + // that is the version worth reporting. The Antigravity CLI version is + // reported separately by the provider snapshot (`agy --version`). + agentInfo: { name: "Antigravity", version: packageJson.version }, + }); + return; + } + // Antigravity manages its own Google sign-in; there is nothing for the + // client to authenticate against, but the handshake still requires a + // successful reply. + case "authenticate": { + sendResult(id, {}); + return; + } + case "session/new": + case "session/load": { + const cwd = typeof params["cwd"] === "string" ? params["cwd"] : ""; + if (!cwd || !NodePath.isAbsolute(cwd)) { + sendError(id, -32602, `${method} requires an absolute cwd`); + return; + } + const requestedSessionId = + typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + const sessionId = + method === "session/load" && requestedSessionId + ? requestedSessionId + : NodeCrypto.randomUUID(); + sessions.set(sessionId, { + cwd, + systemPrompt: + typeof params["systemPrompt"] === "string" ? params["systemPrompt"] : undefined, + conversationId: requestedSessionId ? lookupConversationId(requestedSessionId) : undefined, + model: process.env["T3_AGY_MODEL"]?.trim() || undefined, + effort: process.env["T3_AGY_EFFORT"]?.trim() || undefined, + }); + sendResult(id, method === "session/load" ? {} : { sessionId }); + return; + } + // `--model` is a per-spawn flag that composes with `--conversation`, so a + // switch applies from the next turn while the trajectory carries over. + case "session/set_model": { + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + const session = sessionId ? sessions.get(sessionId) : undefined; + if (!session) { + sendError(id, -32602, "unknown sessionId"); + return; + } + const modelId = typeof params["modelId"] === "string" ? params["modelId"].trim() : ""; + if (modelId.length === 0) { + sendError(id, -32602, "session/set_model requires a modelId"); + return; + } + session.model = modelId; + sendResult(id, {}); + return; + } + case "session/prompt": { + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + const session = sessionId ? sessions.get(sessionId) : undefined; + if (!sessionId || !session) { + sendError(id, -32602, "unknown sessionId"); + return; + } + const queuedAt = queuedPromptGenerations.get(id); + queuedPromptGenerations.delete(id); + if (queuedAt !== undefined && queuedAt !== cancelGeneration(sessionId)) { + // Cancelled while it sat in the queue; never spawn `agy` for it. + sendResult(id, { stopReason: "cancelled" }); + return; + } + const prompt = renderPrompt(session, params["prompt"]); + if (prompt === null) { + sendError(id, -32602, "session/prompt requires at least one text block"); + return; + } + const outcome = await runTurn(sessionId, session, prompt); + if (outcome.failure) { + sendError(id, -32000, `Antigravity turn failed: ${outcome.failure}`); + return; + } + sendResult(id, { stopReason: outcome.stopReason }); + return; + } + case "session/cancel": { + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + if (sessionId) { + // Always recorded, so prompts already queued for this session can see + // that a cancel happened and refuse to start. + cancelGenerations.set(sessionId, cancelGeneration(sessionId) + 1); + // Only a cancel aimed at the turn actually running decides its stop + // reason. Cancels bypass the request queue, so one arriving after a + // turn finished — or targeting an idle session — must not sit in the + // set and mark the next successful turn cancelled. + if (sessionId === activeTurnSessionId) { + cancelledSessions.add(sessionId); + activeChild?.kill("SIGTERM"); + } + } + return; + } + default: { + if (id !== undefined) { + sendError(id, -32601, `method not found: ${method}`); + } + } + } +} + +/** Entry point for `t3 agy-acp`. */ +export async function runAgyBridge(): Promise { + let buffer = ""; + // Requests are handled strictly in order: a turn holds the agent busy, and + // ACP clients do not pipeline prompts for one session. + let queue: Promise = Promise.resolve(); + + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf("\n"); + if (line.trim().length === 0) { + continue; + } + + let message: Record; + try { + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null) { + continue; + } + message = parsed as Record; + } catch { + sendError(null, -32700, "invalid JSON"); + continue; + } + + // A reply to one of the bridge's own requests (tool approval) carries an + // id but no method, and must never enter the request queue — the turn + // holding that queue is exactly what is waiting on the answer. + if (message["method"] === undefined && resolveOutbound(message)) { + continue; + } + + // Cancellation must interrupt an in-flight turn, so it bypasses the + // queue that would otherwise make it wait for that turn to finish. + if (message["method"] === "session/cancel") { + void handleRequest(message); + continue; + } + const pending = message; + // Captured now, not when the turn starts: the gap between the two is + // exactly the window a cancel has to arrive in while this prompt waits. + if (pending["method"] === "session/prompt") { + const target = (pending["params"] as Record | undefined)?.["sessionId"]; + if (typeof target === "string" && pending["id"] !== undefined) { + queuedPromptGenerations.set(pending["id"], cancelGeneration(target)); + } + } + queue = queue + .then(() => handleRequest(pending)) + .catch((error: unknown) => { + // Every request must be answered. Swallowing a handler failure would + // leave the client blocked on a response that never arrives. + if (pending["id"] !== undefined) { + const detail = error instanceof Error ? error.message : String(error); + sendError(pending["id"], -32603, `internal bridge error: ${detail}`); + } + }); + } + } + + // stdin closed: no approval can still be answered, so unblock the hooks + // waiting on them (they fail closed) rather than letting them time out. + failPendingOutbound("T3 Code disconnected before approving this tool call"); + await queue; + activeChild?.kill("SIGTERM"); +} diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts new file mode 100644 index 00000000000..437e2f77f79 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + agyHookResponse, + approvalOutcomeToDecision, + agyTargetPath, + agyToolCallId, + agyToolKind, + agyToolTitle, + hookSessionUpdate, + makeAgyTurnState, + type AgyHookEvent, +} from "./agyEvents.ts"; + +const preToolUse = (overrides: Record = {}): AgyHookEvent => ({ + event: "pre-tool-use", + payload: { + conversationId: "conversation-1", + stepIdx: 3, + transcriptPath: "/brain/conversation-1/.system_generated/logs/transcript_full.jsonl", + modelName: "gemini-3.1-pro-high", + toolCall: { name: "run_command", args: { CommandLine: "ls -la src" } }, + ...overrides, + }, +}); + +describe("agyToolKind", () => { + it("maps Antigravity tool names onto ACP kinds", () => { + expect(agyToolKind("replace_file_content")).toBe("edit"); + expect(agyToolKind("write_to_file")).toBe("edit"); + expect(agyToolKind("view_file")).toBe("read"); + expect(agyToolKind("list_dir")).toBe("read"); + expect(agyToolKind("grep_search")).toBe("search"); + expect(agyToolKind("run_command")).toBe("execute"); + expect(agyToolKind("delete_file")).toBe("delete"); + }); + + it("treats browser tools as fetch and unknown tools as other", () => { + expect(agyToolKind("browser_navigate")).toBe("fetch"); + expect(agyToolKind("some_future_tool")).toBe("other"); + expect(agyToolKind(undefined)).toBe("other"); + }); +}); + +describe("agyToolTitle", () => { + it("prefers Antigravity's own summary over the raw tool name", () => { + expect(agyToolTitle({ name: "run_command", args: { toolSummary: "List files in src" } })).toBe( + "List files in src", + ); + }); + + it("falls back through toolAction to the tool name", () => { + expect(agyToolTitle({ name: "run_command", args: { toolAction: "Listing files" } })).toBe( + "Listing files", + ); + expect(agyToolTitle({ name: "run_command", args: {} })).toBe("run_command"); + expect(agyToolTitle(null)).toBe("Antigravity tool"); + }); +}); + +describe("agyTargetPath", () => { + it("reads the path from whichever PascalCase key the tool used", () => { + expect(agyTargetPath({ name: "replace_file_content", args: { TargetFile: "/a/b.ts" } })).toBe( + "/a/b.ts", + ); + expect(agyTargetPath({ name: "view_file", args: { AbsolutePath: "/a/c.ts" } })).toBe("/a/c.ts"); + expect(agyTargetPath({ name: "list_dir", args: { DirectoryPath: "/a" } })).toBe("/a"); + expect(agyTargetPath({ name: "run_command", args: { CommandLine: "ls" } })).toBeUndefined(); + }); +}); + +describe("hookSessionUpdate", () => { + it("announces a tool call and records it as in flight", () => { + const state = makeAgyTurnState(); + const update = hookSessionUpdate(preToolUse(), state); + + expect(update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: agyToolCallId("conversation-1", 3), + kind: "execute", + status: "in_progress", + rawInput: { CommandLine: "ls -la src" }, + }); + expect(state.toolCalls.get(3)?.completed).toBe(false); + }); + + it("learns the conversation and transcript from any hook, not just stop", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + + expect(state.conversationId).toBe("conversation-1"); + expect(state.modelName).toBe("gemini-3.1-pro-high"); + expect(state.transcriptPath).toBe( + "/brain/conversation-1/.system_generated/logs/transcript_full.jsonl", + ); + }); + + it("ignores hook pairs for internal planner steps that carry no tool", () => { + const state = makeAgyTurnState(); + const update = hookSessionUpdate( + { event: "pre-tool-use", payload: { conversationId: "c", stepIdx: 1, toolCall: null } }, + state, + ); + + expect(update).toBeNull(); + expect(state.toolCalls.size).toBe(0); + }); + + it("completes a tool call that was announced", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + }, + state, + ); + + expect(update).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + rawOutput: { isError: false }, + }); + // Retained, not deleted: the transcript record carrying this step's output + // is often read in the same drain pass and still needs the tool call id. + expect(state.toolCalls.get(3)?.completed).toBe(true); + }); + + it("ignores a duplicate post hook for an already completed step", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + const post = { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + } as const; + + expect(hookSessionUpdate(post, state)).not.toBeNull(); + expect(hookSessionUpdate(post, state)).toBeNull(); + }); + + it("marks a tool call failed and carries the error through", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "exit status 1" }, + }, + state, + ); + + expect(update).toMatchObject({ + status: "failed", + rawOutput: { isError: true, error: "exit status 1" }, + }); + }); + + it("drops a post hook whose pre hook was never observed", () => { + const state = makeAgyTurnState(); + const update = hookSessionUpdate( + { event: "post-tool-use", payload: { conversationId: "c", stepIdx: 9, error: "" } }, + state, + ); + + expect(update).toBeNull(); + }); + + it("emits a diff from the file contents captured around an edit", () => { + const state = makeAgyTurnState(); + hookSessionUpdate( + preToolUse({ + toolCall: { name: "replace_file_content", args: { TargetFile: "/repo/greet.js" } }, + }), + state, + 'return "hi";', + ); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + }, + state, + 'return "hello";', + ); + + expect(update).toMatchObject({ + status: "completed", + content: [ + { + type: "diff", + path: "/repo/greet.js", + oldText: 'return "hi";', + newText: 'return "hello";', + }, + ], + }); + }); + + it("omits the diff when an edit left the file byte-identical", () => { + const state = makeAgyTurnState(); + hookSessionUpdate( + preToolUse({ + toolCall: { name: "write_to_file", args: { TargetFile: "/repo/a.ts" } }, + }), + state, + "same", + ); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + }, + state, + "same", + ); + + expect(update).not.toHaveProperty("content"); + }); +}); + +describe("approvalOutcomeToDecision", () => { + it("accepts any of the configured approve options", () => { + // The client renders an "always allow" choice; without it in this list a + // user pressing it would have their tool denied. + expect( + approvalOutcomeToDecision({ outcome: { outcome: "selected", optionId: "allow-session" } }, [ + "allow", + "allow-session", + ]).decision, + ).toBe("allow"); + }); + + it("allows only an explicit selection of the approve option", () => { + expect( + approvalOutcomeToDecision({ outcome: { outcome: "selected", optionId: "allow" } }, ["allow"]), + ).toEqual({ decision: "allow" }); + }); + + it("denies every other reply", () => { + // This is the only gate in front of a CLI already told to skip its own + // permission prompts, so anything ambiguous has to deny. + for (const reply of [ + { outcome: { outcome: "selected", optionId: "reject" } }, + { outcome: { outcome: "cancelled" } }, + { outcome: { outcome: "selected" } }, + { outcome: null }, + {}, + null, + undefined, + "allow", + ]) { + expect(approvalOutcomeToDecision(reply, ["allow"]).decision).toBe("deny"); + } + }); +}); + +describe("agyHookResponse", () => { + it("allows tools only while a bridge observer is attached", () => { + expect(agyHookResponse("pre-tool-use", true)).toEqual({ decision: "allow" }); + expect(agyHookResponse("pre-tool-use", false)).toMatchObject({ decision: "ask" }); + }); + + it("answers the stop contract with a decision", () => { + expect(agyHookResponse("stop", true)).toEqual({ decision: "stop" }); + expect(agyHookResponse("post-tool-use", true)).toEqual({}); + }); +}); diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts new file mode 100644 index 00000000000..af44842ac51 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -0,0 +1,439 @@ +/** + * Pure translation layer for the Antigravity ACP bridge. + * + * Antigravity exposes no native agent protocol, so the bridge reconstructs an + * ACP event stream from two independent sources that Antigravity *does* + * document: + * + * - **Hooks** (`PreToolUse` / `PostToolUse` / `Stop`) deliver the tool name, + * its arguments, a human-readable summary, and completion status. + * - **The trajectory transcript** (`transcript.jsonl`) delivers assistant + * text and real tool output, appended progressively while the turn runs. + * + * The two correlate exactly: a hook's `stepIdx` is the transcript record's + * `step_index`. Neither source alone is sufficient — hooks never carry tool + * output, and the transcript never carries tool arguments. + * + * Everything here is pure so it can be tested without spawning `agy`. IO lives + * in `agyBridge.ts`. + * + * @module provider/acp/antigravity/agyEvents + */ + +/** Tool-call lifecycle kinds understood by ACP clients. */ +export type AcpToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type AgyHookEventName = "pre-tool-use" | "post-tool-use" | "stop"; + +export interface AgyToolCall { + readonly name?: string; + readonly args?: Record | null; +} + +/** + * Documented Antigravity hook payload. Every event carries `conversationId` + * and `transcriptPath`, which is what lets the bridge resume a trajectory and + * locate its transcript without guessing. + */ +export interface AgyHookPayload { + readonly conversationId?: string; + readonly stepIdx?: number; + readonly toolCall?: AgyToolCall | null; + readonly error?: string; + readonly modelName?: string; + readonly transcriptPath?: string; + readonly artifactDirectoryPath?: string; + readonly workspacePaths?: ReadonlyArray; + readonly fullyIdle?: boolean; + readonly terminationReason?: string; + readonly executionNum?: number; +} + +export interface AgyHookEvent { + readonly event: AgyHookEventName | string; + readonly payload: AgyHookPayload; + /** + * Contents of the tool's target file, captured by the hook process itself. + * + * This cannot be read by the bridge when it drains hook output: both hooks + * for a fast edit land inside a single poll interval, by which point the + * edit has already been written and "before" would read back as "after". + * The hook runs synchronously within the tool lifecycle, so it is the only + * place that observes the true pre-edit state. + */ + readonly capturedFileText?: string | null; +} + +/** + * Antigravity tool names, mapped onto ACP kinds so clients can pick the right + * affordance. Unknown tools degrade to `other` rather than being dropped. + */ +export function agyToolKind(name: string | undefined): AcpToolKind { + switch (name) { + case "write_to_file": + case "replace_file_content": + case "multi_replace_file_content": + case "edit_file": + return "edit"; + case "view_file": + case "view_code_item": + case "list_dir": + case "read_url_content": + return "read"; + case "grep_search": + case "find_by_name": + case "codebase_search": + case "search_web": + return "search"; + case "run_command": + case "command_status": + return "execute"; + case "delete_file": + return "delete"; + default: + if (name && name.startsWith("browser_")) { + return "fetch"; + } + return "other"; + } +} + +/** + * Stable identity for one tool call across its hook pair and its transcript + * record. `stepIdx` is unique within a conversation and is echoed verbatim as + * the transcript's `step_index`. + */ +export function agyToolCallId(conversationId: string | undefined, stepIdx: number): string { + return `agy-${conversationId ?? "unknown"}-${stepIdx}`; +} + +/** + * Prefer Antigravity's own human-readable summary over the raw tool name. + * `PostToolUse` enriches `args` with `toolSummary`/`toolAction`; `PreToolUse` + * usually has neither, so the tool name is the fallback. + */ +export function agyToolTitle(toolCall: AgyToolCall | null | undefined): string { + const args = toolCall?.args; + const summary = typeof args?.["toolSummary"] === "string" ? args["toolSummary"].trim() : ""; + if (summary.length > 0) { + return summary; + } + const action = typeof args?.["toolAction"] === "string" ? args["toolAction"].trim() : ""; + if (action.length > 0) { + return action; + } + return toolCall?.name?.trim() || "Antigravity tool"; +} + +/** + * File path a tool acts on, when it names one. Antigravity uses PascalCase + * argument keys and is not consistent about which one carries the path. + */ +export function agyTargetPath(toolCall: AgyToolCall | null | undefined): string | undefined { + const args = toolCall?.args; + if (!args) { + return undefined; + } + for (const key of ["TargetFile", "AbsolutePath", "FilePath", "Path", "DirectoryPath"]) { + const value = args[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +/** Metadata echoed onto every emitted update for debugging and traceability. */ +export function agyUpdateMeta(event: string, payload: AgyHookPayload): Record { + return { + antigravity: { + event, + conversationId: payload.conversationId ?? null, + stepIdx: payload.stepIdx ?? null, + modelName: payload.modelName ?? null, + transcriptPath: payload.transcriptPath ?? null, + artifactDirectoryPath: payload.artifactDirectoryPath ?? null, + }, + }; +} + +export interface AgyToolCallRecord { + readonly toolCallId: string; + readonly name: string | undefined; + readonly kind: AcpToolKind; + readonly targetPath: string | undefined; + /** File contents captured at `PreToolUse`, used to build an edit diff. */ + readonly beforeText: string | undefined; + /** Set by `PostToolUse`. Records are kept after completion so a transcript + * record that lands in the same poll can still attach the tool's output. */ + completed: boolean; +} + +/** + * Mutable bookkeeping shared by the hook and transcript readers for one turn. + */ +export interface AgyTurnState { + /** + * Every tool call seen this turn, keyed by step index — completed ones + * included. The two event sources are drained in the same pass, so a step + * must stay addressable after its `PostToolUse` hook or its transcript + * output would be dropped whenever both arrive within one poll. + */ + readonly toolCalls: Map; + /** + * Terminal `tool_call_update`s held back until the transcript record with + * that step's output has been streamed. `AcpSessionRuntime` drops its tool + * state once a call completes, so output emitted afterwards would surface as + * a second, never-completed item. + */ + readonly pendingTerminal: Map; + /** + * Steps whose transcript output has already been streamed. The transcript is + * read once by byte offset, so a record consumed before its `PostToolUse` + * hook appeared will never be revisited — without this the tool would render + * as running until the turn ended. + */ + readonly transcriptSeenSteps: Set; + conversationId: string | undefined; + transcriptPath: string | undefined; + /** Transcript file pinned for the turn; see `resolveTranscriptPath`. */ + resolvedTranscriptPath: string | undefined; + /** + * Whether records predating this turn have been discarded. Reading starts at + * byte 0, which on a resumed conversation is the start of the whole history. + */ + transcriptPrimed: boolean; + /** + * Whether this turn resumed an existing conversation. Only then can the + * transcript already hold other turns' records at byte 0. + */ + readonly resumedConversation: boolean; + modelName: string | undefined; +} + +export function makeAgyTurnState(conversationId?: string): AgyTurnState { + return { + toolCalls: new Map(), + pendingTerminal: new Map(), + transcriptSeenSteps: new Set(), + conversationId, + transcriptPath: undefined, + resolvedTranscriptPath: undefined, + transcriptPrimed: false, + resumedConversation: conversationId !== undefined, + modelName: undefined, + }; +} + +/** A `session/update` payload, shaped for ACP but kept as plain JSON. */ +export type AgySessionUpdate = Record; + +/** + * Translate one `PreToolUse` hook into an ACP `tool_call` announcement. + * + * `beforeText` is threaded in by the caller (which does the file read) so this + * function stays pure. + */ +export function preToolUseUpdate( + payload: AgyHookPayload, + state: AgyTurnState, + beforeText?: string, +): AgySessionUpdate | null { + const stepIdx = payload.stepIdx; + if (typeof stepIdx !== "number") { + return null; + } + const toolCall = payload.toolCall; + // Antigravity emits hook pairs for internal planner steps with no tool + // attached. Those are not tool calls and must not render as one. + if (!toolCall || !toolCall.name) { + return null; + } + + const toolCallId = agyToolCallId(payload.conversationId, stepIdx); + const kind = agyToolKind(toolCall.name); + const targetPath = agyTargetPath(toolCall); + state.toolCalls.set(stepIdx, { + toolCallId, + name: toolCall.name, + kind, + targetPath, + beforeText, + completed: false, + }); + + return { + sessionUpdate: "tool_call", + toolCallId, + title: agyToolTitle(toolCall), + kind, + status: "in_progress", + rawInput: toolCall.args ?? null, + ...(targetPath ? { locations: [{ path: targetPath }] } : {}), + _meta: agyUpdateMeta("pre-tool-use", payload), + }; +} + +/** + * Translate one `PostToolUse` hook into an ACP `tool_call_update`. + * + * `afterText` is the on-disk content of an edited file, read by the caller + * after the tool ran. Diffing captured before/after content sidesteps having + * to interpret Antigravity's edit arguments, whose semantics vary by tool. + */ +export function postToolUseUpdate( + payload: AgyHookPayload, + state: AgyTurnState, + afterText?: string, +): AgySessionUpdate | null { + const stepIdx = payload.stepIdx; + if (typeof stepIdx !== "number") { + return null; + } + const active = state.toolCalls.get(stepIdx); + // Only complete calls whose `PreToolUse` we actually observed; Antigravity + // emits unpaired post hooks for internal steps. + if (!active || active.completed) { + return null; + } + // Marked rather than removed: the transcript record carrying this step's + // output is often read in the same drain pass, and dropping the entry here + // would leave it with no tool call to attach to. + active.completed = true; + + const error = typeof payload.error === "string" ? payload.error.trim() : ""; + const failed = error.length > 0; + + const content: Array> = []; + if ( + !failed && + active.kind === "edit" && + active.targetPath && + afterText !== undefined && + afterText !== active.beforeText + ) { + content.push({ + type: "diff", + path: active.targetPath, + ...(active.beforeText === undefined ? {} : { oldText: active.beforeText }), + newText: afterText, + }); + } + + return { + sessionUpdate: "tool_call_update", + toolCallId: active.toolCallId, + status: failed ? "failed" : "completed", + ...(content.length > 0 ? { content } : {}), + rawOutput: failed ? { isError: true, error } : { isError: false }, + _meta: agyUpdateMeta("post-tool-use", payload), + }; +} + +/** + * Fold a hook event into turn state, returning the update to emit (if any). + * + * `Stop` carries no user-visible update; it exists to publish the conversation + * id that lets the next turn resume the same trajectory. + */ +export function hookSessionUpdate( + hook: AgyHookEvent, + state: AgyTurnState, + fileText?: string, +): AgySessionUpdate | null { + const payload = hook.payload ?? {}; + // Every hook carries the conversation id, so the bridge learns it from the + // first event of the turn rather than waiting for `Stop`. + const conversationId = payload.conversationId?.trim(); + if (conversationId) { + state.conversationId = conversationId; + } + const transcriptPath = payload.transcriptPath?.trim(); + if (transcriptPath) { + state.transcriptPath = transcriptPath; + } + const modelName = payload.modelName?.trim(); + if (modelName) { + state.modelName = modelName; + } + + switch (hook.event) { + case "pre-tool-use": + return preToolUseUpdate(payload, state, fileText); + case "post-tool-use": + return postToolUseUpdate(payload, state, fileText); + default: + return null; + } +} + +/** + * Response Antigravity requires on stdout for each hook event. + * + * `pre-tool-use` must return a decision. When no bridge observer is attached + * the safe answer is `ask`: a hook that silently allowed every tool outside a + * managed turn would be a permission bypass. + */ +export interface AgyHookDecision { + readonly decision: "allow" | "deny" | "ask"; + readonly reason?: string; +} + +/** + * Translate the client's `session/request_permission` reply into the decision + * the blocked `PreToolUse` hook will print. + * + * Fails closed: only an explicit selection of the approve option allows the + * tool. A cancelled prompt, an unknown option id, or a malformed reply all + * deny, because this is the only thing standing between the model and a tool + * that `agy` has already been told to run without asking. + */ +export function approvalOutcomeToDecision( + result: unknown, + approveOptionIds: ReadonlyArray, +): AgyHookDecision { + const outcome = + typeof result === "object" && result !== null + ? (result as { outcome?: unknown }).outcome + : undefined; + const selected = + typeof outcome === "object" && outcome !== null + ? (outcome as { outcome?: unknown; optionId?: unknown }) + : undefined; + if ( + selected?.outcome === "selected" && + typeof selected.optionId === "string" && + approveOptionIds.includes(selected.optionId) + ) { + return { decision: "allow" }; + } + return { decision: "deny", reason: "Rejected in T3 Code" }; +} + +export function agyHookResponse(event: string, observerAttached: boolean): Record { + switch (event) { + case "pre-tool-use": + return observerAttached + ? { decision: "allow" } + : { + decision: "ask", + reason: "T3 Code hook observer is not attached to a managed Antigravity turn", + }; + // Antigravity's Stop contract requires a decision; anything other than + // "continue" lets the completed execution stop. + case "stop": + return { decision: "stop" }; + default: + return {}; + } +} diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts new file mode 100644 index 00000000000..79960e6fa95 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hookSessionUpdate, makeAgyTurnState, type AgyTurnState } from "./agyEvents.ts"; +import { + AgyTranscriptCursor, + dropPriorTurnRecords, + normalizeToolOutput, + parseTranscriptLine, + transcriptRecordUpdates, +} from "./agyTranscript.ts"; + +function stateWithActiveTool(stepIdx: number, name = "run_command"): AgyTurnState { + const state = makeAgyTurnState(); + hookSessionUpdate( + { + event: "pre-tool-use", + payload: { conversationId: "conversation-1", stepIdx, toolCall: { name, args: {} } }, + }, + state, + ); + return state; +} + +describe("parseTranscriptLine", () => { + it("returns null for blank and half-written lines", () => { + expect(parseTranscriptLine("")).toBeNull(); + expect(parseTranscriptLine('{"step_index": 1, "type": "RUN_COM')).toBeNull(); + }); + + it("parses a complete record", () => { + expect(parseTranscriptLine('{"step_index":2,"type":"PLANNER_RESPONSE"}')).toMatchObject({ + step_index: 2, + type: "PLANNER_RESPONSE", + }); + }); +}); + +describe("normalizeToolOutput", () => { + it("strips the timestamp preamble", () => { + const output = normalizeToolOutput( + "Created At: 2026-07-24T17:31:41-04:00\nCompleted At: 2026-07-24T17:31:41-04:00\nresult", + ); + expect(output).toBe("result"); + }); + + it("dedents Antigravity's tab-indented framing without touching flush-left output", () => { + // Antigravity indents its own framing with tabs but writes the tool's real + // output flush left, so a plain common-prefix dedent would find zero. + const output = normalizeToolOutput( + "Created At: x\n\n\t\t\t\tThe command exited with code 0.\n\t\t\t\tOutput:\n\t\t\t\ttotal 16\ndrwxr-xr-x 4 alex staff", + ); + expect(output).toBe( + "The command exited with code 0.\nOutput:\ntotal 16\ndrwxr-xr-x 4 alex staff", + ); + }); + + it("dedents tab-indented source uniformly", () => { + expect(normalizeToolOutput("\t\tconst a = 1;\n\t\t\tconst b = 2;")).toBe( + "const a = 1;\n\tconst b = 2;", + ); + }); +}); + +describe("transcriptRecordUpdates", () => { + it("streams assistant text as an agent message chunk", () => { + const result = transcriptRecordUpdates( + { step_index: 7, source: "MODEL", type: "PLANNER_RESPONSE", content: "All done." }, + makeAgyTurnState(), + ); + + expect(result.emittedAssistantText).toBe(true); + expect(result.updates[0]).toMatchObject({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "All done." }, + }); + }); + + it("skips empty planner responses", () => { + const result = transcriptRecordUpdates( + { step_index: 2, source: "MODEL", type: "PLANNER_RESPONSE", content: " " }, + makeAgyTurnState(), + ); + + expect(result.updates).toHaveLength(0); + expect(result.emittedAssistantText).toBe(false); + }); + + it("ignores bookkeeping records that would read as assistant output", () => { + for (const type of ["CHECKPOINT", "CONVERSATION_HISTORY", "USER_INPUT", "SYSTEM_MESSAGE"]) { + const result = transcriptRecordUpdates( + { step_index: 1, source: "SYSTEM", type, content: "internal" }, + makeAgyTurnState(), + ); + expect(result.updates).toHaveLength(0); + } + }); + + it("attaches tool output to the call the matching hook announced", () => { + const state = stateWithActiveTool(3); + const result = transcriptRecordUpdates( + { + step_index: 3, + source: "MODEL", + type: "RUN_COMMAND", + content: + "Created At: x\n\t\t\t\tThe command exited with code 0.\n\t\t\t\tOutput:\n\t\t\t\tok", + }, + state, + ); + + expect(result.updates[0]).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "agy-conversation-1-3", + content: [ + { + type: "content", + content: { type: "text", text: "The command exited with code 0.\nOutput:\nok" }, + }, + ], + }); + }); + + it("still attaches output after the post hook completed the call", () => { + // One drain pass reads hooks before the transcript, so for a fast tool the + // PostToolUse hook and the record carrying its output arrive together. If + // completing a call dropped its bookkeeping, that output would be lost. + const state = stateWithActiveTool(3); + hookSessionUpdate( + { event: "post-tool-use", payload: { conversationId: "conversation-1", stepIdx: 3 } }, + state, + ); + + const result = transcriptRecordUpdates( + { step_index: 3, source: "MODEL", type: "RUN_COMMAND", content: "Created At: x\nok" }, + state, + ); + + expect(result.updates[0]).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "agy-conversation-1-3", + content: [{ type: "content", content: { type: "text", text: "ok" } }], + }); + }); + + it("records the steps whose output has been streamed", () => { + // The transcript is read once by byte offset, so a step consumed before + // its PostToolUse hook appears is never revisited; the bridge uses this to + // complete the call immediately instead of waiting for a second record. + const state = stateWithActiveTool(3); + transcriptRecordUpdates( + { step_index: 3, source: "MODEL", type: "RUN_COMMAND", content: "Created At: x\nok" }, + state, + ); + + expect(state.transcriptSeenSteps.has(3)).toBe(true); + }); + + it("drops tool output with no announced call rather than inventing one", () => { + const result = transcriptRecordUpdates( + { step_index: 99, source: "MODEL", type: "RUN_COMMAND", content: "orphan" }, + makeAgyTurnState(), + ); + + expect(result.updates).toHaveLength(0); + }); +}); + +describe("dropPriorTurnRecords", () => { + const line = (step: number, type: string) => JSON.stringify({ step_index: step, type }); + + it("keeps only what follows the last USER_INPUT", () => { + // Shape taken from a real two-turn transcript: one append-only file per + // conversation, each turn opening with USER_INPUT. + const lines = [ + line(0, "USER_INPUT"), + line(1, "CONVERSATION_HISTORY"), + line(2, "PLANNER_RESPONSE"), + line(3, "LIST_DIRECTORY"), + line(4, "CHECKPOINT"), + line(5, "PLANNER_RESPONSE"), + line(6, "USER_INPUT"), + line(7, "SYSTEM_MESSAGE"), + line(8, "PLANNER_RESPONSE"), + ]; + + expect(dropPriorTurnRecords(lines)).toEqual([ + line(7, "SYSTEM_MESSAGE"), + line(8, "PLANNER_RESPONSE"), + ]); + }); + + it("drops this turn's own opening record on a fresh conversation", () => { + expect(dropPriorTurnRecords([line(0, "USER_INPUT"), line(1, "PLANNER_RESPONSE")])).toEqual([ + line(1, "PLANNER_RESPONSE"), + ]); + }); + + it("keeps everything when the opening record has not been written yet", () => { + const lines = [line(2, "PLANNER_RESPONSE")]; + expect(dropPriorTurnRecords(lines)).toEqual(lines); + expect(dropPriorTurnRecords([])).toEqual([]); + }); +}); + +describe("AgyTranscriptCursor", () => { + it("holds back a partial trailing line until the rest arrives", () => { + const cursor = new AgyTranscriptCursor(); + + expect(cursor.push('{"a":1}\n{"b":')).toEqual(['{"a":1}']); + expect(cursor.push("2}\n")).toEqual(['{"b":2}']); + }); + + it("tracks the byte offset it has consumed", () => { + const cursor = new AgyTranscriptCursor(); + cursor.push("abc\n"); + + expect(cursor.bytesConsumed).toBe(4); + }); + + it("flushes the trailing line once the writer is finished", () => { + const cursor = new AgyTranscriptCursor(); + cursor.push('{"a":1}\n{"b":2}'); + + expect(cursor.flush()).toEqual(['{"b":2}']); + expect(cursor.flush()).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.ts new file mode 100644 index 00000000000..f9529a12bc5 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.ts @@ -0,0 +1,228 @@ +/** + * Reader for Antigravity's trajectory transcript. + * + * Antigravity appends one JSON record per step to + * `//.system_generated/logs/transcript.jsonl` while a + * turn is running, not just at the end. Tailing it is what lets the bridge + * stream assistant text and surface real tool output — neither of which the + * hook payloads carry. + * + * The record format is undocumented. Everything here is written to degrade to + * "emit nothing" rather than throw, so an Antigravity update that changes the + * shape costs observability but never breaks a turn. + * + * @module provider/acp/antigravity/agyTranscript + */ +import type { AgySessionUpdate, AgyTurnState } from "./agyEvents.ts"; + +export interface AgyTranscriptRecord { + readonly step_index?: number; + readonly source?: string; + readonly type?: string; + readonly status?: string; + readonly created_at?: string; + readonly content?: string; +} + +/** + * Record types that are bookkeeping rather than conversation. `CHECKPOINT` in + * particular holds a summary of truncated context and would read as the + * assistant talking to itself. + */ +const IGNORED_RECORD_TYPES = new Set([ + "CONVERSATION_HISTORY", + "CHECKPOINT", + "USER_INPUT", + "SYSTEM_MESSAGE", +]); + +export function parseTranscriptLine(line: string): AgyTranscriptRecord | null { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return null; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null) { + return null; + } + return parsed as AgyTranscriptRecord; + } catch { + // A partially-flushed final line is expected while tailing a live turn. + return null; + } +} + +/** + * Strip the `Created At:` / `Completed At:` preamble Antigravity prepends to + * tool records, then dedent. Tool output arrives indented with tabs, which + * would otherwise render as a code block in the client. + */ +export function normalizeToolOutput(content: string): string { + const withoutTimestamps = content + .split("\n") + .filter((line) => !/^\s*(Created At|Completed At):/.test(line)) + .join("\n"); + + // Antigravity indents its own framing ("The command exited with code 0.", + // "Output:") with tabs while leaving the tool's real output flush left, so a + // plain common-prefix dedent finds an indent of zero and does nothing. + // Dedent across tab-indented lines only: that strips the framing here, and + // still behaves like a normal dedent when the payload is tab-indented source. + const lines = withoutTimestamps.split("\n"); + const tabDepths = lines + .filter((line) => line.trim().length > 0 && line.startsWith("\t")) + .map((line) => line.match(/^\t*/)?.[0].length ?? 0); + const commonTabs = tabDepths.length > 0 ? Math.min(...tabDepths) : 0; + const dedented = + commonTabs > 0 + ? lines.map((line) => (line.startsWith("\t") ? line.slice(commonTabs) : line)).join("\n") + : withoutTimestamps; + + return dedented.trim(); +} + +export interface TranscriptUpdateResult { + readonly updates: ReadonlyArray; + /** True when the record produced assistant-visible text. */ + readonly emittedAssistantText: boolean; +} + +/** + * Translate one transcript record into ACP updates. + * + * Assistant text becomes an `agent_message_chunk`. A tool record is matched to + * the in-flight tool call announced by the matching `PreToolUse` hook — the + * hook's `stepIdx` and the record's `step_index` are the same number — and its + * body is attached as that call's output. + */ +export function transcriptRecordUpdates( + record: AgyTranscriptRecord, + state: AgyTurnState, +): TranscriptUpdateResult { + const type = record.type; + if (!type || IGNORED_RECORD_TYPES.has(type)) { + return { updates: [], emittedAssistantText: false }; + } + + const content = typeof record.content === "string" ? record.content : ""; + + if (type === "PLANNER_RESPONSE") { + const text = content.trim(); + if (text.length === 0) { + return { updates: [], emittedAssistantText: false }; + } + return { + updates: [ + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + ], + emittedAssistantText: true, + }; + } + + // Any other MODEL record is a tool step. Attach its body to the tool call + // the hook already announced; without a matching hook there is no tool call + // to update, so the output is dropped rather than invented. + const stepIndex = record.step_index; + if (typeof stepIndex !== "number") { + return { updates: [], emittedAssistantText: false }; + } + // Recorded before the early returns below: the transcript is read once by + // byte offset, so "this step's record has gone by" holds even when it + // carried nothing worth emitting. + state.transcriptSeenSteps.add(stepIndex); + // Completed calls stay in the map precisely so this lookup still resolves: + // a fast tool's `PostToolUse` hook and its transcript record routinely land + // in the same drain pass, and hooks are read first. + const active = state.toolCalls.get(stepIndex); + if (!active) { + return { updates: [], emittedAssistantText: false }; + } + const output = normalizeToolOutput(content); + if (output.length === 0) { + return { updates: [], emittedAssistantText: false }; + } + + return { + updates: [ + { + sessionUpdate: "tool_call_update", + toolCallId: active.toolCallId, + content: [{ type: "content", content: { type: "text", text: output } }], + _meta: { antigravity: { event: "transcript", recordType: type, stepIdx: stepIndex } }, + }, + ], + emittedAssistantText: false, + }; +} + +/** + * Drop transcript records belonging to earlier turns. + * + * One conversation keeps a single append-only transcript, and every turn opens + * with a `USER_INPUT` record, so the last one in the file marks where the + * current turn begins. Resuming a conversation starts reading at byte 0 — the + * turn's own records cannot be located any other way — which without this trim + * would replay every prior assistant message as new output. + * + * Applied only to the first batch of a turn. Returns the input unchanged when + * no `USER_INPUT` is present, which is the correct read for a transcript whose + * opening record has not been written yet. + */ +export function dropPriorTurnRecords(lines: ReadonlyArray): ReadonlyArray { + let lastUserInput = -1; + for (let index = 0; index < lines.length; index += 1) { + if (parseTranscriptLine(lines[index] ?? "")?.type === "USER_INPUT") { + lastUserInput = index; + } + } + return lastUserInput === -1 ? lines : lines.slice(lastUserInput + 1); +} + +/** + * Incremental transcript cursor. + * + * Tracks a byte offset and holds back a trailing partial line so a record that + * is still being written is parsed once, on the next read, rather than twice. + */ +export class AgyTranscriptCursor { + private offset = 0; + private carry = ""; + + get bytesConsumed(): number { + return this.offset; + } + + /** + * Feed a freshly-read chunk starting at the current offset, returning whole + * lines only. + */ + push(chunk: string): ReadonlyArray { + this.offset += Buffer.byteLength(chunk, "utf8"); + const combined = this.carry + chunk; + const lines = combined.split("\n"); + this.carry = lines.pop() ?? ""; + return lines; + } + + /** + * Push whole lines back to the front of the stream. + * + * Used when a batch cannot be interpreted yet — on a resumed conversation the + * current turn's opening record may not have been written — so the same lines + * are re-examined against the next read rather than emitted or discarded. + */ + retain(lines: ReadonlyArray): void { + this.carry = lines.length > 0 ? `${lines.join("\n")}\n${this.carry}` : this.carry; + } + + /** Flush the trailing line once the writer is known to be finished. */ + flush(): ReadonlyArray { + const remaining = this.carry; + this.carry = ""; + return remaining.trim().length > 0 ? [remaining] : []; + } +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..f6d6625083a 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | AntigravityDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeAntigravityAcpRuntime({ + antigravitySettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + model: resolveAntigravityBaseModelId(modelSelection.model), + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + return yield* runtime.prompt({ prompt: [{ type: "text", text: prompt }] }); + }).pipe( + Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Antigravity request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Antigravity request was cancelled." + : "Antigravity returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Antigravity text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runAntigravityJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runAntigravityJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAntigravityJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAntigravityJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..137c0703e1d 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + AntigravityIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..36ad0d0f45a 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AntigravitySettings, ClaudeSettings, CodexSettings, CursorSettings, @@ -7,7 +8,15 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + AntigravityIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + icon: AntigravityIcon, + badgeLabel: "Experimental", + settingsSchema: AntigravitySettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..5cb6e9a866e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 47fcfccb954..77188a8bd30 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -152,6 +153,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ANTIGRAVITY_DRIVER_KIND]: "Antigravity", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..b3f2066bc8f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -339,6 +339,64 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const AntigravitySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("agy").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Antigravity CLI binary.", + providerSettingsForm: { placeholder: "agy", clearWhenEmpty: "omit" }, + }), + ), + // Antigravity has no native agent protocol, so every turn runs through + // `agy --print`. A turn holds one process open for its whole duration; + // the default matches the bridge's own ceiling. + printTimeout: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Print timeout", + description: "Max duration for a single turn, passed to `agy --print-timeout`.", + providerSettingsForm: { placeholder: "2h", clearWhenEmpty: "omit" }, + }), + ), + // Trajectory discovery reads this root only when the Stop hook does not + // report a conversation id. Overridable for non-default AGY installs. + appDataDir: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "App data directory", + description: "Overrides ~/.gemini/antigravity-cli for conversation discovery.", + providerSettingsForm: { placeholder: "~/.gemini/antigravity-cli", clearWhenEmpty: "omit" }, + }), + ), + // Print mode cannot prompt, so `agy` runs with permissions skipped and the + // bridge's PreToolUse hook becomes the gate instead: it blocks the tool + // until T3 Code answers, and a denial is reported back to the model. + // On by default: without it nothing stands between the model and an + // auto-approved tool, and a gate that has to be discovered protects no one. + requireToolApproval: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Ask before running tools", + description: + "Approve each tool call before Antigravity runs it. Turns block until you answer.", + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "printTimeout", "appDataDir", "requireToolApproval"], + }, +); +export type AntigravitySettings = typeof AntigravitySettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -433,6 +491,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -528,6 +587,15 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const AntigravitySettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + printTimeout: Schema.optionalKey(TrimmedString), + appDataDir: Schema.optionalKey(TrimmedString), + requireToolApproval: Schema.optionalKey(Schema.Boolean), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -558,6 +626,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + antigravity: Schema.optionalKey(AntigravitySettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual