dsh-agy-link 0.4.42 → 0.4.43

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,6 +86,7 @@ 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,
@@ -135,6 +136,20 @@ const Err = {
135
136
  AGY_NOT_INSTALLED: "AGY_NOT_INSTALLED",
136
137
  AGY_VERSION_UNSUPPORTED: "AGY_VERSION_UNSUPPORTED",
137
138
  AGY_ERROR: "AGY_ERROR",
139
+ /**
140
+ * Hard server-issued quota / rate-limit refusal. Distinct from AGY_ERROR so
141
+ * the provider retry policy can retry it (the failed account is already in
142
+ * cooldown, so the retry lands on the next account) without also retrying
143
+ * deterministic AGY_ERRORs (bad region, empty request, dead pool).
144
+ */
145
+ RATE_LIMIT: "RATE_LIMIT",
146
+ /**
147
+ * Google-side region / account eligibility refusal ("User location is not
148
+ * supported for the API use"). Account-level, so it is retryable: the
149
+ * adapter rotates the sticky pointer first, which lets an account whose
150
+ * proxy/egress region IS supported serve the retry.
151
+ */
152
+ ELIGIBILITY: "ELIGIBILITY",
138
153
  TIMEOUT: "TIMEOUT",
139
154
  PROCESS_EXIT: "PROCESS_EXIT",
140
155
  INVALID_OUTPUT: "INVALID_OUTPUT",
@@ -152,7 +167,7 @@ function looksLikeAuthFailure(text) {
152
167
  * location" — not an auth bug and not something the bridge can retry past.
153
168
  */
154
169
  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);
170
+ 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
171
  }
157
172
  /** One-line guidance for an eligibility refusal (shown in the turn error). */
158
173
  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).";
@@ -273,6 +288,7 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
273
288
  maxTokensDefault: asNum(get("maxTokensDefault")) ?? base.maxTokensDefault,
274
289
  forwardSystemPrompt: asBool(get("forwardSystemPrompt")) ?? base.forwardSystemPrompt,
275
290
  digestMaxChars: asNum(get("digestMaxChars")) ?? base.digestMaxChars,
291
+ contextTransferMaxChars: asNum(get("contextTransferMaxChars")) ?? base.contextTransferMaxChars,
276
292
  modelsCacheTtlMs: asNum(get("modelsCacheTtlMs")) ?? base.modelsCacheTtlMs,
277
293
  allowAuxiliary: asBool(get("allowAuxiliary")) ?? base.allowAuxiliary,
278
294
  compactionMaxChars: asNum(get("compactionMaxChars")) ?? base.compactionMaxChars,
@@ -1897,10 +1913,27 @@ const EXT = {
1897
1913
  "image/webp": "webp",
1898
1914
  "image/gif": "gif"
1899
1915
  };
1900
- /** Deterministic staged path: same session+turn+index reuses the file. */
1901
- function stagedPath(dir, key, index, mediaType) {
1916
+ /**
1917
+ * Sanitize an attachment id into a stable filename fragment. Ids whose
1918
+ * sanitized form collides (e.g. "a/b" vs "a_b") get a short raw-id hash
1919
+ * suffix so distinct attachments never overwrite each other's files.
1920
+ */
1921
+ function idFragment(attachmentId) {
1922
+ const cleaned = attachmentId.replace(/[^a-zA-Z0-9_-]+/g, "_");
1923
+ if (cleaned === "") return "img";
1924
+ if (cleaned === attachmentId) return cleaned.slice(0, 80);
1925
+ let h = 0;
1926
+ for (let i = 0; i < attachmentId.length; i++) h = h * 31 + attachmentId.charCodeAt(i) | 0;
1927
+ return (cleaned.slice(0, 40) + "-" + (h >>> 0).toString(36)).slice(0, 80);
1928
+ }
1929
+ /**
1930
+ * Deterministic staged path keyed by the attachment id (not the call index):
1931
+ * the same picture always lands on the same file, so a continuation that
1932
+ * legitimately re-references it overwrites instead of accumulating copies.
1933
+ */
1934
+ function stagedPath(dir, key, attachmentId, mediaType) {
1902
1935
  const ext = EXT[mediaType] ?? "png";
1903
- return join(dir, key + "-" + String(index) + "." + ext);
1936
+ return join(dir, key + "-" + idFragment(attachmentId) + "." + ext);
1904
1937
  }
1905
1938
  /**
1906
1939
  * Stage up to `maxImages` images into `dir`, returning prompt lines that
@@ -1909,6 +1942,7 @@ function stagedPath(dir, key, index, mediaType) {
1909
1942
  */
1910
1943
  async function stageImages(opts) {
1911
1944
  const staged = [];
1945
+ const stagedIds = [];
1912
1946
  const lines = [];
1913
1947
  let skipped = 0;
1914
1948
  const budget = Math.max(0, opts.maxImages);
@@ -1939,15 +1973,17 @@ async function stageImages(opts) {
1939
1973
  i++;
1940
1974
  continue;
1941
1975
  }
1942
- const path = stagedPath(opts.dir, opts.key, i, ref.mediaType);
1976
+ const path = stagedPath(opts.dir, opts.key, ref.attachmentId, ref.mediaType);
1943
1977
  await writeFile(path, data);
1944
1978
  staged.push({
1945
1979
  path,
1946
1980
  name: label,
1947
1981
  width: ref.width,
1948
1982
  height: ref.height,
1949
- bytes: ref.bytes
1983
+ bytes: ref.bytes,
1984
+ attachmentId: ref.attachmentId
1950
1985
  });
1986
+ stagedIds.push(ref.attachmentId);
1951
1987
  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
1988
  i++;
1953
1989
  }
@@ -1955,6 +1991,7 @@ async function stageImages(opts) {
1955
1991
  return {
1956
1992
  promptSuffix: lines.join("\n"),
1957
1993
  staged,
1994
+ stagedIds,
1958
1995
  skipped
1959
1996
  };
1960
1997
  }
@@ -1990,6 +2027,95 @@ function defaultMediaDir(stateDir) {
1990
2027
  //#endregion
1991
2028
  //#region src/host/runner.ts
1992
2029
  const IS_WIN = process.platform === "win32";
2030
+ /**
2031
+ * Windows pipes carry bytes in the CHILD's console code page: agy captures
2032
+ * pwsh tool output as CP936 (GBK) bytes on a zh-CN Windows, so decoding them
2033
+ * as UTF-8 mangles every non-ASCII character - and the mangled text is what
2034
+ * agy stores in its conversation database, so it can never be repaired later
2035
+ * (issue #37: the Chinese refusal text arrives mangled).
2036
+ */
2037
+ const OEM_ENCODINGS = {
2038
+ 932: "shift_jis",
2039
+ 936: "gbk",
2040
+ 949: "euc-kr",
2041
+ 950: "big5",
2042
+ 65001: "utf-8",
2043
+ 1250: "windows-1250",
2044
+ 1251: "windows-1251",
2045
+ 1252: "windows-1252",
2046
+ 1253: "windows-1253",
2047
+ 1254: "windows-1254",
2048
+ 1255: "windows-1255",
2049
+ 1256: "windows-1256",
2050
+ 1257: "windows-1257",
2051
+ 1258: "windows-1258"
2052
+ };
2053
+ let consoleEncoding = null;
2054
+ /**
2055
+ * Console/OEM code page of this process as a TextDecoder label ('utf-8' off
2056
+ * Windows). Probed once; the child inherits our console, so its tool output is
2057
+ * written in this page.
2058
+ */
2059
+ function consoleOutputEncoding(platform = process.platform) {
2060
+ if (platform !== "win32") return "utf-8";
2061
+ if (consoleEncoding !== null) return consoleEncoding;
2062
+ consoleEncoding = "utf-8";
2063
+ try {
2064
+ const out = execFileSync(process.env.ComSpec ?? "cmd.exe", [
2065
+ "/d",
2066
+ "/s",
2067
+ "/c",
2068
+ "chcp"
2069
+ ], {
2070
+ encoding: "utf8",
2071
+ windowsHide: true,
2072
+ timeout: 5e3
2073
+ });
2074
+ const m = /([0-9]{3,5})/.exec(out);
2075
+ const cp = m ? Number(m[1]) : NaN;
2076
+ const mapped = OEM_ENCODINGS[cp];
2077
+ if (mapped !== void 0) consoleEncoding = mapped;
2078
+ } catch {}
2079
+ return consoleEncoding;
2080
+ }
2081
+ /**
2082
+ * Decode one LINE of child output: UTF-8 first, and when that produces
2083
+ * replacement characters re-decode those bytes with the console code page.
2084
+ * Lines (not raw chunks) are the unit on purpose - a multi-byte character can
2085
+ * straddle a chunk boundary but never a newline, in UTF-8 or the OEM pages.
2086
+ */
2087
+ function decodeConsoleLine(bytes, encoding = consoleOutputEncoding()) {
2088
+ const text = bytes.toString("utf8");
2089
+ if (encoding === "utf-8" || !text.includes("�")) return text;
2090
+ try {
2091
+ const alt = new TextDecoder(encoding).decode(bytes);
2092
+ return alt.includes("�") ? text : alt;
2093
+ } catch {
2094
+ return text;
2095
+ }
2096
+ }
2097
+ /** Byte stream to decoded line text, holding an unterminated tail back. */
2098
+ function makeLineDecoder(encoding) {
2099
+ let rest = Buffer.alloc(0);
2100
+ return {
2101
+ push(chunk) {
2102
+ rest = rest.length === 0 ? chunk : Buffer.concat([rest, chunk]);
2103
+ let out = "";
2104
+ let nl;
2105
+ while ((nl = rest.indexOf(10)) >= 0) {
2106
+ out += decodeConsoleLine(rest.subarray(0, nl + 1), encoding);
2107
+ rest = rest.subarray(nl + 1);
2108
+ }
2109
+ return out;
2110
+ },
2111
+ flush() {
2112
+ if (rest.length === 0) return "";
2113
+ const out = decodeConsoleLine(rest, encoding);
2114
+ rest = Buffer.alloc(0);
2115
+ return out;
2116
+ }
2117
+ };
2118
+ }
1993
2119
  /** Executable candidates for one PATH entry, per-platform. Exported for tests. */
1994
2120
  function binCandidates(dir, platform = process.platform) {
1995
2121
  return (platform === "win32" ? [
@@ -2311,14 +2437,16 @@ function startAgyProcess(opts) {
2311
2437
  killTree(child);
2312
2438
  };
2313
2439
  opts.signal?.addEventListener("abort", onAbort, { once: true });
2314
- if (child.stdout) child.stdout.setEncoding("utf8");
2315
- if (child.stderr) child.stderr.setEncoding("utf8");
2440
+ const stdoutDecoder = makeLineDecoder(consoleOutputEncoding());
2441
+ const stderrDecoder = makeLineDecoder(consoleOutputEncoding());
2316
2442
  let pending = "";
2317
2443
  child.stdout?.on("data", (chunk) => {
2318
2444
  refreshWatchdog();
2319
- stdout += chunk;
2445
+ const text = stdoutDecoder.push(chunk);
2446
+ if (text === "") return;
2447
+ stdout += text;
2320
2448
  if (stdout.length > 4e6) stdout = stdout.slice(-2e6);
2321
- pending += chunk;
2449
+ pending += text;
2322
2450
  let nl;
2323
2451
  while ((nl = pending.indexOf("\n")) >= 0) {
2324
2452
  const line = pending.slice(0, nl).replace(/\r$/, "");
@@ -2328,7 +2456,8 @@ function startAgyProcess(opts) {
2328
2456
  });
2329
2457
  child.stderr?.on("data", (chunk) => {
2330
2458
  if (opts.stderrKeepalive) refreshWatchdog();
2331
- stderr = (stderr + chunk).slice(-4096);
2459
+ const text = stderrDecoder.push(chunk);
2460
+ if (text !== "") stderr = (stderr + text).slice(-4096);
2332
2461
  });
2333
2462
  return {
2334
2463
  child,
@@ -2338,6 +2467,13 @@ function startAgyProcess(opts) {
2338
2467
  settled = true;
2339
2468
  if (watchdog) clearTimeout(watchdog);
2340
2469
  opts.signal?.removeEventListener("abort", onAbort);
2470
+ const stdoutTail = stdoutDecoder.flush();
2471
+ if (stdoutTail !== "") {
2472
+ stdout += stdoutTail;
2473
+ pending += stdoutTail;
2474
+ }
2475
+ const stderrTail = stderrDecoder.flush();
2476
+ if (stderrTail !== "") stderr = (stderr + stderrTail).slice(-4096);
2341
2477
  if (pending !== "") {
2342
2478
  opts.onLine?.(pending);
2343
2479
  pending = "";
@@ -2381,6 +2517,54 @@ async function probeProcess(bin, args, timeoutMs = 3e4, signal, env) {
2381
2517
  }).outcome;
2382
2518
  }
2383
2519
  //#endregion
2520
+ //#region src/host/sessions.ts
2521
+ var SessionStore = class {
2522
+ file;
2523
+ data = {};
2524
+ constructor(file) {
2525
+ this.file = file;
2526
+ this.load();
2527
+ }
2528
+ load() {
2529
+ try {
2530
+ if (!existsSync(this.file)) return;
2531
+ const v = JSON.parse(readFileSync(this.file, "utf8"));
2532
+ if (v && typeof v === "object") this.data = v;
2533
+ } catch {}
2534
+ }
2535
+ get(key) {
2536
+ return this.data[key];
2537
+ }
2538
+ set(key, b) {
2539
+ this.data[key] = b;
2540
+ this.persist();
2541
+ }
2542
+ delete(key) {
2543
+ delete this.data[key];
2544
+ this.persist();
2545
+ }
2546
+ clear() {
2547
+ this.data = {};
2548
+ this.persist();
2549
+ }
2550
+ all() {
2551
+ return this.data;
2552
+ }
2553
+ /** Atomic write: tmp file + rename, then merge on next load. */
2554
+ persist() {
2555
+ try {
2556
+ mkdirSync(dirname(this.file), { recursive: true });
2557
+ const tmp = join(dirname(this.file), "." + require$$basename(this.file) + ".tmp");
2558
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
2559
+ renameSync(tmp, this.file);
2560
+ } catch {}
2561
+ }
2562
+ };
2563
+ function require$$basename(p) {
2564
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
2565
+ return i >= 0 ? p.slice(i + 1) : p;
2566
+ }
2567
+ //#endregion
2384
2568
  //#region src/host/agy-db.ts
2385
2569
  const execFileAsync = promisify(execFile);
2386
2570
  let AGY_DB_DIR = join(homedir(), ".gemini", "antigravity-cli", "conversations");
@@ -2890,6 +3074,47 @@ function digestLabel(m) {
2890
3074
  return m.role === "user" ? "User: " : "Assistant: ";
2891
3075
  }
2892
3076
  /**
3077
+ * Images this hop should forward, minus what the current agy conversation
3078
+ * has already received. Trailing-span attachments come first (this hop's
3079
+ * screenshots), then any user-turn attachments since `fromIdx` that carry
3080
+ * unseen ids (tool-hop continuations and foreign-model turns used to fall
3081
+ * into a "re-send every historical screenshot" path).
3082
+ */
3083
+ function collectUnsentImageRefs(messages, trailingUser, fromIdx, sentIds) {
3084
+ const sent = new Set(sentIds);
3085
+ const seen = /* @__PURE__ */ new Set();
3086
+ const out = [];
3087
+ const push = (m) => {
3088
+ const refs = [];
3089
+ collectImageRefs(m, refs);
3090
+ for (const r of refs) {
3091
+ const id = r.attachmentId;
3092
+ if (typeof id !== "string" || id === "" || sent.has(id) || seen.has(id)) continue;
3093
+ seen.add(id);
3094
+ out.push(r);
3095
+ }
3096
+ };
3097
+ for (const m of trailingUser) push(m);
3098
+ const start = Math.max(0, Math.min(fromIdx, messages.length));
3099
+ for (let i = start; i < messages.length; i++) {
3100
+ const m = messages[i];
3101
+ if (m === void 0 || m.role !== "user" || isToolResultMessage(m)) continue;
3102
+ push(m);
3103
+ }
3104
+ return out;
3105
+ }
3106
+ /** Merge newly forwarded attachment ids into a bounded, oldest-first list. */
3107
+ function mergeSentImageIds(existing, added, max = 64) {
3108
+ const out = [];
3109
+ const seen = /* @__PURE__ */ new Set();
3110
+ for (const id of [...existing ?? [], ...added]) {
3111
+ if (typeof id !== "string" || id === "" || seen.has(id)) continue;
3112
+ seen.add(id);
3113
+ out.push(id);
3114
+ }
3115
+ return out.length > max ? out.slice(out.length - max) : out;
3116
+ }
3117
+ /**
2893
3118
  * Rolling digest of turns this agy conversation has not seen (ADR-7).
2894
3119
  *
2895
3120
  * Budget policy (issue #35): the latest real user task is reserved first so
@@ -2968,6 +3193,14 @@ function sawAuthFailure(parser, outcome) {
2968
3193
  if (parser.stats.sawAuthFailure) return true;
2969
3194
  return looksLikeAuthFailure(outcome.stderrTail) || looksLikeAuthFailure(outcome.stdout.slice(0, 4e3));
2970
3195
  }
3196
+ /**
3197
+ * Bounded wait for an aborted run to settle, so a steer replacement never
3198
+ * overlaps the process it just killed on the same agy conversation.
3199
+ */
3200
+ async function waitForRunSettle(run, timeoutMs) {
3201
+ const deadline = Date.now() + timeoutMs;
3202
+ while (!run.isSettled && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
3203
+ }
2971
3204
  var AgyAdapter = class extends LlmAdapter {
2972
3205
  deps;
2973
3206
  warnedKeys = /* @__PURE__ */ new Set();
@@ -3006,6 +3239,11 @@ var AgyAdapter = class extends LlmAdapter {
3006
3239
  /**
3007
3240
  * Fail fast on auth and abort; allow one retry for transient process
3008
3241
  * failures (timeout, crash, malformed stream) per ADR-11.
3242
+ *
3243
+ * RATE_LIMIT is retryable on purpose: the failed account was put into
3244
+ * cooldown and the sticky pointer advanced before the error surfaced, so the
3245
+ * retry is served by the NEXT account in the pool — quota exhaustion used to
3246
+ * dead-end in a user-visible error even though a healthy account was idle.
3009
3247
  */
3010
3248
  providerRetryPolicy(_provider) {
3011
3249
  return {
@@ -3014,7 +3252,9 @@ var AgyAdapter = class extends LlmAdapter {
3014
3252
  retryableCodes: [
3015
3253
  Err.TIMEOUT,
3016
3254
  Err.PROCESS_EXIT,
3017
- Err.INVALID_OUTPUT
3255
+ Err.INVALID_OUTPUT,
3256
+ Err.RATE_LIMIT,
3257
+ Err.ELIGIBILITY
3018
3258
  ],
3019
3259
  initialDelayMs: 2e3,
3020
3260
  maxDelayMs: 1e4,
@@ -3214,14 +3454,25 @@ var AgyAdapter = class extends LlmAdapter {
3214
3454
  let family = modelFamilyOf(activeModel);
3215
3455
  let account = this.deps.pool ? this.deps.pool.selectAccount(family) : void 0;
3216
3456
  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;
3457
+ if (cfg.autoFallbackModel) {
3458
+ const familyModel = {
3459
+ google: "gemini-3.8-flash",
3460
+ anthropic: "claude-sonnet-4-6",
3461
+ openai: "gpt-oss-120b-medium"
3462
+ };
3463
+ for (const fbFam of [
3464
+ "google",
3465
+ "anthropic",
3466
+ "openai"
3467
+ ]) {
3468
+ if (fbFam === family) continue;
3469
+ const fbAcc = this.deps.pool.selectAccount(fbFam);
3470
+ if (fbAcc) {
3471
+ account = fbAcc;
3472
+ family = fbFam;
3473
+ activeModel = familyModel[fbFam] ?? activeModel;
3474
+ break;
3475
+ }
3225
3476
  }
3226
3477
  }
3227
3478
  if (!account) {
@@ -3230,6 +3481,7 @@ var AgyAdapter = class extends LlmAdapter {
3230
3481
  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
3482
  }
3232
3483
  }
3484
+ if (account && !isAux) this.deps.pool?.markAccountActive(account.id);
3233
3485
  const sessionAccountKey = account ? `${sessionKey}:${account.id}` : sessionKey;
3234
3486
  let binding = sessionAccountKey !== "" ? this.deps.store.get(sessionAccountKey) : void 0;
3235
3487
  if (binding === void 0 && !isAux && sessionKey !== "") {
@@ -3271,7 +3523,7 @@ var AgyAdapter = class extends LlmAdapter {
3271
3523
  const trailingJoined = trailingText.join("\n\n");
3272
3524
  if (taskText !== "") prompt = trailingText.includes(taskText) || trailingJoined.includes(taskText) ? trailingJoined : trailingJoined === "" ? taskText : taskText + "\n\n" + trailingJoined;
3273
3525
  else prompt = trailingJoined;
3274
- if (binding === void 0 && lastAssistantIdx >= 0) prompt = buildDigest(messages, 0, cfg.digestMaxChars) + prompt;
3526
+ if (binding === void 0 && lastAssistantIdx >= 0) prompt = buildDigest(messages, 0, cfg.contextTransferMaxChars) + prompt;
3275
3527
  else if (binding !== void 0) {
3276
3528
  const from = Math.min(binding.lastMessageCount, messages.length);
3277
3529
  const end = Math.max(from, lastAssistantIdx + 1);
@@ -3281,18 +3533,15 @@ var AgyAdapter = class extends LlmAdapter {
3281
3533
  }
3282
3534
  if (cfg.forwardSystemPrompt && options.system) prompt = "System instructions:\n" + options.system + "\n\n" + prompt;
3283
3535
  let stagedDirs = [];
3536
+ const newlySentImageIds = [];
3284
3537
  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
- }
3538
+ const sentIds = binding?.sentImageIds ?? [];
3539
+ const imageRefs = collectUnsentImageRefs(messages, trailingUser, binding !== void 0 ? binding.lastMessageCount : 0, sentIds);
3291
3540
  if (imageRefs.length > 0 && this.deps.readImage) {
3292
3541
  const dir = cfg.mediaDir !== "" ? cfg.mediaDir : defaultMediaDir(stateDir());
3293
3542
  const res = await stageImages({
3294
3543
  dir,
3295
- key: (sessionKey !== "" ? sessionKey.replace(/[^a-zA-Z0-9_-]+/g, "_") : "anon") + "-" + messages.length,
3544
+ key: sessionKey !== "" ? sessionKey.replace(/[^a-zA-Z0-9_-]+/g, "_") : "anon",
3296
3545
  images: imageRefs,
3297
3546
  readImage: this.deps.readImage,
3298
3547
  maxImages: cfg.mediaMaxImages,
@@ -3300,6 +3549,7 @@ var AgyAdapter = class extends LlmAdapter {
3300
3549
  });
3301
3550
  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
3551
  if (res.staged.length > 0) stagedDirs = [dir];
3552
+ newlySentImageIds.push(...res.stagedIds);
3303
3553
  }
3304
3554
  if (prompt.trim() === "") throw new LlmError("request carries no user text or images to forward to agy", Err.AGY_ERROR);
3305
3555
  } else if (prompt.trim() === "") throw new LlmError("request carries no user text to forward to agy", Err.AGY_ERROR);
@@ -3323,6 +3573,7 @@ var AgyAdapter = class extends LlmAdapter {
3323
3573
  model: currentModel
3324
3574
  };
3325
3575
  liveRun.requestAbort?.();
3576
+ await waitForRunSettle(liveRun, 3500);
3326
3577
  }
3327
3578
  this.activeSessionPrompts.set(sessionKey, {
3328
3579
  prompt,
@@ -3449,20 +3700,19 @@ var AgyAdapter = class extends LlmAdapter {
3449
3700
  const consumable = r !== null && (r.ok || r.response !== "");
3450
3701
  const rawErrText = [outcome.stderrTail, parser.stats.lastResultError].filter(Boolean).join(" ");
3451
3702
  const isRateLimit = looksLikeRateLimit(rawErrText);
3703
+ const silentTimeout = outcome.timedOut === true && outcome.stdout.trim() === "";
3452
3704
  let failure = null;
3453
3705
  if (outcome.aborted) failure = {
3454
3706
  kind: "aborted",
3455
3707
  code: "ABORTED",
3456
3708
  message: "agy run aborted by caller"
3457
3709
  };
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 = {
3710
+ else if (outcome.timedOut) failure = {
3711
+ kind: "error",
3712
+ code: Err.TIMEOUT,
3713
+ 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,请检查代理或网络配置" : "")
3714
+ };
3715
+ else if (sawAuthFailure(parser, outcome)) failure = {
3466
3716
  kind: "error",
3467
3717
  code: Err.AUTH,
3468
3718
  message: "agy is not signed in — run /agy auth (or run agy once in a terminal) to login"
@@ -3471,14 +3721,14 @@ var AgyAdapter = class extends LlmAdapter {
3471
3721
  const bestMsg = parser.stats.lastResultError || (outcome.stderrTail ? brief(outcome.stderrTail) : "Rate limit or quota reached");
3472
3722
  failure = {
3473
3723
  kind: "error",
3474
- code: Err.AGY_ERROR,
3724
+ code: looksLikeHardRateLimit(rawErrText) ? Err.RATE_LIMIT : Err.AGY_ERROR,
3475
3725
  message: "Google Antigravity quota / rate limit reached: " + bestMsg
3476
3726
  };
3477
3727
  } else if (looksLikeEligibilityFailure(rawErrText) || looksLikeEligibilityFailure(parser.stats.lastResultError ?? "")) {
3478
3728
  const detail = parser.stats.lastResultError ?? (outcome.stderrTail !== "" ? brief(outcome.stderrTail) : "");
3479
3729
  failure = {
3480
3730
  kind: "error",
3481
- code: Err.AGY_ERROR,
3731
+ code: Err.ELIGIBILITY,
3482
3732
  message: ELIGIBILITY_ERROR_HINT + (detail !== "" ? " (" + detail + ")" : "")
3483
3733
  };
3484
3734
  } else if (!consumable) {
@@ -3509,12 +3759,15 @@ var AgyAdapter = class extends LlmAdapter {
3509
3759
  conversationId: finalId,
3510
3760
  lastMessageCount: messages.length,
3511
3761
  updatedAt: Date.now(),
3512
- model: activeModel
3762
+ model: activeModel,
3763
+ sentImageIds: mergeSentImageIds(binding?.sentImageIds, newlySentImageIds)
3513
3764
  });
3514
3765
  }
3515
3766
  } else {
3516
3767
  const effectiveRateLimit = isRateLimit || looksLikeRateLimit(failure.message);
3517
- if (account && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
3768
+ if (account && !outcome.aborted && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
3769
+ if (account && silentTimeout) this.deps.pool?.advanceAccount(account.id, family);
3770
+ if (account && failure.code === Err.ELIGIBILITY) this.deps.pool?.advanceAccount(account.id, family);
3518
3771
  if (account && shouldMarkAuthRequired(failure.code, failure.message)) this.deps.pool?.markAuthRequired(account.id, failure.message);
3519
3772
  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
3773
  if (!isAux && sessionAccountKey !== "" && staleConversation) this.deps.store.delete(sessionAccountKey);
@@ -3524,7 +3777,8 @@ var AgyAdapter = class extends LlmAdapter {
3524
3777
  conversationId: finalId,
3525
3778
  lastMessageCount: messages.length,
3526
3779
  updatedAt: Date.now(),
3527
- model: activeModel
3780
+ model: activeModel,
3781
+ sentImageIds: mergeSentImageIds(binding?.sentImageIds, newlySentImageIds)
3528
3782
  });
3529
3783
  }
3530
3784
  }
@@ -28170,54 +28424,6 @@ function writeDoctorReport(deps) {
28170
28424
  return file;
28171
28425
  }
28172
28426
  //#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
28427
  //#region src/host/pool.ts
28222
28428
  function defaultPoolDir() {
28223
28429
  const dshState = process.env.DSH_STATE_DIR || join(homedir(), ".dsh");
@@ -28239,10 +28445,15 @@ var AccountPoolManager = class {
28239
28445
  if (existsSync(this.file)) {
28240
28446
  const raw = readFileSync(this.file, "utf8");
28241
28447
  const parsed = JSON.parse(raw);
28242
- if (parsed && Array.isArray(parsed.accounts)) return {
28243
- ...defaultPoolData(),
28244
- ...parsed
28245
- };
28448
+ if (parsed && Array.isArray(parsed.accounts)) {
28449
+ for (const acc of parsed.accounts) if (acc.cooldowns) {
28450
+ for (const [fam, cd] of Object.entries(acc.cooldowns)) if (cd && cd.reason === "agy run aborted by caller") delete acc.cooldowns[fam];
28451
+ }
28452
+ return {
28453
+ ...defaultPoolData(),
28454
+ ...parsed
28455
+ };
28456
+ }
28246
28457
  }
28247
28458
  } catch {}
28248
28459
  return defaultPoolData();
@@ -28301,7 +28512,10 @@ var AccountPoolManager = class {
28301
28512
  this.persist();
28302
28513
  }
28303
28514
  getPoolData() {
28304
- return this.data;
28515
+ return {
28516
+ ...this.data,
28517
+ activeAccountId: this.getActiveAccountId("google")
28518
+ };
28305
28519
  }
28306
28520
  getAccounts() {
28307
28521
  return this.data.accounts;
@@ -28434,6 +28648,7 @@ var AccountPoolManager = class {
28434
28648
  });
28435
28649
  } catch {}
28436
28650
  if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
28651
+ if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28437
28652
  if (this.data.activeAccountIds) {
28438
28653
  for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28439
28654
  }
@@ -28459,18 +28674,31 @@ var AccountPoolManager = class {
28459
28674
  const acc = this.getAccount(id);
28460
28675
  if (!acc) return false;
28461
28676
  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];
28677
+ if (!enabled) {
28678
+ if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28679
+ if (this.data.activeAccountIds) {
28680
+ for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28681
+ }
28464
28682
  }
28465
28683
  this.persist();
28466
28684
  return true;
28467
28685
  }
28686
+ /**
28687
+ * Record the account a run actually spawned with. The console derives its
28688
+ * single "in use" badge from this; exactly one account runs agy at a time.
28689
+ */
28690
+ markAccountActive(id) {
28691
+ if (this.data.lastActiveAccountId === id) return;
28692
+ this.data.lastActiveAccountId = id;
28693
+ this.persist();
28694
+ }
28468
28695
  markAuthRequired(id, reason) {
28469
28696
  const acc = this.getAccount(id);
28470
28697
  if (!acc) return;
28471
28698
  acc.authRequired = true;
28472
28699
  acc.authMarkedAt = Date.now();
28473
28700
  acc.authError = reason || "Authentication expired or revoked (invalid_grant)";
28701
+ if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28474
28702
  if (this.data.activeAccountIds) {
28475
28703
  for (const [fam, accId] of Object.entries(this.data.activeAccountIds)) if (accId === id) delete this.data.activeAccountIds[fam];
28476
28704
  }
@@ -28512,6 +28740,7 @@ var AccountPoolManager = class {
28512
28740
  anthropic: id,
28513
28741
  openai: id
28514
28742
  };
28743
+ this.data.lastActiveAccountId = id;
28515
28744
  this.persist();
28516
28745
  return true;
28517
28746
  }
@@ -28534,6 +28763,104 @@ var AccountPoolManager = class {
28534
28763
  this.data.mode = mode;
28535
28764
  this.persist();
28536
28765
  }
28766
+ /**
28767
+ * Check whether an account is eligible to serve requests for the given family.
28768
+ */
28769
+ isAccountEligible(acc, family, now = Date.now()) {
28770
+ if (!acc.enabled || acc.authRequired) return false;
28771
+ const cd = acc.cooldowns[family];
28772
+ if (cd && cd.cooldownUntil > now) return false;
28773
+ const quota = acc.quotas[family];
28774
+ if (quota && typeof quota.remainingFraction === "number" && quota.remainingFraction <= .02) {
28775
+ if (quota.resetTime) {
28776
+ const resetMs = Date.parse(quota.resetTime);
28777
+ if (!Number.isNaN(resetMs) && resetMs > now) return false;
28778
+ }
28779
+ }
28780
+ if (quota && typeof quota.weeklyFraction === "number" && quota.weeklyFraction <= .01) {
28781
+ if (quota.weeklyResetTime) {
28782
+ const resetMs = Date.parse(quota.weeklyResetTime);
28783
+ if (!Number.isNaN(resetMs) && resetMs > now) return false;
28784
+ }
28785
+ }
28786
+ return true;
28787
+ }
28788
+ /**
28789
+ * Returns the currently active and healthy account ID.
28790
+ * If the recorded lastActiveAccountId is healthy, returns it;
28791
+ * otherwise falls back to selectAccount(family) to advance to the next healthy candidate,
28792
+ * or primaryAccountId if all are exhausted.
28793
+ */
28794
+ getActiveAccountId(family = "google") {
28795
+ const now = Date.now();
28796
+ if (this.data.lastActiveAccountId) {
28797
+ const active = this.getAccount(this.data.lastActiveAccountId);
28798
+ if (active && this.isAccountEligible(active, family, now)) return active.id;
28799
+ }
28800
+ const next = this.selectAccount(family);
28801
+ if (next) return next.id;
28802
+ return this.data.primaryAccountId ?? this.data.accounts[0]?.id;
28803
+ }
28804
+ /**
28805
+ * Next moment this account's quota state refreshes for one family: the
28806
+ * sooner of the 5h window reset and the weekly cap reset. NaN when unknown.
28807
+ */
28808
+ nextQuotaResetAt(acc, family) {
28809
+ const q = acc.quotas[family];
28810
+ const stamps = [q?.resetTime, q?.weeklyResetTime].filter((v) => typeof v === "string" && v !== "").map((v) => Date.parse(v)).filter((t) => !Number.isNaN(t));
28811
+ return stamps.length === 0 ? NaN : Math.min(...stamps);
28812
+ }
28813
+ /** Cyclic distance from `fromId` (0 = itself), so ties keep pool order. */
28814
+ cyclicDistance(fromId, id) {
28815
+ const total = this.data.accounts.length;
28816
+ const idx = this.data.accounts.findIndex((a) => a.id === id);
28817
+ if (idx === -1) return Number.MAX_SAFE_INTEGER;
28818
+ if (fromId === void 0) return idx;
28819
+ const start = this.data.accounts.findIndex((a) => a.id === fromId);
28820
+ if (start === -1) return idx;
28821
+ return (idx - start + total) % total;
28822
+ }
28823
+ /**
28824
+ * "Use it or lose it" rotation order. When the sticky account is drained we
28825
+ * prefer the eligible account whose quota window refreshes SOONEST: residual
28826
+ * quota disappears at that reset, so draining it first is free capacity the
28827
+ * pool would otherwise throw away. Accounts with no quota metadata yet keep
28828
+ * the legacy cyclic order (which also keeps a drained slot from bouncing
28829
+ * straight back to the primary).
28830
+ */
28831
+ orderByExpiry(candidates, family, fromId) {
28832
+ return candidates.map((acc, index) => ({
28833
+ acc,
28834
+ index,
28835
+ at: this.nextQuotaResetAt(acc, family)
28836
+ })).sort((a, b) => {
28837
+ const aKnown = !Number.isNaN(a.at);
28838
+ const bKnown = !Number.isNaN(b.at);
28839
+ if (aKnown !== bKnown) return aKnown ? -1 : 1;
28840
+ if (aKnown && bKnown && a.at !== b.at) return a.at - b.at;
28841
+ const da = this.cyclicDistance(fromId, a.acc.id);
28842
+ const db = this.cyclicDistance(fromId, b.acc.id);
28843
+ return da !== db ? da - db : a.index - b.index;
28844
+ }).map((e) => e.acc);
28845
+ }
28846
+ /**
28847
+ * Move the active pointer past `fromId` for one family WITHOUT cooling the
28848
+ * account down, and return the account the next run should use. Used when a
28849
+ * run produced no output at all (silent timeout): the retry must try another
28850
+ * account, but a healthy account should not be punished with a cooldown
28851
+ * because the network blipped.
28852
+ */
28853
+ advanceAccount(fromId, family) {
28854
+ const now = Date.now();
28855
+ const candidates = this.data.accounts.filter((a) => a.id !== fromId && this.isAccountEligible(a, family, now));
28856
+ if (candidates.length === 0) return null;
28857
+ const next = this.orderByExpiry(candidates, family, fromId)[0];
28858
+ if (!this.data.activeAccountIds) this.data.activeAccountIds = {};
28859
+ this.data.activeAccountIds[family] = next.id;
28860
+ this.data.lastActiveAccountId = next.id;
28861
+ this.persist();
28862
+ return next.id;
28863
+ }
28537
28864
  updateAccountQuotas(id, quotas, email) {
28538
28865
  const acc = this.getAccount(id);
28539
28866
  if (!acc) return;
@@ -28542,6 +28869,15 @@ var AccountPoolManager = class {
28542
28869
  ...quotas
28543
28870
  };
28544
28871
  if (email) acc.email = email;
28872
+ const now = Date.now();
28873
+ for (const [famKey, info] of Object.entries(quotas)) {
28874
+ if (!info) continue;
28875
+ const isExhausted5h = typeof info.remainingFraction === "number" && info.remainingFraction <= .02 && (!info.resetTime || Date.parse(info.resetTime) > now);
28876
+ const isExhaustedWeekly = typeof info.weeklyFraction === "number" && info.weeklyFraction <= .01 && (!info.weeklyResetTime || Date.parse(info.weeklyResetTime) > now);
28877
+ if (isExhausted5h || isExhaustedWeekly) {
28878
+ if (this.data.activeAccountIds?.[famKey] === id || this.data.lastActiveAccountId === id) this.selectAccount(famKey);
28879
+ }
28880
+ }
28545
28881
  this.persist();
28546
28882
  }
28547
28883
  /**
@@ -28564,6 +28900,7 @@ var AccountPoolManager = class {
28564
28900
  reason,
28565
28901
  consecutiveFailures: failures
28566
28902
  };
28903
+ if (this.data.activeAccountIds?.[family] === id || this.data.lastActiveAccountId === id) this.selectAccount(family);
28567
28904
  this.persist();
28568
28905
  }
28569
28906
  /**
@@ -28598,48 +28935,33 @@ var AccountPoolManager = class {
28598
28935
  */
28599
28936
  selectAccount(family) {
28600
28937
  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
- });
28938
+ const candidates = this.data.accounts.filter((acc) => this.isAccountEligible(acc, family, now));
28620
28939
  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;
28940
+ if (this.data.mode === "round-robin" && candidates.length > 1) {
28941
+ const picked = candidates.slice().sort((a, b) => (a.lastUsedAt ?? 0) - (b.lastUsedAt ?? 0))[0] ?? null;
28942
+ if (picked) {
28943
+ if (!this.data.activeAccountIds) this.data.activeAccountIds = {};
28944
+ this.data.activeAccountIds[family] = picked.id;
28945
+ this.data.lastActiveAccountId = picked.id;
28946
+ this.persist();
28947
+ }
28948
+ return picked;
28949
+ }
28622
28950
  const activeId = this.data.activeAccountIds?.[family];
28623
28951
  if (activeId) {
28624
28952
  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
- }
28953
+ if (activeCandidate) {
28954
+ if (this.data.lastActiveAccountId !== activeCandidate.id) {
28955
+ this.data.lastActiveAccountId = activeCandidate.id;
28956
+ this.persist();
28638
28957
  }
28958
+ return activeCandidate;
28639
28959
  }
28640
28960
  }
28961
+ const nextAccount = this.orderByExpiry(candidates, family, activeId)[0];
28641
28962
  if (!this.data.activeAccountIds) this.data.activeAccountIds = {};
28642
28963
  this.data.activeAccountIds[family] = nextAccount.id;
28964
+ this.data.lastActiveAccountId = nextAccount.id;
28643
28965
  this.persist();
28644
28966
  return nextAccount;
28645
28967
  }
@@ -29958,6 +30280,31 @@ function apply(ctx, entryConfig = {}) {
29958
30280
  })();
29959
30281
  }
29960
30282
  });
30283
+ reg({
30284
+ kind: "exact",
30285
+ path: "/plugins/agy-link/mermaid.min.js",
30286
+ handler: (_req, res) => {
30287
+ const p = join(import.meta.dirname, "mermaid.min.js");
30288
+ const fallback = join(import.meta.dirname, "..", "assets", "mermaid.min.js");
30289
+ const file = existsSync(p) ? p : existsSync(fallback) ? fallback : null;
30290
+ if (!file) {
30291
+ res.writeHead(404, { "content-type": "text/plain" });
30292
+ res.end("mermaid.min.js not found");
30293
+ return;
30294
+ }
30295
+ try {
30296
+ const content = readFileSync(file);
30297
+ res.writeHead(200, {
30298
+ "content-type": "application/javascript; charset=utf-8",
30299
+ "cache-control": "public, max-age=86400"
30300
+ });
30301
+ res.end(content);
30302
+ } catch (err) {
30303
+ res.writeHead(500, { "content-type": "text/plain" });
30304
+ res.end("read error: " + String(err));
30305
+ }
30306
+ }
30307
+ });
29961
30308
  reg({
29962
30309
  kind: "exact",
29963
30310
  path: "/plugins/agy-link/pool",