# Antigravity parity, part D: sessions, tokens and hooks Issue: #40 part D. Date: 2026-09-29. Status: self-reviewed (the user asked for autonomous work with a self-review per part). Mirrors parts C1 and C2 (`2026-09-29-codex-usage-design.md`, `2026-09-29-codex-hooks-design.md`). ## Spike: what agy offers Verified on agy 1.2.13 in an isolated `HOME`. agy keeps its login in the OS keyring, so that run used the user's own account. ### Tokens Not in the transcript, but in agy's own SQLite file per conversation: `~/.gemini/antigravity-cli/conversations/.db`. - `gen_metadata` has one protobuf row per model call. Field 1 is a message whose field 19 is the model name (`gemini-3.8-flash`) and whose field 4 is `ModelUsageStats`. - The field numbers were read from the proto descriptor embedded in the binary: 2 input, 3 output, 4 cache write, 5 cache read, 9 thinking. - Input does not include cache reads. Output includes thinking (171 = 73 + 98). - `trajectory_meta.cascade_id` is the conversation id. - `trajectory_metadata_blob` holds the workspace as a `file://` URI. - agy 1.0.0 wrote a `.pb` file instead, and Google's service refuses 1.0.0. ### Hooks Documented at antigravity.google/docs/hooks. - `~/.gemini/config/hooks.json` maps hook **names** to their events: `PreToolUse`/`PostToolUse` (`{matcher, hooks}` groups), and `PreInvocation`, `PostInvocation`, `Stop` (flat handler lists). A handler is `{type: "command", command, timeout (s)}`. - agy asks for no trust. - The payload carries `conversationId`, `workspacePaths`, `transcriptPath` (`brain//.system_generated/logs/transcript_full.jsonl`) and `modelName`. `PostToolUse` adds `toolCall {name, args}`, `stepIdx` and `error`. `Stop` adds `terminationReason` and `fullyIdle`. - The hook runs with its cwd in `~/.gemini/config`, and `AGENTHUB_LANE_ID` does not reach it. ### Traps found in the spike - **A `PreToolUse` hook that answers `{}` DENIES the tool** ("tool call denied by pre-tool hook"). There is no neutral answer: - `allow` would override the user's permission rules; - `ask` would force a prompt. - **No permission event, and no Stop when a run ends on an API error** or on a permission that headless mode refused. - `invocationNum` restarts at 0 for every run, including `--continue`. - **Running `agy` self-updates `~/.local/bin/agy`**, even with another `HOME`. The spike took the user's install from 1.0.0 to 1.2.13; the old binary is kept as `agy..old`. ## Design 1. **Sessions and tokens** (`server/lib/antigravity-usage.js`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default 15 s). - Each conversation file is opened read-only when its mtime (or its non-empty `-wal`'s) changes; the mtime lives in `antigravity_conversations`. - It becomes a `tool = 'antigravity'` session: its name and start come from the transcript, its cwd from the blob, its model from the last generation. - Tokens are written whole, per model. - The status reflects the session's last activity: the newest of its file (a non-empty `-wal` included), its transcript and its last hook event. It is quiet after ten minutes when it has any hook event — mid-turn a long tool call writes nothing, and after a `Stop` agy may be waiting for the user, with no exit hook to say otherwise — and after two minutes when it has none. - When a session settles to `completed`, its agents complete too, its waiting flags are cleared, and its antigravity lane is set `idle`, but only if the hooks left it `running` and it has no `run_id`. A `Stop` that marked it `error` keeps that `error`. - A file that is not agy's (`SQLITE_NOTADB`, `SQLITE_CORRUPT`, `no such table`) is recorded with `session_id` `''` and retried only once it changes or at the next server start; any other failure is retried on the next pass. - Only the CLI's directory is read. - Gemini families are added to `pricing-sync.js`, taken from Vertex AI's or the Gemini API's LiteLLM entries. 2. **Hooks** (`server/lib/antigravity-hooks.js`, Settings → Antigravity → **Antigravity hooks**, `GET/POST/DELETE /api/settings/antigravity-hooks`, `sameOriginGuard` on the writes). - The named hook `agenthub` has `PreInvocation`, `PostToolUse` and `Stop`, each `timeout: 5`. It never has `PreToolUse`. - Every other named hook is kept, and a non-object file is refused (`EBADJSON`). - The command is the stable path `~/.gemini/config/agenthub-hook.sh`. The script runs `scripts/hook-handler.js antigravity`, prints `{}`, and is refreshed at every start when it is ours. 3. **Route** (`antigravityHookInput` in `server/routes/hooks.js`). - A hook is accepted only with a UUID `conversationId` and a `transcriptPath` under the CLI's directory. That keeps out the Antigravity IDE, which loads the same global file. - `session_id` is the `conversationId` and `cwd` is `workspacePaths[0]`. - A `PreInvocation` becomes `UserPromptSubmit` when `invocationNum` is 0, when the session has no event yet, when its last event is a `Stop`, when the session is not `active`, or when its last event is more than two minutes old; any other is dropped. `PostToolUse` carries `tool_name`/`tool_input` and `step`. `Stop` stays `Stop` — as `stop_reason: "error"` when it carries a non-empty `error` or a `terminationReason` matching `/error|fail/i`, and dropped while `fullyIdle` is false. Every other event is dropped. 4. **Settings → Antigravity** (`AntigravityConfig.tsx`) holds Model Pricing (`vendor="google"`) and the hooks card. There is no sub-tab bar, because agy has no config surface AgentHub edits. ## Limits (documented, not fixable from here) - There is no "needs your permission" state for an Antigravity lane: agy has no hook for it. - A lane that a dashboard pane owns (`run_id`) keeps its run-owned status, as it does for Codex. - The Antigravity IDE's sessions are not read. - Sub-agents: none were seen in a CLI run, so nothing folds them. - The formats are undocumented: a future agy may move them. A generation that no longer decodes is skipped, not guessed. ## Tests - **Server:** - usage: the protobuf reader; the session and per-model tokens; no re-read; quiet → completed; the stuck lane set idle, while a paned one is kept; deleted stays deleted; another tool's row untouched; a hook-made cwd kept; a non-SQLite file skipped; - hooks: install with three events and no PreToolUse; the forwarder prints `{}`; other named hooks kept; remove; EBADJSON; a hook switched off is reported; the refresh is foreign-safe; - route: a turn on the antigravity lane only; later PreInvocations dropped; tool calls in the timeline; IDE and bad-id hooks ignored; the settings routes, including the cross-origin refusal; - handler: the `antigravity` marker, and an unknown marker dropped; - pricing: Gemini from Vertex AI, and another vendor refused. - **Client:** the hooks card (install, a hook switched off, Refresh, Remove, a refusal). - **End to end:** an isolated server on :4851 with `AGENTHUB_GEMINI_DIR` set. The hook was installed through the API, then a real `agy -p` read a file. Seen: - the lane went `running`; - the run ended with no Stop (a shell command refused in headless mode), and the poll set the lane back to `idle`; - the session had its name, cwd, model and tokens, and `view_file` in its timeline; - Settings → Antigravity showed the card as installed, with its three events. ## Found in the code review, fixed - **Our own read-only open looked like activity.** On a file agy closed cleanly, better-sqlite3's read-only open leaves an empty `-wal`, whose fresh mtime flipped the session back to `active` with "Last active" set to the read time. An empty `-wal` no longer counts. - **The poll undid the hooks' `running` within 15 s.** Activity is now the newest of the file, the transcript and the session's last hook event. The quiet window is ten minutes, not two, when that event is not a `Stop`, because a long tool call writes nothing until it ends. - **A failed run showed as "ready for input".** A `Stop` carrying an `error`, or a `terminationReason` naming one, now arrives as `stop_reason: "error"`, and the poll keeps that `error`. - **Stops and turns.** - A `Stop` with `fullyIdle: false` is dropped, since background work is still running. - The first `PreInvocation` after a `Stop` is a new prompt, whatever its `invocationNum`. - **A script that is not ours** at the forwarder's path is never overwritten or removed (`EFOREIGN`, 400), in `codex-hooks.js` too. - **A rewritten `hooks.json` keeps its mode**, in `codex-hooks.js` too. - **Session id and symlinks.** - The session id is the file name's UUID; `cascade_id` is no longer read. - A symlinked `~/.gemini` is matched under both paths (`agyCliDirs`). - **Settling and bad files.** - A file that is not agy's is recorded, so it is not re-read and warned about every 15 s. - Settling a session to `completed` also completes its agents and clears their waiting flags. - **Docs.** `.env.example` now lists `DASHBOARD_ANTIGRAVITY_POLL_MS` and `AGENTHUB_GEMINI_DIR`. ## Found in the whole-change review, fixed - **An open agy looked finished after a Stop.** The ten-minute window applied only while the last event was not a `Stop`, so a session waiting at the prompt settled to `completed` two minutes after each turn. agy fires no exit hook, so an open agy and a closed one look the same: any session with hook events now gets ten minutes, a hookless one two. - **A turn after a missed Stop was lost.** A `PreInvocation` is now also a prompt when the session has no event yet, is not `active` (a run that ended with no Stop, a session the poll settled), or its last event is over two minutes old — not only on `invocationNum: 0` or after a `Stop`. - **Error Stops.** Only a non-empty `error` counts, and a `terminationReason` counts when it matches `/error|fail/i`. - **A bad file was never retried, a busy one was given up on.** A file that is not agy's is recorded with `session_id` `''`, and those rows are deleted at every server start (`startAntigravityUsagePoll`), so a newer AgentHub tries it again; any other read failure (a busy file, our own error) is no longer recorded, so the next pass retries it. - **Settling left the session waiting.** It now clears the session's own waiting flag as well as its agents'. - **A forwarder path that is not a file of ours** (other content, an unreadable file, a directory — `scriptIsOurs`) is never overwritten (`EFOREIGN`) or removed, in `codex-hooks.js` too. - **A symlinked `hooks.json`** (a dotfiles repo's) left with no hook is emptied through the link instead of being unlinked, in both installers.