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.
- package/hub/team/headless.mjs +13 -10
- package/hub/team/tui-viewer.mjs +104 -91
- package/hub/team/tui.mjs +75 -93
- package/package.json +1 -1
- package/scripts/headless-guard.mjs +5 -0
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
//
|
|
148
|
-
|
|
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;
|
package/hub/team/tui-viewer.mjs
CHANGED
|
@@ -1,130 +1,143 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hub/team/tui-viewer.mjs — psmux pane용 TUI 대시보드 뷰어
|
|
3
|
-
//
|
|
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 =
|
|
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
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
98
|
+
if (!existing) {
|
|
99
|
+
workerState.set(paneName, { paneIdx: pane.index, done: false });
|
|
100
|
+
}
|
|
67
101
|
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
const
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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(
|
|
103
|
-
|
|
104
|
-
// 모든 워커 완료
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
tui.render();
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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 —
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
15
|
+
* 로그 스트림 대시보드 생성 (append-only)
|
|
16
16
|
* @param {object} [opts]
|
|
17
17
|
* @param {NodeJS.WriteStream} [opts.stream=process.stdout]
|
|
18
|
-
* @param {number} [opts.refreshMs=1000] — 자동
|
|
19
|
-
* @
|
|
20
|
-
* @returns {TuiHandle}
|
|
18
|
+
* @param {number} [opts.refreshMs=1000] — 자동 렌더 주기 (0=수동만)
|
|
19
|
+
* @returns {LogDashboardHandle}
|
|
21
20
|
*/
|
|
22
|
-
export function
|
|
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
|
|
37
|
-
function out(text) { if (!closed) stream.write(text); }
|
|
36
|
+
function out(text) { if (!closed) stream.write(`${text}\n`); }
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
@@ -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(
|