SLOPSHOPPER

golfo

Il Golfo: what Claude Code costs you day by day, across sessions and projects, with a weekly budget that warns you at 80% and 100%

newpanecommandtoast
v0.1.0no licenseupdated 2026-10-25pompeitech/affreschi/plugins/golfo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · golfo
│ ┃ Il Golfo ✕ › fix the failing auth test and add an audit log call │ ┃ ≋ Il Golfo · last 7 days · 0 tokens $0.00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ve 03 $0.00 0 ⎿ Read 6 lines │ ┃ sa 04 $0.00 0 ⏺ Update(src/auth.ts) │ ┃ do 05 $0.00 0 ⎿ Added 2 lines, removed 1 line │ ┃ lu 06 $0.00 0 ⏺ Bash(bun test) │ ┃ ma 07 $0.00 0 ⎿ 3 pass, 1 fail │ ┃ me 08 $0.00 0 │ ┃ oggi 09 $0.00 0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Nothing spent in the last seven days. ✻ Worked for 42s · done 4:20 PM │ │ › /golfo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Il Golfo
≋ Il Golfo · last 7 days · 0 tokens $0.00 ve 03 $0.00 0 sa 04 $0.00 0 do 05 $0.00 0 lu 06 $0.00 0 ma 07 $0.00 0 me 08 $0.00 0 oggi 09 $0.00 0 Nothing spent in the last seven days.
README

affreschi

Claude Code interface mods, collected in a private pompeitech marketplace. Just as frescoes decorated the walls of Pompeii's houses, these mods decorate Claude Code's interface.

Layout

affreschi/
├── .claude-plugin/marketplace.json   # the list of mods
└── plugins/
    └── <mod-name>/                   # one folder (one plugin) per mod
        ├── .claude-plugin/plugin.json
        ├── hooks/
        │   ├── hooks.json
        │   ├── palette.ts            # Vesuvius colors
        │   └── register.tsx
        ├── types/index.d.ts          # the mod's $.state contract, when it has one
        └── tests/

Adding a mod

  1. Create the folder plugins/<mod-name>/ with its files.
  2. Add it to the plugins array in .claude-plugin/marketplace.json:
   { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
  1. Check it: claude plugin validate ./plugins/<mod-name>

Development

Load a mod for the current session only (it reloads when you save):

claude --plugin-dir ./plugins/<mod-name>

Installing

/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi

The mods

ModWhat it doesWhere it shows
vesuvius-themeA spinner with a glowing crater and volcanic words, a stripe of color beside every tool call (magma while it runs, lava when it fails, ash once done), and the end-of-turn line as an eruption (▲ Erupted for 1m 4s)Terminal; on the desktop, only the tool call rows
agent-usage/agent-usage opens a live pane (it polls every 2s while an agent runs) where every agent plays a character cast from its type or name: Pulcinella for the main conversation, the artist (beret and brush) for frontend and UI work, the nerd (glasses) for planners and architects, the explorer (head lamp), the reviewer (monocle), the guard (helmet) for security, the tester (lab mask) and the handyman (flat cap) for the rest; their eyes follow the status. In scena holds the main conversation, the agents at work and the ones that did something this turn; at the next prompt the finished ones move to the archivio, one row per role with count, tokens and failures (▸ opens it, svuota archivio clears it and keeps its tokens in the totals). Press a name for model, input/output/cache tokens, requests, tool calls and run time. Teammates and agents started before the mod loaded are listed too. The status line shows the subagents running, and a toast says what each one consumed when it finishesTerminal and desktop
pulcinellaThe band above the prompt: Pulcinella in true pixel art: an SVG of 14×16 pixels in the desktop app, a compact 10×10 drawn two pixels to a character with half blocks (five lines) in the terminal (floppy hat, black half mask, pink cheeks, white blouse), animated two frames per mood (the hat sways and a hammer swings while a tool runs, he blinks at rest, a little z floats while he naps), beside the context window, read again after every model request: the tokens used, the headroom before compaction (vineyard green, then magma and lava past 60% and 85% of the threshold), an estimate of the turns left at the recent pace, the window split by category in one bar, and the largest categories as chips (hide/legend toggles them). details or /context-map opens a pane with every category's tokens and share, the auto-compact threshold, and what fills the window: memory files, MCP servers, custom agents, skills He frets when a command fails, cheers green tests, gasps at changes of 300+ lines, naps after ten quiet minutes, and his line in Neapolitan sits under the bar. His hat grows a pixel per level with the tokens you spend (1M, 10M, 50M, 200M), kept across sessionsTerminal and desktop
maschere/maschera opens a picker (or /maschera arlecchino puts one on at once) and Claude answers in that mask's voice: Pulcinella warm and ironic with a Neapolitan word now and then, Arlecchino as terse as it gets, Balanzone thorough with the reasons and trade-offs. Only the tone changes: the mask's section goes last in the system prompt and says every other instruction wins. The mask worn shows in the status line and is remembered for the next sessionTerminal and desktop
spaccanapoliA row above the prompt with the git branch and its base, commits ahead and behind, files changed with lines added and removed (and a bar of the two), and the branch's PR from gh (open, draft, mergeable, conflicts). Changes made right on the base branch turn it into a red warning. Read again every 10s, after every tool that can change files and at the end of a turn; outside a repository it stays away. Next to pulcinella each keeps the other's bandTerminal and desktop
golfo/golfo opens Il Golfo: the last seven days as bars of cost and tokens, today and the priciest day picked out, and the week's cost by project, recorded across sessions (90 days kept). With a weeklyBudget set in the plugin's options, a bar shows how much of it is spent, and a toast warns at 80% and 100%Terminal and desktop
lazzaroneWhen Claude has been waiting on you for minutes (3 by default, in the plugin's options): a permission, a question, a plan to approve, or a turn of two minutes or more just finished, a system notification says what it waits for. One call per wait; answering or writing a prompt clears themTerminal and desktop
chiaveCounts, per project, the calls you approve by hand, as the rule that would allow them (Bash(npm run lint:*), WebFetch(domain:docs.github.com), an MCP tool), with a line under the permission dialog that keeps the tally. Past threshold approvals (3 by default) a toast says a rule is ready, and /chiave offers it: Consenti nel progetto writes it into .claude/settings.json, Solo per me into .claude/settings.local.json, Ignora never offers it again, togli takes an added rule back. Risky commands (rm, sudo, git push, curl, mv…) and chained or piped ones are never offered; a settings file that is not plain JSON is left aloneTerminal and desktop

The palette

The colors live in each mod's hooks/palette.ts (a plugin cannot import another plugin's files, so each keeps a copy). They are placeholders, to be replaced with the Vesuvius design system's.

Tests

claude plugin validate ./plugins/<mod-name>
claude plugin test ./plugins/<mod-name>
npx -y @biomejs/biome@2.2.4 check plugins

Code follows the officina standards: Biome in StandardJS style (biome.json), braced blocks set off by blank lines, no as casts beyond as const (values the engine hands over as unknown are narrowed by checks), and failures logged to the debug log with $.ui.log(..., { to: 'debug' }), never swallowed.

Source 4 files
hooks/register.tsx 220 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Days } from '../types'
5import {
6  daysFrom,
7  formatTokens,
8  formatUsd,
9  lastWeek,
10  markOf,
11  projectsOf,
12  record,
13  sumOf,
14  weekdayOf
15} from './ledger'
16import { VESUVIUS } from './palette'
17
18const PANE = 'golfo'
19const DAYS_KEY = 'days'
20const ALERT_KEY = 'alert'
21const WEEK_MS = 7 * 24 * 60 * 60 * 1000
22const TRACK = '#3A3A3A'
23const TOP_PROJECTS = 4
24
25const NO_DAYS: Days = {}
26const NOT_COUNTED: number | null = null
27const days = atom({ plugin: 'golfo', key: 'days' } as const, NO_DAYS)
28const counted = atom({ plugin: 'golfo', key: 'counted' } as const, NOT_COUNTED)
29
30// The module's own: the project the session works in, and tokens used since the last turn ended.
31let project = ''
32let pendingTokens = 0
33
34function logFailure($: EngineInterface, what: string, error: unknown): void {
35  const reason = error instanceof Error ? error.message : String(error)
36  $.ui.log(`golfo: ${what} failed: ${reason}`, { to: 'debug' })
37}
38
39async function sessionCost($: EngineInterface): Promise<number> {
40  const usage = await $.session.usage().catch(error => {
41    logFailure($, 'reading the cost', error)
42
43    return undefined
44  })
45
46  return usage?.cost?.usd ?? 0
47}
48
49// The last budget mark announced and when, read back from the store.
50async function lastAlert($: EngineInterface): Promise<{ mark: number; at: number }> {
51  const value = await $.store.get(ALERT_KEY)
52  const mark = typeof value === 'object' && value !== null && 'mark' in value ? value.mark : 0
53  const at = typeof value === 'object' && value !== null && 'at' in value ? value.at : 0
54
55  return { mark: typeof mark === 'number' ? mark : 0, at: typeof at === 'number' ? at : 0 }
56}
57
58// Adds what the session cost since it was last counted, and the tokens since, to today; then
59// announces a budget mark the last seven days just reached, once a week per mark.
60async function settle($: EngineInterface, budget: number): Promise<void> {
61  const now = await $.clock.now()
62  const cost = await sessionCost($)
63  const before = (await read($, counted)) ?? cost
64  const usd = Math.max(0, cost - before)
65  const tokens = pendingTokens
66  pendingTokens = 0
67  await update($, counted, () => cost)
68
69  if (usd === 0 && tokens === 0) {
70    return
71  }
72
73  const stored = daysFrom(await $.store.get(DAYS_KEY))
74  const next = record(stored, now, { usd, tokens, project })
75  await $.store.set(DAYS_KEY, next)
76  await update($, days, () => next)
77
78  const mark = markOf(sumOf(lastWeek(next, now)).usd, budget)
79  const alerted = await lastAlert($)
80  const isNew = mark > 0 && (mark > alerted.mark || now - alerted.at >= WEEK_MS)
81
82  if (isNew) {
83    await $.store.set(ALERT_KEY, { mark, at: now })
84    $.ui.toast(
85      mark === 100
86        ? `Il Golfo: budget settimanale superato · ${formatUsd(budget)}`
87        : `Il Golfo: sei all'80% del budget settimanale di ${formatUsd(budget)}`
88    )
89  }
90}
91
92export const register: Register = (on, options) => {
93  const budget = typeof options.weeklyBudget === 'number' ? options.weeklyBudget : 0
94
95  on('session.start', async ($, e, next) => {
96    project = e.cwd.split('/').filter(Boolean).at(-1) ?? e.cwd
97    await $.command.register({
98      name: 'golfo',
99      description: 'Open Il Golfo: cost and tokens day by day, with the weekly budget',
100      immediate: true
101    })
102    const stored = daysFrom(await $.store.get(DAYS_KEY))
103    await update($, days, () => stored)
104
105    if ((await read($, counted)) === null) {
106      const cost = await sessionCost($)
107      await update($, counted, () => cost)
108    }
109
110    return next(e)
111  })
112
113  on('command.run', { command: 'golfo' }, async $ => {
114    await settle($, budget).catch(error => logFailure($, 'counting the cost', error))
115    await $.ui.open({ id: PANE, title: 'Il Golfo', focus: true, closeOnEscape: true })
116
117    return {}
118  })
119
120  // Every model request, the main loop's and the subagents': its tokens wait for the turn's end.
121  on('turn.step', async function* (_$, e, next) {
122    const result = yield* next(e)
123    const usage = result.usage
124
125    if (usage !== null) {
126      pendingTokens +=
127        usage.input_tokens +
128        usage.output_tokens +
129        usage.cache_read_input_tokens +
130        usage.cache_creation_input_tokens
131    }
132
133    return result
134  })
135
136  on('turn.complete', async ($, e, next) => {
137    const result = await next(e)
138    await settle($, budget).catch(error => logFailure($, 'counting the cost', error))
139
140    return result
141  })
142
143  // The pane: the last seven days as bars, the week against the budget, and the projects.
144  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
145    const { Box, Text } = $.ui.resolve(e)
146    const now = await $.clock.now()
147    const week = lastWeek(await read($, days), now)
148    const total = sumOf(week)
149    const peak = Math.max(...week.map(({ day }) => day.usd))
150    const priciest = week.find(({ day }) => day.usd === peak && peak > 0)
151    const projects = projectsOf(week).slice(0, TOP_PROJECTS)
152    const used = budget > 0 ? Math.round((total.usd / budget) * 100) : 0
153    const budgetColor = used >= 100 ? VESUVIUS.lava : used >= 80 ? VESUVIUS.magma : VESUVIUS.gulf
154
155    return (
156      <Box flexDirection="column" rowGap={1}>
157        <Box flexDirection="row" justifyContent="space-between">
158          <Text>
159            <Text color={VESUVIUS.gulf}>≋</Text> <Text bold>Il Golfo</Text>
160            <Text color={VESUVIUS.ash}> · last 7 days · {formatTokens(total.tokens)} tokens</Text>
161          </Text>
162          <Text>
163            <Text bold>{formatUsd(total.usd)}</Text>
164            {budget > 0 && (
165              <Text color={budgetColor}>
166                {' '}
167                of {formatUsd(budget)} · {used}%
168              </Text>
169            )}
170          </Text>
171        </Box>
172        {budget > 0 && (
173          <Box flexDirection="row" height={1} backgroundColor={TRACK}>
174            <Box width={`${Math.min(100, used)}%`} height={1} backgroundColor={budgetColor}>
175              <Text> </Text>
176            </Box>
177          </Box>
178        )}
179        <Box flexDirection="column">
180          {week.map(({ at, key, day }, i) => {
181            const isToday = i === week.length - 1
182            const share =
183              peak > 0 ? Math.max(day.usd > 0 ? 1 : 0, Math.round((day.usd / peak) * 100)) : 0
184            const color = isToday
185              ? VESUVIUS.lava
186              : day.usd === peak && peak > 0
187                ? VESUVIUS.magma
188                : VESUVIUS.gulf
189
190            return (
191              <Box key={key} flexDirection="row" columnGap={1}>
192                {/* Monospaced, so the days and amounts line up in columns on every surface. */}
193                <Text mono color={isToday ? 'text' : VESUVIUS.ash} bold={isToday}>
194                  {`${isToday ? 'oggi' : weekdayOf(at).padEnd(4)} ${key.slice(8)}`}
195                </Text>
196                <Box flexDirection="row" flexGrow={1} height={1}>
197                  <Box width={`${share}%`} height={1} backgroundColor={color}>
198                    <Text> </Text>
199                  </Box>
200                </Box>
201                <Text mono>
202                  {formatUsd(day.usd).padStart(8)}{' '}
203                  <Text color={VESUVIUS.ash}>{formatTokens(day.tokens).padStart(6)}</Text>
204                </Text>
205              </Box>
206            )
207          })}
208        </Box>
209        <Text color={VESUVIUS.ash} wrap="truncate-end">
210          {priciest === undefined
211            ? 'Nothing spent in the last seven days.'
212            : `Priciest day: ${weekdayOf(priciest.at)} ${priciest.key.slice(8)} · ${formatUsd(priciest.day.usd)}`}
213          {projects.length > 0 ? ' · ' : ''}
214          {projects.map(([name, usd]) => `${name} ${formatUsd(usd)}`).join(' · ')}
215        </Text>
216      </Box>
217    )
218  })
219}
220
hooks/ledger.ts 145 lines
1import type { Day, Days } from '../types'
2
3// The ledger: days by date, what a turn adds to one, the week they fall in, and what the
4// budget says about it. Times come in as milliseconds, so the clock stays the caller's.
5
6const DAY_MS = 24 * 60 * 60 * 1000
7// Days older than this are dropped from the store.
8const KEEP_DAYS = 90
9const WEEKDAYS = ['do', 'lu', 'ma', 'me', 'gi', 've', 'sa'] as const
10
11export const EMPTY_DAY: Day = { usd: 0, tokens: 0, projects: {} }
12
13const pad = (n: number): string => String(n).padStart(2, '0')
14
15// The date a moment falls on, as `2026-10-25`.
16export function dayKey(ms: number): string {
17  const d = new Date(ms)
18
19  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
20}
21
22// The short name of a moment's weekday (`lu`, `ma`…).
23export function weekdayOf(ms: number): string {
24  return WEEKDAYS[new Date(ms).getDay()] ?? ''
25}
26
27// The seven days up to and including `ms`'s, oldest first, each with its date and record.
28export function lastWeek(days: Days, ms: number): { at: number; key: string; day: Day }[] {
29  return Array.from({ length: 7 }, (_, i) => {
30    const at = ms - (6 - i) * DAY_MS
31    const key = dayKey(at)
32
33    return { at, key, day: days[key] ?? EMPTY_DAY }
34  })
35}
36
37// Adds a turn's cost and tokens to its day under its project, and drops the days past keeping.
38export function record(
39  days: Days,
40  ms: number,
41  add: { usd: number; tokens: number; project: string }
42): Days {
43  const key = dayKey(ms)
44  const day = days[key] ?? EMPTY_DAY
45  const oldest = dayKey(ms - KEEP_DAYS * DAY_MS)
46  const kept = Object.fromEntries(Object.entries(days).filter(([k]) => k >= oldest))
47
48  return {
49    ...kept,
50    [key]: {
51      usd: day.usd + add.usd,
52      tokens: day.tokens + add.tokens,
53      projects: { ...day.projects, [add.project]: (day.projects[add.project] ?? 0) + add.usd }
54    }
55  }
56}
57
58export function sumOf(week: readonly { day: Day }[]): { usd: number; tokens: number } {
59  return week.reduce((t, { day }) => ({ usd: t.usd + day.usd, tokens: t.tokens + day.tokens }), {
60    usd: 0,
61    tokens: 0
62  })
63}
64
65// The week's cost by project, the most expensive first.
66export function projectsOf(week: readonly { day: Day }[]): [string, number][] {
67  const totals = new Map<string, number>()
68
69  for (const { day } of week) {
70    for (const [project, usd] of Object.entries(day.projects)) {
71      totals.set(project, (totals.get(project) ?? 0) + usd)
72    }
73  }
74
75  return [...totals].toSorted((a, b) => b[1] - a[1])
76}
77
78// The highest budget mark (80 or 100, in percent) a spend has reached; 0 below both or with no
79// budget.
80export function markOf(spent: number, budget: number): 0 | 80 | 100 {
81  if (budget <= 0) {
82    return 0
83  }
84
85  const percent = (spent / budget) * 100
86
87  return percent >= 100 ? 100 : percent >= 80 ? 80 : 0
88}
89
90// The days a value read back from the store holds, keeping only entries shaped like a day.
91export function daysFrom(value: unknown): Days {
92  if (typeof value !== 'object' || value === null) {
93    return {}
94  }
95
96  const days: Days = {}
97
98  for (const [key, entry] of Object.entries(value)) {
99    if (typeof entry !== 'object' || entry === null) {
100      continue
101    }
102
103    const usd = 'usd' in entry ? entry.usd : undefined
104    const tokens = 'tokens' in entry ? entry.tokens : undefined
105    const projects = 'projects' in entry ? entry.projects : undefined
106
107    if (typeof usd === 'number' && typeof tokens === 'number') {
108      days[key] = {
109        usd,
110        tokens,
111        projects:
112          typeof projects === 'object' && projects !== null
113            ? Object.fromEntries(
114                Object.entries(projects).filter(
115                  (pair): pair is [string, number] => typeof pair[1] === 'number'
116                )
117              )
118            : {}
119      }
120    }
121  }
122
123  return days
124}
125
126export function formatUsd(usd: number): string {
127  return `$${usd.toFixed(2)}`
128}
129
130export function formatTokens(n: number): string {
131  if (n >= 1_000_000) {
132    return `${(n / 1_000_000).toFixed(1)}M`
133  }
134
135  if (n >= 10_000) {
136    return `${Math.round(n / 1000)}k`
137  }
138
139  if (n >= 1000) {
140    return `${(n / 1000).toFixed(1)}k`
141  }
142
143  return String(n)
144}
145
hooks/palette.ts 22 lines
1// Vesuvius palette (placeholder): replace the hex values with the design system's.
2// Every mod in affreschi keeps its own copy, because a plugin cannot import another plugin's files.
3export const VESUVIUS = {
4  lava: '#E4572E',
5  magma: '#F28C28',
6  sulfur: '#F2C14E',
7  tuff: '#C9B79C',
8  ash: '#8A8D91',
9  gulf: '#2E86AB',
10  vineyard: '#7A9E4E'
11} as const
12
13// The order categories take their colors in, warmest first.
14export const SEQUENCE = [
15  VESUVIUS.lava,
16  VESUVIUS.magma,
17  VESUVIUS.sulfur,
18  VESUVIUS.tuff,
19  VESUVIUS.gulf,
20  VESUVIUS.vineyard
21] as const
22
types/index.d.ts 16 lines
1// One day of work: what it cost, the tokens it used, and how the cost split across projects.
2export type Day = { usd: number; tokens: number; projects: Record<string, number> }
3
4// Every day recorded, by its date (`2026-10-25`).
5export type Days = Record<string, Day>
6
7declare module 'claude-code' {
8  interface PluginState {
9    golfo: {
10      days: Days
11      // The session's cost when it was last added to a day, so each turn adds only what is new.
12      counted: number | null
13    }
14  }
15}
16