@triflux/remote 10.1.0 → 10.26.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 (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
@@ -0,0 +1,773 @@
1
+ #!/usr/bin/env node
2
+ // scripts/lib/mcp-health.mjs
3
+ // Dead MCP preflight — session 18 checkpoint의 P3 root-cause fix.
4
+ // Codex config.toml 의 각 mcp_servers.* 정의를 probe 해서 응답 안하면 dead 판정.
5
+ // 결과는 ~/.codex/mcp-health-cache.json 에 TTL 기반으로 캐시.
6
+ // tfx-route.sh 가 swap 전에 이 결과를 읽어 dead 서버를 enabled=false 로 override.
7
+
8
+ import { spawn } from "node:child_process";
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ statSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const DEFAULT_CONFIG_PATH = path.join(homedir(), ".codex", "config.toml");
24
+ const DEFAULT_CACHE_PATH = path.join(
25
+ homedir(),
26
+ ".codex",
27
+ "mcp-health-cache.json",
28
+ );
29
+ const DEFAULT_TTL_MS = 300_000; // 5 min
30
+ const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
31
+
32
+ // ────────── TOML (mcp_servers 한정 단순 파서) ──────────
33
+
34
+ function parseTomlArrayLiteral(literal) {
35
+ const inner = literal.trim().slice(1, -1).trim();
36
+ if (!inner) return [];
37
+ const items = [];
38
+ let i = 0;
39
+ while (i < inner.length) {
40
+ while (i < inner.length && /[\s,]/.test(inner[i])) i++;
41
+ if (i >= inner.length) break;
42
+ const ch = inner[i];
43
+ if (ch === '"' || ch === "'") {
44
+ let j = i + 1;
45
+ while (j < inner.length) {
46
+ if (inner[j] === "\\" && ch === '"') {
47
+ j += 2;
48
+ continue;
49
+ }
50
+ if (inner[j] === ch) break;
51
+ j++;
52
+ }
53
+ if (ch === '"') items.push(JSON.parse(inner.slice(i, j + 1)));
54
+ else items.push(inner.slice(i + 1, j));
55
+ i = j + 1;
56
+ } else {
57
+ let j = i;
58
+ while (j < inner.length && inner[j] !== ",") j++;
59
+ items.push(inner.slice(i, j).trim());
60
+ i = j;
61
+ }
62
+ }
63
+ return items;
64
+ }
65
+
66
+ function parseTomlScalar(raw) {
67
+ const v = raw.replace(/\s+#.*$/, "").trim();
68
+ if (/^".*"$/.test(v)) return JSON.parse(v);
69
+ if (/^'.*'$/.test(v)) return v.slice(1, -1);
70
+ if (v.startsWith("[") && v.endsWith("]")) return parseTomlArrayLiteral(v);
71
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
72
+ if (v === "true") return true;
73
+ if (v === "false") return false;
74
+ return v;
75
+ }
76
+
77
+ // 문자열 리터럴 밖의 `# ...` 은 라인 끝 주석. multiline buffer 에 누적하기
78
+ // 전에 line 단위로 제거해야 parseTomlArrayLiteral 이 scalar 분기에서 주석 문자열을
79
+ // item 으로 잘못 포함하지 않는다.
80
+ function stripInlineComment(line) {
81
+ let inString = false;
82
+ let stringChar = null;
83
+ let escaped = false;
84
+ for (let i = 0; i < line.length; i++) {
85
+ const ch = line[i];
86
+ if (escaped) {
87
+ escaped = false;
88
+ continue;
89
+ }
90
+ if (inString) {
91
+ if (ch === "\\" && stringChar === '"') {
92
+ escaped = true;
93
+ continue;
94
+ }
95
+ if (ch === stringChar) {
96
+ inString = false;
97
+ stringChar = null;
98
+ }
99
+ continue;
100
+ }
101
+ if (ch === '"' || ch === "'") {
102
+ inString = true;
103
+ stringChar = ch;
104
+ continue;
105
+ }
106
+ if (ch === "#") return line.slice(0, i).trimEnd();
107
+ }
108
+ return line;
109
+ }
110
+
111
+ // 문자열 리터럴 내부를 제외한 bracket depth. 음수는 over-close.
112
+ // Review finding A1 (commit 9298fd6 cross-review): 멀티라인 array 값
113
+ // (예: `args = [\n "run",\n "server.js"\n]`) 을 single-line 파서가 `"["`
114
+ // 문자열로 오인해 probeStdio 가 args=[] 로 실행 → 정상 서버를 dead 로 오탐.
115
+ // Inline comment 는 newline 전까지만 무시 — 멀티라인 buffer 에서 첫 `#` 이후
116
+ // 전체가 drop 되면 안 됨.
117
+ function countBracketDelta(str) {
118
+ let depth = 0;
119
+ let inString = false;
120
+ let stringChar = null;
121
+ let escaped = false;
122
+ let inComment = false;
123
+ for (const ch of str) {
124
+ if (escaped) {
125
+ escaped = false;
126
+ continue;
127
+ }
128
+ if (inComment) {
129
+ if (ch === "\n") inComment = false;
130
+ continue;
131
+ }
132
+ if (inString) {
133
+ if (ch === "\\" && stringChar === '"') {
134
+ escaped = true;
135
+ continue;
136
+ }
137
+ if (ch === stringChar) {
138
+ inString = false;
139
+ stringChar = null;
140
+ }
141
+ continue;
142
+ }
143
+ if (ch === '"' || ch === "'") {
144
+ inString = true;
145
+ stringChar = ch;
146
+ continue;
147
+ }
148
+ if (ch === "#") {
149
+ inComment = true;
150
+ continue;
151
+ }
152
+ if (ch === "[") depth++;
153
+ else if (ch === "]") depth--;
154
+ }
155
+ return depth;
156
+ }
157
+
158
+ export function parseMcpServersFromToml(content = "") {
159
+ const servers = {};
160
+ const lines = content.split(/\r?\n/);
161
+ let name = null;
162
+ let scope = null; // "root" | "env"
163
+ let pendingKey = null;
164
+ let pendingBuffer = "";
165
+
166
+ function assign(key, value) {
167
+ if (scope === "env") servers[name].env[key] = String(value);
168
+ else servers[name][key] = value;
169
+ }
170
+
171
+ for (const rawLine of lines) {
172
+ const line = rawLine.replace(/^\uFEFF/, "").trim();
173
+
174
+ if (pendingKey !== null) {
175
+ pendingBuffer += " " + stripInlineComment(line);
176
+ if (countBracketDelta(pendingBuffer) <= 0) {
177
+ assign(pendingKey, parseTomlScalar(pendingBuffer));
178
+ pendingKey = null;
179
+ pendingBuffer = "";
180
+ }
181
+ continue;
182
+ }
183
+
184
+ if (!line || line.startsWith("#")) continue;
185
+
186
+ const rootSection = line.match(/^\[mcp_servers\.([a-zA-Z0-9_.-]+)\]$/);
187
+ const envSection = line.match(/^\[mcp_servers\.([a-zA-Z0-9_.-]+)\.env\]$/);
188
+ const anySection = line.startsWith("[");
189
+
190
+ if (envSection) {
191
+ name = envSection[1];
192
+ scope = "env";
193
+ if (!servers[name]) servers[name] = {};
194
+ if (!servers[name].env) servers[name].env = {};
195
+ continue;
196
+ }
197
+ if (rootSection) {
198
+ name = rootSection[1];
199
+ scope = "root";
200
+ if (!servers[name]) servers[name] = {};
201
+ continue;
202
+ }
203
+ if (anySection) {
204
+ name = null;
205
+ scope = null;
206
+ continue;
207
+ }
208
+ if (!name) continue;
209
+
210
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/);
211
+ if (!kv) continue;
212
+ const [, key, rawValue] = kv;
213
+ const cleanValue = stripInlineComment(rawValue);
214
+
215
+ if (countBracketDelta(cleanValue) > 0) {
216
+ pendingKey = key;
217
+ pendingBuffer = cleanValue;
218
+ continue;
219
+ }
220
+
221
+ assign(key, parseTomlScalar(cleanValue));
222
+ }
223
+
224
+ return servers;
225
+ }
226
+
227
+ export function readMcpServers(configPath = DEFAULT_CONFIG_PATH) {
228
+ if (!existsSync(configPath)) return {};
229
+ try {
230
+ const content = readFileSync(configPath, "utf8");
231
+ return parseMcpServersFromToml(content);
232
+ } catch {
233
+ return {};
234
+ }
235
+ }
236
+
237
+ // ────────── Binary Fingerprint (Issue #149) ──────────
238
+ // Cache staleness 방지: config.toml mtime 만 보면 `npm i -g <mcp-bin>` 설치/제거
239
+ // 를 감지 못해 5분간 stale dead 판정이 유지된다. 서버별 fingerprint (command,
240
+ // args, 해석된 binary path+mtime+size 또는 url) 을 cache 에 저장하고 일치할
241
+ // 때만 hit 으로 판정한다.
242
+
243
+ function statBinary(filePath) {
244
+ try {
245
+ const st = statSync(filePath);
246
+ if (!st.isFile()) return null;
247
+ return { path: filePath, mtime: Math.floor(st.mtimeMs), size: st.size };
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ export function resolveBinaryPath(command) {
254
+ if (!command || typeof command !== "string") return null;
255
+ if (path.isAbsolute(command)) return statBinary(command);
256
+
257
+ const pathEntries = (process.env.PATH || "")
258
+ .split(path.delimiter)
259
+ .filter(Boolean);
260
+ const pathExts =
261
+ process.platform === "win32"
262
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
263
+ .split(";")
264
+ .filter(Boolean)
265
+ : [""];
266
+ // command 에 이미 확장자가 있을 수도 있으니 빈 확장자도 시도.
267
+ const exts = pathExts.includes("") ? pathExts : ["", ...pathExts];
268
+
269
+ for (const dir of pathEntries) {
270
+ for (const ext of exts) {
271
+ const candidate = path.join(dir, command + ext);
272
+ const result = statBinary(candidate);
273
+ if (result) return result;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
279
+ export function computeFingerprint(def) {
280
+ if (!def || typeof def !== "object") return { type: "none" };
281
+ if (typeof def.url === "string" && def.url) {
282
+ return { type: "http", url: def.url };
283
+ }
284
+ if (typeof def.command === "string" && def.command) {
285
+ return {
286
+ type: "stdio",
287
+ command: def.command,
288
+ args: Array.isArray(def.args) ? [...def.args] : [],
289
+ binary: resolveBinaryPath(def.command),
290
+ };
291
+ }
292
+ return { type: "unknown" };
293
+ }
294
+
295
+ export function fingerprintsEqual(a, b) {
296
+ if (!a || !b) return false;
297
+ if (a.type !== b.type) return false;
298
+ if (a.type === "http") return a.url === b.url;
299
+ if (a.type === "stdio") {
300
+ if (a.command !== b.command) return false;
301
+ const ax = Array.isArray(a.args) ? a.args : [];
302
+ const bx = Array.isArray(b.args) ? b.args : [];
303
+ if (ax.length !== bx.length) return false;
304
+ for (let i = 0; i < ax.length; i++) {
305
+ if (ax[i] !== bx[i]) return false;
306
+ }
307
+ if ((a.binary === null) !== (b.binary === null)) return false;
308
+ if (a.binary && b.binary) {
309
+ if (a.binary.path !== b.binary.path) return false;
310
+ if (a.binary.mtime !== b.binary.mtime) return false;
311
+ if (a.binary.size !== b.binary.size) return false;
312
+ }
313
+ return true;
314
+ }
315
+ return true; // "none"/"unknown" — 같은 type 이면 equal
316
+ }
317
+
318
+ // ────────── Probe ──────────
319
+
320
+ function makeInitializeRequest() {
321
+ return (
322
+ JSON.stringify({
323
+ jsonrpc: "2.0",
324
+ id: 1,
325
+ method: "initialize",
326
+ params: {
327
+ protocolVersion: "2024-11-05",
328
+ capabilities: {},
329
+ clientInfo: { name: "tfx-mcp-probe", version: "1.0.0" },
330
+ },
331
+ }) + "\n"
332
+ );
333
+ }
334
+
335
+ function isValidInitResponse(line) {
336
+ const trimmed = line.trim();
337
+ if (!trimmed?.startsWith("{")) return false;
338
+ try {
339
+ const msg = JSON.parse(trimmed);
340
+ if (msg.jsonrpc !== "2.0") return false;
341
+ if (msg.id !== 1 && msg.id !== "1") return false;
342
+ return msg.result !== undefined || msg.error !== undefined;
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+
348
+ export function probeStdio(def, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
349
+ return new Promise((resolve) => {
350
+ const start = Date.now();
351
+ let settled = false;
352
+ let child = null;
353
+
354
+ const done = (result) => {
355
+ if (settled) return;
356
+ settled = true;
357
+ try {
358
+ child?.kill("SIGKILL");
359
+ } catch {
360
+ /* best effort */
361
+ }
362
+ resolve({ ...result, ms: Date.now() - start });
363
+ };
364
+
365
+ const timer = setTimeout(
366
+ () => done({ alive: false, reason: "timeout" }),
367
+ timeoutMs,
368
+ );
369
+
370
+ try {
371
+ child = spawn(def.command, Array.isArray(def.args) ? def.args : [], {
372
+ env: { ...process.env, ...(def.env || {}) },
373
+ stdio: ["pipe", "pipe", "pipe"],
374
+ windowsHide: true,
375
+ });
376
+ } catch (err) {
377
+ clearTimeout(timer);
378
+ done({ alive: false, reason: `spawn:${err.code || err.message}` });
379
+ return;
380
+ }
381
+
382
+ child.on("error", (err) => {
383
+ clearTimeout(timer);
384
+ done({ alive: false, reason: `error:${err.code || err.message}` });
385
+ });
386
+ child.on("exit", (code, signal) => {
387
+ clearTimeout(timer);
388
+ done({
389
+ alive: false,
390
+ reason: signal ? `signal:${signal}` : `exit:${code}`,
391
+ });
392
+ });
393
+
394
+ let buffer = "";
395
+ child.stdout.on("data", (chunk) => {
396
+ buffer += chunk.toString("utf8");
397
+ const lines = buffer.split("\n");
398
+ buffer = lines.pop() ?? "";
399
+ for (const line of lines) {
400
+ if (isValidInitResponse(line)) {
401
+ clearTimeout(timer);
402
+ done({ alive: true });
403
+ return;
404
+ }
405
+ }
406
+ });
407
+ child.stderr.on("data", () => {
408
+ /* drain */
409
+ });
410
+
411
+ try {
412
+ child.stdin.write(makeInitializeRequest(), (err) => {
413
+ if (err) {
414
+ done({ alive: false, reason: `stdin:${err.code || err.message}` });
415
+ return;
416
+ }
417
+ child.stdin.end();
418
+ });
419
+ } catch (err) {
420
+ done({ alive: false, reason: `write:${err.code || err.message}` });
421
+ }
422
+ });
423
+ }
424
+
425
+ // Review finding A2 (commit 9298fd6 cross-review): 기존 구현은 status 2xx-4xx
426
+ // 전부 alive 로 취급 — 404/401 HTML 오류 페이지를 healthy 로 오판. 실제 MCP
427
+ // endpoint 는 200 + JSON-RPC body 를 돌려줘야 살아있는 것. 2xx + JSON-RPC
428
+ // envelope (id 일치, result|error 존재) 둘 다 검증한다.
429
+ export async function probeHttp(url, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
430
+ const start = Date.now();
431
+ try {
432
+ const res = await fetch(url, {
433
+ method: "POST",
434
+ headers: { "content-type": "application/json" },
435
+ body: makeInitializeRequest().trim(),
436
+ signal: AbortSignal.timeout(timeoutMs),
437
+ });
438
+ const ms = Date.now() - start;
439
+ if (res.status < 200 || res.status >= 300) {
440
+ return { alive: false, reason: `http:${res.status}`, ms };
441
+ }
442
+ let body;
443
+ try {
444
+ body = await res.text();
445
+ } catch {
446
+ return { alive: false, reason: "http:body-read-failed", ms };
447
+ }
448
+ // SSE / ndjson 환경 대비 — 첫 JSON-RPC envelope 하나만 찾으면 충분.
449
+ const envelope = body
450
+ .split(/\r?\n/)
451
+ .map((chunk) => chunk.replace(/^data:\s*/, "").trim())
452
+ .find((chunk) => chunk.startsWith("{"));
453
+ if (!envelope) {
454
+ return { alive: false, reason: "http:no-jsonrpc-body", ms };
455
+ }
456
+ try {
457
+ const msg = JSON.parse(envelope);
458
+ if (msg.jsonrpc !== "2.0") {
459
+ return { alive: false, reason: "http:not-jsonrpc", ms };
460
+ }
461
+ if (msg.id !== 1 && msg.id !== "1") {
462
+ return { alive: false, reason: "http:id-mismatch", ms };
463
+ }
464
+ if (msg.result === undefined && msg.error === undefined) {
465
+ return { alive: false, reason: "http:no-result", ms };
466
+ }
467
+ return { alive: true, ms };
468
+ } catch {
469
+ return { alive: false, reason: "http:invalid-json", ms };
470
+ }
471
+ } catch (err) {
472
+ const ms = Date.now() - start;
473
+ const reason =
474
+ err?.name === "AbortError" || err?.name === "TimeoutError"
475
+ ? "timeout"
476
+ : `fetch:${err?.code || err?.message || "unknown"}`;
477
+ return { alive: false, reason, ms };
478
+ }
479
+ }
480
+
481
+ export async function probeServer(def, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
482
+ if (!def || typeof def !== "object") {
483
+ return { alive: false, reason: "no-definition", ms: 0 };
484
+ }
485
+ if (typeof def.url === "string" && def.url) {
486
+ return probeHttp(def.url, timeoutMs);
487
+ }
488
+ if (typeof def.command === "string" && def.command) {
489
+ return probeStdio(def, timeoutMs);
490
+ }
491
+ return { alive: false, reason: "no-transport", ms: 0 };
492
+ }
493
+
494
+ // ────────── Cache ──────────
495
+
496
+ export function readCache(cachePath = DEFAULT_CACHE_PATH) {
497
+ if (!existsSync(cachePath)) return null;
498
+ try {
499
+ return JSON.parse(readFileSync(cachePath, "utf8"));
500
+ } catch {
501
+ return null;
502
+ }
503
+ }
504
+
505
+ export function writeCache(cache, cachePath = DEFAULT_CACHE_PATH) {
506
+ // Issue #154: writeFileSync 는 non-atomic 이라 swarm/병렬 tfx-route 가 동시
507
+ // write 하면 reader 가 partial JSON 을 받아 SyntaxError → null 반환. tmp 파일에
508
+ // 쓰고 rename 으로 교체하면 reader 는 항상 완전한 파일을 본다 (POSIX atomic,
509
+ // Windows MoveFileEx 도 target replace 지원). pid+timestamp 가 tmp 이름에
510
+ // 들어가 writer 끼리도 충돌하지 않는다.
511
+ const tmpPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`;
512
+ try {
513
+ mkdirSync(path.dirname(cachePath), { recursive: true });
514
+ writeFileSync(tmpPath, JSON.stringify(cache, null, 2), "utf8");
515
+ renameSync(tmpPath, cachePath);
516
+ return true;
517
+ } catch {
518
+ try {
519
+ unlinkSync(tmpPath);
520
+ } catch {
521
+ /* best effort */
522
+ }
523
+ return false;
524
+ }
525
+ }
526
+
527
+ export function isCacheFresh(
528
+ cache,
529
+ { now = Date.now(), configMtime = 0 } = {},
530
+ ) {
531
+ if (!cache || typeof cache !== "object") return false;
532
+ if (typeof cache.checkedAt !== "number") return false;
533
+ if (typeof cache.ttlMs !== "number") return false;
534
+ if (configMtime && cache.configMtime !== configMtime) return false;
535
+ return now - cache.checkedAt < cache.ttlMs;
536
+ }
537
+
538
+ // ────────── Orchestration ──────────
539
+
540
+ export async function probeAll({
541
+ configPath = DEFAULT_CONFIG_PATH,
542
+ cachePath = DEFAULT_CACHE_PATH,
543
+ names = null,
544
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
545
+ ttlMs = DEFAULT_TTL_MS,
546
+ useCache = true,
547
+ writeCacheFile = true,
548
+ now = Date.now(),
549
+ } = {}) {
550
+ const configMtime = existsSync(configPath)
551
+ ? Math.floor(statSync(configPath).mtimeMs)
552
+ : 0;
553
+
554
+ const servers = readMcpServers(configPath);
555
+ const allNames = Object.keys(servers);
556
+ const targets =
557
+ Array.isArray(names) && names.length
558
+ ? names.filter((n) => allNames.includes(n))
559
+ : allNames;
560
+
561
+ // Issue #149: per-server fingerprint (binary path+mtime+size) 로 cache 를
562
+ // invalidate 한다. configMtime 은 여전히 meta 로 저장하지만 hit 판정에는
563
+ // 쓰지 않는다 — binary 설치/제거가 config.toml mtime 을 바꾸지 않기 때문.
564
+ const fingerprints = Object.fromEntries(
565
+ targets.map((name) => [name, computeFingerprint(servers[name])]),
566
+ );
567
+
568
+ const existingCache = useCache ? readCache(cachePath) : null;
569
+ const cachedResults = existingCache?.results || {};
570
+ const cacheWithinTtl =
571
+ existingCache &&
572
+ typeof existingCache.checkedAt === "number" &&
573
+ typeof existingCache.ttlMs === "number" &&
574
+ now - existingCache.checkedAt < existingCache.ttlMs;
575
+
576
+ const hits = {};
577
+ const stale = [];
578
+ for (const name of targets) {
579
+ const prior = cachedResults[name];
580
+ if (
581
+ cacheWithinTtl &&
582
+ prior?.fingerprint &&
583
+ fingerprintsEqual(prior.fingerprint, fingerprints[name])
584
+ ) {
585
+ hits[name] = prior;
586
+ } else {
587
+ stale.push(name);
588
+ }
589
+ }
590
+
591
+ if (stale.length === 0) {
592
+ return { results: hits, source: "cache", configMtime };
593
+ }
594
+
595
+ const probes = stale.map(async (name) => {
596
+ const def = servers[name] || {};
597
+ const result = await probeServer(def, timeoutMs);
598
+ return [name, { ...result, fingerprint: fingerprints[name] }];
599
+ });
600
+ const settled = await Promise.all(probes);
601
+ const freshResults = Object.fromEntries(settled);
602
+
603
+ const allResults = { ...hits, ...freshResults };
604
+
605
+ if (writeCacheFile) {
606
+ // 이번에 probe 한 서버만 결과 갱신; 이전 cache 의 다른 서버 결과는 보존.
607
+ const merged = { ...cachedResults, ...freshResults };
608
+ writeCache(
609
+ {
610
+ configMtime,
611
+ checkedAt: now,
612
+ ttlMs,
613
+ results: merged,
614
+ },
615
+ cachePath,
616
+ );
617
+ }
618
+
619
+ return { results: allResults, source: "probe", configMtime };
620
+ }
621
+
622
+ export function splitHealthy(results) {
623
+ const healthy = [];
624
+ const dead = [];
625
+ for (const [name, result] of Object.entries(results || {})) {
626
+ if (result?.alive) healthy.push(name);
627
+ else dead.push(name);
628
+ }
629
+ return { healthy, dead };
630
+ }
631
+
632
+ // ────────── CLI ──────────
633
+
634
+ function parseCliArgs(argv) {
635
+ const args = {
636
+ command: "probe",
637
+ names: null,
638
+ configPath: DEFAULT_CONFIG_PATH,
639
+ cachePath: DEFAULT_CACHE_PATH,
640
+ timeoutMs:
641
+ Number(process.env.TFX_MCP_PROBE_TIMEOUT_MS) || DEFAULT_PROBE_TIMEOUT_MS,
642
+ ttlMs: Number(process.env.TFX_MCP_HEALTH_TTL_MS) || DEFAULT_TTL_MS,
643
+ useCache: true,
644
+ format: "json",
645
+ };
646
+
647
+ const [first] = argv;
648
+ if (first && !first.startsWith("-")) {
649
+ args.command = first;
650
+ argv = argv.slice(1);
651
+ }
652
+
653
+ for (let i = 0; i < argv.length; i += 1) {
654
+ const token = argv[i];
655
+ const next = () => {
656
+ const val = argv[i + 1];
657
+ if (val === undefined) throw new Error(`${token} needs a value`);
658
+ i += 1;
659
+ return val;
660
+ };
661
+ switch (token) {
662
+ case "--names":
663
+ args.names = next()
664
+ .split(/[,\s]+/)
665
+ .filter(Boolean);
666
+ break;
667
+ case "--config":
668
+ args.configPath = next();
669
+ break;
670
+ case "--cache":
671
+ args.cachePath = next();
672
+ break;
673
+ case "--timeout-ms":
674
+ args.timeoutMs = Number(next());
675
+ break;
676
+ case "--ttl-ms":
677
+ args.ttlMs = Number(next());
678
+ break;
679
+ case "--no-cache":
680
+ args.useCache = false;
681
+ break;
682
+ case "--format":
683
+ args.format = next();
684
+ break;
685
+ case "--help":
686
+ case "-h":
687
+ args.command = "help";
688
+ break;
689
+ default:
690
+ throw new Error(`unknown flag: ${token}`);
691
+ }
692
+ }
693
+ return args;
694
+ }
695
+
696
+ function renderHelp() {
697
+ return `mcp-health — dead MCP preflight probe
698
+
699
+ Usage:
700
+ node scripts/lib/mcp-health.mjs probe [--names a,b,c] [--no-cache]
701
+ [--timeout-ms 3000] [--ttl-ms 300000]
702
+ [--format json|shell|disable-flags]
703
+ node scripts/lib/mcp-health.mjs list
704
+
705
+ Env:
706
+ TFX_MCP_PROBE_TIMEOUT_MS default 3000
707
+ TFX_MCP_HEALTH_TTL_MS default 300000
708
+
709
+ Output formats:
710
+ json full results with ms/reason
711
+ shell HEALTHY=a,b DEAD=c,d
712
+ disable-flags -c mcp_servers.c.enabled=false -c mcp_servers.d.enabled=false
713
+ `;
714
+ }
715
+
716
+ function renderOutput(results, source, format) {
717
+ const { healthy, dead } = splitHealthy(results);
718
+ if (format === "shell") {
719
+ return [
720
+ `MCP_HEALTH_SOURCE=${JSON.stringify(source)}`,
721
+ `MCP_HEALTHY=${JSON.stringify(healthy.join(","))}`,
722
+ `MCP_DEAD=${JSON.stringify(dead.join(","))}`,
723
+ ].join("\n");
724
+ }
725
+ if (format === "disable-flags") {
726
+ return dead.map((name) => `-c mcp_servers.${name}.enabled=false`).join(" ");
727
+ }
728
+ return JSON.stringify({ source, healthy, dead, results }, null, 2);
729
+ }
730
+
731
+ export async function runCli(argv = process.argv.slice(2)) {
732
+ let args;
733
+ try {
734
+ args = parseCliArgs(argv);
735
+ } catch (err) {
736
+ process.stderr.write(`[mcp-health] ${err.message}\n`);
737
+ process.exitCode = 64;
738
+ return;
739
+ }
740
+
741
+ if (args.command === "help") {
742
+ process.stdout.write(renderHelp());
743
+ return;
744
+ }
745
+
746
+ if (args.command === "list") {
747
+ const servers = readMcpServers(args.configPath);
748
+ process.stdout.write(
749
+ JSON.stringify(Object.keys(servers).sort(), null, 2) + "\n",
750
+ );
751
+ return;
752
+ }
753
+
754
+ if (args.command !== "probe") {
755
+ process.stderr.write(`[mcp-health] unknown command: ${args.command}\n`);
756
+ process.exitCode = 64;
757
+ return;
758
+ }
759
+
760
+ const { results, source } = await probeAll({
761
+ configPath: args.configPath,
762
+ cachePath: args.cachePath,
763
+ names: args.names,
764
+ timeoutMs: args.timeoutMs,
765
+ ttlMs: args.ttlMs,
766
+ useCache: args.useCache,
767
+ });
768
+ process.stdout.write(renderOutput(results, source, args.format) + "\n");
769
+ }
770
+
771
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
772
+ await runCli();
773
+ }