dsh-agy-link 0.4.42 → 0.4.44

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
@@ -86,11 +86,13 @@ function defaultConfig() {
86
86
  maxTokensDefault: 65536,
87
87
  forwardSystemPrompt: false,
88
88
  digestMaxChars: 8e3,
89
+ contextTransferMaxChars: 66e4,
89
90
  modelsCacheTtlMs: 3e5,
90
91
  allowAuxiliary: true,
91
92
  compactionMaxChars: 8e5,
92
93
  workspaceRoot: "",
93
94
  fallbackModels: DEFAULT_FALLBACK_MODELS,
95
+ hiddenModels: [],
94
96
  askTool: false,
95
97
  mediaDir: "",
96
98
  mediaTtlMs: 864e5,
@@ -135,6 +137,20 @@ const Err = {
135
137
  AGY_NOT_INSTALLED: "AGY_NOT_INSTALLED",
136
138
  AGY_VERSION_UNSUPPORTED: "AGY_VERSION_UNSUPPORTED",
137
139
  AGY_ERROR: "AGY_ERROR",
140
+ /**
141
+ * Hard server-issued quota / rate-limit refusal. Distinct from AGY_ERROR so
142
+ * the provider retry policy can retry it (the failed account is already in
143
+ * cooldown, so the retry lands on the next account) without also retrying
144
+ * deterministic AGY_ERRORs (bad region, empty request, dead pool).
145
+ */
146
+ RATE_LIMIT: "RATE_LIMIT",
147
+ /**
148
+ * Google-side region / account eligibility refusal ("User location is not
149
+ * supported for the API use"). Account-level, so it is retryable: the
150
+ * adapter rotates the sticky pointer first, which lets an account whose
151
+ * proxy/egress region IS supported serve the retry.
152
+ */
153
+ ELIGIBILITY: "ELIGIBILITY",
138
154
  TIMEOUT: "TIMEOUT",
139
155
  PROCESS_EXIT: "PROCESS_EXIT",
140
156
  INVALID_OUTPUT: "INVALID_OUTPUT",
@@ -152,7 +168,7 @@ function looksLikeAuthFailure(text) {
152
168
  * location" — not an auth bug and not something the bridge can retry past.
153
169
  */
154
170
  function looksLikeEligibilityFailure(text) {
155
- return /eligibility check failed|not eligible for antigravity|not currently available in your (?:location|country|region)|not available in your (?:location|country|region)/i.test(text);
171
+ return /eligibility check failed|not eligible for antigravity|not currently available in your (?:location|country|region)|not available in your (?:location|country|region)|user location is not supported|location is not supported for the api|not supported in your (?:location|country|region)/i.test(text);
156
172
  }
157
173
  /** One-line guidance for an eligibility refusal (shown in the turn error). */
158
174
  const ELIGIBILITY_ERROR_HINT = "Google Antigravity is not eligible for this account/region. Use an account and network location where Antigravity is available (see https://antigravity.google).";
@@ -244,6 +260,20 @@ function asNum(v) {
244
260
  if (typeof v === "number" && Number.isFinite(v)) return v;
245
261
  if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
246
262
  }
263
+ /** Trimmed, de-duplicated string list; non-string / blank entries are dropped. */
264
+ function asStringArray(v) {
265
+ if (!Array.isArray(v)) return void 0;
266
+ const out = [];
267
+ const seen = /* @__PURE__ */ new Set();
268
+ for (const x of v) {
269
+ if (typeof x !== "string") continue;
270
+ const s = x.trim();
271
+ if (s === "" || seen.has(s)) continue;
272
+ seen.add(s);
273
+ out.push(s);
274
+ }
275
+ return out;
276
+ }
247
277
  const MODES = [
248
278
  "skip",
249
279
  "plan",
@@ -273,11 +303,13 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
273
303
  maxTokensDefault: asNum(get("maxTokensDefault")) ?? base.maxTokensDefault,
274
304
  forwardSystemPrompt: asBool(get("forwardSystemPrompt")) ?? base.forwardSystemPrompt,
275
305
  digestMaxChars: asNum(get("digestMaxChars")) ?? base.digestMaxChars,
306
+ contextTransferMaxChars: asNum(get("contextTransferMaxChars")) ?? base.contextTransferMaxChars,
276
307
  modelsCacheTtlMs: asNum(get("modelsCacheTtlMs")) ?? base.modelsCacheTtlMs,
277
308
  allowAuxiliary: asBool(get("allowAuxiliary")) ?? base.allowAuxiliary,
278
309
  compactionMaxChars: asNum(get("compactionMaxChars")) ?? base.compactionMaxChars,
279
310
  workspaceRoot: asString(get("workspaceRoot")) ?? base.workspaceRoot,
280
311
  fallbackModels: Array.isArray(get("fallbackModels")) ? get("fallbackModels").filter((x) => !!x && typeof x === "object" && typeof x.id === "string") : base.fallbackModels,
312
+ hiddenModels: asStringArray(get("hiddenModels")) ?? base.hiddenModels,
281
313
  askTool: asBool(get("askTool")) ?? base.askTool,
282
314
  mediaDir: asString(get("mediaDir")) ?? base.mediaDir,
283
315
  mediaTtlMs: asNum(get("mediaTtlMs")) ?? base.mediaTtlMs,
@@ -309,6 +341,10 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
309
341
  if (t && t > 0) cfg.timeoutMs = t;
310
342
  }
311
343
  if (env.DSH_AGY_EXTRA_ARGS) cfg.extraArgs = env.DSH_AGY_EXTRA_ARGS.split(/\s+/).filter(Boolean);
344
+ if (env.DSH_AGY_HIDDEN_MODELS) {
345
+ const list = asStringArray(env.DSH_AGY_HIDDEN_MODELS.split(","));
346
+ if (list) cfg.hiddenModels = list;
347
+ }
312
348
  if (env.DSH_AGY_WORKSPACE_ROOT) cfg.workspaceRoot = env.DSH_AGY_WORKSPACE_ROOT;
313
349
  if (env.DSH_AGY_MEDIA_DIR) cfg.mediaDir = env.DSH_AGY_MEDIA_DIR;
314
350
  if (env.DSH_AGY_MEDIA_TTL_MS) {
@@ -1459,6 +1495,36 @@ function buildFallbackCatalog(defs) {
1459
1495
  efforts: Array.isArray(d.efforts) && d.efforts.length > 0 ? d.efforts.filter((e) => typeof e === "string" && e.trim() !== "") : null
1460
1496
  }));
1461
1497
  }
1498
+ /**
1499
+ * Sanitized (id, name) projection of a catalog: trimmed non-empty ids with
1500
+ * first occurrence winning — exactly what listModels serves to DSH. Shared
1501
+ * with the settings UI's model-visibility list so the picker and the UI can
1502
+ * never disagree about which models exist.
1503
+ */
1504
+ function sanitizeCatalogEntries(models) {
1505
+ const seen = /* @__PURE__ */ new Set();
1506
+ const entries = [];
1507
+ const dropped = [];
1508
+ for (const m of models) {
1509
+ if (!m || typeof m.id !== "string") continue;
1510
+ const id = m.id.trim();
1511
+ if (id === "") continue;
1512
+ if (seen.has(id)) {
1513
+ dropped.push(id);
1514
+ continue;
1515
+ }
1516
+ seen.add(id);
1517
+ const name = typeof m.name === "string" && m.name.trim() !== "" ? m.name.trim() : id;
1518
+ entries.push({
1519
+ id,
1520
+ name
1521
+ });
1522
+ }
1523
+ return {
1524
+ entries,
1525
+ dropped
1526
+ };
1527
+ }
1462
1528
  var ModelCatalog = class {
1463
1529
  discover;
1464
1530
  ttlMs;
@@ -1897,10 +1963,27 @@ const EXT = {
1897
1963
  "image/webp": "webp",
1898
1964
  "image/gif": "gif"
1899
1965
  };
1900
- /** Deterministic staged path: same session+turn+index reuses the file. */
1901
- function stagedPath(dir, key, index, mediaType) {
1966
+ /**
1967
+ * Sanitize an attachment id into a stable filename fragment. Ids whose
1968
+ * sanitized form collides (e.g. "a/b" vs "a_b") get a short raw-id hash
1969
+ * suffix so distinct attachments never overwrite each other's files.
1970
+ */
1971
+ function idFragment(attachmentId) {
1972
+ const cleaned = attachmentId.replace(/[^a-zA-Z0-9_-]+/g, "_");
1973
+ if (cleaned === "") return "img";
1974
+ if (cleaned === attachmentId) return cleaned.slice(0, 80);
1975
+ let h = 0;
1976
+ for (let i = 0; i < attachmentId.length; i++) h = h * 31 + attachmentId.charCodeAt(i) | 0;
1977
+ return (cleaned.slice(0, 40) + "-" + (h >>> 0).toString(36)).slice(0, 80);
1978
+ }
1979
+ /**
1980
+ * Deterministic staged path keyed by the attachment id (not the call index):
1981
+ * the same picture always lands on the same file, so a continuation that
1982
+ * legitimately re-references it overwrites instead of accumulating copies.
1983
+ */
1984
+ function stagedPath(dir, key, attachmentId, mediaType) {
1902
1985
  const ext = EXT[mediaType] ?? "png";
1903
- return join(dir, key + "-" + String(index) + "." + ext);
1986
+ return join(dir, key + "-" + idFragment(attachmentId) + "." + ext);
1904
1987
  }
1905
1988
  /**
1906
1989
  * Stage up to `maxImages` images into `dir`, returning prompt lines that
@@ -1909,6 +1992,7 @@ function stagedPath(dir, key, index, mediaType) {
1909
1992
  */
1910
1993
  async function stageImages(opts) {
1911
1994
  const staged = [];
1995
+ const stagedIds = [];
1912
1996
  const lines = [];
1913
1997
  let skipped = 0;
1914
1998
  const budget = Math.max(0, opts.maxImages);
@@ -1939,15 +2023,17 @@ async function stageImages(opts) {
1939
2023
  i++;
1940
2024
  continue;
1941
2025
  }
1942
- const path = stagedPath(opts.dir, opts.key, i, ref.mediaType);
2026
+ const path = stagedPath(opts.dir, opts.key, ref.attachmentId, ref.mediaType);
1943
2027
  await writeFile(path, data);
1944
2028
  staged.push({
1945
2029
  path,
1946
2030
  name: label,
1947
2031
  width: ref.width,
1948
2032
  height: ref.height,
1949
- bytes: ref.bytes
2033
+ bytes: ref.bytes,
2034
+ attachmentId: ref.attachmentId
1950
2035
  });
2036
+ stagedIds.push(ref.attachmentId);
1951
2037
  lines.push("[image attached: \"" + label + "\" — staged at " + path + " (" + ref.width + "x" + ref.height + ", " + ref.bytes + " bytes). Inspect it using the view_file tool with AbsolutePath: \"" + path + "\"]");
1952
2038
  i++;
1953
2039
  }
@@ -1955,6 +2041,7 @@ async function stageImages(opts) {
1955
2041
  return {
1956
2042
  promptSuffix: lines.join("\n"),
1957
2043
  staged,
2044
+ stagedIds,
1958
2045
  skipped
1959
2046
  };
1960
2047
  }
@@ -1990,6 +2077,95 @@ function defaultMediaDir(stateDir) {
1990
2077
  //#endregion
1991
2078
  //#region src/host/runner.ts
1992
2079
  const IS_WIN = process.platform === "win32";
2080
+ /**
2081
+ * Windows pipes carry bytes in the CHILD's console code page: agy captures
2082
+ * pwsh tool output as CP936 (GBK) bytes on a zh-CN Windows, so decoding them
2083
+ * as UTF-8 mangles every non-ASCII character - and the mangled text is what
2084
+ * agy stores in its conversation database, so it can never be repaired later
2085
+ * (issue #37: the Chinese refusal text arrives mangled).
2086
+ */
2087
+ const OEM_ENCODINGS = {
2088
+ 932: "shift_jis",
2089
+ 936: "gbk",
2090
+ 949: "euc-kr",
2091
+ 950: "big5",
2092
+ 65001: "utf-8",
2093
+ 1250: "windows-1250",
2094
+ 1251: "windows-1251",
2095
+ 1252: "windows-1252",
2096
+ 1253: "windows-1253",
2097
+ 1254: "windows-1254",
2098
+ 1255: "windows-1255",
2099
+ 1256: "windows-1256",
2100
+ 1257: "windows-1257",
2101
+ 1258: "windows-1258"
2102
+ };
2103
+ let consoleEncoding = null;
2104
+ /**
2105
+ * Console/OEM code page of this process as a TextDecoder label ('utf-8' off
2106
+ * Windows). Probed once; the child inherits our console, so its tool output is
2107
+ * written in this page.
2108
+ */
2109
+ function consoleOutputEncoding(platform = process.platform) {
2110
+ if (platform !== "win32") return "utf-8";
2111
+ if (consoleEncoding !== null) return consoleEncoding;
2112
+ consoleEncoding = "utf-8";
2113
+ try {
2114
+ const out = execFileSync(process.env.ComSpec ?? "cmd.exe", [
2115
+ "/d",
2116
+ "/s",
2117
+ "/c",
2118
+ "chcp"
2119
+ ], {
2120
+ encoding: "utf8",
2121
+ windowsHide: true,
2122
+ timeout: 5e3
2123
+ });
2124
+ const m = /([0-9]{3,5})/.exec(out);
2125
+ const cp = m ? Number(m[1]) : NaN;
2126
+ const mapped = OEM_ENCODINGS[cp];
2127
+ if (mapped !== void 0) consoleEncoding = mapped;
2128
+ } catch {}
2129
+ return consoleEncoding;
2130
+ }
2131
+ /**
2132
+ * Decode one LINE of child output: UTF-8 first, and when that produces
2133
+ * replacement characters re-decode those bytes with the console code page.
2134
+ * Lines (not raw chunks) are the unit on purpose - a multi-byte character can
2135
+ * straddle a chunk boundary but never a newline, in UTF-8 or the OEM pages.
2136
+ */
2137
+ function decodeConsoleLine(bytes, encoding = consoleOutputEncoding()) {
2138
+ const text = bytes.toString("utf8");
2139
+ if (encoding === "utf-8" || !text.includes("�")) return text;
2140
+ try {
2141
+ const alt = new TextDecoder(encoding).decode(bytes);
2142
+ return alt.includes("�") ? text : alt;
2143
+ } catch {
2144
+ return text;
2145
+ }
2146
+ }
2147
+ /** Byte stream to decoded line text, holding an unterminated tail back. */
2148
+ function makeLineDecoder(encoding) {
2149
+ let rest = Buffer.alloc(0);
2150
+ return {
2151
+ push(chunk) {
2152
+ rest = rest.length === 0 ? chunk : Buffer.concat([rest, chunk]);
2153
+ let out = "";
2154
+ let nl;
2155
+ while ((nl = rest.indexOf(10)) >= 0) {
2156
+ out += decodeConsoleLine(rest.subarray(0, nl + 1), encoding);
2157
+ rest = rest.subarray(nl + 1);
2158
+ }
2159
+ return out;
2160
+ },
2161
+ flush() {
2162
+ if (rest.length === 0) return "";
2163
+ const out = decodeConsoleLine(rest, encoding);
2164
+ rest = Buffer.alloc(0);
2165
+ return out;
2166
+ }
2167
+ };
2168
+ }
1993
2169
  /** Executable candidates for one PATH entry, per-platform. Exported for tests. */
1994
2170
  function binCandidates(dir, platform = process.platform) {
1995
2171
  return (platform === "win32" ? [
@@ -2253,9 +2429,24 @@ function killTree(child) {
2253
2429
  escalating.delete(child);
2254
2430
  });
2255
2431
  }
2432
+ /**
2433
+ * Environment for spawning a script through `process.execPath`.
2434
+ *
2435
+ * In the packaged desktop host `process.execPath` is the **Electron binary**,
2436
+ * not node: without this flag the child would boot a second GUI instance
2437
+ * instead of executing the script. Plain node ignores the variable, so the
2438
+ * POSIX/test paths are unaffected.
2439
+ */
2440
+ function nodeScriptEnv(env) {
2441
+ return {
2442
+ ...env,
2443
+ ELECTRON_RUN_AS_NODE: "1"
2444
+ };
2445
+ }
2256
2446
  function startAgyProcess(opts) {
2257
2447
  const started = Date.now();
2258
2448
  const viaCmd = IS_WIN && isCmdShim(opts.bin);
2449
+ const isNode = IS_WIN && /\.(mjs|js|cjs)$/i.test(opts.bin);
2259
2450
  const env = withAgyQuietEnv(opts.env ?? process.env);
2260
2451
  const child = viaCmd ? spawn(process.env.ComSpec ?? "cmd.exe", [
2261
2452
  "/d",
@@ -2272,6 +2463,15 @@ function startAgyProcess(opts) {
2272
2463
  ],
2273
2464
  windowsVerbatimArguments: true,
2274
2465
  windowsHide: true
2466
+ }) : isNode ? spawn(process.execPath, [opts.bin, ...opts.args], {
2467
+ cwd: opts.cwd,
2468
+ env: nodeScriptEnv(env),
2469
+ stdio: [
2470
+ "pipe",
2471
+ "pipe",
2472
+ "pipe"
2473
+ ],
2474
+ windowsHide: true
2275
2475
  }) : spawn(opts.bin, opts.args, {
2276
2476
  cwd: opts.cwd,
2277
2477
  env,
@@ -2311,14 +2511,16 @@ function startAgyProcess(opts) {
2311
2511
  killTree(child);
2312
2512
  };
2313
2513
  opts.signal?.addEventListener("abort", onAbort, { once: true });
2314
- if (child.stdout) child.stdout.setEncoding("utf8");
2315
- if (child.stderr) child.stderr.setEncoding("utf8");
2514
+ const stdoutDecoder = makeLineDecoder(consoleOutputEncoding());
2515
+ const stderrDecoder = makeLineDecoder(consoleOutputEncoding());
2316
2516
  let pending = "";
2317
2517
  child.stdout?.on("data", (chunk) => {
2318
2518
  refreshWatchdog();
2319
- stdout += chunk;
2519
+ const text = stdoutDecoder.push(chunk);
2520
+ if (text === "") return;
2521
+ stdout += text;
2320
2522
  if (stdout.length > 4e6) stdout = stdout.slice(-2e6);
2321
- pending += chunk;
2523
+ pending += text;
2322
2524
  let nl;
2323
2525
  while ((nl = pending.indexOf("\n")) >= 0) {
2324
2526
  const line = pending.slice(0, nl).replace(/\r$/, "");
@@ -2328,7 +2530,8 @@ function startAgyProcess(opts) {
2328
2530
  });
2329
2531
  child.stderr?.on("data", (chunk) => {
2330
2532
  if (opts.stderrKeepalive) refreshWatchdog();
2331
- stderr = (stderr + chunk).slice(-4096);
2533
+ const text = stderrDecoder.push(chunk);
2534
+ if (text !== "") stderr = (stderr + text).slice(-4096);
2332
2535
  });
2333
2536
  return {
2334
2537
  child,
@@ -2338,6 +2541,13 @@ function startAgyProcess(opts) {
2338
2541
  settled = true;
2339
2542
  if (watchdog) clearTimeout(watchdog);
2340
2543
  opts.signal?.removeEventListener("abort", onAbort);
2544
+ const stdoutTail = stdoutDecoder.flush();
2545
+ if (stdoutTail !== "") {
2546
+ stdout += stdoutTail;
2547
+ pending += stdoutTail;
2548
+ }
2549
+ const stderrTail = stderrDecoder.flush();
2550
+ if (stderrTail !== "") stderr = (stderr + stderrTail).slice(-4096);
2341
2551
  if (pending !== "") {
2342
2552
  opts.onLine?.(pending);
2343
2553
  pending = "";
@@ -2381,6 +2591,54 @@ async function probeProcess(bin, args, timeoutMs = 3e4, signal, env) {
2381
2591
  }).outcome;
2382
2592
  }
2383
2593
  //#endregion
2594
+ //#region src/host/sessions.ts
2595
+ var SessionStore = class {
2596
+ file;
2597
+ data = {};
2598
+ constructor(file) {
2599
+ this.file = file;
2600
+ this.load();
2601
+ }
2602
+ load() {
2603
+ try {
2604
+ if (!existsSync(this.file)) return;
2605
+ const v = JSON.parse(readFileSync(this.file, "utf8"));
2606
+ if (v && typeof v === "object") this.data = v;
2607
+ } catch {}
2608
+ }
2609
+ get(key) {
2610
+ return this.data[key];
2611
+ }
2612
+ set(key, b) {
2613
+ this.data[key] = b;
2614
+ this.persist();
2615
+ }
2616
+ delete(key) {
2617
+ delete this.data[key];
2618
+ this.persist();
2619
+ }
2620
+ clear() {
2621
+ this.data = {};
2622
+ this.persist();
2623
+ }
2624
+ all() {
2625
+ return this.data;
2626
+ }
2627
+ /** Atomic write: tmp file + rename, then merge on next load. */
2628
+ persist() {
2629
+ try {
2630
+ mkdirSync(dirname(this.file), { recursive: true });
2631
+ const tmp = join(dirname(this.file), "." + require$$basename(this.file) + ".tmp");
2632
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
2633
+ renameSync(tmp, this.file);
2634
+ } catch {}
2635
+ }
2636
+ };
2637
+ function require$$basename(p) {
2638
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
2639
+ return i >= 0 ? p.slice(i + 1) : p;
2640
+ }
2641
+ //#endregion
2384
2642
  //#region src/host/agy-db.ts
2385
2643
  const execFileAsync = promisify(execFile);
2386
2644
  let AGY_DB_DIR = join(homedir(), ".gemini", "antigravity-cli", "conversations");
@@ -2890,6 +3148,47 @@ function digestLabel(m) {
2890
3148
  return m.role === "user" ? "User: " : "Assistant: ";
2891
3149
  }
2892
3150
  /**
3151
+ * Images this hop should forward, minus what the current agy conversation
3152
+ * has already received. Trailing-span attachments come first (this hop's
3153
+ * screenshots), then any user-turn attachments since `fromIdx` that carry
3154
+ * unseen ids (tool-hop continuations and foreign-model turns used to fall
3155
+ * into a "re-send every historical screenshot" path).
3156
+ */
3157
+ function collectUnsentImageRefs(messages, trailingUser, fromIdx, sentIds) {
3158
+ const sent = new Set(sentIds);
3159
+ const seen = /* @__PURE__ */ new Set();
3160
+ const out = [];
3161
+ const push = (m) => {
3162
+ const refs = [];
3163
+ collectImageRefs(m, refs);
3164
+ for (const r of refs) {
3165
+ const id = r.attachmentId;
3166
+ if (typeof id !== "string" || id === "" || sent.has(id) || seen.has(id)) continue;
3167
+ seen.add(id);
3168
+ out.push(r);
3169
+ }
3170
+ };
3171
+ for (const m of trailingUser) push(m);
3172
+ const start = Math.max(0, Math.min(fromIdx, messages.length));
3173
+ for (let i = start; i < messages.length; i++) {
3174
+ const m = messages[i];
3175
+ if (m === void 0 || m.role !== "user" || isToolResultMessage(m)) continue;
3176
+ push(m);
3177
+ }
3178
+ return out;
3179
+ }
3180
+ /** Merge newly forwarded attachment ids into a bounded, oldest-first list. */
3181
+ function mergeSentImageIds(existing, added, max = 64) {
3182
+ const out = [];
3183
+ const seen = /* @__PURE__ */ new Set();
3184
+ for (const id of [...existing ?? [], ...added]) {
3185
+ if (typeof id !== "string" || id === "" || seen.has(id)) continue;
3186
+ seen.add(id);
3187
+ out.push(id);
3188
+ }
3189
+ return out.length > max ? out.slice(out.length - max) : out;
3190
+ }
3191
+ /**
2893
3192
  * Rolling digest of turns this agy conversation has not seen (ADR-7).
2894
3193
  *
2895
3194
  * Budget policy (issue #35): the latest real user task is reserved first so
@@ -2968,6 +3267,14 @@ function sawAuthFailure(parser, outcome) {
2968
3267
  if (parser.stats.sawAuthFailure) return true;
2969
3268
  return looksLikeAuthFailure(outcome.stderrTail) || looksLikeAuthFailure(outcome.stdout.slice(0, 4e3));
2970
3269
  }
3270
+ /**
3271
+ * Bounded wait for an aborted run to settle, so a steer replacement never
3272
+ * overlaps the process it just killed on the same agy conversation.
3273
+ */
3274
+ async function waitForRunSettle(run, timeoutMs) {
3275
+ const deadline = Date.now() + timeoutMs;
3276
+ while (!run.isSettled && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
3277
+ }
2971
3278
  var AgyAdapter = class extends LlmAdapter {
2972
3279
  deps;
2973
3280
  warnedKeys = /* @__PURE__ */ new Set();
@@ -3006,6 +3313,11 @@ var AgyAdapter = class extends LlmAdapter {
3006
3313
  /**
3007
3314
  * Fail fast on auth and abort; allow one retry for transient process
3008
3315
  * failures (timeout, crash, malformed stream) per ADR-11.
3316
+ *
3317
+ * RATE_LIMIT is retryable on purpose: the failed account was put into
3318
+ * cooldown and the sticky pointer advanced before the error surfaced, so the
3319
+ * retry is served by the NEXT account in the pool — quota exhaustion used to
3320
+ * dead-end in a user-visible error even though a healthy account was idle.
3009
3321
  */
3010
3322
  providerRetryPolicy(_provider) {
3011
3323
  return {
@@ -3014,7 +3326,9 @@ var AgyAdapter = class extends LlmAdapter {
3014
3326
  retryableCodes: [
3015
3327
  Err.TIMEOUT,
3016
3328
  Err.PROCESS_EXIT,
3017
- Err.INVALID_OUTPUT
3329
+ Err.INVALID_OUTPUT,
3330
+ Err.RATE_LIMIT,
3331
+ Err.ELIGIBILITY
3018
3332
  ],
3019
3333
  initialDelayMs: 2e3,
3020
3334
  maxDelayMs: 1e4,
@@ -3029,29 +3343,16 @@ var AgyAdapter = class extends LlmAdapter {
3029
3343
  }
3030
3344
  async listModels(_provider) {
3031
3345
  this.deps.catalog.refreshIfNeeded();
3032
- const cat = this.deps.catalog.get();
3033
- const seen = /* @__PURE__ */ new Set();
3034
- const models = [];
3035
- const dropped = [];
3036
- for (const m of cat.models) {
3037
- if (!m || typeof m.id !== "string") continue;
3038
- const id = m.id.trim();
3039
- if (id === "") continue;
3040
- if (seen.has(id)) {
3041
- dropped.push(id);
3042
- continue;
3043
- }
3044
- seen.add(id);
3045
- const name = typeof m.name === "string" && m.name.trim() !== "" ? m.name.trim() : id;
3046
- models.push({
3047
- provider: PROVIDER_ID,
3048
- id,
3049
- name,
3050
- inputModalities: ["text", "image"]
3051
- });
3052
- }
3346
+ const cfg = this.deps.getConfig();
3347
+ const { entries, dropped } = sanitizeCatalogEntries(this.deps.catalog.get().models);
3053
3348
  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)");
3054
- return models;
3349
+ const hidden = new Set(cfg.hiddenModels.map((s) => s.trim()));
3350
+ return entries.filter((m) => !hidden.has(m.id)).map((m) => ({
3351
+ provider: PROVIDER_ID,
3352
+ id: m.id,
3353
+ name: m.name,
3354
+ inputModalities: ["text", "image"]
3355
+ }));
3055
3356
  }
3056
3357
  async resolveModel(_provider, model, _signal) {
3057
3358
  const cfg = this.deps.getConfig();
@@ -3132,10 +3433,12 @@ var AgyAdapter = class extends LlmAdapter {
3132
3433
  const cfg = this.deps.getConfig();
3133
3434
  const bin = this.deps.bin();
3134
3435
  if (!bin) throw new LlmError("agy binary not found on PATH — install it via https://antigravity.google/docs/cli/install", Err.AGY_NOT_INSTALLED);
3135
- const isAux = options.purpose === "compaction" || options.purpose === "session-title";
3136
- if (isAux && !cfg.allowAuxiliary) throw new LlmError("auxiliary calls are disabled for the antigravity route (allowAuxiliary: false)", Err.AUX_DISABLED);
3436
+ const isReview = typeof options.system === "string" && options.system.includes("REVIEW_POLICY");
3437
+ const isAux = options.purpose === "compaction" || options.purpose === "session-title" || isReview;
3438
+ if (isAux && !isReview && !cfg.allowAuxiliary) throw new LlmError("auxiliary calls are disabled for the antigravity route (allowAuxiliary: false)", Err.AUX_DISABLED);
3137
3439
  const toolNames = new Set((options.tools ?? []).map((t) => t.name));
3138
3440
  const isCodeMode = toolNames.has("run_code") && !toolNames.has("agy_tool");
3441
+ const hasToolSupport = options.tools === void 0 || toolNames.has("agy_tool") || toolNames.has("run_code");
3139
3442
  const sessionKey = options.sessionId !== void 0 ? String(options.sessionId) : "";
3140
3443
  let workspaceRoot = cfg.workspaceRoot;
3141
3444
  if (workspaceRoot === "") {
@@ -3149,28 +3452,11 @@ var AgyAdapter = class extends LlmAdapter {
3149
3452
  const continuation = detectContinuation(options.messages);
3150
3453
  if (continuation !== null) {
3151
3454
  const rec = this.deps.runs.get(continuation.runId);
3152
- if (rec === void 0) {
3153
- yield {
3154
- type: "usage",
3155
- usage: {
3156
- inputTokens: 0,
3157
- outputTokens: 0
3158
- }
3159
- };
3160
- yield {
3161
- type: "finish",
3162
- reason: {
3163
- kind: "error",
3164
- failure: {
3165
- message: "agy run " + continuation.runId + " is no longer available (server restarted?) — please resend your message",
3166
- code: Err.AGY_ERROR
3167
- }
3168
- }
3169
- };
3455
+ if (rec !== void 0) {
3456
+ yield* this.driveSpan(rec, continuation.eventIndex + 1, hasToolSupport, isCodeMode);
3170
3457
  return;
3171
3458
  }
3172
- yield* this.driveSpan(rec, continuation.eventIndex + 1, true, isCodeMode);
3173
- return;
3459
+ 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");
3174
3460
  }
3175
3461
  const catalog = this.deps.catalog.get();
3176
3462
  const rawModel = options.model;
@@ -3214,14 +3500,25 @@ var AgyAdapter = class extends LlmAdapter {
3214
3500
  let family = modelFamilyOf(activeModel);
3215
3501
  let account = this.deps.pool ? this.deps.pool.selectAccount(family) : void 0;
3216
3502
  if (this.deps.pool && this.deps.pool.getAccounts().length > 0 && !account) {
3217
- if (cfg.autoFallbackModel) for (const fb of ["gemini-3.5-flash", "gemini-3.6-flash"]) {
3218
- const fbFam = modelFamilyOf(fb);
3219
- const fbAcc = this.deps.pool.selectAccount(fbFam);
3220
- if (fbAcc) {
3221
- account = fbAcc;
3222
- family = fbFam;
3223
- activeModel = fb;
3224
- break;
3503
+ if (cfg.autoFallbackModel) {
3504
+ const familyModel = {
3505
+ google: "gemini-3.8-flash",
3506
+ anthropic: "claude-sonnet-4-6",
3507
+ openai: "gpt-oss-120b-medium"
3508
+ };
3509
+ for (const fbFam of [
3510
+ "google",
3511
+ "anthropic",
3512
+ "openai"
3513
+ ]) {
3514
+ if (fbFam === family) continue;
3515
+ const fbAcc = this.deps.pool.selectAccount(fbFam);
3516
+ if (fbAcc) {
3517
+ account = fbAcc;
3518
+ family = fbFam;
3519
+ activeModel = familyModel[fbFam] ?? activeModel;
3520
+ break;
3521
+ }
3225
3522
  }
3226
3523
  }
3227
3524
  if (!account) {
@@ -3230,6 +3527,7 @@ var AgyAdapter = class extends LlmAdapter {
3230
3527
  throw new LlmError(`All Antigravity accounts in pool are in cooldown for ${family}${waitStr}. Add an account or wait for reset.`, Err.AGY_ERROR);
3231
3528
  }
3232
3529
  }
3530
+ if (account && !isAux) this.deps.pool?.markAccountActive(account.id);
3233
3531
  const sessionAccountKey = account ? `${sessionKey}:${account.id}` : sessionKey;
3234
3532
  let binding = sessionAccountKey !== "" ? this.deps.store.get(sessionAccountKey) : void 0;
3235
3533
  if (binding === void 0 && !isAux && sessionKey !== "") {
@@ -3271,7 +3569,7 @@ var AgyAdapter = class extends LlmAdapter {
3271
3569
  const trailingJoined = trailingText.join("\n\n");
3272
3570
  if (taskText !== "") prompt = trailingText.includes(taskText) || trailingJoined.includes(taskText) ? trailingJoined : trailingJoined === "" ? taskText : taskText + "\n\n" + trailingJoined;
3273
3571
  else prompt = trailingJoined;
3274
- if (binding === void 0 && lastAssistantIdx >= 0) prompt = buildDigest(messages, 0, cfg.digestMaxChars) + prompt;
3572
+ if (binding === void 0 && lastAssistantIdx >= 0) prompt = buildDigest(messages, 0, cfg.contextTransferMaxChars) + prompt;
3275
3573
  else if (binding !== void 0) {
3276
3574
  const from = Math.min(binding.lastMessageCount, messages.length);
3277
3575
  const end = Math.max(from, lastAssistantIdx + 1);
@@ -3281,18 +3579,15 @@ var AgyAdapter = class extends LlmAdapter {
3281
3579
  }
3282
3580
  if (cfg.forwardSystemPrompt && options.system) prompt = "System instructions:\n" + options.system + "\n\n" + prompt;
3283
3581
  let stagedDirs = [];
3582
+ const newlySentImageIds = [];
3284
3583
  if (!isAux) {
3285
- const imageRefs = [];
3286
- for (const m of trailingUser) collectImageRefs(m, imageRefs);
3287
- if (imageRefs.length === 0) for (const m of messages) {
3288
- if (m.role !== "user" || isToolResultMessage(m)) continue;
3289
- collectImageRefs(m, imageRefs);
3290
- }
3584
+ const sentIds = binding?.sentImageIds ?? [];
3585
+ const imageRefs = collectUnsentImageRefs(messages, trailingUser, binding !== void 0 ? binding.lastMessageCount : 0, sentIds);
3291
3586
  if (imageRefs.length > 0 && this.deps.readImage) {
3292
3587
  const dir = cfg.mediaDir !== "" ? cfg.mediaDir : defaultMediaDir(stateDir());
3293
3588
  const res = await stageImages({
3294
3589
  dir,
3295
- key: (sessionKey !== "" ? sessionKey.replace(/[^a-zA-Z0-9_-]+/g, "_") : "anon") + "-" + messages.length,
3590
+ key: sessionKey !== "" ? sessionKey.replace(/[^a-zA-Z0-9_-]+/g, "_") : "anon",
3296
3591
  images: imageRefs,
3297
3592
  readImage: this.deps.readImage,
3298
3593
  maxImages: cfg.mediaMaxImages,
@@ -3300,6 +3595,7 @@ var AgyAdapter = class extends LlmAdapter {
3300
3595
  });
3301
3596
  if (res.promptSuffix !== "") prompt = prompt === "" ? res.promptSuffix + "\n\n[Please inspect the attached image(s) using view_file and assist the user.]" : prompt + "\n\n" + res.promptSuffix;
3302
3597
  if (res.staged.length > 0) stagedDirs = [dir];
3598
+ newlySentImageIds.push(...res.stagedIds);
3303
3599
  }
3304
3600
  if (prompt.trim() === "") throw new LlmError("request carries no user text or images to forward to agy", Err.AGY_ERROR);
3305
3601
  } else if (prompt.trim() === "") throw new LlmError("request carries no user text to forward to agy", Err.AGY_ERROR);
@@ -3323,6 +3619,7 @@ var AgyAdapter = class extends LlmAdapter {
3323
3619
  model: currentModel
3324
3620
  };
3325
3621
  liveRun.requestAbort?.();
3622
+ await waitForRunSettle(liveRun, 3500);
3326
3623
  }
3327
3624
  this.activeSessionPrompts.set(sessionKey, {
3328
3625
  prompt,
@@ -3449,20 +3746,19 @@ var AgyAdapter = class extends LlmAdapter {
3449
3746
  const consumable = r !== null && (r.ok || r.response !== "");
3450
3747
  const rawErrText = [outcome.stderrTail, parser.stats.lastResultError].filter(Boolean).join(" ");
3451
3748
  const isRateLimit = looksLikeRateLimit(rawErrText);
3749
+ const silentTimeout = outcome.timedOut === true && outcome.stdout.trim() === "";
3452
3750
  let failure = null;
3453
3751
  if (outcome.aborted) failure = {
3454
3752
  kind: "aborted",
3455
3753
  code: "ABORTED",
3456
3754
  message: "agy run aborted by caller"
3457
3755
  };
3458
- else if (outcome.timedOut) {
3459
- const neverSpoke = outcome.stdout.trim() === "";
3460
- failure = {
3461
- kind: "error",
3462
- code: Err.TIMEOUT,
3463
- message: "agy run was idle for " + cfg.timeoutMs + "ms without output" + (neverSpoke ? " — agy never emitted a single event; it is likely unable to reach Google (check proxy/network). 无法连接 Google,请检查代理或网络配置" : "")
3464
- };
3465
- } else if (sawAuthFailure(parser, outcome)) failure = {
3756
+ else if (outcome.timedOut) failure = {
3757
+ kind: "error",
3758
+ code: Err.TIMEOUT,
3759
+ message: "agy run was idle for " + cfg.timeoutMs + "ms without output" + (silentTimeout ? " — agy never emitted a single event; it is likely unable to reach Google (check proxy/network). 无法连接 Google,请检查代理或网络配置" : "")
3760
+ };
3761
+ else if (sawAuthFailure(parser, outcome)) failure = {
3466
3762
  kind: "error",
3467
3763
  code: Err.AUTH,
3468
3764
  message: "agy is not signed in — run /agy auth (or run agy once in a terminal) to login"
@@ -3471,14 +3767,14 @@ var AgyAdapter = class extends LlmAdapter {
3471
3767
  const bestMsg = parser.stats.lastResultError || (outcome.stderrTail ? brief(outcome.stderrTail) : "Rate limit or quota reached");
3472
3768
  failure = {
3473
3769
  kind: "error",
3474
- code: Err.AGY_ERROR,
3770
+ code: looksLikeHardRateLimit(rawErrText) ? Err.RATE_LIMIT : Err.AGY_ERROR,
3475
3771
  message: "Google Antigravity quota / rate limit reached: " + bestMsg
3476
3772
  };
3477
3773
  } else if (looksLikeEligibilityFailure(rawErrText) || looksLikeEligibilityFailure(parser.stats.lastResultError ?? "")) {
3478
3774
  const detail = parser.stats.lastResultError ?? (outcome.stderrTail !== "" ? brief(outcome.stderrTail) : "");
3479
3775
  failure = {
3480
3776
  kind: "error",
3481
- code: Err.AGY_ERROR,
3777
+ code: Err.ELIGIBILITY,
3482
3778
  message: ELIGIBILITY_ERROR_HINT + (detail !== "" ? " (" + detail + ")" : "")
3483
3779
  };
3484
3780
  } else if (!consumable) {
@@ -3509,12 +3805,15 @@ var AgyAdapter = class extends LlmAdapter {
3509
3805
  conversationId: finalId,
3510
3806
  lastMessageCount: messages.length,
3511
3807
  updatedAt: Date.now(),
3512
- model: activeModel
3808
+ model: activeModel,
3809
+ sentImageIds: mergeSentImageIds(binding?.sentImageIds, newlySentImageIds)
3513
3810
  });
3514
3811
  }
3515
3812
  } else {
3516
3813
  const effectiveRateLimit = isRateLimit || looksLikeRateLimit(failure.message);
3517
- if (account && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
3814
+ if (account && !outcome.aborted && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
3815
+ if (account && silentTimeout) this.deps.pool?.advanceAccount(account.id, family);
3816
+ if (account && failure.code === Err.ELIGIBILITY) this.deps.pool?.advanceAccount(account.id, family);
3518
3817
  if (account && shouldMarkAuthRequired(failure.code, failure.message)) this.deps.pool?.markAuthRequired(account.id, failure.message);
3519
3818
  const staleConversation = failure.code === Err.AUTH || effectiveRateLimit || failure.message && /conversation.*(not found|invalid|not recognized|expired|does not exist)|session.*(expired|invalid)/i.test(failure.message);
3520
3819
  if (!isAux && sessionAccountKey !== "" && staleConversation) this.deps.store.delete(sessionAccountKey);
@@ -3524,7 +3823,8 @@ var AgyAdapter = class extends LlmAdapter {
3524
3823
  conversationId: finalId,
3525
3824
  lastMessageCount: messages.length,
3526
3825
  updatedAt: Date.now(),
3527
- model: activeModel
3826
+ model: activeModel,
3827
+ sentImageIds: mergeSentImageIds(binding?.sentImageIds, newlySentImageIds)
3528
3828
  });
3529
3829
  }
3530
3830
  }
@@ -3543,7 +3843,7 @@ var AgyAdapter = class extends LlmAdapter {
3543
3843
  message: "internal error: " + brief(String(err))
3544
3844
  });
3545
3845
  });
3546
- yield* this.driveSpan(rec, 0, !isAux, isCodeMode);
3846
+ yield* this.driveSpan(rec, 0, !isAux && hasToolSupport, isCodeMode);
3547
3847
  }
3548
3848
  /**
3549
3849
  * Stream one span of a recording: map events from `from` until the mapper
@@ -28170,54 +28470,6 @@ function writeDoctorReport(deps) {
28170
28470
  return file;
28171
28471
  }
28172
28472
  //#endregion
28173
- //#region src/host/sessions.ts
28174
- var SessionStore = class {
28175
- file;
28176
- data = {};
28177
- constructor(file) {
28178
- this.file = file;
28179
- this.load();
28180
- }
28181
- load() {
28182
- try {
28183
- if (!existsSync(this.file)) return;
28184
- const v = JSON.parse(readFileSync(this.file, "utf8"));
28185
- if (v && typeof v === "object") this.data = v;
28186
- } catch {}
28187
- }
28188
- get(key) {
28189
- return this.data[key];
28190
- }
28191
- set(key, b) {
28192
- this.data[key] = b;
28193
- this.persist();
28194
- }
28195
- delete(key) {
28196
- delete this.data[key];
28197
- this.persist();
28198
- }
28199
- clear() {
28200
- this.data = {};
28201
- this.persist();
28202
- }
28203
- all() {
28204
- return this.data;
28205
- }
28206
- /** Atomic write: tmp file + rename, then merge on next load. */
28207
- persist() {
28208
- try {
28209
- mkdirSync(dirname(this.file), { recursive: true });
28210
- const tmp = join(dirname(this.file), "." + require$$basename(this.file) + ".tmp");
28211
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
28212
- renameSync(tmp, this.file);
28213
- } catch {}
28214
- }
28215
- };
28216
- function require$$basename(p) {
28217
- const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
28218
- return i >= 0 ? p.slice(i + 1) : p;
28219
- }
28220
- //#endregion
28221
28473
  //#region src/host/pool.ts
28222
28474
  function defaultPoolDir() {
28223
28475
  const dshState = process.env.DSH_STATE_DIR || join(homedir(), ".dsh");
@@ -28239,10 +28491,15 @@ var AccountPoolManager = class {
28239
28491
  if (existsSync(this.file)) {
28240
28492
  const raw = readFileSync(this.file, "utf8");
28241
28493
  const parsed = JSON.parse(raw);
28242
- if (parsed && Array.isArray(parsed.accounts)) return {
28243
- ...defaultPoolData(),
28244
- ...parsed
28245
- };
28494
+ if (parsed && Array.isArray(parsed.accounts)) {
28495
+ for (const acc of parsed.accounts) if (acc.cooldowns) {
28496
+ for (const [fam, cd] of Object.entries(acc.cooldowns)) if (cd && cd.reason === "agy run aborted by caller") delete acc.cooldowns[fam];
28497
+ }
28498
+ return {
28499
+ ...defaultPoolData(),
28500
+ ...parsed
28501
+ };
28502
+ }
28246
28503
  }
28247
28504
  } catch {}
28248
28505
  return defaultPoolData();
@@ -28301,7 +28558,10 @@ var AccountPoolManager = class {
28301
28558
  this.persist();
28302
28559
  }
28303
28560
  getPoolData() {
28304
- return this.data;
28561
+ return {
28562
+ ...this.data,
28563
+ activeAccountId: this.getActiveAccountId("google")
28564
+ };
28305
28565
  }
28306
28566
  getAccounts() {
28307
28567
  return this.data.accounts;
@@ -28434,6 +28694,7 @@ var AccountPoolManager = class {
28434
28694
  });
28435
28695
  } catch {}
28436
28696
  if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
28697
+ if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28437
28698
  if (this.data.activeAccountIds) {
28438
28699
  for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28439
28700
  }
@@ -28459,18 +28720,31 @@ var AccountPoolManager = class {
28459
28720
  const acc = this.getAccount(id);
28460
28721
  if (!acc) return false;
28461
28722
  acc.enabled = enabled;
28462
- if (!enabled && this.data.activeAccountIds) {
28463
- for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28723
+ if (!enabled) {
28724
+ if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28725
+ if (this.data.activeAccountIds) {
28726
+ for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28727
+ }
28464
28728
  }
28465
28729
  this.persist();
28466
28730
  return true;
28467
28731
  }
28732
+ /**
28733
+ * Record the account a run actually spawned with. The console derives its
28734
+ * single "in use" badge from this; exactly one account runs agy at a time.
28735
+ */
28736
+ markAccountActive(id) {
28737
+ if (this.data.lastActiveAccountId === id) return;
28738
+ this.data.lastActiveAccountId = id;
28739
+ this.persist();
28740
+ }
28468
28741
  markAuthRequired(id, reason) {
28469
28742
  const acc = this.getAccount(id);
28470
28743
  if (!acc) return;
28471
28744
  acc.authRequired = true;
28472
28745
  acc.authMarkedAt = Date.now();
28473
28746
  acc.authError = reason || "Authentication expired or revoked (invalid_grant)";
28747
+ if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28474
28748
  if (this.data.activeAccountIds) {
28475
28749
  for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28476
28750
  }
@@ -28512,6 +28786,7 @@ var AccountPoolManager = class {
28512
28786
  anthropic: id,
28513
28787
  openai: id
28514
28788
  };
28789
+ this.data.lastActiveAccountId = id;
28515
28790
  this.persist();
28516
28791
  return true;
28517
28792
  }
@@ -28534,6 +28809,104 @@ var AccountPoolManager = class {
28534
28809
  this.data.mode = mode;
28535
28810
  this.persist();
28536
28811
  }
28812
+ /**
28813
+ * Check whether an account is eligible to serve requests for the given family.
28814
+ */
28815
+ isAccountEligible(acc, family, now = Date.now()) {
28816
+ if (!acc.enabled || acc.authRequired) return false;
28817
+ const cd = acc.cooldowns[family];
28818
+ if (cd && cd.cooldownUntil > now) return false;
28819
+ const quota = acc.quotas[family];
28820
+ if (quota && typeof quota.remainingFraction === "number" && quota.remainingFraction <= .02) {
28821
+ if (quota.resetTime) {
28822
+ const resetMs = Date.parse(quota.resetTime);
28823
+ if (!Number.isNaN(resetMs) && resetMs > now) return false;
28824
+ }
28825
+ }
28826
+ if (quota && typeof quota.weeklyFraction === "number" && quota.weeklyFraction <= .01) {
28827
+ if (quota.weeklyResetTime) {
28828
+ const resetMs = Date.parse(quota.weeklyResetTime);
28829
+ if (!Number.isNaN(resetMs) && resetMs > now) return false;
28830
+ }
28831
+ }
28832
+ return true;
28833
+ }
28834
+ /**
28835
+ * Returns the currently active and healthy account ID.
28836
+ * If the recorded lastActiveAccountId is healthy, returns it;
28837
+ * otherwise falls back to selectAccount(family) to advance to the next healthy candidate,
28838
+ * or primaryAccountId if all are exhausted.
28839
+ */
28840
+ getActiveAccountId(family = "google") {
28841
+ const now = Date.now();
28842
+ if (this.data.lastActiveAccountId) {
28843
+ const active = this.getAccount(this.data.lastActiveAccountId);
28844
+ if (active && this.isAccountEligible(active, family, now)) return active.id;
28845
+ }
28846
+ const next = this.selectAccount(family);
28847
+ if (next) return next.id;
28848
+ return this.data.primaryAccountId ?? this.data.accounts[0]?.id;
28849
+ }
28850
+ /**
28851
+ * Next moment this account's quota state refreshes for one family: the
28852
+ * sooner of the 5h window reset and the weekly cap reset. NaN when unknown.
28853
+ */
28854
+ nextQuotaResetAt(acc, family) {
28855
+ const q = acc.quotas[family];
28856
+ const stamps = [q?.resetTime, q?.weeklyResetTime].filter((v) => typeof v === "string" && v !== "").map((v) => Date.parse(v)).filter((t) => !Number.isNaN(t));
28857
+ return stamps.length === 0 ? NaN : Math.min(...stamps);
28858
+ }
28859
+ /** Cyclic distance from `fromId` (0 = itself), so ties keep pool order. */
28860
+ cyclicDistance(fromId, id) {
28861
+ const total = this.data.accounts.length;
28862
+ const idx = this.data.accounts.findIndex((a) => a.id === id);
28863
+ if (idx === -1) return Number.MAX_SAFE_INTEGER;
28864
+ if (fromId === void 0) return idx;
28865
+ const start = this.data.accounts.findIndex((a) => a.id === fromId);
28866
+ if (start === -1) return idx;
28867
+ return (idx - start + total) % total;
28868
+ }
28869
+ /**
28870
+ * "Use it or lose it" rotation order. When the sticky account is drained we
28871
+ * prefer the eligible account whose quota window refreshes SOONEST: residual
28872
+ * quota disappears at that reset, so draining it first is free capacity the
28873
+ * pool would otherwise throw away. Accounts with no quota metadata yet keep
28874
+ * the legacy cyclic order (which also keeps a drained slot from bouncing
28875
+ * straight back to the primary).
28876
+ */
28877
+ orderByExpiry(candidates, family, fromId) {
28878
+ return candidates.map((acc, index) => ({
28879
+ acc,
28880
+ index,
28881
+ at: this.nextQuotaResetAt(acc, family)
28882
+ })).sort((a, b) => {
28883
+ const aKnown = !Number.isNaN(a.at);
28884
+ const bKnown = !Number.isNaN(b.at);
28885
+ if (aKnown !== bKnown) return aKnown ? -1 : 1;
28886
+ if (aKnown && bKnown && a.at !== b.at) return a.at - b.at;
28887
+ const da = this.cyclicDistance(fromId, a.acc.id);
28888
+ const db = this.cyclicDistance(fromId, b.acc.id);
28889
+ return da !== db ? da - db : a.index - b.index;
28890
+ }).map((e) => e.acc);
28891
+ }
28892
+ /**
28893
+ * Move the active pointer past `fromId` for one family WITHOUT cooling the
28894
+ * account down, and return the account the next run should use. Used when a
28895
+ * run produced no output at all (silent timeout): the retry must try another
28896
+ * account, but a healthy account should not be punished with a cooldown
28897
+ * because the network blipped.
28898
+ */
28899
+ advanceAccount(fromId, family) {
28900
+ const now = Date.now();
28901
+ const candidates = this.data.accounts.filter((a) => a.id !== fromId && this.isAccountEligible(a, family, now));
28902
+ if (candidates.length === 0) return null;
28903
+ const next = this.orderByExpiry(candidates, family, fromId)[0];
28904
+ if (!this.data.activeAccountIds) this.data.activeAccountIds = {};
28905
+ this.data.activeAccountIds[family] = next.id;
28906
+ this.data.lastActiveAccountId = next.id;
28907
+ this.persist();
28908
+ return next.id;
28909
+ }
28537
28910
  updateAccountQuotas(id, quotas, email) {
28538
28911
  const acc = this.getAccount(id);
28539
28912
  if (!acc) return;
@@ -28542,6 +28915,15 @@ var AccountPoolManager = class {
28542
28915
  ...quotas
28543
28916
  };
28544
28917
  if (email) acc.email = email;
28918
+ const now = Date.now();
28919
+ for (const [famKey, info] of Object.entries(quotas)) {
28920
+ if (!info) continue;
28921
+ const isExhausted5h = typeof info.remainingFraction === "number" && info.remainingFraction <= .02 && (!info.resetTime || Date.parse(info.resetTime) > now);
28922
+ const isExhaustedWeekly = typeof info.weeklyFraction === "number" && info.weeklyFraction <= .01 && (!info.weeklyResetTime || Date.parse(info.weeklyResetTime) > now);
28923
+ if (isExhausted5h || isExhaustedWeekly) {
28924
+ if (this.data.activeAccountIds?.[famKey] === id || this.data.lastActiveAccountId === id) this.selectAccount(famKey);
28925
+ }
28926
+ }
28545
28927
  this.persist();
28546
28928
  }
28547
28929
  /**
@@ -28564,6 +28946,7 @@ var AccountPoolManager = class {
28564
28946
  reason,
28565
28947
  consecutiveFailures: failures
28566
28948
  };
28949
+ if (this.data.activeAccountIds?.[family] === id || this.data.lastActiveAccountId === id) this.selectAccount(family);
28567
28950
  this.persist();
28568
28951
  }
28569
28952
  /**
@@ -28598,48 +28981,33 @@ var AccountPoolManager = class {
28598
28981
  */
28599
28982
  selectAccount(family) {
28600
28983
  const now = Date.now();
28601
- const candidates = this.data.accounts.filter((acc) => {
28602
- if (!acc.enabled || acc.authRequired) return false;
28603
- const cd = acc.cooldowns[family];
28604
- if (cd && cd.cooldownUntil > now) return false;
28605
- const quota = acc.quotas[family];
28606
- if (quota && typeof quota.remainingFraction === "number" && quota.remainingFraction <= .02) {
28607
- if (quota.resetTime) {
28608
- const resetMs = Date.parse(quota.resetTime);
28609
- if (!Number.isNaN(resetMs) && resetMs > now) return false;
28610
- }
28611
- }
28612
- if (quota && typeof quota.weeklyFraction === "number" && quota.weeklyFraction <= .01) {
28613
- if (quota.weeklyResetTime) {
28614
- const resetMs = Date.parse(quota.weeklyResetTime);
28615
- if (!Number.isNaN(resetMs) && resetMs > now) return false;
28616
- }
28617
- }
28618
- return true;
28619
- });
28984
+ const candidates = this.data.accounts.filter((acc) => this.isAccountEligible(acc, family, now));
28620
28985
  if (candidates.length === 0) return null;
28621
- if (this.data.mode === "round-robin" && candidates.length > 1) return candidates.slice().sort((a, b) => (a.lastUsedAt ?? 0) - (b.lastUsedAt ?? 0))[0] ?? null;
28986
+ if (this.data.mode === "round-robin" && candidates.length > 1) {
28987
+ const picked = candidates.slice().sort((a, b) => (a.lastUsedAt ?? 0) - (b.lastUsedAt ?? 0))[0] ?? null;
28988
+ if (picked) {
28989
+ if (!this.data.activeAccountIds) this.data.activeAccountIds = {};
28990
+ this.data.activeAccountIds[family] = picked.id;
28991
+ this.data.lastActiveAccountId = picked.id;
28992
+ this.persist();
28993
+ }
28994
+ return picked;
28995
+ }
28622
28996
  const activeId = this.data.activeAccountIds?.[family];
28623
28997
  if (activeId) {
28624
28998
  const activeCandidate = candidates.find((a) => a.id === activeId);
28625
- if (activeCandidate) return activeCandidate;
28626
- }
28627
- let nextAccount = candidates[0];
28628
- if (activeId) {
28629
- const currentIndex = this.data.accounts.findIndex((a) => a.id === activeId);
28630
- if (currentIndex !== -1) {
28631
- const total = this.data.accounts.length;
28632
- for (let i = 1; i < total; i++) {
28633
- const checkAcc = this.data.accounts[(currentIndex + i) % total];
28634
- if (candidates.some((c) => c.id === checkAcc.id)) {
28635
- nextAccount = checkAcc;
28636
- break;
28637
- }
28999
+ if (activeCandidate) {
29000
+ if (this.data.lastActiveAccountId !== activeCandidate.id) {
29001
+ this.data.lastActiveAccountId = activeCandidate.id;
29002
+ this.persist();
28638
29003
  }
29004
+ return activeCandidate;
28639
29005
  }
28640
29006
  }
29007
+ const nextAccount = this.orderByExpiry(candidates, family, activeId)[0];
28641
29008
  if (!this.data.activeAccountIds) this.data.activeAccountIds = {};
28642
29009
  this.data.activeAccountIds[family] = nextAccount.id;
29010
+ this.data.lastActiveAccountId = nextAccount.id;
28643
29011
  this.persist();
28644
29012
  return nextAccount;
28645
29013
  }
@@ -29636,6 +30004,11 @@ function apply(ctx, entryConfig = {}) {
29636
30004
  let lastRun = null;
29637
30005
  let lastParser = new StreamJsonParser();
29638
30006
  const getConfig = () => resolveConfig(entryConfig);
30007
+ const notifyPicker = () => {
30008
+ try {
30009
+ ctx.emit("llm/adapters-updated");
30010
+ } catch {}
30011
+ };
29639
30012
  const bin = () => {
29640
30013
  if (binCache === void 0 || binCache === null) binCache = resolveAgyBin(getConfig());
29641
30014
  return binCache;
@@ -29763,6 +30136,7 @@ function apply(ctx, entryConfig = {}) {
29763
30136
  log("version probe failed — continuing with fallback catalog");
29764
30137
  }
29765
30138
  await catalog.refreshIfNeeded().catch(() => void 0);
30139
+ notifyPicker();
29766
30140
  })();
29767
30141
  }, 4e3);
29768
30142
  bootProbeTimer.unref?.();
@@ -29799,6 +30173,7 @@ function apply(ctx, entryConfig = {}) {
29799
30173
  const toolsSvcRef = { current: null };
29800
30174
  const askToolDispose = { current: null };
29801
30175
  const mirrorToolDispose = { current: null };
30176
+ const preExecuteDispose = { current: null };
29802
30177
  const syncAskTool = () => {
29803
30178
  const want = getConfig().askTool && bin() !== null;
29804
30179
  if (want && askToolDispose.current === null && toolsSvcRef.current) {
@@ -29828,7 +30203,17 @@ function apply(ctx, entryConfig = {}) {
29828
30203
  toolsSvcRef.current = sub.get("tools");
29829
30204
  syncAskTool();
29830
30205
  syncMirrorTool();
30206
+ const unlisten = sub.on("tools/pre-execute", async (exec, next) => {
30207
+ if (exec.name === "agy_tool") return { kind: "allow" };
30208
+ return next();
30209
+ }, {
30210
+ prepend: true,
30211
+ global: true
30212
+ });
30213
+ preExecuteDispose.current = typeof unlisten === "function" ? unlisten : null;
29831
30214
  return () => {
30215
+ if (preExecuteDispose.current !== null) preExecuteDispose.current();
30216
+ preExecuteDispose.current = null;
29832
30217
  if (askToolDispose.current !== null) askToolDispose.current();
29833
30218
  askToolDispose.current = null;
29834
30219
  if (mirrorToolDispose.current !== null) mirrorToolDispose.current();
@@ -29891,6 +30276,8 @@ function apply(ctx, entryConfig = {}) {
29891
30276
  count: cat.models.length,
29892
30277
  lastError: cat.lastError ?? null
29893
30278
  },
30279
+ catalogModels: sanitizeCatalogEntries(cat.models).entries,
30280
+ hiddenModels: cfg.hiddenModels,
29894
30281
  bindings: Object.keys(store.all()).length,
29895
30282
  lastRun
29896
30283
  });
@@ -29937,7 +30324,7 @@ function apply(ctx, entryConfig = {}) {
29937
30324
  if (acc) quota.refreshAccountQuota(acc).catch(() => void 0);
29938
30325
  } else quota.refreshAllQuotas().catch(() => void 0);
29939
30326
  store.clear();
29940
- catalog.forceRefresh().catch(() => void 0);
30327
+ catalog.forceRefresh().then(() => notifyPicker()).catch(() => void 0);
29941
30328
  }
29942
30329
  sendJson(res, 200, st);
29943
30330
  })();
@@ -29958,6 +30345,31 @@ function apply(ctx, entryConfig = {}) {
29958
30345
  })();
29959
30346
  }
29960
30347
  });
30348
+ reg({
30349
+ kind: "exact",
30350
+ path: "/plugins/agy-link/mermaid.min.js",
30351
+ handler: (_req, res) => {
30352
+ const p = join(import.meta.dirname, "mermaid.min.js");
30353
+ const fallback = join(import.meta.dirname, "..", "assets", "mermaid.min.js");
30354
+ const file = existsSync(p) ? p : existsSync(fallback) ? fallback : null;
30355
+ if (!file) {
30356
+ res.writeHead(404, { "content-type": "text/plain" });
30357
+ res.end("mermaid.min.js not found");
30358
+ return;
30359
+ }
30360
+ try {
30361
+ const content = readFileSync(file);
30362
+ res.writeHead(200, {
30363
+ "content-type": "application/javascript; charset=utf-8",
30364
+ "cache-control": "public, max-age=86400"
30365
+ });
30366
+ res.end(content);
30367
+ } catch (err) {
30368
+ res.writeHead(500, { "content-type": "text/plain" });
30369
+ res.end("read error: " + String(err));
30370
+ }
30371
+ }
30372
+ });
29961
30373
  reg({
29962
30374
  kind: "exact",
29963
30375
  path: "/plugins/agy-link/pool",
@@ -30187,7 +30599,7 @@ function apply(ctx, entryConfig = {}) {
30187
30599
  const acc = pool.getAccount(id);
30188
30600
  if (acc) await quota.refreshAccountQuota(acc, true);
30189
30601
  } else await quota.refreshAllQuotas(true);
30190
- catalog.forceRefresh().catch(() => void 0);
30602
+ catalog.forceRefresh().then(() => notifyPicker()).catch(() => void 0);
30191
30603
  sendJson(res, 200, {
30192
30604
  ok: true,
30193
30605
  pool: pool.getPoolData()
@@ -30231,14 +30643,20 @@ function apply(ctx, entryConfig = {}) {
30231
30643
  "defaultModel",
30232
30644
  "defaultEffort",
30233
30645
  "askTool",
30234
- "workspaceRoot"
30646
+ "workspaceRoot",
30647
+ "hiddenModels"
30235
30648
  ].includes(key)) {
30236
30649
  sendJson(res, 400, { error: "key not settable" });
30237
30650
  return;
30238
30651
  }
30652
+ if (key === "hiddenModels" && (!Array.isArray(body.value) || !body.value.every((x) => typeof x === "string"))) {
30653
+ sendJson(res, 400, { error: "hiddenModels must be an array of model ids" });
30654
+ return;
30655
+ }
30239
30656
  setOverride(key, body.value);
30240
30657
  syncAskTool();
30241
30658
  syncMirrorTool();
30659
+ if (key === "hiddenModels") notifyPicker();
30242
30660
  sendJson(res, 200, {
30243
30661
  ok: true,
30244
30662
  key,