SLOPSHOPPER

power-cells

Power gauge above the prompt: context tokens left, the 5-hour limit, and a weekly pacing plan that gives you a daily ration so the 7-day limit lasts to the…

newbandcommandtoast
v0.2.0no licenseupdated 2026-10-25ikentrock/starwars-claude-mods/power-cells
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · power-cells
› 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 › /power ⎿ power-cells: Power Cells hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

starwars-claude-mods

Nine local Claude Code mods (UI and hook plugins) with a light Star Wars flavor.

ModWhat it does
brain-dumpIntercepts long, unstructured prompts, asks clarifying questions, rewrites them into a structured prompt. !raw prefix skips it.
helper-deskProgress bar from the to-do list, plus a side panel for sub-agents (/helper-desk).
cache-clockPrompt-cache countdown, Keep Warm button, /keep-warm.
handoffGenerates a handoff prompt at 250k tokens, on cold cache, or on /handoff.
reflectOffers to save rules/preferences to memory, CLAUDE.md, or a second brain.
chat-inventoryZero-token "In This Chat" panel (/inventory).
switchboard/mods panel to toggle mods and hide panels.
power-cellsGauges above the prompt for context, the 5-hour limit, and a weekly pacing plan: a daily ration, work-day weighting, over/under-plan warnings. /power plan shows the week.
droid-consoleRestyles the whole UI as a droid console: personality palettes, transmission headers, droid spinner words, status band. /droid switches personality.

Status: experimental. They pass claude plugin validate and tsc, but have not been tested at runtime. Expect rough edges.

Install

Add the marketplace once, then install the mods you want (user scope makes them load in every session):

claude plugin marketplace add ikentrock/starwars-claude-mods
claude plugin install brain-dump@starwars-claude-mods --scope user
claude plugin install cache-clock@starwars-claude-mods --scope user
claude plugin install chat-inventory@starwars-claude-mods --scope user
claude plugin install droid-console@starwars-claude-mods --scope user
claude plugin install handoff@starwars-claude-mods --scope user
claude plugin install helper-desk@starwars-claude-mods --scope user
claude plugin install power-cells@starwars-claude-mods --scope user
claude plugin install reflect@starwars-claude-mods --scope user
claude plugin install switchboard@starwars-claude-mods --scope user

Or one at a time from a session prompt:

/plugin install <mod> --marketplace ikentrock/starwars-claude-mods

Update later with claude plugin marketplace update starwars-claude-mods.

Source 3 files
hooks/register.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionContextUsage } from 'claude-code'
3
4import type { PowerCell, WeekWindow } from '../types'
5import { baselineFor, computePace, dayKey, parseWorkdays, planRows } from './pace'
6import type { PaceConfig } from './pace'
7
8const cells = atom({ plugin: 'power-cells', key: 'cells' } as const, [], { shape: 'cells-1' })
9const isHidden = atom({ plugin: 'power-cells', key: 'isHidden' } as const, false, {
10  shape: 'hidden-1',
11})
12
13const week = atom({ plugin: 'power-cells', key: 'week' } as const, null, { shape: 'week-1' })
14
15type DayRecord = { date: string; windowEnd: number; baseline: number; toasts: number[] }
16
17const WARN_AT = [20, 10, 5]
18const RATION_WARN_AT = [80, 100]
19const NAMES: Record<string, string> = {
20  five_hour: 'Shields (5h)',
21  seven_day: 'Reactor (7d)',
22  spend_limit: 'Credits',
23}
24
25const compact = (n: number) =>
26  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
27
28const bar = (percent: number, width: number) => {
29  const full = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
30
31  return '█'.repeat(full) + '░'.repeat(width - full)
32}
33
34const colorFor = (percentLeft: number) =>
35  percentLeft > 40 ? 'green' : percentLeft > 15 ? 'yellow' : 'red'
36
37const until = (iso: string | undefined) => {
38  if (!iso) return ''
39  const mins = Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 60000))
40  if (mins >= 1440) return ` · resets ${Math.round(mins / 1440)}d`
41  if (mins >= 60) return ` · resets ${Math.floor(mins / 60)}h${mins % 60}m`
42
43  return ` · resets ${mins}m`
44}
45
46const measure = (context: SessionContextUsage, limits: SessionRateLimit[]): PowerCell[] => {
47  const out: PowerCell[] = []
48  if (context.percent !== undefined && context.tokens !== undefined) {
49    out.push({
50      label: 'Holocron (context)',
51      percentLeft: 100 - context.percent,
52      detail: `${compact(Math.max(0, context.window - context.tokens))} tokens left`,
53    })
54  }
55  for (const limit of limits) {
56    out.push({
57      label: NAMES[limit.kind] ?? limit.kind,
58      percentLeft: Math.max(0, 100 - limit.percentUsed),
59      detail: `${Math.max(0, 100 - limit.percentUsed).toFixed(0)}% left${until(limit.resetsAt)}`,
60    })
61  }
62
63  return out
64}
65
66// Tracks where today's counting starts (kept across sessions) and speaks up at the day's milestones.
67async function trackDay($: EngineInterface, limits: SessionRateLimit[], cfg: PaceConfig) {
68  const limit = limits.find(one => one.kind === 'seven_day')
69  const windowEnd = limit?.resetsAt ? Date.parse(limit.resetsAt) : NaN
70
71  if (!limit || Number.isNaN(windowEnd)) {
72    await update($, week, () => null)
73
74    return
75  }
76
77  const now = Date.now()
78  const today = dayKey(now)
79  const saved = (await $.store.get('day-1')) as DayRecord | undefined
80  const isSameDay =
81    saved !== undefined && saved.date === today && Math.abs(saved.windowEnd - windowEnd) < 3_600_000
82  const record: DayRecord = isSameDay
83    ? saved
84    : { date: today, windowEnd, baseline: baselineFor(now, windowEnd, limit.percentUsed), toasts: [] }
85  const window: WeekWindow = { windowEnd, baseline: record.baseline, percentUsed: limit.percentUsed }
86  const pace = computePace(now, window, cfg)
87  await update($, week, () => window)
88
89  if (!pace) return
90
91  if (!isSameDay) {
92    $.ui.toast(
93      `Rations: ${pace.budget.toFixed(1)}% today (${pace.isWork ? 'work day' : 'off day'}), ${(100 - limit.percentUsed).toFixed(0)}% left for ${pace.daysLeft.toFixed(1)} days.`,
94    )
95  }
96
97  const crossed = RATION_WARN_AT.filter(
98    t => pace.budget > 0 && (pace.used / pace.budget) * 100 >= t && !record.toasts.includes(t),
99  )
100
101  for (const t of crossed) {
102    $.ui.toast(
103      t >= 100
104        ? `Rations: today's ${pace.budget.toFixed(1)}% is spent. Tomorrow's cells are being borrowed.`
105        : `Rations: ${t}% of today's ${pace.budget.toFixed(1)}% used.`,
106    )
107  }
108
109  if (!isSameDay || crossed.length > 0) {
110    await $.store.set('day-1', { ...record, toasts: [...record.toasts, ...crossed] })
111  }
112}
113
114export const register: Register = (on, options) => {
115  const cfg: PaceConfig = {
116    workdays: parseWorkdays(String(options.workdays ?? 'mon,tue,wed,thu,fri')),
117    workWeight: Math.max(0.1, Number(options.workWeight ?? 2) || 2),
118  }
119
120  // Lowest threshold already warned about, per gauge; a recharge re-arms it.
121  const warned = new Map<string, number>()
122
123  on('session.start', async ($, e, next) => {
124    const usage = await $.session.usage()
125    await update($, cells, () => measure(usage.context, usage.rateLimits))
126    await trackDay($, usage.rateLimits, cfg)
127    await $.command.register({
128      name: 'power',
129      description: 'Show or hide the Power Cells gauge; "/power plan" shows the week\'s daily rations',
130    })
131
132    return next(e)
133  })
134
135  on('command.run', { command: 'power' }, async ($, e) => {
136    if (String(e.args ?? '').trim().toLowerCase() === 'plan') {
137      const window = await read($, week)
138      const rows = window ? planRows(Date.now(), window, cfg) : []
139
140      if (rows.length === 0) {
141        return { text: 'No weekly limit reported yet. It appears after the first reply on a subscription account.' }
142      }
143
144      const lines = rows.map(
145        row =>
146          `| ${row.label}${row.isToday ? ' (today)' : ''} | ${row.isWork ? 'work' : 'off'} | ${row.budget.toFixed(1)}% | ${row.isToday ? `${row.used.toFixed(1)}%` : ''} |`,
147      )
148
149      return {
150        text: ['| Day | Type | Ration | Used |', '|---|---|---|---|', ...lines].join('\n'),
151      }
152    }
153
154    const hidden = !(await read($, isHidden))
155    await update($, isHidden, () => hidden)
156
157    return { text: hidden ? 'Power Cells hidden.' : 'Power Cells shown.' }
158  })
159
160  on('session.measure', async ($, e, next) => {
161    const list = measure(e.context, e.rateLimits)
162    await update($, cells, () => list)
163    await trackDay($, e.rateLimits, cfg)
164
165    for (const cell of list) {
166      const last = warned.get(cell.label) ?? 101
167      if (cell.percentLeft > (WARN_AT[0] ?? 20)) {
168        warned.delete(cell.label)
169        continue
170      }
171      const crossed = WARN_AT.filter(t => cell.percentLeft <= t && t < last).sort((a, b) => a - b)[0]
172      if (crossed !== undefined) {
173        warned.set(cell.label, crossed)
174        $.ui.toast(`Power Cells: ${cell.label} at ${cell.percentLeft.toFixed(0)}%. Low on power, we are.`)
175      }
176    }
177
178    return next(e)
179  })
180
181  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
182    const list = await read($, cells)
183    if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return next(e)
184
185    const { Box, Text } = $.ui.resolve(e)
186    const columns = e.props.bodyColumns ?? 80
187    const width = columns >= 100 ? 10 : 6
188    const window = await read($, week)
189    const pace = window ? computePace(Date.now(), window, cfg) : null
190    const ratio = pace && pace.budget > 0 ? pace.used / pace.budget : 0
191    const delta = pace ? pace.weekUsed - pace.expected : 0
192
193    return (
194      <Box flexDirection="column">
195        <Box flexWrap="wrap">
196          {list.map(cell => (
197            <Box key={cell.label} marginRight={2}>
198              <Text dimColor>{columns >= 70 ? cell.label : cell.label.split(' ')[0]} </Text>
199              <Text color={colorFor(cell.percentLeft)}>{bar(cell.percentLeft, width)} </Text>
200              <Text dimColor>{cell.detail}</Text>
201            </Box>
202          ))}
203        </Box>
204        {pace && (
205          <Box flexWrap="wrap">
206            <Box marginRight={2}>
207              <Text dimColor>{columns >= 70 ? 'Ration (today)' : 'Today'} </Text>
208              <Text color={ratio >= 1 ? 'red' : ratio >= 0.85 ? 'yellow' : 'green'}>
209                {bar(ratio * 100, width)}{' '}
210              </Text>
211              <Text dimColor>
212                {pace.used.toFixed(1)} / {pace.budget.toFixed(1)}% · {pace.isWork ? 'work day' : 'off day'}
213              </Text>
214            </Box>
215            <Box marginRight={2}>
216              <Text color={delta > 4 ? 'red' : delta < -4 ? 'cyan' : 'green'}>
217                {delta > 4
218                  ? `${delta.toFixed(0)}% over plan, ease off`
219                  : delta < -4
220                    ? `${(-delta).toFixed(0)}% under plan, room to use more`
221                    : 'on plan'}
222              </Text>
223            </Box>
224            <Text dimColor>
225              {pace.daysLeft.toFixed(1)}d left · {pace.perDay.toFixed(1)}%/day avg
226            </Text>
227          </Box>
228        )}
229      </Box>
230    )
231  })
232}
233
hooks/pace.ts 130 lines
1export type PaceConfig = { workdays: number[]; workWeight: number }
2
3export type Pace = {
4  used: number
5  budget: number
6  isWork: boolean
7  weekUsed: number
8  expected: number
9  daysLeft: number
10  perDay: number
11}
12
13export type PlanRow = { label: string; isWork: boolean; isToday: boolean; budget: number; used: number }
14
15const DAY = 86_400_000
16const NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
17
18export const parseWorkdays = (text: string): number[] =>
19  text
20    .toLowerCase()
21    .split(/[\s,]+/)
22    .map(name => NAMES.indexOf(name.slice(0, 3)))
23    .filter(index => index >= 0)
24
25export const startOfDay = (time: number) => {
26  const d = new Date(time)
27
28  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
29}
30
31export const nextDay = (time: number) => {
32  const d = new Date(time)
33
34  return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
35}
36
37export const dayKey = (time: number) => {
38  const d = new Date(time)
39
40  return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
41}
42
43export const dayLabel = (time: number) => {
44  const d = new Date(time)
45
46  return `${NAMES[d.getDay()]} ${d.getDate()}`
47}
48
49const isWorkday = (time: number, cfg: PaceConfig) => cfg.workdays.includes(new Date(time).getDay())
50
51// Weight of the stretch from..to: each local day counts its share of the day times its day weight.
52const weightBetween = (from: number, to: number, cfg: PaceConfig) => {
53  let total = 0
54  let cursor = from
55
56  while (cursor < to) {
57    const dayEnd = nextDay(cursor)
58    const end = Math.min(dayEnd, to)
59    total += ((end - cursor) / (dayEnd - startOfDay(cursor))) * (isWorkday(cursor, cfg) ? cfg.workWeight : 1)
60    cursor = end
61  }
62
63  return total
64}
65
66// Where today's counting starts: this local midnight, or the window's start when it opened today.
67export const baselineFor = (now: number, windowEnd: number, percentUsed: number) =>
68  windowEnd - 7 * DAY >= startOfDay(now) ? 0 : percentUsed
69
70export const computePace = (
71  now: number,
72  week: { windowEnd: number; baseline: number; percentUsed: number },
73  cfg: PaceConfig,
74): Pace | null => {
75  const windowStart = week.windowEnd - 7 * DAY
76
77  if (week.windowEnd <= now) return null
78
79  const from = Math.max(startOfDay(now), windowStart)
80  const todayWeight = weightBetween(from, Math.min(nextDay(now), week.windowEnd), cfg)
81  const restWeight = weightBetween(from, week.windowEnd, cfg)
82  const budget = restWeight > 0 ? ((100 - week.baseline) * todayWeight) / restWeight : 0
83  const total = weightBetween(windowStart, week.windowEnd, cfg)
84  const daysLeft = (week.windowEnd - now) / DAY
85
86  return {
87    used: Math.max(0, week.percentUsed - week.baseline),
88    budget,
89    isWork: isWorkday(now, cfg),
90    weekUsed: week.percentUsed,
91    expected: total > 0 ? (100 * weightBetween(windowStart, now, cfg)) / total : 0,
92    daysLeft,
93    perDay: Math.max(0, 100 - week.percentUsed) / Math.max(daysLeft, 0.01),
94  }
95}
96
97// Today's ration, then each later day's share of what is left if today is spent exactly on budget.
98export const planRows = (
99  now: number,
100  week: { windowEnd: number; baseline: number; percentUsed: number },
101  cfg: PaceConfig,
102): PlanRow[] => {
103  const pace = computePace(now, week, cfg)
104
105  if (!pace) return []
106
107  const rows: PlanRow[] = [
108    { label: dayLabel(now), isWork: pace.isWork, isToday: true, budget: pace.budget, used: pace.used },
109  ]
110  const left = Math.max(0, 100 - week.baseline - Math.max(pace.used, pace.budget))
111  const days: number[] = []
112
113  for (let day = nextDay(now); day < week.windowEnd; day = nextDay(day)) days.push(day)
114
115  const weights = days.map(day => weightBetween(day, Math.min(nextDay(day), week.windowEnd), cfg))
116  const sum = weights.reduce((a, b) => a + b, 0)
117
118  days.forEach((day, i) =>
119    rows.push({
120      label: dayLabel(day),
121      isWork: isWorkday(day, cfg),
122      isToday: false,
123      budget: sum > 0 ? (left * (weights[i] ?? 0)) / sum : 0,
124      used: 0,
125    }),
126  )
127
128  return rows
129}
130
types/index.d.ts 13 lines
1export type PowerCell = { label: string; percentLeft: number; detail: string }
2export type WeekWindow = { windowEnd: number; baseline: number; percentUsed: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'power-cells': {
7      cells: Shaped<PowerCell[]>
8      isHidden: Shaped<boolean>
9      week: Shaped<WeekWindow | null>
10    }
11  }
12}
13