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