agent-runway 0.2.1 → 0.2.2

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.
@@ -11,7 +11,7 @@
11
11
  "displayName": "Agent Runway",
12
12
  "source": "./",
13
13
  "description": "Quota and model discovery for the coding agents installed on this machine: how much runway each has left, and which model slugs each binary will actually accept.",
14
- "version": "0.2.1",
14
+ "version": "0.2.2",
15
15
  "author": {
16
16
  "name": "jberdah",
17
17
  "url": "https://github.com/jberdah"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-runway",
3
3
  "displayName": "Agent Runway",
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "description": "Quota and model discovery for the coding agents installed on this machine: how much runway each has left, and which model slugs each binary will actually accept.",
6
6
  "author": {
7
7
  "name": "jberdah",
package/README.md CHANGED
@@ -226,6 +226,25 @@ claude.ai fallback endpoint.
226
226
  Before delegating work, an agent needs both halves of the answer, and getting
227
227
  them from two different tools defeats the point:
228
228
 
229
+ ### What is covered, and what is not
230
+
231
+ Not every agent answers both questions. Antigravity reports a quota and cannot
232
+ be spawned; Gemini can be spawned and publishes no usage endpoint. Reading a
233
+ missing cell as zero would be worse than reading nothing.
234
+
235
+ | Agent | Runway | Model list | `resolve` |
236
+ | --- | --- | --- | --- |
237
+ | Claude | yes — token, or session cookie | inferred, by scanning the binary | yes |
238
+ | Codex | yes | declared, from `codex app-server` | yes |
239
+ | GitHub Copilot | yes — through `gh` | declared, from shell completion | yes |
240
+ | Gemini | **no endpoint** | inferred, by scanning the binary | yes |
241
+ | Antigravity | yes — only while its IDE runs | **not covered** | **not spawnable** |
242
+
243
+ *Declared* means the binary was asked and answered. *Inferred* means slugs were
244
+ recovered from the binary itself: indicative, not authoritative — a spawn can
245
+ still be refused, and every inferred catalogue is labelled as such in the
246
+ output.
247
+
229
248
  **How much runway is left, per provider.**
230
249
 
231
250
  ```bash
@@ -307,8 +326,33 @@ unknown. Three outcomes rather than two, because a provider that could not be
307
326
  read has not got room — it is simply unknown, and must never be counted as
308
327
  either.
309
328
 
310
- The recommendation states its own rule and whether the candidates were even
311
- comparable: 0% of a five-hour window is not 0% of a monthly allowance.
329
+ ### Two questions that are easy to confuse
330
+
331
+ *Can I keep working?* and *could anything on this machine take this job?* are
332
+ not the same question, and answering the second when the first was asked is how
333
+ an agent talks itself into a fan-out it has no room for. Claude at 96% beside
334
+ Codex at 10% is **not** a green light.
335
+
336
+ ```bash
337
+ agent-runway --gate 90 --provider claude # about one agent: yours
338
+ agent-runway --gate 90 # defers unless every provider has room
339
+ agent-runway --gate 90 --any # the fan-out question, asked by name
340
+ ```
341
+
342
+ The answer carries the rule that produced it, so `proceed` can never be read as
343
+ more than it claims:
344
+
345
+ ```json
346
+ "overall": {
347
+ "decision": "defer",
348
+ "rule": "all",
349
+ "ruleText": "every readable provider is under the threshold",
350
+ "scoped": null
351
+ }
352
+ ```
353
+
354
+ The recommendation likewise states its own rule and whether the candidates were
355
+ even comparable: 0% of a five-hour window is not 0% of a monthly allowance.
312
356
 
313
357
  ## CLI reference
314
358
 
@@ -316,20 +360,35 @@ comparable: 0% of a five-hour window is not 0% of a monthly allowance.
316
360
  | --- | --- |
317
361
  | *(none)* | Claude only, readable table |
318
362
  | `--all` | Every provider found on this machine |
363
+ | `--provider <id>` | One provider only — use it when asking about yourself |
319
364
  | `--models` | What each install accepts, and where installs disagree |
320
365
  | `resolve <agent>` | Binary, valid slugs and a verdict on one model |
321
366
  | `--gate <N>` | Decision as JSON plus an exit code |
367
+ | `--any` | With `--gate`: proceed if any one provider has room |
322
368
  | `--short` | One line: `session=15% weekly_all=79% weekly_scoped=52%` |
323
- | `--json` | Raw response; combines with `--models` |
369
+ | `--json` | Normalized JSON, always carrying `tool` / `toolVersion` / `kind` |
370
+ | `--raw` | The provider's own payload, unwrapped — not a stable contract |
324
371
  | `--plain` | Table without the header |
325
372
 
373
+ Everything printed under `--json` declares which question it answers, so a
374
+ parser never has to know what was asked to read the answer:
375
+
376
+ | `kind` | Produced by |
377
+ | --- | --- |
378
+ | `usage` | *(none)*, `--all` |
379
+ | `capacity` | `--gate` |
380
+ | `models` | `--models` |
381
+ | `resolve` | `resolve <agent>` |
382
+
326
383
  | Exit code | Meaning |
327
384
  | --- | --- |
328
- | 0 | success |
329
- | 1 | unexpected error |
385
+ | 0 | success, or a gate that says proceed |
386
+ | 1 | unexpected error, or an unusable argument |
330
387
  | 2 | no token found |
331
388
  | 3 | token rejected — regenerate it |
332
389
  | 4 | the usage endpoint is throttling; not your quota |
390
+ | 10 | gate: defer |
391
+ | 11 | gate: unknown — a provider could not be read |
333
392
 
334
393
  ## Security
335
394
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-runway",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Quota and model discovery for the coding agents installed on this machine: how much runway each has left, and which model slugs each binary will actually accept.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@
14
14
  "url": "https://github.com/jberdah/agent-runway/issues"
15
15
  },
16
16
  "engines": {
17
- "node": ">=18"
17
+ "node": ">=20"
18
18
  },
19
19
  "bin": {
20
20
  "agent-runway": "src/cli.mjs",
@@ -31,7 +31,7 @@
31
31
  "LICENSE"
32
32
  ],
33
33
  "scripts": {
34
- "test": "node --test \"test/**/*.test.mjs\"",
34
+ "test": "node scripts/run-tests.mjs",
35
35
  "start": "node src/cli.mjs",
36
36
  "mcp": "node src/mcp.mjs",
37
37
  "smoke": "node scripts/smoke-mcp.mjs",
@@ -13,18 +13,48 @@ run**.
13
13
  All of these are safe, read-only, and print to stdout.
14
14
 
15
15
  ```bash
16
- agent-runway --all # quota for every provider found
17
- agent-runway --models # what each install accepts, and where installs disagree
18
- agent-runway --gate 90 # decision + exit code: 0 proceed, 10 defer, 11 unknown
16
+ agent-runway --all # quota for every provider found
17
+ agent-runway --models # what each install accepts, and where they disagree
18
+ agent-runway --gate 90 --provider claude # can I keep working? 0 proceed, 10 defer, 11 unknown
19
+ agent-runway --gate 90 --any # could any agent here take this job?
19
20
  agent-runway resolve codex --model gpt-6-astra # one answer before spawning
20
21
  ```
21
22
 
22
- Add `--json` to `--models` and `resolve` for machine-readable output. Bare
23
- `agent-runway` reads Claude only and is the fastest path when that is all you
24
- need.
23
+ `--json` works on every command and always returns the same contract, tagged
24
+ with `kind` (`usage`, `capacity`, `models`, `resolve`). `--raw` returns the
25
+ provider's own payload and promises nothing. Bare `agent-runway` reads Claude
26
+ only and is the fastest path when that is all you need.
25
27
 
26
28
  If the command is not on PATH, run it from the checkout: `node src/cli.mjs …`.
27
29
 
30
+ ## Asking the gate the right question
31
+
32
+ **`--gate` without `--provider` is not about you.** It reads every provider on
33
+ the machine, and by default defers unless all of them have room. Two failure
34
+ modes to avoid:
35
+
36
+ - Asking the bare question and reading `proceed` as *your* runway. Pass
37
+ `--provider claude` (or whichever agent you are running as) when the question
38
+ is whether to continue this session.
39
+ - Asking `--any` and reading it as permission to work here. `--any` answers
40
+ *some agent on this machine has room* — which is about delegation, not about
41
+ you.
42
+
43
+ The answer carries `overall.ruleText`. Quote it rather than paraphrasing
44
+ `proceed`.
45
+
46
+ ## Coverage is uneven, and the gaps matter
47
+
48
+ | Agent | Runway | Models | Spawnable |
49
+ | --- | --- | --- | --- |
50
+ | Claude, Codex, GitHub Copilot | yes | yes | yes |
51
+ | Gemini | **no endpoint exists** | yes | yes |
52
+ | Antigravity | only while its IDE runs | **no** | **no** |
53
+
54
+ Never report a missing cell as zero, and never suggest delegating to
55
+ Antigravity. If asked about Gemini's quota, say there is no endpoint to read
56
+ rather than implying it is at zero or unknown-but-checkable.
57
+
28
58
  ## Reading a quota answer
29
59
 
30
60
  - A `*` marks the window **closest to its limit**, which is not the one being
@@ -71,6 +101,20 @@ refused, so a script can branch on it.
71
101
  **Offer the substitute, never apply it silently.** Running a different model
72
102
  than the user asked for, without saying so, is worse than failing.
73
103
 
104
+ ## When the answer is defer
105
+
106
+ The tool stops at `retryAt` and `retryAtBasis`. It does not wake anything up,
107
+ and it cannot save a conversation — a session that runs out of quota loses its
108
+ context whatever this reports.
109
+
110
+ So before proposing to wait for a reset, **write the state down**: what was
111
+ done, what is left, the files and paths involved, in a file the user can hand
112
+ to a fresh session. Offer the reset time as information the user acts on, not
113
+ as a schedule the agent will keep.
114
+
115
+ `retryAtBasis: "window_reset"` means the window rolls over then. It is not a
116
+ promise that service resumes at that moment; say so when quoting it.
117
+
74
118
  ## Rules
75
119
 
76
120
  - Never print, log or copy a credential, not even a prefix. The tool never
package/src/cli.mjs CHANGED
@@ -20,12 +20,19 @@ Commands:
20
20
 
21
21
  Options:
22
22
  --all Every provider found on this machine, not just Claude
23
+ --provider <id> Read one provider only: claude, codex, copilot, antigravity.
24
+ Use this when asking about yourself rather than the machine.
23
25
  --models What each installed agent will accept as a model, and
24
26
  where two installs of the same agent disagree
25
27
  --gate <N> Decide: is there room to start work, at threshold N percent?
26
28
  Prints JSON. Exit 0 proceed, 10 defer, 11 unknown.
29
+ Defers unless every provider read is under the threshold.
30
+ --any With --gate, proceed when any one provider has room. This is
31
+ the fan-out question, and it is not the same as "can I go on".
27
32
  --short One line, machine friendly: session=79% weekly_all=76%
28
- --json Raw API response, unformatted
33
+ --json Normalized JSON. Every shape carries tool/version/kind, so a
34
+ parser can tell which question it is looking at the answer to.
35
+ --raw The provider's own payload, unwrapped. Not a stable contract.
29
36
  --plain Skip the header, print only the windows
30
37
  -h, --help Show this help
31
38
  -v, --version
@@ -53,6 +60,31 @@ const EXIT = { NO_TOKEN: 2, AUTH: 3, RATE_LIMITED: 4 };
53
60
  // the same as one with room, and must never be treated as one.
54
61
  const GATE_EXIT = { proceed: 0, defer: 10, unknown: 11 };
55
62
 
63
+ /**
64
+ * Every `--json` answer carries the same three fields.
65
+ *
66
+ * Before this the flag meant three unrelated things depending on which other
67
+ * flag it sat beside — a raw Anthropic payload, a decision, a model catalogue —
68
+ * so nothing could parse it without first knowing what had been asked. `kind`
69
+ * makes that readable from the answer alone. `--raw` stays outside: it exists
70
+ * precisely to be the unwrapped provider payload, and promising it a shape
71
+ * would be promising something we do not control.
72
+ *
73
+ * `toolVersion` rather than `version` because payloads already carry versions
74
+ * of their own that matter more than ours: `resolve` reports the version of the
75
+ * binary it found, and flattening a field called `version` over it would
76
+ * silently replace "Codex 0.149.1" with the version of this tool.
77
+ */
78
+ const emit = (kind, payload) =>
79
+ `${JSON.stringify({ tool: "agent-runway", toolVersion: VERSION, kind, ...payload }, null, 2)}\n`;
80
+
81
+ /** `--name value` or `--name=value`; null when absent. */
82
+ function flagValue(argv, name) {
83
+ const i = argv.findIndex((a) => a === name || a.startsWith(`${name}=`));
84
+ if (i === -1) return null;
85
+ return argv[i].split("=")[1] ?? argv[i + 1] ?? null;
86
+ }
87
+
56
88
  /** `--gate 90`, `--gate=90`, or `--gate` for the default. */
57
89
  function gateThreshold(argv) {
58
90
  const index = argv.findIndex((a) => a === "--gate" || a.startsWith("--gate="));
@@ -88,10 +120,7 @@ async function main(argv) {
88
120
  return 1;
89
121
  }
90
122
 
91
- const flag = (name) => {
92
- const i = argv.findIndex((a) => a === name || a.startsWith(`${name}=`));
93
- return i === -1 ? null : argv[i].split("=")[1] ?? argv[i + 1] ?? null;
94
- };
123
+ const flag = (name) => flagValue(argv, name);
95
124
 
96
125
  // Capacity is only consulted when asked: it costs a network round trip,
97
126
  // and "which slug" is often the whole question.
@@ -108,7 +137,7 @@ async function main(argv) {
108
137
  capacity,
109
138
  });
110
139
 
111
- process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
140
+ process.stdout.write(emit("resolve", answer));
112
141
  // An unresolvable agent, or a slug its binary refuses, is a failed
113
142
  // precondition rather than a crash: exit 1 so a script can branch.
114
143
  return answer.resolved && answer.model?.valid !== false ? 0 : 1;
@@ -126,7 +155,7 @@ async function main(argv) {
126
155
  const catalogues = await models.modelsForAll(installs);
127
156
 
128
157
  if (has("--json")) {
129
- process.stdout.write(`${JSON.stringify({ catalogues, skew: models.modelSkew(catalogues) }, null, 2)}\n`);
158
+ process.stdout.write(emit("models", { catalogues, skew: models.modelSkew(catalogues) }));
130
159
  return 0;
131
160
  }
132
161
  process.stdout.write(`\n${renderModels(catalogues, models.modelSkew(catalogues))}\n`);
@@ -136,23 +165,30 @@ async function main(argv) {
136
165
  // Multi-provider paths go through the registry, which isolates failures:
137
166
  // Antigravity needs its IDE open, a Codex token expires, gh may be absent.
138
167
  const threshold = gateThreshold(argv);
139
- if (threshold !== null || has("--all")) {
140
- const { readAll, capacity } = await import("./providers/index.mjs");
141
- const results = await readAll();
168
+ const provider = flagValue(argv, "--provider");
169
+ if (threshold !== null || has("--all") || provider) {
170
+ const { PROVIDER_IDS, readAll, capacity } = await import("./providers/index.mjs");
171
+
172
+ if (provider && !PROVIDER_IDS.includes(provider)) {
173
+ process.stderr.write(`agent-runway: --provider takes one of ${PROVIDER_IDS.join(", ")}\n`);
174
+ return 1;
175
+ }
176
+
177
+ const results = await readAll(provider ? { providers: [provider] } : {});
142
178
 
143
179
  if (threshold === null) {
144
- process.stdout.write(`\n${renderProviders(results)}\n`);
180
+ process.stdout.write(
181
+ has("--json") ? emit("usage", { providers: results }) : `\n${renderProviders(results)}\n`
182
+ );
145
183
  return 0;
146
184
  }
147
185
 
148
- const decision = capacity(results, { threshold });
149
- process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
150
-
151
- // The worst outcome decides: an unreadable provider outranks a comfortable
152
- // one, because "I could not tell" must not be reported as room to work.
153
- if (decision.providers.some((p) => p.decision === "proceed")) return GATE_EXIT.proceed;
154
- if (decision.anyUnknown) return GATE_EXIT.unknown;
155
- return GATE_EXIT.defer;
186
+ // The permissive reading has to be asked for. An agent checking whether it
187
+ // can keep going should pass --provider and get an answer about itself;
188
+ // without that, one provider over the threshold defers the whole answer.
189
+ const decision = capacity(results, { threshold, rule: has("--any") ? "any" : "all" });
190
+ process.stdout.write(emit("capacity", decision));
191
+ return GATE_EXIT[decision.overall.decision] ?? GATE_EXIT.unknown;
156
192
  }
157
193
 
158
194
  if (has("-h", "--help")) {
@@ -166,10 +202,27 @@ async function main(argv) {
166
202
 
167
203
  const usage = await fetchUsage();
168
204
 
169
- if (has("--json")) {
205
+ // The escape hatch, and the only output that promises nothing: whatever the
206
+ // provider sent, unwrapped. Useful when the normalized view has dropped a
207
+ // field that turns out to matter.
208
+ if (has("--raw")) {
170
209
  process.stdout.write(`${JSON.stringify(usage.raw, null, 2)}\n`);
171
210
  return 0;
172
211
  }
212
+ if (has("--json")) {
213
+ // Built through the same helpers the registry uses, so reading one provider
214
+ // and reading four produce the same description of a window.
215
+ const [{ toWindows }, { ok }] = await Promise.all([
216
+ import("./providers/claude.mjs"),
217
+ import("./providers/shared.mjs"),
218
+ ]);
219
+ const result = ok("claude", {
220
+ windows: toWindows(usage),
221
+ detail: usage.extraUsage?.enabled ? "extra usage credits enabled" : null,
222
+ });
223
+ process.stdout.write(emit("usage", { providers: [{ ...result, label: "Claude" }] }));
224
+ return 0;
225
+ }
173
226
  if (has("--short")) {
174
227
  process.stdout.write(`${renderShort(usage)}\n`);
175
228
  return 0;
package/src/core.mjs CHANGED
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
9
 
10
- export const VERSION = "0.2.1";
10
+ export const VERSION = "0.2.2";
11
11
 
12
12
  const USER_AGENT = `agent-runway/${VERSION} (+https://github.com/jberdah/agent-runway)`;
13
13
 
package/src/mcp.mjs CHANGED
@@ -65,7 +65,9 @@ const TOOLS = [
65
65
  "is when the window rolls over and not a promise that service resumes " +
66
66
  "exactly then. The recommendation states its own rule and whether the " +
67
67
  "candidates were comparable at all: 0% of a five-hour window is not 0% of " +
68
- "a monthly allowance.",
68
+ "a monthly allowance. Asking whether YOU can keep working is a different " +
69
+ "question from whether any agent on the machine could take the job: pass " +
70
+ "provider for the first, rule=any for the second.",
69
71
  inputSchema: {
70
72
  type: "object",
71
73
  properties: {
@@ -77,6 +79,22 @@ const TOOLS = [
77
79
  "Percent consumed above which to defer. A policy, not a fact: at 92% " +
78
80
  "the provider is not blocked, your rule says do not start. Default 90.",
79
81
  },
82
+ provider: {
83
+ type: "string",
84
+ enum: [...PROVIDERS, "all"],
85
+ description:
86
+ "Restrict the decision to one agent. Pass the agent you are running " +
87
+ "as when the question is whether to continue this session; otherwise " +
88
+ "a comfortable provider elsewhere on the machine can mask yours.",
89
+ },
90
+ rule: {
91
+ type: "string",
92
+ enum: ["all", "any"],
93
+ description:
94
+ "How per-provider decisions combine. 'all' (default) defers unless " +
95
+ "every provider read is under the threshold. 'any' proceeds when one " +
96
+ "of them has room, which answers a fan-out, not your own runway.",
97
+ },
80
98
  },
81
99
  additionalProperties: false,
82
100
  },
@@ -84,10 +102,11 @@ const TOOLS = [
84
102
  type: "object",
85
103
  properties: {
86
104
  threshold: { type: "number" },
105
+ overall: LOOSE,
87
106
  providers: { type: "array", items: LOOSE },
88
107
  anyUnknown: { type: "boolean" },
89
108
  },
90
- required: ["threshold", "providers", "anyUnknown"],
109
+ required: ["threshold", "overall", "providers", "anyUnknown"],
91
110
  additionalProperties: true,
92
111
  },
93
112
  },
@@ -151,12 +170,18 @@ async function getUsage(args) {
151
170
 
152
171
  async function checkCapacity(args) {
153
172
  const { readAll, capacity } = await import("./providers/index.mjs");
154
- const decision = capacity(await readAll(), { threshold: args?.threshold ?? 90 });
155
173
 
156
- const lines = decision.providers.map(
174
+ const wanted = args?.provider && args.provider !== "all" ? [args.provider] : undefined;
175
+ const decision = capacity(await readAll(wanted ? { providers: wanted } : {}), {
176
+ threshold: args?.threshold ?? 90,
177
+ rule: args?.rule === "any" ? "any" : "all",
178
+ });
179
+
180
+ const lines = [`${decision.overall.decision}: ${decision.overall.ruleText}`, ""];
181
+ lines.push(...decision.providers.map(
157
182
  (p) => `${p.provider}: ${p.decision}${p.binding ? ` (${p.binding.label} ${p.binding.percentUsed}%)` : ""}` +
158
183
  `${p.retryAt ? ` - retry at ${p.retryAt}` : ""}`
159
- );
184
+ ));
160
185
  if (decision.recommended) {
161
186
  lines.push("");
162
187
  lines.push(
@@ -15,6 +15,29 @@ export const label = "Claude";
15
15
  const DERIVED_SECONDS = { session: 5 * 3600, weekly_all: 7 * 86400, weekly_scoped: 7 * 86400 };
16
16
  const LABELS = { session: "Session (5h)", weekly_all: "Weekly - all models", weekly_scoped: "Weekly" };
17
17
 
18
+ /**
19
+ * Core's reading, expressed in the shared window contract.
20
+ *
21
+ * Exported because the single-provider CLI path needs the same normalization
22
+ * the registry gets: a caller parsing `--json` and one parsing `--gate` must
23
+ * not receive two different descriptions of the same account.
24
+ */
25
+ export function toWindows(usage) {
26
+ return usage.windows.map((w) => {
27
+ const seconds = DERIVED_SECONDS[w.id] ?? null;
28
+ return makeWindow({
29
+ kind: w.id,
30
+ label: LABELS[w.id] ?? w.label,
31
+ percentUsed: fromUsedPercent(w.percent),
32
+ resetsAt: w.resetsAt,
33
+ windowSeconds: seconds,
34
+ windowSource: seconds ? "derived" : null,
35
+ severity: w.severity,
36
+ model: w.model,
37
+ });
38
+ });
39
+ }
40
+
18
41
  export async function read({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
19
42
  let usage;
20
43
  try {
@@ -30,19 +53,7 @@ export async function read({ env = process.env, fetchImpl = globalThis.fetch } =
30
53
  return unavailable(id, "error", String(error?.message ?? error));
31
54
  }
32
55
 
33
- const windows = usage.windows.map((w) => {
34
- const seconds = DERIVED_SECONDS[w.id] ?? null;
35
- return makeWindow({
36
- kind: w.id,
37
- label: LABELS[w.id] ?? w.label,
38
- percentUsed: fromUsedPercent(w.percent),
39
- resetsAt: w.resetsAt,
40
- windowSeconds: seconds,
41
- windowSource: seconds ? "derived" : null,
42
- severity: w.severity,
43
- model: w.model,
44
- });
45
- });
56
+ const windows = toWindows(usage);
46
57
 
47
58
  // Extra credits mean a window at 100% is not necessarily a wall.
48
59
  const detail = usage.extraUsage?.enabled ? "extra usage credits enabled" : null;
@@ -83,6 +83,40 @@ export async function readAll(options = {}) {
83
83
 
84
84
  export const readProvider = readOne;
85
85
 
86
+ /**
87
+ * How the per-provider decisions combine into one answer.
88
+ *
89
+ * These are two different questions and conflating them is dangerous. "Can I
90
+ * keep working" is about the caller's own provider; "is there any agent that
91
+ * could take this" is about a fan-out. Answering the second when the first was
92
+ * asked reports room that the caller does not have — Claude at 96% beside
93
+ * Codex at 10% would come back as proceed.
94
+ *
95
+ * So the default is the conservative reading, and the permissive one has to be
96
+ * asked for by name. Scoping to a single provider makes the two identical,
97
+ * which is the form a caller asking about itself should use.
98
+ */
99
+ const RULES = {
100
+ all: "every readable provider is under the threshold",
101
+ any: "at least one provider is under the threshold",
102
+ };
103
+
104
+ function overallDecision(providers, rule) {
105
+ if (!providers.length) return "unknown";
106
+ const has = (d) => providers.some((p) => p.decision === d);
107
+
108
+ if (rule === "any") {
109
+ if (has("proceed")) return "proceed";
110
+ return has("unknown") ? "unknown" : "defer";
111
+ }
112
+ // A provider known to be blocked outranks one that could not be read: both
113
+ // stop the work, and "blocked until 14:00" is actionable where "could not
114
+ // tell" is not.
115
+ if (has("defer")) return "defer";
116
+ if (has("unknown")) return "unknown";
117
+ return "proceed";
118
+ }
119
+
86
120
  /**
87
121
  * Turn readings into a decision.
88
122
  *
@@ -90,7 +124,7 @@ export const readProvider = readOne;
90
124
  * caller's own rule says not to start. Three outcomes, because two are not
91
125
  * enough — a provider that cannot be read is "unknown", never "fine".
92
126
  */
93
- export function capacity(results, { threshold = 90 } = {}) {
127
+ export function capacity(results, { threshold = 90, rule = "all" } = {}) {
94
128
  const providers = results.map((r) => {
95
129
  if (r.status !== "ok") {
96
130
  return { provider: r.provider, label: r.label, decision: "unknown", reason: r.status, detail: r.detail };
@@ -140,8 +174,18 @@ export function capacity(results, { threshold = 90 } = {}) {
140
174
 
141
175
  const cadences = new Set(usable.map((p) => p.binding.windowSeconds ?? "calendar"));
142
176
 
177
+ const decision = overallDecision(providers, rule);
178
+
143
179
  return {
144
180
  threshold,
181
+ // The one answer a caller acts on, carrying the rule that produced it so
182
+ // that "proceed" can never be read as more than it claims.
183
+ overall: {
184
+ decision,
185
+ rule: rule === "any" ? "any" : "all",
186
+ ruleText: RULES[rule] ?? RULES.all,
187
+ scoped: providers.length === 1 ? providers[0].provider : null,
188
+ },
145
189
  providers,
146
190
  recommended: recommended
147
191
  ? {