triflux 5.2.0 → 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/headless.mjs +318 -16
- package/hub/team/psmux.mjs +16 -4
- package/package.json +1 -1
- package/scripts/lib/logger.mjs +4 -4
- package/skills/tfx-multi/SKILL.md +39 -15
package/hub/team/headless.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
|
|
2
2
|
// psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
|
|
3
|
+
// v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
|
|
4
|
+
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
3
5
|
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
4
6
|
import { join } from "node:path";
|
|
5
7
|
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
8
|
import { tmpdir } from "node:os";
|
|
9
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
7
10
|
import {
|
|
8
11
|
createPsmuxSession,
|
|
9
12
|
killPsmuxSession,
|
|
@@ -11,6 +14,8 @@ import {
|
|
|
11
14
|
dispatchCommand,
|
|
12
15
|
waitForCompletion,
|
|
13
16
|
capturePsmuxPane,
|
|
17
|
+
startCapture,
|
|
18
|
+
psmuxExec,
|
|
14
19
|
} from "./psmux.mjs";
|
|
15
20
|
|
|
16
21
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
@@ -30,7 +35,7 @@ export function buildHeadlessCommand(cli, prompt, resultFile) {
|
|
|
30
35
|
case "codex":
|
|
31
36
|
return `codex exec '${escaped}' -o '${resultFile}' --color never`;
|
|
32
37
|
case "gemini":
|
|
33
|
-
return `gemini -p '${escaped}' -o text > '${resultFile}' 2
|
|
38
|
+
return `gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
34
39
|
case "claude":
|
|
35
40
|
return `claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
|
|
36
41
|
default:
|
|
@@ -41,8 +46,7 @@ export function buildHeadlessCommand(cli, prompt, resultFile) {
|
|
|
41
46
|
/**
|
|
42
47
|
* 결과 파일 읽기 (없으면 capture-pane fallback)
|
|
43
48
|
* @param {string} resultFile
|
|
44
|
-
* @param {string}
|
|
45
|
-
* @param {string} paneName
|
|
49
|
+
* @param {string} paneId
|
|
46
50
|
* @returns {string}
|
|
47
51
|
*/
|
|
48
52
|
function readResult(resultFile, paneId) {
|
|
@@ -62,6 +66,8 @@ function readResult(resultFile, paneId) {
|
|
|
62
66
|
* @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
|
|
63
67
|
* @param {string} [opts.layout='2x2'] — pane 레이아웃
|
|
64
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로 추가 (실시간 스플릿)
|
|
65
71
|
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
|
|
66
72
|
*/
|
|
67
73
|
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
@@ -69,29 +75,96 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
69
75
|
timeoutSec = 300,
|
|
70
76
|
layout = "2x2",
|
|
71
77
|
onProgress,
|
|
78
|
+
progressIntervalSec = 0,
|
|
79
|
+
progressive = true,
|
|
72
80
|
} = opts;
|
|
73
81
|
|
|
74
82
|
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
83
|
|
|
78
|
-
|
|
84
|
+
let dispatches;
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const dispatch = dispatchCommand(sessionName, paneName, cmd);
|
|
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 });
|
|
86
91
|
|
|
87
|
-
|
|
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}`;
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
|
91
145
|
|
|
92
146
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
93
147
|
const results = await Promise.all(dispatches.map(async (d) => {
|
|
94
|
-
|
|
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);
|
|
95
168
|
|
|
96
169
|
const output = completion.matched
|
|
97
170
|
? readResult(d.resultFile, d.paneId)
|
|
@@ -111,6 +184,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
111
184
|
return {
|
|
112
185
|
cli: d.cli,
|
|
113
186
|
paneName: d.paneName,
|
|
187
|
+
paneId: d.paneId,
|
|
114
188
|
role: d.role,
|
|
115
189
|
matched: completion.matched,
|
|
116
190
|
exitCode: completion.exitCode,
|
|
@@ -144,3 +218,231 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
|
144
218
|
}
|
|
145
219
|
}
|
|
146
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
|
+
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -641,9 +641,11 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
641
641
|
* @param {string} paneNameOrTarget
|
|
642
642
|
* @param {string|RegExp} pattern
|
|
643
643
|
* @param {number} timeoutSec
|
|
644
|
+
* @param {object} [opts]
|
|
645
|
+
* @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
|
|
644
646
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
|
|
645
647
|
*/
|
|
646
|
-
export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300) {
|
|
648
|
+
export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
|
|
647
649
|
ensurePsmuxInstalled();
|
|
648
650
|
|
|
649
651
|
// E4 크래시 복구: 초기 resolvePane도 세션 사망을 감지
|
|
@@ -670,7 +672,8 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
670
672
|
throw new Error(`캡처 로그가 없습니다. 먼저 startCapture(${sessionName}, ${paneName})를 호출하세요.`);
|
|
671
673
|
}
|
|
672
674
|
|
|
673
|
-
const
|
|
675
|
+
const startTime = Date.now();
|
|
676
|
+
const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
|
|
674
677
|
const regex = toPatternRegExp(pattern);
|
|
675
678
|
|
|
676
679
|
while (Date.now() <= deadline) {
|
|
@@ -692,6 +695,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
692
695
|
}
|
|
693
696
|
|
|
694
697
|
const content = readCaptureLog(logPath);
|
|
698
|
+
|
|
699
|
+
// onPoll 콜백 — 각 폴링 주기마다 중간 상태 전달
|
|
700
|
+
if (opts.onPoll) {
|
|
701
|
+
try {
|
|
702
|
+
opts.onPoll({ content, paneId: pane.paneId, paneName, elapsed: Date.now() - startTime });
|
|
703
|
+
} catch { /* 콜백 예외는 삼킴 — 폴링 루프 보호 */ }
|
|
704
|
+
}
|
|
705
|
+
|
|
695
706
|
const match = regex.exec(content);
|
|
696
707
|
if (match) {
|
|
697
708
|
return {
|
|
@@ -724,14 +735,15 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
|
|
|
724
735
|
* @param {string} paneNameOrTarget
|
|
725
736
|
* @param {string} token
|
|
726
737
|
* @param {number} timeoutSec
|
|
738
|
+
* @param {object} [opts] — waitForPattern에 전달할 옵션 (onPoll 등)
|
|
727
739
|
* @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
|
|
728
740
|
*/
|
|
729
|
-
export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300) {
|
|
741
|
+
export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300, opts = {}) {
|
|
730
742
|
const completionRegex = new RegExp(
|
|
731
743
|
`${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
|
|
732
744
|
"m",
|
|
733
745
|
);
|
|
734
|
-
const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec);
|
|
746
|
+
const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec, opts);
|
|
735
747
|
const exitMatch = result.match ? completionRegex.exec(result.match) : null;
|
|
736
748
|
return {
|
|
737
749
|
...result,
|
package/package.json
CHANGED
package/scripts/lib/logger.mjs
CHANGED
|
@@ -93,13 +93,13 @@ export function createModuleLogger(module) {
|
|
|
93
93
|
|
|
94
94
|
// 정상 종료 시 버퍼 flush 보장
|
|
95
95
|
process.on('uncaughtException', (err) => {
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
logger.fatal({ err }, 'process.uncaught_exception');
|
|
97
|
+
logger.flush();
|
|
98
98
|
process.exit(1);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
process.on('unhandledRejection', (reason) => {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
logger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
|
|
103
|
+
logger.flush();
|
|
104
104
|
process.exit(1);
|
|
105
105
|
});
|
|
@@ -148,34 +148,58 @@ status는 "completed"만 사용. 실패 여부는 `metadata.result`로 구분.
|
|
|
148
148
|
3. 실패 시 `forceCleanupTeam(teamName)` → 그래도 실패 시 `rm -rf ~/.claude/teams/{teamName}/` 안내
|
|
149
149
|
4. 종합 보고서 출력
|
|
150
150
|
|
|
151
|
-
### Phase 3-
|
|
151
|
+
### Phase 3-direct: Lead-Direct Headless 실행 (v6.0.0, 기본)
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
CLI 워커(Codex/Gemini/Claude)를 Agent 래퍼 없이 Lead가 headless.mjs로 직접 실행.
|
|
154
|
+
Windows Terminal에 psmux 세션이 자동 팝업되어 사용자가 실시간으로 CLI 출력을 확인.
|
|
154
155
|
|
|
155
|
-
**핵심
|
|
156
|
-
- `
|
|
157
|
-
- `
|
|
158
|
-
- `
|
|
159
|
-
-
|
|
160
|
-
-
|
|
156
|
+
**핵심 기능:**
|
|
157
|
+
- `progressive: true` (기본) — pane이 하나씩 split-window로 추가 (실시간 스플릿)
|
|
158
|
+
- `autoAttach: true` — 세션 생성 즉시 Windows Terminal 자동 팝업
|
|
159
|
+
- `progressIntervalSec` — N초마다 각 pane 스냅샷을 onProgress로 전달
|
|
160
|
+
- `applyTrifluxTheme()` — status bar + pane border 테마 자동 적용
|
|
161
|
+
- 피드백 재실행 — 같은 pane에 후속 명령 dispatch (세션 유지)
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
**Lead 오케스트레이션 패턴:**
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
// headless.mjs의 runHeadlessInteractive()를 Bash 내에서 호출
|
|
167
|
+
// Lead는 Bash의 결과를 직접 파싱 — Agent 래퍼 불필요
|
|
168
|
+
const handle = await runHeadlessInteractive("tfx-session", [
|
|
166
169
|
{ cli: "codex", prompt: "코드 리뷰", role: "reviewer" },
|
|
167
170
|
{ cli: "gemini", prompt: "문서 작성", role: "writer" },
|
|
168
|
-
|
|
171
|
+
{ cli: "claude", prompt: "테스트 실행", role: "tester" },
|
|
172
|
+
], {
|
|
173
|
+
timeoutSec: 300,
|
|
174
|
+
autoAttach: true, // WT 자동 팝업
|
|
175
|
+
progressive: true, // 실시간 스플릿 (기본)
|
|
176
|
+
progressIntervalSec: 10, // 10초마다 진행 스냅샷
|
|
177
|
+
});
|
|
178
|
+
// handle: { results, dispatch(), capture(), snapshots(), waitFor(), kill() }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**결정 로직:**
|
|
182
|
+
```
|
|
183
|
+
Phase 3 선택:
|
|
184
|
+
assignments.every(a => a.cli !== 'claude')
|
|
185
|
+
→ Phase 3-direct (headless, 전부 CLI)
|
|
186
|
+
assignments.some(a => a.cli === 'claude') AND Claude 워커가 Read/Edit 필요
|
|
187
|
+
→ Claude 워커: Agent(subagent_type), CLI 워커: Phase 3-direct
|
|
188
|
+
fallback (psmux 미설치)
|
|
189
|
+
→ Phase 3 Native Teams (기존 slim wrapper)
|
|
169
190
|
```
|
|
170
191
|
|
|
171
192
|
**CLI 헤드리스 명령 패턴:**
|
|
172
193
|
| CLI | 명령 | 출력 |
|
|
173
194
|
|-----|-------|------|
|
|
174
195
|
| 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` | 리다이렉트 |
|
|
196
|
+
| Gemini | `gemini -p 'prompt' -o text > result.txt 2>result.txt.err` | 리다이렉트 |
|
|
197
|
+
| Claude | `claude -p 'prompt' --output-format text > result.txt 2>&1` | 리다이렉트 |
|
|
177
198
|
|
|
178
199
|
**E4 크래시 복구:** `waitForCompletion`이 세션 사망 시 `{sessionDead: true}` 반환 (throw 대신).
|
|
200
|
+
**elevation 불필요:** psmux IPC는 TCP 기반. 비-elevated 환경에서 정상 실행. (v5.2.0 검증 완료)
|
|
201
|
+
**시각적 확인:** Windows Terminal 자동 팝업 + pane 타이틀 `codex (reviewer)` + triflux 테마.
|
|
202
|
+
**실수로 닫아도:** psmux 세션은 독립적. `psmux attach -t 세션이름`으로 재연결.
|
|
179
203
|
|
|
180
204
|
**레거시 인터랙티브 모드:** `Bash("node {PKG_ROOT}/bin/triflux.mjs multi --no-attach --agents {agents} \\\"{task}\\\"")`
|
|
181
205
|
|