triflux 9.5.1 → 9.7.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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/triflux.mjs +498 -0
  3. package/hooks/hook-registry.json +24 -2
  4. package/hooks/mcp-config-watcher.mjs +85 -0
  5. package/hub/team/headless.mjs +8 -1
  6. package/hub/team/psmux.mjs +24 -4
  7. package/package.json +1 -1
  8. package/scripts/__tests__/mcp-guard-engine.test.mjs +118 -0
  9. package/scripts/headless-guard.mjs +1 -1
  10. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  11. package/scripts/mcp-safety-guard.mjs +44 -0
  12. package/scripts/setup.mjs +71 -0
  13. package/scripts/tfx-route.sh +17 -5
  14. package/skills/tfx-analysis/SKILL.md +4 -0
  15. package/skills/tfx-auto/SKILL.md +4 -0
  16. package/skills/tfx-auto-codex/SKILL.md +4 -0
  17. package/skills/tfx-autopilot/SKILL.md +4 -0
  18. package/skills/tfx-autoresearch/SKILL.md +4 -0
  19. package/skills/tfx-autoroute/SKILL.md +4 -0
  20. package/skills/tfx-codex/SKILL.md +4 -0
  21. package/skills/tfx-codex-swarm/SKILL.md +33 -2
  22. package/skills/tfx-consensus/SKILL.md +4 -0
  23. package/skills/tfx-debate/SKILL.md +4 -0
  24. package/skills/tfx-deep-analysis/SKILL.md +4 -0
  25. package/skills/tfx-deep-interview/SKILL.md +4 -0
  26. package/skills/tfx-deep-plan/SKILL.md +4 -0
  27. package/skills/tfx-deep-qa/SKILL.md +4 -0
  28. package/skills/tfx-deep-research/SKILL.md +4 -0
  29. package/skills/tfx-deep-review/SKILL.md +4 -0
  30. package/skills/tfx-doctor/SKILL.md +3 -0
  31. package/skills/tfx-find/SKILL.md +4 -0
  32. package/skills/tfx-forge/SKILL.md +4 -0
  33. package/skills/tfx-fullcycle/SKILL.md +4 -0
  34. package/skills/tfx-gemini/SKILL.md +4 -0
  35. package/skills/tfx-hub/SKILL.md +4 -0
  36. package/skills/tfx-index/SKILL.md +4 -0
  37. package/skills/tfx-interview/SKILL.md +4 -0
  38. package/skills/tfx-multi/SKILL.md +4 -0
  39. package/skills/tfx-panel/SKILL.md +4 -0
  40. package/skills/tfx-persist/SKILL.md +4 -0
  41. package/skills/tfx-plan/SKILL.md +4 -0
  42. package/skills/tfx-prune/SKILL.md +4 -0
  43. package/skills/tfx-qa/SKILL.md +4 -0
  44. package/skills/tfx-ralph/SKILL.md +4 -0
  45. package/skills/tfx-remote-setup/SKILL.md +4 -0
  46. package/skills/tfx-remote-spawn/SKILL.md +4 -0
  47. package/skills/tfx-research/SKILL.md +4 -0
  48. package/skills/tfx-review/SKILL.md +4 -0
@@ -243,10 +243,12 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
243
243
  * @returns {Promise<Array<{d, completion, output}>>}
244
244
  */
245
245
  async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec) {
246
+ const ac = new AbortController();
247
+
246
248
  // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
247
249
  return Promise.all(dispatches.map(async (d) => {
248
250
  // onPoll → onProgress 변환 (throttle by progressIntervalSec)
249
- const pollOpts = {};
251
+ const pollOpts = { signal: ac.signal };
250
252
  if (safeProgress && progressIntervalSec > 0) {
251
253
  let lastProgressAt = 0;
252
254
  const intervalMs = progressIntervalSec * 1000;
@@ -268,6 +270,11 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
268
270
  if (d.logPath) pollOpts.logPath = d.logPath;
269
271
  const completion = await waitForCompletion(sessionName, d.paneId || d.paneName, d.token, timeoutSec, pollOpts);
270
272
 
273
+ // 세션 사망 감지 시 나머지 워커 폴링 즉시 중단
274
+ if (completion.sessionDead && !ac.signal.aborted) {
275
+ ac.abort();
276
+ }
277
+
271
278
  const output = completion.matched
272
279
  ? readResult(d.resultFile, d.paneId)
273
280
  : "";
@@ -570,7 +570,7 @@ function killOrphanPipeHelpers(sessionName) {
570
570
  const safeSession = sanitizePathPart(sessionName);
571
571
  try {
572
572
  const output = childProcess.execSync(
573
- `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
573
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
574
574
  { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
575
575
  );
576
576
  const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
@@ -605,7 +605,7 @@ function killOrphanMcpProcesses(sessionName) {
605
605
  try {
606
606
  // 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
607
607
  const output = childProcess.execSync(
608
- `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
608
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'tfx-headless[/\\\\]${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
609
609
  { encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
610
610
  );
611
611
  const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
@@ -632,6 +632,9 @@ export function killPsmuxSession(sessionName) {
632
632
  }
633
633
  disableAllPipeCaptures(sessionName, paneIds);
634
634
 
635
+ // pipe-pane reader가 EOF를 처리하고 정상 종료할 시간 확보
636
+ sleepMs(500);
637
+
635
638
  // 2. pane 프로세스 트리 강제 종료 (MCP 서버 포함)
636
639
  const pids = collectPanePids(sessionName);
637
640
  for (const pid of pids) {
@@ -856,7 +859,8 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
856
859
  * @param {number} timeoutSec
857
860
  * @param {object} [opts]
858
861
  * @param {(snapshot: {content: string, paneId: string, paneName: string, elapsed: number}) => void} [opts.onPoll] — 각 폴링 주기마다 호출
859
- * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null }}
862
+ * @param {AbortSignal} [opts.signal] 외부에서 폴링 중단 요청 사용
863
+ * @returns {{ matched: boolean, paneId: string, paneName: string, logPath: string, match: string|null, aborted?: boolean }}
860
864
  */
861
865
  export async function waitForPattern(sessionName, paneNameOrTarget, pattern, timeoutSec = 300, opts = {}) {
862
866
  ensurePsmuxInstalled();
@@ -892,7 +896,14 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
892
896
  const deadline = startTime + Math.max(0, Math.trunc(timeoutSec * 1000));
893
897
  const regex = toPatternRegExp(pattern);
894
898
 
899
+ if (opts?.signal?.aborted) {
900
+ return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
901
+ }
902
+
895
903
  while (Date.now() <= deadline) {
904
+ if (opts?.signal?.aborted) {
905
+ return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
906
+ }
896
907
  // E4 크래시 복구: capture 실패 시 세션 생존 체크
897
908
  try {
898
909
  if (opts.logPath) {
@@ -940,6 +951,9 @@ export async function waitForPattern(sessionName, paneNameOrTarget, pattern, tim
940
951
  break;
941
952
  }
942
953
  await sleepMsAsync(POLL_INTERVAL_MS);
954
+ if (opts?.signal?.aborted) {
955
+ return { matched: false, paneId: pane.paneId, paneName, logPath, match: null, aborted: true };
956
+ }
943
957
  }
944
958
 
945
959
  return {
@@ -1112,7 +1126,13 @@ export function killWorker(sessionName, workerName) {
1112
1126
  // send-keys 실패 무시
1113
1127
  }
1114
1128
 
1115
- sleepMs(1000);
1129
+ try {
1130
+ psmuxExec(["send-keys", "-t", paneId, "exit", "Enter"]);
1131
+ } catch {
1132
+ // send-keys 실패 무시
1133
+ }
1134
+
1135
+ sleepMs(2000);
1116
1136
 
1117
1137
  try {
1118
1138
  psmuxExec(["kill-pane", "-t", paneId]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.5.1",
3
+ "version": "9.7.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": {
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { describe, it, afterEach } from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import {
9
+ isWatchedPath,
10
+ loadRegistry,
11
+ remediate,
12
+ resolveHubUrl,
13
+ scanForStdioServers,
14
+ } from "../lib/mcp-guard-engine.mjs";
15
+
16
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
17
+ const PROJECT_ROOT = resolve(TEST_DIR, "..", "..");
18
+ const originalHome = {
19
+ HOME: process.env.HOME,
20
+ USERPROFILE: process.env.USERPROFILE,
21
+ TFX_HUB_PORT: process.env.TFX_HUB_PORT,
22
+ };
23
+
24
+ function createHomeDir(prefix = "mcp-guard-") {
25
+ const base = join(tmpdir(), `${prefix}${Date.now()}-${Math.random().toString(16).slice(2)}`);
26
+ mkdirSync(base, { recursive: true });
27
+ mkdirSync(join(base, ".gemini"), { recursive: true });
28
+ mkdirSync(join(base, ".claude", "cache", "tfx-hub"), { recursive: true });
29
+ mkdirSync(join(base, ".codex"), { recursive: true });
30
+ return base;
31
+ }
32
+
33
+ function withHome(homeDir) {
34
+ process.env.HOME = homeDir;
35
+ process.env.USERPROFILE = homeDir;
36
+ }
37
+
38
+ afterEach(() => {
39
+ if (originalHome.HOME === undefined) delete process.env.HOME;
40
+ else process.env.HOME = originalHome.HOME;
41
+
42
+ if (originalHome.USERPROFILE === undefined) delete process.env.USERPROFILE;
43
+ else process.env.USERPROFILE = originalHome.USERPROFILE;
44
+
45
+ if (originalHome.TFX_HUB_PORT === undefined) delete process.env.TFX_HUB_PORT;
46
+ else process.env.TFX_HUB_PORT = originalHome.TFX_HUB_PORT;
47
+ });
48
+
49
+ describe("mcp guard engine", () => {
50
+ it("loads the MCP registry", () => {
51
+ const registry = loadRegistry();
52
+ assert.equal(registry.version, 1);
53
+ assert.equal(registry.servers["tfx-hub"].url, "http://127.0.0.1:27888/mcp");
54
+ assert.equal(registry.policies.watched_paths.length, 5);
55
+ });
56
+
57
+ it("matches watched paths for Gemini and local .mcp.json", () => {
58
+ const homeDir = createHomeDir();
59
+ withHome(homeDir);
60
+
61
+ assert.equal(isWatchedPath(join(homeDir, ".gemini", "settings.json")), true);
62
+ assert.equal(isWatchedPath(join(PROJECT_ROOT, "nested", ".mcp.json")), true);
63
+ assert.equal(isWatchedPath(join(PROJECT_ROOT, "nested", "settings.yaml")), false);
64
+ });
65
+
66
+ it("detects stdio MCP servers from JSON config", () => {
67
+ const homeDir = createHomeDir();
68
+ withHome(homeDir);
69
+
70
+ const settingsPath = join(homeDir, ".gemini", "settings.json");
71
+ writeFileSync(settingsPath, JSON.stringify({
72
+ mcpServers: {
73
+ "unsafe-stdio": { command: "node", args: ["server.js"] },
74
+ "safe-url": { url: "http://127.0.0.1:27888/mcp" },
75
+ },
76
+ }, null, 2));
77
+
78
+ const found = scanForStdioServers(settingsPath);
79
+ assert.deepEqual(found.map((server) => server.name), ["unsafe-stdio"]);
80
+ });
81
+
82
+ it("replaces stdio MCP entries with tfx-hub and writes a backup", () => {
83
+ const homeDir = createHomeDir();
84
+ withHome(homeDir);
85
+
86
+ const pidPath = join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid");
87
+ writeFileSync(pidPath, JSON.stringify({ host: "127.0.0.1", port: 30123 }), "utf8");
88
+
89
+ const settingsPath = join(homeDir, ".gemini", "settings.json");
90
+ writeFileSync(settingsPath, JSON.stringify({
91
+ mcpServers: {
92
+ "unsafe-stdio": { command: "node", args: ["server.js"] },
93
+ },
94
+ }, null, 2));
95
+
96
+ const result = remediate(settingsPath, scanForStdioServers(settingsPath), { stdio_action: "replace-with-hub" });
97
+ const updated = JSON.parse(readFileSync(settingsPath, "utf8"));
98
+
99
+ assert.equal(result.modified, true);
100
+ assert.equal(existsSync(`${settingsPath}.bak`), true);
101
+ assert.deepEqual(result.removedServers, ["unsafe-stdio"]);
102
+ assert.equal(updated.mcpServers["tfx-hub"].url, "http://127.0.0.1:30123/mcp");
103
+ assert.equal(Object.hasOwn(updated.mcpServers, "unsafe-stdio"), false);
104
+ });
105
+
106
+ it("uses hub.pid port before registry fallback when resolving Hub URL", () => {
107
+ const homeDir = createHomeDir();
108
+ withHome(homeDir);
109
+
110
+ writeFileSync(
111
+ join(homeDir, ".claude", "cache", "tfx-hub", "hub.pid"),
112
+ JSON.stringify({ host: "127.0.0.1", port: 29991 }),
113
+ "utf8",
114
+ );
115
+
116
+ assert.equal(resolveHubUrl(), "http://127.0.0.1:29991/mcp");
117
+ });
118
+ });
@@ -193,7 +193,7 @@ async function main() {
193
193
  }
194
194
 
195
195
  // codex/gemini 직접 CLI 호출 → deny
196
- if (/\bcodex\s+exec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
196
+ if (/\bcodex\b.*\bexec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
197
197
  deny(
198
198
  "[headless-guard] codex/gemini 직접 호출은 spawn-session에서 차단됩니다. " +
199
199
  `승인된 경로: ${HEADLESS_FALLBACK_COMMAND}. ` +