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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +83 -10
- 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 +50 -2
- package/src/providers/shared.mjs +13 -2
|
@@ -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
|
@@ -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
|
-
|
|
304
|
-
|
|
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` |
|
|
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,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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.
|
|
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 };
|
|
@@ -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
|
? {
|
package/src/providers/shared.mjs
CHANGED
|
@@ -56,9 +56,20 @@ export function makeWindow({
|
|
|
56
56
|
model = null,
|
|
57
57
|
unit = "requests",
|
|
58
58
|
}) {
|
|
59
|
-
|
|
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
|
-
?
|
|
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 {
|