triflux 9.2.4 → 9.4.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 (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/triflux.mjs +48 -217
  3. package/hub/pipe.mjs +1 -8
  4. package/hub/team/psmux.mjs +21 -1
  5. package/hub/workers/claude-worker.mjs +1 -24
  6. package/hub/workers/codex-mcp.mjs +0 -4
  7. package/hub/workers/gemini-worker.mjs +108 -28
  8. package/hub/workers/interface.mjs +0 -1
  9. package/hub/workers/worker-utils.mjs +26 -0
  10. package/package.json +1 -1
  11. package/scripts/__tests__/remote-spawn.test.mjs +17 -3
  12. package/scripts/cross-review-gate.mjs +11 -65
  13. package/scripts/cross-review-tracker.mjs +10 -51
  14. package/scripts/headless-guard.mjs +5 -17
  15. package/scripts/lib/cross-review-utils.mjs +51 -0
  16. package/scripts/lib/hook-utils.mjs +14 -0
  17. package/scripts/lib/mcp-filter.mjs +10 -1
  18. package/scripts/psmux-safety-guard.mjs +64 -0
  19. package/scripts/remote-spawn.mjs +77 -23
  20. package/scripts/session-spawn-helper.mjs +2 -1
  21. package/scripts/setup.mjs +36 -6
  22. package/scripts/tfx-route.sh +93 -10
  23. package/skills/tfx-auto/SKILL.md +4 -1
  24. package/skills/tfx-auto-codex/SKILL.md +3 -1
  25. package/skills/tfx-autopilot/SKILL.md +1 -1
  26. package/skills/tfx-autoresearch/SKILL.md +2 -0
  27. package/skills/tfx-codex/SKILL.md +3 -1
  28. package/skills/tfx-consensus/SKILL.md +5 -3
  29. package/skills/tfx-fullcycle/SKILL.md +1 -1
  30. package/skills/tfx-gemini/SKILL.md +3 -1
  31. package/skills/tfx-hooks/SKILL.md +216 -0
  32. package/skills/tfx-multi/SKILL.md +4 -1
  33. package/skills/tfx-plan/SKILL.md +1 -1
  34. package/skills/tfx-psmux-rules/SKILL.md +100 -13
  35. package/skills/tfx-ralph/SKILL.md +11 -66
  36. package/skills/tfx-research/SKILL.md +1 -1
  37. package/skills/tfx-review/SKILL.md +1 -1
  38. package/skills/tfx-setup/SKILL.md +17 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.0.0",
3
+ "version": "9.3.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
package/bin/triflux.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // triflux CLI — setup, doctor, version
3
- import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, rmSync, statSync, openSync, closeSync } from "fs";
3
+ import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, statSync, openSync, closeSync } from "fs";
4
4
  import { join, dirname } from "path";
5
5
  import { homedir } from "os";
6
6
  import { execSync, execFileSync, spawn } from "child_process";
@@ -12,6 +12,11 @@ import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
12
12
  import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
13
13
  import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
14
14
  import { ensureGeminiProfiles } from "../scripts/lib/gemini-profiles.mjs";
15
+ import {
16
+ SYNC_MAP, SKILL_ALIASES, REQUIRED_CODEX_PROFILES,
17
+ syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
18
+ ensureCodexProfiles, getVersion,
19
+ } from "../scripts/setup.mjs";
15
20
 
16
21
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
22
  const CLAUDE_DIR = join(homedir(), ".claude");
@@ -22,72 +27,6 @@ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
22
27
  // 이 배열에 포함된 버전에서만 star prompt를 표시한다 (빈 배열 = 모든 버전에서 표시)
23
28
  const STAR_PROMPT_VERSIONS = [];
24
29
 
25
- const REQUIRED_CODEX_PROFILES = [
26
- {
27
- name: "codex53_high",
28
- lines: [
29
- 'model = "gpt-5.3-codex"',
30
- 'model_reasoning_effort = "high"',
31
- ],
32
- },
33
- {
34
- name: "codex53_xhigh",
35
- lines: [
36
- 'model = "gpt-5.3-codex"',
37
- 'model_reasoning_effort = "xhigh"',
38
- ],
39
- },
40
- {
41
- name: "spark53_low",
42
- lines: [
43
- 'model = "gpt-5.3-codex-spark"',
44
- 'model_reasoning_effort = "low"',
45
- ],
46
- },
47
- ];
48
-
49
- const SKILL_ALIASES = [
50
- {
51
- alias: "tfx-ralph",
52
- source: "tfx-persist",
53
- },
54
- ];
55
-
56
- function buildAliasedSkillContent(srcContent, { alias, source }) {
57
- return srcContent
58
- .replace(/^name:\s*.+$/m, `name: ${alias}`)
59
- .replaceAll(source, alias)
60
- .replace(/^#\s+.+$/m, `# ${alias} — Compatibility Alias for ${source}`);
61
- }
62
-
63
- function syncAliasedSkillDir(srcDir, dstDir, { alias, source }) {
64
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
65
-
66
- let count = 0;
67
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
68
- const srcPath = join(srcDir, entry.name);
69
- const dstPath = join(dstDir, entry.name);
70
-
71
- if (entry.isDirectory()) {
72
- count += syncAliasedSkillDir(srcPath, dstPath, { alias, source });
73
- continue;
74
- }
75
-
76
- if (!entry.name.endsWith(".md")) continue;
77
-
78
- const rawContent = readFileSync(srcPath, "utf8");
79
- const nextContent = entry.name === "SKILL.md"
80
- ? buildAliasedSkillContent(rawContent, { alias, source })
81
- : rawContent;
82
-
83
- if (!existsSync(dstPath) || readFileSync(dstPath, "utf8") !== nextContent) {
84
- writeFileSync(dstPath, nextContent, "utf8");
85
- count++;
86
- }
87
- }
88
-
89
- return count;
90
- }
91
30
 
92
31
  // ── 색상 체계 (triflux brand: amber/orange accent) ──
93
32
  const CYAN = "\x1b[36m";
@@ -328,13 +267,6 @@ function checkShellAvailable(shell) {
328
267
  } catch { return false; }
329
268
  }
330
269
 
331
- function getVersion(filePath) {
332
- try {
333
- const content = readFileSync(filePath, "utf8");
334
- const match = content.match(/VERSION\s*=\s*"([^"]+)"/);
335
- return match ? match[1] : null;
336
- } catch { return null; }
337
- }
338
270
 
339
271
  function parseSessionCreated(rawValue) {
340
272
  const value = String(rawValue || "").trim();
@@ -459,57 +391,35 @@ async function cleanupStaleTeamSessions(staleSessions) {
459
391
  return { cleaned, failed };
460
392
  }
461
393
 
462
- function escapeRegExp(value) {
463
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
464
- }
465
-
466
- function hasProfileSection(tomlContent, profileName) {
467
- const section = `^\\[profiles\\.${escapeRegExp(profileName)}\\]\\s*$`;
468
- return new RegExp(section, "m").test(tomlContent);
469
- }
470
-
471
- function ensureCodexProfiles() {
472
- try {
473
- if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
474
-
475
- const original = existsSync(CODEX_CONFIG_PATH)
476
- ? readFileSync(CODEX_CONFIG_PATH, "utf8")
477
- : "";
478
394
 
479
- let updated = original;
480
- let added = 0;
481
-
482
- for (const profile of REQUIRED_CODEX_PROFILES) {
483
- if (hasProfileSection(updated, profile.name)) continue;
395
+ function previewCodexProfiles() {
396
+ const original = existsSync(CODEX_CONFIG_PATH)
397
+ ? readFileSync(CODEX_CONFIG_PATH, "utf8")
398
+ : "";
399
+ let updated = original;
400
+ const profiles = [];
484
401
 
402
+ for (const profile of REQUIRED_CODEX_PROFILES) {
403
+ const before = updated;
404
+ if (hasProfileSection(updated, profile.name)) {
405
+ updated = replaceProfileSection(updated, profile.name, profile.lines);
406
+ } else {
485
407
  if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
486
408
  if (updated.trim().length > 0) updated += "\n";
487
409
  updated += `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
488
- added++;
489
410
  }
490
-
491
- if (added > 0) {
492
- writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
411
+ if (updated !== before) {
412
+ profiles.push(profile.name);
493
413
  }
494
-
495
- return { ok: true, added };
496
- } catch (e) {
497
- return { ok: false, added: 0, message: e.message };
498
414
  }
499
- }
500
415
 
501
- function previewCodexProfiles() {
502
- const original = existsSync(CODEX_CONFIG_PATH)
503
- ? readFileSync(CODEX_CONFIG_PATH, "utf8")
504
- : "";
505
- const missingProfiles = REQUIRED_CODEX_PROFILES
506
- .filter((profile) => !hasProfileSection(original, profile.name))
507
- .map((profile) => profile.name);
416
+ const windowsSandbox = process.platform === "win32" && !updated.includes("[windows]");
508
417
 
509
418
  return {
510
419
  path: CODEX_CONFIG_PATH,
511
- missingProfiles,
512
- change: missingProfiles.length > 0 ? (original ? "update" : "create") : "noop",
420
+ profiles,
421
+ windowsSandbox,
422
+ change: profiles.length > 0 || windowsSandbox ? (original ? "update" : "create") : "noop",
513
423
  };
514
424
  }
515
425
 
@@ -637,101 +547,6 @@ function checkCliCrossShell(cmd, installHint) {
637
547
 
638
548
  // ── 명령어 ──
639
549
 
640
- function getSetupSyncTargets() {
641
- return [
642
- {
643
- src: join(PKG_ROOT, "scripts", "tfx-route.sh"),
644
- dst: join(CLAUDE_DIR, "scripts", "tfx-route.sh"),
645
- label: "tfx-route.sh",
646
- },
647
- {
648
- src: join(PKG_ROOT, "hud", "hud-qos-status.mjs"),
649
- dst: join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
650
- label: "hud-qos-status.mjs",
651
- },
652
- {
653
- src: join(PKG_ROOT, "scripts", "notion-read.mjs"),
654
- dst: join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
655
- label: "notion-read.mjs",
656
- },
657
- {
658
- src: join(PKG_ROOT, "scripts", "tfx-route-post.mjs"),
659
- dst: join(CLAUDE_DIR, "scripts", "tfx-route-post.mjs"),
660
- label: "tfx-route-post.mjs",
661
- },
662
- {
663
- src: join(PKG_ROOT, "scripts", "tfx-batch-stats.mjs"),
664
- dst: join(CLAUDE_DIR, "scripts", "tfx-batch-stats.mjs"),
665
- label: "tfx-batch-stats.mjs",
666
- },
667
- {
668
- src: join(PKG_ROOT, "scripts", "lib", "mcp-filter.mjs"),
669
- dst: join(CLAUDE_DIR, "scripts", "lib", "mcp-filter.mjs"),
670
- label: "lib/mcp-filter.mjs",
671
- },
672
- {
673
- src: join(PKG_ROOT, "scripts", "lib", "mcp-server-catalog.mjs"),
674
- dst: join(CLAUDE_DIR, "scripts", "lib", "mcp-server-catalog.mjs"),
675
- label: "lib/mcp-server-catalog.mjs",
676
- },
677
- {
678
- src: join(PKG_ROOT, "scripts", "lib", "keyword-rules.mjs"),
679
- dst: join(CLAUDE_DIR, "scripts", "lib", "keyword-rules.mjs"),
680
- label: "lib/keyword-rules.mjs",
681
- },
682
- {
683
- src: join(PKG_ROOT, "scripts", "lib", "gemini-profiles.mjs"),
684
- dst: join(CLAUDE_DIR, "scripts", "lib", "gemini-profiles.mjs"),
685
- label: "lib/gemini-profiles.mjs",
686
- },
687
- {
688
- src: join(PKG_ROOT, "scripts", "tfx-route-worker.mjs"),
689
- dst: join(CLAUDE_DIR, "scripts", "tfx-route-worker.mjs"),
690
- label: "tfx-route-worker.mjs",
691
- },
692
- {
693
- src: join(PKG_ROOT, "hub", "workers", "codex-mcp.mjs"),
694
- dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "codex-mcp.mjs"),
695
- label: "hub/workers/codex-mcp.mjs",
696
- },
697
- {
698
- src: join(PKG_ROOT, "hub", "workers", "delegator-mcp.mjs"),
699
- dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "delegator-mcp.mjs"),
700
- label: "hub/workers/delegator-mcp.mjs",
701
- },
702
- {
703
- src: join(PKG_ROOT, "hub", "workers", "interface.mjs"),
704
- dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "interface.mjs"),
705
- label: "hub/workers/interface.mjs",
706
- },
707
- {
708
- src: join(PKG_ROOT, "hub", "workers", "gemini-worker.mjs"),
709
- dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "gemini-worker.mjs"),
710
- label: "hub/workers/gemini-worker.mjs",
711
- },
712
- {
713
- src: join(PKG_ROOT, "hub", "workers", "claude-worker.mjs"),
714
- dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "claude-worker.mjs"),
715
- label: "hub/workers/claude-worker.mjs",
716
- },
717
- {
718
- src: join(PKG_ROOT, "hub", "workers", "factory.mjs"),
719
- dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "factory.mjs"),
720
- label: "hub/workers/factory.mjs",
721
- },
722
- {
723
- src: join(PKG_ROOT, "scripts", "remote-spawn.mjs"),
724
- dst: join(CLAUDE_DIR, "scripts", "remote-spawn.mjs"),
725
- label: "remote-spawn.mjs",
726
- },
727
- {
728
- src: join(PKG_ROOT, "hub", "team", "psmux.mjs"),
729
- dst: join(CLAUDE_DIR, "hub", "team", "psmux.mjs"),
730
- label: "hub/team/psmux.mjs",
731
- },
732
- ];
733
- }
734
-
735
550
  function listSkillSyncActions() {
736
551
  const skillsSrc = join(PKG_ROOT, "skills");
737
552
  if (!existsSync(skillsSrc)) return [];
@@ -816,7 +631,7 @@ function previewMcpRegistrationActions(mcpUrl) {
816
631
 
817
632
  function buildSetupDryRunPlan() {
818
633
  const actions = [
819
- ...getSetupSyncTargets().map(({ src, dst, label }) => describeSyncAction(src, dst, label)),
634
+ ...SYNC_MAP.map(({ src, dst, label }) => describeSyncAction(src, dst, label)),
820
635
  ...listSkillSyncActions(),
821
636
  ];
822
637
  const codexProfiles = previewCodexProfiles();
@@ -824,7 +639,8 @@ function buildSetupDryRunPlan() {
824
639
  type: "codex-profiles",
825
640
  path: codexProfiles.path,
826
641
  change: codexProfiles.change,
827
- profiles: codexProfiles.missingProfiles,
642
+ profiles: codexProfiles.profiles,
643
+ windowsSandbox: codexProfiles.windowsSandbox,
828
644
  });
829
645
 
830
646
  const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
@@ -846,7 +662,7 @@ function cmdSetup(options = {}) {
846
662
 
847
663
  console.log(`\n${BOLD}triflux setup${RESET}\n`);
848
664
 
849
- for (const target of getSetupSyncTargets()) {
665
+ for (const target of SYNC_MAP) {
850
666
  syncFile(target.src, target.dst, target.label);
851
667
  }
852
668
 
@@ -876,6 +692,21 @@ function cmdSetup(options = {}) {
876
692
  skillCount++;
877
693
  }
878
694
  }
695
+ // references/ 디렉토리 동기화 (존재하면)
696
+ const refSrc = join(skillsSrc, name, "references");
697
+ const refDst = join(skillsDst, name, "references");
698
+ if (existsSync(refSrc)) {
699
+ mkdirSync(refDst, { recursive: true });
700
+ for (const refFile of readdirSync(refSrc)) {
701
+ const rSrc = join(refSrc, refFile);
702
+ const rDst = join(refDst, refFile);
703
+ if (statSync(rSrc).isFile()) {
704
+ if (!existsSync(rDst) || readFileSync(rSrc, "utf8") !== readFileSync(rDst, "utf8")) {
705
+ copyFileSync(rSrc, rDst);
706
+ }
707
+ }
708
+ }
709
+ }
879
710
  }
880
711
  for (const { alias, source } of SKILL_ALIASES) {
881
712
  const srcDir = join(skillsSrc, source);
@@ -898,9 +729,9 @@ function cmdSetup(options = {}) {
898
729
  if (!codexProfileResult.ok) {
899
730
  warn(`Codex profiles 설정 실패: ${codexProfileResult.message}`);
900
731
  summary.push({ item: "Codex profiles", status: "⚠️", detail: codexProfileResult.message });
901
- } else if (codexProfileResult.added > 0) {
902
- ok(`Codex profiles: ${codexProfileResult.added}개 추가됨 (~/.codex/config.toml)`);
903
- summary.push({ item: "Codex profiles", status: "✅", detail: `${codexProfileResult.added}개 추가됨` });
732
+ } else if (codexProfileResult.changed > 0) {
733
+ ok(`Codex profiles: ${codexProfileResult.changed}개 반영됨 (~/.codex/config.toml)`);
734
+ summary.push({ item: "Codex profiles", status: "✅", detail: `${codexProfileResult.changed}개 반영됨` });
904
735
  } else {
905
736
  ok("Codex profiles: 이미 준비됨");
906
737
  summary.push({ item: "Codex profiles", status: "✅", detail: "이미 준비됨" });
@@ -1143,7 +974,7 @@ async function cmdDoctor(options = {}) {
1143
974
  // ── fix 모드: 파일 동기화 + 캐시 정리 후 진단 ──
1144
975
  if (fix) {
1145
976
  section("Auto Fix");
1146
- for (const target of getSetupSyncTargets()) {
977
+ for (const target of SYNC_MAP) {
1147
978
  syncFile(target.src, target.dst, target.label);
1148
979
  }
1149
980
  // 스킬 동기화
@@ -1167,8 +998,8 @@ async function cmdDoctor(options = {}) {
1167
998
  const profileFix = ensureCodexProfiles();
1168
999
  if (!profileFix.ok) {
1169
1000
  warn(`Codex Profiles 자동 복구 실패: ${profileFix.message}`);
1170
- } else if (profileFix.added > 0) {
1171
- ok(`Codex Profiles: ${profileFix.added}개 추가됨`);
1001
+ } else if (profileFix.changed > 0) {
1002
+ ok(`Codex Profiles: ${profileFix.changed}개 반영됨`);
1172
1003
  } else {
1173
1004
  info("Codex Profiles: 이미 최신 상태");
1174
1005
  }
package/hub/pipe.mjs CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  listPipelineStates,
19
19
  readPipelineState,
20
20
  } from './pipeline/state.mjs';
21
+ import { safeJsonParse } from './workers/worker-utils.mjs';
21
22
 
22
23
  const DEFAULT_HEARTBEAT_TTL_MS = 60000;
23
24
 
@@ -29,14 +30,6 @@ export function getPipePath(sessionId = process.pid) {
29
30
  return join('/tmp', `triflux-${sessionId}.sock`);
30
31
  }
31
32
 
32
- function safeJsonParse(line) {
33
- try {
34
- return JSON.parse(line);
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
33
  function normalizeTopics(topics) {
41
34
  if (!Array.isArray(topics)) return [];
42
35
  return topics
@@ -5,7 +5,27 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir, homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
 
8
- const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
8
+ const PSMUX_BIN = (() => {
9
+ if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
10
+ // PATH에서 찾기
11
+ try {
12
+ childProcess.execFileSync("psmux", ["-V"], { stdio: "ignore", timeout: 2000, windowsHide: true });
13
+ return "psmux";
14
+ } catch { /* not in PATH */ }
15
+ // Windows 기본 설치 경로 탐색
16
+ if (process.platform === "win32") {
17
+ const candidates = [
18
+ join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
19
+ join(process.env.APPDATA || "", "npm", "psmux.cmd"),
20
+ join(homedir(), "AppData", "Local", "psmux", "psmux.exe"),
21
+ join(homedir(), "scoop", "shims", "psmux.exe"),
22
+ ];
23
+ for (const p of candidates) {
24
+ if (existsSync(p)) return p;
25
+ }
26
+ }
27
+ return "psmux"; // 최종 fallback — 원래대로
28
+ })();
9
29
  const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
10
30
  const IS_WINDOWS = process.platform === "win32";
11
31
  const PSMUX_TIMEOUT_MS = 10000;
@@ -3,24 +3,7 @@
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
5
  import readline from 'node:readline';
6
-
7
- const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
8
- const DEFAULT_KILL_GRACE_MS = 1000;
9
-
10
- function toStringList(value) {
11
- if (!Array.isArray(value)) return [];
12
- return value
13
- .map((item) => String(item ?? '').trim())
14
- .filter(Boolean);
15
- }
16
-
17
- function safeJsonParse(line) {
18
- try {
19
- return JSON.parse(line);
20
- } catch {
21
- return null;
22
- }
23
- }
6
+ import { toStringList, safeJsonParse, createWorkerError, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from './worker-utils.mjs';
24
7
 
25
8
  function appendTextFragments(value, parts) {
26
9
  if (value == null) return;
@@ -59,12 +42,6 @@ function findSessionId(event) {
59
42
  || null;
60
43
  }
61
44
 
62
- function createWorkerError(message, details = {}) {
63
- const error = new Error(message);
64
- Object.assign(error, details);
65
- return error;
66
- }
67
-
68
45
  function buildClaudeArgs(worker, options) {
69
46
  const args = [...worker.commandArgs];
70
47
 
@@ -292,10 +292,6 @@ export class CodexMcpWorker {
292
292
  }
293
293
  }
294
294
 
295
- export function createCodexMcpWorker(options = {}) {
296
- return new CodexMcpWorker(options);
297
- }
298
-
299
295
  function parseCliArgs(argv) {
300
296
  const options = {
301
297
  command: process.env.CODEX_BIN || 'codex',
@@ -2,25 +2,10 @@
2
2
  // ADR-006: --output-format stream-json 기반 단발 실행 워커.
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { delimiter, extname, join } from 'node:path';
5
7
  import readline from 'node:readline';
6
-
7
- const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
8
- const DEFAULT_KILL_GRACE_MS = 1000;
9
-
10
- function toStringList(value) {
11
- if (!Array.isArray(value)) return [];
12
- return value
13
- .map((item) => String(item ?? '').trim())
14
- .filter(Boolean);
15
- }
16
-
17
- function safeJsonParse(line) {
18
- try {
19
- return JSON.parse(line);
20
- } catch {
21
- return null;
22
- }
23
- }
8
+ import { toStringList, safeJsonParse, createWorkerError, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from './worker-utils.mjs';
24
9
 
25
10
  function appendTextFragments(value, parts) {
26
11
  if (value == null) return;
@@ -84,10 +69,100 @@ function buildGeminiArgs(options) {
84
69
  return args;
85
70
  }
86
71
 
87
- function createWorkerError(message, details = {}) {
88
- const error = new Error(message);
89
- Object.assign(error, details);
90
- return error;
72
+ function resolveSpawnCommand(command, env = process.env) {
73
+ const raw = String(command ?? '').trim();
74
+ if (!raw || process.platform !== 'win32') return raw;
75
+
76
+ const pathExts = (env.PATHEXT || process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
77
+ .split(';')
78
+ .map((ext) => ext.trim().toLowerCase())
79
+ .filter(Boolean);
80
+ const extensions = extname(raw)
81
+ ? ['']
82
+ : [...new Set(['.cmd', '.exe', '.bat', ...pathExts, ''])];
83
+
84
+ const tryResolve = (base) => {
85
+ for (const ext of extensions) {
86
+ const candidate = `${base}${ext}`;
87
+ if (existsSync(candidate)) return candidate;
88
+ }
89
+ return null;
90
+ };
91
+
92
+ if (raw.includes('\\') || raw.includes('/')) {
93
+ return tryResolve(raw.replaceAll('/', '\\')) || raw;
94
+ }
95
+
96
+ const pathEntries = String(env.PATH || process.env.PATH || '')
97
+ .split(delimiter)
98
+ .map((entry) => entry.trim())
99
+ .filter(Boolean);
100
+
101
+ for (const entry of pathEntries) {
102
+ const resolved = tryResolve(join(entry, raw));
103
+ if (resolved) return resolved;
104
+ }
105
+
106
+ return raw;
107
+ }
108
+
109
+ function quoteWindowsCmdArg(value) {
110
+ const raw = String(value ?? '');
111
+ if (raw.length === 0) return '""';
112
+
113
+ const escaped = raw
114
+ .replace(/(\\*)"/g, '$1$1\\"')
115
+ .replace(/(\\+)$/g, '$1$1');
116
+
117
+ return /[\s"&()<>^|]/.test(raw)
118
+ ? `"${escaped}"`
119
+ : escaped;
120
+ }
121
+
122
+ function quotePosixShellArg(value) {
123
+ const raw = String(value ?? '');
124
+ return `'${raw.replaceAll("'", `'\"'\"'`)}'`;
125
+ }
126
+
127
+ function toBashPath(value) {
128
+ return String(value ?? '')
129
+ .replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
130
+ .replaceAll('\\', '/');
131
+ }
132
+
133
+ function buildSpawnSpec(command, args, env = process.env) {
134
+ const resolvedCommand = resolveSpawnCommand(command, env);
135
+
136
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand)) {
137
+ const commandLine = [resolvedCommand, ...args]
138
+ .map((part) => quoteWindowsCmdArg(part))
139
+ .join(' ');
140
+
141
+ return {
142
+ command: 'cmd.exe',
143
+ args: ['/d', '/s', '/c', commandLine],
144
+ resolvedCommand,
145
+ };
146
+ }
147
+
148
+ if (process.platform === 'win32' && !extname(resolvedCommand) && existsSync(resolvedCommand)) {
149
+ const bashCommand = env.TFX_BASH_BIN || env.BASH || 'bash';
150
+ const commandLine = [toBashPath(resolvedCommand), ...args]
151
+ .map((part) => quotePosixShellArg(part))
152
+ .join(' ');
153
+
154
+ return {
155
+ command: bashCommand,
156
+ args: ['-lc', commandLine],
157
+ resolvedCommand,
158
+ };
159
+ }
160
+
161
+ return {
162
+ command: resolvedCommand,
163
+ args,
164
+ resolvedCommand,
165
+ };
91
166
  }
92
167
 
93
168
  /**
@@ -187,10 +262,12 @@ export class GeminiWorker {
187
262
  promptArgument: options.promptArgument ?? '',
188
263
  }),
189
264
  ];
265
+ const env = { ...this.env, ...(options.env || {}) };
266
+ const spawnSpec = buildSpawnSpec(this.command, args, env);
190
267
 
191
- const child = spawn(this.command, args, {
268
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
192
269
  cwd: options.cwd || this.cwd,
193
- env: { ...this.env, ...(options.env || {}) },
270
+ env,
194
271
  stdio: ['pipe', 'pipe', 'pipe'],
195
272
  windowsHide: true,
196
273
  });
@@ -269,10 +346,13 @@ export class GeminiWorker {
269
346
  const response = [
270
347
  extractText(resultEvent),
271
348
  ...events
272
- .filter((event) => event?.type === 'message' || event?.type === 'assistant')
349
+ .filter((event) => (
350
+ event?.type === 'assistant'
351
+ || (event?.type === 'message' && event?.role === 'assistant')
352
+ ))
273
353
  .map((event) => extractText(event))
274
354
  .filter(Boolean),
275
- ...stdoutLines,
355
+ ...stdoutLines.filter((line) => line.trim() !== '""'),
276
356
  ]
277
357
  .filter(Boolean)
278
358
  .join('\n')
@@ -280,8 +360,8 @@ export class GeminiWorker {
280
360
 
281
361
  const result = {
282
362
  type: 'gemini',
283
- command: this.command,
284
- args,
363
+ command: spawnSpec.resolvedCommand,
364
+ args: spawnSpec.args,
285
365
  response,
286
366
  events,
287
367
  resultEvent,
@@ -38,4 +38,3 @@
38
38
  * @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
39
39
  */
40
40
 
41
- export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude', 'delegator']);
@@ -0,0 +1,26 @@
1
+ // hub/workers/worker-utils.mjs — 워커 공통 유틸리티
2
+ // claude-worker, gemini-worker, pipe 등에서 공유하는 순수 유틸 함수 모음.
3
+
4
+ export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
5
+ export const DEFAULT_KILL_GRACE_MS = 1000;
6
+
7
+ export function toStringList(value) {
8
+ if (!Array.isArray(value)) return [];
9
+ return value
10
+ .map((item) => String(item ?? '').trim())
11
+ .filter(Boolean);
12
+ }
13
+
14
+ export function safeJsonParse(line) {
15
+ try {
16
+ return JSON.parse(line);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function createWorkerError(message, details = {}) {
23
+ const error = new Error(message);
24
+ Object.assign(error, details);
25
+ return error;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.2.4",
3
+ "version": "9.4.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": {