triflux 4.2.9 → 4.2.10

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,81 @@
1
+ /**
2
+ * Hub HTTP 서버 요청 로깅 미들웨어.
3
+ *
4
+ * raw http.createServer에 맞춘 래퍼. Express가 아닌 triflux Hub 전용.
5
+ *
6
+ * 사용법 (server.mjs에서):
7
+ * import { wrapRequestHandler } from './middleware/request-logger.mjs';
8
+ * const httpServer = createHttpServer(wrapRequestHandler(originalHandler));
9
+ *
10
+ * 각 요청에 correlationId를 할당하고, 응답 완료 시 구조화 로그를 남긴다.
11
+ * health/status 체크는 로깅을 건너뛴다.
12
+ */
13
+ import { withRequestContext, getCorrelationId } from '../../scripts/lib/context.mjs';
14
+ import { createModuleLogger } from '../../scripts/lib/logger.mjs';
15
+
16
+ const log = createModuleLogger('hub');
17
+
18
+ const SKIP_PATHS = new Set(['/health', '/healthz', '/status', '/ready']);
19
+
20
+ /**
21
+ * 원본 request handler를 래핑하여 로깅 + 컨텍스트 전파를 추가한다.
22
+ *
23
+ * @param {function(import('http').IncomingMessage, import('http').ServerResponse): void} handler
24
+ * @returns {function(import('http').IncomingMessage, import('http').ServerResponse): void}
25
+ */
26
+ export function wrapRequestHandler(handler) {
27
+ return (req, res) => {
28
+ const path = getRequestPath(req.url);
29
+
30
+ if (SKIP_PATHS.has(path)) {
31
+ return handler(req, res);
32
+ }
33
+
34
+ const correlationId =
35
+ req.headers['x-correlation-id'] ||
36
+ req.headers['x-request-id'] ||
37
+ undefined; // withRequestContext will generate one
38
+
39
+ withRequestContext(
40
+ {
41
+ correlationId,
42
+ method: req.method,
43
+ path,
44
+ },
45
+ () => {
46
+ const startTime = process.hrtime.bigint();
47
+
48
+ // 응답 헤더에 상관 ID 포함
49
+ const cid = getCorrelationId();
50
+ if (cid) res.setHeader('X-Correlation-ID', cid);
51
+
52
+ // 응답 완료 시 로깅
53
+ res.on('finish', () => {
54
+ const duration = Number(process.hrtime.bigint() - startTime) / 1_000_000;
55
+ const level = res.statusCode >= 500 ? 'error'
56
+ : res.statusCode >= 400 ? 'warn'
57
+ : 'info';
58
+
59
+ log[level](
60
+ {
61
+ status: res.statusCode,
62
+ duration: Math.round(duration * 100) / 100,
63
+ contentLength: res.getHeader('content-length') || 0,
64
+ },
65
+ 'http.response',
66
+ );
67
+ });
68
+
69
+ handler(req, res);
70
+ },
71
+ );
72
+ };
73
+ }
74
+
75
+ function getRequestPath(url = '/') {
76
+ try {
77
+ return new URL(url, 'http://127.0.0.1').pathname;
78
+ } catch {
79
+ return String(url).replace(/\?.*/, '') || '/';
80
+ }
81
+ }
package/hub/server.mjs CHANGED
@@ -19,6 +19,10 @@ import { createTools } from './tools.mjs';
19
19
  import { ensurePipelineStateDbPath } from './pipeline/state.mjs';
20
20
  import { DelegatorService } from './delegator/index.mjs';
21
21
  import { createDelegatorMcpWorker } from './workers/delegator-mcp.mjs';
22
+ import { createModuleLogger } from '../scripts/lib/logger.mjs';
23
+ import { wrapRequestHandler } from './middleware/request-logger.mjs';
24
+
25
+ const hubLog = createModuleLogger('hub');
22
26
 
23
27
  const MAX_BODY_SIZE = 1024 * 1024;
24
28
  const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
@@ -230,7 +234,7 @@ function servePublicFile(res, path) {
230
234
 
231
235
  mkdirSync(PUBLIC_DIR, { recursive: true });
232
236
  if (!existsSync(filePath)) {
233
- console.warn(`[tfx-hub] 정적 파일 없음: ${filePath}`);
237
+ hubLog.warn({ filePath }, 'static.not_found');
234
238
  res.writeHead(404);
235
239
  res.end('Not Found (static file missing)');
236
240
  return true;
@@ -326,7 +330,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
326
330
  return mcp;
327
331
  }
328
332
 
329
- const httpServer = createHttpServer(async (req, res) => {
333
+ const httpServer = createHttpServer(wrapRequestHandler(async (req, res) => {
330
334
  const path = getRequestPath(req.url);
331
335
  const corsAllowed = applyCorsHeaders(req, res);
332
336
 
@@ -685,7 +689,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
685
689
  res.end('Method Not Allowed');
686
690
  }
687
691
  } catch (error) {
688
- console.error('[tfx-hub] 요청 처리 에러:', error.message);
692
+ hubLog.error({ err: error }, 'http.error');
689
693
  if (!res.headersSent) {
690
694
  const code = error.statusCode === 413 ? 413
691
695
  : error instanceof SyntaxError ? 400 : 500;
@@ -699,7 +703,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
699
703
  }));
700
704
  }
701
705
  }
702
- });
706
+ }));
703
707
 
704
708
  router.startSweeper();
705
709
 
@@ -751,8 +755,8 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
751
755
  started: Date.now(),
752
756
  }));
753
757
 
754
- console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
755
- console.log(`[tfx-hub] PUBLIC_DIR: ${PUBLIC_DIR} (exists: ${existsSync(PUBLIC_DIR)}, dashboard: ${existsSync(resolve(PUBLIC_DIR, 'dashboard.html'))})`);
758
+ hubLog.info({ url: info.url, pipePath: pipe.path, assignCallbackPath: assignCallbacks.path, pid: process.pid }, 'hub.started');
759
+ hubLog.debug({ publicDir: PUBLIC_DIR, exists: existsSync(PUBLIC_DIR), hasDashboard: existsSync(resolve(PUBLIC_DIR, 'dashboard.html')) }, 'hub.public_dir');
756
760
 
757
761
  const stopFn = async () => {
758
762
  router.stopSweeper();
@@ -805,14 +809,14 @@ if (selfRun) {
805
809
 
806
810
  startHub({ port, dbPath }).then((info) => {
807
811
  const shutdown = async (signal) => {
808
- console.log(`\n[tfx-hub] ${signal} 수신, 종료 중...`);
812
+ hubLog.info({ signal }, 'hub.stopping');
809
813
  await info.stop();
810
814
  process.exit(0);
811
815
  };
812
816
  process.on('SIGINT', () => shutdown('SIGINT'));
813
817
  process.on('SIGTERM', () => shutdown('SIGTERM'));
814
818
  }).catch((error) => {
815
- console.error('[tfx-hub] 시작 실패:', error.message);
819
+ hubLog.fatal({ err: error }, 'hub.start_failed');
816
820
  process.exit(1);
817
821
  });
818
822
  }
@@ -151,12 +151,33 @@ function getGeminiRpmLimit(model) {
151
151
  return 300; // Flash 기본
152
152
  }
153
153
 
154
- // Gemini 모델 ID → HUD 표시 라벨
154
+ // Gemini 모델 ID → HUD 표시 라벨 (동적 매핑)
155
155
  function getGeminiModelLabel(model) {
156
156
  if (!model) return "";
157
- if (model.includes("pro")) return "[Pro3.1]";
158
- if (model.includes("flash")) return "[Flash3]";
159
- return "";
157
+ // 버전 + 티어 추출: gemini-3.1-pro-preview [3.1Pro], gemini-2.5-flash → [2.5Flash]
158
+ const m = model.match(/gemini-(\d+(?:\.\d+)?)-(\w+)/);
159
+ if (!m) return "";
160
+ const ver = m[1];
161
+ const tier = m[2].charAt(0).toUpperCase() + m[2].slice(1);
162
+ return `[${ver}${tier}]`;
163
+ }
164
+
165
+ // Gemini Pro 풀 공유 그룹: 같은 remainingFraction을 공유하는 모델 ID들
166
+ const GEMINI_PRO_POOL = new Set(["gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview"]);
167
+ const GEMINI_FLASH_POOL = new Set(["gemini-2.5-flash", "gemini-3-flash-preview"]);
168
+
169
+ // remainingFraction → 사용 퍼센트 변환 (remainingAmount가 있으면 절대값도 제공)
170
+ function deriveGeminiLimits(bucket) {
171
+ if (!bucket || bucket.remainingFraction == null) return null;
172
+ const fraction = bucket.remainingFraction;
173
+ const usedPct = clampPercent(Math.round((1 - fraction) * 100));
174
+ // remainingAmount가 API에서 오면 절대값 역산 (Gemini CLI 방식)
175
+ if (bucket.remainingAmount != null) {
176
+ const remaining = parseInt(bucket.remainingAmount, 10);
177
+ const limit = fraction > 0 ? Math.round(remaining / fraction) : 0;
178
+ return { usedPct, remaining, limit, resetTime: bucket.resetTime, modelId: bucket.modelId };
179
+ }
180
+ return { usedPct, remaining: null, limit: null, resetTime: bucket.resetTime, modelId: bucket.modelId };
160
181
  }
161
182
  // rows 임계값 상수 (selectTier 에서 tier 결정에 사용)
162
183
  const ROWS_BUDGET_FULL = 40;
@@ -493,10 +514,11 @@ function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBuc
493
514
  }
494
515
  }
495
516
 
496
- // Gemini
517
+ // Gemini (일간 쿼터 — P/F/L 3풀)
497
518
  let gVal;
498
519
  if (geminiBucket) {
499
- const gU = clampPercent(geminiBucket.usedPercent ?? 0);
520
+ const gl = deriveGeminiLimits(geminiBucket);
521
+ const gU = gl ? gl.usedPct : clampPercent((1 - (geminiBucket.remainingFraction ?? 1)) * 100);
500
522
  gVal = colorByProvider(gU, `${gU}`, geminiBlue);
501
523
  } else if ((geminiSession?.total || 0) > 0) {
502
524
  gVal = geminiBlue("\u221E");
@@ -536,7 +558,7 @@ function normalizeTimeToken(value) {
536
558
  }
537
559
  const dayHour = text.match(/^(\d+)d(\d+)h$/);
538
560
  if (dayHour) {
539
- return `${Number(dayHour[1])}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
561
+ return `${String(Number(dayHour[1])).padStart(2, "0")}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
540
562
  }
541
563
  return text;
542
564
  }
@@ -923,7 +945,7 @@ function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
923
945
  const totalMinutes = Math.floor(diffMs / 60000);
924
946
  const days = Math.floor(totalMinutes / (60 * 24));
925
947
  const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
926
- return `${days}d${hours}h`;
948
+ return `${String(days).padStart(2, "0")}d${String(hours).padStart(2, "0")}h`;
927
949
  }
928
950
 
929
951
  function calcCooldownLeftSeconds(isoDatetime) {
@@ -1511,13 +1533,16 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1511
1533
  }
1512
1534
  }
1513
1535
  if (realQuota?.type === "gemini") {
1514
- const bucket = realQuota.quotaBucket;
1515
- if (bucket) {
1516
- const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1517
- return { prefix: minPrefix, left: `${colorByProvider(usedP, `${usedP}%`, provFn)}${dim("/")}${dim("\u221E%")}`, right: "" };
1536
+ const pools = realQuota.pools || {};
1537
+ if (pools.pro || pools.flash) {
1538
+ const pP = pools.pro ? clampPercent(Math.round((1 - (pools.pro.remainingFraction ?? 1)) * 100)) : null;
1539
+ const pF = pools.flash ? clampPercent(Math.round((1 - (pools.flash.remainingFraction ?? 1)) * 100)) : null;
1540
+ const pStr = pP != null ? colorByProvider(pP, `${pP}`, provFn) : dim("--");
1541
+ const fStr = pF != null ? colorByProvider(pF, `${pF}`, provFn) : dim("--");
1542
+ return { prefix: minPrefix, left: `${pStr}${dim("/")}${fStr}`, right: "" };
1518
1543
  }
1519
1544
  }
1520
- return { prefix: minPrefix, left: dim("--%/--%"), right: "" };
1545
+ return { prefix: minPrefix, left: dim("--/--"), right: "" };
1521
1546
  }
1522
1547
 
1523
1548
  if (CURRENT_TIER === "minimal") {
@@ -1532,12 +1557,17 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1532
1557
  }
1533
1558
  }
1534
1559
  if (realQuota?.type === "gemini") {
1535
- const bucket = realQuota.quotaBucket;
1536
- if (bucket) {
1537
- const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1538
- quotaSection = `${dim("1d:")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}`;
1560
+ const pools = realQuota.pools || {};
1561
+ if (pools.pro || pools.flash) {
1562
+ const slot = (bucket, label) => {
1563
+ if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())}`;
1564
+ const gl = deriveGeminiLimits(bucket);
1565
+ const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1566
+ return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
1567
+ };
1568
+ quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
1539
1569
  } else {
1540
- quotaSection = `${dim("1d:")}${dim("--%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}`;
1570
+ quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())}`;
1541
1571
  }
1542
1572
  }
1543
1573
  if (!quotaSection) {
@@ -1561,17 +1591,23 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1561
1591
  }
1562
1592
  }
1563
1593
  if (realQuota?.type === "gemini") {
1564
- const bucket = realQuota.quotaBucket;
1565
- if (bucket) {
1566
- const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1567
- const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1568
- quotaSection = `${dim("1d:")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1594
+ const pools = realQuota.pools || {};
1595
+ const hasAnyPool = pools.pro || pools.flash;
1596
+ if (hasAnyPool) {
1597
+ const slot = (bucket, label) => {
1598
+ if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1599
+ const gl = deriveGeminiLimits(bucket);
1600
+ const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1601
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1602
+ return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
1603
+ };
1604
+ quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
1569
1605
  } else {
1570
- quotaSection = `${dim("1d:")}${dim("--%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1606
+ quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1571
1607
  }
1572
1608
  }
1573
1609
  if (!quotaSection) {
1574
- quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("-d--h"))}`;
1610
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
1575
1611
  }
1576
1612
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1577
1613
  const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
@@ -1598,21 +1634,31 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1598
1634
  }
1599
1635
 
1600
1636
  if (realQuota?.type === "gemini") {
1601
- const bucket = realQuota.quotaBucket;
1602
- if (bucket) {
1603
- const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1604
- const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1605
- quotaSection = `${dim("1d:")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ` +
1606
- `${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1637
+ const pools = realQuota.pools || {};
1638
+ const hasAnyPool = pools.pro || pools.flash;
1639
+
1640
+ if (hasAnyPool) {
1641
+ // C/X와 동일한 2슬롯 구조: P:gauge %% (time) F:gauge %% (time)
1642
+ const slot = (bucket, label) => {
1643
+ if (!bucket) {
1644
+ return `${dim(label + ":")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1645
+ }
1646
+ const gl = deriveGeminiLimits(bucket);
1647
+ const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1648
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1649
+ return `${dim(label + ":")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
1650
+ };
1651
+
1652
+ quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
1607
1653
  } else {
1608
- quotaSection = `${dim("1d:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1609
- `${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1654
+ quotaSection = `${dim("Pr:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ` +
1655
+ `${dim("Fl:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
1610
1656
  }
1611
1657
  }
1612
1658
 
1613
1659
  // 폴백: 쿼터 데이터 없을 때
1614
1660
  if (!quotaSection) {
1615
- quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("-d--h"))}`;
1661
+ quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
1616
1662
  }
1617
1663
 
1618
1664
  const prefix = `${bold(markerColor(`${marker}`))}:`;
@@ -1710,11 +1756,15 @@ async function main() {
1710
1756
  geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
1711
1757
  }
1712
1758
 
1713
- // Gemini: 사용 중인 모델의 쿼터 버킷 찾기
1759
+ // Gemini: 3풀 버킷 추출 (Pro/Flash/Lite — 각 풀 내 모델들은 쿼터 공유)
1714
1760
  const geminiModel = geminiSession?.model || "gemini-3-flash-preview";
1715
- const geminiBucket = geminiQuota?.buckets?.find((b) => b.modelId === geminiModel)
1716
- || geminiQuota?.buckets?.find((b) => b.modelId === "gemini-3-flash-preview")
1761
+ const geminiBuckets = geminiQuota?.buckets || [];
1762
+ const geminiBucket = geminiBuckets.find((b) => b.modelId === geminiModel)
1763
+ || geminiBuckets.find((b) => b.modelId === "gemini-3-flash-preview")
1717
1764
  || null;
1765
+ const geminiProBucket = geminiBuckets.find((b) => GEMINI_PRO_POOL.has(b.modelId)) || null;
1766
+ const geminiFlashBucket = geminiBuckets.find((b) => GEMINI_FLASH_POOL.has(b.modelId)) || null;
1767
+ const geminiLiteBucket = geminiBuckets.find((b) => b.modelId?.includes("flash-lite")) || null;
1718
1768
 
1719
1769
  // 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
1720
1770
  const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
@@ -1731,7 +1781,12 @@ async function main() {
1731
1781
  }
1732
1782
 
1733
1783
  const codexQuotaData = codexBuckets ? { type: "codex", buckets: codexBuckets } : null;
1734
- const geminiQuotaData = { type: "gemini", quotaBucket: geminiBucket, session: geminiSession };
1784
+ const geminiQuotaData = {
1785
+ type: "gemini",
1786
+ quotaBucket: geminiBucket,
1787
+ pools: { pro: geminiProBucket, flash: geminiFlashBucket, lite: geminiLiteBucket },
1788
+ session: geminiSession,
1789
+ };
1735
1790
 
1736
1791
  const rows = [
1737
1792
  ...getClaudeRows(stdin, claudeUsageSnapshot.data, combinedSvPct),
@@ -1747,7 +1802,8 @@ async function main() {
1747
1802
 
1748
1803
  // 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
1749
1804
  const codexActive = codexBuckets != null;
1750
- const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null;
1805
+ const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null
1806
+ || geminiProBucket != null || geminiFlashBucket != null;
1751
1807
 
1752
1808
  let outputLines = renderAlignedRows(rows);
1753
1809
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "4.2.9",
3
+ "version": "4.2.10",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,8 @@
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.27.1",
49
49
  "better-sqlite3": "^12.6.2",
50
+ "pino": "^10.3.1",
51
+ "pino-pretty": "^13.1.3",
50
52
  "systray2": "^2.1.4"
51
53
  },
52
54
  "keywords": [
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 요청별 로그 컨텍스트 전파 (AsyncLocalStorage 기반).
3
+ *
4
+ * Hub HTTP 서버의 요청마다 correlationId를 자동 할당하여,
5
+ * 하나의 요청에서 발생한 모든 로그를 추적할 수 있다.
6
+ *
7
+ * 사용법:
8
+ * import { getLogger, getCorrelationId, withRequestContext } from './lib/context.mjs';
9
+ *
10
+ * // 미들웨어에서 컨텍스트 생성
11
+ * withRequestContext({ method: 'POST', path: '/bridge/result' }, () => {
12
+ * const log = getLogger();
13
+ * log.info({ agentId }, 'bridge.result_received');
14
+ * });
15
+ *
16
+ * // 내부 함수에서 자동 상관 ID
17
+ * function processResult() {
18
+ * const log = getLogger();
19
+ * log.info('result.processed'); // correlationId 자동 포함
20
+ * }
21
+ */
22
+ import { AsyncLocalStorage } from 'node:async_hooks';
23
+ import { randomUUID } from 'node:crypto';
24
+
25
+ import { logger } from './logger.mjs';
26
+
27
+ /** @type {AsyncLocalStorage<{logger: import('pino').Logger, correlationId: string}>} */
28
+ export const asyncLocalStorage = new AsyncLocalStorage();
29
+
30
+ /**
31
+ * 현재 요청 컨텍스트의 로거를 반환한다.
32
+ * 요청 컨텍스트 밖에서 호출하면 기본 로거를 반환한다.
33
+ *
34
+ * @returns {import('pino').Logger}
35
+ */
36
+ export function getLogger() {
37
+ return asyncLocalStorage.getStore()?.logger || logger;
38
+ }
39
+
40
+ /**
41
+ * 현재 요청의 상관 ID를 반환한다.
42
+ *
43
+ * @returns {string|undefined}
44
+ */
45
+ export function getCorrelationId() {
46
+ return asyncLocalStorage.getStore()?.correlationId;
47
+ }
48
+
49
+ /**
50
+ * 요청 컨텍스트를 생성하고 콜백을 실행한다.
51
+ *
52
+ * @param {object} context — 컨텍스트 필드 (method, path 등)
53
+ * @param {string} [context.correlationId] — 외부에서 전달된 상관 ID (없으면 자동 생성)
54
+ * @param {function} callback — 컨텍스트 내에서 실행할 함수
55
+ * @returns {*}
56
+ */
57
+ export function withRequestContext(context, callback) {
58
+ const correlationId = context.correlationId || randomUUID();
59
+ const { correlationId: _, ...rest } = context;
60
+
61
+ const store = {
62
+ correlationId,
63
+ logger: logger.child({ correlationId, ...rest }),
64
+ };
65
+
66
+ return asyncLocalStorage.run(store, callback);
67
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Logify — triflux 구조화 로깅 설정
3
+ *
4
+ * 사용법:
5
+ * import { logger, createModuleLogger } from './lib/logger.mjs';
6
+ *
7
+ * // 기본 로거
8
+ * logger.info({ taskId: 'abc' }, 'task.started');
9
+ *
10
+ * // 모듈별 로거
11
+ * const log = createModuleLogger('hub');
12
+ * log.info({ port: 27888 }, 'server.started');
13
+ * log.error({ err }, 'server.error');
14
+ *
15
+ * 이벤트 네이밍: {도메인}.{액션} 형식
16
+ * hub.started, hub.stopped, route.started, route.completed,
17
+ * worker.spawned, worker.completed, worker.timeout,
18
+ * mcp.connected, mcp.disconnected, mcp.error,
19
+ * team.created, team.deleted, task.claimed, task.completed,
20
+ * pipe.connected, pipe.message, pipe.error,
21
+ * http.request, http.response, http.error
22
+ *
23
+ * 로그 레벨 가이드:
24
+ * debug — 개발/트러블슈팅용 (변수 값, MCP 메시지, 캐시 키)
25
+ * info — 정상 흐름 상태 변경 (서버 시작, 워커 완료, 팀 생성)
26
+ * warn — 위험 신호 (재시도 발생, 쿼타 임박, 느린 워커)
27
+ * error — 작업 실패 (CLI 실행 실패, MCP 연결 끊김)
28
+ * fatal — 프로세스 위협 (DB 연결 불가, 포트 충돌)
29
+ */
30
+ import pino from 'pino';
31
+
32
+ const isDev = process.env.NODE_ENV !== 'production';
33
+
34
+ export const logger = pino({
35
+ level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
36
+
37
+ // 모든 로그에 포함되는 기본 필드
38
+ base: {
39
+ service: process.env.SERVICE_NAME || 'triflux',
40
+ env: process.env.NODE_ENV || 'development',
41
+ },
42
+
43
+ // 레벨을 대문자로 출력 (AI 파싱 용이)
44
+ formatters: {
45
+ level: (label) => ({ level: label.toUpperCase() }),
46
+ },
47
+
48
+ // ISO 8601 타임스탬프
49
+ timestamp: pino.stdTimeFunctions.isoTime,
50
+
51
+ // 민감정보 자동 필터링
52
+ redact: {
53
+ paths: [
54
+ 'password',
55
+ 'token',
56
+ 'apiKey',
57
+ 'secret',
58
+ 'authorization',
59
+ '*.password',
60
+ '*.token',
61
+ '*.apiKey',
62
+ '*.secret',
63
+ 'req.headers.authorization',
64
+ 'req.headers.cookie',
65
+ 'hubToken',
66
+ ],
67
+ remove: true,
68
+ },
69
+
70
+ // 개발 환경: 컬러 콘솔 출력
71
+ transport: isDev
72
+ ? {
73
+ target: 'pino-pretty',
74
+ options: {
75
+ colorize: true,
76
+ translateTime: 'yyyy-mm-dd HH:MM:ss',
77
+ ignore: 'pid,hostname',
78
+ },
79
+ }
80
+ : undefined,
81
+ });
82
+
83
+ /**
84
+ * 모듈별 Child Logger 생성.
85
+ * 모듈 이름이 모든 로그에 자동 포함된다.
86
+ *
87
+ * @param {string} module — 모듈 이름 (hub, route, worker, mcp, team 등)
88
+ * @returns {import('pino').Logger}
89
+ */
90
+ export function createModuleLogger(module) {
91
+ return logger.child({ module });
92
+ }
93
+
94
+ // 정상 종료 시 버퍼 flush 보장
95
+ process.on('uncaughtException', (err) => {
96
+ const finalLogger = pino.final(logger);
97
+ finalLogger.fatal({ err }, 'process.uncaught_exception');
98
+ process.exit(1);
99
+ });
100
+
101
+ process.on('unhandledRejection', (reason) => {
102
+ const finalLogger = pino.final(logger);
103
+ finalLogger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
104
+ process.exit(1);
105
+ });