triflux 3.2.0-dev.9 → 3.3.0-dev.3

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.
@@ -3,6 +3,7 @@
3
3
  import { GeminiWorker } from './gemini-worker.mjs';
4
4
  import { ClaudeWorker } from './claude-worker.mjs';
5
5
  import { CodexMcpWorker } from './codex-mcp.mjs';
6
+ import { DelegatorMcpWorker } from './delegator-mcp.mjs';
6
7
 
7
8
  export function createWorker(type, opts = {}) {
8
9
  switch (type) {
@@ -12,6 +13,8 @@ export function createWorker(type, opts = {}) {
12
13
  return new ClaudeWorker(opts);
13
14
  case 'codex':
14
15
  return new CodexMcpWorker(opts);
16
+ case 'delegator':
17
+ return new DelegatorMcpWorker(opts);
15
18
  default:
16
19
  throw new Error(`Unknown worker type: ${type}`);
17
20
  }
@@ -35,7 +35,7 @@
35
35
  * @property {() => Promise<void>} start
36
36
  * @property {() => Promise<void>} stop
37
37
  * @property {() => boolean} isReady
38
- * @property {string} type - 'codex' | 'gemini' | 'claude'
38
+ * @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
39
39
  */
40
40
 
41
- export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude']);
41
+ export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude', 'delegator']);
@@ -105,7 +105,7 @@ const QOS_PATH = join(homedir(), ".omc", "state", "cli_qos_profile.json");
105
105
  const ACCOUNTS_CONFIG_PATH = join(homedir(), ".omc", "router", "accounts.json");
106
106
  const ACCOUNTS_STATE_PATH = join(homedir(), ".omc", "state", "cli_accounts_state.json");
107
107
 
108
- // tfx-team 상태 (v2.2 HUD 통합)
108
+ // tfx-multi 상태 (v2.2 HUD 통합)
109
109
  const TEAM_STATE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "team-state.json");
110
110
 
111
111
  // Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
@@ -189,10 +189,10 @@ const MINIMAL_COLS_THRESHOLD = 60;
189
189
  let _cachedColumns = 0;
190
190
  function getTerminalColumns() {
191
191
  if (_cachedColumns > 0) return _cachedColumns;
192
- if (process.stdout.columns) { _cachedColumns = process.stdout.columns; return _cachedColumns; }
193
- if (process.stderr.columns) { _cachedColumns = process.stderr.columns; return _cachedColumns; }
194
192
  const envCols = Number(process.env.COLUMNS);
195
193
  if (envCols > 0) { _cachedColumns = envCols; return _cachedColumns; }
194
+ if (process.stdout.columns) { _cachedColumns = process.stdout.columns; return _cachedColumns; }
195
+ if (process.stderr.columns) { _cachedColumns = process.stderr.columns; return _cachedColumns; }
196
196
  try {
197
197
  if (process.platform === "win32") {
198
198
  const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
@@ -274,7 +274,7 @@ const MINIMAL_MODE = detectMinimalMode();
274
274
  // 4-Tier 적응형 렌더링: full > normal > compact > nano
275
275
  // ============================================================================
276
276
  // 초기 tier (stdin 없이 결정 가능한 수준)
277
- let CURRENT_TIER = MINIMAL_MODE ? "nano" : COMPACT_MODE ? "compact" : "normal";
277
+ let CURRENT_TIER = MINIMAL_MODE ? "nano" : COMPACT_MODE ? "compact" : "full";
278
278
 
279
279
  /**
280
280
  * 인디케이터 인식 + 터미널 크기 기반 tier 자동 선택.
@@ -285,56 +285,28 @@ function selectTier(stdin, claudeUsage = null) {
285
285
 
286
286
  // 1) 명시적 tier 강제 설정
287
287
  const forcedTier = hudConfig?.tier;
288
- if (["full", "normal", "compact", "nano", "micro"].includes(forcedTier)) return forcedTier;
288
+ if (["full", "compact", "minimal", "micro", "nano"].includes(forcedTier)) return forcedTier;
289
+
290
+ // 1.5) maxLines=1 → nano (1줄 모드: 알림 배너/분할화면 대응)
291
+ if (Number(hudConfig?.lines) === 1) return "nano";
289
292
 
290
- // 1.5) maxLines=1 → micro (1줄 모드: 알림 배너/분할화면 대응)
291
- if (Number(hudConfig?.lines) === 1) return "micro";
293
+ const cols = getTerminalColumns() || 120;
292
294
 
293
- // 1.6) 분할화면 감지: < 80이면 micro (COMPACT_MODE보다 우선)
294
- if ((getTerminalColumns() || 120) < 80) return "micro";
295
+ // 1.6) 극소 폭(< 40col)인 경우 1줄 모드(nano)로 폴백
296
+ if (cols < 40) return "nano";
295
297
 
296
298
  // 2) 기존 모드 플래그 존중
297
- if (MINIMAL_MODE) return "nano";
299
+ if (MINIMAL_MODE) return "micro";
298
300
  if (COMPACT_MODE) return "compact";
299
301
 
300
- // 3) autoResize 비활성이면 normal 유지
301
- if (hudConfig?.autoResize === false) return "normal";
302
+ // 3) autoResize 비활성이면 full 유지
303
+ if (hudConfig?.autoResize === false) return "full";
302
304
 
303
- // 4) 터미널 행/열에서 상태영역 예산 추정
304
- const rows = getTerminalRows();
305
- const cols = getTerminalColumns() || 120;
306
-
307
- let budget;
308
- if (rows >= ROWS_BUDGET_FULL) budget = 6;
309
- else if (rows >= ROWS_BUDGET_LARGE) budget = 5;
310
- else if (rows >= ROWS_BUDGET_MEDIUM) budget = 4;
311
- else if (rows >= ROWS_BUDGET_SMALL) budget = 3;
312
- else if (rows > 0) budget = 2;
313
- else budget = 5; // rows 감지 불가 → 넉넉하게
314
-
315
- // 5) 인디케이터 줄 추정
316
- // bypass permissions 배너(1줄)만 계상
317
- // 선행 \n은 출력 포맷이므로 tier 예산에서 제외 — 이중 계산 시
318
- // budget 4(rows 28-34)에서 totalVisualRows 5가 되어 micro로 추락하는 버그 유발
319
- let indicatorRows = 1;
320
- const contextPercent = getContextPercent(stdin);
321
- // "Context low" 배너 공간은 출력부(leadingBreaks)에서 \n\n으로 처리 — 티어 선택에서 예약 불필요
322
- // Claude Code 사용량 경고 (노란색 배너: "You've used X% of your ... limit")
323
- const weeklyPct = claudeUsage?.weeklyPercent ?? 0;
324
- const fiveHourPct = claudeUsage?.fiveHourPercent ?? 0;
325
- if (weeklyPct >= 80) indicatorRows += 1;
326
- if (fiveHourPct >= 80) indicatorRows += 1;
327
-
328
- // 6) 각 tier에서 줄바꿈 없이 3줄 가용한지 확인
329
- const tierWidths = { full: 70, normal: 60, compact: 40, nano: 34 };
330
- for (const tier of ["full", "normal", "compact", "nano"]) {
331
- const lineWidth = tierWidths[tier];
332
- const visualRowsPerLine = Math.ceil(lineWidth / Math.max(cols, 1));
333
- const totalVisualRows = (3 * visualRowsPerLine) + indicatorRows;
334
- if (totalVisualRows <= budget) return tier;
335
- }
336
- // 어떤 tier도 budget에 안 맞으면 micro (1줄 모드)
337
- return "micro";
305
+ // 4) 터미널 폭에 따른 점진적 축소 (breakpoint)
306
+ if (cols >= 120) return "full";
307
+ if (cols >= 80) return "compact";
308
+ if (cols >= 60) return "minimal";
309
+ return "micro"; // 40 <= cols < 60
338
310
  }
339
311
 
340
312
  // full tier 전용: 게이지 바 접두사 (normal 이하 tier에서는 빈 문자열)
@@ -432,7 +404,7 @@ function getProviderAccountId(provider, accountsConfig, accountsState) {
432
404
  }
433
405
 
434
406
  /**
435
- * tfx-team 상태 행 생성 (v2.2 HUD 통합)
407
+ * tfx-multi 상태 행 생성 (v2.2 HUD 통합)
436
408
  * 활성 팀이 있을 때만 행 반환, 없으면 null
437
409
  * @returns {{ prefix: string, left: string, right: string } | null}
438
410
  */
@@ -451,13 +423,13 @@ function getTeamRow() {
451
423
  const failed = tasks.filter((t) => t.status === "failed").length;
452
424
  const total = tasks.length || workers.length;
453
425
 
454
- // 경과 시간
455
- const elapsed = teamState.startedAt
426
+ // 경과 시간 (80col 이상에서만 표시)
427
+ const elapsed = (teamState.startedAt && (CURRENT_TIER === "full" || CURRENT_TIER === "compact"))
456
428
  ? `${Math.round((Date.now() - teamState.startedAt) / 60000)}m`
457
429
  : "";
458
430
 
459
- // 멤버 상태 아이콘 요약
460
- const memberIcons = workers.map((m) => {
431
+ // 멤버 상태 아이콘 요약 (60col 이상에서만 표시)
432
+ const memberIcons = (CURRENT_TIER === "full" || CURRENT_TIER === "compact" || CURRENT_TIER === "minimal") ? workers.map((m) => {
461
433
  const task = tasks.find((t) => t.owner === m.name);
462
434
  const icon = task?.status === "completed" ? green("✓")
463
435
  : task?.status === "in_progress" ? yellow("●")
@@ -465,16 +437,18 @@ function getTeamRow() {
465
437
  : dim("○");
466
438
  const tag = m.cli ? m.cli.charAt(0) : "?";
467
439
  return `${tag}${icon}`;
468
- }).join(" ");
440
+ }).join(" ") : "";
469
441
 
470
442
  // done / failed 상태 텍스트
471
443
  const doneText = failed > 0
472
444
  ? `${completed}/${total} ${red(`${failed}✗`)}`
473
445
  : `${completed}/${total} done`;
474
446
 
447
+ const leftText = elapsed ? `team ${doneText} ${dim(elapsed)}` : `team ${doneText}`;
448
+
475
449
  return {
476
450
  prefix: bold(claudeOrange("⬡")),
477
- left: `team ${doneText} ${dim(elapsed)}`,
451
+ left: leftText,
478
452
  right: memberIcons,
479
453
  };
480
454
  }
@@ -1415,7 +1389,6 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1415
1389
  const svSuffix = `${dim("sv:")}${svStr}`;
1416
1390
 
1417
1391
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1418
- // 캐시된 percent 그대로 사용 (시간 표시는 advanceToNextCycle이 처리)
1419
1392
  const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
1420
1393
  const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
1421
1394
  const fiveHourReset = claudeUsage?.fiveHourResetsAt
@@ -1427,82 +1400,36 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1427
1400
 
1428
1401
  const hasData = claudeUsage != null;
1429
1402
 
1430
- if (CURRENT_TIER === "nano") {
1431
- const cols = getTerminalColumns() || 80;
1432
- if (!hasData) {
1433
- const quotaSection = cols < 40
1434
- ? `${dim("--%/--%")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`
1435
- : `${dim("5h --% 1w --% sv:--% ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1436
- return [{ prefix, left: quotaSection, right: "" }];
1437
- }
1438
- if (cols < 40) {
1439
- // null이면 '--' 플레이스홀더, 아니면 실제
1440
- const fStr = fiveHourPercent != null ? `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}` : dim("--");
1441
- const wStr = weeklyPercent != null ? `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)}` : dim("--");
1442
- const quotaSection = `${fStr}${dim("/")}${wStr} ` +
1443
- `${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1444
- return [{ prefix, left: quotaSection, right: "" }];
1445
- }
1446
- // nano: c: 5h 12% 1w 95% sv: 191% ctx:90%
1447
- // null이면 '--%' 플레이스홀더 표시
1448
- const fCellNano = fiveHourPercent != null
1449
- ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1450
- : dim(formatPlaceholderPercentCell());
1451
- const wCellNano = weeklyPercent != null
1452
- ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1453
- : dim(formatPlaceholderPercentCell());
1454
- const quotaSection = `${dim("5h")} ${fCellNano} ` +
1455
- `${dim("1w")} ${wCellNano} ` +
1456
- `${dim("sv:")}${svStr} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1403
+ const fStr = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange) : dim(formatPlaceholderPercentCell());
1404
+ const wStr = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange) : dim(formatPlaceholderPercentCell());
1405
+ const fBar = hasData && fiveHourPercent != null ? tierBar(fiveHourPercent, CLAUDE_ORANGE) : tierDimBar();
1406
+ const wBar = hasData && weeklyPercent != null ? tierBar(weeklyPercent, CLAUDE_ORANGE) : tierDimBar();
1407
+ const fTime = formatTimeCell(fiveHourReset);
1408
+ const wTime = formatTimeCellDH(weeklyReset);
1409
+
1410
+ if (CURRENT_TIER === "nano" || CURRENT_TIER === "micro") {
1411
+ // 40~59 cols (micro) & <40 (nano): No time, no token count, short labels
1412
+ const fShort = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange) : dim("--");
1413
+ const wShort = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange) : dim("--");
1414
+ const quotaSection = `${fShort}${dim("/")}${wShort}`;
1457
1415
  return [{ prefix, left: quotaSection, right: "" }];
1458
1416
  }
1459
1417
 
1460
- if (CURRENT_TIER === "compact") {
1461
- if (!hasData) {
1462
- const quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ` +
1463
- `${dim("1w:")}${dim(formatPlaceholderPercentCell())} ` +
1464
- `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1465
- return [{ prefix, left: quotaSection, right: "" }];
1466
- }
1467
- // compact: c: 5h: 14% 1w: 96% | sv: 191% ctx:43%
1468
- // null이면 '--%' 플레이스홀더 표시
1469
- const fCellCmp = fiveHourPercent != null
1470
- ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1471
- : dim(formatPlaceholderPercentCell());
1472
- const wCellCmp = weeklyPercent != null
1473
- ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1474
- : dim(formatPlaceholderPercentCell());
1475
- const quotaSection = `${dim("5h:")}${fCellCmp} ` +
1476
- `${dim("1w:")}${wCellCmp} ` +
1477
- `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1418
+ if (CURRENT_TIER === "minimal") {
1419
+ // 60~79 cols: Labels, but no time, no token count
1420
+ const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}`;
1478
1421
  return [{ prefix, left: quotaSection, right: "" }];
1479
1422
  }
1480
1423
 
1481
- // 데이터 없을 때: 퍼센트+시간 모두 dim 처리 (green 0% 대신)
1482
- if (!hasData) {
1483
- const quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1484
- `${dim(formatTimeCell(fiveHourReset))} ` +
1485
- `${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1486
- `${dim(formatTimeCellDH(weeklyReset))}`;
1424
+ if (CURRENT_TIER === "compact") {
1425
+ // 80~119 cols: Includes Time and token count, no bars
1426
+ const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
1487
1427
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1488
1428
  return [{ prefix, left: quotaSection, right: contextSection }];
1489
1429
  }
1490
1430
 
1491
- // null이면 dim + '--%' 플레이스홀더, 아니면 실제 값 표시
1492
- const fiveHourPercentCell = fiveHourPercent != null
1493
- ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1494
- : dim(formatPlaceholderPercentCell());
1495
- const weeklyPercentCell = weeklyPercent != null
1496
- ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1497
- : dim(formatPlaceholderPercentCell());
1498
- const fiveHourBar = fiveHourPercent != null ? tierBar(fiveHourPercent, CLAUDE_ORANGE) : tierDimBar();
1499
- const weeklyBar = weeklyPercent != null ? tierBar(weeklyPercent, CLAUDE_ORANGE) : tierDimBar();
1500
- const fiveHourTimeCell = formatTimeCell(fiveHourReset);
1501
- const weeklyTimeCell = formatTimeCellDH(weeklyReset);
1502
- const quotaSection = `${dim("5h:")}${fiveHourBar}${fiveHourPercentCell} ` +
1503
- `${dim(fiveHourTimeCell)} ` +
1504
- `${dim("1w:")}${weeklyBar}${weeklyPercentCell} ` +
1505
- `${dim(weeklyTimeCell)}`;
1431
+ // full tier (>= 120 cols): Bars, time, token count
1432
+ const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
1506
1433
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1507
1434
  return [{ prefix, left: quotaSection, right: contextSection }];
1508
1435
  }
@@ -1535,48 +1462,37 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1535
1462
  let quotaSection;
1536
1463
  let extraRightSection = "";
1537
1464
 
1538
- if (CURRENT_TIER === "nano") {
1539
- const cols = getTerminalColumns() || 80;
1465
+ if (CURRENT_TIER === "nano" || CURRENT_TIER === "micro") {
1540
1466
  const minPrefix = `${bold(markerColor(`${marker}`))}:`;
1541
- const svCompact = svStr ? ` ${dim("sv:")}${cyan(svStr)}` : "";
1542
1467
  if (realQuota?.type === "codex") {
1543
1468
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1544
1469
  if (main) {
1545
- // 캐시된 값 그대로 표시 (시간은 advanceToNextCycle이 처리)
1546
1470
  const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1547
1471
  const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1548
- const fCellN = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1549
- const wCellN = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1550
- if (cols < 40) {
1551
- return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}${svCompact}`, right: "" };
1552
- }
1553
- return { prefix: minPrefix, left: `${dim("5h")} ${fCellN} ${dim("1w")} ${wCellN}${svCompact}`, right: "" };
1472
+ const fCellN = fiveP != null ? colorByProvider(fiveP, `${fiveP}%`, provFn) : dim("--%");
1473
+ const wCellN = weekP != null ? colorByProvider(weekP, `${weekP}%`, provFn) : dim("--%");
1474
+ return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}`, right: "" };
1554
1475
  }
1555
1476
  }
1556
1477
  if (realQuota?.type === "gemini") {
1557
1478
  const bucket = realQuota.quotaBucket;
1558
1479
  if (bucket) {
1559
1480
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1560
- if (cols < 40) {
1561
- return { prefix: minPrefix, left: `${colorByProvider(usedP, formatPercentCell(usedP), provFn)}${svCompact}`, right: "" };
1562
- }
1563
- return { prefix: minPrefix, left: `${dim("1d")} ${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim("1w")} ${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}${svCompact}`, right: "" };
1481
+ return { prefix: minPrefix, left: `${colorByProvider(usedP, `${usedP}%`, provFn)}${dim("/")}${dim("\u221E%")}`, right: "" };
1564
1482
  }
1565
1483
  }
1566
- return { prefix: minPrefix, left: dim("--%".padStart(PERCENT_CELL_WIDTH)), right: "" };
1484
+ return { prefix: minPrefix, left: dim("--%/--%"), right: "" };
1567
1485
  }
1568
1486
 
1569
- if (CURRENT_TIER === "compact") {
1487
+ if (CURRENT_TIER === "minimal") {
1570
1488
  if (realQuota?.type === "codex") {
1571
1489
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1572
1490
  if (main) {
1573
- // 캐시된 값 그대로 표시 (시간은 advanceToNextCycle이 처리)
1574
1491
  const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1575
1492
  const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1576
1493
  const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1577
1494
  const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1578
- quotaSection = `${dim("5h:")}${fCell} ` +
1579
- `${dim("1w:")}${wCell}`;
1495
+ quotaSection = `${dim("5h:")}${fCell} ${dim("1w:")}${wCell}`;
1580
1496
  }
1581
1497
  }
1582
1498
  if (realQuota?.type === "gemini") {
@@ -1592,7 +1508,36 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1592
1508
  quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim("1w:")}${dim(formatPlaceholderPercentCell())}`;
1593
1509
  }
1594
1510
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1595
- // compact: sv + 계정 (모델 라벨 제거)
1511
+ return { prefix, left: quotaSection, right: accountLabel ? markerColor(accountLabel) : "" };
1512
+ }
1513
+
1514
+ if (CURRENT_TIER === "compact") {
1515
+ if (realQuota?.type === "codex") {
1516
+ const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1517
+ if (main) {
1518
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1519
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1520
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1521
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1522
+ const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
1523
+ const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
1524
+ quotaSection = `${dim("5h:")}${fCell} ${dim(formatTimeCell(fiveReset))} ${dim("1w:")}${wCell} ${dim(formatTimeCellDH(weekReset))}`;
1525
+ }
1526
+ }
1527
+ if (realQuota?.type === "gemini") {
1528
+ const bucket = realQuota.quotaBucket;
1529
+ if (bucket) {
1530
+ const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1531
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1532
+ quotaSection = `${dim("1d:")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1533
+ } else {
1534
+ quotaSection = `${dim("1d:")}${dim("--%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1535
+ }
1536
+ }
1537
+ if (!quotaSection) {
1538
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("-d--h"))}`;
1539
+ }
1540
+ const prefix = `${bold(markerColor(`${marker}`))}:`;
1596
1541
  const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
1597
1542
  return { prefix, left: quotaSection, right: compactRight };
1598
1543
  }
@@ -1741,8 +1686,8 @@ async function main() {
1741
1686
  // 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
1742
1687
  CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
1743
1688
 
1744
- // micro tier: 1줄 모드 (알림 배너/분할화면 대응)
1745
- if (CURRENT_TIER === "micro") {
1689
+ // nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
1690
+ if (CURRENT_TIER === "nano") {
1746
1691
  const microLine = getMicroLine(stdin, claudeUsageSnapshot.data, codexBuckets,
1747
1692
  geminiSession, geminiBucket, combinedSvPct);
1748
1693
  process.stdout.write(`\x1b[0m${microLine}\n`);
@@ -1760,7 +1705,7 @@ async function main() {
1760
1705
  geminiQuotaData, geminiEmail, geminiSv, null),
1761
1706
  ];
1762
1707
 
1763
- // tfx-team 활성 시 팀 상태 행 추가 (v2.2)
1708
+ // tfx-multi 활성 시 팀 상태 행 추가 (v2.2)
1764
1709
  const teamRow = getTeamRow();
1765
1710
  if (teamRow) rows.push(teamRow);
1766
1711
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.2.0-dev.9",
3
+ "version": "3.3.0-dev.3",
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": {
@@ -29,7 +29,7 @@ const { extractPrompt, sanitizeForKeywordDetection } = detectorModule;
29
29
 
30
30
  function loadCompiledRules() {
31
31
  const rules = loadRules(rulesPath);
32
- assert.equal(rules.length, 19);
32
+ assert.equal(rules.length, 20);
33
33
  return compileRules(rules);
34
34
  }
35
35
 
@@ -79,7 +79,7 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
79
79
  const input = [
80
80
  "정상 문장",
81
81
  "```sh",
82
- "tfx team",
82
+ "tfx multi",
83
83
  "```",
84
84
  "https://example.com/path?q=1",
85
85
  "C:\\Users\\SSAFY\\Desktop\\Projects\\tools\\triflux",
@@ -90,7 +90,7 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
90
90
  const sanitized = sanitizeForKeywordDetection(input);
91
91
 
92
92
  assert.ok(sanitized.includes("정상 문장"));
93
- assert.ok(!sanitized.includes("tfx team"));
93
+ assert.ok(!sanitized.includes("tfx multi"));
94
94
  assert.ok(!sanitized.includes("https://"));
95
95
  assert.ok(!sanitized.includes("C:\\Users\\"));
96
96
  assert.ok(!sanitized.includes("./hooks/keyword-rules.json"));
@@ -100,7 +100,7 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
100
100
 
101
101
  test("loadRules: 유효한 JSON 로드", () => {
102
102
  const rules = loadRules(rulesPath);
103
- assert.equal(rules.length, 19);
103
+ assert.equal(rules.length, 20);
104
104
  assert.equal(rules.filter((rule) => rule.skill).length, 9);
105
105
  assert.equal(rules.filter((rule) => rule.mcp_route).length, 10);
106
106
  });
@@ -122,7 +122,7 @@ test("loadRules: 잘못된 파일 처리", () => {
122
122
  test("compileRules: 정규식 컴파일 성공", () => {
123
123
  const rules = loadRules(rulesPath);
124
124
  const compiled = compileRules(rules);
125
- assert.equal(compiled.length, 19);
125
+ assert.equal(compiled.length, 20);
126
126
  for (const rule of compiled) {
127
127
  assert.ok(Array.isArray(rule.compiledPatterns));
128
128
  assert.ok(rule.compiledPatterns.length > 0);
@@ -138,7 +138,7 @@ test("compileRules: 정규식 컴파일 실패", () => {
138
138
  id: "bad-pattern",
139
139
  priority: 1,
140
140
  patterns: [{ source: "[", flags: "" }],
141
- skill: "tfx-team",
141
+ skill: "tfx-multi",
142
142
  supersedes: [],
143
143
  exclusive: false,
144
144
  state: null,
@@ -152,7 +152,7 @@ test("compileRules: 정규식 컴파일 실패", () => {
152
152
  test("matchRules: tfx 키워드 매칭", () => {
153
153
  const compiledRules = loadCompiledRules();
154
154
  const cases = [
155
- { text: "tfx team 세션 시작", expectedId: "tfx-team" },
155
+ { text: "tfx multi 세션 시작", expectedId: "tfx-multi" },
156
156
  { text: "tfx auto 돌려줘", expectedId: "tfx-auto" },
157
157
  { text: "tfx codex 로 실행", expectedId: "tfx-codex" },
158
158
  { text: "tfx gemini 로 실행", expectedId: "tfx-gemini" },
@@ -218,17 +218,17 @@ test("resolveConflicts: exclusive 처리", () => {
218
218
 
219
219
  test("코드블록 내 키워드: sanitize 후 매칭 안 됨", () => {
220
220
  const compiledRules = loadCompiledRules();
221
- const input = ["```txt", "tfx team", "jira 이슈 생성", "```"].join("\n");
221
+ const input = ["```txt", "tfx multi", "jira 이슈 생성", "```"].join("\n");
222
222
  const clean = sanitizeForKeywordDetection(input);
223
223
  const matches = matchRules(compiledRules, clean);
224
224
  assert.deepEqual(matches, []);
225
225
  });
226
226
 
227
227
  test("OMC 키워드와 triflux 키워드 비간섭 + TRIFLUX 네임스페이스", () => {
228
- const omcLike = runDetector("my tfx team 세션 보여줘");
228
+ const omcLike = runDetector("my tfx multi 세션 보여줘");
229
229
  assert.equal(omcLike.suppressOutput, true);
230
230
 
231
- const triflux = runDetector("tfx team 세션 시작");
231
+ const triflux = runDetector("tfx multi 세션 시작");
232
232
  const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
233
- assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-team\]/);
233
+ assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-multi\]/);
234
234
  });
@@ -0,0 +1,34 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ describe("smoke: 주요 모듈 import 검증", () => {
5
+ it("scripts/lib/keyword-rules.mjs — 순수 함수 export", async () => {
6
+ const mod = await import("../lib/keyword-rules.mjs");
7
+ assert.equal(typeof mod.loadRules, "function");
8
+ assert.equal(typeof mod.compileRules, "function");
9
+ assert.equal(typeof mod.matchRules, "function");
10
+ assert.equal(typeof mod.resolveConflicts, "function");
11
+ });
12
+
13
+ it("hub/team/shared.mjs — ANSI 상수 export", async () => {
14
+ const mod = await import("../../hub/team/shared.mjs");
15
+ assert.equal(typeof mod.AMBER, "string");
16
+ assert.equal(typeof mod.RESET, "string");
17
+ });
18
+
19
+ it("hub/team/staleState.mjs — stale 상태 유틸 export", async () => {
20
+ const mod = await import("../../hub/team/staleState.mjs");
21
+ assert.equal(typeof mod.TEAM_STATE_FILE_NAME, "string");
22
+ assert.equal(typeof mod.STALE_TEAM_MAX_AGE_MS, "number");
23
+ });
24
+
25
+ it("hub/pipeline/transitions.mjs — 파이프라인 전이 규칙 export", async () => {
26
+ const mod = await import("../../hub/pipeline/transitions.mjs");
27
+ assert.ok(Array.isArray(mod.PHASES));
28
+ assert.ok(mod.PHASES.includes("plan"));
29
+ assert.ok(mod.PHASES.includes("complete"));
30
+ assert.ok(mod.TERMINAL instanceof Set);
31
+ assert.ok(mod.TERMINAL.has("complete"));
32
+ assert.ok(mod.TERMINAL.has("failed"));
33
+ });
34
+ });
@@ -63,7 +63,7 @@ async function isHubHealthy(host, port) {
63
63
 
64
64
  function startHubDetached(port) {
65
65
  const serverPath = join(PLUGIN_ROOT, "hub", "server.mjs");
66
- if (!existsSync(serverPath)) return;
66
+ if (!existsSync(serverPath)) return false;
67
67
 
68
68
  try {
69
69
  const child = spawn(process.execPath, [serverPath], {
@@ -72,12 +72,30 @@ function startHubDetached(port) {
72
72
  stdio: "ignore",
73
73
  });
74
74
  child.unref();
75
+ return true;
75
76
  } catch {
76
- // best effort
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /** Hub 기동 후 ready 상태까지 대기 (최대 maxWaitMs) */
82
+ async function waitForHubReady(host, port, maxWaitMs = 5000) {
83
+ const interval = 250;
84
+ const deadline = Date.now() + maxWaitMs;
85
+ while (Date.now() < deadline) {
86
+ if (await isHubHealthy(host, port)) return true;
87
+ await new Promise((r) => setTimeout(r, interval));
77
88
  }
89
+ return false;
78
90
  }
79
91
 
80
92
  const { host, port } = resolveHubTarget();
81
93
  if (!(await isHubHealthy(host, port))) {
82
- startHubDetached(port);
94
+ const started = startHubDetached(port);
95
+ if (started) {
96
+ const ready = await waitForHubReady(host, port);
97
+ if (!ready) {
98
+ console.error("[tfx-hub-ensure] Hub 시작했으나 ready 대기 초과 — MCP 연결 실패 가능");
99
+ }
100
+ }
83
101
  }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // scripts/preflight-cache.mjs — 세션 시작 시 preflight 점검 캐싱
3
+
4
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { execSync } from "node:child_process";
8
+
9
+ const CACHE_DIR = join(homedir(), ".claude", "cache");
10
+ const CACHE_FILE = join(CACHE_DIR, "tfx-preflight.json");
11
+ const CACHE_TTL_MS = 30_000; // 30초
12
+
13
+ function checkHub() {
14
+ try {
15
+ const res = execSync("curl -sf http://127.0.0.1:27888/status", { timeout: 3000, encoding: "utf8" });
16
+ const data = JSON.parse(res);
17
+ return { ok: true, state: data?.hub?.state || "unknown", pid: data?.pid };
18
+ } catch {
19
+ return { ok: false, state: "unreachable" };
20
+ }
21
+ }
22
+
23
+ function checkRoute() {
24
+ const routePath = join(homedir(), ".claude", "scripts", "tfx-route.sh");
25
+ return { ok: existsSync(routePath), path: routePath };
26
+ }
27
+
28
+ function checkCli(name) {
29
+ try {
30
+ const path = execSync(`which ${name} 2>/dev/null || where ${name} 2>nul`, { encoding: "utf8", timeout: 2000 }).trim();
31
+ return { ok: !!path, path };
32
+ } catch {
33
+ return { ok: false };
34
+ }
35
+ }
36
+
37
+ function runPreflight() {
38
+ const result = {
39
+ timestamp: Date.now(),
40
+ hub: checkHub(),
41
+ route: checkRoute(),
42
+ codex: checkCli("codex"),
43
+ gemini: checkCli("gemini"),
44
+ ok: false,
45
+ };
46
+ result.ok = result.hub.ok && result.route.ok;
47
+ return result;
48
+ }
49
+
50
+ // 캐시 읽기 (TTL 검증 포함)
51
+ export function readPreflightCache() {
52
+ try {
53
+ const data = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
54
+ if (Date.now() - data.timestamp < CACHE_TTL_MS) return data;
55
+ } catch {}
56
+ return null;
57
+ }
58
+
59
+ // 메인 실행
60
+ if (process.argv[1]?.endsWith("preflight-cache.mjs")) {
61
+ const result = runPreflight();
62
+ mkdirSync(CACHE_DIR, { recursive: true });
63
+ writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2));
64
+ // 간결 출력 (hook stdout)
65
+ const summary = result.ok ? "preflight: ok" : "preflight: FAIL";
66
+ const details = [];
67
+ if (!result.hub.ok) details.push("hub:" + result.hub.state);
68
+ if (!result.route.ok) details.push("route:missing");
69
+ console.log(details.length ? `${summary} (${details.join(", ")})` : summary);
70
+ }
71
+
72
+ export { runPreflight, CACHE_FILE, CACHE_TTL_MS };