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 CHANGED
@@ -127,8 +127,13 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
127
127
  },
128
128
  },
129
129
  multi: {
130
- usage: "tfx multi <subcommand>",
130
+ usage: "tfx multi [--dashboard-layout single|split-2col|split-3col|auto] <subcommand|task>",
131
131
  description: "멀티-CLI 팀 모드",
132
+ options: [
133
+ { name: "--dashboard", type: "boolean", description: "headless dashboard viewer 표시 (기본값: 켜짐)" },
134
+ { name: "--no-dashboard", type: "boolean", description: "headless dashboard viewer 비활성화" },
135
+ { name: "--dashboard-layout", type: "string", description: "dashboard viewer 레이아웃 선택: single|split-2col|split-3col|auto" },
136
+ ],
132
137
  subcommands: {
133
138
  status: {
134
139
  usage: "tfx multi status [--json]",
@@ -1372,6 +1377,40 @@ async function cmdDoctor(options = {}) {
1372
1377
  }
1373
1378
  }
1374
1379
 
1380
+ // 12.5. 고아 node.exe 프로세스 정리 (Windows)
1381
+ section("Orphan Processes");
1382
+ if (process.platform === "win32") {
1383
+ try {
1384
+ const { cleanupOrphanNodeProcesses } = await import("../hub/lib/process-utils.mjs");
1385
+ if (fix) {
1386
+ const { killed, remaining } = cleanupOrphanNodeProcesses();
1387
+ if (killed > 0) {
1388
+ warn(`고아 node.exe ${killed}개 정리 완료 (남은 프로세스: ${remaining})`);
1389
+ } else {
1390
+ ok(`고아 node.exe 없음 (활성: ${remaining})`);
1391
+ }
1392
+ } else {
1393
+ // --fix 없이는 개수만 보고
1394
+ const { execSync: execSyncDoctor } = await import("node:child_process");
1395
+ const countStr = execSyncDoctor(
1396
+ `powershell -NoProfile -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
1397
+ { encoding: "utf8", timeout: 5000 },
1398
+ ).trim();
1399
+ const count = Number.parseInt(countStr, 10) || 0;
1400
+ if (count > 20) {
1401
+ warn(`node.exe ${count}개 실행 중 (고아 포함 가능). 정리: tfx doctor --fix`);
1402
+ issues++;
1403
+ } else {
1404
+ ok(`node.exe ${count}개 (정상 범위)`);
1405
+ }
1406
+ }
1407
+ } catch (e) {
1408
+ info(`고아 프로세스 검사 실패: ${e.message}`);
1409
+ }
1410
+ } else {
1411
+ ok("Windows 전용 검사 — 건너뜀");
1412
+ }
1413
+
1375
1414
  // 13. Stale Teams (Claude teams/ + tasks/ 자동 감지)
1376
1415
  section("Stale Teams");
1377
1416
  const teamsDir = join(CLAUDE_DIR, "teams");
@@ -1,6 +1,15 @@
1
1
  // hub/lib/process-utils.mjs
2
2
  // 프로세스 관련 공유 유틸리티
3
3
 
4
+ import { execSync } from "node:child_process";
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const CLEANUP_SCRIPT_DIR = join(tmpdir(), "tfx-process-utils");
10
+ const CLEANUP_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "cleanup-orphans.ps1");
11
+ const TREE_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "get-ancestor-tree.ps1");
12
+
4
13
  /**
5
14
  * 주어진 PID의 프로세스가 살아있는지 확인한다.
6
15
  * EPERM: 프로세스는 존재하지만 signal 권한 없음 → alive
@@ -18,3 +27,117 @@ export function isPidAlive(pid) {
18
27
  return false;
19
28
  }
20
29
  }
30
+
31
+ /**
32
+ * PowerShell 헬퍼 스크립트를 임시 디렉토리에 생성한다.
33
+ * bash의 $_ 이스케이핑 문제를 피하기 위해 -File로 실행.
34
+ */
35
+ function ensureHelperScripts() {
36
+ mkdirSync(CLEANUP_SCRIPT_DIR, { recursive: true });
37
+
38
+ if (!existsSync(TREE_SCRIPT_PATH)) {
39
+ writeFileSync(TREE_SCRIPT_PATH, `
40
+ param([int]$StartPid)
41
+ $p = $StartPid
42
+ for ($i = 0; $i -lt 10; $i++) {
43
+ if ($p -le 0) { break }
44
+ Write-Output $p
45
+ $parent = (Get-CimInstance Win32_Process -Filter "ProcessId=$p" -ErrorAction SilentlyContinue).ParentProcessId
46
+ if ($null -eq $parent -or $parent -le 0) { break }
47
+ $p = $parent
48
+ }
49
+ `, "utf8");
50
+ }
51
+
52
+ if (!existsSync(CLEANUP_SCRIPT_PATH)) {
53
+ writeFileSync(CLEANUP_SCRIPT_PATH, `
54
+ $ErrorActionPreference = 'SilentlyContinue'
55
+ Get-CimInstance Win32_Process -Filter "Name='node.exe'" | ForEach-Object {
56
+ Write-Output "$($_.ProcessId),$($_.ParentProcessId)"
57
+ }
58
+ `, "utf8");
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 부모 프로세스가 죽은 고아 node.exe 프로세스를 정리한다.
64
+ * Windows 전용 — Agent 서브프로세스가 MCP 서버를 남기는 문제 대응.
65
+ *
66
+ * 보호 대상: 현재 프로세스, Hub PID, 살아있는 부모를 가진 프로세스
67
+ * @returns {{ killed: number, remaining: number }}
68
+ */
69
+ export function cleanupOrphanNodeProcesses() {
70
+ if (process.platform !== "win32") return { killed: 0, remaining: 0 };
71
+
72
+ ensureHelperScripts();
73
+
74
+ const myPid = process.pid;
75
+
76
+ // Hub PID 보호
77
+ let hubPid = null;
78
+ try {
79
+ const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
80
+ if (existsSync(hubPidPath)) {
81
+ const hubInfo = JSON.parse(readFileSync(hubPidPath, "utf8"));
82
+ hubPid = Number(hubInfo?.pid);
83
+ }
84
+ } catch {}
85
+
86
+ // 보호 PID 세트: 현재 프로세스 + Hub + 현재 프로세스의 조상 트리
87
+ const protectedPids = new Set();
88
+ protectedPids.add(myPid);
89
+ if (Number.isFinite(hubPid) && hubPid > 0) protectedPids.add(hubPid);
90
+
91
+ try {
92
+ // 현재 프로세스의 조상 트리를 보호 목록에 추가
93
+ const treeOutput = execSync(
94
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "${TREE_SCRIPT_PATH}" -StartPid ${myPid}`,
95
+ { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
96
+ );
97
+ for (const line of treeOutput.split(/\r?\n/)) {
98
+ const pid = Number.parseInt(line.trim(), 10);
99
+ if (Number.isFinite(pid) && pid > 0) protectedPids.add(pid);
100
+ }
101
+ } catch {}
102
+
103
+ let killed = 0;
104
+ try {
105
+ // 부모가 죽은 고아 node.exe 찾기 — PS 스크립트로 실행 (bash $_ 이스케이핑 회피)
106
+ const output = execSync(
107
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "${CLEANUP_SCRIPT_PATH}"`,
108
+ { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"] },
109
+ );
110
+
111
+ for (const line of output.split(/\r?\n/)) {
112
+ const trimmed = line.trim();
113
+ if (!trimmed) continue;
114
+ const [pidStr, ppidStr] = trimmed.split(",");
115
+ const pid = Number.parseInt(pidStr, 10);
116
+ const ppid = Number.parseInt(ppidStr, 10);
117
+
118
+ if (!Number.isFinite(pid) || pid <= 0) continue;
119
+ if (protectedPids.has(pid)) continue;
120
+
121
+ // 부모가 살아있으면 건드리지 않음
122
+ if (Number.isFinite(ppid) && ppid > 0 && isPidAlive(ppid)) continue;
123
+
124
+ // 고아 프로세스 종료
125
+ try {
126
+ process.kill(pid, "SIGTERM");
127
+ killed++;
128
+ } catch {}
129
+ }
130
+ } catch {}
131
+
132
+ // 남은 프로세스 수 확인
133
+ let remaining = 0;
134
+ try {
135
+ const countOutput = execSync(
136
+ `powershell -NoProfile -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
137
+ { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
138
+ );
139
+ remaining = Number.parseInt(countOutput.trim(), 10) || 0;
140
+ } catch {}
141
+
142
+ return { killed, remaining };
143
+ }
package/hub/server.mjs CHANGED
@@ -18,6 +18,7 @@ import { createAssignCallbackServer } from './assign-callbacks.mjs';
18
18
  import { createTools } from './tools.mjs';
19
19
  import { DelegatorService } from './delegator/index.mjs';
20
20
  import { createDelegatorMcpWorker } from './workers/delegator-mcp.mjs';
21
+ import { cleanupOrphanNodeProcesses } from './lib/process-utils.mjs';
21
22
  import { createModuleLogger } from '../scripts/lib/logger.mjs';
22
23
  import { wrapRequestHandler } from './middleware/request-logger.mjs';
23
24
 
@@ -778,6 +779,18 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
778
779
  }, 60000);
779
780
  sessionTimer.unref();
780
781
 
782
+ // 고아 node.exe 프로세스 주기적 정리 (5분마다)
783
+ // Agent 서브프로세스가 MCP 서버를 남기는 Windows 이슈 대응
784
+ const orphanCleanupTimer = setInterval(() => {
785
+ try {
786
+ const { killed } = cleanupOrphanNodeProcesses();
787
+ if (killed > 0) {
788
+ hubLog.info({ killed }, 'hub.orphan_cleanup');
789
+ }
790
+ } catch {}
791
+ }, 5 * 60 * 1000);
792
+ orphanCleanupTimer.unref();
793
+
781
794
  // Evict stale rate-limit buckets once per minute to bound memory usage.
782
795
  const rateLimitTimer = setInterval(() => {
783
796
  const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
@@ -793,6 +806,32 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
793
806
  rateLimitTimer.unref();
794
807
 
795
808
  mkdirSync(PID_DIR, { recursive: true });
809
+
810
+ // Stale PID 파일 정리 — 이전 Hub 프로세스가 비정상 종료된 경우
811
+ if (existsSync(PID_FILE)) {
812
+ try {
813
+ const prevInfo = JSON.parse(readFileSync(PID_FILE, 'utf8'));
814
+ const prevPid = Number(prevInfo?.pid);
815
+ if (Number.isFinite(prevPid) && prevPid > 0) {
816
+ try {
817
+ process.kill(prevPid, 0); // alive 체크만
818
+ // 프로세스가 살아있으면 포트 충돌 가능성 — 기존 Hub 재사용 안내
819
+ if (Number(prevInfo.port) === Number(port)) {
820
+ hubLog.warn({ prevPid, port }, 'hub.stale_pid: previous hub still alive on same port');
821
+ }
822
+ } catch {
823
+ // 프로세스 죽음 → stale PID 파일 삭제
824
+ try { unlinkSync(PID_FILE); } catch {}
825
+ hubLog.info({ prevPid }, 'hub.stale_pid_cleaned');
826
+ }
827
+ } else {
828
+ try { unlinkSync(PID_FILE); } catch {}
829
+ }
830
+ } catch {
831
+ try { unlinkSync(PID_FILE); } catch {}
832
+ }
833
+ }
834
+
796
835
  await pipe.start();
797
836
  await assignCallbacks.start();
798
837
 
@@ -832,6 +871,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
832
871
  clearInterval(hitlTimer);
833
872
  clearInterval(sessionTimer);
834
873
  clearInterval(rateLimitTimer);
874
+ clearInterval(orphanCleanupTimer);
835
875
  for (const [, session] of transports) {
836
876
  try { await session.mcp.close(); } catch {}
837
877
  try { await session.transport.close(); } catch {}
@@ -860,7 +900,14 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
860
900
  stop: stopFn,
861
901
  });
862
902
  });
863
- httpServer.on('error', reject);
903
+ httpServer.on('error', (err) => {
904
+ if (err.code === 'EADDRINUSE') {
905
+ hubLog.error({ port, host }, 'hub.port_in_use: port already occupied — check for existing hub or other service');
906
+ reject(new Error(`Hub 포트 ${port}이(가) 이미 사용 중입니다. 기존 Hub 프로세스를 확인하세요. (PID file: ${PID_FILE})`));
907
+ } else {
908
+ reject(err);
909
+ }
910
+ });
864
911
  });
865
912
  }
866
913
 
package/hub/team/ansi.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  // hub/team/ansi.mjs — Zero-dependency ANSI escape 유틸리티
2
2
  // TUI 대시보드 렌더링을 위한 최소 헬퍼.
3
+ //
4
+ // wcwidth 지원: emoji/CJK wide=2셀, combining mark=0셀, ANSI escape=0셀
5
+ // 외부 의존성 없이 Unicode 범위 기반으로 구현.
3
6
 
4
7
  export const ESC = "\x1b";
5
8
 
@@ -65,44 +68,183 @@ export function dim(text) { return `${DIM}${text}${RESET}`; }
65
68
  // ── 박스 그리기 (유니코드 테두리) ──
66
69
  const BOX = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│", ml: "├", mr: "┤" };
67
70
 
68
- export function box(lines, width) {
69
- const top = `${BOX.tl}${BOX.h.repeat(width - 2)}${BOX.tr}`;
70
- const bot = `${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}`;
71
- const mid = `${BOX.ml}${BOX.h.repeat(width - 2)}${BOX.mr}`;
72
- const body = lines.map((l) => `${BOX.v} ${padRight(l, width - 4)} ${BOX.v}`);
71
+ export function box(lines, width, borderColor = "") {
72
+ const bc = borderColor;
73
+ const rst = bc ? RESET : "";
74
+ const top = `${bc}${BOX.tl}${BOX.h.repeat(width - 2)}${BOX.tr}${rst}`;
75
+ const bot = `${bc}${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}${rst}`;
76
+ const mid = `${bc}${BOX.ml}${BOX.h.repeat(width - 2)}${BOX.mr}${rst}`;
77
+ const body = lines.map((l) => `${bc}${BOX.v}${rst} ${padRight(l, width - 4)} ${bc}${BOX.v}${rst}`);
73
78
  return { top, body, bot, mid };
74
79
  }
75
80
 
81
+ // ── wcwidth 구현 (외부 의존성 없음) ──
82
+ // Unicode 코드포인트의 터미널 표시 너비를 반환: 0(combining), 1(일반), 2(wide)
83
+ function charWidth(cp) {
84
+ // combining / zero-width 범위
85
+ if (
86
+ cp === 0 || cp === 0xAD ||
87
+ (cp >= 0x0300 && cp <= 0x036F) || // Combining Diacritical Marks
88
+ (cp >= 0x0610 && cp <= 0x061A) ||
89
+ (cp >= 0x064B && cp <= 0x065F) ||
90
+ (cp >= 0x1AB0 && cp <= 0x1AFF) ||
91
+ (cp >= 0x1DC0 && cp <= 0x1DFF) ||
92
+ (cp >= 0x20D0 && cp <= 0x20FF) || // Combining Diacritical Marks for Symbols
93
+ (cp >= 0xFE20 && cp <= 0xFE2F) // Combining Half Marks
94
+ ) return 0;
95
+
96
+ // Wide: CJK Unified Ideographs, Hangul, Fullwidth, emoji 주요 블록
97
+ if (
98
+ (cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
99
+ (cp >= 0x2E80 && cp <= 0x2EFF) || // CJK Radicals Supplement
100
+ (cp >= 0x2F00 && cp <= 0x2FFF) ||
101
+ (cp >= 0x3000 && cp <= 0x303F) || // CJK Symbols and Punctuation
102
+ (cp >= 0x3040 && cp <= 0x309F) || // Hiragana
103
+ (cp >= 0x30A0 && cp <= 0x30FF) || // Katakana
104
+ (cp >= 0x3100 && cp <= 0x312F) ||
105
+ (cp >= 0x3130 && cp <= 0x318F) || // Hangul Compatibility Jamo
106
+ (cp >= 0x3190 && cp <= 0x319F) ||
107
+ (cp >= 0x31C0 && cp <= 0x31EF) ||
108
+ (cp >= 0x3200 && cp <= 0x32FF) ||
109
+ (cp >= 0x3300 && cp <= 0x33FF) ||
110
+ (cp >= 0x3400 && cp <= 0x4DBF) ||
111
+ (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
112
+ (cp >= 0xA000 && cp <= 0xA48F) ||
113
+ (cp >= 0xA490 && cp <= 0xA4CF) ||
114
+ (cp >= 0xA960 && cp <= 0xA97F) ||
115
+ (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
116
+ (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
117
+ (cp >= 0xFE10 && cp <= 0xFE1F) ||
118
+ (cp >= 0xFE30 && cp <= 0xFE4F) ||
119
+ (cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Forms
120
+ (cp >= 0xFFE0 && cp <= 0xFFE6) ||
121
+ (cp >= 0x1B000 && cp <= 0x1B0FF) ||
122
+ (cp >= 0x1F004 && cp <= 0x1F0CF) ||
123
+ (cp >= 0x1F200 && cp <= 0x1F2FF) ||
124
+ (cp >= 0x1F300 && cp <= 0x1F64F) || // Misc Symbols, Emoticons
125
+ (cp >= 0x1F680 && cp <= 0x1F6FF) || // Transport & Map
126
+ (cp >= 0x1F900 && cp <= 0x1FAFF) || // Supplemental Symbols
127
+ (cp >= 0x20000 && cp <= 0x2FFFD) ||
128
+ (cp >= 0x30000 && cp <= 0x3FFFD)
129
+ ) return 2;
130
+
131
+ return 1;
132
+ }
133
+
134
+ // 문자열의 터미널 표시 너비 계산 (ANSI escape 제외, wcwidth 적용)
135
+ export function wcswidth(str) {
136
+ const plain = stripAnsi(str);
137
+ let width = 0;
138
+ for (const char of plain) {
139
+ width += charWidth(char.codePointAt(0));
140
+ }
141
+ return width;
142
+ }
143
+
76
144
  // ── 텍스트 유틸 ──
145
+ export function stripAnsi(str) {
146
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(\x07|\x1b\\)/g, "");
147
+ }
148
+
149
+ // wcwidth-aware padRight: ANSI + wide char 보정 포함
77
150
  export function padRight(str, len) {
78
- // ANSI 코드 제외한 실제 표시 길이 기준 패딩
79
- const visible = stripAnsi(str);
80
- const pad = Math.max(0, len - visible.length);
151
+ const w = wcswidth(str);
152
+ const pad = Math.max(0, len - w);
81
153
  return str + " ".repeat(pad);
82
154
  }
83
155
 
156
+ // wcwidth-aware truncate: wide char 경계에서 자름
84
157
  export function truncate(str, maxLen) {
85
- const visible = stripAnsi(str);
86
- if (visible.length <= maxLen) return str;
87
- return visible.slice(0, maxLen - 1) + "…";
158
+ const plain = stripAnsi(str);
159
+ const w = wcswidth(plain);
160
+ if (w <= maxLen) return str;
161
+
162
+ let acc = 0;
163
+ let i = 0;
164
+ for (const char of plain) {
165
+ const cw = charWidth(char.codePointAt(0));
166
+ if (acc + cw > maxLen - 1) break;
167
+ acc += cw;
168
+ i += char.length;
169
+ }
170
+ return plain.slice(0, i) + "…";
88
171
  }
89
172
 
90
- export function stripAnsi(str) {
91
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(\x07|\x1b\\)/g, "");
173
+ // wcwidth-aware clip: 정확히 width 셀에 맞게 자르고 패딩 (wide char 경계 보정)
174
+ export function clip(str, width) {
175
+ const plain = stripAnsi(str);
176
+ let acc = 0;
177
+ let i = 0;
178
+ for (const char of plain) {
179
+ const cw = charWidth(char.codePointAt(0));
180
+ if (acc + cw > width) {
181
+ // wide char이 경계를 넘으면 공백으로 채움
182
+ const result = plain.slice(0, i) + " ".repeat(width - acc);
183
+ return result;
184
+ }
185
+ acc += cw;
186
+ i += char.length;
187
+ }
188
+ return plain + " ".repeat(width - acc);
189
+ }
190
+
191
+ // ── Catppuccin Mocha 색상 상수 ──
192
+ export const MOCHA = {
193
+ ok: `${ESC}[38;5;114m`, // #a6e3a1 green
194
+ partial: `${ESC}[38;5;216m`, // #fab387 peach
195
+ fail: `${ESC}[38;5;210m`, // #f38ba8 red
196
+ thinking: `${ESC}[38;5;183m`, // #cba6f7 mauve
197
+ executing: `${ESC}[38;5;117m`, // #74c7ec sky
198
+ border: `${ESC}[38;5;238m`, // #45475a surface1
199
+ text: `${ESC}[38;2;205;214;244m`, // #cdd6f4 catppuccin text
200
+ subtext: `${ESC}[38;2;166;173;200m`, // #a6adc8 subtext0
201
+ overlay: `${ESC}[38;2;108;112;134m`, // #6c7086 overlay0
202
+ blue: `${ESC}[38;2;137;180;250m`, // #89b4fa blue
203
+ yellow: `${ESC}[38;2;249;226;175m`, // #f9e2af yellow
204
+ red: `${ESC}[38;2;243;139;168m`, // #f38ba8 red (truecolor)
205
+ surface0: `${ESC}[38;2;49;50;68m`, // #313244 surface0
206
+ };
207
+
208
+ // ── badge 헬퍼 ──
209
+ // statusBadge(status) → ANSI 색상 문자열
210
+ export function statusBadge(status) {
211
+ switch (status) {
212
+ case "ok":
213
+ case "completed":
214
+ case "done":
215
+ return `${MOCHA.ok}✓ ${status}${RESET}`;
216
+ case "partial":
217
+ case "in_progress":
218
+ case "running":
219
+ return `${MOCHA.partial}◑ ${status}${RESET}`;
220
+ case "fail":
221
+ case "failed":
222
+ case "error":
223
+ return `${MOCHA.fail}✗ ${status}${RESET}`;
224
+ case "thinking":
225
+ return `${MOCHA.thinking}⠿ ${status}${RESET}`;
226
+ case "executing":
227
+ return `${MOCHA.executing}▶ ${status}${RESET}`;
228
+ default:
229
+ return `${FG.muted}· ${status}${RESET}`;
230
+ }
92
231
  }
93
232
 
94
233
  // ── 진행률 바 ──
95
- export function progressBar(ratio, width = 20) {
96
- const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
234
+ // progressBar(percent, width) percent: 0~100, ANSI colored bar string 반환
235
+ export function progressBar(percent, width = 20) {
236
+ const ratio = Math.max(0, Math.min(100, percent)) / 100;
237
+ const filled = Math.round(ratio * width);
97
238
  const empty = width - filled;
98
- return `${FG.accent}${"█".repeat(filled)}${FG.muted}${"░".repeat(empty)}${RESET}`;
239
+ const fillColor = percent >= 100 ? MOCHA.ok : percent >= 50 ? MOCHA.partial : MOCHA.fail;
240
+ return `${fillColor}${"█".repeat(filled)}${MOCHA.border}${"░".repeat(empty)}${RESET}`;
99
241
  }
100
242
 
101
243
  // ── 상태 아이콘 ──
102
244
  export const STATUS_ICON = {
103
- running: `${FG.blue}⏳${RESET}`,
104
- completed: `${FG.green}✓${RESET}`,
105
- failed: `${FG.red}✗${RESET}`,
245
+ running: `${MOCHA.partial}⏳${RESET}`,
246
+ completed: `${MOCHA.ok}✓${RESET}`,
247
+ failed: `${MOCHA.fail}✗${RESET}`,
106
248
  pending: `${FG.gray}⏸${RESET}`,
107
249
  };
108
250
 
@@ -32,7 +32,7 @@ export class GeminiBackend {
32
32
  command() { return "gemini"; }
33
33
 
34
34
  buildArgs(prompt, resultFile, opts = {}) {
35
- return `gemini --prompt ${prompt} --output text > '${resultFile}' 2>'${resultFile}.err'`;
35
+ return `gemini -p ${prompt} --output-format text > '${resultFile}' 2>'${resultFile}.err'`;
36
36
  }
37
37
 
38
38
  env() { return {}; }
@@ -16,6 +16,7 @@ function printStartUsage() {
16
16
  console.log(` 사용법: ${WHITE}tfx multi "작업 설명"${RESET}`);
17
17
  console.log(` ${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}`);
18
18
  console.log(` ${WHITE}tfx multi --teammate-mode headless "작업"${RESET} ${DIM}(psmux 헤드리스, 기본)${RESET}`);
19
+ console.log(` ${WHITE}tfx multi --dashboard-layout auto "작업"${RESET} ${DIM}(dashboard viewer 레이아웃 자동)${RESET}`);
19
20
  console.log(` ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
20
21
  console.log(` ${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(mux 불필요)${RESET}\n`);
21
22
  }
@@ -38,7 +39,7 @@ function renderTmuxInstallHelp() {
38
39
  export { parseTeamArgs };
39
40
 
40
41
  export async function teamStart(args = []) {
41
- const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile, model } = parseTeamArgs(args);
42
+ const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, dashboardLayout, dashboardSize, mcpProfile, model } = parseTeamArgs(args);
42
43
  // --assign 사용 시 task를 자동 생성
43
44
  const task = rawTask || (assigns.length > 0 ? assigns.map(a => a.prompt).join(" + ") : "");
44
45
  if (!task) return printStartUsage();
@@ -82,7 +83,7 @@ export async function teamStart(args = []) {
82
83
  const state = effectiveMode === "in-process"
83
84
  ? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
84
85
  : effectiveMode === "headless"
85
- ? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile, model })
86
+ ? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, dashboardLayout, dashboardSize, mcpProfile, model })
86
87
  : effectiveMode === "wt"
87
88
  ? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
88
89
  : await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
@@ -1,4 +1,5 @@
1
1
  import { normalizeLayout, normalizeTeammateMode } from "../../services/runtime-mode.mjs";
2
+ import { parseDashboardLayout } from "../../../dashboard-layout.mjs";
2
3
 
3
4
  // --assign 파싱 시 마지막 콜론 뒤를 role로 인식할 알려진 역할/CLI 이름
4
5
  const KNOWN_ROLES = new Set([
@@ -47,6 +48,8 @@ export function parseTeamArgs(args = []) {
47
48
  let timeoutSec = 300;
48
49
  let verbose = false;
49
50
  let dashboard = true;
51
+ let dashboardLayout = "single";
52
+ let dashboardSize = 0.40;
50
53
  let mcpProfile = "";
51
54
  let model = "";
52
55
 
@@ -73,6 +76,10 @@ export function parseTeamArgs(args = []) {
73
76
  dashboard = true;
74
77
  } else if (current === "--no-dashboard") {
75
78
  dashboard = false;
79
+ } else if (current === "--dashboard-layout" && args[index + 1]) {
80
+ dashboardLayout = parseDashboardLayout(args[++index]);
81
+ } else if (current === "--dashboard-size" && args[index + 1]) {
82
+ dashboardSize = Math.min(0.8, Math.max(0.2, parseFloat(args[++index]) || 0.50));
76
83
  } else if (current === "--no-progressive") {
77
84
  progressive = false;
78
85
  } else if (current === "--timeout" && args[index + 1]) {
@@ -100,6 +107,8 @@ export function parseTeamArgs(args = []) {
100
107
  timeoutSec,
101
108
  verbose,
102
109
  dashboard,
110
+ dashboardLayout,
111
+ dashboardSize,
103
112
  mcpProfile,
104
113
  model,
105
114
  };
@@ -4,7 +4,7 @@ import { ok, warn } from "../../render.mjs";
4
4
  import { buildTasks } from "../../services/task-model.mjs";
5
5
  import { clearTeamState } from "../../services/state-store.mjs";
6
6
 
7
- export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile, model }) {
7
+ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, dashboardLayout, dashboardSize, mcpProfile, model }) {
8
8
  // --assign이 있으면 그것을 사용, 없으면 agents+subtasks 조합
9
9
  const assignments = assigns && assigns.length > 0
10
10
  ? assigns.map((a, i) => ({ cli: resolveCliType(a.cli), prompt: a.prompt, role: a.role || `worker-${i + 1}`, mcp: mcpProfile, model }))
@@ -18,6 +18,8 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
18
18
  layout,
19
19
  autoAttach: !!autoAttach,
20
20
  dashboard: !!dashboard,
21
+ dashboardLayout,
22
+ dashboardSize: dashboardSize ?? 0.50,
21
23
  progressive: progressive !== false,
22
24
  progressIntervalSec: verbose ? 10 : 0,
23
25
  onProgress: verbose ? function onProgress(event) {
@@ -73,8 +75,9 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
73
75
  }
74
76
  }
75
77
 
76
- // dashboard 모드: tui-viewer가 최종 상태를 캡처할 시간 확보
77
- if (dashboard) await new Promise(r => setTimeout(r, 2000));
78
+ // dashboard 모드: tui-viewer가 최종 상태를 렌더링할 시간 확보
79
+ // WT pane spawn (~1s) + node 기동 (~500ms) + 첫 폴링 (~500ms) + 렌더 여유
80
+ if (dashboard) await new Promise(r => setTimeout(r, 5000));
78
81
 
79
82
  // 세션 정리
80
83
  handle.kill();
@@ -11,6 +11,8 @@ export function renderTeamHelp() {
11
11
  ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}
12
12
  ${WHITE}tfx multi --layout 1xN "작업"${RESET} ${DIM}(세로 분할 컬럼)${RESET}
13
13
  ${WHITE}tfx multi --layout Nx1 "작업"${RESET} ${DIM}(가로 분할 스택)${RESET}
14
+ ${WHITE}tfx multi --dashboard-layout auto "작업"${RESET} ${DIM}(dashboard viewer 레이아웃 자동 결정)${RESET}
15
+ ${WHITE}tfx multi --dashboard-size 0.4 "작업"${RESET} ${DIM}(대시보드 분할 비율 0.2~0.8, 기본 0.50)${RESET}
14
16
  ${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(tmux 불필요)${RESET}
15
17
 
16
18
  ${BOLD}제어${RESET}
@@ -0,0 +1,31 @@
1
+ const USER_DASHBOARD_LAYOUTS = new Set([
2
+ "single",
3
+ "split-2col",
4
+ "split-3col",
5
+ "auto",
6
+ ]);
7
+
8
+ const DASHBOARD_LAYOUTS = new Set([
9
+ ...USER_DASHBOARD_LAYOUTS,
10
+ "summary+detail",
11
+ ]);
12
+
13
+ export function normalizeDashboardLayout(value, { allowAuto = true } = {}) {
14
+ const normalized = String(value ?? "").trim().toLowerCase();
15
+ if (!normalized) return "single";
16
+ if (normalized === "auto" && !allowAuto) return "single";
17
+ return DASHBOARD_LAYOUTS.has(normalized) ? normalized : "single";
18
+ }
19
+
20
+ export function parseDashboardLayout(value) {
21
+ return normalizeDashboardLayout(value, { allowAuto: true });
22
+ }
23
+
24
+ export function resolveDashboardLayout(value, workerCount = 0) {
25
+ const normalized = normalizeDashboardLayout(value, { allowAuto: true });
26
+ if (normalized !== "auto") return normalized;
27
+ if (workerCount >= 4) return "summary+detail";
28
+ if (workerCount === 3) return "split-3col";
29
+ if (workerCount === 2) return "split-2col";
30
+ return "single";
31
+ }