@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.
@@ -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",
@@ -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 ? { type: "antigravity" } : geminiQuotaData,
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,
@@ -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 readClaudeKeychainCredentials(execFileSyncFn = execFileSync) {
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
- const raw = execFileSyncFn(
118
- "security",
119
- ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"],
120
- { encoding: "utf8" },
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 = () => readJson(CLAUDE_CREDENTIALS_PATH, null),
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 fileCreds = normalizeClaudeCredentials(readCredentialFile(), "file");
135
- if (fileCreds) return fileCreds;
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(creds, execFileSyncFn = execFileSync) {
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
- CLAUDE_KEYCHAIN_SERVICE,
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
- return {
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
 
@@ -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 quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}${staleTag}`;
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 quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
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 quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
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
- function formatAntigravityQuotaSection(currentTier) {
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 `${dim("q:")}${dim(formatPlaceholderPercentCell())}`;
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 `${dim("q:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
520
+ return `${slot(currentBucket, abbr, { withBar: false, withTime: true })} ${slot(familyBucket, "Gn", { withBar: false, withTime: true })}`;
443
521
  }
444
- return `${dim("q:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
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(currentTier);
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(currentTier);
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(currentTier);
755
+ quotaSection = formatAntigravityQuotaSection(
756
+ currentTier,
757
+ realQuota,
758
+ provFn,
759
+ provAnsi,
760
+ );
668
761
  }
669
762
 
670
763
  if (realQuota?.type === "codex") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.28.1",
3
+ "version": "10.29.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",