Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
df9a3f1
Add Antigravity (agy) provider via an ACP compatibility bridge
agentool Jul 25, 2026
9e113d7
Fix five Antigravity bridge defects found in review
agentool Jul 25, 2026
c73d5da
Antigravity: fix review findings, add model switching and attachments
agentool Jul 25, 2026
c2ac396
Antigravity: gate tool calls on user approval via the PreToolUse hook
agentool Jul 25, 2026
de53000
Antigravity: fix event ordering, map contention, and turn settlement
agentool Jul 25, 2026
4fc5329
Antigravity: close approval fail-open and cancel gaps
agentool Jul 25, 2026
fa32df5
Antigravity: require tool approval by default, fix approval and resum…
agentool Jul 26, 2026
14633dd
Antigravity: make cancellation authoritative and bound approval waits
agentool Jul 26, 2026
d87551f
Antigravity: baseline resumed transcripts, tighten gate and turn claims
agentool Jul 26, 2026
24ef69c
Antigravity: close the cancel window and the startup rollback gap
agentool Jul 27, 2026
74b160b
Antigravity: make the turn.started claim atomic and pin the resume ba…
agentool Jul 27, 2026
683b6a1
Antigravity: settle turns on stop, drain events before completing
agentool Jul 27, 2026
a5801cc
Antigravity: bound the event drain and harden teardown against races
agentool Jul 27, 2026
f694c88
Antigravity: bind approvals to their turn and fence cancels in transit
agentool Jul 27, 2026
cd9b0e3
Antigravity: serialize turn lifecycle against teardown
agentool Jul 27, 2026
7726024
Antigravity: fence cancels explicitly and bound the registries
agentool Jul 27, 2026
ae78d5f
Antigravity: fence cancels per prompt instead of per session
agentool Jul 27, 2026
fb04218
Antigravity: close the edges of the per-prompt cancel fence
agentool Jul 27, 2026
13b7543
Antigravity: cancel by epoch instead of tracking individual prompts
agentool Jul 27, 2026
e098cc4
Antigravity: count prompts per turn and keep untagged cancels working
agentool Jul 27, 2026
77684af
Antigravity: attribute streamed events to the turn that is actually r…
agentool Jul 27, 2026
9f0af5d
Antigravity: bind approvals to their turn, judge cancels by turn mode
agentool Jul 27, 2026
3ea7d09
Antigravity: one live turn at a time
agentool Jul 27, 2026
786ca8e
Antigravity: let the bridge answer a cancelled prompt itself
agentool Jul 27, 2026
7456c01
Antigravity: scope the cancel fallback to the turn it belongs to
agentool Jul 27, 2026
5c7e7d1
Antigravity: capture the cancelled turn before anything yields
agentool Jul 27, 2026
ef2ac27
Antigravity: authenticate approvals and cancel the whole process group
agentool Jul 27, 2026
3938f2c
Antigravity: rotate the approval key per turn and bind it to the payload
agentool Jul 27, 2026
207da62
Antigravity: approve exactly what was signed, and kill outright on sh…
agentool Jul 27, 2026
01a81b3
Antigravity: publish approval events and their bookkeeping atomically
agentool Jul 27, 2026
1a80321
Antigravity: sweep the publish/record pattern and serialize settlement
agentool Jul 27, 2026
73feed0
Antigravity: tear down a wedged bridge, order the approval deadlines
agentool Jul 27, 2026
68dce0c
Antigravity: check the fallback's turn under the gate that claims the…
agentool Jul 27, 2026
5fc28b0
Antigravity: stop the cancel fallback destroying healthy sessions
agentool Jul 27, 2026
f01390f
Antigravity: bound group kills to the leader's lifetime
agentool Jul 27, 2026
d65e6d1
Antigravity: own the child's process group from spawn
agentool Jul 27, 2026
5ea7be3
Antigravity: never signal a process group after its leader has gone
agentool Jul 27, 2026
fe41541
Antigravity: route Windows kills through the guard, hold unmatched re…
agentool Jul 27, 2026
f5bf71a
Antigravity: make a deferred transcript record a bounded barrier
agentool Jul 27, 2026
ce06735
Antigravity: age the deferral barrier per record, not per batch
agentool Jul 27, 2026
b39ee4a
Antigravity: document what a SIGKILLed bridge leaves behind
agentool Jul 27, 2026
914512b
Antigravity: bound captured output, unwedge the cancel fallback
agentool Jul 27, 2026
c81533a
Antigravity: bound a cancel to its own epoch, clean temp dirs on signal
agentool Jul 27, 2026
158dfe1
Antigravity: record a cancel's epoch before any early return, admit t…
agentool Jul 27, 2026
6576b9c
Antigravity: let interrupts through, claim the turn epoch with the se…
agentool Jul 27, 2026
2e6b5b1
Antigravity: bound transcript reads, reclaim hook files, close segmen…
agentool Jul 27, 2026
008a8f0
Antigravity: bound held transcript state, settle approvals with their…
agentool Jul 27, 2026
6e17b5f
Antigravity: replace the cancel and approval-settling gates that just…
agentool Jul 27, 2026
7b437e7
Antigravity: keep retained transcript records out of the discard path
agentool Jul 27, 2026
8b5aa98
Antigravity: order emissions by step, survive a failed kill, gate sta…
agentool Jul 27, 2026
ca1563b
Antigravity: keep unindexed transcript records in the position they w…
agentool Jul 27, 2026
3e9b4b5
Antigravity: bound priming for real, order the request against its to…
agentool Jul 27, 2026
4a6ee45
Antigravity: one deadline for the whole approval, keep the boundary o…
agentool Jul 27, 2026
2415f16
Antigravity: honour stdout backpressure, bind the opened request to i…
agentool Jul 27, 2026
7a7517f
Antigravity: make stdout backpressure real, size deferred records whole
agentool Jul 27, 2026
5ee8a41
Antigravity: stop reading the transcript when stdout starves the drain
agentool Jul 27, 2026
f7342f6
Antigravity: never report truncation for a transcript that was fully …
agentool Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,78 @@
- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index.
- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile.

## Antigravity provider

The `antigravity` driver wraps the Antigravity CLI (`agy`), which has **no native agent
protocol**. `apps/server/src/provider/acp/antigravity/agyBridge.ts` presents the ACP surface
`AcpSessionRuntime` needs and runs each turn through `agy --print`, shipping as hidden
subcommands of the server binary (`t3 agy-acp`, `t3 agy-hook`).

A live event stream is reconstructed from two documented Antigravity sources that correlate on
step index (`stepIdx` in a hook == `step_index` in the transcript):

- **Hooks** (`PreToolUse`/`PostToolUse`/`Stop`) give tool name, arguments, and status. The bridge
registers them via a throwaway `--add-dir` workspace so nothing is written into the user's repo.
Both the session cwd and that hook workspace must be passed — `--add-dir` replaces the workspace
rather than adding to cwd, so passing only the hook dir hides the project from the agent.
- **The trajectory transcript** (`transcript.jsonl`) gives assistant text and real tool output as
the turn runs. Its record format is undocumented, so parsing degrades to emitting nothing.

Known constraints, all inherent to print mode:

- `agy` always runs with `--dangerously-skip-permissions` because print mode cannot prompt.
Approvals instead come from the `PreToolUse` hook, which blocks the tool until T3 Code
answers — a `deny` decision genuinely stops it and is reported back to the model. Controlled
by the `requireToolApproval` setting, on by default; it fails closed on timeout, a lost
bridge, or any hook failure. Turn it off to let tools run unattended.
- **What the approval gate does and does not defend against.** Decisions travel as files in a
per-turn temp directory, signed with an HMAC over the hook, the verdict, and a digest of the
hook's own payload; the key is generated per turn and reaches the hook through the
environment `agy` passes down. That stops an unrelated process forging a decision, stops a
decision being replayed onto a different request, and stops a tool approved in one turn
signing for any later turn.
It does **not** contain a tool within the turn that approved it: that tool inherits the same
environment, so it can sign decisions for other tools in that turn. Closing that needs an
OS-level boundary — a broker socket or sandbox whose credentials are not inheritable — which
is outside what this provider can do while `agy` runs unsandboxed. The gate's purpose is to
stop the model acting unilaterally, not to contain code the user has already approved.
- Attachments are passed by path, not inline: the adapter sends `resource_link` blocks for
files the attachment store already wrote to disk, and the bridge names those paths in the
prompt and adds their directory to the workspace. `agy` has no attachment flag, so inline
image capability stays off.
- No session modes, and no provider-side rollback — a resumed conversation keeps its full
trajectory, so `rollbackThread` fails rather than silently truncating local state only.
- `--model` is a per-spawn flag that composes with `--conversation`, so a model switch keeps
the trajectory and applies from the next turn (`sessionModelSwitch: "in-session"`).
- Cancelling a turn kills `agy`'s whole process group, not just `agy`: tools it started
otherwise keep writing to the workspace after Stop. Every signal is guarded on the leader
still being alive, because a group id belongs to us only while it holds one — once the
leader is reaped the number is free, and a signal sent afterwards can land on whatever
inherited it. Nothing signals a group after its `exit`.
The cost is that a tool which deliberately outlives `agy` is not reaped. That is the same
boundary the approval gate draws: code the user has already approved can do as it likes,
including backgrounding itself. Reaping those would need a supervisor process that stays
in the group, or a Windows Job Object — neither reachable in plain Node.
The bridge also kills its child group on SIGTERM, SIGINT and normal exit, but a SIGKILL of
the bridge itself runs no handler, and `agy` is then reparented to init. POSIX offers no
portable parent-death signal (`PR_SET_PDEATHSIG` is Linux-only and not exposed by Node), so
a force-killed bridge can leave one `agy` behind until its `--print-timeout` elapses.
- 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.
- Runtime events are buffered without a cap, so a subscriber that stops consuming grows memory
until it resumes. This is the shape every adapter uses — Claude, Codex, Cursor, Grok and
OpenCode all publish through an unbounded queue or pub/sub, as does `ProviderService` itself —
so this provider follows it rather than diverging. Bounding it is a decision for the shared
runtime, not for one provider. What the bridge does bound is its own side: `writeMessage`
honours stdout backpressure and pauses polling while the pipe is behind, so a slow reader
cannot inflate the bridge process.
- A transcript record carrying no `step_index` is ordered by where it was read, inheriting the
preceding entry's key. Every record observed in practice carries one. If a planner response
without an index were read in the same poll as the `PreToolUse` hook of the step after it, the
tool call would be announced first. Hooks have to be ingested before transcript records — a
record has nothing to attach to otherwise — so ordering the two exactly would need a shared
sequence number Antigravity does not emit.

## 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,5 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeCrypto from "node:crypto";
import * as NodeURL from "node:url";
import * as NodeFS from "node:fs";

import type { ChatAttachment } from "@t3tools/contracts";
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
3 changes: 3 additions & 0 deletions 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,6 +50,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
authCommand,
projectCommand,
serviceCommand,
agyAcpCommand,
agyHookCommand,
cloudEnabled ? connectCommand : connectUnavailableCommand,
]),
);
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),
);

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;
}),
};
Loading
Loading