@bto-labs/agy-bridge 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,17 @@
1
+ # Delegation rules: agy-bridge
2
+
3
+ You have agy-bridge MCP tools that delegate heavy work to the Antigravity CLI
4
+ (Gemini). Delegation keeps large content OUT of your context — only answers
5
+ come back. Prefer delegating over doing it yourself when:
6
+
7
+ - **Any file >200 lines** you'd otherwise read → `analyze_files`
8
+ - **More than 3 files** in one analysis/comparison → `analyze_files`
9
+ - **Git history or repo-wide searches** (git log/diff/blame, broad greps) → `deep_search`
10
+ - **Web/documentation lookups** → `web_lookup`
11
+ - **Plan critique or code review** → `adversarial_review` (always — a second
12
+ model family catches what you miss)
13
+ - **Follow-up question on a prior delegation** → `follow_up` with the returned
14
+ session id (never resend the context)
15
+
16
+ Do NOT delegate: small single-file edits, questions you can answer from
17
+ context already loaded, or tasks needing tools only you have.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shahzaib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ <div align="center">
2
+
3
+ <img src="https://raw.githubusercontent.com/bto-labs/agy-bridge/main/assets/banner.svg" alt="agy-bridge — Claude Code delegates heavy tasks to the Antigravity CLI" width="100%">
4
+
5
+ # agy-bridge
6
+
7
+ [![CI](https://github.com/bto-labs/agy-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/bto-labs/agy-bridge/actions/workflows/ci.yml)
8
+ [![npm version](https://img.shields.io/npm/v/@bto-labs/agy-bridge)](https://www.npmjs.com/package/@bto-labs/agy-bridge)
9
+ [![npm downloads](https://img.shields.io/npm/dm/@bto-labs/agy-bridge)](https://www.npmjs.com/package/@bto-labs/agy-bridge)
10
+ [![node](https://img.shields.io/node/v/@bto-labs/agy-bridge)](https://nodejs.org)
11
+ [![license](https://img.shields.io/npm/l/@bto-labs/agy-bridge)](LICENSE)
12
+
13
+ An MCP bridge that lets **Claude Code delegate heavy tasks to the Antigravity CLI (`agy`)** — saving Claude's context window and tokens for what matters.
14
+
15
+ Claude sends a task → the bridge routes it to the best available model via `agy` → only the answer comes back. Large files, deep git searches, and web lookups never touch Claude's context.
16
+
17
+ Forked and maintained by [BTO Labs](https://github.com/bto-labs) under the MIT license.
18
+
19
+ </div>
20
+
21
+ ```
22
+ User → Claude Code → agy-bridge (MCP) → agy CLI → Gemini / Claude / GPT-OSS
23
+ ← ← ←
24
+ ```
25
+
26
+ ## Why this over claude-to-agy?
27
+
28
+ | | claude-to-agy | **agy-bridge** |
29
+ | --------------- | --------------------------- | ------------------------------------------------------------------------------------ |
30
+ | Tool surface | 1 generic `delegate_to_agy` | 6 purpose-built tools — Claude self-routes reliably |
31
+ | Model selection | none (agy default only) | per-tool routing across all `agy models`, with availability detection and fallback |
32
+ | Multi-turn | stateless | session continuity — `follow_up` resumes agy conversations without resending context |
33
+ | Output safety | unbounded | configurable truncation cap protects Claude's context |
34
+ | Sandbox | no | optional `--sandbox` mode |
35
+ | Install | uvx (Python) | npx (Node) — zero install |
36
+
37
+ ## Requirements
38
+
39
+ - Node.js 18+
40
+ - [Antigravity CLI](https://antigravity.google/docs/cli-getting-started) (`agy`) installed and authenticated
41
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ # 1. Register the MCP server (user scope = all projects).
47
+ # add-json bakes in a generous client-side timeout so long analyze_files /
48
+ # delegate calls don't trip Claude Code's tool-call deadline (see Timeouts).
49
+ claude mcp add-json -s user agy-bridge \
50
+ '{"command":"npx","args":["-y","@bto-labs/agy-bridge"],"timeout":600000}'
51
+
52
+ # 2. Add delegation rules to your project (or ~/.claude/CLAUDE.md for global)
53
+ curl -o CLAUDE.md https://raw.githubusercontent.com/bto-labs/agy-bridge/main/CLAUDE.md
54
+ ```
55
+
56
+ > The `"timeout": 600000` (10 min, milliseconds) is the **client-side** tool-call
57
+ > deadline — without it, a cold-start `analyze_files` (~40–50s) or a long
58
+ > `delegate` can hit Claude Code's default and return `timed out waiting for
59
+ response` while the agy run is still going. If your client doesn't honor a
60
+ > per-server `timeout`, set the global env var `MCP_TOOL_TIMEOUT=600000` instead.
61
+ > Details and the agy-side budgets are in [Timeouts and cancellation](#timeouts-and-cancellation).
62
+
63
+ ## Tools
64
+
65
+ | Tool | Use for | Model routing (first available) |
66
+ | -------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- |
67
+ | `analyze_files` | Files >200 lines, >3 files at once, logs, dumps, generated code | Gemini 3.5 Flash (High) → Gemini 3.1 Pro (Low) |
68
+ | `deep_search` | git log/diff/blame archaeology, repo-wide greps | Gemini 3.5 Flash (Medium) → (High) |
69
+ | `web_lookup` | Docs, API references, external/current knowledge | Gemini 3.5 Flash (Medium) → (High) |
70
+ | `adversarial_review` | Plan critiques, design and code reviews | Gemini 3.1 Pro (High) → Claude Opus 4.6 (Thinking) → Flash (High) |
71
+ | `follow_up` | Continue a prior session by `session_id` — no context resend | inherits the session |
72
+ | `delegate` | Anything else heavy | Gemini 3.5 Flash (High) |
73
+
74
+ All tools accept optional `cwd` (project root) and `model` (exact CLI ID or display name from `agy models`; validated, with available models listed on mismatch).
75
+
76
+ Every response ends with a footer:
77
+
78
+ ```
79
+ ---
80
+ [agy-bridge] model: Gemini 3.5 Flash (High) | session: 1f0c…-d4 (use follow_up to continue)
81
+ ```
82
+
83
+ ### Model routing
84
+
85
+ On first use the bridge runs `agy models` (cached for the process lifetime) and picks the first available model in the tool's preference chain. If none is available it falls back to `AGY_DEFAULT_MODEL`, and finally to agy's own default. agy silently ignores unknown `--model` values, so the bridge validates names up front instead of letting requests land on the wrong model.
86
+
87
+ ### Quota-aware failover
88
+
89
+ agy never surfaces quota exhaustion in print mode — it silently retries the 429 until its print-timeout, then exits 0 with empty output, which used to look like an indefinite hang. The bridge now watches each run's log file (via `--log-file`) and on `RESOURCE_EXHAUSTED (code 429)`:
90
+
91
+ 1. kills the agy process group immediately (no waiting out the timeout),
92
+ 2. parses the reset time ("Resets in 4h24m") into an in-process cooldown registry,
93
+ 3. retries the same prompt on the next model in the tool's chain,
94
+ 4. skips cooled-down models on all subsequent calls until their quota resets.
95
+
96
+ Failovers are annotated in the response footer (`failover: <model>: quota exhausted (resets in 4h24m)`). Only when every candidate is exhausted does the call fail — in seconds, with reset times listed — instead of hanging.
97
+
98
+ ### Timeouts and cancellation
99
+
100
+ Each tool has its own default timeout sized to its job: `web_lookup` 120s, `deep_search` 180s, `analyze_files` / `adversarial_review` / `follow_up` 300s, `delegate` 600s. Setting `AGY_TIMEOUT` explicitly overrides all of them at once. To change a single tool, set `AGY_TIMEOUT_<TOOL_NAME>` instead (e.g. `AGY_TIMEOUT_DEEP_SEARCH=300`); a per-tool override takes precedence over the global `AGY_TIMEOUT` and the tool's default. The full set of per-tool variables is `AGY_TIMEOUT_ANALYZE_FILES`, `AGY_TIMEOUT_DEEP_SEARCH`, `AGY_TIMEOUT_WEB_LOOKUP`, `AGY_TIMEOUT_ADVERSARIAL_REVIEW`, `AGY_TIMEOUT_FOLLOW_UP`, and `AGY_TIMEOUT_DELEGATE`. The kill path escalates SIGTERM → SIGKILL across the whole process group, and the deadline fires even if agy's helper processes hold the output pipes open. Cancelling the tool call from the MCP client (e.g. pressing Esc in Claude Code) also kills the agy run instead of orphaning it.
101
+
102
+ **Two timeout layers — align them.** The timeouts above are the _agy-side_ budget. Your MCP client (Claude Code) has its own, separate _tool-call_ timeout, and if it is shorter than the agy budget the client gives up first — you'll see `Error: timed out waiting for response` (note: agy-bridge's own timeout reads `agy timed out after Ns` instead). The work is not lost: the agy session persists, so `follow_up` with the returned `session_id` retrieves the result. But the real fix is to make the client wait at least as long as agy: the [Install](#install) command already sets a per-server `timeout` of 600000ms (scoped to the agy-bridge entry only). If you registered the server without it, re-run the `add-json` command from Install, or set the global env var `MCP_TOOL_TIMEOUT=600000`. Rule of thumb: **client `timeout` ≥ agy budget**.
103
+
104
+ **Expected latency.** Most of the perceived "slowness" is cold start: the first call in a session spawns the agy CLI and warms the model. A simple `analyze_files` over 3 files measures around **40–50s cold** (≈46s observed), dropping on subsequent same-session calls. A first call that also hits a quota 429 takes longer while the bridge fails over. So a client timeout below ~60s will intermittently trip on cold starts even for "simple" questions — size it generously.
105
+
106
+ ## Configuration
107
+
108
+ All optional, via environment variables:
109
+
110
+ | Variable | Default | Description |
111
+ | ---------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- |
112
+ | `AGY_PATH` | `agy` | Path to the agy binary |
113
+ | `AGY_TIMEOUT` | per-tool | Seconds; overrides all per-tool timeouts at once (see above), passed as `--print-timeout`, enforced with a 15s kill grace |
114
+ | `AGY_TIMEOUT_<TOOL>` | per-tool | Seconds; overrides the timeout for a single tool only, e.g. `AGY_TIMEOUT_DEEP_SEARCH=300`. Wins over `AGY_TIMEOUT` |
115
+ | `AGY_MAX_OUTPUT_CHARS` | `50000` | Truncation cap for tool output |
116
+ | `AGY_DEFAULT_MODEL` | unset | Fallback model when no chain entry is available |
117
+ | `AGY_SKIP_PERMISSIONS` | `true` | Pass `--dangerously-skip-permissions` to agy |
118
+ | `AGY_SANDBOX` | `false` | Run agy with `--sandbox` |
119
+ | `AGY_ON_FAILURE` | `fallback` | `strict` appends an instruction to failed-tool errors telling the calling agent not to absorb the work itself |
120
+
121
+ ### Failure behavior
122
+
123
+ The bridge always fails loudly: agy errors surface as MCP tool errors with agy's actual stderr, and degraded model routing is annotated in the response footer. By default the calling agent (Claude) will typically do the work itself after a failure — visible in the transcript, but easy to stop noticing in a long session. Set `AGY_ON_FAILURE=strict` to append an explicit "do NOT perform this work yourself — report the failure to the user" instruction to every delegation error, so you keep control over when token savings are silently lost.
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ npm install
129
+ npm test # vitest unit tests (exec mocked — no agy needed)
130
+ npm run typecheck
131
+ npm run build # tsup → dist/index.js
132
+ ```
133
+
134
+ ## Contributors
135
+
136
+ Contributions are welcome — open an issue or PR.
137
+
138
+ <a href="https://github.com/bto-labs/agy-bridge/graphs/contributors">
139
+ <img src="https://contrib.rocks/image?repo=bto-labs/agy-bridge" alt="Contributors" />
140
+ </a>
141
+
142
+ ## Star History
143
+
144
+ [![Star History Chart](https://api.star-history.com/svg?repos=bto-labs/agy-bridge&type=Date)](https://www.star-history.com/#bto-labs/agy-bridge&Date)
145
+
146
+ ## License
147
+
148
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: agy-delegation
3
+ description: Use when analyzing large files (>200 lines), more than 3 files at once, deep git/grep searches, web lookups, or adversarial reviews - delegates to the Antigravity CLI via agy-bridge MCP tools to save context.
4
+ ---
5
+
6
+ # Delegating to Antigravity CLI
7
+
8
+ Use the agy-bridge MCP tools instead of doing heavy work yourself:
9
+
10
+ | Situation | Tool |
11
+ | -------------------------------------------- | ----------------------------------------- |
12
+ | File >200 lines, logs, dumps, generated code | `analyze_files` |
13
+ | >3 files in one task | `analyze_files` |
14
+ | Git history / repo-wide search | `deep_search` |
15
+ | Docs or external knowledge | `web_lookup` |
16
+ | Plan critique, code review | `adversarial_review` |
17
+ | Follow-up on a prior delegation | `follow_up` (use the returned session id) |
18
+ | Anything else heavy | `delegate` |
19
+
20
+ Every response ends with `[agy-bridge] model: … | session: …`. Reuse the
21
+ session id with `follow_up` for iterative work — the context stays on agy's
22
+ side. Pass `cwd` as the project root so agy can read files and run git.
package/dist/index.js ADDED
@@ -0,0 +1,617 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+
9
+ // src/config.ts
10
+ function positiveInt(raw, fallback) {
11
+ const n = Number(raw);
12
+ return Number.isInteger(n) && n > 0 ? n : fallback;
13
+ }
14
+ function loadPerToolTimeouts(env) {
15
+ const out = {};
16
+ for (const [key, raw] of Object.entries(env)) {
17
+ if (!key.startsWith("AGY_TIMEOUT_")) continue;
18
+ const tool = key.slice("AGY_TIMEOUT_".length).toLowerCase();
19
+ if (!tool) continue;
20
+ const n = Number(raw);
21
+ if (Number.isInteger(n) && n > 0) out[tool] = n;
22
+ }
23
+ return out;
24
+ }
25
+ function loadConfig(env = process.env) {
26
+ return {
27
+ agyPath: env.AGY_PATH || "agy",
28
+ timeoutSec: positiveInt(env.AGY_TIMEOUT, 1200),
29
+ timeoutExplicit: positiveInt(env.AGY_TIMEOUT, 0) > 0,
30
+ perToolTimeouts: loadPerToolTimeouts(env),
31
+ maxOutputChars: positiveInt(env.AGY_MAX_OUTPUT_CHARS, 5e4),
32
+ defaultModel: env.AGY_DEFAULT_MODEL || void 0,
33
+ skipPermissions: env.AGY_SKIP_PERMISSIONS !== "false",
34
+ sandbox: env.AGY_SANDBOX === "true",
35
+ onFailure: env.AGY_ON_FAILURE === "strict" ? "strict" : "fallback"
36
+ };
37
+ }
38
+
39
+ // src/models.ts
40
+ var MODEL_NAME_RE = /^(Gemini|Claude|GPT)[- ]/i;
41
+ function looksLikeModelName(line) {
42
+ return MODEL_NAME_RE.test(line);
43
+ }
44
+ function parseModels(output) {
45
+ const models = /* @__PURE__ */ new Set();
46
+ for (const raw of output.split("\n")) {
47
+ const line = raw.trim().replace(/\s*\(current\)$/, "");
48
+ if (line.length === 0) continue;
49
+ if (line.includes(" ")) {
50
+ const [rawId, rawDisplay] = line.split(" ", 2);
51
+ const cliId = rawId?.trim() ?? "";
52
+ const displayName = rawDisplay?.trim() ?? "";
53
+ if (cliId && looksLikeModelName(cliId)) models.add(cliId);
54
+ if (displayName && looksLikeModelName(displayName)) models.add(displayName);
55
+ } else if (looksLikeModelName(line)) {
56
+ models.add(line);
57
+ }
58
+ }
59
+ return [...models];
60
+ }
61
+ var ModelRegistry = class {
62
+ constructor(fetchListing) {
63
+ this.fetchListing = fetchListing;
64
+ }
65
+ fetchListing;
66
+ listing = null;
67
+ pending = null;
68
+ async available() {
69
+ if (this.listing) return this.listing;
70
+ this.pending ??= this.fetchListing().then(parseModels).catch(() => null);
71
+ const result = await this.pending;
72
+ if (result) this.listing = result;
73
+ else this.pending = null;
74
+ return result;
75
+ }
76
+ async resolve(opts) {
77
+ const r = await this.resolveChain(opts);
78
+ return { model: r.models[0], note: r.note };
79
+ }
80
+ /**
81
+ * Returns every viable model in preference order so callers can fail over
82
+ * (e.g. on quota exhaustion). `[undefined]` means "let agy pick".
83
+ */
84
+ async resolveChain(opts) {
85
+ const available = await this.available();
86
+ if (opts.explicit) {
87
+ if (available === null) {
88
+ return {
89
+ models: [opts.explicit],
90
+ note: "could not list agy models; passing model through unvalidated"
91
+ };
92
+ }
93
+ if (available.includes(opts.explicit)) return { models: [opts.explicit] };
94
+ throw new Error(
95
+ `Model "${opts.explicit}" is not available. Available models:
96
+ ${available.join("\n")}`
97
+ );
98
+ }
99
+ if (available === null) {
100
+ return {
101
+ models: [void 0],
102
+ note: "could not list agy models; using agy's own default model"
103
+ };
104
+ }
105
+ const models = opts.chain.filter((m) => available.includes(m));
106
+ if (opts.defaultModel && available.includes(opts.defaultModel) && !models.includes(opts.defaultModel)) {
107
+ models.push(opts.defaultModel);
108
+ }
109
+ if (models.length === 0) {
110
+ return {
111
+ models: [void 0],
112
+ note: "no preferred model available; using agy's own default model"
113
+ };
114
+ }
115
+ return { models };
116
+ }
117
+ };
118
+
119
+ // src/runner.ts
120
+ import { execFile, spawn } from "child_process";
121
+ import { promisify } from "util";
122
+ import { readFile, rm } from "fs/promises";
123
+ import { homedir, tmpdir } from "os";
124
+ import { randomUUID } from "crypto";
125
+ import path from "path";
126
+
127
+ // src/quota.ts
128
+ var DEFAULT_COOLDOWN_SEC = 15 * 60;
129
+ var QUOTA_RE = /RESOURCE_EXHAUSTED \(code 429\)/;
130
+ var RESET_RE = /Resets in ((?:\d+h)?(?:\d+m)?(?:\d+s)?)\b/;
131
+ function parseResetDuration(text) {
132
+ const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(text);
133
+ if (!m || !m[1] && !m[2] && !m[3]) return void 0;
134
+ return Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0);
135
+ }
136
+ function formatDuration(totalSeconds) {
137
+ const s = Math.max(0, Math.floor(totalSeconds));
138
+ const h = Math.floor(s / 3600);
139
+ const m = Math.floor(s % 3600 / 60);
140
+ const sec = s % 60;
141
+ let out = "";
142
+ if (h) out += `${h}h`;
143
+ if (m) out += `${m}m`;
144
+ if (sec || !out) out += `${sec}s`;
145
+ return out;
146
+ }
147
+ function detectQuota(log) {
148
+ if (!QUOTA_RE.test(log)) return null;
149
+ const reset = RESET_RE.exec(log)?.[1];
150
+ const resetSeconds = reset ? parseResetDuration(reset) : void 0;
151
+ return { resetText: resetSeconds !== void 0 ? reset : void 0, resetSeconds };
152
+ }
153
+ var QuotaError = class extends Error {
154
+ constructor(model, info) {
155
+ const who = model ?? "agy's default model";
156
+ const when = info.resetText ? ` Quota resets in ${info.resetText}.` : "";
157
+ super(`Quota exhausted for ${who} (RESOURCE_EXHAUSTED 429).${when}`);
158
+ this.model = model;
159
+ this.name = "QuotaError";
160
+ this.resetSeconds = info.resetSeconds;
161
+ this.resetText = info.resetText;
162
+ }
163
+ model;
164
+ resetSeconds;
165
+ resetText;
166
+ };
167
+ var CooldownRegistry = class {
168
+ constructor(now = Date.now) {
169
+ this.now = now;
170
+ }
171
+ now;
172
+ until = /* @__PURE__ */ new Map();
173
+ set(model, resetSeconds) {
174
+ this.until.set(model, this.now() + (resetSeconds ?? DEFAULT_COOLDOWN_SEC) * 1e3);
175
+ }
176
+ cooling(model) {
177
+ const t = this.until.get(model);
178
+ return t !== void 0 && t > this.now();
179
+ }
180
+ describe(model) {
181
+ const t = this.until.get(model);
182
+ return formatDuration(t === void 0 ? 0 : (t - this.now()) / 1e3);
183
+ }
184
+ };
185
+
186
+ // src/runner.ts
187
+ var execFileAsync = promisify(execFile);
188
+ var SESSIONS_FILE = path.join(
189
+ homedir(),
190
+ ".gemini",
191
+ "antigravity-cli",
192
+ "cache",
193
+ "last_conversations.json"
194
+ );
195
+ var execWithClosedStdin = (file, args, options) => {
196
+ const promise = execFileAsync(file, args, options);
197
+ promise.child.stdin?.end();
198
+ return promise;
199
+ };
200
+ var MAX_STDOUT_CHARS = 64 * 1024 * 1024;
201
+ var MAX_STDERR_CHARS = 1024 * 1024;
202
+ function spawnDetached(file, args, cwd) {
203
+ const child = spawn(file, args, { cwd, detached: true });
204
+ child.stdin?.end();
205
+ let out = "";
206
+ let err = "";
207
+ child.stdout?.on("data", (d) => {
208
+ if (out.length < MAX_STDOUT_CHARS) out += d.toString();
209
+ });
210
+ child.stderr?.on("data", (d) => {
211
+ if (err.length < MAX_STDERR_CHARS) err += d.toString();
212
+ });
213
+ let exited = false;
214
+ const done = new Promise((resolve) => {
215
+ let exitCode = null;
216
+ let closeFallback;
217
+ child.on("exit", (code) => {
218
+ exited = true;
219
+ exitCode = code;
220
+ closeFallback = setTimeout(() => resolve({ code: exitCode }), 2e3);
221
+ closeFallback.unref();
222
+ });
223
+ child.on("close", (code) => {
224
+ exited = true;
225
+ if (closeFallback) clearTimeout(closeFallback);
226
+ resolve({ code: code ?? exitCode });
227
+ });
228
+ child.on("error", (e) => {
229
+ exited = true;
230
+ resolve({ code: null, error: e });
231
+ });
232
+ });
233
+ return {
234
+ stdout: () => out,
235
+ stderr: () => err,
236
+ wait: () => done,
237
+ kill: (signal) => {
238
+ if (exited || child.pid === void 0) return;
239
+ try {
240
+ process.kill(-child.pid, signal);
241
+ } catch {
242
+ try {
243
+ child.kill(signal);
244
+ } catch {
245
+ }
246
+ }
247
+ }
248
+ };
249
+ }
250
+ var defaultDeps = {
251
+ spawnChild: spawnDetached,
252
+ readLog: async (logPath) => {
253
+ try {
254
+ return await readFile(logPath, "utf8");
255
+ } catch {
256
+ return "";
257
+ }
258
+ },
259
+ removeLog: (logPath) => rm(logPath, { force: true }),
260
+ readSessionsFile: () => readFile(SESSIONS_FILE, "utf8"),
261
+ makeLogPath: () => path.join(tmpdir(), `agy-bridge-${process.pid}-${randomUUID()}.log`)
262
+ };
263
+ function buildArgs(req, cfg, logPath) {
264
+ const timeoutSec = req.timeoutSec ?? cfg.timeoutSec;
265
+ const args = [];
266
+ if (cfg.skipPermissions) args.push("--dangerously-skip-permissions");
267
+ if (cfg.sandbox) args.push("--sandbox");
268
+ args.push("--add-dir", req.cwd);
269
+ args.push("--log-file", logPath);
270
+ if (req.conversationId) args.push("--conversation", req.conversationId);
271
+ if (req.model) args.push("--model", req.model);
272
+ args.push("--print-timeout", `${timeoutSec}s`, "-p", req.prompt);
273
+ return args;
274
+ }
275
+ function truncate(text, max) {
276
+ if (text.length <= max) return { text, truncated: false };
277
+ return {
278
+ text: `${text.slice(0, max)}
279
+
280
+ [agy-bridge: output truncated at ${max} chars; full length was ${text.length} chars. Ask a narrower question or raise AGY_MAX_OUTPUT_CHARS.]`,
281
+ truncated: true
282
+ };
283
+ }
284
+ async function runAgy(req, cfg, deps = defaultDeps) {
285
+ const timeoutSec = req.timeoutSec ?? cfg.timeoutSec;
286
+ const pollMs = deps.pollMs ?? 1e3;
287
+ const graceMs = deps.graceMs ?? 15e3;
288
+ const killGraceMs = deps.killGraceMs ?? 5e3;
289
+ const logPath = deps.makeLogPath();
290
+ const stdout = await new Promise((resolve, reject) => {
291
+ const child = deps.spawnChild(cfg.agyPath, buildArgs(req, cfg, logPath), req.cwd);
292
+ let settled = false;
293
+ let polling = false;
294
+ const timers = [];
295
+ const killChild = () => {
296
+ child.kill("SIGTERM");
297
+ const escalate = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
298
+ escalate.unref?.();
299
+ };
300
+ const finish = (fn) => {
301
+ if (settled) return;
302
+ settled = true;
303
+ clearInterval(poller);
304
+ for (const t of timers) clearTimeout(t);
305
+ req.signal?.removeEventListener("abort", onAbort);
306
+ fn();
307
+ };
308
+ const poller = setInterval(async () => {
309
+ if (polling || settled) return;
310
+ polling = true;
311
+ try {
312
+ const log = await deps.readLog(logPath);
313
+ if (settled) return;
314
+ const quota = detectQuota(log);
315
+ if (quota) {
316
+ killChild();
317
+ finish(() => reject(new QuotaError(req.model, quota)));
318
+ }
319
+ } finally {
320
+ polling = false;
321
+ }
322
+ }, pollMs);
323
+ timers.push(
324
+ setTimeout(
325
+ () => {
326
+ killChild();
327
+ finish(
328
+ () => reject(new Error(`agy timed out after ${timeoutSec}s (AGY_TIMEOUT to adjust).`))
329
+ );
330
+ },
331
+ timeoutSec * 1e3 + graceMs
332
+ )
333
+ );
334
+ const onAbort = () => {
335
+ killChild();
336
+ finish(() => reject(new Error("agy run cancelled by client.")));
337
+ };
338
+ if (req.signal?.aborted) {
339
+ onAbort();
340
+ return;
341
+ }
342
+ req.signal?.addEventListener("abort", onAbort, { once: true });
343
+ void child.wait().then(async ({ code, error }) => {
344
+ if (settled) return;
345
+ if (error?.code === "ENOENT") {
346
+ finish(
347
+ () => reject(
348
+ new Error(
349
+ `agy CLI not found at "${cfg.agyPath}". Install the Antigravity CLI (https://antigravity.google/docs/cli-getting-started) or set AGY_PATH.`
350
+ )
351
+ )
352
+ );
353
+ return;
354
+ }
355
+ if (error) {
356
+ finish(() => reject(new Error(`agy failed: ${error.message}`)));
357
+ return;
358
+ }
359
+ const out = child.stdout().trim();
360
+ if (code !== 0) {
361
+ const stderr = child.stderr().trim();
362
+ finish(
363
+ () => reject(new Error(stderr ? `agy failed: ${stderr}` : `agy exited with code ${code}.`))
364
+ );
365
+ return;
366
+ }
367
+ if (!out) {
368
+ const quota = detectQuota(await deps.readLog(logPath));
369
+ finish(
370
+ () => reject(
371
+ quota ? new QuotaError(req.model, quota) : new Error(
372
+ "agy returned empty output (likely hit its print-timeout without a response)."
373
+ )
374
+ )
375
+ );
376
+ return;
377
+ }
378
+ finish(() => resolve(out));
379
+ });
380
+ }).finally(() => void deps.removeLog(logPath).catch(() => {
381
+ }));
382
+ const { text, truncated } = truncate(stdout, cfg.maxOutputChars);
383
+ let sessionId;
384
+ try {
385
+ const map = JSON.parse(await deps.readSessionsFile());
386
+ sessionId = map[path.resolve(req.cwd)];
387
+ } catch {
388
+ sessionId = void 0;
389
+ }
390
+ return { output: text, truncated, sessionId };
391
+ }
392
+
393
+ // src/tools.ts
394
+ import path2 from "path";
395
+ import { z } from "zod";
396
+ var OUTPUT_RULES = "Answer directly with no preamble or closing remarks. Be thorough but concise. Cite file:line for every code-level finding.";
397
+ function resolveFiles(files, cwd) {
398
+ return files.map((f) => path2.isAbsolute(f) ? f : path2.resolve(cwd, f));
399
+ }
400
+ var commonShape = {
401
+ cwd: z.string().optional().describe(
402
+ "Absolute path to the working directory / project root. Defaults to the server's cwd."
403
+ ),
404
+ model: z.string().optional().describe(
405
+ 'Override the model (CLI ID or display name from `agy models`, e.g. "gemini-3.1-pro-high" or "Gemini 3.1 Pro (High)"). Normally omit \u2014 the tool routes automatically.'
406
+ )
407
+ };
408
+ var TOOLS = [
409
+ {
410
+ name: "analyze_files",
411
+ description: "Delegate file analysis to the Antigravity CLI (Gemini) instead of reading files yourself. USE THIS whenever a file is large (>200 lines) or the task spans more than 3 files: logs, database dumps, generated code, cross-file reviews, comparisons. The files never enter your context \u2014 only the answer does.",
412
+ schema: {
413
+ files: z.array(z.string()).min(1).describe("File paths to analyze (relative to cwd or absolute)."),
414
+ question: z.string().describe("What you want to know about these files."),
415
+ ...commonShape
416
+ },
417
+ chain: ["Gemini 3.5 Flash (High)", "Gemini 3.1 Pro (Low)"],
418
+ timeoutSec: 300,
419
+ buildPrompt(args, cwd) {
420
+ const files = resolveFiles(args.files, cwd);
421
+ return `Read and analyze these files:
422
+ ${files.map((f) => `- ${f}`).join("\n")}
423
+
424
+ Question: ${args.question}
425
+
426
+ ${OUTPUT_RULES}`;
427
+ }
428
+ },
429
+ {
430
+ name: "deep_search",
431
+ description: "Delegate codebase archaeology to the Antigravity CLI: git log/diff/blame spelunking, wide greps across a repo, 'when/why did X change', 'where is Y used'. USE THIS instead of running many search commands yourself \u2014 it saves your context.",
432
+ schema: {
433
+ query: z.string().describe("What to find, e.g. 'when was the auth middleware refactored and why'."),
434
+ ...commonShape
435
+ },
436
+ chain: ["Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (High)"],
437
+ timeoutSec: 180,
438
+ buildPrompt(args) {
439
+ return `Search this repository to answer the following. Use git log, git diff, git blame, and grep as needed.
440
+
441
+ Query: ${args.query}
442
+
443
+ Report findings with commit hashes where relevant. ${OUTPUT_RULES}`;
444
+ }
445
+ },
446
+ {
447
+ name: "web_lookup",
448
+ description: "Delegate a web/documentation lookup to the Antigravity CLI (Gemini with web access): library docs, API references, error messages, current versions, external knowledge. USE THIS when you need information you don't have or that may be newer than your training data.",
449
+ schema: {
450
+ query: z.string().describe("What to look up on the web."),
451
+ ...commonShape
452
+ },
453
+ chain: ["Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (High)"],
454
+ timeoutSec: 120,
455
+ buildPrompt(args) {
456
+ return `Look up on the web: ${args.query}
457
+
458
+ Include source URLs for key claims. ${OUTPUT_RULES}`;
459
+ }
460
+ },
461
+ {
462
+ name: "adversarial_review",
463
+ description: "Get an adversarial second opinion from a different model family (Gemini Pro). ALWAYS use this for plan critiques, design reviews, and pre-merge code review: it hunts for flaws, edge cases, security issues, and unstated assumptions you may have missed.",
464
+ schema: {
465
+ content: z.string().optional().describe("Inline content to review (plan, diff, code snippet)."),
466
+ files: z.array(z.string()).optional().describe("File paths to review instead of inline content."),
467
+ focus: z.string().optional().describe("Optional focus area, e.g. 'security', 'concurrency'."),
468
+ ...commonShape
469
+ },
470
+ chain: ["Gemini 3.1 Pro (High)", "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (High)"],
471
+ timeoutSec: 300,
472
+ buildPrompt(args, cwd) {
473
+ const files = args.files;
474
+ const content = args.content;
475
+ if (!content && !files?.length) {
476
+ throw new Error("adversarial_review requires either `content` or `files`.");
477
+ }
478
+ const subject = content ? `Review the following:
479
+
480
+ ${content}` : `Read and review these files:
481
+ ${resolveFiles(files, cwd).map((f) => `- ${f}`).join("\n")}`;
482
+ const focus = args.focus ? `
483
+ Focus especially on: ${args.focus}.` : "";
484
+ return `You are an adversarial reviewer. Find real flaws: bugs, edge cases, security issues, performance traps, unstated assumptions, and simpler alternatives.${focus}
485
+
486
+ ${subject}
487
+
488
+ Rank findings by severity (critical/major/minor) and justify each. Do not pad with praise or restate the input. ${OUTPUT_RULES}`;
489
+ }
490
+ },
491
+ {
492
+ name: "follow_up",
493
+ description: "Continue a previous Antigravity session by session_id (returned by every other tool). USE THIS for follow-up questions about a prior delegation \u2014 the full prior context is already on agy's side, so you don't resend anything.",
494
+ schema: {
495
+ session_id: z.string().describe("The session id returned by a previous agy-bridge call."),
496
+ question: z.string().describe("The follow-up question."),
497
+ ...commonShape
498
+ },
499
+ chain: [],
500
+ timeoutSec: 300,
501
+ buildPrompt(args) {
502
+ return args.question;
503
+ }
504
+ },
505
+ {
506
+ name: "delegate",
507
+ description: "Raw delegation to the Antigravity CLI for heavy tasks that don't fit the other tools. agy has full tool access (shell, file reads, web) in the given cwd.",
508
+ schema: {
509
+ prompt: z.string().describe("The complete task prompt for agy."),
510
+ ...commonShape
511
+ },
512
+ chain: ["Gemini 3.5 Flash (High)"],
513
+ timeoutSec: 600,
514
+ buildPrompt(args) {
515
+ return args.prompt;
516
+ }
517
+ }
518
+ ];
519
+
520
+ // src/server.ts
521
+ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns = new CooldownRegistry()) {
522
+ return async (args, extra) => {
523
+ try {
524
+ const cwd = args.cwd ?? process.cwd();
525
+ const conversationId = args.session_id;
526
+ const prompt = tool.buildPrompt(args, cwd);
527
+ const timeoutSec = cfg.perToolTimeouts[tool.name] ?? (cfg.timeoutExplicit ? cfg.timeoutSec : tool.timeoutSec);
528
+ const resolution = conversationId ? { models: [void 0], note: void 0 } : await registry.resolveChain({
529
+ explicit: args.model,
530
+ chain: tool.chain,
531
+ defaultModel: cfg.defaultModel
532
+ });
533
+ const attempts = [];
534
+ let result;
535
+ let used;
536
+ for (const model of resolution.models) {
537
+ if (model && cooldowns.cooling(model)) {
538
+ attempts.push(`${model}: quota cooldown, ${cooldowns.describe(model)} left`);
539
+ continue;
540
+ }
541
+ try {
542
+ result = await runAgy(
543
+ { prompt, cwd, model, conversationId, timeoutSec, signal: extra?.signal },
544
+ cfg,
545
+ deps
546
+ );
547
+ used = model;
548
+ break;
549
+ } catch (err) {
550
+ if (err instanceof QuotaError && model) {
551
+ cooldowns.set(model, err.resetSeconds);
552
+ attempts.push(
553
+ `${model}: quota exhausted${err.resetText ? ` (resets in ${err.resetText})` : ""}`
554
+ );
555
+ continue;
556
+ }
557
+ throw err;
558
+ }
559
+ }
560
+ if (!result) {
561
+ throw new Error(
562
+ `All candidate models are quota-exhausted or cooling down:
563
+ ${attempts.map((a) => `- ${a}`).join("\n")}
564
+ Retry after the quota resets, or pass an explicit \`model\`.`
565
+ );
566
+ }
567
+ const meta = [`model: ${used ?? "agy default"}`];
568
+ if (resolution.note) meta.push(`note: ${resolution.note}`);
569
+ if (attempts.length) meta.push(`failover: ${attempts.join("; ")}`);
570
+ if (result.sessionId) meta.push(`session: ${result.sessionId} (use follow_up to continue)`);
571
+ return {
572
+ content: [
573
+ { type: "text", text: `${result.output}
574
+
575
+ ---
576
+ [agy-bridge] ${meta.join(" | ")}` }
577
+ ]
578
+ };
579
+ } catch (err) {
580
+ let text = err.message;
581
+ if (cfg.onFailure === "strict") {
582
+ text += "\n\n[agy-bridge strict mode] Delegation failed. Do NOT perform this work yourself in the main context \u2014 report the failure to the user and let them decide how to proceed.";
583
+ }
584
+ return {
585
+ content: [{ type: "text", text }],
586
+ isError: true
587
+ };
588
+ }
589
+ };
590
+ }
591
+ function createServer() {
592
+ const cfg = loadConfig();
593
+ const registry = new ModelRegistry(async () => {
594
+ const { stdout } = await execWithClosedStdin(cfg.agyPath, ["models"], {
595
+ cwd: process.cwd(),
596
+ timeout: 3e4,
597
+ maxBuffer: 1024 * 1024
598
+ });
599
+ return stdout;
600
+ });
601
+ const cooldowns = new CooldownRegistry();
602
+ const server = new McpServer({ name: "agy-bridge", version: "0.5.0" });
603
+ for (const tool of TOOLS) {
604
+ server.registerTool(
605
+ tool.name,
606
+ { description: tool.description, inputSchema: tool.schema },
607
+ createToolHandler(tool, cfg, registry, defaultDeps, cooldowns)
608
+ );
609
+ }
610
+ return server;
611
+ }
612
+
613
+ // src/index.ts
614
+ createServer().connect(new StdioServerTransport()).catch((err) => {
615
+ console.error("agy-bridge failed to start:", err);
616
+ process.exit(1);
617
+ });
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@bto-labs/agy-bridge",
3
+ "version": "0.5.0",
4
+ "description": "MCP bridge that lets Claude Code delegate heavy tasks to the Antigravity CLI (agy) — purpose-built tools, model routing with fallback, and multi-turn session continuity.",
5
+ "mcpName": "io.github.bto-labs.agy-bridge",
6
+ "type": "module",
7
+ "bin": {
8
+ "agy-bridge": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "CLAUDE.md",
13
+ "SKILL.md",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc --noEmit",
27
+ "format": "prettier --write .",
28
+ "format:check": "prettier --check .",
29
+ "prepare": "husky"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "claude-code",
34
+ "antigravity",
35
+ "agy",
36
+ "gemini",
37
+ "delegation"
38
+ ],
39
+ "author": "Shahzaib (https://github.com/sshahzaiib)",
40
+ "contributors": [
41
+ "BTO Labs (https://github.com/bto-labs)"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/bto-labs/agy-bridge.git"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/bto-labs/agy-bridge/issues"
49
+ },
50
+ "homepage": "https://github.com/bto-labs/agy-bridge#readme",
51
+ "license": "MIT",
52
+ "dependencies": {
53
+ "@modelcontextprotocol/sdk": "^1.12.0",
54
+ "zod": "^3.24.0"
55
+ },
56
+ "devDependencies": {
57
+ "@commitlint/cli": "^21.0.2",
58
+ "@commitlint/config-conventional": "^21.0.2",
59
+ "@types/node": "^20.0.0",
60
+ "husky": "^9.1.7",
61
+ "lint-staged": "^17.0.8",
62
+ "prettier": "^3.8.4",
63
+ "tsup": "^8.0.0",
64
+ "typescript": "^5.5.0",
65
+ "vitest": "^3.0.0"
66
+ },
67
+ "lint-staged": {
68
+ "*.{ts,js,json,md,yml,yaml}": "prettier --write"
69
+ }
70
+ }