@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
@@ -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
- let allowedServers = resolveAllowedServers(resolvedOptions);
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 = new Set([
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
- writeFileSync(resolvedPath, finalRaw, "utf8");
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
- const actualHeaders = actual?.headerDescriptors || {};
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 };