@triflux/core 10.50.1 → 10.51.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 (90) hide show
  1. package/hooks/agy-session-hook.mjs +11 -56
  2. package/hooks/codex-session-hook.mjs +11 -101
  3. package/hub/bridge.mjs +1 -79
  4. package/hub/cli-adapter-base.mjs +12 -90
  5. package/hub/codex-adapter.mjs +0 -1
  6. package/hub/codex-preflight.mjs +5 -28
  7. package/hub/index.mjs +3 -9
  8. package/hub/lib/cto-env.mjs +0 -23
  9. package/hub/lib/known-errors.json +2 -2
  10. package/hub/lib/process-utils.mjs +0 -123
  11. package/hub/lib/spawn-trace.mjs +3 -2
  12. package/hub/lib/timeout-defaults.mjs +0 -1
  13. package/hub/paths.mjs +0 -6
  14. package/hub/pipeline/index.mjs +0 -190
  15. package/hub/pipeline/transitions.mjs +0 -10
  16. package/hub/reflexion.mjs +1 -84
  17. package/hub/role-contract.mjs +0 -2
  18. package/hud/cli-policy.mjs +0 -26
  19. package/hud/colors.mjs +0 -13
  20. package/hud/constants.mjs +7 -153
  21. package/hud/context-monitor.mjs +3 -64
  22. package/hud/hud-qos-status.mjs +54 -264
  23. package/hud/providers/claude.mjs +4 -16
  24. package/hud/providers/gemini.mjs +83 -689
  25. package/hud/renderers.mjs +59 -367
  26. package/hud/terminal.mjs +1 -63
  27. package/hud/utils.mjs +2 -26
  28. package/package.json +1 -4
  29. package/scripts/lib/agent-route-policy.mjs +162 -14
  30. package/scripts/lib/claudemd-scanner.mjs +2 -199
  31. package/scripts/lib/cli-claude.mjs +0 -2
  32. package/scripts/lib/context.mjs +3 -19
  33. package/scripts/lib/env-probe.mjs +5 -64
  34. package/scripts/lib/gemini-profiles.mjs +2 -69
  35. package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
  36. package/scripts/lib/machine-profile.mjs +3 -17
  37. package/scripts/lib/mcp-filter.mjs +109 -53
  38. package/scripts/lib/mcp-health.mjs +2 -2
  39. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  40. package/scripts/lib/psmux-info.mjs +25 -104
  41. package/scripts/lib/session-presence.mjs +1 -1
  42. package/scripts/lib/skill-template.mjs +0 -271
  43. package/hub/codex-compat.mjs +0 -12
  44. package/hub/dynamic-routing-engine.mjs +0 -511
  45. package/hub/fullcycle.mjs +0 -113
  46. package/hub/gemini-adapter.mjs +0 -174
  47. package/hub/intent.mjs +0 -321
  48. package/hub/lib/mcp-response-cache.mjs +0 -239
  49. package/hub/lib/path-utils.mjs +0 -165
  50. package/hub/lib/risk-tier.mjs +0 -53
  51. package/hub/lib/ssh-command.mjs +0 -231
  52. package/hub/lib/staged-file-detect.mjs +0 -43
  53. package/hub/lib/tfx-route-args.mjs +0 -353
  54. package/hub/lib/timeline-adapter.mjs +0 -190
  55. package/hub/pipeline/gates/confidence.mjs +0 -75
  56. package/hub/pipeline/gates/consensus.mjs +0 -106
  57. package/hub/pipeline/gates/index.mjs +0 -9
  58. package/hub/pipeline/gates/selfcheck.mjs +0 -120
  59. package/hub/quality/deslop.mjs +0 -274
  60. package/hub/research.mjs +0 -155
  61. package/hub/role-control-events.mjs +0 -113
  62. package/hub/routing/complexity.mjs +0 -269
  63. package/hub/routing/index.mjs +0 -122
  64. package/hub/routing/q-learning.mjs +0 -387
  65. package/hub/routing-snapshot.mjs +0 -261
  66. package/hub/token-mode.mjs +0 -228
  67. package/hud/mission-board.mjs +0 -56
  68. package/hud/providers/cto.mjs +0 -76
  69. package/mesh/index.mjs +0 -69
  70. package/mesh/mesh-budget.mjs +0 -137
  71. package/mesh/mesh-heartbeat.mjs +0 -100
  72. package/mesh/mesh-protocol.mjs +0 -96
  73. package/mesh/mesh-queue.mjs +0 -163
  74. package/mesh/mesh-registry.mjs +0 -78
  75. package/mesh/mesh-router.mjs +0 -77
  76. package/scripts/lib/agent-json.mjs +0 -27
  77. package/scripts/lib/async.mjs +0 -174
  78. package/scripts/lib/cli-agy.mjs +0 -85
  79. package/scripts/lib/cli-codex.mjs +0 -62
  80. package/scripts/lib/cli-gemini.mjs +0 -37
  81. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  82. package/scripts/lib/env.mjs +0 -14
  83. package/scripts/lib/hub.mjs +0 -48
  84. package/scripts/lib/pid.mjs +0 -63
  85. package/scripts/lib/quota.mjs +0 -47
  86. package/scripts/lib/skill-state.mjs +0 -220
  87. package/scripts/lib/team.mjs +0 -75
  88. package/scripts/lib/timeout.mjs +0 -66
  89. package/scripts/lib/tmp.mjs +0 -26
  90. package/scripts/lib/toml.mjs +0 -132
@@ -1,120 +1,15 @@
1
- // ============================================================================
2
- // Gemini 쿼터 API / 세션 토큰 / RPM 트래커
3
- // ============================================================================
4
-
5
- import { spawn, spawnSync } from "node:child_process";
6
- import { existsSync, readdirSync, readFileSync } from "node:fs";
7
- import https from "node:https";
8
- import { homedir } from "node:os";
9
- import { join } from "node:path";
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
10
3
  import {
11
- ANTIGRAVITY_KEYCHAIN_ACCOUNT,
12
- ANTIGRAVITY_KEYCHAIN_SERVICE,
13
- ANTIGRAVITY_MODEL_ABBREV,
14
4
  ANTIGRAVITY_OAUTH_PATHS,
5
+ ANTIGRAVITY_QUOTA_CACHE_PATH,
6
+ ANTIGRAVITY_QUOTA_STALE_MS,
7
+ ANTIGRAVITY_REFRESH_FLAG,
8
+ ANTIGRAVITY_REFRESH_LOCK_PATH,
15
9
  ANTIGRAVITY_SETTINGS_PATH,
16
- GEMINI_API_TIMEOUT_MS,
17
- GEMINI_OAUTH_PATH,
18
- GEMINI_PROJECT_CACHE_PATH,
19
- GEMINI_QUOTA_CACHE_PATH,
20
- GEMINI_QUOTA_REFRESH_LOCK_PATH,
21
- GEMINI_QUOTA_STALE_MS,
22
- GEMINI_REFRESH_FLAG,
23
- GEMINI_RPM_TRACKER_PATH,
24
- GEMINI_RPM_WINDOW_MS,
25
- GEMINI_SESSION_CACHE_PATH,
26
- GEMINI_SESSION_REFRESH_FLAG,
27
- GEMINI_SESSION_REFRESH_LOCK_PATH,
28
- GEMINI_SESSION_STALE_MS,
29
- LEGACY_GEMINI_PROJECT_CACHE,
30
- LEGACY_GEMINI_QUOTA_CACHE,
31
- LEGACY_GEMINI_RPM_TRACKER,
32
- LEGACY_GEMINI_SESSION_CACHE,
33
10
  SPAWN_LOCK_TTL_MS,
34
11
  } from "../constants.mjs";
35
- import {
36
- clampPercent,
37
- createHttpsPost,
38
- decodeJwtEmail,
39
- makeHash,
40
- readJson,
41
- readJsonMigrate,
42
- writeJsonSafe,
43
- } from "../utils.mjs";
44
-
45
- const httpsPost = createHttpsPost(https, GEMINI_API_TIMEOUT_MS);
46
-
47
- // Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
48
- export function getGeminiRpmLimit(model) {
49
- if (model?.includes("pro")) return 25;
50
- return 300; // Flash 기본
51
- }
52
-
53
- // Gemini 모델 ID → HUD 표시 라벨 (동적 매핑)
54
- export function getGeminiModelLabel(model) {
55
- if (!model) return "";
56
- // 버전 + 티어 추출: gemini-3.1-pro-preview → [3.1Pro], gemini-2.5-flash → [2.5Flash]
57
- const m = model.match(/gemini-(\d+(?:\.\d+)?)-(\w+)/);
58
- if (!m) return "";
59
- const ver = m[1];
60
- const tier = m[2].charAt(0).toUpperCase() + m[2].slice(1);
61
- return `[${ver}${tier}]`;
62
- }
63
-
64
- // remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
65
- export function deriveGeminiLimits(bucket) {
66
- if (!bucket) return null;
67
- // TODO(verify): Workspace quota 응답 모양 라이브 확인 필요
68
- if (isGeminiUnlimitedBucket(bucket, bucket.remainingFraction)) {
69
- return {
70
- unlimited: true,
71
- usedPct: null,
72
- remaining: null,
73
- limit: null,
74
- resetTime: bucket.resetTime,
75
- modelId: bucket.modelId,
76
- };
77
- }
78
- if (bucket.remainingFraction == null) return null;
79
- const fraction = bucket.remainingFraction;
80
- const usedPct = clampPercent(Math.round((1 - fraction) * 100));
81
- // remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
82
- if (bucket.remainingAmount != null) {
83
- const remaining = parseInt(bucket.remainingAmount, 10);
84
- const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
85
- return {
86
- usedPct,
87
- remaining,
88
- limit,
89
- resetTime: bucket.resetTime,
90
- modelId: bucket.modelId,
91
- };
92
- }
93
- return {
94
- usedPct,
95
- remaining: null,
96
- limit: null,
97
- resetTime: bucket.resetTime,
98
- modelId: bucket.modelId,
99
- };
100
- }
101
-
102
- function isUnlimitedValue(value) {
103
- if (value === Infinity) return true;
104
- if (typeof value !== "string") return false;
105
- const normalized = value.trim().toLowerCase();
106
- return normalized === "infinity" || normalized === "unlimited";
107
- }
108
-
109
- function isGeminiUnlimitedBucket(bucket, fraction) {
110
- return (
111
- bucket?.unlimited === true ||
112
- isUnlimitedValue(fraction) ||
113
- isUnlimitedValue(bucket?.limit) ||
114
- isUnlimitedValue(bucket?.remaining) ||
115
- isUnlimitedValue(bucket?.remainingAmount)
116
- );
117
- }
12
+ import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
118
13
 
119
14
  function getCredentialEmail(credential) {
120
15
  return (
@@ -127,558 +22,111 @@ function getCredentialEmail(credential) {
127
22
  );
128
23
  }
129
24
 
130
- export function getGeminiEmail() {
131
- try {
132
- const oauth = readJson(GEMINI_OAUTH_PATH, null);
133
- return getCredentialEmail(oauth);
134
- } catch {
135
- return null;
136
- }
137
- }
138
-
139
- // GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID 를 보여 준다.
25
+ // GCP 프로젝트 인증이면 계정 이메일 대신 프로젝트 ID를 보여 준다.
140
26
  export function getAntigravityAccountLabel() {
141
27
  const project = readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.gcp?.project;
142
28
  if (project) return project;
143
29
  for (const oauthPath of ANTIGRAVITY_OAUTH_PATHS) {
144
30
  try {
145
- const oauth = readJson(oauthPath, null);
146
- const email = getCredentialEmail(oauth);
31
+ const email = getCredentialEmail(readJson(oauthPath, null));
147
32
  if (email) return email;
148
33
  } catch {
149
- // Try the next known Antigravity credential path.
150
- }
151
- }
152
- return null;
153
- }
154
-
155
- // agy 1.0.1+ settings.json `model` 필드에서 현재 장착 모델 라벨을 읽는다.
156
- // 예: "Gemini 3.5 Flash (High)". 없으면 null.
157
- export function getAntigravityCurrentModel() {
158
- const s = readJson(ANTIGRAVITY_SETTINGS_PATH, null);
159
- return s?.model || null;
160
- }
161
-
162
- // 모델 라벨 → 2자 약어 (constants의 ANTIGRAVITY_MODEL_ABBREV 매핑).
163
- // 매핑에 없으면 "<Head> ... (<Level>)" 패턴에서 자동 추출.
164
- export function getAntigravityModelAbbrev(label) {
165
- if (!label) return "??";
166
- const known = ANTIGRAVITY_MODEL_ABBREV[label];
167
- if (known) return known;
168
- const parens = label.match(/\(([^)]+)\)/);
169
- const head = label.match(/^(\w+)/);
170
- if (head && parens) {
171
- return `${head[1][0].toUpperCase()}${parens[1][0].toLowerCase()}`;
172
- }
173
- if (head) return head[1].slice(0, 2);
174
- return "??";
175
- }
176
-
177
- // 모델 라벨 → family ("gemini" | "claude" | "gpt-oss" | "unknown")
178
- // quota 풀이 family 단위로 분리되므로 slot1 데이터 채울지 결정할 때 사용.
179
- export function getAntigravityModelFamily(label) {
180
- if (!label) return "unknown";
181
- if (label.startsWith("Gemini")) return "gemini";
182
- if (label.startsWith("Claude")) return "claude";
183
- if (label.startsWith("GPT")) return "gpt-oss";
184
- return "unknown";
185
- }
186
-
187
- // Gemini family 통합 quota — 모든 bucket 중 최소 remainingFraction을 가진 bucket을 대표로 반환.
188
- // /usage 실측 (2026-05-22): 4개 모델이 같은 reset window 공유하므로 결과는 사실상 family 단일 카운터.
189
- export function deriveGeminiFamilyBucket(buckets) {
190
- if (!Array.isArray(buckets) || buckets.length === 0) return null;
191
- let chosen = null;
192
- for (const b of buckets) {
193
- if (b?.remainingFraction == null) continue;
194
- if (!chosen || b.remainingFraction < chosen.remainingFraction) {
195
- chosen = b;
196
- }
197
- }
198
- return chosen;
199
- }
200
-
201
- export function buildGeminiAuthContext(accountId) {
202
- let oauth = readJson(GEMINI_OAUTH_PATH, null);
203
- let authSource = oauth?.access_token ? "gemini-file" : "none";
204
- let expiryMissing = oauth?.access_token ? oauth.expiry_date == null : false;
205
- const fileExpired =
206
- oauth?.expiry_date != null && oauth.expiry_date < Date.now();
207
- // Preserve a valid Gemini file token; agy Keychain is only a missing/expired fallback.
208
- if (!oauth?.access_token || fileExpired) {
209
- const keychainOAuth = getAntigravityTokenFromKeychain();
210
- if (keychainOAuth?.access_token) {
211
- const {
212
- expiry_date: _dropExpiry,
213
- access_token: _dropToken,
214
- ...fileRest
215
- } = oauth || {};
216
- oauth = { ...fileRest, ...keychainOAuth };
217
- authSource = "antigravity-keychain";
218
- expiryMissing = keychainOAuth.expiry_date == null;
34
+ // 다음 Antigravity 인증 경로를 확인한다.
219
35
  }
220
36
  }
221
- const tokenSource =
222
- oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
223
- const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
224
- const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
225
- return { oauth, tokenFingerprint, cacheKey, authSource, expiryMissing };
226
- }
227
-
228
- function firstPresent(...values) {
229
- for (const value of values) {
230
- if (value != null && value !== "") return value;
231
- }
232
- return null;
233
- }
234
-
235
- function normalizeExpiryDate(value) {
236
- if (value == null || value === "") return null;
237
- if (typeof value === "number" && Number.isFinite(value)) {
238
- return value < 1_000_000_000_000 ? value * 1000 : value;
239
- }
240
- if (typeof value === "string") {
241
- const numeric = Number(value);
242
- if (Number.isFinite(numeric)) return normalizeExpiryDate(numeric);
243
- const parsed = Date.parse(value);
244
- if (Number.isFinite(parsed)) return parsed;
245
- }
246
37
  return null;
247
38
  }
248
39
 
249
- function normalizeAntigravityCredential(parsed) {
250
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
251
- return null;
252
- }
253
- const accessToken = firstPresent(
254
- parsed.access_token,
255
- parsed.accessToken,
256
- parsed.access?.token,
257
- parsed.token,
258
- );
259
- if (!accessToken) return null;
260
- const normalized = { ...parsed, access_token: accessToken };
261
- const idToken = firstPresent(parsed.id_token, parsed.idToken);
262
- if (idToken) normalized.id_token = idToken;
263
- const refreshToken = firstPresent(parsed.refresh_token, parsed.refreshToken);
264
- if (refreshToken) normalized.refresh_token = refreshToken;
265
- const expiryDate = normalizeExpiryDate(
266
- firstPresent(
267
- parsed.expiry_date,
268
- parsed.expiryDate,
269
- parsed.expiry,
270
- parsed.expires_at,
271
- parsed.expiresAt,
272
- ),
273
- );
274
- if (expiryDate != null) normalized.expiry_date = expiryDate;
275
- return normalized;
276
- }
277
-
278
- function getAntigravityTokenFromKeychain() {
279
- // macOS Keychain only; elsewhere `security` is absent (wasted spawn) or a
280
- // different PATH binary entirely, so skip on Linux/Windows and let the
281
- // file-token fallback apply. TFX_HUD_KEYCHAIN_FORCE=1 re-enables the spawn
282
- // for cross-platform tests that inject a fake `security` via PATH.
283
- if (
284
- process.platform !== "darwin" &&
285
- process.env.TFX_HUD_KEYCHAIN_FORCE !== "1"
286
- ) {
287
- return null;
288
- }
289
- try {
290
- const result = spawnSync(
291
- "security",
292
- [
293
- "find-generic-password",
294
- "-s",
295
- ANTIGRAVITY_KEYCHAIN_SERVICE,
296
- "-a",
297
- ANTIGRAVITY_KEYCHAIN_ACCOUNT,
298
- "-w",
299
- ],
300
- { encoding: "utf8", timeout: 3000 },
301
- );
302
- if (result.status !== 0) return null;
303
- const stdout = result.stdout?.trim();
304
- if (!stdout) return null;
305
- try {
306
- return normalizeAntigravityCredential(JSON.parse(stdout));
307
- } catch {
308
- return { access_token: stdout };
309
- }
310
- } catch {
311
- return null;
312
- }
313
- }
314
-
315
- export function classifyGeminiQuotaFailure(response, authContext = {}) {
316
- if (!response) return "network";
317
- const error = response?.error || {};
318
- const code = Number(error.code ?? response.code ?? response.status);
319
- const status = String(error.status || response.status || "").toUpperCase();
320
- const message = String(
321
- error.message || error.code || response.error || response.message || "",
322
- );
323
- if (
324
- code === 401 ||
325
- code === 403 ||
326
- status === "UNAUTHENTICATED" ||
327
- status === "PERMISSION_DENIED" ||
328
- /(unauthorized|forbidden|invalid authentication|invalid credentials|oauth|token|credential|auth)/i.test(
329
- message,
40
+ function isGcpProjectAuth() {
41
+ return Boolean(readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.gcp?.project);
42
+ }
43
+
44
+ export function readAntigravityQuotaSnapshot() {
45
+ // 프로젝트 과금의 기본 응답을 개인 플랜의 0% 사용률로 표시하지 않는다.
46
+ if (isGcpProjectAuth()) return { data: null, shouldRefresh: false };
47
+ const cache = readJson(ANTIGRAVITY_QUOTA_CACHE_PATH, null);
48
+ const matched = cache?.accountLabel === getAntigravityAccountLabel();
49
+ const stale =
50
+ !cache?.timestamp ||
51
+ Date.now() - cache.timestamp >= ANTIGRAVITY_QUOTA_STALE_MS;
52
+ const model = readJson(ANTIGRAVITY_SETTINGS_PATH, null)?.model;
53
+ const buckets = matched && Array.isArray(cache?.buckets) ? cache.buckets : [];
54
+ const bucket = buckets
55
+ .filter(
56
+ (item) =>
57
+ model &&
58
+ item &&
59
+ (item?.name === model || item?.id === model) &&
60
+ Number.isFinite(item.remaining_fraction) &&
61
+ item.remaining_fraction >= 0 &&
62
+ item.remaining_fraction <= 1 &&
63
+ Date.parse(item.reset_time) > Date.now(),
330
64
  )
331
- ) {
332
- return "auth";
333
- }
334
- if (
335
- authContext.authSource === "antigravity-keychain" &&
336
- authContext.expiryMissing === true &&
337
- /(expired|invalid|unauthenticated|permission denied)/i.test(message)
338
- ) {
339
- return "auth";
340
- }
341
- return "api";
342
- }
343
-
344
- function formatGeminiQuotaFailure(response, authContext = {}, stage = "quota") {
345
- const error =
346
- response?.error?.message ||
347
- response?.error?.status ||
348
- response?.error?.code ||
349
- response?.error ||
350
- response?.message ||
351
- "no buckets in response";
352
- const base = String(error);
353
- if (
354
- authContext.authSource === "antigravity-keychain" &&
355
- authContext.expiryMissing === true
356
- ) {
357
- return `expiry-less Antigravity Keychain token failed bounded ${stage} freshness probe: ${base}`;
358
- }
359
- return base;
360
- }
361
-
362
- function writeGeminiQuotaErrorCache(cache, authContext, errorType, errorHint) {
363
- const sameKey = cache?.cacheKey === authContext.cacheKey;
364
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
365
- ...(sameKey ? cache || {} : {}),
366
- timestamp: sameKey && cache?.timestamp ? cache.timestamp : Date.now(),
367
- cacheKey: authContext.cacheKey,
368
- accountId: authContext.accountId || "gemini-main",
369
- tokenFingerprint: authContext.tokenFingerprint,
370
- authSource: authContext.authSource,
371
- expiryMissing: authContext.expiryMissing === true,
372
- error: true,
373
- errorType,
374
- errorHint,
375
- });
376
- }
377
-
378
- // ============================================================================
379
- // Gemini 쿼터 API 호출 (5분 캐시)
380
- // ============================================================================
381
- export async function fetchGeminiQuota(accountId, options = {}) {
382
- const authContext = options.authContext || buildGeminiAuthContext(accountId);
383
- const { oauth, tokenFingerprint, cacheKey } = authContext;
384
- authContext.accountId = accountId || "gemini-main";
385
- const forceRefresh = options.forceRefresh === true;
386
-
387
- // 1. 캐시 확인 (계정/토큰별)
388
- const cache = readJsonMigrate(
389
- GEMINI_QUOTA_CACHE_PATH,
390
- LEGACY_GEMINI_QUOTA_CACHE,
391
- null,
392
- );
393
- if (
394
- !forceRefresh &&
395
- cache?.cacheKey === cacheKey &&
396
- cache?.timestamp &&
397
- Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS
398
- ) {
399
- return cache;
400
- }
401
-
402
- if (!oauth?.access_token) {
403
- // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
404
- writeGeminiQuotaErrorCache(
405
- cache,
406
- authContext,
407
- "auth",
408
- "no access_token in oauth_creds.json",
409
- );
410
- return cache;
411
- }
412
- if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
413
- // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
414
- writeGeminiQuotaErrorCache(
415
- cache,
416
- authContext,
417
- "auth",
418
- `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
419
- );
420
- return cache;
421
- }
422
-
423
- // 3. projectId (캐시 or API)
424
- let loadCodeAssistResponse = null;
425
- const fetchProjectId = async () => {
426
- loadCodeAssistResponse = await httpsPost(
427
- "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
428
- { metadata: { pluginType: "GEMINI" } },
429
- oauth.access_token,
430
- );
431
- const id = loadCodeAssistResponse?.cloudaicompanionProject;
432
- if (id)
433
- writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, {
434
- cacheKey,
435
- projectId: id,
436
- timestamp: Date.now(),
437
- });
438
- return id || null;
439
- };
440
-
441
- const projCache = readJsonMigrate(
442
- GEMINI_PROJECT_CACHE_PATH,
443
- LEGACY_GEMINI_PROJECT_CACHE,
444
- null,
445
- );
446
- let projectId =
447
- projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
448
- if (!projectId) projectId = await fetchProjectId();
449
- if (!projectId) {
450
- writeGeminiQuotaErrorCache(
451
- cache,
452
- authContext,
453
- classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
454
- formatGeminiQuotaFailure(
455
- loadCodeAssistResponse,
456
- authContext,
457
- "loadCodeAssist",
458
- ),
459
- );
460
- return cache;
461
- }
462
-
463
- // 4. retrieveUserQuota 호출
464
- let quotaRes = await httpsPost(
465
- "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
466
- { project: projectId },
467
- oauth.access_token,
468
- );
469
-
470
- // projectId 캐시가 만료/변경된 경우 1회 재시도
471
- if (!quotaRes?.buckets && projCache?.projectId) {
472
- projectId = await fetchProjectId();
473
- if (!projectId) {
474
- writeGeminiQuotaErrorCache(
475
- cache,
476
- authContext,
477
- classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
478
- formatGeminiQuotaFailure(
479
- loadCodeAssistResponse,
480
- authContext,
481
- "loadCodeAssist",
482
- ),
483
- );
484
- return cache;
485
- }
486
- quotaRes = await httpsPost(
487
- "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
488
- { project: projectId },
489
- oauth.access_token,
490
- );
491
- }
492
-
493
- if (!quotaRes?.buckets) {
494
- // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
495
- writeGeminiQuotaErrorCache(
496
- cache,
497
- authContext,
498
- classifyGeminiQuotaFailure(quotaRes, authContext),
499
- formatGeminiQuotaFailure(quotaRes, authContext, "quota"),
500
- );
501
- return cache;
502
- }
503
-
504
- // 5. 캐시 저장
505
- const result = {
506
- timestamp: Date.now(),
507
- cacheKey,
508
- accountId: accountId || "gemini-main",
509
- tokenFingerprint,
510
- buckets: quotaRes.buckets,
65
+ .sort((a, b) => a.remaining_fraction - b.remaining_fraction)[0];
66
+ return {
67
+ data: bucket
68
+ ? {
69
+ usedPercent: Math.round((1 - bucket.remaining_fraction) * 100),
70
+ resetTime: bucket.reset_time,
71
+ stale,
72
+ }
73
+ : null,
74
+ shouldRefresh: !matched || stale,
511
75
  };
512
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, result);
513
- return result;
514
76
  }
515
77
 
516
- /**
517
- * Gemini RPM 트래커에서 최근 60초 내 요청 수를 읽는다.
518
- * @returns {{ count: number, percent: number, remainingSec: number }}
519
- */
520
- export function readGeminiRpm(model) {
78
+ // /usage는 모델 턴을 시작하지 않는 공식 print-mode 조회다.
79
+ export function refreshAntigravityQuotaCache(execFileSyncFn = execFileSync) {
80
+ if (isGcpProjectAuth()) return null;
81
+ const accountLabel = getAntigravityAccountLabel();
82
+ let buckets = null;
521
83
  try {
522
- // 새 경로 → 레거시 경로 fallback
523
- const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH)
524
- ? GEMINI_RPM_TRACKER_PATH
525
- : existsSync(LEGACY_GEMINI_RPM_TRACKER)
526
- ? LEGACY_GEMINI_RPM_TRACKER
527
- : null;
528
- if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
529
- const raw = readFileSync(rpmPath, "utf-8");
530
- const parsed = JSON.parse(raw);
531
- const timestamps = Array.isArray(parsed.timestamps)
532
- ? parsed.timestamps
533
- : [];
534
- const now = Date.now();
535
- const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
536
- const count = recent.length;
537
- const rpmLimit = getGeminiRpmLimit(model);
538
- const percent = clampPercent(Math.round((count / rpmLimit) * 100));
539
- // 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
540
- // 5초 단위 반올림으로 HUD 깜빡임 감소
541
- const rawRemainingSec =
542
- recent.length > 0
543
- ? Math.max(
544
- 0,
545
- Math.ceil(
546
- (GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000,
547
- ),
548
- )
549
- : 0;
550
- const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
551
- return { count, percent, remainingSec };
552
- } catch {
553
- return { count: 0, percent: 0, remainingSec: 60 };
554
- }
555
- }
556
-
557
- export function readGeminiQuotaSnapshot(accountId, authContext) {
558
- const cache = readJsonMigrate(
559
- GEMINI_QUOTA_CACHE_PATH,
560
- LEGACY_GEMINI_QUOTA_CACHE,
561
- null,
562
- );
563
- if (!cache?.buckets) {
564
- return { quota: null, shouldRefresh: true };
565
- }
566
-
567
- const cacheKey = authContext.cacheKey;
568
- const isLegacyCache = !cache.cacheKey;
569
- const keyMatched = cache.cacheKey === cacheKey;
570
- const cacheTs = Number(cache.timestamp);
571
- const ageMs = Number.isFinite(cacheTs)
572
- ? Date.now() - cacheTs
573
- : Number.MAX_SAFE_INTEGER;
574
- const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
575
-
576
- if (keyMatched) {
577
- // resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
578
- if (Array.isArray(cache.buckets)) {
579
- const now = Date.now();
580
- const patchedBuckets = cache.buckets.map((b) =>
581
- b?.resetTime && new Date(b.resetTime).getTime() <= now
582
- ? { ...b, remainingFraction: 1 }
583
- : b,
84
+ const result = JSON.parse(
85
+ execFileSyncFn("agy", ["-p", "/usage", "--output-format", "json"], {
86
+ encoding: "utf8",
87
+ timeout: 15_000,
88
+ maxBuffer: 1024 * 1024,
89
+ stdio: ["ignore", "pipe", "ignore"],
90
+ windowsHide: true,
91
+ }),
92
+ );
93
+ if (
94
+ result.status === "SUCCESS" &&
95
+ result.command?.name === "usage" &&
96
+ Array.isArray(result.command.data?.groups)
97
+ ) {
98
+ buckets = result.command.data.groups.flatMap((group) =>
99
+ Array.isArray(group?.buckets) ? group.buckets : [],
584
100
  );
585
- return {
586
- quota: { ...cache, buckets: patchedBuckets },
587
- shouldRefresh: !isFresh,
588
- };
589
- }
590
- return { quota: cache, shouldRefresh: !isFresh };
591
- }
592
- if (isLegacyCache) {
593
- return { quota: cache, shouldRefresh: true };
594
- }
595
- return { quota: null, shouldRefresh: true };
596
- }
597
-
598
- export function scheduleGeminiQuotaRefresh(accountId) {
599
- const scriptPath = process.argv[1];
600
- if (!scriptPath) return;
601
-
602
- // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
603
- try {
604
- if (existsSync(GEMINI_QUOTA_REFRESH_LOCK_PATH)) {
605
- const lockAge =
606
- Date.now() - readJson(GEMINI_QUOTA_REFRESH_LOCK_PATH, {}).t;
607
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
608
101
  }
609
- writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
610
102
  } catch {
611
- /* 락 실패 무시 — 스폰 진행 */
103
+ // 실패도 캐시해 인증 오류나 CLI 부재 시 매 렌더마다 실행하지 않는다.
612
104
  }
613
-
614
- try {
615
- const child = spawn(
616
- process.execPath,
617
- [
618
- scriptPath,
619
- GEMINI_REFRESH_FLAG,
620
- "--account",
621
- accountId || "gemini-main",
622
- ],
623
- {
624
- detached: true,
625
- stdio: "ignore",
626
- windowsHide: true,
627
- },
628
- );
629
- child.unref();
630
- } catch (spawnErr) {
631
- // spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
632
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
105
+ if (accountLabel === getAntigravityAccountLabel()) {
106
+ writeJsonSafe(ANTIGRAVITY_QUOTA_CACHE_PATH, {
633
107
  timestamp: Date.now(),
634
- error: true,
635
- errorHint: String(spawnErr?.message || spawnErr),
108
+ accountLabel,
109
+ buckets,
636
110
  });
637
111
  }
112
+ return buckets;
638
113
  }
639
114
 
640
- export function readGeminiSessionSnapshot() {
641
- const cache = readJsonMigrate(
642
- GEMINI_SESSION_CACHE_PATH,
643
- LEGACY_GEMINI_SESSION_CACHE,
644
- null,
645
- );
646
- if (!cache?.session) {
647
- return { session: null, shouldRefresh: true };
648
- }
649
- const ts = Number(cache.timestamp);
650
- const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
651
- const isFresh = ageMs < GEMINI_SESSION_STALE_MS;
652
- return { session: cache.session, shouldRefresh: !isFresh };
653
- }
654
-
655
- export function refreshGeminiSessionCache() {
656
- const session = scanGeminiSessionTokens();
657
- if (!session) return null;
658
- writeJsonSafe(GEMINI_SESSION_CACHE_PATH, { timestamp: Date.now(), session });
659
- return session;
660
- }
661
-
662
- export function scheduleGeminiSessionRefresh() {
115
+ export function scheduleAntigravityQuotaRefresh() {
663
116
  const scriptPath = process.argv[1];
664
- if (!scriptPath) return;
665
-
666
- // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
667
- try {
668
- if (existsSync(GEMINI_SESSION_REFRESH_LOCK_PATH)) {
669
- const lockAge =
670
- Date.now() - readJson(GEMINI_SESSION_REFRESH_LOCK_PATH, {}).t;
671
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
672
- }
673
- writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
674
- } catch {
675
- /* 락 실패 무시 — 스폰 진행 */
676
- }
677
-
117
+ if (
118
+ !scriptPath ||
119
+ !existsSync(ANTIGRAVITY_SETTINGS_PATH) ||
120
+ isGcpProjectAuth()
121
+ )
122
+ return;
123
+ const lock = readJson(ANTIGRAVITY_REFRESH_LOCK_PATH, null);
124
+ if (Date.now() - Number(lock?.t || 0) < SPAWN_LOCK_TTL_MS) return;
125
+ writeJsonSafe(ANTIGRAVITY_REFRESH_LOCK_PATH, { t: Date.now() });
678
126
  try {
679
127
  const child = spawn(
680
128
  process.execPath,
681
- [scriptPath, GEMINI_SESSION_REFRESH_FLAG],
129
+ [scriptPath, ANTIGRAVITY_REFRESH_FLAG],
682
130
  {
683
131
  detached: true,
684
132
  stdio: "ignore",
@@ -687,60 +135,6 @@ export function scheduleGeminiSessionRefresh() {
687
135
  );
688
136
  child.unref();
689
137
  } catch {
690
- /* 백그라운드 실행 실패 무시 */
691
- }
692
- }
693
-
694
- // ============================================================================
695
- // Gemini 세션 JSON에서 토큰 사용량 추출
696
- // ============================================================================
697
- export function scanGeminiSessionTokens() {
698
- const tmpDir = join(homedir(), ".gemini", "tmp");
699
- if (!existsSync(tmpDir)) return null;
700
- let best = null;
701
- let bestTime = 0;
702
- try {
703
- const dirs = readdirSync(tmpDir).filter((d) =>
704
- existsSync(join(tmpDir, d, "chats")),
705
- );
706
- for (const dir of dirs) {
707
- const chatsDir = join(tmpDir, dir, "chats");
708
- let files;
709
- try {
710
- files = readdirSync(chatsDir).filter((f) => f.endsWith(".json"));
711
- } catch {
712
- continue;
713
- }
714
- for (const file of files) {
715
- try {
716
- const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
717
- const updatedAt = new Date(data.lastUpdated || 0).getTime();
718
- if (updatedAt <= bestTime) continue;
719
- let input = 0,
720
- output = 0;
721
- let model = "unknown";
722
- for (const msg of data.messages || []) {
723
- if (msg.tokens) {
724
- input += msg.tokens.input || 0;
725
- output += msg.tokens.output || 0;
726
- }
727
- if (msg.model) model = msg.model;
728
- }
729
- bestTime = updatedAt;
730
- best = {
731
- input,
732
- output,
733
- total: input + output,
734
- model,
735
- lastUpdated: data.lastUpdated,
736
- };
737
- } catch {
738
- /* 무시 */
739
- }
740
- }
741
- }
742
- } catch {
743
- /* 무시 */
138
+ // 다음 렌더에서 다시 시도한다.
744
139
  }
745
- return best;
746
140
  }