triflux 5.1.1 → 5.2.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.
- package/hub/team/cli/commands/start/index.mjs +6 -3
- package/hub/team/cli/commands/start/start-headless.mjs +76 -0
- package/hub/team/cli/services/runtime-mode.mjs +1 -0
- package/hub/team/headless.mjs +146 -0
- package/hub/team/psmux.mjs +69 -14
- package/package.json +1 -1
- package/scripts/lib/mcp-filter.mjs +12 -2
- package/scripts/tfx-route.sh +47 -24
- package/skills/tfx-multi/SKILL.md +29 -3
|
@@ -8,6 +8,7 @@ import { fail, ok, warn } from "../../render.mjs";
|
|
|
8
8
|
import { parseTeamArgs } from "./parse-args.mjs";
|
|
9
9
|
import { startInProcessTeam } from "./start-in-process.mjs";
|
|
10
10
|
import { startMuxTeam } from "./start-mux.mjs";
|
|
11
|
+
import { startHeadlessTeam } from "./start-headless.mjs";
|
|
11
12
|
import { startWtTeam } from "./start-wt.mjs";
|
|
12
13
|
|
|
13
14
|
function printStartUsage() {
|
|
@@ -68,9 +69,11 @@ export async function teamStart(args = []) {
|
|
|
68
69
|
|
|
69
70
|
const state = effectiveMode === "in-process"
|
|
70
71
|
? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
|
|
71
|
-
: effectiveMode === "
|
|
72
|
-
? await
|
|
73
|
-
:
|
|
72
|
+
: effectiveMode === "headless"
|
|
73
|
+
? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout })
|
|
74
|
+
: effectiveMode === "wt"
|
|
75
|
+
? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
|
|
76
|
+
: await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
|
|
74
77
|
|
|
75
78
|
if (!state) return fail("in-process supervisor 시작 실패");
|
|
76
79
|
saveTeamState(state);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { BOLD, DIM, GREEN, RESET, AMBER } from "../../../shared.mjs";
|
|
2
|
+
import { runHeadless } from "../../../headless.mjs";
|
|
3
|
+
import { killPsmuxSession } from "../../../psmux.mjs";
|
|
4
|
+
import { ok, warn } from "../../render.mjs";
|
|
5
|
+
import { buildTasks } from "../../services/task-model.mjs";
|
|
6
|
+
|
|
7
|
+
export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout }) {
|
|
8
|
+
console.log(` ${AMBER}모드: headless (psmux 헤드리스 CLI 실행)${RESET}`);
|
|
9
|
+
|
|
10
|
+
const assignments = subtasks.map((subtask, i) => ({
|
|
11
|
+
cli: agents[i],
|
|
12
|
+
prompt: subtask,
|
|
13
|
+
role: `worker-${i + 1}`,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
ok("헤드리스 실행 시작...");
|
|
17
|
+
const { sessionName, results } = await runHeadless(sessionId, assignments, {
|
|
18
|
+
timeoutSec: 300,
|
|
19
|
+
layout,
|
|
20
|
+
onProgress(event) {
|
|
21
|
+
if (event.type === "dispatched") {
|
|
22
|
+
console.log(` ${DIM}[${event.paneName}] ${event.cli} dispatch${RESET}`);
|
|
23
|
+
} else if (event.type === "completed") {
|
|
24
|
+
const icon = event.matched && event.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
|
|
25
|
+
console.log(` ${icon} [${event.paneName}] ${event.cli} exit=${event.exitCode}${event.sessionDead ? " (session dead)" : ""}`);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// 결과 요약
|
|
31
|
+
const succeeded = results.filter((r) => r.matched && r.exitCode === 0);
|
|
32
|
+
const failed = results.filter((r) => !r.matched || r.exitCode !== 0);
|
|
33
|
+
|
|
34
|
+
console.log(`\n ${GREEN}${BOLD}헤드리스 실행 완료${RESET}`);
|
|
35
|
+
console.log(` ${DIM}성공: ${succeeded.length} / 실패: ${failed.length} / 전체: ${results.length}${RESET}`);
|
|
36
|
+
|
|
37
|
+
if (failed.length > 0) {
|
|
38
|
+
warn("실패 워커:");
|
|
39
|
+
for (const r of failed) {
|
|
40
|
+
console.log(` ${r.paneName} (${r.cli}): exit=${r.exitCode}${r.sessionDead ? " session dead" : ""}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 결과 출력 (각 워커의 output 요약)
|
|
45
|
+
for (const r of results) {
|
|
46
|
+
if (r.output) {
|
|
47
|
+
const preview = r.output.length > 200 ? `${r.output.slice(0, 200)}…` : r.output;
|
|
48
|
+
console.log(`\n ${DIM}── ${r.paneName} (${r.cli}) ──${RESET}`);
|
|
49
|
+
console.log(` ${preview}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 세션 정리
|
|
54
|
+
try { killPsmuxSession(sessionName); } catch { /* already cleaned */ }
|
|
55
|
+
|
|
56
|
+
const members = [
|
|
57
|
+
{ role: "lead", name: "lead", cli: lead, pane: `${sessionName}:0.0` },
|
|
58
|
+
...results.map((r, i) => ({ role: "worker", name: r.paneName, cli: r.cli, pane: `${sessionName}:0.${i + 1}`, subtask: subtasks[i] })),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
sessionName,
|
|
63
|
+
task,
|
|
64
|
+
lead,
|
|
65
|
+
agents,
|
|
66
|
+
layout,
|
|
67
|
+
teammateMode: "headless",
|
|
68
|
+
startedAt: Date.now(),
|
|
69
|
+
members,
|
|
70
|
+
headlessResults: results,
|
|
71
|
+
tasks: buildTasks(subtasks, members.filter((m) => m.role === "worker")),
|
|
72
|
+
postSave() {
|
|
73
|
+
console.log(`\n ${DIM}세션 자동 정리 완료. 결과는 위에 표시됨.${RESET}\n`);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
export function normalizeTeammateMode(mode = "auto") {
|
|
9
9
|
const raw = String(mode).toLowerCase();
|
|
10
10
|
if (raw === "inline" || raw === "native") return "in-process";
|
|
11
|
+
if (raw === "headless" || raw === "hl") return "headless";
|
|
11
12
|
if (raw === "in-process" || raw === "tmux" || raw === "wt" || raw === "psmux") return raw;
|
|
12
13
|
if (raw === "windows-terminal" || raw === "windows_terminal") return "wt";
|
|
13
14
|
if (raw === "auto") {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
|
|
2
|
+
// psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
|
|
3
|
+
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import {
|
|
8
|
+
createPsmuxSession,
|
|
9
|
+
killPsmuxSession,
|
|
10
|
+
psmuxSessionExists,
|
|
11
|
+
dispatchCommand,
|
|
12
|
+
waitForCompletion,
|
|
13
|
+
capturePsmuxPane,
|
|
14
|
+
} from "./psmux.mjs";
|
|
15
|
+
|
|
16
|
+
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* CLI별 헤드리스 명령 빌더
|
|
20
|
+
* @param {'codex'|'gemini'|'claude'} cli
|
|
21
|
+
* @param {string} prompt — 실행할 프롬프트
|
|
22
|
+
* @param {string} resultFile — 결과 저장 파일 경로
|
|
23
|
+
* @returns {string} PowerShell 명령
|
|
24
|
+
*/
|
|
25
|
+
export function buildHeadlessCommand(cli, prompt, resultFile) {
|
|
26
|
+
// 프롬프트의 단일 인용부호를 이스케이프
|
|
27
|
+
const escaped = prompt.replace(/'/g, "''");
|
|
28
|
+
|
|
29
|
+
switch (cli) {
|
|
30
|
+
case "codex":
|
|
31
|
+
return `codex exec '${escaped}' -o '${resultFile}' --color never`;
|
|
32
|
+
case "gemini":
|
|
33
|
+
return `gemini -p '${escaped}' -o text > '${resultFile}' 2>&1`;
|
|
34
|
+
case "claude":
|
|
35
|
+
return `claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
|
|
36
|
+
default:
|
|
37
|
+
throw new Error(`지원하지 않는 CLI: ${cli}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 결과 파일 읽기 (없으면 capture-pane fallback)
|
|
43
|
+
* @param {string} resultFile
|
|
44
|
+
* @param {string} sessionName
|
|
45
|
+
* @param {string} paneName
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function readResult(resultFile, paneId) {
|
|
49
|
+
if (existsSync(resultFile)) {
|
|
50
|
+
return readFileSync(resultFile, "utf8").trim();
|
|
51
|
+
}
|
|
52
|
+
// fallback: capture-pane (paneId = "tfx:0.1" 형태)
|
|
53
|
+
return capturePsmuxPane(paneId, 30);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 헤드리스 CLI 오케스트레이션 실행
|
|
58
|
+
*
|
|
59
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
60
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
|
|
63
|
+
* @param {string} [opts.layout='2x2'] — pane 레이아웃
|
|
64
|
+
* @param {(event: object) => void} [opts.onProgress] — 진행 콜백
|
|
65
|
+
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
|
|
66
|
+
*/
|
|
67
|
+
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
68
|
+
const {
|
|
69
|
+
timeoutSec = 300,
|
|
70
|
+
layout = "2x2",
|
|
71
|
+
onProgress,
|
|
72
|
+
} = opts;
|
|
73
|
+
|
|
74
|
+
mkdirSync(RESULT_DIR, { recursive: true });
|
|
75
|
+
const paneCount = assignments.length + 1; // +1 for lead pane (unused but reserved)
|
|
76
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount });
|
|
77
|
+
|
|
78
|
+
if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
79
|
+
|
|
80
|
+
// 각 워커 pane에 헤드리스 명령 dispatch
|
|
81
|
+
const dispatches = assignments.map((assignment, i) => {
|
|
82
|
+
const paneName = `worker-${i + 1}`;
|
|
83
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
84
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
|
|
85
|
+
const dispatch = dispatchCommand(sessionName, paneName, cmd);
|
|
86
|
+
|
|
87
|
+
if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
88
|
+
|
|
89
|
+
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
93
|
+
const results = await Promise.all(dispatches.map(async (d) => {
|
|
94
|
+
const completion = await waitForCompletion(sessionName, d.paneName, d.token, timeoutSec);
|
|
95
|
+
|
|
96
|
+
const output = completion.matched
|
|
97
|
+
? readResult(d.resultFile, d.paneId)
|
|
98
|
+
: "";
|
|
99
|
+
|
|
100
|
+
if (onProgress) {
|
|
101
|
+
onProgress({
|
|
102
|
+
type: "completed",
|
|
103
|
+
paneName: d.paneName,
|
|
104
|
+
cli: d.cli,
|
|
105
|
+
matched: completion.matched,
|
|
106
|
+
exitCode: completion.exitCode,
|
|
107
|
+
sessionDead: completion.sessionDead || false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
cli: d.cli,
|
|
113
|
+
paneName: d.paneName,
|
|
114
|
+
role: d.role,
|
|
115
|
+
matched: completion.matched,
|
|
116
|
+
exitCode: completion.exitCode,
|
|
117
|
+
output,
|
|
118
|
+
sessionDead: completion.sessionDead || false,
|
|
119
|
+
};
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
return { sessionName, results };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 헤드리스 실행 + 자동 정리
|
|
127
|
+
* 성공/실패에 관계없이 세션을 정리한다.
|
|
128
|
+
*
|
|
129
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
130
|
+
* @param {object} [opts] — runHeadless opts + sessionPrefix
|
|
131
|
+
* @returns {{ results: Array, sessionName: string }}
|
|
132
|
+
*/
|
|
133
|
+
export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
134
|
+
const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
|
|
135
|
+
const sessionName = `${sessionPrefix}-${Date.now().toString(36).slice(-6)}`;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return await runHeadless(sessionName, assignments, runOpts);
|
|
139
|
+
} finally {
|
|
140
|
+
try {
|
|
141
|
+
killPsmuxSession(sessionName);
|
|
142
|
+
} catch {
|
|
143
|
+
// 이미 종료된 세션 — 무시
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -37,6 +37,10 @@ function sleepMs(ms) {
|
|
|
37
37
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function sleepMsAsync(ms) {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
function tokenizeCommand(command) {
|
|
41
45
|
const source = String(command || "").trim();
|
|
42
46
|
if (!source) return [];
|
|
@@ -248,14 +252,29 @@ function listPaneDetails(sessionName) {
|
|
|
248
252
|
return parsePaneDetails(output);
|
|
249
253
|
}
|
|
250
254
|
|
|
255
|
+
function paneTitleToIndex(name) {
|
|
256
|
+
const lower = String(name).toLowerCase();
|
|
257
|
+
if (lower === "lead") return 0;
|
|
258
|
+
const m = /^worker-(\d+)$/.exec(lower);
|
|
259
|
+
if (!m) return -1;
|
|
260
|
+
const idx = parseInt(m[1], 10);
|
|
261
|
+
// worker-0은 유효하지 않음 (lead와 충돌, toPaneTitle은 worker-0을 생성하지 않음)
|
|
262
|
+
return idx >= 1 ? idx : -1;
|
|
263
|
+
}
|
|
264
|
+
|
|
251
265
|
function resolvePane(sessionName, paneNameOrTarget) {
|
|
252
266
|
const wanted = String(paneNameOrTarget);
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
267
|
+
const panes = listPaneDetails(sessionName);
|
|
268
|
+
|
|
269
|
+
// 1차: title 또는 paneId 직접 매칭
|
|
270
|
+
const direct = panes.find((entry) => entry.title === wanted || entry.paneId === wanted);
|
|
271
|
+
if (direct) return direct;
|
|
272
|
+
|
|
273
|
+
// 2차: psmux title 미설정 fallback — "lead"→0, "worker-N"→N 인덱스 매칭
|
|
274
|
+
const idx = paneTitleToIndex(wanted);
|
|
275
|
+
if (idx >= 0 && idx < panes.length) return panes[idx];
|
|
276
|
+
|
|
277
|
+
throw new Error(`Pane을 찾을 수 없습니다: ${paneNameOrTarget}`);
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
function refreshCaptureSnapshot(sessionName, paneNameOrTarget) {
|
|
@@ -624,9 +643,27 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
624
643
|
* @param {number} timeoutSec
|
|
625
644
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
|
|
626
645
|
*/
|
|
627
|
-
export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300) {
|
|
646
|
+
export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300) {
|
|
628
647
|
ensurePsmuxInstalled();
|
|
629
|
-
|
|
648
|
+
|
|
649
|
+
// E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
|
|
650
|
+
let pane;
|
|
651
|
+
try {
|
|
652
|
+
pane = resolvePane(sessionName, paneNameOrTarget);
|
|
653
|
+
} catch (resolveError) {
|
|
654
|
+
if (!psmuxSessionExists(sessionName)) {
|
|
655
|
+
return {
|
|
656
|
+
matched: false,
|
|
657
|
+
paneId: "",
|
|
658
|
+
paneName: String(paneNameOrTarget),
|
|
659
|
+
logPath: "",
|
|
660
|
+
match: null,
|
|
661
|
+
sessionDead: true,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
throw resolveError; // 세션은 살아있지만 pane을 못 찾음 → 원래 에러 전파
|
|
665
|
+
}
|
|
666
|
+
|
|
630
667
|
const paneName = pane.title || paneNameOrTarget;
|
|
631
668
|
const logPath = getCaptureLogPath(sessionName, paneName);
|
|
632
669
|
if (!existsSync(logPath)) {
|
|
@@ -637,7 +674,23 @@ export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSe
|
|
|
637
674
|
const regex = toPatternRegExp(pattern);
|
|
638
675
|
|
|
639
676
|
while (Date.now() <= deadline) {
|
|
640
|
-
|
|
677
|
+
// E4 크래시 복구: capture 실패 시 세션 생존 체크
|
|
678
|
+
try {
|
|
679
|
+
refreshCaptureSnapshot(sessionName, pane.paneId);
|
|
680
|
+
} catch {
|
|
681
|
+
if (!psmuxSessionExists(sessionName)) {
|
|
682
|
+
return {
|
|
683
|
+
matched: false,
|
|
684
|
+
paneId: pane.paneId,
|
|
685
|
+
paneName,
|
|
686
|
+
logPath,
|
|
687
|
+
match: null,
|
|
688
|
+
sessionDead: true,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
// 일시적 오류 — 다음 폴링에서 재시도
|
|
692
|
+
}
|
|
693
|
+
|
|
641
694
|
const content = readCaptureLog(logPath);
|
|
642
695
|
const match = regex.exec(content);
|
|
643
696
|
if (match) {
|
|
@@ -653,7 +706,7 @@ export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSe
|
|
|
653
706
|
if (Date.now() > deadline) {
|
|
654
707
|
break;
|
|
655
708
|
}
|
|
656
|
-
|
|
709
|
+
await sleepMsAsync(POLL_INTERVAL_MS);
|
|
657
710
|
}
|
|
658
711
|
|
|
659
712
|
return {
|
|
@@ -673,12 +726,12 @@ export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSe
|
|
|
673
726
|
* @param {number} timeoutSec
|
|
674
727
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
|
|
675
728
|
*/
|
|
676
|
-
export function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300) {
|
|
729
|
+
export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300) {
|
|
677
730
|
const completionRegex = new RegExp(
|
|
678
731
|
`${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
|
|
679
732
|
"m",
|
|
680
733
|
);
|
|
681
|
-
const result = waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec);
|
|
734
|
+
const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec);
|
|
682
735
|
const exitMatch = result.match ? completionRegex.exec(result.match) : null;
|
|
683
736
|
return {
|
|
684
737
|
...result,
|
|
@@ -825,6 +878,7 @@ export function captureWorkerOutput(sessionName, workerName, lines = 50) {
|
|
|
825
878
|
// ─── CLI 진입점 ───
|
|
826
879
|
|
|
827
880
|
if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
|
|
881
|
+
(async () => {
|
|
828
882
|
const [, , cmd, ...args] = process.argv;
|
|
829
883
|
|
|
830
884
|
// CLI 인자 파싱 헬퍼
|
|
@@ -907,7 +961,7 @@ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
|
|
|
907
961
|
console.error("사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
|
|
908
962
|
process.exit(1);
|
|
909
963
|
}
|
|
910
|
-
const result = waitForPattern(session, name, pattern, timeoutSec);
|
|
964
|
+
const result = await waitForPattern(session, name, pattern, timeoutSec);
|
|
911
965
|
console.log(JSON.stringify(result, null, 2));
|
|
912
966
|
if (!result.matched) process.exit(2);
|
|
913
967
|
break;
|
|
@@ -921,7 +975,7 @@ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
|
|
|
921
975
|
console.error("사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
|
|
922
976
|
process.exit(1);
|
|
923
977
|
}
|
|
924
|
-
const result = waitForCompletion(session, name, token, timeoutSec);
|
|
978
|
+
const result = await waitForCompletion(session, name, token, timeoutSec);
|
|
925
979
|
console.log(JSON.stringify(result, null, 2));
|
|
926
980
|
if (!result.matched) process.exit(2);
|
|
927
981
|
break;
|
|
@@ -943,4 +997,5 @@ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
|
|
|
943
997
|
console.error(`오류: ${err.message}`);
|
|
944
998
|
process.exit(1);
|
|
945
999
|
}
|
|
1000
|
+
})();
|
|
946
1001
|
}
|
package/package.json
CHANGED
|
@@ -176,7 +176,10 @@ function normalizeProfileName(profile) {
|
|
|
176
176
|
if (raw === 'auto') return raw;
|
|
177
177
|
if (PROFILE_DEFINITIONS[raw]) return raw;
|
|
178
178
|
if (LEGACY_PROFILE_ALIASES[raw]) return LEGACY_PROFILE_ALIASES[raw];
|
|
179
|
-
|
|
179
|
+
// graceful fallback: --flag나 잘못된 프로필 → 'auto'로 폴백 (hard crash 방지)
|
|
180
|
+
if (raw.startsWith('-') || raw.startsWith('/')) return 'auto';
|
|
181
|
+
console.error(`[mcp-filter] 경고: 알 수 없는 프로필 '${raw}', 'auto'로 폴백`);
|
|
182
|
+
return 'auto';
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
function resolveAutoProfile(agentType = '') {
|
|
@@ -696,7 +699,14 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
696
699
|
return;
|
|
697
700
|
}
|
|
698
701
|
|
|
699
|
-
|
|
702
|
+
let policy;
|
|
703
|
+
try {
|
|
704
|
+
policy = buildMcpPolicy(args);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
console.error(`[mcp-filter] ${error instanceof Error ? error.message : String(error)}`);
|
|
707
|
+
process.exitCode = 65;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
700
710
|
if (args.command === 'shell') {
|
|
701
711
|
process.stdout.write(`${toShellExports(policy)}\n`);
|
|
702
712
|
return;
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -121,6 +121,16 @@ MCP_PROFILE="${3:-auto}"
|
|
|
121
121
|
USER_TIMEOUT="${4:-}"
|
|
122
122
|
CONTEXT_FILE="${5:-}"
|
|
123
123
|
|
|
124
|
+
# ── CLI 이름은 route_agent()에서 기본 역할 alias로 처리됨 (codex→executor, gemini→designer, claude→explore) ──
|
|
125
|
+
|
|
126
|
+
# ── 인자 검증: MCP_PROFILE이 --flag 형태인 경우 거절 ──
|
|
127
|
+
if [[ "$MCP_PROFILE" == --* ]]; then
|
|
128
|
+
echo "ERROR: MCP 프로필 위치(3번째 인자)에 플래그 '$MCP_PROFILE'가 들어왔습니다." >&2
|
|
129
|
+
echo "사용법: tfx-route.sh <역할> \"프롬프트\" [mcp_profile] [timeout]" >&2
|
|
130
|
+
echo "지원 프로필: auto, executor, analyze, implement, review, minimal, full" >&2
|
|
131
|
+
exit 64
|
|
132
|
+
fi
|
|
133
|
+
|
|
124
134
|
# ── CLI 경로 해석 (Windows npm global 대응) ──
|
|
125
135
|
NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
|
|
126
136
|
CODEX_BIN="${CODEX_BIN:-$(command -v codex 2>/dev/null || echo codex)}"
|
|
@@ -461,22 +471,22 @@ team_claim_task() {
|
|
|
461
471
|
esac
|
|
462
472
|
}
|
|
463
473
|
|
|
464
|
-
team_complete_task() {
|
|
465
|
-
local result="${1:-success}" # success/failed/timeout
|
|
466
|
-
local result_summary="${2:-작업 완료}"
|
|
467
|
-
[[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
|
|
468
|
-
|
|
469
|
-
local summary_trimmed result_payload
|
|
470
|
-
summary_trimmed=$(echo "$result_summary" | head -c 4096)
|
|
471
|
-
result_payload=$(bridge_json_stringify task-result "$TFX_TEAM_TASK_ID" "$result" 2>/dev/null || true)
|
|
472
|
-
|
|
473
|
-
# task 파일 completion 쓰기는 Worker Step 6 TaskUpdate가 authority다.
|
|
474
|
-
# route 레벨에서는 task.result 발행 + 로컬 backup만 유지한다.
|
|
475
|
-
|
|
476
|
-
# Hub result 발행 (poll_messages 채널 활성화)
|
|
477
|
-
if [[ -n "$result_payload" ]]; then
|
|
478
|
-
if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
|
|
479
|
-
result \
|
|
474
|
+
team_complete_task() {
|
|
475
|
+
local result="${1:-success}" # success/failed/timeout
|
|
476
|
+
local result_summary="${2:-작업 완료}"
|
|
477
|
+
[[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
|
|
478
|
+
|
|
479
|
+
local summary_trimmed result_payload
|
|
480
|
+
summary_trimmed=$(echo "$result_summary" | head -c 4096)
|
|
481
|
+
result_payload=$(bridge_json_stringify task-result "$TFX_TEAM_TASK_ID" "$result" 2>/dev/null || true)
|
|
482
|
+
|
|
483
|
+
# task 파일 completion 쓰기는 Worker Step 6 TaskUpdate가 authority다.
|
|
484
|
+
# route 레벨에서는 task.result 발행 + 로컬 backup만 유지한다.
|
|
485
|
+
|
|
486
|
+
# Hub result 발행 (poll_messages 채널 활성화)
|
|
487
|
+
if [[ -n "$result_payload" ]]; then
|
|
488
|
+
if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
|
|
489
|
+
result \
|
|
480
490
|
--agent "$TFX_TEAM_AGENT_NAME" \
|
|
481
491
|
--topic task.result \
|
|
482
492
|
--payload "$result_payload" \
|
|
@@ -637,13 +647,13 @@ route_agent() {
|
|
|
637
647
|
CLI_ARGS="-m gemini-3-flash-preview -y --prompt"
|
|
638
648
|
CLI_EFFORT="flash"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
639
649
|
|
|
640
|
-
# ─── 탐색/검증/테스트 (Claude-native 우선, TFX_NO_CLAUDE_NATIVE=1일 때만 Codex 리매핑) ───
|
|
641
|
-
explore|verifier|test-engineer|qa-tester)
|
|
642
|
-
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
643
|
-
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
|
|
644
|
-
case "$agent" in
|
|
645
|
-
test-engineer|qa-tester) DEFAULT_TIMEOUT=1200; RUN_MODE="bg" ;;
|
|
646
|
-
esac
|
|
650
|
+
# ─── 탐색/검증/테스트 (Claude-native 우선, TFX_NO_CLAUDE_NATIVE=1일 때만 Codex 리매핑) ───
|
|
651
|
+
explore|verifier|test-engineer|qa-tester)
|
|
652
|
+
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
653
|
+
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
|
|
654
|
+
case "$agent" in
|
|
655
|
+
test-engineer|qa-tester) DEFAULT_TIMEOUT=1200; RUN_MODE="bg" ;;
|
|
656
|
+
esac
|
|
647
657
|
;;
|
|
648
658
|
|
|
649
659
|
# ─── 경량 ───
|
|
@@ -651,11 +661,24 @@ route_agent() {
|
|
|
651
661
|
CLI_TYPE="codex"; CLI_CMD="codex"
|
|
652
662
|
CLI_ARGS="exec --profile spark_fast ${codex_base}"
|
|
653
663
|
CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
664
|
+
# ─── CLI 이름 alias (사용자 편의) ───
|
|
665
|
+
codex)
|
|
666
|
+
CLI_TYPE="codex"; CLI_CMD="codex"
|
|
667
|
+
CLI_ARGS="exec ${codex_base}"
|
|
668
|
+
CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
669
|
+
gemini)
|
|
670
|
+
CLI_TYPE="gemini"; CLI_CMD="gemini"
|
|
671
|
+
CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
|
|
672
|
+
CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
673
|
+
claude)
|
|
674
|
+
CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
|
|
675
|
+
CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
|
|
654
676
|
*)
|
|
655
677
|
echo "ERROR: 알 수 없는 에이전트 타입: $agent" >&2
|
|
656
678
|
echo "사용 가능: executor, build-fixer, debugger, deep-executor, architect, planner, critic, analyst," >&2
|
|
657
679
|
echo " code-reviewer, security-reviewer, quality-reviewer, scientist, document-specialist," >&2
|
|
658
|
-
echo " designer, writer, explore, verifier, test-engineer, qa-tester, spark" >&2
|
|
680
|
+
echo " designer, writer, explore, verifier, test-engineer, qa-tester, spark," >&2
|
|
681
|
+
echo " codex, gemini, claude (CLI alias)" >&2
|
|
659
682
|
exit 1 ;;
|
|
660
683
|
esac
|
|
661
684
|
}
|
|
@@ -148,10 +148,36 @@ status는 "completed"만 사용. 실패 여부는 `metadata.result`로 구분.
|
|
|
148
148
|
3. 실패 시 `forceCleanupTeam(teamName)` → 그래도 실패 시 `rm -rf ~/.claude/teams/{teamName}/` 안내
|
|
149
149
|
4. 종합 보고서 출력
|
|
150
150
|
|
|
151
|
-
### Phase 3-mux:
|
|
151
|
+
### Phase 3-mux: psmux 헤드리스 모드
|
|
152
152
|
|
|
153
|
-
`--tmux`/`--psmux` 시 pane 기반 실행.
|
|
154
|
-
|
|
153
|
+
`--tmux`/`--psmux` 시 pane 기반 헤드리스 실행. Agent 래퍼 없이 Lead가 직접 CLI를 제어하여 토큰 76-89% 절감.
|
|
154
|
+
|
|
155
|
+
**핵심 프리미티브** (`hub/team/psmux.mjs`):
|
|
156
|
+
- `createPsmuxSession(name, {layout, paneCount})` — 세션 + pane 분할
|
|
157
|
+
- `dispatchCommand(session, paneName, cmd)` → `{token, paneId, logPath}`
|
|
158
|
+
- `waitForCompletion(session, paneName, token, timeoutSec)` → `{matched, exitCode, sessionDead?}`
|
|
159
|
+
- 완료 마커: `__TRIFLUX_DONE__:token:exitCode` (PowerShell 래핑)
|
|
160
|
+
- pane 이름: `"lead"` → index 0, `"worker-N"` → index N (대소문자 무관)
|
|
161
|
+
|
|
162
|
+
**헤드리스 오케스트레이션** (`hub/team/headless.mjs`):
|
|
163
|
+
```
|
|
164
|
+
import { runHeadlessWithCleanup } from "hub/team/headless.mjs";
|
|
165
|
+
const { results } = runHeadlessWithCleanup([
|
|
166
|
+
{ cli: "codex", prompt: "코드 리뷰", role: "reviewer" },
|
|
167
|
+
{ cli: "gemini", prompt: "문서 작성", role: "writer" },
|
|
168
|
+
], { timeoutSec: 300 });
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**CLI 헤드리스 명령 패턴:**
|
|
172
|
+
| CLI | 명령 | 출력 |
|
|
173
|
+
|-----|-------|------|
|
|
174
|
+
| Codex | `codex exec 'prompt' -o result.txt --color never` | 파일 |
|
|
175
|
+
| Gemini | `gemini -p 'prompt' -o text > result.txt` | 리다이렉트 |
|
|
176
|
+
| Claude | `claude -p 'prompt' --output-format text > result.txt` | 리다이렉트 |
|
|
177
|
+
|
|
178
|
+
**E4 크래시 복구:** `waitForCompletion`이 세션 사망 시 `{sessionDead: true}` 반환 (throw 대신).
|
|
179
|
+
|
|
180
|
+
**레거시 인터랙티브 모드:** `Bash("node {PKG_ROOT}/bin/triflux.mjs multi --no-attach --agents {agents} \\\"{task}\\\"")`
|
|
155
181
|
|
|
156
182
|
## 전제 조건
|
|
157
183
|
|