triflux 4.1.4 → 4.2.1

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.
@@ -355,7 +355,7 @@ function readJson(filePath, fallback) {
355
355
  function writeJsonSafe(filePath, data) {
356
356
  try {
357
357
  mkdirSync(dirname(filePath), { recursive: true });
358
- writeFileSync(filePath, JSON.stringify(data));
358
+ writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 });
359
359
  } catch { /* 쓰기 실패 무시 */ }
360
360
  }
361
361
 
@@ -731,7 +731,16 @@ function readClaudeUsageSnapshot() {
731
731
  return { data: stripStaleResets(cache.data), shouldRefresh: ageMs >= backoffMs };
732
732
  }
733
733
  const isFresh = ageMs < getClaudeUsageStaleMs();
734
- return { data: cache.data, shouldRefresh: !isFresh };
734
+ // resets_at이 지난 윈도우의 percent를 0으로 보정 (stale 캐시 방지)
735
+ const data = { ...cache.data };
736
+ const now = Date.now();
737
+ if (data.fiveHourResetsAt && new Date(data.fiveHourResetsAt).getTime() <= now) {
738
+ data.fiveHourPercent = 0;
739
+ }
740
+ if (data.weeklyResetsAt && new Date(data.weeklyResetsAt).getTime() <= now) {
741
+ data.weeklyPercent = 0;
742
+ }
743
+ return { data, shouldRefresh: !isFresh };
735
744
  }
736
745
 
737
746
  // 2차: 에러 backoff — 최근 에러 시 재시도 억제 (무한 spawn 방지)
@@ -996,6 +1005,22 @@ function getGeminiEmail() {
996
1005
  } catch { return null; }
997
1006
  }
998
1007
 
1008
+ // resets_at이 지난 윈도우의 used_percent를 0으로 보정
1009
+ function expireStaleCodexBuckets(buckets) {
1010
+ if (!buckets) return buckets;
1011
+ const nowSec = Math.floor(Date.now() / 1000);
1012
+ for (const bucket of Object.values(buckets)) {
1013
+ if (!bucket) continue;
1014
+ if (bucket.primary?.resets_at && bucket.primary.resets_at <= nowSec) {
1015
+ bucket.primary.used_percent = 0;
1016
+ }
1017
+ if (bucket.secondary?.resets_at && bucket.secondary.resets_at <= nowSec) {
1018
+ bucket.secondary.used_percent = 0;
1019
+ }
1020
+ }
1021
+ return buckets;
1022
+ }
1023
+
999
1024
  // ============================================================================
1000
1025
  // Codex 세션 JSONL에서 실제 rate limits 추출
1001
1026
  // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
@@ -1061,6 +1086,7 @@ function getCodexRateLimits() {
1061
1086
  const main = mergedBuckets.codex || mergedBuckets[Object.keys(mergedBuckets)[0]];
1062
1087
  if (main && !main.tokens) main.tokens = syntheticBucket.tokens;
1063
1088
  }
1089
+ expireStaleCodexBuckets(mergedBuckets);
1064
1090
  return mergedBuckets;
1065
1091
  }
1066
1092
  }
@@ -1213,6 +1239,15 @@ function readGeminiQuotaSnapshot(accountId, authContext) {
1213
1239
  const isFresh = ageMs < GEMINI_QUOTA_STALE_MS;
1214
1240
 
1215
1241
  if (keyMatched) {
1242
+ // resetTime이 지난 버킷의 remainingFraction을 1로 보정 (stale 캐시 방지)
1243
+ if (Array.isArray(cache.buckets)) {
1244
+ const now = Date.now();
1245
+ for (const b of cache.buckets) {
1246
+ if (b?.resetTime && new Date(b.resetTime).getTime() <= now) {
1247
+ b.remainingFraction = 1;
1248
+ }
1249
+ }
1250
+ }
1216
1251
  return { quota: cache, shouldRefresh: !isFresh };
1217
1252
  }
1218
1253
  if (isLegacyCache) {
@@ -1250,6 +1285,7 @@ function readCodexRateLimitSnapshot() {
1250
1285
  if (!cache?.buckets) {
1251
1286
  return { buckets: null, shouldRefresh: true };
1252
1287
  }
1288
+ expireStaleCodexBuckets(cache.buckets);
1253
1289
  const ts = Number(cache.timestamp);
1254
1290
  const ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
1255
1291
  const isFresh = ageMs < CODEX_QUOTA_STALE_MS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "4.1.4",
3
+ "version": "4.2.1",
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": {
package/scripts/setup.mjs CHANGED
@@ -19,14 +19,14 @@ const REQUIRED_CODEX_PROFILES = [
19
19
  {
20
20
  name: "high",
21
21
  lines: [
22
- 'model = "gpt-5.4"',
22
+ 'model = "gpt-5.3-codex"',
23
23
  'model_reasoning_effort = "high"',
24
24
  ],
25
25
  },
26
26
  {
27
27
  name: "xhigh",
28
28
  lines: [
29
- 'model = "gpt-5.4"',
29
+ 'model = "gpt-5.3-codex"',
30
30
  'model_reasoning_effort = "xhigh"',
31
31
  ],
32
32
  },
@@ -147,6 +147,16 @@ function hasProfileSection(tomlContent, profileName) {
147
147
  return new RegExp(section, "m").test(tomlContent);
148
148
  }
149
149
 
150
+ function replaceProfileSection(tomlContent, profileName, lines) {
151
+ const header = `[profiles.${profileName}]`;
152
+ const sectionRe = new RegExp(
153
+ `^\\[profiles\\.${escapeRegExp(profileName)}\\]\\s*\\n(?:(?!\\[)[^\\n]*\\n?)*`,
154
+ "m",
155
+ );
156
+ const replacement = `${header}\n${lines.join("\n")}\n`;
157
+ return tomlContent.replace(sectionRe, replacement);
158
+ }
159
+
150
160
  function ensureCodexProfiles() {
151
161
  try {
152
162
  if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
@@ -156,27 +166,38 @@ function ensureCodexProfiles() {
156
166
  : "";
157
167
 
158
168
  let updated = original;
159
- let added = 0;
169
+ let changed = 0;
160
170
 
161
171
  for (const profile of REQUIRED_CODEX_PROFILES) {
162
- if (hasProfileSection(updated, profile.name)) continue;
163
-
164
- if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
165
- if (updated.trim().length > 0) updated += "\n";
166
- updated += `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
167
- added++;
172
+ const desired = `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
173
+
174
+ if (hasProfileSection(updated, profile.name)) {
175
+ // 기존 프로필이 있으면 강제 갱신
176
+ const before = updated;
177
+ updated = replaceProfileSection(updated, profile.name, profile.lines);
178
+ if (updated !== before) changed++;
179
+ } else {
180
+ // 없으면 추가
181
+ if (updated.length > 0 && !updated.endsWith("\n")) updated += "\n";
182
+ if (updated.trim().length > 0) updated += "\n";
183
+ updated += desired;
184
+ changed++;
185
+ }
168
186
  }
169
187
 
170
- if (added > 0) {
188
+ if (changed > 0) {
171
189
  writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
172
190
  }
173
191
 
174
- return added;
192
+ return changed;
175
193
  } catch {
176
194
  return 0;
177
195
  }
178
196
  }
179
197
 
198
+ export { replaceProfileSection, hasProfileSection };
199
+
200
+ async function main() {
180
201
  let synced = 0;
181
202
 
182
203
  for (const { src, dst, label } of SYNC_MAP) {
@@ -557,3 +578,9 @@ ${D}https://github.com/tellang/triflux${R}
557
578
  }
558
579
 
559
580
  process.exit(0);
581
+ }
582
+
583
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
584
+ main();
585
+ }
586
+
@@ -118,6 +118,36 @@ function filterCodexOutput(rawOutput) {
118
118
  return result.join("\n");
119
119
  }
120
120
 
121
+ function cleanTuiArtifacts(output, cliType) {
122
+ if (!output) return output;
123
+
124
+ const normalizedCliType = cliType || "";
125
+
126
+ let cleaned = output
127
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
128
+ .replace(/\x1b\][^\x07]*\x07/g, "")
129
+ .replace(/\x1b\[[0-9;]*[mGKHJsu]/g, "");
130
+
131
+ cleaned = cleaned.replace(/\r/g, "");
132
+
133
+ if (normalizedCliType.startsWith("codex")) {
134
+ cleaned = cleaned
135
+ .replace(/^[^\S\n]*[╭╮╰╯│─┌┐└┘├┤┬┴┼].*$/gm, "")
136
+ .replace(/^[^\S\n]*[›❯]\s*$/gm, "")
137
+ .replace(/^\s*codex\s*$/gm, "")
138
+ .replace(/^[^\S\n]*[›❯]\s*Applied.*$/gm, "");
139
+ } else if (normalizedCliType.startsWith("gemini")) {
140
+ cleaned = cleaned.replace(/^[^\S\n]*[╭╮╰╯│─═].*$/gm, "").replace(/^[^\S\n]*>\s*$/gm, "");
141
+ } else if (normalizedCliType.startsWith("claude")) {
142
+ cleaned = cleaned.replace(/^[^\S\n]*[━─]{5,}.*$/gm, "");
143
+ }
144
+
145
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
146
+ cleaned = cleaned.trim();
147
+
148
+ return cleaned;
149
+ }
150
+
121
151
  // ── 실행 로그 기록 (JSONL, append-only) ──
122
152
  function logExecution(params) {
123
153
  const logFile = join(LOG_DIR, "tfx-route-stats.jsonl");
@@ -311,7 +341,19 @@ function main() {
311
341
  }
312
342
 
313
343
  // 3. 실행 로그
314
- logExecution({ agent, cli: cliType, effort, run_mode: runMode, opus, status, exit_code: exitCode, elapsed, timeout, mcp_profile: mcpProfile, tokens });
344
+ logExecution({
345
+ agent,
346
+ cli: cliType,
347
+ effort,
348
+ run_mode: runMode,
349
+ opus,
350
+ status,
351
+ exit_code: exitCode,
352
+ elapsed,
353
+ timeout,
354
+ mcp_profile: mcpProfile,
355
+ tokens,
356
+ });
315
357
 
316
358
  // 4. 성공 시 토큰 누적
317
359
  if (exitCode === 0) accumulateTokens(cliType, tokens);
@@ -344,12 +386,19 @@ function main() {
344
386
  console.log("status: success");
345
387
  }
346
388
  console.log("=== OUTPUT ===");
347
- const filtered = cliType === "codex" ? filterCodexOutput(rawOutput) : rawOutput;
389
+ let filtered = cliType === "codex" ? filterCodexOutput(rawOutput) : rawOutput;
390
+ if (a.clean_tui !== "false" && process.env.TFX_CLEAN_TUI !== "0") {
391
+ filtered = cleanTuiArtifacts(filtered, cliType);
392
+ }
348
393
  console.log(truncateOutput(filtered, maxBytes));
349
394
  } else if (exitCode === 124) {
350
395
  console.log(`status: timeout (${timeout}s 초과)`);
351
396
  console.log("=== PARTIAL OUTPUT ===");
352
- console.log(truncateOutput(rawOutput, maxBytes));
397
+ let partialFiltered = rawOutput;
398
+ if (a.clean_tui !== "false" && process.env.TFX_CLEAN_TUI !== "0") {
399
+ partialFiltered = cleanTuiArtifacts(partialFiltered, cliType);
400
+ }
401
+ console.log(truncateOutput(partialFiltered, maxBytes));
353
402
  console.log("=== STDERR ===");
354
403
  console.log(stderrContent.split("\n").slice(-10).join("\n"));
355
404
  } else {
@@ -358,9 +407,18 @@ function main() {
358
407
  console.log(stderrContent.split("\n").slice(-20).join("\n"));
359
408
  if (rawOutput) {
360
409
  console.log("=== PARTIAL OUTPUT ===");
361
- console.log(truncateOutput(rawOutput, maxBytes));
410
+ let partialFiltered = rawOutput;
411
+ if (a.clean_tui !== "false" && process.env.TFX_CLEAN_TUI !== "0") {
412
+ partialFiltered = cleanTuiArtifacts(partialFiltered, cliType);
413
+ }
414
+ console.log(truncateOutput(partialFiltered, maxBytes));
362
415
  }
363
416
  }
364
417
  }
365
418
 
366
- main();
419
+ import { fileURLToPath } from "url";
420
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
421
+ main();
422
+ }
423
+
424
+ export { cleanTuiArtifacts };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # tfx-route.sh v2.3 — CLI 라우팅 래퍼 (triflux)
2
+ # tfx-route.sh v2.4 — CLI 라우팅 래퍼 (triflux)
3
3
  #
4
4
  # v1.x: cli-route.sh (jq+python3+node 혼재, 동기 후처리 ~1s)
5
5
  # v2.0: tfx-route.sh 리네임
@@ -9,7 +9,7 @@
9
9
  # - Gemini health check 지수 백오프 (30×1s → 5×exp)
10
10
  # - 컨텍스트 파일 5번째 인자 지원
11
11
  #
12
- VERSION="2.3"
12
+ VERSION="2.4"
13
13
  #
14
14
  # 사용법:
15
15
  # tfx-route.sh <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
@@ -417,6 +417,54 @@ RESULT_EOF
417
417
  fi
418
418
  }
419
419
 
420
+ detect_quota_exceeded() {
421
+ local stdout_file="$1"
422
+ local stderr_file="$2"
423
+ local -a patterns=(
424
+ "usage limit exceeded" "rate limit exceeded" "rate limit reached"
425
+ "try again at" "purchase more credits"
426
+ "quota exceeded" "RESOURCE_EXHAUSTED" "rateLimitExceeded" "Too Many Requests"
427
+ "rate_limit_error" "overloaded_error" "insufficient_quota"
428
+ )
429
+ local pattern
430
+ for pattern in "${patterns[@]}"; do
431
+ if grep -qi "$pattern" "$stdout_file" 2>/dev/null || grep -qi "$pattern" "$stderr_file" 2>/dev/null; then
432
+ echo "[tfx-quota] 감지: '$pattern' in $CLI_TYPE" >&2
433
+ return 0
434
+ fi
435
+ done
436
+ return 1
437
+ }
438
+
439
+ auto_reroute() {
440
+ local failed_cli="$1"
441
+ local target_cli=""
442
+ case "$failed_cli" in
443
+ codex) target_cli="gemini"; echo "[tfx-quota] Codex → Gemini 자동 전환" >&2 ;;
444
+ gemini) target_cli="codex"; echo "[tfx-quota] Gemini → Codex 자동 전환" >&2 ;;
445
+ *) echo "[tfx-quota] $failed_cli 대체 CLI 없음" >&2; return 1 ;;
446
+ esac
447
+
448
+ # 대상 CLI 존재 확인 (P2: command not found 방지)
449
+ local target_bin
450
+ case "$target_cli" in
451
+ codex) target_bin="$CODEX_BIN" ;;
452
+ gemini) target_bin="$GEMINI_BIN" ;;
453
+ esac
454
+ if ! command -v "$target_bin" &>/dev/null; then
455
+ echo "[tfx-quota] $target_cli CLI 미설치 — 자동 전환 불가" >&2
456
+ return 1
457
+ fi
458
+
459
+ local quota_marker="$TFX_TMP/tfx-quota-${failed_cli}-$(date +%Y%m%d)"
460
+ echo "$(date +%s)" >> "$quota_marker"
461
+ ORIGINAL_AGENT="$AGENT_TYPE"
462
+ ORIGINAL_CLI_ARGS="$CLI_ARGS"
463
+ export TFX_REROUTED_FROM="$CLI_TYPE"
464
+ TFX_CLI_MODE="$target_cli" exec bash "${BASH_SOURCE[0]}" \
465
+ "$AGENT_TYPE" "$PROMPT" "$MCP_PROFILE" "$USER_TIMEOUT" "$CONTEXT_FILE"
466
+ }
467
+
420
468
  capture_workspace_signature() {
421
469
  if ! command -v git &>/dev/null; then
422
470
  return 1
@@ -543,7 +591,7 @@ route_agent() {
543
591
  TFX_CLI_MODE="${TFX_CLI_MODE:-auto}"
544
592
  TFX_NO_CLAUDE_NATIVE="${TFX_NO_CLAUDE_NATIVE:-0}"
545
593
  TFX_VERIFIER_OVERRIDE="${TFX_VERIFIER_OVERRIDE:-auto}"
546
- TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-auto}"
594
+ TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-exec}"
547
595
  # Codex 요금제 자동 감지 (preflight 캐시 → auth.json JWT)
548
596
  # 환경변수 명시 설정 시 우선, 미설정 시 캐시에서 읽기, 캐시도 없으면 pro
549
597
  if [[ -z "${TFX_CODEX_PLAN:-}" ]]; then
@@ -845,6 +893,44 @@ emit_claude_native_metadata() {
845
893
  echo "--- Claude Task($model) 에이전트로 위임하세요 ---"
846
894
  }
847
895
 
896
+ # heartbeat_monitor PID [INTERVAL] [STALL_THRESHOLD]
897
+ # - PID: 감시할 워커 프로세스 PID
898
+ # - INTERVAL: heartbeat 출력 간격 (초, 기본 10)
899
+ # - STALL_THRESHOLD: stall 경고 임계값 (초, 기본 60)
900
+ # 환경변수: TFX_HEARTBEAT (0이면 비활성화), TFX_HEARTBEAT_INTERVAL, TFX_STALL_THRESHOLD
901
+ heartbeat_monitor() {
902
+ [[ "${TFX_HEARTBEAT:-1}" -eq 0 ]] && return 0
903
+ local pid="$1"
904
+ local interval="${2:-${TFX_HEARTBEAT_INTERVAL:-10}}"
905
+ local stall_threshold="${3:-${TFX_STALL_THRESHOLD:-60}}"
906
+ local last_size=0 stall_count=0
907
+
908
+ while kill -0 "$pid" 2>/dev/null; do
909
+ sleep "$interval"
910
+ local current_size=0
911
+ [[ -f "$STDOUT_LOG" ]] && current_size=$(wc -c < "$STDOUT_LOG" 2>/dev/null || echo 0)
912
+ # P3: stderr 활동도 포함하여 거짓 STALL 방지
913
+ local stderr_size=0
914
+ [[ -f "$STDERR_LOG" ]] && stderr_size=$(wc -c < "$STDERR_LOG" 2>/dev/null || echo 0)
915
+ current_size=$((current_size + stderr_size))
916
+ local elapsed=$(($(date +%s) - TIMESTAMP))
917
+
918
+ if [[ "$current_size" -gt "$last_size" ]]; then
919
+ stall_count=0
920
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=active" >&2
921
+ else
922
+ stall_count=$((stall_count + interval))
923
+ if [[ "$stall_count" -ge "$stall_threshold" ]]; then
924
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL stall=${stall_count}s" >&2
925
+ else
926
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=quiet stall=${stall_count}s" >&2
927
+ fi
928
+ fi
929
+ last_size=$current_size
930
+ done
931
+ echo "[tfx-heartbeat] pid=$pid terminated" >&2
932
+ }
933
+
848
934
  resolve_worker_runner_script() {
849
935
  if [[ -n "${TFX_ROUTE_WORKER_RUNNER:-}" && -f "$TFX_ROUTE_WORKER_RUNNER" ]]; then
850
936
  printf '%s\n' "$TFX_ROUTE_WORKER_RUNNER"
@@ -864,6 +950,8 @@ run_stream_worker() {
864
950
  local prompt="$2"
865
951
  local use_tee_flag="$3"
866
952
  shift 3
953
+ local exit_code_local=0
954
+ local worker_pid hb_pid
867
955
 
868
956
  local runner_script
869
957
  if ! runner_script=$(resolve_worker_runner_script); then
@@ -886,10 +974,18 @@ run_stream_worker() {
886
974
  )
887
975
 
888
976
  if [[ "$use_tee_flag" == "true" ]]; then
889
- printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG"
977
+ printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
890
978
  else
891
- printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG"
979
+ printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
892
980
  fi
981
+ worker_pid=$!
982
+
983
+ heartbeat_monitor "$worker_pid" &
984
+ hb_pid=$!
985
+
986
+ wait "$worker_pid" || exit_code_local=$?
987
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
988
+ return "$exit_code_local"
893
989
  }
894
990
 
895
991
  run_legacy_gemini() {
@@ -939,6 +1035,7 @@ run_legacy_gemini() {
939
1035
  done
940
1036
 
941
1037
  local exit_code_local=0
1038
+ local hb_pid
942
1039
  if [[ "$health_ok" == "false" ]]; then
943
1040
  wait "$pid" 2>/dev/null
944
1041
  echo "[tfx-route] Gemini crash 감지, 재시도 중..." >&2
@@ -950,7 +1047,10 @@ run_legacy_gemini() {
950
1047
  pid=$!
951
1048
  fi
952
1049
 
1050
+ heartbeat_monitor "$pid" &
1051
+ hb_pid=$!
953
1052
  wait "$pid" || exit_code_local=$?
1053
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
954
1054
  return "$exit_code_local"
955
1055
  }
956
1056
 
@@ -985,6 +1085,7 @@ run_codex_exec() {
985
1085
  local prompt="$1"
986
1086
  local use_tee_flag="$2"
987
1087
  local exit_code_local=0
1088
+ local worker_pid hb_pid
988
1089
  local -a codex_args=()
989
1090
  read -r -a codex_args <<< "$CLI_ARGS"
990
1091
  if [[ ${#CODEX_CONFIG_FLAGS[@]} -gt 0 ]]; then
@@ -992,10 +1093,17 @@ run_codex_exec() {
992
1093
  fi
993
1094
 
994
1095
  if [[ "$use_tee_flag" == "true" ]]; then
995
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" || exit_code_local=$?
1096
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
996
1097
  else
997
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" || exit_code_local=$?
1098
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
998
1099
  fi
1100
+ worker_pid=$!
1101
+
1102
+ heartbeat_monitor "$worker_pid" &
1103
+ hb_pid=$!
1104
+
1105
+ wait "$worker_pid" || exit_code_local=$?
1106
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
999
1107
 
1000
1108
  if [[ ! -s "$STDOUT_LOG" && -s "$STDERR_LOG" ]]; then
1001
1109
  # stderr에서 마지막 "codex" 마커 이후의 텍스트를 stdout으로 복구
@@ -1029,6 +1137,7 @@ run_codex_mcp() {
1029
1137
  local use_tee_flag="$2"
1030
1138
  local mcp_script node_bin
1031
1139
  local exit_code_local=0
1140
+ local worker_pid hb_pid
1032
1141
 
1033
1142
  if ! mcp_script=$(resolve_codex_mcp_script); then
1034
1143
  echo "[tfx-route] 경고: Codex MCP 래퍼를 찾지 못했습니다." >&2
@@ -1078,10 +1187,17 @@ run_codex_mcp() {
1078
1187
  esac
1079
1188
 
1080
1189
  if [[ "$use_tee_flag" == "true" ]]; then
1081
- timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" || exit_code_local=$?
1190
+ timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1082
1191
  else
1083
- timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" || exit_code_local=$?
1192
+ timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1084
1193
  fi
1194
+ worker_pid=$!
1195
+
1196
+ heartbeat_monitor "$worker_pid" &
1197
+ hb_pid=$!
1198
+
1199
+ wait "$worker_pid" || exit_code_local=$?
1200
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1085
1201
 
1086
1202
  # 모듈 로드 실패(의존성 누락) → MCP transport exit code로 변환하여 fallback 트리거
1087
1203
  if [[ "$exit_code_local" -ne 0 && "$exit_code_local" -ne 124 ]] && grep -q 'ERR_MODULE_NOT_FOUND' "$STDERR_LOG" 2>/dev/null; then
@@ -1197,6 +1313,7 @@ FALLBACK_EOF
1197
1313
  echo "[tfx-route] codex_transport_request=$TFX_CODEX_TRANSPORT" >&2
1198
1314
  fi
1199
1315
  [[ -n "$TFX_TEAM_NAME" ]] && echo "[tfx-route] team=$TFX_TEAM_NAME task=$TFX_TEAM_TASK_ID agent=$TFX_TEAM_AGENT_NAME" >&2
1316
+ [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "[tfx-route] rerouted_from=$TFX_REROUTED_FROM" >&2
1200
1317
 
1201
1318
  # Per-process 에이전트 등록
1202
1319
  register_agent
@@ -1327,6 +1444,14 @@ EOF
1327
1444
  fi
1328
1445
  fi
1329
1446
 
1447
+ # 쿼타 감지 + 자동 re-route
1448
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1449
+ if [[ "${TFX_QUOTA_REROUTE:-1}" -ne 0 ]] && [[ -z "${TFX_REROUTED_FROM:-}" ]] && detect_quota_exceeded "$STDOUT_LOG" "$STDERR_LOG"; then
1450
+ export TFX_REROUTED_FROM="$CLI_TYPE"
1451
+ auto_reroute "$CLI_TYPE"
1452
+ fi
1453
+ fi
1454
+
1330
1455
  # 팀 모드: task complete + 리드 보고
1331
1456
  if [[ -n "$TFX_TEAM_NAME" ]]; then
1332
1457
  if [[ "$exit_code" -eq 0 ]]; then
@@ -1359,18 +1484,29 @@ EOF
1359
1484
  --mcp-profile "$MCP_PROFILE" \
1360
1485
  --stderr-log "$STDERR_LOG" \
1361
1486
  --stdout-log "$STDOUT_LOG" \
1487
+ --rerouted-from "${TFX_REROUTED_FROM:-}" \
1362
1488
  --max-bytes "$MAX_STDOUT_BYTES" \
1363
- --tee-active "$use_tee"
1489
+ --tee-active "$use_tee" \
1490
+ --clean-tui "${TFX_CLEAN_TUI:-true}"
1364
1491
  else
1365
1492
  # post.mjs 없으면 기본 출력 (fallback)
1366
1493
  echo "=== TFX-ROUTE RESULT ==="
1367
1494
  echo "agent: $AGENT_TYPE"
1368
1495
  echo "cli: $CLI_TYPE"
1496
+ [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "rerouted_from: $TFX_REROUTED_FROM"
1369
1497
  echo "exit_code: $exit_code"
1370
1498
  echo "elapsed: ${elapsed}s"
1371
1499
  echo "status: $([ $exit_code -eq 0 ] && echo success || echo failed)"
1372
1500
  echo "=== OUTPUT ==="
1373
- cat "$STDOUT_LOG" 2>/dev/null | head -c "$MAX_STDOUT_BYTES"
1501
+ if [[ "${TFX_CLEAN_TUI:-1}" != "0" ]]; then
1502
+ cat "$STDOUT_LOG" 2>/dev/null \
1503
+ | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
1504
+ | sed '/^[[:space:]]*[╭╮╰╯│─┌┐└┘├┤┬┴┼]/d' \
1505
+ | sed '/^[[:space:]]*[›❯][[:space:]]*$/d' \
1506
+ | head -c "$MAX_STDOUT_BYTES"
1507
+ else
1508
+ cat "$STDOUT_LOG" 2>/dev/null | head -c "$MAX_STDOUT_BYTES"
1509
+ fi
1374
1510
  fi
1375
1511
 
1376
1512
  return "$exit_code"