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:
|
|
26
|
-
onProgress: function onProgress(event) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
const fullPrompt =
|
|
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
|
-
// 첫 번째
|
|
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
|
-
|
|
567
|
-
|
|
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);
|