dsh-agy-cli 0.0.0-stage → 0.12.1

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.
Files changed (91) hide show
  1. package/LICENSE +31 -0
  2. package/README.en.md +48 -0
  3. package/README.md +67 -2
  4. package/agents/dsh-agy-image-view.md +17 -0
  5. package/agents/dsh-agy-mcp-executor.md +32 -0
  6. package/agents/dsh-agy-read-only.md +20 -0
  7. package/agents/dsh-agy-tool-free.md +16 -0
  8. package/agents/dsh-agy-workspace-write.md +23 -0
  9. package/bin/dsh-agy-cli.js +9 -0
  10. package/bin/win32-x64/agy-launcher.exe +0 -0
  11. package/cordis.patch.yml +16 -0
  12. package/dist/client.js +4259 -0
  13. package/lib/agent-installer.d.ts +66 -0
  14. package/lib/agent-installer.js +147 -0
  15. package/lib/agent-presets.d.ts +42 -0
  16. package/lib/agent-presets.js +130 -0
  17. package/lib/agents-cli.d.ts +3 -0
  18. package/lib/agents-cli.js +110 -0
  19. package/lib/agy/diagnostics.d.ts +29 -0
  20. package/lib/agy/diagnostics.js +109 -0
  21. package/lib/agy/errors.d.ts +20 -0
  22. package/lib/agy/errors.js +77 -0
  23. package/lib/agy/experimental-transport.d.ts +79 -0
  24. package/lib/agy/experimental-transport.js +612 -0
  25. package/lib/agy/keepalive.d.ts +140 -0
  26. package/lib/agy/keepalive.js +445 -0
  27. package/lib/agy/limiter.d.ts +32 -0
  28. package/lib/agy/limiter.js +111 -0
  29. package/lib/agy/log.d.ts +214 -0
  30. package/lib/agy/log.js +503 -0
  31. package/lib/agy/models.d.ts +49 -0
  32. package/lib/agy/models.js +203 -0
  33. package/lib/agy/overrides.d.ts +17 -0
  34. package/lib/agy/overrides.js +73 -0
  35. package/lib/agy/parser.d.ts +102 -0
  36. package/lib/agy/parser.js +492 -0
  37. package/lib/agy/persistent-transport.d.ts +101 -0
  38. package/lib/agy/persistent-transport.js +753 -0
  39. package/lib/agy/process.d.ts +144 -0
  40. package/lib/agy/process.js +452 -0
  41. package/lib/agy/redact.d.ts +3 -0
  42. package/lib/agy/redact.js +24 -0
  43. package/lib/agy/stream-protocol.d.ts +50 -0
  44. package/lib/agy/stream-protocol.js +156 -0
  45. package/lib/agy/windows-launcher.d.ts +21 -0
  46. package/lib/agy/windows-launcher.js +76 -0
  47. package/lib/diagnostics.d.ts +78 -0
  48. package/lib/diagnostics.js +162 -0
  49. package/lib/doctor.d.ts +138 -0
  50. package/lib/doctor.js +940 -0
  51. package/lib/dsh/context.d.ts +133 -0
  52. package/lib/dsh/context.js +352 -0
  53. package/lib/dsh-home.d.ts +6 -0
  54. package/lib/dsh-home.js +11 -0
  55. package/lib/index.d.ts +54 -0
  56. package/lib/index.js +267 -0
  57. package/lib/mcp/agy-registration.d.ts +70 -0
  58. package/lib/mcp/agy-registration.js +245 -0
  59. package/lib/mcp/bridge.d.ts +73 -0
  60. package/lib/mcp/bridge.js +295 -0
  61. package/lib/mcp/endpoint.d.ts +41 -0
  62. package/lib/mcp/endpoint.js +142 -0
  63. package/lib/mcp/protocol.d.ts +107 -0
  64. package/lib/mcp/protocol.js +197 -0
  65. package/lib/mcp/tool-projection.d.ts +64 -0
  66. package/lib/mcp/tool-projection.js +168 -0
  67. package/lib/provider/agy.d.ts +153 -0
  68. package/lib/provider/agy.js +2159 -0
  69. package/lib/provider/config.d.ts +226 -0
  70. package/lib/provider/config.js +294 -0
  71. package/lib/provider/error-codes.d.ts +22 -0
  72. package/lib/provider/error-codes.js +29 -0
  73. package/lib/provider/image-bridge.d.ts +60 -0
  74. package/lib/provider/image-bridge.js +290 -0
  75. package/lib/provider/mock.d.ts +15 -0
  76. package/lib/provider/mock.js +78 -0
  77. package/lib/provider/prompt-budget.d.ts +45 -0
  78. package/lib/provider/prompt-budget.js +596 -0
  79. package/lib/provider/serialize.d.ts +15 -0
  80. package/lib/provider/serialize.js +82 -0
  81. package/lib/provider/tool-protocol.d.ts +80 -0
  82. package/lib/provider/tool-protocol.js +848 -0
  83. package/lib/provider/tool-schema-file.d.ts +11 -0
  84. package/lib/provider/tool-schema-file.js +27 -0
  85. package/lib/session/file-store.d.ts +56 -0
  86. package/lib/session/file-store.js +186 -0
  87. package/lib/session/store.d.ts +49 -0
  88. package/lib/session/store.js +62 -0
  89. package/lib/web/router.d.ts +19 -0
  90. package/lib/web/router.js +994 -0
  91. package/package.json +115 -4
@@ -0,0 +1,994 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import tls from 'node:tls';
4
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { configuredModels, normalizeModelId, readConfigFlag } from '../provider/config.js';
9
+ import { isLoopback } from '../mcp/endpoint.js';
10
+ import { readAgyRuntimeOverrides, saveAgyRuntimeOverrides } from '../agy/overrides.js';
11
+ import { lastKeepaliveOutcome, readKeyringBlob, refreshAgyTokenIfDue } from '../agy/keepalive.js';
12
+ import { resolveAgyExecutable } from '../agy/process.js';
13
+ const CORDIS_PATCH_PATH = join(homedir(), '.dsh', 'profiles', 'desktop', 'cordis.patch.yml');
14
+ /**
15
+ * Endpoint hosts for the Cloud Code Assist API. The split is not two accounts
16
+ * and not two subscriptions — it is the same consumer OAuth identity read
17
+ * through two hosts, and only `daily` carries real consumption:
18
+ *
19
+ * - `daily-cloudcode-pa.googleapis.com` — the host agy.exe hardcodes for every
20
+ * `streamGenerateContent` call (verified in every cli-*.log), so it is the
21
+ * only host whose quota numbers describe what we actually spend. Verified in
22
+ * `docs/prod-generation-feasibility-20261020.md` §1-3.
23
+ * - `cloudcode-pa.googleapis.com` ("prod") — answers 200 for quota RPCs but
24
+ * reports `remainingFraction: 1` forever with a resetTime that drifts to
25
+ * "now + window" on every call (06:05→10-26T22:24Z, 06:40→10-26T22:39Z,
26
+ * 09:58→10-27T01:58Z). Generation against it returns 429 `Resource has been
27
+ * exhausted` 24/24 in our probes and 0/4 in the A/B alternation, so it is an
28
+ * empty ledger, not spare capacity. It is kept only as a catalog fallback:
29
+ * its `fetchAvailableModels` lists 25 models vs daily's 19.
30
+ *
31
+ * Reading prod for display is what made the panel show a fake 100% while the
32
+ * account was actually at 1% and returning 429.
33
+ */
34
+ const QUOTA_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
35
+ const QUOTA_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com';
36
+ const CLOUD_CODE_API_VERSION = '1.4.2';
37
+ /**
38
+ * Identity headers. Google returns *different quota payloads* for the same
39
+ * endpoint depending on which client asks: with the terse UA below, daily's
40
+ * `retrieveUserQuotaSummary` omits the Gemini group entirely and returns only
41
+ * the third-party group; with the full CLI identity it returns both groups
42
+ * (probe_ua_identity.mjs: rf=0.008422057, reset 2026-10-23T22:00:21Z). Sending
43
+ * the terse UA is why the panel once read "Gemini 100%" from a host that had no
44
+ * Gemini numbers to give.
45
+ */
46
+ const AGY_IDENTITY_HEADERS = {
47
+ 'User-Agent': `antigravity/cli/${CLOUD_CODE_API_VERSION} (aidev_client; os_type=windows; arch=amd64; auth_method=consumer)`,
48
+ 'X-Goog-Api-Client': 'antigravity/cli/1.4.2 aidev_client windows/amd64 consumer',
49
+ };
50
+ /** POST body ceiling for the panel routes; these carry only small JSON settings. */
51
+ const PANEL_MAX_BODY_BYTES = 1024 * 1024;
52
+ function readOverrides() {
53
+ return readAgyRuntimeOverrides() ?? { hiddenModels: [] };
54
+ }
55
+ function saveOverrides(updates) {
56
+ saveAgyRuntimeOverrides(updates);
57
+ }
58
+ /**
59
+ * Rewrite the plugin's own block in the host `cordis.patch.yml` after a panel
60
+ * change. Failures propagate to the caller so the panel reports them instead of
61
+ * answering `{ok:true}` over a write that never happened.
62
+ *
63
+ * Every interpolation goes through a replacer *function*: a model id or a
64
+ * visible-models line carrying `$&` / `$'` would otherwise be expanded by
65
+ * String.replace's special sequences, and a newline in the value would inject
66
+ * a YAML line into the host config. Values are validated against a model-id
67
+ * allowlist before they reach the file at all.
68
+ *
69
+ * The write is atomic (temp + rename in the same directory): the host parses
70
+ * this file at boot, and a torn write would break the next start, not just the
71
+ * panel.
72
+ */
73
+ function updateCordisPatch(visibleModels, defaultModel) {
74
+ try {
75
+ if (!existsSync(CORDIS_PATCH_PATH))
76
+ return undefined;
77
+ if (defaultModel !== undefined && !/^[A-Za-z0-9._-]+$/.test(defaultModel)) {
78
+ return `refusing to write non-model-id defaultModel: ${defaultModel.slice(0, 64)}`;
79
+ }
80
+ if (visibleModels !== undefined
81
+ && !visibleModels.every(id => /^[A-Za-z0-9._-]+$/.test(id))) {
82
+ return 'refusing to write non-model-id visibleModels entry';
83
+ }
84
+ let content = readFileSync(CORDIS_PATCH_PATH, 'utf8');
85
+ // JS has no \Z anchor; the file-tail alternative is a plain `$`.
86
+ const providerRegex = /(- id: dsh-agy-cli[\s\S]*?)(?=\n- id:|\n[a-zA-Z]|$)/;
87
+ const match = content.match(providerRegex);
88
+ if (!match)
89
+ return undefined;
90
+ let block = match[1];
91
+ if (defaultModel !== undefined) {
92
+ block = block.replace(/(model:\s*)[^\r\n]+/, (_whole, prefix) => `${prefix}${defaultModel}`);
93
+ }
94
+ if (visibleModels !== undefined) {
95
+ const lines = visibleModels.map((m) => ` - ${m}`).join('\n');
96
+ const visibleRegex = /visibleModels:(\s*\n(\s+-[^\r\n]+\n*)*)/;
97
+ if (visibleRegex.test(block)) {
98
+ block = block.replace(visibleRegex, () => `visibleModels:\n${lines}\n`);
99
+ }
100
+ }
101
+ content = content.replace(providerRegex, () => block);
102
+ const temporary = `${CORDIS_PATCH_PATH}.tmp-${process.pid}-${randomUUID()}`;
103
+ try {
104
+ writeFileSync(temporary, content, 'utf8');
105
+ renameSync(temporary, CORDIS_PATCH_PATH);
106
+ }
107
+ catch (error) {
108
+ try {
109
+ rmSync(temporary, { force: true });
110
+ }
111
+ catch {
112
+ // Nothing to clean up, or the cleanup itself failed; the write error wins.
113
+ }
114
+ throw error;
115
+ }
116
+ return undefined;
117
+ }
118
+ catch (error) {
119
+ return error instanceof Error ? error.message : String(error);
120
+ }
121
+ }
122
+ // Why the async read matters: a keyring read used to be `execFileSync`, so each
123
+ // one blocked the host's event loop for 0.2–3 s while Windows PowerShell
124
+ // started. `/status` read it up to three times per request and the panel used to
125
+ // poll, which is what made the Models page blank for 10–22 s (measured
126
+ // 2026-10-23). `readKeyringBlob` in `../agy/keepalive.js` now owns the spawn:
127
+ // async `execFile`, single-flight, and a 30 s freshness window — a renewal done
128
+ // by anything else shows up within one window instead of costing a spawn per
129
+ // panel open.
130
+ /** Identity of the signed-in account, from the shared async keyring read. */
131
+ async function readIdentityFromKeyring(options = {}) {
132
+ const blob = await readKeyringBlob(options);
133
+ return {
134
+ accessToken: blob.accessToken,
135
+ email: blob.email,
136
+ name: blob.name,
137
+ expiresAtMs: blob.expiresAtMs,
138
+ };
139
+ }
140
+ async function readAccessTokenFromKeyring(options = {}) {
141
+ return (await readIdentityFromKeyring(options)).accessToken;
142
+ }
143
+ // Proxy resolution for the quota RPCs. Was: a hardcoded default of
144
+ // `http://127.0.0.1:12450` — the dev machine's tunnel. On any other host (CI,
145
+ // users without that exact proxy) the CONNECT failed silently behind a
146
+ // `.catch(() => null)`, so the quota panel showed nothing and the quota tests
147
+ // only passed where the proxy happened to be listening.
148
+ // Now: use the standard proxy environment (`HTTPS_PROXY`, checked case-first,
149
+ // then lowercase — Node's own env handling accepts both) when it is set, and a
150
+ // plain direct HTTPS request when it is not. Same behavior in production and
151
+ // in tests: no ambient-network dependency left in the fetch path.
152
+ // The panel shows which AGY binary it is talking to. `resolveAgyExecutable`
153
+ // walks PATH, so running it on every `/status` poll (the panel polls) would do
154
+ // a filesystem scan per request. The answer is stable for the life of the host
155
+ // process, so memoize the auto-discovered one; an explicit `agyPath` is read
156
+ // straight from config and stays reactive to settings changes.
157
+ let memoAgyBin;
158
+ function reportAgyBin(agyPath) {
159
+ const configured = agyPath?.trim();
160
+ if (configured)
161
+ return configured;
162
+ if (memoAgyBin === undefined)
163
+ memoAgyBin = resolveAgyExecutable();
164
+ return memoAgyBin;
165
+ }
166
+ function resolveQuotaProxyUrl() {
167
+ const raw = process.env.HTTPS_PROXY ?? process.env.https_proxy;
168
+ const trimmed = raw?.trim();
169
+ if (!trimmed)
170
+ return undefined;
171
+ try {
172
+ const url = new URL(trimmed);
173
+ // A CONNECT tunnel only makes sense for an http(s) proxy endpoint; socks://
174
+ // and friends need an agent we deliberately do not depend on.
175
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
176
+ return undefined;
177
+ return url.toString();
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
183
+ function fetchHttpsDirect(targetUrlStr, accessToken) {
184
+ return new Promise((resolve, reject) => {
185
+ try {
186
+ const targetUrl = new URL(targetUrlStr);
187
+ const body = JSON.stringify({});
188
+ const httpsReq = https.request({
189
+ host: targetUrl.hostname,
190
+ path: targetUrl.pathname + targetUrl.search,
191
+ method: 'POST',
192
+ headers: {
193
+ Authorization: `Bearer ${accessToken}`,
194
+ 'Content-Type': 'application/json',
195
+ ...AGY_IDENTITY_HEADERS,
196
+ 'Content-Length': Buffer.byteLength(body),
197
+ },
198
+ timeout: 10000,
199
+ }, (httpsRes) => {
200
+ const chunks = [];
201
+ httpsRes.on('data', (d) => chunks.push(d));
202
+ httpsRes.on('end', () => {
203
+ try {
204
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
205
+ }
206
+ catch (e) {
207
+ reject(e);
208
+ }
209
+ });
210
+ });
211
+ httpsReq.on('timeout', () => {
212
+ httpsReq.destroy(new Error('HTTPS pa.googleapis.com timed out after 10000ms'));
213
+ });
214
+ httpsReq.on('error', (err) => reject(err));
215
+ httpsReq.write(body);
216
+ httpsReq.end();
217
+ }
218
+ catch (e) {
219
+ reject(e);
220
+ }
221
+ });
222
+ }
223
+ function fetchHttpsThroughHttpProxy(targetUrlStr, accessToken, proxyUrl) {
224
+ const effectiveProxy = proxyUrl ?? resolveQuotaProxyUrl();
225
+ if (!effectiveProxy)
226
+ return fetchHttpsDirect(targetUrlStr, accessToken);
227
+ return new Promise((resolve, reject) => {
228
+ try {
229
+ const targetUrl = new URL(targetUrlStr);
230
+ const proxy = new URL(effectiveProxy);
231
+ const connectReq = http.request({
232
+ host: proxy.hostname,
233
+ port: proxy.port,
234
+ method: 'CONNECT',
235
+ path: `${targetUrl.hostname}:443`,
236
+ timeout: 10000,
237
+ });
238
+ connectReq.on('timeout', () => {
239
+ connectReq.destroy(new Error('Proxy CONNECT timed out after 10000ms'));
240
+ });
241
+ connectReq.on('connect', (res, socket) => {
242
+ if (res.statusCode !== 200) {
243
+ socket.destroy();
244
+ reject(new Error(`Proxy CONNECT failed with status: ${res.statusCode}`));
245
+ return;
246
+ }
247
+ const tlsSocket = tls.connect({
248
+ socket,
249
+ servername: targetUrl.hostname,
250
+ // A CONNECT that succeeds says nothing about the tunnel: the TLS
251
+ // handshake beyond it can stall forever, and this promise used to
252
+ // have no bound for that stage at all. `timeout` here is what keeps
253
+ // a half-open proxy tunnel from hanging the whole quota chain.
254
+ timeout: 10000,
255
+ }, () => {
256
+ const body = JSON.stringify({});
257
+ const httpsReq = https.request({
258
+ host: targetUrl.hostname,
259
+ path: targetUrl.pathname + targetUrl.search,
260
+ method: 'POST',
261
+ headers: {
262
+ Authorization: `Bearer ${accessToken}`,
263
+ 'Content-Type': 'application/json',
264
+ ...AGY_IDENTITY_HEADERS,
265
+ 'Content-Length': Buffer.byteLength(body),
266
+ },
267
+ createConnection: () => tlsSocket,
268
+ timeout: 10000,
269
+ }, (httpsRes) => {
270
+ const chunks = [];
271
+ httpsRes.on('data', (d) => chunks.push(d));
272
+ httpsRes.on('end', () => {
273
+ try {
274
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
275
+ resolve(parsed);
276
+ }
277
+ catch (e) {
278
+ reject(e);
279
+ }
280
+ });
281
+ });
282
+ httpsReq.on('timeout', () => {
283
+ httpsReq.destroy(new Error('HTTPS pa.googleapis.com timed out after 10000ms'));
284
+ });
285
+ httpsReq.on('error', (err) => reject(err));
286
+ httpsReq.write(body);
287
+ httpsReq.end();
288
+ });
289
+ tlsSocket.on('error', (err) => reject(err));
290
+ tlsSocket.on('timeout', () => {
291
+ tlsSocket.destroy(new Error('TLS handshake with the quota host timed out after 10000ms'));
292
+ });
293
+ });
294
+ connectReq.on('error', (err) => reject(err));
295
+ connectReq.end();
296
+ }
297
+ catch (e) {
298
+ reject(e);
299
+ }
300
+ });
301
+ }
302
+ let cachedQuotas = {};
303
+ let lastQuotaFetchTime = 0;
304
+ // The access token the cached snapshot was measured with. Comparing it against
305
+ // the live keyring is how a renewed credential invalidates the 30 s cache.
306
+ let lastQuotaToken;
307
+ // Why this exists: the panel needs to tell "credentials expired" apart from
308
+ // "no quota data yet". Google answers 401 UNAUTHENTICATED when the OAuth token
309
+ // in the Windows keyring has lapsed (agy.exe refreshes it on its next run).
310
+ // A silent catch used to turn that into an empty quota card with no hint.
311
+ let lastQuotaError = null;
312
+ // Serving deadline for the quota paths.
313
+ //
314
+ // Every stage inside `fetchQuotas` has its own timeout, yet the endpoint still
315
+ // managed to hang for minutes: the single-flight promise below is module state,
316
+ // so one run that never settled was handed to every later `/status` and
317
+ // `/refresh-quota` request. The panel then waits forever and only a host restart
318
+ // clears it. A deadline turns that class of failure into "this answer is stale"
319
+ // instead of "this endpoint is dead": the caller always gets an answer, and a
320
+ // run that outlives the deadline stops being the shared one.
321
+ const QUOTA_SERVE_DEADLINE_MS = 12_000;
322
+ // A renewal spawn is allowed to hold the fetch only this long. The spawn has its
323
+ // own 45 s budget and keeps running in the background, but the panel must not
324
+ // pay for it: the token is still valid inside the keep-alive lead window, so
325
+ // answering with it is correct, and the next open sees the rotated one.
326
+ const RENEWAL_AWAIT_MS = 8_000;
327
+ // Model discovery is a sidebar concern, not a quota one. It gets a short leash
328
+ // and falls back to the built-in catalog.
329
+ const DISCOVERY_AWAIT_MS = 4_000;
330
+ /**
331
+ * Resolve with `work`'s value, or with `undefined` if it has not settled within
332
+ * `ms`. Used only on serving paths, where "no answer" must never mean "no
333
+ * response": callers fall back to the last good snapshot.
334
+ */
335
+ async function withDeadline(work, ms) {
336
+ let timer;
337
+ try {
338
+ return await Promise.race([
339
+ work,
340
+ new Promise((resolve) => {
341
+ timer = setTimeout(() => resolve(undefined), ms);
342
+ }),
343
+ ]);
344
+ }
345
+ catch {
346
+ return undefined;
347
+ }
348
+ finally {
349
+ if (timer !== undefined)
350
+ clearTimeout(timer);
351
+ }
352
+ }
353
+ // `refreshQuotas` sits outside `setupWebRoutes` (it is module-level state), but
354
+ // renewal needs the live config. setupWebRoutes installs the current one here
355
+ // on every call; an unset value simply skips renewal instead of guessing.
356
+ let quotaConfig;
357
+ // ---- attach diagnostics (Models page) ----
358
+ // The host renders `settings.models.provider-card` only for the directory row
359
+ // whose `settingsNs` equals the key the client registered, and the plugin's
360
+ // three boot registrations are best-effort (a duplicate mount throws and used to
361
+ // be swallowed). From outside the browser that made four different failures look
362
+ // identical: no directory row, a refused registration, a card the host never
363
+ // dispatched, and a card that rendered empty. `/plugins/agy-cli/diagnose`
364
+ // reports the first two from the host side and the fourth from the browser.
365
+ const bootNotes = new Map();
366
+ /** Record the outcome of one boot registration step (called from apply()). */
367
+ export function noteRegistration(step, detail) {
368
+ bootNotes.set(step, detail);
369
+ }
370
+ const cardPings = [];
371
+ /** Record one card mount reported by the browser half. */
372
+ export function noteCardPing(props) {
373
+ cardPings.push({ at: Date.now(), props });
374
+ if (cardPings.length > 20)
375
+ cardPings.shift();
376
+ }
377
+ /** Snapshot for `/plugins/agy-cli/diagnose`. */
378
+ export function getAttachDiagnostics() {
379
+ return { notes: Object.fromEntries(bootNotes), cardPings: [...cardPings] };
380
+ }
381
+ // The 30 s TTL used to be checked only *after* a refresh finished, so every
382
+ // concurrent request ran the whole chain anyway: the panel's poll and each
383
+ // settings/model write piled two HTTPS round-trips onto the same quota endpoint.
384
+ // Single-flight collapses them onto one in-flight promise. `force` (the manual
385
+ // refresh button) is never handed a snapshot that started before it was
386
+ // pressed — it queues behind the current run, then starts its own.
387
+ let quotaInFlight;
388
+ let quotaInFlightStartedAt = 0;
389
+ async function refreshQuotas(force = false) {
390
+ if (quotaInFlight !== undefined) {
391
+ // A run older than the serving deadline is treated as abandoned: the caller
392
+ // gets a fresh attempt instead of a promise we already know cannot answer in
393
+ // time. It is not cancelled (its stages have their own timeouts); it simply
394
+ // stops being the shared answer, so a stall can never wedge the endpoint.
395
+ const abandoned = Date.now() - quotaInFlightStartedAt > QUOTA_SERVE_DEADLINE_MS;
396
+ if (!abandoned) {
397
+ const shared = quotaInFlight;
398
+ if (!force)
399
+ return shared;
400
+ await shared.catch(() => undefined);
401
+ }
402
+ }
403
+ const run = fetchQuotas(force);
404
+ quotaInFlight = run;
405
+ quotaInFlightStartedAt = Date.now();
406
+ try {
407
+ return await run;
408
+ }
409
+ finally {
410
+ if (quotaInFlight === run) {
411
+ quotaInFlight = undefined;
412
+ quotaInFlightStartedAt = 0;
413
+ }
414
+ }
415
+ }
416
+ async function fetchQuotas(force) {
417
+ const now = Date.now();
418
+ const cacheFresh = !force && now - lastQuotaFetchTime < 30_000 && Object.keys(cachedQuotas).length > 0;
419
+ if (cacheFresh) {
420
+ // A cached snapshot is only valid while the credential it describes is
421
+ // still in the keyring unchanged. That is how a renewal done by anything
422
+ // else (the timer, or this plugin's own keep-alive) shows up on the panel
423
+ // without waiting out the 30 s window. The read is async + cached, so this
424
+ // costs a PowerShell spawn at most once per freshness window.
425
+ try {
426
+ if ((await readAccessTokenFromKeyring()) === lastQuotaToken)
427
+ return cachedQuotas;
428
+ }
429
+ catch {
430
+ // Keyring read failed: treat the cache as stale and let the full fetch
431
+ // below report its own error instead of rejecting out of the cache path.
432
+ }
433
+ lastQuotaFetchTime = 0;
434
+ }
435
+ try {
436
+ // Renewal is decided before the fetch, because the token we are about to
437
+ // send may already be past the lead window. `refreshAgyTokenIfDue` is cheap
438
+ // when nothing is due and refuses to loop on a failing keyring, so it can
439
+ // sit on every refresh path (status poll, timer, manual refresh). A lead of
440
+ // 0 means the operator disabled renewal in one place instead of two.
441
+ let renewalRan = false;
442
+ if (quotaConfig !== undefined) {
443
+ const keepaliveCwd = quotaConfig.tokenKeepaliveCwd?.trim()
444
+ || process.env.USERPROFILE?.trim()
445
+ || process.env.HOME?.trim();
446
+ const renewal = await withDeadline(refreshAgyTokenIfDue({
447
+ executable: quotaConfig.agyPath?.trim() || undefined,
448
+ model: quotaConfig.model,
449
+ leadMs: quotaConfig.tokenKeepaliveLeadMs ?? 0,
450
+ ...(keepaliveCwd === undefined || keepaliveCwd.length === 0 ? {} : { cwd: keepaliveCwd }),
451
+ }), RENEWAL_AWAIT_MS);
452
+ // Only a run that actually happened can have replaced the credential. When
453
+ // nothing was due, the cached token is by definition still valid, and
454
+ // paying another ~1.2 s keyring spawn here would undo the whole point of
455
+ // the freshness window. A renewal that misses its window is not a failed
456
+ // renewal: it keeps running in the background and the next open sees the
457
+ // rotated credential.
458
+ renewalRan = renewal?.refreshed === true;
459
+ }
460
+ // Read after the renewal attempt, never before: sending a token that was
461
+ // just replaced would guarantee a 401 for this very fetch. Forced fresh —
462
+ // a cached token from before the renewal would be exactly the stale one.
463
+ const liveToken = await readAccessTokenFromKeyring({ force: renewalRan });
464
+ if (!liveToken) {
465
+ lastQuotaError = 'unauthorized';
466
+ return cachedQuotas;
467
+ }
468
+ lastQuotaToken = liveToken;
469
+ // Read the host that actually serves our generation traffic. prod is only a
470
+ // catalog fallback: its quota numbers are an empty ledger (see the comment
471
+ // on QUOTA_ENDPOINT_DAILY), so falling back to it would reintroduce the
472
+ // fake-100% panel.
473
+ // Two endpoints, but the second one is only a fallback. The model catalog is
474
+ // 132,811 B and ~2.1 s against the summary's 2,388 B / ~1.8 s (measured
475
+ // 2026-10-23), and it carries no data the panel renders while the summary
476
+ // reports buckets: the fallback below is the only consumer that turns it
477
+ // into family totals. Fetching it up front charged every cold panel open a
478
+ // round trip whose answer had already arrived, so ask for it only when the
479
+ // summary came back without groups.
480
+ const summaryRaw = await fetchHttpsThroughHttpProxy(`${QUOTA_ENDPOINT_DAILY}/v1internal:retrieveUserQuotaSummary`, liveToken).catch(() => null);
481
+ const modelsRaw = Array.isArray(summaryRaw?.groups)
482
+ ? null
483
+ : await fetchHttpsThroughHttpProxy(`${QUOTA_ENDPOINT_DAILY}/v1internal:fetchAvailableModels`, liveToken).catch(() => null);
484
+ // "Unauthenticated" is the only failure worth surfacing in the UI: it means
485
+ // the OAuth token in the keyring lapsed and a fresh agy.exe run (or a
486
+ // message through the provider) will refresh it. Network/upstream failures
487
+ // are transient and self-heal on the next poll.
488
+ const summaryErr = summaryRaw?.error?.code;
489
+ const modelsErr = modelsRaw?.error?.code;
490
+ if (summaryErr === 401 || modelsErr === 401) {
491
+ lastQuotaError = 'unauthorized';
492
+ }
493
+ else if (summaryRaw === null && modelsRaw === null) {
494
+ lastQuotaError = 'network';
495
+ }
496
+ else {
497
+ lastQuotaError = null;
498
+ }
499
+ const familyQuotas = {
500
+ google: { updatedAt: now, models: [] },
501
+ anthropic: { updatedAt: now, models: [] },
502
+ openai: { updatedAt: now, models: [] },
503
+ };
504
+ if (summaryRaw && Array.isArray(summaryRaw.groups)) {
505
+ for (const group of summaryRaw.groups) {
506
+ const dName = (group.displayName || '').toLowerCase();
507
+ const desc = (group.description || '').toLowerCase();
508
+ const isGoogle = dName.includes('gemini') || desc.includes('gemini');
509
+ const is3P = dName.includes('claude') || dName.includes('gpt') || desc.includes('claude') || desc.includes('gpt');
510
+ const targetFamilies = isGoogle
511
+ ? ['google']
512
+ : is3P
513
+ ? ['anthropic', 'openai']
514
+ : [];
515
+ let fiveHourFrac;
516
+ let fiveHourReset;
517
+ let weeklyFrac;
518
+ let weeklyReset;
519
+ for (const b of group.buckets || []) {
520
+ // A disabled window is "does not currently apply" (Google's own wording
521
+ // in the bucket description, e.g. "You have hit your weekly limit, the
522
+ // 5-hour limit does not currently apply"). It is not a reading: skip it
523
+ // so the 5-hour row stays unknown instead of inheriting a stale value.
524
+ if (b.disabled === true)
525
+ continue;
526
+ const w = (b.window || b.bucketId || '').toLowerCase();
527
+ const frac = typeof b.remainingFraction === 'number' && Number.isFinite(b.remainingFraction)
528
+ ? Math.min(1, Math.max(0, b.remainingFraction))
529
+ : undefined;
530
+ if (w.includes('5h') || w.includes('five_hour') || w.includes('5-hour')) {
531
+ fiveHourFrac = frac;
532
+ fiveHourReset = b.resetTime;
533
+ }
534
+ else if (w.includes('weekly') || w.includes('week')) {
535
+ weeklyFrac = frac;
536
+ weeklyReset = b.resetTime;
537
+ }
538
+ }
539
+ for (const fam of targetFamilies) {
540
+ // No fraction for a window means upstream did not report it — record
541
+ // that as unknown. Filling in 1 (the old behaviour) is what made a
542
+ // drained account render as "100% remaining".
543
+ familyQuotas[fam].remainingFraction = fiveHourFrac;
544
+ familyQuotas[fam].resetTime = fiveHourReset;
545
+ familyQuotas[fam].fiveHourUnknown = fiveHourFrac === undefined;
546
+ familyQuotas[fam].weeklyFraction = weeklyFrac;
547
+ familyQuotas[fam].weeklyResetTime = weeklyReset;
548
+ familyQuotas[fam].weeklyUnknown = weeklyFrac === undefined;
549
+ familyQuotas[fam].description = group.description;
550
+ }
551
+ }
552
+ }
553
+ if (modelsRaw && typeof modelsRaw === 'object' && modelsRaw.models) {
554
+ // The upstream catalog response names ~19 models. The panel only ever
555
+ // shows DEFAULT_CATALOG_MODELS, so keep the per-model quota detail for
556
+ // those ids and drop the rest instead of shipping every upstream entry
557
+ // over the wire on each status poll.
558
+ const catalogIds = new Set(DEFAULT_CATALOG_MODELS.map((m) => m.id));
559
+ for (const [id, m] of Object.entries(modelsRaw.models)) {
560
+ if (!catalogIds.has(id))
561
+ continue;
562
+ const info = m?.quotaInfo;
563
+ const displayName = m?.displayName || id;
564
+ const entry = {
565
+ modelId: id,
566
+ displayName,
567
+ remainingFraction: typeof info?.remainingFraction === 'number' && Number.isFinite(info.remainingFraction)
568
+ ? Math.min(1, Math.max(0, info.remainingFraction))
569
+ : undefined,
570
+ ...(info?.resetTime ? { resetTime: info.resetTime } : {}),
571
+ };
572
+ const lower = id.toLowerCase();
573
+ const targetFam = lower.includes('claude')
574
+ ? 'anthropic'
575
+ : lower.includes('gpt')
576
+ ? 'openai'
577
+ : 'google';
578
+ familyQuotas[targetFam].models.push(entry);
579
+ }
580
+ }
581
+ if (!summaryRaw?.groups && modelsRaw?.models) {
582
+ for (const fam of DISPLAY_FAMILIES) {
583
+ const list = familyQuotas[fam]?.models || [];
584
+ // Only models that actually reported a fraction take part in the
585
+ // aggregate. A model whose `quotaInfo` has no `remainingFraction` is a
586
+ // model upstream declined to meter — treating it as 1 (the old
587
+ // behaviour) silently turns "not reported" into "full".
588
+ const metered = list.filter((x) => typeof x.remainingFraction === 'number');
589
+ const remaining = metered.length > 0 ? Math.min(...metered.map((x) => x.remainingFraction)) : undefined;
590
+ const resetEntry = list.find((x) => x.resetTime);
591
+ const isWeekly = resetEntry?.resetTime
592
+ ? (new Date(resetEntry.resetTime).getTime() - now) >= 24 * 3600 * 1000
593
+ : false;
594
+ familyQuotas[fam] = {
595
+ ...familyQuotas[fam],
596
+ remainingFraction: remaining,
597
+ ...(!isWeekly && resetEntry?.resetTime ? { resetTime: resetEntry.resetTime } : {}),
598
+ fiveHourUnknown: remaining === undefined,
599
+ weeklyFraction: remaining,
600
+ ...(isWeekly && resetEntry?.resetTime ? { weeklyResetTime: resetEntry.resetTime } : {}),
601
+ updatedAt: now,
602
+ models: list,
603
+ };
604
+ }
605
+ }
606
+ cachedQuotas = familyQuotas;
607
+ lastQuotaFetchTime = now;
608
+ }
609
+ catch {
610
+ // A throw past the in-function classification (a parse failure, a shape
611
+ // change) must not leave the previous error on display: surface it as a
612
+ // network-class failure so the panel shows a live problem, not a stale
613
+ // snapshot with no explanation.
614
+ lastQuotaError = 'network';
615
+ }
616
+ return cachedQuotas;
617
+ }
618
+ /**
619
+ * The catalog the panel shows. Gemini 3.8 Flash is the only model this account
620
+ * can actually reach: `agy models` lists it (plus effort variants of the same
621
+ * id), and the third-party families are drained — the 3p weekly bucket has read
622
+ * remainingFraction 0 since 2026-10-20, and Google removes Claude/GPT from
623
+ * promotional plans on 2026-11-02. Listing drained models only produces entries
624
+ * that fail on send.
625
+ *
626
+ * The retired entries and a switch-back procedure are parked in
627
+ * `docs/archived-model-catalog-20261020.md` — restoring any of them is a
628
+ * config edit (`models` + `visibleModels` in the profile patch), not a code
629
+ * change.
630
+ */
631
+ const DEFAULT_CATALOG_MODELS = [
632
+ { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
633
+ { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro' },
634
+ ];
635
+ /** Quota families the panel renders. Same reasoning as above; see the doc. */
636
+ const DISPLAY_FAMILIES = ['google'];
637
+ export function setupWebRoutes(ctx, activeConfig, modelDiscovery) {
638
+ const disposers = [];
639
+ quotaConfig = activeConfig;
640
+ disposers.push(() => {
641
+ if (quotaConfig === activeConfig)
642
+ quotaConfig = undefined;
643
+ });
644
+ ctx.inject(['webServer'], (innerCtx) => {
645
+ const webServer = innerCtx.webServer;
646
+ if (!webServer || typeof webServer.register !== 'function')
647
+ return;
648
+ const sendJson = (res, status, body) => {
649
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
650
+ res.end(JSON.stringify(body));
651
+ };
652
+ const readBody = (req) => {
653
+ return new Promise((resolve) => {
654
+ const chunks = [];
655
+ let total = 0;
656
+ // Without this listener an aborted POST surfaces as an unhandled
657
+ // 'error' on the stream — an uncaught exception in the host process.
658
+ req.on('error', () => resolve({}));
659
+ req.on('data', (c) => {
660
+ total += c.length;
661
+ if (total > PANEL_MAX_BODY_BYTES) {
662
+ req.destroy();
663
+ resolve({});
664
+ return;
665
+ }
666
+ chunks.push(c);
667
+ });
668
+ req.on('end', () => {
669
+ try {
670
+ const v = JSON.parse(Buffer.concat(chunks).toString('utf8'));
671
+ resolve(v && typeof v === 'object' ? v : {});
672
+ }
673
+ catch {
674
+ resolve({});
675
+ }
676
+ });
677
+ });
678
+ };
679
+ const reg = (path, handler) => {
680
+ // The panel routes answer with account identity and drive quota fetches;
681
+ // they are loopback-only exactly like the MCP endpoint.
682
+ const d = webServer.register({
683
+ kind: 'exact',
684
+ path,
685
+ handler: async (req, res) => {
686
+ if (!isLoopback(req.socket?.remoteAddress)) {
687
+ sendJson(res, 403, { ok: false, error: 'loopback only' });
688
+ return;
689
+ }
690
+ await handler(req, res);
691
+ },
692
+ });
693
+ if (typeof d === 'function')
694
+ disposers.push(d);
695
+ };
696
+ // Automatic renewal. The actual decision lives in `refreshQuotas` (it runs
697
+ // right before the quota fetch, which is the only moment a lapsed token
698
+ // actually costs something). This timer only exists so renewal also happens
699
+ // while nobody is looking at the panel — a status poll alone is not a
700
+ // dependable clock. See `src/agy/keepalive.ts`.
701
+ const keepaliveMs = activeConfig.tokenKeepaliveMs ?? 0;
702
+ if (keepaliveMs > 0) {
703
+ let stopped = false;
704
+ const keepaliveTimer = setInterval(() => {
705
+ if (stopped)
706
+ return;
707
+ // force=false: refreshQuotas still renews when due; it just will not
708
+ // refetch quota numbers that are younger than the 30 s cache.
709
+ void refreshQuotas(false).catch(() => { });
710
+ }, keepaliveMs);
711
+ // The host must be able to exit without waiting for this timer.
712
+ if (typeof keepaliveTimer.unref === 'function')
713
+ keepaliveTimer.unref();
714
+ disposers.push(() => {
715
+ stopped = true;
716
+ clearInterval(keepaliveTimer);
717
+ });
718
+ }
719
+ // 0. GET /plugins/agy-cli/diagnose — the Models-page attach path as the
720
+ // host sees it. `configurable` must contain one entry whose settingsNs is
721
+ // the card key; when it does and `cardPings` is still empty, the host never
722
+ // dispatched the entry (a directory/namespace problem) rather than the card
723
+ // rendering nothing (a card problem). Read-only; safe to poll.
724
+ reg('/plugins/agy-cli/diagnose', (_req, res) => {
725
+ // The host turns any throwing handler into a bare 400 with an empty body.
726
+ // For a *diagnostic* endpoint that is the worst possible outcome: the one
727
+ // thing it exists to prevent is a failure nobody can read. So this handler
728
+ // never throws — it reads each service defensively and reports every
729
+ // problem inside the JSON.
730
+ let llm;
731
+ let llmError = null;
732
+ try {
733
+ llm = ctx.llm;
734
+ }
735
+ catch (error) {
736
+ // Cordis throws on property access when a declared service is not
737
+ // currently mounted. That is exactly the state worth reporting here.
738
+ llmError = error instanceof Error ? error.message : String(error);
739
+ }
740
+ const readOrNull = (fn) => {
741
+ try {
742
+ return fn();
743
+ }
744
+ catch (error) {
745
+ return error instanceof Error ? error.message : String(error);
746
+ }
747
+ };
748
+ const providers = llmError !== null
749
+ ? `unavailable: ${llmError}`
750
+ : readOrNull(() => (llm?.listProviders?.() ?? []).map((provider) => provider.id));
751
+ const configurable = llmError !== null
752
+ ? `unavailable: ${llmError}`
753
+ : readOrNull(() => (llm?.listConfigurableProviders?.() ?? [])
754
+ .map((entry) => ({ provider: entry.provider, displayName: entry.displayName, settingsNs: entry.settingsNs })));
755
+ const diagnostics = getAttachDiagnostics();
756
+ const body = {
757
+ cardSlot: 'settings.models.provider-card',
758
+ cardKey: 'dsh-agy-cli',
759
+ ...diagnostics,
760
+ llmError,
761
+ providers,
762
+ configurable,
763
+ rowCarriesCardKey: Array.isArray(configurable)
764
+ ? configurable.some((entry) => entry.settingsNs === 'dsh-agy-cli')
765
+ : null,
766
+ };
767
+ try {
768
+ sendJson(res, 200, body);
769
+ }
770
+ catch (error) {
771
+ // A body that cannot be serialized must still say so.
772
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
773
+ res.end(JSON.stringify({
774
+ cardSlot: body.cardSlot,
775
+ cardKey: body.cardKey,
776
+ notes: diagnostics.notes,
777
+ serializeError: error instanceof Error ? error.message : String(error),
778
+ rowCarriesCardKey: null,
779
+ }));
780
+ }
781
+ });
782
+ // The browser half reports every mount of the card here. A dispatch that
783
+ // never happens never arrives, which is what separates "no row" from "card
784
+ // rendered empty"; the prop names prove which owner props the host passed.
785
+ reg('/plugins/agy-cli/card-ping', async (req, res) => {
786
+ const body = await readBody(req);
787
+ const raw = body.props;
788
+ const props = Array.isArray(raw) ? raw.filter((item) => typeof item === 'string') : [];
789
+ noteCardPing(props);
790
+ sendJson(res, 200, { ok: true });
791
+ });
792
+ // 1. GET /plugins/agy-cli/status
793
+ reg('/plugins/agy-cli/status', async (_req, res) => {
794
+ const overrides = readAgyRuntimeOverrides() ?? { hiddenModels: [], defaultEffort: undefined, defaultModel: undefined };
795
+ // Never let the panel wait on the quota chain indefinitely: a timeout
796
+ // answers with the last good snapshot and says so in `quotaError`.
797
+ const quotaAnswer = await withDeadline(refreshQuotas(false), QUOTA_SERVE_DEADLINE_MS);
798
+ if (quotaAnswer === undefined)
799
+ lastQuotaError = 'timeout';
800
+ const quotas = quotaAnswer ?? cachedQuotas;
801
+ let discovered = [...DEFAULT_CATALOG_MODELS];
802
+ let catalogError = null;
803
+ if (modelDiscovery) {
804
+ try {
805
+ const configured = configuredModels(activeConfig);
806
+ const disc = await withDeadline(modelDiscovery.discover(configured), DISCOVERY_AWAIT_MS);
807
+ if (disc?.models && disc.models.length > 0) {
808
+ const set = new Set(discovered.map((m) => m.id));
809
+ for (const dm of disc.models) {
810
+ if (!set.has(dm.id)) {
811
+ discovered.push({ id: dm.id, name: dm.name ?? dm.id });
812
+ }
813
+ }
814
+ }
815
+ }
816
+ catch (error) {
817
+ catalogError = error instanceof Error ? error.message : String(error);
818
+ }
819
+ }
820
+ let hidden = overrides.hiddenModels;
821
+ if (hidden.length === 0 && Array.isArray(activeConfig.visibleModels) && activeConfig.visibleModels.length > 0) {
822
+ hidden = discovered.map((m) => m.id).filter((id) => !activeConfig.visibleModels.includes(id));
823
+ }
824
+ const defaultModel = overrides.defaultModel ?? (activeConfig.model ? normalizeModelId(activeConfig.model) : 'gemini-3.8-flash');
825
+ const defaultEffort = overrides.defaultEffort ?? 'high';
826
+ const identity = await readIdentityFromKeyring();
827
+ const accountAlias = identity.email
828
+ ? `${identity.name ? `${identity.name} ` : ''}(${identity.email})`
829
+ : '本机主账号 (gemini:antigravity)';
830
+ sendJson(res, 200, {
831
+ plugin: 'dsh-agy-cli',
832
+ // Never fall back to a developer's absolute path: this value ships to
833
+ // every panel and used to leak `F:\AgentData\antigravity\cli\agy.exe`
834
+ // into every user's settings page. Empty agyPath means "auto-discover",
835
+ // so report what the spawn path actually resolves to.
836
+ bin: reportAgyBin(activeConfig.agyPath),
837
+ version: CLOUD_CODE_API_VERSION,
838
+ enabled: readConfigFlag(activeConfig.enabled),
839
+ permissionMode: 'dsh-owned',
840
+ workspaceRoot: '',
841
+ defaultModel,
842
+ defaultEffort,
843
+ auth: {
844
+ phase: lastQuotaError === 'unauthorized' ? 'expired' : 'signed_in',
845
+ // The panel needs the real expiry, not just a boolean: with automatic
846
+ // renewal in place the useful question became "how long is left", and
847
+ // a refresh that silently fails must still be visible.
848
+ tokenExpiresAt: identity.expiresAtMs === undefined ? null : new Date(identity.expiresAtMs).toISOString(),
849
+ tokenExpiresInSeconds: identity.expiresAtMs === undefined
850
+ ? null
851
+ : Math.round((identity.expiresAtMs - Date.now()) / 1000),
852
+ keepalive: keepaliveMs > 0,
853
+ // Report the last renewal verdict so a failed renew is visible in the
854
+ // panel instead of masquerading as "still renewing".
855
+ keepaliveLastReason: lastKeepaliveOutcome()?.reason ?? null,
856
+ keepaliveLastDetail: lastKeepaliveOutcome()?.detail ?? null,
857
+ message: lastQuotaError === 'unauthorized'
858
+ ? '登录已过期:发一条 AGY 消息即可自动刷新凭据'
859
+ : 'Windows Credential Manager (gemini:antigravity) 就绪',
860
+ },
861
+ quotaError: lastQuotaError,
862
+ pool: {
863
+ version: 1,
864
+ mode: 'sequential',
865
+ defaultCooldownMs: 900000,
866
+ maxCooldownMs: 3600000,
867
+ primaryAccountId: 'primary',
868
+ activeAccountId: 'primary',
869
+ accounts: [
870
+ {
871
+ id: 'primary',
872
+ alias: accountAlias,
873
+ email: identity.email,
874
+ defaultAlias: false,
875
+ dir: '',
876
+ systemHome: true,
877
+ enabled: true,
878
+ createdAt: Date.now(),
879
+ cooldowns: {},
880
+ quotas,
881
+ },
882
+ ],
883
+ },
884
+ catalog: { source: 'agy models', count: discovered.length, lastError: catalogError },
885
+ catalogModels: discovered,
886
+ hiddenModels: hidden,
887
+ bindings: 1,
888
+ lastRun: null,
889
+ });
890
+ });
891
+ // 2. POST /plugins/agy-cli/config
892
+ reg('/plugins/agy-cli/config', async (req, res) => {
893
+ if (req.method !== 'POST') {
894
+ sendJson(res, 405, { error: 'POST only' });
895
+ return;
896
+ }
897
+ const body = await readBody(req);
898
+ const key = typeof body.key === 'string' ? body.key : '';
899
+ const value = body.value;
900
+ if (key === 'hiddenModels' && Array.isArray(value)) {
901
+ saveOverrides({ hiddenModels: value });
902
+ const discovered = DEFAULT_CATALOG_MODELS.map((m) => m.id);
903
+ const visible = discovered.filter((id) => !value.includes(id));
904
+ const patchError = updateCordisPatch(visible);
905
+ if (patchError !== undefined) {
906
+ sendJson(res, 500, { ok: false, key, error: patchError });
907
+ return;
908
+ }
909
+ sendJson(res, 200, { ok: true, key, value });
910
+ return;
911
+ }
912
+ if (key === 'defaultEffort' && typeof value === 'string') {
913
+ saveOverrides({ defaultEffort: value });
914
+ sendJson(res, 200, { ok: true, key, value });
915
+ return;
916
+ }
917
+ if (key === 'defaultModel' && typeof value === 'string') {
918
+ saveOverrides({ defaultModel: value });
919
+ const patchError = updateCordisPatch(undefined, value);
920
+ if (patchError !== undefined) {
921
+ sendJson(res, 500, { ok: false, key, error: patchError });
922
+ return;
923
+ }
924
+ sendJson(res, 200, { ok: true, key, value });
925
+ return;
926
+ }
927
+ sendJson(res, 200, { ok: true, key, value });
928
+ });
929
+ // 3. POST /plugins/agy-cli/pool/refresh-quota
930
+ reg('/plugins/agy-cli/pool/refresh-quota', async (req, res) => {
931
+ // The manual button is the one caller that may legitimately wait for a new
932
+ // number, but not forever: past the deadline it reports the cached
933
+ // snapshot plus `quotaError: 'timeout'` instead of hanging the panel.
934
+ const quotaAnswer = await withDeadline(refreshQuotas(true), QUOTA_SERVE_DEADLINE_MS);
935
+ if (quotaAnswer === undefined)
936
+ lastQuotaError = 'timeout';
937
+ const quotas = quotaAnswer ?? cachedQuotas;
938
+ const identity = await readIdentityFromKeyring();
939
+ const accountAlias = identity.email
940
+ ? `${identity.name ? `${identity.name} ` : ''}(${identity.email})`
941
+ : '本机主账号 (gemini:antigravity)';
942
+ sendJson(res, 200, {
943
+ ok: true,
944
+ pool: {
945
+ version: 1,
946
+ mode: 'sequential',
947
+ defaultCooldownMs: 900000,
948
+ maxCooldownMs: 3600000,
949
+ primaryAccountId: 'primary',
950
+ activeAccountId: 'primary',
951
+ accounts: [
952
+ {
953
+ id: 'primary',
954
+ alias: accountAlias,
955
+ email: identity.email,
956
+ defaultAlias: false,
957
+ dir: '',
958
+ systemHome: true,
959
+ enabled: true,
960
+ createdAt: Date.now(),
961
+ cooldowns: {},
962
+ quotas,
963
+ },
964
+ ],
965
+ },
966
+ });
967
+ });
968
+ // 4. GET & POST /plugins/agy-cli/auth
969
+ reg('/plugins/agy-cli/auth', async (_req, res) => {
970
+ // Report what the keyring actually says, not an unconditional
971
+ // "signed in": an empty or expired credential shows up as exactly that.
972
+ const identity = await readIdentityFromKeyring();
973
+ const expired = identity.expiresAtMs !== undefined && identity.expiresAtMs <= Date.now();
974
+ const phase = identity.accessToken === null
975
+ ? 'signed_out'
976
+ : expired ? 'expired' : 'signed_in';
977
+ const message = phase === 'signed_in'
978
+ ? '已通过 Windows 凭据管理器授权'
979
+ : phase === 'expired'
980
+ ? '凭据已过期,等待自动续期或重新登录'
981
+ : '凭据管理器中没有 AGY 凭据(gemini:antigravity)';
982
+ sendJson(res, 200, { phase, message });
983
+ });
984
+ });
985
+ return () => {
986
+ for (const d of disposers) {
987
+ try {
988
+ d();
989
+ }
990
+ catch { }
991
+ }
992
+ };
993
+ }
994
+ //# sourceMappingURL=router.js.map