dsh-agy-link 0.4.44 → 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/CHANGELOG.md +92 -0
- package/README.md +1 -0
- package/dist/client.js +465 -132
- package/dist/index.js +1321 -327
- package/docs/KNOWN-GAPS.md +16 -0
- package/docs/README.en.md +1 -0
- package/docs/README.es.md +1 -0
- package/docs/README.pt-BR.md +1 -0
- package/docs/adr-012-account-pool.md +6 -2
- package/package.json +1 -1
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,
|
|
@@ -118,9 +119,19 @@ function defaultConfig() {
|
|
|
118
119
|
function isAuthoritativeAuthError(message) {
|
|
119
120
|
return /invalid_grant|invalid_client|revoked|disabled|unauthorized_client/i.test(message);
|
|
120
121
|
}
|
|
121
|
-
/**
|
|
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
|
+
*/
|
|
122
133
|
function looksLikeNetworkError(message) {
|
|
123
|
-
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);
|
|
124
135
|
}
|
|
125
136
|
/**
|
|
126
137
|
* Decide whether a failed call may flag an account as "needs re-login".
|
|
@@ -154,6 +165,21 @@ const Err = {
|
|
|
154
165
|
TIMEOUT: "TIMEOUT",
|
|
155
166
|
PROCESS_EXIT: "PROCESS_EXIT",
|
|
156
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",
|
|
157
183
|
UNKNOWN_MODEL: "UNKNOWN_MODEL",
|
|
158
184
|
UNSUPPORTED_REASONING_EFFORT: "UNSUPPORTED_REASONING_EFFORT",
|
|
159
185
|
AUX_DISABLED: "AUX_DISABLED",
|
|
@@ -187,15 +213,29 @@ function extractAuthUrl(text) {
|
|
|
187
213
|
*/
|
|
188
214
|
function looksLikeHardRateLimit(text) {
|
|
189
215
|
if (!text) return false;
|
|
190
|
-
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";
|
|
191
229
|
}
|
|
192
230
|
/**
|
|
193
231
|
* Soft heuristic adds capacity signals (model overloaded / high traffic).
|
|
194
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.
|
|
195
235
|
*/
|
|
196
236
|
function looksLikeRateLimit(text) {
|
|
197
237
|
if (!text) return false;
|
|
198
|
-
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);
|
|
199
239
|
}
|
|
200
240
|
/**
|
|
201
241
|
* Parse reset duration in milliseconds from rate-limit / quota-exhausted error strings.
|
|
@@ -298,6 +338,7 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
|
|
|
298
338
|
defaultModel: asString(get("defaultModel")) ?? base.defaultModel,
|
|
299
339
|
defaultEffort: asString(get("defaultEffort")) ?? base.defaultEffort,
|
|
300
340
|
timeoutMs: asNum(get("timeoutMs")) ?? base.timeoutMs,
|
|
341
|
+
longToolIdleMinutes: asNum(get("longToolIdleMinutes")) ?? base.longToolIdleMinutes,
|
|
301
342
|
maxConcurrent: asNum(get("maxConcurrent")) ?? base.maxConcurrent,
|
|
302
343
|
contextWindowDefault: asNum(get("contextWindowDefault")) ?? base.contextWindowDefault,
|
|
303
344
|
maxTokensDefault: asNum(get("maxTokensDefault")) ?? base.maxTokensDefault,
|
|
@@ -340,6 +381,10 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
|
|
|
340
381
|
const t = asNum(env.DSH_AGY_TIMEOUT_MS);
|
|
341
382
|
if (t && t > 0) cfg.timeoutMs = t;
|
|
342
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
|
+
}
|
|
343
388
|
if (env.DSH_AGY_EXTRA_ARGS) cfg.extraArgs = env.DSH_AGY_EXTRA_ARGS.split(/\s+/).filter(Boolean);
|
|
344
389
|
if (env.DSH_AGY_HIDDEN_MODELS) {
|
|
345
390
|
const list = asStringArray(env.DSH_AGY_HIDDEN_MODELS.split(","));
|
|
@@ -401,7 +446,7 @@ function shouldPollAccount(account) {
|
|
|
401
446
|
if (!account.enabled) return false;
|
|
402
447
|
if (account.authRequired) return false;
|
|
403
448
|
const now = Date.now();
|
|
404
|
-
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;
|
|
405
450
|
return true;
|
|
406
451
|
}
|
|
407
452
|
/**
|
|
@@ -435,6 +480,17 @@ function defaultPoolData() {
|
|
|
435
480
|
function defaultConversationsDir() {
|
|
436
481
|
return process.env.DSH_AGY_CONVERSATIONS_DIR ?? join(homedir(), ".gemini", "antigravity-cli", "conversations");
|
|
437
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
|
+
}
|
|
438
494
|
function snapshotConversations(dir = defaultConversationsDir()) {
|
|
439
495
|
const files = /* @__PURE__ */ new Map();
|
|
440
496
|
try {
|
|
@@ -481,10 +537,19 @@ const waiters = Symbol("waiters");
|
|
|
481
537
|
var RunRecording = class {
|
|
482
538
|
runId;
|
|
483
539
|
events = [];
|
|
540
|
+
/** Arrival timestamp for every recorded event (same index as `events`). */
|
|
541
|
+
eventTimes = [];
|
|
484
542
|
settled = false;
|
|
485
543
|
failure = null;
|
|
486
544
|
resultConversationId = null;
|
|
487
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();
|
|
488
553
|
[waiters] = /* @__PURE__ */ new Set();
|
|
489
554
|
/** DB-resolved full tool args keyed by event index (set during span driving). */
|
|
490
555
|
fullArgs = null;
|
|
@@ -493,6 +558,16 @@ var RunRecording = class {
|
|
|
493
558
|
/** Isolated pool-account HOME used for this run's agy conversation DB. */
|
|
494
559
|
accountHome = void 0;
|
|
495
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
|
+
/**
|
|
496
571
|
* Set by the adapter right after spawn. A mid-turn user steer makes DSH
|
|
497
572
|
* open a NEW stream() call for the same session while this run's process
|
|
498
573
|
* is still alive; without aborting it, two agy processes would append to
|
|
@@ -506,11 +581,66 @@ var RunRecording = class {
|
|
|
506
581
|
append(ev) {
|
|
507
582
|
if (this.settled) return;
|
|
508
583
|
this.events.push(ev);
|
|
584
|
+
const now = Date.now();
|
|
585
|
+
this.eventTimes.push(now);
|
|
509
586
|
if (ev.kind === "result") this.resultConversationId = ev.conversationId ?? null;
|
|
510
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
|
+
}
|
|
511
603
|
this.wake();
|
|
512
604
|
}
|
|
513
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
|
+
/**
|
|
514
644
|
* Usage accounting (verified against agy 1.1.16 stream-json): step_update
|
|
515
645
|
* usage is PER-CALL (the current context size at that internal LLM call),
|
|
516
646
|
* while the result envelope's usage is CUMULATIVE across the whole agy
|
|
@@ -661,28 +791,92 @@ function parseMirrorCallId(callId) {
|
|
|
661
791
|
};
|
|
662
792
|
}
|
|
663
793
|
const MAX_RETAINED_RUNS = 8;
|
|
664
|
-
/**
|
|
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
|
+
*/
|
|
665
805
|
var RunRegistry = class {
|
|
666
806
|
runs = /* @__PURE__ */ new Map();
|
|
807
|
+
/** runId -> pin count while a span is actively driving that recording. */
|
|
808
|
+
pinned = /* @__PURE__ */ new Map();
|
|
667
809
|
create() {
|
|
668
810
|
const rec = new RunRecording();
|
|
669
811
|
this.remember(rec);
|
|
670
812
|
return rec;
|
|
671
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
|
+
*/
|
|
672
819
|
remember(rec) {
|
|
820
|
+
if (this.runs.has(rec.runId)) this.runs.delete(rec.runId);
|
|
673
821
|
this.runs.set(rec.runId, rec);
|
|
674
|
-
|
|
675
|
-
const oldest = this.runs.keys().next().value;
|
|
676
|
-
if (oldest === void 0) break;
|
|
677
|
-
this.runs.delete(oldest);
|
|
678
|
-
}
|
|
822
|
+
this.evict();
|
|
679
823
|
}
|
|
680
824
|
get(runId) {
|
|
681
825
|
return this.runs.get(runId);
|
|
682
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
|
+
}
|
|
683
841
|
/** Drop a settled run early (called when its final span finished stop). */
|
|
684
842
|
forget(runId) {
|
|
685
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
|
+
}
|
|
686
880
|
}
|
|
687
881
|
};
|
|
688
882
|
//#endregion
|
|
@@ -761,14 +955,37 @@ function toolStepBrief(toolName, args) {
|
|
|
761
955
|
return "";
|
|
762
956
|
};
|
|
763
957
|
if (toolName === "run_command" || toolName === "execute_command") {
|
|
764
|
-
const cmd = pickStr("Command", "command", "Cmd", "cmd");
|
|
958
|
+
const cmd = pickStr("CommandLine", "command_line", "Command", "command", "Cmd", "cmd");
|
|
765
959
|
return cmd !== "" ? "$ " + cmd.slice(0, 80) : "run command";
|
|
766
960
|
}
|
|
767
|
-
if (toolName === "view_file" || toolName === "read_file") return "read " + (pickStr("Path", "path", "TargetFile", "File") || "file");
|
|
768
|
-
if (toolName === "write_to_file" || toolName === "create_file") return "write " + (pickStr("Path", "path", "
|
|
769
|
-
if (toolName === "replace_file_content" || toolName === "edit_file") return "edit " + (pickStr("Path", "path", "
|
|
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");
|
|
770
964
|
if (toolName === "grep_search" || toolName === "search") return "search " + (pickStr("Query", "query", "SearchPattern") || "");
|
|
771
|
-
if (toolName === "
|
|
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
|
+
}
|
|
772
989
|
return toolName;
|
|
773
990
|
}
|
|
774
991
|
/** agy serializes some tool args as a JSON string; presenters get an object. */
|
|
@@ -794,6 +1011,7 @@ function pick$1(input, ...keys) {
|
|
|
794
1011
|
for (const k of keys) {
|
|
795
1012
|
const v = input[k];
|
|
796
1013
|
if (typeof v === "string") return v;
|
|
1014
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
797
1015
|
}
|
|
798
1016
|
}
|
|
799
1017
|
/** Native pending-card projection for one mirrored agy tool call. */
|
|
@@ -917,12 +1135,68 @@ function presentMirrorCall(args) {
|
|
|
917
1135
|
rawInput: input
|
|
918
1136
|
};
|
|
919
1137
|
}
|
|
920
|
-
case "
|
|
921
|
-
|
|
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") ?? "";
|
|
922
1153
|
return {
|
|
923
1154
|
card: "generic",
|
|
924
1155
|
title: pick$1(input, "Description", "description", "toolAction", "toolSummary") ?? (url ? `Fetch ${url}` : "Fetch URL"),
|
|
925
|
-
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
|
|
926
1200
|
};
|
|
927
1201
|
}
|
|
928
1202
|
case "generate_image": {
|
|
@@ -947,10 +1221,17 @@ function presentMirrorResult(args, result) {
|
|
|
947
1221
|
switch (name) {
|
|
948
1222
|
case "run_command":
|
|
949
1223
|
case "bash":
|
|
950
|
-
case "execute_command":
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
+
}
|
|
954
1235
|
case "replace_file_content":
|
|
955
1236
|
case "write_to_file":
|
|
956
1237
|
case "write_file":
|
|
@@ -984,6 +1265,23 @@ function resultText$1(content) {
|
|
|
984
1265
|
function clip(s, max) {
|
|
985
1266
|
return s.length > max ? s.slice(0, max) + "… (+" + (s.length - max) + " chars)" : s;
|
|
986
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
|
+
}
|
|
987
1285
|
function defineAgyMirrorTool(deps) {
|
|
988
1286
|
const enrichArgs = (args) => {
|
|
989
1287
|
if (typeof args.tool === "string" && args.tool !== "") return args;
|
|
@@ -1033,11 +1331,15 @@ function defineAgyMirrorTool(deps) {
|
|
|
1033
1331
|
exec.signal;
|
|
1034
1332
|
const runId = typeof args.run === "string" ? args.run : "";
|
|
1035
1333
|
const step = typeof args.step === "number" ? args.step : -1;
|
|
1036
|
-
typeof args.tool === "string"
|
|
1334
|
+
const toolName = typeof args.tool === "string" ? args.tool : "";
|
|
1335
|
+
const input = toolInput(args);
|
|
1037
1336
|
const rec = deps.runs.get(runId);
|
|
1038
|
-
if (rec === void 0)
|
|
1337
|
+
if (rec === void 0) return recordingUnavailable(step, toolName, input);
|
|
1039
1338
|
const t = rec.toolEventAt(step);
|
|
1040
|
-
if (t === null)
|
|
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
|
+
}
|
|
1041
1343
|
if (t.error !== void 0) throw new Error("agy tool " + t.name + " failed: " + t.error);
|
|
1042
1344
|
const out = t.output;
|
|
1043
1345
|
if (out === void 0 || out === null) return "";
|
|
@@ -1077,7 +1379,17 @@ var EventMapper = class {
|
|
|
1077
1379
|
openAcc = "";
|
|
1078
1380
|
emittedByKey = /* @__PURE__ */ new Map();
|
|
1079
1381
|
announcedTools = /* @__PURE__ */ new Set();
|
|
1382
|
+
pendingAnnounced = /* @__PURE__ */ new Set();
|
|
1080
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();
|
|
1081
1393
|
bannerOnlyThinkingEmitted = false;
|
|
1082
1394
|
sawTextStep;
|
|
1083
1395
|
finished = false;
|
|
@@ -1138,12 +1450,21 @@ var EventMapper = class {
|
|
|
1138
1450
|
* banner. When real thought prose is present, place the banner and thought
|
|
1139
1451
|
* text on the same initial line so DSH's collapsed summary renders:
|
|
1140
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.
|
|
1141
1458
|
*/
|
|
1142
|
-
*emitThinking(absIndex, thoughtTokens) {
|
|
1459
|
+
*emitThinking(stepKey, absIndex, thoughtTokens, allowBannerOnly = false) {
|
|
1143
1460
|
const text = this.opts.resolvedThoughts?.get(absIndex);
|
|
1144
|
-
|
|
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);
|
|
1145
1465
|
yield* this.ensureBlock("reasoning");
|
|
1146
|
-
const
|
|
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`;
|
|
1147
1468
|
const d = this.appendDelta(combined);
|
|
1148
1469
|
if (d) yield d;
|
|
1149
1470
|
}
|
|
@@ -1162,19 +1483,26 @@ var EventMapper = class {
|
|
|
1162
1483
|
const thoughtTokens = ev.usage?.thinking_tokens ?? 0;
|
|
1163
1484
|
const stepTextEmitted = (this.emittedByKey.get(ev.stepKey) ?? "") !== "";
|
|
1164
1485
|
const dbThought = this.opts.resolvedThoughts?.get(absIndex);
|
|
1165
|
-
const
|
|
1166
|
-
if (
|
|
1486
|
+
const hasRealThought = dbThought !== void 0 && dbThought.trim() !== "";
|
|
1487
|
+
if (hasRealThought && !stepTextEmitted && !this.thinkingAnnounced.has(ev.stepKey)) {
|
|
1167
1488
|
this.thinkingAnnounced.add(ev.stepKey);
|
|
1168
|
-
yield* this.emitThinking(absIndex, thoughtTokens);
|
|
1489
|
+
yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens);
|
|
1169
1490
|
}
|
|
1170
|
-
const deferred =
|
|
1491
|
+
const deferred = hasRealThought && stepTextEmitted && !this.thinkingAnnounced.has(ev.stepKey);
|
|
1171
1492
|
if (ev.text === "" && !ev.fragment) {
|
|
1172
1493
|
if (deferred) {
|
|
1173
1494
|
this.thinkingAnnounced.add(ev.stepKey);
|
|
1174
|
-
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);
|
|
1175
1499
|
}
|
|
1176
1500
|
return;
|
|
1177
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
|
+
}
|
|
1178
1506
|
this.sawTextStep = true;
|
|
1179
1507
|
yield* this.ensureBlock("text");
|
|
1180
1508
|
let d;
|
|
@@ -1190,7 +1518,7 @@ var EventMapper = class {
|
|
|
1190
1518
|
if (d) yield d;
|
|
1191
1519
|
if (deferred) {
|
|
1192
1520
|
this.thinkingAnnounced.add(ev.stepKey);
|
|
1193
|
-
yield* this.emitThinking(absIndex, thoughtTokens);
|
|
1521
|
+
yield* this.emitThinking(ev.stepKey, absIndex, thoughtTokens);
|
|
1194
1522
|
}
|
|
1195
1523
|
return;
|
|
1196
1524
|
}
|
|
@@ -1210,13 +1538,25 @@ var EventMapper = class {
|
|
|
1210
1538
|
if (d) yield d;
|
|
1211
1539
|
}
|
|
1212
1540
|
} else {
|
|
1213
|
-
const
|
|
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);
|
|
1214
1546
|
if (d) yield d;
|
|
1215
1547
|
}
|
|
1216
1548
|
return;
|
|
1217
1549
|
}
|
|
1218
1550
|
if (ev.stepKind === "tool" && ev.tool) {
|
|
1219
|
-
if (!(ev.state === "DONE" || ev.state === "ERROR" || ev.state === void 0 && (ev.tool.output !== void 0 || ev.tool.error !== void 0)))
|
|
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
|
+
}
|
|
1220
1560
|
if (this.announcedTools.has(ev.stepKey)) return;
|
|
1221
1561
|
this.announcedTools.add(ev.stepKey);
|
|
1222
1562
|
if (!this.opts.cutOnTool) return;
|
|
@@ -1252,9 +1592,10 @@ var EventMapper = class {
|
|
|
1252
1592
|
}
|
|
1253
1593
|
};
|
|
1254
1594
|
this.blockIdx++;
|
|
1595
|
+
const trackedCut = this.opts.usage?.finalUsage({});
|
|
1255
1596
|
yield {
|
|
1256
1597
|
type: "usage",
|
|
1257
|
-
usage: {
|
|
1598
|
+
usage: trackedCut !== void 0 && Object.keys(trackedCut).length > 0 ? usageFromRaw(trackedCut) : {
|
|
1258
1599
|
inputTokens: 0,
|
|
1259
1600
|
outputTokens: 0
|
|
1260
1601
|
}
|
|
@@ -1312,14 +1653,22 @@ var EventMapper = class {
|
|
|
1312
1653
|
};
|
|
1313
1654
|
this.finished = true;
|
|
1314
1655
|
}
|
|
1315
|
-
/**
|
|
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
|
+
*/
|
|
1316
1664
|
*emitFailure(kind, code, message) {
|
|
1317
1665
|
if (this.finished) return;
|
|
1318
1666
|
const close = this.closeOpen();
|
|
1319
1667
|
if (close) yield close;
|
|
1668
|
+
const tracked = this.opts.usage?.finalUsage({});
|
|
1320
1669
|
yield {
|
|
1321
1670
|
type: "usage",
|
|
1322
|
-
usage: {
|
|
1671
|
+
usage: tracked !== void 0 && Object.keys(tracked).length > 0 ? usageFromRaw(tracked) : {
|
|
1323
1672
|
inputTokens: 0,
|
|
1324
1673
|
outputTokens: 0
|
|
1325
1674
|
}
|
|
@@ -1525,11 +1874,19 @@ function sanitizeCatalogEntries(models) {
|
|
|
1525
1874
|
dropped
|
|
1526
1875
|
};
|
|
1527
1876
|
}
|
|
1528
|
-
var ModelCatalog = class {
|
|
1877
|
+
var ModelCatalog = class ModelCatalog {
|
|
1529
1878
|
discover;
|
|
1530
1879
|
ttlMs;
|
|
1531
1880
|
current;
|
|
1532
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;
|
|
1533
1890
|
constructor(discover, fallbackDefs, ttlMs) {
|
|
1534
1891
|
this.discover = discover;
|
|
1535
1892
|
this.ttlMs = ttlMs;
|
|
@@ -1545,8 +1902,11 @@ var ModelCatalog = class {
|
|
|
1545
1902
|
/** Refresh if stale; never throws — failures keep the previous catalog. */
|
|
1546
1903
|
async refreshIfNeeded() {
|
|
1547
1904
|
if (this.refreshing) return this.refreshing;
|
|
1548
|
-
const
|
|
1905
|
+
const now = Date.now();
|
|
1906
|
+
const age = now - this.current.discoveredAt;
|
|
1549
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;
|
|
1550
1910
|
this.refreshing = this.refresh().finally(() => {
|
|
1551
1911
|
this.refreshing = null;
|
|
1552
1912
|
});
|
|
@@ -1894,6 +2254,7 @@ var StreamJsonParser = class {
|
|
|
1894
2254
|
consecutiveGarbage: 0,
|
|
1895
2255
|
authUrl: void 0,
|
|
1896
2256
|
sawAuthFailure: false,
|
|
2257
|
+
authFailureText: void 0,
|
|
1897
2258
|
lastResultError: void 0
|
|
1898
2259
|
};
|
|
1899
2260
|
/** Ring buffer of raw stdout lines for /agy doctor export. */
|
|
@@ -1925,7 +2286,11 @@ var StreamJsonParser = class {
|
|
|
1925
2286
|
this.stats.lines++;
|
|
1926
2287
|
this.recentLines.push(line);
|
|
1927
2288
|
if (this.recentLines.length > this.maxRecent) this.recentLines.splice(0, this.recentLines.length - this.maxRecent);
|
|
1928
|
-
if (looksLikeAuthFailure(line))
|
|
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
|
+
}
|
|
1929
2294
|
if (this.stats.authUrl === void 0) {
|
|
1930
2295
|
const u = extractAuthUrl(line);
|
|
1931
2296
|
if (u) this.stats.authUrl = u;
|
|
@@ -2243,20 +2608,17 @@ function ensureIsolatedKeychain(dir, platform = process.platform, run = (file, a
|
|
|
2243
2608
|
if (platform !== "darwin") return false;
|
|
2244
2609
|
if (dir === "") return false;
|
|
2245
2610
|
const kc = join(dir, "Library", "Keychains", "login.keychain-db");
|
|
2611
|
+
if (existsSync(kc)) return false;
|
|
2246
2612
|
try {
|
|
2247
2613
|
const env = {
|
|
2248
2614
|
...process.env,
|
|
2249
2615
|
HOME: dir
|
|
2250
2616
|
};
|
|
2251
|
-
if (existsSync(kc)) {
|
|
2252
|
-
run("security", ["set-keychain-settings", kc], env);
|
|
2253
|
-
return false;
|
|
2254
|
-
}
|
|
2255
2617
|
mkdirSync(join(dir, "Library", "Keychains"), { recursive: true });
|
|
2256
2618
|
run("security", [
|
|
2257
2619
|
"create-keychain",
|
|
2258
2620
|
"-p",
|
|
2259
|
-
|
|
2621
|
+
"",
|
|
2260
2622
|
kc
|
|
2261
2623
|
], env);
|
|
2262
2624
|
run("security", [
|
|
@@ -2265,6 +2627,12 @@ function ensureIsolatedKeychain(dir, platform = process.platform, run = (file, a
|
|
|
2265
2627
|
kc
|
|
2266
2628
|
], env);
|
|
2267
2629
|
run("security", ["set-keychain-settings", kc], env);
|
|
2630
|
+
run("security", [
|
|
2631
|
+
"unlock-keychain",
|
|
2632
|
+
"-p",
|
|
2633
|
+
"",
|
|
2634
|
+
kc
|
|
2635
|
+
], env);
|
|
2268
2636
|
return true;
|
|
2269
2637
|
} catch {
|
|
2270
2638
|
return false;
|
|
@@ -2595,6 +2963,10 @@ async function probeProcess(bin, args, timeoutMs = 3e4, signal, env) {
|
|
|
2595
2963
|
var SessionStore = class {
|
|
2596
2964
|
file;
|
|
2597
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();
|
|
2598
2970
|
constructor(file) {
|
|
2599
2971
|
this.file = file;
|
|
2600
2972
|
this.load();
|
|
@@ -2611,26 +2983,54 @@ var SessionStore = class {
|
|
|
2611
2983
|
}
|
|
2612
2984
|
set(key, b) {
|
|
2613
2985
|
this.data[key] = b;
|
|
2986
|
+
this.dirty.add(key);
|
|
2987
|
+
this.removed.delete(key);
|
|
2614
2988
|
this.persist();
|
|
2615
2989
|
}
|
|
2616
2990
|
delete(key) {
|
|
2617
2991
|
delete this.data[key];
|
|
2992
|
+
this.dirty.delete(key);
|
|
2993
|
+
this.removed.add(key);
|
|
2618
2994
|
this.persist();
|
|
2619
2995
|
}
|
|
2620
2996
|
clear() {
|
|
2997
|
+
for (const key of Object.keys(this.data)) this.removed.add(key);
|
|
2621
2998
|
this.data = {};
|
|
2999
|
+
this.dirty.clear();
|
|
2622
3000
|
this.persist();
|
|
2623
3001
|
}
|
|
2624
3002
|
all() {
|
|
2625
3003
|
return this.data;
|
|
2626
3004
|
}
|
|
2627
|
-
/**
|
|
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
|
+
*/
|
|
2628
3013
|
persist() {
|
|
2629
3014
|
try {
|
|
2630
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
|
+
}
|
|
2631
3028
|
const tmp = join(dirname(this.file), "." + require$$basename(this.file) + ".tmp");
|
|
2632
|
-
writeFileSync(tmp, JSON.stringify(
|
|
3029
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), "utf8");
|
|
2633
3030
|
renameSync(tmp, this.file);
|
|
3031
|
+
this.data = merged;
|
|
3032
|
+
this.dirty.clear();
|
|
3033
|
+
this.removed.clear();
|
|
2634
3034
|
} catch {}
|
|
2635
3035
|
}
|
|
2636
3036
|
};
|
|
@@ -2695,6 +3095,20 @@ const TOOL_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
|
2695
3095
|
const MAX_CACHE_AGE_MS = 3e5;
|
|
2696
3096
|
let cache = null;
|
|
2697
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;
|
|
2698
3112
|
/** agy conversation ids are path-safe tokens; reject anything else before join(). */
|
|
2699
3113
|
const SAFE_CONVERSATION_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
2700
3114
|
function isSafeConversationId(id) {
|
|
@@ -2827,8 +3241,37 @@ function extractStepThoughts(payload) {
|
|
|
2827
3241
|
return null;
|
|
2828
3242
|
}
|
|
2829
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
|
+
/**
|
|
2830
3272
|
* Copy the agy SQLite DB to a temp path (avoiding WAL lock issues), then
|
|
2831
|
-
* query tool and thought step payloads
|
|
3273
|
+
* query tool and thought step payloads — node:sqlite first, sqlite3 CLI as
|
|
3274
|
+
* the fallback reader.
|
|
2832
3275
|
*/
|
|
2833
3276
|
async function loadAgyDbData(conversationId, accountHome) {
|
|
2834
3277
|
const steps = /* @__PURE__ */ new Map();
|
|
@@ -2845,21 +3288,37 @@ async function loadAgyDbData(conversationId, accountHome) {
|
|
|
2845
3288
|
thoughts,
|
|
2846
3289
|
seenSteps
|
|
2847
3290
|
};
|
|
3291
|
+
let tmpDir = null;
|
|
2848
3292
|
const tmpDb = join(tmpdir(), `agy-db-${conversationId.slice(0, 8)}-${Date.now()}.db`);
|
|
2849
3293
|
try {
|
|
2850
|
-
await
|
|
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);
|
|
2851
3298
|
try {
|
|
2852
|
-
await copyFile(dbPath + "-wal",
|
|
3299
|
+
await copyFile(dbPath + "-wal", target + "-wal");
|
|
2853
3300
|
} catch {}
|
|
2854
3301
|
try {
|
|
2855
|
-
await copyFile(dbPath + "-shm",
|
|
3302
|
+
await copyFile(dbPath + "-shm", target + "-shm");
|
|
2856
3303
|
} catch {}
|
|
2857
|
-
const
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
}
|
|
2862
|
-
|
|
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) {
|
|
2863
3322
|
const firstPipe = line.indexOf("|");
|
|
2864
3323
|
if (firstPipe < 0) continue;
|
|
2865
3324
|
const secondPipe = line.indexOf("|", firstPipe + 1);
|
|
@@ -2880,16 +3339,37 @@ async function loadAgyDbData(conversationId, accountHome) {
|
|
|
2880
3339
|
if (th !== null && th.trim() !== "" && !/^SYSTEM ROLE/i.test(th.trim())) thoughts.set(idx, th);
|
|
2881
3340
|
}
|
|
2882
3341
|
}
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
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
|
+
});
|
|
2892
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
|
+
}
|
|
2893
3373
|
}
|
|
2894
3374
|
return {
|
|
2895
3375
|
steps,
|
|
@@ -3013,6 +3493,38 @@ function scanFallbackJson(payload) {
|
|
|
3013
3493
|
return null;
|
|
3014
3494
|
}
|
|
3015
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
|
+
/**
|
|
3016
3528
|
* Read the full tool parameter object for a given step index from the agy
|
|
3017
3529
|
* conversation database. Returns null when the DB is unavailable or does
|
|
3018
3530
|
* not contain the requested step.
|
|
@@ -3028,16 +3540,8 @@ async function readFullToolArgs(conversationId, stepIndex, accountHome) {
|
|
|
3028
3540
|
if (cached !== void 0) return cached;
|
|
3029
3541
|
if (cache.checkedSteps.has(stepIndex)) return null;
|
|
3030
3542
|
}
|
|
3031
|
-
|
|
3032
|
-
cache
|
|
3033
|
-
conversationId,
|
|
3034
|
-
steps: data.steps,
|
|
3035
|
-
thoughts: data.thoughts,
|
|
3036
|
-
checkedSteps: data.seenSteps,
|
|
3037
|
-
loaded: true
|
|
3038
|
-
};
|
|
3039
|
-
cacheTime = now;
|
|
3040
|
-
return data.steps.get(stepIndex) ?? null;
|
|
3543
|
+
await loadCached(conversationId, accountHome);
|
|
3544
|
+
return cache?.steps.get(stepIndex) ?? null;
|
|
3041
3545
|
}
|
|
3042
3546
|
/**
|
|
3043
3547
|
* Read the full thought/reasoning text for a given step index from the agy
|
|
@@ -3052,16 +3556,8 @@ async function readStepThoughts(conversationId, stepIndex, accountHome) {
|
|
|
3052
3556
|
if (cached !== void 0) return cached;
|
|
3053
3557
|
if (cache.checkedSteps.has(stepIndex)) return null;
|
|
3054
3558
|
}
|
|
3055
|
-
|
|
3056
|
-
cache
|
|
3057
|
-
conversationId,
|
|
3058
|
-
steps: data.steps,
|
|
3059
|
-
thoughts: data.thoughts,
|
|
3060
|
-
checkedSteps: data.seenSteps,
|
|
3061
|
-
loaded: true
|
|
3062
|
-
};
|
|
3063
|
-
cacheTime = now;
|
|
3064
|
-
return data.thoughts.get(stepIndex) ?? null;
|
|
3559
|
+
await loadCached(conversationId, accountHome);
|
|
3560
|
+
return cache?.thoughts.get(stepIndex) ?? null;
|
|
3065
3561
|
}
|
|
3066
3562
|
//#endregion
|
|
3067
3563
|
//#region src/host/adapter.ts
|
|
@@ -3109,22 +3605,85 @@ function textOf(m) {
|
|
|
3109
3605
|
walk(m.content);
|
|
3110
3606
|
return parts.filter((s) => s !== "").join("\n");
|
|
3111
3607
|
}
|
|
3112
|
-
function isForeignAssistant(m) {
|
|
3608
|
+
function isForeignAssistant(m, currentConvId) {
|
|
3113
3609
|
if (m.role !== "assistant") return false;
|
|
3114
3610
|
const src = m.source;
|
|
3115
|
-
|
|
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;
|
|
3116
3644
|
}
|
|
3117
3645
|
/**
|
|
3118
|
-
* Latest real human turn (not a tool result, not assistant
|
|
3119
|
-
* 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
|
|
3120
3648
|
* (issue #35: "续跑提示词里没有任务").
|
|
3121
3649
|
*/
|
|
3122
3650
|
function latestUserTaskText(messages) {
|
|
3123
3651
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3124
3652
|
const m = messages[i];
|
|
3125
|
-
if (m === void 0
|
|
3126
|
-
if (
|
|
3127
|
-
|
|
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();
|
|
3128
3687
|
if (text !== "") return text;
|
|
3129
3688
|
}
|
|
3130
3689
|
return "";
|
|
@@ -3202,6 +3761,7 @@ function buildDigest(messages, fromIdx, maxChars) {
|
|
|
3202
3761
|
for (let i = fromIdx; i < messages.length; i++) {
|
|
3203
3762
|
const m = messages[i];
|
|
3204
3763
|
if (m === void 0 || m.role === "system") continue;
|
|
3764
|
+
if (isSyntheticHarnessMessage(m)) continue;
|
|
3205
3765
|
const text = textOf(m);
|
|
3206
3766
|
if (text === "") continue;
|
|
3207
3767
|
if (taskLine !== "" && !isToolResultMessage(m) && m.role === "user" && text === taskText) continue;
|
|
@@ -3270,12 +3830,17 @@ function sawAuthFailure(parser, outcome) {
|
|
|
3270
3830
|
/**
|
|
3271
3831
|
* Bounded wait for an aborted run to settle, so a steer replacement never
|
|
3272
3832
|
* overlaps the process it just killed on the same agy conversation.
|
|
3833
|
+
* Returns whether the run actually settled before the deadline.
|
|
3273
3834
|
*/
|
|
3274
3835
|
async function waitForRunSettle(run, timeoutMs) {
|
|
3275
3836
|
const deadline = Date.now() + timeoutMs;
|
|
3276
|
-
while (
|
|
3837
|
+
while (Date.now() < deadline) {
|
|
3838
|
+
if (run.isSettled) return true;
|
|
3839
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
3840
|
+
}
|
|
3841
|
+
return run.isSettled;
|
|
3277
3842
|
}
|
|
3278
|
-
var AgyAdapter = class extends LlmAdapter {
|
|
3843
|
+
var AgyAdapter = class AgyAdapter extends LlmAdapter {
|
|
3279
3844
|
deps;
|
|
3280
3845
|
warnedKeys = /* @__PURE__ */ new Set();
|
|
3281
3846
|
/** sessionKey -> in-flight run, for steer-time preemption. */
|
|
@@ -3291,6 +3856,32 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3291
3856
|
minSpawnIntervalMs = 500;
|
|
3292
3857
|
/** Global sliding window timestamps for batch rate-limiting protection */
|
|
3293
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;
|
|
3294
3885
|
constructor(deps) {
|
|
3295
3886
|
super();
|
|
3296
3887
|
this.deps = deps;
|
|
@@ -3311,25 +3902,69 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3311
3902
|
});
|
|
3312
3903
|
}
|
|
3313
3904
|
/**
|
|
3314
|
-
*
|
|
3315
|
-
*
|
|
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.
|
|
3939
|
+
*
|
|
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.
|
|
3316
3945
|
*
|
|
3317
|
-
*
|
|
3318
|
-
*
|
|
3319
|
-
*
|
|
3320
|
-
*
|
|
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.
|
|
3321
3951
|
*/
|
|
3322
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);
|
|
3323
3964
|
return {
|
|
3324
3965
|
mode: "normal",
|
|
3325
|
-
maxRetries: 1,
|
|
3326
|
-
retryableCodes
|
|
3327
|
-
Err.TIMEOUT,
|
|
3328
|
-
Err.PROCESS_EXIT,
|
|
3329
|
-
Err.INVALID_OUTPUT,
|
|
3330
|
-
Err.RATE_LIMIT,
|
|
3331
|
-
Err.ELIGIBILITY
|
|
3332
|
-
],
|
|
3966
|
+
maxRetries: enabledAccounts >= 3 ? 2 : 1,
|
|
3967
|
+
retryableCodes,
|
|
3333
3968
|
initialDelayMs: 2e3,
|
|
3334
3969
|
maxDelayMs: 1e4,
|
|
3335
3970
|
jitterRatio: .1
|
|
@@ -3452,11 +4087,15 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3452
4087
|
const continuation = detectContinuation(options.messages);
|
|
3453
4088
|
if (continuation !== null) {
|
|
3454
4089
|
const rec = this.deps.runs.get(continuation.runId);
|
|
3455
|
-
|
|
4090
|
+
const deadFailure = rec !== void 0 && rec.isSettled ? rec.failureInfo : null;
|
|
4091
|
+
if (rec !== void 0 && !(deadFailure !== null && this.claimFailover(sessionKey, deadFailure.code))) {
|
|
3456
4092
|
yield* this.driveSpan(rec, continuation.eventIndex + 1, hasToolSupport, isCodeMode);
|
|
3457
4093
|
return;
|
|
3458
4094
|
}
|
|
3459
|
-
|
|
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");
|
|
3460
4099
|
}
|
|
3461
4100
|
const catalog = this.deps.catalog.get();
|
|
3462
4101
|
const rawModel = options.model;
|
|
@@ -3522,17 +4161,38 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3522
4161
|
}
|
|
3523
4162
|
}
|
|
3524
4163
|
if (!account) {
|
|
3525
|
-
const
|
|
3526
|
-
const
|
|
3527
|
-
|
|
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
|
+
}
|
|
3528
4178
|
}
|
|
3529
4179
|
}
|
|
3530
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)");
|
|
3531
4182
|
const sessionAccountKey = account ? `${sessionKey}:${account.id}` : sessionKey;
|
|
3532
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
|
+
}
|
|
3533
4193
|
if (binding === void 0 && !isAux && sessionKey !== "") {
|
|
3534
4194
|
const live = this.activeRuns.get(sessionKey);
|
|
3535
|
-
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;
|
|
3536
4196
|
if (liveCid) binding = {
|
|
3537
4197
|
conversationId: liveCid,
|
|
3538
4198
|
lastMessageCount: Math.max(0, lastAssistantIdx + 1),
|
|
@@ -3564,20 +4224,37 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3564
4224
|
}
|
|
3565
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.";
|
|
3566
4226
|
} else {
|
|
3567
|
-
const
|
|
3568
|
-
const
|
|
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 !== "");
|
|
3569
4237
|
const trailingJoined = trailingText.join("\n\n");
|
|
3570
4238
|
if (taskText !== "") prompt = trailingText.includes(taskText) || trailingJoined.includes(taskText) ? trailingJoined : trailingJoined === "" ? taskText : taskText + "\n\n" + trailingJoined;
|
|
3571
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]";
|
|
3572
4241
|
if (binding === void 0 && lastAssistantIdx >= 0) prompt = buildDigest(messages, 0, cfg.contextTransferMaxChars) + prompt;
|
|
3573
4242
|
else if (binding !== void 0) {
|
|
3574
4243
|
const from = Math.min(binding.lastMessageCount, messages.length);
|
|
3575
4244
|
const end = Math.max(from, lastAssistantIdx + 1);
|
|
3576
|
-
const
|
|
4245
|
+
const currentConvId = binding.conversationId;
|
|
4246
|
+
const span = messages.slice(from, end).filter((m) => m.role !== "assistant" || isForeignAssistant(m, currentConvId));
|
|
3577
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;
|
|
3578
4249
|
}
|
|
3579
4250
|
}
|
|
3580
|
-
if (cfg.forwardSystemPrompt
|
|
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
|
+
}
|
|
3581
4258
|
let stagedDirs = [];
|
|
3582
4259
|
const newlySentImageIds = [];
|
|
3583
4260
|
if (!isAux) {
|
|
@@ -3608,18 +4285,39 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3608
4285
|
const liveRun = this.activeRuns.get(sessionKey);
|
|
3609
4286
|
const runLive = liveRun !== void 0 && !liveRun.isSettled;
|
|
3610
4287
|
const samePrompt = prevPrompt !== void 0 && prevPrompt.prompt === prompt;
|
|
3611
|
-
if (runLive && samePrompt)
|
|
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
|
+
}
|
|
3612
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);
|
|
3613
4303
|
if (runLive && !samePrompt) {
|
|
3614
|
-
const liveCid = liveRun.conversationId;
|
|
4304
|
+
const liveCid = (liveRun.accountHome ?? "") === (account?.dir ?? "") ? liveRun.conversationId : null;
|
|
3615
4305
|
if (liveCid !== null && binding === void 0 && !isAux && sessionAccountKey !== "") binding = {
|
|
3616
4306
|
conversationId: liveCid,
|
|
3617
4307
|
lastMessageCount: Math.max(0, lastAssistantIdx + 1),
|
|
3618
4308
|
updatedAt: Date.now(),
|
|
3619
|
-
model: currentModel
|
|
4309
|
+
model: currentModel,
|
|
4310
|
+
interrupted: true
|
|
3620
4311
|
};
|
|
3621
|
-
|
|
3622
|
-
|
|
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
|
+
}
|
|
3623
4321
|
}
|
|
3624
4322
|
this.activeSessionPrompts.set(sessionKey, {
|
|
3625
4323
|
prompt,
|
|
@@ -3627,18 +4325,57 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3627
4325
|
inFlight: true
|
|
3628
4326
|
});
|
|
3629
4327
|
}
|
|
3630
|
-
const before = snapshotConversations();
|
|
3631
4328
|
const rec = this.deps.runs.create();
|
|
3632
4329
|
rec.accountHome = account && account.dir ? account.dir : void 0;
|
|
4330
|
+
const conversationsDir = conversationsDirFor(rec.accountHome);
|
|
4331
|
+
const before = snapshotConversations(conversationsDir);
|
|
3633
4332
|
const parser = new StreamJsonParser();
|
|
3634
4333
|
this.deps.onParser?.(parser);
|
|
3635
4334
|
let streamCid = null;
|
|
3636
4335
|
const openToolKeys = /* @__PURE__ */ new Set();
|
|
3637
|
-
const
|
|
3638
|
-
const
|
|
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;
|
|
3639
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;
|
|
3640
4354
|
const applyToolIdleBudget = () => {
|
|
3641
|
-
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?.();
|
|
3642
4379
|
};
|
|
3643
4380
|
const activeModelForArgs = activeModel === "" ? cfg.defaultModel : activeModel;
|
|
3644
4381
|
const effectiveAddDirs = workspaceRoot !== "" ? stagedDirs.includes(workspaceRoot) ? stagedDirs : [workspaceRoot, ...stagedDirs] : stagedDirs;
|
|
@@ -3674,6 +4411,10 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3674
4411
|
released = true;
|
|
3675
4412
|
release();
|
|
3676
4413
|
};
|
|
4414
|
+
if (options.signal?.aborted) {
|
|
4415
|
+
releaseOnce();
|
|
4416
|
+
throw new LlmError("request aborted before the agy process was spawned", Err.AGY_ERROR);
|
|
4417
|
+
}
|
|
3677
4418
|
if (account && account.dir) ensureIsolatedKeychain(account.dir);
|
|
3678
4419
|
const env = {
|
|
3679
4420
|
...process.env,
|
|
@@ -3686,16 +4427,17 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3686
4427
|
...account && account.dir ? isolatedHomeEnv(account.dir) : {},
|
|
3687
4428
|
...proxyEnvFor(account?.proxyUrl || cfg.proxyUrl || void 0)
|
|
3688
4429
|
};
|
|
3689
|
-
if (account) {
|
|
3690
|
-
const lastSpawn = this.lastAccountSpawnTime.get(account.id) ?? 0;
|
|
3691
|
-
const elapsed = Date.now() - lastSpawn;
|
|
3692
|
-
const jitter = Math.floor(Math.random() * 300);
|
|
3693
|
-
const targetInterval = this.minSpawnIntervalMs + jitter;
|
|
3694
|
-
if (elapsed < targetInterval) await new Promise((r) => setTimeout(r, targetInterval - elapsed));
|
|
3695
|
-
this.lastAccountSpawnTime.set(account.id, Date.now());
|
|
3696
|
-
}
|
|
3697
4430
|
let proc;
|
|
4431
|
+
let spawned = false;
|
|
3698
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
|
+
}
|
|
3699
4441
|
proc = startAgyProcess({
|
|
3700
4442
|
bin,
|
|
3701
4443
|
args,
|
|
@@ -3704,7 +4446,12 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3704
4446
|
signal: options.signal,
|
|
3705
4447
|
env,
|
|
3706
4448
|
stdinPayload,
|
|
4449
|
+
stderrKeepalive: true,
|
|
3707
4450
|
onLine: (line) => {
|
|
4451
|
+
sawAnyEvent = true;
|
|
4452
|
+
lastOutputAt = Date.now();
|
|
4453
|
+
startToolHeartbeat();
|
|
4454
|
+
applyToolIdleBudget();
|
|
3708
4455
|
for (const ev of parser.feed(line + "\n")) {
|
|
3709
4456
|
if (ev.kind === "init" && ev.conversationId) streamCid = ev.conversationId;
|
|
3710
4457
|
if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
|
|
@@ -3712,40 +4459,62 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3712
4459
|
if (!(ev.state === "DONE" || ev.state === "ERROR" || ev.state === void 0 && (ev.tool.output !== void 0 || ev.tool.error !== void 0))) {
|
|
3713
4460
|
if (!openToolKeys.has(ev.stepKey)) {
|
|
3714
4461
|
openToolKeys.add(ev.stepKey);
|
|
4462
|
+
openToolSince.set(ev.stepKey, Date.now());
|
|
3715
4463
|
applyToolIdleBudget();
|
|
4464
|
+
startToolHeartbeat();
|
|
3716
4465
|
}
|
|
3717
|
-
} else if (openToolKeys.delete(ev.stepKey))
|
|
4466
|
+
} else if (openToolKeys.delete(ev.stepKey)) {
|
|
4467
|
+
openToolSince.delete(ev.stepKey);
|
|
4468
|
+
applyToolIdleBudget();
|
|
4469
|
+
if (openToolKeys.size === 0) stopToolHeartbeat();
|
|
4470
|
+
}
|
|
3718
4471
|
}
|
|
3719
4472
|
rec.append(ev);
|
|
3720
4473
|
}
|
|
3721
4474
|
}
|
|
3722
4475
|
});
|
|
4476
|
+
spawned = true;
|
|
3723
4477
|
runningProc = proc;
|
|
3724
4478
|
applyToolIdleBudget();
|
|
3725
4479
|
if (!isAux && sessionKey !== "") {
|
|
3726
4480
|
rec.requestAbort = () => proc.kill("abort");
|
|
3727
4481
|
this.activeRuns.set(sessionKey, rec);
|
|
3728
4482
|
}
|
|
4483
|
+
if (options.signal?.aborted) proc.kill("abort");
|
|
3729
4484
|
} catch (e) {
|
|
3730
4485
|
releaseOnce();
|
|
3731
4486
|
this.clearInFlightPrompt(sessionKey, prompt);
|
|
3732
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
|
+
}
|
|
3733
4493
|
}
|
|
3734
4494
|
(async () => {
|
|
3735
4495
|
const outcome = await proc.outcome;
|
|
3736
4496
|
releaseOnce();
|
|
4497
|
+
stopToolHeartbeat();
|
|
4498
|
+
openToolKeys.clear();
|
|
4499
|
+
openToolSince.clear();
|
|
3737
4500
|
if (this.activeRuns.get(sessionKey) === rec) this.activeRuns.delete(sessionKey);
|
|
3738
4501
|
this.clearInFlightPrompt(sessionKey, prompt);
|
|
3739
4502
|
for (const ev of parser.flush()) {
|
|
3740
4503
|
if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
|
|
3741
4504
|
rec.append(ev);
|
|
3742
4505
|
}
|
|
3743
|
-
const diffed = diffConversations(before).conversationId;
|
|
4506
|
+
const diffed = diffConversations(before, conversationsDir).conversationId;
|
|
3744
4507
|
const conversationId = streamCid ?? diffed;
|
|
3745
4508
|
const r = rec.getResultEvent();
|
|
3746
4509
|
const consumable = r !== null && (r.ok || r.response !== "");
|
|
3747
4510
|
const rawErrText = [outcome.stderrTail, parser.stats.lastResultError].filter(Boolean).join(" ");
|
|
3748
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(" "));
|
|
3749
4518
|
const silentTimeout = outcome.timedOut === true && outcome.stdout.trim() === "";
|
|
3750
4519
|
let failure = null;
|
|
3751
4520
|
if (outcome.aborted) failure = {
|
|
@@ -3758,7 +4527,11 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3758
4527
|
code: Err.TIMEOUT,
|
|
3759
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,请检查代理或网络配置" : "")
|
|
3760
4529
|
};
|
|
3761
|
-
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
|
+
} : {
|
|
3762
4535
|
kind: "error",
|
|
3763
4536
|
code: Err.AUTH,
|
|
3764
4537
|
message: "agy is not signed in — run /agy auth (or run agy once in a terminal) to login"
|
|
@@ -3767,7 +4540,7 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3767
4540
|
const bestMsg = parser.stats.lastResultError || (outcome.stderrTail ? brief(outcome.stderrTail) : "Rate limit or quota reached");
|
|
3768
4541
|
failure = {
|
|
3769
4542
|
kind: "error",
|
|
3770
|
-
code: looksLikeHardRateLimit(rawErrText) ? Err.RATE_LIMIT : Err.
|
|
4543
|
+
code: looksLikeHardRateLimit(rawErrText) ? Err.RATE_LIMIT : Err.OVERLOADED,
|
|
3771
4544
|
message: "Google Antigravity quota / rate limit reached: " + bestMsg
|
|
3772
4545
|
};
|
|
3773
4546
|
} else if (looksLikeEligibilityFailure(rawErrText) || looksLikeEligibilityFailure(parser.stats.lastResultError ?? "")) {
|
|
@@ -3797,9 +4570,12 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3797
4570
|
};
|
|
3798
4571
|
}
|
|
3799
4572
|
rec.settle(failure);
|
|
4573
|
+
const superseded = this.activeRuns.get(sessionKey) !== void 0 && this.activeRuns.get(sessionKey) !== rec;
|
|
3800
4574
|
if (failure === null) {
|
|
3801
4575
|
if (account) this.deps.pool?.recordSuccess(account.id, family);
|
|
3802
|
-
|
|
4576
|
+
this.failoverBudget.delete(sessionKey);
|
|
4577
|
+
this.rateLimitFailover.delete(sessionKey);
|
|
4578
|
+
if (!isAux && sessionAccountKey !== "" && !superseded) {
|
|
3803
4579
|
const finalId = binding !== void 0 ? binding.conversationId : conversationId;
|
|
3804
4580
|
if (finalId) this.deps.store.set(sessionAccountKey, {
|
|
3805
4581
|
conversationId: finalId,
|
|
@@ -3810,21 +4586,23 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3810
4586
|
});
|
|
3811
4587
|
}
|
|
3812
4588
|
} else {
|
|
3813
|
-
const effectiveRateLimit = isRateLimit || looksLikeRateLimit(failure.message);
|
|
3814
4589
|
if (account && !outcome.aborted && looksLikeHardRateLimit(rawErrText)) this.deps.pool?.recordFailure(account.id, family, failure.message);
|
|
3815
4590
|
if (account && silentTimeout) this.deps.pool?.advanceAccount(account.id, family);
|
|
3816
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);
|
|
3817
4593
|
if (account && shouldMarkAuthRequired(failure.code, failure.message)) this.deps.pool?.markAuthRequired(account.id, failure.message);
|
|
3818
|
-
const staleConversation = failure.code === Err.AUTH ||
|
|
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;
|
|
3819
4596
|
if (!isAux && sessionAccountKey !== "" && staleConversation) this.deps.store.delete(sessionAccountKey);
|
|
3820
|
-
else if (!isAux && sessionAccountKey !== "" && !
|
|
4597
|
+
else if (!isAux && sessionAccountKey !== "" && !superseded) {
|
|
3821
4598
|
const finalId = binding !== void 0 ? binding.conversationId : conversationId;
|
|
3822
4599
|
if (finalId) this.deps.store.set(sessionAccountKey, {
|
|
3823
4600
|
conversationId: finalId,
|
|
3824
4601
|
lastMessageCount: messages.length,
|
|
3825
4602
|
updatedAt: Date.now(),
|
|
3826
4603
|
model: activeModel,
|
|
3827
|
-
sentImageIds: mergeSentImageIds(binding?.sentImageIds,
|
|
4604
|
+
sentImageIds: mergeSentImageIds(binding?.sentImageIds, []),
|
|
4605
|
+
...interruptedTurn || binding?.interrupted === true ? { interrupted: true } : {}
|
|
3828
4606
|
});
|
|
3829
4607
|
}
|
|
3830
4608
|
}
|
|
@@ -3833,7 +4611,8 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3833
4611
|
processCode: outcome.aborted ? "ABORTED" : outcome.timedOut ? Err.TIMEOUT : outcome.code === 0 ? "OK" : "EXIT_" + String(outcome.code),
|
|
3834
4612
|
toolErrors: rec.toolErrors(),
|
|
3835
4613
|
durationMs: outcome.durationMs,
|
|
3836
|
-
model
|
|
4614
|
+
model,
|
|
4615
|
+
tokenSpeed: rec.speedStats()
|
|
3837
4616
|
});
|
|
3838
4617
|
})().catch((err) => {
|
|
3839
4618
|
releaseOnce();
|
|
@@ -3853,6 +4632,17 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3853
4632
|
* provider error.
|
|
3854
4633
|
*/
|
|
3855
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) {
|
|
3856
4646
|
const queue = new ChunkQueue();
|
|
3857
4647
|
(async () => {
|
|
3858
4648
|
const resolved = /* @__PURE__ */ new Map();
|
|
@@ -3880,8 +4670,12 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3880
4670
|
if (activeConvId !== null && Number.isFinite(stepIdx)) try {
|
|
3881
4671
|
let th = await readStepThoughts(activeConvId, stepIdx, rec.accountHome);
|
|
3882
4672
|
if (th === null && (ev.state === "DONE" || (ev.usage?.thinking_tokens ?? 0) > 0 || ev.stepKind === "thinking")) {
|
|
3883
|
-
await new Promise((r) => setTimeout(r,
|
|
4673
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
3884
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
|
+
}
|
|
3885
4679
|
}
|
|
3886
4680
|
if (th !== null && th.trim() !== "") {
|
|
3887
4681
|
resolvedThoughts.set(i, th);
|
|
@@ -3924,7 +4718,10 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
3924
4718
|
}
|
|
3925
4719
|
for (const ch of mapper.map(ev, i)) queue.push(ch);
|
|
3926
4720
|
i++;
|
|
3927
|
-
if (mapper.isFinished)
|
|
4721
|
+
if (mapper.isFinished) {
|
|
4722
|
+
rec.lastCutIndex = i - 1;
|
|
4723
|
+
break;
|
|
4724
|
+
}
|
|
3928
4725
|
}
|
|
3929
4726
|
if (!mapper.isFinished) {
|
|
3930
4727
|
const f = rec.failureInfo;
|
|
@@ -27688,10 +28485,21 @@ function agentFor(proxyUrl) {
|
|
|
27688
28485
|
}
|
|
27689
28486
|
return envAgent;
|
|
27690
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;
|
|
27691
28496
|
/** fetch() honoring env proxies, or an explicit per-account proxy URL. */
|
|
27692
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);
|
|
27693
28500
|
return (0, import_undici.fetch)(url, {
|
|
27694
28501
|
...init,
|
|
28502
|
+
signal,
|
|
27695
28503
|
dispatcher: agentFor(proxyUrl)
|
|
27696
28504
|
});
|
|
27697
28505
|
}
|
|
@@ -27873,7 +28681,14 @@ function startCallbackListener(timeoutMs = 3e5) {
|
|
|
27873
28681
|
"Content-Type": "text/html",
|
|
27874
28682
|
connection: "close"
|
|
27875
28683
|
});
|
|
27876
|
-
res.end(`<h2>Authorization failed: ${oauthError
|
|
28684
|
+
res.end(`<h2>Authorization failed: ${oauthError.replace(/[&<>"']/g, (ch) => ({
|
|
28685
|
+
"&": "&",
|
|
28686
|
+
"<": "<",
|
|
28687
|
+
">": ">",
|
|
28688
|
+
"\"": """,
|
|
28689
|
+
"'": "'"
|
|
28690
|
+
})[ch] ?? ch)}</h2>`);
|
|
28691
|
+
clearTimeout(timeout);
|
|
27877
28692
|
rejectResult(/* @__PURE__ */ new Error(`oauth error: ${oauthError}`));
|
|
27878
28693
|
setTimeout(() => server.close(), 1500);
|
|
27879
28694
|
return;
|
|
@@ -27888,6 +28703,7 @@ function startCallbackListener(timeoutMs = 3e5) {
|
|
|
27888
28703
|
connection: "close"
|
|
27889
28704
|
});
|
|
27890
28705
|
res.end(CALLBACK_SUCCESS_HTML);
|
|
28706
|
+
clearTimeout(timeout);
|
|
27891
28707
|
resolveResult({
|
|
27892
28708
|
code,
|
|
27893
28709
|
state
|
|
@@ -27895,6 +28711,7 @@ function startCallbackListener(timeoutMs = 3e5) {
|
|
|
27895
28711
|
setTimeout(() => server.close(), 1500);
|
|
27896
28712
|
});
|
|
27897
28713
|
server.on("error", (error) => {
|
|
28714
|
+
clearTimeout(timeout);
|
|
27898
28715
|
rejectResult(error.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`port ${OAUTH_CALLBACK_PORT} is busy`) : error);
|
|
27899
28716
|
});
|
|
27900
28717
|
server.listen(OAUTH_CALLBACK_PORT, "127.0.0.1");
|
|
@@ -27918,16 +28735,21 @@ function openBrowser(url) {
|
|
|
27918
28735
|
return new Promise((resolve) => {
|
|
27919
28736
|
try {
|
|
27920
28737
|
if (process.platform === "darwin") execFile("open", [url], (err) => resolve(!err));
|
|
27921
|
-
else if (process.platform === "win32")
|
|
27922
|
-
"/
|
|
27923
|
-
|
|
27924
|
-
|
|
27925
|
-
|
|
27926
|
-
|
|
27927
|
-
|
|
27928
|
-
|
|
27929
|
-
|
|
27930
|
-
|
|
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));
|
|
27931
28753
|
} catch {
|
|
27932
28754
|
resolve(false);
|
|
27933
28755
|
}
|
|
@@ -27967,6 +28789,8 @@ var AuthHelper = class {
|
|
|
27967
28789
|
urlTimer = null;
|
|
27968
28790
|
activeHomeDir = null;
|
|
27969
28791
|
activeAccountId = null;
|
|
28792
|
+
/** Monotonic begin() generation; a superseded begin must not write state. */
|
|
28793
|
+
beginSeq = 0;
|
|
27970
28794
|
signedInCache = null;
|
|
27971
28795
|
signedInFlight = null;
|
|
27972
28796
|
constructor(bin) {
|
|
@@ -28084,6 +28908,7 @@ var AuthHelper = class {
|
|
|
28084
28908
|
}
|
|
28085
28909
|
/** Start (or restart) the login flow; resolves with the consent URL. */
|
|
28086
28910
|
async begin(homeDir, accountId, proxyUrl) {
|
|
28911
|
+
const seq = ++this.beginSeq;
|
|
28087
28912
|
this.activeHomeDir = homeDir || null;
|
|
28088
28913
|
this.activeAccountId = accountId || null;
|
|
28089
28914
|
if (!this.startProbe(homeDir, proxyUrl)) {
|
|
@@ -28108,6 +28933,7 @@ var AuthHelper = class {
|
|
|
28108
28933
|
resolve(null);
|
|
28109
28934
|
}, URL_WAIT_MS);
|
|
28110
28935
|
});
|
|
28936
|
+
if (seq !== this.beginSeq) return this.status();
|
|
28111
28937
|
if (this.urlTimer) clearTimeout(this.urlTimer);
|
|
28112
28938
|
this.urlTimer = null;
|
|
28113
28939
|
if (!url) {
|
|
@@ -28128,6 +28954,7 @@ var AuthHelper = class {
|
|
|
28128
28954
|
margin: 1
|
|
28129
28955
|
});
|
|
28130
28956
|
} catch {}
|
|
28957
|
+
if (seq !== this.beginSeq) return this.status();
|
|
28131
28958
|
this.state = {
|
|
28132
28959
|
phase: "pending",
|
|
28133
28960
|
accountId: this.activeAccountId || void 0,
|
|
@@ -28169,7 +28996,11 @@ var AuthHelper = class {
|
|
|
28169
28996
|
};
|
|
28170
28997
|
return this.status();
|
|
28171
28998
|
}
|
|
28172
|
-
|
|
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);
|
|
28173
29004
|
if (outcome === null) {
|
|
28174
29005
|
this.state = {
|
|
28175
29006
|
phase: "failed",
|
|
@@ -28216,7 +29047,9 @@ var AuthHelper = class {
|
|
|
28216
29047
|
}
|
|
28217
29048
|
if (this.urlTimer) clearTimeout(this.urlTimer);
|
|
28218
29049
|
this.urlTimer = null;
|
|
29050
|
+
const waiter = this.urlWaiter;
|
|
28219
29051
|
this.urlWaiter = null;
|
|
29052
|
+
waiter?.(null);
|
|
28220
29053
|
}
|
|
28221
29054
|
dispose() {
|
|
28222
29055
|
this.cancel();
|
|
@@ -28224,6 +29057,73 @@ var AuthHelper = class {
|
|
|
28224
29057
|
}
|
|
28225
29058
|
};
|
|
28226
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
|
|
28227
29127
|
//#region src/host/commands.ts
|
|
28228
29128
|
const HELP = [
|
|
28229
29129
|
"**/agy** — Antigravity (agy CLI) bridge",
|
|
@@ -28275,7 +29175,7 @@ async function handle(deps, raw) {
|
|
|
28275
29175
|
const sec = Math.ceil((cd.cooldownUntil - Date.now()) / 1e3);
|
|
28276
29176
|
cds.push(`${fam} cooldown: ${sec}s left (${cd.reason})`);
|
|
28277
29177
|
}
|
|
28278
|
-
lines.push(`${i + 1}. **${a.alias}** (${a.email || a.id})${isPrimary ? " ⭐ primary" : ""}`, ` - Proxy: ${a.proxyUrl
|
|
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");
|
|
28279
29179
|
}
|
|
28280
29180
|
return ok(lines.join("\n"));
|
|
28281
29181
|
}
|
|
@@ -28283,10 +29183,12 @@ async function handle(deps, raw) {
|
|
|
28283
29183
|
const pool = deps.pool?.();
|
|
28284
29184
|
if (!pool) return err("Account pool not available");
|
|
28285
29185
|
const acc = pool.createAccountSlot(arg || void 0);
|
|
29186
|
+
pool.markAuthRequired(acc.id, "not signed in yet — complete the terminal login");
|
|
28286
29187
|
return ok([
|
|
28287
29188
|
`**Account slot created: [${acc.alias}]** (id: \`${acc.id}\`)`,
|
|
28288
29189
|
`- Isolated directory: \`${acc.dir}\``,
|
|
28289
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.`,
|
|
28290
29192
|
`- Once logged in, run: \`/agy pool\` or click 🔄 刷新额度 in WebUI.`
|
|
28291
29193
|
].join("\n"));
|
|
28292
29194
|
}
|
|
@@ -28387,7 +29289,12 @@ async function renderStatus(deps) {
|
|
|
28387
29289
|
const cfg = deps.cfg();
|
|
28388
29290
|
const bin = deps.bin();
|
|
28389
29291
|
const authHelper = deps.auth();
|
|
28390
|
-
|
|
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;
|
|
28391
29298
|
const cat = deps.catalog().get();
|
|
28392
29299
|
const bindings = Object.keys(deps.store().all()).length;
|
|
28393
29300
|
const last = deps.lastRun();
|
|
@@ -28405,6 +29312,7 @@ async function renderStatus(deps) {
|
|
|
28405
29312
|
"- catalog: " + cat.models.length + " models — " + cat.source + (cat.lastError === void 0 ? "" : " — last error: " + cat.lastError),
|
|
28406
29313
|
"- conversation bindings: " + bindings,
|
|
28407
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)" : "")] : [],
|
|
28408
29316
|
...last && last.toolErrors.length > 0 ? [
|
|
28409
29317
|
"- last run tool errors (" + last.toolErrors.length + "): " + last.toolErrors.join(" | "),
|
|
28410
29318
|
...errorKinds.includes("missing_file") ? ["- Guidance: `missing_file` — verify the expected workspace/artifact path for this conversation; do not invent paths or bypass protections."] : [],
|
|
@@ -28426,50 +29334,6 @@ function err(text) {
|
|
|
28426
29334
|
};
|
|
28427
29335
|
}
|
|
28428
29336
|
//#endregion
|
|
28429
|
-
//#region src/host/diagnostics.ts
|
|
28430
|
-
/** Redact auth URLs, long tokens, and authorization codes. */
|
|
28431
|
-
function redactLine(line) {
|
|
28432
|
-
let out = line;
|
|
28433
|
-
out = out.replace(/https:\/\/accounts\.google\.com\/\S+/g, "<auth-url-redacted>");
|
|
28434
|
-
out = out.replace(/\b[0-9]{4,}\//g, "4/<code-redacted>");
|
|
28435
|
-
out = out.replace(/ya29\.[A-Za-z0-9._-]+/g, "<oauth-token-redacted>");
|
|
28436
|
-
out = out.replace(/Bearer\s+\S+/gi, "Bearer <redacted>");
|
|
28437
|
-
return out;
|
|
28438
|
-
}
|
|
28439
|
-
function writeDoctorReport(deps) {
|
|
28440
|
-
const cfg = deps.cfg();
|
|
28441
|
-
const cat = deps.catalog().get();
|
|
28442
|
-
const bindings = deps.store().all();
|
|
28443
|
-
const lines = [];
|
|
28444
|
-
lines.push("# dsh-agy-link diagnostic report");
|
|
28445
|
-
lines.push("");
|
|
28446
|
-
lines.push("- generated: " + (/* @__PURE__ */ new Date()).toISOString());
|
|
28447
|
-
lines.push("- agy binary: " + (deps.bin() ?? "NOT FOUND"));
|
|
28448
|
-
lines.push("- agy version: " + (deps.version() ?? "unknown"));
|
|
28449
|
-
lines.push("- plugin config: " + JSON.stringify({
|
|
28450
|
-
...cfg,
|
|
28451
|
-
extraArgs: cfg.extraArgs.length
|
|
28452
|
-
}));
|
|
28453
|
-
lines.push("- catalog: " + cat.source + " — " + cat.models.length + " models" + (cat.lastError === void 0 ? "" : " — error: " + cat.lastError));
|
|
28454
|
-
for (const m of cat.models) lines.push(" - " + m.id + (m.efforts ? " [" + m.efforts.join("/") + "]" : ""));
|
|
28455
|
-
lines.push("- conversation bindings: " + Object.keys(bindings).length);
|
|
28456
|
-
for (const [k, b] of Object.entries(bindings)) lines.push(" - " + k + " -> " + b.conversationId + " @ " + new Date(b.updatedAt).toISOString());
|
|
28457
|
-
lines.push("- node: " + process.version + " — " + process.platform + " " + process.arch);
|
|
28458
|
-
lines.push("");
|
|
28459
|
-
lines.push("## last stream-json stdout (redacted, tail)");
|
|
28460
|
-
lines.push("");
|
|
28461
|
-
lines.push("```");
|
|
28462
|
-
const recent = deps.recentLines();
|
|
28463
|
-
for (const l of recent.slice(-400)) lines.push(redactLine(l));
|
|
28464
|
-
lines.push("```");
|
|
28465
|
-
lines.push("");
|
|
28466
|
-
const dir = join(stateDir(), "diagnostics");
|
|
28467
|
-
mkdirSync(dir, { recursive: true });
|
|
28468
|
-
const file = join(dir, "doctor-" + (/* @__PURE__ */ new Date()).toISOString().replace(/[^0-9]/g, "").slice(0, 14) + ".md");
|
|
28469
|
-
writeFileSync(file, lines.join("\n"), "utf8");
|
|
28470
|
-
return file;
|
|
28471
|
-
}
|
|
28472
|
-
//#endregion
|
|
28473
29337
|
//#region src/host/pool.ts
|
|
28474
29338
|
function defaultPoolDir() {
|
|
28475
29339
|
const dshState = process.env.DSH_STATE_DIR || join(homedir(), ".dsh");
|
|
@@ -28479,12 +29343,23 @@ var AccountPoolManager = class {
|
|
|
28479
29343
|
data;
|
|
28480
29344
|
baseDir;
|
|
28481
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 = "";
|
|
28482
29355
|
constructor(baseDir = defaultPoolDir()) {
|
|
28483
29356
|
this.baseDir = baseDir;
|
|
28484
29357
|
this.file = join(baseDir, "pool.json");
|
|
28485
29358
|
this.data = this.load();
|
|
28486
29359
|
this.bootstrapDefaultAccount();
|
|
28487
29360
|
this.normalizeLegacyPrimary();
|
|
29361
|
+
this.persistedJson = JSON.stringify(this.data);
|
|
29362
|
+
for (const acc of this.data.accounts) this.knownIds.add(acc.id);
|
|
28488
29363
|
}
|
|
28489
29364
|
load() {
|
|
28490
29365
|
try {
|
|
@@ -28492,7 +29367,11 @@ var AccountPoolManager = class {
|
|
|
28492
29367
|
const raw = readFileSync(this.file, "utf8");
|
|
28493
29368
|
const parsed = JSON.parse(raw);
|
|
28494
29369
|
if (parsed && Array.isArray(parsed.accounts)) {
|
|
28495
|
-
for (const acc of parsed.accounts)
|
|
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;
|
|
28496
29375
|
for (const [fam, cd] of Object.entries(acc.cooldowns)) if (cd && cd.reason === "agy run aborted by caller") delete acc.cooldowns[fam];
|
|
28497
29376
|
}
|
|
28498
29377
|
return {
|
|
@@ -28501,16 +29380,45 @@ var AccountPoolManager = class {
|
|
|
28501
29380
|
};
|
|
28502
29381
|
}
|
|
28503
29382
|
}
|
|
28504
|
-
} catch {
|
|
29383
|
+
} catch {
|
|
29384
|
+
try {
|
|
29385
|
+
if (existsSync(this.file)) copyFileSync(this.file, this.file + ".corrupt-" + Date.now());
|
|
29386
|
+
} catch {}
|
|
29387
|
+
}
|
|
28505
29388
|
return defaultPoolData();
|
|
28506
29389
|
}
|
|
28507
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 {}
|
|
28508
29409
|
try {
|
|
28509
29410
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
29411
|
+
if (serialized === this.persistedJson && merged === this.data) return;
|
|
28510
29412
|
const tmp = join(dirname(this.file), ".pool.json.tmp");
|
|
28511
|
-
writeFileSync(tmp, JSON.stringify(
|
|
29413
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), "utf8");
|
|
28512
29414
|
renameSync(tmp, this.file);
|
|
28513
|
-
|
|
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
|
+
}
|
|
28514
29422
|
}
|
|
28515
29423
|
/**
|
|
28516
29424
|
* Bootstraps the primary account on first start.
|
|
@@ -28603,6 +29511,7 @@ var AccountPoolManager = class {
|
|
|
28603
29511
|
quotas: {}
|
|
28604
29512
|
};
|
|
28605
29513
|
this.data.accounts.push(newAccount);
|
|
29514
|
+
this.knownIds.add(newAccount.id);
|
|
28606
29515
|
this.persist();
|
|
28607
29516
|
return newAccount;
|
|
28608
29517
|
}
|
|
@@ -28680,6 +29589,7 @@ var AccountPoolManager = class {
|
|
|
28680
29589
|
quotas: {}
|
|
28681
29590
|
};
|
|
28682
29591
|
this.data.accounts.push(newAccount);
|
|
29592
|
+
this.knownIds.add(newAccount.id);
|
|
28683
29593
|
this.persist();
|
|
28684
29594
|
return newAccount;
|
|
28685
29595
|
}
|
|
@@ -28687,12 +29597,16 @@ var AccountPoolManager = class {
|
|
|
28687
29597
|
const idx = this.data.accounts.findIndex((a) => a.id === id);
|
|
28688
29598
|
if (idx === -1) return false;
|
|
28689
29599
|
const [removed] = this.data.accounts.splice(idx, 1);
|
|
28690
|
-
if (removed)
|
|
28691
|
-
|
|
28692
|
-
|
|
28693
|
-
|
|
28694
|
-
|
|
28695
|
-
|
|
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
|
+
}
|
|
28696
29610
|
if (this.data.primaryAccountId === id) this.data.primaryAccountId = void 0;
|
|
28697
29611
|
if (this.data.lastActiveAccountId === id) this.data.lastActiveAccountId = void 0;
|
|
28698
29612
|
if (this.data.activeAccountIds) {
|
|
@@ -28814,13 +29728,13 @@ var AccountPoolManager = class {
|
|
|
28814
29728
|
*/
|
|
28815
29729
|
isAccountEligible(acc, family, now = Date.now()) {
|
|
28816
29730
|
if (!acc.enabled || acc.authRequired) return false;
|
|
28817
|
-
const cd = acc.cooldowns[family];
|
|
29731
|
+
const cd = acc.cooldowns?.[family];
|
|
28818
29732
|
if (cd && cd.cooldownUntil > now) return false;
|
|
28819
|
-
const quota = acc.quotas[family];
|
|
29733
|
+
const quota = acc.quotas?.[family];
|
|
28820
29734
|
if (quota && typeof quota.remainingFraction === "number" && quota.remainingFraction <= .02) {
|
|
28821
29735
|
if (quota.resetTime) {
|
|
28822
29736
|
const resetMs = Date.parse(quota.resetTime);
|
|
28823
|
-
if (
|
|
29737
|
+
if (Number.isNaN(resetMs) || resetMs > now) return false;
|
|
28824
29738
|
}
|
|
28825
29739
|
}
|
|
28826
29740
|
if (quota && typeof quota.weeklyFraction === "number" && quota.weeklyFraction <= .01) {
|
|
@@ -28852,7 +29766,7 @@ var AccountPoolManager = class {
|
|
|
28852
29766
|
* sooner of the 5h window reset and the weekly cap reset. NaN when unknown.
|
|
28853
29767
|
*/
|
|
28854
29768
|
nextQuotaResetAt(acc, family) {
|
|
28855
|
-
const q = acc.quotas[family];
|
|
29769
|
+
const q = acc.quotas?.[family];
|
|
28856
29770
|
const stamps = [q?.resetTime, q?.weeklyResetTime].filter((v) => typeof v === "string" && v !== "").map((v) => Date.parse(v)).filter((t) => !Number.isNaN(t));
|
|
28857
29771
|
return stamps.length === 0 ? NaN : Math.min(...stamps);
|
|
28858
29772
|
}
|
|
@@ -28916,10 +29830,11 @@ var AccountPoolManager = class {
|
|
|
28916
29830
|
};
|
|
28917
29831
|
if (email) acc.email = email;
|
|
28918
29832
|
const now = Date.now();
|
|
29833
|
+
const futureReset = (iso) => typeof iso === "string" && iso !== "" && !Number.isNaN(Date.parse(iso)) && Date.parse(iso) > now;
|
|
28919
29834
|
for (const [famKey, info] of Object.entries(quotas)) {
|
|
28920
29835
|
if (!info) continue;
|
|
28921
|
-
const isExhausted5h = typeof info.remainingFraction === "number" && info.remainingFraction <= .02 &&
|
|
28922
|
-
const isExhaustedWeekly = typeof info.weeklyFraction === "number" && info.weeklyFraction <= .01 &&
|
|
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);
|
|
28923
29838
|
if (isExhausted5h || isExhaustedWeekly) {
|
|
28924
29839
|
if (this.data.activeAccountIds?.[famKey] === id || this.data.lastActiveAccountId === id) this.selectAccount(famKey);
|
|
28925
29840
|
}
|
|
@@ -28932,6 +29847,7 @@ var AccountPoolManager = class {
|
|
|
28932
29847
|
recordFailure(id, family, reason, serverResetTime) {
|
|
28933
29848
|
const acc = this.getAccount(id);
|
|
28934
29849
|
if (!acc) return;
|
|
29850
|
+
acc.cooldowns ??= {};
|
|
28935
29851
|
const failures = (acc.cooldowns[family]?.consecutiveFailures ?? 0) + 1;
|
|
28936
29852
|
let cooldownUntil;
|
|
28937
29853
|
const parsedDuration = parseResetDurationMs(serverResetTime || reason);
|
|
@@ -28961,17 +29877,21 @@ var AccountPoolManager = class {
|
|
|
28961
29877
|
delete acc.authError;
|
|
28962
29878
|
delete acc.authMarkedAt;
|
|
28963
29879
|
}
|
|
28964
|
-
if (acc.cooldowns[family]) delete acc.cooldowns[family];
|
|
29880
|
+
if (acc.cooldowns?.[family]) delete acc.cooldowns[family];
|
|
28965
29881
|
this.persist();
|
|
28966
29882
|
}
|
|
28967
29883
|
clearCooldown(id, family) {
|
|
28968
29884
|
if (id) {
|
|
28969
29885
|
const acc = this.getAccount(id);
|
|
28970
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 ??= {};
|
|
28971
29892
|
if (family) delete acc.cooldowns[family];
|
|
28972
29893
|
else acc.cooldowns = {};
|
|
28973
|
-
}
|
|
28974
|
-
else acc.cooldowns = {};
|
|
29894
|
+
}
|
|
28975
29895
|
this.persist();
|
|
28976
29896
|
}
|
|
28977
29897
|
/**
|
|
@@ -29019,13 +29939,42 @@ var AccountPoolManager = class {
|
|
|
29019
29939
|
let earliest = null;
|
|
29020
29940
|
for (const acc of this.data.accounts) {
|
|
29021
29941
|
if (!acc.enabled) continue;
|
|
29022
|
-
const cd = acc.cooldowns[family];
|
|
29942
|
+
const cd = acc.cooldowns?.[family];
|
|
29023
29943
|
if (cd && cd.cooldownUntil > now) {
|
|
29024
29944
|
if (earliest === null || cd.cooldownUntil < earliest) earliest = cd.cooldownUntil;
|
|
29025
29945
|
}
|
|
29026
29946
|
}
|
|
29027
29947
|
return earliest !== null ? Math.max(0, earliest - now) : null;
|
|
29028
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
|
+
}
|
|
29029
29978
|
};
|
|
29030
29979
|
//#endregion
|
|
29031
29980
|
//#region src/host/pool-auth.ts
|
|
@@ -29056,9 +30005,12 @@ var PoolAuthFlow = class {
|
|
|
29056
30005
|
...this.statusValue,
|
|
29057
30006
|
...patch
|
|
29058
30007
|
};
|
|
29059
|
-
if (patch.phase === "done")
|
|
29060
|
-
|
|
29061
|
-
|
|
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
|
+
}
|
|
29062
30014
|
}
|
|
29063
30015
|
/**
|
|
29064
30016
|
* Shared starter: PKCE + loopback listener + server-side browser open.
|
|
@@ -29100,7 +30052,7 @@ var PoolAuthFlow = class {
|
|
|
29100
30052
|
code: browserOpened ? void 0 : "browser_manual"
|
|
29101
30053
|
});
|
|
29102
30054
|
this.log(`pool auth begun (${mode} mode, browserOpened=${browserOpened}${active.primary ? ", primary" : ""})`);
|
|
29103
|
-
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) => {
|
|
29104
30056
|
if (!this.flow || this.flow.listener !== listener) return;
|
|
29105
30057
|
this.fail("callback_failed", err);
|
|
29106
30058
|
});
|
|
@@ -29151,16 +30103,27 @@ var PoolAuthFlow = class {
|
|
|
29151
30103
|
phase: "waiting",
|
|
29152
30104
|
code: "invalid_state"
|
|
29153
30105
|
};
|
|
29154
|
-
await this.finishWithCode(parsed.code, parsed.state ?? flow.state);
|
|
30106
|
+
await this.finishWithCode(parsed.code, parsed.state ?? flow.state, flow);
|
|
29155
30107
|
const after = this.status();
|
|
29156
30108
|
return {
|
|
29157
30109
|
...after,
|
|
29158
30110
|
ok: after.phase === "done"
|
|
29159
30111
|
};
|
|
29160
30112
|
}
|
|
29161
|
-
|
|
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) {
|
|
29162
30124
|
const flow = this.flow;
|
|
29163
30125
|
if (!flow || this.statusValue.phase !== "waiting") return;
|
|
30126
|
+
if (owner !== void 0 && owner !== flow) return;
|
|
29164
30127
|
if (returnedState !== flow.state) {
|
|
29165
30128
|
this.fail("invalid_state");
|
|
29166
30129
|
return;
|
|
@@ -29315,30 +30278,40 @@ function parseGoKeyringPayload(raw) {
|
|
|
29315
30278
|
}
|
|
29316
30279
|
}
|
|
29317
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
|
+
/**
|
|
29318
30299
|
* Reads the active primary Antigravity OAuth token from the Linux Secret
|
|
29319
30300
|
* Service (GNOME Keyring / KDE Wallet via FreeDesktop secrets). agy 1.1.15+
|
|
29320
30301
|
* on Linux stores primary credentials via go-keyring under service "gemini"
|
|
29321
30302
|
* / username "antigravity" — the same JSON shape as the macOS Keychain.
|
|
29322
30303
|
* GH #8: without this reader, Linux quota resolution always returned null.
|
|
29323
30304
|
*/
|
|
29324
|
-
function readLinuxSecretToken() {
|
|
30305
|
+
async function readLinuxSecretToken() {
|
|
29325
30306
|
if (process.platform !== "linux") return null;
|
|
29326
30307
|
try {
|
|
29327
|
-
const raw =
|
|
30308
|
+
const raw = (await execFileT("secret-tool", [
|
|
29328
30309
|
"lookup",
|
|
29329
30310
|
"service",
|
|
29330
30311
|
"gemini",
|
|
29331
30312
|
"username",
|
|
29332
30313
|
"antigravity"
|
|
29333
|
-
],
|
|
29334
|
-
encoding: "utf8",
|
|
29335
|
-
timeout: 3e3,
|
|
29336
|
-
stdio: [
|
|
29337
|
-
"ignore",
|
|
29338
|
-
"pipe",
|
|
29339
|
-
"ignore"
|
|
29340
|
-
]
|
|
29341
|
-
}).trim();
|
|
30314
|
+
], 3e3)).trim();
|
|
29342
30315
|
if (raw) {
|
|
29343
30316
|
const tok = parseGoKeyringPayload(raw);
|
|
29344
30317
|
if (tok) return tok;
|
|
@@ -29360,15 +30333,7 @@ function readLinuxSecretToken() {
|
|
|
29360
30333
|
" pass"
|
|
29361
30334
|
].join("\n");
|
|
29362
30335
|
for (const pyBin of ["python3", "python"]) try {
|
|
29363
|
-
const raw =
|
|
29364
|
-
encoding: "utf8",
|
|
29365
|
-
timeout: 4e3,
|
|
29366
|
-
stdio: [
|
|
29367
|
-
"ignore",
|
|
29368
|
-
"pipe",
|
|
29369
|
-
"ignore"
|
|
29370
|
-
]
|
|
29371
|
-
}).trim();
|
|
30336
|
+
const raw = (await execFileT(pyBin, ["-c", pyScript], 4e3)).trim();
|
|
29372
30337
|
if (raw) {
|
|
29373
30338
|
const tok = parseGoKeyringPayload(raw);
|
|
29374
30339
|
if (tok) return tok;
|
|
@@ -29381,25 +30346,17 @@ function readLinuxSecretToken() {
|
|
|
29381
30346
|
* agy 1.1.15+ on macOS stores primary credentials via go-keyring in the Keychain
|
|
29382
30347
|
* under service "gemini" / account "antigravity" (base64-encoded JSON).
|
|
29383
30348
|
*/
|
|
29384
|
-
function readMacKeychainToken() {
|
|
30349
|
+
async function readMacKeychainToken() {
|
|
29385
30350
|
if (process.platform !== "darwin") return null;
|
|
29386
30351
|
try {
|
|
29387
|
-
const raw =
|
|
30352
|
+
const raw = (await execFileT("security", [
|
|
29388
30353
|
"find-generic-password",
|
|
29389
30354
|
"-s",
|
|
29390
30355
|
"gemini",
|
|
29391
30356
|
"-a",
|
|
29392
30357
|
"antigravity",
|
|
29393
30358
|
"-w"
|
|
29394
|
-
],
|
|
29395
|
-
encoding: "utf8",
|
|
29396
|
-
timeout: 3e3,
|
|
29397
|
-
stdio: [
|
|
29398
|
-
"ignore",
|
|
29399
|
-
"pipe",
|
|
29400
|
-
"ignore"
|
|
29401
|
-
]
|
|
29402
|
-
}).trim();
|
|
30359
|
+
], 3e3)).trim();
|
|
29403
30360
|
if (!raw) return null;
|
|
29404
30361
|
return parseGoKeyringPayload(raw);
|
|
29405
30362
|
} catch {
|
|
@@ -29414,58 +30371,48 @@ function readMacKeychainToken() {
|
|
|
29414
30371
|
* empty on Windows: the on-disk `antigravity-oauth-token` file is often
|
|
29415
30372
|
* absent while agy itself still authenticates via CredRead (issue #30).
|
|
29416
30373
|
*/
|
|
29417
|
-
function readWindowsCredentialToken() {
|
|
30374
|
+
async function readWindowsCredentialToken() {
|
|
29418
30375
|
if (process.platform !== "win32") return null;
|
|
29419
30376
|
try {
|
|
29420
|
-
const
|
|
29421
|
-
"$sig = @'",
|
|
29422
|
-
"using System;",
|
|
29423
|
-
"using System.Runtime.InteropServices;",
|
|
29424
|
-
"public class AgyCred {",
|
|
29425
|
-
" [DllImport(\"advapi32.dll\", SetLastError=true, CharSet=CharSet.Unicode)]",
|
|
29426
|
-
" public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);",
|
|
29427
|
-
" [DllImport(\"advapi32.dll\", SetLastError=true)]",
|
|
29428
|
-
" public static extern void CredFree(IntPtr cred);",
|
|
29429
|
-
" [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]",
|
|
29430
|
-
" public struct CREDENTIAL {",
|
|
29431
|
-
" public int Flags; public int Type; public IntPtr TargetName;",
|
|
29432
|
-
" public IntPtr Comment; public long LastWritten; public int CredentialBlobSize;",
|
|
29433
|
-
" public IntPtr CredentialBlob; public int Persist; public int AttributeCount;",
|
|
29434
|
-
" public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;",
|
|
29435
|
-
" }",
|
|
29436
|
-
" public static byte[] Read(string target) {",
|
|
29437
|
-
" IntPtr p;",
|
|
29438
|
-
" if (!CredRead(target, 1, 0, out p)) return null;",
|
|
29439
|
-
" CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));",
|
|
29440
|
-
" byte[] b = new byte[c.CredentialBlobSize];",
|
|
29441
|
-
" Marshal.Copy(c.CredentialBlob, b, 0, c.CredentialBlobSize);",
|
|
29442
|
-
" CredFree(p);",
|
|
29443
|
-
" return b;",
|
|
29444
|
-
" }",
|
|
29445
|
-
"}",
|
|
29446
|
-
"'@",
|
|
29447
|
-
"Add-Type -TypeDefinition $sig -ErrorAction Stop",
|
|
29448
|
-
"$b = [AgyCred]::Read('gemini:antigravity')",
|
|
29449
|
-
"if ($b -eq $null) { exit 1 }",
|
|
29450
|
-
"[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($b))"
|
|
29451
|
-
].join("\n");
|
|
29452
|
-
const raw = execFileSync("powershell.exe", [
|
|
30377
|
+
const raw = (await execFileT("powershell.exe", [
|
|
29453
30378
|
"-NoProfile",
|
|
29454
30379
|
"-NonInteractive",
|
|
29455
30380
|
"-ExecutionPolicy",
|
|
29456
30381
|
"Bypass",
|
|
29457
30382
|
"-Command",
|
|
29458
|
-
|
|
29459
|
-
|
|
29460
|
-
|
|
29461
|
-
|
|
29462
|
-
|
|
29463
|
-
"
|
|
29464
|
-
"
|
|
29465
|
-
"
|
|
29466
|
-
|
|
29467
|
-
|
|
29468
|
-
|
|
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();
|
|
29469
30416
|
if (!raw) return null;
|
|
29470
30417
|
return parseGoKeyringPayload(raw);
|
|
29471
30418
|
} catch {
|
|
@@ -29476,6 +30423,8 @@ var QuotaService = class {
|
|
|
29476
30423
|
pool;
|
|
29477
30424
|
globalProxy;
|
|
29478
30425
|
preferredEndpointIndex = 0;
|
|
30426
|
+
/** accountId -> in-flight getValidAccessToken attempt (dedup). */
|
|
30427
|
+
tokenRefreshInFlight = /* @__PURE__ */ new Map();
|
|
29479
30428
|
/**
|
|
29480
30429
|
* @param globalProxy resolves the plugin-wide proxy URL; used when the
|
|
29481
30430
|
* account has no dedicated proxyUrl. Injected so tests stay network-free.
|
|
@@ -29496,7 +30445,7 @@ var QuotaService = class {
|
|
|
29496
30445
|
* Read the system-HOME Keychain credential. Protected so tests (and future
|
|
29497
30446
|
* platforms) can substitute the reader without touching the real Keychain.
|
|
29498
30447
|
*/
|
|
29499
|
-
readSystemKeychainToken() {
|
|
30448
|
+
async readSystemKeychainToken() {
|
|
29500
30449
|
if (process.platform === "darwin") return readMacKeychainToken();
|
|
29501
30450
|
if (process.platform === "linux") return readLinuxSecretToken();
|
|
29502
30451
|
if (process.platform === "win32") return readWindowsCredentialToken();
|
|
@@ -29514,7 +30463,7 @@ var QuotaService = class {
|
|
|
29514
30463
|
* WRONG account's numbers). Isolated pool accounts only ever read their
|
|
29515
30464
|
* own directory's file; the Keychain is one shared slot they must not see.
|
|
29516
30465
|
*/
|
|
29517
|
-
getStoredToken(account) {
|
|
30466
|
+
async getStoredToken(account) {
|
|
29518
30467
|
const disk = (() => {
|
|
29519
30468
|
const file = this.getTokenFilePath(account);
|
|
29520
30469
|
if (!existsSync(file)) return null;
|
|
@@ -29525,7 +30474,7 @@ var QuotaService = class {
|
|
|
29525
30474
|
return null;
|
|
29526
30475
|
})();
|
|
29527
30476
|
if (account.systemHome || !account.dir) {
|
|
29528
|
-
const keychainToken = this.readSystemKeychainToken();
|
|
30477
|
+
const keychainToken = await this.readSystemKeychainToken();
|
|
29529
30478
|
if (keychainToken && (keychainToken.accessToken || keychainToken.refreshToken)) return keychainToken;
|
|
29530
30479
|
}
|
|
29531
30480
|
return disk;
|
|
@@ -29550,16 +30499,33 @@ var QuotaService = class {
|
|
|
29550
30499
|
raw.access_token = tokens.access_token;
|
|
29551
30500
|
if (tokens.expiryMs) raw.expiry = tokens.expiryMs;
|
|
29552
30501
|
}
|
|
29553
|
-
writeFileSync(file, JSON.stringify(raw),
|
|
30502
|
+
writeFileSync(file, JSON.stringify(raw), {
|
|
30503
|
+
encoding: "utf8",
|
|
30504
|
+
mode: 384
|
|
30505
|
+
});
|
|
29554
30506
|
} catch {}
|
|
29555
30507
|
}
|
|
29556
30508
|
/**
|
|
29557
30509
|
* Get a valid access token, refreshing via refresh_token when expired.
|
|
29558
30510
|
* Uses the public Antigravity client credentials (env-overridable), so
|
|
29559
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).
|
|
29560
30517
|
*/
|
|
29561
30518
|
async getValidAccessToken(account) {
|
|
29562
|
-
const
|
|
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);
|
|
29563
30529
|
if (!tok) return null;
|
|
29564
30530
|
if (tok.accessToken && (!tok.expiryMs || tok.expiryMs > Date.now() + 6e4)) return tok.accessToken;
|
|
29565
30531
|
if (tok.refreshToken) try {
|
|
@@ -29694,11 +30660,12 @@ var QuotaService = class {
|
|
|
29694
30660
|
let weeklyReset;
|
|
29695
30661
|
for (const b of group.buckets || []) {
|
|
29696
30662
|
const w = (b.window || b.bucketId || "").toLowerCase();
|
|
29697
|
-
|
|
29698
|
-
|
|
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;
|
|
29699
30666
|
fiveHourReset = b.resetTime;
|
|
29700
|
-
} else if (w.includes("weekly")) {
|
|
29701
|
-
weeklyFrac =
|
|
30667
|
+
} else if (w.includes("weekly") || w.includes("week")) {
|
|
30668
|
+
weeklyFrac = frac;
|
|
29702
30669
|
weeklyReset = b.resetTime;
|
|
29703
30670
|
}
|
|
29704
30671
|
}
|
|
@@ -29715,9 +30682,10 @@ var QuotaService = class {
|
|
|
29715
30682
|
if (discovered && discovered.models) for (const [modelId, entry] of Object.entries(discovered.models)) {
|
|
29716
30683
|
const fam = modelFamilyOf(modelId);
|
|
29717
30684
|
if (fam === "unknown") continue;
|
|
29718
|
-
const
|
|
30685
|
+
const rawRemaining = entry.quotaInfo?.remainingFraction;
|
|
30686
|
+
const remaining = typeof rawRemaining === "number" && Number.isFinite(rawRemaining) ? Math.min(1, Math.max(0, rawRemaining)) : void 0;
|
|
29719
30687
|
const resetTime = entry.quotaInfo?.resetTime;
|
|
29720
|
-
if (
|
|
30688
|
+
if (remaining === void 0) continue;
|
|
29721
30689
|
if (!familyModels[fam]) familyModels[fam] = [];
|
|
29722
30690
|
familyModels[fam].push({
|
|
29723
30691
|
modelId,
|
|
@@ -29765,7 +30733,7 @@ var QuotaService = class {
|
|
|
29765
30733
|
if (!force) {
|
|
29766
30734
|
const now = Date.now();
|
|
29767
30735
|
for (const acc of accounts) {
|
|
29768
|
-
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);
|
|
29769
30737
|
if (!acc.systemHome || !flagged) continue;
|
|
29770
30738
|
const detected = detectEmailFromAgyLogs(acc.systemHome || !acc.dir ? homedir() : acc.dir);
|
|
29771
30739
|
if (detected && detected !== acc.email) this.pool.resetAccountIdentity(acc.id, detected);
|
|
@@ -29950,15 +30918,39 @@ function writeMcpConfig(workspaceRoot, bridge) {
|
|
|
29950
30918
|
}
|
|
29951
30919
|
};
|
|
29952
30920
|
root.mcpServers = servers;
|
|
30921
|
+
let written = false;
|
|
29953
30922
|
try {
|
|
29954
30923
|
writeFileSync(file, JSON.stringify(root, null, 2) + "\n", "utf8");
|
|
30924
|
+
written = true;
|
|
29955
30925
|
} catch {
|
|
29956
30926
|
return () => {};
|
|
29957
30927
|
}
|
|
29958
30928
|
return () => {
|
|
29959
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;
|
|
29960
30947
|
if (previous === null) {
|
|
29961
|
-
|
|
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");
|
|
29962
30954
|
} else writeFileSync(file, previous, "utf8");
|
|
29963
30955
|
} catch {}
|
|
29964
30956
|
};
|
|
@@ -30279,6 +31271,7 @@ function apply(ctx, entryConfig = {}) {
|
|
|
30279
31271
|
catalogModels: sanitizeCatalogEntries(cat.models).entries,
|
|
30280
31272
|
hiddenModels: cfg.hiddenModels,
|
|
30281
31273
|
bindings: Object.keys(store.all()).length,
|
|
31274
|
+
credentialIsolationLimited: process.platform === "win32" && pool.getAccounts().some((a) => a.enabled && !a.systemHome),
|
|
30282
31275
|
lastRun
|
|
30283
31276
|
});
|
|
30284
31277
|
})();
|
|
@@ -30453,6 +31446,7 @@ function apply(ctx, entryConfig = {}) {
|
|
|
30453
31446
|
const body = await readBody(req);
|
|
30454
31447
|
const alias = typeof body.alias === "string" ? body.alias : void 0;
|
|
30455
31448
|
const acc = pool.createAccountSlot(alias);
|
|
31449
|
+
pool.markAuthRequired(acc.id, "not signed in yet — complete the terminal login");
|
|
30456
31450
|
if (body.autoOpenTerminal !== false && acc.dir) {
|
|
30457
31451
|
if (process.platform === "darwin") {
|
|
30458
31452
|
const script = `tell application "Terminal" to activate\ntell application "Terminal" to do script "export HOME='${acc.dir}'; agy"`;
|