triflux 2.4.6 → 2.5.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.
@@ -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,21 +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
441
- const cF = claudeUsage ? clampPercent(claudeUsage.fiveHourPercent ?? 0) : null;
442
- const cW = claudeUsage ? clampPercent(claudeUsage.weeklyPercent ?? 0) : null;
443
- const cVal = cF != null
444
- ? `${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("--")}`
445
456
  : dim("--/--");
446
457
 
447
- // Codex 5h/1w
458
+ // Codex 5h/1w (캐시된 값 그대로 표시)
448
459
  let xVal = dim("--/--");
449
460
  if (codexBuckets) {
450
461
  const mb = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
451
462
  if (mb) {
452
- const xF = isResetPast(mb.primary?.resets_at) ? 0 : clampPercent(mb.primary?.used_percent ?? 0);
453
- const xW = isResetPast(mb.secondary?.resets_at) ? 0 : clampPercent(mb.secondary?.used_percent ?? 0);
454
- 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("--")}`;
455
466
  }
456
467
  }
457
468
 
@@ -663,31 +674,15 @@ function parseClaudeUsageResponse(response) {
663
674
  // stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
664
675
  function stripStaleResets(data) {
665
676
  if (!data) return data;
666
- const now = Date.now();
667
677
  const copy = { ...data };
668
-
669
- // 5시간 주기: 과거 reset → 5시간씩 전진하여 미래 시점 추정
670
678
  if (copy.fiveHourResetsAt) {
671
- let t = new Date(copy.fiveHourResetsAt).getTime();
672
- if (t < now) {
673
- const cycle = 5 * 60 * 60 * 1000;
674
- const elapsed = now - t;
675
- t += Math.ceil(elapsed / cycle) * cycle;
676
- copy.fiveHourResetsAt = new Date(t).toISOString();
677
- }
679
+ const t = new Date(copy.fiveHourResetsAt).getTime();
680
+ if (!isNaN(t)) copy.fiveHourResetsAt = new Date(advanceToNextCycle(t, FIVE_HOUR_MS)).toISOString();
678
681
  }
679
-
680
- // 7일 주기: 과거 reset → 7일씩 전진하여 미래 시점 추정
681
682
  if (copy.weeklyResetsAt) {
682
- let t = new Date(copy.weeklyResetsAt).getTime();
683
- if (t < now) {
684
- const cycle = 7 * 24 * 60 * 60 * 1000;
685
- const elapsed = now - t;
686
- t += Math.ceil(elapsed / cycle) * cycle;
687
- copy.weeklyResetsAt = new Date(t).toISOString();
688
- }
683
+ const t = new Date(copy.weeklyResetsAt).getTime();
684
+ if (!isNaN(t)) copy.weeklyResetsAt = new Date(advanceToNextCycle(t, SEVEN_DAY_MS)).toISOString();
689
685
  }
690
-
691
686
  return copy;
692
687
  }
693
688
 
@@ -813,12 +808,15 @@ function scheduleClaudeUsageRefresh() {
813
808
 
814
809
  try {
815
810
  const child = spawn(process.execPath, [scriptPath, CLAUDE_REFRESH_FLAG], {
816
- detached: process.platform !== "win32",
811
+ detached: true,
817
812
  stdio: "ignore",
818
813
  windowsHide: true,
819
814
  });
820
815
  child.unref();
821
- } catch { /* 백그라운드 실행 실패 무시 */ }
816
+ } catch (spawnErr) {
817
+ // spawn 실패 시 에러 유형을 캐시에 기록 (HUD에서 원인 힌트 표시 가능)
818
+ writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
819
+ }
822
820
  }
823
821
 
824
822
  function getContextPercent(stdin) {
@@ -833,12 +831,21 @@ function getContextPercent(stdin) {
833
831
  return clampPercent((totalTokens / capacity) * 100);
834
832
  }
835
833
 
836
- 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) {
837
843
  if (!isoOrUnix) return "";
838
844
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
839
845
  if (isNaN(d.getTime())) return "";
840
- const diffMs = d.getTime() - Date.now();
841
- if (diffMs <= 0) return "0h00m";
846
+ const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
847
+ const diffMs = targetMs - Date.now();
848
+ if (diffMs <= 0) return "";
842
849
  const totalMinutes = Math.floor(diffMs / 60000);
843
850
  const totalHours = Math.floor(totalMinutes / 60);
844
851
  const minutes = totalMinutes % 60;
@@ -851,12 +858,13 @@ function isResetPast(isoOrUnix) {
851
858
  return !isNaN(d.getTime()) && d.getTime() <= Date.now();
852
859
  }
853
860
 
854
- function formatResetRemainingDayHour(isoOrUnix) {
861
+ function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
855
862
  if (!isoOrUnix) return "";
856
863
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
857
864
  if (isNaN(d.getTime())) return "";
858
- const diffMs = d.getTime() - Date.now();
859
- if (diffMs <= 0) return "0d0h";
865
+ const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
866
+ const diffMs = targetMs - Date.now();
867
+ if (diffMs <= 0) return "";
860
868
  const totalMinutes = Math.floor(diffMs / 60000);
861
869
  const days = Math.floor(totalMinutes / (60 * 24));
862
870
  const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
@@ -902,20 +910,33 @@ function httpsPost(url, body, accessToken) {
902
910
  });
903
911
  }
904
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
+
905
933
  // ============================================================================
906
934
  // Codex JWT에서 이메일 추출
907
935
  // ============================================================================
908
936
  function getCodexEmail() {
909
937
  try {
910
938
  const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
911
- const idToken = auth?.tokens?.id_token;
912
- if (!idToken) return null;
913
- const parts = idToken.split(".");
914
- if (parts.length < 2) return null;
915
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
916
- while (payload.length % 4) payload += "=";
917
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
918
- return decoded.email || null;
939
+ return decodeJwtEmail(auth?.tokens?.id_token);
919
940
  } catch { return null; }
920
941
  }
921
942
 
@@ -925,22 +946,19 @@ function getCodexEmail() {
925
946
  function getGeminiEmail() {
926
947
  try {
927
948
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
928
- const idToken = oauth?.id_token;
929
- if (!idToken) return null;
930
- const parts = idToken.split(".");
931
- if (parts.length < 2) return null;
932
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
933
- while (payload.length % 4) payload += "=";
934
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
935
- return decoded.email || null;
949
+ return decodeJwtEmail(oauth?.id_token);
936
950
  } catch { return null; }
937
951
  }
938
952
 
939
953
  // ============================================================================
940
954
  // Codex 세션 JSONL에서 실제 rate limits 추출
955
+ // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
956
+ // 오늘 날짜의 모든 세션 파일을 스캔해 가장 최신 rate_limits 버킷을 수집한다.
957
+ // (단일 파일 즉시 return 방식에서 → 당일 전체 스캔 후 최신 데이터 우선 병합으로 변경)
941
958
  // ============================================================================
942
959
  function getCodexRateLimits() {
943
960
  const now = new Date();
961
+ let todayHasFiles = false;
944
962
  for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
945
963
  const d = new Date(now.getTime() - dayOffset * 86_400_000);
946
964
  const sessDir = join(
@@ -953,17 +971,22 @@ function getCodexRateLimits() {
953
971
  let files;
954
972
  try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
955
973
  catch { continue; }
974
+ if (dayOffset === 0 && files.length > 0) todayHasFiles = true;
975
+
976
+ // 당일 모든 세션 파일을 스캔해 limit_id별 가장 최신 버킷을 병합
977
+ // (파일 목록은 이름 역순 정렬 → 최신 세션 우선)
978
+ const mergedBuckets = {};
956
979
  for (const file of files) {
957
980
  try {
958
981
  const content = readFileSync(join(sessDir, file), "utf-8");
959
982
  const lines = content.trim().split("\n").reverse();
960
- const buckets = {};
961
983
  for (const line of lines) {
962
984
  try {
963
985
  const evt = JSON.parse(line);
964
986
  const rl = evt?.payload?.rate_limits;
965
- if (rl?.limit_id && !buckets[rl.limit_id]) {
966
- buckets[rl.limit_id] = {
987
+ if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
988
+ // limit_id별로 발견(=해당 세션의 가장 최신 이벤트)만 기록
989
+ mergedBuckets[rl.limit_id] = {
967
990
  limitId: rl.limit_id, limitName: rl.limit_name,
968
991
  primary: rl.primary, secondary: rl.secondary,
969
992
  credits: rl.credits,
@@ -973,11 +996,14 @@ function getCodexRateLimits() {
973
996
  };
974
997
  }
975
998
  } catch { /* 라인 파싱 실패 무시 */ }
976
- if (Object.keys(buckets).length >= 2) break;
999
+ if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
977
1000
  }
978
- if (Object.keys(buckets).length > 0) return buckets;
979
1001
  } catch { /* 파일 읽기 실패 무시 */ }
980
1002
  }
1003
+ if (Object.keys(mergedBuckets).length > 0) return mergedBuckets;
1004
+
1005
+ // 오늘 세션 파일이 존재하지만 rate_limits가 없으면 어제 stale 데이터로 폴백하지 않음
1006
+ if (todayHasFiles && dayOffset === 0) return null;
981
1007
  }
982
1008
  return null;
983
1009
  }
@@ -999,8 +1025,28 @@ async function fetchGeminiQuota(accountId, options = {}) {
999
1025
  return cache;
1000
1026
  }
1001
1027
 
1002
- if (!oauth?.access_token) return cache;
1003
- if (oauth.expiry_date && oauth.expiry_date < Date.now()) return cache; // 만료 시 stale 캐시
1028
+ if (!oauth?.access_token) {
1029
+ // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
1030
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1031
+ ...(cache || {}),
1032
+ timestamp: cache?.timestamp || Date.now(),
1033
+ error: true,
1034
+ errorType: "auth",
1035
+ errorHint: "no access_token in oauth_creds.json",
1036
+ });
1037
+ return cache;
1038
+ }
1039
+ if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
1040
+ // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
1041
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1042
+ ...(cache || {}),
1043
+ timestamp: cache?.timestamp || Date.now(),
1044
+ error: true,
1045
+ errorType: "auth",
1046
+ errorHint: `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
1047
+ });
1048
+ return cache;
1049
+ }
1004
1050
 
1005
1051
  // 3. projectId (캐시 or API)
1006
1052
  const fetchProjectId = async () => {
@@ -1037,7 +1083,18 @@ async function fetchGeminiQuota(accountId, options = {}) {
1037
1083
  );
1038
1084
  }
1039
1085
 
1040
- if (!quotaRes?.buckets) return cache;
1086
+ if (!quotaRes?.buckets) {
1087
+ // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
1088
+ const apiError = quotaRes?.error?.message || quotaRes?.error?.code || quotaRes?.error || "no buckets in response";
1089
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1090
+ ...(cache || {}),
1091
+ timestamp: cache?.timestamp || Date.now(),
1092
+ error: true,
1093
+ errorType: "api",
1094
+ errorHint: String(apiError),
1095
+ });
1096
+ return cache;
1097
+ }
1041
1098
 
1042
1099
  // 5. 캐시 저장
1043
1100
  const result = {
@@ -1112,13 +1169,20 @@ function scheduleGeminiQuotaRefresh(accountId) {
1112
1169
  process.execPath,
1113
1170
  [scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
1114
1171
  {
1115
- detached: process.platform !== "win32",
1172
+ detached: true,
1116
1173
  stdio: "ignore",
1117
1174
  windowsHide: true,
1118
1175
  },
1119
1176
  );
1120
1177
  child.unref();
1121
- } catch { /* 백그라운드 실행 실패 무시 */ }
1178
+ } catch (spawnErr) {
1179
+ // spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
1180
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1181
+ timestamp: Date.now(),
1182
+ error: true,
1183
+ errorHint: String(spawnErr?.message || spawnErr),
1184
+ });
1185
+ }
1122
1186
  }
1123
1187
 
1124
1188
  function readCodexRateLimitSnapshot() {
@@ -1134,7 +1198,7 @@ function readCodexRateLimitSnapshot() {
1134
1198
 
1135
1199
  function refreshCodexRateLimitsCache() {
1136
1200
  const buckets = getCodexRateLimits();
1137
- if (!buckets) return null;
1201
+ // buckets null이어도 캐시 갱신 (stale 데이터 제거)
1138
1202
  writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
1139
1203
  return buckets;
1140
1204
  }
@@ -1144,12 +1208,20 @@ function scheduleCodexRateLimitRefresh() {
1144
1208
  if (!scriptPath) return;
1145
1209
  try {
1146
1210
  const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
1147
- detached: process.platform !== "win32",
1211
+ detached: true,
1148
1212
  stdio: "ignore",
1149
1213
  windowsHide: true,
1150
1214
  });
1151
1215
  child.unref();
1152
- } catch { /* 백그라운드 실행 실패 무시 */ }
1216
+ } catch (spawnErr) {
1217
+ // spawn 실패 시 캐시에 에러 힌트 기록
1218
+ writeJsonSafe(CODEX_QUOTA_CACHE_PATH, {
1219
+ timestamp: Date.now(),
1220
+ buckets: null,
1221
+ error: true,
1222
+ errorHint: String(spawnErr?.message || spawnErr),
1223
+ });
1224
+ }
1153
1225
  }
1154
1226
 
1155
1227
  function readGeminiSessionSnapshot() {
@@ -1175,7 +1247,7 @@ function scheduleGeminiSessionRefresh() {
1175
1247
  if (!scriptPath) return;
1176
1248
  try {
1177
1249
  const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
1178
- detached: process.platform !== "win32",
1250
+ detached: true,
1179
1251
  stdio: "ignore",
1180
1252
  windowsHide: true,
1181
1253
  });
@@ -1257,13 +1329,14 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1257
1329
  const svSuffix = `${dim("sv:")}${svStr}`;
1258
1330
 
1259
1331
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1260
- const fiveHourPercent = claudeUsage?.fiveHourPercent ?? 0;
1261
- const weeklyPercent = claudeUsage?.weeklyPercent ?? 0;
1332
+ // 캐시된 percent 그대로 사용 (시간 표시는 advanceToNextCycle이 처리)
1333
+ const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
1334
+ const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
1262
1335
  const fiveHourReset = claudeUsage?.fiveHourResetsAt
1263
- ? formatResetRemaining(claudeUsage.fiveHourResetsAt)
1336
+ ? formatResetRemaining(claudeUsage.fiveHourResetsAt, FIVE_HOUR_MS)
1264
1337
  : "n/a";
1265
1338
  const weeklyReset = claudeUsage?.weeklyResetsAt
1266
- ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt)
1339
+ ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt, SEVEN_DAY_MS)
1267
1340
  : "n/a";
1268
1341
 
1269
1342
  const hasData = claudeUsage != null;
@@ -1277,14 +1350,23 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1277
1350
  return [{ prefix, left: quotaSection, right: "" }];
1278
1351
  }
1279
1352
  if (cols < 40) {
1280
- const quotaSection = `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}${dim("/")}` +
1281
- `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)} ` +
1353
+ // null이면 '--' 플레이스홀더, 아니면 실제
1354
+ const fStr = fiveHourPercent != null ? `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}` : dim("--");
1355
+ const wStr = weeklyPercent != null ? `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)}` : dim("--");
1356
+ const quotaSection = `${fStr}${dim("/")}${wStr} ` +
1282
1357
  `${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1283
1358
  return [{ prefix, left: quotaSection, right: "" }];
1284
1359
  }
1285
1360
  // nano: c: 5h 12% 1w 95% sv: 191% ctx:90%
1286
- const quotaSection = `${dim("5h")} ${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1287
- `${dim("1w")} ${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1361
+ // null이면 '--%' 플레이스홀더 표시
1362
+ const fCellNano = fiveHourPercent != null
1363
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1364
+ : dim(formatPlaceholderPercentCell());
1365
+ const wCellNano = weeklyPercent != null
1366
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1367
+ : dim(formatPlaceholderPercentCell());
1368
+ const quotaSection = `${dim("5h")} ${fCellNano} ` +
1369
+ `${dim("1w")} ${wCellNano} ` +
1288
1370
  `${dim("sv:")}${svStr} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1289
1371
  return [{ prefix, left: quotaSection, right: "" }];
1290
1372
  }
@@ -1297,8 +1379,15 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1297
1379
  return [{ prefix, left: quotaSection, right: "" }];
1298
1380
  }
1299
1381
  // compact: c: 5h: 14% 1w: 96% | sv: 191% ctx:43%
1300
- const quotaSection = `${dim("5h:")}${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1301
- `${dim("1w:")}${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1382
+ // null이면 '--%' 플레이스홀더 표시
1383
+ const fCellCmp = fiveHourPercent != null
1384
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1385
+ : dim(formatPlaceholderPercentCell());
1386
+ const wCellCmp = weeklyPercent != null
1387
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1388
+ : dim(formatPlaceholderPercentCell());
1389
+ const quotaSection = `${dim("5h:")}${fCellCmp} ` +
1390
+ `${dim("1w:")}${wCellCmp} ` +
1302
1391
  `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1303
1392
  return [{ prefix, left: quotaSection, right: "" }];
1304
1393
  }
@@ -1313,13 +1402,20 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1313
1402
  return [{ prefix, left: quotaSection, right: contextSection }];
1314
1403
  }
1315
1404
 
1316
- const fiveHourPercentCell = formatPercentCell(fiveHourPercent);
1317
- const weeklyPercentCell = formatPercentCell(weeklyPercent);
1405
+ // null이면 dim 바 + '--%' 플레이스홀더, 아니면 실제 값 표시
1406
+ const fiveHourPercentCell = fiveHourPercent != null
1407
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1408
+ : dim(formatPlaceholderPercentCell());
1409
+ const weeklyPercentCell = weeklyPercent != null
1410
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1411
+ : dim(formatPlaceholderPercentCell());
1412
+ const fiveHourBar = fiveHourPercent != null ? tierBar(fiveHourPercent, CLAUDE_ORANGE) : tierDimBar();
1413
+ const weeklyBar = weeklyPercent != null ? tierBar(weeklyPercent, CLAUDE_ORANGE) : tierDimBar();
1318
1414
  const fiveHourTimeCell = formatTimeCell(fiveHourReset);
1319
1415
  const weeklyTimeCell = formatTimeCellDH(weeklyReset);
1320
- const quotaSection = `${dim("5h:")}${tierBar(fiveHourPercent, CLAUDE_ORANGE)}${colorByProvider(fiveHourPercent, fiveHourPercentCell, claudeOrange)} ` +
1416
+ const quotaSection = `${dim("5h:")}${fiveHourBar}${fiveHourPercentCell} ` +
1321
1417
  `${dim(fiveHourTimeCell)} ` +
1322
- `${dim("1w:")}${tierBar(weeklyPercent, CLAUDE_ORANGE)}${colorByProvider(weeklyPercent, weeklyPercentCell, claudeOrange)} ` +
1418
+ `${dim("1w:")}${weeklyBar}${weeklyPercentCell} ` +
1323
1419
  `${dim(weeklyTimeCell)}`;
1324
1420
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1325
1421
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1360,12 +1456,15 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1360
1456
  if (realQuota?.type === "codex") {
1361
1457
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1362
1458
  if (main) {
1363
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1364
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1459
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1460
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1461
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1462
+ const fCellN = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1463
+ const wCellN = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1365
1464
  if (cols < 40) {
1366
- return { prefix: minPrefix, left: `${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)}${dim("/")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1465
+ return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}${svCompact}`, right: "" };
1367
1466
  }
1368
- return { prefix: minPrefix, left: `${dim("5h")} ${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ${dim("1w")} ${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1467
+ return { prefix: minPrefix, left: `${dim("5h")} ${fCellN} ${dim("1w")} ${wCellN}${svCompact}`, right: "" };
1369
1468
  }
1370
1469
  }
1371
1470
  if (realQuota?.type === "gemini") {
@@ -1385,10 +1484,13 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1385
1484
  if (realQuota?.type === "codex") {
1386
1485
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1387
1486
  if (main) {
1388
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1389
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1390
- quotaSection = `${dim("5h:")}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1391
- `${dim("1w:")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}`;
1487
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1488
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1489
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1490
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1491
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1492
+ quotaSection = `${dim("5h:")}${fCell} ` +
1493
+ `${dim("1w:")}${wCell}`;
1392
1494
  }
1393
1495
  }
1394
1496
  if (realQuota?.type === "gemini") {
@@ -1401,7 +1503,7 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1401
1503
  }
1402
1504
  }
1403
1505
  if (!quotaSection) {
1404
- quotaSection = `${dim("5h:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))}`;
1506
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim("1w:")}${dim(formatPlaceholderPercentCell())}`;
1405
1507
  }
1406
1508
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1407
1509
  // compact: sv + 계정 (모델 라벨 제거)
@@ -1412,13 +1514,18 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1412
1514
  if (realQuota?.type === "codex") {
1413
1515
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1414
1516
  if (main) {
1415
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1416
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1417
- const fiveReset = formatResetRemaining(main.primary?.resets_at) || "n/a";
1418
- const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at) || "n/a";
1419
- quotaSection = `${dim("5h:")}${tierBar(fiveP, provAnsi)}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1517
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1518
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1519
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1520
+ const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
1521
+ const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
1522
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1523
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1524
+ const fBar = fiveP != null ? tierBar(fiveP, provAnsi) : tierDimBar();
1525
+ const wBar = weekP != null ? tierBar(weekP, provAnsi) : tierDimBar();
1526
+ quotaSection = `${dim("5h:")}${fBar}${fCell} ` +
1420
1527
  `${dim(formatTimeCell(fiveReset))} ` +
1421
- `${dim("1w:")}${tierBar(weekP, provAnsi)}${colorByProvider(weekP, formatPercentCell(weekP), provFn)} ` +
1528
+ `${dim("1w:")}${wBar}${wCell} ` +
1422
1529
  `${dim(formatTimeCellDH(weekReset))}`;
1423
1530
  }
1424
1531
  }
@@ -1427,7 +1534,7 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1427
1534
  const bucket = realQuota.quotaBucket;
1428
1535
  if (bucket) {
1429
1536
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1430
- const rstRemaining = formatResetRemaining(bucket.resetTime) || "n/a";
1537
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1431
1538
  quotaSection = `${dim("1d:")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ` +
1432
1539
  `${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1433
1540
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "2.4.6",
3
+ "version": "2.5.1",
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": {