triflux 5.1.2 → 6.0.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/bin/tfx-doctor.mjs +3 -7
- package/bin/tfx-setup.mjs +3 -7
- package/bin/triflux.mjs +2470 -2463
- 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 +448 -0
- package/hub/team/psmux.mjs +61 -9
- package/package.json +1 -1
- package/scripts/lib/logger.mjs +4 -4
- package/scripts/lib/mcp-filter.mjs +4 -1
- package/scripts/tfx-route.sh +15 -9
- package/skills/tfx-multi/SKILL.md +53 -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,448 @@
|
|
|
1
|
+
// hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
|
|
2
|
+
// psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
|
|
3
|
+
// v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
|
|
4
|
+
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
5
|
+
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
10
|
+
import {
|
|
11
|
+
createPsmuxSession,
|
|
12
|
+
killPsmuxSession,
|
|
13
|
+
psmuxSessionExists,
|
|
14
|
+
dispatchCommand,
|
|
15
|
+
waitForCompletion,
|
|
16
|
+
capturePsmuxPane,
|
|
17
|
+
startCapture,
|
|
18
|
+
psmuxExec,
|
|
19
|
+
} from "./psmux.mjs";
|
|
20
|
+
|
|
21
|
+
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CLI별 헤드리스 명령 빌더
|
|
25
|
+
* @param {'codex'|'gemini'|'claude'} cli
|
|
26
|
+
* @param {string} prompt — 실행할 프롬프트
|
|
27
|
+
* @param {string} resultFile — 결과 저장 파일 경로
|
|
28
|
+
* @returns {string} PowerShell 명령
|
|
29
|
+
*/
|
|
30
|
+
export function buildHeadlessCommand(cli, prompt, resultFile) {
|
|
31
|
+
// 프롬프트의 단일 인용부호를 이스케이프
|
|
32
|
+
const escaped = prompt.replace(/'/g, "''");
|
|
33
|
+
|
|
34
|
+
switch (cli) {
|
|
35
|
+
case "codex":
|
|
36
|
+
return `codex exec '${escaped}' -o '${resultFile}' --color never`;
|
|
37
|
+
case "gemini":
|
|
38
|
+
return `gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
39
|
+
case "claude":
|
|
40
|
+
return `claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`지원하지 않는 CLI: ${cli}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 결과 파일 읽기 (없으면 capture-pane fallback)
|
|
48
|
+
* @param {string} resultFile
|
|
49
|
+
* @param {string} paneId
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function readResult(resultFile, paneId) {
|
|
53
|
+
if (existsSync(resultFile)) {
|
|
54
|
+
return readFileSync(resultFile, "utf8").trim();
|
|
55
|
+
}
|
|
56
|
+
// fallback: capture-pane (paneId = "tfx:0.1" 형태)
|
|
57
|
+
return capturePsmuxPane(paneId, 30);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 헤드리스 CLI 오케스트레이션 실행
|
|
62
|
+
*
|
|
63
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
64
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
65
|
+
* @param {object} [opts]
|
|
66
|
+
* @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
|
|
67
|
+
* @param {string} [opts.layout='2x2'] — pane 레이아웃
|
|
68
|
+
* @param {(event: object) => void} [opts.onProgress] — 진행 콜백
|
|
69
|
+
* @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
|
|
70
|
+
* @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
|
|
71
|
+
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
|
|
72
|
+
*/
|
|
73
|
+
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
74
|
+
const {
|
|
75
|
+
timeoutSec = 300,
|
|
76
|
+
layout = "2x2",
|
|
77
|
+
onProgress,
|
|
78
|
+
progressIntervalSec = 0,
|
|
79
|
+
progressive = true,
|
|
80
|
+
} = opts;
|
|
81
|
+
|
|
82
|
+
mkdirSync(RESULT_DIR, { recursive: true });
|
|
83
|
+
|
|
84
|
+
let dispatches;
|
|
85
|
+
|
|
86
|
+
if (progressive) {
|
|
87
|
+
// ─── 실시간 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가 ───
|
|
88
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
|
|
89
|
+
applyTrifluxTheme(sessionName);
|
|
90
|
+
if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
91
|
+
|
|
92
|
+
dispatches = assignments.map((assignment, i) => {
|
|
93
|
+
const paneName = `worker-${i + 1}`;
|
|
94
|
+
const paneTitle = assignment.role
|
|
95
|
+
? `${assignment.cli} (${assignment.role})`
|
|
96
|
+
: `${assignment.cli}-${i + 1}`;
|
|
97
|
+
|
|
98
|
+
// split-window로 새 pane 추가 — paneId 직접 획득
|
|
99
|
+
const newPaneId = psmuxExec([
|
|
100
|
+
"split-window", "-t", sessionName, "-P", "-F",
|
|
101
|
+
"#{session_name}:#{window_index}.#{pane_index}",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// 타이틀 설정
|
|
105
|
+
try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
|
|
106
|
+
|
|
107
|
+
if (onProgress) onProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
|
|
108
|
+
|
|
109
|
+
// 캡처 시작 + 명령 dispatch (paneId 직접 사용 — resolvePane race 회피)
|
|
110
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
111
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
|
|
112
|
+
startCapture(sessionName, newPaneId);
|
|
113
|
+
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
114
|
+
|
|
115
|
+
if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
116
|
+
|
|
117
|
+
return { ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
|
|
121
|
+
try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
|
|
122
|
+
|
|
123
|
+
} else {
|
|
124
|
+
// ─── 기존 모드: 모든 pane을 한 번에 생성 ───
|
|
125
|
+
const paneCount = assignments.length + 1;
|
|
126
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount });
|
|
127
|
+
applyTrifluxTheme(sessionName);
|
|
128
|
+
if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
129
|
+
|
|
130
|
+
dispatches = assignments.map((assignment, i) => {
|
|
131
|
+
const paneName = `worker-${i + 1}`;
|
|
132
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
133
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
|
|
134
|
+
const dispatch = dispatchCommand(sessionName, paneName, cmd);
|
|
135
|
+
|
|
136
|
+
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
137
|
+
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
138
|
+
// progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
|
|
139
|
+
|
|
140
|
+
if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
141
|
+
|
|
142
|
+
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
147
|
+
const results = await Promise.all(dispatches.map(async (d) => {
|
|
148
|
+
// onPoll → onProgress 변환 (throttle by progressIntervalSec)
|
|
149
|
+
const pollOpts = {};
|
|
150
|
+
if (onProgress && progressIntervalSec > 0) {
|
|
151
|
+
let lastProgressAt = 0;
|
|
152
|
+
const intervalMs = progressIntervalSec * 1000;
|
|
153
|
+
pollOpts.onPoll = ({ content }) => {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (now - lastProgressAt >= intervalMs) {
|
|
156
|
+
lastProgressAt = now;
|
|
157
|
+
onProgress({
|
|
158
|
+
type: "progress",
|
|
159
|
+
paneName: d.paneName,
|
|
160
|
+
cli: d.cli,
|
|
161
|
+
snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const completion = await waitForCompletion(sessionName, d.paneName, d.token, timeoutSec, pollOpts);
|
|
168
|
+
|
|
169
|
+
const output = completion.matched
|
|
170
|
+
? readResult(d.resultFile, d.paneId)
|
|
171
|
+
: "";
|
|
172
|
+
|
|
173
|
+
if (onProgress) {
|
|
174
|
+
onProgress({
|
|
175
|
+
type: "completed",
|
|
176
|
+
paneName: d.paneName,
|
|
177
|
+
cli: d.cli,
|
|
178
|
+
matched: completion.matched,
|
|
179
|
+
exitCode: completion.exitCode,
|
|
180
|
+
sessionDead: completion.sessionDead || false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
cli: d.cli,
|
|
186
|
+
paneName: d.paneName,
|
|
187
|
+
paneId: d.paneId,
|
|
188
|
+
role: d.role,
|
|
189
|
+
matched: completion.matched,
|
|
190
|
+
exitCode: completion.exitCode,
|
|
191
|
+
output,
|
|
192
|
+
sessionDead: completion.sessionDead || false,
|
|
193
|
+
};
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
return { sessionName, results };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 헤드리스 실행 + 자동 정리
|
|
201
|
+
* 성공/실패에 관계없이 세션을 정리한다.
|
|
202
|
+
*
|
|
203
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
204
|
+
* @param {object} [opts] — runHeadless opts + sessionPrefix
|
|
205
|
+
* @returns {{ results: Array, sessionName: string }}
|
|
206
|
+
*/
|
|
207
|
+
export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
208
|
+
const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
|
|
209
|
+
const sessionName = `${sessionPrefix}-${Date.now().toString(36).slice(-6)}`;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
return await runHeadless(sessionName, assignments, runOpts);
|
|
213
|
+
} finally {
|
|
214
|
+
try {
|
|
215
|
+
killPsmuxSession(sessionName);
|
|
216
|
+
} catch {
|
|
217
|
+
// 이미 종료된 세션 — 무시
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── v6.0.0: Theme + Visual ───
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* psmux 세션에 triflux 테마를 적용한다.
|
|
226
|
+
* status bar + pane border 색상 + 브랜딩.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} sessionName
|
|
229
|
+
*/
|
|
230
|
+
export function applyTrifluxTheme(sessionName) {
|
|
231
|
+
const opts = [
|
|
232
|
+
// Status bar — Catppuccin Mocha 기반
|
|
233
|
+
["status-style", "bg=#1e1e2e,fg=#cdd6f4"],
|
|
234
|
+
["status-left", " #[fg=#89b4fa,bold]▲ triflux#[default] "],
|
|
235
|
+
["status-left-length", "20"],
|
|
236
|
+
["status-right", " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] "],
|
|
237
|
+
["status-right-length", "40"],
|
|
238
|
+
// Pane border — active/inactive 구분
|
|
239
|
+
["pane-active-border-style", "fg=#89b4fa"],
|
|
240
|
+
["pane-border-style", "fg=#45475a"],
|
|
241
|
+
// Status bar 위치
|
|
242
|
+
["status-position", "bottom"],
|
|
243
|
+
];
|
|
244
|
+
for (const [key, value] of opts) {
|
|
245
|
+
try { psmuxExec(["set-option", "-t", sessionName, key, value]); } catch { /* 무시 */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Windows Terminal에서 psmux 세션을 자동 attach한다.
|
|
253
|
+
* 별도 창이 열리며 사용자가 실시간으로 CLI 출력을 볼 수 있다.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} sessionName — attach할 psmux 세션 이름
|
|
256
|
+
* @param {object} [opts]
|
|
257
|
+
* @param {string} [opts.position] — "right" | "left" | 없으면 기본 위치
|
|
258
|
+
* @returns {boolean} 성공 여부
|
|
259
|
+
*/
|
|
260
|
+
export function autoAttachTerminal(sessionName, opts = {}) {
|
|
261
|
+
try {
|
|
262
|
+
// Windows Terminal이 설치되어 있는지 확인
|
|
263
|
+
execSync("where wt.exe", { stdio: "ignore" });
|
|
264
|
+
} catch {
|
|
265
|
+
return false; // wt.exe 미설치 — 사용자에게 수동 attach 안내 필요
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
// Fix P2: argv 스타일 — shell injection 방지
|
|
270
|
+
execFileSync("wt.exe", ["-w", "0", "nt", "psmux", "attach", "-t", sessionName], { stdio: "ignore" });
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
try {
|
|
274
|
+
execFileSync("wt.exe", ["psmux", "attach", "-t", sessionName], { stdio: "ignore" });
|
|
275
|
+
return true;
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 모든 워커 pane의 현재 스냅샷을 수집한다.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} sessionName
|
|
286
|
+
* @param {Array<{paneId: string, paneName: string, cli: string}>} dispatches
|
|
287
|
+
* @param {number} [lines=15] — 각 pane에서 캡처할 줄 수
|
|
288
|
+
* @returns {Array<{paneName: string, cli: string, snapshot: string}>}
|
|
289
|
+
*/
|
|
290
|
+
export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
|
|
291
|
+
if (!psmuxSessionExists(sessionName)) return [];
|
|
292
|
+
return dispatches.map((d) => {
|
|
293
|
+
try {
|
|
294
|
+
const snapshot = capturePsmuxPane(d.paneId, lines);
|
|
295
|
+
return { paneName: d.paneName, cli: d.cli, snapshot };
|
|
296
|
+
} catch {
|
|
297
|
+
return { paneName: d.paneName, cli: d.cli, snapshot: "(캡처 실패)" };
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Lead-Direct Interactive 헤드리스 실행.
|
|
304
|
+
* 세션을 유지하면서 결과 수집 후에도 추가 명령을 dispatch할 수 있다.
|
|
305
|
+
* 반환된 handle의 kill()을 반드시 호출하여 세션을 정리해야 한다.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
308
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
309
|
+
* @param {object} [opts]
|
|
310
|
+
* @param {number} [opts.timeoutSec=300]
|
|
311
|
+
* @param {string} [opts.layout='2x2']
|
|
312
|
+
* @param {(event: object) => void} [opts.onProgress]
|
|
313
|
+
* @param {number} [opts.progressIntervalSec=0]
|
|
314
|
+
* @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
|
|
315
|
+
* @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
|
|
316
|
+
* @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
|
|
317
|
+
* @returns {Promise<{
|
|
318
|
+
* sessionName: string,
|
|
319
|
+
* results: Array,
|
|
320
|
+
* dispatches: Array,
|
|
321
|
+
* dispatch: (paneName: string, command: string) => {paneId: string, paneName: string, token: string},
|
|
322
|
+
* capture: (paneName: string, lines?: number) => string,
|
|
323
|
+
* snapshots: (lines?: number) => Array,
|
|
324
|
+
* waitFor: (paneName: string, token: string, timeoutSec?: number, opts?: object) => Promise,
|
|
325
|
+
* alive: () => boolean,
|
|
326
|
+
* kill: () => void,
|
|
327
|
+
* }>}
|
|
328
|
+
*/
|
|
329
|
+
export async function runHeadlessInteractive(sessionName, assignments, opts = {}) {
|
|
330
|
+
const {
|
|
331
|
+
autoAttach = false,
|
|
332
|
+
signal,
|
|
333
|
+
maxIdleSec = 0,
|
|
334
|
+
...runOpts
|
|
335
|
+
} = opts;
|
|
336
|
+
|
|
337
|
+
// autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
|
|
338
|
+
const userOnProgress = runOpts.onProgress;
|
|
339
|
+
let terminalAttached = false;
|
|
340
|
+
runOpts.onProgress = (event) => {
|
|
341
|
+
if (autoAttach && event.type === "session_created" && !terminalAttached) {
|
|
342
|
+
terminalAttached = true;
|
|
343
|
+
autoAttachTerminal(sessionName);
|
|
344
|
+
}
|
|
345
|
+
if (userOnProgress) userOnProgress(event);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
|
|
349
|
+
const { results } = await runHeadless(sessionName, assignments, runOpts);
|
|
350
|
+
|
|
351
|
+
// Phase 2: 세션을 유지하고 interactive handle 반환
|
|
352
|
+
// Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
|
|
353
|
+
const dispatches = results.map((r, i) => ({
|
|
354
|
+
paneName: r.paneName,
|
|
355
|
+
paneId: r.paneId || "",
|
|
356
|
+
cli: r.cli,
|
|
357
|
+
role: r.role,
|
|
358
|
+
}));
|
|
359
|
+
|
|
360
|
+
// Fix P2: maxIdleSec 리셋을 위한 타이머 관리
|
|
361
|
+
let idleTimer = null;
|
|
362
|
+
function resetIdleTimer() {
|
|
363
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
364
|
+
if (maxIdleSec > 0) {
|
|
365
|
+
idleTimer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
|
|
366
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const handle = {
|
|
371
|
+
sessionName,
|
|
372
|
+
results,
|
|
373
|
+
dispatches,
|
|
374
|
+
_killed: false,
|
|
375
|
+
|
|
376
|
+
/** 특정 pane에 후속 명령 dispatch (캡처 자동 재시작) */
|
|
377
|
+
dispatch(paneName, command) {
|
|
378
|
+
if (this._killed) throw new Error("세션이 이미 종료되었습니다.");
|
|
379
|
+
try { startCapture(sessionName, paneName); } catch { /* 이미 활성 — 무시 */ }
|
|
380
|
+
resetIdleTimer();
|
|
381
|
+
return dispatchCommand(sessionName, paneName, command);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
/** 특정 pane의 현재 출력 캡처 */
|
|
385
|
+
capture(paneName, lines = 30) {
|
|
386
|
+
if (this._killed) return "";
|
|
387
|
+
try {
|
|
388
|
+
// Fix P2: paneName으로 resolvePane을 경유하여 정확한 paneId 획득
|
|
389
|
+
return capturePsmuxPane(paneName, lines);
|
|
390
|
+
} catch {
|
|
391
|
+
return "(캡처 실패)";
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
/** 모든 pane 스냅샷 */
|
|
396
|
+
snapshots(lines = 15) {
|
|
397
|
+
if (this._killed) return [];
|
|
398
|
+
return getProgressSnapshots(sessionName, dispatches, lines);
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
/** 특정 pane에서 완료 대기 */
|
|
402
|
+
async waitFor(paneName, token, timeoutSec = 300, waitOpts = {}) {
|
|
403
|
+
if (this._killed) return { matched: false, sessionDead: true };
|
|
404
|
+
resetIdleTimer();
|
|
405
|
+
return waitForCompletion(sessionName, paneName, token, timeoutSec, waitOpts);
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
/** 세션 생존 확인 */
|
|
409
|
+
alive() {
|
|
410
|
+
if (this._killed) return false;
|
|
411
|
+
return psmuxSessionExists(sessionName);
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** 세션 종료 */
|
|
415
|
+
kill() {
|
|
416
|
+
if (this._killed) return;
|
|
417
|
+
this._killed = true;
|
|
418
|
+
try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// AbortController signal로 자동 정리
|
|
423
|
+
if (signal) {
|
|
424
|
+
if (signal.aborted) {
|
|
425
|
+
handle.kill();
|
|
426
|
+
} else {
|
|
427
|
+
signal.addEventListener("abort", () => handle.kill(), { once: true });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 유휴 타임아웃 자동 정리
|
|
432
|
+
if (maxIdleSec > 0) {
|
|
433
|
+
const timer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
|
|
434
|
+
if (timer.unref) timer.unref(); // Node.js exit를 방해하지 않음
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 프로세스 종료 시 safety net
|
|
438
|
+
const exitHandler = () => handle.kill();
|
|
439
|
+
process.on("exit", exitHandler);
|
|
440
|
+
// kill() 후 리스너 제거를 위해 참조 보관
|
|
441
|
+
const originalKill = handle.kill.bind(handle);
|
|
442
|
+
handle.kill = function () {
|
|
443
|
+
originalKill();
|
|
444
|
+
process.removeListener("exit", exitHandler);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
return handle;
|
|
448
|
+
}
|