From 690fb20ac57b051e8261b14ae38d1e4f82c6eaa9 Mon Sep 17 00:00:00 2001 From: cyurekli Date: Thu, 5 Nov 2026 15:29:36 +0100 Subject: [PATCH 1/3] Add experimental Antigravity CLI (agy) host Adapter for agy 1.5.1's hook contract: SessionStart, PreInvocation and Stop are translated into the controller's events (tool results are read from the transcript at the next PreInvocation; invocationNum 0 opens a prompt-turn), and answers go back as injectSteps. Micro-skills, the loop guard and the session ledger are supported; tool pruning is not. Side calls run through `agy -p`. Refs #114 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- README.md | 29 +- docs/PLUGIN.md | 20 +- plugin/.antigravity-plugin/plugin.json | 10 + plugin/README.md | 47 ++- plugin/hooks.json | 13 + tests/conftest.py | 3 +- tests/test_antigravity.py | 449 +++++++++++++++++++++++++ tests/test_plugin_package.py | 2 + tokeneconomizer/plugin/cli.py | 9 +- tokeneconomizer/plugin/hook.py | 13 +- tokeneconomizer/plugin/hosts.py | 176 +++++++++- tokeneconomizer/plugin/sidecall.py | 37 +- tokeneconomizer/trace/README.md | 1 + tokeneconomizer/trace/__init__.py | 10 +- tokeneconomizer/trace/antigravity.py | 229 +++++++++++++ 16 files changed, 1035 insertions(+), 21 deletions(-) create mode 100644 plugin/.antigravity-plugin/plugin.json create mode 100644 plugin/hooks.json create mode 100644 tests/test_antigravity.py create mode 100644 tokeneconomizer/trace/antigravity.py diff --git a/CLAUDE.md b/CLAUDE.md index f127a07..7200ccd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ # TokenEconomizer -A plugin for Claude Code, Codex, the Copilot CLI and Pi (experimentally opencode, Cursor and Hermes -Agent) that reads finished sessions and changes what later sessions in the same repository pay -for: micro-skills, tool pruning, and a loop guard. +A plugin for Claude Code, Codex, the Copilot CLI and Pi (experimentally opencode, Cursor, Hermes +Agent and the Antigravity CLI) that reads finished sessions and changes what later sessions in the +same repository pay for: micro-skills, tool pruning, and a loop guard. See `README.md` for the overview and the method, `docs/PLUGIN.md` for the design and the verified agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. @@ -12,7 +12,7 @@ agent behavior, and `docs/ARCHITECTURE.md` for how the package fits together. - `toolprune/` — unused-tool evidence and the deny-list proposal; Codex capability packages - `loopguard/` — the per-call failure-loop guard and its per-repository envelope - `trace/` — each agent's session-log format (Claude Code, Codex, Copilot CLI, Pi, opencode, - Cursor, Hermes Agent) + Cursor, Hermes Agent, Antigravity CLI) - `plugin/` — the runtime: hook dispatch, per-repository state, settings, the `te` CLI - `config.py`, `ledger.py`, `pricing.py`, `domain.py`, `stage.py` — shared by all three - `plugin/` — the installable plugin directory: manifests per agent, hook files, launchers diff --git a/README.md b/README.md index b3d05a2..f7ee9c0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ mechanisms: itself. TokenEconomizer is a plugin for **Claude Code**, **Codex**, the **GitHub Copilot CLI** and **Pi**, -with experimental support for **opencode**, **Cursor Agent** and **Hermes Agent**. Each italic +with experimental support for **opencode**, **Cursor Agent**, **Hermes Agent** and the +**Antigravity CLI**. Each italic letter above is a setting, shown with its default ([Settings](#settings)), and each mechanism can be turned on or off. The plugin announces each action in one line, and if any part of it fails, your session carries on as if it were not installed. Its model calls (the fact proposer, and the judge with the @@ -175,6 +176,19 @@ hook events locally and reports session cost as unavailable. Cursor support is m only: on Windows, Cursor runs hook commands through PowerShell, and this plugin's launcher is a POSIX shell script. +**Antigravity CLI** (`agy`; experimental; micro-skills and the loop guard) + +```sh +python scripts/build_plugin.py --out dist/marketplace +agy plugin install dist/marketplace/tokeneconomizer +``` + +Start `agy` and send a prompt in a repository, then check with +`~/.tokeneconomizer/bin/te --host antigravity status`. `agy` has no plugin commands, so the +plugin's commands run from a terminal, and tool pruning is not available. macOS and Linux only: +on Windows `agy` runs hook commands through `cmd /c`, and this plugin's launcher is a POSIX shell +script. See [its limits](plugin/README.md#antigravity-cli). + **opencode** (experimental) ```sh @@ -211,6 +225,7 @@ To update: |---|---| | Claude Code, Copilot CLI, opencode | `git pull` in the clone | | Hermes Agent | `git pull` in the clone, then `hermes gateway restart` | +| Antigravity CLI | build the bundle again and `agy plugin install` it | | Codex | `git pull` in the clone, then the `codex plugin add` line again | | Pi | `pi update --extensions` | @@ -228,6 +243,7 @@ uninstalling, using Copilot with your own model key, and troubleshooting. | Cursor Agent (experimental) | 3.24.9, macOS and Linux (below) | not tested | | opencode (experimental) | 1.19.5 and 2.2.0 | not tested | | Hermes Agent (experimental) | 0.22.5 | not tested | +| Antigravity CLI (experimental) | agy 1.5.1, macOS and Linux | not tested | The commands above were tested on the oldest and newest version in each range; newer versions have not been tested yet. @@ -240,6 +256,9 @@ have not been tested yet. 2.x terminal UI. - **Cursor Agent** support is experimental. Cursor 3.24.9 loaded the local plugin, but hook delivery from a live Agent session has not yet been verified. +- **Antigravity CLI** support is experimental: it was written against agy 1.5.1's hook contract + and transcript format, which are not a public interface, and only micro-skills and the loop guard + are supported. See [its limits](plugin/README.md#antigravity-cli). - **Hermes Agent** support is experimental: it was tested on 0.22.5 only, with a scripted local model and one free real model, on the classic CLI and one-shot runs only. Micro-skills and a redirect reaching a real model are verified there; the still-stuck notice reaching a person is @@ -260,13 +279,14 @@ commands. They have the same names on every agent; only the way you type them di | opencode | from a terminal: `~/.tokeneconomizer/bin/te --host opencode ` | | Cursor Agent | from a terminal: `~/.tokeneconomizer/bin/te --host cursor ` | | Hermes Agent | from a terminal: `~/.tokeneconomizer/bin/te --host hermes ` | +| Antigravity CLI | from a terminal: `~/.tokeneconomizer/bin/te --host antigravity ` | | a terminal, in the repository | `~/.tokeneconomizer/bin/te ` | | Command | What it does | |---|---| | `status` | what it learned and did here, and what your sessions cost | | `microskills` | the facts it injects, and those still forming | -| `toolprune` | the tools it left out, and the evidence (not on Pi, opencode or Hermes Agent) | +| `toolprune` | the tools it left out, and the evidence (not on Pi, opencode, Hermes Agent or the Antigravity CLI) | | `toolprune restore` | give them back; they will not be removed again | | `config` | every setting in effect | | `config set microskills=on` | turn a mechanism on (or `=off`) in this repository; add `--global` for every repository | @@ -348,6 +368,7 @@ what it can leave out differs: | Cursor Agent | nothing yet | — | | opencode | nothing yet | — | | Hermes Agent | nothing yet | — | +| Antigravity CLI | nothing yet | — | Tool pruning adds only its own entries and never touches deny rules you wrote; a settings file it creates is kept out of git (`.git/info/exclude`). On Claude Code, the session-start line that says @@ -373,7 +394,7 @@ redirects, you see one line that ends with how to stop it: > calls). Press Esc to interrupt it. Where that line appears, and how to stop the turn, in the terminal UI and in a headless run -(`claude -p`, `codex exec`, `copilot -p`, `pi -p`, `opencode run`, `hermes -z`): +(`claude -p`, `codex exec`, `copilot -p`, `pi -p`, `opencode run`, `hermes -z`, `agy -p`): | Agent | Interactive: where it shows | How to stop | Headless: where it shows | How to stop | |---|---|---|---|---| @@ -383,6 +404,7 @@ Where that line appears, and how to stop the turn, in the terminal UI and in a h | Pi | as a notification | Esc | on stderr | Ctrl+C | | opencode | as a notification | Esc | on stderr | Ctrl+C | | Hermes Agent | in the model's reply | Ctrl+C | in the final reply | Ctrl+C | +| Antigravity CLI | in the model's reply | Esc | in the final reply | Ctrl+C | - **Claude Code** shows a plugin's own lines only in its detailed transcript (Ctrl+O), so the plugin asks Claude to repeat this one in its reply; the model may word it differently or leave @@ -393,6 +415,7 @@ Where that line appears, and how to stop the turn, in the terminal UI and in a h - **opencode** 2.x prints the line to the opencode server's error output, not in its terminal UI. - **Hermes Agent**: the plugin asks the model to repeat the line, since no hook was found to reach the person's view; a model may word it differently or leave it out. +- **Antigravity CLI**: the same, for the same reason. In a headless run nobody may be watching, so a flagged turn runs until it ends or reaches the agent's own limits; afterwards, `te status` counts the redirects and "still stuck" notices. diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index d8f17a0..020d233 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -1,7 +1,7 @@ # The plugin: design and verified agent behaviour How the plugin runs the three mechanisms inside your own Claude Code, Codex, Copilot CLI, Pi, -opencode, Cursor and Hermes Agent sessions, through the hooks (on Pi, the extension events) each agent +opencode, Cursor, Hermes Agent and Antigravity CLI sessions, through the hooks (on Pi, the extension events) each agent exposes: what it does at each hook, what differs from the experiments and why, the agent behaviour each choice rests on, where its state lives, and its known limitations. The code is `tokeneconomizer/plugin/` (what the hooks run) and `plugin/` (the packaging for each agent). To install and use it, see @@ -694,6 +694,24 @@ open-vocabulary. On a repository you do not trust, leave micro-skills off (the d * **Hermes Agent** — experimental: micro-skills and the loop guard, on the classic CLI and one-shot runs, verified with a scripted model and one free real model. Its limits are listed in `plugin/README.md`. +* **Antigravity CLI (`agy`, experimental)** — micro-skills and the loop guard, written against + agy 1.5.1 and its own installed hook documentation; tool pruning is not implemented. What + was captured live: hooks come from a `hooks.json` at the plugin root, run through `sh -c` in + that directory, with camelCase payloads that name no event (the hook file's command does, + `--event`) and leave out a zero (`invocationNum` 0, the first model call of a prompt). A + `PreToolUse` hook that prints `{}` makes agy deny the tool, so none is registered; + `PostToolUse` carries no tool result and a matcher `"*"` never matches, so the results are read + from the conversation's `transcript_full.jsonl` at the next `PreInvocation` and at `Stop`, where + every result of the previous model call is already written (`trace/antigravity.py`). There is no + prompt event and no `SessionEnd`: a prompt is the `PreInvocation` with `invocationNum` 0, and a + conversation is folded by the next session start in its repository once its transcript has been + quiet for ten minutes. Context goes back as `injectSteps`: a persistent `systemMessage` at + `SessionStart`, an `ephemeralMessage` (dropped after the turn) at `PreInvocation`. No hook + output was found that reaches the person, so a notice goes through the model. Calls a subagent + makes are not in the transcript and are not seen. Transcript token fields are absent from older + agy versions (unknown, not zero), and the transcript names no model, so session cost is priced + on the ledger's labelled fallback. macOS and Linux only: agy runs hook commands through + `cmd /c` on Windows, and the launcher is a POSIX shell script. * **Known gaps:** * `codex exec` prints no hook messages, so a headless Codex run shows no notice; `te status` counts it afterwards. On Claude Code the notice reaches the conversation and `claude -p`'s diff --git a/plugin/.antigravity-plugin/plugin.json b/plugin/.antigravity-plugin/plugin.json new file mode 100644 index 0000000..c90e35c --- /dev/null +++ b/plugin/.antigravity-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "tokeneconomizer", + "version": "0.1.0", + "description": "Learn repository facts and redirect coding-agent turns that appear stuck.", + "author": { + "name": "TokenEconomizer authors" + }, + "repository": "https://github.com/microsoft/TokenEconomizer", + "license": "Apache-2.0" +} diff --git a/plugin/README.md b/plugin/README.md index ade07e9..6588fba 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,13 +1,14 @@ # TokenEconomizer plugin How to install and use TokenEconomizer in Claude Code, Codex, the GitHub Copilot CLI, Pi, Cursor -(experimental), opencode (experimental) and Hermes Agent (experimental). What the plugin does and why is in the [main README](../README.md); its +(experimental), opencode (experimental), Hermes Agent (experimental) and the Antigravity CLI +(experimental). What the plugin does and why is in the [main README](../README.md); its design and the verified behaviour of each agent are in [`docs/PLUGIN.md`](../docs/PLUGIN.md). - [Before you start](#before-you-start) - Install and use it on [Claude Code](#claude-code) · [Codex](#codex) · [Copilot CLI](#copilot-cli) · [Pi](#pi) · [Cursor](#cursor) · [opencode](#opencode) · - [Hermes Agent](#hermes-agent) + [Hermes Agent](#hermes-agent) · [Antigravity CLI](#antigravity-cli) - [What to expect](#what-to-expect) · [Commands](#commands) · [Settings](#settings) - [Repository map](#repository-map) · [Using with rtk](#using-with-rtk) · [Model calls and API keys](#model-calls-and-api-keys) · [Troubleshooting](#troubleshooting) @@ -493,6 +494,48 @@ log a warning for the empty section of a session with no facts. **Uninstall.** `hermes plugins disable tokeneconomizer`, `rm ~/.hermes/plugins/tokeneconomizer`, `hermes gateway restart`. Optional: `rm -rf ~/.tokeneconomizer`. + +## Antigravity CLI + +Experimental: **micro-skills and the loop guard**, written against `agy` 1.5.1 on macOS and Linux +(agy runs hook commands through `cmd /c` on Windows, and the launcher is a POSIX shell script). + +**1. Install.** Build the self-contained plugin, which copies the package into it, and install the +directory: + +```bash +python scripts/build_plugin.py --out dist/marketplace +agy plugin install dist/marketplace/tokeneconomizer +``` + +`agy plugin validate dist/marketplace/tokeneconomizer` checks it. agy reads the plugin's hooks +from `hooks.json` at the plugin root; the `hooks/hooks.json` beside it is Claude Code's, and agy's +validator says it is never loaded. + +**2. Check it.** Run a session, then `~/.tokeneconomizer/bin/te --host antigravity status` in the +repository. agy plugins load no slash commands, so every command runs from a terminal. Micro-skills +is off by default: `te --host antigravity config set microskills=on` (or `envfacts`, without model +calls). + +**How it works, and what it does not do.** + +- Three hooks are registered: `SessionStart` (injects the learned facts), `PreInvocation` (before + every model call: opens a turn at a new prompt, feeds the loop guard the tool results since the + last call, and carries its redirect as an ephemeral message) and `Stop`. +- It never registers `PreToolUse`: a `PreToolUse` hook that prints `{}` makes agy deny the tool. + `PostToolUse` is unused: its payload holds no tool result, so the results are read from the + conversation's `transcript_full.jsonl` instead. A cursor into it is kept in + `~/.tokeneconomizer/antigravity-sessions/`, private to you. +- agy has no `SessionEnd`: a conversation is folded by the next session start in that repository, + once its transcript has been quiet for 10 minutes. +- Nothing a hook prints reaches your view, so the loop guard's notice goes through the model, which + may word it differently or leave it out. Press Esc in the UI (Ctrl+C for `agy -p`) to stop a turn. +- Tool pruning is not available. Calls a subagent makes are not in the transcript and are not seen. + The transcript names no model, so session cost is priced on a labelled fallback, and older agy + versions leave token counts out of most of it. +- Model calls (the proposer, the loop guard's judge) run `agy --print` in a throwaway directory on + your default model (`paid_model` names another). Each one may leave a conversation in agy's history. + ## What to expect The plugin works session by session: what it learns from a repository's earlier sessions is diff --git a/plugin/hooks.json b/plugin/hooks.json new file mode 100644 index 0000000..9307311 --- /dev/null +++ b/plugin/hooks.json @@ -0,0 +1,13 @@ +{ + "tokeneconomizer": { + "SessionStart": [ + { "type": "command", "command": "./scripts/te-hook --host antigravity --event SessionStart", "timeout": 15 } + ], + "PreInvocation": [ + { "type": "command", "command": "./scripts/te-hook --host antigravity --event PreInvocation", "timeout": 10 } + ], + "Stop": [ + { "type": "command", "command": "./scripts/te-hook --host antigravity --event Stop", "timeout": 10 } + ] + } +} diff --git a/tests/conftest.py b/tests/conftest.py index c7bdbe1..9e7ec76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,5 +44,6 @@ def _no_model_calls(request, monkeypatch): monkeypatch.setattr(judge, "spawn_job", judge.run_job) if request.node.get_closest_marker("real_sidecalls"): return - for name in ("claude_call", "codex_call", "copilot_call", "hermes_call", "pi_call"): + for name in ("agy_call", "claude_call", "codex_call", "copilot_call", "hermes_call", + "pi_call"): monkeypatch.setattr(sidecall, name, lambda *a, **k: ("", None)) diff --git a/tests/test_antigravity.py b/tests/test_antigravity.py new file mode 100644 index 0000000..06369f0 --- /dev/null +++ b/tests/test_antigravity.py @@ -0,0 +1,449 @@ +"""Antigravity CLI (`agy`) host adapter: one hook call -> several controller events, the transcript +reader, the trace and usage readers, the side call, and the plugin's packaging. + +Every transcript here is synthetic (fake ids, `/work/repo`, `/home/u`): rows shaped as agy 1.5.1 +writes `transcript_full.jsonl`, built by `Transcript` below. (`*.jsonl` is not tracked in this +repository, so fixtures are written at test time.) +""" +import io +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from tokeneconomizer import trace +from tokeneconomizer.plugin import hook, hosts, sidecall +from tokeneconomizer.plugin.hosts import Antigravity, ClaudeCode, Cursor, merge_outputs + +CID = "4c748b89-0000-4000-8000-000000000001" +ROOT = Path(__file__).resolve().parents[1] +NOTE = ("Created At: 2026-11-05T10:00:00Z\nCompleted At: 2026-11-05T10:00:01Z\n" + "A pre-tool hook changed the arguments of this tool call before it ran. " + "Changed: CommandLine.\n\n") + + +class Transcript: + """Rows as agy writes them, appended one prompt or model call at a time.""" + + def __init__(self, path): + self.path, self.n = str(path), 0 + + def _add(self, **row): + row.update(step_index=self.n, status=row.get("status", "DONE"), + created_at="2026-11-05T10:00:00Z") + self.n += 1 + with open(self.path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(row) + "\n") + + def prompt(self, text): + self._add(source="USER_EXPLICIT", type="USER_INPUT", content=( + f"\n{text}\n\n\nnow\n" + "")) + + def call(self, calls, results, tokens=True): + """One model call: its planner row, then one result step per entry of `results` + (`(content, extra)`; extra overrides the step's fields, `type` included).""" + row = {"source": "MODEL", "type": "PLANNER_RESPONSE", + "tool_calls": [{"name": n, "args": a} for n, a in calls]} + if tokens: + row.update(input_tokens=100, cache_read_tokens=1000, output_tokens=20) + self._add(**row) + for content, extra in results: + self._add(**dict({"source": "MODEL", "type": "GENERIC", "content": content}, **extra)) + + def system(self): + self._add(source="SYSTEM", type="CHECKPOINT", content="checkpoint") + + +def sh(cmd): + return "run_command", {"CommandLine": cmd, "Cwd": "/work/repo", "toolAction": "x", + "toolSummary": "y", "WaitMsBeforeAsync": 500} + + +def out(code, text=""): + return f"{NOTE}Command exited with code {code}.\n\nCommand output:\n{text}\r\n", {} + + +def hook_payload(tp, event, ws="/work/repo", **extra): + return dict({"hook_event_name": event, "conversationId": CID, "workspacePaths": [ws], + "transcriptPath": str(tp), "modelName": "m", "lastUserInput": "fix it"}, **extra) + + +@pytest.fixture +def env(tmp_path): + return {"TOKENECONOMIZER_HOME": str(tmp_path / "home"), "TE_FOLD_SYNC": "1", + "TE_MICROSKILLS": "envfacts", "GIT_CEILING_DIRECTORIES": str(tmp_path), + "PATH": os.environ.get("PATH", "")} + + +@pytest.fixture +def tp(tmp_path): + d = tmp_path / "brain" / CID / ".system_generated" / "logs" + d.mkdir(parents=True) + return d / "transcript_full.jsonl" + + +def kinds(events): + return [(e["hook_event_name"], e.get("tool_name") or e.get("prompt"), e.get("prompt_id")) + for e in events] + + +# ---- one agy call -> the controller's events ---------------------------------------------- +def test_expand_puts_a_turns_results_before_the_next_prompt_and_counts_turns(tp, env): + host, t = Antigravity(), Transcript(tp) + t.prompt("fix it") + assert kinds(host.expand(hook_payload(tp, "SessionStart"), env)) == [ + ("SessionStart", None, None)] + # invocationNum 0 is left out of the payload (protojson): the first call of a prompt. + assert kinds(host.expand(hook_payload(tp, "PreInvocation"), env)) == [ + ("UserPromptSubmit", "fix it", f"{CID}:1")] + t.call([sh("ls")], [out(0, "a")]) + t.call([("view_file", {"AbsolutePath": "/work/repo/a.py"})], [("print(1)\n", {})]) + t.call([("replace_file_content", {"TargetFile": "/work/repo/a.py", "ReplacementContent": "x", + "TargetContent": "y"})], [("edited", {"type": "CODE_ACTION"})]) + assert kinds(host.expand(hook_payload(tp, "PreInvocation", invocationNum=3), env)) == [ + ("PostToolUse", "Bash", f"{CID}:1"), ("PostToolUse", "Read", f"{CID}:1"), + ("PostToolUse", "Edit", f"{CID}:1")] + t.call([sh("pytest")], [out(1, "FAILED")]) + t.prompt("again") # a new prompt: its results belong to the old turn + assert kinds(host.expand(hook_payload(tp, "PreInvocation"), env)) == [ + ("PostToolUse", "Bash", f"{CID}:1"), ("UserPromptSubmit", "fix it", f"{CID}:2")] + t.call([sh("ls")], [out(0, "b")]) + stop = host.expand(hook_payload(tp, "Stop", terminationReason="NO_TOOL_CALL"), env) + assert kinds(stop) == [("PostToolUse", "Bash", f"{CID}:2"), ("Stop", None, None)] + assert stop[-1]["terminationReason"] == "NO_TOOL_CALL" + + +def test_expand_does_not_emit_a_step_twice(tp, env): + host, t = Antigravity(), Transcript(tp) + host.expand(hook_payload(tp, "SessionStart"), env) + host.expand(hook_payload(tp, "PreInvocation"), env) + t.call([sh("ls")], [out(0)]) + assert len(host.expand(hook_payload(tp, "PreInvocation", invocationNum=1), env)) == 1 + assert host.expand(hook_payload(tp, "PreInvocation", invocationNum=2), env) == [] + t.call([sh("pwd")], [out(0)]) + again = host.expand(hook_payload(tp, "PreInvocation", invocationNum=3), env) + assert [e["tool_input"] for e in again] == [{"command": "pwd"}] + + +def test_a_call_whose_results_are_not_all_in_waits_for_them(tp, env): + host, t = Antigravity(), Transcript(tp) + host.expand(hook_payload(tp, "SessionStart"), env) + host.expand(hook_payload(tp, "PreInvocation"), env) + t.call([sh("a"), sh("b")], [out(0)]) # the second result is not written yet + assert host.expand(hook_payload(tp, "PreInvocation", invocationNum=1), env) == [] + t._add(source="MODEL", type="GENERIC", content=out(0)[0]) + got = host.expand(hook_payload(tp, "PreInvocation", invocationNum=2), env) + assert [e["tool_input"]["command"] for e in got] == ["a", "b"] + + +def test_a_conversation_this_plugin_did_not_start_is_not_replayed(tp, env): + t = Transcript(tp) + t.prompt("old") + t.call([sh("ls")], [out(0)]) + got = Antigravity().expand(hook_payload(tp, "PreInvocation"), env) + assert kinds(got) == [("UserPromptSubmit", "fix it", f"{CID}:1")] + + +def test_the_cursor_file_is_private(tp, env): + Antigravity().expand(hook_payload(tp, "SessionStart"), env) + state = Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions" + assert (state.stat().st_mode & 0o777, (state / f"{CID}.json").stat().st_mode & 0o777) == ( + 0o700, 0o600) + + +def test_a_missing_or_unreadable_transcript_gives_no_tool_events(tmp_path, env): + host = Antigravity() + gone = tmp_path / "nope.jsonl" + host.expand(hook_payload(gone, "SessionStart"), env) + assert kinds(host.expand(hook_payload(gone, "PreInvocation"), env)) == [ + ("UserPromptSubmit", "fix it", f"{CID}:1")] + assert kinds(host.expand(hook_payload(gone, "Stop"), env)) == [("Stop", None, None)] + assert host.expand(hook_payload(gone, "PreInvocation", conversationId="../x"), env) == [] + assert host.expand({"hook_event_name": "PostToolUse", "conversationId": CID}, env) == [] + + +# ---- what a result step becomes ------------------------------------------------------------ +def test_generic_and_typed_steps_become_claude_shaped_calls(tp): + t = Transcript(tp) + t.prompt("go") + t.call([sh("pytest -q"), ("grep_search", {"Query": "foo", "SearchPath": "/work/repo", + "toolAction": "z"}), + ("list_dir", {"DirectoryPath": "/work/repo"}), ("call_mcp_tool", { + "ServerName": "s", "ToolName": "t", "toolSummary": "x"})], + [out(1, "ls: nope"), ("hit\n", {"type": "GREP_SEARCH"}), ("a\nb\n", {"type": "LIST_DIRECTORY"}), + ("ok", {"type": "MCP_TOOL"})]) + t.call([("run_command", {"CommandLine": "make"})], + [("done", {"type": "RUN_COMMAND", "exit_code": 2})]) + items = [i for i in trace.antigravity_read(str(tp))[0] if i["kind"] == "tool"] + assert [(i["name"], i["args"]) for i in items] == [ + ("Bash", {"command": "pytest -q"}), ("Grep", {"pattern": "foo", "path": "/work/repo"}), + ("Glob", {"path": "/work/repo"}), ("call_mcp_tool", {"ServerName": "s", "ToolName": "t"}), + ("Bash", {"command": "make"})] + assert items[0]["cwd"] == "/work/repo" + # the rtk notice and the timestamps are gone; the exit code is read from the text, or the + # typed step's own field + assert items[0]["content"].startswith("Command exited with code 1.") + assert [i["exit_code"] for i in items] == [1, None, None, None, 2] + assert [i["is_error"] for i in items] == [True, False, False, False, True] + + +def test_a_step_with_status_error_is_a_failure_with_its_error_text(tp): + t = Transcript(tp) + t.call([("view_file", {"AbsolutePath": "/work/repo/x"})], + [("", {"status": "ERROR", "error": "tool call denied by pre-tool hook: "})]) + (item,) = trace.antigravity_read(str(tp))[0] + assert item["is_error"] and item["exit_code"] is None + assert "denied by pre-tool hook" in item["content"] + host = Antigravity() + name, args, text, code = host.tool_call({ + "tool_name": "Read", "tool_input": {}, "tool_response": { + "content": item["content"], "exit_code": None, "isError": True}}) + assert (name, code) == ("Read", 1) and "denied" in text + assert host.tool_call({"tool_name": "Bash", "tool_input": {}, "tool_response": { + "content": "ok", "exit_code": None, "isError": False}})[3] is None + + +def test_running_steps_system_rows_and_finish_do_not_shift_the_pairing(tp): + t = Transcript(tp) + t.call([sh("sleep 9"), sh("ls")], [("", {"status": "RUNNING"}), out(0, "x")]) + t.system() + t.call([("finish", {"summary": "s"})], [("", {"type": "FINISH"})]) + items = trace.antigravity_read(str(tp))[0] + assert [(i["args"]["command"], i["exit_code"]) for i in items] == [("ls", 0)] + + +# ---- agy's answer -------------------------------------------------------------------------- +def test_render_answers_each_event_in_agys_terms(): + host = Antigravity() + ctx = merge_outputs(host.context("SessionStart", "facts"), host.notice("heads up")) + start = host.render("SessionStart", ctx) + (step,) = start["injectSteps"] + assert step["systemMessage"]["systemMessage"].startswith("facts\n\n") + assert "heads up" in step["systemMessage"]["systemMessage"] # told through the model + assert host.render("PreInvocation", {"hookSpecificOutput": { + "hookEventName": "PostToolUse", "additionalContext": "redirect"}, "systemMessage": "n"}) == { + "injectSteps": [{"ephemeralMessage": "redirect\n\nn"}]} + assert host.render("Stop", ctx) == {} + assert host.render("PreInvocation", {}) == {} == host.render("SessionStart", {}) + + +@pytest.mark.parametrize("reason,normal", [("NO_TOOL_CALL", True), ("USER_CANCELED", False), + ("ERROR", False), (None, False)]) +def test_only_a_turn_that_ran_to_its_end_stopped_normally(reason, normal): + assert Antigravity().stopped_normally({"terminationReason": reason}) is normal + + +def test_session_id_cwd_and_transcript_come_from_the_payload(): + host, p = Antigravity(), hook_payload("/h/.gemini/antigravity-cli/brain/x/t.jsonl", "Stop") + assert (host.session_id(p), host.cwd(p, {}), host.transcript(p)) == ( + CID, "/work/repo", "/h/.gemini/antigravity-cli/brain/x/t.jsonl") + assert host.cwd({"workspacePaths": []}, {}) == os.getcwd() + + +def test_detect_finds_agy_by_its_payload_and_leaves_the_others_alone(): + p = {"conversationId": CID, "transcriptPath": "/home/u/.gemini/antigravity-cli/brain/x/t"} + assert isinstance(hosts.detect(p, {}), Antigravity) + assert isinstance(hosts.detect(p, {}, hint="antigravity"), Antigravity) + assert isinstance(hosts.detect(dict(p, transcriptPath="/x/other.jsonl"), {}), ClaudeCode) + assert isinstance(hosts.detect({"transcript_path": "/x/y"}, {}), ClaudeCode) + assert hosts.HOSTS["antigravity"] is Antigravity + + +# ---- the finished session ------------------------------------------------------------------ +def test_usage_sums_the_rows_that_report_it_and_skips_the_rest(tp): + t = Transcript(tp) + t.prompt("go") + t.call([sh("a")], [out(0)]) + t.call([sh("b")], [out(0)], tokens=False) # an older agy: no token fields + t.call([], []) + t._add(source="MODEL", type="PLANNER_RESPONSE", input_tokens=5, output_tokens=7) + assert trace.antigravity_session_usage(str(tp)) == { + "input_tokens": 205, "cache_read_input_tokens": 2000, "cache_creation_input_tokens": 0, + "output_tokens": 47, "requests": 3, "model": "antigravity"} + assert trace.antigravity_session_usage(str(tp) + ".none") == {} + from tokeneconomizer.plugin import accounting + assert accounting.effective(Antigravity().session_usage(str(tp)))[1] == "fallback" + assert accounting.effective(Antigravity().session_usage(str(tp) + ".none")) == ( + None, "no_usage") + + +def test_the_trace_conversion_feeds_the_mechanisms_and_prompts_are_counted(tp, tmp_path): + t = Transcript(tp) + t.prompt("first") + t.call([sh("pytest")], [out(1, "FAILED x")]) + t.call([("replace_file_content", {"TargetFile": "/work/repo/a.py"})], + [("ok", {"type": "CODE_ACTION"})]) + t.prompt("second") + t.call([("view_file", {"AbsolutePath": "/work/repo/a.py"})], [("body", {"type": "VIEW_FILE"})]) + host, work = Antigravity(), tmp_path / "w" + work.mkdir() + path = host.mechanism_trace(str(tp), str(work)) + assert trace.load_trajectory(path) == [ + ("Bash", {"command": "pytest"}, "Command exited with code 1.\n\nCommand output:\nFAILED x", + False), + ("Edit", {"file_path": "/work/repo/a.py"}, "ok", True), + ("Read", {"file_path": "/work/repo/a.py"}, "body", True)] + assert trace.call_cwds(path)[0] == "/work/repo" + assert host.prompt_count(str(tp)) == 2 + assert host.mechanism_trace(str(tp) + ".none", str(work)) is None + + +# ---- the hook entry point ------------------------------------------------------------------ +def test_main_reads_the_event_from_the_command_line(tp, env): + out_ = io.StringIO() + Transcript(tp).prompt("x") + p = hook_payload(tp, "ignored") + del p["hook_event_name"] + hook.main(["--host", "antigravity", "--event", "SessionStart"], io.StringIO(json.dumps(p)), + out_, dict(env, TE_MICROSKILLS="off")) + assert json.loads(out_.getvalue()) == {} + assert (Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions" / f"{CID}.json").exists() + + +def test_a_sidecall_marker_makes_the_hook_do_nothing(tp, env): + out_ = io.StringIO() + hook.main(["--host", "antigravity", "--event", "SessionStart"], + io.StringIO(json.dumps(hook_payload(tp, "SessionStart"))), out_, + dict(env, TE_SIDECALL="1")) + assert json.loads(out_.getvalue()) == {} + assert not (Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions").exists() + + +def test_the_expand_render_seam_leaves_other_hosts_as_they_were(tmp_path, env): + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + p = {"session_id": "s", "cwd": str(repo), "transcript_path": "", "hook_event_name": + "SessionStart", "source": "startup"} + assert hook.run(p, env, host_hint="claude") == {} + for cls in (ClaudeCode, Cursor): + assert not hasattr(cls, "expand") and not hasattr(cls, "render") + + +def test_the_seam_runs_each_expanded_event_in_order_and_renders_the_merge(monkeypatch, tmp_path, + env): + seen = [] + + class Fake(ClaudeCode): + name = "claude" + + def expand(self, payload, env): + return [dict(payload, hook_event_name="SessionStart", n=1), + dict(payload, hook_event_name="SessionStart", n=2)] + + def render(self, event, merged): + return {"event": event, "merged": merged} + + from tokeneconomizer.plugin import controller + monkeypatch.setitem(hosts.HOSTS, "fake", Fake) + monkeypatch.setattr(controller.LiveController, "handle", lambda self: ( + seen.append(self.p["n"]) or {"additionalContext": f"c{self.p['n']}"})) + got = hook.run({"session_id": "s", "cwd": str(tmp_path), "hook_event_name": "PreInvocation"}, + env, host_hint="fake") + assert seen == [1, 2] + assert got == {"event": "PreInvocation", "merged": {"additionalContext": "c1\n\nc2"}} + + +# ---- end to end: a stuck turn becomes an ephemeral message --------------------------------- +def _run_turn(env, tp, t, prompt, batches, ws): + """One prompt through the hook, as agy calls it: PreInvocation per model call, then Stop.""" + outs = [] + + def call(event, **extra): + buf = io.StringIO() + hook.main(["--host", "antigravity", "--event", event], + io.StringIO(json.dumps(hook_payload(tp, event, ws, lastUserInput=prompt, **extra))), + buf, env) + return json.loads(buf.getvalue()) + + t.prompt(prompt) + outs.append(call("PreInvocation")) + for n, (calls, results) in enumerate(batches, 1): + t.call(calls, results) + outs.append(call("PreInvocation", invocationNum=n)) + call("Stop", terminationReason="NO_TOOL_CALL", executionNum=1) + return outs + + +def test_a_stuck_turn_is_redirected_through_an_ephemeral_message(tp, tmp_path, env): + repo = tmp_path / "repo" + (repo / ".git" / "info").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + (repo / ".git" / "config").write_text( + '[remote "origin"]\n\turl = https://github.com/acme/widget.git\n') + env = dict(env, TE_LOOPGUARD="on") + t, ws = Transcript(tp), str(repo) + hook.main(["--host", "antigravity", "--event", "SessionStart"], io.StringIO( + json.dumps(hook_payload(tp, "SessionStart", ws))), io.StringIO(), env) + green = [([("view_file", {"AbsolutePath": f"{repo}/a.py"})] * 8, [("ok", {})] * 8)] + for k in range(5): + _run_turn(env, tp, t, f"green {k}", green, ws) + fail = "FAILED tests/test_a.py::test_x - AssertionError: assert 1 == 2" + stuck = [([("replace_file_content", {"TargetFile": f"{repo}/pkg/a.py"}), sh("pytest -q")], + [("edited", {"type": "CODE_ACTION"}), out(1, fail)]) for _ in range(20)] + outs = _run_turn(env, tp, t, "stuck", stuck, ws) + steps = [s for o in outs for s in o.get("injectSteps", [])] + assert steps and all(set(s) == {"ephemeralMessage"} for s in steps) + assert any("Process check (automated)" in s["ephemeralMessage"] for s in steps) + + +# ---- the side call ------------------------------------------------------------------------- +@pytest.mark.real_sidecalls +def test_the_side_call_runs_agy_print_and_prices_its_usage(monkeypatch): + seen = [] + reply = {"conversation_id": "c", "status": "SUCCESS", "response": "DONE\n", "num_turns": 1, + "usage": {"input_tokens": 36496, "output_tokens": 1406, "thinking_tokens": 1185, + "cache_read_tokens": 100, "total_tokens": 37902}} + + def fake_run(cmd, **kw): + seen.append((cmd, kw)) + return subprocess.CompletedProcess(cmd, 0, json.dumps(reply), "") + + monkeypatch.setattr(sidecall.subprocess, "run", fake_run) + text, eff = sidecall.agy_call("is it stuck? -p", model="paid-m") + assert text == "DONE" and eff + (cmd, kw), = seen + assert cmd == ["agy", "--output-format", "json", "--disable-slash-commands", "--model", + "paid-m", "--print=is it stuck? -p"] + assert kw["env"]["TE_SIDECALL"] == "1" and kw["stdin"] == subprocess.DEVNULL + assert kw["cwd"] != os.getcwd() and "te-side-" in kw["cwd"] + runner = sidecall.runner_for("antigravity", type("C", (), {"paid_model": ""})()) + assert runner("p") == ("DONE", eff) + assert sidecall.call_name("antigravity") == "agy -p" + + +@pytest.mark.real_sidecalls +def test_a_failed_side_call_is_no_reply(monkeypatch): + noted = [] + monkeypatch.setattr(sidecall, "_no_reply", lambda host, o: noted.append(host)) + bad = {"status": "ERROR", "finished_reason": "x", "response": "partial"} + monkeypatch.setattr(sidecall.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess( + cmd, 1, json.dumps(bad), "")) + assert sidecall.agy_call("p") == ("", None) + monkeypatch.setattr(sidecall.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess( + cmd, 0, "not json", "")) + assert sidecall.agy_call("p") == ("", None) + assert noted == ["antigravity", "antigravity"] + + +# ---- packaging ----------------------------------------------------------------------------- +def test_the_plugin_declares_the_three_events_and_never_pretooluse(): + manifest = json.loads((ROOT / "plugin/.antigravity-plugin/plugin.json").read_text()) + claude = json.loads((ROOT / "plugin/.claude-plugin/plugin.json").read_text()) + assert (manifest["name"], manifest["version"]) == ("tokeneconomizer", claude["version"]) + hooks = json.loads((ROOT / "plugin/hooks.json").read_text()) + assert set(hooks) == {"tokeneconomizer"} + events = hooks["tokeneconomizer"] + # PreToolUse would make agy deny every tool whose hook prints `{}`; PostToolUse carries no + # result; neither is registered. + assert set(events) == {"SessionStart", "PreInvocation", "Stop"} + assert {e: h[0]["timeout"] for e, h in events.items()} == { + "SessionStart": 15, "PreInvocation": 10, "Stop": 10} + for event, (handler,) in events.items(): + assert handler["command"] == f"./scripts/te-hook --host antigravity --event {event}" + assert os.access(ROOT / "plugin/scripts/te-hook", os.X_OK) + # no other agent reads the root hook file: Claude Code's is `hooks/hooks.json`. + assert "hooks" not in claude diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index 33a6336..313b601 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -195,6 +195,8 @@ def test_the_bundle_runs_without_a_checkout(tmp_path): assert (bundle / ".github" / "plugin" / "plugin.json").exists() assert (bundle / "hooks" / "copilot-hooks.json").exists() assert (bundle / "hermes" / "plugin.yaml").exists() + assert (bundle / "hooks.json").exists() # Antigravity's, at the root + assert (bundle / ".antigravity-plugin" / "plugin.json").exists() assert (bundle / "lib" / "tokeneconomizer" / "__init__.py").exists() assert not (bundle / "lib" / "te_harness").exists() assert not (bundle / "lib" / "te_bench").exists() diff --git a/tokeneconomizer/plugin/cli.py b/tokeneconomizer/plugin/cli.py index 46354f2..5a7e6f3 100644 --- a/tokeneconomizer/plugin/cli.py +++ b/tokeneconomizer/plugin/cli.py @@ -813,11 +813,12 @@ def cmd_doctor(args): ok = False import shutil as _sh # Only the agents installed here: a Codex-only machine has no Claude Code catalog to probe. - agents = [h for h in ("claude", "codex", "copilot", "hermes", "opencode", "pi") + agents = [h for h in ("antigravity", "claude", "codex", "copilot", "hermes", "opencode", "pi") if _sh.which(sidecall.claude_cli() if h == "claude" else - sidecall.hermes_cli() if h == "hermes" else h)] - print(f"agents " - f"{', '.join(agents) or 'none of claude, codex, copilot, hermes, opencode, pi on PATH'}") + sidecall.hermes_cli() if h == "hermes" else + "agy" if h == "antigravity" else h)] + print("agents " + (', '.join(agents) or 'none of antigravity (agy), claude, codex, ' + 'copilot, hermes, opencode, pi on PATH')) for h in agents: pause = sidecall.paused(h, root) if pause: diff --git a/tokeneconomizer/plugin/hook.py b/tokeneconomizer/plugin/hook.py index 5e77336..c556dd6 100644 --- a/tokeneconomizer/plugin/hook.py +++ b/tokeneconomizer/plugin/hook.py @@ -1,12 +1,15 @@ """The entry point every plugin hook executes: hook JSON on stdin, hook JSON on stdout. - python -m tokeneconomizer.plugin.hook [--host claude|codex|copilot|hermes|opencode|pi] + python -m tokeneconomizer.plugin.hook [--host antigravity|claude|codex|copilot|hermes|opencode|pi] + [--event NAME] It never fails the agent. Whatever goes wrong -- unreadable input, a full disk, a bug -- the hook prints `{}` (which changes nothing) and records the traceback in `/errors.log`. It also does nothing at all in two cases: `TE_DISABLE=1`, and `TE_SIDECALL=1`, which marks the nested agent process of the plugin's own paid side calls (`sidecall`) so it cannot recurse into these hooks. + +`--event` names the hook event for an agent whose payloads do not (Antigravity's). """ import contextlib import json @@ -34,6 +37,11 @@ def run(payload, env, host_hint=None): return {} if hasattr(host, "record_event"): host.record_event(payload) + if hasattr(host, "expand"): + # One call of this agent's hook stands for several events (Antigravity's `PreInvocation` + # is a prompt and the tool results before it): run each, then answer in the agent's terms. + outs = [LiveController(ctx, p).handle() for p in host.expand(payload, env)] + return host.render(payload.get("hook_event_name"), hosts.merge_outputs(*outs)) return LiveController(ctx, payload).handle() @@ -58,6 +66,7 @@ def main(argv=None, stdin=None, stdout=None, env=None): if "--host" in argv: i = argv.index("--host") hint = argv[i + 1] if i + 1 < len(argv) else None + event = argv[argv.index("--event") + 1] if "--event" in argv[:-1] else None out = {} try: # The agents send UTF-8 whatever the locale; a text stdin would decode it with the @@ -67,6 +76,8 @@ def main(argv=None, stdin=None, stdout=None, env=None): if not (_truthy(env.get("TE_SIDECALL")) or _truthy(env.get("TE_DISABLE"))): payload = json.loads(raw) if raw.strip() else {} if isinstance(payload, dict): + if event: + payload.setdefault("hook_event_name", event) # stdout carries exactly one JSON object back to the agent. The mechanisms # print progress and warnings (ToolingStage's catalog warning, for one), and a # single stray line there would make the agent discard the whole hook output. diff --git a/tokeneconomizer/plugin/hosts.py b/tokeneconomizer/plugin/hosts.py index 343c714..f4ebe54 100644 --- a/tokeneconomizer/plugin/hosts.py +++ b/tokeneconomizer/plugin/hosts.py @@ -756,6 +756,177 @@ def session_usage(self, transcript_path): return trace.hermes_session_usage(transcript_path) +class Antigravity(Host): + """Antigravity CLI (`agy`), through the plugin's `hooks.json` (verified on 1.5.1). + + agy's hook contract differs from the shared one, so this adapter translates both ways: + + * Payloads are camelCase and name no event; the hook file's command names it (`--event`). + `SessionStart`, `PreInvocation` (before every model call) and `Stop` are the events used. + A `PreToolUse` hook is never registered: one that prints `{}` makes agy deny the tool. + `PostToolUse` is not needed -- its payload holds no tool result, and the transcript, which + has every result of the previous model call by the next `PreInvocation`, does. + * There is no prompt event either. `PreInvocation` with `invocationNum` 0 (a number protojson + leaves out of the payload) is the first model call of a prompt, with the prompt in + `lastUserInput`. `expand` turns one agy call into the controller's events: the results the + transcript gained since the last call become `PostToolUse` events (those of a turn that + just ended before the new prompt's `UserPromptSubmit`), and `Stop` closes the turn. + * Output is `injectSteps`: a persistent `systemMessage` step at `SessionStart`, an + `ephemeralMessage` (dropped after the turn) at `PreInvocation`. Nothing reaches the + person from a hook, so a notice goes through the model (`relay`), as on Cursor. + * There is no `SessionEnd`: a conversation is folded by the next session start in its + repository once its transcript has been quiet for `fold.STALE_S`. + + The cursor into the transcript, the turn counter and nothing else are kept per conversation + in `/antigravity-sessions/`. Tool pruning is not supported, and subagents' calls + are not in the transcript, so they are not seen. + """ + name = "antigravity" + # The only prompt-time channel is the ephemeral message, which agy drops after the turn: the + # repository map goes in at SessionStart, where it persists. + prompt_context = False + relays_notices = True + interrupt = "Press Esc to cancel the turn." + end_run = "Press Ctrl+C to end the run." + STATE_DIR = "antigravity-sessions" + STATE_KEEP_S = 30 * 86400 + + def session_id(self, p): + return str(p.get("session_id") or p.get("conversationId") or "") + + def transcript(self, p): + return p.get("transcript_path") or p.get("transcriptPath") or "" + + def cwd(self, p, env): + roots = p.get("workspacePaths") + root = roots[0] if isinstance(roots, list) and roots else "" + return p.get("cwd") or root or os.getcwd() + + def turn_key(self, p): + return str(p.get("prompt_id") or "") + + def context(self, event, text): + return {"additionalContext": text} + + def notice(self, text): + return {"additionalContext": self.relay(text)} + + def stopped_normally(self, p): + return (p or {}).get("terminationReason") == "NO_TOOL_CALL" + + def tool_call(self, p): + name, args, text, code = super().tool_call(p) + resp = p.get("tool_response") + if code is None and isinstance(resp, dict) and resp.get("isError"): + code = 1 # a step agy marks as failed, whatever it printed + return name, args, text, code + + # -- one agy hook call -> the controller's events ------------------------------------- + def _state_path(self, env, cid): + from .paths import state_root + return os.path.join(state_root(env), self.STATE_DIR, cid + ".json") + + def _load(self, env, cid): + try: + with open(self._state_path(env, cid), encoding="utf-8") as fh: + st = json.load(fh) + except (OSError, ValueError): + return None + return st if isinstance(st, dict) else None + + def _save(self, env, cid, st): + path = self._state_path(env, cid) + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + tmp = f"{path}.{os.getpid()}.tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(st, fh) + os.replace(tmp, path) + + def _prune(self, env): + """Forget cursors of conversations nobody has used for `STATE_KEEP_S`.""" + folder = os.path.dirname(self._state_path(env, "x")) + try: + for name in os.listdir(folder): + path = os.path.join(folder, name) + if time.time() - os.path.getmtime(path) > self.STATE_KEEP_S: + os.remove(path) + except OSError: + pass + + def expand(self, payload, env): + """The controller events one agy hook call stands for, in order; see the class.""" + event = payload.get("hook_event_name") + cid = trace.claude_safe_session_id(self.session_id(payload)) + if event not in ("SessionStart", "PreInvocation", "Stop") or not cid: + return [] + base = {"session_id": cid, "cwd": self.cwd(payload, env), + "transcript_path": self.transcript(payload)} + if event == "SessionStart": + self._prune(env) + if self._load(env, cid) is None: + self._save(env, cid, {"offset": 0, "last_step_index": -1, "turn_seq": 0}) + return [dict(base, hook_event_name="SessionStart", source="startup")] + # The first call of a prompt carries no `invocationNum` (protojson drops a zero). + first = event == "PreInvocation" and not payload.get("invocationNum") + st = self._load(env, cid) + path = base["transcript_path"] + out, items = [], [] + if st is None: + # A conversation whose start this plugin did not see: what happened before is not + # replayed as if it had just happened. + try: + size = os.path.getsize(path) + except OSError: + size = 0 + st = {"offset": size, "last_step_index": -1, "turn_seq": 0} + else: + items, st["offset"], st["last_step_index"] = trace.antigravity_read( + path, int(st.get("offset") or 0), int(st.get("last_step_index", -1)), + final=first or event == "Stop") + key = f"{cid}:{st['turn_seq']}" + for item in (i for i in items if i["kind"] == "tool"): + out.append(dict(base, hook_event_name="PostToolUse", prompt_id=key, + tool_name=item["name"], tool_input=item["args"], + tool_response={"content": item["content"][:RESULT_CAP], + "exit_code": item["exit_code"], + "isError": item["is_error"]})) + if first: + st["turn_seq"] = int(st.get("turn_seq") or 0) + 1 + out.append(dict(base, hook_event_name="UserPromptSubmit", + prompt=str(payload.get("lastUserInput") or ""), + prompt_id=f"{cid}:{st['turn_seq']}")) + elif event == "Stop": + out.append(dict(base, hook_event_name="Stop", + terminationReason=payload.get("terminationReason"))) + self._save(env, cid, st) + return out + + def render(self, event, out): + """agy's answer to `event`, from the controller's merged output.""" + hso = out.get("hookSpecificOutput") + parts = [hso.get("additionalContext") if isinstance(hso, dict) else None, + out.get("additionalContext"), out.get("systemMessage")] + text = "\n\n".join(t for t in parts if t) + if not text or event == "Stop": + return {} + if event == "SessionStart": + return {"injectSteps": [{"systemMessage": {"systemMessage": text}}]} + return {"injectSteps": [{"ephemeralMessage": text}]} + + # -- the finished session, in the shapes the mechanisms read -------------------------- + def mechanism_trace(self, transcript_path, workdir): + out = os.path.join(workdir, "antigravity_trace.jsonl") + n = trace.antigravity_session_to_claude(transcript_path, out) + return out if n is not None else None + + def prompt_count(self, transcript_path): + return trace.antigravity_prompt_count(transcript_path) + + def session_usage(self, transcript_path): + return trace.antigravity_session_usage(transcript_path) + + def copilot_home(env=None): env = os.environ if env is None else env return env.get("COPILOT_HOME") or os.path.join(os.path.expanduser("~"), ".copilot") @@ -848,7 +1019,7 @@ def _ancestor_args(limit=4): HOSTS = {"claude": ClaudeCode, "codex": Codex, "copilot": Copilot, "opencode": OpenCode, - "pi": Pi, "cursor": Cursor, "hermes": Hermes} + "pi": Pi, "cursor": Cursor, "hermes": Hermes, "antigravity": Antigravity} def default_host(env=None, recent=None): @@ -874,6 +1045,9 @@ def detect(payload, env=None, hint=None): return HOSTS[hint]() if "turn_id" in (payload or {}): return Codex() + if (payload or {}).get("conversationId") and "/antigravity" in str( + payload.get("transcriptPath") or "").replace("\\", "/"): + return Antigravity() tp = str((payload or {}).get("transcript_path") or "") sp = tp.replace("\\", "/") # a Windows path, with either separator if sp.rsplit("/", 1)[-1].startswith("rollout-") or "/.codex/" in sp: diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index 626c207..60f379c 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -32,6 +32,8 @@ * opencode: `opencode run --format json` in a throwaway directory, on the person's default model, with `bash`, `edit`, `webfetch` and `websearch` denied, no other plugin (`--pure` on 1.x, `--standalone` on 2.x) and its session deleted afterwards. + * Antigravity: `agy --output-format json --disable-slash-commands --print=` in a + throwaway directory, on the person's default model; its usage is in the reply. * Hermes: `hermes chat -Q --oneshot --source tool --ignore-rules --max-turns 1 -t bot_room` on the person's default model. Never `hermes -z`, which auto-approves dangerous commands: a side call's prompt holds text a repository controls, and this form offers it no tool. See @@ -396,6 +398,37 @@ def cursor_call(prompt, model="", cli="cursor-agent", timeout=TIMEOUT_S): return text or "", None +def agy_call(prompt, model="", cli="agy", timeout=TIMEOUT_S): + """A side call through `agy --print`, in an empty throwaway directory, on the person's default + model unless `model` names one. `--print=` is one argument: `-p` followed by a flag + would take the flag for the prompt. A tool that needs permission is denied in print mode, + which a completion does not miss. The reply's usage is priced like a session's: agy names + no model, so on the ledger's fallback curve unless `model` does.""" + with tempfile.TemporaryDirectory(prefix="te-side-", ignore_cleanup_errors=True) as cwd: + cmd = [*program(cli), "--output-format", "json", "--disable-slash-commands"] + if model: + cmd += ["--model", model] + cmd.append(f"--print={prompt}") + out = subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace", + timeout=timeout, env=_env(), cwd=cwd, stdin=subprocess.DEVNULL) + try: + data = json.loads(out.stdout or "{}") + except ValueError: + data = {} + data = data if isinstance(data, dict) else {} + text = data.get("response") if data.get("status") == "SUCCESS" else "" + text = text if isinstance(text, str) else "" + if not text.strip(): + _no_reply("antigravity", out) + usage, eff = data.get("usage"), None + if isinstance(usage, dict) and text.strip(): + eff, _ = accounting.effective({ + "input_tokens": usage.get("input_tokens"), + "cache_read_input_tokens": usage.get("cache_read_tokens"), + "output_tokens": usage.get("output_tokens"), "model": model or "antigravity"}) + return text.strip(), eff + + HERMES_SOURCE = "tool" # Hermes refuses a `--source` it does not know (`invalid_params`, 0.22.5, though its docs say any # value is stored), and `tool` is the one meant for this: hidden from its session pickers, and not @@ -648,7 +681,7 @@ def copilot_call(prompt, model="", cli="copilot", timeout=TIMEOUT_S): # The command each agent's side calls run, as the notices name it; the rest run ` -p`. _CALLS = {"codex": "codex exec", "opencode": "opencode run", "cursor": "cursor-agent -p", - "hermes": "hermes chat"} + "hermes": "hermes chat", "antigravity": "agy -p"} def call_name(host_name): @@ -670,6 +703,8 @@ def runner_for(host_name, config): return lambda prompt, _m="agent": opencode_call(prompt, model=model) if host_name == "cursor": return lambda prompt, _m="agent": cursor_call(prompt, model=model) + if host_name == "antigravity": + return lambda prompt, _m="agent": agy_call(prompt, model=model) if host_name == "hermes": cli = os.path.expanduser(config.hermes_cli) if config.hermes_cli else None return lambda prompt, _m="agent": hermes_call(prompt, model=model, cli=cli) diff --git a/tokeneconomizer/trace/README.md b/tokeneconomizer/trace/README.md index a3dcec7..3fd2cc9 100644 --- a/tokeneconomizer/trace/README.md +++ b/tokeneconomizer/trace/README.md @@ -8,6 +8,7 @@ session as an ordered list of `(tool_name, args, result_text, success)` calls th |---|---| | `calls.py` | `load_trajectory` (format sniffed per file), `call_cwds`, `tool_usage`, `strip_rtk_prefix` | | `catalog.py` | the tool list a session loaded, from whichever source the agent publishes | +| `antigravity.py` | Antigravity CLI (`agy`) transcripts: results paired with their calls, read whole by the fold and a few steps at a time by the hook; usage | | `claude.py` | Claude Code transcripts: calls with the shell's directory, usage by message id, subagents, deferred tools | | `codex.py` | `codex exec --json` streams; a session's rollout file, converted to that stream; usage | | `copilot.py` | Copilot CLI event logs: calls, usage, prompt-turns, MCP servers | diff --git a/tokeneconomizer/trace/__init__.py b/tokeneconomizer/trace/__init__.py index 1457a4c..62e5453 100644 --- a/tokeneconomizer/trace/__init__.py +++ b/tokeneconomizer/trace/__init__.py @@ -6,9 +6,9 @@ * `calls` -- `load_trajectory`: any agent's log as ordered `(name, args, result, ok)` calls; `call_cwds`, `tool_usage`, `strip_rtk_prefix`; * `catalog` -- the tool list a session loaded, from whichever source the agent publishes; - * `claude`, `codex`, `copilot`, `hermes`, `opencode`, `pi` -- each agent's own session file: - usage, metadata, prompt-turns, and the conversions that let the readers above - handle it. + * `antigravity`, `claude`, `codex`, `copilot`, `hermes`, `opencode`, `pi` -- each agent's own + session file: usage, metadata, prompt-turns, and the conversions that let the + readers above handle it. Stdlib only. """ @@ -27,6 +27,8 @@ from .copilot import (copilot_event_calls, copilot_mcp_servers, copilot_prompt_count, copilot_session_meta, copilot_session_usage, copilot_turns, mcp_server_of) +from .antigravity import (antigravity_prompt_count, antigravity_read, + antigravity_session_to_claude, antigravity_session_usage) from .cursor import cursor_events_to_claude, cursor_prompt_count from .hermes import (hermes_export, hermes_find_session, hermes_prompt_count, hermes_result, hermes_row_usage, hermes_session_info, hermes_session_row, @@ -49,6 +51,8 @@ "codex_session_meta", "codex_session_usage", "rollout_item_to_exec", "copilot_event_calls", "copilot_mcp_servers", "copilot_prompt_count", "copilot_session_meta", "copilot_session_usage", "copilot_turns", "mcp_server_of", + "antigravity_prompt_count", "antigravity_read", "antigravity_session_to_claude", + "antigravity_session_usage", "cursor_events_to_claude", "cursor_prompt_count", "hermes_export", "hermes_find_session", "hermes_prompt_count", "hermes_result", "hermes_row_usage", "hermes_session_info", "hermes_session_row", "hermes_session_to_claude", diff --git a/tokeneconomizer/trace/antigravity.py b/tokeneconomizer/trace/antigravity.py new file mode 100644 index 0000000..e99b68e --- /dev/null +++ b/tokeneconomizer/trace/antigravity.py @@ -0,0 +1,229 @@ +"""Antigravity CLI (`agy`) sessions, read from the conversation's `transcript_full.jsonl`. + +The transcript is one JSON object per line (snake_case), written under +`~/.gemini/antigravity-cli/brain//.system_generated/logs/`. Verified on 1.5.1: + + * `USER_INPUT` (source `USER_EXPLICIT`) is a prompt, its text wrapped in ``. + * `PLANNER_RESPONSE` (source `MODEL`) is one model call: `tool_calls` [{name, args}] and, on + current versions, `input_tokens` (fresh input only), `cache_read_tokens` and `output_tokens` + (thinking included). Older versions leave the token fields off most rows: missing is unknown, + not zero. + * The tool results follow their planner row, one `MODEL` step per call, in the call order. + Current agy writes them all as `GENERIC` steps whose `content` reads `Created At: ...`, + `Completed At: ...`, perhaps a line saying a pre-tool hook changed the arguments (rtk does), + then `Command exited with code N.` and `Command output:` for a shell call; older versions + write typed steps (`RUN_COMMAND` with an `exit_code`, `VIEW_FILE`, `GREP_SEARCH`, ...). A + failed call has `status: "ERROR"` and an `error` string. `SYSTEM` rows (`CHECKPOINT`, + `EPHEMERAL_MESSAGE`, ...) are agy's own and are skipped. + +`antigravity_read` pairs the results with their calls and is what both the live adapter (a few +steps at a time, from where it stopped) and the fold (the whole file) use; its tool names and +arguments are the ones the mechanisms know (`Bash {command}`, `Read {file_path}`, ...), so no +mechanism needs an agy branch. Nothing is read from a tool's arguments but what that mapping +keeps: a file write's content, a prompt sent to a subagent, and agy's display-only keys +(`toolAction`, `toolSummary`) stay in agy's log. +""" +import json +import os +import re + +from .jsonl import records as _records + +_USER_RE = re.compile(r"\s*(.*?)\s*", re.S) +_EXIT_RE = re.compile(r"Command exited with code (-?\d+)") +# The lines agy puts ahead of a result's text: when it ran, and that a hook rewrote the call. +_PREFIX_RE = re.compile(r"(?:Created At|Completed At):|A pre-tool hook changed the arguments") +_TOKENS = (("input_tokens", "input_tokens"), ("cache_read_tokens", "cache_read_input_tokens"), + ("output_tokens", "output_tokens")) +_ARG_CAP = 2000 # characters of an unmapped tool's string argument that are kept + +# agy tool -> (name the mechanisms read, {their argument: agy's argument names, first found}) +_FILE = ("TargetFile", "AbsolutePath", "Path") +_TOOLS = { + "run_command": ("Bash", {"command": ("CommandLine",)}), + "view_file": ("Read", {"file_path": _FILE}), + "read_file": ("Read", {"file_path": _FILE}), + "write_to_file": ("Write", {"file_path": _FILE}), + "replace_file_content": ("Edit", {"file_path": _FILE}), + "multi_replace_file_content": ("Edit", {"file_path": _FILE}), + "sed_file": ("Edit", {"file_path": _FILE}), + "grep_search": ("Grep", {"pattern": ("Query",), "path": ("SearchPath",)}), + "find_by_name": ("Glob", {"pattern": ("Pattern",), "path": ("SearchDirectory",)}), + "list_dir": ("Glob", {"path": ("DirectoryPath",)}), +} +_UI_KEYS = ("toolAction", "toolSummary", "WaitMsBeforeAsync") + + +def antigravity_tool(name, args): + """(name, args) of a call as the mechanisms read it; a tool without a mapping keeps its name + and its arguments less the UI-only ones.""" + args = args if isinstance(args, dict) else {} + if name in _TOOLS: + mapped, keys = _TOOLS[name] + return mapped, {k: next((args[a] for a in names if isinstance(args.get(a), str)), "") + for k, names in keys.items()} + return str(name or "?"), {k: v[:_ARG_CAP] if isinstance(v, str) else v + for k, v in args.items() if k not in _UI_KEYS} + + +def antigravity_result(row): + """(text, exit_code or None, failed) of one tool-result step.""" + content = row.get("content") if isinstance(row.get("content"), str) else "" + lines = content.replace("\r\n", "\n").split("\n") + while lines and _PREFIX_RE.match(lines[0]): + lines.pop(0) + text = "\n".join(lines).strip("\n") + code = row.get("exit_code") + if not isinstance(code, int) or isinstance(code, bool): + m = _EXIT_RE.search(text[:400]) + code = int(m.group(1)) if m else None + failed = row.get("status") in ("ERROR", "INVALID") or bool(code) + if isinstance(row.get("error"), str) and row["error"]: + text = (row["error"] + "\n" + text).strip("\n") + return text, code, failed + + +def _prompt_text(row): + content = row.get("content") if isinstance(row.get("content"), str) else "" + m = _USER_RE.search(content) + return (m.group(1) if m else content.split("")[0]).strip() + + +def _read_rows(path, offset): + """[(offset after the line, row)] for the complete lines from `offset` on. A last line still + being written is left for the next read; an offset past the end starts over.""" + try: + with open(path, "rb") as fh: + if offset > os.fstat(fh.fileno()).st_size: + offset = 0 + fh.seek(offset) + data = fh.read() + except OSError: + return [] + out, pos = [], offset + for raw in data.splitlines(keepends=True): + try: + row = json.loads(raw) + except ValueError: + if not raw.endswith(b"\n"): + break + row = None + pos += len(raw) + if isinstance(row, dict): + out.append((pos, row)) + return out + + +def _is_result(row): + # A `finish` call may leave a FINISH step; it is agy's way of ending a turn, no tool run. + return row.get("source") == "MODEL" and row.get("type") not in ("PLANNER_RESPONSE", "FINISH") + + +def antigravity_read(path, offset=0, after=-1, final=True): + """(items, offset, step_index): what the transcript at `path` holds from byte `offset` on, + skipping steps up to `after`, and where to read from next. + + An item is {"kind": "prompt", "text"} or {"kind": "tool", "name", "args", "cwd", "content", + "exit_code", "is_error"}, in transcript order. A planner row's calls are paired with the + result steps after it, in order, once the next planner row or prompt closes them. The last + planner row is closed too if all its results are in or `final` is set (a read at the end of + a turn); otherwise it is left for the next read, and the returned offset stays before it. A + call with no result is left out.""" + rows = [(pos, r) for pos, r in _read_rows(path, offset) + if not isinstance(r.get("step_index"), int) or r["step_index"] > after] + items, done, calls, results = [], 0, None, [] + + def close(): + nonlocal calls, results + for spec, row in zip(calls or (), results): + if row.get("status") == "RUNNING": + continue + text, code, failed = antigravity_result(row) + name, args = antigravity_tool(spec.get("name"), spec.get("args")) + cwd = (spec.get("args") or {}).get("Cwd") + items.append({"kind": "tool", "name": name, "args": args, + "cwd": cwd if isinstance(cwd, str) else None, + "content": text, "exit_code": code, "is_error": failed}) + calls, results = None, [] + + for i, (_pos, row) in enumerate(rows): + if row.get("type") == "PLANNER_RESPONSE" or row.get("type") == "USER_INPUT": + close() + done = i + 1 + if row["type"] == "USER_INPUT": + items.append({"kind": "prompt", "text": _prompt_text(row)}) + elif isinstance(row.get("tool_calls"), list): + calls = [c for c in row["tool_calls"] + if isinstance(c, dict) and isinstance(c.get("args") or {}, dict) + and c.get("name") != "finish"] + if calls: + done = i # not consumed until its results are + elif calls: + if _is_result(row): + results.append(row) + else: + done = i + 1 + if calls and (final or len(results) >= len(calls)): + close() + done = len(rows) + if not done: + return items, offset, after + last = rows[done - 1][1].get("step_index") + return items, rows[done - 1][0], last if isinstance(last, int) else after + + +def antigravity_session_to_claude(path, out_path): + """Write the transcript as Claude Code transcript lines (the shape `cursor_events_to_claude` + writes). Returns the number of records, or None if the file is unreadable or empty.""" + items = antigravity_read(path)[0] + if not items: + return None + out = [] + for n, item in enumerate(items): + if item["kind"] == "prompt": + if item["text"]: + out.append({"type": "user", "message": {"content": [ + {"type": "text", "text": item["text"]}]}}) + continue + tool_id = f"agy-{n}" + out.append({"type": "assistant", "cwd": item["cwd"], "message": {"content": [ + {"type": "tool_use", "id": tool_id, "name": item["name"], "input": item["args"]}]}}) + out.append({"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": tool_id, "content": item["content"], + "is_error": item["is_error"]}]}}) + try: + with open(out_path, "w", encoding="utf-8") as fh: + for rec in out: + fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + except OSError: + return None + return len(out) + + +def antigravity_session_usage(path): + """Token breakdown over the planner rows that report usage; {} when none does. `input_tokens` + is fresh input only, as agy writes it, so cached re-reads are `cache_read_input_tokens`. + The transcript names no model, so `model` is "antigravity": an id no price table has, which + the ledger prices on its labelled fallback curve (with none it would be the Claude curve).""" + totals = {"input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, + "output_tokens": 0} + requests = 0 + for row in _records(path): + if row.get("type") != "PLANNER_RESPONSE": + continue + seen = False + for src, dst in _TOKENS: + v = row.get(src) + if isinstance(v, int) and not isinstance(v, bool): + totals[dst] += v + seen = True + requests += seen + if not requests: + return {} + return dict(totals, requests=requests, model="antigravity") + + +def antigravity_prompt_count(path): + """How many prompts the person gave the conversation.""" + return sum(1 for row in _records(path) if row.get("type") == "USER_INPUT" + and row.get("source") == "USER_EXPLICIT") From 8fbbab041044b26744fd9322fafd7c4694a38a3a Mon Sep 17 00:00:00 2001 From: cyurekli Date: Fri, 6 Nov 2026 09:58:22 +0100 Subject: [PATCH 2/3] Antigravity: no side calls, no replay at SessionStart, Windows-safe test - agy has no option that runs a prompt with every tool off, so the plugin makes no model call through it: `sidecall.NO_SIDE_CALLS` gives Antigravity no runner, and the loop guard never starts a judge for it. Docs now say micro-skills' LLM-free facts only. - SessionStart with no saved cursor starts reading at the transcript's current end, as PreInvocation already did, so a resumed conversation does not replay its old tool calls. - The cursor-file permission test is POSIX-only, like the other permission-bit tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++--- docs/PLUGIN.md | 9 ++-- plugin/README.md | 17 ++++--- tests/conftest.py | 3 +- tests/test_antigravity.py | 66 +++++++++++++--------------- tokeneconomizer/plugin/controller.py | 8 ++-- tokeneconomizer/plugin/hosts.py | 20 +++++---- tokeneconomizer/plugin/sidecall.py | 48 +++++--------------- 8 files changed, 79 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 3165128..dea1ab7 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ hook events locally and reports session cost as unavailable. Cursor support is m only: on Windows, Cursor runs hook commands through PowerShell, and this plugin's launcher is a POSIX shell script. -**Antigravity CLI** (`agy`; experimental; micro-skills and the loop guard) +**Antigravity CLI** (`agy`; experimental; micro-skills' LLM-free facts only) ```sh python scripts/build_plugin.py --out dist/marketplace @@ -185,7 +185,9 @@ agy plugin install dist/marketplace/tokeneconomizer Start `agy` and send a prompt in a repository, then check with `~/.tokeneconomizer/bin/te --host antigravity status`. `agy` has no plugin commands, so the -plugin's commands run from a terminal, and tool pruning is not available. macOS and Linux only: +plugin's commands run from a terminal. The plugin makes no model call through `agy`, which has no +option that runs a prompt with every tool off, so the paid proposer and the loop guard are off +and tool pruning is not available. macOS and Linux only: on Windows `agy` runs hook commands through `cmd /c`, and this plugin's launcher is a POSIX shell script. See [its limits](plugin/README.md#antigravity-cli). @@ -261,8 +263,8 @@ have not been tested yet. own transcript and a run that ends as completed closes its turn. Session usage stays unavailable, and the interactive TUI has not been tested. - **Antigravity CLI** support is experimental: it was written against agy 1.5.1's hook contract - and transcript format, which are not a public interface, and only micro-skills and the loop guard - are supported. See [its limits](plugin/README.md#antigravity-cli). + and transcript format, which are not a public interface, and only micro-skills' LLM-free facts + are supported: the plugin makes no model call through `agy`. See [its limits](plugin/README.md#antigravity-cli). - **Hermes Agent** support is experimental: it was tested on 0.22.5 only, with a scripted local model and one free real model, on the classic CLI and one-shot runs only. Micro-skills and a redirect reaching a real model are verified there; the still-stuck notice reaching a person is @@ -398,7 +400,7 @@ redirects, you see one line that ends with how to stop it: > calls). Press Esc to interrupt it. Where that line appears, and how to stop the turn, in the terminal UI and in a headless run -(`claude -p`, `codex exec`, `copilot -p`, `pi -p`, `opencode run`, `hermes -z`, `agy -p`): +(`claude -p`, `codex exec`, `copilot -p`, `pi -p`, `opencode run`, `hermes -z`): | Agent | Interactive: where it shows | How to stop | Headless: where it shows | How to stop | |---|---|---|---|---| @@ -408,7 +410,6 @@ Where that line appears, and how to stop the turn, in the terminal UI and in a h | Pi | as a notification | Esc | on stderr | Ctrl+C | | opencode | as a notification | Esc | on stderr | Ctrl+C | | Hermes Agent | in the model's reply | Ctrl+C | in the final reply | Ctrl+C | -| Antigravity CLI | in the model's reply | Esc | in the final reply | Ctrl+C | - **Claude Code** shows a plugin's own lines only in its detailed transcript (Ctrl+O), so the plugin asks Claude to repeat this one in its reply; the model may word it differently or leave @@ -419,7 +420,6 @@ Where that line appears, and how to stop the turn, in the terminal UI and in a h - **opencode** 2.x prints the line to the opencode server's error output, not in its terminal UI. - **Hermes Agent**: the plugin asks the model to repeat the line, since no hook was found to reach the person's view; a model may word it differently or leave it out. -- **Antigravity CLI**: the same, for the same reason. In a headless run nobody may be watching, so a flagged turn runs until it ends or reaches the agent's own limits; afterwards, `te status` counts the redirects and "still stuck" notices. diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md index 75454d2..37ea225 100644 --- a/docs/PLUGIN.md +++ b/docs/PLUGIN.md @@ -701,8 +701,10 @@ open-vocabulary. On a repository you do not trust, leave micro-skills off (the d * **Hermes Agent** — experimental: micro-skills and the loop guard, on the classic CLI and one-shot runs, verified with a scripted model and one free real model. Its limits are listed in `plugin/README.md`. -* **Antigravity CLI (`agy`, experimental)** — micro-skills and the loop guard, written against - agy 1.5.1 and its own installed hook documentation; tool pruning is not implemented. What +* **Antigravity CLI (`agy`, experimental)** — micro-skills' LLM-free facts, written against + agy 1.5.1 and its own installed hook documentation; tool pruning is not implemented. The + plugin makes no model call through `agy`, which has no option that runs a prompt with every tool + off (`sidecall.NO_SIDE_CALLS`), so the paid proposer and the loop guard's judge never run. What was captured live: hooks come from a `hooks.json` at the plugin root, run through `sh -c` in that directory, with camelCase payloads that name no event (the hook file's command does, `--event`) and leave out a zero (`invocationNum` 0, the first model call of a prompt). A @@ -713,8 +715,7 @@ open-vocabulary. On a repository you do not trust, leave micro-skills off (the d prompt event and no `SessionEnd`: a prompt is the `PreInvocation` with `invocationNum` 0, and a conversation is folded by the next session start in its repository once its transcript has been quiet for ten minutes. Context goes back as `injectSteps`: a persistent `systemMessage` at - `SessionStart`, an `ephemeralMessage` (dropped after the turn) at `PreInvocation`. No hook - output was found that reaches the person, so a notice goes through the model. Calls a subagent + `SessionStart`, an `ephemeralMessage` (dropped after the turn) at `PreInvocation`. Calls a subagent makes are not in the transcript and are not seen. Transcript token fields are absent from older agy versions (unknown, not zero), and the transcript names no model, so session cost is priced on the ledger's labelled fallback. macOS and Linux only: agy runs hook commands through diff --git a/plugin/README.md b/plugin/README.md index a0e7509..3c419bb 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -499,7 +499,7 @@ log a warning for the empty section of a session with no facts. ## Antigravity CLI -Experimental: **micro-skills and the loop guard**, written against `agy` 1.5.1 on macOS and Linux +Experimental: **micro-skills' LLM-free facts**, written against `agy` 1.5.1 on macOS and Linux (agy runs hook commands through `cmd /c` on Windows, and the launcher is a POSIX shell script). **1. Install.** Build the self-contained plugin, which copies the package into it, and install the @@ -516,27 +516,26 @@ validator says it is never loaded. **2. Check it.** Run a session, then `~/.tokeneconomizer/bin/te --host antigravity status` in the repository. agy plugins load no slash commands, so every command runs from a terminal. Micro-skills -is off by default: `te --host antigravity config set microskills=on` (or `envfacts`, without model -calls). +is off by default: `te --host antigravity config set microskills=envfacts`. **How it works, and what it does not do.** - Three hooks are registered: `SessionStart` (injects the learned facts), `PreInvocation` (before - every model call: opens a turn at a new prompt, feeds the loop guard the tool results since the - last call, and carries its redirect as an ephemeral message) and `Stop`. + every model call: opens a turn at a new prompt and reads the tool results since the last call) + and `Stop`. - It never registers `PreToolUse`: a `PreToolUse` hook that prints `{}` makes agy deny the tool. `PostToolUse` is unused: its payload holds no tool result, so the results are read from the conversation's `transcript_full.jsonl` instead. A cursor into it is kept in `~/.tokeneconomizer/antigravity-sessions/`, private to you. - agy has no `SessionEnd`: a conversation is folded by the next session start in that repository, once its transcript has been quiet for 10 minutes. -- Nothing a hook prints reaches your view, so the loop guard's notice goes through the model, which - may word it differently or leave it out. Press Esc in the UI (Ctrl+C for `agy -p`) to stop a turn. - Tool pruning is not available. Calls a subagent makes are not in the transcript and are not seen. The transcript names no model, so session cost is priced on a labelled fallback, and older agy versions leave token counts out of most of it. -- Model calls (the proposer, the loop guard's judge) run `agy --print` in a throwaway directory on - your default model (`paid_model` names another). Each one may leave a conversation in agy's history. +- The plugin makes no model call through `agy`: it has no option that runs a prompt with every + tool off, and such a call's prompt holds text a repository controls. So micro-skills' paid + proposer never runs, and the loop guard, which needs a judge's verdict to act, never redirects + a turn. ## What to expect diff --git a/tests/conftest.py b/tests/conftest.py index dfaa652..595beda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,5 @@ def _no_model_calls(request, monkeypatch): monkeypatch.setattr(judge, "spawn_job", judge.run_job) if request.node.get_closest_marker("real_sidecalls"): return - for name in ("agy_call", "claude_call", "codex_call", "copilot_call", "hermes_call", - "pi_call"): + for name in ("claude_call", "codex_call", "copilot_call", "hermes_call", "pi_call"): monkeypatch.setattr(sidecall, name, lambda *a, **k: ("", None)) diff --git a/tests/test_antigravity.py b/tests/test_antigravity.py index 06369f0..766af7b 100644 --- a/tests/test_antigravity.py +++ b/tests/test_antigravity.py @@ -147,6 +147,20 @@ def test_a_conversation_this_plugin_did_not_start_is_not_replayed(tp, env): assert kinds(got) == [("UserPromptSubmit", "fix it", f"{CID}:1")] +def test_a_resumed_conversation_does_not_replay_its_old_calls_at_session_start(tp, env): + host, t = Antigravity(), Transcript(tp) + t.prompt("old") + t.call([sh("ls")], [out(0)]) # before this plugin saw the conversation + host.expand(hook_payload(tp, "SessionStart"), env) + assert kinds(host.expand(hook_payload(tp, "PreInvocation"), env)) == [ + ("UserPromptSubmit", "fix it", f"{CID}:1")] + t.call([sh("pwd")], [out(0)]) + got = host.expand(hook_payload(tp, "PreInvocation", invocationNum=1), env) + assert [e["tool_input"]["command"] for e in got] == ["pwd"] + + +@pytest.mark.skipif(os.name == "nt", + reason="POSIX permission bits: Windows keeps the state private by ACL") def test_the_cursor_file_is_private(tp, env): Antigravity().expand(hook_payload(tp, "SessionStart"), env) state = Path(env["TOKENECONOMIZER_HOME"]) / "antigravity-sessions" @@ -391,42 +405,22 @@ def test_a_stuck_turn_is_redirected_through_an_ephemeral_message(tp, tmp_path, e # ---- the side call ------------------------------------------------------------------------- -@pytest.mark.real_sidecalls -def test_the_side_call_runs_agy_print_and_prices_its_usage(monkeypatch): - seen = [] - reply = {"conversation_id": "c", "status": "SUCCESS", "response": "DONE\n", "num_turns": 1, - "usage": {"input_tokens": 36496, "output_tokens": 1406, "thinking_tokens": 1185, - "cache_read_tokens": 100, "total_tokens": 37902}} - - def fake_run(cmd, **kw): - seen.append((cmd, kw)) - return subprocess.CompletedProcess(cmd, 0, json.dumps(reply), "") - - monkeypatch.setattr(sidecall.subprocess, "run", fake_run) - text, eff = sidecall.agy_call("is it stuck? -p", model="paid-m") - assert text == "DONE" and eff - (cmd, kw), = seen - assert cmd == ["agy", "--output-format", "json", "--disable-slash-commands", "--model", - "paid-m", "--print=is it stuck? -p"] - assert kw["env"]["TE_SIDECALL"] == "1" and kw["stdin"] == subprocess.DEVNULL - assert kw["cwd"] != os.getcwd() and "te-side-" in kw["cwd"] - runner = sidecall.runner_for("antigravity", type("C", (), {"paid_model": ""})()) - assert runner("p") == ("DONE", eff) - assert sidecall.call_name("antigravity") == "agy -p" - - -@pytest.mark.real_sidecalls -def test_a_failed_side_call_is_no_reply(monkeypatch): - noted = [] - monkeypatch.setattr(sidecall, "_no_reply", lambda host, o: noted.append(host)) - bad = {"status": "ERROR", "finished_reason": "x", "response": "partial"} - monkeypatch.setattr(sidecall.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess( - cmd, 1, json.dumps(bad), "")) - assert sidecall.agy_call("p") == ("", None) - monkeypatch.setattr(sidecall.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess( - cmd, 0, "not json", "")) - assert sidecall.agy_call("p") == ("", None) - assert noted == ["antigravity", "antigravity"] +def test_antigravity_makes_no_side_call(monkeypatch): + # agy has no option that runs a turn with every tool off, and a side call's prompt holds + # text a repository controls: the proposer and the judge get no runner. + started = [] + monkeypatch.setattr(sidecall.subprocess, "run", lambda *a, **k: started.append(a)) + assert sidecall.runner_for("antigravity", type("C", (), {"paid_model": "m"})()) is None + assert not hasattr(sidecall, "agy_call") and started == [] + + +def test_a_stuck_looking_agy_turn_never_starts_a_judge(tp, env, monkeypatch): + from tokeneconomizer.plugin import controller, judge + spawned = [] + monkeypatch.setattr(judge, "spawn_job", lambda *a, **k: spawned.append(a)) + ctl = type("C", (), {"host": Antigravity(), "ctx": None, "sid": CID})() + verdict = controller.LiveController._judge(ctl, {"judge_confidence": 0.5}, envelope={}) + assert isinstance(verdict, judge.NoVerdict) and spawned == [] # ---- packaging ----------------------------------------------------------------------------- diff --git a/tokeneconomizer/plugin/controller.py b/tokeneconomizer/plugin/controller.py index 2722033..9261919 100644 --- a/tokeneconomizer/plugin/controller.py +++ b/tokeneconomizer/plugin/controller.py @@ -320,12 +320,14 @@ def _judge(self, thr, envelope): None before this repository has an envelope -- the guard does not act on a repository whose usual turns it has not learned (the rule fallback never acts without one either). - While this agent's model calls are paused, a judge that never answers: the turn is - spared, as a missing verdict is, rather than redirected on the rule alone.""" + While this agent's model calls are paused, or it has none (`sidecall.NO_SIDE_CALLS`), + a judge that never answers: the turn is spared, as a missing verdict is, rather than + redirected on the rule alone.""" from .judge import AsyncJudge, NoVerdict if envelope is None: return None - if sidecall.paused(self.host.name, self.ctx.root): + if self.host.name in sidecall.NO_SIDE_CALLS or sidecall.paused(self.host.name, + self.ctx.root): return NoVerdict() return AsyncJudge(self.ctx, self.sid, thr.get("judge_confidence")) diff --git a/tokeneconomizer/plugin/hosts.py b/tokeneconomizer/plugin/hosts.py index 84a77a7..2490413 100644 --- a/tokeneconomizer/plugin/hosts.py +++ b/tokeneconomizer/plugin/hosts.py @@ -882,7 +882,7 @@ def expand(self, payload, env): if event == "SessionStart": self._prune(env) if self._load(env, cid) is None: - self._save(env, cid, {"offset": 0, "last_step_index": -1, "turn_seq": 0}) + self._save(env, cid, self._fresh(base["transcript_path"])) return [dict(base, hook_event_name="SessionStart", source="startup")] # The first call of a prompt carries no `invocationNum` (protojson drops a zero). first = event == "PreInvocation" and not payload.get("invocationNum") @@ -890,13 +890,7 @@ def expand(self, payload, env): path = base["transcript_path"] out, items = [], [] if st is None: - # A conversation whose start this plugin did not see: what happened before is not - # replayed as if it had just happened. - try: - size = os.path.getsize(path) - except OSError: - size = 0 - st = {"offset": size, "last_step_index": -1, "turn_seq": 0} + st = self._fresh(path) else: items, st["offset"], st["last_step_index"] = trace.antigravity_read( path, int(st.get("offset") or 0), int(st.get("last_step_index", -1)), @@ -919,6 +913,16 @@ def expand(self, payload, env): self._save(env, cid, st) return out + @staticmethod + def _fresh(path): + """The state of a conversation first seen now. Reading starts at the transcript's current + end, so a resumed conversation's earlier steps are not replayed as if they just happened.""" + try: + size = os.path.getsize(path) + except (OSError, TypeError): + size = 0 + return {"offset": size, "last_step_index": -1, "turn_seq": 0} + def render(self, event, out): """agy's answer to `event`, from the controller's merged output.""" hso = out.get("hookSpecificOutput") diff --git a/tokeneconomizer/plugin/sidecall.py b/tokeneconomizer/plugin/sidecall.py index 60f379c..d369464 100644 --- a/tokeneconomizer/plugin/sidecall.py +++ b/tokeneconomizer/plugin/sidecall.py @@ -32,8 +32,9 @@ * opencode: `opencode run --format json` in a throwaway directory, on the person's default model, with `bash`, `edit`, `webfetch` and `websearch` denied, no other plugin (`--pure` on 1.x, `--standalone` on 2.x) and its session deleted afterwards. - * Antigravity: `agy --output-format json --disable-slash-commands --print=` in a - throwaway directory, on the person's default model; its usage is in the reply. + * Antigravity: none. `agy` has no option that runs a turn without tools, and a side call's + prompt holds text a repository controls, so its micro-skills keep to the LLM-free facts and + its loop guard never asks a judge (`NO_SIDE_CALLS`). * Hermes: `hermes chat -Q --oneshot --source tool --ignore-rules --max-turns 1 -t bot_room` on the person's default model. Never `hermes -z`, which auto-approves dangerous commands: a side call's prompt holds text a repository controls, and this form offers it no tool. See @@ -398,37 +399,6 @@ def cursor_call(prompt, model="", cli="cursor-agent", timeout=TIMEOUT_S): return text or "", None -def agy_call(prompt, model="", cli="agy", timeout=TIMEOUT_S): - """A side call through `agy --print`, in an empty throwaway directory, on the person's default - model unless `model` names one. `--print=` is one argument: `-p` followed by a flag - would take the flag for the prompt. A tool that needs permission is denied in print mode, - which a completion does not miss. The reply's usage is priced like a session's: agy names - no model, so on the ledger's fallback curve unless `model` does.""" - with tempfile.TemporaryDirectory(prefix="te-side-", ignore_cleanup_errors=True) as cwd: - cmd = [*program(cli), "--output-format", "json", "--disable-slash-commands"] - if model: - cmd += ["--model", model] - cmd.append(f"--print={prompt}") - out = subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace", - timeout=timeout, env=_env(), cwd=cwd, stdin=subprocess.DEVNULL) - try: - data = json.loads(out.stdout or "{}") - except ValueError: - data = {} - data = data if isinstance(data, dict) else {} - text = data.get("response") if data.get("status") == "SUCCESS" else "" - text = text if isinstance(text, str) else "" - if not text.strip(): - _no_reply("antigravity", out) - usage, eff = data.get("usage"), None - if isinstance(usage, dict) and text.strip(): - eff, _ = accounting.effective({ - "input_tokens": usage.get("input_tokens"), - "cache_read_input_tokens": usage.get("cache_read_tokens"), - "output_tokens": usage.get("output_tokens"), "model": model or "antigravity"}) - return text.strip(), eff - - HERMES_SOURCE = "tool" # Hermes refuses a `--source` it does not know (`invalid_params`, 0.22.5, though its docs say any # value is stored), and `tool` is the one meant for this: hidden from its session pickers, and not @@ -679,9 +649,12 @@ def copilot_call(prompt, model="", cli="copilot", timeout=TIMEOUT_S): return text, eff +# Hosts the plugin makes no model call for: their CLI cannot run a prompt with every tool off. +NO_SIDE_CALLS = frozenset({"antigravity"}) + # The command each agent's side calls run, as the notices name it; the rest run ` -p`. _CALLS = {"codex": "codex exec", "opencode": "opencode run", "cursor": "cursor-agent -p", - "hermes": "hermes chat", "antigravity": "agy -p"} + "hermes": "hermes chat"} def call_name(host_name): @@ -692,7 +665,10 @@ def call_name(host_name): def runner_for(host_name, config): """The optimizer callable for this host. Each host's calls go through that host's own CLI, never another agent's. The caller decides whether a call is allowed: micro-skills' proposer - when micro-skills is on, the loop guard's judge when the guard is.""" + when micro-skills is on, the loop guard's judge when the guard is. None for a host in + `NO_SIDE_CALLS`.""" + if host_name in NO_SIDE_CALLS: + return None model = config.paid_model if host_name == "pi": cli = os.path.expanduser(config.pi_cli) if config.pi_cli else "pi" @@ -703,8 +679,6 @@ def runner_for(host_name, config): return lambda prompt, _m="agent": opencode_call(prompt, model=model) if host_name == "cursor": return lambda prompt, _m="agent": cursor_call(prompt, model=model) - if host_name == "antigravity": - return lambda prompt, _m="agent": agy_call(prompt, model=model) if host_name == "hermes": cli = os.path.expanduser(config.hermes_cli) if config.hermes_cli else None return lambda prompt, _m="agent": hermes_call(prompt, model=model, cli=cli) From 139e0b74e7db5fb835c7961fc60eb6f649e0aea7 Mon Sep 17 00:00:00 2001 From: cyurekli Date: Fri, 6 Nov 2026 21:51:38 +0100 Subject: [PATCH 3/3] plugin/README: list the Antigravity CLI in the commands table Co-Authored-By: Claude Opus 5.5 --- plugin/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/README.md b/plugin/README.md index b087010..4b76c34 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -665,6 +665,7 @@ How to run them: | opencode | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host opencode ` | | Grok Build | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host grok ` | | Hermes Agent | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host hermes ` | +| Antigravity CLI | none (run from a terminal) | `~/.tokeneconomizer/bin/te --host antigravity ` | `doctor`, `history` and `repomap` run from a terminal (or as `/te …` on Pi); there is no slash command or Codex skill for them.