triflux 3.2.0-dev.1 → 3.2.0-dev.11

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