create-byan-agent 2.39.0 → 2.42.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 (46) hide show
  1. package/CHANGELOG.md +153 -1
  2. package/install/bin/byan-handoff.js +139 -0
  3. package/install/bin/create-byan-agent-v2.js +70 -9
  4. package/install/lib/codex-autodelegate-setup.js +100 -0
  5. package/install/lib/codex-native-setup.js +94 -2
  6. package/install/lib/project-handoff.js +300 -0
  7. package/install/lib/rtk-integration.js +81 -14
  8. package/install/templates/.claude/CLAUDE.md +16 -0
  9. package/install/templates/.claude/hooks/codex-autodelegate.js +74 -0
  10. package/install/templates/.claude/hooks/lib/autodelegate-decision.js +152 -0
  11. package/install/templates/.claude/hooks/lib/perf-routing.js +47 -0
  12. package/install/templates/.claude/hooks/lib/usage-estimator.js +262 -0
  13. package/install/templates/.claude/hooks/tier-script-guard.js +185 -0
  14. package/install/templates/.claude/rules/native-workflows.md +33 -7
  15. package/install/templates/.claude/settings.json +35 -22
  16. package/install/templates/.claude/skills/byan-byan/SKILL.md +21 -2
  17. package/install/templates/.claude/skills/byan-codex/SKILL.md +7 -0
  18. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
  19. package/install/templates/.claude/workflows/sprint-planning.js +2 -2
  20. package/install/templates/.claude/workflows/testarch-trace.js +3 -2
  21. package/install/templates/.codex/skills/byan/SKILL.md +77 -0
  22. package/install/templates/_byan/INDEX.md +6 -2
  23. package/install/templates/_byan/_config/workflow-manifest.csv +1 -1
  24. package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-tier-script.js +52 -0
  25. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +22 -0
  26. package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +26 -9
  27. package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +104 -0
  28. package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +92 -6
  29. package/install/templates/_byan/mcp/byan-mcp-server/server.js +22 -7
  30. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +3 -3
  31. package/install/templates/_byan/workflow/simple/byan/project-handoff-workflow.md +90 -0
  32. package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
  33. package/install/templates/dist/skill-bundles/byan-codex.zip +0 -0
  34. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  35. package/install/templates/docs/codex-auto-delegation.md +140 -0
  36. package/install/templates/docs/native-workflows-contract.md +55 -12
  37. package/package.json +2 -1
  38. package/src/loadbalancer/capability-matrix.js +14 -0
  39. package/src/loadbalancer/degradation-ladder.js +121 -0
  40. package/src/loadbalancer/loadbalancer.default.yaml +23 -1
  41. package/src/loadbalancer/mcp-server.js +200 -18
  42. package/src/loadbalancer/providers/codex-provider.js +260 -0
  43. package/src/loadbalancer/providers/factory.js +36 -0
  44. package/src/loadbalancer/subscription-window.js +142 -0
  45. package/src/loadbalancer/switch-tolerance.js +63 -0
  46. package/src/loadbalancer/tools/index.js +17 -2
@@ -96,20 +96,29 @@ tier vocabulary, the leaf classifier, and the model map.
96
96
  | Tier | `opts.model` | Used for |
97
97
  |------|--------------|----------|
98
98
  | `deep` | **omitted** (inherit the session model) | implement, verify, analysis — the default |
99
- | `balanced` | `sonnet` | mid-weight leaf, explicit manual opt-in only |
99
+ | `balanced` | `sonnet` | MECHANICAL verification, opt-in only via the `mech-` label prefix |
100
100
  | `cheap` | `haiku` | a pure exploration leaf: read / load / parse / detect |
101
101
 
102
- Two hard rules:
102
+ Three hard rules:
103
103
 
104
104
  - **No pin-up.** `deep` is an omission, not `model: 'opus'`. Omitting lets a
105
105
  leaf inherit whatever the session runs — Opus by default, Sonnet if the user
106
106
  chose Sonnet. Pinning a fixed high tier would override that and could silently
107
107
  downgrade a Sonnet/Opus session's heavy leaf.
108
- - **Only exploration downgrades.** A leaf is pinned to `cheap` only when it is
109
- unambiguous read/extract work. `classifyLeaf` keys off the LABEL (the prompt
110
- is too noisy — an exploration leaf often says "report what you found").
111
- Protected types (implementation / verification / analysis) and any unknown
112
- label default to `deep`.
108
+ - **Only exploration and mech- downgrade.** A leaf is pinned to `cheap` only
109
+ when it is unambiguous read/extract work. `classifyLeaf` keys off the LABEL
110
+ (the prompt is too noisy — an exploration leaf often says "report what you
111
+ found"). Protected types (implementation / verification / analysis) and any
112
+ unknown label default to `deep`.
113
+ - **`mech-` is a held declaration.** The `balanced` tier is reachable ONLY
114
+ through the explicit `mech-` label prefix (`mech-validate-json`): a binary,
115
+ judgment-free check — JSON parses, schema matches, lint passes — whose FIXING
116
+ (if any) happens in a different leaf. The prefix wins over keyword
117
+ classification (that is the point of the opt-in), and the linter holds the
118
+ script to it: a `mech-` leaf with no model (`mechanical-without-model`) or
119
+ with `haiku` (`mechanical-below-tier`) is a hard violation. Verification that
120
+ reads MEANING (content vs requirements, coverage adequacy, adversarial
121
+ review) is not mechanical and stays deep.
113
122
 
114
123
  The classifier is permissive (it labels by keyword), so it is a FLOOR, not a
115
124
  ceiling: the linter forbids downgrading a protected leaf, but it does not force
@@ -146,13 +155,15 @@ verbatim/gate sink, making the re-read net-negative or marginal.
146
155
  Enforcement (because the in-session hooks do not fire inside a script):
147
156
 
148
157
  - `workflows-lint.js` -> `modelRoutingViolations` rejects (a) a `model:` value
149
- that is not a known downgrade tier, (b) a downgrade on a non-exploration leaf
150
- (`protected-leaf-downgraded`), (c) a downgrade with no in-object label.
151
- It is part of `validateContract`, so `byan-lint-workflows` and the pre-commit
152
- gate enforce it.
158
+ that is not a known downgrade tier, (b) a downgrade on a protected leaf
159
+ (`protected-leaf-downgraded`), (c) a downgrade with no in-object label,
160
+ (d) `haiku` on a `mech-` leaf (`mechanical-below-tier`); and
161
+ `mechanicalLabelViolations` rejects a `mech-` leaf with no model at all
162
+ (`mechanical-without-model`). Both are part of `validateContract`, so
163
+ `byan-lint-workflows` and the pre-commit gate enforce them.
153
164
  - `test/native-routing-integration.test.js` pins the invariant on the SHIPPED
154
165
  scripts: every script passes the contract, and every downgrade sits on an
155
- exploration leaf.
166
+ exploration or `mech-` leaf.
156
167
  - `workflows-lint.js` -> `untieredExplorationViolations` is the SYMMETRIC
157
168
  advisory: it surfaces an exploration-labelled leaf that runs deep (a possible
158
169
  saving). It is DELIBERATELY OUT of `validateContract` — forcing those leaves to
@@ -162,6 +173,38 @@ Enforcement (because the in-session hooks do not fire inside a script):
162
173
  knob (the native `agent()` / Agent API exposes only `model`), so model tier is
163
174
  the sole token lever and effort-by-complexity reduces to model-by-complexity.
164
175
 
176
+ ## Ad-hoc scripts — the tier gate at the Workflow chokepoint
177
+
178
+ The repo linter only sees committed `.claude/workflows/*.js`. A script authored
179
+ inline for one run (the "adversarial review" pattern) is invisible to it and
180
+ used to run every leaf on the session model. It crosses exactly one enforceable
181
+ chokepoint before executing — the Workflow tool invocation — and that is where
182
+ the gate now sits:
183
+
184
+ | Piece | File | Role |
185
+ |-------|------|------|
186
+ | Engine | `lib/tier-script.js` | per-leaf report (`analyzeScript`) + pure gate decision (`decideTierGate`) against native-tiers; parsing stays in `workflows-lint.js` (`extractLabelledLeaves`) |
187
+ | Hook | `.claude/hooks/tier-script-guard.js` | PreToolUse (matcher `Workflow`), CJS shell around the engine |
188
+ | CLI | `bin/byan-tier-script.js` | standalone report on any script file; exit 0 clean/acknowledged, 1 gaps, 2 violations |
189
+ | Batch aid | `byan_dispatch { leaves: [...] }` | per-leaf `opts.model` BEFORE the script is written |
190
+ | Ledger | `_byan-output/tier-ledger.jsonl` | every gate decision + per-model histogram — the measurement basis for token gains |
191
+
192
+ Gate behaviour, deliberately narrow:
193
+
194
+ - **Deny once, with the exact fix.** Undecided exploration/`mech-` leaves deny
195
+ the invocation ONE time, listing each leaf and the value to write. An
196
+ identical resubmission passes — the gate forces a decision, it does not trap
197
+ the turn (same one-regen shape as the autobench Stop hook).
198
+ - **It rewrites nothing.** An auto-stamped model on a misclassified leaf would
199
+ be the STRICT-2 No Downgrade regression; the author keeps the pen.
200
+ - **Acknowledgment marker.** `// BYAN-TIER: reviewed` in the script asserts the
201
+ deep choices are deliberate; the gate then passes and logs `acknowledged`.
202
+ - **Registry passes.** A name-only invocation resolves to a committed script the
203
+ pre-commit linter already owns.
204
+ - **Escape hatch.** `touch .byan-tier/off` disables gating; the ledger still
205
+ records `escape-hatch` so misses stay auditable. Deny-once memory lives in the
206
+ gitignored `.byan-tier/` sidecar, keyed by script hash.
207
+
165
208
  If a future runtime needs full model ids instead of the `haiku`/`sonnet`
166
209
  aliases, `TIER_MODEL` in `native-tiers.js` is the only edit; the linter then
167
210
  flags every script literal that drifts from it, so the fan-out stays bounded.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "create-byan-agent",
3
- "version": "2.39.0",
3
+ "version": "2.42.0",
4
4
  "description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Claude Code, Codex). Merise Agile + TDD + 71 Mantras. ~54% LLM cost savings.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "create-byan-agent": "./install/bin/create-byan-agent-v2.js",
8
8
  "byan-v2": "./install/bin/create-byan-agent-v2.js",
9
9
  "byan-cleanup": "./install/bin/byan-cleanup.js",
10
+ "byan-handoff": "./install/bin/byan-handoff.js",
10
11
  "byan-ledger": "./install/bin/byan-ledger.js",
11
12
  "byan-kanban": "./install/bin/byan-kanban.js",
12
13
  "update-byan-agent": "./update-byan-agent/bin/update-byan-agent.js"
@@ -35,6 +35,20 @@ const DEFAULT_CAPABILITIES = {
35
35
  tool_use: true,
36
36
  image_input: true,
37
37
  },
38
+ codex: {
39
+ file_edit: true,
40
+ file_read: true,
41
+ bash: true,
42
+ web_search: false,
43
+ mcp_tools: true,
44
+ subagents: false,
45
+ git: true,
46
+ streaming: false, // codex exec is request/response (JSONL events, not token stream)
47
+ multi_turn: true, // thread id enables continuation
48
+ max_context_tokens: 256000,
49
+ tool_use: true,
50
+ image_input: false,
51
+ },
38
52
  byan_api: {
39
53
  file_edit: false,
40
54
  file_read: false,
@@ -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
  }