triflux 10.3.4 → 10.7.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 (329) hide show
  1. package/LICENSE +21 -21
  2. package/bin/tfx-doctor-tui.mjs +1 -1
  3. package/bin/tfx-doctor.mjs +6 -1
  4. package/bin/tfx-profile.mjs +1 -1
  5. package/bin/tfx-setup-tui.mjs +1 -1
  6. package/bin/tfx-setup.mjs +6 -1
  7. package/bin/triflux.mjs +2396 -1140
  8. package/hooks/agent-route-guard.mjs +12 -8
  9. package/hooks/cross-review-tracker.mjs +21 -8
  10. package/hooks/error-context.mjs +19 -7
  11. package/hooks/hook-adaptive-collector.mjs +18 -16
  12. package/hooks/hook-manager.mjs +93 -32
  13. package/hooks/hook-orchestrator.mjs +108 -24
  14. package/hooks/hook-registry.json +11 -0
  15. package/hooks/keyword-rules.json +6 -10
  16. package/hooks/lib/resolve-root.mjs +1 -1
  17. package/hooks/mcp-config-watcher.mjs +6 -2
  18. package/hooks/pipeline-stop.mjs +3 -6
  19. package/hooks/safety-guard.mjs +99 -28
  20. package/hooks/session-start-fast.mjs +143 -0
  21. package/hooks/subagent-verifier.mjs +5 -4
  22. package/hub/account-broker.mjs +256 -60
  23. package/hub/adaptive-diagnostic.mjs +75 -48
  24. package/hub/adaptive-inject.mjs +95 -57
  25. package/hub/adaptive-memory.mjs +156 -42
  26. package/hub/adaptive.mjs +60 -31
  27. package/hub/assign-callbacks.mjs +67 -30
  28. package/hub/bridge.mjs +0 -1
  29. package/hub/cli-adapter-base.mjs +200 -48
  30. package/hub/codex-adapter.mjs +76 -96
  31. package/hub/codex-compat.mjs +3 -3
  32. package/hub/codex-preflight.mjs +63 -37
  33. package/hub/delegator/contracts.mjs +19 -23
  34. package/hub/delegator/index.mjs +3 -3
  35. package/hub/delegator/service.mjs +88 -64
  36. package/hub/delegator/tool-definitions.mjs +5 -5
  37. package/hub/fullcycle.mjs +33 -17
  38. package/hub/gemini-adapter.mjs +69 -94
  39. package/hub/hitl.mjs +89 -30
  40. package/hub/intent.mjs +161 -38
  41. package/hub/lib/cache-guard.mjs +43 -17
  42. package/hub/lib/mcp-response-cache.mjs +66 -32
  43. package/hub/lib/memory-store.mjs +285 -111
  44. package/hub/lib/path-utils.mjs +35 -37
  45. package/hub/lib/process-utils.mjs +106 -37
  46. package/hub/lib/spawn-trace.mjs +527 -0
  47. package/hub/lib/ssh-command.mjs +34 -4
  48. package/hub/lib/ssh-retry.mjs +5 -1
  49. package/hub/lib/uuidv7.mjs +4 -3
  50. package/hub/memory-doctor.mjs +266 -106
  51. package/hub/middleware/request-logger.mjs +61 -34
  52. package/hub/paths.mjs +9 -9
  53. package/hub/pipeline/gates/confidence.mjs +34 -15
  54. package/hub/pipeline/gates/consensus.mjs +27 -15
  55. package/hub/pipeline/gates/index.mjs +7 -3
  56. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  57. package/hub/pipeline/index.mjs +77 -42
  58. package/hub/pipeline/state.mjs +10 -10
  59. package/hub/pipeline/transitions.mjs +40 -23
  60. package/hub/platform.mjs +57 -48
  61. package/hub/promote-penalties.mjs +25 -7
  62. package/hub/quality/deslop.mjs +70 -49
  63. package/hub/research.mjs +32 -25
  64. package/hub/router.mjs +240 -107
  65. package/hub/routing/complexity.mjs +132 -29
  66. package/hub/routing/index.mjs +17 -12
  67. package/hub/routing/q-learning.mjs +76 -28
  68. package/hub/server.mjs +4 -4
  69. package/hub/session-fingerprint.mjs +126 -60
  70. package/hub/state.mjs +84 -43
  71. package/hub/store-adapter.mjs +59 -26
  72. package/hub/store.mjs +356 -153
  73. package/hub/team/agent-map.json +22 -7
  74. package/hub/team/ansi.mjs +186 -122
  75. package/hub/team/backend.mjs +28 -10
  76. package/hub/team/cli/commands/attach.mjs +29 -9
  77. package/hub/team/cli/commands/control.mjs +29 -8
  78. package/hub/team/cli/commands/debug.mjs +32 -11
  79. package/hub/team/cli/commands/focus.mjs +38 -11
  80. package/hub/team/cli/commands/interrupt.mjs +18 -6
  81. package/hub/team/cli/commands/kill.mjs +16 -5
  82. package/hub/team/cli/commands/list.mjs +11 -4
  83. package/hub/team/cli/commands/send.mjs +19 -6
  84. package/hub/team/cli/commands/start/index.mjs +154 -31
  85. package/hub/team/cli/commands/start/parse-args.mjs +38 -11
  86. package/hub/team/cli/commands/start/start-headless.mjs +112 -36
  87. package/hub/team/cli/commands/start/start-in-process.mjs +12 -2
  88. package/hub/team/cli/commands/start/start-mux.mjs +70 -21
  89. package/hub/team/cli/commands/start/start-wt.mjs +29 -12
  90. package/hub/team/cli/commands/status.mjs +43 -14
  91. package/hub/team/cli/commands/stop.mjs +11 -4
  92. package/hub/team/cli/commands/task.mjs +8 -3
  93. package/hub/team/cli/commands/tasks.mjs +1 -1
  94. package/hub/team/cli/index.mjs +2 -2
  95. package/hub/team/cli/manifest.mjs +38 -8
  96. package/hub/team/cli/render.mjs +30 -8
  97. package/hub/team/cli/services/attach-fallback.mjs +31 -11
  98. package/hub/team/cli/services/hub-client.mjs +42 -14
  99. package/hub/team/cli/services/member-selector.mjs +11 -4
  100. package/hub/team/cli/services/native-control.mjs +48 -21
  101. package/hub/team/cli/services/runtime-mode.mjs +2 -1
  102. package/hub/team/cli/services/state-store.mjs +25 -8
  103. package/hub/team/cli/services/task-model.mjs +16 -6
  104. package/hub/team/conductor-mesh-bridge.mjs +24 -23
  105. package/hub/team/conductor.mjs +8 -4
  106. package/hub/team/dashboard-anchor.mjs +4 -5
  107. package/hub/team/dashboard-layout.mjs +3 -1
  108. package/hub/team/dashboard-open.mjs +41 -21
  109. package/hub/team/dashboard.mjs +76 -28
  110. package/hub/team/event-log.mjs +18 -10
  111. package/hub/team/handoff.mjs +31 -15
  112. package/hub/team/headless.mjs +2 -1
  113. package/hub/team/health-probe.mjs +69 -54
  114. package/hub/team/launcher-template.mjs +16 -13
  115. package/hub/team/native-supervisor.mjs +65 -21
  116. package/hub/team/native.mjs +74 -35
  117. package/hub/team/nativeProxy.mjs +184 -113
  118. package/hub/team/notify.mjs +119 -76
  119. package/hub/team/orchestrator.mjs +9 -4
  120. package/hub/team/pane.mjs +12 -7
  121. package/hub/team/process-cleanup.mjs +25 -16
  122. package/hub/team/psmux.mjs +491 -201
  123. package/hub/team/remote-probe.mjs +68 -52
  124. package/hub/team/remote-session.mjs +117 -59
  125. package/hub/team/remote-watcher.mjs +61 -33
  126. package/hub/team/routing.mjs +51 -25
  127. package/hub/team/runtime-strategy.mjs +3 -1
  128. package/hub/team/session.mjs +98 -34
  129. package/hub/team/staleState.mjs +72 -30
  130. package/hub/team/swarm-locks.mjs +15 -13
  131. package/hub/team/swarm-planner.mjs +32 -21
  132. package/hub/team/swarm-reconciler.mjs +48 -23
  133. package/hub/team/tui-lite.mjs +266 -68
  134. package/hub/team/tui-remote-adapter.mjs +14 -10
  135. package/hub/team/tui-viewer.mjs +99 -43
  136. package/hub/team/tui.mjs +708 -271
  137. package/hub/team/worktree-lifecycle.mjs +152 -58
  138. package/hub/team/wt-manager.mjs +24 -14
  139. package/hub/token-mode.mjs +71 -71
  140. package/hub/tray.mjs +66 -23
  141. package/hub/workers/claude-worker.mjs +162 -118
  142. package/hub/workers/codex-mcp.mjs +192 -141
  143. package/hub/workers/delegator-mcp.mjs +507 -333
  144. package/hub/workers/factory.mjs +8 -8
  145. package/hub/workers/gemini-worker.mjs +115 -84
  146. package/hub/workers/interface.mjs +6 -1
  147. package/hub/workers/worker-utils.mjs +21 -14
  148. package/hud/colors.mjs +27 -9
  149. package/hud/constants.mjs +162 -26
  150. package/hud/context-monitor.mjs +82 -41
  151. package/hud/hud-qos-status.mjs +129 -49
  152. package/hud/mission-board.mjs +6 -3
  153. package/hud/providers/claude.mjs +226 -115
  154. package/hud/providers/codex.mjs +62 -22
  155. package/hud/providers/gemini.mjs +168 -56
  156. package/hud/renderers.mjs +384 -119
  157. package/hud/terminal.mjs +101 -31
  158. package/hud/utils.mjs +78 -38
  159. package/mesh/index.mjs +11 -5
  160. package/mesh/mesh-budget.mjs +18 -9
  161. package/mesh/mesh-heartbeat.mjs +1 -1
  162. package/mesh/mesh-queue.mjs +3 -5
  163. package/mesh/mesh-router.mjs +5 -4
  164. package/package.json +2 -1
  165. package/scripts/__tests__/gen-skill-docs.test.mjs +36 -7
  166. package/scripts/__tests__/keyword-detector.test.mjs +77 -28
  167. package/scripts/__tests__/mcp-guard-engine.test.mjs +58 -20
  168. package/scripts/__tests__/remote-spawn-transfer.test.mjs +30 -19
  169. package/scripts/__tests__/remote-spawn.test.mjs +10 -4
  170. package/scripts/__tests__/session-start-fast.test.mjs +36 -0
  171. package/scripts/__tests__/skill-template.test.mjs +98 -50
  172. package/scripts/__tests__/smoke.test.mjs +1 -1
  173. package/scripts/__tests__/spawn-trace.test.mjs +102 -0
  174. package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +48 -0
  175. package/scripts/cache-doctor.mjs +11 -4
  176. package/scripts/cache-warmup.mjs +96 -37
  177. package/scripts/claudemd-sync.mjs +27 -17
  178. package/scripts/codex-gateway-preflight.mjs +52 -37
  179. package/scripts/codex-mcp-gateway-sync.mjs +59 -39
  180. package/scripts/completions/tfx.bash +47 -47
  181. package/scripts/completions/tfx.fish +44 -44
  182. package/scripts/completions/tfx.zsh +83 -83
  183. package/scripts/config-audit.mjs +232 -0
  184. package/scripts/convert-to-tmpl.mjs +54 -0
  185. package/scripts/cross-review-gate.mjs +35 -12
  186. package/scripts/cross-review-tracker.mjs +21 -8
  187. package/scripts/demo.mjs +35 -17
  188. package/scripts/doctor-diagnose.mjs +284 -0
  189. package/scripts/gen-skill-docs.mjs +7 -2
  190. package/scripts/gen-skill-manifest.mjs +2 -1
  191. package/scripts/headless-guard.mjs +86 -48
  192. package/scripts/hub-ensure.mjs +45 -26
  193. package/scripts/keyword-detector.mjs +41 -20
  194. package/scripts/keyword-rules-expander.mjs +47 -30
  195. package/scripts/lib/claudemd-scanner.mjs +6 -1
  196. package/scripts/lib/context.mjs +3 -3
  197. package/scripts/lib/cross-review-utils.mjs +6 -3
  198. package/scripts/lib/env-probe.mjs +47 -28
  199. package/scripts/lib/gemini-profiles.mjs +44 -10
  200. package/scripts/lib/handoff.mjs +33 -17
  201. package/scripts/lib/hook-utils.mjs +8 -6
  202. package/scripts/lib/keyword-rules.mjs +43 -19
  203. package/scripts/lib/logger.mjs +24 -24
  204. package/scripts/lib/mcp-filter.mjs +377 -239
  205. package/scripts/lib/mcp-guard-engine.mjs +194 -79
  206. package/scripts/lib/mcp-manifest.mjs +23 -13
  207. package/scripts/lib/mcp-server-catalog.mjs +300 -63
  208. package/scripts/lib/psmux-info.mjs +11 -6
  209. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  210. package/scripts/lib/skill-template.mjs +30 -7
  211. package/scripts/mcp-check.mjs +58 -39
  212. package/scripts/mcp-gateway-config.mjs +83 -39
  213. package/scripts/mcp-gateway-ensure.mjs +43 -35
  214. package/scripts/mcp-gateway-integration-test.mjs +70 -58
  215. package/scripts/mcp-gateway-start.mjs +126 -60
  216. package/scripts/mcp-gateway-verify.mjs +24 -22
  217. package/scripts/mcp-safety-guard.mjs +44 -11
  218. package/scripts/notion-read.mjs +199 -84
  219. package/scripts/pack.mjs +94 -89
  220. package/scripts/preflight-cache.mjs +27 -10
  221. package/scripts/preinstall.mjs +42 -13
  222. package/scripts/remote-spawn.mjs +309 -94
  223. package/scripts/run.cjs +8 -5
  224. package/scripts/session-spawn-helper.mjs +130 -39
  225. package/scripts/session-stale-cleanup.mjs +123 -0
  226. package/scripts/setup.mjs +941 -492
  227. package/scripts/test-lock.mjs +20 -7
  228. package/scripts/test-tfx-route-no-claude-native.mjs +16 -12
  229. package/scripts/tfx-batch-stats.mjs +32 -11
  230. package/scripts/tfx-gate-activate.mjs +11 -4
  231. package/scripts/tfx-route-post.mjs +87 -20
  232. package/scripts/tfx-route-worker.mjs +57 -51
  233. package/scripts/tfx-route.sh +41 -124
  234. package/scripts/tmp-cleanup.mjs +21 -7
  235. package/scripts/token-snapshot.mjs +204 -85
  236. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
  237. package/skills/.omc/state/idle-notif-cooldown.json +3 -0
  238. package/skills/.omc/state/last-tool-error.json +7 -0
  239. package/skills/.omc/state/subagent-tracking.json +7 -0
  240. package/skills/_templates/base.md +1 -6
  241. package/skills/merge-worktree/SKILL.md.tmpl +144 -0
  242. package/skills/shared/telemetry-segment.md +6 -0
  243. package/skills/star-prompt/SKILL.md.tmpl +222 -0
  244. package/skills/tfx-analysis/SKILL.md.tmpl +107 -0
  245. package/skills/tfx-analysis/skill.json +1 -6
  246. package/skills/tfx-auto/SKILL.md +1 -0
  247. package/skills/tfx-auto-codex/SKILL.md.tmpl +106 -0
  248. package/skills/tfx-auto-codex/skill.json +1 -3
  249. package/skills/tfx-autopilot/SKILL.md.tmpl +116 -0
  250. package/skills/tfx-autopilot/skill.json +1 -5
  251. package/skills/tfx-autoresearch/SKILL.md.tmpl +136 -0
  252. package/skills/tfx-autoroute/SKILL.md.tmpl +189 -0
  253. package/skills/tfx-autoroute/skill.json +1 -7
  254. package/skills/tfx-codex/SKILL.md +1 -0
  255. package/skills/tfx-codex/skill.json +1 -3
  256. package/skills/tfx-codex-swarm/SKILL.md.tmpl +16 -0
  257. package/skills/tfx-codex-swarm/evals/evals.json +1 -1
  258. package/skills/tfx-codex-swarm/skill.json +1 -4
  259. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +54 -12
  260. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +35 -7
  261. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +35 -7
  262. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +25 -5
  263. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +25 -5
  264. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +20 -4
  265. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +16 -4
  266. package/skills/tfx-consensus/SKILL.md.tmpl +146 -0
  267. package/skills/tfx-debate/SKILL.md.tmpl +192 -0
  268. package/skills/tfx-debate/skill.json +1 -7
  269. package/skills/tfx-deep-analysis/SKILL.md.tmpl +228 -0
  270. package/skills/tfx-deep-analysis/skill.json +1 -5
  271. package/skills/tfx-deep-interview/SKILL.md.tmpl +203 -0
  272. package/skills/tfx-deep-plan/SKILL.md.tmpl +282 -0
  273. package/skills/tfx-deep-qa/SKILL.md.tmpl +165 -0
  274. package/skills/tfx-deep-qa/skill.json +1 -6
  275. package/skills/tfx-deep-research/SKILL.md.tmpl +217 -0
  276. package/skills/tfx-deep-review/SKILL.md.tmpl +179 -0
  277. package/skills/tfx-doctor/SKILL.md +21 -0
  278. package/skills/tfx-doctor/SKILL.md.tmpl +172 -0
  279. package/skills/tfx-doctor/skill.json +1 -3
  280. package/skills/tfx-find/SKILL.md +1 -0
  281. package/skills/tfx-forge/SKILL.md.tmpl +187 -0
  282. package/skills/tfx-fullcycle/SKILL.md.tmpl +286 -0
  283. package/skills/tfx-fullcycle/skill.json +1 -6
  284. package/skills/tfx-gemini/SKILL.md.tmpl +91 -0
  285. package/skills/tfx-gemini/skill.json +1 -3
  286. package/skills/tfx-hooks/SKILL.md.tmpl +216 -0
  287. package/skills/tfx-hooks/skill.json +1 -3
  288. package/skills/tfx-hub/SKILL.md.tmpl +212 -0
  289. package/skills/tfx-hub/skill.json +1 -3
  290. package/skills/tfx-index/SKILL.md +1 -0
  291. package/skills/tfx-index/skill.json +1 -6
  292. package/skills/tfx-interview/SKILL.md.tmpl +285 -0
  293. package/skills/tfx-multi/SKILL.md.tmpl +183 -0
  294. package/skills/tfx-multi/skill.json +1 -3
  295. package/skills/tfx-panel/SKILL.md.tmpl +189 -0
  296. package/skills/tfx-panel/skill.json +1 -7
  297. package/skills/tfx-persist/SKILL.md.tmpl +270 -0
  298. package/skills/tfx-persist/skill.json +1 -7
  299. package/skills/tfx-plan/SKILL.md +1 -0
  300. package/skills/tfx-plan/skill.json +1 -6
  301. package/skills/tfx-profile/SKILL.md.tmpl +239 -0
  302. package/skills/tfx-profile/skill.json +1 -3
  303. package/skills/tfx-prune/SKILL.md.tmpl +200 -0
  304. package/skills/tfx-prune/skill.json +1 -7
  305. package/skills/tfx-psmux-rules/SKILL.md.tmpl +326 -0
  306. package/skills/tfx-psmux-rules/skill.json +1 -4
  307. package/skills/tfx-qa/SKILL.md +1 -0
  308. package/skills/tfx-qa/skill.json +1 -6
  309. package/skills/tfx-ralph/SKILL.md.tmpl +28 -0
  310. package/skills/tfx-ralph/skill.json +1 -4
  311. package/skills/tfx-remote-setup/SKILL.md.tmpl +576 -0
  312. package/skills/tfx-remote-setup/skill.json +1 -3
  313. package/skills/tfx-remote-spawn/SKILL.md.tmpl +263 -0
  314. package/skills/tfx-remote-spawn/references/hosts.json +16 -0
  315. package/skills/tfx-remote-spawn/skill.json +1 -4
  316. package/skills/tfx-research/SKILL.md +1 -0
  317. package/skills/tfx-review/SKILL.md +1 -0
  318. package/skills/tfx-review/skill.json +1 -6
  319. package/skills/tfx-setup/SKILL.md.tmpl +504 -0
  320. package/skills/tfx-setup/skill.json +1 -3
  321. package/skills/tfx-swarm/SKILL.md +22 -0
  322. package/skills/tfx-swarm/SKILL.md.tmpl +218 -0
  323. package/tui/codex-profile.mjs +88 -33
  324. package/tui/core.mjs +45 -15
  325. package/tui/doctor.mjs +75 -28
  326. package/tui/gemini-profile.mjs +74 -29
  327. package/tui/monitor-data.mjs +8 -4
  328. package/tui/monitor.mjs +71 -27
  329. package/tui/setup.mjs +133 -42
@@ -29,7 +29,8 @@ function main() {
29
29
 
30
30
  const agentType = input.agent_type || input.subagent_type || "unknown";
31
31
  const result = input.tool_output || input.result || "";
32
- const resultStr = typeof result === "string" ? result : JSON.stringify(result);
32
+ const resultStr =
33
+ typeof result === "string" ? result : JSON.stringify(result);
33
34
 
34
35
  const issues = [];
35
36
 
@@ -37,7 +38,7 @@ function main() {
37
38
  if (!resultStr.trim() || resultStr.trim().length < 20) {
38
39
  issues.push(
39
40
  `서브에이전트(${agentType})가 거의 빈 결과를 반환했습니다. ` +
40
- "프롬프트를 더 구체적으로 작성하거나, 다른 subagent_type을 시도하세요."
41
+ "프롬프트를 더 구체적으로 작성하거나, 다른 subagent_type을 시도하세요.",
41
42
  );
42
43
  }
43
44
 
@@ -50,7 +51,7 @@ function main() {
50
51
  if (hasError && resultStr.length > 50) {
51
52
  issues.push(
52
53
  `서브에이전트(${agentType}) 결과에 에러 신호가 감지되었습니다. ` +
53
- "결과를 검토하고, 필요 시 다른 접근 방식을 사용하세요."
54
+ "결과를 검토하고, 필요 시 다른 접근 방식을 사용하세요.",
54
55
  );
55
56
  }
56
57
 
@@ -58,7 +59,7 @@ function main() {
58
59
  if (resultStr.length > 15000) {
59
60
  issues.push(
60
61
  `서브에이전트(${agentType}) 결과가 ${Math.round(resultStr.length / 1000)}K 자입니다. ` +
61
- "핵심만 추출하여 컨텍스트 윈도우를 절약하세요."
62
+ "핵심만 추출하여 컨텍스트 윈도우를 절약하세요.",
62
63
  );
63
64
  }
64
65
 
@@ -1,35 +1,45 @@
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 { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, sep } from "node:path";
10
+ import * as z from "zod";
9
11
 
10
12
  // ── Zod schema ───────────────────────────────────────────────────
11
13
 
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
- });
14
+ const AccountSchema = z
15
+ .object({
16
+ id: z.string().min(1),
17
+ mode: z.enum(["profile", "env", "auth"]),
18
+ profile: z.string().optional(),
19
+ env: z.record(z.string(), z.string()).optional(),
20
+ authFile: z.string().optional(),
21
+ host: z.string().min(1).optional(),
22
+ tier: z
23
+ .enum(["pro", "plus", "free", "unknown"])
24
+ .optional()
25
+ .default("unknown"),
26
+ })
27
+ .superRefine((val, ctx) => {
28
+ if (val.mode === "auth" && !val.authFile) {
29
+ ctx.addIssue({
30
+ code: z.ZodIssueCode.custom,
31
+ message: 'authFile is required when mode is "auth"',
32
+ path: ["authFile"],
33
+ });
34
+ }
35
+ });
28
36
 
29
37
  const ConfigSchema = z.object({
30
- defaults: z.object({
31
- cooldownMs: z.number().int().positive().optional(),
32
- }).optional(),
38
+ defaults: z
39
+ .object({
40
+ cooldownMs: z.number().int().positive().optional(),
41
+ })
42
+ .optional(),
33
43
  codex: z.array(AccountSchema).optional(),
34
44
  gemini: z.array(AccountSchema).optional(),
35
45
  });
@@ -37,7 +47,9 @@ const ConfigSchema = z.object({
37
47
  const DEFAULT_COOLDOWN_MS = 300_000; // 5 minutes
38
48
  const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
39
49
  const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
40
- const AUTH_BASE_PATH = join(homedir(), '.claude', 'cache', 'tfx-hub');
50
+ const CIRCUIT_WINDOW_MS = 10 * 60_000; // 10 minutes
51
+ const CIRCUIT_MAX_FAILURES = 3;
52
+ const AUTH_BASE_PATH = join(homedir(), ".claude", "cache", "tfx-hub");
41
53
 
42
54
  // ── env var resolution ───────────────────────────────────────────
43
55
 
@@ -45,9 +57,9 @@ function resolveEnvValues(env) {
45
57
  if (!env) return undefined;
46
58
  const resolved = {};
47
59
  for (const [key, value] of Object.entries(env)) {
48
- if (typeof value === 'string' && value.startsWith('$')) {
60
+ if (typeof value === "string" && value.startsWith("$")) {
49
61
  const varName = value.slice(1);
50
- resolved[key] = process.env[varName] ?? '';
62
+ resolved[key] = process.env[varName] ?? "";
51
63
  } else {
52
64
  resolved[key] = value;
53
65
  }
@@ -55,14 +67,24 @@ function resolveEnvValues(env) {
55
67
  return resolved;
56
68
  }
57
69
 
70
+ function isRemoteAccount(account) {
71
+ return Boolean(account.host);
72
+ }
73
+
74
+ function getRemainingLeaseMs(account, now) {
75
+ if (!account.busy || account.leasedAt === null) return 0;
76
+ return Math.max(0, LEASE_TTL_MS - (now - account.leasedAt));
77
+ }
78
+
58
79
  // ── AccountBroker ────────────────────────────────────────────────
59
80
 
60
- class AccountBroker {
81
+ class AccountBroker extends EventEmitter {
61
82
  #config;
62
83
  #state; // Map<accountId, accountState>
63
84
  #roundRobinIndex; // Map<provider, number>
64
85
 
65
86
  constructor(config) {
87
+ super();
66
88
  const parsed = ConfigSchema.parse(config);
67
89
  this.#config = parsed;
68
90
 
@@ -70,8 +92,8 @@ class AccountBroker {
70
92
  this.#roundRobinIndex = new Map();
71
93
 
72
94
  const allAccounts = [
73
- ...(parsed.codex || []).map((a) => ({ ...a, provider: 'codex' })),
74
- ...(parsed.gemini || []).map((a) => ({ ...a, provider: 'gemini' })),
95
+ ...(parsed.codex || []).map((a) => ({ ...a, provider: "codex" })),
96
+ ...(parsed.gemini || []).map((a) => ({ ...a, provider: "gemini" })),
75
97
  ];
76
98
 
77
99
  for (const account of allAccounts) {
@@ -82,22 +104,73 @@ class AccountBroker {
82
104
  profile: account.profile,
83
105
  env: account.env,
84
106
  authFile: account.authFile,
85
- tier: account.tier ?? 'unknown',
107
+ host: account.host,
108
+ tier: account.tier ?? "unknown",
86
109
  busy: false,
87
110
  leasedAt: null,
88
111
  cooldownUntil: 0,
89
- failures: 0,
112
+ // per-account circuit breaker state
113
+ failureTimestamps: [], // timestamp array for window-based decay
114
+ circuitOpenedAt: 0,
115
+ circuitTrialInFlight: false,
90
116
  lastUsedAt: 0,
91
117
  totalSessions: 0,
92
118
  });
93
119
  }
94
120
  }
95
121
 
122
+ // ── per-account circuit breaker ─────────────────────────────────
123
+
124
+ #getCircuitState(acct, now) {
125
+ const validFailures = acct.failureTimestamps.filter(
126
+ (ts) => now - ts < CIRCUIT_WINDOW_MS,
127
+ );
128
+ const withinWindow =
129
+ acct.circuitOpenedAt && now - acct.circuitOpenedAt < CIRCUIT_WINDOW_MS;
130
+ if (withinWindow) return { state: "open", failures: validFailures };
131
+ if (acct.circuitOpenedAt)
132
+ return { state: "half-open", failures: validFailures };
133
+ return { state: "closed", failures: validFailures };
134
+ }
135
+
136
+ #isCircuitBlocked(acct, now) {
137
+ const circuit = this.#getCircuitState(acct, now);
138
+ if (circuit.state === "open") return true;
139
+ if (circuit.state === "half-open" && acct.circuitTrialInFlight) return true;
140
+ return false;
141
+ }
142
+
143
+ #recordCircuitFailure(acct, isHalfOpen, now) {
144
+ const validFailures = [
145
+ ...acct.failureTimestamps.filter((ts) => now - ts < CIRCUIT_WINDOW_MS),
146
+ now,
147
+ ];
148
+ const shouldOpen =
149
+ isHalfOpen || validFailures.length >= CIRCUIT_MAX_FAILURES;
150
+ return {
151
+ failureTimestamps: validFailures,
152
+ circuitOpenedAt: shouldOpen ? now : acct.circuitOpenedAt,
153
+ circuitTrialInFlight: false,
154
+ };
155
+ }
156
+
157
+ #resetCircuit() {
158
+ return {
159
+ failureTimestamps: [],
160
+ circuitOpenedAt: 0,
161
+ circuitTrialInFlight: false,
162
+ };
163
+ }
164
+
96
165
  // ── lease TTL pruning ──────────────────────────────────────────
97
166
 
98
167
  #pruneExpiredLeases(now) {
99
168
  for (const [id, acct] of this.#state) {
100
- if (acct.busy && acct.leasedAt !== null && now - acct.leasedAt > LEASE_TTL_MS) {
169
+ if (
170
+ acct.busy &&
171
+ acct.leasedAt !== null &&
172
+ now - acct.leasedAt > LEASE_TTL_MS
173
+ ) {
101
174
  this.#state.set(id, { ...acct, busy: false, leasedAt: null });
102
175
  }
103
176
  }
@@ -105,16 +178,36 @@ class AccountBroker {
105
178
 
106
179
  // ── lease ─────────────────────────────────────────────────────
107
180
 
108
- lease({ provider }) {
181
+ lease({ provider, remote = false } = {}) {
109
182
  const now = Date.now();
110
183
  this.#pruneExpiredLeases(now);
111
184
 
112
- const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
185
+ const wantsRemote = remote === true;
186
+ const accounts = [...this.#state.values()].filter(
187
+ (a) => a.provider === provider && isRemoteAccount(a) === wantsRemote,
188
+ );
113
189
  if (!accounts.length) return null;
114
190
 
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;
191
+ // filter: not busy, not in cooldown, circuit not blocked
192
+ const available = accounts.filter(
193
+ (a) =>
194
+ !a.busy && a.cooldownUntil <= now && !this.#isCircuitBlocked(a, now),
195
+ );
196
+
197
+ if (!available.length) {
198
+ // check if any accounts exist but all are blocked by circuit
199
+ const circuitBlocked = accounts.filter(
200
+ (a) =>
201
+ !a.busy && a.cooldownUntil <= now && this.#isCircuitBlocked(a, now),
202
+ );
203
+ if (circuitBlocked.length) {
204
+ this.emit("noAvailableAccounts", {
205
+ provider,
206
+ count: circuitBlocked.length,
207
+ });
208
+ }
209
+ return null;
210
+ }
118
211
 
119
212
  // sort by tier priority; stable sort preserves original order within same priority
120
213
  const sorted = [...available].sort(
@@ -125,8 +218,23 @@ class AccountBroker {
125
218
  const bestTier = sorted[0].tier;
126
219
  const sameTierAccounts = sorted.filter((a) => a.tier === bestTier);
127
220
 
221
+ // detect tier fallback
222
+ const highestTier = accounts.reduce(
223
+ (best, a) => Math.min(best, TIER_PRIORITY[a.tier] ?? 2),
224
+ Infinity,
225
+ );
226
+ if ((TIER_PRIORITY[bestTier] ?? 2) > highestTier) {
227
+ this.emit("tierFallback", {
228
+ provider,
229
+ from: Object.entries(TIER_PRIORITY).find(
230
+ ([, v]) => v === highestTier,
231
+ )?.[0],
232
+ to: bestTier,
233
+ });
234
+ }
235
+
128
236
  // use a per-provider+tier round-robin key to distribute within the tier
129
- const rrKey = `${provider}:${bestTier}`;
237
+ const rrKey = `${provider}:${bestTier}:${wantsRemote ? "remote" : "local"}`;
130
238
  const rrCurrent = this.#roundRobinIndex.get(rrKey) ?? 0;
131
239
  const tierCount = sameTierAccounts.length;
132
240
  const idx = rrCurrent % tierCount;
@@ -135,6 +243,10 @@ class AccountBroker {
135
243
  // advance round-robin index for this tier
136
244
  this.#roundRobinIndex.set(rrKey, (idx + 1) % tierCount);
137
245
 
246
+ // mark half-open trial if applicable
247
+ const circuit = this.#getCircuitState(acct, now);
248
+ const isHalfOpen = circuit.state === "half-open";
249
+
138
250
  // update state (immutable)
139
251
  this.#state.set(acct.id, {
140
252
  ...acct,
@@ -142,14 +254,45 @@ class AccountBroker {
142
254
  leasedAt: now,
143
255
  lastUsedAt: now,
144
256
  totalSessions: acct.totalSessions + 1,
257
+ circuitTrialInFlight: isHalfOpen ? true : acct.circuitTrialInFlight,
258
+ });
259
+
260
+ this.emit("lease", {
261
+ id: acct.id,
262
+ provider,
263
+ tier: acct.tier,
264
+ halfOpen: isHalfOpen,
145
265
  });
146
266
 
267
+ // path traversal guard for authFile
268
+ let authFile;
269
+ if (acct.mode === "auth") {
270
+ const resolved = join(AUTH_BASE_PATH, acct.authFile);
271
+ if (!resolved.startsWith(AUTH_BASE_PATH + sep)) {
272
+ this.emit("securityViolation", {
273
+ id: acct.id,
274
+ authFile: acct.authFile,
275
+ });
276
+ // undo the lease — path traversal blocked
277
+ this.#state.set(acct.id, {
278
+ ...this.#state.get(acct.id),
279
+ busy: false,
280
+ leasedAt: null,
281
+ });
282
+ return null;
283
+ }
284
+ authFile = resolved;
285
+ }
286
+
147
287
  return {
148
288
  id: acct.id,
149
289
  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,
290
+ remote: isRemoteAccount(acct),
291
+ host: acct.host,
292
+ halfOpen: isHalfOpen,
293
+ profile: acct.mode === "profile" ? acct.profile : undefined,
294
+ env: acct.mode === "env" ? resolveEnvValues(acct.env) : undefined,
295
+ authFile,
153
296
  };
154
297
  }
155
298
 
@@ -157,26 +300,45 @@ class AccountBroker {
157
300
 
158
301
  release(accountId, result) {
159
302
  const acct = this.#state.get(accountId);
160
- if (!acct) return;
303
+ if (!acct?.busy) return;
161
304
 
305
+ const now = Date.now();
162
306
  const ok = result?.ok === true;
163
- const newFailures = ok ? 0 : acct.failures + 1;
307
+ const circuit = this.#getCircuitState(acct, now);
308
+ const isHalfOpen = circuit.state === "half-open";
309
+
310
+ let circuitUpdate;
311
+ if (ok) {
312
+ circuitUpdate = this.#resetCircuit();
313
+ if (isHalfOpen) {
314
+ this.emit("circuitClose", { id: accountId });
315
+ }
316
+ } else {
317
+ circuitUpdate = this.#recordCircuitFailure(acct, isHalfOpen, now);
318
+ if (circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt) {
319
+ this.emit("circuitOpen", {
320
+ id: accountId,
321
+ failures: circuitUpdate.failureTimestamps.length,
322
+ });
323
+ }
324
+ }
325
+
164
326
  const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
165
327
 
328
+ // rate-limit style cooldown: if circuit just opened, also set cooldown
329
+ const shouldCooldown =
330
+ !ok && circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt;
331
+
166
332
  const updated = {
167
333
  ...acct,
168
334
  busy: false,
169
335
  leasedAt: null,
170
- failures: newFailures,
336
+ ...circuitUpdate,
337
+ cooldownUntil: shouldCooldown ? now + cooldownMs : acct.cooldownUntil,
171
338
  };
172
339
 
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
340
  this.#state.set(accountId, updated);
341
+ this.emit("release", { id: accountId, ok });
180
342
  }
181
343
 
182
344
  // ── markRateLimited ───────────────────────────────────────────
@@ -195,7 +357,14 @@ class AccountBroker {
195
357
  // ── snapshot ──────────────────────────────────────────────────
196
358
 
197
359
  snapshot() {
198
- return [...this.#state.values()].map((acct) => ({ ...acct }));
360
+ const now = Date.now();
361
+ this.#pruneExpiredLeases(now);
362
+ return [...this.#state.values()].map((acct) => ({
363
+ ...acct,
364
+ failureTimestamps: [...acct.failureTimestamps],
365
+ remainingMs: getRemainingLeaseMs(acct, now),
366
+ circuitState: this.#getCircuitState(acct, now).state,
367
+ }));
199
368
  }
200
369
 
201
370
  // ── nextAvailableEta ──────────────────────────────────────────
@@ -204,7 +373,9 @@ class AccountBroker {
204
373
  const now = Date.now();
205
374
  this.#pruneExpiredLeases(now);
206
375
 
207
- const accounts = [...this.#state.values()].filter((a) => a.provider === provider);
376
+ const accounts = [...this.#state.values()].filter(
377
+ (a) => a.provider === provider,
378
+ );
208
379
  if (!accounts.length) return null;
209
380
 
210
381
  // find minimum cooldownUntil among accounts that are in cooldown or busy
@@ -214,7 +385,9 @@ class AccountBroker {
214
385
  // this account is available now — no ETA needed
215
386
  return null;
216
387
  }
217
- const eta = acct.busy ? (acct.leasedAt ?? now) + LEASE_TTL_MS : acct.cooldownUntil;
388
+ const eta = acct.busy
389
+ ? (acct.leasedAt ?? now) + LEASE_TTL_MS
390
+ : acct.cooldownUntil;
218
391
  if (earliest === null || eta < earliest) {
219
392
  earliest = eta;
220
393
  }
@@ -226,11 +399,21 @@ class AccountBroker {
226
399
  // ── Config loader ────────────────────────────────────────────────
227
400
 
228
401
  function loadConfig() {
229
- const configPath = join(homedir(), '.claude', 'cache', 'tfx-hub', 'accounts.json');
402
+ const configPath = join(
403
+ homedir(),
404
+ ".claude",
405
+ "cache",
406
+ "tfx-hub",
407
+ "accounts.json",
408
+ );
230
409
  if (!existsSync(configPath)) return null;
231
410
  try {
232
- return JSON.parse(readFileSync(configPath, 'utf8'));
233
- } catch {
411
+ return JSON.parse(readFileSync(configPath, "utf8"));
412
+ } catch (err) {
413
+ console.error(
414
+ "[account-broker] Failed to parse accounts.json:",
415
+ err.message,
416
+ );
234
417
  return null;
235
418
  }
236
419
  }
@@ -242,10 +425,23 @@ function createBroker() {
242
425
  if (!config) return null;
243
426
  try {
244
427
  return new AccountBroker(config);
245
- } catch {
428
+ } catch (err) {
429
+ console.error("[account-broker] Failed to create broker:", err.message);
246
430
  return null;
247
431
  }
248
432
  }
249
433
 
250
- export const broker = createBroker();
251
- export { AccountBroker };
434
+ /** Re-read config and replace the module-level singleton. ESM live binding propagates to all importers. */
435
+ function reloadBroker() {
436
+ const config = loadConfig();
437
+ if (!config) return { ok: false, error: "Config not found or invalid" };
438
+ try {
439
+ broker = new AccountBroker(config);
440
+ return { ok: true, broker };
441
+ } catch (err) {
442
+ return { ok: false, error: err.message };
443
+ }
444
+ }
445
+
446
+ export let broker = createBroker();
447
+ export { AccountBroker, reloadBroker };