@triflux/core 10.28.1 → 10.29.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.
- package/hooks/session-end-cleanup.mjs +15 -0
- package/hooks/session-start-fast.mjs +123 -0
- package/hub/team/synapse-http.mjs +161 -0
- package/hud/constants.mjs +38 -0
- package/hud/hud-qos-status.mjs +28 -2
- package/hud/providers/claude.mjs +116 -14
- package/hud/providers/gemini.mjs +48 -0
- package/hud/renderers.mjs +103 -10
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
10
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { unregisterSynapseSession } from "../hub/team/synapse-http.mjs";
|
|
12
13
|
|
|
13
14
|
const MARKER = "[session-end-cleanup]";
|
|
14
15
|
const STALE_MS = 30 * 60 * 1000;
|
|
@@ -292,6 +293,20 @@ function buildOutput(summary, cleanup, tmuxReport) {
|
|
|
292
293
|
export function run(input, env = process.env) {
|
|
293
294
|
if (input?.hook_event_name !== "SessionEnd") return "";
|
|
294
295
|
|
|
296
|
+
// Best-effort Synapse unregister (fire-and-forget). Crash-safe: if this hook
|
|
297
|
+
// never fires, the 5-min interactive TTL eventually marks the row stale, so
|
|
298
|
+
// unregister is an optimization, not a correctness dependency. Never throws,
|
|
299
|
+
// never changes the report-only stdout contract below.
|
|
300
|
+
//
|
|
301
|
+
// Bounded timeout (unref AbortController inside synapse-http) so a stalled hub
|
|
302
|
+
// cannot keep this short-lived SessionEnd hook process alive past its budget.
|
|
303
|
+
try {
|
|
304
|
+
const sessionId = String(input?.session_id || "").trim();
|
|
305
|
+
if (sessionId) unregisterSynapseSession(sessionId, { timeoutMs: 1500 });
|
|
306
|
+
} catch {
|
|
307
|
+
/* swallow — report-only charter preserved */
|
|
308
|
+
}
|
|
309
|
+
|
|
295
310
|
const root = env.CLAUDE_CWD || process.cwd();
|
|
296
311
|
const statePath = join(root, ".triflux", "subagents", "subagents.json");
|
|
297
312
|
if (!existsSync(statePath)) return "";
|
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
//
|
|
11
11
|
// external source 훅 (session-vault 등)은 여전히 execFile로 실행된다.
|
|
12
12
|
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
14
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import {
|
|
17
|
+
drainPendingSynapse,
|
|
18
|
+
heartbeatSynapseSession,
|
|
19
|
+
registerSynapseSession,
|
|
20
|
+
} from "../hub/team/synapse-http.mjs";
|
|
15
21
|
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
16
22
|
|
|
17
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -20,6 +26,111 @@ const importMod = (p) => import(pathToFileURL(p).href);
|
|
|
20
26
|
|
|
21
27
|
const log = createModuleLogger("session-start-fast");
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* SessionStart 페이로드(JSON 문자열)에서 session_id / cwd 를 추출한다.
|
|
31
|
+
* 파싱 실패는 빈 객체로 흡수한다 (BLOCKING path 에 절대 영향 없음).
|
|
32
|
+
*/
|
|
33
|
+
function parseStartPayload(stdinData) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = typeof stdinData === "string" ? stdinData : "";
|
|
36
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* cwd 기준 git 컨텍스트(worktree root / branch)를 best-effort, 비동기로 수집.
|
|
44
|
+
* execFileSync 와 달리 호출자(BACKGROUND)를 블로킹하지 않는다. 각 git 호출은
|
|
45
|
+
* tight timeout(1.5s) + 강제 kill 로 묶여 hub/디스크 stall 시에도 잔류하지 않는다.
|
|
46
|
+
* @param {string} cwd
|
|
47
|
+
* @param {(args:string[], cb:(out:string)=>void)=>void} [gitRunner] 테스트용 seam
|
|
48
|
+
* @returns {Promise<{ worktreePath: string, branch: string }>}
|
|
49
|
+
*/
|
|
50
|
+
function gitContextAsync(cwd, gitRunner = defaultGitRunner) {
|
|
51
|
+
const run = (args) =>
|
|
52
|
+
new Promise((resolve) => {
|
|
53
|
+
try {
|
|
54
|
+
gitRunner(cwd, args, (out) => resolve(out));
|
|
55
|
+
} catch {
|
|
56
|
+
resolve("");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return Promise.all([
|
|
60
|
+
run(["rev-parse", "--show-toplevel"]),
|
|
61
|
+
run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
62
|
+
]).then(([toplevel, branch]) => ({
|
|
63
|
+
worktreePath: toplevel || cwd,
|
|
64
|
+
branch,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 기본 git runner: execFile(async) + tight timeout + kill. 실패는 빈 문자열. */
|
|
69
|
+
function defaultGitRunner(cwd, args, cb) {
|
|
70
|
+
execFile(
|
|
71
|
+
"git",
|
|
72
|
+
args,
|
|
73
|
+
{
|
|
74
|
+
cwd,
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: 1500,
|
|
77
|
+
killSignal: "SIGKILL",
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
},
|
|
80
|
+
(err, stdout) => {
|
|
81
|
+
cb(err ? "" : String(stdout || "").trim());
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 인터랙티브 세션을 Synapse 레지스트리에 self-register (fire-and-forget).
|
|
88
|
+
* hub 미응답이면 silent no-op. BLOCKING path 에 latency 0 — git 컨텍스트는
|
|
89
|
+
* 블로킹 경로 밖에서 비동기로 enrich 한다 (cwd 만으로 즉시 minimal register).
|
|
90
|
+
*
|
|
91
|
+
* pid 는 의도적으로 보내지 않는다: 이 훅 프로세스의 process.pid 는 short-lived
|
|
92
|
+
* 훅 PID 일 뿐 세션 PID 가 아니므로 오기재가 된다. liveness 는 TTL 이 담당한다.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} stdinData
|
|
95
|
+
* @param {object} [seams] 테스트용 injectable seam
|
|
96
|
+
* @param {Function} [seams.register] registerSynapseSession 대체
|
|
97
|
+
* @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
|
|
98
|
+
* @param {Function} [seams.gitRunner] git runner 대체
|
|
99
|
+
* @returns {void} (enrich 는 백그라운드에서 완료)
|
|
100
|
+
*/
|
|
101
|
+
export function registerInteractiveSession(stdinData, seams = {}) {
|
|
102
|
+
const register = seams.register || registerSynapseSession;
|
|
103
|
+
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
104
|
+
const gitRunner = seams.gitRunner || defaultGitRunner;
|
|
105
|
+
try {
|
|
106
|
+
const payload = parseStartPayload(stdinData);
|
|
107
|
+
const sessionId = String(payload?.session_id || "").trim();
|
|
108
|
+
if (!sessionId) return;
|
|
109
|
+
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
110
|
+
|
|
111
|
+
// 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).
|
|
112
|
+
register({
|
|
113
|
+
sessionId,
|
|
114
|
+
cwd,
|
|
115
|
+
worktreePath: cwd,
|
|
116
|
+
branch: "",
|
|
117
|
+
host: "local",
|
|
118
|
+
sessionKind: "interactive",
|
|
119
|
+
isRemote: false,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 2) worktree/branch 는 블로킹 경로 밖에서 비동기 enrich → heartbeat partial.
|
|
123
|
+
gitContextAsync(cwd, gitRunner)
|
|
124
|
+
.then(({ worktreePath, branch }) => {
|
|
125
|
+
if (worktreePath === cwd && !branch) return; // 추가 정보 없음
|
|
126
|
+
heartbeat(sessionId, { worktreePath, branch });
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {});
|
|
129
|
+
} catch {
|
|
130
|
+
/* best-effort — never affects session start */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
23
134
|
/**
|
|
24
135
|
* BLOCKING 훅을 순차 실행. 하나라도 throw하면 로그만 남기고 계속.
|
|
25
136
|
* @param {string} stdinData
|
|
@@ -174,6 +285,9 @@ function runBackground(stdinData) {
|
|
|
174
285
|
.then((mod) => mod.run(stdinData))
|
|
175
286
|
.catch(() => {}); // 완전 무시
|
|
176
287
|
|
|
288
|
+
// Synapse: 인터랙티브 세션 self-register (fire-and-forget, hub 미응답 무시)
|
|
289
|
+
registerInteractiveSession(stdinData);
|
|
290
|
+
|
|
177
291
|
// session-vault은 external source — hook-orchestrator가 execFile로 실행
|
|
178
292
|
}
|
|
179
293
|
|
|
@@ -195,6 +309,15 @@ export async function execute(stdinData, externalHooks = []) {
|
|
|
195
309
|
runDeferred(stdinData);
|
|
196
310
|
runBackground(stdinData);
|
|
197
311
|
|
|
312
|
+
// hook-orchestrator process.exit(0)s immediately after we return, which would
|
|
313
|
+
// drop the just-fired Synapse register POST before it flushes to the loopback
|
|
314
|
+
// socket (Node's exit abandons pending I/O). Drain it with a tight bound: the
|
|
315
|
+
// register is a fast localhost POST (hub was ensured up in the BLOCKING phase)
|
|
316
|
+
// and the ceiling caps a hub stall so SessionStart never hangs. The async
|
|
317
|
+
// git-enrich heartbeat stays best-effort (it may not have fired yet) — losing
|
|
318
|
+
// it only drops worktree/branch enrichment, not the core registration.
|
|
319
|
+
await drainPendingSynapse(1000);
|
|
320
|
+
|
|
198
321
|
const totalDur = performance.now() - totalStart;
|
|
199
322
|
log.info(
|
|
200
323
|
{
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const HUB_DEFAULT_PORT = 27888;
|
|
6
|
+
// Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
|
|
7
|
+
// imported so this helper stays dependency-free and byte-identical across the
|
|
8
|
+
// packages/core mirror (which cannot carry relative cross-package imports).
|
|
9
|
+
const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
|
|
10
|
+
|
|
11
|
+
// In-flight fire-and-forget POSTs. A short-lived caller (the SessionStart hook,
|
|
12
|
+
// which process.exit(0)s immediately after firing register) must be able to
|
|
13
|
+
// flush these before the process dies: Node's process.exit() abandons pending
|
|
14
|
+
// socket I/O, so an un-awaited loopback POST is created but never written.
|
|
15
|
+
// Callers on an exit budget drain this set via drainPendingSynapse() first.
|
|
16
|
+
const inFlightSynapse = new Set();
|
|
17
|
+
|
|
18
|
+
function normalizeToken(raw) {
|
|
19
|
+
if (raw == null) return null;
|
|
20
|
+
const token = String(raw).trim();
|
|
21
|
+
return token || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
|
|
25
|
+
// Missing token → null (backward-compatible with token-less hubs).
|
|
26
|
+
function readHubToken() {
|
|
27
|
+
const envToken = normalizeToken(process.env.TFX_HUB_TOKEN);
|
|
28
|
+
if (envToken) return envToken;
|
|
29
|
+
try {
|
|
30
|
+
return normalizeToken(readFileSync(HUB_TOKEN_FILE, "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Wrap IPv6 hosts in [...] so they form a valid URL authority (cf. bridge.mjs).
|
|
37
|
+
function formatHostForUrl(host) {
|
|
38
|
+
return String(host).includes(":") ? `[${host}]` : host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve the hub base URL from env (TFX_HUB_URL / TFX_HUB_PORT) before falling
|
|
42
|
+
// back to the loopback default, so a cascaded / non-default hub port is hit
|
|
43
|
+
// instead of silently posting to 127.0.0.1:27888 and missing the live hub.
|
|
44
|
+
function resolveSynapseBaseUrl() {
|
|
45
|
+
const envUrl = normalizeToken(process.env.TFX_HUB_URL);
|
|
46
|
+
if (envUrl) return envUrl.replace(/\/mcp$/, "");
|
|
47
|
+
|
|
48
|
+
const envPort = Number.parseInt(String(process.env.TFX_HUB_PORT ?? ""), 10);
|
|
49
|
+
if (Number.isFinite(envPort) && envPort > 0) {
|
|
50
|
+
return `http://${formatHostForUrl("127.0.0.1")}:${envPort}`;
|
|
51
|
+
}
|
|
52
|
+
return `http://${formatHostForUrl("127.0.0.1")}:${HUB_DEFAULT_PORT}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveSynapseFetch(fetchImpl) {
|
|
56
|
+
if (fetchImpl === null) return null;
|
|
57
|
+
if (typeof fetchImpl === "function") return fetchImpl;
|
|
58
|
+
if (typeof globalThis.fetch === "function") {
|
|
59
|
+
return globalThis.fetch.bind(globalThis);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildSynapseTaskSummary(prompt, maxLength = 100) {
|
|
65
|
+
if (maxLength <= 0) return "";
|
|
66
|
+
return String(prompt ?? "").slice(0, maxLength);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fireAndForgetSynapse(path, payload, opts = {}) {
|
|
70
|
+
const fetchImpl = resolveSynapseFetch(opts.fetchImpl);
|
|
71
|
+
if (!fetchImpl) return false;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const baseUrl = opts.baseUrl || resolveSynapseBaseUrl();
|
|
75
|
+
const url = new URL(path, baseUrl).toString();
|
|
76
|
+
const headers = { "content-type": "application/json" };
|
|
77
|
+
// token: false explicitly skips auth; otherwise reuse the hub token so a
|
|
78
|
+
// token-required hub does not 401 (the failure would be swallowed silently).
|
|
79
|
+
if (opts.token !== false) {
|
|
80
|
+
const token =
|
|
81
|
+
typeof opts.token === "string" && opts.token.trim()
|
|
82
|
+
? opts.token.trim()
|
|
83
|
+
: readHubToken();
|
|
84
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
85
|
+
}
|
|
86
|
+
const init = {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers,
|
|
89
|
+
body: JSON.stringify(payload),
|
|
90
|
+
};
|
|
91
|
+
// Optional bounded timeout so callers on a teardown budget (e.g. SessionEnd)
|
|
92
|
+
// cannot hang the host process if the hub stalls. The timer is unref'd so it
|
|
93
|
+
// never keeps the event loop (and thus the hook process) alive on its own.
|
|
94
|
+
let timer = null;
|
|
95
|
+
const timeoutMs = Number(opts.timeoutMs);
|
|
96
|
+
if (
|
|
97
|
+
Number.isFinite(timeoutMs) &&
|
|
98
|
+
timeoutMs > 0 &&
|
|
99
|
+
typeof AbortController === "function"
|
|
100
|
+
) {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
init.signal = controller.signal;
|
|
103
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
104
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
105
|
+
}
|
|
106
|
+
const tracked = Promise.resolve(fetchImpl(url, init))
|
|
107
|
+
.catch(() => {})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
if (timer) clearTimeout(timer);
|
|
110
|
+
inFlightSynapse.delete(tracked);
|
|
111
|
+
});
|
|
112
|
+
inFlightSynapse.add(tracked);
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Await any in-flight fire-and-forget Synapse POSTs, bounded by timeoutMs so a
|
|
120
|
+
// stalled hub can never hang the caller. The SessionStart hook calls this right
|
|
121
|
+
// before process.exit(0) so the register POST actually flushes to the socket.
|
|
122
|
+
export function drainPendingSynapse(timeoutMs = 1000) {
|
|
123
|
+
if (inFlightSynapse.size === 0) return Promise.resolve();
|
|
124
|
+
const settled = Promise.allSettled([...inFlightSynapse]);
|
|
125
|
+
const ms = Number(timeoutMs);
|
|
126
|
+
if (!Number.isFinite(ms) || ms <= 0) return settled;
|
|
127
|
+
return Promise.race([
|
|
128
|
+
settled,
|
|
129
|
+
new Promise((resolve) => {
|
|
130
|
+
const t = setTimeout(resolve, ms);
|
|
131
|
+
if (typeof t.unref === "function") t.unref();
|
|
132
|
+
}),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function registerSynapseSession(meta, opts = {}) {
|
|
137
|
+
return fireAndForgetSynapse("/synapse/register", meta, opts);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function heartbeatSynapseSession(
|
|
141
|
+
sessionId,
|
|
142
|
+
partialMeta = {},
|
|
143
|
+
opts = {},
|
|
144
|
+
) {
|
|
145
|
+
// The live route reads `{ sessionId, partial }` (hub/server.mjs
|
|
146
|
+
// /synapse/heartbeat → synapseRegistry.heartbeat(sessionId, partial)), so the
|
|
147
|
+
// helper must nest the meta under `partial` rather than spreading it top-level.
|
|
148
|
+
return fireAndForgetSynapse(
|
|
149
|
+
"/synapse/heartbeat",
|
|
150
|
+
{
|
|
151
|
+
sessionId,
|
|
152
|
+
partial:
|
|
153
|
+
partialMeta && typeof partialMeta === "object" ? partialMeta : {},
|
|
154
|
+
},
|
|
155
|
+
opts,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function unregisterSynapseSession(sessionId, opts = {}) {
|
|
160
|
+
return fireAndForgetSynapse("/synapse/unregister", { sessionId }, opts);
|
|
161
|
+
}
|
package/hud/constants.mjs
CHANGED
|
@@ -73,6 +73,21 @@ export const CLAUDE_CREDENTIALS_PATH = join(
|
|
|
73
73
|
".claude",
|
|
74
74
|
".credentials.json",
|
|
75
75
|
);
|
|
76
|
+
export const CLAUDE_CREDENTIALS_FILENAME = ".credentials.json";
|
|
77
|
+
export const CLAUDE_CODE_USER_AGENT_FALLBACK = "claude-code/2.x";
|
|
78
|
+
export function getClaudeCredentialPaths(env = process.env) {
|
|
79
|
+
const paths = [];
|
|
80
|
+
if (env.CLAUDE_CONFIG_DIR) {
|
|
81
|
+
paths.push(join(env.CLAUDE_CONFIG_DIR, CLAUDE_CREDENTIALS_FILENAME));
|
|
82
|
+
}
|
|
83
|
+
paths.push(CLAUDE_CREDENTIALS_PATH);
|
|
84
|
+
return paths;
|
|
85
|
+
}
|
|
86
|
+
export function getClaudeCodeUserAgent(env = process.env) {
|
|
87
|
+
return env.CLAUDE_CODE_VERSION
|
|
88
|
+
? `claude-code/${env.CLAUDE_CODE_VERSION}`
|
|
89
|
+
: CLAUDE_CODE_USER_AGENT_FALLBACK;
|
|
90
|
+
}
|
|
76
91
|
export const CLAUDE_USAGE_CACHE_PATH = join(
|
|
77
92
|
homedir(),
|
|
78
93
|
".claude",
|
|
@@ -143,6 +158,29 @@ export const ANTIGRAVITY_OAUTH_PATHS = [
|
|
|
143
158
|
join(homedir(), ".gemini", "antigravity-cli", "credentials.json"),
|
|
144
159
|
GEMINI_OAUTH_PATH,
|
|
145
160
|
];
|
|
161
|
+
export const ANTIGRAVITY_SETTINGS_PATH = join(
|
|
162
|
+
homedir(),
|
|
163
|
+
".gemini",
|
|
164
|
+
"antigravity-cli",
|
|
165
|
+
"settings.json",
|
|
166
|
+
);
|
|
167
|
+
// Antigravity CLI 현재 장착 모델 라벨 → HUD 2자 약어 매핑
|
|
168
|
+
// /usage 실측 (2026-05-22): Gemini 4개 모델이 동일 reset window 공유 (family-pool).
|
|
169
|
+
// Claude/GPT-OSS family는 Gemini와 별도 quota 윈도우.
|
|
170
|
+
export const ANTIGRAVITY_MODEL_ABBREV = {
|
|
171
|
+
// Gemini family — 통합 quota
|
|
172
|
+
"Gemini 3.5 Flash (High)": "Fh",
|
|
173
|
+
"Gemini 3.5 Flash (Medium)": "Fm",
|
|
174
|
+
"Gemini 3.5 Flash (Low)": "Fl",
|
|
175
|
+
"Gemini 3.1 Pro (High)": "Ph",
|
|
176
|
+
"Gemini 3.1 Pro (Low)": "Pl",
|
|
177
|
+
"Gemini 3 Flash": "F3",
|
|
178
|
+
// Claude family — 각자 별도 quota
|
|
179
|
+
"Claude Sonnet 4.6 (Thinking)": "Cs",
|
|
180
|
+
"Claude Opus 4.6 (Thinking)": "Co",
|
|
181
|
+
// GPT-OSS family — 각자 별도 quota
|
|
182
|
+
"GPT-OSS 120B (Medium)": "Go",
|
|
183
|
+
};
|
|
146
184
|
export const GEMINI_QUOTA_CACHE_PATH = join(
|
|
147
185
|
homedir(),
|
|
148
186
|
".claude",
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -50,8 +50,12 @@ import {
|
|
|
50
50
|
// Gemini provider
|
|
51
51
|
import {
|
|
52
52
|
buildGeminiAuthContext,
|
|
53
|
+
deriveGeminiFamilyBucket,
|
|
53
54
|
fetchGeminiQuota,
|
|
55
|
+
getAntigravityCurrentModel,
|
|
54
56
|
getAntigravityEmail,
|
|
57
|
+
getAntigravityModelAbbrev,
|
|
58
|
+
getAntigravityModelFamily,
|
|
55
59
|
getGeminiEmail,
|
|
56
60
|
readGeminiQuotaSnapshot,
|
|
57
61
|
readGeminiSessionSnapshot,
|
|
@@ -151,7 +155,9 @@ async function main() {
|
|
|
151
155
|
// 실측 데이터 추출
|
|
152
156
|
const stdin = await stdinPromise;
|
|
153
157
|
const contextView = buildContextUsageView(stdin, contextSnapshot);
|
|
154
|
-
const claudeUsage = claudeUsageSnapshot.data
|
|
158
|
+
const claudeUsage = claudeUsageSnapshot.data
|
|
159
|
+
? { ...claudeUsageSnapshot.data, stale: claudeUsageSnapshot.isStale }
|
|
160
|
+
: claudeUsageSnapshot.data;
|
|
155
161
|
const codexEmail = getCodexEmail();
|
|
156
162
|
const geminiEmail = getGeminiEmail();
|
|
157
163
|
const antigravityEmail = getAntigravityEmail();
|
|
@@ -199,6 +205,15 @@ async function main() {
|
|
|
199
205
|
const geminiLiteBucket =
|
|
200
206
|
geminiBuckets.find((b) => b.modelId?.includes("flash-lite")) || null;
|
|
201
207
|
|
|
208
|
+
// Antigravity 현재 장착 모델 + family 통합 bucket (slot1 = 장착 모델 약어, slot2 = Gn family)
|
|
209
|
+
// slot1 quota: 현재 모델이 Gemini family면 family bucket, 아니면(Claude/GPT-OSS) placeholder.
|
|
210
|
+
const antigravityModel = getAntigravityCurrentModel();
|
|
211
|
+
const antigravityAbbrev = getAntigravityModelAbbrev(antigravityModel);
|
|
212
|
+
const antigravityModelFamily = getAntigravityModelFamily(antigravityModel);
|
|
213
|
+
const antigravityFamilyBucket = deriveGeminiFamilyBucket(geminiBuckets);
|
|
214
|
+
const antigravitySlot1Bucket =
|
|
215
|
+
antigravityModelFamily === "gemini" ? antigravityFamilyBucket : null;
|
|
216
|
+
|
|
202
217
|
// 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
|
|
203
218
|
const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
|
|
204
219
|
|
|
@@ -257,7 +272,18 @@ async function main() {
|
|
|
257
272
|
qosProfile,
|
|
258
273
|
accountsConfig,
|
|
259
274
|
accountsState,
|
|
260
|
-
antigravityReady
|
|
275
|
+
antigravityReady
|
|
276
|
+
? {
|
|
277
|
+
type: "antigravity",
|
|
278
|
+
pools: {
|
|
279
|
+
current: antigravitySlot1Bucket,
|
|
280
|
+
gemini_family: antigravityFamilyBucket,
|
|
281
|
+
},
|
|
282
|
+
currentAbbrev: antigravityAbbrev,
|
|
283
|
+
currentModel: antigravityModel,
|
|
284
|
+
currentFamily: antigravityModelFamily,
|
|
285
|
+
}
|
|
286
|
+
: geminiQuotaData,
|
|
261
287
|
antigravityReady ? antigravityEmail : geminiEmail,
|
|
262
288
|
antigravityReady ? null : geminiSv,
|
|
263
289
|
null,
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// ============================================================================
|
|
4
4
|
|
|
5
5
|
import { execFileSync, spawn } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { existsSync, writeFileSync } from "node:fs";
|
|
7
8
|
import https from "node:https";
|
|
9
|
+
import { userInfo } from "node:os";
|
|
8
10
|
import {
|
|
9
11
|
CLAUDE_API_TIMEOUT_MS,
|
|
10
12
|
CLAUDE_CREDENTIALS_PATH,
|
|
@@ -17,6 +19,8 @@ import {
|
|
|
17
19
|
CLAUDE_USAGE_STALE_MS_WITH_OMC,
|
|
18
20
|
DEFAULT_OAUTH_CLIENT_ID,
|
|
19
21
|
FIVE_HOUR_MS,
|
|
22
|
+
getClaudeCodeUserAgent,
|
|
23
|
+
getClaudeCredentialPaths,
|
|
20
24
|
OMC_PLUGIN_USAGE_CACHE_PATH,
|
|
21
25
|
SEVEN_DAY_MS,
|
|
22
26
|
SPAWN_LOCK_TTL_MS,
|
|
@@ -112,30 +116,91 @@ function normalizeClaudeCredentials(data, source, supportsUsageApi = true) {
|
|
|
112
116
|
};
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
function
|
|
119
|
+
export function getKeychainServiceName(env = process.env) {
|
|
120
|
+
const configDir = env.CLAUDE_CONFIG_DIR;
|
|
121
|
+
if (configDir) {
|
|
122
|
+
const hash = createHash("sha256")
|
|
123
|
+
.update(configDir)
|
|
124
|
+
.digest("hex")
|
|
125
|
+
.slice(0, 8);
|
|
126
|
+
return `${CLAUDE_KEYCHAIN_SERVICE}-${hash}`;
|
|
127
|
+
}
|
|
128
|
+
return CLAUDE_KEYCHAIN_SERVICE;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isCredentialExpired(creds) {
|
|
132
|
+
return creds.expiresAt != null && creds.expiresAt <= Date.now();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getKeychainAccount() {
|
|
116
136
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
return userInfo().username?.trim() || null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readClaudeKeychainCredential(
|
|
144
|
+
serviceName,
|
|
145
|
+
account,
|
|
146
|
+
execFileSyncFn = execFileSync,
|
|
147
|
+
) {
|
|
148
|
+
try {
|
|
149
|
+
const args = account
|
|
150
|
+
? ["find-generic-password", "-s", serviceName, "-a", account, "-w"]
|
|
151
|
+
: ["find-generic-password", "-s", serviceName, "-w"];
|
|
152
|
+
const raw = execFileSyncFn("security", args, { encoding: "utf8" });
|
|
122
153
|
return normalizeClaudeCredentials(JSON.parse(raw.trim()), "keychain");
|
|
123
154
|
} catch {
|
|
124
155
|
return null;
|
|
125
156
|
}
|
|
126
157
|
}
|
|
127
158
|
|
|
159
|
+
function readClaudeKeychainCredentials(
|
|
160
|
+
execFileSyncFn = execFileSync,
|
|
161
|
+
env = process.env,
|
|
162
|
+
) {
|
|
163
|
+
const serviceName = getKeychainServiceName(env);
|
|
164
|
+
const candidateAccounts = [];
|
|
165
|
+
const username = getKeychainAccount();
|
|
166
|
+
if (username) candidateAccounts.push(username);
|
|
167
|
+
candidateAccounts.push(undefined);
|
|
168
|
+
|
|
169
|
+
let expiredFallback = null;
|
|
170
|
+
for (const account of candidateAccounts) {
|
|
171
|
+
const creds = readClaudeKeychainCredential(
|
|
172
|
+
serviceName,
|
|
173
|
+
account,
|
|
174
|
+
execFileSyncFn,
|
|
175
|
+
);
|
|
176
|
+
if (!creds) continue;
|
|
177
|
+
if (!isCredentialExpired(creds)) return creds;
|
|
178
|
+
expiredFallback ??= creds;
|
|
179
|
+
}
|
|
180
|
+
return expiredFallback;
|
|
181
|
+
}
|
|
182
|
+
|
|
128
183
|
export function readClaudeCredentials({
|
|
129
|
-
readCredentialFile = (
|
|
184
|
+
readCredentialFile = (filePath = CLAUDE_CREDENTIALS_PATH) =>
|
|
185
|
+
readJson(filePath, null),
|
|
130
186
|
platform = process.platform,
|
|
131
187
|
execFileSyncFn = execFileSync,
|
|
132
188
|
env = process.env,
|
|
133
189
|
} = {}) {
|
|
134
|
-
const
|
|
135
|
-
|
|
190
|
+
for (const filePath of getClaudeCredentialPaths(env)) {
|
|
191
|
+
try {
|
|
192
|
+
const fileCreds = normalizeClaudeCredentials(
|
|
193
|
+
readCredentialFile(filePath),
|
|
194
|
+
"file",
|
|
195
|
+
);
|
|
196
|
+
if (fileCreds) return fileCreds;
|
|
197
|
+
} catch {
|
|
198
|
+
/* try next credential source */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
136
201
|
|
|
137
202
|
if (platform === "darwin") {
|
|
138
|
-
const keychainCreds = readClaudeKeychainCredentials(execFileSyncFn);
|
|
203
|
+
const keychainCreds = readClaudeKeychainCredentials(execFileSyncFn, env);
|
|
139
204
|
if (keychainCreds) return keychainCreds;
|
|
140
205
|
}
|
|
141
206
|
|
|
@@ -169,6 +234,7 @@ export function refreshClaudeAccessToken(refreshToken) {
|
|
|
169
234
|
headers: {
|
|
170
235
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
171
236
|
"Content-Length": Buffer.byteLength(body),
|
|
237
|
+
"User-Agent": getClaudeCodeUserAgent(),
|
|
172
238
|
},
|
|
173
239
|
timeout: CLAUDE_API_TIMEOUT_MS,
|
|
174
240
|
},
|
|
@@ -217,13 +283,20 @@ function serializeClaudeCredentials(creds) {
|
|
|
217
283
|
return { claudeAiOauth: oauth };
|
|
218
284
|
}
|
|
219
285
|
|
|
220
|
-
function writeClaudeKeychainCredentials(
|
|
286
|
+
function writeClaudeKeychainCredentials(
|
|
287
|
+
creds,
|
|
288
|
+
execFileSyncFn = execFileSync,
|
|
289
|
+
env = process.env,
|
|
290
|
+
) {
|
|
291
|
+
const username = getKeychainAccount();
|
|
292
|
+
const accountArgs = username ? ["-a", username] : [];
|
|
221
293
|
execFileSyncFn(
|
|
222
294
|
"security",
|
|
223
295
|
[
|
|
224
296
|
"add-generic-password",
|
|
225
297
|
"-s",
|
|
226
|
-
|
|
298
|
+
getKeychainServiceName(env),
|
|
299
|
+
...accountArgs,
|
|
227
300
|
"-w",
|
|
228
301
|
JSON.stringify(serializeClaudeCredentials(creds)),
|
|
229
302
|
"-U",
|
|
@@ -240,6 +313,7 @@ export function writeBackClaudeCredentials(
|
|
|
240
313
|
writeFileSync(CLAUDE_CREDENTIALS_PATH, JSON.stringify(data, null, 2)),
|
|
241
314
|
platform = process.platform,
|
|
242
315
|
execFileSyncFn = execFileSync,
|
|
316
|
+
env = process.env,
|
|
243
317
|
} = {},
|
|
244
318
|
) {
|
|
245
319
|
if (creds?.source === "env") return;
|
|
@@ -265,7 +339,7 @@ export function writeBackClaudeCredentials(
|
|
|
265
339
|
|
|
266
340
|
if (platform === "darwin") {
|
|
267
341
|
try {
|
|
268
|
-
writeClaudeKeychainCredentials(creds, execFileSyncFn);
|
|
342
|
+
writeClaudeKeychainCredentials(creds, execFileSyncFn, env);
|
|
269
343
|
} catch {
|
|
270
344
|
/* Keychain 쓰기 실패 무시 */
|
|
271
345
|
}
|
|
@@ -283,6 +357,7 @@ export function fetchClaudeUsageFromApi(accessToken) {
|
|
|
283
357
|
Authorization: `Bearer ${accessToken}`,
|
|
284
358
|
"anthropic-beta": "oauth-2025-04-20",
|
|
285
359
|
"Content-Type": "application/json",
|
|
360
|
+
"User-Agent": getClaudeCodeUserAgent(),
|
|
286
361
|
},
|
|
287
362
|
timeout: CLAUDE_API_TIMEOUT_MS,
|
|
288
363
|
},
|
|
@@ -320,7 +395,7 @@ export function parseClaudeUsageResponse(response) {
|
|
|
320
395
|
// 키 자체가 부재하면 percent=null (HUD --% placeholder), utilization=null 만 0%로 처리
|
|
321
396
|
const fiveHourPresent = response.five_hour != null;
|
|
322
397
|
const sevenDayPresent = response.seven_day != null;
|
|
323
|
-
|
|
398
|
+
const result = {
|
|
324
399
|
fiveHourPercent: fiveHourPresent
|
|
325
400
|
? clampPercent(response.five_hour.utilization ?? 0)
|
|
326
401
|
: null,
|
|
@@ -330,6 +405,19 @@ export function parseClaudeUsageResponse(response) {
|
|
|
330
405
|
fiveHourResetsAt: response.five_hour?.resets_at || null,
|
|
331
406
|
weeklyResetsAt: response.seven_day?.resets_at || null,
|
|
332
407
|
};
|
|
408
|
+
if (response.seven_day_sonnet != null) {
|
|
409
|
+
result.sonnetWeeklyPercent = clampPercent(
|
|
410
|
+
response.seven_day_sonnet.utilization ?? 0,
|
|
411
|
+
);
|
|
412
|
+
result.sonnetWeeklyResetsAt = response.seven_day_sonnet.resets_at || null;
|
|
413
|
+
}
|
|
414
|
+
if (response.seven_day_opus != null) {
|
|
415
|
+
result.opusWeeklyPercent = clampPercent(
|
|
416
|
+
response.seven_day_opus.utilization ?? 0,
|
|
417
|
+
);
|
|
418
|
+
result.opusWeeklyResetsAt = response.seven_day_opus.resets_at || null;
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
333
421
|
}
|
|
334
422
|
|
|
335
423
|
// stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
|
|
@@ -350,6 +438,20 @@ export function stripStaleResets(data) {
|
|
|
350
438
|
advanceToNextCycle(t, SEVEN_DAY_MS),
|
|
351
439
|
).toISOString();
|
|
352
440
|
}
|
|
441
|
+
if (copy.sonnetWeeklyResetsAt) {
|
|
442
|
+
const t = new Date(copy.sonnetWeeklyResetsAt).getTime();
|
|
443
|
+
if (!Number.isNaN(t))
|
|
444
|
+
copy.sonnetWeeklyResetsAt = new Date(
|
|
445
|
+
advanceToNextCycle(t, SEVEN_DAY_MS),
|
|
446
|
+
).toISOString();
|
|
447
|
+
}
|
|
448
|
+
if (copy.opusWeeklyResetsAt) {
|
|
449
|
+
const t = new Date(copy.opusWeeklyResetsAt).getTime();
|
|
450
|
+
if (!Number.isNaN(t))
|
|
451
|
+
copy.opusWeeklyResetsAt = new Date(
|
|
452
|
+
advanceToNextCycle(t, SEVEN_DAY_MS),
|
|
453
|
+
).toISOString();
|
|
454
|
+
}
|
|
353
455
|
return copy;
|
|
354
456
|
}
|
|
355
457
|
|
package/hud/providers/gemini.mjs
CHANGED
|
@@ -8,7 +8,9 @@ import https from "node:https";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import {
|
|
11
|
+
ANTIGRAVITY_MODEL_ABBREV,
|
|
11
12
|
ANTIGRAVITY_OAUTH_PATHS,
|
|
13
|
+
ANTIGRAVITY_SETTINGS_PATH,
|
|
12
14
|
GEMINI_API_TIMEOUT_MS,
|
|
13
15
|
GEMINI_OAUTH_PATH,
|
|
14
16
|
GEMINI_PROJECT_CACHE_PATH,
|
|
@@ -116,6 +118,52 @@ export function getAntigravityEmail() {
|
|
|
116
118
|
return null;
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// agy 1.0.1+ settings.json `model` 필드에서 현재 장착 모델 라벨을 읽는다.
|
|
122
|
+
// 예: "Gemini 3.5 Flash (High)". 없으면 null.
|
|
123
|
+
export function getAntigravityCurrentModel() {
|
|
124
|
+
const s = readJson(ANTIGRAVITY_SETTINGS_PATH, null);
|
|
125
|
+
return s?.model || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 모델 라벨 → 2자 약어 (constants의 ANTIGRAVITY_MODEL_ABBREV 매핑).
|
|
129
|
+
// 매핑에 없으면 "<Head> ... (<Level>)" 패턴에서 자동 추출.
|
|
130
|
+
export function getAntigravityModelAbbrev(label) {
|
|
131
|
+
if (!label) return "??";
|
|
132
|
+
const known = ANTIGRAVITY_MODEL_ABBREV[label];
|
|
133
|
+
if (known) return known;
|
|
134
|
+
const parens = label.match(/\(([^)]+)\)/);
|
|
135
|
+
const head = label.match(/^(\w+)/);
|
|
136
|
+
if (head && parens) {
|
|
137
|
+
return `${head[1][0].toUpperCase()}${parens[1][0].toLowerCase()}`;
|
|
138
|
+
}
|
|
139
|
+
if (head) return head[1].slice(0, 2);
|
|
140
|
+
return "??";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 모델 라벨 → family ("gemini" | "claude" | "gpt-oss" | "unknown")
|
|
144
|
+
// quota 풀이 family 단위로 분리되므로 slot1 데이터 채울지 결정할 때 사용.
|
|
145
|
+
export function getAntigravityModelFamily(label) {
|
|
146
|
+
if (!label) return "unknown";
|
|
147
|
+
if (label.startsWith("Gemini")) return "gemini";
|
|
148
|
+
if (label.startsWith("Claude")) return "claude";
|
|
149
|
+
if (label.startsWith("GPT")) return "gpt-oss";
|
|
150
|
+
return "unknown";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Gemini family 통합 quota — 모든 bucket 중 최소 remainingFraction을 가진 bucket을 대표로 반환.
|
|
154
|
+
// /usage 실측 (2026-05-22): 4개 모델이 같은 reset window 공유하므로 결과는 사실상 family 단일 카운터.
|
|
155
|
+
export function deriveGeminiFamilyBucket(buckets) {
|
|
156
|
+
if (!Array.isArray(buckets) || buckets.length === 0) return null;
|
|
157
|
+
let chosen = null;
|
|
158
|
+
for (const b of buckets) {
|
|
159
|
+
if (b?.remainingFraction == null) continue;
|
|
160
|
+
if (!chosen || b.remainingFraction < chosen.remainingFraction) {
|
|
161
|
+
chosen = b;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return chosen;
|
|
165
|
+
}
|
|
166
|
+
|
|
119
167
|
export function buildGeminiAuthContext(accountId) {
|
|
120
168
|
const oauth = readJson(GEMINI_OAUTH_PATH, null);
|
|
121
169
|
const tokenSource =
|
package/hud/renderers.mjs
CHANGED
|
@@ -375,6 +375,34 @@ export function getClaudeRows(
|
|
|
375
375
|
: tierDimBar(currentTier);
|
|
376
376
|
const fTime = formatTimeCell(fiveHourReset);
|
|
377
377
|
const wTime = formatTimeCellDH(weeklyReset);
|
|
378
|
+
const modelWeeklyPart = (label, percent, resetsAt, { includeTime }) => {
|
|
379
|
+
if (!hasData || percent == null || percent <= 0) return null;
|
|
380
|
+
const value = colorByProvider(
|
|
381
|
+
percent,
|
|
382
|
+
formatPercentCell(percent),
|
|
383
|
+
claudeOrange,
|
|
384
|
+
);
|
|
385
|
+
const reset = resetsAt
|
|
386
|
+
? formatResetRemainingDayHour(resetsAt, SEVEN_DAY_MS)
|
|
387
|
+
: null;
|
|
388
|
+
const time = includeTime && reset ? ` ${dim(formatTimeCellDH(reset))}` : "";
|
|
389
|
+
return `${dim(`${label}:`)}${value}${time}`;
|
|
390
|
+
};
|
|
391
|
+
const modelWeeklyParts = ({ includeTime = false } = {}) =>
|
|
392
|
+
[
|
|
393
|
+
modelWeeklyPart(
|
|
394
|
+
"Sn",
|
|
395
|
+
claudeUsage?.sonnetWeeklyPercent,
|
|
396
|
+
claudeUsage?.sonnetWeeklyResetsAt,
|
|
397
|
+
{ includeTime },
|
|
398
|
+
),
|
|
399
|
+
modelWeeklyPart(
|
|
400
|
+
"Op",
|
|
401
|
+
claudeUsage?.opusWeeklyPercent,
|
|
402
|
+
claudeUsage?.opusWeeklyResetsAt,
|
|
403
|
+
{ includeTime },
|
|
404
|
+
),
|
|
405
|
+
].filter(Boolean);
|
|
378
406
|
|
|
379
407
|
if (currentTier === "nano" || currentTier === "micro") {
|
|
380
408
|
const fShort =
|
|
@@ -391,13 +419,18 @@ export function getClaudeRows(
|
|
|
391
419
|
|
|
392
420
|
if (currentTier === "minimal") {
|
|
393
421
|
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
394
|
-
const
|
|
422
|
+
const modelSection = modelWeeklyParts();
|
|
423
|
+
const modelSuffix = modelSection.length ? ` ${modelSection.join(" ")}` : "";
|
|
424
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}${modelSuffix}${staleTag}`;
|
|
395
425
|
const right = `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
|
|
396
426
|
return [{ prefix, left: quotaSection, right }];
|
|
397
427
|
}
|
|
398
428
|
|
|
399
429
|
if (currentTier === "compact") {
|
|
400
|
-
const
|
|
430
|
+
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
431
|
+
const modelSection = modelWeeklyParts();
|
|
432
|
+
const modelSuffix = modelSection.length ? ` ${modelSection.join(" ")}` : "";
|
|
433
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}${modelSuffix}${staleTag}`;
|
|
401
434
|
const warning = ctxView.warningTag
|
|
402
435
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
403
436
|
: "";
|
|
@@ -406,7 +439,10 @@ export function getClaudeRows(
|
|
|
406
439
|
}
|
|
407
440
|
|
|
408
441
|
// full tier (>= 120 cols)
|
|
409
|
-
const
|
|
442
|
+
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
443
|
+
const modelSection = modelWeeklyParts({ includeTime: true });
|
|
444
|
+
const modelSuffix = modelSection.length ? ` ${modelSection.join(" ")}` : "";
|
|
445
|
+
const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}${modelSuffix}${staleTag}`;
|
|
410
446
|
const warning = ctxView.warningTag
|
|
411
447
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
412
448
|
: "";
|
|
@@ -434,14 +470,56 @@ export function getAccountLabel(
|
|
|
434
470
|
return label;
|
|
435
471
|
}
|
|
436
472
|
|
|
437
|
-
|
|
473
|
+
// 2슬롯: slot1 = 현재 장착 모델 약어 (Fh/Fm/Ph/Pl 등), slot2 = "Gn" Gemini family 통합
|
|
474
|
+
// realQuota.pools.{current,gemini_family} 가 있으면 실데이터, 없으면 placeholder.
|
|
475
|
+
function formatAntigravityQuotaSection(
|
|
476
|
+
currentTier,
|
|
477
|
+
realQuota,
|
|
478
|
+
provFn,
|
|
479
|
+
provAnsi,
|
|
480
|
+
) {
|
|
481
|
+
const pools = realQuota?.pools || {};
|
|
482
|
+
const abbr = realQuota?.currentAbbrev || "??";
|
|
483
|
+
const currentBucket = pools.current;
|
|
484
|
+
const familyBucket = pools.gemini_family;
|
|
485
|
+
const hasData = Boolean(currentBucket || familyBucket);
|
|
486
|
+
|
|
487
|
+
if (!hasData) {
|
|
488
|
+
// 기존 placeholder 동작 유지
|
|
489
|
+
if (currentTier === "minimal") {
|
|
490
|
+
return `${dim(`${abbr}:`)}${dim(formatPlaceholderPercentCell())} ${dim("Gn:")}${dim(formatPlaceholderPercentCell())}`;
|
|
491
|
+
}
|
|
492
|
+
if (currentTier === "compact") {
|
|
493
|
+
return `${dim(`${abbr}:`)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Gn:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
494
|
+
}
|
|
495
|
+
return `${dim(`${abbr}:`)}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Gn:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const slot = (bucket, label, { withBar, withTime }) => {
|
|
499
|
+
if (!bucket) {
|
|
500
|
+
const bar = withBar ? tierDimBar(currentTier) : "";
|
|
501
|
+
const base = `${dim(`${label}:`)}${bar}${dim(formatPlaceholderPercentCell())}`;
|
|
502
|
+
return withTime ? `${base} ${dim(formatTimeCell("n/a"))}` : base;
|
|
503
|
+
}
|
|
504
|
+
const gl = deriveGeminiLimits(bucket);
|
|
505
|
+
const usedP = gl
|
|
506
|
+
? gl.usedPct
|
|
507
|
+
: clampPercent(Math.round((1 - (bucket.remainingFraction ?? 1)) * 100));
|
|
508
|
+
const bar = withBar ? tierBar(currentTier, usedP, provAnsi) : "";
|
|
509
|
+
const base = `${dim(`${label}:`)}${bar}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
|
|
510
|
+
if (!withTime) return base;
|
|
511
|
+
const rstRemaining =
|
|
512
|
+
formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
|
|
513
|
+
return `${base} ${dim(formatTimeCell(rstRemaining))}`;
|
|
514
|
+
};
|
|
515
|
+
|
|
438
516
|
if (currentTier === "minimal") {
|
|
439
|
-
return `${
|
|
517
|
+
return `${slot(currentBucket, abbr, { withBar: false, withTime: false })} ${slot(familyBucket, "Gn", { withBar: false, withTime: false })}`;
|
|
440
518
|
}
|
|
441
519
|
if (currentTier === "compact") {
|
|
442
|
-
return `${
|
|
520
|
+
return `${slot(currentBucket, abbr, { withBar: false, withTime: true })} ${slot(familyBucket, "Gn", { withBar: false, withTime: true })}`;
|
|
443
521
|
}
|
|
444
|
-
return `${
|
|
522
|
+
return `${slot(currentBucket, abbr, { withBar: true, withTime: true })} ${slot(familyBucket, "Gn", { withBar: true, withTime: true })}`;
|
|
445
523
|
}
|
|
446
524
|
|
|
447
525
|
export function getProviderRow(
|
|
@@ -540,7 +618,12 @@ export function getProviderRow(
|
|
|
540
618
|
|
|
541
619
|
if (currentTier === "minimal") {
|
|
542
620
|
if (provider === "antigravity") {
|
|
543
|
-
quotaSection = formatAntigravityQuotaSection(
|
|
621
|
+
quotaSection = formatAntigravityQuotaSection(
|
|
622
|
+
currentTier,
|
|
623
|
+
realQuota,
|
|
624
|
+
provFn,
|
|
625
|
+
provAnsi,
|
|
626
|
+
);
|
|
544
627
|
}
|
|
545
628
|
if (realQuota?.type === "codex") {
|
|
546
629
|
const main =
|
|
@@ -596,7 +679,12 @@ export function getProviderRow(
|
|
|
596
679
|
|
|
597
680
|
if (currentTier === "compact") {
|
|
598
681
|
if (provider === "antigravity") {
|
|
599
|
-
quotaSection = formatAntigravityQuotaSection(
|
|
682
|
+
quotaSection = formatAntigravityQuotaSection(
|
|
683
|
+
currentTier,
|
|
684
|
+
realQuota,
|
|
685
|
+
provFn,
|
|
686
|
+
provAnsi,
|
|
687
|
+
);
|
|
600
688
|
}
|
|
601
689
|
if (realQuota?.type === "codex") {
|
|
602
690
|
const main =
|
|
@@ -664,7 +752,12 @@ export function getProviderRow(
|
|
|
664
752
|
|
|
665
753
|
// full tier
|
|
666
754
|
if (provider === "antigravity") {
|
|
667
|
-
quotaSection = formatAntigravityQuotaSection(
|
|
755
|
+
quotaSection = formatAntigravityQuotaSection(
|
|
756
|
+
currentTier,
|
|
757
|
+
realQuota,
|
|
758
|
+
provFn,
|
|
759
|
+
provAnsi,
|
|
760
|
+
);
|
|
668
761
|
}
|
|
669
762
|
|
|
670
763
|
if (realQuota?.type === "codex") {
|