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.
- package/LICENSE +31 -0
- package/README.en.md +48 -0
- package/README.md +67 -2
- package/agents/dsh-agy-image-view.md +17 -0
- package/agents/dsh-agy-mcp-executor.md +32 -0
- package/agents/dsh-agy-read-only.md +20 -0
- package/agents/dsh-agy-tool-free.md +16 -0
- package/agents/dsh-agy-workspace-write.md +23 -0
- package/bin/dsh-agy-cli.js +9 -0
- package/bin/win32-x64/agy-launcher.exe +0 -0
- package/cordis.patch.yml +16 -0
- package/dist/client.js +4259 -0
- package/lib/agent-installer.d.ts +66 -0
- package/lib/agent-installer.js +147 -0
- package/lib/agent-presets.d.ts +42 -0
- package/lib/agent-presets.js +130 -0
- package/lib/agents-cli.d.ts +3 -0
- package/lib/agents-cli.js +110 -0
- package/lib/agy/diagnostics.d.ts +29 -0
- package/lib/agy/diagnostics.js +109 -0
- package/lib/agy/errors.d.ts +20 -0
- package/lib/agy/errors.js +77 -0
- package/lib/agy/experimental-transport.d.ts +79 -0
- package/lib/agy/experimental-transport.js +612 -0
- package/lib/agy/keepalive.d.ts +140 -0
- package/lib/agy/keepalive.js +445 -0
- package/lib/agy/limiter.d.ts +32 -0
- package/lib/agy/limiter.js +111 -0
- package/lib/agy/log.d.ts +214 -0
- package/lib/agy/log.js +503 -0
- package/lib/agy/models.d.ts +49 -0
- package/lib/agy/models.js +203 -0
- package/lib/agy/overrides.d.ts +17 -0
- package/lib/agy/overrides.js +73 -0
- package/lib/agy/parser.d.ts +102 -0
- package/lib/agy/parser.js +492 -0
- package/lib/agy/persistent-transport.d.ts +101 -0
- package/lib/agy/persistent-transport.js +753 -0
- package/lib/agy/process.d.ts +144 -0
- package/lib/agy/process.js +452 -0
- package/lib/agy/redact.d.ts +3 -0
- package/lib/agy/redact.js +24 -0
- package/lib/agy/stream-protocol.d.ts +50 -0
- package/lib/agy/stream-protocol.js +156 -0
- package/lib/agy/windows-launcher.d.ts +21 -0
- package/lib/agy/windows-launcher.js +76 -0
- package/lib/diagnostics.d.ts +78 -0
- package/lib/diagnostics.js +162 -0
- package/lib/doctor.d.ts +138 -0
- package/lib/doctor.js +940 -0
- package/lib/dsh/context.d.ts +133 -0
- package/lib/dsh/context.js +352 -0
- package/lib/dsh-home.d.ts +6 -0
- package/lib/dsh-home.js +11 -0
- package/lib/index.d.ts +54 -0
- package/lib/index.js +267 -0
- package/lib/mcp/agy-registration.d.ts +70 -0
- package/lib/mcp/agy-registration.js +245 -0
- package/lib/mcp/bridge.d.ts +73 -0
- package/lib/mcp/bridge.js +295 -0
- package/lib/mcp/endpoint.d.ts +41 -0
- package/lib/mcp/endpoint.js +142 -0
- package/lib/mcp/protocol.d.ts +107 -0
- package/lib/mcp/protocol.js +197 -0
- package/lib/mcp/tool-projection.d.ts +64 -0
- package/lib/mcp/tool-projection.js +168 -0
- package/lib/provider/agy.d.ts +153 -0
- package/lib/provider/agy.js +2159 -0
- package/lib/provider/config.d.ts +226 -0
- package/lib/provider/config.js +294 -0
- package/lib/provider/error-codes.d.ts +22 -0
- package/lib/provider/error-codes.js +29 -0
- package/lib/provider/image-bridge.d.ts +60 -0
- package/lib/provider/image-bridge.js +290 -0
- package/lib/provider/mock.d.ts +15 -0
- package/lib/provider/mock.js +78 -0
- package/lib/provider/prompt-budget.d.ts +45 -0
- package/lib/provider/prompt-budget.js +596 -0
- package/lib/provider/serialize.d.ts +15 -0
- package/lib/provider/serialize.js +82 -0
- package/lib/provider/tool-protocol.d.ts +80 -0
- package/lib/provider/tool-protocol.js +848 -0
- package/lib/provider/tool-schema-file.d.ts +11 -0
- package/lib/provider/tool-schema-file.js +27 -0
- package/lib/session/file-store.d.ts +56 -0
- package/lib/session/file-store.js +186 -0
- package/lib/session/store.d.ts +49 -0
- package/lib/session/store.js +62 -0
- package/lib/web/router.d.ts +19 -0
- package/lib/web/router.js +994 -0
- 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
|