Skip to content
Closed
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@
- `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:

- Tools are auto-approved (`--dangerously-skip-permissions`); no approval flow is possible.
- No image attachments, and no session modes.
- The model binds when the bridge spawns `agy`, so `sessionModelSwitch` is `"unsupported"`.
- 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
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeCrypto from "node:crypto";
import * as NodeFS from "node:fs";
import * as NodeURL from "node:url";

import type { ChatAttachment } from "@t3tools/contracts";

Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,13 +50,15 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
authCommand,
projectCommand,
serviceCommand,
agyAcpCommand,
agyHookCommand,
cloudEnabled ? connectCommand : connectUnavailableCommand,
]),
);

export const cli = makeCli();

if (import.meta.main) {
if (import.meta.main || (process.argv[1] && process.argv[1].includes("bin.ts"))) {
Command.run(cli, { version: packageJson.version }).pipe(
Effect.scoped,
Effect.provide(CliRuntimeLayer),
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/cli/agy.ts
Original file line number Diff line number Diff line change
@@ -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
* <event>` 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))),
);
177 changes: 177 additions & 0 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
@@ -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<AntigravitySettings, AntigravityDriverEnv> = {
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),
);
Comment on lines +126 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkAntigravityProviderStatus now requires ChildProcessSpawner | FileSystem.FileSystem | Path.Path (it reads the CLI log dir and onboarding.json through FileSystem/Path), but only ChildProcessSpawner is provided here, so checkProvider still carries unsatisfied requirements while makeManagedServerProvider expects Effect<ServerProvider, ServerSettingsError> with no environment.

Suggest acquiring both services from the environment next to spawner and providing them, the way GrokDriver provides Crypto for its probe:

       const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+      const fileSystem = yield* FileSystem.FileSystem;
+      const path = yield* Path.Path;
@@
       const checkProvider = checkAntigravityProviderStatus(effectiveConfig, processEnv).pipe(
         Effect.map(stampIdentity),
         Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+        Effect.provideService(FileSystem.FileSystem, fileSystem),
+        Effect.provideService(Path.Path, path),
       );

Posted via Macroscope — Effect Service Conventions


const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<
ProviderSnapshotSettings<AntigravitySettings>
>({
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;
}),
};
74 changes: 74 additions & 0 deletions apps/server/src/provider/Layers/AntigravityAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";

import {
ApprovalRequestId,
AntigravitySettings,
ProviderDriverKind,
ThreadId,
} from "@t3tools/contracts";

import * as ServerConfig from "../../config.ts";
import { makeAntigravityAdapter } from "./AntigravityAdapter.ts";

const decodeAntigravitySettings = Schema.decodeSync(AntigravitySettings);

const antigravityAdapterTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-agy-adapter-test-",
}).pipe(Layer.provideMerge(NodeServices.layer));

it.layer(antigravityAdapterTestLayer)("AntigravityAdapterLive", (it) => {
it.effect("declares sessionModelSwitch as in-session in capabilities", () =>
Effect.gen(function* () {
const adapter = yield* makeAntigravityAdapter(decodeAntigravitySettings({}));
assert.equal(adapter.capabilities.sessionModelSwitch, "in-session");
assert.equal(adapter.provider, ProviderDriverKind.make("antigravity"));
}),
);

it.effect(
"returns ProviderAdapterSessionNotFoundError for respondToRequest on unknown thread",
() =>
Effect.gen(function* () {
const adapter = yield* makeAntigravityAdapter(decodeAntigravitySettings({}));
const threadId = ThreadId.make("agy-nonexistent-thread");

const error = yield* Effect.flip(
adapter.respondToRequest(threadId, ApprovalRequestId.make("req-1"), "accept"),
);

assert.equal(error._tag, "ProviderAdapterSessionNotFoundError");
}),
);

it.effect(
"returns ProviderAdapterSessionNotFoundError for respondToUserInput on unknown thread",
() =>
Effect.gen(function* () {
const adapter = yield* makeAntigravityAdapter(decodeAntigravitySettings({}));
const threadId = ThreadId.make("agy-nonexistent-thread");

const error = yield* Effect.flip(
adapter.respondToUserInput(threadId, ApprovalRequestId.make("req-1"), {}),
);

assert.equal(error._tag, "ProviderAdapterSessionNotFoundError");
}),
);

it.effect(
"returns ProviderAdapterSessionNotFoundError for rollbackThread on unknown thread",
() =>
Effect.gen(function* () {
const adapter = yield* makeAntigravityAdapter(decodeAntigravitySettings({}));
const threadId = ThreadId.make("agy-nonexistent-thread");

const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1));

assert.equal(error._tag, "ProviderAdapterSessionNotFoundError");
}),
);
});
Loading
Loading