triflux 7.1.4 → 7.2.2

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 +725 -690
  64. package/scripts/tfx-route-post.mjs +424 -424
  65. package/scripts/tfx-route.sh +1671 -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,1671 @@
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
+ # ── breadcrumb 폴백 (synced 환경: ~/.claude/scripts/) ──
578
+ if [[ ! -f "$map_file" && -n "$TFX_PKG_ROOT" ]]; then
579
+ map_file="$TFX_PKG_ROOT/hub/team/agent-map.json"
580
+ fi
581
+ if [[ ! -f "$map_file" ]]; then
582
+ echo "ERROR: agent-map.json 미발견 (경로: $map_file, TFX_PKG_ROOT=${TFX_PKG_ROOT:-unset})" >&2
583
+ exit 1
584
+ fi
585
+
586
+ # ── CLI_TYPE: 단일 소스 (agent-map.json) ──
587
+ local _raw_type
588
+ _raw_type=$(node -e "
589
+ const p=require('path').resolve(process.argv[1]);
590
+ const m=JSON.parse(require('fs').readFileSync(p,'utf8'));
591
+ const t=m[process.argv[2]];
592
+ if(t)process.stdout.write(t);
593
+ " "$map_file" "$agent" 2>/dev/null)
594
+
595
+ if [[ -z "$_raw_type" ]]; then
596
+ echo "ERROR: 알 수 없는 에이전트 타입: $agent" >&2
597
+ 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
598
+ exit 1
599
+ fi
600
+
601
+ # "claude" "claude-native" (headless.mjs는 "claude", route.sh는 "claude-native")
602
+ CLI_TYPE="$_raw_type"
603
+ [[ "$CLI_TYPE" == "claude" ]] && CLI_TYPE="claude-native"
604
+
605
+ # ── CLI_CMD: CLI_TYPE에서 파생 ──
606
+ case "$CLI_TYPE" in
607
+ codex) CLI_CMD="codex" ;;
608
+ gemini) CLI_CMD="gemini" ;;
609
+ claude-native) CLI_CMD=""; CLI_ARGS="" ;;
610
+ esac
611
+
612
+ # ── 에이전트별 상세 설정 ──
613
+ case "$agent" in
614
+ # ─── 구현 레인 ───
615
+ executor)
616
+ CLI_ARGS="exec ${codex_base}"
617
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
618
+ build-fixer)
619
+ CLI_ARGS="exec --profile fast ${codex_base}"
620
+ CLI_EFFORT="fast"; DEFAULT_TIMEOUT=540; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
621
+ debugger)
622
+ CLI_ARGS="exec ${codex_base}"
623
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
624
+ deep-executor)
625
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
626
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
627
+
628
+ # ─── 설계/분석 레인 ───
629
+ architect)
630
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
631
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
632
+ planner)
633
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
634
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
635
+ critic)
636
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
637
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
638
+ analyst)
639
+ CLI_ARGS="exec --profile xhigh ${codex_base}"
640
+ CLI_EFFORT="xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
641
+
642
+ # ─── 리뷰 레인 ───
643
+ code-reviewer)
644
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
645
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
646
+ security-reviewer)
647
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
648
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
649
+ quality-reviewer)
650
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
651
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
652
+
653
+ # ─── 리서치 레인 ───
654
+ scientist)
655
+ CLI_ARGS="exec ${codex_base}"
656
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
657
+ scientist-deep)
658
+ CLI_ARGS="exec --profile thorough ${codex_base}"
659
+ CLI_EFFORT="thorough"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
660
+ document-specialist)
661
+ CLI_ARGS="exec ${codex_base}"
662
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
663
+
664
+ # ─── UI/문서 레인 ───
665
+ designer)
666
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
667
+ CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
668
+ writer)
669
+ CLI_ARGS="-m gemini-3-flash-preview -y --prompt"
670
+ CLI_EFFORT="flash"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
671
+
672
+ # ─── 탐색/검증/테스트 (Claude-native 우선, TFX_NO_CLAUDE_NATIVE=1일 때만 Codex 리매핑) ───
673
+ explore|verifier|test-engineer|qa-tester)
674
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
675
+ case "$agent" in
676
+ test-engineer|qa-tester) DEFAULT_TIMEOUT=1200; RUN_MODE="bg" ;;
677
+ esac
678
+ ;;
679
+
680
+ # ─── 경량 ───
681
+ spark)
682
+ CLI_ARGS="exec --profile spark_fast ${codex_base}"
683
+ CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
684
+ # ─── CLI 이름 alias (사용자 편의) ───
685
+ codex)
686
+ CLI_ARGS="exec ${codex_base}"
687
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
688
+ gemini)
689
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
690
+ CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
691
+ claude)
692
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
693
+ # ─── agent-map.json에만 정의된 신규 에이전트 (CLI_TYPE별 기본값) ───
694
+ *)
695
+ case "$CLI_TYPE" in
696
+ codex)
697
+ CLI_ARGS="exec ${codex_base}"
698
+ CLI_EFFORT="high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
699
+ gemini)
700
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"
701
+ CLI_EFFORT="pro"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
702
+ claude-native)
703
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
704
+ esac ;;
705
+ esac
706
+ }
707
+
708
+ # ── CLI 모드 오버라이드 (tfx-codex / tfx-gemini 스킬용) ──
709
+ TFX_CLI_MODE="${TFX_CLI_MODE:-auto}"
710
+ TFX_NO_CLAUDE_NATIVE="${TFX_NO_CLAUDE_NATIVE:-0}"
711
+ TFX_VERIFIER_OVERRIDE="${TFX_VERIFIER_OVERRIDE:-auto}"
712
+ TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-exec}"
713
+ # Codex 요금제 자동 감지 (preflight 캐시 → auth.json JWT)
714
+ # 환경변수 명시 설정 시 우선, 미설정 시 캐시에서 읽기, 캐시도 없으면 pro
715
+ if [[ -z "${TFX_CODEX_PLAN:-}" ]]; then
716
+ _detected_plan=$(node -e '
717
+ try {
718
+ const c = JSON.parse(require("fs").readFileSync(require("path").join(require("os").homedir(),".claude","cache","tfx-preflight.json"),"utf8"));
719
+ const p = c?.codex_plan?.plan;
720
+ if (p && p !== "unknown" && p !== "api") { process.stdout.write(p); }
721
+ } catch {}
722
+ ' 2>/dev/null)
723
+ TFX_CODEX_PLAN="${_detected_plan:-pro}"
724
+ unset _detected_plan
725
+ fi
726
+ TFX_WORKER_INDEX="${TFX_WORKER_INDEX:-}"
727
+ TFX_SEARCH_TOOL="${TFX_SEARCH_TOOL:-}"
728
+ case "$TFX_NO_CLAUDE_NATIVE" in
729
+ 0|1) ;;
730
+ *)
731
+ echo "ERROR: TFX_NO_CLAUDE_NATIVE 값은 0 또는 1이어야 합니다. (현재: $TFX_NO_CLAUDE_NATIVE)" >&2
732
+ exit 1
733
+ ;;
734
+ esac
735
+ case "$TFX_CODEX_PLAN" in
736
+ pro|plus|free) ;;
737
+ *)
738
+ echo "ERROR: TFX_CODEX_PLAN 값은 pro, plus, free 중 하나여야 합니다. (현재: $TFX_CODEX_PLAN)" >&2
739
+ exit 1
740
+ ;;
741
+ esac
742
+ case "$TFX_CODEX_TRANSPORT" in
743
+ auto|mcp|exec) ;;
744
+ *)
745
+ echo "ERROR: TFX_CODEX_TRANSPORT 값은 auto, mcp, exec 중 하나여야 합니다. (현재: $TFX_CODEX_TRANSPORT)" >&2
746
+ exit 1
747
+ ;;
748
+ esac
749
+ case "$TFX_VERIFIER_OVERRIDE" in
750
+ auto|claude) ;;
751
+ *)
752
+ echo "ERROR: TFX_VERIFIER_OVERRIDE 값은 auto 또는 claude여야 합니다. (현재: $TFX_VERIFIER_OVERRIDE)" >&2
753
+ exit 1
754
+ ;;
755
+ esac
756
+ case "$TFX_WORKER_INDEX" in
757
+ "") ;;
758
+ *[!0-9]*|0)
759
+ echo "ERROR: TFX_WORKER_INDEX 값은 1 이상의 정수여야 합니다. (현재: $TFX_WORKER_INDEX)" >&2
760
+ exit 1
761
+ ;;
762
+ esac
763
+ case "$TFX_SEARCH_TOOL" in
764
+ ""|brave-search|tavily|exa) ;;
765
+ *)
766
+ echo "ERROR: TFX_SEARCH_TOOL 값은 brave-search, tavily, exa 중 하나여야 합니다. (현재: $TFX_SEARCH_TOOL)" >&2
767
+ exit 1
768
+ ;;
769
+ esac
770
+ CODEX_MCP_TRANSPORT_EXIT_CODE=70
771
+
772
+ apply_cli_mode() {
773
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
774
+
775
+ case "$TFX_CLI_MODE" in
776
+ codex)
777
+ if [[ "$CLI_TYPE" == "gemini" ]]; then
778
+ CLI_TYPE="codex"; CLI_CMD="codex"
779
+ case "$AGENT_TYPE" in
780
+ designer)
781
+ CLI_ARGS="exec ${codex_base}"; CLI_EFFORT="high"; DEFAULT_TIMEOUT=600 ;;
782
+ writer)
783
+ CLI_ARGS="exec --profile spark_fast ${codex_base}"; CLI_EFFORT="spark_fast"; DEFAULT_TIMEOUT=180 ;;
784
+ esac
785
+ echo "[tfx-route] TFX_CLI_MODE=codex: $AGENT_TYPE → codex($CLI_EFFORT)로 리매핑" >&2
786
+ fi ;;
787
+ gemini)
788
+ if [[ "$CLI_TYPE" == "codex" ]]; then
789
+ CLI_TYPE="gemini"; CLI_CMD="gemini"
790
+ case "$AGENT_TYPE" in
791
+ executor|debugger|deep-executor|architect|planner|critic|analyst|\
792
+ code-reviewer|security-reviewer|quality-reviewer|scientist-deep)
793
+ CLI_ARGS="-m gemini-3.1-pro-preview -y --prompt"; CLI_EFFORT="pro" ;;
794
+ build-fixer|spark)
795
+ CLI_ARGS="-m gemini-3-flash-preview -y --prompt"; CLI_EFFORT="flash"; DEFAULT_TIMEOUT=180 ;;
796
+ *)
797
+ CLI_ARGS="-m gemini-3-flash-preview -y --prompt"; CLI_EFFORT="flash" ;;
798
+ esac
799
+ echo "[tfx-route] TFX_CLI_MODE=gemini: $AGENT_TYPE → gemini($CLI_EFFORT)로 리매핑" >&2
800
+ fi ;;
801
+ auto)
802
+ if [[ "$CLI_TYPE" == "codex" ]] && ! command -v "$CODEX_BIN" &>/dev/null; then
803
+ if command -v "$GEMINI_BIN" &>/dev/null; then
804
+ TFX_CLI_MODE="gemini"; 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
+ elif [[ "$CLI_TYPE" == "gemini" ]] && ! command -v "$GEMINI_BIN" &>/dev/null; then
811
+ if command -v "$CODEX_BIN" &>/dev/null; then
812
+ TFX_CLI_MODE="codex"; apply_cli_mode; return
813
+ else
814
+ ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
815
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
816
+ echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
817
+ fi
818
+ fi ;;
819
+ esac
820
+ }
821
+
822
+ # ── Codex 요금제 가드 (fast 프로필은 Pro 전용) ──
823
+ apply_plan_guard() {
824
+ [[ "$CLI_TYPE" != "codex" ]] && return
825
+ [[ "$TFX_CODEX_PLAN" == "pro" ]] && return
826
+
827
+ if [[ "$CLI_EFFORT" == "fast" ]]; then
828
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
829
+ CLI_ARGS="exec ${codex_base}"
830
+ CLI_EFFORT="high"
831
+ echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: --profile fast → high로 다운그레이드 (Pro 전용)" >&2
832
+ fi
833
+ }
834
+
835
+ # ── Claude 네이티브 제거 (Codex 리드 환경에서 선택적 활성화) ──
836
+ apply_no_claude_native_mode() {
837
+ local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
838
+
839
+ [[ "$TFX_NO_CLAUDE_NATIVE" != "1" ]] && return
840
+ [[ "$TFX_CLI_MODE" == "gemini" ]] && return
841
+ [[ "$CLI_TYPE" != "claude-native" ]] && return
842
+
843
+ if ! command -v "$CODEX_BIN" &>/dev/null; then
844
+ echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1 이지만 codex를 찾지 못해 claude-native 유지" >&2
845
+ return
846
+ fi
847
+
848
+ ORIGINAL_AGENT="${AGENT_TYPE}"
849
+ CLI_TYPE="codex"; CLI_CMD="codex"
850
+
851
+ case "$AGENT_TYPE" in
852
+ explore)
853
+ CLI_ARGS="exec --profile fast ${codex_base}"
854
+ CLI_EFFORT="fast"
855
+ DEFAULT_TIMEOUT=600
856
+ RUN_MODE="fg"
857
+ OPUS_OVERSIGHT="false"
858
+ ;;
859
+ verifier)
860
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
861
+ CLI_EFFORT="thorough"
862
+ DEFAULT_TIMEOUT=1200
863
+ RUN_MODE="fg"
864
+ OPUS_OVERSIGHT="false"
865
+ ;;
866
+ test-engineer)
867
+ CLI_ARGS="exec ${codex_base}"
868
+ CLI_EFFORT="high"
869
+ DEFAULT_TIMEOUT=1200
870
+ RUN_MODE="bg"
871
+ OPUS_OVERSIGHT="false"
872
+ ;;
873
+ qa-tester)
874
+ CLI_ARGS="exec --profile thorough ${codex_base} review"
875
+ CLI_EFFORT="thorough"
876
+ DEFAULT_TIMEOUT=1200
877
+ RUN_MODE="bg"
878
+ OPUS_OVERSIGHT="false"
879
+ ;;
880
+ *)
881
+ # claude-native 타입 중 위에 없는 경우는 보수적으로 유지
882
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
883
+ return
884
+ ;;
885
+ esac
886
+
887
+ echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1: $AGENT_TYPE -> codex($CLI_EFFORT) 리매핑" >&2
888
+ }
889
+
890
+ apply_verifier_override() {
891
+ [[ "$AGENT_TYPE" != "verifier" ]] && return
892
+
893
+ case "$TFX_VERIFIER_OVERRIDE" in
894
+ auto|"")
895
+ return 0
896
+ ;;
897
+ claude)
898
+ ORIGINAL_AGENT="${ORIGINAL_AGENT:-$AGENT_TYPE}"
899
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
900
+ echo "[tfx-route] TFX_VERIFIER_OVERRIDE=claude: verifier -> claude-native" >&2
901
+ ;;
902
+ esac
903
+
904
+ return 0
905
+ }
906
+
907
+ # ── MCP 인벤토리 캐시 ──
908
+ MCP_CACHE="${HOME}/.claude/cache/mcp-inventory.json"
909
+ MCP_FILTER_SCRIPT=""
910
+ MCP_PROFILE_REQUESTED="auto"
911
+ MCP_RESOLVED_PROFILE="default"
912
+ MCP_HINT=""
913
+ GEMINI_ALLOWED_SERVERS=()
914
+ CODEX_CONFIG_FLAGS=()
915
+ CODEX_CONFIG_JSON=""
916
+
917
+ get_cached_servers() {
918
+ local cli_type="$1"
919
+ if [[ -f "$MCP_CACHE" ]]; then
920
+ 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
921
+ fi
922
+ }
923
+
924
+ resolve_mcp_filter_script() {
925
+ if [[ -n "$MCP_FILTER_SCRIPT" && -f "$MCP_FILTER_SCRIPT" ]]; then
926
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
927
+ return 0
928
+ fi
929
+
930
+ local script_ref script_dir candidate
931
+ local -a candidates=()
932
+
933
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
934
+ if [[ -n "$script_ref" ]]; then
935
+ script_dir="$(cd "$(dirname "$script_ref")" 2>/dev/null && pwd -P || true)"
936
+ [[ -n "$script_dir" ]] && candidates+=("$script_dir/lib/mcp-filter.mjs")
937
+ fi
938
+
939
+ candidates+=(
940
+ "$PWD/scripts/lib/mcp-filter.mjs"
941
+ "$PWD/lib/mcp-filter.mjs"
942
+ )
943
+
944
+ for candidate in "${candidates[@]}"; do
945
+ if [[ -f "$candidate" ]]; then
946
+ MCP_FILTER_SCRIPT="$candidate"
947
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
948
+ return 0
949
+ fi
950
+ done
951
+
952
+ return 1
953
+ }
954
+
955
+ resolve_mcp_policy() {
956
+ local filter_script available_servers
957
+ if ! filter_script=$(resolve_mcp_filter_script); then
958
+ echo "[tfx-route] 경고: mcp-filter.mjs를 찾지 못해 기본 MCP 정책을 사용합니다." >&2
959
+ MCP_PROFILE_REQUESTED="$MCP_PROFILE"
960
+ MCP_RESOLVED_PROFILE="$MCP_PROFILE"
961
+ MCP_HINT=""
962
+ GEMINI_ALLOWED_SERVERS=()
963
+ CODEX_CONFIG_FLAGS=()
964
+ CODEX_CONFIG_JSON=""
965
+ return 0
966
+ fi
967
+
968
+ available_servers=$(get_cached_servers "$CLI_TYPE")
969
+ # Codex 0.115+: 미등록 서버에 config override(enabled=true/false 모두)를 보내면
970
+ # "invalid transport" 에러 발생. 캐시 비어있으면 빈 문자열로 유지하여
971
+ # mcp-filter가 override를 생성하지 않도록 한다.
972
+ [[ -z "$available_servers" ]] && available_servers=""
973
+
974
+ local -a cmd=(
975
+ "$NODE_BIN" "$filter_script" shell
976
+ "--agent" "$AGENT_TYPE"
977
+ "--profile" "$MCP_PROFILE"
978
+ "--available" "$available_servers"
979
+ "--inventory-file" "$MCP_CACHE"
980
+ "--task-text" "$PROMPT"
981
+ )
982
+ [[ -n "$TFX_SEARCH_TOOL" ]] && cmd+=("--search-tool" "$TFX_SEARCH_TOOL")
983
+ [[ -n "$TFX_WORKER_INDEX" ]] && cmd+=("--worker-index" "$TFX_WORKER_INDEX")
984
+
985
+ local shell_exports
986
+ if ! shell_exports="$("${cmd[@]}")"; then
987
+ echo "[tfx-route] ERROR: MCP 정책 계산 실패" >&2
988
+ return 1
989
+ fi
990
+
991
+ eval "$shell_exports"
992
+ }
993
+
994
+ get_claude_model() {
995
+ case "$AGENT_TYPE" in
996
+ explore) echo "haiku" ;;
997
+ *) echo "sonnet" ;;
998
+ esac
999
+ }
1000
+
1001
+ emit_claude_native_metadata() {
1002
+ local model
1003
+ model=$(get_claude_model)
1004
+ echo "ROUTE_TYPE=claude-native"
1005
+ echo "AGENT=$AGENT_TYPE"
1006
+ echo "MODEL=$model"
1007
+ echo "RUN_MODE=$RUN_MODE"
1008
+ echo "OPUS_OVERSIGHT=$OPUS_OVERSIGHT"
1009
+ echo "TIMEOUT=$TIMEOUT_SEC"
1010
+ echo "MCP_PROFILE=$MCP_PROFILE"
1011
+ [[ -n "$ORIGINAL_AGENT" ]] && echo "ORIGINAL_AGENT=$ORIGINAL_AGENT"
1012
+ echo "PROMPT=$PROMPT"
1013
+ echo "--- Claude Task($model) 에이전트로 위임하세요 ---"
1014
+ }
1015
+
1016
+ # heartbeat_monitor PID [INTERVAL] [STALL_THRESHOLD]
1017
+ # - PID: 감시할 워커 프로세스 PID
1018
+ # - INTERVAL: heartbeat 출력 간격 (초, 기본 10)
1019
+ # - STALL_THRESHOLD: stall 경고 임계값 (초, 기본 60)
1020
+ # 환경변수: TFX_HEARTBEAT (0이면 비활성화), TFX_HEARTBEAT_INTERVAL, TFX_STALL_THRESHOLD
1021
+ heartbeat_monitor() {
1022
+ [[ "${TFX_HEARTBEAT:-1}" -eq 0 ]] && return 0
1023
+ local pid="$1"
1024
+ local interval="${2:-${TFX_HEARTBEAT_INTERVAL:-10}}"
1025
+ local stall_threshold="${3:-${TFX_STALL_THRESHOLD:-60}}"
1026
+ local last_size=0 stall_count=0
1027
+
1028
+ while kill -0 "$pid" 2>/dev/null; do
1029
+ sleep "$interval"
1030
+ local current_size=0
1031
+ [[ -f "$STDOUT_LOG" ]] && current_size=$(wc -c < "$STDOUT_LOG" 2>/dev/null || echo 0)
1032
+ # P3: stderr 활동도 포함하여 거짓 STALL 방지
1033
+ local stderr_size=0
1034
+ [[ -f "$STDERR_LOG" ]] && stderr_size=$(wc -c < "$STDERR_LOG" 2>/dev/null || echo 0)
1035
+ current_size=$((current_size + stderr_size))
1036
+ local elapsed=$(($(date +%s) - TIMESTAMP))
1037
+
1038
+ if [[ "$current_size" -gt "$last_size" ]]; then
1039
+ stall_count=0
1040
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=active" >&2
1041
+ else
1042
+ stall_count=$((stall_count + interval))
1043
+ if [[ "$stall_count" -ge "$stall_threshold" ]]; then
1044
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL stall=${stall_count}s" >&2
1045
+ else
1046
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=quiet stall=${stall_count}s" >&2
1047
+ fi
1048
+ fi
1049
+ last_size=$current_size
1050
+ done
1051
+ echo "[tfx-heartbeat] pid=$pid terminated" >&2
1052
+ }
1053
+
1054
+ resolve_worker_runner_script() {
1055
+ if [[ -n "${TFX_ROUTE_WORKER_RUNNER:-}" && -f "$TFX_ROUTE_WORKER_RUNNER" ]]; then
1056
+ printf '%s\n' "$TFX_ROUTE_WORKER_RUNNER"
1057
+ return 0
1058
+ fi
1059
+
1060
+ local script_ref script_dir
1061
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1062
+ script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1063
+ local candidate="$script_dir/tfx-route-worker.mjs"
1064
+ [[ -f "$candidate" ]] || return 1
1065
+ printf '%s\n' "$candidate"
1066
+ }
1067
+
1068
+ run_stream_worker() {
1069
+ local worker_type="$1"
1070
+ local prompt="$2"
1071
+ local use_tee_flag="$3"
1072
+ shift 3
1073
+ local exit_code_local=0
1074
+ local worker_pid hb_pid
1075
+
1076
+ local runner_script
1077
+ if ! runner_script=$(resolve_worker_runner_script); then
1078
+ echo "[tfx-route] 경고: stream worker runner를 찾지 못했습니다." >&2
1079
+ return 127
1080
+ fi
1081
+
1082
+ if ! command -v "$NODE_BIN" &>/dev/null; then
1083
+ echo "[tfx-route] 경고: node를 찾지 못해 stream worker를 실행할 수 없습니다." >&2
1084
+ return 127
1085
+ fi
1086
+
1087
+ local -a worker_cmd=(
1088
+ "$NODE_BIN"
1089
+ "$runner_script"
1090
+ "--type" "$worker_type"
1091
+ "--timeout-ms" "$((TIMEOUT_SEC * 1000))"
1092
+ "--cwd" "$PWD"
1093
+ "$@"
1094
+ )
1095
+
1096
+ if [[ "$use_tee_flag" == "true" ]]; then
1097
+ printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1098
+ else
1099
+ printf '%s' "$prompt" | timeout "$TIMEOUT_SEC" "${worker_cmd[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1100
+ fi
1101
+ worker_pid=$!
1102
+
1103
+ heartbeat_monitor "$worker_pid" &
1104
+ hb_pid=$!
1105
+
1106
+ wait "$worker_pid" || exit_code_local=$?
1107
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1108
+ return "$exit_code_local"
1109
+ }
1110
+
1111
+ run_legacy_gemini() {
1112
+ local prompt="$1"
1113
+ local use_tee_flag="$2"
1114
+ local -a gemini_args=()
1115
+ read -r -a gemini_args <<< "$CLI_ARGS"
1116
+
1117
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1118
+ local gemini_mcp_filter prompt_index=-1
1119
+ gemini_mcp_filter=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")
1120
+ for i in "${!gemini_args[@]}"; do
1121
+ if [[ "${gemini_args[$i]}" == "--prompt" ]]; then
1122
+ prompt_index="$i"
1123
+ break
1124
+ fi
1125
+ done
1126
+ if [[ "$prompt_index" -ge 0 ]]; then
1127
+ gemini_args=(
1128
+ "${gemini_args[@]:0:$prompt_index}"
1129
+ "--allowed-mcp-server-names" "$gemini_mcp_filter"
1130
+ "${gemini_args[@]:$prompt_index}"
1131
+ )
1132
+ echo "[tfx-route] Gemini MCP 필터: $gemini_mcp_filter" >&2
1133
+ fi
1134
+ fi
1135
+
1136
+ if [[ "$use_tee_flag" == "true" ]]; then
1137
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1138
+ else
1139
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1140
+ fi
1141
+ local pid=$!
1142
+
1143
+ local health_ok=true
1144
+ local intervals=(1 2 3 5 8)
1145
+ for wait_sec in "${intervals[@]}"; do
1146
+ sleep "$wait_sec"
1147
+ if [[ -s "$STDOUT_LOG" ]] || [[ -s "$STDERR_LOG" ]]; then
1148
+ break
1149
+ fi
1150
+ if ! kill -0 "$pid" 2>/dev/null; then
1151
+ health_ok=false
1152
+ echo "[tfx-route] Gemini: 출력 없이 프로세스 종료 (${wait_sec}초 체크)" >&2
1153
+ break
1154
+ fi
1155
+ done
1156
+
1157
+ local exit_code_local=0
1158
+ local hb_pid
1159
+ if [[ "$health_ok" == "false" ]]; then
1160
+ wait "$pid" 2>/dev/null
1161
+ echo "[tfx-route] Gemini crash 감지, 재시도 중..." >&2
1162
+ if [[ "$use_tee_flag" == "true" ]]; then
1163
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1164
+ else
1165
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1166
+ fi
1167
+ pid=$!
1168
+ fi
1169
+
1170
+ heartbeat_monitor "$pid" &
1171
+ hb_pid=$!
1172
+ wait "$pid" || exit_code_local=$?
1173
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1174
+ return "$exit_code_local"
1175
+ }
1176
+
1177
+ resolve_codex_mcp_script() {
1178
+ if [[ -n "${TFX_CODEX_MCP_SCRIPT:-}" && -f "$TFX_CODEX_MCP_SCRIPT" ]]; then
1179
+ printf '%s\n' "$TFX_CODEX_MCP_SCRIPT"
1180
+ return 0
1181
+ fi
1182
+
1183
+ local script_ref script_dir
1184
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1185
+ script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1186
+ local candidates=()
1187
+ [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/workers/codex-mcp.mjs")
1188
+ candidates+=(
1189
+ "$script_dir/hub/workers/codex-mcp.mjs"
1190
+ "$script_dir/../hub/workers/codex-mcp.mjs"
1191
+ )
1192
+
1193
+ local candidate
1194
+ for candidate in "${candidates[@]}"; do
1195
+ if [[ -f "$candidate" ]]; then
1196
+ printf '%s\n' "$candidate"
1197
+ return 0
1198
+ fi
1199
+ done
1200
+
1201
+ return 1
1202
+ }
1203
+
1204
+ run_codex_exec() {
1205
+ local prompt="$1"
1206
+ local use_tee_flag="$2"
1207
+ local exit_code_local=0
1208
+ local worker_pid hb_pid
1209
+ local -a codex_args=()
1210
+ read -r -a codex_args <<< "$CLI_ARGS"
1211
+ if [[ ${#CODEX_CONFIG_FLAGS[@]} -gt 0 ]]; then
1212
+ codex_args+=("${CODEX_CONFIG_FLAGS[@]}")
1213
+ fi
1214
+
1215
+ if [[ "$use_tee_flag" == "true" ]]; then
1216
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1217
+ else
1218
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1219
+ fi
1220
+ worker_pid=$!
1221
+
1222
+ heartbeat_monitor "$worker_pid" &
1223
+ hb_pid=$!
1224
+
1225
+ wait "$worker_pid" || exit_code_local=$?
1226
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1227
+
1228
+ if [[ ! -s "$STDOUT_LOG" && -s "$STDERR_LOG" ]]; then
1229
+ # stderr에서 마지막 "codex" 마커 이후의 텍스트를 stdout으로 복구
1230
+ # 1차: "codex" 마커 기반 (Windows \r 제거 후 매칭)
1231
+ sed 's/\r$//' "$STDERR_LOG" \
1232
+ | awk '/^codex$/{found=NR;content=""} found && NR>found{content=content RS $0} END{if(content) print substr(content,2)}' \
1233
+ > "$STDOUT_LOG"
1234
+
1235
+ # 2차: 마커 없을 때 node fallback (MCP/헤더/sandbox 로그 제외, 응답 부분만 추출)
1236
+ if [[ ! -s "$STDOUT_LOG" ]]; then
1237
+ node -e '
1238
+ const fs=require("fs"),lines=fs.readFileSync(process.argv[1],"utf-8").split(/\r?\n/);
1239
+ const skip=/^(mcp[: ]|OpenAI Codex|--------|workdir:|model:|provider:|approval:|sandbox:|reasoning|session id:|user$|tokens used|EXIT:|exec$|"[A-Z]:|succeeded in |\s*$)/;
1240
+ const out=lines.filter(l=>!skip.test(l));
1241
+ if(out.length) fs.writeFileSync(process.argv[2],out.join("\n"));
1242
+ ' -- "$STDERR_LOG" "$STDOUT_LOG" 2>/dev/null || true
1243
+ fi
1244
+
1245
+ if [[ -s "$STDOUT_LOG" ]]; then
1246
+ echo "[tfx-route] 경고: codex stdout 비어있음, stderr에서 응답 복구 ($(wc -c < "$STDOUT_LOG") bytes)" >&2
1247
+ else
1248
+ echo "[tfx-route] 경고: codex stdout 비어있음, stderr 복구도 실패" >&2
1249
+ fi
1250
+ fi
1251
+
1252
+ return "$exit_code_local"
1253
+ }
1254
+
1255
+ run_codex_mcp() {
1256
+ local prompt="$1"
1257
+ local use_tee_flag="$2"
1258
+ local mcp_script node_bin
1259
+ local exit_code_local=0
1260
+ local worker_pid hb_pid
1261
+
1262
+ if ! mcp_script=$(resolve_codex_mcp_script); then
1263
+ echo "[tfx-route] 경고: Codex MCP 래퍼를 찾지 못했습니다." >&2
1264
+ return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1265
+ fi
1266
+
1267
+ node_bin="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
1268
+ if ! command -v "$node_bin" &>/dev/null; then
1269
+ echo "[tfx-route] 경고: node를 찾지 못해 Codex MCP 경로를 사용할 수 없습니다." >&2
1270
+ return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1271
+ fi
1272
+
1273
+ local -a mcp_args=(
1274
+ "$mcp_script"
1275
+ "--prompt" "$prompt"
1276
+ "--cwd" "$PWD"
1277
+ "--profile" "$CLI_EFFORT"
1278
+ "--approval-policy" "never"
1279
+ "--sandbox" "danger-full-access"
1280
+ "--timeout-ms" "$((TIMEOUT_SEC * 1000))"
1281
+ "--codex-command" "$CODEX_BIN"
1282
+ )
1283
+
1284
+ if [[ -n "$CODEX_CONFIG_JSON" && "$CODEX_CONFIG_JSON" != "{}" ]]; then
1285
+ mcp_args+=("--config-json" "$CODEX_CONFIG_JSON")
1286
+ fi
1287
+
1288
+ case "$AGENT_TYPE" in
1289
+ code-reviewer)
1290
+ mcp_args+=(
1291
+ "--developer-instructions"
1292
+ "코드 리뷰 모드로 동작하라. 버그, 리스크, 회귀, 테스트 누락을 우선 식별하라."
1293
+ )
1294
+ ;;
1295
+ security-reviewer)
1296
+ mcp_args+=(
1297
+ "--developer-instructions"
1298
+ "보안 리뷰 모드로 동작하라. 취약점, 권한 경계, 비밀정보 노출 가능성을 우선 식별하라."
1299
+ )
1300
+ ;;
1301
+ quality-reviewer)
1302
+ mcp_args+=(
1303
+ "--developer-instructions"
1304
+ "품질 리뷰 모드로 동작하라. 로직 결함, 유지보수성 저하, 테스트 누락을 우선 식별하라."
1305
+ )
1306
+ ;;
1307
+ esac
1308
+
1309
+ if [[ "$use_tee_flag" == "true" ]]; then
1310
+ timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1311
+ else
1312
+ timeout "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1313
+ fi
1314
+ worker_pid=$!
1315
+
1316
+ heartbeat_monitor "$worker_pid" &
1317
+ hb_pid=$!
1318
+
1319
+ wait "$worker_pid" || exit_code_local=$?
1320
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1321
+
1322
+ # 모듈 로드 실패(의존성 누락) → MCP transport exit code로 변환하여 fallback 트리거
1323
+ if [[ "$exit_code_local" -ne 0 && "$exit_code_local" -ne 124 ]] && grep -q 'ERR_MODULE_NOT_FOUND' "$STDERR_LOG" 2>/dev/null; then
1324
+ echo "[tfx-route] Codex MCP 모듈 로드 실패 — fallback 가능 exit code로 변환" >&2
1325
+ return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1326
+ fi
1327
+
1328
+ return "$exit_code_local"
1329
+ }
1330
+
1331
+ # ── 메인 실행 ──
1332
+ main() {
1333
+ # 종료 시 per-process 에이전트 파일 자동 삭제
1334
+ trap 'deregister_agent' EXIT
1335
+
1336
+ route_agent "$AGENT_TYPE"
1337
+ apply_cli_mode
1338
+ apply_no_claude_native_mode
1339
+ apply_plan_guard
1340
+ apply_verifier_override
1341
+
1342
+ # CLI 경로 해석
1343
+ case "$CLI_CMD" in
1344
+ codex) CLI_CMD="$CODEX_BIN" ;;
1345
+ gemini) CLI_CMD="$GEMINI_BIN" ;;
1346
+ claude) CLI_CMD="$CLAUDE_BIN" ;;
1347
+ esac
1348
+
1349
+ # 타임아웃 결정 (에이전트별 최소값 보장)
1350
+ local MIN_TIMEOUT
1351
+ case "$AGENT_TYPE" in
1352
+ deep-executor|architect|planner|critic|analyst) MIN_TIMEOUT=900 ;;
1353
+ document-specialist|scientist|scientist-deep) MIN_TIMEOUT=900 ;;
1354
+ code-reviewer|security-reviewer|quality-reviewer) MIN_TIMEOUT=600 ;;
1355
+ executor|debugger) MIN_TIMEOUT=300 ;;
1356
+ *) MIN_TIMEOUT=120 ;;
1357
+ esac
1358
+
1359
+ if [[ -n "$USER_TIMEOUT" ]]; then
1360
+ if ! [[ "$USER_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then
1361
+ echo "[tfx-route] 경고: 유효하지 않은 타임아웃 값 ($USER_TIMEOUT), 기본값 사용" >&2
1362
+ USER_TIMEOUT=""
1363
+ TIMEOUT_SEC="$DEFAULT_TIMEOUT"
1364
+ elif [[ "$USER_TIMEOUT" -lt "$MIN_TIMEOUT" ]]; then
1365
+ echo "[tfx-route] 경고: 타임아웃 ${USER_TIMEOUT}s < 최소 ${MIN_TIMEOUT}s ($AGENT_TYPE), 최소값 적용" >&2
1366
+ TIMEOUT_SEC="$MIN_TIMEOUT"
1367
+ else
1368
+ TIMEOUT_SEC="$USER_TIMEOUT"
1369
+ fi
1370
+ else
1371
+ TIMEOUT_SEC="$DEFAULT_TIMEOUT"
1372
+ fi
1373
+
1374
+ # 컨텍스트 파일 → 프롬프트에 주입
1375
+ if [[ -n "$CONTEXT_FILE" && -f "$CONTEXT_FILE" ]]; then
1376
+ local ctx_content
1377
+ ctx_content=$(cat "$CONTEXT_FILE" 2>/dev/null | head -c 32768) # 32KB 상한
1378
+ PROMPT="${PROMPT}
1379
+
1380
+ <prior_context>
1381
+ ${ctx_content}
1382
+ </prior_context>"
1383
+ fi
1384
+
1385
+ resolve_mcp_policy
1386
+
1387
+ # Claude native는 비-TTY 환경에서 subprocess wrapper를 우선 시도
1388
+ if [[ "$CLI_TYPE" == "claude-native" && -n "$TFX_TEAM_NAME" ]]; then
1389
+ if { [[ ! -t 0 ]] || [[ ! -t 1 ]]; } && command -v "$CLAUDE_BIN" &>/dev/null && resolve_worker_runner_script >/dev/null 2>&1; then
1390
+ CLI_TYPE="claude"
1391
+ CLI_CMD="$CLAUDE_BIN"
1392
+ echo "[tfx-route] non-tty 팀 환경: claude-native -> claude stream wrapper 전환" >&2
1393
+ else
1394
+ echo "[tfx-route] claude stream wrapper 미사용: native metadata 유지" >&2
1395
+ fi
1396
+ fi
1397
+
1398
+ # Claude 네이티브 에이전트는 이 스크립트로 처리 불가
1399
+ if [[ "$CLI_TYPE" == "claude-native" ]]; then
1400
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1401
+ # 모드: Hub에 fallback 필요 시그널 전송 후 구조화된 출력
1402
+ echo "[tfx-route] claude-native 역할($AGENT_TYPE)은 tfx-route.sh로 실행 불가 — Claude Agent fallback 필요" >&2
1403
+ team_complete_task "fallback" "claude-native 역할 실행 불가: ${AGENT_TYPE}. Claude Task(sonnet) 에이전트로 위임하세요."
1404
+ cat <<FALLBACK_EOF
1405
+ === TFX_NEEDS_FALLBACK ===
1406
+ agent_type: ${AGENT_TYPE}
1407
+ reason: claude-native roles require Claude Agent tools (Read/Edit/Grep). tfx-route.sh cannot provide these.
1408
+ action: Lead should spawn Agent(subagent_type="${AGENT_TYPE}") for this task.
1409
+ task_id: ${TFX_TEAM_TASK_ID:-none}
1410
+ FALLBACK_EOF
1411
+ exit 0
1412
+ fi
1413
+ emit_claude_native_metadata
1414
+ exit 0
1415
+ fi
1416
+
1417
+ local FULL_PROMPT="$PROMPT"
1418
+ [[ -n "$MCP_HINT" ]] && FULL_PROMPT="${PROMPT}. ${MCP_HINT}"
1419
+ local codex_transport_effective="n/a"
1420
+
1421
+ # 메타정보 (stderr)
1422
+ echo "[tfx-route] v${VERSION} type=$CLI_TYPE agent=$AGENT_TYPE effort=$CLI_EFFORT mode=$RUN_MODE timeout=${TIMEOUT_SEC}s" >&2
1423
+ echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE resolved_profile=$MCP_RESOLVED_PROFILE verifier_override=$TFX_VERIFIER_OVERRIDE" >&2
1424
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1425
+ echo "[tfx-route] allowed_mcp_servers=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1426
+ else
1427
+ echo "[tfx-route] allowed_mcp_servers=none" >&2
1428
+ fi
1429
+ if [[ -n "$TFX_WORKER_INDEX" || -n "$TFX_SEARCH_TOOL" ]]; then
1430
+ echo "[tfx-route] worker_index=${TFX_WORKER_INDEX:-auto} search_tool=${TFX_SEARCH_TOOL:-auto}" >&2
1431
+ fi
1432
+ if [[ "$CLI_TYPE" == "codex" ]]; then
1433
+ echo "[tfx-route] codex_transport_request=$TFX_CODEX_TRANSPORT" >&2
1434
+ fi
1435
+ [[ -n "$TFX_TEAM_NAME" ]] && echo "[tfx-route] team=$TFX_TEAM_NAME task=$TFX_TEAM_TASK_ID agent=$TFX_TEAM_AGENT_NAME" >&2
1436
+ [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "[tfx-route] rerouted_from=$TFX_REROUTED_FROM" >&2
1437
+
1438
+ # Per-process 에이전트 등록
1439
+ register_agent
1440
+
1441
+ # 팀 모드: task claim
1442
+ team_claim_task
1443
+ team_send_message "작업 시작: ${TFX_TEAM_AGENT_NAME}" "task ${TFX_TEAM_TASK_ID} started"
1444
+
1445
+ # CLI 실행 (stderr 분리 + 타임아웃 + 소요시간 측정)
1446
+ local exit_code=0
1447
+ local start_time
1448
+ start_time=$(date +%s)
1449
+ local workspace_signature_before=""
1450
+ local workspace_signature_after=""
1451
+ local workspace_probe_supported=false
1452
+ if workspace_signature_before=$(capture_workspace_signature); then
1453
+ workspace_probe_supported=true
1454
+ fi
1455
+
1456
+ # tee 활성화 조건: 팀 모드 + 실제 터미널(TTY/tmux)
1457
+ # Agent 래퍼 안에서는 가상 stdout 캡처로 tee 출력이 사용자에게 안 보임 → 파일 전용
1458
+ # 실시간 모니터링은 Shift+Down으로 워커 pane 전환 권장
1459
+ local use_tee=false
1460
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1461
+ if [[ -t 1 ]] || [[ -n "${TMUX:-}" ]]; then
1462
+ use_tee=true
1463
+ fi
1464
+ fi
1465
+
1466
+ if [[ "$CLI_TYPE" == "codex" ]]; then
1467
+ codex_transport_effective="exec"
1468
+ if [[ "$TFX_CODEX_TRANSPORT" != "exec" ]]; then
1469
+ run_codex_mcp "$FULL_PROMPT" "$use_tee" || exit_code=$?
1470
+ if [[ "$exit_code" -eq 0 ]]; then
1471
+ codex_transport_effective="mcp"
1472
+ elif [[ "$exit_code" -eq "$CODEX_MCP_TRANSPORT_EXIT_CODE" && "$TFX_CODEX_TRANSPORT" == "auto" ]]; then
1473
+ echo "[tfx-route] Codex MCP bootstrap 실패(exit=${exit_code}). legacy exec 경로로 fallback합니다." >&2
1474
+ : > "$STDOUT_LOG"
1475
+ : > "$STDERR_LOG"
1476
+ exit_code=0
1477
+ run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
1478
+ codex_transport_effective="exec-fallback"
1479
+ else
1480
+ codex_transport_effective="mcp"
1481
+ fi
1482
+ else
1483
+ run_codex_exec "$FULL_PROMPT" "$use_tee" || exit_code=$?
1484
+ codex_transport_effective="exec"
1485
+ fi
1486
+ echo "[tfx-route] codex_transport_effective=$codex_transport_effective" >&2
1487
+
1488
+ elif [[ "$CLI_TYPE" == "gemini" ]]; then
1489
+ local gemini_model
1490
+ gemini_model=$(awk '{
1491
+ for (i = 1; i <= NF; i++) {
1492
+ if ($i == "-m" || $i == "--model") {
1493
+ print $(i + 1)
1494
+ exit
1495
+ }
1496
+ }
1497
+ }' <<< "$CLI_ARGS")
1498
+ local -a gemini_worker_args=(
1499
+ "--command" "$CLI_CMD"
1500
+ "--command-args-json" "$GEMINI_BIN_ARGS_JSON"
1501
+ "--model" "$gemini_model"
1502
+ "--approval-mode" "yolo"
1503
+ )
1504
+
1505
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1506
+ echo "[tfx-route] Gemini MCP 서버: $(IFS=' '; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1507
+ local server_name
1508
+ for server_name in "${GEMINI_ALLOWED_SERVERS[@]}"; do
1509
+ gemini_worker_args+=("--allowed-mcp-server-name" "$server_name")
1510
+ done
1511
+ fi
1512
+
1513
+ run_stream_worker "gemini" "$FULL_PROMPT" "$use_tee" "${gemini_worker_args[@]}" || exit_code=$?
1514
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1515
+ echo "[tfx-route] Gemini stream wrapper 실패(exit=${exit_code}). legacy CLI 경로로 fallback합니다." >&2
1516
+ : > "$STDOUT_LOG"
1517
+ : > "$STDERR_LOG"
1518
+ exit_code=0
1519
+ run_legacy_gemini "$FULL_PROMPT" "$use_tee" || exit_code=$?
1520
+ fi
1521
+
1522
+ elif [[ "$CLI_TYPE" == "claude" ]]; then
1523
+ local claude_model
1524
+ claude_model=$(get_claude_model)
1525
+ local -a claude_worker_args=(
1526
+ "--command" "$CLI_CMD"
1527
+ "--command-args-json" "$CLAUDE_BIN_ARGS_JSON"
1528
+ "--model" "$claude_model"
1529
+ "--permission-mode" "bypassPermissions"
1530
+ "--allow-dangerously-skip-permissions"
1531
+ )
1532
+
1533
+ run_stream_worker "claude" "$FULL_PROMPT" "$use_tee" "${claude_worker_args[@]}" || exit_code=$?
1534
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1535
+ echo "[tfx-route] Claude stream wrapper 실패(exit=${exit_code}). native metadata로 fallback합니다." >&2
1536
+ cat > "$STDOUT_LOG" <<EOF
1537
+ $(emit_claude_native_metadata)
1538
+ EOF
1539
+ : > "$STDERR_LOG"
1540
+ exit_code=0
1541
+ CLI_TYPE="claude-native"
1542
+ fi
1543
+ fi
1544
+
1545
+ local end_time
1546
+ end_time=$(date +%s)
1547
+ local elapsed=$((end_time - start_time))
1548
+
1549
+ if [[ "$exit_code" -eq 0 ]]; then
1550
+ local workspace_changed="unknown"
1551
+ if [[ "$workspace_probe_supported" == "true" ]]; then
1552
+ if workspace_signature_after=$(capture_workspace_signature); then
1553
+ if [[ "$workspace_signature_before" != "$workspace_signature_after" ]]; then
1554
+ workspace_changed="yes"
1555
+ else
1556
+ workspace_changed="no"
1557
+ fi
1558
+ fi
1559
+ fi
1560
+
1561
+ if [[ ! -s "$STDOUT_LOG" && "$workspace_changed" == "no" ]]; then
1562
+ printf '%s\n' "[tfx-route] exit 0 이지만 stdout 비어있고 워크스페이스 변화가 없습니다. no-op 성공을 실패로 승격합니다." >> "$STDERR_LOG"
1563
+ exit_code=68
1564
+ fi
1565
+ fi
1566
+
1567
+ # 쿼타 감지 + 자동 re-route
1568
+ if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1569
+ if [[ "${TFX_QUOTA_REROUTE:-1}" -ne 0 ]] && [[ -z "${TFX_REROUTED_FROM:-}" ]] && detect_quota_exceeded "$STDOUT_LOG" "$STDERR_LOG"; then
1570
+ export TFX_REROUTED_FROM="$CLI_TYPE"
1571
+ auto_reroute "$CLI_TYPE"
1572
+ fi
1573
+ fi
1574
+
1575
+ # 모드: task complete + 리드 보고
1576
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1577
+ if [[ "$exit_code" -eq 0 ]]; then
1578
+ local output_preview
1579
+ output_preview=$(head -c 2048 "$STDOUT_LOG" 2>/dev/null || echo "출력 없음")
1580
+ team_complete_task "success" "$output_preview"
1581
+ elif [[ "$exit_code" -eq 124 ]]; then
1582
+ team_complete_task "timeout" "타임아웃 (${TIMEOUT_SEC}초)"
1583
+ else
1584
+ local err_preview
1585
+ err_preview=$(tail -c 1024 "$STDERR_LOG" 2>/dev/null || echo "에러 정보 없음")
1586
+ team_complete_task "failed" "exit_code=${exit_code}: ${err_preview}"
1587
+ fi
1588
+ fi
1589
+
1590
+ # ── 후처리: 단일 node 프로세스로 위임 ──
1591
+ # 토큰 추출, 출력 필터링, 로그, 토큰 누적, AIMD, 이슈 추적, 결과 출력 전부 처리
1592
+ local post_script="${HOME}/.claude/scripts/tfx-route-post.mjs"
1593
+ if [[ -f "$post_script" ]]; then
1594
+ node "$post_script" \
1595
+ --agent "$AGENT_TYPE" \
1596
+ --cli "$CLI_TYPE" \
1597
+ --cli-cmd "$CLI_CMD" \
1598
+ --effort "$CLI_EFFORT" \
1599
+ --run-mode "$RUN_MODE" \
1600
+ --opus "$OPUS_OVERSIGHT" \
1601
+ --exit-code "$exit_code" \
1602
+ --elapsed "$elapsed" \
1603
+ --timeout "$TIMEOUT_SEC" \
1604
+ --mcp-profile "$MCP_PROFILE" \
1605
+ --stderr-log "$STDERR_LOG" \
1606
+ --stdout-log "$STDOUT_LOG" \
1607
+ --rerouted-from "${TFX_REROUTED_FROM:-}" \
1608
+ --max-bytes "$MAX_STDOUT_BYTES" \
1609
+ --tee-active "$use_tee" \
1610
+ --clean-tui "${TFX_CLEAN_TUI:-true}"
1611
+ else
1612
+ # post.mjs 없으면 기본 출력 (fallback)
1613
+ echo "=== TFX-ROUTE RESULT ==="
1614
+ echo "agent: $AGENT_TYPE"
1615
+ echo "cli: $CLI_TYPE"
1616
+ [[ -n "${TFX_REROUTED_FROM:-}" ]] && echo "rerouted_from: $TFX_REROUTED_FROM"
1617
+ echo "exit_code: $exit_code"
1618
+ echo "elapsed: ${elapsed}s"
1619
+ echo "status: $([ $exit_code -eq 0 ] && echo success || echo failed)"
1620
+ echo "=== OUTPUT ==="
1621
+ if [[ "${TFX_CLEAN_TUI:-1}" != "0" ]]; then
1622
+ cat "$STDOUT_LOG" 2>/dev/null \
1623
+ | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
1624
+ | sed '/^[[:space:]]*[╭╮╰╯│─┌┐└┘├┤┬┴┼]/d' \
1625
+ | sed '/^[[:space:]]*[›❯][[:space:]]*$/d' \
1626
+ | head -c "$MAX_STDOUT_BYTES"
1627
+ else
1628
+ cat "$STDOUT_LOG" 2>/dev/null | head -c "$MAX_STDOUT_BYTES"
1629
+ fi
1630
+ fi
1631
+
1632
+ return "$exit_code"
1633
+ }
1634
+
1635
+ # ── Async 모드: 백그라운드 실행 + 즉시 job_id 반환 ──
1636
+ if [[ "$TFX_ASYNC_MODE" -eq 1 ]]; then
1637
+ mkdir -p "$TFX_JOBS_DIR"
1638
+ JOB_ID="$TIMESTAMP-$$-${RANDOM}"
1639
+ JOB_DIR="$TFX_JOBS_DIR/$JOB_ID"
1640
+ mkdir -p "$JOB_DIR"
1641
+ echo "$AGENT_TYPE" > "$JOB_DIR/agent_type"
1642
+ date +%s > "$JOB_DIR/start_time"
1643
+
1644
+ # 백그라운드 서브쉘: main 실행 → 결과 저장
1645
+ (
1646
+ set +e # main 내부 에러가 exit_code 기록 전에 서브쉘을 죽이는 것 방지
1647
+ exec > "$JOB_DIR/result.log" 2>"$JOB_DIR/stderr.log"
1648
+ main
1649
+ echo $? > "$JOB_DIR/exit_code"
1650
+ touch "$JOB_DIR/done"
1651
+ ) &
1652
+ bg_pid=$!
1653
+ echo "$bg_pid" > "$JOB_DIR/pid"
1654
+
1655
+ # 종료 감지 데몬 (main이 signal/crash로 죽어도 done 마커 생성)
1656
+ (
1657
+ wait "$bg_pid" 2>/dev/null
1658
+ ec=$?
1659
+ if [[ ! -f "$JOB_DIR/done" ]]; then
1660
+ echo "$ec" > "$JOB_DIR/exit_code"
1661
+ touch "$JOB_DIR/done"
1662
+ fi
1663
+ ) &
1664
+ disown
1665
+
1666
+ # 즉시 리턴: 1초 이내에 Claude Code Bash 도구 완료
1667
+ echo "$JOB_ID"
1668
+ exit 0
1669
+ fi
1670
+
1671
+ main