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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.13.10",
3
+ "version": "10.14.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.4"' },
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 getProjectMcpJsonPath(projectRoot) {
29
- if (typeof projectRoot === "string" && projectRoot.length > 0) {
30
- return join(projectRoot, ".claude", "mcp.json");
31
- }
32
- return join(process.cwd(), ".claude", "mcp.json");
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
- if (hubServer.url === hubUrl) {
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)} -> ${hubUrl}`,
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 outcome = await syncProjectMcpFile({
438
- filePath: getProjectMcpJsonPath(projectRoot),
439
- hubUrl,
440
- dryRun,
441
- logger,
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
- if (outcome.kind === "updated") {
445
- result.updated.push(outcome.path);
446
- } else if (outcome.kind === "skipped") {
447
- result.skipped.push(outcome.path);
448
- } else {
449
- result.errors.push({ path: outcome.path, reason: outcome.reason });
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;
@@ -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
- echo "[tfx-route] Codex MCP bootstrap 실패(exit=${exit_code}). exec fallback 비활성(stdin 블록 위험)." >&2
1896
- codex_transport_effective="mcp-failed"
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
@@ -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
- | fast | gpt-5.3-codex | low |
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.4" → 최신 플래그십
79
- - label: "gpt-5.3-codex" 코딩 특화 (Recommended)
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.4 | 최신 플래그십 |
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 | 추론 특화 |