claude-usage-limits 1.7.0 → 1.8.0

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.7.0",
4
+ "version": "1.8.0",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/README.md CHANGED
@@ -307,7 +307,7 @@ one line into Claude's context:
307
307
 
308
308
  ```
309
309
  [usage-limits] binding window is 5-hour 47% used, about 75 turns of headroom,
310
- resets in 3h 52m. Other windows: weekly 16%. This session: 229 turns, $64.16.
310
+ resets in 3h 52m. Other windows: weekly 16%. This session: 229 turns, 14.2M tokens, $64.16.
311
311
  ```
312
312
 
313
313
  It names the window that will stop the work first and hangs the figures off
@@ -344,12 +344,67 @@ minute, so it costs about 400ms cold and 120ms warm.
344
344
 
345
345
  | Variable | Default | Effect |
346
346
  | --- | --- | --- |
347
- | `USAGE_LIMITS_BRIEF` | on | Set to `off` to turn the line off entirely. |
348
- | `USAGE_LIMITS_NEAR` | 80 | Percent used that always counts as tight. |
349
- | `USAGE_LIMITS_FLOOR` | 40 | Below this, pace is ignored. |
350
- | `USAGE_LIMITS_AHEAD` | 15 | Points ahead of pace that count as burning fast. |
347
+ | `USAGE_LIMITS_BRIEF` | on | Set to `off` to turn the before-prompt line off entirely. |
348
+ | `USAGE_LIMITS_NEAR` | 90 | Percent used at which the budget counts as tight. Nothing below it is discouraged. |
349
+ | `USAGE_LIMITS_FEW_TURNS` | 10 | Turns of headroom at or below which the budget counts as tight. |
350
+ | `USAGE_LIMITS_RUNWAY` | 10 | Minutes of runway at the current pace below which the budget counts as tight. |
351
351
  | `USAGE_LIMITS_CACHE` | 60 | Seconds the measured half stays good for. |
352
- | `USAGE_LIMITS_FEW_TURNS` | 20 | Turn count at or below which the budget counts as tight. |
352
+ | `USAGE_LIMITS_FLOOR`, `USAGE_LIMITS_AHEAD` | 40, 15 | Only feed the reported pace figure; they no longer change the wording. |
353
+ | `USAGE_LIMITS_PULSE` | on | `off` silences the mid-turn line; `always` prints it even when there is room. |
354
+ | `USAGE_LIMITS_PULSE_SECONDS` | 120 | How often the mid-turn line can fire. |
355
+ | `USAGE_LIMITS_TALLY` | on | Set to `off` to turn off the after-reply tally, the closing line and the session history. |
356
+
357
+ ## What a session cost
358
+
359
+ The other half of the question. After every reply, a `Stop` hook shows you one
360
+ line with what that reply cost and what the session has cost so far, tokens
361
+ first because that is what people ask:
362
+
363
+ ```
364
+ [usage-limits] this reply: 6 turns, 210k tokens, $0.95. This session: 9 prompts,
365
+ 48 turns, 3.1M tokens (2.9M cache read, 61k output), about $12.40, roughly 31
366
+ points of the 5-hour window. Context is now about 130k tokens.
367
+ ```
368
+
369
+ It goes to you, not into the context, so it costs the model nothing. It reads
370
+ only the bytes of the transcript written since the previous reply, including
371
+ any subagent transcripts under the session's own folder, so it takes a few
372
+ milliseconds however long the session has run. When the session closes, a
373
+ `SessionEnd` hook prints the closing line:
374
+
375
+ ```
376
+ [usage-limits] session closed after 1h 42m: 9 prompts, 48 turns, 3.1M tokens, about $12.40.
377
+ ```
378
+
379
+ Claude is also asked to end finished work with the total in its own words, one
380
+ plain line, and to skip it on partial progress. The before-prompt line carries
381
+ the session's tokens, what the last reply cost, and how large the context has
382
+ become, with one clause of advice once it passes 150k tokens, because the
383
+ context is re-sent on every call and past a point it is the cost of the session.
384
+
385
+ The history is kept in `usage-limits-sessions.json` beside the other caches:
386
+
387
+ ```
388
+ node skills/usage-limits/scripts/usage.js --sessions
389
+ node skills/usage-limits/scripts/usage.js --session last
390
+ ```
391
+
392
+ ```
393
+ Id When Project Prompts Turns Tokens Cost
394
+ 4940f126 9m ago C--Users-OWNER 1 16 2.8M $11.36 open
395
+ 380e664a 9m ago C--Users-OWNER 10 112+35 51.0M $93.11 open
396
+ ```
397
+
398
+ `--session last` (or an id, or a unique prefix of one) shows one session in
399
+ full: the token split, the model mix, the subagent calls and the context size.
400
+ Installed as a plugin, `/usage-limits:session` reads the same thing back.
401
+ Turns are main-thread calls; `+N` is what subagents made on top. Set
402
+ `USAGE_LIMITS_TALLY=off` to turn all of this off.
403
+
404
+ If you installed the plain skill rather than the plugin, add the two hooks
405
+ beside the first one in `settings.json`: `Stop` running `scripts/stop.js` and
406
+ `SessionEnd` running `scripts/sessionend.js`.
407
+
353
408
 
354
409
  ## What would this job cost
355
410
 
@@ -528,6 +583,12 @@ Good enough to plan with, not a bill. The honest caveats:
528
583
  doubling the limits. If demand-based limits ever return, the numbers here
529
584
  follow automatically, because they are calibrated from what your traffic did
530
585
  to the meter rather than from an assumption about the clock.
586
+ - Subagent transcripts, written under the session's own folder, are read too.
587
+ Their calls count in the money and the tokens and are reported apart from
588
+ the turns, because a turn is one main-thread call.
589
+ - The account's own list of limits is read as well as the per-window buckets,
590
+ so a per-model weekly such as `weekly (Fable)` shows up as a window of its
591
+ own, priced from that model's calls alone.
531
592
  - The turn cost behind "turns of headroom" is a median over at least five
532
593
  turns, so one compaction cannot define your pace.
533
594
  - What a point of a window costs is learned once from the best sample seen and
@@ -565,12 +626,15 @@ names, the formulas, and the rest of it.
565
626
  agents/openai.yaml how Codex lists the plugin
566
627
  skills/usage-limits/SKILL.md what the agent reads
567
628
  skills/usage-limits/agents/ how Codex lists the skill
568
- skills/usage-limits/scripts/ usage.js, brief.js, pulse.js, codex.js,
569
- host.js, lowpower.js, install-codex-hook.js
629
+ skills/usage-limits/scripts/ usage.js, brief.js, pulse.js, stop.js,
630
+ sessionend.js, tally.js, codex.js, host.js,
631
+ lowpower.js, install-codex-hook.js
570
632
  skills/usage-limits/references/ the longer notes
571
- hooks/hooks.json runs brief.js before each prompt, and
572
- pulse.js during long turns
633
+ hooks/hooks.json runs brief.js before each prompt, pulse.js
634
+ during long turns, stop.js after each reply
635
+ and sessionend.js when the session closes
573
636
  commands/check.md the /usage-limits:check command
637
+ commands/session.md the /usage-limits:session command
574
638
  bin/cli.js the npx entry point
575
639
  tools/sync-version.js keeps the manifest version in step
576
640
  test/ node --test, no dependencies
@@ -582,8 +646,8 @@ test/ node --test, no dependencies
582
646
  node --test
583
647
  ```
584
648
 
585
- 248 tests over the pricing, the window arithmetic, plan and credit detection,
586
- the status line, the before-prompt line, the mid-turn pulse, job forecasting,
649
+ 308 tests over the pricing, the window arithmetic, plan and credit detection,
650
+ the status line, the before-prompt line, the mid-turn pulse, the after-reply tally and the session history, job forecasting,
587
651
  per-project attribution, the Codex reader and its installer, the CLI,
588
652
  packaging, and the settings save/restore.
589
653
 
package/bin/cli.js CHANGED
@@ -11,6 +11,8 @@ const HELP = `claude-usage-limits - how much agent usage is left, and whether th
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
13
  claude-usage-limits --forecast 15 what a 15 turn job would cost
14
+ claude-usage-limits --sessions what recent sessions cost
15
+ claude-usage-limits --session last one session in full, or give its id
14
16
 
15
17
  claude-usage-limits --host codex read Codex's limits instead
16
18
  claude-usage-limits --host codex --refresh ask Codex for a live reading
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Report what this session has cost so far in prompts, turns, tokens and money
3
+ ---
4
+
5
+ Run `node "${CLAUDE_PLUGIN_ROOT}/skills/usage-limits/scripts/usage.js" --session last`
6
+ and read it back to me in one or two plain lines: prompts, turns (and subagent
7
+ turns if any), tokens with the split, the cost, and how large the context is now.
8
+
9
+ `last` is the most recently active session on this machine, which is normally
10
+ this one. If more than one window is open and the project named does not look
11
+ like this one, run `--sessions` and pick the right id.
12
+
13
+ Report the numbers and stop. Do not start other work as part of this command.
package/hooks/hooks.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
- "description": "Tells Claude how much usage budget is left: once before each prompt, and again during long turns so the figure it is working from does not go stale.",
2
+ "description": "Tells Claude how much usage budget is left: once before each prompt, and again during long turns so the figure it is working from does not go stale. Tells you what each reply and each session cost, after the reply and when the session closes.",
3
3
  "hooks": {
4
+ "Stop": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/usage-limits/scripts/stop.js\"",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "SessionEnd": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/usage-limits/scripts/sessionend.js\"",
21
+ "timeout": 5
22
+ }
23
+ ]
24
+ }
25
+ ],
4
26
  "UserPromptSubmit": [
5
27
  {
6
28
  "hooks": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -225,6 +225,31 @@ context grows.
225
225
 
226
226
  Reach for it whenever the answer to "will this finish" is not obvious.
227
227
 
228
+ ## Closing with what it cost
229
+
230
+ The budget line opens the reply; the cost closes it. When a reply completes
231
+ what was asked, or wraps up the session, end it with one plain line:
232
+
233
+ > Spent this session: 48 turns, 3.1M tokens, about $12.40.
234
+
235
+ The figures come from the `This session:` part of the budget line, so there is
236
+ nothing to run. They are exact as of the start of this turn, and the Stop hook
237
+ prints the up-to-date figure to the user the moment you finish, so do not go
238
+ and re-measure for the closing line.
239
+
240
+ Three rules keep it useful rather than noisy:
241
+
242
+ - **Finished work only.** A progress note mid-task, a clarifying question, or
243
+ a reply that ends with "shall I go on" is not the moment. The line marks a
244
+ goal reached or a session wrapped up.
245
+ - **One line, at the end.** It is a footer, not a section, and it never
246
+ replaces the recap of what was actually done.
247
+ - **Plain text.** No symbols, no formatting tricks. The number is the point.
248
+
249
+ The user can also ask any time with `/usage-limits:session`, or run
250
+ `node scripts/usage.js --session last` for the full breakdown and
251
+ `--sessions` for the history of recent sessions on this machine.
252
+
228
253
 
229
254
  ## 4. Low power
230
255
 
@@ -359,9 +384,12 @@ a turn, which is the thing it is trying to save.
359
384
 
360
385
  | Path | What it is |
361
386
  | --- | --- |
362
- | `scripts/usage.js` | The report. `--json` for raw fields, `--status` for a one-line readout that skips the transcript scan, `--forecast N` for what an N turn job would cost, `--host codex` to read Codex's limits, `--refresh` to ask Codex for a live figure. |
387
+ | `scripts/usage.js` | The report. `--json` for raw fields, `--status` for a one-line readout that skips the transcript scan, `--forecast N` for what an N turn job would cost, `--sessions` for what recent sessions cost, `--session last` (or an id) for one session in full, `--host codex` to read Codex's limits, `--refresh` to ask Codex for a live figure. |
363
388
  | `scripts/brief.js` | What the hook runs before each prompt. Not meant to be called by hand. |
364
389
  | `scripts/pulse.js` | What runs after tool calls, to re-check the budget during a long turn. Not meant to be called by hand. |
390
+ | `scripts/stop.js` | What runs after each reply: shows the user what the reply and the session have cost. Not meant to be called by hand. |
391
+ | `scripts/sessionend.js` | What runs when the session closes: the closing line, and the session marked closed in the history. Not meant to be called by hand. |
392
+ | `scripts/tally.js` | The running per-session total behind those two, kept in `usage-limits-sessions.json` and read incrementally. |
365
393
  | `scripts/host.js` | Works out which agent this is running inside, so one host's percentages are never reported against the other's turns. |
366
394
  | `scripts/codex.js` | The Codex reader: the meter and the pace out of `~/.codex/sessions`, plus the live `--refresh` call. |
367
395
  | `scripts/install-codex-hook.js` | `status`, `on`, `off`. Installs the Codex-side instruction, which Claude Code does not need. |
@@ -28,6 +28,14 @@ the report includes them when they are present.
28
28
  If the key is missing, run `/usage` once inside Claude Code. That is what
29
29
  populates it.
30
30
 
31
+ The same object carries a `limits` array: one entry per limit the account
32
+ enforces, each with `kind` (`session`, `weekly_all`, `weekly_scoped`),
33
+ `percent`, `severity`, `resets_at`, `is_active`, and for the scoped weeklies
34
+ which model they cover. The report reads it too. A scoped weekly becomes a
35
+ window of its own, labelled `weekly (Fable)` and priced from that model's
36
+ calls alone; `is_active` breaks ties in the binding choice; and a bucket that
37
+ quotes `limit_dollars` is priced from that directly instead of being calibrated.
38
+
31
39
  The plan name comes from `oauthAccount.organizationType` in the same file.
32
40
  Current effort and model come from `settings.json` in the config directory.
33
41
 
@@ -64,6 +72,13 @@ Assistant turns carry a usage record:
64
72
  }
65
73
  ```
66
74
 
75
+ Subagents write their own transcripts under
76
+ `<project>/<session id>/subagents/agent-*.jsonl`, with `isSidechain: true` and
77
+ the parent's `sessionId`. They are read too: their calls count in the money and
78
+ the tokens, and are kept apart from the turns, because a turn is one main-thread
79
+ call. Lines from a model called `<synthetic>` are interrupts and client-side
80
+ errors, not calls, and are skipped.
81
+
67
82
  Files whose modification time predates the window are skipped. Turns are keyed
68
83
  by `message.id` plus `requestId` and counted once, because resuming or forking
69
84
  a session copies earlier turns into a new file.
@@ -47,7 +47,7 @@ Per million tokens, input and output:
47
47
  | Model | Input | Output |
48
48
  | --- | --- | --- |
49
49
  | Opus 5 | $5 | $25 |
50
- | Sonnet 5 | $3 | $15 |
50
+ | Sonnet 5 | $2 | $10 |
51
51
  | Haiku 4.5 | $1 | $5 |
52
52
 
53
53
  Renaming symbols, writing boilerplate tests, formatting, mechanical
@@ -15,6 +15,7 @@ const path = require('path');
15
15
 
16
16
  const usage = require('./usage.js');
17
17
  const host = require('./host.js');
18
+ const tally = require('./tally.js');
18
19
 
19
20
  const SECOND = 1000;
20
21
  const DAY = 24 * 60 * 60 * 1000;
@@ -133,6 +134,20 @@ function liveSessions(all, now, windowMs, exceptId) {
133
134
  return count;
134
135
  }
135
136
 
137
+ // How many sessions are sharing the budget, and how much of it is this one's.
138
+ // One place for both hooks. The pulse once counted only sessions that had
139
+ // spent while the brief also counted those that had merely prompted, and the
140
+ // two lines disagreed about how many sessions there were minutes apart. The
141
+ // spend-derived count is the accurate one when it has caught up; the
142
+ // open-session count is the one that is right immediately. Take whichever is
143
+ // higher, because under-counting is what makes the headroom read as more
144
+ // yours than it is.
145
+ function activeShare(sessions, slots, now, sessionId) {
146
+ const spent = sessions || [];
147
+ const active = Math.max(spent.length, liveSessions(slots, now, LIVE_WINDOW_MS, sessionId) + 1);
148
+ return { active, share: active > 1 ? usage.shareOf(spent, sessionId, active) : 1 };
149
+ }
150
+
136
151
  // Keep the newest few so a machine with many sessions does not grow the file
137
152
  // without bound.
138
153
  function mergeCache(all, sessionId, entry, keep) {
@@ -252,6 +267,7 @@ const CACHED_BINDING_FIELDS = [
252
267
  'adjusted',
253
268
  'pointsSinceSnapshot',
254
269
  'correctionUnreliable',
270
+ 'pointsBeyondSnapshot',
255
271
  'resetsAt',
256
272
  'verdict',
257
273
  'windowStart',
@@ -332,7 +348,10 @@ function briefText(parts) {
332
348
  // the binding window is named and its figures are attached to it.
333
349
  const bound = [];
334
350
  const described = describeWindow(parts.binding);
335
- if (described) bound.push(described + (parts.binding.stale ? '' : ' used'));
351
+ // When spending since the snapshot has outrun what the snapshot said was
352
+ // left, the percentage is the last real reading, not the current one, and
353
+ // the headline has to say so or every later prompt repeats a stale number.
354
+ if (described) bound.push(described + (parts.binding.stale ? '' : (parts.correctionUnreliable ? ' used at the last real reading' : ' used')));
336
355
  if (Number.isFinite(parts.turnsLeft)) {
337
356
  // Another session spending the same budget means fewer of those turns are
338
357
  // yours, so say both numbers rather than the flattering one.
@@ -361,6 +380,13 @@ function briefText(parts) {
361
380
  ? '[usage-limits] binding window is ' + bound.join(', ') + '.'
362
381
  : '[usage-limits] no usable window reading.'
363
382
  );
383
+ if (parts.planChanged) {
384
+ sentences.push(
385
+ 'The plan has changed since these figures were learned, so the reading ' +
386
+ 'above was measured against a different allowance and may predate the ' +
387
+ 'change; run /usage before trusting it.'
388
+ );
389
+ }
364
390
  if (parts.rebuilt) {
365
391
  sentences.push(
366
392
  'That figure was rebuilt from local history because the ' +
@@ -380,6 +406,16 @@ function briefText(parts) {
380
406
  parts.snapshotAge + ' old, so run /usage before trusting the rest.'
381
407
  );
382
408
  }
409
+ if (parts.correctionUnreliable) {
410
+ // A reading three hours old that has been spent past its own remainder is
411
+ // not 82%; it is unknown, with 82% as the floor. Say exactly that.
412
+ sentences.push(
413
+ 'That percentage is a floor, not a current reading: the last real snapshot is ' +
414
+ parts.snapshotAge + ' old and about ' + parts.pointsBeyondSnapshot +
415
+ ' points have been spent since, more than it said was left. Either the window is ' +
416
+ 'already exhausted or the snapshot is wrong; /usage refreshes it.'
417
+ );
418
+ }
383
419
  // Work having actually been stopped is the most useful thing that can be said
384
420
  // about a budget, and the percentages stop showing it the moment the window
385
421
  // turns over. Saying it plainly is what stops the next session opening with
@@ -405,9 +441,45 @@ function briefText(parts) {
405
441
  if (parts.session) {
406
442
  sentences.push(
407
443
  'This session: ' + parts.session.turns + ' turns, ' +
444
+ (Number.isFinite(parts.session.tokens) ? usage.formatTokens(parts.session.tokens) + ' tokens, ' : '') +
408
445
  usage.formatUSD(parts.session.cost) + '.'
409
446
  );
410
447
  }
448
+ // From the tally the Stop hook keeps, so these are exact as of the last reply.
449
+ if (parts.lastReply && Number.isFinite(parts.lastReply.cost)) {
450
+ sentences.push('Last reply ' + usage.formatMoney(parts.lastReply.cost) + '.');
451
+ }
452
+ if (Number.isFinite(parts.context) && parts.context > 0) {
453
+ // The context is re-sent on every call, so past a point it is the cost of
454
+ // the session. One clause, once it is large; no advice while it is not.
455
+ sentences.push(
456
+ 'Context about ' + usage.formatTokens(parts.context) + ' tokens' +
457
+ (parts.context >= LARGE_CONTEXT_TOKENS
458
+ ? '; each turn re-reads that, so a fresh session or /compact at the next clean boundary cuts per-turn cost.'
459
+ : '.')
460
+ );
461
+ }
462
+ // Said once, on a session's first prompt, because the previous session's
463
+ // total is the one figure nothing else ever shows.
464
+ if (parts.lastSession && parts.lastSession.turns) {
465
+ const last = parts.lastSession;
466
+ sentences.push(
467
+ 'Last session: ' + last.turns + ' turns, ' + usage.formatTokens(last.tokens) + ' tokens, ' +
468
+ roundMoney(last.cost) +
469
+ (last.project || last.endedAgo
470
+ ? ' (' +
471
+ [
472
+ last.project,
473
+ last.endedAgo
474
+ ? (last.open ? 'still open, last active ' : 'ended ') + last.endedAgo + ' ago'
475
+ : null,
476
+ ]
477
+ .filter(Boolean)
478
+ .join(', ') +
479
+ ')'
480
+ : '') + '.'
481
+ );
482
+ }
411
483
 
412
484
  // Three states, and only the last one stops anything.
413
485
  //
@@ -444,7 +516,72 @@ function briefText(parts) {
444
516
  'and reset time above belong to the binding window alone; do not read them ' +
445
517
  'against another window percentage.';
446
518
 
447
- return sentences.join(' ') + '\n' + instruction + care;
519
+ // Finished work closes with what it cost. Not every reply: a progress note
520
+ // mid-task is not the moment, and once the budget is gone nothing further
521
+ // runs, so there is no reply to close.
522
+ const closing =
523
+ parts.pressure === 'gone'
524
+ ? ''
525
+ : ' When this reply completes what was asked, or wraps up the session, end it ' +
526
+ 'with one plain line giving the session total above (turns, tokens and cost). ' +
527
+ 'Skip it on partial progress; the hook prints the exact figure after you stop.';
528
+
529
+ return sentences.join(' ') + '\n' + instruction + closing + care;
530
+ }
531
+
532
+ // Past this the context is the cost of the session, not a detail of it.
533
+ const LARGE_CONTEXT_TOKENS = 150000;
534
+
535
+ // A previous session's total reads better in whole dollars once it is large.
536
+ function roundMoney(value) {
537
+ if (!Number.isFinite(value)) return '-';
538
+ return value >= 10 ? '$' + Math.round(value) : usage.formatMoney(value);
539
+ }
540
+
541
+ function isTallyEntry(value) {
542
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
543
+ }
544
+
545
+ // What the Stop hook's tally can add to the line: this session's last reply
546
+ // and context when it is a session the tally has seen, or the previous
547
+ // session's total when it has not, which is how a first prompt gets told what
548
+ // the last session cost.
549
+ function tallyContext(all, sessionId, now) {
550
+ const none = { lastReply: null, context: null, lastSession: null };
551
+ if (!all || typeof all !== 'object') return none;
552
+
553
+ const mine = sessionId && isTallyEntry(all[sessionId]) ? all[sessionId] : null;
554
+ if (mine) {
555
+ return {
556
+ lastReply: mine.lastReply || null,
557
+ context: Number.isFinite(mine.context) ? mine.context : null,
558
+ lastSession: null,
559
+ };
560
+ }
561
+
562
+ const others = Object.keys(all)
563
+ .filter((key) => key !== tally.IDS_KEY && isTallyEntry(all[key]))
564
+ .map((key) => all[key])
565
+ .sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0));
566
+ const last = others[0];
567
+ if (!last || !last.turns) return none;
568
+
569
+ const open = !Number.isFinite(last.endedAt);
570
+ const ended = open ? last.lastAt : last.endedAt;
571
+ return {
572
+ lastReply: null,
573
+ context: null,
574
+ lastSession: {
575
+ turns: last.turns,
576
+ tokens: tally.totalTokens(last.tokens),
577
+ cost: last.cost,
578
+ project: last.project || null,
579
+ // Another window may still be in it, so say when it was last active
580
+ // rather than claiming it ended.
581
+ open,
582
+ endedAgo: Number.isFinite(ended) ? usage.formatDuration(now - ended) : null,
583
+ },
584
+ };
448
585
  }
449
586
 
450
587
  async function run(now, hookInput) {
@@ -478,6 +615,7 @@ async function run(now, hookInput) {
478
615
  othersSummary: summariseOthers(data.windows, binding && binding.key),
479
616
  sessions: data.sessions,
480
617
  staleWindows: data.staleWindows,
618
+ planChanged: data.planChanged,
481
619
  critical: usage.criticalOthers(data.windows, binding && binding.key).map((w) => ({
482
620
  label: w.label,
483
621
  percentUsed: w.percentUsed,
@@ -490,14 +628,7 @@ async function run(now, hookInput) {
490
628
  }
491
629
 
492
630
  const binding = view.binding;
493
- const sessions = view.sessions || [];
494
- // The spend-derived count is the accurate one when it has caught up; the
495
- // open-session count is the one that is right immediately. Take whichever is
496
- // higher rather than the one that happens to be handy, because under-counting
497
- // is what makes the headroom read as more yours than it is.
498
- const active = Math.max(sessions.length, liveSessions(all, now, LIVE_WINDOW_MS, sessionId) + 1);
499
- const share =
500
- sessions.length > 1 ? usage.shareOf(sessions, sessionId) : active > 1 ? 1 / active : 1;
631
+ const { active, share } = activeShare(view.sessions, all, now, sessionId);
501
632
  const yourTurnsLeft = Number.isFinite(view.turnsLeft)
502
633
  ? Math.max(1, Math.round(view.turnsLeft * share))
503
634
  : null;
@@ -506,7 +637,12 @@ async function run(now, hookInput) {
506
637
  const shortRunway =
507
638
  binding && Number.isFinite(binding.headroomMs) &&
508
639
  binding.headroomMs <= RUNWAY_MENTION_MS;
640
+ // Outside the cache on purpose: the tally moves after every reply.
641
+ const found = tallyContext(tally.readState(), sessionId, now);
509
642
  return briefText({
643
+ lastReply: found.lastReply,
644
+ context: found.context,
645
+ lastSession: found.lastSession,
510
646
  sessions: active,
511
647
  yourTurnsLeft,
512
648
  runsOutIn: shortRunway ? usage.formatDuration(binding.headroomMs) : null,
@@ -526,8 +662,11 @@ async function run(now, hookInput) {
526
662
  session: view.session,
527
663
  rebuilt: Boolean(binding && binding.estimated),
528
664
  staleWindows: view.staleWindows || 0,
665
+ planChanged: Boolean(view.planChanged),
529
666
  critical: view.critical || [],
530
667
  pointsSinceSnapshot: (binding && binding.pointsSinceSnapshot) || 0,
668
+ correctionUnreliable: Boolean(binding && binding.correctionUnreliable),
669
+ pointsBeyondSnapshot: (binding && binding.pointsBeyondSnapshot) || 0,
531
670
  snapshotAge: view.snapshotAge,
532
671
  // The turn count that matters for this session is its share of a shared
533
672
  // budget, not the whole window's. Escalating on the whole window meant a
@@ -565,11 +704,15 @@ module.exports = {
565
704
  describeWindow,
566
705
  summariseOthers,
567
706
  briefText,
707
+ tallyContext,
708
+ LARGE_CONTEXT_TOKENS,
568
709
  settings,
569
710
  keepSlots,
570
711
  pickCached,
571
712
  mergeCache,
572
713
  liveSessions,
714
+ activeShare,
715
+ readCache,
573
716
  cacheableBinding,
574
717
  pressureInputs,
575
718
  CACHED_BINDING_FIELDS,
@@ -129,10 +129,12 @@ async function run(now, hookInput) {
129
129
  const binding = data.binding;
130
130
  if (!binding) return '';
131
131
 
132
- const sessions = data.sessions || [];
133
- const share = usage.shareOf(sessions, sessionId);
132
+ // The same count and the same split as the brief, so the two lines never
133
+ // disagree about how many sessions there are or how much of the budget is
134
+ // this one's.
135
+ const { active, share } = brief.activeShare(data.sessions, brief.readCache(), now, sessionId);
134
136
  const turnsLeft = Number.isFinite(binding.turnsLeft)
135
- ? sessions.length > 1
137
+ ? active > 1
136
138
  ? Math.max(1, Math.round(binding.turnsLeft * share))
137
139
  : binding.turnsLeft
138
140
  : null;
@@ -156,7 +158,7 @@ async function run(now, hookInput) {
156
158
  Number.isFinite(binding.headroomMs) && binding.headroomMs <= runwayMs
157
159
  ? usage.formatDuration(binding.headroomMs)
158
160
  : null,
159
- sessions: sessions.length,
161
+ sessions: active,
160
162
  pressure,
161
163
  });
162
164
  }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The closing line.
5
+ //
6
+ // Runs as the SessionEnd hook. Brings the session's total up to date one last
7
+ // time, marks it closed, and prints one line saying how long it ran and what
8
+ // it cost. SessionEnd shows plain stdout to the user and gives hooks a short
9
+ // shared budget, which the incremental read fits inside comfortably.
10
+
11
+ const usage = require('./usage.js');
12
+ const host = require('./host.js');
13
+ const tally = require('./tally.js');
14
+
15
+ async function run(now, hookInput) {
16
+ if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
17
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
18
+
19
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
20
+ const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
21
+ if (!sessionId || !transcript) return '';
22
+
23
+ const all = tally.readState();
24
+ const { session } = tally.update(all, sessionId, transcript, now, { cwd: hookInput.cwd || null });
25
+ session.endedAt = now;
26
+ session.reason = typeof hookInput.reason === 'string' ? hookInput.reason : null;
27
+ tally.writeState(tally.trim(all));
28
+
29
+ return tally.formatClosed(session, now);
30
+ }
31
+
32
+ if (require.main === module) {
33
+ tally
34
+ .readHookInput()
35
+ .then((input) => run(Date.now(), input))
36
+ .then(
37
+ (text) => {
38
+ if (text) process.stdout.write(text + '\n');
39
+ process.exit(0);
40
+ },
41
+ () => {
42
+ process.exit(0);
43
+ }
44
+ );
45
+ }
46
+
47
+ module.exports = { run };