triflux 6.0.19 → 6.0.21

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.
@@ -38,7 +38,7 @@ function renderTmuxInstallHelp() {
38
38
  export { parseTeamArgs };
39
39
 
40
40
  export async function teamStart(args = []) {
41
- const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec } = parseTeamArgs(args);
41
+ const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose } = parseTeamArgs(args);
42
42
  // --assign 사용 시 task를 자동 생성
43
43
  const task = rawTask || (assigns.length > 0 ? assigns.map(a => a.prompt).join(" + ") : "");
44
44
  if (!task) return printStartUsage();
@@ -72,7 +72,7 @@ export async function teamStart(args = []) {
72
72
  const state = effectiveMode === "in-process"
73
73
  ? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
74
74
  : effectiveMode === "headless"
75
- ? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec })
75
+ ? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose })
76
76
  : effectiveMode === "wt"
77
77
  ? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
78
78
  : await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
@@ -10,6 +10,7 @@ export function parseTeamArgs(args = []) {
10
10
  let autoAttach = false;
11
11
  let progressive = true;
12
12
  let timeoutSec = 300;
13
+ let verbose = false;
13
14
 
14
15
  for (let index = 0; index < args.length; index += 1) {
15
16
  const current = args[index];
@@ -29,6 +30,10 @@ export function parseTeamArgs(args = []) {
29
30
  }
30
31
  } else if (current === "--auto-attach") {
31
32
  autoAttach = true;
33
+ } else if (current === "--no-auto-attach") {
34
+ autoAttach = false;
35
+ } else if (current === "--verbose") {
36
+ verbose = true;
32
37
  } else if (current === "--no-progressive") {
33
38
  progressive = false;
34
39
  } else if (current === "--timeout" && args[index + 1]) {
@@ -48,5 +53,6 @@ export function parseTeamArgs(args = []) {
48
53
  autoAttach,
49
54
  progressive,
50
55
  timeoutSec,
56
+ verbose,
51
57
  };
52
58
  }
@@ -4,23 +4,21 @@ import { ok, warn } from "../../render.mjs";
4
4
  import { buildTasks } from "../../services/task-model.mjs";
5
5
  import { clearTeamState } from "../../services/state-store.mjs";
6
6
 
7
- export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec }) {
8
- console.log(` ${AMBER}모드: headless (Lead-Direct v6.0.0)${RESET}`);
9
-
7
+ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose }) {
10
8
  // --assign이 있으면 그것을 사용, 없으면 agents+subtasks 조합
11
9
  const assignments = assigns && assigns.length > 0
12
10
  ? assigns.map((a, i) => ({ cli: a.cli, prompt: a.prompt, role: a.role || `worker-${i + 1}` }))
13
11
  : subtasks.map((subtask, i) => ({ cli: agents[i] || agents[0], prompt: subtask, role: `worker-${i + 1}` }));
14
12
 
15
- ok(`헤드리스 실행 시작 (${assignments.length}워커, progressive=${progressive !== false})`);
13
+ ok(`headless ${assignments.length}워커 시작`);
16
14
 
17
15
  const handle = await runHeadlessInteractive(sessionId, assignments, {
18
16
  timeoutSec: timeoutSec || 300,
19
17
  layout,
20
- autoAttach: autoAttach !== false, // 기본 true
21
- progressive: progressive !== false, // 기본 true
22
- progressIntervalSec: 10,
23
- onProgress(event) {
18
+ autoAttach: !!autoAttach,
19
+ progressive: progressive !== false,
20
+ progressIntervalSec: verbose ? 10 : 0,
21
+ onProgress: verbose ? function onProgress(event) {
24
22
  if (event.type === "session_created") {
25
23
  console.log(` ${DIM}세션: ${event.sessionName}${RESET}`);
26
24
  } else if (event.type === "worker_added") {
@@ -34,28 +32,36 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
34
32
  const icon = event.matched && event.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
35
33
  console.log(` ${icon} [${event.paneName}] ${event.cli} exit=${event.exitCode}${event.sessionDead ? " (dead)" : ""}`);
36
34
  }
37
- },
35
+ } : undefined,
38
36
  });
39
37
 
40
- // 결과 요약
38
+ // 최소 결과 요약
41
39
  const results = handle.results;
42
40
  const succeeded = results.filter((r) => r.matched && r.exitCode === 0);
43
41
  const failed = results.filter((r) => !r.matched || r.exitCode !== 0);
44
42
 
45
- console.log(`\n ${GREEN}${BOLD}헤드리스 실행 완료${RESET}`);
46
- console.log(` ${DIM}성공: ${succeeded.length} / 실패: ${failed.length} / 전체: ${results.length}${RESET}`);
43
+ ok(`헤드리스 완료: ${succeeded.length}성공 / ${failed.length}실패 / ${results.length}전체`);
47
44
 
48
45
  if (failed.length > 0) {
49
- warn("실패 워커:");
50
- for (const r of failed) console.log(` ${r.paneName} (${r.cli}): exit=${r.exitCode}`);
46
+ for (const r of failed) console.log(` ${AMBER}✗${RESET} ${r.paneName} (${r.cli}) exit=${r.exitCode}`);
51
47
  }
52
48
 
53
- // 결과 출력 + JSON stdout
49
+ // 결과 파일 경로 (Lead가 필요시 Read()로 확인)
54
50
  for (const r of results) {
55
- if (r.output) {
56
- const preview = r.output.length > 200 ? `${r.output.slice(0, 200)}…` : r.output;
57
- console.log(`\n ${DIM}── ${r.paneName} (${r.cli}${r.role ? `, ${r.role}` : ""}) ──${RESET}`);
58
- console.log(` ${preview}`);
51
+ const icon = r.matched && r.exitCode === 0 ? `${GREEN}✓${RESET}` : `${AMBER}✗${RESET}`;
52
+ if (r.resultFile) {
53
+ console.log(` ${icon} ${r.paneName}: ${r.resultFile}`);
54
+ }
55
+ }
56
+
57
+ // --verbose: 기존 장황한 출력 (200자 preview)
58
+ if (verbose) {
59
+ for (const r of results) {
60
+ if (r.output) {
61
+ const preview = r.output.length > 200 ? `${r.output.slice(0, 200)}…` : r.output;
62
+ console.log(`\n ${DIM}── ${r.paneName} (${r.cli}${r.role ? `, ${r.role}` : ""}) ──${RESET}`);
63
+ console.log(` ${preview}`);
64
+ }
59
65
  }
60
66
  }
61
67
 
@@ -6,7 +6,7 @@
6
6
  import { join } from "node:path";
7
7
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
- import { execSync, execFileSync } from "node:child_process";
9
+ import { execSync, execFileSync, spawn } from "node:child_process";
10
10
  import {
11
11
  createPsmuxSession,
12
12
  killPsmuxSession,
@@ -39,14 +39,16 @@ const ANSI_DIM = "\x1b[2m";
39
39
  export function buildHeadlessCommand(cli, prompt, resultFile) {
40
40
  // 프롬프트의 단일 인용부호를 이스케이프
41
41
  const escaped = prompt.replace(/'/g, "''");
42
+ // Clear-Host: 실행 즉시 이전 PS 프롬프트 + 명령 텍스트를 깨끗이 지움
43
+ const cls = "Clear-Host; ";
42
44
 
43
45
  switch (cli) {
44
46
  case "codex":
45
- return `codex exec '${escaped}' -o '${resultFile}' --color never`;
47
+ return `${cls}codex exec '${escaped}' -o '${resultFile}' --color never`;
46
48
  case "gemini":
47
- return `gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
49
+ return `${cls}gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
48
50
  case "claude":
49
- return `claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
51
+ return `${cls}claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
50
52
  default:
51
53
  throw new Error(`지원하지 않는 CLI: ${cli}`);
52
54
  }
@@ -156,7 +158,8 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
156
158
  const paneName = `worker-${i + 1}`;
157
159
  const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
158
160
  const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile);
159
- const dispatch = dispatchCommand(sessionName, paneName, cmd);
161
+ const scriptDir = join(RESULT_DIR, sessionName);
162
+ const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
160
163
 
161
164
  // P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
162
165
  // 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
@@ -216,6 +219,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
216
219
  matched: completion.matched,
217
220
  exitCode: completion.exitCode,
218
221
  output,
222
+ resultFile: d.resultFile,
219
223
  sessionDead: completion.sessionDead || false,
220
224
  };
221
225
  }));
@@ -238,11 +242,9 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
238
242
  try {
239
243
  return await runHeadless(sessionName, assignments, runOpts);
240
244
  } finally {
241
- try {
242
- killPsmuxSession(sessionName);
243
- } catch {
244
- // 이미 종료된 세션 — 무시
245
- }
245
+ try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
246
+ // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
247
+ // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
246
248
  }
247
249
  }
248
250
 
@@ -280,6 +282,7 @@ export function applyTrifluxTheme(sessionName) {
280
282
  * 반투명 + 비포커스 시 더 투명 + Catppuccin 테마.
281
283
  * @returns {boolean} 성공 여부
282
284
  */
285
+
283
286
  /**
284
287
  * WT 기본 프로필의 폰트 크기를 읽는다.
285
288
  * @returns {number} 기본 폰트 크기 (못 읽으면 12)
@@ -359,44 +362,72 @@ export function ensureWtProfile(workerCount = 2) {
359
362
  */
360
363
  export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
361
364
  try {
362
- // Windows Terminal이 설치되어 있는지 확인
363
365
  execSync("where wt.exe", { stdio: "ignore" });
364
366
  } catch {
365
- return false; // wt.exe 미설치 — 사용자에게 수동 attach 안내 필요
367
+ return false;
366
368
  }
367
369
 
368
- // triflux WT 프로필 확보 (투명도 + 테마 + 폰트 크기)
369
370
  ensureWtProfile(workerCount);
370
371
 
371
- const shells = ["pwsh.exe", "powershell.exe"];
372
- // 방법 1: split-pane — 같은 WT 창에서 가로 분할
373
- // 워커 수에 따라 split 비율 조정: 1-2→30%, 3-4→40%, 5-6→50%, 7+→60%
374
- const splitSize = Math.min(0.6, 0.2 + workerCount * 0.05).toFixed(2);
375
- if (process.env.WT_SESSION) {
376
- for (const shell of shells) {
377
- try {
378
- // 1) 하단 분할 생성
379
- execSync(
380
- `wt.exe -w 0 sp -H --size ${splitSize} --profile triflux --title triflux -- ${shell} -Command "psmux attach -t ${sessionName}"`,
381
- { stdio: "ignore", timeout: 5000 },
382
- );
383
- // 2) 포커스를 Claude Code(위 pane)로 되돌림
384
- try { execSync(`wt.exe -w 0 mf up`, { stdio: "ignore", timeout: 2000 }); } catch { /* 무시 */ }
385
- return true;
386
- } catch { /* 다음 shell */ }
387
- }
388
- }
389
- // 방법 2 fallback: 새 탭 (WT_SESSION 없거나 sp 실패 시)
390
- for (const shell of shells) {
372
+ const mode = opts.mode || "auto";
373
+
374
+ if (mode === "split" && process.env.WT_SESSION) {
375
+ // inner split 같은 WT 창에서 가로 분할. psmux를 직접 실행 (pwsh 불필요).
391
376
  try {
392
- execSync(
393
- `start "" /b wt.exe nt --profile triflux --title triflux -- ${shell} -Command "psmux attach -t ${sessionName}"`,
394
- { stdio: "ignore", shell: true, timeout: 5000 },
395
- );
377
+ const child = spawn("wt.exe", [
378
+ "-w", "0", "sp", "-H", "-s", "0.50",
379
+ "--profile", "triflux", "--title", "triflux",
380
+ "--", "psmux", "attach", "-t", sessionName,
381
+ ], { detached: true, stdio: "ignore" });
382
+ child.unref();
383
+ try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch { /* 무시 */ }
396
384
  return true;
385
+ } catch { /* fallthrough to window */ }
386
+ }
387
+
388
+ // v6.0.22: 읽기전용 뷰어 + 최소화 트릭.
389
+ // 1. 현재 HWND 캡처 → 2. WT 열기(읽기전용 뷰어) → 3. 새 창 즉시 최소화
390
+ // → Windows가 자동으로 이전 창에 포커스 복원. 사용자는 작업표시줄에서 확인.
391
+ const logDir = join(tmpdir(), "psmux-steering", sessionName).replace(/\\/g, "/");
392
+ const cols = Math.max(100, 60 + workerCount * 15);
393
+ const rows = Math.max(25, 15 + workerCount * 4);
394
+
395
+ // 읽기전용 뷰어 스크립트 생성
396
+ const viewerScript = join(tmpdir(), "tfx-viewer-" + sessionName + ".ps1").replace(/\\/g, "/");
397
+ writeFileSync(viewerScript, [
398
+ "$Host.UI.RawUI.WindowTitle = '" + sessionName + "'",
399
+ "Write-Host \"`e[38;5;214m⬡ triflux viewer (read-only)`e[0m — " + sessionName + "\"",
400
+ "Write-Host 'Log: " + logDir + "'",
401
+ "Write-Host '---'",
402
+ "for ($i=0; $i -lt 10; $i++) { if (Test-Path '" + logDir + "') { break }; Start-Sleep 1 }",
403
+ "Get-Content -Path '" + logDir + "\\*.log' -Wait -Tail 50",
404
+ ].join("\n"), "utf8");
405
+
406
+ // v6.0.23: 같은 WT 창에 탭 추가 (-w 0).
407
+ // WT에 "이전 탭" CLI 명령이 없으므로, 탭 추가 후 Ctrl+Shift+Tab 시뮬레이션.
408
+ try {
409
+ const child = spawn("wt.exe", [
410
+ "-w", "0", "nt",
411
+ "--profile", "triflux",
412
+ "--title", sessionName,
413
+ "--", "pwsh.exe", "-NoProfile", "-NoLogo", "-File", viewerScript,
414
+ ], { detached: true, stdio: "ignore" });
415
+ child.unref();
416
+ } catch {
417
+ return false;
418
+ }
419
+
420
+ // 300ms 후 Ctrl+Shift+Tab → "이전 탭"으로 복귀 (탭 인덱스 무관)
421
+ const prevTabScript = "Start-Sleep -Milliseconds 300; Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('^+{TAB}')";
422
+ for (const shell of ["pwsh.exe", "powershell.exe"]) {
423
+ try {
424
+ spawn(shell, ["-NoProfile", "-NonInteractive", "-Command", prevTabScript], {
425
+ detached: true, stdio: "ignore",
426
+ }).unref();
427
+ break;
397
428
  } catch { /* 다음 shell */ }
398
429
  }
399
- return false;
430
+ return true;
400
431
  }
401
432
 
402
433
  /**
@@ -460,7 +491,8 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
460
491
  runOpts.onProgress = (event) => {
461
492
  if (autoAttach && event.type === "session_created" && !terminalAttached) {
462
493
  terminalAttached = true;
463
- autoAttachTerminal(sessionName, {}, assignments.length);
494
+ // v6.0.20: 항상 별도 창 (포커스 문제 회피)
495
+ autoAttachTerminal(sessionName, { mode: "window" }, assignments.length);
464
496
  }
465
497
  if (userOnProgress) userOnProgress(event);
466
498
  };
@@ -531,11 +563,13 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
531
563
  return psmuxSessionExists(sessionName);
532
564
  },
533
565
 
534
- /** 세션 종료 */
566
+ /** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
535
567
  kill() {
536
568
  if (this._killed) return;
537
569
  this._killed = true;
538
570
  try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
571
+ // WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
572
+ // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
539
573
  },
540
574
  };
541
575
 
@@ -630,6 +630,7 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
630
630
 
631
631
  const token = randomToken(paneName);
632
632
  const wrapped = `${commandText}; $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit"`;
633
+
633
634
  sendLiteralToPane(pane.paneId, wrapped, true);
634
635
 
635
636
  return { paneId: pane.paneId, paneName, token, logPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "6.0.19",
3
+ "version": "6.0.21",
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": {
@@ -1,62 +1,69 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * headless-guard.mjs — PreToolUse 훅 (auto-route 모드)
3
+ * headless-guard.mjs — PreToolUse 훅 (상시 활성 auto-route)
4
4
  *
5
- * Phase 3 headless 모드 활성 중 Lead가 Bash(tfx-route.sh) 개별 호출하면
6
- * deny 대신 자동으로 headless 명령으로 변환한다.
5
+ * psmux가 설치된 환경에서 Bash(tfx-route.sh) 개별 호출을
6
+ * 자동으로 headless 명령으로 변환한다.
7
+ *
8
+ * v2: 마커 파일 의존 제거. psmux 설치 여부만으로 판단.
9
+ * Opus가 SKILL.md를 무시해도 auto-route가 작동한다.
7
10
  *
8
11
  * 동작:
9
- * - 마커 존재 + Bash(tfx-route.sh agent prompt mcp) → updatedInput: tfx multi --headless --assign
10
- * - 마커 존재 + Agent(codex/gemini CLI 워커) → deny (tool 타입 변환 불가, 안내 메시지)
11
- * - 마커 없음전부 통과
12
- * - 마커 30분 초과 자동 만료 (stale 방지)
12
+ * - psmux 설치 + Bash(tfx-route.sh) → updatedInput: tfx multi --headless --assign
13
+ * - psmux 설치 + Bash(codex exec / gemini -p) → deny
14
+ * - psmux 설치 + Agent(codex/gemini CLI 래핑) deny
15
+ * - psmux 미설치전부 통과
13
16
  *
14
- * Exit 0 + stdout JSON: auto-route (updatedInput)
15
- * Exit 2 + stderr: deny (Agent CLI 래핑만)
16
- * Exit 0 (no stdout): allow
17
+ * 성능: psmux 감지 결과를 5분간 캐시 ($TMPDIR/tfx-psmux-check.json)
17
18
  */
18
19
 
19
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { execFileSync } from "node:child_process";
20
22
  import { tmpdir } from "node:os";
21
23
  import { join } from "node:path";
22
24
 
23
- const LOCK_FILE = join(tmpdir(), "tfx-headless-guard.lock");
24
- const MAX_AGE_MS = 30 * 60 * 1000; // 30
25
+ const CACHE_FILE = join(tmpdir(), "tfx-psmux-check.json");
26
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5
25
27
 
26
- function isLockActive() {
27
- if (!existsSync(LOCK_FILE)) return false;
28
+ function isPsmuxInstalled() {
29
+ // 캐시 확인
28
30
  try {
29
- const ts = Number(readFileSync(LOCK_FILE, "utf8").trim());
30
- if (Date.now() - ts > MAX_AGE_MS) {
31
- unlinkSync(LOCK_FILE);
32
- return false;
31
+ if (existsSync(CACHE_FILE)) {
32
+ const cache = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
33
+ if (Date.now() - cache.ts < CACHE_TTL_MS) return cache.ok;
33
34
  }
34
- return true;
35
- } catch {
36
- return false;
37
- }
35
+ } catch { /* cache miss */ }
36
+
37
+ // psmux -V 실행
38
+ let ok = false;
39
+ try {
40
+ execFileSync("psmux", ["-V"], { timeout: 2000, stdio: "ignore" });
41
+ ok = true;
42
+ } catch { /* not installed */ }
43
+
44
+ // 캐시 저장
45
+ try {
46
+ writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), ok }));
47
+ } catch { /* ignore */ }
48
+
49
+ return ok;
38
50
  }
39
51
 
40
52
  /**
41
53
  * tfx-route.sh 명령에서 agent, prompt를 파싱한다.
42
- * 형식: bash ~/.claude/scripts/tfx-route.sh {agent} '{prompt}' {mcp} [timeout] [context]
43
54
  */
44
55
  function parseRouteCommand(cmd) {
45
- // MCP 프로필 목록 (tfx-route.sh의 마지막 위치 인자)
46
56
  const MCP_PROFILES = ["implement", "analyze", "review", "docs"];
47
57
 
48
- // 전략: agent명 추출 후, 나머지에서 MCP 프로필을 역방향으로 찾아 프롬프트 경계를 결정
49
58
  const agentMatch = cmd.match(/tfx-route\.sh\s+(\S+)\s+/);
50
59
  if (!agentMatch) return null;
51
60
 
52
61
  const agent = agentMatch[1];
53
62
  const afterAgent = cmd.slice(agentMatch.index + agentMatch[0].length);
54
63
 
55
- // MCP 프로필을 역방향으로 찾기
56
64
  let mcp = "";
57
65
  let promptRaw = afterAgent;
58
66
  for (const profile of MCP_PROFILES) {
59
- // 프롬프트 뒤에 오는 MCP 프로필 (공백 구분)
60
67
  const profileIdx = afterAgent.lastIndexOf(` ${profile}`);
61
68
  if (profileIdx >= 0) {
62
69
  mcp = profile;
@@ -65,26 +72,24 @@ function parseRouteCommand(cmd) {
65
72
  }
66
73
  }
67
74
 
68
- // 프롬프트에서 바깥쪽 따옴표 제거
69
75
  const prompt = promptRaw
70
76
  .replace(/^['"]/, "")
71
77
  .replace(/['"]$/, "")
72
- .replace(/'\\''/g, "'") // bash '\'' → '
73
- .replace(/'"'"'/g, "'") // bash '"'"' → '
78
+ .replace(/'\\''/g, "'")
79
+ .replace(/'"'"'/g, "'")
74
80
  .trim();
75
81
 
76
82
  return { agent, prompt, mcp };
77
83
  }
78
84
 
79
85
  function autoRoute(updatedCommand, reason) {
80
- const output = {
86
+ process.stdout.write(JSON.stringify({
81
87
  hookSpecificOutput: {
82
88
  hookEventName: "PreToolUse",
83
89
  updatedInput: { command: updatedCommand },
84
90
  additionalContext: reason,
85
91
  },
86
- };
87
- process.stdout.write(JSON.stringify(output));
92
+ }));
88
93
  process.exit(0);
89
94
  }
90
95
 
@@ -94,7 +99,8 @@ function deny(reason) {
94
99
  }
95
100
 
96
101
  async function main() {
97
- if (!isLockActive()) process.exit(0);
102
+ // psmux 미설치 → 전부 통과
103
+ if (!isPsmuxInstalled()) process.exit(0);
98
104
 
99
105
  let raw = "";
100
106
  for await (const chunk of process.stdin) raw += chunk;
@@ -109,58 +115,45 @@ async function main() {
109
115
  const toolName = input.tool_name || "";
110
116
  const toolInput = input.tool_input || {};
111
117
 
112
- // ── Bash: tfx-route.sh → headless auto-route ──
118
+ // ── Bash ──
113
119
  if (toolName === "Bash") {
114
120
  const cmd = toolInput.command || "";
115
121
 
116
- // 이미 headless 명령이면 통과
122
+ // headless 명령은 통과
117
123
  if (cmd.includes("tfx multi") || cmd.includes("triflux.mjs multi")) {
118
124
  process.exit(0);
119
125
  }
120
126
 
121
- // 마커 조작 통과
122
- if (cmd.includes("tfx-headless-guard")) {
123
- process.exit(0);
124
- }
125
-
126
- // codex/gemini 직접 CLI 호출 감지 → deny (auto-route 불가: 원본 agent/role 정보 없음)
127
+ // codex/gemini 직접 CLI 호출 → deny
127
128
  if (/\bcodex\s+exec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
128
129
  deny(
129
- "[headless-guard] Phase 3 활성 중. codex/gemini 직접 호출하지 마세요. " +
130
- 'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...") 로 headless 엔진에 위임하세요.',
130
+ "[headless-guard] codex/gemini 직접 호출 대신 headless를 사용하세요. " +
131
+ 'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")',
131
132
  );
132
133
  }
133
134
 
134
- // tfx-route.sh 개별 호출 headless 자동 변환
135
- if (cmd.includes("tfx-route.sh")) {
135
+ // tfx-route.sh 실행만 감지: 명령이 bash로 시작할 때만 (커밋 메시지/echo 등 무시)
136
+ if (/^\s*bash\s+.*tfx-route\.sh\s/.test(cmd)) {
136
137
  const parsed = parseRouteCommand(cmd);
137
138
  if (parsed) {
138
- const role = parsed.agent;
139
- // 프롬프트에서 싱글쿼트 이스케이프
140
139
  const safePrompt = parsed.prompt.replace(/'/g, "'\\''");
141
- const headlessCmd =
142
- `tfx multi --teammate-mode headless --auto-attach ` +
143
- `--assign '${parsed.agent}:${safePrompt}:${role}' --timeout 600`;
144
140
  autoRoute(
145
- headlessCmd,
146
- `[headless-guard] auto-route: tfx-route.sh → headless 변환. 원본 agent=${parsed.agent}, mcp=${parsed.mcp}`,
141
+ `tfx multi --teammate-mode headless --auto-attach --assign '${parsed.agent}:${safePrompt}:${parsed.agent}' --timeout 600`,
142
+ `[headless-guard] auto-route: tfx-route.sh ${parsed.agent} → headless. mcp=${parsed.mcp}`,
147
143
  );
148
144
  }
149
- // 파싱 실패 시 deny fallback
150
145
  deny(
151
- "[headless-guard] Phase 3 활성 중. tfx-route.sh 명령을 headless로 변환할 없습니다. " +
152
- 'Bash("tfx multi --teammate-mode headless --auto-attach --assign \'cli:prompt:role\' ...") 형식을 사용하세요.',
146
+ "[headless-guard] tfx-route.sh headless로 변환 실패. " +
147
+ 'Bash("tfx multi --teammate-mode headless --assign \'cli:prompt:role\' ...") 형식을 사용하세요.',
153
148
  );
154
149
  }
155
150
  }
156
151
 
157
- // ── Agent: CLI 워커 래핑 시도 → deny (tool 타입 변환 불가) ──
152
+ // ── Agent: CLI 워커 래핑 → deny ──
158
153
  if (toolName === "Agent") {
159
- const prompt = (toolInput.prompt || "").toLowerCase();
160
- const desc = (toolInput.description || "").toLowerCase();
161
- const combined = `${prompt} ${desc}`;
154
+ const combined = `${(toolInput.prompt || "").toLowerCase()} ${(toolInput.description || "").toLowerCase()}`;
162
155
 
163
- const cliWorkerPatterns = [
156
+ const cliPatterns = [
164
157
  /codex\s+(exec|run|실행)/,
165
158
  /gemini\s+(-p|run|실행)/,
166
159
  /tfx-route/,
@@ -168,10 +161,9 @@ async function main() {
168
161
  /bash.*gemini/,
169
162
  ];
170
163
 
171
- if (cliWorkerPatterns.some((p) => p.test(combined))) {
164
+ if (cliPatterns.some((p) => p.test(combined))) {
172
165
  deny(
173
- "[headless-guard] Phase 3 활성 중. " +
174
- "Codex/Gemini를 Agent()로 래핑하지 말고 headless --assign으로 전달하세요. " +
166
+ "[headless-guard] Codex/Gemini를 Agent()로 래핑하지 마세요. " +
175
167
  'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")',
176
168
  );
177
169
  }
@@ -75,29 +75,17 @@ preflight와 Agent 생성을 병렬로 실행하여 사용자 체감 지연을
75
75
 
76
76
  ### Phase 3: Lead-Direct Headless 실행 (v6.0.0, 기본)
77
77
 
78
- > **MANDATORY: 2개+ CLI 워커 실행 시 headless 엔진 필수**
79
- > 서브태스크가 2개 이상이면 반드시 아래 `Bash()` 명령으로 headless 엔진을 실행한다.
80
- > `Agent()` 백그라운드나 `Bash(tfx-route.sh)` 개별 호출로 대체하지 않는다.
78
+ > **MANDATORY: CLI 워커는 headless 엔진으로 실행**
79
+ > CLI 워커(Codex/Gemini)는 반드시 아래 `Bash()` 명령으로 headless 엔진을 통해 실행한다.
80
+ > `Bash(tfx-route.sh)` 개별 호출이나 `Agent()` CLI 래핑은 PreToolUse 훅이 자동 차단/변환한다.
81
81
  > headless 엔진이 psmux 세션 생성 → WT 자동 팝업 → CLI dispatch → 결과 수집을 전부 처리한다.
82
82
 
83
- **Step 1 — headless guard 활성화 (Phase 3 진입 시 반드시 먼저 실행):**
84
-
85
- ```
86
- Bash("node -e \"require('fs').writeFileSync(require('os').tmpdir()+'/tfx-headless-guard.lock', String(Date.now()))\"")
87
- ```
88
-
89
- **Step 2 — headless 엔진 실행 (Lead가 호출하는 유일한 명령):**
83
+ **실행 명령 (Lead가 호출하는 유일한 명령):**
90
84
 
91
85
  ```
92
86
  Bash("tfx multi --teammate-mode headless --auto-attach --assign 'codex:{프롬프트1}:{역할1}' --assign 'gemini:{프롬프트2}:{역할2}' --timeout 600")
93
87
  ```
94
88
 
95
- **Step 3 — headless guard 해제 (Phase 3 완료 후):**
96
-
97
- ```
98
- Bash("node -e \"try{require('fs').unlinkSync(require('os').tmpdir()+'/tfx-headless-guard.lock')}catch{}\"")
99
- ```
100
-
101
89
  **예시:**
102
90
 
103
91
  ```