triflux 2.4.7 → 3.0.0

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.
@@ -121,6 +121,9 @@ function getClaudeUsageStaleMs() {
121
121
  const CLAUDE_USAGE_429_BACKOFF_MS = 10 * 60 * 1000; // 429 에러 시 10분 backoff
122
122
  const CLAUDE_USAGE_ERROR_BACKOFF_MS = 3 * 60 * 1000; // 기타 에러 시 3분 backoff
123
123
  const CLAUDE_API_TIMEOUT_MS = 10_000;
124
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
125
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
126
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
124
127
  const DEFAULT_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
125
128
  const CODEX_AUTH_PATH = join(homedir(), ".codex", "auth.json");
126
129
  const CODEX_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "codex-rate-limits-cache.json");
@@ -152,6 +155,14 @@ function getGeminiModelLabel(model) {
152
155
  if (model.includes("flash")) return "[Flash3]";
153
156
  return "";
154
157
  }
158
+ // rows 임계값 상수 (selectTier 에서 tier 결정에 사용)
159
+ const ROWS_BUDGET_FULL = 40;
160
+ const ROWS_BUDGET_LARGE = 35;
161
+ const ROWS_BUDGET_MEDIUM = 28;
162
+ const ROWS_BUDGET_SMALL = 22;
163
+ // Codex rate_limits에서 최소 수집할 버킷 수
164
+ const CODEX_MIN_BUCKETS = 2;
165
+
155
166
  const GEMINI_RPM_WINDOW_MS = 60 * 1000; // 60초 슬라이딩 윈도우
156
167
  const GEMINI_QUOTA_STALE_MS = 5 * 60 * 1000; // 5분
157
168
  const GEMINI_SESSION_STALE_MS = 15 * 1000; // 15초
@@ -291,10 +302,10 @@ function selectTier(stdin, claudeUsage = null) {
291
302
  const cols = getTerminalColumns() || 120;
292
303
 
293
304
  let budget;
294
- if (rows >= 40) budget = 6;
295
- else if (rows >= 35) budget = 5;
296
- else if (rows >= 28) budget = 4;
297
- else if (rows >= 22) budget = 3;
305
+ if (rows >= ROWS_BUDGET_FULL) budget = 6;
306
+ else if (rows >= ROWS_BUDGET_LARGE) budget = 5;
307
+ else if (rows >= ROWS_BUDGET_MEDIUM) budget = 4;
308
+ else if (rows >= ROWS_BUDGET_SMALL) budget = 3;
298
309
  else if (rows > 0) budget = 2;
299
310
  else budget = 5; // rows 감지 불가 → 넉넉하게
300
311
 
@@ -437,25 +448,21 @@ function renderAlignedRows(rows) {
437
448
  function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
438
449
  const ctx = getContextPercent(stdin);
439
450
 
440
- // Claude 5h/1w (reset 과거면 0으로 표시)
441
- const cF = claudeUsage
442
- ? (isResetPast(claudeUsage.fiveHourResetsAt) ? 0 : clampPercent(claudeUsage.fiveHourPercent ?? 0))
443
- : null;
444
- const cW = claudeUsage
445
- ? (isResetPast(claudeUsage.weeklyResetsAt) ? 0 : clampPercent(claudeUsage.weeklyPercent ?? 0))
446
- : null;
447
- const cVal = cF != null
448
- ? `${colorByProvider(cF, `${cF}`, claudeOrange)}${dim("/")}${colorByProvider(cW, `${cW}`, claudeOrange)}`
451
+ // Claude 5h/1w (캐시된 그대로 표시, 시간은 advanceToNextCycle이 처리)
452
+ const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
453
+ const cW = claudeUsage?.weeklyPercent != null ? clampPercent(claudeUsage.weeklyPercent) : null;
454
+ const cVal = claudeUsage != null
455
+ ? `${cF != null ? colorByProvider(cF, `${cF}`, claudeOrange) : dim("--")}${dim("/")}${cW != null ? colorByProvider(cW, `${cW}`, claudeOrange) : dim("--")}`
449
456
  : dim("--/--");
450
457
 
451
- // Codex 5h/1w
458
+ // Codex 5h/1w (캐시된 값 그대로 표시)
452
459
  let xVal = dim("--/--");
453
460
  if (codexBuckets) {
454
461
  const mb = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
455
462
  if (mb) {
456
- const xF = isResetPast(mb.primary?.resets_at) ? 0 : clampPercent(mb.primary?.used_percent ?? 0);
457
- const xW = isResetPast(mb.secondary?.resets_at) ? 0 : clampPercent(mb.secondary?.used_percent ?? 0);
458
- xVal = `${colorByProvider(xF, `${xF}`, codexWhite)}${dim("/")}${colorByProvider(xW, `${xW}`, codexWhite)}`;
463
+ const xF = mb.primary?.used_percent != null ? clampPercent(mb.primary.used_percent) : null;
464
+ const xW = mb.secondary?.used_percent != null ? clampPercent(mb.secondary.used_percent) : null;
465
+ xVal = `${xF != null ? colorByProvider(xF, `${xF}`, codexWhite) : dim("--")}${dim("/")}${xW != null ? colorByProvider(xW, `${xW}`, codexWhite) : dim("--")}`;
459
466
  }
460
467
  }
461
468
 
@@ -502,7 +509,7 @@ function normalizeTimeToken(value) {
502
509
  }
503
510
  const dayHour = text.match(/^(\d+)d(\d+)h$/);
504
511
  if (dayHour) {
505
- return `${Number(dayHour[1])}d${Number(dayHour[2])}h`;
512
+ return `${Number(dayHour[1])}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
506
513
  }
507
514
  return text;
508
515
  }
@@ -667,31 +674,15 @@ function parseClaudeUsageResponse(response) {
667
674
  // stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
668
675
  function stripStaleResets(data) {
669
676
  if (!data) return data;
670
- const now = Date.now();
671
677
  const copy = { ...data };
672
-
673
- // 5시간 주기: 과거 reset → 5시간씩 전진하여 미래 시점 추정
674
678
  if (copy.fiveHourResetsAt) {
675
- let t = new Date(copy.fiveHourResetsAt).getTime();
676
- if (t < now) {
677
- const cycle = 5 * 60 * 60 * 1000;
678
- const elapsed = now - t;
679
- t += Math.ceil(elapsed / cycle) * cycle;
680
- copy.fiveHourResetsAt = new Date(t).toISOString();
681
- }
679
+ const t = new Date(copy.fiveHourResetsAt).getTime();
680
+ if (!isNaN(t)) copy.fiveHourResetsAt = new Date(advanceToNextCycle(t, FIVE_HOUR_MS)).toISOString();
682
681
  }
683
-
684
- // 7일 주기: 과거 reset → 7일씩 전진하여 미래 시점 추정
685
682
  if (copy.weeklyResetsAt) {
686
- let t = new Date(copy.weeklyResetsAt).getTime();
687
- if (t < now) {
688
- const cycle = 7 * 24 * 60 * 60 * 1000;
689
- const elapsed = now - t;
690
- t += Math.ceil(elapsed / cycle) * cycle;
691
- copy.weeklyResetsAt = new Date(t).toISOString();
692
- }
683
+ const t = new Date(copy.weeklyResetsAt).getTime();
684
+ if (!isNaN(t)) copy.weeklyResetsAt = new Date(advanceToNextCycle(t, SEVEN_DAY_MS)).toISOString();
693
685
  }
694
-
695
686
  return copy;
696
687
  }
697
688
 
@@ -817,12 +808,15 @@ function scheduleClaudeUsageRefresh() {
817
808
 
818
809
  try {
819
810
  const child = spawn(process.execPath, [scriptPath, CLAUDE_REFRESH_FLAG], {
820
- detached: process.platform !== "win32",
811
+ detached: true,
821
812
  stdio: "ignore",
822
813
  windowsHide: true,
823
814
  });
824
815
  child.unref();
825
- } catch { /* 백그라운드 실행 실패 무시 */ }
816
+ } catch (spawnErr) {
817
+ // spawn 실패 시 에러 유형을 캐시에 기록 (HUD에서 원인 힌트 표시 가능)
818
+ writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
819
+ }
826
820
  }
827
821
 
828
822
  function getContextPercent(stdin) {
@@ -837,11 +831,20 @@ function getContextPercent(stdin) {
837
831
  return clampPercent((totalTokens / capacity) * 100);
838
832
  }
839
833
 
840
- function formatResetRemaining(isoOrUnix) {
834
+ // 과거 리셋 시간 → 다음 주기로 순환하여 미래 시점 반환
835
+ function advanceToNextCycle(epochMs, cycleMs) {
836
+ const now = Date.now();
837
+ if (epochMs >= now || !cycleMs) return epochMs;
838
+ const elapsed = now - epochMs;
839
+ return epochMs + Math.ceil(elapsed / cycleMs) * cycleMs;
840
+ }
841
+
842
+ function formatResetRemaining(isoOrUnix, cycleMs = 0) {
841
843
  if (!isoOrUnix) return "";
842
844
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
843
845
  if (isNaN(d.getTime())) return "";
844
- const diffMs = d.getTime() - Date.now();
846
+ const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
847
+ const diffMs = targetMs - Date.now();
845
848
  if (diffMs <= 0) return "";
846
849
  const totalMinutes = Math.floor(diffMs / 60000);
847
850
  const totalHours = Math.floor(totalMinutes / 60);
@@ -855,11 +858,12 @@ function isResetPast(isoOrUnix) {
855
858
  return !isNaN(d.getTime()) && d.getTime() <= Date.now();
856
859
  }
857
860
 
858
- function formatResetRemainingDayHour(isoOrUnix) {
861
+ function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
859
862
  if (!isoOrUnix) return "";
860
863
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
861
864
  if (isNaN(d.getTime())) return "";
862
- const diffMs = d.getTime() - Date.now();
865
+ const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
866
+ const diffMs = targetMs - Date.now();
863
867
  if (diffMs <= 0) return "";
864
868
  const totalMinutes = Math.floor(diffMs / 60000);
865
869
  const days = Math.floor(totalMinutes / (60 * 24));
@@ -906,20 +910,33 @@ function httpsPost(url, body, accessToken) {
906
910
  });
907
911
  }
908
912
 
913
+ // ============================================================================
914
+ // JWT base64 디코딩 공통 헬퍼
915
+ // ============================================================================
916
+ /**
917
+ * JWT 파일에서 이메일을 추출하는 공통 헬퍼.
918
+ * @param {string|null} idToken - JWT 문자열
919
+ * @returns {string|null} 이메일 또는 null
920
+ */
921
+ function decodeJwtEmail(idToken) {
922
+ if (!idToken) return null;
923
+ const parts = idToken.split(".");
924
+ if (parts.length < 2) return null;
925
+ let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
926
+ while (payload.length % 4) payload += "=";
927
+ try {
928
+ const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
929
+ return decoded.email || null;
930
+ } catch { return null; }
931
+ }
932
+
909
933
  // ============================================================================
910
934
  // Codex JWT에서 이메일 추출
911
935
  // ============================================================================
912
936
  function getCodexEmail() {
913
937
  try {
914
938
  const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
915
- const idToken = auth?.tokens?.id_token;
916
- if (!idToken) return null;
917
- const parts = idToken.split(".");
918
- if (parts.length < 2) return null;
919
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
920
- while (payload.length % 4) payload += "=";
921
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
922
- return decoded.email || null;
939
+ return decodeJwtEmail(auth?.tokens?.id_token);
923
940
  } catch { return null; }
924
941
  }
925
942
 
@@ -929,23 +946,21 @@ function getCodexEmail() {
929
946
  function getGeminiEmail() {
930
947
  try {
931
948
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
932
- const idToken = oauth?.id_token;
933
- if (!idToken) return null;
934
- const parts = idToken.split(".");
935
- if (parts.length < 2) return null;
936
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
937
- while (payload.length % 4) payload += "=";
938
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
939
- return decoded.email || null;
949
+ return decodeJwtEmail(oauth?.id_token);
940
950
  } catch { return null; }
941
951
  }
942
952
 
943
953
  // ============================================================================
944
954
  // Codex 세션 JSONL에서 실제 rate limits 추출
955
+ // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
956
+ // 오늘 날짜의 모든 세션 파일을 스캔해 가장 최신 rate_limits 버킷을 수집한다.
957
+ // (단일 파일 즉시 return 방식에서 → 당일 전체 스캔 후 최신 데이터 우선 병합으로 변경)
945
958
  // ============================================================================
946
959
  function getCodexRateLimits() {
947
960
  const now = new Date();
948
- let todayHasFiles = false;
961
+ let syntheticBucket = null; // 오늘 token_count에서 합성 (행 활성화 + 토큰 데이터용)
962
+
963
+ // 2일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
949
964
  for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
950
965
  const d = new Date(now.getTime() - dayOffset * 86_400_000);
951
966
  const sessDir = join(
@@ -958,18 +973,19 @@ function getCodexRateLimits() {
958
973
  let files;
959
974
  try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
960
975
  catch { continue; }
961
- if (dayOffset === 0 && files.length > 0) todayHasFiles = true;
976
+
977
+ const mergedBuckets = {};
962
978
  for (const file of files) {
963
979
  try {
964
980
  const content = readFileSync(join(sessDir, file), "utf-8");
965
981
  const lines = content.trim().split("\n").reverse();
966
- const buckets = {};
967
982
  for (const line of lines) {
968
983
  try {
969
984
  const evt = JSON.parse(line);
970
985
  const rl = evt?.payload?.rate_limits;
971
- if (rl?.limit_id && !buckets[rl.limit_id]) {
972
- buckets[rl.limit_id] = {
986
+ if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
987
+ // 실제 rate_limits: limit_id 최신 이벤트만 기록
988
+ mergedBuckets[rl.limit_id] = {
973
989
  limitId: rl.limit_id, limitName: rl.limit_name,
974
990
  primary: rl.primary, secondary: rl.secondary,
975
991
  credits: rl.credits,
@@ -977,17 +993,33 @@ function getCodexRateLimits() {
977
993
  contextWindow: evt.payload?.info?.model_context_window,
978
994
  timestamp: evt.timestamp,
979
995
  };
996
+ } else if (dayOffset === 0 && !rl && evt?.payload?.info?.total_token_usage && !syntheticBucket) {
997
+ // 오늘 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용)
998
+ syntheticBucket = {
999
+ limitId: "codex", limitName: "codex-session",
1000
+ primary: null, secondary: null,
1001
+ credits: null,
1002
+ tokens: evt.payload.info.total_token_usage,
1003
+ contextWindow: evt.payload.info.model_context_window,
1004
+ timestamp: evt.timestamp,
1005
+ };
980
1006
  }
981
1007
  } catch { /* 라인 파싱 실패 무시 */ }
982
- if (Object.keys(buckets).length >= 2) break;
1008
+ if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
983
1009
  }
984
- if (Object.keys(buckets).length > 0) return buckets;
985
1010
  } catch { /* 파일 읽기 실패 무시 */ }
986
1011
  }
987
- // 오늘 세션 파일이 존재하지만 rate_limits가 없으면 어제 stale 데이터로 폴백하지 않음
988
- if (todayHasFiles && dayOffset === 0) return null;
1012
+ // 실제 rate_limits 발견 오늘 토큰 데이터 병합 즉시 반환
1013
+ if (Object.keys(mergedBuckets).length > 0) {
1014
+ if (syntheticBucket) {
1015
+ const main = mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
1016
+ if (main && !main.tokens) main.tokens = syntheticBucket.tokens;
1017
+ }
1018
+ return mergedBuckets;
1019
+ }
989
1020
  }
990
- return null;
1021
+ // 실제 rate_limits 없음 → 합성 버킷이라도 반환 (행 활성화)
1022
+ return syntheticBucket ? { codex: syntheticBucket } : null;
991
1023
  }
992
1024
 
993
1025
  // ============================================================================
@@ -1007,8 +1039,28 @@ async function fetchGeminiQuota(accountId, options = {}) {
1007
1039
  return cache;
1008
1040
  }
1009
1041
 
1010
- if (!oauth?.access_token) return cache;
1011
- if (oauth.expiry_date && oauth.expiry_date < Date.now()) return cache; // 만료 시 stale 캐시
1042
+ if (!oauth?.access_token) {
1043
+ // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
1044
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1045
+ ...(cache || {}),
1046
+ timestamp: cache?.timestamp || Date.now(),
1047
+ error: true,
1048
+ errorType: "auth",
1049
+ errorHint: "no access_token in oauth_creds.json",
1050
+ });
1051
+ return cache;
1052
+ }
1053
+ if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
1054
+ // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
1055
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1056
+ ...(cache || {}),
1057
+ timestamp: cache?.timestamp || Date.now(),
1058
+ error: true,
1059
+ errorType: "auth",
1060
+ errorHint: `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
1061
+ });
1062
+ return cache;
1063
+ }
1012
1064
 
1013
1065
  // 3. projectId (캐시 or API)
1014
1066
  const fetchProjectId = async () => {
@@ -1045,7 +1097,18 @@ async function fetchGeminiQuota(accountId, options = {}) {
1045
1097
  );
1046
1098
  }
1047
1099
 
1048
- if (!quotaRes?.buckets) return cache;
1100
+ if (!quotaRes?.buckets) {
1101
+ // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
1102
+ const apiError = quotaRes?.error?.message || quotaRes?.error?.code || quotaRes?.error || "no buckets in response";
1103
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1104
+ ...(cache || {}),
1105
+ timestamp: cache?.timestamp || Date.now(),
1106
+ error: true,
1107
+ errorType: "api",
1108
+ errorHint: String(apiError),
1109
+ });
1110
+ return cache;
1111
+ }
1049
1112
 
1050
1113
  // 5. 캐시 저장
1051
1114
  const result = {
@@ -1120,13 +1183,20 @@ function scheduleGeminiQuotaRefresh(accountId) {
1120
1183
  process.execPath,
1121
1184
  [scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
1122
1185
  {
1123
- detached: process.platform !== "win32",
1186
+ detached: true,
1124
1187
  stdio: "ignore",
1125
1188
  windowsHide: true,
1126
1189
  },
1127
1190
  );
1128
1191
  child.unref();
1129
- } catch { /* 백그라운드 실행 실패 무시 */ }
1192
+ } catch (spawnErr) {
1193
+ // spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
1194
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1195
+ timestamp: Date.now(),
1196
+ error: true,
1197
+ errorHint: String(spawnErr?.message || spawnErr),
1198
+ });
1199
+ }
1130
1200
  }
1131
1201
 
1132
1202
  function readCodexRateLimitSnapshot() {
@@ -1152,12 +1222,20 @@ function scheduleCodexRateLimitRefresh() {
1152
1222
  if (!scriptPath) return;
1153
1223
  try {
1154
1224
  const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
1155
- detached: process.platform !== "win32",
1225
+ detached: true,
1156
1226
  stdio: "ignore",
1157
1227
  windowsHide: true,
1158
1228
  });
1159
1229
  child.unref();
1160
- } catch { /* 백그라운드 실행 실패 무시 */ }
1230
+ } catch (spawnErr) {
1231
+ // spawn 실패 시 캐시에 에러 힌트 기록
1232
+ writeJsonSafe(CODEX_QUOTA_CACHE_PATH, {
1233
+ timestamp: Date.now(),
1234
+ buckets: null,
1235
+ error: true,
1236
+ errorHint: String(spawnErr?.message || spawnErr),
1237
+ });
1238
+ }
1161
1239
  }
1162
1240
 
1163
1241
  function readGeminiSessionSnapshot() {
@@ -1183,7 +1261,7 @@ function scheduleGeminiSessionRefresh() {
1183
1261
  if (!scriptPath) return;
1184
1262
  try {
1185
1263
  const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
1186
- detached: process.platform !== "win32",
1264
+ detached: true,
1187
1265
  stdio: "ignore",
1188
1266
  windowsHide: true,
1189
1267
  });
@@ -1265,13 +1343,14 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1265
1343
  const svSuffix = `${dim("sv:")}${svStr}`;
1266
1344
 
1267
1345
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1268
- const fiveHourPercent = isResetPast(claudeUsage?.fiveHourResetsAt) ? 0 : (claudeUsage?.fiveHourPercent ?? 0);
1269
- const weeklyPercent = isResetPast(claudeUsage?.weeklyResetsAt) ? 0 : (claudeUsage?.weeklyPercent ?? 0);
1346
+ // 캐시된 percent 그대로 사용 (시간 표시는 advanceToNextCycle이 처리)
1347
+ const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
1348
+ const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
1270
1349
  const fiveHourReset = claudeUsage?.fiveHourResetsAt
1271
- ? formatResetRemaining(claudeUsage.fiveHourResetsAt)
1350
+ ? formatResetRemaining(claudeUsage.fiveHourResetsAt, FIVE_HOUR_MS)
1272
1351
  : "n/a";
1273
1352
  const weeklyReset = claudeUsage?.weeklyResetsAt
1274
- ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt)
1353
+ ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt, SEVEN_DAY_MS)
1275
1354
  : "n/a";
1276
1355
 
1277
1356
  const hasData = claudeUsage != null;
@@ -1285,14 +1364,23 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1285
1364
  return [{ prefix, left: quotaSection, right: "" }];
1286
1365
  }
1287
1366
  if (cols < 40) {
1288
- const quotaSection = `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}${dim("/")}` +
1289
- `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)} ` +
1367
+ // null이면 '--' 플레이스홀더, 아니면 실제
1368
+ const fStr = fiveHourPercent != null ? `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}` : dim("--");
1369
+ const wStr = weeklyPercent != null ? `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)}` : dim("--");
1370
+ const quotaSection = `${fStr}${dim("/")}${wStr} ` +
1290
1371
  `${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1291
1372
  return [{ prefix, left: quotaSection, right: "" }];
1292
1373
  }
1293
1374
  // nano: c: 5h 12% 1w 95% sv: 191% ctx:90%
1294
- const quotaSection = `${dim("5h")} ${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1295
- `${dim("1w")} ${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1375
+ // null이면 '--%' 플레이스홀더 표시
1376
+ const fCellNano = fiveHourPercent != null
1377
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1378
+ : dim(formatPlaceholderPercentCell());
1379
+ const wCellNano = weeklyPercent != null
1380
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1381
+ : dim(formatPlaceholderPercentCell());
1382
+ const quotaSection = `${dim("5h")} ${fCellNano} ` +
1383
+ `${dim("1w")} ${wCellNano} ` +
1296
1384
  `${dim("sv:")}${svStr} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1297
1385
  return [{ prefix, left: quotaSection, right: "" }];
1298
1386
  }
@@ -1305,8 +1393,15 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1305
1393
  return [{ prefix, left: quotaSection, right: "" }];
1306
1394
  }
1307
1395
  // compact: c: 5h: 14% 1w: 96% | sv: 191% ctx:43%
1308
- const quotaSection = `${dim("5h:")}${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1309
- `${dim("1w:")}${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1396
+ // null이면 '--%' 플레이스홀더 표시
1397
+ const fCellCmp = fiveHourPercent != null
1398
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1399
+ : dim(formatPlaceholderPercentCell());
1400
+ const wCellCmp = weeklyPercent != null
1401
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1402
+ : dim(formatPlaceholderPercentCell());
1403
+ const quotaSection = `${dim("5h:")}${fCellCmp} ` +
1404
+ `${dim("1w:")}${wCellCmp} ` +
1310
1405
  `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1311
1406
  return [{ prefix, left: quotaSection, right: "" }];
1312
1407
  }
@@ -1321,13 +1416,20 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1321
1416
  return [{ prefix, left: quotaSection, right: contextSection }];
1322
1417
  }
1323
1418
 
1324
- const fiveHourPercentCell = formatPercentCell(fiveHourPercent);
1325
- const weeklyPercentCell = formatPercentCell(weeklyPercent);
1419
+ // null이면 dim 바 + '--%' 플레이스홀더, 아니면 실제 값 표시
1420
+ const fiveHourPercentCell = fiveHourPercent != null
1421
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1422
+ : dim(formatPlaceholderPercentCell());
1423
+ const weeklyPercentCell = weeklyPercent != null
1424
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1425
+ : dim(formatPlaceholderPercentCell());
1426
+ const fiveHourBar = fiveHourPercent != null ? tierBar(fiveHourPercent, CLAUDE_ORANGE) : tierDimBar();
1427
+ const weeklyBar = weeklyPercent != null ? tierBar(weeklyPercent, CLAUDE_ORANGE) : tierDimBar();
1326
1428
  const fiveHourTimeCell = formatTimeCell(fiveHourReset);
1327
1429
  const weeklyTimeCell = formatTimeCellDH(weeklyReset);
1328
- const quotaSection = `${dim("5h:")}${tierBar(fiveHourPercent, CLAUDE_ORANGE)}${colorByProvider(fiveHourPercent, fiveHourPercentCell, claudeOrange)} ` +
1430
+ const quotaSection = `${dim("5h:")}${fiveHourBar}${fiveHourPercentCell} ` +
1329
1431
  `${dim(fiveHourTimeCell)} ` +
1330
- `${dim("1w:")}${tierBar(weeklyPercent, CLAUDE_ORANGE)}${colorByProvider(weeklyPercent, weeklyPercentCell, claudeOrange)} ` +
1432
+ `${dim("1w:")}${weeklyBar}${weeklyPercentCell} ` +
1331
1433
  `${dim(weeklyTimeCell)}`;
1332
1434
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1333
1435
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1368,12 +1470,15 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1368
1470
  if (realQuota?.type === "codex") {
1369
1471
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1370
1472
  if (main) {
1371
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1372
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1473
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1474
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1475
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1476
+ const fCellN = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1477
+ const wCellN = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1373
1478
  if (cols < 40) {
1374
- return { prefix: minPrefix, left: `${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)}${dim("/")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1479
+ return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}${svCompact}`, right: "" };
1375
1480
  }
1376
- return { prefix: minPrefix, left: `${dim("5h")} ${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ${dim("1w")} ${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1481
+ return { prefix: minPrefix, left: `${dim("5h")} ${fCellN} ${dim("1w")} ${wCellN}${svCompact}`, right: "" };
1377
1482
  }
1378
1483
  }
1379
1484
  if (realQuota?.type === "gemini") {
@@ -1393,10 +1498,13 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1393
1498
  if (realQuota?.type === "codex") {
1394
1499
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1395
1500
  if (main) {
1396
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1397
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1398
- quotaSection = `${dim("5h:")}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1399
- `${dim("1w:")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}`;
1501
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1502
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1503
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1504
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1505
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1506
+ quotaSection = `${dim("5h:")}${fCell} ` +
1507
+ `${dim("1w:")}${wCell}`;
1400
1508
  }
1401
1509
  }
1402
1510
  if (realQuota?.type === "gemini") {
@@ -1409,7 +1517,7 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1409
1517
  }
1410
1518
  }
1411
1519
  if (!quotaSection) {
1412
- quotaSection = `${dim("5h:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))}`;
1520
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim("1w:")}${dim(formatPlaceholderPercentCell())}`;
1413
1521
  }
1414
1522
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1415
1523
  // compact: sv + 계정 (모델 라벨 제거)
@@ -1420,13 +1528,18 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1420
1528
  if (realQuota?.type === "codex") {
1421
1529
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1422
1530
  if (main) {
1423
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1424
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1425
- const fiveReset = formatResetRemaining(main.primary?.resets_at) || "n/a";
1426
- const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at) || "n/a";
1427
- quotaSection = `${dim("5h:")}${tierBar(fiveP, provAnsi)}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1531
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1532
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1533
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1534
+ const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
1535
+ const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
1536
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1537
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1538
+ const fBar = fiveP != null ? tierBar(fiveP, provAnsi) : tierDimBar();
1539
+ const wBar = weekP != null ? tierBar(weekP, provAnsi) : tierDimBar();
1540
+ quotaSection = `${dim("5h:")}${fBar}${fCell} ` +
1428
1541
  `${dim(formatTimeCell(fiveReset))} ` +
1429
- `${dim("1w:")}${tierBar(weekP, provAnsi)}${colorByProvider(weekP, formatPercentCell(weekP), provFn)} ` +
1542
+ `${dim("1w:")}${wBar}${wCell} ` +
1430
1543
  `${dim(formatTimeCellDH(weekReset))}`;
1431
1544
  }
1432
1545
  }
@@ -1435,7 +1548,7 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1435
1548
  const bucket = realQuota.quotaBucket;
1436
1549
  if (bucket) {
1437
1550
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1438
- const rstRemaining = formatResetRemaining(bucket.resetTime) || "n/a";
1551
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1439
1552
  quotaSection = `${dim("1d:")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ` +
1440
1553
  `${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1441
1554
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "2.4.7",
3
+ "version": "3.0.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {