claude-usage-limits 1.25.0 → 1.26.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.25.0",
4
+ "version": "1.26.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.25.0",
3
+ "version": "1.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.25.0",
3
+ "version": "1.26.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",
@@ -124,7 +124,7 @@ async function run(now, input, argv) {
124
124
  if (event === 'PreToolUse') {
125
125
  const tool = toolNameOf(input);
126
126
  if (!ceiling.isMultiplier(tool)) return {};
127
- const at = ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env });
127
+ const at = ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env, sessionId });
128
128
  const call = ceiling.verdict(at, tool);
129
129
  if (call.decision !== 'deny') return {};
130
130
  return { decision: 'deny', reason: call.reason };
@@ -141,7 +141,7 @@ async function run(now, input, argv) {
141
141
  text = '';
142
142
  }
143
143
  const warning = ceiling.warning(
144
- ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env })
144
+ ceiling.assess({ percent: percentNow(now), state: budget.state, env: process.env, sessionId })
145
145
  );
146
146
  const message = [text, warning].filter(Boolean).join(' ');
147
147
  return message ? { injectSteps: [{ ephemeralMessage: message }] } : { injectSteps: [] };
@@ -64,10 +64,22 @@ function isMultiplier(tool) {
64
64
  return MULTIPLIER_PATTERN.test(name);
65
65
  }
66
66
 
67
- // The number, and who set it. An explicit environment value is the user saying
68
- // it outright for this one session and beats the file, the same precedence the
69
- // mode uses.
70
- function ceilingFrom(state, env) {
67
+ // The number, and who set it.
68
+ //
69
+ // A cap belongs to THE SESSION THAT SET IT, and this is the whole of the fix
70
+ // for it outliving one. "Do not spend past 65 per cent" is a thing somebody
71
+ // says about the work in front of them; carrying it into tomorrow's session
72
+ // means refusing fan-outs on a fresh window for a reason nobody remembers
73
+ // giving. It was reported exactly that way: the session restarted and the cap
74
+ // was still being enforced.
75
+ //
76
+ // So the stored cap carries the session id it was set in, and applies only
77
+ // there. A cap with no session recorded is a cap from before this rule existed
78
+ // - it is ignored rather than honoured, because honouring it is the bug.
79
+ //
80
+ // An explicit environment value is different: it is the user saying it outright
81
+ // for this one process, so it beats the file and needs no session.
82
+ function ceilingFrom(state, env, sessionId) {
71
83
  const environment = env || process.env;
72
84
  const raw = environment.USAGE_LIMITS_CEILING;
73
85
  if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
@@ -81,8 +93,16 @@ function ceilingFrom(state, env) {
81
93
  // the file rather than enforcing a number nobody typed.
82
94
  }
83
95
  const stored = state && Number.isFinite(state.ceilingPercent) ? state.ceilingPercent : null;
84
- if (stored !== null && stored > 0 && stored <= 100) return { percent: stored, source: 'the file' };
85
- return { percent: null, source: null };
96
+ if (stored === null || stored <= 0 || stored > 100) return { percent: null, source: null };
97
+ const owner = state && state.ceilingSession ? String(state.ceilingSession) : null;
98
+ if (!owner) {
99
+ // Set before caps were session-scoped. Not this session's instruction.
100
+ return { percent: null, source: null, staleCap: stored };
101
+ }
102
+ if (!sessionId || String(sessionId) !== owner) {
103
+ return { percent: null, source: null, otherSessionCap: stored };
104
+ }
105
+ return { percent: stored, source: 'this session' };
86
106
  }
87
107
 
88
108
  // Where the binding window stands against the ceiling.
@@ -92,7 +112,7 @@ function ceilingFrom(state, env) {
92
112
  // the same mistake the brief was corrected for.
93
113
  function assess(options) {
94
114
  const opts = options || {};
95
- const ceiling = ceilingFrom(opts.state, opts.env);
115
+ const ceiling = ceilingFrom(opts.state, opts.env, opts.sessionId);
96
116
  const percent = Number.isFinite(opts.percent) ? opts.percent : null;
97
117
  if (ceiling.percent === null || percent === null) {
98
118
  return {
@@ -103,6 +123,8 @@ function assess(options) {
103
123
  over: false,
104
124
  near: false,
105
125
  headroomPoints: null,
126
+ staleCap: ceiling.staleCap || null,
127
+ otherSessionCap: ceiling.otherSessionCap || null,
106
128
  };
107
129
  }
108
130
  const headroomPoints = ceiling.percent - percent;
@@ -175,7 +175,7 @@ function bindingReset(now) {
175
175
  function sessionId(argv, env) {
176
176
  const at = argv.indexOf('--session-id');
177
177
  if (at !== -1 && argv[at + 1]) return argv[at + 1];
178
- return env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID || 'defer-' + Date.now().toString(36);
178
+ return env.CLAUDE_CODE_SESSION_ID || env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID || 'defer-' + Date.now().toString(36);
179
179
  }
180
180
 
181
181
  function argOf(argv, name) {
@@ -192,7 +192,7 @@ function describe(settings, state) {
192
192
  function logSettingsChange(changes, direction, env) {
193
193
  if (!changes || !changes.length) return;
194
194
  const e = env || process.env;
195
- const by = e.CLAUDE_SESSION_ID || e.CODEX_SESSION_ID ? 'claude' : 'user';
195
+ const by = e.CLAUDE_CODE_SESSION_ID || e.CLAUDE_SESSION_ID || e.CODEX_SESSION_ID ? 'claude' : 'user';
196
196
  try {
197
197
  const mode = require('./mode.js');
198
198
  for (const change of changes) {
@@ -388,6 +388,11 @@ function empty() {
388
388
  // reported: past it, fan-out calls are refused at the hook. Null means no
389
389
  // ceiling, and a ceiling nobody set never refuses anything. See ceiling.js.
390
390
  ceilingPercent: null,
391
+ // WHOSE cap it is. A cap is said about the work in front of someone, so
392
+ // it binds only in the session that set it; carrying it into the next one
393
+ // means refusing fan-outs on a fresh window for a reason nobody gave. A
394
+ // stored cap with no session here predates this rule and is ignored.
395
+ ceilingSession: null,
391
396
  setAt: null,
392
397
  setBy: null,
393
398
  session: null,
@@ -431,6 +436,7 @@ function read() {
431
436
  base.guardPercent = Number.isFinite(parsed.guardPercent) ? parsed.guardPercent : null;
432
437
  // Anything outside 1-100 is not a ceiling, and enforcing a number that was
433
438
  // never a percentage would refuse work over a typo.
439
+ base.ceilingSession = typeof parsed.ceilingSession === 'string' ? parsed.ceilingSession : null;
434
440
  base.ceilingPercent =
435
441
  Number.isFinite(parsed.ceilingPercent) && parsed.ceilingPercent > 0 && parsed.ceilingPercent <= 100
436
442
  ? parsed.ceilingPercent
@@ -1240,7 +1246,10 @@ function setMode(name, opts, now) {
1240
1246
  state.setAt = at;
1241
1247
  state.setBy = 'user';
1242
1248
  if (opts && Number.isFinite(opts.guard)) state.guardPercent = opts.guard;
1243
- if (opts && Number.isFinite(opts.ceiling)) state.ceilingPercent = opts.ceiling;
1249
+ if (opts && Number.isFinite(opts.ceiling)) {
1250
+ state.ceilingPercent = opts.ceiling;
1251
+ state.ceilingSession = opts.sessionId || null;
1252
+ }
1244
1253
  write(state);
1245
1254
  logChange({ plane: 'mode', key: 'mode', from: before, to: name, by: 'user', reason: null }, at);
1246
1255
 
@@ -1288,7 +1297,10 @@ function main(argv) {
1288
1297
  const next = args[at + 1];
1289
1298
  return next && next.indexOf('--') !== 0 ? next : null;
1290
1299
  };
1291
- const sessionId = value('--session-id') || process.env.CLAUDE_SESSION_ID || null;
1300
+ // CLAUDE_CODE_SESSION_ID is the one Claude Code actually exports;
1301
+ // CLAUDE_SESSION_ID never existed, so this read null and a cap set from the
1302
+ // command line could record no owner and therefore never bind.
1303
+ const sessionId = value('--session-id') || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || null;
1292
1304
  const decided = resolve({ sessionId });
1293
1305
 
1294
1306
  if (flag('--list')) return list(decided);
@@ -1465,6 +1477,7 @@ function main(argv) {
1465
1477
  const state = read();
1466
1478
  const previous = state.ceilingPercent;
1467
1479
  state.ceilingPercent = ceilingArg.clear ? null : ceilingArg.percent;
1480
+ state.ceilingSession = ceilingArg.clear ? null : sessionId;
1468
1481
  write(state);
1469
1482
  logChange(
1470
1483
  { plane: 'mode', key: 'ceiling', from: previous, to: state.ceilingPercent, by: 'user', reason: null },
@@ -1481,6 +1494,7 @@ function clearCeiling(now) {
1481
1494
  const state = read();
1482
1495
  const previous = state.ceilingPercent;
1483
1496
  state.ceilingPercent = null;
1497
+ state.ceilingSession = null;
1484
1498
  write(state);
1485
1499
  logChange({ plane: 'mode', key: 'ceiling', from: previous, to: null, by: 'user', reason: null }, now);
1486
1500
  }
@@ -1493,10 +1507,11 @@ function ceilingLine() {
1493
1507
  return 'Ceiling off. Nothing is refused; the plugin reports and does not intervene.';
1494
1508
  }
1495
1509
  return (
1496
- 'Ceiling ' + state.ceilingPercent + '%. Past that, fan-out calls (Agent, Task, Workflow and ' +
1497
- 'their equivalents) are refused at the hook, in every session on this machine. Nothing else ' +
1510
+ 'Cap ' + state.ceilingPercent + '%' + (state.ceilingSession ? ', for THIS session only' : ' (stored against no session, so it is ignored - set it again to apply it here)') +
1511
+ '. Past that, fan-out calls (Agent, Task, Workflow and ' +
1512
+ 'their equivalents) are refused at the hook. Nothing else ' +
1498
1513
  'is blocked, so the work still finishes - sequentially, in one session, which is where the ' +
1499
- 'saving comes from. "mode --cap off" removes it.'
1514
+ 'saving comes from. It lapses when this session ends; "mode --cap off" removes it now.'
1500
1515
  );
1501
1516
  }
1502
1517
 
@@ -253,6 +253,8 @@ async function run(now, hookInput) {
253
253
  percent: ceilingPercent(now),
254
254
  state: budget.state,
255
255
  env: process.env,
256
+ // The cap only binds in the session that set it.
257
+ sessionId,
256
258
  });
257
259
  const call = ceiling.verdict(at, tool);
258
260
  if (call.decision === 'deny') return { deny: true, reason: call.reason };
@@ -521,8 +521,27 @@ function wakeAt(resetsAt, graceMinutes, now) {
521
521
  return Math.max(at, floor);
522
522
  }
523
523
 
524
- function taskName(id) {
525
- return 'UsageLimitsRelay-' + String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40);
524
+ // One name per WAKE, not one per session.
525
+ //
526
+ // Reusing a name entangles every registration with the previous one's fate,
527
+ // and all of it is undocumented: whether -Force truly replaces a stale
528
+ // definition, what happens when a name is re-registered while its predecessor
529
+ // is pending expiry-deletion, and whether an old EndBoundary survives the
530
+ // update. On top of that MultipleInstances defaults to IgnoreNew, so a
531
+ // previous run still held open by the four-hour execution limit silently
532
+ // swallows the next launch.
533
+ //
534
+ // A unique suffix removes all of it at once, and lets DeleteExpiredTaskAfter
535
+ // do what it should: each fired wake garbage-collects itself. cancelSchedule
536
+ // works off the stored name, so nothing downstream changes.
537
+ function taskName(id, at) {
538
+ const stamp = Number.isFinite(at) ? at : Date.now();
539
+ return (
540
+ 'UsageLimitsRelay-' +
541
+ String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40) +
542
+ '-' +
543
+ stamp.toString(36)
544
+ );
526
545
  }
527
546
 
528
547
  function psQuote(value) {
@@ -574,7 +593,45 @@ function remainingMs(deadline, ceiling) {
574
593
  return Math.max(0, Math.min(ceiling, left));
575
594
  }
576
595
 
596
+ // Does Windows agree that this task will run?
597
+ //
598
+ // `state` and `next` come straight from Get-ScheduledTask and
599
+ // Get-ScheduledTaskInfo. A task can register cleanly and still never fire,
600
+ // and each of these has been seen: Disabled by policy or by an earlier
601
+ // failure; NextRunTime empty because the trigger time had already passed by
602
+ // the time it was written; NextRunTime set to something other than what was
603
+ // asked for, meaning the trigger did not take. None of them raised an error
604
+ // before, so the relay reported an armed wake and nothing happened.
605
+ function verifyRegistration(state, next, wanted, now) {
606
+ const said = String(state || '').trim();
607
+ if (/disabled/i.test(said)) {
608
+ return { ok: false, error: 'the scheduled task registered but is Disabled, so it will not run' };
609
+ }
610
+ const text = String(next || '').trim();
611
+ if (!text) {
612
+ return { ok: false, error: 'the scheduled task registered but Windows reports no next run time, so it will not fire' };
613
+ }
614
+ const at = Date.parse(text);
615
+ if (!Number.isFinite(at)) {
616
+ return { ok: false, error: 'could not read the next run time Windows reported: ' + text };
617
+ }
618
+ if (at <= now) {
619
+ return { ok: false, error: 'the scheduled task next run time is already in the past' };
620
+ }
621
+ // Five minutes of slack: Task Scheduler rounds to the minute and a trigger
622
+ // can be nudged. Anything further out is a different time from the one asked
623
+ // for, which means the trigger did not take.
624
+ if (Number.isFinite(wanted) && Math.abs(at - wanted) > 5 * 60 * 1000) {
625
+ return {
626
+ ok: false,
627
+ error: 'the scheduled task is set for ' + new Date(at).toISOString() + ', not the requested ' + new Date(wanted).toISOString(),
628
+ };
629
+ }
630
+ return { ok: true, nextRun: at };
631
+ }
632
+
577
633
  function scheduleWindows(when, argv, name, cwd, deadline) {
634
+ let lastVerifyError = null;
578
635
  const date = new Date(when);
579
636
  const stamp =
580
637
  date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) + ' ' +
@@ -589,12 +646,33 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
589
646
  // leaves a dead entry in Task Scheduler forever; the wake also unregisters
590
647
  // itself when it finishes, and this is what catches the wakes that never
591
648
  // get to run at all.
592
- '$trigger.EndBoundary = (Get-Date ' + psQuote(stamp) + ').AddHours(12).ToString("s")',
649
+ // "zzz" is load-bearing. EndBoundary is documented as
650
+ // YYYY-MM-DDTHH:MM:SS(+-)HH:MM, and .ToString("s") emits no offset at
651
+ // all. A boundary written without one can be read as UTC, which west of
652
+ // Greenwich puts it HOURS IN THE PAST - so the task is born expired,
653
+ // never fires, and DeleteExpiredTaskAfter quietly reaps it. None of that
654
+ // raises an error: registration returns SCHED_S_SOME_TRIGGERS_FAILED
655
+ // (0x0004131B), which is a SUCCESS code, so PowerShell never throws and
656
+ // the relay reported a wake that could not happen.
657
+ '$trigger.EndBoundary = (Get-Date ' + psQuote(stamp) + ').AddHours(12).ToString("yyyy-MM-ddTHH:mm:sszzz")',
593
658
  '$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun -AllowStartIfOnBatteries ' +
594
659
  '-DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 4) ' +
595
660
  '-DeleteExpiredTaskAfter (New-TimeSpan -Minutes 10)',
596
661
  'Register-ScheduledTask -TaskName ' + psQuote(name) + ' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null',
597
- 'Write-Output "registered"',
662
+ // Ask Windows whether it will actually fire, rather than trusting that a
663
+ // registration which did not throw is a wake that will happen.
664
+ //
665
+ // This is the difference between "it said it would restart at 4:30 and
666
+ // nothing happened" and an error at arming time. Register-ScheduledTask
667
+ // reports success for a task that will never run: a trigger already in the
668
+ // past, a task left Disabled, a name whose previous registration is still
669
+ // being torn down. NextRunTime is the only field that answers the question
670
+ // actually being asked.
671
+ '$info = Get-ScheduledTaskInfo -TaskName ' + psQuote(name) + ' -ErrorAction SilentlyContinue',
672
+ '$state = (Get-ScheduledTask -TaskName ' + psQuote(name) + ' -ErrorAction SilentlyContinue).State',
673
+ '$next = ""',
674
+ 'if ($info -and $info.NextRunTime) { $next = $info.NextRunTime.ToString("s") }',
675
+ 'Write-Output ("registered|" + $state + "|" + $next)',
598
676
  ].join('\n');
599
677
  const file = path.join(os.tmpdir(), name + '.ps1');
600
678
  try {
@@ -604,7 +682,14 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
604
682
  windowsHide: true,
605
683
  timeout: Math.max(500, remainingMs(deadline, 8000)),
606
684
  });
607
- if (run.status === 0 && /registered/.test(run.stdout || '')) return { ok: true, how: 'ScheduledTasks' };
685
+ const registered = (run.stdout || '').match(/registered\|([^|\r\n]*)\|([^\r\n]*)/);
686
+ if (run.status === 0 && registered) {
687
+ const verdict = verifyRegistration(registered[1], registered[2], when, Date.now());
688
+ if (verdict.ok) return { ok: true, how: 'ScheduledTasks', nextRun: verdict.nextRun };
689
+ // Registered but it will not fire. Fall through to schtasks rather than
690
+ // reporting a wake that is not going to happen.
691
+ lastVerifyError = verdict.error;
692
+ }
608
693
  // No time left for a second attempt is a plain refusal, not a hung hook.
609
694
  if (remainingMs(deadline, 8000) < 1500) return { ok: false, error: 'no time left in this hook to register the wake; it will arm on the next prompt' };
610
695
  // schtasks cannot express StartWhenAvailable, so this path is a worse
@@ -618,7 +703,7 @@ function scheduleWindows(when, argv, name, cwd, deadline) {
618
703
  { encoding: 'utf8', windowsHide: true, timeout: Math.max(500, remainingMs(deadline, 8000)) }
619
704
  );
620
705
  if (fallback.status === 0) return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake' };
621
- return { ok: false, error: (run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
706
+ return { ok: false, error: (lastVerifyError || run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
622
707
  } catch (err) {
623
708
  return { ok: false, error: err.message };
624
709
  } finally {
@@ -725,7 +810,17 @@ function arm(input) {
725
810
 
726
811
  const state = read();
727
812
  const id = options.sessionId;
728
- const name = taskName(id);
813
+ const name = taskName(id, when);
814
+ // Names are unique per wake now, so re-arming no longer replaces the old
815
+ // registration by name. Retire it explicitly, or every re-arm leaves a live
816
+ // task behind that would fire on its own.
817
+ if (state.armed && state.armed.task && state.armed.task !== name) {
818
+ try {
819
+ cancelSchedule(state.armed.task);
820
+ } catch (err) {
821
+ // The expiry removes it eventually either way.
822
+ }
823
+ }
729
824
  // Re-arming the same session for the same reset would register the task
730
825
  // twice; -Force replaces it, and the record is rewritten either way.
731
826
  const argv = [path.join(__dirname, 'wake.js'), '--id', id];
@@ -1234,6 +1329,7 @@ module.exports = {
1234
1329
  remainingMs,
1235
1330
  armByHand,
1236
1331
  schedule,
1332
+ verifyRegistration,
1237
1333
  scheduleWindows,
1238
1334
  schedulePosix,
1239
1335
  cancelSchedule,