Files
AgentHub/docs/superpowers/specs/2026-09-29-antigravity-parity-design.md
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
2026-09-29 16:09:19 +07:00

203 lines
11 KiB
Markdown

# 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/<conversation id>.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/<id>/.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.<n>.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 <Event> 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.