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.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // hub/team/tui-viewer.mjs — psmux pane용 append-only 로그 뷰어 v3
3
- // 같은 psmux 세션의 워커 pane을 capture-pane으로 모니터링한다.
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
- const sessionIdx = args.indexOf("--session");
14
- const resultDirIdx = args.indexOf("--result-dir");
15
- const SESSION = sessionIdx >= 0 ? args[sessionIdx + 1] : null;
16
- const RESULT_DIR = resultDirIdx >= 0
17
- ? args[resultDirIdx + 1]
18
- : join(tmpdir(), "tfx-headless");
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("Usage: node tui-viewer.mjs --session <name>\n");
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("ERROR: psmux not found or not executable. Install psmux before running tui-viewer.\n");
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
- const tui = createLogDashboard({ refreshMs: 0 });
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
- // ── psmux pane 목록 조회 ──
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("psmux", [
41
- "list-panes", "-t", SESSION, "-F",
42
- "#{pane_index}:#{pane_title}:#{pane_pid}",
43
- ], { encoding: "utf8", timeout: 2000 });
44
- return out.trim().split("\n").filter(Boolean).map(line => {
45
- const [index, title, pid] = line.split(":");
46
- return { index: parseInt(index, 10), title: title || "", pid };
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
- // ── pane 캡처 ──
55
- function capturePane(paneIdx, lines = 5) {
169
+ function capturePane(paneIdx, lines = 20) {
56
170
  try {
57
- return execFileSync("psmux", [
58
- "capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p",
59
- ], { encoding: "utf8", timeout: 2000 }).trim().split("\n").slice(-lines).join("\n");
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().length === 0) return null;
73
- return processHandoff(content, { exitCode: 0, resultFile });
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
- const POLL_MS = 1000;
82
- const workerState = new Map(); // paneName { paneIdx, done }
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
- function poll() {
238
+ // ── data ingest (4Hz = 250ms) ──
239
+ function ingest() {
86
240
  const panes = listPanes();
87
241
 
88
- // 세션 사망 감지: 워커 pane이 없는 상태가 지속되면 자동 종료
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
- tui.close(); clearInterval(timer); clearInterval(doneCheck); process.exit(0);
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
- } else {
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
- if (existing?.done) continue;
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 타입 추정 (pane title에서)
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
- // result 파일 확인 (완료 여부)
113
- const handoffResult = checkResultFile(paneName);
114
- if (handoffResult && !handoffResult.fallback) {
115
- workerState.set(paneName, { paneIdx: pane.index, done: true });
116
- tui.updateWorker(paneName, {
117
- cli,
118
- role: pane.title,
119
- status: handoffResult.handoff.status === "failed" ? "failed" : "completed",
120
- handoff: handoffResult.handoff,
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
- // 진행 중 — pane 캡처로 스냅샷
127
- const snapshot = capturePane(pane.index, 5);
128
- const lines = snapshot.split("\n").filter(l => l.trim());
129
- const lastLine = lines.pop() || "";
130
-
131
- if (!existing) {
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 { /* 파일 미존재 — size 0 유지 */ }
322
+ try { resultSize = statSync(resultFile).size; } catch { /* missing */ }
139
323
 
140
324
  const shellReturned = /^(PS\s|>|\$)\s*/.test(lastLine) && lines.length > 2;
141
- const tokensLine = lines.find(l => /tokens?\s+used/i.test(l));
142
-
325
+ const tokens = extractTokenLabel(snapshot);
143
326
  const elapsed = Math.round((Date.now() - startTime) / 1000);
327
+
144
328
  if (resultSize > 10 || shellReturned) {
145
- workerState.set(paneName, { paneIdx: pane.index, done: true });
146
- const meaningful = lines.filter((l) => !(/^(PS\s|>|\$)/.test(l)) && !(/tokens?\s+used/i.test(l)));
147
- const verdict = meaningful.pop() || "completed";
148
- tui.updateWorker(paneName, {
149
- cli,
150
- role: pane.title,
151
- status: "completed",
152
- handoff: { verdict, confidence: tokensLine ? "high" : "low" },
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
- } else {
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
- tui.render();
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
- tui.render();
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
- const timer = setInterval(poll, POLL_MS);
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
- // 모든 워커 완료 15초 유지 후 종료
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
- setTimeout(() => { tui.close(); clearInterval(timer); process.exit(0); }, 15000);
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
- process.on("SIGINT", () => { tui.close(); clearInterval(timer); clearInterval(doneCheck); process.exit(0); });
180
- setTimeout(() => { tui.close(); clearInterval(timer); clearInterval(doneCheck); process.exit(0); }, 10 * 60 * 1000);
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); });