agent-runway 0.2.0 → 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.0",
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.0",
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
@@ -70,10 +70,17 @@ unless you pass `--force`.
70
70
  ### As a Claude Code plugin (skill + MCP tool)
71
71
 
72
72
  ```
73
- /plugin marketplace add jberdah/agent-runway
73
+ /plugin marketplace add https://github.com/jberdah/agent-runway.git
74
74
  /plugin install agent-runway@agent-runway
75
75
  ```
76
76
 
77
+ The full HTTPS URL rather than the `owner/repo` shorthand: the shorthand
78
+ resolves to SSH, which fails on any machine that has not accepted GitHub's host
79
+ key. If the clone then fails with *"self signed certificate in certificate
80
+ chain"*, a proxy is inspecting TLS; on Windows, `git config --global
81
+ http.sslBackend schannel` makes git trust the certificate store the rest of the
82
+ system already uses.
83
+
77
84
  Claude then reads your usage whenever it is relevant — ask "how much quota do I
78
85
  have left?" or let it check before a long task.
79
86
 
@@ -219,6 +226,25 @@ claude.ai fallback endpoint.
219
226
  Before delegating work, an agent needs both halves of the answer, and getting
220
227
  them from two different tools defeats the point:
221
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
+
222
248
  **How much runway is left, per provider.**
223
249
 
224
250
  ```bash
@@ -300,8 +326,33 @@ unknown. Three outcomes rather than two, because a provider that could not be
300
326
  read has not got room — it is simply unknown, and must never be counted as
301
327
  either.
302
328
 
303
- The recommendation states its own rule and whether the candidates were even
304
- 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.
305
356
 
306
357
  ## CLI reference
307
358
 
@@ -309,20 +360,35 @@ comparable: 0% of a five-hour window is not 0% of a monthly allowance.
309
360
  | --- | --- |
310
361
  | *(none)* | Claude only, readable table |
311
362
  | `--all` | Every provider found on this machine |
363
+ | `--provider <id>` | One provider only — use it when asking about yourself |
312
364
  | `--models` | What each install accepts, and where installs disagree |
313
365
  | `resolve <agent>` | Binary, valid slugs and a verdict on one model |
314
366
  | `--gate <N>` | Decision as JSON plus an exit code |
367
+ | `--any` | With `--gate`: proceed if any one provider has room |
315
368
  | `--short` | One line: `session=15% weekly_all=79% weekly_scoped=52%` |
316
- | `--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 |
317
371
  | `--plain` | Table without the header |
318
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
+
319
383
  | Exit code | Meaning |
320
384
  | --- | --- |
321
- | 0 | success |
322
- | 1 | unexpected error |
385
+ | 0 | success, or a gate that says proceed |
386
+ | 1 | unexpected error, or an unusable argument |
323
387
  | 2 | no token found |
324
388
  | 3 | token rejected — regenerate it |
325
389
  | 4 | the usage endpoint is throttling; not your quota |
390
+ | 10 | gate: defer |
391
+ | 11 | gate: unknown — a provider could not be read |
326
392
 
327
393
  ## Security
328
394
 
@@ -356,10 +422,17 @@ npm run smoke # drives the MCP server over stdio; needs network and a token
356
422
  ```
357
423
 
358
424
  The runtime has **zero dependencies**. `src/mcp.mjs` speaks JSON-RPC directly
359
- rather than importing the MCP SDK, because a Claude Code plugin installed from
360
- git is never `npm install`ed an imported dependency would have to be vendored.
361
- `scripts/smoke-mcp.mjs` connects the official SDK client to it, so the
362
- hand-rolled framing is checked against the real implementation.
425
+ rather than importing the MCP SDK, and `scripts/smoke-mcp.mjs` connects the
426
+ official SDK client to it so the hand-rolled framing is checked against the real
427
+ implementation.
428
+
429
+ That choice was originally justified by a belief that a Claude Code plugin
430
+ installed from git is never `npm install`ed. **That is wrong**: installing this
431
+ plugin produced 91 packages in the plugin cache, devDependencies included. The
432
+ constraint is kept anyway, for reasons that survive the correction — nobody
433
+ installing a CLI that makes one HTTP request should wait on 91 packages, the
434
+ supply-chain surface stays at zero, and it keeps working where `npm install`
435
+ does not, which on a corporate network is not hypothetical.
363
436
 
364
437
  ```
365
438
  src/core.mjs token resolution, HTTP, response normalization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-runway",
3
- "version": "0.2.0",
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.0";
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 };
@@ -106,7 +140,7 @@ export function capacity(results, { threshold = 90 } = {}) {
106
140
  if (r.allowed === false) {
107
141
  return {
108
142
  provider: r.provider, label: r.label, decision: "defer", reason: "provider_says_limit_reached",
109
- binding, retryAt: binding.resetsAt, retryAtBasis: "reported_reset",
143
+ binding, windows: r.windows, retryAt: binding.resetsAt, retryAtBasis: "reported_reset",
110
144
  };
111
145
  }
112
146
 
@@ -117,6 +151,10 @@ export function capacity(results, { threshold = 90 } = {}) {
117
151
  decision: over ? "defer" : "proceed",
118
152
  reason: over ? "threshold_exceeded" : "within_threshold",
119
153
  binding,
154
+ // The binding window decides, but hiding the rest loses information the
155
+ // read already paid for: a session at 0% next to a weekly at 89% is a
156
+ // different situation from a session at 0% alone.
157
+ windows: r.windows,
120
158
  retryAt: over ? binding.resetsAt : null,
121
159
  // The reset is when the window rolls over, not a promise that service
122
160
  // resumes exactly then. Naming the basis keeps the two apart.
@@ -136,8 +174,18 @@ export function capacity(results, { threshold = 90 } = {}) {
136
174
 
137
175
  const cadences = new Set(usable.map((p) => p.binding.windowSeconds ?? "calendar"));
138
176
 
177
+ const decision = overallDecision(providers, rule);
178
+
139
179
  return {
140
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
+ },
141
189
  providers,
142
190
  recommended: recommended
143
191
  ? {
@@ -56,9 +56,20 @@ export function makeWindow({
56
56
  model = null,
57
57
  unit = "requests",
58
58
  }) {
59
- const startsAt =
59
+ // A start can only be derived from a window that is actually running. When a
60
+ // provider has nothing to report it answers with the whole window ahead —
61
+ // Codex returns reset_at = now + 18000s at 0% consumption — and subtracting
62
+ // the duration then yields "now", which reads as a window that just began
63
+ // when none has. A start that lands on or after the present moment is not a
64
+ // measurement, so it is withheld rather than invented.
65
+ const derivedStart =
60
66
  resetsAt && Number.isFinite(windowSeconds)
61
- ? new Date(Date.parse(resetsAt) - windowSeconds * 1000).toISOString()
67
+ ? Date.parse(resetsAt) - windowSeconds * 1000
68
+ : null;
69
+ const UNKNOWABLE_MS = 60_000;
70
+ const startsAt =
71
+ derivedStart != null && derivedStart < Date.now() - UNKNOWABLE_MS
72
+ ? new Date(derivedStart).toISOString()
62
73
  : null;
63
74
 
64
75
  return {