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 +129 -4
- package/hooks/safety-guard.mjs +14 -7
- package/hub/account-broker.mjs +242 -0
- package/hub/codex-adapter.mjs +1 -0
- package/hub/gemini-adapter.mjs +1 -0
- package/hub/lib/cache-guard.mjs +114 -0
- package/hub/team/conductor.mjs +73 -13
- package/package.json +1 -1
- package/scripts/claudemd-sync.mjs +103 -0
- package/scripts/setup.mjs +80 -77
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
|
|
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":
|
package/hooks/safety-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 };
|
package/hub/codex-adapter.mjs
CHANGED
|
@@ -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';
|
package/hub/gemini-adapter.mjs
CHANGED
|
@@ -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
|
+
}
|
package/hub/team/conductor.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn, execFile } from 'node:child_process';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import {
|
|
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 =
|
|
524
|
+
const launcher = resolvedConfig.remote
|
|
465
525
|
? null
|
|
466
526
|
: buildLauncher({
|
|
467
|
-
agent:
|
|
468
|
-
profile:
|
|
469
|
-
prompt:
|
|
470
|
-
workdir:
|
|
471
|
-
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:
|
|
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(
|
|
548
|
+
sessions.set(resolvedConfig.id, session);
|
|
489
549
|
|
|
490
|
-
if (
|
|
550
|
+
if (resolvedConfig.remote) {
|
|
491
551
|
startRemoteSession(session);
|
|
492
552
|
} else {
|
|
493
553
|
void respawnSession(session);
|
|
494
554
|
}
|
|
495
|
-
return
|
|
555
|
+
return resolvedConfig.id;
|
|
496
556
|
}
|
|
497
557
|
|
|
498
558
|
/**
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
594
|
+
let synced = claudeRoutingChangedCount;
|
|
581
595
|
|
|
582
|
-
for (const { src, dst
|
|
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
|
|
994
|
-
if (
|
|
1007
|
+
const codexProfilesResult = ensureCodexProfiles();
|
|
1008
|
+
if (codexProfilesResult.ok && codexProfilesResult.changed > 0) {
|
|
995
1009
|
synced++;
|
|
996
1010
|
}
|
|
997
1011
|
|
|
998
|
-
// ── CLAUDE.md
|
|
1012
|
+
// ── CLAUDE.md 라우팅 섹션 자동 동기화 ──
|
|
999
1013
|
|
|
1000
1014
|
try {
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
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");
|