triflux 10.9.9 → 10.9.11

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.
@@ -11,7 +11,12 @@
11
11
  "skill": null,
12
12
  "action": "suppress_all",
13
13
  "priority": 0,
14
- "supersedes": ["tfx-multi", "tfx-unified", "tfx-codex", "tfx-gemini"],
14
+ "supersedes": [
15
+ "tfx-multi",
16
+ "tfx-unified",
17
+ "tfx-codex",
18
+ "tfx-gemini"
19
+ ],
15
20
  "exclusive": true,
16
21
  "state": null,
17
22
  "mcp_route": null
@@ -57,7 +62,9 @@
57
62
  ],
58
63
  "skill": "tfx-swarm",
59
64
  "priority": 1,
60
- "supersedes": ["tfx-codex"],
65
+ "supersedes": [
66
+ "tfx-codex"
67
+ ],
61
68
  "exclusive": false,
62
69
  "state": null,
63
70
  "mcp_route": null
@@ -65,14 +72,38 @@
65
72
  {
66
73
  "id": "tfx-unified",
67
74
  "patterns": [
68
- { "source": "\\btfx[\\s-]?auto\\b", "flags": "i" },
69
- { "source": "\\btfx[\\s-]?auto[\\s-]?codex\\b", "flags": "i" },
70
- { "source": "(?:만들어|고쳐|구현해|짜줘|수정해|바꿔)", "flags": "i" },
71
- { "source": "(?:리뷰해|검토해|봐줘|괜찮아)", "flags": "i" },
72
- { "source": "(?:테스트|검증|돌려봐|QA)", "flags": "i" },
73
- { "source": "(?:분석해|계획|설계해)", "flags": "i" },
74
- { "source": "(?:찾아봐|조사해|검색해)", "flags": "i" },
75
- { "source": "(?:정리해|슬롭|클린업)", "flags": "i" },
75
+ {
76
+ "source": "\\btfx[\\s-]?auto\\b",
77
+ "flags": "i"
78
+ },
79
+ {
80
+ "source": "\\btfx[\\s-]?auto[\\s-]?codex\\b",
81
+ "flags": "i"
82
+ },
83
+ {
84
+ "source": "(?:만들어|고쳐|구현해|짜줘|수정해|바꿔)",
85
+ "flags": "i"
86
+ },
87
+ {
88
+ "source": "(?:리뷰해|검토해|봐줘|괜찮아)",
89
+ "flags": "i"
90
+ },
91
+ {
92
+ "source": "(?:테스트|검증|돌려봐|QA)",
93
+ "flags": "i"
94
+ },
95
+ {
96
+ "source": "(?:분석해|계획|설계해)",
97
+ "flags": "i"
98
+ },
99
+ {
100
+ "source": "(?:찾아봐|조사해|검색해)",
101
+ "flags": "i"
102
+ },
103
+ {
104
+ "source": "(?:정리해|슬롭|클린업)",
105
+ "flags": "i"
106
+ },
76
107
  {
77
108
  "source": "\\b(?:implement|build|fix|review|test|plan|analyze)\\b",
78
109
  "flags": "i"
@@ -80,7 +111,9 @@
80
111
  ],
81
112
  "skill": "tfx-auto",
82
113
  "priority": 2,
83
- "supersedes": ["tfx-auto-codex"],
114
+ "supersedes": [
115
+ "tfx-auto-codex"
116
+ ],
84
117
  "exclusive": false,
85
118
  "state": null,
86
119
  "mcp_route": null
@@ -193,17 +226,32 @@
193
226
  {
194
227
  "id": "wt-tab-route",
195
228
  "patterns": [
196
- { "source": "(?:새\\s*탭|탭\\s*(?:새로|추가|생성|열|띄|파|만들))", "flags": "i" },
197
- { "source": "(?:패인|화면|pane)\\s*(?:분할|나눠|스플릿|split)", "flags": "i" },
198
- { "source": "\\bwt\\b\\s*(?:에\\s*)?(?:탭|tab|열|띄|새)", "flags": "i" },
199
- { "source": "(?:터미널|terminal)\\s*(?:탭|새\\s*탭)", "flags": "i" },
200
- { "source": "\\b(?:new\\s+tab|split\\s+pane|open\\s+(?:new\\s+)?tab)\\b", "flags": "i" }
229
+ {
230
+ "source": "(?:새\\s*탭|탭\\s*(?:새로|추가|생성|열|띄|파|만들))",
231
+ "flags": "i"
232
+ },
233
+ {
234
+ "source": "(?:패인|화면|pane)\\s*(?:분할|나눠|스플릿|split)",
235
+ "flags": "i"
236
+ },
237
+ {
238
+ "source": "\\bwt\\b\\s*(?:에\\s*)?(?:탭|tab|열|띄|새)",
239
+ "flags": "i"
240
+ },
241
+ {
242
+ "source": "(?:터미널|terminal)\\s*(?:탭|새\\s*탭)",
243
+ "flags": "i"
244
+ },
245
+ {
246
+ "source": "\\b(?:new\\s+tab|split\\s+pane|open\\s+(?:new\\s+)?tab)\\b",
247
+ "flags": "i"
248
+ }
201
249
  ],
202
- "skill": null,
203
- "action": "context_hint",
204
- "hint": "WT 탭/패인 생성 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.createTab({ title, command, profile, cwd }) — 새 탭\n- wt.splitPane({ direction: 'H'|'V', title, command }) — 패인 분할\n- wt.applySplitLayout([{ title, command, direction }]) — 다중 배치\n- wt.closeTab(title) — 탭 닫기\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); wt.createTab({ title: 'MyTab', command: 'pwsh' }); })\"",
250
+ "skill": "tfx-wt",
205
251
  "priority": 1,
206
- "supersedes": ["tfx-unified"],
252
+ "supersedes": [
253
+ "tfx-unified"
254
+ ],
207
255
  "exclusive": false,
208
256
  "state": null,
209
257
  "mcp_route": null
@@ -211,14 +259,20 @@
211
259
  {
212
260
  "id": "wt-tab-rename",
213
261
  "patterns": [
214
- { "source": "(?:탭\\s*(?:이름|제목)\\s*(?:바꿔|변경|rename))", "flags": "i" },
215
- { "source": "(?:rename\\s+tab|tab\\s+rename)", "flags": "i" }
262
+ {
263
+ "source": "(?:탭\\s*(?:이름|제목)\\s*(?:바꿔|변경|rename))",
264
+ "flags": "i"
265
+ },
266
+ {
267
+ "source": "(?:rename\\s+tab|tab\\s+rename)",
268
+ "flags": "i"
269
+ }
216
270
  ],
217
- "skill": null,
218
- "action": "context_hint",
219
- "hint": "WT 탭 이름 변경 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.renameTab({ oldTitle, newTitle }) — 탭 이름 변경\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); wt.renameTab({ oldTitle: 'OldName', newTitle: 'NewName' }); })\"",
271
+ "skill": "tfx-wt",
220
272
  "priority": 1,
221
- "supersedes": ["tfx-unified"],
273
+ "supersedes": [
274
+ "tfx-unified"
275
+ ],
222
276
  "exclusive": false,
223
277
  "state": null,
224
278
  "mcp_route": null
@@ -226,14 +280,20 @@
226
280
  {
227
281
  "id": "wt-tab-list",
228
282
  "patterns": [
229
- { "source": "(?:탭\\s*(?:목록|리스트|열린|현재))", "flags": "i" },
230
- { "source": "(?:list\\s+tabs|tab\\s+list|열린\\s*탭)", "flags": "i" }
283
+ {
284
+ "source": "(?:탭\\s*(?:목록|리스트|열린|현재))",
285
+ "flags": "i"
286
+ },
287
+ {
288
+ "source": "(?:list\\s+tabs|tab\\s+list|열린\\s*탭)",
289
+ "flags": "i"
290
+ }
231
291
  ],
232
- "skill": null,
233
- "action": "context_hint",
234
- "hint": "WT 탭 목록 조회 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.listTabs() — 현재 열린 탭 목록 (pid 파일 기반)\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); console.log(wt.listTabs()); })\"",
292
+ "skill": "tfx-wt",
235
293
  "priority": 1,
236
- "supersedes": ["tfx-unified"],
294
+ "supersedes": [
295
+ "tfx-unified"
296
+ ],
237
297
  "exclusive": false,
238
298
  "state": null,
239
299
  "mcp_route": null
@@ -241,14 +301,20 @@
241
301
  {
242
302
  "id": "wt-tab-close",
243
303
  "patterns": [
244
- { "source": "(?:탭\\s*(?:닫아|닫기|종료|정리|close))", "flags": "i" },
245
- { "source": "(?:close\\s+tab|tab\\s+close|탭\\s*정리)", "flags": "i" }
304
+ {
305
+ "source": "(?:탭\\s*(?:닫아|닫기|종료|정리|close))",
306
+ "flags": "i"
307
+ },
308
+ {
309
+ "source": "(?:close\\s+tab|tab\\s+close|탭\\s*정리)",
310
+ "flags": "i"
311
+ }
246
312
  ],
247
- "skill": null,
248
- "action": "context_hint",
249
- "hint": "WT 탭 닫기 요청입니다. wt.exe 직접 호출은 safety-guard가 차단합니다.\n\nhub/team/wt-manager.mjs의 createWtManager() 팩토리로 인스턴스를 만든 뒤 API를 호출하세요:\n- wt.closeTab(title) — 제목으로 탭 닫기\n- wt.closeStale({ olderThanMs, titlePattern }) — 오래된/패턴 매칭 탭 정리\n\n사용법:\nnode -e \"import('./hub/team/wt-manager.mjs').then(m => { const wt = m.createWtManager(); wt.closeTab('MyTab'); })\"",
313
+ "skill": "tfx-wt",
250
314
  "priority": 1,
251
- "supersedes": ["tfx-unified"],
315
+ "supersedes": [
316
+ "tfx-unified"
317
+ ],
252
318
  "exclusive": false,
253
319
  "state": null,
254
320
  "mcp_route": null
@@ -354,6 +354,13 @@ class AccountBroker extends EventEmitter {
354
354
  if (isHalfOpen) {
355
355
  this.emit("circuitClose", { id: accountId });
356
356
  }
357
+ } else if (result?.skipCircuit) {
358
+ // 인프라 에러 — circuit/cooldown에 카운트하지 않음
359
+ circuitUpdate = {
360
+ failureTimestamps: acct.failureTimestamps,
361
+ circuitOpenedAt: acct.circuitOpenedAt,
362
+ circuitTrialInFlight: acct.circuitTrialInFlight,
363
+ };
357
364
  } else {
358
365
  circuitUpdate = this.#recordCircuitFailure(acct, isHalfOpen, now);
359
366
  if (circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt) {
@@ -282,6 +282,9 @@ export async function executeWithCircuitBroker({
282
282
  const coolMs = parseRetryAfterMs(text, provider);
283
283
  brokerMod.broker.markRateLimited(lease.id, coolMs);
284
284
  brokerMod.broker.emit("cooldown", { id: lease.id, provider, coolMs, reason: "quota_exhausted" });
285
+ } else if (lastResult.failureMode === "crash") {
286
+ // 인프라 에러(모듈 누락, 서버 에러 등)는 계정 문제가 아님 → circuit/cooldown 건너뜀
287
+ brokerMod.broker.release(lease.id, { ok: false, skipCircuit: true });
285
288
  } else {
286
289
  brokerMod.broker.release(lease.id, { ok: false });
287
290
  }
@@ -47,6 +47,17 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
47
47
  .legend-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px;vertical-align:middle}
48
48
 
49
49
  .placeholder{text-align:center;padding:24px;color:#8b949e;font-size:.85rem}
50
+ .btn{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 12px;border-radius:6px;font-size:.78rem;cursor:pointer;transition:background .2s}
51
+ .btn:hover{background:#30363d}
52
+ .btn:active{background:#3b414a}
53
+ .btn.ok{border-color:#3fb950;color:#3fb950}
54
+ .guide{display:none;max-width:1200px;margin:0 auto 16px;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;font-size:.82rem;line-height:1.7;color:#8b949e}
55
+ .guide.open{display:block}
56
+ .guide h3{color:#c9d1d9;font-size:.9rem;margin:12px 0 6px}
57
+ .guide h3:first-child{margin-top:0}
58
+ .guide code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.78rem;color:#d19a66}
59
+ .guide ul{padding-left:18px;margin:4px 0}
60
+ .actions{display:flex;gap:8px;align-items:center}
50
61
  </style>
51
62
  </head>
52
63
  <body>
@@ -57,9 +68,35 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
57
68
  <span class="status-dot" id="statusDot"></span>
58
69
  <span id="statusText">연결 중...</span>
59
70
  <span id="lastUpdate" class="sub"></span>
71
+ <div class="actions">
72
+ <button class="btn" id="guideBtn" title="도움말">?</button>
73
+ <button class="btn" id="reloadBtn" title="계정 브로커 리로드">Reload</button>
74
+ <a href="/broker/dashboard" target="_blank" class="btn" style="text-decoration:none">Broker</a>
75
+ </div>
60
76
  </div>
61
77
  </div>
62
78
 
79
+ <div class="guide" id="guidePanel">
80
+ <h3>AIMD 동시 워커</h3>
81
+ <p>AIMD(Additive Increase / Multiplicative Decrease) 알고리즘으로 최적 동시 워커 수를 자동 조절합니다. 게이지가 <code>10/10</code>에 가까우면 시스템이 포화 상태입니다.</p>
82
+ <h3>CLI 쿼터 사용률</h3>
83
+ <ul>
84
+ <li><strong>Claude</strong> — 5시간/주간 사용량. 80% 이상이면 빨간색 경고.</li>
85
+ <li><strong>Codex</strong> — Primary/Secondary 버킷별 사용률. 리셋 시간이 표시됩니다.</li>
86
+ <li><strong>Gemini</strong> — 남은 비율 기반 사용률. RPM/RPD 제한 포함.</li>
87
+ </ul>
88
+ <h3>호출 이력 타임라인</h3>
89
+ <p>최근 CLI 호출의 성공/실패/타임아웃을 시간순으로 표시합니다. <span style="color:#3fb950">녹색</span>=성공, <span style="color:#f85149">빨강</span>=실패, <span style="color:#d29922">노랑</span>=타임아웃.</p>
90
+ <h3>토큰 누적 현황</h3>
91
+ <p>CLI별 누적 토큰 사용량과 호출 횟수입니다. 세션 시작 이후의 합계입니다.</p>
92
+ <h3>조작</h3>
93
+ <ul>
94
+ <li><strong>Reload</strong> — <code>POST /broker/reload</code> 호출. accounts.json을 핫리로드합니다.</li>
95
+ <li><strong>Broker</strong> — 계정 브로커 대시보드(별도 페이지)를 엽니다.</li>
96
+ <li>데이터는 <strong>5초</strong>마다 자동 갱신됩니다.</li>
97
+ </ul>
98
+ </div>
99
+
63
100
  <div class="grid">
64
101
  <!-- AIMD 게이지 -->
65
102
  <div class="card">
@@ -347,6 +384,30 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
347
384
  rTimer = setTimeout(function() { drawEvents(cachedEvents); }, 150);
348
385
  });
349
386
 
387
+ /* ── 가이드 토글 ── */
388
+ document.getElementById('guideBtn').onclick = function() {
389
+ var p = document.getElementById('guidePanel');
390
+ p.classList.toggle('open');
391
+ this.classList.toggle('ok');
392
+ };
393
+
394
+ /* ── 브로커 리로드 ── */
395
+ document.getElementById('reloadBtn').onclick = function() {
396
+ var btn = this;
397
+ btn.textContent = '...';
398
+ fetch('/broker/reload', { method: 'POST' })
399
+ .then(function(r) { return r.json(); })
400
+ .then(function(d) {
401
+ btn.textContent = d.ok ? 'OK (' + (d.accounts || 0) + ')' : 'Fail';
402
+ btn.classList.toggle('ok', d.ok);
403
+ setTimeout(function() { btn.textContent = 'Reload'; btn.classList.remove('ok'); }, 3000);
404
+ })
405
+ .catch(function() {
406
+ btn.textContent = 'Error';
407
+ setTimeout(function() { btn.textContent = 'Reload'; }, 3000);
408
+ });
409
+ };
410
+
350
411
  poll();
351
412
  setInterval(poll, POLL);
352
413
  })();
package/hub/server.mjs CHANGED
@@ -719,6 +719,16 @@ export async function startHub({
719
719
  return;
720
720
  }
721
721
 
722
+ if (path === "/broker/quota-refresh" && (req.method === "POST" || req.method === "GET")) {
723
+ try {
724
+ const results = await refreshAllAccountQuotas();
725
+ return writeJson(res, 200, { ok: true, results, ts: Date.now() });
726
+ } catch (err) {
727
+ hubLog.error({ err: String(err?.message || err) }, "broker.quota_refresh_error");
728
+ return writeJson(res, 200, { ok: false, error: String(err?.message || err) });
729
+ }
730
+ }
731
+
722
732
  if (path === "/broker/reload" && req.method === "POST") {
723
733
  const result = reloadBroker();
724
734
  if (!result.ok) {
@@ -1667,17 +1677,70 @@ function cleanupStaleSpawnSessions(log) {
1667
1677
  return killed;
1668
1678
  }
1669
1679
 
1680
+ // ── Quota check for all accounts ───────────────────────────────
1681
+
1682
+ const QUOTA_CACHE_PATH = join(CACHE_DIR, "broker-quota-cache.json");
1683
+
1684
+ async function checkSingleAccountQuota(acct) {
1685
+ const authPath = join(PID_DIR, acct.authFile);
1686
+ if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
1687
+ try {
1688
+ const auth = JSON.parse(readFileSync(authPath, "utf8"));
1689
+ if (acct.provider === "codex") {
1690
+ const token = auth.tokens?.access_token || auth.OPENAI_API_KEY || "";
1691
+ if (!token) return { id: acct.id, status: "no_token" };
1692
+ const r = await fetch("https://api.openai.com/v1/chat/completions", {
1693
+ method: "POST", signal: AbortSignal.timeout(8000),
1694
+ headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
1695
+ body: JSON.stringify({ model: "gpt-4.1-nano", messages: [{ role: "user", content: "hi" }], max_tokens: 1 }),
1696
+ });
1697
+ const hdrs = Object.fromEntries([...r.headers.entries()].filter(([k]) => /ratelimit/i.test(k)));
1698
+ if (r.status === 429) return { id: acct.id, status: "quota_hit", http: 429, headers: hdrs };
1699
+ if (r.status === 401) {
1700
+ // 401이어도 ratelimit 헤더가 있으면 쿼터 정보 추출 가능
1701
+ return { id: acct.id, status: "auth_error", http: 401, headers: hdrs };
1702
+ }
1703
+ return { id: acct.id, status: r.ok ? "ok" : "error", http: r.status, headers: hdrs };
1704
+ }
1705
+ // gemini — OAuth token refresh needed for accurate check
1706
+ return { id: acct.id, status: "oauth_check_needed" };
1707
+ } catch (e) {
1708
+ return { id: acct.id, status: "error", message: e.message?.substring(0, 60) };
1709
+ }
1710
+ }
1711
+
1712
+ async function refreshAllAccountQuotas() {
1713
+ const snap = brokerInstance?.snapshot() || [];
1714
+ const checks = snap.filter(a => a.authFile).map(a => checkSingleAccountQuota(a));
1715
+ const results = await Promise.all(checks);
1716
+ // 캐시 저장
1717
+ try {
1718
+ writeFileSync(QUOTA_CACHE_PATH, JSON.stringify({ ts: Date.now(), results }));
1719
+ } catch { /* best-effort */ }
1720
+ return results;
1721
+ }
1722
+
1723
+ function loadQuotaCache() {
1724
+ try {
1725
+ if (!existsSync(QUOTA_CACHE_PATH)) return null;
1726
+ return JSON.parse(readFileSync(QUOTA_CACHE_PATH, "utf8"));
1727
+ } catch { return null; }
1728
+ }
1729
+
1670
1730
  // ── Broker Dashboard HTML ──────────────────────────────────────
1671
1731
 
1672
1732
  function renderBrokerDashboard() {
1673
1733
  return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
1674
1734
  <title>Account Broker Dashboard</title>
1675
- <meta http-equiv="refresh" content="30">
1676
1735
  <style>
1677
1736
  *{margin:0;padding:0;box-sizing:border-box}
1678
1737
  body{background:#0a0a0b;color:#e8e8ec;font:14px/1.6 'SF Mono',Consolas,monospace;padding:24px}
1679
- h1{font-size:18px;color:#a8b1ff;margin-bottom:16px}
1680
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px}
1738
+ h1{font-size:18px;color:#a8b1ff;margin-bottom:8px}
1739
+ .toolbar{margin-bottom:16px;display:flex;gap:8px;align-items:center}
1740
+ .toolbar button{background:#1a1a2e;color:#a8b1ff;border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:4px 12px;cursor:pointer;font:12px inherit}
1741
+ .toolbar button:hover{background:#252547}
1742
+ .toolbar .status-text{font-size:11px;color:#555}
1743
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:12px}
1681
1744
  .card{background:#141416;border:1px solid rgba(255,255,255,0.06);border-radius:8px;padding:16px}
1682
1745
  .card.available{border-left:3px solid #34d399}
1683
1746
  .card.busy{border-left:3px solid #fbbf24}
@@ -1690,16 +1753,75 @@ h1{font-size:18px;color:#a8b1ff;margin-bottom:16px}
1690
1753
  .status.busy{background:#451a03;color:#fbbf24}
1691
1754
  .status.cooldown{background:#450a0a;color:#f87171}
1692
1755
  .status.circuit-open{background:#450a0a;color:#ef4444}
1693
- .meta{margin-top:8px;font-size:11px;color:#666;line-height:1.8}
1756
+ .gauge{margin-top:8px}
1757
+ .gauge-row{display:flex;align-items:center;gap:8px;margin:3px 0}
1758
+ .gauge-label{font-size:10px;color:#888;width:28px;text-align:right}
1759
+ .gauge-bar{flex:1;height:8px;background:#1a1a1e;border-radius:4px;overflow:hidden}
1760
+ .gauge-fill{height:100%;border-radius:4px;transition:width .3s}
1761
+ .gauge-fill.green{background:linear-gradient(90deg,#34d399,#059669)}
1762
+ .gauge-fill.yellow{background:linear-gradient(90deg,#fbbf24,#d97706)}
1763
+ .gauge-fill.red{background:linear-gradient(90deg,#f87171,#dc2626)}
1764
+ .gauge-pct{font-size:10px;color:#999;width:32px}
1765
+ .gauge-reset{font-size:9px;color:#555}
1766
+ .meta{margin-top:6px;font-size:11px;color:#666;line-height:1.8}
1694
1767
  .meta b{color:#999}
1695
1768
  .timer{color:#f87171;font-weight:600}
1696
- .refresh{color:#555;font-size:11px;margin-top:16px}
1769
+ .refresh-bar{color:#555;font-size:11px;margin-top:16px}
1697
1770
  </style></head><body>
1698
- <h1>🔑 Account Broker Dashboard</h1>
1771
+ <h1>Account Broker Dashboard</h1>
1772
+ <div class="toolbar">
1773
+ <button onclick="checkQuotas()">Check All Quotas</button>
1774
+ <button onclick="reloadBroker()">Reload Broker</button>
1775
+ <span id="toolbar-status" class="status-text"></span>
1776
+ </div>
1699
1777
  <div id="grid" class="grid"></div>
1700
- <p class="refresh">auto-refresh: 10s | <a href="/broker/snapshot" style="color:#666">JSON API</a></p>
1778
+ <p class="refresh-bar">auto-refresh: 10s | <a href="/broker/snapshot" style="color:#666">JSON</a> | <a href="/broker/quota-refresh" style="color:#666">Quota API</a></p>
1701
1779
  <script>
1780
+ let quotaData={};
1702
1781
  function fmt(ms){if(ms<=0)return'-';const s=Math.floor(ms/1000),m=Math.floor(s/60),h=Math.floor(m/60),d=Math.floor(h/24);if(d>0)return d+'d '+h%24+'h';if(h>0)return h+'h '+m%60+'m';if(m>0)return m+'m '+s%60+'s';return s+'s'}
1782
+
1783
+ function gaugeColor(pct){return pct<60?'green':pct<85?'yellow':'red'}
1784
+
1785
+ function renderGauge(label,pct,resetTs){
1786
+ if(pct==null)return '';
1787
+ const now=Date.now()/1000;
1788
+ const resetIn=resetTs>now?fmt((resetTs-now)*1000):'';
1789
+ return '<div class="gauge-row"><span class="gauge-label">'+label+'</span>'+
1790
+ '<div class="gauge-bar"><div class="gauge-fill '+gaugeColor(pct)+'" style="width:'+pct+'%"></div></div>'+
1791
+ '<span class="gauge-pct">'+pct+'%</span>'+
1792
+ (resetIn?'<span class="gauge-reset">reset '+resetIn+'</span>':'')+
1793
+ '</div>';
1794
+ }
1795
+
1796
+ function renderQuotaGauges(acctId){
1797
+ const q=quotaData[acctId];
1798
+ if(!q)return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#444">quota: click Check All Quotas</span></div></div>';
1799
+ if(q.status==='quota_hit')return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#f87171">QUOTA EXHAUSTED</span></div></div>';
1800
+ if(q.status==='auth_error'||q.status==='no_token'||q.status==='oauth_check_needed'){
1801
+ // 401이어도 ratelimit 헤더가 있으면 표시
1802
+ const h=q.headers||{};
1803
+ const limReq=h['x-ratelimit-limit-requests'],remReq=h['x-ratelimit-remaining-requests'];
1804
+ const limTok=h['x-ratelimit-limit-tokens'],remTok=h['x-ratelimit-remaining-tokens'];
1805
+ if(limReq){
1806
+ const pctReq=Math.round((1-remReq/limReq)*100);
1807
+ const pctTok=limTok?Math.round((1-remTok/limTok)*100):null;
1808
+ return '<div class="gauge">'+renderGauge('req',pctReq,0)+(pctTok!=null?renderGauge('tok',pctTok,0):'')+'</div>';
1809
+ }
1810
+ return '<div class="gauge"><div class="gauge-row"><span style="font-size:10px;color:#fbbf24">'+(q.status==='oauth_check_needed'?'OAuth refresh needed':'auth: token refresh needed')+'</span></div></div>';
1811
+ }
1812
+ if(q.status==='ok'||q.status==='error'){
1813
+ const h=q.headers||{};
1814
+ const limReq=h['x-ratelimit-limit-requests'],remReq=h['x-ratelimit-remaining-requests'];
1815
+ const limTok=h['x-ratelimit-limit-tokens'],remTok=h['x-ratelimit-remaining-tokens'];
1816
+ const resetReq=h['x-ratelimit-reset-requests'],resetTok=h['x-ratelimit-reset-tokens'];
1817
+ if(!limReq)return '';
1818
+ const pctReq=Math.round((1-remReq/limReq)*100);
1819
+ const pctTok=limTok?Math.round((1-remTok/limTok)*100):null;
1820
+ return '<div class="gauge">'+renderGauge('req',pctReq,0)+(pctTok!=null?renderGauge('tok',pctTok,0):'')+'</div>';
1821
+ }
1822
+ return '';
1823
+ }
1824
+
1703
1825
  async function refresh(){
1704
1826
  try{
1705
1827
  const r=await fetch('/broker/snapshot');
@@ -1719,6 +1841,7 @@ async function refresh(){
1719
1841
  '<div class="id">'+a.id+'</div>'+
1720
1842
  '<span class="status '+st+'">'+sl+'</span>'+
1721
1843
  (cd>0?'<span class="timer" style="margin-left:8px">'+fmt(cd)+' remaining</span>':'')+
1844
+ renderQuotaGauges(a.id)+
1722
1845
  '<div class="meta">'+
1723
1846
  '<b>Sessions:</b> '+a.totalSessions+
1724
1847
  (a.lastUsedAt?' | <b>Last:</b> '+new Date(a.lastUsedAt).toLocaleTimeString():'')+
@@ -1728,6 +1851,29 @@ async function refresh(){
1728
1851
  }).join('');
1729
1852
  }catch(e){console.error('refresh failed',e)}
1730
1853
  }
1854
+
1855
+ async function checkQuotas(){
1856
+ const el=document.getElementById('toolbar-status');
1857
+ el.textContent='checking quotas...';
1858
+ try{
1859
+ const r=await fetch('/broker/quota-refresh',{method:'POST'});
1860
+ const d=await r.json();
1861
+ if(d.ok){
1862
+ d.results.forEach(q=>{quotaData[q.id]=q});
1863
+ el.textContent='updated '+d.results.length+' accounts ('+new Date().toLocaleTimeString()+')';
1864
+ refresh();
1865
+ }else{el.textContent='error';}
1866
+ }catch(e){el.textContent='failed: '+e.message;}
1867
+ }
1868
+
1869
+ async function reloadBroker(){
1870
+ await fetch('/broker/reload',{method:'POST'});
1871
+ document.getElementById('toolbar-status').textContent='broker reloaded';
1872
+ refresh();
1873
+ }
1874
+
1875
+ // 캐시에서 초기 로드
1876
+ fetch('/broker/quota-refresh').catch(()=>{});
1731
1877
  refresh();setInterval(refresh,10000);
1732
1878
  </script></body></html>`;
1733
1879
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.9",
3
+ "version": "10.9.11",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,6 +51,7 @@ if (!createWorker) {
51
51
  function parseArgs(argv) {
52
52
  const args = {
53
53
  allowedMcpServerNames: [],
54
+ extraArgs: [],
54
55
  mcpConfig: [],
55
56
  };
56
57
 
@@ -94,6 +95,10 @@ function parseArgs(argv) {
94
95
  args.allowedMcpServerNames.push(next);
95
96
  index += 1;
96
97
  break;
98
+ case "--extra-arg":
99
+ args.extraArgs.push(next);
100
+ index += 1;
101
+ break;
97
102
  case "--mcp-config":
98
103
  args.mcpConfig.push(next);
99
104
  index += 1;
@@ -202,6 +207,7 @@ const worker = createWorker(args.type, {
202
207
  permissionMode: args.permissionMode,
203
208
  allowDangerouslySkipPermissions: args.allowDangerouslySkipPermissions,
204
209
  allowedMcpServerNames: args.allowedMcpServerNames,
210
+ extraArgs: args.extraArgs,
205
211
  mcpConfig:
206
212
  args.type === "claude" && args.mcpConfig.length === 0
207
213
  ? resolveDefaultMcpConfig(args.cwd || process.cwd())
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # tfx-route.sh v2.4 — CLI 라우팅 래퍼 (triflux)
2
+ # tfx-route.sh v2.7 — CLI 라우팅 래퍼 (triflux)
3
3
  #
4
4
  # v1.x: cli-route.sh (jq+python3+node 혼재, 동기 후처리 ~1s)
5
5
  # v2.0: tfx-route.sh 리네임
@@ -9,7 +9,7 @@
9
9
  # - Gemini health check 지수 백오프 (30×1s → 5×exp)
10
10
  # - 컨텍스트 파일 5번째 인자 지원
11
11
  #
12
- VERSION="2.5"
12
+ VERSION="2.7"
13
13
  #
14
14
  # 사용법:
15
15
  # tfx-route.sh <agent_type> <prompt> [mcp_profile] [timeout_sec] [context_file]
@@ -220,10 +220,13 @@ CODEX_BIN="${CODEX_BIN:-$(command -v codex 2>/dev/null || echo codex)}"
220
220
  GEMINI_BIN="${GEMINI_BIN:-$(command -v gemini 2>/dev/null || echo gemini)}"
221
221
  CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude 2>/dev/null || echo claude)}"
222
222
  GEMINI_BIN_ARGS_JSON="${GEMINI_BIN_ARGS_JSON:-[]}"
223
+ # ── Gemini 확장 플래그 (issue #64) ──
224
+ TFX_GEMINI_EXTENSIONS="${TFX_GEMINI_EXTENSIONS:-}"
225
+ TFX_GEMINI_FLAGS="${TFX_GEMINI_FLAGS:-}"
223
226
  CLAUDE_BIN_ARGS_JSON="${CLAUDE_BIN_ARGS_JSON:-[]}"
224
227
 
225
228
  # ── Gemini 프로필 경로 (Codex config.toml 대칭) ──
226
- GEMINI_PROFILES_PATH="${GEMINI_PROFILES_PATH:-$(eval echo ~)/.gemini/triflux-profiles.json}"
229
+ GEMINI_PROFILES_PATH="${GEMINI_PROFILES_PATH:-${HOME}/.gemini/triflux-profiles.json}"
227
230
 
228
231
  # ── 상수 ──
229
232
  MAX_STDOUT_BYTES=51200 # 50KB — Claude 컨텍스트 절약
@@ -250,7 +253,6 @@ unset _tfx_breadcrumb
250
253
 
251
254
  # fallback 시 원래 에이전트 정보 보존
252
255
  ORIGINAL_AGENT=""
253
- ORIGINAL_CLI_ARGS=""
254
256
 
255
257
  # JSON 문자열 이스케이프:
256
258
  # - "\", """ 필수 이스케이프
@@ -340,33 +342,32 @@ normalize_script_path() {
340
342
  printf '%s\n' "$path"
341
343
  }
342
344
 
343
- # ── Hub Bridge 통신 ──
344
- resolve_bridge_script() {
345
- if [[ -n "${TFX_BRIDGE_SCRIPT:-}" && -f "$TFX_BRIDGE_SCRIPT" ]]; then
346
- printf '%s\n' "$TFX_BRIDGE_SCRIPT"
347
- return 0
345
+ # ── 스크립트 경로 해석 공통 인프라 ──
346
+ _tfx_script_dir=""
347
+ _get_script_dir() {
348
+ if [[ -z "$_tfx_script_dir" ]]; then
349
+ local ref; ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
350
+ _tfx_script_dir="$(cd "$(dirname "$ref")" && pwd -P)"
348
351
  fi
352
+ printf '%s\n' "$_tfx_script_dir"
353
+ }
349
354
 
350
- local script_dir
351
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
352
- local candidates=()
353
- [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/bridge.mjs")
354
- candidates+=(
355
- "$script_dir/../hub/bridge.mjs"
356
- "$script_dir/hub/bridge.mjs"
357
- )
358
-
359
- local candidate
360
- for candidate in "${candidates[@]}"; do
361
- if [[ -f "$candidate" ]]; then
362
- printf '%s\n' "$candidate"
363
- return 0
364
- fi
365
- done
366
-
355
+ # _resolve_script ENV_VAR_VALUE CANDIDATE... → 첫 번째 존재하는 파일 경로 반환
356
+ _resolve_script() {
357
+ local env_val="${1:-}"; shift
358
+ [[ -n "$env_val" && -f "$env_val" ]] && { printf '%s\n' "$env_val"; return 0; }
359
+ local c; for c in "$@"; do [[ -f "$c" ]] && { printf '%s\n' "$c"; return 0; }; done
367
360
  return 1
368
361
  }
369
362
 
363
+ # ── 팀 Hub Bridge 통신 ──
364
+ resolve_bridge_script() {
365
+ local sd; sd="$(_get_script_dir)"
366
+ _resolve_script "${TFX_BRIDGE_SCRIPT:-}" \
367
+ ${TFX_PKG_ROOT:+"$TFX_PKG_ROOT/hub/bridge.mjs"} \
368
+ "$sd/../hub/bridge.mjs" "$sd/hub/bridge.mjs"
369
+ }
370
+
370
371
  bridge_cli() {
371
372
  if ! command -v "$NODE_BIN" &>/dev/null; then
372
373
  return 127
@@ -401,14 +402,6 @@ bridge_json_stringify() {
401
402
  shift || true
402
403
 
403
404
  case "$mode" in
404
- metadata-patch)
405
- "$NODE_BIN" -e '
406
- process.stdout.write(JSON.stringify({
407
- result: process.argv[1] || "",
408
- summary: process.argv[2] || "",
409
- }));
410
- ' -- "${1:-}" "${2:-}"
411
- ;;
412
405
  task-result)
413
406
  "$NODE_BIN" -e '
414
407
  process.stdout.write(JSON.stringify({
@@ -584,10 +577,13 @@ team_complete_task() {
584
577
  # Claude 재로그인 시 Agent 래퍼가 죽어도 이 파일로 결과 수집 가능
585
578
  local result_dir="${TFX_RESULT_DIR:-${HOME}/.claude/tfx-results/${TFX_TEAM_NAME}}"
586
579
  if mkdir -p "$result_dir" 2>/dev/null; then
587
- cat > "${result_dir}/${TFX_TEAM_TASK_ID}.json" 2>/dev/null <<RESULT_EOF
588
- {"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)"}
589
- RESULT_EOF
590
- [[ $? -eq 0 ]] && echo "[tfx-route] 결과 백업: ${result_dir}/${TFX_TEAM_TASK_ID}.json" >&2
580
+ # 전체를 Node.js로 안전하게 stringify — 변수 직접 삽입 인젝션 방지
581
+ "$NODE_BIN" -e '
582
+ const [,taskId,agent,team,result,summary,ts] = process.argv;
583
+ process.stdout.write(JSON.stringify({taskId,agent,team,result,summary,timestamp:ts}));
584
+ ' -- "$TFX_TEAM_TASK_ID" "$TFX_TEAM_AGENT_NAME" "$TFX_TEAM_NAME" "$result" "$summary_trimmed" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
585
+ > "${result_dir}/${TFX_TEAM_TASK_ID}.json" 2>/dev/null \
586
+ && echo "[tfx-route] 결과 백업: ${result_dir}/${TFX_TEAM_TASK_ID}.json" >&2
591
587
  fi
592
588
  }
593
589
 
@@ -633,8 +629,9 @@ auto_reroute() {
633
629
  local quota_marker="$TFX_TMP/tfx-quota-${failed_cli}-$(date +%Y%m%d)"
634
630
  echo "$(date +%s)" >> "$quota_marker"
635
631
  ORIGINAL_AGENT="$AGENT_TYPE"
636
- ORIGINAL_CLI_ARGS="$CLI_ARGS"
637
632
  export TFX_REROUTED_FROM="$CLI_TYPE"
633
+ # EXIT trap 정리 — exec는 현재 프로세스를 교체하므로 trap이 실행되지 않음
634
+ cleanup_workers
638
635
  TFX_CLI_MODE="$target_cli" exec bash "${BASH_SOURCE[0]}" \
639
636
  "$AGENT_TYPE" "$PROMPT" "$MCP_PROFILE" "$USER_TIMEOUT" "$CONTEXT_FILE"
640
637
  }
@@ -816,7 +813,7 @@ route_agent() {
816
813
  # ── 에이전트별 상세 설정 ──
817
814
  case "$agent" in
818
815
  # ─── 구현 레인 ───
819
- executor)
816
+ executor|codex)
820
817
  CLI_ARGS="exec --profile codex53_high ${codex_base}"
821
818
  CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
822
819
  build-fixer)
@@ -825,48 +822,33 @@ route_agent() {
825
822
  debugger)
826
823
  CLI_ARGS="exec --profile codex53_high ${codex_base}"
827
824
  CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
828
- deep-executor)
829
- CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
830
- CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
831
825
 
832
826
  # ─── 설계/분석 레인 (5.4: 1M 컨텍스트, 에이전틱) ───
833
- architect)
834
- CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
835
- CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
836
- planner)
837
- CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
838
- CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
839
- critic)
827
+ deep-executor|architect|critic)
840
828
  CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
841
829
  CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
842
- analyst)
830
+ planner|analyst)
843
831
  CLI_ARGS="exec --profile gpt54_xhigh ${codex_base}"
844
832
  CLI_EFFORT="gpt54_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="fg"; OPUS_OVERSIGHT="true" ;;
845
833
 
846
834
  # ─── 리뷰 레인 (5.3-codex: SWE-Bench 72%) ───
847
- code-reviewer)
835
+ code-reviewer|quality-reviewer)
848
836
  CLI_ARGS="exec --profile codex53_high ${codex_base} review"
849
837
  CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
850
838
  security-reviewer)
851
839
  CLI_ARGS="exec --profile codex53_high ${codex_base} review"
852
840
  CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="true" ;;
853
- quality-reviewer)
854
- CLI_ARGS="exec --profile codex53_high ${codex_base} review"
855
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1800; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
856
841
 
857
842
  # ─── 리서치 레인 ───
858
- scientist)
843
+ scientist|document-specialist)
859
844
  CLI_ARGS="exec --profile codex53_high ${codex_base}"
860
845
  CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
861
846
  scientist-deep)
862
847
  CLI_ARGS="exec --profile gpt54_high ${codex_base}"
863
848
  CLI_EFFORT="gpt54_high"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
864
- document-specialist)
865
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
866
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1440; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
867
849
 
868
850
  # ─── UI/문서 레인 ───
869
- designer)
851
+ designer|gemini)
870
852
  CLI_ARGS="-m $(resolve_gemini_profile pro31) -y --prompt"
871
853
  CLI_EFFORT="pro31"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
872
854
  writer)
@@ -874,10 +856,10 @@ route_agent() {
874
856
  CLI_EFFORT="flash3"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
875
857
 
876
858
  # ─── 탐색 (Claude-native: Glob/Grep/Read 직접 접근) ───
877
- explore)
859
+ explore|claude)
878
860
  CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
879
861
 
880
- # ─── 검증/테스트 (Codex: 무료 + 파일 쓰기 가능) ───
862
+ # ─── 검증/테스트 ───
881
863
  verifier)
882
864
  CLI_ARGS="exec --profile codex53_high ${codex_base} review"
883
865
  CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
@@ -892,15 +874,6 @@ route_agent() {
892
874
  spark)
893
875
  CLI_ARGS="exec --profile spark53_low ${codex_base}"
894
876
  CLI_EFFORT="spark53_low"; DEFAULT_TIMEOUT=180; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
895
- # ─── CLI 이름 alias (사용자 편의) ───
896
- codex)
897
- CLI_ARGS="exec --profile codex53_high ${codex_base}"
898
- CLI_EFFORT="codex53_high"; DEFAULT_TIMEOUT=1080; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
899
- gemini)
900
- CLI_ARGS="-m $(resolve_gemini_profile pro31) -y --prompt"
901
- CLI_EFFORT="pro31"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
902
- claude)
903
- CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=600; RUN_MODE="fg"; OPUS_OVERSIGHT="false" ;;
904
877
  # ─── agent-map.json에만 정의된 신규 에이전트 (CLI_TYPE별 기본값) ───
905
878
  *)
906
879
  case "$CLI_TYPE" in
@@ -924,21 +897,29 @@ TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-auto}"
924
897
  # Preflight 캐시 일괄 로드 — CLI/Hub 가용성 + Codex 요금제를 환경변수로 내보냄
925
898
  # 하위 프로세스(스킬 포함)가 TFX_CODEX_OK, TFX_GEMINI_OK, TFX_HUB_OK로 즉시 참조 가능
926
899
  if [[ -z "${TFX_PREFLIGHT_LOADED:-}" ]]; then
927
- eval "$(node -e '
928
- try {
929
- const c = JSON.parse(require("fs").readFileSync(require("path").join(require("os").homedir(),".claude","cache","tfx-preflight.json"),"utf8"));
930
- const lines = [];
931
- lines.push("export TFX_CODEX_OK=" + (c?.codex?.ok ? "1" : "0"));
932
- lines.push("export TFX_GEMINI_OK=" + (c?.gemini?.ok ? "1" : "0"));
933
- lines.push("export TFX_HUB_OK=" + (c?.hub?.ok ? "1" : "0"));
934
- const p = c?.codex_plan?.plan;
935
- if (p && p !== "unknown" && p !== "api") lines.push("export TFX_CODEX_PLAN=" + p);
936
- const agents = c?.available_agents;
937
- if (Array.isArray(agents)) lines.push("export TFX_AVAILABLE_AGENTS=" + agents.join(","));
938
- lines.push("export TFX_PREFLIGHT_LOADED=1");
939
- process.stdout.write(lines.join("\n") + "\n");
940
- } catch { process.stdout.write("export TFX_PREFLIGHT_LOADED=1\n"); }
941
- ' 2>/dev/null)"
900
+ # eval 제거 — pipe-delimited read로 인젝션 위험 차단
901
+ IFS='|' read -r _pf_codex _pf_gemini _pf_hub _pf_plan _pf_agents < <(
902
+ "$NODE_BIN" -e '
903
+ try {
904
+ const c = JSON.parse(require("fs").readFileSync(require("path").join(require("os").homedir(),".claude","cache","tfx-preflight.json"),"utf8"));
905
+ const parts = [
906
+ c?.codex?.ok ? "1" : "0",
907
+ c?.gemini?.ok ? "1" : "0",
908
+ c?.hub?.ok ? "1" : "0",
909
+ (c?.codex_plan?.plan && c.codex_plan.plan !== "unknown" && c.codex_plan.plan !== "api") ? c.codex_plan.plan : "",
910
+ Array.isArray(c?.available_agents) ? c.available_agents.join(",") : ""
911
+ ];
912
+ process.stdout.write(parts.join("|"));
913
+ } catch { process.stdout.write("0|0|0||"); }
914
+ ' 2>/dev/null
915
+ ) || true
916
+ export TFX_CODEX_OK="${_pf_codex:-0}"
917
+ export TFX_GEMINI_OK="${_pf_gemini:-0}"
918
+ export TFX_HUB_OK="${_pf_hub:-0}"
919
+ [[ -n "${_pf_plan:-}" ]] && export TFX_CODEX_PLAN="$_pf_plan"
920
+ [[ -n "${_pf_agents:-}" ]] && export TFX_AVAILABLE_AGENTS="$_pf_agents"
921
+ export TFX_PREFLIGHT_LOADED=1
922
+ unset _pf_codex _pf_gemini _pf_hub _pf_plan _pf_agents
942
923
  TFX_CODEX_PLAN="${TFX_CODEX_PLAN:-pro}"
943
924
  fi
944
925
  TFX_WORKER_INDEX="${TFX_WORKER_INDEX:-}"
@@ -1027,8 +1008,6 @@ apply_cli_mode() {
1027
1008
  CLI_ARGS="-m $(resolve_gemini_profile pro31) -y --prompt"; CLI_EFFORT="pro31" ;;
1028
1009
  build-fixer|spark)
1029
1010
  CLI_ARGS="-m $(resolve_gemini_profile flash3) -y --prompt"; CLI_EFFORT="flash3"; DEFAULT_TIMEOUT=180 ;;
1030
- writer)
1031
- CLI_ARGS="-m $(resolve_gemini_profile flash3) -y --prompt"; CLI_EFFORT="flash3" ;;
1032
1011
  *)
1033
1012
  CLI_ARGS="-m $(resolve_gemini_profile flash3) -y --prompt"; CLI_EFFORT="flash3" ;;
1034
1013
  esac
@@ -1044,16 +1023,14 @@ apply_cli_mode() {
1044
1023
  if command -v "$GEMINI_BIN" &>/dev/null; then
1045
1024
  TFX_CLI_MODE="gemini"; apply_cli_mode; return
1046
1025
  else
1047
- ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
1048
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
1026
+ ORIGINAL_AGENT="${AGENT_TYPE}" CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
1049
1027
  echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
1050
1028
  fi
1051
1029
  elif [[ "$CLI_TYPE" == "gemini" ]] && ! command -v "$GEMINI_BIN" &>/dev/null; then
1052
1030
  if command -v "$CODEX_BIN" &>/dev/null; then
1053
1031
  TFX_CLI_MODE="codex"; apply_cli_mode; return
1054
1032
  else
1055
- ORIGINAL_AGENT="${AGENT_TYPE}"; ORIGINAL_CLI_ARGS="$CLI_ARGS"
1056
- CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
1033
+ ORIGINAL_AGENT="${AGENT_TYPE}" CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
1057
1034
  echo "[tfx-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
1058
1035
  fi
1059
1036
  fi ;;
@@ -1067,7 +1044,7 @@ apply_plan_guard() {
1067
1044
 
1068
1045
  if [[ "$CLI_EFFORT" == spark53_* ]]; then
1069
1046
  local codex_base
1070
- codex_base="$(build_codex_base)"
1047
+ codex_base="$(build_codex_base)"
1071
1048
  CLI_ARGS="exec --profile codex53_high ${codex_base}"
1072
1049
  CLI_EFFORT="codex53_high"
1073
1050
  echo "[tfx-route] TFX_CODEX_PLAN=$TFX_CODEX_PLAN: spark → codex53_high로 다운그레이드 (Pro 전용)" >&2
@@ -1168,34 +1145,12 @@ get_cached_servers() {
1168
1145
  }
1169
1146
 
1170
1147
  resolve_mcp_filter_script() {
1171
- if [[ -n "$MCP_FILTER_SCRIPT" && -f "$MCP_FILTER_SCRIPT" ]]; then
1172
- printf '%s\n' "$MCP_FILTER_SCRIPT"
1173
- return 0
1174
- fi
1175
-
1176
- local script_ref script_dir candidate
1177
- local -a candidates=()
1178
-
1179
- script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1180
- if [[ -n "$script_ref" ]]; then
1181
- script_dir="$(cd "$(dirname "$script_ref")" 2>/dev/null && pwd -P || true)"
1182
- [[ -n "$script_dir" ]] && candidates+=("$script_dir/lib/mcp-filter.mjs")
1183
- fi
1184
-
1185
- candidates+=(
1186
- "$PWD/scripts/lib/mcp-filter.mjs"
1187
- "$PWD/lib/mcp-filter.mjs"
1188
- )
1189
-
1190
- for candidate in "${candidates[@]}"; do
1191
- if [[ -f "$candidate" ]]; then
1192
- MCP_FILTER_SCRIPT="$candidate"
1193
- printf '%s\n' "$MCP_FILTER_SCRIPT"
1194
- return 0
1195
- fi
1196
- done
1197
-
1198
- return 1
1148
+ [[ -n "$MCP_FILTER_SCRIPT" && -f "$MCP_FILTER_SCRIPT" ]] && { printf '%s\n' "$MCP_FILTER_SCRIPT"; return 0; }
1149
+ local sd; sd="$(_get_script_dir)"
1150
+ MCP_FILTER_SCRIPT=$(_resolve_script "" \
1151
+ ${sd:+"$sd/lib/mcp-filter.mjs"} \
1152
+ "$PWD/scripts/lib/mcp-filter.mjs" "$PWD/lib/mcp-filter.mjs") || return 1
1153
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
1199
1154
  }
1200
1155
 
1201
1156
  resolve_mcp_policy() {
@@ -1216,9 +1171,8 @@ resolve_mcp_policy() {
1216
1171
  available_servers=""
1217
1172
  fi
1218
1173
  # Codex 0.115+: 미등록 서버에 config override(enabled=true/false 모두)를 보내면
1219
- # "invalid transport" 에러 발생. 캐시 비어있으면 빈 문자열로 유지하여
1220
- # mcp-filter가 override를 생성하지 않도록 한다.
1221
- [[ -z "$available_servers" ]] && available_servers=""
1174
+ # "invalid transport" 에러 발생. 캐시 비어있으면 빈 문자열이 유지되어
1175
+ # mcp-filter가 override를 생성하지 않는다.
1222
1176
 
1223
1177
  local -a cmd=(
1224
1178
  "$NODE_BIN" "$filter_script" shell
@@ -1358,18 +1312,19 @@ heartbeat_monitor() {
1358
1312
  echo "[tfx-heartbeat] pid=$pid terminated" >&2
1359
1313
  }
1360
1314
 
1361
- resolve_worker_runner_script() {
1362
- if [[ -n "${TFX_ROUTE_WORKER_RUNNER:-}" && -f "$TFX_ROUTE_WORKER_RUNNER" ]]; then
1363
- printf '%s\n' "$TFX_ROUTE_WORKER_RUNNER"
1364
- return 0
1365
- fi
1315
+ # _wait_with_heartbeat PID — track + heartbeat + wait + cleanup
1316
+ _wait_with_heartbeat() {
1317
+ local wpid="$1" hb_pid ec=0
1318
+ track_worker_pid "$wpid"
1319
+ heartbeat_monitor "$wpid" &
1320
+ hb_pid=$!
1321
+ wait "$wpid" || ec=$?
1322
+ kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1323
+ return "$ec"
1324
+ }
1366
1325
 
1367
- local script_ref script_dir
1368
- script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1369
- script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1370
- local candidate="$script_dir/tfx-route-worker.mjs"
1371
- [[ -f "$candidate" ]] || return 1
1372
- printf '%s\n' "$candidate"
1326
+ resolve_worker_runner_script() {
1327
+ _resolve_script "${TFX_ROUTE_WORKER_RUNNER:-}" "$(_get_script_dir)/tfx-route-worker.mjs"
1373
1328
  }
1374
1329
 
1375
1330
  run_stream_worker() {
@@ -1378,7 +1333,7 @@ run_stream_worker() {
1378
1333
  local use_tee_flag="$3"
1379
1334
  shift 3
1380
1335
  local exit_code_local=0
1381
- local worker_pid hb_pid
1336
+ local worker_pid
1382
1337
 
1383
1338
  local runner_script
1384
1339
  if ! runner_script=$(resolve_worker_runner_script); then
@@ -1406,48 +1361,22 @@ run_stream_worker() {
1406
1361
  printf '%s' "$prompt" | "$TIMEOUT_BIN" "$TIMEOUT_SEC" "${worker_cmd[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1407
1362
  fi
1408
1363
  worker_pid=$!
1409
- track_worker_pid "$worker_pid"
1410
-
1411
- heartbeat_monitor "$worker_pid" &
1412
- hb_pid=$!
1413
-
1414
- wait "$worker_pid" || exit_code_local=$?
1415
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1364
+ _wait_with_heartbeat "$worker_pid" || exit_code_local=$?
1416
1365
  return "$exit_code_local"
1417
1366
  }
1418
1367
 
1419
1368
  resolve_codex_mcp_script() {
1420
- if [[ -n "${TFX_CODEX_MCP_SCRIPT:-}" && -f "$TFX_CODEX_MCP_SCRIPT" ]]; then
1421
- printf '%s\n' "$TFX_CODEX_MCP_SCRIPT"
1422
- return 0
1423
- fi
1424
-
1425
- local script_ref script_dir
1426
- script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
1427
- script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
1428
- local candidates=()
1429
- [[ -n "$TFX_PKG_ROOT" ]] && candidates+=("$TFX_PKG_ROOT/hub/workers/codex-mcp.mjs")
1430
- candidates+=(
1431
- "$script_dir/hub/workers/codex-mcp.mjs"
1432
- "$script_dir/../hub/workers/codex-mcp.mjs"
1433
- )
1434
-
1435
- local candidate
1436
- for candidate in "${candidates[@]}"; do
1437
- if [[ -f "$candidate" ]]; then
1438
- printf '%s\n' "$candidate"
1439
- return 0
1440
- fi
1441
- done
1442
-
1443
- return 1
1369
+ local sd; sd="$(_get_script_dir)"
1370
+ _resolve_script "${TFX_CODEX_MCP_SCRIPT:-}" \
1371
+ ${TFX_PKG_ROOT:+"$TFX_PKG_ROOT/hub/workers/codex-mcp.mjs"} \
1372
+ "$sd/hub/workers/codex-mcp.mjs" "$sd/../hub/workers/codex-mcp.mjs"
1444
1373
  }
1445
1374
 
1446
1375
  run_codex_exec() {
1447
1376
  local prompt="$1"
1448
1377
  local use_tee_flag="$2"
1449
1378
  local exit_code_local=0
1450
- local worker_pid hb_pid
1379
+ local worker_pid
1451
1380
  local -a codex_args=()
1452
1381
  read -r -a codex_args <<< "$CLI_ARGS"
1453
1382
  if [[ ${#CODEX_CONFIG_FLAGS[@]} -gt 0 ]]; then
@@ -1460,13 +1389,7 @@ run_codex_exec() {
1460
1389
  "$TIMEOUT_BIN" "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" < /dev/null >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1461
1390
  fi
1462
1391
  worker_pid=$!
1463
- track_worker_pid "$worker_pid"
1464
-
1465
- heartbeat_monitor "$worker_pid" &
1466
- hb_pid=$!
1467
-
1468
- wait "$worker_pid" || exit_code_local=$?
1469
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1392
+ _wait_with_heartbeat "$worker_pid" || exit_code_local=$?
1470
1393
 
1471
1394
  if [[ ! -s "$STDOUT_LOG" && -s "$STDERR_LOG" ]]; then
1472
1395
  # stderr에서 마지막 "codex" 마커 이후의 텍스트를 stdout으로 복구
@@ -1498,17 +1421,16 @@ run_codex_exec() {
1498
1421
  run_codex_mcp() {
1499
1422
  local prompt="$1"
1500
1423
  local use_tee_flag="$2"
1501
- local mcp_script node_bin
1424
+ local mcp_script
1502
1425
  local exit_code_local=0
1503
- local worker_pid hb_pid
1426
+ local worker_pid
1504
1427
 
1505
1428
  if ! mcp_script=$(resolve_codex_mcp_script); then
1506
1429
  echo "[tfx-route] 경고: Codex MCP 래퍼를 찾지 못했습니다." >&2
1507
1430
  return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1508
1431
  fi
1509
1432
 
1510
- node_bin="${NODE_BIN:-$(command -v node 2>/dev/null || echo node)}"
1511
- if ! command -v "$node_bin" &>/dev/null; then
1433
+ if ! command -v "$NODE_BIN" &>/dev/null; then
1512
1434
  echo "[tfx-route] 경고: node를 찾지 못해 Codex MCP 경로를 사용할 수 없습니다." >&2
1513
1435
  return "$CODEX_MCP_TRANSPORT_EXIT_CODE"
1514
1436
  fi
@@ -1550,18 +1472,12 @@ run_codex_mcp() {
1550
1472
  esac
1551
1473
 
1552
1474
  if [[ "$use_tee_flag" == "true" ]]; then
1553
- "$TIMEOUT_BIN" "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1475
+ "$TIMEOUT_BIN" "$TIMEOUT_SEC" "$NODE_BIN" "${mcp_args[@]}" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
1554
1476
  else
1555
- "$TIMEOUT_BIN" "$TIMEOUT_SEC" "$node_bin" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1477
+ "$TIMEOUT_BIN" "$TIMEOUT_SEC" "$NODE_BIN" "${mcp_args[@]}" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
1556
1478
  fi
1557
1479
  worker_pid=$!
1558
- track_worker_pid "$worker_pid"
1559
-
1560
- heartbeat_monitor "$worker_pid" &
1561
- hb_pid=$!
1562
-
1563
- wait "$worker_pid" || exit_code_local=$?
1564
- kill "$hb_pid" 2>/dev/null; wait "$hb_pid" 2>/dev/null
1480
+ _wait_with_heartbeat "$worker_pid" || exit_code_local=$?
1565
1481
 
1566
1482
  # 모듈 로드 실패(의존성 누락) → MCP transport exit code로 변환하여 fallback 트리거
1567
1483
  if [[ "$exit_code_local" -ne 0 && "$exit_code_local" -ne 124 ]] && grep -q 'ERR_MODULE_NOT_FOUND' "$STDERR_LOG" 2>/dev/null; then
@@ -1760,6 +1676,27 @@ FALLBACK_EOF
1760
1676
  done
1761
1677
  fi
1762
1678
 
1679
+ # ── Gemini extensions (-e) 주입 (issue #64) ──
1680
+ if [[ -n "$TFX_GEMINI_EXTENSIONS" ]]; then
1681
+ local ext
1682
+ IFS="," read -ra _gemini_exts <<< "$TFX_GEMINI_EXTENSIONS"
1683
+ for ext in "${_gemini_exts[@]}"; do
1684
+ ext=$(echo "$ext" | xargs) # trim whitespace
1685
+ [[ -n "$ext" ]] && gemini_worker_args+=("--extra-arg" "-e" "--extra-arg" "$ext")
1686
+ done
1687
+ echo "[tfx-route] Gemini extensions: ${TFX_GEMINI_EXTENSIONS}" >&2
1688
+ fi
1689
+
1690
+ # ── Gemini 추가 플래그 주입 (issue #64) ──
1691
+ if [[ -n "$TFX_GEMINI_FLAGS" ]]; then
1692
+ local flag
1693
+ read -ra _gemini_flags <<< "$TFX_GEMINI_FLAGS"
1694
+ for flag in "${_gemini_flags[@]}"; do
1695
+ [[ -n "$flag" ]] && gemini_worker_args+=("--extra-arg" "$flag")
1696
+ done
1697
+ echo "[tfx-route] Gemini extra flags: ${TFX_GEMINI_FLAGS}" >&2
1698
+ fi
1699
+
1763
1700
  run_stream_worker "gemini" "$FULL_PROMPT" "$use_tee" "${gemini_worker_args[@]}" || exit_code=$?
1764
1701
  if [[ "$exit_code" -ne 0 && "$exit_code" -ne 124 ]]; then
1765
1702
  echo "[tfx-route] Gemini stream wrapper 실패(exit=${exit_code}). claude-native fallback." >&2
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // wt-cli.mjs — wt-manager CLI wrapper for Claude Code
3
+ // safety-guard가 wt.exe 직접 호출을 차단하므로 이 스크립트를 경유한다.
4
+ import { createWtManager } from "../hub/team/wt-manager.mjs";
5
+
6
+ const [action, ...rest] = process.argv.slice(2);
7
+ if (!action) {
8
+ console.error(
9
+ "Usage: node scripts/wt-cli.mjs <action> [json-opts]\n" +
10
+ "Actions: create-tab, split-pane, layout, list, close, close-stale, rename",
11
+ );
12
+ process.exit(1);
13
+ }
14
+
15
+ const opts = rest.length ? JSON.parse(rest.join(" ")) : {};
16
+ const wt = createWtManager();
17
+
18
+ switch (action) {
19
+ case "create-tab": {
20
+ const r = await wt.createTab(opts);
21
+ console.log(JSON.stringify(r));
22
+ break;
23
+ }
24
+ case "split-pane": {
25
+ await wt.splitPane(opts);
26
+ console.log(JSON.stringify({ success: true }));
27
+ break;
28
+ }
29
+ case "layout": {
30
+ const panes = Array.isArray(opts) ? opts : opts.panes;
31
+ const r = await wt.applySplitLayout(panes);
32
+ console.log(JSON.stringify(r || { success: true }));
33
+ break;
34
+ }
35
+ case "list": {
36
+ console.log(JSON.stringify(wt.listTabs()));
37
+ break;
38
+ }
39
+ case "close": {
40
+ await wt.closeTab(opts.title);
41
+ console.log(JSON.stringify({ success: true }));
42
+ break;
43
+ }
44
+ case "close-stale": {
45
+ const closed = await wt.closeStale(opts);
46
+ console.log(JSON.stringify({ success: true, closed }));
47
+ break;
48
+ }
49
+ case "rename": {
50
+ const r = wt.renameTab(opts);
51
+ console.log(JSON.stringify(r));
52
+ break;
53
+ }
54
+ default:
55
+ console.error(`Unknown action: ${action}`);
56
+ process.exit(1);
57
+ }
@@ -46,13 +46,7 @@ psmux --version 2>/dev/null && \
46
46
  IF claude -p (one-shot 모드):
47
47
  → Tier 3 즉시 fallback
48
48
 
49
- IF psmux 없음:
50
- → Tier 3
51
-
52
- IF Hub 미응답:
53
- → hub-ensure 자동 재시작 시도: Bash("node ~/.claude/scripts/hub-ensure.mjs")
54
- → 재시작 성공(exit 0) → Tier 판정 재시도
55
- → 재시작 실패 → Tier 3
49
+ IF psmux 없음 OR Hub 미응답:
56
50
  → Tier 3
57
51
 
58
52
  IF Codex 없음 AND Gemini 없음:
@@ -53,7 +53,6 @@ IF Hub 미응답:
53
53
  → hub-ensure 자동 재시작 시도: Bash("node ~/.claude/scripts/hub-ensure.mjs")
54
54
  → 재시작 성공(exit 0) → Tier 판정 재시도
55
55
  → 재시작 실패 → Tier 3
56
- → Tier 3
57
56
 
58
57
  IF Codex 없음 AND Gemini 없음:
59
58
  → Tier 3