Skip to content

feat(agent): add Antigravity (agy CLI) as a direct-CLI agent - #741

Open
kaizhou-lab wants to merge 20 commits into
mainfrom
feat/antigravity-agy-cli
Open

feat(agent): add Antigravity (agy CLI) as a direct-CLI agent#741
kaizhou-lab wants to merge 20 commits into
mainfrom
feat/antigravity-agy-cli

Conversation

@kaizhou-lab

@kaizhou-lab kaizhou-lab commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Adds Antigravity (Google's agy CLI) as a first-class direct-CLI agent. agy does not speak ACP, so it gets its own AgentType and backend rather than being bent onto the ACP path.

Requested in iOfficeAI/AionUi#3764, iOfficeAI/AionUi#3454, iOfficeAI/AionUi#3516 and iOfficeAI/AionUi#3240 (the last one specifically as a Gemini CLI successor). iOfficeAI/AionUi#3385 shows the custom-agent workaround failing, which this replaces with a builtin.

Frontend counterpart: iOfficeAI/AionUi (branch feat/antigravity-agy-support) — the renderer needs a handful of small changes before agy is usable at all.

Shape

One process per turn: agy has no --input-format (its flag set is validated by Go's flag package, so an unknown flag is a hard error), which rules out the persistent-stdin FIFO the claude lane uses. Continuity comes from --conversation <id>, which agy resumes from its own store.

Permission is the part that shaped the design. agy cannot prompt in headless mode, and its PreToolUse hook can only tighten a decision — a hook returning allow cannot override a headless auto-deny. So the gate is opened by --dangerously-skip-permissions and every real decision comes from a hook that calls back into AionUi. Every failure path on that callback denies.

Two measured facts shape this, both of which contradicted an earlier assumption:

  • No mode is full auto. All three of agy's modes (default / accept-edits / plan) are refused the "command" permission without the flag and all three run commands with it — the flag alone decides. agy's own descriptions are accept-edits: file edits auto-approved (edits only) and plan: research & plan only. Full auto is therefore expressed as a sentinel mode id (yolo) travelling the same yolo_id / full_auto_mode_id channel every other agent uses, so a teammate or a scheduled run needs no backend special-casing; it is also offered in the mode list so a user can pick it deliberately. The sentinel is never forwarded as agy's --mode — agy accepts unknown mode values silently, so passing it would look fine and quietly fall back.
  • The hook is fail-open past ~30s. agy abandons a PreToolUse hook after roughly 30 seconds and runs the tool anyway. So the hook answers deny at 20s and retracts the card, which makes the refusal ours rather than agy's silent approval. Details under "Bugs found".

What works

Model discovery (agy models), the three-mode axis, workspace MCP servers (stdio + SSE; HTTP is skipped rather than mistranslated), skills as slash commands, queued input during a turn, and actionable sign-in errors.

Bugs found while verifying, and fixed here

agy models hung forever. It prints its list and then waits on stdin, so holding the handle open meant stdout never reached EOF and discovery never returned — the model picker stayed silently empty with no error anywhere. Verified against agy 1.1.9: stdin open leaves it running indefinitely, stdin closed exits in ~3s.

Antigravity was routed to the ACP manager. The guid page creates a conversation without a type and the backend derives it from the assistant's agent, so an agy conversation reaches the ACP factory. Falling through handed it to AcpAgentManager, which waited for an initialize handshake agy can never answer — surfacing as "The selected Agent failed to start" after 30s. Only conversations created with an explicit Antigravity type took the direct-CLI path, and the UI never sends one, so every backend test passed while the product was broken.

Mojibake in non-ASCII replies. agy cuts text_delta on byte boundaries, so a multi-byte character straddling two deltas arrives as two U+FFFD — "设计和构建" rendered as "设计\u{fffd}\u{fffd}构建". Confirmed by reading agy's raw stdout: --output-format stream-json contains U+FFFD where json and text contain none. The bytes are gone by then, so the deltas cannot be repaired; its result frame carries the same reply intact, and that is now the source. Trade-off: the reply appears when the turn ends rather than growing as deltas land. agy's deltas are coarse anyway (4 frames for a 290-character answer).

A foreign model id failed the turn outright. Before discovery lands the picker has no agy models to offer, so the conversation is seeded with whatever model the user last used elsewhere (e.g. gemini-3.1-pro-preview), which agy rejects with "invalid model selection". A model agy never reported is now dropped so it runs on its own default. An empty list means "not discovered yet", not "nothing is valid".

Usage vanished on returning to a conversation. GET /api/conversations/{id}/usage required a live task, so a reaped session answered NOT_FOUND — the indicator went blank exactly when the user came back, which is when it matters. It now falls back to the durable snapshot in acp_session.session_config.runtime.context_usage. This was not Antigravity-specific: every direct-CLI backend lost its usage the same way.

Antigravity conversations had no session row at all. Creation of the acp_session row was gated on AgentType::Acp with the comment "other agent types have no session-level state". That left agy with nowhere to persist the context-usage snapshot (the indicator read zero), its resume anchor, or the observed mode/model. The seeded current_mode_id was gated the same way, so the mode picker opened without its initial value. Every earlier check created conversations with an explicit type the UI never sends, which produced the ACP row and hid all of it.

The model list was re-discovered per session. agy's models belong to the signed-in account, not to a conversation, and the renderer fetches config options once — so a session that opened before discovery landed (~3s) showed "use the CLI's model" for its whole life. The list is now cached process-wide; empty results are never cached, so "not signed in" cannot get pinned.

Full auto could not be expressed at all. full_auto_mode_id returned the neutral default and yolo_id was NULL, so "run unattended" was indistinguishable from "the user picked the default mode" — and the backend, unable to tell, kept raising approval prompts nobody was there to answer, stalling teammates and scheduled runs. Deriving it from where the session came from (team? cron?) would need a new branch per future caller; the sentinel needs none.

The approval hook was fail-open, which quietly undermined this PR's main claim. The code deliberately had no deadline, assuming a permission card may wait indefinitely. It may not. With a hook that blocked for 180s and never answered, the tool executed at 29.5 / 29.7 / 29.8 / 30.0s across four runs (two tool types, two independent timing methods) while the card was still on screen — the user believes nothing has been approved, and whatever they click next changes nothing. agy does honour a real deny (a separate run confirmed the command is refused and the reason is reported back to the user), so the defect was purely the wait. Answering deny at 20s and retracting the card converts agy's fail-open into fail-closed.

One intermediate measurement read -3.7s (artifact before the hook call) and was traced to a flaw in the measuring script, not to agy: the hook overwrote a single timestamp file, so a second invocation erased the first. Re-measured with every invocation appended, it agreed with the rest at 29.7s.

The catalog write-back gave up too early. Its 5s window barely covered a ~3.4s cold discovery. It now watches for 35s while still publishing modes/commands at the original 5s mark, so a backend that legitimately reports no models is not held back.

Known limits

agy's own:

  • No context window or cost is reported, so the usage indicator shows the hollow-ring form: raw counts, never a percentage against a guessed denominator.
  • agy's builtin slash commands cannot be enumerated headlessly. agy commands/agy skills drop into the TUI, the binary yields no reliable list, and /help is answered by the model itself — not a source worth trusting. Workspace skills under .agents/skills are surfaced; the global ~/.agents/skills is not read by agy (verified with a probe skill).

Ours:

  • A permission card must be answered within 20s. That is not a UX choice: past ~30s agy stops waiting and runs the tool regardless, so a longer window would mean approving by inaction. A late answer refuses the tool and the user retries.
  • A first-ever conversation can still open before model discovery lands and latch onto an empty picker for that session's lifetime; later sessions reuse the cached list. Closing that fully needs the renderer to re-fetch config options, which is a frontend change.

Verification

Full gate green. Beyond unit and integration coverage, the flow was driven from the real AionUi UI — select the Antigravity assistant, send a message, get a reply — because both UI-path bugs above were invisible to bridge-created conversations, which carry an explicit type the UI never sends.

zk added 18 commits July 31, 2026 22:32
…type

Antigravity is driven through the `agy` CLI, which runs one process per turn
with `-p --output-format stream-json`. It is neither an ACP vendor (it does
not speak ACP) nor an in-process engine like Aionrs (it does spawn a child
process), so it gets its own AgentType instead of reusing `acp`.

- `full_auto_mode_id` returns "default": agy's full-auto is the
  `--dangerously-skip-permissions` flag, not a mode id; its mode axis is
  default / accept-edits / plan.
- `native_skills_dirs` returns `.agents/skills`, verified against agy 1.1.8 —
  a skill at `.agents/skills/<name>/SKILL.md` is discovered and executed in
  headless runs.
- `supports_new_conversation` now includes the new variant.

Exhaustive matches broken by the new variant are filled in so the workspace
still builds:

- conversation/service.rs: rules ride `preset_context`, same as ACP.
- conversation/session_context.rs: rejects with an explicit "not wired yet"
  error until AgentSessionKind::Antigravity and its factory land, rather
  than silently falling through to another backend.
- ai-agent agent_types_integration test: unreachable arm (that test only
  exercises Acp and Aionrs).
Models the NDJSON stream that `agy -p --output-format stream-json` emits:
`init`, `step_update` and `result` envelopes. Shapes are taken from captured
samples under protocols/samples/antigravity-cli/1.1.8, not inferred.

Tolerance is deliberate. agy self-updates (1.1.8 -> 1.1.9 was observed while
this was being written), so every field is optional or `#[serde(other)]`
guarded, an unknown step type degrades to `Unknown` instead of aborting the
turn, and `parse_line` returns None for blank lines and for the plain-text
notices agy interleaves with the stream.

Two shapes are load-bearing and have their own tests:

- `init.model` is the authoritative echo of the model actually in use. Its
  ABSENCE means agy rejected `--model` and silently fell back to its default;
  a bare or bogus id yields neither the key nor an error on stderr.
- a logged-out run fails fast with `status:"ERROR"`, an `error` string and an
  EMPTY `conversation_id`, which must never be persisted as a resume anchor.

`call_mcp_tool` parameters are covered too: agy routes every MCP tool through
that single tool and puts the real target in `ServerName` / `ToolName`.

The types have no production consumer until the translator lands, hence the
module-level dead_code allowance.
Folds the agy NDJSON stream into the backend-neutral event vocabulary the
reducer already understands, so no reducer or FSM change is required.

Mapping:
- init            -> BackendBound (the resume anchor)
- agent_response  -> MessageDelta
- tool ACTIVE     -> ToolCall; tool DONE/ERROR -> ToolResult
- result          -> TurnResult, plus UsageDelta when usage is present
- user_input / checkpoint / system_message / unknown -> nothing

Demux uses agy's `step_index`: it is monotonic within a conversation and
keeps counting across a `--conversation` resume, so `step-<index>` reliably
pairs a tool's ACTIVE frame with its DONE frame.

Three behaviours are pinned by tests because getting them wrong fails
silently rather than loudly:

- thinking: agy reports `thinking_tokens` but emits no thinking TEXT, so no
  ThoughtDelta is ever synthesized; doing so would render an empty card.
- resume anchor: a failed run reports `conversation_id: ""`. Binding that
  would launch the next turn with an empty `--conversation`.
- model fallback: a missing `init.model` is recorded as a fallback signal,
  since that absence is exactly how agy reports that it rejected `--model`
  and quietly used its default instead.
…r turn)

Implements BackendConnection / SessionBackend for the agy CLI. The shape
differs from the claude lane in one load-bearing way: agy has no
`--input-format`, so a TURN IS A PROCESS. `open_session` therefore spawns
nothing — it only registers the session and pre-seeds the resume anchor; the
first process appears on the first Send and exits when that turn ends.

argv: `-p <prompt> --output-format stream-json --dangerously-skip-permissions`,
plus `--add-dir <workspace>` and, when set, `--conversation` / `--model` /
`--mode`.

- `--add-dir` is not optional in practice: without it agy runs tools in its
  own scratch directory and cannot see any of the conversation's files.
- the permission gate is opened deliberately. agy cannot prompt in headless
  mode and soft-denies every confirmable tool with the gate shut, and a
  PreToolUse hook returning "allow" cannot override that (hooks tighten, they
  do not loosen). AionUi's own hook becomes the sole gatekeeper instead.
- no `--effort`: agy's model ids already carry the effort suffix, and a
  stripped id is silently ignored while it falls back to another model.

The reader task drains stdout, translates each line, and writes the agy
conversation id back to the SESSION's anchor so the next turn can resume. It
also clears the process slot on exit, so a later Cancel cannot try to kill an
already-exited pid. A run that ends without a `result` frame (cancel, crash)
still gets a synthesized terminal, so the FSM never hangs waiting for one.

A silently ignored `--model` is surfaced as a warn log — agy signals that
condition only by omitting `init.model`, with nothing on stderr.

Capabilities describe what agy can actually do: no steering (it ignores stdin
mid-turn), no rewind, text-only prompts, synthesized prompt-ack.
Completes the path from a conversation to a running agy process:
AgentSessionKind::Antigravity -> factory::antigravity ->
build_antigravity_instance -> AntigravityConnection -> SessionAgentTask.

build_antigravity_instance sits ALONGSIDE build_session_instance rather than
inside it. That function carries claude/codex-private assembly — cc-switch
provider env, the codex sandbox/approval derivation, persisted-effort replay,
and an `if claude {..} else {"codex"}` label that would silently mislabel any
third backend. The shared helpers (spec/mode/model resolution, the MCP fold,
spawn_env, catalog write-back) are reused; the private parts are not.

`cli_program` stays None on purpose: agy is a large native binary the user
installs themselves, not something the app bundles, so the backend spawns the
`agy` found on PATH.

Migration 034 seeds the builtin catalog row. Two schema details are easy to get
wrong and both fail silently:

- `agent_id` is NOT NULL since 030 and mirrors `id` for builtin rows. Omitting
  it fails the constraint, `INSERT OR IGNORE` swallows the failure, and the
  migration reports success while seeding nothing at all.
- `behavior_policy` must NOT carry `supports_team`. Migration 033 retired the
  `supports_team: false` form (it reads like a denial but is a no-op inside an
  OR) and a test enforces its absence. Team capability is derived instead.

McpSupportPolicy is written explicitly rather than falling through to the
all-true default: agy supports stdio and SSE only, and the fallback would let
users configure HTTP-transport MCP servers that write cleanly into agy's config
and then never connect.

Seed-row count assertions move 42 -> 43 for the new builtin.
agy cannot prompt for tool permission in headless mode, and a PreToolUse hook
can only TIGHTEN a decision, never loosen one. So the session opens agy's gate
with --dangerously-skip-permissions and registers AionUi's own binary as that
hook, making AionUi the sole gatekeeper — every decision made at runtime by the
user rather than by a policy file.

Flow: agy -> hook process (stdin JSON) -> callback endpoint -> the session's
permission card -> the user's choice -> hook stdout JSON -> agy.

Verified against agy 1.1.8 while building this:

- `hooks.json`'s `command` is a COMMAND LINE, not a bare path. agy word-splits
  it and honours double quotes, so the entry is `"<binary>" antigravity-hook`.
  Writing just the path would launch the whole backend instead of the hook, and
  leaving it unquoted would break any install path containing a space.
- agy passes its own environment to the hook child process, which is what makes
  the app -> factory -> spawn_env -> agy -> hook chain work at all.

The written `hooks.json` carries no policy, only the registration; a test
asserts no literal allow/deny ever reaches the file.

Safety properties, each pinned by a test:

- fail-closed everywhere. A missing, wrong or foreign token, an absent session,
  an unparseable request, an unreachable backend and a dropped answer channel
  all resolve to `deny`. An unapproved tool must never run just because the
  bridge was unhealthy.
- no stranded hooks. Turn end, cancel and teardown drain the pending table as
  denials. A hook left waiting holds agy's tool call open until --print-timeout
  (5 minutes), which the user experiences as an unexplained hang.
- per-conversation tokens, reissued on every session build, so a stale hook
  process cannot answer for a rebuilt session or for another conversation.

The endpoint is deliberately NOT behind auth_middleware: the hook is a local
process with no user session. Since this router applies auth per route group,
the handler verifies the token itself.

SessionBackend gains `request_external_permission`, defaulting to `Denied`.
Only Antigravity implements it — other backends raise permissions on their own
wire and are unaffected, and a backend that cannot ask the user never silently
allows.
Without this the model picker is empty and every turn silently runs on agy's
default model — the exact condition the earlier `init.model` warning reports.

`agy models` prints one id per line. Those ids already encode reasoning effort
(`-high` / `-medium` / `-low`) and agy only accepts a complete one, so they are
surfaced verbatim and `reasoning_efforts` stays empty. Advertising a separate
effort axis would let the UI assemble a stripped id, which agy drops while
falling back to another model without reporting anything.

Parsing rejects anything that is not a bare token, so the signed-out notice
("Error: Please sign in to view available models…") cannot end up in the picker
as if it were a model.

The probe runs OFF the open path. It costs a process launch, and a connection
is built per conversation, so probing inline would add that latency to every
session open for a list only the picker needs. The catalog write-back already
polls `capabilities()` for late discovery and picks the result up when it
lands; until then the session simply runs on agy's default.

Modes need no probe — agy's `--mode` axis is a fixed default / accept-edits /
plan — so they are reported directly, along with the current model and mode, so
both pickers highlight the active selection.
…ol names

Two halves of the same gap: without the config an Antigravity teammate has no
team tools at all, and without the name expansion every team step reads as an
indistinguishable "call_mcp_tool".

Config: agy has no per-run flag for MCP — it only reads files — so the session's
servers (team coordination first, then the user's) are written to
`<workspace>/.agents/mcp_config.json` before the first turn spawns. Workspace
scope is what gives each conversation its own set, and a workspace config is
honoured in headless runs (verified).

Transport mapping follows what agy actually supports, stdio and SSE:

- Stdio -> command / args / env (env becomes an object; we carry ordered pairs)
- Sse   -> serverUrl / headers
- Http  -> SKIPPED with a warning. Emitting it as `serverUrl` would produce a
  config that looks valid and then never connects, since SSE and
  streamable-HTTP are different protocols.

An empty server set removes the file rather than leaving one behind, so a
session cannot inherit the previous session's servers.

Names: agy routes every MCP tool through the single `call_mcp_tool` tool and
puts the real target in the parameters. Both the tool card and the permission
card now show `<server>/<tool>`; a malformed call degrades to the raw name
instead of producing "/"-junk. Non-MCP tools are untouched.

This closes the ordering risk noted in the plan: `team_capable` was already
true by derivation, so Antigravity could be picked for a team before anything
wrote its MCP config.
Two fixes for the same user-visible failure.

Deep probe: `POST /api/agents/{id}/health-check` routed agy into the
`try_connect_custom_agent` branch, which performs an ACP-style handshake. agy
does not speak ACP, so every health check failed with `acp_init_failed` — an
error about a protocol the user never chose. It now takes the same PATH +
`--version` path as the other direct CLIs.

Turn errors: agy reports a signed-out run as `authentication failed or timed
out`, which says nothing actionable. That case is now rewritten to name the fix
("run `agy` in a terminal to log in") while keeping the original text for
diagnosis. This message matters more than it looks: `agy --version` exits 0
whether or not the user is signed in, so probing CANNOT detect the condition
and this is the only signal they get.

Only the auth case is recognised. Other failures pass through untouched rather
than being given an invented explanation.
The input box no longer locks while a turn runs. agy genuinely cannot take
input mid-turn — writing to its stdin during a run is ignored, verified — so
there is no steering wire and `supported_commands.steer` stays false. But its
next turn is a fresh process regardless, so a message sent now can simply
become that next turn.

A Send arriving mid-turn is therefore accepted with `Admission::Queued` (an
existing state meaning "accepted, waiting for the current turn"), and the
reader starts it when the running process exits, resuming the same agy
conversation.

Details that matter:

- queued messages stay SEPARATE and ordered. Merging them would collapse two
  things the user said into one turn, and the second would never be echoed.
- Cancel discards the queue. The user asked to stop; running what they queued
  afterwards would be the opposite of what they meant.
- the reader holds a Weak self-reference: the trait's `dispatch(&self)` cannot
  thread an Arc through, and a strong one would keep the session alive forever.

This is a better outcome than the ACP backends, which set
`accepts_proactive_input: false` and leave the input box locked for the whole
turn.
agy has no way to list its commands — `agy commands` / `agy skills` both drop
into the TUI and headless has no equivalent — but `-p "/<skill-name>"` does
invoke a skill (verified). Since AionUi provisions those skills itself under
`.agents/skills/`, the command list is read off the skill files instead of
asked for.

Frontmatter parsing is deliberately minimal (only `name` and `description`) so
a skill using richer YAML elsewhere cannot break discovery. The folded `>-`
form agy's own builtin skills use is supported.

A malformed skill is skipped rather than fatal: one bad file must not cost the
user their whole command list. Results are sorted so the picker does not
reshuffle between sessions on filesystem ordering.
Model discovery hung forever and the picker stayed silently empty
(`available_models: None` in the catalog, no error anywhere).

`agy models` prints its list and then keeps running until stdin reaches
EOF. `probe_models` bound the stdin handle for the whole function, so the
pipe stayed open, stdout never reached EOF, and the read loop never
returned — verified against agy 1.1.9: stdin open leaves the process
alive indefinitely, stdin closed exits in ~3s.

Drop the stdin handle immediately, and add a 30s timeout that kills the
child, so a hung agy cannot leak a process from this detached task.

The regression test spawns a stand-in that waits on stdin the same way;
it times out and fails if the handle is held open again.
The write-back gave up 5s after session open. agy discovers its models by
running `agy models` off the open path, which takes ~3s cold and longer on
a slow network — close enough to the deadline that the model picker could
end up permanently empty for a session, with no error to explain it.

Keep polling for 35s for a late model list, while still publishing the
modes/commands partial at the original 5s mark so a backend that
legitimately reports no models (claude/codex fill capabilities from the
handshake) is not held back by the longer window.

Adds a test-only catalog channel so this logic can be exercised without
standing up a registry.
…as an ACP-family conversation

Picking Antigravity in the AionUi UI produced "The selected Agent failed
to start" after a 30s timeout, with a working agy installed.

The renderer puts every non-aionrs agent on the ACP chat surface, so an
agy conversation reaches the ACP factory. agy does not speak ACP: falling
through handed it to the AcpAgentManager, which waited for an initialize
handshake agy can never answer. Only conversations created with an
explicit Antigravity type — which the UI never sends — took the direct-CLI
path, so every backend test passed while the product was broken.

Resolve the runtime from the backend label instead, and delegate agy to
its own factory. The mapping is now a named function with tests, so the
"which runtime does this backend need" decision has one home rather than
being spread across inline string matches.
…ng the turn

Two problems visible from the UI.

The catalog row points at `logos/ai-major/antigravity.svg`, which was never
added, so every request 404'd and the agent rendered as a broken image in
the conversation list and header. The asset is Antigravity's own mark,
taken from the google-antigravity GitHub organisation avatar and embedded
at the size the UI actually renders it.

Separately, a turn could fail outright with "invalid model selection".
Model discovery runs off the open path, so before it lands the picker has
no agy models to offer and the conversation is seeded with whatever model
the user last used elsewhere (e.g. `gemini-3.1-pro-preview`), which agy
rejects. Drop a model agy never reported and let it use its own default:
running on the default beats failing the conversation over a picker default
the user never chose. An empty list means "not discovered yet", not
"nothing is valid", so it still passes the request through.
…its deltas

Chinese (and any non-ASCII) replies came back with mojibake: "设计和构建"
rendered as "设计���构建", both on screen and in the database.

agy cuts `text_delta` on byte boundaries, so a multi-byte character that
straddles two deltas is decoded as two U+FFFD before it ever reaches us —
verified by reading agy's raw stdout directly: `--output-format stream-json`
contains U+FFFD where `json` and `text` contain none. The bytes are gone by
then, so the deltas cannot be repaired.

Its `result` frame carries the same reply assembled once, and intact. Buffer
the assistant text and emit it from there instead. A failed or cancelled
turn has no authoritative reply, so the buffered deltas are still emitted —
agy's own mojibake beats losing the partial answer.

Trade-off: the reply now appears when the turn ends rather than growing as
deltas land. agy's deltas are coarse anyway (4 frames for a 290-character
answer), so this costs little, and a correct answer beats a smoothly
streamed corrupted one.
… the task is gone

Switching away from a conversation and back blanked the token usage
indicator: `GET /api/conversations/{id}/usage` answered
`NOT_FOUND: No active agent for this conversation`.

The handler required a live task, so a reaped session had no answer — but
the figure it needs is already durable in
`acp_session.session_config.runtime.context_usage`, and
`SessionAgentTask::get_usage` reads it from exactly there. The live-task
requirement defeated that: usage disappeared precisely when the user
returned to an idle conversation, which is when the indicator matters most.

Fall back to reading the persisted snapshot directly when no task is live.
Reproduced by restarting the backend (equivalent to a reaped task) against
a conversation with recorded usage: NOT_FOUND before, the same figure the
hot task reported after.

This is not Antigravity-specific — every direct-CLI backend lost its usage
the same way.
…ir state needs

Antigravity conversations had no `acp_session` row, so everything the
session is supposed to remember had nowhere to land: the context-usage
snapshot (the indicator read back zero), agy's resume anchor, and the
observed mode/model. Row creation was gated on `AgentType::Acp` with the
comment "other agent types have no session-level state" — true when it was
written, not true for a direct-CLI agent that resumes its own conversations.

The seeded `current_mode_id` was gated the same way, leaving agy's mode
picker without its initial value even though it has the same three-mode axis.

Also caches the discovered model list process-wide. agy's models belong to
the signed-in account, not to a conversation, so re-running `agy models` per
session only delays the picker — the renderer fetches config options once,
so a session that opens before discovery lands shows "use the CLI's model"
for its whole life. The first-ever session still races; later ones no longer
do. Empty results are never cached, so "not signed in" cannot get pinned.

Found by driving the real UI: every earlier check created conversations with
an explicit `type` the UI never sends, which produced the ACP row and hid all
of this.
zk added 2 commits August 1, 2026 23:19
…ps waiting

Two problems, both found by measuring agy instead of reasoning about it.

**Full auto had no way to be expressed.** `full_auto_mode_id` returned the
neutral `default` and `yolo_id` was NULL, so a caller meaning "run unattended"
(a teammate via `session_mode_for_backend`, a scheduled run via
`fallback_full_auto_mode`) was indistinguishable from a user who simply picked
the default mode — and the backend, unable to tell, kept asking for approval
nobody was there to give. Sniffing the session's origin instead would need a
new branch for every future caller.

`yolo` is now a sentinel mode id carried through the channel every other agent
already uses, and offered in the mode list so a user can choose full auto
deliberately. It is never forwarded as agy's `--mode`: agy accepts unknown mode
values silently, so passing it would look fine and quietly fall back.

Measured on agy 1.1.9, none of its modes is full auto: default / accept-edits /
plan are all refused the "command" permission without
`--dangerously-skip-permissions` and all three run commands with it. agy's own
wording is `accept-edits: file edits auto-approved` and `plan: research & plan
only` — the flag alone decides.

**The approval hook was fail-open past ~30s.** The old code deliberately had no
deadline, on the assumption that a card may wait indefinitely. It may not: agy
abandons a PreToolUse hook after ~30s and runs the tool anyway. Measured with a
hook that blocked 180s and never answered, the tool executed at 29.5 / 29.7 /
29.8 / 30.0s across four runs while the card was still on screen — the user
believes they have not approved anything, and whatever they click next changes
nothing. (agy does honour an actual `deny`; a separate run confirmed the command
is refused and the reason is reported back. The problem was only the wait.)

Answer `deny` at 20s and retract the card, turning agy's fail-open into
fail-closed: a slow user gets a refusal they can retry, never a tool that ran
behind a pending prompt. An answer given in time is unaffected.

Migration 034 carries the new `yolo_id` and mode list, plus an UPDATE, because
`INSERT OR IGNORE` leaves an already-seeded row untouched.
…n 034

The migration now seeds `yolo_id = 'yolo'` and a fourth mode, so the
assertions pinning NULL and three modes no longer describe the contract.
Both are rewritten to state why the sentinel exists, and a new assertion
pins that it is never the seeded `current_mode_id` — starting a fresh
conversation with approval prompts off would be a silent downgrade.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant