Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "usage-limits",
"displayName": "Usage Limits",
"version": "1.43.0",
"version": "1.43.2",
"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.",
"author": {
"name": "Ridelink",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "usage-limits",
"version": "1.43.0",
"version": "1.43.2",
"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.",
"author": {
"name": "Ridelink",
Expand Down
4 changes: 3 additions & 1 deletion commands/defer.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ will be:
the relay's `permission` mode and `model`. It needs no session id. On Windows
the hand-off goes inline only through the native `claude.exe`; with only the
npm `claude.cmd` shim, the first prompt points at the hand-off file instead,
because cmd.exe cuts an argument at its first line break.
because cmd.exe cuts an argument at its first line break. It does the same
anywhere when the hand-off is too long for one argument (30,000 characters on
Windows, 120,000 bytes elsewhere).
- `relay show off` - either kind runs headless (`claude -p`, the prompt on
stdin) with no window and no Remote Control; its output is kept in
`relay log --run`.
Expand Down
4 changes: 3 additions & 1 deletion commands/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ The rest:
and model - for a session that has grown long, so the next window starts
clean instead of carrying all of it. The old conversation is left as it was.
On Windows the inline hand-off needs the native `claude.exe`; with only the
npm `claude.cmd` shim the first prompt points at the file instead. Both
npm `claude.cmd` shim the first prompt points at the file instead, and so it
does anywhere for a hand-off too long for one argument (30,000 characters on
Windows, 120,000 bytes elsewhere). Both
modes keep one terminal: if the old session is open and working again at the
wake, the wake stands down; if it is open but quiet for three minutes, the
wake opens its one window anyway. Under `mode notify` neither applies, and
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-usage-limits",
"version": "1.43.0",
"version": "1.43.2",
"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.",
"keywords": [
"claude",
Expand Down
13 changes: 9 additions & 4 deletions skills/usage-limits/scripts/relay.js
Original file line number Diff line number Diff line change
Expand Up @@ -1836,12 +1836,17 @@ function cloudCli(env, platform) {
}

// The claude that `claude` typed into a Windows shell runs: the first match on
// PATH, which is what `where` lists first. Only asked on Windows.
function shellClaude(env) {
// PATH that has an extension. `where claude` also lists the extensionless sh
// script npm writes beside claude.cmd, and lists it first, but neither shell
// runs a file with no extension - so taking the first line named the sh
// script, and the shim warning never fired for the npm install it is about.
// Only asked on Windows. `run` stands in for spawnSync in the tests.
function shellClaude(env, run) {
const environment = env || process.env;
if (environment.USAGE_LIMITS_CLAUDE_CLI) return firstExisting([environment.USAGE_LIMITS_CLAUDE_CLI]);
const where = spawnSync('where', ['claude'], { encoding: 'utf8', timeout: 10000, windowsHide: true });
const first = where.status === 0 ? String(where.stdout || '').split(/\r?\n/)[0].trim() : '';
const where = (run || spawnSync)('where', ['claude'], { encoding: 'utf8', timeout: 10000, windowsHide: true });
const lines = where && where.status === 0 ? String(where.stdout || '').split(/\r?\n/).map((line) => line.trim()) : [];
const first = lines.find((line) => line && path.win32.extname(line)) || '';
return first || findClaude(environment);
}

Expand Down
3 changes: 3 additions & 0 deletions skills/usage-limits/scripts/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ const RATES = {
'claude-opus-4-8': { input: 5, output: 25 },
'claude-opus-4-7': { input: 5, output: 25 },
'claude-opus-4-6': { input: 5, output: 25 },
// Sonnet 5.5 keeps Sonnet 5's $2/$10, and its $0.20 reads are the tenth rule
// (claude-api model table, cached 2026-09-25).
'claude-sonnet-5-5': { input: 2, output: 10 },
'claude-sonnet-5': { input: 2, output: 10 },
'claude-sonnet-4-6': { input: 3, output: 15 },
'claude-haiku-4-5': { input: 1, output: 5 },
Expand Down
17 changes: 12 additions & 5 deletions skills/usage-limits/scripts/wake.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,14 @@ function freshFor(config) {
}

// One argument has to hold the hand-off. Linux refuses a single argument past
// 128 KiB; Windows caps a whole command line at 32,767 characters, and there
// each double quote and backslash can cost one more when it is quoted.
// 128 KiB - bytes, not characters, so an accented letter or a dash costs two
// or three (70,000 two-byte characters measured E2BIG here); Windows caps a
// whole command line at 32,767 UTF-16 characters, and there each double quote
// and backslash can cost one more when it is quoted.
const FRESH_ARG_MAX = { win32: 30000, posix: 120000 };

function argCost(text, platform) {
return platform === 'win32' ? text.length + (text.match(/["\\]/g) || []).length : text.length;
return platform === 'win32' ? text.length + (text.match(/["\\]/g) || []).length : Buffer.byteLength(text, 'utf8');
}

function isShim(cli) {
Expand Down Expand Up @@ -291,7 +293,8 @@ function freshLaunch(record, config, cli, prompt, promptFile, platform) {
if (platform === 'win32' && isShim(cli)) {
why = cli + ' is a batch shim and cmd.exe ends a command at the first line break';
} else if (argCost(text, platform) > limit) {
why = 'the hand-off is ' + text.length + ' characters, more than one argument holds here (' + limit + ')';
why = 'the hand-off is ' + argCost(text, platform) + (platform === 'win32' ? ' characters' : ' bytes') +
', more than one argument holds here (' + limit + ')';
}
return { cli, args: freshArgs(record, config, why ? freshPointerPrompt(promptFile) : text), inline: !why, why };
}
Expand Down Expand Up @@ -550,7 +553,11 @@ function shQuote(text) {
function launcherScriptPosix(record, config, cli, promptFile, exitPath, args) {
return [
'#!/bin/sh',
'cd ' + shQuote(record.cwd) + ' || exit 1',
// The folder the start-up questions were answered for, as on Windows: for
// a project in the home folder that is the trusted launch folder, with
// --add-dir back to the project. Started in the home folder itself, the
// window sat at the trust question the preflight never answered.
'cd ' + shQuote(record.launchCwd || record.cwd) + ' || exit 1',
'unset CLAUDE_CODE_CHILD_SESSION CLAUDE_CODE_SESSION_ID',
shQuote(cli) + ' ' + (args || visibleArgs(record, config, promptFile)).map(shQuote).join(' '),
'code=$?',
Expand Down
11 changes: 11 additions & 0 deletions test/model-version.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ test('model ids come apart into family and version; a bare alias has no version'
assert.strictEqual(usage.parseModelId(null), null);
});

test('Sonnet 5.5 is priced from the table, not the family average', () => {
assert.strictEqual(usage.isKnownModel('claude-sonnet-5-5'), true);
assert.deepStrictEqual(usage.rateFor('claude-sonnet-5-5'), { input: 2, output: 10 });
assert.deepStrictEqual(usage.rateFor('claude-sonnet-5-5[1m]'), usage.rateFor('claude-sonnet-5-5'));
assert.strictEqual(usage.costOf({ input_tokens: 1e6, output_tokens: 1e6 }, 'claude-sonnet-5-5'), 12);
assert.ok(Math.abs(usage.costOf({ cache_read_input_tokens: 1e6 }, 'claude-sonnet-5-5') - 0.2) < 1e-12);
assert.deepStrictEqual(usage.parseModelId('claude-sonnet-5-5'), { family: 'sonnet', version: [5, 5] });
});

test('the newer sibling is same family, newer, no dearer on any rate, and cheaper on one', () => {
const opus5 = usage.newerSibling('claude-opus-5');
assert.ok(opus5);
Expand All @@ -77,6 +86,8 @@ test('the newer sibling is same family, newer, no dearer on any rate, and cheape
// so Sonnet 4.6 is not told Sonnet 5 is cheaper and Opus 4.6 is not pointed
// at 5.5.
assert.strictEqual(usage.newerSibling('claude-sonnet-4-6'), null);
// Sonnet 5.5 prices every rate the same as Sonnet 5, so it is not cheaper.
assert.strictEqual(usage.newerSibling('claude-sonnet-5'), null);
assert.strictEqual(usage.newerSibling('claude-opus-4-6'), null);
// A bare alias names no release, and a model with no row of its own is not
// compared against a guess.
Expand Down
17 changes: 17 additions & 0 deletions test/relay-cloud.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,20 @@ test('a Windows command meant for a batch shim warns that cmd.exe cuts the hand-
assert.doesNotMatch(r.cloud(['--session', 'sess-9', 'x'], { git: cleanGit, platform: 'linux', cwd: '/repo', account: {}, cli: '/x/claude.cmd' }), /batch shim/);
assert.deepStrictEqual(r.cloudWarnings(r.gitState('/x', cleanGit), 'short', 'win32', 'C:\\bin\\claude.exe'), []);
}));

// `where claude` on an npm install lists the extensionless sh script before
// claude.cmd. Neither shell runs a file with no extension, and taking the
// first line meant the shim warning above never fired for the install it is
// about.
test('the claude a Windows shell runs is the first match with an extension, not the sh script npm writes beside the shim', () => {
const r = relay();
const calls = [];
const where = (cmd, args) => {
calls.push([cmd].concat(args));
return { status: 0, stdout: 'C:\\Users\\me\\AppData\\Roaming\\npm\\claude\r\nC:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd\r\nC:\\Users\\me\\.local\\bin\\claude.exe\r\n' };
};
assert.strictEqual(r.shellClaude({}, where), 'C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd');
assert.deepStrictEqual(calls, [['where', 'claude']]);
const native = () => ({ status: 0, stdout: 'C:\\Users\\me\\.local\\bin\\claude.exe\r\nC:\\Users\\me\\AppData\\Roaming\\npm\\claude\r\n' });
assert.strictEqual(r.shellClaude({}, native), 'C:\\Users\\me\\.local\\bin\\claude.exe');
});
33 changes: 33 additions & 0 deletions test/relay-fresh.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,36 @@ test('defer marks its own record, not whichever relay was armed first', () =>
assert.strictEqual(relay.armedFor(held, 'sess-defer-own').deferred, true);
assert.strictEqual(relay.armedFor(held, 'someone-else').deferred, undefined, 'the other relay is left as it was');
}));

// --- found in review ----------------------------------------------------------

// Linux's limit on one argument is 128 KiB of BYTES. Counting characters let
// 70,000 two-byte characters (140,000 bytes) through as inline, and the exec
// then failed E2BIG in the window, the same way on every retry.
test('the POSIX inline limit counts bytes, so a hand-off heavy in non-ASCII text goes by file instead of failing E2BIG', () => {
const record = { id: ID, cwd: '/work/proj', project: 'proj' };
const wide = '\u00e9'.repeat(70000);
const plan = wake.freshLaunch(record, {}, '/bin/claude', wide, '/cfg/p.md', 'linux');
assert.strictEqual(plan.inline, false);
assert.match(plan.why, /140000 bytes, more than one argument holds here \(120000\)/);
assert.ok(plan.args[plan.args.length - 1].includes('/cfg/p.md'));
assert.strictEqual(wake.freshLaunch(record, {}, '/bin/claude', 'x'.repeat(70000), '/cfg/p.md', 'linux').inline, true);
});

// A project in the home folder is launched from the trusted folder the
// preflight answered for, with --add-dir back to it, as on Windows - and as
// the new session's own opening says. The POSIX launcher cd'd into the home
// folder itself, where the trust question waits for nobody.
test('the POSIX window starts a home-folder project from its trusted launch folder, with --add-dir back to it', () =>
isolated((dir) => {
const launch = path.join(dir, 'relay-cwd');
const record = { id: ID, cwd: '/home/me', launchCwd: launch, project: 'me' };
const opened = [];
const result = wake.deliverClaude(record, PROMPT, { show: true, fresh: true }, '/usr/local/bin/claude', [], clockIo({ platform: 'linux', open: (launcher) => opened.push(launcher) }));
assert.strictEqual(result.ok, true);
const script = fs.readFileSync(opened[0], 'utf8');
assert.ok(script.includes('cd ' + wake.shQuote(launch) + ' || exit 1'), script);
assert.ok(script.includes("'--add-dir' '/home/me'"), script);
const resume = wake.launcherScriptPosix(record, {}, '/usr/local/bin/claude', '/cfg/p.md', '/cfg/p.exit');
assert.ok(resume.includes('cd ' + wake.shQuote(launch) + ' || exit 1'), 'the resume launches from the same folder');
}));
6 changes: 5 additions & 1 deletion test/usage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ test('cache reads use the per-model rate when one is stated', () => {
});

test('rateFor falls back to the family when the id is unknown', () => {
assert.deepStrictEqual(usage.rateFor('claude-sonnet-9-9'), { input: 2.5, output: 12.5 });
// The Sonnet average takes in Sonnet 5.5 and 5 at $2/$10 and 4.6 at $3/$15,
// so (2 + 2 + 3) / 3 in and (10 + 10 + 15) / 3 out.
const sonnet = usage.rateFor('claude-sonnet-9-9');
near(sonnet.input, 7 / 3, 1e-9);
near(sonnet.output, 35 / 3, 1e-9);
// The Opus average takes in every Opus row: four at $5/$25 and Opus 5.5 at
// $4/$20, so (4 x 5 + 4) / 5 = 4.8 in and (4 x 25 + 20) / 5 = 24 out.
assert.deepStrictEqual(usage.rateFor('CLAUDE-OPUS-9'), { input: 4.8, output: 24 });
Expand Down
2 changes: 1 addition & 1 deletion vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "claude-usage-limits",
"displayName": "Claude Usage Limits",
"description": "Your Claude Code session, weekly and per-model limits as live bars in a view beside the Claude Code chat, drawn in Claude's own colours from Claude's own numbers.",
"version": "1.43.0",
"version": "1.43.2",
"publisher": "ridelink",
"license": "MIT",
"icon": "media/icon.png",
Expand Down
Loading