triflux 5.1.2 → 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.
@@ -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 === "wt"
72
- ? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
73
- : await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
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
+ }
@@ -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 [];
@@ -639,9 +643,27 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
639
643
  * @param {number} timeoutSec
640
644
  * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
641
645
  */
642
- export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300) {
646
+ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300) {
643
647
  ensurePsmuxInstalled();
644
- const pane = resolvePane(sessionName, paneNameOrTarget);
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
+
645
667
  const paneName = pane.title || paneNameOrTarget;
646
668
  const logPath = getCaptureLogPath(sessionName, paneName);
647
669
  if (!existsSync(logPath)) {
@@ -652,7 +674,23 @@ export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSe
652
674
  const regex = toPatternRegExp(pattern);
653
675
 
654
676
  while (Date.now() <= deadline) {
655
- refreshCaptureSnapshot(sessionName, pane.paneId);
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
+
656
694
  const content = readCaptureLog(logPath);
657
695
  const match = regex.exec(content);
658
696
  if (match) {
@@ -668,7 +706,7 @@ export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSe
668
706
  if (Date.now() > deadline) {
669
707
  break;
670
708
  }
671
- sleepMs(POLL_INTERVAL_MS);
709
+ await sleepMsAsync(POLL_INTERVAL_MS);
672
710
  }
673
711
 
674
712
  return {
@@ -688,12 +726,12 @@ export function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSe
688
726
  * @param {number} timeoutSec
689
727
  * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, token: string, exitCode: number|null }}
690
728
  */
691
- export function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300) {
729
+ export async function waitForCompletion(sessionName, paneNameOrTarget, token, timeoutSec = 300) {
692
730
  const completionRegex = new RegExp(
693
731
  `${escapeRegExp(COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
694
732
  "m",
695
733
  );
696
- const result = waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec);
734
+ const result = await waitForPattern(sessionName, paneNameOrTarget, completionRegex, timeoutSec);
697
735
  const exitMatch = result.match ? completionRegex.exec(result.match) : null;
698
736
  return {
699
737
  ...result,
@@ -840,6 +878,7 @@ export function captureWorkerOutput(sessionName, workerName, lines = 50) {
840
878
  // ─── CLI 진입점 ───
841
879
 
842
880
  if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
881
+ (async () => {
843
882
  const [, , cmd, ...args] = process.argv;
844
883
 
845
884
  // CLI 인자 파싱 헬퍼
@@ -922,7 +961,7 @@ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
922
961
  console.error("사용법: node psmux.mjs wait-pattern --session <세션> --name <pane> --pattern <정규식> [--timeout <초>]");
923
962
  process.exit(1);
924
963
  }
925
- const result = waitForPattern(session, name, pattern, timeoutSec);
964
+ const result = await waitForPattern(session, name, pattern, timeoutSec);
926
965
  console.log(JSON.stringify(result, null, 2));
927
966
  if (!result.matched) process.exit(2);
928
967
  break;
@@ -936,7 +975,7 @@ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
936
975
  console.error("사용법: node psmux.mjs wait-completion --session <세션> --name <pane> --token <토큰> [--timeout <초>]");
937
976
  process.exit(1);
938
977
  }
939
- const result = waitForCompletion(session, name, token, timeoutSec);
978
+ const result = await waitForCompletion(session, name, token, timeoutSec);
940
979
  console.log(JSON.stringify(result, null, 2));
941
980
  if (!result.matched) process.exit(2);
942
981
  break;
@@ -958,4 +997,5 @@ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
958
997
  console.error(`오류: ${err.message}`);
959
998
  process.exit(1);
960
999
  }
1000
+ })();
961
1001
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "5.1.2",
3
+ "version": "5.2.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": {
@@ -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
- throw new Error(`지원하지 않는 MCP 프로필: ${raw}`);
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 = '') {
@@ -121,14 +121,7 @@ MCP_PROFILE="${3:-auto}"
121
121
  USER_TIMEOUT="${4:-}"
122
122
  CONTEXT_FILE="${5:-}"
123
123
 
124
- # ── 인자 검증: CLI 이름을 role 자리에 사용한 경우 안내 ──
125
- case "$AGENT_TYPE" in
126
- codex|gemini|claude|claude-native)
127
- echo "ERROR: '$AGENT_TYPE'는 CLI 이름이지 에이전트 역할이 아닙니다." >&2
128
- echo "올바른 사용법: TFX_CLI_MODE=$AGENT_TYPE bash tfx-route.sh <역할> \"프롬프트\"" >&2
129
- echo "사용 가능한 역할: executor, code-reviewer, scientist, designer, architect, verifier 등" >&2
130
- exit 64 ;;
131
- esac
124
+ # ── CLI 이름은 route_agent()에서 기본 역할 alias로 처리됨 (codex→executor, gemini→designer, claude→explore) ──
132
125
 
133
126
  # ── 인자 검증: MCP_PROFILE이 --flag 형태인 경우 거절 ──
134
127
  if [[ "$MCP_PROFILE" == --* ]]; then
@@ -668,11 +661,24 @@ route_agent() {
668
661
  CLI_TYPE="codex"; CLI_CMD="codex"
669
662
  CLI_ARGS="exec --profile spark_fast ${codex_base}"
670
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" ;;
671
676
  *)
672
677
  echo "ERROR: 알 수 없는 에이전트 타입: $agent" >&2
673
678
  echo "사용 가능: executor, build-fixer, debugger, deep-executor, architect, planner, critic, analyst," >&2
674
679
  echo " code-reviewer, security-reviewer, quality-reviewer, scientist, document-specialist," >&2
675
- 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
676
682
  exit 1 ;;
677
683
  esac
678
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: 레거시 psmux/tmux 모드
151
+ ### Phase 3-mux: psmux 헤드리스 모드
152
152
 
153
- `--tmux`/`--psmux` 시 pane 기반 실행. psmuxWindows 1순위.
154
- `Bash("node {PKG_ROOT}/bin/triflux.mjs multi --no-attach --agents {agents} \\\"{task}\\\"")`
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