triflux 8.4.1 → 8.5.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.
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 {}
@@ -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) {
@@ -388,9 +388,12 @@ function ingest() {
388
388
  }
389
389
 
390
390
  // ── tui.updateWorker 래퍼 — raw internal data 누출 방지 ──
391
- function pushToTui(paneName, cli, role, update) {
391
+ function pushToTui(paneName, cli, paneTitle, update) {
392
392
  // _leadAction은 tui에 노출하지 않음 (내부용)
393
393
  const { _leadAction: _ignored, ...safeUpdate } = update;
394
+ // pane title에서 실제 역할만 추출: "⚪ codex (executor)" → "executor"
395
+ const roleMatch = paneTitle.match(/\(([^)]+)\)$/);
396
+ const role = roleMatch ? roleMatch[1] : "";
394
397
  tui.updateWorker(paneName, { cli, role, ...safeUpdate });
395
398
  }
396
399
 
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,21 @@ 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
+ // CLI indicator emojis 제거
232
+ r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/g, "").trim();
233
+ // 빈 괄호 제거 + 중첩 괄호 정리
234
+ r = r.replace(/\(\s*\)/g, "").trim();
235
+ r = r.replace(/^\(([^()]+)\)$/, "$1").trim();
236
+ r = r.replace(/^\s*[•·\-]\s*/, "").trim();
237
+ return r;
238
+ }
239
+
218
240
  // ── 텍스트 래핑 ──────────────────────────────────────────────────────────
219
241
  function wrapLine(text, width) {
220
242
  const limit = Math.max(8, width);
@@ -310,7 +332,7 @@ function buildTier1(names, workers, pipeline, elapsed, width, version) {
310
332
  const phase = pipeline.phase || "exec";
311
333
  const row1 = truncate(
312
334
  `${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)}`,
335
+ `${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
336
  width,
315
337
  );
316
338
  return [row1];
@@ -357,9 +379,10 @@ function buildWorkerRail(name, st, opts = {}) {
357
379
 
358
380
  // Tier2 행 1: 이름 + CLI + role
359
381
  const selMark = selected ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux)) : " ";
360
- const hb = heartbeat(status);
382
+ const hb = heartbeat(status, status === "running" ? currentShimmer() : 0);
383
+ const displayRole = dedupeRole(role, name, cli);
361
384
  const title = truncate(
362
- `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${role ? ` ${color(`(${role})`, MOCHA.overlay)}` : ""}`,
385
+ `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
363
386
  innerWidth,
364
387
  );
365
388
 
@@ -442,9 +465,10 @@ function buildFocusPane(name, st, opts = {}) {
442
465
  const status = runtimeStatus(st);
443
466
 
444
467
  // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
445
- const tabLog = `${MOCHA.blue}${bold("[Log]")}`;
446
- const tabDetail = color("[Detail]", MOCHA.overlay);
447
- const tabFiles = color(`[Files ${files.length}]`, MOCHA.overlay);
468
+ const activeTab = opts.activeTab || "log";
469
+ const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
470
+ const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
471
+ const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
448
472
  const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
449
473
 
450
474
  const stickyLines = [
@@ -457,7 +481,26 @@ function buildFocusPane(name, st, opts = {}) {
457
481
 
458
482
  // 본문 스크롤 영역
459
483
  const bodyAvail = Math.max(0, height - stickyLines.length - 3); // top+bot border + scrollInfo
460
- const allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
484
+
485
+ let allBodyLines;
486
+ if (activeTab === "detail") {
487
+ const summaryLines = [];
488
+ for (const key of SUMMARY_KEYS) {
489
+ const value = st.handoff?.[key];
490
+ if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
491
+ else if (value) summaryLines.push(`${key}: ${value}`);
492
+ }
493
+ allBodyLines = summaryLines.length > 0
494
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
495
+ : [dim("no structured data")];
496
+ } else if (activeTab === "files") {
497
+ const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
498
+ allBodyLines = filesList.length > 0
499
+ ? filesList.map((f, i) => `${i + 1}. ${f}`)
500
+ : [dim("no files changed")];
501
+ } else {
502
+ allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
503
+ }
461
504
 
462
505
  let startIdx;
463
506
  if (followTail) {
@@ -497,7 +540,7 @@ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, versio
497
540
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
498
541
  });
499
542
  const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
500
- const keysLine = truncate(color("Tab:focus • j/k:scroll • f:follow • r:raw • 1-9:jump", MOCHA.subtext), width - 4);
543
+ const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext), width - 4);
501
544
  const framed = box([chipsLine, keysLine], width);
502
545
  return [framed.top, ...framed.body, framed.bot];
503
546
  }
@@ -590,6 +633,7 @@ export function createLogDashboard(opts = {}) {
590
633
  let detailScrollOffset = 0;
591
634
  let followTail = false;
592
635
  let rawMode = false;
636
+ let focusTab = "log"; // "log" | "detail" | "files"
593
637
  let inputAttached = false;
594
638
  let rawModeEnabled = false;
595
639
 
@@ -682,6 +726,14 @@ export function createLogDashboard(opts = {}) {
682
726
  if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
683
727
  // r: raw mode 토글
684
728
  if (key === "r") { rawMode = !rawMode; render(); return; }
729
+ // l: 탭 전환 (Log → Detail → Files)
730
+ if (key === "l") {
731
+ const tabs = ["log", "detail", "files"];
732
+ focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
733
+ detailScrollOffset = 0;
734
+ render();
735
+ return;
736
+ }
685
737
  // 1-9: 워커 직접 선택
686
738
  if (/^[1-9]$/.test(key)) {
687
739
  const names = visibleWorkerNames();
@@ -747,6 +799,7 @@ export function createLogDashboard(opts = {}) {
747
799
  followTail,
748
800
  rawMode,
749
801
  focused: focus === "detail",
802
+ activeTab: focusTab,
750
803
  });
751
804
  return [...tier1, ...summaryBar, ...focusPane];
752
805
  }
@@ -765,7 +818,7 @@ export function createLogDashboard(opts = {}) {
765
818
  const railLines = [];
766
819
  for (const name of names) {
767
820
  const card = buildWorkerRail(name, workers.get(name), {
768
- width: railWidth - 2, // box 테두리 감안
821
+ width: railWidth,
769
822
  selected: name === selectedWorker,
770
823
  focused: focus === "rail" && name === selectedWorker,
771
824
  rawMode,
@@ -787,6 +840,7 @@ export function createLogDashboard(opts = {}) {
787
840
  followTail,
788
841
  rawMode,
789
842
  focused: focus === "detail",
843
+ activeTab: focusTab,
790
844
  });
791
845
  }
792
846
  while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
@@ -927,6 +981,15 @@ export function createLogDashboard(opts = {}) {
927
981
  return focus === "detail";
928
982
  },
929
983
 
984
+ getFocusTab() {
985
+ return focusTab;
986
+ },
987
+
988
+ setFocusTab(tab) {
989
+ const valid = ["log", "detail", "files"];
990
+ if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
991
+ },
992
+
930
993
  getLayout() {
931
994
  return layoutHint;
932
995
  },
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.1",
3
+ "version": "8.5.1",
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": {