triflux 10.13.10 → 10.14.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/retry-state-machine.mjs +2 -2
- package/package.json +1 -1
- package/scripts/lib/mcp-health.mjs +771 -0
- package/scripts/setup.mjs +18 -1
- package/scripts/sync-hub-mcp-settings.mjs +29 -19
- package/scripts/tfx-route.sh +168 -4
- package/skills/tfx-auto/SKILL.md +3 -3
- package/skills/tfx-profile/SKILL.md +8 -4
|
@@ -39,8 +39,8 @@ export const MODES = Object.freeze({
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
const DEFAULT_ESCALATION_CHAIN = Object.freeze([
|
|
42
|
-
Object.freeze({ cli: "codex", model: "gpt-5-mini" }),
|
|
43
|
-
Object.freeze({ cli: "codex", model: "gpt-5" }),
|
|
42
|
+
Object.freeze({ cli: "codex", model: "gpt-5.4-mini" }),
|
|
43
|
+
Object.freeze({ cli: "codex", model: "gpt-5.5" }),
|
|
44
44
|
Object.freeze({ cli: "claude", model: "sonnet-4-6" }),
|
|
45
45
|
Object.freeze({ cli: "claude", model: "opus-4-7" }),
|
|
46
46
|
]);
|
package/package.json
CHANGED
|
@@ -0,0 +1,771 @@
|
|
|
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 || !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
|
+
});
|
|
416
|
+
} catch (err) {
|
|
417
|
+
done({ alive: false, reason: `write:${err.code || err.message}` });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Review finding A2 (commit 9298fd6 cross-review): 기존 구현은 status 2xx-4xx
|
|
423
|
+
// 전부 alive 로 취급 — 404/401 HTML 오류 페이지를 healthy 로 오판. 실제 MCP
|
|
424
|
+
// endpoint 는 200 + JSON-RPC body 를 돌려줘야 살아있는 것. 2xx + JSON-RPC
|
|
425
|
+
// envelope (id 일치, result|error 존재) 둘 다 검증한다.
|
|
426
|
+
export async function probeHttp(url, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
427
|
+
const start = Date.now();
|
|
428
|
+
try {
|
|
429
|
+
const res = await fetch(url, {
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: { "content-type": "application/json" },
|
|
432
|
+
body: makeInitializeRequest().trim(),
|
|
433
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
434
|
+
});
|
|
435
|
+
const ms = Date.now() - start;
|
|
436
|
+
if (res.status < 200 || res.status >= 300) {
|
|
437
|
+
return { alive: false, reason: `http:${res.status}`, ms };
|
|
438
|
+
}
|
|
439
|
+
let body;
|
|
440
|
+
try {
|
|
441
|
+
body = await res.text();
|
|
442
|
+
} catch {
|
|
443
|
+
return { alive: false, reason: "http:body-read-failed", ms };
|
|
444
|
+
}
|
|
445
|
+
// SSE / ndjson 환경 대비 — 첫 JSON-RPC envelope 하나만 찾으면 충분.
|
|
446
|
+
const envelope = body
|
|
447
|
+
.split(/\r?\n/)
|
|
448
|
+
.map((chunk) => chunk.replace(/^data:\s*/, "").trim())
|
|
449
|
+
.find((chunk) => chunk.startsWith("{"));
|
|
450
|
+
if (!envelope) {
|
|
451
|
+
return { alive: false, reason: "http:no-jsonrpc-body", ms };
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const msg = JSON.parse(envelope);
|
|
455
|
+
if (msg.jsonrpc !== "2.0") {
|
|
456
|
+
return { alive: false, reason: "http:not-jsonrpc", ms };
|
|
457
|
+
}
|
|
458
|
+
if (msg.id !== 1 && msg.id !== "1") {
|
|
459
|
+
return { alive: false, reason: "http:id-mismatch", ms };
|
|
460
|
+
}
|
|
461
|
+
if (msg.result === undefined && msg.error === undefined) {
|
|
462
|
+
return { alive: false, reason: "http:no-result", ms };
|
|
463
|
+
}
|
|
464
|
+
return { alive: true, ms };
|
|
465
|
+
} catch {
|
|
466
|
+
return { alive: false, reason: "http:invalid-json", ms };
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
const ms = Date.now() - start;
|
|
470
|
+
const reason =
|
|
471
|
+
err?.name === "AbortError" || err?.name === "TimeoutError"
|
|
472
|
+
? "timeout"
|
|
473
|
+
: `fetch:${err?.code || err?.message || "unknown"}`;
|
|
474
|
+
return { alive: false, reason, ms };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function probeServer(def, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
479
|
+
if (!def || typeof def !== "object") {
|
|
480
|
+
return { alive: false, reason: "no-definition", ms: 0 };
|
|
481
|
+
}
|
|
482
|
+
if (typeof def.url === "string" && def.url) {
|
|
483
|
+
return probeHttp(def.url, timeoutMs);
|
|
484
|
+
}
|
|
485
|
+
if (typeof def.command === "string" && def.command) {
|
|
486
|
+
return probeStdio(def, timeoutMs);
|
|
487
|
+
}
|
|
488
|
+
return { alive: false, reason: "no-transport", ms: 0 };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ────────── Cache ──────────
|
|
492
|
+
|
|
493
|
+
export function readCache(cachePath = DEFAULT_CACHE_PATH) {
|
|
494
|
+
if (!existsSync(cachePath)) return null;
|
|
495
|
+
try {
|
|
496
|
+
return JSON.parse(readFileSync(cachePath, "utf8"));
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function writeCache(cache, cachePath = DEFAULT_CACHE_PATH) {
|
|
503
|
+
// Issue #154: writeFileSync 는 non-atomic 이라 swarm/병렬 tfx-route 가 동시
|
|
504
|
+
// write 하면 reader 가 partial JSON 을 받아 SyntaxError → null 반환. tmp 파일에
|
|
505
|
+
// 쓰고 rename 으로 교체하면 reader 는 항상 완전한 파일을 본다 (POSIX atomic,
|
|
506
|
+
// Windows MoveFileEx 도 target replace 지원). pid+timestamp 가 tmp 이름에
|
|
507
|
+
// 들어가 writer 끼리도 충돌하지 않는다.
|
|
508
|
+
const tmpPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`;
|
|
509
|
+
try {
|
|
510
|
+
mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
511
|
+
writeFileSync(tmpPath, JSON.stringify(cache, null, 2), "utf8");
|
|
512
|
+
renameSync(tmpPath, cachePath);
|
|
513
|
+
return true;
|
|
514
|
+
} catch {
|
|
515
|
+
try {
|
|
516
|
+
unlinkSync(tmpPath);
|
|
517
|
+
} catch {
|
|
518
|
+
/* best effort */
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function isCacheFresh(
|
|
525
|
+
cache,
|
|
526
|
+
{ now = Date.now(), configMtime = 0 } = {},
|
|
527
|
+
) {
|
|
528
|
+
if (!cache || typeof cache !== "object") return false;
|
|
529
|
+
if (typeof cache.checkedAt !== "number") return false;
|
|
530
|
+
if (typeof cache.ttlMs !== "number") return false;
|
|
531
|
+
if (configMtime && cache.configMtime !== configMtime) return false;
|
|
532
|
+
return now - cache.checkedAt < cache.ttlMs;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ────────── Orchestration ──────────
|
|
536
|
+
|
|
537
|
+
export async function probeAll({
|
|
538
|
+
configPath = DEFAULT_CONFIG_PATH,
|
|
539
|
+
cachePath = DEFAULT_CACHE_PATH,
|
|
540
|
+
names = null,
|
|
541
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
542
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
543
|
+
useCache = true,
|
|
544
|
+
writeCacheFile = true,
|
|
545
|
+
now = Date.now(),
|
|
546
|
+
} = {}) {
|
|
547
|
+
const configMtime = existsSync(configPath)
|
|
548
|
+
? Math.floor(statSync(configPath).mtimeMs)
|
|
549
|
+
: 0;
|
|
550
|
+
|
|
551
|
+
const servers = readMcpServers(configPath);
|
|
552
|
+
const allNames = Object.keys(servers);
|
|
553
|
+
const targets =
|
|
554
|
+
Array.isArray(names) && names.length
|
|
555
|
+
? names.filter((n) => allNames.includes(n))
|
|
556
|
+
: allNames;
|
|
557
|
+
|
|
558
|
+
// Issue #149: per-server fingerprint (binary path+mtime+size) 로 cache 를
|
|
559
|
+
// invalidate 한다. configMtime 은 여전히 meta 로 저장하지만 hit 판정에는
|
|
560
|
+
// 쓰지 않는다 — binary 설치/제거가 config.toml mtime 을 바꾸지 않기 때문.
|
|
561
|
+
const fingerprints = Object.fromEntries(
|
|
562
|
+
targets.map((name) => [name, computeFingerprint(servers[name])]),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const existingCache = useCache ? readCache(cachePath) : null;
|
|
566
|
+
const cachedResults = existingCache?.results || {};
|
|
567
|
+
const cacheWithinTtl =
|
|
568
|
+
existingCache &&
|
|
569
|
+
typeof existingCache.checkedAt === "number" &&
|
|
570
|
+
typeof existingCache.ttlMs === "number" &&
|
|
571
|
+
now - existingCache.checkedAt < existingCache.ttlMs;
|
|
572
|
+
|
|
573
|
+
const hits = {};
|
|
574
|
+
const stale = [];
|
|
575
|
+
for (const name of targets) {
|
|
576
|
+
const prior = cachedResults[name];
|
|
577
|
+
if (
|
|
578
|
+
cacheWithinTtl &&
|
|
579
|
+
prior &&
|
|
580
|
+
prior.fingerprint &&
|
|
581
|
+
fingerprintsEqual(prior.fingerprint, fingerprints[name])
|
|
582
|
+
) {
|
|
583
|
+
hits[name] = prior;
|
|
584
|
+
} else {
|
|
585
|
+
stale.push(name);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (stale.length === 0) {
|
|
590
|
+
return { results: hits, source: "cache", configMtime };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const probes = stale.map(async (name) => {
|
|
594
|
+
const def = servers[name] || {};
|
|
595
|
+
const result = await probeServer(def, timeoutMs);
|
|
596
|
+
return [name, { ...result, fingerprint: fingerprints[name] }];
|
|
597
|
+
});
|
|
598
|
+
const settled = await Promise.all(probes);
|
|
599
|
+
const freshResults = Object.fromEntries(settled);
|
|
600
|
+
|
|
601
|
+
const allResults = { ...hits, ...freshResults };
|
|
602
|
+
|
|
603
|
+
if (writeCacheFile) {
|
|
604
|
+
// 이번에 probe 한 서버만 결과 갱신; 이전 cache 의 다른 서버 결과는 보존.
|
|
605
|
+
const merged = { ...cachedResults, ...freshResults };
|
|
606
|
+
writeCache(
|
|
607
|
+
{
|
|
608
|
+
configMtime,
|
|
609
|
+
checkedAt: now,
|
|
610
|
+
ttlMs,
|
|
611
|
+
results: merged,
|
|
612
|
+
},
|
|
613
|
+
cachePath,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { results: allResults, source: "probe", configMtime };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function splitHealthy(results) {
|
|
621
|
+
const healthy = [];
|
|
622
|
+
const dead = [];
|
|
623
|
+
for (const [name, result] of Object.entries(results || {})) {
|
|
624
|
+
if (result?.alive) healthy.push(name);
|
|
625
|
+
else dead.push(name);
|
|
626
|
+
}
|
|
627
|
+
return { healthy, dead };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ────────── CLI ──────────
|
|
631
|
+
|
|
632
|
+
function parseCliArgs(argv) {
|
|
633
|
+
const args = {
|
|
634
|
+
command: "probe",
|
|
635
|
+
names: null,
|
|
636
|
+
configPath: DEFAULT_CONFIG_PATH,
|
|
637
|
+
cachePath: DEFAULT_CACHE_PATH,
|
|
638
|
+
timeoutMs:
|
|
639
|
+
Number(process.env.TFX_MCP_PROBE_TIMEOUT_MS) || DEFAULT_PROBE_TIMEOUT_MS,
|
|
640
|
+
ttlMs: Number(process.env.TFX_MCP_HEALTH_TTL_MS) || DEFAULT_TTL_MS,
|
|
641
|
+
useCache: true,
|
|
642
|
+
format: "json",
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const [first] = argv;
|
|
646
|
+
if (first && !first.startsWith("-")) {
|
|
647
|
+
args.command = first;
|
|
648
|
+
argv = argv.slice(1);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
652
|
+
const token = argv[i];
|
|
653
|
+
const next = () => {
|
|
654
|
+
const val = argv[i + 1];
|
|
655
|
+
if (val === undefined) throw new Error(`${token} needs a value`);
|
|
656
|
+
i += 1;
|
|
657
|
+
return val;
|
|
658
|
+
};
|
|
659
|
+
switch (token) {
|
|
660
|
+
case "--names":
|
|
661
|
+
args.names = next()
|
|
662
|
+
.split(/[,\s]+/)
|
|
663
|
+
.filter(Boolean);
|
|
664
|
+
break;
|
|
665
|
+
case "--config":
|
|
666
|
+
args.configPath = next();
|
|
667
|
+
break;
|
|
668
|
+
case "--cache":
|
|
669
|
+
args.cachePath = next();
|
|
670
|
+
break;
|
|
671
|
+
case "--timeout-ms":
|
|
672
|
+
args.timeoutMs = Number(next());
|
|
673
|
+
break;
|
|
674
|
+
case "--ttl-ms":
|
|
675
|
+
args.ttlMs = Number(next());
|
|
676
|
+
break;
|
|
677
|
+
case "--no-cache":
|
|
678
|
+
args.useCache = false;
|
|
679
|
+
break;
|
|
680
|
+
case "--format":
|
|
681
|
+
args.format = next();
|
|
682
|
+
break;
|
|
683
|
+
case "--help":
|
|
684
|
+
case "-h":
|
|
685
|
+
args.command = "help";
|
|
686
|
+
break;
|
|
687
|
+
default:
|
|
688
|
+
throw new Error(`unknown flag: ${token}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return args;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function renderHelp() {
|
|
695
|
+
return `mcp-health — dead MCP preflight probe
|
|
696
|
+
|
|
697
|
+
Usage:
|
|
698
|
+
node scripts/lib/mcp-health.mjs probe [--names a,b,c] [--no-cache]
|
|
699
|
+
[--timeout-ms 3000] [--ttl-ms 300000]
|
|
700
|
+
[--format json|shell|disable-flags]
|
|
701
|
+
node scripts/lib/mcp-health.mjs list
|
|
702
|
+
|
|
703
|
+
Env:
|
|
704
|
+
TFX_MCP_PROBE_TIMEOUT_MS default 3000
|
|
705
|
+
TFX_MCP_HEALTH_TTL_MS default 300000
|
|
706
|
+
|
|
707
|
+
Output formats:
|
|
708
|
+
json full results with ms/reason
|
|
709
|
+
shell HEALTHY=a,b DEAD=c,d
|
|
710
|
+
disable-flags -c mcp_servers.c.enabled=false -c mcp_servers.d.enabled=false
|
|
711
|
+
`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function renderOutput(results, source, format) {
|
|
715
|
+
const { healthy, dead } = splitHealthy(results);
|
|
716
|
+
if (format === "shell") {
|
|
717
|
+
return [
|
|
718
|
+
`MCP_HEALTH_SOURCE=${JSON.stringify(source)}`,
|
|
719
|
+
`MCP_HEALTHY=${JSON.stringify(healthy.join(","))}`,
|
|
720
|
+
`MCP_DEAD=${JSON.stringify(dead.join(","))}`,
|
|
721
|
+
].join("\n");
|
|
722
|
+
}
|
|
723
|
+
if (format === "disable-flags") {
|
|
724
|
+
return dead.map((name) => `-c mcp_servers.${name}.enabled=false`).join(" ");
|
|
725
|
+
}
|
|
726
|
+
return JSON.stringify({ source, healthy, dead, results }, null, 2);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
730
|
+
let args;
|
|
731
|
+
try {
|
|
732
|
+
args = parseCliArgs(argv);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
process.stderr.write(`[mcp-health] ${err.message}\n`);
|
|
735
|
+
process.exitCode = 64;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (args.command === "help") {
|
|
740
|
+
process.stdout.write(renderHelp());
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (args.command === "list") {
|
|
745
|
+
const servers = readMcpServers(args.configPath);
|
|
746
|
+
process.stdout.write(
|
|
747
|
+
JSON.stringify(Object.keys(servers).sort(), null, 2) + "\n",
|
|
748
|
+
);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (args.command !== "probe") {
|
|
753
|
+
process.stderr.write(`[mcp-health] unknown command: ${args.command}\n`);
|
|
754
|
+
process.exitCode = 64;
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const { results, source } = await probeAll({
|
|
759
|
+
configPath: args.configPath,
|
|
760
|
+
cachePath: args.cachePath,
|
|
761
|
+
names: args.names,
|
|
762
|
+
timeoutMs: args.timeoutMs,
|
|
763
|
+
ttlMs: args.ttlMs,
|
|
764
|
+
useCache: args.useCache,
|
|
765
|
+
});
|
|
766
|
+
process.stdout.write(renderOutput(results, source, args.format) + "\n");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
770
|
+
await runCli();
|
|
771
|
+
}
|
package/scripts/setup.mjs
CHANGED
|
@@ -49,6 +49,23 @@ const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
|
|
|
49
49
|
const HUD_PATH = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
|
|
50
50
|
|
|
51
51
|
const REQUIRED_CODEX_PROFILES = [
|
|
52
|
+
// gpt-5.5 — 새 main 플래그십. xhigh/high/med/low 4 tier 전부 보장.
|
|
53
|
+
{
|
|
54
|
+
name: "gpt55_xhigh",
|
|
55
|
+
lines: ['model = "gpt-5.5"', 'model_reasoning_effort = "xhigh"'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "gpt55_high",
|
|
59
|
+
lines: ['model = "gpt-5.5"', 'model_reasoning_effort = "high"'],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "gpt55_med",
|
|
63
|
+
lines: ['model = "gpt-5.5"', 'model_reasoning_effort = "medium"'],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "gpt55_low",
|
|
67
|
+
lines: ['model = "gpt-5.5"', 'model_reasoning_effort = "low"'],
|
|
68
|
+
},
|
|
52
69
|
{
|
|
53
70
|
name: "codex53_high",
|
|
54
71
|
lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "high"'],
|
|
@@ -594,7 +611,7 @@ function ensureCodexHubServerConfig({
|
|
|
594
611
|
// Only injected when the key is completely absent — existing user values are
|
|
595
612
|
// never overwritten, regardless of what value was set.
|
|
596
613
|
const REQUIRED_TOP_LEVEL_SETTINGS = [
|
|
597
|
-
{ key: "model", value: '"gpt-5.
|
|
614
|
+
{ key: "model", value: '"gpt-5.5"' },
|
|
598
615
|
{ key: "model_reasoning_effort", value: '"high"' },
|
|
599
616
|
{ key: "service_tier", value: '"fast"' },
|
|
600
617
|
];
|
|
@@ -25,11 +25,12 @@ function getCodexConfigPath(codexConfigPath) {
|
|
|
25
25
|
return join(home, ...CODEX_CONFIG_FILE);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
function getProjectMcpJsonPaths(projectRoot) {
|
|
29
|
+
const root =
|
|
30
|
+
typeof projectRoot === "string" && projectRoot.length > 0
|
|
31
|
+
? projectRoot
|
|
32
|
+
: process.cwd();
|
|
33
|
+
return [join(root, ".claude", "mcp.json"), join(root, ".mcp.json")];
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function getReason(error, fallback) {
|
|
@@ -334,7 +335,13 @@ async function syncProjectMcpFile({ filePath, hubUrl, dryRun, logger }) {
|
|
|
334
335
|
return { kind: "error", path: filePath, reason };
|
|
335
336
|
}
|
|
336
337
|
|
|
337
|
-
|
|
338
|
+
// Claude Code 는 현재 type:"http" 만 허용. 과거 type:"url" 엔트리는 스키마 오류로
|
|
339
|
+
// project config parse 실패 → MCP 전체 연결 단절. url 일치만으로 skip 하면 legacy
|
|
340
|
+
// type 이 영원히 안 고쳐진다. syncSingleFile (user-level settings) 이 type+url
|
|
341
|
+
// 둘 다 보는 것과 동일 규약 적용.
|
|
342
|
+
const typeOk = hubServer.type === "http";
|
|
343
|
+
const urlOk = hubServer.url === hubUrl;
|
|
344
|
+
if (typeOk && urlOk) {
|
|
338
345
|
log(logger, "info", `[project-mcp-sync] skipped: ${filePath}`);
|
|
339
346
|
return { kind: "skipped", path: filePath };
|
|
340
347
|
}
|
|
@@ -342,11 +349,12 @@ async function syncProjectMcpFile({ filePath, hubUrl, dryRun, logger }) {
|
|
|
342
349
|
log(
|
|
343
350
|
logger,
|
|
344
351
|
"debug",
|
|
345
|
-
`[project-mcp-sync] ${filePath} url:${String(hubServer.url)} ->
|
|
352
|
+
`[project-mcp-sync] ${filePath} type:${String(hubServer.type)} url:${String(hubServer.url)} -> type:http url:${hubUrl}`,
|
|
346
353
|
);
|
|
347
354
|
|
|
348
355
|
if (!dryRun) {
|
|
349
356
|
try {
|
|
357
|
+
hubServer.type = "http";
|
|
350
358
|
hubServer.url = hubUrl;
|
|
351
359
|
await writeJsonAtomic(filePath, settings);
|
|
352
360
|
} catch (error) {
|
|
@@ -434,19 +442,21 @@ export async function syncProjectMcpJson({
|
|
|
434
442
|
errors: [],
|
|
435
443
|
};
|
|
436
444
|
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
445
|
+
for (const filePath of getProjectMcpJsonPaths(projectRoot)) {
|
|
446
|
+
const outcome = await syncProjectMcpFile({
|
|
447
|
+
filePath,
|
|
448
|
+
hubUrl,
|
|
449
|
+
dryRun,
|
|
450
|
+
logger,
|
|
451
|
+
});
|
|
443
452
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
453
|
+
if (outcome.kind === "updated") {
|
|
454
|
+
result.updated.push(outcome.path);
|
|
455
|
+
} else if (outcome.kind === "skipped") {
|
|
456
|
+
result.skipped.push(outcome.path);
|
|
457
|
+
} else {
|
|
458
|
+
result.errors.push({ path: outcome.path, reason: outcome.reason });
|
|
459
|
+
}
|
|
450
460
|
}
|
|
451
461
|
|
|
452
462
|
return result;
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -93,6 +93,22 @@ cleanup_workers() {
|
|
|
93
93
|
rm -f "$_PID_TRACK"
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
# ── Preflight env vars (P0 prompt-dodge): git/npm/gh 자동 응답 ──
|
|
97
|
+
# 워커가 dispatch 직후 git credential / npm install / gh auth prompt에서
|
|
98
|
+
# stall하지 않도록 환경변수를 선주입한다. 사용자 값이 있으면 존중.
|
|
99
|
+
export GIT_TERMINAL_PROMPT="${GIT_TERMINAL_PROMPT:-0}"
|
|
100
|
+
export GIT_ASKPASS="${GIT_ASKPASS:-false}"
|
|
101
|
+
export npm_config_yes="${npm_config_yes:-true}"
|
|
102
|
+
|
|
103
|
+
_preflight_check_gh_auth() {
|
|
104
|
+
command -v gh >/dev/null 2>&1 || return 0
|
|
105
|
+
[[ -n "${GH_TOKEN:-}" || -n "${GITHUB_TOKEN:-}" ]] && return 0
|
|
106
|
+
if ! gh auth status >/dev/null 2>&1; then
|
|
107
|
+
echo "[tfx-route] 경고: gh 인증 미설정 (GH_TOKEN/GITHUB_TOKEN 미설정 + 'gh auth status' 실패). gh 명령 실행 시 prompt 발생 가능" >&2
|
|
108
|
+
fi
|
|
109
|
+
}
|
|
110
|
+
_preflight_check_gh_auth
|
|
111
|
+
|
|
96
112
|
# ── config.toml sandbox/approval_mode 감지 ──
|
|
97
113
|
# config.toml에 이미 설정되어 있으면 CLI 플래그 중복 시 Codex가 에러를 던짐.
|
|
98
114
|
# 단, [mcp_servers.*.tools.*] 섹션 내부의 approval_mode는 tool 단위 승인 설정으로
|
|
@@ -1471,6 +1487,123 @@ resolve_codex_mcp_script() {
|
|
|
1471
1487
|
"$sd/hub/workers/codex-mcp.mjs" "$sd/../hub/workers/codex-mcp.mjs"
|
|
1472
1488
|
}
|
|
1473
1489
|
|
|
1490
|
+
## ── MCP Preflight: dead 서버 감지 후 CODEX_CONFIG_FLAGS 에서 제거 ──
|
|
1491
|
+
# Session 18 체크포인트 P3 root-cause fix. dead MCP 가 allowed_pat 에 포함되면
|
|
1492
|
+
# _codex_config_swap 이 section 을 유지 → Codex 가 init 시도 → -32000 으로 죽는다.
|
|
1493
|
+
# Preflight 가 각 서버를 probe (initialize 요청) 한 뒤 응답 없는 서버의
|
|
1494
|
+
# enabled=true 플래그를 제거해서 swap 이 그 section 을 자동으로 drop 하게 만든다.
|
|
1495
|
+
# Opt-out: TFX_MCP_HEALTH_CHECK=0
|
|
1496
|
+
_mcp_preflight_filter_dead() {
|
|
1497
|
+
local opt="${TFX_MCP_HEALTH_CHECK:-1}"
|
|
1498
|
+
if [[ "$opt" == "0" || "$opt" == "false" || "$opt" == "off" ]]; then
|
|
1499
|
+
return 0
|
|
1500
|
+
fi
|
|
1501
|
+
if [[ "${#CODEX_CONFIG_FLAGS[@]}" -eq 0 ]]; then
|
|
1502
|
+
return 0
|
|
1503
|
+
fi
|
|
1504
|
+
|
|
1505
|
+
local sd; sd="$(_get_script_dir)"
|
|
1506
|
+
local health_script
|
|
1507
|
+
health_script="$(_resolve_script "${TFX_MCP_HEALTH_SCRIPT:-}" \
|
|
1508
|
+
${TFX_PKG_ROOT:+"$TFX_PKG_ROOT/scripts/lib/mcp-health.mjs"} \
|
|
1509
|
+
"$sd/lib/mcp-health.mjs" "$sd/../scripts/lib/mcp-health.mjs")" || return 0
|
|
1510
|
+
[[ -n "$health_script" && -f "$health_script" ]] || return 0
|
|
1511
|
+
command -v "$NODE_BIN" &>/dev/null || return 0
|
|
1512
|
+
|
|
1513
|
+
# CODEX_CONFIG_FLAGS 에서 enabled=true 항목으로부터 후보 서버 이름 수집.
|
|
1514
|
+
# #153: parseMcpServersFromToml 은 section 이름에 dot 을 허용 (`[a-zA-Z0-9_.-]+`).
|
|
1515
|
+
# `[mcp_servers.foo.bar]` 같은 dotted 서버가 `mcp_servers.foo.bar.enabled=true`
|
|
1516
|
+
# 플래그로 전달될 때 과거 `[^.]+` 정규식은 `foo` 만 captur 해 suffix 매치 실패
|
|
1517
|
+
# → dotted 서버가 preflight candidate 에서 통째로 누락됐다. `\.enabled=true$`
|
|
1518
|
+
# 로 끝 anchor 가 고정돼 있어 `(.+)` greedy 가 반복 보장한다.
|
|
1519
|
+
local names=""
|
|
1520
|
+
local i=0
|
|
1521
|
+
local n="${#CODEX_CONFIG_FLAGS[@]}"
|
|
1522
|
+
while (( i < n )); do
|
|
1523
|
+
local flag="${CODEX_CONFIG_FLAGS[$i]}"
|
|
1524
|
+
if [[ "$flag" == "-c" ]] && (( i + 1 < n )); then
|
|
1525
|
+
local value="${CODEX_CONFIG_FLAGS[$((i+1))]}"
|
|
1526
|
+
if [[ "$value" =~ ^mcp_servers\.(.+)\.enabled=true$ ]]; then
|
|
1527
|
+
[[ -n "$names" ]] && names="${names},"
|
|
1528
|
+
names="${names}${BASH_REMATCH[1]}"
|
|
1529
|
+
fi
|
|
1530
|
+
i=$((i+2))
|
|
1531
|
+
else
|
|
1532
|
+
i=$((i+1))
|
|
1533
|
+
fi
|
|
1534
|
+
done
|
|
1535
|
+
[[ -z "$names" ]] && return 0
|
|
1536
|
+
|
|
1537
|
+
# Probe — TTL cache 로 재호출 부하 억제
|
|
1538
|
+
local probe_output
|
|
1539
|
+
if ! probe_output=$("$NODE_BIN" "$health_script" probe \
|
|
1540
|
+
--names "$names" --format shell 2>/dev/null); then
|
|
1541
|
+
echo "[tfx-route] MCP preflight probe 실패 — 스킵" >&2
|
|
1542
|
+
return 0
|
|
1543
|
+
fi
|
|
1544
|
+
|
|
1545
|
+
local dead_list=""
|
|
1546
|
+
while IFS= read -r line; do
|
|
1547
|
+
if [[ "$line" =~ ^MCP_DEAD=\"(.*)\"$ ]]; then
|
|
1548
|
+
dead_list="${BASH_REMATCH[1]}"
|
|
1549
|
+
fi
|
|
1550
|
+
done <<< "$probe_output"
|
|
1551
|
+
[[ -z "$dead_list" ]] && return 0
|
|
1552
|
+
|
|
1553
|
+
# dead 서버의 모든 mcp_servers.<dead>.* override 를 CODEX_CONFIG_FLAGS 에서 제거
|
|
1554
|
+
local -a dead_names=()
|
|
1555
|
+
IFS=',' read -ra dead_names <<< "$dead_list"
|
|
1556
|
+
local -a new_flags=()
|
|
1557
|
+
i=0
|
|
1558
|
+
while (( i < n )); do
|
|
1559
|
+
local flag="${CODEX_CONFIG_FLAGS[$i]}"
|
|
1560
|
+
if [[ "$flag" == "-c" ]] && (( i + 1 < n )); then
|
|
1561
|
+
local value="${CODEX_CONFIG_FLAGS[$((i+1))]}"
|
|
1562
|
+
local drop=false
|
|
1563
|
+
local dead
|
|
1564
|
+
for dead in "${dead_names[@]}"; do
|
|
1565
|
+
[[ -z "$dead" ]] && continue
|
|
1566
|
+
if [[ "$value" == "mcp_servers.${dead}."* ]]; then
|
|
1567
|
+
drop=true
|
|
1568
|
+
break
|
|
1569
|
+
fi
|
|
1570
|
+
done
|
|
1571
|
+
if [[ "$drop" == "false" ]]; then
|
|
1572
|
+
new_flags+=("-c" "$value")
|
|
1573
|
+
fi
|
|
1574
|
+
i=$((i+2))
|
|
1575
|
+
else
|
|
1576
|
+
new_flags+=("$flag")
|
|
1577
|
+
i=$((i+1))
|
|
1578
|
+
fi
|
|
1579
|
+
done
|
|
1580
|
+
|
|
1581
|
+
CODEX_CONFIG_FLAGS=("${new_flags[@]}")
|
|
1582
|
+
echo "[tfx-route] MCP preflight: ${#dead_names[@]}개 dead MCP 제외 (${dead_list})" >&2
|
|
1583
|
+
|
|
1584
|
+
# #148: profile-allowed 전부 dead 인 all-dead 엣지케이스 조기 실패.
|
|
1585
|
+
# 빈 allowed_pat 은 _codex_config_swap fail-safe (#132) 에 의해 원본 config
|
|
1586
|
+
# 전체를 유지 → 비필요 MCP 까지 전부 spawn → 역효과.
|
|
1587
|
+
# TFX_MCP_ALLOW_ALL_DEAD=1 로 명시적 opt-in 시 MCP 없이 진행 (degraded).
|
|
1588
|
+
local remaining_alive=0
|
|
1589
|
+
local rflag
|
|
1590
|
+
for rflag in "${CODEX_CONFIG_FLAGS[@]}"; do
|
|
1591
|
+
if [[ "$rflag" =~ ^mcp_servers\.[^.]+\.enabled=true$ ]]; then
|
|
1592
|
+
remaining_alive=$((remaining_alive + 1))
|
|
1593
|
+
fi
|
|
1594
|
+
done
|
|
1595
|
+
|
|
1596
|
+
if [[ "$remaining_alive" -eq 0 ]]; then
|
|
1597
|
+
if [[ "${TFX_MCP_ALLOW_ALL_DEAD:-0}" == "1" ]]; then
|
|
1598
|
+
echo "[tfx-route] TFX_MCP_ALLOW_ALL_DEAD=1 — MCP 없이 계속 진행 (degraded)" >&2
|
|
1599
|
+
return 0
|
|
1600
|
+
fi
|
|
1601
|
+
echo "[tfx-route] 조기 실패: profile 에서 허용한 MCP 전부 dead — Codex 호출 중단" >&2
|
|
1602
|
+
echo " 복구: (1) dead MCP 복구 (2) TFX_MCP_HEALTH_CHECK=0 preflight 비활성 (3) TFX_MCP_ALLOW_ALL_DEAD=1 MCP 없이 진행" >&2
|
|
1603
|
+
return 78
|
|
1604
|
+
fi
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1474
1607
|
## ── Config Swap: 프로필별 MCP 서버 필터링 ──
|
|
1475
1608
|
# codex exec는 -c flag로 MCP enabled/disabled를 제어할 수 없다.
|
|
1476
1609
|
# config.toml을 원자적으로 교체하여 불필요한 서버 시작을 방지한다.
|
|
@@ -1633,10 +1766,12 @@ run_codex_exec() {
|
|
|
1633
1766
|
# -c flags는 codex exec에서 MCP enabled 제어 불가 — config swap으로 대체
|
|
1634
1767
|
# config swap은 codex 블록 최상단(_codex_config_swap "filter")에서 실행됨
|
|
1635
1768
|
|
|
1769
|
+
# `--` end-of-options: prompt가 '--'/'---' (front-matter 등)로 시작하면
|
|
1770
|
+
# clap이 flag로 파싱하는 것을 방지. fallback path에서 특히 중요.
|
|
1636
1771
|
if [[ "$use_tee_flag" == "true" ]]; then
|
|
1637
|
-
"$TIMEOUT_BIN" "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" < /dev/null 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
|
|
1772
|
+
"$TIMEOUT_BIN" "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" -- "$prompt" < /dev/null 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
|
|
1638
1773
|
else
|
|
1639
|
-
"$TIMEOUT_BIN" "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" < /dev/null >"$STDOUT_LOG" 2>"$STDERR_LOG" &
|
|
1774
|
+
"$TIMEOUT_BIN" "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" -- "$prompt" < /dev/null >"$STDOUT_LOG" 2>"$STDERR_LOG" &
|
|
1640
1775
|
fi
|
|
1641
1776
|
worker_pid=$!
|
|
1642
1777
|
_wait_with_heartbeat "$worker_pid" || exit_code_local=$?
|
|
@@ -1830,6 +1965,22 @@ FALLBACK_EOF
|
|
|
1830
1965
|
exit 0
|
|
1831
1966
|
fi
|
|
1832
1967
|
|
|
1968
|
+
# Issue #156: hub-ensure 무조건 호출 — codex/gemini 가 tfx-hub MCP 를 쓸 수
|
|
1969
|
+
# 있도록 사전 보장. Claude 세션 SessionStart 훅 외부에서 (Windows 재부팅 후
|
|
1970
|
+
# codex 단독 실행, hub crash 후 Claude 미오픈, WSL/SSH 등) 도 hub 가 자동
|
|
1971
|
+
# 기동된다. hub 가 이미 alive 면 /health 1회 호출로 no-op (저비용).
|
|
1972
|
+
# best-effort: 실패해도 tfx-route 진행 차단하지 않음.
|
|
1973
|
+
if command -v "$NODE_BIN" &>/dev/null; then
|
|
1974
|
+
local _sd_he; _sd_he="$(_get_script_dir)"
|
|
1975
|
+
local _hub_ensure_script
|
|
1976
|
+
_hub_ensure_script="$(_resolve_script "${TFX_HUB_ENSURE_SCRIPT:-}" \
|
|
1977
|
+
${TFX_PKG_ROOT:+"$TFX_PKG_ROOT/scripts/hub-ensure.mjs"} \
|
|
1978
|
+
"$_sd_he/hub-ensure.mjs" "$_sd_he/../scripts/hub-ensure.mjs" 2>/dev/null)" || _hub_ensure_script=""
|
|
1979
|
+
if [[ -n "$_hub_ensure_script" && -f "$_hub_ensure_script" ]]; then
|
|
1980
|
+
"$NODE_BIN" "$_hub_ensure_script" >/dev/null 2>&1 || true
|
|
1981
|
+
fi
|
|
1982
|
+
fi
|
|
1983
|
+
|
|
1833
1984
|
local FULL_PROMPT="$PROMPT"
|
|
1834
1985
|
[[ -n "$MCP_HINT" ]] && FULL_PROMPT="${PROMPT}. ${MCP_HINT}"
|
|
1835
1986
|
local codex_transport_effective="n/a"
|
|
@@ -1880,6 +2031,15 @@ FALLBACK_EOF
|
|
|
1880
2031
|
fi
|
|
1881
2032
|
|
|
1882
2033
|
if [[ "$CLI_TYPE" == "codex" ]]; then
|
|
2034
|
+
# Preflight: dead MCP 감지 후 CODEX_CONFIG_FLAGS 에서 제거.
|
|
2035
|
+
# swap 이 allowed_pat 을 이 배열에서 계산하므로, 여기서 제거하면
|
|
2036
|
+
# dead section 이 config.toml 에서 자동으로 drop 된다.
|
|
2037
|
+
# #148: preflight 가 78 반환 시 all-dead → Codex 호출 중단 (early fail).
|
|
2038
|
+
local _preflight_rc=0
|
|
2039
|
+
_mcp_preflight_filter_dead || _preflight_rc=$?
|
|
2040
|
+
if [[ "$_preflight_rc" -eq 78 ]]; then
|
|
2041
|
+
exit 78
|
|
2042
|
+
fi
|
|
1883
2043
|
# Config swap: 프로필에 맞는 MCP 서버만 남긴 임시 config 적용
|
|
1884
2044
|
# run_codex_mcp / run_codex_exec 어느 경로든 적용되도록 최상단에서 실행
|
|
1885
2045
|
_codex_config_swap "filter"
|
|
@@ -1892,8 +2052,12 @@ FALLBACK_EOF
|
|
|
1892
2052
|
if [[ "$exit_code" -eq 0 ]]; then
|
|
1893
2053
|
codex_transport_effective="mcp"
|
|
1894
2054
|
elif [[ "$exit_code" -eq "$CODEX_MCP_TRANSPORT_EXIT_CODE" && "$TFX_CODEX_TRANSPORT" == "auto" ]]; then
|
|
1895
|
-
|
|
1896
|
-
|
|
2055
|
+
# MCP 실패 → exec fallback. run_codex_exec는 < /dev/null 로 stdin 블록 회피 (line 1639).
|
|
2056
|
+
# 정책: codex/gemini 강건성 — MCP 가용 시 MCP, 실패 시 그래도 워커 자체는 굴러간다.
|
|
2057
|
+
echo "[tfx-route] Codex MCP 실패(exit=${exit_code}). exec fallback 시도." >&2
|
|
2058
|
+
exit_code=0
|
|
2059
|
+
run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
|
|
2060
|
+
codex_transport_effective="exec-fallback"
|
|
1897
2061
|
else
|
|
1898
2062
|
codex_transport_effective="mcp"
|
|
1899
2063
|
fi
|
package/skills/tfx-auto/SKILL.md
CHANGED
|
@@ -158,7 +158,7 @@ ARGUMENTS 에 아래 플래그가 있으면 Step 0 스마트 라우팅의 내부
|
|
|
158
158
|
| `--retry` | `0` | 자동 재시도 없음 | — |
|
|
159
159
|
| `--retry` | `1` (기본) | bounded verify → fix loop 3회 | — |
|
|
160
160
|
| `--retry` | `ralph` | **Phase 3** — true ralph state machine (unlimited, stuck detector 3회 중단) | retry-state-machine.mjs |
|
|
161
|
-
| `--retry` | `auto-escalate` | **Phase 3** — 체인 승격 (codex:mini → codex:gpt-5 → claude:sonnet → claude:opus) | retry-state-machine.mjs |
|
|
161
|
+
| `--retry` | `auto-escalate` | **Phase 3** — 체인 승격 (codex:5.4-mini → codex:gpt-5.5 → claude:sonnet → claude:opus) | retry-state-machine.mjs |
|
|
162
162
|
| `--isolation` | `none` (기본) | cwd 공유 | — |
|
|
163
163
|
| `--isolation` | `worktree` | shard별 `.codex-swarm/wt-*/` 격리 | `--parallel swarm` 자동 강제 |
|
|
164
164
|
| `--remote` | `none` (기본) | 로컬만 | — |
|
|
@@ -214,8 +214,8 @@ ARGUMENTS 에 아래 플래그가 있으면 Step 0 스마트 라우팅의 내부
|
|
|
214
214
|
- `--retry auto-escalate` 는 `DEFAULT_ESCALATION_CHAIN` 을 기본으로 사용한다. 커스텀 체인이 필요하면 `.claude/rules/tfx-escalation-chain.md` 로 override 한다.
|
|
215
215
|
|
|
216
216
|
`DEFAULT_ESCALATION_CHAIN`
|
|
217
|
-
1. codex : gpt-5-mini
|
|
218
|
-
2. codex : gpt-5
|
|
217
|
+
1. codex : gpt-5.4-mini
|
|
218
|
+
2. codex : gpt-5.5
|
|
219
219
|
3. claude : sonnet-4-6
|
|
220
220
|
4. claude : opus-4-7
|
|
221
221
|
|
|
@@ -45,7 +45,7 @@ options:
|
|
|
45
45
|
```
|
|
46
46
|
| 프로파일 | 모델 | Effort |
|
|
47
47
|
|----------|------|--------|
|
|
48
|
-
|
|
|
48
|
+
| gpt55_high | gpt-5.5 | high |
|
|
49
49
|
| ... | ... | ... |
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -75,8 +75,10 @@ options:
|
|
|
75
75
|
2. AskUserQuestion으로 모델 선택:
|
|
76
76
|
```
|
|
77
77
|
options:
|
|
78
|
-
- label: "gpt-5.
|
|
79
|
-
- label: "gpt-5.
|
|
78
|
+
- label: "gpt-5.5" → 최신 플래그십 (Recommended)
|
|
79
|
+
- label: "gpt-5.4" → 이전 플래그십
|
|
80
|
+
- label: "gpt-5.4-mini" → 경량 (mini)
|
|
81
|
+
- label: "gpt-5.3-codex" → 코딩 특화
|
|
80
82
|
- label: "gpt-5.1-codex-mini" → 경량 Spark
|
|
81
83
|
- label: "o3" → 추론 특화
|
|
82
84
|
- label: "o4-mini" → 추론 경량
|
|
@@ -183,7 +185,9 @@ options:
|
|
|
183
185
|
|
|
184
186
|
| 모델 | 용도 |
|
|
185
187
|
|------|------|
|
|
186
|
-
| gpt-5.
|
|
188
|
+
| gpt-5.5 | 최신 플래그십 (default) |
|
|
189
|
+
| gpt-5.4 | 이전 플래그십 |
|
|
190
|
+
| gpt-5.4-mini | 경량 (mini) |
|
|
187
191
|
| gpt-5.3-codex | 코딩 특화 |
|
|
188
192
|
| gpt-5.1-codex-mini | 경량 Spark |
|
|
189
193
|
| o3 | 추론 특화 |
|