[Experimental] Every omp provider's remaining quota in-session — a /quota band above the prompt, /quota refresh, and a toast when a provider worsens from ok…

Shows every omp provider's remaining quota inside a Claude Code session, as one Claude Mod (a function-hooks module, hooks/register.ts).
/quota: toggles a compact table above the prompt, one line per provider./quota refresh: drops omp's cache and fetches fresh quota, without a model turn.ok.Needs Claude Code 2.1.287 or later, where Claude Mods are on by default. Built and tested against Claude Code 2.1.287.
Each fetch runs omp usage --json with PI_CODING_AGENT_DIR set to $HOME/.omp/agent and a 30 s limit (a cold fetch across all providers took about 10 s on 2026-09-25). The override matters: Claude Code may pass down a PI_CODING_AGENT_DIR of its own (pi-dispatch sets one), and omp reading that home answers with exit 0 and no providers. omp is found on Claude Code's PATH (it usually lives in ~/.bun/bin). With HOME unset, omp is not run, the band reads Unavailable: HOME is unset, and /quota refresh answers omp quota refresh failed: HOME is unset.
Quota is fetched once at session start and then every 5 minutes. A failed fetch leaves the poll running; there is no other retry. A refused /quota registration leaves it running too. When a reload re-fires session start, the previous poll is cancelled first, so the cadence never doubles.
Nothing waits on omp. Session start (which Claude Code awaits before the first prompt) starts the fetch without awaiting it, and the module hooks no tool-call or prompt event, so no tool call or prompt is ever held up by a slow omp.
remainingFraction when it is a number, else 1 − usedFraction, else none; clamped to 0–100%. A provider's share is the lowest of its limits' shares. Shown as a rounded percentage, or — when none is computable.exhausted > warning > ok); limits without one are ignored, and a provider with none has no status. Reports with the same provider name merge into one provider; a report without one is listed as (unnamed).ok in the previous good fetch and is warning or exhausted now. The first good fetch has no previous; warning → exhausted, a provider without a status on either side, and a newly appearing provider do not count.metadata (email, account id, endpoint) and each limit's scope never reach a toast, the transcript, or the band.When a good fetch finds providers that worsened since the previous good fetch (see Worsened provider above), one in-session toast names them all: omp quota: openai-codex now warning, anthropic now exhausted. Failed fetches in between do not reset the comparison. There is no OS notification; a worsening that happens while you are in another window shows in the band, if it is on, when you return.
/quota toggles the band above the prompt on or off and prints nothing in the transcript. Only this command changes the band — a poll or a worsening never does. The choice is kept in the plugin's store (key band) and read at session start, so the band stays as you left it; with nothing stored, or a store that fails, it starts off. A store that fails on the toggle still flips the band for this session./quota refresh runs omp usage invalidate, then fetches, both against the same omp home, and answers one line: omp quota refreshed or omp quota refresh failed: <reason> (the band then shows the failure in its notice line). When the invalidate fails, the fetch still runs and the answer ends in (cache not invalidated). It does not change the band and needs no model turn./quota <anything else> answers usage: /quota [refresh] and runs nothing.The band is the strip directly above the prompt input. While /quota has it on, it shows a dim notice line while fetching (Fetching omp usage), when no fetch has succeeded (Unavailable: <reason>), or when the latest fetch failed (Stale: <reason>; showing data from <age> ago). Then one line per provider that reports limits, from the least share left to the most, for example:
cursor Monthly 0% 11h 25m exhausted
openai-codex 5 hours 100% 4h 59m 7 days 6% 1d 11h warning
anthropic 5 Hour 86% 1h 44m 7 Day 94% 5d 15h
Each line holds one cell per window (omp's window label, or the limit label when it has none), soonest reset first: <window> <share> <time to reset>, plus the window's status only when it is warning or exhausted. A window's share and reset come from its limit with the least share left (the first one when none has a share), so repeated pools such as antigravity's shared Claude & GPT limits collapse into one cell. Cells are padded so the windows line up across providers. A provider's order uses its lowest share across all limits; providers without a share go last, and one reporting no limits (such as ollama-cloud) is left out. Time to reset reads Xd Yh, Xh Ym, or Ym, now when due, and — when omp gives none. Each share is colored by the percentage shown: red for 0–30%, orange for 31–60%, green for 61–100%; — stays uncolored.
Every line is cut at the band's width with an ellipsis rather than wrapped. The band yields to a survey while one holds it, shares its space with any band another mod draws there (/cal, for one) instead of hiding it, and redraws after every settled fetch, failed ones included, so it always shows the latest data the module holds. Collapse it with ctrl+x ctrl+a (or its [-] mark) without turning it off. It is drawn from Box and Text only, with the props flexDirection, color, dimColor, and wrap.
claude plugin test omp-quota
The test kit is hermetic: no filesystem, network, or process; every $ call a test makes is answered by a stub beneath the plugin.
tests/fixtures/snapshot.ts is a redacted copy of a live omp usage --json taken on 2026-09-18. To refresh it, capture a new snapshot, remove every metadata object, every scope.projectId and scope.accountId, and resetCredits, then paste it in. The first test in tests/snapshot.test.ts fails while any account key or any string holding @ remains.
hooks/register.ts 157 lines1import type { On } from 'claude-code'
2import { quotaModelOf } from './quota-model.ts'
3import type { QuotaView, WindowCell } from './quota-model.ts'
4import { readUsage, worsenedProviders } from './usage.ts'
5import type { OmpOutcome, UsageReading } from './usage.ts'
6
7const FETCH_ARGV = ['omp', 'usage', '--json']
8const INVALIDATE_ARGV = ['omp', 'usage', 'invalidate']
9const OMP_TIMEOUT_MS = 30_000
10const POLL_MS = 300_000
11const BAND_KEY = 'band'
12
13let view: QuotaView = { usage: null, failure: null, lastGoodAt: null }
14let bandOn = false
15let poll: { cancel(): void } | null = null
16let fetchesStarted = 0
17let lastPublished = 0
18
19async function runOmp($: any, home: string, argv: string[]): Promise<OmpOutcome> {
20 try {
21 // No shell, so no `~`; and env can only override the PI_CODING_AGENT_DIR that
22 // Claude Code's own settings pass down, never unset it.
23 const result = await $.process.run(argv, {
24 env: { PI_CODING_AGENT_DIR: `${home}/.omp/agent` },
25 timeoutMs: OMP_TIMEOUT_MS,
26 })
27 return { kind: 'exited', exitCode: result.exitCode, stdout: result.stdout }
28 } catch {
29 return { kind: 'rejected' }
30 }
31}
32
33async function fetchUsage($: any): Promise<UsageReading> {
34 const home = await $.env.get('HOME')
35 if (!home) return { ok: false, reason: 'HOME is unset' }
36 return readUsage(await runOmp($, home, FETCH_ARGV))
37}
38
39// A fetch that started before one already published would put older data back on screen.
40async function publish($: any, seq: number, reading: UsageReading) {
41 const now = await $.clock.now()
42 if (seq < lastPublished) return
43 lastPublished = seq
44 if (reading.ok) {
45 const worsened = worsenedProviders(view.usage, reading.usage)
46 view = { usage: reading.usage, failure: null, lastGoodAt: now }
47 if (worsened.length) $.ui.toast(`omp quota: ${worsened.map((w) => `${w.provider} now ${w.status}`).join(', ')}`)
48 } else {
49 view = { ...view, failure: reading.reason }
50 }
51 $.ui.invalidate('ui.render')
52}
53
54async function fetchAndPublish($: any) {
55 const seq = ++fetchesStarted
56 await publish($, seq, await fetchUsage($))
57}
58
59async function refresh($: any) {
60 const home = await $.env.get('HOME')
61 if (!home) {
62 await publish($, ++fetchesStarted, { ok: false, reason: 'HOME is unset' })
63 return { text: 'omp quota refresh failed: HOME is unset' }
64 }
65 const invalidated = await runOmp($, home, INVALIDATE_ARGV)
66 const seq = ++fetchesStarted
67 const reading = readUsage(await runOmp($, home, FETCH_ARGV))
68 await publish($, seq, reading)
69 const note = invalidated.kind === 'exited' && invalidated.exitCode === 0 ? '' : ' (cache not invalidated)'
70 return { text: reading.ok ? `omp quota refreshed${note}` : `omp quota refresh failed: ${reading.reason}${note}` }
71}
72
73async function readBand($: any) {
74 try {
75 bandOn = (await $.store.get(BAND_KEY)) === true
76 } catch {
77 bandOn = false
78 }
79}
80
81async function toggleBand($: any) {
82 bandOn = !bandOn
83 try {
84 await $.store.set(BAND_KEY, bandOn)
85 } catch {
86 // The band still toggles for this session; only the next one will not remember it.
87 }
88 $.ui.invalidate('ui.render')
89 return {}
90}
91
92// Every window cell is padded to the widest one in its column, so windows line up across
93// providers; a line's last cell is left unpadded so no line ends in spaces.
94async function renderBand($: any, e: any, beneath: unknown) {
95 const { Box, Text } = await $.ui.resolve(e)
96 const model = quotaModelOf(view, await $.clock.now())
97 const cells = model.providers.flatMap((p) => p.windows)
98 const nameWidth = Math.max(0, ...model.providers.map((p) => p.provider.length))
99 const windowWidth = Math.max(0, ...cells.map((c) => c.window.length))
100 const cellText = (c: WindowCell) =>
101 `${c.window.padEnd(windowWidth)} ${c.share.padStart(4)} ${c.resets}${c.status ? ` ${c.status}` : ''}`
102 const columnWidths: number[] = []
103 for (const { windows } of model.providers) {
104 windows.slice(0, -1).forEach((c, i) => (columnWidths[i] = Math.max(columnWidths[i] ?? 0, cellText(c).length)))
105 }
106 const cellSpans = (c: WindowCell, i: number, last: boolean) => {
107 const padded = last ? cellText(c) : cellText(c).padEnd(columnWidths[i]!)
108 const at = windowWidth + 1 + 4 - c.share.length
109 const share = c.shareColor ? Text({ color: c.shareColor, children: [c.share] }) : c.share
110 return [' ', padded.slice(0, at), share, padded.slice(at + c.share.length)]
111 }
112 const lines = []
113 if (model.notice) lines.push(Text({ dimColor: true, wrap: 'truncate-end', children: [model.notice] }))
114 for (const { provider, windows } of model.providers) {
115 const spans = windows.flatMap((c, i) => cellSpans(c, i, i === windows.length - 1))
116 lines.push(Text({ wrap: 'truncate-end', children: [provider.padEnd(nameWidth), ...spans] }))
117 }
118 if (beneath) lines.push(beneath)
119 return Box({ flexDirection: 'column', children: lines })
120}
121
122export function register(on: On) {
123 on('session.start', async ($, e, next) => {
124 try {
125 await $.command.register({
126 name: 'quota',
127 description: 'Show omp provider quota',
128 argumentHint: '[refresh]',
129 immediate: true,
130 })
131 } catch {
132 // A refused /quota leaves the poll working.
133 }
134 await readBand($)
135 void fetchAndPublish($).catch(() => {})
136 // A reload re-fires session.start on this instance; a second timer would double the cadence.
137 poll?.cancel()
138 poll = $.clock.every(POLL_MS, () => {
139 void fetchAndPublish($).catch(() => {})
140 })
141 return next(e)
142 })
143
144 on('command.run', { command: 'quota' }, async ($, e) => {
145 const args = (e.args ?? '').trim()
146 if (args === '') return toggleBand($)
147 if (args === 'refresh') return refresh($)
148 return { text: 'usage: /quota [refresh]' }
149 })
150
151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152 if (!bandOn || e.props.hasSurvey) return next(e)
153 // The band is one instance: a tree that drops next(e) hides every other plugin's band.
154 return renderBand($, e, await next(e))
155 })
156}
157hooks/quota-model.ts 92 lines1import type { LimitQuota, Usage } from './usage.ts'
2
3export type QuotaView = { usage: Usage | null; failure: string | null; lastGoodAt: number | null }
4
5export type WindowCell = { window: string; share: string; shareColor?: string; status: string | null; resets: string }
6
7export type ProviderSection = { provider: string; windows: WindowCell[] }
8
9export type QuotaModel = { notice: string | null; providers: ProviderSection[] }
10
11const MINUTE = 60_000
12const HOUR = 60 * MINUTE
13const DAY = 24 * HOUR
14
15const RED = '#e5484d'
16const ORANGE = '#f5a524'
17const GREEN = '#46a758'
18
19function percentOf(share: number | null): string {
20 return share === null ? '—' : `${Math.round(share * 100)}%`
21}
22
23export function shareColorOf(share: number | null): string | undefined {
24 if (share === null) return undefined
25 const percent = Math.round(share * 100)
26 return percent <= 30 ? RED : percent <= 60 ? ORANGE : GREEN
27}
28
29function durationOf(ms: number): string {
30 if (ms >= DAY) return `${Math.floor(ms / DAY)}d ${Math.floor((ms % DAY) / HOUR)}h`
31 if (ms >= HOUR) return `${Math.floor(ms / HOUR)}h ${Math.floor((ms % HOUR) / MINUTE)}m`
32 return `${Math.floor(ms / MINUTE)}m`
33}
34
35function resetsOf(resetsAt: number | null, nowMs: number): string {
36 if (resetsAt === null) return '—'
37 return resetsAt <= nowMs ? 'now' : durationOf(resetsAt - nowMs)
38}
39
40function leastIndexOf(limits: LimitQuota[]): number {
41 let least = 0
42 limits.forEach((limit, i) => {
43 const share = limits[least]!.share
44 if (limit.share !== null && (share === null || limit.share < share)) least = i
45 })
46 return least
47}
48
49function worstBadStatusOf(limits: LimitQuota[]): string | null {
50 if (limits.some((l) => l.status === 'exhausted')) return 'exhausted'
51 return limits.some((l) => l.status === 'warning') ? 'warning' : null
52}
53
54function byNullLast(a: number | null, b: number | null): number {
55 return (a ?? Infinity) - (b ?? Infinity)
56}
57
58function windowsOf(limits: LimitQuota[], nowMs: number): WindowCell[] {
59 const groups = new Map<string, LimitQuota[]>()
60 for (const limit of limits) {
61 const key = limit.windowLabel || limit.label
62 groups.set(key, [...(groups.get(key) ?? []), limit])
63 }
64 return [...groups]
65 .map(([window, members]) => ({ window, members, least: members[leastIndexOf(members)]! }))
66 .sort((a, b) => byNullLast(a.least.resetsAt, b.least.resetsAt))
67 .map(({ window, members, least }) => ({
68 window,
69 share: percentOf(least.share),
70 shareColor: shareColorOf(least.share),
71 status: worstBadStatusOf(members),
72 resets: resetsOf(least.resetsAt, nowMs),
73 }))
74}
75
76function sectionsOf(usage: Usage, nowMs: number): ProviderSection[] {
77 return usage.providers
78 .filter((provider) => provider.limits.length)
79 .sort((a, b) => byNullLast(a.share, b.share))
80 .map((provider) => ({ provider: provider.provider, windows: windowsOf(provider.limits, nowMs) }))
81}
82
83export function quotaModelOf(view: QuotaView, nowMs: number): QuotaModel {
84 if (!view.usage) {
85 return { notice: view.failure ? `Unavailable: ${view.failure}` : 'Fetching omp usage', providers: [] }
86 }
87 const notice = view.failure
88 ? `Stale: ${view.failure}; showing data from ${durationOf(nowMs - (view.lastGoodAt ?? nowMs))} ago`
89 : null
90 return { notice, providers: sectionsOf(view.usage, nowMs) }
91}
92hooks/usage.ts 109 lines1export type QuotaStatus = 'ok' | 'warning' | 'exhausted'
2
3export type LimitQuota = {
4 id: string
5 label: string
6 windowLabel: string
7 resetsAt: number | null
8 share: number | null
9 status: QuotaStatus | null
10}
11
12export type ProviderQuota = {
13 provider: string
14 limits: LimitQuota[]
15 share: number | null
16 status: QuotaStatus | null
17}
18
19export type Usage = { providers: ProviderQuota[] }
20
21export type OmpOutcome = { kind: 'exited'; exitCode: number; stdout: string } | { kind: 'rejected' }
22
23export type UsageReading = { ok: true; usage: Usage } | { ok: false; reason: string }
24
25const STATUS_RANK: Record<QuotaStatus, number> = { ok: 0, warning: 1, exhausted: 2 }
26
27function isFiniteNumber(value: unknown): value is number {
28 return typeof value === 'number' && Number.isFinite(value)
29}
30
31function field(value: unknown, key: string): unknown {
32 return value !== null && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined
33}
34
35export function remainingShareOf(amount: unknown): number | null {
36 const remaining = field(amount, 'remainingFraction')
37 const used = field(amount, 'usedFraction')
38 const share = isFiniteNumber(remaining) ? remaining : isFiniteNumber(used) ? 1 - used : null
39 return share === null ? null : Math.min(1, Math.max(0, share))
40}
41
42function statusOf(value: unknown): QuotaStatus | null {
43 return value === 'ok' || value === 'warning' || value === 'exhausted' ? value : null
44}
45
46function stringOf(value: unknown): string {
47 return typeof value === 'string' ? value : ''
48}
49
50function limitOf(raw: unknown): LimitQuota | null {
51 const id = field(raw, 'id')
52 if (typeof id !== 'string') return null
53 const window = field(raw, 'window')
54 const resetsAt = field(window, 'resetsAt')
55 return {
56 id,
57 label: stringOf(field(raw, 'label')),
58 windowLabel: stringOf(field(window, 'label')),
59 resetsAt: isFiniteNumber(resetsAt) ? resetsAt : null,
60 share: remainingShareOf(field(raw, 'amount')),
61 status: statusOf(field(raw, 'status')),
62 }
63}
64
65function providerOf(provider: string, limits: LimitQuota[]): ProviderQuota {
66 const shares = limits.map((l) => l.share).filter((s): s is number => s !== null)
67 const statuses = limits.map((l) => l.status).filter((s): s is QuotaStatus => s !== null)
68 return {
69 provider,
70 limits,
71 share: shares.length ? Math.min(...shares) : null,
72 status: statuses.length ? statuses.reduce((a, b) => (STATUS_RANK[b] > STATUS_RANK[a] ? b : a)) : null,
73 }
74}
75
76export function readUsage(outcome: OmpOutcome): UsageReading {
77 if (outcome.kind === 'rejected') return { ok: false, reason: 'omp did not answer' }
78 if (outcome.exitCode !== 0) return { ok: false, reason: `omp exited ${outcome.exitCode}` }
79 let parsed: unknown
80 try {
81 parsed = JSON.parse(outcome.stdout)
82 } catch {
83 return { ok: false, reason: 'omp output unreadable' }
84 }
85 const reports = field(parsed, 'reports')
86 if (!Array.isArray(reports)) return { ok: false, reason: 'omp output unreadable' }
87 if (reports.length === 0) return { ok: false, reason: 'omp reported no providers' }
88 const grouped = new Map<string, LimitQuota[]>()
89 for (const report of reports) {
90 const provider = stringOf(field(report, 'provider')) || '(unnamed)'
91 const rawLimits = field(report, 'limits')
92 const limits = (Array.isArray(rawLimits) ? rawLimits : []).map(limitOf).filter((l): l is LimitQuota => l !== null)
93 grouped.set(provider, [...(grouped.get(provider) ?? []), ...limits])
94 }
95 return { ok: true, usage: { providers: [...grouped].map(([provider, limits]) => providerOf(provider, limits)) } }
96}
97
98export type Worsened = { provider: string; status: 'warning' | 'exhausted' }
99
100export function worsenedProviders(previous: Usage | null, current: Usage): Worsened[] {
101 if (!previous) return []
102 const before = new Map(previous.providers.map((p) => [p.provider, p.status]))
103 return current.providers.flatMap((p) =>
104 before.get(p.provider) === 'ok' && (p.status === 'warning' || p.status === 'exhausted')
105 ? [{ provider: p.provider, status: p.status }]
106 : [],
107 )
108}
109