triflux 7.0.5 → 7.1.0

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;
@@ -1,25 +1,28 @@
1
1
  #!/usr/bin/env node
2
- // hub/team/tui-viewer.mjs — psmux pane용 TUI 대시보드 뷰어 v2
3
- // 같은 psmux 세션의 워커 pane을 capture-pane으로 실시간 모니터링
2
+ // hub/team/tui-viewer.mjs — psmux pane용 append-only 로그 뷰어 v3
3
+ // 같은 psmux 세션의 워커 pane을 capture-pane으로 모니터링한다.
4
4
 
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { join } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
- import { createTui } from "./tui.mjs";
9
+ import { createLogDashboard } from "./tui.mjs";
10
10
  import { processHandoff } from "./handoff.mjs";
11
11
 
12
12
  const args = process.argv.slice(2);
13
13
  const sessionIdx = args.indexOf("--session");
14
+ const resultDirIdx = args.indexOf("--result-dir");
14
15
  const SESSION = sessionIdx >= 0 ? args[sessionIdx + 1] : null;
15
- const RESULT_DIR = join(tmpdir(), "tfx-headless");
16
+ const RESULT_DIR = resultDirIdx >= 0
17
+ ? args[resultDirIdx + 1]
18
+ : join(tmpdir(), "tfx-headless");
16
19
 
17
20
  if (!SESSION) {
18
21
  process.stderr.write("Usage: node tui-viewer.mjs --session <name>\n");
19
22
  process.exit(1);
20
23
  }
21
24
 
22
- const tui = createTui({ refreshMs: 0 });
25
+ const tui = createLogDashboard({ refreshMs: 0 });
23
26
  const startTime = Date.now();
24
27
  tui.setStartTime(startTime);
25
28
 
@@ -34,7 +37,9 @@ function listPanes() {
34
37
  const [index, title, pid] = line.split(":");
35
38
  return { index: parseInt(index, 10), title: title || "", pid };
36
39
  });
37
- } catch { return []; }
40
+ } catch {
41
+ return [];
42
+ }
38
43
  }
39
44
 
40
45
  // ── pane 캡처 ──
@@ -43,7 +48,9 @@ function capturePane(paneIdx, lines = 5) {
43
48
  return execFileSync("psmux", [
44
49
  "capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p",
45
50
  ], { encoding: "utf8", timeout: 2000 }).trim().split("\n").slice(-lines).join("\n");
46
- } catch { return ""; }
51
+ } catch {
52
+ return "";
53
+ }
47
54
  }
48
55
 
49
56
  // ── result 파일에서 handoff 파싱 ──
@@ -54,7 +61,9 @@ function checkResultFile(paneName) {
54
61
  const content = readFileSync(resultFile, "utf8");
55
62
  if (content.trim().length === 0) return null;
56
63
  return processHandoff(content, { exitCode: 0, resultFile });
57
- } catch { return null; }
64
+ } catch {
65
+ return null;
66
+ }
58
67
  }
59
68
 
60
69
  // ── 메인 폴링 ──
@@ -63,6 +72,9 @@ const workerState = new Map(); // paneName → { paneIdx, done }
63
72
 
64
73
  function poll() {
65
74
  const panes = listPanes();
75
+ if (panes.length <= 1 && workerState.size === 0) {
76
+ process.stderr.write(`[poll] no workers found. panes=${JSON.stringify(panes)} session=${SESSION}\n`);
77
+ }
66
78
  // pane 0 = 대시보드 (자기 자신), pane 1+ = 워커
67
79
  for (const pane of panes) {
68
80
  if (pane.index === 0) continue; // 자기 자신 건너뜀
@@ -91,37 +103,38 @@ function poll() {
91
103
  }
92
104
 
93
105
  // 진행 중 — pane 캡처로 스냅샷
94
- const snapshot = capturePane(pane.index, 3);
95
- const lastLine = snapshot.split("\n").filter(l => l.trim()).pop() || "";
106
+ const snapshot = capturePane(pane.index, 5);
107
+ const lines = snapshot.split("\n").filter(l => l.trim());
108
+ const lastLine = lines.pop() || "";
96
109
 
97
110
  if (!existing) {
98
111
  workerState.set(paneName, { paneIdx: pane.index, done: false });
99
112
  }
100
113
 
101
- // result 파일이 있고 내용이 있으면 완료로 간주
114
+ // 완료 감지: (1) result 파일 존재, (2) 프롬프트 복귀, (3) "tokens used" 텍스트
102
115
  const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
103
116
  let resultSize = 0;
104
117
  try { resultSize = statSync(resultFile).size; } catch {}
105
118
 
106
- if (resultSize > 10) {
107
- // 완료 (fallback handoff)
119
+ const shellReturned = /^(PS\s|>|\$)\s*/.test(lastLine) && lines.length > 2;
120
+ const tokensLine = lines.find(l => /tokens?\s+used/i.test(l));
121
+
122
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
123
+ if (resultSize > 10 || shellReturned) {
108
124
  workerState.set(paneName, { paneIdx: pane.index, done: true });
109
- const fb = handoffResult || { handoff: { verdict: lastLine.slice(0, 60), confidence: "low" } };
125
+ const meaningful = lines.filter((l) => !(/^(PS\s|>|\$)/.test(l)) && !(/tokens?\s+used/i.test(l)));
126
+ const verdict = meaningful.pop()?.slice(0, 80) || "completed";
110
127
  tui.updateWorker(paneName, {
111
128
  cli,
112
129
  role: pane.title,
113
130
  status: "completed",
114
- handoff: fb.handoff,
115
- elapsed: Math.round((Date.now() - startTime) / 1000),
131
+ handoff: { verdict, confidence: tokensLine ? "high" : "low" },
132
+ elapsed,
116
133
  });
117
134
  } 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
- });
135
+ const meaningful = lines.filter(l => !(/^(PS\s|>|\$)/.test(l)));
136
+ const snap = meaningful.pop()?.slice(0, 60) || lastLine.slice(0, 60);
137
+ tui.updateWorker(paneName, { cli, role: pane.title, status: "running", snapshot: snap, elapsed });
125
138
  }
126
139
  }
127
140
 
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,75 @@ export function createTui(opts = {}) {
38
30
  let timer = null;
39
31
  let closed = false;
40
32
  let frameCount = 0;
33
+ const lastLineByWorker = new Map();
41
34
 
42
- function w() { return fixedWidth > 0 ? fixedWidth : (stream.columns || 80); }
43
- function out(text) { if (!closed) stream.write(text); }
35
+ function out(text) { if (!closed) stream.write(`${text}\n`); }
44
36
 
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}`;
37
+ function nowElapsedSec() {
38
+ return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
58
39
  }
59
40
 
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
- }
73
-
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
- }
41
+ function elapsedLabel(sec) {
42
+ return dim(`[${sec}s]`);
43
+ }
79
44
 
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
- }
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
+ }
86
50
 
87
- if (st.status === "running" && st.snapshot) {
88
- lines.push(` ${dim(truncate(st.snapshot, w() - 8))}`);
89
- }
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
+ }
90
57
 
91
- return lines;
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");
92
63
  }
93
64
 
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("→"))}`;
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");
70
+ }
71
+ return st.status || "pending";
105
72
  }
106
73
 
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}`;
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
+ const sec = Number.isFinite(st._logSec) ? st._logSec : nowElapsedSec();
83
+ return `${elapsedLabel(sec)} ${icon} ${workerLabel} (${cliLabel}) ${statusText} ${dim("—")} ${message}`;
112
84
  }
113
85
 
86
+ // 현재 상태와 마지막으로 출력한 라인을 비교해 변경분만 append
114
87
  function render() {
115
88
  if (closed) return;
116
89
  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");
90
+
91
+ const names = [...workers.keys()].sort();
92
+ for (const name of names) {
93
+ const st = workers.get(name);
94
+ const line = workerLine(name, st);
95
+ if (line !== lastLineByWorker.get(name)) {
96
+ lastLineByWorker.set(name, line);
97
+ out(line);
129
98
  }
130
99
  }
131
-
132
- buf.push(sep + "\n");
133
- buf.push(pipelineBar() + clearLine + "\n");
134
- buf.push(footer() + clearLine + "\n");
135
-
136
- out(buf.join(""));
137
100
  }
138
101
 
139
- // 자동 갱신
140
102
  if (refreshMs > 0) {
141
103
  timer = setInterval(render, refreshMs);
142
104
  if (timer.unref) timer.unref();
@@ -145,17 +107,37 @@ export function createTui(opts = {}) {
145
107
  return {
146
108
  updateWorker(paneName, state) {
147
109
  const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
148
- workers.set(paneName, { ...existing, ...state });
110
+ const merged = { ...existing, ...state };
111
+ const nextSig = [
112
+ merged.cli || "",
113
+ merged.status || "",
114
+ merged.snapshot || "",
115
+ merged.handoff?.verdict || "",
116
+ merged.handoff?.lead_action || "",
117
+ merged.handoff?.confidence || "",
118
+ merged.handoff?.risk || "",
119
+ ].join("|");
120
+ const sigChanged = nextSig !== existing._sig;
121
+ const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
122
+ merged._sig = nextSig;
123
+ merged._logSec = sigChanged
124
+ ? (explicitElapsed ?? nowElapsedSec())
125
+ : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
126
+ workers.set(paneName, merged);
127
+ },
128
+ updatePipeline(state) {
129
+ pipeline = { ...pipeline, ...state };
130
+ },
131
+ setStartTime(ms) {
132
+ startedAt = ms;
149
133
  },
150
- updatePipeline(state) { pipeline = { ...pipeline, ...state }; },
151
- setStartTime(ms) { startedAt = ms; },
152
134
  render,
153
135
  getWorkers() { return new Map(workers); },
154
136
  getFrameCount() { return frameCount; },
155
137
  close() {
156
138
  if (closed) return;
157
- closed = true;
158
139
  if (timer) clearInterval(timer);
140
+ closed = true;
159
141
  },
160
142
  };
161
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "7.0.5",
3
+ "version": "7.1.0",
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(