dsh-agy-link 0.4.67 → 0.4.74

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/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
- import path, { delimiter, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import path, { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { accessSync, chmodSync, constants, copyFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import * as dshLlm from "@deepseek-ai/dsh-llm";
7
7
  import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
8
8
  import { createHash, randomBytes, randomUUID } from "node:crypto";
9
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
- import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
10
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { createServer } from "node:http";
13
13
  import { promisify } from "node:util";
@@ -130,14 +130,19 @@ function defaultConfig() {
130
130
  mediaMaxBytes: 10485760,
131
131
  mediaMaxImages: 8,
132
132
  mcpBridge: false,
133
+ mcpConfigScope: "global",
134
+ mcpConfigPath: "",
133
135
  mcpToolAllowlist: "",
134
136
  rateLimitPerMinute: 0,
135
137
  autoFallbackModel: false,
136
138
  logRetentionDays: 7,
137
139
  disableTelemetry: true,
138
- quotaPollIntervalMs: 9e5,
140
+ quotaPollIntervalMs: 36e5,
141
+ quotaViaCli: true,
139
142
  proxyUrl: "",
140
- suppressAgyAutoUpdate: process.platform === "win32"
143
+ suppressAgyAutoUpdate: process.platform === "win32",
144
+ autoWarmup: false,
145
+ warmupModels: ["gemini-3.8-flash", "claude-sonnet-4-6"]
141
146
  };
142
147
  }
143
148
  /**
@@ -165,6 +170,60 @@ function looksLikeNetworkError(message) {
165
170
  return /dial tcp|fetch failed|econn(?:refused|reset|aborted)|enotfound|ehostunreach|enetunreach|socket hang up|getaddrinfo|bad gateway|service unavailable|unexpected eof|\beof\b|tls handshake timeout|i\/o timeout|network (?:error|is unreachable)|no route to host|connection reset|context deadline exceeded|temporary failure in name resolution|proxyconnect|token validation failed|validate (?:the )?token|silent auth failed/i.test(message);
166
171
  }
167
172
  /**
173
+ * Backoff schedule for CONSECUTIVE transport (NETWORK) failures.
174
+ *
175
+ * DSH's generic backoff starts at 2s and doubles, which spends the whole retry
176
+ * budget inside a few seconds. A real egress outage does not look like that:
177
+ * on 2026-10-21 the machine's proxy answered CONNECT 200 and then killed TLS
178
+ * for ~14 minutes, so every agy run failed its startup eligibility probe with
179
+ * `Post "…/loadCodeAssist": EOF` and the turn died after two retries in 4s
180
+ * while agy itself kept working whenever a gap appeared.
181
+ *
182
+ * These delays are stated per failure as `providerRetryAfterMs`, which the
183
+ * retry plugin honours verbatim — but only up to the policy's maxDelayMs (a
184
+ * longer request makes it drop the retry entirely), so the cap must stay above
185
+ * the last entry. The schedule is capped, never unbounded: a dead egress still
186
+ * ends the turn, just after minutes instead of seconds.
187
+ */
188
+ const NETWORK_RETRY_DELAYS_MS = [
189
+ 5e3,
190
+ 15e3,
191
+ 3e4
192
+ ];
193
+ /** Delay to request after `consecutiveFailures` transport failures in a row. */
194
+ function networkRetryDelayMs(consecutiveFailures) {
195
+ const index = Math.min(Math.max(Math.trunc(consecutiveFailures) - 1, 0), NETWORK_RETRY_DELAYS_MS.length - 1);
196
+ return NETWORK_RETRY_DELAYS_MS[index];
197
+ }
198
+ /**
199
+ * Whether every ENABLED account leaves through the same proxy.
200
+ *
201
+ * When it does, a transport failure is a property of the machine's egress, not
202
+ * of the account: rotating the pool repoints the session for nothing and burns
203
+ * the rotation budget that a real quota/eligibility hop needs. Accounts with
204
+ * their own `proxyUrl` (or one with an override next to the default) can fail
205
+ * independently, so rotation stays meaningful there.
206
+ *
207
+ * `globalProxy` is the proxy an account without an override uses (config
208
+ * `proxyUrl`, else the detected system proxy). It matters because a newly
209
+ * created pool account is seeded with a COPY of the system proxy while the
210
+ * primary/system account keeps an empty override — comparing raw overrides
211
+ * would call that "two different egresses" and rotate on every blip.
212
+ *
213
+ * Disabled accounts are ignored: they are never selected, so their proxy cannot
214
+ * make the reachable egress distinct.
215
+ */
216
+ function accountsShareSingleEgress(accounts, globalProxy) {
217
+ const fallback = (globalProxy ?? "").trim();
218
+ const urls = /* @__PURE__ */ new Set();
219
+ for (const account of accounts) {
220
+ if (account.enabled === false) continue;
221
+ urls.add((account.proxyUrl ?? "").trim() || fallback);
222
+ if (urls.size > 1) return false;
223
+ }
224
+ return true;
225
+ }
226
+ /**
168
227
  * Evidence from agy's OWN per-run log that a stored credential was present but
169
228
  * could not be VALIDATED over the network — i.e. jitter wearing an auth costume.
170
229
  *
@@ -383,6 +442,10 @@ function readOverrides(file = overridesPath()) {
383
442
  function asString(v) {
384
443
  return typeof v === "string" ? v : void 0;
385
444
  }
445
+ /** `mcpConfigScope` is a closed enum: unknown values fall back to the default. */
446
+ function asMcpConfigScope(v) {
447
+ return v === "global" || v === "workspace" || v === "both" ? v : void 0;
448
+ }
386
449
  function asBool(v) {
387
450
  if (typeof v === "boolean") return v;
388
451
  if (typeof v === "string") return v === "true" || v === "1";
@@ -451,14 +514,19 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
451
514
  mediaMaxBytes: asNum(get("mediaMaxBytes")) ?? base.mediaMaxBytes,
452
515
  mediaMaxImages: asNum(get("mediaMaxImages")) ?? base.mediaMaxImages,
453
516
  mcpBridge: asBool(get("mcpBridge")) ?? base.mcpBridge,
517
+ mcpConfigScope: asMcpConfigScope(get("mcpConfigScope")) ?? base.mcpConfigScope,
518
+ mcpConfigPath: asString(get("mcpConfigPath")) ?? base.mcpConfigPath,
454
519
  mcpToolAllowlist: asString(get("mcpToolAllowlist")) ?? base.mcpToolAllowlist,
455
520
  rateLimitPerMinute: asNum(get("rateLimitPerMinute")) ?? base.rateLimitPerMinute,
456
521
  autoFallbackModel: asBool(get("autoFallbackModel")) ?? base.autoFallbackModel,
457
522
  logRetentionDays: asNum(get("logRetentionDays")) ?? base.logRetentionDays,
458
523
  disableTelemetry: asBool(get("disableTelemetry")) ?? base.disableTelemetry,
459
524
  quotaPollIntervalMs: asNum(get("quotaPollIntervalMs")) ?? base.quotaPollIntervalMs,
525
+ quotaViaCli: asBool(get("quotaViaCli")) ?? base.quotaViaCli,
460
526
  proxyUrl: asString(get("proxyUrl")) ?? base.proxyUrl,
461
- suppressAgyAutoUpdate: asBool(get("suppressAgyAutoUpdate")) ?? base.suppressAgyAutoUpdate
527
+ suppressAgyAutoUpdate: asBool(get("suppressAgyAutoUpdate")) ?? base.suppressAgyAutoUpdate,
528
+ autoWarmup: asBool(get("autoWarmup")) ?? base.autoWarmup,
529
+ warmupModels: asStringArray(get("warmupModels")) ?? base.warmupModels
462
530
  };
463
531
  if (env.DSH_AGY_ENABLED !== void 0) cfg.enabled = asBool(env.DSH_AGY_ENABLED) ?? cfg.enabled;
464
532
  if (env.DSH_AGY_BIN) cfg.agyBin = env.DSH_AGY_BIN;
@@ -496,6 +564,11 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
496
564
  if (b !== void 0) cfg.mcpBridge = b;
497
565
  }
498
566
  if (env.DSH_AGY_MCP_TOOL_ALLOWLIST) cfg.mcpToolAllowlist = env.DSH_AGY_MCP_TOOL_ALLOWLIST;
567
+ if (env.DSH_AGY_MCP_CONFIG_PATH) cfg.mcpConfigPath = env.DSH_AGY_MCP_CONFIG_PATH;
568
+ if (env.DSH_AGY_MCP_CONFIG_SCOPE !== void 0) {
569
+ const scope = asMcpConfigScope(env.DSH_AGY_MCP_CONFIG_SCOPE);
570
+ if (scope !== void 0) cfg.mcpConfigScope = scope;
571
+ }
499
572
  if (env.DSH_AGY_RATE_LIMIT_PER_MINUTE) {
500
573
  const r = asNum(env.DSH_AGY_RATE_LIMIT_PER_MINUTE);
501
574
  if (r !== void 0 && r >= 0) cfg.rateLimitPerMinute = r;
@@ -516,11 +589,23 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
516
589
  const q = asNum(env.DSH_AGY_QUOTA_POLL_INTERVAL_MS);
517
590
  if (q && q >= 6e4) cfg.quotaPollIntervalMs = q;
518
591
  }
592
+ if (env.DSH_AGY_QUOTA_VIA_CLI !== void 0) {
593
+ const qvc = asBool(env.DSH_AGY_QUOTA_VIA_CLI);
594
+ if (qvc !== void 0) cfg.quotaViaCli = qvc;
595
+ }
519
596
  if (env.DSH_AGY_PROXY_URL) cfg.proxyUrl = env.DSH_AGY_PROXY_URL;
520
597
  if (env.DSH_AGY_SUPPRESS_AUTOUPDATE !== void 0) {
521
598
  const sa = asBool(env.DSH_AGY_SUPPRESS_AUTOUPDATE);
522
599
  if (sa !== void 0) cfg.suppressAgyAutoUpdate = sa;
523
600
  }
601
+ if (env.DSH_AGY_AUTO_WARMUP !== void 0) {
602
+ const aw = asBool(env.DSH_AGY_AUTO_WARMUP);
603
+ if (aw !== void 0) cfg.autoWarmup = aw;
604
+ }
605
+ if (env.DSH_AGY_WARMUP_MODELS) {
606
+ const wm = asStringArray(env.DSH_AGY_WARMUP_MODELS.split(","));
607
+ if (wm) cfg.warmupModels = wm;
608
+ }
524
609
  return cfg;
525
610
  }
526
611
  //#endregion
@@ -1989,7 +2074,7 @@ var EventMapper = class {
1989
2074
  * context occupancy, so a failed turn used to reset the meter and could fire
1990
2075
  * a bogus compaction a step later.
1991
2076
  */
1992
- *emitFailure(kind, code, message) {
2077
+ *emitFailure(kind, code, message, retryAfterMs) {
1993
2078
  if (this.finished) return;
1994
2079
  const close = this.closeOpen();
1995
2080
  if (close) yield close;
@@ -2001,20 +2086,19 @@ var EventMapper = class {
2001
2086
  outputTokens: 0
2002
2087
  }
2003
2088
  };
2089
+ const failure = {
2090
+ message,
2091
+ code,
2092
+ ...retryAfterMs !== void 0 && Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? { providerRetryAfterMs: Math.trunc(retryAfterMs) } : {}
2093
+ };
2004
2094
  yield {
2005
2095
  type: "finish",
2006
2096
  reason: kind === "error" ? {
2007
2097
  kind: "error",
2008
- failure: {
2009
- message,
2010
- code
2011
- }
2098
+ failure
2012
2099
  } : {
2013
2100
  kind: "aborted",
2014
- failure: {
2015
- message,
2016
- code
2017
- }
2101
+ failure
2018
2102
  }
2019
2103
  };
2020
2104
  this.finished = true;
@@ -2022,6 +2106,24 @@ var EventMapper = class {
2022
2106
  };
2023
2107
  //#endregion
2024
2108
  //#region src/host/models.ts
2109
+ /** Check if a model identifier belongs to the Gemini 4 Argon family. */
2110
+ function isArgonModel(id) {
2111
+ return id.trim().toLowerCase().includes("argon");
2112
+ }
2113
+ /**
2114
+ * Determine whether a managed account has confirmed access to the Gemini 4 Argon family.
2115
+ * Argon is exclusive to paid Pro/Ultra plans and returns a separate quota bucket
2116
+ * and/or dedicated model list from agy cli. Free/standard accounts do not have Argon.
2117
+ */
2118
+ function accountHasArgon(acc, pool, catalog) {
2119
+ if (!acc) return false;
2120
+ if (acc.quotas?.argon !== void 0) return true;
2121
+ if (pool && typeof pool.hasDedicatedGroup === "function" && pool.hasDedicatedGroup(acc.id, "argon")) return true;
2122
+ if (acc.quotas?.google?.models?.some((m) => isArgonModel(m.modelId))) return true;
2123
+ if (acc.quotas?.google !== void 0) return false;
2124
+ if (catalog && catalog.source === "discovered") return catalog.models.some((m) => isArgonModel(m.id));
2125
+ return false;
2126
+ }
2025
2127
  /** Parse `agy models` stdout: JSON shapes first, then two-column text. */
2026
2128
  function parseModelsOutput(stdout) {
2027
2129
  const text = stdout.trim();
@@ -2207,6 +2309,7 @@ var ModelCatalog = class ModelCatalog {
2207
2309
  ttlMs;
2208
2310
  onUpdate;
2209
2311
  current;
2312
+ accountCatalogs = /* @__PURE__ */ new Map();
2210
2313
  refreshing = null;
2211
2314
  /**
2212
2315
  * While the catalog is still on the fallback source, re-probe at most once
@@ -2226,46 +2329,59 @@ var ModelCatalog = class ModelCatalog {
2226
2329
  discoveredAt: 0
2227
2330
  };
2228
2331
  }
2229
- get() {
2332
+ get(accountId) {
2333
+ if (accountId && this.accountCatalogs.has(accountId)) return this.accountCatalogs.get(accountId);
2230
2334
  return this.current;
2231
2335
  }
2336
+ hasAccountCatalog(accountId) {
2337
+ return this.accountCatalogs.has(accountId);
2338
+ }
2232
2339
  /** Refresh if stale; never throws — failures keep the previous catalog. */
2233
- async refreshIfNeeded() {
2340
+ async refreshIfNeeded(accountId) {
2234
2341
  if (this.refreshing) return this.refreshing;
2235
2342
  const now = Date.now();
2236
- const age = now - this.current.discoveredAt;
2237
- if (this.current.source === "discovered" && age < this.ttlMs) return;
2238
- if (this.current.source === "fallback" && now - this.lastAttemptAt < ModelCatalog.FALLBACK_RETRY_MS) return;
2343
+ const accountCat = accountId ? this.accountCatalogs.get(accountId) : void 0;
2344
+ if (!accountId || accountCat) {
2345
+ const targetCat = accountCat ?? this.current;
2346
+ const age = now - targetCat.discoveredAt;
2347
+ if (targetCat.source === "discovered" && age < this.ttlMs) return;
2348
+ if (targetCat.source === "fallback" && now - this.lastAttemptAt < ModelCatalog.FALLBACK_RETRY_MS) return;
2349
+ }
2239
2350
  this.lastAttemptAt = now;
2240
- this.refreshing = this.refresh().finally(() => {
2351
+ this.refreshing = this.refresh(accountId).finally(() => {
2241
2352
  this.refreshing = null;
2242
2353
  });
2243
2354
  return this.refreshing;
2244
2355
  }
2245
- async forceRefresh() {
2246
- await this.refresh();
2247
- this.onUpdate?.();
2248
- return this.current;
2356
+ async forceRefresh(accountId) {
2357
+ await this.refresh(accountId);
2358
+ return this.get(accountId);
2249
2359
  }
2250
- async refresh() {
2360
+ async refresh(accountId) {
2251
2361
  try {
2252
2362
  const ac = new AbortController();
2253
2363
  const timer = setTimeout(() => ac.abort(), 3e4);
2254
2364
  try {
2255
- const { stdout } = await this.discover(ac.signal);
2365
+ const { stdout } = await this.discover(ac.signal, accountId);
2256
2366
  const raw = parseModelsOutput(stdout);
2257
2367
  if (raw.length > 0) {
2258
2368
  const newModels = foldEfforts(raw);
2259
- const changed = this.current.source !== "discovered" || this.current.models.length !== newModels.length || this.current.models.some((m, i) => m.id !== newModels[i]?.id);
2260
- this.current = {
2369
+ const newCatalog = {
2261
2370
  source: "discovered",
2262
2371
  models: newModels,
2263
2372
  discoveredAt: Date.now()
2264
2373
  };
2374
+ const prev = this.get(accountId);
2375
+ const changed = prev.source !== "discovered" || prev.models.length !== newModels.length || prev.models.some((m, i) => m.id !== newModels[i]?.id);
2376
+ if (accountId) this.accountCatalogs.set(accountId, newCatalog);
2377
+ else this.current = newCatalog;
2265
2378
  if (changed) this.onUpdate?.();
2266
2379
  return;
2267
2380
  }
2268
- this.current = {
2381
+ if (accountId) {
2382
+ const cat = this.accountCatalogs.get(accountId);
2383
+ if (cat) cat.lastError = "agy models returned no entries";
2384
+ } else this.current = {
2269
2385
  ...this.current,
2270
2386
  lastError: "agy models returned no entries"
2271
2387
  };
@@ -2273,9 +2389,13 @@ var ModelCatalog = class ModelCatalog {
2273
2389
  clearTimeout(timer);
2274
2390
  }
2275
2391
  } catch (err) {
2276
- this.current = {
2392
+ const errMsg = err instanceof Error ? err.message : String(err);
2393
+ if (accountId) {
2394
+ const cat = this.accountCatalogs.get(accountId);
2395
+ if (cat) cat.lastError = errMsg;
2396
+ } else this.current = {
2277
2397
  ...this.current,
2278
- lastError: err instanceof Error ? err.message : String(err)
2398
+ lastError: errMsg
2279
2399
  };
2280
2400
  }
2281
2401
  }
@@ -3042,12 +3162,6 @@ function ensureIsolatedKeychain(dir, platform = process.platform, run = (file, a
3042
3162
  kc
3043
3163
  ], env);
3044
3164
  run("security", ["set-keychain-settings", kc], env);
3045
- run("security", [
3046
- "unlock-keychain",
3047
- "-p",
3048
- "",
3049
- kc
3050
- ], env);
3051
3165
  return true;
3052
3166
  } catch {
3053
3167
  return false;
@@ -3058,8 +3172,9 @@ function ensureIsolatedKeychain(dir, platform = process.platform, run = (file, a
3058
3172
  * Keychains from older versions (v0.4.41-v0.4.44) were provisioned with
3059
3173
  * ephemeral random passwords that cannot be unlocked with an empty password,
3060
3174
  * causing repeated modal password dialogs from macOS SecurityAgent.
3061
- * If unlocking with `-p ""` fails, the file and its registration in securityd
3062
- * are purged so that ensureIsolatedKeychain can cleanly re-provision it.
3175
+ * We test whether the keychain settings can be updated silently without prompt.
3176
+ * If setting settings fails, the file is un-unlockable or corrupted, so it is
3177
+ * unregistered and purged so ensureIsolatedKeychain can cleanly re-provision it.
3063
3178
  */
3064
3179
  function purgeStaleIsolatedKeychain(dir, platform = process.platform, run = (file, args, env) => execFileSync(file, args, {
3065
3180
  stdio: "ignore",
@@ -3075,12 +3190,7 @@ function purgeStaleIsolatedKeychain(dir, platform = process.platform, run = (fil
3075
3190
  HOME: dir
3076
3191
  };
3077
3192
  try {
3078
- run("security", [
3079
- "unlock-keychain",
3080
- "-p",
3081
- "",
3082
- kc
3083
- ], env);
3193
+ run("security", ["set-keychain-settings", kc], env);
3084
3194
  return false;
3085
3195
  } catch {
3086
3196
  try {
@@ -3173,6 +3283,7 @@ function repairMacSystemKeychain(userHome = homedir(), platform = process.platfo
3173
3283
  function openAccountTerminal(accDir, platform = process.platform, run = (file, args, opts) => execFile(file, [...args], { windowsHide: opts?.windowsHide }, () => {})) {
3174
3284
  if (!accDir) return false;
3175
3285
  if (platform === "darwin") {
3286
+ ensureIsolatedKeychain(accDir, platform);
3176
3287
  const script = `tell application "Terminal" to activate\ntell application "Terminal" to do script "export HOME='${accDir}'; agy"`;
3177
3288
  try {
3178
3289
  run("osascript", ["-e", script]);
@@ -3698,7 +3809,7 @@ function runWinCredPs(target, user, action, payload = "") {
3698
3809
  payload
3699
3810
  ], {
3700
3811
  encoding: "utf8",
3701
- timeout: 8e3,
3812
+ timeout: action === "write" ? 3e4 : 8e3,
3702
3813
  windowsHide: true,
3703
3814
  stdio: [
3704
3815
  "ignore",
@@ -4118,6 +4229,7 @@ var AccountPoolManager = class {
4118
4229
  cooldowns: {},
4119
4230
  quotas: {}
4120
4231
  };
4232
+ if (process.platform === "darwin" && newAccount.dir) ensureIsolatedKeychain(newAccount.dir);
4121
4233
  this.data.accounts.push(newAccount);
4122
4234
  this.knownIds.add(newAccount.id);
4123
4235
  this.persist();
@@ -4185,6 +4297,7 @@ var AccountPoolManager = class {
4185
4297
  const id = `acc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
4186
4298
  const dir = join(this.baseDir, id);
4187
4299
  mkdirSync(join(dir, ".gemini", "antigravity-cli"), { recursive: true });
4300
+ if (process.platform === "darwin") ensureIsolatedKeychain(dir);
4188
4301
  const count = this.data.accounts.length + 1;
4189
4302
  const newAccount = {
4190
4303
  id,
@@ -4516,6 +4629,19 @@ var AccountPoolManager = class {
4516
4629
  this.persist();
4517
4630
  }
4518
4631
  /**
4632
+ * Drop one family's quota row.
4633
+ *
4634
+ * Used when stored numbers provably cannot be attributed to this account —
4635
+ * `updateAccountQuotas` only merges, so an untrustworthy row would otherwise
4636
+ * survive every refresh.
4637
+ */
4638
+ clearQuotaFamily(id, family) {
4639
+ const acc = this.getAccount(id);
4640
+ if (!acc?.quotas || acc.quotas[family] === void 0) return;
4641
+ delete acc.quotas[family];
4642
+ this.persist();
4643
+ }
4644
+ /**
4519
4645
  * Records a rate-limit / 429 error and sets cooldown for the requested model family.
4520
4646
  */
4521
4647
  recordFailure(id, family, reason, serverResetTime) {
@@ -4555,6 +4681,28 @@ var AccountPoolManager = class {
4555
4681
  this.authStrikes.delete(id);
4556
4682
  this.persist();
4557
4683
  }
4684
+ /** True when this account has reported a dedicated quota group for `family`. */
4685
+ hasDedicatedGroup(id, family) {
4686
+ return this.getAccount(id)?.dedicatedGroups?.includes(family) === true;
4687
+ }
4688
+ /**
4689
+ * Remember that `family` has its own quota group on this account.
4690
+ * Persists only on an actual change so a steady-state poll stays write-free.
4691
+ */
4692
+ noteDedicatedGroup(id, family) {
4693
+ const acc = this.getAccount(id);
4694
+ if (!acc) return;
4695
+ if (acc.dedicatedGroups?.includes(family)) return;
4696
+ acc.dedicatedGroups = [...acc.dedicatedGroups ?? [], family];
4697
+ this.persist();
4698
+ }
4699
+ /** Forget a family's dedicated group after it stopped being reported. */
4700
+ forgetDedicatedGroup(id, family) {
4701
+ const acc = this.getAccount(id);
4702
+ if (!acc?.dedicatedGroups?.includes(family)) return;
4703
+ acc.dedicatedGroups = acc.dedicatedGroups.filter((f) => f !== family);
4704
+ this.persist();
4705
+ }
4558
4706
  clearCooldown(id, family) {
4559
4707
  if (id) {
4560
4708
  const acc = this.getAccount(id);
@@ -28143,12 +28291,7 @@ const OAUTH_USERINFO_URL$1 = "https://www.googleapis.com/oauth2/v1/userinfo";
28143
28291
  const OAUTH_CALLBACK_PORT = 51121;
28144
28292
  const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}/oauth-callback`;
28145
28293
  /** Runtime endpoint fallback order (Antigravity endpoint first, then Cloud Code PA fallbacks). */
28146
- const AGY_ENDPOINTS = [
28147
- "https://daily-cloudcode-pa.googleapis.com",
28148
- "https://cloudcode-pa.googleapis.com",
28149
- "https://daily-cloudcode-pa.sandbox.googleapis.com",
28150
- "https://autopush-cloudcode-pa.sandbox.googleapis.com"
28151
- ];
28294
+ const AGY_ENDPOINTS = ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"];
28152
28295
  function generatePkce() {
28153
28296
  const verifier = randomBytes(32).toString("base64url");
28154
28297
  return {
@@ -28449,7 +28592,7 @@ function credentialFingerprint(raw) {
28449
28592
  * back into an account file, re-asserting the primary), so "cannot prove it"
28450
28593
  * must mean "not the same".
28451
28594
  */
28452
- function sameCredentialGrant(a, b) {
28595
+ function sameCredentialGrant$1(a, b) {
28453
28596
  const docA = parseCredentialDoc(a);
28454
28597
  const docB = parseCredentialDoc(b);
28455
28598
  if (!docA?.refreshToken || !docB?.refreshToken) return false;
@@ -28524,8 +28667,8 @@ var AsyncMutex = class {
28524
28667
  async acquire(opts = {}) {
28525
28668
  const signal = opts.signal;
28526
28669
  if (signal?.aborted) throw new AbortedWaitError("aborted before mutex acquisition");
28527
- if (this.holders < 1) {
28528
- this.holders += 1;
28670
+ if (this.holders < 1 && this.queue.length === 0) {
28671
+ this.holders = 1;
28529
28672
  return () => this.releaseOne();
28530
28673
  }
28531
28674
  const pollMs = opts.pollMs ?? 0;
@@ -28551,15 +28694,12 @@ var AsyncMutex = class {
28551
28694
  pollTimer = null;
28552
28695
  }
28553
28696
  };
28554
- const take = () => {
28555
- this.holders += 1;
28556
- return () => this.releaseOne();
28557
- };
28558
28697
  const grant = () => {
28559
28698
  if (settled) return;
28560
28699
  settled = true;
28561
28700
  cleanup();
28562
- resolve(take());
28701
+ entry = null;
28702
+ resolve(() => this.releaseOne());
28563
28703
  };
28564
28704
  const fail = (err) => {
28565
28705
  if (settled) return;
@@ -28568,6 +28708,7 @@ var AsyncMutex = class {
28568
28708
  if (entry !== null) {
28569
28709
  const idx = this.queue.indexOf(entry);
28570
28710
  if (idx >= 0) this.queue.splice(idx, 1);
28711
+ else this.releaseOne();
28571
28712
  entry = null;
28572
28713
  } else if (takenDirectly) this.releaseOne();
28573
28714
  reject(err);
@@ -28590,6 +28731,7 @@ var AsyncMutex = class {
28590
28731
  if (idx >= 0) this.queue.splice(idx, 1);
28591
28732
  entry = null;
28592
28733
  takenDirectly = true;
28734
+ this.holders = 1;
28593
28735
  grant();
28594
28736
  }
28595
28737
  }, pollMs);
@@ -28598,12 +28740,9 @@ var AsyncMutex = class {
28598
28740
  });
28599
28741
  }
28600
28742
  releaseOne() {
28601
- this.holders -= 1;
28602
- this.wakeNext();
28603
- }
28604
- wakeNext() {
28605
28743
  const next = this.queue.shift();
28606
28744
  if (next) queueMicrotask(next);
28745
+ else this.holders = Math.max(0, this.holders - 1);
28607
28746
  }
28608
28747
  };
28609
28748
  /** The caller's abort signal fired while queued on the mutex. */
@@ -28841,6 +28980,32 @@ var SlotUnavailableError = class extends Error {
28841
28980
  this.name = "SlotUnavailableError";
28842
28981
  }
28843
28982
  };
28983
+ /**
28984
+ * Install an identity into the machine-wide slot and PROVE the write landed.
28985
+ *
28986
+ * The four install sites here used to ignore `io.writeSlot()`'s boolean. On
28987
+ * Windows that write is a PowerShell cold start that compiles a C# P/Invoke
28988
+ * shim, so on a loaded machine it can exceed its own timeout and return false
28989
+ * WITHOUT touching the slot (reproduced 2026-10-23 while probing: a delete
28990
+ * followed by a write reported false and left the slot empty). The run then
28991
+ * spawned anyway and authenticated as whichever account was already installed —
28992
+ * silently spending the wrong account's quota, which is the exact corruption
28993
+ * class this module exists to prevent. One retry, then refuse.
28994
+ */
28995
+ function installSlotOrThrow(io, raw, log, label) {
28996
+ if (io.writeSlot(raw)) return;
28997
+ log("[agy-link] credential slot write failed for " + label + " — retrying once");
28998
+ if (io.writeSlot(raw)) return;
28999
+ throw new SlotUnavailableError("could not install the " + label + " credential into the Windows credential slot (the write was rejected) — refusing to run, because agy would authenticate as whichever account is already in the slot");
29000
+ }
29001
+ /** Restore path: never fail a teardown, but say so loudly. */
29002
+ function installSlotBestEffort(io, raw, log, label) {
29003
+ try {
29004
+ installSlotOrThrow(io, raw, log, label);
29005
+ } catch (err) {
29006
+ log("[agy-link] WARNING: could not restore the " + label + " credential into the slot (" + (err instanceof Error ? err.message : String(err)) + ") — the next run re-installs it");
29007
+ }
29008
+ }
28844
29009
  let primaryFastRuns = 0;
28845
29010
  /** The secondary identity currently installed in the slot, if any. */
28846
29011
  let installedIdentity = null;
@@ -28866,6 +29031,54 @@ function releaseSwitchTarget(key) {
28866
29031
  function fastPathRunCount() {
28867
29032
  return primaryFastRuns + (installedIdentity?.holders ?? 0);
28868
29033
  }
29034
+ /** Default ceiling for holding the slot while waiting for the first event. */
29035
+ const SLOT_HANDOFF_TIMEOUT_MS = 45e3;
29036
+ /**
29037
+ * Arrange for a credential-slot lease to be released after the startup read.
29038
+ *
29039
+ * Correctness rests on the caller's spawn ordering, which does not change: the
29040
+ * identity is installed UNDER the mutex immediately before the spawn, so
29041
+ * whatever a later switcher does cannot affect a run that is already past its
29042
+ * read. The residual exposure is a mid-run 401 forcing a re-auth (agy would
29043
+ * then read whatever identity is installed at that moment); the hand-off logs
29044
+ * when it fires so that case is diagnosable instead of silent.
29045
+ */
29046
+ function armSlotHandoff(opts) {
29047
+ const timeoutMs = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? opts.timeoutMs : SLOT_HANDOFF_TIMEOUT_MS;
29048
+ const log = opts.log ?? (() => {});
29049
+ let done = false;
29050
+ let byTimeout = false;
29051
+ let timer = null;
29052
+ const clear = () => {
29053
+ if (timer !== null) {
29054
+ clearTimeout(timer);
29055
+ timer = null;
29056
+ }
29057
+ };
29058
+ const fire = (reason) => {
29059
+ if (done) return;
29060
+ done = true;
29061
+ clear();
29062
+ try {
29063
+ opts.release();
29064
+ } catch {}
29065
+ if (reason !== "first-event") log("[agy-link] credential slot handed back at " + reason + " (no first event yet)");
29066
+ };
29067
+ timer = setTimeout(() => {
29068
+ timer = null;
29069
+ if (done) return;
29070
+ byTimeout = true;
29071
+ log("[agy-link] credential slot held for " + Math.round(timeoutMs / 1e3) + "s without a first event — handing it back so other sessions are not blocked");
29072
+ fire("timeout");
29073
+ }, timeoutMs);
29074
+ timer.unref?.();
29075
+ return {
29076
+ onFirstEvent: () => fire("first-event"),
29077
+ dispose: () => fire("run-end"),
29078
+ released: () => done,
29079
+ timedOut: () => byTimeout
29080
+ };
29081
+ }
28869
29082
  /** True while a run is waiting for (or performing) a slot identity switch. */
28870
29083
  function isSlotSwitchPending() {
28871
29084
  return pendingSwitchTargets.size > 0;
@@ -28920,7 +29133,7 @@ async function acquireCredentialSlot(account, pool, options = {}) {
28920
29133
  const isPrimarySlot = Boolean(account.systemHome) || !account.dir;
28921
29134
  if (parallel && isPrimarySlot && !isSlotSwitchPending() && !options.signal?.aborted) {
28922
29135
  const stored = readPrimaryCredential(baseDir);
28923
- if (stored && sameCredentialGrant(io.readSlot(), stored.raw)) {
29136
+ if (stored && sameCredentialGrant$1(io.readSlot(), stored.raw)) {
28924
29137
  primaryFastRuns += 1;
28925
29138
  log("[agy-link] same-account fast path: slot already holds the primary credential — running without exclusivity");
28926
29139
  return {
@@ -28929,7 +29142,7 @@ async function acquireCredentialSlot(account, pool, options = {}) {
28929
29142
  primaryFastRuns = Math.max(0, primaryFastRuns - 1);
28930
29143
  try {
28931
29144
  const afterRaw = io.readSlot();
28932
- if (afterRaw && afterRaw !== stored.raw && sameCredentialGrant(afterRaw, stored.raw)) writePrimaryCredential({
29145
+ if (afterRaw && afterRaw !== stored.raw && sameCredentialGrant$1(afterRaw, stored.raw)) writePrimaryCredential({
28933
29146
  raw: afterRaw,
28934
29147
  email: stored.email,
28935
29148
  savedAt: Date.now()
@@ -28949,7 +29162,7 @@ async function acquireCredentialSlot(account, pool, options = {}) {
28949
29162
  const state = installedIdentity;
28950
29163
  if (state === null || state.fingerprint !== requestedFingerprint) return null;
28951
29164
  for (const key of pendingSwitchTargets.keys()) if (key !== switchKey) return null;
28952
- if (!sameCredentialGrant(io.readSlot(), requestedRaw)) return null;
29165
+ if (!sameCredentialGrant$1(io.readSlot(), requestedRaw)) return null;
28953
29166
  state.holders += 1;
28954
29167
  return {
28955
29168
  identity: account.email,
@@ -29070,9 +29283,9 @@ async function acquireCredentialSlot(account, pool, options = {}) {
29070
29283
  throw new SlotUnavailableError("Cannot verify the primary Antigravity credential, so the Windows credential slot cannot be handed over safely. Sign the primary account in again, then retry.");
29071
29284
  }
29072
29285
  if (isPrimarySlot) {
29073
- if (!sameCredentialGrant(io.readSlot(), primary.raw)) {
29286
+ if (!sameCredentialGrant$1(io.readSlot(), primary.raw)) {
29074
29287
  log("[agy-link] re-asserting the primary credential into the Windows credential slot");
29075
- io.writeSlot(primary.raw);
29288
+ installSlotOrThrow(io, primary.raw, log, "primary");
29076
29289
  }
29077
29290
  releaseLockFileNow();
29078
29291
  releaseClaim();
@@ -29082,7 +29295,7 @@ async function acquireCredentialSlot(account, pool, options = {}) {
29082
29295
  release: () => {
29083
29296
  try {
29084
29297
  const afterRaw = io.readSlot();
29085
- if (sameCredentialGrant(afterRaw, primary.raw)) {
29298
+ if (sameCredentialGrant$1(afterRaw, primary.raw)) {
29086
29299
  if (afterRaw && afterRaw !== primary.raw) writePrimaryCredential({
29087
29300
  raw: afterRaw,
29088
29301
  email: primary.email,
@@ -29090,7 +29303,7 @@ async function acquireCredentialSlot(account, pool, options = {}) {
29090
29303
  }, baseDir);
29091
29304
  } else if (afterRaw) {
29092
29305
  log("[agy-link] credential slot changed during a primary run — restoring the primary credential");
29093
- io.writeSlot(primary.raw);
29306
+ installSlotBestEffort(io, primary.raw, log, "primary");
29094
29307
  writePrimaryCredential(primary, baseDir);
29095
29308
  }
29096
29309
  } finally {
@@ -29117,11 +29330,11 @@ async function acquireCredentialSlot(account, pool, options = {}) {
29117
29330
  done();
29118
29331
  throw new SlotUnavailableError(`Account ${account.alias || account.id} has an unreadable credential file. Sign it in again.`);
29119
29332
  }
29120
- if (sameCredentialGrant(accountRaw, primary.raw)) {
29333
+ if (sameCredentialGrant$1(accountRaw, primary.raw)) {
29121
29334
  done();
29122
29335
  throw new SlotUnavailableError(`Account ${account.alias || account.id} holds the SAME credential as the primary account — its login was overwritten. Sign this account in again to restore its own token.`);
29123
29336
  }
29124
- io.writeSlot(accountRaw);
29337
+ installSlotOrThrow(io, accountRaw, log, account.alias || account.id);
29125
29338
  releaseLockFileNow();
29126
29339
  const state = {
29127
29340
  fingerprint: accountFingerprint,
@@ -29130,12 +29343,12 @@ async function acquireCredentialSlot(account, pool, options = {}) {
29130
29343
  try {
29131
29344
  const afterRaw = io.readSlot();
29132
29345
  const afterFingerprint = credentialFingerprint(afterRaw);
29133
- if (sameCredentialGrant(afterRaw, accountRaw)) {
29346
+ if (sameCredentialGrant$1(afterRaw, accountRaw)) {
29134
29347
  if (afterRaw && afterRaw !== accountRaw) writeFileAtomic(accountFile, afterRaw);
29135
29348
  } else if (afterFingerprint !== null) log(`[agy-link] credential slot was replaced during ${account.alias || account.id}'s run — refusing to write a foreign credential into the account file`);
29136
29349
  } finally {
29137
29350
  try {
29138
- io.writeSlot(primary.raw);
29351
+ installSlotBestEffort(io, primary.raw, log, "primary");
29139
29352
  } finally {
29140
29353
  done();
29141
29354
  }
@@ -29543,8 +29756,19 @@ async function queryViaNodeSqlite(dbPath, sql) {
29543
29756
  if (typeof mod.DatabaseSync !== "function") throw new Error("DatabaseSync missing");
29544
29757
  const Database = mod.DatabaseSync;
29545
29758
  nodeSqliteQuery = async (p, q) => {
29546
- const db = new Database(p);
29759
+ let db;
29760
+ try {
29761
+ db = new Database(p, { readOnly: true });
29762
+ } catch {
29763
+ db = new Database(p);
29764
+ }
29547
29765
  try {
29766
+ try {
29767
+ db.exec("PRAGMA busy_timeout = 2000;");
29768
+ } catch {}
29769
+ try {
29770
+ db.exec("PRAGMA query_only = ON;");
29771
+ } catch {}
29548
29772
  return db.prepare(q).all().map((r) => `${String(r.idx)}|${String(r.step_type)}|${typeof r.payload_hex === "string" ? r.payload_hex : ""}`);
29549
29773
  } finally {
29550
29774
  db.close();
@@ -29557,9 +29781,13 @@ async function queryViaNodeSqlite(dbPath, sql) {
29557
29781
  return nodeSqliteQuery(dbPath, sql);
29558
29782
  }
29559
29783
  /**
29560
- * Copy the agy SQLite DB to a temp path (avoiding WAL lock issues), then
29561
- * query tool and thought step payloads — node:sqlite first, sqlite3 CLI as
29562
- * the fallback reader.
29784
+ * Query tool and thought step payloads directly from the agy SQLite DB —
29785
+ * node:sqlite first, sqlite3 CLI as the fallback reader.
29786
+ *
29787
+ * Direct read avoids the file-copy busy loop on macOS (issue #52) where
29788
+ * libuv uv_fs_copyfile spins at 100% CPU if an active WAL file is truncated
29789
+ * during copy. SQLite WAL mode provides concurrent read MVCC snapshots
29790
+ * natively without copying to temp.
29563
29791
  */
29564
29792
  async function loadAgyDbData(conversationId, accountHome) {
29565
29793
  const steps = /* @__PURE__ */ new Map();
@@ -29576,26 +29804,34 @@ async function loadAgyDbData(conversationId, accountHome) {
29576
29804
  thoughts,
29577
29805
  seenSteps
29578
29806
  };
29579
- let tmpDir = null;
29580
- const tmpDb = join(tmpdir(), `agy-db-${conversationId.slice(0, 8)}-${Date.now()}.db`);
29581
29807
  try {
29582
- tmpDir = await mkdtemp(join(tmpdir(), "agy-db-"));
29583
- const target = join(tmpDir, "conversation.db");
29584
- await copyFile(dbPath, target);
29585
- await chmod(target, 384).catch(() => void 0);
29586
- try {
29587
- await copyFile(dbPath + "-wal", target + "-wal");
29588
- } catch {}
29589
- try {
29590
- await copyFile(dbPath + "-shm", target + "-shm");
29591
- } catch {}
29808
+ if ((await stat(dbPath)).size === 0) return {
29809
+ steps,
29810
+ thoughts,
29811
+ seenSteps
29812
+ };
29813
+ } catch {
29814
+ return {
29815
+ steps,
29816
+ thoughts,
29817
+ seenSteps
29818
+ };
29819
+ }
29820
+ try {
29592
29821
  const sql = `SELECT idx, step_type, hex(step_payload) AS payload_hex FROM steps WHERE (step_type IN (5,7,8,9,14,15,17,21,33,101,132,138,139)) AND length(step_payload) > 20`;
29593
29822
  let lines = null;
29594
29823
  try {
29595
- lines = await queryViaNodeSqlite(target, sql);
29824
+ lines = await queryViaNodeSqlite(dbPath, sql);
29596
29825
  } catch {
29597
29826
  try {
29598
- const { stdout } = await execFileAsync("sqlite3", [target, sql], {
29827
+ const { stdout } = await execFileAsync("sqlite3", [
29828
+ "-cmd",
29829
+ ".timeout 2000",
29830
+ "-cmd",
29831
+ "PRAGMA query_only = ON;",
29832
+ dbPath,
29833
+ sql
29834
+ ], {
29599
29835
  timeout: 5e3,
29600
29836
  encoding: "utf-8",
29601
29837
  maxBuffer: 26214400
@@ -29640,24 +29876,6 @@ async function loadAgyDbData(conversationId, accountHome) {
29640
29876
  console.error("[dsh-agy-link] agy conversation DB unavailable (" + String(err) + ") — falling back to stream-carried metadata only" + (code === "ENOENT" ? "; install the sqlite3 CLI for full tool args and thoughts" : ""));
29641
29877
  } catch {}
29642
29878
  }
29643
- } finally {
29644
- if (tmpDir !== null) try {
29645
- await rm(tmpDir, {
29646
- recursive: true,
29647
- force: true
29648
- });
29649
- } catch {}
29650
- else {
29651
- try {
29652
- await unlink(tmpDb);
29653
- } catch {}
29654
- try {
29655
- await unlink(tmpDb + "-wal");
29656
- } catch {}
29657
- try {
29658
- await unlink(tmpDb + "-shm");
29659
- } catch {}
29660
- }
29661
29879
  }
29662
29880
  return {
29663
29881
  steps,
@@ -30056,13 +30274,13 @@ function buildDigest(messages, fromIdx, maxChars) {
30056
30274
  otherLines.push(digestLabel(m) + text);
30057
30275
  }
30058
30276
  let budget = maxChars;
30059
- const kept = [];
30277
+ let taskPart = "";
30060
30278
  if (taskLine !== "") {
30061
30279
  if (taskLine.length > budget) {
30062
- kept.push(taskLine.slice(0, Math.max(0, budget)));
30280
+ taskPart = taskLine.slice(0, Math.max(0, budget));
30063
30281
  budget = 0;
30064
30282
  } else {
30065
- kept.push(taskLine);
30283
+ taskPart = taskLine;
30066
30284
  budget -= taskLine.length;
30067
30285
  }
30068
30286
  }
@@ -30076,7 +30294,8 @@ function buildDigest(messages, fromIdx, maxChars) {
30076
30294
  keptOther.unshift(line);
30077
30295
  budget -= line.length;
30078
30296
  }
30079
- kept.push(...keptOther);
30297
+ const kept = [...keptOther];
30298
+ if (taskPart !== "") kept.push(taskPart);
30080
30299
  if (kept.length === 0) return "";
30081
30300
  return "[conversation so far]\n" + kept.join("\n\n") + "\n[end of conversation so far]\n\n";
30082
30301
  }
@@ -30111,6 +30330,92 @@ function brief(s) {
30111
30330
  const flat = s.trim().replace(/\s+/g, " ");
30112
30331
  return flat.length > 300 ? flat.slice(0, 300) + "..." : flat;
30113
30332
  }
30333
+ /**
30334
+ * Await `work` while periodically yielding a SESSION-VISIBLE notice.
30335
+ *
30336
+ * Why this exists: Windows has ONE machine-global credential slot, so every
30337
+ * session's agy run is serialized behind whichever session got there first.
30338
+ * A healthy long run (a 15-minute build, a 240-minute long tool) made the
30339
+ * OTHER sessions sit with a completely empty transcript for up to
30340
+ * `dispatchTimeoutMinutes` — indistinguishable from a freeze. The console
30341
+ * heartbeat was invisible to the user, so the only honest signal was "nothing
30342
+ * is happening" (2026-10-22: two sessions looked hung for 15 minutes while
30343
+ * another project's agy run held the slot).
30344
+ *
30345
+ * Resolves with `work`'s value; rejects with `work`'s rejection untouched, so
30346
+ * the caller's existing error classification is unchanged.
30347
+ */
30348
+ async function* awaitWithNotices(work, opts) {
30349
+ const baseInterval = opts.intervalMs > 0 ? opts.intervalMs : 3e4;
30350
+ const escalate = opts.escalate !== void 0 && opts.escalate > 1 ? opts.escalate : 1;
30351
+ const maxInterval = opts.maxIntervalMs !== void 0 && opts.maxIntervalMs > baseInterval ? opts.maxIntervalMs : baseInterval * 100;
30352
+ let intervalMs = baseInterval;
30353
+ const startedAt = Date.now();
30354
+ let settled = null;
30355
+ let wake = null;
30356
+ const ping = () => {
30357
+ const resume = wake;
30358
+ wake = null;
30359
+ resume?.();
30360
+ };
30361
+ work.then((value) => {
30362
+ if (settled === null) {
30363
+ settled = {
30364
+ ok: true,
30365
+ value
30366
+ };
30367
+ ping();
30368
+ }
30369
+ }, (error) => {
30370
+ if (settled === null) {
30371
+ settled = {
30372
+ ok: false,
30373
+ error
30374
+ };
30375
+ ping();
30376
+ }
30377
+ });
30378
+ let pendingNotices = 0;
30379
+ let timer = null;
30380
+ const arm = () => {
30381
+ if (timer !== null || settled !== null) return;
30382
+ timer = setTimeout(() => {
30383
+ timer = null;
30384
+ pendingNotices++;
30385
+ intervalMs = Math.min(Math.round(intervalMs * escalate), maxInterval);
30386
+ ping();
30387
+ }, intervalMs);
30388
+ timer.unref?.();
30389
+ };
30390
+ try {
30391
+ arm();
30392
+ for (;;) {
30393
+ if (settled !== null) break;
30394
+ if (pendingNotices > 0) {
30395
+ pendingNotices--;
30396
+ yield {
30397
+ type: "reasoning-delta",
30398
+ index: -1,
30399
+ text: opts.notice(Date.now() - startedAt) + "\n"
30400
+ };
30401
+ arm();
30402
+ continue;
30403
+ }
30404
+ await new Promise((resolve) => {
30405
+ wake = resolve;
30406
+ });
30407
+ }
30408
+ } finally {
30409
+ if (timer !== null) {
30410
+ clearTimeout(timer);
30411
+ timer = null;
30412
+ }
30413
+ }
30414
+ const done = settled;
30415
+ if (done === null) throw new Error("awaitWithNotices lost its completion signal");
30416
+ if (done.ok) return done.value;
30417
+ throw done.error;
30418
+ }
30114
30419
  function sawAuthFailure(parser, outcome) {
30115
30420
  if (parser.stats.sawAuthFailure) return true;
30116
30421
  if (looksLikeAuthFailure(outcome.stderrTail)) return true;
@@ -30172,6 +30477,16 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30172
30477
  */
30173
30478
  rateLimitFailover = /* @__PURE__ */ new Map();
30174
30479
  static RATE_LIMIT_FAILOVER_WINDOW_MS = 6e5;
30480
+ /**
30481
+ * sessionKey -> consecutive transport failures and when the streak started.
30482
+ *
30483
+ * The streak drives the 5s/15s/30s retry delay handed to DSH. A success (or
30484
+ * any non-transport failure) clears it, and a gap longer than the window
30485
+ * treats the next failure as a fresh streak: a user who retries a turn ten
30486
+ * minutes after an outage must not start by waiting 30 seconds.
30487
+ */
30488
+ networkStreak = /* @__PURE__ */ new Map();
30489
+ static NETWORK_STREAK_WINDOW_MS = 6e5;
30175
30490
  constructor(deps) {
30176
30491
  super();
30177
30492
  this.deps = deps;
@@ -30181,6 +30496,39 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30181
30496
  this.warnedKeys.add(key);
30182
30497
  this.deps.log?.("WARNING: " + msg);
30183
30498
  }
30499
+ /**
30500
+ * Record one transport failure and return the streak position (1-based).
30501
+ * Position drives the delay requested from DSH.
30502
+ */
30503
+ noteNetworkFailure(sessionKey) {
30504
+ if (sessionKey === "") return 1;
30505
+ const now = Date.now();
30506
+ const prev = this.networkStreak.get(sessionKey);
30507
+ const count = (prev === void 0 || now - prev.at > AgyAdapter.NETWORK_STREAK_WINDOW_MS ? 0 : prev.count) + 1;
30508
+ this.networkStreak.set(sessionKey, {
30509
+ count,
30510
+ at: now
30511
+ });
30512
+ return count;
30513
+ }
30514
+ /** Drop the transport streak: the session reached Google (or failed another way). */
30515
+ clearNetworkStreak(sessionKey) {
30516
+ if (sessionKey === "") return;
30517
+ this.networkStreak.delete(sessionKey);
30518
+ }
30519
+ /**
30520
+ * Whether every enabled account leaves through the same proxy.
30521
+ *
30522
+ * A shared egress makes a transport failure a property of the machine's
30523
+ * network, not of the account: rotating the sticky pointer repoints the
30524
+ * session for nothing and spends the rotation budget a real quota or
30525
+ * eligibility hop needs (live 2026-10-21: one flapping proxy produced EOFs
30526
+ * on every account and the turn still rotated the pool).
30527
+ */
30528
+ sharesSingleEgress() {
30529
+ const fallback = (this.deps.getConfig().proxyUrl ?? "").trim() || (detectSystemProxy() ?? "");
30530
+ return accountsShareSingleEgress(this.deps.pool?.getAccounts() ?? [], fallback);
30531
+ }
30184
30532
  /** Mark a session prompt as no longer in flight so retries are not debounced. */
30185
30533
  clearInFlightPrompt(sessionKey, prompt) {
30186
30534
  if (sessionKey === "") return;
@@ -30199,6 +30547,7 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30199
30547
  */
30200
30548
  claimFailover(sessionKey, code) {
30201
30549
  if (!isAccountScopedFailure(code)) return false;
30550
+ if (code === Err.NETWORK && this.sharesSingleEgress()) return true;
30202
30551
  const enabled = (this.deps.pool?.getAccounts() ?? []).filter((a) => a.enabled).length;
30203
30552
  const max = Math.max(1, enabled - 1);
30204
30553
  const now = Date.now();
@@ -30253,10 +30602,10 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30253
30602
  if (enabledAccounts >= 2) retryableCodes.push(Err.AUTH);
30254
30603
  return {
30255
30604
  mode: "normal",
30256
- maxRetries: enabledAccounts >= 3 ? 2 : 1,
30605
+ maxRetries: Math.max(enabledAccounts >= 3 ? 2 : 1, NETWORK_RETRY_DELAYS_MS.length),
30257
30606
  retryableCodes,
30258
30607
  initialDelayMs: 2e3,
30259
- maxDelayMs: 1e4,
30608
+ maxDelayMs: 45e3,
30260
30609
  jitterRatio: .1
30261
30610
  };
30262
30611
  }
@@ -30266,10 +30615,20 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30266
30615
  name: "Antigravity (agy CLI)"
30267
30616
  };
30268
30617
  }
30618
+ getActiveAccount() {
30619
+ if (!this.deps.pool) return void 0;
30620
+ const pool = this.deps.pool;
30621
+ const activeId = pool.getActiveAccountId("google") ?? pool.getPoolData().lastActiveAccountId ?? pool.getPoolData().primaryAccountId;
30622
+ return activeId ? pool.getAccount(activeId) : void 0;
30623
+ }
30269
30624
  async listModels(_provider) {
30270
- this.deps.catalog.refreshIfNeeded();
30625
+ const activeAcc = this.getActiveAccount();
30626
+ this.deps.catalog.refreshIfNeeded(activeAcc?.id);
30271
30627
  const cfg = this.deps.getConfig();
30272
- const { entries, dropped } = sanitizeCatalogEntries(this.deps.catalog.get().models);
30628
+ const cat = this.deps.catalog.get(activeAcc?.id);
30629
+ let models = cat.models;
30630
+ if (!activeAcc || !accountHasArgon(activeAcc, this.deps.pool, cat)) models = models.filter((m) => !isArgonModel(m.id));
30631
+ const { entries, dropped } = sanitizeCatalogEntries(models);
30273
30632
  if (dropped.length > 0) this.warnOnce("catalog-dupes", "model catalog contained duplicate ids [" + dropped.join(", ") + "] — kept first occurrence so DSH does not drop the whole provider group (INVALID_CATALOG)");
30274
30633
  const hidden = new Set(cfg.hiddenModels.map((s) => s.trim()));
30275
30634
  return entries.filter((m) => !hidden.has(m.id)).map((m) => ({
@@ -30280,8 +30639,9 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30280
30639
  }));
30281
30640
  }
30282
30641
  async resolveModel(_provider, model, _signal) {
30642
+ const activeAcc = this.getActiveAccount();
30283
30643
  const cfg = this.deps.getConfig();
30284
- const cat = this.deps.catalog.get();
30644
+ const cat = this.deps.catalog.get(activeAcc?.id);
30285
30645
  const rawModel = typeof model === "string" ? model.trim() : "";
30286
30646
  const cleanId = rawModel !== "" ? rawModel : cfg.defaultModel;
30287
30647
  const entry = findEntry(cat, cleanId);
@@ -30411,7 +30771,7 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30411
30771
  }
30412
30772
  if (rec !== void 0) {
30413
30773
  this.deps.runs.forget(continuation.runId);
30414
- this.warnOnce("dead-continuation:" + continuation.runId, "agy run " + continuation.runId + " settled in " + (deadFailure?.code ?? "") + " — retrying this step on another account instead of replaying it");
30774
+ this.warnOnce("dead-continuation:" + continuation.runId, "agy run " + continuation.runId + " settled in " + (deadFailure?.code ?? "") + " — retrying this step instead of replaying it" + (deadFailure?.code === Err.NETWORK && this.sharesSingleEgress() ? " on the same account (shared egress)" : " on another account"));
30415
30775
  } else this.warnOnce("stale-continuation:" + continuation.runId, "agy run " + continuation.runId + " is no longer available in memory (server restarted?) — falling back to fresh turn prompt assembly");
30416
30776
  }
30417
30777
  const catalog = this.deps.catalog.get();
@@ -30467,18 +30827,24 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30467
30827
  if (model === "" && activeModel !== "" && !isAux && sessionKey !== "") this.deps.syncSessionModel?.(sessionKey, activeModel, options.reasoningEffort ? String(options.reasoningEffort) : void 0);
30468
30828
  let account;
30469
30829
  if (this.deps.pool && !isAux && sessionKey !== "") {
30470
- const prefix = `${sessionKey}:`;
30471
- let mostRecentAccount;
30472
- let latestUpdated = -1;
30473
- for (const [k, b] of Object.entries(this.deps.store.all())) if (k.startsWith(prefix) && b.updatedAt > latestUpdated) {
30474
- const boundId = k.slice(prefix.length);
30475
- const boundAcc = this.deps.pool.getAccount(boundId);
30476
- if (boundAcc) {
30477
- mostRecentAccount = boundAcc;
30478
- latestUpdated = b.updatedAt;
30479
- } else this.deps.store.delete(k);
30480
- }
30481
- if (mostRecentAccount && this.deps.pool.isAccountEligible(mostRecentAccount, family)) account = mostRecentAccount;
30830
+ if (process.platform === "win32" && cfg.parallelSameAccount) {
30831
+ const poolActive = this.deps.pool.selectAccount(family);
30832
+ if (poolActive) account = poolActive;
30833
+ }
30834
+ if (!account) {
30835
+ const prefix = `${sessionKey}:`;
30836
+ let mostRecentAccount;
30837
+ let latestUpdated = -1;
30838
+ for (const [k, b] of Object.entries(this.deps.store.all())) if (k.startsWith(prefix) && b.updatedAt > latestUpdated) {
30839
+ const boundId = k.slice(prefix.length);
30840
+ const boundAcc = this.deps.pool.getAccount(boundId);
30841
+ if (boundAcc) {
30842
+ mostRecentAccount = boundAcc;
30843
+ latestUpdated = b.updatedAt;
30844
+ } else this.deps.store.delete(k);
30845
+ }
30846
+ if (mostRecentAccount && this.deps.pool.isAccountEligible(mostRecentAccount, family)) account = mostRecentAccount;
30847
+ }
30482
30848
  }
30483
30849
  if (!account && this.deps.pool) account = this.deps.pool.selectAccount(family) ?? void 0;
30484
30850
  if (this.deps.pool && this.deps.pool.getAccounts().length > 0 && !account) {
@@ -30560,9 +30926,13 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30560
30926
  if (sessionAccountKey !== "") this.deps.store.delete(sessionAccountKey);
30561
30927
  binding = void 0;
30562
30928
  }
30563
- if (!isAux && binding !== void 0 && messages.length < binding.lastMessageCount) {
30929
+ const isCompacted = binding !== void 0 && (messages.length <= 4 && binding.lastMessageCount >= 5 || binding.lastMessageCount > 10 && messages.length < Math.floor(binding.lastMessageCount * .8) || binding.lastMessageCount <= 10 && messages.length < binding.lastMessageCount - 2);
30930
+ if (!isAux && binding !== void 0 && isCompacted) {
30564
30931
  if (sessionAccountKey !== "") this.deps.store.delete(sessionAccountKey);
30565
30932
  binding = void 0;
30933
+ } else if (binding !== void 0 && messages.length < binding.lastMessageCount) {
30934
+ binding.lastMessageCount = messages.length;
30935
+ if (sessionAccountKey !== "") this.deps.store.set(sessionAccountKey, binding);
30566
30936
  }
30567
30937
  let prompt = "";
30568
30938
  if (isAux && options.purpose === "compaction") {
@@ -30773,6 +31143,8 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30773
31143
  const dispatchHardCapMs = cfg.dispatchTimeoutMinutes > 0 ? cfg.dispatchTimeoutMinutes * 6e4 : 0;
30774
31144
  const dispatchStartedAt = Date.now();
30775
31145
  const DISPATCH_HEARTBEAT_MS = 12e4;
31146
+ /** First session-visible "waiting for the slot" notice (then ×3 each time). */
31147
+ const DISPATCH_NOTICE_MS = 2e4;
30776
31148
  let dispatchHeartbeat = null;
30777
31149
  const stopDispatchHeartbeat = () => {
30778
31150
  if (dispatchHeartbeat !== null) {
@@ -30809,9 +31181,22 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30809
31181
  let granted = false;
30810
31182
  let dispatchBackoffMs = 2e3;
30811
31183
  const maxDispatchAttempts = 1 + Math.max(0, cfg.dispatchRetries);
31184
+ let abandonedDuringDispatch = false;
31185
+ let dispatchSettled = false;
30812
31186
  try {
30813
31187
  for (let attempt = 1; attempt <= maxDispatchAttempts && !granted; attempt++) try {
30814
- release = await this.deps.acquire(options.signal);
31188
+ const pendingConcurrencySlot = this.deps.acquire(options.signal);
31189
+ pendingConcurrencySlot.then((late) => {
31190
+ if (abandonedDuringDispatch) try {
31191
+ late();
31192
+ } catch {}
31193
+ }, () => {});
31194
+ release = yield* awaitWithNotices(pendingConcurrencySlot, {
31195
+ intervalMs: DISPATCH_NOTICE_MS,
31196
+ escalate: 3,
31197
+ maxIntervalMs: 54e4,
31198
+ notice: (waitedMs) => "[agy] 正在等待 agy 凭据槽:另一个会话正在运行(已等待 " + Math.max(1, Math.round(waitedMs / 6e4)) + " 分钟)。同一台机器同一时刻只能有一个 agy 身份占用系统凭据槽;稍候会自动开始,按 Stop 可取消。\n[agy] waiting for the agy credential slot: another session is running (" + Math.max(1, Math.round(waitedMs / 6e4)) + "m elapsed)."
31199
+ });
30815
31200
  startDispatchHeartbeat();
30816
31201
  slotLease = process.env.NODE_TEST_CONTEXT === void 0 ? await acquireCredentialSlot(account ?? void 0, this.deps.pool, {
30817
31202
  baseDir: this.deps.pool?.dir,
@@ -30829,6 +31214,11 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30829
31214
  if (e instanceof WaitTimeoutError) {
30830
31215
  if (attempt < maxDispatchAttempts) {
30831
31216
  this.deps.log?.("agy dispatch attempt " + attempt + "/" + maxDispatchAttempts + " timed out after " + cfg.dispatchTimeoutMinutes + "m waiting for the credential slot — releasing and retrying in " + Math.round(dispatchBackoffMs / 1e3) + "s");
31217
+ yield {
31218
+ type: "reasoning-delta",
31219
+ index: -1,
31220
+ text: "[agy] 等待 agy 凭据槽已超过 " + cfg.dispatchTimeoutMinutes + " 分钟,正在重新排队(" + Math.round(dispatchBackoffMs / 1e3) + " 秒后重试 " + (attempt + 1) + "/" + maxDispatchAttempts + ")。\n[agy] credential-slot wait exceeded " + cfg.dispatchTimeoutMinutes + "m — re-queueing (attempt " + (attempt + 1) + "/" + maxDispatchAttempts + ").\n"
31221
+ };
30832
31222
  stopDispatchHeartbeat();
30833
31223
  await dispatchSleep(dispatchBackoffMs);
30834
31224
  dispatchBackoffMs = Math.min(dispatchBackoffMs * 2, 15e3);
@@ -30839,18 +31229,30 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30839
31229
  const detail = e instanceof SlotUnavailableError ? e.message : brief(String(e));
30840
31230
  throw new LlmError(detail, Err.AGY_ERROR);
30841
31231
  }
31232
+ dispatchSettled = true;
30842
31233
  } catch (e) {
31234
+ dispatchSettled = true;
30843
31235
  stopDispatchHeartbeat();
30844
31236
  this.clearInFlightPrompt(sessionKey, prompt);
30845
31237
  throw e;
31238
+ } finally {
31239
+ if (!dispatchSettled) abandonedDuringDispatch = true;
30846
31240
  }
30847
31241
  stopDispatchHeartbeat();
30848
- const releaseOnce = () => {
30849
- if (released) return;
30850
- released = true;
31242
+ let slotHandoff = null;
31243
+ const releaseSlotLease = () => {
31244
+ if (slotHandoff !== null) {
31245
+ slotHandoff.dispose();
31246
+ return;
31247
+ }
30851
31248
  try {
30852
31249
  slotLease?.release();
30853
31250
  } catch {}
31251
+ };
31252
+ const releaseOnce = () => {
31253
+ if (released) return;
31254
+ released = true;
31255
+ releaseSlotLease();
30854
31256
  release?.();
30855
31257
  release = null;
30856
31258
  };
@@ -30864,6 +31266,7 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30864
31266
  const effectiveProxy = account?.proxyUrl || cfg.proxyUrl || detectSystemProxy() || void 0;
30865
31267
  const env = {
30866
31268
  ...process.env,
31269
+ ...sessionKey === "" ? {} : { DSH_MCP_SESSION: sessionKey },
30867
31270
  ...cfg.disableTelemetry ? {
30868
31271
  DO_NOT_TRACK: "1",
30869
31272
  DISABLE_TELEMETRY: "1",
@@ -30897,6 +31300,14 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30897
31300
  }
30898
31301
  this.lastAccountSpawnTime.set(account.id, Date.now());
30899
31302
  }
31303
+ if (slotLease !== void 0) slotHandoff = armSlotHandoff({
31304
+ release: () => {
31305
+ try {
31306
+ slotLease?.release();
31307
+ } catch {}
31308
+ },
31309
+ log: (msg) => this.deps.log?.(msg)
31310
+ });
30900
31311
  proc = startAgyProcess({
30901
31312
  bin,
30902
31313
  args,
@@ -30909,6 +31320,7 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30909
31320
  stderrKeepalive: true,
30910
31321
  onLine: (line) => {
30911
31322
  sawAnyEvent = true;
31323
+ slotHandoff?.onFirstEvent();
30912
31324
  lastOutputAt = Date.now();
30913
31325
  startToolHeartbeat();
30914
31326
  applyToolIdleBudget();
@@ -31048,12 +31460,17 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
31048
31460
  message: "agy produced no result event (" + parser.stats.garbage + " unparseable lines)"
31049
31461
  };
31050
31462
  }
31463
+ if (failure !== null) {
31464
+ if (failure.code === Err.NETWORK) failure.retryAfterMs = networkRetryDelayMs(this.noteNetworkFailure(sessionKey));
31465
+ else this.clearNetworkStreak(sessionKey);
31466
+ }
31051
31467
  rec.settle(failure);
31052
31468
  const superseded = this.activeRuns.get(sessionKey) !== void 0 && this.activeRuns.get(sessionKey) !== rec;
31053
31469
  if (failure === null) {
31054
31470
  if (account) this.deps.pool?.recordSuccess(account.id, family);
31055
31471
  this.failoverBudget.delete(sessionKey);
31056
31472
  this.rateLimitFailover.delete(sessionKey);
31473
+ this.clearNetworkStreak(sessionKey);
31057
31474
  if (!isAux && sessionAccountKey !== "" && !superseded) {
31058
31475
  const finalId = binding !== void 0 ? binding.conversationId : conversationId;
31059
31476
  if (finalId) this.deps.store.set(sessionAccountKey, {
@@ -31068,7 +31485,8 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
31068
31485
  if (account && !outcome.aborted && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
31069
31486
  if (account && silentTimeout) this.deps.pool?.advanceAccount(account.id, family);
31070
31487
  if (account && failure.code === Err.ELIGIBILITY) this.deps.pool?.advanceAccount(account.id, family);
31071
- if (!outcome.aborted && failure.code !== Err.RATE_LIMIT && failure.code !== Err.OVERLOADED && (failure.code === Err.NETWORK || failure.code === Err.AUTH) && account) this.deps.pool?.advanceAccount(account.id, family);
31488
+ const sharedEgressNetwork = failure.code === Err.NETWORK && this.sharesSingleEgress();
31489
+ if (!outcome.aborted && failure.code !== Err.RATE_LIMIT && failure.code !== Err.OVERLOADED && !sharedEgressNetwork && (failure.code === Err.NETWORK || failure.code === Err.AUTH) && account) this.deps.pool?.advanceAccount(account.id, family);
31072
31490
  if (account && shouldMarkAuthRequired(failure.code, failure.message)) {
31073
31491
  const authoritative = isAuthoritativeAuthError(rawErrText + " " + runLogText);
31074
31492
  if (failure.code === Err.AUTH && !authoritative) this.deps.pool?.noteAuthFailure(account.id, failure.message);
@@ -31244,7 +31662,7 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
31244
31662
  }
31245
31663
  if (!mapper.isFinished && !abortedLocally && !signal?.aborted) {
31246
31664
  const f = rec.failureInfo;
31247
- if (f !== null) for (const ch of mapper.emitFailure(f.kind, f.code, f.message)) queue.push(ch);
31665
+ if (f !== null) for (const ch of mapper.emitFailure(f.kind, f.code, f.message, f.retryAfterMs)) queue.push(ch);
31248
31666
  else for (const ch of mapper.emitFailure("error", Err.INVALID_OUTPUT, "agy stream ended without a result event")) queue.push(ch);
31249
31667
  }
31250
31668
  } catch (err) {
@@ -32584,6 +33002,174 @@ var PoolAuthFlow = class {
32584
33002
  }
32585
33003
  };
32586
33004
  //#endregion
33005
+ //#region src/host/agy-usage.ts
33006
+ function num(v) {
33007
+ return typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : void 0;
33008
+ }
33009
+ function str(v) {
33010
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : void 0;
33011
+ }
33012
+ function normalizeBucket(raw) {
33013
+ const id = str(raw.id);
33014
+ if (id === void 0) return null;
33015
+ return {
33016
+ id,
33017
+ name: str(raw.name),
33018
+ window: (str(raw.window) ?? "").toLowerCase(),
33019
+ remainingFraction: num(raw.remaining_fraction ?? raw.remainingFraction),
33020
+ resetTime: str(raw.reset_time ?? raw.resetTime)
33021
+ };
33022
+ }
33023
+ /** Pull groups out of one `command_result` envelope. */
33024
+ function groupsFromCommandResult(parsed) {
33025
+ const command = parsed.command;
33026
+ if (command === void 0 || command.name !== "usage") return null;
33027
+ const rawGroups = command.data?.groups;
33028
+ if (!Array.isArray(rawGroups) || rawGroups.length === 0) return null;
33029
+ const groups = [];
33030
+ for (const g of rawGroups) {
33031
+ if (g === null || typeof g !== "object") continue;
33032
+ const group = g;
33033
+ const name = str(group.name);
33034
+ if (name === void 0) continue;
33035
+ const buckets = [];
33036
+ if (Array.isArray(group.buckets)) for (const b of group.buckets) {
33037
+ if (b === null || typeof b !== "object") continue;
33038
+ const bucket = normalizeBucket(b);
33039
+ if (bucket !== null) buckets.push(bucket);
33040
+ }
33041
+ groups.push({
33042
+ name,
33043
+ description: str(group.description),
33044
+ buckets
33045
+ });
33046
+ }
33047
+ return groups.length > 0 ? groups : null;
33048
+ }
33049
+ /**
33050
+ * Parse `agy -p /usage --output-format stream-json` stdout.
33051
+ *
33052
+ * Primary shape: one NDJSON line carrying a `command_result` event with
33053
+ * `command.name === "usage"`. Fallback: the plain-text table mode
33054
+ * (`<group>\t<window label>\t<pct>%\t<reset ISO>`), which the CLI prints when
33055
+ * run without `--output-format stream-json`.
33056
+ *
33057
+ * Returns null when nothing usable was found — callers keep their fallback data.
33058
+ */
33059
+ function parseAgyUsageOutput(stdout) {
33060
+ const lines = stdout.split(/\r?\n/);
33061
+ for (const line of lines) {
33062
+ const trimmed = line.trim();
33063
+ if (!trimmed.startsWith("{")) continue;
33064
+ let parsed;
33065
+ try {
33066
+ parsed = JSON.parse(trimmed);
33067
+ } catch {
33068
+ continue;
33069
+ }
33070
+ if (parsed === null || typeof parsed !== "object") continue;
33071
+ const obj = parsed;
33072
+ if (obj.event !== "command_result") continue;
33073
+ const groups = groupsFromCommandResult(obj);
33074
+ if (groups !== null) return groups;
33075
+ }
33076
+ return parseUsageTextTable(stdout);
33077
+ }
33078
+ /** `<group>\t<label>\t<pct>%\t<reset>` rows; weekly labels carry "week". */
33079
+ function parseUsageTextTable(text) {
33080
+ const byName = /* @__PURE__ */ new Map();
33081
+ for (const raw of text.split(/\r?\n/)) {
33082
+ const line = raw.trim();
33083
+ if (line === "") continue;
33084
+ const cells = line.split(" ");
33085
+ if (cells.length < 3) continue;
33086
+ const [groupName, label, pctCell] = cells;
33087
+ const pct = Number.parseFloat(pctCell.replace("%", "").trim());
33088
+ if (!Number.isFinite(pct)) continue;
33089
+ const window = /week/i.test(label) ? "weekly" : "5h";
33090
+ let group = byName.get(groupName);
33091
+ if (group === void 0) {
33092
+ group = {
33093
+ name: groupName,
33094
+ buckets: []
33095
+ };
33096
+ byName.set(groupName, group);
33097
+ }
33098
+ group.buckets.push({
33099
+ id: `${groupName.toLowerCase().replace(/\s+/g, "-")}-${window}`,
33100
+ name: label,
33101
+ window,
33102
+ remainingFraction: Math.min(1, Math.max(0, pct / 100)),
33103
+ resetTime: cells[3]?.trim() || void 0
33104
+ });
33105
+ }
33106
+ return byName.size > 0 ? [...byName.values()] : null;
33107
+ }
33108
+ /**
33109
+ * Map CLI groups onto pool families.
33110
+ *
33111
+ * Order matters: the Argon group's description mentions "Gemini quota bucket",
33112
+ * so the Argon gate must run before the generic Gemini one or the two groups
33113
+ * collapse into `google` and overwrite each other.
33114
+ */
33115
+ function usageGroupsToFamilies(groups, now = Date.now()) {
33116
+ const out = {};
33117
+ if (groups === null) return out;
33118
+ for (const group of groups) {
33119
+ const name = group.name.toLowerCase();
33120
+ const desc = (group.description ?? "").toLowerCase();
33121
+ const hasArgonBucket = group.buckets.some((b) => b.id.toLowerCase().includes("argon"));
33122
+ const isArgon = name.includes("argon") || desc.includes("argon") || hasArgonBucket;
33123
+ const isGoogle = !isArgon && (name.includes("gemini") || desc.includes("gemini"));
33124
+ const is3P = name.includes("claude") || name.includes("gpt") || desc.includes("claude") || desc.includes("gpt");
33125
+ const families = isArgon ? ["argon"] : isGoogle ? ["google"] : is3P ? ["anthropic", "openai"] : [];
33126
+ if (families.length === 0) continue;
33127
+ let fiveHourFrac;
33128
+ let fiveHourReset;
33129
+ let weeklyFrac;
33130
+ let weeklyReset;
33131
+ for (const bucket of group.buckets) {
33132
+ const w = (bucket.window || bucket.id).toLowerCase();
33133
+ if (w.includes("week")) {
33134
+ weeklyFrac = bucket.remainingFraction;
33135
+ weeklyReset = bucket.resetTime;
33136
+ } else if (w.includes("5h") || w.includes("five")) {
33137
+ fiveHourFrac = bucket.remainingFraction;
33138
+ fiveHourReset = bucket.resetTime;
33139
+ }
33140
+ }
33141
+ for (const family of families) out[family] = {
33142
+ remainingFraction: fiveHourFrac,
33143
+ resetTime: fiveHourReset,
33144
+ weeklyFraction: weeklyFrac,
33145
+ weeklyResetTime: weeklyReset,
33146
+ description: group.description,
33147
+ updatedAt: now
33148
+ };
33149
+ }
33150
+ return out;
33151
+ }
33152
+ /** Spawn one `agy -p /usage` and parse its groups. Never throws, never rejects. */
33153
+ async function readAgyUsage(opts) {
33154
+ try {
33155
+ return parseAgyUsageOutput((await startAgyProcess({
33156
+ bin: opts.bin,
33157
+ args: [
33158
+ "-p",
33159
+ "/usage",
33160
+ "--output-format",
33161
+ "stream-json"
33162
+ ],
33163
+ timeoutMs: opts.timeoutMs ?? 2e4,
33164
+ signal: opts.signal,
33165
+ env: opts.env,
33166
+ preemptAutoUpdate: opts.preemptAutoUpdate
33167
+ }).outcome).stdout);
33168
+ } catch {
33169
+ return null;
33170
+ }
33171
+ }
33172
+ //#endregion
32587
33173
  //#region src/host/quota.ts
32588
33174
  /**
32589
33175
  * When the quota-summary endpoint transiently fails, per-model fallback
@@ -32597,6 +33183,56 @@ function mergeFallbackFamilyQuota(prev, fallback) {
32597
33183
  if (prev && typeof prev.remainingFraction === "number") return prev;
32598
33184
  return fallback;
32599
33185
  }
33186
+ /**
33187
+ * True when any returned group is the dedicated Argon bucket.
33188
+ *
33189
+ * The backend stops emitting that group once the plan folds Argon into the
33190
+ * larger Gemini bucket (Ultra). From then on the family is governed by the
33191
+ * shared bucket, so a cooldown recorded against the retired pool — e.g. a 429
33192
+ * taken minutes before an upgrade — must not keep blocking the family for days.
33193
+ */
33194
+ function summaryHasArgonGroup(groups) {
33195
+ return groups.some((group) => {
33196
+ const fields = [group.displayName, group.description];
33197
+ for (const bucket of group.buckets ?? []) fields.push(bucket.bucketId, bucket.displayName);
33198
+ return fields.some((value) => typeof value === "string" && value.toLowerCase().includes("argon"));
33199
+ });
33200
+ }
33201
+ /**
33202
+ * Decide whether a family's cooldown belongs to a quota bucket that no longer exists.
33203
+ *
33204
+ * A cooldown is recorded against the pool that produced the 429. A plan change can
33205
+ * retire that pool — a Pro → Ultra upgrade folds Argon into the larger Gemini bucket
33206
+ * and the backend stops emitting the `Gemini 4 Argon` group — leaving a cooldown that
33207
+ * blocks the family until a reset that no longer applies:
33208
+ *
33209
+ * All Antigravity accounts are unavailable: no account can serve argon right now
33210
+ * (1 in cooldown) (earliest reset in 345338s)
33211
+ *
33212
+ * `groupSeenBefore` is what keeps this from firing on accounts that never had the
33213
+ * group at all (free tier, or any plan without an Argon bucket): their argon
33214
+ * cooldowns come from genuine 429s and must survive.
33215
+ */
33216
+ function shouldReleaseOrphanedCooldown(state) {
33217
+ return state.summaryAuthoritative && !state.groupPresent && state.groupSeenBefore && state.hasCooldown;
33218
+ }
33219
+ /**
33220
+ * Pick the family fraction when this poll's summary carried no group for it.
33221
+ *
33222
+ * An *authoritative* summary (at least one group returned) means the absence is
33223
+ * real: the family lost its own bucket — a Pro → Ultra upgrade folds the
33224
+ * separate Argon pool into the larger Gemini bucket, and the backend then stops
33225
+ * reporting a `Gemini 4 Argon` group entirely while `fetchAvailableModels`
33226
+ * keeps listing the argon models with fresh per-model fractions. Keeping the
33227
+ * last-known-good row there would freeze a dead window on screen (0%, a reset
33228
+ * time from the previous tier).
33229
+ *
33230
+ * A failed or empty summary is the case `mergeFallbackFamilyQuota` protects:
33231
+ * nothing authoritative arrived, so the previous complete value stays.
33232
+ */
33233
+ function pickFamilyQuota(prev, fresh, summaryAuthoritative) {
33234
+ return summaryAuthoritative ? fresh : mergeFallbackFamilyQuota(prev, fresh);
33235
+ }
32600
33236
  function detectEmailFromAgyLogs(homeDir) {
32601
33237
  const logDir = join(homeDir, ".gemini", "antigravity-cli", "log");
32602
33238
  if (!existsSync(logDir)) return void 0;
@@ -32615,9 +33251,25 @@ function detectEmailFromAgyLogs(homeDir) {
32615
33251
  } catch {}
32616
33252
  }
32617
33253
  const OAUTH_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo";
33254
+ /** Used when the installed agy version is unknown or older than AGY_UA_MIN_VERSION. */
33255
+ const AGY_UA_FALLBACK_VERSION = "1.4.5";
33256
+ function semverAtLeast(candidate, floor) {
33257
+ if (!candidate) return false;
33258
+ const a = candidate.match(/^(\d+)\.(\d+)\.(\d+)$/);
33259
+ const b = floor.match(/^(\d+)\.(\d+)\.(\d+)$/);
33260
+ if (!a || !b) return false;
33261
+ for (let i = 1; i <= 3; i += 1) {
33262
+ const x = Number(a[i]);
33263
+ const y = Number(b[i]);
33264
+ if (x !== y) return x > y;
33265
+ }
33266
+ return true;
33267
+ }
32618
33268
  /** Platform-faithful agy User-Agent (hardcoding darwin/arm64 on Windows is a bad fingerprint). */
32619
- function agyUserAgent(version = "2.24.0") {
32620
- return `antigravity/${version} ${process.platform === "win32" ? "windows" : process.platform}/${process.arch === "x64" ? "amd64" : process.arch}`;
33269
+ function agyUserAgent(version) {
33270
+ const os = process.platform === "win32" ? "windows" : process.platform;
33271
+ const arch = process.arch === "x64" ? "amd64" : process.arch;
33272
+ return `antigravity/cli/${semverAtLeast(version, "1.3.0") ? version : AGY_UA_FALLBACK_VERSION} (aidev_client; os_type=${os}; arch=${arch}; auth_method=consumer)`;
32621
33273
  }
32622
33274
  /** Coerce an expiry value (ISO string, epoch millis, or epoch seconds) to ms. */
32623
33275
  function parseExpiryMs(value) {
@@ -32663,6 +33315,49 @@ function isStale5hBucket(remainingFraction, resetTime, now = Date.now()) {
32663
33315
  return resetMs > 162e5 && resetMs < 1872e4;
32664
33316
  }
32665
33317
  /**
33318
+ * True only when two token snapshots belong to the SAME OAuth grant.
33319
+ *
33320
+ * Used to decide whether the machine credential slot really holds the account
33321
+ * we are about to poll through the `agy` CLI. A refresh token identifies a
33322
+ * grant; an access token rotates, so it is only consulted when neither side
33323
+ * carries a refresh token. Mismatched shapes count as "not proven" — the
33324
+ * caller then takes the safe path instead of misattributing a quota row.
33325
+ */
33326
+ function sameCredentialGrant(a, b) {
33327
+ const fingerprint = (token) => {
33328
+ if (token?.refreshToken) return "r:" + token.refreshToken;
33329
+ if (token?.accessToken) return "a:" + token.accessToken;
33330
+ return null;
33331
+ };
33332
+ const fa = fingerprint(a);
33333
+ return fa !== null && fa === fingerprint(b);
33334
+ }
33335
+ /** How close two ISO reset stamps must be to name the SAME quota window. */
33336
+ const QUOTA_WINDOW_MATCH_TOLERANCE_MS = 3e5;
33337
+ /**
33338
+ * Organic stagger delay with randomized jitter for multi-account sequential polling.
33339
+ * Prevents rigid periodic cadence against Google Cloud Code endpoints (default: 2000~3500ms).
33340
+ */
33341
+ function staggerJitterMs(baseMs = 2e3, maxJitterMs = 1500) {
33342
+ return baseMs + Math.floor(Math.random() * maxJitterMs);
33343
+ }
33344
+ /**
33345
+ * True when two reset stamps point at the same quota window.
33346
+ *
33347
+ * `fetchAvailableModels` returns a per-model `quotaInfo` with NO window label:
33348
+ * for some accounts it carries the 5-hour bucket, for others the WEEKLY bucket
33349
+ * (live: daoxzeex@gmail.com reports the weekly fraction + weekly reset on every
33350
+ * Gemini model). The only reliable discriminator is the reset instant — it
33351
+ * matches exactly one of the `retrieveUserQuotaSummary` buckets.
33352
+ */
33353
+ function sameQuotaWindow(a, b, toleranceMs = QUOTA_WINDOW_MATCH_TOLERANCE_MS) {
33354
+ if (!a || !b) return false;
33355
+ const ta = Date.parse(a);
33356
+ const tb = Date.parse(b);
33357
+ if (!Number.isFinite(ta) || !Number.isFinite(tb)) return false;
33358
+ return Math.abs(ta - tb) <= toleranceMs;
33359
+ }
33360
+ /**
32666
33361
  * Parse a go-keyring payload (optionally "go-keyring-base64:" prefixed) into
32667
33362
  * a normalized StoredToken. Shared by the macOS and Linux keyring readers.
32668
33363
  */
@@ -32828,6 +33523,8 @@ async function readWindowsCredentialToken() {
32828
33523
  var QuotaService = class {
32829
33524
  pool;
32830
33525
  globalProxy;
33526
+ agyVersion;
33527
+ cliDeps;
32831
33528
  preferredEndpointIndex = 0;
32832
33529
  /** accountId -> in-flight getValidAccessToken attempt (dedup). */
32833
33530
  tokenRefreshInFlight = /* @__PURE__ */ new Map();
@@ -32835,9 +33532,58 @@ var QuotaService = class {
32835
33532
  * @param globalProxy resolves the plugin-wide proxy URL; used when the
32836
33533
  * account has no dedicated proxyUrl. Injected so tests stay network-free.
32837
33534
  */
32838
- constructor(pool, globalProxy = () => "") {
33535
+ constructor(pool, globalProxy = () => "", agyVersion = () => null, cliDeps = {}) {
32839
33536
  this.pool = pool;
32840
33537
  this.globalProxy = globalProxy;
33538
+ this.agyVersion = agyVersion;
33539
+ this.cliDeps = cliDeps;
33540
+ }
33541
+ /**
33542
+ * One `agy -p /usage` spawn, mapped onto pool families.
33543
+ *
33544
+ * SCOPE: only the account that owns the OS credential store. `agy` resolves
33545
+ * its credential from the platform keystore (Windows Credential Manager,
33546
+ * macOS Keychain, Linux Secret Service) BEFORE the per-HOME token file, and
33547
+ * that store is a single per-user entry — so the CLI can only ever answer for
33548
+ * one identity no matter which HOME we hand it. Isolated pool accounts never
33549
+ * own it, so polling them through the CLI filed the owner's numbers under
33550
+ * their rows (2026-10-26: four pool rows showing one account's quota with
33551
+ * identical reset instants).
33552
+ *
33553
+ * That single-owner rule keeps the CLI usage check strictly attributed to the
33554
+ * slot owner: isolated accounts fall back to the REST path, which signs with
33555
+ * each account's own token and reports authentic quotas (including the dedicated
33556
+ * Gemini 4 Argon group for Pro/Ultra plans via the agy User-Agent).
33557
+ */
33558
+ async readCliUsageFamilies(account) {
33559
+ const bin = this.cliDeps.bin?.();
33560
+ if (!bin) return null;
33561
+ if (account.dir && !account.systemHome) return null;
33562
+ if (!await this.cliSlotHoldsAccount(account)) return null;
33563
+ const groups = await readAgyUsage({
33564
+ bin,
33565
+ env: {
33566
+ ...process.env,
33567
+ ...account.dir ? isolatedHomeEnv(account.dir) : {},
33568
+ ...proxyEnvFor(this.proxyFor(account))
33569
+ },
33570
+ timeoutMs: 2e4,
33571
+ preemptAutoUpdate: this.cliDeps.preemptAutoUpdate?.()
33572
+ });
33573
+ return groups === null ? null : usageGroupsToFamilies(groups);
33574
+ }
33575
+ /**
33576
+ * True when the machine credential slot provably holds this account's grant.
33577
+ *
33578
+ * This gates the CLI quota read, which can only ever see the slot owner.
33579
+ * Conservative by design: when the two snapshots cannot be shown to be the
33580
+ * same grant, the caller falls back to REST rather than risk attributing one
33581
+ * account's quota to another.
33582
+ */
33583
+ async cliSlotHoldsAccount(account) {
33584
+ const slot = await this.readSystemKeychainToken();
33585
+ if (slot === null) return false;
33586
+ return sameCredentialGrant(slot, await this.getStoredToken(account));
32841
33587
  }
32842
33588
  /** Dedicated account proxy wins, then the global config proxy, then detected system proxy. */
32843
33589
  proxyFor(account) {
@@ -33002,7 +33748,7 @@ var QuotaService = class {
33002
33748
  headers: {
33003
33749
  Authorization: `Bearer ${accessToken}`,
33004
33750
  "Content-Type": "application/json",
33005
- "User-Agent": agyUserAgent()
33751
+ "User-Agent": agyUserAgent(this.agyVersion())
33006
33752
  },
33007
33753
  body: JSON.stringify({})
33008
33754
  }, proxyUrl);
@@ -33052,7 +33798,7 @@ var QuotaService = class {
33052
33798
  headers: {
33053
33799
  Authorization: `Bearer ${accessToken}`,
33054
33800
  "Content-Type": "application/json",
33055
- "User-Agent": agyUserAgent()
33801
+ "User-Agent": agyUserAgent(this.agyVersion())
33056
33802
  },
33057
33803
  body: JSON.stringify({})
33058
33804
  }, proxyUrl);
@@ -33085,7 +33831,7 @@ var QuotaService = class {
33085
33831
  headers: {
33086
33832
  Authorization: `Bearer ${accessToken}`,
33087
33833
  "Content-Type": "application/json",
33088
- "User-Agent": agyUserAgent()
33834
+ "User-Agent": agyUserAgent(this.agyVersion())
33089
33835
  },
33090
33836
  body: JSON.stringify({})
33091
33837
  }, proxyUrl);
@@ -33130,6 +33876,20 @@ var QuotaService = class {
33130
33876
  const siblingEmails = new Set(this.pool.getAccounts().filter((a) => a.id !== account.id && a.email).map((a) => a.email));
33131
33877
  if (detected && !siblingEmails.has(detected)) email = detected;
33132
33878
  }
33879
+ if (this.cliDeps.viaCli?.() !== false) {
33880
+ const cliFamilies = await this.readCliUsageFamilies(account);
33881
+ if (cliFamilies !== null && Object.keys(cliFamilies).length > 0) {
33882
+ if (account.authRequired) this.pool.clearAuthRequired(account.id);
33883
+ if (cliFamilies.argon) this.pool.noteDedicatedGroup(account.id, "argon");
33884
+ else {
33885
+ this.pool.clearQuotaFamily(account.id, "argon");
33886
+ this.pool.forgetDedicatedGroup(account.id, "argon");
33887
+ this.pool.clearCooldown(account.id, "argon");
33888
+ }
33889
+ this.pool.updateAccountQuotas(account.id, cliFamilies, email);
33890
+ return cliFamilies;
33891
+ }
33892
+ }
33133
33893
  const accessToken = await this.getValidAccessToken(account);
33134
33894
  if (!accessToken) return null;
33135
33895
  if (force) {
@@ -33241,24 +34001,32 @@ var QuotaService = class {
33241
34001
  });
33242
34002
  const isStale = stale5hFamilies.has(fam);
33243
34003
  if (!familyQuotas[fam]) {
33244
- familyQuotas[fam] = mergeFallbackFamilyQuota(account.quotas[fam], {
34004
+ familyQuotas[fam] = pickFamilyQuota(account.quotas[fam], {
33245
34005
  remainingFraction: remaining,
33246
34006
  resetTime,
33247
34007
  updatedAt: now
33248
- });
34008
+ }, summaryGroups.length > 0);
33249
34009
  if (fam === "argon" && familyQuotas[fam].weeklyFraction === void 0) {
33250
34010
  familyQuotas[fam].weeklyFraction = remaining;
33251
34011
  familyQuotas[fam].weeklyResetTime = resetTime;
33252
34012
  }
33253
34013
  } else {
33254
- if (remaining < (familyQuotas[fam].remainingFraction ?? 1) || isStale) {
33255
- familyQuotas[fam].remainingFraction = remaining;
33256
- familyQuotas[fam].resetTime = resetTime;
34014
+ const familyQuota = familyQuotas[fam];
34015
+ if (sameQuotaWindow(resetTime, familyQuota.weeklyResetTime) && !sameQuotaWindow(resetTime, familyQuota.resetTime)) {
34016
+ if (remaining < (familyQuota.weeklyFraction ?? 1)) {
34017
+ familyQuota.weeklyFraction = remaining;
34018
+ familyQuota.weeklyResetTime = resetTime;
34019
+ }
34020
+ continue;
34021
+ }
34022
+ if (remaining < (familyQuota.remainingFraction ?? 1) || isStale) {
34023
+ familyQuota.remainingFraction = remaining;
34024
+ familyQuota.resetTime = resetTime;
33257
34025
  stale5hFamilies.delete(fam);
33258
34026
  }
33259
- if (fam === "argon" && familyQuotas[fam].weeklyFraction === void 0) {
33260
- familyQuotas[fam].weeklyFraction = remaining;
33261
- familyQuotas[fam].weeklyResetTime = resetTime;
34027
+ if (fam === "argon" && familyQuota.weeklyFraction === void 0) {
34028
+ familyQuota.weeklyFraction = remaining;
34029
+ familyQuota.weeklyResetTime = resetTime;
33262
34030
  }
33263
34031
  }
33264
34032
  }
@@ -33266,6 +34034,21 @@ var QuotaService = class {
33266
34034
  const fam = famKey;
33267
34035
  if (familyQuotas[fam]) familyQuotas[fam].models = list;
33268
34036
  }
34037
+ if (summaryGroups.length > 0) {
34038
+ if (summaryHasArgonGroup(summaryGroups) || familyQuotas.argon) this.pool.noteDedicatedGroup(account.id, "argon");
34039
+ else {
34040
+ this.pool.clearQuotaFamily(account.id, "argon");
34041
+ if (shouldReleaseOrphanedCooldown({
34042
+ summaryAuthoritative: true,
34043
+ groupPresent: false,
34044
+ groupSeenBefore: this.pool.hasDedicatedGroup(account.id, "argon"),
34045
+ hasCooldown: account.cooldowns?.argon !== void 0
34046
+ })) {
34047
+ this.pool.forgetDedicatedGroup(account.id, "argon");
34048
+ this.pool.clearCooldown(account.id, "argon");
34049
+ }
34050
+ }
34051
+ }
33269
34052
  this.pool.updateAccountQuotas(account.id, familyQuotas, email);
33270
34053
  return familyQuotas;
33271
34054
  }
@@ -33303,11 +34086,454 @@ var QuotaService = class {
33303
34086
  if (detected && detected !== acc.email) this.pool.resetAccountIdentity(acc.id, detected);
33304
34087
  }
33305
34088
  accounts = accounts.filter((acc) => shouldPollAccount(acc) || needsAuthReverify(acc));
34089
+ for (let i = 0; i < accounts.length; i++) {
34090
+ const acc = accounts[i];
34091
+ await this.refreshAccountQuota(acc, false).catch(() => null);
34092
+ if (i < accounts.length - 1) await new Promise((resolve) => setTimeout(resolve, staggerJitterMs()));
34093
+ }
34094
+ return;
33306
34095
  }
33307
34096
  await Promise.allSettled(accounts.map((acc) => this.refreshAccountQuota(acc, force)));
33308
34097
  }
33309
34098
  };
33310
34099
  //#endregion
34100
+ //#region src/host/warmup.ts
34101
+ /** Canonical model route resolution for upstream Google Cloud Code PA endpoints. */
34102
+ function toCanonicalWarmupModel(model) {
34103
+ const clean = model.trim().toLowerCase();
34104
+ if (clean === "gemini-3-flash") return "gemini-3.8-flash-tiered";
34105
+ if (/^gemini-3\.\d+-flash$/.test(clean)) return clean + "-tiered";
34106
+ if (clean.includes("argon")) return "gemini-4-argon";
34107
+ return model.trim();
34108
+ }
34109
+ /** Select target model for a given family honoring user monitored_models preferences. */
34110
+ function pickWarmupModelForFamily(family, monitoredModels = []) {
34111
+ if (family === "anthropic") return monitoredModels.find((m) => m.toLowerCase().includes("claude")) ?? "claude-sonnet-4-6";
34112
+ if (family === "openai") return monitoredModels.find((m) => m.toLowerCase().includes("gpt")) ?? "gpt-oss-120b-medium";
34113
+ if (family === "argon") return "gemini-4-argon";
34114
+ return monitoredModels.find((m) => m.toLowerCase().includes("gemini") && !m.toLowerCase().includes("argon")) ?? "gemini-3.8-flash";
34115
+ }
34116
+ /**
34117
+ * `loadCodeAssist` returns `cloudaicompanionProject` either as a bare string or
34118
+ * as an object `{ id }` (the official clients and this repo's own OAuth fixture
34119
+ * both show the object form). Normalize both; an unresolved project must stay
34120
+ * empty instead of falling back to a shared project id — the backend rejects
34121
+ * foreign project ids with 403 "Caller does not have required permission".
34122
+ */
34123
+ function extractWarmupProjectId(data) {
34124
+ const project = data?.cloudaicompanionProject;
34125
+ if (typeof project === "string" && project.trim() !== "") return project.trim();
34126
+ const id = project?.id;
34127
+ if (typeof id === "string" && id.trim() !== "") return id.trim();
34128
+ return "";
34129
+ }
34130
+ const SIX_DAYS_MS = 5184e5;
34131
+ const FOUR_HOURS_MS = 144e5;
34132
+ const THIRTY_DAYS_MS = 2592e6;
34133
+ /**
34134
+ * A failed probe (429/403/network) must not be retried by the 5-minute
34135
+ * scheduler; otherwise one restricted account turns into an endless WAF
34136
+ * hammer. Back off for 30 minutes before the next scheduled attempt.
34137
+ */
34138
+ const FAILURE_BACKOFF_MS = 18e5;
34139
+ /** 1.5–3s randomized gap: a metronomic interval is itself a WAF signal. */
34140
+ function warmupDelayMs() {
34141
+ return 1500 + Math.floor(Math.random() * 1500);
34142
+ }
34143
+ /** Project ID resolver endpoints */
34144
+ const LOAD_PROJECT_ENDPOINTS = [
34145
+ "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
34146
+ "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist",
34147
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
34148
+ ];
34149
+ var WarmupHistoryStore = class {
34150
+ history = /* @__PURE__ */ new Map();
34151
+ filePath;
34152
+ constructor(filePath) {
34153
+ this.filePath = filePath ?? join(stateDir(), "warmup-history.json");
34154
+ this.load();
34155
+ }
34156
+ load() {
34157
+ try {
34158
+ if (existsSync(this.filePath)) {
34159
+ const raw = readFileSync(this.filePath, "utf8");
34160
+ const data = JSON.parse(raw);
34161
+ if (data && typeof data === "object") {
34162
+ const next = /* @__PURE__ */ new Map();
34163
+ for (const [key, value] of Object.entries(data)) if (typeof value === "number" && Number.isFinite(value)) next.set(key, value);
34164
+ this.history = next;
34165
+ }
34166
+ }
34167
+ } catch {}
34168
+ }
34169
+ save() {
34170
+ try {
34171
+ this.pruneOldEntries();
34172
+ const obj = Object.fromEntries(this.history.entries());
34173
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
34174
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
34175
+ renameSync(tmp, this.filePath);
34176
+ } catch {}
34177
+ }
34178
+ checkCooldown(key, cooldownMs, now = Date.now()) {
34179
+ this.load();
34180
+ const last = this.history.get(key);
34181
+ if (last === void 0) return false;
34182
+ return now - last < cooldownMs;
34183
+ }
34184
+ record(key, now = Date.now()) {
34185
+ this.history.set(key, now);
34186
+ this.save();
34187
+ }
34188
+ pruneOldEntries(now = Date.now()) {
34189
+ const cutoff = now - THIRTY_DAYS_MS;
34190
+ for (const [k, ts] of this.history.entries()) if (ts < cutoff) this.history.delete(k);
34191
+ }
34192
+ clear() {
34193
+ this.history.clear();
34194
+ this.save();
34195
+ }
34196
+ };
34197
+ var WarmupService = class {
34198
+ pool;
34199
+ quota;
34200
+ globalProxy;
34201
+ agyVersion;
34202
+ config;
34203
+ log;
34204
+ history;
34205
+ constructor(pool, quota, globalProxy = () => "", agyVersion = () => null, config = () => ({}), log = () => void 0, historyStore) {
34206
+ this.pool = pool;
34207
+ this.quota = quota;
34208
+ this.globalProxy = globalProxy;
34209
+ this.agyVersion = agyVersion;
34210
+ this.config = config;
34211
+ this.log = log;
34212
+ this.history = historyStore ?? new WarmupHistoryStore();
34213
+ }
34214
+ proxyFor(account) {
34215
+ return account.proxyUrl?.trim() || this.globalProxy() || void 0;
34216
+ }
34217
+ /**
34218
+ * Fetch cloudaicompanionProject via loadCodeAssist (official Antigravity project resolution).
34219
+ */
34220
+ async fetchProjectId(accessToken, proxyUrl) {
34221
+ const reqBody = JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } });
34222
+ const userAgent = agyUserAgent(this.agyVersion());
34223
+ for (const url of LOAD_PROJECT_ENDPOINTS) try {
34224
+ const res = await agyFetch(url, {
34225
+ method: "POST",
34226
+ headers: {
34227
+ Authorization: `Bearer ${accessToken}`,
34228
+ "Content-Type": "application/json",
34229
+ "User-Agent": userAgent,
34230
+ "Client-Metadata": "{\"ideType\":\"ANTIGRAVITY\"}"
34231
+ },
34232
+ body: reqBody
34233
+ }, proxyUrl);
34234
+ if (res.ok) {
34235
+ const projectId = extractWarmupProjectId(await res.json());
34236
+ if (projectId !== "") return projectId;
34237
+ }
34238
+ } catch {}
34239
+ return "";
34240
+ }
34241
+ /**
34242
+ * Send a minimal probe to upstream v1internal:streamGenerateContent (fallback: generateContent).
34243
+ */
34244
+ async warmupModelDirectly(account, modelName) {
34245
+ const start = Date.now();
34246
+ const accessToken = await this.quota.getValidAccessToken(account);
34247
+ if (!accessToken) return {
34248
+ ok: false,
34249
+ error: "Could not obtain valid access token",
34250
+ durationMs: Date.now() - start
34251
+ };
34252
+ const proxy = this.proxyFor(account);
34253
+ const projectId = await this.fetchProjectId(accessToken, proxy);
34254
+ if (projectId === "") return {
34255
+ ok: false,
34256
+ error: "Could not resolve cloudaicompanionProject (loadCodeAssist failed); refusing to probe a foreign project",
34257
+ durationMs: Date.now() - start
34258
+ };
34259
+ const mappedModel = toCanonicalWarmupModel(modelName);
34260
+ const isClaude = mappedModel.toLowerCase().includes("claude");
34261
+ const sessionId = `warmup_${Date.now()}_${randomBytes(4).toString("hex")}`;
34262
+ const reqBody = JSON.stringify({
34263
+ ...projectId !== "" ? { project: projectId } : {},
34264
+ model: mappedModel,
34265
+ requestId: `agent/${randomUUID()}`,
34266
+ request: {
34267
+ contents: [{
34268
+ role: "user",
34269
+ parts: [{ text: isClaude ? "ping" : "Say hi" }]
34270
+ }],
34271
+ generationConfig: {
34272
+ temperature: 0,
34273
+ maxOutputTokens: 1,
34274
+ ...isClaude ? {} : { responseModalities: ["TEXT"] }
34275
+ },
34276
+ sessionId
34277
+ }
34278
+ });
34279
+ const headers = {
34280
+ Authorization: `Bearer ${accessToken}`,
34281
+ "Content-Type": "application/json",
34282
+ "User-Agent": agyUserAgent(this.agyVersion()),
34283
+ "Client-Metadata": "{\"ideType\":\"ANTIGRAVITY\"}"
34284
+ };
34285
+ if (isClaude) headers["anthropic-beta"] = "claude-code-20250219";
34286
+ const endpoints = AGY_ENDPOINTS;
34287
+ const attempts = [{
34288
+ method: "streamGenerateContent",
34289
+ query: "?alt=sse"
34290
+ }, {
34291
+ method: "generateContent",
34292
+ query: ""
34293
+ }];
34294
+ let lastStatus = 0;
34295
+ let lastError = "";
34296
+ for (const attempt of attempts) for (const ep of endpoints) try {
34297
+ const res = await agyFetch(`${ep}/v1internal:${attempt.method}${attempt.query}`, {
34298
+ method: "POST",
34299
+ headers,
34300
+ body: reqBody
34301
+ }, proxy);
34302
+ lastStatus = res.status;
34303
+ if (res.ok) {
34304
+ const bodyText = await res.text().catch(() => "");
34305
+ if (bodyText.includes("no longer available") || bodyText.includes("is not available") || bodyText.includes("is retired")) return {
34306
+ ok: false,
34307
+ status: res.status,
34308
+ error: `Model is retired upstream: ${mappedModel}`,
34309
+ durationMs: Date.now() - start
34310
+ };
34311
+ return {
34312
+ ok: true,
34313
+ status: res.status,
34314
+ durationMs: Date.now() - start
34315
+ };
34316
+ }
34317
+ const errBody = await res.text().catch(() => "");
34318
+ if (errBody.toLowerCase().includes("validation_required")) return {
34319
+ ok: false,
34320
+ status: res.status,
34321
+ error: "Account requires browser validation (VALIDATION_REQUIRED)",
34322
+ durationMs: Date.now() - start
34323
+ };
34324
+ lastError = `HTTP ${res.status}: ${errBody.slice(0, 160)}`;
34325
+ if (res.status === 401 || res.status === 403) return {
34326
+ ok: false,
34327
+ status: res.status,
34328
+ error: lastError,
34329
+ durationMs: Date.now() - start
34330
+ };
34331
+ } catch (err) {
34332
+ lastError = String(err);
34333
+ }
34334
+ return {
34335
+ ok: false,
34336
+ status: lastStatus,
34337
+ error: lastError,
34338
+ durationMs: Date.now() - start
34339
+ };
34340
+ }
34341
+ /**
34342
+ * Warm up one account.
34343
+ * If options.model is specified, warms up that model directly.
34344
+ * Otherwise screens quota families for eligible weekly reset windows or cold-starts.
34345
+ */
34346
+ async warmupAccount(account, options = {}) {
34347
+ const results = [];
34348
+ const identity = account.email || account.id;
34349
+ const now = Date.now();
34350
+ if (!account.enabled) return [{
34351
+ accountId: account.id,
34352
+ alias: account.alias,
34353
+ email: account.email,
34354
+ model: options.model ?? "default",
34355
+ success: false,
34356
+ durationMs: 0,
34357
+ skipped: true,
34358
+ skipReason: "Account is disabled"
34359
+ }];
34360
+ if (account.authRequired) return [{
34361
+ accountId: account.id,
34362
+ alias: account.alias,
34363
+ email: account.email,
34364
+ model: options.model ?? "default",
34365
+ success: false,
34366
+ durationMs: 0,
34367
+ skipped: true,
34368
+ skipReason: "Account requires authentication"
34369
+ }];
34370
+ if (!options.force && !shouldPollAccount(account)) return [{
34371
+ accountId: account.id,
34372
+ alias: account.alias,
34373
+ email: account.email,
34374
+ model: options.model ?? "default",
34375
+ success: false,
34376
+ durationMs: 0,
34377
+ skipped: true,
34378
+ skipReason: "Account is in cooldown or auth quarantine"
34379
+ }];
34380
+ if (options.model) {
34381
+ const model = options.model;
34382
+ const manualKey = `${identity}:${model}:100`;
34383
+ const manualFailureKey = `${manualKey}:failed`;
34384
+ if (!options.force && this.history.checkCooldown(manualKey, FOUR_HOURS_MS, now)) return [{
34385
+ accountId: account.id,
34386
+ alias: account.alias,
34387
+ email: account.email,
34388
+ model,
34389
+ success: false,
34390
+ durationMs: 0,
34391
+ skipped: true,
34392
+ skipReason: "Model in 4-hour warmup cooldown"
34393
+ }];
34394
+ if (!options.force && this.history.checkCooldown(manualFailureKey, FAILURE_BACKOFF_MS, now)) return [{
34395
+ accountId: account.id,
34396
+ alias: account.alias,
34397
+ email: account.email,
34398
+ model,
34399
+ success: false,
34400
+ durationMs: 0,
34401
+ skipped: true,
34402
+ skipReason: "Recent warmup attempt failed; retrying after backoff"
34403
+ }];
34404
+ this.log(`[Warmup] pinging ${model} for account ${account.id}...`);
34405
+ const res = await this.warmupModelDirectly(account, model);
34406
+ if (res.ok) this.history.record(manualKey, now);
34407
+ else {
34408
+ this.history.record(manualFailureKey, now);
34409
+ if (res.status === 401) this.pool.markAuthRequired(account.id, "Warmup probe rejected with HTTP 401");
34410
+ }
34411
+ return [{
34412
+ accountId: account.id,
34413
+ alias: account.alias,
34414
+ email: account.email,
34415
+ model,
34416
+ success: res.ok,
34417
+ status: res.status,
34418
+ error: res.error,
34419
+ durationMs: res.durationMs
34420
+ }];
34421
+ }
34422
+ const configuredModels = this.config().warmupModels;
34423
+ if (configuredModels !== void 0 && configuredModels.length === 0) return [{
34424
+ accountId: account.id,
34425
+ alias: account.alias,
34426
+ email: account.email,
34427
+ model: "none",
34428
+ success: true,
34429
+ durationMs: 0,
34430
+ skipped: true,
34431
+ skipReason: "No warmup models configured"
34432
+ }];
34433
+ const monitored = configuredModels ?? ["gemini-3.8-flash", "claude-sonnet-4-6"];
34434
+ const quotas = account.quotas ?? {};
34435
+ const families = Object.keys(quotas);
34436
+ const modelsToPing = [];
34437
+ for (const fam of families) {
34438
+ const q = quotas[fam];
34439
+ if (!q) continue;
34440
+ const familyCooldown = account.cooldowns?.[fam];
34441
+ if (!options.force && familyCooldown && familyCooldown.cooldownUntil > now) continue;
34442
+ const weeklyFrac = q.weeklyFraction;
34443
+ if (weeklyFrac === void 0 || weeklyFrac < .999) continue;
34444
+ let shouldWarm = false;
34445
+ let historyKey = "";
34446
+ if (q.weeklyResetTime) {
34447
+ const resetTs = Date.parse(q.weeklyResetTime);
34448
+ if (!isNaN(resetTs)) {
34449
+ if (now >= resetTs - 6e4) {
34450
+ historyKey = `${identity}:${fam}:weekly:${resetTs}`;
34451
+ shouldWarm = options.force || !this.history.checkCooldown(historyKey, SIX_DAYS_MS, now);
34452
+ }
34453
+ }
34454
+ } else {
34455
+ historyKey = `${identity}:${fam}:weekly:initial`;
34456
+ shouldWarm = options.force || !this.history.checkCooldown(historyKey, SIX_DAYS_MS, now);
34457
+ }
34458
+ const failureKey = `${historyKey}:failed`;
34459
+ if (shouldWarm && !options.force && this.history.checkCooldown(failureKey, FAILURE_BACKOFF_MS, now)) shouldWarm = false;
34460
+ if (shouldWarm) {
34461
+ const model = pickWarmupModelForFamily(fam, monitored);
34462
+ modelsToPing.push({
34463
+ family: fam,
34464
+ model,
34465
+ historyKey,
34466
+ failureKey
34467
+ });
34468
+ }
34469
+ }
34470
+ if (modelsToPing.length === 0 && options.force) {
34471
+ const fallbackModel = pickWarmupModelForFamily("google", monitored);
34472
+ modelsToPing.push({
34473
+ family: "google",
34474
+ model: fallbackModel,
34475
+ historyKey: `${identity}:google:forced:${Math.floor(now / SIX_DAYS_MS)}`,
34476
+ failureKey: `${identity}:google:forced:${Math.floor(now / SIX_DAYS_MS)}:failed`
34477
+ });
34478
+ }
34479
+ if (modelsToPing.length === 0) return [{
34480
+ accountId: account.id,
34481
+ alias: account.alias,
34482
+ email: account.email,
34483
+ model: "none",
34484
+ success: true,
34485
+ durationMs: 0,
34486
+ skipped: true,
34487
+ skipReason: "All weekly quota windows in cooldown or not yet due for reset"
34488
+ }];
34489
+ for (let i = 0; i < modelsToPing.length; i++) {
34490
+ const target = modelsToPing[i];
34491
+ this.log(`[Warmup] weekly warmup for ${target.model} @ ${account.id} (${target.family})...`);
34492
+ const res = await this.warmupModelDirectly(account, target.model);
34493
+ if (res.ok) {
34494
+ this.history.record(target.historyKey, now);
34495
+ this.history.record(`${identity}:${target.model}:100`, now);
34496
+ } else {
34497
+ this.history.record(target.failureKey, now);
34498
+ if (res.status === 401) this.pool.markAuthRequired(account.id, "Warmup probe rejected with HTTP 401");
34499
+ }
34500
+ results.push({
34501
+ accountId: account.id,
34502
+ alias: account.alias,
34503
+ email: account.email,
34504
+ model: target.model,
34505
+ success: res.ok,
34506
+ status: res.status,
34507
+ error: res.error,
34508
+ durationMs: res.durationMs
34509
+ });
34510
+ if (i < modelsToPing.length - 1) await new Promise((r) => setTimeout(r, warmupDelayMs()));
34511
+ }
34512
+ return results;
34513
+ }
34514
+ /**
34515
+ * Warm up all accounts sequentially.
34516
+ */
34517
+ async warmupAllAccounts(options = {}) {
34518
+ const accounts = this.pool.getAccounts().filter((a) => options.force ? a.enabled && !a.authRequired : shouldPollAccount(a));
34519
+ const allResults = [];
34520
+ for (let i = 0; i < accounts.length; i++) {
34521
+ const acc = accounts[i];
34522
+ const res = await this.warmupAccount(acc, options);
34523
+ allResults.push(...res);
34524
+ if (i < accounts.length - 1) await new Promise((r) => setTimeout(r, warmupDelayMs()));
34525
+ }
34526
+ return allResults;
34527
+ }
34528
+ /**
34529
+ * Scheduled periodic run: checks weekly quotas and triggers reset activation when due.
34530
+ */
34531
+ async runScheduledWarmup() {
34532
+ if (!this.config().autoWarmup) return [];
34533
+ return this.warmupAllAccounts({ force: false });
34534
+ }
34535
+ };
34536
+ //#endregion
33311
34537
  //#region src/host/mcp-bridge.ts
33312
34538
  const MCP_SERVER_KEY = "dsh-tools";
33313
34539
  /** MCP tool names are [a-zA-Z0-9_-]; DSH names may contain dots. */
@@ -33410,16 +34636,39 @@ function startMcpBridge(opts) {
33410
34636
  return;
33411
34637
  }
33412
34638
  callSeq++;
34639
+ const startedAt = Date.now();
34640
+ const audit = (outcome) => {
34641
+ try {
34642
+ opts.log?.("mcp call " + dshName + " [" + outcome + "] " + (Date.now() - startedAt) + "ms");
34643
+ } catch {}
34644
+ };
33413
34645
  try {
34646
+ const sessionHeader = String(req.headers["x-dsh-session"] ?? "");
34647
+ const resolveSession = async (id) => id === "" ? void 0 : await Promise.resolve(opts.resolveAgent?.(id));
34648
+ let agent = await resolveSession(sessionHeader);
34649
+ if (agent === void 0 && sessionHeader !== "" && !sessionHeader.startsWith("session-")) agent = await resolveSession("session-" + sessionHeader);
34650
+ const controller = new AbortController();
34651
+ const abortIfOpen = () => {
34652
+ if (!res.writableEnded) controller.abort();
34653
+ };
34654
+ req.once("aborted", abortIfOpen);
34655
+ res.once("close", () => {
34656
+ if (!res.writableEnded) controller.abort();
34657
+ });
34658
+ const result = await svc.execute({
34659
+ callId: "agy-mcp-" + callSeq,
34660
+ name: dshName,
34661
+ arguments: parsed.arguments ?? {},
34662
+ signal: controller.signal,
34663
+ ...agent === void 0 ? {} : { agent }
34664
+ });
34665
+ audit("ok caller=" + (agent === void 0 ? "none" : "agent"));
33414
34666
  sendJson(res, 200, {
33415
34667
  ok: true,
33416
- text: resultText(await svc.execute({
33417
- callId: "agy-mcp-" + callSeq,
33418
- name: dshName,
33419
- arguments: parsed.arguments ?? {}
33420
- }))
34668
+ text: resultText(result)
33421
34669
  });
33422
34670
  } catch (e) {
34671
+ audit("failed: " + String(e).slice(0, 200));
33423
34672
  sendJson(res, 200, {
33424
34673
  ok: false,
33425
34674
  error: String(e)
@@ -33452,12 +34701,30 @@ function startMcpBridge(opts) {
33452
34701
  });
33453
34702
  }
33454
34703
  /**
33455
- * Merge our server entry into the workspace .mcp.json. Returns a restore
33456
- * function that puts the previous content back (or deletes the file we
33457
- * created). Never throws.
34704
+ * Absolute path of the MCP server registry `agy` 1.4+ actually loads.
34705
+ * `override` wins so callers can target a specific file.
34706
+ * Correctly distinguishes between a home dir (`/path/to/home`) and a
34707
+ * direct .gemini dir (`/path/to/home/.gemini`).
34708
+ */
34709
+ function agyMcpConfigPath(override = "", env = process.env) {
34710
+ if (override !== "") return override;
34711
+ const gch = env.GEMINI_CLI_HOME;
34712
+ if (typeof gch === "string" && gch !== "") return basename(gch) === ".gemini" ? join(gch, "config", "mcp_config.json") : join(gch, ".gemini", "config", "mcp_config.json");
34713
+ const home = typeof env.HOME === "string" && env.HOME !== "" ? env.HOME : homedir();
34714
+ return join(home, ".gemini", "config", "mcp_config.json");
34715
+ }
34716
+ /** Registry key for one bridge instance: unique per port so two DSH hosts sharing a global file never clobber each other. */
34717
+ function mcpServerKey(port) {
34718
+ return "dsh-tools-" + String(port);
34719
+ }
34720
+ /**
34721
+ * Merge our server entry into the MCP registry file at `file` (`agy` 1.4+
34722
+ * reads the global one; a workspace `.mcp.json` is ignored, see issue #55).
34723
+ * Returns a restore function that puts the previous content back (or deletes
34724
+ * the file we created). Never throws.
33458
34725
  */
33459
- function writeMcpConfig(workspaceRoot, bridge) {
33460
- const file = join(workspaceRoot, ".mcp.json");
34726
+ function writeMcpConfig(file, bridge) {
34727
+ const key = mcpServerKey(bridge.port);
33461
34728
  let previous = null;
33462
34729
  try {
33463
34730
  if (existsSync(file)) previous = readFileSync(file, "utf8");
@@ -33472,7 +34739,8 @@ function writeMcpConfig(workspaceRoot, bridge) {
33472
34739
  root = {};
33473
34740
  }
33474
34741
  const servers = root.mcpServers && typeof root.mcpServers === "object" ? root.mcpServers : {};
33475
- servers[MCP_SERVER_KEY] = {
34742
+ if (MCP_SERVER_KEY in servers) delete servers[MCP_SERVER_KEY];
34743
+ servers[key] = {
33476
34744
  type: "stdio",
33477
34745
  command: process.execPath,
33478
34746
  args: [bridge.bridgeScript],
@@ -33483,8 +34751,11 @@ function writeMcpConfig(workspaceRoot, bridge) {
33483
34751
  };
33484
34752
  root.mcpServers = servers;
33485
34753
  let written = false;
34754
+ let writtenBytes = "";
33486
34755
  try {
33487
- writeFileSync(file, JSON.stringify(root, null, 2) + "\n", "utf8");
34756
+ mkdirSync(dirname(file), { recursive: true });
34757
+ writtenBytes = JSON.stringify(root, null, 2) + "\n";
34758
+ writeFileSync(file, writtenBytes, "utf8");
33488
34759
  written = true;
33489
34760
  } catch {
33490
34761
  return () => {};
@@ -33499,6 +34770,31 @@ function writeMcpConfig(workspaceRoot, bridge) {
33499
34770
  current = null;
33500
34771
  }
33501
34772
  if (current === null) return;
34773
+ if (current !== writtenBytes) {
34774
+ let live = {};
34775
+ try {
34776
+ const v = JSON.parse(current);
34777
+ if (v && typeof v === "object") live = v;
34778
+ } catch {
34779
+ return;
34780
+ }
34781
+ const liveServers = live.mcpServers && typeof live.mcpServers === "object" ? live.mcpServers : {};
34782
+ if (!(key in liveServers)) return;
34783
+ delete liveServers[key];
34784
+ const emptyRegistry = Object.keys(liveServers).length === 0;
34785
+ const nothingElse = Object.keys(live).every((k) => k === "mcpServers");
34786
+ if (emptyRegistry && nothingElse) {
34787
+ try {
34788
+ unlinkSync(file);
34789
+ } catch {}
34790
+ return;
34791
+ }
34792
+ writeFileSync(file, JSON.stringify({
34793
+ ...live,
34794
+ mcpServers: liveServers
34795
+ }, null, 2) + "\n", "utf8");
34796
+ return;
34797
+ }
33502
34798
  let parsed = {};
33503
34799
  try {
33504
34800
  const v = JSON.parse(current);
@@ -33507,9 +34803,9 @@ function writeMcpConfig(workspaceRoot, bridge) {
33507
34803
  return;
33508
34804
  }
33509
34805
  const curServers = parsed.mcpServers && typeof parsed.mcpServers === "object" ? parsed.mcpServers : {};
33510
- if (!(MCP_SERVER_KEY in curServers)) return;
34806
+ if (!(key in curServers)) return;
33511
34807
  if (previous === null) {
33512
- delete curServers[MCP_SERVER_KEY];
34808
+ delete curServers[key];
33513
34809
  if (Object.keys(curServers).length === 0) unlinkSync(file);
33514
34810
  else writeFileSync(file, JSON.stringify({
33515
34811
  ...parsed,
@@ -33747,7 +35043,12 @@ function apply(ctx, entryConfig = {}) {
33747
35043
  const version = () => versionCache;
33748
35044
  const store = new SessionStore(join(stateDir(), "sessions.json"));
33749
35045
  const pool = new AccountPoolManager();
33750
- const quota = new QuotaService(pool, () => getConfig().proxyUrl || detectSystemProxy() || "");
35046
+ const quota = new QuotaService(pool, () => getConfig().proxyUrl || detectSystemProxy() || "", version, {
35047
+ bin: () => bin(),
35048
+ viaCli: () => getConfig().quotaViaCli,
35049
+ preemptAutoUpdate: () => getConfig().suppressAgyAutoUpdate
35050
+ });
35051
+ const warmup = new WarmupService(pool, quota, () => getConfig().proxyUrl || detectSystemProxy() || "", version, getConfig, log);
33751
35052
  const semaphore = new Semaphore(() => getConfig().maxConcurrent);
33752
35053
  const getDiscoveryEnv = (account) => {
33753
35054
  const cfg = getConfig();
@@ -33763,12 +35064,13 @@ function apply(ctx, entryConfig = {}) {
33763
35064
  ...proxyEnvFor(account?.proxyUrl || cfg.proxyUrl || detectSystemProxy() || void 0)
33764
35065
  };
33765
35066
  };
33766
- const catalog = new ModelCatalog(async (signal) => {
35067
+ const catalog = new ModelCatalog(async (signal, accountId) => {
33767
35068
  const b = bin();
33768
35069
  if (!b) throw new Error("agy binary not found");
33769
35070
  const poolData = pool.getPoolData();
33770
- const primaryAcc = poolData.primaryAccountId ? pool.getAccount(poolData.primaryAccountId) : void 0;
33771
- const candidateAccount = primaryAcc && primaryAcc.enabled && !primaryAcc.authRequired ? primaryAcc : pool.getAccounts().find((a) => a.enabled && !a.authRequired);
35071
+ const targetAcc = accountId ? pool.getAccount(accountId) : void 0;
35072
+ const activeId = pool.getActiveAccountId("google") ?? poolData.lastActiveAccountId ?? poolData.primaryAccountId;
35073
+ const candidateAccount = targetAcc && targetAcc.enabled && !targetAcc.authRequired ? targetAcc : (activeId ? pool.getAccount(activeId) : void 0) ?? (poolData.primaryAccountId ? pool.getAccount(poolData.primaryAccountId) : void 0) ?? pool.getAccounts().find((a) => a.enabled && !a.authRequired);
33772
35074
  let out = await probeProcess(b, ["models"], 3e4, signal, getDiscoveryEnv(candidateAccount), getConfig().suppressAgyAutoUpdate);
33773
35075
  if ((out.code !== 0 || out.stdout.includes("Please sign in") || /sign in|auth|not logged in/i.test(out.stderrTail)) && (!candidateAccount || !candidateAccount.dir)) {
33774
35076
  const secondary = pool.getAccounts().find((a) => a.enabled && !a.authRequired && a.dir);
@@ -34034,7 +35336,7 @@ function apply(ctx, entryConfig = {}) {
34034
35336
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
34035
35337
  res.end(JSON.stringify(body));
34036
35338
  };
34037
- const readBody = (req) => {
35339
+ const readBody = (req, strict = false) => {
34038
35340
  const r = req;
34039
35341
  return new Promise((resolve) => {
34040
35342
  const chunks = [];
@@ -34044,7 +35346,7 @@ function apply(ctx, entryConfig = {}) {
34044
35346
  const v = JSON.parse(Buffer.concat(chunks).toString("utf8"));
34045
35347
  resolve(v && typeof v === "object" ? v : {});
34046
35348
  } catch {
34047
- resolve({});
35349
+ resolve(strict ? { __invalidJson: true } : {});
34048
35350
  }
34049
35351
  });
34050
35352
  });
@@ -34081,6 +35383,8 @@ function apply(ctx, entryConfig = {}) {
34081
35383
  },
34082
35384
  catalogModels: sanitizeCatalogEntries(cat.models).entries,
34083
35385
  hiddenModels: cfg.hiddenModels,
35386
+ autoWarmup: cfg.autoWarmup,
35387
+ warmupModels: cfg.warmupModels,
34084
35388
  bindings: Object.keys(store.all()).length,
34085
35389
  lastRun
34086
35390
  });
@@ -34127,7 +35431,7 @@ function apply(ctx, entryConfig = {}) {
34127
35431
  if (acc) quota.refreshAccountQuota(acc).catch(() => void 0);
34128
35432
  } else quota.refreshAllQuotas().catch(() => void 0);
34129
35433
  store.clear();
34130
- catalog.forceRefresh().then(() => notifyPicker()).catch(() => void 0);
35434
+ catalog.forceRefresh().catch(() => void 0);
34131
35435
  }
34132
35436
  sendJson(res, 200, st);
34133
35437
  })();
@@ -34256,6 +35560,13 @@ function apply(ctx, entryConfig = {}) {
34256
35560
  const body = await readBody(req);
34257
35561
  const alias = typeof body.alias === "string" ? body.alias : void 0;
34258
35562
  const acc = pool.createAccountSlot(alias);
35563
+ if (bridgeState.bridge !== null) {
35564
+ const cfg = getConfig();
35565
+ if (cfg.mcpConfigScope === "global" || cfg.mcpConfigScope === "both") {
35566
+ const accTarget = agyMcpConfigPath("", isolatedHomeEnv(acc.dir));
35567
+ bridgeState.restores.push(writeMcpConfig(accTarget, bridgeState.bridge));
35568
+ }
35569
+ }
34259
35570
  pool.markAuthRequired(acc.id, "not signed in yet — complete the terminal login");
34260
35571
  if (body.autoOpenTerminal !== false && acc.dir) openAccountTerminal(acc.dir);
34261
35572
  sendJson(res, 200, {
@@ -34401,7 +35712,7 @@ function apply(ctx, entryConfig = {}) {
34401
35712
  const acc = pool.getAccount(id);
34402
35713
  if (acc) await quota.refreshAccountQuota(acc, true);
34403
35714
  } else await quota.refreshAllQuotas(true);
34404
- catalog.forceRefresh().then(() => notifyPicker()).catch(() => void 0);
35715
+ catalog.forceRefresh().catch(() => void 0);
34405
35716
  sendJson(res, 200, {
34406
35717
  ok: true,
34407
35718
  pool: pool.getPoolData()
@@ -34409,6 +35720,60 @@ function apply(ctx, entryConfig = {}) {
34409
35720
  })();
34410
35721
  }
34411
35722
  });
35723
+ reg({
35724
+ kind: "exact",
35725
+ path: "/plugins/agy-link/pool/warmup",
35726
+ handler: (req, res) => {
35727
+ (async () => {
35728
+ try {
35729
+ if (methodOf(req) !== "POST") {
35730
+ sendJson(res, 405, { error: "POST only" });
35731
+ return;
35732
+ }
35733
+ const body = await readBody(req, true);
35734
+ if (body.__invalidJson === true) {
35735
+ sendJson(res, 400, { error: "invalid JSON body" });
35736
+ return;
35737
+ }
35738
+ const id = typeof body.id === "string" ? body.id : "";
35739
+ const force = body.force === true;
35740
+ const rawModel = body.model;
35741
+ if (rawModel !== void 0 && (typeof rawModel !== "string" || !/^[A-Za-z0-9._/-]{1,80}$/.test(rawModel))) {
35742
+ sendJson(res, 400, { error: "invalid model" });
35743
+ return;
35744
+ }
35745
+ const model = typeof rawModel === "string" ? rawModel : void 0;
35746
+ if (id === "" && body.all !== true) {
35747
+ sendJson(res, 400, { error: "id or { all: true } required" });
35748
+ return;
35749
+ }
35750
+ let results = [];
35751
+ if (id) {
35752
+ const acc = pool.getAccount(id);
35753
+ if (!acc) {
35754
+ sendJson(res, 404, { error: "Account not found" });
35755
+ return;
35756
+ }
35757
+ results = await warmup.warmupAccount(acc, {
35758
+ force,
35759
+ model
35760
+ });
35761
+ await quota.refreshAccountQuota(acc, true).catch(() => void 0);
35762
+ } else {
35763
+ results = await warmup.warmupAllAccounts({ force });
35764
+ await quota.refreshAllQuotas(false).catch(() => void 0);
35765
+ }
35766
+ sendJson(res, 200, {
35767
+ ok: true,
35768
+ results,
35769
+ pool: pool.getPoolData()
35770
+ });
35771
+ } catch (e) {
35772
+ sendJson(res, 500, { error: String(e) });
35773
+ }
35774
+ })();
35775
+ }
35776
+ });
34412
35777
  reg({
34413
35778
  kind: "exact",
34414
35779
  path: "/plugins/agy-link/pool/clear-cooldown",
@@ -34441,6 +35806,7 @@ function apply(ctx, entryConfig = {}) {
34441
35806
  const body = await readBody(req);
34442
35807
  const id = typeof body.id === "string" ? body.id : "";
34443
35808
  const ok = id !== "" && pool.setActiveAccount(id);
35809
+ if (ok) catalog.forceRefresh(id).catch(() => void 0);
34444
35810
  sendJson(res, ok ? 200 : 400, {
34445
35811
  ok,
34446
35812
  pool: pool.getPoolData()
@@ -34484,7 +35850,9 @@ function apply(ctx, entryConfig = {}) {
34484
35850
  "defaultEffort",
34485
35851
  "askTool",
34486
35852
  "workspaceRoot",
34487
- "hiddenModels"
35853
+ "hiddenModels",
35854
+ "autoWarmup",
35855
+ "warmupModels"
34488
35856
  ].includes(key)) {
34489
35857
  sendJson(res, 400, { error: "key not settable" });
34490
35858
  return;
@@ -34493,6 +35861,10 @@ function apply(ctx, entryConfig = {}) {
34493
35861
  sendJson(res, 400, { error: "hiddenModels must be an array of model ids" });
34494
35862
  return;
34495
35863
  }
35864
+ if (key === "warmupModels" && (!Array.isArray(body.value) || !body.value.every((x) => typeof x === "string"))) {
35865
+ sendJson(res, 400, { error: "warmupModels must be an array of model names" });
35866
+ return;
35867
+ }
34496
35868
  setOverride(key, body.value);
34497
35869
  syncAskTool();
34498
35870
  syncMirrorTool();
@@ -34577,7 +35949,11 @@ function apply(ctx, entryConfig = {}) {
34577
35949
  ctx.effect(() => {
34578
35950
  if (!getConfig().enabled) return () => void 0;
34579
35951
  const refresh = () => {
34580
- quota.refreshAllQuotas().catch(() => void 0);
35952
+ const prevArgon = pool.getAccounts().map((a) => Boolean(a.quotas?.argon)).join(",");
35953
+ quota.refreshAllQuotas().then(() => {
35954
+ const nextArgon = pool.getAccounts().map((a) => Boolean(a.quotas?.argon)).join(",");
35955
+ if (prevArgon !== nextArgon) notifyPicker();
35956
+ }).catch(() => void 0);
34581
35957
  };
34582
35958
  const boot = setTimeout(refresh, 5e3);
34583
35959
  boot.unref?.();
@@ -34588,9 +35964,25 @@ function apply(ctx, entryConfig = {}) {
34588
35964
  clearInterval(timer);
34589
35965
  };
34590
35966
  });
35967
+ ctx.effect(() => {
35968
+ if (!getConfig().enabled) return () => void 0;
35969
+ let running = false;
35970
+ const checkWarmup = () => {
35971
+ if (running) return;
35972
+ const cfg = getConfig();
35973
+ if (!cfg.enabled || !cfg.autoWarmup) return;
35974
+ running = true;
35975
+ warmup.runScheduledWarmup().catch(() => void 0).finally(() => {
35976
+ running = false;
35977
+ });
35978
+ };
35979
+ const timer = setInterval(checkWarmup, 3e5);
35980
+ timer.unref?.();
35981
+ return () => clearInterval(timer);
35982
+ });
34591
35983
  const bridgeState = {
34592
35984
  bridge: null,
34593
- restore: null
35985
+ restores: []
34594
35986
  };
34595
35987
  let bridgeDisposed = false;
34596
35988
  const syncMcpBridge = () => {
@@ -34600,10 +35992,14 @@ function apply(ctx, entryConfig = {}) {
34600
35992
  try {
34601
35993
  const script = fileURLToPath(new URL("./bridge.mjs", import.meta.url));
34602
35994
  const toolsSvc = ctx.get("tools");
35995
+ const agents = () => {
35996
+ return ctx.get("agents") ?? ctx.agents;
35997
+ };
34603
35998
  const bridge = await startMcpBridge({
34604
35999
  bridgeScript: script,
34605
36000
  tools: () => ctx.get("tools"),
34606
36001
  allowlist: () => getConfig().mcpToolAllowlist,
36002
+ resolveAgent: (sessionId) => agents()?.get(sessionId),
34607
36003
  log
34608
36004
  });
34609
36005
  if (bridgeDisposed) {
@@ -34612,21 +36008,28 @@ function apply(ctx, entryConfig = {}) {
34612
36008
  }
34613
36009
  bridgeState.bridge = bridge;
34614
36010
  const root = cfg.workspaceRoot !== "" ? cfg.workspaceRoot : process.cwd();
34615
- bridgeState.restore = writeMcpConfig(root, bridge);
34616
- log("mcp bridge ready at " + bridge.url + (toolsSvc ? "" : " (tools service not yet available)"));
36011
+ const targets = [];
36012
+ if (cfg.mcpConfigScope === "global" || cfg.mcpConfigScope === "both") {
36013
+ targets.push(agyMcpConfigPath(cfg.mcpConfigPath));
36014
+ for (const acc of pool.getAccounts()) if (acc.dir) targets.push(agyMcpConfigPath("", isolatedHomeEnv(acc.dir)));
36015
+ }
36016
+ if (cfg.mcpConfigScope === "workspace" || cfg.mcpConfigScope === "both") targets.push(join(root, ".mcp.json"));
36017
+ const uniqueTargets = Array.from(new Set(targets));
36018
+ bridgeState.restores = uniqueTargets.map((file) => writeMcpConfig(file, bridge));
36019
+ log("mcp bridge ready at " + bridge.url + " (registries: " + uniqueTargets.join(", ") + ")" + (toolsSvc ? "" : " (tools service not yet available)"));
34617
36020
  } catch (e) {
34618
- bridgeState.restore?.();
36021
+ for (const undo of bridgeState.restores) undo();
34619
36022
  await bridgeState.bridge?.close();
34620
36023
  bridgeState.bridge = null;
34621
- bridgeState.restore = null;
36024
+ bridgeState.restores = [];
34622
36025
  log("mcp bridge failed to start: " + String(e));
34623
36026
  }
34624
36027
  })();
34625
36028
  else if (!want && bridgeState.bridge !== null) {
34626
- bridgeState.restore?.();
36029
+ for (const undo of bridgeState.restores) undo();
34627
36030
  bridgeState.bridge.close();
34628
36031
  bridgeState.bridge = null;
34629
- bridgeState.restore = null;
36032
+ bridgeState.restores = [];
34630
36033
  }
34631
36034
  };
34632
36035
  syncMcpBridge();
@@ -34637,7 +36040,7 @@ function apply(ctx, entryConfig = {}) {
34637
36040
  poolAuth.cancel();
34638
36041
  if (askToolDispose.current !== null) askToolDispose.current();
34639
36042
  if (mirrorToolDispose.current !== null) mirrorToolDispose.current();
34640
- bridgeState.restore?.();
36043
+ for (const undo of bridgeState.restores) undo();
34641
36044
  bridgeState.bridge?.close();
34642
36045
  };
34643
36046
  });