triflux 7.0.2 → 7.0.4

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,5 @@
1
1
  import { BOLD, DIM, GREEN, RESET, AMBER } from "../../../shared.mjs";
2
2
  import { runHeadlessInteractive, resolveCliType } from "../../../headless.mjs";
3
- import { createTui } from "../../../tui.mjs";
4
3
  import { ok, warn } from "../../render.mjs";
5
4
  import { buildTasks } from "../../services/task-model.mjs";
6
5
  import { clearTeamState } from "../../services/state-store.mjs";
@@ -14,48 +13,28 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
14
13
  const startedAt = Date.now();
15
14
  ok(`headless ${assignments.length}워커 시작`);
16
15
 
17
- // TUI 대시보드 (--dashboard 플래그)
18
- const tui = dashboard ? createTui({ refreshMs: 1000 }) : null;
19
-
20
16
  const handle = await runHeadlessInteractive(sessionId, assignments, {
21
17
  timeoutSec: timeoutSec || 300,
22
18
  layout,
23
19
  autoAttach: !!autoAttach,
20
+ dashboard: !!dashboard,
24
21
  progressive: progressive !== false,
25
- progressIntervalSec: (verbose || dashboard) ? 10 : 0,
26
- onProgress: function onProgress(event) {
27
- // TUI 대시보드 업데이트
28
- if (tui) {
29
- if (event.type === "dispatched") {
30
- tui.updateWorker(event.paneName, { cli: event.cli, status: "running" });
31
- } else if (event.type === "progress") {
32
- const snap = (event.snapshot || "").split("\n").filter(l => l.trim()).pop() || "";
33
- tui.updateWorker(event.paneName, { snapshot: snap });
34
- } else if (event.type === "completed") {
35
- tui.updateWorker(event.paneName, {
36
- status: event.matched && event.exitCode === 0 ? "completed" : "failed",
37
- elapsed: Math.round((Date.now() - (tui._start || Date.now())) / 1000),
38
- });
39
- }
40
- }
41
-
42
- // verbose 텍스트 출력 (TUI 비활성 시)
43
- if (verbose && !tui) {
44
- if (event.type === "session_created") {
45
- console.log(` ${DIM}세션: ${event.sessionName}${RESET}`);
46
- } else if (event.type === "worker_added") {
47
- console.log(` ${DIM}[+] ${event.paneTitle}${RESET}`);
48
- } else if (event.type === "dispatched") {
49
- console.log(` ${DIM}[${event.paneName}] ${event.cli} dispatch${RESET}`);
50
- } else if (event.type === "progress") {
51
- const last = (event.snapshot || "").split("\n").filter(l => l.trim()).pop() || "";
52
- if (last) console.log(` ${DIM}[${event.paneName}] ${last.slice(0, 60)}${RESET}`);
53
- } else if (event.type === "completed") {
54
- const icon = event.matched && event.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
55
- console.log(` ${icon} [${event.paneName}] ${event.cli} exit=${event.exitCode}${event.sessionDead ? " (dead)" : ""}`);
56
- }
22
+ progressIntervalSec: verbose ? 10 : 0,
23
+ onProgress: verbose ? function onProgress(event) {
24
+ if (event.type === "session_created") {
25
+ console.log(` ${DIM}세션: ${event.sessionName}${RESET}`);
26
+ } else if (event.type === "worker_added") {
27
+ console.log(` ${DIM}[+] ${event.paneTitle}${RESET}`);
28
+ } else if (event.type === "dispatched") {
29
+ console.log(` ${DIM}[${event.paneName}] ${event.cli} dispatch${RESET}`);
30
+ } else if (event.type === "progress") {
31
+ const last = (event.snapshot || "").split("\n").filter(l => l.trim()).pop() || "";
32
+ if (last) console.log(` ${DIM}[${event.paneName}] ${last.slice(0, 60)}${RESET}`);
33
+ } else if (event.type === "completed") {
34
+ const icon = event.matched && event.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
35
+ console.log(` ${icon} [${event.paneName}] ${event.cli} exit=${event.exitCode}${event.sessionDead ? " (dead)" : ""}`);
57
36
  }
58
- },
37
+ } : undefined,
59
38
  });
60
39
 
61
40
  // 최소 결과 요약
@@ -94,21 +73,6 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
94
73
  }
95
74
  }
96
75
 
97
- // TUI에 handoff 최종 반영
98
- if (tui) {
99
- for (const r of results) {
100
- if (r.handoff) {
101
- tui.updateWorker(r.paneName, {
102
- status: r.matched && r.exitCode === 0 ? "completed" : "failed",
103
- handoff: r.handoff,
104
- elapsed: Math.round((Date.now() - startedAt) / 1000),
105
- });
106
- }
107
- }
108
- tui.render(); // 최종 프레임
109
- tui.close();
110
- }
111
-
112
76
  // 세션 정리
113
77
  handle.kill();
114
78
 
@@ -72,17 +72,12 @@ const MCP_PROFILE_HINTS = {
72
72
  */
73
73
  export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
74
74
  const { handoff = true, mcp } = opts;
75
- // 에이전트 역할명("executor" 등)을 CLI 타입으로 해석
76
75
  const resolvedCli = resolveCliType(cli);
77
- // MCP 프로필 힌트를 프롬프트에 삽입
78
- const mcpHint = mcp && MCP_PROFILE_HINTS[mcp] ? `\n[MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}` : "";
79
- // HANDOFF 지시를 프롬프트에 삽입 (opt-out 가능)
80
- const fullPrompt = handoff
81
- ? `${prompt}${mcpHint}\n\n${HANDOFF_INSTRUCTION}`
82
- : `${prompt}${mcpHint}`;
83
- // 프롬프트의 단일 인용부호를 이스케이프
76
+ const mcpHint = mcp && MCP_PROFILE_HINTS[mcp] ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}` : "";
77
+ // HANDOFF 지시는 프롬프트에 삽입하지 않음 headless 후처리(processHandoff)에서 처리
78
+ // psmux send-keys 줄바꿈 문제 + codex exec "---" 인자 충돌 방지
79
+ const fullPrompt = `${prompt}${mcpHint}`;
84
80
  const escaped = fullPrompt.replace(/'/g, "''");
85
- // Clear-Host: 실행 즉시 이전 PS 프롬프트 + 명령 텍스트를 깨끗이 지움
86
81
  const cls = "Clear-Host; ";
87
82
 
88
83
  switch (resolvedCli) {
@@ -131,6 +126,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
131
126
  onProgress,
132
127
  progressIntervalSec = 0,
133
128
  progressive = true,
129
+ dashboard = false,
134
130
  } = opts;
135
131
 
136
132
  mkdirSync(RESULT_DIR, { recursive: true });
@@ -148,6 +144,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
148
144
  applyTrifluxTheme(sessionName);
149
145
  if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
150
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
+ }
157
+
151
158
  dispatches = assignments.map((assignment, i) => {
152
159
  const paneName = `worker-${i + 1}`;
153
160
  const resolvedCli = resolveCliType(assignment.cli);
@@ -157,8 +164,8 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
157
164
  : `${brand.emoji} ${resolvedCli}-${i + 1}`;
158
165
 
159
166
  let newPaneId;
160
- if (i === 0) {
161
- // 첫 번째 워커: 빈 lead pane 직접 사용 (빈 pane 제거)
167
+ if (i === 0 && !dashboard) {
168
+ // 대시보드 없으면: 첫 번째 워커가 빈 lead pane 사용
162
169
  newPaneId = `${sessionName}:0.0`;
163
170
  } else {
164
171
  // 2번째+: split-window로 추가
@@ -502,6 +509,40 @@ export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
502
509
  return true;
503
510
  }
504
511
 
512
+ /**
513
+ * v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
514
+ * @param {string} sessionName
515
+ * @param {number} workerCount
516
+ * @returns {boolean}
517
+ */
518
+ export function attachDashboardTab(sessionName, workerCount = 2) {
519
+ try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
520
+ ensureWtProfile(workerCount);
521
+
522
+ try {
523
+ // psmux attach로 전체 세션을 WT 탭에 표시
524
+ const child = spawn("wt.exe", [
525
+ "-w", "0", "nt",
526
+ "--profile", "triflux",
527
+ "--title", `▲ ${sessionName}`,
528
+ "--", "psmux", "attach", "-t", sessionName,
529
+ ], { detached: true, stdio: "ignore" });
530
+ child.unref();
531
+ } catch { return false; }
532
+
533
+ // 150ms 후 이전 탭으로 복귀
534
+ const prevTabScript = "Start-Sleep -Milliseconds 150; Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('^+{TAB}')";
535
+ for (const shell of ["pwsh.exe", "powershell.exe"]) {
536
+ try {
537
+ spawn(shell, ["-NoProfile", "-NonInteractive", "-Command", prevTabScript], {
538
+ detached: true, stdio: "ignore",
539
+ }).unref();
540
+ break;
541
+ } catch { /* 다음 shell */ }
542
+ }
543
+ return true;
544
+ }
545
+
505
546
  /**
506
547
  * 모든 워커 pane의 현재 스냅샷을 수집한다.
507
548
  *
@@ -552,19 +593,27 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
552
593
  export async function runHeadlessInteractive(sessionName, assignments, opts = {}) {
553
594
  const {
554
595
  autoAttach = false,
596
+ dashboard = false,
555
597
  signal,
556
598
  maxIdleSec = 0,
557
599
  ...runOpts
558
600
  } = opts;
559
601
 
602
+ // dashboard 옵션을 runHeadless에 전달
603
+ if (dashboard) runOpts.dashboard = true;
604
+
560
605
  // autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
561
606
  const userOnProgress = runOpts.onProgress;
562
607
  let terminalAttached = false;
563
608
  runOpts.onProgress = (event) => {
564
609
  if (autoAttach && event.type === "session_created" && !terminalAttached) {
565
610
  terminalAttached = true;
566
- // v6.0.20: 항상 별도 창 (포커스 문제 회피)
567
- autoAttachTerminal(sessionName, { mode: "window" }, assignments.length);
611
+ if (dashboard) {
612
+ // v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
613
+ attachDashboardTab(sessionName, assignments.length);
614
+ } else {
615
+ autoAttachTerminal(sessionName, { mode: "window" }, assignments.length);
616
+ }
568
617
  }
569
618
  if (userOnProgress) userOnProgress(event);
570
619
  };
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // hub/team/tui-viewer.mjs — psmux pane용 TUI 대시보드 뷰어
3
+ // 독립 프로세스로 실행되어 headless 워커 상태를 실시간 렌더링한다.
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 (워커 진행 상황)
9
+
10
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { createTui } from "./tui.mjs";
14
+ import { processHandoff } from "./handoff.mjs";
15
+
16
+ // ── 인자 파싱 ──
17
+ const args = process.argv.slice(2);
18
+ const sessionIdx = args.indexOf("--session");
19
+ const resultDirIdx = args.indexOf("--result-dir");
20
+ const SESSION = sessionIdx >= 0 ? args[sessionIdx + 1] : null;
21
+ const RESULT_DIR = resultDirIdx >= 0 ? args[resultDirIdx + 1] : join(tmpdir(), "tfx-headless");
22
+
23
+ if (!SESSION) {
24
+ process.stderr.write("Usage: node tui-viewer.mjs --session <name>\n");
25
+ process.exit(1);
26
+ }
27
+
28
+ // ── TUI 초기화 ──
29
+ const tui = createTui({ refreshMs: 0 }); // 수동 렌더 (폴링 주기에 맞춤)
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
+ }
51
+
52
+ function pollWorkers() {
53
+ discoverWorkers();
54
+
55
+ for (const [paneName, state] of knownWorkers) {
56
+ if (state.done) continue;
57
+
58
+ let size = 0;
59
+ try { size = statSync(state.resultFile).size; } catch { continue; }
60
+
61
+ if (size === state.lastSize) continue;
62
+ state.lastSize = size;
63
+
64
+ // 결과 파일 읽기
65
+ let content = "";
66
+ try { content = readFileSync(state.resultFile, "utf8"); } catch { continue; }
67
+
68
+ if (content.trim().length === 0) continue;
69
+
70
+ // handoff 파싱 시도
71
+ const result = processHandoff(content, { exitCode: 0, resultFile: state.resultFile });
72
+
73
+ if (result.handoff && !result.fallback) {
74
+ // handoff 블록 발견 → 완료
75
+ state.done = true;
76
+ tui.updateWorker(paneName, {
77
+ status: result.handoff.status === "failed" ? "failed" : "completed",
78
+ handoff: result.handoff,
79
+ elapsed: Math.round((Date.now() - startTime) / 1000),
80
+ });
81
+ } else {
82
+ // 아직 진행 중 — 마지막 줄을 스냅샷으로
83
+ const lastLine = content.trim().split("\n").filter(l => l.trim()).pop() || "";
84
+ tui.updateWorker(paneName, {
85
+ status: "running",
86
+ snapshot: lastLine.slice(0, 60),
87
+ elapsed: Math.round((Date.now() - startTime) / 1000),
88
+ });
89
+ }
90
+ }
91
+
92
+ tui.render();
93
+ }
94
+
95
+ // ── 메인 루프 ──
96
+ const startTime = Date.now();
97
+ tui.setStartTime(startTime);
98
+
99
+ // 초기 렌더
100
+ tui.render();
101
+
102
+ const timer = setInterval(pollWorkers, POLL_MS);
103
+
104
+ // 모든 워커 완료 감지 → 10초 후 자동 종료
105
+ let doneCheckTimer = setInterval(() => {
106
+ if (knownWorkers.size > 0 && [...knownWorkers.values()].every(w => w.done)) {
107
+ tui.render(); // 최종 렌더
108
+ setTimeout(() => {
109
+ tui.close();
110
+ clearInterval(timer);
111
+ clearInterval(doneCheckTimer);
112
+ process.exit(0);
113
+ }, 10000); // 10초 유지 후 종료
114
+ clearInterval(doneCheckTimer);
115
+ }
116
+ }, 2000);
117
+
118
+ // Ctrl+C 정리
119
+ process.on("SIGINT", () => {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "7.0.2",
3
+ "version": "7.0.4",
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": {