create-byan-agent 2.39.0 → 2.41.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 (34) hide show
  1. package/CHANGELOG.md +102 -1
  2. package/install/bin/create-byan-agent-v2.js +40 -0
  3. package/install/lib/codex-autodelegate-setup.js +100 -0
  4. package/install/templates/.claude/hooks/codex-autodelegate.js +74 -0
  5. package/install/templates/.claude/hooks/lib/autodelegate-decision.js +152 -0
  6. package/install/templates/.claude/hooks/lib/perf-routing.js +47 -0
  7. package/install/templates/.claude/hooks/lib/usage-estimator.js +262 -0
  8. package/install/templates/.claude/hooks/tier-script-guard.js +185 -0
  9. package/install/templates/.claude/rules/native-workflows.md +33 -7
  10. package/install/templates/.claude/settings.json +13 -0
  11. package/install/templates/.claude/skills/byan-byan/SKILL.md +7 -2
  12. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
  13. package/install/templates/.claude/workflows/sprint-planning.js +2 -2
  14. package/install/templates/.claude/workflows/testarch-trace.js +3 -2
  15. package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-tier-script.js +52 -0
  16. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +22 -0
  17. package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +26 -9
  18. package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +104 -0
  19. package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +92 -6
  20. package/install/templates/_byan/mcp/byan-mcp-server/server.js +22 -7
  21. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +2 -2
  22. package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
  23. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  24. package/install/templates/docs/native-workflows-contract.md +55 -12
  25. package/package.json +1 -1
  26. package/src/loadbalancer/capability-matrix.js +14 -0
  27. package/src/loadbalancer/degradation-ladder.js +121 -0
  28. package/src/loadbalancer/loadbalancer.default.yaml +23 -1
  29. package/src/loadbalancer/mcp-server.js +200 -18
  30. package/src/loadbalancer/providers/codex-provider.js +260 -0
  31. package/src/loadbalancer/providers/factory.js +36 -0
  32. package/src/loadbalancer/subscription-window.js +142 -0
  33. package/src/loadbalancer/switch-tolerance.js +63 -0
  34. package/src/loadbalancer/tools/index.js +17 -2
@@ -0,0 +1,121 @@
1
+ /**
2
+ * degradation-ladder — the 4-rung switch policy that answers the 5h pain.
3
+ *
4
+ * Driven by the subscription-window tracker (F2), NOT by 429s: it moves work to
5
+ * the secondary pool as the primary's rolling 5h budget heats up, BEFORE the wall
6
+ * — the whole point ("j'en ai marre de reach ma limite 5h"). It obeys the red
7
+ * line (F4, switch-tolerance): only delegable work crosses to Codex; BYAN's
8
+ * judgment/identity work stays on the primary, and queues rather than denatures
9
+ * when the primary is truly spent.
10
+ *
11
+ * Pure: takes a snapshot { primary, secondaries, pools: { name: poolState } } and
12
+ * a task nature; returns a routing decision. poolState is
13
+ * { windowProximity (0-1|null), pressureRecommendation ('ok'|'caution'|'switch_now'),
14
+ * canAccept (bool), usable (bool) }. No clocks, no I/O — the caller assembles the
15
+ * snapshot from LoadBalancerLive.getWindowStates() + tracker states.
16
+ */
17
+
18
+ const { isDelegable } = require('./switch-tolerance');
19
+
20
+ const RUNGS = Object.freeze({
21
+ HEALTHY: 'HEALTHY',
22
+ PRIMARY_HOT: 'PRIMARY_HOT',
23
+ PRIMARY_EXHAUSTED: 'PRIMARY_EXHAUSTED',
24
+ ALL_EXHAUSTED: 'ALL_EXHAUSTED',
25
+ });
26
+
27
+ const CAUTION = 0.5;
28
+ const SWITCH_NOW = 0.8;
29
+
30
+ function poolExhausted(p) {
31
+ if (!p) return true;
32
+ if (p.canAccept === false) return true;
33
+ if (p.pressureRecommendation === 'switch_now') return true;
34
+ if (typeof p.windowProximity === 'number' && p.windowProximity >= SWITCH_NOW) return true;
35
+ return false;
36
+ }
37
+
38
+ function poolHot(p) {
39
+ if (!p || poolExhausted(p)) return false;
40
+ if (p.pressureRecommendation === 'caution') return true;
41
+ if (typeof p.windowProximity === 'number' && p.windowProximity >= CAUTION) return true;
42
+ return false;
43
+ }
44
+
45
+ function poolUsable(p) {
46
+ return !!p && p.usable !== false && p.canAccept !== false && !poolExhausted(p);
47
+ }
48
+
49
+ function firstUsableSecondary(snapshot) {
50
+ for (const name of snapshot.secondaries || []) {
51
+ if (poolUsable(snapshot.pools[name])) return name;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // System-level rung (independent of the task nature).
57
+ function computeRung(snapshot) {
58
+ const primary = snapshot.pools[snapshot.primary];
59
+ const usableSecondary = firstUsableSecondary(snapshot);
60
+
61
+ if (poolExhausted(primary)) {
62
+ return {
63
+ rung: usableSecondary ? RUNGS.PRIMARY_EXHAUSTED : RUNGS.ALL_EXHAUSTED,
64
+ usableSecondary,
65
+ };
66
+ }
67
+ if (poolHot(primary)) return { rung: RUNGS.PRIMARY_HOT, usableSecondary };
68
+ return { rung: RUNGS.HEALTHY, usableSecondary };
69
+ }
70
+
71
+ // Routing decision for ONE task, given its nature. Returns
72
+ // { action: 'route'|'queue', target: providerName|null, rung, delegable, reason }.
73
+ function decideRoute(snapshot, nature) {
74
+ const { rung, usableSecondary } = computeRung(snapshot);
75
+ const primary = snapshot.primary;
76
+ const primaryPool = snapshot.pools[primary];
77
+ const delegable = isDelegable(nature);
78
+
79
+ // Red line: primary-only work (judgment/identity) never crosses to a secondary.
80
+ if (!delegable) {
81
+ if (primaryPool && primaryPool.canAccept !== false) {
82
+ return {
83
+ action: 'route',
84
+ target: primary,
85
+ rung,
86
+ delegable: false,
87
+ reason: `primary-only nature '${nature}' stays on ${primary} (red line: no denaturing), even at rung ${rung}`,
88
+ };
89
+ }
90
+ return {
91
+ action: 'queue',
92
+ target: null,
93
+ rung,
94
+ delegable: false,
95
+ reason: `primary '${primary}' cannot accept and nature '${nature}' is primary-only — queue rather than denature by crossing to a secondary`,
96
+ };
97
+ }
98
+
99
+ // Delegable work follows the pressure.
100
+ if (rung === RUNGS.HEALTHY) {
101
+ return { action: 'route', target: primary, rung, delegable: true, reason: `primary ${primary} healthy — no switch needed` };
102
+ }
103
+
104
+ if (usableSecondary) {
105
+ return {
106
+ action: 'route',
107
+ target: usableSecondary,
108
+ rung,
109
+ delegable: true,
110
+ reason: `primary ${primary} at rung ${rung} — delegable '${nature}' offloaded to ${usableSecondary}`,
111
+ };
112
+ }
113
+
114
+ // No usable secondary. Ride the primary while it still accepts, else queue.
115
+ if (primaryPool && primaryPool.canAccept !== false) {
116
+ return { action: 'route', target: primary, rung, delegable: true, reason: `no usable secondary at rung ${rung} — riding ${primary} (degraded)` };
117
+ }
118
+ return { action: 'queue', target: null, rung, delegable: true, reason: `all pools exhausted at rung ${rung} — queue with backoff` };
119
+ }
120
+
121
+ module.exports = { computeRung, decideRoute, RUNGS, poolExhausted, poolHot, poolUsable, CAUTION, SWITCH_NOW };
@@ -3,6 +3,9 @@
3
3
 
4
4
  providers:
5
5
  copilot:
6
+ # Optional pool. Needs @github/copilot-sdk, an EXTERNAL optional dep NOT pinned
7
+ # in package.json (no fabricated version): the provider degrades to disabled if
8
+ # the SDK is absent. Install it manually to enable this pool.
6
9
  enabled: true
7
10
  sdk: "@github/copilot-sdk"
8
11
  transport: "json-rpc"
@@ -20,13 +23,32 @@ providers:
20
23
  worker: "claude-haiku-4.5"
21
24
  agent: "claude-sonnet-4.5"
22
25
 
26
+ codex:
27
+ # OpenAI Codex pool. Backed by the `codex` SYSTEM CLI (codex-cli), spawned in
28
+ # headless `codex exec --json` mode — NOT an npm SDK, so nothing to install via
29
+ # package.json; the provider degrades to disabled if the binary is absent.
30
+ # Two auth pools back the same binary: CODEX_API_KEY (per-token, no weekly cap)
31
+ # wins when set, else the ChatGPT-subscription session (~/.codex/auth.json,
32
+ # the 5h + weekly window). Sandbox is bounded for automation.
33
+ enabled: true
34
+ bin: "codex"
35
+ transport: "cli-exec"
36
+ auth_env: "CODEX_API_KEY"
37
+ sandbox: "read-only"
38
+ max_context_tokens: 256000
39
+ # Plain gpt-5.x is the entitled family on a ChatGPT subscription; -codex ids
40
+ # are API-key only (rejected on subscription). See resolveCodexModel.
41
+ models:
42
+ worker: "gpt-5.4"
43
+ agent: "gpt-5.4"
44
+
23
45
  byan_api:
24
46
  enabled: false
25
47
  url: "http://localhost:3737"
26
48
  auth_env: "BYAN_API_TOKEN"
27
49
 
28
50
  primary: "claude"
29
- fallback_order: ["copilot", "byan_api"]
51
+ fallback_order: ["codex", "copilot", "byan_api"]
30
52
 
31
53
  rate_limits:
32
54
  window_ms: 60000
@@ -24,12 +24,22 @@ const { RateLimitTracker } = require('./rate-limit-tracker');
24
24
  const { Metrics } = require('./metrics');
25
25
  const { VelocityEstimator } = require('./velocity-estimator');
26
26
  const { calculatePressure, formatPressureSummary } = require('./pressure-score');
27
+ const { buildProviders } = require('./providers/factory');
28
+ const { SessionBridge } = require('./session-bridge');
29
+ const { GracefulDegradation } = require('./graceful-degradation');
30
+ const { SubscriptionWindow } = require('./subscription-window');
31
+ const { decideRoute } = require('./degradation-ladder');
27
32
  const { EventEmitter } = require('events');
28
33
 
29
34
  const VERSION = '0.2.0';
30
35
 
31
36
  class LoadBalancerLive extends EventEmitter {
32
- constructor(config) {
37
+ /**
38
+ * @param {object} config - loaded loadbalancer config
39
+ * @param {object} [deps] - injectable seams for tests: { providers, bridge,
40
+ * degradation, windows }. Omitted in production -> built from config.
41
+ */
42
+ constructor(config, deps = {}) {
33
43
  super();
34
44
  this.config = config;
35
45
  this.activeProvider = config.primary;
@@ -38,6 +48,7 @@ class LoadBalancerLive extends EventEmitter {
38
48
 
39
49
  this.trackers = {};
40
50
  this.velocities = {};
51
+ this.windows = {}; // subscription-window tracker per pool (F2 -> F3 wiring)
41
52
  this.metrics = new Metrics();
42
53
 
43
54
  const rlOpts = config.rate_limits || {};
@@ -60,12 +71,61 @@ class LoadBalancerLive extends EventEmitter {
60
71
  this.velocities[name].on('threshold_warning', (evt) => {
61
72
  this.emit('velocity_warning', evt);
62
73
  });
74
+
75
+ // Subscription-window burn tracker per pool (5h + weekly). Budgets are
76
+ // optional per-provider config; absent -> honest null proximity.
77
+ this.windows[name] = (deps.windows && deps.windows[name]) || new SubscriptionWindow(name, {
78
+ windowTokenBudget: prov.window_token_budget || null,
79
+ weeklyTokenBudget: prov.weekly_token_budget || null,
80
+ });
63
81
  }
64
82
  }
65
83
 
84
+ // Real execution surface (LB-01/LB-04/LB-STATE unblocked): a provider
85
+ // registry, a session bridge for cross-provider context transfer, and the
86
+ // graceful-degradation queue wired to this emitter's rate_limit_change events.
87
+ this.providers = deps.providers || buildProviders(config);
88
+ this.bridge = deps.bridge || (deps.store ? new SessionBridge({ store: deps.store, maxTokens: config.sessions?.context_summary_max_tokens }) : null);
89
+ this._initializedProviders = new Set();
90
+ this.degradation = deps.degradation || new GracefulDegradation({ lb: this });
91
+
66
92
  this.metrics.attachToLoadBalancer(this);
67
93
  }
68
94
 
95
+ // Order of pools to try: preferred first (if not 'auto'), then primary, then
96
+ // configured fallback order. Deduped, enabled-only.
97
+ _providerOrder(prefer) {
98
+ const order = [];
99
+ if (prefer && prefer !== 'auto') order.push(prefer);
100
+ order.push(this.config.primary, ...(this.config.fallback_order || []));
101
+ return [...new Set(order)].filter((n) => this.trackers[n]);
102
+ }
103
+
104
+ async _ensureAvailable(name) {
105
+ const provider = this.providers[name];
106
+ if (!provider) return false;
107
+ if (!this._initializedProviders.has(name)) {
108
+ try {
109
+ await provider.initialize();
110
+ } catch {
111
+ return false;
112
+ }
113
+ this._initializedProviders.add(name);
114
+ }
115
+ try {
116
+ return await provider.isAvailable();
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ _recordWindowUsage(name, response) {
123
+ const w = this.windows[name];
124
+ if (w && typeof w.recordFromResponse === 'function') {
125
+ w.recordFromResponse(response, Date.now());
126
+ }
127
+ }
128
+
69
129
  getTracker(provider) {
70
130
  return this.trackers[provider] || null;
71
131
  }
@@ -201,45 +261,164 @@ class LoadBalancerLive extends EventEmitter {
201
261
  return result;
202
262
  }
203
263
 
204
- async send(opts) {
264
+ // Real send (LB-01 unblocked): walk the provider order, skip pools that are
265
+ // rate-limited (tracker) or unauthenticated/absent (isAvailable), call the
266
+ // first healthy one, record success/429 + window usage. When NONE can serve,
267
+ // return a graceful degraded result (never a throw, never a stub message).
268
+ async send(opts = {}) {
269
+ for (const name of this._providerOrder(opts.preferProvider)) {
270
+ const tracker = this.trackers[name];
271
+ const provider = this.providers[name];
272
+ if (!provider || !tracker || !tracker.canAcceptRequest()) continue;
273
+ if (!(await this._ensureAvailable(name))) continue;
274
+
275
+ try {
276
+ const res = await provider.send({ prompt: opts.prompt, model: opts.model, sessionId: opts.sessionId });
277
+ if (res && res.rateLimited) {
278
+ this.record429(name, res.rateLimitHeaders || {});
279
+ continue; // try the next pool
280
+ }
281
+ this.recordSuccess(name);
282
+ this._recordWindowUsage(name, res);
283
+ this.activeProvider = name;
284
+ return { ...res, provider: name, degraded: false };
285
+ } catch (err) {
286
+ // Hard failure on this pool: record and try the next one.
287
+ this.record429(name, { source: 'send_error' });
288
+ continue;
289
+ }
290
+ }
291
+
205
292
  return {
206
293
  provider: this.activeProvider,
207
- status: 'stub',
208
- message: 'ProviderAdapter not yet integrated (LB-01). Use lb_status/lb_quota for monitoring.',
209
- prompt: opts.prompt?.substring(0, 100),
294
+ content: null,
295
+ degraded: true,
296
+ rateLimited: true,
297
+ reason: 'no provider available (all rate-limited, unauthenticated, or absent)',
210
298
  };
211
299
  }
212
300
 
213
- async switchProvider(opts) {
301
+ // Real switch (LB-04 unblocked): flip the active pool + record history, and —
302
+ // when a session bridge is configured — transfer the portable context so the
303
+ // target pool resumes with the summary/decisions, not a cold start. Without a
304
+ // bridge, the switch still happens; contextTransferred is honestly false.
305
+ async switchProvider(opts = {}) {
214
306
  const prev = this.activeProvider;
215
- this.activeProvider = opts.target;
307
+ const target = opts.target;
308
+
309
+ let injectionPrompt = null;
310
+ let contextTransferred = false;
311
+ if (this.bridge && opts.sessionId && this.providers[prev]) {
312
+ try {
313
+ const transfer = await this.bridge.transfer(
314
+ this.providers[prev], target, opts.sessionId, opts.reason || 'manual_switch'
315
+ );
316
+ injectionPrompt = transfer.injectionPrompt;
317
+ contextTransferred = true;
318
+ } catch {
319
+ contextTransferred = false;
320
+ }
321
+ }
322
+
323
+ this.activeProvider = target;
216
324
  const entry = {
217
325
  timestamp: new Date().toISOString(),
218
326
  from: prev,
219
- to: opts.target,
327
+ to: target,
220
328
  reason: opts.reason || 'manual_switch',
329
+ contextTransferred,
221
330
  };
222
331
  this.switchHistory.push(entry);
223
332
  this.emit('switch', entry);
224
- return {
225
- switched: true,
226
- from: prev,
227
- to: opts.target,
228
- reason: opts.reason,
229
- contextTransferred: false,
230
- message: 'SessionBridge not yet integrated (LB-04). No context transfer.',
231
- };
333
+
334
+ return { switched: true, from: prev, to: target, reason: opts.reason, contextTransferred, injectionPrompt };
232
335
  }
233
336
 
337
+ // Real context read (LB-STATE unblocked): the portable context via the bridge
338
+ // when a store is configured; otherwise the honest current-provider view with a
339
+ // note that no session store is wired (no fabricated "not yet integrated" stub).
234
340
  async getSessionContext(sessionId) {
341
+ const id = sessionId || 'current';
342
+ if (this.bridge && sessionId && this.providers[this.activeProvider]) {
343
+ try {
344
+ const context = await this.bridge.extract(this.providers[this.activeProvider], sessionId);
345
+ return { sessionId, provider: this.activeProvider, context };
346
+ } catch {
347
+ /* fall through to the no-context view */
348
+ }
349
+ }
235
350
  return {
236
- sessionId: sessionId || 'current',
351
+ sessionId: id,
237
352
  provider: this.activeProvider,
238
353
  context: null,
239
- message: 'SharedStateStore not yet integrated (LB-STATE).',
354
+ note: 'no session store configured; context transfer is unavailable this run',
355
+ };
356
+ }
357
+
358
+ // Per-pool subscription-window burn (F2), for the degradation ladder (F5) and
359
+ // the budget surface (F6).
360
+ getWindowStates(now = Date.now()) {
361
+ const out = {};
362
+ for (const [name, w] of Object.entries(this.windows)) {
363
+ out[name] = w.getState(now);
364
+ }
365
+ return out;
366
+ }
367
+
368
+ getDegradationStatus() {
369
+ return this.degradation ? this.degradation.getStatus() : null;
370
+ }
371
+
372
+ // Unified cross-pool budget view — the anti-"limite atteinte" dashboard. Per
373
+ // pool: rolling-5h + weekly burn, proximity (null when no budget is configured
374
+ // -> honest estimate), ETA, recommendation. Plus the current ladder rung. The
375
+ // note states the two hard truths plainly: this is an ESTIMATE (no provider
376
+ // exposes a machine-readable 5h quota), and load-balancing DOUBLES the ceiling,
377
+ // it does not remove it (Codex has its own 5h window).
378
+ getBudget(now = Date.now()) {
379
+ const windows = this.getWindowStates(now);
380
+ const pools = {};
381
+ for (const [name, w] of Object.entries(windows)) {
382
+ pools[name] = {
383
+ windowTokens: w.windowTokens,
384
+ windowProximity: w.windowProximity,
385
+ weekTokens: w.weekTokens,
386
+ weekProximity: w.weekProximity,
387
+ etaMinutes: w.etaMinutes === Infinity ? null : w.etaMinutes,
388
+ recommendation: w.recommendation,
389
+ budgetConfigured: w.windowBudget != null,
390
+ };
391
+ }
392
+ return {
393
+ pools,
394
+ rung: this.planRoute('exploration', now).rung,
395
+ note: 'estimate from observed token usage (no provider exposes a machine-readable 5h quota); load-balancing doubles the ceiling across pools, it does not remove it',
240
396
  };
241
397
  }
242
398
 
399
+ // Advisory routing plan for a task of a given nature: assembles the ladder
400
+ // snapshot from the window tracker + pressure + circuit-breaker state and
401
+ // returns the degradation decision (route to a pool, or queue). Side-effect
402
+ // free (uses canAccept, does not probe provider availability), so it is safe
403
+ // to call for planning; the actual send() still verifies isAvailable.
404
+ planRoute(nature, now = Date.now()) {
405
+ const quota = this.getQuota();
406
+ const windows = this.getWindowStates(now);
407
+ const secondaries = (this.config.fallback_order || []).filter((n) => this.trackers[n]);
408
+ const pools = {};
409
+ for (const name of [this.config.primary, ...secondaries]) {
410
+ const tracker = this.trackers[name];
411
+ if (!tracker) continue;
412
+ pools[name] = {
413
+ windowProximity: windows[name] ? windows[name].windowProximity : null,
414
+ pressureRecommendation: quota[name] ? quota[name].recommendation : 'ok',
415
+ canAccept: tracker.canAcceptRequest(),
416
+ usable: tracker.canAcceptRequest(),
417
+ };
418
+ }
419
+ return decideRoute({ primary: this.config.primary, secondaries, pools }, nature);
420
+ }
421
+
243
422
  destroy() {
244
423
  for (const tracker of Object.values(this.trackers)) {
245
424
  tracker.destroy();
@@ -247,6 +426,9 @@ class LoadBalancerLive extends EventEmitter {
247
426
  for (const ve of Object.values(this.velocities)) {
248
427
  ve.destroy();
249
428
  }
429
+ if (this.degradation && typeof this.degradation.destroy === 'function') {
430
+ this.degradation.destroy();
431
+ }
250
432
  this.removeAllListeners();
251
433
  }
252
434
  }