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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +64 -5
- package/package.json +3 -3
- package/skills/agent-runway/SKILL.md +50 -6
- package/src/cli.mjs +73 -20
- package/src/core.mjs +1 -1
- package/src/mcp.mjs +30 -5
- package/src/providers/claude.mjs +24 -13
- package/src/providers/index.mjs +45 -1
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
311
|
-
|
|
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` |
|
|
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.
|
|
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": ">=
|
|
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
|
|
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
|
|
17
|
-
agent-runway --models
|
|
18
|
-
agent-runway --gate 90
|
|
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
|
-
|
|
23
|
-
`
|
|
24
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
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(
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
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
|
|
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(
|
package/src/providers/claude.mjs
CHANGED
|
@@ -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
|
|
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;
|
package/src/providers/index.mjs
CHANGED
|
@@ -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
|
? {
|