@triflux/core 10.51.0 → 10.52.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/hub/bridge.mjs +65 -1172
- package/hub/index.mjs +2 -18
- package/hub/lib/process-utils.mjs +1 -33
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- package/hub/team/nativeProxy.mjs +759 -0
- package/hub/team/session-context.mjs +1 -1
- package/hud/constants.mjs +2 -0
- package/hud/hud-qos-status.mjs +21 -5
- package/hud/providers/gemini.mjs +20 -0
- package/hud/renderers.mjs +79 -46
- package/hud/utils.mjs +9 -5
- package/package.json +2 -4
- package/scripts/lib/agent-route-policy.mjs +0 -67
- package/scripts/lib/cli-claude.mjs +0 -12
- package/scripts/lib/env-probe.mjs +0 -70
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/mcp-guard-engine.mjs +40 -98
- package/scripts/lib/session-presence.mjs +1 -99
- package/hub/adaptive-diagnostic.mjs +0 -350
- package/hub/adaptive-inject.mjs +0 -224
- package/hub/adaptive-memory.mjs +0 -277
- package/hub/adaptive.mjs +0 -172
- package/hub/assign-callbacks.mjs +0 -170
- package/hub/delegator/contracts.mjs +0 -38
- package/hub/delegator/index.mjs +0 -14
- package/hub/delegator/schema/delegator-tools.schema.json +0 -250
- package/hub/delegator/service.mjs +0 -336
- package/hub/delegator/tool-definitions.mjs +0 -35
- package/hub/hitl.mjs +0 -202
- package/hub/lib/known-errors.json +0 -72
- package/hub/lib/memory-store.mjs +0 -1001
- package/hub/lib/phase-manager.mjs +0 -493
- package/hub/lib/state-snapshot.mjs +0 -293
- package/hub/lib/trace-recorder.mjs +0 -153
- package/hub/lib/uuidv7.mjs +0 -45
- package/hub/memory-doctor.mjs +0 -640
- package/hub/middleware/quota-middleware.mjs +0 -70
- package/hub/middleware/request-logger.mjs +0 -191
- package/hub/paths.mjs +0 -24
- package/hub/pipeline/index.mjs +0 -163
- package/hub/pipeline/state.mjs +0 -191
- package/hub/pipeline/transitions.mjs +0 -131
- package/hub/reflexion.mjs +0 -290
- package/hub/role-contract.mjs +0 -312
- package/hub/router.mjs +0 -2026
- package/hub/session-fingerprint.mjs +0 -418
- package/hub/state.mjs +0 -300
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team-bridge.mjs +0 -27
- package/scripts/lib/claudemd-scanner.mjs +0 -26
- package/scripts/lib/context.mjs +0 -51
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
|
@@ -1,63 +1,12 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { join } from "node:path";
|
|
6
|
-
import { resolveHubPortForContext } from "../../hub/hub-lifecycle.mjs";
|
|
7
5
|
import { whichCommand, whichCommandAsync } from "../../hub/platform.mjs";
|
|
8
6
|
|
|
9
|
-
const HUB_DEFAULT_PORT = 27888;
|
|
10
7
|
const CLI_PROBE_CACHE = new Map();
|
|
11
8
|
const CLI_PROBE_PROMISES = new Map();
|
|
12
9
|
|
|
13
|
-
function fetchHubStatus({
|
|
14
|
-
execSyncFn = execSync,
|
|
15
|
-
statusUrl = resolveDefaultStatusUrl(),
|
|
16
|
-
timeout = 3000,
|
|
17
|
-
} = {}) {
|
|
18
|
-
const response = execSyncFn(`curl -sf ${statusUrl}`, {
|
|
19
|
-
timeout,
|
|
20
|
-
encoding: "utf8",
|
|
21
|
-
windowsHide: true,
|
|
22
|
-
});
|
|
23
|
-
const data = JSON.parse(response);
|
|
24
|
-
return {
|
|
25
|
-
ok: true,
|
|
26
|
-
state: data?.hub?.state || "unknown",
|
|
27
|
-
pid: data?.pid,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function resolveDefaultStatusUrl(env = process.env, cwd = process.cwd()) {
|
|
32
|
-
const port = resolveHubPortForContext({
|
|
33
|
-
env,
|
|
34
|
-
cwd,
|
|
35
|
-
defaultPort: HUB_DEFAULT_PORT,
|
|
36
|
-
});
|
|
37
|
-
return `http://127.0.0.1:${port}/status`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function resolveStatusUrlForContext({
|
|
41
|
-
statusUrl,
|
|
42
|
-
env = process.env,
|
|
43
|
-
cwd = process.cwd(),
|
|
44
|
-
} = {}) {
|
|
45
|
-
try {
|
|
46
|
-
const url = new URL(String(statusUrl));
|
|
47
|
-
url.port = String(
|
|
48
|
-
resolveHubPortForContext({
|
|
49
|
-
port: url.port,
|
|
50
|
-
env,
|
|
51
|
-
cwd,
|
|
52
|
-
defaultPort: HUB_DEFAULT_PORT,
|
|
53
|
-
}),
|
|
54
|
-
);
|
|
55
|
-
return url.toString();
|
|
56
|
-
} catch {
|
|
57
|
-
return resolveDefaultStatusUrl(env, cwd);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
10
|
function normalizeCliName(name) {
|
|
62
11
|
return String(name ?? "").trim() || null;
|
|
63
12
|
}
|
|
@@ -219,22 +168,3 @@ export function detectCodexPlan(options = {}) {
|
|
|
219
168
|
const { plan, source } = detectCodexAuthState(options);
|
|
220
169
|
return { plan, source };
|
|
221
170
|
}
|
|
222
|
-
|
|
223
|
-
export function checkHub({
|
|
224
|
-
env = process.env,
|
|
225
|
-
cwd = process.cwd(),
|
|
226
|
-
statusUrl = resolveDefaultStatusUrl(env, cwd),
|
|
227
|
-
requestTimeoutMs = 3000,
|
|
228
|
-
execSyncFn = execSync,
|
|
229
|
-
} = {}) {
|
|
230
|
-
const guardedStatusUrl = resolveStatusUrlForContext({ statusUrl, env, cwd });
|
|
231
|
-
try {
|
|
232
|
-
return fetchHubStatus({
|
|
233
|
-
execSyncFn,
|
|
234
|
-
statusUrl: guardedStatusUrl,
|
|
235
|
-
timeout: requestTimeoutMs,
|
|
236
|
-
});
|
|
237
|
-
} catch {
|
|
238
|
-
return { ok: false, state: "unreachable", restart: "disabled" };
|
|
239
|
-
}
|
|
240
|
-
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readFileSync,
|
|
10
10
|
realpathSync,
|
|
11
11
|
renameSync,
|
|
12
|
+
rmSync,
|
|
12
13
|
statSync,
|
|
13
14
|
unlinkSync,
|
|
14
15
|
writeFileSync,
|
|
@@ -781,3 +782,285 @@ export function cleanupLegacyMcp({
|
|
|
781
782
|
}
|
|
782
783
|
return result;
|
|
783
784
|
}
|
|
785
|
+
|
|
786
|
+
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"],
|
|
796
|
+
];
|
|
797
|
+
|
|
798
|
+
// 허브가 등록한 항목은 loopback 의 /mcp 주소다. 다른 모양이면 사용자 항목으로 본다.
|
|
799
|
+
function isHubEntry(config) {
|
|
800
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
801
|
+
return false;
|
|
802
|
+
return [config.url, config.httpUrl, config.serverUrl].some((value) => {
|
|
803
|
+
try {
|
|
804
|
+
const url = new URL(value);
|
|
805
|
+
return (
|
|
806
|
+
url.protocol === "http:" &&
|
|
807
|
+
["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) &&
|
|
808
|
+
url.pathname === "/mcp"
|
|
809
|
+
);
|
|
810
|
+
} catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function removeHubFromJson(original, file, warnings) {
|
|
817
|
+
const data = JSON.parse(original);
|
|
818
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
819
|
+
throw new Error(`${file}: JSON 객체가 아닙니다`);
|
|
820
|
+
const groups = [
|
|
821
|
+
data.mcpServers,
|
|
822
|
+
...Object.values(data.projects ?? {}).map((project) => project?.mcpServers),
|
|
823
|
+
];
|
|
824
|
+
let count = 0;
|
|
825
|
+
for (const entries of groups) {
|
|
826
|
+
if (
|
|
827
|
+
!entries ||
|
|
828
|
+
typeof entries !== "object" ||
|
|
829
|
+
Array.isArray(entries) ||
|
|
830
|
+
!(HUB_SERVER in entries)
|
|
831
|
+
)
|
|
832
|
+
continue;
|
|
833
|
+
if (!isHubEntry(entries[HUB_SERVER])) {
|
|
834
|
+
warnings.push(`${file}: ${HUB_SERVER} 항목의 소유를 확인할 수 없어 보존`);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
delete entries[HUB_SERVER];
|
|
838
|
+
count++;
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
|
|
842
|
+
count,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function removeHubFromToml(original, file, warnings) {
|
|
847
|
+
const data = toml.parse(original);
|
|
848
|
+
const entry = data.mcp_servers?.[HUB_SERVER];
|
|
849
|
+
if (!entry) return { output: original, count: 0 };
|
|
850
|
+
if (!isHubEntry(entry)) {
|
|
851
|
+
warnings.push(`${file}: ${HUB_SERVER} 항목의 소유를 확인할 수 없어 보존`);
|
|
852
|
+
return { output: original, count: 0 };
|
|
853
|
+
}
|
|
854
|
+
// [mcp_servers.tfx-hub] 와 그 하위 표만 다음 표 머리글 전까지 걷어낸다.
|
|
855
|
+
const hubHeader = new RegExp(
|
|
856
|
+
`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*(?:"${HUB_SERVER}"|'${HUB_SERVER}'|${HUB_SERVER})\\s*(?:\\.[^\\]]*)?\\]`,
|
|
857
|
+
);
|
|
858
|
+
const kept = [];
|
|
859
|
+
let skipping = false;
|
|
860
|
+
let trailing = [];
|
|
861
|
+
for (const line of original.split(/(?<=\n)/)) {
|
|
862
|
+
if (/^\s*\[/.test(line)) {
|
|
863
|
+
skipping = hubHeader.test(line);
|
|
864
|
+
if (!skipping) {
|
|
865
|
+
while (trailing[0]?.trim() === "" && kept.at(-1)?.trim() === "")
|
|
866
|
+
trailing.shift();
|
|
867
|
+
kept.push(...trailing);
|
|
868
|
+
}
|
|
869
|
+
trailing = [];
|
|
870
|
+
} else if (skipping) {
|
|
871
|
+
trailing = /^\s*(?:#.*)?\r?\n?$/.test(line) ? [...trailing, line] : [];
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!skipping) kept.push(line);
|
|
875
|
+
}
|
|
876
|
+
const output = kept.join("");
|
|
877
|
+
delete data.mcp_servers[HUB_SERVER];
|
|
878
|
+
if (!Object.keys(data.mcp_servers).length) delete data.mcp_servers;
|
|
879
|
+
try {
|
|
880
|
+
if (!isDeepStrictEqual(toml.parse(output), data))
|
|
881
|
+
throw new Error("TOML 구조 불일치");
|
|
882
|
+
} catch {
|
|
883
|
+
warnings.push(`${file}: 이주 결과 TOML 검증 실패, 파일 보존`);
|
|
884
|
+
return { output: original, count: 0 };
|
|
885
|
+
}
|
|
886
|
+
return { output, count: 1 };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function hubCommandLine(pid, platform, run) {
|
|
890
|
+
const result =
|
|
891
|
+
platform === "win32"
|
|
892
|
+
? runPowerShell(
|
|
893
|
+
run,
|
|
894
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
|
|
895
|
+
)
|
|
896
|
+
: tryRun(run, "ps", ["-p", String(pid), "-o", "command="]);
|
|
897
|
+
if (result.ok) return result.output.trim() || null;
|
|
898
|
+
// ps 는 프로세스가 없을 때 status 1 로 끝난다. PowerShell 실패는 확인 불가다.
|
|
899
|
+
return platform !== "win32" && result.status === 1 ? null : undefined;
|
|
900
|
+
}
|
|
901
|
+
|
|
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;
|
|
907
|
+
try {
|
|
908
|
+
pid = Number(JSON.parse(readFileSync(target, "utf8")).pid);
|
|
909
|
+
} catch {
|
|
910
|
+
/* 아래에서 보존 처리 */
|
|
911
|
+
}
|
|
912
|
+
if (!Number.isInteger(pid) || pid <= 1) {
|
|
913
|
+
result.warnings.push(`${pidFile}: pid 를 읽을 수 없어 보존`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const command = hubCommandLine(pid, platform, run);
|
|
917
|
+
if (command === undefined) {
|
|
918
|
+
result.warnings.push(`${pidFile}: pid ${pid} 확인 실패, 종료하지 않음`);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (command !== null) {
|
|
922
|
+
if (!/[/\\]hub[/\\]server\.mjs(?:["'\s]|$)/.test(command)) {
|
|
923
|
+
// 허브가 아닌 프로세스는 건드리지 않고 우리 파일만 지운다.
|
|
924
|
+
unlinkSync(target);
|
|
925
|
+
result.changed = true;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const stopped =
|
|
929
|
+
platform === "win32"
|
|
930
|
+
? tryRun(run, "taskkill", ["/F", "/T", "/PID", String(pid)])
|
|
931
|
+
: tryRun(run, "kill", ["-TERM", String(pid)]);
|
|
932
|
+
let gone = false;
|
|
933
|
+
for (let attempt = 0; stopped.ok && attempt < 10 && !gone; attempt++) {
|
|
934
|
+
gone = hubCommandLine(pid, platform, run) === null;
|
|
935
|
+
if (!gone)
|
|
936
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
937
|
+
}
|
|
938
|
+
if (!gone) {
|
|
939
|
+
result.warnings.push(`${pidFile}: pid ${pid} 종료 확인 실패, 파일 보존`);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
result.hubStopped = true;
|
|
943
|
+
}
|
|
944
|
+
unlinkSync(target);
|
|
945
|
+
result.changed = true;
|
|
946
|
+
}
|
|
947
|
+
|
|
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;
|
|
961
|
+
}
|
|
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
|
+
}
|
|
971
|
+
|
|
972
|
+
/** cwd 의 프로젝트 MCP 파일 중 제거된 허브가 만든 tfx-hub 항목이 있는 파일을 돌려준다. */
|
|
973
|
+
export function findProjectHubEntries(cwd = process.cwd()) {
|
|
974
|
+
return [".mcp.json", ".claude/mcp.json"].flatMap((relative) => {
|
|
975
|
+
const file = join(cwd, relative);
|
|
976
|
+
try {
|
|
977
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
978
|
+
return isHubEntry(data?.mcpServers?.[HUB_SERVER]) ? [file] : [];
|
|
979
|
+
} catch {
|
|
980
|
+
return [];
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** 제거된 허브의 설정 항목, 실행 중인 프로세스, 예약 작업, 설치본 스냅샷(gemini, codex)을 정리한다. */
|
|
986
|
+
export function cleanupTfxHub({
|
|
987
|
+
home = homedir(),
|
|
988
|
+
platform = osPlatform(),
|
|
989
|
+
run = defaultRun,
|
|
990
|
+
pluginRoot,
|
|
991
|
+
} = {}) {
|
|
992
|
+
const result = {
|
|
993
|
+
ok: true,
|
|
994
|
+
changed: false,
|
|
995
|
+
removed: 0,
|
|
996
|
+
backups: [],
|
|
997
|
+
warnings: [],
|
|
998
|
+
hubStopped: false,
|
|
999
|
+
};
|
|
1000
|
+
const injected = run !== defaultRun;
|
|
1001
|
+
// 테스트에서는 실제 홈과 시스템 명령을 건드리지 않는다.
|
|
1002
|
+
if (
|
|
1003
|
+
!injected &&
|
|
1004
|
+
(process.env.NODE_TEST_CONTEXT || process.env.TRIFLUX_TEST_HOME)
|
|
1005
|
+
) {
|
|
1006
|
+
result.skipped = true;
|
|
1007
|
+
return result;
|
|
1008
|
+
}
|
|
1009
|
+
for (const [relative, kind] of HUB_CONFIG_FILES) {
|
|
1010
|
+
const file = join(home, relative);
|
|
1011
|
+
try {
|
|
1012
|
+
const target = fileTarget(file);
|
|
1013
|
+
if (!target) continue;
|
|
1014
|
+
const original = readFileSync(target, "utf8");
|
|
1015
|
+
const plan =
|
|
1016
|
+
kind === "toml"
|
|
1017
|
+
? removeHubFromToml(original, file, result.warnings)
|
|
1018
|
+
: removeHubFromJson(original, file, result.warnings);
|
|
1019
|
+
if (!plan.count) continue;
|
|
1020
|
+
if (
|
|
1021
|
+
fileTarget(file) !== target ||
|
|
1022
|
+
readFileSync(target, "utf8") !== original
|
|
1023
|
+
) {
|
|
1024
|
+
result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
result.backups.push(writeAtomic(file, target, plan.output));
|
|
1028
|
+
result.removed += plan.count;
|
|
1029
|
+
result.changed = true;
|
|
1030
|
+
} catch {
|
|
1031
|
+
result.warnings.push(`${file}: 설정 형식 또는 읽기 실패`);
|
|
1032
|
+
result.ok = false;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const realSystem =
|
|
1036
|
+
injected ||
|
|
1037
|
+
(resolve(home) === resolve(homedir()) && platform === osPlatform());
|
|
1038
|
+
if (realSystem) {
|
|
1039
|
+
try {
|
|
1040
|
+
stopHub(home, platform, run, result);
|
|
1041
|
+
if (platform === "win32") removeHubTask(run, result);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
result.ok = false;
|
|
1044
|
+
result.warnings.push(`허브 프로세스 정리 실패: ${error.message}`);
|
|
1045
|
+
}
|
|
1046
|
+
} else {
|
|
1047
|
+
result.warnings.push("격리 HOME: 허브 프로세스와 예약 작업 정리는 건너뜀");
|
|
1048
|
+
}
|
|
1049
|
+
for (const name of pluginRoot
|
|
1050
|
+
? ["gemini-snapshots", "codex-snapshots"]
|
|
1051
|
+
: []) {
|
|
1052
|
+
const snapshots = join(pluginRoot, "references", name);
|
|
1053
|
+
// EBUSY, EPERM, 읽기 전용 설치 디렉터리에서도 setup 이 멈추지 않게 한다.
|
|
1054
|
+
try {
|
|
1055
|
+
if (lstatSync(snapshots, { throwIfNoEntry: false })?.isDirectory()) {
|
|
1056
|
+
rmSync(snapshots, { recursive: true, force: true });
|
|
1057
|
+
result.changed = true;
|
|
1058
|
+
}
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
result.warnings.push(
|
|
1061
|
+
`${snapshots}: 삭제 실패 (${error.code ?? error.message})`,
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return result;
|
|
1066
|
+
}
|
|
@@ -19,35 +19,23 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
|
|
20
20
|
const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
21
21
|
const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
|
|
22
|
-
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
23
|
-
const DEFAULT_HUB_PATH = "/mcp";
|
|
24
22
|
const SERVER_POLICIES = new Set(["hosted", "stdio"]);
|
|
25
23
|
const DEFAULT_REGISTRY = Object.freeze({
|
|
26
24
|
$schema: "mcp-registry-schema",
|
|
27
25
|
version: 1,
|
|
28
26
|
description: "MCP 서버 중앙 레지스트리 — 진실의 원천",
|
|
29
27
|
defaults: {
|
|
30
|
-
transport: "
|
|
31
|
-
hub_base: "http://127.0.0.1:27888",
|
|
28
|
+
transport: "http",
|
|
32
29
|
},
|
|
33
30
|
policy_notes: {
|
|
34
31
|
transport:
|
|
35
|
-
'Server transport accepts "
|
|
32
|
+
'Server transport accepts "http" for direct Streamable HTTP MCP endpoints, or "stdio" for upstream-stdio-only MCP servers (rare; only for servers that have no hosted HTTP endpoint, e.g. brave-search). stdio servers require command, args, and may include env.',
|
|
36
33
|
headers:
|
|
37
34
|
'Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {"value":"literal"} for non-secret static values, {"env":"ENV_VAR_NAME"} for secrets resolved at sync/runtime, or {"env":"ENV_VAR_NAME","prefix":"Bearer "} for common authorization formats.',
|
|
38
35
|
secret_safety:
|
|
39
36
|
"Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
|
|
40
37
|
},
|
|
41
|
-
servers: {
|
|
42
|
-
"tfx-hub": {
|
|
43
|
-
policy: "hosted",
|
|
44
|
-
transport: "hub-url",
|
|
45
|
-
url: "http://127.0.0.1:27888/mcp",
|
|
46
|
-
safe: true,
|
|
47
|
-
targets: ["claude", "gemini", "codex", "antigravity"],
|
|
48
|
-
description: "triflux Hub MCP 서버",
|
|
49
|
-
},
|
|
50
|
-
},
|
|
38
|
+
servers: {},
|
|
51
39
|
policies: {
|
|
52
40
|
unknown_server_action: "warn",
|
|
53
41
|
sync_denylist: [],
|
|
@@ -711,28 +699,6 @@ function upsertTomlServer(raw, name, config, codex = {}) {
|
|
|
711
699
|
return output.join("\n");
|
|
712
700
|
}
|
|
713
701
|
|
|
714
|
-
function getHubServerEntry(registry) {
|
|
715
|
-
const entries = Object.entries(registry?.servers || {});
|
|
716
|
-
if (entries.length === 0) {
|
|
717
|
-
return [
|
|
718
|
-
"tfx-hub",
|
|
719
|
-
{
|
|
720
|
-
url: `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`,
|
|
721
|
-
},
|
|
722
|
-
];
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return (
|
|
726
|
-
entries.find(([name]) => name === "tfx-hub") ||
|
|
727
|
-
entries.find(([, config]) => config?.transport === "hub-url") ||
|
|
728
|
-
entries[0]
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function _makeHubRuntimeConfig() {
|
|
733
|
-
return { url: resolveHubUrl() };
|
|
734
|
-
}
|
|
735
|
-
|
|
736
702
|
function serverTargets(serverConfig) {
|
|
737
703
|
if (Array.isArray(serverConfig?.targets) && serverConfig.targets.length > 0) {
|
|
738
704
|
return [
|
|
@@ -806,10 +772,7 @@ export function buildDesiredServerRecord(name, serverConfig, filePath) {
|
|
|
806
772
|
};
|
|
807
773
|
}
|
|
808
774
|
|
|
809
|
-
const url =
|
|
810
|
-
serverConfig?.transport === "hub-url"
|
|
811
|
-
? resolveHubUrl()
|
|
812
|
-
: normalizeUrl(serverConfig?.url || "");
|
|
775
|
+
const url = normalizeUrl(serverConfig?.url || "");
|
|
813
776
|
const basenameValue = pathBasename(filePath);
|
|
814
777
|
const resolvedHeaders = resolveHeaderDescriptors(
|
|
815
778
|
name,
|
|
@@ -1205,10 +1168,8 @@ export function validateRegistry(registry) {
|
|
|
1205
1168
|
errors.push(`registry.servers.${name}.policy must be hosted or stdio`);
|
|
1206
1169
|
}
|
|
1207
1170
|
const transport = server.transport || registry.defaults?.transport;
|
|
1208
|
-
if (!["
|
|
1209
|
-
errors.push(
|
|
1210
|
-
`registry.servers.${name}.transport must be hub-url, http, or stdio`,
|
|
1211
|
-
);
|
|
1171
|
+
if (!["http", "stdio"].includes(transport)) {
|
|
1172
|
+
errors.push(`registry.servers.${name}.transport must be http or stdio`);
|
|
1212
1173
|
}
|
|
1213
1174
|
if (
|
|
1214
1175
|
policy === "hosted" &&
|
|
@@ -1407,6 +1368,38 @@ export function validateRegistry(registry) {
|
|
|
1407
1368
|
return errors;
|
|
1408
1369
|
}
|
|
1409
1370
|
|
|
1371
|
+
const warnedLegacyHub = new Set();
|
|
1372
|
+
|
|
1373
|
+
// 허브가 있던 시기의 사용자 레지스트리는 hub-url 항목을 걸러내고 기본 transport 를 http 로 읽는다.
|
|
1374
|
+
function dropLegacyHub(registry) {
|
|
1375
|
+
if (!registry || typeof registry !== "object" || Array.isArray(registry))
|
|
1376
|
+
return registry;
|
|
1377
|
+
const next = { ...registry };
|
|
1378
|
+
if (next.defaults?.transport === "hub-url") {
|
|
1379
|
+
const { hub_base: _hubBase, ...defaults } = next.defaults;
|
|
1380
|
+
next.defaults = { ...defaults, transport: "http" };
|
|
1381
|
+
}
|
|
1382
|
+
if (
|
|
1383
|
+
next.servers &&
|
|
1384
|
+
typeof next.servers === "object" &&
|
|
1385
|
+
!Array.isArray(next.servers)
|
|
1386
|
+
) {
|
|
1387
|
+
next.servers = Object.fromEntries(
|
|
1388
|
+
Object.entries(next.servers).filter(([name, server]) => {
|
|
1389
|
+
if (server?.transport !== "hub-url") return true;
|
|
1390
|
+
if (!warnedLegacyHub.has(name)) {
|
|
1391
|
+
warnedLegacyHub.add(name);
|
|
1392
|
+
process.stderr.write(
|
|
1393
|
+
`[mcp-guard] 제거된 hub-url 서버 ${name} 항목을 무시합니다\n`,
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
return false;
|
|
1397
|
+
}),
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
return next;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1410
1403
|
export function inspectRegistry() {
|
|
1411
1404
|
if (!existsSync(registryPath())) {
|
|
1412
1405
|
return {
|
|
@@ -1419,7 +1412,7 @@ export function inspectRegistry() {
|
|
|
1419
1412
|
}
|
|
1420
1413
|
|
|
1421
1414
|
try {
|
|
1422
|
-
const registry = readJsonFile(registryPath());
|
|
1415
|
+
const registry = dropLegacyHub(readJsonFile(registryPath()));
|
|
1423
1416
|
const errors = validateRegistry(registry);
|
|
1424
1417
|
return {
|
|
1425
1418
|
path: registryPath(),
|
|
@@ -1669,56 +1662,6 @@ export function scanForStdioServers(filePath) {
|
|
|
1669
1662
|
return scanConfig(filePath).stdioServers;
|
|
1670
1663
|
}
|
|
1671
1664
|
|
|
1672
|
-
export function resolveHubUrl() {
|
|
1673
|
-
const registryState = inspectRegistry();
|
|
1674
|
-
const registry = registryState.valid
|
|
1675
|
-
? registryState.registry
|
|
1676
|
-
: cloneDefaultRegistry();
|
|
1677
|
-
const [, hubServer] = getHubServerEntry(registry);
|
|
1678
|
-
const fallbackRaw =
|
|
1679
|
-
hubServer?.url ||
|
|
1680
|
-
`${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`;
|
|
1681
|
-
|
|
1682
|
-
let fallback;
|
|
1683
|
-
try {
|
|
1684
|
-
fallback = new URL(fallbackRaw);
|
|
1685
|
-
} catch {
|
|
1686
|
-
fallback = new URL(`http://127.0.0.1:27888${DEFAULT_HUB_PATH}`);
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
1690
|
-
const envPort =
|
|
1691
|
-
Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
1692
|
-
const target = {
|
|
1693
|
-
protocol: fallback.protocol || "http:",
|
|
1694
|
-
host: fallback.hostname || "127.0.0.1",
|
|
1695
|
-
port: envPort || Number(fallback.port || 27888),
|
|
1696
|
-
pathname:
|
|
1697
|
-
fallback.pathname && fallback.pathname !== "/"
|
|
1698
|
-
? fallback.pathname
|
|
1699
|
-
: DEFAULT_HUB_PATH,
|
|
1700
|
-
};
|
|
1701
|
-
|
|
1702
|
-
// PR #158 정책: port = TFX_HUB_PORT env (없으면 registry/default 27888) single source.
|
|
1703
|
-
// hub.pid 는 loopback host 힌트 전용. 과거 pid port cascade 는 오염된 port 영속화의
|
|
1704
|
-
// 원인이었고 hub-ensure.resolveHubTarget 에서 이미 제거됨. 여기도 일관 적용.
|
|
1705
|
-
const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
1706
|
-
if (existsSync(hubPidPath)) {
|
|
1707
|
-
try {
|
|
1708
|
-
const info = readJsonFile(hubPidPath);
|
|
1709
|
-
if (typeof info?.host === "string") {
|
|
1710
|
-
const host = info.host.trim();
|
|
1711
|
-
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
1712
|
-
}
|
|
1713
|
-
} catch {
|
|
1714
|
-
// pid 파일 파싱 실패 시 registry 기본값 사용
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
const hostPart = target.host.includes(":") ? `[${target.host}]` : target.host;
|
|
1719
|
-
return `${target.protocol}//${hostPart}:${target.port}${target.pathname}`;
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
1665
|
export function isWatchedPath(filePath) {
|
|
1723
1666
|
const registryState = inspectRegistry();
|
|
1724
1667
|
const registry = registryState.valid
|
|
@@ -1758,8 +1701,7 @@ export function addRegistryServer(name, url, options = {}) {
|
|
|
1758
1701
|
const registry = registryState.valid
|
|
1759
1702
|
? loadRegistry()
|
|
1760
1703
|
: cloneDefaultRegistry();
|
|
1761
|
-
const transport =
|
|
1762
|
-
options.transport || (trimmedName === "tfx-hub" ? "hub-url" : "http");
|
|
1704
|
+
const transport = options.transport || "http";
|
|
1763
1705
|
const policy = options.policy || (transport === "stdio" ? "stdio" : "hosted");
|
|
1764
1706
|
|
|
1765
1707
|
registry.servers[trimmedName] = {
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
// Codex와 Antigravity 세션 hook이 공유하는 presence 기록 함수.
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { resolveRoleControlSnapshot } from "../../hub/lib/cto-env.mjs";
|
|
5
|
-
import {
|
|
6
|
-
buildSynapseTaskSummary,
|
|
7
|
-
heartbeatSynapseSession,
|
|
8
|
-
registerSynapseSession,
|
|
9
|
-
} from "../../hub/team/synapse-http.mjs";
|
|
10
5
|
|
|
11
6
|
function parseStartPayload(stdinData) {
|
|
12
7
|
try {
|
|
@@ -154,96 +149,3 @@ export function shouldSkipInteractiveRegistration(payload, seams = {}) {
|
|
|
154
149
|
commandUsesClaudePrintMode(command),
|
|
155
150
|
);
|
|
156
151
|
}
|
|
157
|
-
|
|
158
|
-
/** git 조회는 세션 시작을 막지 않도록 비동기로 실행한다. */
|
|
159
|
-
function gitContextAsync(cwd, gitRunner = defaultGitRunner) {
|
|
160
|
-
const run = (args) =>
|
|
161
|
-
new Promise((resolve) => {
|
|
162
|
-
try {
|
|
163
|
-
gitRunner(cwd, args, (out) => resolve(out));
|
|
164
|
-
} catch {
|
|
165
|
-
resolve("");
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
return Promise.all([
|
|
169
|
-
run(["rev-parse", "--show-toplevel"]),
|
|
170
|
-
run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
171
|
-
]).then(([toplevel, branch]) => ({
|
|
172
|
-
worktreePath: toplevel || cwd,
|
|
173
|
-
branch,
|
|
174
|
-
}));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** git 조회 실패는 빈 문자열로 처리한다. */
|
|
178
|
-
function defaultGitRunner(cwd, args, cb) {
|
|
179
|
-
execFile(
|
|
180
|
-
"git",
|
|
181
|
-
args,
|
|
182
|
-
{
|
|
183
|
-
cwd,
|
|
184
|
-
encoding: "utf8",
|
|
185
|
-
timeout: 1500,
|
|
186
|
-
killSignal: "SIGKILL",
|
|
187
|
-
windowsHide: true,
|
|
188
|
-
},
|
|
189
|
-
(err, stdout) => {
|
|
190
|
-
cb(err ? "" : String(stdout || "").trim());
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** 세션을 즉시 등록하고 git 정보는 뒤이어 갱신한다. 훅 PID는 세션 PID가 아니다. */
|
|
196
|
-
export function registerInteractiveSession(stdinData, seams = {}) {
|
|
197
|
-
let ctoEvent = null;
|
|
198
|
-
const register = seams.register || registerSynapseSession;
|
|
199
|
-
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
200
|
-
const gitRunner = seams.gitRunner || defaultGitRunner;
|
|
201
|
-
try {
|
|
202
|
-
const payload = parseStartPayload(stdinData);
|
|
203
|
-
const sessionId = String(payload?.session_id || "").trim();
|
|
204
|
-
if (!sessionId) return null;
|
|
205
|
-
if (shouldSkipInteractiveRegistration(payload, seams)) return null;
|
|
206
|
-
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
207
|
-
|
|
208
|
-
register({
|
|
209
|
-
sessionId,
|
|
210
|
-
cwd,
|
|
211
|
-
worktreePath: cwd,
|
|
212
|
-
branch: "",
|
|
213
|
-
host: "local",
|
|
214
|
-
sessionKind: "interactive",
|
|
215
|
-
isRemote: false,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
ctoEvent = emitParticipantSessionStarted(stdinData, seams).catch(
|
|
219
|
-
() => null,
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
gitContextAsync(cwd, gitRunner)
|
|
223
|
-
.then(({ worktreePath, branch }) => {
|
|
224
|
-
if (worktreePath === cwd && !branch) return;
|
|
225
|
-
heartbeat(sessionId, { worktreePath, branch });
|
|
226
|
-
})
|
|
227
|
-
.catch(() => {});
|
|
228
|
-
} catch {
|
|
229
|
-
/* 세션 시작은 계속한다. */
|
|
230
|
-
}
|
|
231
|
-
return ctoEvent;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** 사용자 활동을 갱신한다. 호출자는 종료 전에 전송을 완료해야 한다. */
|
|
235
|
-
export function heartbeatInteractiveSession(stdinData, seams = {}) {
|
|
236
|
-
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
237
|
-
try {
|
|
238
|
-
const payload = parseStartPayload(stdinData);
|
|
239
|
-
const sessionId = String(payload?.session_id || "").trim();
|
|
240
|
-
if (!sessionId) return;
|
|
241
|
-
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
242
|
-
const partial = { worktreePath: cwd, host: "local" };
|
|
243
|
-
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
244
|
-
if (prompt) partial.taskSummary = buildSynapseTaskSummary(prompt);
|
|
245
|
-
heartbeat(sessionId, partial);
|
|
246
|
-
} catch {
|
|
247
|
-
/* 프롬프트 처리는 계속한다. */
|
|
248
|
-
}
|
|
249
|
-
}
|