@triflux/core 10.28.1 → 10.30.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/hook-orchestrator.mjs +24 -0
- package/hooks/session-end-cleanup.mjs +15 -0
- package/hooks/session-start-fast.mjs +158 -0
- package/hub/team/synapse-http.mjs +166 -0
- package/hud/constants.mjs +45 -0
- package/hud/hud-qos-status.mjs +43 -2
- package/hud/providers/claude.mjs +88 -13
- package/hud/providers/cto.mjs +73 -0
- package/hud/providers/gemini.mjs +48 -0
- package/hud/renderers.mjs +68 -9
- package/package.json +1 -1
|
@@ -441,6 +441,30 @@ async function main() {
|
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
// ── UserPromptSubmit: interactive 세션 liveness heartbeat ──
|
|
445
|
+
// SessionStart 가 register, 매 프롬프트가 갱신한다(사용자 활동 = liveness). 이
|
|
446
|
+
// 갱신이 없으면 hub monitor 가 5분 TTL 후 세션을 stale 로 전이시켜 `cto status`
|
|
447
|
+
// 의 live_sessions 가 항상 비어 보인다. fire-and-forget POST 는 process.exit 가
|
|
448
|
+
// 버리므로(cf. [[project_process_exit_drops_fire_and_forget]]) 발사 직후 바로
|
|
449
|
+
// drain 한다 — 아래 registry hook 이 없어 early-exit 하는 경로에서도 flush 를
|
|
450
|
+
// 보장하기 위해서다. drain 은 localhost POST 라 보통 즉시 반환되고, hub stall
|
|
451
|
+
// 시에만 짧은 상한(500ms)으로 프롬프트 지연을 막는다. keyword-detector /
|
|
452
|
+
// north-star 등 registry hook 은 그 뒤로 평소대로 실행된다.
|
|
453
|
+
if (eventName === "UserPromptSubmit") {
|
|
454
|
+
try {
|
|
455
|
+
const { heartbeatInteractiveSession } = await import(
|
|
456
|
+
"./session-start-fast.mjs"
|
|
457
|
+
);
|
|
458
|
+
heartbeatInteractiveSession(stdinRaw);
|
|
459
|
+
const { drainPendingSynapse } = await import(
|
|
460
|
+
"../hub/team/synapse-http.mjs"
|
|
461
|
+
);
|
|
462
|
+
await drainPendingSynapse(500);
|
|
463
|
+
} catch {
|
|
464
|
+
/* best-effort — heartbeat 실패가 프롬프트 턴을 막지 않는다 */
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
444
468
|
// 이벤트에 해당하는 훅 목록
|
|
445
469
|
const hooks = registry.events[eventName];
|
|
446
470
|
if (!hooks || hooks.length === 0) process.exit(0);
|
|
@@ -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,15 @@
|
|
|
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
|
+
buildSynapseTaskSummary,
|
|
18
|
+
drainPendingSynapse,
|
|
19
|
+
heartbeatSynapseSession,
|
|
20
|
+
registerSynapseSession,
|
|
21
|
+
} from "../hub/team/synapse-http.mjs";
|
|
15
22
|
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
16
23
|
|
|
17
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -20,6 +27,145 @@ const importMod = (p) => import(pathToFileURL(p).href);
|
|
|
20
27
|
|
|
21
28
|
const log = createModuleLogger("session-start-fast");
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* SessionStart 페이로드(JSON 문자열)에서 session_id / cwd 를 추출한다.
|
|
32
|
+
* 파싱 실패는 빈 객체로 흡수한다 (BLOCKING path 에 절대 영향 없음).
|
|
33
|
+
*/
|
|
34
|
+
function parseStartPayload(stdinData) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = typeof stdinData === "string" ? stdinData : "";
|
|
37
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* cwd 기준 git 컨텍스트(worktree root / branch)를 best-effort, 비동기로 수집.
|
|
45
|
+
* execFileSync 와 달리 호출자(BACKGROUND)를 블로킹하지 않는다. 각 git 호출은
|
|
46
|
+
* tight timeout(1.5s) + 강제 kill 로 묶여 hub/디스크 stall 시에도 잔류하지 않는다.
|
|
47
|
+
* @param {string} cwd
|
|
48
|
+
* @param {(args:string[], cb:(out:string)=>void)=>void} [gitRunner] 테스트용 seam
|
|
49
|
+
* @returns {Promise<{ worktreePath: string, branch: string }>}
|
|
50
|
+
*/
|
|
51
|
+
function gitContextAsync(cwd, gitRunner = defaultGitRunner) {
|
|
52
|
+
const run = (args) =>
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
try {
|
|
55
|
+
gitRunner(cwd, args, (out) => resolve(out));
|
|
56
|
+
} catch {
|
|
57
|
+
resolve("");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return Promise.all([
|
|
61
|
+
run(["rev-parse", "--show-toplevel"]),
|
|
62
|
+
run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
63
|
+
]).then(([toplevel, branch]) => ({
|
|
64
|
+
worktreePath: toplevel || cwd,
|
|
65
|
+
branch,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 기본 git runner: execFile(async) + tight timeout + kill. 실패는 빈 문자열. */
|
|
70
|
+
function defaultGitRunner(cwd, args, cb) {
|
|
71
|
+
execFile(
|
|
72
|
+
"git",
|
|
73
|
+
args,
|
|
74
|
+
{
|
|
75
|
+
cwd,
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 1500,
|
|
78
|
+
killSignal: "SIGKILL",
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
},
|
|
81
|
+
(err, stdout) => {
|
|
82
|
+
cb(err ? "" : String(stdout || "").trim());
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 인터랙티브 세션을 Synapse 레지스트리에 self-register (fire-and-forget).
|
|
89
|
+
* hub 미응답이면 silent no-op. BLOCKING path 에 latency 0 — git 컨텍스트는
|
|
90
|
+
* 블로킹 경로 밖에서 비동기로 enrich 한다 (cwd 만으로 즉시 minimal register).
|
|
91
|
+
*
|
|
92
|
+
* pid 는 의도적으로 보내지 않는다: 이 훅 프로세스의 process.pid 는 short-lived
|
|
93
|
+
* 훅 PID 일 뿐 세션 PID 가 아니므로 오기재가 된다. liveness 는 TTL 이 담당한다.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} stdinData
|
|
96
|
+
* @param {object} [seams] 테스트용 injectable seam
|
|
97
|
+
* @param {Function} [seams.register] registerSynapseSession 대체
|
|
98
|
+
* @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
|
|
99
|
+
* @param {Function} [seams.gitRunner] git runner 대체
|
|
100
|
+
* @returns {void} (enrich 는 백그라운드에서 완료)
|
|
101
|
+
*/
|
|
102
|
+
export function registerInteractiveSession(stdinData, seams = {}) {
|
|
103
|
+
const register = seams.register || registerSynapseSession;
|
|
104
|
+
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
105
|
+
const gitRunner = seams.gitRunner || defaultGitRunner;
|
|
106
|
+
try {
|
|
107
|
+
const payload = parseStartPayload(stdinData);
|
|
108
|
+
const sessionId = String(payload?.session_id || "").trim();
|
|
109
|
+
if (!sessionId) return;
|
|
110
|
+
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
111
|
+
|
|
112
|
+
// 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).
|
|
113
|
+
register({
|
|
114
|
+
sessionId,
|
|
115
|
+
cwd,
|
|
116
|
+
worktreePath: cwd,
|
|
117
|
+
branch: "",
|
|
118
|
+
host: "local",
|
|
119
|
+
sessionKind: "interactive",
|
|
120
|
+
isRemote: false,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 2) worktree/branch 는 블로킹 경로 밖에서 비동기 enrich → heartbeat partial.
|
|
124
|
+
gitContextAsync(cwd, gitRunner)
|
|
125
|
+
.then(({ worktreePath, branch }) => {
|
|
126
|
+
if (worktreePath === cwd && !branch) return; // 추가 정보 없음
|
|
127
|
+
heartbeat(sessionId, { worktreePath, branch });
|
|
128
|
+
})
|
|
129
|
+
.catch(() => {});
|
|
130
|
+
} catch {
|
|
131
|
+
/* best-effort — never affects session start */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* UserPromptSubmit 마다 interactive 세션의 liveness 를 갱신한다.
|
|
137
|
+
* SessionStart 가 register, 이후 매 프롬프트가 heartbeat — 사용자 활동이 곧
|
|
138
|
+
* liveness 다. 이 갱신이 없으면 hub monitor 가 5분 TTL 후 세션을 stale 로 전이시켜
|
|
139
|
+
* `cto status` 의 live_sessions 가 항상 비어 보인다.
|
|
140
|
+
*
|
|
141
|
+
* heartbeat 는 fire-and-forget POST 이므로 호출측(hook-orchestrator)이
|
|
142
|
+
* process.exit 전에 drainPendingSynapse 로 flush 해야 drop 되지 않는다
|
|
143
|
+
* (cf. SessionStart 의 execute() 자체 drain).
|
|
144
|
+
*
|
|
145
|
+
* pid 는 register 와 동일하게 보내지 않는다(91-92 주석): liveness 는 TTL +
|
|
146
|
+
* heartbeat 가 담당한다.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} stdinData UserPromptSubmit stdin JSON
|
|
149
|
+
* @param {object} [seams] 테스트용 injectable seam
|
|
150
|
+
* @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
|
|
151
|
+
* @returns {void}
|
|
152
|
+
*/
|
|
153
|
+
export function heartbeatInteractiveSession(stdinData, seams = {}) {
|
|
154
|
+
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
155
|
+
try {
|
|
156
|
+
const payload = parseStartPayload(stdinData);
|
|
157
|
+
const sessionId = String(payload?.session_id || "").trim();
|
|
158
|
+
if (!sessionId) return;
|
|
159
|
+
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
160
|
+
const partial = { worktreePath: cwd, host: "local" };
|
|
161
|
+
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
162
|
+
if (prompt) partial.taskSummary = buildSynapseTaskSummary(prompt);
|
|
163
|
+
heartbeat(sessionId, partial);
|
|
164
|
+
} catch {
|
|
165
|
+
/* best-effort — never affects the prompt turn */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
23
169
|
/**
|
|
24
170
|
* BLOCKING 훅을 순차 실행. 하나라도 throw하면 로그만 남기고 계속.
|
|
25
171
|
* @param {string} stdinData
|
|
@@ -174,6 +320,9 @@ function runBackground(stdinData) {
|
|
|
174
320
|
.then((mod) => mod.run(stdinData))
|
|
175
321
|
.catch(() => {}); // 완전 무시
|
|
176
322
|
|
|
323
|
+
// Synapse: 인터랙티브 세션 self-register (fire-and-forget, hub 미응답 무시)
|
|
324
|
+
registerInteractiveSession(stdinData);
|
|
325
|
+
|
|
177
326
|
// session-vault은 external source — hook-orchestrator가 execFile로 실행
|
|
178
327
|
}
|
|
179
328
|
|
|
@@ -195,6 +344,15 @@ export async function execute(stdinData, externalHooks = []) {
|
|
|
195
344
|
runDeferred(stdinData);
|
|
196
345
|
runBackground(stdinData);
|
|
197
346
|
|
|
347
|
+
// hook-orchestrator process.exit(0)s immediately after we return, which would
|
|
348
|
+
// drop the just-fired Synapse register POST before it flushes to the loopback
|
|
349
|
+
// socket (Node's exit abandons pending I/O). Drain it with a tight bound: the
|
|
350
|
+
// register is a fast localhost POST (hub was ensured up in the BLOCKING phase)
|
|
351
|
+
// and the ceiling caps a hub stall so SessionStart never hangs. The async
|
|
352
|
+
// git-enrich heartbeat stays best-effort (it may not have fired yet) — losing
|
|
353
|
+
// it only drops worktree/branch enrichment, not the core registration.
|
|
354
|
+
await drainPendingSynapse(1000);
|
|
355
|
+
|
|
198
356
|
const totalDur = performance.now() - totalStart;
|
|
199
357
|
log.info(
|
|
200
358
|
{
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
let timeout = null;
|
|
128
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
129
|
+
// Keep this timer ref'ed: drainPendingSynapse() is awaited specifically
|
|
130
|
+
// to hold the process open until either in-flight POSTs settle or the
|
|
131
|
+
// bounded drain budget expires. An unref'ed timer can let Node's test
|
|
132
|
+
// runner/process exit before the Promise resolves when no other handles
|
|
133
|
+
// remain.
|
|
134
|
+
timeout = setTimeout(resolve, ms);
|
|
135
|
+
});
|
|
136
|
+
return Promise.race([settled, timeoutPromise]).finally(() => {
|
|
137
|
+
if (timeout) clearTimeout(timeout);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function registerSynapseSession(meta, opts = {}) {
|
|
142
|
+
return fireAndForgetSynapse("/synapse/register", meta, opts);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function heartbeatSynapseSession(
|
|
146
|
+
sessionId,
|
|
147
|
+
partialMeta = {},
|
|
148
|
+
opts = {},
|
|
149
|
+
) {
|
|
150
|
+
// The live route reads `{ sessionId, partial }` (hub/server.mjs
|
|
151
|
+
// /synapse/heartbeat → synapseRegistry.heartbeat(sessionId, partial)), so the
|
|
152
|
+
// helper must nest the meta under `partial` rather than spreading it top-level.
|
|
153
|
+
return fireAndForgetSynapse(
|
|
154
|
+
"/synapse/heartbeat",
|
|
155
|
+
{
|
|
156
|
+
sessionId,
|
|
157
|
+
partial:
|
|
158
|
+
partialMeta && typeof partialMeta === "object" ? partialMeta : {},
|
|
159
|
+
},
|
|
160
|
+
opts,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function unregisterSynapseSession(sessionId, opts = {}) {
|
|
165
|
+
return fireAndForgetSynapse("/synapse/unregister", { sessionId }, opts);
|
|
166
|
+
}
|
package/hud/constants.mjs
CHANGED
|
@@ -36,6 +36,13 @@ export const TEAM_STATE_PATH = join(
|
|
|
36
36
|
"tfx-hub",
|
|
37
37
|
"team-state.json",
|
|
38
38
|
);
|
|
39
|
+
export const CTO_LAKE_CURRENT_RELATIVE_PATH = join(
|
|
40
|
+
".triflux",
|
|
41
|
+
"lake",
|
|
42
|
+
"current.md",
|
|
43
|
+
);
|
|
44
|
+
export const CTO_STATUS_STALE_MS = 60 * 60 * 1000; // 1시간
|
|
45
|
+
export const CTO_STATUS_LINE_MAX_CHARS = 140;
|
|
39
46
|
export const CONTEXT_MONITOR_CACHE_PATH = join(
|
|
40
47
|
homedir(),
|
|
41
48
|
".claude",
|
|
@@ -73,6 +80,21 @@ export const CLAUDE_CREDENTIALS_PATH = join(
|
|
|
73
80
|
".claude",
|
|
74
81
|
".credentials.json",
|
|
75
82
|
);
|
|
83
|
+
export const CLAUDE_CREDENTIALS_FILENAME = ".credentials.json";
|
|
84
|
+
export const CLAUDE_CODE_USER_AGENT_FALLBACK = "claude-code/2.x";
|
|
85
|
+
export function getClaudeCredentialPaths(env = process.env) {
|
|
86
|
+
const paths = [];
|
|
87
|
+
if (env.CLAUDE_CONFIG_DIR) {
|
|
88
|
+
paths.push(join(env.CLAUDE_CONFIG_DIR, CLAUDE_CREDENTIALS_FILENAME));
|
|
89
|
+
}
|
|
90
|
+
paths.push(CLAUDE_CREDENTIALS_PATH);
|
|
91
|
+
return paths;
|
|
92
|
+
}
|
|
93
|
+
export function getClaudeCodeUserAgent(env = process.env) {
|
|
94
|
+
return env.CLAUDE_CODE_VERSION
|
|
95
|
+
? `claude-code/${env.CLAUDE_CODE_VERSION}`
|
|
96
|
+
: CLAUDE_CODE_USER_AGENT_FALLBACK;
|
|
97
|
+
}
|
|
76
98
|
export const CLAUDE_USAGE_CACHE_PATH = join(
|
|
77
99
|
homedir(),
|
|
78
100
|
".claude",
|
|
@@ -143,6 +165,29 @@ export const ANTIGRAVITY_OAUTH_PATHS = [
|
|
|
143
165
|
join(homedir(), ".gemini", "antigravity-cli", "credentials.json"),
|
|
144
166
|
GEMINI_OAUTH_PATH,
|
|
145
167
|
];
|
|
168
|
+
export const ANTIGRAVITY_SETTINGS_PATH = join(
|
|
169
|
+
homedir(),
|
|
170
|
+
".gemini",
|
|
171
|
+
"antigravity-cli",
|
|
172
|
+
"settings.json",
|
|
173
|
+
);
|
|
174
|
+
// Antigravity CLI 현재 장착 모델 라벨 → HUD 2자 약어 매핑
|
|
175
|
+
// /usage 실측 (2026-05-22): Gemini 4개 모델이 동일 reset window 공유 (family-pool).
|
|
176
|
+
// Claude/GPT-OSS family는 Gemini와 별도 quota 윈도우.
|
|
177
|
+
export const ANTIGRAVITY_MODEL_ABBREV = {
|
|
178
|
+
// Gemini family — 통합 quota
|
|
179
|
+
"Gemini 3.5 Flash (High)": "Fh",
|
|
180
|
+
"Gemini 3.5 Flash (Medium)": "Fm",
|
|
181
|
+
"Gemini 3.5 Flash (Low)": "Fl",
|
|
182
|
+
"Gemini 3.1 Pro (High)": "Ph",
|
|
183
|
+
"Gemini 3.1 Pro (Low)": "Pl",
|
|
184
|
+
"Gemini 3 Flash": "F3",
|
|
185
|
+
// Claude family — 각자 별도 quota
|
|
186
|
+
"Claude Sonnet 4.6 (Thinking)": "Cs",
|
|
187
|
+
"Claude Opus 4.6 (Thinking)": "Co",
|
|
188
|
+
// GPT-OSS family — 각자 별도 quota
|
|
189
|
+
"GPT-OSS 120B (Medium)": "Go",
|
|
190
|
+
};
|
|
146
191
|
export const GEMINI_QUOTA_CACHE_PATH = join(
|
|
147
192
|
homedir(),
|
|
148
193
|
".claude",
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -47,11 +47,16 @@ import {
|
|
|
47
47
|
refreshCodexRateLimitsCache,
|
|
48
48
|
scheduleCodexRateLimitRefresh,
|
|
49
49
|
} from "./providers/codex.mjs";
|
|
50
|
+
import { readCtoStatus } from "./providers/cto.mjs";
|
|
50
51
|
// Gemini provider
|
|
51
52
|
import {
|
|
52
53
|
buildGeminiAuthContext,
|
|
54
|
+
deriveGeminiFamilyBucket,
|
|
53
55
|
fetchGeminiQuota,
|
|
56
|
+
getAntigravityCurrentModel,
|
|
54
57
|
getAntigravityEmail,
|
|
58
|
+
getAntigravityModelAbbrev,
|
|
59
|
+
getAntigravityModelFamily,
|
|
55
60
|
getGeminiEmail,
|
|
56
61
|
readGeminiQuotaSnapshot,
|
|
57
62
|
readGeminiSessionSnapshot,
|
|
@@ -78,6 +83,7 @@ import {
|
|
|
78
83
|
getProviderAccountId,
|
|
79
84
|
readJson,
|
|
80
85
|
readStdinJson,
|
|
86
|
+
stripAnsi,
|
|
81
87
|
} from "./utils.mjs";
|
|
82
88
|
|
|
83
89
|
// ============================================================================
|
|
@@ -151,7 +157,9 @@ async function main() {
|
|
|
151
157
|
// 실측 데이터 추출
|
|
152
158
|
const stdin = await stdinPromise;
|
|
153
159
|
const contextView = buildContextUsageView(stdin, contextSnapshot);
|
|
154
|
-
const claudeUsage = claudeUsageSnapshot.data
|
|
160
|
+
const claudeUsage = claudeUsageSnapshot.data
|
|
161
|
+
? { ...claudeUsageSnapshot.data, stale: claudeUsageSnapshot.isStale }
|
|
162
|
+
: claudeUsageSnapshot.data;
|
|
155
163
|
const codexEmail = getCodexEmail();
|
|
156
164
|
const geminiEmail = getGeminiEmail();
|
|
157
165
|
const antigravityEmail = getAntigravityEmail();
|
|
@@ -199,6 +207,15 @@ async function main() {
|
|
|
199
207
|
const geminiLiteBucket =
|
|
200
208
|
geminiBuckets.find((b) => b.modelId?.includes("flash-lite")) || null;
|
|
201
209
|
|
|
210
|
+
// Antigravity 현재 장착 모델 + family 통합 bucket (slot1 = 장착 모델 약어, slot2 = Gn family)
|
|
211
|
+
// slot1 quota: 현재 모델이 Gemini family면 family bucket, 아니면(Claude/GPT-OSS) placeholder.
|
|
212
|
+
const antigravityModel = getAntigravityCurrentModel();
|
|
213
|
+
const antigravityAbbrev = getAntigravityModelAbbrev(antigravityModel);
|
|
214
|
+
const antigravityModelFamily = getAntigravityModelFamily(antigravityModel);
|
|
215
|
+
const antigravityFamilyBucket = deriveGeminiFamilyBucket(geminiBuckets);
|
|
216
|
+
const antigravitySlot1Bucket =
|
|
217
|
+
antigravityModelFamily === "gemini" ? antigravityFamilyBucket : null;
|
|
218
|
+
|
|
202
219
|
// 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
|
|
203
220
|
const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
|
|
204
221
|
|
|
@@ -257,13 +274,37 @@ async function main() {
|
|
|
257
274
|
qosProfile,
|
|
258
275
|
accountsConfig,
|
|
259
276
|
accountsState,
|
|
260
|
-
antigravityReady
|
|
277
|
+
antigravityReady
|
|
278
|
+
? {
|
|
279
|
+
type: "antigravity",
|
|
280
|
+
pools: {
|
|
281
|
+
current: antigravitySlot1Bucket,
|
|
282
|
+
gemini_family: antigravityFamilyBucket,
|
|
283
|
+
},
|
|
284
|
+
currentAbbrev: antigravityAbbrev,
|
|
285
|
+
currentModel: antigravityModel,
|
|
286
|
+
currentFamily: antigravityModelFamily,
|
|
287
|
+
}
|
|
288
|
+
: geminiQuotaData,
|
|
261
289
|
antigravityReady ? antigravityEmail : geminiEmail,
|
|
262
290
|
antigravityReady ? null : geminiSv,
|
|
263
291
|
null,
|
|
264
292
|
),
|
|
265
293
|
];
|
|
266
294
|
|
|
295
|
+
const ctoStatus = readCtoStatus();
|
|
296
|
+
if (ctoStatus) {
|
|
297
|
+
const rightTag =
|
|
298
|
+
CURRENT_TIER === "nano" || CURRENT_TIER === "micro"
|
|
299
|
+
? ""
|
|
300
|
+
: stripAnsi(ctoStatus.rightTag || "").trim();
|
|
301
|
+
rows.push({
|
|
302
|
+
prefix: `${bold(claudeOrange("^"))}:`,
|
|
303
|
+
left: `${dim("cto:")}${stripAnsi(ctoStatus.line)}`,
|
|
304
|
+
right: rightTag ? dim(rightTag) : "",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
267
308
|
// tfx-multi 활성 시 팀 상태 행 추가 (v2.2)
|
|
268
309
|
const teamRow = getTeamRow(CURRENT_TIER);
|
|
269
310
|
if (teamRow) rows.push(teamRow);
|
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
|
},
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
CTO_LAKE_CURRENT_RELATIVE_PATH,
|
|
5
|
+
CTO_STATUS_LINE_MAX_CHARS,
|
|
6
|
+
CTO_STATUS_STALE_MS,
|
|
7
|
+
} from "../constants.mjs";
|
|
8
|
+
|
|
9
|
+
function oneLine(value) {
|
|
10
|
+
return String(value || "")
|
|
11
|
+
.replace(/\x1b\[[0-9;]*m/g, "")
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function capLine(line) {
|
|
17
|
+
const text = oneLine(line);
|
|
18
|
+
if (text.length <= CTO_STATUS_LINE_MAX_CHARS) return text;
|
|
19
|
+
return `${text.slice(0, CTO_STATUS_LINE_MAX_CHARS - 3)}...`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveCurrentPath({ currentPath, rootDir } = {}) {
|
|
23
|
+
if (currentPath) return currentPath;
|
|
24
|
+
return join(rootDir || process.cwd(), CTO_LAKE_CURRENT_RELATIVE_PATH);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractBriefVersion(lines) {
|
|
28
|
+
const versionLine = lines.find((line) => /^brief_version:\s*/i.test(line));
|
|
29
|
+
return oneLine(versionLine?.replace(/^brief_version:\s*/i, "")) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractStatusLine(lines) {
|
|
33
|
+
const summaryLine = lines.find((line) => /^summary:\s*/i.test(line));
|
|
34
|
+
const summary = oneLine(summaryLine?.replace(/^summary:\s*/i, ""));
|
|
35
|
+
if (summary) return capLine(summary);
|
|
36
|
+
|
|
37
|
+
const sectionHeaders = new Set([
|
|
38
|
+
"repo_state",
|
|
39
|
+
"active_goals",
|
|
40
|
+
"swarm_shards",
|
|
41
|
+
"hub_status",
|
|
42
|
+
"recent_events",
|
|
43
|
+
"generated_at",
|
|
44
|
+
"injection_note",
|
|
45
|
+
]);
|
|
46
|
+
const fallback = lines.find((line) => {
|
|
47
|
+
const clean = oneLine(line);
|
|
48
|
+
return (
|
|
49
|
+
clean && !/^brief_version:\s*/i.test(clean) && !sectionHeaders.has(clean)
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
return fallback ? capLine(fallback) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readCtoStatus(options = {}) {
|
|
56
|
+
try {
|
|
57
|
+
const currentPath = resolveCurrentPath(options);
|
|
58
|
+
const stat = statSync(currentPath);
|
|
59
|
+
const raw = readFileSync(currentPath, "utf8");
|
|
60
|
+
const lines = raw.split(/\r?\n/u).map(oneLine).filter(Boolean);
|
|
61
|
+
const line = extractStatusLine(lines);
|
|
62
|
+
if (!line) return null;
|
|
63
|
+
|
|
64
|
+
const version = extractBriefVersion(lines);
|
|
65
|
+
const isStale = Date.now() - stat.mtimeMs > CTO_STATUS_STALE_MS;
|
|
66
|
+
return {
|
|
67
|
+
line,
|
|
68
|
+
rightTag: isStale ? "stale" : version || "cto",
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
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
|
@@ -397,7 +397,8 @@ export function getClaudeRows(
|
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
if (currentTier === "compact") {
|
|
400
|
-
const
|
|
400
|
+
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
401
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}${staleTag}`;
|
|
401
402
|
const warning = ctxView.warningTag
|
|
402
403
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
403
404
|
: "";
|
|
@@ -406,7 +407,8 @@ export function getClaudeRows(
|
|
|
406
407
|
}
|
|
407
408
|
|
|
408
409
|
// full tier (>= 120 cols)
|
|
409
|
-
const
|
|
410
|
+
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
411
|
+
const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}${staleTag}`;
|
|
410
412
|
const warning = ctxView.warningTag
|
|
411
413
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
412
414
|
: "";
|
|
@@ -434,14 +436,56 @@ export function getAccountLabel(
|
|
|
434
436
|
return label;
|
|
435
437
|
}
|
|
436
438
|
|
|
437
|
-
|
|
439
|
+
// 2슬롯: slot1 = 현재 장착 모델 약어 (Fh/Fm/Ph/Pl 등), slot2 = "Gn" Gemini family 통합
|
|
440
|
+
// realQuota.pools.{current,gemini_family} 가 있으면 실데이터, 없으면 placeholder.
|
|
441
|
+
function formatAntigravityQuotaSection(
|
|
442
|
+
currentTier,
|
|
443
|
+
realQuota,
|
|
444
|
+
provFn,
|
|
445
|
+
provAnsi,
|
|
446
|
+
) {
|
|
447
|
+
const pools = realQuota?.pools || {};
|
|
448
|
+
const abbr = realQuota?.currentAbbrev || "??";
|
|
449
|
+
const currentBucket = pools.current;
|
|
450
|
+
const familyBucket = pools.gemini_family;
|
|
451
|
+
const hasData = Boolean(currentBucket || familyBucket);
|
|
452
|
+
|
|
453
|
+
if (!hasData) {
|
|
454
|
+
// 기존 placeholder 동작 유지
|
|
455
|
+
if (currentTier === "minimal") {
|
|
456
|
+
return `${dim(`${abbr}:`)}${dim(formatPlaceholderPercentCell())} ${dim("Gn:")}${dim(formatPlaceholderPercentCell())}`;
|
|
457
|
+
}
|
|
458
|
+
if (currentTier === "compact") {
|
|
459
|
+
return `${dim(`${abbr}:`)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Gn:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
460
|
+
}
|
|
461
|
+
return `${dim(`${abbr}:`)}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Gn:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const slot = (bucket, label, { withBar, withTime }) => {
|
|
465
|
+
if (!bucket) {
|
|
466
|
+
const bar = withBar ? tierDimBar(currentTier) : "";
|
|
467
|
+
const base = `${dim(`${label}:`)}${bar}${dim(formatPlaceholderPercentCell())}`;
|
|
468
|
+
return withTime ? `${base} ${dim(formatTimeCell("n/a"))}` : base;
|
|
469
|
+
}
|
|
470
|
+
const gl = deriveGeminiLimits(bucket);
|
|
471
|
+
const usedP = gl
|
|
472
|
+
? gl.usedPct
|
|
473
|
+
: clampPercent(Math.round((1 - (bucket.remainingFraction ?? 1)) * 100));
|
|
474
|
+
const bar = withBar ? tierBar(currentTier, usedP, provAnsi) : "";
|
|
475
|
+
const base = `${dim(`${label}:`)}${bar}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
|
|
476
|
+
if (!withTime) return base;
|
|
477
|
+
const rstRemaining =
|
|
478
|
+
formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
|
|
479
|
+
return `${base} ${dim(formatTimeCell(rstRemaining))}`;
|
|
480
|
+
};
|
|
481
|
+
|
|
438
482
|
if (currentTier === "minimal") {
|
|
439
|
-
return `${
|
|
483
|
+
return `${slot(currentBucket, abbr, { withBar: false, withTime: false })} ${slot(familyBucket, "Gn", { withBar: false, withTime: false })}`;
|
|
440
484
|
}
|
|
441
485
|
if (currentTier === "compact") {
|
|
442
|
-
return `${
|
|
486
|
+
return `${slot(currentBucket, abbr, { withBar: false, withTime: true })} ${slot(familyBucket, "Gn", { withBar: false, withTime: true })}`;
|
|
443
487
|
}
|
|
444
|
-
return `${
|
|
488
|
+
return `${slot(currentBucket, abbr, { withBar: true, withTime: true })} ${slot(familyBucket, "Gn", { withBar: true, withTime: true })}`;
|
|
445
489
|
}
|
|
446
490
|
|
|
447
491
|
export function getProviderRow(
|
|
@@ -540,7 +584,12 @@ export function getProviderRow(
|
|
|
540
584
|
|
|
541
585
|
if (currentTier === "minimal") {
|
|
542
586
|
if (provider === "antigravity") {
|
|
543
|
-
quotaSection = formatAntigravityQuotaSection(
|
|
587
|
+
quotaSection = formatAntigravityQuotaSection(
|
|
588
|
+
currentTier,
|
|
589
|
+
realQuota,
|
|
590
|
+
provFn,
|
|
591
|
+
provAnsi,
|
|
592
|
+
);
|
|
544
593
|
}
|
|
545
594
|
if (realQuota?.type === "codex") {
|
|
546
595
|
const main =
|
|
@@ -596,7 +645,12 @@ export function getProviderRow(
|
|
|
596
645
|
|
|
597
646
|
if (currentTier === "compact") {
|
|
598
647
|
if (provider === "antigravity") {
|
|
599
|
-
quotaSection = formatAntigravityQuotaSection(
|
|
648
|
+
quotaSection = formatAntigravityQuotaSection(
|
|
649
|
+
currentTier,
|
|
650
|
+
realQuota,
|
|
651
|
+
provFn,
|
|
652
|
+
provAnsi,
|
|
653
|
+
);
|
|
600
654
|
}
|
|
601
655
|
if (realQuota?.type === "codex") {
|
|
602
656
|
const main =
|
|
@@ -664,7 +718,12 @@ export function getProviderRow(
|
|
|
664
718
|
|
|
665
719
|
// full tier
|
|
666
720
|
if (provider === "antigravity") {
|
|
667
|
-
quotaSection = formatAntigravityQuotaSection(
|
|
721
|
+
quotaSection = formatAntigravityQuotaSection(
|
|
722
|
+
currentTier,
|
|
723
|
+
realQuota,
|
|
724
|
+
provFn,
|
|
725
|
+
provAnsi,
|
|
726
|
+
);
|
|
668
727
|
}
|
|
669
728
|
|
|
670
729
|
if (realQuota?.type === "codex") {
|