Band above the prompt: Codex + Claude rate limits with reset countdowns, context left, a clock, and an optional next event

Hooks, tools, one skill, and a set of mods for Claude Code. The hooks and tools cover the four things that quietly cost the most: a memory store that rots, subagent fan-out that eats a usage window, bulk work sent to an expensive model, and an agent pushing to a repo it should not.
Zero dependencies, plain Node (18+), ESM. The mods run inside Claude Code itself.
On macOS, install the two prerequisites first. agentcall is the one bash script here and it needs bash 4+; macOS ships 3.2, where expanding an empty array under set -u is a fatal error. Two adapters parse JSON output with jq. Everything else is Node and works as-is.
brew install bash jq
git clone https://github.com/VibeCodyH/claude-craft
cd claude-craft
node install.mjs --dry-run # show the plan
node install.mjs # copy the tools, merge the hook wiring
The installer reads your existing ~/.claude/settings.json, adds only the hook entries that are missing, and writes one timestamped backup before its first change. Re-running it is a no-op. If the file is not valid JSON it stops and tells you, rather than replacing config it cannot read.
Install a subset by name, and remove the wiring the same way:
node install.mjs --list
node install.mjs memory delegation
node install.mjs --uninstall rotate
Restart Claude Code afterward to pick up the hooks.
| Component | Runs as | Does |
|---|---|---|
memory | hooks + CLI | Lint, recall, and staleness detection for the auto-memory store. |
delegation | hooks | Caps subagent fan-out; nudges mechanical sweeps toward a cheaper executor. |
agentcall | CLI | One calling convention for every agent CLI you have installed. |
crunch | CLI + library | Bulk LLM lane: strongest free cloud model first, your own box as the floor. |
protected-repos | hook | Blocks pushes and PRs on repos where a human runs every push. |
rotate | hooks | Swaps logins at a usage threshold, via swappy. |
skills | skill | delegate: when to hand work to an executor, and how. |
mods | /plugin install, see Mods | Bands and panes in Claude Code's own UI: blast radius, prod tripwire, PR tracker, usage limits. |
Claude Code's auto-memory is a directory of markdown files plus a MEMORY.md index. It's great — until it rots. Links dangle after renames, the index drifts from the files, near-duplicate memories pile up, and a memory that pins "how deploy.sh works" goes silently stale the day deploy.sh changes. And even a healthy store only helps if the agent actually recalls the right memory before acting on a fresh (worse) idea.
| Tool | Runs as | Does |
|---|---|---|
memory-lint.mjs | manual / CI | Reports dangling [[wikilinks]], index drift (broken + orphaned entries), and likely duplicates. |
memory-preflight.mjs | UserPromptSubmit hook | Injects strongly-relevant prior memories into context when the user's prompt is on-topic. |
memcheck.mjs | invoked by the agent | Scores a proposed approach against the store, so a recorded ★decision outranks a fresh idea. |
memory-staleness.mjs | SessionStart hook | Recomputes hashes for file-pinned memories and reports only drift. |
memory-lib.mjs | library | Shared parser + scorer the rest import. |
The preflight/memcheck pair matters: a prompt hook only sees the user's words, so it can't catch a mistake born from the agent's own plan. memcheck is the other half — tell your agent (in CLAUDE.md) to run it against its own approach before committing to one.
# lint the current project's store (or any dir)
node ~/.claude/tools/memory-lint.mjs
node ~/.claude/tools/memory-lint.mjs --json --dir path/to/memory
# self-check an approach before pitching it
node ~/.claude/tools/memcheck.mjs "rewrite the dark mode cards to use a new surface color"
# pin a memory to file contents, then let SessionStart flag drift
node ~/.claude/tools/memory-staleness.mjs --bind src/deploy.sh
# - /abs/path/src/deploy.sh@1a2b3c4d5e6f <- paste into the memory under `binds:`
Both hooks are fail-safe: on any error they print nothing and exit 0. They will never block a prompt or a session.
Memory-repo benchmarks (LoCoMo, LongMemEval) measure QA accuracy over long chat histories — the wrong question for a hook that injects context into a coding agent. What actually matters there is: does it surface the right memory when the prompt is on-topic, and stay silent when it isn't? So this repo ships a labeled eval instead: a synthetic 18-memory store and 36 developer-realistic queries (24 on-topic incl. hard paraphrases, 12 off-topic), run through the exact shipped gating code:
| Tier | Recall | False-fire |
|---|---|---|
preflight (hook — every prompt) | 14/24 (58%) | 0/12 (0%) |
memcheck (agent-invoked) | 20/24 (83%) | 1/12 (8%) |
The asymmetry is the design: the hook fires on every prompt, so it is tuned to never inject noise; memcheck is asked for deliberately, so it trades a little noise for recall. The misses are paraphrase-heavy queries with few shared tokens — the known ceiling of a lexical scorer, and the price of zero dependencies. Reproduce with npm run bench (--verbose lists every miss); CI floors in the test suite pin false-fire at 0 and recall above 50%/75%.
One memory per file, frontmatter with name / description / metadata.type (user | feedback | project | reference), [[wikilinks]] between memories, and a MEMORY.md index of - Title — hook lines. A MEMORY-cold-index.md is treated as a second, deliberately-not-resident index tier. feedback/project memories get a scoring boost — they carry decisions.
Hash-pinned binds — a memory that describes a specific file can pin it:
binds:
- /abs/path/to/file@1a2b3c4d5e6f # first 12 hex of sha256(contents)
Shared team store — point memcheck at a git repo of memories your team maintains (--team <dir> or CLAUDE_TEAM_MEMORY_DIR). Results are tagged [local] / [team] / [both]; mirrored entries collapse with the team copy winning. Two engineers' Claude instances sharing one curated store of platform facts works better than it has any right to.
Domain acronyms — the scorer drops tokens under 4 chars except a keep-list (llm ssh api db …). Extend it for your domain: CLAUDE_MEM_KEEP_SHORT="k8s rds sqs".
Two failures cost real money, and both are reflexes rather than decisions. The first: a task looks parallel, so the agent spawns five subagents, each starting cold and re-deriving context the parent already had. The second: the agent hand-edits forty files itself, one at a time, while an executor CLI sits idle on its own quota.
delegation-guard.mjs (PreToolUse on Agent and Workflow) puts two hard caps on the first, in a rolling window:
The premium cap is the one that earns its keep. A spawn with no explicit model on the default agent type inherits the parent's model, which is normally the most expensive one you run, so an unqualified spawn counts as premium. Named specialist types with no model are exempt, because they carry their own default. A refused spawn is not recorded, so being stopped never extends the window.
delegation-nudge.mjs (PostToolUse on edits) handles the second. When the main loop touches 3 distinct files in 5 minutes, it prints a ready-to-run executor command for the rest of the sweep. It never blocks. It stays silent inside any repo listed in protected-repos.json, on the theory that a repo you must push by hand is a repo whose edits you want to have read yourself.
Every threshold is an environment variable. Defaults in parentheses:
CLAUDE_DELEGATION_CAP (4) CLAUDE_NUDGE_FILES (3)
CLAUDE_DELEGATION_PREMIUM_CAP (2) CLAUDE_NUDGE_WINDOW_MIN (5)
CLAUDE_DELEGATION_WINDOW_MIN (10) CLAUDE_NUDGE_COOLDOWN_MIN (8)
CLAUDE_PREMIUM_MODELS (opus,fable) CLAUDE_NUDGE_SKIP (colon-separated paths)
CLAUDE_WORKFLOW_POLICY (ask) CLAUDE_NUDGE_CMD (codex)
CLAUDE_EXECUTORS (named in the refusal message)
CLAUDE_WORKFLOW_POLICY=ask is the default because a workflow's own agent() calls fan out subagents, and a stock research workflow can spawn dozens of them before anyone notices. Set it to deny or allow once you know which workflows you run.
Every agent CLI has a different flag for "yes, you may use your tools" and a different way of emitting its final answer. Getting one of those wrong does not produce an error. It produces a confident review of a diff the model never opened. bin/agentcall normalizes them:
agentcall codex -d ~/src/app -m ro < review-prompt.md # same shape for every agent
agentcall --probe # which CLIs are installed and authenticated
agentcall --list # what each is good for, and its known traps
agentcall --print-command grok -d . "hi" # inspect the invocation, run nothing
agentcall --new mytool # scaffold an adapter for a new CLI
It ships adapters for codex, claude, grok, kimi, vibe, glm, agy, opencode, copilot, and coderabbit. Each one encodes a trap found by hitting it:
-o FILE. One 4-file PR produced 152KB.-p is --single and consumes the next argument, so the prompt has to go last. Without --always-approve its tool calls are cancelled silently, and the partial narration reads exactly like a short bad review.--auto, with the same failure mode.type:"complete" record.--print-command exists because every one of those bugs was invisible until something downstream looked wrong. A new CLI is a drop-in file at ~/.config/agentcall/agents.d/<name>.sh, not a patch to the script. A drop-in whose name matches a builtin overrides it, which is how you fix a recipe that upstream changed.
The delegate skill in skills/ is the policy layer over all of this: what to hand off, what to keep, how to write the spec, and how to review what comes back.
A lot of what gets pushed through a coding agent is not reasoning. It is summarize this transcript, classify these 400 rows, extract fields from this dump. That work wants a cheap model, and several vendors give away a good one. crunch walks them in order and falls through to a local OpenAI-compatible server (Ollama, LM Studio, llama.cpp, vLLM) when every cloud tier is missing, capped, or down.
crunch "write a one-line commit message for: added retry backoff"
git log --oneline -50 | crunch --task summarize
crunch --task classify --json "<text>"
crunch --local "<sensitive text>" # your box only, nothing leaves the machine
crunch --models cerebras # list the model ids a provider really serves
Chain: openrouter → cerebras → groq → zai → mistral → cloudflare → local. A provider with no key is skipped, so one key is enough to start. A provider that returns 429 is skipped for the rest of the run rather than costing a round trip on every remaining call of a bulk job. Every attempt logs to stderr, so a chain that is quietly landing on the local floor looks different from one that is working. Copy crunch.env.example to ~/.config/claude-craft/crunch.env and fill in what you have.
Free model slugs rotate. A slug that stops being free returns 404, which cascades to the next provider instead of failing loudly, so a chain can quietly get slower without breaking. Run crunch --models openrouter | grep ':free' and update CRUNCH_OPENROUTER_MODEL when that happens. GitHub Models was in this chain until 2026-08-07 and is not any more: its endpoint now answers 410 github_models_retirement_brownout. It was the best entry while it lasted, because it reused your gh auth login token and needed no key.
On the redactor. Input is scrubbed for credential shapes before any cloud request: API keys, tokens, JWTs, private key blocks, connection strings, and NAME=value pairs whose name says credential. It is not a PII scrubber and will not catch names, emails, addresses, or account numbers. There is a test asserting that, so the limit stays honest. For real PII use --local, which skips both the cloud chain and the redactor, because scrubbing would gut the data you kept local in order to work on.
Point CRUNCH_SCRUB_MODULE at your own ESM module exporting scrub(text) -> { text, redactions } to substitute a better one. If it fails to load or throws, crunch falls through to the local floor rather than sending text the intended guard never inspected.
Importable as a library from the checkout: import { crunch } from "<checkout>/tools/crunch.mjs". The installer puts the CLI on your PATH and deliberately does not keep a second copy under ~/.claude/tools, because two copies of the same file drift apart.
tools/protected-repo-guard.mjs enforces "the agent may work on this clone locally but NEVER pushes it" — useful when Claude works on an employer's or teammate's repo where a human must run every push and open every PR. A prose rule in CLAUDE.md can be argued with; a PreToolUse deny can't. It catches git push (by cwd, cd chains, -C, slug, or URL), gh pr create|merge, gh repo sync, and gh api writes — while leaving local commits, branches, and the issue/comment lane open.
Configure ~/.claude/protected-repos.json:
[{ "slug": "some-org/their-repo", "dir": "/abs/path/to/local/clone" }]
Fail-open by design: no config or a broken config protects nothing rather than bricking every Bash call. The guard is belt-and-suspenders over your prose rule, not a replacement for it.
tools/swappy-auto-rotate.mjs closes the loop on multi-account usage limits: when either subscription window (5-hour or 7-day) crosses a threshold (default 95%), a Stop hook rotates you to your next saved login automatically and sends a desktop notification. The swap itself is done by swappy — PiXeL16's excellent minimal Claude Code login rotator. Install and set that up first (swappy save <name> per account); this hook is just the trigger finger.
Three things make the design work:
rate_limits percentages every turn. Persist them from your statusline command script: // statusline receives the payload as JSON on stdin — add:
if (payload.rate_limits) {
fs.writeFileSync(path.join(os.homedir(), ".claude", "usage-snapshot.json"),
JSON.stringify({ ts: Date.now(), rate_limits: payload.rate_limits }));
}
If you rotate logins, also stamp which account the reading came from — the payload has no account field, so read oauthAccount.accountUuid out of ~/.claude.json and add it as acct. A session that has not made an API call since a swap keeps re-rendering the account it was on, and that snapshot is fresh by ts while being wrong about whose usage it is. With acct present the hook skips those runs instead of filing them under the wrong profile. It stays optional: no stamp means the old trust-the-snapshot behavior.
swappy next can move you from an account with hours of 5-hour headroom onto one that is 100% capped and kill the turn. (Observed: work at 7d 96% but 5h 9%, rotated onto a 5h-exhausted personal.) The hook keeps a per-profile ledger of the last usage seen for each login — it is running every turn anyway — and swaps with swappy use <name> to the account it knows is healthy.The rule that falls out of that is worth stating on its own: an unmeasured account is only worth gambling on when you are already stuck. If your current 5-hour window is still live, the hook only swaps to a profile the ledger knows is healthy; being 7-day-hot is not a reason to trade a working session for a coin flip. When nothing beats where you are it stays put, and says so only if you are genuinely capped — a correct "everything still works" is not worth a notification every cooldown period.
The installer wires it as both a Stop hook and an async PostToolUse hook. Why both: a Stop hook only fires between turns, and one marathon turn can ride from 90% to a hard limit without ever reaching Stop (field-tested: 98% before a human noticed). The statusline snapshot updates during turns, so the PostToolUse wiring catches the crossing after the next tool call; "async": true keeps it off the critical path, and the cooldown stamp already dedups the two wirings.
Guard rails: windows whose resets_at already passed are ignored (the limit reset while you were idle), malformed or >6h-old snapshots are ignored, and when every account is over the threshold a cooldown stamp (default 15 min) stops exhausted accounts from ping-ponging — the stamp is claimed before swapping so concurrent sessions can't double-rotate, and a failed swap backs off only 2 minutes instead of consuming the full cooldown. All subprocess calls are timeboxed so the hook can never stall a turn.
The ledger has its own rails, because a wrong entry there sends you to a dead account: a reading is only filed against the active profile if the snapshot is under 5 minutes old, recording pauses for 2 minutes after a swap (Claude Code has not re-read credentials yet, so the statusline is still reporting the account you just left), entries older than 8 days stop counting as evidence, and a recorded cap whose resets_at has since passed stops counting against that profile without needing to have been active to observe it. If swappy ls cannot be read at all the hook refuses to swap rather than guess. Tune via SWAPPY_GUARD_THRESHOLD, SWAPPY_GUARD_COOLDOWN_MIN, SWAPPY_GUARD_LEDGER, SWAPPY_BIN; test decisions safely with SWAPPY_GUARD_DRYRUN=1.
Mods hook into Claude Code's own interface: a band above the prompt, a pane beside the approval dialog, a tool the model can call. They need Claude Code 2.1.286 or later (mods are early access). Install each one by name:
/plugin install usage-band --marketplace VibeCodyH/claude-craft
Answer y to add the marketplace, then pick a scope. The options below are userConfig fields in each mod's plugin.json.
| Mod | Does | Options |
|---|---|---|
blast-radius | For rm -r, git reset --hard, git clean, force pushes, branch -D, discards and db:migrate:prod, runs the tools' own dry runs and shows what would be touched beside the approval prompt. It never approves or blocks. | wrappers: extra command prefixes to see through, like a wrapper script. |
prod-tripwire | A red PROD band while a command touches production, then a dim "last prod touch" line until your next prompt. Built-in patterns: db:migrate:prod, neonctl, Vercel --prod, gh pr merge. | patterns: your own label=regex entries, separated by ;;. |
pr-tracker | Pins the PR the session is babysitting: review verdict, checks, merge state. A PR joins when the session runs gh pr create, or gh pr edit --add-reviewer, or a watch-review.sh owner/repo N watcher line. Needs gh. | |
usage-band | One row above the prompt: Codex weekly limit (from ~/.codex/sessions), Claude 5-hour and 7-day limits, context left, and a clock. Shows % left with reset countdowns. | nextFile: a JSON file with next_date and next_text to show your next event. Empty hides it. |
needs-you | Gives Claude ask and resolve tools. Each question or command it needs from you sits in an amber pill above the prompt, with a copy button, until it's answered. | |
clipboard-step | When Claude puts a command on your clipboard, shows it above the prompt until you paste it, clear it, or send your next prompt. Wayland only (wl-copy / wl-paste). | |
yellow-titles | Draws bold spans in replies in yellow so titles stand out. Display only. |
For keeping secrets, emails and IPs out of the transcript, use Ray Amjad's secret-redactor. I run it alongside these.
If you would rather not run the installer, copy what you want and wire it yourself:
cp claude-craft/tools/*.mjs ~/.claude/tools/
cp -r claude-craft/skills/delegate ~/.claude/skills/
cp claude-craft/bin/agentcall claude-craft/tools/crunch.mjs ~/.local/bin/
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/memory-preflight.mjs" }] }
],
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/memory-staleness.mjs" }] }
],
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/protected-repo-guard.mjs" }] },
{ "matcher": "Agent|Workflow", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/delegation-guard.mjs" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/delegation-nudge.mjs" }] },
{ "matcher": "*", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/swappy-auto-rotate.mjs", "timeout": 15, "async": true }] }
],
"Stop": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/swappy-auto-rotate.mjs", "timeout": 15 }] }
]
}
}
Use absolute paths. ~ in a hook command is not expanded on every platform, and a hook that fails to run fails silently.
npm test
npm run bench
Each mod has its own suite:
claude plugin validate mods/usage-band
claude plugin test mods/usage-band
MIT
hooks/register.tsx 247 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Snapshot, Window } from '../types'
5
6const snap = atom({ plugin: 'usage-band', key: 'snap' } as const, null)
7
8const REFRESH_MS = 60_000
9const MAX_READ_BYTES = 3_500_000
10const DAY_MS = 86_400_000
11
12const pad = (n: number) => String(n).padStart(2, '0')
13
14export function countdown(resetsAt: number, now: number): string {
15 const remaining = Math.floor((resetsAt - now) / 1000)
16 if (remaining <= 0) return 'ready'
17 const days = Math.floor(remaining / 86400)
18 const hours = Math.floor((remaining % 86400) / 3600)
19 const minutes = Math.floor((remaining % 3600) / 60)
20 if (days > 0) return `${days}d${hours}h`
21 if (hours > 0) return `${hours}h${minutes}m`
22 return `${Math.max(minutes, 1)}m`
23}
24
25// % left → green / amber / red (same thresholds as ghostty-cockpit).
26export function pctColor(left: number): string {
27 if (left <= 10) return '#ff5f5f'
28 if (left <= 30) return '#d7d787'
29 return '#5fd787'
30}
31
32type CodexLimit = {
33 used_percent?: number
34 window_minutes?: number
35 resets_at?: number
36 resets_in_seconds?: number
37}
38
39// Finds the newest token_count rate-limit reading in a Codex session log.
40export function parseCodex(text: string): Snapshot['codex'] {
41 const out: Snapshot['codex'] = { fiveHour: null, weekly: null }
42 const lines = text.split('\n')
43 for (let i = lines.length - 1; i >= 0; i--) {
44 const line = lines[i]
45 if (!line || !line.includes('"token_count"') || !line.includes('rate_limits')) continue
46 let row: any
47 try {
48 row = JSON.parse(line)
49 } catch {
50 continue
51 }
52 const limits = row?.payload?.type === 'token_count' ? row.payload.rate_limits : null
53 if (!limits) continue
54 const stampMs = row.timestamp ? Date.parse(row.timestamp) : NaN
55 const slots: [CodexLimit | undefined, 'fiveHour' | 'weekly'][] = [
56 [limits.primary, 'fiveHour'],
57 [limits.secondary, 'weekly'],
58 ]
59 for (const [limit, fallback] of slots) {
60 if (!limit || typeof limit.used_percent !== 'number') continue
61 const resetsAt =
62 typeof limit.resets_at === 'number'
63 ? limit.resets_at * 1000
64 : typeof limit.resets_in_seconds === 'number' && !Number.isNaN(stampMs)
65 ? stampMs + limit.resets_in_seconds * 1000
66 : 0
67 const slot =
68 typeof limit.window_minutes === 'number'
69 ? limit.window_minutes <= 360
70 ? 'fiveHour'
71 : 'weekly'
72 : fallback
73 out[slot] = { left: Math.round(100 - limit.used_percent), resetsAt }
74 }
75 if (out.fiveHour || out.weekly) return out
76 }
77 return out
78}
79
80async function readCodex($: EngineInterface, home: string, now: number): Promise<Snapshot['codex']> {
81 const root = `${home}/.codex/sessions`
82 let newest: { path: string; mtimeMs: number; size: number } | null = null
83 // Codex files sessions by local date; scan around today in case the
84 // engine's clock zone differs, then fall back up to a week.
85 for (let offset = 1; offset >= -7; offset--) {
86 const d = new Date(now + offset * DAY_MS)
87 const dir = `${root}/${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`
88 const entries = await $.fs.list(dir).catch(() => [])
89 for (const entry of entries) {
90 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
91 if (!newest || entry.mtimeMs > newest.mtimeMs) {
92 newest = { path: `${dir}/${entry.name}`, mtimeMs: entry.mtimeMs, size: entry.size }
93 }
94 }
95 if (newest && offset <= -1) break
96 }
97 if (!newest) return { fiveHour: null, weekly: null }
98
99 let text = ''
100 if (newest.size <= MAX_READ_BYTES) {
101 text = await $.fs.read(newest.path).catch(() => '')
102 } else {
103 const r = await $.process.run(['tail', '-n', '2000', newest.path]).catch(() => null)
104 text = r?.stdout ?? ''
105 }
106 return parseCodex(text)
107}
108
109// The NEXT segment's file (the `nextFile` option): JSON with `next_date` and
110// `next_text`. Unset hides the segment.
111let nextFile = ''
112
113export function parseNext(raw: string): string | null {
114 try {
115 const next = JSON.parse(raw)
116 if (!next.next_text) return null
117 return `${next.next_date ?? ''} ${next.next_text}`.trim()
118 } catch {
119 return null
120 }
121}
122
123async function readNext($: EngineInterface, home: string): Promise<string | null> {
124 if (!nextFile) return null
125 const raw = await $.fs.read(nextFile.replace(/^~(?=\/)/, home)).catch(() => null)
126 return raw ? parseNext(raw) : null
127}
128
129// Local date and time from the host's `date`: the mod's own environment has no time zone.
130async function readClock($: EngineInterface): Promise<string | null> {
131 const r = await $.process.run(['date', '+%a %b %-d %-I:%M %p']).catch(() => null)
132 return r && r.exitCode === 0 ? r.stdout.trim() || null : null
133}
134
135export function claudeWindows(limits: SessionRateLimit[]): Snapshot['claude'] {
136 const pick = (kind: string): Window | null => {
137 const l = limits.find(x => x.kind === kind)
138 if (!l) return null
139 return { left: Math.round(100 - l.percentUsed), resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : 0 }
140 }
141 return { fiveHour: pick('five_hour'), weekly: pick('seven_day') }
142}
143
144async function refresh($: EngineInterface) {
145 const now = await $.clock.now()
146 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
147 const [codex, next, usage, clock] = await Promise.all([
148 readCodex($, home, now).catch(() => ({ fiveHour: null, weekly: null })),
149 readNext($, home),
150 $.session.usage().catch(() => null),
151 readClock($),
152 ])
153 const percent = usage?.context.percent
154 const value: Snapshot = {
155 at: now,
156 next,
157 clock,
158 codex,
159 claude: claudeWindows(usage?.rateLimits ?? []),
160 contextLeft: typeof percent === 'number' ? 100 - percent : null,
161 }
162 await update($, snap, () => value)
163}
164
165export const register: Register = (on, options) => {
166 nextFile = String(options?.nextFile ?? '').trim()
167 on('session.start', async ($, e, next) => {
168 const result = await next(e)
169 const now = await refresh($).then(() => $.clock.now()).catch(() => 0)
170 // Start the minute ticks on the minute so the clock never runs behind.
171 $.clock.after(REFRESH_MS - (now % REFRESH_MS), () => {
172 refresh($).catch(() => {})
173 $.clock.every(REFRESH_MS, () => {
174 refresh($).catch(() => {})
175 })
176 })
177 return result
178 })
179
180 on('session.measure', async ($, e, next) => {
181 const result = await next(e)
182 await refresh($).catch(() => {})
183 return result
184 })
185
186 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
187 const s = await read($, snap)
188 if (e.props.hasSurvey || s === null) return next(e)
189
190 const { Box, Text } = $.ui.resolve(e)
191 const sep = <Text color="#5a5a6e"> │ </Text>
192 const win = (label: string, w: Window | null) =>
193 w === null ? (
194 <Text>
195 <Text color="#87d7ff">{label} </Text>
196 <Text color="#8a8a8a">—</Text>
197 </Text>
198 ) : (
199 <Text>
200 <Text color="#87d7ff">{label} </Text>
201 <Text color={pctColor(w.resetsAt && w.resetsAt <= s.at ? 100 : w.left)}>
202 {w.resetsAt && w.resetsAt <= s.at ? 100 : w.left}%
203 </Text>
204 <Text color="#8a8a8a"> {w.resetsAt ? countdown(w.resetsAt, s.at) : '—'}</Text>
205 </Text>
206 )
207
208 return (
209 <Box flexDirection="row" justifyContent="space-between">
210 <Box flexDirection="row" flexWrap="wrap" flexShrink={1}>
211 {nextFile && (
212 <Text color="#c05cbf" bold wrap="truncate-end">
213 {s.next ? `NEXT ${s.next}` : 'NEXT —'}
214 </Text>
215 )}
216 {nextFile && sep}
217 <Text>
218 <Text color="#26c6c8" bold>CDX </Text>
219 {win('7D', s.codex.weekly)}
220 </Text>
221 {sep}
222 <Text>
223 <Text color="#c05cbf" bold>CLD </Text>
224 {win('5H', s.claude.fiveHour)}
225 <Text color="#5a5a6e"> · </Text>
226 {win('7D', s.claude.weekly)}
227 </Text>
228 {sep}
229 <Text>
230 <Text color="#87d7ff">CTX </Text>
231 {s.contextLeft === null ? (
232 <Text color="#8a8a8a">—</Text>
233 ) : (
234 <Text color={pctColor(s.contextLeft)}>{s.contextLeft}% left</Text>
235 )}
236 </Text>
237 </Box>
238 {s.clock ? (
239 <Box flexShrink={0} marginLeft={2}>
240 <Text color="#8a8a8a">{s.clock}</Text>
241 </Box>
242 ) : null}
243 </Box>
244 )
245 })
246}
247types/index.d.ts 17 lines1export type Window = { left: number; resetsAt: number }
2
3export type Snapshot = {
4 at: number
5 next: string | null
6 clock: string | null
7 codex: { fiveHour: Window | null; weekly: Window | null }
8 claude: { fiveHour: Window | null; weekly: Window | null }
9 contextLeft: number | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'usage-band': { snap: Snapshot | null }
15 }
16}
17