feat(agy): support Google Antigravity CLI as a first-class agent - #80
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The Gemini CLI is EOL upstream; Google replaced it with the Antigravity CLI (
agy). Because agy keeps the~/.geminidirectory and still runs Gemini models, muxa's existing Gemini support looks like it carries over. It doesn't — and every mismatch fails silently.gemini)agy)hookskey in~/.gemini/settings.json~/.gemini/config/hooks.jsonSessionStart,Before/AfterAgent,Before/AfterTool,Notification,SessionEndSessionStart,Pre/PostInvocation,Pre/PostToolUse,Stopsession_idconversationIdConfirmed against a live install: hooks written to
settings.jsonmake agy logloaded 0 named hooks from 0 hooks.json file(s)and carry on, and the Gemini adapter's requiredsession_idwould reject an agy payload outright. Meanwhilepane_current_commandisagy, whichclassify_commanddidn't recognise — so agy panes weren't even discovered.What
AgentKind::Antigravityas a separate kind, not a rename — both CLIs still work and can be installed side by side.adapters/antigravity.rs) covering all six events.muxa hook agyCLI handler.adapters/antigravity_transcript.rs). No agy payload carries prompt or response text — they carry atranscriptPath.PreInvocationandStopread it, mirroring what Claude Code'sStophook already does here.muxa init --component agy-hookswriting~/.gemini/config/hooks.json, plus a matchingmuxa doctorcheck.agyscreen manifest, which supplies the one state agy's hooks cannot — see below.muxa agent start --agent agy, the watch spawn form,muxa timeline, MCPmuxa_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 agymust be fail-open and byte-silent.agy reads a hook's stdout as a verdict. Empty stdout means "no opinion"; JSON without a valid
decisionblocks the call, and so does a non-zero exit. I hit this by accident during probing:So this handler writes zero bytes to stdout and exits
0even on an unparseable payload, an unknown event flag, or a down daemon.That guarantee was then being defeated from outside the handler:
main()loadsconfig.tomlwith?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.
invocationNumis per-turn, not per-session.Captured across a two-turn session: turn one ran invocations
0,1,2,3, turn two reset to0,1. SoPreInvocationat0is the turn boundary and the only place a prompt is reported; later invocations emit aHeartbeat(which also refreshesmodel— agy stampsmodelNameon 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
WaitingInputat all. And becausemuxadskips 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 returntrueand are completely unaffected; agy returnsfalse, and for those rows the detector contributes exactly one signal — applied to the real row:BlockedErrorWaitingInputIdleIdle(releases a stuck wait)Three exclusions, each a bug if reversed: no
Heartbeat(it would overwrite the hook-suppliedmodel),Workingis left to the hooks (milliseconds vs a seconds-late tick), and no synthetic row is minted —Store::applyevicts synthetic rows from a pane a real row owns, so one would flap in and out on every hook event. Refined panes are also nevertracked, 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. Norows — the label-based patterns would have missed it had option 2 not happened to contain "always allow". Now anchored onRequesting 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:idle → working → idle, with prompt, response, model, cwd, conversation id and tool events all landing on the row.agy -c, noSessionStartfires): lands on the same row viaconversationId, prompts recovered from the transcript for both turns.working → waiting_input → approve → idle, with the hook-supplied model and response intact throughout.exit=0,0bytes on stdout.muxa init --component agy-hooks→muxa doctor→--uninstallround-trip in an isolatedHOME;doctoris silent when agy isn't installed and requires all six events when it is.muxa agent start --agent agylaunchesagy --dangerously-skip-permissions.Suite:
cargo test --workspace --no-fail-fast— everything green exceptupgrade::tests::dry_run_renders_plan, which assertssystemctl --user restart muxadand already fails onmainunder macOS. ~60 new unit tests.cargo clippy --workspace --all-targetsintroduces no new diagnostics versusmain(client_kind_from_argdead-code is pre-existing).Known gaps (agy-side, documented not worked around)
Stopis a turn boundary. agy rows are reaped by pane liveness, like Codex's.workspacePathsis empty in print mode, socwdis absent foragy -p. A hook's own cwd is thehooks.jsondirectory, so there's nothing to fall back to.modelNamerides on every payload; the usage fields stayNone.hooks.jsonneeds an agy restart to take effect. Called out in the docs.🤖 Generated with Claude Code