triflux 10.20.2 → 10.22.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 (50) hide show
  1. package/CLAUDE.md +174 -0
  2. package/bin/tfx-doctor-tui.mjs +0 -0
  3. package/bin/tfx-doctor.mjs +0 -0
  4. package/bin/tfx-profile.mjs +0 -0
  5. package/bin/tfx-setup-tui.mjs +0 -0
  6. package/bin/tfx-setup.mjs +0 -0
  7. package/bin/triflux.mjs +609 -8
  8. package/hooks/keyword-rules.json +1 -1
  9. package/hub/cli-adapter-base.mjs +34 -1
  10. package/hub/codex-adapter.mjs +4 -0
  11. package/hub/dynamic-routing-engine.mjs +511 -0
  12. package/hub/lib/prompt-tmp.mjs +33 -0
  13. package/hub/lib/spawn-trace.mjs +5 -1
  14. package/hub/routing-snapshot.mjs +261 -0
  15. package/hub/team/conductor.mjs +67 -3
  16. package/hub/team/execution-mode.mjs +34 -30
  17. package/hub/team/headless.mjs +8 -6
  18. package/hub/team/launcher-template.mjs +5 -0
  19. package/hub/team/psmux.mjs +57 -30
  20. package/hub/team/session.mjs +54 -2
  21. package/hub/team/swarm-hypervisor.mjs +59 -0
  22. package/hub/team/terminal-opener.mjs +24 -4
  23. package/hub/team/tui-viewer.mjs +2 -1
  24. package/package.json +23 -67
  25. package/references/cli-parameter-reference.md +240 -0
  26. package/references/codex-plugin-cc-analysis.md +706 -0
  27. package/references/codex-plugin-cc-code-patterns.md +468 -0
  28. package/scripts/__tests__/setup-cleanup-stale-skills.test.mjs +19 -1
  29. package/scripts/check-codex-config-stable.mjs +166 -38
  30. package/scripts/doctor-dynamic-routing.mjs +87 -0
  31. package/scripts/hub-ensure.mjs +689 -33
  32. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  33. package/scripts/lib/psmux-info.mjs +59 -4
  34. package/scripts/pack.mjs +3 -0
  35. package/scripts/session-spawn-helper.mjs +2 -1
  36. package/scripts/setup.mjs +12 -4
  37. package/scripts/test-lock.mjs +194 -26
  38. package/scripts/tfx-route.sh +36 -0
  39. package/skills/tfx-auto/SKILL.md +8 -8
  40. package/skills/tfx-auto/SKILL.md.tmpl +8 -8
  41. package/.claude-plugin/marketplace.json +0 -34
  42. package/.claude-plugin/plugin.json +0 -22
  43. package/config/mcp-registry.json +0 -44
  44. package/tui/codex-profile.mjs +0 -459
  45. package/tui/core.mjs +0 -266
  46. package/tui/doctor.mjs +0 -375
  47. package/tui/gemini-profile.mjs +0 -299
  48. package/tui/monitor-data.mjs +0 -152
  49. package/tui/monitor.mjs +0 -317
  50. package/tui/setup.mjs +0 -599
package/bin/triflux.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  readdirSync,
13
13
  readFileSync,
14
14
  readSync,
15
+ realpathSync,
15
16
  renameSync,
16
17
  statSync,
17
18
  unlinkSync,
@@ -26,6 +27,7 @@ import {
26
27
  validateRuntimeCachePaths,
27
28
  } from "../hub/lib/cache-guard.mjs";
28
29
  import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
30
+ import { getVersionHash } from "../hub/state.mjs";
29
31
  import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
30
32
  import {
31
33
  detectMultiplexer,
@@ -77,6 +79,7 @@ import {
77
79
  getVersion,
78
80
  getWindowsHubAutostartStatus,
79
81
  hasProfileSection,
82
+ isLocalDevSkillDir,
80
83
  isSetupUserStateFile,
81
84
  LEGACY_CODEX_MODELS,
82
85
  REQUIRED_CODEX_PROFILES,
@@ -118,6 +121,8 @@ const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
118
121
  const _DOT = `${GRAY}·${RESET}`;
119
122
  const STALE_TEAM_MAX_AGE_SEC = 3600;
120
123
  const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
124
+ const HUB_DEFAULT_PORT = 27888;
125
+ const DOCTOR_HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
121
126
 
122
127
  const _EXIT_SUCCESS = 0;
123
128
  const EXIT_ERROR = 1;
@@ -150,7 +155,7 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
150
155
  },
151
156
  doctor: {
152
157
  usage:
153
- "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--json]",
158
+ "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--json]",
154
159
  description: "설치 상태 진단 및 자동 복구",
155
160
  options: [
156
161
  {
@@ -180,6 +185,30 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
180
185
  description:
181
186
  "--fix 와 함께 사용. cli-issues.jsonl 에서 7일 초과 항목 물리 삭제 (#144)",
182
187
  },
188
+ {
189
+ name: "--dynamic-routing",
190
+ type: "boolean",
191
+ description:
192
+ "Phase 1 dynamic routing 상태 진단 (env / policy / snapshot cache / preview decision)",
193
+ },
194
+ {
195
+ name: "--cleanup-stale-hubs",
196
+ type: "boolean",
197
+ description:
198
+ "PPID=1 hub/server.mjs 후보를 보고하고 opt-in 정리 모드를 활성화",
199
+ },
200
+ {
201
+ name: "--dry-run",
202
+ type: "boolean",
203
+ description:
204
+ "--cleanup-stale-hubs 와 함께 사용. active healthy hub를 제외하고 정리 후보만 표시",
205
+ },
206
+ {
207
+ name: "--apply",
208
+ type: "boolean",
209
+ description:
210
+ "--cleanup-stale-hubs 와 함께 사용. active healthy hub 제외 후 stale hub 종료",
211
+ },
183
212
  {
184
213
  name: "--json",
185
214
  type: "boolean",
@@ -498,6 +527,403 @@ function createCliError(
498
527
  return error;
499
528
  }
500
529
 
530
+ function parseHubPort(value) {
531
+ const parsed = Number.parseInt(String(value ?? ""), 10);
532
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
533
+ }
534
+
535
+ function isPidAliveForHub(pid, killFn = process.kill) {
536
+ const resolvedPid = Number(pid);
537
+ if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
538
+ try {
539
+ killFn(resolvedPid, 0);
540
+ return true;
541
+ } catch (error) {
542
+ return error?.code === "EPERM";
543
+ }
544
+ }
545
+
546
+ function isHubServerCommand(command) {
547
+ return /(^|[\\/,\s])hub[\\/]server\.mjs(?=$|[\s"'`])/i.test(
548
+ String(command || ""),
549
+ );
550
+ }
551
+
552
+ function parsePortFromAddress(address) {
553
+ const match = String(address || "").match(/:(\d+)(?:\s|$)/);
554
+ return parseHubPort(match?.[1]);
555
+ }
556
+
557
+ export function parseDetachedHubProcessRows(output) {
558
+ const rows = [];
559
+ for (const line of String(output || "").split(/\r?\n/)) {
560
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
561
+ if (!match) continue;
562
+ const [, pidText, ppidText, rssText, uptime, command] = match;
563
+ const pid = Number.parseInt(pidText, 10);
564
+ const ppid = Number.parseInt(ppidText, 10);
565
+ const rssKb = Number.parseInt(rssText, 10);
566
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
567
+ if (ppid !== 1) continue;
568
+ if (!isHubServerCommand(command)) continue;
569
+ rows.push({
570
+ pid,
571
+ ppid,
572
+ rssKb: Number.isFinite(rssKb) ? rssKb : null,
573
+ uptime,
574
+ command: command.trim(),
575
+ });
576
+ }
577
+ return rows;
578
+ }
579
+
580
+ function queryDetachedHubProcessRows({
581
+ platform = process.platform,
582
+ execFile = execFileSync,
583
+ } = {}) {
584
+ if (platform === "win32") return [];
585
+ try {
586
+ const output = execFile("ps", ["-axo", "pid=,ppid=,rss=,etime=,command="], {
587
+ encoding: "utf8",
588
+ timeout: 5000,
589
+ stdio: ["ignore", "pipe", "ignore"],
590
+ windowsHide: true,
591
+ });
592
+ return parseDetachedHubProcessRows(output);
593
+ } catch {
594
+ return [];
595
+ }
596
+ }
597
+
598
+ function parseLsofListeningPorts(output) {
599
+ const ports = new Set();
600
+ for (const line of String(output || "").split(/\r?\n/)) {
601
+ if (!/\(LISTEN\)/i.test(line)) continue;
602
+ const match = line.match(/TCP\s+\S+:(\d+)\s+\(LISTEN\)/i);
603
+ const port = parseHubPort(match?.[1]) ?? parsePortFromAddress(line);
604
+ if (port) ports.add(port);
605
+ }
606
+ return [...ports];
607
+ }
608
+
609
+ function queryListeningPortsForPid(
610
+ pid,
611
+ { platform = process.platform, execFile = execFileSync } = {},
612
+ ) {
613
+ const resolvedPid = Number(pid);
614
+ if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return [];
615
+ if (platform === "win32") return [];
616
+ try {
617
+ const output = execFile(
618
+ "lsof",
619
+ ["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:LISTEN"],
620
+ {
621
+ encoding: "utf8",
622
+ timeout: 5000,
623
+ stdio: ["ignore", "pipe", "ignore"],
624
+ windowsHide: true,
625
+ },
626
+ );
627
+ return parseLsofListeningPorts(output);
628
+ } catch {
629
+ return [];
630
+ }
631
+ }
632
+
633
+ function queryEstablishedCountForPid(
634
+ pid,
635
+ { platform = process.platform, execFile = execFileSync } = {},
636
+ ) {
637
+ const resolvedPid = Number(pid);
638
+ if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return 0;
639
+ if (platform === "win32") return 0;
640
+ try {
641
+ const output = execFile(
642
+ "lsof",
643
+ ["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:ESTABLISHED"],
644
+ {
645
+ encoding: "utf8",
646
+ timeout: 5000,
647
+ stdio: ["ignore", "pipe", "ignore"],
648
+ windowsHide: true,
649
+ },
650
+ );
651
+ return Math.max(0, output.trim().split(/\r?\n/).filter(Boolean).length - 1);
652
+ } catch {
653
+ return 0;
654
+ }
655
+ }
656
+
657
+ function queryPidCommand(
658
+ pid,
659
+ { platform = process.platform, execFile = execFileSync } = {},
660
+ ) {
661
+ const resolvedPid = Number(pid);
662
+ if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return "";
663
+ try {
664
+ if (platform === "win32") return "";
665
+ return execFile("ps", ["-p", String(resolvedPid), "-o", "command="], {
666
+ encoding: "utf8",
667
+ timeout: 5000,
668
+ stdio: ["ignore", "pipe", "ignore"],
669
+ windowsHide: true,
670
+ }).trim();
671
+ } catch {
672
+ return "";
673
+ }
674
+ }
675
+
676
+ function queryListeningPidByPort(
677
+ port,
678
+ { platform = process.platform, execFile = execFileSync } = {},
679
+ ) {
680
+ const targetPort = parseHubPort(port);
681
+ if (!targetPort || platform === "win32") return null;
682
+ try {
683
+ const output = execFile(
684
+ "lsof",
685
+ ["-nP", "-iTCP:" + targetPort, "-sTCP:LISTEN", "-t"],
686
+ {
687
+ encoding: "utf8",
688
+ timeout: 5000,
689
+ stdio: ["ignore", "pipe", "ignore"],
690
+ windowsHide: true,
691
+ },
692
+ );
693
+ const pid = Number.parseInt(output.trim().split(/\r?\n/)[0] ?? "", 10);
694
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ async function fetchHubHealthForDoctor(
701
+ host,
702
+ port,
703
+ { fetchImpl = fetch, timeoutMs = 1000 } = {},
704
+ ) {
705
+ try {
706
+ const urlHost = String(host || "127.0.0.1").includes(":")
707
+ ? `[${host}]`
708
+ : host || "127.0.0.1";
709
+ const response = await fetchImpl(`http://${urlHost}:${port}/health`, {
710
+ signal: AbortSignal.timeout(timeoutMs),
711
+ });
712
+ if (!response.ok) return { ok: false, version: null };
713
+ const body = await response.json().catch(() => null);
714
+ return {
715
+ ok: body?.ok === true,
716
+ version: typeof body?.version === "string" ? body.version : null,
717
+ raw: body,
718
+ };
719
+ } catch (error) {
720
+ return { ok: false, version: null, error };
721
+ }
722
+ }
723
+
724
+ function readHubPidInfo({
725
+ pidFilePath = DOCTOR_HUB_PID_FILE,
726
+ exists = existsSync,
727
+ readFile = readFileSync,
728
+ } = {}) {
729
+ if (!exists(pidFilePath)) return null;
730
+ try {
731
+ return JSON.parse(readFile(pidFilePath, "utf8"));
732
+ } catch {
733
+ return null;
734
+ }
735
+ }
736
+
737
+ async function resolveActiveHealthyHub({
738
+ expectedVersion = getVersionHash(),
739
+ pidFilePath = DOCTOR_HUB_PID_FILE,
740
+ exists = existsSync,
741
+ readFile = readFileSync,
742
+ killFn = process.kill,
743
+ platform = process.platform,
744
+ execFile = execFileSync,
745
+ fetchImpl = fetch,
746
+ } = {}) {
747
+ const info = readHubPidInfo({ pidFilePath, exists, readFile });
748
+ const pid = Number(info?.pid);
749
+ const port = parseHubPort(info?.port);
750
+ const host =
751
+ typeof info?.host === "string" && info.host.trim()
752
+ ? info.host.trim()
753
+ : "127.0.0.1";
754
+ if (pid && port && isPidAliveForHub(pid, killFn)) {
755
+ const command = queryPidCommand(pid, { platform, execFile });
756
+ const health = await fetchHubHealthForDoctor(host, port, { fetchImpl });
757
+ if (
758
+ isHubServerCommand(command) &&
759
+ health.ok &&
760
+ health.version === expectedVersion
761
+ ) {
762
+ return { pid, port, host, version: health.version, source: "pid-file" };
763
+ }
764
+ }
765
+
766
+ const defaultPid = queryListeningPidByPort(HUB_DEFAULT_PORT, {
767
+ platform,
768
+ execFile,
769
+ });
770
+ if (!defaultPid || !isPidAliveForHub(defaultPid, killFn)) return null;
771
+ const command = queryPidCommand(defaultPid, { platform, execFile });
772
+ if (!isHubServerCommand(command)) return null;
773
+ const health = await fetchHubHealthForDoctor("127.0.0.1", HUB_DEFAULT_PORT, {
774
+ fetchImpl,
775
+ });
776
+ if (!health.ok || health.version !== expectedVersion) return null;
777
+ return {
778
+ pid: defaultPid,
779
+ port: HUB_DEFAULT_PORT,
780
+ host: "127.0.0.1",
781
+ version: health.version,
782
+ source: "default-port",
783
+ };
784
+ }
785
+
786
+ export async function inspectDetachedHubProcesses({
787
+ expectedVersion = getVersionHash(),
788
+ pidFilePath = DOCTOR_HUB_PID_FILE,
789
+ exists = existsSync,
790
+ readFile = readFileSync,
791
+ killFn = process.kill,
792
+ platform = process.platform,
793
+ execFile = execFileSync,
794
+ fetchImpl = fetch,
795
+ } = {}) {
796
+ const rows = queryDetachedHubProcessRows({ platform, execFile });
797
+ const activeHealthy = await resolveActiveHealthyHub({
798
+ expectedVersion,
799
+ pidFilePath,
800
+ exists,
801
+ readFile,
802
+ killFn,
803
+ platform,
804
+ execFile,
805
+ fetchImpl,
806
+ });
807
+
808
+ const hubs = [];
809
+ for (const row of rows) {
810
+ const ports = queryListeningPortsForPid(row.pid, { platform, execFile });
811
+ const established = queryEstablishedCountForPid(row.pid, {
812
+ platform,
813
+ execFile,
814
+ });
815
+ let version = null;
816
+ let healthy = false;
817
+ for (const port of ports) {
818
+ const health = await fetchHubHealthForDoctor("127.0.0.1", port, {
819
+ fetchImpl,
820
+ });
821
+ if (!health.ok) continue;
822
+ version = health.version;
823
+ healthy = true;
824
+ break;
825
+ }
826
+ hubs.push({
827
+ ...row,
828
+ ports,
829
+ established,
830
+ version,
831
+ healthy,
832
+ activeHealthy: activeHealthy?.pid === row.pid,
833
+ staleCandidate: activeHealthy?.pid !== row.pid,
834
+ });
835
+ }
836
+
837
+ return {
838
+ expectedVersion,
839
+ activeHealthy,
840
+ hubs,
841
+ staleCandidates: hubs.filter((hub) => hub.staleCandidate),
842
+ };
843
+ }
844
+
845
+ async function waitForHubProcessExit(
846
+ pid,
847
+ { killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
848
+ ) {
849
+ const deadline = Date.now() + Math.max(0, graceMs);
850
+ while (Date.now() <= deadline) {
851
+ if (!isPidAliveForHub(pid, killFn)) return true;
852
+ await delay(pollMs);
853
+ }
854
+ return !isPidAliveForHub(pid, killFn);
855
+ }
856
+
857
+ async function retireDetachedHubPid(
858
+ pid,
859
+ { killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
860
+ ) {
861
+ if (!isPidAliveForHub(pid, killFn)) return { ok: true, reason: "dead" };
862
+ try {
863
+ killFn(pid, "SIGTERM");
864
+ } catch (error) {
865
+ return { ok: false, reason: "sigterm_failed", error };
866
+ }
867
+ if (await waitForHubProcessExit(pid, { killFn, graceMs, pollMs })) {
868
+ return { ok: true, reason: "sigterm" };
869
+ }
870
+ try {
871
+ killFn(pid, "SIGKILL");
872
+ } catch (error) {
873
+ return { ok: false, reason: "sigkill_failed", error };
874
+ }
875
+ const exited = await waitForHubProcessExit(pid, {
876
+ killFn,
877
+ graceMs: 1000,
878
+ pollMs,
879
+ });
880
+ return { ok: exited, reason: exited ? "sigkill" : "still_alive" };
881
+ }
882
+
883
+ export async function cleanupDetachedHubProcesses({
884
+ hubs,
885
+ activeHealthy,
886
+ dryRun = true,
887
+ apply = false,
888
+ killFn = process.kill,
889
+ graceMs = 5000,
890
+ pollMs = 100,
891
+ } = {}) {
892
+ const results = [];
893
+ for (const hub of hubs || []) {
894
+ if (activeHealthy?.pid === hub.pid || hub.activeHealthy) {
895
+ results.push({ pid: hub.pid, action: "excluded-active", ok: true, hub });
896
+ continue;
897
+ }
898
+ if (!apply || dryRun) {
899
+ results.push({ pid: hub.pid, action: "dry-run-skip", ok: true, hub });
900
+ continue;
901
+ }
902
+ const retired = await retireDetachedHubPid(hub.pid, {
903
+ killFn,
904
+ graceMs,
905
+ pollMs,
906
+ });
907
+ results.push({
908
+ pid: hub.pid,
909
+ action: retired.ok ? "retired" : "failed",
910
+ ok: retired.ok,
911
+ reason: retired.reason,
912
+ hub,
913
+ });
914
+ }
915
+ return {
916
+ dryRun: !apply || dryRun,
917
+ results,
918
+ failed: results.filter((result) => result.ok === false).length,
919
+ retired: results.filter((result) => result.action === "retired").length,
920
+ skipped: results.filter((result) => result.action === "dry-run-skip")
921
+ .length,
922
+ excluded: results.filter((result) => result.action === "excluded-active")
923
+ .length,
924
+ };
925
+ }
926
+
501
927
  function inferExitCode(error) {
502
928
  if (Number.isInteger(error?.exitCode)) return error.exitCode;
503
929
  if (error?.code === "ENOENT") return EXIT_CLI_MISSING;
@@ -583,15 +1009,22 @@ function which(cmd) {
583
1009
  }
584
1010
 
585
1011
  function whichInShell(cmd, shell) {
1012
+ const escapedCmd = cmd.replace(/(["\\$`])/g, "\\$1");
586
1013
  const shellArgs = {
587
1014
  bash: [
588
1015
  "bash",
589
- ["-c", `source ~/.bashrc 2>/dev/null && command -v "${cmd}" 2>/dev/null`],
1016
+ [
1017
+ "-lc",
1018
+ `source ~/.bashrc 2>/dev/null || true; command -v "${escapedCmd}" 2>/dev/null`,
1019
+ ],
590
1020
  ],
591
1021
  cmd: ["cmd", ["/c", "where", cmd]],
592
1022
  zsh: [
593
1023
  "zsh",
594
- ["-c", `source ~/.zshrc 2>/dev/null && command -v "${cmd}" 2>/dev/null`],
1024
+ [
1025
+ "-lc",
1026
+ `source ~/.zshrc 2>/dev/null || true; command -v "${escapedCmd}" 2>/dev/null`,
1027
+ ],
595
1028
  ],
596
1029
  pwsh: [
597
1030
  "pwsh",
@@ -626,6 +1059,7 @@ function isDevUpdateRequested(argv = process.argv) {
626
1059
  function checkShellAvailable(shell) {
627
1060
  const cmds = {
628
1061
  bash: "bash --version",
1062
+ zsh: "zsh --version",
629
1063
  cmd: "cmd /c echo ok",
630
1064
  pwsh: "pwsh -NoProfile -c echo ok",
631
1065
  };
@@ -1849,6 +2283,9 @@ async function cmdDoctor(options = {}) {
1849
2283
  fix = false,
1850
2284
  reset = false,
1851
2285
  purgeLogs = false,
2286
+ cleanupStaleHubs = false,
2287
+ cleanupStaleHubsDryRun = true,
2288
+ cleanupStaleHubsApply = false,
1852
2289
  json = false,
1853
2290
  } = options;
1854
2291
  const report = {
@@ -1858,6 +2295,7 @@ async function cmdDoctor(options = {}) {
1858
2295
  actions: [],
1859
2296
  hook_coverage: { total: 0, registered: 0, missing: [] },
1860
2297
  fsmonitorDaemons: { stale: 0, killed: 0 },
2298
+ hubServers: { detached: 0, stale: 0, activeHealthy: null },
1861
2299
  issue_count: 0,
1862
2300
  };
1863
2301
 
@@ -2701,6 +3139,7 @@ async function cmdDoctor(options = {}) {
2701
3139
 
2702
3140
  for (const n of readdirSync(userSkillsDir)) {
2703
3141
  if (!n.startsWith("tfx-")) continue;
3142
+ if (isLocalDevSkillDir(join(userSkillsDir, n))) continue;
2704
3143
  if (!pkgSkills.has(n)) staleSkills.push(n);
2705
3144
  }
2706
3145
  }
@@ -3216,6 +3655,96 @@ async function cmdDoctor(options = {}) {
3216
3655
  }
3217
3656
  }
3218
3657
 
3658
+ // 12.4. detached tfx-hub/server.mjs 누적 감지 및 opt-in 정리
3659
+ section("Hub Servers");
3660
+ try {
3661
+ const hubReport = await inspectDetachedHubProcesses();
3662
+ report.hubServers = {
3663
+ detached: hubReport.hubs.length,
3664
+ stale: hubReport.staleCandidates.length,
3665
+ activeHealthy: hubReport.activeHealthy,
3666
+ expectedVersion: hubReport.expectedVersion,
3667
+ hubs: hubReport.hubs.map((hub) => ({
3668
+ pid: hub.pid,
3669
+ ppid: hub.ppid,
3670
+ version: hub.version,
3671
+ uptime: hub.uptime,
3672
+ ports: hub.ports,
3673
+ established: hub.established,
3674
+ rssKb: hub.rssKb,
3675
+ activeHealthy: hub.activeHealthy,
3676
+ staleCandidate: hub.staleCandidate,
3677
+ })),
3678
+ };
3679
+ addDoctorCheck(report, {
3680
+ name: "hub-server-processes",
3681
+ status: hubReport.staleCandidates.length > 0 ? "warning" : "ok",
3682
+ detached: hubReport.hubs.length,
3683
+ stale: hubReport.staleCandidates.length,
3684
+ activeHealthy: hubReport.activeHealthy,
3685
+ });
3686
+
3687
+ if (hubReport.hubs.length === 0) {
3688
+ ok("PPID=1 hub/server.mjs 없음");
3689
+ } else {
3690
+ for (const hub of hubReport.hubs) {
3691
+ const portLabel = hub.ports.length > 0 ? hub.ports.join(",") : "?";
3692
+ const versionLabel = hub.version || "unknown";
3693
+ const rssLabel = hub.rssKb == null ? "?" : `${hub.rssKb}KB`;
3694
+ const activeLabel = hub.activeHealthy
3695
+ ? " active healthy excluded"
3696
+ : " stale candidate";
3697
+ const line = `PID=${hub.pid} PPID=${hub.ppid} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
3698
+ if (hub.activeHealthy) ok(line);
3699
+ else warn(line);
3700
+ }
3701
+ }
3702
+
3703
+ if (cleanupStaleHubs) {
3704
+ const cleanupResult = await cleanupDetachedHubProcesses({
3705
+ hubs: hubReport.hubs,
3706
+ activeHealthy: hubReport.activeHealthy,
3707
+ dryRun: cleanupStaleHubsDryRun,
3708
+ apply: cleanupStaleHubsApply,
3709
+ });
3710
+ report.actions.push({
3711
+ type: "cleanup-stale-hubs",
3712
+ status: cleanupResult.failed > 0 ? "failed" : "ok",
3713
+ dryRun: cleanupResult.dryRun,
3714
+ retired: cleanupResult.retired,
3715
+ skipped: cleanupResult.skipped,
3716
+ excluded: cleanupResult.excluded,
3717
+ failed: cleanupResult.failed,
3718
+ });
3719
+ for (const result of cleanupResult.results) {
3720
+ if (result.action === "excluded-active") {
3721
+ ok(`active healthy hub excluded: PID=${result.pid}`);
3722
+ } else if (result.action === "dry-run-skip") {
3723
+ info(`dry-run skip stale hub: PID=${result.pid}`);
3724
+ } else if (result.action === "retired") {
3725
+ ok(`stale hub retired: PID=${result.pid} (${result.reason})`);
3726
+ } else {
3727
+ fail(`stale hub cleanup failed: PID=${result.pid}`);
3728
+ }
3729
+ }
3730
+ issues += cleanupResult.failed;
3731
+ if (cleanupResult.dryRun && hubReport.staleCandidates.length > 0) {
3732
+ issues += hubReport.staleCandidates.length;
3733
+ }
3734
+ } else if (hubReport.staleCandidates.length > 0) {
3735
+ info("정리: tfx doctor --cleanup-stale-hubs --dry-run|--apply");
3736
+ issues += hubReport.staleCandidates.length;
3737
+ }
3738
+ } catch (error) {
3739
+ addDoctorCheck(report, {
3740
+ name: "hub-server-processes",
3741
+ status: "warning",
3742
+ error: error.message,
3743
+ });
3744
+ warn(`hub/server.mjs 검사 실패: ${error.message}`);
3745
+ issues++;
3746
+ }
3747
+
3219
3748
  // 12.5. 고아 node.exe 프로세스 정리 (Windows)
3220
3749
  section("Orphan Processes");
3221
3750
  if (process.platform === "win32") {
@@ -5954,10 +6483,70 @@ async function main() {
5954
6483
  }
5955
6484
  return;
5956
6485
  }
6486
+ if (cmdArgs.includes("--dynamic-routing")) {
6487
+ const { diagnoseDynamicRouting } = await import(
6488
+ "../scripts/doctor-dynamic-routing.mjs"
6489
+ );
6490
+ const report = await diagnoseDynamicRouting();
6491
+ if (JSON_OUTPUT) {
6492
+ console.log(JSON.stringify(report, null, 2));
6493
+ } else {
6494
+ const mark = (b) =>
6495
+ b ? `${GREEN_BRIGHT}✓${RESET}` : `${RED}✗${RESET}`;
6496
+ console.log(
6497
+ `\n ${AMBER}${BOLD}⬡ triflux doctor — dynamic routing${RESET}\n`,
6498
+ );
6499
+ console.log(
6500
+ ` ${mark(report.enabled)} enabled: ${report.enabled} (TRIFLUX_DYNAMIC_ROUTING=${report.envFlag ?? "미설정"})`,
6501
+ );
6502
+ console.log(
6503
+ ` ${mark(report.policyLoaded)} policy loaded: ${report.policyLoaded} (scenarios=${report.policyScenarios.length})`,
6504
+ );
6505
+ console.log(
6506
+ ` ${mark(report.snapshotCached)} snapshot cache: ${
6507
+ report.snapshotCached
6508
+ ? `hit (age=${report.snapshotAgeMs}ms)`
6509
+ : "miss"
6510
+ }`,
6511
+ );
6512
+ if (report.previewDecision) {
6513
+ const d = report.previewDecision;
6514
+ console.log(
6515
+ ` ${GREEN_BRIGHT}→${RESET} preview decision: scenario=${d.scenario}, mode=${d.mode}, lane=${d.lane}, shards[0].cli=${d.shards?.[0]?.cli ?? "?"}`,
6516
+ );
6517
+ }
6518
+ if (report.error) {
6519
+ console.log(` ${RED}✗${RESET} error: ${report.error}`);
6520
+ }
6521
+ console.log("");
6522
+ }
6523
+ return;
6524
+ }
5957
6525
  const fix = cmdArgs.includes("--fix");
5958
6526
  const reset = cmdArgs.includes("--reset");
5959
6527
  const purgeLogs = cmdArgs.includes("--purge-logs");
5960
- await cmdDoctor({ fix, reset, purgeLogs, json: JSON_OUTPUT });
6528
+ const cleanupStaleHubs = cmdArgs.includes("--cleanup-stale-hubs");
6529
+ const cleanupApply = cmdArgs.includes("--apply");
6530
+ const cleanupDryRun = cmdArgs.includes("--dry-run") || !cleanupApply;
6531
+ if (cleanupStaleHubs && cleanupApply && cmdArgs.includes("--dry-run")) {
6532
+ throw createCliError(
6533
+ "--cleanup-stale-hubs 에서는 --dry-run 과 --apply 중 하나만 지정하세요",
6534
+ {
6535
+ exitCode: EXIT_ARG_ERROR,
6536
+ reason: "argError",
6537
+ fix: "tfx doctor --cleanup-stale-hubs --dry-run",
6538
+ },
6539
+ );
6540
+ }
6541
+ await cmdDoctor({
6542
+ fix,
6543
+ reset,
6544
+ purgeLogs,
6545
+ cleanupStaleHubs,
6546
+ cleanupStaleHubsDryRun: cleanupDryRun,
6547
+ cleanupStaleHubsApply: cleanupApply,
6548
+ json: JSON_OUTPUT,
6549
+ });
5961
6550
  return;
5962
6551
  }
5963
6552
  case "mcp":
@@ -6231,8 +6820,20 @@ ${s.options.map((o) => ` ${DIM}${o.name.padEnd(16)}${RESET} ${GRAY}${o.descri
6231
6820
  }
6232
6821
  }
6233
6822
 
6234
- try {
6235
- await main();
6236
- } catch (error) {
6237
- handleFatalError(error, { json: JSON_OUTPUT });
6823
+ function isMainModule() {
6824
+ if (!process.argv[1]) return false;
6825
+ const modulePath = fileURLToPath(import.meta.url);
6826
+ try {
6827
+ return realpathSync(process.argv[1]) === modulePath;
6828
+ } catch {
6829
+ return resolve(process.argv[1]) === modulePath;
6830
+ }
6831
+ }
6832
+
6833
+ if (isMainModule()) {
6834
+ try {
6835
+ await main();
6836
+ } catch (error) {
6837
+ handleFatalError(error, { json: JSON_OUTPUT });
6838
+ }
6238
6839
  }