@triflux/core 10.0.3 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) 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 +47 -7
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +279 -33
  7. package/hooks/hook-registry.json +104 -7
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  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 +308 -18
  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 +832 -291
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -3
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -17
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +171 -39
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +303 -107
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +116 -58
  75. package/hub/research.mjs +34 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +129 -63
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +76 -72
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +393 -249
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1282 -116
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +72 -12
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +36 -11
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. 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,47 @@
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,
15
11
  GEMINI_API_TIMEOUT_MS,
16
- GEMINI_REFRESH_FLAG, GEMINI_SESSION_REFRESH_FLAG,
12
+ GEMINI_OAUTH_PATH,
13
+ GEMINI_PROJECT_CACHE_PATH,
14
+ GEMINI_QUOTA_CACHE_PATH,
15
+ GEMINI_QUOTA_REFRESH_LOCK_PATH,
16
+ GEMINI_QUOTA_STALE_MS,
17
+ GEMINI_REFRESH_FLAG,
18
+ GEMINI_RPM_TRACKER_PATH,
19
+ GEMINI_RPM_WINDOW_MS,
20
+ GEMINI_SESSION_CACHE_PATH,
21
+ GEMINI_SESSION_REFRESH_FLAG,
22
+ GEMINI_SESSION_REFRESH_LOCK_PATH,
23
+ GEMINI_SESSION_STALE_MS,
24
+ LEGACY_GEMINI_PROJECT_CACHE,
25
+ LEGACY_GEMINI_QUOTA_CACHE,
26
+ LEGACY_GEMINI_RPM_TRACKER,
27
+ LEGACY_GEMINI_SESSION_CACHE,
28
+ SPAWN_LOCK_TTL_MS,
17
29
  } from "../constants.mjs";
18
30
  import {
19
- readJson, writeJsonSafe, readJsonMigrate, makeHash, clampPercent,
20
- decodeJwtEmail, createHttpsPost,
31
+ clampPercent,
32
+ createHttpsPost,
33
+ decodeJwtEmail,
34
+ makeHash,
35
+ readJson,
36
+ readJsonMigrate,
37
+ writeJsonSafe,
21
38
  } from "../utils.mjs";
22
39
 
23
40
  const httpsPost = createHttpsPost(https, GEMINI_API_TIMEOUT_MS);
24
41
 
25
42
  // Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
26
43
  export function getGeminiRpmLimit(model) {
27
- if (model && model.includes("pro")) return 25;
44
+ if (model?.includes("pro")) return 25;
28
45
  return 300; // Flash 기본
29
46
  }
30
47
 
@@ -48,21 +65,36 @@ export function deriveGeminiLimits(bucket) {
48
65
  if (bucket.remainingAmount != null) {
49
66
  const remaining = parseInt(bucket.remainingAmount, 10);
50
67
  const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
51
- return { usedPct, remaining, limit, resetTime: bucket.resetTime, modelId: bucket.modelId };
68
+ return {
69
+ usedPct,
70
+ remaining,
71
+ limit,
72
+ resetTime: bucket.resetTime,
73
+ modelId: bucket.modelId,
74
+ };
52
75
  }
53
- return { usedPct, remaining: null, limit: null, resetTime: bucket.resetTime, modelId: bucket.modelId };
76
+ return {
77
+ usedPct,
78
+ remaining: null,
79
+ limit: null,
80
+ resetTime: bucket.resetTime,
81
+ modelId: bucket.modelId,
82
+ };
54
83
  }
55
84
 
56
85
  export function getGeminiEmail() {
57
86
  try {
58
87
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
59
88
  return decodeJwtEmail(oauth?.id_token);
60
- } catch { return null; }
89
+ } catch {
90
+ return null;
91
+ }
61
92
  }
62
93
 
63
94
  export function buildGeminiAuthContext(accountId) {
64
95
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
65
- const tokenSource = oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
96
+ const tokenSource =
97
+ oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
66
98
  const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
67
99
  const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
68
100
  return { oauth, tokenFingerprint, cacheKey };
@@ -77,11 +109,17 @@ export async function fetchGeminiQuota(accountId, options = {}) {
77
109
  const forceRefresh = options.forceRefresh === true;
78
110
 
79
111
  // 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)) {
112
+ const cache = readJsonMigrate(
113
+ GEMINI_QUOTA_CACHE_PATH,
114
+ LEGACY_GEMINI_QUOTA_CACHE,
115
+ null,
116
+ );
117
+ if (
118
+ !forceRefresh &&
119
+ cache?.cacheKey === cacheKey &&
120
+ cache?.timestamp &&
121
+ Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS
122
+ ) {
85
123
  return cache;
86
124
  }
87
125
 
@@ -116,12 +154,22 @@ export async function fetchGeminiQuota(accountId, options = {}) {
116
154
  oauth.access_token,
117
155
  );
118
156
  const id = loadRes?.cloudaicompanionProject;
119
- if (id) writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, { cacheKey, projectId: id, timestamp: Date.now() });
157
+ if (id)
158
+ writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, {
159
+ cacheKey,
160
+ projectId: id,
161
+ timestamp: Date.now(),
162
+ });
120
163
  return id || null;
121
164
  };
122
165
 
123
- const projCache = readJsonMigrate(GEMINI_PROJECT_CACHE_PATH, LEGACY_GEMINI_PROJECT_CACHE, null);
124
- let projectId = projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
166
+ const projCache = readJsonMigrate(
167
+ GEMINI_PROJECT_CACHE_PATH,
168
+ LEGACY_GEMINI_PROJECT_CACHE,
169
+ null,
170
+ );
171
+ let projectId =
172
+ projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
125
173
  if (!projectId) projectId = await fetchProjectId();
126
174
  if (!projectId) return cache;
127
175
 
@@ -145,7 +193,11 @@ export async function fetchGeminiQuota(accountId, options = {}) {
145
193
 
146
194
  if (!quotaRes?.buckets) {
147
195
  // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
148
- const apiError = quotaRes?.error?.message || quotaRes?.error?.code || quotaRes?.error || "no buckets in response";
196
+ const apiError =
197
+ quotaRes?.error?.message ||
198
+ quotaRes?.error?.code ||
199
+ quotaRes?.error ||
200
+ "no buckets in response";
149
201
  writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
150
202
  ...(cache || {}),
151
203
  timestamp: cache?.timestamp || Date.now(),
@@ -175,12 +227,17 @@ export async function fetchGeminiQuota(accountId, options = {}) {
175
227
  export function readGeminiRpm(model) {
176
228
  try {
177
229
  // 새 경로 → 레거시 경로 fallback
178
- const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH) ? GEMINI_RPM_TRACKER_PATH
179
- : existsSync(LEGACY_GEMINI_RPM_TRACKER) ? LEGACY_GEMINI_RPM_TRACKER : null;
230
+ const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH)
231
+ ? GEMINI_RPM_TRACKER_PATH
232
+ : existsSync(LEGACY_GEMINI_RPM_TRACKER)
233
+ ? LEGACY_GEMINI_RPM_TRACKER
234
+ : null;
180
235
  if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
181
236
  const raw = readFileSync(rpmPath, "utf-8");
182
237
  const parsed = JSON.parse(raw);
183
- const timestamps = Array.isArray(parsed.timestamps) ? parsed.timestamps : [];
238
+ const timestamps = Array.isArray(parsed.timestamps)
239
+ ? parsed.timestamps
240
+ : [];
184
241
  const now = Date.now();
185
242
  const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
186
243
  const count = recent.length;
@@ -188,9 +245,15 @@ export function readGeminiRpm(model) {
188
245
  const percent = clampPercent(Math.round((count / rpmLimit) * 100));
189
246
  // 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
190
247
  // 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;
248
+ const rawRemainingSec =
249
+ recent.length > 0
250
+ ? Math.max(
251
+ 0,
252
+ Math.ceil(
253
+ (GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000,
254
+ ),
255
+ )
256
+ : 0;
194
257
  const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
195
258
  return { count, percent, remainingSec };
196
259
  } catch {
@@ -199,7 +262,11 @@ export function readGeminiRpm(model) {
199
262
  }
200
263
 
201
264
  export function readGeminiQuotaSnapshot(accountId, authContext) {
202
- const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
265
+ const cache = readJsonMigrate(
266
+ GEMINI_QUOTA_CACHE_PATH,
267
+ LEGACY_GEMINI_QUOTA_CACHE,
268
+ null,
269
+ );
203
270
  if (!cache?.buckets) {
204
271
  return { quota: null, shouldRefresh: true };
205
272
  }
@@ -208,19 +275,24 @@ export function readGeminiQuotaSnapshot(accountId, authContext) {
208
275
  const isLegacyCache = !cache.cacheKey;
209
276
  const keyMatched = cache.cacheKey === cacheKey;
210
277
  const cacheTs = Number(cache.timestamp);
211
- const ageMs = Number.isFinite(cacheTs) ? Date.now() - cacheTs : Number.MAX_SAFE_INTEGER;
278
+ const ageMs = Number.isFinite(cacheTs)
279
+ ? Date.now() - cacheTs
280
+ : Number.MAX_SAFE_INTEGER;
212
281
  const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
213
282
 
214
283
  if (keyMatched) {
215
284
  // resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
216
285
  if (Array.isArray(cache.buckets)) {
217
286
  const now = Date.now();
218
- const patchedBuckets = cache.buckets.map(b =>
287
+ const patchedBuckets = cache.buckets.map((b) =>
219
288
  b?.resetTime && new Date(b.resetTime).getTime() <= now
220
289
  ? { ...b, remainingFraction: 1 }
221
- : b
290
+ : b,
222
291
  );
223
- return { quota: { ...cache, buckets: patchedBuckets }, shouldRefresh: !isFresh };
292
+ return {
293
+ quota: { ...cache, buckets: patchedBuckets },
294
+ shouldRefresh: !isFresh,
295
+ };
224
296
  }
225
297
  return { quota: cache, shouldRefresh: !isFresh };
226
298
  }
@@ -233,10 +305,28 @@ export function readGeminiQuotaSnapshot(accountId, authContext) {
233
305
  export function scheduleGeminiQuotaRefresh(accountId) {
234
306
  const scriptPath = process.argv[1];
235
307
  if (!scriptPath) return;
308
+
309
+ // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
310
+ try {
311
+ if (existsSync(GEMINI_QUOTA_REFRESH_LOCK_PATH)) {
312
+ const lockAge =
313
+ Date.now() - readJson(GEMINI_QUOTA_REFRESH_LOCK_PATH, {}).t;
314
+ if (lockAge < SPAWN_LOCK_TTL_MS) return;
315
+ }
316
+ writeJsonSafe(GEMINI_QUOTA_REFRESH_LOCK_PATH, { t: Date.now() });
317
+ } catch {
318
+ /* 락 실패 무시 — 스폰 진행 */
319
+ }
320
+
236
321
  try {
237
322
  const child = spawn(
238
323
  process.execPath,
239
- [scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
324
+ [
325
+ scriptPath,
326
+ GEMINI_REFRESH_FLAG,
327
+ "--account",
328
+ accountId || "gemini-main",
329
+ ],
240
330
  {
241
331
  detached: true,
242
332
  stdio: "ignore",
@@ -255,7 +345,11 @@ export function scheduleGeminiQuotaRefresh(accountId) {
255
345
  }
256
346
 
257
347
  export function readGeminiSessionSnapshot() {
258
- const cache = readJsonMigrate(GEMINI_SESSION_CACHE_PATH, LEGACY_GEMINI_SESSION_CACHE, null);
348
+ const cache = readJsonMigrate(
349
+ GEMINI_SESSION_CACHE_PATH,
350
+ LEGACY_GEMINI_SESSION_CACHE,
351
+ null,
352
+ );
259
353
  if (!cache?.session) {
260
354
  return { session: null, shouldRefresh: true };
261
355
  }
@@ -275,14 +369,33 @@ export function refreshGeminiSessionCache() {
275
369
  export function scheduleGeminiSessionRefresh() {
276
370
  const scriptPath = process.argv[1];
277
371
  if (!scriptPath) return;
372
+
373
+ // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
278
374
  try {
279
- const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
280
- detached: true,
281
- stdio: "ignore",
282
- windowsHide: true,
283
- });
375
+ if (existsSync(GEMINI_SESSION_REFRESH_LOCK_PATH)) {
376
+ const lockAge =
377
+ Date.now() - readJson(GEMINI_SESSION_REFRESH_LOCK_PATH, {}).t;
378
+ if (lockAge < SPAWN_LOCK_TTL_MS) return;
379
+ }
380
+ writeJsonSafe(GEMINI_SESSION_REFRESH_LOCK_PATH, { t: Date.now() });
381
+ } catch {
382
+ /* 락 실패 무시 — 스폰 진행 */
383
+ }
384
+
385
+ try {
386
+ const child = spawn(
387
+ process.execPath,
388
+ [scriptPath, GEMINI_SESSION_REFRESH_FLAG],
389
+ {
390
+ detached: true,
391
+ stdio: "ignore",
392
+ windowsHide: true,
393
+ },
394
+ );
284
395
  child.unref();
285
- } catch { /* 백그라운드 실행 실패 무시 */ }
396
+ } catch {
397
+ /* 백그라운드 실행 실패 무시 */
398
+ }
286
399
  }
287
400
 
288
401
  // ============================================================================
@@ -294,27 +407,47 @@ export function scanGeminiSessionTokens() {
294
407
  let best = null;
295
408
  let bestTime = 0;
296
409
  try {
297
- const dirs = readdirSync(tmpDir).filter((d) => existsSync(join(tmpDir, d, "chats")));
410
+ const dirs = readdirSync(tmpDir).filter((d) =>
411
+ existsSync(join(tmpDir, d, "chats")),
412
+ );
298
413
  for (const dir of dirs) {
299
414
  const chatsDir = join(tmpDir, dir, "chats");
300
415
  let files;
301
- try { files = readdirSync(chatsDir).filter((f) => f.endsWith(".json")); } catch { continue; }
416
+ try {
417
+ files = readdirSync(chatsDir).filter((f) => f.endsWith(".json"));
418
+ } catch {
419
+ continue;
420
+ }
302
421
  for (const file of files) {
303
422
  try {
304
423
  const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
305
424
  const updatedAt = new Date(data.lastUpdated || 0).getTime();
306
425
  if (updatedAt <= bestTime) continue;
307
- let input = 0, output = 0;
426
+ let input = 0,
427
+ output = 0;
308
428
  let model = "unknown";
309
429
  for (const msg of data.messages || []) {
310
- if (msg.tokens) { input += msg.tokens.input || 0; output += msg.tokens.output || 0; }
430
+ if (msg.tokens) {
431
+ input += msg.tokens.input || 0;
432
+ output += msg.tokens.output || 0;
433
+ }
311
434
  if (msg.model) model = msg.model;
312
435
  }
313
436
  bestTime = updatedAt;
314
- best = { input, output, total: input + output, model, lastUpdated: data.lastUpdated };
315
- } catch { /* 무시 */ }
437
+ best = {
438
+ input,
439
+ output,
440
+ total: input + output,
441
+ model,
442
+ lastUpdated: data.lastUpdated,
443
+ };
444
+ } catch {
445
+ /* 무시 */
446
+ }
316
447
  }
317
448
  }
318
- } catch { /* 무시 */ }
449
+ } catch {
450
+ /* 무시 */
451
+ }
319
452
  return best;
320
453
  }