SLOPSHOPPER

usage-meter

Shows the 5h and 7d rate-limit windows and the session cost in the status line, with a /usage-meter pane and a toast when a window runs hot.

newpanecommandtoaststatus
★ 1v0.1.0no licenseupdated 2026-10-08sadoMasupilami/nix-darwin/config/claude-mods/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ 5h ███████░░░░░░░░░░░░░░░░░ 31% │ ┃ Ctx ████████████░░░░░░░░░░░░ 49% ⏺ Read(src/auth.ts) │ ┃ Cost $0.42 this session ⎿ 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 │ │ › /usage-meter │ ⎿ usage-meter: Usage pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-meter: 5h 31% · $0.42

Draws

Pane · Usage
5h ███████░░░░░░░░░░░░░░░░░ 31% Ctx ████████████░░░░░░░░░░░░ 49% Cost $0.42 this session
README

nix-darwin und Home Manager

Dieses Repository verwaltet dieselbe Benutzerumgebung aus einer gemeinsamen Flake für:

  • macOS mit nix-darwin und Home Manager,
  • Linux mit Home Manager,
  • Homebrew einschließlich aller Tap-Quellen,
  • lokale Qwen-Transkription und den Raycast-Teams-Aufruf.

Benutzername, Plattformen, Home-Verzeichnisse, Hostname und Checkout-Pfade stehen ausschließlich in machine-config.nix. Die Konfiguration ist in kleine Dateien unter modules/ aufgeteilt.

macOS: Ersteinrichtung

Homebrew und Determinate Nix sind die einmaligen Voraussetzungen:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | \
  sh -s -- install

Rosetta wird für diese Konfiguration nicht benötigt. Der Intel-Homebrew-Prefix ist deaktiviert; ein bestehendes /usr/local wird weder verändert noch gelöscht.

Danach das Repository an den in machine-config.nix hinterlegten Ort klonen und die Maschinenwerte prüfen:

mkdir -p ~/.config
cd ~/.config
git clone https://github.com/sadomasupilami/nix-darwin.git
cd nix-darwin

Vor einer Aktivierung immer den read-only Preflight ausführen:

./config/nix-config/nix-config-preflight

Er baut die Konfiguration, wertet ein nicht leeres Brewfile aus, schützt chatgpt und Minutes, parst alle installierten Formeln und Casks, prüft Homebrew Bundle und zeigt mit --all --zap die geplante Bereinigung ohne --force. Dafür verwendet er bereits das aus dem gemeinsamen Lockfile gebaute Homebrew und nicht die eventuell noch ältere Live-Installation. Der Preflight führt weder Upgrades noch Löschungen oder eine Aktivierung aus.

Einmaliger Wechsel auf unveränderliche Taps

nix-homebrew.mutableTaps = false benötigt /opt/homebrew/Library/Taps als Nix-verwalteten Symlink. Solange dort das bisherige echte Verzeichnis liegt, stoppt der Preflight absichtlich. Die Migration ist nicht automatisiert:

  1. Den vom Preflight gemeldeten Pfad prüfen.
  2. Erst nach separater Zustimmung das vorhandene Verzeichnis auf einen eindeutig datierten Backup-Pfad verschieben.
  3. Den Preflight erneut ausführen.
  4. Den ersten gepinnten Apply ausführen.
  5. Homebrew und alle vier Taps verifizieren; das Backup erst danach und nur mit separater Zustimmung entfernen.

Beispiel für Schritt 2, bewusst nicht automatisch ausgeführt:

sudo mv /opt/homebrew/Library/Taps \
  /opt/homebrew/Library/Taps.before-nix-homebrew-YYYYMMDD-HHMMSS

Der empfohlene erste Apply führt den Preflight nochmals aus und verwendet intern den im gemeinsamen Lockfile gepinnten darwin-rebuild:

./config/nix-config/nix-config-apply

Der zugrunde liegende Flake-Einstieg ist sudo -- "$(command -v nix)" run "$snapshot#darwin-rebuild" -- switch --flake "$snapshot#macos". $snapshot ist dabei der zuvor geprüfte Nix-Store-Snapshot. sudo ist für die Systemaktivierung erforderlich; die App und ihre Version stammen weiterhin aus dem gemeinsamen Lockfile.

Ein echter Apply bleibt eine gesondert zu bestätigende Aktion. Er aktualisiert deklarierte Homebrew- und App-Store-Apps und entfernt mit cleanup = "zap" nicht deklarierte Homebrew-Apps samt Zap-Daten. Ein Nix-Rollback stellt weder Homebrew-Upgrades noch gezappte Daten wieder her.

Updates und Apply-Helfer

Nach der ersten Home-Manager-Aktivierung liegen diese Befehle in ~/.bin. Ihre Implementierung und Hilfsprogramme kommen aus dem Nix Store; Änderungen im Checkout ändern die installierten Helfer erst beim nächsten Apply:

nix-config-update [all|homebrew]
nix-config-preflight
nix-config-apply [--gc] [--accept-zap HASH]
  • nix-config-update all aktualisiert alle Root-Flake-Inputs und führt danach den Preflight aus.
  • nix-config-update homebrew aktualisiert nur nix-homebrew, Core, Cask sowie die Azure- und Silverstein-Taps als gemeinsame Gruppe und führt danach den Preflight aus.
  • Ein fehlgeschlagener Preflight lässt das neue flake.lock zur Prüfung liegen, blockiert aber den Apply.
  • Preflight und Apply erfassen die getrackten Working-Tree-Dateien einmal als unveränderlichen Nix-Store-Snapshot. Prüfung und Aktivierung verwenden genau diesen Stand, auch wenn der Checkout inzwischen geändert wird. Ein sauberer Checkout gibt seinen Commit als configurationRevision an die Generation weiter; ein Checkout mit lokalen Änderungen bleibt unbeschriftet.
  • Der Preflight vergleicht deklarierte Formula-Taps mit den installierten Receipts, damit Alias-Konflikte vor der Aktivierung auffallen. azd bleibt explizit azure/azd/azd; Minutes verwendet wegen des gleichnamigen Formula- Eintrags einen separaten Cask-Installationspfad.
  • Ein nicht leerer Zap-Preview (auch nur alte Versionen oder Cache-Dateien) stoppt mit Exitcode 3. Nach Prüfung der Liste kann nix-config-apply --accept-zap HASH mit dem ausgegebenen Hash ausgeführt werden. Die Freigabe gilt nur für genau diese Liste und diesen Snapshot; geänderte Dateien oder eine andere Entfernungsliste benötigen eine neue Freigabe. Während des Apply keine parallelen Homebrew-Änderungen vornehmen.
  • nix-config-apply --gc führt ein vollständiges nix-collect-garbage aus, weiterhin ohne Löschen alter Generationen und ohne Schonfrist für kürzlich entstandene Pfade (siehe unten). Ein separates mas upgrade gibt es nicht mehr.
  • Neue Quelldateien müssen vor dem Preflight mit git add getrackt werden; ignorierte und ungetrackte Dateien gehören bewusst nicht zum Snapshot.

Automatische Garbage Collection

Der Determinate-Nixd-eigene Collector ist abgeschaltet (garbageCollector.strategy = "disabled"), weil er keine Aufbewahrungsfrist kennt. An seiner Stelle prüft der LaunchDaemon nix-gc-recent (modules/nix-gc.nix, Skript in config/nix-gc/) stündlich den freien Platz auf /nix:

  • Ab 20 % frei passiert nichts.
  • Darunter wird nix-store --gc ausgeführt, aber unerreichbare Store-Pfade, die jünger als 14 Tage sind, bleiben samt ihren Abhängigkeiten erhalten. Dafür legt das Skript für die Dauer des Laufs temporäre GC-Roots unter /nix/var/nix/gcroots/nix-gc-recent an.
  • Unter 5 % frei entfällt die Schonfrist, damit eine fast volle Platte die Bereinigung nicht blockiert.

/nix ist mit noatime eingehängt, ein Pfad trägt also nur den Zeitpunkt seiner Registrierung. Die Schonfrist schützt daher kürzlich gebaute oder geladene Pfade, nicht kürzlich ausgeführte. Protokoll: /var/log/nix-gc-recent.log. Prüfen ohne Root und ohne Änderung:

nix-gc-recent --force --dry-run

Schonfrist und Schwellen stehen oben in modules/nix-gc.nix.

Homebrew-Casks und App-Store-Apps bleiben bewusst nativ verwaltet. Gepinnte Tap-Quellen fixieren die Paketdefinitionen; App-eigene Updater und App-Store- Versionen können davon unabhängig sein. Nix-Rollbacks rollen diese Apps nicht zurück.

Die gewählte Homebrew-Politik bleibt autoUpdate = false, upgrade = true und cleanup = "zap". Die Homebrew-Version wird aus flake.lock abgeleitet. chatgpt ist der aktuelle Desktop-Weg mit integriertem Codex. Das Verhalten der unforced Cleanup-Vorschau entspricht der Homebrew-Manpage.

mas erkennt installierte App-Store-Apps über Spotlight. Der read-only Preflight unterbindet die automatische Indexierung durch mas; meldet er eine vorhandene und deklarierte App trotzdem als fehlend, muss der Spotlight-Index repariert werden, nicht die App zusätzlich als Cask deklariert werden. Siehe die Spotlight-Hinweise von mas und Apples Anleitung zum Neuaufbau des Index.

Linux Home Manager

Nach der Nix-Installation das Repository an den Linux-Pfad aus machine-config.nix klonen. Die verschachtelte Flake und ihr zweites Lockfile gibt es nicht mehr. Der erste Apply verwendet ausschließlich die im Root-Lock festgelegte Home-Manager-Version:

cd ~/.config/nix-darwin
nix run .#home-manager -- switch --flake .#default

Dieselben Helfer stehen anschließend zur Verfügung. Unter Linux überspringt der Preflight nur die macOS-/Homebrew-Prüfungen; die Root-Flake wird weiterhin geprüft.

Die öffentlichen Flake-Ausgaben sind:

darwinConfigurations.macos
homeConfigurations.default
homeConfigurations.michaelklug

Claude-Code-Anweisungen

modules/claude.nix verlinkt config/claude/CLAUDE.md nach ~/.claude/CLAUDE.md. Claude Code lädt die Datei in jeder Session. Sie sagt Claude, dass fehlende Tools per nix shell nixpkgs#<paket> geholt werden statt global installiert.

Claude-Mods

Der Flake-Input claude-code-playground pinnt die offiziellen Sample-Mods von Anthropic in flake.lock. modules/claude-mods.nix verlinkt token-weather (Kontextfenster-Prognose über dem Prompt) und blast-radius (hält riskante Shell-Befehle an und zeigt vorab, was sie ändern würden) nach ~/.claude/skills. Dazu kommt der eigene Mod usage-meter aus config/claude-mods/usage-meter/: Er zeigt die Rate-Limit-Fenster (5h, 7d) und die Session-Kosten in der Statuszeile, mit /usage-meter als Pane mit Balken und Reset-Zeiten, und warnt per Toast ab 80 % und 95 %. Claude Code lädt diese Ordner in jeder Session automatisch als Plugin <name>@skills-dir; ~/.claude/settings.json bleibt unangetastet. Tests für den eigenen Mod: claude plugin test config/claude-mods/usage-meter.

Mods brauchen Claude Code ab 2.1.287. Dafür installiert Homebrew das Cask claude-code@latest statt des langsameren Stable-Casks claude-code. Mods laufen mit den eigenen Rechten und ohne Sandbox. Vor einem Pin-Update claude plugin validate auf die neuen Store-Pfade anwenden und die hooks:- und calls:-Zeilen prüfen. Update mit nix flake update claude-code-playground oder nix-config-update all, danach Preflight und Apply. Kontrolle mit claude plugin list.

Lokale Meeting-Transkription

Auf Apple Silicon installiert Home Manager die Befehle qwen-meeting und qwen-meeting-setup samt unveränderlicher Python-Laufzeit im Nix Store. packages/qwen-meeting-runtime bezieht die Python-3.13-Wheels aus dem committed uv.lock mit ihren Hashes; Installation und Abhängigkeitsprüfung im Paket erfolgen offline. Nix lädt die Wheels beim Build. Die Wrapper referenzieren die Laufzeit ihrer Generation, sodass ein Rollback auch die Python-Abhängigkeiten wiederherstellt.

Nur die großen Modellgewichte werden durch den ausdrücklichen Setup-Aufruf geladen:

qwen-meeting-setup

Er lädt mit der Nix-Laufzeit exakt diese Modellrevisionen:

  • Qwen3-ASR-1.7B: 7278e1e70fe206f11671096ffdd38061171dd6e5
  • Qwen3-ForcedAligner-0.6B: c7cbfc2048c462b0d63a45797104fc9db3ad62b7

Eine vorhandene ~/.venvs/qwen3-asr wird weder verändert noch entfernt und von den neuen Wrappern nicht mehr verwendet. Modelle und Transkripte bleiben außerhalb des Nix Stores. Die Modellrevisionen werden weiterhin separat durch Setup verwaltet; ein Runtime-Rollback lädt oder ersetzt keine Modellgewichte.

Transkriptionsläufe verwenden danach nur lokale Modellpfade und HF_HUB_OFFLINE=1:

qwen-meeting "/vollständiger/Pfad/aufnahme.m4a" [auto|de|en]
qwen-meeting --copy "/vollständiger/Pfad/aufnahme.m4a" auto

Der Ausgabeordner öffnet sich standardmäßig im Finder. Das Transkript wird nicht ins Terminal geschrieben und nur mit --copy in die Zwischenablage kopiert. Neue Verzeichnisse erhalten Modus 0700, neue Dateien 0600. Vorhandene unsichere Verzeichnisse werden mit einem Hinweis abgelehnt und nicht automatisch umgestellt. Das verwaltete Vokabular liegt in config/qwen-meeting/context.txt.

Raycast und Teams

Die persönliche Kontaktliste bleibt bewusst außerhalb von Git und Nix Store:

~/.config/raycast/teams-people.csv
name,email,tenantId

Das Verzeichnis muss 0700, die CSV 0600 haben. Das Skript ändert bestehende Rechte nicht rückwirkend. Es protokolliert weder Namen noch E-Mail-Adressen, Tenant-IDs oder Deep Links. Statt des früheren globalen /tmp-Logs schreibt es nur eine generische, atomar ersetzte Statuszeile in eine private Datei. Das alte /tmp/raycast-teams-video-call.log wird nicht automatisch gelöscht.

Tests und CI

Lokal:

nix flake check --all-systems --no-build
nix flake check
bash tests/run-local-tools-tests.sh
bash tests/nix-config-helpers.sh
nix fmt -- --check
git diff --check

GitHub Actions wertet und baut auf ubuntu-24.04 und macos-26. CI hat nur contents: read und führt keine Systemaktivierung, Homebrew-Upgrades oder Zap-Bereinigung aus.

Bewusst akzeptierte Risiken und erhaltene Integrationen

  • Der lokale Benutzer bleibt ein vertrauenswürdiger Nix-Benutzer und Nix- Sandboxing bleibt deaktiviert.
  • Die macOS Application Firewall und Stealth Mode bleiben deaktiviert. Das ist ein bewusst akzeptiertes Restrisiko; die Konfiguration aktiviert sie nicht.
  • Minutes bleibt als Cask silverstein/tap/minutes erhalten. Die GUI erhält weiterhin MINUTES_FFMPEG aus dem Nix-Benutzerprofil.
  • WireGuard bleibt als App-Store-App deklariert.
  • Die SSH-Reconciliation behält dynamische Coder-Blöcke und verwaltet nur den stabilen Include-Teil.
  • mas und Docker Credential Helpers kommen ausschließlich aus Nix.
  • Persönliche Transkripte, vorhandene CSV-Rechte und das alte Teams-Log werden ohne separate Freigabe weder migriert noch gelöscht.

Weitere manuelle Einrichtung: Bartender-Lizenz. Pakete lassen sich über search.nixos.org suchen.

Source 2 files
hooks/register.tsx 144 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Snapshot, Window } from '../types'
5
6const PANE = 'usage-meter'
7const WARN = 80
8const CRITICAL = 95
9const THRESHOLDS = [WARN, CRITICAL]
10const BAR_WIDTH = 24
11
12const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null)
13const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, {})
14
15const LABELS: Record<string, string> = {
16  five_hour: '5h',
17  seven_day: '7d',
18  spend_limit: 'Spend',
19}
20
21const label = (kind: string) => LABELS[kind] ?? kind
22
23const toSnapshot = (u: SessionUsage | SessionMeasureInput): Snapshot => ({
24  windows: u.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
25  costUsd: u.cost?.usd,
26  contextPercent: u.context.percent,
27})
28
29const usd = (n: number) => `$${n < 10 ? n.toFixed(2) : n.toFixed(1)}`
30
31export const statusText = (s: Snapshot): string | undefined => {
32  const parts = s.windows.map(w => `${label(w.kind)} ${Math.round(w.percentUsed)}%`)
33  if (s.costUsd !== undefined) parts.push(usd(s.costUsd))
34  return parts.length ? parts.join(' · ') : undefined
35}
36
37export const until = (resetsAt: string | undefined, now: number): string | undefined => {
38  const at = resetsAt ? Date.parse(resetsAt) : NaN
39  if (Number.isNaN(at)) return undefined
40  const minutes = Math.max(0, Math.round((at - now) / 60000))
41  const days = Math.floor(minutes / 1440)
42  const hours = Math.floor((minutes % 1440) / 60)
43  if (days > 0) return `${days}d ${hours}h`
44  if (hours > 0) return `${hours}h ${minutes % 60}m`
45  return `${minutes}m`
46}
47
48export const bar = (percent: number, width = BAR_WIDTH): string => {
49  const filled = Math.min(width, Math.round((Math.min(percent, 100) / 100) * width))
50  return '█'.repeat(filled) + '░'.repeat(width - filled)
51}
52
53const colorOf = (percent: number) =>
54  percent >= CRITICAL ? 'error' : percent >= WARN ? 'warning' : 'success'
55
56const levelOf = (percent: number) => THRESHOLDS.filter(t => percent >= t).pop() ?? 0
57
58const apply = async ($: EngineInterface, s: Snapshot) => {
59  await update($, snapshot, () => s)
60  $.ui.status(statusText(s))
61
62  const seen = await read($, warned)
63  const next: Record<string, number> = { ...seen }
64  for (const w of s.windows) {
65    const level = levelOf(w.percentUsed)
66    if (level > (seen[w.kind] ?? 0)) {
67      const resets = until(w.resetsAt, await $.clock.now())
68      $.ui.toast(
69        `${label(w.kind)} limit at ${Math.round(w.percentUsed)}%${resets ? `, resets in ${resets}` : ''}`,
70        { timeoutMs: 8000 },
71      )
72    }
73    // A drop below the last warned level means the window reset: warn again next time.
74    next[w.kind] = level
75  }
76  await update($, warned, () => next)
77}
78
79export const register: Register = on => {
80  on('session.start', async ($, e, next) => {
81    await $.command.register({
82      name: 'usage-meter',
83      description: 'Show rate-limit windows and session cost in a pane',
84    })
85    const result = await next(e)
86    await apply($, toSnapshot(await $.session.usage()))
87    return result
88  })
89
90  on('session.measure', async ($, e, next) => {
91    await apply($, toSnapshot(e))
92    return next(e)
93  })
94
95  on('command.run', { command: 'usage-meter' }, async $ => {
96    await apply($, toSnapshot(await $.session.usage()))
97    await $.ui.open({ id: PANE, title: 'Usage' })
98    return { text: 'Usage pane opened.' }
99  })
100
101  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
102    const { Box, Text } = $.ui.resolve(e)
103    const s = await read($, snapshot)
104    const now = await $.clock.now()
105    const width = Math.max(8, Math.min(BAR_WIDTH, (e.props.bodyColumns ?? 40) - 16))
106
107    if (!s) return <Text dimColor>No usage reading yet.</Text>
108
109    return (
110      <Box flexDirection="column">
111        {s.windows.length === 0 && (
112          <Text dimColor>No rate-limit windows (not on a subscription, or no response yet).</Text>
113        )}
114        {s.windows.map((w: Window) => {
115          const resets = until(w.resetsAt, now)
116          return (
117            <Box key={w.kind} flexDirection="column">
118              <Text>
119                <Text bold>{label(w.kind).padEnd(5)}</Text>
120                <Text color={colorOf(w.percentUsed)}>{bar(w.percentUsed, width)}</Text>
121                <Text> {Math.round(w.percentUsed)}%</Text>
122              </Text>
123              {resets && <Text dimColor>{'     '}resets in {resets}</Text>}
124            </Box>
125          )
126        })}
127        {s.contextPercent !== undefined && (
128          <Text>
129            <Text bold>{'Ctx'.padEnd(5)}</Text>
130            <Text color={colorOf(s.contextPercent)}>{bar(s.contextPercent, width)}</Text>
131            <Text> {Math.round(s.contextPercent)}%</Text>
132          </Text>
133        )}
134        {s.costUsd !== undefined && (
135          <Text>
136            <Text bold>{'Cost'.padEnd(5)}</Text>
137            <Text>{usd(s.costUsd)} this session</Text>
138          </Text>
139        )}
140      </Box>
141    )
142  })
143}
144
types/index.d.ts 18 lines
1export type Window = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Snapshot = {
4  windows: Window[]
5  costUsd?: number
6  contextPercent?: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'usage-meter': {
12      snapshot: Snapshot | null
13      // Highest warning threshold already toasted, per window kind.
14      warned: Record<string, number>
15    }
16  }
17}
18