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.
@@ -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>&1`;
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} sessionName
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
- if (onProgress) onProgress({ type: "session_created", sessionName, panes: session.panes });
84
+ let dispatches;
79
85
 
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
+ 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
- if (onProgress) onProgress({ type: "dispatched", paneName, cli: assignment.cli });
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
- return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
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
- const completion = await waitForCompletion(sessionName, d.paneName, d.token, timeoutSec);
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
+ }
@@ -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 deadline = Date.now() + Math.max(0, Math.trunc(timeoutSec * 1000));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "5.2.0",
3
+ "version": "6.0.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,13 +93,13 @@ export function createModuleLogger(module) {
93
93
 
94
94
  // 정상 종료 시 버퍼 flush 보장
95
95
  process.on('uncaughtException', (err) => {
96
- const finalLogger = pino.final(logger);
97
- finalLogger.fatal({ err }, 'process.uncaught_exception');
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
- const finalLogger = pino.final(logger);
103
- finalLogger.fatal({ reason: String(reason) }, 'process.unhandled_rejection');
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-mux: psmux 헤드리스 모드
151
+ ### Phase 3-direct: Lead-Direct Headless 실행 (v6.0.0, 기본)
152
152
 
153
- `--tmux`/`--psmux` pane 기반 헤드리스 실행. Agent 래퍼 없이 Lead가 직접 CLI를 제어하여 토큰 76-89% 절감.
153
+ CLI 워커(Codex/Gemini/Claude)를 Agent 래퍼 없이 Lead가 headless.mjs로 직접 실행.
154
+ Windows Terminal에 psmux 세션이 자동 팝업되어 사용자가 실시간으로 CLI 출력을 확인.
154
155
 
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 (대소문자 무관)
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
- **헤드리스 오케스트레이션** (`hub/team/headless.mjs`):
163
- ```
164
- import { runHeadlessWithCleanup } from "hub/team/headless.mjs";
165
- const { results } = runHeadlessWithCleanup([
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
- ], { timeoutSec: 300 });
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