@triflux/core 10.0.0-alpha.1

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.
Files changed (75) hide show
  1. package/hooks/agent-route-guard.mjs +109 -0
  2. package/hooks/cross-review-tracker.mjs +122 -0
  3. package/hooks/error-context.mjs +148 -0
  4. package/hooks/hook-manager.mjs +352 -0
  5. package/hooks/hook-orchestrator.mjs +312 -0
  6. package/hooks/hook-registry.json +213 -0
  7. package/hooks/hooks.json +89 -0
  8. package/hooks/keyword-rules.json +581 -0
  9. package/hooks/lib/resolve-root.mjs +59 -0
  10. package/hooks/mcp-config-watcher.mjs +85 -0
  11. package/hooks/pipeline-stop.mjs +76 -0
  12. package/hooks/safety-guard.mjs +106 -0
  13. package/hooks/subagent-verifier.mjs +80 -0
  14. package/hub/assign-callbacks.mjs +133 -0
  15. package/hub/bridge.mjs +799 -0
  16. package/hub/cli-adapter-base.mjs +192 -0
  17. package/hub/codex-adapter.mjs +190 -0
  18. package/hub/codex-compat.mjs +78 -0
  19. package/hub/codex-preflight.mjs +147 -0
  20. package/hub/delegator/contracts.mjs +37 -0
  21. package/hub/delegator/index.mjs +14 -0
  22. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  23. package/hub/delegator/service.mjs +307 -0
  24. package/hub/delegator/tool-definitions.mjs +35 -0
  25. package/hub/fullcycle.mjs +96 -0
  26. package/hub/gemini-adapter.mjs +179 -0
  27. package/hub/hitl.mjs +143 -0
  28. package/hub/intent.mjs +193 -0
  29. package/hub/lib/process-utils.mjs +361 -0
  30. package/hub/middleware/request-logger.mjs +81 -0
  31. package/hub/paths.mjs +30 -0
  32. package/hub/pipeline/gates/confidence.mjs +56 -0
  33. package/hub/pipeline/gates/consensus.mjs +94 -0
  34. package/hub/pipeline/gates/index.mjs +5 -0
  35. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  36. package/hub/pipeline/index.mjs +318 -0
  37. package/hub/pipeline/state.mjs +191 -0
  38. package/hub/pipeline/transitions.mjs +124 -0
  39. package/hub/platform.mjs +225 -0
  40. package/hub/quality/deslop.mjs +253 -0
  41. package/hub/reflexion.mjs +372 -0
  42. package/hub/research.mjs +146 -0
  43. package/hub/router.mjs +791 -0
  44. package/hub/routing/complexity.mjs +166 -0
  45. package/hub/routing/index.mjs +117 -0
  46. package/hub/routing/q-learning.mjs +336 -0
  47. package/hub/session-fingerprint.mjs +352 -0
  48. package/hub/state.mjs +245 -0
  49. package/hub/team-bridge.mjs +25 -0
  50. package/hub/token-mode.mjs +224 -0
  51. package/hub/workers/worker-utils.mjs +104 -0
  52. package/hud/colors.mjs +88 -0
  53. package/hud/constants.mjs +81 -0
  54. package/hud/hud-qos-status.mjs +206 -0
  55. package/hud/providers/claude.mjs +309 -0
  56. package/hud/providers/codex.mjs +151 -0
  57. package/hud/providers/gemini.mjs +320 -0
  58. package/hud/renderers.mjs +424 -0
  59. package/hud/terminal.mjs +140 -0
  60. package/hud/utils.mjs +287 -0
  61. package/package.json +31 -0
  62. package/scripts/lib/claudemd-manager.mjs +325 -0
  63. package/scripts/lib/context.mjs +67 -0
  64. package/scripts/lib/cross-review-utils.mjs +51 -0
  65. package/scripts/lib/env-probe.mjs +241 -0
  66. package/scripts/lib/gemini-profiles.mjs +85 -0
  67. package/scripts/lib/hook-utils.mjs +14 -0
  68. package/scripts/lib/keyword-rules.mjs +166 -0
  69. package/scripts/lib/logger.mjs +105 -0
  70. package/scripts/lib/mcp-filter.mjs +739 -0
  71. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  72. package/scripts/lib/mcp-manifest.mjs +79 -0
  73. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  74. package/scripts/lib/psmux-info.mjs +119 -0
  75. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
@@ -0,0 +1,424 @@
1
+ // ============================================================================
2
+ // 라인 렌더러 (tier별 행 생성)
3
+ // ============================================================================
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import {
8
+ dim, bold, green, red, yellow, cyan,
9
+ claudeOrange, codexWhite, geminiBlue,
10
+ colorByPercent, colorByProvider,
11
+ CLAUDE_ORANGE, CODEX_WHITE, GEMINI_BLUE,
12
+ } from "./colors.mjs";
13
+ import {
14
+ PROVIDER_PREFIX_WIDTH, ACCOUNT_LABEL_WIDTH,
15
+ FIVE_HOUR_MS, SEVEN_DAY_MS, ONE_DAY_MS,
16
+ TEAM_STATE_PATH, SV_ACCUMULATOR_PATH, LEGACY_SV_ACCUMULATOR,
17
+ } from "./constants.mjs";
18
+ import {
19
+ readJson, readJsonMigrate, stripAnsi, padAnsiRight, fitText,
20
+ clampPercent, formatPercentCell, formatPlaceholderPercentCell,
21
+ formatTimeCell, formatTimeCellDH,
22
+ formatResetRemaining, formatResetRemainingDayHour,
23
+ getContextPercent, formatTokenCount, formatSvPct, formatSavings,
24
+ } from "./utils.mjs";
25
+ import { tierBar, tierDimBar } from "./terminal.mjs";
26
+ import { deriveGeminiLimits } from "./providers/gemini.mjs";
27
+
28
+ // ============================================================================
29
+ // 최근 벤치마크 diff 파일 읽기
30
+ // ============================================================================
31
+ export function readLatestBenchmarkDiff() {
32
+ const diffsDir = join(homedir(), ".omc", "state", "cx-auto-tokens", "diffs");
33
+ if (!existsSync(diffsDir)) return null;
34
+ try {
35
+ const files = readdirSync(diffsDir).filter(f => f.endsWith(".json")).sort().reverse();
36
+ if (files.length === 0) return null;
37
+ return readJson(join(diffsDir, files[0]), null);
38
+ } catch { return null; }
39
+ }
40
+
41
+ // 토큰 절약액 누적치 읽기 (tfx-auto token tracker)
42
+ export function readTokenSavings() {
43
+ const savingsPath = join(homedir(), ".omc", "state", "tfx-auto-tokens", "savings-total.json");
44
+ const data = readJson(savingsPath, null);
45
+ if (!data || data.totalSaved === 0) return null;
46
+ return data;
47
+ }
48
+
49
+ // sv-accumulator.json에서 누적 토큰/비용 읽기
50
+ export function readSvAccumulator() {
51
+ return readJsonMigrate(SV_ACCUMULATOR_PATH, LEGACY_SV_ACCUMULATOR, null);
52
+ }
53
+
54
+ /**
55
+ * 파이프라인 벤치마크 diff 결과를 HUD 요약 문자열로 포맷
56
+ */
57
+ export function formatTokenSummary(diff) {
58
+ if (!diff?.delta?.total || !diff?.savings) return "";
59
+ const t = diff.delta.total;
60
+ const s = diff.savings;
61
+
62
+ const inputStr = formatTokenCount(t.input);
63
+ const outputStr = formatTokenCount(t.output);
64
+ const actualStr = formatSavings(s.actualCost);
65
+ const claudeStr = formatSavings(s.claudeCost);
66
+ const savedPct = s.claudeCost > 0
67
+ ? Math.round((s.saved / s.claudeCost) * 100)
68
+ : 0;
69
+
70
+ return `${dim("tok:")}${inputStr}${dim("in")} ${outputStr}${dim("out")} ` +
71
+ `${dim("cost:")}${actualStr} ` +
72
+ `${dim("sv:")}${green(formatSavings(s.saved))}${dim("(")}${savedPct}%${dim(")")}`;
73
+ }
74
+
75
+ // ============================================================================
76
+ // tfx-multi 상태 행 생성 (v2.2 HUD 통합)
77
+ // ============================================================================
78
+ export function getTeamRow(currentTier) {
79
+ const teamState = readJson(TEAM_STATE_PATH, null);
80
+ if (!teamState || !teamState.sessionName) return null;
81
+
82
+ // 팀 생존 확인: startedAt 기준 24시간 초과면 stale로 간주
83
+ if (teamState.startedAt && (Date.now() - teamState.startedAt) > 24 * 60 * 60 * 1000) return null;
84
+
85
+ const workers = (teamState.members || []).filter((m) => m.role === "worker");
86
+ if (!workers.length) return null;
87
+
88
+ const tasks = teamState.tasks || [];
89
+ const completed = tasks.filter((t) => t.status === "completed").length;
90
+ const failed = tasks.filter((t) => t.status === "failed").length;
91
+ const total = tasks.length || workers.length;
92
+
93
+ // 경과 시간 (80col 이상에서만 표시)
94
+ const elapsed = (teamState.startedAt && (currentTier === "full" || currentTier === "compact"))
95
+ ? `${Math.round((Date.now() - teamState.startedAt) / 60000)}m`
96
+ : "";
97
+
98
+ // CLI 브랜드: 단일문자 + ANSI 색상 (x=codex, g=gemini, c=claude)
99
+ const cliTag = (cli) => cli === "codex" ? bold(codexWhite("x")) : cli === "gemini" ? bold(geminiBlue("g")) : bold(claudeOrange("c"));
100
+ // 멤버 상태: 태그 + 상태기호 (60col 이상)
101
+ const memberIcons = (currentTier === "full" || currentTier === "compact" || currentTier === "minimal") ? workers.map((m) => {
102
+ const task = tasks.find((t) => t.owner === m.name);
103
+ const status = task?.status === "completed" ? green("\u2713")
104
+ : task?.status === "in_progress" ? yellow("\u22EF")
105
+ : task?.status === "failed" ? red("\u2717")
106
+ : dim("\u25CC");
107
+ return `${cliTag(m.cli)}${status}`;
108
+ }).join(" ") : "";
109
+
110
+ // 진행 텍스트
111
+ const doneText = failed > 0
112
+ ? `${completed}/${total} ${red(`${failed}\u2717`)}`
113
+ : `${completed}/${total}`;
114
+
115
+ const leftText = elapsed ? `${doneText} ${dim(elapsed)}` : doneText;
116
+
117
+ return {
118
+ prefix: bold(claudeOrange("\u25B2")),
119
+ left: leftText,
120
+ right: memberIcons,
121
+ };
122
+ }
123
+
124
+ // ============================================================================
125
+ // 행 정렬 렌더링
126
+ // ============================================================================
127
+ export function renderAlignedRows(rows) {
128
+ const rightRows = rows.filter((row) => stripAnsi(String(row.right || "")).trim().length > 0);
129
+ const rawLeftWidth = rightRows.reduce((max, row) => Math.max(max, stripAnsi(row.left).length), 0);
130
+ return rows.map((row) => {
131
+ const prefix = padAnsiRight(row.prefix, PROVIDER_PREFIX_WIDTH);
132
+ const hasRight = stripAnsi(String(row.right || "")).trim().length > 0;
133
+ if (!hasRight) {
134
+ return `${prefix} ${row.left}`;
135
+ }
136
+ // 자기 left 대비 패딩 상한: 최대 2칸까지만 패딩 (과도한 공백 방지)
137
+ const ownLen = stripAnsi(row.left).length;
138
+ const effectiveWidth = Math.min(rawLeftWidth, ownLen + 2);
139
+ const left = padAnsiRight(row.left, effectiveWidth);
140
+ return `${prefix} ${left} ${dim("|")} ${row.right}`;
141
+ });
142
+ }
143
+
144
+ // ============================================================================
145
+ // micro tier: 모든 프로바이더를 1줄로 압축
146
+ // ============================================================================
147
+ export function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
148
+ const ctx = getContextPercent(stdin);
149
+
150
+ // Claude 5h/1w
151
+ const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
152
+ const cW = claudeUsage?.weeklyPercent != null ? clampPercent(claudeUsage.weeklyPercent) : null;
153
+ const cVal = claudeUsage != null
154
+ ? `${cF != null ? colorByProvider(cF, `${cF}`, claudeOrange) : dim("--")}${dim("/")}${cW != null ? colorByProvider(cW, `${cW}`, claudeOrange) : dim("--")}`
155
+ : dim("--/--");
156
+
157
+ // Codex 5h/1w
158
+ let xVal = dim("--/--");
159
+ if (codexBuckets) {
160
+ const mb = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
161
+ if (mb) {
162
+ const xF = mb.primary?.used_percent != null ? clampPercent(mb.primary.used_percent) : null;
163
+ const xW = mb.secondary?.used_percent != null ? clampPercent(mb.secondary.used_percent) : null;
164
+ xVal = `${xF != null ? colorByProvider(xF, `${xF}`, codexWhite) : dim("--")}${dim("/")}${xW != null ? colorByProvider(xW, `${xW}`, codexWhite) : dim("--")}`;
165
+ }
166
+ }
167
+
168
+ // Gemini
169
+ let gVal;
170
+ if (geminiBucket) {
171
+ const gl = deriveGeminiLimits(geminiBucket);
172
+ const gU = gl ? gl.usedPct : clampPercent((1 - (geminiBucket.remainingFraction ?? 1)) * 100);
173
+ gVal = colorByProvider(gU, `${gU}`, geminiBlue);
174
+ } else if ((geminiSession?.total || 0) > 0) {
175
+ gVal = geminiBlue("\u221E");
176
+ } else {
177
+ gVal = dim("--");
178
+ }
179
+
180
+ // sv
181
+ const sv = formatSvPct(combinedSvPct || 0).trim();
182
+
183
+ return `${bold(claudeOrange("c"))}${dim(":")}${cVal} ` +
184
+ `${bold(codexWhite("x"))}${dim(":")}${xVal} ` +
185
+ `${bold(geminiBlue("g"))}${dim(":")}${gVal} ` +
186
+ `${dim("sv:")}${sv} ` +
187
+ `${dim("ctx:")}${colorByPercent(ctx, `${ctx}%`)}`;
188
+ }
189
+
190
+ // ============================================================================
191
+ // Claude 행 렌더러
192
+ // ============================================================================
193
+ export function getClaudeRows(currentTier, stdin, claudeUsage, combinedSvPct) {
194
+ const contextPercent = getContextPercent(stdin);
195
+ const prefix = `${bold(claudeOrange("c"))}:`;
196
+
197
+ // 절약 퍼센트
198
+ const svStr = formatSvPct(combinedSvPct || 0);
199
+ const svSuffix = `${dim("sv:")}${svStr}`;
200
+
201
+ // API 실측 데이터
202
+ const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
203
+ const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
204
+ const fiveHourReset = claudeUsage?.fiveHourResetsAt
205
+ ? formatResetRemaining(claudeUsage.fiveHourResetsAt, FIVE_HOUR_MS)
206
+ : "n/a";
207
+ const weeklyReset = claudeUsage?.weeklyResetsAt
208
+ ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt, SEVEN_DAY_MS)
209
+ : "n/a";
210
+
211
+ const hasData = claudeUsage != null;
212
+
213
+ const fStr = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange) : dim(formatPlaceholderPercentCell());
214
+ const wStr = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange) : dim(formatPlaceholderPercentCell());
215
+ const fBar = hasData && fiveHourPercent != null ? tierBar(currentTier, fiveHourPercent, CLAUDE_ORANGE) : tierDimBar(currentTier);
216
+ const wBar = hasData && weeklyPercent != null ? tierBar(currentTier, weeklyPercent, CLAUDE_ORANGE) : tierDimBar(currentTier);
217
+ const fTime = formatTimeCell(fiveHourReset);
218
+ const wTime = formatTimeCellDH(weeklyReset);
219
+
220
+ if (currentTier === "nano" || currentTier === "micro") {
221
+ const fShort = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange) : dim("--");
222
+ const wShort = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange) : dim("--");
223
+ const quotaSection = `${fShort}${dim("/")}${wShort}`;
224
+ return [{ prefix, left: quotaSection, right: "" }];
225
+ }
226
+
227
+ if (currentTier === "minimal") {
228
+ const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}`;
229
+ return [{ prefix, left: quotaSection, right: "" }];
230
+ }
231
+
232
+ if (currentTier === "compact") {
233
+ const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
234
+ const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
235
+ return [{ prefix, left: quotaSection, right: contextSection }];
236
+ }
237
+
238
+ // full tier (>= 120 cols)
239
+ const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
240
+ const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
241
+ return [{ prefix, left: quotaSection, right: contextSection }];
242
+ }
243
+
244
+ // ============================================================================
245
+ // 계정 라벨 + 범용 프로바이더 행 렌더러
246
+ // ============================================================================
247
+ export function getAccountLabel(provider, accountsConfig, accountsState, codexEmail) {
248
+ const providerConfig = accountsConfig?.providers?.[provider] || [];
249
+ const providerState = accountsState?.providers?.[provider] || {};
250
+ const lastId = providerState.last_selected_id;
251
+ const picked = providerConfig.find((a) => a.id === lastId) || providerConfig[0]
252
+ || { id: `${provider}-main`, label: provider };
253
+ let label = picked.label || picked.id;
254
+ if (codexEmail) label = codexEmail;
255
+ if (label.includes("@")) label = label.split("@")[0];
256
+ return label;
257
+ }
258
+
259
+ export function getProviderRow(currentTier, provider, marker, markerColor, qosProfile, accountsConfig, accountsState, realQuota, codexEmail, savingsMultiplier, modelLabel) {
260
+ const accountLabel = fitText(getAccountLabel(provider, accountsConfig, accountsState, codexEmail), ACCOUNT_LABEL_WIDTH);
261
+
262
+ // 절약 퍼센트 섹션
263
+ const svPct = savingsMultiplier != null ? Math.round(savingsMultiplier * 100) : null;
264
+ const svStr = formatSvPct(svPct);
265
+ const modelLabelStr = modelLabel ? ` ${markerColor(modelLabel)}` : "";
266
+
267
+ // 프로바이더별 색상 프로필
268
+ const provAnsi = provider === "codex" ? CODEX_WHITE : provider === "gemini" ? GEMINI_BLUE : GREEN;
269
+ const provFn = provider === "codex" ? codexWhite : provider === "gemini" ? geminiBlue : green;
270
+
271
+ let quotaSection;
272
+ let extraRightSection = "";
273
+
274
+ if (currentTier === "nano" || currentTier === "micro") {
275
+ const minPrefix = `${bold(markerColor(`${marker}`))}:`;
276
+ if (realQuota?.type === "codex") {
277
+ const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
278
+ if (main) {
279
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
280
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
281
+ const fCellN = fiveP != null ? colorByProvider(fiveP, `${fiveP}%`, provFn) : dim("--%");
282
+ const wCellN = weekP != null ? colorByProvider(weekP, `${weekP}%`, provFn) : dim("--%");
283
+ return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}`, right: "" };
284
+ }
285
+ }
286
+ if (realQuota?.type === "gemini") {
287
+ const pools = realQuota.pools || {};
288
+ if (pools.pro || pools.flash) {
289
+ const pP = pools.pro ? clampPercent(Math.round((1 - (pools.pro.remainingFraction ?? 1)) * 100)) : null;
290
+ const pF = pools.flash ? clampPercent(Math.round((1 - (pools.flash.remainingFraction ?? 1)) * 100)) : null;
291
+ const pStr = pP != null ? colorByProvider(pP, `${pP}`, provFn) : dim("--");
292
+ const fStr = pF != null ? colorByProvider(pF, `${pF}`, provFn) : dim("--");
293
+ return { prefix: minPrefix, left: `${pStr}${dim("/")}${fStr}`, right: "" };
294
+ }
295
+ }
296
+ return { prefix: minPrefix, left: dim("--/--"), right: "" };
297
+ }
298
+
299
+ if (currentTier === "minimal") {
300
+ if (realQuota?.type === "codex") {
301
+ const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
302
+ if (main) {
303
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
304
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
305
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
306
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
307
+ quotaSection = `${dim("5h:")}${fCell} ${dim("1w:")}${wCell}`;
308
+ }
309
+ }
310
+ if (realQuota?.type === "gemini") {
311
+ const pools = realQuota.pools || {};
312
+ if (pools.pro || pools.flash) {
313
+ const slot = (bucket, label) => {
314
+ if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())}`;
315
+ const gl = deriveGeminiLimits(bucket);
316
+ const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
317
+ return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)}`;
318
+ };
319
+ quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
320
+ } else {
321
+ quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())}`;
322
+ }
323
+ }
324
+ if (!quotaSection) {
325
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim("1w:")}${dim(formatPlaceholderPercentCell())}`;
326
+ }
327
+ const prefix = `${bold(markerColor(`${marker}`))}:`;
328
+ return { prefix, left: quotaSection, right: accountLabel ? markerColor(accountLabel) : "" };
329
+ }
330
+
331
+ if (currentTier === "compact") {
332
+ if (realQuota?.type === "codex") {
333
+ const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
334
+ if (main) {
335
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
336
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
337
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
338
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
339
+ const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
340
+ const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
341
+ quotaSection = `${dim("5h:")}${fCell} ${dim(formatTimeCell(fiveReset))} ${dim("1w:")}${wCell} ${dim(formatTimeCellDH(weekReset))}`;
342
+ }
343
+ }
344
+ if (realQuota?.type === "gemini") {
345
+ const pools = realQuota.pools || {};
346
+ const hasAnyPool = pools.pro || pools.flash;
347
+ if (hasAnyPool) {
348
+ const slot = (bucket, label) => {
349
+ if (!bucket) return `${dim(label + ":")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
350
+ const gl = deriveGeminiLimits(bucket);
351
+ const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
352
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
353
+ return `${dim(label + ":")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
354
+ };
355
+ quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
356
+ } else {
357
+ quotaSection = `${dim("Pr:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("Fl:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
358
+ }
359
+ }
360
+ if (!quotaSection) {
361
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
362
+ }
363
+ const prefix = `${bold(markerColor(`${marker}`))}:`;
364
+ const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
365
+ return { prefix, left: quotaSection, right: compactRight };
366
+ }
367
+
368
+ // full tier
369
+ if (realQuota?.type === "codex") {
370
+ const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
371
+ if (main) {
372
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
373
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
374
+ const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
375
+ const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
376
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
377
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
378
+ const fBar = fiveP != null ? tierBar(currentTier, fiveP, provAnsi) : tierDimBar(currentTier);
379
+ const wBar = weekP != null ? tierBar(currentTier, weekP, provAnsi) : tierDimBar(currentTier);
380
+ quotaSection = `${dim("5h:")}${fBar}${fCell} ` +
381
+ `${dim(formatTimeCell(fiveReset))} ` +
382
+ `${dim("1w:")}${wBar}${wCell} ` +
383
+ `${dim(formatTimeCellDH(weekReset))}`;
384
+ }
385
+ }
386
+
387
+ if (realQuota?.type === "gemini") {
388
+ const pools = realQuota.pools || {};
389
+ const hasAnyPool = pools.pro || pools.flash;
390
+
391
+ if (hasAnyPool) {
392
+ const slot = (bucket, label) => {
393
+ if (!bucket) {
394
+ return `${dim(label + ":")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
395
+ }
396
+ const gl = deriveGeminiLimits(bucket);
397
+ const usedP = gl ? gl.usedPct : clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
398
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
399
+ return `${dim(label + ":")}${tierBar(currentTier, usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))}`;
400
+ };
401
+
402
+ quotaSection = `${slot(pools.pro, "Pr")} ${slot(pools.flash, "Fl")}`;
403
+ } else {
404
+ quotaSection = `${dim("Pr:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ` +
405
+ `${dim("Fl:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))}`;
406
+ }
407
+ }
408
+
409
+ // 폴백
410
+ if (!quotaSection) {
411
+ quotaSection = `${dim("5h:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCell("n/a"))} ${dim("1w:")}${tierDimBar(currentTier)}${dim(formatPlaceholderPercentCell())} ${dim(formatTimeCellDH("--d--h"))}`;
412
+ }
413
+
414
+ const prefix = `${bold(markerColor(`${marker}`))}:`;
415
+ const accountSection = `${markerColor(accountLabel)}`;
416
+ const svSection = svStr ? `${dim("sv:")}${svStr}` : "";
417
+ const modelLabelSection = modelLabel ? markerColor(modelLabel) : "";
418
+ const rightParts = [svSection, accountSection, modelLabelSection].filter(Boolean);
419
+ return {
420
+ prefix,
421
+ left: quotaSection,
422
+ right: rightParts.join(` ${dim("|")} `),
423
+ };
424
+ }
@@ -0,0 +1,140 @@
1
+ // ============================================================================
2
+ // 터미널 감지 / 4-Tier 적응형 렌더링
3
+ // ============================================================================
4
+ import { execSync } from "node:child_process";
5
+ import { DIM, RESET, GAUGE_WIDTH, coloredBar } from "./colors.mjs";
6
+ import { readJson } from "./utils.mjs";
7
+ import { HUD_CONFIG_PATH, COMPACT_COLS_THRESHOLD, MINIMAL_COLS_THRESHOLD } from "./constants.mjs";
8
+
9
+ let _cachedColumns = 0;
10
+ export function getTerminalColumns() {
11
+ if (_cachedColumns > 0) return _cachedColumns;
12
+ const envCols = Number(process.env.COLUMNS);
13
+ if (envCols > 0) { _cachedColumns = envCols; return _cachedColumns; }
14
+ if (process.stdout.columns) { _cachedColumns = process.stdout.columns; return _cachedColumns; }
15
+ if (process.stderr.columns) { _cachedColumns = process.stderr.columns; return _cachedColumns; }
16
+ try {
17
+ if (process.platform === "win32") {
18
+ const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
19
+ const m = raw.match(/Columns[^:]*:\s*(\d+)/i) || raw.match(/열[^:]*:\s*(\d+)/);
20
+ if (m) { _cachedColumns = Number(m[1]); return _cachedColumns; }
21
+ } else {
22
+ const raw = execSync("tput cols 2>/dev/null || stty size 2>/dev/null | awk '{print $2}'", {
23
+ timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
24
+ }).toString().trim();
25
+ if (raw && !isNaN(Number(raw))) { _cachedColumns = Number(raw); return _cachedColumns; }
26
+ }
27
+ } catch { /* 감지 실패 */ }
28
+ return 0;
29
+ }
30
+
31
+ let _cachedRows = 0;
32
+ export function getTerminalRows() {
33
+ if (_cachedRows > 0) return _cachedRows;
34
+ if (process.stdout.rows) { _cachedRows = process.stdout.rows; return _cachedRows; }
35
+ if (process.stderr.rows) { _cachedRows = process.stderr.rows; return _cachedRows; }
36
+ const envLines = Number(process.env.LINES);
37
+ if (envLines > 0) { _cachedRows = envLines; return _cachedRows; }
38
+ try {
39
+ if (process.platform === "win32") {
40
+ const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
41
+ const m = raw.match(/Lines[^:]*:\s*(\d+)/i) || raw.match(/줄[^:]*:\s*(\d+)/);
42
+ if (m) { _cachedRows = Number(m[1]); return _cachedRows; }
43
+ } else {
44
+ const raw = execSync("tput lines 2>/dev/null || stty size 2>/dev/null | awk '{print $1}'", {
45
+ timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
46
+ }).toString().trim();
47
+ if (raw && !isNaN(Number(raw))) { _cachedRows = Number(raw); return _cachedRows; }
48
+ }
49
+ } catch { /* 감지 실패 */ }
50
+ return 0;
51
+ }
52
+
53
+ export function detectCompactMode() {
54
+ // 1. 명시적 CLI 플래그
55
+ if (process.argv.includes("--compact")) return true;
56
+ if (process.argv.includes("--no-compact")) return false;
57
+ // 2. 환경변수 오버라이드
58
+ if (process.env.TERMUX_VERSION) return true;
59
+ if (process.env.OMC_HUD_COMPACT === "1") return true;
60
+ if (process.env.OMC_HUD_COMPACT === "0") return false;
61
+ // 3. 설정 파일 (~/.omc/config/hud.json)
62
+ const hudConfig = readJson(HUD_CONFIG_PATH, null);
63
+ if (hudConfig?.compact === true || hudConfig?.compact === "always") return true;
64
+ if (hudConfig?.compact === false || hudConfig?.compact === "never") return false;
65
+ // 4. maxLines < 3이면 자동 컴팩트 (알림 배너 공존 대응)
66
+ if (Number(hudConfig?.lines) > 0 && Number(hudConfig?.lines) < 3) return true;
67
+ // 5. 터미널 폭 자동 감지 (TTY 있을 때만 유효)
68
+ const threshold = Number(hudConfig?.compactThreshold) || COMPACT_COLS_THRESHOLD;
69
+ const cols = getTerminalColumns();
70
+ if (cols > 0 && cols < threshold) return true;
71
+ return false;
72
+ }
73
+
74
+ export function detectMinimalMode() {
75
+ // 1. 명시적 CLI 플래그
76
+ if (process.argv.includes("--minimal")) return true;
77
+ // 2. 환경변수
78
+ if (process.env.OMC_HUD_MINIMAL === "1") return true;
79
+ if (process.env.OMC_HUD_MINIMAL === "0") return false;
80
+ // 3. 설정 파일 (~/.omc/config/hud.json)
81
+ const hudConfig = readJson(HUD_CONFIG_PATH, null);
82
+ if (hudConfig?.compact === "minimal") return true;
83
+ // 4. 터미널 폭 자동 감지
84
+ const cols = getTerminalColumns();
85
+ if (cols > 0 && cols < MINIMAL_COLS_THRESHOLD) return true;
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * 인디케이터 인식 + 터미널 크기 기반 tier 자동 선택.
91
+ * main()에서 stdin 수신 후 호출하여 CURRENT_TIER 갱신.
92
+ */
93
+ export function selectTier(stdin, claudeUsage = null) {
94
+ const hudConfig = readJson(HUD_CONFIG_PATH, null);
95
+
96
+ // 1) 명시적 tier 강제 설정
97
+ const forcedTier = hudConfig?.tier;
98
+ if (["full", "compact", "minimal", "micro", "nano"].includes(forcedTier)) return forcedTier;
99
+
100
+ // 1.5) maxLines=1 → nano (1줄 모드: 알림 배너/분할화면 대응)
101
+ if (Number(hudConfig?.lines) === 1) return "nano";
102
+
103
+ const cols = getTerminalColumns() || 120;
104
+
105
+ // 1.6) 극소 폭(< 40col)인 경우 1줄 모드(nano)로 폴백
106
+ if (cols < 40) return "nano";
107
+
108
+ // 2) 기존 모드 플래그 존중
109
+ const COMPACT_MODE = detectCompactMode();
110
+ const MINIMAL_MODE = detectMinimalMode();
111
+ if (MINIMAL_MODE) return "micro";
112
+ if (COMPACT_MODE) return "compact";
113
+
114
+ // 3) autoResize 비활성이면 full 유지
115
+ if (hudConfig?.autoResize === false) return "full";
116
+
117
+ // 4) 터미널 폭에 따른 점진적 축소 (breakpoint)
118
+ if (cols >= 120) return "full";
119
+ if (cols >= 80) return "compact";
120
+ if (cols >= 60) return "minimal";
121
+ return "micro"; // 40 <= cols < 60
122
+ }
123
+
124
+ // full tier 전용: 게이지 바 접두사 (normal 이하 tier에서는 빈 문자열)
125
+ export function tierBar(currentTier, percent, baseColor = null) {
126
+ return currentTier === "full" ? coloredBar(percent, GAUGE_WIDTH, baseColor) + " " : "";
127
+ }
128
+ export function tierDimBar(currentTier) {
129
+ return currentTier === "full" ? DIM + "░".repeat(GAUGE_WIDTH) + RESET + " " : "";
130
+ }
131
+ // Gemini ∞% 전용: 무한 쿼터이므로 dim 회색 바
132
+ export function tierInfBar(currentTier) {
133
+ return currentTier === "full" ? DIM + "█".repeat(GAUGE_WIDTH) + RESET + " " : "";
134
+ }
135
+
136
+ // 테스트 지원: 캐시 초기화
137
+ export function _resetTerminalCache() {
138
+ _cachedColumns = 0;
139
+ _cachedRows = 0;
140
+ }