@triflux/core 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/hooks/agent-route-guard.mjs +109 -0
  2. package/hooks/cross-review-tracker.mjs +122 -0
  3. package/hooks/error-context.mjs +148 -0
  4. package/hooks/hook-manager.mjs +352 -0
  5. package/hooks/hook-orchestrator.mjs +312 -0
  6. package/hooks/hook-registry.json +213 -0
  7. package/hooks/hooks.json +89 -0
  8. package/hooks/keyword-rules.json +581 -0
  9. package/hooks/lib/resolve-root.mjs +59 -0
  10. package/hooks/mcp-config-watcher.mjs +85 -0
  11. package/hooks/pipeline-stop.mjs +76 -0
  12. package/hooks/safety-guard.mjs +106 -0
  13. package/hooks/subagent-verifier.mjs +80 -0
  14. package/hub/assign-callbacks.mjs +133 -0
  15. package/hub/bridge.mjs +799 -0
  16. package/hub/cli-adapter-base.mjs +192 -0
  17. package/hub/codex-adapter.mjs +190 -0
  18. package/hub/codex-compat.mjs +78 -0
  19. package/hub/codex-preflight.mjs +147 -0
  20. package/hub/delegator/contracts.mjs +37 -0
  21. package/hub/delegator/index.mjs +14 -0
  22. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  23. package/hub/delegator/service.mjs +307 -0
  24. package/hub/delegator/tool-definitions.mjs +35 -0
  25. package/hub/fullcycle.mjs +96 -0
  26. package/hub/gemini-adapter.mjs +179 -0
  27. package/hub/hitl.mjs +143 -0
  28. package/hub/intent.mjs +193 -0
  29. package/hub/lib/process-utils.mjs +361 -0
  30. package/hub/middleware/request-logger.mjs +81 -0
  31. package/hub/paths.mjs +30 -0
  32. package/hub/pipeline/gates/confidence.mjs +56 -0
  33. package/hub/pipeline/gates/consensus.mjs +94 -0
  34. package/hub/pipeline/gates/index.mjs +5 -0
  35. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  36. package/hub/pipeline/index.mjs +318 -0
  37. package/hub/pipeline/state.mjs +191 -0
  38. package/hub/pipeline/transitions.mjs +124 -0
  39. package/hub/platform.mjs +225 -0
  40. package/hub/quality/deslop.mjs +253 -0
  41. package/hub/reflexion.mjs +372 -0
  42. package/hub/research.mjs +146 -0
  43. package/hub/router.mjs +791 -0
  44. package/hub/routing/complexity.mjs +166 -0
  45. package/hub/routing/index.mjs +117 -0
  46. package/hub/routing/q-learning.mjs +336 -0
  47. package/hub/session-fingerprint.mjs +352 -0
  48. package/hub/state.mjs +245 -0
  49. package/hub/team-bridge.mjs +25 -0
  50. package/hub/token-mode.mjs +224 -0
  51. package/hub/workers/worker-utils.mjs +104 -0
  52. package/hud/colors.mjs +88 -0
  53. package/hud/constants.mjs +81 -0
  54. package/hud/hud-qos-status.mjs +206 -0
  55. package/hud/providers/claude.mjs +309 -0
  56. package/hud/providers/codex.mjs +151 -0
  57. package/hud/providers/gemini.mjs +320 -0
  58. package/hud/renderers.mjs +424 -0
  59. package/hud/terminal.mjs +140 -0
  60. package/hud/utils.mjs +287 -0
  61. package/package.json +31 -0
  62. package/scripts/lib/claudemd-manager.mjs +325 -0
  63. package/scripts/lib/context.mjs +67 -0
  64. package/scripts/lib/cross-review-utils.mjs +51 -0
  65. package/scripts/lib/env-probe.mjs +241 -0
  66. package/scripts/lib/gemini-profiles.mjs +85 -0
  67. package/scripts/lib/hook-utils.mjs +14 -0
  68. package/scripts/lib/keyword-rules.mjs +166 -0
  69. package/scripts/lib/logger.mjs +105 -0
  70. package/scripts/lib/mcp-filter.mjs +739 -0
  71. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  72. package/scripts/lib/mcp-manifest.mjs +79 -0
  73. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  74. package/scripts/lib/psmux-info.mjs +119 -0
  75. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
@@ -0,0 +1,206 @@
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
+ });
@@ -0,0 +1,309 @@
1
+ // ============================================================================
2
+ // Claude Usage API (api.anthropic.com/api/oauth/usage)
3
+ // ============================================================================
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import https from "node:https";
8
+ import { spawn } from "node:child_process";
9
+ import {
10
+ CLAUDE_CREDENTIALS_PATH, CLAUDE_USAGE_CACHE_PATH, OMC_PLUGIN_USAGE_CACHE_PATH,
11
+ CLAUDE_USAGE_STALE_MS_SOLO, CLAUDE_USAGE_STALE_MS_WITH_OMC,
12
+ CLAUDE_USAGE_429_BACKOFF_MS, CLAUDE_USAGE_ERROR_BACKOFF_MS,
13
+ CLAUDE_API_TIMEOUT_MS, FIVE_HOUR_MS, SEVEN_DAY_MS,
14
+ DEFAULT_OAUTH_CLIENT_ID, CLAUDE_REFRESH_FLAG,
15
+ } from "../constants.mjs";
16
+ import { readJson, writeJsonSafe, clampPercent, advanceToNextCycle } from "../utils.mjs";
17
+
18
+ // OMC 활성 여부에 따라 캐시 TTL 동적 결정
19
+ function getClaudeUsageStaleMs() {
20
+ return existsSync(OMC_PLUGIN_USAGE_CACHE_PATH)
21
+ ? CLAUDE_USAGE_STALE_MS_WITH_OMC
22
+ : CLAUDE_USAGE_STALE_MS_SOLO;
23
+ }
24
+
25
+ export function readClaudeCredentials() {
26
+ const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
27
+ if (!data) return null;
28
+ const creds = data.claudeAiOauth || data;
29
+ if (!creds.accessToken) return null;
30
+ return {
31
+ accessToken: creds.accessToken,
32
+ refreshToken: creds.refreshToken,
33
+ expiresAt: creds.expiresAt,
34
+ };
35
+ }
36
+
37
+ export function refreshClaudeAccessToken(refreshToken) {
38
+ return new Promise((resolve) => {
39
+ const clientId = process.env.CLAUDE_CODE_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
40
+ const body = new URLSearchParams({
41
+ grant_type: "refresh_token",
42
+ refresh_token: refreshToken,
43
+ client_id: clientId,
44
+ }).toString();
45
+ const req = https.request({
46
+ hostname: "platform.claude.com",
47
+ path: "/v1/oauth/token",
48
+ method: "POST",
49
+ headers: {
50
+ "Content-Type": "application/x-www-form-urlencoded",
51
+ "Content-Length": Buffer.byteLength(body),
52
+ },
53
+ timeout: CLAUDE_API_TIMEOUT_MS,
54
+ }, (res) => {
55
+ let data = "";
56
+ res.on("data", (chunk) => { data += chunk; });
57
+ res.on("end", () => {
58
+ if (res.statusCode === 200) {
59
+ try {
60
+ const parsed = JSON.parse(data);
61
+ if (parsed.access_token) {
62
+ resolve({
63
+ accessToken: parsed.access_token,
64
+ refreshToken: parsed.refresh_token || refreshToken,
65
+ expiresAt: parsed.expires_in
66
+ ? Date.now() + parsed.expires_in * 1000
67
+ : parsed.expires_at,
68
+ });
69
+ return;
70
+ }
71
+ } catch { /* parse 실패 */ }
72
+ }
73
+ resolve(null);
74
+ });
75
+ });
76
+ req.on("error", () => resolve(null));
77
+ req.on("timeout", () => { req.destroy(); resolve(null); });
78
+ req.end(body);
79
+ });
80
+ }
81
+
82
+ export function writeBackClaudeCredentials(creds) {
83
+ try {
84
+ const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
85
+ if (!data) return;
86
+ const target = data.claudeAiOauth || data;
87
+ target.accessToken = creds.accessToken;
88
+ if (creds.expiresAt != null) target.expiresAt = creds.expiresAt;
89
+ if (creds.refreshToken) target.refreshToken = creds.refreshToken;
90
+ writeFileSync(CLAUDE_CREDENTIALS_PATH, JSON.stringify(data, null, 2));
91
+ } catch { /* 쓰기 실패 무시 */ }
92
+ }
93
+
94
+ export function fetchClaudeUsageFromApi(accessToken) {
95
+ return new Promise((resolve) => {
96
+ const req = https.request({
97
+ hostname: "api.anthropic.com",
98
+ path: "/api/oauth/usage",
99
+ method: "GET",
100
+ headers: {
101
+ "Authorization": `Bearer ${accessToken}`,
102
+ "anthropic-beta": "oauth-2025-04-20",
103
+ "Content-Type": "application/json",
104
+ },
105
+ timeout: CLAUDE_API_TIMEOUT_MS,
106
+ }, (res) => {
107
+ let data = "";
108
+ res.on("data", (chunk) => { data += chunk; });
109
+ res.on("end", () => {
110
+ if (res.statusCode === 200) {
111
+ try { resolve({ ok: true, data: JSON.parse(data) }); } catch { resolve({ ok: false, status: 0 }); }
112
+ } else {
113
+ resolve({ ok: false, status: res.statusCode });
114
+ }
115
+ });
116
+ });
117
+ req.on("error", () => resolve({ ok: false, status: 0, error: "network" }));
118
+ req.on("timeout", () => { req.destroy(); resolve({ ok: false, status: 0, error: "timeout" }); });
119
+ req.end();
120
+ });
121
+ }
122
+
123
+ export function parseClaudeUsageResponse(response) {
124
+ if (!response || typeof response !== "object") return null;
125
+ // five_hour/seven_day 키 자체가 없으면 비정상 응답
126
+ if (!response.five_hour && !response.seven_day) return null;
127
+ const fiveHour = response.five_hour?.utilization;
128
+ const sevenDay = response.seven_day?.utilization;
129
+ // utilization이 null이면 0%로 처리 (API 200 성공 시 null = 사용량 없음)
130
+ return {
131
+ fiveHourPercent: clampPercent(fiveHour ?? 0),
132
+ weeklyPercent: clampPercent(sevenDay ?? 0),
133
+ fiveHourResetsAt: response.five_hour?.resets_at || null,
134
+ weeklyResetsAt: response.seven_day?.resets_at || null,
135
+ };
136
+ }
137
+
138
+ // stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
139
+ export function stripStaleResets(data) {
140
+ if (!data) return data;
141
+ const copy = { ...data };
142
+ if (copy.fiveHourResetsAt) {
143
+ const t = new Date(copy.fiveHourResetsAt).getTime();
144
+ if (!isNaN(t)) copy.fiveHourResetsAt = new Date(advanceToNextCycle(t, FIVE_HOUR_MS)).toISOString();
145
+ }
146
+ if (copy.weeklyResetsAt) {
147
+ const t = new Date(copy.weeklyResetsAt).getTime();
148
+ if (!isNaN(t)) copy.weeklyResetsAt = new Date(advanceToNextCycle(t, SEVEN_DAY_MS)).toISOString();
149
+ }
150
+ return copy;
151
+ }
152
+
153
+ export function readClaudeUsageSnapshot() {
154
+ const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
155
+ const ts = Number(cache?.timestamp);
156
+ const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
157
+
158
+ // 1차: 자체 캐시에 유효 데이터가 있는 경우
159
+ if (cache?.data) {
160
+ // 에러 상태에서 보존된 stale 데이터 → backoff 존중하되 표시용 데이터 반환
161
+ if (cache.error) {
162
+ const backoffMs = cache.errorType === "rate_limit"
163
+ ? CLAUDE_USAGE_429_BACKOFF_MS
164
+ : CLAUDE_USAGE_ERROR_BACKOFF_MS;
165
+ return { data: stripStaleResets(cache.data), shouldRefresh: ageMs >= backoffMs };
166
+ }
167
+ const isFresh = ageMs < getClaudeUsageStaleMs();
168
+ // resets_at이 지난 윈도우의 percent를 0으로 보정 (stale 캐시 방지)
169
+ const data = { ...cache.data };
170
+ const now = Date.now();
171
+ if (data.fiveHourResetsAt && new Date(data.fiveHourResetsAt).getTime() <= now) {
172
+ data.fiveHourPercent = 0;
173
+ }
174
+ if (data.weeklyResetsAt && new Date(data.weeklyResetsAt).getTime() <= now) {
175
+ data.weeklyPercent = 0;
176
+ }
177
+ return { data, shouldRefresh: !isFresh };
178
+ }
179
+
180
+ // 2차: 에러 backoff — 최근 에러 시 재시도 억제 (무한 spawn 방지)
181
+ if (cache?.error && Number.isFinite(ts)) {
182
+ const backoffMs = cache.errorType === "rate_limit"
183
+ ? CLAUDE_USAGE_429_BACKOFF_MS
184
+ : CLAUDE_USAGE_ERROR_BACKOFF_MS;
185
+ if (ageMs < backoffMs) {
186
+ const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
187
+ // OMC 캐시가 에러 이후 갱신되었으면 → 에러 캐시 덮어쓰고 그 데이터 사용
188
+ if (omcCache?.data?.fiveHourPercent != null && omcCache.timestamp > ts) {
189
+ writeClaudeUsageCache(omcCache.data);
190
+ return { data: omcCache.data, shouldRefresh: false };
191
+ }
192
+ // stale OMC fallback 또는 null (--% 플레이스홀더 표시, 가짜 0% 방지)
193
+ const staleData = omcCache?.data?.fiveHourPercent != null ? stripStaleResets(omcCache.data) : null;
194
+ return { data: staleData, shouldRefresh: false };
195
+ }
196
+ }
197
+
198
+ // 3차: OMC 플러그인 캐시 (같은 API 데이터, 중복 호출 방지)
199
+ const OMC_CACHE_MAX_AGE_MS = 30 * 60 * 1000;
200
+ const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
201
+ if (omcCache?.data?.fiveHourPercent != null) {
202
+ const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Number.MAX_SAFE_INTEGER;
203
+ if (omcAge < OMC_CACHE_MAX_AGE_MS) {
204
+ writeClaudeUsageCache(omcCache.data);
205
+ return { data: omcCache.data, shouldRefresh: omcAge > getClaudeUsageStaleMs() };
206
+ }
207
+ // stale이어도 data: null보다는 오래된 데이터를 fallback으로 표시
208
+ return { data: stripStaleResets(omcCache.data), shouldRefresh: true };
209
+ }
210
+
211
+ // 캐시/fallback 모두 없음: null 반환 → --% 플레이스홀더 + 리프레시 시도
212
+ return { data: null, shouldRefresh: true };
213
+ }
214
+
215
+ export function writeClaudeUsageCache(data, errorInfo = null) {
216
+ const entry = {
217
+ timestamp: Date.now(),
218
+ data,
219
+ error: !!errorInfo,
220
+ errorType: errorInfo?.type || null, // "rate_limit" | "auth" | "network" | "unknown"
221
+ errorStatus: errorInfo?.status || null, // HTTP 상태 코드
222
+ };
223
+ // 에러 시 기존 유효 데이터 보존 (--% n/a 방지)
224
+ if (errorInfo && data == null) {
225
+ const prev = readJson(CLAUDE_USAGE_CACHE_PATH, null);
226
+ if (prev?.data) {
227
+ entry.data = prev.data;
228
+ entry.stale = true;
229
+ }
230
+ }
231
+ writeJsonSafe(CLAUDE_USAGE_CACHE_PATH, entry);
232
+ }
233
+
234
+ export async function fetchClaudeUsage(forceRefresh = false) {
235
+ const existingSnapshot = readClaudeUsageSnapshot();
236
+ if (!forceRefresh && !existingSnapshot.shouldRefresh && existingSnapshot.data) {
237
+ return existingSnapshot.data;
238
+ }
239
+ let creds = readClaudeCredentials();
240
+ if (!creds) {
241
+ writeClaudeUsageCache(null, { type: "auth", status: 0 });
242
+ return existingSnapshot.data || null;
243
+ }
244
+
245
+ // 토큰 만료 시 리프레시
246
+ if (creds.expiresAt && creds.expiresAt <= Date.now() && creds.refreshToken) {
247
+ const refreshed = await refreshClaudeAccessToken(creds.refreshToken);
248
+ if (refreshed) {
249
+ creds = { ...creds, ...refreshed };
250
+ writeBackClaudeCredentials(creds);
251
+ } else {
252
+ writeClaudeUsageCache(null, { type: "auth", status: 0 });
253
+ return existingSnapshot.data || null;
254
+ }
255
+ }
256
+
257
+ const result = await fetchClaudeUsageFromApi(creds.accessToken);
258
+ if (!result.ok) {
259
+ // 에러 유형별 분류하여 backoff 차등 적용
260
+ const errorType = result.status === 429 ? "rate_limit"
261
+ : result.status === 401 || result.status === 403 ? "auth"
262
+ : result.error === "timeout" || result.error === "network" ? "network"
263
+ : "unknown";
264
+ writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
265
+ return existingSnapshot.data || null;
266
+ }
267
+ const usage = parseClaudeUsageResponse(result.data);
268
+ writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 });
269
+ return usage;
270
+ }
271
+
272
+ export function scheduleClaudeUsageRefresh() {
273
+ const scriptPath = process.argv[1];
274
+ if (!scriptPath) return;
275
+
276
+ // OMC 플러그인이 이미 fresh 데이터를 가지고 있으면 HUD 리프레시 불필요 (429 방지)
277
+ try {
278
+ const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
279
+ if (omcCache?.data?.fiveHourPercent != null) {
280
+ const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Infinity;
281
+ if (omcAge < getClaudeUsageStaleMs()) {
282
+ writeClaudeUsageCache(omcCache.data); // HUD 캐시에 복사만
283
+ return;
284
+ }
285
+ }
286
+ } catch { /* 무시 */ }
287
+
288
+ // 스폰 락: 30초 내 이미 스폰했으면 중복 방지 (첫 설치 시 429 방지)
289
+ const lockPath = join(homedir(), ".claude", "cache", ".claude-refresh-lock");
290
+ try {
291
+ if (existsSync(lockPath)) {
292
+ const lockAge = Date.now() - readJson(lockPath, {}).t;
293
+ if (lockAge < 30000) return; // 30초 이내 스폰 이력 → 건너뜀
294
+ }
295
+ writeJsonSafe(lockPath, { t: Date.now() });
296
+ } catch { /* 락 실패 무시 — 스폰 진행 */ }
297
+
298
+ try {
299
+ const child = spawn(process.execPath, [scriptPath, CLAUDE_REFRESH_FLAG], {
300
+ detached: true,
301
+ stdio: "ignore",
302
+ windowsHide: true,
303
+ });
304
+ child.unref();
305
+ } catch (spawnErr) {
306
+ // spawn 실패 시 에러 유형을 캐시에 기록 (HUD에서 원인 힌트 표시 가능)
307
+ writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
308
+ }
309
+ }
@@ -0,0 +1,151 @@
1
+ // ============================================================================
2
+ // Codex rate limits 추출 / 캐싱
3
+ // ============================================================================
4
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import {
9
+ CODEX_AUTH_PATH, CODEX_QUOTA_CACHE_PATH, CODEX_QUOTA_STALE_MS,
10
+ CODEX_MIN_BUCKETS, CODEX_REFRESH_FLAG,
11
+ } from "../constants.mjs";
12
+ import { readJson, writeJsonSafe, decodeJwtEmail } from "../utils.mjs";
13
+
14
+ export function getCodexEmail() {
15
+ try {
16
+ const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
17
+ return decodeJwtEmail(auth?.tokens?.id_token);
18
+ } catch { return null; }
19
+ }
20
+
21
+ // resets_at이 지난 윈도우의 used_percent를 0으로 보정
22
+ export function expireStaleCodexBuckets(buckets) {
23
+ if (!buckets) return buckets;
24
+ const nowSec = Math.floor(Date.now() / 1000);
25
+ const result = {};
26
+ for (const [key, bucket] of Object.entries(buckets)) {
27
+ if (!bucket) { result[key] = bucket; continue; }
28
+ let updated = bucket;
29
+ if (bucket.primary?.resets_at && bucket.primary.resets_at <= nowSec) {
30
+ updated = { ...updated, primary: { ...updated.primary, used_percent: 0 } };
31
+ }
32
+ if (bucket.secondary?.resets_at && bucket.secondary.resets_at <= nowSec) {
33
+ updated = { ...updated, secondary: { ...updated.secondary, used_percent: 0 } };
34
+ }
35
+ result[key] = updated;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ // ============================================================================
41
+ // Codex 세션 JSONL에서 실제 rate limits 추출
42
+ // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
43
+ // 최근 7일간 세션 파일을 스캔해 가장 최신 rate_limits 버킷을 수집한다.
44
+ // 합성 버킷(token_count 기반)은 2일 이내 데이터만 허용하여 stale 방지.
45
+ // ============================================================================
46
+ export function getCodexRateLimits() {
47
+ const now = new Date();
48
+ let syntheticBucket = null; // 최근 token_count에서 합성 (행 활성화 + 토큰 데이터용)
49
+
50
+ // 7일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
51
+ for (let dayOffset = 0; dayOffset <= 6; dayOffset++) {
52
+ const d = new Date(now.getTime() - dayOffset * 86_400_000);
53
+ const sessDir = join(
54
+ homedir(), ".codex", "sessions",
55
+ String(d.getFullYear()),
56
+ String(d.getMonth() + 1).padStart(2, "0"),
57
+ String(d.getDate()).padStart(2, "0"),
58
+ );
59
+ if (!existsSync(sessDir)) continue;
60
+ let files;
61
+ try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
62
+ catch { continue; }
63
+
64
+ const mergedBuckets = {};
65
+ for (const file of files) {
66
+ try {
67
+ const content = readFileSync(join(sessDir, file), "utf-8");
68
+ const lines = content.trim().split("\n").reverse();
69
+ for (const line of lines) {
70
+ try {
71
+ const evt = JSON.parse(line);
72
+ const rl = evt?.payload?.rate_limits;
73
+ if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
74
+ // 실제 rate_limits: limit_id별 최신 이벤트만 기록
75
+ mergedBuckets[rl.limit_id] = {
76
+ limitId: rl.limit_id, limitName: rl.limit_name,
77
+ primary: rl.primary, secondary: rl.secondary,
78
+ credits: rl.credits,
79
+ tokens: evt.payload?.info?.total_token_usage,
80
+ contextWindow: evt.payload?.info?.model_context_window,
81
+ timestamp: evt.timestamp,
82
+ };
83
+ } else if (dayOffset <= 1 && !rl && evt?.payload?.info?.total_token_usage && !syntheticBucket) {
84
+ // 2일 이내 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용, stale 방지)
85
+ syntheticBucket = {
86
+ limitId: "codex", limitName: "codex-session",
87
+ primary: null, secondary: null,
88
+ credits: null,
89
+ tokens: evt.payload.info.total_token_usage,
90
+ contextWindow: evt.payload.info.model_context_window,
91
+ timestamp: evt.timestamp,
92
+ };
93
+ }
94
+ } catch { /* 라인 파싱 실패 무시 */ }
95
+ if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
96
+ }
97
+ } catch { /* 파일 읽기 실패 무시 */ }
98
+ }
99
+ // 실제 rate_limits 발견 → 토큰 데이터 병합 후 즉시 반환
100
+ if (Object.keys(mergedBuckets).length > 0) {
101
+ if (syntheticBucket) {
102
+ const main = mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
103
+ if (main && !main.tokens) main.tokens = syntheticBucket.tokens;
104
+ }
105
+ expireStaleCodexBuckets(mergedBuckets);
106
+ return mergedBuckets;
107
+ }
108
+ }
109
+ // 실제 rate_limits 없음 → 합성 버킷이라도 반환 (행 활성화)
110
+ return syntheticBucket ? { codex: syntheticBucket } : null;
111
+ }
112
+
113
+ export function readCodexRateLimitSnapshot() {
114
+ const cache = readJson(CODEX_QUOTA_CACHE_PATH, null);
115
+ if (!cache?.buckets) {
116
+ return { buckets: null, shouldRefresh: true };
117
+ }
118
+ expireStaleCodexBuckets(cache.buckets);
119
+ const ts = Number(cache.timestamp);
120
+ const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
121
+ const isFresh = ageMs < CODEX_QUOTA_STALE_MS;
122
+ return { buckets: cache.buckets, shouldRefresh: !isFresh };
123
+ }
124
+
125
+ export function refreshCodexRateLimitsCache() {
126
+ const buckets = getCodexRateLimits();
127
+ // buckets가 null이어도 캐시 갱신 (stale 데이터 제거)
128
+ writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
129
+ return buckets;
130
+ }
131
+
132
+ export function scheduleCodexRateLimitRefresh() {
133
+ const scriptPath = process.argv[1];
134
+ if (!scriptPath) return;
135
+ try {
136
+ const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
137
+ detached: true,
138
+ stdio: "ignore",
139
+ windowsHide: true,
140
+ });
141
+ child.unref();
142
+ } catch (spawnErr) {
143
+ // spawn 실패 시 캐시에 에러 힌트 기록
144
+ writeJsonSafe(CODEX_QUOTA_CACHE_PATH, {
145
+ timestamp: Date.now(),
146
+ buckets: null,
147
+ error: true,
148
+ errorHint: String(spawnErr?.message || spawnErr),
149
+ });
150
+ }
151
+ }