triflux 9.8.5 → 9.8.7

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/bin/triflux.mjs CHANGED
@@ -29,7 +29,13 @@ import {
29
29
  extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
30
30
  ensureCodexHubServerConfig,
31
31
  } from "../scripts/setup.mjs";
32
+ import {
33
+ ensureGlobalClaudeRoutingSection,
34
+ ensureTfxSection,
35
+ getLatestRoutingTable,
36
+ } from "../scripts/claudemd-sync.mjs";
32
37
  import { cleanupTmpFiles } from "../scripts/tmp-cleanup.mjs";
38
+ import { checkNetworkAvailability, validateRuntimeCachePaths } from "../hub/lib/cache-guard.mjs";
33
39
 
34
40
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
35
41
  const CLAUDE_DIR = join(homedir(), ".claude");
@@ -534,6 +540,26 @@ function describeSyncAction(src, dst, label) {
534
540
  };
535
541
  }
536
542
 
543
+ function syncClaudeRoutingSectionsForCli() {
544
+ try {
545
+ const routingTable = getLatestRoutingTable();
546
+ return [
547
+ ensureTfxSection(join(PKG_ROOT, "CLAUDE.md"), routingTable),
548
+ ensureGlobalClaudeRoutingSection(CLAUDE_DIR),
549
+ ];
550
+ } catch (error) {
551
+ const reason = error instanceof Error ? error.message : "routing_sync_failed";
552
+ return [{ action: "unchanged", path: join(PKG_ROOT, "CLAUDE.md"), skipped: true, reason }];
553
+ }
554
+ }
555
+
556
+ function getClaudeRoutingSyncSummary(results) {
557
+ return results.reduce((summary, result) => ({
558
+ changed: summary.changed + (result.action === "created" || result.action === "updated" ? 1 : 0),
559
+ skipped: summary.skipped + (result.skipped ? 1 : 0),
560
+ }), { changed: 0, skipped: 0 });
561
+ }
562
+
537
563
  // ── 크로스 셸 진단 ──
538
564
 
539
565
  function checkCliCrossShell(cmd, installHint) {
@@ -703,7 +729,7 @@ function buildSetupDryRunPlan() {
703
729
  }
704
730
 
705
731
  function cmdSetup(options = {}) {
706
- const { dryRun = false, overrideVersion } = options;
732
+ const { dryRun = false, overrideVersion, skipClaudeMdSync = false } = options;
707
733
  if (dryRun) {
708
734
  printJson(buildSetupDryRunPlan());
709
735
  return;
@@ -793,6 +819,21 @@ function cmdSetup(options = {}) {
793
819
  // ── 결과 추적 ──
794
820
  const summary = [];
795
821
 
822
+ if (!skipClaudeMdSync) {
823
+ const claudeRoutingResults = syncClaudeRoutingSectionsForCli();
824
+ const claudeRoutingSummary = getClaudeRoutingSyncSummary(claudeRoutingResults);
825
+ if (claudeRoutingSummary.changed > 0) {
826
+ ok(`CLAUDE.md 라우팅: ${claudeRoutingSummary.changed}개 파일 반영`);
827
+ summary.push({ item: "CLAUDE.md 라우팅", status: "✅", detail: `${claudeRoutingSummary.changed}개 파일 반영` });
828
+ } else if (claudeRoutingSummary.skipped > 0) {
829
+ ok("CLAUDE.md 라우팅: 대상 파일 없음 (건너뜀)");
830
+ summary.push({ item: "CLAUDE.md 라우팅", status: "⏭️", detail: "대상 파일 없음" });
831
+ } else {
832
+ ok("CLAUDE.md 라우팅: 최신 상태");
833
+ summary.push({ item: "CLAUDE.md 라우팅", status: "✅", detail: "최신 상태" });
834
+ }
835
+ }
836
+
796
837
  const codexProfileResult = ensureCodexProfiles();
797
838
  if (!codexProfileResult.ok) {
798
839
  warn(`Codex profiles 설정 실패: ${codexProfileResult.message}`);
@@ -2446,7 +2487,56 @@ async function cmdDoctor(options = {}) {
2446
2487
  });
2447
2488
  }
2448
2489
 
2449
- function cmdUpdate() {
2490
+ function normalizeRemoteReachabilityUrl(remoteUrl) {
2491
+ if (!remoteUrl) return null;
2492
+ if (/^https?:\/\//iu.test(remoteUrl)) {
2493
+ try {
2494
+ return new URL(remoteUrl).origin;
2495
+ } catch {
2496
+ return null;
2497
+ }
2498
+ }
2499
+ const scpMatch = /^git@([^:]+):/iu.exec(remoteUrl);
2500
+ if (scpMatch) return `https://${scpMatch[1]}`;
2501
+ if (/^ssh:\/\//iu.test(remoteUrl)) {
2502
+ try {
2503
+ return `https://${new URL(remoteUrl).hostname}`;
2504
+ } catch {
2505
+ return null;
2506
+ }
2507
+ }
2508
+ return null;
2509
+ }
2510
+
2511
+ function resolveGitUpdateUrl(repoDir) {
2512
+ try {
2513
+ const remoteUrl = execSync("git remote get-url origin", {
2514
+ encoding: "utf8",
2515
+ timeout: 10_000,
2516
+ cwd: repoDir,
2517
+ stdio: ["pipe", "pipe", "ignore"],
2518
+ windowsHide: true,
2519
+ }).trim();
2520
+ return normalizeRemoteReachabilityUrl(remoteUrl);
2521
+ } catch {
2522
+ return null;
2523
+ }
2524
+ }
2525
+
2526
+ function resolveUpdateTargets({ installMode, pluginPath }) {
2527
+ const repoDir = installMode === "plugin" ? (pluginPath || PKG_ROOT) : PKG_ROOT;
2528
+ const gitUrl = resolveGitUpdateUrl(repoDir);
2529
+
2530
+ if (installMode === "npm-global" || installMode === "npm-local") {
2531
+ return ["https://registry.npmjs.org/triflux"];
2532
+ }
2533
+ if (installMode === "plugin" || installMode === "git-local") {
2534
+ return gitUrl ? [gitUrl] : ["https://github.com"];
2535
+ }
2536
+ return [];
2537
+ }
2538
+
2539
+ async function cmdUpdate() {
2450
2540
  const isDev = isDevUpdateRequested(NORMALIZED_ARGS);
2451
2541
  const tagLabel = isDev ? ` ${YELLOW}--dev${RESET}` : "";
2452
2542
  console.log(`\n${BOLD}triflux update${RESET}${tagLabel}\n`);
@@ -2502,6 +2592,27 @@ function cmdUpdate() {
2502
2592
 
2503
2593
  info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
2504
2594
 
2595
+ const networkTargets = resolveUpdateTargets({ installMode, pluginPath });
2596
+ if (networkTargets.length > 0) {
2597
+ const networkStatus = await checkNetworkAvailability(networkTargets);
2598
+ if (!networkStatus.online) {
2599
+ fail(`네트워크 확인 실패: ${networkStatus.unreachable.join(", ")}`);
2600
+ info("네트워크 연결을 확인한 뒤 다시 시도하세요.");
2601
+ return;
2602
+ }
2603
+ ok(`네트워크 확인 완료 (${networkStatus.reachable.join(", ")})`);
2604
+ }
2605
+
2606
+ const cacheValidation = validateRuntimeCachePaths(join(CLAUDE_DIR, "cache"));
2607
+ if (!cacheValidation.ok) {
2608
+ warn(`런타임 캐시 검증 이슈 ${cacheValidation.issues.length}건 발견`);
2609
+ for (const issue of cacheValidation.issues) {
2610
+ info(`${issue.file}: ${issue.error}`);
2611
+ }
2612
+ } else {
2613
+ ok("런타임 캐시 검증 완료");
2614
+ }
2615
+
2505
2616
  // 2. 설치 방식에 따라 업데이트
2506
2617
  const oldVer = PKG.version;
2507
2618
  let updated = false;
@@ -2666,9 +2777,23 @@ function cmdUpdate() {
2666
2777
  }
2667
2778
  }
2668
2779
 
2780
+ // ── Post-update: CLAUDE.md 라우팅 동기화 ──
2781
+ console.log(`\n${CYAN}── CLAUDE.md 라우팅 동기화 ──${RESET}`);
2782
+ {
2783
+ const claudeRoutingResults = syncClaudeRoutingSectionsForCli();
2784
+ const claudeRoutingSummary = getClaudeRoutingSyncSummary(claudeRoutingResults);
2785
+ if (claudeRoutingSummary.changed > 0) {
2786
+ ok(`CLAUDE.md 라우팅 ${claudeRoutingSummary.changed}개 파일 반영`);
2787
+ } else if (claudeRoutingSummary.skipped > 0) {
2788
+ ok("CLAUDE.md 라우팅 대상 파일 없음 (건너뜀)");
2789
+ } else {
2790
+ ok("CLAUDE.md 라우팅 최신 상태");
2791
+ }
2792
+ }
2793
+
2669
2794
  // ── Post-update: 설정 동기화 ──
2670
2795
  console.log(`\n${CYAN}── 설정 동기화 ──${RESET}`);
2671
- cmdSetup({ fromUpdate: true, overrideVersion: newVer });
2796
+ cmdSetup({ fromUpdate: true, overrideVersion: newVer, skipClaudeMdSync: true });
2672
2797
 
2673
2798
  // ── Post-update: 훅 오케스트레이터 적용 ──
2674
2799
  {
@@ -3688,7 +3813,7 @@ async function main() {
3688
3813
  cmdSchema(cmdArgs);
3689
3814
  return;
3690
3815
  case "update":
3691
- cmdUpdate();
3816
+ await cmdUpdate();
3692
3817
  return;
3693
3818
  case "list":
3694
3819
  case "ls":
@@ -65,13 +65,20 @@ function main() {
65
65
  // psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
66
66
  // git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
67
67
  function isPsmuxInvocation(cmd) {
68
- // 명령을 세그먼트로 분할 (&&, ;, | 기준)
69
- const segments = cmd.split(/[;&|]+/);
70
- return segments.some((seg) => {
71
- const trimmed = seg.trim();
72
- if (trimmed.startsWith("#")) return false; // 주석
73
- // 세그먼트의 단어가 psmux인 경우만 실제 호출
74
- return /^\s*psmux\s+kill-(session|server)\b/i.test(trimmed);
68
+ // psmux kill-session/server가 명령에 없으면 즉시 false
69
+ if (!/\bpsmux\s+kill-(session|server)\b/i.test(cmd)) return false;
70
+
71
+ // 분할 → 세그먼트 분할(&&, ;, ||)로 각 명령 단위 검사
72
+ // 세그먼트가 echo/grep/git-commit으로 시작하면 인자 텍스트이므로 무시
73
+ const lines = cmd.split(/\n/);
74
+ return lines.some((line) => {
75
+ const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
76
+ return segments.some((seg) => {
77
+ const t = seg.trim();
78
+ if (!t || t.startsWith("#")) return false;
79
+ if (/^\s*(echo|printf|grep|git\s+commit)\b/i.test(t)) return false;
80
+ return /\bpsmux\s+kill-(session|server)\b/i.test(t);
81
+ });
75
82
  });
76
83
  }
77
84
 
@@ -0,0 +1,242 @@
1
+ // hub/account-broker.mjs — Multi-account CLI pool broker
2
+ // Manages lease/release/cooldown for Codex and Gemini accounts.
3
+ // Singleton export. All state changes create new objects (immutable pattern).
4
+
5
+ import { readFileSync, existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import * as z from 'zod';
9
+
10
+ // ── Zod schema ───────────────────────────────────────────────────
11
+
12
+ const AccountSchema = z.object({
13
+ id: z.string().min(1),
14
+ mode: z.enum(['profile', 'env', 'auth']),
15
+ profile: z.string().optional(),
16
+ env: z.record(z.string(), z.string()).optional(),
17
+ authFile: z.string().optional(),
18
+ }).superRefine((val, ctx) => {
19
+ if (val.mode === 'auth' && !val.authFile) {
20
+ ctx.addIssue({
21
+ code: z.ZodIssueCode.custom,
22
+ message: 'authFile is required when mode is "auth"',
23
+ path: ['authFile'],
24
+ });
25
+ }
26
+ });
27
+
28
+ const ConfigSchema = z.object({
29
+ defaults: z.object({
30
+ cooldownMs: z.number().int().positive().optional(),
31
+ }).optional(),
32
+ codex: z.array(AccountSchema).optional(),
33
+ gemini: z.array(AccountSchema).optional(),
34
+ });
35
+
36
+ const DEFAULT_COOLDOWN_MS = 300_000; // 5 minutes
37
+ const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
38
+ const AUTH_BASE_PATH = join(homedir(), '.claude', 'cache', 'tfx-hub');
39
+
40
+ // ── env var resolution ───────────────────────────────────────────
41
+
42
+ function resolveEnvValues(env) {
43
+ if (!env) return undefined;
44
+ const resolved = {};
45
+ for (const [key, value] of Object.entries(env)) {
46
+ if (typeof value === 'string' && value.startsWith('$')) {
47
+ const varName = value.slice(1);
48
+ resolved[key] = process.env[varName] ?? '';
49
+ } else {
50
+ resolved[key] = value;
51
+ }
52
+ }
53
+ return resolved;
54
+ }
55
+
56
+ // ── AccountBroker ────────────────────────────────────────────────
57
+
58
+ class AccountBroker {
59
+ #config;
60
+ #state; // Map<accountId, accountState>
61
+ #roundRobinIndex; // Map<provider, number>
62
+
63
+ constructor(config) {
64
+ const parsed = ConfigSchema.parse(config);
65
+ this.#config = parsed;
66
+
67
+ this.#state = new Map();
68
+ this.#roundRobinIndex = new Map();
69
+
70
+ const allAccounts = [
71
+ ...(parsed.codex || []).map((a) => ({ ...a, provider: 'codex' })),
72
+ ...(parsed.gemini || []).map((a) => ({ ...a, provider: 'gemini' })),
73
+ ];
74
+
75
+ for (const account of allAccounts) {
76
+ this.#state.set(account.id, {
77
+ id: account.id,
78
+ provider: account.provider,
79
+ mode: account.mode,
80
+ profile: account.profile,
81
+ env: account.env,
82
+ authFile: account.authFile,
83
+ busy: false,
84
+ leasedAt: null,
85
+ cooldownUntil: 0,
86
+ failures: 0,
87
+ lastUsedAt: 0,
88
+ totalSessions: 0,
89
+ });
90
+ }
91
+ }
92
+
93
+ // ── lease TTL pruning ──────────────────────────────────────────
94
+
95
+ #pruneExpiredLeases(now) {
96
+ for (const [id, acct] of this.#state) {
97
+ if (acct.busy && acct.leasedAt !== null && now - acct.leasedAt > LEASE_TTL_MS) {
98
+ this.#state.set(id, { ...acct, busy: false, leasedAt: null });
99
+ }
100
+ }
101
+ }
102
+
103
+ // ── lease ─────────────────────────────────────────────────────
104
+
105
+ lease({ provider }) {
106
+ const now = Date.now();
107
+ this.#pruneExpiredLeases(now);
108
+
109
+ const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
110
+ if (!accounts.length) return null;
111
+
112
+ const current = this.#roundRobinIndex.get(provider) ?? 0;
113
+ const count = accounts.length;
114
+
115
+ // round-robin starting from current index, skip busy/cooldown
116
+ for (let i = 0; i < count; i++) {
117
+ const idx = (current + i) % count;
118
+ const acct = accounts[idx];
119
+
120
+ if (acct.busy) continue;
121
+ if (acct.cooldownUntil > now) continue;
122
+
123
+ // advance round-robin index
124
+ this.#roundRobinIndex.set(provider, (idx + 1) % count);
125
+
126
+ // update state
127
+ this.#state.set(acct.id, {
128
+ ...acct,
129
+ busy: true,
130
+ leasedAt: now,
131
+ lastUsedAt: now,
132
+ totalSessions: acct.totalSessions + 1,
133
+ });
134
+
135
+ return {
136
+ id: acct.id,
137
+ mode: acct.mode,
138
+ profile: acct.mode === 'profile' ? acct.profile : undefined,
139
+ env: acct.mode === 'env' ? resolveEnvValues(acct.env) : undefined,
140
+ authFile: acct.mode === 'auth' ? join(AUTH_BASE_PATH, acct.authFile) : undefined,
141
+ };
142
+ }
143
+
144
+ return null;
145
+ }
146
+
147
+ // ── release ───────────────────────────────────────────────────
148
+
149
+ release(accountId, result) {
150
+ const acct = this.#state.get(accountId);
151
+ if (!acct) return;
152
+
153
+ const ok = result?.ok === true;
154
+ const newFailures = ok ? 0 : acct.failures + 1;
155
+ const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
156
+
157
+ const updated = {
158
+ ...acct,
159
+ busy: false,
160
+ leasedAt: null,
161
+ failures: newFailures,
162
+ };
163
+
164
+ // consecutive failure guard: 3+ failures → auto-cooldown
165
+ if (newFailures >= 3) {
166
+ updated.cooldownUntil = Date.now() + cooldownMs;
167
+ updated.failures = 0; // reset after cooldown triggered
168
+ }
169
+
170
+ this.#state.set(accountId, updated);
171
+ }
172
+
173
+ // ── markRateLimited ───────────────────────────────────────────
174
+
175
+ markRateLimited(id, coolMs) {
176
+ const acct = this.#state.get(id);
177
+ if (!acct) return;
178
+ this.#state.set(id, {
179
+ ...acct,
180
+ busy: false,
181
+ leasedAt: null,
182
+ cooldownUntil: Date.now() + coolMs,
183
+ });
184
+ }
185
+
186
+ // ── snapshot ──────────────────────────────────────────────────
187
+
188
+ snapshot() {
189
+ return [...this.#state.values()].map((acct) => ({ ...acct }));
190
+ }
191
+
192
+ // ── nextAvailableEta ──────────────────────────────────────────
193
+
194
+ nextAvailableEta(provider) {
195
+ const now = Date.now();
196
+ this.#pruneExpiredLeases(now);
197
+
198
+ const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
199
+ if (!accounts.length) return null;
200
+
201
+ // find minimum cooldownUntil among accounts that are in cooldown or busy
202
+ let earliest = null;
203
+ for (const acct of accounts) {
204
+ if (!acct.busy && acct.cooldownUntil <= now) {
205
+ // this account is available now — no ETA needed
206
+ return null;
207
+ }
208
+ const eta = acct.busy ? (acct.leasedAt ?? now) + LEASE_TTL_MS : acct.cooldownUntil;
209
+ if (earliest === null || eta < earliest) {
210
+ earliest = eta;
211
+ }
212
+ }
213
+ return earliest;
214
+ }
215
+ }
216
+
217
+ // ── Config loader ────────────────────────────────────────────────
218
+
219
+ function loadConfig() {
220
+ const configPath = join(homedir(), '.claude', 'cache', 'tfx-hub', 'accounts.json');
221
+ if (!existsSync(configPath)) return null;
222
+ try {
223
+ return JSON.parse(readFileSync(configPath, 'utf8'));
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ // ── Singleton ────────────────────────────────────────────────────
230
+
231
+ function createBroker() {
232
+ const config = loadConfig();
233
+ if (!config) return null;
234
+ try {
235
+ return new AccountBroker(config);
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ export const broker = createBroker();
242
+ export { AccountBroker };
@@ -20,6 +20,7 @@ const breaker = createCircuitBreaker();
20
20
 
21
21
  function inferStallMode(stdout, stderr) {
22
22
  const text = `${stdout}\n${stderr}`.toLowerCase();
23
+ if (/(rate.?limit|quota|throttl|too.many.requests|429|usage.limit)/u.test(text)) return 'rate_limited';
23
24
  if (/(approval|approve|permission|sandbox|bypass)/u.test(text)) return 'approval_stall';
24
25
  if (/\bmcp\b|context7|playwright|tavily|exa|brave|sequential|server/u.test(text)) return 'mcp_stall';
25
26
  return 'timeout';
@@ -22,6 +22,7 @@ const breaker = createCircuitBreaker();
22
22
 
23
23
  function inferStallMode(stdout, stderr) {
24
24
  const text = `${stdout}\n${stderr}`.toLowerCase();
25
+ if (/(rate.?limit|quota|resource.?exhaust|429)/u.test(text)) return 'rate_limited';
25
26
  if (/(unauthorized|forbidden|auth|login|token|credential|api.?key)/u.test(text)) return 'auth_stall';
26
27
  if (/\bmcp\b|playwright|tavily|brave|sequential|server/u.test(text)) return 'mcp_stall';
27
28
  return 'timeout';
@@ -0,0 +1,114 @@
1
+ import { accessSync, constants, existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import { basename, join, relative } from "node:path";
5
+
6
+ const NETWORK_TIMEOUT_MS = 3_000;
7
+
8
+ function toIssuePath(cacheDir, filePath) {
9
+ const relPath = relative(cacheDir, filePath);
10
+ return (relPath && relPath.length > 0 ? relPath : basename(filePath)).replace(/\\/g, "/");
11
+ }
12
+
13
+ function collectCacheFiles(cacheDir) {
14
+ return readdirSync(cacheDir, { withFileTypes: true }).flatMap((entry) => {
15
+ const filePath = join(cacheDir, entry.name);
16
+ if (entry.isDirectory()) return collectCacheFiles(filePath);
17
+ return entry.isFile() ? [filePath] : [];
18
+ });
19
+ }
20
+
21
+ function validateCacheFile(cacheDir, filePath) {
22
+ try {
23
+ accessSync(filePath, constants.R_OK);
24
+ if (filePath.endsWith(".json")) {
25
+ JSON.parse(readFileSync(filePath, "utf8"));
26
+ }
27
+ return null;
28
+ } catch (error) {
29
+ return {
30
+ file: toIssuePath(cacheDir, filePath),
31
+ error: error instanceof Error ? error.message : String(error),
32
+ };
33
+ }
34
+ }
35
+
36
+ function probeUrl(url) {
37
+ return new Promise((resolve) => {
38
+ let settled = false;
39
+
40
+ const finish = (ok) => {
41
+ if (settled) return;
42
+ settled = true;
43
+ resolve({ url, ok });
44
+ };
45
+
46
+ try {
47
+ const parsed = new URL(url);
48
+ const transport = parsed.protocol === "https:" ? https : parsed.protocol === "http:" ? http : null;
49
+ if (!transport) {
50
+ finish(false);
51
+ return;
52
+ }
53
+
54
+ const request = transport.request(parsed, {
55
+ method: "HEAD",
56
+ headers: { "user-agent": "triflux-cache-guard" },
57
+ }, (response) => {
58
+ response.resume();
59
+ finish(true);
60
+ });
61
+
62
+ request.setTimeout(NETWORK_TIMEOUT_MS, () => {
63
+ request.destroy(new Error("timeout"));
64
+ });
65
+ request.on("error", () => finish(false));
66
+ request.end();
67
+ } catch {
68
+ finish(false);
69
+ }
70
+ });
71
+ }
72
+
73
+ export function validateRuntimeCachePaths(cacheDir) {
74
+ if (!cacheDir || !existsSync(cacheDir)) {
75
+ return { ok: true, issues: [] };
76
+ }
77
+
78
+ try {
79
+ const files = collectCacheFiles(cacheDir);
80
+ const issues = files
81
+ .map((filePath) => validateCacheFile(cacheDir, filePath))
82
+ .filter((issue) => issue !== null);
83
+
84
+ return {
85
+ ok: issues.length === 0,
86
+ issues,
87
+ };
88
+ } catch (error) {
89
+ return {
90
+ ok: false,
91
+ issues: [{
92
+ file: ".",
93
+ error: error instanceof Error ? error.message : String(error),
94
+ }],
95
+ };
96
+ }
97
+ }
98
+
99
+ export async function checkNetworkAvailability(urls) {
100
+ const targets = [...new Set((Array.isArray(urls) ? urls : []).filter(Boolean))];
101
+ if (targets.length === 0) {
102
+ return { online: true, reachable: [], unreachable: [] };
103
+ }
104
+
105
+ const results = await Promise.all(targets.map((url) => probeUrl(url)));
106
+ const reachable = results.filter((result) => result.ok).map((result) => result.url);
107
+ const unreachable = results.filter((result) => !result.ok).map((result) => result.url);
108
+
109
+ return {
110
+ online: unreachable.length === 0,
111
+ reachable,
112
+ unreachable,
113
+ };
114
+ }
@@ -10,7 +10,8 @@
10
10
 
11
11
  import { spawn, execFile } from 'node:child_process';
12
12
  import { join } from 'node:path';
13
- import { mkdirSync, createWriteStream, readFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { mkdirSync, createWriteStream, readFileSync, copyFileSync } from 'node:fs';
14
15
  import { EventEmitter } from 'node:events';
15
16
 
16
17
  import { killProcess, IS_WINDOWS } from '../platform.mjs';
@@ -18,6 +19,7 @@ import { createEventLog } from './event-log.mjs';
18
19
  import { createHealthProbe } from './health-probe.mjs';
19
20
  import { createRemoteProbe } from './remote-probe.mjs';
20
21
  import { buildLauncher } from './launcher-template.mjs';
22
+ import { broker } from '../account-broker.mjs';
21
23
 
22
24
  /** 세션 상태 */
23
25
  export const STATES = Object.freeze({
@@ -272,6 +274,14 @@ export function createConductor(opts = {}) {
272
274
  } else {
273
275
  transition(session, STATES.DEAD, `maxRestarts(${maxRestarts})_exceeded`);
274
276
  emitter.emit('dead', { sessionId: session.id, reason });
277
+
278
+ // broker release on final death
279
+ if (broker && session.config.accountId) {
280
+ broker.release(session.config.accountId, { ok: false, failureMode: session.lastFailureMode });
281
+ if (session.lastFailureMode === 'rate_limited') {
282
+ broker.markRateLimited(session.config.accountId, 5 * 60 * 1000);
283
+ }
284
+ }
275
285
  }
276
286
  }
277
287
 
@@ -299,7 +309,7 @@ export function createConductor(opts = {}) {
299
309
  try {
300
310
  child = spawn(launcher.command, {
301
311
  shell: true,
302
- env: { ...process.env, ...launcher.env },
312
+ env: { ...process.env, ...launcher.env, ...(session.config.env || {}) },
303
313
  stdio: ['pipe', 'pipe', 'pipe'],
304
314
  windowsHide: true,
305
315
  });
@@ -352,7 +362,14 @@ export function createConductor(opts = {}) {
352
362
  if (code === 0 && !signal) {
353
363
  transition(session, STATES.COMPLETED, 'exit_0');
354
364
  emitter.emit('completed', { sessionId: session.id });
365
+ if (broker && session.config.accountId) {
366
+ broker.release(session.config.accountId, { ok: true });
367
+ }
355
368
  } else {
369
+ // detect rate_limited from recent output before handleFailure
370
+ if (/(rate.?limit|quota|throttl|too.many.requests|429|usage.limit)/ui.test(recentOutput)) {
371
+ session.lastFailureMode = 'rate_limited';
372
+ }
356
373
  handleFailure(session, `exit_code:${code},signal:${signal}`);
357
374
  }
358
375
 
@@ -460,20 +477,63 @@ export function createConductor(opts = {}) {
460
477
  if (sessions.has(config.id)) throw new Error(`Session "${config.id}" already exists`);
461
478
  if (config.remote && !config.host) throw new Error('host is required for remote sessions');
462
479
 
480
+ // broker lease (graceful — broker null if accounts.json absent)
481
+ let lease = null;
482
+ if (broker && config.agent && !config.remote) {
483
+ lease = broker.lease({ provider: config.agent });
484
+ if (lease === null) {
485
+ const eta = broker.nextAvailableEta(config.agent);
486
+ eventLog.append('broker_no_lease', {
487
+ session: config.id,
488
+ agent: config.agent,
489
+ eta: eta ? new Date(eta).toISOString() : 'unknown',
490
+ });
491
+ // PoC: skip session when all accounts in cooldown
492
+ return config.id;
493
+ }
494
+ }
495
+
496
+ // apply lease profile/env/auth to config (immutable — new object)
497
+ const resolvedConfig = lease
498
+ ? {
499
+ ...config,
500
+ profile: lease.profile ?? config.profile,
501
+ env: { ...(config.env || {}), ...(lease.env || {}) },
502
+ accountId: lease.id,
503
+ }
504
+ : config;
505
+
506
+ // auth file copy — broker resolved absolute path, conductor does the actual copy
507
+ if (lease?.mode === 'auth' && lease.authFile) {
508
+ const destMap = {
509
+ codex: join(homedir(), '.codex', 'auth.json'),
510
+ gemini: join(homedir(), '.gemini', 'oauth_creds.json'),
511
+ };
512
+ const dest = destMap[config.agent];
513
+ if (dest) {
514
+ try {
515
+ copyFileSync(lease.authFile, dest);
516
+ eventLog.append('auth_copy', { session: config.id, agent: config.agent, dest });
517
+ } catch (err) {
518
+ eventLog.append('auth_copy_error', { session: config.id, error: err.message });
519
+ }
520
+ }
521
+ }
522
+
463
523
  // 원격 세션은 launcher 불필요 (이미 원격에서 실행 중)
464
- const launcher = config.remote
524
+ const launcher = resolvedConfig.remote
465
525
  ? null
466
526
  : buildLauncher({
467
- agent: config.agent,
468
- profile: config.profile,
469
- prompt: config.prompt,
470
- workdir: config.workdir,
471
- model: config.model,
527
+ agent: resolvedConfig.agent,
528
+ profile: resolvedConfig.profile,
529
+ prompt: resolvedConfig.prompt,
530
+ workdir: resolvedConfig.workdir,
531
+ model: resolvedConfig.model,
472
532
  });
473
533
 
474
534
  const session = {
475
- id: config.id,
476
- config,
535
+ id: resolvedConfig.id,
536
+ config: resolvedConfig,
477
537
  launcher,
478
538
  state: STATES.INIT,
479
539
  child: null,
@@ -485,14 +545,14 @@ export function createConductor(opts = {}) {
485
545
  createdAt: Date.now(),
486
546
  };
487
547
 
488
- sessions.set(config.id, session);
548
+ sessions.set(resolvedConfig.id, session);
489
549
 
490
- if (config.remote) {
550
+ if (resolvedConfig.remote) {
491
551
  startRemoteSession(session);
492
552
  } else {
493
553
  void respawnSession(session);
494
554
  }
495
- return config.id;
555
+ return resolvedConfig.id;
496
556
  }
497
557
 
498
558
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.8.5",
3
+ "version": "9.8.7",
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": {
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
6
+ const PROJECT_CLAUDE_MD_PATH = join(PROJECT_ROOT, "CLAUDE.md");
7
+ const ROUTING_SECTION_HEADING = "## triflux CLI 라우팅";
8
+
9
+ function findRoutingSection(markdown) {
10
+ const content = String(markdown || "");
11
+ const headingPattern = new RegExp(`(^|\\n)${ROUTING_SECTION_HEADING.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?=\\n|$)`, "u");
12
+ const match = headingPattern.exec(content);
13
+
14
+ if (!match) {
15
+ return { found: false, startIndex: -1, endIndex: -1, section: "" };
16
+ }
17
+
18
+ const startIndex = match.index + match[1].length;
19
+ const nextHeadingIndex = content.indexOf("\n## ", startIndex + ROUTING_SECTION_HEADING.length);
20
+ const endIndex = nextHeadingIndex === -1 ? content.length : nextHeadingIndex + 1;
21
+
22
+ return {
23
+ found: true,
24
+ startIndex,
25
+ endIndex,
26
+ section: content.slice(startIndex, endIndex),
27
+ };
28
+ }
29
+
30
+ function normalizeRoutingSection(routingTable) {
31
+ const section = String(routingTable || "").trim();
32
+ return section ? `${section}\n` : "";
33
+ }
34
+
35
+ function buildNextMarkdown(currentMarkdown, routingSection) {
36
+ const current = String(currentMarkdown || "");
37
+ const nextSection = normalizeRoutingSection(routingSection);
38
+ const existing = findRoutingSection(current);
39
+
40
+ if (existing.found) {
41
+ return `${current.slice(0, existing.startIndex)}${nextSection}${current.slice(existing.endIndex)}`;
42
+ }
43
+
44
+ if (!current) {
45
+ return nextSection;
46
+ }
47
+
48
+ const separator = current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
49
+ return `${current}${separator}${nextSection}`;
50
+ }
51
+
52
+ function toSkippedResult(path, reason) {
53
+ return { action: "unchanged", path, skipped: true, reason };
54
+ }
55
+
56
+ export function getLatestRoutingTable() {
57
+ if (!existsSync(PROJECT_CLAUDE_MD_PATH)) {
58
+ throw new Error(`project CLAUDE.md not found: ${PROJECT_CLAUDE_MD_PATH}`);
59
+ }
60
+
61
+ const projectMarkdown = readFileSync(PROJECT_CLAUDE_MD_PATH, "utf8");
62
+ const section = findRoutingSection(projectMarkdown);
63
+
64
+ if (!section.found) {
65
+ throw new Error(`routing section not found in: ${PROJECT_CLAUDE_MD_PATH}`);
66
+ }
67
+
68
+ return section.section.trim();
69
+ }
70
+
71
+ export function ensureTfxSection(claudeMdPath, routingTable) {
72
+ if (!existsSync(claudeMdPath)) {
73
+ return toSkippedResult(claudeMdPath, "missing_file");
74
+ }
75
+
76
+ const currentMarkdown = readFileSync(claudeMdPath, "utf8");
77
+ const nextMarkdown = buildNextMarkdown(currentMarkdown, routingTable);
78
+
79
+ if (nextMarkdown === currentMarkdown) {
80
+ return { action: "unchanged", path: claudeMdPath };
81
+ }
82
+
83
+ writeFileSync(claudeMdPath, nextMarkdown, "utf8");
84
+
85
+ return {
86
+ action: findRoutingSection(currentMarkdown).found ? "updated" : "created",
87
+ path: claudeMdPath,
88
+ };
89
+ }
90
+
91
+ export function ensureGlobalClaudeRoutingSection(claudeDir) {
92
+ const claudeMdPath = join(claudeDir, "CLAUDE.md");
93
+ if (!existsSync(claudeMdPath)) {
94
+ return toSkippedResult(claudeMdPath, "missing_file");
95
+ }
96
+
97
+ try {
98
+ return ensureTfxSection(claudeMdPath, getLatestRoutingTable());
99
+ } catch (error) {
100
+ const reason = error instanceof Error ? error.message : "routing_table_unavailable";
101
+ return toSkippedResult(claudeMdPath, reason);
102
+ }
103
+ }
package/scripts/setup.mjs CHANGED
@@ -5,10 +5,11 @@
5
5
  // - skills/를 ~/.claude/skills/에 동기화
6
6
 
7
7
  import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync } from "fs";
8
- import { join, dirname } from "path";
8
+ import { join, dirname, relative } from "path";
9
9
  import { homedir } from "os";
10
10
  import { spawn, execFileSync } from "child_process";
11
11
  import { fileURLToPath } from "url";
12
+ import { ensureGlobalClaudeRoutingSection, ensureTfxSection, getLatestRoutingTable } from "./claudemd-sync.mjs";
12
13
  import { cleanupTmpFiles } from "./tmp-cleanup.mjs";
13
14
 
14
15
  const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -54,6 +55,42 @@ const REQUIRED_CODEX_PROFILES = [
54
55
  },
55
56
  ];
56
57
 
58
+ const HUD_SYNC_EXCLUDES = new Set(["omc-hud.mjs", "omc-hud.mjs.bak"]);
59
+
60
+ function scanHudFiles(pluginRoot, claudeDir) {
61
+ const hudRoot = join(pluginRoot, "hud");
62
+ if (!existsSync(hudRoot)) return [];
63
+
64
+ const walk = (currentDir) => {
65
+ const entries = readdirSync(currentDir, { withFileTypes: true })
66
+ .sort((left, right) => left.name.localeCompare(right.name));
67
+
68
+ return entries.flatMap((entry) => {
69
+ const absolutePath = join(currentDir, entry.name);
70
+ if (entry.isDirectory()) {
71
+ return walk(absolutePath);
72
+ }
73
+
74
+ if (!entry.isFile() || HUD_SYNC_EXCLUDES.has(entry.name) || !entry.name.endsWith(".mjs")) {
75
+ return [];
76
+ }
77
+
78
+ const hudRelativePath = relative(hudRoot, absolutePath);
79
+ const normalizedRelativePath = hudRelativePath.replace(/\\/g, "/");
80
+
81
+ return [{
82
+ src: absolutePath,
83
+ dst: join(claudeDir, "hud", hudRelativePath),
84
+ label: normalizedRelativePath === "hud-qos-status.mjs"
85
+ ? "hud-qos-status.mjs"
86
+ : `hud/${normalizedRelativePath}`,
87
+ }];
88
+ });
89
+ };
90
+
91
+ return walk(hudRoot);
92
+ }
93
+
57
94
  // ── 파일 동기화 ──
58
95
 
59
96
  const SYNC_MAP = [
@@ -107,51 +144,7 @@ const SYNC_MAP = [
107
144
  dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "factory.mjs"),
108
145
  label: "hub/workers/factory.mjs",
109
146
  },
110
- {
111
- src: join(PLUGIN_ROOT, "hud", "hud-qos-status.mjs"),
112
- dst: join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
113
- label: "hud-qos-status.mjs",
114
- },
115
- {
116
- src: join(PLUGIN_ROOT, "hud", "colors.mjs"),
117
- dst: join(CLAUDE_DIR, "hud", "colors.mjs"),
118
- label: "hud/colors.mjs",
119
- },
120
- {
121
- src: join(PLUGIN_ROOT, "hud", "constants.mjs"),
122
- dst: join(CLAUDE_DIR, "hud", "constants.mjs"),
123
- label: "hud/constants.mjs",
124
- },
125
- {
126
- src: join(PLUGIN_ROOT, "hud", "terminal.mjs"),
127
- dst: join(CLAUDE_DIR, "hud", "terminal.mjs"),
128
- label: "hud/terminal.mjs",
129
- },
130
- {
131
- src: join(PLUGIN_ROOT, "hud", "utils.mjs"),
132
- dst: join(CLAUDE_DIR, "hud", "utils.mjs"),
133
- label: "hud/utils.mjs",
134
- },
135
- {
136
- src: join(PLUGIN_ROOT, "hud", "renderers.mjs"),
137
- dst: join(CLAUDE_DIR, "hud", "renderers.mjs"),
138
- label: "hud/renderers.mjs",
139
- },
140
- {
141
- src: join(PLUGIN_ROOT, "hud", "providers", "claude.mjs"),
142
- dst: join(CLAUDE_DIR, "hud", "providers", "claude.mjs"),
143
- label: "hud/providers/claude.mjs",
144
- },
145
- {
146
- src: join(PLUGIN_ROOT, "hud", "providers", "codex.mjs"),
147
- dst: join(CLAUDE_DIR, "hud", "providers", "codex.mjs"),
148
- label: "hud/providers/codex.mjs",
149
- },
150
- {
151
- src: join(PLUGIN_ROOT, "hud", "providers", "gemini.mjs"),
152
- dst: join(CLAUDE_DIR, "hud", "providers", "gemini.mjs"),
153
- label: "hud/providers/gemini.mjs",
154
- },
147
+ ...scanHudFiles(PLUGIN_ROOT, CLAUDE_DIR),
155
148
  {
156
149
  src: join(PLUGIN_ROOT, "scripts", "notion-read.mjs"),
157
150
  dst: join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
@@ -526,9 +519,23 @@ function ensureCodexProfiles() {
526
519
  writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
527
520
  }
528
521
 
529
- return changed;
530
- } catch {
531
- return 0;
522
+ return { ok: true, changed };
523
+ } catch (error) {
524
+ const message = error instanceof Error && error.message ? error.message.trim() : "unknown error";
525
+ return { ok: false, changed: 0, message };
526
+ }
527
+ }
528
+
529
+ function syncClaudeRoutingSections() {
530
+ try {
531
+ const routingTable = getLatestRoutingTable();
532
+ return [
533
+ ensureTfxSection(join(PLUGIN_ROOT, "CLAUDE.md"), routingTable),
534
+ ensureGlobalClaudeRoutingSection(CLAUDE_DIR),
535
+ ];
536
+ } catch (error) {
537
+ const reason = error instanceof Error ? error.message : "routing_sync_failed";
538
+ return [{ action: "unchanged", path: join(PLUGIN_ROOT, "CLAUDE.md"), skipped: true, reason }];
532
539
  }
533
540
  }
534
541
 
@@ -536,6 +543,7 @@ export {
536
543
  replaceProfileSection,
537
544
  hasProfileSection,
538
545
  detectDevMode,
546
+ scanHudFiles,
539
547
  SYNC_MAP,
540
548
  BREADCRUMB_PATH,
541
549
  PLUGIN_ROOT,
@@ -572,14 +580,20 @@ if (isSync) {
572
580
 
573
581
  const pkgVersion = getPackageVersion();
574
582
  const marker = readMarker();
583
+ const claudeRoutingResults = syncClaudeRoutingSections();
584
+ const claudeRoutingChangedCount = claudeRoutingResults.filter((result) => result.action === "created" || result.action === "updated").length;
575
585
  if (pkgVersion && marker?.version === pkgVersion && !isForce) {
576
- console.log(`setup: skip (v${pkgVersion} already synced)`);
586
+ if (claudeRoutingChangedCount > 0) {
587
+ console.log(`setup: skip core sync (v${pkgVersion} already synced, CLAUDE.md ${claudeRoutingChangedCount}건 반영)`);
588
+ } else {
589
+ console.log(`setup: skip (v${pkgVersion} already synced)`);
590
+ }
577
591
  process.exit(0);
578
592
  }
579
593
 
580
- let synced = 0;
594
+ let synced = claudeRoutingChangedCount;
581
595
 
582
- for (const { src, dst, label } of SYNC_MAP) {
596
+ for (const { src, dst } of SYNC_MAP) {
583
597
  if (!existsSync(src)) continue;
584
598
 
585
599
  const dstDir = dirname(dst);
@@ -990,39 +1004,28 @@ if (process.platform === "win32") {
990
1004
 
991
1005
  // ── Codex 프로필 자동 보정 ──
992
1006
 
993
- const codexProfilesAdded = ensureCodexProfiles();
994
- if (codexProfilesAdded > 0) {
1007
+ const codexProfilesResult = ensureCodexProfiles();
1008
+ if (codexProfilesResult.ok && codexProfilesResult.changed > 0) {
995
1009
  synced++;
996
1010
  }
997
1011
 
998
- // ── CLAUDE.md TFX 섹션 관리 (마이그레이션 + 업데이트) ──
1012
+ // ── CLAUDE.md 라우팅 섹션 자동 동기화 ──
999
1013
 
1000
1014
  try {
1001
- const { migrate, readSection, diagnose, getPackageVersion: getTfxVersion } = await import("./lib/claudemd-manager.mjs");
1002
- const globalClaudeMd = join(CLAUDE_DIR, "CLAUDE.md");
1003
- const projectClaudeMd = join(PLUGIN_ROOT, "CLAUDE.md");
1004
-
1005
- for (const mdPath of [globalClaudeMd, projectClaudeMd]) {
1006
- if (!existsSync(mdPath)) continue;
1007
- const section = readSection(mdPath);
1008
- const ver = getTfxVersion();
1009
-
1010
- if (!section.found || section.version !== ver) {
1011
- const result = migrate(mdPath, { version: ver });
1012
- const label = mdPath === globalClaudeMd ? "global" : "project";
1013
- if (result.action === "migrated") {
1014
- console.log(` \x1b[32m✓\x1b[0m CLAUDE.md (${label}): 레거시 → TFX v${ver} 마이그레이션`);
1015
- synced++;
1016
- } else if (result.action !== "already_managed") {
1017
- console.log(` \x1b[32m✓\x1b[0m CLAUDE.md (${label}): TFX v${ver} ${result.action}`);
1018
- synced++;
1019
- }
1020
- }
1015
+ const routingTable = getLatestRoutingTable();
1016
+ const projectResult = ensureTfxSection(join(PLUGIN_ROOT, "CLAUDE.md"), routingTable);
1017
+ if (projectResult.action !== "unchanged") {
1018
+ console.log(` \x1b[32m✓\x1b[0m CLAUDE.md (project): ${projectResult.action}`);
1019
+ synced++;
1020
+ }
1021
+ const globalResult = ensureGlobalClaudeRoutingSection(CLAUDE_DIR);
1022
+ if (globalResult.action !== "unchanged") {
1023
+ console.log(` \x1b[32m✓\x1b[0m CLAUDE.md (global): ${globalResult.action}`);
1024
+ synced++;
1021
1025
  }
1022
1026
  } catch (error) {
1023
- console.log(` \x1b[33m⚠\x1b[0m CLAUDE.md 관리 실패: ${error.message}`);
1027
+ console.log(` \x1b[33m⚠\x1b[0m CLAUDE.md 동기화 실패: ${error.message}`);
1024
1028
  }
1025
-
1026
1029
  // ── MCP 인벤토리 백그라운드 갱신 ──
1027
1030
 
1028
1031
  const mcpCheck = join(PLUGIN_ROOT, "scripts", "mcp-check.mjs");