@triflux/remote 10.53.0 → 10.55.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 (47) hide show
  1. package/hub/team/backend.mjs +10 -3
  2. package/hub/team/cli/commands/start/index.mjs +0 -2
  3. package/hub/team/handoff.mjs +1 -1
  4. package/hub/team/headless.mjs +41 -57
  5. package/hub/team/intervention.mjs +6 -4
  6. package/hub/team/nativeProxy.mjs +2 -48
  7. package/hub/team/pane.mjs +31 -18
  8. package/hub/team/psmux.mjs +36 -20
  9. package/hub/team/remote-session.mjs +66 -56
  10. package/hub/team/retry-state-machine.mjs +1 -1
  11. package/hub/team/session.mjs +80 -59
  12. package/hub/team/terminal-opener.mjs +5 -19
  13. package/hub/team/tui-viewer.mjs +2 -2
  14. package/hub/team/tui.mjs +0 -6
  15. package/hub/workers/factory.mjs +1 -10
  16. package/hub/workers/interface.mjs +0 -7
  17. package/package.json +2 -6
  18. package/scripts/lib/backup-rotation.mjs +60 -0
  19. package/scripts/lib/codex-profile-config.mjs +20 -7
  20. package/scripts/lib/codex-recovery.sh +2 -3
  21. package/scripts/lib/install-blob-history.json +3196 -0
  22. package/scripts/lib/install-retire.mjs +203 -0
  23. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  24. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  25. package/scripts/lib/machine-profile.mjs +2 -2
  26. package/scripts/lib/mcp-filter.mjs +1 -42
  27. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  28. package/scripts/lib/mcp-manifest.mjs +0 -40
  29. package/scripts/lib/process-utils.mjs +19 -0
  30. package/scripts/lib/stable-node.mjs +35 -0
  31. package/scripts/lib/test-env.mjs +20 -0
  32. package/cto/brief.mjs +0 -73
  33. package/cto/collect.mjs +0 -490
  34. package/cto/current.schema.json +0 -148
  35. package/cto/events.mjs +0 -208
  36. package/cto/hygiene.mjs +0 -320
  37. package/cto/lake-root.mjs +0 -127
  38. package/cto/status.mjs +0 -124
  39. package/hub/lib/cto-env.mjs +0 -1
  40. package/hub/team/notify.mjs +0 -385
  41. package/hub/team/process-cleanup.mjs +0 -386
  42. package/hub/team/routing.mjs +0 -249
  43. package/hub/team/staleState.mjs +0 -403
  44. package/hub/team/worker-signal.mjs +0 -263
  45. package/hub/team/worker-signal.types.d.ts +0 -52
  46. package/hub/workers/codex-mcp.mjs +0 -853
  47. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
@@ -15,63 +15,82 @@ import {
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
17
  import { createRequire } from "node:module";
18
- import { homedir, platform as osPlatform } from "node:os";
18
+ import { homedir, platform as osPlatform, userInfo } from "node:os";
19
19
  import { basename, dirname, join, resolve } from "node:path";
20
20
  import { isDeepStrictEqual } from "node:util";
21
+ import { loadRegistryOrDefault, pinnedArgs } from "./mcp-guard-engine.mjs";
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const toml = require("@iarna/toml");
25
+ // 이주가 써 넣는 직접 연결 패키지. 버전은 이 표 한 곳에서만 올린다(2026-10-08 npm 최신, serena 는 v1.7.0 커밋).
26
+ export const MCP_PACKAGE_PINS = Object.freeze({
27
+ context7: "@upstash/context7-mcp@4.2.0",
28
+ "brave-search": "@brave/brave-search-mcp-server@2.1.4",
29
+ exa: "exa-mcp-server@3.4.2",
30
+ tavily: "tavily-mcp@0.2.22",
31
+ jira: "mcp-jira-cloud@4.4.0",
32
+ serena:
33
+ "git+https://github.com/oraios/serena@949a27ef1e5fda1a6e7b561e777bcece345c6ffd",
34
+ notion: "@notionhq/notion-mcp-server@2.5.2",
35
+ });
36
+ // gatewayCmd 는 옛 게이트웨이가 띄운 명령 그대로다. 남은 프로세스를 알아보는 데만 쓴다.
24
37
  const SERVERS = [
25
- ["context7", 8100, "npx", ["-y", "@upstash/context7-mcp@latest"], []],
38
+ ["context7", 8100, "npx -y @upstash/context7-mcp@latest", []],
26
39
  [
27
40
  "brave-search",
28
41
  8101,
29
- "npx",
30
- ["-y", "@brave/brave-search-mcp-server"],
42
+ "npx -y @brave/brave-search-mcp-server",
31
43
  ["BRAVE_API_KEY"],
32
44
  ],
33
- ["exa", 8102, "npx", ["-y", "exa-mcp-server"], ["EXA_API_KEY"]],
34
- ["tavily", 8103, "npx", ["-y", "tavily-mcp@latest"], ["TAVILY_API_KEY"]],
45
+ ["exa", 8102, "npx -y exa-mcp-server", ["EXA_API_KEY"]],
46
+ ["tavily", 8103, "npx -y tavily-mcp@latest", ["TAVILY_API_KEY"]],
35
47
  [
36
48
  "jira",
37
49
  8104,
38
- "npx",
39
- ["-y", "mcp-jira-cloud@latest"],
50
+ "npx -y mcp-jira-cloud@latest",
40
51
  ["JIRA_API_TOKEN", "JIRA_EMAIL", "JIRA_INSTANCE_URL"],
41
52
  ],
42
53
  [
43
54
  "serena",
44
55
  8105,
45
- "uvx",
46
- [
47
- "--from",
48
- "git+https://github.com/oraios/serena",
49
- "serena",
50
- "start-mcp-server",
51
- ],
56
+ "uvx --from git+https://github.com/oraios/serena serena start-mcp-server",
52
57
  [],
53
58
  ],
54
- [
55
- "notion",
56
- 8106,
57
- "npx",
58
- ["-y", "@notionhq/notion-mcp-server"],
59
- ["NOTION_TOKEN"],
60
- ],
59
+ ["notion", 8106, "npx -y @notionhq/notion-mcp-server", ["NOTION_TOKEN"]],
61
60
  [
62
61
  "notion-guest",
63
62
  8107,
64
- "npx",
65
- ["-y", "@notionhq/notion-mcp-server"],
63
+ "npx -y @notionhq/notion-mcp-server",
66
64
  ["NOTION_TOKEN"],
67
65
  ],
68
- ].map(([name, port, command, args, envVars]) => ({
69
- name,
70
- port,
71
- command,
72
- args,
73
- envVars,
74
- }));
66
+ ].map(([name, port, gatewayCmd, envVars]) => {
67
+ const pin = MCP_PACKAGE_PINS[name === "notion-guest" ? "notion" : name];
68
+ return {
69
+ name,
70
+ port,
71
+ gatewayCmd,
72
+ command: name === "serena" ? "uvx" : "npx",
73
+ args:
74
+ name === "serena"
75
+ ? ["--from", pin, "serena", "start-mcp-server"]
76
+ : ["-y", pin],
77
+ envVars,
78
+ };
79
+ });
80
+ // 게이트웨이 이주와 허브 정리가 함께 쓰는 사용자 MCP 설정 파일. HOME 기준 경로와 형식.
81
+ // ~/.mcp.json 은 HOME 에서 Claude 를 열 때 읽는 파일이라 프로젝트 파일이 아니라 사용자 파일로 본다.
82
+ const USER_MCP_FILES = [
83
+ [".claude.json", "json"],
84
+ [".mcp.json", "json"],
85
+ [".claude/mcp.json", "json"],
86
+ [".claude/.mcp.json", "json"],
87
+ [".claude/settings.json", "json"],
88
+ [".codex/config.json", "json"],
89
+ [".codex/config.toml", "toml"],
90
+ [".gemini/settings.json", "json"],
91
+ // agy 는 \${VAR} 참조를 풀지 않으므로 env 없이 바꾸고 셸 환경을 물려받게 한다.
92
+ [".gemini/config/mcp_config.json", "agy"],
93
+ ];
75
94
  const BY_PORT = new Map(SERVERS.map((server) => [server.port, server]));
76
95
  const LABEL = "com.tellang.mcp-gateway";
77
96
  const TASK = "MCP Gateway";
@@ -94,6 +113,13 @@ function fileTarget(file) {
94
113
  return target;
95
114
  }
96
115
 
116
+ function markFirst(seen, target) {
117
+ const key = realpathSync(target);
118
+ if (seen.has(key)) return false;
119
+ seen.add(key);
120
+ return true;
121
+ }
122
+
97
123
  function ownedUrl(value) {
98
124
  if (typeof value !== "string") return null;
99
125
  let url;
@@ -349,8 +375,11 @@ function backupStartup(home, file) {
349
375
  return backupFile(join(directory, basename(file)), readFileSync(file));
350
376
  }
351
377
 
352
- function writeAtomic(file, target, output) {
353
- const backup = backupFile(file);
378
+ // setup 한 번은 게이트웨이 이주와 허브 정리에 같은 backups 를 넘긴다.
379
+ // 같은 파일은 그 setup 에서 처음 바꾸기 직전의 원본만 백업한다.
380
+ function writeAtomic(file, target, output, backups = new Map()) {
381
+ const backup = backups.get(target) ?? backupFile(file);
382
+ backups.set(target, backup);
354
383
  const temp = join(
355
384
  dirname(target),
356
385
  `.tfx-mcp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
@@ -600,9 +629,9 @@ function ownedCommand(command) {
600
629
  "--healthEndpoint /healthz",
601
630
  ].every((flag) => command.includes(flag)) &&
602
631
  SERVERS.some(
603
- ({ port, command: executable, args }) =>
632
+ ({ port, gatewayCmd }) =>
604
633
  new RegExp(`\\s--port\\s+${port}(?:\\s|$)`).test(command) &&
605
- command.includes([executable, ...args].join(" ")),
634
+ command.includes(gatewayCmd),
606
635
  )
607
636
  );
608
637
  }
@@ -676,6 +705,7 @@ export function cleanupLegacyMcp({
676
705
  uid = process.getuid?.(),
677
706
  env = process.env,
678
707
  dryRun = false,
708
+ backups,
679
709
  } = {}) {
680
710
  const result = {
681
711
  ok: true,
@@ -713,21 +743,35 @@ export function cleanupLegacyMcp({
713
743
  result.warnings.push("별도 HOME과 실제 작업 디렉터리가 섞여 이주를 중단함");
714
744
  return result;
715
745
  }
716
- const files = [
717
- [join(home, ".claude.json"), "json"],
718
- [join(repoRoot, ".mcp.json"), "json"],
719
- [join(home, ".codex/config.toml"), "toml"],
720
- [join(home, ".gemini/settings.json"), "json"],
721
- // agy 는 \${VAR} 참조를 풀지 않으므로 env 없이 바꾸고 셸 환경을 물려받게 한다.
722
- [join(home, ".gemini/config/mcp_config.json"), "agy"],
723
- ];
746
+ const files = USER_MCP_FILES.map(([relative, kind]) => [
747
+ join(home, relative),
748
+ kind,
749
+ ]);
750
+ if (resolve(repoRoot) !== resolve(home))
751
+ files.push([join(repoRoot, ".mcp.json"), "json"]);
724
752
  const plans = [];
753
+ const kinds = new Map();
725
754
  let blocked = false;
726
755
  for (const [file, kind] of files) {
727
756
  try {
728
757
  const target = fileTarget(file);
729
758
  if (!target) continue;
759
+ // symlink 별칭이 같은 파일을 두 번 계획하면 두 번째 쓰기가 원문 검증에서 막힌다.
760
+ // 형식이 다른 별칭(agy 와 json)은 env 처리가 달라 한쪽이 깨지므로 이주를 멈춘다.
761
+ const key = realpathSync(target);
762
+ if (kinds.has(key)) {
763
+ if (kinds.get(key) !== kind) {
764
+ result.warnings.push(
765
+ `${file}: 형식이 다른 설정과 같은 파일이라 이주를 멈춤`,
766
+ );
767
+ blocked = true;
768
+ }
769
+ continue;
770
+ }
771
+ kinds.set(key, kind);
730
772
  const original = readFileSync(target, "utf8");
773
+ // 빈 파일은 항목이 없는 것이다. 형식 오류로 보면 이주 전체가 멈춘다.
774
+ if (!original.trim()) continue;
731
775
  const plan =
732
776
  kind === "toml"
733
777
  ? patchToml(original, file, home, env, result.warnings)
@@ -754,7 +798,9 @@ export function cleanupLegacyMcp({
754
798
  readFileSync(plan.target, "utf8") !== plan.original
755
799
  )
756
800
  throw new Error(`${plan.file}: 검사 후 변경됨`);
757
- result.backups.push(writeAtomic(plan.file, plan.target, plan.output));
801
+ result.backups.push(
802
+ writeAtomic(plan.file, plan.target, plan.output, backups),
803
+ );
758
804
  result.changed = true;
759
805
  }
760
806
  } catch (error) {
@@ -784,15 +830,11 @@ export function cleanupLegacyMcp({
784
830
  }
785
831
 
786
832
  const HUB_SERVER = "tfx-hub";
787
- const HUB_TASK = "TrifluxHubEnsure";
788
- const HUB_CONFIG_FILES = [
789
- [".claude.json", "json"],
790
- [".claude/settings.json", "json"],
791
- [".claude/mcp.json", "json"],
792
- [".codex/config.json", "json"],
793
- [".codex/config.toml", "toml"],
794
- [".gemini/settings.json", "json"],
795
- [".gemini/config/mcp_config.json", "json"],
833
+ // [작업 이름, 그 작업이 허브용임을 보여 주는 실행 명령 조각]
834
+ // \Triflux\Hub 는 만든 코드가 이력에 없지만 지워진 `tfx hub ensure` 를 실행해 로그온마다 실패한다.
835
+ const HUB_TASKS = [
836
+ ["TrifluxHubEnsure", "hub-ensure.mjs"],
837
+ ["\\Triflux\\Hub", "tfx hub ensure"],
796
838
  ];
797
839
 
798
840
  // 허브가 등록한 항목은 loopback 의 /mcp 주소다. 다른 모양이면 사용자 항목으로 본다.
@@ -899,32 +941,130 @@ function hubCommandLine(pid, platform, run) {
899
941
  return platform !== "win32" && result.status === 1 ? null : undefined;
900
942
  }
901
943
 
902
- function stopHub(home, platform, run, result) {
903
- const pidFile = join(home, ".claude/cache/tfx-hub/hub.pid");
904
- const target = fileTarget(pidFile);
905
- if (!target) return;
906
- let pid;
944
+ // 명령줄을 argv 로 나눈다. Linux 는 /proc 의 실제 argv, Windows 는 따옴표 규칙을 쓴다.
945
+ // macOS ps 는 인자 경계를 보존하지 않아 공백으로 나누고 exact 를 false 로 둔다.
946
+ function processArgv(pid, command, platform) {
947
+ if (platform === "linux") {
948
+ try {
949
+ const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
950
+ if (argv.at(-1) === "") argv.pop();
951
+ if (argv.length) return { argv, exact: true };
952
+ } catch {
953
+ // 프로세스가 사라졌거나 /proc 를 읽을 수 없다.
954
+ }
955
+ }
956
+ if (platform === "win32") return splitWindowsCommandLine(command);
957
+ return { argv: command.trim().split(/\s+/), exact: false };
958
+ }
959
+
960
+ // 따옴표 밖 space, tab 에서만 끊고 따옴표 구간은 앞뒤 글자와 한 인자로 잇는다(Windows CRT 규칙).
961
+ // 백슬래시로 가린 따옴표는 규칙이 복잡해 경계를 믿지 않는다.
962
+ function splitWindowsCommandLine(command) {
963
+ const argv = [];
964
+ let current = "";
965
+ let quoted = false;
966
+ let started = false;
967
+ for (const char of command) {
968
+ if (char === '"') {
969
+ quoted = !quoted;
970
+ started = true;
971
+ } else if ((char === " " || char === "\t") && !quoted) {
972
+ if (started) argv.push(current);
973
+ current = "";
974
+ started = false;
975
+ } else {
976
+ current += char;
977
+ started = true;
978
+ }
979
+ }
980
+ if (started) argv.push(current);
981
+ return { argv, exact: !command.includes('\\"') };
982
+ }
983
+
984
+ // 허용 목록. 값을 받는 옵션(다음 인자 또는 --name=value)과 값이 없는 플래그만 건너뛴다.
985
+ // 그 밖의 옵션(--run, --eval, --test, --watch 같은 실행 모드 포함)은 entry 를 특정할 수 없어
986
+ // 허브가 아닌 것으로 본다.
987
+ const NODE_VALUE_OPTIONS = new Set([
988
+ "-r",
989
+ "--require",
990
+ "--import",
991
+ "--loader",
992
+ "--experimental-loader",
993
+ "--env-file",
994
+ "--input-type",
995
+ "-C",
996
+ "--conditions",
997
+ ]);
998
+ const NODE_FLAG_OPTIONS = new Set([
999
+ "--no-warnings",
1000
+ "--enable-source-maps",
1001
+ "--trace-warnings",
1002
+ "--trace-uncaught",
1003
+ "--inspect",
1004
+ "--expose-gc",
1005
+ ]);
1006
+
1007
+ // node 의 entry script 가 triflux 패키지의 hub/server.mjs 일 때만 패키지 루트를 돌려준다.
1008
+ // 같은 경로를 인자로 받거나 편집하는 다른 프로세스는 허브가 아니다.
1009
+ // argv 경계를 믿을 수 없으면(exact=false) 값이 붙는 옵션은 공백 든 값일 수 있어 판정하지 않는다.
1010
+ function trifluxHubRoot({ argv, exact }) {
1011
+ if (!/(?:^|[/\\])node(?:\.exe)?$/i.test(argv[0] ?? "")) return null;
1012
+ let index = 1;
1013
+ while (argv[index]?.startsWith("-")) {
1014
+ const [name, value] = argv[index].split("=");
1015
+ if (NODE_FLAG_OPTIONS.has(name) && value === undefined) index += 1;
1016
+ else if (exact && NODE_VALUE_OPTIONS.has(name))
1017
+ index += value === undefined ? 2 : 1;
1018
+ else return null;
1019
+ }
1020
+ // 공백이 든 경로가 잘린 조각은 상대 경로로 남으므로 절대 경로만 받는다.
1021
+ const entry = argv[index]?.match(
1022
+ /^((?:[A-Za-z]:)?[/\\].*)[/\\]hub[/\\]server\.mjs$/,
1023
+ );
1024
+ if (!entry) return null;
1025
+ // 공백 든 entry 가 잘린 조각일 수 있으므로 뒤에 인자가 남으면 판정하지 않는다.
1026
+ if (!exact && argv.length > index + 1) return null;
907
1027
  try {
908
- pid = Number(JSON.parse(readFileSync(target, "utf8")).pid);
1028
+ const pkg = JSON.parse(
1029
+ readFileSync(join(entry[1], "package.json"), "utf8"),
1030
+ );
1031
+ return pkg?.name === "triflux" ? entry[1] : null;
909
1032
  } catch {
910
- /* 아래에서 보존 처리 */
1033
+ return null;
911
1034
  }
912
- if (!Number.isInteger(pid) || pid <= 1) {
913
- result.warnings.push(`${pidFile}: pid 를 읽을 수 없어 보존`);
914
- return;
1035
+ }
1036
+
1037
+ function listHubProcesses(platform, run) {
1038
+ if (platform === "win32") {
1039
+ const result = runPowerShell(
1040
+ run,
1041
+ "$items=@(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object { @{ pid=[string]$_.ProcessId; command=$_.CommandLine } }); ConvertTo-Json -InputObject $items -Compress",
1042
+ );
1043
+ if (!result.ok) return null;
1044
+ return JSON.parse(result.output || "[]");
915
1045
  }
916
- const command = hubCommandLine(pid, platform, run);
917
- if (command === undefined) {
918
- result.warnings.push(`${pidFile}: pid ${pid} 확인 실패, 종료하지 않음`);
1046
+ const result = tryRun(run, "ps", ["-axo", "pid=,command="]);
1047
+ if (!result.ok) return null;
1048
+ return result.output.split(/\r?\n/).flatMap((line) => {
1049
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
1050
+ return match ? [{ pid: match[1], command: match[2] }] : [];
1051
+ });
1052
+ }
1053
+
1054
+ // 허브는 제거됐으므로 triflux 패키지의 hub/server.mjs 로 확인된 프로세스는 설치본,
1055
+ // 체크아웃, worktree 를 가리지 않고 종료한다(2026-10-08 결정).
1056
+ function stopHubs(home, platform, run, result, log) {
1057
+ const processes = listHubProcesses(platform, run);
1058
+ if (!processes) {
1059
+ result.warnings.push("허브 프로세스 조회 실패, 종료하지 않음");
919
1060
  return;
920
1061
  }
921
- if (command !== null) {
922
- if (!/[/\\]hub[/\\]server\.mjs(?:["'\s]|$)/.test(command)) {
923
- // 허브가 아닌 프로세스는 건드리지 않고 우리 파일만 지운다.
924
- unlinkSync(target);
925
- result.changed = true;
926
- return;
927
- }
1062
+ let allStopped = true;
1063
+ for (const { pid, command } of processes) {
1064
+ if (Number(pid) === process.pid || typeof command !== "string") continue;
1065
+ const root = trifluxHubRoot(processArgv(pid, command, platform));
1066
+ if (!root) continue;
1067
+ log(`허브 종료: pid ${pid} (${root})`);
928
1068
  const stopped =
929
1069
  platform === "win32"
930
1070
  ? tryRun(run, "taskkill", ["/F", "/T", "/PID", String(pid)])
@@ -935,38 +1075,67 @@ function stopHub(home, platform, run, result) {
935
1075
  if (!gone)
936
1076
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
937
1077
  }
938
- if (!gone) {
939
- result.warnings.push(`${pidFile}: pid ${pid} 종료 확인 실패, 파일 보존`);
940
- return;
1078
+ if (gone) result.hubStopped = true;
1079
+ else {
1080
+ allStopped = false;
1081
+ result.warnings.push(`허브 pid ${pid}: 종료 확인 실패`);
941
1082
  }
942
- result.hubStopped = true;
943
1083
  }
944
- unlinkSync(target);
1084
+ const pidFile = join(home, ".claude/cache/tfx-hub/hub.pid");
1085
+ if (!allStopped || !fileTarget(pidFile)) return;
1086
+ // 판정을 못 했지만 허브로 보이는 프로세스가 hub.pid 에 있으면 단서를 남긴다.
1087
+ let pid;
1088
+ try {
1089
+ pid = Number(JSON.parse(readFileSync(pidFile, "utf8")).pid);
1090
+ } catch {
1091
+ /* 읽을 수 없는 pid 파일은 지운다 */
1092
+ }
1093
+ const command = Number.isInteger(pid)
1094
+ ? hubCommandLine(pid, platform, run)
1095
+ : null;
1096
+ if (command && /[/\\]hub[/\\]server\.mjs/.test(command)) {
1097
+ result.warnings.push(`${pidFile}: pid ${pid} 를 허브로 확인하지 못해 보존`);
1098
+ return;
1099
+ }
1100
+ unlinkSync(pidFile);
945
1101
  result.changed = true;
946
1102
  }
947
1103
 
948
- function removeHubTask(run, result) {
949
- const query = tryRun(run, "schtasks.exe", [
950
- "/Query",
951
- "/TN",
952
- HUB_TASK,
953
- "/FO",
954
- "LIST",
955
- "/V",
956
- ]);
957
- if (!query.ok) return;
958
- if (!query.output.includes("hub-ensure.mjs")) {
959
- result.warnings.push(`${HUB_TASK}: hub-ensure 작업이 아니라 보존`);
960
- return;
1104
+ // 작업 XML 에서 실행 동작만 꺼낸다. 설명 같은 다른 필드의 문구로 판정하지 않는다.
1105
+ function taskExecCommand(xml) {
1106
+ const decode = (text = "") =>
1107
+ text
1108
+ .replace(/&quot;/g, '"')
1109
+ .replace(/&apos;/g, "'")
1110
+ .replace(/&lt;/g, "<")
1111
+ .replace(/&gt;/g, ">")
1112
+ .replace(/&amp;/g, "&");
1113
+ const actions = xml.match(/<Actions\b[^>]*>([\s\S]*?)<\/Actions>/)?.[1] ?? "";
1114
+ const execs = [...actions.matchAll(/<Exec\b[^>]*>([\s\S]*?)<\/Exec>/g)];
1115
+ if (
1116
+ execs.length !== 1 ||
1117
+ /<(?:ComHandler|SendEmail|ShowMessage)\b/.test(actions)
1118
+ )
1119
+ return null;
1120
+ const field = (name) =>
1121
+ decode(
1122
+ execs[0][1].match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1],
1123
+ );
1124
+ return `${field("Command")} ${field("Arguments")}`;
1125
+ }
1126
+
1127
+ function removeHubTasks(run, result) {
1128
+ for (const [task, marker] of HUB_TASKS) {
1129
+ const query = tryRun(run, "schtasks.exe", ["/Query", "/TN", task, "/XML"]);
1130
+ if (!query.ok) continue;
1131
+ if (!taskExecCommand(query.output)?.includes(marker)) {
1132
+ result.warnings.push(`${task}: 허브 작업이 아니라 보존`);
1133
+ continue;
1134
+ }
1135
+ const removed = tryRun(run, "schtasks.exe", ["/Delete", "/TN", task, "/F"]);
1136
+ if (removed.ok) result.changed = true;
1137
+ else result.warnings.push(`${task}: 예약 작업 삭제 실패`);
961
1138
  }
962
- const removed = tryRun(run, "schtasks.exe", [
963
- "/Delete",
964
- "/TN",
965
- HUB_TASK,
966
- "/F",
967
- ]);
968
- if (removed.ok) result.changed = true;
969
- else result.warnings.push(`${HUB_TASK}: 예약 작업 삭제 실패`);
970
1139
  }
971
1140
 
972
1141
  /** cwd 의 프로젝트 MCP 파일 중 제거된 허브가 만든 tfx-hub 항목이 있는 파일을 돌려준다. */
@@ -988,6 +1157,8 @@ export function cleanupTfxHub({
988
1157
  platform = osPlatform(),
989
1158
  run = defaultRun,
990
1159
  pluginRoot,
1160
+ log = () => {},
1161
+ backups,
991
1162
  } = {}) {
992
1163
  const result = {
993
1164
  ok: true,
@@ -1006,12 +1177,14 @@ export function cleanupTfxHub({
1006
1177
  result.skipped = true;
1007
1178
  return result;
1008
1179
  }
1009
- for (const [relative, kind] of HUB_CONFIG_FILES) {
1180
+ const seen = new Set();
1181
+ for (const [relative, kind] of USER_MCP_FILES) {
1010
1182
  const file = join(home, relative);
1011
1183
  try {
1012
1184
  const target = fileTarget(file);
1013
- if (!target) continue;
1185
+ if (!target || !markFirst(seen, target)) continue;
1014
1186
  const original = readFileSync(target, "utf8");
1187
+ if (!original.trim()) continue;
1015
1188
  const plan =
1016
1189
  kind === "toml"
1017
1190
  ? removeHubFromToml(original, file, result.warnings)
@@ -1024,7 +1197,7 @@ export function cleanupTfxHub({
1024
1197
  result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
1025
1198
  continue;
1026
1199
  }
1027
- result.backups.push(writeAtomic(file, target, plan.output));
1200
+ result.backups.push(writeAtomic(file, target, plan.output, backups));
1028
1201
  result.removed += plan.count;
1029
1202
  result.changed = true;
1030
1203
  } catch {
@@ -1037,8 +1210,8 @@ export function cleanupTfxHub({
1037
1210
  (resolve(home) === resolve(homedir()) && platform === osPlatform());
1038
1211
  if (realSystem) {
1039
1212
  try {
1040
- stopHub(home, platform, run, result);
1041
- if (platform === "win32") removeHubTask(run, result);
1213
+ stopHubs(home, platform, run, result, log);
1214
+ if (platform === "win32") removeHubTasks(run, result);
1042
1215
  } catch (error) {
1043
1216
  result.ok = false;
1044
1217
  result.warnings.push(`허브 프로세스 정리 실패: ${error.message}`);
@@ -1064,3 +1237,131 @@ export function cleanupTfxHub({
1064
1237
  }
1065
1238
  return result;
1066
1239
  }
1240
+
1241
+ // doctor 가 레지스트리와 비교하는 사용자 설정 파일. [HOME 기준 경로, 형식, 클라이언트]
1242
+ // 프로젝트의 .mcp.json 은 저장소에 커밋될 수 있어 setup 이 바꾸지 않는다(doctor --fix, mcp sync 몫).
1243
+ const REGISTRY_PIN_FILES = [
1244
+ [".claude.json", "json", "claude"],
1245
+ [".mcp.json", "json", "claude"],
1246
+ [".claude/mcp.json", "json", "claude"],
1247
+ [".codex/config.toml", "toml", "codex"],
1248
+ [".gemini/config/mcp_config.json", "json", "antigravity"],
1249
+ ];
1250
+
1251
+ function pinJson(original, servers) {
1252
+ const data = JSON.parse(original);
1253
+ let count = 0;
1254
+ for (const [name, server] of servers) {
1255
+ const entry = data?.mcpServers?.[name];
1256
+ const args = pinnedArgs(entry, server);
1257
+ if (!args) continue;
1258
+ entry.args = args;
1259
+ count++;
1260
+ }
1261
+ return {
1262
+ output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
1263
+ count,
1264
+ };
1265
+ }
1266
+
1267
+ // [mcp_servers.<name>] 머리글 다음 줄부터 다음 표 머리글 전까지의 범위.
1268
+ function tomlSectionBody(text, name) {
1269
+ let offset = 0;
1270
+ let start = -1;
1271
+ for (const line of text.split(/(?<=\n)/)) {
1272
+ if (start >= 0 && /^\s*\[/.test(line)) return { start, end: offset };
1273
+ const match = line.match(
1274
+ /^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?\r?\n?$/,
1275
+ );
1276
+ offset += line.length;
1277
+ if ((match?.[1] ?? match?.[2] ?? match?.[3]) === name) start = offset;
1278
+ }
1279
+ return start >= 0 ? { start, end: offset } : null;
1280
+ }
1281
+
1282
+ function pinToml(original, servers) {
1283
+ const data = toml.parse(original);
1284
+ let output = original;
1285
+ let count = 0;
1286
+ for (const [name, server] of servers) {
1287
+ const args = pinnedArgs(data.mcp_servers?.[name], server);
1288
+ const section = args && tomlSectionBody(output, name);
1289
+ if (!section) continue;
1290
+ const body = output.slice(section.start, section.end);
1291
+ const next = body.replace(
1292
+ /^([ \t]*(?:args|"args"|'args')[ \t]*=[ \t]*)\[[^\]]*\]/m,
1293
+ `$1${JSON.stringify(args)}`,
1294
+ );
1295
+ if (next === body) continue;
1296
+ output = output.slice(0, section.start) + next + output.slice(section.end);
1297
+ data.mcp_servers[name].args = args;
1298
+ count++;
1299
+ }
1300
+ if (count && !isDeepStrictEqual(toml.parse(output), data))
1301
+ throw new Error("TOML 구조 불일치");
1302
+ return { output, count };
1303
+ }
1304
+
1305
+ /** 레지스트리 stdio 항목 중 패키지 버전만 다른 것을 레지스트리 고정 버전으로 바꾼다. */
1306
+ export function pinRegistryMcpPackages({
1307
+ home = homedir(),
1308
+ registry = loadRegistryOrDefault(),
1309
+ backups,
1310
+ } = {}) {
1311
+ const result = {
1312
+ ok: true,
1313
+ changed: false,
1314
+ pinned: 0,
1315
+ backups: [],
1316
+ warnings: [],
1317
+ };
1318
+ // 테스트가 실제 홈의 설정을 바꾸지 않게 한다.
1319
+ if (
1320
+ process.env.NODE_TEST_CONTEXT &&
1321
+ resolve(home) === resolve(userInfo().homedir)
1322
+ ) {
1323
+ result.skipped = true;
1324
+ return result;
1325
+ }
1326
+ const stdio = Object.entries(registry?.servers ?? {}).filter(
1327
+ ([, server]) =>
1328
+ typeof server?.command === "string" && Array.isArray(server.args),
1329
+ );
1330
+ const seen = new Set();
1331
+ for (const [relative, kind, client] of REGISTRY_PIN_FILES) {
1332
+ const servers = stdio.filter(([, server]) =>
1333
+ // mcp-guard-engine 의 serverTargets 처럼 빈 targets 는 기본 세 클라이언트다.
1334
+ (server.targets?.length
1335
+ ? server.targets
1336
+ : ["claude", "codex", "antigravity"]
1337
+ ).includes(client),
1338
+ );
1339
+ if (!servers.length) continue;
1340
+ const file = join(home, relative);
1341
+ try {
1342
+ const target = fileTarget(file);
1343
+ if (!target || !markFirst(seen, target)) continue;
1344
+ const original = readFileSync(target, "utf8");
1345
+ if (!original.trim()) continue;
1346
+ const plan =
1347
+ kind === "toml"
1348
+ ? pinToml(original, servers)
1349
+ : pinJson(original, servers);
1350
+ if (!plan.count) continue;
1351
+ if (
1352
+ fileTarget(file) !== target ||
1353
+ readFileSync(target, "utf8") !== original
1354
+ ) {
1355
+ result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
1356
+ continue;
1357
+ }
1358
+ result.backups.push(writeAtomic(file, target, plan.output, backups));
1359
+ result.pinned += plan.count;
1360
+ result.changed = true;
1361
+ } catch {
1362
+ result.warnings.push(`${file}: MCP 버전 고정 반영 실패, 파일 보존`);
1363
+ result.ok = false;
1364
+ }
1365
+ }
1366
+ return result;
1367
+ }
@@ -7,8 +7,8 @@
7
7
  // 신뢰 경계: 파일을 source 하거나 eval 하지 않는다. allowlist key 와 안전한
8
8
  // literal 값만 읽으므로 셸 표현식은 실행되지 않는다.
9
9
  //
10
- // 부수효과: 모듈 로드 시 파일을 읽지 않고 환경도 바꾸지 않는다. hub/lib/cto-env.mjs
11
- // 처럼 순수 함수만 노출하므로 HUD, hook, hub 어느 소비자에서도 안전하게 import 한다.
10
+ // 부수효과: 모듈 로드 시 파일을 읽지 않고 환경도 바꾸지 않는다. 순수 함수만
11
+ // 노출하므로 HUD, hook, hub 어느 소비자에서도 안전하게 import 한다.
12
12
 
13
13
  import { readFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";