triflux 8.4.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -61,6 +61,10 @@
61
61
 
62
62
  ## Tri-CLI 합의 엔진
63
63
 
64
+ <p align="center">
65
+ <img src="docs/assets/consensus-flow.svg" alt="Tri-CLI Consensus 플로우" width="680">
66
+ </p>
67
+
64
68
  triflux의 핵심 혁신입니다. 단일 모델을 맹신하는 대신, 모든 Deep 스킬은 다음 과정을 거칩니다:
65
69
 
66
70
  ```
@@ -175,6 +179,10 @@ Phase 3: Resolution (합의율 < 70%일 경우)
175
179
 
176
180
  모든 도메인에서 두 가지 모드를 제공합니다:
177
181
 
182
+ <p align="center">
183
+ <img src="docs/assets/deep-vs-light.svg" alt="Deep vs Light 비교" width="680">
184
+ </p>
185
+
178
186
  | 항목 | Light | Deep |
179
187
  |------|-------|------|
180
188
  | **CLI** | 단일 (주로 Codex) | 3자 (Claude + Codex + Gemini) |
@@ -188,6 +196,13 @@ Phase 3: Resolution (합의율 < 70%일 경우)
188
196
 
189
197
  ## 아키텍처
190
198
 
199
+ <p align="center">
200
+ <img src="docs/assets/architecture.svg" alt="triflux 아키텍처" width="680">
201
+ </p>
202
+
203
+ <details>
204
+ <summary>인터랙티브 다이어그램 (GitHub 전용)</summary>
205
+
191
206
  ```mermaid
192
207
  graph TD
193
208
  User([사용자 / Claude Code]) <-->|Skills & Slash Commands| TFX[tfx Skills Layer]
@@ -217,6 +232,8 @@ graph TD
217
232
  HUB -.->|MCP Bridge| External[외부 MCP 클라이언트]
218
233
  ```
219
234
 
235
+ </details>
236
+
220
237
  ---
221
238
 
222
239
  ## 빠른 시작
package/README.md CHANGED
@@ -61,6 +61,10 @@
61
61
 
62
62
  ## Tri-CLI Consensus
63
63
 
64
+ <p align="center">
65
+ <img src="docs/assets/consensus-flow.svg" alt="Tri-CLI Consensus Flow" width="680">
66
+ </p>
67
+
64
68
  The core innovation of triflux. Instead of trusting a single model, every Deep skill runs:
65
69
 
66
70
  ```
@@ -175,6 +179,10 @@ Phase 3: Resolution (if consensus < 70%)
175
179
 
176
180
  Every domain offers both modes:
177
181
 
182
+ <p align="center">
183
+ <img src="docs/assets/deep-vs-light.svg" alt="Deep vs Light comparison" width="680">
184
+ </p>
185
+
178
186
  | Dimension | Light | Deep |
179
187
  |-----------|-------|------|
180
188
  | **CLIs** | Single (usually Codex) | 3-party (Claude + Codex + Gemini) |
@@ -188,6 +196,13 @@ Every domain offers both modes:
188
196
 
189
197
  ## Architecture
190
198
 
199
+ <p align="center">
200
+ <img src="docs/assets/architecture.svg" alt="triflux architecture" width="680">
201
+ </p>
202
+
203
+ <details>
204
+ <summary>Interactive diagram (GitHub only)</summary>
205
+
191
206
  ```mermaid
192
207
  graph TD
193
208
  User([User / Claude Code]) <-->|Skills & Slash Commands| TFX[tfx Skills Layer]
@@ -217,6 +232,8 @@ graph TD
217
232
  HUB -.->|MCP Bridge| External[External MCP Clients]
218
233
  ```
219
234
 
235
+ </details>
236
+
220
237
  ---
221
238
 
222
239
  ## Quick Start
package/bin/triflux.mjs CHANGED
@@ -1403,7 +1403,7 @@ async function cmdDoctor(options = {}) {
1403
1403
  // --fix 없이는 개수만 보고
1404
1404
  const { execSync: execSyncDoctor } = await import("node:child_process");
1405
1405
  const countStr = execSyncDoctor(
1406
- `powershell -NoProfile -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
1406
+ `powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
1407
1407
  { encoding: "utf8", timeout: 5000 },
1408
1408
  ).trim();
1409
1409
  const count = Number.parseInt(countStr, 10) || 0;
@@ -1486,7 +1486,7 @@ async function cmdDoctor(options = {}) {
1486
1486
  // Claude Code 프로세스에서 세션 ID 검색
1487
1487
  if (process.platform === "win32") {
1488
1488
  const psOut = execSync(
1489
- `powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${safeToken}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
1489
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${safeToken}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
1490
1490
  { encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
1491
1491
  ).trim();
1492
1492
  if (psOut && psOut !== "null") {
@@ -1497,7 +1497,7 @@ async function cmdDoctor(options = {}) {
1497
1497
  } else {
1498
1498
  const psOut = execSync(
1499
1499
  `ps -ax -o pid=,command= | grep -i '${safeToken}' | grep -v grep`,
1500
- { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
1500
+ { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
1501
1501
  ).trim();
1502
1502
  hasActiveMember = psOut.length > 0;
1503
1503
  }
@@ -155,8 +155,8 @@ export function cleanupOrphanNodeProcesses() {
155
155
 
156
156
  try {
157
157
  const treeOutput = execSync(
158
- `powershell -NoProfile -ExecutionPolicy Bypass -File "${TREE_SCRIPT_PATH}" -StartPid ${myPid}`,
159
- { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
158
+ `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${TREE_SCRIPT_PATH}" -StartPid ${myPid}`,
159
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
160
160
  );
161
161
  for (const line of treeOutput.split(/\r?\n/)) {
162
162
  const pid = Number.parseInt(line.trim(), 10);
@@ -168,8 +168,8 @@ export function cleanupOrphanNodeProcesses() {
168
168
  const procMap = new Map();
169
169
  try {
170
170
  const output = execSync(
171
- `powershell -NoProfile -ExecutionPolicy Bypass -File "${SCAN_SCRIPT_PATH}"`,
172
- { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"] },
171
+ `powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${SCAN_SCRIPT_PATH}"`,
172
+ { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
173
173
  );
174
174
 
175
175
  for (const line of output.split(/\r?\n/)) {
@@ -203,8 +203,8 @@ export function cleanupOrphanNodeProcesses() {
203
203
  let remaining = 0;
204
204
  try {
205
205
  const countOutput = execSync(
206
- `powershell -NoProfile -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
207
- { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
206
+ `powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
207
+ { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
208
208
  );
209
209
  remaining = Number.parseInt(countOutput.trim(), 10) || 0;
210
210
  } catch {}
package/hub/team/ansi.mjs CHANGED
@@ -39,7 +39,7 @@ export const FG = {
39
39
  cyan: `${ESC}[36m`,
40
40
  gray: `${ESC}[90m`,
41
41
  // triflux 브랜드
42
- codex: `${ESC}[97m`, // bright white
42
+ codex: `${ESC}[38;2;16;163;127m`, // #10a37f codex green
43
43
  gemini: `${ESC}[38;5;39m`, // blue
44
44
  claude: `${ESC}[38;2;232;112;64m`, // orange
45
45
  triflux: `${ESC}[38;5;214m`, // amber
@@ -185,7 +185,7 @@ export function clip(str, width) {
185
185
  acc += cw;
186
186
  i += char.length;
187
187
  }
188
- return plain + " ".repeat(width - acc);
188
+ return str + " ".repeat(width - acc);
189
189
  }
190
190
 
191
191
  // ── Catppuccin Mocha 색상 상수 ──
@@ -514,8 +514,8 @@ function killOrphanPipeHelpers(sessionName) {
514
514
  const safeSession = sanitizePathPart(sessionName);
515
515
  try {
516
516
  const output = childProcess.execSync(
517
- `powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
518
- { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
517
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
518
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
519
519
  );
520
520
  const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
521
521
  for (const pid of pids) {
@@ -549,8 +549,8 @@ function killOrphanMcpProcesses(sessionName) {
549
549
  try {
550
550
  // 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
551
551
  const output = childProcess.execSync(
552
- `powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
553
- { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
552
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
553
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
554
554
  );
555
555
  const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
556
556
  for (const pid of pids) {
package/hub/team/tui.mjs CHANGED
@@ -72,6 +72,13 @@ function heartbeat(status, shimmerIntensity = 0) {
72
72
  : SPINNER_BASE_COLOR;
73
73
  return `\x1b[38;2;${c.r};${c.g};${c.b}m${SPINNER_FRAMES[idx]}${RESET}`;
74
74
  }
75
+
76
+ function currentShimmer() {
77
+ const elapsed = Date.now() - spinnerStart;
78
+ const t = (elapsed % SPINNER_CYCLE_MS) / SPINNER_CYCLE_MS;
79
+ return 0.5 * (1 + Math.sin(t * Math.PI * 2));
80
+ }
81
+
75
82
  const GRID_GAP = 2;
76
83
  const DEFAULT_DETAIL_LINES = 10;
77
84
  // Tier1 상단 고정 행 수
@@ -215,6 +222,16 @@ function fadeBorderColor(currentStatus, prevStatus, changedAt) {
215
222
  return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
216
223
  }
217
224
 
225
+ function dedupeRole(role, name, cli) {
226
+ if (!role) return "";
227
+ let r = role;
228
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
229
+ r = r.replace(new RegExp(esc(cli), "gi"), "").trim();
230
+ r = r.replace(new RegExp(esc(name), "gi"), "").trim();
231
+ r = r.replace(/\(\s*\)/g, "").replace(/^\s*[•·\-]\s*/, "").trim();
232
+ return r;
233
+ }
234
+
218
235
  // ── 텍스트 래핑 ──────────────────────────────────────────────────────────
219
236
  function wrapLine(text, width) {
220
237
  const limit = Math.max(8, width);
@@ -310,7 +327,7 @@ function buildTier1(names, workers, pipeline, elapsed, width, version) {
310
327
  const phase = pipeline.phase || "exec";
311
328
  const row1 = truncate(
312
329
  `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
313
- `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)} ${color("Tab/j/k:nav • f:follow • r:raw • 1-9:jump", MOCHA.subtext)}`,
330
+ `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)} ${color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext)}`,
314
331
  width,
315
332
  );
316
333
  return [row1];
@@ -357,22 +374,22 @@ function buildWorkerRail(name, st, opts = {}) {
357
374
 
358
375
  // Tier2 행 1: 이름 + CLI + role
359
376
  const selMark = selected ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux)) : " ";
360
- const hb = heartbeat(status);
377
+ const hb = heartbeat(status, status === "running" ? currentShimmer() : 0);
378
+ const displayRole = dedupeRole(role, name, cli);
361
379
  const title = truncate(
362
- `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${role ? ` ${color(`(${role})`, MOCHA.overlay)}` : ""}`,
380
+ `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
363
381
  innerWidth,
364
382
  );
365
383
 
366
- // status-specific border: runningblue, completedgreen, failedred, partial→yellow
384
+ // status-specific border: focusedmauve, selectedbright, non-selecteddimmed tint
367
385
  const statusBorderColor = (() => {
368
386
  if (focused) return MOCHA.thinking;
369
- if (selected) {
370
- if (status === "running" || status === "in_progress") return MOCHA.blue;
371
- if (status === "ok" || status === "completed") return MOCHA.ok;
372
- if (status === "failed") return MOCHA.fail;
373
- if (status === "partial") return MOCHA.yellow;
374
- }
375
- return fadeBorderColor(status, st._prevStatus, st._statusChangedAt);
387
+ if (selected) return statusColor(status);
388
+ // Non-selected: status-tinted border (50% blend toward border gray)
389
+ const from = statusToRgb(status);
390
+ const to = MOCHA_RGB.border;
391
+ const c = lerpRgb(from, to, 0.5);
392
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
376
393
  })();
377
394
 
378
395
  if (compact) {
@@ -443,9 +460,10 @@ function buildFocusPane(name, st, opts = {}) {
443
460
  const status = runtimeStatus(st);
444
461
 
445
462
  // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
446
- const tabLog = `${MOCHA.blue}${bold("[Log]")}`;
447
- const tabDetail = color("[Detail]", MOCHA.overlay);
448
- const tabFiles = color(`[Files ${files.length}]`, MOCHA.overlay);
463
+ const activeTab = opts.activeTab || "log";
464
+ const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
465
+ const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
466
+ const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
449
467
  const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
450
468
 
451
469
  const stickyLines = [
@@ -457,8 +475,27 @@ function buildFocusPane(name, st, opts = {}) {
457
475
  ];
458
476
 
459
477
  // 본문 스크롤 영역
460
- const bodyAvail = Math.max(0, height - stickyLines.length - 2); // top+bot border
461
- const allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
478
+ const bodyAvail = Math.max(0, height - stickyLines.length - 3); // top+bot border + scrollInfo
479
+
480
+ let allBodyLines;
481
+ if (activeTab === "detail") {
482
+ const summaryLines = [];
483
+ for (const key of SUMMARY_KEYS) {
484
+ const value = st.handoff?.[key];
485
+ if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
486
+ else if (value) summaryLines.push(`${key}: ${value}`);
487
+ }
488
+ allBodyLines = summaryLines.length > 0
489
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
490
+ : [dim("no structured data")];
491
+ } else if (activeTab === "files") {
492
+ const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
493
+ allBodyLines = filesList.length > 0
494
+ ? filesList.map((f, i) => `${i + 1}. ${f}`)
495
+ : [dim("no files changed")];
496
+ } else {
497
+ allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
498
+ }
462
499
 
463
500
  let startIdx;
464
501
  if (followTail) {
@@ -498,7 +535,7 @@ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, versio
498
535
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
499
536
  });
500
537
  const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
501
- const keysLine = truncate(color("Tab:focus • j/k:scroll • f:follow • r:raw • 1-9:jump", MOCHA.subtext), width - 4);
538
+ const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext), width - 4);
502
539
  const framed = box([chipsLine, keysLine], width);
503
540
  return [framed.top, ...framed.body, framed.bot];
504
541
  }
@@ -591,6 +628,7 @@ export function createLogDashboard(opts = {}) {
591
628
  let detailScrollOffset = 0;
592
629
  let followTail = false;
593
630
  let rawMode = false;
631
+ let focusTab = "log"; // "log" | "detail" | "files"
594
632
  let inputAttached = false;
595
633
  let rawModeEnabled = false;
596
634
 
@@ -683,6 +721,14 @@ export function createLogDashboard(opts = {}) {
683
721
  if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
684
722
  // r: raw mode 토글
685
723
  if (key === "r") { rawMode = !rawMode; render(); return; }
724
+ // l: 탭 전환 (Log → Detail → Files)
725
+ if (key === "l") {
726
+ const tabs = ["log", "detail", "files"];
727
+ focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
728
+ detailScrollOffset = 0;
729
+ render();
730
+ return;
731
+ }
686
732
  // 1-9: 워커 직접 선택
687
733
  if (/^[1-9]$/.test(key)) {
688
734
  const names = visibleWorkerNames();
@@ -748,6 +794,7 @@ export function createLogDashboard(opts = {}) {
748
794
  followTail,
749
795
  rawMode,
750
796
  focused: focus === "detail",
797
+ activeTab: focusTab,
751
798
  });
752
799
  return [...tier1, ...summaryBar, ...focusPane];
753
800
  }
@@ -788,6 +835,7 @@ export function createLogDashboard(opts = {}) {
788
835
  followTail,
789
836
  rawMode,
790
837
  focused: focus === "detail",
838
+ activeTab: focusTab,
791
839
  });
792
840
  }
793
841
  while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
@@ -928,6 +976,15 @@ export function createLogDashboard(opts = {}) {
928
976
  return focus === "detail";
929
977
  },
930
978
 
979
+ getFocusTab() {
980
+ return focusTab;
981
+ },
982
+
983
+ setFocusTab(tab) {
984
+ const valid = ["log", "detail", "files"];
985
+ if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
986
+ },
987
+
931
988
  getLayout() {
932
989
  return layoutHint;
933
990
  },
package/hud/utils.mjs CHANGED
@@ -6,9 +6,7 @@ import { dirname } from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  import {
8
8
  PERCENT_CELL_WIDTH, TIME_CELL_INNER_WIDTH, SV_CELL_WIDTH,
9
- FIVE_HOUR_MS, SEVEN_DAY_MS,
10
9
  } from "./constants.mjs";
11
- import { dim } from "./colors.mjs";
12
10
 
13
11
  export async function readStdinJson() {
14
12
  if (process.stdin.isTTY) return {};
@@ -58,16 +56,23 @@ export function stripAnsi(text) {
58
56
  return String(text).replace(/\x1b\[[0-9;]*m/g, "");
59
57
  }
60
58
 
61
- export function padAnsiRight(text, width) {
62
- const len = stripAnsi(text).length;
59
+ function getVisibleLength(text) {
60
+ return stripAnsi(text).length;
61
+ }
62
+
63
+ function padAnsi(text, width, align = "right") {
64
+ const len = getVisibleLength(text);
63
65
  if (len >= width) return text;
64
- return text + " ".repeat(width - len);
66
+ const padding = " ".repeat(width - len);
67
+ return align === "left" ? padding + text : text + padding;
68
+ }
69
+
70
+ export function padAnsiRight(text, width) {
71
+ return padAnsi(text, width, "right");
65
72
  }
66
73
 
67
74
  export function padAnsiLeft(text, width) {
68
- const len = stripAnsi(text).length;
69
- if (len >= width) return text;
70
- return " ".repeat(width - len) + text;
75
+ return padAnsi(text, width, "left");
71
76
  }
72
77
 
73
78
  export function fitText(text, width) {
@@ -164,13 +169,29 @@ export function advanceToNextCycle(epochMs, cycleMs) {
164
169
  return epochMs + Math.ceil(elapsed / cycleMs) * cycleMs;
165
170
  }
166
171
 
172
+ function parseResetDate(isoOrUnix) {
173
+ if (!isoOrUnix) return null;
174
+ const date = typeof isoOrUnix === "string"
175
+ ? new Date(isoOrUnix)
176
+ : new Date(isoOrUnix * 1000);
177
+ return Number.isNaN(date.getTime()) ? null : date;
178
+ }
179
+
180
+ function getResetTargetMs(isoOrUnix, cycleMs = 0) {
181
+ const date = parseResetDate(isoOrUnix);
182
+ if (!date) return null;
183
+ return advanceToNextCycle(date.getTime(), cycleMs);
184
+ }
185
+
186
+ function getRemainingResetMs(isoOrUnix, cycleMs = 0) {
187
+ const targetMs = getResetTargetMs(isoOrUnix, cycleMs);
188
+ if (targetMs == null) return null;
189
+ return targetMs - Date.now();
190
+ }
191
+
167
192
  export function formatResetRemaining(isoOrUnix, cycleMs = 0) {
168
- if (!isoOrUnix) return "";
169
- const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
170
- if (isNaN(d.getTime())) return "";
171
- const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
172
- const diffMs = targetMs - Date.now();
173
- if (diffMs <= 0) return "";
193
+ const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
194
+ if (diffMs == null || diffMs <= 0) return "";
174
195
  const totalMinutes = Math.floor(diffMs / 60000);
175
196
  const totalHours = Math.floor(totalMinutes / 60);
176
197
  const minutes = totalMinutes % 60;
@@ -178,18 +199,13 @@ export function formatResetRemaining(isoOrUnix, cycleMs = 0) {
178
199
  }
179
200
 
180
201
  export function isResetPast(isoOrUnix) {
181
- if (!isoOrUnix) return false;
182
- const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
183
- return !isNaN(d.getTime()) && d.getTime() <= Date.now();
202
+ const date = parseResetDate(isoOrUnix);
203
+ return date != null && date.getTime() <= Date.now();
184
204
  }
185
205
 
186
206
  export function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
187
- if (!isoOrUnix) return "";
188
- const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
189
- if (isNaN(d.getTime())) return "";
190
- const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
191
- const diffMs = targetMs - Date.now();
192
- if (diffMs <= 0) return "";
207
+ const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
208
+ if (diffMs == null || diffMs <= 0) return "";
193
209
  const totalMinutes = Math.floor(diffMs / 60000);
194
210
  const days = Math.floor(totalMinutes / (60 * 24));
195
211
  const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "8.4.0",
3
+ "version": "8.5.0",
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": {