triflux 7.0.4 → 7.0.5
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/tui-viewer.mjs +100 -84
- package/hub/team/tui.mjs +7 -1
- package/package.json +1 -1
package/hub/team/tui-viewer.mjs
CHANGED
|
@@ -1,87 +1,123 @@
|
|
|
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
|
-
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
|
-
}
|
|
50
|
-
}
|
|
22
|
+
const tui = createTui({ refreshMs: 0 });
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
tui.setStartTime(startTime);
|
|
51
25
|
|
|
52
|
-
|
|
53
|
-
|
|
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 []; }
|
|
38
|
+
}
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
48
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|
|
63
92
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
93
|
+
// 진행 중 — pane 캡처로 스냅샷
|
|
94
|
+
const snapshot = capturePane(pane.index, 3);
|
|
95
|
+
const lastLine = snapshot.split("\n").filter(l => l.trim()).pop() || "";
|
|
67
96
|
|
|
68
|
-
if (
|
|
97
|
+
if (!existing) {
|
|
98
|
+
workerState.set(paneName, { paneIdx: pane.index, done: false });
|
|
99
|
+
}
|
|
69
100
|
|
|
70
|
-
//
|
|
71
|
-
const
|
|
101
|
+
// result 파일이 있고 내용이 있으면 완료로 간주
|
|
102
|
+
const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
|
|
103
|
+
let resultSize = 0;
|
|
104
|
+
try { resultSize = statSync(resultFile).size; } catch {}
|
|
72
105
|
|
|
73
|
-
if (
|
|
74
|
-
//
|
|
75
|
-
|
|
106
|
+
if (resultSize > 10) {
|
|
107
|
+
// 완료 (fallback handoff)
|
|
108
|
+
workerState.set(paneName, { paneIdx: pane.index, done: true });
|
|
109
|
+
const fb = handoffResult || { handoff: { verdict: lastLine.slice(0, 60), confidence: "low" } };
|
|
76
110
|
tui.updateWorker(paneName, {
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
cli,
|
|
112
|
+
role: pane.title,
|
|
113
|
+
status: "completed",
|
|
114
|
+
handoff: fb.handoff,
|
|
79
115
|
elapsed: Math.round((Date.now() - startTime) / 1000),
|
|
80
116
|
});
|
|
81
117
|
} else {
|
|
82
|
-
// 아직 진행 중 — 마지막 줄을 스냅샷으로
|
|
83
|
-
const lastLine = content.trim().split("\n").filter(l => l.trim()).pop() || "";
|
|
84
118
|
tui.updateWorker(paneName, {
|
|
119
|
+
cli,
|
|
120
|
+
role: pane.title,
|
|
85
121
|
status: "running",
|
|
86
122
|
snapshot: lastLine.slice(0, 60),
|
|
87
123
|
elapsed: Math.round((Date.now() - startTime) / 1000),
|
|
@@ -92,39 +128,19 @@ function pollWorkers() {
|
|
|
92
128
|
tui.render();
|
|
93
129
|
}
|
|
94
130
|
|
|
95
|
-
// ── 메인 루프 ──
|
|
96
|
-
const startTime = Date.now();
|
|
97
|
-
tui.setStartTime(startTime);
|
|
98
|
-
|
|
99
131
|
// 초기 렌더
|
|
100
132
|
tui.render();
|
|
101
133
|
|
|
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);
|
|
134
|
+
const timer = setInterval(poll, POLL_MS);
|
|
135
|
+
|
|
136
|
+
// 모든 워커 완료 → 15초 유지 후 종료
|
|
137
|
+
const doneCheck = setInterval(() => {
|
|
138
|
+
if (workerState.size > 0 && [...workerState.values()].every(w => w.done)) {
|
|
139
|
+
tui.render();
|
|
140
|
+
clearInterval(doneCheck);
|
|
141
|
+
setTimeout(() => { tui.close(); clearInterval(timer); process.exit(0); }, 15000);
|
|
115
142
|
}
|
|
116
143
|
}, 2000);
|
|
117
144
|
|
|
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);
|
|
145
|
+
process.on("SIGINT", () => { tui.close(); clearInterval(timer); process.exit(0); });
|
|
146
|
+
setTimeout(() => { tui.close(); clearInterval(timer); process.exit(0); }, 10 * 60 * 1000);
|
package/hub/team/tui.mjs
CHANGED
|
@@ -9,7 +9,13 @@ import {
|
|
|
9
9
|
STATUS_ICON, CLI_ICON,
|
|
10
10
|
} from "./ansi.mjs";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// package.json에서 동적 로드 (실패 시 fallback)
|
|
13
|
+
let VERSION = "7.x";
|
|
14
|
+
try {
|
|
15
|
+
const { createRequire } = await import("node:module");
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
VERSION = require("../../package.json").version;
|
|
18
|
+
} catch { /* fallback */ }
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* TUI 대시보드 생성
|