SLOPSHOPPER

LimitPace

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

newpanebandguardcommandtoast
v0.1.1MITupdated 2026-10-02fstandhartinger/limitpace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limitpace
│ ┃ LimitPace ✕ › fix the failing auth test and add an audit log call │ ┃ Usage and pace │ ┃ Switch for new sessions (current session ⏺ Read(src/auth.ts) │ ┃ stays on its account) ⎿ Read 6 lines │ ┃ 1: Claude ⏺ Update(src/auth.ts) │ ┃ [ Refresh (r) ] [ Close (c) ] ⎿ Added 2 lines, removed 1 line │ ┃ Claude * this session ⏺ Bash(bun test) │ ┃ Session▕████████████░░░░░░░░░░░░░░░░░░░┃░░░ ⎿ 3 pass, 1 fail │ ┃ Week ? unmeasured │ ┃ Resets: session 1h 0m · week ? ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Claude session · age 0m │ ✻ Worked for 42s · done 4:20 PM │ │ › /limitpace │ │ ⟨Claude Code's own drawing⟩ Session▕████████████░░░░░░░░░░░░░░░░░░░░┃░░░░░░░▏ 31% pace 80% 49 under resets 1h 0m Week ? unmeasured ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Session▕████████████░░░░░░░░░░░░░░░░░░░░┃░░░░░░░▏ 31% pace 80% 49 under resets 1h 0m Week ? unmeasured
Pane · LimitPace
Usage and pace Switch for new sessions (current session stays on its account) 1: Claude [ Refresh (r) ] [ Close (c) ] Claude * this session Session▕████████████░░░░░░░░░░░░░░░░░░░┃░░░░░░░▏ 31% Week ? unmeasured Resets: session 1h 0m · week ? Claude session · age 0m
README

LimitPace

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.

Single-account detailed demo capture

Multiple-provider compact demo capture

Account pane demo capture

Install

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.

Read the band

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.

Configure

Four fields appear in the plugin's configuration:

FieldDefaultValues
layoutautoauto, detailed, compact, off
config_file~/.config/limitpace/config.jsonOptional JSON file, or demo:single / demo:multi
advisoroffoff, prompt, file, both
refresh_minutes51–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:

KeyMeaning
sessionPercentSession percentage, 0–100
sessionResetsAtSession reset time
weekPercentWeekly percentage, 0–100
weekResetsAtWeekly reset time
weekWindowDaysWeekly 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.

Demo mode

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.

Optional engine advisor

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.

What LimitPace reads, runs and sends

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.

Hook and callback disclosures

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 callbackWhat it doesWhat it fetchesWhat it runsWhat it writes
session.startStarts 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, forkRefreshes 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.measureRefreshes 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.completeRefreshes 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 callbackRefreshes 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.submitIn 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__usageIn 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 /limitpacerefresh 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 AbovePromptDraws 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 PaneDraws usage and buttons from the current view.None while rendering.None while rendering.None while rendering.
Pane account button callbackSwitches 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 callbacksRefresh 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.

Surfaces and limitations

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.

Development

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.

Source 4 files
hooks/limitpace.mjs 316 lines
1import { 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}
316
hooks/core.mjs 156 lines
1// 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}
156
hooks/drawing.mjs 58 lines
1import { 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}
58
types/index.d.ts 16 lines
1export 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