claude-usage-limits 1.13.0 → 1.13.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.13.0",
4
+ "version": "1.13.2",
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.13.0",
3
+ "version": "1.13.2",
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/commands/relay.md CHANGED
@@ -31,6 +31,11 @@ The rest:
31
31
  - `thinking off|resume|always` - `resume` puts the word ultrathink into the
32
32
  prompt the relay delivers. `always` sets `alwaysThinkingEnabled` in your
33
33
  settings, backs the file up first, and applies to new sessions.
34
+ - `arm [--session <id>] ["<text>"]` - arm by hand, against the binding
35
+ window's reset, without waiting for the hook to see a todo list. This is the
36
+ way under Codex, which writes no plan tool into its rollouts for the hook to
37
+ read, and for any project that lives in your head rather than a list. Any
38
+ text given is stored as the continuation.
34
39
  - `note "<text>"` (or `note --file <path>`) - store the continuation. This is
35
40
  the text that gets delivered, so write it to be acted on: what is done, what
36
41
  is next in order, which files are mid-change, what to verify first.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
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",
@@ -815,6 +815,9 @@ function relayState(now, hookInput, binding, sessionId) {
815
815
  const armed = relay.arm({
816
816
  now,
817
817
  config,
818
+ // Registration must be finished, not merely started, inside the hook's
819
+ // ten seconds; the scheduler trims its own waits to this.
820
+ deadline: now + ARM_DEADLINE_MS + 4000,
818
821
  sessionId,
819
822
  binding,
820
823
  work,
@@ -914,6 +917,11 @@ async function run(now, hookInput) {
914
917
  turnsLeft: binding && Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
915
918
  session: data.session,
916
919
  othersSummary: summariseOthers(data.windows, binding && binding.key),
920
+ // Every window, trimmed to the cacheable fields, so the corrected reading
921
+ // can be recorded for all three columns of the status line on a cache
922
+ // hit too. Recording the binding window alone left the other two at
923
+ // their raw snapshots on every short turn, where the pulse never runs.
924
+ windows: (data.windows || []).map(cacheableBinding),
917
925
  sessions: data.sessions,
918
926
  staleWindows: data.staleWindows,
919
927
  planChanged: data.planChanged,
@@ -954,7 +962,7 @@ async function run(now, hookInput) {
954
962
  // too often to scan for itself, and without this it shows the raw snapshot,
955
963
  // which during a heavy session is wrong by tens of points in the flattering
956
964
  // direction.
957
- reading.record(binding, now, usage.isCodex() ? require('./codex.js').homeDir() : null);
965
+ reading.recordAll(view.windows && view.windows.length ? view.windows : [binding], now, usage.isCodex() ? require('./codex.js').homeDir() : null);
958
966
  const carry = relayState(now, hookInput, binding, sessionId);
959
967
  // An instruction the user typed at /usage-limits:voice set. The learned
960
968
  // traits are for writing AS them and stay out of the way; this is them
@@ -89,7 +89,13 @@ function record(binding, now, codexHome) {
89
89
  else if (Number.isFinite(entry.resetsAt) && entry.resetsAt <= at) delete all[key];
90
90
  }
91
91
  fs.mkdirSync(path.dirname(readingFile(codexHome)), { recursive: true });
92
- fs.writeFileSync(readingFile(codexHome), JSON.stringify(all));
92
+ // Write beside and rename: the prompt hook and the pulse can both land
93
+ // here in the same second, and a reader between a truncate and a write
94
+ // would see half a file.
95
+ const file = readingFile(codexHome);
96
+ const tmp = file + '.' + process.pid + '.tmp';
97
+ fs.writeFileSync(tmp, JSON.stringify(all));
98
+ fs.renameSync(tmp, file);
93
99
  return true;
94
100
  } catch (err) {
95
101
  return false;
@@ -114,10 +114,19 @@ function read() {
114
114
  };
115
115
  }
116
116
 
117
+ // Written to a sibling and renamed into place. Two hooks can run at once, and a
118
+ // reader that lands between the truncate and the write of a plain writeFileSync
119
+ // sees half a file; the rename is the only step another process can observe.
120
+ function writeAtomic(file, text) {
121
+ const tmp = file + '.' + process.pid + '.tmp';
122
+ fs.writeFileSync(tmp, text);
123
+ fs.renameSync(tmp, file);
124
+ }
125
+
117
126
  function write(state) {
118
127
  try {
119
128
  fs.mkdirSync(configDir(), { recursive: true });
120
- fs.writeFileSync(relayFile(), JSON.stringify(state, null, 2) + '\n');
129
+ writeAtomic(relayFile(), JSON.stringify(state, null, 2) + '\n');
121
130
  return true;
122
131
  } catch (err) {
123
132
  return false;
@@ -432,6 +441,31 @@ function psQuote(value) {
432
441
  return "'" + String(value).replace(/'/g, "''") + "'";
433
442
  }
434
443
 
444
+ // One argument, quoted the way CommandLineToArgvW will unquote it. The rule
445
+ // that matters and that a naive version misses: a backslash is only an escape
446
+ // when it precedes a quote, so a run of backslashes before the closing quote
447
+ // has to be doubled or the quote is eaten - which is exactly what a config
448
+ // directory ending in a backslash did to the wake's command line.
449
+ function winArg(value) {
450
+ const text = String(value);
451
+ if (!/[\s"]/.test(text)) return text;
452
+ let out = '"';
453
+ let slashes = 0;
454
+ for (const ch of text) {
455
+ if (ch === '\\') {
456
+ slashes += 1;
457
+ continue;
458
+ }
459
+ if (ch === '"') {
460
+ out += '\\'.repeat(slashes * 2 + 1) + '"';
461
+ } else {
462
+ out += '\\'.repeat(slashes) + ch;
463
+ }
464
+ slashes = 0;
465
+ }
466
+ return out + '\\'.repeat(slashes * 2) + '"';
467
+ }
468
+
435
469
  function two(value) {
436
470
  return String(value).padStart(2, '0');
437
471
  }
@@ -442,12 +476,22 @@ function two(value) {
442
476
  // silently and forever. -StartWhenAvailable makes it fire on wake instead,
443
477
  // which is the whole difference between a relay and a coin toss. schtasks is
444
478
  // still the fallback for a box where the module is missing.
445
- function scheduleWindows(when, argv, name, cwd) {
479
+ // The two registrations below are bounded by the caller's deadline, not by a
480
+ // fixed figure: from inside a hook there may be a few seconds left, from the
481
+ // command line there are as many as it takes. Measured once at 38 seconds
482
+ // worst case when both spawns waited their full fixed timeouts, inside a hook
483
+ // that is killed at ten.
484
+ function remainingMs(deadline, ceiling) {
485
+ const left = Number.isFinite(deadline) ? deadline - Date.now() : ceiling;
486
+ return Math.max(0, Math.min(ceiling, left));
487
+ }
488
+
489
+ function scheduleWindows(when, argv, name, cwd, deadline) {
446
490
  const date = new Date(when);
447
491
  const stamp =
448
492
  date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) + ' ' +
449
493
  two(date.getHours()) + ':' + two(date.getMinutes()) + ':' + two(date.getSeconds());
450
- const argument = argv.map((value) => (/[\s"]/.test(value) ? '"' + value.replace(/"/g, '\\"') + '"' : value)).join(' ');
494
+ const argument = argv.map(winArg).join(' ');
451
495
  const script = [
452
496
  '$ErrorActionPreference = "Stop"',
453
497
  '$action = New-ScheduledTaskAction -Execute ' + psQuote(process.execPath) +
@@ -470,9 +514,11 @@ function scheduleWindows(when, argv, name, cwd) {
470
514
  const run = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file], {
471
515
  encoding: 'utf8',
472
516
  windowsHide: true,
473
- timeout: 8000,
517
+ timeout: Math.max(500, remainingMs(deadline, 8000)),
474
518
  });
475
519
  if (run.status === 0 && /registered/.test(run.stdout || '')) return { ok: true, how: 'ScheduledTasks' };
520
+ // No time left for a second attempt is a plain refusal, not a hung hook.
521
+ 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' };
476
522
  // schtasks cannot express StartWhenAvailable, so this path is a worse
477
523
  // guarantee and says so rather than pretending the two are the same.
478
524
  const fallback = spawnSync(
@@ -481,7 +527,7 @@ function scheduleWindows(when, argv, name, cwd) {
481
527
  '/ST', two(date.getHours()) + ':' + two(date.getMinutes()),
482
528
  '/SD', two(date.getMonth() + 1) + '/' + two(date.getDate()) + '/' + date.getFullYear(),
483
529
  '/IT', '/Z', '/F'],
484
- { encoding: 'utf8', windowsHide: true, timeout: 30000 }
530
+ { encoding: 'utf8', windowsHide: true, timeout: Math.max(500, remainingMs(deadline, 8000)) }
485
531
  );
486
532
  if (fallback.status === 0) return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake' };
487
533
  return { ok: false, error: (run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
@@ -524,8 +570,8 @@ function schedulePosix(when, argv, name, cwd) {
524
570
  }
525
571
  }
526
572
 
527
- function schedule(when, argv, name, cwd) {
528
- if (process.platform === 'win32') return scheduleWindows(when, argv, name, cwd);
573
+ function schedule(when, argv, name, cwd, deadline) {
574
+ if (process.platform === 'win32') return scheduleWindows(when, argv, name, cwd, deadline);
529
575
  return schedulePosix(when, argv, name, cwd);
530
576
  }
531
577
 
@@ -579,7 +625,7 @@ function arm(input) {
579
625
  // pointed at the same one or it reads somebody else's relay - which in
580
626
  // testing meant it read the real one and found nothing armed.
581
627
  if (process.env.CLAUDE_CONFIG_DIR) argv.push('--config-dir', process.env.CLAUDE_CONFIG_DIR);
582
- const scheduled = options.schedule === false ? { ok: true, how: 'none' } : schedule(when, argv, name, options.cwd);
628
+ const scheduled = options.schedule === false ? { ok: true, how: 'none' } : schedule(when, argv, name, options.cwd, options.deadline);
583
629
  if (!scheduled.ok) {
584
630
  note('arm failed for ' + id + ': ' + scheduled.error, now);
585
631
  return { ok: false, error: scheduled.error };
@@ -698,7 +744,10 @@ function applyAlwaysThinking(on) {
698
744
  try {
699
745
  const raw = fs.readFileSync(file, 'utf8');
700
746
  existed = true;
701
- parsed = JSON.parse(raw);
747
+ // Editors on Windows save JSON with a byte order mark often enough that
748
+ // refusing it would read as "your settings file is broken" to somebody
749
+ // whose settings file is fine.
750
+ parsed = JSON.parse(raw.replace(/^/, ''));
702
751
  fs.writeFileSync(file + '.bak-usage-limits', raw);
703
752
  } catch (err) {
704
753
  if (existed) return { ok: false, error: 'settings.json is not readable JSON; left untouched' };
@@ -715,6 +764,42 @@ function applyAlwaysThinking(on) {
715
764
  return { ok: true, file, backup: existed ? file + '.bak-usage-limits' : null };
716
765
  }
717
766
 
767
+ // `relay arm [--session <id>] [note text]`: take the current reading, book the
768
+ // wake against the binding window's reset, and store any text given as the
769
+ // continuation. Uses the report rather than the hook's cache, so it costs a
770
+ // scan; that is fine from a command line.
771
+ async function armByHand(rest) {
772
+ const usage = require('./usage.js');
773
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
774
+ const config = configure({ enabled: true });
775
+ const now = Date.now();
776
+ const data = await usage.report(now, {});
777
+ const binding = data && data.binding;
778
+ if (!binding || !Number.isFinite(binding.resetsAt)) return 'No window with a known reset time to arm against; run /usage and try again.';
779
+ const at = rest.indexOf('--session');
780
+ const sessionId = at !== -1 && rest[at + 1] ? rest[at + 1] : (data.session && data.session.id) || null;
781
+ if (!sessionId) return 'No session id: pass --session <id> (from usage.js --sessions).';
782
+ const text = rest.filter((item, i) => item !== '--session' && !(at !== -1 && i === at + 1)).join(' ').trim();
783
+ const result = arm({
784
+ now,
785
+ config,
786
+ sessionId,
787
+ binding,
788
+ resetsAt: binding.resetsAt,
789
+ cwd: process.cwd(),
790
+ project: path.basename(process.cwd()),
791
+ hostName: usage.currentHost(),
792
+ work: { hasWork: true, pending: 1, source: 'manual', todos: [], plan: text || null },
793
+ });
794
+ if (!result.ok) return 'Could not arm: ' + result.error;
795
+ if (text) saveContinuation(sessionId, text);
796
+ return (
797
+ 'Armed by hand for session ' + sessionId.slice(0, 8) + ': wake at ' +
798
+ new Date(result.record.wakeAt).toLocaleString() + ' via ' + result.record.how + '.' +
799
+ (text ? ' Continuation saved.' : ' No continuation yet - add one with: relay note "<text>"')
800
+ );
801
+ }
802
+
718
803
  /* ----------------------------------------------------------------- cli ---- */
719
804
 
720
805
  function main(argv) {
@@ -796,6 +881,10 @@ function main(argv) {
796
881
  const result = disarm('cancelled by hand', Date.now());
797
882
  return result.changed ? 'Relay cancelled and the scheduled wake removed.' : 'Nothing was armed.';
798
883
  }
884
+ // Arming by hand, for the cases the hook cannot see: Codex writes no plan
885
+ // tool into its rollouts, so nothing there ever reads as work to carry; and
886
+ // a person can have a project in their head that is in no todo list.
887
+ if (command === 'arm') return armByHand(rest);
799
888
  if (command === 'log') {
800
889
  try {
801
890
  return fs.readFileSync(logFile(), 'utf8').split('\n').slice(-20).join('\n');
@@ -804,19 +893,25 @@ function main(argv) {
804
893
  }
805
894
  }
806
895
  return [
807
- 'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|thinking off|resume|always|note TEXT|cancel|log]',
896
+ 'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|thinking off|resume|always|arm [--session ID] [TEXT]|note TEXT|cancel|log]',
808
897
  '',
809
898
  status(Date.now()),
810
899
  ].join('\n');
811
900
  }
812
901
 
813
902
  if (require.main === module) {
814
- try {
815
- process.stdout.write(main(process.argv.slice(2)) + '\n');
816
- } catch (err) {
817
- process.stdout.write('relay: ' + err.message + '\n');
818
- }
819
- process.exit(0);
903
+ Promise.resolve()
904
+ .then(() => main(process.argv.slice(2)))
905
+ .then(
906
+ (text) => {
907
+ process.stdout.write(text + '\n');
908
+ process.exit(0);
909
+ },
910
+ (err) => {
911
+ process.stdout.write('relay: ' + err.message + '\n');
912
+ process.exit(0);
913
+ }
914
+ );
820
915
  }
821
916
 
822
917
  module.exports = {
@@ -847,6 +942,9 @@ module.exports = {
847
942
  wakeAt,
848
943
  taskName,
849
944
  psQuote,
945
+ winArg,
946
+ remainingMs,
947
+ armByHand,
850
948
  schedule,
851
949
  scheduleWindows,
852
950
  schedulePosix,
@@ -144,13 +144,14 @@ function planOn(settings, state, options) {
144
144
  const opts = options || {};
145
145
  const launcher = opts.launcher || launcherFile();
146
146
  const existing = current.statusLine === undefined ? null : current.statusLine;
147
- // A second "on" must not record our own launcher as the thing to restore.
148
- const previous =
149
- state && Object.prototype.hasOwnProperty.call(state, 'previous')
150
- ? state.previous
151
- : isOurs(existing, launcher)
152
- ? null
153
- : existing;
147
+ // What "off" must put back is whatever is in the settings right now that is
148
+ // not ours. A second "on" must not record our own launcher as that thing -
149
+ // but it must not keep an old memory either: a status line the user set
150
+ // between two "on" runs was being overwritten and lost, because the first
151
+ // run's record was trusted over the file in front of us.
152
+ const remembered =
153
+ state && Object.prototype.hasOwnProperty.call(state, 'previous') ? state.previous : null;
154
+ const previous = isOurs(existing, launcher) ? remembered : existing;
154
155
  const command = 'node ' + JSON.stringify(launcher);
155
156
  const statusLine = { type: 'command', command };
156
157
  if (Number.isFinite(opts.refresh) && opts.refresh >= 1) statusLine.refreshInterval = Math.floor(opts.refresh);
@@ -1272,7 +1272,12 @@ function codexBlock(now) {
1272
1272
  // The warning that would have caught that incident: what the window holds at
1273
1273
  // the effort actually set, rather than at the average of everything ever run.
1274
1274
  function effortWarning(events, current, window) {
1275
- if (!current || !window || window.stale) return null;
1275
+ // Once spending has outrun the snapshot, percentLeft and usdPerPercent are
1276
+ // a floor and a stale price. buildWindow nulls the turn count for that
1277
+ // reason, and this function was quietly recomputing one from the two fields
1278
+ // it left behind - printing "at ultra effort this window holds about 104
1279
+ // turns" beside the warning that the window may already be exhausted.
1280
+ if (!current || !window || window.stale || window.correctionUnreliable) return null;
1276
1281
  const rates = effortRates(events);
1277
1282
  const here = rates.find((row) => row.effort === current);
1278
1283
  if (!here || here.turns < MIN_EFFORT_SAMPLE || !here.perTurn || here.perTurn <= 0) return null;
@@ -2080,7 +2085,9 @@ function criticalOthers(windows, bindingKey, threshold) {
2080
2085
  // do about it is less work, against a limit its work never touches.
2081
2086
  w.applies !== false &&
2082
2087
  w.percentUsed !== null &&
2083
- w.percentUsed >= limit
2088
+ // A window whose spend has outrun its snapshot reads as its old floor;
2089
+ // it belongs in this list whatever that floor says.
2090
+ (w.percentUsed >= limit || w.correctionUnreliable)
2084
2091
  );
2085
2092
  }
2086
2093
 
@@ -2107,6 +2114,11 @@ function bindingWindow(windows) {
2107
2114
  // ranked by how full it is instead, because a nearly full window must never
2108
2115
  // be passed over merely because nothing has been spent in it lately.
2109
2116
  const soonest = (w) => {
2117
+ // Spend that has outrun the snapshot leaves percentUsed at the old floor
2118
+ // and headroomMs empty, so on those two numbers alone the fullest window
2119
+ // on the account ranked as the least urgent one. It is the most urgent:
2120
+ // the budget it describes may already be gone.
2121
+ if (w.correctionUnreliable) return 0;
2110
2122
  if (Number.isFinite(w.headroomMs)) return w.headroomMs;
2111
2123
  return w.percentUsed >= 90 ? 0 : Infinity;
2112
2124
  };
@@ -89,8 +89,12 @@ async function windowReopened(record, now) {
89
89
  const codex = require('./codex.js');
90
90
  await codex.refresh({ now, timeoutMs: 8000 }).catch(() => null);
91
91
  const collected = codex.collect(now);
92
- const percent = collected && collected.utilization ? Number(collected.utilization.primary) : null;
93
- return { known: Number.isFinite(percent), percent };
92
+ // Same shape as the Claude side: keyed by window, each { utilization }.
93
+ // Reading a flat "primary" that codex.collect never writes answered
94
+ // "cannot tell" every time and disabled this check under Codex.
95
+ const bucket = collected && collected.utilization ? collected.utilization[record.windowKey || 'five_hour'] : null;
96
+ const value = bucket && typeof bucket.utilization === 'number' ? bucket.utilization : null;
97
+ return { known: value !== null, percent: value };
94
98
  }
95
99
  const live = require('./live.js');
96
100
  const usage = require('./usage.js');