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.
- package/hub/team/cli/commands/start/index.mjs +2 -2
- package/hub/team/cli/commands/start/parse-args.mjs +6 -0
- package/hub/team/cli/commands/start/start-headless.mjs +25 -19
- package/hub/team/headless.mjs +74 -40
- package/hub/team/psmux.mjs +1 -0
- package/package.json +1 -1
- package/scripts/headless-guard.mjs +57 -65
- package/skills/tfx-multi/SKILL.md +4 -16
|
@@ -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(
|
|
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
|
|
21
|
-
progressive: progressive !== false,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// 결과
|
|
49
|
+
// 결과 파일 경로 (Lead가 필요시 Read()로 확인)
|
|
54
50
|
for (const r of results) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
console.log(
|
|
58
|
-
|
|
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
|
|
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
|
47
|
+
return `${cls}codex exec '${escaped}' -o '${resultFile}' --color never`;
|
|
46
48
|
case "gemini":
|
|
47
|
-
return
|
|
49
|
+
return `${cls}gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
48
50
|
case "claude":
|
|
49
|
-
return
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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;
|
|
367
|
+
return false;
|
|
366
368
|
}
|
|
367
369
|
|
|
368
|
-
// triflux WT 프로필 확보 (투명도 + 테마 + 폰트 크기)
|
|
369
370
|
ensureWtProfile(workerCount);
|
|
370
371
|
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -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,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
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* psmux가 설치된 환경에서 Bash(tfx-route.sh) 개별 호출을
|
|
6
|
+
* 자동으로 headless 명령으로 변환한다.
|
|
7
|
+
*
|
|
8
|
+
* v2: 마커 파일 의존 제거. psmux 설치 여부만으로 판단.
|
|
9
|
+
* Opus가 SKILL.md를 무시해도 auto-route가 작동한다.
|
|
7
10
|
*
|
|
8
11
|
* 동작:
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
24
|
-
const
|
|
25
|
+
const CACHE_FILE = join(tmpdir(), "tfx-psmux-check.json");
|
|
26
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
|
|
25
27
|
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
+
function isPsmuxInstalled() {
|
|
29
|
+
// 캐시 확인
|
|
28
30
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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, "'")
|
|
73
|
-
.replace(/'"'"'/g, "'")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
118
|
+
// ── Bash ──
|
|
113
119
|
if (toolName === "Bash") {
|
|
114
120
|
const cmd = toolInput.command || "";
|
|
115
121
|
|
|
116
|
-
//
|
|
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]
|
|
130
|
-
'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")
|
|
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
|
|
135
|
-
if (
|
|
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
|
-
|
|
146
|
-
`[headless-guard] auto-route: tfx-route.sh
|
|
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]
|
|
152
|
-
'Bash("tfx multi --teammate-mode headless --
|
|
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 워커 래핑
|
|
152
|
+
// ── Agent: CLI 워커 래핑 → deny ──
|
|
158
153
|
if (toolName === "Agent") {
|
|
159
|
-
const
|
|
160
|
-
const desc = (toolInput.description || "").toLowerCase();
|
|
161
|
-
const combined = `${prompt} ${desc}`;
|
|
154
|
+
const combined = `${(toolInput.prompt || "").toLowerCase()} ${(toolInput.description || "").toLowerCase()}`;
|
|
162
155
|
|
|
163
|
-
const
|
|
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 (
|
|
164
|
+
if (cliPatterns.some((p) => p.test(combined))) {
|
|
172
165
|
deny(
|
|
173
|
-
"[headless-guard]
|
|
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:
|
|
79
|
-
>
|
|
80
|
-
> `
|
|
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
|
-
|
|
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
|
```
|