triflux 6.0.18 → 6.0.20

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.
@@ -7,7 +7,7 @@ export function parseTeamArgs(args = []) {
7
7
  let teammateMode = "auto";
8
8
  const taskParts = [];
9
9
  const assigns = []; // --assign "codex:프롬프트:역할" 형식
10
- let autoAttach = false;
10
+ let autoAttach = true;
11
11
  let progressive = true;
12
12
  let timeoutSec = 300;
13
13
 
@@ -238,11 +238,9 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
238
238
  try {
239
239
  return await runHeadless(sessionName, assignments, runOpts);
240
240
  } finally {
241
- try {
242
- killPsmuxSession(sessionName);
243
- } catch {
244
- // 이미 종료된 세션 — 무시
245
- }
241
+ try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
242
+ // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
243
+ // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
246
244
  }
247
245
  }
248
246
 
@@ -369,28 +367,36 @@ export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
369
367
  ensureWtProfile(workerCount);
370
368
 
371
369
  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) {
370
+ const mode = opts.mode || "auto"; // "split" | "window" | "auto"
371
+
372
+ // v6.0.20: 기본 popup(별도 창). split은 --split 명시 시에만.
373
+ // 이유: WT sp는 포커스된 pane을 분할하므로, 사용자가 다른 탭에 있으면
374
+ // 엉뚱한 곳이 분할됨. 별도 창은 포커스 무관하게 안정적.
375
+ const preferSplit = mode === "split";
376
+ const preferWindow = mode !== "split";
377
+
378
+ if (preferSplit && !preferWindow && process.env.WT_SESSION) {
379
+ // inner split — 같은 WT 창에서 가로 분할
380
+ const splitSize = "0.50";
376
381
  for (const shell of shells) {
377
382
  try {
378
- // 1) 하단 분할 생성
379
383
  execSync(
380
384
  `wt.exe -w 0 sp -H --size ${splitSize} --profile triflux --title triflux -- ${shell} -Command "psmux attach -t ${sessionName}"`,
381
385
  { stdio: "ignore", timeout: 5000 },
382
386
  );
383
- // 2) 포커스를 Claude Code(위 pane)로 되돌림
384
387
  try { execSync(`wt.exe -w 0 mf up`, { stdio: "ignore", timeout: 2000 }); } catch { /* 무시 */ }
385
388
  return true;
386
389
  } catch { /* 다음 shell */ }
387
390
  }
388
391
  }
389
- // 방법 2 fallback: (WT_SESSION 없거나 sp 실패 시)
392
+ // 별도 WT PowerShell 콘솔 크기 지정 psmux attach
393
+ const cols = Math.max(100, 60 + workerCount * 15);
394
+ const rows = Math.max(25, 15 + workerCount * 4);
395
+ const resizePs = `$s=$Host.UI.RawUI;$b=$s.BufferSize;$b.Width=${cols};$s.BufferSize=$b;$w=$s.WindowSize;$w.Width=${cols};$w.Height=${rows};$s.WindowSize=$w`;
390
396
  for (const shell of shells) {
391
397
  try {
392
398
  execSync(
393
- `start "" /b wt.exe nt --profile triflux --title triflux -- ${shell} -Command "psmux attach -t ${sessionName}"`,
399
+ `start "" /b wt.exe -w new --profile triflux --title "triflux workers (${workerCount})" -- ${shell} -NoProfile -Command "${resizePs};psmux attach -t ${sessionName}"`,
394
400
  { stdio: "ignore", shell: true, timeout: 5000 },
395
401
  );
396
402
  return true;
@@ -460,7 +466,8 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
460
466
  runOpts.onProgress = (event) => {
461
467
  if (autoAttach && event.type === "session_created" && !terminalAttached) {
462
468
  terminalAttached = true;
463
- autoAttachTerminal(sessionName, {}, assignments.length);
469
+ // v6.0.20: 항상 별도 창 (포커스 문제 회피)
470
+ autoAttachTerminal(sessionName, { mode: "window" }, assignments.length);
464
471
  }
465
472
  if (userOnProgress) userOnProgress(event);
466
473
  };
@@ -531,11 +538,13 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
531
538
  return psmuxSessionExists(sessionName);
532
539
  },
533
540
 
534
- /** 세션 종료 */
541
+ /** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
535
542
  kill() {
536
543
  if (this._killed) return;
537
544
  this._killed = true;
538
545
  try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
546
+ // WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
547
+ // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
539
548
  },
540
549
  };
541
550
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "6.0.18",
3
+ "version": "6.0.20",
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
  }
package/scripts/setup.mjs CHANGED
@@ -117,6 +117,11 @@ const SYNC_MAP = [
117
117
  dst: join(CLAUDE_DIR, "scripts", "lib", "keyword-rules.mjs"),
118
118
  label: "lib/keyword-rules.mjs",
119
119
  },
120
+ {
121
+ src: join(PLUGIN_ROOT, "scripts", "headless-guard.mjs"),
122
+ dst: join(CLAUDE_DIR, "scripts", "headless-guard.mjs"),
123
+ label: "headless-guard.mjs",
124
+ },
120
125
  ];
121
126
 
122
127
  function getVersion(filePath) {
@@ -549,6 +554,55 @@ try {
549
554
  writeFileSync(settingsPath, JSON.stringify(hookSettings, null, 2) + "\n", "utf8");
550
555
  synced++;
551
556
  }
557
+
558
+ // ── PreToolUse 훅: headless-guard (auto-route) ──
559
+ // Phase 3 headless 모드 활성 중 tfx-route.sh 개별 호출을
560
+ // headless 명령으로 자동 변환한다.
561
+ if (!Array.isArray(hookSettings.hooks.PreToolUse)) {
562
+ hookSettings.hooks.PreToolUse = [];
563
+ }
564
+
565
+ const guardScriptPath = join(CLAUDE_DIR, "scripts", "headless-guard.mjs").replace(/\\/g, "/");
566
+ const hasGuardHook = hookSettings.hooks.PreToolUse.some((entry) =>
567
+ Array.isArray(entry.hooks) &&
568
+ entry.hooks.some((h) => typeof h.command === "string" && h.command.includes("headless-guard")),
569
+ );
570
+
571
+ if (!hasGuardHook && existsSync(guardScriptPath.replace(/\//g, "\\"))) {
572
+ const nodePath = process.execPath.replace(/\\/g, "/");
573
+ const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
574
+
575
+ hookSettings.hooks.PreToolUse.push({
576
+ matcher: "Bash|Agent",
577
+ hooks: [
578
+ {
579
+ type: "command",
580
+ command: `${nodeRef} "${guardScriptPath}"`,
581
+ timeout: 3,
582
+ },
583
+ ],
584
+ });
585
+ writeFileSync(settingsPath, JSON.stringify(hookSettings, null, 2) + "\n", "utf8");
586
+ synced++;
587
+ } else if (hasGuardHook) {
588
+ // 기존 훅 경로를 동기화된 경로로 업데이트
589
+ let updated = false;
590
+ for (const entry of hookSettings.hooks.PreToolUse) {
591
+ if (!Array.isArray(entry.hooks)) continue;
592
+ for (const h of entry.hooks) {
593
+ if (typeof h.command === "string" && h.command.includes("headless-guard") && !h.command.includes(guardScriptPath)) {
594
+ const nodePath = process.execPath.replace(/\\/g, "/");
595
+ const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
596
+ h.command = `${nodeRef} "${guardScriptPath}"`;
597
+ updated = true;
598
+ }
599
+ }
600
+ }
601
+ if (updated) {
602
+ writeFileSync(settingsPath, JSON.stringify(hookSettings, null, 2) + "\n", "utf8");
603
+ synced++;
604
+ }
605
+ }
552
606
  } catch {
553
607
  // settings.json 파싱 실패 시 무시 — 기존 설정 보존
554
608
  }
@@ -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
  ```