Live subscription usage and pace bars, with an optional provider-neutral delegation advisor.

LimitPace puts subscription usage beside its pace line above the Claude Code prompt. See whether a five-hour or weekly allowance is ahead of schedule, compare Claude accounts and other providers, and open an account switcher with /limitpace. An optional advisor gives Claude provider-neutral delegation guidance.
Requires Claude Code 2.1.287 or later, the first version with mods. No dependencies, no build step, no telemetry.
The images below are captures of LimitPace running in Claude Code at 120 columns with fixed demo data.



The repository is its own plugin marketplace:
/plugin marketplace add fstandhartinger/limitpace
/plugin install limitpace@limitpace
/reload-plugins
Restart Claude Code if it does not appear after reloading. For one development session, use claude --plugin-dir /path/to/limitpace.
Green means usage is under its pace line; yellow means over pace; red means at least 90% used. The cyan ┃ marks elapsed time through that window. Pace is (now − window start) / window length, clamped to 0–100%; headroom is pace minus usage, in percentage points. Missing resets mean unknown pace, never an assumed zero. Session windows default to five hours and weekly windows to seven days; Codex durations and configured weekly durations override those defaults.
With one Claude account and no other providers, auto draws separate Session and Week bars. Multiple accounts or extra providers select the compact line. Combined Claude weekly usage and pace are weighted means of measured profiles; each profile defaults to weight 1. * marks this session's account. At narrow widths, the compact line drops session percentages, then pace detail, then trailing providers; the pane always lists every provider. Errors show briefly in the band and in full in the pane. Reset days and times use the host's local timezone.
/limitpace opens the pane; its account buttons have hotkeys 1–9. Switching applies to new sessions. Without a configured switch command, a button copies a CLAUDE_CONFIG_DIR=... claude launch command. Refresh and Close have hotkeys r and c.
/limitpace refresh forces a new usage check and returns a plain summary. /limitpace text returns the summary without opening anything. The command also returns text on surfaces with no mod drawing, such as the VS Code chat panel. Completely non-interactive sessions with the advisor off remain inert, including not registering the command; opt in to an advisor mode when using the command in claude -p or the SDK.
Four fields appear in the plugin's configuration:
| Field | Default | Values |
|---|---|---|
layout | auto | auto, detailed, compact, off |
config_file | ~/.config/limitpace/config.json | Optional JSON file, or demo:single / demo:multi |
advisor | off | off, prompt, file, both |
refresh_minutes | 5 | 1–60 |
LimitPace validates these values at startup: an unrecognized layout becomes auto, an unrecognized advisor becomes off, and refresh_minutes is converted to a number, defaults to 5 if that conversion produces NaN, and is clamped to 1–60.
For an installed plugin, values live under pluginConfigs["limitpace@limitpace"].options in Claude Code settings. For --plugin-dir, use limitpace@inline. For example:
{
"pluginConfigs": {
"limitpace@inline": {
"options": {
"layout": "auto",
"config_file": "~/.config/limitpace/config.json",
"advisor": "off",
"refresh_minutes": 5
}
}
}
}
The config file is optional and every key is optional. With no file, LimitPace measures exactly the current Claude account and uses the detailed band. Invalid JSON gives a dim explanation and falls back to that account. Changes to the config file take effect after /reload-plugins or a restart.
Use the minimal example for a single account, or the multi-account example for two Claude accounts, Codex and a local Devin quota sample. That example has no account-switch command, because each account there lives in its own CLAUDE_CONFIG_DIR. A general example is:
{
"claudeProfiles": [
{"label": "A", "configDir": "~/.claude-A", "weight": 1},
{"label": "B", "configDir": "~/.claude", "weight": 1}
],
"switchCommand": ["claude-account", "switch", "{label}"],
"providers": [
{"id": "codex", "label": "ChatGPT/Codex", "type": "codex", "authFile": "~/.codex/auth.json", "delegate": "codex exec"},
{"id": "devin", "label": "Devin", "type": "json", "path": "~/.local/state/agent-limits/devin-latest.json",
"map": {"weekPercent": "weekly_percent", "weekResetsAt": "weekly_reset_at"}, "delegate": "devin"},
{"id": "other", "label": "Other", "type": "command", "argv": ["my-usage", "--json"],
"map": {"weekPercent": "week.used", "weekResetsAt": "week.reset"}}
],
"advisor": {"target": "auto", "minChangePoints": 5, "createIfMissing": false}
}
configDir, authFile, and path support ~. File paths otherwise follow Claude Code's working-directory rules. The current Claude profile is the one whose config directory equals CLAUDE_CONFIG_DIR, or ~/.claude when that variable is unset. Give each provider a unique id and each Claude profile its own directory.
A json provider reads a local file. A command provider runs only its configured argv and parses stdout as JSON. map maps these five keys to dotted JSON paths:
| Key | Meaning |
|---|---|
sessionPercent | Session percentage, 0–100 |
sessionResetsAt | Session reset time |
weekPercent | Weekly percentage, 0–100 |
weekResetsAt | Weekly reset time |
weekWindowDays | Weekly window length, default 7 days |
Without a map, the JSON keys above are used directly. Reset values may be ISO timestamps, epoch seconds or epoch milliseconds. Missing percentages stay unknown. A successful external reading is cached under its own source-specific store key. Other sessions re-read that key before fetching. This reduces duplicate checks but is not an atomic cross-session lock; simultaneous first fetches can still race. HTTP 429 retains the last successful reading, shows an error, and waits until the next refresh interval before retrying. Manual Refresh deliberately bypasses cache freshness.
Some setups keep one config directory and swap logins in and out of it, storing each account's login in its own file (for example ~/.claude/accounts/<label>.json, in the same claudeAiOauth shape as .credentials.json). Give those profiles a credentialsFile (example). LimitPace reads the inactive accounts from their own files, and marks as current the profile whose stored login matches the live one (compared in memory, never stored). Pair it with a switchCommand so the pane's buttons can swap accounts.
On macOS, additional Claude accounts may use Keychain instead of .credentials.json. Configure an optional tokenCommand on that profile:
{
"claudeProfiles": [
{"label": "Work", "configDir": "~/.claude-work", "tokenCommand": ["my-keychain-reader", "--access-token"]}
]
}
The command must print only an existing access token. LimitPace never refreshes it. Command failures and HTTP failures are reported with generic hints rather than displaying stdout, stderr or thrown messages.
Set config_file to demo:single or demo:multi for fixed sample data with zero file reads, network calls or configured processes. Alternatively, a JSON config can contain {"demo":"single"} or {"demo":"multi"}. Loading that file requires one config read; after it is loaded, demo collection reads no credentials or provider files and makes no network requests. Account switching and advisor file writes are disabled in demos.
The advisor is off by default. It does not select a model, change Claude's routing or launch work. It compares provider/plan percentages to their pace lines and advises delegation to the eligible provider with the most weekly headroom. It excludes providers with errors, those at least 90% through their session window or 95% through their week, and providers without measured weekly pace. If nobody is under pace, it says so and recommends keeping delegation small.
Advice deliberately contains no model ids. The strongest available model can lead and exercise judgement; delegation depends on plan headroom rather than a hard-coded model family. Configured delegate strings explain how to use a provider. Claude subagents always use this session's Claude account.
prompt: adds hidden prompt context on the first prompt, then only if the recommendation changes or any session/weekly headroom moves by at least minChangePoints (default 5). It preserves other context and registers mcp__limitpace__usage for fresh advice.file: maintains only the block between <!-- limitpace:start --> and <!-- limitpace:end -->. It chooses the project's AGENTS.md if present, otherwise CLAUDE.md. An explicit advisor.target can choose another path; relative targets are under the project root. No new file is created unless createIfMissing is true. Outside text remains byte-identical. Malformed or duplicate markers are left untouched. Rewrites require a significant recommendation/headroom change and at least 30 minutes since the last write. Timestamps are minute-rounded and change only on a rewrite, limiting prompt-cache churn.both: enables both behaviors.The advice is a scheduling hint, not a quota guarantee. Unknown or stale data should be refreshed before important delegation decisions.
Network: only https://api.anthropic.com/api/oauth/usage for configured Claude profiles and https://chatgpt.com/backend-api/wham/usage for configured Codex providers. It sends an access token to that provider's usage endpoint, plus the required headers; Codex's account id is sent only to its own endpoint when present. No telemetry and no other destination is configured by LimitPace.
Reads: its optional JSON config, configured Claude profiles' .credentials.json or explicit credentialsFile, the configured Codex auth file, and configured JSON provider files. Profile-swap detection compares stored and live login values in memory and may read the account UUID from the current profile's .claude.json (or ~/.claude.json for the default profile). Advisor file mode also reads its chosen project instruction file. It reads HOME and CLAUDE_CONFIG_DIR only to resolve paths and identify the current profile. Current Claude limits come from the free $.session.usage() reading when available; before a response has populated them, the configured account is measured through its usage endpoint.
Runs: LimitPace can run commands, but only argv you configure: command providers, tokenCommand, and account switchCommand when you press a button. Nothing runs by default. Each argv is passed directly to $.process.run with a five-second timeout; LimitPace does not invoke a shell. A configured executable can itself perform additional actions, so choose helpers you trust. Sample paths and commands are examples, not hidden dependencies.
Writes: usage readings and advisor file snapshots/write times in $.store, reactive usage views and prompt advisor snapshots in $.state, and the selected AGENTS.md/CLAUDE.md or explicit target only in opt-in advisor file mode. That file update preserves text outside <!-- limitpace:start --> and <!-- limitpace:end -->. A switch button without a command writes a launch command to the clipboard. LimitPace never writes credentials or refreshes tokens. It reads credentials locally from the user's own files (or configured tokenCommand); an access token is sent only to that provider's own usage endpoint. Tokens and account ids are never stored in readings, logged, drawn or returned. Exceptions and command output are not echoed into errors.
The usage cache contains provider metadata (id, label, type, current-account flag, weight and delegation hint), parsed usage windows, timestamps, source and generic error hints. Host calls pass through Claude Code's mod middleware, so an earlier mod may inspect or refuse those calls; only use plugins you trust.
In the table, usage refresh means reading current limits through $.session.usage(), then measuring configured providers as needed. The only HTTP requests are Claude usage and Codex usage, with the credential handling described above. A refresh may run configured tokenCommand and command provider argv, reads configured local sources, updates $.store and $.state, and may maintain the opt-in advisor file block. Cached external readings are reused until the refresh interval expires unless refresh is forced. Demo collection uses sample readings instead.
| Hook / event or callback | What it does | What it fetches | What it runs | What it writes |
|---|---|---|---|---|
session.start | Starts only for an interactive session or an enabled advisor; refreshes usage, schedules the timer, registers /limitpace, and registers the advice tool for prompt/both. | Usage refresh: Claude/Codex endpoints above, as needed. | Configured tokenCommand and command provider argv during refresh; nothing by default. | Refresh stores/state and optional advisor file block. |
classic.SessionStart with clear, resume, fork | Refreshes after the event when enabled. | Usage refresh: Claude/Codex endpoints above, as needed. | Configured refresh argv only. | Refresh stores/state and optional advisor file block. |
session.measure | Refreshes after new session measurements when enabled. | Usage refresh: Claude/Codex endpoints above, as needed. | Configured refresh argv only. | Refresh stores/state and optional advisor file block. |
turn.complete | Refreshes after a main-session turn when enabled; skips subagent turns. | Usage refresh: Claude/Codex endpoints above, as needed. | Configured refresh argv only. | Refresh stores/state and optional advisor file block. |
$.clock.every callback | Refreshes at the configured 1–60 minute interval; this is a timer callback, not a separate hook. | Usage refresh: Claude/Codex endpoints above, as needed. | Configured refresh argv only. | Refresh stores/state and optional advisor file block. |
prompt.submit | In opt-in prompt/both mode, adds advice as prompt context on a significant change and preserves existing context. | None; reads the existing usage view. | None. | Prompt snapshot in $.state if the prompt is not dropped. |
tool.call for mcp__limitpace__usage | In opt-in prompt/both mode, refreshes and answers with advice text. | Usage refresh: Claude/Codex endpoints above, as needed; follows cache freshness. | Configured refresh argv only. | Refresh stores/state; optional advisor file block in both mode. |
command.run for /limitpace | refresh forces usage refresh; text returns a summary; no argument opens the pane on supported surfaces or returns text. Refreshes if config has not yet loaded. | Usage refresh when forced or config is absent; otherwise none. | Configured refresh argv only when refreshing. | Refresh stores/state and optional advisor file block when refreshing; otherwise none. |
ui.render for AbovePrompt | Draws the band from the usage view unless layout is off, a survey is present, or the session is disabled. | None. | None. | None. |
ui.render for the LimitPace Pane | Draws usage and buttons from the current view. | None while rendering. | None while rendering. | None while rendering. |
| Pane account button callback | Switches for new sessions or copies a launch command; disabled in demos. | None in LimitPace. | Only configured switchCommand argv, replacing {label} with the selected profile label; nothing by default. | Clipboard when no switch command is configured; a configured helper controls its own effects. |
| Pane Refresh / Close callbacks | Refresh forces a usage check; Close closes the pane. | Refresh: Claude/Codex endpoints above, as needed; Close: none. | Refresh: configured refresh argv only; Close: none. | Refresh stores/state and optional advisor file block; Close: none. |
There is no permission-related hook: LimitPace does not answer permission requests or change permission settings. Its classic.SessionStart, session.* and turn.complete handlers call next(e) first and return its result unchanged; command.run handles only LimitPace's own /limitpace command. See also PRIVACY.md. The tool.call handler answers only its own opt-in usage advice tool.
The band and pane are designed for the terminal and Code tab of Claude Desktop. The VS Code chat panel, SDK, cloud sessions and claude -p run hooks but do not draw mod UI; headless advisor-off sessions perform no background work. Claude Desktop WSL sessions currently do not load plugins. Surveys retain the band, and LimitPace includes other mods' band content.
The usage endpoint is the one Claude Code's /usage uses and may change. OAuth tokens that are missing or expired require opening a session on that profile; LimitPace does not renew credentials. The Codex usage endpoint may also change. Keychain profiles need their own read-only token helper. There is no built-in Devin credential reader: provide a local quota JSON sample or an explicit command.
Relative imports, state declarations, startup redraw and the pane have been verified on Claude Code 2.1.287. Generated build-specific types were inspected after loading. Account switching was tested with simulated commands; no actual account switch was executed. Desktop rendering has not been checked.
claude plugin validate --strict .
claude plugin validate --strict .claude-plugin/plugin.json
claude plugin test
node scripts/test-local.mjs
The first command checks the marketplace when both manifests exist; the second explicitly checks the plugin, imports, hooks and state contract. hooks/hooks.json has both modules and an empty top-level hooks object to satisfy the directory checklist; that shape passes local strict validation.
The official runner runs tests/*.test.ts, including actual host drawing tests. scripts/test-local.mjs runs the pure logic and deterministic host simulation tests with Node, independently of Claude Code's rollout. It does not certify Claude Code UI elements or hook dispatch. Preview regeneration uses node scripts/render-previews.mjs, then python3 scripts/ansi-to-png.py screenshots/*.ansi (Pillow and DejaVu Sans Mono required). Real captures and data belong in the gitignored screenshots/private/ directory.
If the official runner reports that hooks modules are turned off by the rollout switch, installed mods cannot load in that process. Check again after the rollout changes; do not override the switch.
hooks/limitpace.mjs 316 lines1import { parseClaude, parseCodex, parseMapped, parseSession, demoReadings, advice, snapshot, changed, summary, upsertBlock, cleanLabel, resetText } from './core.mjs';
2import { band, barRow } from './drawing.mjs';
3
4const VERSION = '0.1.1';
5const VIEW = { plugin: 'limitpace', key: 'view' };
6const INJECTED = { plugin: 'limitpace', key: 'injected' };
7let config = null;
8let options = {};
9let enabled = false;
10let refreshing = false;
11
12function expand(path, home) {
13 return String(path).replace(/^~(?=[/\\]|$)/, home).replace(/\\/g, '/').replace(/\/$/, '');
14}
15function hash(value) {
16 let n = 2166136261;
17 for (const c of value) n = Math.imul(n ^ c.charCodeAt(0), 16777619);
18 return (n >>> 0).toString(16);
19}
20function argv(value) {
21 return Array.isArray(value) && value.length > 0 && value.every(a => typeof a === 'string' && !a.includes('\0')) ? value : undefined;
22}
23async function loadConfig($) {
24 if (config) return config;
25 // These shortcuts deliberately need no host reads, including env and config.
26 if (/^demo:(single|multi)$/.test(options.config_file)) {
27 config = { demo: options.config_file.slice(5), profiles: [], providers: [], advisor: {} };
28 return config;
29 }
30 const home = (await $.env.get('HOME')) ?? '';
31 const currentDir = expand((await $.env.get('CLAUDE_CONFIG_DIR')) || '~/.claude', home);
32 const path = expand(options.config_file, home);
33 let data = {}, configError;
34 try {
35 if (path && await $.fs.exists(path)) data = JSON.parse(await $.fs.read(path));
36 if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error();
37 } catch { data = {}; configError = 'Config unreadable or invalid JSON; using this Claude session.'; }
38 const inputProfiles = Array.isArray(data.claudeProfiles) ? data.claudeProfiles : [{ label: 'Claude', configDir: currentDir }];
39 const profiles = inputProfiles.filter(p => p && typeof p.configDir === 'string').map((p, i) => {
40 const configDir = expand(p.configDir, home);
41 const credentialsFile = typeof p.credentialsFile === 'string' ? expand(p.credentialsFile, home) : undefined;
42 return {
43 id: 'claude:' + hash(configDir + (credentialsFile ? '|' + credentialsFile : '')), label: cleanLabel(p.label ?? `Claude ${i + 1}`), type: 'claude',
44 configDir, credentialsFile, current: !credentialsFile && configDir === currentDir,
45 weight: Number(p.weight) > 0 ? Number(p.weight) : 1, tokenCommand: argv(p.tokenCommand),
46 };
47 });
48 // Profile-swap setups keep every account's login in its own file and swap one into the shared config dir.
49 // The active one is the profile whose stored login matches the live one (compared in memory, never kept).
50 if (!profiles.some(p => p.current)) {
51 for (const p of profiles.filter(p => p.credentialsFile && p.configDir === currentDir)) {
52 try {
53 const live = JSON.parse(await $.fs.read(p.configDir + '/.credentials.json')).claudeAiOauth ?? {};
54 const stored = JSON.parse(await $.fs.read(p.credentialsFile));
55 const o = stored.claudeAiOauth ?? {};
56 let same = (o.refreshToken && o.refreshToken === live.refreshToken) || (o.accessToken && o.accessToken === live.accessToken);
57 if (!same && stored.accountUuid) {
58 const meta = JSON.parse(await $.fs.read(currentDir === expand('~/.claude', home) ? home + '/.claude.json' : currentDir + '/.claude.json'));
59 same = meta?.oauthAccount?.accountUuid === stored.accountUuid;
60 }
61 if (same) { p.current = true; break; }
62 } catch { /* unknown stays not-current */ }
63 }
64 }
65 const providers = (Array.isArray(data.providers) ? data.providers : []).filter(p => p && ['codex', 'json', 'command'].includes(p.type)).map((p, i) => ({
66 id: cleanLabel(p.id ?? `provider-${i + 1}`), label: cleanLabel(p.label ?? p.id ?? `Provider ${i + 1}`), type: p.type,
67 authFile: expand(p.authFile ?? '~/.codex/auth.json', home), path: expand(p.path ?? '', home),
68 argv: argv(p.argv), map: p.map ?? {}, delegate: p.delegate ? cleanLabel(p.delegate) : undefined,
69 }));
70 // Store identity includes source config, preventing unrelated configurations sharing an id.
71 for (const p of [...profiles, ...providers]) p.cacheKey = 'reading:' + p.id + ':' + hash(JSON.stringify({ type: p.type, configDir: p.configDir, credentialsFile: p.credentialsFile, authFile: p.authFile, path: p.path, argv: p.argv, map: p.map, tokenCommand: p.tokenCommand }));
72 config = { profiles, providers, advisor: data.advisor ?? {}, switchCommand: argv(data.switchCommand), demo: ['single', 'multi'].includes(data.demo) ? data.demo : undefined, home, configError };
73 return config;
74}
75function publicReading(provider, fields, now) {
76 return JSON.parse(JSON.stringify({ id: provider.id, label: provider.label, type: provider.type, current: provider.current,
77 weight: provider.weight, delegate: provider.delegate, fetchedAt: now, ...fields }));
78}
79async function recentReading($, provider, now, force) {
80 const latest = await $.store.get(provider.cacheKey);
81 if (!force && latest && now - (latest.checkedAt ?? latest.fetchedAt) < options.refresh_minutes * 60000) {
82 return { ...latest, label: provider.label, current: provider.current, delegate: provider.delegate, weight: provider.weight };
83 }
84}
85async function measure($, provider, now, force, live) {
86 const key = provider.cacheKey;
87 const previous = await $.store.get(key);
88 if (provider.current && (live.session || live.week)) {
89 const reading = publicReading(provider, { session: live.session ?? previous?.session, week: live.week ?? previous?.week, source: 'Claude session' }, now);
90 await $.store.set(key, reading); return reading;
91 }
92 // Always re-read the shared store immediately before fetching.
93 const cached = await $.store.get(key);
94 if (!force && cached && now - (cached.checkedAt ?? cached.fetchedAt) < options.refresh_minutes * 60000) {
95 return { ...cached, label: provider.label, current: provider.current, delegate: provider.delegate, weight: provider.weight };
96 }
97 let fields, source, error;
98 try {
99 if (provider.type === 'claude') {
100 let token;
101 if (provider.tokenCommand) {
102 const result = await $.process.run(provider.tokenCommand, { timeoutMs: 5000 });
103 if (result.exitCode !== 0) throw new Error();
104 token = result.stdout.trim();
105 } else {
106 const credentials = JSON.parse(await $.fs.read(provider.credentialsFile ?? provider.configDir + '/.credentials.json'));
107 const oauth = credentials.claudeAiOauth;
108 if (!oauth?.accessToken || !Number.isFinite(Number(oauth.expiresAt)) || Number(oauth.expiresAt) <= now) {
109 error = `token expired or missing; open a session on ${provider.label}`;
110 } else token = oauth.accessToken;
111 }
112 if (!error && token) {
113 const latest = await recentReading($, provider, now, force);
114 if (latest) return latest;
115 const response = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
116 headers: { Authorization: 'Bearer ' + token, 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'limitpace/' + VERSION },
117 });
118 if (response.status === 429) error = 'usage rate limited; keeping last reading';
119 else if (!response.ok) error = 'usage unavailable (HTTP ' + response.status + ')';
120 else fields = parseClaude(JSON.parse(response.text));
121 } else if (!error) error = `token missing; open a session on ${provider.label}`;
122 source = 'Claude OAuth usage';
123 } else if (provider.type === 'codex') {
124 const auth = JSON.parse(await $.fs.read(provider.authFile));
125 const token = auth.tokens?.access_token;
126 if (!token) error = 'token missing; open a Codex session';
127 else {
128 const latest = await recentReading($, provider, now, force);
129 if (latest) return latest;
130 const headers = { Authorization: 'Bearer ' + token, Accept: 'application/json' };
131 if (auth.tokens.account_id) headers['ChatGPT-Account-Id'] = auth.tokens.account_id;
132 const response = await $.http.fetch('https://chatgpt.com/backend-api/wham/usage', { headers });
133 if (response.status === 429) error = 'usage rate limited; keeping last reading';
134 else if (!response.ok) error = 'usage unavailable (HTTP ' + response.status + ')';
135 else fields = parseCodex(JSON.parse(response.text));
136 }
137 source = 'Codex usage';
138 } else if (provider.type === 'json') {
139 fields = parseMapped(JSON.parse(await $.fs.read(provider.path)), provider.map); source = 'configured JSON';
140 } else {
141 if (!provider.argv) throw new Error();
142 const result = await $.process.run(provider.argv, { timeoutMs: 5000 });
143 if (result.exitCode !== 0) throw new Error();
144 fields = parseMapped(JSON.parse(result.stdout), provider.map); source = 'configured command';
145 }
146 if (!error && !fields?.session && !fields?.week) { error = 'no usage windows reported'; fields = undefined; }
147 } catch {
148 // Never echo thrown messages or process output: either can contain credentials.
149 error = provider.type === 'claude' ? `credentials or usage unavailable; open a session on ${provider.label}` : 'usage unavailable; check configured source';
150 }
151 const reading = publicReading(provider, {
152 session: fields?.session ?? cached?.session, week: fields?.week ?? cached?.week,
153 source: source ?? cached?.source ?? 'unavailable', error,
154 fetchedAt: fields ? now : cached?.fetchedAt ?? now, checkedAt: now,
155 }, now);
156 await $.store.set(key, reading);
157 return reading;
158}
159async function refresh($, force = false) {
160 if (refreshing) return;
161 refreshing = true;
162 try {
163 const cfg = await loadConfig($);
164 const now = await $.clock.now();
165 let readings;
166 if (cfg.demo) readings = demoReadings(cfg.demo, now);
167 else {
168 const live = parseSession((await $.session.usage()).rateLimits);
169 readings = [];
170 for (const provider of [...cfg.profiles, ...cfg.providers]) readings.push(await measure($, provider, now, force, live));
171 }
172 await $.state.set(VIEW, { readings, now, ...(cfg.configError ? { configError: cfg.configError } : {}) });
173 if (enabled && ['file', 'both'].includes(options.advisor) && !cfg.demo) await writeAdvisor($, readings, now);
174 } finally { refreshing = false; }
175}
176async function writeAdvisor($, readings, now) {
177 const root = await $.session.root() || await $.session.cwd();
178 const target = config.advisor.target;
179 let path;
180 if (target && target !== 'auto') {
181 const expanded = expand(target, config.home);
182 path = expanded.startsWith('/') || /^[A-Za-z]:\//.test(expanded) ? expanded : root + '/' + expanded;
183 }
184 else if (await $.fs.exists(root + '/AGENTS.md')) path = root + '/AGENTS.md';
185 else path = root + '/CLAUDE.md';
186 const key = 'advisor-file:' + hash(path);
187 const last = await $.store.get(key);
188 const snap = snapshot(readings, now);
189 const threshold = Math.max(1, Number(config.advisor.minChangePoints) || 5);
190 if (last && (now - last.writtenAt < 30 * 60000 || !changed(last.snapshot, snap, threshold))) return;
191 const exists = await $.fs.exists(path);
192 if (!exists && config.advisor.createIfMissing !== true) return;
193 const original = exists ? await $.fs.read(path) : '';
194 const body = advice(readings, now) + '\n\nUpdated ' + new Date(Math.floor(now / 60000) * 60000).toISOString() + '.';
195 const updated = upsertBlock(original, body);
196 if (updated === original) return;
197 await $.fs.write(path, updated);
198 await $.store.set(key, { snapshot: snap, writtenAt: now });
199}
200async function switchProfile($, profile) {
201 if (config.demo) { await $.ui.toast('demo: account switch disabled'); return; }
202 try {
203 if (config.switchCommand) {
204 const command = config.switchCommand.map(part => part.replaceAll('{label}', profile.label));
205 const result = await $.process.run(command, { timeoutMs: 5000 });
206 await $.ui.toast(result.exitCode === 0 ? `new sessions use ${profile.label}` : 'switch failed; check configured command');
207 } else {
208 const dir = profile.configDir.replaceAll("'", "'\\''");
209 const copied = await $.ui.copy(`CLAUDE_CONFIG_DIR='${dir}' claude`);
210 await $.ui.toast(copied.isCopied ? `copied: start a session on ${profile.label}` : 'clipboard unavailable');
211 }
212 } catch { await $.ui.toast('switch unavailable; check configured command'); }
213}
214export function normalizeOptions(userOptions = {}) {
215 const normalized = { layout: 'auto', config_file: '~/.config/limitpace/config.json', advisor: 'off', refresh_minutes: 5, ...userOptions };
216 if (!['auto', 'detailed', 'compact', 'off'].includes(normalized.layout)) normalized.layout = 'auto';
217 if (!['off', 'prompt', 'file', 'both'].includes(normalized.advisor)) normalized.advisor = 'off';
218 const minutes = Number(normalized.refresh_minutes);
219 normalized.refresh_minutes = Number.isNaN(minutes) ? 5 : Math.min(60, Math.max(1, minutes));
220 return normalized;
221}
222export function register(on, userOptions) {
223 config = null; enabled = false; refreshing = false;
224 options = normalizeOptions(userOptions);
225 on('session.start', async ($, e, next) => {
226 const result = await next(e);
227 enabled = e.isInteractive || options.advisor !== 'off';
228 if (!enabled) return result;
229 await refresh($);
230 $.clock.every(options.refresh_minutes * 60000, async () => refresh($));
231 if (['prompt', 'both'].includes(options.advisor)) await $.tool.register({ name: 'usage', description: 'Current subscription usage and provider-neutral delegation advice', inputSchema: { type: 'object', properties: {}, additionalProperties: false } });
232 await $.command.register({ name: 'limitpace', description: 'Usage, pace and account switcher', argumentHint: '[refresh|text]', immediate: true });
233 return result;
234 });
235 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
236 const result = await next(e);
237 if (enabled) await refresh($);
238 return result;
239 });
240 on('session.measure', async ($, e, next) => {
241 const result = await next(e);
242 if (enabled) await refresh($);
243 return result;
244 });
245 on('turn.complete', async ($, e, next) => {
246 const result = await next(e);
247 if (enabled && !e.agentId) await refresh($);
248 return result;
249 });
250 on('prompt.submit', async ($, e, next) => {
251 if (!enabled || !['prompt', 'both'].includes(options.advisor)) return next(e);
252 const { value: view } = await $.state.get(VIEW);
253 if (!view) return next(e);
254 const now = await $.clock.now();
255 const snap = snapshot(view.readings, now);
256 const { value: last } = await $.state.get(INJECTED);
257 if (!changed(last, snap, Math.max(1, Number(config?.advisor.minChangePoints) || 5))) return next(e);
258 const result = await next({ ...e, context: [...(e.context ?? []), advice(view.readings, now)] });
259 if (!result.drop) await $.state.set(INJECTED, snap);
260 return result;
261 });
262 on('tool.call', { tool: 'mcp__limitpace__usage' }, async ($, e, next) => {
263 if (!enabled || !['prompt', 'both'].includes(options.advisor)) return next(e);
264 await refresh($);
265 const { value: view } = await $.state.get(VIEW);
266 return { result: advice(view?.readings ?? [], await $.clock.now()) };
267 });
268 on('command.run', { command: 'limitpace' }, async ($, e) => {
269 const arg = e.args.trim();
270 if (arg === 'refresh' || !config) await refresh($, arg === 'refresh');
271 const { value: view } = await $.state.get(VIEW);
272 const text = summary(view?.readings ?? [], await $.clock.now()) || 'LimitPace: no configured providers.';
273 if (arg === 'text' || arg === 'refresh') return { text };
274 if (arg) return { text: 'Use /limitpace [refresh|text].' };
275 const surfaces = await $.session.surfaces();
276 if (!surfaces.some(s => s === 'terminal' || s === 'desktop')) return { text };
277 const opened = await $.ui.open({ id: 'limitpace', title: 'LimitPace', focus: true, closeOnEscape: true, rows: 30, columns: 66 });
278 return opened.isPlaced ? {} : { text };
279 });
280 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
281 if (e.props.hasSurvey || options.layout === 'off') return next(e);
282 const { value: view } = await $.state.get(VIEW);
283 if (!enabled || !view?.readings.length) return next(e);
284 const existing = await next(e);
285 const { Box, Text } = $.ui.resolve(e);
286 return Box({ flexDirection: 'column', paddingX: 1, children: [
287 ...(existing ? [existing] : []),
288 band(Box, Text, view.readings, view.now, Math.max(1, e.props.bodyColumns - 2), options.layout),
289 ...(view.configError ? [Text({ dimColor: true, wrap: 'truncate', children: view.configError })] : []),
290 ] });
291 });
292 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
293 if (e.requestId !== 'limitpace') return next(e);
294 const { value: view = { readings: [], now: 0 } } = await $.state.get(VIEW);
295 const { Box, Text, Button } = $.ui.resolve(e);
296 const columns = Math.max(1, e.props.bodyColumns - 2);
297 const profiles = config?.demo ? view.readings.filter(r => r.type === 'claude') : config?.profiles ?? [];
298 return Box({ flexDirection: 'column', paddingX: 1, children: [
299 Text({ bold: true, children: 'Usage and pace' }),
300 Text({ dimColor: true, children: '\nSwitch for new sessions (current session stays on its account)' }),
301 ...profiles.slice(0, 9).map((p, i) => Button({ key: 'switch-' + i, label: p.label, plain: true, hotkey: String(i + 1), onPress: async () => switchProfile($, p) })),
302 Box({ flexDirection: 'row', columnGap: 2, children: [
303 Button({ key: 'refresh', label: 'Refresh (r)', hotkey: 'r', onPress: async () => refresh($, true) }),
304 Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: async () => $.ui.close({ id: 'limitpace' }) }),
305 ] }),
306 ...view.readings.flatMap(r => [
307 Text({ bold: true, children: '\n' + r.label + (r.current ? ' * this session' : '') }),
308 barRow(Box, Text, 'Session', r.session, view.now, columns),
309 barRow(Box, Text, 'Week', r.week, view.now, columns, true),
310 ...(columns < 88 ? [Text({ dimColor: true, wrap: 'wrap', children: `Resets: session ${resetText(r.session, view.now)} · week ${resetText(r.week, view.now, true)}` })] : []),
311 Text({ dimColor: true, wrap: 'wrap', children: `${r.source} · age ${Math.max(0, Math.floor((view.now - r.fetchedAt) / 60000))}m${r.error ? '\n' + r.error : ''}` }),
312 ]),
313 ] });
314 });
315}
316hooks/core.mjs 156 lines1// Pure functions: no credentials, host calls, model names or wall-clock globals.
2export const HOUR = 3_600_000;
3export const WEEK = 7 * 24 * HOUR;
4export const clamp = (n, low = 0, high = 100) => Math.max(low, Math.min(high, n));
5export function epoch(value) {
6 if (value === undefined || value === null || value === '') return undefined;
7 const number = typeof value === 'number' ? value : /^\d+(\.\d+)?$/.test(value) ? Number(value) : NaN;
8 const ms = Number.isFinite(number) ? (Math.abs(number) < 1e11 ? number * 1000 : number) : Date.parse(value);
9 return Number.isFinite(ms) ? ms : undefined;
10}
11export function percent(value) {
12 if (value === null || value === undefined || value === '' || typeof value === 'boolean') return undefined;
13 const n = Number(value);
14 return Number.isFinite(n) ? clamp(n) : undefined;
15}
16export function windowReading(used, reset, length) {
17 const n = percent(used);
18 const resetsAt = epoch(reset);
19 return n === undefined ? undefined : { used: n, ...(resetsAt === undefined ? {} : { resetsAt }), length };
20}
21export function pace(window, now) {
22 if (!window || !Number.isFinite(window.resetsAt) || !(window.length > 0)) return undefined;
23 return clamp((now - (window.resetsAt - window.length)) / window.length * 100);
24}
25export function headroom(window, now) {
26 const p = pace(window, now);
27 return p === undefined ? undefined : p - window.used;
28}
29export function status(window, now) {
30 if (!window) return 'unknown';
31 if (window.used >= 90) return 'critical';
32 const h = headroom(window, now);
33 return h === undefined ? 'unknown' : h >= 0 ? 'under' : 'over';
34}
35export const color = (w, now) => ({ under: 'green', over: 'yellow', critical: 'red', unknown: 'gray' })[status(w, now)];
36export function parseClaude(json) {
37 return {
38 session: windowReading(json.five_hour?.utilization, json.five_hour?.resets_at, 5 * HOUR),
39 week: windowReading(json.seven_day?.utilization, json.seven_day?.resets_at, WEEK),
40 };
41}
42export function parseSession(limits = []) {
43 return {
44 session: windowReading(limits.find(r => r.kind === 'five_hour')?.percentUsed, limits.find(r => r.kind === 'five_hour')?.resetsAt, 5 * HOUR),
45 week: windowReading(limits.find(r => r.kind === 'seven_day')?.percentUsed, limits.find(r => r.kind === 'seven_day')?.resetsAt, WEEK),
46 };
47}
48export function parseCodex(json) {
49 const rate = json.rate_limit ?? json.rateLimit ?? {};
50 const primary = rate.primary_window ?? rate.primaryWindow;
51 const secondary = rate.secondary_window ?? rate.secondaryWindow;
52 const duration = w => Number(w?.limit_window_seconds ?? w?.limitWindowSeconds ?? (w?.window_minutes ?? w?.windowMinutes) * 60) * 1000;
53 const windows = [primary, secondary].filter(Boolean);
54 let week, session;
55 if (windows.every(w => !(duration(w) > 0))) { week = primary; session = secondary; }
56 else {
57 week = windows.filter(w => duration(w) >= 24 * HOUR).sort((a, b) => duration(b) - duration(a))[0];
58 session = windows.filter(w => duration(w) > 0 && duration(w) <= 6 * HOUR).sort((a, b) => duration(a) - duration(b))[0];
59 }
60 const read = (w, fallback) => w ? windowReading(w.used_percent ?? w.usedPercent, w.reset_at ?? w.resetAt, duration(w) > 0 ? duration(w) : fallback) : undefined;
61 return { week: read(week, WEEK), session: read(session, 5 * HOUR) };
62}
63export function dotted(json, path) {
64 return typeof path === 'string' ? path.split('.').reduce((v, k) => v && Object.hasOwn(v, k) ? v[k] : undefined, json) : undefined;
65}
66export function parseMapped(json, map = {}) {
67 const read = key => dotted(json, map[key] ?? key);
68 const days = Number(read('weekWindowDays'));
69 return {
70 session: windowReading(read('sessionPercent'), read('sessionResetsAt'), 5 * HOUR),
71 week: windowReading(read('weekPercent'), read('weekResetsAt'), (days > 0 && Number.isFinite(days) ? days : 7) * 24 * HOUR),
72 };
73}
74export function combined(readings, now) {
75 const measured = readings.filter(r => r.type === 'claude' && r.week);
76 const average = getter => {
77 const entries = measured.map(r => ({ value: getter(r), weight: r.weight > 0 ? r.weight : 1 })).filter(r => Number.isFinite(r.value));
78 return entries.length ? entries.reduce((s, r) => s + r.value * r.weight, 0) / entries.reduce((s, r) => s + r.weight, 0) : undefined;
79 };
80 return { used: average(r => r.week.used), pace: average(r => pace(r.week, now)) };
81}
82export function cleanLabel(s) {
83 return String(s ?? '').replace(/[\x00-\x1f\x7f|`<>]/g, ' ').slice(0, 64);
84}
85const modelPattern = /\b(?:opus|sonnet|haiku|fable|gpt-[\w.-]*|o[1-9]\b|gemini)\b/gi;
86const advisorLabel = s => cleanLabel(s).replace(modelPattern, '[plan]');
87export function snapshot(readings, now) {
88 const points = {};
89 for (const r of readings) for (const key of ['session', 'week']) {
90 const h = headroom(r[key], now);
91 if (h !== undefined) points[r.id + ':' + key] = h;
92 }
93 const eligible = readings.filter(r => !r.error && r.week && r.week.used < 95 && (!r.session || r.session.used < 90) && headroom(r.week, now) >= 0);
94 eligible.sort((a, b) => headroom(b.week, now) - headroom(a.week, now));
95 return { recommendation: eligible[0]?.id ?? null, points };
96}
97export function changed(before, after, threshold = 5) {
98 if (!before || before.recommendation !== after.recommendation) return true;
99 const keys = new Set([...Object.keys(before.points), ...Object.keys(after.points)]);
100 return [...keys].some(k => before.points[k] === undefined || after.points[k] === undefined || Math.abs(before.points[k] - after.points[k]) >= threshold);
101}
102export function advice(readings, now) {
103 const snap = snapshot(readings, now);
104 const n = value => value === undefined ? '?' : String(Math.round(value));
105 const rows = readings.map(r => `| ${advisorLabel(r.label)}${r.current ? ' *' : ''} | ${n(r.session?.used)} | ${n(r.week?.used)} | ${n(pace(r.week, now))} | ${n(headroom(r.week, now))} |`);
106 const best = readings.find(r => r.id === snap.recommendation);
107 return [
108 '## LimitPace delegation advice',
109 '| Provider / plan | Session % | Week % | Pace % | Headroom pts |',
110 '| --- | ---: | ---: | ---: | ---: |', ...rows,
111 `Prefer running subagents and delegated work on the provider with the most headroom relative to its pace line (now: ${best ? advisorLabel(best.label) : 'none under pace'}). Keep the strongest model for leading and judgement. Avoid providers whose session window is >= 90 % or week >= 95 %.`,
112 ...(!best ? ['No provider is under pace with safe measured limits; keep delegation small.'] : []),
113 'Claude subagents run on this session\'s Claude account (*).',
114 ...readings.filter(r => r.delegate).map(r => `${advisorLabel(r.label)}: via \`${advisorLabel(r.delegate)}\`.`),
115 ...(readings.some(r => r.error) ? ['Readings with errors are excluded from recommendations; refresh before relying on stale data.'] : []),
116 ].join('\n');
117}
118export const START = '<!-- limitpace:start -->';
119export const END = '<!-- limitpace:end -->';
120export function upsertBlock(text, block) {
121 const start = text.indexOf(START), end = text.indexOf(END);
122 const replacement = `${START}\n${block}\n${END}`;
123 if (start >= 0 || end >= 0) {
124 // Refuse malformed/duplicate marker sets instead of risking outside text.
125 if (start < 0 || end < start || text.indexOf(START, start + START.length) >= 0 || text.indexOf(END, end + END.length) >= 0) return text;
126 return text.slice(0, start) + replacement + text.slice(end + END.length);
127 }
128 return text + (text.endsWith('\n') || text === '' ? '' : '\n') + replacement + '\n';
129}
130export function resetText(window, now, weekly = false) {
131 if (!Number.isFinite(window?.resetsAt)) return '?';
132 if (!weekly) {
133 const minutes = Math.max(0, Math.ceil((window.resetsAt - now) / 60000));
134 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
135 }
136 return new Date(window.resetsAt).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' });
137}
138export function summary(readings, now) {
139 return readings.map(r => {
140 const parts = ['session', 'week'].map(k => {
141 const w = r[k], p = pace(w, now), h = headroom(w, now);
142 return `${k}: ${w ? Math.round(w.used) + '%' : '?'}; pace ${p === undefined ? '?' : Math.round(p) + '%'}; ${h === undefined ? 'headroom ?' : Math.round(Math.abs(h)) + ' ' + (h >= 0 ? 'under' : 'over')}; resets ${resetText(w, now, k === 'week')}`;
143 });
144 return `${r.label}${r.current ? '*' : ''} — ${parts.join(' | ')}\n ${r.source}; age ${Math.max(0, Math.floor((now - r.fetchedAt) / 60000))}m${r.error ? '; ' + r.error : ''}`;
145 }).join('\n');
146}
147export function demoReadings(mode, now) {
148 const read = (id, label, type, s, w, sp, wp, current = false) => ({ id, label, type, current, source: 'demo', fetchedAt: now, session: windowReading(s, now + (100 - sp) / 100 * 5 * HOUR, 5 * HOUR), week: windowReading(w, now + (100 - wp) / 100 * WEEK, WEEK) });
149 return mode === 'multi' ? [
150 read('claude:A', 'A', 'claude', 0, 99, 25, 48),
151 read('claude:B', 'B', 'claude', 47, 56, 38, 48, true),
152 { ...read('codex', 'Codex', 'codex', 42, 85, 65, 60), delegate: 'codex exec' },
153 { ...read('devin', 'Devin', 'json', 14, 32, 55, 61), delegate: 'devin' },
154 ] : [read('claude:current', 'Claude', 'claude', 47, 31, 38, 52, true)];
155}
156hooks/drawing.mjs 58 lines1import { color, pace, headroom, resetText, combined, cleanLabel } from './core.mjs';
2
3export function barRow(Box, Text, label, window, now, columns, weekly = false) {
4 if (!window) return Text({ dimColor: true, children: `${label.padEnd(7)} ? unmeasured` });
5 const p = pace(window, now), h = headroom(window, now);
6 const head = h === undefined ? '?' : `${Math.round(Math.abs(h))} ${h >= 0 ? 'under' : 'over'}`;
7 const suffix = columns >= 88
8 ? ` ${Math.round(window.used)}% pace ${p === undefined ? '?' : Math.round(p) + '%'} ${head} resets ${resetText(window, now, weekly)}`
9 : columns >= 55 ? ` ${Math.round(window.used)}% p${p === undefined ? '?' : Math.round(p)} ${head}` : ` ${Math.round(window.used)}%`;
10 const width = Math.max(3, Math.min(40, columns - 11 - suffix.length));
11 const filled = Math.round(window.used / 100 * width);
12 const marker = p === undefined ? -1 : Math.min(width - 1, Math.floor(p / 100 * width));
13 const cells = Array.from({ length: width }, (_, i) => Text({
14 color: i === marker ? 'cyan' : i < filled ? color(window, now) : 'gray',
15 children: i === marker ? '┃' : i < filled ? '█' : '░',
16 }));
17 return Box({ flexDirection: 'row', children: [
18 Text({ children: label.padEnd(7) + '▕' }), ...cells, Text({ children: '▏' + suffix }),
19 ] });
20}
21export function compactParts(readings, now, columns) {
22 const c = combined(readings, now);
23 const total = c.used === undefined ? 'Claude W?' : `Claude W ${Math.round(c.used)}%${columns >= 100 && c.pace !== undefined ? ` (pace ${Math.round(c.pace)})` : ''}`;
24 const parts = [{ text: total, color: c.used >= 90 ? 'red' : c.used <= c.pace ? 'green' : 'yellow' }];
25 for (const r of readings) {
26 const label = cleanLabel(r.label) + (r.current ? '*' : '');
27 const text = `${label} W${r.week ? Math.round(r.week.used) : '?'}${columns >= 100 && r.session ? ` S${Math.round(r.session.used)}` : ''}${r.error ? ' !' : ''}`;
28 parts.push({ text, color: color(r.week, now), dim: !r.week });
29 }
30 // Keep every provider visible while space allows; cut only at whole segments.
31 let remaining = Math.max(0, columns - 2);
32 const result = [];
33 for (let i = 0; i < parts.length; i++) {
34 const prefix = i ? ' │ ' : '';
35 let text = prefix + parts[i].text;
36 if (text.length > remaining) {
37 if (remaining >= 3) result.push({ text: ' …', dim: true });
38 break;
39 }
40 result.push({ ...parts[i], text }); remaining -= text.length;
41 }
42 return result;
43}
44export function band(Box, Text, readings, now, columns, layout) {
45 const claude = readings.filter(r => r.type === 'claude');
46 const extra = readings.filter(r => r.type !== 'claude');
47 const detailed = layout === 'detailed' || (layout === 'auto' && claude.length === 1 && extra.length === 0);
48 if (!detailed) return Box({ flexDirection: 'row', children: compactParts(readings, now, columns).map(p => Text({ color: p.color, dimColor: p.dim, children: p.text })) });
49 const current = claude.find(r => r.current) ?? claude[0];
50 if (!current) return Text({ dimColor: true, children: 'Claude ? configure a profile' });
51 return Box({ flexDirection: 'column', children: [
52 barRow(Box, Text, 'Session', current.session, now, columns),
53 barRow(Box, Text, 'Week', current.week, now, columns, true),
54 ...(current.error ? [Text({ dimColor: true, wrap: 'truncate', children: current.error })] : []),
55 ...(extra.length ? [Box({ flexDirection: 'row', children: compactParts(extra, now, columns).slice(1).map(p => Text({ color: p.color, dimColor: p.dim, children: p.text.replace(/^ │ /, ' ') })) })] : []),
56 ] });
57}
58types/index.d.ts 16 lines1export type WindowReading = { used: number; resetsAt?: number; length: number };
2export type Reading = {
3 id: string; label: string; type: string; current?: boolean; weight?: number;
4 delegate?: string; session?: WindowReading; week?: WindowReading;
5 fetchedAt: number; source: string; error?: string; checkedAt?: number;
6};
7export type AdviceSnapshot = { recommendation: string | null; points: Record<string, number> };
8declare module 'claude-code' {
9 interface PluginState {
10 limitpace: {
11 view: { readings: Reading[]; now: number; configError?: string };
12 injected: AdviceSnapshot | null;
13 };
14 }
15}
16