@triflux/core 10.51.0 → 10.53.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.
Files changed (61) hide show
  1. package/hub/bridge.mjs +69 -1197
  2. package/hub/cli-adapter-base.mjs +4 -104
  3. package/hub/codex-adapter.mjs +7 -116
  4. package/hub/index.mjs +3 -20
  5. package/hub/lib/process-utils.mjs +1 -33
  6. package/hub/team/claude-daemon-control.mjs +1 -2
  7. package/hub/team/claude-session-projection.mjs +5 -73
  8. package/hub/team/claude-transcript.mjs +19 -0
  9. package/hub/team/nativeProxy.mjs +759 -0
  10. package/hub/team/session-context.mjs +1 -1
  11. package/hud/constants.mjs +9 -22
  12. package/hud/hud-qos-status.mjs +16 -19
  13. package/hud/providers/codex-probe.mjs +2 -31
  14. package/hud/providers/codex.mjs +16 -58
  15. package/hud/providers/gemini.mjs +20 -0
  16. package/hud/renderers.mjs +101 -77
  17. package/hud/utils.mjs +9 -13
  18. package/package.json +3 -6
  19. package/scripts/lib/agent-route-policy.mjs +0 -67
  20. package/scripts/lib/cli-claude.mjs +0 -12
  21. package/scripts/lib/env-probe.mjs +0 -70
  22. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  23. package/scripts/lib/mcp-guard-engine.mjs +42 -101
  24. package/scripts/lib/session-presence.mjs +1 -99
  25. package/hub/account-broker.mjs +0 -1221
  26. package/hub/adaptive-diagnostic.mjs +0 -350
  27. package/hub/adaptive-inject.mjs +0 -224
  28. package/hub/adaptive-memory.mjs +0 -277
  29. package/hub/adaptive.mjs +0 -172
  30. package/hub/assign-callbacks.mjs +0 -170
  31. package/hub/delegator/contracts.mjs +0 -38
  32. package/hub/delegator/index.mjs +0 -14
  33. package/hub/delegator/schema/delegator-tools.schema.json +0 -250
  34. package/hub/delegator/service.mjs +0 -336
  35. package/hub/delegator/tool-definitions.mjs +0 -35
  36. package/hub/hitl.mjs +0 -202
  37. package/hub/lib/known-errors.json +0 -72
  38. package/hub/lib/memory-store.mjs +0 -1001
  39. package/hub/lib/phase-manager.mjs +0 -493
  40. package/hub/lib/state-snapshot.mjs +0 -293
  41. package/hub/lib/trace-recorder.mjs +0 -153
  42. package/hub/lib/uuidv7.mjs +0 -45
  43. package/hub/memory-doctor.mjs +0 -640
  44. package/hub/middleware/quota-middleware.mjs +0 -70
  45. package/hub/middleware/request-logger.mjs +0 -191
  46. package/hub/paths.mjs +0 -24
  47. package/hub/pipeline/index.mjs +0 -163
  48. package/hub/pipeline/state.mjs +0 -191
  49. package/hub/pipeline/transitions.mjs +0 -131
  50. package/hub/reflexion.mjs +0 -290
  51. package/hub/role-contract.mjs +0 -312
  52. package/hub/router.mjs +0 -2026
  53. package/hub/session-fingerprint.mjs +0 -418
  54. package/hub/state.mjs +0 -300
  55. package/hub/team/synapse-http.mjs +0 -311
  56. package/hub/team-bridge.mjs +0 -27
  57. package/scripts/lib/claudemd-scanner.mjs +0 -26
  58. package/scripts/lib/context.mjs +0 -51
  59. package/scripts/lib/handoff.mjs +0 -187
  60. package/scripts/lib/logger.mjs +0 -122
  61. package/scripts/lib/stealth-fetch.mjs +0 -176
@@ -1,1221 +0,0 @@
1
- // hub/account-broker.mjs — Multi-account CLI pool broker
2
- // Manages lease/release/cooldown/circuit-breaker for Codex and Gemini accounts.
3
- // Per-account circuit breaker: one bad account does not block others.
4
- // Singleton export. All state changes create new objects (immutable pattern).
5
-
6
- import { EventEmitter } from "node:events";
7
- import {
8
- chmodSync,
9
- closeSync,
10
- existsSync,
11
- constants as fsConstants,
12
- mkdirSync,
13
- openSync,
14
- readFileSync,
15
- renameSync,
16
- statSync,
17
- unlinkSync,
18
- writeFileSync,
19
- } from "node:fs";
20
- import { homedir } from "node:os";
21
- import { dirname, join } from "node:path";
22
- import * as z from "zod";
23
-
24
- import { isPathWithin } from "./platform.mjs";
25
-
26
- // ── Zod schema ───────────────────────────────────────────────────
27
-
28
- const AccountSchema = z
29
- .object({
30
- id: z.string().min(1),
31
- mode: z.enum(["profile", "env", "auth"]),
32
- profile: z.string().optional(),
33
- env: z.record(z.string(), z.string()).optional(),
34
- authFile: z.string().optional(),
35
- host: z.string().min(1).optional(),
36
- tier: z
37
- .enum(["pro", "plus", "free", "unknown"])
38
- .optional()
39
- .default("unknown"),
40
- })
41
- .superRefine((val, ctx) => {
42
- if (val.mode === "auth" && !val.authFile) {
43
- ctx.addIssue({
44
- code: z.ZodIssueCode.custom,
45
- message: 'authFile is required when mode is "auth"',
46
- path: ["authFile"],
47
- });
48
- }
49
- });
50
-
51
- const ConfigSchema = z.object({
52
- defaults: z
53
- .object({
54
- cooldownMs: z.number().int().positive().optional(),
55
- })
56
- .optional(),
57
- codex: z.array(AccountSchema).optional(),
58
- gemini: z.array(AccountSchema).optional(),
59
- });
60
-
61
- const DEFAULT_COOLDOWN_MS = 300_000; // 5 minutes
62
- const _QUOTA_COOLDOWN_MS = {
63
- codex: 5 * 60 * 60_000, // 5 hours (단기 쿼터)
64
- codex_weekly: 7 * 24 * 60 * 60_000, // 7 days (주간 쿼터)
65
- gemini: 24 * 60 * 60_000, // 24 hours
66
- };
67
- const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
68
- const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
69
- const CIRCUIT_WINDOW_MS = 10 * 60_000; // 10 minutes
70
- const CIRCUIT_MAX_FAILURES = 3;
71
- function getAuthBasePath() {
72
- return join(homedir(), ".claude", "cache", "tfx-hub");
73
- }
74
-
75
- function getCodexAuthSourcePath() {
76
- return join(homedir(), ".codex", "auth.json");
77
- }
78
-
79
- function getStatePersistPath() {
80
- return join(getAuthBasePath(), "broker-state.json");
81
- }
82
- const AUTH_SYNC_LOCK_TIMEOUT_MS = 5_000;
83
- const AUTH_SYNC_LOCK_RETRY_MS = 25;
84
- const AUTH_SYNC_LOCK_STALE_MS = 30_000;
85
- const AUTH_SYNC_SAB = new Int32Array(new SharedArrayBuffer(4));
86
-
87
- // ── State persistence ────────────────────────────────────────────
88
-
89
- function persistState(stateMap) {
90
- try {
91
- const authBasePath = getAuthBasePath();
92
- const statePersistPath = getStatePersistPath();
93
- const now = Date.now();
94
- const entries = {};
95
- for (const [id, acct] of stateMap) {
96
- // 활성 쿨다운 또는 circuit open만 저장 (불필요한 데이터 제거)
97
- if (
98
- acct.cooldownUntil > now ||
99
- acct.circuitOpenedAt > 0 ||
100
- acct.totalSessions > 0
101
- ) {
102
- entries[id] = {
103
- cooldownUntil: acct.cooldownUntil,
104
- circuitOpenedAt: acct.circuitOpenedAt,
105
- failureTimestamps: acct.failureTimestamps,
106
- totalSessions: acct.totalSessions,
107
- lastUsedAt: acct.lastUsedAt,
108
- };
109
- }
110
- }
111
- mkdirSync(authBasePath, { recursive: true });
112
- writeFileSync(statePersistPath, JSON.stringify({ ts: now, entries }));
113
- } catch (err) {
114
- try {
115
- console.error("[account-broker] persistState failed:", err.message);
116
- } catch {}
117
- }
118
- }
119
-
120
- function loadPersistedState() {
121
- try {
122
- const statePersistPath = getStatePersistPath();
123
- if (!existsSync(statePersistPath)) return null;
124
- return JSON.parse(readFileSync(statePersistPath, "utf8"));
125
- } catch {
126
- return null;
127
- }
128
- }
129
-
130
- // ── env var resolution ───────────────────────────────────────────
131
-
132
- function resolveEnvValues(env) {
133
- if (!env) return undefined;
134
- const resolved = {};
135
- for (const [key, value] of Object.entries(env)) {
136
- if (typeof value === "string" && value.startsWith("$")) {
137
- const varName = value.slice(1);
138
- resolved[key] = process.env[varName] ?? "";
139
- } else {
140
- resolved[key] = value;
141
- }
142
- }
143
- return resolved;
144
- }
145
-
146
- function isRemoteAccount(account) {
147
- return Boolean(account.host);
148
- }
149
-
150
- function getRemainingLeaseMs(account, now) {
151
- if (!account.busy || account.leasedAt === null) return 0;
152
- return Math.max(0, LEASE_TTL_MS - (now - account.leasedAt));
153
- }
154
-
155
- function sleepSync(ms) {
156
- if (!Number.isFinite(ms) || ms <= 0) return;
157
- Atomics.wait(AUTH_SYNC_SAB, 0, 0, ms);
158
- }
159
-
160
- function statOrNull(filePath) {
161
- try {
162
- return statSync(filePath);
163
- } catch (error) {
164
- if (error?.code === "ENOENT") return null;
165
- throw error;
166
- }
167
- }
168
-
169
- function readAuthPayload(filePath) {
170
- const buffer = readFileSync(filePath);
171
- const parsed = JSON.parse(buffer.toString("utf8"));
172
- return {
173
- buffer,
174
- parsed,
175
- accountId:
176
- parsed?.tokens?.account_id ??
177
- parsed?.account_id ??
178
- parsed?.accountId ??
179
- null,
180
- };
181
- }
182
-
183
- function createSyncResult({
184
- accountId,
185
- direction,
186
- sourcePath,
187
- cachePath,
188
- runtimePath,
189
- copied = false,
190
- skipped = false,
191
- reason = "ok",
192
- }) {
193
- return {
194
- ok: !skipped || reason === "up_to_date",
195
- accountId,
196
- direction,
197
- sourcePath,
198
- cachePath,
199
- runtimePath,
200
- copied,
201
- skipped,
202
- reason,
203
- };
204
- }
205
-
206
- function withLockFile(lockPath, opts, task) {
207
- const retryMs = opts.retryMs ?? AUTH_SYNC_LOCK_RETRY_MS;
208
- const timeoutMs = opts.timeoutMs ?? AUTH_SYNC_LOCK_TIMEOUT_MS;
209
- const staleMs = opts.staleMs ?? AUTH_SYNC_LOCK_STALE_MS;
210
- const start = Date.now();
211
-
212
- while (true) {
213
- let fd;
214
- try {
215
- mkdirSync(dirname(lockPath), { recursive: true });
216
- fd = openSync(
217
- lockPath,
218
- fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
219
- 0o600,
220
- );
221
- writeFileSync(fd, `${process.pid}\n${Date.now()}`, "utf8");
222
- try {
223
- return task();
224
- } finally {
225
- closeSync(fd);
226
- try {
227
- unlinkSync(lockPath);
228
- } catch {}
229
- }
230
- } catch (error) {
231
- if (fd !== undefined) {
232
- try {
233
- closeSync(fd);
234
- } catch {}
235
- }
236
- if (error?.code !== "EEXIST") throw error;
237
-
238
- const lockStat = statOrNull(lockPath);
239
- if (lockStat && Date.now() - lockStat.mtimeMs > staleMs) {
240
- try {
241
- unlinkSync(lockPath);
242
- continue;
243
- } catch {}
244
- }
245
-
246
- if (Date.now() - start >= timeoutMs) {
247
- return { lockTimeout: true };
248
- }
249
-
250
- sleepSync(retryMs);
251
- }
252
- }
253
- }
254
-
255
- // ── AccountBroker ────────────────────────────────────────────────
256
-
257
- /**
258
- * AccountBroker는 codex/gemini 계정 풀 + lease/cooldown/circuit breaker 를 관리한다.
259
- *
260
- * **Resolved-at-construction 정책 (lazy/eager mix 주의)**
261
- *
262
- * `getAuthBasePath()` / `getCodexAuthSourcePath()` 는 호출 시점에 `homedir()` 를
263
- * 다시 평가하는 lazy getter 다. 그러나 default 파라미터 (`_authBasePath = getAuthBasePath()`)
264
- * 는 생성자 호출 시점에 한 번만 평가되고 결과가 `#authBasePath` 등 private field 에 박힌다.
265
- *
266
- * 결과: 생성자 이후 process.env.HOME 변경은 broker 가 보는 경로에 전파되지 않는다.
267
- * 테스트에서 HOME 을 갈아끼우려면 `new AccountBroker(config, { _authBasePath, _codexAuthSourcePath })`
268
- * 로 오버라이드를 직접 주입하거나, broker 자체를 재생성해야 한다.
269
- */
270
- class AccountBroker extends EventEmitter {
271
- #config;
272
- #state; // Map<accountId, accountState>
273
- #roundRobinIndex; // Map<provider, number>
274
- #persist; // boolean — disable persistence for tests
275
- #authBasePath;
276
- #codexAuthSourcePath;
277
- #authSyncLockOpts;
278
- #syncCopyDelayMs;
279
-
280
- constructor(
281
- config,
282
- {
283
- _skipPersistence = false,
284
- _authBasePath = getAuthBasePath(),
285
- _codexAuthSourcePath = getCodexAuthSourcePath(),
286
- _authSyncLockTimeoutMs = AUTH_SYNC_LOCK_TIMEOUT_MS,
287
- _authSyncLockRetryMs = AUTH_SYNC_LOCK_RETRY_MS,
288
- _authSyncLockStaleMs = AUTH_SYNC_LOCK_STALE_MS,
289
- _syncCopyDelayMs = 0,
290
- _activeLeases = [],
291
- } = {},
292
- ) {
293
- super();
294
- const parsed = ConfigSchema.parse(config);
295
- this.#config = parsed;
296
- this.#persist = !_skipPersistence;
297
- this.#authBasePath = _authBasePath;
298
- this.#codexAuthSourcePath = _codexAuthSourcePath;
299
- this.#authSyncLockOpts = {
300
- timeoutMs: _authSyncLockTimeoutMs,
301
- retryMs: _authSyncLockRetryMs,
302
- staleMs: _authSyncLockStaleMs,
303
- };
304
- this.#syncCopyDelayMs = _syncCopyDelayMs;
305
-
306
- this.#state = new Map();
307
- this.#roundRobinIndex = new Map();
308
-
309
- const allAccounts = [
310
- ...(parsed.codex || []).map((a) => ({ ...a, provider: "codex" })),
311
- ...(parsed.gemini || []).map((a) => ({ ...a, provider: "gemini" })),
312
- ];
313
-
314
- const persisted = this.#persist ? loadPersistedState() : null;
315
- const pEntries = persisted?.entries || {};
316
-
317
- const activeLeaseById = new Map(
318
- Array.isArray(_activeLeases)
319
- ? _activeLeases
320
- .filter((lease) => lease?.id)
321
- .map((lease) => [lease.id, lease])
322
- : [],
323
- );
324
-
325
- for (const account of allAccounts) {
326
- const saved = pEntries[account.id];
327
- const activeLease = activeLeaseById.get(account.id);
328
- this.#state.set(account.id, {
329
- id: account.id,
330
- provider: account.provider,
331
- mode: account.mode,
332
- profile: account.profile,
333
- env: account.env,
334
- authFile: account.authFile,
335
- host: account.host,
336
- tier: account.tier ?? "unknown",
337
- busy: activeLease?.busy === true,
338
- leasedAt: activeLease?.leasedAt ?? null,
339
- cooldownUntil: saved?.cooldownUntil ?? 0,
340
- failureTimestamps: saved?.failureTimestamps ?? [],
341
- circuitOpenedAt: saved?.circuitOpenedAt ?? 0,
342
- circuitTrialInFlight: activeLease?.circuitTrialInFlight === true,
343
- lastUsedAt: activeLease?.lastUsedAt ?? saved?.lastUsedAt ?? 0,
344
- totalSessions: Math.max(
345
- saved?.totalSessions ?? 0,
346
- activeLease?.totalSessions ?? 0,
347
- ),
348
- });
349
- }
350
- }
351
-
352
- #resolveCacheAuthPath(acct) {
353
- if (!acct?.authFile) return null;
354
- const resolved = join(this.#authBasePath, acct.authFile);
355
- if (
356
- !isPathWithin(resolved, this.#authBasePath) ||
357
- resolved === this.#authBasePath
358
- ) {
359
- return null;
360
- }
361
- return resolved;
362
- }
363
-
364
- #getSourceAuthPath(acct) {
365
- if (!acct || acct.mode !== "auth") return null;
366
- if (acct.provider === "codex") return this.#codexAuthSourcePath;
367
- return null;
368
- }
369
-
370
- #getLockPath(acct) {
371
- if (acct?.provider === "codex" && acct?.mode === "auth") {
372
- return join(this.#authBasePath, "codex-auth-source.lock");
373
- }
374
- return join(this.#authBasePath, `auth-sync-${acct.id}.lock`);
375
- }
376
-
377
- #copyAuthPayload(sourcePath, destPath) {
378
- const payload = readFileSync(sourcePath);
379
- if (this.#syncCopyDelayMs > 0) {
380
- sleepSync(this.#syncCopyDelayMs);
381
- }
382
- mkdirSync(dirname(destPath), { recursive: true });
383
- const tmpPath = join(
384
- dirname(destPath),
385
- `.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
386
- );
387
- try {
388
- writeFileSync(tmpPath, payload, { mode: 0o600 });
389
- renameSync(tmpPath, destPath);
390
- try {
391
- chmodSync(destPath, 0o600);
392
- } catch {}
393
- } catch (error) {
394
- try {
395
- unlinkSync(tmpPath);
396
- } catch {}
397
- throw error;
398
- }
399
- }
400
-
401
- #runtimeAuthPath(acct) {
402
- const safeId = String(acct.id).replace(/[^a-zA-Z0-9._-]/g, "_");
403
- return join(this.#authBasePath, "codex-home", safeId, "auth.json");
404
- }
405
-
406
- #prepareRuntimeAuthFile(acct, cachePath) {
407
- const runtimePath = this.#runtimeAuthPath(acct);
408
- try {
409
- const cacheAuth = readAuthPayload(cachePath);
410
- if (!cacheAuth.accountId) {
411
- return {
412
- ok: false,
413
- reason: "cache_account_unknown",
414
- authFile: runtimePath,
415
- };
416
- }
417
- if (cacheAuth.accountId !== acct.id) {
418
- return {
419
- ok: false,
420
- reason: "cache_account_mismatch",
421
- authFile: runtimePath,
422
- };
423
- }
424
- this.#copyAuthPayload(cachePath, runtimePath);
425
- return {
426
- ok: true,
427
- reason: "runtime_auth_prepared",
428
- authFile: runtimePath,
429
- };
430
- } catch (error) {
431
- return {
432
- ok: false,
433
- reason:
434
- error?.code === "ENOENT" ? "cache_missing" : "runtime_auth_error",
435
- authFile: runtimePath,
436
- };
437
- }
438
- }
439
-
440
- #syncAuth(accountId, direction) {
441
- const acct = this.#state.get(accountId);
442
- const sourcePath = this.#getSourceAuthPath(acct);
443
- const cachePath = this.#resolveCacheAuthPath(acct);
444
- const runtimePath = acct ? this.#runtimeAuthPath(acct) : null;
445
-
446
- if (!acct || acct.mode !== "auth" || acct.provider !== "codex") {
447
- return createSyncResult({
448
- accountId,
449
- direction,
450
- sourcePath,
451
- cachePath,
452
- runtimePath,
453
- skipped: true,
454
- reason: "unsupported_account",
455
- });
456
- }
457
-
458
- if (!cachePath) {
459
- this.emit("securityViolation", {
460
- id: acct.id,
461
- authFile: acct.authFile,
462
- });
463
- return createSyncResult({
464
- accountId,
465
- direction,
466
- sourcePath,
467
- cachePath,
468
- runtimePath,
469
- skipped: true,
470
- reason: "invalid_cache_path",
471
- });
472
- }
473
-
474
- if (!sourcePath) {
475
- return createSyncResult({
476
- accountId,
477
- direction,
478
- sourcePath,
479
- cachePath,
480
- runtimePath,
481
- skipped: true,
482
- reason: "unsupported_source",
483
- });
484
- }
485
-
486
- const locked = withLockFile(
487
- this.#getLockPath(acct),
488
- this.#authSyncLockOpts,
489
- () => {
490
- try {
491
- if (direction === "runtime-to-source") {
492
- const runtimeStat = statOrNull(runtimePath);
493
- if (!runtimeStat) {
494
- return createSyncResult({
495
- accountId,
496
- direction,
497
- sourcePath,
498
- cachePath,
499
- runtimePath,
500
- skipped: true,
501
- reason: "runtime_missing",
502
- });
503
- }
504
-
505
- const runtimeAuth = readAuthPayload(runtimePath);
506
- if (!runtimeAuth.accountId) {
507
- return createSyncResult({
508
- accountId,
509
- direction,
510
- sourcePath,
511
- cachePath,
512
- runtimePath,
513
- skipped: true,
514
- reason: "runtime_account_unknown",
515
- });
516
- }
517
- if (runtimeAuth.accountId !== accountId) {
518
- return createSyncResult({
519
- accountId,
520
- direction,
521
- sourcePath,
522
- cachePath,
523
- runtimePath,
524
- skipped: true,
525
- reason: "runtime_account_mismatch",
526
- });
527
- }
528
-
529
- const cacheStat = statOrNull(cachePath);
530
- let shouldCopyRuntimeToCache = !cacheStat;
531
- if (cacheStat) {
532
- const cacheAuth = readAuthPayload(cachePath);
533
- if (!cacheAuth.accountId) {
534
- return createSyncResult({
535
- accountId,
536
- direction,
537
- sourcePath,
538
- cachePath,
539
- runtimePath,
540
- skipped: true,
541
- reason: "cache_account_unknown",
542
- });
543
- }
544
- if (cacheAuth.accountId !== accountId) {
545
- return createSyncResult({
546
- accountId,
547
- direction,
548
- sourcePath,
549
- cachePath,
550
- runtimePath,
551
- skipped: true,
552
- reason: "cache_account_mismatch",
553
- });
554
- }
555
- shouldCopyRuntimeToCache =
556
- runtimeStat.mtimeMs > cacheStat.mtimeMs ||
557
- !runtimeAuth.buffer.equals(cacheAuth.buffer);
558
- }
559
-
560
- let copied = false;
561
- if (shouldCopyRuntimeToCache) {
562
- this.#copyAuthPayload(runtimePath, cachePath);
563
- copied = true;
564
- }
565
-
566
- const nextCacheStat = statOrNull(cachePath);
567
- const nextCacheAuth = readAuthPayload(cachePath);
568
- const sourceStat = statOrNull(sourcePath);
569
- let shouldCopyCacheToSource = !sourceStat;
570
- if (sourceStat) {
571
- const sourceAuth = readAuthPayload(sourcePath);
572
- shouldCopyCacheToSource =
573
- sourceAuth.accountId !== accountId ||
574
- nextCacheStat.mtimeMs > sourceStat.mtimeMs ||
575
- !nextCacheAuth.buffer.equals(sourceAuth.buffer);
576
- }
577
- if (shouldCopyCacheToSource) {
578
- this.#copyAuthPayload(cachePath, sourcePath);
579
- copied = true;
580
- }
581
-
582
- return createSyncResult({
583
- accountId,
584
- direction,
585
- sourcePath,
586
- cachePath,
587
- runtimePath,
588
- copied,
589
- skipped: !copied,
590
- reason: copied ? "runtime_propagated" : "up_to_date",
591
- });
592
- }
593
-
594
- if (direction === "from-source") {
595
- const sourceStat = statOrNull(sourcePath);
596
- if (!sourceStat) {
597
- return createSyncResult({
598
- accountId,
599
- direction,
600
- sourcePath,
601
- cachePath,
602
- runtimePath,
603
- skipped: true,
604
- reason: "source_missing",
605
- });
606
- }
607
-
608
- const sourceAuth = readAuthPayload(sourcePath);
609
- if (!sourceAuth.accountId) {
610
- return createSyncResult({
611
- accountId,
612
- direction,
613
- sourcePath,
614
- cachePath,
615
- runtimePath,
616
- skipped: true,
617
- reason: "source_account_unknown",
618
- });
619
- }
620
- if (sourceAuth.accountId !== accountId) {
621
- return createSyncResult({
622
- accountId,
623
- direction,
624
- sourcePath,
625
- cachePath,
626
- runtimePath,
627
- skipped: true,
628
- reason: "source_account_mismatch",
629
- });
630
- }
631
-
632
- const cacheStat = statOrNull(cachePath);
633
- if (cacheStat) {
634
- const cacheAuth = readAuthPayload(cachePath);
635
- if (!cacheAuth.accountId) {
636
- return createSyncResult({
637
- accountId,
638
- direction,
639
- sourcePath,
640
- cachePath,
641
- runtimePath,
642
- skipped: true,
643
- reason: "cache_account_unknown",
644
- });
645
- }
646
- if (cacheAuth.accountId !== accountId) {
647
- return createSyncResult({
648
- accountId,
649
- direction,
650
- sourcePath,
651
- cachePath,
652
- runtimePath,
653
- skipped: true,
654
- reason: "cache_account_mismatch",
655
- });
656
- }
657
- }
658
-
659
- if (cacheStat && sourceStat.mtimeMs <= cacheStat.mtimeMs) {
660
- return createSyncResult({
661
- accountId,
662
- direction,
663
- sourcePath,
664
- cachePath,
665
- runtimePath,
666
- skipped: true,
667
- reason: "up_to_date",
668
- });
669
- }
670
-
671
- this.#copyAuthPayload(sourcePath, cachePath);
672
- return createSyncResult({
673
- accountId,
674
- direction,
675
- sourcePath,
676
- cachePath,
677
- runtimePath,
678
- copied: true,
679
- reason: cacheStat ? "cache_updated" : "cache_created",
680
- });
681
- }
682
-
683
- const cacheStat = statOrNull(cachePath);
684
- if (!cacheStat) {
685
- return createSyncResult({
686
- accountId,
687
- direction,
688
- sourcePath,
689
- cachePath,
690
- runtimePath,
691
- skipped: true,
692
- reason: "cache_missing",
693
- });
694
- }
695
-
696
- const cacheAuth = readAuthPayload(cachePath);
697
- if (!cacheAuth.accountId) {
698
- return createSyncResult({
699
- accountId,
700
- direction,
701
- sourcePath,
702
- cachePath,
703
- runtimePath,
704
- skipped: true,
705
- reason: "cache_account_unknown",
706
- });
707
- }
708
- if (cacheAuth.accountId !== accountId) {
709
- return createSyncResult({
710
- accountId,
711
- direction,
712
- sourcePath,
713
- cachePath,
714
- runtimePath,
715
- skipped: true,
716
- reason: "cache_account_mismatch",
717
- });
718
- }
719
-
720
- const sourceStat = statOrNull(sourcePath);
721
- if (sourceStat && cacheStat.mtimeMs <= sourceStat.mtimeMs) {
722
- return createSyncResult({
723
- accountId,
724
- direction,
725
- sourcePath,
726
- cachePath,
727
- runtimePath,
728
- skipped: true,
729
- reason: "up_to_date",
730
- });
731
- }
732
-
733
- this.#copyAuthPayload(cachePath, sourcePath);
734
- return createSyncResult({
735
- accountId,
736
- direction,
737
- sourcePath,
738
- cachePath,
739
- runtimePath,
740
- copied: true,
741
- reason: sourceStat ? "source_updated" : "source_created",
742
- });
743
- } catch (error) {
744
- return createSyncResult({
745
- accountId,
746
- direction,
747
- sourcePath,
748
- cachePath,
749
- runtimePath,
750
- skipped: true,
751
- reason: error?.code === "ENOENT" ? "file_missing" : "read_error",
752
- });
753
- }
754
- },
755
- );
756
-
757
- if (locked?.lockTimeout) {
758
- return createSyncResult({
759
- accountId,
760
- direction,
761
- sourcePath,
762
- cachePath,
763
- runtimePath,
764
- skipped: true,
765
- reason: "lock_timeout",
766
- });
767
- }
768
-
769
- return locked;
770
- }
771
-
772
- syncAuthFromSource(accountId) {
773
- return this.#syncAuth(accountId, "from-source");
774
- }
775
-
776
- syncAuthToSource(accountId) {
777
- return this.#syncAuth(accountId, "to-source");
778
- }
779
-
780
- syncRuntimeAuthToSource(accountId) {
781
- return this.#syncAuth(accountId, "runtime-to-source");
782
- }
783
-
784
- // ── per-account circuit breaker ─────────────────────────────────
785
-
786
- #getCircuitState(acct, now) {
787
- const validFailures = acct.failureTimestamps.filter(
788
- (ts) => now - ts < CIRCUIT_WINDOW_MS,
789
- );
790
- const withinWindow =
791
- acct.circuitOpenedAt && now - acct.circuitOpenedAt < CIRCUIT_WINDOW_MS;
792
- if (withinWindow) return { state: "open", failures: validFailures };
793
- if (acct.circuitOpenedAt)
794
- return { state: "half-open", failures: validFailures };
795
- return { state: "closed", failures: validFailures };
796
- }
797
-
798
- #isCircuitBlocked(acct, now) {
799
- const circuit = this.#getCircuitState(acct, now);
800
- if (circuit.state === "open") return true;
801
- if (circuit.state === "half-open" && acct.circuitTrialInFlight) return true;
802
- return false;
803
- }
804
-
805
- #recordCircuitFailure(acct, isHalfOpen, now) {
806
- const validFailures = [
807
- ...acct.failureTimestamps.filter((ts) => now - ts < CIRCUIT_WINDOW_MS),
808
- now,
809
- ];
810
- const shouldOpen =
811
- isHalfOpen || validFailures.length >= CIRCUIT_MAX_FAILURES;
812
- return {
813
- failureTimestamps: validFailures,
814
- circuitOpenedAt: shouldOpen ? now : acct.circuitOpenedAt,
815
- circuitTrialInFlight: false,
816
- };
817
- }
818
-
819
- #resetCircuit() {
820
- return {
821
- failureTimestamps: [],
822
- circuitOpenedAt: 0,
823
- circuitTrialInFlight: false,
824
- };
825
- }
826
-
827
- // ── lease TTL pruning ──────────────────────────────────────────
828
-
829
- #pruneExpiredLeases(now) {
830
- for (const [id, acct] of this.#state) {
831
- if (
832
- acct.busy &&
833
- acct.leasedAt !== null &&
834
- now - acct.leasedAt > LEASE_TTL_MS
835
- ) {
836
- this.#state.set(id, { ...acct, busy: false, leasedAt: null });
837
- }
838
- }
839
- }
840
-
841
- // ── lease ─────────────────────────────────────────────────────
842
-
843
- lease({ provider, remote = false, autoSync = true } = {}) {
844
- const now = Date.now();
845
- this.#pruneExpiredLeases(now);
846
-
847
- const wantsRemote = remote === true;
848
- const accounts = [...this.#state.values()].filter(
849
- (a) => a.provider === provider && isRemoteAccount(a) === wantsRemote,
850
- );
851
- if (!accounts.length) return null;
852
-
853
- // filter: not busy, not in cooldown, circuit not blocked
854
- const available = accounts.filter(
855
- (a) =>
856
- !a.busy && a.cooldownUntil <= now && !this.#isCircuitBlocked(a, now),
857
- );
858
-
859
- if (!available.length) {
860
- // check if any accounts exist but all are blocked by circuit
861
- const circuitBlocked = accounts.filter(
862
- (a) =>
863
- !a.busy && a.cooldownUntil <= now && this.#isCircuitBlocked(a, now),
864
- );
865
- if (circuitBlocked.length) {
866
- this.emit("noAvailableAccounts", {
867
- provider,
868
- count: circuitBlocked.length,
869
- });
870
- }
871
- return null;
872
- }
873
-
874
- // sort by tier priority; stable sort preserves original order within same priority
875
- const sorted = [...available].sort(
876
- (a, b) => (TIER_PRIORITY[a.tier] ?? 2) - (TIER_PRIORITY[b.tier] ?? 2),
877
- );
878
-
879
- // pick the best tier, then apply round-robin within that tier's accounts
880
- const bestTier = sorted[0].tier;
881
- const sameTierAccounts = sorted.filter((a) => a.tier === bestTier);
882
-
883
- // detect tier fallback
884
- const highestTier = accounts.reduce(
885
- (best, a) => Math.min(best, TIER_PRIORITY[a.tier] ?? 2),
886
- Infinity,
887
- );
888
- if ((TIER_PRIORITY[bestTier] ?? 2) > highestTier) {
889
- this.emit("tierFallback", {
890
- provider,
891
- from: Object.entries(TIER_PRIORITY).find(
892
- ([, v]) => v === highestTier,
893
- )?.[0],
894
- to: bestTier,
895
- });
896
- }
897
-
898
- // use a per-provider+tier round-robin key to distribute within the tier
899
- const rrKey = `${provider}:${bestTier}:${wantsRemote ? "remote" : "local"}`;
900
- const rrCurrent = this.#roundRobinIndex.get(rrKey) ?? 0;
901
- const tierCount = sameTierAccounts.length;
902
- const idx = rrCurrent % tierCount;
903
- const acct = sameTierAccounts[idx];
904
-
905
- // advance round-robin index for this tier
906
- this.#roundRobinIndex.set(rrKey, (idx + 1) % tierCount);
907
-
908
- let authFile;
909
- if (acct.mode === "auth") {
910
- const resolved = this.#resolveCacheAuthPath(acct);
911
- if (!resolved) {
912
- this.emit("securityViolation", {
913
- id: acct.id,
914
- authFile: acct.authFile,
915
- });
916
- return null;
917
- }
918
- authFile = resolved;
919
- if (autoSync !== false) {
920
- try {
921
- const syncResult = this.syncAuthFromSource(acct.id);
922
- this.emit("authSync", syncResult);
923
- if (!syncResult?.ok) {
924
- this.emit("authSyncError", {
925
- accountId: acct.id,
926
- direction: "from-source",
927
- reason: syncResult?.reason || "sync_failed",
928
- });
929
- return null;
930
- }
931
- } catch (error) {
932
- this.emit("authSyncError", {
933
- accountId: acct.id,
934
- direction: "from-source",
935
- error: error?.message || String(error),
936
- });
937
- return null;
938
- }
939
- }
940
- const runtimeAuth = this.#prepareRuntimeAuthFile(acct, authFile);
941
- if (!runtimeAuth.ok) {
942
- this.emit("authSyncError", {
943
- accountId: acct.id,
944
- direction: "runtime-auth",
945
- reason: runtimeAuth.reason,
946
- });
947
- return null;
948
- }
949
- authFile = runtimeAuth.authFile;
950
- }
951
-
952
- // mark half-open trial if applicable
953
- const circuit = this.#getCircuitState(acct, now);
954
- const isHalfOpen = circuit.state === "half-open";
955
-
956
- // update state (immutable)
957
- this.#state.set(acct.id, {
958
- ...acct,
959
- busy: true,
960
- leasedAt: now,
961
- lastUsedAt: now,
962
- totalSessions: acct.totalSessions + 1,
963
- circuitTrialInFlight: isHalfOpen ? true : acct.circuitTrialInFlight,
964
- });
965
-
966
- this.emit("lease", {
967
- id: acct.id,
968
- provider,
969
- tier: acct.tier,
970
- halfOpen: isHalfOpen,
971
- });
972
-
973
- return {
974
- id: acct.id,
975
- mode: acct.mode,
976
- remote: isRemoteAccount(acct),
977
- host: acct.host,
978
- halfOpen: isHalfOpen,
979
- profile: acct.mode === "profile" ? acct.profile : undefined,
980
- env: acct.mode === "env" ? resolveEnvValues(acct.env) : undefined,
981
- authFile,
982
- };
983
- }
984
-
985
- // ── release ───────────────────────────────────────────────────
986
-
987
- release(accountId, result) {
988
- const acct = this.#state.get(accountId);
989
- if (!acct?.busy) return;
990
-
991
- if (acct.provider === "codex" && acct.mode === "auth") {
992
- const authSync = this.syncRuntimeAuthToSource(accountId);
993
- this.emit(authSync?.ok ? "authSync" : "authSyncError", {
994
- ...authSync,
995
- accountId,
996
- });
997
- }
998
-
999
- const now = Date.now();
1000
- const ok = result?.ok === true;
1001
- const circuit = this.#getCircuitState(acct, now);
1002
- const isHalfOpen = circuit.state === "half-open";
1003
-
1004
- let circuitUpdate;
1005
- if (ok) {
1006
- circuitUpdate = this.#resetCircuit();
1007
- if (isHalfOpen) {
1008
- this.emit("circuitClose", { id: accountId });
1009
- }
1010
- } else if (result?.skipCircuit) {
1011
- // 인프라 에러 — circuit/cooldown에 카운트하지 않음
1012
- circuitUpdate = {
1013
- failureTimestamps: acct.failureTimestamps,
1014
- circuitOpenedAt: acct.circuitOpenedAt,
1015
- circuitTrialInFlight: acct.circuitTrialInFlight,
1016
- };
1017
- } else {
1018
- circuitUpdate = this.#recordCircuitFailure(acct, isHalfOpen, now);
1019
- if (circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt) {
1020
- this.emit("circuitOpen", {
1021
- id: accountId,
1022
- failures: circuitUpdate.failureTimestamps.length,
1023
- });
1024
- }
1025
- }
1026
-
1027
- const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
1028
-
1029
- // rate-limit style cooldown: if circuit just opened, also set cooldown
1030
- const shouldCooldown =
1031
- !ok && circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt;
1032
-
1033
- const updated = {
1034
- ...acct,
1035
- busy: false,
1036
- leasedAt: null,
1037
- ...circuitUpdate,
1038
- cooldownUntil: shouldCooldown ? now + cooldownMs : acct.cooldownUntil,
1039
- };
1040
-
1041
- this.#state.set(accountId, updated);
1042
- if (this.#persist) persistState(this.#state);
1043
- this.emit("release", { id: accountId, ok });
1044
- }
1045
-
1046
- // ── markRateLimited ───────────────────────────────────────────
1047
-
1048
- markRateLimited(id, coolMs) {
1049
- const acct = this.#state.get(id);
1050
- if (!acct) return;
1051
- this.#state.set(id, {
1052
- ...acct,
1053
- busy: false,
1054
- leasedAt: null,
1055
- cooldownUntil: Date.now() + coolMs,
1056
- });
1057
- if (this.#persist) persistState(this.#state);
1058
- }
1059
-
1060
- // ── snapshot ──────────────────────────────────────────────────
1061
-
1062
- snapshot() {
1063
- const now = Date.now();
1064
- this.#pruneExpiredLeases(now);
1065
- return [...this.#state.values()].map((acct) => ({
1066
- ...acct,
1067
- failureTimestamps: [...acct.failureTimestamps],
1068
- remainingMs: getRemainingLeaseMs(acct, now),
1069
- circuitState: this.#getCircuitState(acct, now).state,
1070
- }));
1071
- }
1072
-
1073
- publicSnapshot() {
1074
- const now = Date.now();
1075
- this.#pruneExpiredLeases(now);
1076
- return [...this.#state.values()].map((acct) => ({
1077
- id: acct.id,
1078
- provider: acct.provider,
1079
- tier: acct.tier ?? "unknown",
1080
- busy: acct.busy === true,
1081
- cooldownUntil: acct.cooldownUntil ?? 0,
1082
- lastUsedAt: acct.lastUsedAt ?? 0,
1083
- totalSessions: acct.totalSessions ?? 0,
1084
- remainingMs: getRemainingLeaseMs(acct, now),
1085
- circuitState: this.#getCircuitState(acct, now).state,
1086
- failureCount: Array.isArray(acct.failureTimestamps)
1087
- ? acct.failureTimestamps.length
1088
- : 0,
1089
- }));
1090
- }
1091
-
1092
- activeLeases() {
1093
- const now = Date.now();
1094
- this.#pruneExpiredLeases(now);
1095
- return [...this.#state.values()]
1096
- .filter((acct) => acct.busy && acct.leasedAt !== null)
1097
- .map((acct) => ({
1098
- id: acct.id,
1099
- provider: acct.provider,
1100
- busy: acct.busy,
1101
- leasedAt: acct.leasedAt,
1102
- lastUsedAt: acct.lastUsedAt,
1103
- totalSessions: acct.totalSessions,
1104
- circuitTrialInFlight: acct.circuitTrialInFlight,
1105
- }));
1106
- }
1107
-
1108
- // ── disabled marker ───────────────────────────────────────────
1109
-
1110
- /**
1111
- * True when the broker holds zero accounts. Used by adapters to distinguish
1112
- * "broker exists but is empty (disable env / no accounts)" from "broker has
1113
- * accounts but lease() returned null (all busy/cooldown/circuit)" — the
1114
- * former should fall back to default ~/.codex/auth.json instead of returning
1115
- * circuit_open.
1116
- */
1117
- get isDisabled() {
1118
- return this.#state.size === 0;
1119
- }
1120
-
1121
- // ── nextAvailableEta ──────────────────────────────────────────
1122
-
1123
- nextAvailableEta(provider) {
1124
- const now = Date.now();
1125
- this.#pruneExpiredLeases(now);
1126
-
1127
- const accounts = [...this.#state.values()].filter(
1128
- (a) => a.provider === provider,
1129
- );
1130
- if (!accounts.length) return null;
1131
-
1132
- // find minimum cooldownUntil among accounts that are in cooldown or busy
1133
- let earliest = null;
1134
- for (const acct of accounts) {
1135
- if (!acct.busy && acct.cooldownUntil <= now) {
1136
- // this account is available now — no ETA needed
1137
- return null;
1138
- }
1139
- const eta = acct.busy
1140
- ? (acct.leasedAt ?? now) + LEASE_TTL_MS
1141
- : acct.cooldownUntil;
1142
- if (earliest === null || eta < earliest) {
1143
- earliest = eta;
1144
- }
1145
- }
1146
- return earliest;
1147
- }
1148
- }
1149
-
1150
- // ── Config loader ────────────────────────────────────────────────
1151
-
1152
- function loadConfig() {
1153
- const configPath = join(
1154
- homedir(),
1155
- ".claude",
1156
- "cache",
1157
- "tfx-hub",
1158
- "accounts.json",
1159
- );
1160
- if (!existsSync(configPath)) return null;
1161
- try {
1162
- return JSON.parse(readFileSync(configPath, "utf8"));
1163
- } catch (err) {
1164
- console.error(
1165
- "[account-broker] Failed to parse accounts.json:",
1166
- err.message,
1167
- );
1168
- return null;
1169
- }
1170
- }
1171
-
1172
- // ── Singleton ────────────────────────────────────────────────────
1173
-
1174
- function isBrokerDisabledByEnv() {
1175
- const flag = process.env.TFX_DISABLE_ACCOUNT_BROKER;
1176
- return flag === "1" || flag === "true";
1177
- }
1178
-
1179
- function createEmptyBroker() {
1180
- return new AccountBroker({ codex: [], gemini: [] });
1181
- }
1182
-
1183
- function createBroker() {
1184
- if (isBrokerDisabledByEnv()) {
1185
- // Empty broker: lease() always returns null → callers fall back to default
1186
- // single-account path (~/.codex/auth.json). Avoids per-account multiplexing
1187
- // race conditions while keeping the AccountBroker API surface intact.
1188
- return createEmptyBroker();
1189
- }
1190
- const config = loadConfig();
1191
- if (!config) return null;
1192
- try {
1193
- return new AccountBroker(config);
1194
- } catch (err) {
1195
- console.error("[account-broker] Failed to create broker:", err.message);
1196
- return null;
1197
- }
1198
- }
1199
-
1200
- /** Re-read config and replace the module-level singleton. ESM live binding propagates to all importers. */
1201
- function reloadBroker() {
1202
- const activeLeases =
1203
- broker && typeof broker.activeLeases === "function"
1204
- ? broker.activeLeases()
1205
- : [];
1206
- if (isBrokerDisabledByEnv()) {
1207
- broker = createEmptyBroker();
1208
- return { ok: true, broker, disabled: true };
1209
- }
1210
- const config = loadConfig();
1211
- if (!config) return { ok: false, error: "Config not found or invalid" };
1212
- try {
1213
- broker = new AccountBroker(config, { _activeLeases: activeLeases });
1214
- return { ok: true, broker };
1215
- } catch (err) {
1216
- return { ok: false, error: err.message };
1217
- }
1218
- }
1219
-
1220
- export let broker = createBroker();
1221
- export { AccountBroker, reloadBroker };