@triflux/core 10.1.0 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +254 -33
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +831 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -18
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +167 -38
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +285 -111
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +87 -13
  75. package/hub/research.mjs +33 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +127 -61
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +72 -71
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +391 -247
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +70 -10
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +32 -7
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -1,43 +1,131 @@
1
1
  // hub/account-broker.mjs — Multi-account CLI pool broker
2
- // Manages lease/release/cooldown for Codex and Gemini accounts.
2
+ // Manages lease/release/cooldown/circuit-breaker for Codex and Gemini accounts.
3
+ // Per-account circuit breaker: one bad account does not block others.
3
4
  // Singleton export. All state changes create new objects (immutable pattern).
4
5
 
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';
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";
9
25
 
10
26
  // ── Zod schema ───────────────────────────────────────────────────
11
27
 
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
+ 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
+ });
28
50
 
29
51
  const ConfigSchema = z.object({
30
- defaults: z.object({
31
- cooldownMs: z.number().int().positive().optional(),
32
- }).optional(),
52
+ defaults: z
53
+ .object({
54
+ cooldownMs: z.number().int().positive().optional(),
55
+ })
56
+ .optional(),
33
57
  codex: z.array(AccountSchema).optional(),
34
58
  gemini: z.array(AccountSchema).optional(),
35
59
  });
36
60
 
37
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
+ };
38
67
  const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
39
68
  const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
40
- const AUTH_BASE_PATH = join(homedir(), '.claude', 'cache', 'tfx-hub');
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
+ }
41
129
 
42
130
  // ── env var resolution ───────────────────────────────────────────
43
131
 
@@ -45,9 +133,9 @@ function resolveEnvValues(env) {
45
133
  if (!env) return undefined;
46
134
  const resolved = {};
47
135
  for (const [key, value] of Object.entries(env)) {
48
- if (typeof value === 'string' && value.startsWith('$')) {
136
+ if (typeof value === "string" && value.startsWith("$")) {
49
137
  const varName = value.slice(1);
50
- resolved[key] = process.env[varName] ?? '';
138
+ resolved[key] = process.env[varName] ?? "";
51
139
  } else {
52
140
  resolved[key] = value;
53
141
  }
@@ -55,26 +143,188 @@ function resolveEnvValues(env) {
55
143
  return resolved;
56
144
  }
57
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
+
58
255
  // ── AccountBroker ────────────────────────────────────────────────
59
256
 
60
- class AccountBroker {
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 {
61
271
  #config;
62
272
  #state; // Map<accountId, accountState>
63
273
  #roundRobinIndex; // Map<provider, number>
274
+ #persist; // boolean — disable persistence for tests
275
+ #authBasePath;
276
+ #codexAuthSourcePath;
277
+ #authSyncLockOpts;
278
+ #syncCopyDelayMs;
64
279
 
65
- constructor(config) {
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();
66
294
  const parsed = ConfigSchema.parse(config);
67
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;
68
305
 
69
306
  this.#state = new Map();
70
307
  this.#roundRobinIndex = new Map();
71
308
 
72
309
  const allAccounts = [
73
- ...(parsed.codex || []).map((a) => ({ ...a, provider: 'codex' })),
74
- ...(parsed.gemini || []).map((a) => ({ ...a, provider: 'gemini' })),
310
+ ...(parsed.codex || []).map((a) => ({ ...a, provider: "codex" })),
311
+ ...(parsed.gemini || []).map((a) => ({ ...a, provider: "gemini" })),
75
312
  ];
76
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
+
77
325
  for (const account of allAccounts) {
326
+ const saved = pEntries[account.id];
327
+ const activeLease = activeLeaseById.get(account.id);
78
328
  this.#state.set(account.id, {
79
329
  id: account.id,
80
330
  provider: account.provider,
@@ -82,22 +332,507 @@ class AccountBroker {
82
332
  profile: account.profile,
83
333
  env: account.env,
84
334
  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,
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",
92
766
  });
93
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
+ };
94
825
  }
95
826
 
96
827
  // ── lease TTL pruning ──────────────────────────────────────────
97
828
 
98
829
  #pruneExpiredLeases(now) {
99
830
  for (const [id, acct] of this.#state) {
100
- if (acct.busy && acct.leasedAt !== null && now - acct.leasedAt > LEASE_TTL_MS) {
831
+ if (
832
+ acct.busy &&
833
+ acct.leasedAt !== null &&
834
+ now - acct.leasedAt > LEASE_TTL_MS
835
+ ) {
101
836
  this.#state.set(id, { ...acct, busy: false, leasedAt: null });
102
837
  }
103
838
  }
@@ -105,16 +840,36 @@ class AccountBroker {
105
840
 
106
841
  // ── lease ─────────────────────────────────────────────────────
107
842
 
108
- lease({ provider }) {
843
+ lease({ provider, remote = false, autoSync = true } = {}) {
109
844
  const now = Date.now();
110
845
  this.#pruneExpiredLeases(now);
111
846
 
112
- const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
847
+ const wantsRemote = remote === true;
848
+ const accounts = [...this.#state.values()].filter(
849
+ (a) => a.provider === provider && isRemoteAccount(a) === wantsRemote,
850
+ );
113
851
  if (!accounts.length) return null;
114
852
 
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;
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
+ }
118
873
 
119
874
  // sort by tier priority; stable sort preserves original order within same priority
120
875
  const sorted = [...available].sort(
@@ -125,8 +880,23 @@ class AccountBroker {
125
880
  const bestTier = sorted[0].tier;
126
881
  const sameTierAccounts = sorted.filter((a) => a.tier === bestTier);
127
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
+
128
898
  // use a per-provider+tier round-robin key to distribute within the tier
129
- const rrKey = `${provider}:${bestTier}`;
899
+ const rrKey = `${provider}:${bestTier}:${wantsRemote ? "remote" : "local"}`;
130
900
  const rrCurrent = this.#roundRobinIndex.get(rrKey) ?? 0;
131
901
  const tierCount = sameTierAccounts.length;
132
902
  const idx = rrCurrent % tierCount;
@@ -135,6 +905,54 @@ class AccountBroker {
135
905
  // advance round-robin index for this tier
136
906
  this.#roundRobinIndex.set(rrKey, (idx + 1) % tierCount);
137
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
+
138
956
  // update state (immutable)
139
957
  this.#state.set(acct.id, {
140
958
  ...acct,
@@ -142,14 +960,25 @@ class AccountBroker {
142
960
  leasedAt: now,
143
961
  lastUsedAt: now,
144
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,
145
971
  });
146
972
 
147
973
  return {
148
974
  id: acct.id,
149
975
  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,
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,
153
982
  };
154
983
  }
155
984
 
@@ -157,26 +986,61 @@ class AccountBroker {
157
986
 
158
987
  release(accountId, result) {
159
988
  const acct = this.#state.get(accountId);
160
- if (!acct) return;
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
+ }
161
998
 
999
+ const now = Date.now();
162
1000
  const ok = result?.ok === true;
163
- const newFailures = ok ? 0 : acct.failures + 1;
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
+
164
1027
  const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
165
1028
 
1029
+ // rate-limit style cooldown: if circuit just opened, also set cooldown
1030
+ const shouldCooldown =
1031
+ !ok && circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt;
1032
+
166
1033
  const updated = {
167
1034
  ...acct,
168
1035
  busy: false,
169
1036
  leasedAt: null,
170
- failures: newFailures,
1037
+ ...circuitUpdate,
1038
+ cooldownUntil: shouldCooldown ? now + cooldownMs : acct.cooldownUntil,
171
1039
  };
172
1040
 
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
1041
  this.#state.set(accountId, updated);
1042
+ if (this.#persist) persistState(this.#state);
1043
+ this.emit("release", { id: accountId, ok });
180
1044
  }
181
1045
 
182
1046
  // ── markRateLimited ───────────────────────────────────────────
@@ -190,12 +1054,68 @@ class AccountBroker {
190
1054
  leasedAt: null,
191
1055
  cooldownUntil: Date.now() + coolMs,
192
1056
  });
1057
+ if (this.#persist) persistState(this.#state);
193
1058
  }
194
1059
 
195
1060
  // ── snapshot ──────────────────────────────────────────────────
196
1061
 
197
1062
  snapshot() {
198
- return [...this.#state.values()].map((acct) => ({ ...acct }));
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;
199
1119
  }
200
1120
 
201
1121
  // ── nextAvailableEta ──────────────────────────────────────────
@@ -204,7 +1124,9 @@ class AccountBroker {
204
1124
  const now = Date.now();
205
1125
  this.#pruneExpiredLeases(now);
206
1126
 
207
- const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
1127
+ const accounts = [...this.#state.values()].filter(
1128
+ (a) => a.provider === provider,
1129
+ );
208
1130
  if (!accounts.length) return null;
209
1131
 
210
1132
  // find minimum cooldownUntil among accounts that are in cooldown or busy
@@ -214,7 +1136,9 @@ class AccountBroker {
214
1136
  // this account is available now — no ETA needed
215
1137
  return null;
216
1138
  }
217
- const eta = acct.busy ? (acct.leasedAt ?? now) + LEASE_TTL_MS : acct.cooldownUntil;
1139
+ const eta = acct.busy
1140
+ ? (acct.leasedAt ?? now) + LEASE_TTL_MS
1141
+ : acct.cooldownUntil;
218
1142
  if (earliest === null || eta < earliest) {
219
1143
  earliest = eta;
220
1144
  }
@@ -226,26 +1150,72 @@ class AccountBroker {
226
1150
  // ── Config loader ────────────────────────────────────────────────
227
1151
 
228
1152
  function loadConfig() {
229
- const configPath = join(homedir(), '.claude', 'cache', 'tfx-hub', 'accounts.json');
1153
+ const configPath = join(
1154
+ homedir(),
1155
+ ".claude",
1156
+ "cache",
1157
+ "tfx-hub",
1158
+ "accounts.json",
1159
+ );
230
1160
  if (!existsSync(configPath)) return null;
231
1161
  try {
232
- return JSON.parse(readFileSync(configPath, 'utf8'));
233
- } catch {
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
+ );
234
1168
  return null;
235
1169
  }
236
1170
  }
237
1171
 
238
1172
  // ── Singleton ────────────────────────────────────────────────────
239
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
+
240
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
+ }
241
1190
  const config = loadConfig();
242
1191
  if (!config) return null;
243
1192
  try {
244
1193
  return new AccountBroker(config);
245
- } catch {
1194
+ } catch (err) {
1195
+ console.error("[account-broker] Failed to create broker:", err.message);
246
1196
  return null;
247
1197
  }
248
1198
  }
249
1199
 
250
- export const broker = createBroker();
251
- export { AccountBroker };
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 };