claude-usage-limits 1.0.0 → 1.1.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.0.0",
4
+ "version": "1.1.1",
5
5
  "description": "Reads how much of your Claude Code usage limit is left, converts it into turns of headroom, and plans the work to fit inside it. Includes a low power switch that keeps spending down even at high effort.",
6
6
  "author": {
7
7
  "name": "Ridelink",
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # usage-limits
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/claude-usage-limits)](https://www.npmjs.com/package/claude-usage-limits)
4
+
3
5
  A Claude Code plugin that reads how much of your usage limit is left and plans
4
6
  the work to fit inside it.
5
7
 
@@ -71,7 +73,9 @@ npx claude-usage-limits --status
71
73
  npx claude-usage-limits lowpower on
72
74
  ```
73
75
 
74
- That runs the same code as the plugin. Node 18 or newer.
76
+ That runs the same code as the plugin, from
77
+ [claude-usage-limits](https://www.npmjs.com/package/claude-usage-limits) on
78
+ npm. Node 18 or newer.
75
79
 
76
80
  To have Claude read the numbers and plan against them, install it properly.
77
81
 
@@ -96,6 +100,29 @@ git clone https://github.com/ridelink0/claude-code-usage-limits
96
100
  Copy-Item -Recurse claude-code-usage-limits\skills\usage-limits "$env:USERPROFILE\.claude\skills\usage-limits"
97
101
  ```
98
102
 
103
+ One difference between the two: the hook that puts the budget line in front of
104
+ every prompt is declared in the plugin manifest, so it only runs on a plugin
105
+ install. If you took the plain skill and want that behaviour, add it yourself
106
+ in `~/.claude/settings.json`, pointing at wherever you put the skill:
107
+
108
+ ```json
109
+ {
110
+ "hooks": {
111
+ "UserPromptSubmit": [
112
+ {
113
+ "hooks": [
114
+ {
115
+ "type": "command",
116
+ "command": "node \"$HOME/.claude/skills/usage-limits/scripts/brief.js\"",
117
+ "timeout": 10
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ ```
125
+
99
126
  Either way, ask something like "how much usage do I have left" or "can we
100
127
  finish this before the limit hits" and Claude will load it. Installed as a
101
128
  plugin it also gives you `/usage-limits:check`, which prints the report and
@@ -136,6 +163,18 @@ The skill also requires Claude to say up front when a job will not fit in what
136
163
  is left, name what it is doing now, what it is leaving, and when the rest can
137
164
  happen, rather than starting and stopping halfway through an edit.
138
165
 
166
+ ## Releasing
167
+
168
+ ```
169
+ npm version patch
170
+ git push --follow-tags
171
+ ```
172
+
173
+ Pushing the tag runs the tests and publishes to npm. That goes through npm's
174
+ trusted publishing over OIDC, so there is no publish token stored in the repo
175
+ or in CI. `npm version` also syncs the version in the plugin manifest, so the
176
+ marketplace and the npm package never disagree about which release is current.
177
+
139
178
  ## Status line
140
179
 
141
180
  For a permanent readout instead of asking, point Claude Code's status line at
@@ -161,6 +200,67 @@ it runs in about a tenth of a second and is safe on every redraw. Use the full
161
200
  path rather than `~` if your shell does not expand it, and point it at the
162
201
  plugin copy instead if that is how you installed it.
163
202
 
203
+ ## It tells you where you stand, every time
204
+
205
+ Installed as a plugin, a hook measures the budget before each prompt and puts
206
+ one line into Claude's context:
207
+
208
+ ```
209
+ [usage-limits] 5-hour 47%, weekly 16%, about 75 turns of headroom, resets in
210
+ 3h 52m, this session 229 turns, $64.16.
211
+ ```
212
+
213
+ Claude opens with it. When there is room that is a single line and it moves on:
214
+
215
+ > Weekly is at 16%, 5-hour at 47%, about 75 turns of headroom. This fits easily.
216
+
217
+ When there is not, the line becomes a plan rather than a status:
218
+
219
+ > The weekly window has about 22 turns left. That covers the parser change and
220
+ > its tests, but not the migration or the docs pass, so I will do the first two
221
+ > and leave the rest for after the reset at 09:00.
222
+
223
+ The wording changes with the pressure, not only the numbers. The trigger worth
224
+ explaining is pace: two days into a week you should be near 29 percent spent, so
225
+ 60 percent means you will not last the week, and that is worth hearing at 60
226
+ rather than at 85.
227
+
228
+ It has to be cheap, because it runs on every prompt. The percentages come from
229
+ one small file. The transcript scan behind "turns of headroom" is cached for a
230
+ minute, so it costs about 400ms cold and 120ms warm.
231
+
232
+ | Variable | Default | Effect |
233
+ | --- | --- | --- |
234
+ | `USAGE_LIMITS_BRIEF` | on | Set to `off` to turn the line off entirely. |
235
+ | `USAGE_LIMITS_NEAR` | 80 | Percent used that always counts as tight. |
236
+ | `USAGE_LIMITS_FLOOR` | 40 | Below this, pace is ignored. |
237
+ | `USAGE_LIMITS_AHEAD` | 15 | Points ahead of pace that count as burning fast. |
238
+ | `USAGE_LIMITS_CACHE` | 60 | Seconds the measured half stays good for. |
239
+
240
+ ## What would this job cost
241
+
242
+ ```
243
+ node skills/usage-limits/scripts/usage.js --forecast 15
244
+ ```
245
+
246
+ ```
247
+ Forecast for 15 turns
248
+
249
+ Window Would cost Leaves Verdict
250
+ 5-hour 7.4% to 8.4% 45% fits
251
+ weekly 0.8% to 0.9% 83% fits
252
+
253
+ Priced from 64 recent turns: $0.296 typical, $0.333 at the expensive end.
254
+
255
+ There is room for this. No need to work around the limit.
256
+ ```
257
+
258
+ It prices turns at what turns have really cost on your account, and gives a
259
+ range rather than one number, because a turn that reads three files costs many
260
+ times one that answers from context. The upper end is the honest one for a long
261
+ run, since turns get dearer as the context grows.
262
+
263
+
164
264
  ## Plans
165
265
 
166
266
  It reads which plan you are on and adjusts what it tells you, because the
@@ -274,9 +374,9 @@ test/ node --test, no dependencies
274
374
  node --test
275
375
  ```
276
376
 
277
- 72 tests over the pricing, the window arithmetic, plan and credit detection,
278
- the status line, per-project attribution, the CLI, and the settings
279
- save/restore.
377
+ 105 tests over the pricing, the window arithmetic, plan and credit detection,
378
+ the status line, the before-prompt line, job forecasting, per-project
379
+ attribution, the CLI, packaging, and the settings save/restore.
280
380
 
281
381
  ## Status
282
382
 
package/bin/cli.js CHANGED
@@ -10,6 +10,7 @@ const HELP = `claude-usage-limits - how much Claude Code usage is left, and whet
10
10
  claude-usage-limits the report
11
11
  claude-usage-limits --json the same numbers, machine readable
12
12
  claude-usage-limits --status one short line, for a status line
13
+ claude-usage-limits --forecast 15 what a 15 turn job would cost
13
14
 
14
15
  claude-usage-limits lowpower status show the current effort setting
15
16
  claude-usage-limits lowpower on lower effortLevel, remembering the old value
@@ -0,0 +1,16 @@
1
+ {
2
+ "description": "Tells Claude how much usage budget is left, but only when it is tight enough to change what can be done.",
3
+ "hooks": {
4
+ "UserPromptSubmit": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/usage-limits/scripts/brief.js\"",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "See how much of your Claude Code usage limit is left as turns of work rather than a percentage, and plan the job to fit inside it.",
5
5
  "keywords": [
6
6
  "claude",
@@ -31,6 +31,7 @@
31
31
  "bin/",
32
32
  "skills/",
33
33
  "commands/",
34
+ "hooks/",
34
35
  ".claude-plugin/"
35
36
  ],
36
37
  "engines": {
@@ -38,6 +39,7 @@
38
39
  },
39
40
  "scripts": {
40
41
  "test": "node --test",
41
- "usage": "node bin/cli.js"
42
+ "usage": "node bin/cli.js",
43
+ "version": "node tools/sync-version.js && git add .claude-plugin/plugin.json"
42
44
  }
43
45
  }
@@ -92,6 +92,47 @@ yourself: Claude Code announces it and asks before drawing on credits, and
92
92
  repeating that only adds noise. Just factor it into the plan, and take the
93
93
  user's answer to that prompt as the decision.
94
94
 
95
+ ## Opening with the budget
96
+
97
+ Start every reply with one line saying where the budget stands and whether what
98
+ was asked fits. The hook puts the numbers in front of you before the prompt, so
99
+ there is nothing to go and look up.
100
+
101
+ When there is room, one line, then get on with it:
102
+
103
+ > Weekly is at 16%, 5-hour at 47%, about 75 turns of headroom. This fits easily.
104
+
105
+ When it does not fit, that line becomes the plan:
106
+
107
+ > The weekly window has about 22 turns left. That covers the parser change and
108
+ > its tests, but not the migration or the docs pass, so I will do the first two
109
+ > and leave the rest for after the reset at 09:00.
110
+
111
+ What makes the second one useful is the split, not the percentage. "78% used"
112
+ is not something anyone can act on. "This fits, that does not, here is the
113
+ order" is.
114
+
115
+ Keep it to one line unless the work genuinely does not fit. The budget note is
116
+ a header, not a section, and it must never push the actual answer down the
117
+ page.
118
+
119
+ ### Pricing the job before starting it
120
+
121
+ Size the work in turns using the table above, then price it:
122
+
123
+ ```
124
+ node scripts/usage.js --forecast 15
125
+ ```
126
+
127
+ That converts turns into points of each window using what turns have really
128
+ cost on this account, and reports a range rather than a single number, because
129
+ a turn that reads three files costs many times one that answers from context.
130
+ The upper end is the honest one for a long run, since turns get dearer as the
131
+ context grows.
132
+
133
+ Reach for it whenever the answer to "will this finish" is not obvious.
134
+
135
+
95
136
  ## 4. Low power
96
137
 
97
138
  Two halves, and the second one is the half that actually binds.
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The hook entry point. Runs before each prompt is handled and puts one line
5
+ // into Claude's context saying where the budget stands, what this session has
6
+ // cost so far, and to open the reply with it.
7
+ //
8
+ // It runs on every prompt, so two things matter more than features: it has to
9
+ // be one line, and it has to be fast. The expensive half is cached for a
10
+ // minute, and the numbers that change every turn are the cheap ones.
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const usage = require('./usage.js');
17
+
18
+ const SECOND = 1000;
19
+ const DAY = 24 * 60 * 60 * 1000;
20
+
21
+ const DEFAULTS = {
22
+ // Close enough to the wall that the wording should change.
23
+ near: 80,
24
+ // Below this, pace is not worth worrying about.
25
+ floor: 40,
26
+ // Points of budget spent beyond the share of the window that has elapsed.
27
+ ahead: 15,
28
+ // How long the measured part stays good for. Prompts often arrive in
29
+ // bursts, and a transcript scan per prompt would be wasteful.
30
+ cacheSeconds: 60,
31
+ };
32
+
33
+ function configDir() {
34
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
35
+ }
36
+
37
+ function cacheFile() {
38
+ return path.join(configDir(), 'usage-limits-brief.json');
39
+ }
40
+
41
+ function readCache() {
42
+ try {
43
+ return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
44
+ } catch (err) {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function writeCache(value) {
50
+ try {
51
+ fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
52
+ fs.writeFileSync(cacheFile(), JSON.stringify(value), 'utf8');
53
+ } catch (err) {
54
+ // A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
55
+ }
56
+ }
57
+
58
+ function settings() {
59
+ const env = process.env;
60
+ const number = (value, fallback) => {
61
+ const parsed = Number(value);
62
+ return Number.isFinite(parsed) ? parsed : fallback;
63
+ };
64
+ return {
65
+ near: number(env.USAGE_LIMITS_NEAR, DEFAULTS.near),
66
+ floor: number(env.USAGE_LIMITS_FLOOR, DEFAULTS.floor),
67
+ ahead: number(env.USAGE_LIMITS_AHEAD, DEFAULTS.ahead),
68
+ cacheSeconds: number(env.USAGE_LIMITS_CACHE, DEFAULTS.cacheSeconds),
69
+ };
70
+ }
71
+
72
+ // How far ahead of the clock the spending is. A weekly window two days in
73
+ // should be near 29 percent spent; 60 percent means it will not last.
74
+ function aheadOfPace(window, now) {
75
+ if (!window || !window.spanMs || !Number.isFinite(window.windowStart)) return null;
76
+ if (window.percentUsed === null) return null;
77
+ const elapsed = ((now - window.windowStart) / window.spanMs) * 100;
78
+ return window.percentUsed - Math.min(100, Math.max(0, elapsed));
79
+ }
80
+
81
+ // Not whether to speak, which is always, but how hard to lean on it.
82
+ function pressure(window, now, config) {
83
+ if (!window || window.percentUsed === null || window.stale) return 'unknown';
84
+ if (window.verdict === 'exhausted') return 'gone';
85
+ if (window.verdict === 'runs-out') return 'tight';
86
+ if (window.percentUsed >= config.near) return 'tight';
87
+
88
+ const lead = aheadOfPace(window, now);
89
+ if (window.percentUsed >= config.floor && lead !== null && lead >= config.ahead) {
90
+ return 'tight';
91
+ }
92
+ return 'roomy';
93
+ }
94
+
95
+ // The hook is handed JSON on stdin. The session id in it is what lets this
96
+ // report what the current session has cost rather than the whole window.
97
+ function readHookInput() {
98
+ return new Promise((resolve) => {
99
+ if (process.stdin.isTTY) return resolve(null);
100
+
101
+ let raw = '';
102
+ let settled = false;
103
+ const done = () => {
104
+ if (settled) return;
105
+ settled = true;
106
+ try {
107
+ resolve(raw ? JSON.parse(raw) : null);
108
+ } catch (err) {
109
+ resolve(null);
110
+ }
111
+ };
112
+
113
+ // A hook must never hang the prompt waiting for input that is not coming.
114
+ const timer = setTimeout(done, 500);
115
+ if (timer.unref) timer.unref();
116
+
117
+ process.stdin.setEncoding('utf8');
118
+ process.stdin.on('data', (chunk) => {
119
+ raw += chunk;
120
+ });
121
+ process.stdin.on('end', done);
122
+ process.stdin.on('error', done);
123
+ });
124
+ }
125
+
126
+ function sessionSpend(events, sessionId) {
127
+ if (!sessionId) return null;
128
+ let cost = 0;
129
+ let turns = 0;
130
+ for (const event of events) {
131
+ if (event.sessionId !== sessionId) continue;
132
+ cost += event.cost;
133
+ turns += 1;
134
+ }
135
+ return turns ? { turns, cost } : null;
136
+ }
137
+
138
+ function summarise(windows) {
139
+ return windows
140
+ .filter((window) => window.percentUsed !== null)
141
+ .map((window) =>
142
+ window.stale
143
+ ? window.label + ' rolling over'
144
+ : window.label + ' ' + window.percentUsed + '%'
145
+ )
146
+ .join(', ');
147
+ }
148
+
149
+ function briefText(parts) {
150
+ const facts = [];
151
+ if (parts.windowSummary) facts.push(parts.windowSummary);
152
+ if (Number.isFinite(parts.turnsLeft)) {
153
+ facts.push('about ' + parts.turnsLeft + ' turns of headroom');
154
+ }
155
+ if (parts.resetsIn) facts.push('resets in ' + parts.resetsIn);
156
+ if (parts.session) {
157
+ facts.push(
158
+ 'this session ' + parts.session.turns + ' turns, ' +
159
+ usage.formatUSD(parts.session.cost)
160
+ );
161
+ }
162
+
163
+ const head = '[usage-limits] ' + facts.join(', ') + '.';
164
+
165
+ const instruction =
166
+ parts.pressure === 'tight' || parts.pressure === 'gone'
167
+ ? 'Open your reply with one line on where this leaves the budget, then say ' +
168
+ 'what you will do now and what you will leave for after the reset. ' +
169
+ 'Do not start work that clearly will not finish.'
170
+ : 'Open your reply with one short line stating this and confirming the ' +
171
+ 'request fits, then get on with the work. Keep it to a single line.';
172
+
173
+ return head + '\n' + instruction;
174
+ }
175
+
176
+ async function run(now, hookInput) {
177
+ if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
178
+
179
+ const config = settings();
180
+ const base = usage.collect(now);
181
+ if (!base.utilization) return '';
182
+
183
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
184
+
185
+ // The percentages are cheap: one small file, no transcripts.
186
+ const cheap = usage.buildWindows(base.utilization, [], now);
187
+ if (!cheap.length) return '';
188
+
189
+ const cached = readCache();
190
+ const fresh =
191
+ cached &&
192
+ Number.isFinite(cached.at) &&
193
+ now - cached.at < config.cacheSeconds * SECOND &&
194
+ cached.sessionId === sessionId;
195
+
196
+ let turnsLeft = fresh ? cached.turnsLeft : null;
197
+ let session = fresh ? cached.session : null;
198
+ let windows = cheap;
199
+
200
+ if (!fresh) {
201
+ const events = await usage.readEvents(now - 8 * DAY);
202
+ windows = usage.buildWindows(base.utilization, events, now);
203
+ const binding = usage.bindingWindow(windows);
204
+ turnsLeft = binding && Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null;
205
+ session = sessionSpend(events, sessionId);
206
+ writeCache({ at: now, turnsLeft, session, sessionId });
207
+ }
208
+
209
+ const binding = usage.bindingWindow(windows) || windows[0];
210
+
211
+ return briefText({
212
+ windowSummary: summarise(windows),
213
+ turnsLeft,
214
+ resetsIn:
215
+ binding && !binding.stale && binding.msToReset !== null
216
+ ? usage.formatDuration(binding.msToReset)
217
+ : null,
218
+ session,
219
+ pressure: pressure(binding, now, config),
220
+ });
221
+ }
222
+
223
+ if (require.main === module) {
224
+ readHookInput()
225
+ .then((input) => run(Date.now(), input))
226
+ .then(
227
+ (text) => {
228
+ if (text) process.stdout.write(text + '\n');
229
+ process.exit(0);
230
+ },
231
+ () => {
232
+ // A hook that throws must not disrupt the prompt it runs before.
233
+ process.exit(0);
234
+ }
235
+ );
236
+ }
237
+
238
+ module.exports = {
239
+ DEFAULTS,
240
+ aheadOfPace,
241
+ pressure,
242
+ sessionSpend,
243
+ summarise,
244
+ briefText,
245
+ settings,
246
+ run,
247
+ cacheFile,
248
+ };
@@ -217,6 +217,7 @@ function eventFrom(line, seen, project) {
217
217
  tokens: tokensOf(entry.message.usage),
218
218
  parts: tokenParts(entry.message.usage),
219
219
  project: project || null,
220
+ sessionId: entry.sessionId || null,
220
221
  };
221
222
  }
222
223
 
@@ -396,6 +397,46 @@ function creditsFrom(utilization) {
396
397
  };
397
398
  }
398
399
 
400
+ // Turn cost is not a single number, it is a spread: a turn that reads three
401
+ // files costs many times one that answers from context. A median alone
402
+ // under-promises on the expensive half, so carry a high end too.
403
+ function costPercentiles(events) {
404
+ const costs = (events || [])
405
+ .map((event) => event.cost)
406
+ .filter((cost) => Number.isFinite(cost) && cost > 0)
407
+ .sort((a, b) => a - b);
408
+ if (!costs.length) return null;
409
+
410
+ const at = (fraction) => costs[Math.min(costs.length - 1, Math.floor(fraction * costs.length))];
411
+ return { median: at(0.5), high: at(0.8), sample: costs.length };
412
+ }
413
+
414
+ // What a job of this many turns would take out of one window.
415
+ function forecastWindow(window, turns, rates) {
416
+ if (!window || !rates || !window.usdPerPercent || window.stale) return null;
417
+ if (!Number.isFinite(turns) || turns <= 0) return null;
418
+
419
+ const usdLow = turns * rates.median;
420
+ const usdHigh = turns * rates.high;
421
+ const percentLow = usdLow / window.usdPerPercent;
422
+ const percentHigh = usdHigh / window.usdPerPercent;
423
+
424
+ return {
425
+ key: window.key,
426
+ label: window.label,
427
+ turns,
428
+ usdLow,
429
+ usdHigh,
430
+ percentLow,
431
+ percentHigh,
432
+ // The pessimistic cost is what decides whether it fits, so the room left
433
+ // over is measured against that.
434
+ leaves: window.percentLeft - percentHigh,
435
+ fits: percentHigh <= window.percentLeft,
436
+ tight: percentHigh > window.percentLeft * 0.75 && percentHigh <= window.percentLeft,
437
+ };
438
+ }
439
+
399
440
  // Everything the report needs about one limit window.
400
441
  function buildWindow(spec, snapshot, events, now) {
401
442
  const percent =
@@ -420,6 +461,7 @@ function buildWindow(spec, snapshot, events, now) {
420
461
  resetsAt: hasReset ? resetsAt : null,
421
462
  msToReset: hasReset ? resetsAt - now : null,
422
463
  windowStart: start,
464
+ spanMs: spec.span,
423
465
  spentUSD: spent.cost,
424
466
  spentTokens: spent.tokens,
425
467
  turns: spent.turns,
@@ -626,6 +668,7 @@ async function report(now) {
626
668
  windows,
627
669
  binding,
628
670
  credits: creditsFrom(base.utilization),
671
+ rates: costPercentiles(recentEvents.length >= 5 ? recentEvents : scoped),
629
672
  resumeAt: binding ? binding.resetsAt : null,
630
673
  models: byModel(scoped),
631
674
  projects: byProject(scoped),
@@ -861,6 +904,79 @@ function render(data) {
861
904
  return lines.join('\n');
862
905
  }
863
906
 
907
+ function formatPercent(value) {
908
+ if (!Number.isFinite(value)) return '-';
909
+ if (value >= 10) return Math.round(value) + '%';
910
+ return value.toFixed(1) + '%';
911
+ }
912
+
913
+ function renderForecast(data, turns) {
914
+ const lines = [];
915
+ lines.push('Forecast for ' + turns + ' turns');
916
+ lines.push('');
917
+
918
+ if (!Number.isFinite(turns) || turns <= 0) {
919
+ lines.push(' Give a number of turns, for example --forecast 15.');
920
+ return lines.join('\n');
921
+ }
922
+ if (!data.rates) {
923
+ lines.push(' Nothing recent to price this against yet. Do some work in this');
924
+ lines.push(' session first, then ask again.');
925
+ return lines.join('\n');
926
+ }
927
+
928
+ const rows = data.windows
929
+ .map((window) => forecastWindow(window, turns, data.rates))
930
+ .filter(Boolean);
931
+
932
+ if (!rows.length) {
933
+ lines.push(' No window has enough measured spend to price a forecast against.');
934
+ return lines.join('\n');
935
+ }
936
+
937
+ lines.push(
938
+ ' ' + pad('Window', 15) + padLeft('Would cost', 18) + padLeft('Leaves', 10) + ' Verdict'
939
+ );
940
+ for (const row of rows) {
941
+ const verdict = row.fits ? (row.tight ? 'fits, barely' : 'fits') : 'does not fit';
942
+ lines.push(
943
+ ' ' + pad(row.label, 15) +
944
+ padLeft(formatPercent(row.percentLow) + ' to ' + formatPercent(row.percentHigh), 18) +
945
+ padLeft(formatPercent(Math.max(0, row.leaves)), 10) +
946
+ ' ' + verdict
947
+ );
948
+ }
949
+ lines.push('');
950
+ lines.push(
951
+ ' Priced from ' + data.rates.sample + ' recent turns: ' +
952
+ formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
953
+ ' at the expensive end.'
954
+ );
955
+
956
+ const blocked = rows.filter((row) => !row.fits);
957
+ const tight = rows.filter((row) => row.fits && row.tight);
958
+ lines.push('');
959
+ if (blocked.length) {
960
+ lines.push(
961
+ ' The ' + blocked[0].label + ' window does not cover this. Cut it down or ' +
962
+ 'split it at a clean boundary rather than starting and getting cut off.'
963
+ );
964
+ } else if (tight.length) {
965
+ lines.push(
966
+ ' It fits, but only if nothing goes wrong. Order the work so the valuable ' +
967
+ 'part lands first.'
968
+ );
969
+ } else {
970
+ lines.push(' There is room for this. No need to work around the limit.');
971
+ }
972
+ lines.push(
973
+ ' Turns get dearer as context grows, so the higher number is the honest one ' +
974
+ 'for a long run.'
975
+ );
976
+
977
+ return lines.join('\n');
978
+ }
979
+
864
980
  async function main(argv) {
865
981
  // The status line runs on every redraw, so it must not scan transcripts.
866
982
  if (argv.indexOf('--status') !== -1) {
@@ -870,6 +986,20 @@ async function main(argv) {
870
986
 
871
987
  const wantsJson = argv.indexOf('--json') !== -1;
872
988
  const data = await report(Date.now());
989
+
990
+ const forecastAt = argv.indexOf('--forecast');
991
+ if (forecastAt !== -1) {
992
+ const turns = Number(argv[forecastAt + 1]);
993
+ if (wantsJson) {
994
+ const rows = data.windows
995
+ .map((window) => forecastWindow(window, turns, data.rates))
996
+ .filter(Boolean);
997
+ process.stdout.write(JSON.stringify({ turns, rates: data.rates, windows: rows }, null, 2) + '\n');
998
+ } else {
999
+ process.stdout.write(renderForecast(data, turns) + '\n');
1000
+ }
1001
+ return 0;
1002
+ }
873
1003
  if (wantsJson) {
874
1004
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
875
1005
  } else {
@@ -893,6 +1023,7 @@ module.exports = {
893
1023
  costOf,
894
1024
  tokensOf,
895
1025
  eventFrom,
1026
+ readEvents,
896
1027
  buildWindow,
897
1028
  buildWindows,
898
1029
  bindingWindow,
@@ -905,6 +1036,9 @@ module.exports = {
905
1036
  report,
906
1037
  collect,
907
1038
  detectPlan,
1039
+ costPercentiles,
1040
+ forecastWindow,
1041
+ renderForecast,
908
1042
  creditsFrom,
909
1043
  formatClock,
910
1044
  statusLine,