triflux 2.4.7 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -187,7 +187,8 @@ tfx doctor
187
187
  - **Codex CLI** (선택): `npm install -g @openai/codex`
188
188
  - **Gemini CLI** (선택): `npm install -g @google/gemini-cli`
189
189
 
190
- > Codex/Gemini 없이도 동작합니다. 자동으로 Claude 네이티브 에이전트로 fallback됩니다.
190
+ > [!TIP]
191
+ > **triflux는 100% 독립적으로 동작합니다.** 기능을 수행하기 위해 [oh-my-claudecode (OMC)](https://github.com/nicepkg/oh-my-claudecode)가 필요하지 않습니다. OMC가 설치된 경우에만 이를 감지하여 선택적인 통합 기능을 제공합니다. Codex/Gemini 없이도 자동으로 Claude 네이티브 에이전트로 fallback되어 동작합니다.
191
192
 
192
193
  ### 설치 후
193
194
 
@@ -224,14 +225,15 @@ Claude Code 상태줄에서 실시간 모니터링:
224
225
  </details>
225
226
 
226
227
  <details>
227
- <summary><strong>oh-my-claudecode 통합</strong></summary>
228
+ <summary><strong>선택 사항: oh-my-claudecode (OMC) 통합</strong></summary>
228
229
 
229
- triflux는 단독으로 동작하거나 [oh-my-claudecode](https://github.com/nicepkg/oh-my-claudecode) 함께 사용할 있습니다:
230
+ triflux는 **100% 독립적으로 동작**하며 기능을 수행하기 위해 외부 도구에 의존하지 않습니다. 다만, [oh-my-claudecode](https://github.com/nicepkg/oh-my-claudecode) 생태계를 사용하는 사용자를 위해 선택적 호환성을 제공합니다:
230
231
 
231
- - OMC 플러그인 시스템을 통한 스킬 자동 등록
232
- - `cli-route.sh`가 OMC 에이전트 카탈로그와 통합
233
- - HUD가 OMC 상태줄을 확장
234
- - OMC autopilot, ralph, team, ultrawork 모드와 호환
232
+ - **완전한 독립성**: OMC 다른 래퍼 없이 단독으로 100% 동작합니다.
233
+ - **캐시 호환성**: OMC 캐시 경로(예: `~/.omc/state/`)가 존재할 경우 이를 감지하고 배려하지만, 기본적으로 자체 격리된 상태를 유지합니다.
234
+ - **심리스한 플러그인**: OMC설치된 경우 OMC 플러그인 시스템을 통해 스킬이 자동 등록됩니다.
235
+ - **확장된 HUD**: OMC 감지되면 HUD가 자동으로 OMC 상태줄을 확장하여 표시합니다.
236
+ - **모드 지원**: OMC autopilot, ralph, team, ultrawork 모드와 완벽히 호환됩니다.
235
237
 
236
238
  </details>
237
239
 
package/README.md CHANGED
@@ -187,7 +187,8 @@ User: "/tfx-auto refactor auth + improve UI + add tests"
187
187
  - **Codex CLI** (optional): `npm install -g @openai/codex`
188
188
  - **Gemini CLI** (optional): `npm install -g @google/gemini-cli`
189
189
 
190
- > Without Codex or Gemini, triflux automatically falls back to Claude native agents.
190
+ > [!TIP]
191
+ > **triflux is 100% standalone.** It does not require [oh-my-claudecode (OMC)](https://github.com/nicepkg/oh-my-claudecode) to function. It will automatically detect and provide optional integration only if OMC is present. Without Codex or Gemini, triflux falls back to Claude native agents.
191
192
 
192
193
  ### Post-install
193
194
 
@@ -224,14 +225,15 @@ Configured automatically via `tfx setup`.
224
225
  </details>
225
226
 
226
227
  <details>
227
- <summary><strong>oh-my-claudecode Integration</strong></summary>
228
+ <summary><strong>Optional: oh-my-claudecode (OMC) Integration</strong></summary>
228
229
 
229
- triflux works standalone or as part of [oh-my-claudecode](https://github.com/nicepkg/oh-my-claudecode):
230
+ triflux is **100% independent** and does not require any external tools to function. However, it provides optional compatibility with [oh-my-claudecode](https://github.com/nicepkg/oh-my-claudecode) for users who prefer that ecosystem:
230
231
 
231
- - Skills auto-registered via OMC's plugin system
232
- - `cli-route.sh` integrates with OMC's agent catalog
233
- - HUD extends OMC's status line
234
- - Compatible with OMC autopilot, ralph, team, and ultrawork modes
232
+ - **Full Independence**: Works standalone without OMC or any other wrappers.
233
+ - **Cache Compatibility**: Detects and respects OMC cache paths (e.g., `~/.omc/state/`) if present, but maintains its own isolated state by default.
234
+ - **Seamless Plugin**: Skills auto-registered via OMC's plugin system if OMC is installed.
235
+ - **Extended HUD**: HUD automatically extends OMC's status line when detected.
236
+ - **Mode Support**: Compatible with OMC autopilot, ralph, team, and ultrawork modes.
235
237
 
236
238
  </details>
237
239
 
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // tfx-doctor — triflux doctor 바로가기
3
3
  import { dirname } from "path";
4
4
  import { fileURLToPath } from "url";
package/bin/tfx-setup.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // tfx-setup — triflux setup 바로가기
3
3
  import { dirname } from "path";
4
4
  import { fileURLToPath } from "url";
package/bin/triflux.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // triflux CLI — setup, doctor, version
3
3
  import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync } from "fs";
4
4
  import { join, dirname } from "path";
@@ -121,6 +121,9 @@ function getClaudeUsageStaleMs() {
121
121
  const CLAUDE_USAGE_429_BACKOFF_MS = 10 * 60 * 1000; // 429 에러 시 10분 backoff
122
122
  const CLAUDE_USAGE_ERROR_BACKOFF_MS = 3 * 60 * 1000; // 기타 에러 시 3분 backoff
123
123
  const CLAUDE_API_TIMEOUT_MS = 10_000;
124
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
125
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
126
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
124
127
  const DEFAULT_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
125
128
  const CODEX_AUTH_PATH = join(homedir(), ".codex", "auth.json");
126
129
  const CODEX_QUOTA_CACHE_PATH = join(homedir(), ".claude", "cache", "codex-rate-limits-cache.json");
@@ -152,6 +155,14 @@ function getGeminiModelLabel(model) {
152
155
  if (model.includes("flash")) return "[Flash3]";
153
156
  return "";
154
157
  }
158
+ // rows 임계값 상수 (selectTier 에서 tier 결정에 사용)
159
+ const ROWS_BUDGET_FULL = 40;
160
+ const ROWS_BUDGET_LARGE = 35;
161
+ const ROWS_BUDGET_MEDIUM = 28;
162
+ const ROWS_BUDGET_SMALL = 22;
163
+ // Codex rate_limits에서 최소 수집할 버킷 수
164
+ const CODEX_MIN_BUCKETS = 2;
165
+
155
166
  const GEMINI_RPM_WINDOW_MS = 60 * 1000; // 60초 슬라이딩 윈도우
156
167
  const GEMINI_QUOTA_STALE_MS = 5 * 60 * 1000; // 5분
157
168
  const GEMINI_SESSION_STALE_MS = 15 * 1000; // 15초
@@ -291,10 +302,10 @@ function selectTier(stdin, claudeUsage = null) {
291
302
  const cols = getTerminalColumns() || 120;
292
303
 
293
304
  let budget;
294
- if (rows >= 40) budget = 6;
295
- else if (rows >= 35) budget = 5;
296
- else if (rows >= 28) budget = 4;
297
- else if (rows >= 22) budget = 3;
305
+ if (rows >= ROWS_BUDGET_FULL) budget = 6;
306
+ else if (rows >= ROWS_BUDGET_LARGE) budget = 5;
307
+ else if (rows >= ROWS_BUDGET_MEDIUM) budget = 4;
308
+ else if (rows >= ROWS_BUDGET_SMALL) budget = 3;
298
309
  else if (rows > 0) budget = 2;
299
310
  else budget = 5; // rows 감지 불가 → 넉넉하게
300
311
 
@@ -437,25 +448,21 @@ function renderAlignedRows(rows) {
437
448
  function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
438
449
  const ctx = getContextPercent(stdin);
439
450
 
440
- // Claude 5h/1w (reset 과거면 0으로 표시)
441
- const cF = claudeUsage
442
- ? (isResetPast(claudeUsage.fiveHourResetsAt) ? 0 : clampPercent(claudeUsage.fiveHourPercent ?? 0))
443
- : null;
444
- const cW = claudeUsage
445
- ? (isResetPast(claudeUsage.weeklyResetsAt) ? 0 : clampPercent(claudeUsage.weeklyPercent ?? 0))
446
- : null;
447
- const cVal = cF != null
448
- ? `${colorByProvider(cF, `${cF}`, claudeOrange)}${dim("/")}${colorByProvider(cW, `${cW}`, claudeOrange)}`
451
+ // Claude 5h/1w (캐시된 그대로 표시, 시간은 advanceToNextCycle이 처리)
452
+ const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
453
+ const cW = claudeUsage?.weeklyPercent != null ? clampPercent(claudeUsage.weeklyPercent) : null;
454
+ const cVal = claudeUsage != null
455
+ ? `${cF != null ? colorByProvider(cF, `${cF}`, claudeOrange) : dim("--")}${dim("/")}${cW != null ? colorByProvider(cW, `${cW}`, claudeOrange) : dim("--")}`
449
456
  : dim("--/--");
450
457
 
451
- // Codex 5h/1w
458
+ // Codex 5h/1w (캐시된 값 그대로 표시)
452
459
  let xVal = dim("--/--");
453
460
  if (codexBuckets) {
454
461
  const mb = codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
455
462
  if (mb) {
456
- const xF = isResetPast(mb.primary?.resets_at) ? 0 : clampPercent(mb.primary?.used_percent ?? 0);
457
- const xW = isResetPast(mb.secondary?.resets_at) ? 0 : clampPercent(mb.secondary?.used_percent ?? 0);
458
- xVal = `${colorByProvider(xF, `${xF}`, codexWhite)}${dim("/")}${colorByProvider(xW, `${xW}`, codexWhite)}`;
463
+ const xF = mb.primary?.used_percent != null ? clampPercent(mb.primary.used_percent) : null;
464
+ const xW = mb.secondary?.used_percent != null ? clampPercent(mb.secondary.used_percent) : null;
465
+ xVal = `${xF != null ? colorByProvider(xF, `${xF}`, codexWhite) : dim("--")}${dim("/")}${xW != null ? colorByProvider(xW, `${xW}`, codexWhite) : dim("--")}`;
459
466
  }
460
467
  }
461
468
 
@@ -667,31 +674,15 @@ function parseClaudeUsageResponse(response) {
667
674
  // stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
668
675
  function stripStaleResets(data) {
669
676
  if (!data) return data;
670
- const now = Date.now();
671
677
  const copy = { ...data };
672
-
673
- // 5시간 주기: 과거 reset → 5시간씩 전진하여 미래 시점 추정
674
678
  if (copy.fiveHourResetsAt) {
675
- let t = new Date(copy.fiveHourResetsAt).getTime();
676
- if (t < now) {
677
- const cycle = 5 * 60 * 60 * 1000;
678
- const elapsed = now - t;
679
- t += Math.ceil(elapsed / cycle) * cycle;
680
- copy.fiveHourResetsAt = new Date(t).toISOString();
681
- }
679
+ const t = new Date(copy.fiveHourResetsAt).getTime();
680
+ if (!isNaN(t)) copy.fiveHourResetsAt = new Date(advanceToNextCycle(t, FIVE_HOUR_MS)).toISOString();
682
681
  }
683
-
684
- // 7일 주기: 과거 reset → 7일씩 전진하여 미래 시점 추정
685
682
  if (copy.weeklyResetsAt) {
686
- let t = new Date(copy.weeklyResetsAt).getTime();
687
- if (t < now) {
688
- const cycle = 7 * 24 * 60 * 60 * 1000;
689
- const elapsed = now - t;
690
- t += Math.ceil(elapsed / cycle) * cycle;
691
- copy.weeklyResetsAt = new Date(t).toISOString();
692
- }
683
+ const t = new Date(copy.weeklyResetsAt).getTime();
684
+ if (!isNaN(t)) copy.weeklyResetsAt = new Date(advanceToNextCycle(t, SEVEN_DAY_MS)).toISOString();
693
685
  }
694
-
695
686
  return copy;
696
687
  }
697
688
 
@@ -817,12 +808,15 @@ function scheduleClaudeUsageRefresh() {
817
808
 
818
809
  try {
819
810
  const child = spawn(process.execPath, [scriptPath, CLAUDE_REFRESH_FLAG], {
820
- detached: process.platform !== "win32",
811
+ detached: true,
821
812
  stdio: "ignore",
822
813
  windowsHide: true,
823
814
  });
824
815
  child.unref();
825
- } catch { /* 백그라운드 실행 실패 무시 */ }
816
+ } catch (spawnErr) {
817
+ // spawn 실패 시 에러 유형을 캐시에 기록 (HUD에서 원인 힌트 표시 가능)
818
+ writeClaudeUsageCache(null, { type: "network", status: 0, hint: String(spawnErr?.message || spawnErr) });
819
+ }
826
820
  }
827
821
 
828
822
  function getContextPercent(stdin) {
@@ -837,11 +831,20 @@ function getContextPercent(stdin) {
837
831
  return clampPercent((totalTokens / capacity) * 100);
838
832
  }
839
833
 
840
- function formatResetRemaining(isoOrUnix) {
834
+ // 과거 리셋 시간 → 다음 주기로 순환하여 미래 시점 반환
835
+ function advanceToNextCycle(epochMs, cycleMs) {
836
+ const now = Date.now();
837
+ if (epochMs >= now || !cycleMs) return epochMs;
838
+ const elapsed = now - epochMs;
839
+ return epochMs + Math.ceil(elapsed / cycleMs) * cycleMs;
840
+ }
841
+
842
+ function formatResetRemaining(isoOrUnix, cycleMs = 0) {
841
843
  if (!isoOrUnix) return "";
842
844
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
843
845
  if (isNaN(d.getTime())) return "";
844
- const diffMs = d.getTime() - Date.now();
846
+ const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
847
+ const diffMs = targetMs - Date.now();
845
848
  if (diffMs <= 0) return "";
846
849
  const totalMinutes = Math.floor(diffMs / 60000);
847
850
  const totalHours = Math.floor(totalMinutes / 60);
@@ -855,11 +858,12 @@ function isResetPast(isoOrUnix) {
855
858
  return !isNaN(d.getTime()) && d.getTime() <= Date.now();
856
859
  }
857
860
 
858
- function formatResetRemainingDayHour(isoOrUnix) {
861
+ function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
859
862
  if (!isoOrUnix) return "";
860
863
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
861
864
  if (isNaN(d.getTime())) return "";
862
- const diffMs = d.getTime() - Date.now();
865
+ const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
866
+ const diffMs = targetMs - Date.now();
863
867
  if (diffMs <= 0) return "";
864
868
  const totalMinutes = Math.floor(diffMs / 60000);
865
869
  const days = Math.floor(totalMinutes / (60 * 24));
@@ -906,20 +910,33 @@ function httpsPost(url, body, accessToken) {
906
910
  });
907
911
  }
908
912
 
913
+ // ============================================================================
914
+ // JWT base64 디코딩 공통 헬퍼
915
+ // ============================================================================
916
+ /**
917
+ * JWT 파일에서 이메일을 추출하는 공통 헬퍼.
918
+ * @param {string|null} idToken - JWT 문자열
919
+ * @returns {string|null} 이메일 또는 null
920
+ */
921
+ function decodeJwtEmail(idToken) {
922
+ if (!idToken) return null;
923
+ const parts = idToken.split(".");
924
+ if (parts.length < 2) return null;
925
+ let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
926
+ while (payload.length % 4) payload += "=";
927
+ try {
928
+ const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
929
+ return decoded.email || null;
930
+ } catch { return null; }
931
+ }
932
+
909
933
  // ============================================================================
910
934
  // Codex JWT에서 이메일 추출
911
935
  // ============================================================================
912
936
  function getCodexEmail() {
913
937
  try {
914
938
  const auth = JSON.parse(readFileSync(CODEX_AUTH_PATH, "utf-8"));
915
- const idToken = auth?.tokens?.id_token;
916
- if (!idToken) return null;
917
- const parts = idToken.split(".");
918
- if (parts.length < 2) return null;
919
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
920
- while (payload.length % 4) payload += "=";
921
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
922
- return decoded.email || null;
939
+ return decodeJwtEmail(auth?.tokens?.id_token);
923
940
  } catch { return null; }
924
941
  }
925
942
 
@@ -929,19 +946,15 @@ function getCodexEmail() {
929
946
  function getGeminiEmail() {
930
947
  try {
931
948
  const oauth = readJson(GEMINI_OAUTH_PATH, null);
932
- const idToken = oauth?.id_token;
933
- if (!idToken) return null;
934
- const parts = idToken.split(".");
935
- if (parts.length < 2) return null;
936
- let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
937
- while (payload.length % 4) payload += "=";
938
- const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
939
- return decoded.email || null;
949
+ return decodeJwtEmail(oauth?.id_token);
940
950
  } catch { return null; }
941
951
  }
942
952
 
943
953
  // ============================================================================
944
954
  // Codex 세션 JSONL에서 실제 rate limits 추출
955
+ // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
956
+ // 오늘 날짜의 모든 세션 파일을 스캔해 가장 최신 rate_limits 버킷을 수집한다.
957
+ // (단일 파일 즉시 return 방식에서 → 당일 전체 스캔 후 최신 데이터 우선 병합으로 변경)
945
958
  // ============================================================================
946
959
  function getCodexRateLimits() {
947
960
  const now = new Date();
@@ -959,17 +972,21 @@ function getCodexRateLimits() {
959
972
  try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
960
973
  catch { continue; }
961
974
  if (dayOffset === 0 && files.length > 0) todayHasFiles = true;
975
+
976
+ // 당일 모든 세션 파일을 스캔해 limit_id별 가장 최신 버킷을 병합
977
+ // (파일 목록은 이름 역순 정렬 → 최신 세션 우선)
978
+ const mergedBuckets = {};
962
979
  for (const file of files) {
963
980
  try {
964
981
  const content = readFileSync(join(sessDir, file), "utf-8");
965
982
  const lines = content.trim().split("\n").reverse();
966
- const buckets = {};
967
983
  for (const line of lines) {
968
984
  try {
969
985
  const evt = JSON.parse(line);
970
986
  const rl = evt?.payload?.rate_limits;
971
- if (rl?.limit_id && !buckets[rl.limit_id]) {
972
- buckets[rl.limit_id] = {
987
+ if (rl?.limit_id && !mergedBuckets[rl.limit_id]) {
988
+ // limit_id별로 발견(=해당 세션의 가장 최신 이벤트)만 기록
989
+ mergedBuckets[rl.limit_id] = {
973
990
  limitId: rl.limit_id, limitName: rl.limit_name,
974
991
  primary: rl.primary, secondary: rl.secondary,
975
992
  credits: rl.credits,
@@ -979,11 +996,12 @@ function getCodexRateLimits() {
979
996
  };
980
997
  }
981
998
  } catch { /* 라인 파싱 실패 무시 */ }
982
- if (Object.keys(buckets).length >= 2) break;
999
+ if (Object.keys(mergedBuckets).length >= CODEX_MIN_BUCKETS) break;
983
1000
  }
984
- if (Object.keys(buckets).length > 0) return buckets;
985
1001
  } catch { /* 파일 읽기 실패 무시 */ }
986
1002
  }
1003
+ if (Object.keys(mergedBuckets).length > 0) return mergedBuckets;
1004
+
987
1005
  // 오늘 세션 파일이 존재하지만 rate_limits가 없으면 어제 stale 데이터로 폴백하지 않음
988
1006
  if (todayHasFiles && dayOffset === 0) return null;
989
1007
  }
@@ -1007,8 +1025,28 @@ async function fetchGeminiQuota(accountId, options = {}) {
1007
1025
  return cache;
1008
1026
  }
1009
1027
 
1010
- if (!oauth?.access_token) return cache;
1011
- if (oauth.expiry_date && oauth.expiry_date < Date.now()) return cache; // 만료 시 stale 캐시
1028
+ if (!oauth?.access_token) {
1029
+ // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
1030
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1031
+ ...(cache || {}),
1032
+ timestamp: cache?.timestamp || Date.now(),
1033
+ error: true,
1034
+ errorType: "auth",
1035
+ errorHint: "no access_token in oauth_creds.json",
1036
+ });
1037
+ return cache;
1038
+ }
1039
+ if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
1040
+ // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
1041
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1042
+ ...(cache || {}),
1043
+ timestamp: cache?.timestamp || Date.now(),
1044
+ error: true,
1045
+ errorType: "auth",
1046
+ errorHint: `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
1047
+ });
1048
+ return cache;
1049
+ }
1012
1050
 
1013
1051
  // 3. projectId (캐시 or API)
1014
1052
  const fetchProjectId = async () => {
@@ -1045,7 +1083,18 @@ async function fetchGeminiQuota(accountId, options = {}) {
1045
1083
  );
1046
1084
  }
1047
1085
 
1048
- if (!quotaRes?.buckets) return cache;
1086
+ if (!quotaRes?.buckets) {
1087
+ // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
1088
+ const apiError = quotaRes?.error?.message || quotaRes?.error?.code || quotaRes?.error || "no buckets in response";
1089
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1090
+ ...(cache || {}),
1091
+ timestamp: cache?.timestamp || Date.now(),
1092
+ error: true,
1093
+ errorType: "api",
1094
+ errorHint: String(apiError),
1095
+ });
1096
+ return cache;
1097
+ }
1049
1098
 
1050
1099
  // 5. 캐시 저장
1051
1100
  const result = {
@@ -1120,13 +1169,20 @@ function scheduleGeminiQuotaRefresh(accountId) {
1120
1169
  process.execPath,
1121
1170
  [scriptPath, GEMINI_REFRESH_FLAG, "--account", accountId || "gemini-main"],
1122
1171
  {
1123
- detached: process.platform !== "win32",
1172
+ detached: true,
1124
1173
  stdio: "ignore",
1125
1174
  windowsHide: true,
1126
1175
  },
1127
1176
  );
1128
1177
  child.unref();
1129
- } catch { /* 백그라운드 실행 실패 무시 */ }
1178
+ } catch (spawnErr) {
1179
+ // spawn 실패 시 캐시에 에러 힌트 기록 (다음 HUD 렌더에서 원인 확인 가능)
1180
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
1181
+ timestamp: Date.now(),
1182
+ error: true,
1183
+ errorHint: String(spawnErr?.message || spawnErr),
1184
+ });
1185
+ }
1130
1186
  }
1131
1187
 
1132
1188
  function readCodexRateLimitSnapshot() {
@@ -1152,12 +1208,20 @@ function scheduleCodexRateLimitRefresh() {
1152
1208
  if (!scriptPath) return;
1153
1209
  try {
1154
1210
  const child = spawn(process.execPath, [scriptPath, CODEX_REFRESH_FLAG], {
1155
- detached: process.platform !== "win32",
1211
+ detached: true,
1156
1212
  stdio: "ignore",
1157
1213
  windowsHide: true,
1158
1214
  });
1159
1215
  child.unref();
1160
- } catch { /* 백그라운드 실행 실패 무시 */ }
1216
+ } catch (spawnErr) {
1217
+ // spawn 실패 시 캐시에 에러 힌트 기록
1218
+ writeJsonSafe(CODEX_QUOTA_CACHE_PATH, {
1219
+ timestamp: Date.now(),
1220
+ buckets: null,
1221
+ error: true,
1222
+ errorHint: String(spawnErr?.message || spawnErr),
1223
+ });
1224
+ }
1161
1225
  }
1162
1226
 
1163
1227
  function readGeminiSessionSnapshot() {
@@ -1183,7 +1247,7 @@ function scheduleGeminiSessionRefresh() {
1183
1247
  if (!scriptPath) return;
1184
1248
  try {
1185
1249
  const child = spawn(process.execPath, [scriptPath, GEMINI_SESSION_REFRESH_FLAG], {
1186
- detached: process.platform !== "win32",
1250
+ detached: true,
1187
1251
  stdio: "ignore",
1188
1252
  windowsHide: true,
1189
1253
  });
@@ -1265,13 +1329,14 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1265
1329
  const svSuffix = `${dim("sv:")}${svStr}`;
1266
1330
 
1267
1331
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1268
- const fiveHourPercent = isResetPast(claudeUsage?.fiveHourResetsAt) ? 0 : (claudeUsage?.fiveHourPercent ?? 0);
1269
- const weeklyPercent = isResetPast(claudeUsage?.weeklyResetsAt) ? 0 : (claudeUsage?.weeklyPercent ?? 0);
1332
+ // 캐시된 percent 그대로 사용 (시간 표시는 advanceToNextCycle이 처리)
1333
+ const fiveHourPercent = claudeUsage?.fiveHourPercent ?? null;
1334
+ const weeklyPercent = claudeUsage?.weeklyPercent ?? null;
1270
1335
  const fiveHourReset = claudeUsage?.fiveHourResetsAt
1271
- ? formatResetRemaining(claudeUsage.fiveHourResetsAt)
1336
+ ? formatResetRemaining(claudeUsage.fiveHourResetsAt, FIVE_HOUR_MS)
1272
1337
  : "n/a";
1273
1338
  const weeklyReset = claudeUsage?.weeklyResetsAt
1274
- ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt)
1339
+ ? formatResetRemainingDayHour(claudeUsage.weeklyResetsAt, SEVEN_DAY_MS)
1275
1340
  : "n/a";
1276
1341
 
1277
1342
  const hasData = claudeUsage != null;
@@ -1285,14 +1350,23 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1285
1350
  return [{ prefix, left: quotaSection, right: "" }];
1286
1351
  }
1287
1352
  if (cols < 40) {
1288
- const quotaSection = `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}${dim("/")}` +
1289
- `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)} ` +
1353
+ // null이면 '--' 플레이스홀더, 아니면 실제
1354
+ const fStr = fiveHourPercent != null ? `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}` : dim("--");
1355
+ const wStr = weeklyPercent != null ? `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)}` : dim("--");
1356
+ const quotaSection = `${fStr}${dim("/")}${wStr} ` +
1290
1357
  `${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1291
1358
  return [{ prefix, left: quotaSection, right: "" }];
1292
1359
  }
1293
1360
  // nano: c: 5h 12% 1w 95% sv: 191% ctx:90%
1294
- const quotaSection = `${dim("5h")} ${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1295
- `${dim("1w")} ${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1361
+ // null이면 '--%' 플레이스홀더 표시
1362
+ const fCellNano = fiveHourPercent != null
1363
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1364
+ : dim(formatPlaceholderPercentCell());
1365
+ const wCellNano = weeklyPercent != null
1366
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1367
+ : dim(formatPlaceholderPercentCell());
1368
+ const quotaSection = `${dim("5h")} ${fCellNano} ` +
1369
+ `${dim("1w")} ${wCellNano} ` +
1296
1370
  `${dim("sv:")}${svStr} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1297
1371
  return [{ prefix, left: quotaSection, right: "" }];
1298
1372
  }
@@ -1305,8 +1379,15 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1305
1379
  return [{ prefix, left: quotaSection, right: "" }];
1306
1380
  }
1307
1381
  // compact: c: 5h: 14% 1w: 96% | sv: 191% ctx:43%
1308
- const quotaSection = `${dim("5h:")}${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1309
- `${dim("1w:")}${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1382
+ // null이면 '--%' 플레이스홀더 표시
1383
+ const fCellCmp = fiveHourPercent != null
1384
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1385
+ : dim(formatPlaceholderPercentCell());
1386
+ const wCellCmp = weeklyPercent != null
1387
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1388
+ : dim(formatPlaceholderPercentCell());
1389
+ const quotaSection = `${dim("5h:")}${fCellCmp} ` +
1390
+ `${dim("1w:")}${wCellCmp} ` +
1310
1391
  `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1311
1392
  return [{ prefix, left: quotaSection, right: "" }];
1312
1393
  }
@@ -1321,13 +1402,20 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1321
1402
  return [{ prefix, left: quotaSection, right: contextSection }];
1322
1403
  }
1323
1404
 
1324
- const fiveHourPercentCell = formatPercentCell(fiveHourPercent);
1325
- const weeklyPercentCell = formatPercentCell(weeklyPercent);
1405
+ // null이면 dim 바 + '--%' 플레이스홀더, 아니면 실제 값 표시
1406
+ const fiveHourPercentCell = fiveHourPercent != null
1407
+ ? colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)
1408
+ : dim(formatPlaceholderPercentCell());
1409
+ const weeklyPercentCell = weeklyPercent != null
1410
+ ? colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)
1411
+ : dim(formatPlaceholderPercentCell());
1412
+ const fiveHourBar = fiveHourPercent != null ? tierBar(fiveHourPercent, CLAUDE_ORANGE) : tierDimBar();
1413
+ const weeklyBar = weeklyPercent != null ? tierBar(weeklyPercent, CLAUDE_ORANGE) : tierDimBar();
1326
1414
  const fiveHourTimeCell = formatTimeCell(fiveHourReset);
1327
1415
  const weeklyTimeCell = formatTimeCellDH(weeklyReset);
1328
- const quotaSection = `${dim("5h:")}${tierBar(fiveHourPercent, CLAUDE_ORANGE)}${colorByProvider(fiveHourPercent, fiveHourPercentCell, claudeOrange)} ` +
1416
+ const quotaSection = `${dim("5h:")}${fiveHourBar}${fiveHourPercentCell} ` +
1329
1417
  `${dim(fiveHourTimeCell)} ` +
1330
- `${dim("1w:")}${tierBar(weeklyPercent, CLAUDE_ORANGE)}${colorByProvider(weeklyPercent, weeklyPercentCell, claudeOrange)} ` +
1418
+ `${dim("1w:")}${weeklyBar}${weeklyPercentCell} ` +
1331
1419
  `${dim(weeklyTimeCell)}`;
1332
1420
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1333
1421
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1368,12 +1456,15 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1368
1456
  if (realQuota?.type === "codex") {
1369
1457
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1370
1458
  if (main) {
1371
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1372
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1459
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1460
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1461
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1462
+ const fCellN = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1463
+ const wCellN = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1373
1464
  if (cols < 40) {
1374
- return { prefix: minPrefix, left: `${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)}${dim("/")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1465
+ return { prefix: minPrefix, left: `${fCellN}${dim("/")}${wCellN}${svCompact}`, right: "" };
1375
1466
  }
1376
- return { prefix: minPrefix, left: `${dim("5h")} ${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ${dim("1w")} ${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1467
+ return { prefix: minPrefix, left: `${dim("5h")} ${fCellN} ${dim("1w")} ${wCellN}${svCompact}`, right: "" };
1377
1468
  }
1378
1469
  }
1379
1470
  if (realQuota?.type === "gemini") {
@@ -1393,10 +1484,13 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1393
1484
  if (realQuota?.type === "codex") {
1394
1485
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1395
1486
  if (main) {
1396
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1397
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1398
- quotaSection = `${dim("5h:")}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1399
- `${dim("1w:")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}`;
1487
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1488
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1489
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1490
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1491
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1492
+ quotaSection = `${dim("5h:")}${fCell} ` +
1493
+ `${dim("1w:")}${wCell}`;
1400
1494
  }
1401
1495
  }
1402
1496
  if (realQuota?.type === "gemini") {
@@ -1409,7 +1503,7 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1409
1503
  }
1410
1504
  }
1411
1505
  if (!quotaSection) {
1412
- quotaSection = `${dim("5h:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))}`;
1506
+ quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ${dim("1w:")}${dim(formatPlaceholderPercentCell())}`;
1413
1507
  }
1414
1508
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1415
1509
  // compact: sv + 계정 (모델 라벨 제거)
@@ -1420,13 +1514,18 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1420
1514
  if (realQuota?.type === "codex") {
1421
1515
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1422
1516
  if (main) {
1423
- const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1424
- const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1425
- const fiveReset = formatResetRemaining(main.primary?.resets_at) || "n/a";
1426
- const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at) || "n/a";
1427
- quotaSection = `${dim("5h:")}${tierBar(fiveP, provAnsi)}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1517
+ // 캐시된 그대로 표시 (시간은 advanceToNextCycle이 처리)
1518
+ const fiveP = main.primary?.used_percent != null ? clampPercent(main.primary.used_percent) : null;
1519
+ const weekP = main.secondary?.used_percent != null ? clampPercent(main.secondary.used_percent) : null;
1520
+ const fiveReset = formatResetRemaining(main.primary?.resets_at, FIVE_HOUR_MS) || "n/a";
1521
+ const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at, SEVEN_DAY_MS) || "n/a";
1522
+ const fCell = fiveP != null ? colorByProvider(fiveP, formatPercentCell(fiveP), provFn) : dim(formatPlaceholderPercentCell());
1523
+ const wCell = weekP != null ? colorByProvider(weekP, formatPercentCell(weekP), provFn) : dim(formatPlaceholderPercentCell());
1524
+ const fBar = fiveP != null ? tierBar(fiveP, provAnsi) : tierDimBar();
1525
+ const wBar = weekP != null ? tierBar(weekP, provAnsi) : tierDimBar();
1526
+ quotaSection = `${dim("5h:")}${fBar}${fCell} ` +
1428
1527
  `${dim(formatTimeCell(fiveReset))} ` +
1429
- `${dim("1w:")}${tierBar(weekP, provAnsi)}${colorByProvider(weekP, formatPercentCell(weekP), provFn)} ` +
1528
+ `${dim("1w:")}${wBar}${wCell} ` +
1430
1529
  `${dim(formatTimeCellDH(weekReset))}`;
1431
1530
  }
1432
1531
  }
@@ -1435,7 +1534,7 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1435
1534
  const bucket = realQuota.quotaBucket;
1436
1535
  if (bucket) {
1437
1536
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1438
- const rstRemaining = formatResetRemaining(bucket.resetTime) || "n/a";
1537
+ const rstRemaining = formatResetRemaining(bucket.resetTime, ONE_DAY_MS) || "n/a";
1439
1538
  quotaSection = `${dim("1d:")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ` +
1440
1539
  `${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1441
1540
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "2.4.7",
3
+ "version": "2.5.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # cli-route.sh v1.6 — CLI 라우팅 래퍼 (ai-scaffold 템플릿)
2
+ # cli-route.sh v1.7 — CLI 라우팅 래퍼 (ai-scaffold 템플릿)
3
3
  # v1.0: 기본 라우팅 (Codex/Gemini/Claude 분기)
4
4
  # v1.1: stderr 분리, 출력 필터링, 타임아웃, MCP 프로필 지원
5
5
  # v1.2: effort 동적 라우팅, bg/fg 모드, Opus 직접 수행, Gemini 모델 분기, 실행 로그
@@ -7,7 +7,8 @@
7
7
  # v1.4: TFX_CLI_MODE 지원 (codex-only/gemini-only), CLI 미설치 자동 fallback
8
8
  # v1.5: MCP 인벤토리 캐싱 — 실제 서버 가용성 기반 동적 힌트 생성
9
9
  # v1.6: 토큰 사용량 추출 + sv-accumulator.json 누적
10
- VERSION="1.6"
10
+ # v1.7: 배치 AIMD 전략 — 성공 시 +1, 실패/타임아웃 시 ×0.5, 수렴 감지
11
+ VERSION="1.7"
11
12
  #
12
13
  # 설치: cp scripts/cli-route.sh ~/.claude/scripts/cli-route.sh
13
14
  #
@@ -35,8 +36,207 @@ GEMINI_BIN="${GEMINI_BIN:-$(command -v gemini 2>/dev/null || echo gemini)}"
35
36
  # ── 상수 ──
36
37
  MAX_STDOUT_BYTES=51200 # 50KB — Claude 컨텍스트 절약
37
38
  TIMESTAMP=$(date +%s)
38
- STDERR_LOG="/tmp/omc-route-${AGENT_TYPE}-${TIMESTAMP}-stderr.log"
39
- STDOUT_LOG="/tmp/omc-route-${AGENT_TYPE}-${TIMESTAMP}-stdout.log"
39
+ STDERR_LOG="/tmp/tfx-route-${AGENT_TYPE}-${TIMESTAMP}-stderr.log"
40
+ STDOUT_LOG="/tmp/tfx-route-${AGENT_TYPE}-${TIMESTAMP}-stdout.log"
41
+
42
+ # fallback 시 원래 에이전트/CLI 인자 보존용 (수정 3: review fallback 프로필 유실 방지)
43
+ ORIGINAL_AGENT=""
44
+ ORIGINAL_CLI_ARGS=""
45
+
46
+ # ── 크로스 세션 활성 에이전트 추적 ──
47
+ # 활성 에이전트 레지스트리 경로
48
+ ACTIVE_AGENTS_FILE="${HOME}/.claude/cache/active-agents.json"
49
+
50
+ # 죽은 PID 및 좀비 정리
51
+ cleanup_stale_agents() {
52
+ [[ ! -f "$ACTIVE_AGENTS_FILE" ]] && return
53
+ local now
54
+ now=$(date +%s)
55
+ local tmp="${ACTIVE_AGENTS_FILE}.tmp"
56
+ # jq가 있으면 사용, 없으면 건너뜀
57
+ if command -v jq &>/dev/null; then
58
+ jq --argjson now "$now" '
59
+ .agents |= map(select(
60
+ # PID 생존 확인은 셸에서 하므로 여기선 타임아웃만 체크
61
+ (.started + 1200) > $now
62
+ ))
63
+ ' "$ACTIVE_AGENTS_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$ACTIVE_AGENTS_FILE"
64
+ # 추가로 kill -0으로 죽은 PID 제거
65
+ local pids
66
+ pids=$(jq -r '.agents[].pid' "$ACTIVE_AGENTS_FILE" 2>/dev/null)
67
+ local pid
68
+ for pid in $pids; do
69
+ if ! kill -0 "$pid" 2>/dev/null; then
70
+ jq --argjson pid "$pid" '.agents |= map(select(.pid != $pid))' "$ACTIVE_AGENTS_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$ACTIVE_AGENTS_FILE"
71
+ fi
72
+ done
73
+ fi
74
+ }
75
+
76
+ # 에이전트 등록
77
+ register_agent() {
78
+ local pid="$1" cli="$2" agent="$3"
79
+ local now
80
+ now=$(date +%s)
81
+ cleanup_stale_agents
82
+ if command -v jq &>/dev/null; then
83
+ # 캐시 디렉토리가 없으면 생성
84
+ mkdir -p "$(dirname "$ACTIVE_AGENTS_FILE")"
85
+ if [[ -f "$ACTIVE_AGENTS_FILE" ]]; then
86
+ jq --argjson pid "$pid" --arg cli "$cli" --arg agent "$agent" --argjson started "$now" \
87
+ '.agents += [{"pid": $pid, "cli": $cli, "agent": $agent, "started": $started}]' \
88
+ "$ACTIVE_AGENTS_FILE" > "${ACTIVE_AGENTS_FILE}.tmp" && mv "${ACTIVE_AGENTS_FILE}.tmp" "$ACTIVE_AGENTS_FILE"
89
+ else
90
+ echo "{\"agents\":[{\"pid\":$pid,\"cli\":\"$cli\",\"agent\":\"$agent\",\"started\":$now}]}" > "$ACTIVE_AGENTS_FILE"
91
+ fi
92
+ fi
93
+ }
94
+
95
+ # 에이전트 등록 해제
96
+ deregister_agent() {
97
+ local pid="$1"
98
+ if command -v jq &>/dev/null && [[ -f "$ACTIVE_AGENTS_FILE" ]]; then
99
+ jq --argjson pid "$pid" '.agents |= map(select(.pid != $pid))' \
100
+ "$ACTIVE_AGENTS_FILE" > "${ACTIVE_AGENTS_FILE}.tmp" && mv "${ACTIVE_AGENTS_FILE}.tmp" "$ACTIVE_AGENTS_FILE"
101
+ fi
102
+ }
103
+
104
+ # ── 배치 AIMD 전략 ──
105
+ # 배치 설정 파일: ~/.claude/cache/batch-config.json
106
+ # 초기 batch_size=2, 성공→+1 (AI), 실패/타임아웃→×0.5 (MD), 상한=8, 수렴=3연속 동일
107
+ BATCH_CONFIG_FILE="${HOME}/.claude/cache/batch-config.json"
108
+
109
+ # 현재 batch_size 반환 (파일 없으면 기본값 2)
110
+ get_batch_size() {
111
+ if ! command -v jq &>/dev/null; then
112
+ echo "2"
113
+ return
114
+ fi
115
+ if [[ -f "$BATCH_CONFIG_FILE" ]]; then
116
+ local size
117
+ size=$(jq -r '.batch_size // 2' "$BATCH_CONFIG_FILE" 2>/dev/null)
118
+ # 숫자가 아니거나 비어 있으면 기본값
119
+ if [[ "$size" =~ ^[0-9]+$ ]]; then
120
+ echo "$size"
121
+ else
122
+ echo "2"
123
+ fi
124
+ else
125
+ echo "2"
126
+ fi
127
+ }
128
+
129
+ # 현재 활성 에이전트 수 반환 (active-agents.json 기반)
130
+ get_active_agent_count() {
131
+ if ! command -v jq &>/dev/null; then
132
+ echo "0"
133
+ return
134
+ fi
135
+ if [[ -f "$ACTIVE_AGENTS_FILE" ]]; then
136
+ local count
137
+ count=$(jq '.agents | length' "$ACTIVE_AGENTS_FILE" 2>/dev/null)
138
+ echo "${count:-0}"
139
+ else
140
+ echo "0"
141
+ fi
142
+ }
143
+
144
+ # AIMD 결과 기록 및 batch_size 업데이트
145
+ # 인자: result (success/failed/timeout), agent
146
+ update_batch_result() {
147
+ local result="$1"
148
+ local agent="${2:-unknown}"
149
+
150
+ if ! command -v jq &>/dev/null; then
151
+ return
152
+ fi
153
+
154
+ mkdir -p "$(dirname "$BATCH_CONFIG_FILE")"
155
+
156
+ # 현재 설정 읽기 (없으면 초기값)
157
+ local current_size consecutive_same converged
158
+ if [[ -f "$BATCH_CONFIG_FILE" ]]; then
159
+ current_size=$(jq -r '.batch_size // 2' "$BATCH_CONFIG_FILE" 2>/dev/null)
160
+ consecutive_same=$(jq -r '.consecutive_same // 0' "$BATCH_CONFIG_FILE" 2>/dev/null)
161
+ converged=$(jq -r '.converged // false' "$BATCH_CONFIG_FILE" 2>/dev/null)
162
+ else
163
+ current_size=2
164
+ consecutive_same=0
165
+ converged="false"
166
+ fi
167
+
168
+ # 숫자 검증
169
+ [[ "$current_size" =~ ^[0-9]+$ ]] || current_size=2
170
+ [[ "$consecutive_same" =~ ^[0-9]+$ ]] || consecutive_same=0
171
+
172
+ # 수렴 상태면 batch_size 고정 (업데이트만 기록)
173
+ local new_size="$current_size"
174
+ if [[ "$converged" != "true" ]]; then
175
+ case "$result" in
176
+ success)
177
+ # Additive Increase: +1, 상한 8
178
+ new_size=$((current_size + 1))
179
+ if [[ $new_size -gt 8 ]]; then new_size=8; fi
180
+ ;;
181
+ failed|timeout)
182
+ # Multiplicative Decrease: ×0.5, 하한 1
183
+ new_size=$((current_size / 2))
184
+ if [[ $new_size -lt 1 ]]; then new_size=1; fi
185
+ ;;
186
+ esac
187
+ fi
188
+
189
+ # 수렴 판단: 3연속 동일하면 converged=true
190
+ if [[ $new_size -eq $current_size ]]; then
191
+ consecutive_same=$((consecutive_same + 1))
192
+ else
193
+ consecutive_same=0
194
+ fi
195
+
196
+ local new_converged="false"
197
+ if [[ $consecutive_same -ge 3 ]]; then
198
+ new_converged="true"
199
+ fi
200
+
201
+ local now
202
+ now=$(date +%s)
203
+
204
+ # history에 추가 (최대 50건 유지) 후 batch_size 업데이트
205
+ local tmp="${BATCH_CONFIG_FILE}.tmp"
206
+ if [[ -f "$BATCH_CONFIG_FILE" ]]; then
207
+ jq --argjson now "$now" \
208
+ --arg agent "$agent" \
209
+ --arg result "$result" \
210
+ --argjson batch_at_time "$current_size" \
211
+ --argjson new_size "$new_size" \
212
+ --argjson consecutive_same "$consecutive_same" \
213
+ --argjson converged "$new_converged" \
214
+ '
215
+ .history += [{"timestamp": $now, "agent": $agent, "result": $result, "batch_at_time": $batch_at_time}] |
216
+ .history = (.history | if length > 50 then .[-50:] else . end) |
217
+ .batch_size = $new_size |
218
+ .consecutive_same = $consecutive_same |
219
+ .converged = $converged
220
+ ' "$BATCH_CONFIG_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$BATCH_CONFIG_FILE"
221
+ else
222
+ # 파일 신규 생성
223
+ jq -n \
224
+ --argjson now "$now" \
225
+ --arg agent "$agent" \
226
+ --arg result "$result" \
227
+ --argjson new_size "$new_size" \
228
+ --argjson consecutive_same "$consecutive_same" \
229
+ --argjson converged "$new_converged" \
230
+ '{
231
+ batch_size: $new_size,
232
+ history: [{"timestamp": $now, "agent": $agent, "result": $result, "batch_at_time": 2}],
233
+ consecutive_same: $consecutive_same,
234
+ converged: $converged
235
+ }' > "$BATCH_CONFIG_FILE" 2>/dev/null || true
236
+ fi
237
+
238
+ echo "[cli-route] AIMD: $result → batch_size $current_size→$new_size (consecutive_same=$consecutive_same, converged=$new_converged)" >&2
239
+ }
40
240
 
41
241
  # ── 라우팅 테이블 ──
42
242
  # 반환: CLI_CMD, CLI_ARGS, CLI_TYPE, CLI_EFFORT, DEFAULT_TIMEOUT, RUN_MODE, OPUS_OVERSIGHT
@@ -373,10 +573,13 @@ apply_cli_mode() {
373
573
  apply_cli_mode
374
574
  return
375
575
  else
576
+ # 원래 에이전트 및 MCP 프로필 정보 보존
577
+ ORIGINAL_AGENT="${AGENT_TYPE}"
578
+ ORIGINAL_CLI_ARGS="$CLI_ARGS"
376
579
  CLI_TYPE="claude-native"
377
580
  CLI_CMD=""
378
581
  CLI_ARGS=""
379
- echo "[cli-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
582
+ echo "[cli-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback (원래 프로필: $MCP_PROFILE)" >&2
380
583
  fi
381
584
  elif [[ "$CLI_TYPE" == "gemini" ]] && ! command -v "$GEMINI_BIN" &>/dev/null; then
382
585
  if command -v "$CODEX_BIN" &>/dev/null; then
@@ -384,10 +587,13 @@ apply_cli_mode() {
384
587
  apply_cli_mode
385
588
  return
386
589
  else
590
+ # 원래 에이전트 및 MCP 프로필 정보 보존
591
+ ORIGINAL_AGENT="${AGENT_TYPE}"
592
+ ORIGINAL_CLI_ARGS="$CLI_ARGS"
387
593
  CLI_TYPE="claude-native"
388
594
  CLI_CMD=""
389
595
  CLI_ARGS=""
390
- echo "[cli-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback" >&2
596
+ echo "[cli-route] codex/gemini 모두 미설치: $AGENT_TYPE → claude-native fallback (원래 프로필: $MCP_PROFILE)" >&2
391
597
  fi
392
598
  fi
393
599
  ;;
@@ -764,6 +970,9 @@ truncate_output() {
764
970
 
765
971
  # ── 메인 실행 ──
766
972
  main() {
973
+ # 종료 시 활성 에이전트 레지스트리에서 자동 제거
974
+ trap 'deregister_agent $$' EXIT
975
+
767
976
  route_agent "$AGENT_TYPE"
768
977
 
769
978
  # CLI 모드 오버라이드 적용 (tfx-codex/tfx-gemini 또는 auto-fallback)
@@ -782,12 +991,12 @@ main() {
782
991
  TIMEOUT_SEC="$DEFAULT_TIMEOUT"
783
992
  fi
784
993
 
785
- # kteam 안정화: Gemini 에이전트 기본 타임아웃 축소 (사용자 미지정 시만)
994
+ # kteam 안정화: Gemini 에이전트 기본 타임아웃 하한 적용 (사용자 미지정 시만)
786
995
  if [[ -z "$USER_TIMEOUT" ]]; then
787
996
  case "$AGENT_TYPE" in
788
997
  designer|writer)
789
- if [[ "$DEFAULT_TIMEOUT" -gt 60 ]]; then
790
- TIMEOUT_SEC=60
998
+ if [[ "$DEFAULT_TIMEOUT" -gt 300 ]]; then
999
+ TIMEOUT_SEC=300
791
1000
  fi
792
1001
  ;;
793
1002
  esac
@@ -808,6 +1017,12 @@ main() {
808
1017
  echo "RUN_MODE=$RUN_MODE"
809
1018
  echo "OPUS_OVERSIGHT=$OPUS_OVERSIGHT"
810
1019
  echo "TIMEOUT=$TIMEOUT_SEC"
1020
+ echo "MCP_PROFILE=$MCP_PROFILE"
1021
+ # fallback 시 원래 에이전트/MCP 프로필 정보를 함께 출력 (수정 3)
1022
+ if [[ -n "$ORIGINAL_AGENT" ]]; then
1023
+ echo "ORIGINAL_AGENT=$ORIGINAL_AGENT"
1024
+ echo "ORIGINAL_CLI_ARGS=$ORIGINAL_CLI_ARGS"
1025
+ fi
811
1026
  echo "PROMPT=$PROMPT"
812
1027
  echo "--- Claude Task($model) 에이전트로 위임하세요 ---"
813
1028
  exit 0
@@ -825,6 +1040,9 @@ main() {
825
1040
  echo "[cli-route] type=$CLI_TYPE agent=$AGENT_TYPE effort=$CLI_EFFORT mode=$RUN_MODE timeout=${TIMEOUT_SEC}s" >&2
826
1041
  echo "[cli-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE stderr_log=$STDERR_LOG" >&2
827
1042
 
1043
+ # 크로스 세션 활성 에이전트 레지스트리에 등록 (수정 4)
1044
+ register_agent $$ "$CLI_TYPE" "$AGENT_TYPE"
1045
+
828
1046
  # CLI 실행 (stderr 분리 + 타임아웃 + 소요시간 측정)
829
1047
  local exit_code=0
830
1048
  local raw_output=""
@@ -906,6 +1124,15 @@ main() {
906
1124
  accumulate_tokens "$CLI_TYPE" "$input_tokens" "$output_tokens" || true
907
1125
  fi
908
1126
 
1127
+ # AIMD 배치 크기 업데이트 (exit code 기반)
1128
+ if [[ $exit_code -eq 0 ]]; then
1129
+ update_batch_result "success" "$AGENT_TYPE" || true
1130
+ elif [[ $exit_code -eq 124 ]]; then
1131
+ update_batch_result "timeout" "$AGENT_TYPE" || true
1132
+ else
1133
+ update_batch_result "failed" "$AGENT_TYPE" || true
1134
+ fi
1135
+
909
1136
  # CLI 이슈 자동 수집
910
1137
  local _stderr_for_track=""
911
1138
  [[ -f "$STDERR_LOG" ]] && _stderr_for_track=$(cat "$STDERR_LOG" 2>/dev/null || echo "")
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // notion-read.mjs v1.1 — Notion 대형 페이지 리더 (Codex/Gemini/Claude MCP 위임)
2
+ // notion-read.mjs v1.2 — Notion 대형 페이지 리더 (Codex/Gemini/Claude MCP 위임)
3
3
  //
4
4
  // Codex/Gemini/Claude CLI에 설치된 Notion MCP를 활용하여 대형 페이지를 마크다운으로 추출.
5
5
  // 폴백 체인: Codex(무료) → Gemini(무료) → Claude(최후) → 에러
6
+ // 이관 모드(--delegate): Claude(notion-guest 우선) 단독 실행 + 결과 파일 저장
6
7
  //
7
8
  // 사용법:
8
9
  // node notion-read.mjs <notion-url-or-page-id> [옵션]
@@ -14,27 +15,28 @@
14
15
  // --cli, -c <codex|gemini> CLI 강제 지정 (기본: 자동 + 폴백)
15
16
  // --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
16
17
  // --guest notion-guest 통합 사용 (기본: notion)
18
+ // --delegate Claude 이관 모드 (notion-guest 우선, 파일 저장)
17
19
 
18
- import { execSync } from "child_process";
19
- import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from "fs";
20
- import { join, dirname } from "path";
21
- import { homedir, tmpdir } from "os";
20
+ import { execSync } from 'child_process';
21
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
22
+ import { join, dirname, resolve } from 'path';
23
+ import { homedir, tmpdir } from 'os';
22
24
 
23
- const VERSION = "1.1";
24
- const CLAUDE_DIR = join(homedir(), ".claude");
25
- const MCP_CACHE = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
26
- const LOG_FILE = join(CLAUDE_DIR, "logs", "cli-route-stats.jsonl");
27
- const ACC_FILE = join(CLAUDE_DIR, "cache", "sv-accumulator.json");
25
+ const VERSION = '1.2';
26
+ const CLAUDE_DIR = join(homedir(), '.claude');
27
+ const MCP_CACHE = join(CLAUDE_DIR, 'cache', 'mcp-inventory.json');
28
+ const LOG_FILE = join(CLAUDE_DIR, 'logs', 'cli-route-stats.jsonl');
29
+ const ACC_FILE = join(CLAUDE_DIR, 'cache', 'sv-accumulator.json');
28
30
 
29
31
  // ── ANSI 색상 ──
30
- const AMBER = "\x1b[38;5;214m";
31
- const GREEN = "\x1b[38;5;82m";
32
- const RED = "\x1b[38;5;196m";
33
- const YELLOW = "\x1b[33m";
34
- const DIM = "\x1b[2m";
35
- const BOLD = "\x1b[1m";
36
- const RESET = "\x1b[0m";
37
- const GRAY = "\x1b[38;5;245m";
32
+ const AMBER = '\x1b[38;5;214m';
33
+ const GREEN = '\x1b[38;5;82m';
34
+ const RED = '\x1b[38;5;196m';
35
+ const YELLOW = '\x1b[33m';
36
+ const DIM = '\x1b[2m';
37
+ const BOLD = '\x1b[1m';
38
+ const RESET = '\x1b[0m';
39
+ const GRAY = '\x1b[38;5;245m';
38
40
 
39
41
  // ── URL 파싱 ──
40
42
  function parseNotionUrl(input) {
@@ -165,24 +167,24 @@ ${mcpServer} MCP 서버의 도구를 사용하라.
165
167
  }
166
168
 
167
169
  // ── CLI 실행 (임시 파일 + execSync — Windows .cmd 호환) ──
168
- function runWithCli(cliType, prompt, timeout) {
169
- const cliName = cliType === "claude" ? "claude" : cliType === "codex" ? "codex" : "gemini";
170
+ function runWithCli(cliType, prompt, timeout, runMode = 'fg') {
171
+ const cliName = cliType === 'claude' ? 'claude' : cliType === 'codex' ? 'codex' : 'gemini';
170
172
  if (!cliExists(cliName)) {
171
- return { success: false, output: "", error: `${cliType} CLI 미설치`, cli: cliType };
173
+ return { success: false, output: '', error: `${cliType} CLI 미설치`, cli: cliType };
172
174
  }
173
175
 
174
176
  // 프롬프트를 임시 파일에 저장 (shell escaping 회피)
175
177
  const promptFile = join(tmpdir(), `notion-prompt-${Date.now()}.md`);
176
- writeFileSync(promptFile, prompt, "utf8");
177
- const promptPath = promptFile.replace(/\\/g, "/");
178
+ writeFileSync(promptFile, prompt, 'utf8');
179
+ const promptPath = promptFile.replace(/\\/g, '/');
178
180
 
179
181
  // CLI에 전달할 짧은 메타 프롬프트
180
182
  const metaPrompt = `Read the file at ${promptPath} and execute all instructions in it exactly as described. Output only the final markdown result.`;
181
183
 
182
184
  let cmd;
183
- if (cliType === "codex") {
185
+ if (cliType === 'codex') {
184
186
  cmd = `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "${metaPrompt}"`;
185
- } else if (cliType === "gemini") {
187
+ } else if (cliType === 'gemini') {
186
188
  cmd = `gemini -m gemini-3-flash-preview -y --allowed-mcp-server-names notion,notion-guest --prompt "${metaPrompt}"`;
187
189
  } else {
188
190
  // Claude CLI — print 모드 (MCP 도구 자동 접근)
@@ -192,16 +194,16 @@ function runWithCli(cliType, prompt, timeout) {
192
194
  console.error(`${AMBER}▸${RESET} ${cliType}로 실행 중... (timeout: ${timeout}s)`);
193
195
  const startTime = Date.now();
194
196
 
195
- let stdout = "";
196
- let stderr = "";
197
+ let stdout = '';
198
+ let stderr = '';
197
199
  let exitCode = 0;
198
200
 
199
201
  try {
200
202
  stdout = execSync(cmd, {
201
- encoding: "utf8",
203
+ encoding: 'utf8',
202
204
  timeout: (timeout + 30) * 1000,
203
205
  maxBuffer: 10 * 1024 * 1024,
204
- stdio: ["pipe", "pipe", "pipe"],
206
+ stdio: ['pipe', 'pipe', 'pipe'],
205
207
  cwd: process.cwd(),
206
208
  });
207
209
  } catch (e) {
@@ -216,7 +218,7 @@ function runWithCli(cliType, prompt, timeout) {
216
218
  try { unlinkSync(promptFile); } catch {}
217
219
 
218
220
  // 실행 로그 기록
219
- logExecution(cliType, exitCode, elapsed, timeout, stderr);
221
+ logExecution(cliType, exitCode, elapsed, timeout, stderr, runMode);
220
222
 
221
223
  if (exitCode === 0 && stdout) {
222
224
  return { success: true, output: stdout, cli: cliType, elapsed };
@@ -263,30 +265,30 @@ function cleanCodexOutput(raw) {
263
265
  }
264
266
 
265
267
  // ── 실행 로그 (cli-route.sh 호환) ──
266
- function logExecution(cliType, exitCode, elapsed, timeout, stderr) {
268
+ function logExecution(cliType, exitCode, elapsed, timeout, stderr, runMode = 'fg') {
267
269
  try {
268
270
  const logDir = dirname(LOG_FILE);
269
271
  if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
270
272
 
271
273
  const ts = new Date().toISOString();
272
- const status = exitCode === 0 ? "success" : exitCode === 124 ? "timeout" : "failed";
274
+ const status = exitCode === 0 ? 'success' : exitCode === 124 ? 'timeout' : 'failed';
273
275
  const entry = JSON.stringify({
274
276
  ts,
275
- agent: "notion-read",
277
+ agent: 'notion-read',
276
278
  cli: cliType,
277
- effort: cliType === "codex" ? "high" : cliType === "claude" ? "sonnet" : "flash",
278
- run_mode: "fg",
279
- opus_oversight: "false",
279
+ effort: cliType === 'codex' ? 'high' : cliType === 'claude' ? 'sonnet' : 'flash',
280
+ run_mode: runMode,
281
+ opus_oversight: 'false',
280
282
  status,
281
283
  exit_code: exitCode,
282
284
  elapsed_sec: elapsed,
283
285
  timeout_sec: timeout,
284
- mcp_profile: "notion",
286
+ mcp_profile: runMode === 'delegate' ? 'notion-guest' : 'notion',
285
287
  input_tokens: 0,
286
288
  output_tokens: 0,
287
289
  total_tokens: 0,
288
290
  });
289
- appendFileSync(LOG_FILE, entry + "\n");
291
+ appendFileSync(LOG_FILE, entry + '\n');
290
292
  } catch {}
291
293
  }
292
294
 
@@ -309,6 +311,7 @@ function main() {
309
311
  --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
310
312
  --comments 블록/페이지 댓글 포함
311
313
  --guest notion-guest 통합 사용
314
+ --delegate Claude 이관 모드 (notion-guest 우선, 파일 저장)
312
315
 
313
316
  ${BOLD}폴백 체인${RESET}
314
317
  Codex(무료) → Gemini(무료) → Claude(최후) → 에러
@@ -318,6 +321,8 @@ function main() {
318
321
  tfx notion-read abc123def456... --output page.md --comments
319
322
  tfx notion-read abc123def456... --cli gemini --timeout 900
320
323
  tfx notion-read abc123def456... --guest --comments
324
+ tfx notion-read abc123def456... --delegate
325
+ tfx notion-read abc123def456... --delegate --output .notion-cache/page.md
321
326
  `);
322
327
  return;
323
328
  }
@@ -330,6 +335,7 @@ function main() {
330
335
  let depth = 3;
331
336
  let useGuest = false;
332
337
  let includeComments = false;
338
+ let delegateMode = false;
333
339
 
334
340
  for (let i = 1; i < args.length; i++) {
335
341
  switch (args[i]) {
@@ -355,6 +361,9 @@ function main() {
355
361
  case "--comments":
356
362
  includeComments = true;
357
363
  break;
364
+ case '--delegate':
365
+ delegateMode = true;
366
+ break;
358
367
  }
359
368
  }
360
369
 
@@ -368,7 +377,62 @@ function main() {
368
377
  console.error(
369
378
  `${AMBER}▸${RESET} 페이지: ${parsed.pageId}${parsed.blockId ? ` (블록: ${parsed.blockId})` : ""}`,
370
379
  );
371
- console.error(`${GRAY} 통합: ${useGuest ? "notion-guest" : "notion"} | 깊이: ${depth} | 댓글: ${includeComments ? "O" : "X"} | 타임아웃: ${timeout}s${RESET}`);
380
+ console.error(`${GRAY} 통합: ${delegateMode ? 'notion-guest(우선)' : useGuest ? 'notion-guest' : 'notion'} | 깊이: ${depth} | 댓글: ${includeComments ? 'O' : 'X'} | 타임아웃: ${timeout}s${RESET}`);
381
+
382
+ // 프롬프트 생성
383
+ const prompt = buildPrompt(parsed.pageId, parsed.blockId, depth, useGuest, includeComments);
384
+ // Claude 폴백용: notion/notion-guest 양쪽 시도 프롬프트
385
+ const claudePrompt = buildPrompt(parsed.pageId, parsed.blockId, depth, false, includeComments)
386
+ .replace(
387
+ 'notion MCP 서버의 도구를 사용하라.',
388
+ '가능하면 notion-guest MCP 서버를 먼저 사용하라. 실패하면 notion MCP 서버를 사용하라.',
389
+ );
390
+
391
+ // delegate 모드: Claude 단독 + notion-guest 우선 + 파일 저장
392
+ if (delegateMode) {
393
+ console.error(`${AMBER}▸${RESET} delegate 모드 활성화: Claude로 notion-guest 우선 접근`);
394
+
395
+ const delegatePrompt = `${claudePrompt}
396
+
397
+ ### delegate 모드 추가 지시
398
+ - notion-guest MCP 서버를 최우선으로 먼저 시도하라.
399
+ - notion-guest가 실패하거나 미구성일 때만 notion 서버로 폴백하라.
400
+ - 도구 호출 결과를 바탕으로 최종 마크다운만 출력하라.`;
401
+
402
+ const delegateResult = runWithCli('claude', delegatePrompt, timeout, 'delegate');
403
+ if (!delegateResult.success) {
404
+ console.error(`${RED}✗${RESET} delegate 모드 실패: ${delegateResult.error}`);
405
+ if (delegateResult.stderr) {
406
+ console.error(`${GRAY} stderr: ${delegateResult.stderr.slice(0, 250)}${RESET}`);
407
+ }
408
+ console.error(`${GRAY} 대안: --delegate 없이 실행해 기존 폴백 체인을 사용하세요.${RESET}`);
409
+ console.error(`${GRAY} 예: tfx notion-read ${parsed.pageId} --comments${RESET}`);
410
+ process.exit(1);
411
+ }
412
+
413
+ const delegateOutput = delegateResult.output.trim();
414
+ const isDelegateFailureOutput =
415
+ (delegateOutput.includes('조회 실패') || delegateOutput.includes('읽기 실패') || delegateOutput.includes('not_found')) &&
416
+ delegateOutput.length < 500;
417
+
418
+ if (delegateOutput.length <= 100 || isDelegateFailureOutput) {
419
+ console.error(`${RED}✗${RESET} delegate 모드 실패: Claude 결과가 비정상적입니다.`);
420
+ console.error(`${GRAY} 대안: --delegate 없이 실행해 Codex/Gemini/Claude 폴백 체인을 사용하세요.${RESET}`);
421
+ process.exit(1);
422
+ }
423
+
424
+ const delegateTarget = outputFile || join('.notion-cache', `${parsed.pageId}.md`);
425
+ const delegateDir = dirname(delegateTarget);
426
+ if (delegateDir && delegateDir !== '.' && !existsSync(delegateDir)) {
427
+ mkdirSync(delegateDir, { recursive: true });
428
+ }
429
+ writeFileSync(delegateTarget, delegateOutput, 'utf8');
430
+
431
+ const savedPath = resolve(delegateTarget);
432
+ console.error(`${GREEN}✓${RESET} delegate 결과 저장: ${savedPath}`);
433
+ console.error(`${GRAY} 후속 작업 참조 경로: ${savedPath}${RESET}`);
434
+ return;
435
+ }
372
436
 
373
437
  // MCP 가용성 확인
374
438
  const mcpAvail = getNotionMcpClis(useGuest);
@@ -400,15 +464,6 @@ function main() {
400
464
  cliOrder.push("claude");
401
465
  }
402
466
 
403
- // 프롬프트 생성
404
- const prompt = buildPrompt(parsed.pageId, parsed.blockId, depth, useGuest, includeComments);
405
- // Claude 폴백용: notion/notion-guest 양쪽 시도 프롬프트
406
- const claudePrompt = buildPrompt(parsed.pageId, parsed.blockId, depth, false, includeComments)
407
- .replace(
408
- "404 에러가 나면 notion-guest 서버로 재시도하라.",
409
- "404 에러가 나면 반드시 notion-guest 서버로 재시도하라. notion, notion-guest, claude_ai_Notion 등 사용 가능한 모든 Notion MCP 서버를 시도하라.",
410
- );
411
-
412
467
  // 실행 + 폴백
413
468
  let lastResult = null;
414
469
  let notionAccessFailed = false;