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 +40 -1
- package/hub/lib/process-utils.mjs +123 -0
- package/hub/server.mjs +48 -1
- package/hub/team/ansi.mjs +161 -19
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/commands/start/index.mjs +3 -2
- package/hub/team/cli/commands/start/parse-args.mjs +9 -0
- package/hub/team/cli/commands/start/start-headless.mjs +6 -3
- package/hub/team/cli/help.mjs +2 -0
- package/hub/team/dashboard-layout.mjs +31 -0
- package/hub/team/headless.mjs +146 -33
- package/hub/team/psmux.mjs +174 -7
- package/hub/team/tui-viewer.mjs +354 -90
- package/hub/team/tui.mjs +856 -67
- package/package.json +1 -1
- package/scripts/remote-spawn.mjs +92 -12
- package/scripts/tfx-route.sh +17 -8
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',
|
|
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
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
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
|
-
|
|
79
|
-
const
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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: `${
|
|
104
|
-
completed: `${
|
|
105
|
-
failed: `${
|
|
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
|
|
package/hub/team/backend.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export class GeminiBackend {
|
|
|
32
32
|
command() { return "gemini"; }
|
|
33
33
|
|
|
34
34
|
buildArgs(prompt, resultFile, opts = {}) {
|
|
35
|
-
return `gemini
|
|
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
|
-
|
|
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();
|
package/hub/team/cli/help.mjs
CHANGED
|
@@ -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
|
+
}
|