create-byan-agent 2.9.4 → 2.9.5

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 (92) hide show
  1. package/install/bin/byan-cleanup.js +156 -0
  2. package/install/bin/byan-kanban.js +159 -0
  3. package/install/bin/byan-ledger.js +45 -0
  4. package/install/lib/cleanup/detector.js +154 -0
  5. package/install/lib/cleanup/executor.js +72 -0
  6. package/install/lib/subagent-generator.js +208 -0
  7. package/install/lib/token-ledger.js +131 -0
  8. package/install/templates/.claude/agents/bmad-bmad-master.md +14 -0
  9. package/install/templates/.claude/agents/bmad-bmb-agent-builder.md +14 -0
  10. package/install/templates/.claude/agents/bmad-bmb-module-builder.md +14 -0
  11. package/install/templates/.claude/agents/bmad-bmb-workflow-builder.md +14 -0
  12. package/install/templates/.claude/agents/bmad-bmm-analyst.md +14 -0
  13. package/install/templates/.claude/agents/bmad-bmm-architect.md +14 -0
  14. package/install/templates/.claude/agents/bmad-bmm-dev.md +14 -0
  15. package/install/templates/.claude/agents/bmad-bmm-pm.md +14 -0
  16. package/install/templates/.claude/agents/bmad-bmm-quick-flow-solo-dev.md +14 -0
  17. package/install/templates/.claude/agents/bmad-bmm-quinn.md +14 -0
  18. package/install/templates/.claude/agents/bmad-bmm-sm.md +14 -0
  19. package/install/templates/.claude/agents/bmad-bmm-tech-writer.md +14 -0
  20. package/install/templates/.claude/agents/bmad-bmm-ux-designer.md +14 -0
  21. package/install/templates/.claude/agents/bmad-byan-v2.md +14 -0
  22. package/install/templates/.claude/agents/bmad-byan.md +152 -0
  23. package/install/templates/.claude/agents/bmad-carmack.md +14 -0
  24. package/install/templates/.claude/agents/bmad-cis-brainstorming-coach.md +14 -0
  25. package/install/templates/.claude/agents/bmad-cis-creative-problem-solver.md +14 -0
  26. package/install/templates/.claude/agents/bmad-cis-design-thinking-coach.md +14 -0
  27. package/install/templates/.claude/agents/bmad-cis-innovation-strategist.md +14 -0
  28. package/install/templates/.claude/agents/bmad-cis-presentation-master.md +14 -0
  29. package/install/templates/.claude/agents/bmad-cis-storyteller.md +14 -0
  30. package/install/templates/.claude/agents/bmad-claude.md +26 -0
  31. package/install/templates/.claude/agents/bmad-codex.md +26 -0
  32. package/install/templates/.claude/agents/bmad-compliance.md +68 -0
  33. package/install/templates/.claude/agents/bmad-drawio.md +25 -0
  34. package/install/templates/.claude/agents/bmad-expert-merise-agile.md +54 -0
  35. package/install/templates/.claude/agents/bmad-fact-checker.md +14 -0
  36. package/install/templates/.claude/agents/bmad-forgeron.md +14 -0
  37. package/install/templates/.claude/agents/bmad-hermes.md +59 -0
  38. package/install/templates/.claude/agents/bmad-marc.md +25 -0
  39. package/install/templates/.claude/agents/bmad-patnote.md +26 -0
  40. package/install/templates/.claude/agents/bmad-rachid.md +25 -0
  41. package/install/templates/.claude/agents/bmad-tao.md +14 -0
  42. package/install/templates/.claude/agents/bmad-tea-tea.md +14 -0
  43. package/install/templates/.claude/agents/bmad-yanstaller.md +47 -0
  44. package/install/templates/.claude/hooks/fact-check-absolutes.js +185 -0
  45. package/install/templates/.claude/hooks/fd-phase-guard.js +87 -0
  46. package/install/templates/.claude/hooks/fd-response-check.js +92 -0
  47. package/install/templates/.claude/hooks/lib/failure-detector.js +14 -0
  48. package/install/templates/.claude/hooks/pre-compact-save.js +148 -0
  49. package/install/templates/.claude/hooks/tool-failure-guard.js +6 -0
  50. package/install/templates/.claude/hooks/tool-transparency.js +4 -0
  51. package/install/templates/.claude/settings.json +23 -0
  52. package/install/templates/.claude/skills/byan-byan/SKILL.md +115 -163
  53. package/install/templates/.claude/skills/byan-orchestrate/SKILL.md +100 -0
  54. package/install/templates/.githooks/pre-commit +75 -0
  55. package/install/templates/_byan/mcp/byan-mcp-server/lib/copilot.js +148 -0
  56. package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +163 -0
  57. package/install/templates/_byan/mcp/byan-mcp-server/lib/kanban.js +226 -0
  58. package/install/templates/_byan/mcp/byan-mcp-server/lib/peer-review.js +187 -0
  59. package/install/templates/_byan/mcp/byan-mcp-server/server.js +463 -0
  60. package/install/templates/detector.js +154 -0
  61. package/package.json +6 -7
  62. package/src/loadbalancer/capability-matrix.js +157 -0
  63. package/src/loadbalancer/config.js +141 -0
  64. package/src/loadbalancer/graceful-degradation.js +212 -0
  65. package/src/loadbalancer/health-probe.js +151 -0
  66. package/src/loadbalancer/hooks/claude-hooks.js +53 -0
  67. package/src/loadbalancer/hooks/copilot-hooks.js +74 -0
  68. package/src/loadbalancer/index.js +81 -0
  69. package/src/loadbalancer/loadbalancer.default.yaml +65 -0
  70. package/src/loadbalancer/loadbalancer.js +324 -0
  71. package/src/loadbalancer/mcp-server.js +304 -0
  72. package/src/loadbalancer/metrics.js +146 -0
  73. package/src/loadbalancer/native/claude-integration.js +64 -0
  74. package/src/loadbalancer/native/copilot-integration.js +59 -0
  75. package/src/loadbalancer/pressure-score.js +102 -0
  76. package/src/loadbalancer/providers/base-provider.js +80 -0
  77. package/src/loadbalancer/providers/byan-api-provider.js +132 -0
  78. package/src/loadbalancer/providers/claude-provider.js +113 -0
  79. package/src/loadbalancer/providers/copilot-provider.js +104 -0
  80. package/src/loadbalancer/rate-limit-tracker.js +216 -0
  81. package/src/loadbalancer/session-bridge.js +179 -0
  82. package/src/loadbalancer/state/db.js +211 -0
  83. package/src/loadbalancer/state/migrations/001-initial.sql +50 -0
  84. package/src/loadbalancer/tools/index.js +123 -0
  85. package/src/loadbalancer/velocity-estimator.js +147 -0
  86. package/update-byan-agent/bin/update-byan-agent.js +27 -2
  87. package/API-BYAN-V2.md +0 -741
  88. package/BMAD-QUICK-REFERENCE.md +0 -370
  89. package/CHANGELOG-v2.1.0.md +0 -371
  90. package/MIGRATION-v2.0-to-v2.1.md +0 -430
  91. package/README-BYAN-V2.md +0 -446
  92. package/TEST-GUIDE-v2.3.2.md +0 -161
@@ -0,0 +1,157 @@
1
+ /**
2
+ * CapabilityMatrix — Provider Feature Parity Routing
3
+ *
4
+ * Maps which tools/capabilities each provider supports.
5
+ * Routes capability-specific tasks to the right provider.
6
+ * Degrades gracefully when target provider lacks a capability.
7
+ */
8
+
9
+ const DEFAULT_CAPABILITIES = {
10
+ claude: {
11
+ file_edit: true,
12
+ file_read: true,
13
+ bash: true,
14
+ web_search: false,
15
+ mcp_tools: true,
16
+ subagents: true,
17
+ git: true,
18
+ streaming: true,
19
+ multi_turn: true,
20
+ max_context_tokens: 200000,
21
+ tool_use: true,
22
+ image_input: true,
23
+ },
24
+ copilot: {
25
+ file_edit: true,
26
+ file_read: true,
27
+ bash: true,
28
+ web_search: true,
29
+ mcp_tools: true,
30
+ subagents: true,
31
+ git: true,
32
+ streaming: true,
33
+ multi_turn: true,
34
+ max_context_tokens: 128000,
35
+ tool_use: true,
36
+ image_input: true,
37
+ },
38
+ byan_api: {
39
+ file_edit: false,
40
+ file_read: false,
41
+ bash: false,
42
+ web_search: false,
43
+ mcp_tools: false,
44
+ subagents: false,
45
+ git: false,
46
+ streaming: false,
47
+ multi_turn: true,
48
+ max_context_tokens: 32000,
49
+ tool_use: false,
50
+ image_input: false,
51
+ },
52
+ };
53
+
54
+ class CapabilityMatrix {
55
+ /**
56
+ * @param {object} [overrides] - Per-provider capability overrides
57
+ */
58
+ constructor(overrides = {}) {
59
+ this.matrix = {};
60
+
61
+ for (const [provider, caps] of Object.entries(DEFAULT_CAPABILITIES)) {
62
+ this.matrix[provider] = { ...caps, ...(overrides[provider] || {}) };
63
+ }
64
+
65
+ for (const [provider, caps] of Object.entries(overrides)) {
66
+ if (!this.matrix[provider]) {
67
+ this.matrix[provider] = caps;
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check if a provider supports a specific capability.
74
+ * @param {string} provider
75
+ * @param {string} capability
76
+ * @returns {boolean}
77
+ */
78
+ supports(provider, capability) {
79
+ return !!(this.matrix[provider]?.[capability]);
80
+ }
81
+
82
+ /**
83
+ * Get all capabilities for a provider.
84
+ * @param {string} provider
85
+ * @returns {object|null}
86
+ */
87
+ getCapabilities(provider) {
88
+ return this.matrix[provider] || null;
89
+ }
90
+
91
+ /**
92
+ * Find providers that support a required capability.
93
+ * @param {string} capability
94
+ * @returns {string[]} Provider names that support it
95
+ */
96
+ findProviders(capability) {
97
+ return Object.entries(this.matrix)
98
+ .filter(([, caps]) => caps[capability])
99
+ .map(([name]) => name);
100
+ }
101
+
102
+ /**
103
+ * Find the best provider for a set of required capabilities.
104
+ * @param {string[]} required - Required capability names
105
+ * @param {string[]} preferOrder - Preferred provider order
106
+ * @returns {{ provider: string|null, supported: string[], missing: string[] }}
107
+ */
108
+ findBestProvider(required, preferOrder = []) {
109
+ let bestMatch = null;
110
+ let bestScore = -1;
111
+
112
+ const candidates = preferOrder.length > 0
113
+ ? preferOrder
114
+ : Object.keys(this.matrix);
115
+
116
+ for (const provider of candidates) {
117
+ const caps = this.matrix[provider];
118
+ if (!caps) continue;
119
+
120
+ const supported = required.filter(cap => caps[cap]);
121
+ const score = supported.length;
122
+
123
+ if (score > bestScore) {
124
+ bestScore = score;
125
+ bestMatch = {
126
+ provider,
127
+ supported,
128
+ missing: required.filter(cap => !caps[cap]),
129
+ };
130
+ }
131
+
132
+ if (score === required.length) break;
133
+ }
134
+
135
+ return bestMatch || { provider: null, supported: [], missing: required };
136
+ }
137
+
138
+ /**
139
+ * Get a comparison table of all providers.
140
+ * @returns {object} { capabilities: string[], providers: { name: { cap: bool } } }
141
+ */
142
+ compare() {
143
+ const allCaps = new Set();
144
+ for (const caps of Object.values(this.matrix)) {
145
+ for (const key of Object.keys(caps)) {
146
+ allCaps.add(key);
147
+ }
148
+ }
149
+
150
+ return {
151
+ capabilities: [...allCaps].sort(),
152
+ providers: { ...this.matrix },
153
+ };
154
+ }
155
+ }
156
+
157
+ module.exports = { CapabilityMatrix, DEFAULT_CAPABILITIES };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * LoadBalancer Configuration Loader
3
+ *
4
+ * Loads loadbalancer.yaml, merges with defaults, resolves env vars.
5
+ * Config resolution order:
6
+ * 1. loadbalancer.default.yaml (bundled defaults)
7
+ * 2. {project-root}/_byan/loadbalancer.yaml (user overrides)
8
+ * 3. Environment variables (BYAN_LB_PRIMARY, etc.)
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const yaml = require('js-yaml');
14
+
15
+ const DEFAULT_CONFIG_PATH = path.join(__dirname, 'loadbalancer.default.yaml');
16
+
17
+ function deepMerge(target, source) {
18
+ const result = { ...target };
19
+ for (const key of Object.keys(source)) {
20
+ if (
21
+ source[key] &&
22
+ typeof source[key] === 'object' &&
23
+ !Array.isArray(source[key]) &&
24
+ target[key] &&
25
+ typeof target[key] === 'object' &&
26
+ !Array.isArray(target[key])
27
+ ) {
28
+ result[key] = deepMerge(target[key], source[key]);
29
+ } else {
30
+ result[key] = source[key];
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+
36
+ function loadYaml(filePath) {
37
+ if (!fs.existsSync(filePath)) return null;
38
+ const content = fs.readFileSync(filePath, 'utf8');
39
+ return yaml.load(content);
40
+ }
41
+
42
+ function resolveEnvOverrides(config) {
43
+ const envMap = {
44
+ BYAN_LB_PRIMARY: 'primary',
45
+ BYAN_LB_PORT: 'mcp_server.port',
46
+ BYAN_LB_HOST: 'mcp_server.host',
47
+ BYAN_LB_LOG_LEVEL: 'logging.level',
48
+ };
49
+
50
+ for (const [envKey, configPath] of Object.entries(envMap)) {
51
+ const val = process.env[envKey];
52
+ if (val === undefined) continue;
53
+
54
+ const parts = configPath.split('.');
55
+ let target = config;
56
+ for (let i = 0; i < parts.length - 1; i++) {
57
+ if (!target[parts[i]]) target[parts[i]] = {};
58
+ target = target[parts[i]];
59
+ }
60
+
61
+ const lastKey = parts[parts.length - 1];
62
+ target[lastKey] = isNaN(val) ? val : Number(val);
63
+ }
64
+
65
+ return config;
66
+ }
67
+
68
+ function validateConfig(config) {
69
+ const errors = [];
70
+
71
+ if (!config.providers || typeof config.providers !== 'object') {
72
+ errors.push('providers: must be an object with at least one provider');
73
+ }
74
+
75
+ if (!config.primary) {
76
+ errors.push('primary: must specify a primary provider');
77
+ } else if (!config.providers?.[config.primary]) {
78
+ errors.push(`primary: provider "${config.primary}" not found in providers`);
79
+ }
80
+
81
+ if (!Array.isArray(config.fallback_order)) {
82
+ errors.push('fallback_order: must be an array');
83
+ }
84
+
85
+ const rl = config.rate_limits;
86
+ if (rl) {
87
+ if (typeof rl.window_ms !== 'number' || rl.window_ms <= 0) {
88
+ errors.push('rate_limits.window_ms: must be a positive number');
89
+ }
90
+ if (typeof rl.max_429_in_window !== 'number' || rl.max_429_in_window <= 0) {
91
+ errors.push('rate_limits.max_429_in_window: must be a positive number');
92
+ }
93
+ }
94
+
95
+ return errors;
96
+ }
97
+
98
+ function loadConfig(projectRoot) {
99
+ const defaults = loadYaml(DEFAULT_CONFIG_PATH) || {};
100
+
101
+ const userConfigPath = path.join(projectRoot, '_byan', 'loadbalancer.yaml');
102
+ const userConfig = loadYaml(userConfigPath) || {};
103
+
104
+ let config = deepMerge(defaults, userConfig);
105
+ config = resolveEnvOverrides(config);
106
+
107
+ const errors = validateConfig(config);
108
+ if (errors.length > 0) {
109
+ throw new Error(`LoadBalancer config validation failed:\n - ${errors.join('\n - ')}`);
110
+ }
111
+
112
+ config._resolved = {
113
+ projectRoot,
114
+ configSources: [
115
+ DEFAULT_CONFIG_PATH,
116
+ ...(fs.existsSync(userConfigPath) ? [userConfigPath] : []),
117
+ ],
118
+ };
119
+
120
+ return config;
121
+ }
122
+
123
+ function getEnabledProviders(config) {
124
+ return Object.entries(config.providers)
125
+ .filter(([, p]) => p.enabled !== false)
126
+ .map(([name, p]) => ({ name, ...p }));
127
+ }
128
+
129
+ function getProviderOrder(config) {
130
+ const order = [config.primary, ...config.fallback_order];
131
+ return order.filter(name => config.providers[name]?.enabled !== false);
132
+ }
133
+
134
+ module.exports = {
135
+ loadConfig,
136
+ validateConfig,
137
+ getEnabledProviders,
138
+ getProviderOrder,
139
+ deepMerge,
140
+ DEFAULT_CONFIG_PATH,
141
+ };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * GracefulDegradation — Queue + Backoff When All Providers Rate-Limited
3
+ *
4
+ * When no provider can accept requests:
5
+ * 1. Queue pending tasks by priority (P1 > P2 > P3)
6
+ * 2. Exponential backoff (1s, 2s, 4s, 8s... cap 60s)
7
+ * 3. Notify user with estimated wait time
8
+ * 4. Auto-resume when any provider recovers
9
+ *
10
+ * Integrates with LoadBalancer via event listeners.
11
+ */
12
+
13
+ const { EventEmitter } = require('events');
14
+
15
+ const PRIORITY = { P1: 1, P2: 2, P3: 3, DEFAULT: 2 };
16
+ const MIN_BACKOFF_MS = 1000;
17
+ const MAX_BACKOFF_MS = 60000;
18
+
19
+ class GracefulDegradation extends EventEmitter {
20
+ /**
21
+ * @param {object} opts
22
+ * @param {import('./loadbalancer').LoadBalancer} opts.lb
23
+ */
24
+ constructor(opts = {}) {
25
+ super();
26
+ this.lb = opts.lb;
27
+ this.queue = [];
28
+ this.backoffMs = MIN_BACKOFF_MS;
29
+ this.retryTimer = null;
30
+ this.processing = false;
31
+ this.totalQueued = 0;
32
+ this.totalProcessed = 0;
33
+ this.totalDropped = 0;
34
+
35
+ if (this.lb) {
36
+ this.lb.on('auto_failover', () => this._onProviderRecovery());
37
+ this.lb.on('rate_limit_change', (event) => {
38
+ if (event.to === 'HEALTHY' || event.to === 'RECOVERING') {
39
+ this._onProviderRecovery();
40
+ }
41
+ });
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Enqueue a request when all providers are down.
47
+ * @param {object} request - { prompt, sessionId, preferProvider, priority }
48
+ * @returns {{ queued: true, position: number, estimatedWaitMs: number }}
49
+ */
50
+ enqueue(request) {
51
+ const priority = PRIORITY[request.priority] || PRIORITY.DEFAULT;
52
+
53
+ const entry = {
54
+ id: `q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
55
+ request,
56
+ priority,
57
+ enqueuedAt: Date.now(),
58
+ resolve: null,
59
+ reject: null,
60
+ };
61
+
62
+ const promise = new Promise((resolve, reject) => {
63
+ entry.resolve = resolve;
64
+ entry.reject = reject;
65
+ });
66
+
67
+ this.queue.push(entry);
68
+ this.queue.sort((a, b) => a.priority - b.priority || a.enqueuedAt - b.enqueuedAt);
69
+ this.totalQueued++;
70
+
71
+ const position = this.queue.indexOf(entry) + 1;
72
+ const estimatedWaitMs = this.backoffMs * position;
73
+
74
+ this.emit('enqueued', {
75
+ id: entry.id,
76
+ position,
77
+ estimatedWaitMs,
78
+ queueSize: this.queue.length,
79
+ });
80
+
81
+ this._scheduleRetry();
82
+
83
+ entry.promise = promise;
84
+ return { queued: true, position, estimatedWaitMs, id: entry.id, promise };
85
+ }
86
+
87
+ /**
88
+ * Get queue status.
89
+ */
90
+ getStatus() {
91
+ return {
92
+ queueSize: this.queue.length,
93
+ backoffMs: this.backoffMs,
94
+ processing: this.processing,
95
+ totalQueued: this.totalQueued,
96
+ totalProcessed: this.totalProcessed,
97
+ totalDropped: this.totalDropped,
98
+ items: this.queue.map((e, i) => ({
99
+ id: e.id,
100
+ position: i + 1,
101
+ priority: e.priority,
102
+ waitingMs: Date.now() - e.enqueuedAt,
103
+ })),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Drop a queued item by ID.
109
+ */
110
+ drop(id) {
111
+ const idx = this.queue.findIndex(e => e.id === id);
112
+ if (idx === -1) return false;
113
+
114
+ const entry = this.queue.splice(idx, 1)[0];
115
+ entry.reject(new Error('Request dropped from queue'));
116
+ this.totalDropped++;
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Drop all queued items.
122
+ */
123
+ dropAll() {
124
+ for (const entry of this.queue) {
125
+ entry.reject(new Error('Queue flushed'));
126
+ }
127
+ this.totalDropped += this.queue.length;
128
+ this.queue = [];
129
+ }
130
+
131
+ _scheduleRetry() {
132
+ if (this.retryTimer || this.processing) return;
133
+
134
+ this.retryTimer = setTimeout(() => {
135
+ this.retryTimer = null;
136
+ this._processQueue();
137
+ }, this.backoffMs);
138
+
139
+ if (this.retryTimer.unref) this.retryTimer.unref();
140
+ }
141
+
142
+ async _processQueue() {
143
+ if (this.queue.length === 0 || this.processing) return;
144
+
145
+ this.processing = true;
146
+
147
+ const entry = this.queue[0];
148
+
149
+ try {
150
+ const result = await this.lb.send(entry.request);
151
+
152
+ if (result.rateLimited && result.error) {
153
+ this._increaseBackoff();
154
+ this.emit('retry_failed', {
155
+ id: entry.id,
156
+ backoffMs: this.backoffMs,
157
+ queueSize: this.queue.length,
158
+ });
159
+ this._scheduleRetry();
160
+ } else {
161
+ this.queue.shift();
162
+ entry.resolve(result);
163
+ this.totalProcessed++;
164
+ this.backoffMs = MIN_BACKOFF_MS;
165
+
166
+ this.emit('processed', {
167
+ id: entry.id,
168
+ provider: result.provider,
169
+ queueRemaining: this.queue.length,
170
+ });
171
+
172
+ if (this.queue.length > 0) {
173
+ this._scheduleRetry();
174
+ }
175
+ }
176
+ } catch (err) {
177
+ this._increaseBackoff();
178
+ this._scheduleRetry();
179
+ }
180
+
181
+ this.processing = false;
182
+ }
183
+
184
+ _onProviderRecovery() {
185
+ this.backoffMs = MIN_BACKOFF_MS;
186
+
187
+ if (this.retryTimer) {
188
+ clearTimeout(this.retryTimer);
189
+ this.retryTimer = null;
190
+ }
191
+
192
+ if (this.queue.length > 0) {
193
+ this.emit('recovery_detected', { queueSize: this.queue.length });
194
+ this._processQueue();
195
+ }
196
+ }
197
+
198
+ _increaseBackoff() {
199
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
200
+ }
201
+
202
+ destroy() {
203
+ if (this.retryTimer) {
204
+ clearTimeout(this.retryTimer);
205
+ this.retryTimer = null;
206
+ }
207
+ this.dropAll();
208
+ this.removeAllListeners();
209
+ }
210
+ }
211
+
212
+ module.exports = { GracefulDegradation, PRIORITY };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * HealthProbe — Proactive Provider Availability Monitoring
3
+ *
4
+ * Periodically pings each provider with a cheap request to detect:
5
+ * - Complete unavailability (no response / error)
6
+ * - Silent degradation (response time > threshold)
7
+ * - Pre-throttle signals (rate limit headers on probe)
8
+ *
9
+ * Updates RateLimitTracker proactively before user hits a wall.
10
+ */
11
+
12
+ const { EventEmitter } = require('events');
13
+
14
+ class HealthProbe extends EventEmitter {
15
+ /**
16
+ * @param {object} opts
17
+ * @param {object} opts.providers - { name: ProviderAdapter } map
18
+ * @param {object} opts.trackers - { name: RateLimitTracker } map
19
+ * @param {object} [opts.config] - health_probe section from loadbalancer config
20
+ */
21
+ constructor(opts) {
22
+ super();
23
+ this.providers = opts.providers;
24
+ this.trackers = opts.trackers || {};
25
+ this.config = opts.config || {};
26
+
27
+ this.intervalMs = this.config.interval_ms || 30000;
28
+ this.timeoutMs = this.config.timeout_ms || 5000;
29
+ this.enabled = this.config.enabled !== false;
30
+
31
+ this.timer = null;
32
+ this.probeResults = {};
33
+ this.running = false;
34
+ }
35
+
36
+ /**
37
+ * Start periodic health probing.
38
+ */
39
+ start() {
40
+ if (!this.enabled || this.running) return;
41
+ this.running = true;
42
+
43
+ this._probeAll();
44
+
45
+ this.timer = setInterval(() => this._probeAll(), this.intervalMs);
46
+ if (this.timer.unref) this.timer.unref();
47
+ }
48
+
49
+ /**
50
+ * Stop health probing.
51
+ */
52
+ stop() {
53
+ this.running = false;
54
+ if (this.timer) {
55
+ clearInterval(this.timer);
56
+ this.timer = null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Get latest probe results for all providers.
62
+ */
63
+ getResults() {
64
+ return { ...this.probeResults };
65
+ }
66
+
67
+ /**
68
+ * Run a single probe against one provider.
69
+ * @param {string} name - Provider name
70
+ * @returns {Promise<object>} Probe result
71
+ */
72
+ async probeOne(name) {
73
+ const provider = this.providers[name];
74
+ if (!provider) {
75
+ return { provider: name, available: false, error: 'Provider not found' };
76
+ }
77
+
78
+ const start = Date.now();
79
+
80
+ try {
81
+ const available = await Promise.race([
82
+ provider.isAvailable(),
83
+ new Promise((_, reject) =>
84
+ setTimeout(() => reject(new Error('Probe timeout')), this.timeoutMs)
85
+ ),
86
+ ]);
87
+
88
+ const latencyMs = Date.now() - start;
89
+
90
+ const result = {
91
+ provider: name,
92
+ available: !!available,
93
+ latencyMs,
94
+ degraded: latencyMs > this.timeoutMs * 0.8,
95
+ probedAt: new Date().toISOString(),
96
+ };
97
+
98
+ this.probeResults[name] = result;
99
+
100
+ if (result.degraded && this.trackers[name]) {
101
+ this.trackers[name].recordHeaders({ 'x-ratelimit-remaining': '5' });
102
+ this.emit('degradation', { provider: name, latencyMs });
103
+ }
104
+
105
+ if (!result.available && this.trackers[name]) {
106
+ this.trackers[name].record429({ source: 'health_probe', reason: 'unavailable' });
107
+ this.emit('unavailable', { provider: name });
108
+ }
109
+
110
+ return result;
111
+ } catch (err) {
112
+ const latencyMs = Date.now() - start;
113
+ const result = {
114
+ provider: name,
115
+ available: false,
116
+ latencyMs,
117
+ degraded: true,
118
+ error: err.message,
119
+ probedAt: new Date().toISOString(),
120
+ };
121
+
122
+ this.probeResults[name] = result;
123
+
124
+ if (this.trackers[name]) {
125
+ this.trackers[name].record429({ source: 'health_probe', reason: err.message });
126
+ }
127
+
128
+ this.emit('probe_error', { provider: name, error: err.message });
129
+ return result;
130
+ }
131
+ }
132
+
133
+ async _probeAll() {
134
+ const names = Object.keys(this.providers);
135
+ const results = await Promise.allSettled(
136
+ names.map(name => this.probeOne(name))
137
+ );
138
+
139
+ this.emit('probe_cycle', {
140
+ results: results.map(r => r.value || r.reason),
141
+ timestamp: new Date().toISOString(),
142
+ });
143
+ }
144
+
145
+ destroy() {
146
+ this.stop();
147
+ this.removeAllListeners();
148
+ }
149
+ }
150
+
151
+ module.exports = { HealthProbe };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Claude Hooks — Rate Limit Detection for Claude Agent SDK
3
+ *
4
+ * Integrates with Claude Agent SDK hooks (PostToolUseFailure, Stop)
5
+ * to detect rate limit errors and feed them to RateLimitTracker.
6
+ */
7
+
8
+ /**
9
+ * Create Claude hook handlers that report rate limits to the tracker.
10
+ * @param {import('../rate-limit-tracker').RateLimitTracker} tracker
11
+ * @returns {object} Hook configuration for Claude Agent SDK
12
+ */
13
+ function createClaudeHooks(tracker) {
14
+ return {
15
+ PostToolUseFailure: [
16
+ {
17
+ matcher: { toolName: '*' },
18
+ callback: (event) => {
19
+ const error = event.error || {};
20
+ const isRateLimit =
21
+ error.name === 'RateLimitError' ||
22
+ error.status === 429 ||
23
+ (error.message && error.message.includes('rate_limit'));
24
+
25
+ if (isRateLimit) {
26
+ const retryAfter = error.headers?.['retry-after'] || error.retryAfter;
27
+ tracker.record429({
28
+ source: 'claude-hook',
29
+ toolName: event.toolName,
30
+ retryAfter,
31
+ });
32
+ }
33
+ },
34
+ },
35
+ ],
36
+
37
+ Stop: [
38
+ {
39
+ matcher: {},
40
+ callback: (event) => {
41
+ const reason = event.reason || '';
42
+ if (reason.includes('rate_limit') || reason.includes('overloaded')) {
43
+ tracker.record429({ source: 'claude-stop', reason });
44
+ } else {
45
+ tracker.recordSuccess();
46
+ }
47
+ },
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ module.exports = { createClaudeHooks };