@bto-labs/agy-bridge 0.5.0 → 0.6.1

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 +64 -22
  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(
@@ -525,8 +566,9 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
525
566
  const conversationId = args.session_id;
526
567
  const prompt = tool.buildPrompt(args, cwd);
527
568
  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,
569
+ const explicitModel = args.model;
570
+ const resolution = tool.name === "follow_up" && !explicitModel ? { models: [void 0], note: void 0 } : await registry.resolveChain({
571
+ explicit: explicitModel,
530
572
  chain: tool.chain,
531
573
  defaultModel: cfg.defaultModel
532
574
  });
@@ -534,8 +576,8 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
534
576
  let result;
535
577
  let used;
536
578
  for (const model of resolution.models) {
537
- if (model && cooldowns.cooling(model)) {
538
- attempts.push(`${model}: quota cooldown, ${cooldowns.describe(model)} left`);
579
+ if (model && await cooldowns.cooling(model)) {
580
+ attempts.push(`${model}: quota cooldown, ${await cooldowns.describe(model)} left`);
539
581
  continue;
540
582
  }
541
583
  try {
@@ -548,7 +590,7 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
548
590
  break;
549
591
  } catch (err) {
550
592
  if (err instanceof QuotaError && model) {
551
- cooldowns.set(model, err.resetSeconds);
593
+ await cooldowns.set(model, err.resetSeconds);
552
594
  attempts.push(
553
595
  `${model}: quota exhausted${err.resetText ? ` (resets in ${err.resetText})` : ""}`
554
596
  );
@@ -599,7 +641,7 @@ function createServer() {
599
641
  return stdout;
600
642
  });
601
643
  const cooldowns = new CooldownRegistry();
602
- const server = new McpServer({ name: "agy-bridge", version: "0.5.0" });
644
+ const server = new McpServer({ name: "agy-bridge", version: "0.6.1" });
603
645
  for (const tool of TOOLS) {
604
646
  server.registerTool(
605
647
  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.1",
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",