triflux 7.0.4 → 7.0.6

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.
@@ -144,16 +144,8 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
144
144
  applyTrifluxTheme(sessionName);
145
145
  if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
146
146
 
147
- // v7.0: dashboard pane pane 0에 TUI 뷰어를 send-keys로 실행
148
- if (dashboard) {
149
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
150
- const resultDir = RESULT_DIR.replace(/\\/g, "/");
151
- const viewerCmd = `node ${viewerPath} --session ${sessionName} --result-dir ${resultDir}`;
152
- try {
153
- psmuxExec(["send-keys", "-t", `${sessionName}:0.0`, viewerCmd, "Enter"]);
154
- psmuxExec(["select-pane", "-t", `${sessionName}:0.0`, "-T", "▲ dashboard"]);
155
- } catch { /* 무시 */ }
156
- }
147
+ // dashboard: 워커 pane 먼저 생성한 후 pane 0에 대시보드를 실행
148
+ // (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
157
149
 
158
150
  dispatches = assignments.map((assignment, i) => {
159
151
  const paneName = `worker-${i + 1}`;
@@ -196,6 +188,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
196
188
  // 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
197
189
  try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
198
190
 
191
+ // v7.0.6: 워커 pane 생성 후 대시보드 시작 (워커가 먼저 존재해야 listPanes 감지 가능)
192
+ if (dashboard) {
193
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
194
+ const resultDir = RESULT_DIR.replace(/\\/g, "/");
195
+ const viewerCmd = `node ${viewerPath} --session ${sessionName}`;
196
+ try {
197
+ psmuxExec(["send-keys", "-t", `${sessionName}:0.0`, viewerCmd, "Enter"]);
198
+ psmuxExec(["select-pane", "-t", `${sessionName}:0.0`, "-T", "▲ dashboard"]);
199
+ } catch { /* 무시 */ }
200
+ }
201
+
199
202
  } else {
200
203
  // ─── 기존 모드: 모든 pane을 한 번에 생성 ───
201
204
  const paneCount = assignments.length + 1;
@@ -1,130 +1,143 @@
1
1
  #!/usr/bin/env node
2
- // hub/team/tui-viewer.mjs — psmux pane용 TUI 대시보드 뷰어
3
- // 독립 프로세스로 실행되어 headless 워커 상태를 실시간 렌더링한다.
4
- //
5
- // 실행: node hub/team/tui-viewer.mjs --session <name> [--result-dir <dir>]
6
- //
7
- // 데이터 소스: result 파일 (RESULT_DIR/{session}-worker-N.txt) polling
8
- // + psmux capture-pane (워커 진행 상황)
2
+ // hub/team/tui-viewer.mjs — psmux pane용 TUI 대시보드 뷰어 v2
3
+ // 같은 psmux 세션의 워커 pane을 capture-pane으로 실시간 모니터링
9
4
 
10
5
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
+ import { execFileSync } from "node:child_process";
11
7
  import { join } from "node:path";
12
8
  import { tmpdir } from "node:os";
13
9
  import { createTui } from "./tui.mjs";
14
10
  import { processHandoff } from "./handoff.mjs";
15
11
 
16
- // ── 인자 파싱 ──
17
12
  const args = process.argv.slice(2);
18
13
  const sessionIdx = args.indexOf("--session");
19
- const resultDirIdx = args.indexOf("--result-dir");
20
14
  const SESSION = sessionIdx >= 0 ? args[sessionIdx + 1] : null;
21
- const RESULT_DIR = resultDirIdx >= 0 ? args[resultDirIdx + 1] : join(tmpdir(), "tfx-headless");
15
+ const RESULT_DIR = join(tmpdir(), "tfx-headless");
22
16
 
23
17
  if (!SESSION) {
24
18
  process.stderr.write("Usage: node tui-viewer.mjs --session <name>\n");
25
19
  process.exit(1);
26
20
  }
27
21
 
28
- // ── TUI 초기화 ──
29
- const tui = createTui({ refreshMs: 0 }); // 수동 렌더 (폴링 주기에 맞춤)
30
-
31
- // ── 워커 상태 폴링 ──
32
- const POLL_MS = 800;
33
- const knownWorkers = new Map(); // paneName → { resultFile, lastSize, done }
34
-
35
- function discoverWorkers() {
36
- if (!existsSync(RESULT_DIR)) return;
37
- const prefix = `${SESSION}-worker-`;
38
- for (const f of readdirSync(RESULT_DIR)) {
39
- if (!f.startsWith(prefix) || !f.endsWith(".txt")) continue;
40
- const paneName = f.replace(`${SESSION}-`, "").replace(".txt", "");
41
- if (!knownWorkers.has(paneName)) {
42
- knownWorkers.set(paneName, {
43
- resultFile: join(RESULT_DIR, f),
44
- lastSize: 0,
45
- done: false,
46
- });
47
- tui.updateWorker(paneName, { cli: "codex", status: "running" });
48
- }
49
- }
22
+ const tui = createTui({ refreshMs: 0 });
23
+ const startTime = Date.now();
24
+ tui.setStartTime(startTime);
25
+
26
+ // ── psmux pane 목록 조회 ──
27
+ function listPanes() {
28
+ try {
29
+ const out = execFileSync("psmux", [
30
+ "list-panes", "-t", SESSION, "-F",
31
+ "#{pane_index}:#{pane_title}:#{pane_pid}",
32
+ ], { encoding: "utf8", timeout: 2000 });
33
+ return out.trim().split("\n").filter(Boolean).map(line => {
34
+ const [index, title, pid] = line.split(":");
35
+ return { index: parseInt(index, 10), title: title || "", pid };
36
+ });
37
+ } catch { return []; }
50
38
  }
51
39
 
52
- function pollWorkers() {
53
- discoverWorkers();
40
+ // ── pane 캡처 ──
41
+ function capturePane(paneIdx, lines = 5) {
42
+ try {
43
+ return execFileSync("psmux", [
44
+ "capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p",
45
+ ], { encoding: "utf8", timeout: 2000 }).trim().split("\n").slice(-lines).join("\n");
46
+ } catch { return ""; }
47
+ }
54
48
 
55
- for (const [paneName, state] of knownWorkers) {
56
- if (state.done) continue;
49
+ // ── result 파일에서 handoff 파싱 ──
50
+ function checkResultFile(paneName) {
51
+ const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
52
+ if (!existsSync(resultFile)) return null;
53
+ try {
54
+ const content = readFileSync(resultFile, "utf8");
55
+ if (content.trim().length === 0) return null;
56
+ return processHandoff(content, { exitCode: 0, resultFile });
57
+ } catch { return null; }
58
+ }
57
59
 
58
- let size = 0;
59
- try { size = statSync(state.resultFile).size; } catch { continue; }
60
+ // ── 메인 폴링 ──
61
+ const POLL_MS = 1000;
62
+ const workerState = new Map(); // paneName → { paneIdx, done }
63
+
64
+ function poll() {
65
+ const panes = listPanes();
66
+ // pane 0 = 대시보드 (자기 자신), pane 1+ = 워커
67
+ for (const pane of panes) {
68
+ if (pane.index === 0) continue; // 자기 자신 건너뜀
69
+ const paneName = `worker-${pane.index}`;
70
+ const existing = workerState.get(paneName);
71
+
72
+ if (existing?.done) continue;
73
+
74
+ // CLI 타입 추정 (pane title에서)
75
+ let cli = "codex";
76
+ if (pane.title.includes("gemini") || pane.title.includes("🔵")) cli = "gemini";
77
+ else if (pane.title.includes("claude") || pane.title.includes("🟠")) cli = "claude";
78
+
79
+ // result 파일 확인 (완료 여부)
80
+ const handoffResult = checkResultFile(paneName);
81
+ if (handoffResult && !handoffResult.fallback) {
82
+ workerState.set(paneName, { paneIdx: pane.index, done: true });
83
+ tui.updateWorker(paneName, {
84
+ cli,
85
+ role: pane.title,
86
+ status: handoffResult.handoff.status === "failed" ? "failed" : "completed",
87
+ handoff: handoffResult.handoff,
88
+ elapsed: Math.round((Date.now() - startTime) / 1000),
89
+ });
90
+ continue;
91
+ }
60
92
 
61
- if (size === state.lastSize) continue;
62
- state.lastSize = size;
93
+ // 진행 pane 캡처로 스냅샷
94
+ const snapshot = capturePane(pane.index, 5);
95
+ const lines = snapshot.split("\n").filter(l => l.trim());
96
+ const lastLine = lines.pop() || "";
63
97
 
64
- // 결과 파일 읽기
65
- let content = "";
66
- try { content = readFileSync(state.resultFile, "utf8"); } catch { continue; }
98
+ if (!existing) {
99
+ workerState.set(paneName, { paneIdx: pane.index, done: false });
100
+ }
67
101
 
68
- if (content.trim().length === 0) continue;
102
+ // 완료 감지: (1) result 파일 존재, (2) 프롬프트 복귀, (3) "tokens used" 텍스트
103
+ const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
104
+ let resultSize = 0;
105
+ try { resultSize = statSync(resultFile).size; } catch {}
69
106
 
70
- // handoff 파싱 시도
71
- const result = processHandoff(content, { exitCode: 0, resultFile: state.resultFile });
107
+ const shellReturned = /^(PS\s|>|\$)\s*/.test(lastLine) && lines.length > 2;
108
+ const tokensLine = lines.find(l => /tokens?\s+used/i.test(l));
72
109
 
73
- if (result.handoff && !result.fallback) {
74
- // handoff 블록 발견 완료
75
- state.done = true;
76
- tui.updateWorker(paneName, {
77
- status: result.handoff.status === "failed" ? "failed" : "completed",
78
- handoff: result.handoff,
79
- elapsed: Math.round((Date.now() - startTime) / 1000),
80
- });
110
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
111
+ if (resultSize > 10 || shellReturned) {
112
+ workerState.set(paneName, { paneIdx: pane.index, done: true });
113
+ const meaningful = lines.filter(l => !(/^(PS\s|>|\$)/.test(l)) && !(/tokens?\s+used/i.test(l)));
114
+ const verdict = meaningful.pop()?.slice(0, 80) || "completed";
115
+ tui.updateWorker(paneName, { cli, role: pane.title, status: "completed", handoff: { verdict, confidence: tokensLine ? "high" : "low" }, elapsed });
81
116
  } else {
82
- // 아직 진행 마지막 줄을 스냅샷으로
83
- const lastLine = content.trim().split("\n").filter(l => l.trim()).pop() || "";
84
- tui.updateWorker(paneName, {
85
- status: "running",
86
- snapshot: lastLine.slice(0, 60),
87
- elapsed: Math.round((Date.now() - startTime) / 1000),
88
- });
117
+ const meaningful = lines.filter(l => !(/^(PS\s|>|\$)/.test(l)));
118
+ const snap = meaningful.pop()?.slice(0, 60) || lastLine.slice(0, 60);
119
+ tui.updateWorker(paneName, { cli, role: pane.title, status: "running", snapshot: snap, elapsed });
89
120
  }
121
+ // debug: stderr에 상태 출력 (캡처 영향 없음)
122
+ process.stderr.write(`[dbg] ${paneName} cli=${cli} status=${shellReturned?"done":"run"} lines=${lines.length}\n`);
90
123
  }
91
124
 
92
125
  tui.render();
93
126
  }
94
127
 
95
- // ── 메인 루프 ──
96
- const startTime = Date.now();
97
- tui.setStartTime(startTime);
98
-
99
128
  // 초기 렌더
100
129
  tui.render();
101
130
 
102
- const timer = setInterval(pollWorkers, POLL_MS);
103
-
104
- // 모든 워커 완료 감지 10초 후 자동 종료
105
- let doneCheckTimer = setInterval(() => {
106
- if (knownWorkers.size > 0 && [...knownWorkers.values()].every(w => w.done)) {
107
- tui.render(); // 최종 렌더
108
- setTimeout(() => {
109
- tui.close();
110
- clearInterval(timer);
111
- clearInterval(doneCheckTimer);
112
- process.exit(0);
113
- }, 10000); // 10초 유지 후 종료
114
- clearInterval(doneCheckTimer);
131
+ const timer = setInterval(poll, POLL_MS);
132
+
133
+ // 모든 워커 완료 → 15유지 후 종료
134
+ const doneCheck = setInterval(() => {
135
+ if (workerState.size > 0 && [...workerState.values()].every(w => w.done)) {
136
+ tui.render();
137
+ clearInterval(doneCheck);
138
+ setTimeout(() => { tui.close(); clearInterval(timer); process.exit(0); }, 15000);
115
139
  }
116
140
  }, 2000);
117
141
 
118
- // Ctrl+C 정리
119
- process.on("SIGINT", () => {
120
- tui.close();
121
- clearInterval(timer);
122
- process.exit(0);
123
- });
124
-
125
- // 5분 안전 타임아웃
126
- setTimeout(() => {
127
- tui.close();
128
- clearInterval(timer);
129
- process.exit(0);
130
- }, 5 * 60 * 1000);
142
+ process.on("SIGINT", () => { tui.close(); clearInterval(timer); process.exit(0); });
143
+ setTimeout(() => { tui.close(); clearInterval(timer); process.exit(0); }, 10 * 60 * 1000);
package/hub/team/tui.mjs CHANGED
@@ -1,29 +1,27 @@
1
- // hub/team/tui.mjs — Zero-dependency TUI 대시보드 (v7.0)
2
- // handoff 데이터 기반 워커 상태 실시간 ANSI 렌더링
3
- // 외부 의존성 0 — Node.js 내장 + ansi.mjs만 사용
1
+ // hub/team/tui.mjs — Append-only 로그 대시보드 (v8)
2
+ // ANSI 색상은 유지하되 커서 이동/화면 덮어쓰기는 사용하지 않는다.
4
3
 
5
- import {
6
- cursorHome, clearLine, cursorHide, cursorShow,
7
- RESET, BOLD, DIM, FG, BG,
8
- color, dim, truncate, stripAnsi,
9
- STATUS_ICON, CLI_ICON,
10
- } from "./ansi.mjs";
4
+ import { RESET, FG, color, dim, STATUS_ICON } from "./ansi.mjs";
11
5
 
12
- const VERSION = "7.0.0";
6
+ // package.json에서 동적 로드 (실패 시 fallback)
7
+ let VERSION = "7.x";
8
+ try {
9
+ const { createRequire } = await import("node:module");
10
+ const require = createRequire(import.meta.url);
11
+ VERSION = require("../../package.json").version;
12
+ } catch { /* fallback */ }
13
13
 
14
14
  /**
15
- * TUI 대시보드 생성
15
+ * 로그 스트림 대시보드 생성 (append-only)
16
16
  * @param {object} [opts]
17
17
  * @param {NodeJS.WriteStream} [opts.stream=process.stdout]
18
- * @param {number} [opts.refreshMs=1000] — 자동 갱신 주기 (0=수동만)
19
- * @param {number} [opts.width=0] — 0이면 터미널 폭 자동
20
- * @returns {TuiHandle}
18
+ * @param {number} [opts.refreshMs=1000] — 자동 렌더 주기 (0=수동만)
19
+ * @returns {LogDashboardHandle}
21
20
  */
22
- export function createTui(opts = {}) {
21
+ export function createLogDashboard(opts = {}) {
23
22
  const {
24
23
  stream = process.stdout,
25
24
  refreshMs = 1000,
26
- width: fixedWidth = 0,
27
25
  } = opts;
28
26
 
29
27
  const workers = new Map();
@@ -32,105 +30,88 @@ export function createTui(opts = {}) {
32
30
  let timer = null;
33
31
  let closed = false;
34
32
  let frameCount = 0;
33
+ const lastLineByWorker = new Map();
34
+ let lastPipelineLine = "";
35
35
 
36
- function w() { return fixedWidth > 0 ? fixedWidth : (stream.columns || 80); }
37
- function out(text) { if (!closed) stream.write(text); }
36
+ function out(text) { if (!closed) stream.write(`${text}\n`); }
38
37
 
39
- // ── 렌더 함수 ──
40
-
41
- function header() {
42
- const elapsed = Math.round((Date.now() - startedAt) / 1000);
43
- const vals = [...workers.values()];
44
- const done = vals.filter((s) => s.status === "completed").length;
45
- const fail = vals.filter((s) => s.status === "failed").length;
46
- const run = vals.filter((s) => s.status === "running").length;
47
-
48
- const left = ` ${color("▲ triflux", FG.triflux)} ${dim(`v${VERSION}`)}`;
49
- const right = `${color(`${done}✓`, FG.green)} ${color(`${fail}✗`, FG.red)} ${run}⏳ ${dim(`${vals.length}총`)} ${dim(`${elapsed}s`)}`;
50
- const gap = Math.max(1, w() - stripAnsi(left).length - stripAnsi(right).length - 1);
51
- return `${BG.header}${left}${" ".repeat(gap)}${right}${RESET}`;
38
+ function elapsedLabel(st) {
39
+ const sec = Number.isFinite(st.elapsed)
40
+ ? Math.max(0, Math.round(st.elapsed))
41
+ : Math.max(0, Math.round((Date.now() - startedAt) / 1000));
42
+ return dim(`[${sec}s]`);
52
43
  }
53
44
 
54
- function workerCard(name, st) {
55
- const icon = CLI_ICON[st.cli] || dim("");
56
- const sIcon = STATUS_ICON[st.status] || STATUS_ICON.pending;
57
- const el = st.elapsed != null ? `${st.elapsed}s` : "";
58
- const role = st.role ? dim(`(${st.role})`) : "";
59
- const conf = st.handoff?.confidence ? dim(` ${st.handoff.confidence}`) : "";
60
-
61
- const lines = [];
62
- lines.push(` ${icon} ${st.cli} ${role} ${sIcon} ${el}${conf}`);
63
-
64
- if (st.handoff?.verdict) {
65
- lines.push(` ${truncate(st.handoff.verdict, w() - 8)}`);
66
- }
45
+ function oneLine(text, fallback = "n/a") {
46
+ const normalized = String(text || "").replace(/\s+/g, " ").trim();
47
+ if (!normalized) return fallback;
48
+ return normalized.length > 160 ? `${normalized.slice(0, 157)}...` : normalized;
49
+ }
67
50
 
68
- if (st.handoff?.files_changed?.length) {
69
- const f = st.handoff.files_changed.slice(0, 3).join(", ");
70
- const more = st.handoff.files_changed.length > 3 ? ` +${st.handoff.files_changed.length - 3}` : "";
71
- lines.push(` ${dim(`files: ${f}${more}`)}`);
72
- }
51
+ function cliColor(cli) {
52
+ if (cli === "gemini") return FG.gemini;
53
+ if (cli === "claude") return FG.claude;
54
+ if (cli === "codex") return FG.codex;
55
+ return FG.white;
56
+ }
73
57
 
74
- if (st.status === "failed" && st.handoff) {
75
- const p = [];
76
- if (st.handoff.risk) p.push(`risk:${st.handoff.risk}`);
77
- if (st.handoff.lead_action) p.push(`action:${st.handoff.lead_action}`);
78
- if (p.length) lines.push(` ${color(p.join(" "), FG.red)}`);
79
- }
58
+ function statusLabel(status) {
59
+ if (status === "completed") return color("completed", FG.green);
60
+ if (status === "failed") return color("failed", FG.red);
61
+ if (status === "running") return color("running", FG.blue);
62
+ return dim(status || "pending");
63
+ }
80
64
 
81
- if (st.status === "running" && st.snapshot) {
82
- lines.push(` ${dim(truncate(st.snapshot, w() - 8))}`);
65
+ function messageLabel(st) {
66
+ if (st.handoff?.verdict) return oneLine(st.handoff.verdict, "completed");
67
+ if (st.snapshot) return oneLine(st.snapshot, st.status || "running");
68
+ if (st.status === "failed" && st.handoff?.lead_action) {
69
+ return oneLine(`action=${st.handoff.lead_action}`, "failed");
83
70
  }
84
-
85
- return lines;
71
+ return st.status || "pending";
86
72
  }
87
73
 
88
- function pipelineBar() {
89
- const phases = ["plan", "prd", "confidence", "exec", "deslop", "verify", "selfcheck", "complete"];
90
- const cur = pipeline.phase;
91
- const parts = phases.map((p) => {
92
- const ci = phases.indexOf(cur);
93
- const pi = phases.indexOf(p);
94
- if (p === cur) return `${FG.accent}${BOLD}[${p}]${RESET}`;
95
- if (pi < ci) return `${FG.green}${p}${RESET}`;
96
- return dim(p);
97
- });
98
- return ` ${parts.join(dim("→"))}`;
74
+ function workerLine(name, st) {
75
+ const status = st.status || "pending";
76
+ const icon = STATUS_ICON[status] || STATUS_ICON.pending;
77
+ const cli = st.cli || "codex";
78
+ const cliLabel = `${cliColor(cli)}${cli}${RESET}`;
79
+ const workerLabel = color(name, FG.triflux);
80
+ const statusText = statusLabel(status);
81
+ const message = messageLabel(st);
82
+ return `${elapsedLabel(st)} ${icon} ${workerLabel} (${cliLabel}) ${statusText} ${dim("—")} ${message}`;
99
83
  }
100
84
 
101
- function footer() {
102
- const vals = [...workers.values()];
103
- const done = vals.filter((s) => s.status === "completed").length;
104
- const tokenSaved = done * 850;
105
- return `${BG.header} ${done}✓ 완료 │ ~${tokenSaved} 토큰 절감 ${RESET}`;
85
+ function pipelineLine() {
86
+ const phase = pipeline.phase || "exec";
87
+ const fix = Number.isFinite(pipeline.fix_attempt) ? pipeline.fix_attempt : 0;
88
+ return `${dim(`[${Math.max(0, Math.round((Date.now() - startedAt) / 1000))}s]`)} ${color("◇", FG.accent)} ${color("pipeline", FG.accent)} ${dim("(system)")} ${dim("state")} ${dim("—")} ${oneLine(`${phase} (fix=${fix})`, phase)}`;
106
89
  }
107
90
 
91
+ // 현재 상태와 마지막으로 출력한 라인을 비교해 변경분만 append
108
92
  function render() {
109
93
  if (closed) return;
110
94
  frameCount++;
111
- const sep = dim("─".repeat(w()));
112
- const buf = [cursorHome];
113
-
114
- buf.push(header() + clearLine + "\n");
115
- buf.push(sep + "\n");
116
-
117
- if (workers.size === 0) {
118
- buf.push(dim(" (워커 대기 중...)\n"));
119
- } else {
120
- for (const [name, st] of workers) {
121
- for (const l of workerCard(name, st)) buf.push(l + clearLine + "\n");
122
- buf.push("\n");
95
+
96
+ const names = [...workers.keys()].sort();
97
+ for (const name of names) {
98
+ const st = workers.get(name);
99
+ const line = workerLine(name, st);
100
+ if (line !== lastLineByWorker.get(name)) {
101
+ lastLineByWorker.set(name, line);
102
+ out(line);
123
103
  }
124
104
  }
125
105
 
126
- buf.push(sep + "\n");
127
- buf.push(pipelineBar() + clearLine + "\n");
128
- buf.push(footer() + clearLine + "\n");
129
-
130
- out(buf.join(""));
106
+ const nextPipelineLine = pipelineLine();
107
+ if (nextPipelineLine !== lastPipelineLine) {
108
+ lastPipelineLine = nextPipelineLine;
109
+ out(nextPipelineLine);
110
+ }
131
111
  }
132
112
 
133
- // 자동 갱신
113
+ out(`${dim("[0s]")} ${color("▲ triflux", FG.triflux)} ${dim(`v${VERSION}`)} ${dim("log-dashboard started")}`);
114
+
134
115
  if (refreshMs > 0) {
135
116
  timer = setInterval(render, refreshMs);
136
117
  if (timer.unref) timer.unref();
@@ -150,6 +131,7 @@ export function createTui(opts = {}) {
150
131
  if (closed) return;
151
132
  closed = true;
152
133
  if (timer) clearInterval(timer);
134
+ out(`${dim(`[${Math.max(0, Math.round((Date.now() - startedAt) / 1000))}s]`)} ${dim("log-dashboard stopped")}`);
153
135
  },
154
136
  };
155
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "7.0.4",
3
+ "version": "7.0.6",
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": {
@@ -135,6 +135,11 @@ async function main() {
135
135
  process.exit(0);
136
136
  }
137
137
 
138
+ // psmux send-keys / capture-pane은 통과 (pane 내 간접 실행)
139
+ if (/psmux\s+(send-keys|capture-pane|list-panes|split-window|select-pane)/.test(cmd)) {
140
+ process.exit(0);
141
+ }
142
+
138
143
  // codex/gemini 직접 CLI 호출 → deny
139
144
  if (/\bcodex\s+exec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
140
145
  deny(