diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index cd5446b..e2bdd2c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 589f643..dfbdb4f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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", diff --git a/commands/defer.md b/commands/defer.md index ec2ec12..0ad6c82 100644 --- a/commands/defer.md +++ b/commands/defer.md @@ -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`. diff --git a/commands/relay.md b/commands/relay.md index 86641f7..da6f196 100644 --- a/commands/relay.md +++ b/commands/relay.md @@ -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 diff --git a/package.json b/package.json index 5ccbec9..a915d17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/usage-limits/scripts/relay.js b/skills/usage-limits/scripts/relay.js index fca84ea..a2c7ab0 100644 --- a/skills/usage-limits/scripts/relay.js +++ b/skills/usage-limits/scripts/relay.js @@ -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); } diff --git a/skills/usage-limits/scripts/usage.js b/skills/usage-limits/scripts/usage.js index 27a071c..c03082a 100644 --- a/skills/usage-limits/scripts/usage.js +++ b/skills/usage-limits/scripts/usage.js @@ -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 }, diff --git a/skills/usage-limits/scripts/wake.js b/skills/usage-limits/scripts/wake.js index e265ef6..584e2e3 100644 --- a/skills/usage-limits/scripts/wake.js +++ b/skills/usage-limits/scripts/wake.js @@ -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) { @@ -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 }; } @@ -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=$?', diff --git a/test/model-version.test.js b/test/model-version.test.js index fc03186..54ec787 100644 --- a/test/model-version.test.js +++ b/test/model-version.test.js @@ -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); @@ -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. diff --git a/test/relay-cloud.test.js b/test/relay-cloud.test.js index 56a476a..58f517b 100644 --- a/test/relay-cloud.test.js +++ b/test/relay-cloud.test.js @@ -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'); +}); diff --git a/test/relay-fresh.test.js b/test/relay-fresh.test.js index 6b770ef..e6be46a 100644 --- a/test/relay-fresh.test.js +++ b/test/relay-fresh.test.js @@ -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'); + })); diff --git a/test/usage.test.js b/test/usage.test.js index 391b952..5158a97 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -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 }); diff --git a/vscode/package.json b/vscode/package.json index b5dcc6c..731f121 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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",