@triflux/core 10.1.0 → 10.26.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 (145) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +191 -31
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +262 -34
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +1051 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +22 -21
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/schema/delegator-tools.schema.json +1 -1
  35. package/hub/delegator/service.mjs +93 -64
  36. package/hub/delegator/tool-definitions.mjs +35 -35
  37. package/hub/dynamic-routing-engine.mjs +511 -0
  38. package/hub/fullcycle.mjs +34 -18
  39. package/hub/gemini-adapter.mjs +82 -108
  40. package/hub/hitl.mjs +202 -143
  41. package/hub/intent.mjs +167 -38
  42. package/hub/lib/bash-path.mjs +73 -0
  43. package/hub/lib/cache-guard.mjs +43 -17
  44. package/hub/lib/env-detect.mjs +145 -0
  45. package/hub/lib/hosts-compat.mjs +319 -0
  46. package/hub/lib/mcp-response-cache.mjs +239 -0
  47. package/hub/lib/memory-store.mjs +285 -111
  48. package/hub/lib/path-utils.mjs +165 -0
  49. package/hub/lib/phase-manager.mjs +493 -0
  50. package/hub/lib/process-utils.mjs +797 -104
  51. package/hub/lib/prompt-tmp.mjs +33 -0
  52. package/hub/lib/risk-tier.mjs +53 -0
  53. package/hub/lib/spawn-trace.mjs +561 -0
  54. package/hub/lib/ssh-command.mjs +44 -24
  55. package/hub/lib/ssh-retry.mjs +5 -1
  56. package/hub/lib/staged-file-detect.mjs +43 -0
  57. package/hub/lib/state-snapshot.mjs +293 -0
  58. package/hub/lib/tfx-route-args.mjs +309 -0
  59. package/hub/lib/timeline-adapter.mjs +190 -0
  60. package/hub/lib/trace-recorder.mjs +153 -0
  61. package/hub/lib/uuidv7.mjs +4 -3
  62. package/hub/memory-doctor.mjs +266 -106
  63. package/hub/middleware/quota-middleware.mjs +70 -0
  64. package/hub/middleware/request-logger.mjs +61 -34
  65. package/hub/paths.mjs +9 -9
  66. package/hub/pipeline/gates/confidence.mjs +34 -15
  67. package/hub/pipeline/gates/consensus.mjs +27 -15
  68. package/hub/pipeline/gates/index.mjs +7 -3
  69. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  70. package/hub/pipeline/index.mjs +77 -42
  71. package/hub/pipeline/state.mjs +10 -10
  72. package/hub/pipeline/transitions.mjs +40 -23
  73. package/hub/platform.mjs +65 -48
  74. package/hub/quality/deslop.mjs +70 -49
  75. package/hub/reflexion.mjs +87 -13
  76. package/hub/research.mjs +33 -25
  77. package/hub/router.mjs +941 -791
  78. package/hub/routing/complexity.mjs +132 -29
  79. package/hub/routing/index.mjs +17 -12
  80. package/hub/routing/q-learning.mjs +78 -30
  81. package/hub/routing-snapshot.mjs +261 -0
  82. package/hub/session-fingerprint.mjs +127 -61
  83. package/hub/state.mjs +86 -44
  84. package/hub/team/claude-daemon-control.mjs +1130 -0
  85. package/hub/team/retry-state-machine.mjs +336 -0
  86. package/hub/token-mode.mjs +72 -71
  87. package/hub/workers/worker-utils.mjs +23 -17
  88. package/hud/colors.mjs +27 -9
  89. package/hud/constants.mjs +190 -27
  90. package/hud/context-monitor.mjs +133 -44
  91. package/hud/hud-qos-status.mjs +157 -50
  92. package/hud/mission-board.mjs +56 -0
  93. package/hud/providers/claude.mjs +398 -114
  94. package/hud/providers/codex.mjs +96 -21
  95. package/hud/providers/gemini.mjs +210 -52
  96. package/hud/renderers.mjs +440 -127
  97. package/hud/terminal.mjs +101 -31
  98. package/hud/utils.mjs +81 -39
  99. package/mesh/index.mjs +69 -0
  100. package/mesh/mesh-budget.mjs +137 -0
  101. package/mesh/mesh-heartbeat.mjs +100 -0
  102. package/mesh/mesh-protocol.mjs +96 -0
  103. package/mesh/mesh-queue.mjs +163 -0
  104. package/mesh/mesh-registry.mjs +78 -0
  105. package/mesh/mesh-router.mjs +77 -0
  106. package/package.json +5 -1
  107. package/scripts/lib/agent-json.mjs +27 -0
  108. package/scripts/lib/async.mjs +174 -0
  109. package/scripts/lib/claudemd-scanner.mjs +6 -1
  110. package/scripts/lib/cli-agy.mjs +62 -0
  111. package/scripts/lib/cli-claude.mjs +78 -0
  112. package/scripts/lib/cli-codex.mjs +199 -0
  113. package/scripts/lib/cli-gemini.mjs +37 -0
  114. package/scripts/lib/codex-recovery.sh +50 -0
  115. package/scripts/lib/context.mjs +3 -3
  116. package/scripts/lib/cross-review-utils.mjs +13 -5
  117. package/scripts/lib/doctor-env-checks.mjs +121 -0
  118. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  119. package/scripts/lib/env-probe.mjs +58 -30
  120. package/scripts/lib/env.mjs +14 -0
  121. package/scripts/lib/gemini-profiles.mjs +44 -10
  122. package/scripts/lib/handoff.mjs +33 -17
  123. package/scripts/lib/hook-utils.mjs +8 -6
  124. package/scripts/lib/hub.mjs +48 -0
  125. package/scripts/lib/keyword-rules.mjs +49 -20
  126. package/scripts/lib/logger.mjs +24 -24
  127. package/scripts/lib/mcp-filter.mjs +391 -247
  128. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  129. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  130. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  131. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  132. package/scripts/lib/mcp-health.mjs +773 -0
  133. package/scripts/lib/mcp-manifest.mjs +24 -14
  134. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  135. package/scripts/lib/pid.mjs +63 -0
  136. package/scripts/lib/process-utils.mjs +26 -0
  137. package/scripts/lib/psmux-info.mjs +117 -12
  138. package/scripts/lib/quota.mjs +47 -0
  139. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  140. package/scripts/lib/skill-state.mjs +220 -0
  141. package/scripts/lib/skill-template.mjs +32 -7
  142. package/scripts/lib/team.mjs +75 -0
  143. package/scripts/lib/timeout.mjs +66 -0
  144. package/scripts/lib/tmp.mjs +26 -0
  145. package/scripts/lib/toml.mjs +113 -0
@@ -1,21 +1,52 @@
1
1
  // ============================================================================
2
2
  // Codex rate limits 추출 / 캐싱
3
3
  // ============================================================================
4
- import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
7
  import { homedir } from "node:os";
6
8
  import { join } from "node:path";
7
- import { spawn } from "node:child_process";
8
9
  import {
9
- CODEX_AUTH_PATH, CODEX_QUOTA_CACHE_PATH, CODEX_QUOTA_STALE_MS,
10
- CODEX_MIN_BUCKETS, CODEX_REFRESH_FLAG,
10
+ CODEX_AUTH_PATH,
11
+ CODEX_MIN_BUCKETS,
12
+ CODEX_QUOTA_CACHE_PATH,
13
+ CODEX_QUOTA_STALE_MS,
14
+ CODEX_REFRESH_FLAG,
15
+ CODEX_REFRESH_LOCK_PATH,
16
+ SPAWN_LOCK_TTL_MS,
11
17
  } from "../constants.mjs";
12
- import { readJson, writeJsonSafe, decodeJwtEmail } from "../utils.mjs";
18
+ import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
19
+
20
+ // window_minutes 기반 5h/1w 슬롯 분류
21
+ // 5h: ≤360min (300min 표준, ±20% 허용)
22
+ // weekly: ≥7000min (10080min 표준, ~5d 이상만 허용 — 24h/48h 등 중간 버킷 도입 시 silent 오분류 방지)
23
+ export function classifyBucket(bucket) {
24
+ if (!bucket?.window_minutes) return null;
25
+ if (bucket.window_minutes <= 360) return "five_hour";
26
+ if (bucket.window_minutes >= 7000) return "weekly";
27
+ return null;
28
+ }
29
+
30
+ // primary/secondary를 window_minutes 기준으로 정규화
31
+ // HUD 규약: primary=5h, secondary=1w
32
+ export function normalizeBuckets(rl) {
33
+ let primary = null;
34
+ let secondary = null;
35
+ for (const bucket of [rl.primary, rl.secondary].filter(Boolean)) {
36
+ const kind = classifyBucket(bucket);
37
+ if (kind === "five_hour") primary = bucket;
38
+ else if (kind === "weekly") secondary = bucket;
39
+ }
40
+ return { primary, secondary };
41
+ }
13
42
 
14
43
  export function getCodexEmail() {
15
44
  try {
16
45
  const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
17
46
  return decodeJwtEmail(auth?.tokens?.id_token);
18
- } catch { return null; }
47
+ } catch {
48
+ return null;
49
+ }
19
50
  }
20
51
 
21
52
  // resets_at이 지난 윈도우의 used_percent를 0으로 보정
@@ -24,13 +55,22 @@ export function expireStaleCodexBuckets(buckets) {
24
55
  const nowSec = Math.floor(Date.now() / 1000);
25
56
  const result = {};
26
57
  for (const [key, bucket] of Object.entries(buckets)) {
27
- if (!bucket) { result[key] = bucket; continue; }
58
+ if (!bucket) {
59
+ result[key] = bucket;
60
+ continue;
61
+ }
28
62
  let updated = bucket;
29
63
  if (bucket.primary?.resets_at && bucket.primary.resets_at <= nowSec) {
30
- updated = { ...updated, primary: { ...updated.primary, used_percent: 0 } };
64
+ updated = {
65
+ ...updated,
66
+ primary: { ...updated.primary, used_percent: 0 },
67
+ };
31
68
  }
32
69
  if (bucket.secondary?.resets_at && bucket.secondary.resets_at <= nowSec) {
33
- updated = { ...updated, secondary: { ...updated.secondary, used_percent: 0 } };
70
+ updated = {
71
+ ...updated,
72
+ secondary: { ...updated.secondary, used_percent: 0 },
73
+ };
34
74
  }
35
75
  result[key] = updated;
36
76
  }
@@ -51,15 +91,23 @@ export function getCodexRateLimits() {
51
91
  for (let dayOffset = 0; dayOffset <= 6; dayOffset++) {
52
92
  const d = new Date(now.getTime() - dayOffset * 86_400_000);
53
93
  const sessDir = join(
54
- homedir(), ".codex", "sessions",
94
+ homedir(),
95
+ ".codex",
96
+ "sessions",
55
97
  String(d.getFullYear()),
56
98
  String(d.getMonth() + 1).padStart(2, "0"),
57
99
  String(d.getDate()).padStart(2, "0"),
58
100
  );
59
101
  if (!existsSync(sessDir)) continue;
60
102
  let files;
61
- try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
62
- catch { continue; }
103
+ try {
104
+ files = readdirSync(sessDir)
105
+ .filter((f) => f.endsWith(".jsonl"))
106
+ .sort()
107
+ .reverse();
108
+ } catch {
109
+ continue;
110
+ }
63
111
 
64
112
  const mergedBuckets = {};
65
113
  for (const file of files) {
@@ -71,35 +119,50 @@ export function getCodexRateLimits() {
71
119
  const evt = JSON.parse(line);
72
120
  const rl = evt?.payload?.rate_limits;
73
121
  if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
74
- // 실제 rate_limits: limit_id별 최신 이벤트만 기록
122
+ // window_minutes 기준으로 5h/1w 슬롯 정규화
123
+ const { primary, secondary } = normalizeBuckets(rl);
75
124
  mergedBuckets[rl.limit_id] = {
76
- limitId: rl.limit_id, limitName: rl.limit_name,
77
- primary: rl.primary, secondary: rl.secondary,
125
+ limitId: rl.limit_id,
126
+ limitName: rl.limit_name,
127
+ primary,
128
+ secondary,
78
129
  credits: rl.credits,
79
130
  tokens: evt.payload?.info?.total_token_usage,
80
131
  contextWindow: evt.payload?.info?.model_context_window,
81
132
  timestamp: evt.timestamp,
82
133
  };
83
- } else if (dayOffset <= 1 && !rl && evt?.payload?.info?.total_token_usage && !syntheticBucket) {
134
+ } else if (
135
+ dayOffset <= 1 &&
136
+ !rl &&
137
+ evt?.payload?.info?.total_token_usage &&
138
+ !syntheticBucket
139
+ ) {
84
140
  // 2일 이내 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용, stale 방지)
85
141
  syntheticBucket = {
86
- limitId: "codex", limitName: "codex-session",
87
- primary: null, secondary: null,
142
+ limitId: "codex",
143
+ limitName: "codex-session",
144
+ primary: null,
145
+ secondary: null,
88
146
  credits: null,
89
147
  tokens: evt.payload.info.total_token_usage,
90
148
  contextWindow: evt.payload.info.model_context_window,
91
149
  timestamp: evt.timestamp,
92
150
  };
93
151
  }
94
- } catch { /* 라인 파싱 실패 무시 */ }
152
+ } catch {
153
+ /* 라인 파싱 실패 무시 */
154
+ }
95
155
  if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
96
156
  }
97
- } catch { /* 파일 읽기 실패 무시 */ }
157
+ } catch {
158
+ /* 파일 읽기 실패 무시 */
159
+ }
98
160
  }
99
161
  // 실제 rate_limits 발견 → 토큰 데이터 병합 후 즉시 반환
100
162
  if (Object.keys(mergedBuckets).length > 0) {
101
163
  if (syntheticBucket) {
102
- const main = mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
164
+ const main =
165
+ mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
103
166
  if (main && !main.tokens) main.tokens = syntheticBucket.tokens;
104
167
  }
105
168
  expireStaleCodexBuckets(mergedBuckets);
@@ -132,6 +195,18 @@ export function refreshCodexRateLimitsCache() {
132
195
  export function scheduleCodexRateLimitRefresh() {
133
196
  const scriptPath = process.argv[1];
134
197
  if (!scriptPath) return;
198
+
199
+ // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
200
+ try {
201
+ if (existsSync(CODEX_REFRESH_LOCK_PATH)) {
202
+ const lockAge = Date.now() - readJson(CODEX_REFRESH_LOCK_PATH, {}).t;
203
+ if (lockAge < SPAWN_LOCK_TTL_MS) return;
204
+ }
205
+ writeJsonSafe(CODEX_REFRESH_LOCK_PATH, { t: Date.now() });
206
+ } catch {
207
+ /* 락 실패 무시 — 스폰 진행 */
208
+ }
209
+
135
210
  try {
136
211
  const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
137
212
  detached: true,
@@ -1,30 +1,48 @@
1
1
  // ============================================================================
2
2
  // Gemini 쿼터 API / 세션 토큰 / RPM 트래커
3
3
  // ============================================================================
4
- import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import https from "node:https";
5
8
  import { homedir } from "node:os";
6
9
  import { join } from "node:path";
7
- import https from "node:https";
8
- import { spawn } from "node:child_process";
9
10
  import {
10
- GEMINI_OAUTH_PATH, GEMINI_QUOTA_CACHE_PATH, GEMINI_PROJECT_CACHE_PATH,
11
- GEMINI_SESSION_CACHE_PATH, GEMINI_RPM_TRACKER_PATH,
12
- LEGACY_GEMINI_QUOTA_CACHE, LEGACY_GEMINI_PROJECT_CACHE,
13
- LEGACY_GEMINI_SESSION_CACHE, LEGACY_GEMINI_RPM_TRACKER,
14
- GEMINI_RPM_WINDOW_MS, GEMINI_QUOTA_STALE_MS, GEMINI_SESSION_STALE_MS,
11
+ ANTIGRAVITY_OAUTH_PATHS,
15
12
  GEMINI_API_TIMEOUT_MS,
16
- GEMINI_REFRESH_FLAG, GEMINI_SESSION_REFRESH_FLAG,
13
+ GEMINI_OAUTH_PATH,
14
+ GEMINI_PROJECT_CACHE_PATH,
15
+ GEMINI_QUOTA_CACHE_PATH,
16
+ GEMINI_QUOTA_REFRESH_LOCK_PATH,
17
+ GEMINI_QUOTA_STALE_MS,
18
+ GEMINI_REFRESH_FLAG,
19
+ GEMINI_RPM_TRACKER_PATH,
20
+ GEMINI_RPM_WINDOW_MS,
21
+ GEMINI_SESSION_CACHE_PATH,
22
+ GEMINI_SESSION_REFRESH_FLAG,
23
+ GEMINI_SESSION_REFRESH_LOCK_PATH,
24
+ GEMINI_SESSION_STALE_MS,
25
+ LEGACY_GEMINI_PROJECT_CACHE,
26
+ LEGACY_GEMINI_QUOTA_CACHE,
27
+ LEGACY_GEMINI_RPM_TRACKER,
28
+ LEGACY_GEMINI_SESSION_CACHE,
29
+ SPAWN_LOCK_TTL_MS,
17
30
  } from "../constants.mjs";
18
31
  import {
19
- readJson, writeJsonSafe, readJsonMigrate, makeHash, clampPercent,
20
- decodeJwtEmail, createHttpsPost,
32
+ clampPercent,
33
+ createHttpsPost,
34
+ decodeJwtEmail,
35
+ makeHash,
36
+ readJson,
37
+ readJsonMigrate,
38
+ writeJsonSafe,
21
39
  } from "../utils.mjs";
22
40
 
23
41
  const httpsPost = createHttpsPost(https, GEMINI_API_TIMEOUT_MS);
24
42
 
25
43
  // Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
26
44
  export function getGeminiRpmLimit(model) {
27
- if (model && model.includes("pro")) return 25;
45
+ if (model?.includes("pro")) return 25;
28
46
  return 300; // Flash 기본
29
47
  }
30
48
 
@@ -48,21 +66,60 @@ export function deriveGeminiLimits(bucket) {
48
66
  if (bucket.remainingAmount != null) {
49
67
  const remaining = parseInt(bucket.remainingAmount, 10);
50
68
  const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
51
- return { usedPct, remaining, limit, resetTime: bucket.resetTime, modelId: bucket.modelId };
69
+ return {
70
+ usedPct,
71
+ remaining,
72
+ limit,
73
+ resetTime: bucket.resetTime,
74
+ modelId: bucket.modelId,
75
+ };
52
76
  }
53
- return { usedPct, remaining: null, limit: null, resetTime: bucket.resetTime, modelId: bucket.modelId };
77
+ return {
78
+ usedPct,
79
+ remaining: null,
80
+ limit: null,
81
+ resetTime: bucket.resetTime,
82
+ modelId: bucket.modelId,
83
+ };
84
+ }
85
+
86
+ function getCredentialEmail(credential) {
87
+ return (
88
+ decodeJwtEmail(credential?.id_token) ||
89
+ credential?.email ||
90
+ credential?.account?.email ||
91
+ credential?.user?.email ||
92
+ credential?.profile?.email ||
93
+ null
94
+ );
54
95
  }
55
96
 
56
97
  export function getGeminiEmail() {
57
98
  try {
58
99
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
59
- return decodeJwtEmail(oauth?.id_token);
60
- } catch { return null; }
100
+ return getCredentialEmail(oauth);
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ export function getAntigravityEmail() {
107
+ for (const oauthPath of ANTIGRAVITY_OAUTH_PATHS) {
108
+ try {
109
+ const oauth = readJson(oauthPath, null);
110
+ const email = getCredentialEmail(oauth);
111
+ if (email) return email;
112
+ } catch {
113
+ // Try the next known Antigravity credential path.
114
+ }
115
+ }
116
+ return null;
61
117
  }
62
118
 
63
119
  export function buildGeminiAuthContext(accountId) {
64
120
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
65
- const tokenSource = oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
121
+ const tokenSource =
122
+ oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
66
123
  const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
67
124
  const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
68
125
  return { oauth, tokenFingerprint, cacheKey };
@@ -77,11 +134,17 @@ export async function fetchGeminiQuota(accountId, options = {}) {
77
134
  const forceRefresh = options.forceRefresh === true;
78
135
 
79
136
  // 1. 캐시 확인 (계정/토큰별)
80
- const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
81
- if (!forceRefresh
82
- && cache?.cacheKey === cacheKey
83
- && cache?.timestamp
84
- && (Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS)) {
137
+ const cache = readJsonMigrate(
138
+ GEMINI_QUOTA_CACHE_PATH,
139
+ LEGACY_GEMINI_QUOTA_CACHE,
140
+ null,
141
+ );
142
+ if (
143
+ !forceRefresh &&
144
+ cache?.cacheKey === cacheKey &&
145
+ cache?.timestamp &&
146
+ Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS
147
+ ) {
85
148
  return cache;
86
149
  }
87
150
 
@@ -116,12 +179,22 @@ export async function fetchGeminiQuota(accountId, options = {}) {
116
179
  oauth.access_token,
117
180
  );
118
181
  const id = loadRes?.cloudaicompanionProject;
119
- if (id) writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, { cacheKey, projectId: id, timestamp: Date.now() });
182
+ if (id)
183
+ writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, {
184
+ cacheKey,
185
+ projectId: id,
186
+ timestamp: Date.now(),
187
+ });
120
188
  return id || null;
121
189
  };
122
190
 
123
- const projCache = readJsonMigrate(GEMINI_PROJECT_CACHE_PATH, LEGACY_GEMINI_PROJECT_CACHE, null);
124
- let projectId = projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
191
+ const projCache = readJsonMigrate(
192
+ GEMINI_PROJECT_CACHE_PATH,
193
+ LEGACY_GEMINI_PROJECT_CACHE,
194
+ null,
195
+ );
196
+ let projectId =
197
+ projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
125
198
  if (!projectId) projectId = await fetchProjectId();
126
199
  if (!projectId) return cache;
127
200
 
@@ -145,7 +218,11 @@ export async function fetchGeminiQuota(accountId, options = {}) {
145
218
 
146
219
  if (!quotaRes?.buckets) {
147
220
  // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
148
- const apiError = quotaRes?.error?.message || quotaRes?.error?.code || quotaRes?.error || "no buckets in response";
221
+ const apiError =
222
+ quotaRes?.error?.message ||
223
+ quotaRes?.error?.code ||
224
+ quotaRes?.error ||
225
+ "no buckets in response";
149
226
  writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
150
227
  ...(cache || {}),
151
228
  timestamp: cache?.timestamp || Date.now(),
@@ -175,12 +252,17 @@ export async function fetchGeminiQuota(accountId, options = {}) {
175
252
  export function readGeminiRpm(model) {
176
253
  try {
177
254
  // 새 경로 → 레거시 경로 fallback
178
- const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH) ? GEMINI_RPM_TRACKER_PATH
179
- : existsSync(LEGACY_GEMINI_RPM_TRACKER) ? LEGACY_GEMINI_RPM_TRACKER : null;
255
+ const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH)
256
+ ? GEMINI_RPM_TRACKER_PATH
257
+ : existsSync(LEGACY_GEMINI_RPM_TRACKER)
258
+ ? LEGACY_GEMINI_RPM_TRACKER
259
+ : null;
180
260
  if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
181
261
  const raw = readFileSync(rpmPath, "utf-8");
182
262
  const parsed = JSON.parse(raw);
183
- const timestamps = Array.isArray(parsed.timestamps) ? parsed.timestamps : [];
263
+ const timestamps = Array.isArray(parsed.timestamps)
264
+ ? parsed.timestamps
265
+ : [];
184
266
  const now = Date.now();
185
267
  const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
186
268
  const count = recent.length;
@@ -188,9 +270,15 @@ export function readGeminiRpm(model) {
188
270
  const percent = clampPercent(Math.round((count / rpmLimit) * 100));
189
271
  // 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
190
272
  // 5초 단위 반올림으로 HUD 깜빡임 감소
191
- const rawRemainingSec = recent.length > 0
192
- ? Math.max(0, Math.ceil((GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000))
193
- : 0;
273
+ const rawRemainingSec =
274
+ recent.length > 0
275
+ ? Math.max(
276
+ 0,
277
+ Math.ceil(
278
+ (GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000,
279
+ ),
280
+ )
281
+ : 0;
194
282
  const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
195
283
  return { count, percent, remainingSec };
196
284
  } catch {
@@ -199,7 +287,11 @@ export function readGeminiRpm(model) {
199
287
  }
200
288
 
201
289
  export function readGeminiQuotaSnapshot(accountId, authContext) {
202
- const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
290
+ const cache = readJsonMigrate(
291
+ GEMINI_QUOTA_CACHE_PATH,
292
+ LEGACY_GEMINI_QUOTA_CACHE,
293
+ null,
294
+ );
203
295
  if (!cache?.buckets) {
204
296
  return { quota: null, shouldRefresh: true };
205
297
  }
@@ -208,19 +300,24 @@ export function readGeminiQuotaSnapshot(accountId, authContext) {
208
300
  const isLegacyCache = !cache.cacheKey;
209
301
  const keyMatched = cache.cacheKey === cacheKey;
210
302
  const cacheTs = Number(cache.timestamp);
211
- const ageMs = Number.isFinite(cacheTs) ? Date.now() - cacheTs : Number.MAX_SAFE_INTEGER;
303
+ const ageMs = Number.isFinite(cacheTs)
304
+ ? Date.now() - cacheTs
305
+ : Number.MAX_SAFE_INTEGER;
212
306
  const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
213
307
 
214
308
  if (keyMatched) {
215
309
  // resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
216
310
  if (Array.isArray(cache.buckets)) {
217
311
  const now = Date.now();
218
- const patchedBuckets = cache.buckets.map(b =>
312
+ const patchedBuckets = cache.buckets.map((b) =>
219
313
  b?.resetTime && new Date(b.resetTime).getTime() <= now
220
314
  ? { ...b, remainingFraction: 1 }
221
- : b
315
+ : b,
222
316
  );
223
- return { quota: { ...cache, buckets: patchedBuckets }, shouldRefresh: !isFresh };
317
+ return {
318
+ quota: { ...cache, buckets: patchedBuckets },
319
+ shouldRefresh: !isFresh,
320
+ };
224
321
  }
225
322
  return { quota: cache, shouldRefresh: !isFresh };
226
323
  }
@@ -233,10 +330,28 @@ export function readGeminiQuotaSnapshot(accountId, authContext) {
233
330
  export function scheduleGeminiQuotaRefresh(accountId) {
234
331
  const scriptPath = process.argv[1];
235
332
  if (!scriptPath) return;
333
+
334
+ // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
335
+ try {
336
+ if (existsSync(GEMINI_QUOTA_REFRESH_LOCK_PATH)) {
337
+ const lockAge =
338
+ Date.now() - readJson(GEMINI_QUOTA_REFRESH_LOCK_PATH, {}).t;
339
+ if (lockAge < SPAWN_LOCK_TTL_MS) return;
340
+ }
341
+ writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
342
+ } catch {
343
+ /* 락 실패 무시 — 스폰 진행 */
344
+ }
345
+
236
346
  try {
237
347
  const child = spawn(
238
348
  process.execPath,
239
- [scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
349
+ [
350
+ scriptPath,
351
+ GEMINI_REFRESH_FLAG,
352
+ "--account",
353
+ accountId || "gemini-main",
354
+ ],
240
355
  {
241
356
  detached: true,
242
357
  stdio: "ignore",
@@ -255,7 +370,11 @@ export function scheduleGeminiQuotaRefresh(accountId) {
255
370
  }
256
371
 
257
372
  export function readGeminiSessionSnapshot() {
258
- const cache = readJsonMigrate(GEMINI_SESSION_CACHE_PATH, LEGACY_GEMINI_SESSION_CACHE, null);
373
+ const cache = readJsonMigrate(
374
+ GEMINI_SESSION_CACHE_PATH,
375
+ LEGACY_GEMINI_SESSION_CACHE,
376
+ null,
377
+ );
259
378
  if (!cache?.session) {
260
379
  return { session: null, shouldRefresh: true };
261
380
  }
@@ -275,14 +394,33 @@ export function refreshGeminiSessionCache() {
275
394
  export function scheduleGeminiSessionRefresh() {
276
395
  const scriptPath = process.argv[1];
277
396
  if (!scriptPath) return;
397
+
398
+ // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
278
399
  try {
279
- const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
280
- detached: true,
281
- stdio: "ignore",
282
- windowsHide: true,
283
- });
400
+ if (existsSync(GEMINI_SESSION_REFRESH_LOCK_PATH)) {
401
+ const lockAge =
402
+ Date.now() - readJson(GEMINI_SESSION_REFRESH_LOCK_PATH, {}).t;
403
+ if (lockAge < SPAWN_LOCK_TTL_MS) return;
404
+ }
405
+ writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
406
+ } catch {
407
+ /* 락 실패 무시 — 스폰 진행 */
408
+ }
409
+
410
+ try {
411
+ const child = spawn(
412
+ process.execPath,
413
+ [scriptPath, GEMINI_SESSION_REFRESH_FLAG],
414
+ {
415
+ detached: true,
416
+ stdio: "ignore",
417
+ windowsHide: true,
418
+ },
419
+ );
284
420
  child.unref();
285
- } catch { /* 백그라운드 실행 실패 무시 */ }
421
+ } catch {
422
+ /* 백그라운드 실행 실패 무시 */
423
+ }
286
424
  }
287
425
 
288
426
  // ============================================================================
@@ -294,27 +432,47 @@ export function scanGeminiSessionTokens() {
294
432
  let best = null;
295
433
  let bestTime = 0;
296
434
  try {
297
- const dirs = readdirSync(tmpDir).filter((d) => existsSync(join(tmpDir, d, "chats")));
435
+ const dirs = readdirSync(tmpDir).filter((d) =>
436
+ existsSync(join(tmpDir, d, "chats")),
437
+ );
298
438
  for (const dir of dirs) {
299
439
  const chatsDir = join(tmpDir, dir, "chats");
300
440
  let files;
301
- try { files = readdirSync(chatsDir).filter((f) => f.endsWith(".json")); } catch { continue; }
441
+ try {
442
+ files = readdirSync(chatsDir).filter((f) => f.endsWith(".json"));
443
+ } catch {
444
+ continue;
445
+ }
302
446
  for (const file of files) {
303
447
  try {
304
448
  const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
305
449
  const updatedAt = new Date(data.lastUpdated || 0).getTime();
306
450
  if (updatedAt <= bestTime) continue;
307
- let input = 0, output = 0;
451
+ let input = 0,
452
+ output = 0;
308
453
  let model = "unknown";
309
454
  for (const msg of data.messages || []) {
310
- if (msg.tokens) { input += msg.tokens.input || 0; output += msg.tokens.output || 0; }
455
+ if (msg.tokens) {
456
+ input += msg.tokens.input || 0;
457
+ output += msg.tokens.output || 0;
458
+ }
311
459
  if (msg.model) model = msg.model;
312
460
  }
313
461
  bestTime = updatedAt;
314
- best = { input, output, total: input + output, model, lastUpdated: data.lastUpdated };
315
- } catch { /* 무시 */ }
462
+ best = {
463
+ input,
464
+ output,
465
+ total: input + output,
466
+ model,
467
+ lastUpdated: data.lastUpdated,
468
+ };
469
+ } catch {
470
+ /* 무시 */
471
+ }
316
472
  }
317
473
  }
318
- } catch { /* 무시 */ }
474
+ } catch {
475
+ /* 무시 */
476
+ }
319
477
  return best;
320
478
  }