triflux 10.56.0 → 10.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/triflux.mjs +6 -5
- package/hud/constants.mjs +5 -0
- package/hud/hud-qos-status.mjs +87 -19
- package/hud/hud-statusline.sh +15 -0
- package/hud/providers/claude.mjs +33 -11
- package/hud/providers/codex.mjs +7 -6
- package/hud/providers/gemini.mjs +8 -4
- package/hud/renderers.mjs +39 -6
- package/hud/utils.mjs +48 -18
- package/package.json +1 -1
- package/scripts/setup.mjs +57 -21
package/bin/triflux.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
listInlineProfileNames,
|
|
75
75
|
MODS_UPDATE_COMMAND,
|
|
76
76
|
persistSettings,
|
|
77
|
+
planStatusLine,
|
|
77
78
|
REQUIRED_CODEX_PROFILES,
|
|
78
79
|
retireOldInstallFiles,
|
|
79
80
|
runConsentSteps,
|
|
@@ -1139,11 +1140,11 @@ function previewStatusLineAction() {
|
|
|
1139
1140
|
return {
|
|
1140
1141
|
type: "statusLine",
|
|
1141
1142
|
path: settingsPath,
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1143
|
+
// setup 은 statusLine 을 다루기 전에 HUD 파일(래퍼 포함)을 먼저 복사한다.
|
|
1144
|
+
change: planStatusLine(settings, {
|
|
1145
|
+
hudPath,
|
|
1146
|
+
willSyncWrapper: existsSync(join(PKG_ROOT, "hud", "hud-statusline.sh")),
|
|
1147
|
+
}),
|
|
1147
1148
|
current: currentCmd || null,
|
|
1148
1149
|
target: hudPath,
|
|
1149
1150
|
};
|
package/hud/constants.mjs
CHANGED
|
@@ -14,6 +14,11 @@ export const CLAUDE_BAND_MARKER_DIR = join(
|
|
|
14
14
|
"triflux",
|
|
15
15
|
"claude-band",
|
|
16
16
|
);
|
|
17
|
+
export const BAND_FLAG = "--band";
|
|
18
|
+
export const BAND_HEADER = "tfx-band 1";
|
|
19
|
+
export const BAND_ALL_TTL_MS = 3 * 60 * 1000;
|
|
20
|
+
// 양옆 여백 4칸과 첫 행 오른쪽 알림 자리 30칸.
|
|
21
|
+
export const STATUSLINE_RESERVE_COLS = 34;
|
|
17
22
|
export const CLAUDE_BAND_MARKER_TTL_MS = 12 * 60 * 60 * 1000;
|
|
18
23
|
|
|
19
24
|
export const CONTEXT_MONITOR_CACHE_PATH = join(
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -15,12 +15,16 @@ import {
|
|
|
15
15
|
} from "./colors.mjs";
|
|
16
16
|
import {
|
|
17
17
|
ANTIGRAVITY_REFRESH_FLAG,
|
|
18
|
+
BAND_ALL_TTL_MS,
|
|
19
|
+
BAND_FLAG,
|
|
20
|
+
BAND_HEADER,
|
|
18
21
|
CLAUDE_BAND_MARKER_DIR,
|
|
19
22
|
CLAUDE_BAND_MARKER_TTL_MS,
|
|
20
23
|
CLAUDE_REFRESH_FLAG,
|
|
21
24
|
CODEX_REFRESH_FLAG,
|
|
22
25
|
getCodexAuthPath,
|
|
23
26
|
getCodexHome,
|
|
27
|
+
STATUSLINE_RESERVE_COLS,
|
|
24
28
|
} from "./constants.mjs";
|
|
25
29
|
import { buildContextUsageView } from "./context-monitor.mjs";
|
|
26
30
|
import {
|
|
@@ -64,7 +68,18 @@ async function main() {
|
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
// --band: triflux mods 가 입력창 위에 그릴 줄을 만든다. statusLine 은 band 가 다 그리는 세션에서 비운다.
|
|
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);
|
|
77
|
+
const stdin = await readStdinJson();
|
|
78
|
+
const bandState = bandMode ? "none" : readBandState(stdin?.session_id);
|
|
79
|
+
if (bandState === "all") return;
|
|
80
|
+
// Claude Code 가 넘긴 세션 사용량이 있으면 Claude API 를 따로 조회하지 않는다.
|
|
81
|
+
const stdinClaudeUsage = claudeUsageFromStdin(stdin);
|
|
82
|
+
|
|
68
83
|
const { showCodex, antigravityAllowed } = resolveHudCliVisibility();
|
|
69
84
|
const claudeUsageSnapshot = readClaudeUsageSnapshot();
|
|
70
85
|
const codexSnapshot = readCodexRateLimitSnapshot();
|
|
@@ -74,6 +89,7 @@ async function main() {
|
|
|
74
89
|
if (antigravitySnapshot?.shouldRefresh) scheduleAntigravityQuotaRefresh();
|
|
75
90
|
// 설정이 없는 홈에서는 갱신 프로세스를 시작하지 않는다.
|
|
76
91
|
if (
|
|
92
|
+
!stdinClaudeUsage &&
|
|
77
93
|
claudeUsageSnapshot.shouldRefresh &&
|
|
78
94
|
existsSync(join(homedir(), ".claude"))
|
|
79
95
|
) {
|
|
@@ -83,11 +99,12 @@ async function main() {
|
|
|
83
99
|
scheduleCodexRateLimitRefresh();
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
const stdin = await stdinPromise;
|
|
87
102
|
const contextView = buildContextUsageView(stdin);
|
|
88
|
-
const claudeUsage =
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
const claudeUsage =
|
|
104
|
+
stdinClaudeUsage ??
|
|
105
|
+
(claudeUsageSnapshot.data
|
|
106
|
+
? { ...claudeUsageSnapshot.data, stale: claudeUsageSnapshot.isStale }
|
|
107
|
+
: null);
|
|
91
108
|
const codexBuckets = codexSnapshot.buckets;
|
|
92
109
|
const antigravityQuota = antigravityAllowed
|
|
93
110
|
? { ...antigravitySnapshot?.data, auth: getAntigravityAuthKind() }
|
|
@@ -103,13 +120,20 @@ async function main() {
|
|
|
103
120
|
showAntigravity: antigravityAllowed,
|
|
104
121
|
antigravityQuota,
|
|
105
122
|
});
|
|
106
|
-
|
|
123
|
+
const header = bandMode ? `${BAND_HEADER}\n` : "";
|
|
124
|
+
process.stdout.write(`${header}\x1b[0m${microLine}\n`);
|
|
107
125
|
return;
|
|
108
126
|
}
|
|
109
127
|
|
|
110
|
-
const rows =
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
const rows =
|
|
129
|
+
bandState === "claude"
|
|
130
|
+
? []
|
|
131
|
+
: getClaudeRows(
|
|
132
|
+
currentTier,
|
|
133
|
+
contextView,
|
|
134
|
+
claudeUsage,
|
|
135
|
+
Number(stdin?.cost?.total_cost_usd ?? Number.NaN),
|
|
136
|
+
);
|
|
113
137
|
let codexRowIndex = -1;
|
|
114
138
|
if (showCodex) {
|
|
115
139
|
codexRowIndex = rows.length;
|
|
@@ -141,10 +165,16 @@ async function main() {
|
|
|
141
165
|
if (outputLines[codexRowIndex] != null && codexLoggedOut) {
|
|
142
166
|
outputLines[codexRowIndex] = `${DIM}${outputLines[codexRowIndex]}${RESET}`;
|
|
143
167
|
}
|
|
144
|
-
// 알림 배너와 TUI 스타일이 HUD 내용에 겹치지 않도록 한다.
|
|
145
|
-
const leadingBreaks =
|
|
168
|
+
// 알림 배너와 TUI 스타일이 HUD 내용에 겹치지 않도록 한다. band 는 자기 자리에 그리니 띄우지 않는다.
|
|
169
|
+
const leadingBreaks = bandMode
|
|
170
|
+
? ""
|
|
171
|
+
: contextView.percent >= 85
|
|
172
|
+
? "\n\n"
|
|
173
|
+
: "\n";
|
|
146
174
|
const resetLines = outputLines.map((line) => `\x1b[0m${line}`);
|
|
147
|
-
|
|
175
|
+
// band 는 첫 줄 표지로 이 HUD 가 --band 를 아는지 확인한다.
|
|
176
|
+
const header = bandMode ? `${BAND_HEADER}\n` : "";
|
|
177
|
+
process.stdout.write(`${header}${leadingBreaks}${resetLines.join("\n")}\n`);
|
|
148
178
|
}
|
|
149
179
|
|
|
150
180
|
main().catch(() => {
|
|
@@ -153,15 +183,53 @@ main().catch(() => {
|
|
|
153
183
|
);
|
|
154
184
|
});
|
|
155
185
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
186
|
+
// 입력창 위 band 가 무엇을 그리는지: "all" 은 모든 행, "claude" 는 c 행만(10.56.0 mods), "none" 은 없음.
|
|
187
|
+
// band 위치를 statusline 으로 고른 세션은 표식에 "off" 를 쓴다.
|
|
188
|
+
function readBandState(sessionId) {
|
|
189
|
+
if (!sessionId) return "none";
|
|
159
190
|
try {
|
|
160
191
|
const marker = join(CLAUDE_BAND_MARKER_DIR, sessionId);
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
192
|
+
const content = readFileSync(marker, "utf8").trim();
|
|
193
|
+
if (content === "off") return "none";
|
|
194
|
+
const age = Date.now() - statSync(marker).mtimeMs;
|
|
195
|
+
// all 표식은 band 가 1분마다 다시 쓴다. band 가 멈추면 짧게 끊어 statusLine 이 다시 그린다.
|
|
196
|
+
if (content.startsWith("all:"))
|
|
197
|
+
return age < BAND_ALL_TTL_MS ? "all" : "none";
|
|
198
|
+
return age < CLAUDE_BAND_MARKER_TTL_MS ? "claude" : "none";
|
|
164
199
|
} catch {
|
|
165
|
-
return
|
|
200
|
+
return "none";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
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 };
|
|
166
219
|
}
|
|
220
|
+
const {
|
|
221
|
+
five_hour: fiveHour,
|
|
222
|
+
seven_day: weekly,
|
|
223
|
+
spend_limit: spendLimit,
|
|
224
|
+
} = windows;
|
|
225
|
+
if (!fiveHour && !weekly && !spendLimit) return null;
|
|
226
|
+
const percent = (w) =>
|
|
227
|
+
Number.isFinite(w?.percent) ? Math.round(w.percent) : null;
|
|
228
|
+
return {
|
|
229
|
+
fiveHourPercent: percent(fiveHour),
|
|
230
|
+
weeklyPercent: percent(weekly),
|
|
231
|
+
fiveHourResetsAt: fiveHour?.resetsAt || null,
|
|
232
|
+
weeklyResetsAt: weekly?.resetsAt || null,
|
|
233
|
+
spendLimitPercent: percent(spendLimit),
|
|
234
|
+
};
|
|
167
235
|
}
|
|
@@ -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"
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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
|
-
?
|
|
678
|
-
|
|
679
|
-
|
|
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 (
|
|
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
|
}
|
package/hud/providers/codex.mjs
CHANGED
|
@@ -26,7 +26,12 @@ import {
|
|
|
26
26
|
getCodexHome,
|
|
27
27
|
SPAWN_LOCK_TTL_MS,
|
|
28
28
|
} from "../constants.mjs";
|
|
29
|
-
import {
|
|
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 (
|
|
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
|
}
|
package/hud/providers/gemini.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
144
|
-
|
|
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
|
@@ -100,10 +100,13 @@ export function getMicroLine(
|
|
|
100
100
|
claudeUsage?.weeklyPercent != null
|
|
101
101
|
? clampPercent(claudeUsage.weeklyPercent)
|
|
102
102
|
: null;
|
|
103
|
+
const cSpend = spendOnlyPercent(claudeUsage);
|
|
103
104
|
const cVal =
|
|
104
|
-
|
|
105
|
-
?
|
|
106
|
-
:
|
|
105
|
+
cSpend != null
|
|
106
|
+
? colorByProvider(cSpend, `$${cSpend}%`, claudeOrange)
|
|
107
|
+
: claudeUsage != null
|
|
108
|
+
? `${cF != null ? colorByProvider(cF, `${cF}%`, claudeOrange) : dim("--%")}${dim("/")}${cW != null ? colorByProvider(cW, `${cW}%`, claudeOrange) : dim("--%")}`
|
|
109
|
+
: dim("--%/--%");
|
|
107
110
|
|
|
108
111
|
// Codex 5h/1w
|
|
109
112
|
let xVal = dim("--%/--%");
|
|
@@ -163,12 +166,42 @@ function contextPercentText(ctxView) {
|
|
|
163
166
|
return colorByPercent(ctxView.percent, `${ctxView.percent}%`);
|
|
164
167
|
}
|
|
165
168
|
|
|
169
|
+
// gateway 세션은 5h, 1w 대신 spend_limit 창 하나만 받는다.
|
|
170
|
+
function spendOnlyPercent(claudeUsage) {
|
|
171
|
+
if (claudeUsage?.spendLimitPercent == null) return null;
|
|
172
|
+
if (claudeUsage.fiveHourPercent != null || claudeUsage.weeklyPercent != null)
|
|
173
|
+
return null;
|
|
174
|
+
return clampPercent(claudeUsage.spendLimitPercent);
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
// ============================================================================
|
|
167
178
|
// Claude 행 렌더러
|
|
168
179
|
// ============================================================================
|
|
169
|
-
export function getClaudeRows(
|
|
180
|
+
export function getClaudeRows(
|
|
181
|
+
currentTier,
|
|
182
|
+
contextView,
|
|
183
|
+
claudeUsage,
|
|
184
|
+
costUsd = null,
|
|
185
|
+
) {
|
|
170
186
|
const ctxView = contextView || buildContextUsageView({});
|
|
171
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
|
+
: "";
|
|
194
|
+
const spend = spendOnlyPercent(claudeUsage);
|
|
195
|
+
if (spend != null) {
|
|
196
|
+
if (currentTier === "nano" || currentTier === "micro") {
|
|
197
|
+
const left = colorByProvider(spend, `${spend}%`, claudeOrange);
|
|
198
|
+
return [{ prefix, left, right: "" }];
|
|
199
|
+
}
|
|
200
|
+
const pct = colorByProvider(spend, formatPercentCell(spend), claudeOrange);
|
|
201
|
+
const left = `${dim("$:")}${tierBar(currentTier, spend, CLAUDE_ORANGE)}${pct}`;
|
|
202
|
+
const right = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}`;
|
|
203
|
+
return [{ prefix, left, right }];
|
|
204
|
+
}
|
|
172
205
|
// API 실측 데이터
|
|
173
206
|
const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
|
|
174
207
|
const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
|
|
@@ -234,7 +267,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
|
|
|
234
267
|
const warning = ctxView.warningTag
|
|
235
268
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
236
269
|
: "";
|
|
237
|
-
const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${warning}`;
|
|
270
|
+
const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}${warning}`;
|
|
238
271
|
return [{ prefix, left: quotaSection, right: contextSection }];
|
|
239
272
|
}
|
|
240
273
|
|
|
@@ -244,7 +277,7 @@ export function getClaudeRows(currentTier, contextView, claudeUsage) {
|
|
|
244
277
|
const warning = ctxView.warningTag
|
|
245
278
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
246
279
|
: "";
|
|
247
|
-
const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${warning}`;
|
|
280
|
+
const contextSection = `${dim("CTX:")}${contextPercentText(ctxView)}${costText}${warning}`;
|
|
248
281
|
return [{ prefix, left: quotaSection, right: contextSection }];
|
|
249
282
|
}
|
|
250
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
process.stdin.removeAllListeners("data");
|
|
23
|
+
process.stdin.destroy();
|
|
24
|
+
resolve(value);
|
|
25
|
+
};
|
|
26
|
+
const tryParse = () => {
|
|
26
27
|
try {
|
|
27
|
-
|
|
28
|
+
return JSON.parse(raw);
|
|
28
29
|
} catch {
|
|
29
|
-
|
|
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("
|
|
33
|
-
|
|
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
package/scripts/setup.mjs
CHANGED
|
@@ -676,7 +676,7 @@ function installEntries(pluginRoot) {
|
|
|
676
676
|
"scripts/codex-profile-sanitize.mjs",
|
|
677
677
|
// tfx-route.sh 가 $sd/lib 에서 찾는다.
|
|
678
678
|
...listFiles(pluginRoot, "scripts/lib", [".mjs", ".sh"]),
|
|
679
|
-
...listFiles(pluginRoot, "hud", [".mjs"]),
|
|
679
|
+
...listFiles(pluginRoot, "hud", [".mjs", ".sh"]),
|
|
680
680
|
];
|
|
681
681
|
}
|
|
682
682
|
|
|
@@ -1689,35 +1689,71 @@ export function persistSettings(settings, settingsPath = SETTINGS_PATH) {
|
|
|
1689
1689
|
}
|
|
1690
1690
|
}
|
|
1691
1691
|
|
|
1692
|
+
const HUD_STATUSLINE_SCRIPT = "hud-statusline.sh";
|
|
1693
|
+
|
|
1694
|
+
function commandTokens(command) {
|
|
1695
|
+
return [
|
|
1696
|
+
...String(command || "").matchAll(/"([^"\n]+)"|'([^'\n]+)'|(\S+)/gu),
|
|
1697
|
+
].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// macOS/Linux 는 sh 래퍼로 돌려 mods band 가 그리는 세션에서 node 를 띄우지 않는다(hud/hud-statusline.sh).
|
|
1701
|
+
// willSyncWrapper: 미리보기처럼 래퍼를 아직 복사하기 전이지만 setup 이 곧 복사할 때.
|
|
1702
|
+
function buildStatusLineCommand(hudPath, { willSyncWrapper = false } = {}) {
|
|
1703
|
+
const wrapper = join(dirname(hudPath), HUD_STATUSLINE_SCRIPT);
|
|
1704
|
+
if (process.platform === "win32" || !(willSyncWrapper || existsSync(wrapper)))
|
|
1705
|
+
return buildNodeScriptCommand(hudPath);
|
|
1706
|
+
const node = resolveStableNodeBin(process.execPath, { fallback: "node" });
|
|
1707
|
+
return `sh ${quoteShellCommandArg(wrapper)} ${quoteShellCommandArg(node)}`;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// triflux 가 등록한 statusLine 인지: node <hud> 또는 sh <래퍼> <node>.
|
|
1711
|
+
function isTrifluxStatusLine(command, hudPath) {
|
|
1712
|
+
const isNode = (bin) => /(?:^|[/\\])node(?:\.exe)?$/u.test(bin ?? "");
|
|
1713
|
+
const tokens = commandTokens(command);
|
|
1714
|
+
const hud = hudPath.replace(/\\/g, "/");
|
|
1715
|
+
const wrapper = join(dirname(hudPath), HUD_STATUSLINE_SCRIPT).replace(
|
|
1716
|
+
/\\/g,
|
|
1717
|
+
"/",
|
|
1718
|
+
);
|
|
1719
|
+
if (tokens.length === 2) return isNode(tokens[0]) && tokens[1] === hud;
|
|
1720
|
+
if (tokens.length === 3)
|
|
1721
|
+
return tokens[0] === "sh" && tokens[1] === wrapper && isNode(tokens[2]);
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// 미리보기와 실제 적용이 같은 판정을 쓴다. 사용자가 직접 넣은 statusLine 은 skip 이다.
|
|
1726
|
+
export function planStatusLine(
|
|
1727
|
+
settings,
|
|
1728
|
+
{ hudPath = HUD_PATH, willSyncWrapper = false } = {},
|
|
1729
|
+
) {
|
|
1730
|
+
const current = settings.statusLine;
|
|
1731
|
+
if (current == null) return "create";
|
|
1732
|
+
if (current.command === buildStatusLineCommand(hudPath, { willSyncWrapper }))
|
|
1733
|
+
return "noop";
|
|
1734
|
+
return current.type === "command" &&
|
|
1735
|
+
isTrifluxStatusLine(current.command, hudPath)
|
|
1736
|
+
? "update"
|
|
1737
|
+
: "skip";
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1692
1740
|
export function applyStatusLine(
|
|
1693
1741
|
settings,
|
|
1694
1742
|
{ hudPath = HUD_PATH, warn = console.warn } = {},
|
|
1695
1743
|
) {
|
|
1696
1744
|
if (!existsSync(hudPath)) return false;
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
if (
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
/^(?:"([^"\n]+)"|'([^'\n]+)'|(\S+))\s+(?:"([^"\n]+)"|'([^'\n]+)'|(\S+))$/u,
|
|
1745
|
+
const change = planStatusLine(settings, { hudPath });
|
|
1746
|
+
if (change === "noop") return false;
|
|
1747
|
+
if (change === "skip") {
|
|
1748
|
+
warn(
|
|
1749
|
+
"기존 statusLine 유지: Triflux HUD를 쓰려면 settings.json에서 직접 선택하세요.",
|
|
1703
1750
|
);
|
|
1704
|
-
|
|
1705
|
-
const script = tokens?.[4] ?? tokens?.[5] ?? tokens?.[6];
|
|
1706
|
-
if (
|
|
1707
|
-
current.type !== "command" ||
|
|
1708
|
-
!/(?:^|[/\\])node(?:\.exe)?$/u.test(node) ||
|
|
1709
|
-
script !== hudPath.replace(/\\/g, "/")
|
|
1710
|
-
) {
|
|
1711
|
-
warn(
|
|
1712
|
-
"기존 statusLine 유지: Triflux HUD를 쓰려면 settings.json에서 직접 선택하세요.",
|
|
1713
|
-
);
|
|
1714
|
-
return false;
|
|
1715
|
-
}
|
|
1751
|
+
return false;
|
|
1716
1752
|
}
|
|
1717
1753
|
settings.statusLine = {
|
|
1718
|
-
...
|
|
1754
|
+
...settings.statusLine,
|
|
1719
1755
|
type: "command",
|
|
1720
|
-
command:
|
|
1756
|
+
command: buildStatusLineCommand(hudPath),
|
|
1721
1757
|
};
|
|
1722
1758
|
return true;
|
|
1723
1759
|
}
|