triflux 0.0.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.mcp.json +3 -0
- package/LICENSE +21 -0
- package/README.ko.md +276 -0
- package/README.md +276 -1
- package/bin/tfx-doctor.mjs +8 -0
- package/bin/tfx-setup.mjs +8 -0
- package/bin/triflux.mjs +707 -0
- package/hooks/hooks.json +17 -0
- package/hud/hud-qos-status.mjs +1380 -0
- package/package.json +46 -7
- package/scripts/cli-route.sh +937 -0
- package/scripts/mcp-check.mjs +88 -0
- package/scripts/setup.mjs +215 -0
- package/skills/auto-verify/SKILL.md +145 -0
- package/skills/manage-skills/SKILL.md +192 -0
- package/skills/tfx-auto/SKILL.md +658 -0
- package/skills/tfx-codex/SKILL.md +61 -0
- package/skills/tfx-gemini/SKILL.md +78 -0
- package/skills/tfx-setup/SKILL.md +97 -0
- package/skills/verify-implementation/SKILL.md +138 -0
|
@@ -0,0 +1,1380 @@
|
|
|
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 = "1.7";
|
|
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;214m";
|
|
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
|
+
function coloredBar(percent, width = 8) {
|
|
56
|
+
const safePercent = Math.min(100, Math.max(0, percent));
|
|
57
|
+
const filled = Math.round((safePercent / 100) * width);
|
|
58
|
+
const empty = width - filled;
|
|
59
|
+
let barColor;
|
|
60
|
+
if (safePercent >= 85) barColor = RED;
|
|
61
|
+
else if (safePercent >= 70) barColor = YELLOW;
|
|
62
|
+
else barColor = GREEN;
|
|
63
|
+
return `${barColor}${"█".repeat(filled)}${DIM}${"░".repeat(empty)}${RESET}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// 상수 / 경로
|
|
68
|
+
// ============================================================================
|
|
69
|
+
const QOS_PATH = join(homedir(), ".omc", "state", "cli_qos_profile.json");
|
|
70
|
+
const ACCOUNTS_CONFIG_PATH = join(homedir(), ".omc", "router", "accounts.json");
|
|
71
|
+
const ACCOUNTS_STATE_PATH = join(homedir(), ".omc", "state", "cli_accounts_state.json");
|
|
72
|
+
|
|
73
|
+
// Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
|
|
74
|
+
const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
75
|
+
const CLAUDE_USAGE_CACHE_PATH = join(homedir(), ".claude", "cache", "claude-usage-cache.json");
|
|
76
|
+
const OMC_PLUGIN_USAGE_CACHE_PATH = join(homedir(), ".claude", "plugins", "oh-my-claudecode", ".usage-cache.json");
|
|
77
|
+
const CLAUDE_USAGE_STALE_MS = 5 * 60 * 1000; // 5분 캐시 (OMC 플러그인과 API 충돌 방지)
|
|
78
|
+
const CLAUDE_USAGE_429_BACKOFF_MS = 10 * 60 * 1000; // 429 에러 시 10분 backoff
|
|
79
|
+
const CLAUDE_USAGE_ERROR_BACKOFF_MS = 3 * 60 * 1000; // 기타 에러 시 3분 backoff
|
|
80
|
+
const CLAUDE_API_TIMEOUT_MS = 10_000;
|
|
81
|
+
const DEFAULT_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
82
|
+
const CODEX_AUTH_PATH = join(homedir(), ".codex", "auth.json");
|
|
83
|
+
const CODEX_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "codex-rate-limits-cache.json");
|
|
84
|
+
const CODEX_QUOTA_STALE_MS = 15 * 1000; // 15초
|
|
85
|
+
|
|
86
|
+
// Gemini 쿼터 API 관련
|
|
87
|
+
const GEMINI_OAUTH_PATH = join(homedir(), ".gemini", "oauth_creds.json");
|
|
88
|
+
const GEMINI_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-quota-cache.json");
|
|
89
|
+
const GEMINI_PROJECT_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-project-id.json");
|
|
90
|
+
const GEMINI_SESSION_CACHE_PATH = join(homedir(), ".claude", "cache", "gemini-session-cache.json");
|
|
91
|
+
const GEMINI_RPM_TRACKER_PATH = join(homedir(), ".claude", "cache", "gemini-rpm-tracker.json");
|
|
92
|
+
const SV_ACCUMULATOR_PATH = join(homedir(), ".claude", "cache", "sv-accumulator.json");
|
|
93
|
+
// 이전 .omc/ 경로 fallback (기존 환경 호환)
|
|
94
|
+
const LEGACY_GEMINI_QUOTA_CACHE = join(homedir(), ".omc", "state", "gemini_quota_cache.json");
|
|
95
|
+
const LEGACY_GEMINI_PROJECT_CACHE = join(homedir(), ".omc", "state", "gemini_project_id.json");
|
|
96
|
+
const LEGACY_GEMINI_SESSION_CACHE = join(homedir(), ".omc", "state", "gemini_session_tokens_cache.json");
|
|
97
|
+
const LEGACY_GEMINI_RPM_TRACKER = join(homedir(), ".omc", "state", "gemini_rpm_tracker.json");
|
|
98
|
+
const LEGACY_SV_ACCUMULATOR = join(homedir(), ".omc", "state", "sv-accumulator.json");
|
|
99
|
+
// Gemini 모델별 RPM 한도 (실측 기반: Pro 25, Flash 300)
|
|
100
|
+
function getGeminiRpmLimit(model) {
|
|
101
|
+
if (model && model.includes("pro")) return 25;
|
|
102
|
+
return 300; // Flash 기본
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Gemini 모델 ID → HUD 표시 라벨
|
|
106
|
+
function getGeminiModelLabel(model) {
|
|
107
|
+
if (!model) return "";
|
|
108
|
+
if (model.includes("pro")) return "[Pro3.1]";
|
|
109
|
+
if (model.includes("flash")) return "[Flash3]";
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
const GEMINI_RPM_WINDOW_MS = 60 * 1000; // 60초 슬라이딩 윈도우
|
|
113
|
+
const GEMINI_QUOTA_STALE_MS = 5 * 60 * 1000; // 5분
|
|
114
|
+
const GEMINI_SESSION_STALE_MS = 15 * 1000; // 15초
|
|
115
|
+
const GEMINI_API_TIMEOUT_MS = 3000; // 3초
|
|
116
|
+
const ACCOUNT_LABEL_WIDTH = 10;
|
|
117
|
+
const PROVIDER_PREFIX_WIDTH = 2;
|
|
118
|
+
const PERCENT_CELL_WIDTH = 4;
|
|
119
|
+
const TIME_CELL_INNER_WIDTH = 6;
|
|
120
|
+
const CLAUDE_REFRESH_FLAG = "--refresh-claude-usage";
|
|
121
|
+
const CODEX_REFRESH_FLAG = "--refresh-codex-rate-limits";
|
|
122
|
+
const GEMINI_REFRESH_FLAG = "--refresh-gemini-quota";
|
|
123
|
+
const GEMINI_SESSION_REFRESH_FLAG = "--refresh-gemini-session";
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// 모바일/Termux 컴팩트 모드 감지
|
|
127
|
+
// ============================================================================
|
|
128
|
+
const HUD_CONFIG_PATH = join(homedir(), ".omc", "config", "hud.json");
|
|
129
|
+
const COMPACT_COLS_THRESHOLD = 80;
|
|
130
|
+
const MINIMAL_COLS_THRESHOLD = 60;
|
|
131
|
+
|
|
132
|
+
let _cachedColumns = 0;
|
|
133
|
+
function getTerminalColumns() {
|
|
134
|
+
if (_cachedColumns > 0) return _cachedColumns;
|
|
135
|
+
if (process.stdout.columns) { _cachedColumns = process.stdout.columns; return _cachedColumns; }
|
|
136
|
+
if (process.stderr.columns) { _cachedColumns = process.stderr.columns; return _cachedColumns; }
|
|
137
|
+
const envCols = Number(process.env.COLUMNS);
|
|
138
|
+
if (envCols > 0) { _cachedColumns = envCols; return _cachedColumns; }
|
|
139
|
+
try {
|
|
140
|
+
if (process.platform === "win32") {
|
|
141
|
+
const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
|
|
142
|
+
const m = raw.match(/Columns[^:]*:\s*(\d+)/i) || raw.match(/열[^:]*:\s*(\d+)/);
|
|
143
|
+
if (m) { _cachedColumns = Number(m[1]); return _cachedColumns; }
|
|
144
|
+
} else {
|
|
145
|
+
const raw = execSync("tput cols 2>/dev/null || stty size 2>/dev/null | awk '{print $2}'", {
|
|
146
|
+
timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
|
|
147
|
+
}).toString().trim();
|
|
148
|
+
if (raw && !isNaN(Number(raw))) { _cachedColumns = Number(raw); return _cachedColumns; }
|
|
149
|
+
}
|
|
150
|
+
} catch { /* 감지 실패 */ }
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function detectCompactMode() {
|
|
155
|
+
// 1. 명시적 CLI 플래그
|
|
156
|
+
if (process.argv.includes("--compact")) return true;
|
|
157
|
+
if (process.argv.includes("--no-compact")) return false;
|
|
158
|
+
// 2. 환경변수 오버라이드
|
|
159
|
+
if (process.env.TERMUX_VERSION) return true;
|
|
160
|
+
if (process.env.OMC_HUD_COMPACT === "1") return true;
|
|
161
|
+
if (process.env.OMC_HUD_COMPACT === "0") return false;
|
|
162
|
+
// 3. 설정 파일 (~/.omc/config/hud.json)
|
|
163
|
+
const hudConfig = readJson(HUD_CONFIG_PATH, null);
|
|
164
|
+
if (hudConfig?.compact === true || hudConfig?.compact === "always") return true;
|
|
165
|
+
if (hudConfig?.compact === false || hudConfig?.compact === "never") return false;
|
|
166
|
+
// 4. maxLines < 3이면 자동 컴팩트 (알림 배너 공존 대응)
|
|
167
|
+
if (Number(hudConfig?.lines) > 0 && Number(hudConfig?.lines) < 3) return true;
|
|
168
|
+
// 5. 터미널 폭 자동 감지 (TTY 있을 때만 유효)
|
|
169
|
+
const threshold = Number(hudConfig?.compactThreshold) || COMPACT_COLS_THRESHOLD;
|
|
170
|
+
const cols = getTerminalColumns();
|
|
171
|
+
if (cols > 0 && cols < threshold) return true;
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const COMPACT_MODE = detectCompactMode();
|
|
176
|
+
|
|
177
|
+
function detectMinimalMode() {
|
|
178
|
+
// 1. 명시적 CLI 플래그
|
|
179
|
+
if (process.argv.includes("--minimal")) return true;
|
|
180
|
+
// 2. 환경변수
|
|
181
|
+
if (process.env.OMC_HUD_MINIMAL === "1") return true;
|
|
182
|
+
if (process.env.OMC_HUD_MINIMAL === "0") return false;
|
|
183
|
+
// 3. 설정 파일 (~/.omc/config/hud.json)
|
|
184
|
+
const hudConfig = readJson(HUD_CONFIG_PATH, null);
|
|
185
|
+
if (hudConfig?.compact === "minimal") return true;
|
|
186
|
+
// 4. 터미널 폭 자동 감지
|
|
187
|
+
const cols = getTerminalColumns();
|
|
188
|
+
if (cols > 0 && cols < MINIMAL_COLS_THRESHOLD) return true;
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const MINIMAL_MODE = detectMinimalMode();
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// 유틸
|
|
196
|
+
// ============================================================================
|
|
197
|
+
async function readStdinJson() {
|
|
198
|
+
if (process.stdin.isTTY) return {};
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const timeout = setTimeout(() => {
|
|
201
|
+
process.stdin.destroy();
|
|
202
|
+
resolve({});
|
|
203
|
+
}, 200);
|
|
204
|
+
const chunks = [];
|
|
205
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
206
|
+
process.stdin.on("end", () => {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
const raw = chunks.join("").trim();
|
|
209
|
+
if (!raw) { resolve({}); return; }
|
|
210
|
+
try { resolve(JSON.parse(raw)); } catch { resolve({}); }
|
|
211
|
+
});
|
|
212
|
+
process.stdin.on("error", () => {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
resolve({});
|
|
215
|
+
});
|
|
216
|
+
process.stdin.resume();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readJson(filePath, fallback) {
|
|
221
|
+
if (!existsSync(filePath)) return fallback;
|
|
222
|
+
try { return JSON.parse(readFileSync(filePath, "utf-8")); } catch { return fallback; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function writeJsonSafe(filePath, data) {
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
228
|
+
writeFileSync(filePath, JSON.stringify(data));
|
|
229
|
+
} catch { /* 쓰기 실패 무시 */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// .omc/ → .claude/cache/ 마이그레이션: 새 경로 우선, 없으면 레거시 읽고 복사
|
|
233
|
+
function readJsonMigrate(newPath, legacyPath, fallback) {
|
|
234
|
+
const data = readJson(newPath, null);
|
|
235
|
+
if (data != null) return data;
|
|
236
|
+
const legacy = readJson(legacyPath, null);
|
|
237
|
+
if (legacy != null) { writeJsonSafe(newPath, legacy); return legacy; }
|
|
238
|
+
return fallback;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stripAnsi(text) {
|
|
242
|
+
return String(text).replace(/\x1b\[[0-9;]*m/g, "");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function padAnsiRight(text, width) {
|
|
246
|
+
const len = stripAnsi(text).length;
|
|
247
|
+
if (len >= width) return text;
|
|
248
|
+
return text + " ".repeat(width - len);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function padAnsiLeft(text, width) {
|
|
252
|
+
const len = stripAnsi(text).length;
|
|
253
|
+
if (len >= width) return text;
|
|
254
|
+
return " ".repeat(width - len) + text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function fitText(text, width) {
|
|
258
|
+
const t = String(text || "");
|
|
259
|
+
if (t.length <= width) return t;
|
|
260
|
+
if (width <= 1) return "…";
|
|
261
|
+
return `${t.slice(0, width - 1)}…`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function makeHash(text) {
|
|
265
|
+
return createHash("sha256").update(String(text || ""), "utf8").digest("hex").slice(0, 16);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getProviderAccountId(provider, accountsConfig, accountsState) {
|
|
269
|
+
const providerState = accountsState?.providers?.[provider] || {};
|
|
270
|
+
const selectedId = providerState.last_selected_id;
|
|
271
|
+
if (selectedId) return selectedId;
|
|
272
|
+
const providerConfig = accountsConfig?.providers?.[provider] || [];
|
|
273
|
+
return providerConfig[0]?.id || `${provider}-main`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderAlignedRows(rows) {
|
|
277
|
+
const rightRows = rows.filter((row) => stripAnsi(String(row.right || "")).trim().length > 0);
|
|
278
|
+
const rawLeftWidth = rightRows.reduce((max, row) => Math.max(max, stripAnsi(row.left).length), 0);
|
|
279
|
+
return rows.map((row) => {
|
|
280
|
+
const prefix = padAnsiRight(row.prefix, PROVIDER_PREFIX_WIDTH);
|
|
281
|
+
const hasRight = stripAnsi(String(row.right || "")).trim().length > 0;
|
|
282
|
+
if (!hasRight) {
|
|
283
|
+
return `${prefix} ${row.left}`;
|
|
284
|
+
}
|
|
285
|
+
// 자기 left 대비 패딩 상한: 최대 2칸까지만 패딩 (과도한 공백 방지)
|
|
286
|
+
const ownLen = stripAnsi(row.left).length;
|
|
287
|
+
const effectiveWidth = Math.min(rawLeftWidth, ownLen + 2);
|
|
288
|
+
const left = padAnsiRight(row.left, effectiveWidth);
|
|
289
|
+
return `${prefix} ${left} ${dim("|")} ${row.right}`;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function clampPercent(value) {
|
|
294
|
+
const numeric = Number(value);
|
|
295
|
+
if (!Number.isFinite(numeric)) return 0;
|
|
296
|
+
return Math.max(0, Math.min(100, Math.round(numeric)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatPercentCell(value) {
|
|
300
|
+
return `${clampPercent(value)}%`.padStart(PERCENT_CELL_WIDTH, " ");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function formatPlaceholderPercentCell() {
|
|
304
|
+
return "--%".padStart(PERCENT_CELL_WIDTH, " ");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function normalizeTimeToken(value) {
|
|
308
|
+
const text = String(value || "n/a");
|
|
309
|
+
const hourMinute = text.match(/^(\d+)h(\d+)m$/);
|
|
310
|
+
if (hourMinute) {
|
|
311
|
+
const hours = String(Number(hourMinute[1])).padStart(2, "0");
|
|
312
|
+
const minutes = String(Number(hourMinute[2])).padStart(2, "0");
|
|
313
|
+
return `${hours}h${minutes}m`;
|
|
314
|
+
}
|
|
315
|
+
const dayHour = text.match(/^(\d+)d(\d+)h$/);
|
|
316
|
+
if (dayHour) {
|
|
317
|
+
const days = String(Number(dayHour[1]));
|
|
318
|
+
const hours = String(Number(dayHour[2])).padStart(2, "0");
|
|
319
|
+
return `${days}d${hours}h`;
|
|
320
|
+
}
|
|
321
|
+
return text;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function formatTimeCell(value) {
|
|
325
|
+
const text = normalizeTimeToken(value);
|
|
326
|
+
return `(${text.padStart(TIME_CELL_INNER_WIDTH, " ")})`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 주간(d/h) 전용 — 최대 7d00h(5자)이므로 공백 불필요
|
|
330
|
+
function formatTimeCellDH(value) {
|
|
331
|
+
const text = normalizeTimeToken(value);
|
|
332
|
+
return `(${text})`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getCliArgValue(flag) {
|
|
336
|
+
const idx = process.argv.indexOf(flag);
|
|
337
|
+
if (idx < 0) return null;
|
|
338
|
+
return process.argv[idx + 1] || null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildGeminiAuthContext(accountId) {
|
|
342
|
+
const oauth = readJson(GEMINI_OAUTH_PATH, null);
|
|
343
|
+
const tokenSource = oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
|
|
344
|
+
const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
|
|
345
|
+
const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
|
|
346
|
+
return { oauth, tokenFingerprint, cacheKey };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function formatDuration(ms) {
|
|
350
|
+
if (!Number.isFinite(ms) || ms <= 0) return "n/a";
|
|
351
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
352
|
+
const days = Math.floor(totalMinutes / (60 * 24));
|
|
353
|
+
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
354
|
+
const minutes = totalMinutes % 60;
|
|
355
|
+
if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`;
|
|
356
|
+
if (hours > 0) return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
|
|
357
|
+
return `${minutes}m`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function formatTokenCount(n) {
|
|
361
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
362
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
|
363
|
+
return String(n);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ============================================================================
|
|
367
|
+
// Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
|
|
368
|
+
// ============================================================================
|
|
369
|
+
function readClaudeCredentials() {
|
|
370
|
+
const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
|
|
371
|
+
if (!data) return null;
|
|
372
|
+
const creds = data.claudeAiOauth || data;
|
|
373
|
+
if (!creds.accessToken) return null;
|
|
374
|
+
return {
|
|
375
|
+
accessToken: creds.accessToken,
|
|
376
|
+
refreshToken: creds.refreshToken,
|
|
377
|
+
expiresAt: creds.expiresAt,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function refreshClaudeAccessToken(refreshToken) {
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
const clientId = process.env.CLAUDE_CODE_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
|
|
384
|
+
const body = new URLSearchParams({
|
|
385
|
+
grant_type: "refresh_token",
|
|
386
|
+
refresh_token: refreshToken,
|
|
387
|
+
client_id: clientId,
|
|
388
|
+
}).toString();
|
|
389
|
+
const req = https.request({
|
|
390
|
+
hostname: "platform.claude.com",
|
|
391
|
+
path: "/v1/oauth/token",
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: {
|
|
394
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
395
|
+
"Content-Length": Buffer.byteLength(body),
|
|
396
|
+
},
|
|
397
|
+
timeout: CLAUDE_API_TIMEOUT_MS,
|
|
398
|
+
}, (res) => {
|
|
399
|
+
let data = "";
|
|
400
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
401
|
+
res.on("end", () => {
|
|
402
|
+
if (res.statusCode === 200) {
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(data);
|
|
405
|
+
if (parsed.access_token) {
|
|
406
|
+
resolve({
|
|
407
|
+
accessToken: parsed.access_token,
|
|
408
|
+
refreshToken: parsed.refresh_token || refreshToken,
|
|
409
|
+
expiresAt: parsed.expires_in
|
|
410
|
+
? Date.now() + parsed.expires_in * 1000
|
|
411
|
+
: parsed.expires_at,
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
} catch { /* parse 실패 */ }
|
|
416
|
+
}
|
|
417
|
+
resolve(null);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
req.on("error", () => resolve(null));
|
|
421
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
422
|
+
req.end(body);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function writeBackClaudeCredentials(creds) {
|
|
427
|
+
try {
|
|
428
|
+
const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
|
|
429
|
+
if (!data) return;
|
|
430
|
+
const target = data.claudeAiOauth || data;
|
|
431
|
+
target.accessToken = creds.accessToken;
|
|
432
|
+
if (creds.expiresAt != null) target.expiresAt = creds.expiresAt;
|
|
433
|
+
if (creds.refreshToken) target.refreshToken = creds.refreshToken;
|
|
434
|
+
writeFileSync(CLAUDE_CREDENTIALS_PATH, JSON.stringify(data, null, 2));
|
|
435
|
+
} catch { /* 쓰기 실패 무시 */ }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function fetchClaudeUsageFromApi(accessToken) {
|
|
439
|
+
return new Promise((resolve) => {
|
|
440
|
+
const req = https.request({
|
|
441
|
+
hostname: "api.anthropic.com",
|
|
442
|
+
path: "/api/oauth/usage",
|
|
443
|
+
method: "GET",
|
|
444
|
+
headers: {
|
|
445
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
446
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
447
|
+
"Content-Type": "application/json",
|
|
448
|
+
},
|
|
449
|
+
timeout: CLAUDE_API_TIMEOUT_MS,
|
|
450
|
+
}, (res) => {
|
|
451
|
+
let data = "";
|
|
452
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
453
|
+
res.on("end", () => {
|
|
454
|
+
if (res.statusCode === 200) {
|
|
455
|
+
try { resolve({ ok: true, data: JSON.parse(data) }); } catch { resolve({ ok: false, status: 0 }); }
|
|
456
|
+
} else {
|
|
457
|
+
resolve({ ok: false, status: res.statusCode });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
req.on("error", () => resolve({ ok: false, status: 0, error: "network" }));
|
|
462
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, status: 0, error: "timeout" }); });
|
|
463
|
+
req.end();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseClaudeUsageResponse(response) {
|
|
468
|
+
const fiveHour = response?.five_hour?.utilization;
|
|
469
|
+
const sevenDay = response?.seven_day?.utilization;
|
|
470
|
+
if (fiveHour == null && sevenDay == null) return null;
|
|
471
|
+
return {
|
|
472
|
+
fiveHourPercent: clampPercent(fiveHour ?? 0),
|
|
473
|
+
weeklyPercent: clampPercent(sevenDay ?? 0),
|
|
474
|
+
fiveHourResetsAt: response?.five_hour?.resets_at || null,
|
|
475
|
+
weeklyResetsAt: response?.seven_day?.resets_at || null,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
|
|
480
|
+
function stripStaleResets(data) {
|
|
481
|
+
if (!data) return data;
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
const copy = { ...data };
|
|
484
|
+
|
|
485
|
+
// 5시간 주기: 과거 reset → 5시간씩 전진하여 미래 시점 추정
|
|
486
|
+
if (copy.fiveHourResetsAt) {
|
|
487
|
+
let t = new Date(copy.fiveHourResetsAt).getTime();
|
|
488
|
+
if (t < now) {
|
|
489
|
+
const cycle = 5 * 60 * 60 * 1000;
|
|
490
|
+
const elapsed = now - t;
|
|
491
|
+
t += Math.ceil(elapsed / cycle) * cycle;
|
|
492
|
+
copy.fiveHourResetsAt = new Date(t).toISOString();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 7일 주기: 과거 reset → 7일씩 전진하여 미래 시점 추정
|
|
497
|
+
if (copy.weeklyResetsAt) {
|
|
498
|
+
let t = new Date(copy.weeklyResetsAt).getTime();
|
|
499
|
+
if (t < now) {
|
|
500
|
+
const cycle = 7 * 24 * 60 * 60 * 1000;
|
|
501
|
+
const elapsed = now - t;
|
|
502
|
+
t += Math.ceil(elapsed / cycle) * cycle;
|
|
503
|
+
copy.weeklyResetsAt = new Date(t).toISOString();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return copy;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function readClaudeUsageSnapshot() {
|
|
511
|
+
const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
|
|
512
|
+
const ts = Number(cache?.timestamp);
|
|
513
|
+
const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
|
|
514
|
+
|
|
515
|
+
// 1차: 자체 캐시에 유효 데이터가 있는 경우
|
|
516
|
+
if (cache?.data) {
|
|
517
|
+
const isFresh = ageMs < CLAUDE_USAGE_STALE_MS;
|
|
518
|
+
return { data: cache.data, shouldRefresh: !isFresh };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 2차: 에러 backoff — 최근 에러 시 재시도 억제 (무한 spawn 방지)
|
|
522
|
+
if (cache?.error && Number.isFinite(ts)) {
|
|
523
|
+
const backoffMs = cache.errorType === "rate_limit"
|
|
524
|
+
? CLAUDE_USAGE_429_BACKOFF_MS
|
|
525
|
+
: CLAUDE_USAGE_ERROR_BACKOFF_MS;
|
|
526
|
+
if (ageMs < backoffMs) {
|
|
527
|
+
const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
|
|
528
|
+
// OMC 캐시가 에러 이후 갱신되었으면 → 에러 캐시 덮어쓰고 그 데이터 사용
|
|
529
|
+
if (omcCache?.data?.fiveHourPercent != null && omcCache.timestamp > ts) {
|
|
530
|
+
writeClaudeUsageCache(omcCache.data);
|
|
531
|
+
return { data: omcCache.data, shouldRefresh: false };
|
|
532
|
+
}
|
|
533
|
+
// stale OMC fallback 또는 기본 0%
|
|
534
|
+
const staleData = omcCache?.data?.fiveHourPercent != null ? stripStaleResets(omcCache.data) : null;
|
|
535
|
+
const fallback = staleData || { fiveHourPercent: 0, weeklyPercent: 0, fiveHourResetsAt: null, weeklyResetsAt: null };
|
|
536
|
+
return { data: fallback, shouldRefresh: false };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 3차: OMC 플러그인 캐시 (같은 API 데이터, 중복 호출 방지)
|
|
541
|
+
const OMC_CACHE_MAX_AGE_MS = 30 * 60 * 1000;
|
|
542
|
+
const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
|
|
543
|
+
if (omcCache?.data?.fiveHourPercent != null) {
|
|
544
|
+
const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Number.MAX_SAFE_INTEGER;
|
|
545
|
+
if (omcAge < OMC_CACHE_MAX_AGE_MS) {
|
|
546
|
+
writeClaudeUsageCache(omcCache.data);
|
|
547
|
+
return { data: omcCache.data, shouldRefresh: omcAge > CLAUDE_USAGE_STALE_MS };
|
|
548
|
+
}
|
|
549
|
+
// stale이어도 data: null보다는 오래된 데이터를 fallback으로 표시
|
|
550
|
+
return { data: stripStaleResets(omcCache.data), shouldRefresh: true };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 캐시/fallback 모두 없음: 기본 0% 표시 + 리프레시 시도 (--% 방지)
|
|
554
|
+
return { data: { fiveHourPercent: 0, weeklyPercent: 0, fiveHourResetsAt: null, weeklyResetsAt: null }, shouldRefresh: true };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function writeClaudeUsageCache(data, errorInfo = null) {
|
|
558
|
+
writeJsonSafe(CLAUDE_USAGE_CACHE_PATH, {
|
|
559
|
+
timestamp: Date.now(),
|
|
560
|
+
data,
|
|
561
|
+
error: !!errorInfo,
|
|
562
|
+
errorType: errorInfo?.type || null, // "rate_limit" | "auth" | "network" | "unknown"
|
|
563
|
+
errorStatus: errorInfo?.status || null, // HTTP 상태 코드
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function fetchClaudeUsage(forceRefresh = false) {
|
|
568
|
+
const existingSnapshot = readClaudeUsageSnapshot();
|
|
569
|
+
if (!forceRefresh && !existingSnapshot.shouldRefresh && existingSnapshot.data) {
|
|
570
|
+
return existingSnapshot.data;
|
|
571
|
+
}
|
|
572
|
+
let creds = readClaudeCredentials();
|
|
573
|
+
if (!creds) {
|
|
574
|
+
writeClaudeUsageCache(null, { type: "auth", status: 0 });
|
|
575
|
+
return existingSnapshot.data || null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 토큰 만료 시 리프레시
|
|
579
|
+
if (creds.expiresAt && creds.expiresAt <= Date.now() && creds.refreshToken) {
|
|
580
|
+
const refreshed = await refreshClaudeAccessToken(creds.refreshToken);
|
|
581
|
+
if (refreshed) {
|
|
582
|
+
creds = { ...creds, ...refreshed };
|
|
583
|
+
writeBackClaudeCredentials(creds);
|
|
584
|
+
} else {
|
|
585
|
+
writeClaudeUsageCache(null, { type: "auth", status: 0 });
|
|
586
|
+
return existingSnapshot.data || null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const result = await fetchClaudeUsageFromApi(creds.accessToken);
|
|
591
|
+
if (!result.ok) {
|
|
592
|
+
// 에러 유형별 분류하여 backoff 차등 적용
|
|
593
|
+
const errorType = result.status === 429 ? "rate_limit"
|
|
594
|
+
: result.status === 401 || result.status === 403 ? "auth"
|
|
595
|
+
: result.error === "timeout" || result.error === "network" ? "network"
|
|
596
|
+
: "unknown";
|
|
597
|
+
writeClaudeUsageCache(null, { type: errorType, status: result.status });
|
|
598
|
+
return existingSnapshot.data || null;
|
|
599
|
+
}
|
|
600
|
+
const usage = parseClaudeUsageResponse(result.data);
|
|
601
|
+
writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 });
|
|
602
|
+
return usage;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function scheduleClaudeUsageRefresh() {
|
|
606
|
+
const scriptPath = process.argv[1];
|
|
607
|
+
if (!scriptPath) return;
|
|
608
|
+
|
|
609
|
+
// OMC 플러그인이 이미 fresh 데이터를 가지고 있으면 HUD 리프레시 불필요 (429 방지)
|
|
610
|
+
try {
|
|
611
|
+
const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
|
|
612
|
+
if (omcCache?.data?.fiveHourPercent != null) {
|
|
613
|
+
const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Infinity;
|
|
614
|
+
if (omcAge < CLAUDE_USAGE_STALE_MS) {
|
|
615
|
+
writeClaudeUsageCache(omcCache.data); // HUD 캐시에 복사만
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} catch { /* 무시 */ }
|
|
620
|
+
|
|
621
|
+
// 스폰 락: 30초 내 이미 스폰했으면 중복 방지 (첫 설치 시 429 방지)
|
|
622
|
+
const lockPath = join(homedir(), ".claude", "cache", ".claude-refresh-lock");
|
|
623
|
+
try {
|
|
624
|
+
if (existsSync(lockPath)) {
|
|
625
|
+
const lockAge = Date.now() - readJson(lockPath, {}).t;
|
|
626
|
+
if (lockAge < 30000) return; // 30초 이내 스폰 이력 → 건너뜀
|
|
627
|
+
}
|
|
628
|
+
writeJsonSafe(lockPath, { t: Date.now() });
|
|
629
|
+
} catch { /* 락 실패 무시 — 스폰 진행 */ }
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const child = spawn(process.execPath, [scriptPath, CLAUDE_REFRESH_FLAG], {
|
|
633
|
+
detached: true,
|
|
634
|
+
stdio: "ignore",
|
|
635
|
+
windowsHide: true,
|
|
636
|
+
});
|
|
637
|
+
child.unref();
|
|
638
|
+
} catch { /* 백그라운드 실행 실패 무시 */ }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function getContextPercent(stdin) {
|
|
642
|
+
const nativePercent = stdin?.context_window?.used_percentage;
|
|
643
|
+
if (typeof nativePercent === "number" && Number.isFinite(nativePercent)) return clampPercent(nativePercent);
|
|
644
|
+
const usage = stdin?.context_window?.current_usage || {};
|
|
645
|
+
const totalTokens = Number(usage.input_tokens || 0)
|
|
646
|
+
+ Number(usage.cache_creation_input_tokens || 0)
|
|
647
|
+
+ Number(usage.cache_read_input_tokens || 0);
|
|
648
|
+
const capacity = Number(stdin?.context_window?.context_window_size || 0);
|
|
649
|
+
if (!capacity || capacity <= 0) return 0;
|
|
650
|
+
return clampPercent((totalTokens / capacity) * 100);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function formatResetRemaining(isoOrUnix) {
|
|
654
|
+
if (!isoOrUnix) return "";
|
|
655
|
+
const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
|
|
656
|
+
if (isNaN(d.getTime())) return "";
|
|
657
|
+
const diffMs = d.getTime() - Date.now();
|
|
658
|
+
if (diffMs <= 0) return "00h00m";
|
|
659
|
+
const totalMinutes = Math.floor(diffMs / 60000);
|
|
660
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
661
|
+
const minutes = totalMinutes % 60;
|
|
662
|
+
return `${String(totalHours).padStart(2, "0")}h${String(minutes).padStart(2, "0")}m`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isResetPast(isoOrUnix) {
|
|
666
|
+
if (!isoOrUnix) return false;
|
|
667
|
+
const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
|
|
668
|
+
return !isNaN(d.getTime()) && d.getTime() <= Date.now();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function formatResetRemainingDayHour(isoOrUnix) {
|
|
672
|
+
if (!isoOrUnix) return "";
|
|
673
|
+
const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
|
|
674
|
+
if (isNaN(d.getTime())) return "";
|
|
675
|
+
const diffMs = d.getTime() - Date.now();
|
|
676
|
+
if (diffMs <= 0) return "0d00h";
|
|
677
|
+
const totalMinutes = Math.floor(diffMs / 60000);
|
|
678
|
+
const days = Math.floor(totalMinutes / (60 * 24));
|
|
679
|
+
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
680
|
+
return `${days}d${String(hours).padStart(2, "0")}h`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function calcCooldownLeftSeconds(isoDatetime) {
|
|
684
|
+
if (!isoDatetime) return 0;
|
|
685
|
+
const cooldownMs = new Date(isoDatetime).getTime() - Date.now();
|
|
686
|
+
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return 0;
|
|
687
|
+
return Math.ceil(cooldownMs / 1000);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ============================================================================
|
|
691
|
+
// HTTPS POST (타임아웃 포함)
|
|
692
|
+
// ============================================================================
|
|
693
|
+
function httpsPost(url, body, accessToken) {
|
|
694
|
+
return new Promise((resolve) => {
|
|
695
|
+
const urlObj = new URL(url);
|
|
696
|
+
const data = JSON.stringify(body);
|
|
697
|
+
const req = https.request({
|
|
698
|
+
hostname: urlObj.hostname,
|
|
699
|
+
path: urlObj.pathname + urlObj.search,
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: {
|
|
702
|
+
"Content-Type": "application/json",
|
|
703
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
704
|
+
"Content-Length": Buffer.byteLength(data),
|
|
705
|
+
},
|
|
706
|
+
timeout: GEMINI_API_TIMEOUT_MS,
|
|
707
|
+
}, (res) => {
|
|
708
|
+
const chunks = [];
|
|
709
|
+
res.on("data", (c) => chunks.push(c));
|
|
710
|
+
res.on("end", () => {
|
|
711
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
712
|
+
catch { resolve(null); }
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
req.on("error", () => resolve(null));
|
|
716
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
717
|
+
req.write(data);
|
|
718
|
+
req.end();
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ============================================================================
|
|
723
|
+
// Codex JWT에서 이메일 추출
|
|
724
|
+
// ============================================================================
|
|
725
|
+
function getCodexEmail() {
|
|
726
|
+
try {
|
|
727
|
+
const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
|
|
728
|
+
const idToken = auth?.tokens?.id_token;
|
|
729
|
+
if (!idToken) return null;
|
|
730
|
+
const parts = idToken.split(".");
|
|
731
|
+
if (parts.length < 2) return null;
|
|
732
|
+
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
733
|
+
while (payload.length % 4) payload += "=";
|
|
734
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
|
|
735
|
+
return decoded.email || null;
|
|
736
|
+
} catch { return null; }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ============================================================================
|
|
740
|
+
// Gemini JWT에서 이메일 추출
|
|
741
|
+
// ============================================================================
|
|
742
|
+
function getGeminiEmail() {
|
|
743
|
+
try {
|
|
744
|
+
const oauth = readJson(GEMINI_OAUTH_PATH, null);
|
|
745
|
+
const idToken = oauth?.id_token;
|
|
746
|
+
if (!idToken) return null;
|
|
747
|
+
const parts = idToken.split(".");
|
|
748
|
+
if (parts.length < 2) return null;
|
|
749
|
+
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
750
|
+
while (payload.length % 4) payload += "=";
|
|
751
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
|
|
752
|
+
return decoded.email || null;
|
|
753
|
+
} catch { return null; }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ============================================================================
|
|
757
|
+
// Codex 세션 JSONL에서 실제 rate limits 추출
|
|
758
|
+
// ============================================================================
|
|
759
|
+
function getCodexRateLimits() {
|
|
760
|
+
const now = new Date();
|
|
761
|
+
for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
|
|
762
|
+
const d = new Date(now.getTime() - dayOffset * 86_400_000);
|
|
763
|
+
const sessDir = join(
|
|
764
|
+
homedir(), ".codex", "sessions",
|
|
765
|
+
String(d.getFullYear()),
|
|
766
|
+
String(d.getMonth() + 1).padStart(2, "0"),
|
|
767
|
+
String(d.getDate()).padStart(2, "0"),
|
|
768
|
+
);
|
|
769
|
+
if (!existsSync(sessDir)) continue;
|
|
770
|
+
let files;
|
|
771
|
+
try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
|
|
772
|
+
catch { continue; }
|
|
773
|
+
for (const file of files) {
|
|
774
|
+
try {
|
|
775
|
+
const content = readFileSync(join(sessDir, file), "utf-8");
|
|
776
|
+
const lines = content.trim().split("\n").reverse();
|
|
777
|
+
const buckets = {};
|
|
778
|
+
for (const line of lines) {
|
|
779
|
+
try {
|
|
780
|
+
const evt = JSON.parse(line);
|
|
781
|
+
const rl = evt?.payload?.rate_limits;
|
|
782
|
+
if (rl?.limit_id && !buckets[rl.limit_id]) {
|
|
783
|
+
buckets[rl.limit_id] = {
|
|
784
|
+
limitId: rl.limit_id, limitName: rl.limit_name,
|
|
785
|
+
primary: rl.primary, secondary: rl.secondary,
|
|
786
|
+
credits: rl.credits,
|
|
787
|
+
tokens: evt.payload?.info?.total_token_usage,
|
|
788
|
+
contextWindow: evt.payload?.info?.model_context_window,
|
|
789
|
+
timestamp: evt.timestamp,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
} catch { /* 라인 파싱 실패 무시 */ }
|
|
793
|
+
if (Object.keys(buckets).length >= 2) break;
|
|
794
|
+
}
|
|
795
|
+
if (Object.keys(buckets).length > 0) return buckets;
|
|
796
|
+
} catch { /* 파일 읽기 실패 무시 */ }
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ============================================================================
|
|
803
|
+
// Gemini 쿼터 API 호출 (5분 캐시)
|
|
804
|
+
// ============================================================================
|
|
805
|
+
async function fetchGeminiQuota(accountId, options = {}) {
|
|
806
|
+
const authContext = options.authContext || buildGeminiAuthContext(accountId);
|
|
807
|
+
const { oauth, tokenFingerprint, cacheKey } = authContext;
|
|
808
|
+
const forceRefresh = options.forceRefresh === true;
|
|
809
|
+
|
|
810
|
+
// 1. 캐시 확인 (계정/토큰별)
|
|
811
|
+
const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
|
|
812
|
+
if (!forceRefresh
|
|
813
|
+
&& cache?.cacheKey === cacheKey
|
|
814
|
+
&& cache?.timestamp
|
|
815
|
+
&& (Date.now() - cache.timestamp < GEMINI_QUOTA_STALE_MS)) {
|
|
816
|
+
return cache;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!oauth?.access_token) return cache;
|
|
820
|
+
if (oauth.expiry_date && oauth.expiry_date < Date.now()) return cache; // 만료 시 stale 캐시
|
|
821
|
+
|
|
822
|
+
// 3. projectId (캐시 or API)
|
|
823
|
+
const fetchProjectId = async () => {
|
|
824
|
+
const loadRes = await httpsPost(
|
|
825
|
+
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
|
826
|
+
{ metadata: { pluginType: "GEMINI" } },
|
|
827
|
+
oauth.access_token,
|
|
828
|
+
);
|
|
829
|
+
const id = loadRes?.cloudaicompanionProject;
|
|
830
|
+
if (id) writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, { cacheKey, projectId: id, timestamp: Date.now() });
|
|
831
|
+
return id || null;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const projCache = readJsonMigrate(GEMINI_PROJECT_CACHE_PATH, LEGACY_GEMINI_PROJECT_CACHE, null);
|
|
835
|
+
let projectId = projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
|
|
836
|
+
if (!projectId) projectId = await fetchProjectId();
|
|
837
|
+
if (!projectId) return cache;
|
|
838
|
+
|
|
839
|
+
// 4. retrieveUserQuota 호출
|
|
840
|
+
let quotaRes = await httpsPost(
|
|
841
|
+
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
842
|
+
{ project: projectId },
|
|
843
|
+
oauth.access_token,
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// projectId 캐시가 만료/변경된 경우 1회 재시도
|
|
847
|
+
if (!quotaRes?.buckets && projCache?.projectId) {
|
|
848
|
+
projectId = await fetchProjectId();
|
|
849
|
+
if (!projectId) return cache;
|
|
850
|
+
quotaRes = await httpsPost(
|
|
851
|
+
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
|
852
|
+
{ project: projectId },
|
|
853
|
+
oauth.access_token,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (!quotaRes?.buckets) return cache;
|
|
858
|
+
|
|
859
|
+
// 5. 캐시 저장
|
|
860
|
+
const result = {
|
|
861
|
+
timestamp: Date.now(),
|
|
862
|
+
cacheKey,
|
|
863
|
+
accountId: accountId || "gemini-main",
|
|
864
|
+
tokenFingerprint,
|
|
865
|
+
buckets: quotaRes.buckets,
|
|
866
|
+
};
|
|
867
|
+
writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, result);
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Gemini RPM 트래커에서 최근 60초 내 요청 수를 읽는다.
|
|
873
|
+
* @returns {{ count: number, percent: number, remainingSec: number }}
|
|
874
|
+
*/
|
|
875
|
+
function readGeminiRpm(model) {
|
|
876
|
+
try {
|
|
877
|
+
// 새 경로 → 레거시 경로 fallback
|
|
878
|
+
const rpmPath = existsSync(GEMINI_RPM_TRACKER_PATH) ? GEMINI_RPM_TRACKER_PATH
|
|
879
|
+
: existsSync(LEGACY_GEMINI_RPM_TRACKER) ? LEGACY_GEMINI_RPM_TRACKER : null;
|
|
880
|
+
if (!rpmPath) return { count: 0, percent: 0, remainingSec: 60 };
|
|
881
|
+
const raw = readFileSync(rpmPath, "utf-8");
|
|
882
|
+
const parsed = JSON.parse(raw);
|
|
883
|
+
const timestamps = Array.isArray(parsed.timestamps) ? parsed.timestamps : [];
|
|
884
|
+
const now = Date.now();
|
|
885
|
+
const recent = timestamps.filter((t) => now - t < GEMINI_RPM_WINDOW_MS);
|
|
886
|
+
const count = recent.length;
|
|
887
|
+
const rpmLimit = getGeminiRpmLimit(model);
|
|
888
|
+
const percent = clampPercent(Math.round((count / rpmLimit) * 100));
|
|
889
|
+
// 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초
|
|
890
|
+
// 가장 오래된 엔트리가 윈도우에서 빠지기까지 남은 초 (0건이면 0s)
|
|
891
|
+
// 5초 단위 반올림으로 HUD 깜빡임 감소
|
|
892
|
+
const rawRemainingSec = recent.length > 0
|
|
893
|
+
? Math.max(0, Math.ceil((GEMINI_RPM_WINDOW_MS - (now - Math.min(...recent))) / 1000))
|
|
894
|
+
: 0;
|
|
895
|
+
const remainingSec = Math.ceil(rawRemainingSec / 5) * 5;
|
|
896
|
+
return { count, percent, remainingSec };
|
|
897
|
+
} catch {
|
|
898
|
+
return { count: 0, percent: 0, remainingSec: 60 };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function readGeminiQuotaSnapshot(accountId, authContext) {
|
|
903
|
+
const cache = readJsonMigrate(GEMINI_QUOTA_CACHE_PATH, LEGACY_GEMINI_QUOTA_CACHE, null);
|
|
904
|
+
if (!cache?.buckets) {
|
|
905
|
+
return { quota: null, shouldRefresh: true };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const cacheKey = authContext.cacheKey;
|
|
909
|
+
const isLegacyCache = !cache.cacheKey;
|
|
910
|
+
const keyMatched = cache.cacheKey === cacheKey;
|
|
911
|
+
const cacheTs = Number(cache.timestamp);
|
|
912
|
+
const ageMs = Number.isFinite(cacheTs) ? Date.now() - cacheTs : Number.MAX_SAFE_INTEGER;
|
|
913
|
+
const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
|
|
914
|
+
|
|
915
|
+
if (keyMatched) {
|
|
916
|
+
return { quota: cache, shouldRefresh: !isFresh };
|
|
917
|
+
}
|
|
918
|
+
if (isLegacyCache) {
|
|
919
|
+
return { quota: cache, shouldRefresh: true };
|
|
920
|
+
}
|
|
921
|
+
return { quota: null, shouldRefresh: true };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function scheduleGeminiQuotaRefresh(accountId) {
|
|
925
|
+
const scriptPath = process.argv[1];
|
|
926
|
+
if (!scriptPath) return;
|
|
927
|
+
try {
|
|
928
|
+
const child = spawn(
|
|
929
|
+
process.execPath,
|
|
930
|
+
[scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
|
|
931
|
+
{
|
|
932
|
+
detached: true,
|
|
933
|
+
stdio: "ignore",
|
|
934
|
+
windowsHide: true,
|
|
935
|
+
},
|
|
936
|
+
);
|
|
937
|
+
child.unref();
|
|
938
|
+
} catch { /* 백그라운드 실행 실패 무시 */ }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function readCodexRateLimitSnapshot() {
|
|
942
|
+
const cache = readJson(CODEX_QUOTA_CACHE_PATH, null);
|
|
943
|
+
if (!cache?.buckets) {
|
|
944
|
+
return { buckets: null, shouldRefresh: true };
|
|
945
|
+
}
|
|
946
|
+
const ts = Number(cache.timestamp);
|
|
947
|
+
const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
|
|
948
|
+
const isFresh = ageMs < CODEX_QUOTA_STALE_MS;
|
|
949
|
+
return { buckets: cache.buckets, shouldRefresh: !isFresh };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function refreshCodexRateLimitsCache() {
|
|
953
|
+
const buckets = getCodexRateLimits();
|
|
954
|
+
if (!buckets) return null;
|
|
955
|
+
writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
|
|
956
|
+
return buckets;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function scheduleCodexRateLimitRefresh() {
|
|
960
|
+
const scriptPath = process.argv[1];
|
|
961
|
+
if (!scriptPath) return;
|
|
962
|
+
try {
|
|
963
|
+
const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
|
|
964
|
+
detached: true,
|
|
965
|
+
stdio: "ignore",
|
|
966
|
+
windowsHide: true,
|
|
967
|
+
});
|
|
968
|
+
child.unref();
|
|
969
|
+
} catch { /* 백그라운드 실행 실패 무시 */ }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function readGeminiSessionSnapshot() {
|
|
973
|
+
const cache = readJsonMigrate(GEMINI_SESSION_CACHE_PATH, LEGACY_GEMINI_SESSION_CACHE, null);
|
|
974
|
+
if (!cache?.session) {
|
|
975
|
+
return { session: null, shouldRefresh: true };
|
|
976
|
+
}
|
|
977
|
+
const ts = Number(cache.timestamp);
|
|
978
|
+
const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
|
|
979
|
+
const isFresh = ageMs < GEMINI_SESSION_STALE_MS;
|
|
980
|
+
return { session: cache.session, shouldRefresh: !isFresh };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function refreshGeminiSessionCache() {
|
|
984
|
+
const session = scanGeminiSessionTokens();
|
|
985
|
+
if (!session) return null;
|
|
986
|
+
writeJsonSafe(GEMINI_SESSION_CACHE_PATH, { timestamp: Date.now(), session });
|
|
987
|
+
return session;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function scheduleGeminiSessionRefresh() {
|
|
991
|
+
const scriptPath = process.argv[1];
|
|
992
|
+
if (!scriptPath) return;
|
|
993
|
+
try {
|
|
994
|
+
const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
|
|
995
|
+
detached: true,
|
|
996
|
+
stdio: "ignore",
|
|
997
|
+
windowsHide: true,
|
|
998
|
+
});
|
|
999
|
+
child.unref();
|
|
1000
|
+
} catch { /* 백그라운드 실행 실패 무시 */ }
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ============================================================================
|
|
1004
|
+
// Gemini 세션 JSON에서 토큰 사용량 추출
|
|
1005
|
+
// ============================================================================
|
|
1006
|
+
function scanGeminiSessionTokens() {
|
|
1007
|
+
const tmpDir = join(homedir(), ".gemini", "tmp");
|
|
1008
|
+
if (!existsSync(tmpDir)) return null;
|
|
1009
|
+
let best = null;
|
|
1010
|
+
let bestTime = 0;
|
|
1011
|
+
try {
|
|
1012
|
+
const dirs = readdirSync(tmpDir).filter((d) => existsSync(join(tmpDir, d, "chats")));
|
|
1013
|
+
for (const dir of dirs) {
|
|
1014
|
+
const chatsDir = join(tmpDir, dir, "chats");
|
|
1015
|
+
let files;
|
|
1016
|
+
try { files = readdirSync(chatsDir).filter((f) => f.endsWith(".json")); } catch { continue; }
|
|
1017
|
+
for (const file of files) {
|
|
1018
|
+
try {
|
|
1019
|
+
const data = JSON.parse(readFileSync(join(chatsDir, file), "utf-8"));
|
|
1020
|
+
const updatedAt = new Date(data.lastUpdated || 0).getTime();
|
|
1021
|
+
if (updatedAt <= bestTime) continue;
|
|
1022
|
+
let input = 0, output = 0;
|
|
1023
|
+
let model = "unknown";
|
|
1024
|
+
for (const msg of data.messages || []) {
|
|
1025
|
+
if (msg.tokens) { input += msg.tokens.input || 0; output += msg.tokens.output || 0; }
|
|
1026
|
+
if (msg.model) model = msg.model;
|
|
1027
|
+
}
|
|
1028
|
+
bestTime = updatedAt;
|
|
1029
|
+
best = { input, output, total: input + output, model, lastUpdated: data.lastUpdated };
|
|
1030
|
+
} catch { /* 무시 */ }
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
} catch { /* 무시 */ }
|
|
1034
|
+
return best;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ============================================================================
|
|
1038
|
+
// 라인 렌더러
|
|
1039
|
+
// ============================================================================
|
|
1040
|
+
// 토큰 절약액 누적치 읽기 (tfx-auto token tracker)
|
|
1041
|
+
function readTokenSavings() {
|
|
1042
|
+
const savingsPath = join(homedir(), ".omc", "state", "tfx-auto-tokens", "savings-total.json");
|
|
1043
|
+
const data = readJson(savingsPath, null);
|
|
1044
|
+
if (!data || data.totalSaved === 0) return null;
|
|
1045
|
+
return data;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// sv-accumulator.json에서 누적 토큰/비용 읽기
|
|
1049
|
+
function readSvAccumulator() {
|
|
1050
|
+
return readJsonMigrate(SV_ACCUMULATOR_PATH, LEGACY_SV_ACCUMULATOR, null);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function formatSavings(dollars) {
|
|
1054
|
+
if (dollars >= 100) return `$${Math.round(dollars)}`;
|
|
1055
|
+
if (dollars >= 10) return `$${dollars.toFixed(1)}`;
|
|
1056
|
+
return `$${dollars.toFixed(2)}`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
|
|
1060
|
+
const contextPercent = getContextPercent(stdin);
|
|
1061
|
+
const prefix = `${bold(claudeOrange("c"))}:`;
|
|
1062
|
+
|
|
1063
|
+
// 절약 달러 누적 (Codex+Gemini 합산)
|
|
1064
|
+
const svText = totalSvDollar > 0 ? formatSavings(totalSvDollar) : "$0";
|
|
1065
|
+
const svSuffix = `${dim("sv:")}${cyan(svText.padStart(6))}`;
|
|
1066
|
+
|
|
1067
|
+
// API 실측 데이터 사용 (없으면 플레이스홀더)
|
|
1068
|
+
const fiveHourPercent = claudeUsage?.fiveHourPercent ?? 0;
|
|
1069
|
+
const weeklyPercent = claudeUsage?.weeklyPercent ?? 0;
|
|
1070
|
+
const fiveHourReset = claudeUsage?.fiveHourResetsAt
|
|
1071
|
+
? formatResetRemaining(claudeUsage.fiveHourResetsAt)
|
|
1072
|
+
: (claudeUsage ? "n/a" : "--h--m");
|
|
1073
|
+
const weeklyReset = claudeUsage?.weeklyResetsAt
|
|
1074
|
+
? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt)
|
|
1075
|
+
: (claudeUsage ? "n/a" : "--d--h");
|
|
1076
|
+
|
|
1077
|
+
const hasData = claudeUsage != null;
|
|
1078
|
+
|
|
1079
|
+
if (MINIMAL_MODE) {
|
|
1080
|
+
const cols = getTerminalColumns() || 80;
|
|
1081
|
+
if (!hasData) {
|
|
1082
|
+
const quotaSection = cols < 40
|
|
1083
|
+
? `${dim("--%/--%")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`
|
|
1084
|
+
: `${dim("5h --% 1w --% sv:-- ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1085
|
+
return [{ prefix, left: quotaSection, right: "" }];
|
|
1086
|
+
}
|
|
1087
|
+
if (cols < 40) {
|
|
1088
|
+
// 초소형: c: 12%/8% ctx:45%
|
|
1089
|
+
const quotaSection = `${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)}${dim("/")}` +
|
|
1090
|
+
`${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
|
|
1091
|
+
`${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1092
|
+
return [{ prefix, left: quotaSection, right: "" }];
|
|
1093
|
+
}
|
|
1094
|
+
// minimal: c: 5h 12% 1w 8% sv:$16.57 ctx:45%
|
|
1095
|
+
const svCompact = `${dim("sv:")}${cyan(svText)}`;
|
|
1096
|
+
const quotaSection = `${dim("5h")} ${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)} ` +
|
|
1097
|
+
`${dim("1w")} ${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
|
|
1098
|
+
`${svCompact} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1099
|
+
return [{ prefix, left: quotaSection, right: "" }];
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (COMPACT_MODE) {
|
|
1103
|
+
// 데이터 없을 때: 전체 dim 처리 (green 0% 대신)
|
|
1104
|
+
if (!hasData) {
|
|
1105
|
+
const quotaSection = `${dim("5h: --% 1w: --% ")}` +
|
|
1106
|
+
`${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1107
|
+
return [{ prefix, left: quotaSection, right: "" }];
|
|
1108
|
+
}
|
|
1109
|
+
const quotaSection = `${dim("5h:")}${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)} ` +
|
|
1110
|
+
`${dim("1w:")}${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
|
|
1111
|
+
`${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1112
|
+
return [{ prefix, left: quotaSection, right: "" }];
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// 데이터 없을 때: 퍼센트+시간 모두 dim 처리 (green 0% 대신)
|
|
1116
|
+
if (!hasData) {
|
|
1117
|
+
const quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ` +
|
|
1118
|
+
`${dim(formatTimeCell(fiveHourReset))} ` +
|
|
1119
|
+
`${dim("1w:")}${dim(formatPlaceholderPercentCell())} ` +
|
|
1120
|
+
`${dim(formatTimeCellDH(weeklyReset))}`;
|
|
1121
|
+
const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1122
|
+
return [{ prefix, left: quotaSection, right: contextSection }];
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const fiveHourPercentCell = formatPercentCell(fiveHourPercent);
|
|
1126
|
+
const weeklyPercentCell = formatPercentCell(weeklyPercent);
|
|
1127
|
+
const fiveHourTimeCell = formatTimeCell(fiveHourReset);
|
|
1128
|
+
const weeklyTimeCell = formatTimeCellDH(weeklyReset);
|
|
1129
|
+
const quotaSection = `${dim("5h:")}${colorByPercent(fiveHourPercent, fiveHourPercentCell)} ` +
|
|
1130
|
+
`${dim(fiveHourTimeCell)} ` +
|
|
1131
|
+
`${dim("1w:")}${colorByPercent(weeklyPercent, weeklyPercentCell)} ` +
|
|
1132
|
+
`${dim(weeklyTimeCell)}`;
|
|
1133
|
+
const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
|
|
1134
|
+
return [{ prefix, left: quotaSection, right: contextSection }];
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function getAccountLabel(provider, accountsConfig, accountsState, codexEmail) {
|
|
1138
|
+
const providerConfig = accountsConfig?.providers?.[provider] || [];
|
|
1139
|
+
const providerState = accountsState?.providers?.[provider] || {};
|
|
1140
|
+
const lastId = providerState.last_selected_id;
|
|
1141
|
+
const picked = providerConfig.find((a) => a.id === lastId) || providerConfig[0]
|
|
1142
|
+
|| { id: `${provider}-main`, label: provider };
|
|
1143
|
+
let label = picked.label || picked.id;
|
|
1144
|
+
if (codexEmail) label = codexEmail;
|
|
1145
|
+
if (label.includes("@")) label = label.split("@")[0];
|
|
1146
|
+
return label;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfig, accountsState, realQuota, codexEmail, savingsMultiplier, modelLabel) {
|
|
1150
|
+
const accountLabel = fitText(getAccountLabel(provider, accountsConfig, accountsState, codexEmail), ACCOUNT_LABEL_WIDTH);
|
|
1151
|
+
|
|
1152
|
+
// 절약 퍼센트 섹션 (context window 대비 %, 4자리 고정폭)
|
|
1153
|
+
const svPct = savingsMultiplier != null ? Math.round(savingsMultiplier * 100) : null;
|
|
1154
|
+
const svStr = svPct != null ? `${svPct}%`.padStart(6) : "--%".padStart(6);
|
|
1155
|
+
const modelLabelStr = modelLabel ? ` ${markerColor(modelLabel)}` : "";
|
|
1156
|
+
|
|
1157
|
+
// ── 쿼터 섹션 ──
|
|
1158
|
+
let quotaSection;
|
|
1159
|
+
let extraRightSection = "";
|
|
1160
|
+
|
|
1161
|
+
if (MINIMAL_MODE) {
|
|
1162
|
+
const cols = getTerminalColumns() || 80;
|
|
1163
|
+
const minPrefix = `${bold(markerColor(`${marker}`))}:`;
|
|
1164
|
+
const svCompact = svStr ? ` ${dim("sv:")}${cyan(svStr.trim())}` : "";
|
|
1165
|
+
if (realQuota?.type === "codex") {
|
|
1166
|
+
const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
|
|
1167
|
+
if (main) {
|
|
1168
|
+
const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
|
|
1169
|
+
const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
|
|
1170
|
+
if (cols < 40) {
|
|
1171
|
+
return { prefix: minPrefix, left: `${colorByPercent(fiveP, `${fiveP}%`)}${dim("/")}${colorByPercent(weekP, `${weekP}%`)}${svCompact}`, right: "" };
|
|
1172
|
+
}
|
|
1173
|
+
return { prefix: minPrefix, left: `${dim("5h")} ${colorByPercent(fiveP, `${fiveP}%`)} ${dim("1w")} ${colorByPercent(weekP, `${weekP}%`)}${svCompact}`, right: "" };
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (realQuota?.type === "gemini") {
|
|
1177
|
+
const bucket = realQuota.quotaBucket;
|
|
1178
|
+
if (bucket) {
|
|
1179
|
+
const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
|
|
1180
|
+
if (cols < 40) {
|
|
1181
|
+
return { prefix: minPrefix, left: `${colorByPercent(usedP, `${usedP}%`)}${svCompact}${modelLabelStr}`, right: "" };
|
|
1182
|
+
}
|
|
1183
|
+
return { prefix: minPrefix, left: `${dim("1d")} ${colorByPercent(usedP, `${usedP}%`)} ${dim("1w")} ${bold("\u221E%")}${svCompact}${modelLabelStr}`, right: "" };
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return { prefix: minPrefix, left: dim("--%"), right: "" };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (COMPACT_MODE) {
|
|
1190
|
+
// 컴팩트 모드: 바 없이 퍼센트만, right 섹션 생략
|
|
1191
|
+
if (realQuota?.type === "codex") {
|
|
1192
|
+
const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
|
|
1193
|
+
if (main) {
|
|
1194
|
+
const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
|
|
1195
|
+
const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
|
|
1196
|
+
quotaSection = `${dim("5h:")}${colorByPercent(fiveP, `${fiveP}%`)} ` +
|
|
1197
|
+
`${dim("1w:")}${colorByPercent(weekP, `${weekP}%`)}`;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (realQuota?.type === "gemini") {
|
|
1201
|
+
const bucket = realQuota.quotaBucket;
|
|
1202
|
+
if (bucket) {
|
|
1203
|
+
const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
|
|
1204
|
+
quotaSection = `${dim("1d:")}${colorByPercent(usedP, `${usedP}%`)} ${dim("1w:")}${bold("\u221E%")}`;
|
|
1205
|
+
} else {
|
|
1206
|
+
quotaSection = `${dim("1d:")}${dim("--%")} ${dim("1w:")}${bold("\u221E%")}`;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (!quotaSection) {
|
|
1210
|
+
quotaSection = `${dim("5h:")}${green("0%")} ${dim("1w:")}${green("0%")}`;
|
|
1211
|
+
}
|
|
1212
|
+
const prefix = `${bold(markerColor(`${marker}`))}:`;
|
|
1213
|
+
const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", modelLabel ? markerColor(modelLabel) : ""].filter(Boolean).join(" ");
|
|
1214
|
+
return { prefix, left: quotaSection, right: compactRight };
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (realQuota?.type === "codex") {
|
|
1218
|
+
const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
|
|
1219
|
+
if (main) {
|
|
1220
|
+
const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
|
|
1221
|
+
const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
|
|
1222
|
+
const fiveReset = formatResetRemaining(main.primary?.resets_at) || "n/a";
|
|
1223
|
+
const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at) || "n/a";
|
|
1224
|
+
quotaSection = `${dim("5h:")}${colorByPercent(fiveP, formatPercentCell(fiveP))} ` +
|
|
1225
|
+
`${dim(formatTimeCell(fiveReset))} ` +
|
|
1226
|
+
`${dim("1w:")}${colorByPercent(weekP, formatPercentCell(weekP))} ` +
|
|
1227
|
+
`${dim(formatTimeCellDH(weekReset))}`;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (realQuota?.type === "gemini") {
|
|
1232
|
+
const bucket = realQuota.quotaBucket;
|
|
1233
|
+
if (bucket) {
|
|
1234
|
+
const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
|
|
1235
|
+
const rstRemaining = formatResetRemaining(bucket.resetTime) || "n/a";
|
|
1236
|
+
quotaSection = `${dim("1d:")}${colorByPercent(usedP, formatPercentCell(usedP))} ${dim(formatTimeCell(rstRemaining))} ` +
|
|
1237
|
+
`${dim("1w:")}${bold("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
|
|
1238
|
+
} else {
|
|
1239
|
+
quotaSection = `${dim("1d:")}${dim(formatPlaceholderPercentCell())} ` +
|
|
1240
|
+
`${dim(formatTimeCell("--h--m"))} ${dim("1w:")}${bold("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// 폴백: 쿼터 데이터 없을 때
|
|
1245
|
+
if (!quotaSection) {
|
|
1246
|
+
quotaSection = `${dim("5h:")}${dim("--%")} ${dim("1w:")}${dim("--%")}`;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const prefix = `${bold(markerColor(`${marker}`))}:`;
|
|
1250
|
+
const accountSection = `${markerColor(accountLabel)}`;
|
|
1251
|
+
const svSection = svStr ? `${dim("sv:")}${svStr}` : "";
|
|
1252
|
+
const modelLabelSection = modelLabel ? markerColor(modelLabel) : "";
|
|
1253
|
+
const rightParts = [svSection, accountSection, modelLabelSection].filter(Boolean);
|
|
1254
|
+
return {
|
|
1255
|
+
prefix,
|
|
1256
|
+
left: quotaSection,
|
|
1257
|
+
right: rightParts.join(` ${dim("|")} `),
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// ============================================================================
|
|
1262
|
+
// 메인
|
|
1263
|
+
// ============================================================================
|
|
1264
|
+
async function main() {
|
|
1265
|
+
// 백그라운드 Claude 사용량 리프레시
|
|
1266
|
+
if (process.argv.includes(CLAUDE_REFRESH_FLAG)) {
|
|
1267
|
+
await fetchClaudeUsage(true);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (process.argv.includes(CODEX_REFRESH_FLAG)) {
|
|
1272
|
+
refreshCodexRateLimitsCache();
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (process.argv.includes(GEMINI_SESSION_REFRESH_FLAG)) {
|
|
1277
|
+
refreshGeminiSessionCache();
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// 백그라운드 Gemini 쿼터 리프레시 전용 실행 모드
|
|
1282
|
+
if (process.argv.includes(GEMINI_REFRESH_FLAG)) {
|
|
1283
|
+
const accountId = getCliArgValue("--account") || "gemini-main";
|
|
1284
|
+
const authContext = buildGeminiAuthContext(accountId);
|
|
1285
|
+
await fetchGeminiQuota(accountId, { authContext, forceRefresh: true });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// 메인 HUD 경로: 즉시 렌더 우선
|
|
1290
|
+
const stdinPromise = readStdinJson();
|
|
1291
|
+
|
|
1292
|
+
const qosProfile = readJson(QOS_PATH, { providers: {} });
|
|
1293
|
+
const accountsConfig = readJson(ACCOUNTS_CONFIG_PATH, { providers: {} });
|
|
1294
|
+
const accountsState = readJson(ACCOUNTS_STATE_PATH, { providers: {} });
|
|
1295
|
+
const claudeUsageSnapshot = readClaudeUsageSnapshot();
|
|
1296
|
+
if (claudeUsageSnapshot.shouldRefresh) {
|
|
1297
|
+
scheduleClaudeUsageRefresh();
|
|
1298
|
+
}
|
|
1299
|
+
const geminiAccountId = getProviderAccountId("gemini", accountsConfig, accountsState);
|
|
1300
|
+
const codexSnapshot = readCodexRateLimitSnapshot();
|
|
1301
|
+
const geminiSessionSnapshot = readGeminiSessionSnapshot();
|
|
1302
|
+
const geminiAuthContext = buildGeminiAuthContext(geminiAccountId);
|
|
1303
|
+
const geminiQuotaSnapshot = readGeminiQuotaSnapshot(geminiAccountId, geminiAuthContext);
|
|
1304
|
+
if (codexSnapshot.shouldRefresh) {
|
|
1305
|
+
scheduleCodexRateLimitRefresh();
|
|
1306
|
+
}
|
|
1307
|
+
if (geminiSessionSnapshot.shouldRefresh) {
|
|
1308
|
+
scheduleGeminiSessionRefresh();
|
|
1309
|
+
}
|
|
1310
|
+
if (geminiQuotaSnapshot.shouldRefresh) {
|
|
1311
|
+
scheduleGeminiQuotaRefresh(geminiAccountId);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// 실측 데이터 추출
|
|
1315
|
+
const stdin = await stdinPromise;
|
|
1316
|
+
const codexEmail = getCodexEmail();
|
|
1317
|
+
const geminiEmail = getGeminiEmail();
|
|
1318
|
+
const codexBuckets = codexSnapshot.buckets;
|
|
1319
|
+
const geminiSession = geminiSessionSnapshot.session;
|
|
1320
|
+
const geminiQuota = geminiQuotaSnapshot.quota;
|
|
1321
|
+
|
|
1322
|
+
// 누적 절약 데이터 읽기
|
|
1323
|
+
const svSavings = readTokenSavings();
|
|
1324
|
+
const svAccumulator = readSvAccumulator();
|
|
1325
|
+
const totalCostSaved = svSavings?.totalSaved || svAccumulator?.totalCostSaved || 0;
|
|
1326
|
+
|
|
1327
|
+
// 세션/누적 토큰 → context 대비 절약 배수 (개별 provider sv%)
|
|
1328
|
+
const ctxCapacity = stdin?.context_window?.context_window_size || 200000;
|
|
1329
|
+
let codexSv = null;
|
|
1330
|
+
if (svAccumulator?.codex?.tokens > 0) {
|
|
1331
|
+
codexSv = svAccumulator.codex.tokens / ctxCapacity;
|
|
1332
|
+
} else if (codexBuckets) {
|
|
1333
|
+
const main = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
|
|
1334
|
+
if (main?.tokens?.total_tokens) codexSv = main.tokens.total_tokens / ctxCapacity;
|
|
1335
|
+
}
|
|
1336
|
+
let geminiSv = null;
|
|
1337
|
+
if (svAccumulator?.gemini?.tokens > 0) {
|
|
1338
|
+
geminiSv = svAccumulator.gemini.tokens / ctxCapacity;
|
|
1339
|
+
} else {
|
|
1340
|
+
const geminiTokens = geminiSession?.total || null;
|
|
1341
|
+
geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Gemini: 사용 중인 모델의 쿼터 버킷 찾기
|
|
1345
|
+
const geminiModel = geminiSession?.model || "gemini-3-flash-preview";
|
|
1346
|
+
const geminiBucket = geminiQuota?.buckets?.find((b) => b.modelId === geminiModel)
|
|
1347
|
+
|| geminiQuota?.buckets?.find((b) => b.modelId === "gemini-3-flash-preview")
|
|
1348
|
+
|| null;
|
|
1349
|
+
|
|
1350
|
+
// 합산 절약: 달러 누적 (getClaudeRows에서 $ 포맷)
|
|
1351
|
+
const totalSvDollar = totalCostSaved;
|
|
1352
|
+
|
|
1353
|
+
const rows = [
|
|
1354
|
+
...getClaudeRows(stdin, claudeUsageSnapshot.data, totalSvDollar),
|
|
1355
|
+
getProviderRow("codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
|
|
1356
|
+
codexBuckets ? { type: "codex", buckets: codexBuckets } : null, codexEmail,
|
|
1357
|
+
codexSv, null),
|
|
1358
|
+
getProviderRow("gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
|
|
1359
|
+
{ type: "gemini", quotaBucket: geminiBucket, session: geminiSession }, geminiEmail,
|
|
1360
|
+
geminiSv, getGeminiModelLabel(geminiModel)),
|
|
1361
|
+
];
|
|
1362
|
+
let outputLines = renderAlignedRows(rows);
|
|
1363
|
+
// maxLines 설정: 알림 배너와 공존할 때 라인 수 제한 (hud.json의 lines 값)
|
|
1364
|
+
const maxLines = Number(readJson(HUD_CONFIG_PATH, null)?.lines) || 0;
|
|
1365
|
+
if (maxLines > 0 && outputLines.length > maxLines) {
|
|
1366
|
+
while (outputLines.length > maxLines) {
|
|
1367
|
+
const last = outputLines.pop();
|
|
1368
|
+
outputLines[outputLines.length - 1] += ` ${last.trim()}`;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// Context low 메시지 뒤에 HUD가 분리되도록 선행 개행 추가
|
|
1372
|
+
const contextPercent = getContextPercent(stdin);
|
|
1373
|
+
const contextLowPrefix = contextPercent >= 85 ? "\n" : "";
|
|
1374
|
+
// RESET prefix: 이전 렌더 잔여 ANSI 색상 방지
|
|
1375
|
+
process.stdout.write(`\x1b[0m${contextLowPrefix}${outputLines.join("\n")}\n`);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
main().catch(() => {
|
|
1379
|
+
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`);
|
|
1380
|
+
});
|