triflux 7.0.5 → 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;
@@ -91,38 +91,35 @@ function poll() {
91
91
  }
92
92
 
93
93
  // 진행 중 — pane 캡처로 스냅샷
94
- const snapshot = capturePane(pane.index, 3);
95
- const lastLine = snapshot.split("\n").filter(l => l.trim()).pop() || "";
94
+ const snapshot = capturePane(pane.index, 5);
95
+ const lines = snapshot.split("\n").filter(l => l.trim());
96
+ const lastLine = lines.pop() || "";
96
97
 
97
98
  if (!existing) {
98
99
  workerState.set(paneName, { paneIdx: pane.index, done: false });
99
100
  }
100
101
 
101
- // result 파일이 있고 내용이 있으면 완료로 간주
102
+ // 완료 감지: (1) result 파일 존재, (2) 프롬프트 복귀, (3) "tokens used" 텍스트
102
103
  const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
103
104
  let resultSize = 0;
104
105
  try { resultSize = statSync(resultFile).size; } catch {}
105
106
 
106
- if (resultSize > 10) {
107
- // 완료 (fallback handoff)
107
+ const shellReturned = /^(PS\s|>|\$)\s*/.test(lastLine) && lines.length > 2;
108
+ const tokensLine = lines.find(l => /tokens?\s+used/i.test(l));
109
+
110
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
111
+ if (resultSize > 10 || shellReturned) {
108
112
  workerState.set(paneName, { paneIdx: pane.index, done: true });
109
- const fb = handoffResult || { handoff: { verdict: lastLine.slice(0, 60), confidence: "low" } };
110
- tui.updateWorker(paneName, {
111
- cli,
112
- role: pane.title,
113
- status: "completed",
114
- handoff: fb.handoff,
115
- elapsed: Math.round((Date.now() - startTime) / 1000),
116
- });
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 });
117
116
  } else {
118
- tui.updateWorker(paneName, {
119
- cli,
120
- role: pane.title,
121
- status: "running",
122
- snapshot: lastLine.slice(0, 60),
123
- elapsed: Math.round((Date.now() - startTime) / 1000),
124
- });
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 });
125
120
  }
121
+ // debug: stderr에 상태 출력 (캡처 영향 없음)
122
+ process.stderr.write(`[dbg] ${paneName} cli=${cli} status=${shellReturned?"done":"run"} lines=${lines.length}\n`);
126
123
  }
127
124
 
128
125
  tui.render();
package/hub/team/tui.mjs CHANGED
@@ -1,13 +1,7 @@
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
6
  // package.json에서 동적 로드 (실패 시 fallback)
13
7
  let VERSION = "7.x";
@@ -18,18 +12,16 @@ try {
18
12
  } catch { /* fallback */ }
19
13
 
20
14
  /**
21
- * TUI 대시보드 생성
15
+ * 로그 스트림 대시보드 생성 (append-only)
22
16
  * @param {object} [opts]
23
17
  * @param {NodeJS.WriteStream} [opts.stream=process.stdout]
24
- * @param {number} [opts.refreshMs=1000] — 자동 갱신 주기 (0=수동만)
25
- * @param {number} [opts.width=0] — 0이면 터미널 폭 자동
26
- * @returns {TuiHandle}
18
+ * @param {number} [opts.refreshMs=1000] — 자동 렌더 주기 (0=수동만)
19
+ * @returns {LogDashboardHandle}
27
20
  */
28
- export function createTui(opts = {}) {
21
+ export function createLogDashboard(opts = {}) {
29
22
  const {
30
23
  stream = process.stdout,
31
24
  refreshMs = 1000,
32
- width: fixedWidth = 0,
33
25
  } = opts;
34
26
 
35
27
  const workers = new Map();
@@ -38,105 +30,88 @@ export function createTui(opts = {}) {
38
30
  let timer = null;
39
31
  let closed = false;
40
32
  let frameCount = 0;
33
+ const lastLineByWorker = new Map();
34
+ let lastPipelineLine = "";
41
35
 
42
- function w() { return fixedWidth > 0 ? fixedWidth : (stream.columns || 80); }
43
- function out(text) { if (!closed) stream.write(text); }
36
+ function out(text) { if (!closed) stream.write(`${text}\n`); }
44
37
 
45
- // ── 렌더 함수 ──
46
-
47
- function header() {
48
- const elapsed = Math.round((Date.now() - startedAt) / 1000);
49
- const vals = [...workers.values()];
50
- const done = vals.filter((s) => s.status === "completed").length;
51
- const fail = vals.filter((s) => s.status === "failed").length;
52
- const run = vals.filter((s) => s.status === "running").length;
53
-
54
- const left = ` ${color("▲ triflux", FG.triflux)} ${dim(`v${VERSION}`)}`;
55
- const right = `${color(`${done}✓`, FG.green)} ${color(`${fail}✗`, FG.red)} ${run}⏳ ${dim(`${vals.length}총`)} ${dim(`${elapsed}s`)}`;
56
- const gap = Math.max(1, w() - stripAnsi(left).length - stripAnsi(right).length - 1);
57
- 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]`);
58
43
  }
59
44
 
60
- function workerCard(name, st) {
61
- const icon = CLI_ICON[st.cli] || dim("");
62
- const sIcon = STATUS_ICON[st.status] || STATUS_ICON.pending;
63
- const el = st.elapsed != null ? `${st.elapsed}s` : "";
64
- const role = st.role ? dim(`(${st.role})`) : "";
65
- const conf = st.handoff?.confidence ? dim(` ${st.handoff.confidence}`) : "";
66
-
67
- const lines = [];
68
- lines.push(` ${icon} ${st.cli} ${role} ${sIcon} ${el}${conf}`);
69
-
70
- if (st.handoff?.verdict) {
71
- lines.push(` ${truncate(st.handoff.verdict, w() - 8)}`);
72
- }
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
+ }
73
50
 
74
- if (st.handoff?.files_changed?.length) {
75
- const f = st.handoff.files_changed.slice(0, 3).join(", ");
76
- const more = st.handoff.files_changed.length > 3 ? ` +${st.handoff.files_changed.length - 3}` : "";
77
- lines.push(` ${dim(`files: ${f}${more}`)}`);
78
- }
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
+ }
79
57
 
80
- if (st.status === "failed" && st.handoff) {
81
- const p = [];
82
- if (st.handoff.risk) p.push(`risk:${st.handoff.risk}`);
83
- if (st.handoff.lead_action) p.push(`action:${st.handoff.lead_action}`);
84
- if (p.length) lines.push(` ${color(p.join(" "), FG.red)}`);
85
- }
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
+ }
86
64
 
87
- if (st.status === "running" && st.snapshot) {
88
- 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");
89
70
  }
90
-
91
- return lines;
71
+ return st.status || "pending";
92
72
  }
93
73
 
94
- function pipelineBar() {
95
- const phases = ["plan", "prd", "confidence", "exec", "deslop", "verify", "selfcheck", "complete"];
96
- const cur = pipeline.phase;
97
- const parts = phases.map((p) => {
98
- const ci = phases.indexOf(cur);
99
- const pi = phases.indexOf(p);
100
- if (p === cur) return `${FG.accent}${BOLD}[${p}]${RESET}`;
101
- if (pi < ci) return `${FG.green}${p}${RESET}`;
102
- return dim(p);
103
- });
104
- 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}`;
105
83
  }
106
84
 
107
- function footer() {
108
- const vals = [...workers.values()];
109
- const done = vals.filter((s) => s.status === "completed").length;
110
- const tokenSaved = done * 850;
111
- 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)}`;
112
89
  }
113
90
 
91
+ // 현재 상태와 마지막으로 출력한 라인을 비교해 변경분만 append
114
92
  function render() {
115
93
  if (closed) return;
116
94
  frameCount++;
117
- const sep = dim("─".repeat(w()));
118
- const buf = [cursorHome];
119
-
120
- buf.push(header() + clearLine + "\n");
121
- buf.push(sep + "\n");
122
-
123
- if (workers.size === 0) {
124
- buf.push(dim(" (워커 대기 중...)\n"));
125
- } else {
126
- for (const [name, st] of workers) {
127
- for (const l of workerCard(name, st)) buf.push(l + clearLine + "\n");
128
- 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);
129
103
  }
130
104
  }
131
105
 
132
- buf.push(sep + "\n");
133
- buf.push(pipelineBar() + clearLine + "\n");
134
- buf.push(footer() + clearLine + "\n");
135
-
136
- out(buf.join(""));
106
+ const nextPipelineLine = pipelineLine();
107
+ if (nextPipelineLine !== lastPipelineLine) {
108
+ lastPipelineLine = nextPipelineLine;
109
+ out(nextPipelineLine);
110
+ }
137
111
  }
138
112
 
139
- // 자동 갱신
113
+ out(`${dim("[0s]")} ${color("▲ triflux", FG.triflux)} ${dim(`v${VERSION}`)} ${dim("log-dashboard started")}`);
114
+
140
115
  if (refreshMs > 0) {
141
116
  timer = setInterval(render, refreshMs);
142
117
  if (timer.unref) timer.unref();
@@ -156,6 +131,7 @@ export function createTui(opts = {}) {
156
131
  if (closed) return;
157
132
  closed = true;
158
133
  if (timer) clearInterval(timer);
134
+ out(`${dim(`[${Math.max(0, Math.round((Date.now() - startedAt) / 1000))}s]`)} ${dim("log-dashboard stopped")}`);
159
135
  },
160
136
  };
161
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "7.0.5",
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(