dsh-agy-link 0.4.43 → 0.4.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
- import path, { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
4
- import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path, { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { accessSync, chmodSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import * as dshLlm from "@deepseek-ai/dsh-llm";
7
7
  import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
8
8
  import { createHash, randomBytes, randomUUID } from "node:crypto";
9
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
- import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
10
+ import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
11
11
  import { promisify } from "node:util";
12
12
  import { createServer } from "node:http";
13
13
  import { fileURLToPath } from "node:url";
@@ -81,6 +81,7 @@ function defaultConfig() {
81
81
  defaultModel: "",
82
82
  defaultEffort: "",
83
83
  timeoutMs: 6e5,
84
+ longToolIdleMinutes: 240,
84
85
  maxConcurrent: 3,
85
86
  contextWindowDefault: 1048576,
86
87
  maxTokensDefault: 65536,
@@ -92,6 +93,7 @@ function defaultConfig() {
92
93
  compactionMaxChars: 8e5,
93
94
  workspaceRoot: "",
94
95
  fallbackModels: DEFAULT_FALLBACK_MODELS,
96
+ hiddenModels: [],
95
97
  askTool: false,
96
98
  mediaDir: "",
97
99
  mediaTtlMs: 864e5,
@@ -117,9 +119,19 @@ function defaultConfig() {
117
119
  function isAuthoritativeAuthError(message) {
118
120
  return /invalid_grant|invalid_client|revoked|disabled|unauthorized_client/i.test(message);
119
121
  }
120
- /** Transient transport failures — never proof that a login is dead. */
122
+ /**
123
+ * Transient transport failures — never proof that a login is dead.
124
+ *
125
+ * Deliberately broader than the classic dial/DNS set: agy reports "not signed
126
+ * in" whenever it cannot VALIDATE the stored token, so a flaky proxy produces
127
+ * `Post https://oauth2.googleapis.com/token: EOF`, `TLS handshake timeout` or
128
+ * `token validation failed due to network error (token may still be valid)`
129
+ * next to an auth-shaped banner. Treating that as a sign-out ends the turn on
130
+ * a HEALTHY account and DSH renders the AUTH code as "API key is invalid"
131
+ * (observed live: 2026-10-10 14:17, session-c65a826e turn 22).
132
+ */
121
133
  function looksLikeNetworkError(message) {
122
- return /dial tcp|fetch failed|econn(?:refused|reset|aborted)|enotfound|ehostunreach|enetunreach|socket hang up|getaddrinfo|bad gateway|service unavailable/i.test(message);
134
+ return /dial tcp|fetch failed|econn(?:refused|reset|aborted)|enotfound|ehostunreach|enetunreach|socket hang up|getaddrinfo|bad gateway|service unavailable|unexpected eof|\beof\b|tls handshake timeout|i\/o timeout|network (?:error|is unreachable)|no route to host|connection reset|context deadline exceeded|temporary failure in name resolution|proxyconnect|token validation failed|validate (?:the )?token|silent auth failed/i.test(message);
123
135
  }
124
136
  /**
125
137
  * Decide whether a failed call may flag an account as "needs re-login".
@@ -153,6 +165,21 @@ const Err = {
153
165
  TIMEOUT: "TIMEOUT",
154
166
  PROCESS_EXIT: "PROCESS_EXIT",
155
167
  INVALID_OUTPUT: "INVALID_OUTPUT",
168
+ /**
169
+ * Transport failure wrapped around an auth-looking refusal. agy cannot tell
170
+ * "the token is gone" from "I could not validate the token because the
171
+ * network died", and it reports both as "not logged into Antigravity".
172
+ * Kept separate from AUTH so the turn is retried on ANOTHER account instead
173
+ * of ending on a healthy one (and so no account is flagged for re-login).
174
+ */
175
+ NETWORK: "NETWORK",
176
+ /**
177
+ * Soft Google-side capacity refusal ("model overloaded" / "experiencing high
178
+ * traffic"). Account-scoped for retry purposes — the sticky pointer moves to
179
+ * the next account — but it is NEVER a cooldown reason: nothing was spent, so
180
+ * a 15-minute penalty would only shrink the pool.
181
+ */
182
+ OVERLOADED: "OVERLOADED",
156
183
  UNKNOWN_MODEL: "UNKNOWN_MODEL",
157
184
  UNSUPPORTED_REASONING_EFFORT: "UNSUPPORTED_REASONING_EFFORT",
158
185
  AUX_DISABLED: "AUX_DISABLED",
@@ -186,15 +213,29 @@ function extractAuthUrl(text) {
186
213
  */
187
214
  function looksLikeHardRateLimit(text) {
188
215
  if (!text) return false;
189
- return /RESOURCE_EXHAUSTED|code[ :]?429\b|status[ :]?429\b|HTTP[ :]?429\b|too many requests|individual quota reached|quota (?:exceeded|reached|exhausted)|rate[ -]?limit(?:ed)? (?:exceeded|reached|hit)|exceeded (?:your |the )?quota/i.test(text);
216
+ return /RESOURCE_EXHAUSTED|code[ :]?429\b|status[ :]?429\b|HTTP[ :]?429\b|too many requests|individual quota reached|quota (?:exceeded|reached|exhausted)|rate[ -]?limit(?:ed)? (?:exceeded|reached|hit)|exceeded (?:your |the )?quota|has been exhausted|exhausted your quota|out of quota|check quota|insufficient quota/i.test(text);
217
+ }
218
+ /**
219
+ * Failures that belong to the ACCOUNT (spent quota, refused region, broken
220
+ * transport, capacity refusal, signed-out login) rather than to the request.
221
+ * The retry policy retries these and the adapter rotates the sticky pointer,
222
+ * so the retry is served by a different account instead of interrupting the
223
+ * user's turn. Deterministic request-level failures (AGY_ERROR, a bare
224
+ * PROCESS_EXIT from a crash) are deliberately excluded: re-running those
225
+ * merely repeats the same work on another account.
226
+ */
227
+ function isAccountScopedFailure(code) {
228
+ return code === "RATE_LIMIT" || code === "ELIGIBILITY" || code === "NETWORK" || code === "OVERLOADED" || code === "AUTH";
190
229
  }
191
230
  /**
192
231
  * Soft heuristic adds capacity signals (model overloaded / high traffic).
193
232
  * Shapes the user-facing error message only — NEVER cools an account down.
233
+ * The adapter still ROTATES on these: nothing was spent locally, and ending a
234
+ * turn while an idle account could serve it is the interruption users report.
194
235
  */
195
236
  function looksLikeRateLimit(text) {
196
237
  if (!text) return false;
197
- return looksLikeHardRateLimit(text) || /model overloaded|experiencing high traffic/i.test(text);
238
+ return looksLikeHardRateLimit(text) || /model overloaded|experiencing high traffic|is overloaded|capacity (?:exhausted|unavailable)|temporarily overloaded/i.test(text);
198
239
  }
199
240
  /**
200
241
  * Parse reset duration in milliseconds from rate-limit / quota-exhausted error strings.
@@ -259,6 +300,20 @@ function asNum(v) {
259
300
  if (typeof v === "number" && Number.isFinite(v)) return v;
260
301
  if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
261
302
  }
303
+ /** Trimmed, de-duplicated string list; non-string / blank entries are dropped. */
304
+ function asStringArray(v) {
305
+ if (!Array.isArray(v)) return void 0;
306
+ const out = [];
307
+ const seen = /* @__PURE__ */ new Set();
308
+ for (const x of v) {
309
+ if (typeof x !== "string") continue;
310
+ const s = x.trim();
311
+ if (s === "" || seen.has(s)) continue;
312
+ seen.add(s);
313
+ out.push(s);
314
+ }
315
+ return out;
316
+ }
262
317
  const MODES = [
263
318
  "skip",
264
319
  "plan",
@@ -283,6 +338,7 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
283
338
  defaultModel: asString(get("defaultModel")) ?? base.defaultModel,
284
339
  defaultEffort: asString(get("defaultEffort")) ?? base.defaultEffort,
285
340
  timeoutMs: asNum(get("timeoutMs")) ?? base.timeoutMs,
341
+ longToolIdleMinutes: asNum(get("longToolIdleMinutes")) ?? base.longToolIdleMinutes,
286
342
  maxConcurrent: asNum(get("maxConcurrent")) ?? base.maxConcurrent,
287
343
  contextWindowDefault: asNum(get("contextWindowDefault")) ?? base.contextWindowDefault,
288
344
  maxTokensDefault: asNum(get("maxTokensDefault")) ?? base.maxTokensDefault,
@@ -294,6 +350,7 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
294
350
  compactionMaxChars: asNum(get("compactionMaxChars")) ?? base.compactionMaxChars,
295
351
  workspaceRoot: asString(get("workspaceRoot")) ?? base.workspaceRoot,
296
352
  fallbackModels: Array.isArray(get("fallbackModels")) ? get("fallbackModels").filter((x) => !!x && typeof x === "object" && typeof x.id === "string") : base.fallbackModels,
353
+ hiddenModels: asStringArray(get("hiddenModels")) ?? base.hiddenModels,
297
354
  askTool: asBool(get("askTool")) ?? base.askTool,
298
355
  mediaDir: asString(get("mediaDir")) ?? base.mediaDir,
299
356
  mediaTtlMs: asNum(get("mediaTtlMs")) ?? base.mediaTtlMs,
@@ -324,7 +381,15 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
324
381
  const t = asNum(env.DSH_AGY_TIMEOUT_MS);
325
382
  if (t && t > 0) cfg.timeoutMs = t;
326
383
  }
384
+ if (env.DSH_AGY_LONG_TOOL_IDLE_MINUTES) {
385
+ const t = asNum(env.DSH_AGY_LONG_TOOL_IDLE_MINUTES);
386
+ if (t !== void 0 && t >= 0) cfg.longToolIdleMinutes = t;
387
+ }
327
388
  if (env.DSH_AGY_EXTRA_ARGS) cfg.extraArgs = env.DSH_AGY_EXTRA_ARGS.split(/\s+/).filter(Boolean);
389
+ if (env.DSH_AGY_HIDDEN_MODELS) {
390
+ const list = asStringArray(env.DSH_AGY_HIDDEN_MODELS.split(","));
391
+ if (list) cfg.hiddenModels = list;
392
+ }
328
393
  if (env.DSH_AGY_WORKSPACE_ROOT) cfg.workspaceRoot = env.DSH_AGY_WORKSPACE_ROOT;
329
394
  if (env.DSH_AGY_MEDIA_DIR) cfg.mediaDir = env.DSH_AGY_MEDIA_DIR;
330
395
  if (env.DSH_AGY_MEDIA_TTL_MS) {
@@ -381,7 +446,7 @@ function shouldPollAccount(account) {
381
446
  if (!account.enabled) return false;
382
447
  if (account.authRequired) return false;
383
448
  const now = Date.now();
384
- for (const cd of Object.values(account.cooldowns)) if (cd && cd.cooldownUntil > now) return false;
449
+ for (const cd of Object.values(account.cooldowns ?? {})) if (cd && cd.cooldownUntil > now) return false;
385
450
  return true;
386
451
  }
387
452
  /**
@@ -415,6 +480,17 @@ function defaultPoolData() {
415
480
  function defaultConversationsDir() {
416
481
  return process.env.DSH_AGY_CONVERSATIONS_DIR ?? join(homedir(), ".gemini", "antigravity-cli", "conversations");
417
482
  }
483
+ /**
484
+ * Conversations directory for one run's HOME. Secondary pool accounts run
485
+ * under an isolated HOME, so the fallback discovery must watch THEIR
486
+ * conversations dir — snapshotting the system HOME silently never saw those
487
+ * conversation databases (agy-db.ts fixed the same class of bug for its DB
488
+ * readers; this aligns discovery with it).
489
+ */
490
+ function conversationsDirFor(accountHome) {
491
+ if (accountHome !== void 0 && accountHome !== "") return join(accountHome, ".gemini", "antigravity-cli", "conversations");
492
+ return defaultConversationsDir();
493
+ }
418
494
  function snapshotConversations(dir = defaultConversationsDir()) {
419
495
  const files = /* @__PURE__ */ new Map();
420
496
  try {
@@ -461,10 +537,19 @@ const waiters = Symbol("waiters");
461
537
  var RunRecording = class {
462
538
  runId;
463
539
  events = [];
540
+ /** Arrival timestamp for every recorded event (same index as `events`). */
541
+ eventTimes = [];
464
542
  settled = false;
465
543
  failure = null;
466
544
  resultConversationId = null;
467
545
  lastStepUsageRaw = null;
546
+ /**
547
+ * Per-step generation timing for token-speed statistics: stepKey ->
548
+ * { startedAt, lastAt, outputTokens }. The last usage sample of a step wins
549
+ * (updates are cumulative snapshots of one internal call), so replacing —
550
+ * not accumulating — keeps the numbers honest.
551
+ */
552
+ stepSpeed = /* @__PURE__ */ new Map();
468
553
  [waiters] = /* @__PURE__ */ new Set();
469
554
  /** DB-resolved full tool args keyed by event index (set during span driving). */
470
555
  fullArgs = null;
@@ -473,6 +558,16 @@ var RunRecording = class {
473
558
  /** Isolated pool-account HOME used for this run's agy conversation DB. */
474
559
  accountHome = void 0;
475
560
  /**
561
+ * Index of the event where the most recent span CUT (the last event the
562
+ * previous run_code call yielded), or null while no span has finished on
563
+ * this recording. Lets the duplicate-request debounce RESCUE an identical
564
+ * prompt by re-driving the live recording from the cut point instead of
565
+ * throwing BUSY at a mid-flight agentic turn (2026-10-10 23:0x incident:
566
+ * a span whose mirror result did not parse as a continuation fell through
567
+ * to full prompt assembly and the debounce killed the whole turn).
568
+ */
569
+ lastCutIndex = null;
570
+ /**
476
571
  * Set by the adapter right after spawn. A mid-turn user steer makes DSH
477
572
  * open a NEW stream() call for the same session while this run's process
478
573
  * is still alive; without aborting it, two agy processes would append to
@@ -486,11 +581,66 @@ var RunRecording = class {
486
581
  append(ev) {
487
582
  if (this.settled) return;
488
583
  this.events.push(ev);
584
+ const now = Date.now();
585
+ this.eventTimes.push(now);
489
586
  if (ev.kind === "result") this.resultConversationId = ev.conversationId ?? null;
490
587
  if (ev.kind === "init" && typeof ev.conversationId === "string" && ev.conversationId !== "") this.resultConversationId = this.resultConversationId ?? ev.conversationId;
588
+ if (ev.kind === "step" && ev.usage) {
589
+ const out = typeof ev.usage.output_tokens === "number" && Number.isFinite(ev.usage.output_tokens) ? ev.usage.output_tokens : 0;
590
+ if (out > 0) {
591
+ const prev = this.stepSpeed.get(ev.stepKey);
592
+ if (prev === void 0) this.stepSpeed.set(ev.stepKey, {
593
+ startedAt: now,
594
+ lastAt: now,
595
+ outputTokens: out
596
+ });
597
+ else {
598
+ prev.lastAt = now;
599
+ prev.outputTokens = out;
600
+ }
601
+ }
602
+ }
491
603
  this.wake();
492
604
  }
493
605
  /**
606
+ * Token-speed statistics over the generation steps recorded so far
607
+ * (tokens/second of model OUTPUT, thinking included where agy reports it).
608
+ *
609
+ * `avgTps` is the output-weighted mean (Σ tokens / Σ seconds) over steps
610
+ * with a measurable window (≥ 500 ms); `lastTps` is the most recently
611
+ * measured step. Tool-only and instantaneous steps are excluded, so the
612
+ * numbers reflect actual generation speed rather than tool wait time.
613
+ */
614
+ speedStats() {
615
+ let tokens = 0;
616
+ let ms = 0;
617
+ let steps = 0;
618
+ let lastTps = null;
619
+ for (const s of this.stepSpeed.values()) {
620
+ tokens += s.outputTokens;
621
+ const windowMs = s.lastAt - s.startedAt;
622
+ if (s.outputTokens > 0 && windowMs >= 500) {
623
+ ms += windowMs;
624
+ steps++;
625
+ }
626
+ }
627
+ for (let i = this.events.length - 1; i >= 0; i--) {
628
+ const ev = this.events[i];
629
+ if (ev === void 0 || ev.kind !== "step") continue;
630
+ const s = this.stepSpeed.get(ev.stepKey);
631
+ if (s === void 0) continue;
632
+ if (s.outputTokens > 0 && s.lastAt - s.startedAt >= 500) lastTps = s.outputTokens / ((s.lastAt - s.startedAt) / 1e3);
633
+ break;
634
+ }
635
+ return {
636
+ lastTps,
637
+ avgTps: ms > 0 ? tokens / (ms / 1e3) : null,
638
+ outputTokens: tokens,
639
+ measuredSteps: steps,
640
+ generatingMs: ms
641
+ };
642
+ }
643
+ /**
494
644
  * Usage accounting (verified against agy 1.1.16 stream-json): step_update
495
645
  * usage is PER-CALL (the current context size at that internal LLM call),
496
646
  * while the result envelope's usage is CUMULATIVE across the whole agy
@@ -641,28 +791,92 @@ function parseMirrorCallId(callId) {
641
791
  };
642
792
  }
643
793
  const MAX_RETAINED_RUNS = 8;
644
- /** Bounded registry keeping the most recent runs for continuation spans. */
794
+ /**
795
+ * Bounded registry keeping the most recent runs for continuation spans.
796
+ *
797
+ * Eviction is deliberately conservative: a run is only dropped when it is
798
+ * SETTLED, UNPINNED and is not the most recently used entry. A wedged agy
799
+ * tool step can keep a run alive for hours while other DSH sessions create
800
+ * their own runs (flash-blast incident, 2026-10-10): a pure FIFO cap evicted
801
+ * that run mid-flight, and the mirror call the span had already queued —
802
+ * `agy_tool({run, step})` — could no longer be resolved, so the model lost
803
+ * the tool result and burned the turn debugging the bridge instead.
804
+ */
645
805
  var RunRegistry = class {
646
806
  runs = /* @__PURE__ */ new Map();
807
+ /** runId -> pin count while a span is actively driving that recording. */
808
+ pinned = /* @__PURE__ */ new Map();
647
809
  create() {
648
810
  const rec = new RunRecording();
649
811
  this.remember(rec);
650
812
  return rec;
651
813
  }
814
+ /**
815
+ * Insert a run, or refresh one that is about to be consumed again (a
816
+ * continuation span re-registers its recording so a long-lived run is the
817
+ * most recent entry and survives the eviction that follows its cut).
818
+ */
652
819
  remember(rec) {
820
+ if (this.runs.has(rec.runId)) this.runs.delete(rec.runId);
653
821
  this.runs.set(rec.runId, rec);
654
- while (this.runs.size > MAX_RETAINED_RUNS) {
655
- const oldest = this.runs.keys().next().value;
656
- if (oldest === void 0) break;
657
- this.runs.delete(oldest);
658
- }
822
+ this.evict();
659
823
  }
660
824
  get(runId) {
661
825
  return this.runs.get(runId);
662
826
  }
827
+ /**
828
+ * Pin a run for the duration of a span drive. Pinned (and unsettled) runs
829
+ * are exempt from eviction, so a span that waits hours for a wedged tool
830
+ * step can still resolve the mirror calls it emits afterwards.
831
+ */
832
+ pin(runId) {
833
+ this.pinned.set(runId, (this.pinned.get(runId) ?? 0) + 1);
834
+ }
835
+ unpin(runId) {
836
+ const n = this.pinned.get(runId);
837
+ if (n === void 0) return;
838
+ if (n <= 1) this.pinned.delete(runId);
839
+ else this.pinned.set(runId, n - 1);
840
+ }
663
841
  /** Drop a settled run early (called when its final span finished stop). */
664
842
  forget(runId) {
665
843
  this.runs.delete(runId);
844
+ this.pinned.delete(runId);
845
+ }
846
+ /** Retained run ids, oldest first (diagnostics / tests). */
847
+ get runIds() {
848
+ return [...this.runs.keys()];
849
+ }
850
+ /** Number of retained runs. */
851
+ get size() {
852
+ return this.runs.size;
853
+ }
854
+ /** Number of currently pinned run ids (diagnostics / tests). */
855
+ get pinnedCount() {
856
+ return this.pinned.size;
857
+ }
858
+ /**
859
+ * Trim back to the cap without ever breaking an in-flight run: evict the
860
+ * oldest SETTLED, UNPINNED entry, never the most recent one (the next
861
+ * mirror dispatch or continuation addresses it). Live (unsettled) and
862
+ * pinned runs are exempt, so the registry may temporarily exceed the cap
863
+ * while several runs are in flight — strictly better than failing a replay.
864
+ */
865
+ evict() {
866
+ while (this.runs.size > MAX_RETAINED_RUNS) {
867
+ let newest;
868
+ for (const id of this.runs.keys()) newest = id;
869
+ let victim;
870
+ for (const [id, rec] of this.runs) {
871
+ if (id === newest) continue;
872
+ if (!rec.isSettled) continue;
873
+ if (this.pinned.has(id)) continue;
874
+ victim = id;
875
+ break;
876
+ }
877
+ if (victim === void 0) return;
878
+ this.runs.delete(victim);
879
+ }
666
880
  }
667
881
  };
668
882
  //#endregion
@@ -741,14 +955,37 @@ function toolStepBrief(toolName, args) {
741
955
  return "";
742
956
  };
743
957
  if (toolName === "run_command" || toolName === "execute_command") {
744
- const cmd = pickStr("Command", "command", "Cmd", "cmd");
958
+ const cmd = pickStr("CommandLine", "command_line", "Command", "command", "Cmd", "cmd");
745
959
  return cmd !== "" ? "$ " + cmd.slice(0, 80) : "run command";
746
960
  }
747
- if (toolName === "view_file" || toolName === "read_file") return "read " + (pickStr("Path", "path", "TargetFile", "File") || "file");
748
- if (toolName === "write_to_file" || toolName === "create_file") return "write " + (pickStr("Path", "path", "TargetFile", "File") || "file");
749
- if (toolName === "replace_file_content" || toolName === "edit_file") return "edit " + (pickStr("Path", "path", "TargetFile", "File") || "file");
961
+ if (toolName === "view_file" || toolName === "read_file") return "read " + (pickStr("AbsolutePath", "absolute_path", "Path", "path", "TargetFile", "File") || "file");
962
+ if (toolName === "write_to_file" || toolName === "create_file") return "write " + (pickStr("TargetFile", "target_file", "Path", "path", "AbsolutePath", "File") || "file");
963
+ if (toolName === "replace_file_content" || toolName === "edit_file") return "edit " + (pickStr("TargetFile", "target_file", "Path", "path", "AbsolutePath", "File") || "file");
750
964
  if (toolName === "grep_search" || toolName === "search") return "search " + (pickStr("Query", "query", "SearchPattern") || "");
751
- if (toolName === "list_dir" || toolName === "list_directory") return "ls " + (pickStr("Path", "path", "DirectoryPath") || ".");
965
+ if (toolName === "search_web" || toolName === "web_search") return "search web " + (pickStr("query", "Query", "search_query", "q") || "");
966
+ if (toolName === "read_url_content" || toolName === "web_fetch" || toolName === "fetch_web_page") return "fetch " + (pickStr("Url", "url", "target_url", "targetUrl") || "url");
967
+ if (toolName === "list_dir" || toolName === "list_directory") return "ls " + (pickStr("DirectoryPath", "directory_path", "Path", "path", "AbsolutePath") || ".");
968
+ if (toolName === "manage_task") {
969
+ const action = pickStr("Action", "action") || "manage";
970
+ const id = pickStr("TaskId", "taskId", "task_id");
971
+ return id !== "" ? "task " + action + " " + id : "task " + action;
972
+ }
973
+ if (toolName === "schedule") {
974
+ const prompt = pickStr("Prompt", "prompt");
975
+ const cron = pickStr("CronExpression", "cron_expression");
976
+ const dur = pickStr("DurationSeconds", "duration_seconds");
977
+ const timing = cron ? "cron " + cron : dur ? dur + "s" : "";
978
+ return timing ? "schedule " + timing : prompt ? "schedule " + prompt.slice(0, 40) : "schedule";
979
+ }
980
+ if (toolName === "invoke_subagent") {
981
+ const subs = a.Subagents ?? a.subagents;
982
+ const firstRole = Array.isArray(subs) && subs.length > 0 && typeof subs[0].Role === "string" ? subs[0].Role : "";
983
+ return firstRole ? "subagent: " + firstRole : "invoke subagent";
984
+ }
985
+ if (toolName === "send_message") {
986
+ const recipient = pickStr("Recipient", "recipient", "to");
987
+ return recipient ? "msg " + recipient : "send message";
988
+ }
752
989
  return toolName;
753
990
  }
754
991
  /** agy serializes some tool args as a JSON string; presenters get an object. */
@@ -774,6 +1011,7 @@ function pick$1(input, ...keys) {
774
1011
  for (const k of keys) {
775
1012
  const v = input[k];
776
1013
  if (typeof v === "string") return v;
1014
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
777
1015
  }
778
1016
  }
779
1017
  /** Native pending-card projection for one mirrored agy tool call. */
@@ -897,12 +1135,68 @@ function presentMirrorCall(args) {
897
1135
  rawInput: input
898
1136
  };
899
1137
  }
900
- case "read_url_content": {
901
- const url = pick$1(input, "Url", "url") ?? "";
1138
+ case "search_web":
1139
+ case "web_search": {
1140
+ const q = pick$1(input, "query", "Query", "search_query", "q") ?? "";
1141
+ return {
1142
+ card: "generic",
1143
+ title: pick$1(input, "Description", "description", "toolAction", "toolSummary") ?? (q !== "" ? "Web search " + q : "Web search"),
1144
+ kind: "search",
1145
+ rawInput: input
1146
+ };
1147
+ }
1148
+ case "read_url_content":
1149
+ case "web_fetch":
1150
+ case "fetch_web_page":
1151
+ case "fetch_url": {
1152
+ const url = pick$1(input, "Url", "url", "target_url", "targetUrl") ?? "";
902
1153
  return {
903
1154
  card: "generic",
904
1155
  title: pick$1(input, "Description", "description", "toolAction", "toolSummary") ?? (url ? `Fetch ${url}` : "Fetch URL"),
905
- kind: "fetch"
1156
+ kind: "fetch",
1157
+ rawInput: input
1158
+ };
1159
+ }
1160
+ case "manage_task": {
1161
+ const action = pick$1(input, "Action", "action") ?? "manage";
1162
+ const taskId = pick$1(input, "TaskId", "taskId", "task_id");
1163
+ return {
1164
+ card: "generic",
1165
+ title: pick$1(input, "Description", "description", "toolAction", "toolSummary") ?? (taskId ? `Task ${action} (${taskId})` : `Task ${action}`),
1166
+ kind: "execute",
1167
+ rawInput: input
1168
+ };
1169
+ }
1170
+ case "schedule": {
1171
+ const prompt = pick$1(input, "Prompt", "prompt");
1172
+ const cron = pick$1(input, "CronExpression", "cron_expression");
1173
+ const duration = pick$1(input, "DurationSeconds", "duration_seconds");
1174
+ const desc = pick$1(input, "Description", "description", "toolAction", "toolSummary");
1175
+ const timing = cron ? `cron ${cron}` : duration ? `${duration}s timer` : "";
1176
+ return {
1177
+ card: "generic",
1178
+ title: desc ?? (timing ? `Schedule ${timing}` : prompt ? `Schedule: ${prompt}` : "Schedule"),
1179
+ kind: "other",
1180
+ rawInput: input
1181
+ };
1182
+ }
1183
+ case "invoke_subagent": {
1184
+ const subs = input.Subagents ?? input.subagents;
1185
+ const firstRole = Array.isArray(subs) && subs.length > 0 && typeof subs[0].Role === "string" ? subs[0].Role : Array.isArray(subs) && subs.length > 0 && typeof subs[0].TypeName === "string" ? subs[0].TypeName : void 0;
1186
+ return {
1187
+ card: "generic",
1188
+ title: pick$1(input, "Description", "description", "toolAction", "toolSummary") ?? (firstRole ? `Subagent: ${firstRole}` : "Invoke subagent"),
1189
+ kind: "execute",
1190
+ rawInput: input
1191
+ };
1192
+ }
1193
+ case "send_message": {
1194
+ const recipient = pick$1(input, "Recipient", "recipient", "to");
1195
+ return {
1196
+ card: "generic",
1197
+ title: pick$1(input, "Description", "description", "toolAction", "toolSummary") ?? (recipient ? `Message subagent ${recipient}` : "Send message"),
1198
+ kind: "other",
1199
+ rawInput: input
906
1200
  };
907
1201
  }
908
1202
  case "generate_image": {
@@ -927,10 +1221,17 @@ function presentMirrorResult(args, result) {
927
1221
  switch (name) {
928
1222
  case "run_command":
929
1223
  case "bash":
930
- case "execute_command": return {
931
- card: "terminal",
932
- output: text
933
- };
1224
+ case "execute_command": {
1225
+ let exitCode;
1226
+ const m = /The command exited with code (\d+)/i.exec(text);
1227
+ if (m && m[1]) exitCode = parseInt(m[1], 10);
1228
+ else if (result.isError) exitCode = 1;
1229
+ return {
1230
+ card: "terminal",
1231
+ output: text,
1232
+ ...exitCode !== void 0 ? { exitCode } : {}
1233
+ };
1234
+ }
934
1235
  case "replace_file_content":
935
1236
  case "write_to_file":
936
1237
  case "write_file":
@@ -964,6 +1265,23 @@ function resultText$1(content) {
964
1265
  function clip(s, max) {
965
1266
  return s.length > max ? s.slice(0, max) + "… (+" + (s.length - max) + " chars)" : s;
966
1267
  }
1268
+ /** Short preview of the recorded step for the degraded-result message. */
1269
+ function stepLabel(toolName, input) {
1270
+ if (toolName === "") return "";
1271
+ return toolStepBrief(toolName, input);
1272
+ }
1273
+ /**
1274
+ * Degraded result for a mirrored step whose recording is gone (bridge
1275
+ * restarted, or the run was recycled while a wedged tool step held the span
1276
+ * for hours). It must NOT read like a harness error: the model used to
1277
+ * receive `no recorded agy run` and spent the whole turn reverse-engineering
1278
+ * this plugin instead of finishing the user's task (flash-blast incident,
1279
+ * 2026-10-10). The step really did run — only its replay is unavailable.
1280
+ */
1281
+ function recordingUnavailable(step, toolName, input, reason) {
1282
+ const label = stepLabel(toolName, input);
1283
+ return "⚠ [agy bridge] This agy tool step executed, but its recorded output is no longer resident in the bridge (" + (reason !== void 0 && reason !== "" ? reason : "the step ran for a long time and the in-memory recording was recycled") + "). Nothing is wrong with the tool or the workspace, and there is nothing to debug here — do NOT investigate the bridge, its logs or this message; continue the user's task. If the output is needed, re-run the same operation as a new step. Step #" + step + (label !== "" ? ": " + label : "");
1284
+ }
967
1285
  function defineAgyMirrorTool(deps) {
968
1286
  const enrichArgs = (args) => {
969
1287
  if (typeof args.tool === "string" && args.tool !== "") return args;
@@ -1013,11 +1331,15 @@ function defineAgyMirrorTool(deps) {
1013
1331
  exec.signal;
1014
1332
  const runId = typeof args.run === "string" ? args.run : "";
1015
1333
  const step = typeof args.step === "number" ? args.step : -1;
1016
- typeof args.tool === "string" && args.tool;
1334
+ const toolName = typeof args.tool === "string" ? args.tool : "";
1335
+ const input = toolInput(args);
1017
1336
  const rec = deps.runs.get(runId);
1018
- if (rec === void 0) throw new Error("agy_tool: no recorded agy run \"" + runId + "\" — this tool only replays bridge-recorded activity");
1337
+ if (rec === void 0) return recordingUnavailable(step, toolName, input);
1019
1338
  const t = rec.toolEventAt(step);
1020
- if (t === null) throw new Error("agy_tool: event " + step + " of run " + runId + " is not a completed tool step");
1339
+ if (t === null) {
1340
+ const reason = (step >= 0 && step < rec.length ? rec.eventAt(step) : void 0) === void 0 ? "the step index is outside this recording — the step has not been recorded (yet), or the bridge restarted" : "event #" + step + " in this recording is not a completed tool step";
1341
+ return recordingUnavailable(step, toolName !== "" ? toolName : "unknown", input, reason);
1342
+ }
1021
1343
  if (t.error !== void 0) throw new Error("agy tool " + t.name + " failed: " + t.error);
1022
1344
  const out = t.output;
1023
1345
  if (out === void 0 || out === null) return "";
@@ -1057,7 +1379,17 @@ var EventMapper = class {
1057
1379
  openAcc = "";
1058
1380
  emittedByKey = /* @__PURE__ */ new Map();
1059
1381
  announcedTools = /* @__PURE__ */ new Set();
1382
+ pendingAnnounced = /* @__PURE__ */ new Set();
1060
1383
  thinkingAnnounced = /* @__PURE__ */ new Set();
1384
+ /**
1385
+ * Steps whose banner already streamed WITHOUT prose (issue #43: desktop
1386
+ * hosts with no sqlite reader). Separate from `thinkingAnnounced` on
1387
+ * purpose: DB prose can resolve AFTER the banner streamed, and marking the
1388
+ * step announced there would drop the real text (the exact regression
1389
+ * issue #43 fixed). When prose arrives later it is appended WITHOUT the
1390
+ * banner and the entry is consumed.
1391
+ */
1392
+ bannerOnlyShown = /* @__PURE__ */ new Set();
1061
1393
  bannerOnlyThinkingEmitted = false;
1062
1394
  sawTextStep;
1063
1395
  finished = false;
@@ -1118,12 +1450,21 @@ var EventMapper = class {
1118
1450
  * banner. When real thought prose is present, place the banner and thought
1119
1451
  * text on the same initial line so DSH's collapsed summary renders:
1120
1452
  * [agy thinking turn · *** thinking tokens] [Chain-of-Thought body]
1453
+ *
1454
+ * With `allowBannerOnly` the bare banner is emitted when the DB prose never
1455
+ * arrived (desktop hosts without any sqlite reader, issue #43): the user
1456
+ * then at least SEES that a thinking turn happened and how large it was —
1457
+ * previously high-effort turns rendered nothing at all.
1121
1458
  */
1122
- *emitThinking(absIndex, thoughtTokens) {
1459
+ *emitThinking(stepKey, absIndex, thoughtTokens, allowBannerOnly = false) {
1123
1460
  const text = this.opts.resolvedThoughts?.get(absIndex);
1124
- if (!(text !== void 0 && text.trim() !== "")) return;
1461
+ const hasText = text !== void 0 && text.trim() !== "";
1462
+ if (!hasText && !allowBannerOnly) return;
1463
+ const bannerAlreadyShown = this.bannerOnlyShown.has(stepKey);
1464
+ if (hasText) this.bannerOnlyShown.delete(stepKey);
1125
1465
  yield* this.ensureBlock("reasoning");
1126
- const combined = `${thoughtTokens > 0 ? "[agy thinking turn · " + thoughtTokens + " thinking tokens]" : "[agy thinking turn]"} ${text.trim()}\n`;
1466
+ const banner = thoughtTokens > 0 ? "[agy thinking turn · " + thoughtTokens + " thinking tokens]" : "[agy thinking turn]";
1467
+ const combined = !hasText ? `${banner}\n` : bannerAlreadyShown ? `${text.trim()}\n` : `${banner} ${text.trim()}\n`;
1127
1468
  const d = this.appendDelta(combined);
1128
1469
  if (d) yield d;
1129
1470
  }
@@ -1142,19 +1483,26 @@ var EventMapper = class {
1142
1483
  const thoughtTokens = ev.usage?.thinking_tokens ?? 0;
1143
1484
  const stepTextEmitted = (this.emittedByKey.get(ev.stepKey) ?? "") !== "";
1144
1485
  const dbThought = this.opts.resolvedThoughts?.get(absIndex);
1145
- const shouldAnnounce = dbThought !== void 0 && dbThought.trim() !== "" || thoughtTokens > 0;
1146
- if (shouldAnnounce && !stepTextEmitted && !this.thinkingAnnounced.has(ev.stepKey)) {
1486
+ const hasRealThought = dbThought !== void 0 && dbThought.trim() !== "";
1487
+ if (hasRealThought && !stepTextEmitted && !this.thinkingAnnounced.has(ev.stepKey)) {
1147
1488
  this.thinkingAnnounced.add(ev.stepKey);
1148
- yield* this.emitThinking(absIndex, thoughtTokens);
1489
+ yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens);
1149
1490
  }
1150
- const deferred = shouldAnnounce && stepTextEmitted && !this.thinkingAnnounced.has(ev.stepKey);
1491
+ const deferred = hasRealThought && stepTextEmitted && !this.thinkingAnnounced.has(ev.stepKey);
1151
1492
  if (ev.text === "" && !ev.fragment) {
1152
1493
  if (deferred) {
1153
1494
  this.thinkingAnnounced.add(ev.stepKey);
1154
- yield* this.emitThinking(absIndex, thoughtTokens);
1495
+ yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens);
1496
+ } else if (!this.sawTextStep && thoughtTokens > 0 && !this.thinkingAnnounced.has(ev.stepKey)) {
1497
+ this.bannerOnlyShown.add(ev.stepKey);
1498
+ yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens, true);
1155
1499
  }
1156
1500
  return;
1157
1501
  }
1502
+ if (!hasRealThought && !this.sawTextStep && thoughtTokens > 0 && !this.thinkingAnnounced.has(ev.stepKey)) {
1503
+ this.bannerOnlyShown.add(ev.stepKey);
1504
+ yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens, true);
1505
+ }
1158
1506
  this.sawTextStep = true;
1159
1507
  yield* this.ensureBlock("text");
1160
1508
  let d;
@@ -1170,7 +1518,7 @@ var EventMapper = class {
1170
1518
  if (d) yield d;
1171
1519
  if (deferred) {
1172
1520
  this.thinkingAnnounced.add(ev.stepKey);
1173
- yield* this.emitThinking(absIndex, thoughtTokens);
1521
+ yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens);
1174
1522
  }
1175
1523
  return;
1176
1524
  }
@@ -1190,13 +1538,25 @@ var EventMapper = class {
1190
1538
  if (d) yield d;
1191
1539
  }
1192
1540
  } else {
1193
- const d = this.appendDelta("[agy subagent] " + ev.text + "\n");
1541
+ const prev = this.emittedByKey.get(ev.stepKey) ?? "";
1542
+ const deltaText = suffixDelta(prev, ev.text);
1543
+ this.emittedByKey.set(ev.stepKey, ev.text);
1544
+ const banner = prev === "" ? "[agy subagent] " : "";
1545
+ const d = this.appendDelta(banner + deltaText);
1194
1546
  if (d) yield d;
1195
1547
  }
1196
1548
  return;
1197
1549
  }
1198
1550
  if (ev.stepKind === "tool" && ev.tool) {
1199
- if (!(ev.state === "DONE" || ev.state === "ERROR" || ev.state === void 0 && (ev.tool.output !== void 0 || ev.tool.error !== void 0))) return;
1551
+ if (!(ev.state === "DONE" || ev.state === "ERROR" || ev.state === void 0 && (ev.tool.output !== void 0 || ev.tool.error !== void 0))) {
1552
+ if (this.opts.cutOnTool && !this.finished && ev.state === "ACTIVE" && !this.pendingAnnounced.has(ev.stepKey)) {
1553
+ this.pendingAnnounced.add(ev.stepKey);
1554
+ yield* this.ensureBlock("reasoning");
1555
+ const d = this.appendDelta("[agy tool running: " + ev.tool.name + " — the result card streams when it finishes]\n");
1556
+ if (d) yield d;
1557
+ }
1558
+ return;
1559
+ }
1200
1560
  if (this.announcedTools.has(ev.stepKey)) return;
1201
1561
  this.announcedTools.add(ev.stepKey);
1202
1562
  if (!this.opts.cutOnTool) return;
@@ -1232,9 +1592,10 @@ var EventMapper = class {
1232
1592
  }
1233
1593
  };
1234
1594
  this.blockIdx++;
1595
+ const trackedCut = this.opts.usage?.finalUsage({});
1235
1596
  yield {
1236
1597
  type: "usage",
1237
- usage: {
1598
+ usage: trackedCut !== void 0 && Object.keys(trackedCut).length > 0 ? usageFromRaw(trackedCut) : {
1238
1599
  inputTokens: 0,
1239
1600
  outputTokens: 0
1240
1601
  }
@@ -1292,14 +1653,22 @@ var EventMapper = class {
1292
1653
  };
1293
1654
  this.finished = true;
1294
1655
  }
1295
- /** Terminal error/abort: close what is open, zero usage, failure finish. */
1656
+ /**
1657
+ * Terminal error/abort: close what is open and finish with the failure.
1658
+ *
1659
+ * The usage sample is the last per-call context size when agy reported one,
1660
+ * NOT a hard-coded 0/0: DSH's token meter treats the last sample as current
1661
+ * context occupancy, so a failed turn used to reset the meter and could fire
1662
+ * a bogus compaction a step later.
1663
+ */
1296
1664
  *emitFailure(kind, code, message) {
1297
1665
  if (this.finished) return;
1298
1666
  const close = this.closeOpen();
1299
1667
  if (close) yield close;
1668
+ const tracked = this.opts.usage?.finalUsage({});
1300
1669
  yield {
1301
1670
  type: "usage",
1302
- usage: {
1671
+ usage: tracked !== void 0 && Object.keys(tracked).length > 0 ? usageFromRaw(tracked) : {
1303
1672
  inputTokens: 0,
1304
1673
  outputTokens: 0
1305
1674
  }
@@ -1475,11 +1844,49 @@ function buildFallbackCatalog(defs) {
1475
1844
  efforts: Array.isArray(d.efforts) && d.efforts.length > 0 ? d.efforts.filter((e) => typeof e === "string" && e.trim() !== "") : null
1476
1845
  }));
1477
1846
  }
1478
- var ModelCatalog = class {
1847
+ /**
1848
+ * Sanitized (id, name) projection of a catalog: trimmed non-empty ids with
1849
+ * first occurrence winning — exactly what listModels serves to DSH. Shared
1850
+ * with the settings UI's model-visibility list so the picker and the UI can
1851
+ * never disagree about which models exist.
1852
+ */
1853
+ function sanitizeCatalogEntries(models) {
1854
+ const seen = /* @__PURE__ */ new Set();
1855
+ const entries = [];
1856
+ const dropped = [];
1857
+ for (const m of models) {
1858
+ if (!m || typeof m.id !== "string") continue;
1859
+ const id = m.id.trim();
1860
+ if (id === "") continue;
1861
+ if (seen.has(id)) {
1862
+ dropped.push(id);
1863
+ continue;
1864
+ }
1865
+ seen.add(id);
1866
+ const name = typeof m.name === "string" && m.name.trim() !== "" ? m.name.trim() : id;
1867
+ entries.push({
1868
+ id,
1869
+ name
1870
+ });
1871
+ }
1872
+ return {
1873
+ entries,
1874
+ dropped
1875
+ };
1876
+ }
1877
+ var ModelCatalog = class ModelCatalog {
1479
1878
  discover;
1480
1879
  ttlMs;
1481
1880
  current;
1482
1881
  refreshing = null;
1882
+ /**
1883
+ * While the catalog is still on the fallback source, re-probe at most once
1884
+ * per this interval: `agy models` costs a 30s-capped spawn, and with the
1885
+ * user signed out EVERY listModels call (picker open, resolveModel, stream)
1886
+ * used to trigger one — a spawn storm that never converged.
1887
+ */
1888
+ static FALLBACK_RETRY_MS = 6e4;
1889
+ lastAttemptAt = 0;
1483
1890
  constructor(discover, fallbackDefs, ttlMs) {
1484
1891
  this.discover = discover;
1485
1892
  this.ttlMs = ttlMs;
@@ -1495,8 +1902,11 @@ var ModelCatalog = class {
1495
1902
  /** Refresh if stale; never throws — failures keep the previous catalog. */
1496
1903
  async refreshIfNeeded() {
1497
1904
  if (this.refreshing) return this.refreshing;
1498
- const age = Date.now() - this.current.discoveredAt;
1905
+ const now = Date.now();
1906
+ const age = now - this.current.discoveredAt;
1499
1907
  if (this.current.source === "discovered" && age < this.ttlMs) return;
1908
+ if (this.current.source === "fallback" && now - this.lastAttemptAt < ModelCatalog.FALLBACK_RETRY_MS) return;
1909
+ this.lastAttemptAt = now;
1500
1910
  this.refreshing = this.refresh().finally(() => {
1501
1911
  this.refreshing = null;
1502
1912
  });
@@ -1844,6 +2254,7 @@ var StreamJsonParser = class {
1844
2254
  consecutiveGarbage: 0,
1845
2255
  authUrl: void 0,
1846
2256
  sawAuthFailure: false,
2257
+ authFailureText: void 0,
1847
2258
  lastResultError: void 0
1848
2259
  };
1849
2260
  /** Ring buffer of raw stdout lines for /agy doctor export. */
@@ -1875,7 +2286,11 @@ var StreamJsonParser = class {
1875
2286
  this.stats.lines++;
1876
2287
  this.recentLines.push(line);
1877
2288
  if (this.recentLines.length > this.maxRecent) this.recentLines.splice(0, this.recentLines.length - this.maxRecent);
1878
- if (looksLikeAuthFailure(line)) this.stats.sawAuthFailure = true;
2289
+ if (looksLikeAuthFailure(line)) {
2290
+ this.stats.sawAuthFailure = true;
2291
+ const prev = this.stats.authFailureText ?? "";
2292
+ if (prev.length < 600) this.stats.authFailureText = (prev === "" ? "" : prev + " ") + line.trim().slice(0, 300);
2293
+ }
1879
2294
  if (this.stats.authUrl === void 0) {
1880
2295
  const u = extractAuthUrl(line);
1881
2296
  if (u) this.stats.authUrl = u;
@@ -2193,20 +2608,17 @@ function ensureIsolatedKeychain(dir, platform = process.platform, run = (file, a
2193
2608
  if (platform !== "darwin") return false;
2194
2609
  if (dir === "") return false;
2195
2610
  const kc = join(dir, "Library", "Keychains", "login.keychain-db");
2611
+ if (existsSync(kc)) return false;
2196
2612
  try {
2197
2613
  const env = {
2198
2614
  ...process.env,
2199
2615
  HOME: dir
2200
2616
  };
2201
- if (existsSync(kc)) {
2202
- run("security", ["set-keychain-settings", kc], env);
2203
- return false;
2204
- }
2205
2617
  mkdirSync(join(dir, "Library", "Keychains"), { recursive: true });
2206
2618
  run("security", [
2207
2619
  "create-keychain",
2208
2620
  "-p",
2209
- randomBytes(18).toString("base64url"),
2621
+ "",
2210
2622
  kc
2211
2623
  ], env);
2212
2624
  run("security", [
@@ -2215,6 +2627,12 @@ function ensureIsolatedKeychain(dir, platform = process.platform, run = (file, a
2215
2627
  kc
2216
2628
  ], env);
2217
2629
  run("security", ["set-keychain-settings", kc], env);
2630
+ run("security", [
2631
+ "unlock-keychain",
2632
+ "-p",
2633
+ "",
2634
+ kc
2635
+ ], env);
2218
2636
  return true;
2219
2637
  } catch {
2220
2638
  return false;
@@ -2379,9 +2797,24 @@ function killTree(child) {
2379
2797
  escalating.delete(child);
2380
2798
  });
2381
2799
  }
2800
+ /**
2801
+ * Environment for spawning a script through `process.execPath`.
2802
+ *
2803
+ * In the packaged desktop host `process.execPath` is the **Electron binary**,
2804
+ * not node: without this flag the child would boot a second GUI instance
2805
+ * instead of executing the script. Plain node ignores the variable, so the
2806
+ * POSIX/test paths are unaffected.
2807
+ */
2808
+ function nodeScriptEnv(env) {
2809
+ return {
2810
+ ...env,
2811
+ ELECTRON_RUN_AS_NODE: "1"
2812
+ };
2813
+ }
2382
2814
  function startAgyProcess(opts) {
2383
2815
  const started = Date.now();
2384
2816
  const viaCmd = IS_WIN && isCmdShim(opts.bin);
2817
+ const isNode = IS_WIN && /\.(mjs|js|cjs)$/i.test(opts.bin);
2385
2818
  const env = withAgyQuietEnv(opts.env ?? process.env);
2386
2819
  const child = viaCmd ? spawn(process.env.ComSpec ?? "cmd.exe", [
2387
2820
  "/d",
@@ -2398,6 +2831,15 @@ function startAgyProcess(opts) {
2398
2831
  ],
2399
2832
  windowsVerbatimArguments: true,
2400
2833
  windowsHide: true
2834
+ }) : isNode ? spawn(process.execPath, [opts.bin, ...opts.args], {
2835
+ cwd: opts.cwd,
2836
+ env: nodeScriptEnv(env),
2837
+ stdio: [
2838
+ "pipe",
2839
+ "pipe",
2840
+ "pipe"
2841
+ ],
2842
+ windowsHide: true
2401
2843
  }) : spawn(opts.bin, opts.args, {
2402
2844
  cwd: opts.cwd,
2403
2845
  env,
@@ -2521,6 +2963,10 @@ async function probeProcess(bin, args, timeoutMs = 3e4, signal, env) {
2521
2963
  var SessionStore = class {
2522
2964
  file;
2523
2965
  data = {};
2966
+ /** Keys written by THIS process since the last persist. */
2967
+ dirty = /* @__PURE__ */ new Set();
2968
+ /** Keys deleted by THIS process since the last persist. */
2969
+ removed = /* @__PURE__ */ new Set();
2524
2970
  constructor(file) {
2525
2971
  this.file = file;
2526
2972
  this.load();
@@ -2537,26 +2983,54 @@ var SessionStore = class {
2537
2983
  }
2538
2984
  set(key, b) {
2539
2985
  this.data[key] = b;
2986
+ this.dirty.add(key);
2987
+ this.removed.delete(key);
2540
2988
  this.persist();
2541
2989
  }
2542
2990
  delete(key) {
2543
2991
  delete this.data[key];
2992
+ this.dirty.delete(key);
2993
+ this.removed.add(key);
2544
2994
  this.persist();
2545
2995
  }
2546
2996
  clear() {
2997
+ for (const key of Object.keys(this.data)) this.removed.add(key);
2547
2998
  this.data = {};
2999
+ this.dirty.clear();
2548
3000
  this.persist();
2549
3001
  }
2550
3002
  all() {
2551
3003
  return this.data;
2552
3004
  }
2553
- /** Atomic write: tmp file + rename, then merge on next load. */
3005
+ /**
3006
+ * Atomic write: tmp file + rename, MERGING with whatever another host
3007
+ * process wrote in the meantime (web + headless share this file). Only the
3008
+ * keys this process touched are overwritten and only the keys it deleted are
3009
+ * dropped, so a concurrent writer's bindings survive instead of being
3010
+ * clobbered — which used to cost a session its agy conversation (fresh
3011
+ * digest-only prompt, or a binding pointing at the wrong conversation).
3012
+ */
2554
3013
  persist() {
2555
3014
  try {
2556
3015
  mkdirSync(dirname(this.file), { recursive: true });
3016
+ let merged = {};
3017
+ if (this.dirty.size > 0 || this.removed.size > 0) try {
3018
+ if (existsSync(this.file)) {
3019
+ const onDisk = JSON.parse(readFileSync(this.file, "utf8"));
3020
+ if (onDisk && typeof onDisk === "object") merged = onDisk;
3021
+ }
3022
+ } catch {}
3023
+ for (const key of this.removed) delete merged[key];
3024
+ for (const key of this.dirty) {
3025
+ const b = this.data[key];
3026
+ if (b !== void 0) merged[key] = b;
3027
+ }
2557
3028
  const tmp = join(dirname(this.file), "." + require$$basename(this.file) + ".tmp");
2558
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
3029
+ writeFileSync(tmp, JSON.stringify(merged, null, 2), "utf8");
2559
3030
  renameSync(tmp, this.file);
3031
+ this.data = merged;
3032
+ this.dirty.clear();
3033
+ this.removed.clear();
2560
3034
  } catch {}
2561
3035
  }
2562
3036
  };
@@ -2621,6 +3095,20 @@ const TOOL_STEP_TYPES = /* @__PURE__ */ new Set([
2621
3095
  const MAX_CACHE_AGE_MS = 3e5;
2622
3096
  let cache = null;
2623
3097
  let cacheTime = 0;
3098
+ /**
3099
+ * Failure backoff state. Without it, a missing `sqlite3` binary (or an
3100
+ * oversized/locked DB) made EVERY step event re-spawn sqlite3: the empty
3101
+ * result was cached as `loaded: true` with empty maps, every new stepIndex
3102
+ * missed, and the spawn storm repeated for the whole session with no log.
3103
+ * - ENOENT (binary absent): negative-cache for the full cache window.
3104
+ * - Other failures (lock, maxBuffer, parse): short backoff so the adapter's
3105
+ * deliberate 50-100ms WAL-flush retries still get a fresh chance.
3106
+ */
3107
+ let lastFailureAt = 0;
3108
+ let failureWindowMs = 0;
3109
+ let warnedUnavailable = false;
3110
+ /** In-flight loader: parallel readFullToolArgs/readStepThoughts share one spawn. */
3111
+ let loadInFlight = null;
2624
3112
  /** agy conversation ids are path-safe tokens; reject anything else before join(). */
2625
3113
  const SAFE_CONVERSATION_ID = /^[A-Za-z0-9_-]{1,64}$/;
2626
3114
  function isSafeConversationId(id) {
@@ -2753,8 +3241,37 @@ function extractStepThoughts(payload) {
2753
3241
  return null;
2754
3242
  }
2755
3243
  /**
3244
+ * node:sqlite reader (Node >= 22.5, REQUIRED node >= 24 for this plugin).
3245
+ * Preferred over the sqlite3 CLI: GUI/desktop hosts often lack the CLI on
3246
+ * PATH (macOS GUI apps do not inherit the shell PATH; Windows never ships
3247
+ * one), which is exactly the "thinking/tool args invisible on desktop"
3248
+ * report (issue #43). `undefined` = not probed yet, `null` = unavailable.
3249
+ */
3250
+ let nodeSqliteQuery;
3251
+ async function queryViaNodeSqlite(dbPath, sql) {
3252
+ if (nodeSqliteQuery === null) throw new Error("node:sqlite unavailable");
3253
+ if (nodeSqliteQuery === void 0) try {
3254
+ const mod = await import("node:sqlite");
3255
+ if (typeof mod.DatabaseSync !== "function") throw new Error("DatabaseSync missing");
3256
+ const Database = mod.DatabaseSync;
3257
+ nodeSqliteQuery = async (p, q) => {
3258
+ const db = new Database(p);
3259
+ try {
3260
+ return db.prepare(q).all().map((r) => `${String(r.idx)}|${String(r.step_type)}|${typeof r.payload_hex === "string" ? r.payload_hex : ""}`);
3261
+ } finally {
3262
+ db.close();
3263
+ }
3264
+ };
3265
+ } catch (err) {
3266
+ nodeSqliteQuery = null;
3267
+ throw err instanceof Error ? err : new Error(String(err));
3268
+ }
3269
+ return nodeSqliteQuery(dbPath, sql);
3270
+ }
3271
+ /**
2756
3272
  * Copy the agy SQLite DB to a temp path (avoiding WAL lock issues), then
2757
- * query tool and thought step payloads via sqlite3 CLI.
3273
+ * query tool and thought step payloads — node:sqlite first, sqlite3 CLI as
3274
+ * the fallback reader.
2758
3275
  */
2759
3276
  async function loadAgyDbData(conversationId, accountHome) {
2760
3277
  const steps = /* @__PURE__ */ new Map();
@@ -2771,21 +3288,37 @@ async function loadAgyDbData(conversationId, accountHome) {
2771
3288
  thoughts,
2772
3289
  seenSteps
2773
3290
  };
3291
+ let tmpDir = null;
2774
3292
  const tmpDb = join(tmpdir(), `agy-db-${conversationId.slice(0, 8)}-${Date.now()}.db`);
2775
3293
  try {
2776
- await copyFile(dbPath, tmpDb);
3294
+ tmpDir = await mkdtemp(join(tmpdir(), "agy-db-"));
3295
+ const target = join(tmpDir, "conversation.db");
3296
+ await copyFile(dbPath, target);
3297
+ await chmod(target, 384).catch(() => void 0);
2777
3298
  try {
2778
- await copyFile(dbPath + "-wal", tmpDb + "-wal");
3299
+ await copyFile(dbPath + "-wal", target + "-wal");
2779
3300
  } catch {}
2780
3301
  try {
2781
- await copyFile(dbPath + "-shm", tmpDb + "-shm");
3302
+ await copyFile(dbPath + "-shm", target + "-shm");
2782
3303
  } catch {}
2783
- const { stdout } = await execFileAsync("sqlite3", [tmpDb, `SELECT idx, step_type, hex(step_payload) FROM steps WHERE (step_type IN (5,7,8,9,14,15,17,21,33,101,132,138,139)) AND length(step_payload) > 20`], {
2784
- timeout: 5e3,
2785
- encoding: "utf-8",
2786
- maxBuffer: 26214400
2787
- });
2788
- for (const line of stdout.split("\n")) {
3304
+ const sql = `SELECT idx, step_type, hex(step_payload) AS payload_hex FROM steps WHERE (step_type IN (5,7,8,9,14,15,17,21,33,101,132,138,139)) AND length(step_payload) > 20`;
3305
+ let lines = null;
3306
+ try {
3307
+ lines = await queryViaNodeSqlite(target, sql);
3308
+ } catch {
3309
+ try {
3310
+ const { stdout } = await execFileAsync("sqlite3", [target, sql], {
3311
+ timeout: 5e3,
3312
+ encoding: "utf-8",
3313
+ maxBuffer: 26214400
3314
+ });
3315
+ lines = stdout.split("\n");
3316
+ } catch {
3317
+ lines = null;
3318
+ }
3319
+ }
3320
+ if (lines === null) throw new Error("no sqlite reader available (node:sqlite and sqlite3 CLI both failed)");
3321
+ for (const line of lines) {
2789
3322
  const firstPipe = line.indexOf("|");
2790
3323
  if (firstPipe < 0) continue;
2791
3324
  const secondPipe = line.indexOf("|", firstPipe + 1);
@@ -2806,16 +3339,37 @@ async function loadAgyDbData(conversationId, accountHome) {
2806
3339
  if (th !== null && th.trim() !== "" && !/^SYSTEM ROLE/i.test(th.trim())) thoughts.set(idx, th);
2807
3340
  }
2808
3341
  }
2809
- } catch {} finally {
2810
- try {
2811
- await unlink(tmpDb);
2812
- } catch {}
2813
- try {
2814
- await unlink(tmpDb + "-wal");
2815
- } catch {}
2816
- try {
2817
- await unlink(tmpDb + "-shm");
3342
+ lastFailureAt = 0;
3343
+ failureWindowMs = 0;
3344
+ } catch (err) {
3345
+ const code = err.code;
3346
+ if (code === "ENOENT") failureWindowMs = MAX_CACHE_AGE_MS;
3347
+ else failureWindowMs = 300;
3348
+ lastFailureAt = Date.now();
3349
+ if (!warnedUnavailable) {
3350
+ warnedUnavailable = true;
3351
+ try {
3352
+ console.error("[dsh-agy-link] agy conversation DB unavailable (" + String(err) + ") — falling back to stream-carried metadata only" + (code === "ENOENT" ? "; install the sqlite3 CLI for full tool args and thoughts" : ""));
3353
+ } catch {}
3354
+ }
3355
+ } finally {
3356
+ if (tmpDir !== null) try {
3357
+ await rm(tmpDir, {
3358
+ recursive: true,
3359
+ force: true
3360
+ });
2818
3361
  } catch {}
3362
+ else {
3363
+ try {
3364
+ await unlink(tmpDb);
3365
+ } catch {}
3366
+ try {
3367
+ await unlink(tmpDb + "-wal");
3368
+ } catch {}
3369
+ try {
3370
+ await unlink(tmpDb + "-shm");
3371
+ } catch {}
3372
+ }
2819
3373
  }
2820
3374
  return {
2821
3375
  steps,
@@ -2939,6 +3493,38 @@ function scanFallbackJson(payload) {
2939
3493
  return null;
2940
3494
  }
2941
3495
  /**
3496
+ * Shared loader behind readFullToolArgs/readStepThoughts: deduplicates
3497
+ * concurrent callers into ONE spawn, and honors the failure backoff so a
3498
+ * missing/failing sqlite3 cannot turn every step event into a re-spawn.
3499
+ */
3500
+ async function loadCached(conversationId, accountHome) {
3501
+ const now = Date.now();
3502
+ if (lastFailureAt !== 0 && now - lastFailureAt < failureWindowMs) return;
3503
+ const key = conversationId + "|" + (accountHome ?? "");
3504
+ if (loadInFlight !== null && loadInFlight.key === key) {
3505
+ await loadInFlight.promise;
3506
+ return;
3507
+ }
3508
+ const promise = loadAgyDbData(conversationId, accountHome);
3509
+ loadInFlight = {
3510
+ key,
3511
+ promise
3512
+ };
3513
+ try {
3514
+ const data = await promise;
3515
+ cache = {
3516
+ conversationId,
3517
+ steps: data.steps,
3518
+ thoughts: data.thoughts,
3519
+ checkedSteps: data.seenSteps,
3520
+ loaded: true
3521
+ };
3522
+ cacheTime = now;
3523
+ } finally {
3524
+ if (loadInFlight !== null && loadInFlight.promise === promise) loadInFlight = null;
3525
+ }
3526
+ }
3527
+ /**
2942
3528
  * Read the full tool parameter object for a given step index from the agy
2943
3529
  * conversation database. Returns null when the DB is unavailable or does
2944
3530
  * not contain the requested step.
@@ -2954,16 +3540,8 @@ async function readFullToolArgs(conversationId, stepIndex, accountHome) {
2954
3540
  if (cached !== void 0) return cached;
2955
3541
  if (cache.checkedSteps.has(stepIndex)) return null;
2956
3542
  }
2957
- const data = await loadAgyDbData(conversationId, accountHome);
2958
- cache = {
2959
- conversationId,
2960
- steps: data.steps,
2961
- thoughts: data.thoughts,
2962
- checkedSteps: data.seenSteps,
2963
- loaded: true
2964
- };
2965
- cacheTime = now;
2966
- return data.steps.get(stepIndex) ?? null;
3543
+ await loadCached(conversationId, accountHome);
3544
+ return cache?.steps.get(stepIndex) ?? null;
2967
3545
  }
2968
3546
  /**
2969
3547
  * Read the full thought/reasoning text for a given step index from the agy
@@ -2978,16 +3556,8 @@ async function readStepThoughts(conversationId, stepIndex, accountHome) {
2978
3556
  if (cached !== void 0) return cached;
2979
3557
  if (cache.checkedSteps.has(stepIndex)) return null;
2980
3558
  }
2981
- const data = await loadAgyDbData(conversationId, accountHome);
2982
- cache = {
2983
- conversationId,
2984
- steps: data.steps,
2985
- thoughts: data.thoughts,
2986
- checkedSteps: data.seenSteps,
2987
- loaded: true
2988
- };
2989
- cacheTime = now;
2990
- return data.thoughts.get(stepIndex) ?? null;
3559
+ await loadCached(conversationId, accountHome);
3560
+ return cache?.thoughts.get(stepIndex) ?? null;
2991
3561
  }
2992
3562
  //#endregion
2993
3563
  //#region src/host/adapter.ts
@@ -3035,22 +3605,85 @@ function textOf(m) {
3035
3605
  walk(m.content);
3036
3606
  return parts.filter((s) => s !== "").join("\n");
3037
3607
  }
3038
- function isForeignAssistant(m) {
3608
+ function isForeignAssistant(m, currentConvId) {
3039
3609
  if (m.role !== "assistant") return false;
3040
3610
  const src = m.source;
3041
- return !src || src.provider !== "antigravity";
3611
+ if (!src || src.provider !== "antigravity") return true;
3612
+ if (currentConvId) {
3613
+ const replayConvId = src.replayState && typeof src.replayState === "object" ? src.replayState.response?.conversationId : void 0;
3614
+ if (typeof replayConvId === "string" && replayConvId !== "" && replayConvId !== currentConvId) return true;
3615
+ }
3616
+ return false;
3617
+ }
3618
+ /**
3619
+ * Detect synthetic harness messages injected by DSH.
3620
+ *
3621
+ * DSH injects runtime telemetry and reminder blocks with `role: 'user'`, such as:
3622
+ * - time-context: "Time sampled while preparing turn X, step Y: ..."
3623
+ * - skill-catalog: "<system-reminder>\nA skill is a reusable set..."
3624
+ * - runtime-context: "Current runtime context. This snapshot supersedes..."
3625
+ * - model-selection: "[model changed: ...]"
3626
+ * - system-reminder: "<system-reminder>..."
3627
+ *
3628
+ * These are harness infrastructure, NEVER real human user tasks. If treated
3629
+ * as a user task, they overwrite the user's actual instruction with a timestamp
3630
+ * or reminder block, causing agy to completely ignore the user's latest input
3631
+ * (e.g. session-c65a826e where 42 time samples were appended).
3632
+ */
3633
+ function isSyntheticHarnessMessage(m) {
3634
+ if (isToolResultMessage(m)) return false;
3635
+ const src = m.source;
3636
+ const kind = src?.kind;
3637
+ if (kind !== void 0 && kind !== "" && kind !== "user") {
3638
+ if (kind === "time-context" || kind === "skill-catalog" || kind === "runtime-context" || kind === "model-selection" || kind === "system-reminder" || kind === "dsh-session-title-llm" || kind === "synthetic") return true;
3639
+ if (kind === "plugin" && src.form === "snapshot") return true;
3640
+ }
3641
+ const text = textOf(m).trim();
3642
+ if (text.startsWith("Time sampled while preparing") || text.startsWith("<system-reminder>") || text.startsWith("Current runtime context") || text.startsWith("[model changed:") || text.startsWith("[conversation so far]") || text.startsWith("A skill is a reusable set of task-specific instructions")) return true;
3643
+ return false;
3042
3644
  }
3043
3645
  /**
3044
- * Latest real human turn (not a tool result, not assistant). This is the
3045
- * live task; a digest or tool-result-only trailing span must never drop it
3646
+ * Latest real human turn (not a tool result, not assistant, not harness metadata).
3647
+ * This is the live task; a digest or tool-result-only trailing span must never drop it
3046
3648
  * (issue #35: "续跑提示词里没有任务").
3047
3649
  */
3048
3650
  function latestUserTaskText(messages) {
3049
3651
  for (let i = messages.length - 1; i >= 0; i--) {
3050
3652
  const m = messages[i];
3051
- if (m === void 0 || m.role === "system" || m.role === "assistant") continue;
3052
- if (isToolResultMessage(m)) continue;
3053
- const text = textOf(m);
3653
+ if (m === void 0) continue;
3654
+ if (isSyntheticHarnessMessage(m)) continue;
3655
+ if (isToolResultMessage(m) || m.role === "assistant" || m.role === "system") continue;
3656
+ if (m.role === "user") return textOf(m).trim();
3657
+ }
3658
+ return "";
3659
+ }
3660
+ /**
3661
+ * Last user message REGARDLESS of the synthetic-looking prefix heuristics,
3662
+ * searched only within the TRAILING span (after the last assistant message).
3663
+ *
3664
+ * The sniffing exists so harness telemetry (time samples, skill catalogs,
3665
+ * `<system-reminder>` snapshots) never overwrites a REAL task. But when the
3666
+ * synthetic-looking message is the ONLY user content of the turn — the user
3667
+ * pasted a reminder block to ask about it — filtering it left the turn with
3668
+ * no task at all ("request carries no user text"). Callers fall back to this.
3669
+ *
3670
+ * The span bound keeps the image-only regression fixed: a trailing
3671
+ * image-only turn must NOT rescue an older question from before the last
3672
+ * assistant reply, exactly as latestUserTaskText refuses to.
3673
+ */
3674
+ function latestUserMessageAny(messages) {
3675
+ let lastAssistantIdx = -1;
3676
+ for (let i = messages.length - 1; i >= 0; i--) {
3677
+ const m = messages[i];
3678
+ if (m !== void 0 && m.role === "assistant") {
3679
+ lastAssistantIdx = i;
3680
+ break;
3681
+ }
3682
+ }
3683
+ for (let i = messages.length - 1; i > lastAssistantIdx; i--) {
3684
+ const m = messages[i];
3685
+ if (m === void 0 || m.role !== "user" || isToolResultMessage(m)) continue;
3686
+ const text = textOf(m).trim();
3054
3687
  if (text !== "") return text;
3055
3688
  }
3056
3689
  return "";
@@ -3128,6 +3761,7 @@ function buildDigest(messages, fromIdx, maxChars) {
3128
3761
  for (let i = fromIdx; i < messages.length; i++) {
3129
3762
  const m = messages[i];
3130
3763
  if (m === void 0 || m.role === "system") continue;
3764
+ if (isSyntheticHarnessMessage(m)) continue;
3131
3765
  const text = textOf(m);
3132
3766
  if (text === "") continue;
3133
3767
  if (taskLine !== "" && !isToolResultMessage(m) && m.role === "user" && text === taskText) continue;
@@ -3196,12 +3830,17 @@ function sawAuthFailure(parser, outcome) {
3196
3830
  /**
3197
3831
  * Bounded wait for an aborted run to settle, so a steer replacement never
3198
3832
  * overlaps the process it just killed on the same agy conversation.
3833
+ * Returns whether the run actually settled before the deadline.
3199
3834
  */
3200
3835
  async function waitForRunSettle(run, timeoutMs) {
3201
3836
  const deadline = Date.now() + timeoutMs;
3202
- while (!run.isSettled && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
3837
+ while (Date.now() < deadline) {
3838
+ if (run.isSettled) return true;
3839
+ await new Promise((r) => setTimeout(r, 50));
3840
+ }
3841
+ return run.isSettled;
3203
3842
  }
3204
- var AgyAdapter = class extends LlmAdapter {
3843
+ var AgyAdapter = class AgyAdapter extends LlmAdapter {
3205
3844
  deps;
3206
3845
  warnedKeys = /* @__PURE__ */ new Set();
3207
3846
  /** sessionKey -> in-flight run, for steer-time preemption. */
@@ -3217,6 +3856,32 @@ var AgyAdapter = class extends LlmAdapter {
3217
3856
  minSpawnIntervalMs = 500;
3218
3857
  /** Global sliding window timestamps for batch rate-limiting protection */
3219
3858
  requestTimestamps = [];
3859
+ /**
3860
+ * sessionKey -> account failovers claimed inside the current window.
3861
+ *
3862
+ * A settled recording that died of an ACCOUNT-scoped failure is retried on
3863
+ * another account instead of being replayed (post-tool quota exhaustion used
3864
+ * to dead-end the turn). That retry happens inside one DSH step, so DSH's own
3865
+ * retry budget cannot bound it: without this counter a soft capacity refusal
3866
+ * (which cools nothing) would rotate A→B→C→A…, re-running the user's tools on
3867
+ * every cycle.
3868
+ */
3869
+ failoverBudget = /* @__PURE__ */ new Map();
3870
+ static FAILOVER_WINDOW_MS = 12e4;
3871
+ /**
3872
+ * Same shape, but for HARD rate-limit hops only. A hard 429 gives the failed
3873
+ * account a cooldown of at least the server-quoted reset (default 10m), so
3874
+ * each claim provably makes progress and cannot loop. The generic 120s/2-hop
3875
+ * budget above exists to stop SOFT failures (which cool nothing) from
3876
+ * rotating forever — but a long-running turn's quota failures arrive spread
3877
+ * over minutes (observed live: 429 at 20:28 with "Resets in 11m", further
3878
+ * hops at 20:28:43 and 20:29:17), so the tight window spent itself while a
3879
+ * full-quota account sat idle and the turn dead-ended (2026-10-10 20:29).
3880
+ * Rate-limit hops get their own pool-depth budget over a window as long as
3881
+ * the default cooldown, refreshed on every fresh failure.
3882
+ */
3883
+ rateLimitFailover = /* @__PURE__ */ new Map();
3884
+ static RATE_LIMIT_FAILOVER_WINDOW_MS = 6e5;
3220
3885
  constructor(deps) {
3221
3886
  super();
3222
3887
  this.deps = deps;
@@ -3237,25 +3902,69 @@ var AgyAdapter = class extends LlmAdapter {
3237
3902
  });
3238
3903
  }
3239
3904
  /**
3240
- * Fail fast on auth and abort; allow one retry for transient process
3241
- * failures (timeout, crash, malformed stream) per ADR-11.
3905
+ * Claim one account failover for a session, bounded by the pool's spare
3906
+ * depth ((enabled accounts - 1) inside a short window). Returns false when
3907
+ * the failure is not account-scoped or the budget is spent — the caller then
3908
+ * replays the recorded failure so the turn ends with the real error.
3909
+ */
3910
+ claimFailover(sessionKey, code) {
3911
+ if (!isAccountScopedFailure(code)) return false;
3912
+ const enabled = (this.deps.pool?.getAccounts() ?? []).filter((a) => a.enabled).length;
3913
+ const max = Math.max(1, enabled - 1);
3914
+ const now = Date.now();
3915
+ if (code === Err.RATE_LIMIT) {
3916
+ const prevRl = this.rateLimitFailover.get(sessionKey);
3917
+ const freshRl = prevRl === void 0 || now - prevRl.firstAt > AgyAdapter.RATE_LIMIT_FAILOVER_WINDOW_MS;
3918
+ const countRl = freshRl ? 0 : prevRl.count;
3919
+ if (countRl >= max) return false;
3920
+ this.rateLimitFailover.set(sessionKey, {
3921
+ count: countRl + 1,
3922
+ firstAt: freshRl ? now : prevRl.firstAt
3923
+ });
3924
+ return true;
3925
+ }
3926
+ const prev = this.failoverBudget.get(sessionKey);
3927
+ const fresh = prev === void 0 || now - prev.firstAt > AgyAdapter.FAILOVER_WINDOW_MS;
3928
+ const count = fresh ? 0 : prev.count;
3929
+ if (count >= max) return false;
3930
+ this.failoverBudget.set(sessionKey, {
3931
+ count: count + 1,
3932
+ firstAt: fresh ? now : prev.firstAt
3933
+ });
3934
+ return true;
3935
+ }
3936
+ /**
3937
+ * Fail fast on auth and abort; allow retries for transient process failures
3938
+ * (timeout, crash, malformed stream) per ADR-11.
3242
3939
  *
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.
3940
+ * Account-scoped failures (RATE_LIMIT / ELIGIBILITY / NETWORK / OVERLOADED)
3941
+ * are retryable on purpose: the failed account was cooled down or skipped and
3942
+ * the sticky pointer advanced BEFORE the error surfaced, so the retry is
3943
+ * served by the NEXT account in the pool — quota exhaustion used to dead-end
3944
+ * in a user-visible error even though a healthy account was idle.
3945
+ *
3946
+ * The budget follows the pool: a 3-account pool gets 2 retries, because one
3947
+ * hop is not enough to survive a spent account followed by a second account
3948
+ * whose token expired mid-flight (observed live: primary ELIGIBILITY → g98
3949
+ * "not signed in" → turn lost, 2026-10-10 14:17). A single-account pool keeps
3950
+ * the old single retry.
3247
3951
  */
3248
3952
  providerRetryPolicy(_provider) {
3953
+ const enabledAccounts = (this.deps.pool?.getAccounts() ?? []).filter((a) => a.enabled).length;
3954
+ const retryableCodes = [
3955
+ Err.TIMEOUT,
3956
+ Err.PROCESS_EXIT,
3957
+ Err.INVALID_OUTPUT,
3958
+ Err.RATE_LIMIT,
3959
+ Err.ELIGIBILITY,
3960
+ Err.NETWORK,
3961
+ Err.OVERLOADED
3962
+ ];
3963
+ if (enabledAccounts >= 2) retryableCodes.push(Err.AUTH);
3249
3964
  return {
3250
3965
  mode: "normal",
3251
- maxRetries: 1,
3252
- retryableCodes: [
3253
- Err.TIMEOUT,
3254
- Err.PROCESS_EXIT,
3255
- Err.INVALID_OUTPUT,
3256
- Err.RATE_LIMIT,
3257
- Err.ELIGIBILITY
3258
- ],
3966
+ maxRetries: enabledAccounts >= 3 ? 2 : 1,
3967
+ retryableCodes,
3259
3968
  initialDelayMs: 2e3,
3260
3969
  maxDelayMs: 1e4,
3261
3970
  jitterRatio: .1
@@ -3269,29 +3978,16 @@ var AgyAdapter = class extends LlmAdapter {
3269
3978
  }
3270
3979
  async listModels(_provider) {
3271
3980
  this.deps.catalog.refreshIfNeeded();
3272
- const cat = this.deps.catalog.get();
3273
- const seen = /* @__PURE__ */ new Set();
3274
- const models = [];
3275
- const dropped = [];
3276
- for (const m of cat.models) {
3277
- if (!m || typeof m.id !== "string") continue;
3278
- const id = m.id.trim();
3279
- if (id === "") continue;
3280
- if (seen.has(id)) {
3281
- dropped.push(id);
3282
- continue;
3283
- }
3284
- seen.add(id);
3285
- const name = typeof m.name === "string" && m.name.trim() !== "" ? m.name.trim() : id;
3286
- models.push({
3287
- provider: PROVIDER_ID,
3288
- id,
3289
- name,
3290
- inputModalities: ["text", "image"]
3291
- });
3292
- }
3981
+ const cfg = this.deps.getConfig();
3982
+ const { entries, dropped } = sanitizeCatalogEntries(this.deps.catalog.get().models);
3293
3983
  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)");
3294
- return models;
3984
+ const hidden = new Set(cfg.hiddenModels.map((s) => s.trim()));
3985
+ return entries.filter((m) => !hidden.has(m.id)).map((m) => ({
3986
+ provider: PROVIDER_ID,
3987
+ id: m.id,
3988
+ name: m.name,
3989
+ inputModalities: ["text", "image"]
3990
+ }));
3295
3991
  }
3296
3992
  async resolveModel(_provider, model, _signal) {
3297
3993
  const cfg = this.deps.getConfig();
@@ -3372,10 +4068,12 @@ var AgyAdapter = class extends LlmAdapter {
3372
4068
  const cfg = this.deps.getConfig();
3373
4069
  const bin = this.deps.bin();
3374
4070
  if (!bin) throw new LlmError("agy binary not found on PATH — install it via https://antigravity.google/docs/cli/install", Err.AGY_NOT_INSTALLED);
3375
- const isAux = options.purpose === "compaction" || options.purpose === "session-title";
3376
- if (isAux && !cfg.allowAuxiliary) throw new LlmError("auxiliary calls are disabled for the antigravity route (allowAuxiliary: false)", Err.AUX_DISABLED);
4071
+ const isReview = typeof options.system === "string" && options.system.includes("REVIEW_POLICY");
4072
+ const isAux = options.purpose === "compaction" || options.purpose === "session-title" || isReview;
4073
+ if (isAux && !isReview && !cfg.allowAuxiliary) throw new LlmError("auxiliary calls are disabled for the antigravity route (allowAuxiliary: false)", Err.AUX_DISABLED);
3377
4074
  const toolNames = new Set((options.tools ?? []).map((t) => t.name));
3378
4075
  const isCodeMode = toolNames.has("run_code") && !toolNames.has("agy_tool");
4076
+ const hasToolSupport = options.tools === void 0 || toolNames.has("agy_tool") || toolNames.has("run_code");
3379
4077
  const sessionKey = options.sessionId !== void 0 ? String(options.sessionId) : "";
3380
4078
  let workspaceRoot = cfg.workspaceRoot;
3381
4079
  if (workspaceRoot === "") {
@@ -3389,28 +4087,15 @@ var AgyAdapter = class extends LlmAdapter {
3389
4087
  const continuation = detectContinuation(options.messages);
3390
4088
  if (continuation !== null) {
3391
4089
  const rec = this.deps.runs.get(continuation.runId);
3392
- if (rec === void 0) {
3393
- yield {
3394
- type: "usage",
3395
- usage: {
3396
- inputTokens: 0,
3397
- outputTokens: 0
3398
- }
3399
- };
3400
- yield {
3401
- type: "finish",
3402
- reason: {
3403
- kind: "error",
3404
- failure: {
3405
- message: "agy run " + continuation.runId + " is no longer available (server restarted?) — please resend your message",
3406
- code: Err.AGY_ERROR
3407
- }
3408
- }
3409
- };
4090
+ const deadFailure = rec !== void 0 && rec.isSettled ? rec.failureInfo : null;
4091
+ if (rec !== void 0 && !(deadFailure !== null && this.claimFailover(sessionKey, deadFailure.code))) {
4092
+ yield* this.driveSpan(rec, continuation.eventIndex + 1, hasToolSupport, isCodeMode);
3410
4093
  return;
3411
4094
  }
3412
- yield* this.driveSpan(rec, continuation.eventIndex + 1, true, isCodeMode);
3413
- return;
4095
+ if (rec !== void 0) {
4096
+ this.deps.runs.forget(continuation.runId);
4097
+ this.warnOnce("dead-continuation:" + continuation.runId, "agy run " + continuation.runId + " settled in " + (deadFailure?.code ?? "") + " — retrying this step on another account instead of replaying it");
4098
+ } else this.warnOnce("stale-continuation:" + continuation.runId, "agy run " + continuation.runId + " is no longer available in memory (server restarted?) — falling back to fresh turn prompt assembly");
3414
4099
  }
3415
4100
  const catalog = this.deps.catalog.get();
3416
4101
  const rawModel = options.model;
@@ -3476,17 +4161,38 @@ var AgyAdapter = class extends LlmAdapter {
3476
4161
  }
3477
4162
  }
3478
4163
  if (!account) {
3479
- const countdown = this.deps.pool.getEarliestResetCountdown(family);
3480
- const waitStr = countdown ? ` (earliest reset in ${Math.ceil(countdown / 1e3)}s)` : "";
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);
4164
+ const pool = this.deps.pool;
4165
+ const accounts = pool.getAccounts().filter((a) => a.enabled);
4166
+ const cooling = accounts.filter((a) => pool.isAccountEligible(a, family) === false && !a.authRequired).length;
4167
+ const signedOut = accounts.filter((a) => a.authRequired === true).length;
4168
+ const countdown = pool.getEarliestAvailableCountdown?.(family) ?? pool.getEarliestResetCountdown(family);
4169
+ if (countdown !== null && countdown > 0 && countdown <= 2e4) {
4170
+ await new Promise((r) => setTimeout(r, countdown + 250));
4171
+ account = pool.selectAccount(family);
4172
+ }
4173
+ if (!account) {
4174
+ const why = signedOut > 0 && signedOut === accounts.length ? "every account needs a re-login (run /agy auth)" : `no account can serve ${family} right now (${cooling} in cooldown, ${signedOut} signed out)`;
4175
+ const waitStr = countdown !== null && countdown > 0 ? ` (earliest reset in ${Math.ceil(countdown / 1e3)}s)` : "";
4176
+ throw new LlmError(`All Antigravity accounts are unavailable: ${why}${waitStr}. Add an account, wait for reset, or run /agy clear-cooldown.`, Err.AGY_ERROR);
4177
+ }
3482
4178
  }
3483
4179
  }
3484
4180
  if (account && !isAux) this.deps.pool?.markAccountActive(account.id);
4181
+ if (process.platform === "win32" && account && !account.systemHome && !isAux) this.warnOnce("win-keyring-slot:" + account.id, "Windows cannot isolate pool credentials: agy authenticates via the system-wide Credential Manager slot (gemini:antigravity), so this isolated account still uses the system login (issue #45 — see KNOWN-GAPS.md)");
3485
4182
  const sessionAccountKey = account ? `${sessionKey}:${account.id}` : sessionKey;
3486
4183
  let binding = sessionAccountKey !== "" ? this.deps.store.get(sessionAccountKey) : void 0;
4184
+ if (binding === void 0 && sessionKey !== "") {
4185
+ binding = this.deps.store.get(sessionKey);
4186
+ if (binding === void 0 && account === void 0) {
4187
+ for (const [k, b] of Object.entries(this.deps.store.all())) if (k.startsWith(`${sessionKey}:`)) {
4188
+ binding = b;
4189
+ break;
4190
+ }
4191
+ }
4192
+ }
3487
4193
  if (binding === void 0 && !isAux && sessionKey !== "") {
3488
4194
  const live = this.activeRuns.get(sessionKey);
3489
- const liveCid = live !== void 0 && !live.isSettled ? live.conversationId : null;
4195
+ const liveCid = live !== void 0 && !live.isSettled && (live.accountHome ?? "") === (account?.dir ?? "") ? live.conversationId : null;
3490
4196
  if (liveCid) binding = {
3491
4197
  conversationId: liveCid,
3492
4198
  lastMessageCount: Math.max(0, lastAssistantIdx + 1),
@@ -3518,20 +4224,37 @@ var AgyAdapter = class extends LlmAdapter {
3518
4224
  }
3519
4225
  prompt = "[summarize this conversation for context compaction]\n\n" + parts.join("\n\n") + "\n\nProduce a compact summary that preserves decisions, file paths, and open tasks.";
3520
4226
  } else {
3521
- const trailingText = trailingUser.map(textOf).filter((s) => s !== "");
3522
- const taskText = latestUserTaskText(messages);
4227
+ const trailingStart = binding !== void 0 ? Math.max(Math.min(binding.lastMessageCount, messages.length), lastAssistantIdx + 1) : lastAssistantIdx + 1;
4228
+ const effectiveTrailingUser = messages.slice(trailingStart).filter((m) => {
4229
+ const role = m.role;
4230
+ if (role !== "user" && role !== "tool") return false;
4231
+ if (isSyntheticHarnessMessage(m)) return false;
4232
+ return true;
4233
+ });
4234
+ const taskText = latestUserTaskText(messages) || latestUserMessageAny(messages);
4235
+ const dropToolPayloads = binding !== void 0;
4236
+ const trailingText = effectiveTrailingUser.filter((m) => !(dropToolPayloads && isToolResultMessage(m))).map(textOf).map((s) => s.trim()).filter((s) => s !== "");
3523
4237
  const trailingJoined = trailingText.join("\n\n");
3524
4238
  if (taskText !== "") prompt = trailingText.includes(taskText) || trailingJoined.includes(taskText) ? trailingJoined : trailingJoined === "" ? taskText : taskText + "\n\n" + trailingJoined;
3525
4239
  else prompt = trailingJoined;
4240
+ if (prompt === "" && dropToolPayloads && effectiveTrailingUser.length > 0) prompt = "[continue — the tool output for your last tool call is already in this conversation]";
3526
4241
  if (binding === void 0 && lastAssistantIdx >= 0) prompt = buildDigest(messages, 0, cfg.contextTransferMaxChars) + prompt;
3527
4242
  else if (binding !== void 0) {
3528
4243
  const from = Math.min(binding.lastMessageCount, messages.length);
3529
4244
  const end = Math.max(from, lastAssistantIdx + 1);
3530
- const span = messages.slice(from, end).filter((m) => m.role !== "assistant" || isForeignAssistant(m));
4245
+ const currentConvId = binding.conversationId;
4246
+ const span = messages.slice(from, end).filter((m) => m.role !== "assistant" || isForeignAssistant(m, currentConvId));
3531
4247
  if (span.some((m) => m.role === "assistant")) prompt = buildDigest(span, 0, cfg.digestMaxChars) + prompt;
4248
+ if (binding.interrupted === true) prompt = "[the previous turn was interrupted before it finished — do not continue or answer that unfinished work; respond only to the newest request below]\n\n" + prompt;
3532
4249
  }
3533
4250
  }
3534
- if (cfg.forwardSystemPrompt && options.system) prompt = "System instructions:\n" + options.system + "\n\n" + prompt;
4251
+ if (cfg.forwardSystemPrompt) {
4252
+ const sysInstruction = options.system || (() => {
4253
+ const sm = messages.find((m) => m.role === "system");
4254
+ return sm ? textOf(sm).trim() : "";
4255
+ })();
4256
+ if (sysInstruction) prompt = "System instructions:\n" + sysInstruction + "\n\n" + prompt;
4257
+ }
3535
4258
  let stagedDirs = [];
3536
4259
  const newlySentImageIds = [];
3537
4260
  if (!isAux) {
@@ -3562,18 +4285,39 @@ var AgyAdapter = class extends LlmAdapter {
3562
4285
  const liveRun = this.activeRuns.get(sessionKey);
3563
4286
  const runLive = liveRun !== void 0 && !liveRun.isSettled;
3564
4287
  const samePrompt = prevPrompt !== void 0 && prevPrompt.prompt === prompt;
3565
- if (runLive && samePrompt) throw new LlmError("Duplicate request ignored: an identical request is already running for this session.", Err.BUSY);
4288
+ if (runLive && samePrompt) {
4289
+ const rescueFrom = liveRun.lastCutIndex !== null ? liveRun.lastCutIndex + 1 : null;
4290
+ if (rescueFrom !== null) {
4291
+ this.deps.log?.("duplicate-debounce rescue: identical prompt for a live agy run " + liveRun.runId + " — re-driving from event " + rescueFrom + " instead of throwing BUSY");
4292
+ this.activeSessionPrompts.set(sessionKey, {
4293
+ prompt,
4294
+ startedAt: now,
4295
+ inFlight: true
4296
+ });
4297
+ yield* this.driveSpan(liveRun, rescueFrom, hasToolSupport, isCodeMode);
4298
+ return;
4299
+ }
4300
+ throw new LlmError("Duplicate request ignored: an identical request is already running for this session.", Err.BUSY);
4301
+ }
3566
4302
  if (samePrompt && prevPrompt !== void 0 && prevPrompt.inFlight && liveRun === void 0 && now - prevPrompt.startedAt < 2e3) throw new LlmError("Duplicate request ignored: an identical request is already running for this session.", Err.BUSY);
3567
4303
  if (runLive && !samePrompt) {
3568
- const liveCid = liveRun.conversationId;
4304
+ const liveCid = (liveRun.accountHome ?? "") === (account?.dir ?? "") ? liveRun.conversationId : null;
3569
4305
  if (liveCid !== null && binding === void 0 && !isAux && sessionAccountKey !== "") binding = {
3570
4306
  conversationId: liveCid,
3571
4307
  lastMessageCount: Math.max(0, lastAssistantIdx + 1),
3572
4308
  updatedAt: Date.now(),
3573
- model: currentModel
4309
+ model: currentModel,
4310
+ interrupted: true
3574
4311
  };
3575
- liveRun.requestAbort?.();
3576
- await waitForRunSettle(liveRun, 3500);
4312
+ else if (binding !== void 0) binding.interrupted = true;
4313
+ if (liveRun.hasResult) {
4314
+ if (!await waitForRunSettle(liveRun, 1500)) this.deps.log?.("previous agy run did not exit within 1.5s after producing its result — preempting it");
4315
+ }
4316
+ if (!liveRun.isSettled) {
4317
+ liveRun.requestAbort?.();
4318
+ if (!await waitForRunSettle(liveRun, 3500)) this.warnOnce("settle-timeout:" + sessionKey, "the preempted agy run did not exit within 3.5s — starting the replacement anyway; its late write-back is ignored because the newer run owns the session");
4319
+ if (!prompt.includes("the previous turn was interrupted")) prompt = "[the previous turn was interrupted before it finished — do not continue or answer that unfinished work; respond only to the newest request below]\n\n" + prompt;
4320
+ }
3577
4321
  }
3578
4322
  this.activeSessionPrompts.set(sessionKey, {
3579
4323
  prompt,
@@ -3581,18 +4325,57 @@ var AgyAdapter = class extends LlmAdapter {
3581
4325
  inFlight: true
3582
4326
  });
3583
4327
  }
3584
- const before = snapshotConversations();
3585
4328
  const rec = this.deps.runs.create();
3586
4329
  rec.accountHome = account && account.dir ? account.dir : void 0;
4330
+ const conversationsDir = conversationsDirFor(rec.accountHome);
4331
+ const before = snapshotConversations(conversationsDir);
3587
4332
  const parser = new StreamJsonParser();
3588
4333
  this.deps.onParser?.(parser);
3589
4334
  let streamCid = null;
3590
4335
  const openToolKeys = /* @__PURE__ */ new Set();
3591
- const printTimeoutMinutes = Math.max(240, Math.ceil(cfg.timeoutMs / 6e4));
3592
- const longToolIdleMs = printTimeoutMinutes * 6e4;
4336
+ const openToolSince = /* @__PURE__ */ new Map();
4337
+ const longToolIdleMinutes = cfg.longToolIdleMinutes > 0 ? cfg.longToolIdleMinutes : 0;
4338
+ const printTimeoutMinutes = Math.max(longToolIdleMinutes > 0 ? longToolIdleMinutes : 240, Math.ceil(cfg.timeoutMs / 6e4));
4339
+ const longToolIdleMs = longToolIdleMinutes > 0 ? longToolIdleMinutes * 6e4 : cfg.timeoutMs;
3593
4340
  let runningProc = null;
4341
+ /**
4342
+ * True once agy produced ANY output for this run. A run that has already
4343
+ * emitted events is demonstrably alive and authenticated; a silent gap
4344
+ * after that is a long model/tool phase, not a dead process. The idle
4345
+ * watchdog used to kill those at the 10-minute default — 12 of the 37
4346
+ * failures in one week of this user's logs were exactly
4347
+ * "agy run was idle for 600000ms without output", each one costing the
4348
+ * whole turn and a resend. Before the first event the strict budget stays:
4349
+ * that is the genuinely wedged/unreachable-Google shape.
4350
+ */
4351
+ let sawAnyEvent = false;
4352
+ /** Timestamp of the most recent stdout line — drives the silence heartbeat. */
4353
+ let lastOutputAt = 0;
3594
4354
  const applyToolIdleBudget = () => {
3595
- runningProc?.noteActivity(openToolKeys.size > 0 ? longToolIdleMs : cfg.timeoutMs);
4355
+ runningProc?.noteActivity(openToolKeys.size > 0 || sawAnyEvent ? longToolIdleMs : cfg.timeoutMs);
4356
+ };
4357
+ const TOOL_HEARTBEAT_MS = 3e5;
4358
+ let toolHeartbeat = null;
4359
+ const stopToolHeartbeat = () => {
4360
+ if (toolHeartbeat !== null) {
4361
+ clearInterval(toolHeartbeat);
4362
+ toolHeartbeat = null;
4363
+ }
4364
+ };
4365
+ const startToolHeartbeat = () => {
4366
+ if (toolHeartbeat !== null) return;
4367
+ toolHeartbeat = setInterval(() => {
4368
+ if (openToolKeys.size === 0 && !sawAnyEvent) {
4369
+ stopToolHeartbeat();
4370
+ return;
4371
+ }
4372
+ let oldest = Date.now();
4373
+ for (const startedAt of openToolSince.values()) if (startedAt < oldest) oldest = startedAt;
4374
+ if (openToolKeys.size === 0 && lastOutputAt > 0) oldest = lastOutputAt;
4375
+ const mins = Math.max(1, Math.round((Date.now() - oldest) / 6e4));
4376
+ this.deps.log?.(openToolKeys.size > 0 ? "agy tool step still running: " + openToolKeys.size + " open, oldest " + mins + "m (idle budget " + Math.round(longToolIdleMs / 6e4) + "m) — the turn is waiting on agy" : "agy run still working: no new output for " + mins + "m (idle budget " + Math.round(longToolIdleMs / 6e4) + "m) — the turn is waiting on agy; press Stop to cancel");
4377
+ }, TOOL_HEARTBEAT_MS);
4378
+ toolHeartbeat.unref?.();
3596
4379
  };
3597
4380
  const activeModelForArgs = activeModel === "" ? cfg.defaultModel : activeModel;
3598
4381
  const effectiveAddDirs = workspaceRoot !== "" ? stagedDirs.includes(workspaceRoot) ? stagedDirs : [workspaceRoot, ...stagedDirs] : stagedDirs;
@@ -3628,6 +4411,10 @@ var AgyAdapter = class extends LlmAdapter {
3628
4411
  released = true;
3629
4412
  release();
3630
4413
  };
4414
+ if (options.signal?.aborted) {
4415
+ releaseOnce();
4416
+ throw new LlmError("request aborted before the agy process was spawned", Err.AGY_ERROR);
4417
+ }
3631
4418
  if (account && account.dir) ensureIsolatedKeychain(account.dir);
3632
4419
  const env = {
3633
4420
  ...process.env,
@@ -3640,16 +4427,17 @@ var AgyAdapter = class extends LlmAdapter {
3640
4427
  ...account && account.dir ? isolatedHomeEnv(account.dir) : {},
3641
4428
  ...proxyEnvFor(account?.proxyUrl || cfg.proxyUrl || void 0)
3642
4429
  };
3643
- if (account) {
3644
- const lastSpawn = this.lastAccountSpawnTime.get(account.id) ?? 0;
3645
- const elapsed = Date.now() - lastSpawn;
3646
- const jitter = Math.floor(Math.random() * 300);
3647
- const targetInterval = this.minSpawnIntervalMs + jitter;
3648
- if (elapsed < targetInterval) await new Promise((r) => setTimeout(r, targetInterval - elapsed));
3649
- this.lastAccountSpawnTime.set(account.id, Date.now());
3650
- }
3651
4430
  let proc;
4431
+ let spawned = false;
3652
4432
  try {
4433
+ if (account) {
4434
+ const lastSpawn = this.lastAccountSpawnTime.get(account.id) ?? 0;
4435
+ const elapsed = Date.now() - lastSpawn;
4436
+ const jitter = Math.floor(Math.random() * 300);
4437
+ const targetInterval = this.minSpawnIntervalMs + jitter;
4438
+ if (elapsed < targetInterval) await new Promise((r) => setTimeout(r, targetInterval - elapsed));
4439
+ this.lastAccountSpawnTime.set(account.id, Date.now());
4440
+ }
3653
4441
  proc = startAgyProcess({
3654
4442
  bin,
3655
4443
  args,
@@ -3658,7 +4446,12 @@ var AgyAdapter = class extends LlmAdapter {
3658
4446
  signal: options.signal,
3659
4447
  env,
3660
4448
  stdinPayload,
4449
+ stderrKeepalive: true,
3661
4450
  onLine: (line) => {
4451
+ sawAnyEvent = true;
4452
+ lastOutputAt = Date.now();
4453
+ startToolHeartbeat();
4454
+ applyToolIdleBudget();
3662
4455
  for (const ev of parser.feed(line + "\n")) {
3663
4456
  if (ev.kind === "init" && ev.conversationId) streamCid = ev.conversationId;
3664
4457
  if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
@@ -3666,40 +4459,62 @@ var AgyAdapter = class extends LlmAdapter {
3666
4459
  if (!(ev.state === "DONE" || ev.state === "ERROR" || ev.state === void 0 && (ev.tool.output !== void 0 || ev.tool.error !== void 0))) {
3667
4460
  if (!openToolKeys.has(ev.stepKey)) {
3668
4461
  openToolKeys.add(ev.stepKey);
4462
+ openToolSince.set(ev.stepKey, Date.now());
3669
4463
  applyToolIdleBudget();
4464
+ startToolHeartbeat();
3670
4465
  }
3671
- } else if (openToolKeys.delete(ev.stepKey)) applyToolIdleBudget();
4466
+ } else if (openToolKeys.delete(ev.stepKey)) {
4467
+ openToolSince.delete(ev.stepKey);
4468
+ applyToolIdleBudget();
4469
+ if (openToolKeys.size === 0) stopToolHeartbeat();
4470
+ }
3672
4471
  }
3673
4472
  rec.append(ev);
3674
4473
  }
3675
4474
  }
3676
4475
  });
4476
+ spawned = true;
3677
4477
  runningProc = proc;
3678
4478
  applyToolIdleBudget();
3679
4479
  if (!isAux && sessionKey !== "") {
3680
4480
  rec.requestAbort = () => proc.kill("abort");
3681
4481
  this.activeRuns.set(sessionKey, rec);
3682
4482
  }
4483
+ if (options.signal?.aborted) proc.kill("abort");
3683
4484
  } catch (e) {
3684
4485
  releaseOnce();
3685
4486
  this.clearInFlightPrompt(sessionKey, prompt);
3686
4487
  throw new LlmError("failed to spawn agy: " + brief(String(e)), Err.PROCESS_EXIT);
4488
+ } finally {
4489
+ if (!spawned) {
4490
+ releaseOnce();
4491
+ this.clearInFlightPrompt(sessionKey, prompt);
4492
+ }
3687
4493
  }
3688
4494
  (async () => {
3689
4495
  const outcome = await proc.outcome;
3690
4496
  releaseOnce();
4497
+ stopToolHeartbeat();
4498
+ openToolKeys.clear();
4499
+ openToolSince.clear();
3691
4500
  if (this.activeRuns.get(sessionKey) === rec) this.activeRuns.delete(sessionKey);
3692
4501
  this.clearInFlightPrompt(sessionKey, prompt);
3693
4502
  for (const ev of parser.flush()) {
3694
4503
  if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
3695
4504
  rec.append(ev);
3696
4505
  }
3697
- const diffed = diffConversations(before).conversationId;
4506
+ const diffed = diffConversations(before, conversationsDir).conversationId;
3698
4507
  const conversationId = streamCid ?? diffed;
3699
4508
  const r = rec.getResultEvent();
3700
4509
  const consumable = r !== null && (r.ok || r.response !== "");
3701
4510
  const rawErrText = [outcome.stderrTail, parser.stats.lastResultError].filter(Boolean).join(" ");
3702
4511
  const isRateLimit = looksLikeRateLimit(rawErrText);
4512
+ const transportFailure = looksLikeNetworkError([
4513
+ rawErrText,
4514
+ parser.stats.authFailureText ?? "",
4515
+ outcome.stderrTail,
4516
+ outcome.stdout.slice(0, 4e3)
4517
+ ].filter(Boolean).join(" "));
3703
4518
  const silentTimeout = outcome.timedOut === true && outcome.stdout.trim() === "";
3704
4519
  let failure = null;
3705
4520
  if (outcome.aborted) failure = {
@@ -3712,7 +4527,11 @@ var AgyAdapter = class extends LlmAdapter {
3712
4527
  code: Err.TIMEOUT,
3713
4528
  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
4529
  };
3715
- else if (sawAuthFailure(parser, outcome)) failure = {
4530
+ else if (sawAuthFailure(parser, outcome)) failure = transportFailure ? {
4531
+ kind: "error",
4532
+ code: Err.NETWORK,
4533
+ message: "agy could not validate its Google login over the network (transport failure, not a sign-out) — retrying on another account. " + (rawErrText !== "" ? brief(rawErrText) : "")
4534
+ } : {
3716
4535
  kind: "error",
3717
4536
  code: Err.AUTH,
3718
4537
  message: "agy is not signed in — run /agy auth (or run agy once in a terminal) to login"
@@ -3721,7 +4540,7 @@ var AgyAdapter = class extends LlmAdapter {
3721
4540
  const bestMsg = parser.stats.lastResultError || (outcome.stderrTail ? brief(outcome.stderrTail) : "Rate limit or quota reached");
3722
4541
  failure = {
3723
4542
  kind: "error",
3724
- code: looksLikeHardRateLimit(rawErrText) ? Err.RATE_LIMIT : Err.AGY_ERROR,
4543
+ code: looksLikeHardRateLimit(rawErrText) ? Err.RATE_LIMIT : Err.OVERLOADED,
3725
4544
  message: "Google Antigravity quota / rate limit reached: " + bestMsg
3726
4545
  };
3727
4546
  } else if (looksLikeEligibilityFailure(rawErrText) || looksLikeEligibilityFailure(parser.stats.lastResultError ?? "")) {
@@ -3751,9 +4570,12 @@ var AgyAdapter = class extends LlmAdapter {
3751
4570
  };
3752
4571
  }
3753
4572
  rec.settle(failure);
4573
+ const superseded = this.activeRuns.get(sessionKey) !== void 0 && this.activeRuns.get(sessionKey) !== rec;
3754
4574
  if (failure === null) {
3755
4575
  if (account) this.deps.pool?.recordSuccess(account.id, family);
3756
- if (!isAux && sessionAccountKey !== "") {
4576
+ this.failoverBudget.delete(sessionKey);
4577
+ this.rateLimitFailover.delete(sessionKey);
4578
+ if (!isAux && sessionAccountKey !== "" && !superseded) {
3757
4579
  const finalId = binding !== void 0 ? binding.conversationId : conversationId;
3758
4580
  if (finalId) this.deps.store.set(sessionAccountKey, {
3759
4581
  conversationId: finalId,
@@ -3764,21 +4586,23 @@ var AgyAdapter = class extends LlmAdapter {
3764
4586
  });
3765
4587
  }
3766
4588
  } else {
3767
- const effectiveRateLimit = isRateLimit || looksLikeRateLimit(failure.message);
3768
4589
  if (account && !outcome.aborted && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
3769
4590
  if (account && silentTimeout) this.deps.pool?.advanceAccount(account.id, family);
3770
4591
  if (account && failure.code === Err.ELIGIBILITY) this.deps.pool?.advanceAccount(account.id, family);
4592
+ if (!outcome.aborted && failure.code !== Err.RATE_LIMIT && (failure.code === Err.NETWORK || failure.code === Err.OVERLOADED || failure.code === Err.AUTH || outcome.code !== 0 || parser.stats.lastResultError !== void 0) && account) this.deps.pool?.advanceAccount(account.id, family);
3771
4593
  if (account && shouldMarkAuthRequired(failure.code, failure.message)) this.deps.pool?.markAuthRequired(account.id, failure.message);
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);
4594
+ const staleConversation = failure.code === Err.AUTH || failure.message !== "" && /conversation.*(not found|invalid|not recognized|expired|does not exist)|session.*(expired|invalid)/i.test(failure.message);
4595
+ const interruptedTurn = failure.kind === "aborted" || outcome.timedOut === true;
3773
4596
  if (!isAux && sessionAccountKey !== "" && staleConversation) this.deps.store.delete(sessionAccountKey);
3774
- else if (!isAux && sessionAccountKey !== "" && !staleConversation) {
4597
+ else if (!isAux && sessionAccountKey !== "" && !superseded) {
3775
4598
  const finalId = binding !== void 0 ? binding.conversationId : conversationId;
3776
4599
  if (finalId) this.deps.store.set(sessionAccountKey, {
3777
4600
  conversationId: finalId,
3778
4601
  lastMessageCount: messages.length,
3779
4602
  updatedAt: Date.now(),
3780
4603
  model: activeModel,
3781
- sentImageIds: mergeSentImageIds(binding?.sentImageIds, newlySentImageIds)
4604
+ sentImageIds: mergeSentImageIds(binding?.sentImageIds, []),
4605
+ ...interruptedTurn || binding?.interrupted === true ? { interrupted: true } : {}
3782
4606
  });
3783
4607
  }
3784
4608
  }
@@ -3787,7 +4611,8 @@ var AgyAdapter = class extends LlmAdapter {
3787
4611
  processCode: outcome.aborted ? "ABORTED" : outcome.timedOut ? Err.TIMEOUT : outcome.code === 0 ? "OK" : "EXIT_" + String(outcome.code),
3788
4612
  toolErrors: rec.toolErrors(),
3789
4613
  durationMs: outcome.durationMs,
3790
- model
4614
+ model,
4615
+ tokenSpeed: rec.speedStats()
3791
4616
  });
3792
4617
  })().catch((err) => {
3793
4618
  releaseOnce();
@@ -3797,7 +4622,7 @@ var AgyAdapter = class extends LlmAdapter {
3797
4622
  message: "internal error: " + brief(String(err))
3798
4623
  });
3799
4624
  });
3800
- yield* this.driveSpan(rec, 0, !isAux, isCodeMode);
4625
+ yield* this.driveSpan(rec, 0, !isAux && hasToolSupport, isCodeMode);
3801
4626
  }
3802
4627
  /**
3803
4628
  * Stream one span of a recording: map events from `from` until the mapper
@@ -3807,6 +4632,17 @@ var AgyAdapter = class extends LlmAdapter {
3807
4632
  * provider error.
3808
4633
  */
3809
4634
  async *driveSpan(rec, from, cutOnTool, useCodeWrapper) {
4635
+ this.deps.runs.remember(rec);
4636
+ this.deps.runs.pin(rec.runId);
4637
+ try {
4638
+ yield* this.driveSpanBody(rec, from, cutOnTool, useCodeWrapper);
4639
+ } finally {
4640
+ this.deps.runs.unpin(rec.runId);
4641
+ this.deps.runs.remember(rec);
4642
+ }
4643
+ }
4644
+ /** Body of {@link driveSpan}: map recorded events until the span finishes. */
4645
+ async *driveSpanBody(rec, from, cutOnTool, useCodeWrapper) {
3810
4646
  const queue = new ChunkQueue();
3811
4647
  (async () => {
3812
4648
  const resolved = /* @__PURE__ */ new Map();
@@ -3834,8 +4670,12 @@ var AgyAdapter = class extends LlmAdapter {
3834
4670
  if (activeConvId !== null && Number.isFinite(stepIdx)) try {
3835
4671
  let th = await readStepThoughts(activeConvId, stepIdx, rec.accountHome);
3836
4672
  if (th === null && (ev.state === "DONE" || (ev.usage?.thinking_tokens ?? 0) > 0 || ev.stepKind === "thinking")) {
3837
- await new Promise((r) => setTimeout(r, 50));
4673
+ await new Promise((r) => setTimeout(r, 60));
3838
4674
  th = await readStepThoughts(activeConvId, stepIdx, rec.accountHome);
4675
+ if (th === null) {
4676
+ await new Promise((r) => setTimeout(r, 100));
4677
+ th = await readStepThoughts(activeConvId, stepIdx, rec.accountHome);
4678
+ }
3839
4679
  }
3840
4680
  if (th !== null && th.trim() !== "") {
3841
4681
  resolvedThoughts.set(i, th);
@@ -3878,7 +4718,10 @@ var AgyAdapter = class extends LlmAdapter {
3878
4718
  }
3879
4719
  for (const ch of mapper.map(ev, i)) queue.push(ch);
3880
4720
  i++;
3881
- if (mapper.isFinished) break;
4721
+ if (mapper.isFinished) {
4722
+ rec.lastCutIndex = i - 1;
4723
+ break;
4724
+ }
3882
4725
  }
3883
4726
  if (!mapper.isFinished) {
3884
4727
  const f = rec.failureInfo;
@@ -27642,10 +28485,21 @@ function agentFor(proxyUrl) {
27642
28485
  }
27643
28486
  return envAgent;
27644
28487
  }
28488
+ /**
28489
+ * Default timeout for one Google endpoint call. undici's own default
28490
+ * headers timeout is 300s, so a hung refresh-token / quota endpoint used to
28491
+ * park the whole chain (token refresh → two endpoints → userinfo) for many
28492
+ * minutes — enough for `/agy refresh-quota` to look dead. Callers may pass
28493
+ * their own `signal`; it is combined with the timeout.
28494
+ */
28495
+ const AGY_FETCH_TIMEOUT_MS = 1e4;
27645
28496
  /** fetch() honoring env proxies, or an explicit per-account proxy URL. */
27646
28497
  function agyFetch(url, init = {}, proxyUrl) {
28498
+ const callerSignal = init.signal ?? void 0;
28499
+ const signal = callerSignal !== void 0 ? AbortSignal.any([AbortSignal.timeout(AGY_FETCH_TIMEOUT_MS), callerSignal]) : AbortSignal.timeout(AGY_FETCH_TIMEOUT_MS);
27647
28500
  return (0, import_undici.fetch)(url, {
27648
28501
  ...init,
28502
+ signal,
27649
28503
  dispatcher: agentFor(proxyUrl)
27650
28504
  });
27651
28505
  }
@@ -27827,7 +28681,14 @@ function startCallbackListener(timeoutMs = 3e5) {
27827
28681
  "Content-Type": "text/html",
27828
28682
  connection: "close"
27829
28683
  });
27830
- res.end(`<h2>Authorization failed: ${oauthError}</h2>`);
28684
+ res.end(`<h2>Authorization failed: ${oauthError.replace(/[&<>"']/g, (ch) => ({
28685
+ "&": "&amp;",
28686
+ "<": "&lt;",
28687
+ ">": "&gt;",
28688
+ "\"": "&quot;",
28689
+ "'": "&#39;"
28690
+ })[ch] ?? ch)}</h2>`);
28691
+ clearTimeout(timeout);
27831
28692
  rejectResult(/* @__PURE__ */ new Error(`oauth error: ${oauthError}`));
27832
28693
  setTimeout(() => server.close(), 1500);
27833
28694
  return;
@@ -27842,6 +28703,7 @@ function startCallbackListener(timeoutMs = 3e5) {
27842
28703
  connection: "close"
27843
28704
  });
27844
28705
  res.end(CALLBACK_SUCCESS_HTML);
28706
+ clearTimeout(timeout);
27845
28707
  resolveResult({
27846
28708
  code,
27847
28709
  state
@@ -27849,6 +28711,7 @@ function startCallbackListener(timeoutMs = 3e5) {
27849
28711
  setTimeout(() => server.close(), 1500);
27850
28712
  });
27851
28713
  server.on("error", (error) => {
28714
+ clearTimeout(timeout);
27852
28715
  rejectResult(error.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`port ${OAUTH_CALLBACK_PORT} is busy`) : error);
27853
28716
  });
27854
28717
  server.listen(OAUTH_CALLBACK_PORT, "127.0.0.1");
@@ -27872,16 +28735,21 @@ function openBrowser(url) {
27872
28735
  return new Promise((resolve) => {
27873
28736
  try {
27874
28737
  if (process.platform === "darwin") execFile("open", [url], (err) => resolve(!err));
27875
- else if (process.platform === "win32") execFile("cmd.exe", [
27876
- "/d",
27877
- "/s",
27878
- "/c",
27879
- `start "" "${url}"`
27880
- ], {
27881
- windowsVerbatimArguments: true,
27882
- windowsHide: true
27883
- }, (err) => resolve(!err));
27884
- else execFile("xdg-open", [url], (err) => resolve(!err));
28738
+ else if (process.platform === "win32") {
28739
+ if (url.includes("\"") || /[\r\n]/.test(url)) {
28740
+ resolve(false);
28741
+ return;
28742
+ }
28743
+ execFile("cmd.exe", [
28744
+ "/d",
28745
+ "/s",
28746
+ "/c",
28747
+ `start "" "${url}"`
28748
+ ], {
28749
+ windowsVerbatimArguments: true,
28750
+ windowsHide: true
28751
+ }, (err) => resolve(!err));
28752
+ } else execFile("xdg-open", [url], (err) => resolve(!err));
27885
28753
  } catch {
27886
28754
  resolve(false);
27887
28755
  }
@@ -27921,6 +28789,8 @@ var AuthHelper = class {
27921
28789
  urlTimer = null;
27922
28790
  activeHomeDir = null;
27923
28791
  activeAccountId = null;
28792
+ /** Monotonic begin() generation; a superseded begin must not write state. */
28793
+ beginSeq = 0;
27924
28794
  signedInCache = null;
27925
28795
  signedInFlight = null;
27926
28796
  constructor(bin) {
@@ -28038,6 +28908,7 @@ var AuthHelper = class {
28038
28908
  }
28039
28909
  /** Start (or restart) the login flow; resolves with the consent URL. */
28040
28910
  async begin(homeDir, accountId, proxyUrl) {
28911
+ const seq = ++this.beginSeq;
28041
28912
  this.activeHomeDir = homeDir || null;
28042
28913
  this.activeAccountId = accountId || null;
28043
28914
  if (!this.startProbe(homeDir, proxyUrl)) {
@@ -28062,6 +28933,7 @@ var AuthHelper = class {
28062
28933
  resolve(null);
28063
28934
  }, URL_WAIT_MS);
28064
28935
  });
28936
+ if (seq !== this.beginSeq) return this.status();
28065
28937
  if (this.urlTimer) clearTimeout(this.urlTimer);
28066
28938
  this.urlTimer = null;
28067
28939
  if (!url) {
@@ -28082,6 +28954,7 @@ var AuthHelper = class {
28082
28954
  margin: 1
28083
28955
  });
28084
28956
  } catch {}
28957
+ if (seq !== this.beginSeq) return this.status();
28085
28958
  this.state = {
28086
28959
  phase: "pending",
28087
28960
  accountId: this.activeAccountId || void 0,
@@ -28123,7 +28996,11 @@ var AuthHelper = class {
28123
28996
  };
28124
28997
  return this.status();
28125
28998
  }
28126
- const outcome = await Promise.race([proc.outcome, new Promise((resolve) => setTimeout(() => resolve(null), CODE_SETTLE_MS))]);
28999
+ let settleTimer = null;
29000
+ const outcome = await Promise.race([proc.outcome, new Promise((resolve) => {
29001
+ settleTimer = setTimeout(() => resolve(null), CODE_SETTLE_MS);
29002
+ })]);
29003
+ if (settleTimer !== null) clearTimeout(settleTimer);
28127
29004
  if (outcome === null) {
28128
29005
  this.state = {
28129
29006
  phase: "failed",
@@ -28170,7 +29047,9 @@ var AuthHelper = class {
28170
29047
  }
28171
29048
  if (this.urlTimer) clearTimeout(this.urlTimer);
28172
29049
  this.urlTimer = null;
29050
+ const waiter = this.urlWaiter;
28173
29051
  this.urlWaiter = null;
29052
+ waiter?.(null);
28174
29053
  }
28175
29054
  dispose() {
28176
29055
  this.cancel();
@@ -28178,6 +29057,73 @@ var AuthHelper = class {
28178
29057
  }
28179
29058
  };
28180
29059
  //#endregion
29060
+ //#region src/host/diagnostics.ts
29061
+ /**
29062
+ * Redact user:pass credentials from a proxy URL for display in command output
29063
+ * and reports. The full URL (with embedded credentials) stays in config only.
29064
+ */
29065
+ function redactProxyUrl(url) {
29066
+ if (url === "") return url;
29067
+ try {
29068
+ const u = new URL(url);
29069
+ if (u.username !== "" || u.password !== "") {
29070
+ u.username = u.username !== "" ? "***" : "";
29071
+ u.password = u.password !== "" ? "***" : "";
29072
+ return u.toString();
29073
+ }
29074
+ return url;
29075
+ } catch {
29076
+ return "<proxy-url-redacted>";
29077
+ }
29078
+ }
29079
+ /** Redact auth URLs, long tokens, and authorization codes. */
29080
+ function redactLine(line) {
29081
+ let out = line;
29082
+ out = out.replace(/https:\/\/accounts\.google\.com\/\S+/g, "<auth-url-redacted>");
29083
+ out = out.replace(/\b[0-9]{4,}\//g, "4/<code-redacted>");
29084
+ out = out.replace(/ya29\.[A-Za-z0-9._-]+/g, "<oauth-token-redacted>");
29085
+ out = out.replace(/Bearer\s+\S+/gi, "Bearer <redacted>");
29086
+ out = out.replace(/\b1\/\/0[A-Za-z0-9._-]{10,}/g, "<oauth-refresh-token-redacted>");
29087
+ out = out.replace(/\bAIza[0-9A-Za-z_-]{30,}\b/g, "<api-key-redacted>");
29088
+ out = out.replace(/\b(?:https?|socks[45]):\/\/[^\s/]*:[^\s/@]*@[^\s]+/gi, "<proxy-url-redacted>");
29089
+ return out;
29090
+ }
29091
+ function writeDoctorReport(deps) {
29092
+ const cfg = deps.cfg();
29093
+ const cat = deps.catalog().get();
29094
+ const bindings = deps.store().all();
29095
+ const lines = [];
29096
+ lines.push("# dsh-agy-link diagnostic report");
29097
+ lines.push("");
29098
+ lines.push("- generated: " + (/* @__PURE__ */ new Date()).toISOString());
29099
+ lines.push("- agy binary: " + (deps.bin() ?? "NOT FOUND"));
29100
+ lines.push("- agy version: " + (deps.version() ?? "unknown"));
29101
+ const cfgDump = {
29102
+ ...cfg,
29103
+ extraArgs: cfg.extraArgs.length
29104
+ };
29105
+ if (typeof cfgDump.proxyUrl === "string" && cfgDump.proxyUrl !== "") cfgDump.proxyUrl = redactProxyUrl(cfgDump.proxyUrl);
29106
+ lines.push("- plugin config: " + JSON.stringify(cfgDump));
29107
+ lines.push("- catalog: " + cat.source + " — " + cat.models.length + " models" + (cat.lastError === void 0 ? "" : " — error: " + cat.lastError));
29108
+ for (const m of cat.models) lines.push(" - " + m.id + (m.efforts ? " [" + m.efforts.join("/") + "]" : ""));
29109
+ lines.push("- conversation bindings: " + Object.keys(bindings).length);
29110
+ for (const [k, b] of Object.entries(bindings)) lines.push(" - " + k + " -> " + b.conversationId + " @ " + new Date(b.updatedAt).toISOString());
29111
+ lines.push("- node: " + process.version + " — " + process.platform + " " + process.arch);
29112
+ lines.push("");
29113
+ lines.push("## last stream-json stdout (redacted, tail)");
29114
+ lines.push("");
29115
+ lines.push("```");
29116
+ const recent = deps.recentLines();
29117
+ for (const l of recent.slice(-400)) lines.push(redactLine(l));
29118
+ lines.push("```");
29119
+ lines.push("");
29120
+ const dir = join(stateDir(), "diagnostics");
29121
+ mkdirSync(dir, { recursive: true });
29122
+ const file = join(dir, "doctor-" + (/* @__PURE__ */ new Date()).toISOString().replace(/[^0-9]/g, "").slice(0, 14) + ".md");
29123
+ writeFileSync(file, lines.join("\n"), "utf8");
29124
+ return file;
29125
+ }
29126
+ //#endregion
28181
29127
  //#region src/host/commands.ts
28182
29128
  const HELP = [
28183
29129
  "**/agy** — Antigravity (agy CLI) bridge",
@@ -28229,7 +29175,7 @@ async function handle(deps, raw) {
28229
29175
  const sec = Math.ceil((cd.cooldownUntil - Date.now()) / 1e3);
28230
29176
  cds.push(`${fam} cooldown: ${sec}s left (${cd.reason})`);
28231
29177
  }
28232
- lines.push(`${i + 1}. **${a.alias}** (${a.email || a.id})${isPrimary ? " ⭐ primary" : ""}`, ` - Proxy: ${a.proxyUrl || deps.cfg().proxyUrl || "system default"}`, ` - Quota: Gemini ${qG} | Claude ${qC} | GPT-OSS ${qO}`, cds.length > 0 ? ` - ⚠️ ${cds.join("; ")}` : " - Status: 🟢 Ready");
29178
+ lines.push(`${i + 1}. **${a.alias}** (${a.email || a.id})${isPrimary ? " ⭐ primary" : ""}`, ` - Proxy: ${a.proxyUrl ? redactProxyUrl(a.proxyUrl) : deps.cfg().proxyUrl ? redactProxyUrl(deps.cfg().proxyUrl) : "system default"}`, ` - Quota: Gemini ${qG} | Claude ${qC} | GPT-OSS ${qO}`, cds.length > 0 ? ` - ⚠️ ${cds.join("; ")}` : " - Status: 🟢 Ready");
28233
29179
  }
28234
29180
  return ok(lines.join("\n"));
28235
29181
  }
@@ -28237,10 +29183,12 @@ async function handle(deps, raw) {
28237
29183
  const pool = deps.pool?.();
28238
29184
  if (!pool) return err("Account pool not available");
28239
29185
  const acc = pool.createAccountSlot(arg || void 0);
29186
+ pool.markAuthRequired(acc.id, "not signed in yet — complete the terminal login");
28240
29187
  return ok([
28241
29188
  `**Account slot created: [${acc.alias}]** (id: \`${acc.id}\`)`,
28242
29189
  `- Isolated directory: \`${acc.dir}\``,
28243
29190
  `- Run in terminal: \`HOME="${acc.dir}" agy\` to authenticate this Google account.`,
29191
+ `- The slot stays OUT of rotation (authRequired) until the login completes and a quota refresh verifies it.`,
28244
29192
  `- Once logged in, run: \`/agy pool\` or click 🔄 刷新额度 in WebUI.`
28245
29193
  ].join("\n"));
28246
29194
  }
@@ -28341,7 +29289,12 @@ async function renderStatus(deps) {
28341
29289
  const cfg = deps.cfg();
28342
29290
  const bin = deps.bin();
28343
29291
  const authHelper = deps.auth();
28344
- const auth = authHelper ? await Promise.race([authHelper.resolvedStatus(), new Promise((r) => setTimeout(() => r(void 0), 8e3))]) : void 0;
29292
+ let probeTimer = null;
29293
+ const auth = authHelper ? await Promise.race([authHelper.resolvedStatus(), new Promise((r) => {
29294
+ probeTimer = setTimeout(() => r(void 0), 8e3);
29295
+ })]).finally(() => {
29296
+ if (probeTimer !== null) clearTimeout(probeTimer);
29297
+ }) : void 0;
28345
29298
  const cat = deps.catalog().get();
28346
29299
  const bindings = Object.keys(deps.store().all()).length;
28347
29300
  const last = deps.lastRun();
@@ -28359,6 +29312,7 @@ async function renderStatus(deps) {
28359
29312
  "- catalog: " + cat.models.length + " models — " + cat.source + (cat.lastError === void 0 ? "" : " — last error: " + cat.lastError),
28360
29313
  "- conversation bindings: " + bindings,
28361
29314
  "- last run process: " + (last ? (last.processOk ? "ok" : last.processCode) + " — " + last.model + " in " + Math.round(last.durationMs / 100) / 10 + "s" : "none yet"),
29315
+ ...last?.tokenSpeed !== void 0 && (last.tokenSpeed.measuredSteps > 0 || last.tokenSpeed.outputTokens > 0) ? ["- last run tokens: " + last.tokenSpeed.outputTokens + " output tokens" + (last.tokenSpeed.avgTps !== null ? " — avg " + last.tokenSpeed.avgTps.toFixed(1) + " tok/s" : "") + (last.tokenSpeed.lastTps !== null ? " (last generation step: " + last.tokenSpeed.lastTps.toFixed(1) + " tok/s)" : "")] : [],
28362
29316
  ...last && last.toolErrors.length > 0 ? [
28363
29317
  "- last run tool errors (" + last.toolErrors.length + "): " + last.toolErrors.join(" | "),
28364
29318
  ...errorKinds.includes("missing_file") ? ["- Guidance: `missing_file` — verify the expected workspace/artifact path for this conversation; do not invent paths or bypass protections."] : [],
@@ -28380,50 +29334,6 @@ function err(text) {
28380
29334
  };
28381
29335
  }
28382
29336
  //#endregion
28383
- //#region src/host/diagnostics.ts
28384
- /** Redact auth URLs, long tokens, and authorization codes. */
28385
- function redactLine(line) {
28386
- let out = line;
28387
- out = out.replace(/https:\/\/accounts\.google\.com\/\S+/g, "<auth-url-redacted>");
28388
- out = out.replace(/\b[0-9]{4,}\//g, "4/<code-redacted>");
28389
- out = out.replace(/ya29\.[A-Za-z0-9._-]+/g, "<oauth-token-redacted>");
28390
- out = out.replace(/Bearer\s+\S+/gi, "Bearer <redacted>");
28391
- return out;
28392
- }
28393
- function writeDoctorReport(deps) {
28394
- const cfg = deps.cfg();
28395
- const cat = deps.catalog().get();
28396
- const bindings = deps.store().all();
28397
- const lines = [];
28398
- lines.push("# dsh-agy-link diagnostic report");
28399
- lines.push("");
28400
- lines.push("- generated: " + (/* @__PURE__ */ new Date()).toISOString());
28401
- lines.push("- agy binary: " + (deps.bin() ?? "NOT FOUND"));
28402
- lines.push("- agy version: " + (deps.version() ?? "unknown"));
28403
- lines.push("- plugin config: " + JSON.stringify({
28404
- ...cfg,
28405
- extraArgs: cfg.extraArgs.length
28406
- }));
28407
- lines.push("- catalog: " + cat.source + " — " + cat.models.length + " models" + (cat.lastError === void 0 ? "" : " — error: " + cat.lastError));
28408
- for (const m of cat.models) lines.push(" - " + m.id + (m.efforts ? " [" + m.efforts.join("/") + "]" : ""));
28409
- lines.push("- conversation bindings: " + Object.keys(bindings).length);
28410
- for (const [k, b] of Object.entries(bindings)) lines.push(" - " + k + " -> " + b.conversationId + " @ " + new Date(b.updatedAt).toISOString());
28411
- lines.push("- node: " + process.version + " — " + process.platform + " " + process.arch);
28412
- lines.push("");
28413
- lines.push("## last stream-json stdout (redacted, tail)");
28414
- lines.push("");
28415
- lines.push("```");
28416
- const recent = deps.recentLines();
28417
- for (const l of recent.slice(-400)) lines.push(redactLine(l));
28418
- lines.push("```");
28419
- lines.push("");
28420
- const dir = join(stateDir(), "diagnostics");
28421
- mkdirSync(dir, { recursive: true });
28422
- const file = join(dir, "doctor-" + (/* @__PURE__ */ new Date()).toISOString().replace(/[^0-9]/g, "").slice(0, 14) + ".md");
28423
- writeFileSync(file, lines.join("\n"), "utf8");
28424
- return file;
28425
- }
28426
- //#endregion
28427
29337
  //#region src/host/pool.ts
28428
29338
  function defaultPoolDir() {
28429
29339
  const dshState = process.env.DSH_STATE_DIR || join(homedir(), ".dsh");
@@ -28433,12 +29343,23 @@ var AccountPoolManager = class {
28433
29343
  data;
28434
29344
  baseDir;
28435
29345
  file;
29346
+ /**
29347
+ * Account ids this process has seen (loaded from disk + created here). On
29348
+ * persist, disk ids OUTSIDE this set were added by another host process and
29349
+ * are preserved instead of being clobbered (same merge contract as
29350
+ * sessions.json: web + headless share this file).
29351
+ */
29352
+ knownIds = /* @__PURE__ */ new Set();
29353
+ /** Serialized snapshot of the last successful persist/load; skips no-op writes. */
29354
+ persistedJson = "";
28436
29355
  constructor(baseDir = defaultPoolDir()) {
28437
29356
  this.baseDir = baseDir;
28438
29357
  this.file = join(baseDir, "pool.json");
28439
29358
  this.data = this.load();
28440
29359
  this.bootstrapDefaultAccount();
28441
29360
  this.normalizeLegacyPrimary();
29361
+ this.persistedJson = JSON.stringify(this.data);
29362
+ for (const acc of this.data.accounts) this.knownIds.add(acc.id);
28442
29363
  }
28443
29364
  load() {
28444
29365
  try {
@@ -28446,7 +29367,11 @@ var AccountPoolManager = class {
28446
29367
  const raw = readFileSync(this.file, "utf8");
28447
29368
  const parsed = JSON.parse(raw);
28448
29369
  if (parsed && Array.isArray(parsed.accounts)) {
28449
- for (const acc of parsed.accounts) if (acc.cooldowns) {
29370
+ for (const acc of parsed.accounts) {
29371
+ if (!acc || typeof acc !== "object") continue;
29372
+ if (acc.cooldowns === void 0 || acc.cooldowns === null) acc.cooldowns = {};
29373
+ if (acc.quotas === void 0 || acc.quotas === null) acc.quotas = {};
29374
+ if (acc.enabled === void 0) acc.enabled = true;
28450
29375
  for (const [fam, cd] of Object.entries(acc.cooldowns)) if (cd && cd.reason === "agy run aborted by caller") delete acc.cooldowns[fam];
28451
29376
  }
28452
29377
  return {
@@ -28455,16 +29380,45 @@ var AccountPoolManager = class {
28455
29380
  };
28456
29381
  }
28457
29382
  }
28458
- } catch {}
29383
+ } catch {
29384
+ try {
29385
+ if (existsSync(this.file)) copyFileSync(this.file, this.file + ".corrupt-" + Date.now());
29386
+ } catch {}
29387
+ }
28459
29388
  return defaultPoolData();
28460
29389
  }
28461
29390
  persist() {
29391
+ const serialized = JSON.stringify(this.data);
29392
+ let merged = this.data;
29393
+ try {
29394
+ if (existsSync(this.file)) {
29395
+ const onDisk = JSON.parse(readFileSync(this.file, "utf8"));
29396
+ if (onDisk && Array.isArray(onDisk.accounts)) {
29397
+ const ours = new Set(this.data.accounts.map((a) => a.id));
29398
+ const foreign = onDisk.accounts.filter((a) => a && typeof a === "object" && a.id !== void 0 && !ours.has(a.id) && !this.knownIds.has(a.id));
29399
+ if (foreign.length > 0) {
29400
+ merged = {
29401
+ ...this.data,
29402
+ accounts: [...this.data.accounts, ...foreign]
29403
+ };
29404
+ for (const acc of foreign) this.knownIds.add(acc.id);
29405
+ }
29406
+ }
29407
+ }
29408
+ } catch {}
28462
29409
  try {
28463
29410
  mkdirSync(dirname(this.file), { recursive: true });
29411
+ if (serialized === this.persistedJson && merged === this.data) return;
28464
29412
  const tmp = join(dirname(this.file), ".pool.json.tmp");
28465
- writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
29413
+ writeFileSync(tmp, JSON.stringify(merged, null, 2), "utf8");
28466
29414
  renameSync(tmp, this.file);
28467
- } catch {}
29415
+ this.persistedJson = JSON.stringify(merged);
29416
+ if (merged !== this.data) this.data = merged;
29417
+ } catch (e) {
29418
+ try {
29419
+ console.error("[dsh-agy-link] pool persist failed: " + String(e));
29420
+ } catch {}
29421
+ }
28468
29422
  }
28469
29423
  /**
28470
29424
  * Bootstraps the primary account on first start.
@@ -28557,6 +29511,7 @@ var AccountPoolManager = class {
28557
29511
  quotas: {}
28558
29512
  };
28559
29513
  this.data.accounts.push(newAccount);
29514
+ this.knownIds.add(newAccount.id);
28560
29515
  this.persist();
28561
29516
  return newAccount;
28562
29517
  }
@@ -28634,6 +29589,7 @@ var AccountPoolManager = class {
28634
29589
  quotas: {}
28635
29590
  };
28636
29591
  this.data.accounts.push(newAccount);
29592
+ this.knownIds.add(newAccount.id);
28637
29593
  this.persist();
28638
29594
  return newAccount;
28639
29595
  }
@@ -28641,12 +29597,16 @@ var AccountPoolManager = class {
28641
29597
  const idx = this.data.accounts.findIndex((a) => a.id === id);
28642
29598
  if (idx === -1) return false;
28643
29599
  const [removed] = this.data.accounts.splice(idx, 1);
28644
- if (removed) try {
28645
- if (existsSync(removed.dir)) rmSync(removed.dir, {
28646
- recursive: true,
28647
- force: true
28648
- });
28649
- } catch {}
29600
+ if (removed) {
29601
+ const rel = removed.dir !== "" ? relative(this.baseDir, removed.dir) : "";
29602
+ const contained = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
29603
+ if (removed.dir && contained && !removed.systemHome && existsSync(removed.dir)) try {
29604
+ rmSync(removed.dir, {
29605
+ recursive: true,
29606
+ force: true
29607
+ });
29608
+ } catch {}
29609
+ }
28650
29610
  if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
28651
29611
  if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
28652
29612
  if (this.data.activeAccountIds) {
@@ -28768,13 +29728,13 @@ var AccountPoolManager = class {
28768
29728
  */
28769
29729
  isAccountEligible(acc, family, now = Date.now()) {
28770
29730
  if (!acc.enabled || acc.authRequired) return false;
28771
- const cd = acc.cooldowns[family];
29731
+ const cd = acc.cooldowns?.[family];
28772
29732
  if (cd && cd.cooldownUntil > now) return false;
28773
- const quota = acc.quotas[family];
29733
+ const quota = acc.quotas?.[family];
28774
29734
  if (quota && typeof quota.remainingFraction === "number" && quota.remainingFraction <= .02) {
28775
29735
  if (quota.resetTime) {
28776
29736
  const resetMs = Date.parse(quota.resetTime);
28777
- if (!Number.isNaN(resetMs) && resetMs > now) return false;
29737
+ if (Number.isNaN(resetMs) || resetMs > now) return false;
28778
29738
  }
28779
29739
  }
28780
29740
  if (quota && typeof quota.weeklyFraction === "number" && quota.weeklyFraction <= .01) {
@@ -28806,7 +29766,7 @@ var AccountPoolManager = class {
28806
29766
  * sooner of the 5h window reset and the weekly cap reset. NaN when unknown.
28807
29767
  */
28808
29768
  nextQuotaResetAt(acc, family) {
28809
- const q = acc.quotas[family];
29769
+ const q = acc.quotas?.[family];
28810
29770
  const stamps = [q?.resetTime, q?.weeklyResetTime].filter((v) => typeof v === "string" && v !== "").map((v) => Date.parse(v)).filter((t) => !Number.isNaN(t));
28811
29771
  return stamps.length === 0 ? NaN : Math.min(...stamps);
28812
29772
  }
@@ -28870,10 +29830,11 @@ var AccountPoolManager = class {
28870
29830
  };
28871
29831
  if (email) acc.email = email;
28872
29832
  const now = Date.now();
29833
+ const futureReset = (iso) => typeof iso === "string" && iso !== "" && !Number.isNaN(Date.parse(iso)) && Date.parse(iso) > now;
28873
29834
  for (const [famKey, info] of Object.entries(quotas)) {
28874
29835
  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);
29836
+ const isExhausted5h = typeof info.remainingFraction === "number" && info.remainingFraction <= .02 && !futureReset(info.resetTime);
29837
+ const isExhaustedWeekly = typeof info.weeklyFraction === "number" && info.weeklyFraction <= .01 && !futureReset(info.weeklyResetTime);
28877
29838
  if (isExhausted5h || isExhaustedWeekly) {
28878
29839
  if (this.data.activeAccountIds?.[famKey] === id || this.data.lastActiveAccountId === id) this.selectAccount(famKey);
28879
29840
  }
@@ -28886,6 +29847,7 @@ var AccountPoolManager = class {
28886
29847
  recordFailure(id, family, reason, serverResetTime) {
28887
29848
  const acc = this.getAccount(id);
28888
29849
  if (!acc) return;
29850
+ acc.cooldowns ??= {};
28889
29851
  const failures = (acc.cooldowns[family]?.consecutiveFailures ?? 0) + 1;
28890
29852
  let cooldownUntil;
28891
29853
  const parsedDuration = parseResetDurationMs(serverResetTime || reason);
@@ -28915,17 +29877,21 @@ var AccountPoolManager = class {
28915
29877
  delete acc.authError;
28916
29878
  delete acc.authMarkedAt;
28917
29879
  }
28918
- if (acc.cooldowns[family]) delete acc.cooldowns[family];
29880
+ if (acc.cooldowns?.[family]) delete acc.cooldowns[family];
28919
29881
  this.persist();
28920
29882
  }
28921
29883
  clearCooldown(id, family) {
28922
29884
  if (id) {
28923
29885
  const acc = this.getAccount(id);
28924
29886
  if (!acc) return;
29887
+ acc.cooldowns ??= {};
29888
+ if (family) delete acc.cooldowns[family];
29889
+ else acc.cooldowns = {};
29890
+ } else for (const acc of this.data.accounts) {
29891
+ acc.cooldowns ??= {};
28925
29892
  if (family) delete acc.cooldowns[family];
28926
29893
  else acc.cooldowns = {};
28927
- } else for (const acc of this.data.accounts) if (family) delete acc.cooldowns[family];
28928
- else acc.cooldowns = {};
29894
+ }
28929
29895
  this.persist();
28930
29896
  }
28931
29897
  /**
@@ -28973,13 +29939,42 @@ var AccountPoolManager = class {
28973
29939
  let earliest = null;
28974
29940
  for (const acc of this.data.accounts) {
28975
29941
  if (!acc.enabled) continue;
28976
- const cd = acc.cooldowns[family];
29942
+ const cd = acc.cooldowns?.[family];
28977
29943
  if (cd && cd.cooldownUntil > now) {
28978
29944
  if (earliest === null || cd.cooldownUntil < earliest) earliest = cd.cooldownUntil;
28979
29945
  }
28980
29946
  }
28981
29947
  return earliest !== null ? Math.max(0, earliest - now) : null;
28982
29948
  }
29949
+ /**
29950
+ * Earliest moment ANY enabled account could serve `family` again: the sooner
29951
+ * of a cooldown expiry and a quota-window reset.
29952
+ *
29953
+ * `getEarliestResetCountdown` only inspects cooldowns, while an account can
29954
+ * be excluded by its remaining 5h/weekly fraction (isAccountEligible) — so a
29955
+ * fully quota-drained pool reported NO wait at all ("Add an account or wait
29956
+ * for reset") even though every account knew exactly when it resets. Signed
29957
+ * out and disabled accounts are skipped: no countdown can fix them.
29958
+ */
29959
+ getEarliestAvailableCountdown(family) {
29960
+ const now = Date.now();
29961
+ let earliest = null;
29962
+ const bump = (at) => {
29963
+ if (at === null || Number.isNaN(at) || at <= now) return;
29964
+ if (earliest === null || at < earliest) earliest = at;
29965
+ };
29966
+ for (const acc of this.data.accounts) {
29967
+ if (!acc.enabled || acc.authRequired) continue;
29968
+ const cd = acc.cooldowns?.[family];
29969
+ if (cd && cd.cooldownUntil > now) bump(cd.cooldownUntil);
29970
+ const quota = acc.quotas?.[family];
29971
+ if (quota === void 0) continue;
29972
+ const drained5h = typeof quota.remainingFraction === "number" && quota.remainingFraction <= .02 && typeof quota.resetTime === "string" && Date.parse(quota.resetTime) > now;
29973
+ const drainedWeekly = typeof quota.weeklyFraction === "number" && quota.weeklyFraction <= .01 && typeof quota.weeklyResetTime === "string" && Date.parse(quota.weeklyResetTime) > now;
29974
+ if (drained5h || drainedWeekly) bump(this.nextQuotaResetAt(acc, family));
29975
+ }
29976
+ return earliest !== null ? Math.max(0, earliest - now) : null;
29977
+ }
28983
29978
  };
28984
29979
  //#endregion
28985
29980
  //#region src/host/pool-auth.ts
@@ -29010,9 +30005,12 @@ var PoolAuthFlow = class {
29010
30005
  ...this.statusValue,
29011
30006
  ...patch
29012
30007
  };
29013
- if (patch.phase === "done") this.doneResetTimer = setTimeout(() => {
29014
- if (this.statusValue.phase === "done") this.statusValue = { phase: "idle" };
29015
- }, DONE_STATUS_TTL_MS);
30008
+ if (patch.phase === "done") {
30009
+ this.doneResetTimer = setTimeout(() => {
30010
+ if (this.statusValue.phase === "done") this.statusValue = { phase: "idle" };
30011
+ }, DONE_STATUS_TTL_MS);
30012
+ this.doneResetTimer.unref?.();
30013
+ }
29016
30014
  }
29017
30015
  /**
29018
30016
  * Shared starter: PKCE + loopback listener + server-side browser open.
@@ -29054,7 +30052,7 @@ var PoolAuthFlow = class {
29054
30052
  code: browserOpened ? void 0 : "browser_manual"
29055
30053
  });
29056
30054
  this.log(`pool auth begun (${mode} mode, browserOpened=${browserOpened}${active.primary ? ", primary" : ""})`);
29057
- if (listener) listener.result.then(({ code, state: returnedState }) => this.finishWithCode(code, returnedState)).catch((err) => {
30055
+ if (listener) listener.result.then(({ code, state: returnedState }) => this.finishWithCode(code, returnedState, active)).catch((err) => {
29058
30056
  if (!this.flow || this.flow.listener !== listener) return;
29059
30057
  this.fail("callback_failed", err);
29060
30058
  });
@@ -29105,16 +30103,27 @@ var PoolAuthFlow = class {
29105
30103
  phase: "waiting",
29106
30104
  code: "invalid_state"
29107
30105
  };
29108
- await this.finishWithCode(parsed.code, parsed.state ?? flow.state);
30106
+ await this.finishWithCode(parsed.code, parsed.state ?? flow.state, flow);
29109
30107
  const after = this.status();
29110
30108
  return {
29111
30109
  ...after,
29112
30110
  ok: after.phase === "done"
29113
30111
  };
29114
30112
  }
29115
- async finishWithCode(code, returnedState) {
30113
+ /**
30114
+ * Settle the ACTIVE flow with an authorization code.
30115
+ *
30116
+ * `owner` names the flow the code belongs to. Without it, a STALE flow's
30117
+ * late callback (its `exchangeCode` still in flight while the user cancelled
30118
+ * and began flow B) settled against `this.flow` — flow B — and either killed
30119
+ * the new flow with `invalid_state` (deleting B's staging dir) or, worse,
30120
+ * wrote flow A's token file into the freshly cleaned A dir and committed a
30121
+ * ghost account. The identity check makes a stale settlement a no-op.
30122
+ */
30123
+ async finishWithCode(code, returnedState, owner) {
29116
30124
  const flow = this.flow;
29117
30125
  if (!flow || this.statusValue.phase !== "waiting") return;
30126
+ if (owner !== void 0 && owner !== flow) return;
29118
30127
  if (returnedState !== flow.state) {
29119
30128
  this.fail("invalid_state");
29120
30129
  return;
@@ -29269,30 +30278,40 @@ function parseGoKeyringPayload(raw) {
29269
30278
  }
29270
30279
  }
29271
30280
  /**
30281
+ * Promisified execFile with a hard timeout. The keyring readers used to run
30282
+ * execFileSync, so a locked Keychain prompt or a slow PowerShell cold start
30283
+ * (up to 8s) froze the WHOLE DSH event loop on every quota poll — the service
30284
+ * runs inside the host process. Async with a timeout keeps the host live.
30285
+ */
30286
+ function execFileT(file, args, timeoutMs) {
30287
+ return new Promise((resolve, reject) => {
30288
+ execFile(file, [...args], {
30289
+ encoding: "utf8",
30290
+ timeout: timeoutMs,
30291
+ windowsHide: true
30292
+ }, (err, stdout) => {
30293
+ if (err !== null && err !== void 0) reject(err);
30294
+ else resolve(typeof stdout === "string" ? stdout : String(stdout));
30295
+ });
30296
+ });
30297
+ }
30298
+ /**
29272
30299
  * Reads the active primary Antigravity OAuth token from the Linux Secret
29273
30300
  * Service (GNOME Keyring / KDE Wallet via FreeDesktop secrets). agy 1.1.15+
29274
30301
  * on Linux stores primary credentials via go-keyring under service "gemini"
29275
30302
  * / username "antigravity" — the same JSON shape as the macOS Keychain.
29276
30303
  * GH #8: without this reader, Linux quota resolution always returned null.
29277
30304
  */
29278
- function readLinuxSecretToken() {
30305
+ async function readLinuxSecretToken() {
29279
30306
  if (process.platform !== "linux") return null;
29280
30307
  try {
29281
- const raw = execFileSync("secret-tool", [
30308
+ const raw = (await execFileT("secret-tool", [
29282
30309
  "lookup",
29283
30310
  "service",
29284
30311
  "gemini",
29285
30312
  "username",
29286
30313
  "antigravity"
29287
- ], {
29288
- encoding: "utf8",
29289
- timeout: 3e3,
29290
- stdio: [
29291
- "ignore",
29292
- "pipe",
29293
- "ignore"
29294
- ]
29295
- }).trim();
30314
+ ], 3e3)).trim();
29296
30315
  if (raw) {
29297
30316
  const tok = parseGoKeyringPayload(raw);
29298
30317
  if (tok) return tok;
@@ -29314,15 +30333,7 @@ function readLinuxSecretToken() {
29314
30333
  " pass"
29315
30334
  ].join("\n");
29316
30335
  for (const pyBin of ["python3", "python"]) try {
29317
- const raw = execFileSync(pyBin, ["-c", pyScript], {
29318
- encoding: "utf8",
29319
- timeout: 4e3,
29320
- stdio: [
29321
- "ignore",
29322
- "pipe",
29323
- "ignore"
29324
- ]
29325
- }).trim();
30336
+ const raw = (await execFileT(pyBin, ["-c", pyScript], 4e3)).trim();
29326
30337
  if (raw) {
29327
30338
  const tok = parseGoKeyringPayload(raw);
29328
30339
  if (tok) return tok;
@@ -29335,25 +30346,17 @@ function readLinuxSecretToken() {
29335
30346
  * agy 1.1.15+ on macOS stores primary credentials via go-keyring in the Keychain
29336
30347
  * under service "gemini" / account "antigravity" (base64-encoded JSON).
29337
30348
  */
29338
- function readMacKeychainToken() {
30349
+ async function readMacKeychainToken() {
29339
30350
  if (process.platform !== "darwin") return null;
29340
30351
  try {
29341
- const raw = execFileSync("security", [
30352
+ const raw = (await execFileT("security", [
29342
30353
  "find-generic-password",
29343
30354
  "-s",
29344
30355
  "gemini",
29345
30356
  "-a",
29346
30357
  "antigravity",
29347
30358
  "-w"
29348
- ], {
29349
- encoding: "utf8",
29350
- timeout: 3e3,
29351
- stdio: [
29352
- "ignore",
29353
- "pipe",
29354
- "ignore"
29355
- ]
29356
- }).trim();
30359
+ ], 3e3)).trim();
29357
30360
  if (!raw) return null;
29358
30361
  return parseGoKeyringPayload(raw);
29359
30362
  } catch {
@@ -29368,58 +30371,48 @@ function readMacKeychainToken() {
29368
30371
  * empty on Windows: the on-disk `antigravity-oauth-token` file is often
29369
30372
  * absent while agy itself still authenticates via CredRead (issue #30).
29370
30373
  */
29371
- function readWindowsCredentialToken() {
30374
+ async function readWindowsCredentialToken() {
29372
30375
  if (process.platform !== "win32") return null;
29373
30376
  try {
29374
- const script = [
29375
- "$sig = @'",
29376
- "using System;",
29377
- "using System.Runtime.InteropServices;",
29378
- "public class AgyCred {",
29379
- " [DllImport(\"advapi32.dll\", SetLastError=true, CharSet=CharSet.Unicode)]",
29380
- " public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);",
29381
- " [DllImport(\"advapi32.dll\", SetLastError=true)]",
29382
- " public static extern void CredFree(IntPtr cred);",
29383
- " [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]",
29384
- " public struct CREDENTIAL {",
29385
- " public int Flags; public int Type; public IntPtr TargetName;",
29386
- " public IntPtr Comment; public long LastWritten; public int CredentialBlobSize;",
29387
- " public IntPtr CredentialBlob; public int Persist; public int AttributeCount;",
29388
- " public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;",
29389
- " }",
29390
- " public static byte[] Read(string target) {",
29391
- " IntPtr p;",
29392
- " if (!CredRead(target, 1, 0, out p)) return null;",
29393
- " CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));",
29394
- " byte[] b = new byte[c.CredentialBlobSize];",
29395
- " Marshal.Copy(c.CredentialBlob, b, 0, c.CredentialBlobSize);",
29396
- " CredFree(p);",
29397
- " return b;",
29398
- " }",
29399
- "}",
29400
- "'@",
29401
- "Add-Type -TypeDefinition $sig -ErrorAction Stop",
29402
- "$b = [AgyCred]::Read('gemini:antigravity')",
29403
- "if ($b -eq $null) { exit 1 }",
29404
- "[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($b))"
29405
- ].join("\n");
29406
- const raw = execFileSync("powershell.exe", [
30377
+ const raw = (await execFileT("powershell.exe", [
29407
30378
  "-NoProfile",
29408
30379
  "-NonInteractive",
29409
30380
  "-ExecutionPolicy",
29410
30381
  "Bypass",
29411
30382
  "-Command",
29412
- script
29413
- ], {
29414
- encoding: "utf8",
29415
- timeout: 8e3,
29416
- stdio: [
29417
- "ignore",
29418
- "pipe",
29419
- "ignore"
29420
- ],
29421
- windowsHide: true
29422
- }).trim();
30383
+ [
30384
+ "$sig = @'",
30385
+ "using System;",
30386
+ "using System.Runtime.InteropServices;",
30387
+ "public class AgyCred {",
30388
+ " [DllImport(\"advapi32.dll\", SetLastError=true, CharSet=CharSet.Unicode)]",
30389
+ " public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);",
30390
+ " [DllImport(\"advapi32.dll\", SetLastError=true)]",
30391
+ " public static extern void CredFree(IntPtr cred);",
30392
+ " [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]",
30393
+ " public struct CREDENTIAL {",
30394
+ " public int Flags; public int Type; public IntPtr TargetName;",
30395
+ " public IntPtr Comment; public long LastWritten; public int CredentialBlobSize;",
30396
+ " public IntPtr CredentialBlob; public int Persist; public int AttributeCount;",
30397
+ " public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;",
30398
+ " }",
30399
+ " public static byte[] Read(string target) {",
30400
+ " IntPtr p;",
30401
+ " if (!CredRead(target, 1, 0, out p)) return null;",
30402
+ " CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));",
30403
+ " byte[] b = new byte[c.CredentialBlobSize];",
30404
+ " Marshal.Copy(c.CredentialBlob, b, 0, c.CredentialBlobSize);",
30405
+ " CredFree(p);",
30406
+ " return b;",
30407
+ " }",
30408
+ "}",
30409
+ "'@",
30410
+ "Add-Type -TypeDefinition $sig -ErrorAction Stop",
30411
+ "$b = [AgyCred]::Read('gemini:antigravity')",
30412
+ "if ($b -eq $null) { exit 1 }",
30413
+ "[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($b))"
30414
+ ].join("\n")
30415
+ ], 8e3)).trim();
29423
30416
  if (!raw) return null;
29424
30417
  return parseGoKeyringPayload(raw);
29425
30418
  } catch {
@@ -29430,6 +30423,8 @@ var QuotaService = class {
29430
30423
  pool;
29431
30424
  globalProxy;
29432
30425
  preferredEndpointIndex = 0;
30426
+ /** accountId -> in-flight getValidAccessToken attempt (dedup). */
30427
+ tokenRefreshInFlight = /* @__PURE__ */ new Map();
29433
30428
  /**
29434
30429
  * @param globalProxy resolves the plugin-wide proxy URL; used when the
29435
30430
  * account has no dedicated proxyUrl. Injected so tests stay network-free.
@@ -29450,7 +30445,7 @@ var QuotaService = class {
29450
30445
  * Read the system-HOME Keychain credential. Protected so tests (and future
29451
30446
  * platforms) can substitute the reader without touching the real Keychain.
29452
30447
  */
29453
- readSystemKeychainToken() {
30448
+ async readSystemKeychainToken() {
29454
30449
  if (process.platform === "darwin") return readMacKeychainToken();
29455
30450
  if (process.platform === "linux") return readLinuxSecretToken();
29456
30451
  if (process.platform === "win32") return readWindowsCredentialToken();
@@ -29468,7 +30463,7 @@ var QuotaService = class {
29468
30463
  * WRONG account's numbers). Isolated pool accounts only ever read their
29469
30464
  * own directory's file; the Keychain is one shared slot they must not see.
29470
30465
  */
29471
- getStoredToken(account) {
30466
+ async getStoredToken(account) {
29472
30467
  const disk = (() => {
29473
30468
  const file = this.getTokenFilePath(account);
29474
30469
  if (!existsSync(file)) return null;
@@ -29479,7 +30474,7 @@ var QuotaService = class {
29479
30474
  return null;
29480
30475
  })();
29481
30476
  if (account.systemHome || !account.dir) {
29482
- const keychainToken = this.readSystemKeychainToken();
30477
+ const keychainToken = await this.readSystemKeychainToken();
29483
30478
  if (keychainToken && (keychainToken.accessToken || keychainToken.refreshToken)) return keychainToken;
29484
30479
  }
29485
30480
  return disk;
@@ -29504,16 +30499,33 @@ var QuotaService = class {
29504
30499
  raw.access_token = tokens.access_token;
29505
30500
  if (tokens.expiryMs) raw.expiry = tokens.expiryMs;
29506
30501
  }
29507
- writeFileSync(file, JSON.stringify(raw), "utf8");
30502
+ writeFileSync(file, JSON.stringify(raw), {
30503
+ encoding: "utf8",
30504
+ mode: 384
30505
+ });
29508
30506
  } catch {}
29509
30507
  }
29510
30508
  /**
29511
30509
  * Get a valid access token, refreshing via refresh_token when expired.
29512
30510
  * Uses the public Antigravity client credentials (env-overridable), so
29513
30511
  * refresh works out of the box.
30512
+ *
30513
+ * Concurrent calls for the SAME account share one in-flight attempt: the
30514
+ * poll timer, three manual HTTP entry points and per-account allSettled
30515
+ * used to race into duplicate refreshTokens for one account (double
30516
+ * risk-control exposure for the same outcome).
29514
30517
  */
29515
30518
  async getValidAccessToken(account) {
29516
- const tok = this.getStoredToken(account);
30519
+ const existing = this.tokenRefreshInFlight.get(account.id);
30520
+ if (existing !== void 0) return existing;
30521
+ const attempt = this.getValidAccessTokenUncached(account).finally(() => {
30522
+ this.tokenRefreshInFlight.delete(account.id);
30523
+ });
30524
+ this.tokenRefreshInFlight.set(account.id, attempt);
30525
+ return attempt;
30526
+ }
30527
+ async getValidAccessTokenUncached(account) {
30528
+ const tok = await this.getStoredToken(account);
29517
30529
  if (!tok) return null;
29518
30530
  if (tok.accessToken && (!tok.expiryMs || tok.expiryMs > Date.now() + 6e4)) return tok.accessToken;
29519
30531
  if (tok.refreshToken) try {
@@ -29648,11 +30660,12 @@ var QuotaService = class {
29648
30660
  let weeklyReset;
29649
30661
  for (const b of group.buckets || []) {
29650
30662
  const w = (b.window || b.bucketId || "").toLowerCase();
29651
- if (w.includes("5h")) {
29652
- fiveHourFrac = b.remainingFraction;
30663
+ const frac = typeof b.remainingFraction === "number" && Number.isFinite(b.remainingFraction) ? Math.min(1, Math.max(0, b.remainingFraction)) : void 0;
30664
+ if (w.includes("5h") || w.includes("five_hour") || w.includes("5-hour")) {
30665
+ fiveHourFrac = frac;
29653
30666
  fiveHourReset = b.resetTime;
29654
- } else if (w.includes("weekly")) {
29655
- weeklyFrac = b.remainingFraction;
30667
+ } else if (w.includes("weekly") || w.includes("week")) {
30668
+ weeklyFrac = frac;
29656
30669
  weeklyReset = b.resetTime;
29657
30670
  }
29658
30671
  }
@@ -29669,9 +30682,10 @@ var QuotaService = class {
29669
30682
  if (discovered && discovered.models) for (const [modelId, entry] of Object.entries(discovered.models)) {
29670
30683
  const fam = modelFamilyOf(modelId);
29671
30684
  if (fam === "unknown") continue;
29672
- const remaining = entry.quotaInfo?.remainingFraction;
30685
+ const rawRemaining = entry.quotaInfo?.remainingFraction;
30686
+ const remaining = typeof rawRemaining === "number" && Number.isFinite(rawRemaining) ? Math.min(1, Math.max(0, rawRemaining)) : void 0;
29673
30687
  const resetTime = entry.quotaInfo?.resetTime;
29674
- if (typeof remaining !== "number" || !Number.isFinite(remaining)) continue;
30688
+ if (remaining === void 0) continue;
29675
30689
  if (!familyModels[fam]) familyModels[fam] = [];
29676
30690
  familyModels[fam].push({
29677
30691
  modelId,
@@ -29719,7 +30733,7 @@ var QuotaService = class {
29719
30733
  if (!force) {
29720
30734
  const now = Date.now();
29721
30735
  for (const acc of accounts) {
29722
- const flagged = acc.authRequired || Object.values(acc.cooldowns).some((cd) => cd && cd.cooldownUntil > now);
30736
+ const flagged = acc.authRequired || Object.values(acc.cooldowns ?? {}).some((cd) => cd && cd.cooldownUntil > now);
29723
30737
  if (!acc.systemHome || !flagged) continue;
29724
30738
  const detected = detectEmailFromAgyLogs(acc.systemHome || !acc.dir ? homedir() : acc.dir);
29725
30739
  if (detected && detected !== acc.email) this.pool.resetAccountIdentity(acc.id, detected);
@@ -29904,15 +30918,39 @@ function writeMcpConfig(workspaceRoot, bridge) {
29904
30918
  }
29905
30919
  };
29906
30920
  root.mcpServers = servers;
30921
+ let written = false;
29907
30922
  try {
29908
30923
  writeFileSync(file, JSON.stringify(root, null, 2) + "\n", "utf8");
30924
+ written = true;
29909
30925
  } catch {
29910
30926
  return () => {};
29911
30927
  }
29912
30928
  return () => {
29913
30929
  try {
30930
+ if (!written) return;
30931
+ let current = null;
30932
+ try {
30933
+ if (existsSync(file)) current = readFileSync(file, "utf8");
30934
+ } catch {
30935
+ current = null;
30936
+ }
30937
+ if (current === null) return;
30938
+ let parsed = {};
30939
+ try {
30940
+ const v = JSON.parse(current);
30941
+ if (v && typeof v === "object") parsed = v;
30942
+ } catch {
30943
+ return;
30944
+ }
30945
+ const curServers = parsed.mcpServers && typeof parsed.mcpServers === "object" ? parsed.mcpServers : {};
30946
+ if (!(MCP_SERVER_KEY in curServers)) return;
29914
30947
  if (previous === null) {
29915
- if (existsSync(file)) unlinkSync(file);
30948
+ delete curServers[MCP_SERVER_KEY];
30949
+ if (Object.keys(curServers).length === 0) unlinkSync(file);
30950
+ else writeFileSync(file, JSON.stringify({
30951
+ ...parsed,
30952
+ mcpServers: curServers
30953
+ }, null, 2) + "\n", "utf8");
29916
30954
  } else writeFileSync(file, previous, "utf8");
29917
30955
  } catch {}
29918
30956
  };
@@ -29958,6 +30996,11 @@ function apply(ctx, entryConfig = {}) {
29958
30996
  let lastRun = null;
29959
30997
  let lastParser = new StreamJsonParser();
29960
30998
  const getConfig = () => resolveConfig(entryConfig);
30999
+ const notifyPicker = () => {
31000
+ try {
31001
+ ctx.emit("llm/adapters-updated");
31002
+ } catch {}
31003
+ };
29961
31004
  const bin = () => {
29962
31005
  if (binCache === void 0 || binCache === null) binCache = resolveAgyBin(getConfig());
29963
31006
  return binCache;
@@ -30085,6 +31128,7 @@ function apply(ctx, entryConfig = {}) {
30085
31128
  log("version probe failed — continuing with fallback catalog");
30086
31129
  }
30087
31130
  await catalog.refreshIfNeeded().catch(() => void 0);
31131
+ notifyPicker();
30088
31132
  })();
30089
31133
  }, 4e3);
30090
31134
  bootProbeTimer.unref?.();
@@ -30121,6 +31165,7 @@ function apply(ctx, entryConfig = {}) {
30121
31165
  const toolsSvcRef = { current: null };
30122
31166
  const askToolDispose = { current: null };
30123
31167
  const mirrorToolDispose = { current: null };
31168
+ const preExecuteDispose = { current: null };
30124
31169
  const syncAskTool = () => {
30125
31170
  const want = getConfig().askTool && bin() !== null;
30126
31171
  if (want && askToolDispose.current === null && toolsSvcRef.current) {
@@ -30150,7 +31195,17 @@ function apply(ctx, entryConfig = {}) {
30150
31195
  toolsSvcRef.current = sub.get("tools");
30151
31196
  syncAskTool();
30152
31197
  syncMirrorTool();
31198
+ const unlisten = sub.on("tools/pre-execute", async (exec, next) => {
31199
+ if (exec.name === "agy_tool") return { kind: "allow" };
31200
+ return next();
31201
+ }, {
31202
+ prepend: true,
31203
+ global: true
31204
+ });
31205
+ preExecuteDispose.current = typeof unlisten === "function" ? unlisten : null;
30153
31206
  return () => {
31207
+ if (preExecuteDispose.current !== null) preExecuteDispose.current();
31208
+ preExecuteDispose.current = null;
30154
31209
  if (askToolDispose.current !== null) askToolDispose.current();
30155
31210
  askToolDispose.current = null;
30156
31211
  if (mirrorToolDispose.current !== null) mirrorToolDispose.current();
@@ -30213,7 +31268,10 @@ function apply(ctx, entryConfig = {}) {
30213
31268
  count: cat.models.length,
30214
31269
  lastError: cat.lastError ?? null
30215
31270
  },
31271
+ catalogModels: sanitizeCatalogEntries(cat.models).entries,
31272
+ hiddenModels: cfg.hiddenModels,
30216
31273
  bindings: Object.keys(store.all()).length,
31274
+ credentialIsolationLimited: process.platform === "win32" && pool.getAccounts().some((a) => a.enabled && !a.systemHome),
30217
31275
  lastRun
30218
31276
  });
30219
31277
  })();
@@ -30259,7 +31317,7 @@ function apply(ctx, entryConfig = {}) {
30259
31317
  if (acc) quota.refreshAccountQuota(acc).catch(() => void 0);
30260
31318
  } else quota.refreshAllQuotas().catch(() => void 0);
30261
31319
  store.clear();
30262
- catalog.forceRefresh().catch(() => void 0);
31320
+ catalog.forceRefresh().then(() => notifyPicker()).catch(() => void 0);
30263
31321
  }
30264
31322
  sendJson(res, 200, st);
30265
31323
  })();
@@ -30388,6 +31446,7 @@ function apply(ctx, entryConfig = {}) {
30388
31446
  const body = await readBody(req);
30389
31447
  const alias = typeof body.alias === "string" ? body.alias : void 0;
30390
31448
  const acc = pool.createAccountSlot(alias);
31449
+ pool.markAuthRequired(acc.id, "not signed in yet — complete the terminal login");
30391
31450
  if (body.autoOpenTerminal !== false && acc.dir) {
30392
31451
  if (process.platform === "darwin") {
30393
31452
  const script = `tell application "Terminal" to activate\ntell application "Terminal" to do script "export HOME='${acc.dir}'; agy"`;
@@ -30534,7 +31593,7 @@ function apply(ctx, entryConfig = {}) {
30534
31593
  const acc = pool.getAccount(id);
30535
31594
  if (acc) await quota.refreshAccountQuota(acc, true);
30536
31595
  } else await quota.refreshAllQuotas(true);
30537
- catalog.forceRefresh().catch(() => void 0);
31596
+ catalog.forceRefresh().then(() => notifyPicker()).catch(() => void 0);
30538
31597
  sendJson(res, 200, {
30539
31598
  ok: true,
30540
31599
  pool: pool.getPoolData()
@@ -30578,14 +31637,20 @@ function apply(ctx, entryConfig = {}) {
30578
31637
  "defaultModel",
30579
31638
  "defaultEffort",
30580
31639
  "askTool",
30581
- "workspaceRoot"
31640
+ "workspaceRoot",
31641
+ "hiddenModels"
30582
31642
  ].includes(key)) {
30583
31643
  sendJson(res, 400, { error: "key not settable" });
30584
31644
  return;
30585
31645
  }
31646
+ if (key === "hiddenModels" && (!Array.isArray(body.value) || !body.value.every((x) => typeof x === "string"))) {
31647
+ sendJson(res, 400, { error: "hiddenModels must be an array of model ids" });
31648
+ return;
31649
+ }
30586
31650
  setOverride(key, body.value);
30587
31651
  syncAskTool();
30588
31652
  syncMirrorTool();
31653
+ if (key === "hiddenModels") notifyPicker();
30589
31654
  sendJson(res, 200, {
30590
31655
  ok: true,
30591
31656
  key,