triflux 7.1.4 → 7.2.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 (73) hide show
  1. package/.claude-plugin/marketplace.json +31 -31
  2. package/.claude-plugin/plugin.json +22 -23
  3. package/bin/triflux.mjs +18 -5
  4. package/hooks/keyword-rules.json +393 -361
  5. package/hub/bridge.mjs +799 -786
  6. package/hub/delegator/contracts.mjs +37 -38
  7. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  8. package/hub/delegator/service.mjs +307 -302
  9. package/hub/intent.mjs +108 -11
  10. package/hub/lib/process-utils.mjs +20 -0
  11. package/hub/pipe.mjs +589 -589
  12. package/hub/pipeline/gates/confidence.mjs +1 -1
  13. package/hub/pipeline/gates/selfcheck.mjs +2 -4
  14. package/hub/pipeline/state.mjs +191 -187
  15. package/hub/pipeline/transitions.mjs +124 -120
  16. package/hub/public/dashboard.html +355 -349
  17. package/hub/quality/deslop.mjs +5 -3
  18. package/hub/reflexion.mjs +5 -1
  19. package/hub/research.mjs +6 -1
  20. package/hub/router.mjs +791 -782
  21. package/hub/server.mjs +893 -822
  22. package/hub/store.mjs +807 -778
  23. package/hub/team/agent-map.json +10 -0
  24. package/hub/team/ansi.mjs +3 -4
  25. package/hub/team/cli/commands/control.mjs +43 -43
  26. package/hub/team/cli/commands/interrupt.mjs +36 -36
  27. package/hub/team/cli/commands/kill.mjs +3 -3
  28. package/hub/team/cli/commands/send.mjs +37 -37
  29. package/hub/team/cli/commands/start/index.mjs +18 -8
  30. package/hub/team/cli/commands/start/parse-args.mjs +3 -1
  31. package/hub/team/cli/commands/start/start-headless.mjs +4 -1
  32. package/hub/team/cli/commands/status.mjs +87 -87
  33. package/hub/team/cli/commands/stop.mjs +1 -1
  34. package/hub/team/cli/commands/task.mjs +1 -1
  35. package/hub/team/cli/index.mjs +41 -39
  36. package/hub/team/cli/manifest.mjs +29 -28
  37. package/hub/team/cli/services/hub-client.mjs +37 -0
  38. package/hub/team/cli/services/state-store.mjs +26 -12
  39. package/hub/team/dashboard.mjs +11 -4
  40. package/hub/team/handoff.mjs +12 -0
  41. package/hub/team/headless.mjs +202 -200
  42. package/hub/team/native-supervisor.mjs +386 -346
  43. package/hub/team/nativeProxy.mjs +680 -692
  44. package/hub/team/staleState.mjs +361 -369
  45. package/hub/team/tui-viewer.mjs +27 -3
  46. package/hub/team/tui.mjs +1 -0
  47. package/hub/token-mode.mjs +114 -24
  48. package/hub/workers/delegator-mcp.mjs +1059 -1057
  49. package/hud/colors.mjs +88 -0
  50. package/hud/constants.mjs +78 -0
  51. package/hud/hud-qos-status.mjs +206 -1872
  52. package/hud/providers/claude.mjs +309 -0
  53. package/hud/providers/codex.mjs +151 -0
  54. package/hud/providers/gemini.mjs +320 -0
  55. package/hud/renderers.mjs +424 -0
  56. package/hud/terminal.mjs +140 -0
  57. package/hud/utils.mjs +271 -0
  58. package/package.json +1 -2
  59. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  60. package/scripts/headless-guard-fast.sh +21 -0
  61. package/scripts/headless-guard.mjs +26 -6
  62. package/scripts/lib/keyword-rules.mjs +166 -168
  63. package/scripts/setup.mjs +720 -690
  64. package/scripts/tfx-route-post.mjs +424 -424
  65. package/scripts/tfx-route.sh +1663 -1650
  66. package/scripts/tmp-cleanup.mjs +74 -0
  67. package/skills/tfx-auto/SKILL.md +279 -278
  68. package/skills/tfx-auto-codex/SKILL.md +98 -77
  69. package/skills/tfx-codex/SKILL.md +65 -65
  70. package/skills/tfx-gemini/SKILL.md +83 -82
  71. package/skills/tfx-hub/SKILL.md +205 -136
  72. package/skills/tfx-multi/SKILL.md +11 -5
  73. package/.mcp.json +0 -8
@@ -1,1872 +1,206 @@
1
- #!/usr/bin/env node
2
-
3
- import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { join, dirname } from "node:path";
6
- import https from "node:https";
7
- import { createHash } from "node:crypto";
8
- import { spawn, execSync } from "node:child_process";
9
-
10
- const VERSION = "2.0";
11
-
12
- // ============================================================================
13
- // ANSI 색상 (OMC colors.js 스키마 일치)
14
- // ============================================================================
15
- const RESET = "\x1b[0m";
16
- const DIM = "\x1b[2m";
17
- const BOLD = "\x1b[1m";
18
- const RED = "\x1b[31m";
19
- const GREEN = "\x1b[32m";
20
- const YELLOW = "\x1b[33m";
21
- const CYAN = "\x1b[36m";
22
- const CLAUDE_ORANGE = "\x1b[38;2;232;112;64m"; // #E87040 (Claude 공식 오렌지)
23
- const CODEX_WHITE = "\x1b[97m"; // bright white (SGR 37은 Windows Terminal에서 연회색 매핑)
24
- const GEMINI_BLUE = "\x1b[38;5;39m";
25
-
26
- function green(t) { return `${GREEN}${t}${RESET}`; }
27
- function yellow(t) { return `${YELLOW}${t}${RESET}`; }
28
- function red(t) { return `${RED}${t}${RESET}`; }
29
- function cyan(t) { return `${CYAN}${t}${RESET}`; }
30
- function dim(t) { return `${DIM}${t}${RESET}`; }
31
- function bold(t) { return `${BOLD}${t}${RESET}`; }
32
- function claudeOrange(t) { return `${CLAUDE_ORANGE}${t}${RESET}`; }
33
- function codexWhite(t) { return `${CODEX_WHITE}${t}${RESET}`; }
34
- function geminiBlue(t) { return `${GEMINI_BLUE}${t}${RESET}`; }
35
-
36
- function colorByPercent(value, text) {
37
- if (value >= 85) return red(text);
38
- if (value >= 70) return yellow(text);
39
- if (value >= 50) return cyan(text);
40
- return green(text);
41
- }
42
-
43
- function colorCooldown(seconds, text) {
44
- if (seconds > 120) return red(text);
45
- if (seconds > 0) return yellow(text);
46
- return dim(text);
47
- }
48
-
49
- function colorParallel(current, cap) {
50
- if (current >= cap) return green(`${current}/${cap}`);
51
- if (current > 1) return yellow(`${current}/${cap}`);
52
- return red(`${current}/${cap}`);
53
- }
54
-
55
- const GAUGE_WIDTH = 5;
56
- const GAUGE_BLOCKS = ["░", "▒", "▓", "█"]; // 밝기 0~3
57
-
58
- function coloredBar(percent, width = GAUGE_WIDTH, baseColor = null) {
59
- const safePercent = Math.min(100, Math.max(0, percent));
60
- const perBlock = 100 / width;
61
-
62
- // 상태별 색상
63
- let barColor;
64
- if (safePercent >= 85) barColor = RED;
65
- else if (safePercent >= 70) barColor = YELLOW;
66
- else barColor = baseColor || GREEN;
67
-
68
- let bar = "";
69
- for (let i = 0; i < width; i++) {
70
- const blockStart = i * perBlock;
71
- const blockEnd = (i + 1) * perBlock;
72
-
73
- if (safePercent >= blockEnd) {
74
- bar += "█"; // 완전 채움
75
- } else if (safePercent > blockStart) {
76
- // 프론티어: 구간 내 진행률
77
- const progress = (safePercent - blockStart) / perBlock;
78
- if (progress >= 0.75) bar += "▓";
79
- else if (progress >= 0.33) bar += "▒";
80
- else bar += "░";
81
- } else {
82
- bar += "░"; // 미도달
83
- }
84
- }
85
-
86
- // 채워진 부분 = barColor, 빈 부분 = DIM
87
- const filledEnd = Math.ceil(safePercent / perBlock);
88
- const coloredPart = barColor + bar.slice(0, filledEnd) + RESET;
89
- const dimPart = filledEnd < width ? DIM + bar.slice(filledEnd) + RESET : "";
90
-
91
- return coloredPart + dimPart;
92
- }
93
-
94
- // 프로바이더별 색상 % (< 70%: 프로바이더 색, ≥ 70%: 경고색)
95
- function colorByProvider(value, text, providerColorFn) {
96
- if (value >= 85) return red(text);
97
- if (value >= 70) return yellow(text);
98
- return providerColorFn(text);
99
- }
100
-
101
- // ============================================================================
102
- // 상수 / 경로
103
- // ============================================================================
104
- const QOS_PATH = join(homedir(), ".omc", "state", "cli_qos_profile.json");
105
- const ACCOUNTS_CONFIG_PATH = join(homedir(), ".omc", "router", "accounts.json");
106
- const ACCOUNTS_STATE_PATH = join(homedir(), ".omc", "state", "cli_accounts_state.json");
107
-
108
- // tfx-multi 상태 (v2.2 HUD 통합)
109
- const TEAM_STATE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "team-state.json");
110
-
111
- // Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
112
- const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
113
- const CLAUDE_USAGE_CACHE_PATH = join(homedir(), ".claude", "cache", "claude-usage-cache.json");
114
- const OMC_PLUGIN_USAGE_CACHE_PATH = join(homedir(), ".claude", "plugins", "oh-my-claudecode", ".usage-cache.json");
115
- const CLAUDE_USAGE_STALE_MS_SOLO = 5 * 60 * 1000; // OMC 없을 때: 5분 캐시
116
- const CLAUDE_USAGE_STALE_MS_WITH_OMC = 15 * 60 * 1000; // OMC 있을 때: 15분 (OMC가 30초마다 갱신)
117
-
118
- // OMC 활성 여부에 따라 캐시 TTL 동적 결정
119
- function getClaudeUsageStaleMs() {
120
- return existsSync(OMC_PLUGIN_USAGE_CACHE_PATH)
121
- ? CLAUDE_USAGE_STALE_MS_WITH_OMC
122
- : CLAUDE_USAGE_STALE_MS_SOLO;
123
- }
124
- const CLAUDE_USAGE_429_BACKOFF_MS = 10 * 60 * 1000; // 429 에러 시 10분 backoff
125
- const CLAUDE_USAGE_ERROR_BACKOFF_MS = 3 * 60 * 1000; // 기타 에러 시 3분 backoff
126
- const CLAUDE_API_TIMEOUT_MS = 10_000;
127
- const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
128
- const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
129
- const ONE_DAY_MS = 24 * 60 * 60 * 1000;
130
- const DEFAULT_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
131
- const CODEX_AUTH_PATH = join(homedir(), ".codex", "auth.json");
132
- const CODEX_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "codex-rate-limits-cache.json");
133
- const CODEX_QUOTA_STALE_MS = 15 * 1000; // 15초
134
-
135
- // Gemini 쿼터 API 관련
136
- const GEMINI_OAUTH_PATH = join(homedir(), ".gemini", "oauth_creds.json");
137
- const GEMINI_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-quota-cache.json");
138
- const GEMINI_PROJECT_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-project-id.json");
139
- const GEMINI_SESSION_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-session-cache.json");
140
- const GEMINI_RPM_TRACKER_PATH = join(homedir(), ".claude", "cache", "gemini-rpm-tracker.json");
141
- const SV_ACCUMULATOR_PATH = join(homedir(), ".claude", "cache", "sv-accumulator.json");
142
- // 이전 .omc/ 경로 fallback (기존 환경 호환)
143
- const LEGACY_GEMINI_QUOTA_CACHE = join(homedir(), ".omc", "state", "gemini_quota_cache.json");
144
- const LEGACY_GEMINI_PROJECT_CACHE = join(homedir(), ".omc", "state", "gemini_project_id.json");
145
- const LEGACY_GEMINI_SESSION_CACHE = join(homedir(), ".omc", "state", "gemini_session_tokens_cache.json");
146
- const LEGACY_GEMINI_RPM_TRACKER = join(homedir(), ".omc", "state", "gemini_rpm_tracker.json");
147
- const LEGACY_SV_ACCUMULATOR = join(homedir(), ".omc", "state", "sv-accumulator.json");
148
- // Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
149
- function getGeminiRpmLimit(model) {
150
- if (model && model.includes("pro")) return 25;
151
- return 300; // Flash 기본
152
- }
153
-
154
- // Gemini 모델 ID HUD 표시 라벨 (동적 매핑)
155
- function getGeminiModelLabel(model) {
156
- if (!model) return "";
157
- // 버전 + 티어 추출: gemini-3.1-pro-preview → [3.1Pro], gemini-2.5-flash → [2.5Flash]
158
- const m = model.match(/gemini-(\d+(?:\.\d+)?)-(\w+)/);
159
- if (!m) return "";
160
- const ver = m[1];
161
- const tier = m[2].charAt(0).toUpperCase() + m[2].slice(1);
162
- return `[${ver}${tier}]`;
163
- }
164
-
165
- // Gemini Pro 풀 공유 그룹: 같은 remainingFraction을 공유하는 모델 ID들
166
- const GEMINI_PRO_POOL = new Set(["gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview"]);
167
- const GEMINI_FLASH_POOL = new Set(["gemini-2.5-flash", "gemini-3-flash-preview"]);
168
-
169
- // remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
170
- function deriveGeminiLimits(bucket) {
171
- if (!bucket || bucket.remainingFraction == null) return null;
172
- const fraction = bucket.remainingFraction;
173
- const usedPct = clampPercent(Math.round((1 - fraction) * 100));
174
- // remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
175
- if (bucket.remainingAmount != null) {
176
- const remaining = parseInt(bucket.remainingAmount, 10);
177
- const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
178
- return { usedPct, remaining, limit, resetTime: bucket.resetTime, modelId: bucket.modelId };
179
- }
180
- return { usedPct, remaining: null, limit: null, resetTime: bucket.resetTime, modelId: bucket.modelId };
181
- }
182
- // rows 임계값 상수 (selectTier 에서 tier 결정에 사용)
183
- const ROWS_BUDGET_FULL = 40;
184
- const ROWS_BUDGET_LARGE = 35;
185
- const ROWS_BUDGET_MEDIUM = 28;
186
- const ROWS_BUDGET_SMALL = 22;
187
- // Codex rate_limits에서 최소 수집할 버킷 수
188
- const CODEX_MIN_BUCKETS = 2;
189
-
190
- const GEMINI_RPM_WINDOW_MS = 60 * 1000; // 60초 슬라이딩 윈도우
191
- const GEMINI_QUOTA_STALE_MS = 5 * 60 * 1000; // 5분
192
- const GEMINI_SESSION_STALE_MS = 15 * 1000; // 15초
193
- const GEMINI_API_TIMEOUT_MS = 3000; // 3초
194
- const ACCOUNT_LABEL_WIDTH = 10;
195
- const PROVIDER_PREFIX_WIDTH = 2;
196
- const PERCENT_CELL_WIDTH = 3;
197
- const TIME_CELL_INNER_WIDTH = 6;
198
- const CLAUDE_REFRESH_FLAG = "--refresh-claude-usage";
199
- const CODEX_REFRESH_FLAG = "--refresh-codex-rate-limits";
200
- const GEMINI_REFRESH_FLAG = "--refresh-gemini-quota";
201
- const GEMINI_SESSION_REFRESH_FLAG = "--refresh-gemini-session";
202
-
203
- // ============================================================================
204
- // 모바일/Termux 컴팩트 모드 감지
205
- // ============================================================================
206
- const HUD_CONFIG_PATH = join(homedir(), ".omc", "config", "hud.json");
207
- const COMPACT_COLS_THRESHOLD = 80;
208
- const MINIMAL_COLS_THRESHOLD = 60;
209
-
210
- let _cachedColumns = 0;
211
- function getTerminalColumns() {
212
- if (_cachedColumns > 0) return _cachedColumns;
213
- const envCols = Number(process.env.COLUMNS);
214
- if (envCols > 0) { _cachedColumns = envCols; return _cachedColumns; }
215
- if (process.stdout.columns) { _cachedColumns = process.stdout.columns; return _cachedColumns; }
216
- if (process.stderr.columns) { _cachedColumns = process.stderr.columns; return _cachedColumns; }
217
- try {
218
- if (process.platform === "win32") {
219
- const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
220
- const m = raw.match(/Columns[^:]*:\s*(\d+)/i) || raw.match(/열[^:]*:\s*(\d+)/);
221
- if (m) { _cachedColumns = Number(m[1]); return _cachedColumns; }
222
- } else {
223
- const raw = execSync("tput cols 2>/dev/null || stty size 2>/dev/null | awk '{print $2}'", {
224
- timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
225
- }).toString().trim();
226
- if (raw && !isNaN(Number(raw))) { _cachedColumns = Number(raw); return _cachedColumns; }
227
- }
228
- } catch { /* 감지 실패 */ }
229
- return 0;
230
- }
231
-
232
- let _cachedRows = 0;
233
- function getTerminalRows() {
234
- if (_cachedRows > 0) return _cachedRows;
235
- if (process.stdout.rows) { _cachedRows = process.stdout.rows; return _cachedRows; }
236
- if (process.stderr.rows) { _cachedRows = process.stderr.rows; return _cachedRows; }
237
- const envLines = Number(process.env.LINES);
238
- if (envLines > 0) { _cachedRows = envLines; return _cachedRows; }
239
- try {
240
- if (process.platform === "win32") {
241
- const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
242
- const m = raw.match(/Lines[^:]*:\s*(\d+)/i) || raw.match(/줄[^:]*:\s*(\d+)/);
243
- if (m) { _cachedRows = Number(m[1]); return _cachedRows; }
244
- } else {
245
- const raw = execSync("tput lines 2>/dev/null || stty size 2>/dev/null | awk '{print $1}'", {
246
- timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
247
- }).toString().trim();
248
- if (raw && !isNaN(Number(raw))) { _cachedRows = Number(raw); return _cachedRows; }
249
- }
250
- } catch { /* 감지 실패 */ }
251
- return 0;
252
- }
253
-
254
- function detectCompactMode() {
255
- // 1. 명시적 CLI 플래그
256
- if (process.argv.includes("--compact")) return true;
257
- if (process.argv.includes("--no-compact")) return false;
258
- // 2. 환경변수 오버라이드
259
- if (process.env.TERMUX_VERSION) return true;
260
- if (process.env.OMC_HUD_COMPACT === "1") return true;
261
- if (process.env.OMC_HUD_COMPACT === "0") return false;
262
- // 3. 설정 파일 (~/.omc/config/hud.json)
263
- const hudConfig = readJson(HUD_CONFIG_PATH, null);
264
- if (hudConfig?.compact === true || hudConfig?.compact === "always") return true;
265
- if (hudConfig?.compact === false || hudConfig?.compact === "never") return false;
266
- // 4. maxLines < 3이면 자동 컴팩트 (알림 배너 공존 대응)
267
- if (Number(hudConfig?.lines) > 0 && Number(hudConfig?.lines) < 3) return true;
268
- // 5. 터미널 폭 자동 감지 (TTY 있을 때만 유효)
269
- const threshold = Number(hudConfig?.compactThreshold) || COMPACT_COLS_THRESHOLD;
270
- const cols = getTerminalColumns();
271
- if (cols > 0 && cols < threshold) return true;
272
- return false;
273
- }
274
-
275
- const COMPACT_MODE = detectCompactMode();
276
-
277
- function detectMinimalMode() {
278
- // 1. 명시적 CLI 플래그
279
- if (process.argv.includes("--minimal")) return true;
280
- // 2. 환경변수
281
- if (process.env.OMC_HUD_MINIMAL === "1") return true;
282
- if (process.env.OMC_HUD_MINIMAL === "0") return false;
283
- // 3. 설정 파일 (~/.omc/config/hud.json)
284
- const hudConfig = readJson(HUD_CONFIG_PATH, null);
285
- if (hudConfig?.compact === "minimal") return true;
286
- // 4. 터미널 폭 자동 감지
287
- const cols = getTerminalColumns();
288
- if (cols > 0 && cols < MINIMAL_COLS_THRESHOLD) return true;
289
- return false;
290
- }
291
-
292
- const MINIMAL_MODE = detectMinimalMode();
293
-
294
- // ============================================================================
295
- // 4-Tier 적응형 렌더링: full > normal > compact > nano
296
- // ============================================================================
297
- // 초기 tier (stdin 없이 결정 가능한 수준)
298
- let CURRENT_TIER = MINIMAL_MODE ? "nano" : COMPACT_MODE ? "compact" : "full";
299
-
300
- /**
301
- * 인디케이터 인식 + 터미널 크기 기반 tier 자동 선택.
302
- * main()에서 stdin 수신 후 호출하여 CURRENT_TIER 갱신.
303
- */
304
- function selectTier(stdin, claudeUsage = null) {
305
- const hudConfig = readJson(HUD_CONFIG_PATH, null);
306
-
307
- // 1) 명시적 tier 강제 설정
308
- const forcedTier = hudConfig?.tier;
309
- if (["full", "compact", "minimal", "micro", "nano"].includes(forcedTier)) return forcedTier;
310
-
311
- // 1.5) maxLines=1 → nano (1줄 모드: 알림 배너/분할화면 대응)
312
- if (Number(hudConfig?.lines) === 1) return "nano";
313
-
314
- const cols = getTerminalColumns() || 120;
315
-
316
- // 1.6) 극소 폭(< 40col)인 경우 1줄 모드(nano)로 폴백
317
- if (cols < 40) return "nano";
318
-
319
- // 2) 기존 모드 플래그 존중
320
- if (MINIMAL_MODE) return "micro";
321
- if (COMPACT_MODE) return "compact";
322
-
323
- // 3) autoResize 비활성이면 full 유지
324
- if (hudConfig?.autoResize === false) return "full";
325
-
326
- // 4) 터미널 폭에 따른 점진적 축소 (breakpoint)
327
- if (cols >= 120) return "full";
328
- if (cols >= 80) return "compact";
329
- if (cols >= 60) return "minimal";
330
- return "micro"; // 40 <= cols < 60
331
- }
332
-
333
- // full tier 전용: 게이지 바 접두사 (normal 이하 tier에서는 빈 문자열)
334
- function tierBar(percent, baseColor = null) {
335
- return CURRENT_TIER === "full" ? coloredBar(percent, GAUGE_WIDTH, baseColor) + " " : "";
336
- }
337
- function tierDimBar() {
338
- return CURRENT_TIER === "full" ? DIM + "░".repeat(GAUGE_WIDTH) + RESET + " " : "";
339
- }
340
- // Gemini ∞% 전용: 무한 쿼터이므로 dim 회색 바
341
- function tierInfBar() {
342
- return CURRENT_TIER === "full" ? DIM + "█".repeat(GAUGE_WIDTH) + RESET + " " : "";
343
- }
344
-
345
- // ============================================================================
346
- // 유틸
347
- // ============================================================================
348
- async function readStdinJson() {
349
- if (process.stdin.isTTY) return {};
350
- return new Promise((resolve) => {
351
- const timeout = setTimeout(() => {
352
- process.stdin.destroy();
353
- resolve({});
354
- }, 200);
355
- const chunks = [];
356
- process.stdin.on("data", (chunk) => chunks.push(chunk));
357
- process.stdin.on("end", () => {
358
- clearTimeout(timeout);
359
- const raw = chunks.join("").trim();
360
- if (!raw) { resolve({}); return; }
361
- try { resolve(JSON.parse(raw)); } catch { resolve({}); }
362
- });
363
- process.stdin.on("error", () => {
364
- clearTimeout(timeout);
365
- resolve({});
366
- });
367
- process.stdin.resume();
368
- });
369
- }
370
-
371
- function readJson(filePath, fallback) {
372
- if (!existsSync(filePath)) return fallback;
373
- try { return JSON.parse(readFileSync(filePath, "utf-8")); } catch { return fallback; }
374
- }
375
-
376
- function writeJsonSafe(filePath, data) {
377
- try {
378
- mkdirSync(dirname(filePath), { recursive: true });
379
- writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 });
380
- } catch { /* 쓰기 실패 무시 */ }
381
- }
382
-
383
- // .omc/ → .claude/cache/ 마이그레이션: 새 경로 우선, 없으면 레거시 읽고 복사
384
- function readJsonMigrate(newPath, legacyPath, fallback) {
385
- const data = readJson(newPath, null);
386
- if (data != null) return data;
387
- const legacy = readJson(legacyPath, null);
388
- if (legacy != null) { writeJsonSafe(newPath, legacy); return legacy; }
389
- return fallback;
390
- }
391
-
392
- function stripAnsi(text) {
393
- return String(text).replace(/\x1b\[[0-9;]*m/g, "");
394
- }
395
-
396
- function padAnsiRight(text, width) {
397
- const len = stripAnsi(text).length;
398
- if (len >= width) return text;
399
- return text + " ".repeat(width - len);
400
- }
401
-
402
- function padAnsiLeft(text, width) {
403
- const len = stripAnsi(text).length;
404
- if (len >= width) return text;
405
- return " ".repeat(width - len) + text;
406
- }
407
-
408
- function fitText(text, width) {
409
- const t = String(text || "");
410
- if (t.length <= width) return t;
411
- if (width <= 1) return "…";
412
- return `${t.slice(0, width - 1)}…`;
413
- }
414
-
415
- function makeHash(text) {
416
- return createHash("sha256").update(String(text || ""), "utf8").digest("hex").slice(0, 16);
417
- }
418
-
419
- function getProviderAccountId(provider, accountsConfig, accountsState) {
420
- const providerState = accountsState?.providers?.[provider] || {};
421
- const selectedId = providerState.last_selected_id;
422
- if (selectedId) return selectedId;
423
- const providerConfig = accountsConfig?.providers?.[provider] || [];
424
- return providerConfig[0]?.id || `${provider}-main`;
425
- }
426
-
427
- /**
428
- * tfx-multi 상태 행 생성 (v2.2 HUD 통합)
429
- * 활성 팀이 있을 때만 행 반환, 없으면 null
430
- * @returns {{ prefix: string, left: string, right: string } | null}
431
- */
432
- function getTeamRow() {
433
- const teamState = readJson(TEAM_STATE_PATH, null);
434
- if (!teamState || !teamState.sessionName) return null;
435
-
436
- // 팀 생존 확인: startedAt 기준 24시간 초과면 stale로 간주
437
- if (teamState.startedAt && (Date.now() - teamState.startedAt) > 24 * 60 * 60 * 1000) return null;
438
-
439
- const workers = (teamState.members || []).filter((m) => m.role === "worker");
440
- if (!workers.length) return null;
441
-
442
- const tasks = teamState.tasks || [];
443
- const completed = tasks.filter((t) => t.status === "completed").length;
444
- const failed = tasks.filter((t) => t.status === "failed").length;
445
- const total = tasks.length || workers.length;
446
-
447
- // 경과 시간 (80col 이상에서만 표시)
448
- const elapsed = (teamState.startedAt && (CURRENT_TIER === "full" || CURRENT_TIER === "compact"))
449
- ? `${Math.round((Date.now() - teamState.startedAt) / 60000)}m`
450
- : "";
451
-
452
- // CLI 브랜드: 단일문자 + ANSI 색상 (x=codex, g=gemini, c=claude)
453
- // CLI 브랜드 색상 통일 — 프로바이더 행과 동일 (codexWhite, geminiBlue, claudeOrange)
454
- const cliTag = (cli) => cli === "codex" ? bold(codexWhite("x")) : cli === "gemini" ? bold(geminiBlue("g")) : bold(claudeOrange("c"));
455
- // 멤버 상태: 태그 + 상태기호 (60col 이상)
456
- const memberIcons = (CURRENT_TIER === "full" || CURRENT_TIER === "compact" || CURRENT_TIER === "minimal") ? workers.map((m) => {
457
- const task = tasks.find((t) => t.owner === m.name);
458
- const status = task?.status === "completed" ? green("✓")
459
- : task?.status === "in_progress" ? yellow("⋯")
460
- : task?.status === "failed" ? red("✗")
461
- : dim("◌");
462
- return `${cliTag(m.cli)}${status}`;
463
- }).join(" ") : "";
464
-
465
- // 진행 텍스트 — "team" 제거, 숫자만
466
- const doneText = failed > 0
467
- ? `${completed}/${total} ${red(`${failed}✗`)}`
468
- : `${completed}/${total}`;
469
-
470
- const leftText = elapsed ? `${doneText} ${dim(elapsed)}` : doneText;
471
-
472
- return {
473
- prefix: bold(claudeOrange("▲")),
474
- left: leftText,
475
- right: memberIcons,
476
- };
477
- }
478
-
479
- function renderAlignedRows(rows) {
480
- const rightRows = rows.filter((row) => stripAnsi(String(row.right || "")).trim().length > 0);
481
- const rawLeftWidth = rightRows.reduce((max, row) => Math.max(max, stripAnsi(row.left).length), 0);
482
- return rows.map((row) => {
483
- const prefix = padAnsiRight(row.prefix, PROVIDER_PREFIX_WIDTH);
484
- const hasRight = stripAnsi(String(row.right || "")).trim().length > 0;
485
- if (!hasRight) {
486
- return `${prefix} ${row.left}`;
487
- }
488
- // 자기 left 대비 패딩 상한: 최대 2칸까지만 패딩 (과도한 공백 방지)
489
- const ownLen = stripAnsi(row.left).length;
490
- const effectiveWidth = Math.min(rawLeftWidth, ownLen + 2);
491
- const left = padAnsiRight(row.left, effectiveWidth);
492
- return `${prefix} ${left} ${dim("|")} ${row.right}`;
493
- });
494
- }
495
-
496
- // micro tier: 모든 프로바이더를 1줄로 압축 (알림 배너/분할화면 대응)
497
- // 형식: c:16/3 x:5/2 g:∞ sv:143% ctx:53%
498
- function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
499
- const ctx = getContextPercent(stdin);
500
-
501
- // Claude 5h/1w (캐시된 값 그대로 표시, 시간은 advanceToNextCycle이 처리)
502
- const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
503
- const cW = claudeUsage?.weeklyPercent != null ? clampPercent(claudeUsage.weeklyPercent) : null;
504
- const cVal = claudeUsage != null
505
- ? `${cF != null ? colorByProvider(cF, `${cF}`, claudeOrange) : dim("--")}${dim("/")}${cW != null ? colorByProvider(cW, `${cW}`, claudeOrange) : dim("--")}`
506
- : dim("--/--");
507
-
508
- // Codex 5h/1w (캐시된 값 그대로 표시)
509
- let xVal = dim("--/--");
510
- if (codexBuckets) {
511
- const mb = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
512
- if (mb) {
513
- const xF = mb.primary?.used_percent != null ? clampPercent(mb.primary.used_percent) : null;
514
- const xW = mb.secondary?.used_percent != null ? clampPercent(mb.secondary.used_percent) : null;
515
- xVal = `${xF != null ? colorByProvider(xF, `${xF}`, codexWhite) : dim("--")}${dim("/")}${xW != null ? colorByProvider(xW, `${xW}`, codexWhite) : dim("--")}`;
516
- }
517
- }
518
-
519
- // Gemini (일간 쿼터 — P/F/L 3풀)
520
- let gVal;
521
- if (geminiBucket) {
522
- const gl = deriveGeminiLimits(geminiBucket);
523
- const gU = gl ? gl.usedPct : clampPercent((1 - (geminiBucket.remainingFraction ?? 1)) * 100);
524
- gVal = colorByProvider(gU, `${gU}`, geminiBlue);
525
- } else if ((geminiSession?.total || 0) > 0) {
526
- gVal = geminiBlue("\u221E");
527
- } else {
528
- gVal = dim("--");
529
- }
530
-
531
- // sv (trimmed)
532
- const sv = formatSvPct(combinedSvPct || 0).trim();
533
-
534
- return `${bold(claudeOrange("c"))}${dim(":")}${cVal} ` +
535
- `${bold(codexWhite("x"))}${dim(":")}${xVal} ` +
536
- `${bold(geminiBlue("g"))}${dim(":")}${gVal} ` +
537
- `${dim("sv:")}${sv} ` +
538
- `${dim("ctx:")}${colorByPercent(ctx, `${ctx}%`)}`;
539
- }
540
-
541
- function clampPercent(value) {
542
- const numeric = Number(value);
543
- if (!Number.isFinite(numeric)) return 0;
544
- return Math.max(0, Math.min(100, Math.round(numeric)));
545
- }
546
-
547
- function formatPercentCell(value) {
548
- return `${clampPercent(value)}%`.padStart(PERCENT_CELL_WIDTH, " ");
549
- }
550
-
551
- function formatPlaceholderPercentCell() {
552
- return "--%".padStart(PERCENT_CELL_WIDTH, " ");
553
- }
554
-
555
- function normalizeTimeToken(value) {
556
- const text = String(value || "n/a");
557
- const hourMinute = text.match(/^(\d+)h(\d+)m$/);
558
- if (hourMinute) {
559
- return `${Number(hourMinute[1])}h${String(Number(hourMinute[2])).padStart(2, "0")}m`;
560
- }
561
- const dayHour = text.match(/^(\d+)d(\d+)h$/);
562
- if (dayHour) {
563
- return `${String(Number(dayHour[1])).padStart(2, "0")}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
564
- }
565
- return text;
566
- }
567
-
568
- function formatTimeCell(value) {
569
- const text = normalizeTimeToken(value);
570
- // 시간값(숫자 포함)은 0패딩, 비시간값(n/a 등)은 공백패딩
571
- const padChar = /\d/.test(text) ? "0" : " ";
572
- return `(${text.padStart(TIME_CELL_INNER_WIDTH, padChar)})`;
573
- }
574
-
575
- // 주간(d/h) 전용 — 최대 7d00h(5자)이므로 공백 불필요
576
- function formatTimeCellDH(value) {
577
- const text = normalizeTimeToken(value);
578
- return `(${text})`;
579
- }
580
-
581
- function getCliArgValue(flag) {
582
- const idx = process.argv.indexOf(flag);
583
- if (idx < 0) return null;
584
- return process.argv[idx + 1] || null;
585
- }
586
-
587
- function buildGeminiAuthContext(accountId) {
588
- const oauth = readJson(GEMINI_OAUTH_PATH, null);
589
- const tokenSource = oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
590
- const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
591
- const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
592
- return { oauth, tokenFingerprint, cacheKey };
593
- }
594
-
595
- function formatDuration(ms) {
596
- if (!Number.isFinite(ms) || ms <= 0) return "n/a";
597
- const totalMinutes = Math.floor(ms / 60000);
598
- const days = Math.floor(totalMinutes / (60 * 24));
599
- const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
600
- const minutes = totalMinutes % 60;
601
- if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`;
602
- if (hours > 0) return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
603
- return `${minutes}m`;
604
- }
605
-
606
- function formatTokenCount(n) {
607
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
608
- if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
609
- return String(n);
610
- }
611
-
612
- // ============================================================================
613
- // Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
614
- // ============================================================================
615
- function readClaudeCredentials() {
616
- const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
617
- if (!data) return null;
618
- const creds = data.claudeAiOauth || data;
619
- if (!creds.accessToken) return null;
620
- return {
621
- accessToken: creds.accessToken,
622
- refreshToken: creds.refreshToken,
623
- expiresAt: creds.expiresAt,
624
- };
625
- }
626
-
627
- function refreshClaudeAccessToken(refreshToken) {
628
- return new Promise((resolve) => {
629
- const clientId = process.env.CLAUDE_CODE_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
630
- const body = new URLSearchParams({
631
- grant_type: "refresh_token",
632
- refresh_token: refreshToken,
633
- client_id: clientId,
634
- }).toString();
635
- const req = https.request({
636
- hostname: "platform.claude.com",
637
- path: "/v1/oauth/token",
638
- method: "POST",
639
- headers: {
640
- "Content-Type": "application/x-www-form-urlencoded",
641
- "Content-Length": Buffer.byteLength(body),
642
- },
643
- timeout: CLAUDE_API_TIMEOUT_MS,
644
- }, (res) => {
645
- let data = "";
646
- res.on("data", (chunk) => { data += chunk; });
647
- res.on("end", () => {
648
- if (res.statusCode === 200) {
649
- try {
650
- const parsed = JSON.parse(data);
651
- if (parsed.access_token) {
652
- resolve({
653
- accessToken: parsed.access_token,
654
- refreshToken: parsed.refresh_token || refreshToken,
655
- expiresAt: parsed.expires_in
656
- ? Date.now() + parsed.expires_in * 1000
657
- : parsed.expires_at,
658
- });
659
- return;
660
- }
661
- } catch { /* parse 실패 */ }
662
- }
663
- resolve(null);
664
- });
665
- });
666
- req.on("error", () => resolve(null));
667
- req.on("timeout", () => { req.destroy(); resolve(null); });
668
- req.end(body);
669
- });
670
- }
671
-
672
- function writeBackClaudeCredentials(creds) {
673
- try {
674
- const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
675
- if (!data) return;
676
- const target = data.claudeAiOauth || data;
677
- target.accessToken = creds.accessToken;
678
- if (creds.expiresAt != null) target.expiresAt = creds.expiresAt;
679
- if (creds.refreshToken) target.refreshToken = creds.refreshToken;
680
- writeFileSync(CLAUDE_CREDENTIALS_PATH, JSON.stringify(data, null, 2));
681
- } catch { /* 쓰기 실패 무시 */ }
682
- }
683
-
684
- function fetchClaudeUsageFromApi(accessToken) {
685
- return new Promise((resolve) => {
686
- const req = https.request({
687
- hostname: "api.anthropic.com",
688
- path: "/api/oauth/usage",
689
- method: "GET",
690
- headers: {
691
- "Authorization": `Bearer ${accessToken}`,
692
- "anthropic-beta": "oauth-2025-04-20",
693
- "Content-Type": "application/json",
694
- },
695
- timeout: CLAUDE_API_TIMEOUT_MS,
696
- }, (res) => {
697
- let data = "";
698
- res.on("data", (chunk) => { data += chunk; });
699
- res.on("end", () => {
700
- if (res.statusCode === 200) {
701
- try { resolve({ ok: true, data: JSON.parse(data) }); } catch { resolve({ ok: false, status: 0 }); }
702
- } else {
703
- resolve({ ok: false, status: res.statusCode });
704
- }
705
- });
706
- });
707
- req.on("error", () => resolve({ ok: false, status: 0, error: "network" }));
708
- req.on("timeout", () => { req.destroy(); resolve({ ok: false, status: 0, error: "timeout" }); });
709
- req.end();
710
- });
711
- }
712
-
713
- function parseClaudeUsageResponse(response) {
714
- if (!response || typeof response !== "object") return null;
715
- // five_hour/seven_day 키 자체가 없으면 비정상 응답
716
- if (!response.five_hour && !response.seven_day) return null;
717
- const fiveHour = response.five_hour?.utilization;
718
- const sevenDay = response.seven_day?.utilization;
719
- // utilization이 null이면 0%로 처리 (API 200 성공 시 null = 사용량 없음)
720
- return {
721
- fiveHourPercent: clampPercent(fiveHour ?? 0),
722
- weeklyPercent: clampPercent(sevenDay ?? 0),
723
- fiveHourResetsAt: response.five_hour?.resets_at || null,
724
- weeklyResetsAt: response.seven_day?.resets_at || null,
725
- };
726
- }
727
-
728
- // stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
729
- function stripStaleResets(data) {
730
- if (!data) return data;
731
- const copy = { ...data };
732
- if (copy.fiveHourResetsAt) {
733
- const t = new Date(copy.fiveHourResetsAt).getTime();
734
- if (!isNaN(t)) copy.fiveHourResetsAt = new Date(advanceToNextCycle(t, FIVE_HOUR_MS)).toISOString();
735
- }
736
- if (copy.weeklyResetsAt) {
737
- const t = new Date(copy.weeklyResetsAt).getTime();
738
- if (!isNaN(t)) copy.weeklyResetsAt = new Date(advanceToNextCycle(t, SEVEN_DAY_MS)).toISOString();
739
- }
740
- return copy;
741
- }
742
-
743
- function readClaudeUsageSnapshot() {
744
- const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
745
- const ts = Number(cache?.timestamp);
746
- const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
747
-
748
- // 1차: 자체 캐시에 유효 데이터가 있는 경우
749
- if (cache?.data) {
750
- // 에러 상태에서 보존된 stale 데이터 → backoff 존중하되 표시용 데이터 반환
751
- if (cache.error) {
752
- const backoffMs = cache.errorType === "rate_limit"
753
- ? CLAUDE_USAGE_429_BACKOFF_MS
754
- : CLAUDE_USAGE_ERROR_BACKOFF_MS;
755
- return { data: stripStaleResets(cache.data), shouldRefresh: ageMs >= backoffMs };
756
- }
757
- const isFresh = ageMs < getClaudeUsageStaleMs();
758
- // resets_at이 지난 윈도우의 percent를 0으로 보정 (stale 캐시 방지)
759
- const data = { ...cache.data };
760
- const now = Date.now();
761
- if (data.fiveHourResetsAt && new Date(data.fiveHourResetsAt).getTime() <= now) {
762
- data.fiveHourPercent = 0;
763
- }
764
- if (data.weeklyResetsAt && new Date(data.weeklyResetsAt).getTime() <= now) {
765
- data.weeklyPercent = 0;
766
- }
767
- return { data, shouldRefresh: !isFresh };
768
- }
769
-
770
- // 2차: 에러 backoff — 최근 에러 시 재시도 억제 (무한 spawn 방지)
771
- if (cache?.error && Number.isFinite(ts)) {
772
- const backoffMs = cache.errorType === "rate_limit"
773
- ? CLAUDE_USAGE_429_BACKOFF_MS
774
- : CLAUDE_USAGE_ERROR_BACKOFF_MS;
775
- if (ageMs < backoffMs) {
776
- const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
777
- // OMC 캐시가 에러 이후 갱신되었으면 → 에러 캐시 덮어쓰고 그 데이터 사용
778
- if (omcCache?.data?.fiveHourPercent != null && omcCache.timestamp > ts) {
779
- writeClaudeUsageCache(omcCache.data);
780
- return { data: omcCache.data, shouldRefresh: false };
781
- }
782
- // stale OMC fallback 또는 null (--% 플레이스홀더 표시, 가짜 0% 방지)
783
- const staleData = omcCache?.data?.fiveHourPercent != null ? stripStaleResets(omcCache.data) : null;
784
- return { data: staleData, shouldRefresh: false };
785
- }
786
- }
787
-
788
- // 3차: OMC 플러그인 캐시 (같은 API 데이터, 중복 호출 방지)
789
- const OMC_CACHE_MAX_AGE_MS = 30 * 60 * 1000;
790
- const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
791
- if (omcCache?.data?.fiveHourPercent != null) {
792
- const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Number.MAX_SAFE_INTEGER;
793
- if (omcAge < OMC_CACHE_MAX_AGE_MS) {
794
- writeClaudeUsageCache(omcCache.data);
795
- return { data: omcCache.data, shouldRefresh: omcAge > getClaudeUsageStaleMs() };
796
- }
797
- // stale이어도 data: null보다는 오래된 데이터를 fallback으로 표시
798
- return { data: stripStaleResets(omcCache.data), shouldRefresh: true };
799
- }
800
-
801
- // 캐시/fallback 모두 없음: null 반환 → --% 플레이스홀더 + 리프레시 시도
802
- return { data: null, shouldRefresh: true };
803
- }
804
-
805
- function writeClaudeUsageCache(data, errorInfo = null) {
806
- const entry = {
807
- timestamp: Date.now(),
808
- data,
809
- error: !!errorInfo,
810
- errorType: errorInfo?.type || null, // "rate_limit" | "auth" | "network" | "unknown"
811
- errorStatus: errorInfo?.status || null, // HTTP 상태 코드
812
- };
813
- // 에러 시 기존 유효 데이터 보존 (--% n/a 방지)
814
- if (errorInfo && data == null) {
815
- const prev = readJson(CLAUDE_USAGE_CACHE_PATH, null);
816
- if (prev?.data) {
817
- entry.data = prev.data;
818
- entry.stale = true;
819
- }
820
- }
821
- writeJsonSafe(CLAUDE_USAGE_CACHE_PATH, entry);
822
- }
823
-
824
- async function fetchClaudeUsage(forceRefresh = false) {
825
- const existingSnapshot = readClaudeUsageSnapshot();
826
- if (!forceRefresh && !existingSnapshot.shouldRefresh && existingSnapshot.data) {
827
- return existingSnapshot.data;
828
- }
829
- let creds = readClaudeCredentials();
830
- if (!creds) {
831
- writeClaudeUsageCache(null, { type: "auth", status: 0 });
832
- return existingSnapshot.data || null;
833
- }
834
-
835
- // 토큰 만료 시 리프레시
836
- if (creds.expiresAt && creds.expiresAt <= Date.now() && creds.refreshToken) {
837
- const refreshed = await refreshClaudeAccessToken(creds.refreshToken);
838
- if (refreshed) {
839
- creds = { ...creds, ...refreshed };
840
- writeBackClaudeCredentials(creds);
841
- } else {
842
- writeClaudeUsageCache(null, { type: "auth", status: 0 });
843
- return existingSnapshot.data || null;
844
- }
845
- }
846
-
847
- const result = await fetchClaudeUsageFromApi(creds.accessToken);
848
- if (!result.ok) {
849
- // 에러 유형별 분류하여 backoff 차등 적용
850
- const errorType = result.status === 429 ? "rate_limit"
851
- : result.status === 401 || result.status === 403 ? "auth"
852
- : result.error === "timeout" || result.error === "network" ? "network"
853
- : "unknown";
854
- writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
855
- return existingSnapshot.data || null;
856
- }
857
- const usage = parseClaudeUsageResponse(result.data);
858
- writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 });
859
- return usage;
860
- }
861
-
862
- function scheduleClaudeUsageRefresh() {
863
- const scriptPath = process.argv[1];
864
- if (!scriptPath) return;
865
-
866
- // OMC 플러그인이 이미 fresh 데이터를 가지고 있으면 HUD 리프레시 불필요 (429 방지)
867
- try {
868
- const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
869
- if (omcCache?.data?.fiveHourPercent != null) {
870
- const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Infinity;
871
- if (omcAge < getClaudeUsageStaleMs()) {
872
- writeClaudeUsageCache(omcCache.data); // HUD 캐시에 복사만
873
- return;
874
- }
875
- }
876
- } catch { /* 무시 */ }
877
-
878
- // 스폰 락: 30초 내 이미 스폰했으면 중복 방지 (첫 설치 시 429 방지)
879
- const lockPath = join(homedir(), ".claude", "cache", ".claude-refresh-lock");
880
- try {
881
- if (existsSync(lockPath)) {
882
- const lockAge = Date.now() - readJson(lockPath, {}).t;
883
- if (lockAge < 30000) return; // 30초 이내 스폰 이력 → 건너뜀
884
- }
885
- writeJsonSafe(lockPath, { t: Date.now() });
886
- } catch { /* 락 실패 무시 — 스폰 진행 */ }
887
-
888
- try {
889
- const child = spawn(process.execPath, [scriptPath, CLAUDE_REFRESH_FLAG], {
890
- detached: true,
891
- stdio: "ignore",
892
- windowsHide: true,
893
- });
894
- child.unref();
895
- } catch (spawnErr) {
896
- // spawn 실패 시 에러 유형을 캐시에 기록 (HUD에서 원인 힌트 표시 가능)
897
- writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
898
- }
899
- }
900
-
901
- function getContextPercent(stdin) {
902
- const nativePercent = stdin?.context_window?.used_percentage;
903
- if (typeof nativePercent === "number" && Number.isFinite(nativePercent)) return clampPercent(nativePercent);
904
- const usage = stdin?.context_window?.current_usage || {};
905
- const totalTokens = Number(usage.input_tokens || 0)
906
- + Number(usage.cache_creation_input_tokens || 0)
907
- + Number(usage.cache_read_input_tokens || 0);
908
- const capacity = Number(stdin?.context_window?.context_window_size || 0);
909
- if (!capacity || capacity <= 0) return 0;
910
- return clampPercent((totalTokens / capacity) * 100);
911
- }
912
-
913
- // 과거 리셋 시간 → 다음 주기로 순환하여 미래 시점 반환
914
- function advanceToNextCycle(epochMs, cycleMs) {
915
- const now = Date.now();
916
- if (epochMs >= now || !cycleMs) return epochMs;
917
- const elapsed = now - epochMs;
918
- return epochMs + Math.ceil(elapsed / cycleMs) * cycleMs;
919
- }
920
-
921
- function formatResetRemaining(isoOrUnix, cycleMs = 0) {
922
- if (!isoOrUnix) return "";
923
- const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
924
- if (isNaN(d.getTime())) return "";
925
- const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
926
- const diffMs = targetMs - Date.now();
927
- if (diffMs <= 0) return "";
928
- const totalMinutes = Math.floor(diffMs / 60000);
929
- const totalHours = Math.floor(totalMinutes / 60);
930
- const minutes = totalMinutes % 60;
931
- return `${totalHours}h${String(minutes).padStart(2, "0")}m`;
932
- }
933
-
934
- function isResetPast(isoOrUnix) {
935
- if (!isoOrUnix) return false;
936
- const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
937
- return !isNaN(d.getTime()) && d.getTime() <= Date.now();
938
- }
939
-
940
- function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
941
- if (!isoOrUnix) return "";
942
- const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
943
- if (isNaN(d.getTime())) return "";
944
- const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
945
- const diffMs = targetMs - Date.now();
946
- if (diffMs <= 0) return "";
947
- const totalMinutes = Math.floor(diffMs / 60000);
948
- const days = Math.floor(totalMinutes / (60 * 24));
949
- const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
950
- return `${String(days).padStart(2, "0")}d${String(hours).padStart(2, "0")}h`;
951
- }
952
-
953
- function calcCooldownLeftSeconds(isoDatetime) {
954
- if (!isoDatetime) return 0;
955
- const cooldownMs = new Date(isoDatetime).getTime() - Date.now();
956
- if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return 0;
957
- return Math.ceil(cooldownMs / 1000);
958
- }
959
-
960
- // ============================================================================
961
- // HTTPS POST (타임아웃 포함)
962
- // ============================================================================
963
- function httpsPost(url, body, accessToken) {
964
- return new Promise((resolve) => {
965
- const urlObj = new URL(url);
966
- const data = JSON.stringify(body);
967
- const req = https.request({
968
- hostname: urlObj.hostname,
969
- path: urlObj.pathname + urlObj.search,
970
- method: "POST",
971
- headers: {
972
- "Content-Type": "application/json",
973
- "Authorization": `Bearer ${accessToken}`,
974
- "Content-Length": Buffer.byteLength(data),
975
- },
976
- timeout: GEMINI_API_TIMEOUT_MS,
977
- }, (res) => {
978
- const chunks = [];
979
- res.on("data", (c) => chunks.push(c));
980
- res.on("end", () => {
981
- try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
982
- catch { resolve(null); }
983
- });
984
- });
985
- req.on("error", () => resolve(null));
986
- req.on("timeout", () => { req.destroy(); resolve(null); });
987
- req.write(data);
988
- req.end();
989
- });
990
- }
991
-
992
- // ============================================================================
993
- // JWT base64 디코딩 공통 헬퍼
994
- // ============================================================================
995
- /**
996
- * JWT 파일에서 이메일을 추출하는 공통 헬퍼.
997
- * @param {string|null} idToken - JWT 문자열
998
- * @returns {string|null} 이메일 또는 null
999
- */
1000
- function decodeJwtEmail(idToken) {
1001
- if (!idToken) return null;
1002
- const parts = idToken.split(".");
1003
- if (parts.length < 2) return null;
1004
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
1005
- while (payload.length % 4) payload += "=";
1006
- try {
1007
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
1008
- return decoded.email || null;
1009
- } catch { return null; }
1010
- }
1011
-
1012
- // ============================================================================
1013
- // Codex JWT에서 이메일 추출
1014
- // ============================================================================
1015
- function getCodexEmail() {
1016
- try {
1017
- const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
1018
- return decodeJwtEmail(auth?.tokens?.id_token);
1019
- } catch { return null; }
1020
- }
1021
-
1022
- // ============================================================================
1023
- // Gemini JWT에서 이메일 추출
1024
- // ============================================================================
1025
- function getGeminiEmail() {
1026
- try {
1027
- const oauth = readJson(GEMINI_OAUTH_PATH, null);
1028
- return decodeJwtEmail(oauth?.id_token);
1029
- } catch { return null; }
1030
- }
1031
-
1032
- // resets_at이 지난 윈도우의 used_percent를 0으로 보정
1033
- function expireStaleCodexBuckets(buckets) {
1034
- if (!buckets) return buckets;
1035
- const nowSec = Math.floor(Date.now() / 1000);
1036
- for (const bucket of Object.values(buckets)) {
1037
- if (!bucket) continue;
1038
- if (bucket.primary?.resets_at && bucket.primary.resets_at <= nowSec) {
1039
- bucket.primary.used_percent = 0;
1040
- }
1041
- if (bucket.secondary?.resets_at && bucket.secondary.resets_at <= nowSec) {
1042
- bucket.secondary.used_percent = 0;
1043
- }
1044
- }
1045
- return buckets;
1046
- }
1047
-
1048
- // ============================================================================
1049
- // Codex 세션 JSONL에서 실제 rate limits 추출
1050
- // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
1051
- // 최근 7일간 세션 파일을 스캔해 가장 최신 rate_limits 버킷을 수집한다.
1052
- // 합성 버킷(token_count 기반)은 2일 이내 데이터만 허용하여 stale 방지.
1053
- // ============================================================================
1054
- function getCodexRateLimits() {
1055
- const now = new Date();
1056
- let syntheticBucket = null; // 최근 token_count에서 합성 (행 활성화 + 토큰 데이터용)
1057
-
1058
- // 7일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
1059
- for (let dayOffset = 0; dayOffset <= 6; dayOffset++) {
1060
- const d = new Date(now.getTime() - dayOffset * 86_400_000);
1061
- const sessDir = join(
1062
- homedir(), ".codex", "sessions",
1063
- String(d.getFullYear()),
1064
- String(d.getMonth() + 1).padStart(2, "0"),
1065
- String(d.getDate()).padStart(2, "0"),
1066
- );
1067
- if (!existsSync(sessDir)) continue;
1068
- let files;
1069
- try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
1070
- catch { continue; }
1071
-
1072
- const mergedBuckets = {};
1073
- for (const file of files) {
1074
- try {
1075
- const content = readFileSync(join(sessDir, file), "utf-8");
1076
- const lines = content.trim().split("\n").reverse();
1077
- for (const line of lines) {
1078
- try {
1079
- const evt = JSON.parse(line);
1080
- const rl = evt?.payload?.rate_limits;
1081
- if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
1082
- // 실제 rate_limits: limit_id별 최신 이벤트만 기록
1083
- mergedBuckets[rl.limit_id] = {
1084
- limitId: rl.limit_id, limitName: rl.limit_name,
1085
- primary: rl.primary, secondary: rl.secondary,
1086
- credits: rl.credits,
1087
- tokens: evt.payload?.info?.total_token_usage,
1088
- contextWindow: evt.payload?.info?.model_context_window,
1089
- timestamp: evt.timestamp,
1090
- };
1091
- } else if (dayOffset <= 1 && !rl && evt?.payload?.info?.total_token_usage && !syntheticBucket) {
1092
- // 2일 이내 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용, stale 방지)
1093
- syntheticBucket = {
1094
- limitId: "codex", limitName: "codex-session",
1095
- primary: null, secondary: null,
1096
- credits: null,
1097
- tokens: evt.payload.info.total_token_usage,
1098
- contextWindow: evt.payload.info.model_context_window,
1099
- timestamp: evt.timestamp,
1100
- };
1101
- }
1102
- } catch { /* 라인 파싱 실패 무시 */ }
1103
- if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
1104
- }
1105
- } catch { /* 파일 읽기 실패 무시 */ }
1106
- }
1107
- // 실제 rate_limits 발견 → 토큰 데이터 병합 후 즉시 반환
1108
- if (Object.keys(mergedBuckets).length > 0) {
1109
- if (syntheticBucket) {
1110
- const main = mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
1111
- if (main && !main.tokens) main.tokens = syntheticBucket.tokens;
1112
- }
1113
- expireStaleCodexBuckets(mergedBuckets);
1114
- return mergedBuckets;
1115
- }
1116
- }
1117
- // 실제 rate_limits 없음 → 합성 버킷이라도 반환 (행 활성화)
1118
- return syntheticBucket ? { codex: syntheticBucket } : null;
1119
- }
1120
-
1121
- // ============================================================================
1122
- // Gemini 쿼터 API 호출 (5분 캐시)
1123
- // ============================================================================
1124
- async function fetchGeminiQuota(accountId, options = {}) {
1125
- const authContext = options.authContext || buildGeminiAuthContext(accountId);
1126
- const { oauth, tokenFingerprint, cacheKey } = authContext;
1127
- const forceRefresh = options.forceRefresh === true;
1128
-
1129
- // 1. 캐시 확인 (계정/토큰별)
1130
- const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
1131
- if (!forceRefresh
1132
- && cache?.cacheKey === cacheKey
1133
- && cache?.timestamp
1134
- && (Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS)) {
1135
- return cache;
1136
- }
1137
-
1138
- if (!oauth?.access_token) {
1139
- // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
1140
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1141
- ...(cache || {}),
1142
- timestamp: cache?.timestamp || Date.now(),
1143
- error: true,
1144
- errorType: "auth",
1145
- errorHint: "no access_token in oauth_creds.json",
1146
- });
1147
- return cache;
1148
- }
1149
- if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
1150
- // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
1151
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1152
- ...(cache || {}),
1153
- timestamp: cache?.timestamp || Date.now(),
1154
- error: true,
1155
- errorType: "auth",
1156
- errorHint: `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
1157
- });
1158
- return cache;
1159
- }
1160
-
1161
- // 3. projectId (캐시 or API)
1162
- const fetchProjectId = async () => {
1163
- const loadRes = await httpsPost(
1164
- "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
1165
- { metadata: { pluginType: "GEMINI" } },
1166
- oauth.access_token,
1167
- );
1168
- const id = loadRes?.cloudaicompanionProject;
1169
- if (id) writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, { cacheKey, projectId: id, timestamp: Date.now() });
1170
- return id || null;
1171
- };
1172
-
1173
- const projCache = readJsonMigrate(GEMINI_PROJECT_CACHE_PATH, LEGACY_GEMINI_PROJECT_CACHE, null);
1174
- let projectId = projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
1175
- if (!projectId) projectId = await fetchProjectId();
1176
- if (!projectId) return cache;
1177
-
1178
- // 4. retrieveUserQuota 호출
1179
- let quotaRes = await httpsPost(
1180
- "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
1181
- { project: projectId },
1182
- oauth.access_token,
1183
- );
1184
-
1185
- // projectId 캐시가 만료/변경된 경우 1회 재시도
1186
- if (!quotaRes?.buckets && projCache?.projectId) {
1187
- projectId = await fetchProjectId();
1188
- if (!projectId) return cache;
1189
- quotaRes = await httpsPost(
1190
- "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
1191
- { project: projectId },
1192
- oauth.access_token,
1193
- );
1194
- }
1195
-
1196
- if (!quotaRes?.buckets) {
1197
- // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
1198
- const apiError = quotaRes?.error?.message || quotaRes?.error?.code || quotaRes?.error || "no buckets in response";
1199
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1200
- ...(cache || {}),
1201
- timestamp: cache?.timestamp || Date.now(),
1202
- error: true,
1203
- errorType: "api",
1204
- errorHint: String(apiError),
1205
- });
1206
- return cache;
1207
- }
1208
-
1209
- // 5. 캐시 저장
1210
- const result = {
1211
- timestamp: Date.now(),
1212
- cacheKey,
1213
- accountId: accountId || "gemini-main",
1214
- tokenFingerprint,
1215
- buckets: quotaRes.buckets,
1216
- };
1217
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, result);
1218
- return result;
1219
- }
1220
-
1221
- /**
1222
- * Gemini RPM 트래커에서 최근 60초 내 요청 수를 읽는다.
1223
- * @returns {{ count: number, percent: number, remainingSec: number }}
1224
- */
1225
- function readGeminiRpm(model) {
1226
- try {
1227
- // 새 경로 → 레거시 경로 fallback
1228
- const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH) ? GEMINI_RPM_TRACKER_PATH
1229
- : existsSync(LEGACY_GEMINI_RPM_TRACKER) ? LEGACY_GEMINI_RPM_TRACKER : null;
1230
- if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
1231
- const raw = readFileSync(rpmPath, "utf-8");
1232
- const parsed = JSON.parse(raw);
1233
- const timestamps = Array.isArray(parsed.timestamps) ? parsed.timestamps : [];
1234
- const now = Date.now();
1235
- const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
1236
- const count = recent.length;
1237
- const rpmLimit = getGeminiRpmLimit(model);
1238
- const percent = clampPercent(Math.round((count / rpmLimit) * 100));
1239
- // 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초
1240
- // 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
1241
- // 5초 단위 반올림으로 HUD 깜빡임 감소
1242
- const rawRemainingSec = recent.length > 0
1243
- ? Math.max(0, Math.ceil((GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000))
1244
- : 0;
1245
- const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
1246
- return { count, percent, remainingSec };
1247
- } catch {
1248
- return { count: 0, percent: 0, remainingSec: 60 };
1249
- }
1250
- }
1251
-
1252
- function readGeminiQuotaSnapshot(accountId, authContext) {
1253
- const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
1254
- if (!cache?.buckets) {
1255
- return { quota: null, shouldRefresh: true };
1256
- }
1257
-
1258
- const cacheKey = authContext.cacheKey;
1259
- const isLegacyCache = !cache.cacheKey;
1260
- const keyMatched = cache.cacheKey === cacheKey;
1261
- const cacheTs = Number(cache.timestamp);
1262
- const ageMs = Number.isFinite(cacheTs) ? Date.now() - cacheTs : Number.MAX_SAFE_INTEGER;
1263
- const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
1264
-
1265
- if (keyMatched) {
1266
- // resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
1267
- if (Array.isArray(cache.buckets)) {
1268
- const now = Date.now();
1269
- for (const b of cache.buckets) {
1270
- if (b?.resetTime && new Date(b.resetTime).getTime() <= now) {
1271
- b.remainingFraction = 1;
1272
- }
1273
- }
1274
- }
1275
- return { quota: cache, shouldRefresh: !isFresh };
1276
- }
1277
- if (isLegacyCache) {
1278
- return { quota: cache, shouldRefresh: true };
1279
- }
1280
- return { quota: null, shouldRefresh: true };
1281
- }
1282
-
1283
- function scheduleGeminiQuotaRefresh(accountId) {
1284
- const scriptPath = process.argv[1];
1285
- if (!scriptPath) return;
1286
- try {
1287
- const child = spawn(
1288
- process.execPath,
1289
- [scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
1290
- {
1291
- detached: true,
1292
- stdio: "ignore",
1293
- windowsHide: true,
1294
- },
1295
- );
1296
- child.unref();
1297
- } catch (spawnErr) {
1298
- // spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
1299
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1300
- timestamp: Date.now(),
1301
- error: true,
1302
- errorHint: String(spawnErr?.message || spawnErr),
1303
- });
1304
- }
1305
- }
1306
-
1307
- function readCodexRateLimitSnapshot() {
1308
- const cache = readJson(CODEX_QUOTA_CACHE_PATH, null);
1309
- if (!cache?.buckets) {
1310
- return { buckets: null, shouldRefresh: true };
1311
- }
1312
- expireStaleCodexBuckets(cache.buckets);
1313
- const ts = Number(cache.timestamp);
1314
- const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
1315
- const isFresh = ageMs < CODEX_QUOTA_STALE_MS;
1316
- return { buckets: cache.buckets, shouldRefresh: !isFresh };
1317
- }
1318
-
1319
- function refreshCodexRateLimitsCache() {
1320
- const buckets = getCodexRateLimits();
1321
- // buckets가 null이어도 캐시 갱신 (stale 데이터 제거)
1322
- writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
1323
- return buckets;
1324
- }
1325
-
1326
- function scheduleCodexRateLimitRefresh() {
1327
- const scriptPath = process.argv[1];
1328
- if (!scriptPath) return;
1329
- try {
1330
- const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
1331
- detached: true,
1332
- stdio: "ignore",
1333
- windowsHide: true,
1334
- });
1335
- child.unref();
1336
- } catch (spawnErr) {
1337
- // spawn 실패 시 캐시에 에러 힌트 기록
1338
- writeJsonSafe(CODEX_QUOTA_CACHE_PATH, {
1339
- timestamp: Date.now(),
1340
- buckets: null,
1341
- error: true,
1342
- errorHint: String(spawnErr?.message || spawnErr),
1343
- });
1344
- }
1345
- }
1346
-
1347
- function readGeminiSessionSnapshot() {
1348
- const cache = readJsonMigrate(GEMINI_SESSION_CACHE_PATH, LEGACY_GEMINI_SESSION_CACHE, null);
1349
- if (!cache?.session) {
1350
- return { session: null, shouldRefresh: true };
1351
- }
1352
- const ts = Number(cache.timestamp);
1353
- const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
1354
- const isFresh = ageMs < GEMINI_SESSION_STALE_MS;
1355
- return { session: cache.session, shouldRefresh: !isFresh };
1356
- }
1357
-
1358
- function refreshGeminiSessionCache() {
1359
- const session = scanGeminiSessionTokens();
1360
- if (!session) return null;
1361
- writeJsonSafe(GEMINI_SESSION_CACHE_PATH, { timestamp: Date.now(), session });
1362
- return session;
1363
- }
1364
-
1365
- function scheduleGeminiSessionRefresh() {
1366
- const scriptPath = process.argv[1];
1367
- if (!scriptPath) return;
1368
- try {
1369
- const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
1370
- detached: true,
1371
- stdio: "ignore",
1372
- windowsHide: true,
1373
- });
1374
- child.unref();
1375
- } catch { /* 백그라운드 실행 실패 무시 */ }
1376
- }
1377
-
1378
- // ============================================================================
1379
- // Gemini 세션 JSON에서 토큰 사용량 추출
1380
- // ============================================================================
1381
- function scanGeminiSessionTokens() {
1382
- const tmpDir = join(homedir(), ".gemini", "tmp");
1383
- if (!existsSync(tmpDir)) return null;
1384
- let best = null;
1385
- let bestTime = 0;
1386
- try {
1387
- const dirs = readdirSync(tmpDir).filter((d) => existsSync(join(tmpDir, d, "chats")));
1388
- for (const dir of dirs) {
1389
- const chatsDir = join(tmpDir, dir, "chats");
1390
- let files;
1391
- try { files = readdirSync(chatsDir).filter((f) => f.endsWith(".json")); } catch { continue; }
1392
- for (const file of files) {
1393
- try {
1394
- const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
1395
- const updatedAt = new Date(data.lastUpdated || 0).getTime();
1396
- if (updatedAt <= bestTime) continue;
1397
- let input = 0, output = 0;
1398
- let model = "unknown";
1399
- for (const msg of data.messages || []) {
1400
- if (msg.tokens) { input += msg.tokens.input || 0; output += msg.tokens.output || 0; }
1401
- if (msg.model) model = msg.model;
1402
- }
1403
- bestTime = updatedAt;
1404
- best = { input, output, total: input + output, model, lastUpdated: data.lastUpdated };
1405
- } catch { /* 무시 */ }
1406
- }
1407
- }
1408
- } catch { /* 무시 */ }
1409
- return best;
1410
- }
1411
-
1412
- // ============================================================================
1413
- // 라인 렌더러
1414
- // ============================================================================
1415
- // 최근 벤치마크 diff 파일 읽기 (cx-auto-tokens/diffs/ 에서 최신 1개)
1416
- function readLatestBenchmarkDiff() {
1417
- const diffsDir = join(homedir(), ".omc", "state", "cx-auto-tokens", "diffs");
1418
- if (!existsSync(diffsDir)) return null;
1419
- try {
1420
- const files = readdirSync(diffsDir).filter(f => f.endsWith(".json")).sort().reverse();
1421
- if (files.length === 0) return null;
1422
- return readJson(join(diffsDir, files[0]), null);
1423
- } catch { return null; }
1424
- }
1425
-
1426
- // 토큰 절약액 누적치 읽기 (tfx-auto token tracker)
1427
- function readTokenSavings() {
1428
- const savingsPath = join(homedir(), ".omc", "state", "tfx-auto-tokens", "savings-total.json");
1429
- const data = readJson(savingsPath, null);
1430
- if (!data || data.totalSaved === 0) return null;
1431
- return data;
1432
- }
1433
-
1434
- // sv-accumulator.json에서 누적 토큰/비용 읽기
1435
- function readSvAccumulator() {
1436
- return readJsonMigrate(SV_ACCUMULATOR_PATH, LEGACY_SV_ACCUMULATOR, null);
1437
- }
1438
-
1439
- function formatSavings(dollars) {
1440
- if (dollars >= 100) return `$${Math.round(dollars)}`;
1441
- if (dollars >= 10) return `$${dollars.toFixed(1)}`;
1442
- return `$${dollars.toFixed(2)}`;
1443
- }
1444
-
1445
- /**
1446
- * 파이프라인 벤치마크 diff 결과를 HUD 요약 문자열로 포맷
1447
- * @param {object} diff - computeDiff() 반환 결과
1448
- * @returns {string} 토큰 소비 요약 (input/output, 비용, 절감률)
1449
- */
1450
- function formatTokenSummary(diff) {
1451
- if (!diff?.delta?.total || !diff?.savings) return "";
1452
- const t = diff.delta.total;
1453
- const s = diff.savings;
1454
-
1455
- const inputStr = formatTokenCount(t.input);
1456
- const outputStr = formatTokenCount(t.output);
1457
- const actualStr = formatSavings(s.actualCost);
1458
- const claudeStr = formatSavings(s.claudeCost);
1459
- const savedPct = s.claudeCost > 0
1460
- ? Math.round((s.saved / s.claudeCost) * 100)
1461
- : 0;
1462
-
1463
- return `${dim("tok:")}${inputStr}${dim("in")} ${outputStr}${dim("out")} ` +
1464
- `${dim("cost:")}${actualStr} ` +
1465
- `${dim("sv:")}${green(formatSavings(s.saved))}${dim("(")}${savedPct}%${dim(")")}`;
1466
- }
1467
-
1468
- // sv 퍼센트 포맷 (1000+ → k 표기, 5자 고정폭)
1469
- const SV_CELL_WIDTH = 5;
1470
- function formatSvPct(value) {
1471
- if (value == null) return "--%".padStart(SV_CELL_WIDTH);
1472
- if (value >= 10000) return `${Math.round(value / 1000)}k%`.padStart(SV_CELL_WIDTH);
1473
- if (value >= 1000) return `${(value / 1000).toFixed(1)}k%`.padStart(SV_CELL_WIDTH);
1474
- return `${value}%`.padStart(SV_CELL_WIDTH);
1475
- }
1476
-
1477
- function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1478
- const contextPercent = getContextPercent(stdin);
1479
- const prefix = `${bold(claudeOrange("c"))}:`;
1480
-
1481
- // 절약 퍼센트 (Codex+Gemini sv% 합산, x/g와 동일 형식)
1482
- const svStr = formatSvPct(combinedSvPct || 0);
1483
- const svSuffix = `${dim("sv:")}${svStr}`;
1484
-
1485
- // API 실측 데이터 사용 (없으면 플레이스홀더)
1486
- const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
1487
- const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
1488
- const fiveHourReset = claudeUsage?.fiveHourResetsAt
1489
- ? formatResetRemaining(claudeUsage.fiveHourResetsAt, FIVE_HOUR_MS)
1490
- : "n/a";
1491
- const weeklyReset = claudeUsage?.weeklyResetsAt
1492
- ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt, SEVEN_DAY_MS)
1493
- : "n/a";
1494
-
1495
- const hasData = claudeUsage != null;
1496
-
1497
- const fStr = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange) : dim(formatPlaceholderPercentCell());
1498
- const wStr = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange) : dim(formatPlaceholderPercentCell());
1499
- const fBar = hasData && fiveHourPercent != null ? tierBar(fiveHourPercent, CLAUDE_ORANGE) : tierDimBar();
1500
- const wBar = hasData && weeklyPercent != null ? tierBar(weeklyPercent, CLAUDE_ORANGE) : tierDimBar();
1501
- const fTime = formatTimeCell(fiveHourReset);
1502
- const wTime = formatTimeCellDH(weeklyReset);
1503
-
1504
- if (CURRENT_TIER === "nano" || CURRENT_TIER === "micro") {
1505
- // 40~59 cols (micro) & <40 (nano): No time, no token count, short labels
1506
- const fShort = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange) : dim("--");
1507
- const wShort = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange) : dim("--");
1508
- const quotaSection = `${fShort}${dim("/")}${wShort}`;
1509
- return [{ prefix, left: quotaSection, right: "" }];
1510
- }
1511
-
1512
- if (CURRENT_TIER === "minimal") {
1513
- // 60~79 cols: Labels, but no time, no token count
1514
- const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}`;
1515
- return [{ prefix, left: quotaSection, right: "" }];
1516
- }
1517
-
1518
- if (CURRENT_TIER === "compact") {
1519
- // 80~119 cols: Includes Time and token count, no bars
1520
- const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
1521
- const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1522
- return [{ prefix, left: quotaSection, right: contextSection }];
1523
- }
1524
-
1525
- // full tier (>= 120 cols): Bars, time, token count
1526
- const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
1527
- const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1528
- return [{ prefix, left: quotaSection, right: contextSection }];
1529
- }
1530
-
1531
- function getAccountLabel(provider, accountsConfig, accountsState, codexEmail) {
1532
- const providerConfig = accountsConfig?.providers?.[provider] || [];
1533
- const providerState = accountsState?.providers?.[provider] || {};
1534
- const lastId = providerState.last_selected_id;
1535
- const picked = providerConfig.find((a) => a.id === lastId) || providerConfig[0]
1536
- || { id: `${provider}-main`, label: provider };
1537
- let label = picked.label || picked.id;
1538
- if (codexEmail) label = codexEmail;
1539
- if (label.includes("@")) label = label.split("@")[0];
1540
- return label;
1541
- }
1542
-
1543
- function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfig, accountsState, realQuota, codexEmail, savingsMultiplier, modelLabel) {
1544
- const accountLabel = fitText(getAccountLabel(provider, accountsConfig, accountsState, codexEmail), ACCOUNT_LABEL_WIDTH);
1545
-
1546
- // 절약 퍼센트 섹션 (context window 대비 %, 4자리 고정폭)
1547
- const svPct = savingsMultiplier != null ? Math.round(savingsMultiplier * 100) : null;
1548
- const svStr = formatSvPct(svPct);
1549
- const modelLabelStr = modelLabel ? ` ${markerColor(modelLabel)}` : "";
1550
-
1551
- // ── 프로바이더별 색상 프로필 ──
1552
- const provAnsi = provider === "codex" ? CODEX_WHITE : provider === "gemini" ? GEMINI_BLUE : GREEN;
1553
- const provFn = provider === "codex" ? codexWhite : provider === "gemini" ? geminiBlue : green;
1554
-
1555
- // ── 쿼터 섹션 ──
1556
- let quotaSection;
1557
- let extraRightSection = "";
1558
-
1559
- if (CURRENT_TIER === "nano" || CURRENT_TIER === "micro") {
1560
- const minPrefix = `${bold(markerColor(`${marker}`))}:`;
1561
- if (realQuota?.type === "codex") {
1562
- const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1563
- if (main) {
1564
- const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1565
- const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1566
- const fCellN = fiveP != null ? colorByProvider(fiveP, `${fiveP}%`, provFn) : dim("--%");
1567
- const wCellN = weekP != null ? colorByProvider(weekP, `${weekP}%`, provFn) : dim("--%");
1568
- return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}`, right: "" };
1569
- }
1570
- }
1571
- if (realQuota?.type === "gemini") {
1572
- const pools = realQuota.pools || {};
1573
- if (pools.pro || pools.flash) {
1574
- const pP = pools.pro ? clampPercent(Math.round((1 - (pools.pro.remainingFraction ?? 1)) * 100)) : null;
1575
- const pF = pools.flash ? clampPercent(Math.round((1 - (pools.flash.remainingFraction ?? 1)) * 100)) : null;
1576
- const pStr = pP != null ? colorByProvider(pP, `${pP}`, provFn) : dim("--");
1577
- const fStr = pF != null ? colorByProvider(pF, `${pF}`, provFn) : dim("--");
1578
- return { prefix: minPrefix, left: `${pStr}${dim("/")}${fStr}`, right: "" };
1579
- }
1580
- }
1581
- return { prefix: minPrefix, left: dim("--/--"), right: "" };
1582
- }
1583
-
1584
- if (CURRENT_TIER === "minimal") {
1585
- if (realQuota?.type === "codex") {
1586
- const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1587
- if (main) {
1588
- const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1589
- const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1590
- const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1591
- const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1592
- quotaSection = `${dim("5h:")}${fCell} ${dim("1w:")}${wCell}`;
1593
- }
1594
- }
1595
- if (realQuota?.type === "gemini") {
1596
- const pools = realQuota.pools || {};
1597
- if (pools.pro || pools.flash) {
1598
- const slot = (bucket, label) => {
1599
- if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())}`;
1600
- const gl = deriveGeminiLimits(bucket);
1601
- const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1602
- return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
1603
- };
1604
- quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
1605
- } else {
1606
- quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())}`;
1607
- }
1608
- }
1609
- if (!quotaSection) {
1610
- quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim("1w:")}${dim(formatPlaceholderPercentCell())}`;
1611
- }
1612
- const prefix = `${bold(markerColor(`${marker}`))}:`;
1613
- return { prefix, left: quotaSection, right: accountLabel ? markerColor(accountLabel) : "" };
1614
- }
1615
-
1616
- if (CURRENT_TIER === "compact") {
1617
- if (realQuota?.type === "codex") {
1618
- const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1619
- if (main) {
1620
- const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1621
- const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1622
- const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1623
- const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1624
- const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
1625
- const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
1626
- quotaSection = `${dim("5h:")}${fCell} ${dim(formatTimeCell(fiveReset))} ${dim("1w:")}${wCell} ${dim(formatTimeCellDH(weekReset))}`;
1627
- }
1628
- }
1629
- if (realQuota?.type === "gemini") {
1630
- const pools = realQuota.pools || {};
1631
- const hasAnyPool = pools.pro || pools.flash;
1632
- if (hasAnyPool) {
1633
- const slot = (bucket, label) => {
1634
- if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1635
- const gl = deriveGeminiLimits(bucket);
1636
- const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1637
- const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1638
- return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
1639
- };
1640
- quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
1641
- } else {
1642
- quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1643
- }
1644
- }
1645
- if (!quotaSection) {
1646
- quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
1647
- }
1648
- const prefix = `${bold(markerColor(`${marker}`))}:`;
1649
- const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
1650
- return { prefix, left: quotaSection, right: compactRight };
1651
- }
1652
-
1653
- if (realQuota?.type === "codex") {
1654
- const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1655
- if (main) {
1656
- // 캐시된 값 그대로 표시 (시간은 advanceToNextCycle이 처리)
1657
- const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1658
- const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1659
- const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
1660
- const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
1661
- const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1662
- const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1663
- const fBar = fiveP != null ? tierBar(fiveP, provAnsi) : tierDimBar();
1664
- const wBar = weekP != null ? tierBar(weekP, provAnsi) : tierDimBar();
1665
- quotaSection = `${dim("5h:")}${fBar}${fCell} ` +
1666
- `${dim(formatTimeCell(fiveReset))} ` +
1667
- `${dim("1w:")}${wBar}${wCell} ` +
1668
- `${dim(formatTimeCellDH(weekReset))}`;
1669
- }
1670
- }
1671
-
1672
- if (realQuota?.type === "gemini") {
1673
- const pools = realQuota.pools || {};
1674
- const hasAnyPool = pools.pro || pools.flash;
1675
-
1676
- if (hasAnyPool) {
1677
- // C/X와 동일한 2슬롯 구조: P:gauge %% (time) F:gauge %% (time)
1678
- const slot = (bucket, label) => {
1679
- if (!bucket) {
1680
- return `${dim(label + ":")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1681
- }
1682
- const gl = deriveGeminiLimits(bucket);
1683
- const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1684
- const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1685
- return `${dim(label + ":")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
1686
- };
1687
-
1688
- quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
1689
- } else {
1690
- quotaSection = `${dim("Pr:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ` +
1691
- `${dim("Fl:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1692
- }
1693
- }
1694
-
1695
- // 폴백: 쿼터 데이터 없을 때
1696
- if (!quotaSection) {
1697
- quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
1698
- }
1699
-
1700
- const prefix = `${bold(markerColor(`${marker}`))}:`;
1701
- const accountSection = `${markerColor(accountLabel)}`;
1702
- const svSection = svStr ? `${dim("sv:")}${svStr}` : "";
1703
- const modelLabelSection = modelLabel ? markerColor(modelLabel) : "";
1704
- const rightParts = [svSection, accountSection, modelLabelSection].filter(Boolean);
1705
- return {
1706
- prefix,
1707
- left: quotaSection,
1708
- right: rightParts.join(` ${dim("|")} `),
1709
- };
1710
- }
1711
-
1712
- // ============================================================================
1713
- // 메인
1714
- // ============================================================================
1715
- async function main() {
1716
- // 백그라운드 Claude 사용량 리프레시
1717
- if (process.argv.includes(CLAUDE_REFRESH_FLAG)) {
1718
- await fetchClaudeUsage(true);
1719
- return;
1720
- }
1721
-
1722
- if (process.argv.includes(CODEX_REFRESH_FLAG)) {
1723
- refreshCodexRateLimitsCache();
1724
- return;
1725
- }
1726
-
1727
- if (process.argv.includes(GEMINI_SESSION_REFRESH_FLAG)) {
1728
- refreshGeminiSessionCache();
1729
- return;
1730
- }
1731
-
1732
- // 백그라운드 Gemini 쿼터 리프레시 전용 실행 모드
1733
- if (process.argv.includes(GEMINI_REFRESH_FLAG)) {
1734
- const accountId = getCliArgValue("--account") || "gemini-main";
1735
- const authContext = buildGeminiAuthContext(accountId);
1736
- await fetchGeminiQuota(accountId, { authContext, forceRefresh: true });
1737
- return;
1738
- }
1739
-
1740
- // 메인 HUD 경로: 즉시 렌더 우선
1741
- const stdinPromise = readStdinJson();
1742
-
1743
- const qosProfile = readJson(QOS_PATH, { providers: {} });
1744
- const accountsConfig = readJson(ACCOUNTS_CONFIG_PATH, { providers: {} });
1745
- const accountsState = readJson(ACCOUNTS_STATE_PATH, { providers: {} });
1746
- const claudeUsageSnapshot = readClaudeUsageSnapshot();
1747
- if (claudeUsageSnapshot.shouldRefresh) {
1748
- scheduleClaudeUsageRefresh();
1749
- }
1750
- const geminiAccountId = getProviderAccountId("gemini", accountsConfig, accountsState);
1751
- const codexSnapshot = readCodexRateLimitSnapshot();
1752
- const geminiSessionSnapshot = readGeminiSessionSnapshot();
1753
- const geminiAuthContext = buildGeminiAuthContext(geminiAccountId);
1754
- const geminiQuotaSnapshot = readGeminiQuotaSnapshot(geminiAccountId, geminiAuthContext);
1755
- if (codexSnapshot.shouldRefresh) {
1756
- scheduleCodexRateLimitRefresh();
1757
- }
1758
- if (geminiSessionSnapshot.shouldRefresh) {
1759
- scheduleGeminiSessionRefresh();
1760
- }
1761
- if (geminiQuotaSnapshot.shouldRefresh) {
1762
- scheduleGeminiQuotaRefresh(geminiAccountId);
1763
- }
1764
-
1765
- // 실측 데이터 추출
1766
- const stdin = await stdinPromise;
1767
- const codexEmail = getCodexEmail();
1768
- const geminiEmail = getGeminiEmail();
1769
- const codexBuckets = codexSnapshot.buckets;
1770
- const geminiSession = geminiSessionSnapshot.session;
1771
- const geminiQuota = geminiQuotaSnapshot.quota;
1772
-
1773
- // 누적 절약 데이터 읽기
1774
- const svSavings = readTokenSavings();
1775
- const svAccumulator = readSvAccumulator();
1776
- const totalCostSaved = svSavings?.totalSaved || svAccumulator?.totalCostSaved || 0;
1777
-
1778
- // 세션/누적 토큰 → context 대비 절약 배수 (개별 provider sv%)
1779
- const ctxCapacity = stdin?.context_window?.context_window_size || 200000;
1780
- let codexSv = null;
1781
- if (svAccumulator?.codex?.tokens > 0) {
1782
- codexSv = svAccumulator.codex.tokens / ctxCapacity;
1783
- } else if (codexBuckets) {
1784
- const main = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
1785
- if (main?.tokens?.total_tokens) codexSv = main.tokens.total_tokens / ctxCapacity;
1786
- }
1787
- let geminiSv = null;
1788
- if (svAccumulator?.gemini?.tokens > 0) {
1789
- geminiSv = svAccumulator.gemini.tokens / ctxCapacity;
1790
- } else {
1791
- const geminiTokens = geminiSession?.total || null;
1792
- geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
1793
- }
1794
-
1795
- // Gemini: 3풀 버킷 추출 (Pro/Flash/Lite — 각 풀 내 모델들은 쿼터 공유)
1796
- const geminiModel = geminiSession?.model || "gemini-3-flash-preview";
1797
- const geminiBuckets = geminiQuota?.buckets || [];
1798
- const geminiBucket = geminiBuckets.find((b) => b.modelId === geminiModel)
1799
- || geminiBuckets.find((b) => b.modelId === "gemini-3-flash-preview")
1800
- || null;
1801
- const geminiProBucket = geminiBuckets.find((b) => GEMINI_PRO_POOL.has(b.modelId)) || null;
1802
- const geminiFlashBucket = geminiBuckets.find((b) => GEMINI_FLASH_POOL.has(b.modelId)) || null;
1803
- const geminiLiteBucket = geminiBuckets.find((b) => b.modelId?.includes("flash-lite")) || null;
1804
-
1805
- // 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
1806
- const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
1807
-
1808
- // 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
1809
- CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
1810
-
1811
- // nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
1812
- if (CURRENT_TIER === "nano") {
1813
- const microLine = getMicroLine(stdin, claudeUsageSnapshot.data, codexBuckets,
1814
- geminiSession, geminiBucket, combinedSvPct);
1815
- process.stdout.write(`\x1b[0m${microLine}\n`);
1816
- return;
1817
- }
1818
-
1819
- const codexQuotaData = codexBuckets ? { type: "codex", buckets: codexBuckets } : null;
1820
- const geminiQuotaData = {
1821
- type: "gemini",
1822
- quotaBucket: geminiBucket,
1823
- pools: { pro: geminiProBucket, flash: geminiFlashBucket, lite: geminiLiteBucket },
1824
- session: geminiSession,
1825
- };
1826
-
1827
- const rows = [
1828
- ...getClaudeRows(stdin, claudeUsageSnapshot.data, combinedSvPct),
1829
- getProviderRow("codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
1830
- codexQuotaData, codexEmail, codexSv, null),
1831
- getProviderRow("gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
1832
- geminiQuotaData, geminiEmail, geminiSv, null),
1833
- ];
1834
-
1835
- // tfx-multi 활성 시 팀 상태 행 추가 (v2.2)
1836
- const teamRow = getTeamRow();
1837
- if (teamRow) rows.push(teamRow);
1838
-
1839
- // 최근 벤치마크 diff → 토큰 요약 행 추가
1840
- const latestDiff = readLatestBenchmarkDiff();
1841
- if (latestDiff) {
1842
- const summary = formatTokenSummary(latestDiff);
1843
- if (summary) {
1844
- rows.push({ prefix: `${dim("$")}:`, left: summary, right: "" });
1845
- }
1846
- }
1847
-
1848
- // 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
1849
- const codexActive = codexBuckets != null;
1850
- const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null
1851
- || geminiProBucket != null || geminiFlashBucket != null;
1852
-
1853
- let outputLines = renderAlignedRows(rows);
1854
-
1855
- // 비활성 줄 dim 래핑 (rows 순서: [claude, codex, gemini])
1856
- if (outputLines.length >= 3) {
1857
- if (!codexActive) outputLines[1] = `${DIM}${outputLines[1]}${RESET}`;
1858
- if (!geminiActive) outputLines[2] = `${DIM}${outputLines[2]}${RESET}`;
1859
- }
1860
-
1861
- // 선행 개행: 알림 배너(노란 글씨)가 빈 첫 줄에 오도록 → HUD 내용 보호
1862
- // Context low(≥85%) 시 추가 개행으로 배너 분리
1863
- const contextPercent = getContextPercent(stdin);
1864
- const leadingBreaks = contextPercent >= 85 ? "\n\n" : "\n";
1865
- // 줄별 RESET: Claude Code TUI 스타일 간섭 방지 (색상 밝기 버그 수정)
1866
- const resetedLines = outputLines.map(line => `\x1b[0m${line}`);
1867
- process.stdout.write(`${leadingBreaks}${resetedLines.join("\n")}\n`);
1868
- }
1869
-
1870
- main().catch(() => {
1871
- process.stdout.write(`\x1b[0m${bold(claudeOrange("c"))}: ${dim("5h:")}${green("0%")} ${dim("(n/a)")} ${dim("1w:")}${green("0%")} ${dim("(n/a)")} ${dim("|")} ${dim("ctx:")}${green("0%")}\n`);
1872
- });
1
+ #!/usr/bin/env node
2
+
3
+ // ============================================================================
4
+ // HUD QoS Status 메인 오케스트레이터
5
+ // 모듈에서 색상, 터미널, 프로바이더, 렌더러를 가져와 조합한다.
6
+ // ============================================================================
7
+
8
+ import { DIM, RESET, bold, dim, green, claudeOrange, codexWhite, geminiBlue, colorByPercent } from "./colors.mjs";
9
+ import {
10
+ QOS_PATH, ACCOUNTS_CONFIG_PATH, ACCOUNTS_STATE_PATH,
11
+ CLAUDE_REFRESH_FLAG, CODEX_REFRESH_FLAG,
12
+ GEMINI_REFRESH_FLAG, GEMINI_SESSION_REFRESH_FLAG,
13
+ GEMINI_PRO_POOL, GEMINI_FLASH_POOL,
14
+ } from "./constants.mjs";
15
+ import {
16
+ readJson, readStdinJson, getContextPercent, getProviderAccountId, getCliArgValue,
17
+ } from "./utils.mjs";
18
+ import { selectTier } from "./terminal.mjs";
19
+
20
+ // Claude provider
21
+ import {
22
+ readClaudeUsageSnapshot, scheduleClaudeUsageRefresh, fetchClaudeUsage,
23
+ } from "./providers/claude.mjs";
24
+
25
+ // Codex provider
26
+ import {
27
+ getCodexEmail, readCodexRateLimitSnapshot,
28
+ refreshCodexRateLimitsCache, scheduleCodexRateLimitRefresh,
29
+ } from "./providers/codex.mjs";
30
+
31
+ // Gemini provider
32
+ import {
33
+ getGeminiEmail, buildGeminiAuthContext,
34
+ readGeminiQuotaSnapshot, readGeminiSessionSnapshot,
35
+ fetchGeminiQuota, refreshGeminiSessionCache,
36
+ scheduleGeminiQuotaRefresh, scheduleGeminiSessionRefresh,
37
+ } from "./providers/gemini.mjs";
38
+
39
+ // Renderers
40
+ import {
41
+ getClaudeRows, getProviderRow, getTeamRow,
42
+ renderAlignedRows, getMicroLine,
43
+ readLatestBenchmarkDiff, formatTokenSummary,
44
+ readTokenSavings, readSvAccumulator,
45
+ } from "./renderers.mjs";
46
+
47
+ // ============================================================================
48
+ // 메인
49
+ // ============================================================================
50
+ async function main() {
51
+ // 백그라운드 Claude 사용량 리프레시
52
+ if (process.argv.includes(CLAUDE_REFRESH_FLAG)) {
53
+ await fetchClaudeUsage(true);
54
+ return;
55
+ }
56
+
57
+ if (process.argv.includes(CODEX_REFRESH_FLAG)) {
58
+ refreshCodexRateLimitsCache();
59
+ return;
60
+ }
61
+
62
+ if (process.argv.includes(GEMINI_SESSION_REFRESH_FLAG)) {
63
+ refreshGeminiSessionCache();
64
+ return;
65
+ }
66
+
67
+ // 백그라운드 Gemini 쿼터 리프레시 전용 실행 모드
68
+ if (process.argv.includes(GEMINI_REFRESH_FLAG)) {
69
+ const accountId = getCliArgValue("--account") || "gemini-main";
70
+ const authContext = buildGeminiAuthContext(accountId);
71
+ await fetchGeminiQuota(accountId, { authContext, forceRefresh: true });
72
+ return;
73
+ }
74
+
75
+ // 메인 HUD 경로: 즉시 렌더 우선
76
+ const stdinPromise = readStdinJson();
77
+
78
+ const qosProfile = readJson(QOS_PATH, { providers: {} });
79
+ const accountsConfig = readJson(ACCOUNTS_CONFIG_PATH, { providers: {} });
80
+ const accountsState = readJson(ACCOUNTS_STATE_PATH, { providers: {} });
81
+ const claudeUsageSnapshot = readClaudeUsageSnapshot();
82
+ if (claudeUsageSnapshot.shouldRefresh) {
83
+ scheduleClaudeUsageRefresh();
84
+ }
85
+ const geminiAccountId = getProviderAccountId("gemini", accountsConfig, accountsState);
86
+ const codexSnapshot = readCodexRateLimitSnapshot();
87
+ const geminiSessionSnapshot = readGeminiSessionSnapshot();
88
+ const geminiAuthContext = buildGeminiAuthContext(geminiAccountId);
89
+ const geminiQuotaSnapshot = readGeminiQuotaSnapshot(geminiAccountId, geminiAuthContext);
90
+ if (codexSnapshot.shouldRefresh) {
91
+ scheduleCodexRateLimitRefresh();
92
+ }
93
+ if (geminiSessionSnapshot.shouldRefresh) {
94
+ scheduleGeminiSessionRefresh();
95
+ }
96
+ if (geminiQuotaSnapshot.shouldRefresh) {
97
+ scheduleGeminiQuotaRefresh(geminiAccountId);
98
+ }
99
+
100
+ // 실측 데이터 추출
101
+ const stdin = await stdinPromise;
102
+ const codexEmail = getCodexEmail();
103
+ const geminiEmail = getGeminiEmail();
104
+ const codexBuckets = codexSnapshot.buckets;
105
+ const geminiSession = geminiSessionSnapshot.session;
106
+ const geminiQuota = geminiQuotaSnapshot.quota;
107
+
108
+ // 누적 절약 데이터 읽기
109
+ const svSavings = readTokenSavings();
110
+ const svAccumulator = readSvAccumulator();
111
+ const totalCostSaved = svSavings?.totalSaved || svAccumulator?.totalCostSaved || 0;
112
+
113
+ // 세션/누적 토큰 → context 대비 절약 배수 (개별 provider sv%)
114
+ const ctxCapacity = stdin?.context_window?.context_window_size || 200000;
115
+ let codexSv = null;
116
+ if (svAccumulator?.codex?.tokens > 0) {
117
+ codexSv = svAccumulator.codex.tokens / ctxCapacity;
118
+ } else if (codexBuckets) {
119
+ const main = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
120
+ if (main?.tokens?.total_tokens) codexSv = main.tokens.total_tokens / ctxCapacity;
121
+ }
122
+ let geminiSv = null;
123
+ if (svAccumulator?.gemini?.tokens > 0) {
124
+ geminiSv = svAccumulator.gemini.tokens / ctxCapacity;
125
+ } else {
126
+ const geminiTokens = geminiSession?.total || null;
127
+ geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
128
+ }
129
+
130
+ // Gemini: 3풀 버킷 추출 (Pro/Flash/Lite — 각 풀 내 모델들은 쿼터 공유)
131
+ const geminiModel = geminiSession?.model || "gemini-3-flash-preview";
132
+ const geminiBuckets = geminiQuota?.buckets || [];
133
+ const geminiBucket = geminiBuckets.find((b) => b.modelId === geminiModel)
134
+ || geminiBuckets.find((b) => b.modelId === "gemini-3-flash-preview")
135
+ || null;
136
+ const geminiProBucket = geminiBuckets.find((b) => GEMINI_PRO_POOL.has(b.modelId)) || null;
137
+ const geminiFlashBucket = geminiBuckets.find((b) => GEMINI_FLASH_POOL.has(b.modelId)) || null;
138
+ const geminiLiteBucket = geminiBuckets.find((b) => b.modelId?.includes("flash-lite")) || null;
139
+
140
+ // 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
141
+ const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
142
+
143
+ // 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
144
+ const CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
145
+
146
+ // nano tier: 1줄 모드 (극소 또는 알림 배너 대응)
147
+ if (CURRENT_TIER === "nano") {
148
+ const microLine = getMicroLine(stdin, claudeUsageSnapshot.data, codexBuckets,
149
+ geminiSession, geminiBucket, combinedSvPct);
150
+ process.stdout.write(`\x1b[0m${microLine}\n`);
151
+ return;
152
+ }
153
+
154
+ const codexQuotaData = codexBuckets ? { type: "codex", buckets: codexBuckets } : null;
155
+ const geminiQuotaData = {
156
+ type: "gemini",
157
+ quotaBucket: geminiBucket,
158
+ pools: { pro: geminiProBucket, flash: geminiFlashBucket, lite: geminiLiteBucket },
159
+ session: geminiSession,
160
+ };
161
+
162
+ const rows = [
163
+ ...getClaudeRows(CURRENT_TIER, stdin, claudeUsageSnapshot.data, combinedSvPct),
164
+ getProviderRow(CURRENT_TIER, "codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
165
+ codexQuotaData, codexEmail, codexSv, null),
166
+ getProviderRow(CURRENT_TIER, "gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
167
+ geminiQuotaData, geminiEmail, geminiSv, null),
168
+ ];
169
+
170
+ // tfx-multi 활성 시 팀 상태 행 추가 (v2.2)
171
+ const teamRow = getTeamRow(CURRENT_TIER);
172
+ if (teamRow) rows.push(teamRow);
173
+
174
+ // 최근 벤치마크 diff 토큰 요약 추가
175
+ const latestDiff = readLatestBenchmarkDiff();
176
+ if (latestDiff) {
177
+ const summary = formatTokenSummary(latestDiff);
178
+ if (summary) {
179
+ rows.push({ prefix: `${dim("$")}:`, left: summary, right: "" });
180
+ }
181
+ }
182
+
183
+ // 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
184
+ const codexActive = codexBuckets != null;
185
+ const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null
186
+ || geminiProBucket != null || geminiFlashBucket != null;
187
+
188
+ let outputLines = renderAlignedRows(rows);
189
+
190
+ // 비활성 dim 래핑 (rows 순서: [claude, codex, gemini])
191
+ if (outputLines.length >= 3) {
192
+ if (!codexActive) outputLines[1] = `${DIM}${outputLines[1]}${RESET}`;
193
+ if (!geminiActive) outputLines[2] = `${DIM}${outputLines[2]}${RESET}`;
194
+ }
195
+
196
+ // 선행 개행: 알림 배너(노란 글씨)가 빈 첫 줄에 오도록 → HUD 내용 보호
197
+ const contextPercent = getContextPercent(stdin);
198
+ const leadingBreaks = contextPercent >= 85 ? "\n\n" : "\n";
199
+ // 줄별 RESET: Claude Code TUI 스타일 간섭 방지 (색상 밝기 버그 수정)
200
+ const resetedLines = outputLines.map(line => `\x1b[0m${line}`);
201
+ process.stdout.write(`${leadingBreaks}${resetedLines.join("\n")}\n`);
202
+ }
203
+
204
+ main().catch(() => {
205
+ process.stdout.write(`\x1b[0m${bold(claudeOrange("c"))}: ${dim("5h:")}${green("0%")} ${dim("(n/a)")} ${dim("1w:")}${green("0%")} ${dim("(n/a)")} ${dim("|")} ${dim("ctx:")}${green("0%")}\n`);
206
+ });