dsh-agy-cli 0.0.0-stage → 0.12.0

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