triflux 7.1.3 → 7.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.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +31 -31
  2. package/.claude-plugin/plugin.json +22 -23
  3. package/bin/triflux.mjs +18 -5
  4. package/hooks/keyword-rules.json +393 -361
  5. package/hub/bridge.mjs +799 -786
  6. package/hub/delegator/contracts.mjs +37 -38
  7. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  8. package/hub/delegator/service.mjs +307 -302
  9. package/hub/intent.mjs +108 -11
  10. package/hub/lib/process-utils.mjs +20 -0
  11. package/hub/pipe.mjs +589 -589
  12. package/hub/pipeline/gates/confidence.mjs +1 -1
  13. package/hub/pipeline/gates/selfcheck.mjs +2 -4
  14. package/hub/pipeline/state.mjs +191 -187
  15. package/hub/pipeline/transitions.mjs +124 -120
  16. package/hub/public/dashboard.html +355 -349
  17. package/hub/quality/deslop.mjs +5 -3
  18. package/hub/reflexion.mjs +5 -1
  19. package/hub/research.mjs +6 -1
  20. package/hub/router.mjs +791 -782
  21. package/hub/server.mjs +893 -822
  22. package/hub/store.mjs +807 -778
  23. package/hub/team/agent-map.json +10 -0
  24. package/hub/team/ansi.mjs +3 -4
  25. package/hub/team/cli/commands/control.mjs +43 -43
  26. package/hub/team/cli/commands/interrupt.mjs +36 -36
  27. package/hub/team/cli/commands/kill.mjs +3 -3
  28. package/hub/team/cli/commands/send.mjs +37 -37
  29. package/hub/team/cli/commands/start/index.mjs +18 -8
  30. package/hub/team/cli/commands/start/parse-args.mjs +3 -1
  31. package/hub/team/cli/commands/start/start-headless.mjs +4 -1
  32. package/hub/team/cli/commands/status.mjs +87 -87
  33. package/hub/team/cli/commands/stop.mjs +1 -1
  34. package/hub/team/cli/commands/task.mjs +1 -1
  35. package/hub/team/cli/index.mjs +41 -39
  36. package/hub/team/cli/manifest.mjs +29 -28
  37. package/hub/team/cli/services/hub-client.mjs +37 -0
  38. package/hub/team/cli/services/state-store.mjs +26 -12
  39. package/hub/team/dashboard.mjs +11 -4
  40. package/hub/team/handoff.mjs +12 -0
  41. package/hub/team/headless.mjs +202 -200
  42. package/hub/team/native-supervisor.mjs +386 -346
  43. package/hub/team/nativeProxy.mjs +680 -692
  44. package/hub/team/staleState.mjs +361 -369
  45. package/hub/team/tui-viewer.mjs +27 -3
  46. package/hub/team/tui.mjs +1 -0
  47. package/hub/token-mode.mjs +114 -24
  48. package/hub/workers/delegator-mcp.mjs +1059 -1057
  49. package/hud/colors.mjs +88 -0
  50. package/hud/constants.mjs +78 -0
  51. package/hud/hud-qos-status.mjs +206 -1872
  52. package/hud/providers/claude.mjs +309 -0
  53. package/hud/providers/codex.mjs +151 -0
  54. package/hud/providers/gemini.mjs +320 -0
  55. package/hud/renderers.mjs +424 -0
  56. package/hud/terminal.mjs +140 -0
  57. package/hud/utils.mjs +271 -0
  58. package/package.json +1 -2
  59. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  60. package/scripts/headless-guard-fast.sh +21 -0
  61. package/scripts/headless-guard.mjs +26 -6
  62. package/scripts/lib/keyword-rules.mjs +166 -168
  63. package/scripts/setup.mjs +720 -690
  64. package/scripts/tfx-route-post.mjs +424 -424
  65. package/scripts/tfx-route.sh +1663 -1650
  66. package/scripts/tmp-cleanup.mjs +74 -0
  67. package/skills/tfx-auto/SKILL.md +279 -278
  68. package/skills/tfx-auto-codex/SKILL.md +98 -77
  69. package/skills/tfx-codex/SKILL.md +65 -65
  70. package/skills/tfx-gemini/SKILL.md +83 -82
  71. package/skills/tfx-hub/SKILL.md +205 -136
  72. package/skills/tfx-multi/SKILL.md +11 -5
  73. package/.mcp.json +0 -8
@@ -1,1650 +1,1663 @@
1
- #!/usr/bin/env bash
2
- # tfx-route.sh v2.4 — CLI 라우팅 래퍼 (triflux)
3
- #
4
- # v1.x: cli-route.sh (jq+python3+node 혼재, 동기 후처리 ~1s)
5
- # v2.0: tfx-route.sh 리네임
6
- # - 후처리 전부 tfx-route-post.mjs로 이관 (node 단일 ~100ms)
7
- # - per-process 에이전트 등록 (race condition 구조적 제거)
8
- # - get_mcp_hint 통합 (캐시/비캐시 단일 코드경로)
9
- # - Gemini health check 지수 백오프 (30×1s → 5×exp)
10
- # - 컨텍스트 파일 5번째 인자 지원
11
- #
12
- VERSION="2.5"
13
- #
14
- # 사용법:
15
- # tfx-route.sh <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
16
- # tfx-route.sh --async <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
17
- # tfx-route.sh --job-status <job_id>
18
- # tfx-route.sh --job-result <job_id>
19
- #
20
- # --async: 백그라운드 실행, 즉시 job_id 반환 (Claude Code Bash 600초 제한 우회)
21
- # --job-status: running | done | timeout | failed
22
- # --job-result: 완료된 잡의 전체 출력
23
- #
24
- # 예시:
25
- # tfx-route.sh executor "코드 구현" implement
26
- # tfx-route.sh --async scientist "딥 리서치" auto 1440
27
- # tfx-route.sh --job-status 1742400000-12345-9876
28
- # tfx-route.sh --job-result 1742400000-12345-9876
29
-
30
- set -euo pipefail
31
-
32
- # ── Async Job 디렉토리 ──
33
- TFX_JOBS_DIR="${TMPDIR:-/tmp}/tfx-jobs"
34
-
35
- # ── --job-status / --job-result 핸들러 (인자 파싱 전에 처리) ──
36
- if [[ "${1:-}" == "--job-status" ]]; then
37
- job_id="${2:?job_id 필수}"
38
- job_dir="$TFX_JOBS_DIR/$job_id"
39
- [[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
40
-
41
- if [[ -f "$job_dir/done" ]]; then
42
- exit_code=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
43
- if [[ "$exit_code" -eq 0 ]]; then
44
- echo "done"
45
- elif [[ "$exit_code" -eq 124 ]]; then
46
- echo "timeout"
47
- else
48
- echo "failed"
49
- fi
50
- elif [[ -f "$job_dir/pid" ]]; then
51
- pid=$(cat "$job_dir/pid")
52
- if kill -0 "$pid" 2>/dev/null; then
53
- # 진행 상황 힌트
54
- local_bytes=$(wc -c < "$job_dir/stdout.log" 2>/dev/null || echo 0)
55
- elapsed=$(( $(date +%s) - $(cat "$job_dir/start_time" 2>/dev/null || date +%s) ))
56
- echo "running elapsed=${elapsed}s output=${local_bytes}B"
57
- else
58
- # 프로세스 종료됐는데 done 마커 없음 → 비정상 종료
59
- echo "failed"
60
- fi
61
- else
62
- echo "error: invalid job state"
63
- exit 1
64
- fi
65
- exit 0
66
- fi
67
-
68
- if [[ "${1:-}" == "--job-result" ]]; then
69
- job_id="${2:?job_id 필수}"
70
- job_dir="$TFX_JOBS_DIR/$job_id"
71
- [[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
72
- [[ -f "$job_dir/done" ]] || { echo "error: job still running"; exit 1; }
73
-
74
- cat "$job_dir/result.log" 2>/dev/null
75
- exit_code=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
76
- exit "$exit_code"
77
- fi
78
-
79
- # ── --job-wait: 내부 폴링으로 완료 대기 (Bash 도구 호출 횟수 최소화) ──
80
- # 사용법: tfx-route.sh --job-wait <job_id> [max_seconds=540]
81
- # 출력: 주기적 "waiting elapsed=Ns" + 최종 "done"|"timeout"|"failed"|"still_running"
82
- if [[ "${1:-}" == "--job-wait" ]]; then
83
- job_id="${2:?job_id 필수}"
84
- max_wait="${3:-540}" # 기본 540초 (9분, Bash 도구 600초 제한 이내)
85
- poll_interval=15
86
- job_dir="$TFX_JOBS_DIR/$job_id"
87
- [[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
88
-
89
- elapsed=0
90
- while [[ "$elapsed" -lt "$max_wait" ]]; do
91
- if [[ -f "$job_dir/done" ]]; then
92
- ec=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
93
- if [[ "$ec" -eq 0 ]]; then echo "done"
94
- elif [[ "$ec" -eq 124 ]]; then echo "timeout"
95
- else echo "failed (exit=$ec)"
96
- fi
97
- exit 0
98
- fi
99
- sleep "$poll_interval"
100
- elapsed=$((elapsed + poll_interval))
101
- stderr_bytes=$(wc -c < "$job_dir/stderr.log" 2>/dev/null || echo 0)
102
- echo "waiting elapsed=${elapsed}s progress=${stderr_bytes}B"
103
- done
104
-
105
- # max_wait 도달했지만 아직 실행 중
106
- echo "still_running elapsed=${elapsed}s"
107
- exit 0
108
- fi
109
-
110
- # ── --async 플래그 감지 ──
111
- TFX_ASYNC_MODE=0
112
- if [[ "${1:-}" == "--async" ]]; then
113
- TFX_ASYNC_MODE=1
114
- shift
115
- fi
116
-
117
- # ── 인자 파싱 ──
118
- AGENT_TYPE="${1:?에이전트 타입 필수 (executor, debugger, designer 등)}"
119
- PROMPT="${2:?프롬프트 필수}"
120
- MCP_PROFILE="${3:-auto}"
121
- USER_TIMEOUT="${4:-}"
122
- CONTEXT_FILE="${5:-}"
123
-
124
- # ── CLI 이름은 route_agent()에서 기본 역할 alias로 처리됨 (codex→executor, gemini→designer, claude→explore) ──
125
-
126
- # ── 인자 검증: MCP_PROFILE이 --flag 형태인 경우 거절 ──
127
- if [[ "$MCP_PROFILE" == --* ]]; then
128
- echo "ERROR: MCP 프로필 위치(3번째 인자)에 플래그 '$MCP_PROFILE'가 들어왔습니다." >&2
129
- echo "사용법: tfx-route.sh <역할> \"프롬프트\" [mcp_profile] [timeout]" >&2
130
- echo "지원 프로필: auto, executor, analyze, implement, review, minimal, full" >&2
131
- exit 64
132
- fi
133
-
134
- # ── CLI 경로 해석 (Windows npm global 대응) ──
135
- NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
136
- CODEX_BIN="${CODEX_BIN:-$(command -v codex 2>/dev/null || echo codex)}"
137
- GEMINI_BIN="${GEMINI_BIN:-$(command -v gemini 2>/dev/null || echo gemini)}"
138
- CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude 2>/dev/null || echo claude)}"
139
- GEMINI_BIN_ARGS_JSON="${GEMINI_BIN_ARGS_JSON:-[]}"
140
- CLAUDE_BIN_ARGS_JSON="${CLAUDE_BIN_ARGS_JSON:-[]}"
141
-
142
- # ── 상수 ──
143
- MAX_STDOUT_BYTES=51200 # 50KB — Claude 컨텍스트 절약
144
- TIMESTAMP=$(date +%s)
145
- RUN_ID="${TIMESTAMP}-$$-${RANDOM}"
146
- STDERR_LOG="/tmp/tfx-route-${AGENT_TYPE}-${RUN_ID}-stderr.log"
147
- STDOUT_LOG="/tmp/tfx-route-${AGENT_TYPE}-${RUN_ID}-stdout.log"
148
- TFX_TMP="${TMPDIR:-/tmp}"
149
-
150
- # ── 팀 환경변수 ──
151
- TFX_TEAM_NAME="${TFX_TEAM_NAME:-}"
152
- TFX_TEAM_TASK_ID="${TFX_TEAM_TASK_ID:-}"
153
- TFX_TEAM_AGENT_NAME="${TFX_TEAM_AGENT_NAME:-${AGENT_TYPE}-worker-$$}"
154
- TFX_TEAM_LEAD_NAME="${TFX_TEAM_LEAD_NAME:-team-lead}"
155
- TFX_HUB_PIPE="${TFX_HUB_PIPE:-}"
156
- TFX_HUB_URL="${TFX_HUB_URL:-http://127.0.0.1:27888}" # bridge.mjs HTTP fallback hint
157
-
158
- # ── 패키지 루트 해석 (setup.mjs가 기록한 breadcrumb) ──
159
- TFX_PKG_ROOT=""
160
- _tfx_breadcrumb="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.tfx-pkg-root"
161
- if [[ -f "$_tfx_breadcrumb" ]]; then
162
- TFX_PKG_ROOT="$(head -1 "$_tfx_breadcrumb" 2>/dev/null | tr -d '\r\n')"
163
- fi
164
- unset _tfx_breadcrumb
165
-
166
- # fallback 시 원래 에이전트 정보 보존
167
- ORIGINAL_AGENT=""
168
- ORIGINAL_CLI_ARGS=""
169
-
170
- # JSON 문자열 이스케이프:
171
- # - "\", """ 필수 이스케이프
172
- # - 제어문자 U+0000..U+001F 이스케이프
173
- # - 비ASCII 문자는 \uXXXX(또는 surrogate pair)로 강제
174
- json_escape() {
175
- local s="${1:-}"
176
-
177
- if command -v "$NODE_BIN" &>/dev/null; then
178
- "$NODE_BIN" -e '
179
- const input = process.argv[1] ?? "";
180
- let out = "";
181
- for (const ch of input) {
182
- const cp = ch.codePointAt(0);
183
- if (cp === 0x22) { out += "\\\""; continue; } // "
184
- if (cp === 0x5c) { out += "\\\\"; continue; } // \
185
- if (cp <= 0x1f) {
186
- if (cp === 0x08) { out += "\\b"; continue; }
187
- if (cp === 0x09) { out += "\\t"; continue; }
188
- if (cp === 0x0a) { out += "\\n"; continue; }
189
- if (cp === 0x0c) { out += "\\f"; continue; }
190
- if (cp === 0x0d) { out += "\\r"; continue; }
191
- out += `\\u${cp.toString(16).padStart(4, "0")}`;
192
- continue;
193
- }
194
- if (cp >= 0x20 && cp <= 0x7e) {
195
- out += ch;
196
- continue;
197
- }
198
- if (cp <= 0xffff) {
199
- out += `\\u${cp.toString(16).padStart(4, "0")}`;
200
- continue;
201
- }
202
- const v = cp - 0x10000;
203
- const hi = 0xd800 + (v >> 10);
204
- const lo = 0xdc00 + (v & 0x3ff);
205
- out += `\\u${hi.toString(16).padStart(4, "0")}\\u${lo.toString(16).padStart(4, "0")}`;
206
- }
207
- process.stdout.write(out);
208
- ' -- "$s"
209
- return
210
- fi
211
-
212
- echo "[tfx-route] ERROR: node 미설치로 안전한 JSON 이스케이프를 수행할 수 없습니다." >&2
213
- return 1
214
- }
215
-
216
- # ── Per-process 에이전트 등록 (원자적, 락 불필요) ──
217
- register_agent() {
218
- local agent_file="${TFX_TMP}/tfx-agent-$$.json"
219
- local safe_cli safe_agent started_at
220
- safe_cli=$(json_escape "$CLI_TYPE" 2>/dev/null || true)
221
- safe_agent=$(json_escape "$AGENT_TYPE" 2>/dev/null || true)
222
- started_at=$(date +%s)
223
-
224
- # fail-closed: 안전 인코딩 불가 시 agent 파일을 쓰지 않는다
225
- if [[ -n "$CLI_TYPE" && -z "$safe_cli" ]]; then
226
- return 0
227
- fi
228
- if [[ -n "$AGENT_TYPE" && -z "$safe_agent" ]]; then
229
- return 0
230
- fi
231
-
232
- printf '{"pid":%s,"cli":"%s","agent":"%s","started":%s}\n' "$$" "$safe_cli" "$safe_agent" "$started_at" \
233
- > "$agent_file" 2>/dev/null || true
234
- }
235
-
236
- deregister_agent() {
237
- rm -f "${TFX_TMP}/tfx-agent-$$.json" 2>/dev/null || true
238
- }
239
-
240
- normalize_script_path() {
241
- local path="${1:-}"
242
- if [[ -z "$path" ]]; then
243
- return 0
244
- fi
245
-
246
- if command -v cygpath &>/dev/null; then
247
- case "$path" in
248
- [A-Za-z]:\\*|[A-Za-z]:/*)
249
- cygpath -u "$path"
250
- return 0
251
- ;;
252
- esac
253
- fi
254
-
255
- printf '%s\n' "$path"
256
- }
257
-
258
- # ── 팀 Hub Bridge 통신 ──
259
- resolve_bridge_script() {
260
- if [[ -n "${TFX_BRIDGE_SCRIPT:-}" && -f "$TFX_BRIDGE_SCRIPT" ]]; then
261
- printf '%s\n' "$TFX_BRIDGE_SCRIPT"
262
- return 0
263
- fi
264
-
265
- local script_dir
266
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
267
- local candidates=()
268
- [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/bridge.mjs")
269
- candidates+=(
270
- "$script_dir/../hub/bridge.mjs"
271
- "$script_dir/hub/bridge.mjs"
272
- )
273
-
274
- local candidate
275
- for candidate in "${candidates[@]}"; do
276
- if [[ -f "$candidate" ]]; then
277
- printf '%s\n' "$candidate"
278
- return 0
279
- fi
280
- done
281
-
282
- return 1
283
- }
284
-
285
- bridge_cli() {
286
- if ! command -v "$NODE_BIN" &>/dev/null; then
287
- return 127
288
- fi
289
-
290
- local bridge_script
291
- if ! bridge_script=$(resolve_bridge_script); then
292
- return 127
293
- fi
294
-
295
- TFX_HUB_PIPE="$TFX_HUB_PIPE" TFX_HUB_URL="$TFX_HUB_URL" TFX_HUB_TOKEN="${TFX_HUB_TOKEN:-}" \
296
- "$NODE_BIN" "$bridge_script" "$@" 2>/dev/null
297
- }
298
-
299
- bridge_json_get() {
300
- local json="${1:-}"
301
- local path="${2:-}"
302
- [[ -z "$json" || -z "$path" ]] && return 1
303
-
304
- "$NODE_BIN" -e '
305
- const data = JSON.parse(process.argv[1] || "{}");
306
- const keys = String(process.argv[2] || "").split(".").filter(Boolean);
307
- let value = data;
308
- for (const key of keys) value = value?.[key];
309
- if (value === undefined || value === null) process.exit(1);
310
- process.stdout.write(typeof value === "object" ? JSON.stringify(value) : String(value));
311
- ' -- "$json" "$path" 2>/dev/null
312
- }
313
-
314
- bridge_json_stringify() {
315
- local mode="${1:-}"
316
- shift || true
317
-
318
- case "$mode" in
319
- metadata-patch)
320
- "$NODE_BIN" -e '
321
- process.stdout.write(JSON.stringify({
322
- result: process.argv[1] || "",
323
- summary: process.argv[2] || "",
324
- }));
325
- ' -- "${1:-}" "${2:-}"
326
- ;;
327
- task-result)
328
- "$NODE_BIN" -e '
329
- process.stdout.write(JSON.stringify({
330
- task_id: process.argv[1] || "",
331
- result: process.argv[2] || "",
332
- }));
333
- ' -- "${1:-}" "${2:-}"
334
- ;;
335
- *)
336
- return 1
337
- ;;
338
- esac
339
- }
340
-
341
- team_send_message() {
342
- local text="${1:-}"
343
- local summary="${2:-}"
344
- [[ -z "$TFX_TEAM_NAME" || -z "$text" ]] && return 0
345
-
346
- if ! bridge_cli_with_restart "팀 메시지 전송" "Hub 재시작 후 팀 메시지 전송 성공." \
347
- team-send-message \
348
- --team "$TFX_TEAM_NAME" \
349
- --from "$TFX_TEAM_AGENT_NAME" \
350
- --to "$TFX_TEAM_LEAD_NAME" \
351
- --text "$text" \
352
- --summary "${summary:-status update}"; then
353
- echo "[tfx-route] 경고: 팀 메시지 전송 실패 (team=$TFX_TEAM_NAME, to=$TFX_TEAM_LEAD_NAME)" >&2
354
- return 0
355
- fi
356
-
357
- return 0
358
- }
359
-
360
- # ── Hub 자동 재시작 (슬립 복귀 등으로 Hub 종료 시) ──
361
- try_restart_hub() {
362
- local hub_server script_dir hub_port
363
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
364
- hub_server=""
365
- local _hub_candidates=()
366
- [[ -n "$TFX_PKG_ROOT" ]] && _hub_candidates+=("$TFX_PKG_ROOT/hub/server.mjs")
367
- _hub_candidates+=("$script_dir/../hub/server.mjs")
368
- for _hc in "${_hub_candidates[@]}"; do
369
- if [[ -f "$_hc" ]]; then hub_server="$_hc"; break; fi
370
- done
371
- unset _hub_candidates _hc
372
-
373
- if [[ -z "$hub_server" ]]; then
374
- echo "[tfx-route] Hub 서버 스크립트 미발견 (pkg_root=${TFX_PKG_ROOT:-unset}, script_dir=$script_dir)" >&2
375
- return 1
376
- fi
377
-
378
- # TFX_HUB_URL에서 포트 추출 (기본 27888)
379
- hub_port="${TFX_HUB_URL##*:}"
380
- hub_port="${hub_port%%/*}"
381
- [[ -z "$hub_port" || "$hub_port" == "$TFX_HUB_URL" ]] && hub_port=27888
382
-
383
- echo "[tfx-route] Hub 미응답 — 자동 재시작 시도 (port=$hub_port)..." >&2
384
- TFX_HUB_PORT="$hub_port" "$NODE_BIN" "$hub_server" &>/dev/null &
385
- local hub_pid=$!
386
-
387
- # 최대 4초 대기 (0.5초 간격)
388
- local i
389
- for i in 1 2 3 4 5 6 7 8; do
390
- sleep 0.5
391
- if curl -sf "${TFX_HUB_URL}/status" >/dev/null 2>&1; then
392
- echo "[tfx-route] Hub 재시작 성공 (pid=$hub_pid)" >&2
393
- return 0
394
- fi
395
- done
396
-
397
- echo "[tfx-route] Hub 재시작 실패 — claim 없이 계속 실행" >&2
398
- return 1
399
- }
400
-
401
- bridge_cli_with_restart() {
402
- local action_label="${1:-bridge 호출}"
403
- local success_message="${2:-}"
404
- shift 2 || true
405
-
406
- if bridge_cli "$@" >/dev/null 2>&1; then
407
- return 0
408
- fi
409
-
410
- if ! try_restart_hub; then
411
- return 1
412
- fi
413
-
414
- if bridge_cli "$@" >/dev/null 2>&1; then
415
- [[ -n "$success_message" ]] && echo "[tfx-route] ${success_message}" >&2
416
- return 0
417
- fi
418
-
419
- echo "[tfx-route] 경고: Hub 재시작 후 ${action_label} 재시도 실패." >&2
420
- return 1
421
- }
422
-
423
- team_claim_task() {
424
- [[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
425
- local response ok error_code error_message owner_before status_before
426
- response=$(bridge_cli team-task-update \
427
- --team "$TFX_TEAM_NAME" \
428
- --task-id "$TFX_TEAM_TASK_ID" \
429
- --claim \
430
- --owner "$TFX_TEAM_AGENT_NAME" \
431
- --status in_progress || true)
432
-
433
- ok=$(bridge_json_get "$response" "ok" || true)
434
- error_code=$(bridge_json_get "$response" "error.code" || true)
435
- error_message=$(bridge_json_get "$response" "error.message" || true)
436
- owner_before=$(bridge_json_get "$response" "error.details.task_before.owner" || true)
437
- status_before=$(bridge_json_get "$response" "error.details.task_before.status" || true)
438
-
439
- case "$ok:$error_code" in
440
- true:*) ;;
441
- false:CLAIM_CONFLICT)
442
- if [[ "$owner_before" == "$TFX_TEAM_AGENT_NAME" && "$status_before" == "in_progress" ]]; then
443
- echo "[tfx-route] 동일 owner(${TFX_TEAM_AGENT_NAME})가 이미 claim한 task ${TFX_TEAM_TASK_ID} — 계속 실행." >&2
444
- return 0
445
- fi
446
- echo "[tfx-route] CLAIM_CONFLICT: task ${TFX_TEAM_TASK_ID}가 이미 claim됨(owner=${owner_before:-unknown}, status=${status_before:-unknown}). 실행 중단." >&2
447
- team_send_message \
448
- "task ${TFX_TEAM_TASK_ID} claim conflict: owner=${owner_before:-unknown}, status=${status_before:-unknown}" \
449
- "task ${TFX_TEAM_TASK_ID} claim conflict"
450
- exit 0 ;;
451
- :|false:)
452
- # Hub 연결 실패 → 자동 재시작 시도 후 claim 재시도
453
- if try_restart_hub; then
454
- response=$(bridge_cli team-task-update \
455
- --team "$TFX_TEAM_NAME" \
456
- --task-id "$TFX_TEAM_TASK_ID" \
457
- --claim \
458
- --owner "$TFX_TEAM_AGENT_NAME" \
459
- --status in_progress || true)
460
- ok=$(bridge_json_get "$response" "ok" || true)
461
- if [[ "$ok" == "true" ]]; then
462
- echo "[tfx-route] Hub 재시작 후 claim 성공." >&2
463
- else
464
- echo "[tfx-route] 경고: Hub 재시작 후 claim 실패. claim 없이 계속 실행." >&2
465
- fi
466
- else
467
- echo "[tfx-route] 경고: Hub 연결 실패 (미실행?). claim 없이 계속 실행." >&2
468
- fi ;;
469
- *)
470
- echo "[tfx-route] 경고: Hub claim 실패 (${error_code:-unknown}${error_message:+: ${error_message}}). claim 없이 계속 실행." >&2 ;;
471
- esac
472
- }
473
-
474
- team_complete_task() {
475
- local result="${1:-success}" # success/failed/timeout
476
- local result_summary="${2:-작업 완료}"
477
- [[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
478
-
479
- local summary_trimmed result_payload
480
- summary_trimmed=$(echo "$result_summary" | head -c 4096)
481
- result_payload=$(bridge_json_stringify task-result "$TFX_TEAM_TASK_ID" "$result" 2>/dev/null || true)
482
-
483
- # task 파일 completion 쓰기는 Worker Step 6 TaskUpdate가 authority다.
484
- # route 레벨에서는 task.result 발행 + 로컬 backup만 유지한다.
485
-
486
- # Hub result 발행 (poll_messages 채널 활성화)
487
- if [[ -n "$result_payload" ]]; then
488
- if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
489
- result \
490
- --agent "$TFX_TEAM_AGENT_NAME" \
491
- --topic task.result \
492
- --payload "$result_payload" \
493
- --trace "$TFX_TEAM_NAME"; then
494
- echo "[tfx-route] 경고: Hub result 발행 실패 (agent=$TFX_TEAM_AGENT_NAME, task=$TFX_TEAM_TASK_ID)" >&2
495
- fi
496
- fi
497
-
498
- # 로컬 결과 파일 백업 (세션 끊김 복구용)
499
- # Claude 재로그인 시 Agent 래퍼가 죽어도 이 파일로 결과 수집 가능
500
- local result_dir="${TFX_RESULT_DIR:-${HOME}/.claude/tfx-results/${TFX_TEAM_NAME}}"
501
- if mkdir -p "$result_dir" 2>/dev/null; then
502
- cat > "${result_dir}/${TFX_TEAM_TASK_ID}.json" 2>/dev/null <<RESULT_EOF
503
- {"taskId":"${TFX_TEAM_TASK_ID}","agent":"${TFX_TEAM_AGENT_NAME}","team":"${TFX_TEAM_NAME}","result":"${result}","summary":$(printf '%s' "$summary_trimmed" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.stringify(d)))" 2>/dev/null || echo '""'),"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
504
- RESULT_EOF
505
- [[ $? -eq 0 ]] && echo "[tfx-route] 결과 백업: ${result_dir}/${TFX_TEAM_TASK_ID}.json" >&2
506
- fi
507
- }
508
-
509
- detect_quota_exceeded() {
510
- local stdout_file="$1"
511
- local stderr_file="$2"
512
- local -a patterns=(
513
- "usage limit exceeded" "rate limit exceeded" "rate limit reached"
514
- "try again at" "purchase more credits"
515
- "quota exceeded" "RESOURCE_EXHAUSTED" "rateLimitExceeded" "Too Many Requests"
516
- "rate_limit_error" "overloaded_error" "insufficient_quota"
517
- )
518
- local pattern
519
- for pattern in "${patterns[@]}"; do
520
- if grep -qi "$pattern" "$stdout_file" 2>/dev/null || grep -qi "$pattern" "$stderr_file" 2>/dev/null; then
521
- echo "[tfx-quota] 감지: '$pattern' in $CLI_TYPE" >&2
522
- return 0
523
- fi
524
- done
525
- return 1
526
- }
527
-
528
- auto_reroute() {
529
- local failed_cli="$1"
530
- local target_cli=""
531
- case "$failed_cli" in
532
- codex) target_cli="gemini"; echo "[tfx-quota] Codex → Gemini 자동 전환" >&2 ;;
533
- gemini) target_cli="codex"; echo "[tfx-quota] Gemini → Codex 자동 전환" >&2 ;;
534
- *) echo "[tfx-quota] $failed_cli 대체 CLI 없음" >&2; return 1 ;;
535
- esac
536
-
537
- # 대상 CLI 존재 확인 (P2: command not found 방지)
538
- local target_bin
539
- case "$target_cli" in
540
- codex) target_bin="$CODEX_BIN" ;;
541
- gemini) target_bin="$GEMINI_BIN" ;;
542
- esac
543
- if ! command -v "$target_bin" &>/dev/null; then
544
- echo "[tfx-quota] $target_cli CLI 미설치 — 자동 전환 불가" >&2
545
- return 1
546
- fi
547
-
548
- local quota_marker="$TFX_TMP/tfx-quota-${failed_cli}-$(date +%Y%m%d)"
549
- echo "$(date +%s)" >> "$quota_marker"
550
- ORIGINAL_AGENT="$AGENT_TYPE"
551
- ORIGINAL_CLI_ARGS="$CLI_ARGS"
552
- export TFX_REROUTED_FROM="$CLI_TYPE"
553
- TFX_CLI_MODE="$target_cli" exec bash "${BASH_SOURCE[0]}" \
554
- "$AGENT_TYPE" "$PROMPT" "$MCP_PROFILE" "$USER_TIMEOUT" "$CONTEXT_FILE"
555
- }
556
-
557
- capture_workspace_signature() {
558
- if ! command -v git &>/dev/null; then
559
- return 1
560
- fi
561
-
562
- if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
563
- return 1
564
- fi
565
-
566
- git status --short --untracked-files=all --ignore-submodules=all 2>/dev/null || return 1
567
- }
568
-
569
- # ── 라우팅 테이블 ──
570
- # 반환: CLI_CMD, CLI_ARGS, CLI_TYPE, CLI_EFFORT, DEFAULT_TIMEOUT, RUN_MODE, OPUS_OVERSIGHT
571
- route_agent() {
572
- local agent="$1"
573
- local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
574
-
575
- case "$agent" in
576
- # ─── 구현 레인 ───
577
- executor)
578
- CLI_TYPE="codex"; CLI_CMD="codex"
579
- CLI_ARGS="exec ${codex_base}"
580
- CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
581
- build-fixer)
582
- CLI_TYPE="codex"; CLI_CMD="codex"
583
- CLI_ARGS="exec --profile fast ${codex_base}"
584
- CLI_EFFORT="fast"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
585
- debugger)
586
- CLI_TYPE="codex"; CLI_CMD="codex"
587
- CLI_ARGS="exec ${codex_base}"
588
- CLI_EFFORT="high"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
589
- deep-executor)
590
- CLI_TYPE="codex"; CLI_CMD="codex"
591
- CLI_ARGS="exec --profile xhigh ${codex_base}"
592
- CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
593
-
594
- # ─── 설계/분석 레인 ───
595
- architect)
596
- CLI_TYPE="codex"; CLI_CMD="codex"
597
- CLI_ARGS="exec --profile xhigh ${codex_base}"
598
- CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
599
- planner)
600
- CLI_TYPE="codex"; CLI_CMD="codex"
601
- CLI_ARGS="exec --profile xhigh ${codex_base}"
602
- CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
603
- critic)
604
- CLI_TYPE="codex"; CLI_CMD="codex"
605
- CLI_ARGS="exec --profile xhigh ${codex_base}"
606
- CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
607
- analyst)
608
- CLI_TYPE="codex"; CLI_CMD="codex"
609
- CLI_ARGS="exec --profile xhigh ${codex_base}"
610
- CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
611
-
612
- # ─── 리뷰 레인 ───
613
- code-reviewer)
614
- CLI_TYPE="codex"; CLI_CMD="codex"
615
- CLI_ARGS="exec --profile thorough ${codex_base} review"
616
- CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
617
- security-reviewer)
618
- CLI_TYPE="codex"; CLI_CMD="codex"
619
- CLI_ARGS="exec --profile thorough ${codex_base} review"
620
- CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
621
- quality-reviewer)
622
- CLI_TYPE="codex"; CLI_CMD="codex"
623
- CLI_ARGS="exec --profile thorough ${codex_base} review"
624
- CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
625
-
626
- # ─── 리서치 레인 ───
627
- scientist)
628
- CLI_TYPE="codex"; CLI_CMD="codex"
629
- CLI_ARGS="exec ${codex_base}"
630
- CLI_EFFORT="high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
631
- scientist-deep)
632
- CLI_TYPE="codex"; CLI_CMD="codex"
633
- CLI_ARGS="exec --profile thorough ${codex_base}"
634
- CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
635
- document-specialist)
636
- CLI_TYPE="codex"; CLI_CMD="codex"
637
- CLI_ARGS="exec ${codex_base}"
638
- CLI_EFFORT="high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
639
-
640
- # ─── UI/문서 레인 ───
641
- designer)
642
- CLI_TYPE="gemini"; CLI_CMD="gemini"
643
- CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
644
- CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
645
- writer)
646
- CLI_TYPE="gemini"; CLI_CMD="gemini"
647
- CLI_ARGS="-m gemini-3-flash-preview -y --prompt"
648
- CLI_EFFORT="flash"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
649
-
650
- # ─── 탐색/검증/테스트 (Claude-native 우선, TFX_NO_CLAUDE_NATIVE=1일 때만 Codex 리매핑) ───
651
- explore|verifier|test-engineer|qa-tester)
652
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
653
- CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
654
- case "$agent" in
655
- test-engineer|qa-tester) DEFAULT_TIMEOUT=1200; RUN_MODE="bg" ;;
656
- esac
657
- ;;
658
-
659
- # ─── 경량 ───
660
- spark)
661
- CLI_TYPE="codex"; CLI_CMD="codex"
662
- CLI_ARGS="exec --profile spark_fast ${codex_base}"
663
- CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
664
- # ─── CLI 이름 alias (사용자 편의) ───
665
- codex)
666
- CLI_TYPE="codex"; CLI_CMD="codex"
667
- CLI_ARGS="exec ${codex_base}"
668
- CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
669
- gemini)
670
- CLI_TYPE="gemini"; CLI_CMD="gemini"
671
- CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
672
- CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
673
- claude)
674
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
675
- CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
676
- *)
677
- echo "ERROR: 알 수 없는 에이전트 타입: $agent" >&2
678
- echo "사용 가능: executor, build-fixer, debugger, deep-executor, architect, planner, critic, analyst," >&2
679
- echo " code-reviewer, security-reviewer, quality-reviewer, scientist, document-specialist," >&2
680
- echo " designer, writer, explore, verifier, test-engineer, qa-tester, spark," >&2
681
- echo " codex, gemini, claude (CLI alias)" >&2
682
- exit 1 ;;
683
- esac
684
- }
685
-
686
- # ── CLI 모드 오버라이드 (tfx-codex / tfx-gemini 스킬용) ──
687
- TFX_CLI_MODE="${TFX_CLI_MODE:-auto}"
688
- TFX_NO_CLAUDE_NATIVE="${TFX_NO_CLAUDE_NATIVE:-0}"
689
- TFX_VERIFIER_OVERRIDE="${TFX_VERIFIER_OVERRIDE:-auto}"
690
- TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-exec}"
691
- # Codex 요금제 자동 감지 (preflight 캐시 → auth.json JWT)
692
- # 환경변수 명시 설정 시 우선, 미설정 시 캐시에서 읽기, 캐시도 없으면 pro
693
- if [[ -z "${TFX_CODEX_PLAN:-}" ]]; then
694
- _detected_plan=$(node -e '
695
- try {
696
- const c = JSON.parse(require("fs").readFileSync(require("path").join(require("os").homedir(),".claude","cache","tfx-preflight.json"),"utf8"));
697
- const p = c?.codex_plan?.plan;
698
- if (p && p !== "unknown" && p !== "api") { process.stdout.write(p); }
699
- } catch {}
700
- ' 2>/dev/null)
701
- TFX_CODEX_PLAN="${_detected_plan:-pro}"
702
- unset _detected_plan
703
- fi
704
- TFX_WORKER_INDEX="${TFX_WORKER_INDEX:-}"
705
- TFX_SEARCH_TOOL="${TFX_SEARCH_TOOL:-}"
706
- case "$TFX_NO_CLAUDE_NATIVE" in
707
- 0|1) ;;
708
- *)
709
- echo "ERROR: TFX_NO_CLAUDE_NATIVE 값은 0 또는 1이어야 합니다. (현재: $TFX_NO_CLAUDE_NATIVE)" >&2
710
- exit 1
711
- ;;
712
- esac
713
- case "$TFX_CODEX_PLAN" in
714
- pro|plus|free) ;;
715
- *)
716
- echo "ERROR: TFX_CODEX_PLAN 값은 pro, plus, free 중 하나여야 합니다. (현재: $TFX_CODEX_PLAN)" >&2
717
- exit 1
718
- ;;
719
- esac
720
- case "$TFX_CODEX_TRANSPORT" in
721
- auto|mcp|exec) ;;
722
- *)
723
- echo "ERROR: TFX_CODEX_TRANSPORT 값은 auto, mcp, exec 중 하나여야 합니다. (현재: $TFX_CODEX_TRANSPORT)" >&2
724
- exit 1
725
- ;;
726
- esac
727
- case "$TFX_VERIFIER_OVERRIDE" in
728
- auto|claude) ;;
729
- *)
730
- echo "ERROR: TFX_VERIFIER_OVERRIDE 값은 auto 또는 claude여야 합니다. (현재: $TFX_VERIFIER_OVERRIDE)" >&2
731
- exit 1
732
- ;;
733
- esac
734
- case "$TFX_WORKER_INDEX" in
735
- "") ;;
736
- *[!0-9]*|0)
737
- echo "ERROR: TFX_WORKER_INDEX 값은 1 이상의 정수여야 합니다. (현재: $TFX_WORKER_INDEX)" >&2
738
- exit 1
739
- ;;
740
- esac
741
- case "$TFX_SEARCH_TOOL" in
742
- ""|brave-search|tavily|exa) ;;
743
- *)
744
- echo "ERROR: TFX_SEARCH_TOOL 값은 brave-search, tavily, exa 중 하나여야 합니다. (현재: $TFX_SEARCH_TOOL)" >&2
745
- exit 1
746
- ;;
747
- esac
748
- CODEX_MCP_TRANSPORT_EXIT_CODE=70
749
-
750
- apply_cli_mode() {
751
- local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
752
-
753
- case "$TFX_CLI_MODE" in
754
- codex)
755
- if [[ "$CLI_TYPE" == "gemini" ]]; then
756
- CLI_TYPE="codex"; CLI_CMD="codex"
757
- case "$AGENT_TYPE" in
758
- designer)
759
- CLI_ARGS="exec ${codex_base}"; CLI_EFFORT="high"; DEFAULT_TIMEOUT=600 ;;
760
- writer)
761
- CLI_ARGS="exec --profile spark_fast ${codex_base}"; CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180 ;;
762
- esac
763
- echo "[tfx-route] TFX_CLI_MODE=codex: $AGENT_TYPE → codex($CLI_EFFORT)로 리매핑" >&2
764
- fi ;;
765
- gemini)
766
- if [[ "$CLI_TYPE" == "codex" ]]; then
767
- CLI_TYPE="gemini"; CLI_CMD="gemini"
768
- case "$AGENT_TYPE" in
769
- executor|debugger|deep-executor|architect|planner|critic|analyst|\
770
- code-reviewer|security-reviewer|quality-reviewer|scientist-deep)
771
- CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"; CLI_EFFORT="pro" ;;
772
- build-fixer|spark)
773
- CLI_ARGS="-m gemini-3-flash-preview -y --prompt"; CLI_EFFORT="flash"; DEFAULT_TIMEOUT=180 ;;
774
- *)
775
- CLI_ARGS="-m gemini-3-flash-preview -y --prompt"; CLI_EFFORT="flash" ;;
776
- esac
777
- echo "[tfx-route] TFX_CLI_MODE=gemini: $AGENT_TYPE → gemini($CLI_EFFORT)로 리매핑" >&2
778
- fi ;;
779
- auto)
780
- if [[ "$CLI_TYPE" == "codex" ]] && ! command -v "$CODEX_BIN" &>/dev/null; then
781
- if command -v "$GEMINI_BIN" &>/dev/null; then
782
- TFX_CLI_MODE="gemini"; apply_cli_mode; return
783
- else
784
- ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
785
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
786
- echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
787
- fi
788
- elif [[ "$CLI_TYPE" == "gemini" ]] && ! command -v "$GEMINI_BIN" &>/dev/null; then
789
- if command -v "$CODEX_BIN" &>/dev/null; then
790
- TFX_CLI_MODE="codex"; apply_cli_mode; return
791
- else
792
- ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
793
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
794
- echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE claude-native fallback" >&2
795
- fi
796
- fi ;;
797
- esac
798
- }
799
-
800
- # ── Codex 요금제 가드 (fast 프로필은 Pro 전용) ──
801
- apply_plan_guard() {
802
- [[ "$CLI_TYPE" != "codex" ]] && return
803
- [[ "$TFX_CODEX_PLAN" == "pro" ]] && return
804
-
805
- if [[ "$CLI_EFFORT" == "fast" ]]; then
806
- local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
807
- CLI_ARGS="exec ${codex_base}"
808
- CLI_EFFORT="high"
809
- echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: --profile fast → high로 다운그레이드 (Pro 전용)" >&2
810
- fi
811
- }
812
-
813
- # ── Claude 네이티브 제거 (Codex 리드 환경에서 선택적 활성화) ──
814
- apply_no_claude_native_mode() {
815
- local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
816
-
817
- [[ "$TFX_NO_CLAUDE_NATIVE" != "1" ]] && return
818
- [[ "$TFX_CLI_MODE" == "gemini" ]] && return
819
- [[ "$CLI_TYPE" != "claude-native" ]] && return
820
-
821
- if ! command -v "$CODEX_BIN" &>/dev/null; then
822
- echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1 이지만 codex를 찾지 못해 claude-native 유지" >&2
823
- return
824
- fi
825
-
826
- ORIGINAL_AGENT="${AGENT_TYPE}"
827
- CLI_TYPE="codex"; CLI_CMD="codex"
828
-
829
- case "$AGENT_TYPE" in
830
- explore)
831
- CLI_ARGS="exec --profile fast ${codex_base}"
832
- CLI_EFFORT="fast"
833
- DEFAULT_TIMEOUT=600
834
- RUN_MODE="fg"
835
- OPUS_OVERSIGHT="false"
836
- ;;
837
- verifier)
838
- CLI_ARGS="exec --profile thorough ${codex_base} review"
839
- CLI_EFFORT="thorough"
840
- DEFAULT_TIMEOUT=1200
841
- RUN_MODE="fg"
842
- OPUS_OVERSIGHT="false"
843
- ;;
844
- test-engineer)
845
- CLI_ARGS="exec ${codex_base}"
846
- CLI_EFFORT="high"
847
- DEFAULT_TIMEOUT=1200
848
- RUN_MODE="bg"
849
- OPUS_OVERSIGHT="false"
850
- ;;
851
- qa-tester)
852
- CLI_ARGS="exec --profile thorough ${codex_base} review"
853
- CLI_EFFORT="thorough"
854
- DEFAULT_TIMEOUT=1200
855
- RUN_MODE="bg"
856
- OPUS_OVERSIGHT="false"
857
- ;;
858
- *)
859
- # claude-native 타입 중 위에 없는 경우는 보수적으로 유지
860
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
861
- return
862
- ;;
863
- esac
864
-
865
- echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1: $AGENT_TYPE -> codex($CLI_EFFORT) 리매핑" >&2
866
- }
867
-
868
- apply_verifier_override() {
869
- [[ "$AGENT_TYPE" != "verifier" ]] && return
870
-
871
- case "$TFX_VERIFIER_OVERRIDE" in
872
- auto|"")
873
- return 0
874
- ;;
875
- claude)
876
- ORIGINAL_AGENT="${ORIGINAL_AGENT:-$AGENT_TYPE}"
877
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
878
- CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
879
- echo "[tfx-route] TFX_VERIFIER_OVERRIDE=claude: verifier -> claude-native" >&2
880
- ;;
881
- esac
882
-
883
- return 0
884
- }
885
-
886
- # ── MCP 인벤토리 캐시 ──
887
- MCP_CACHE="${HOME}/.claude/cache/mcp-inventory.json"
888
- MCP_FILTER_SCRIPT=""
889
- MCP_PROFILE_REQUESTED="auto"
890
- MCP_RESOLVED_PROFILE="default"
891
- MCP_HINT=""
892
- GEMINI_ALLOWED_SERVERS=()
893
- CODEX_CONFIG_FLAGS=()
894
- CODEX_CONFIG_JSON=""
895
-
896
- get_cached_servers() {
897
- local cli_type="$1"
898
- if [[ -f "$MCP_CACHE" ]]; then
899
- node -e 'const[,f,t]=process.argv;const inv=JSON.parse(require("fs").readFileSync(f,"utf8"));const s=(inv[t]||{}).servers||[];console.log(s.filter(x=>x.status==="enabled"||x.status==="configured").map(x=>x.name).join(","))' -- "$MCP_CACHE" "$cli_type" 2>/dev/null
900
- fi
901
- }
902
-
903
- resolve_mcp_filter_script() {
904
- if [[ -n "$MCP_FILTER_SCRIPT" && -f "$MCP_FILTER_SCRIPT" ]]; then
905
- printf '%s\n' "$MCP_FILTER_SCRIPT"
906
- return 0
907
- fi
908
-
909
- local script_ref script_dir candidate
910
- local -a candidates=()
911
-
912
- script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
913
- if [[ -n "$script_ref" ]]; then
914
- script_dir="$(cd "$(dirname "$script_ref")" 2>/dev/null && pwd -P || true)"
915
- [[ -n "$script_dir" ]] && candidates+=("$script_dir/lib/mcp-filter.mjs")
916
- fi
917
-
918
- candidates+=(
919
- "$PWD/scripts/lib/mcp-filter.mjs"
920
- "$PWD/lib/mcp-filter.mjs"
921
- )
922
-
923
- for candidate in "${candidates[@]}"; do
924
- if [[ -f "$candidate" ]]; then
925
- MCP_FILTER_SCRIPT="$candidate"
926
- printf '%s\n' "$MCP_FILTER_SCRIPT"
927
- return 0
928
- fi
929
- done
930
-
931
- return 1
932
- }
933
-
934
- resolve_mcp_policy() {
935
- local filter_script available_servers
936
- if ! filter_script=$(resolve_mcp_filter_script); then
937
- echo "[tfx-route] 경고: mcp-filter.mjs를 찾지 못해 기본 MCP 정책을 사용합니다." >&2
938
- MCP_PROFILE_REQUESTED="$MCP_PROFILE"
939
- MCP_RESOLVED_PROFILE="$MCP_PROFILE"
940
- MCP_HINT=""
941
- GEMINI_ALLOWED_SERVERS=()
942
- CODEX_CONFIG_FLAGS=()
943
- CODEX_CONFIG_JSON=""
944
- return 0
945
- fi
946
-
947
- available_servers=$(get_cached_servers "$CLI_TYPE")
948
- # Codex 0.115+: 미등록 서버에 config override(enabled=true/false 모두)를 보내면
949
- # "invalid transport" 에러 발생. 캐시 비어있으면 빈 문자열로 유지하여
950
- # mcp-filter가 override생성하지 않도록 한다.
951
- [[ -z "$available_servers" ]] && available_servers=""
952
-
953
- local -a cmd=(
954
- "$NODE_BIN" "$filter_script" shell
955
- "--agent" "$AGENT_TYPE"
956
- "--profile" "$MCP_PROFILE"
957
- "--available" "$available_servers"
958
- "--inventory-file" "$MCP_CACHE"
959
- "--task-text" "$PROMPT"
960
- )
961
- [[ -n "$TFX_SEARCH_TOOL" ]] && cmd+=("--search-tool" "$TFX_SEARCH_TOOL")
962
- [[ -n "$TFX_WORKER_INDEX" ]] && cmd+=("--worker-index" "$TFX_WORKER_INDEX")
963
-
964
- local shell_exports
965
- if ! shell_exports="$("${cmd[@]}")"; then
966
- echo "[tfx-route] ERROR: MCP 정책 계산 실패" >&2
967
- return 1
968
- fi
969
-
970
- eval "$shell_exports"
971
- }
972
-
973
- get_claude_model() {
974
- case "$AGENT_TYPE" in
975
- explore) echo "haiku" ;;
976
- *) echo "sonnet" ;;
977
- esac
978
- }
979
-
980
- emit_claude_native_metadata() {
981
- local model
982
- model=$(get_claude_model)
983
- echo "ROUTE_TYPE=claude-native"
984
- echo "AGENT=$AGENT_TYPE"
985
- echo "MODEL=$model"
986
- echo "RUN_MODE=$RUN_MODE"
987
- echo "OPUS_OVERSIGHT=$OPUS_OVERSIGHT"
988
- echo "TIMEOUT=$TIMEOUT_SEC"
989
- echo "MCP_PROFILE=$MCP_PROFILE"
990
- [[ -n "$ORIGINAL_AGENT" ]] && echo "ORIGINAL_AGENT=$ORIGINAL_AGENT"
991
- echo "PROMPT=$PROMPT"
992
- echo "--- Claude Task($model) 에이전트로 위임하세요 ---"
993
- }
994
-
995
- # heartbeat_monitor PID [INTERVAL] [STALL_THRESHOLD]
996
- # - PID: 감시할 워커 프로세스 PID
997
- # - INTERVAL: heartbeat 출력 간격 (초, 기본 10)
998
- # - STALL_THRESHOLD: stall 경고 임계값 (초, 기본 60)
999
- # 환경변수: TFX_HEARTBEAT (0이면 비활성화), TFX_HEARTBEAT_INTERVAL, TFX_STALL_THRESHOLD
1000
- heartbeat_monitor() {
1001
- [[ "${TFX_HEARTBEAT:-1}" -eq 0 ]] && return 0
1002
- local pid="$1"
1003
- local interval="${2:-${TFX_HEARTBEAT_INTERVAL:-10}}"
1004
- local stall_threshold="${3:-${TFX_STALL_THRESHOLD:-60}}"
1005
- local last_size=0 stall_count=0
1006
-
1007
- while kill -0 "$pid" 2>/dev/null; do
1008
- sleep "$interval"
1009
- local current_size=0
1010
- [[ -f "$STDOUT_LOG" ]] && current_size=$(wc -c < "$STDOUT_LOG" 2>/dev/null || echo 0)
1011
- # P3: stderr 활동도 포함하여 거짓 STALL 방지
1012
- local stderr_size=0
1013
- [[ -f "$STDERR_LOG" ]] && stderr_size=$(wc -c < "$STDERR_LOG" 2>/dev/null || echo 0)
1014
- current_size=$((current_size + stderr_size))
1015
- local elapsed=$(($(date +%s) - TIMESTAMP))
1016
-
1017
- if [[ "$current_size" -gt "$last_size" ]]; then
1018
- stall_count=0
1019
- echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=active" >&2
1020
- else
1021
- stall_count=$((stall_count + interval))
1022
- if [[ "$stall_count" -ge "$stall_threshold" ]]; then
1023
- echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL stall=${stall_count}s" >&2
1024
- else
1025
- echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=quiet stall=${stall_count}s" >&2
1026
- fi
1027
- fi
1028
- last_size=$current_size
1029
- done
1030
- echo "[tfx-heartbeat] pid=$pid terminated" >&2
1031
- }
1032
-
1033
- resolve_worker_runner_script() {
1034
- if [[ -n "${TFX_ROUTE_WORKER_RUNNER:-}" && -f "$TFX_ROUTE_WORKER_RUNNER" ]]; then
1035
- printf '%s\n' "$TFX_ROUTE_WORKER_RUNNER"
1036
- return 0
1037
- fi
1038
-
1039
- local script_ref script_dir
1040
- script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1041
- script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1042
- local candidate="$script_dir/tfx-route-worker.mjs"
1043
- [[ -f "$candidate" ]] || return 1
1044
- printf '%s\n' "$candidate"
1045
- }
1046
-
1047
- run_stream_worker() {
1048
- local worker_type="$1"
1049
- local prompt="$2"
1050
- local use_tee_flag="$3"
1051
- shift 3
1052
- local exit_code_local=0
1053
- local worker_pid hb_pid
1054
-
1055
- local runner_script
1056
- if ! runner_script=$(resolve_worker_runner_script); then
1057
- echo "[tfx-route] 경고: stream worker runner를 찾지 못했습니다." >&2
1058
- return 127
1059
- fi
1060
-
1061
- if ! command -v "$NODE_BIN" &>/dev/null; then
1062
- echo "[tfx-route] 경고: node를 찾지 못해 stream worker를 실행할 수 없습니다." >&2
1063
- return 127
1064
- fi
1065
-
1066
- local -a worker_cmd=(
1067
- "$NODE_BIN"
1068
- "$runner_script"
1069
- "--type" "$worker_type"
1070
- "--timeout-ms" "$((TIMEOUT_SEC * 1000))"
1071
- "--cwd" "$PWD"
1072
- "$@"
1073
- )
1074
-
1075
- if [[ "$use_tee_flag" == "true" ]]; then
1076
- printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1077
- else
1078
- printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1079
- fi
1080
- worker_pid=$!
1081
-
1082
- heartbeat_monitor "$worker_pid" &
1083
- hb_pid=$!
1084
-
1085
- wait "$worker_pid" || exit_code_local=$?
1086
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1087
- return "$exit_code_local"
1088
- }
1089
-
1090
- run_legacy_gemini() {
1091
- local prompt="$1"
1092
- local use_tee_flag="$2"
1093
- local -a gemini_args=()
1094
- read -r -a gemini_args <<< "$CLI_ARGS"
1095
-
1096
- if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1097
- local gemini_mcp_filter prompt_index=-1
1098
- gemini_mcp_filter=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")
1099
- for i in "${!gemini_args[@]}"; do
1100
- if [[ "${gemini_args[$i]}" == "--prompt" ]]; then
1101
- prompt_index="$i"
1102
- break
1103
- fi
1104
- done
1105
- if [[ "$prompt_index" -ge 0 ]]; then
1106
- gemini_args=(
1107
- "${gemini_args[@]:0:$prompt_index}"
1108
- "--allowed-mcp-server-names" "$gemini_mcp_filter"
1109
- "${gemini_args[@]:$prompt_index}"
1110
- )
1111
- echo "[tfx-route] Gemini MCP 필터: $gemini_mcp_filter" >&2
1112
- fi
1113
- fi
1114
-
1115
- if [[ "$use_tee_flag" == "true" ]]; then
1116
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1117
- else
1118
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1119
- fi
1120
- local pid=$!
1121
-
1122
- local health_ok=true
1123
- local intervals=(1 2 3 5 8)
1124
- for wait_sec in "${intervals[@]}"; do
1125
- sleep "$wait_sec"
1126
- if [[ -s "$STDOUT_LOG" ]] || [[ -s "$STDERR_LOG" ]]; then
1127
- break
1128
- fi
1129
- if ! kill -0 "$pid" 2>/dev/null; then
1130
- health_ok=false
1131
- echo "[tfx-route] Gemini: 출력 없이 프로세스 종료 (${wait_sec} 체크)" >&2
1132
- break
1133
- fi
1134
- done
1135
-
1136
- local exit_code_local=0
1137
- local hb_pid
1138
- if [[ "$health_ok" == "false" ]]; then
1139
- wait "$pid" 2>/dev/null
1140
- echo "[tfx-route] Gemini crash 감지, 재시도 중..." >&2
1141
- if [[ "$use_tee_flag" == "true" ]]; then
1142
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1143
- else
1144
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1145
- fi
1146
- pid=$!
1147
- fi
1148
-
1149
- heartbeat_monitor "$pid" &
1150
- hb_pid=$!
1151
- wait "$pid" || exit_code_local=$?
1152
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1153
- return "$exit_code_local"
1154
- }
1155
-
1156
- resolve_codex_mcp_script() {
1157
- if [[ -n "${TFX_CODEX_MCP_SCRIPT:-}" && -f "$TFX_CODEX_MCP_SCRIPT" ]]; then
1158
- printf '%s\n' "$TFX_CODEX_MCP_SCRIPT"
1159
- return 0
1160
- fi
1161
-
1162
- local script_ref script_dir
1163
- script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1164
- script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1165
- local candidates=()
1166
- [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/workers/codex-mcp.mjs")
1167
- candidates+=(
1168
- "$script_dir/hub/workers/codex-mcp.mjs"
1169
- "$script_dir/../hub/workers/codex-mcp.mjs"
1170
- )
1171
-
1172
- local candidate
1173
- for candidate in "${candidates[@]}"; do
1174
- if [[ -f "$candidate" ]]; then
1175
- printf '%s\n' "$candidate"
1176
- return 0
1177
- fi
1178
- done
1179
-
1180
- return 1
1181
- }
1182
-
1183
- run_codex_exec() {
1184
- local prompt="$1"
1185
- local use_tee_flag="$2"
1186
- local exit_code_local=0
1187
- local worker_pid hb_pid
1188
- local -a codex_args=()
1189
- read -r -a codex_args <<< "$CLI_ARGS"
1190
- if [[ ${#CODEX_CONFIG_FLAGS[@]} -gt 0 ]]; then
1191
- codex_args+=("${CODEX_CONFIG_FLAGS[@]}")
1192
- fi
1193
-
1194
- if [[ "$use_tee_flag" == "true" ]]; then
1195
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1196
- else
1197
- timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1198
- fi
1199
- worker_pid=$!
1200
-
1201
- heartbeat_monitor "$worker_pid" &
1202
- hb_pid=$!
1203
-
1204
- wait "$worker_pid" || exit_code_local=$?
1205
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1206
-
1207
- if [[ ! -s "$STDOUT_LOG" && -s "$STDERR_LOG" ]]; then
1208
- # stderr에서 마지막 "codex" 마커 이후의 텍스트를 stdout으로 복구
1209
- # 1차: "codex" 마커 기반 (Windows \r 제거 후 매칭)
1210
- sed 's/\r$//' "$STDERR_LOG" \
1211
- | awk '/^codex$/{found=NR;content=""} found && NR>found{content=content RS $0} END{if(content) print substr(content,2)}' \
1212
- > "$STDOUT_LOG"
1213
-
1214
- # 2차: 마커 없을 때 node fallback (MCP/헤더/sandbox 로그 제외, 응답 부분만 추출)
1215
- if [[ ! -s "$STDOUT_LOG" ]]; then
1216
- node -e '
1217
- const fs=require("fs"),lines=fs.readFileSync(process.argv[1],"utf-8").split(/\r?\n/);
1218
- const skip=/^(mcp[: ]|OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|tokens used|EXIT:|exec$|"[A-Z]:|succeeded in |\s*$)/;
1219
- const out=lines.filter(l=>!skip.test(l));
1220
- if(out.length) fs.writeFileSync(process.argv[2],out.join("\n"));
1221
- ' -- "$STDERR_LOG" "$STDOUT_LOG" 2>/dev/null || true
1222
- fi
1223
-
1224
- if [[ -s "$STDOUT_LOG" ]]; then
1225
- echo "[tfx-route] 경고: codex stdout 비어있음, stderr에서 응답 복구 ($(wc -c < "$STDOUT_LOG") bytes)" >&2
1226
- else
1227
- echo "[tfx-route] 경고: codex stdout 비어있음, stderr 복구도 실패" >&2
1228
- fi
1229
- fi
1230
-
1231
- return "$exit_code_local"
1232
- }
1233
-
1234
- run_codex_mcp() {
1235
- local prompt="$1"
1236
- local use_tee_flag="$2"
1237
- local mcp_script node_bin
1238
- local exit_code_local=0
1239
- local worker_pid hb_pid
1240
-
1241
- if ! mcp_script=$(resolve_codex_mcp_script); then
1242
- echo "[tfx-route] 경고: Codex MCP 래퍼를 찾지 못했습니다." >&2
1243
- return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1244
- fi
1245
-
1246
- node_bin="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
1247
- if ! command -v "$node_bin" &>/dev/null; then
1248
- echo "[tfx-route] 경고: node를 찾지 못해 Codex MCP 경로를 사용할 수 없습니다." >&2
1249
- return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1250
- fi
1251
-
1252
- local -a mcp_args=(
1253
- "$mcp_script"
1254
- "--prompt" "$prompt"
1255
- "--cwd" "$PWD"
1256
- "--profile" "$CLI_EFFORT"
1257
- "--approval-policy" "never"
1258
- "--sandbox" "danger-full-access"
1259
- "--timeout-ms" "$((TIMEOUT_SEC * 1000))"
1260
- "--codex-command" "$CODEX_BIN"
1261
- )
1262
-
1263
- if [[ -n "$CODEX_CONFIG_JSON" && "$CODEX_CONFIG_JSON" != "{}" ]]; then
1264
- mcp_args+=("--config-json" "$CODEX_CONFIG_JSON")
1265
- fi
1266
-
1267
- case "$AGENT_TYPE" in
1268
- code-reviewer)
1269
- mcp_args+=(
1270
- "--developer-instructions"
1271
- "코드 리뷰 모드로 동작하라. 버그, 리스크, 회귀, 테스트 누락을 우선 식별하라."
1272
- )
1273
- ;;
1274
- security-reviewer)
1275
- mcp_args+=(
1276
- "--developer-instructions"
1277
- "보안 리뷰 모드로 동작하라. 취약점, 권한 경계, 비밀정보 노출 가능성을 우선 식별하라."
1278
- )
1279
- ;;
1280
- quality-reviewer)
1281
- mcp_args+=(
1282
- "--developer-instructions"
1283
- "품질 리뷰 모드로 동작하라. 로직 결함, 유지보수성 저하, 테스트 누락을 우선 식별하라."
1284
- )
1285
- ;;
1286
- esac
1287
-
1288
- if [[ "$use_tee_flag" == "true" ]]; then
1289
- timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1290
- else
1291
- timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1292
- fi
1293
- worker_pid=$!
1294
-
1295
- heartbeat_monitor "$worker_pid" &
1296
- hb_pid=$!
1297
-
1298
- wait "$worker_pid" || exit_code_local=$?
1299
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1300
-
1301
- # 모듈 로드 실패(의존성 누락) MCP transport exit code로 변환하여 fallback 트리거
1302
- if [[ "$exit_code_local" -ne 0 && "$exit_code_local" -ne 124 ]] && grep -q 'ERR_MODULE_NOT_FOUND' "$STDERR_LOG" 2>/dev/null; then
1303
- echo "[tfx-route] Codex MCP 모듈 로드 실패 — fallback 가능 exit code로 변환" >&2
1304
- return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1305
- fi
1306
-
1307
- return "$exit_code_local"
1308
- }
1309
-
1310
- # ── 메인 실행 ──
1311
- main() {
1312
- # 종료 per-process 에이전트 파일 자동 삭제
1313
- trap 'deregister_agent' EXIT
1314
-
1315
- route_agent "$AGENT_TYPE"
1316
- apply_cli_mode
1317
- apply_no_claude_native_mode
1318
- apply_plan_guard
1319
- apply_verifier_override
1320
-
1321
- # CLI 경로 해석
1322
- case "$CLI_CMD" in
1323
- codex) CLI_CMD="$CODEX_BIN" ;;
1324
- gemini) CLI_CMD="$GEMINI_BIN" ;;
1325
- claude) CLI_CMD="$CLAUDE_BIN" ;;
1326
- esac
1327
-
1328
- # 타임아웃 결정 (에이전트별 최소값 보장)
1329
- local MIN_TIMEOUT
1330
- case "$AGENT_TYPE" in
1331
- deep-executor|architect|planner|critic|analyst) MIN_TIMEOUT=900 ;;
1332
- document-specialist|scientist|scientist-deep) MIN_TIMEOUT=900 ;;
1333
- code-reviewer|security-reviewer|quality-reviewer) MIN_TIMEOUT=600 ;;
1334
- executor|debugger) MIN_TIMEOUT=300 ;;
1335
- *) MIN_TIMEOUT=120 ;;
1336
- esac
1337
-
1338
- if [[ -n "$USER_TIMEOUT" ]]; then
1339
- if ! [[ "$USER_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then
1340
- echo "[tfx-route] 경고: 유효하지 않은 타임아웃 값 ($USER_TIMEOUT), 기본값 사용" >&2
1341
- USER_TIMEOUT=""
1342
- TIMEOUT_SEC="$DEFAULT_TIMEOUT"
1343
- elif [[ "$USER_TIMEOUT" -lt "$MIN_TIMEOUT" ]]; then
1344
- echo "[tfx-route] 경고: 타임아웃 ${USER_TIMEOUT}s < 최소 ${MIN_TIMEOUT}s ($AGENT_TYPE), 최소값 적용" >&2
1345
- TIMEOUT_SEC="$MIN_TIMEOUT"
1346
- else
1347
- TIMEOUT_SEC="$USER_TIMEOUT"
1348
- fi
1349
- else
1350
- TIMEOUT_SEC="$DEFAULT_TIMEOUT"
1351
- fi
1352
-
1353
- # 컨텍스트 파일 프롬프트에 주입
1354
- if [[ -n "$CONTEXT_FILE" && -f "$CONTEXT_FILE" ]]; then
1355
- local ctx_content
1356
- ctx_content=$(cat "$CONTEXT_FILE" 2>/dev/null | head -c 32768) # 32KB 상한
1357
- PROMPT="${PROMPT}
1358
-
1359
- <prior_context>
1360
- ${ctx_content}
1361
- </prior_context>"
1362
- fi
1363
-
1364
- resolve_mcp_policy
1365
-
1366
- # Claude native는 비-TTY 환경에서 subprocess wrapper를 우선 시도
1367
- if [[ "$CLI_TYPE" == "claude-native" && -n "$TFX_TEAM_NAME" ]]; then
1368
- if { [[ ! -t 0 ]] || [[ ! -t 1 ]]; } && command -v "$CLAUDE_BIN" &>/dev/null && resolve_worker_runner_script >/dev/null 2>&1; then
1369
- CLI_TYPE="claude"
1370
- CLI_CMD="$CLAUDE_BIN"
1371
- echo "[tfx-route] non-tty 팀 환경: claude-native -> claude stream wrapper 전환" >&2
1372
- else
1373
- echo "[tfx-route] claude stream wrapper 미사용: native metadata 유지" >&2
1374
- fi
1375
- fi
1376
-
1377
- # Claude 네이티브 에이전트는 이 스크립트로 처리 불가
1378
- if [[ "$CLI_TYPE" == "claude-native" ]]; then
1379
- if [[ -n "$TFX_TEAM_NAME" ]]; then
1380
- # 모드: Hub에 fallback 필요 시그널 전송 구조화된 출력
1381
- echo "[tfx-route] claude-native 역할($AGENT_TYPE)은 tfx-route.sh로 실행 불가 Claude Agent fallback 필요" >&2
1382
- team_complete_task "fallback" "claude-native 역할 실행 불가: ${AGENT_TYPE}. Claude Task(sonnet) 에이전트로 위임하세요."
1383
- cat <<FALLBACK_EOF
1384
- === TFX_NEEDS_FALLBACK ===
1385
- agent_type: ${AGENT_TYPE}
1386
- reason: claude-native roles require Claude Agent tools (Read/Edit/Grep). tfx-route.sh cannot provide these.
1387
- action: Lead should spawn Agent(subagent_type="${AGENT_TYPE}") for this task.
1388
- task_id: ${TFX_TEAM_TASK_ID:-none}
1389
- FALLBACK_EOF
1390
- exit 0
1391
- fi
1392
- emit_claude_native_metadata
1393
- exit 0
1394
- fi
1395
-
1396
- local FULL_PROMPT="$PROMPT"
1397
- [[ -n "$MCP_HINT" ]] && FULL_PROMPT="${PROMPT}. ${MCP_HINT}"
1398
- local codex_transport_effective="n/a"
1399
-
1400
- # 메타정보 (stderr)
1401
- echo "[tfx-route] v${VERSION} type=$CLI_TYPE agent=$AGENT_TYPE effort=$CLI_EFFORT mode=$RUN_MODE timeout=${TIMEOUT_SEC}s" >&2
1402
- echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE resolved_profile=$MCP_RESOLVED_PROFILE verifier_override=$TFX_VERIFIER_OVERRIDE" >&2
1403
- if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1404
- echo "[tfx-route] allowed_mcp_servers=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1405
- else
1406
- echo "[tfx-route] allowed_mcp_servers=none" >&2
1407
- fi
1408
- if [[ -n "$TFX_WORKER_INDEX" || -n "$TFX_SEARCH_TOOL" ]]; then
1409
- echo "[tfx-route] worker_index=${TFX_WORKER_INDEX:-auto} search_tool=${TFX_SEARCH_TOOL:-auto}" >&2
1410
- fi
1411
- if [[ "$CLI_TYPE" == "codex" ]]; then
1412
- echo "[tfx-route] codex_transport_request=$TFX_CODEX_TRANSPORT" >&2
1413
- fi
1414
- [[ -n "$TFX_TEAM_NAME" ]] && echo "[tfx-route] team=$TFX_TEAM_NAME task=$TFX_TEAM_TASK_ID agent=$TFX_TEAM_AGENT_NAME" >&2
1415
- [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "[tfx-route] rerouted_from=$TFX_REROUTED_FROM" >&2
1416
-
1417
- # Per-process 에이전트 등록
1418
- register_agent
1419
-
1420
- # 팀 모드: task claim
1421
- team_claim_task
1422
- team_send_message "작업 시작: ${TFX_TEAM_AGENT_NAME}" "task ${TFX_TEAM_TASK_ID} started"
1423
-
1424
- # CLI 실행 (stderr 분리 + 타임아웃 + 소요시간 측정)
1425
- local exit_code=0
1426
- local start_time
1427
- start_time=$(date +%s)
1428
- local workspace_signature_before=""
1429
- local workspace_signature_after=""
1430
- local workspace_probe_supported=false
1431
- if workspace_signature_before=$(capture_workspace_signature); then
1432
- workspace_probe_supported=true
1433
- fi
1434
-
1435
- # tee 활성화 조건: 모드 + 실제 터미널(TTY/tmux)
1436
- # Agent 래퍼 안에서는 가상 stdout 캡처로 tee 출력이 사용자에게 안 보임 → 파일 전용
1437
- # 실시간 모니터링은 Shift+Down으로 워커 pane 전환 권장
1438
- local use_tee=false
1439
- if [[ -n "$TFX_TEAM_NAME" ]]; then
1440
- if [[ -t 1 ]] || [[ -n "${TMUX:-}" ]]; then
1441
- use_tee=true
1442
- fi
1443
- fi
1444
-
1445
- if [[ "$CLI_TYPE" == "codex" ]]; then
1446
- codex_transport_effective="exec"
1447
- if [[ "$TFX_CODEX_TRANSPORT" != "exec" ]]; then
1448
- run_codex_mcp "$FULL_PROMPT" "$use_tee" || exit_code=$?
1449
- if [[ "$exit_code" -eq 0 ]]; then
1450
- codex_transport_effective="mcp"
1451
- elif [[ "$exit_code" -eq "$CODEX_MCP_TRANSPORT_EXIT_CODE" && "$TFX_CODEX_TRANSPORT" == "auto" ]]; then
1452
- echo "[tfx-route] Codex MCP bootstrap 실패(exit=${exit_code}). legacy exec 경로로 fallback합니다." >&2
1453
- : > "$STDOUT_LOG"
1454
- : > "$STDERR_LOG"
1455
- exit_code=0
1456
- run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
1457
- codex_transport_effective="exec-fallback"
1458
- else
1459
- codex_transport_effective="mcp"
1460
- fi
1461
- else
1462
- run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
1463
- codex_transport_effective="exec"
1464
- fi
1465
- echo "[tfx-route] codex_transport_effective=$codex_transport_effective" >&2
1466
-
1467
- elif [[ "$CLI_TYPE" == "gemini" ]]; then
1468
- local gemini_model
1469
- gemini_model=$(awk '{
1470
- for (i = 1; i <= NF; i++) {
1471
- if ($i == "-m" || $i == "--model") {
1472
- print $(i + 1)
1473
- exit
1474
- }
1475
- }
1476
- }' <<< "$CLI_ARGS")
1477
- local -a gemini_worker_args=(
1478
- "--command" "$CLI_CMD"
1479
- "--command-args-json" "$GEMINI_BIN_ARGS_JSON"
1480
- "--model" "$gemini_model"
1481
- "--approval-mode" "yolo"
1482
- )
1483
-
1484
- if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1485
- echo "[tfx-route] Gemini MCP 서버: $(IFS=' '; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1486
- local server_name
1487
- for server_name in "${GEMINI_ALLOWED_SERVERS[@]}"; do
1488
- gemini_worker_args+=("--allowed-mcp-server-name" "$server_name")
1489
- done
1490
- fi
1491
-
1492
- run_stream_worker "gemini" "$FULL_PROMPT" "$use_tee" "${gemini_worker_args[@]}" || exit_code=$?
1493
- if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1494
- echo "[tfx-route] Gemini stream wrapper 실패(exit=${exit_code}). legacy CLI 경로로 fallback합니다." >&2
1495
- : > "$STDOUT_LOG"
1496
- : > "$STDERR_LOG"
1497
- exit_code=0
1498
- run_legacy_gemini "$FULL_PROMPT" "$use_tee" || exit_code=$?
1499
- fi
1500
-
1501
- elif [[ "$CLI_TYPE" == "claude" ]]; then
1502
- local claude_model
1503
- claude_model=$(get_claude_model)
1504
- local -a claude_worker_args=(
1505
- "--command" "$CLI_CMD"
1506
- "--command-args-json" "$CLAUDE_BIN_ARGS_JSON"
1507
- "--model" "$claude_model"
1508
- "--permission-mode" "bypassPermissions"
1509
- "--allow-dangerously-skip-permissions"
1510
- )
1511
-
1512
- run_stream_worker "claude" "$FULL_PROMPT" "$use_tee" "${claude_worker_args[@]}" || exit_code=$?
1513
- if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1514
- echo "[tfx-route] Claude stream wrapper 실패(exit=${exit_code}). native metadata로 fallback합니다." >&2
1515
- cat > "$STDOUT_LOG" <<EOF
1516
- $(emit_claude_native_metadata)
1517
- EOF
1518
- : > "$STDERR_LOG"
1519
- exit_code=0
1520
- CLI_TYPE="claude-native"
1521
- fi
1522
- fi
1523
-
1524
- local end_time
1525
- end_time=$(date +%s)
1526
- local elapsed=$((end_time - start_time))
1527
-
1528
- if [[ "$exit_code" -eq 0 ]]; then
1529
- local workspace_changed="unknown"
1530
- if [[ "$workspace_probe_supported" == "true" ]]; then
1531
- if workspace_signature_after=$(capture_workspace_signature); then
1532
- if [[ "$workspace_signature_before" != "$workspace_signature_after" ]]; then
1533
- workspace_changed="yes"
1534
- else
1535
- workspace_changed="no"
1536
- fi
1537
- fi
1538
- fi
1539
-
1540
- if [[ ! -s "$STDOUT_LOG" && "$workspace_changed" == "no" ]]; then
1541
- printf '%s\n' "[tfx-route] exit 0 이지만 stdout 비어있고 워크스페이스 변화가 없습니다. no-op 성공을 실패로 승격합니다." >> "$STDERR_LOG"
1542
- exit_code=68
1543
- fi
1544
- fi
1545
-
1546
- # 쿼타 감지 + 자동 re-route
1547
- if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1548
- if [[ "${TFX_QUOTA_REROUTE:-1}" -ne 0 ]] && [[ -z "${TFX_REROUTED_FROM:-}" ]] && detect_quota_exceeded "$STDOUT_LOG" "$STDERR_LOG"; then
1549
- export TFX_REROUTED_FROM="$CLI_TYPE"
1550
- auto_reroute "$CLI_TYPE"
1551
- fi
1552
- fi
1553
-
1554
- # 모드: task complete + 리드 보고
1555
- if [[ -n "$TFX_TEAM_NAME" ]]; then
1556
- if [[ "$exit_code" -eq 0 ]]; then
1557
- local output_preview
1558
- output_preview=$(head -c 2048 "$STDOUT_LOG" 2>/dev/null || echo "출력 없음")
1559
- team_complete_task "success" "$output_preview"
1560
- elif [[ "$exit_code" -eq 124 ]]; then
1561
- team_complete_task "timeout" "타임아웃 (${TIMEOUT_SEC}초)"
1562
- else
1563
- local err_preview
1564
- err_preview=$(tail -c 1024 "$STDERR_LOG" 2>/dev/null || echo "에러 정보 없음")
1565
- team_complete_task "failed" "exit_code=${exit_code}: ${err_preview}"
1566
- fi
1567
- fi
1568
-
1569
- # ── 후처리: 단일 node 프로세스로 위임 ──
1570
- # 토큰 추출, 출력 필터링, 로그, 토큰 누적, AIMD, 이슈 추적, 결과 출력 전부 처리
1571
- local post_script="${HOME}/.claude/scripts/tfx-route-post.mjs"
1572
- if [[ -f "$post_script" ]]; then
1573
- node "$post_script" \
1574
- --agent "$AGENT_TYPE" \
1575
- --cli "$CLI_TYPE" \
1576
- --cli-cmd "$CLI_CMD" \
1577
- --effort "$CLI_EFFORT" \
1578
- --run-mode "$RUN_MODE" \
1579
- --opus "$OPUS_OVERSIGHT" \
1580
- --exit-code "$exit_code" \
1581
- --elapsed "$elapsed" \
1582
- --timeout "$TIMEOUT_SEC" \
1583
- --mcp-profile "$MCP_PROFILE" \
1584
- --stderr-log "$STDERR_LOG" \
1585
- --stdout-log "$STDOUT_LOG" \
1586
- --rerouted-from "${TFX_REROUTED_FROM:-}" \
1587
- --max-bytes "$MAX_STDOUT_BYTES" \
1588
- --tee-active "$use_tee" \
1589
- --clean-tui "${TFX_CLEAN_TUI:-true}"
1590
- else
1591
- # post.mjs 없으면 기본 출력 (fallback)
1592
- echo "=== TFX-ROUTE RESULT ==="
1593
- echo "agent: $AGENT_TYPE"
1594
- echo "cli: $CLI_TYPE"
1595
- [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "rerouted_from: $TFX_REROUTED_FROM"
1596
- echo "exit_code: $exit_code"
1597
- echo "elapsed: ${elapsed}s"
1598
- echo "status: $([ $exit_code -eq 0 ] && echo success || echo failed)"
1599
- echo "=== OUTPUT ==="
1600
- if [[ "${TFX_CLEAN_TUI:-1}" != "0" ]]; then
1601
- cat "$STDOUT_LOG" 2>/dev/null \
1602
- | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
1603
- | sed '/^[[:space:]]*[╭╮╰╯│─┌┐└┘├┤┬┴┼]/d' \
1604
- | sed '/^[[:space:]]*[›❯][[:space:]]*$/d' \
1605
- | head -c "$MAX_STDOUT_BYTES"
1606
- else
1607
- cat "$STDOUT_LOG" 2>/dev/null | head -c "$MAX_STDOUT_BYTES"
1608
- fi
1609
- fi
1610
-
1611
- return "$exit_code"
1612
- }
1613
-
1614
- # ── Async 모드: 백그라운드 실행 + 즉시 job_id 반환 ──
1615
- if [[ "$TFX_ASYNC_MODE" -eq 1 ]]; then
1616
- mkdir -p "$TFX_JOBS_DIR"
1617
- JOB_ID="$TIMESTAMP-$$-${RANDOM}"
1618
- JOB_DIR="$TFX_JOBS_DIR/$JOB_ID"
1619
- mkdir -p "$JOB_DIR"
1620
- echo "$AGENT_TYPE" > "$JOB_DIR/agent_type"
1621
- date +%s > "$JOB_DIR/start_time"
1622
-
1623
- # 백그라운드 서브쉘: main 실행 → 결과 저장
1624
- (
1625
- set +e # main 내부 에러가 exit_code 기록 전에 서브쉘을 죽이는 것 방지
1626
- exec > "$JOB_DIR/result.log" 2>"$JOB_DIR/stderr.log"
1627
- main
1628
- echo $? > "$JOB_DIR/exit_code"
1629
- touch "$JOB_DIR/done"
1630
- ) &
1631
- bg_pid=$!
1632
- echo "$bg_pid" > "$JOB_DIR/pid"
1633
-
1634
- # 종료 감지 데몬 (main이 signal/crash로 죽어도 done 마커 생성)
1635
- (
1636
- wait "$bg_pid" 2>/dev/null
1637
- ec=$?
1638
- if [[ ! -f "$JOB_DIR/done" ]]; then
1639
- echo "$ec" > "$JOB_DIR/exit_code"
1640
- touch "$JOB_DIR/done"
1641
- fi
1642
- ) &
1643
- disown
1644
-
1645
- # 즉시 리턴: 1초 이내에 Claude Code Bash 도구 완료
1646
- echo "$JOB_ID"
1647
- exit 0
1648
- fi
1649
-
1650
- main
1
+ #!/usr/bin/env bash
2
+ # tfx-route.sh v2.4 — CLI 라우팅 래퍼 (triflux)
3
+ #
4
+ # v1.x: cli-route.sh (jq+python3+node 혼재, 동기 후처리 ~1s)
5
+ # v2.0: tfx-route.sh 리네임
6
+ # - 후처리 전부 tfx-route-post.mjs로 이관 (node 단일 ~100ms)
7
+ # - per-process 에이전트 등록 (race condition 구조적 제거)
8
+ # - get_mcp_hint 통합 (캐시/비캐시 단일 코드경로)
9
+ # - Gemini health check 지수 백오프 (30×1s → 5×exp)
10
+ # - 컨텍스트 파일 5번째 인자 지원
11
+ #
12
+ VERSION="2.5"
13
+ #
14
+ # 사용법:
15
+ # tfx-route.sh <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
16
+ # tfx-route.sh --async <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
17
+ # tfx-route.sh --job-status <job_id>
18
+ # tfx-route.sh --job-result <job_id>
19
+ #
20
+ # --async: 백그라운드 실행, 즉시 job_id 반환 (Claude Code Bash 600초 제한 우회)
21
+ # --job-status: running | done | timeout | failed
22
+ # --job-result: 완료된 잡의 전체 출력
23
+ #
24
+ # 예시:
25
+ # tfx-route.sh executor "코드 구현" implement
26
+ # tfx-route.sh --async scientist "딥 리서치" auto 1440
27
+ # tfx-route.sh --job-status 1742400000-12345-9876
28
+ # tfx-route.sh --job-result 1742400000-12345-9876
29
+
30
+ set -euo pipefail
31
+
32
+ # ── Async Job 디렉토리 ──
33
+ TFX_JOBS_DIR="${TMPDIR:-/tmp}/tfx-jobs"
34
+
35
+ # ── --job-status / --job-result 핸들러 (인자 파싱 전에 처리) ──
36
+ if [[ "${1:-}" == "--job-status" ]]; then
37
+ job_id="${2:?job_id 필수}"
38
+ job_dir="$TFX_JOBS_DIR/$job_id"
39
+ [[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
40
+
41
+ if [[ -f "$job_dir/done" ]]; then
42
+ exit_code=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
43
+ if [[ "$exit_code" -eq 0 ]]; then
44
+ echo "done"
45
+ elif [[ "$exit_code" -eq 124 ]]; then
46
+ echo "timeout"
47
+ else
48
+ echo "failed"
49
+ fi
50
+ elif [[ -f "$job_dir/pid" ]]; then
51
+ pid=$(cat "$job_dir/pid")
52
+ if kill -0 "$pid" 2>/dev/null; then
53
+ # 진행 상황 힌트
54
+ local_bytes=$(wc -c < "$job_dir/stdout.log" 2>/dev/null || echo 0)
55
+ elapsed=$(( $(date +%s) - $(cat "$job_dir/start_time" 2>/dev/null || date +%s) ))
56
+ echo "running elapsed=${elapsed}s output=${local_bytes}B"
57
+ else
58
+ # 프로세스 종료됐는데 done 마커 없음 → 비정상 종료
59
+ echo "failed"
60
+ fi
61
+ else
62
+ echo "error: invalid job state"
63
+ exit 1
64
+ fi
65
+ exit 0
66
+ fi
67
+
68
+ if [[ "${1:-}" == "--job-result" ]]; then
69
+ job_id="${2:?job_id 필수}"
70
+ job_dir="$TFX_JOBS_DIR/$job_id"
71
+ [[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
72
+ [[ -f "$job_dir/done" ]] || { echo "error: job still running"; exit 1; }
73
+
74
+ cat "$job_dir/result.log" 2>/dev/null
75
+ exit_code=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
76
+ exit "$exit_code"
77
+ fi
78
+
79
+ # ── --job-wait: 내부 폴링으로 완료 대기 (Bash 도구 호출 횟수 최소화) ──
80
+ # 사용법: tfx-route.sh --job-wait <job_id> [max_seconds=540]
81
+ # 출력: 주기적 "waiting elapsed=Ns" + 최종 "done"|"timeout"|"failed"|"still_running"
82
+ if [[ "${1:-}" == "--job-wait" ]]; then
83
+ job_id="${2:?job_id 필수}"
84
+ max_wait="${3:-540}" # 기본 540초 (9분, Bash 도구 600초 제한 이내)
85
+ poll_interval=15
86
+ job_dir="$TFX_JOBS_DIR/$job_id"
87
+ [[ -d "$job_dir" ]] || { echo "error: job not found"; exit 1; }
88
+
89
+ elapsed=0
90
+ while [[ "$elapsed" -lt "$max_wait" ]]; do
91
+ if [[ -f "$job_dir/done" ]]; then
92
+ ec=$(cat "$job_dir/exit_code" 2>/dev/null || echo 1)
93
+ if [[ "$ec" -eq 0 ]]; then echo "done"
94
+ elif [[ "$ec" -eq 124 ]]; then echo "timeout"
95
+ else echo "failed (exit=$ec)"
96
+ fi
97
+ exit 0
98
+ fi
99
+ sleep "$poll_interval"
100
+ elapsed=$((elapsed + poll_interval))
101
+ stderr_bytes=$(wc -c < "$job_dir/stderr.log" 2>/dev/null || echo 0)
102
+ echo "waiting elapsed=${elapsed}s progress=${stderr_bytes}B"
103
+ done
104
+
105
+ # max_wait 도달했지만 아직 실행 중
106
+ echo "still_running elapsed=${elapsed}s"
107
+ exit 0
108
+ fi
109
+
110
+ # ── --async 플래그 감지 ──
111
+ TFX_ASYNC_MODE=0
112
+ if [[ "${1:-}" == "--async" ]]; then
113
+ TFX_ASYNC_MODE=1
114
+ shift
115
+ fi
116
+
117
+ # ── 인자 파싱 ──
118
+ AGENT_TYPE="${1:?에이전트 타입 필수 (executor, debugger, designer 등)}"
119
+ PROMPT="${2:?프롬프트 필수}"
120
+ MCP_PROFILE="${3:-auto}"
121
+ USER_TIMEOUT="${4:-}"
122
+ CONTEXT_FILE="${5:-}"
123
+
124
+ # ── CLI 이름은 route_agent()에서 기본 역할 alias로 처리됨 (codex→executor, gemini→designer, claude→explore) ──
125
+
126
+ # ── 인자 검증: MCP_PROFILE이 --flag 형태인 경우 거절 ──
127
+ if [[ "$MCP_PROFILE" == --* ]]; then
128
+ echo "ERROR: MCP 프로필 위치(3번째 인자)에 플래그 '$MCP_PROFILE'가 들어왔습니다." >&2
129
+ echo "사용법: tfx-route.sh <역할> \"프롬프트\" [mcp_profile] [timeout]" >&2
130
+ echo "지원 프로필: auto, executor, analyze, implement, review, minimal, full" >&2
131
+ exit 64
132
+ fi
133
+
134
+ # ── CLI 경로 해석 (Windows npm global 대응) ──
135
+ NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
136
+ CODEX_BIN="${CODEX_BIN:-$(command -v codex 2>/dev/null || echo codex)}"
137
+ GEMINI_BIN="${GEMINI_BIN:-$(command -v gemini 2>/dev/null || echo gemini)}"
138
+ CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude 2>/dev/null || echo claude)}"
139
+ GEMINI_BIN_ARGS_JSON="${GEMINI_BIN_ARGS_JSON:-[]}"
140
+ CLAUDE_BIN_ARGS_JSON="${CLAUDE_BIN_ARGS_JSON:-[]}"
141
+
142
+ # ── 상수 ──
143
+ MAX_STDOUT_BYTES=51200 # 50KB — Claude 컨텍스트 절약
144
+ TIMESTAMP=$(date +%s)
145
+ RUN_ID="${TIMESTAMP}-$$-${RANDOM}"
146
+ STDERR_LOG="/tmp/tfx-route-${AGENT_TYPE}-${RUN_ID}-stderr.log"
147
+ STDOUT_LOG="/tmp/tfx-route-${AGENT_TYPE}-${RUN_ID}-stdout.log"
148
+ TFX_TMP="${TMPDIR:-/tmp}"
149
+
150
+ # ── 팀 환경변수 ──
151
+ TFX_TEAM_NAME="${TFX_TEAM_NAME:-}"
152
+ TFX_TEAM_TASK_ID="${TFX_TEAM_TASK_ID:-}"
153
+ TFX_TEAM_AGENT_NAME="${TFX_TEAM_AGENT_NAME:-${AGENT_TYPE}-worker-$$}"
154
+ TFX_TEAM_LEAD_NAME="${TFX_TEAM_LEAD_NAME:-team-lead}"
155
+ TFX_HUB_PIPE="${TFX_HUB_PIPE:-}"
156
+ TFX_HUB_URL="${TFX_HUB_URL:-http://127.0.0.1:27888}" # bridge.mjs HTTP fallback hint
157
+
158
+ # ── 패키지 루트 해석 (setup.mjs가 기록한 breadcrumb) ──
159
+ TFX_PKG_ROOT=""
160
+ _tfx_breadcrumb="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.tfx-pkg-root"
161
+ if [[ -f "$_tfx_breadcrumb" ]]; then
162
+ TFX_PKG_ROOT="$(head -1 "$_tfx_breadcrumb" 2>/dev/null | tr -d '\r\n')"
163
+ fi
164
+ unset _tfx_breadcrumb
165
+
166
+ # fallback 시 원래 에이전트 정보 보존
167
+ ORIGINAL_AGENT=""
168
+ ORIGINAL_CLI_ARGS=""
169
+
170
+ # JSON 문자열 이스케이프:
171
+ # - "\", """ 필수 이스케이프
172
+ # - 제어문자 U+0000..U+001F 이스케이프
173
+ # - 비ASCII 문자는 \uXXXX(또는 surrogate pair)로 강제
174
+ json_escape() {
175
+ local s="${1:-}"
176
+
177
+ if command -v "$NODE_BIN" &>/dev/null; then
178
+ "$NODE_BIN" -e '
179
+ const input = process.argv[1] ?? "";
180
+ let out = "";
181
+ for (const ch of input) {
182
+ const cp = ch.codePointAt(0);
183
+ if (cp === 0x22) { out += "\\\""; continue; } // "
184
+ if (cp === 0x5c) { out += "\\\\"; continue; } // \
185
+ if (cp <= 0x1f) {
186
+ if (cp === 0x08) { out += "\\b"; continue; }
187
+ if (cp === 0x09) { out += "\\t"; continue; }
188
+ if (cp === 0x0a) { out += "\\n"; continue; }
189
+ if (cp === 0x0c) { out += "\\f"; continue; }
190
+ if (cp === 0x0d) { out += "\\r"; continue; }
191
+ out += `\\u${cp.toString(16).padStart(4, "0")}`;
192
+ continue;
193
+ }
194
+ if (cp >= 0x20 && cp <= 0x7e) {
195
+ out += ch;
196
+ continue;
197
+ }
198
+ if (cp <= 0xffff) {
199
+ out += `\\u${cp.toString(16).padStart(4, "0")}`;
200
+ continue;
201
+ }
202
+ const v = cp - 0x10000;
203
+ const hi = 0xd800 + (v >> 10);
204
+ const lo = 0xdc00 + (v & 0x3ff);
205
+ out += `\\u${hi.toString(16).padStart(4, "0")}\\u${lo.toString(16).padStart(4, "0")}`;
206
+ }
207
+ process.stdout.write(out);
208
+ ' -- "$s"
209
+ return
210
+ fi
211
+
212
+ echo "[tfx-route] ERROR: node 미설치로 안전한 JSON 이스케이프를 수행할 수 없습니다." >&2
213
+ return 1
214
+ }
215
+
216
+ # ── Per-process 에이전트 등록 (원자적, 락 불필요) ──
217
+ register_agent() {
218
+ local agent_file="${TFX_TMP}/tfx-agent-$$.json"
219
+ local safe_cli safe_agent started_at
220
+ safe_cli=$(json_escape "$CLI_TYPE" 2>/dev/null || true)
221
+ safe_agent=$(json_escape "$AGENT_TYPE" 2>/dev/null || true)
222
+ started_at=$(date +%s)
223
+
224
+ # fail-closed: 안전 인코딩 불가 시 agent 파일을 쓰지 않는다
225
+ if [[ -n "$CLI_TYPE" && -z "$safe_cli" ]]; then
226
+ return 0
227
+ fi
228
+ if [[ -n "$AGENT_TYPE" && -z "$safe_agent" ]]; then
229
+ return 0
230
+ fi
231
+
232
+ printf '{"pid":%s,"cli":"%s","agent":"%s","started":%s}\n' "$$" "$safe_cli" "$safe_agent" "$started_at" \
233
+ > "$agent_file" 2>/dev/null || true
234
+ }
235
+
236
+ deregister_agent() {
237
+ rm -f "${TFX_TMP}/tfx-agent-$$.json" 2>/dev/null || true
238
+ }
239
+
240
+ normalize_script_path() {
241
+ local path="${1:-}"
242
+ if [[ -z "$path" ]]; then
243
+ return 0
244
+ fi
245
+
246
+ if command -v cygpath &>/dev/null; then
247
+ case "$path" in
248
+ [A-Za-z]:\\*|[A-Za-z]:/*)
249
+ cygpath -u "$path"
250
+ return 0
251
+ ;;
252
+ esac
253
+ fi
254
+
255
+ printf '%s\n' "$path"
256
+ }
257
+
258
+ # ── 팀 Hub Bridge 통신 ──
259
+ resolve_bridge_script() {
260
+ if [[ -n "${TFX_BRIDGE_SCRIPT:-}" && -f "$TFX_BRIDGE_SCRIPT" ]]; then
261
+ printf '%s\n' "$TFX_BRIDGE_SCRIPT"
262
+ return 0
263
+ fi
264
+
265
+ local script_dir
266
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
267
+ local candidates=()
268
+ [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/bridge.mjs")
269
+ candidates+=(
270
+ "$script_dir/../hub/bridge.mjs"
271
+ "$script_dir/hub/bridge.mjs"
272
+ )
273
+
274
+ local candidate
275
+ for candidate in "${candidates[@]}"; do
276
+ if [[ -f "$candidate" ]]; then
277
+ printf '%s\n' "$candidate"
278
+ return 0
279
+ fi
280
+ done
281
+
282
+ return 1
283
+ }
284
+
285
+ bridge_cli() {
286
+ if ! command -v "$NODE_BIN" &>/dev/null; then
287
+ return 127
288
+ fi
289
+
290
+ local bridge_script
291
+ if ! bridge_script=$(resolve_bridge_script); then
292
+ return 127
293
+ fi
294
+
295
+ TFX_HUB_PIPE="$TFX_HUB_PIPE" TFX_HUB_URL="$TFX_HUB_URL" TFX_HUB_TOKEN="${TFX_HUB_TOKEN:-}" \
296
+ "$NODE_BIN" "$bridge_script" "$@" 2>/dev/null
297
+ }
298
+
299
+ bridge_json_get() {
300
+ local json="${1:-}"
301
+ local path="${2:-}"
302
+ [[ -z "$json" || -z "$path" ]] && return 1
303
+
304
+ "$NODE_BIN" -e '
305
+ const data = JSON.parse(process.argv[1] || "{}");
306
+ const keys = String(process.argv[2] || "").split(".").filter(Boolean);
307
+ let value = data;
308
+ for (const key of keys) value = value?.[key];
309
+ if (value === undefined || value === null) process.exit(1);
310
+ process.stdout.write(typeof value === "object" ? JSON.stringify(value) : String(value));
311
+ ' -- "$json" "$path" 2>/dev/null
312
+ }
313
+
314
+ bridge_json_stringify() {
315
+ local mode="${1:-}"
316
+ shift || true
317
+
318
+ case "$mode" in
319
+ metadata-patch)
320
+ "$NODE_BIN" -e '
321
+ process.stdout.write(JSON.stringify({
322
+ result: process.argv[1] || "",
323
+ summary: process.argv[2] || "",
324
+ }));
325
+ ' -- "${1:-}" "${2:-}"
326
+ ;;
327
+ task-result)
328
+ "$NODE_BIN" -e '
329
+ process.stdout.write(JSON.stringify({
330
+ task_id: process.argv[1] || "",
331
+ result: process.argv[2] || "",
332
+ }));
333
+ ' -- "${1:-}" "${2:-}"
334
+ ;;
335
+ *)
336
+ return 1
337
+ ;;
338
+ esac
339
+ }
340
+
341
+ team_send_message() {
342
+ local text="${1:-}"
343
+ local summary="${2:-}"
344
+ [[ -z "$TFX_TEAM_NAME" || -z "$text" ]] && return 0
345
+
346
+ if ! bridge_cli_with_restart "팀 메시지 전송" "Hub 재시작 후 팀 메시지 전송 성공." \
347
+ team-send-message \
348
+ --team "$TFX_TEAM_NAME" \
349
+ --from "$TFX_TEAM_AGENT_NAME" \
350
+ --to "$TFX_TEAM_LEAD_NAME" \
351
+ --text "$text" \
352
+ --summary "${summary:-status update}"; then
353
+ echo "[tfx-route] 경고: 팀 메시지 전송 실패 (team=$TFX_TEAM_NAME, to=$TFX_TEAM_LEAD_NAME)" >&2
354
+ return 0
355
+ fi
356
+
357
+ return 0
358
+ }
359
+
360
+ # ── Hub 자동 재시작 (슬립 복귀 등으로 Hub 종료 시) ──
361
+ try_restart_hub() {
362
+ local hub_server script_dir hub_port
363
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
364
+ hub_server=""
365
+ local _hub_candidates=()
366
+ [[ -n "$TFX_PKG_ROOT" ]] && _hub_candidates+=("$TFX_PKG_ROOT/hub/server.mjs")
367
+ _hub_candidates+=("$script_dir/../hub/server.mjs")
368
+ for _hc in "${_hub_candidates[@]}"; do
369
+ if [[ -f "$_hc" ]]; then hub_server="$_hc"; break; fi
370
+ done
371
+ unset _hub_candidates _hc
372
+
373
+ if [[ -z "$hub_server" ]]; then
374
+ echo "[tfx-route] Hub 서버 스크립트 미발견 (pkg_root=${TFX_PKG_ROOT:-unset}, script_dir=$script_dir)" >&2
375
+ return 1
376
+ fi
377
+
378
+ # TFX_HUB_URL에서 포트 추출 (기본 27888)
379
+ hub_port="${TFX_HUB_URL##*:}"
380
+ hub_port="${hub_port%%/*}"
381
+ [[ -z "$hub_port" || "$hub_port" == "$TFX_HUB_URL" ]] && hub_port=27888
382
+
383
+ echo "[tfx-route] Hub 미응답 — 자동 재시작 시도 (port=$hub_port)..." >&2
384
+ TFX_HUB_PORT="$hub_port" "$NODE_BIN" "$hub_server" &>/dev/null &
385
+ local hub_pid=$!
386
+
387
+ # 최대 4초 대기 (0.5초 간격)
388
+ local i
389
+ for i in 1 2 3 4 5 6 7 8; do
390
+ sleep 0.5
391
+ if curl -sf "${TFX_HUB_URL}/status" >/dev/null 2>&1; then
392
+ echo "[tfx-route] Hub 재시작 성공 (pid=$hub_pid)" >&2
393
+ return 0
394
+ fi
395
+ done
396
+
397
+ echo "[tfx-route] Hub 재시작 실패 — claim 없이 계속 실행" >&2
398
+ return 1
399
+ }
400
+
401
+ bridge_cli_with_restart() {
402
+ local action_label="${1:-bridge 호출}"
403
+ local success_message="${2:-}"
404
+ shift 2 || true
405
+
406
+ if bridge_cli "$@" >/dev/null 2>&1; then
407
+ return 0
408
+ fi
409
+
410
+ if ! try_restart_hub; then
411
+ return 1
412
+ fi
413
+
414
+ if bridge_cli "$@" >/dev/null 2>&1; then
415
+ [[ -n "$success_message" ]] && echo "[tfx-route] ${success_message}" >&2
416
+ return 0
417
+ fi
418
+
419
+ echo "[tfx-route] 경고: Hub 재시작 후 ${action_label} 재시도 실패." >&2
420
+ return 1
421
+ }
422
+
423
+ team_claim_task() {
424
+ [[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
425
+ local response ok error_code error_message owner_before status_before
426
+ response=$(bridge_cli team-task-update \
427
+ --team "$TFX_TEAM_NAME" \
428
+ --task-id "$TFX_TEAM_TASK_ID" \
429
+ --claim \
430
+ --owner "$TFX_TEAM_AGENT_NAME" \
431
+ --status in_progress || true)
432
+
433
+ ok=$(bridge_json_get "$response" "ok" || true)
434
+ error_code=$(bridge_json_get "$response" "error.code" || true)
435
+ error_message=$(bridge_json_get "$response" "error.message" || true)
436
+ owner_before=$(bridge_json_get "$response" "error.details.task_before.owner" || true)
437
+ status_before=$(bridge_json_get "$response" "error.details.task_before.status" || true)
438
+
439
+ case "$ok:$error_code" in
440
+ true:*) ;;
441
+ false:CLAIM_CONFLICT)
442
+ if [[ "$owner_before" == "$TFX_TEAM_AGENT_NAME" && "$status_before" == "in_progress" ]]; then
443
+ echo "[tfx-route] 동일 owner(${TFX_TEAM_AGENT_NAME})가 이미 claim한 task ${TFX_TEAM_TASK_ID} — 계속 실행." >&2
444
+ return 0
445
+ fi
446
+ echo "[tfx-route] CLAIM_CONFLICT: task ${TFX_TEAM_TASK_ID}가 이미 claim됨(owner=${owner_before:-unknown}, status=${status_before:-unknown}). 실행 중단." >&2
447
+ team_send_message \
448
+ "task ${TFX_TEAM_TASK_ID} claim conflict: owner=${owner_before:-unknown}, status=${status_before:-unknown}" \
449
+ "task ${TFX_TEAM_TASK_ID} claim conflict"
450
+ exit 0 ;;
451
+ :|false:)
452
+ # Hub 연결 실패 → 자동 재시작 시도 후 claim 재시도
453
+ if try_restart_hub; then
454
+ response=$(bridge_cli team-task-update \
455
+ --team "$TFX_TEAM_NAME" \
456
+ --task-id "$TFX_TEAM_TASK_ID" \
457
+ --claim \
458
+ --owner "$TFX_TEAM_AGENT_NAME" \
459
+ --status in_progress || true)
460
+ ok=$(bridge_json_get "$response" "ok" || true)
461
+ if [[ "$ok" == "true" ]]; then
462
+ echo "[tfx-route] Hub 재시작 후 claim 성공." >&2
463
+ else
464
+ echo "[tfx-route] 경고: Hub 재시작 후 claim 실패. claim 없이 계속 실행." >&2
465
+ fi
466
+ else
467
+ echo "[tfx-route] 경고: Hub 연결 실패 (미실행?). claim 없이 계속 실행." >&2
468
+ fi ;;
469
+ *)
470
+ echo "[tfx-route] 경고: Hub claim 실패 (${error_code:-unknown}${error_message:+: ${error_message}}). claim 없이 계속 실행." >&2 ;;
471
+ esac
472
+ }
473
+
474
+ team_complete_task() {
475
+ local result="${1:-success}" # success/failed/timeout
476
+ local result_summary="${2:-작업 완료}"
477
+ [[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
478
+
479
+ local summary_trimmed result_payload
480
+ summary_trimmed=$(echo "$result_summary" | head -c 4096)
481
+ result_payload=$(bridge_json_stringify task-result "$TFX_TEAM_TASK_ID" "$result" 2>/dev/null || true)
482
+
483
+ # task 파일 completion 쓰기는 Worker Step 6 TaskUpdate가 authority다.
484
+ # route 레벨에서는 task.result 발행 + 로컬 backup만 유지한다.
485
+
486
+ # Hub result 발행 (poll_messages 채널 활성화)
487
+ if [[ -n "$result_payload" ]]; then
488
+ if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
489
+ result \
490
+ --agent "$TFX_TEAM_AGENT_NAME" \
491
+ --topic task.result \
492
+ --payload "$result_payload" \
493
+ --trace "$TFX_TEAM_NAME"; then
494
+ echo "[tfx-route] 경고: Hub result 발행 실패 (agent=$TFX_TEAM_AGENT_NAME, task=$TFX_TEAM_TASK_ID)" >&2
495
+ fi
496
+ fi
497
+
498
+ # 로컬 결과 파일 백업 (세션 끊김 복구용)
499
+ # Claude 재로그인 시 Agent 래퍼가 죽어도 이 파일로 결과 수집 가능
500
+ local result_dir="${TFX_RESULT_DIR:-${HOME}/.claude/tfx-results/${TFX_TEAM_NAME}}"
501
+ if mkdir -p "$result_dir" 2>/dev/null; then
502
+ cat > "${result_dir}/${TFX_TEAM_TASK_ID}.json" 2>/dev/null <<RESULT_EOF
503
+ {"taskId":"${TFX_TEAM_TASK_ID}","agent":"${TFX_TEAM_AGENT_NAME}","team":"${TFX_TEAM_NAME}","result":"${result}","summary":$(printf '%s' "$summary_trimmed" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.stringify(d)))" 2>/dev/null || echo '""'),"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
504
+ RESULT_EOF
505
+ [[ $? -eq 0 ]] && echo "[tfx-route] 결과 백업: ${result_dir}/${TFX_TEAM_TASK_ID}.json" >&2
506
+ fi
507
+ }
508
+
509
+ detect_quota_exceeded() {
510
+ local stdout_file="$1"
511
+ local stderr_file="$2"
512
+ local -a patterns=(
513
+ "usage limit exceeded" "rate limit exceeded" "rate limit reached"
514
+ "try again at" "purchase more credits"
515
+ "quota exceeded" "RESOURCE_EXHAUSTED" "rateLimitExceeded" "Too Many Requests"
516
+ "rate_limit_error" "overloaded_error" "insufficient_quota"
517
+ )
518
+ local pattern
519
+ for pattern in "${patterns[@]}"; do
520
+ if grep -qi "$pattern" "$stdout_file" 2>/dev/null || grep -qi "$pattern" "$stderr_file" 2>/dev/null; then
521
+ echo "[tfx-quota] 감지: '$pattern' in $CLI_TYPE" >&2
522
+ return 0
523
+ fi
524
+ done
525
+ return 1
526
+ }
527
+
528
+ auto_reroute() {
529
+ local failed_cli="$1"
530
+ local target_cli=""
531
+ case "$failed_cli" in
532
+ codex) target_cli="gemini"; echo "[tfx-quota] Codex → Gemini 자동 전환" >&2 ;;
533
+ gemini) target_cli="codex"; echo "[tfx-quota] Gemini → Codex 자동 전환" >&2 ;;
534
+ *) echo "[tfx-quota] $failed_cli 대체 CLI 없음" >&2; return 1 ;;
535
+ esac
536
+
537
+ # 대상 CLI 존재 확인 (P2: command not found 방지)
538
+ local target_bin
539
+ case "$target_cli" in
540
+ codex) target_bin="$CODEX_BIN" ;;
541
+ gemini) target_bin="$GEMINI_BIN" ;;
542
+ esac
543
+ if ! command -v "$target_bin" &>/dev/null; then
544
+ echo "[tfx-quota] $target_cli CLI 미설치 — 자동 전환 불가" >&2
545
+ return 1
546
+ fi
547
+
548
+ local quota_marker="$TFX_TMP/tfx-quota-${failed_cli}-$(date +%Y%m%d)"
549
+ echo "$(date +%s)" >> "$quota_marker"
550
+ ORIGINAL_AGENT="$AGENT_TYPE"
551
+ ORIGINAL_CLI_ARGS="$CLI_ARGS"
552
+ export TFX_REROUTED_FROM="$CLI_TYPE"
553
+ TFX_CLI_MODE="$target_cli" exec bash "${BASH_SOURCE[0]}" \
554
+ "$AGENT_TYPE" "$PROMPT" "$MCP_PROFILE" "$USER_TIMEOUT" "$CONTEXT_FILE"
555
+ }
556
+
557
+ capture_workspace_signature() {
558
+ if ! command -v git &>/dev/null; then
559
+ return 1
560
+ fi
561
+
562
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
563
+ return 1
564
+ fi
565
+
566
+ git status --short --untracked-files=all --ignore-submodules=all 2>/dev/null || return 1
567
+ }
568
+
569
+ # ── 라우팅 테이블 ──
570
+ # CLI_TYPE/CLI_CMD: agent-map.json 단일 소스. 상세 설정: 아래 case 문.
571
+ # 반환: CLI_TYPE, CLI_CMD, CLI_ARGS, CLI_EFFORT, DEFAULT_TIMEOUT, RUN_MODE, OPUS_OVERSIGHT
572
+ route_agent() {
573
+ local agent="$1"
574
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
575
+ local map_file
576
+ map_file="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../hub/team/agent-map.json"
577
+
578
+ # ── CLI_TYPE: 단일 소스 (agent-map.json) ──
579
+ local _raw_type
580
+ _raw_type=$(node -e "
581
+ const p=require('path').resolve(process.argv[1]);
582
+ const m=JSON.parse(require('fs').readFileSync(p,'utf8'));
583
+ const t=m[process.argv[2]];
584
+ if(t)process.stdout.write(t);
585
+ " "$map_file" "$agent" 2>/dev/null)
586
+
587
+ if [[ -z "$_raw_type" ]]; then
588
+ echo "ERROR: 없는 에이전트 타입: $agent" >&2
589
+ echo "사용 가능: $(node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync(require('path').resolve(process.argv[1]),'utf8'))).join(', '))" "$map_file" 2>/dev/null)" >&2
590
+ exit 1
591
+ fi
592
+
593
+ # "claude" → "claude-native" (headless.mjs는 "claude", route.sh는 "claude-native")
594
+ CLI_TYPE="$_raw_type"
595
+ [[ "$CLI_TYPE" == "claude" ]] && CLI_TYPE="claude-native"
596
+
597
+ # ── CLI_CMD: CLI_TYPE에서 파생 ──
598
+ case "$CLI_TYPE" in
599
+ codex) CLI_CMD="codex" ;;
600
+ gemini) CLI_CMD="gemini" ;;
601
+ claude-native) CLI_CMD=""; CLI_ARGS="" ;;
602
+ esac
603
+
604
+ # ── 에이전트별 상세 설정 ──
605
+ case "$agent" in
606
+ # ─── 구현 레인 ───
607
+ executor)
608
+ CLI_ARGS="exec ${codex_base}"
609
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
610
+ build-fixer)
611
+ CLI_ARGS="exec --profile fast ${codex_base}"
612
+ CLI_EFFORT="fast"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
613
+ debugger)
614
+ CLI_ARGS="exec ${codex_base}"
615
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
616
+ deep-executor)
617
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
618
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
619
+
620
+ # ─── 설계/분석 레인 ───
621
+ architect)
622
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
623
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
624
+ planner)
625
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
626
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
627
+ critic)
628
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
629
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
630
+ analyst)
631
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
632
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
633
+
634
+ # ─── 리뷰 레인 ───
635
+ code-reviewer)
636
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
637
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
638
+ security-reviewer)
639
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
640
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
641
+ quality-reviewer)
642
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
643
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
644
+
645
+ # ─── 리서치 레인 ───
646
+ scientist)
647
+ CLI_ARGS="exec ${codex_base}"
648
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
649
+ scientist-deep)
650
+ CLI_ARGS="exec --profile thorough ${codex_base}"
651
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
652
+ document-specialist)
653
+ CLI_ARGS="exec ${codex_base}"
654
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
655
+
656
+ # ─── UI/문서 레인 ───
657
+ designer)
658
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
659
+ CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
660
+ writer)
661
+ CLI_ARGS="-m gemini-3-flash-preview -y --prompt"
662
+ CLI_EFFORT="flash"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
663
+
664
+ # ─── 탐색/검증/테스트 (Claude-native 우선, TFX_NO_CLAUDE_NATIVE=1일 때만 Codex 리매핑) ───
665
+ explore|verifier|test-engineer|qa-tester)
666
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
667
+ case "$agent" in
668
+ test-engineer|qa-tester) DEFAULT_TIMEOUT=1200; RUN_MODE="bg" ;;
669
+ esac
670
+ ;;
671
+
672
+ # ─── 경량 ───
673
+ spark)
674
+ CLI_ARGS="exec --profile spark_fast ${codex_base}"
675
+ CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
676
+ # ─── CLI 이름 alias (사용자 편의) ───
677
+ codex)
678
+ CLI_ARGS="exec ${codex_base}"
679
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
680
+ gemini)
681
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
682
+ CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
683
+ claude)
684
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
685
+ # ─── agent-map.json에만 정의된 신규 에이전트 (CLI_TYPE별 기본값) ───
686
+ *)
687
+ case "$CLI_TYPE" in
688
+ codex)
689
+ CLI_ARGS="exec ${codex_base}"
690
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
691
+ gemini)
692
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
693
+ CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
694
+ claude-native)
695
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
696
+ esac ;;
697
+ esac
698
+ }
699
+
700
+ # ── CLI 모드 오버라이드 (tfx-codex / tfx-gemini 스킬용) ──
701
+ TFX_CLI_MODE="${TFX_CLI_MODE:-auto}"
702
+ TFX_NO_CLAUDE_NATIVE="${TFX_NO_CLAUDE_NATIVE:-0}"
703
+ TFX_VERIFIER_OVERRIDE="${TFX_VERIFIER_OVERRIDE:-auto}"
704
+ TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-exec}"
705
+ # Codex 요금제 자동 감지 (preflight 캐시 → auth.json JWT)
706
+ # 환경변수 명시 설정 시 우선, 미설정 시 캐시에서 읽기, 캐시도 없으면 pro
707
+ if [[ -z "${TFX_CODEX_PLAN:-}" ]]; then
708
+ _detected_plan=$(node -e '
709
+ try {
710
+ const c = JSON.parse(require("fs").readFileSync(require("path").join(require("os").homedir(),".claude","cache","tfx-preflight.json"),"utf8"));
711
+ const p = c?.codex_plan?.plan;
712
+ if (p && p !== "unknown" && p !== "api") { process.stdout.write(p); }
713
+ } catch {}
714
+ ' 2>/dev/null)
715
+ TFX_CODEX_PLAN="${_detected_plan:-pro}"
716
+ unset _detected_plan
717
+ fi
718
+ TFX_WORKER_INDEX="${TFX_WORKER_INDEX:-}"
719
+ TFX_SEARCH_TOOL="${TFX_SEARCH_TOOL:-}"
720
+ case "$TFX_NO_CLAUDE_NATIVE" in
721
+ 0|1) ;;
722
+ *)
723
+ echo "ERROR: TFX_NO_CLAUDE_NATIVE 값은 0 또는 1이어야 합니다. (현재: $TFX_NO_CLAUDE_NATIVE)" >&2
724
+ exit 1
725
+ ;;
726
+ esac
727
+ case "$TFX_CODEX_PLAN" in
728
+ pro|plus|free) ;;
729
+ *)
730
+ echo "ERROR: TFX_CODEX_PLAN 값은 pro, plus, free 중 하나여야 합니다. (현재: $TFX_CODEX_PLAN)" >&2
731
+ exit 1
732
+ ;;
733
+ esac
734
+ case "$TFX_CODEX_TRANSPORT" in
735
+ auto|mcp|exec) ;;
736
+ *)
737
+ echo "ERROR: TFX_CODEX_TRANSPORT 값은 auto, mcp, exec 중 하나여야 합니다. (현재: $TFX_CODEX_TRANSPORT)" >&2
738
+ exit 1
739
+ ;;
740
+ esac
741
+ case "$TFX_VERIFIER_OVERRIDE" in
742
+ auto|claude) ;;
743
+ *)
744
+ echo "ERROR: TFX_VERIFIER_OVERRIDE 값은 auto 또는 claude여야 합니다. (현재: $TFX_VERIFIER_OVERRIDE)" >&2
745
+ exit 1
746
+ ;;
747
+ esac
748
+ case "$TFX_WORKER_INDEX" in
749
+ "") ;;
750
+ *[!0-9]*|0)
751
+ echo "ERROR: TFX_WORKER_INDEX 값은 1 이상의 정수여야 합니다. (현재: $TFX_WORKER_INDEX)" >&2
752
+ exit 1
753
+ ;;
754
+ esac
755
+ case "$TFX_SEARCH_TOOL" in
756
+ ""|brave-search|tavily|exa) ;;
757
+ *)
758
+ echo "ERROR: TFX_SEARCH_TOOL 값은 brave-search, tavily, exa 중 하나여야 합니다. (현재: $TFX_SEARCH_TOOL)" >&2
759
+ exit 1
760
+ ;;
761
+ esac
762
+ CODEX_MCP_TRANSPORT_EXIT_CODE=70
763
+
764
+ apply_cli_mode() {
765
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
766
+
767
+ case "$TFX_CLI_MODE" in
768
+ codex)
769
+ if [[ "$CLI_TYPE" == "gemini" ]]; then
770
+ CLI_TYPE="codex"; CLI_CMD="codex"
771
+ case "$AGENT_TYPE" in
772
+ designer)
773
+ CLI_ARGS="exec ${codex_base}"; CLI_EFFORT="high"; DEFAULT_TIMEOUT=600 ;;
774
+ writer)
775
+ CLI_ARGS="exec --profile spark_fast ${codex_base}"; CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180 ;;
776
+ esac
777
+ echo "[tfx-route] TFX_CLI_MODE=codex: $AGENT_TYPE → codex($CLI_EFFORT)로 리매핑" >&2
778
+ fi ;;
779
+ gemini)
780
+ if [[ "$CLI_TYPE" == "codex" ]]; then
781
+ CLI_TYPE="gemini"; CLI_CMD="gemini"
782
+ case "$AGENT_TYPE" in
783
+ executor|debugger|deep-executor|architect|planner|critic|analyst|\
784
+ code-reviewer|security-reviewer|quality-reviewer|scientist-deep)
785
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"; CLI_EFFORT="pro" ;;
786
+ build-fixer|spark)
787
+ CLI_ARGS="-m gemini-3-flash-preview -y --prompt"; CLI_EFFORT="flash"; DEFAULT_TIMEOUT=180 ;;
788
+ *)
789
+ CLI_ARGS="-m gemini-3-flash-preview -y --prompt"; CLI_EFFORT="flash" ;;
790
+ esac
791
+ echo "[tfx-route] TFX_CLI_MODE=gemini: $AGENT_TYPE → gemini($CLI_EFFORT)로 리매핑" >&2
792
+ fi ;;
793
+ auto)
794
+ if [[ "$CLI_TYPE" == "codex" ]] && ! command -v "$CODEX_BIN" &>/dev/null; then
795
+ if command -v "$GEMINI_BIN" &>/dev/null; then
796
+ TFX_CLI_MODE="gemini"; apply_cli_mode; return
797
+ else
798
+ ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
799
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
800
+ echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE claude-native fallback" >&2
801
+ fi
802
+ elif [[ "$CLI_TYPE" == "gemini" ]] && ! command -v "$GEMINI_BIN" &>/dev/null; then
803
+ if command -v "$CODEX_BIN" &>/dev/null; then
804
+ TFX_CLI_MODE="codex"; apply_cli_mode; return
805
+ else
806
+ ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
807
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
808
+ echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
809
+ fi
810
+ fi ;;
811
+ esac
812
+ }
813
+
814
+ # ── Codex 요금제 가드 (fast 프로필은 Pro 전용) ──
815
+ apply_plan_guard() {
816
+ [[ "$CLI_TYPE" != "codex" ]] && return
817
+ [[ "$TFX_CODEX_PLAN" == "pro" ]] && return
818
+
819
+ if [[ "$CLI_EFFORT" == "fast" ]]; then
820
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
821
+ CLI_ARGS="exec ${codex_base}"
822
+ CLI_EFFORT="high"
823
+ echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: --profile fast → high로 다운그레이드 (Pro 전용)" >&2
824
+ fi
825
+ }
826
+
827
+ # ── Claude 네이티브 제거 (Codex 리드 환경에서 선택적 활성화) ──
828
+ apply_no_claude_native_mode() {
829
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
830
+
831
+ [[ "$TFX_NO_CLAUDE_NATIVE" != "1" ]] && return
832
+ [[ "$TFX_CLI_MODE" == "gemini" ]] && return
833
+ [[ "$CLI_TYPE" != "claude-native" ]] && return
834
+
835
+ if ! command -v "$CODEX_BIN" &>/dev/null; then
836
+ echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1 이지만 codex를 찾지 못해 claude-native 유지" >&2
837
+ return
838
+ fi
839
+
840
+ ORIGINAL_AGENT="${AGENT_TYPE}"
841
+ CLI_TYPE="codex"; CLI_CMD="codex"
842
+
843
+ case "$AGENT_TYPE" in
844
+ explore)
845
+ CLI_ARGS="exec --profile fast ${codex_base}"
846
+ CLI_EFFORT="fast"
847
+ DEFAULT_TIMEOUT=600
848
+ RUN_MODE="fg"
849
+ OPUS_OVERSIGHT="false"
850
+ ;;
851
+ verifier)
852
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
853
+ CLI_EFFORT="thorough"
854
+ DEFAULT_TIMEOUT=1200
855
+ RUN_MODE="fg"
856
+ OPUS_OVERSIGHT="false"
857
+ ;;
858
+ test-engineer)
859
+ CLI_ARGS="exec ${codex_base}"
860
+ CLI_EFFORT="high"
861
+ DEFAULT_TIMEOUT=1200
862
+ RUN_MODE="bg"
863
+ OPUS_OVERSIGHT="false"
864
+ ;;
865
+ qa-tester)
866
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
867
+ CLI_EFFORT="thorough"
868
+ DEFAULT_TIMEOUT=1200
869
+ RUN_MODE="bg"
870
+ OPUS_OVERSIGHT="false"
871
+ ;;
872
+ *)
873
+ # claude-native 타입 중 위에 없는 경우는 보수적으로 유지
874
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
875
+ return
876
+ ;;
877
+ esac
878
+
879
+ echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1: $AGENT_TYPE -> codex($CLI_EFFORT) 리매핑" >&2
880
+ }
881
+
882
+ apply_verifier_override() {
883
+ [[ "$AGENT_TYPE" != "verifier" ]] && return
884
+
885
+ case "$TFX_VERIFIER_OVERRIDE" in
886
+ auto|"")
887
+ return 0
888
+ ;;
889
+ claude)
890
+ ORIGINAL_AGENT="${ORIGINAL_AGENT:-$AGENT_TYPE}"
891
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
892
+ echo "[tfx-route] TFX_VERIFIER_OVERRIDE=claude: verifier -> claude-native" >&2
893
+ ;;
894
+ esac
895
+
896
+ return 0
897
+ }
898
+
899
+ # ── MCP 인벤토리 캐시 ──
900
+ MCP_CACHE="${HOME}/.claude/cache/mcp-inventory.json"
901
+ MCP_FILTER_SCRIPT=""
902
+ MCP_PROFILE_REQUESTED="auto"
903
+ MCP_RESOLVED_PROFILE="default"
904
+ MCP_HINT=""
905
+ GEMINI_ALLOWED_SERVERS=()
906
+ CODEX_CONFIG_FLAGS=()
907
+ CODEX_CONFIG_JSON=""
908
+
909
+ get_cached_servers() {
910
+ local cli_type="$1"
911
+ if [[ -f "$MCP_CACHE" ]]; then
912
+ node -e 'const[,f,t]=process.argv;const inv=JSON.parse(require("fs").readFileSync(f,"utf8"));const s=(inv[t]||{}).servers||[];console.log(s.filter(x=>x.status==="enabled"||x.status==="configured").map(x=>x.name).join(","))' -- "$MCP_CACHE" "$cli_type" 2>/dev/null
913
+ fi
914
+ }
915
+
916
+ resolve_mcp_filter_script() {
917
+ if [[ -n "$MCP_FILTER_SCRIPT" && -f "$MCP_FILTER_SCRIPT" ]]; then
918
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
919
+ return 0
920
+ fi
921
+
922
+ local script_ref script_dir candidate
923
+ local -a candidates=()
924
+
925
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
926
+ if [[ -n "$script_ref" ]]; then
927
+ script_dir="$(cd "$(dirname "$script_ref")" 2>/dev/null && pwd -P || true)"
928
+ [[ -n "$script_dir" ]] && candidates+=("$script_dir/lib/mcp-filter.mjs")
929
+ fi
930
+
931
+ candidates+=(
932
+ "$PWD/scripts/lib/mcp-filter.mjs"
933
+ "$PWD/lib/mcp-filter.mjs"
934
+ )
935
+
936
+ for candidate in "${candidates[@]}"; do
937
+ if [[ -f "$candidate" ]]; then
938
+ MCP_FILTER_SCRIPT="$candidate"
939
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
940
+ return 0
941
+ fi
942
+ done
943
+
944
+ return 1
945
+ }
946
+
947
+ resolve_mcp_policy() {
948
+ local filter_script available_servers
949
+ if ! filter_script=$(resolve_mcp_filter_script); then
950
+ echo "[tfx-route] 경고: mcp-filter.mjs찾지 못해 기본 MCP 정책을 사용합니다." >&2
951
+ MCP_PROFILE_REQUESTED="$MCP_PROFILE"
952
+ MCP_RESOLVED_PROFILE="$MCP_PROFILE"
953
+ MCP_HINT=""
954
+ GEMINI_ALLOWED_SERVERS=()
955
+ CODEX_CONFIG_FLAGS=()
956
+ CODEX_CONFIG_JSON=""
957
+ return 0
958
+ fi
959
+
960
+ available_servers=$(get_cached_servers "$CLI_TYPE")
961
+ # Codex 0.115+: 미등록 서버에 config override(enabled=true/false 모두)를 보내면
962
+ # "invalid transport" 에러 발생. 캐시 비어있으면 빈 문자열로 유지하여
963
+ # mcp-filter가 override를 생성하지 않도록 한다.
964
+ [[ -z "$available_servers" ]] && available_servers=""
965
+
966
+ local -a cmd=(
967
+ "$NODE_BIN" "$filter_script" shell
968
+ "--agent" "$AGENT_TYPE"
969
+ "--profile" "$MCP_PROFILE"
970
+ "--available" "$available_servers"
971
+ "--inventory-file" "$MCP_CACHE"
972
+ "--task-text" "$PROMPT"
973
+ )
974
+ [[ -n "$TFX_SEARCH_TOOL" ]] && cmd+=("--search-tool" "$TFX_SEARCH_TOOL")
975
+ [[ -n "$TFX_WORKER_INDEX" ]] && cmd+=("--worker-index" "$TFX_WORKER_INDEX")
976
+
977
+ local shell_exports
978
+ if ! shell_exports="$("${cmd[@]}")"; then
979
+ echo "[tfx-route] ERROR: MCP 정책 계산 실패" >&2
980
+ return 1
981
+ fi
982
+
983
+ eval "$shell_exports"
984
+ }
985
+
986
+ get_claude_model() {
987
+ case "$AGENT_TYPE" in
988
+ explore) echo "haiku" ;;
989
+ *) echo "sonnet" ;;
990
+ esac
991
+ }
992
+
993
+ emit_claude_native_metadata() {
994
+ local model
995
+ model=$(get_claude_model)
996
+ echo "ROUTE_TYPE=claude-native"
997
+ echo "AGENT=$AGENT_TYPE"
998
+ echo "MODEL=$model"
999
+ echo "RUN_MODE=$RUN_MODE"
1000
+ echo "OPUS_OVERSIGHT=$OPUS_OVERSIGHT"
1001
+ echo "TIMEOUT=$TIMEOUT_SEC"
1002
+ echo "MCP_PROFILE=$MCP_PROFILE"
1003
+ [[ -n "$ORIGINAL_AGENT" ]] && echo "ORIGINAL_AGENT=$ORIGINAL_AGENT"
1004
+ echo "PROMPT=$PROMPT"
1005
+ echo "--- Claude Task($model) 에이전트로 위임하세요 ---"
1006
+ }
1007
+
1008
+ # heartbeat_monitor PID [INTERVAL] [STALL_THRESHOLD]
1009
+ # - PID: 감시할 워커 프로세스 PID
1010
+ # - INTERVAL: heartbeat 출력 간격 (초, 기본 10)
1011
+ # - STALL_THRESHOLD: stall 경고 임계값 (초, 기본 60)
1012
+ # 환경변수: TFX_HEARTBEAT (0이면 비활성화), TFX_HEARTBEAT_INTERVAL, TFX_STALL_THRESHOLD
1013
+ heartbeat_monitor() {
1014
+ [[ "${TFX_HEARTBEAT:-1}" -eq 0 ]] && return 0
1015
+ local pid="$1"
1016
+ local interval="${2:-${TFX_HEARTBEAT_INTERVAL:-10}}"
1017
+ local stall_threshold="${3:-${TFX_STALL_THRESHOLD:-60}}"
1018
+ local last_size=0 stall_count=0
1019
+
1020
+ while kill -0 "$pid" 2>/dev/null; do
1021
+ sleep "$interval"
1022
+ local current_size=0
1023
+ [[ -f "$STDOUT_LOG" ]] && current_size=$(wc -c < "$STDOUT_LOG" 2>/dev/null || echo 0)
1024
+ # P3: stderr 활동도 포함하여 거짓 STALL 방지
1025
+ local stderr_size=0
1026
+ [[ -f "$STDERR_LOG" ]] && stderr_size=$(wc -c < "$STDERR_LOG" 2>/dev/null || echo 0)
1027
+ current_size=$((current_size + stderr_size))
1028
+ local elapsed=$(($(date +%s) - TIMESTAMP))
1029
+
1030
+ if [[ "$current_size" -gt "$last_size" ]]; then
1031
+ stall_count=0
1032
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=active" >&2
1033
+ else
1034
+ stall_count=$((stall_count + interval))
1035
+ if [[ "$stall_count" -ge "$stall_threshold" ]]; then
1036
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL stall=${stall_count}s" >&2
1037
+ else
1038
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=quiet stall=${stall_count}s" >&2
1039
+ fi
1040
+ fi
1041
+ last_size=$current_size
1042
+ done
1043
+ echo "[tfx-heartbeat] pid=$pid terminated" >&2
1044
+ }
1045
+
1046
+ resolve_worker_runner_script() {
1047
+ if [[ -n "${TFX_ROUTE_WORKER_RUNNER:-}" && -f "$TFX_ROUTE_WORKER_RUNNER" ]]; then
1048
+ printf '%s\n' "$TFX_ROUTE_WORKER_RUNNER"
1049
+ return 0
1050
+ fi
1051
+
1052
+ local script_ref script_dir
1053
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1054
+ script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1055
+ local candidate="$script_dir/tfx-route-worker.mjs"
1056
+ [[ -f "$candidate" ]] || return 1
1057
+ printf '%s\n' "$candidate"
1058
+ }
1059
+
1060
+ run_stream_worker() {
1061
+ local worker_type="$1"
1062
+ local prompt="$2"
1063
+ local use_tee_flag="$3"
1064
+ shift 3
1065
+ local exit_code_local=0
1066
+ local worker_pid hb_pid
1067
+
1068
+ local runner_script
1069
+ if ! runner_script=$(resolve_worker_runner_script); then
1070
+ echo "[tfx-route] 경고: stream worker runner를 찾지 못했습니다." >&2
1071
+ return 127
1072
+ fi
1073
+
1074
+ if ! command -v "$NODE_BIN" &>/dev/null; then
1075
+ echo "[tfx-route] 경고: node를 찾지 못해 stream worker를 실행할 수 없습니다." >&2
1076
+ return 127
1077
+ fi
1078
+
1079
+ local -a worker_cmd=(
1080
+ "$NODE_BIN"
1081
+ "$runner_script"
1082
+ "--type" "$worker_type"
1083
+ "--timeout-ms" "$((TIMEOUT_SEC * 1000))"
1084
+ "--cwd" "$PWD"
1085
+ "$@"
1086
+ )
1087
+
1088
+ if [[ "$use_tee_flag" == "true" ]]; then
1089
+ printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1090
+ else
1091
+ printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1092
+ fi
1093
+ worker_pid=$!
1094
+
1095
+ heartbeat_monitor "$worker_pid" &
1096
+ hb_pid=$!
1097
+
1098
+ wait "$worker_pid" || exit_code_local=$?
1099
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1100
+ return "$exit_code_local"
1101
+ }
1102
+
1103
+ run_legacy_gemini() {
1104
+ local prompt="$1"
1105
+ local use_tee_flag="$2"
1106
+ local -a gemini_args=()
1107
+ read -r -a gemini_args <<< "$CLI_ARGS"
1108
+
1109
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1110
+ local gemini_mcp_filter prompt_index=-1
1111
+ gemini_mcp_filter=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")
1112
+ for i in "${!gemini_args[@]}"; do
1113
+ if [[ "${gemini_args[$i]}" == "--prompt" ]]; then
1114
+ prompt_index="$i"
1115
+ break
1116
+ fi
1117
+ done
1118
+ if [[ "$prompt_index" -ge 0 ]]; then
1119
+ gemini_args=(
1120
+ "${gemini_args[@]:0:$prompt_index}"
1121
+ "--allowed-mcp-server-names" "$gemini_mcp_filter"
1122
+ "${gemini_args[@]:$prompt_index}"
1123
+ )
1124
+ echo "[tfx-route] Gemini MCP 필터: $gemini_mcp_filter" >&2
1125
+ fi
1126
+ fi
1127
+
1128
+ if [[ "$use_tee_flag" == "true" ]]; then
1129
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1130
+ else
1131
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1132
+ fi
1133
+ local pid=$!
1134
+
1135
+ local health_ok=true
1136
+ local intervals=(1 2 3 5 8)
1137
+ for wait_sec in "${intervals[@]}"; do
1138
+ sleep "$wait_sec"
1139
+ if [[ -s "$STDOUT_LOG" ]] || [[ -s "$STDERR_LOG" ]]; then
1140
+ break
1141
+ fi
1142
+ if ! kill -0 "$pid" 2>/dev/null; then
1143
+ health_ok=false
1144
+ echo "[tfx-route] Gemini: 출력 없이 프로세스 종료 (${wait_sec} 체크)" >&2
1145
+ break
1146
+ fi
1147
+ done
1148
+
1149
+ local exit_code_local=0
1150
+ local hb_pid
1151
+ if [[ "$health_ok" == "false" ]]; then
1152
+ wait "$pid" 2>/dev/null
1153
+ echo "[tfx-route] Gemini crash 감지, 재시도 중..." >&2
1154
+ if [[ "$use_tee_flag" == "true" ]]; then
1155
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1156
+ else
1157
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1158
+ fi
1159
+ pid=$!
1160
+ fi
1161
+
1162
+ heartbeat_monitor "$pid" &
1163
+ hb_pid=$!
1164
+ wait "$pid" || exit_code_local=$?
1165
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1166
+ return "$exit_code_local"
1167
+ }
1168
+
1169
+ resolve_codex_mcp_script() {
1170
+ if [[ -n "${TFX_CODEX_MCP_SCRIPT:-}" && -f "$TFX_CODEX_MCP_SCRIPT" ]]; then
1171
+ printf '%s\n' "$TFX_CODEX_MCP_SCRIPT"
1172
+ return 0
1173
+ fi
1174
+
1175
+ local script_ref script_dir
1176
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1177
+ script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1178
+ local candidates=()
1179
+ [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/workers/codex-mcp.mjs")
1180
+ candidates+=(
1181
+ "$script_dir/hub/workers/codex-mcp.mjs"
1182
+ "$script_dir/../hub/workers/codex-mcp.mjs"
1183
+ )
1184
+
1185
+ local candidate
1186
+ for candidate in "${candidates[@]}"; do
1187
+ if [[ -f "$candidate" ]]; then
1188
+ printf '%s\n' "$candidate"
1189
+ return 0
1190
+ fi
1191
+ done
1192
+
1193
+ return 1
1194
+ }
1195
+
1196
+ run_codex_exec() {
1197
+ local prompt="$1"
1198
+ local use_tee_flag="$2"
1199
+ local exit_code_local=0
1200
+ local worker_pid hb_pid
1201
+ local -a codex_args=()
1202
+ read -r -a codex_args <<< "$CLI_ARGS"
1203
+ if [[ ${#CODEX_CONFIG_FLAGS[@]} -gt 0 ]]; then
1204
+ codex_args+=("${CODEX_CONFIG_FLAGS[@]}")
1205
+ fi
1206
+
1207
+ if [[ "$use_tee_flag" == "true" ]]; then
1208
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1209
+ else
1210
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1211
+ fi
1212
+ worker_pid=$!
1213
+
1214
+ heartbeat_monitor "$worker_pid" &
1215
+ hb_pid=$!
1216
+
1217
+ wait "$worker_pid" || exit_code_local=$?
1218
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1219
+
1220
+ if [[ ! -s "$STDOUT_LOG" && -s "$STDERR_LOG" ]]; then
1221
+ # stderr에서 마지막 "codex" 마커 이후의 텍스트를 stdout으로 복구
1222
+ # 1차: "codex" 마커 기반 (Windows \r 제거 후 매칭)
1223
+ sed 's/\r$//' "$STDERR_LOG" \
1224
+ | awk '/^codex$/{found=NR;content=""} found && NR>found{content=content RS $0} END{if(content) print substr(content,2)}' \
1225
+ > "$STDOUT_LOG"
1226
+
1227
+ # 2차: 마커 없을 node fallback (MCP/헤더/sandbox 로그 제외, 응답 부분만 추출)
1228
+ if [[ ! -s "$STDOUT_LOG" ]]; then
1229
+ node -e '
1230
+ const fs=require("fs"),lines=fs.readFileSync(process.argv[1],"utf-8").split(/\r?\n/);
1231
+ const skip=/^(mcp[: ]|OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|tokens used|EXIT:|exec$|"[A-Z]:|succeeded in |\s*$)/;
1232
+ const out=lines.filter(l=>!skip.test(l));
1233
+ if(out.length) fs.writeFileSync(process.argv[2],out.join("\n"));
1234
+ ' -- "$STDERR_LOG" "$STDOUT_LOG" 2>/dev/null || true
1235
+ fi
1236
+
1237
+ if [[ -s "$STDOUT_LOG" ]]; then
1238
+ echo "[tfx-route] 경고: codex stdout 비어있음, stderr에서 응답 복구 ($(wc -c < "$STDOUT_LOG") bytes)" >&2
1239
+ else
1240
+ echo "[tfx-route] 경고: codex stdout 비어있음, stderr 복구도 실패" >&2
1241
+ fi
1242
+ fi
1243
+
1244
+ return "$exit_code_local"
1245
+ }
1246
+
1247
+ run_codex_mcp() {
1248
+ local prompt="$1"
1249
+ local use_tee_flag="$2"
1250
+ local mcp_script node_bin
1251
+ local exit_code_local=0
1252
+ local worker_pid hb_pid
1253
+
1254
+ if ! mcp_script=$(resolve_codex_mcp_script); then
1255
+ echo "[tfx-route] 경고: Codex MCP 래퍼를 찾지 못했습니다." >&2
1256
+ return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1257
+ fi
1258
+
1259
+ node_bin="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
1260
+ if ! command -v "$node_bin" &>/dev/null; then
1261
+ echo "[tfx-route] 경고: node를 찾지 못해 Codex MCP 경로를 사용할 수 없습니다." >&2
1262
+ return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1263
+ fi
1264
+
1265
+ local -a mcp_args=(
1266
+ "$mcp_script"
1267
+ "--prompt" "$prompt"
1268
+ "--cwd" "$PWD"
1269
+ "--profile" "$CLI_EFFORT"
1270
+ "--approval-policy" "never"
1271
+ "--sandbox" "danger-full-access"
1272
+ "--timeout-ms" "$((TIMEOUT_SEC * 1000))"
1273
+ "--codex-command" "$CODEX_BIN"
1274
+ )
1275
+
1276
+ if [[ -n "$CODEX_CONFIG_JSON" && "$CODEX_CONFIG_JSON" != "{}" ]]; then
1277
+ mcp_args+=("--config-json" "$CODEX_CONFIG_JSON")
1278
+ fi
1279
+
1280
+ case "$AGENT_TYPE" in
1281
+ code-reviewer)
1282
+ mcp_args+=(
1283
+ "--developer-instructions"
1284
+ "코드 리뷰 모드로 동작하라. 버그, 리스크, 회귀, 테스트 누락을 우선 식별하라."
1285
+ )
1286
+ ;;
1287
+ security-reviewer)
1288
+ mcp_args+=(
1289
+ "--developer-instructions"
1290
+ "보안 리뷰 모드로 동작하라. 취약점, 권한 경계, 비밀정보 노출 가능성을 우선 식별하라."
1291
+ )
1292
+ ;;
1293
+ quality-reviewer)
1294
+ mcp_args+=(
1295
+ "--developer-instructions"
1296
+ "품질 리뷰 모드로 동작하라. 로직 결함, 유지보수성 저하, 테스트 누락을 우선 식별하라."
1297
+ )
1298
+ ;;
1299
+ esac
1300
+
1301
+ if [[ "$use_tee_flag" == "true" ]]; then
1302
+ timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1303
+ else
1304
+ timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1305
+ fi
1306
+ worker_pid=$!
1307
+
1308
+ heartbeat_monitor "$worker_pid" &
1309
+ hb_pid=$!
1310
+
1311
+ wait "$worker_pid" || exit_code_local=$?
1312
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1313
+
1314
+ # 모듈 로드 실패(의존성 누락) → MCP transport exit code로 변환하여 fallback 트리거
1315
+ if [[ "$exit_code_local" -ne 0 && "$exit_code_local" -ne 124 ]] && grep -q 'ERR_MODULE_NOT_FOUND' "$STDERR_LOG" 2>/dev/null; then
1316
+ echo "[tfx-route] Codex MCP 모듈 로드 실패 — fallback 가능 exit code로 변환" >&2
1317
+ return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1318
+ fi
1319
+
1320
+ return "$exit_code_local"
1321
+ }
1322
+
1323
+ # ── 메인 실행 ──
1324
+ main() {
1325
+ # 종료 시 per-process 에이전트 파일 자동 삭제
1326
+ trap 'deregister_agent' EXIT
1327
+
1328
+ route_agent "$AGENT_TYPE"
1329
+ apply_cli_mode
1330
+ apply_no_claude_native_mode
1331
+ apply_plan_guard
1332
+ apply_verifier_override
1333
+
1334
+ # CLI 경로 해석
1335
+ case "$CLI_CMD" in
1336
+ codex) CLI_CMD="$CODEX_BIN" ;;
1337
+ gemini) CLI_CMD="$GEMINI_BIN" ;;
1338
+ claude) CLI_CMD="$CLAUDE_BIN" ;;
1339
+ esac
1340
+
1341
+ # 타임아웃 결정 (에이전트별 최소값 보장)
1342
+ local MIN_TIMEOUT
1343
+ case "$AGENT_TYPE" in
1344
+ deep-executor|architect|planner|critic|analyst) MIN_TIMEOUT=900 ;;
1345
+ document-specialist|scientist|scientist-deep) MIN_TIMEOUT=900 ;;
1346
+ code-reviewer|security-reviewer|quality-reviewer) MIN_TIMEOUT=600 ;;
1347
+ executor|debugger) MIN_TIMEOUT=300 ;;
1348
+ *) MIN_TIMEOUT=120 ;;
1349
+ esac
1350
+
1351
+ if [[ -n "$USER_TIMEOUT" ]]; then
1352
+ if ! [[ "$USER_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then
1353
+ echo "[tfx-route] 경고: 유효하지 않은 타임아웃 값 ($USER_TIMEOUT), 기본값 사용" >&2
1354
+ USER_TIMEOUT=""
1355
+ TIMEOUT_SEC="$DEFAULT_TIMEOUT"
1356
+ elif [[ "$USER_TIMEOUT" -lt "$MIN_TIMEOUT" ]]; then
1357
+ echo "[tfx-route] 경고: 타임아웃 ${USER_TIMEOUT}s < 최소 ${MIN_TIMEOUT}s ($AGENT_TYPE), 최소값 적용" >&2
1358
+ TIMEOUT_SEC="$MIN_TIMEOUT"
1359
+ else
1360
+ TIMEOUT_SEC="$USER_TIMEOUT"
1361
+ fi
1362
+ else
1363
+ TIMEOUT_SEC="$DEFAULT_TIMEOUT"
1364
+ fi
1365
+
1366
+ # 컨텍스트 파일 프롬프트에 주입
1367
+ if [[ -n "$CONTEXT_FILE" && -f "$CONTEXT_FILE" ]]; then
1368
+ local ctx_content
1369
+ ctx_content=$(cat "$CONTEXT_FILE" 2>/dev/null | head -c 32768) # 32KB 상한
1370
+ PROMPT="${PROMPT}
1371
+
1372
+ <prior_context>
1373
+ ${ctx_content}
1374
+ </prior_context>"
1375
+ fi
1376
+
1377
+ resolve_mcp_policy
1378
+
1379
+ # Claude native는 비-TTY 환경에서 subprocess wrapper를 우선 시도
1380
+ if [[ "$CLI_TYPE" == "claude-native" && -n "$TFX_TEAM_NAME" ]]; then
1381
+ if { [[ ! -t 0 ]] || [[ ! -t 1 ]]; } && command -v "$CLAUDE_BIN" &>/dev/null && resolve_worker_runner_script >/dev/null 2>&1; then
1382
+ CLI_TYPE="claude"
1383
+ CLI_CMD="$CLAUDE_BIN"
1384
+ echo "[tfx-route] non-tty 팀 환경: claude-native -> claude stream wrapper 전환" >&2
1385
+ else
1386
+ echo "[tfx-route] claude stream wrapper 미사용: native metadata 유지" >&2
1387
+ fi
1388
+ fi
1389
+
1390
+ # Claude 네이티브 에이전트는 이 스크립트로 처리 불가
1391
+ if [[ "$CLI_TYPE" == "claude-native" ]]; then
1392
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1393
+ # 팀 모드: Hub에 fallback 필요 시그널 전송 후 구조화된 출력
1394
+ echo "[tfx-route] claude-native 역할($AGENT_TYPE)은 tfx-route.sh로 실행 불가 — Claude Agent fallback 필요" >&2
1395
+ team_complete_task "fallback" "claude-native 역할 실행 불가: ${AGENT_TYPE}. Claude Task(sonnet) 에이전트로 위임하세요."
1396
+ cat <<FALLBACK_EOF
1397
+ === TFX_NEEDS_FALLBACK ===
1398
+ agent_type: ${AGENT_TYPE}
1399
+ reason: claude-native roles require Claude Agent tools (Read/Edit/Grep). tfx-route.sh cannot provide these.
1400
+ action: Lead should spawn Agent(subagent_type="${AGENT_TYPE}") for this task.
1401
+ task_id: ${TFX_TEAM_TASK_ID:-none}
1402
+ FALLBACK_EOF
1403
+ exit 0
1404
+ fi
1405
+ emit_claude_native_metadata
1406
+ exit 0
1407
+ fi
1408
+
1409
+ local FULL_PROMPT="$PROMPT"
1410
+ [[ -n "$MCP_HINT" ]] && FULL_PROMPT="${PROMPT}. ${MCP_HINT}"
1411
+ local codex_transport_effective="n/a"
1412
+
1413
+ # 메타정보 (stderr)
1414
+ echo "[tfx-route] v${VERSION} type=$CLI_TYPE agent=$AGENT_TYPE effort=$CLI_EFFORT mode=$RUN_MODE timeout=${TIMEOUT_SEC}s" >&2
1415
+ echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE resolved_profile=$MCP_RESOLVED_PROFILE verifier_override=$TFX_VERIFIER_OVERRIDE" >&2
1416
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1417
+ echo "[tfx-route] allowed_mcp_servers=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1418
+ else
1419
+ echo "[tfx-route] allowed_mcp_servers=none" >&2
1420
+ fi
1421
+ if [[ -n "$TFX_WORKER_INDEX" || -n "$TFX_SEARCH_TOOL" ]]; then
1422
+ echo "[tfx-route] worker_index=${TFX_WORKER_INDEX:-auto} search_tool=${TFX_SEARCH_TOOL:-auto}" >&2
1423
+ fi
1424
+ if [[ "$CLI_TYPE" == "codex" ]]; then
1425
+ echo "[tfx-route] codex_transport_request=$TFX_CODEX_TRANSPORT" >&2
1426
+ fi
1427
+ [[ -n "$TFX_TEAM_NAME" ]] && echo "[tfx-route] team=$TFX_TEAM_NAME task=$TFX_TEAM_TASK_ID agent=$TFX_TEAM_AGENT_NAME" >&2
1428
+ [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "[tfx-route] rerouted_from=$TFX_REROUTED_FROM" >&2
1429
+
1430
+ # Per-process 에이전트 등록
1431
+ register_agent
1432
+
1433
+ # 팀 모드: task claim
1434
+ team_claim_task
1435
+ team_send_message "작업 시작: ${TFX_TEAM_AGENT_NAME}" "task ${TFX_TEAM_TASK_ID} started"
1436
+
1437
+ # CLI 실행 (stderr 분리 + 타임아웃 + 소요시간 측정)
1438
+ local exit_code=0
1439
+ local start_time
1440
+ start_time=$(date +%s)
1441
+ local workspace_signature_before=""
1442
+ local workspace_signature_after=""
1443
+ local workspace_probe_supported=false
1444
+ if workspace_signature_before=$(capture_workspace_signature); then
1445
+ workspace_probe_supported=true
1446
+ fi
1447
+
1448
+ # tee 활성화 조건: 팀 모드 + 실제 터미널(TTY/tmux)
1449
+ # Agent 래퍼 안에서는 가상 stdout 캡처로 tee 출력이 사용자에게 안 보임 → 파일 전용
1450
+ # 실시간 모니터링은 Shift+Down으로 워커 pane 전환 권장
1451
+ local use_tee=false
1452
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1453
+ if [[ -t 1 ]] || [[ -n "${TMUX:-}" ]]; then
1454
+ use_tee=true
1455
+ fi
1456
+ fi
1457
+
1458
+ if [[ "$CLI_TYPE" == "codex" ]]; then
1459
+ codex_transport_effective="exec"
1460
+ if [[ "$TFX_CODEX_TRANSPORT" != "exec" ]]; then
1461
+ run_codex_mcp "$FULL_PROMPT" "$use_tee" || exit_code=$?
1462
+ if [[ "$exit_code" -eq 0 ]]; then
1463
+ codex_transport_effective="mcp"
1464
+ elif [[ "$exit_code" -eq "$CODEX_MCP_TRANSPORT_EXIT_CODE" && "$TFX_CODEX_TRANSPORT" == "auto" ]]; then
1465
+ echo "[tfx-route] Codex MCP bootstrap 실패(exit=${exit_code}). legacy exec 경로로 fallback합니다." >&2
1466
+ : > "$STDOUT_LOG"
1467
+ : > "$STDERR_LOG"
1468
+ exit_code=0
1469
+ run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
1470
+ codex_transport_effective="exec-fallback"
1471
+ else
1472
+ codex_transport_effective="mcp"
1473
+ fi
1474
+ else
1475
+ run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
1476
+ codex_transport_effective="exec"
1477
+ fi
1478
+ echo "[tfx-route] codex_transport_effective=$codex_transport_effective" >&2
1479
+
1480
+ elif [[ "$CLI_TYPE" == "gemini" ]]; then
1481
+ local gemini_model
1482
+ gemini_model=$(awk '{
1483
+ for (i = 1; i <= NF; i++) {
1484
+ if ($i == "-m" || $i == "--model") {
1485
+ print $(i + 1)
1486
+ exit
1487
+ }
1488
+ }
1489
+ }' <<< "$CLI_ARGS")
1490
+ local -a gemini_worker_args=(
1491
+ "--command" "$CLI_CMD"
1492
+ "--command-args-json" "$GEMINI_BIN_ARGS_JSON"
1493
+ "--model" "$gemini_model"
1494
+ "--approval-mode" "yolo"
1495
+ )
1496
+
1497
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1498
+ echo "[tfx-route] Gemini MCP 서버: $(IFS=' '; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1499
+ local server_name
1500
+ for server_name in "${GEMINI_ALLOWED_SERVERS[@]}"; do
1501
+ gemini_worker_args+=("--allowed-mcp-server-name" "$server_name")
1502
+ done
1503
+ fi
1504
+
1505
+ run_stream_worker "gemini" "$FULL_PROMPT" "$use_tee" "${gemini_worker_args[@]}" || exit_code=$?
1506
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1507
+ echo "[tfx-route] Gemini stream wrapper 실패(exit=${exit_code}). legacy CLI 경로로 fallback합니다." >&2
1508
+ : > "$STDOUT_LOG"
1509
+ : > "$STDERR_LOG"
1510
+ exit_code=0
1511
+ run_legacy_gemini "$FULL_PROMPT" "$use_tee" || exit_code=$?
1512
+ fi
1513
+
1514
+ elif [[ "$CLI_TYPE" == "claude" ]]; then
1515
+ local claude_model
1516
+ claude_model=$(get_claude_model)
1517
+ local -a claude_worker_args=(
1518
+ "--command" "$CLI_CMD"
1519
+ "--command-args-json" "$CLAUDE_BIN_ARGS_JSON"
1520
+ "--model" "$claude_model"
1521
+ "--permission-mode" "bypassPermissions"
1522
+ "--allow-dangerously-skip-permissions"
1523
+ )
1524
+
1525
+ run_stream_worker "claude" "$FULL_PROMPT" "$use_tee" "${claude_worker_args[@]}" || exit_code=$?
1526
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1527
+ echo "[tfx-route] Claude stream wrapper 실패(exit=${exit_code}). native metadata로 fallback합니다." >&2
1528
+ cat > "$STDOUT_LOG" <<EOF
1529
+ $(emit_claude_native_metadata)
1530
+ EOF
1531
+ : > "$STDERR_LOG"
1532
+ exit_code=0
1533
+ CLI_TYPE="claude-native"
1534
+ fi
1535
+ fi
1536
+
1537
+ local end_time
1538
+ end_time=$(date +%s)
1539
+ local elapsed=$((end_time - start_time))
1540
+
1541
+ if [[ "$exit_code" -eq 0 ]]; then
1542
+ local workspace_changed="unknown"
1543
+ if [[ "$workspace_probe_supported" == "true" ]]; then
1544
+ if workspace_signature_after=$(capture_workspace_signature); then
1545
+ if [[ "$workspace_signature_before" != "$workspace_signature_after" ]]; then
1546
+ workspace_changed="yes"
1547
+ else
1548
+ workspace_changed="no"
1549
+ fi
1550
+ fi
1551
+ fi
1552
+
1553
+ if [[ ! -s "$STDOUT_LOG" && "$workspace_changed" == "no" ]]; then
1554
+ printf '%s\n' "[tfx-route] exit 0 이지만 stdout 비어있고 워크스페이스 변화가 없습니다. no-op 성공을 실패로 승격합니다." >> "$STDERR_LOG"
1555
+ exit_code=68
1556
+ fi
1557
+ fi
1558
+
1559
+ # 쿼타 감지 + 자동 re-route
1560
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1561
+ if [[ "${TFX_QUOTA_REROUTE:-1}" -ne 0 ]] && [[ -z "${TFX_REROUTED_FROM:-}" ]] && detect_quota_exceeded "$STDOUT_LOG" "$STDERR_LOG"; then
1562
+ export TFX_REROUTED_FROM="$CLI_TYPE"
1563
+ auto_reroute "$CLI_TYPE"
1564
+ fi
1565
+ fi
1566
+
1567
+ # 팀 모드: task complete + 리드 보고
1568
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1569
+ if [[ "$exit_code" -eq 0 ]]; then
1570
+ local output_preview
1571
+ output_preview=$(head -c 2048 "$STDOUT_LOG" 2>/dev/null || echo "출력 없음")
1572
+ team_complete_task "success" "$output_preview"
1573
+ elif [[ "$exit_code" -eq 124 ]]; then
1574
+ team_complete_task "timeout" "타임아웃 (${TIMEOUT_SEC}초)"
1575
+ else
1576
+ local err_preview
1577
+ err_preview=$(tail -c 1024 "$STDERR_LOG" 2>/dev/null || echo "에러 정보 없음")
1578
+ team_complete_task "failed" "exit_code=${exit_code}: ${err_preview}"
1579
+ fi
1580
+ fi
1581
+
1582
+ # ── 후처리: 단일 node 프로세스로 위임 ──
1583
+ # 토큰 추출, 출력 필터링, 로그, 토큰 누적, AIMD, 이슈 추적, 결과 출력 전부 처리
1584
+ local post_script="${HOME}/.claude/scripts/tfx-route-post.mjs"
1585
+ if [[ -f "$post_script" ]]; then
1586
+ node "$post_script" \
1587
+ --agent "$AGENT_TYPE" \
1588
+ --cli "$CLI_TYPE" \
1589
+ --cli-cmd "$CLI_CMD" \
1590
+ --effort "$CLI_EFFORT" \
1591
+ --run-mode "$RUN_MODE" \
1592
+ --opus "$OPUS_OVERSIGHT" \
1593
+ --exit-code "$exit_code" \
1594
+ --elapsed "$elapsed" \
1595
+ --timeout "$TIMEOUT_SEC" \
1596
+ --mcp-profile "$MCP_PROFILE" \
1597
+ --stderr-log "$STDERR_LOG" \
1598
+ --stdout-log "$STDOUT_LOG" \
1599
+ --rerouted-from "${TFX_REROUTED_FROM:-}" \
1600
+ --max-bytes "$MAX_STDOUT_BYTES" \
1601
+ --tee-active "$use_tee" \
1602
+ --clean-tui "${TFX_CLEAN_TUI:-true}"
1603
+ else
1604
+ # post.mjs 없으면 기본 출력 (fallback)
1605
+ echo "=== TFX-ROUTE RESULT ==="
1606
+ echo "agent: $AGENT_TYPE"
1607
+ echo "cli: $CLI_TYPE"
1608
+ [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "rerouted_from: $TFX_REROUTED_FROM"
1609
+ echo "exit_code: $exit_code"
1610
+ echo "elapsed: ${elapsed}s"
1611
+ echo "status: $([ $exit_code -eq 0 ] && echo success || echo failed)"
1612
+ echo "=== OUTPUT ==="
1613
+ if [[ "${TFX_CLEAN_TUI:-1}" != "0" ]]; then
1614
+ cat "$STDOUT_LOG" 2>/dev/null \
1615
+ | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
1616
+ | sed '/^[[:space:]]*[╭╮╰╯│─┌┐└┘├┤┬┴┼]/d' \
1617
+ | sed '/^[[:space:]]*[›❯][[:space:]]*$/d' \
1618
+ | head -c "$MAX_STDOUT_BYTES"
1619
+ else
1620
+ cat "$STDOUT_LOG" 2>/dev/null | head -c "$MAX_STDOUT_BYTES"
1621
+ fi
1622
+ fi
1623
+
1624
+ return "$exit_code"
1625
+ }
1626
+
1627
+ # ── Async 모드: 백그라운드 실행 + 즉시 job_id 반환 ──
1628
+ if [[ "$TFX_ASYNC_MODE" -eq 1 ]]; then
1629
+ mkdir -p "$TFX_JOBS_DIR"
1630
+ JOB_ID="$TIMESTAMP-$$-${RANDOM}"
1631
+ JOB_DIR="$TFX_JOBS_DIR/$JOB_ID"
1632
+ mkdir -p "$JOB_DIR"
1633
+ echo "$AGENT_TYPE" > "$JOB_DIR/agent_type"
1634
+ date +%s > "$JOB_DIR/start_time"
1635
+
1636
+ # 백그라운드 서브쉘: main 실행 → 결과 저장
1637
+ (
1638
+ set +e # main 내부 에러가 exit_code 기록 전에 서브쉘을 죽이는 것 방지
1639
+ exec > "$JOB_DIR/result.log" 2>"$JOB_DIR/stderr.log"
1640
+ main
1641
+ echo $? > "$JOB_DIR/exit_code"
1642
+ touch "$JOB_DIR/done"
1643
+ ) &
1644
+ bg_pid=$!
1645
+ echo "$bg_pid" > "$JOB_DIR/pid"
1646
+
1647
+ # 종료 감지 데몬 (main이 signal/crash로 죽어도 done 마커 생성)
1648
+ (
1649
+ wait "$bg_pid" 2>/dev/null
1650
+ ec=$?
1651
+ if [[ ! -f "$JOB_DIR/done" ]]; then
1652
+ echo "$ec" > "$JOB_DIR/exit_code"
1653
+ touch "$JOB_DIR/done"
1654
+ fi
1655
+ ) &
1656
+ disown
1657
+
1658
+ # 즉시 리턴: 1초 이내에 Claude Code Bash 도구 완료
1659
+ echo "$JOB_ID"
1660
+ exit 0
1661
+ fi
1662
+
1663
+ main