@triflux/core 10.0.0-alpha.1 → 10.0.0
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/hooks/hook-adaptive-collector.mjs +86 -0
- package/hooks/hook-manager.mjs +15 -2
- package/hooks/hook-registry.json +37 -4
- package/hooks/keyword-rules.json +2 -1
- package/hooks/mcp-config-watcher.mjs +2 -7
- package/hooks/safety-guard.mjs +37 -0
- package/hub/account-broker.mjs +251 -0
- package/hub/adaptive-diagnostic.mjs +323 -0
- package/hub/adaptive-inject.mjs +186 -0
- package/hub/adaptive-memory.mjs +163 -0
- package/hub/adaptive.mjs +143 -0
- package/hub/cli-adapter-base.mjs +89 -1
- package/hub/codex-adapter.mjs +12 -3
- package/hub/codex-compat.mjs +11 -78
- package/hub/codex-preflight.mjs +20 -1
- package/hub/gemini-adapter.mjs +1 -0
- package/hub/index.mjs +34 -0
- package/hub/lib/cache-guard.mjs +114 -0
- package/hub/lib/known-errors.json +72 -0
- package/hub/lib/memory-store.mjs +748 -0
- package/hub/lib/ssh-command.mjs +150 -0
- package/hub/lib/uuidv7.mjs +44 -0
- package/hub/memory-doctor.mjs +480 -0
- package/hub/middleware/request-logger.mjs +80 -0
- package/hub/router.mjs +1 -1
- package/hub/team-bridge.mjs +21 -19
- package/hud/constants.mjs +7 -0
- package/hud/context-monitor.mjs +403 -0
- package/hud/hud-qos-status.mjs +8 -4
- package/hud/providers/claude.mjs +5 -0
- package/hud/renderers.mjs +32 -14
- package/hud/utils.mjs +26 -0
- package/package.json +3 -2
- package/scripts/lib/claudemd-scanner.mjs +218 -0
- package/scripts/lib/handoff.mjs +171 -0
- package/scripts/lib/mcp-guard-engine.mjs +20 -6
- package/scripts/lib/skill-template.mjs +269 -0
- package/scripts/lib/claudemd-manager.mjs +0 -325
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { createAdaptiveEngine } from '../hub/adaptive.mjs';
|
|
8
|
+
|
|
9
|
+
let engine = null;
|
|
10
|
+
let createEngine = createAdaptiveEngine;
|
|
11
|
+
|
|
12
|
+
function readStdin() {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(0, 'utf8');
|
|
15
|
+
} catch {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inferProjectSlug(cwd = process.cwd()) {
|
|
21
|
+
const packagePath = join(cwd, 'package.json');
|
|
22
|
+
if (existsSync(packagePath)) {
|
|
23
|
+
try {
|
|
24
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
25
|
+
if (typeof pkg.name === 'string' && pkg.name.trim()) return pkg.name.trim();
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return basename(cwd) || 'default';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getEngine() {
|
|
32
|
+
if (engine) return engine;
|
|
33
|
+
engine = createEngine({
|
|
34
|
+
projectSlug: inferProjectSlug(),
|
|
35
|
+
repoRoot: process.cwd(),
|
|
36
|
+
});
|
|
37
|
+
engine.startSession?.();
|
|
38
|
+
return engine;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildErrorContext(event = {}) {
|
|
42
|
+
return {
|
|
43
|
+
exitCode: event.exitCode,
|
|
44
|
+
stderr: String(event.stderr || '').slice(0, 500),
|
|
45
|
+
tool: event.tool,
|
|
46
|
+
command: String(event.command || '').slice(0, 200),
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default function hookAdaptiveCollector(event = {}) {
|
|
52
|
+
if (Number(event.exitCode) === 0) return null;
|
|
53
|
+
if (!event.tool || event.tool === 'Read') return null;
|
|
54
|
+
|
|
55
|
+
const result = getEngine().handleError(buildErrorContext(event));
|
|
56
|
+
if (result?.diagnosed) {
|
|
57
|
+
console.error(`[adaptive] 에러 패턴 감지: ${result.rule?.id || 'unknown'}`);
|
|
58
|
+
if (result.promoted) {
|
|
59
|
+
console.error(`[adaptive] 규칙 승격 → Tier ${result.rule?.tier ?? '?'}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function __setAdaptiveCollectorFactoryForTests(factory) {
|
|
66
|
+
createEngine = factory;
|
|
67
|
+
engine = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function __resetAdaptiveCollectorForTests() {
|
|
71
|
+
createEngine = createAdaptiveEngine;
|
|
72
|
+
engine = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function main() {
|
|
76
|
+
const raw = readStdin();
|
|
77
|
+
if (!raw.trim()) return;
|
|
78
|
+
try {
|
|
79
|
+
hookAdaptiveCollector(JSON.parse(raw));
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isEntrypoint = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
84
|
+
if (isEntrypoint) {
|
|
85
|
+
main();
|
|
86
|
+
}
|
package/hooks/hook-manager.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Claude 대화에서 AskUserQuestion으로 UI를 제공하며 내부적으로 이 명령들을 호출합니다.
|
|
14
14
|
|
|
15
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
@@ -21,7 +21,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
21
21
|
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
22
22
|
const SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
23
23
|
const BACKUP_PATH = join(HOME, ".claude", "settings.hooks-backup.json");
|
|
24
|
-
const
|
|
24
|
+
const BUNDLED_REGISTRY_PATH = join(__dirname, "hook-registry.json");
|
|
25
|
+
const REGISTRY_PATH = join(HOME, ".claude", "cache", "hook-registry.json");
|
|
26
|
+
|
|
27
|
+
function ensureUserRegistry() {
|
|
28
|
+
if (!existsSync(REGISTRY_PATH) && existsSync(BUNDLED_REGISTRY_PATH)) {
|
|
29
|
+
mkdirSync(dirname(REGISTRY_PATH), { recursive: true });
|
|
30
|
+
writeFileSync(REGISTRY_PATH, readFileSync(BUNDLED_REGISTRY_PATH, "utf8"), "utf8");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
25
33
|
|
|
26
34
|
// ── 유틸리티 ────────────────────────────────────────────────
|
|
27
35
|
|
|
@@ -45,6 +53,7 @@ function getNodeExe() {
|
|
|
45
53
|
// ── scan: 현재 settings.json 훅 분석 ───────────────────────
|
|
46
54
|
|
|
47
55
|
function scan() {
|
|
56
|
+
ensureUserRegistry();
|
|
48
57
|
const settings = loadJSON(SETTINGS_PATH);
|
|
49
58
|
if (!settings?.hooks) {
|
|
50
59
|
return { status: "no_hooks", message: "settings.json에 훅이 없습니다.", events: {} };
|
|
@@ -115,6 +124,7 @@ function resolveVars(cmd) {
|
|
|
115
124
|
// ── diff: 적용 시 변경점 미리보기 ───────────────────────────
|
|
116
125
|
|
|
117
126
|
function diff() {
|
|
127
|
+
ensureUserRegistry();
|
|
118
128
|
const settings = loadJSON(SETTINGS_PATH);
|
|
119
129
|
if (!settings?.hooks) return { status: "no_hooks", changes: [] };
|
|
120
130
|
|
|
@@ -158,6 +168,7 @@ function isOrchestrator(matchers) {
|
|
|
158
168
|
// ── apply: 오케스트레이터 적용 ──────────────────────────────
|
|
159
169
|
|
|
160
170
|
function apply() {
|
|
171
|
+
ensureUserRegistry();
|
|
161
172
|
const settings = loadJSON(SETTINGS_PATH);
|
|
162
173
|
if (!settings) return { status: "error", message: "settings.json을 찾을 수 없습니다." };
|
|
163
174
|
|
|
@@ -250,6 +261,7 @@ function restore() {
|
|
|
250
261
|
// ── set-priority: 우선순위 변경 ─────────────────────────────
|
|
251
262
|
|
|
252
263
|
function setPriority(hookId, priority) {
|
|
264
|
+
ensureUserRegistry();
|
|
253
265
|
const registry = loadJSON(REGISTRY_PATH);
|
|
254
266
|
if (!registry) return { status: "error", message: "레지스트리를 찾을 수 없습니다." };
|
|
255
267
|
|
|
@@ -275,6 +287,7 @@ function setPriority(hookId, priority) {
|
|
|
275
287
|
// ── toggle: 활성/비활성 토글 ────────────────────────────────
|
|
276
288
|
|
|
277
289
|
function toggle(hookId) {
|
|
290
|
+
ensureUserRegistry();
|
|
278
291
|
const registry = loadJSON(REGISTRY_PATH);
|
|
279
292
|
if (!registry) return { status: "error", message: "레지스트리를 찾을 수 없습니다." };
|
|
280
293
|
|
package/hooks/hook-registry.json
CHANGED
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
"blocking": false,
|
|
32
32
|
"description": "서브에이전트 스폰 시 triflux 컨텍스트 주입"
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
"id": "tfx-cross-review-gate",
|
|
36
|
+
"source": "triflux",
|
|
37
|
+
"matcher": "Bash",
|
|
38
|
+
"command": "node \"${PLUGIN_ROOT}/scripts/cross-review-gate.mjs\"",
|
|
39
|
+
"priority": 1,
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"timeout": 3,
|
|
42
|
+
"blocking": true,
|
|
43
|
+
"description": "git commit 전 교차 리뷰 미검증 파일 차단/경고"
|
|
44
|
+
},
|
|
34
45
|
{
|
|
35
46
|
"id": "omc-headless-guard",
|
|
36
47
|
"source": "omc",
|
|
@@ -89,6 +100,17 @@
|
|
|
89
100
|
"timeout": 3,
|
|
90
101
|
"blocking": false,
|
|
91
102
|
"description": "도구 실패 시 에러 힌트 자동 주입"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "tfx-adaptive-collector",
|
|
106
|
+
"source": "triflux",
|
|
107
|
+
"matcher": "*",
|
|
108
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/hook-adaptive-collector.mjs\"",
|
|
109
|
+
"priority": 1,
|
|
110
|
+
"enabled": true,
|
|
111
|
+
"timeout": 3,
|
|
112
|
+
"blocking": false,
|
|
113
|
+
"description": "에러 패턴 수집 → Neural Memory adaptive engine 학습"
|
|
92
114
|
}
|
|
93
115
|
],
|
|
94
116
|
"UserPromptSubmit": [
|
|
@@ -149,13 +171,24 @@
|
|
|
149
171
|
"blocking": false,
|
|
150
172
|
"description": "CLI/Hub 가용성 캐시 (hub-ensure 완료 후 실행)"
|
|
151
173
|
},
|
|
174
|
+
{
|
|
175
|
+
"id": "tfx-mcp-gateway-ensure",
|
|
176
|
+
"source": "triflux",
|
|
177
|
+
"matcher": "*",
|
|
178
|
+
"command": "node \"${PLUGIN_ROOT}/scripts/mcp-gateway-ensure.mjs\"",
|
|
179
|
+
"priority": 4,
|
|
180
|
+
"enabled": true,
|
|
181
|
+
"timeout": 8,
|
|
182
|
+
"blocking": false,
|
|
183
|
+
"description": "supergateway MCP 서비스 헬스체크 및 자동 기동"
|
|
184
|
+
},
|
|
152
185
|
{
|
|
153
186
|
"id": "ext-session-vault-start",
|
|
154
187
|
"source": "session-vault",
|
|
155
188
|
"matcher": "*",
|
|
156
|
-
"command": "${HOME}/Desktop/Projects/
|
|
189
|
+
"command": "bash \"${HOME}/Desktop/Projects/tools/session-vault/scripts/start_hook.sh\"",
|
|
157
190
|
"priority": 100,
|
|
158
|
-
"enabled":
|
|
191
|
+
"enabled": true,
|
|
159
192
|
"timeout": 10,
|
|
160
193
|
"blocking": false,
|
|
161
194
|
"description": "세션 볼트 로깅 시작"
|
|
@@ -177,9 +210,9 @@
|
|
|
177
210
|
"id": "ext-session-vault-export",
|
|
178
211
|
"source": "session-vault",
|
|
179
212
|
"matcher": "*",
|
|
180
|
-
"command": "${HOME}/Desktop/Projects/
|
|
213
|
+
"command": "bash \"${HOME}/Desktop/Projects/tools/session-vault/scripts/export_hook.sh\"",
|
|
181
214
|
"priority": 100,
|
|
182
|
-
"enabled":
|
|
215
|
+
"enabled": true,
|
|
183
216
|
"timeout": 30,
|
|
184
217
|
"blocking": false,
|
|
185
218
|
"description": "세션 트랜스크립트 내보내기"
|
package/hooks/keyword-rules.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
8
|
import {
|
|
9
9
|
isWatchedPath,
|
|
10
|
-
|
|
10
|
+
loadRegistryOrDefault,
|
|
11
11
|
remediate,
|
|
12
12
|
scanForStdioServers,
|
|
13
13
|
} from "../scripts/lib/mcp-guard-engine.mjs";
|
|
@@ -60,12 +60,7 @@ function main() {
|
|
|
60
60
|
const filePath = input.tool_input?.file_path || "";
|
|
61
61
|
if (!filePath || !isWatchedPath(filePath)) process.exit(0);
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
registry = loadRegistry();
|
|
66
|
-
} catch {
|
|
67
|
-
process.exit(0);
|
|
68
|
-
}
|
|
63
|
+
const registry = loadRegistryOrDefault();
|
|
69
64
|
|
|
70
65
|
const stdioServers = scanForStdioServers(filePath);
|
|
71
66
|
if (stdioServers.length === 0) process.exit(0);
|
package/hooks/safety-guard.mjs
CHANGED
|
@@ -22,6 +22,8 @@ const BLOCK_RULES = [
|
|
|
22
22
|
{ pattern: /\bformat\s+[a-z]:/i, reason: "디스크 포맷 차단" },
|
|
23
23
|
{ pattern: /\b(del|rmdir)\s+\/[sq]\b/i, reason: "Windows 재귀 삭제 차단" },
|
|
24
24
|
{ pattern: /\bgit\s+clean\s+.*-fd/i, reason: "git clean -fd 차단 — 추적되지 않은 파일 소실 위험" },
|
|
25
|
+
{ pattern: /\bpsmux\s+kill-session\b/i, reason: "raw psmux kill-session 차단 — WT ConPTY 프리징 위험. 안전 경로: node hub/team/psmux.mjs kill --session <name>", skipIfGit: true },
|
|
26
|
+
{ pattern: /\bpsmux\s+kill-server\b/i, reason: "psmux kill-server 차단 — 모든 세션이 즉시 종료됩니다. node hub/team/psmux.mjs kill-swarm 사용", skipIfGit: true },
|
|
25
27
|
];
|
|
26
28
|
|
|
27
29
|
// ── 경고 규칙 ──────────────────────────────────────────────
|
|
@@ -60,8 +62,43 @@ function main() {
|
|
|
60
62
|
const command = (input.tool_input?.command || "").trim();
|
|
61
63
|
if (!command) process.exit(0);
|
|
62
64
|
|
|
65
|
+
// psmux 명령이 실제 CLI 호출인지 판별 (오탐 방지)
|
|
66
|
+
// git commit 메시지, echo, grep, cat, heredoc 안의 텍스트는 무시
|
|
67
|
+
function isPsmuxInvocation(cmd) {
|
|
68
|
+
// psmux kill-session/server가 명령에 없으면 즉시 false
|
|
69
|
+
if (!/\bpsmux\s+kill-(session|server)\b/i.test(cmd)) return false;
|
|
70
|
+
|
|
71
|
+
// 줄 분할 → heredoc 경계 추적 → 세그먼트 분할(&&, ;, ||)로 각 명령 단위 검사
|
|
72
|
+
// 세그먼트가 echo/grep/git-commit으로 시작하면 인자 텍스트이므로 무시
|
|
73
|
+
const lines = cmd.split(/\n/);
|
|
74
|
+
let heredocDelimiter = null;
|
|
75
|
+
return lines.some((line) => {
|
|
76
|
+
// heredoc 내부이면 닫는 구분자인지만 확인하고 건너뜀
|
|
77
|
+
if (heredocDelimiter !== null) {
|
|
78
|
+
if (line.trim() === heredocDelimiter) heredocDelimiter = null;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
// heredoc 시작 감지: <<'WORD', <<"WORD", <<WORD
|
|
82
|
+
const heredocMatch = line.match(/<<['"]?(\w+)['"]?/);
|
|
83
|
+
if (heredocMatch) {
|
|
84
|
+
heredocDelimiter = heredocMatch[1];
|
|
85
|
+
// heredoc 시작 줄 자체는 실제 명령이므로 계속 검사하되
|
|
86
|
+
// 시작 줄에 psmux kill이 포함될 리 없으므로 여기서 false 반환
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const segments = line.split(/\s*(?:&&|;|\|\|)\s*/);
|
|
90
|
+
return segments.some((seg) => {
|
|
91
|
+
const t = seg.trim();
|
|
92
|
+
if (!t || t.startsWith("#")) return false;
|
|
93
|
+
if (/^\s*(echo|printf|grep|git\s+commit)\b/i.test(t)) return false;
|
|
94
|
+
return /\bpsmux\s+kill-(session|server)\b/i.test(t);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
63
99
|
// 1. BLOCK 체크 — exit 2로 차단
|
|
64
100
|
for (const rule of BLOCK_RULES) {
|
|
101
|
+
if (rule.skipIfGit && !isPsmuxInvocation(command)) continue;
|
|
65
102
|
if (rule.pattern.test(command)) {
|
|
66
103
|
process.stderr.write(
|
|
67
104
|
`[triflux safety-guard] BLOCKED: ${rule.reason}\n` +
|
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
tier: z.enum(['pro', 'plus', 'free', 'unknown']).optional().default('unknown'),
|
|
19
|
+
}).superRefine((val, ctx) => {
|
|
20
|
+
if (val.mode === 'auth' && !val.authFile) {
|
|
21
|
+
ctx.addIssue({
|
|
22
|
+
code: z.ZodIssueCode.custom,
|
|
23
|
+
message: 'authFile is required when mode is "auth"',
|
|
24
|
+
path: ['authFile'],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ConfigSchema = z.object({
|
|
30
|
+
defaults: z.object({
|
|
31
|
+
cooldownMs: z.number().int().positive().optional(),
|
|
32
|
+
}).optional(),
|
|
33
|
+
codex: z.array(AccountSchema).optional(),
|
|
34
|
+
gemini: z.array(AccountSchema).optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const DEFAULT_COOLDOWN_MS = 300_000; // 5 minutes
|
|
38
|
+
const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
|
|
39
|
+
const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
40
|
+
const AUTH_BASE_PATH = join(homedir(), '.claude', 'cache', 'tfx-hub');
|
|
41
|
+
|
|
42
|
+
// ── env var resolution ───────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function resolveEnvValues(env) {
|
|
45
|
+
if (!env) return undefined;
|
|
46
|
+
const resolved = {};
|
|
47
|
+
for (const [key, value] of Object.entries(env)) {
|
|
48
|
+
if (typeof value === 'string' && value.startsWith('$')) {
|
|
49
|
+
const varName = value.slice(1);
|
|
50
|
+
resolved[key] = process.env[varName] ?? '';
|
|
51
|
+
} else {
|
|
52
|
+
resolved[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── AccountBroker ────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class AccountBroker {
|
|
61
|
+
#config;
|
|
62
|
+
#state; // Map<accountId, accountState>
|
|
63
|
+
#roundRobinIndex; // Map<provider, number>
|
|
64
|
+
|
|
65
|
+
constructor(config) {
|
|
66
|
+
const parsed = ConfigSchema.parse(config);
|
|
67
|
+
this.#config = parsed;
|
|
68
|
+
|
|
69
|
+
this.#state = new Map();
|
|
70
|
+
this.#roundRobinIndex = new Map();
|
|
71
|
+
|
|
72
|
+
const allAccounts = [
|
|
73
|
+
...(parsed.codex || []).map((a) => ({ ...a, provider: 'codex' })),
|
|
74
|
+
...(parsed.gemini || []).map((a) => ({ ...a, provider: 'gemini' })),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
for (const account of allAccounts) {
|
|
78
|
+
this.#state.set(account.id, {
|
|
79
|
+
id: account.id,
|
|
80
|
+
provider: account.provider,
|
|
81
|
+
mode: account.mode,
|
|
82
|
+
profile: account.profile,
|
|
83
|
+
env: account.env,
|
|
84
|
+
authFile: account.authFile,
|
|
85
|
+
tier: account.tier ?? 'unknown',
|
|
86
|
+
busy: false,
|
|
87
|
+
leasedAt: null,
|
|
88
|
+
cooldownUntil: 0,
|
|
89
|
+
failures: 0,
|
|
90
|
+
lastUsedAt: 0,
|
|
91
|
+
totalSessions: 0,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── lease TTL pruning ──────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
#pruneExpiredLeases(now) {
|
|
99
|
+
for (const [id, acct] of this.#state) {
|
|
100
|
+
if (acct.busy && acct.leasedAt !== null && now - acct.leasedAt > LEASE_TTL_MS) {
|
|
101
|
+
this.#state.set(id, { ...acct, busy: false, leasedAt: null });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── lease ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
lease({ provider }) {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
this.#pruneExpiredLeases(now);
|
|
111
|
+
|
|
112
|
+
const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
|
|
113
|
+
if (!accounts.length) return null;
|
|
114
|
+
|
|
115
|
+
// group available accounts by tier, preserving insertion order within each tier
|
|
116
|
+
const available = accounts.filter((a) => !a.busy && a.cooldownUntil <= now);
|
|
117
|
+
if (!available.length) return null;
|
|
118
|
+
|
|
119
|
+
// sort by tier priority; stable sort preserves original order within same priority
|
|
120
|
+
const sorted = [...available].sort(
|
|
121
|
+
(a, b) => (TIER_PRIORITY[a.tier] ?? 2) - (TIER_PRIORITY[b.tier] ?? 2),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// pick the best tier, then apply round-robin within that tier's accounts
|
|
125
|
+
const bestTier = sorted[0].tier;
|
|
126
|
+
const sameTierAccounts = sorted.filter((a) => a.tier === bestTier);
|
|
127
|
+
|
|
128
|
+
// use a per-provider+tier round-robin key to distribute within the tier
|
|
129
|
+
const rrKey = `${provider}:${bestTier}`;
|
|
130
|
+
const rrCurrent = this.#roundRobinIndex.get(rrKey) ?? 0;
|
|
131
|
+
const tierCount = sameTierAccounts.length;
|
|
132
|
+
const idx = rrCurrent % tierCount;
|
|
133
|
+
const acct = sameTierAccounts[idx];
|
|
134
|
+
|
|
135
|
+
// advance round-robin index for this tier
|
|
136
|
+
this.#roundRobinIndex.set(rrKey, (idx + 1) % tierCount);
|
|
137
|
+
|
|
138
|
+
// update state (immutable)
|
|
139
|
+
this.#state.set(acct.id, {
|
|
140
|
+
...acct,
|
|
141
|
+
busy: true,
|
|
142
|
+
leasedAt: now,
|
|
143
|
+
lastUsedAt: now,
|
|
144
|
+
totalSessions: acct.totalSessions + 1,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
id: acct.id,
|
|
149
|
+
mode: acct.mode,
|
|
150
|
+
profile: acct.mode === 'profile' ? acct.profile : undefined,
|
|
151
|
+
env: acct.mode === 'env' ? resolveEnvValues(acct.env) : undefined,
|
|
152
|
+
authFile: acct.mode === 'auth' ? join(AUTH_BASE_PATH, acct.authFile) : undefined,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── release ───────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
release(accountId, result) {
|
|
159
|
+
const acct = this.#state.get(accountId);
|
|
160
|
+
if (!acct) return;
|
|
161
|
+
|
|
162
|
+
const ok = result?.ok === true;
|
|
163
|
+
const newFailures = ok ? 0 : acct.failures + 1;
|
|
164
|
+
const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
165
|
+
|
|
166
|
+
const updated = {
|
|
167
|
+
...acct,
|
|
168
|
+
busy: false,
|
|
169
|
+
leasedAt: null,
|
|
170
|
+
failures: newFailures,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// consecutive failure guard: 3+ failures → auto-cooldown
|
|
174
|
+
if (newFailures >= 3) {
|
|
175
|
+
updated.cooldownUntil = Date.now() + cooldownMs;
|
|
176
|
+
updated.failures = 0; // reset after cooldown triggered
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.#state.set(accountId, updated);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── markRateLimited ───────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
markRateLimited(id, coolMs) {
|
|
185
|
+
const acct = this.#state.get(id);
|
|
186
|
+
if (!acct) return;
|
|
187
|
+
this.#state.set(id, {
|
|
188
|
+
...acct,
|
|
189
|
+
busy: false,
|
|
190
|
+
leasedAt: null,
|
|
191
|
+
cooldownUntil: Date.now() + coolMs,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── snapshot ──────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
snapshot() {
|
|
198
|
+
return [...this.#state.values()].map((acct) => ({ ...acct }));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── nextAvailableEta ──────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
nextAvailableEta(provider) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
this.#pruneExpiredLeases(now);
|
|
206
|
+
|
|
207
|
+
const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
|
|
208
|
+
if (!accounts.length) return null;
|
|
209
|
+
|
|
210
|
+
// find minimum cooldownUntil among accounts that are in cooldown or busy
|
|
211
|
+
let earliest = null;
|
|
212
|
+
for (const acct of accounts) {
|
|
213
|
+
if (!acct.busy && acct.cooldownUntil <= now) {
|
|
214
|
+
// this account is available now — no ETA needed
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const eta = acct.busy ? (acct.leasedAt ?? now) + LEASE_TTL_MS : acct.cooldownUntil;
|
|
218
|
+
if (earliest === null || eta < earliest) {
|
|
219
|
+
earliest = eta;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return earliest;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Config loader ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function loadConfig() {
|
|
229
|
+
const configPath = join(homedir(), '.claude', 'cache', 'tfx-hub', 'accounts.json');
|
|
230
|
+
if (!existsSync(configPath)) return null;
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Singleton ────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function createBroker() {
|
|
241
|
+
const config = loadConfig();
|
|
242
|
+
if (!config) return null;
|
|
243
|
+
try {
|
|
244
|
+
return new AccountBroker(config);
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const broker = createBroker();
|
|
251
|
+
export { AccountBroker };
|