@triflux/core 10.34.0 → 10.35.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.
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { argv, exit, stdin, stdout } from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+ import { drainPendingSynapse as defaultDrainPendingSynapse } from "../hub/team/synapse-http.mjs";
6
+ import {
7
+ heartbeatInteractiveSession as defaultHeartbeatInteractiveSession,
8
+ registerInteractiveSession as defaultRegisterInteractiveSession,
9
+ } from "./session-start-fast.mjs";
10
+
11
+ // hub-ensure is loaded lazily so the byte-identical packages/core mirror of this
12
+ // file loads cleanly. packages/core mirrors scripts/lib only (not scripts/*), so a
13
+ // static `../scripts/hub-ensure.mjs` import would make the core copy throw
14
+ // ERR_MODULE_NOT_FOUND at load time. Mirrors codex-session-hook.mjs's pattern.
15
+ async function defaultHubEnsureRun(stdinData) {
16
+ const { run } = await import(
17
+ new URL("../scripts/hub-ensure.mjs", import.meta.url).href
18
+ );
19
+ return run(stdinData);
20
+ }
21
+
22
+ function parsePayload(stdinData) {
23
+ try {
24
+ const raw = typeof stdinData === "string" ? stdinData : "";
25
+ return raw.trim()
26
+ ? { ok: true, payload: JSON.parse(raw) }
27
+ : { ok: false, payload: {} };
28
+ } catch {
29
+ return { ok: false, payload: {} };
30
+ }
31
+ }
32
+
33
+ // Antigravity hook payloads use camelCase system metadata (conversationId,
34
+ // workspacePaths) rather than Codex's session_id/cwd. registerInteractiveSession
35
+ // and heartbeatInteractiveSession parse the Codex shape, so we adapt the agy
36
+ // payload into that shape before delegating. conversationId is the stable
37
+ // per-conversation UUID (== session identity); the first mounted workspace path
38
+ // is the effective cwd.
39
+ export function toSessionPayload(payload) {
40
+ const sessionId = String(payload?.conversationId || "").trim();
41
+ const workspacePaths = Array.isArray(payload?.workspacePaths)
42
+ ? payload.workspacePaths
43
+ : [];
44
+ const cwd =
45
+ typeof workspacePaths[0] === "string" && workspacePaths[0]
46
+ ? workspacePaths[0]
47
+ : process.cwd();
48
+ return JSON.stringify({ session_id: sessionId, cwd });
49
+ }
50
+
51
+ // agy has no distinct SessionStart / UserPromptSubmit events. PreInvocation
52
+ // fires before every model call, so the per-conversation invocationNum gates
53
+ // register (first call == session start) vs heartbeat (subsequent calls).
54
+ // An explicit argv mode (register|heartbeat) overrides, mirroring the codex hook.
55
+ export function normalizeMode(argvMode, payload) {
56
+ const direct = String(argvMode || "")
57
+ .trim()
58
+ .toLowerCase();
59
+ if (direct === "register" || direct === "heartbeat") return direct;
60
+
61
+ const invocationNum = Number(payload?.invocationNum);
62
+ if (Number.isFinite(invocationNum)) {
63
+ return invocationNum <= 1 ? "register" : "heartbeat";
64
+ }
65
+ // Unknown invocation index: register is idempotent and also ensures the hub,
66
+ // so it is the safe default for a first-contact payload.
67
+ return "register";
68
+ }
69
+
70
+ export async function runAgySessionHook(stdinData, opts = {}) {
71
+ const output = "{}\n";
72
+ const parsed = parsePayload(stdinData);
73
+ if (!parsed.ok) {
74
+ if (opts.writeStdout !== false) stdout.write(output);
75
+ return output;
76
+ }
77
+
78
+ const sessionPayload = toSessionPayload(parsed.payload);
79
+ const sessionId = JSON.parse(sessionPayload).session_id;
80
+ // No conversation id means nothing to register; stay a silent no-op.
81
+ if (!sessionId) {
82
+ if (opts.writeStdout !== false) stdout.write(output);
83
+ return output;
84
+ }
85
+
86
+ const mode = normalizeMode(opts.argvMode ?? argv[2], parsed.payload);
87
+ const hubEnsureRun = opts.hubEnsureRun || defaultHubEnsureRun;
88
+ const registerInteractiveSession =
89
+ opts.registerInteractiveSession || defaultRegisterInteractiveSession;
90
+ const heartbeatInteractiveSession =
91
+ opts.heartbeatInteractiveSession || defaultHeartbeatInteractiveSession;
92
+ const drainPendingSynapse =
93
+ opts.drainPendingSynapse || defaultDrainPendingSynapse;
94
+
95
+ try {
96
+ if (mode === "register") {
97
+ try {
98
+ await hubEnsureRun(sessionPayload);
99
+ } catch {}
100
+ try {
101
+ registerInteractiveSession(sessionPayload);
102
+ } catch {}
103
+ try {
104
+ await drainPendingSynapse(1000);
105
+ } catch {}
106
+ } else if (mode === "heartbeat") {
107
+ try {
108
+ heartbeatInteractiveSession(sessionPayload);
109
+ } catch {}
110
+ try {
111
+ await drainPendingSynapse(500);
112
+ } catch {}
113
+ }
114
+ } catch {
115
+ // agy session hooks are observational and must never block the session.
116
+ }
117
+
118
+ if (opts.writeStdout !== false) {
119
+ stdout.write(output);
120
+ }
121
+ return output;
122
+ }
123
+
124
+ function readStdin() {
125
+ return new Promise((resolve) => {
126
+ let data = "";
127
+ stdin.setEncoding("utf8");
128
+ stdin.on("data", (chunk) => {
129
+ data += chunk;
130
+ });
131
+ stdin.on("end", () => resolve(data));
132
+ stdin.on("error", () => resolve(data));
133
+ });
134
+ }
135
+
136
+ if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
137
+ const stdinData = await readStdin();
138
+ await runAgySessionHook(stdinData);
139
+ exit(0);
140
+ }
package/hub/bridge.mjs CHANGED
@@ -1186,6 +1186,36 @@ function daemonAttachErrorResult(error) {
1186
1186
  };
1187
1187
  }
1188
1188
 
1189
+ function daemonAttachProbeFailureResult(probe) {
1190
+ return {
1191
+ ok: false,
1192
+ text: "",
1193
+ raw: "",
1194
+ responseRaw: "",
1195
+ matchedCompletion: false,
1196
+ timedOut: false,
1197
+ closed: false,
1198
+ inputSent: false,
1199
+ error: probe?.error || probe?.reason || "Claude daemon unavailable",
1200
+ reason: probe?.reason || "daemon-unavailable",
1201
+ daemon: probe?.daemon ?? null,
1202
+ daemons: probe?.daemons ?? [],
1203
+ matches: probe?.matches ?? [],
1204
+ candidateResults: probe?.candidateResults ?? [],
1205
+ callerProvenance: probe?.callerProvenance ?? null,
1206
+ };
1207
+ }
1208
+
1209
+ function daemonProbeMetadata(probe) {
1210
+ return {
1211
+ daemon: probe?.daemon ?? null,
1212
+ daemons: probe?.daemons ?? [],
1213
+ matches: probe?.matches ?? [],
1214
+ candidateResults: probe?.candidateResults ?? [],
1215
+ callerProvenance: probe?.callerProvenance ?? null,
1216
+ };
1217
+ }
1218
+
1189
1219
  function daemonInterruptErrorResult(error) {
1190
1220
  return {
1191
1221
  ok: false,
@@ -1197,6 +1227,22 @@ function daemonInterruptErrorResult(error) {
1197
1227
  };
1198
1228
  }
1199
1229
 
1230
+ function daemonInterruptProbeFailureResult(probe) {
1231
+ return {
1232
+ ok: false,
1233
+ done: false,
1234
+ aborted: false,
1235
+ reason: probe?.reason || "interrupt_failed",
1236
+ inputSent: false,
1237
+ error: probe?.error || probe?.reason || "Claude daemon unavailable",
1238
+ daemon: probe?.daemon ?? null,
1239
+ daemons: probe?.daemons ?? [],
1240
+ matches: probe?.matches ?? [],
1241
+ candidateResults: probe?.candidateResults ?? [],
1242
+ callerProvenance: probe?.callerProvenance ?? null,
1243
+ };
1244
+ }
1245
+
1200
1246
  function numericOption(value, fallback) {
1201
1247
  const parsed = Number(value);
1202
1248
  return Number.isFinite(parsed) ? parsed : fallback;
@@ -1205,35 +1251,15 @@ function numericOption(value, fallback) {
1205
1251
  async function cmdDaemonProbe(args) {
1206
1252
  try {
1207
1253
  const payload = readBridgePayload(args);
1208
- const {
1209
- deriveClaudeDaemonPaths,
1210
- extractClaudeAgentSessions,
1211
- findDaemonJobBySessionId,
1212
- findDaemonJobByShort,
1213
- sendClaudeControlRequest,
1214
- } = await loadDaemonControl();
1215
- const daemonPaths = deriveClaudeDaemonPaths({
1254
+ const { probeClaudeDaemonCandidates } = await loadDaemonControl();
1255
+ const probe = await probeClaudeDaemonCandidates({
1216
1256
  configDir: payload.configDir,
1257
+ env: process.env,
1258
+ short: payload.short,
1259
+ sessionId: payload.sessionId,
1260
+ timeoutMs: numericOption(payload.timeoutMs, 6000),
1217
1261
  });
1218
- const list = await sendClaudeControlRequest(
1219
- daemonPaths.controlSock,
1220
- { proto: 1, op: "list" },
1221
- { timeoutMs: numericOption(payload.timeoutMs, 6000) },
1222
- );
1223
- const target = payload.sessionId
1224
- ? findDaemonJobBySessionId(list, payload.sessionId)
1225
- : payload.short
1226
- ? findDaemonJobByShort(list, payload.short)
1227
- : null;
1228
- const sessions = extractClaudeAgentSessions(list);
1229
-
1230
- return emitJson({
1231
- ok: list?.ok !== false,
1232
- controlSock: daemonPaths.controlSock,
1233
- sessions,
1234
- target: target || undefined,
1235
- error: list?.ok === false ? list?.error : undefined,
1236
- });
1262
+ return emitJson(probe);
1237
1263
  } catch (error) {
1238
1264
  return emitJson(daemonErrorResult(error));
1239
1265
  }
@@ -1246,33 +1272,49 @@ async function cmdDaemonAttach(args) {
1246
1272
 
1247
1273
  const {
1248
1274
  attachClaudeDaemonSession,
1249
- deriveClaudeDaemonPaths,
1250
- findDaemonJobBySessionId,
1251
- sendClaudeControlRequest,
1275
+ buildDaemonControlAuth,
1276
+ probeClaudeDaemonCandidates,
1252
1277
  } = await loadDaemonControl();
1253
- const daemonPaths = deriveClaudeDaemonPaths({
1278
+ const probe = await probeClaudeDaemonCandidates({
1254
1279
  configDir: payload.configDir,
1280
+ env: process.env,
1281
+ short: payload.short,
1282
+ sessionId: payload.sessionId,
1283
+ timeoutMs: numericOption(payload.timeoutMs, 6000),
1255
1284
  });
1256
- let short = payload.short;
1257
- if (!short && payload.sessionId) {
1258
- const list = await sendClaudeControlRequest(
1259
- daemonPaths.controlSock,
1260
- { proto: 1, op: "list" },
1261
- { timeoutMs: numericOption(payload.timeoutMs, 6000) },
1285
+ if (!probe.ok) return emitJson(daemonAttachProbeFailureResult(probe));
1286
+ const short = payload.short ?? probe.target?.short;
1287
+ if (!short) {
1288
+ return emitJson(
1289
+ daemonAttachProbeFailureResult({
1290
+ ...probe,
1291
+ ok: false,
1292
+ reason: "target-not-found",
1293
+ error: "short or resolvable sessionId is required",
1294
+ }),
1262
1295
  );
1263
- const target = findDaemonJobBySessionId(list, payload.sessionId);
1264
- short = target?.short;
1265
1296
  }
1266
- if (!short) throw new Error("short or resolvable sessionId is required");
1267
-
1268
- const result = await attachClaudeDaemonSession({
1269
- controlSock: daemonPaths.controlSock,
1270
- short,
1271
- input: payload.prompt,
1272
- cols: numericOption(payload.cols, undefined),
1273
- rows: numericOption(payload.rows, undefined),
1274
- timeoutMs: numericOption(payload.timeoutMs, 30_000),
1275
- });
1297
+ const controlAuth = await buildDaemonControlAuth(
1298
+ probe.daemon?.configDir ?? payload.configDir,
1299
+ );
1300
+
1301
+ let result;
1302
+ try {
1303
+ result = await attachClaudeDaemonSession({
1304
+ controlSock: probe.controlSock,
1305
+ short,
1306
+ input: payload.prompt,
1307
+ ...controlAuth,
1308
+ cols: numericOption(payload.cols, undefined),
1309
+ rows: numericOption(payload.rows, undefined),
1310
+ timeoutMs: numericOption(payload.timeoutMs, 30_000),
1311
+ });
1312
+ } catch (error) {
1313
+ return emitJson({
1314
+ ...daemonAttachErrorResult(error),
1315
+ ...daemonProbeMetadata(probe),
1316
+ });
1317
+ }
1276
1318
 
1277
1319
  return emitJson({
1278
1320
  ok: result.matchedCompletion === true,
@@ -1285,6 +1327,7 @@ async function cmdDaemonAttach(args) {
1285
1327
  inputSent: result.inputSent === true,
1286
1328
  error:
1287
1329
  result.handshake?.ok === false ? result.handshake?.error : undefined,
1330
+ ...daemonProbeMetadata(probe),
1288
1331
  });
1289
1332
  } catch (error) {
1290
1333
  return emitJson(daemonAttachErrorResult(error));
@@ -1296,33 +1339,49 @@ async function cmdDaemonInterrupt(args) {
1296
1339
  const payload = readBridgePayload(args);
1297
1340
 
1298
1341
  const {
1299
- deriveClaudeDaemonPaths,
1300
- findDaemonJobBySessionId,
1342
+ buildDaemonControlAuth,
1301
1343
  interruptClaudeDaemonSession,
1302
- sendClaudeControlRequest,
1344
+ probeClaudeDaemonCandidates,
1303
1345
  } = await loadDaemonControl();
1304
- const daemonPaths = deriveClaudeDaemonPaths({
1346
+ const probe = await probeClaudeDaemonCandidates({
1305
1347
  configDir: payload.configDir,
1348
+ env: process.env,
1349
+ short: payload.short,
1350
+ sessionId: payload.sessionId,
1351
+ timeoutMs: numericOption(payload.timeoutMs, 6000),
1306
1352
  });
1307
- let short = payload.short;
1308
- if (!short && payload.sessionId) {
1309
- const list = await sendClaudeControlRequest(
1310
- daemonPaths.controlSock,
1311
- { proto: 1, op: "list" },
1312
- { timeoutMs: numericOption(payload.timeoutMs, 6000) },
1353
+ if (!probe.ok) return emitJson(daemonInterruptProbeFailureResult(probe));
1354
+ const short = payload.short ?? probe.target?.short;
1355
+ if (!short) {
1356
+ return emitJson(
1357
+ daemonInterruptProbeFailureResult({
1358
+ ...probe,
1359
+ ok: false,
1360
+ reason: "target-not-found",
1361
+ error: "short or resolvable sessionId is required",
1362
+ }),
1313
1363
  );
1314
- const target = findDaemonJobBySessionId(list, payload.sessionId);
1315
- short = target?.short;
1316
1364
  }
1317
- if (!short) throw new Error("short or resolvable sessionId is required");
1318
-
1319
- const result = await interruptClaudeDaemonSession({
1320
- controlSock: daemonPaths.controlSock,
1321
- short,
1322
- cols: numericOption(payload.cols, undefined),
1323
- rows: numericOption(payload.rows, undefined),
1324
- timeoutMs: numericOption(payload.timeoutMs, 5000),
1325
- });
1365
+ const controlAuth = await buildDaemonControlAuth(
1366
+ probe.daemon?.configDir ?? payload.configDir,
1367
+ );
1368
+
1369
+ let result;
1370
+ try {
1371
+ result = await interruptClaudeDaemonSession({
1372
+ controlSock: probe.controlSock,
1373
+ short,
1374
+ ...controlAuth,
1375
+ cols: numericOption(payload.cols, undefined),
1376
+ rows: numericOption(payload.rows, undefined),
1377
+ timeoutMs: numericOption(payload.timeoutMs, 5000),
1378
+ });
1379
+ } catch (error) {
1380
+ return emitJson({
1381
+ ...daemonInterruptErrorResult(error),
1382
+ ...daemonProbeMetadata(probe),
1383
+ });
1384
+ }
1326
1385
  const aborted = result.inputSent === true;
1327
1386
 
1328
1387
  return emitJson({
@@ -1336,6 +1395,7 @@ async function cmdDaemonInterrupt(args) {
1336
1395
  inputSent: result.inputSent === true,
1337
1396
  error:
1338
1397
  result.handshake?.ok === false ? result.handshake?.error : undefined,
1398
+ ...daemonProbeMetadata(probe),
1339
1399
  });
1340
1400
  } catch (error) {
1341
1401
  return emitJson(daemonInterruptErrorResult(error));
@@ -16,7 +16,24 @@ import {
16
16
 
17
17
  export function resolveClaudeConfigDir(env = process.env) {
18
18
  if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
19
- return path.join(os.homedir(), ".claude");
19
+ return path.join(resolveClaudeHomeDir(env), ".claude");
20
+ }
21
+
22
+ export function resolveClaudeHomeDir(
23
+ env = process.env,
24
+ platform = process.platform,
25
+ ) {
26
+ // win32: claude daemon launcher 의 os.homedir() 는 USERPROFILE 기반이고
27
+ // HOME 을 보지 않는다. git-bash 가 HOME 을 따로 잡아도 launcher 폴백을
28
+ // 그대로 미러해야 socket hash 가 daemon 과 일치한다 (HOME 수용 금지).
29
+ if (platform === "win32") {
30
+ const userProfile = nullableEnv(env.USERPROFILE);
31
+ return userProfile ? path.resolve(userProfile) : os.homedir();
32
+ }
33
+ // POSIX 는 HOME 우선 — sandbox HOME 오버라이드를 추적하는 provenance
34
+ // 해석(#382)과 일치 (os.homedir() 도 POSIX 에서는 HOME 을 우선한다).
35
+ const home = nullableEnv(env.HOME);
36
+ return home ? path.resolve(home) : os.homedir();
20
37
  }
21
38
 
22
39
  export function deriveClaudeDaemonPaths({
@@ -44,6 +61,316 @@ export function deriveClaudeDaemonPaths({
44
61
  };
45
62
  }
46
63
 
64
+ function nullableEnv(value) {
65
+ const text = typeof value === "string" ? value.trim() : "";
66
+ return text.length > 0 ? text : null;
67
+ }
68
+
69
+ function defaultClaudeConfigDir(env = process.env) {
70
+ return path.join(resolveClaudeHomeDir(env), ".claude");
71
+ }
72
+
73
+ export function detectCallerProvenance(env = process.env) {
74
+ const claudeConfigDir = nullableEnv(env.CLAUDE_CONFIG_DIR);
75
+ const omxSessionId = nullableEnv(env.OMX_SESSION_ID);
76
+ const omxEntryPath = nullableEnv(env.OMX_ENTRY_PATH);
77
+ const codexThreadId = nullableEnv(env.CODEX_THREAD_ID);
78
+ const codexLauncher =
79
+ omxSessionId || omxEntryPath ? "omx" : codexThreadId ? "codex" : "unknown";
80
+ return {
81
+ claudeLauncher: "unknown",
82
+ codexLauncher,
83
+ signals: {
84
+ claudeConfigDir,
85
+ omxSessionId,
86
+ omxEntryPath,
87
+ codexThreadId,
88
+ },
89
+ };
90
+ }
91
+
92
+ export async function readOmcLaunchProfile(configDir) {
93
+ if (!configDir) return null;
94
+ try {
95
+ // Provenance hint only: OMC writes this profile in its runtime config dir.
96
+ // It is not an auth/security boundary and must not override explicit/env
97
+ // config-dir authority.
98
+ const parsed = JSON.parse(
99
+ await fs.readFile(
100
+ path.join(path.resolve(configDir), ".omc-launch-profile.json"),
101
+ "utf8",
102
+ ),
103
+ );
104
+ const sourceConfigDir = nullableEnv(parsed?.sourceConfigDir);
105
+ if (!sourceConfigDir) return null;
106
+ const sourceClaudeMd = nullableEnv(parsed?.sourceClaudeMd);
107
+ return {
108
+ sourceConfigDir: path.resolve(sourceConfigDir),
109
+ sourceClaudeMd: sourceClaudeMd ? path.resolve(sourceClaudeMd) : null,
110
+ };
111
+ } catch (error) {
112
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
113
+ return null;
114
+ }
115
+ }
116
+
117
+ async function buildClaudeDaemonCandidate({
118
+ configDir,
119
+ configDirSource,
120
+ selectionMode,
121
+ env,
122
+ uid,
123
+ tmpRoot,
124
+ }) {
125
+ const paths = deriveClaudeDaemonPaths({ configDir, uid, tmpRoot });
126
+ const defaultConfig = path.resolve(defaultClaudeConfigDir(env));
127
+ const omcProfile = await readOmcLaunchProfile(paths.configDir);
128
+ const claudeLauncher = omcProfile
129
+ ? "omc"
130
+ : paths.configDir === defaultConfig
131
+ ? "claude"
132
+ : "unknown";
133
+ return {
134
+ ...paths,
135
+ configDirSource,
136
+ selectionMode,
137
+ claudeLauncher,
138
+ sourceConfigDir: omcProfile?.sourceConfigDir,
139
+ sourceClaudeMd: omcProfile?.sourceClaudeMd ?? null,
140
+ callerProvenance: detectCallerProvenance(env),
141
+ };
142
+ }
143
+
144
+ export async function buildClaudeDaemonDiscoveryCandidates({
145
+ configDir,
146
+ env = process.env,
147
+ uid = typeof process.getuid === "function" ? process.getuid() : 0,
148
+ tmpRoot = "/tmp",
149
+ } = {}) {
150
+ const explicitConfigDir = nullableEnv(configDir);
151
+ if (explicitConfigDir) {
152
+ return [
153
+ await buildClaudeDaemonCandidate({
154
+ configDir: explicitConfigDir,
155
+ configDirSource: "explicit",
156
+ selectionMode: "exact",
157
+ env,
158
+ uid,
159
+ tmpRoot,
160
+ }),
161
+ ];
162
+ }
163
+
164
+ const envConfigDir = nullableEnv(env.CLAUDE_CONFIG_DIR);
165
+ if (envConfigDir) {
166
+ return [
167
+ await buildClaudeDaemonCandidate({
168
+ configDir: envConfigDir,
169
+ configDirSource: "env",
170
+ selectionMode: "env-strict",
171
+ env,
172
+ uid,
173
+ tmpRoot,
174
+ }),
175
+ ];
176
+ }
177
+
178
+ const candidates = [];
179
+ const defaultConfig = defaultClaudeConfigDir(env);
180
+ const omcRuntime = path.join(defaultConfig, ".omc-launch");
181
+ if (await readOmcLaunchProfile(omcRuntime)) {
182
+ candidates.push(
183
+ await buildClaudeDaemonCandidate({
184
+ configDir: omcRuntime,
185
+ configDirSource: "omc-runtime",
186
+ selectionMode: "ambient",
187
+ env,
188
+ uid,
189
+ tmpRoot,
190
+ }),
191
+ );
192
+ }
193
+ candidates.push(
194
+ await buildClaudeDaemonCandidate({
195
+ configDir: defaultConfig,
196
+ configDirSource: "default",
197
+ selectionMode: "ambient",
198
+ env,
199
+ uid,
200
+ tmpRoot,
201
+ }),
202
+ );
203
+ return candidates;
204
+ }
205
+
206
+ export function publicClaudeDaemonCandidate(candidate) {
207
+ if (!candidate) return null;
208
+ return {
209
+ configDirSource: candidate.configDirSource,
210
+ selectionMode: candidate.selectionMode,
211
+ claudeLauncher: candidate.claudeLauncher,
212
+ configDir: candidate.configDir,
213
+ sourceConfigDir: candidate.sourceConfigDir ?? null,
214
+ sourceClaudeMd: candidate.sourceClaudeMd ?? null,
215
+ hash: candidate.hash,
216
+ controlSock: candidate.controlSock,
217
+ };
218
+ }
219
+
220
+ function sessionsFromDaemonList(listResponse) {
221
+ // probe 경로도 list/find 경로와 같은 정규화 row 를 내보내야 한다
222
+ // (jobs/sessions, snake_case, dispatch 중첩 변형 흡수 — 8016b724).
223
+ return extractClaudeAgentSessions(listResponse);
224
+ }
225
+
226
+ function errorMessage(error) {
227
+ return error?.message || String(error);
228
+ }
229
+
230
+ function findDaemonTargetInSessions(sessions, { short, sessionId } = {}) {
231
+ if (sessionId) {
232
+ return findDaemonJobBySessionId({ jobs: sessions }, sessionId);
233
+ }
234
+ if (short) {
235
+ return findDaemonJobByShort({ jobs: sessions }, short);
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function candidateResultSummary(result) {
241
+ const summary = publicClaudeDaemonCandidate(result.candidate);
242
+ return {
243
+ ...summary,
244
+ ok: result.ok === true,
245
+ error: result.error ?? null,
246
+ sessionCount: Array.isArray(result.sessions) ? result.sessions.length : 0,
247
+ };
248
+ }
249
+
250
+ function buildProbeResponse({
251
+ candidates,
252
+ results,
253
+ selected,
254
+ matches,
255
+ targetRequested,
256
+ callerProvenance,
257
+ }) {
258
+ const reachable = results.filter((result) => result.ok === true);
259
+ let ok = false;
260
+ let reason = "daemon-unavailable";
261
+ let error = null;
262
+
263
+ if (targetRequested && matches.length > 1) {
264
+ reason = "ambiguous-target";
265
+ error = `Claude daemon target ambiguous across ${matches.length} candidates`;
266
+ } else if (selected) {
267
+ ok = true;
268
+ reason = selected.target ? "target-found" : "daemon-available";
269
+ } else if (targetRequested && reachable.length > 0) {
270
+ reason = "target-not-found";
271
+ error = "Claude daemon target not found in reachable candidates";
272
+ } else if (results.length > 0) {
273
+ error =
274
+ results
275
+ .map((result) => result.error)
276
+ .filter(Boolean)
277
+ .join("; ") || "Claude daemon unavailable";
278
+ }
279
+
280
+ const selectedSessions = selected
281
+ ? selected.sessions
282
+ : targetRequested
283
+ ? []
284
+ : reachable.flatMap((result) => result.sessions);
285
+ const selectedDaemon = publicClaudeDaemonCandidate(selected?.candidate);
286
+ const reachableDaemons = reachable.map((result) =>
287
+ publicClaudeDaemonCandidate(result.candidate),
288
+ );
289
+
290
+ return {
291
+ ok,
292
+ reason,
293
+ controlSock: selected?.candidate?.controlSock,
294
+ daemon: selectedDaemon,
295
+ daemons: reachableDaemons,
296
+ sessions: selectedSessions,
297
+ target: selected?.target || undefined,
298
+ matches: matches.map((match) => ({
299
+ daemon: publicClaudeDaemonCandidate(match.candidate),
300
+ target: match.target,
301
+ })),
302
+ candidateResults: results.map(candidateResultSummary),
303
+ callerProvenance,
304
+ candidates: candidates.map(publicClaudeDaemonCandidate),
305
+ error: ok ? undefined : error,
306
+ };
307
+ }
308
+
309
+ export async function probeClaudeDaemonCandidates({
310
+ configDir,
311
+ env = process.env,
312
+ short,
313
+ sessionId,
314
+ timeoutMs = 6000,
315
+ } = {}) {
316
+ const candidates = await buildClaudeDaemonDiscoveryCandidates({
317
+ configDir,
318
+ env,
319
+ });
320
+ const callerProvenance = detectCallerProvenance(env);
321
+ const targetRequested = Boolean(short || sessionId);
322
+ const results = [];
323
+
324
+ for (const candidate of candidates) {
325
+ try {
326
+ const list = await sendClaudeControlRequest(
327
+ candidate.controlSock,
328
+ { proto: 1, op: "list" },
329
+ { timeoutMs },
330
+ );
331
+ const sessions = sessionsFromDaemonList(list);
332
+ const ok = list?.ok !== false;
333
+ results.push({
334
+ ok,
335
+ candidate,
336
+ list,
337
+ sessions: ok ? sessions : [],
338
+ target: ok
339
+ ? findDaemonTargetInSessions(sessions, { short, sessionId })
340
+ : null,
341
+ error: ok ? null : list?.error || "Claude daemon list failed",
342
+ });
343
+ } catch (error) {
344
+ results.push({
345
+ ok: false,
346
+ candidate,
347
+ list: null,
348
+ sessions: [],
349
+ target: null,
350
+ error: errorMessage(error),
351
+ });
352
+ }
353
+ }
354
+
355
+ const matches = targetRequested
356
+ ? results.filter((result) => result.ok === true && result.target)
357
+ : [];
358
+ const selected = targetRequested
359
+ ? matches.length === 1
360
+ ? matches[0]
361
+ : null
362
+ : results.find((result) => result.ok === true) || null;
363
+
364
+ return buildProbeResponse({
365
+ candidates,
366
+ results,
367
+ selected,
368
+ matches,
369
+ targetRequested,
370
+ callerProvenance,
371
+ });
372
+ }
373
+
47
374
  export function getProcStart(pid = process.pid) {
48
375
  if (!Number.isInteger(pid) || pid <= 0)
49
376
  throw new Error(`invalid pid: ${pid}`);
@@ -141,6 +468,7 @@ export function buildDaemonAttachRequest({
141
468
  cols = DEFAULT_DAEMON_ATTACH_COLS,
142
469
  rows = 40,
143
470
  caps = { terminal: null, mux: null, ssh: false },
471
+ auth,
144
472
  } = {}) {
145
473
  if (!short) throw new Error("short is required");
146
474
  return {
@@ -150,6 +478,7 @@ export function buildDaemonAttachRequest({
150
478
  cols,
151
479
  rows,
152
480
  caps,
481
+ ...(auth ? { auth } : {}),
153
482
  };
154
483
  }
155
484
 
@@ -683,6 +1012,7 @@ export function attachClaudeDaemonSession({
683
1012
  controlSock,
684
1013
  short,
685
1014
  input,
1015
+ auth,
686
1016
  cols = DEFAULT_DAEMON_ATTACH_COLS,
687
1017
  rows = 40,
688
1018
  caps,
@@ -790,7 +1120,9 @@ export function attachClaudeDaemonSession({
790
1120
  });
791
1121
  socket.on("connect", () => {
792
1122
  socket.write(
793
- `${JSON.stringify(buildDaemonAttachRequest({ short, cols, rows, caps }))}\n`,
1123
+ `${JSON.stringify(
1124
+ buildDaemonAttachRequest({ short, cols, rows, caps, auth }),
1125
+ )}\n`,
794
1126
  );
795
1127
  });
796
1128
  socket.on("data", (chunk) => {
@@ -906,6 +1238,7 @@ export function attachClaudeDaemonSession({
906
1238
  export function interruptClaudeDaemonSession({
907
1239
  controlSock,
908
1240
  short,
1241
+ auth,
909
1242
  cols = DEFAULT_DAEMON_ATTACH_COLS,
910
1243
  rows = 40,
911
1244
  caps,
@@ -956,7 +1289,9 @@ export function interruptClaudeDaemonSession({
956
1289
  socket.on("error", fail);
957
1290
  socket.on("connect", () => {
958
1291
  socket.write(
959
- `${JSON.stringify(buildDaemonAttachRequest({ short, cols, rows, caps }))}\n`,
1292
+ `${JSON.stringify(
1293
+ buildDaemonAttachRequest({ short, cols, rows, caps, auth }),
1294
+ )}\n`,
960
1295
  );
961
1296
  });
962
1297
  socket.on("data", (chunk) => {
@@ -1182,6 +1517,7 @@ export async function sendKillBySessionId({
1182
1517
  daemonPaths,
1183
1518
  sessionId,
1184
1519
  timeoutMs = 6000,
1520
+ auth,
1185
1521
  } = {}) {
1186
1522
  const controlSock = daemonPaths?.controlSock;
1187
1523
  if (!controlSock || !sessionId) {
@@ -1201,12 +1537,16 @@ export async function sendKillBySessionId({
1201
1537
  if (!job?.short) {
1202
1538
  return { ok: true, killed: false, reason: "not_found" };
1203
1539
  }
1540
+ const controlAuth = auth
1541
+ ? { auth }
1542
+ : await buildDaemonControlAuth(daemonPaths?.configDir);
1204
1543
  return await sendClaudeControlRequest(
1205
1544
  controlSock,
1206
1545
  {
1207
1546
  proto: 1,
1208
1547
  op: "kill",
1209
1548
  short: job.short,
1549
+ ...controlAuth,
1210
1550
  },
1211
1551
  { timeoutMs },
1212
1552
  );
@@ -1356,6 +1696,7 @@ export async function teardownClaudeDaemonJob({
1356
1696
  _deps.removeClaudeSessionProjection || removeClaudeSessionProjection;
1357
1697
  const killJob = _deps.killDaemonJob || killDaemonJob;
1358
1698
  const removeJobStateImpl = _deps.removeClaudeJobState;
1699
+ const buildAuth = _deps.buildDaemonControlAuth || buildDaemonControlAuth;
1359
1700
  const resolvedControlSock = controlSock || paths?.controlSock;
1360
1701
  const resolvedJobsDir =
1361
1702
  jobsDir ||
@@ -1371,7 +1712,10 @@ export async function teardownClaudeDaemonJob({
1371
1712
  steps.push(sendKillBySessionId({ daemonPaths, sessionId }).catch(() => {}));
1372
1713
  }
1373
1714
  if (resolvedControlSock && short) {
1374
- steps.push(killJob(resolvedControlSock, short).catch(() => {}));
1715
+ const controlAuth = await buildAuth(paths?.configDir);
1716
+ steps.push(
1717
+ killJob(resolvedControlSock, short, controlAuth).catch(() => {}),
1718
+ );
1375
1719
  }
1376
1720
  if (removeJobState && removeJobStateImpl && resolvedJobsDir && short) {
1377
1721
  steps.push(removeJobStateImpl(resolvedJobsDir, short).catch(() => {}));
package/hud/constants.mjs CHANGED
@@ -165,6 +165,8 @@ export const ANTIGRAVITY_OAUTH_PATHS = [
165
165
  join(homedir(), ".gemini", "antigravity-cli", "credentials.json"),
166
166
  GEMINI_OAUTH_PATH,
167
167
  ];
168
+ export const ANTIGRAVITY_KEYCHAIN_SERVICE = "gemini";
169
+ export const ANTIGRAVITY_KEYCHAIN_ACCOUNT = "antigravity";
168
170
  export const ANTIGRAVITY_SETTINGS_PATH = join(
169
171
  homedir(),
170
172
  ".gemini",
@@ -2,12 +2,14 @@
2
2
  // Gemini 쿼터 API / 세션 토큰 / RPM 트래커
3
3
  // ============================================================================
4
4
 
5
- import { spawn } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  import { existsSync, readdirSync, readFileSync } from "node:fs";
7
7
  import https from "node:https";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import {
11
+ ANTIGRAVITY_KEYCHAIN_ACCOUNT,
12
+ ANTIGRAVITY_KEYCHAIN_SERVICE,
11
13
  ANTIGRAVITY_MODEL_ABBREV,
12
14
  ANTIGRAVITY_OAUTH_PATHS,
13
15
  ANTIGRAVITY_SETTINGS_PATH,
@@ -61,7 +63,19 @@ export function getGeminiModelLabel(model) {
61
63
 
62
64
  // remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
63
65
  export function deriveGeminiLimits(bucket) {
64
- if (!bucket || bucket.remainingFraction == null) return null;
66
+ if (!bucket) return null;
67
+ // TODO(verify): Workspace quota 응답 모양 라이브 확인 필요
68
+ if (isGeminiUnlimitedBucket(bucket, bucket.remainingFraction)) {
69
+ return {
70
+ unlimited: true,
71
+ usedPct: null,
72
+ remaining: null,
73
+ limit: null,
74
+ resetTime: bucket.resetTime,
75
+ modelId: bucket.modelId,
76
+ };
77
+ }
78
+ if (bucket.remainingFraction == null) return null;
65
79
  const fraction = bucket.remainingFraction;
66
80
  const usedPct = clampPercent(Math.round((1 - fraction) * 100));
67
81
  // remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
@@ -85,6 +99,23 @@ export function deriveGeminiLimits(bucket) {
85
99
  };
86
100
  }
87
101
 
102
+ function isUnlimitedValue(value) {
103
+ if (value === Infinity) return true;
104
+ if (typeof value !== "string") return false;
105
+ const normalized = value.trim().toLowerCase();
106
+ return normalized === "infinity" || normalized === "unlimited";
107
+ }
108
+
109
+ function isGeminiUnlimitedBucket(bucket, fraction) {
110
+ return (
111
+ bucket?.unlimited === true ||
112
+ isUnlimitedValue(fraction) ||
113
+ isUnlimitedValue(bucket?.limit) ||
114
+ isUnlimitedValue(bucket?.remaining) ||
115
+ isUnlimitedValue(bucket?.remainingAmount)
116
+ );
117
+ }
118
+
88
119
  function getCredentialEmail(credential) {
89
120
  return (
90
121
  decodeJwtEmail(credential?.id_token) ||
@@ -165,7 +196,21 @@ export function deriveGeminiFamilyBucket(buckets) {
165
196
  }
166
197
 
167
198
  export function buildGeminiAuthContext(accountId) {
168
- const oauth = readJson(GEMINI_OAUTH_PATH, null);
199
+ let oauth = readJson(GEMINI_OAUTH_PATH, null);
200
+ const fileExpired =
201
+ oauth?.expiry_date != null && oauth.expiry_date < Date.now();
202
+ // Preserve a valid Gemini file token; agy Keychain is only a missing/expired fallback.
203
+ if (!oauth?.access_token || fileExpired) {
204
+ const keychainOAuth = getAntigravityTokenFromKeychain();
205
+ if (keychainOAuth?.access_token) {
206
+ const {
207
+ expiry_date: _dropExpiry,
208
+ access_token: _dropToken,
209
+ ...fileRest
210
+ } = oauth || {};
211
+ oauth = { ...fileRest, ...keychainOAuth };
212
+ }
213
+ }
169
214
  const tokenSource =
170
215
  oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
171
216
  const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
@@ -173,6 +218,93 @@ export function buildGeminiAuthContext(accountId) {
173
218
  return { oauth, tokenFingerprint, cacheKey };
174
219
  }
175
220
 
221
+ function firstPresent(...values) {
222
+ for (const value of values) {
223
+ if (value != null && value !== "") return value;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ function normalizeExpiryDate(value) {
229
+ if (value == null || value === "") return null;
230
+ if (typeof value === "number" && Number.isFinite(value)) {
231
+ return value < 1_000_000_000_000 ? value * 1000 : value;
232
+ }
233
+ if (typeof value === "string") {
234
+ const numeric = Number(value);
235
+ if (Number.isFinite(numeric)) return normalizeExpiryDate(numeric);
236
+ const parsed = Date.parse(value);
237
+ if (Number.isFinite(parsed)) return parsed;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function normalizeAntigravityCredential(parsed) {
243
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
244
+ return null;
245
+ }
246
+ const accessToken = firstPresent(
247
+ parsed.access_token,
248
+ parsed.accessToken,
249
+ parsed.access?.token,
250
+ parsed.token,
251
+ );
252
+ if (!accessToken) return null;
253
+ const normalized = { ...parsed, access_token: accessToken };
254
+ const idToken = firstPresent(parsed.id_token, parsed.idToken);
255
+ if (idToken) normalized.id_token = idToken;
256
+ const refreshToken = firstPresent(parsed.refresh_token, parsed.refreshToken);
257
+ if (refreshToken) normalized.refresh_token = refreshToken;
258
+ const expiryDate = normalizeExpiryDate(
259
+ firstPresent(
260
+ parsed.expiry_date,
261
+ parsed.expiryDate,
262
+ parsed.expiry,
263
+ parsed.expires_at,
264
+ parsed.expiresAt,
265
+ ),
266
+ );
267
+ if (expiryDate != null) normalized.expiry_date = expiryDate;
268
+ return normalized;
269
+ }
270
+
271
+ function getAntigravityTokenFromKeychain() {
272
+ // macOS Keychain only; elsewhere `security` is absent (wasted spawn) or a
273
+ // different PATH binary entirely, so skip on Linux/Windows and let the
274
+ // file-token fallback apply. TFX_HUD_KEYCHAIN_FORCE=1 re-enables the spawn
275
+ // for cross-platform tests that inject a fake `security` via PATH.
276
+ if (
277
+ process.platform !== "darwin" &&
278
+ process.env.TFX_HUD_KEYCHAIN_FORCE !== "1"
279
+ ) {
280
+ return null;
281
+ }
282
+ try {
283
+ const result = spawnSync(
284
+ "security",
285
+ [
286
+ "find-generic-password",
287
+ "-s",
288
+ ANTIGRAVITY_KEYCHAIN_SERVICE,
289
+ "-a",
290
+ ANTIGRAVITY_KEYCHAIN_ACCOUNT,
291
+ "-w",
292
+ ],
293
+ { encoding: "utf8", timeout: 3000 },
294
+ );
295
+ if (result.status !== 0) return null;
296
+ const stdout = result.stdout?.trim();
297
+ if (!stdout) return null;
298
+ try {
299
+ return normalizeAntigravityCredential(JSON.parse(stdout));
300
+ } catch {
301
+ return { access_token: stdout };
302
+ }
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
176
308
  // ============================================================================
177
309
  // Gemini 쿼터 API 호출 (5분 캐시)
178
310
  // ============================================================================
package/hud/renderers.mjs CHANGED
@@ -51,6 +51,25 @@ import {
51
51
  truncateAnsi,
52
52
  } from "./utils.mjs";
53
53
 
54
+ function formatGeminiLimitValue(limit, provFn) {
55
+ if (limit?.unlimited) return provFn("\u221E");
56
+ const usedP = limit?.usedPct;
57
+ if (usedP == null) return dim(formatPlaceholderPercentCell());
58
+ return colorByProvider(usedP, formatPercentCell(usedP), provFn);
59
+ }
60
+
61
+ function getGeminiLimitUsedPercent(bucket, limit) {
62
+ if (limit?.unlimited) return null;
63
+ if (limit?.usedPct != null) return limit.usedPct;
64
+ return clampPercent(Math.round((1 - (bucket?.remainingFraction ?? 1)) * 100));
65
+ }
66
+
67
+ function formatGeminiCompactValue(bucket, limit, provFn) {
68
+ if (limit?.unlimited) return provFn("\u221E");
69
+ const usedP = getGeminiLimitUsedPercent(bucket, limit);
70
+ return usedP != null ? colorByProvider(usedP, `${usedP}`, provFn) : dim("--");
71
+ }
72
+
54
73
  // ============================================================================
55
74
  // 최근 벤치마크 diff 파일 읽기
56
75
  // ============================================================================
@@ -299,10 +318,7 @@ export function getMicroLine(
299
318
  let gVal;
300
319
  if (geminiBucket) {
301
320
  const gl = deriveGeminiLimits(geminiBucket);
302
- const gU = gl
303
- ? gl.usedPct
304
- : clampPercent((1 - (geminiBucket.remainingFraction ?? 1)) * 100);
305
- gVal = colorByProvider(gU, `${gU}`, geminiBlue);
321
+ gVal = formatGeminiCompactValue(geminiBucket, gl, geminiBlue);
306
322
  } else if ((geminiSession?.total || 0) > 0) {
307
323
  gVal = geminiBlue("\u221E");
308
324
  } else {
@@ -468,11 +484,10 @@ function formatAntigravityQuotaSection(
468
484
  return withTime ? `${base} ${dim(formatTimeCell("n/a"))}` : base;
469
485
  }
470
486
  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)}`;
487
+ const usedP = getGeminiLimitUsedPercent(bucket, gl);
488
+ const bar =
489
+ withBar && usedP != null ? tierBar(currentTier, usedP, provAnsi) : "";
490
+ const base = `${dim(`${label}:`)}${bar}${formatGeminiLimitValue(gl, provFn)}`;
476
491
  if (!withTime) return base;
477
492
  const rstRemaining =
478
493
  formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
@@ -558,20 +573,14 @@ export function getProviderRow(
558
573
  if (provider === "gemini" && realQuota?.type === "gemini") {
559
574
  const pools = realQuota.pools || {};
560
575
  if (pools.pro || pools.flash) {
561
- const pP = pools.pro
562
- ? clampPercent(
563
- Math.round((1 - (pools.pro.remainingFraction ?? 1)) * 100),
564
- )
565
- : null;
566
- const pF = pools.flash
567
- ? clampPercent(
568
- Math.round((1 - (pools.flash.remainingFraction ?? 1)) * 100),
569
- )
570
- : null;
571
- const pStr =
572
- pP != null ? colorByProvider(pP, `${pP}`, provFn) : dim("--");
573
- const fStr =
574
- pF != null ? colorByProvider(pF, `${pF}`, provFn) : dim("--");
576
+ const pP = pools.pro ? deriveGeminiLimits(pools.pro) : null;
577
+ const pF = pools.flash ? deriveGeminiLimits(pools.flash) : null;
578
+ const pStr = pools.pro
579
+ ? formatGeminiCompactValue(pools.pro, pP, provFn)
580
+ : dim("--");
581
+ const fStr = pools.flash
582
+ ? formatGeminiCompactValue(pools.flash, pF, provFn)
583
+ : dim("--");
575
584
  return {
576
585
  prefix: minPrefix,
577
586
  left: `${pStr}${dim("/")}${fStr}`,
@@ -622,10 +631,7 @@ export function getProviderRow(
622
631
  if (!bucket)
623
632
  return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())}`;
624
633
  const gl = deriveGeminiLimits(bucket);
625
- const usedP = gl
626
- ? gl.usedPct
627
- : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
628
- return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
634
+ return `${dim(label + ":")}${formatGeminiLimitValue(gl, provFn)}`;
629
635
  };
630
636
  quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
631
637
  } else {
@@ -691,12 +697,9 @@ export function getProviderRow(
691
697
  if (!bucket)
692
698
  return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
693
699
  const gl = deriveGeminiLimits(bucket);
694
- const usedP = gl
695
- ? gl.usedPct
696
- : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
697
700
  const rstRemaining =
698
701
  formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
699
- return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
702
+ return `${dim(label + ":")}${formatGeminiLimitValue(gl, provFn)} ${dim(formatTimeCell(rstRemaining))}`;
700
703
  };
701
704
  quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
702
705
  } else {
@@ -778,12 +781,11 @@ export function getProviderRow(
778
781
  return `${dim(label + ":")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
779
782
  }
780
783
  const gl = deriveGeminiLimits(bucket);
781
- const usedP = gl
782
- ? gl.usedPct
783
- : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
784
+ const usedP = getGeminiLimitUsedPercent(bucket, gl);
784
785
  const rstRemaining =
785
786
  formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
786
- return `${dim(label + ":")}${tierBar(currentTier, usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
787
+ const bar = usedP != null ? tierBar(currentTier, usedP, provAnsi) : "";
788
+ return `${dim(label + ":")}${bar}${formatGeminiLimitValue(gl, provFn)} ${dim(formatTimeCell(rstRemaining))}`;
787
789
  };
788
790
 
789
791
  quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.34.0",
3
+ "version": "10.35.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -13,8 +13,8 @@ export const MANIFEST_PATH = join(
13
13
  "mcp-enabled.json",
14
14
  );
15
15
 
16
- /** API 키 불필요 — 항상 활성화 for gateway-managed MCP servers */
17
- export const CORE_SERVERS = Object.freeze(["serena"]);
16
+ /** API 키 불필요 — 항상 활성화 for gateway-managed MCP servers (현재 없음 — serena는 2026-06-10 core에서 제거) */
17
+ export const CORE_SERVERS = Object.freeze([]);
18
18
 
19
19
  /** 검색 MCP — API 키 필요 */
20
20
  export const SEARCH_SERVERS = Object.freeze([