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