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.
- package/bin/triflux.mjs +1516 -1386
- package/hooks/hooks.json +22 -0
- package/hooks/keyword-rules.json +4 -4
- package/hooks/pipeline-stop.mjs +54 -0
- package/hub/bridge.mjs +120 -13
- package/hub/pipe.mjs +23 -0
- package/hub/pipeline/index.mjs +121 -0
- package/hub/pipeline/state.mjs +164 -0
- package/hub/pipeline/transitions.mjs +114 -0
- package/hub/router.mjs +322 -1
- package/hub/schema.sql +49 -2
- package/hub/server.mjs +173 -8
- package/hub/store.mjs +259 -1
- package/hub/team/cli-team-control.mjs +381 -381
- package/hub/team/cli-team-start.mjs +474 -470
- package/hub/team/cli-team-status.mjs +238 -238
- package/hub/team/cli.mjs +86 -86
- package/hub/team/native.mjs +144 -6
- package/hub/team/nativeProxy.mjs +51 -38
- package/hub/team/orchestrator.mjs +15 -20
- package/hub/team/pane.mjs +101 -90
- package/hub/team/psmux.mjs +721 -72
- package/hub/team/session.mjs +450 -450
- package/hub/tools.mjs +223 -63
- package/hub/workers/delegator-mcp.mjs +900 -0
- package/hub/workers/factory.mjs +3 -0
- package/hub/workers/interface.mjs +2 -2
- package/hud/hud-qos-status.mjs +89 -144
- package/package.json +1 -1
- package/scripts/__tests__/keyword-detector.test.mjs +11 -11
- package/scripts/__tests__/smoke.test.mjs +34 -0
- package/scripts/hub-ensure.mjs +21 -3
- package/scripts/preflight-cache.mjs +72 -0
- package/scripts/setup.mjs +23 -11
- package/scripts/tfx-route.sh +74 -15
- package/skills/{tfx-team → tfx-multi}/SKILL.md +115 -32
package/hub/workers/factory.mjs
CHANGED
|
@@ -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']);
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -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-
|
|
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" : "
|
|
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", "
|
|
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
|
-
|
|
291
|
-
if (Number(hudConfig?.lines) === 1) return "micro";
|
|
293
|
+
const cols = getTerminalColumns() || 120;
|
|
292
294
|
|
|
293
|
-
// 1.6)
|
|
294
|
-
if (
|
|
295
|
+
// 1.6) 극소 폭(< 40col)인 경우 1줄 모드(nano)로 폴백
|
|
296
|
+
if (cols < 40) return "nano";
|
|
295
297
|
|
|
296
298
|
// 2) 기존 모드 플래그 존중
|
|
297
|
-
if (MINIMAL_MODE) return "
|
|
299
|
+
if (MINIMAL_MODE) return "micro";
|
|
298
300
|
if (COMPACT_MODE) return "compact";
|
|
299
301
|
|
|
300
|
-
// 3) autoResize 비활성이면
|
|
301
|
-
if (hudConfig?.autoResize === false) return "
|
|
302
|
+
// 3) autoResize 비활성이면 full 유지
|
|
303
|
+
if (hudConfig?.autoResize === false) return "full";
|
|
302
304
|
|
|
303
|
-
// 4) 터미널
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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-
|
|
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:
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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 === "
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
const quotaSection = `${dim("5h:")}${
|
|
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
|
-
//
|
|
1492
|
-
const
|
|
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,
|
|
1549
|
-
const wCellN = weekP != null ? colorByProvider(weekP,
|
|
1550
|
-
|
|
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
|
-
|
|
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("
|
|
1484
|
+
return { prefix: minPrefix, left: dim("--%/--%"), right: "" };
|
|
1567
1485
|
}
|
|
1568
1486
|
|
|
1569
|
-
if (CURRENT_TIER === "
|
|
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
|
-
|
|
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
|
-
//
|
|
1745
|
-
if (CURRENT_TIER === "
|
|
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-
|
|
1708
|
+
// tfx-multi 활성 시 팀 상태 행 추가 (v2.2)
|
|
1764
1709
|
const teamRow = getTeamRow();
|
|
1765
1710
|
if (teamRow) rows.push(teamRow);
|
|
1766
1711
|
|
package/package.json
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
228
|
+
const omcLike = runDetector("my tfx multi 세션 보여줘");
|
|
229
229
|
assert.equal(omcLike.suppressOutput, true);
|
|
230
230
|
|
|
231
|
-
const triflux = runDetector("tfx
|
|
231
|
+
const triflux = runDetector("tfx multi 세션 시작");
|
|
232
232
|
const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
|
|
233
|
-
assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-
|
|
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
|
+
});
|
package/scripts/hub-ensure.mjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|