@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.
@@ -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",
@@ -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 ? { type: "antigravity" } : geminiQuotaData,
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);
@@ -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
  },
@@ -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
+ }
@@ -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 quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
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 quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
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
- function formatAntigravityQuotaSection(currentTier) {
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 `${dim("q:")}${dim(formatPlaceholderPercentCell())}`;
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 `${dim("q:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
486
+ return `${slot(currentBucket, abbr, { withBar: false, withTime: true })} ${slot(familyBucket, "Gn", { withBar: false, withTime: true })}`;
443
487
  }
444
- return `${dim("q:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
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(currentTier);
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(currentTier);
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(currentTier);
721
+ quotaSection = formatAntigravityQuotaSection(
722
+ currentTier,
723
+ realQuota,
724
+ provFn,
725
+ provAnsi,
726
+ );
668
727
  }
669
728
 
670
729
  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.30.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",