@triflux/core 10.57.0 → 10.57.2

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/hud/constants.mjs CHANGED
@@ -17,6 +17,8 @@ export const CLAUDE_BAND_MARKER_DIR = join(
17
17
  export const BAND_FLAG = "--band";
18
18
  export const BAND_HEADER = "tfx-band 1";
19
19
  export const BAND_ALL_TTL_MS = 3 * 60 * 1000;
20
+ // 양옆 여백 4칸과 첫 행 오른쪽 알림 자리 30칸.
21
+ export const STATUSLINE_RESERVE_COLS = 34;
20
22
  export const CLAUDE_BAND_MARKER_TTL_MS = 12 * 60 * 60 * 1000;
21
23
 
22
24
  export const CONTEXT_MONITOR_CACHE_PATH = join(
@@ -24,6 +24,7 @@ import {
24
24
  CODEX_REFRESH_FLAG,
25
25
  getCodexAuthPath,
26
26
  getCodexHome,
27
+ STATUSLINE_RESERVE_COLS,
27
28
  } from "./constants.mjs";
28
29
  import { buildContextUsageView } from "./context-monitor.mjs";
29
30
  import {
@@ -69,11 +70,15 @@ async function main() {
69
70
 
70
71
  // --band: triflux mods 가 입력창 위에 그릴 줄을 만든다. statusLine 은 band 가 다 그리는 세션에서 비운다.
71
72
  const bandMode = process.argv.includes(BAND_FLAG);
73
+ // statusLine 은 엔진이 양옆에 2칸씩 여백을 두고, 첫 행 오른쪽에 알림을 띄운다(공식 문서). 그만큼 좁게 그린다.
74
+ // 알림 자리를 빼고 40칸이 안 남는 좁은 화면은 그대로 두어 원래 축소 단계를 따른다.
75
+ const reserved = Number(process.env.COLUMNS) - STATUSLINE_RESERVE_COLS;
76
+ if (!bandMode && reserved >= 40) process.env.COLUMNS = String(reserved);
72
77
  const stdin = await readStdinJson();
73
78
  const bandState = bandMode ? "none" : readBandState(stdin?.session_id);
74
79
  if (bandState === "all") return;
75
- // mods 가 넘긴 세션 사용량이 있으면 Claude API 를 따로 조회하지 않는다.
76
- const bandClaudeUsage = claudeUsageFromRateLimits(stdin?.claude_rate_limits);
80
+ // Claude Code 가 넘긴 세션 사용량이 있으면 Claude API 를 따로 조회하지 않는다.
81
+ const stdinClaudeUsage = claudeUsageFromStdin(stdin);
77
82
 
78
83
  const { showCodex, antigravityAllowed } = resolveHudCliVisibility();
79
84
  const claudeUsageSnapshot = readClaudeUsageSnapshot();
@@ -84,7 +89,7 @@ async function main() {
84
89
  if (antigravitySnapshot?.shouldRefresh) scheduleAntigravityQuotaRefresh();
85
90
  // 설정이 없는 홈에서는 갱신 프로세스를 시작하지 않는다.
86
91
  if (
87
- !bandClaudeUsage &&
92
+ !stdinClaudeUsage &&
88
93
  claudeUsageSnapshot.shouldRefresh &&
89
94
  existsSync(join(homedir(), ".claude"))
90
95
  ) {
@@ -96,7 +101,7 @@ async function main() {
96
101
 
97
102
  const contextView = buildContextUsageView(stdin);
98
103
  const claudeUsage =
99
- bandClaudeUsage ??
104
+ stdinClaudeUsage ??
100
105
  (claudeUsageSnapshot.data
101
106
  ? { ...claudeUsageSnapshot.data, stale: claudeUsageSnapshot.isStale }
102
107
  : null);
@@ -123,7 +128,12 @@ async function main() {
123
128
  const rows =
124
129
  bandState === "claude"
125
130
  ? []
126
- : getClaudeRows(currentTier, contextView, claudeUsage);
131
+ : getClaudeRows(
132
+ currentTier,
133
+ contextView,
134
+ claudeUsage,
135
+ Number(stdin?.cost?.total_cost_usd ?? Number.NaN),
136
+ );
127
137
  let codexRowIndex = -1;
128
138
  if (showCodex) {
129
139
  codexRowIndex = rows.length;
@@ -191,16 +201,30 @@ function readBandState(sessionId) {
191
201
  }
192
202
  }
193
203
 
194
- // mods 의 $.session.usage() rateLimits 를 HUD 의 Claude 사용량 형식으로 바꾼다.
195
- function claudeUsageFromRateLimits(rateLimits) {
196
- if (!Array.isArray(rateLimits)) return null;
197
- const find = (kind) => rateLimits.find((w) => w?.kind === kind);
198
- const fiveHour = find("five_hour");
199
- const weekly = find("seven_day");
200
- const spendLimit = find("spend_limit");
204
+ // statusLine 입력의 rate_limits(Claude Code 2.1.251+, Pro/Max 와 gateway)를 HUD 의 Claude 사용량 형식으로 바꾼다.
205
+ // 10.57.0 mods 가 넘기던 claude_rate_limits 배열도 받는다.
206
+ function claudeUsageFromStdin(stdin) {
207
+ const windows = {};
208
+ const limits = stdin?.rate_limits;
209
+ if (limits && typeof limits === "object" && !Array.isArray(limits)) {
210
+ for (const kind of ["five_hour", "seven_day", "spend_limit"]) {
211
+ const w = limits[kind];
212
+ if (w)
213
+ windows[kind] = { percent: w.used_percentage, resetsAt: w.resets_at };
214
+ }
215
+ } else if (Array.isArray(stdin?.claude_rate_limits)) {
216
+ for (const w of stdin.claude_rate_limits)
217
+ if (w?.kind)
218
+ windows[w.kind] = { percent: w.percentUsed, resetsAt: w.resetsAt };
219
+ }
220
+ const {
221
+ five_hour: fiveHour,
222
+ seven_day: weekly,
223
+ spend_limit: spendLimit,
224
+ } = windows;
201
225
  if (!fiveHour && !weekly && !spendLimit) return null;
202
226
  const percent = (w) =>
203
- Number.isFinite(w?.percentUsed) ? Math.round(w.percentUsed) : null;
227
+ Number.isFinite(w?.percent) ? Math.round(w.percent) : null;
204
228
  return {
205
229
  fiveHourPercent: percent(fiveHour),
206
230
  weeklyPercent: percent(weekly),
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # statusLine 진입점. triflux mods band 가 HUD 를 입력창 위에 그리는 세션이면 node 를 띄우지 않고 끝낸다.
3
+ # band 는 1분마다 표식에 "all:<시각>" 을 다시 쓰므로, 3분 넘게 멈췄거나 표식이 없으면 HUD 를 그대로 돌린다.
4
+ # 인자 1: node 실행 파일(없으면 PATH 의 node).
5
+ # Claude Code 는 입력을 닫아 주지만, 닫히지 않아도 1초 뒤에는 읽기를 끝낸다.
6
+ # 백그라운드 명령은 stdin 이 /dev/null 로 바뀌므로 fd 3 으로 넘긴다.
7
+ exec 3<&0
8
+ input=$({ cat <&3 & cat_pid=$!; (sleep 1; kill "$cat_pid" 2>/dev/null) >/dev/null 2>&1 & wait "$cat_pid"; })
9
+ exec 3<&-
10
+ sid=$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p' | head -n 1)
11
+ marker="$HOME/.claude/cache/triflux/claude-band/$sid"
12
+ if [ -n "$sid" ] && [ -f "$marker" ] && [ -n "$(find "$marker" -mmin -3 2>/dev/null)" ]; then
13
+ case "$(head -c 4 "$marker")" in all:) exit 0 ;; esac
14
+ fi
15
+ printf '%s' "$input" | "${1:-node}" "$(dirname "$0")/hud-qos-status.mjs"
@@ -26,8 +26,10 @@ import {
26
26
  SPAWN_LOCK_TTL_MS,
27
27
  } from "../constants.mjs";
28
28
  import {
29
+ acquireSpawnLock,
29
30
  advanceToNextCycle,
30
31
  clampPercent,
32
+ parseRetryAfterMs,
31
33
  readJson,
32
34
  writeJsonSafe,
33
35
  } from "../utils.mjs";
@@ -77,10 +79,27 @@ export function computeClaudeUsagePollState({
77
79
  };
78
80
  }
79
81
 
82
+ // 서버가 Retry-After 로 더 기다리라고 하면 따르되, 잘못된 큰 값에 오래 묶이지 않게 1시간에서 자른다.
83
+ const MAX_RETRY_AFTER_MS = 60 * 60 * 1000;
84
+ function withRetryAfter(pollState, retryAfterMs) {
85
+ if (!retryAfterMs) return pollState;
86
+ return {
87
+ ...pollState,
88
+ delayMs: Math.max(
89
+ pollState.delayMs,
90
+ Math.min(retryAfterMs, MAX_RETRY_AFTER_MS),
91
+ ),
92
+ };
93
+ }
94
+
80
95
  function getSnapshotSchedule(cache) {
81
96
  const timestamp = Number(cache?.timestamp);
82
97
  const nextRefreshAt = Number(cache?.nextRefreshAt);
83
- if (Number.isFinite(nextRefreshAt)) {
98
+ // 다음 갱신이 상한보다 멀면 시계가 뒤로 간 것이다. 그대로 두면 갱신이 오래 멈춘다.
99
+ if (
100
+ Number.isFinite(nextRefreshAt) &&
101
+ nextRefreshAt - Date.now() <= MAX_RETRY_AFTER_MS
102
+ ) {
84
103
  return {
85
104
  nextRefreshAt,
86
105
  shouldRefresh: Date.now() >= nextRefreshAt,
@@ -448,7 +467,11 @@ export function fetchClaudeUsageFromApi(accessToken) {
448
467
  resolve({ ok: false, status: 0 });
449
468
  }
450
469
  } else {
451
- resolve({ ok: false, status: res.statusCode });
470
+ resolve({
471
+ ok: false,
472
+ status: res.statusCode,
473
+ retryAfterMs: parseRetryAfterMs(res.headers?.["retry-after"]),
474
+ });
452
475
  }
453
476
  });
454
477
  },
@@ -674,10 +697,13 @@ export async function fetchClaudeUsage(forceRefresh = false) {
674
697
  : "unknown";
675
698
  const pollState =
676
699
  errorType === "rate_limit"
677
- ? computeClaudeUsagePollState({
678
- consecutive429s,
679
- outcome: "rate_limit",
680
- })
700
+ ? withRetryAfter(
701
+ computeClaudeUsagePollState({
702
+ consecutive429s,
703
+ outcome: "rate_limit",
704
+ }),
705
+ result.retryAfterMs,
706
+ )
681
707
  : null;
682
708
  writeClaudeUsageCache(
683
709
  existingSnapshot.data,
@@ -720,11 +746,7 @@ export function scheduleClaudeUsageRefresh() {
720
746
 
721
747
  // 스폰 락: 30초 내 이미 스폰했으면 중복 방지 (첫 설치 시 429 방지)
722
748
  try {
723
- if (existsSync(CLAUDE_REFRESH_LOCK_PATH)) {
724
- const lockAge = Date.now() - readJson(CLAUDE_REFRESH_LOCK_PATH, {}).t;
725
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
726
- }
727
- writeJsonSafe(CLAUDE_REFRESH_LOCK_PATH, { t: Date.now() });
749
+ if (!acquireSpawnLock(CLAUDE_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS)) return;
728
750
  } catch {
729
751
  /* 락 실패 무시 — 스폰 진행 */
730
752
  }
@@ -26,7 +26,12 @@ import {
26
26
  getCodexHome,
27
27
  SPAWN_LOCK_TTL_MS,
28
28
  } from "../constants.mjs";
29
- import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
29
+ import {
30
+ acquireSpawnLock,
31
+ decodeJwtEmail,
32
+ readJson,
33
+ writeJsonSafe,
34
+ } from "../utils.mjs";
30
35
  import {
31
36
  listProbeTargets,
32
37
  probeRateLimitsViaAppServer,
@@ -552,11 +557,7 @@ export function scheduleCodexRateLimitRefresh() {
552
557
 
553
558
  // 스폰 락: 30초 내 이미 스폰했으면 중복 방지
554
559
  try {
555
- if (existsSync(CODEX_REFRESH_LOCK_PATH)) {
556
- const lockAge = Date.now() - readJson(CODEX_REFRESH_LOCK_PATH, {}).t;
557
- if (lockAge < SPAWN_LOCK_TTL_MS) return;
558
- }
559
- writeJsonSafe(CODEX_REFRESH_LOCK_PATH, { t: Date.now() });
560
+ if (!acquireSpawnLock(CODEX_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS)) return;
560
561
  } catch {
561
562
  /* 락 실패 무시 — 스폰 진행 */
562
563
  }
@@ -10,7 +10,12 @@ import {
10
10
  ANTIGRAVITY_SIGNED_IN_GRACE_MS,
11
11
  SPAWN_LOCK_TTL_MS,
12
12
  } from "../constants.mjs";
13
- import { decodeJwtEmail, readJson, writeJsonSafe } from "../utils.mjs";
13
+ import {
14
+ acquireSpawnLock,
15
+ decodeJwtEmail,
16
+ readJson,
17
+ writeJsonSafe,
18
+ } from "../utils.mjs";
14
19
 
15
20
  function getCredentialEmail(credential) {
16
21
  return (
@@ -140,9 +145,8 @@ export function scheduleAntigravityQuotaRefresh() {
140
145
  isGcpProjectAuth()
141
146
  )
142
147
  return;
143
- const lock = readJson(ANTIGRAVITY_REFRESH_LOCK_PATH, null);
144
- if (Date.now() - Number(lock?.t || 0) < SPAWN_LOCK_TTL_MS) return;
145
- writeJsonSafe(ANTIGRAVITY_REFRESH_LOCK_PATH, { t: Date.now() });
148
+ if (!acquireSpawnLock(ANTIGRAVITY_REFRESH_LOCK_PATH, SPAWN_LOCK_TTL_MS))
149
+ return;
146
150
  try {
147
151
  const child = spawn(
148
152
  process.execPath,
package/hud/renderers.mjs CHANGED
@@ -177,9 +177,20 @@ function spendOnlyPercent(claudeUsage) {
177
177
  // ============================================================================
178
178
  // Claude 행 렌더러
179
179
  // ============================================================================
180
- export function getClaudeRows(currentTier, contextView, claudeUsage) {
180
+ export function getClaudeRows(
181
+ currentTier,
182
+ contextView,
183
+ claudeUsage,
184
+ costUsd = null,
185
+ ) {
181
186
  const ctxView = contextView || buildContextUsageView({});
182
187
  const prefix = `${bold(claudeOrange("c"))}:`;
188
+ // 이번 세션 비용. 폭이 넉넉한 full, compact 에서만 CTX 뒤에 붙인다.
189
+ const costText =
190
+ Number.isFinite(costUsd) &&
191
+ (currentTier === "full" || currentTier === "compact")
192
+ ? ` ${dim(`$${costUsd.toFixed(2)}`)}`
193
+ : "";
183
194
  const spend = spendOnlyPercent(claudeUsage);
184
195
  if (spend != null) {
185
196
  if (currentTier === "nano" || currentTier === "micro") {
@@ -188,7 +199,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
188
199
  }
189
200
  const pct = colorByProvider(spend, formatPercentCell(spend), claudeOrange);
190
201
  const left = `${dim("$:")}${tierBar(currentTier, spend, CLAUDE_ORANGE)}${pct}`;
191
- const right = `${dim("CTX:")}${contextPercentText(ctxView)}`;
202
+ const right = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}`;
192
203
  return [{ prefix, left, right }];
193
204
  }
194
205
  // API 실측 데이터
@@ -256,7 +267,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
256
267
  const warning = ctxView.warningTag
257
268
  ? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
258
269
  : "";
259
- const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${warning}`;
270
+ const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}${warning}`;
260
271
  return [{ prefix, left: quotaSection, right: contextSection }];
261
272
  }
262
273
 
@@ -266,7 +277,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
266
277
  const warning = ctxView.warningTag
267
278
  ? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
268
279
  : "";
269
- const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${warning}`;
280
+ const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}${warning}`;
270
281
  return [{ prefix, left: quotaSection, right: contextSection }];
271
282
  }
272
283
 
package/hud/utils.mjs CHANGED
@@ -7,32 +7,40 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  import { PERCENT_CELL_WIDTH, TIME_CELL_INNER_WIDTH } from "./constants.mjs";
9
9
 
10
+ const STDIN_MAX_BYTES = 1024 * 1024;
11
+
12
+ // 입력이 JSON 으로 읽히는 순간 끝낸다. Claude Code 가 stdin 을 늦게 닫아도 기다리지 않는다.
10
13
  export async function readStdinJson() {
11
14
  if (process.stdin.isTTY) return {};
12
15
  return new Promise((resolve) => {
13
- const timeout = setTimeout(() => {
14
- process.stdin.destroy();
15
- resolve({});
16
- }, 200);
17
- const chunks = [];
18
- process.stdin.on("data", (chunk) => chunks.push(chunk));
19
- process.stdin.on("end", () => {
16
+ let raw = "";
17
+ let done = false;
18
+ const finish = (value) => {
19
+ if (done) return;
20
+ done = true;
20
21
  clearTimeout(timeout);
21
- const raw = chunks.join("").trim();
22
- if (!raw) {
23
- resolve({});
24
- return;
25
- }
22
+ process.stdin.removeAllListeners("data");
23
+ process.stdin.destroy();
24
+ resolve(value);
25
+ };
26
+ const tryParse = () => {
26
27
  try {
27
- resolve(JSON.parse(raw));
28
+ return JSON.parse(raw);
28
29
  } catch {
29
- resolve({});
30
+ return null;
30
31
  }
32
+ };
33
+ // 200ms 안에 다 못 받으면 받은 만큼으로 판정한다.
34
+ const timeout = setTimeout(() => finish(tryParse() ?? {}), 200);
35
+ process.stdin.setEncoding("utf8");
36
+ process.stdin.on("data", (chunk) => {
37
+ raw += chunk;
38
+ if (raw.length > STDIN_MAX_BYTES) return finish({});
39
+ const parsed = tryParse();
40
+ if (parsed) finish(parsed);
31
41
  });
32
- process.stdin.on("error", () => {
33
- clearTimeout(timeout);
34
- resolve({});
35
- });
42
+ process.stdin.on("end", () => finish(tryParse() ?? {}));
43
+ process.stdin.on("error", () => finish({}));
36
44
  process.stdin.resume();
37
45
  });
38
46
  }
@@ -56,6 +64,28 @@ export function writeJsonSafe(filePath, data) {
56
64
  }
57
65
 
58
66
  // .omc/ → .claude/cache/ 마이그레이션: 새 경로 우선, 없으면 레거시 읽고 복사
67
+ // 갱신 프로세스를 겹쳐 띄우지 않는 락. 만료 시각을 적고, 남은 시간이 ttl 보다 길면(시계가 뒤로 감) 깨진 락으로 본다.
68
+ // 예전 { t } 락도 읽는다.
69
+ export function acquireSpawnLock(lockPath, ttlMs, now = Date.now()) {
70
+ const lock = readJson(lockPath, null);
71
+ const until = Number(lock?.until ?? Number(lock?.t) + ttlMs);
72
+ const remaining = until - now;
73
+ if (Number.isFinite(remaining) && remaining > 0 && remaining <= ttlMs)
74
+ return false;
75
+ writeJsonSafe(lockPath, { until: now + ttlMs });
76
+ return true;
77
+ }
78
+
79
+ // Retry-After 헤더(초 또는 HTTP 날짜)를 ms 로 바꾼다. 없거나 지난 값이면 null.
80
+ export function parseRetryAfterMs(value, now = Date.now()) {
81
+ if (value == null || value === "") return null;
82
+ const seconds = Number(value);
83
+ const ms = Number.isFinite(seconds)
84
+ ? seconds * 1000
85
+ : Date.parse(value) - now;
86
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
87
+ }
88
+
59
89
  export function readJsonMigrate(newPath, legacyPath, fallback) {
60
90
  const data = readJson(newPath, null);
61
91
  if (data != null) return data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.57.0",
3
+ "version": "10.57.2",
4
4
  "description": "triflux core — CLI routing, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",