@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.
- package/hub/team/backend.mjs +10 -3
- package/hub/team/cli/commands/start/index.mjs +0 -2
- package/hub/team/handoff.mjs +1 -1
- package/hub/team/headless.mjs +41 -57
- package/hub/team/intervention.mjs +6 -4
- package/hub/team/nativeProxy.mjs +2 -48
- package/hub/team/pane.mjs +31 -18
- package/hub/team/psmux.mjs +36 -20
- package/hub/team/remote-session.mjs +66 -56
- package/hub/team/retry-state-machine.mjs +1 -1
- package/hub/team/session.mjs +80 -59
- package/hub/team/terminal-opener.mjs +5 -19
- package/hub/team/tui-viewer.mjs +2 -2
- package/hub/team/tui.mjs +0 -6
- package/hub/workers/factory.mjs +1 -10
- package/hub/workers/interface.mjs +0 -7
- package/package.json +2 -6
- package/scripts/lib/backup-rotation.mjs +60 -0
- package/scripts/lib/codex-profile-config.mjs +20 -7
- package/scripts/lib/codex-recovery.sh +2 -3
- package/scripts/lib/install-blob-history.json +3196 -0
- package/scripts/lib/install-retire.mjs +203 -0
- package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
- package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
- package/scripts/lib/machine-profile.mjs +2 -2
- package/scripts/lib/mcp-filter.mjs +1 -42
- package/scripts/lib/mcp-guard-engine.mjs +198 -19
- package/scripts/lib/mcp-manifest.mjs +0 -40
- package/scripts/lib/process-utils.mjs +19 -0
- package/scripts/lib/stable-node.mjs +35 -0
- package/scripts/lib/test-env.mjs +20 -0
- package/cto/brief.mjs +0 -73
- package/cto/collect.mjs +0 -490
- package/cto/current.schema.json +0 -148
- package/cto/events.mjs +0 -208
- package/cto/hygiene.mjs +0 -320
- package/cto/lake-root.mjs +0 -127
- package/cto/status.mjs +0 -124
- package/hub/lib/cto-env.mjs +0 -1
- package/hub/team/notify.mjs +0 -385
- package/hub/team/process-cleanup.mjs +0 -386
- package/hub/team/routing.mjs +0 -249
- package/hub/team/staleState.mjs +0 -403
- package/hub/team/worker-signal.mjs +0 -263
- package/hub/team/worker-signal.types.d.ts +0 -52
- package/hub/workers/codex-mcp.mjs +0 -853
- package/scripts/lib/remote-spawn-transfer.mjs +0 -226
|
@@ -137,31 +137,6 @@ const PROFILE_DEFINITIONS = Object.freeze({
|
|
|
137
137
|
}),
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
/**
|
|
141
|
-
* 파이프라인 단계별 MCP 서버/도구 제한 (post-filter).
|
|
142
|
-
* role-based 프로필 위에 추가 적용. 빈 배열 = 전체 차단, 미정의 = 제한 없음.
|
|
143
|
-
*/
|
|
144
|
-
const PHASE_OVERRIDES = Object.freeze({
|
|
145
|
-
plan: Object.freeze({
|
|
146
|
-
description: "계획 단계: 읽기 전용 탐색만 허용",
|
|
147
|
-
allowedServers: Object.freeze(["context7"]),
|
|
148
|
-
blockedServers: Object.freeze(["playwright", "tavily", "exa"]),
|
|
149
|
-
}),
|
|
150
|
-
prd: Object.freeze({
|
|
151
|
-
description: "PRD 단계: 읽기 전용 탐색 + 문서 조회",
|
|
152
|
-
allowedServers: Object.freeze(["context7", "brave-search"]),
|
|
153
|
-
blockedServers: Object.freeze(["playwright"]),
|
|
154
|
-
}),
|
|
155
|
-
exec: Object.freeze({
|
|
156
|
-
description: "실행 단계: 프로필 기반 전체 허용 (제한 없음)",
|
|
157
|
-
}),
|
|
158
|
-
verify: Object.freeze({
|
|
159
|
-
description: "검증 단계: 읽기 전용 + 분석 도구",
|
|
160
|
-
allowedServers: Object.freeze(["context7", "brave-search", "exa"]),
|
|
161
|
-
blockedServers: Object.freeze(["playwright"]),
|
|
162
|
-
}),
|
|
163
|
-
});
|
|
164
|
-
|
|
165
140
|
const LEGACY_PROFILE_ALIASES = Object.freeze({
|
|
166
141
|
implement: "executor",
|
|
167
142
|
analyze: "analyze",
|
|
@@ -762,17 +737,9 @@ export function buildMcpPolicy(options = {}) {
|
|
|
762
737
|
options.agentType,
|
|
763
738
|
options.requestedProfile,
|
|
764
739
|
);
|
|
765
|
-
|
|
740
|
+
const allowedServers = resolveAllowedServers(resolvedOptions);
|
|
766
741
|
const hint = buildPromptHint(resolvedOptions);
|
|
767
742
|
|
|
768
|
-
// Phase-aware post-filter: 파이프라인 단계별 서버 제한 적용
|
|
769
|
-
const phase = options.phase;
|
|
770
|
-
const phaseOverride = phase && PHASE_OVERRIDES[phase];
|
|
771
|
-
if (phaseOverride?.blockedServers) {
|
|
772
|
-
const blocked = new Set(phaseOverride.blockedServers);
|
|
773
|
-
allowedServers = allowedServers.filter((s) => !blocked.has(s));
|
|
774
|
-
}
|
|
775
|
-
|
|
776
743
|
// 플러그인 서버는 transport 설정이 없어 CLI override를 받으면 부팅이 실패한다.
|
|
777
744
|
const codexOptions = {
|
|
778
745
|
...resolvedOptions,
|
|
@@ -785,7 +752,6 @@ export function buildMcpPolicy(options = {}) {
|
|
|
785
752
|
? options.requestedProfile
|
|
786
753
|
: "auto",
|
|
787
754
|
resolvedProfile,
|
|
788
|
-
resolvedPhase: phase || null,
|
|
789
755
|
allowedServers,
|
|
790
756
|
hint,
|
|
791
757
|
codexConfig: getCodexMcpConfig(codexOptions),
|
|
@@ -810,7 +776,6 @@ function toDelimited(policy) {
|
|
|
810
776
|
policy.allowedServers.join(","),
|
|
811
777
|
policy.codexConfigOverrides.flatMap((o) => ["-c", o]).join("\x1f"),
|
|
812
778
|
JSON.stringify(policy.codexConfig),
|
|
813
|
-
policy.resolvedPhase || "",
|
|
814
779
|
].join(RS);
|
|
815
780
|
}
|
|
816
781
|
|
|
@@ -826,9 +791,6 @@ function toShellExports(policy) {
|
|
|
826
791
|
),
|
|
827
792
|
`CODEX_CONFIG_JSON=${shellEscape(JSON.stringify(policy.codexConfig))}`,
|
|
828
793
|
];
|
|
829
|
-
if (policy.resolvedPhase) {
|
|
830
|
-
lines.push(`MCP_PIPELINE_PHASE=${shellEscape(policy.resolvedPhase)}`);
|
|
831
|
-
}
|
|
832
794
|
return lines.join("\n");
|
|
833
795
|
}
|
|
834
796
|
|
|
@@ -889,9 +851,6 @@ function parseCliArgs(argv) {
|
|
|
889
851
|
case "--worker-index":
|
|
890
852
|
args.workerIndex = Number.parseInt(next(), 10);
|
|
891
853
|
break;
|
|
892
|
-
case "--phase":
|
|
893
|
-
args.phase = next();
|
|
894
|
-
break;
|
|
895
854
|
default:
|
|
896
855
|
throw new Error(`알 수 없는 옵션: ${token}`);
|
|
897
856
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
readFileSync,
|
|
7
7
|
writeFileSync,
|
|
8
8
|
} from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import { homedir } from "node:os";
|
|
10
11
|
import {
|
|
11
12
|
basename,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
resolve,
|
|
17
18
|
} from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { isDeepStrictEqual } from "node:util";
|
|
19
21
|
|
|
20
22
|
const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
21
23
|
const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
|
|
@@ -155,6 +157,10 @@ function isMigratedAntigravityConfig(filePath, raw = null) {
|
|
|
155
157
|
return String(contents || "").trim() === "";
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
function isProjectMcpJson(filePath) {
|
|
161
|
+
return /(^|\/)\.mcp\.json$/.test(normalizeForMatch(filePath));
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
function plaintextSecretHeaderClient(filePath) {
|
|
159
165
|
const client = detectClient(filePath);
|
|
160
166
|
return client === "gemini" || client === "antigravity" ? client : null;
|
|
@@ -214,8 +220,6 @@ function detectLabel(filePath) {
|
|
|
214
220
|
if (normalized.endsWith("/.gemini/config/mcp_config.json"))
|
|
215
221
|
return "Antigravity";
|
|
216
222
|
if (normalized.endsWith("/.gemini/settings.json")) return "Gemini";
|
|
217
|
-
if (normalized.endsWith("/.gemini/config/mcp_config.json"))
|
|
218
|
-
return "Antigravity";
|
|
219
223
|
if (normalized.endsWith("/.codex/config.toml")) return "Codex";
|
|
220
224
|
if (normalized.endsWith("/.claude.json")) return "Claude User MCP";
|
|
221
225
|
if (normalized.endsWith("/.claude/settings.json")) return "Claude User";
|
|
@@ -484,6 +488,12 @@ function resolveHeaderDescriptors(name, serverConfig, filePath) {
|
|
|
484
488
|
continue;
|
|
485
489
|
}
|
|
486
490
|
|
|
491
|
+
// 프로젝트 .mcp.json 은 저장소에 커밋될 수 있다. Claude Code 가 실행 시 ${VAR} 를 확장하므로 값 대신 참조를 쓴다.
|
|
492
|
+
if (isProjectMcpJson(filePath)) {
|
|
493
|
+
headers[headerName] = `${prefix}\${${envName}}`;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
|
|
487
497
|
headers[headerName] = `${prefix}${envValue}`;
|
|
488
498
|
|
|
489
499
|
const plaintextClient = plaintextSecretHeaderClient(filePath);
|
|
@@ -624,20 +634,31 @@ function formatTomlArray(values = []) {
|
|
|
624
634
|
return `[${values.map((value) => formatTomlString(value)).join(", ")}]`;
|
|
625
635
|
}
|
|
626
636
|
|
|
637
|
+
// 한 줄에서 열린 [ 와 닫힌 ] 의 차. 문자열과 주석 안의 괄호는 세지 않는다.
|
|
638
|
+
function arrayDepth(text) {
|
|
639
|
+
const code = text
|
|
640
|
+
.replace(/"""[\s\S]*?"""|'''[\s\S]*?'''/g, "")
|
|
641
|
+
.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "")
|
|
642
|
+
.replace(/#.*$/, "");
|
|
643
|
+
return (code.match(/\[/g) || []).length - (code.match(/\]/g) || []).length;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const TOML_MANAGED_KEYS = new Set([
|
|
647
|
+
"url",
|
|
648
|
+
"transport",
|
|
649
|
+
"command",
|
|
650
|
+
"args",
|
|
651
|
+
"bearer_token_env_var",
|
|
652
|
+
"http_headers",
|
|
653
|
+
"env_http_headers",
|
|
654
|
+
"env",
|
|
655
|
+
"env_vars",
|
|
656
|
+
]);
|
|
657
|
+
|
|
627
658
|
function upsertTomlServer(raw, name, config, codex = {}) {
|
|
628
659
|
const lines = String(raw || "").split(/\r?\n/);
|
|
629
660
|
const header = `[mcp_servers.${name}]`;
|
|
630
|
-
const managedKeys =
|
|
631
|
-
"url",
|
|
632
|
-
"transport",
|
|
633
|
-
"command",
|
|
634
|
-
"args",
|
|
635
|
-
"bearer_token_env_var",
|
|
636
|
-
"http_headers",
|
|
637
|
-
"env_http_headers",
|
|
638
|
-
"env",
|
|
639
|
-
"env_vars",
|
|
640
|
-
]);
|
|
661
|
+
const managedKeys = TOML_MANAGED_KEYS;
|
|
641
662
|
const nextManagedLines = [];
|
|
642
663
|
if (typeof config.url === "string") {
|
|
643
664
|
nextManagedLines.push(`url = ${formatTomlString(config.url)}`);
|
|
@@ -688,6 +709,21 @@ function upsertTomlServer(raw, name, config, codex = {}) {
|
|
|
688
709
|
const keyMatch = lines[index].match(/^\s*([A-Za-z0-9_]+)\s*=/);
|
|
689
710
|
if (!keyMatch || !managedKeys.has(keyMatch[1])) {
|
|
690
711
|
preserved.push(lines[index]);
|
|
712
|
+
} else {
|
|
713
|
+
// 여러 줄 배열 값은 닫는 ] 까지 함께 걷어낸다. 남기면 다음 줄들이 고아가 되어 파일이 깨진다.
|
|
714
|
+
// 배열 값일 때만, 다음 키나 표 머리글 앞에서는 멈춘다.
|
|
715
|
+
const value = lines[index].slice(keyMatch[0].length);
|
|
716
|
+
let depth = value.trimStart().startsWith("[") ? arrayDepth(value) : 0;
|
|
717
|
+
while (
|
|
718
|
+
depth > 0 &&
|
|
719
|
+
index + 1 < lines.length &&
|
|
720
|
+
!/^\s*(?:[A-Za-z0-9_-]+\s*=|\[\[?\s*[A-Za-z0-9_"'-])/.test(
|
|
721
|
+
lines[index + 1],
|
|
722
|
+
)
|
|
723
|
+
) {
|
|
724
|
+
index += 1;
|
|
725
|
+
depth += arrayDepth(lines[index]);
|
|
726
|
+
}
|
|
691
727
|
}
|
|
692
728
|
index += 1;
|
|
693
729
|
}
|
|
@@ -743,6 +779,29 @@ function syncDenylistKey(client, serverName) {
|
|
|
743
779
|
.toLowerCase()}`;
|
|
744
780
|
}
|
|
745
781
|
|
|
782
|
+
// 범위 이름(@scope/name)의 첫 @ 는 이름에 속한다.
|
|
783
|
+
function splitPackageSpec(spec) {
|
|
784
|
+
const match = /^((?:@[^/@\s]+\/)?[^@\s]+)(?:@(\S+))?$/.exec(spec);
|
|
785
|
+
return match ? { name: match[1], version: match[2] ?? null } : null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// 명령과 인자 수가 같고, 다른 자리는 레지스트리의 고정 패키지와 이름만 같을 때 고정 인자를 돌려준다.
|
|
789
|
+
export function pinnedArgs(entry, server) {
|
|
790
|
+
if (entry?.command !== server.command || !Array.isArray(entry.args))
|
|
791
|
+
return null;
|
|
792
|
+
if (entry.args.length !== server.args.length) return null;
|
|
793
|
+
let changed = false;
|
|
794
|
+
for (const [index, want] of server.args.entries()) {
|
|
795
|
+
const have = entry.args[index];
|
|
796
|
+
if (have === want) continue;
|
|
797
|
+
const pin = splitPackageSpec(want);
|
|
798
|
+
if (!pin?.version || typeof have !== "string") return null;
|
|
799
|
+
if (splitPackageSpec(have)?.name !== pin.name) return null;
|
|
800
|
+
changed = true;
|
|
801
|
+
}
|
|
802
|
+
return changed ? [...server.args] : null;
|
|
803
|
+
}
|
|
804
|
+
|
|
746
805
|
export function buildDesiredServerRecord(name, serverConfig, filePath) {
|
|
747
806
|
const policy = serverPolicy(serverConfig);
|
|
748
807
|
if (policy === "stdio") {
|
|
@@ -1099,9 +1158,31 @@ function updateCodexConfig(filePath, updates = [], removals = []) {
|
|
|
1099
1158
|
if (finalRaw === previousRaw) {
|
|
1100
1159
|
return { modified: false, filePath: resolvedPath };
|
|
1101
1160
|
}
|
|
1161
|
+
// 줄 단위 치환이 파일을 깨뜨리거나 관리 키 밖의 값을 지우면 쓰지 않는다.
|
|
1162
|
+
// 파서가 원본부터 못 읽는 표기(파서가 모르는 TOML 1.0 문법)는 판정할 수 없어 예전처럼 쓴다.
|
|
1163
|
+
const toml = tomlParser();
|
|
1164
|
+
try {
|
|
1165
|
+
if (
|
|
1166
|
+
toml &&
|
|
1167
|
+
parsesAsToml(toml, previousRaw) &&
|
|
1168
|
+
!isDeepStrictEqual(
|
|
1169
|
+
withoutManagedServers(toml.parse(previousRaw), updates, removals),
|
|
1170
|
+
withoutManagedServers(toml.parse(finalRaw), updates, removals),
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1173
|
+
throw new Error("unmanaged TOML changed");
|
|
1174
|
+
} catch {
|
|
1175
|
+
return {
|
|
1176
|
+
modified: false,
|
|
1177
|
+
filePath: resolvedPath,
|
|
1178
|
+
skipped: true,
|
|
1179
|
+
reason: "toml-rewrite-unsafe",
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1102
1182
|
|
|
1103
1183
|
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
1104
|
-
|
|
1184
|
+
// 헤더 비밀이 들어갈 수 있다. 새 파일은 0600, 기존 파일은 권한이 그대로 유지된다.
|
|
1185
|
+
writeFileSync(resolvedPath, finalRaw, { encoding: "utf8", mode: 0o600 });
|
|
1105
1186
|
return { modified: true, filePath: resolvedPath };
|
|
1106
1187
|
}
|
|
1107
1188
|
|
|
@@ -1121,10 +1202,6 @@ function scanConfig(filePath) {
|
|
|
1121
1202
|
};
|
|
1122
1203
|
}
|
|
1123
1204
|
|
|
1124
|
-
export function getRegistryPath() {
|
|
1125
|
-
return registryPath();
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
1205
|
export function createDefaultRegistry() {
|
|
1129
1206
|
return cloneDefaultRegistry();
|
|
1130
1207
|
}
|
|
@@ -1588,7 +1665,26 @@ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
|
|
|
1588
1665
|
: descriptorsFromJsonConfig(desired.config);
|
|
1589
1666
|
const actual =
|
|
1590
1667
|
config.servers.find((server) => server.name === name) || null;
|
|
1591
|
-
|
|
1668
|
+
// JSON 설정의 기대 헤더는 지금 셸의 env 로 만든다. 키를 export 하지 않은 셸에서는
|
|
1669
|
+
// 기대 헤더가 비어 같은 설정도 mismatch 로 보였다. 그 env 를 가리키는 참조이거나
|
|
1670
|
+
// 확인할 수 없는 평문 값이면 비교에서 빼고, 다른 참조면 불일치로 남긴다.
|
|
1671
|
+
const unverifiableHeader = (headerName, actualDescriptor) => {
|
|
1672
|
+
const expected = desired.headerDescriptors?.[headerName];
|
|
1673
|
+
if (isCodexConfig(config.filePath) || !expected?.env) return false;
|
|
1674
|
+
if (process.env[expected.env]) return false;
|
|
1675
|
+
const value = String(actualDescriptor?.value ?? "");
|
|
1676
|
+
const prefix = expected.prefix || "";
|
|
1677
|
+
return value.includes("${")
|
|
1678
|
+
? value === `${prefix}\${${expected.env}}`
|
|
1679
|
+
: value.startsWith(prefix) &&
|
|
1680
|
+
value.slice(prefix.length).trim() !== "";
|
|
1681
|
+
};
|
|
1682
|
+
const actualHeaders = Object.fromEntries(
|
|
1683
|
+
Object.entries(actual?.headerDescriptors || {}).filter(
|
|
1684
|
+
([headerName, descriptor]) =>
|
|
1685
|
+
!unverifiableHeader(headerName, descriptor),
|
|
1686
|
+
),
|
|
1687
|
+
);
|
|
1592
1688
|
const currentHeaderStatus = headerStatus(expectedHeaders, actualHeaders);
|
|
1593
1689
|
let status = "missing";
|
|
1594
1690
|
|
|
@@ -1623,8 +1719,10 @@ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
|
|
|
1623
1719
|
filePath: config.filePath,
|
|
1624
1720
|
expectedUrl,
|
|
1625
1721
|
expectedCommand,
|
|
1722
|
+
expectedArgs: desired.config.args || [],
|
|
1626
1723
|
actualUrl: actual?.url || "",
|
|
1627
1724
|
actualCommand: actual?.command || "",
|
|
1725
|
+
actualArgs: actual?.args || [],
|
|
1628
1726
|
status,
|
|
1629
1727
|
migrated: Boolean(config.migrated),
|
|
1630
1728
|
message: config.message || "",
|
|
@@ -1804,6 +1902,52 @@ export function removeServerFromTargets(name, options = {}) {
|
|
|
1804
1902
|
return { actions };
|
|
1805
1903
|
}
|
|
1806
1904
|
|
|
1905
|
+
// packages/remote 는 @iarna/toml 을 의존으로 두지 않아 늦게 불러오고, 없으면 null.
|
|
1906
|
+
function tomlParser() {
|
|
1907
|
+
try {
|
|
1908
|
+
return createRequire(import.meta.url)("@iarna/toml");
|
|
1909
|
+
} catch {
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// 갱신한 서버의 관리 키만 뺀다. 남은 값이 원본과 같아야 치환이 사용자 값을 건드리지 않은 것이다.
|
|
1915
|
+
function withoutManagedServers(data, updates, removals) {
|
|
1916
|
+
for (const name of removals) delete data.mcp_servers?.[name];
|
|
1917
|
+
for (const { name } of updates) {
|
|
1918
|
+
const server = data.mcp_servers?.[name];
|
|
1919
|
+
if (!server) continue;
|
|
1920
|
+
for (const key of TOML_MANAGED_KEYS) delete server[key];
|
|
1921
|
+
if (!Object.keys(server).length) delete data.mcp_servers[name];
|
|
1922
|
+
}
|
|
1923
|
+
if (data.mcp_servers && !Object.keys(data.mcp_servers).length)
|
|
1924
|
+
delete data.mcp_servers;
|
|
1925
|
+
return data;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function parsesAsToml(toml, raw) {
|
|
1929
|
+
try {
|
|
1930
|
+
toml.parse(raw);
|
|
1931
|
+
return true;
|
|
1932
|
+
} catch {
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// 간이 TOML 스캐너는 작은따옴표와 줄 끝 주석을 잘못 읽으므로 보호 판정에 쓸 Codex 항목은 실제 파서로 읽는다.
|
|
1938
|
+
function existingServerEntry(filePath, snapshot, name) {
|
|
1939
|
+
const scanned = snapshot.servers.find((server) => server.name === name);
|
|
1940
|
+
const toml = isCodexConfig(filePath) && tomlParser();
|
|
1941
|
+
if (!toml) return scanned;
|
|
1942
|
+
try {
|
|
1943
|
+
const entry = toml.parse(readFileSync(resolveFilePath(filePath), "utf8"))
|
|
1944
|
+
.mcp_servers?.[name];
|
|
1945
|
+
return entry && { ...entry, args: entry.args ?? [] };
|
|
1946
|
+
} catch {
|
|
1947
|
+
return scanned;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1807
1951
|
export function syncRegistryTargets(options = {}) {
|
|
1808
1952
|
const registry = options.registry || loadRegistryOrDefault();
|
|
1809
1953
|
const syncTargets = Array.isArray(options.targets)
|
|
@@ -1908,6 +2052,29 @@ export function syncRegistryTargets(options = {}) {
|
|
|
1908
2052
|
continue;
|
|
1909
2053
|
}
|
|
1910
2054
|
|
|
2055
|
+
// 사용자가 바꾼 stdio 항목은 덮어쓰지 않는다. 같은 패키지의 버전 차이만 고정 버전으로 맞춘다.
|
|
2056
|
+
const existing = existingServerEntry(target.filePath, snapshot, name);
|
|
2057
|
+
if (
|
|
2058
|
+
serverPolicy(serverConfig) === "stdio" &&
|
|
2059
|
+
typeof existing?.command === "string" &&
|
|
2060
|
+
existing.command &&
|
|
2061
|
+
!(
|
|
2062
|
+
existing.command === serverConfig.command &&
|
|
2063
|
+
JSON.stringify(existing.args) === JSON.stringify(serverConfig.args)
|
|
2064
|
+
) &&
|
|
2065
|
+
!pinnedArgs(existing, serverConfig)
|
|
2066
|
+
) {
|
|
2067
|
+
actions.push({
|
|
2068
|
+
type: "sync",
|
|
2069
|
+
filePath: target.filePath,
|
|
2070
|
+
label: target.label,
|
|
2071
|
+
status: "warning",
|
|
2072
|
+
server: name,
|
|
2073
|
+
message: `${name}: 레지스트리와 다른 사용자 항목이라 덮어쓰지 않음 (${[existing.command, ...(existing.args ?? [])].join(" ")})`,
|
|
2074
|
+
});
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1911
2078
|
updates.push(
|
|
1912
2079
|
buildDesiredServerRecord(name, serverConfig, target.filePath),
|
|
1913
2080
|
);
|
|
@@ -1924,6 +2091,18 @@ export function syncRegistryTargets(options = {}) {
|
|
|
1924
2091
|
continue;
|
|
1925
2092
|
}
|
|
1926
2093
|
|
|
2094
|
+
if (result.reason === "toml-rewrite-unsafe") {
|
|
2095
|
+
actions.push({
|
|
2096
|
+
type: "sync",
|
|
2097
|
+
filePath: target.filePath,
|
|
2098
|
+
label: target.label,
|
|
2099
|
+
status: "warning",
|
|
2100
|
+
message:
|
|
2101
|
+
"줄 단위로 고칠 수 없는 TOML 표기(여러 줄 값 등)라 파일을 바꾸지 않음",
|
|
2102
|
+
});
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1927
2106
|
actions.push({
|
|
1928
2107
|
type: "sync",
|
|
1929
2108
|
filePath: target.filePath,
|
|
@@ -16,23 +16,6 @@ export const MANIFEST_PATH = join(
|
|
|
16
16
|
/** API 키 불필요 서버 (현재 없음) */
|
|
17
17
|
export const CORE_SERVERS = Object.freeze([]);
|
|
18
18
|
|
|
19
|
-
/** 검색 MCP — API 키 필요 */
|
|
20
|
-
export const SEARCH_SERVERS = Object.freeze([
|
|
21
|
-
{ name: "brave-search", envVars: ["BRAVE_API_KEY"] },
|
|
22
|
-
{ name: "exa", envVars: ["EXA_API_KEY"] },
|
|
23
|
-
{ name: "tavily", envVars: ["TAVILY_API_KEY"] },
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
/** 통합 MCP — API 키 + 추가 설정 필요 */
|
|
27
|
-
export const INTEGRATION_SERVERS = Object.freeze([
|
|
28
|
-
{
|
|
29
|
-
name: "jira",
|
|
30
|
-
envVars: ["JIRA_API_TOKEN", "JIRA_EMAIL", "JIRA_INSTANCE_URL"],
|
|
31
|
-
},
|
|
32
|
-
{ name: "notion", envVars: ["NOTION_TOKEN"] },
|
|
33
|
-
{ name: "notion-guest", envVars: ["NOTION_TOKEN"] },
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
19
|
export function readManifest() {
|
|
37
20
|
if (!existsSync(MANIFEST_PATH)) return null;
|
|
38
21
|
try {
|
|
@@ -54,21 +37,6 @@ export function writeManifest(enabledServers) {
|
|
|
54
37
|
return manifest;
|
|
55
38
|
}
|
|
56
39
|
|
|
57
|
-
/**
|
|
58
|
-
* 매니페스트 기준으로 활성 서버만 필터링.
|
|
59
|
-
* @param {Array<string|{name:string}>} allServers — 전체 서버 목록
|
|
60
|
-
* @returns {Array} 활성 서버 목록. 매니페스트 미존재 시 null (레거시 모드).
|
|
61
|
-
*/
|
|
62
|
-
export function filterByManifest(allServers) {
|
|
63
|
-
const manifest = readManifest();
|
|
64
|
-
if (!manifest) return null;
|
|
65
|
-
const enabled = new Set(manifest.enabled || []);
|
|
66
|
-
for (const core of CORE_SERVERS) enabled.add(core);
|
|
67
|
-
return allServers.filter((s) =>
|
|
68
|
-
enabled.has(typeof s === "string" ? s : s.name),
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
40
|
/**
|
|
73
41
|
* 단일 서버 활성화 여부 확인.
|
|
74
42
|
* 매니페스트 미존재 시 true (레거시 호환).
|
|
@@ -79,11 +47,3 @@ export function isServerEnabled(serverName) {
|
|
|
79
47
|
if (CORE_SERVERS.includes(serverName)) return true;
|
|
80
48
|
return (manifest.enabled || []).includes(serverName);
|
|
81
49
|
}
|
|
82
|
-
|
|
83
|
-
/** 특정 서버에 필요한 환경변수 중 누락된 것 반환 */
|
|
84
|
-
export function getMissingEnvVars(serverName) {
|
|
85
|
-
const all = [...SEARCH_SERVERS, ...INTEGRATION_SERVERS];
|
|
86
|
-
const entry = all.find((s) => s.name === serverName);
|
|
87
|
-
if (!entry) return [];
|
|
88
|
-
return entry.envVars.filter((k) => !process.env[k]);
|
|
89
|
-
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
// Note: hub/lib/process-utils.mjs에는 더 포괄적인 orphan cleanup 로직이 있습니다.
|
|
5
5
|
// 이 파일은 단순 alive 체크만 필요한 훅/스크립트용 (상위 의존성 없이 동작).
|
|
6
6
|
|
|
7
|
+
import { realpathSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* 주어진 PID의 프로세스가 살아있는지 확인합니다.
|
|
9
13
|
* - EPERM: 프로세스는 존재하지만 signal 권한 없음 → alive
|
|
@@ -24,3 +28,18 @@ export function isProcessAlive(pid) {
|
|
|
24
28
|
return false;
|
|
25
29
|
}
|
|
26
30
|
}
|
|
31
|
+
|
|
32
|
+
// 직접 실행 판정. 문자열로 붙인 file:// 은 공백, 한글 경로에서 어긋나므로 양쪽 실제 경로로 비교한다.
|
|
33
|
+
export function isMainModule(moduleUrl) {
|
|
34
|
+
const entry = process.argv[1];
|
|
35
|
+
if (!entry) return false;
|
|
36
|
+
return realPathOf(entry) === realPathOf(fileURLToPath(moduleUrl));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function realPathOf(path) {
|
|
40
|
+
try {
|
|
41
|
+
return realpathSync(path);
|
|
42
|
+
} catch {
|
|
43
|
+
return resolve(path);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// process.execPath 는 Homebrew 의 bin/node 링크를 따라가 버전이 박힌 Cellar 경로가 된다.
|
|
5
|
+
// 그 경로는 brew upgrade node 뒤 사라지므로, 같은 바이너리를 가리키는 bin/node 별칭이 있으면 그쪽을 쓴다.
|
|
6
|
+
// 그 밖(nvm, volta, 시스템 node, Windows)은 fallback 을 쓴다. 기본은 execPath 이고,
|
|
7
|
+
// 오래 남는 명령(HUD statusLine)은 버전이 박힌 경로 대신 "node" 를 넘겨 PATH 에 맡긴다.
|
|
8
|
+
export function resolveStableNodeBin(execPath = process.execPath, opts = {}) {
|
|
9
|
+
const env = opts.env || process.env;
|
|
10
|
+
const realpath = opts.realpath || realpathSync;
|
|
11
|
+
const fallback = opts.fallback ?? execPath;
|
|
12
|
+
const candidates = [];
|
|
13
|
+
if (env.HOMEBREW_PREFIX)
|
|
14
|
+
candidates.push(join(env.HOMEBREW_PREFIX, "bin", "node"));
|
|
15
|
+
candidates.push(
|
|
16
|
+
"/opt/homebrew/bin/node",
|
|
17
|
+
"/usr/local/bin/node",
|
|
18
|
+
"/home/linuxbrew/.linuxbrew/bin/node",
|
|
19
|
+
);
|
|
20
|
+
let target;
|
|
21
|
+
try {
|
|
22
|
+
target = realpath(execPath);
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
if (candidate === execPath) return execPath;
|
|
28
|
+
try {
|
|
29
|
+
if (realpath(candidate) === target) return candidate;
|
|
30
|
+
} catch {
|
|
31
|
+
// 이 기기에 없는 후보
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// 테스트 실행 신호. node --test 자식과 test-lock 아래에서 켜진다.
|
|
2
|
+
export function isTestRun(env = process.env) {
|
|
3
|
+
return (
|
|
4
|
+
env.NODE_ENV === "test" ||
|
|
5
|
+
env.TFX_TEST === "1" ||
|
|
6
|
+
Boolean(
|
|
7
|
+
env.TEST_LOCK_PID || env.NODE_TEST_CONTEXT || env.NODE_TEST_WORKER_ID,
|
|
8
|
+
)
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 테스트인데 홈을 임시 디렉터리로 돌렸다는 신호가 없으면 실제 홈에 쓰게 된다(#529).
|
|
13
|
+
// TRIFLUX_TEST_HOME 은 테스트가 직접 주고, TFX_TEST_HOME_ISOLATED 는 test-lock 이 준다.
|
|
14
|
+
export function writesRealHomeInTest(env = process.env) {
|
|
15
|
+
return (
|
|
16
|
+
isTestRun(env) &&
|
|
17
|
+
!env.TRIFLUX_TEST_HOME &&
|
|
18
|
+
env.TFX_TEST_HOME_ISOLATED !== "1"
|
|
19
|
+
);
|
|
20
|
+
}
|
package/cto/brief.mjs
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
const BRIEF_VERSION = "cto-lake.v1";
|
|
2
|
-
const MAX_BRIEF_BYTES = 2048;
|
|
3
|
-
const TRUNCATION_MARKER = "... truncated";
|
|
4
|
-
|
|
5
|
-
function oneLine(value, fallback = "n/a") {
|
|
6
|
-
const text =
|
|
7
|
-
typeof value === "string"
|
|
8
|
-
? value
|
|
9
|
-
: value === null || value === undefined
|
|
10
|
-
? fallback
|
|
11
|
-
: JSON.stringify(value);
|
|
12
|
-
return String(text).replace(/\s+/g, " ").trim() || fallback;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function formatGoal(goal) {
|
|
16
|
-
const id = oneLine(goal?.id, "goal");
|
|
17
|
-
const status = oneLine(goal?.status, "unknown");
|
|
18
|
-
const title = oneLine(
|
|
19
|
-
goal?.title || goal?.objective || goal?.summary,
|
|
20
|
-
"untitled",
|
|
21
|
-
);
|
|
22
|
-
return `- ${id} ${status}: ${title}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function formatEvent(event) {
|
|
26
|
-
const ts = oneLine(event?.ts, "unknown_ts");
|
|
27
|
-
const name = oneLine(event?.event, "event");
|
|
28
|
-
const summary = oneLine(event?.summary, "");
|
|
29
|
-
return summary ? `- ${ts} ${name}: ${summary}` : `- ${ts} ${name}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function enforceCap(text) {
|
|
33
|
-
if (Buffer.byteLength(text, "utf8") <= MAX_BRIEF_BYTES) return text;
|
|
34
|
-
const marker = `\n${TRUNCATION_MARKER}`;
|
|
35
|
-
let candidate = text;
|
|
36
|
-
while (
|
|
37
|
-
candidate.length > 0 &&
|
|
38
|
-
Buffer.byteLength(`${candidate}${marker}`, "utf8") > MAX_BRIEF_BYTES
|
|
39
|
-
) {
|
|
40
|
-
candidate = candidate.slice(0, -1);
|
|
41
|
-
}
|
|
42
|
-
return `${candidate.replace(/\s+$/u, "")}${marker}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function renderBrief(current) {
|
|
46
|
-
const repo = current?.repo || {};
|
|
47
|
-
const summary = current?.summary || {};
|
|
48
|
-
const activeGoals = Array.isArray(summary.active_goals)
|
|
49
|
-
? summary.active_goals.slice(0, 3)
|
|
50
|
-
: [];
|
|
51
|
-
const recentEvents = Array.isArray(current?.ledger_tail)
|
|
52
|
-
? current.ledger_tail.slice(-2)
|
|
53
|
-
: [];
|
|
54
|
-
|
|
55
|
-
const lines = [
|
|
56
|
-
`brief_version: ${BRIEF_VERSION}`,
|
|
57
|
-
"repo_state",
|
|
58
|
-
`branch: ${oneLine(repo.branch, "unknown")} head: ${oneLine(repo.head, "unknown")} dirty: ${repo.dirty === true}`,
|
|
59
|
-
`summary: ${oneLine(summary.repo_state, "no repo summary")}`,
|
|
60
|
-
"active_goals",
|
|
61
|
-
...(activeGoals.length > 0 ? activeGoals.map(formatGoal) : ["- none"]),
|
|
62
|
-
"recent_events",
|
|
63
|
-
...(recentEvents.length > 0 ? recentEvents.map(formatEvent) : ["- none"]),
|
|
64
|
-
"generated_at",
|
|
65
|
-
oneLine(current?.generated_at, "unknown"),
|
|
66
|
-
"injection_note",
|
|
67
|
-
"read by agent hooks; align to this north-star",
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
return enforceCap(`${lines.join("\n")}\n`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export { BRIEF_VERSION, MAX_BRIEF_BYTES };
|