triflux 8.0.0 → 8.2.2
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/bin/triflux.mjs +40 -1
- package/hub/lib/process-utils.mjs +123 -0
- package/hub/server.mjs +48 -1
- package/hub/team/ansi.mjs +161 -19
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/commands/start/index.mjs +3 -2
- package/hub/team/cli/commands/start/parse-args.mjs +9 -0
- package/hub/team/cli/commands/start/start-headless.mjs +6 -3
- package/hub/team/cli/help.mjs +2 -0
- package/hub/team/dashboard-layout.mjs +31 -0
- package/hub/team/headless.mjs +146 -33
- package/hub/team/psmux.mjs +174 -7
- package/hub/team/tui-viewer.mjs +354 -90
- package/hub/team/tui.mjs +856 -67
- package/package.json +1 -1
- package/scripts/remote-spawn.mjs +92 -12
- package/scripts/tfx-route.sh +17 -8
package/hub/team/tui-viewer.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hub/team/tui-viewer.mjs —
|
|
3
|
-
//
|
|
2
|
+
// hub/team/tui-viewer.mjs — worker state aggregator v5
|
|
3
|
+
// psmux capture-pane 기반 워커 상태 집계 + TUI 렌더링
|
|
4
|
+
// data ingest: ~2Hz (500ms), render: 8-12FPS (별도 루프)
|
|
4
5
|
|
|
5
6
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
@@ -8,173 +9,436 @@ import { join } from "node:path";
|
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
10
|
import { createLogDashboard } from "./tui.mjs";
|
|
10
11
|
import { processHandoff } from "./handoff.mjs";
|
|
12
|
+
import { statusBadge } from "./ansi.mjs";
|
|
11
13
|
|
|
14
|
+
|
|
15
|
+
// ── CLI 인자 파싱 ──
|
|
12
16
|
const args = process.argv.slice(2);
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
function argVal(flag) {
|
|
18
|
+
const idx = args.indexOf(flag);
|
|
19
|
+
return idx >= 0 ? args[idx + 1] : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SESSION = argVal("--session");
|
|
23
|
+
const RESULT_DIR = argVal("--result-dir") ?? join(tmpdir(), "tfx-headless");
|
|
24
|
+
const LAYOUT = argVal("--layout") ?? "single";
|
|
19
25
|
|
|
20
26
|
if (!SESSION) {
|
|
21
|
-
process.stderr.write(
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
"Usage: node tui-viewer.mjs --session <name> [--result-dir <dir>] [--layout <name>]\n",
|
|
29
|
+
);
|
|
22
30
|
process.exit(1);
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
// ── psmux 존재 확인 ──
|
|
26
33
|
try {
|
|
27
34
|
execFileSync("psmux", ["--version"], { encoding: "utf8", timeout: 2000 });
|
|
28
35
|
} catch {
|
|
29
|
-
process.stderr.write(
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
"ERROR: psmux not found or not executable. Install psmux before running tui-viewer.\n",
|
|
38
|
+
);
|
|
30
39
|
process.exit(1);
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
// ── 메모리 보호 상수 ──
|
|
43
|
+
const MAX_BODY_BYTES = 10240;
|
|
44
|
+
|
|
45
|
+
// ── TUI 초기화 ──
|
|
46
|
+
// WT pane에서 spawn 시 process.stdout.isTTY=false일 수 있음
|
|
47
|
+
// forceTTY 시 alternate screen이 WT pane에서 렌더링 안 되는 문제 → append-only 유지
|
|
48
|
+
const tui = createLogDashboard({
|
|
49
|
+
refreshMs: 0, // render 루프를 직접 제어
|
|
50
|
+
stream: process.stdout,
|
|
51
|
+
input: process.stdin,
|
|
52
|
+
columns: process.stdout.columns || parseInt(process.env.COLUMNS, 10) || 120,
|
|
53
|
+
layout: LAYOUT,
|
|
54
|
+
});
|
|
34
55
|
const startTime = Date.now();
|
|
35
56
|
tui.setStartTime(startTime);
|
|
36
57
|
|
|
37
|
-
// ──
|
|
58
|
+
// ── 내부 raw data 누출 방지 패턴 ──
|
|
59
|
+
const INTERNAL_PATTERNS = [
|
|
60
|
+
/\$trifluxExit/,
|
|
61
|
+
/\.err\b/,
|
|
62
|
+
/completion[-_]token/i,
|
|
63
|
+
/^---\s*HANDOFF\s*---$/i,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function isInternalLine(line) {
|
|
67
|
+
return INTERNAL_PATTERNS.some((re) => re.test(line));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 코드블록 필터링 → filtered_body 생성 ──
|
|
71
|
+
function filterCodeBlocks(text) {
|
|
72
|
+
return String(text || "")
|
|
73
|
+
.replace(/\r/g, "")
|
|
74
|
+
.replace(/```[\s\S]*?(?:```|$)/gm, "\n")
|
|
75
|
+
.replace(/^\s*```.*$/gm, "")
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toFilteredBody(text) {
|
|
80
|
+
return filterCodeBlocks(text)
|
|
81
|
+
.split("\n")
|
|
82
|
+
.map((l) => l.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.filter((l) => !isInternalLine(l))
|
|
85
|
+
.filter((l) => !/^(PS\s|>|\$)\s*/.test(l))
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toLines(text) {
|
|
90
|
+
return toFilteredBody(text).split("\n").filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 토큰 라벨 추출 ──
|
|
94
|
+
function extractTokenLabel(text) {
|
|
95
|
+
const m = String(text || "").match(
|
|
96
|
+
/(\d+(?:[.,]\d+)?\s*[kKmM]?)(?=\s*tokens?\s+used|\s*tokens?\b)/i,
|
|
97
|
+
);
|
|
98
|
+
return m ? m[1].replace(/\s+/g, "").toLowerCase() : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── findings 추출 ──
|
|
102
|
+
function extractFindings(lines, verdict = "") {
|
|
103
|
+
return lines
|
|
104
|
+
.map((l) => l.replace(/^verdict\s*:\s*/i, "").trim())
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.filter(
|
|
107
|
+
(l) =>
|
|
108
|
+
!/^(status|lead_action|confidence|files_changed|detail|risk|error_stage|retryable|partial_output)\s*:/i.test(
|
|
109
|
+
l,
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
.filter((l) => l !== verdict)
|
|
113
|
+
.slice(-2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Phase 가중치 진행률 (Plan=10%, Research=30%, Exec=50%, Verify=10%) ──
|
|
117
|
+
const PHASE_WEIGHTS = {
|
|
118
|
+
plan: 0.10,
|
|
119
|
+
research:0.40, // plan + research
|
|
120
|
+
exec: 0.90, // plan + research + exec
|
|
121
|
+
verify: 1.00,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function estimateProgress(lines, context = {}) {
|
|
125
|
+
if (context.done) return 1;
|
|
126
|
+
|
|
127
|
+
const text = lines.join("\n").toLowerCase();
|
|
128
|
+
let phase = "plan";
|
|
129
|
+
|
|
130
|
+
if (/verify|assert|test|check|confirm/.test(text)) phase = "verify";
|
|
131
|
+
else if (/edit|patch|implement|write|update|fix|refactor/.test(text)) phase = "exec";
|
|
132
|
+
else if (/search|read|inspect|analy|review|research/.test(text)) phase = "research";
|
|
133
|
+
|
|
134
|
+
let ratio = PHASE_WEIGHTS[phase];
|
|
135
|
+
|
|
136
|
+
// 라인 수 기반 보정
|
|
137
|
+
if (lines.length < 2) ratio = Math.min(ratio, 0.12);
|
|
138
|
+
|
|
139
|
+
// 토큰 발생 시 최소 88%
|
|
140
|
+
if (context.tokens) ratio = Math.max(ratio, 0.88);
|
|
141
|
+
|
|
142
|
+
// 결과 파일 존재 or 쉘 복귀 → 완료로 간주
|
|
143
|
+
if (context.resultSize > 10 || context.shellReturned) return 1;
|
|
144
|
+
|
|
145
|
+
return Math.min(0.97, ratio);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── psmux 래퍼 ──
|
|
38
149
|
function listPanes() {
|
|
39
150
|
try {
|
|
40
|
-
const out = execFileSync(
|
|
41
|
-
"
|
|
42
|
-
"#{pane_index}:#{pane_title}:#{pane_pid}",
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
151
|
+
const out = execFileSync(
|
|
152
|
+
"psmux",
|
|
153
|
+
["list-panes", "-t", SESSION, "-F", "#{pane_index}:#{pane_title}:#{pane_pid}"],
|
|
154
|
+
{ encoding: "utf8", timeout: 2000 },
|
|
155
|
+
);
|
|
156
|
+
return out
|
|
157
|
+
.trim()
|
|
158
|
+
.split("\n")
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map((line) => {
|
|
161
|
+
const [index, title, pid] = line.split(":");
|
|
162
|
+
return { index: parseInt(index, 10), title: title || "", pid };
|
|
163
|
+
});
|
|
48
164
|
} catch {
|
|
49
|
-
// psmux 미설치 또는 세션 없음 — 빈 목록 반환
|
|
50
165
|
return [];
|
|
51
166
|
}
|
|
52
167
|
}
|
|
53
168
|
|
|
54
|
-
|
|
55
|
-
function capturePane(paneIdx, lines = 5) {
|
|
169
|
+
function capturePane(paneIdx, lines = 20) {
|
|
56
170
|
try {
|
|
57
|
-
return execFileSync(
|
|
58
|
-
"
|
|
59
|
-
|
|
171
|
+
return execFileSync(
|
|
172
|
+
"psmux",
|
|
173
|
+
["capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p"],
|
|
174
|
+
{ encoding: "utf8", timeout: 2000 },
|
|
175
|
+
)
|
|
176
|
+
.trim()
|
|
177
|
+
.split("\n")
|
|
178
|
+
.slice(-lines)
|
|
179
|
+
.join("\n");
|
|
60
180
|
} catch {
|
|
61
|
-
// pane 캡처 실패 (pane 종료 또는 세션 소멸) — 빈 문자열 반환
|
|
62
181
|
return "";
|
|
63
182
|
}
|
|
64
183
|
}
|
|
65
184
|
|
|
66
|
-
// ── result 파일에서 handoff 파싱 ──
|
|
67
185
|
function checkResultFile(paneName) {
|
|
68
186
|
const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
|
|
69
187
|
if (!existsSync(resultFile)) return null;
|
|
70
188
|
try {
|
|
71
189
|
const content = readFileSync(resultFile, "utf8");
|
|
72
|
-
if (content.trim()
|
|
73
|
-
return
|
|
190
|
+
if (!content.trim()) return null;
|
|
191
|
+
return {
|
|
192
|
+
resultFile,
|
|
193
|
+
content,
|
|
194
|
+
processed: processHandoff(content, { exitCode: 0, resultFile }),
|
|
195
|
+
};
|
|
74
196
|
} catch {
|
|
75
|
-
// result 파일 파싱 실패 — null 반환하여 진행 중으로 처리
|
|
76
197
|
return null;
|
|
77
198
|
}
|
|
78
199
|
}
|
|
79
200
|
|
|
80
|
-
// ──
|
|
81
|
-
|
|
82
|
-
|
|
201
|
+
// ── 워커 상태 모델 ──
|
|
202
|
+
// 각 워커는 다음 필드를 가짐:
|
|
203
|
+
// raw_body — capture-pane 원시 텍스트
|
|
204
|
+
// filtered_body — 코드블록 + 내부 패턴 제거된 텍스트
|
|
205
|
+
// verdict — 한 줄 결론
|
|
206
|
+
// findings[] — 주목할 라인 (최대 2)
|
|
207
|
+
// handoff{} — { status, lead_action, verdict, ... }
|
|
208
|
+
// progress — 0~1
|
|
209
|
+
// activityAt — 마지막 변경 타임스탬프
|
|
210
|
+
// done — boolean
|
|
211
|
+
function makeWorkerState(paneIdx) {
|
|
212
|
+
return {
|
|
213
|
+
paneIdx,
|
|
214
|
+
done: false,
|
|
215
|
+
raw_body: "",
|
|
216
|
+
filtered_body: "",
|
|
217
|
+
verdict: "",
|
|
218
|
+
findings: [],
|
|
219
|
+
handoff: null,
|
|
220
|
+
progress: 0,
|
|
221
|
+
activityAt: Date.now(),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// HANDOFF status / lead_action 분리
|
|
226
|
+
function splitHandoff(handoff) {
|
|
227
|
+
if (!handoff) return { status: "pending", lead_action: null };
|
|
228
|
+
return {
|
|
229
|
+
status: handoff.status || "pending",
|
|
230
|
+
lead_action: handoff.lead_action || null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── 상태 집계 저장소 ──
|
|
235
|
+
const workerState = new Map(); // paneName → 내부 상태
|
|
83
236
|
let emptyPollCount = 0;
|
|
84
237
|
|
|
85
|
-
|
|
238
|
+
// ── data ingest (4Hz = 250ms) ──
|
|
239
|
+
function ingest() {
|
|
86
240
|
const panes = listPanes();
|
|
87
241
|
|
|
88
|
-
|
|
89
|
-
if (!panes.some(p => p.index !== 0)) {
|
|
242
|
+
if (!panes.some((p) => p.index !== 0)) {
|
|
90
243
|
emptyPollCount++;
|
|
91
244
|
const threshold = workerState.size === 0 ? 15 : 10;
|
|
92
245
|
if (emptyPollCount >= threshold) {
|
|
93
|
-
|
|
246
|
+
// 세션 종료 후에도 최종 결과 유지 — 키 입력 시 종료
|
|
247
|
+
clearInterval(ingestTimer);
|
|
248
|
+
clearInterval(renderTimer);
|
|
249
|
+
tui.render();
|
|
250
|
+
process.stdout.write("\n\x1b[38;5;245m 세션 종료됨 — 아무 키나 누르면 닫힘\x1b[0m");
|
|
251
|
+
if (process.stdin.isTTY) {
|
|
252
|
+
process.stdin.setRawMode(true);
|
|
253
|
+
process.stdin.resume();
|
|
254
|
+
process.stdin.once("data", () => { cleanup(); process.exit(0); });
|
|
255
|
+
} else {
|
|
256
|
+
setTimeout(() => { cleanup(); process.exit(0); }, 30000);
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
94
259
|
}
|
|
95
|
-
|
|
96
|
-
emptyPollCount = 0;
|
|
260
|
+
return;
|
|
97
261
|
}
|
|
262
|
+
emptyPollCount = 0;
|
|
98
263
|
|
|
99
|
-
// pane 0 = 대시보드 (자기 자신), pane 1+ = 워커
|
|
100
264
|
for (const pane of panes) {
|
|
101
|
-
if (pane.index === 0) continue;
|
|
102
|
-
const paneName = `worker-${pane.index}`;
|
|
103
|
-
const existing = workerState.get(paneName);
|
|
265
|
+
if (pane.index === 0) continue;
|
|
104
266
|
|
|
105
|
-
|
|
267
|
+
const paneName = `worker-${pane.index}`;
|
|
268
|
+
let ws = workerState.get(paneName);
|
|
269
|
+
if (!ws) {
|
|
270
|
+
ws = makeWorkerState(pane.index);
|
|
271
|
+
workerState.set(paneName, ws);
|
|
272
|
+
}
|
|
273
|
+
if (ws.done) continue;
|
|
106
274
|
|
|
107
|
-
// CLI 타입
|
|
275
|
+
// CLI 타입 감지
|
|
108
276
|
let cli = "codex";
|
|
109
277
|
if (pane.title.includes("gemini") || pane.title.includes("🔵")) cli = "gemini";
|
|
110
278
|
else if (pane.title.includes("claude") || pane.title.includes("🟠")) cli = "claude";
|
|
111
279
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
280
|
+
const resultData = checkResultFile(paneName);
|
|
281
|
+
if (resultData?.processed && !resultData.processed.fallback) {
|
|
282
|
+
// 결과 파일 처리 완료
|
|
283
|
+
const raw = resultData.content;
|
|
284
|
+
const filtered = toFilteredBody(raw);
|
|
285
|
+
const lines = filtered.split("\n").filter(Boolean);
|
|
286
|
+
const handoff = resultData.processed.handoff;
|
|
287
|
+
const verdict = handoff.verdict || "completed";
|
|
288
|
+
|
|
289
|
+
ws.done = true;
|
|
290
|
+
ws.raw_body = raw;
|
|
291
|
+
ws.filtered_body = filtered;
|
|
292
|
+
ws.verdict = verdict;
|
|
293
|
+
ws.findings = extractFindings(lines, verdict);
|
|
294
|
+
ws.handoff = handoff;
|
|
295
|
+
ws.progress = 1;
|
|
296
|
+
ws.activityAt = Date.now();
|
|
297
|
+
|
|
298
|
+
const { status, lead_action } = splitHandoff(handoff);
|
|
299
|
+
pushToTui(paneName, cli, pane.title, {
|
|
300
|
+
status: status === "failed" ? "failed" : "completed",
|
|
301
|
+
handoff,
|
|
302
|
+
summary: verdict,
|
|
303
|
+
detail: filtered,
|
|
304
|
+
findings: ws.findings,
|
|
305
|
+
tokens: extractTokenLabel(raw),
|
|
306
|
+
progress: 1,
|
|
121
307
|
elapsed: Math.round((Date.now() - startTime) / 1000),
|
|
308
|
+
_leadAction: lead_action,
|
|
122
309
|
});
|
|
123
310
|
continue;
|
|
124
311
|
}
|
|
125
312
|
|
|
126
|
-
// 진행 중
|
|
127
|
-
const snapshot = capturePane(pane.index,
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
workerState.set(paneName, { paneIdx: pane.index, done: false });
|
|
133
|
-
}
|
|
313
|
+
// 스냅샷 기반 진행 중 상태
|
|
314
|
+
const snapshot = capturePane(pane.index, 20);
|
|
315
|
+
const raw_body = snapshot;
|
|
316
|
+
const filtered_body = toFilteredBody(snapshot);
|
|
317
|
+
const lines = filtered_body.split("\n").filter(Boolean);
|
|
318
|
+
const lastLine = lines.at(-1) || "";
|
|
134
319
|
|
|
135
|
-
// 완료 감지: (1) result 파일 존재, (2) 셸 프롬프트 복귀, (3) "tokens used" 텍스트
|
|
136
320
|
const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
|
|
137
321
|
let resultSize = 0;
|
|
138
|
-
try { resultSize = statSync(resultFile).size; } catch { /*
|
|
322
|
+
try { resultSize = statSync(resultFile).size; } catch { /* missing */ }
|
|
139
323
|
|
|
140
324
|
const shellReturned = /^(PS\s|>|\$)\s*/.test(lastLine) && lines.length > 2;
|
|
141
|
-
const
|
|
142
|
-
|
|
325
|
+
const tokens = extractTokenLabel(snapshot);
|
|
143
326
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
327
|
+
|
|
144
328
|
if (resultSize > 10 || shellReturned) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
329
|
+
const resultContent = existsSync(resultFile)
|
|
330
|
+
? readFileSync(resultFile, "utf8")
|
|
331
|
+
: snapshot;
|
|
332
|
+
const rLines = toLines(resultContent);
|
|
333
|
+
const verdict = extractFindings(rLines).at(-1) || lastLine || "completed";
|
|
334
|
+
const handoffStatus = /fail|error|exception/i.test(rLines.join("\n")) ? "failed" : "ok";
|
|
335
|
+
const handoff = {
|
|
336
|
+
status: handoffStatus,
|
|
337
|
+
lead_action: handoffStatus === "failed" ? "retry" : "accept",
|
|
338
|
+
verdict,
|
|
339
|
+
confidence: tokens ? "high" : "medium",
|
|
340
|
+
files_changed: [],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
ws.done = true;
|
|
344
|
+
ws.raw_body = resultContent;
|
|
345
|
+
ws.filtered_body = toFilteredBody(resultContent);
|
|
346
|
+
ws.verdict = verdict;
|
|
347
|
+
ws.findings = extractFindings(rLines, verdict);
|
|
348
|
+
ws.handoff = handoff;
|
|
349
|
+
ws.progress = 1;
|
|
350
|
+
ws.activityAt = Date.now();
|
|
351
|
+
|
|
352
|
+
pushToTui(paneName, cli, pane.title, {
|
|
353
|
+
status: handoffStatus === "failed" ? "failed" : "completed",
|
|
354
|
+
handoff,
|
|
355
|
+
summary: verdict,
|
|
356
|
+
detail: ws.filtered_body,
|
|
357
|
+
findings: ws.findings,
|
|
358
|
+
tokens,
|
|
359
|
+
progress: 1,
|
|
153
360
|
elapsed,
|
|
154
361
|
});
|
|
155
|
-
|
|
156
|
-
const meaningful = lines.filter(l => !(/^(PS\s|>|\$)/.test(l)));
|
|
157
|
-
const snap = meaningful.pop() || lastLine;
|
|
158
|
-
tui.updateWorker(paneName, { cli, role: pane.title, status: "running", snapshot: snap, elapsed });
|
|
362
|
+
continue;
|
|
159
363
|
}
|
|
160
|
-
}
|
|
161
364
|
|
|
162
|
-
|
|
365
|
+
// 진행 중
|
|
366
|
+
const progress = estimateProgress(lines, { tokens, resultSize, shellReturned, done: false });
|
|
367
|
+
const verdict = lastLine;
|
|
368
|
+
|
|
369
|
+
ws.raw_body = raw_body.length > MAX_BODY_BYTES ? raw_body.slice(-MAX_BODY_BYTES) : raw_body;
|
|
370
|
+
ws.filtered_body = filtered_body.length > MAX_BODY_BYTES ? filtered_body.slice(-MAX_BODY_BYTES) : filtered_body;
|
|
371
|
+
ws.verdict = verdict;
|
|
372
|
+
ws.findings = extractFindings(lines, lastLine);
|
|
373
|
+
ws.progress = progress;
|
|
374
|
+
ws.activityAt = Date.now();
|
|
375
|
+
|
|
376
|
+
pushToTui(paneName, cli, pane.title, {
|
|
377
|
+
status: "running",
|
|
378
|
+
snapshot: lastLine,
|
|
379
|
+
summary: lastLine,
|
|
380
|
+
detail: filtered_body,
|
|
381
|
+
findings: ws.findings,
|
|
382
|
+
confidence: tokens ? "medium" : "low",
|
|
383
|
+
tokens,
|
|
384
|
+
progress,
|
|
385
|
+
elapsed,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
163
388
|
}
|
|
164
389
|
|
|
165
|
-
//
|
|
166
|
-
|
|
390
|
+
// ── tui.updateWorker 래퍼 — raw internal data 누출 방지 ──
|
|
391
|
+
function pushToTui(paneName, cli, role, update) {
|
|
392
|
+
// _leadAction은 tui에 노출하지 않음 (내부용)
|
|
393
|
+
const { _leadAction: _ignored, ...safeUpdate } = update;
|
|
394
|
+
tui.updateWorker(paneName, { cli, role, ...safeUpdate });
|
|
395
|
+
}
|
|
167
396
|
|
|
168
|
-
|
|
397
|
+
// ── render 루프 (8-12FPS ≈ 100ms) ──
|
|
398
|
+
let renderTimer = null;
|
|
399
|
+
function startRender() {
|
|
400
|
+
renderTimer = setInterval(() => { tui.render(); }, 100);
|
|
401
|
+
if (renderTimer.unref) renderTimer.unref();
|
|
402
|
+
}
|
|
169
403
|
|
|
170
|
-
//
|
|
404
|
+
// ── 완료 감지 ──
|
|
171
405
|
const doneCheck = setInterval(() => {
|
|
172
|
-
if (workerState.size > 0 && [...workerState.values()].every(w => w.done)) {
|
|
406
|
+
if (workerState.size > 0 && [...workerState.values()].every((w) => w.done)) {
|
|
173
407
|
tui.render();
|
|
174
408
|
clearInterval(doneCheck);
|
|
175
|
-
|
|
409
|
+
clearInterval(ingestTimer);
|
|
410
|
+
clearInterval(renderTimer);
|
|
411
|
+
process.stdout.write("\n\x1b[38;5;245m 전체 완료 — 아무 키나 누르면 닫힘\x1b[0m");
|
|
412
|
+
if (process.stdin.isTTY) {
|
|
413
|
+
process.stdin.setRawMode(true);
|
|
414
|
+
process.stdin.resume();
|
|
415
|
+
process.stdin.once("data", () => { cleanup(); process.exit(0); });
|
|
416
|
+
} else {
|
|
417
|
+
setTimeout(() => { cleanup(); process.exit(0); }, 30000);
|
|
418
|
+
}
|
|
176
419
|
}
|
|
177
420
|
}, 2000);
|
|
178
421
|
|
|
179
|
-
|
|
180
|
-
|
|
422
|
+
// ── resize 대응 ──
|
|
423
|
+
process.stdout.on("resize", () => {
|
|
424
|
+
tui.render();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ── 정리 ──
|
|
428
|
+
function cleanup() {
|
|
429
|
+
clearInterval(ingestTimer);
|
|
430
|
+
clearInterval(renderTimer);
|
|
431
|
+
clearInterval(doneCheck);
|
|
432
|
+
tui.close();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── 진입점 ──
|
|
436
|
+
tui.render();
|
|
437
|
+
const ingestTimer = setInterval(ingest, 500); // 2Hz
|
|
438
|
+
startRender();
|
|
439
|
+
|
|
440
|
+
// 타임아웃 (10분)
|
|
441
|
+
setTimeout(() => { cleanup(); process.exit(0); }, 10 * 60 * 1000);
|
|
442
|
+
|
|
443
|
+
// Ctrl-C
|
|
444
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|