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,260 @@
1
+ /**
2
+ * CodexProvider — wraps the OpenAI Codex CLI (`codex exec`) as a load-balancer pool.
3
+ *
4
+ * Unlike ClaudeProvider / CopilotProvider (npm SDKs), Codex ships as a SYSTEM CLI
5
+ * (`/usr/bin/codex`, codex-cli). So this provider spawns the binary in headless
6
+ * mode (`codex exec --json`) and parses the JSON Lines stream, rather than
7
+ * require()-ing a package. If the binary is absent the provider degrades to
8
+ * initialized=false, exactly like a missing SDK — the load-balancer then skips it.
9
+ *
10
+ * Two auth pools back the same binary (the arbitrage the subscription tracker
11
+ * reads): CODEX_API_KEY (billed per token, no weekly cap) wins when set, else the
12
+ * ChatGPT-subscription session in ~/.codex/auth.json (the 5h + weekly window).
13
+ *
14
+ * Entitled-model rule (live-verified on codex-cli 0.101 against a ChatGPT
15
+ * subscription): the OpenAI backend REJECTS every `-codex`-suffixed model on a
16
+ * subscription account ("The '<model>' model is not supported when using Codex
17
+ * with a ChatGPT account") — those ids are API-key only. Only the plain gpt-5.x
18
+ * family (e.g. gpt-5.4) is entitled on a subscription. So the default model is a
19
+ * plain entitled id, and resolveCodexModel normalizes a `-codex` request to that
20
+ * default when the active pool is the subscription. On an API key, `-codex`
21
+ * models bill fine and pass through untouched.
22
+ *
23
+ * Security posture for automation: a bounded sandbox (`-s`, default read-only)
24
+ * keeps a delegated run safe; a workspace-write run is opt-in via config. There
25
+ * is no approval flag — `codex exec` is already non-interactive, so no `-a` is
26
+ * passed (it would be a hard "unexpected argument" error; see send()). The API
27
+ * key is read from the environment and passed to the child process env; it is
28
+ * never logged, never written to disk, never placed in argv.
29
+ *
30
+ * Testability: parseCodexJsonl and detectCodexAuth are pure; the spawn is behind
31
+ * this._runCodex and the binary probe behind this._probeBinary, so the unit tests
32
+ * never touch the real CLI, the network, or the user's OpenAI quota.
33
+ */
34
+
35
+ const os = require('os');
36
+ const path = require('path');
37
+ const fs = require('fs');
38
+ const { spawn } = require('child_process');
39
+ const { BaseProvider } = require('./base-provider');
40
+
41
+ // Parse a `codex exec --json` JSON Lines stream into { content, usage, threadId }.
42
+ // Defensive by construction: blank lines and non-JSON noise are skipped (the CLI
43
+ // may emit progress/log lines), agent-message items are concatenated in order,
44
+ // and usage falls back to zero (never undefined) so downstream accounting is safe.
45
+ function parseCodexJsonl(stdout) {
46
+ const parts = [];
47
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
48
+ let threadId = null;
49
+
50
+ for (const rawLine of String(stdout || '').split('\n')) {
51
+ const line = rawLine.trim();
52
+ if (!line) continue;
53
+ let ev;
54
+ try {
55
+ ev = JSON.parse(line);
56
+ } catch {
57
+ continue; // non-JSON progress/log noise
58
+ }
59
+ if (!ev || typeof ev !== 'object') continue;
60
+
61
+ if (ev.type === 'thread.started' && ev.thread && ev.thread.id) {
62
+ threadId = ev.thread.id;
63
+ continue;
64
+ }
65
+
66
+ // Agent output: item events carrying a text payload. The item schema varies
67
+ // by codex version, so read text from the common shapes.
68
+ const item = ev.item || ev;
69
+ const itemType = item && item.type;
70
+ if (itemType === 'agent_message' || itemType === 'assistant_message' || itemType === 'message') {
71
+ const text = item.text || item.content || item.message;
72
+ if (typeof text === 'string' && text) parts.push(text);
73
+ continue;
74
+ }
75
+
76
+ if (ev.type === 'turn.completed' && ev.usage) {
77
+ const u = ev.usage;
78
+ const input = Number(u.input_tokens ?? u.input ?? 0) || 0;
79
+ const output = Number(u.output_tokens ?? u.output ?? 0) || 0;
80
+ usage = { inputTokens: input, outputTokens: output, totalTokens: input + output };
81
+ }
82
+ }
83
+
84
+ return { content: parts.join('\n'), usage, threadId };
85
+ }
86
+
87
+ // Decide which OpenAI pool backs the CLI. API key wins (per-token, no weekly cap);
88
+ // else the ChatGPT-subscription session file; else no auth. Pure — env and fs are
89
+ // injected so it is testable without a real home dir.
90
+ function detectCodexAuth({ env = process.env, fs: fsImpl = fs, home = os.homedir() } = {}) {
91
+ if (env.CODEX_API_KEY) return 'api-key';
92
+ const authPath = path.join(home, '.codex', 'auth.json');
93
+ try {
94
+ if (fsImpl.existsSync(authPath)) return 'subscription';
95
+ } catch {
96
+ /* fall through */
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // Rate-limit / quota-exhaustion detection from a failed codex exec. Codex has no
102
+ // machine-readable quota (OpenAI issue #10233), so we key off the stderr text of
103
+ // a non-zero exit. Conservative: only clear usage-limit language counts as a
104
+ // rate-limit (a soft, retryable signal); any other failure is a hard error.
105
+ const RATE_LIMIT_RE = /(usage limit|rate limit|rate_limit|quota|429|too many requests|limit reached)/i;
106
+
107
+ function isRateLimitFailure({ code, stderr }) {
108
+ return code !== 0 && RATE_LIMIT_RE.test(String(stderr || ''));
109
+ }
110
+
111
+ // The plain gpt-5.x id verified entitled on a ChatGPT subscription (codex-cli
112
+ // 0.101). The CLI/companion default is a `-codex` id that a subscription rejects,
113
+ // so this is the safe default and the subscription remap target.
114
+ const SUBSCRIPTION_DEFAULT_MODEL = 'gpt-5.4';
115
+
116
+ // Pick the model that will actually be accepted by the active auth pool. On a
117
+ // subscription, a `-codex`-suffixed id is API-only and would be rejected, so it
118
+ // is remapped to the known-entitled default (we only remap what we KNOW fails,
119
+ // and only to the one id verified to work — we do not guess stripped variants).
120
+ // On an API key, or for an already-plain id, the request passes through. Pure.
121
+ function resolveCodexModel({ requested, authPool } = {}) {
122
+ const model = String(requested || '').trim();
123
+ if (!model) return SUBSCRIPTION_DEFAULT_MODEL;
124
+ if (authPool === 'subscription' && /-codex$/i.test(model)) {
125
+ return SUBSCRIPTION_DEFAULT_MODEL;
126
+ }
127
+ return model;
128
+ }
129
+
130
+ class CodexProvider extends BaseProvider {
131
+ constructor(providerConfig = {}) {
132
+ super('codex', providerConfig);
133
+ this._auth = null;
134
+ }
135
+
136
+ // Probe whether the codex binary is on PATH (spawn `codex --version`). Seam for
137
+ // tests. Resolves boolean, never throws.
138
+ async _probeBinary() {
139
+ return new Promise((resolve) => {
140
+ let done = false;
141
+ const finish = (ok) => { if (!done) { done = true; resolve(ok); } };
142
+ try {
143
+ const bin = this.config.bin || 'codex';
144
+ const child = spawn(bin, ['--version'], { stdio: 'ignore' });
145
+ child.on('error', () => finish(false));
146
+ child.on('close', (code) => finish(code === 0));
147
+ } catch {
148
+ finish(false);
149
+ }
150
+ });
151
+ }
152
+
153
+ _detectAuth() {
154
+ return detectCodexAuth({});
155
+ }
156
+
157
+ async initialize() {
158
+ const present = await this._probeBinary();
159
+ this.initialized = present === true;
160
+ if (this.initialized) this._auth = this._detectAuth();
161
+ }
162
+
163
+ authPool() {
164
+ return this._detectAuth();
165
+ }
166
+
167
+ async isAvailable() {
168
+ if (!this.initialized) return false;
169
+ return this._detectAuth() !== null;
170
+ }
171
+
172
+ // Spawn `codex exec --json` and collect { stdout, stderr, code }. Seam for tests.
173
+ // The prompt is passed on stdin (never argv — avoids leaking it into ps output).
174
+ async _runCodex(args, input, env) {
175
+ return new Promise((resolve, reject) => {
176
+ let child;
177
+ try {
178
+ child = spawn(this.config.bin || 'codex', args, {
179
+ env: { ...process.env, ...env },
180
+ stdio: ['pipe', 'pipe', 'pipe'],
181
+ });
182
+ } catch (err) {
183
+ reject(err);
184
+ return;
185
+ }
186
+ let stdout = '';
187
+ let stderr = '';
188
+ child.stdout.on('data', (d) => { stdout += d; });
189
+ child.stderr.on('data', (d) => { stderr += d; });
190
+ child.on('error', reject);
191
+ child.on('close', (code) => resolve({ stdout, stderr, code }));
192
+ if (input) child.stdin.write(input);
193
+ child.stdin.end();
194
+ });
195
+ }
196
+
197
+ async send(opts) {
198
+ if (!this.initialized) throw new Error('CodexProvider not initialized');
199
+
200
+ const start = Date.now();
201
+ const requested = opts.model || this.config.models?.agent || SUBSCRIPTION_DEFAULT_MODEL;
202
+ // Normalize to an id the active auth pool will accept (a -codex model is
203
+ // API-only; a subscription would reject it). See resolveCodexModel.
204
+ const model = resolveCodexModel({ requested, authPool: this._detectAuth() });
205
+ const sandbox = this.config.sandbox || 'read-only';
206
+
207
+ // Non-interactive automation contract, verified against codex-cli 0.101.0
208
+ // `codex exec --help`: exec is ALREADY non-interactive (reads the prompt from
209
+ // stdin, prints JSONL events) so there is no `-a`/approval flag on the exec
210
+ // subcommand — passing one is a hard "unexpected argument" error. The bounded
211
+ // sandbox (`-s read-only` by default) is what keeps a delegated run safe; a
212
+ // workspace-write run is an explicit config opt-in.
213
+ const args = ['exec', '--json', '-s', sandbox, '-m', model];
214
+
215
+ const { stdout, stderr, code } = await this._runCodex(args, opts.prompt, {});
216
+
217
+ if (isRateLimitFailure({ code, stderr })) {
218
+ return {
219
+ provider: this.name,
220
+ content: null,
221
+ model,
222
+ rateLimitHeaders: null,
223
+ latencyMs: Date.now() - start,
224
+ rateLimited: true,
225
+ };
226
+ }
227
+
228
+ if (code !== 0) {
229
+ throw new Error(`codex exec failed (exit ${code}): ${String(stderr).split('\n')[0]}`);
230
+ }
231
+
232
+ const parsed = parseCodexJsonl(stdout);
233
+ return {
234
+ provider: this.name,
235
+ content: parsed.content,
236
+ model,
237
+ threadId: parsed.threadId,
238
+ usage: parsed.usage,
239
+ rateLimitHeaders: null,
240
+ latencyMs: Date.now() - start,
241
+ rateLimited: false,
242
+ };
243
+ }
244
+
245
+ getCapabilities() {
246
+ return {
247
+ streaming: false, // exec is request/response; --json streams events, not tokens
248
+ tools: true, // codex runs tools in its sandbox
249
+ multiTurn: true, // thread id enables continuation
250
+ maxContextTokens: this.config.max_context_tokens || 256000,
251
+ };
252
+ }
253
+
254
+ async destroy() {
255
+ this._auth = null;
256
+ await super.destroy();
257
+ }
258
+ }
259
+
260
+ module.exports = { CodexProvider, parseCodexJsonl, detectCodexAuth, isRateLimitFailure, resolveCodexModel, SUBSCRIPTION_DEFAULT_MODEL };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Provider factory — build the { name: ProviderInstance } registry from config.
3
+ *
4
+ * The one place that knows which provider class backs each config name. The
5
+ * LoadBalancer engine and the MCP shell both consume this instead of hand-wiring
6
+ * instances, so adding a pool (codex) is one line here, not N call sites.
7
+ */
8
+
9
+ const { ClaudeProvider } = require('./claude-provider');
10
+ const { CopilotProvider } = require('./copilot-provider');
11
+ const { CodexProvider } = require('./codex-provider');
12
+ const { ByanApiProvider } = require('./byan-api-provider');
13
+
14
+ const PROVIDER_CLASSES = Object.freeze({
15
+ claude: ClaudeProvider,
16
+ copilot: CopilotProvider,
17
+ codex: CodexProvider,
18
+ byan_api: ByanApiProvider,
19
+ });
20
+
21
+ // buildProviders(config) -> { name: instance } for every ENABLED provider whose
22
+ // name is known. Unknown names are skipped (never fatal) so a typo in config
23
+ // degrades to "that pool is absent", not a crash.
24
+ function buildProviders(config) {
25
+ const out = {};
26
+ const providers = (config && config.providers) || {};
27
+ for (const [name, section] of Object.entries(providers)) {
28
+ if (section && section.enabled === false) continue;
29
+ const Cls = PROVIDER_CLASSES[name];
30
+ if (!Cls) continue;
31
+ out[name] = new Cls(section || {});
32
+ }
33
+ return out;
34
+ }
35
+
36
+ module.exports = { buildProviders, PROVIDER_CLASSES };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * SubscriptionWindow — per-pool burn against the rolling 5h + weekly caps.
3
+ *
4
+ * pressure-score.js answers "am I getting 429'd right now" (an API burst signal
5
+ * from the circuit breaker). This module answers the ORTHOGONAL question the 5h
6
+ * subscription pain is actually about: "how much of my rolling 5-hour and weekly
7
+ * budget have I already burned on this pool" (Claude Pro/Max = chat+code shared;
8
+ * Codex = local+cloud shared). The degradation ladder (F5) reads THIS to switch
9
+ * delegable work to the other pool BEFORE the wall, not after the first 429.
10
+ *
11
+ * Honest by construction: neither Claude nor Codex exposes a machine-readable 5h
12
+ * quota (OpenAI issue #10233). So this is an ESTIMATE from observed token usage.
13
+ * Without a configured budget it reports raw burn + velocity and a NULL proximity
14
+ * (never a fabricated percentage); with a budget it reports proximity + an ETA to
15
+ * exhaustion at recent velocity. The surface (F6) reconciles against /usage.
16
+ *
17
+ * The core (computeWindowState) is pure — `now` is injected, no Date.now — so the
18
+ * rolling windows are deterministic under test.
19
+ */
20
+
21
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
22
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
23
+ const DEFAULT_VELOCITY_WINDOW_MS = 15 * 60 * 1000; // recent-rate window for ETA
24
+
25
+ function sumTokensSince(events, cutoff) {
26
+ let total = 0;
27
+ for (const e of events) {
28
+ if (e && e.timestamp > cutoff && Number.isFinite(e.tokens)) total += e.tokens;
29
+ }
30
+ return total;
31
+ }
32
+
33
+ function clampProximity(tokens, budget) {
34
+ if (!budget || budget <= 0) return null; // no budget => estimate only, never fabricate a %
35
+ return Math.min(1, tokens / budget);
36
+ }
37
+
38
+ // Pure. events: [{ timestamp, tokens }]. Returns the window/week burn, proximity
39
+ // (null when the matching budget is absent), and an ETA to window-budget
40
+ // exhaustion at the recent velocity.
41
+ function computeWindowState(events, opts = {}) {
42
+ const {
43
+ now,
44
+ windowMs = FIVE_HOURS_MS,
45
+ weekMs = WEEK_MS,
46
+ windowTokenBudget = null,
47
+ weeklyTokenBudget = null,
48
+ velocityWindowMs = DEFAULT_VELOCITY_WINDOW_MS,
49
+ } = opts;
50
+
51
+ const windowTokens = sumTokensSince(events, now - windowMs);
52
+ const weekTokens = sumTokensSince(events, now - weekMs);
53
+
54
+ const windowProximity = clampProximity(windowTokens, windowTokenBudget);
55
+ const weekProximity = clampProximity(weekTokens, weeklyTokenBudget);
56
+
57
+ // ETA: recent velocity (tokens/min over velocityWindowMs) projected onto the
58
+ // remaining window budget. Infinite when idle or when no budget is known.
59
+ let etaMinutes = Infinity;
60
+ if (windowTokenBudget && windowTokenBudget > 0) {
61
+ const recentTokens = sumTokensSince(events, now - velocityWindowMs);
62
+ const perMin = recentTokens / (velocityWindowMs / 60000);
63
+ const remaining = Math.max(0, windowTokenBudget - windowTokens);
64
+ if (perMin > 0) etaMinutes = Math.round(remaining / perMin);
65
+ }
66
+
67
+ return {
68
+ windowTokens,
69
+ weekTokens,
70
+ windowProximity,
71
+ weekProximity,
72
+ windowBudget: windowTokenBudget,
73
+ weeklyBudget: weeklyTokenBudget,
74
+ etaMinutes,
75
+ };
76
+ }
77
+
78
+ // Recommendation from a proximity in [0,1]. Mirrors pressure-score's bands so the
79
+ // two signals speak the same language to the ladder. Null proximity => unknown.
80
+ function windowRecommendation(proximity) {
81
+ if (proximity === null || proximity === undefined) return 'unknown';
82
+ if (proximity >= 0.8) return 'switch_now';
83
+ if (proximity >= 0.5) return 'caution';
84
+ return 'ok';
85
+ }
86
+
87
+ class SubscriptionWindow {
88
+ /**
89
+ * @param {string} pool - 'claude' | 'codex' | ...
90
+ * @param {object} opts - { windowTokenBudget, weeklyTokenBudget, windowMs, weekMs, velocityWindowMs }
91
+ */
92
+ constructor(pool, opts = {}) {
93
+ this.pool = pool;
94
+ this.opts = opts;
95
+ this.weekMs = opts.weekMs || WEEK_MS;
96
+ this.events = [];
97
+ }
98
+
99
+ record({ tokens, timestamp }) {
100
+ if (!Number.isFinite(tokens) || tokens <= 0) return;
101
+ this.events.push({ tokens, timestamp });
102
+ }
103
+
104
+ // Extract usage from a ProviderResponse ({ usage: { totalTokens } }). No-op when
105
+ // the response carries no usage (e.g. a rate-limited response).
106
+ recordFromResponse(response, timestamp) {
107
+ const total = response && response.usage && Number(response.usage.totalTokens);
108
+ if (Number.isFinite(total) && total > 0) this.record({ tokens: total, timestamp });
109
+ }
110
+
111
+ _prune(now) {
112
+ const cutoff = now - this.weekMs;
113
+ this.events = this.events.filter((e) => e.timestamp > cutoff);
114
+ }
115
+
116
+ getState(now) {
117
+ this._prune(now);
118
+ const state = computeWindowState(this.events, { now, ...this.opts });
119
+ return {
120
+ pool: this.pool,
121
+ ...state,
122
+ recommendation: windowRecommendation(state.windowProximity),
123
+ };
124
+ }
125
+
126
+ eventCount() {
127
+ return this.events.length;
128
+ }
129
+
130
+ reset() {
131
+ this.events = [];
132
+ }
133
+ }
134
+
135
+ module.exports = {
136
+ SubscriptionWindow,
137
+ computeWindowState,
138
+ windowRecommendation,
139
+ sumTokensSince,
140
+ FIVE_HOURS_MS,
141
+ WEEK_MS,
142
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * switch-tolerance — WHICH task natures may cross to a secondary provider pool.
3
+ *
4
+ * The load-balancer's model tier answers "which model" (native-tiers). THIS
5
+ * answers the orthogonal, identity-critical axis the user drew as the red line:
6
+ * "reduce tokens WITHOUT denaturing BYAN". Degradation (F5) may offload only
7
+ * DELEGABLE work to a secondary pool (Codex): mechanical checks (machine-
8
+ * verifiable output), exploration (read/scan), and implementation (where tests
9
+ * are the objective arbiter, provider-independent). Everything that carries
10
+ * BYAN's judgment or identity — verification (adversarial/semantic), analysis,
11
+ * and anything soul/identity/review/gate — stays on the primary (Claude) whatever
12
+ * the subscription pressure. This module is the single source of that line.
13
+ *
14
+ * Pure and dependency-light: an optional CapabilityMatrix filters secondaries by
15
+ * required capability, but the tolerance decision itself is a static classification.
16
+ */
17
+
18
+ const DELEGABLE_NATURES = Object.freeze(['exploration', 'mechanical', 'implementation']);
19
+ const PRIMARY_ONLY_NATURES = Object.freeze([
20
+ 'verification', // adversarial / semantic review = BYAN judgment
21
+ 'analysis', // design / risk / evaluation = BYAN judgment
22
+ 'soul', // identity
23
+ 'identity',
24
+ 'review', // the adversarial second pair of eyes stays on the primary
25
+ 'gate', // phase / strict gates
26
+ ]);
27
+
28
+ const SWITCH_TOLERANCE = Object.freeze({
29
+ ...Object.fromEntries(DELEGABLE_NATURES.map((n) => [n, 'delegable'])),
30
+ ...Object.fromEntries(PRIMARY_ONLY_NATURES.map((n) => [n, 'primary-only'])),
31
+ });
32
+
33
+ // Conservative: only an explicitly-delegable nature is delegable. Unknown /
34
+ // missing nature => primary-only (never delegate on a guess — the red line).
35
+ function isDelegable(nature) {
36
+ return SWITCH_TOLERANCE[nature] === 'delegable';
37
+ }
38
+
39
+ // eligibleProviders(nature, { primary, secondaries, capabilityMatrix, required })
40
+ // -> ordered provider list, primary first. A primary-only nature returns only the
41
+ // primary. A delegable nature returns primary + secondaries that satisfy the
42
+ // required capabilities (when a matrix + required list are supplied).
43
+ function eligibleProviders(nature, opts = {}) {
44
+ const { primary, secondaries = [], capabilityMatrix = null, required = [] } = opts;
45
+ if (!primary) return [];
46
+ if (!isDelegable(nature)) return [primary];
47
+
48
+ let allowed = secondaries;
49
+ if (capabilityMatrix && required.length > 0) {
50
+ allowed = secondaries.filter((name) =>
51
+ required.every((cap) => capabilityMatrix.supports(name, cap))
52
+ );
53
+ }
54
+ return [primary, ...allowed.filter((n) => n !== primary)];
55
+ }
56
+
57
+ module.exports = {
58
+ SWITCH_TOLERANCE,
59
+ DELEGABLE_NATURES,
60
+ PRIMARY_ONLY_NATURES,
61
+ isDelegable,
62
+ eligibleProviders,
63
+ };
@@ -27,7 +27,7 @@ function createTools(lb) {
27
27
  properties: {
28
28
  prompt: { type: 'string', description: 'The prompt to send' },
29
29
  session_id: { type: 'string', description: 'Optional session ID for sticky routing' },
30
- prefer_provider: { type: 'string', enum: ['claude', 'copilot', 'auto'], description: 'Provider preference (default: auto)' },
30
+ prefer_provider: { type: 'string', enum: ['claude', 'copilot', 'codex', 'auto'], description: 'Provider preference (default: auto)' },
31
31
  },
32
32
  required: ['prompt'],
33
33
  },
@@ -46,7 +46,7 @@ function createTools(lb) {
46
46
  inputSchema: {
47
47
  type: 'object',
48
48
  properties: {
49
- target_provider: { type: 'string', enum: ['claude', 'copilot'], description: 'Provider to switch to' },
49
+ target_provider: { type: 'string', enum: ['claude', 'copilot', 'codex'], description: 'Provider to switch to' },
50
50
  session_id: { type: 'string', description: 'Session to transfer context from' },
51
51
  reason: { type: 'string', description: 'Reason for the switch' },
52
52
  },
@@ -117,6 +117,21 @@ function createTools(lb) {
117
117
  return { content: [{ type: 'text', text: summaries + '\n\n' + JSON.stringify(quota, null, 2) }] };
118
118
  },
119
119
  },
120
+ {
121
+ name: 'lb_budget',
122
+ description: 'Unified cross-pool subscription budget: rolling-5h + weekly token burn per pool (Claude / Codex), proximity to the cap (null when no budget configured), ETA to exhaustion, and the current degradation rung. The anti-"5h limit reached" dashboard. Honest estimate: no provider exposes a machine-readable 5h quota, and load-balancing doubles the ceiling across pools without removing it.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {},
126
+ },
127
+ handler: async () => {
128
+ if (typeof lb.getBudget !== 'function') {
129
+ return { content: [{ type: 'text', text: 'lb_budget requires LoadBalancerLive with the subscription-window tracker.' }] };
130
+ }
131
+ const budget = lb.getBudget();
132
+ return { content: [{ type: 'text', text: JSON.stringify(budget, null, 2) }] };
133
+ },
134
+ },
120
135
  ];
121
136
  }
122
137