diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..b121ae62f026 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -39,6 +39,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "antigravity") { + return ( + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts new file mode 100644 index 000000000000..cc83d449fc8a --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -0,0 +1,226 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ServerConfig from "../../config.ts"; +import * as IdAllocator from "../IdAllocator.ts"; +import { makeAntigravityAdapterV2 } from "./AntigravityAdapterV2.ts"; + +const TestLayer = Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), +); + +it.layer(TestLayer)("AntigravityAdapterV2", (it) => { + it.effect("projects a streamed turn and persists its conversation head", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-adapter-", + }); + const binary = path.join(directory, "agy"); + const line = (json: string) => `printf '%s\\n' '${json}'`; + yield* fileSystem.writeFileString( + binary, + [ + "#!/bin/sh", + line('{"event":"init","conversation_id":"conv-1"}'), + line( + '{"event":"step_update","step_update":{"step_index":1,"state":"ACTIVE","step_type":"agent_response","text_delta":"Hello "}}', + ), + line( + '{"event":"step_update","step_update":{"step_index":1,"state":"DONE","step_type":"agent_response","text_delta":"world"}}', + ), + line( + `{"event":"step_update","step_update":{"step_index":2,"state":"DONE","step_type":"tool","tool_name":"run_command","tool_info":{"parameters":{"CommandLine":"pwd"},"output":"${directory}"}}}`, + ), + line( + '{"event":"result","result":{"conversation_id":"conv-1","status":"SUCCESS","response":"Hello world"}}', + ), + "", + ].join("\n"), + ); + yield* fileSystem.chmod(binary, 0o755); + + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const config = yield* ServerConfig.ServerConfig; + const adapter = makeAntigravityAdapterV2({ + instanceId: ProviderInstanceId.make("antigravity"), + settings: { enabled: true, binaryPath: binary, customModels: [], launchArgs: "" }, + environment: process.env, + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + idAllocator, + path, + serverConfig: config, + }); + const threadId = ThreadId.make("thread-antigravity-v2"); + const modelSelection = { + instanceId: ProviderInstanceId.make("antigravity"), + model: "gemini-3.6-flash", + options: [{ id: "effort", value: "medium" }], + } as const; + const runtimePolicy = { + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + cwd: directory, + }; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("session-antigravity-v2"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + const now = yield* DateTime.now; + const runId = RunId.make("run-antigravity-v2"); + yield* runtime.startTurn({ + appThread: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project-antigravity-v2"), + title: "Antigravity test", + providerInstanceId: ProviderInstanceId.make("antigravity"), + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: directory, + activeProviderThreadId: providerThread.id, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt-antigravity-v2"), + rootNodeId: NodeId.make("node-antigravity-v2"), + providerThread, + message: { + messageId: MessageId.make("message-antigravity-v2"), + text: "Say hello", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection, + runtimePolicy, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completedMessage = events.find( + (event) => + event.type === "message.updated" && + event.message.role === "assistant" && + !event.message.streaming, + ); + const completedTool = events.find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + const updatedThread = events.findLast((event) => event.type === "provider_thread.updated"); + const terminal = events.at(-1); + + assert.strictEqual( + completedMessage?.type === "message.updated" ? completedMessage.message.text : null, + "Hello world", + ); + assert.strictEqual( + completedTool?.type === "turn_item.updated" && + completedTool.turnItem.type === "dynamic_tool" + ? completedTool.turnItem.output + : null, + directory, + ); + assert.strictEqual( + updatedThread?.type === "provider_thread.updated" + ? updatedThread.providerThread.nativeConversationHeadRef?.nativeId + : null, + "conv-1", + ); + assert.strictEqual(terminal?.type, "turn.terminal"); + assert.strictEqual( + terminal?.type === "turn.terminal" ? terminal.status : null, + "completed", + ); + }), + ), + ); + + it.effect("rejects modes that require interactive approvals", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = makeAntigravityAdapterV2({ + instanceId: ProviderInstanceId.make("antigravity"), + settings: { enabled: true, binaryPath: "agy", customModels: [], launchArgs: "" }, + environment: process.env, + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + idAllocator: yield* IdAllocator.IdAllocatorV2, + path: yield* Path.Path, + serverConfig: yield* ServerConfig.ServerConfig, + }); + const result = yield* Effect.result( + adapter.openSession({ + threadId: ThreadId.make("thread-antigravity-approval"), + providerSessionId: ProviderSessionId.make("session-antigravity-approval"), + modelSelection: { + instanceId: ProviderInstanceId.make("antigravity"), + model: "gemini-3.6-flash", + options: [], + }, + runtimePolicy: { + runtimeMode: "approval-required", + interactionMode: "default", + cwd: process.cwd(), + }, + }), + ); + assert.isTrue(result._tag === "Failure"); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts new file mode 100644 index 000000000000..405168352255 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -0,0 +1,936 @@ +import { HostProcessEnvironment, isHostWindows } from "@t3tools/shared/hostProcess"; +import { + AntigravitySettings, + defaultInstanceIdForDriver, + ProviderDriverKind, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2TurnItem, + type ProviderInstanceId, + type ProviderTurnId, +} from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + antigravityTerminalStatus, + buildAntigravityTurnArgs, + decodeAntigravityLine, + normalizeAntigravityConversationId, + type AntigravityStreamEvent, +} from "../../provider/antigravity/AntigravityCli.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; +import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunUnsupportedError, + ProviderAdapterTurnStartError, + ProviderAdapterV2, + type ProviderAdapterV2EnsureThreadInput, + type ProviderAdapterV2Event, + type ProviderAdapterV2InterruptInput, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; + +export const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); +export const ANTIGRAVITY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ANTIGRAVITY_DRIVER_KIND); +const DEFAULT_SETTINGS = Schema.decodeSync(AntigravitySettings)({}); + +export const AntigravityProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: false, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: false, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: true, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: false, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: false, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: false, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: false, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: false, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: true, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: true, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: false, + }, + identity: { + nativeThreadIds: "weak", + nativeTurnIds: "none", + nativeItemIds: "weak", + nativeRequestIds: "none", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +interface ActiveTurn { + readonly input: ProviderAdapterV2TurnInput; + readonly providerTurnId: ProviderTurnId; + readonly startedAt: DateTime.Utc; + readonly completed: Deferred.Deferred; + readonly assistant: Map; + readonly tools: Map< + number, + { name: string; input: unknown; output?: string; startedAt: DateTime.Utc } + >; + child: ChildProcessSpawner.ChildProcessHandle | undefined; + conversationId: string | undefined; + interrupted: boolean; + finalized: boolean; + resultStatus: string | undefined; + resultError: string | undefined; + sawResult: boolean; + stderr: string; +} + +function providerSession(input: { + readonly sessionInput: ProviderAdapterV2OpenSessionInput; + readonly instanceId: ProviderInstanceId; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderSession { + return { + id: input.sessionInput.providerSessionId, + driver: ANTIGRAVITY_DRIVER_KIND, + providerInstanceId: input.instanceId, + status: "ready", + cwd: input.sessionInput.runtimePolicy.cwd ?? process.cwd(), + model: input.sessionInput.modelSelection.model, + capabilities: AntigravityProviderCapabilitiesV2, + createdAt: input.now, + updatedAt: input.now, + lastError: null, + }; +} + +function makeProviderThread(input: { + readonly idAllocator: IdAllocatorV2Shape; + readonly instanceId: ProviderInstanceId; + readonly session: OrchestrationV2ProviderSession; + readonly threadInput: ProviderAdapterV2EnsureThreadInput; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderThread { + const syntheticNativeId = `${input.instanceId}:${input.threadInput.threadId}`; + return { + id: input.idAllocator.derive.providerThread({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeThreadId: syntheticNativeId, + }), + driver: ANTIGRAVITY_DRIVER_KIND, + providerInstanceId: input.instanceId, + providerSessionId: input.session.id, + appThreadId: input.threadInput.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver: ANTIGRAVITY_DRIVER_KIND, + nativeId: syntheticNativeId, + strength: "weak", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: input.now, + updatedAt: input.now, + }; +} + +function conversationId(thread: OrchestrationV2ProviderThread) { + return normalizeAntigravityConversationId( + thread.nativeConversationHeadRef?.nativeId ?? undefined, + ); +} + +function nativeItemId(context: ActiveTurn, stepIndex: number, kind: string) { + return `${context.providerTurnId}:${kind}:${stepIndex}`; +} + +function itemOrdinal(context: ActiveTurn, stepIndex: number) { + return context.input.providerTurnOrdinal * 100 + stepIndex + 1; +} + +export function makeAntigravityAdapterV2(options: { + readonly instanceId: ProviderInstanceId; + readonly settings: AntigravitySettings; + readonly environment: NodeJS.ProcessEnv; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly idAllocator: IdAllocatorV2Shape; + readonly path: Path.Path; + readonly serverConfig: ServerConfig["Service"]; +}) { + const openSession = Effect.fn("AntigravityAdapterV2.openSession")( + function* (input: ProviderAdapterV2OpenSessionInput) { + if (input.runtimePolicy.runtimeMode !== "full-access") { + return yield* new ProviderAdapterProtocolError({ + driver: ANTIGRAVITY_DRIVER_KIND, + detail: + "Antigravity print mode requires full-access because it cannot service interactive approvals.", + }); + } + const now = yield* DateTime.now; + const sessionScope = yield* Effect.scope; + const session = providerSession({ sessionInput: input, instanceId: options.instanceId, now }); + const events = yield* Queue.unbounded(); + const activeTurn = yield* Ref.make(null); + const emit = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const providerTurn = ( + context: ActiveTurn, + status: OrchestrationV2ProviderTurn["status"], + completedAt: DateTime.Utc | null, + ): OrchestrationV2ProviderTurn => ({ + id: context.providerTurnId, + providerThreadId: context.input.providerThread.id, + nodeId: context.input.rootNodeId, + runAttemptId: context.input.attemptId, + nativeTurnRef: null, + ordinal: context.input.providerTurnOrdinal, + status, + startedAt: context.startedAt, + completedAt, + }); + + const emitAssistant = Effect.fnUntraced(function* ( + context: ActiveTurn, + stepIndex: number, + completed: boolean, + ) { + const assistant = context.assistant.get(stepIndex); + if (assistant === undefined || assistant.text.length === 0) return; + const at = yield* DateTime.now; + const nativeId = nativeItemId(context, stepIndex, "assistant"); + const nativeRef = { + driver: ANTIGRAVITY_DRIVER_KIND, + nativeId, + strength: "weak" as const, + }; + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeItemId: nativeId, + }); + const messageId = options.idAllocator.derive.messageFromProviderItem({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeItemId: nativeId, + }); + const turnItemId = options.idAllocator.derive.turnItemFromProviderItem({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeItemId: nativeId, + }); + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "assistant_message", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: nativeRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: assistant.startedAt, + completedAt: completed ? at : null, + }; + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + role: "assistant", + text: assistant.text, + attachments: [], + streaming: !completed, + createdAt: assistant.startedAt, + updatedAt: at, + }; + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: nativeRef, + parentItemId: null, + ordinal: itemOrdinal(context, stepIndex), + status: completed ? "completed" : "running", + title: null, + startedAt: assistant.startedAt, + completedAt: completed ? at : null, + updatedAt: at, + type: "assistant_message", + messageId, + text: assistant.text, + streaming: !completed, + }; + yield* emit({ type: "node.updated", driver: ANTIGRAVITY_DRIVER_KIND, node }); + yield* emit({ type: "message.updated", driver: ANTIGRAVITY_DRIVER_KIND, message }); + yield* emit({ type: "turn_item.updated", driver: ANTIGRAVITY_DRIVER_KIND, turnItem }); + }); + + const emitTool = Effect.fnUntraced(function* ( + context: ActiveTurn, + stepIndex: number, + completed: boolean, + ) { + const tool = context.tools.get(stepIndex); + if (tool === undefined) return; + const at = yield* DateTime.now; + const nativeId = nativeItemId(context, stepIndex, "tool"); + const nativeRef = { + driver: ANTIGRAVITY_DRIVER_KIND, + nativeId, + strength: "weak" as const, + }; + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeItemId: nativeId, + }); + const turnItemId = options.idAllocator.derive.turnItemFromProviderItem({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeItemId: nativeId, + }); + yield* emit({ + type: "node.updated", + driver: ANTIGRAVITY_DRIVER_KIND, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "tool_call", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: nativeRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: tool.startedAt, + completedAt: completed ? at : null, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: ANTIGRAVITY_DRIVER_KIND, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: nativeRef, + parentItemId: null, + ordinal: itemOrdinal(context, stepIndex), + status: completed ? "completed" : "running", + title: tool.name, + startedAt: tool.startedAt, + completedAt: completed ? at : null, + updatedAt: at, + type: "dynamic_tool", + toolName: tool.name || null, + input: tool.input, + ...(tool.output ? { output: tool.output } : {}), + }, + }); + }); + + const handleEvent = Effect.fnUntraced(function* ( + context: ActiveTurn, + event: AntigravityStreamEvent, + ) { + if (event.event === "init") { + context.conversationId = + normalizeAntigravityConversationId(event.conversation_id) ?? context.conversationId; + return; + } + if (event.event === "result") { + context.sawResult = true; + context.resultStatus = event.result.status; + context.resultError = event.result.error?.trim() || undefined; + context.conversationId = + normalizeAntigravityConversationId(event.result.conversation_id) ?? + context.conversationId; + return; + } + const step = event.step_update; + context.conversationId = + normalizeAntigravityConversationId(step.conversation_id) ?? context.conversationId; + if (step.step_type === "agent_response" && step.text_delta) { + const existing = context.assistant.get(step.step_index); + context.assistant.set(step.step_index, { + text: (existing?.text ?? "") + step.text_delta, + startedAt: existing?.startedAt ?? (yield* DateTime.now), + }); + yield* emitAssistant(context, step.step_index, step.state === "DONE"); + return; + } + if (step.step_type === "tool") { + const existing = context.tools.get(step.step_index); + context.tools.set(step.step_index, { + name: step.tool_name ?? step.tool_info?.name ?? existing?.name ?? "tool", + input: step.tool_info?.parameters ?? existing?.input ?? {}, + ...(step.tool_info?.output ? { output: step.tool_info.output } : {}), + startedAt: existing?.startedAt ?? (yield* DateTime.now), + }); + yield* emitTool(context, step.step_index, step.state === "DONE"); + } + }); + + const finalize = Effect.fnUntraced(function* (finalInput: { + readonly context: ActiveTurn; + readonly status: "completed" | "interrupted" | "cancelled" | "failed"; + readonly failure?: OrchestrationV2ProviderFailure; + }) { + const context = finalInput.context; + if (context.finalized) return; + context.finalized = true; + const at = yield* DateTime.now; + for (const stepIndex of context.assistant.keys()) + yield* emitAssistant(context, stepIndex, true); + for (const stepIndex of context.tools.keys()) yield* emitTool(context, stepIndex, true); + yield* emit({ + type: "provider_turn.updated", + driver: ANTIGRAVITY_DRIVER_KIND, + providerTurn: providerTurn(context, finalInput.status, at), + }); + const updatedThread: OrchestrationV2ProviderThread = { + ...context.input.providerThread, + providerSessionId: session.id, + nativeConversationHeadRef: context.conversationId + ? { + driver: ANTIGRAVITY_DRIVER_KIND, + nativeId: context.conversationId, + strength: "strong", + } + : context.input.providerThread.nativeConversationHeadRef, + status: "idle", + firstRunOrdinal: context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal, + lastRunOrdinal: context.input.runOrdinal, + updatedAt: at, + }; + yield* emit({ + type: "provider_thread.updated", + driver: ANTIGRAVITY_DRIVER_KIND, + providerThread: updatedThread, + }); + yield* emit( + finalInput.status === "failed" + ? { + type: "turn.terminal", + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + runOrdinal: context.input.runOrdinal, + failureItemOrdinal: context.input.providerTurnOrdinal * 100 + 99, + status: "failed", + failure: finalInput.failure ?? makeProviderFailure({ class: "provider_error" }), + threadDisposition: "reusable", + } + : { + type: "turn.terminal", + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + runOrdinal: context.input.runOrdinal, + status: finalInput.status, + failure: null, + threadDisposition: "reusable", + }, + ); + yield* Ref.set(activeTurn, null); + yield* Deferred.succeed(context.completed, undefined); + }); + + const promptWithAttachments = Effect.fnUntraced(function* ( + turnInput: ProviderAdapterV2TurnInput, + ) { + const paths: Array = []; + for (const attachment of turnInput.message.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: options.serverConfig.attachmentsDir, + attachment, + }); + if (attachmentPath === null) { + return yield* new ProviderAdapterProtocolError({ + driver: ANTIGRAVITY_DRIVER_KIND, + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + paths.push(attachmentPath); + } + return { + prompt: + paths.length === 0 + ? turnInput.message.text + : `${turnInput.message.text}\n\n${paths.map((path) => `[Attachment: ${path}]`).join("\n")}`, + addDirectories: [ + ...new Set([ + turnInput.runtimePolicy.cwd ?? process.cwd(), + ...paths.map(options.path.dirname), + ]), + ], + }; + }); + + const runProcess = Effect.fnUntraced(function* (context: ActiveTurn) { + const cwd = context.input.runtimePolicy.cwd ?? process.cwd(); + const attachmentPrompt = yield* promptWithAttachments(context.input); + const args = buildAntigravityTurnArgs({ + prompt: attachmentPrompt.prompt, + modelSelection: context.input.modelSelection, + conversationId: context.conversationId, + planMode: context.input.runtimePolicy.interactionMode === "plan", + addDirectories: attachmentPrompt.addDirectories, + launchArgs: options.settings.launchArgs, + }); + const binary = options.settings.binaryPath || "agy"; + const spawn = yield* resolveSpawnCommand(binary, [...args], { env: options.environment }); + const child = yield* options.spawner.spawn( + ChildProcess.make(spawn.command, spawn.args, { + env: options.environment, + cwd, + shell: spawn.shell, + stdin: "ignore", + }), + ); + context.child = child; + if (context.interrupted) { + yield* child.kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" }); + } + const stdout = child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => { + const event = decodeAntigravityLine(line); + return event === undefined ? Effect.void : handleEvent(context, event); + }), + ); + const stderr = child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.sync(() => { + context.stderr = (context.stderr + chunk).slice(-8_000); + }), + ), + ); + const stdoutFiber = yield* Effect.forkChild(stdout); + const stderrFiber = yield* Effect.forkChild(stderr); + const exitCode = Number(yield* child.exitCode); + yield* Fiber.join(stdoutFiber); + yield* Fiber.join(stderrFiber); + context.child = undefined; + if (context.interrupted) { + return yield* finalize({ context, status: "interrupted" }); + } + const status = + exitCode === 0 && context.sawResult + ? antigravityTerminalStatus(context.resultStatus) + : "failed"; + const failureMessage = + context.resultError || + context.stderr.trim() || + (!context.sawResult + ? "Antigravity CLI exited before reporting a result." + : `Antigravity CLI exited with code ${exitCode}.`); + return yield* finalize({ + context, + status, + ...(status === "failed" + ? { failure: makeProviderFailure({ message: failureMessage, class: "provider_error" }) } + : {}), + }); + }); + + const startTurn = Effect.fn("AntigravityAdapterV2.startTurn")( + function* (turnInput: ProviderAdapterV2TurnInput) { + const current = yield* Ref.get(activeTurn); + if (current !== null) { + return yield* new ProviderAdapterProtocolError({ + driver: ANTIGRAVITY_DRIVER_KIND, + detail: `Antigravity turn ${current.providerTurnId} is still active.`, + }); + } + if (!turnInput.message.text.trim() && turnInput.message.attachments.length === 0) { + return yield* new ProviderAdapterProtocolError({ + driver: ANTIGRAVITY_DRIVER_KIND, + detail: "Antigravity turns require text or an attachment.", + }); + } + if ((yield* isHostWindows) && turnInput.message.text.length > 24_000) { + return yield* new ProviderAdapterProtocolError({ + driver: ANTIGRAVITY_DRIVER_KIND, + detail: + "Antigravity prompts on Windows must stay below 24,000 characters because print mode passes the prompt through argv.", + }); + } + const startedAt = yield* DateTime.now; + const providerTurnId = options.idAllocator.derive.providerTurn({ + driver: ANTIGRAVITY_DRIVER_KIND, + nativeTurnId: `${turnInput.providerThread.id}:${turnInput.providerTurnOrdinal}`, + }); + const context: ActiveTurn = { + input: turnInput, + providerTurnId, + startedAt, + completed: yield* Deferred.make(), + assistant: new Map(), + tools: new Map(), + child: undefined, + conversationId: conversationId(turnInput.providerThread), + interrupted: false, + finalized: false, + resultStatus: undefined, + resultError: undefined, + sawResult: false, + stderr: "", + }; + yield* Ref.set(activeTurn, context); + yield* emit({ + type: "provider_turn.updated", + driver: ANTIGRAVITY_DRIVER_KIND, + providerTurn: providerTurn(context, "running", null), + }); + yield* emit({ + type: "provider_thread.updated", + driver: ANTIGRAVITY_DRIVER_KIND, + providerThread: { + ...turnInput.providerThread, + providerSessionId: session.id, + status: "active", + updatedAt: startedAt, + }, + }); + yield* runProcess(context).pipe( + Effect.catchCause((cause) => + finalize({ + context, + status: context.interrupted ? "interrupted" : "failed", + ...(context.interrupted + ? {} + : { failure: makeProviderFailure({ cause, class: "transport_error" }) }), + }), + ), + Effect.scoped, + Effect.forkIn(sessionScope), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: ANTIGRAVITY_DRIVER_KIND, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ); + + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: ANTIGRAVITY_DRIVER_KIND, + providerSessionId: input.providerSessionId, + providerSession: session, + events: Stream.fromEffectRepeat(Queue.take(events)), + ensureThread: Effect.fn("AntigravityAdapterV2.ensureThread")( + function* (threadInput: ProviderAdapterV2EnsureThreadInput) { + return makeProviderThread({ + idAllocator: options.idAllocator, + instanceId: options.instanceId, + session, + threadInput, + now: yield* DateTime.now, + }); + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: ANTIGRAVITY_DRIVER_KIND, + threadId: threadInput.threadId, + cause, + }), + ), + ), + ), + resumeThread: Effect.fn("AntigravityAdapterV2.resumeThread")( + function* ({ + providerThread, + }: { + readonly providerThread: OrchestrationV2ProviderThread; + }) { + return { + ...providerThread, + providerSessionId: session.id, + status: "idle" as const, + updatedAt: yield* DateTime.now, + }; + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerSessionId: session.id, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + ), + startTurn, + steerTurn: (turnInput) => + Effect.fail( + new ProviderAdapterSteerRunUnsupportedError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: turnInput.providerThread.id, + }), + ), + interruptTurn: Effect.fn("AntigravityAdapterV2.interruptTurn")( + function* (turnInput: ProviderAdapterV2InterruptInput) { + const context = yield* Ref.get(activeTurn); + if (context?.providerTurnId !== turnInput.providerTurnId) return; + context.interrupted = true; + if (context.child !== undefined) { + yield* context.child + .kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" }) + .pipe(Effect.ignore); + } + const settled = yield* Deferred.await(context.completed).pipe( + Effect.timeoutOption("10 seconds"), + ); + if (Option.isNone(settled)) yield* finalize({ context, status: "interrupted" }); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.fail( + new ProviderAdapterRuntimeRequestResponseError({ + driver: ANTIGRAVITY_DRIVER_KIND, + requestId: requestInput.requestId, + cause: new ProviderAdapterProtocolError({ + driver: ANTIGRAVITY_DRIVER_KIND, + detail: "Antigravity full-access turns do not open runtime requests.", + }), + }), + ), + readThreadSnapshot: (snapshotInput) => + Effect.fail( + new ProviderAdapterReadThreadSnapshotError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: snapshotInput.providerThread.id, + }), + ), + rollbackThread: (rollbackInput) => + Effect.fail( + new ProviderAdapterRollbackThreadError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: rollbackInput.providerThread.id, + }), + ), + forkThread: (forkInput) => + Effect.fail( + new ProviderAdapterForkThreadError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerThreadId: forkInput.sourceProviderThread.id, + }), + ), + }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + if (context?.child !== undefined) { + yield* context.child + .kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" }) + .pipe(Effect.ignore); + } + yield* Queue.shutdown(events); + }), + ); + return runtime; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: ANTIGRAVITY_DRIVER_KIND, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + ); + + return ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: ANTIGRAVITY_DRIVER_KIND, + getCapabilities: () => Effect.succeed(AntigravityProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + openSession, + }); +} + +export type AntigravityAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | IdAllocatorV2 + | Path.Path + | ServerConfig; + +export const AntigravityAdapterV2Driver: ProviderAdapterDriver< + AntigravitySettings, + AntigravityAdapterV2DriverEnv +> = { + driverKind: ANTIGRAVITY_DRIVER_KIND, + configSchema: AntigravitySettings, + defaultConfig: () => DEFAULT_SETTINGS, + create: Effect.fn("AntigravityAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + return makeAntigravityAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + idAllocator: yield* IdAllocatorV2, + path: yield* Path.Path, + serverConfig: yield* ServerConfig, + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: ANTIGRAVITY_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create the Antigravity V2 adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts new file mode 100644 index 000000000000..9a8b8cff74f0 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -0,0 +1,172 @@ +import { AntigravitySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { + AntigravityAdapterV2Driver, + type AntigravityAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAntigravityTextGeneration } from "../../textGeneration/AntigravityTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialAntigravityProviderSnapshot, + checkAntigravityProviderStatus, + enrichAntigravitySnapshot, +} from "../Layers/AntigravityProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("antigravity"); +const decodeSettings = Schema.decodeSync(AntigravitySettings); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, packageName: null }), +); + +export type AntigravityDriverEnv = + | AntigravityAdapterV2DriverEnv + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ServerConfig + | ServerSettingsService; + +const withIdentity = + (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: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const path = yield* Path.Path; + const serverSettings = yield* ServerSettingsService; + const { cwd } = yield* ServerConfig; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AntigravitySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + const orchestrationAdapter = yield* AntigravityAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Antigravity orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeAntigravityTextGeneration(effectiveConfig, processEnv); + const checkProvider = checkAntigravityProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + 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, + }), + }).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, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts new file mode 100644 index 000000000000..67f315fad3a4 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -0,0 +1,21 @@ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverSkillsFromRoots, type SkillRoot } from "./SkillDiscovery.ts"; + +export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* ( + cwd?: string, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const home = path.join(NodeOS.homedir(), ".gemini"); + const roots: ReadonlyArray = [ + { directory: path.join(home, "config", "skills"), scope: "user" }, + { directory: path.join(home, "antigravity-cli", "skills"), scope: "user" }, + ...(cwd ? [{ directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }] : []), + ]; + return yield* discoverSkillsFromRoots(roots); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 5c33fba0b9e9..839db31f84fa 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -17,44 +17,8 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { parse as parseYamlDocument } from "yaml"; - import { expandHomePath } from "../../pathExpansion.ts"; - -type ClaudeSkillScope = "user" | "project"; - -const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; - -type SkillFrontmatter = - | { readonly kind: "missing" } - | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; - -function parseSkillFrontmatter(contents: string): SkillFrontmatter { - const match = FRONTMATTER_PATTERN.exec(contents); - if (!match) { - return { kind: "missing" }; - } - - let parsed: unknown; - try { - parsed = parseYamlDocument(match[1] ?? ""); - } catch { - return { kind: "malformed" }; - } - if (typeof parsed !== "object" || parsed === null) { - return { kind: "malformed" }; - } - - const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const description = typeof record.description === "string" ? record.description.trim() : ""; - return { - kind: "parsed", - ...(name ? { name } : {}), - ...(description ? { description } : {}), - }; -} +import { discoverSkillsFromRoots, type SkillRoot } from "./SkillDiscovery.ts"; /** * Resolve the Claude config directory the CLI would use, matching the @@ -97,11 +61,10 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* cwd?: string, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); - const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [ @@ -111,45 +74,5 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* : []), ]; - const skillsByName = new Map(); - for (const root of roots) { - const entries = yield* fileSystem - .readDirectory(root.directory) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - - for (const entry of [...entries].sort()) { - const skillPath = path.join(root.directory, entry, "SKILL.md"); - const contents = yield* fileSystem - .readFileString(skillPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (contents === undefined) { - continue; - } - - const frontmatter = parseSkillFrontmatter(contents); - // Malformed frontmatter means the skill won't load in Claude Code - // either — skip it rather than surfacing a broken entry under its - // directory name. - if (frontmatter.kind === "malformed") { - continue; - } - - const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); - if (!name) { - continue; - } - - skillsByName.set(name, { - name, - path: skillPath, - enabled: true, - scope: root.scope, - ...(frontmatter.kind === "parsed" && frontmatter.description - ? { description: frontmatter.description } - : {}), - }); - } - } - - return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); + return yield* discoverSkillsFromRoots(roots); }); diff --git a/apps/server/src/provider/Drivers/SkillDiscovery.ts b/apps/server/src/provider/Drivers/SkillDiscovery.ts new file mode 100644 index 000000000000..5c04faad6a07 --- /dev/null +++ b/apps/server/src/provider/Drivers/SkillDiscovery.ts @@ -0,0 +1,59 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +export interface SkillRoot { + readonly directory: string; + readonly scope: "user" | "project"; +} + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +function metadata(contents: string) { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) return { valid: true } as const; + try { + const parsed: unknown = parseYamlDocument(match[1] ?? ""); + if (typeof parsed !== "object" || parsed === null) return { valid: false } as const; + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { valid: true, name, description } as const; + } catch { + return { valid: false } as const; + } +} + +export const discoverSkillsFromRoots = Effect.fn("discoverSkillsFromRoots")(function* ( + roots: ReadonlyArray, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skills = new Map(); + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) continue; + const parsed = metadata(contents); + if (!parsed.valid) continue; + const name = parsed.name || entry.trim(); + if (!name) continue; + skills.set(name, { + name, + path: skillPath, + enabled: true, + scope: root.scope, + ...(parsed.description ? { description: parsed.description } : {}), + }); + } + } + return [...skills.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts new file mode 100644 index 000000000000..8b2607bb41fa --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -0,0 +1,81 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AntigravitySettings } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildInitialAntigravityProviderSnapshot, + checkAntigravityProviderStatus, +} from "./AntigravityProvider.ts"; + +const decodeSettings = Schema.decodeSync(AntigravitySettings); + +describe("buildInitialAntigravityProviderSnapshot", () => { + it.effect("advertises plan mode and in-session model changes", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAntigravityProviderSnapshot(decodeSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.showInteractionModeToggle).toBe(true); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.badgeLabel).toBe("Early Access"); + }), + ); +}); + +it.layer(NodeServices.layer)("checkAntigravityProviderStatus", (it) => { + it.effect("discovers the authenticated TSV model catalog", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-antigravity-provider-", + }); + const binary = path.join(directory, "agy"); + yield* fileSystem.writeFileString( + binary, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "agy 1.1.12\\n"', + 'elif [ "$1" = "models" ]; then', + ' printf "gemini-3.6-flash-high\\tGemini 3.6 Flash (High)\\n"', + ' printf "gemini-3.6-flash-medium\\tGemini 3.6 Flash (Medium)\\n"', + ' printf "gemini-3.6-flash-low\\tGemini 3.6 Flash (Low)\\n"', + "else", + " exit 2", + "fi", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(binary, 0o755); + return yield* checkAntigravityProviderStatus( + decodeSettings({ binaryPath: binary }), + process.env, + directory, + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.version).toBe("1.1.12"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["gemini-3.6-flash"]); + }), + ); + + it.effect("reports a missing binary without exposing process details", () => + Effect.gen(function* () { + const snapshot = yield* checkAntigravityProviderStatus( + decodeSettings({ binaryPath: "/definitely/not-installed/agy" }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("not installed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts new file mode 100644 index 000000000000..2fbac2a9f84a --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -0,0 +1,245 @@ +import type { + AntigravitySettings, + ModelCapabilities, + ServerProvider, + ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { discoverAntigravitySkills } from "../Drivers/AntigravitySkills.ts"; +import { parseAntigravityModels } from "../antigravity/AntigravityCli.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const PRESENTATION = { + displayName: "Antigravity", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const FALLBACK_MODELS: ReadonlyArray = parseAntigravityModels( + [ + "gemini-3.6-flash-high\tGemini 3.6 Flash (High)", + "gemini-3.6-flash-medium\tGemini 3.6 Flash (Medium)", + "gemini-3.6-flash-low\tGemini 3.6 Flash (Low)", + ].join("\n"), +); + +function modelsFromSettings( + customModels: ReadonlyArray, + builtIn: ReadonlyArray = FALLBACK_MODELS, +) { + return providerModelsFromSettings(builtIn, customModels, EMPTY_CAPABILITIES); +} + +const runCommand = ( + settings: AntigravitySettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const binary = settings.binaryPath || "agy"; + const spawn = yield* resolveSpawnCommand(binary, [...args], { env: environment }); + return yield* spawnAndCollect( + binary, + ChildProcess.make(spawn.command, spawn.args, { + env: environment, + shell: spawn.shell, + stdin: "ignore", + }), + ); + }); + +function snapshot(input: { + readonly settings: AntigravitySettings; + readonly checkedAt: string; + readonly models: ReadonlyArray; + readonly skills?: ServerProviderDraft["skills"]; + readonly installed: boolean; + readonly version: string | null; + readonly status: "ready" | "warning" | "error"; + readonly auth: "authenticated" | "unauthenticated" | "unknown"; + readonly message?: string; +}) { + return buildServerProvider({ + presentation: PRESENTATION, + enabled: input.settings.enabled, + checkedAt: input.checkedAt, + models: input.models, + ...(input.skills ? { skills: input.skills } : {}), + probe: { + installed: input.installed, + version: input.version, + status: input.status, + auth: { status: input.auth }, + ...(input.message ? { message: input.message } : {}), + }, + }); +} + +export function buildInitialAntigravityProviderSnapshot(settings: AntigravitySettings) { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return snapshot({ + settings, + checkedAt, + models: modelsFromSettings(settings.customModels), + installed: settings.enabled, + version: null, + status: "warning", + auth: "unknown", + message: settings.enabled + ? "Checking Antigravity CLI availability..." + : "Antigravity is disabled in T3 Code settings.", + }); + }); +} + +export const checkAntigravityProviderStatus = Effect.fn("checkAntigravityProviderStatus")( + function* ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, + ): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + > { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallback = modelsFromSettings(settings.customModels); + if (!settings.enabled) { + return yield* buildInitialAntigravityProviderSnapshot(settings); + } + const skills = yield* discoverAntigravitySkills(cwd); + const versionResult = yield* runCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(4_000), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + return snapshot({ + settings, + checkedAt, + models: fallback, + skills, + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: "unknown", + message: isCommandMissingCause(versionResult.failure) + ? "Antigravity CLI (`agy`) is not installed or not on PATH." + : "Failed to execute Antigravity CLI health check.", + }); + } + if (Option.isNone(versionResult.success)) { + return snapshot({ + settings, + checkedAt, + models: fallback, + skills, + installed: true, + version: null, + status: "error", + auth: "unknown", + message: "Antigravity CLI timed out while running `agy --version`.", + }); + } + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return snapshot({ + settings, + checkedAt, + models: fallback, + skills, + installed: true, + version, + status: "error", + auth: "unknown", + message: "Antigravity CLI is installed but failed to run.", + }); + } + const modelsResult = yield* runCommand(settings, ["models"], environment).pipe( + Effect.timeoutOption(15_000), + Effect.result, + ); + if (Result.isFailure(modelsResult) || Option.isNone(modelsResult.success)) { + return snapshot({ + settings, + checkedAt, + models: fallback, + skills, + installed: true, + version, + status: "warning", + auth: "unknown", + message: "Antigravity CLI is installed but `agy models` did not answer.", + }); + } + const modelsOutput = modelsResult.success.value; + if (modelsOutput.code !== 0) { + return snapshot({ + settings, + checkedAt, + models: fallback, + skills, + installed: true, + version, + status: "warning", + auth: "unauthenticated", + message: "Antigravity CLI is installed but not signed in. Run `agy` to authenticate.", + }); + } + const discovered = parseAntigravityModels(modelsOutput.stdout); + return snapshot({ + settings, + checkedAt, + models: discovered.length ? modelsFromSettings(settings.customModels, discovered) : fallback, + skills, + installed: true, + version, + status: "ready", + auth: discovered.length ? "authenticated" : "unknown", + }); + }, +); + +export const enrichAntigravitySnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}) => + enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap(input.publishSnapshot), + 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 32f6448dab5f..61f35d299c20 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1614,7 +1614,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); + assert.deepStrictEqual( + spawnedCommands.filter((command) => command.startsWith("t3code_codex_")), + [firstMissing], + ); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1651,7 +1654,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.deepStrictEqual( + spawnedCommands.filter((command) => command.startsWith("t3code_codex_")), + [firstMissing, secondMissing], + ); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); @@ -1738,6 +1744,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, + antigravity: { + enabled: false, + }, }, }), ), @@ -1803,6 +1812,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/antigravity/AntigravityCli.test.ts b/apps/server/src/provider/antigravity/AntigravityCli.test.ts new file mode 100644 index 000000000000..73fa28f3240b --- /dev/null +++ b/apps/server/src/provider/antigravity/AntigravityCli.test.ts @@ -0,0 +1,122 @@ +import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + antigravityTerminalStatus, + buildAntigravityTurnArgs, + decodeAntigravityLine, + normalizeAntigravityConversationId, + parseAntigravityModels, + resolveAntigravityModel, +} from "./AntigravityCli.ts"; + +const selection = (model: string, effort?: string): ModelSelection => ({ + instanceId: ProviderInstanceId.make("antigravity"), + model, + options: effort ? [{ id: "effort", value: effort }] : [], +}); + +describe("Antigravity model discovery", () => { + it("groups current TSV effort variants into one selectable model", () => { + const models = parseAntigravityModels( + [ + "gemini-3.6-flash-high\tGemini 3.6 Flash (High)", + "gemini-3.6-flash-medium\tGemini 3.6 Flash (Medium)", + "gemini-3.6-flash-low\tGemini 3.6 Flash (Low)", + "claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)", + ].join("\n"), + ); + + expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([ + { slug: "gemini-3.6-flash", name: "Gemini 3.6 Flash" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" }, + ]); + expect(models[0]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ + id: "effort", + currentValue: "medium", + options: [{ id: "low" }, { id: "medium", isDefault: true }, { id: "high" }], + }); + }); + + it("resolves a grouped model back to the concrete CLI slug", () => { + expect(resolveAntigravityModel(selection("gemini-3.6-flash", "high"))).toBe( + "gemini-3.6-flash-high", + ); + expect(resolveAntigravityModel(selection("claude-sonnet-4-6"))).toBe("claude-sonnet-4-6"); + expect(resolveAntigravityModel(selection("gemini-3.6-flash"))).toBe("gemini-3.6-flash-medium"); + }); +}); + +describe("Antigravity turn arguments", () => { + it("starts a new project and applies plan mode, directories, and launch args", () => { + expect( + buildAntigravityTurnArgs({ + prompt: "hello", + modelSelection: selection("gemini-3.6-flash", "medium"), + conversationId: undefined, + planMode: true, + addDirectories: ["/work", "/attachments"], + launchArgs: '--verbose --theme "dark mode"', + }), + ).toEqual([ + "-p", + "hello", + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--print-timeout", + "24h", + "--model", + "gemini-3.6-flash-medium", + "--new-project", + "--mode", + "plan", + "--add-dir", + "/work", + "--add-dir", + "/attachments", + "--verbose", + "--theme", + "dark mode", + ]); + }); + + it("resumes a persisted conversation", () => { + const args = buildAntigravityTurnArgs({ + prompt: "continue", + modelSelection: selection("claude-sonnet-4-6"), + conversationId: "conversation-123", + planMode: false, + addDirectories: [], + launchArgs: "", + }); + expect(args).toContain("--conversation"); + expect(args).toContain("conversation-123"); + expect(args).not.toContain("--new-project"); + }); +}); + +describe("Antigravity stream decoding", () => { + it("decodes supported NDJSON and ignores noise", () => { + expect(decodeAntigravityLine("not json")).toBeUndefined(); + expect( + decodeAntigravityLine( + JSON.stringify({ + event: "result", + result: { conversation_id: "c1", status: "SUCCESS", response: "done" }, + }), + ), + ).toMatchObject({ event: "result", result: { conversation_id: "c1" } }); + }); + + it("does not replace a resume cursor with AGY's blank failed-result id", () => { + expect(normalizeAntigravityConversationId("")).toBeUndefined(); + expect(normalizeAntigravityConversationId(" c1 ")).toBe("c1"); + }); + + it("maps terminal statuses", () => { + expect(antigravityTerminalStatus("SUCCESS")).toBe("completed"); + expect(antigravityTerminalStatus("CANCELLED")).toBe("cancelled"); + expect(antigravityTerminalStatus("ERROR")).toBe("failed"); + }); +}); diff --git a/apps/server/src/provider/antigravity/AntigravityCli.ts b/apps/server/src/provider/antigravity/AntigravityCli.ts new file mode 100644 index 000000000000..92f0fd2de43c --- /dev/null +++ b/apps/server/src/provider/antigravity/AntigravityCli.ts @@ -0,0 +1,191 @@ +import type { ModelSelection, ServerProviderModel } from "@t3tools/contracts"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { createModelCapabilities, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Schema from "effect/Schema"; + +const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const EFFORTS = ["low", "medium", "high"] as const; +type AntigravityEffort = (typeof EFFORTS)[number]; + +interface DiscoveredVariant { + readonly slug: string; + readonly name: string; + readonly baseName: string; + readonly effort: AntigravityEffort | undefined; +} + +function parseVariant(line: string): DiscoveredVariant | undefined { + const [rawSlug, rawName] = line.split("\t", 2); + const slug = rawSlug?.trim().replace(/^[-*]\s*/, "") ?? ""; + if (!slug || slug.endsWith(":")) return undefined; + const name = rawName?.trim() || slug; + const match = /^(.*?)\s+\((Low|Medium|High)\)$/i.exec(name); + const effort = match?.[2]?.toLowerCase() as AntigravityEffort | undefined; + return { + slug, + name, + baseName: match?.[1]?.trim() || name, + effort, + }; +} + +function baseSlug(variants: ReadonlyArray): string { + const first = variants[0]!; + if (variants.length < 2 || first.effort === undefined) return first.slug; + const suffix = new RegExp(`-${first.effort}$`, "i"); + return first.slug.replace(suffix, ""); +} + +export function parseAntigravityModels(stdout: string): ReadonlyArray { + const groups = new Map>(); + for (const line of stdout.split(/\r?\n/)) { + const variant = parseVariant(line); + if (variant === undefined) continue; + const group = groups.get(variant.baseName) ?? []; + if (!group.some((candidate) => candidate.slug === variant.slug)) group.push(variant); + groups.set(variant.baseName, group); + } + + return [...groups.values()].map((variants) => { + const efforts = EFFORTS.filter((effort) => + variants.some((variant) => variant.effort === effort), + ); + const capabilities = + efforts.length > 1 + ? createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + type: "select", + label: "Effort", + currentValue: efforts.includes("medium") ? "medium" : efforts[0], + options: efforts.map((effort) => ({ + id: effort, + label: effort[0]!.toUpperCase() + effort.slice(1), + isDefault: effort === (efforts.includes("medium") ? "medium" : efforts[0]), + })), + }, + ], + }) + : EMPTY_CAPABILITIES; + return { + slug: baseSlug(variants), + name: variants.length > 1 ? variants[0]!.baseName : variants[0]!.name, + isCustom: false, + capabilities, + }; + }); +} + +export function resolveAntigravityModel(selection: ModelSelection): string { + const effort = getModelSelectionStringOptionValue(selection, "effort"); + if (!effort || !EFFORTS.includes(effort as AntigravityEffort)) { + return /^gemini-3\.(?:5|6)-flash$/u.test(selection.model) + ? `${selection.model}-medium` + : selection.model; + } + return EFFORTS.some((candidate) => selection.model.endsWith(`-${candidate}`)) + ? selection.model + : `${selection.model}-${effort}`; +} + +export function buildAntigravityTurnArgs(input: { + readonly prompt: string; + readonly modelSelection: ModelSelection; + readonly conversationId: string | undefined; + readonly planMode: boolean; + readonly addDirectories: ReadonlyArray; + readonly launchArgs: string; +}): ReadonlyArray { + return [ + "-p", + input.prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--print-timeout", + "24h", + "--model", + resolveAntigravityModel(input.modelSelection), + ...(input.conversationId ? ["--conversation", input.conversationId] : ["--new-project"]), + ...(input.planMode ? ["--mode", "plan"] : []), + ...input.addDirectories.flatMap((directory) => ["--add-dir", directory]), + ...tokenizeCliArgs(input.launchArgs), + ]; +} + +const Usage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + thinking_tokens: Schema.optional(Schema.Number), + cache_read_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), +}); + +const StepUpdate = Schema.Struct({ + conversation_id: Schema.optional(Schema.String), + step_index: Schema.Number, + state: Schema.String, + step_type: Schema.String, + text_delta: Schema.optional(Schema.String), + tool_name: Schema.optional(Schema.String), + tool_info: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + parameters: Schema.optional(Schema.Unknown), + output: Schema.optional(Schema.String), + }), + ), + subagent_info: Schema.optional(Schema.Unknown), + usage: Schema.optional(Usage), +}); + +export const AntigravityStreamEvent = Schema.Union([ + Schema.Struct({ + event: Schema.Literal("init"), + conversation_id: Schema.optional(Schema.String), + init: Schema.optional(Schema.Unknown), + }), + Schema.Struct({ event: Schema.Literal("step_update"), step_update: StepUpdate }), + Schema.Struct({ + event: Schema.Literal("result"), + result: Schema.Struct({ + conversation_id: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + response: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + usage: Schema.optional(Usage), + }), + }), +]); +export type AntigravityStreamEvent = typeof AntigravityStreamEvent.Type; + +const isStreamEvent = Schema.is(AntigravityStreamEvent); + +export function decodeAntigravityLine(line: string): AntigravityStreamEvent | undefined { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) return undefined; + try { + const parsed: unknown = JSON.parse(trimmed); + return isStreamEvent(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function normalizeAntigravityConversationId(value: string | undefined) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function antigravityTerminalStatus(status: string | undefined) { + switch (status?.toUpperCase()) { + case "SUCCESS": + return "completed" as const; + case "CANCELLED": + case "CANCELED": + return "cancelled" as const; + default: + return "failed" as const; + } +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2b..1254e551ea22 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -21,6 +21,7 @@ * @module provider/builtInDrivers */ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; +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"; @@ -35,6 +36,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; */ export type BuiltInDriversEnv = | AcpRegistryDriverEnv + | AntigravityDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -52,5 +54,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { + it("places Antigravity after OpenCode", () => { + const providers = [ + makeProvider(ANTIGRAVITY_DRIVER, { displayName: "Antigravity" }), + makeProvider(OPENCODE_DRIVER, { displayName: "OpenCode" }), + makeProvider(CODEX_DRIVER, { displayName: "Codex" }), + makeProvider(CLAUDE_AGENT_DRIVER, { displayName: "Claude" }), + ]; + + assert.deepStrictEqual( + orderProviderSnapshots(providers).map((provider) => provider.displayName), + ["Claude", "Codex", "OpenCode", "Antigravity"], + ); + }); + it.effect("logs structural diagnostics without retaining invalid cache contents", () => { const messages: Array = []; const logger = Logger.make((options) => { diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 2fe0424b4f57..bd5aab73e676 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,5 +1,5 @@ import { - type ProviderDriverKind, + ProviderDriverKind, type ProviderInstanceId, type ServerProvider, ServerProvider as ServerProviderSchema, @@ -16,6 +16,11 @@ const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), ); +const ANTIGRAVITY_DRIVER = ProviderDriverKind.make("antigravity"); + +const providerOrderBucket = (provider: ServerProvider): number => + provider.driver === ANTIGRAVITY_DRIVER ? 1 : 0; + const mergeProviderModels = ( fallbackModels: ReadonlyArray, cachedModels: ReadonlyArray, @@ -29,6 +34,7 @@ export const orderProviderSnapshots = ( ): ReadonlyArray => [...providers].toSorted( (left, right) => + providerOrderBucket(left) - providerOrderBucket(right) || (left.displayName ?? "").localeCompare(right.displayName ?? "") || left.driver.localeCompare(right.driver) || left.instanceId.localeCompare(right.instanceId), diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts new file mode 100644 index 000000000000..7cd936f569ac --- /dev/null +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -0,0 +1,240 @@ +import { + TextGenerationError, + type AntigravitySettings, + type ModelSelection, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveAntigravityModel } from "../provider/antigravity/AntigravityCli.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "./TextGenerationUtils.ts"; + +const OutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown }); +const decodeEnvelope = Schema.decodeEffect(Schema.fromJsonString(OutputEnvelope)); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGeneration")(function* ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runJson = (input: { + readonly operation: string; + readonly cwd: string; + readonly prompt: string; + readonly schema: S; + readonly modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const jsonSchema = yield* encodeJson(toJsonSchemaObject(input.schema)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "Failed to encode structured output schema.", + cause, + }), + ), + ); + const binary = settings.binaryPath || "agy"; + const spawn = yield* resolveSpawnCommand( + binary, + [ + "-p", + input.prompt, + "--output-format", + "json", + "--json-schema", + jsonSchema, + "--model", + resolveAntigravityModel(input.modelSelection), + "--dangerously-skip-permissions", + "--new-project", + "--print-timeout", + "3m", + ], + { env: environment }, + ); + const command = ChildProcess.make(spawn.command, spawn.args, { + env: environment, + cwd: input.cwd, + shell: spawn.shell, + stdin: "ignore", + }); + const output = yield* Effect.gen(function* () { + const child = yield* spawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("agy", input.operation, cause, "Failed to spawn Antigravity CLI."), + ), + ); + const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("agy", input.operation, cause, "Failed to collect CLI output."), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + if (Number(exitCode) !== 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: stderr.trim() || stdout.trim() || `Antigravity exited with code ${exitCode}.`, + }); + } + return stdout; + }).pipe( + Effect.scoped, + Effect.timeoutOption(180_000), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "Antigravity CLI request timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + normalizeCliError("agy", input.operation, cause, "Antigravity CLI request failed."), + ), + ); + const envelope = yield* decodeEnvelope(output).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "Antigravity CLI returned unexpected structured output.", + cause, + }), + ), + ); + const decodeOutput = Schema.decodeEffect(input.schema); + return yield* decodeOutput(envelope.structured_output).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "Antigravity CLI returned invalid structured output.", + cause, + }), + ), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { + const built = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt: built.prompt, + schema: built.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 built = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt: built.prompt, + schema: built.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 built = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt: built.prompt, + schema: built.outputSchema, + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { + const built = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt: built.prompt, + schema: built.outputSchema, + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..a434cbc316d1 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "antigravity" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..b5a6ec277605 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -643,7 +643,7 @@ const ANTIGRAVITY_ICON_DATA_URL = export const AntigravityIcon: Icon = (props) => ( - + ); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..e364044d7f93 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 45ae5a79921b..eb8fd8cfb0e2 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,5 +1,6 @@ import { AcpRegistrySettings, + AntigravitySettings, ClaudeSettings, CodexSettings, CursorSettings, @@ -10,6 +11,7 @@ import { import type * as Schema from "effect/Schema"; import { ACPRegistryIcon, + AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, @@ -102,6 +104,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + icon: AntigravityIcon, + badgeLabel: "Early Access", + 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 6d874ab59329..a2eaa5420186 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -44,6 +44,12 @@ export const PROVIDER_OPTIONS: Array<{ pickerSidebarBadge: "new", }, { value: ProviderDriverKind.make("grok"), label: "Grok", available: true }, + { + 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 1c8c16a0638b..dc5e757356ed 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [ANTIGRAVITY_DRIVER_KIND]: "Antigravity", [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2ae720410528..8a3f7063b4fb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -445,6 +445,41 @@ 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" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + launchArgs: Schema.String.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to each Antigravity turn.", + providerSettingsForm: { + placeholder: "e.g. --verbose", + clearWhenEmpty: "omit", + }, + }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type AntigravitySettings = typeof AntigravitySettings.Type; + export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; @@ -686,6 +721,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -780,6 +816,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const AntigravitySettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), + launchArgs: Schema.optionalKey(TrimmedString), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -828,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + antigravity: Schema.optionalKey(AntigravitySettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),