Skip to content

feat(agy): support Google Antigravity CLI as a first-class agent - #80

Merged
jiunbae merged 3 commits into
mainfrom
feat/antigravity-agy-support
Aug 21, 2026
Merged

feat(agy): support Google Antigravity CLI as a first-class agent#80
jiunbae merged 3 commits into
mainfrom
feat/antigravity-agy-support

Conversation

@jiunbae

@jiunbae jiunbae commented Aug 21, 2026

Copy link
Copy Markdown
Member

Why

The Gemini CLI is EOL upstream; Google replaced it with the Antigravity CLI (agy). Because agy keeps the ~/.gemini directory and still runs Gemini models, muxa's existing Gemini support looks like it carries over. It doesn't — and every mismatch fails silently.

Gemini CLI (gemini) Antigravity CLI (agy)
Hook config hooks key in ~/.gemini/settings.json its own ~/.gemini/config/hooks.json
Events SessionStart, Before/AfterAgent, Before/AfterTool, Notification, SessionEnd SessionStart, Pre/PostInvocation, Pre/PostToolUse, Stop
Payload snake_case, session_id camelCase protojson, conversationId

Confirmed against a live install: hooks written to settings.json make agy log loaded 0 named hooks from 0 hooks.json file(s) and carry on, and the Gemini adapter's required session_id would reject an agy payload outright. Meanwhile pane_current_command is agy, which classify_command didn't recognise — so agy panes weren't even discovered.

What

AgentKind::Antigravity as a separate kind, not a rename — both CLIs still work and can be installed side by side.

  • Adapter (adapters/antigravity.rs) covering all six events.
  • muxa hook agy CLI handler.
  • Transcript reader (adapters/antigravity_transcript.rs). No agy payload carries prompt or response text — they carry a transcriptPath. PreInvocation and Stop read it, mirroring what Claude Code's Stop hook already does here.
  • muxa init --component agy-hooks writing ~/.gemini/config/hooks.json, plus a matching muxa doctor check.
  • Bundled agy screen manifest, which supplies the one state agy's hooks cannot — see below.
  • Wired through everything else that enumerates agents: discovery, muxa agent start --agent agy, the watch spawn form, muxa timeline, MCP muxa_start_agent / muxa_call_peer, the dashboard kind filter, the herdr bridge, and the omp sink.

Docs: docs/ANTIGRAVITY.md, plus updates to README (en/ko), INSTALL, SCREEN_DETECTION, SINKS, MCP, TIMELINE, HERDR, CONFIGURATION, ARCHITECTURE, DASHBOARD.

Four findings that shaped the design

1. muxa hook agy must be fail-open and byte-silent.
agy reads a hook's stdout as a verdict. Empty stdout means "no opinion"; JSON without a valid decision blocks the call, and so does a non-zero exit. I hit this by accident during probing:

Encountered error in tool execution: tool call denied by pre-tool hook

So this handler writes zero bytes to stdout and exits 0 even on an unparseable payload, an unknown event flag, or a down daemon.

That guarantee was then being defeated from outside the handler: main() loads config.toml with ? before dispatching, so one TOML typo made every hook exit non-zero and blocked every tool call in every agy session. muxa hook … now degrades to defaults and reports the parse error on stderr; every other subcommand still fails loudly. (Released 0.8.34 → exit 1; this branch → exit 0.)

2. invocationNum is per-turn, not per-session.
Captured across a two-turn session: turn one ran invocations 0,1,2,3, turn two reset to 0,1. So PreInvocation at 0 is the turn boundary and the only place a prompt is reported; later invocations emit a Heartbeat (which also refreshes model — agy stamps modelName on every payload, more than Codex or the Gemini CLI give us).

3. Hook-authoritative precedence needed its first carve-out.
agy fires no hook when it raises an approval prompt, so an agy row could not reach WaitingInput at all. And because muxad skips screen inference on any pane a live hook row owns, installing muxa's agy hooks actively removed the one signal the manifest could have given. Hooks-or-attention is the wrong trade for the single most important thing muxa reports.

New AgentKind::hooks_report_attention() keys the exception. Claude Code, Codex, the Gemini CLI and opencode all return true and are completely unaffected; agy returns false, and for those rows the detector contributes exactly one signal — applied to the real row:

Screen says Row is Result
Blocked not waiting, not Error WaitingInput
Idle waiting Idle (releases a stuck wait)
anything else nothing

Three exclusions, each a bug if reversed: no Heartbeat (it would overwrite the hook-supplied model), Working is left to the hooks (milliseconds vs a seconds-late tick), and no synthetic row is mintedStore::apply evicts synthetic rows from a pane a real row owns, so one would flap in and out on every hook event. Refined panes are also never tracked, since that set drives the stop-sweep and a real row isn't ours to stop.

4. Seeing the real prompts fixed the manifest.
agy's command prompt renders numbered > 1. Yes / 4. No rows — the label-based patterns would have missed it had option 2 not happened to contain "always allow". Now anchored on Requesting permission for:, the header agy prints above every request whatever the rows look like, plus the cursor-marked numbered row.

Testing

End to end against agy 1.1.17, isolated muxad, real tmux panes:

  • Fresh session: idle → working → idle, with prompt, response, model, cwd, conversation id and tool events all landing on the row.
  • Resumed session (agy -c, no SessionStart fires): lands on the same row via conversationId, prompts recovered from the transcript for both turns.
  • Attention refinement, on both prompt shapes agy renders (numbered command prompt and file-creation prompt): working → waiting_input → approve → idle, with the hook-supplied model and response intact throughout.
  • All four fail-open paths: valid payload, malformed JSON, daemon down, unknown event → exit=0, 0 bytes on stdout.
  • muxa init --component agy-hooksmuxa doctor--uninstall round-trip in an isolated HOME; doctor is silent when agy isn't installed and requires all six events when it is.
  • muxa agent start --agent agy launches agy --dangerously-skip-permissions.

Suite: cargo test --workspace --no-fail-fast — everything green except upgrade::tests::dry_run_renders_plan, which asserts systemctl --user restart muxad and already fails on main under macOS. ~60 new unit tests. cargo clippy --workspace --all-targets introduces no new diagnostics versus main (client_kind_from_arg dead-code is pre-existing).

Note for reviewers: tests/e2e.rs locates muxad at target/debug/muxad, so on a tree that has only ever been release-built it fails to spawn and takes 10 tests down with it. cargo build -p muxad first.

Known gaps (agy-side, documented not worked around)

  • No session-end hookStop is a turn boundary. agy rows are reaped by pane liveness, like Codex's.
  • workspacePaths is empty in print mode, so cwd is absent for agy -p. A hook's own cwd is the hooks.json directory, so there's nothing to fall back to.
  • No rate-limit or cost signal. modelName rides on every payload; the usage fields stay None.
  • agy caches hooks in a long-lived backend process — editing hooks.json needs an agy restart to take effect. Called out in the docs.

🤖 Generated with Claude Code

jiunbae and others added 3 commits August 22, 2026 01:34
agy replaced the Gemini CLI upstream and kept the `~/.gemini` directory,
which made muxa's existing Gemini support look like it carried over. It
does not — all three things muxa needs are different, and every mismatch
fails silently:

  * hooks live in `~/.gemini/config/hooks.json` (or a workspace's
    `.agents/hooks.json`), not the `hooks` key of `settings.json`. Hooks
    written to the old location make agy log `loaded 0 named hooks`.
  * the lifecycle is SessionStart / PreInvocation / PostInvocation /
    PreToolUse / PostToolUse / Stop. There is no Notification and no
    SessionEnd.
  * payloads are camelCase protojson keyed on `conversationId`, so the
    Gemini adapter's required `session_id` rejects them outright.

Add `AgentKind::Antigravity`, an adapter covering all six events, and a
`muxa hook agy` handler. Neither prompts nor responses appear in any agy
payload, so PreInvocation and Stop read them from the transcript agy
points at; PreInvocation only reports a prompt at `invocationNum == 0`,
the turn boundary, so a multi-invocation turn does not restate it.

`muxa hook agy` is fail-open and byte-silent on stdout. agy reads a
hook's stdout as a verdict and treats a non-zero exit or a decision-less
reply as `tool call denied by pre-tool hook`, so a muxa parse error or a
down daemon must never be able to block the user's tool call.

Also wire agy through the surfaces that enumerate agents: discovery,
`muxa init --component agy-hooks` (owning one named key so plugin and
/hooks entries survive), `muxa doctor`, `muxa agent start --agent agy`,
the watch spawn form, `muxa timeline`, MCP `muxa_start_agent`, the
dashboard kind filter, the herdr bridge, and the omp sink — the last
under its own `antigravity` slug rather than as `gemini`.

Ship a bundled `agy` screen manifest for panes with no hooks wired. It
is the only path to `WaitingInput` for agy, which exposes no permission
hook. Unlike the other bundled manifests its patterns come from a real
agy 1.1.17 session rather than being written blind.

Verified end to end against agy 1.1.17: hooks install, a live tmux pane
transitions idle -> working -> idle, and prompt, response, model, cwd,
conversation id, and tool events all land on the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fail-open guarantee was the important one. `muxa hook agy` swallows
its own errors so agy can never read a muxa failure as
`tool call denied by pre-tool hook` — but `main()` loads config.toml with
`?` *before* dispatching to the handler, so a single TOML typo made every
hook exit non-zero and blocked every tool call in every agy session, with
nothing on screen naming the cause. `muxa hook …` now degrades to
defaults and reports the parse error on stderr; every other subcommand
still fails loudly. Verified against the released 0.8.34 binary (exit 1)
versus this branch (exit 0).

Also:

  * `muxa_call_peer`'s `spawn_agent` enum and `target` description were
    missed when `muxa_start_agent` was updated, so a schema-validating
    MCP client rejected `spawn_agent: "agy"` — which muxa itself now
    suggests via `agent_program_label`.
  * `antigravity_hook_configured` passed on any single muxa command, so
    a partial block left by an older muxa reported as installed while
    prompts and tools silently never arrived. It now requires all six
    events, counted across named keys so a hand-split config still
    passes.
  * `muxa doctor` only reports the Antigravity line when agy is actually
    installed. Unlike the other three agents a missing hooks.json is the
    normal state for a fresh agy, so an unconditional check turned a red
    line — and a remedy that wires an agent they do not have — on for
    every existing user.
  * `files::antigravity::upsert` refused unparseable JSON but happily
    replaced a valid non-object root (`[]`, `5`) with `{}`. That is the
    same data loss its own test name forbids; it now errors. `remove`
    stays lenient — nothing of ours can live in a root we don't
    recognize.
  * The `agy` screen manifest matched only `agy` while
    `classify_command` accepts `antigravity` too, so a pane discovery
    called an Antigravity agent got no screen detection at all.
  * `detect_antigravity` was inserted between `opencode_config_dir` and
    its doc comment, re-parenting three lines about opencode's state
    directory onto the agy probe.

Not addressed here: screen detection is skipped for any pane a live hook
row owns, so installing agy hooks costs the `WaitingInput` signal the
manifest would otherwise provide. Nothing regresses relative to main —
agy panes produced no row there at all — and a naive per-kind exemption
is wrong, because the synthetic producer mints `synthetic-*` rows that
`Store::apply` evicts on the next hook event, flapping a duplicate row
per pane. Doing it properly means teaching the synthetic layer to refine
an existing real row, which changes shared machinery behind herdr and
five other manifests. Left as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the gap left open in the previous commit. agy fires no hook when
it raises an approval prompt, so an agy row could not reach
`WaitingInput` at all — and because `muxad` skips screen inference on any
pane a live hook row owns, installing muxa's agy hooks actively removed
the one signal the bundled manifest could have provided. The user's
choice was hooks-or-attention, which is the wrong trade for the one thing
muxa exists to report.

Hook-authoritative precedence gains its first carve-out, keyed on a new
`AgentKind::hooks_report_attention()`. Claude Code (`Notification`),
Codex (`PermissionRequest`), the Gemini CLI (`Notification`) and opencode
(`permission.asked`) all report `true` and behave exactly as before; agy
reports `false`.

For such a row `synthetic::pane_ownership` returns `AttentionBlind`
instead of `Hooked`, and the detector applies `attention_refinement_events`
to the REAL row:

  * `Blocked`, row not already waiting and not `Error` → `NeedsInput`.
  * `Idle`, row waiting → `TurnStopped { idle_confirmed: true }`, the
    backstop for a hook stream that stopped arriving mid-wait.
  * anything else → nothing.

Three deliberate exclusions, each a bug if reversed. No `Heartbeat`: the
row's `model` is what its hooks reported (`gemini-3.7-flash-high`), and
the manifest name would overwrite it. `Working` is left to the hooks,
which report it in milliseconds against a seconds-late tick. And no
synthetic row is minted — `Store::apply` evicts synthetic rows from a
pane a real row owns, so one would be evicted on every hook event and
re-minted on every tick, flapping a duplicate row forever. Refined panes
are also never added to `tracked`, which drives the stop-sweep: a real row
is not screen detection's to stop. `last_state` pruning moved off
`tracked` accordingly, or it would leak an entry per agy pane ever seen.

Seeing agy's real permission prompts also improved the manifest. The
command prompt renders numbered `> 1. Yes` / `4. No` rows, which the
label-based patterns would have missed had option 2 not happened to
contain "always allow". Anchor on `Requesting permission for:` — the
header agy prints above every request, whatever the rows look like — plus
the cursor-marked numbered row.

Verified against a live agy 1.1.17 pane on both prompt shapes agy
renders: `working` -> `waiting_input` -> approve -> `idle`, with the
hook-supplied model and response intact throughout.

Test-suite note: `tests/e2e.rs` locates `muxad` at `target/debug/muxad`,
so it had been failing to spawn (and taking 10 tests with it) on a tree
that had only ever been release-built. With that binary present the whole
workspace passes except `upgrade::tests::dry_run_renders_plan`, which
asserts `systemctl --user` and already fails on main under macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiunbae
jiunbae merged commit e6dd0b9 into main Aug 21, 2026
5 checks passed
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