SLOPSHOPPER

Oil

An oil gauge above the prompt: this week's Fable use, beside Gas Gauge.

bandcommandstatus
v0.1.0MITupdated 2026-10-11DJPalefaceSD/rostech-mods/plugins/oil
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oil
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /oil ⎿ oil: No reading yet. The gauge fills after Claude answers once. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🛢️ Oil

Gas Gauge's neighbour. A band above the prompt that shows this week's Fable use.

What it shows

  • Fable's token count this week. Oil counts every model response itself, the main thread and subagents alike, and adds up the ones Fable answered. Cache reads are left out.
  • The week runs to your plan's weekly refill, read off the same window Gas Gauge reads.
  • Your Fable limit, when it can. Claude plans have a separate weekly limit for Fable. Claude Code does not yet give that window to plugins, so Oil says so instead of guessing. If it ever arrives, Oil draws it as a gauge, like Gas Gauge.

Commands

  • /oil prints the numbers.
  • /oil hide moves it to the status line. /oil show brings the band back.

Privacy

Everything stays on your machine: a count in the plugin's own store. Nothing is sent anywhere.

More mods

See every Rostech mod, live, at rostech.app.

Source 2 files
hooks/register.tsx 167 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, TurnUsage } from 'claude-code'
3
4import type { Limit, Week } from '../types'
5
6// His idea, 3 Oct 2026: "Oil - shows your Fable use similar to gas gauge".
7//
8// The engine reports the plan's windows (5-hour, week) but not what each model
9// used. So Oil counts it: every model response, main thread and subagents alike,
10// carries the model that answered and its tokens. Fable's tokens over everyone's,
11// since the plan's week last refilled, is the oil level. If the plan ever reports
12// a window of Fable's own, Oil reads that instead, the way Gas Gauge does.
13
14const week = atom({ plugin: 'oil', key: 'week' } as const, { resetsAt: '', fable: 0, all: 0 } as Week)
15const limit = atom({ plugin: 'oil', key: 'limit' } as const, null as Limit | null)
16const isHidden = atom({ plugin: 'oil', key: 'isHidden' } as const, false)
17
18const STORE_KEY = 'week'
19const CELLS = 6
20
21export function isFable(model: string): boolean {
22  return model.toLowerCase().includes('fable')
23}
24
25// Cache reads are left out: they are the cheap re-read of a conversation, and on a
26// long session they would drown out the work itself.
27export function tokens(u: TurnUsage): number {
28  return u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
29}
30
31// The level, in percent: Fable's share of the week's work, or how much of
32// Fable's own window is left when the plan reports one.
33export function level(w: Week, l: Limit | null): number {
34  if (l) return Math.max(0, Math.round(100 - l.percentUsed))
35  return w.all > 0 ? Math.round((w.fable / w.all) * 100) : 0
36}
37
38export function size(n: number): string {
39  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
40  if (n >= 1_000) return `${Math.round(n / 1_000)}k`
41  return String(n)
42}
43
44function halves(pct: number): [string, string] {
45  const full = Math.round((pct / 100) * CELLS)
46  const cells = '█'.repeat(full) + '░'.repeat(CELLS - full)
47  return [cells.slice(0, CELLS / 2), cells.slice(CELLS / 2)]
48}
49
50export function words(w: Week, l: Limit | null): string {
51  if (l) return `Fable ${level(w, l)}% left`
52  return `Fable ${size(w.fable)} tokens this week`
53}
54
55// A new week starts when the plan's week window moves to a later refill.
56export function rollover(w: Week, resetsAt: string | undefined): Week {
57  if (!resetsAt || resetsAt === w.resetsAt) return w
58  if (w.resetsAt && new Date(resetsAt) <= new Date(w.resetsAt)) return w
59  return { resetsAt, fable: w.resetsAt ? 0 : w.fable, all: w.resetsAt ? 0 : w.all }
60}
61
62function fableWindow(all: readonly SessionRateLimit[]): Limit | null {
63  return all.find(t => isFable(t.kind)) ?? null
64}
65
66// Every session on this machine adds to the same week, kept in the store.
67async function load($: EngineInterface): Promise<Week> {
68  const kept = (await $.store.get(STORE_KEY)) as Week | undefined
69  return kept && typeof kept.all === 'number' ? kept : { resetsAt: '', fable: 0, all: 0 }
70}
71
72export const register: Register = on => {
73  on('session.start', async ($, e, next) => {
74    await $.command.register({ name: 'oil', description: "Show this week's Fable use. /oil hide or /oil show for the band." })
75    const usage = await $.session.usage()
76    await $.store.set('windows', usage.rateLimits)
77    const seven = usage.rateLimits.find(t => t.kind === 'seven_day')
78    const w = rollover(await load($), seven?.resetsAt)
79    await $.store.set(STORE_KEY, w)
80    await update($, week, () => w)
81    await update($, limit, () => fableWindow(usage.rateLimits))
82    return next(e)
83  })
84
85  on('session.measure', async ($, e, next) => {
86    // Every window the engine reports, kept as received, so a window by another name can be found.
87    await $.store.set('windows', e.rateLimits)
88    if (e.changed.includes('rateLimits')) {
89      const seven = e.rateLimits.find(t => t.kind === 'seven_day')
90      const w = rollover(await load($), seven?.resetsAt)
91      await $.store.set(STORE_KEY, w)
92      await update($, week, () => w)
93      await update($, limit, () => fableWindow(e.rateLimits))
94    }
95    return next(e)
96  })
97
98  // Every model response, main thread and subagents, says which model answered.
99  on('turn.step', async function* ($, e, next) {
100    const r = yield* next(e)
101    if (r.usage) {
102      const used = tokens(r.usage)
103      const kept = await load($)
104      const w: Week = { ...kept, all: kept.all + used, fable: kept.fable + (isFable(r.usage.model) ? used : 0) }
105      await $.store.set(STORE_KEY, w)
106      await update($, week, () => w)
107      if ((await read($, isHidden)) && w.fable > 0) $.ui.status(`🛢️ ${words(w, await read($, limit))}`)
108    }
109    return r
110  })
111
112  on('command.run', { command: 'oil' }, async ($, e) => {
113    const arg = e.args.trim().toLowerCase()
114    if (arg === 'hide' || arg === 'show') {
115      await update($, isHidden, () => arg === 'hide')
116      if (arg === 'show') $.ui.status(undefined)
117      return { text: arg === 'hide' ? 'Band hidden. The status line keeps the number.' : 'Band showing.' }
118    }
119    const w = await read($, week)
120    const l = await read($, limit)
121    if (w.all === 0 && !l) return { text: 'No reading yet. The gauge fills after Claude answers once.' }
122    return { text: `${words(w, l)}\nAll models this week: ${size(w.all)} tokens. Counted on this machine, cache reads left out.` }
123  })
124
125  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
126    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
127    const w = await read($, week)
128    const l = await read($, limit)
129    if (w.fable === 0 && !l) return next(e)
130
131    const { Box, Text } = $.ui.resolve(e)
132    const below = await next(e)
133    // His catch, 3 Oct: the share of the work read 82% while his Fable limit read 49%.
134    // The engine sends mods the 5-hour and week windows only, never Fable's, so
135    // without one Oil shows the count it can stand behind and no percent at all.
136    if (!l) {
137      return (
138        <Box flexDirection="column">
139          <Box flexDirection="row" flexWrap="wrap">
140            <Text>🛢️ Fable  </Text>
141            <Text color="magenta" bold>{size(w.fable)} tokens</Text>
142            <Text dimColor> this week · your Fable limit is not shown to mods</Text>
143          </Box>
144          {below}
145        </Box>
146      )
147    }
148    const pct = level(w, l)
149    const [low, high] = halves(pct)
150    return (
151      <Box flexDirection="column">
152        <Box flexDirection="row" flexWrap="wrap">
153          <Text>🛢️ Fable  </Text>
154          <Text bold>L </Text>
155          <Text color="magenta">{low}</Text>
156          <Text bold> ½ </Text>
157          <Text color="magenta">{high}</Text>
158          <Text bold> H</Text>
159          <Text color="magenta" bold>  {pct}% </Text>
160          <Text dimColor>left</Text>
161        </Box>
162        {below}
163      </Box>
164    )
165  })
166}
167
types/index.d.ts 11 lines
1// This week's work, counted off every model response: all of it, and Fable's share.
2export type Week = { resetsAt: string; fable: number; all: number }
3// A plan window that names Fable, if the plan ever reports one.
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    oil: { week: Week; limit: Limit | null; isHidden: boolean }
9  }
10}
11