triflux 10.21.0 → 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.
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,
@@ -119,6 +121,8 @@ const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
119
121
  const _DOT = `${GRAY}·${RESET}`;
120
122
  const STALE_TEAM_MAX_AGE_SEC = 3600;
121
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");
122
126
 
123
127
  const _EXIT_SUCCESS = 0;
124
128
  const EXIT_ERROR = 1;
@@ -151,7 +155,7 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
151
155
  },
152
156
  doctor: {
153
157
  usage:
154
- "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--json]",
158
+ "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--json]",
155
159
  description: "설치 상태 진단 및 자동 복구",
156
160
  options: [
157
161
  {
@@ -187,6 +191,24 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
187
191
  description:
188
192
  "Phase 1 dynamic routing 상태 진단 (env / policy / snapshot cache / preview decision)",
189
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
+ },
190
212
  {
191
213
  name: "--json",
192
214
  type: "boolean",
@@ -505,6 +527,403 @@ function createCliError(
505
527
  return error;
506
528
  }
507
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
+
508
927
  function inferExitCode(error) {
509
928
  if (Number.isInteger(error?.exitCode)) return error.exitCode;
510
929
  if (error?.code === "ENOENT") return EXIT_CLI_MISSING;
@@ -1864,6 +2283,9 @@ async function cmdDoctor(options = {}) {
1864
2283
  fix = false,
1865
2284
  reset = false,
1866
2285
  purgeLogs = false,
2286
+ cleanupStaleHubs = false,
2287
+ cleanupStaleHubsDryRun = true,
2288
+ cleanupStaleHubsApply = false,
1867
2289
  json = false,
1868
2290
  } = options;
1869
2291
  const report = {
@@ -1873,6 +2295,7 @@ async function cmdDoctor(options = {}) {
1873
2295
  actions: [],
1874
2296
  hook_coverage: { total: 0, registered: 0, missing: [] },
1875
2297
  fsmonitorDaemons: { stale: 0, killed: 0 },
2298
+ hubServers: { detached: 0, stale: 0, activeHealthy: null },
1876
2299
  issue_count: 0,
1877
2300
  };
1878
2301
 
@@ -3232,6 +3655,96 @@ async function cmdDoctor(options = {}) {
3232
3655
  }
3233
3656
  }
3234
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
+
3235
3748
  // 12.5. 고아 node.exe 프로세스 정리 (Windows)
3236
3749
  section("Orphan Processes");
3237
3750
  if (process.platform === "win32") {
@@ -6012,7 +6525,28 @@ async function main() {
6012
6525
  const fix = cmdArgs.includes("--fix");
6013
6526
  const reset = cmdArgs.includes("--reset");
6014
6527
  const purgeLogs = cmdArgs.includes("--purge-logs");
6015
- 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
+ });
6016
6550
  return;
6017
6551
  }
6018
6552
  case "mcp":
@@ -6286,8 +6820,20 @@ ${s.options.map((o) => ` ${DIM}${o.name.padEnd(16)}${RESET} ${GRAY}${o.descri
6286
6820
  }
6287
6821
  }
6288
6822
 
6289
- try {
6290
- await main();
6291
- } catch (error) {
6292
- 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
+ }
6293
6839
  }
@@ -8,8 +8,9 @@ import {
8
8
  capturePsmuxPane,
9
9
  configurePsmuxKeybindings,
10
10
  createPsmuxSession,
11
+ getMultiplexerType,
11
12
  getPsmuxSessionAttachedCount,
12
- hasPsmux,
13
+ hasMultiplexer,
13
14
  killPsmuxSession,
14
15
  listPsmuxSessions,
15
16
  psmuxExec,
@@ -68,6 +69,38 @@ function hasGitBashTmux() {
68
69
  }
69
70
  }
70
71
 
72
+ function getCommandVersion(command) {
73
+ const r = spawnSync(command, ["-V"], {
74
+ encoding: "utf8",
75
+ timeout: 3000,
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ windowsHide: true,
78
+ });
79
+ if ((r.status ?? 1) !== 0) return null;
80
+ return `${r.stdout || ""}${r.stderr || ""}`.trim();
81
+ }
82
+
83
+ function isPsmuxVersion(command) {
84
+ try {
85
+ return /\bpsmux\b/i.test(getCommandVersion(command) || "");
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function isPsmuxCommandName(command) {
92
+ const name = String(command || "")
93
+ .replace(/\\/g, "/")
94
+ .split("/")
95
+ .pop()
96
+ .toLowerCase();
97
+ return name === "psmux" || name === "psmux.exe" || name === "psmux.cmd";
98
+ }
99
+
100
+ function hasLiteralPsmuxBinary(command = process.env.PSMUX_BIN || "psmux") {
101
+ return isPsmuxVersion(command);
102
+ }
103
+
71
104
  /**
72
105
  * 터미널 멀티플렉서 감지 (결과 캐싱 — 프로세스 수명 동안 불변)
73
106
  * @returns {'tmux'|'git-bash-tmux'|'wsl-tmux'|'psmux'|null}
@@ -75,7 +108,26 @@ function hasGitBashTmux() {
75
108
  let _cachedMux;
76
109
  export function detectMultiplexer() {
77
110
  if (_cachedMux !== undefined) return _cachedMux;
78
- if (hasPsmux()) {
111
+
112
+ if (process.platform !== "win32") {
113
+ const primaryMux = getMultiplexerType();
114
+ if (primaryMux === "tmux" && hasMultiplexer()) {
115
+ _cachedMux = "tmux";
116
+ return _cachedMux;
117
+ }
118
+ if (hasTmux()) {
119
+ _cachedMux = "tmux";
120
+ return _cachedMux;
121
+ }
122
+ if (hasLiteralPsmuxBinary()) {
123
+ _cachedMux = "psmux";
124
+ return _cachedMux;
125
+ }
126
+ _cachedMux = null;
127
+ return _cachedMux;
128
+ }
129
+
130
+ if (hasMultiplexer() && isPsmuxCommandName(getMultiplexerType())) {
79
131
  _cachedMux = "psmux";
80
132
  return _cachedMux;
81
133
  }