@bto-labs/agy-bridge 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/dist/index.js +61 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -89,10 +89,18 @@ On first use the bridge runs `agy models` (cached for the process lifetime) and
89
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
90
 
91
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,
92
+ 2. parses the reset time ("Resets in 4h24m") into a cooldown record,
93
93
  3. retries the same prompt on the next model in the tool's chain,
94
94
  4. skips cooled-down models on all subsequent calls until their quota resets.
95
95
 
96
+ Cooldowns are shared **across every concurrent agy-bridge process** on a host, not just
97
+ within one — the bridge runs as a separate process per MCP client/session, and several can
98
+ share the same underlying account's quota. Each model's cooldown deadline is recorded as the
99
+ mtime of a small marker file under `~/.cache/agy-bridge/cooldowns/`, so one process's quota
100
+ hit immediately protects every other concurrent process from retrying the same
101
+ already-exhausted model. A cooldown is never shortened by a later, shorter-duration write
102
+ (e.g. a per-minute rate limit racing a per-day quota on the same model) — only extended.
103
+
96
104
  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
105
 
98
106
  ### Timeouts and cancellation
package/dist/index.js CHANGED
@@ -120,11 +120,14 @@ ${available.join("\n")}`
120
120
  import { execFile, spawn } from "child_process";
121
121
  import { promisify } from "util";
122
122
  import { readFile, rm } from "fs/promises";
123
- import { homedir, tmpdir } from "os";
123
+ import { homedir as homedir2, tmpdir } from "os";
124
124
  import { randomUUID } from "crypto";
125
- import path from "path";
125
+ import path2 from "path";
126
126
 
127
127
  // src/quota.ts
128
+ import { mkdir, open, stat, utimes } from "fs/promises";
129
+ import { homedir } from "os";
130
+ import path from "path";
128
131
  var DEFAULT_COOLDOWN_SEC = 15 * 60;
129
132
  var QUOTA_RE = /RESOURCE_EXHAUSTED \(code 429\)/;
130
133
  var RESET_RE = /Resets in ((?:\d+h)?(?:\d+m)?(?:\d+s)?)\b/;
@@ -164,29 +167,67 @@ var QuotaError = class extends Error {
164
167
  resetSeconds;
165
168
  resetText;
166
169
  };
170
+ var DEFAULT_COOLDOWN_DIR = path.join(homedir(), ".cache", "agy-bridge", "cooldowns");
171
+ function slugifyModel(model) {
172
+ return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
173
+ }
174
+ async function ensureDir(dir) {
175
+ try {
176
+ await mkdir(dir, { recursive: true });
177
+ } catch (err) {
178
+ if (err.code !== "EEXIST") throw err;
179
+ }
180
+ }
181
+ function defaultCooldownDeps(dir = DEFAULT_COOLDOWN_DIR) {
182
+ const filePath = (model) => path.join(dir, `${slugifyModel(model)}.cooldown`);
183
+ return {
184
+ async readDeadline(model) {
185
+ try {
186
+ return (await stat(filePath(model))).mtimeMs;
187
+ } catch {
188
+ return void 0;
189
+ }
190
+ },
191
+ async writeDeadline(model, untilMs) {
192
+ try {
193
+ await ensureDir(dir);
194
+ const file = filePath(model);
195
+ const handle = await open(file, "a");
196
+ await handle.close();
197
+ const when = new Date(untilMs);
198
+ await utimes(file, when, when);
199
+ } catch {
200
+ }
201
+ }
202
+ };
203
+ }
167
204
  var CooldownRegistry = class {
168
- constructor(now = Date.now) {
205
+ constructor(now = Date.now, deps = defaultCooldownDeps()) {
169
206
  this.now = now;
207
+ this.deps = deps;
170
208
  }
171
209
  now;
172
- until = /* @__PURE__ */ new Map();
173
- set(model, resetSeconds) {
174
- this.until.set(model, this.now() + (resetSeconds ?? DEFAULT_COOLDOWN_SEC) * 1e3);
210
+ deps;
211
+ async set(model, resetSeconds) {
212
+ const until = this.now() + (resetSeconds ?? DEFAULT_COOLDOWN_SEC) * 1e3;
213
+ const existing = await this.deps.readDeadline(model);
214
+ if (existing !== void 0 && existing >= until) return;
215
+ await this.deps.writeDeadline(model, until);
175
216
  }
176
- cooling(model) {
177
- const t = this.until.get(model);
217
+ async cooling(model) {
218
+ const t = await this.deps.readDeadline(model);
178
219
  return t !== void 0 && t > this.now();
179
220
  }
180
- describe(model) {
181
- const t = this.until.get(model);
221
+ async describe(model) {
222
+ const t = await this.deps.readDeadline(model);
182
223
  return formatDuration(t === void 0 ? 0 : (t - this.now()) / 1e3);
183
224
  }
184
225
  };
185
226
 
186
227
  // src/runner.ts
187
228
  var execFileAsync = promisify(execFile);
188
- var SESSIONS_FILE = path.join(
189
- homedir(),
229
+ var SESSIONS_FILE = path2.join(
230
+ homedir2(),
190
231
  ".gemini",
191
232
  "antigravity-cli",
192
233
  "cache",
@@ -258,7 +299,7 @@ var defaultDeps = {
258
299
  },
259
300
  removeLog: (logPath) => rm(logPath, { force: true }),
260
301
  readSessionsFile: () => readFile(SESSIONS_FILE, "utf8"),
261
- makeLogPath: () => path.join(tmpdir(), `agy-bridge-${process.pid}-${randomUUID()}.log`)
302
+ makeLogPath: () => path2.join(tmpdir(), `agy-bridge-${process.pid}-${randomUUID()}.log`)
262
303
  };
263
304
  function buildArgs(req, cfg, logPath) {
264
305
  const timeoutSec = req.timeoutSec ?? cfg.timeoutSec;
@@ -383,7 +424,7 @@ async function runAgy(req, cfg, deps = defaultDeps) {
383
424
  let sessionId;
384
425
  try {
385
426
  const map = JSON.parse(await deps.readSessionsFile());
386
- sessionId = map[path.resolve(req.cwd)];
427
+ sessionId = map[path2.resolve(req.cwd)];
387
428
  } catch {
388
429
  sessionId = void 0;
389
430
  }
@@ -391,11 +432,11 @@ async function runAgy(req, cfg, deps = defaultDeps) {
391
432
  }
392
433
 
393
434
  // src/tools.ts
394
- import path2 from "path";
435
+ import path3 from "path";
395
436
  import { z } from "zod";
396
437
  var OUTPUT_RULES = "Answer directly with no preamble or closing remarks. Be thorough but concise. Cite file:line for every code-level finding.";
397
438
  function resolveFiles(files, cwd) {
398
- return files.map((f) => path2.isAbsolute(f) ? f : path2.resolve(cwd, f));
439
+ return files.map((f) => path3.isAbsolute(f) ? f : path3.resolve(cwd, f));
399
440
  }
400
441
  var commonShape = {
401
442
  cwd: z.string().optional().describe(
@@ -534,8 +575,8 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
534
575
  let result;
535
576
  let used;
536
577
  for (const model of resolution.models) {
537
- if (model && cooldowns.cooling(model)) {
538
- attempts.push(`${model}: quota cooldown, ${cooldowns.describe(model)} left`);
578
+ if (model && await cooldowns.cooling(model)) {
579
+ attempts.push(`${model}: quota cooldown, ${await cooldowns.describe(model)} left`);
539
580
  continue;
540
581
  }
541
582
  try {
@@ -548,7 +589,7 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
548
589
  break;
549
590
  } catch (err) {
550
591
  if (err instanceof QuotaError && model) {
551
- cooldowns.set(model, err.resetSeconds);
592
+ await cooldowns.set(model, err.resetSeconds);
552
593
  attempts.push(
553
594
  `${model}: quota exhausted${err.resetText ? ` (resets in ${err.resetText})` : ""}`
554
595
  );
@@ -599,7 +640,7 @@ function createServer() {
599
640
  return stdout;
600
641
  });
601
642
  const cooldowns = new CooldownRegistry();
602
- const server = new McpServer({ name: "agy-bridge", version: "0.5.0" });
643
+ const server = new McpServer({ name: "agy-bridge", version: "0.6.0" });
603
644
  for (const tool of TOOLS) {
604
645
  server.registerTool(
605
646
  tool.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bto-labs/agy-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
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
5
  "mcpName": "io.github.bto-labs.agy-bridge",
6
6
  "type": "module",