SLOPSHOPPER

limits-handy

Schickt Sitzungs- und Wochenlimit sowie den Tab-Status an die Android-App „Limit Ring“ – auch aus der Claude-Desktop-App.

newguardcommandtoastprocessnetwork
v0.1.0no licenseupdated 2026-10-15bonnibonni13-star/limits-handy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits-handy
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ limits-handy │ ● limits-handy: limits-handy: Briefkasten antwortet 0 │ Limit Ring: Mit /handy-koppeln koppelst du │ ● limits-handy: limits-handy: Briefkasten antwortet 0 │ dein Handy. │ ⏺ 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 › /handy-koppeln ⎿ limits-handy: Limit Ring: Die PIN konnte gerade nicht abgeholt werden. Bitte später noch einmal. ● limits-handy: limits-handy: Briefkasten antwortet 0 ● limits-handy: limits-handy: Briefkasten antwortet 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Limit Ring – Claude-Code-Limits aufs Handy

Die Android-App Limit Ring zeigt dir, wie viel von deinem Claude-Code-Limit verbraucht ist – in der App, im Widget, auf dem Sperrbildschirm und in der Statusleiste:

  • Sitzung: wie viel vom 5-Stunden-Fenster weg ist und wann es zurückgesetzt wird
  • Woche: seit wann die Woche läuft und wie viel vom Wochenlimit verbraucht ist
  • Offene Tabs: welche Claude-Code-Sitzungen gerade arbeiten oder auf dich warten

Die Zahlen kommen von Claude Code auf deinem Computer (Desktop-App oder Terminal; Mac, Windows oder Linux). Dafür installierst du dort einmal den kleinen Mod aus diesem Repo.

Limit Ring ist ein privates Projekt und gehört nicht zu Anthropic.

Einrichten

Du brauchst: ein Android-Handy (ab Android 12) und Claude Code auf deinem Computer.

1. App aufs Handy

Öffne auf dem Handy diesen Link und lade die App herunter:

limit-ring.apk herunterladen

Tippe auf die heruntergeladene Datei. Android fragt, ob dein Browser Apps installieren darf – erlauben. Warnt Google Play Protect vor einer unbekannten App, tippe auf Weitere Details → Trotzdem installieren.

2. Mod installieren

Öffne Claude Code auf deinem Computer und schick Claude diesen Satz (er steht auch in der App zum Kopieren):

Installiere github.com/bonnibonni13-star/limits-handy als Skill nach ~/.claude/skills/limits-handy

Wenn Claude fragt, ob es den Befehl ausführen darf, antworte mit Ja.

3. Koppeln

Öffne in Claude Code eine neue Sitzung und tippe:

/handy-koppeln

Claude zeigt dir eine 6-stellige PIN. Trag sie in der App bei Schritt 3 ein und tippe auf Koppeln.

Fertig. Ab der nächsten Antwort von Claude siehst du deine Limits auf dem Handy.

Häufige Fragen

Die PIN ist abgelaufen. Sie gilt 15 Minuten. Tippe einfach noch einmal /handy-koppeln.

/handy-koppeln gibt es nicht. Der Mod lädt erst in einer neuen Sitzung. Schließ die Sitzung und öffne eine neue. Mod und Befehl brauchen Claude Code 2.1.286 oder neuer.

Muss ich mich in der App bei Claude anmelden? Nein. Deine Claude-Anmeldung bleibt auf deinem Computer. Die App bekommt nur die Zahlen, die Claude Code ohnehin anzeigt.

Ich habe mehrere Computer. Installiere den Mod auf jedem Computer und kopiere die Datei ~/.claude-limits/config vom ersten Computer auf die anderen. Dann melden alle aufs selbe Handy.

Die Insel um die Kamera lässt sich nicht einschalten. Bei Apps, die nicht aus dem Play Store kommen, sperrt Android die Bedienungshilfe zuerst: Einstellungen → Apps → Limit Ring → ⋮ (oben rechts) → Eingeschränkte Einstellungen zulassen, danach erneut einschalten. Die Insel liest keine Bildschirminhalte.

Updates: Neue App-Version einfach über den Link oben installieren. Den Mod aktualisierst du, indem du Claude schickst: „Aktualisiere den Skill in ~/.claude/skills/limits-handy mit git pull“.

Entfernen: In der App „Entkoppeln“ tippen und die App löschen. Auf dem Computer die Ordner ~/.claude/skills/limits-handy und ~/.claude-limits löschen.

Datenschutz

Gesendet werden nur Zahlen und Namen: Prozent und Reset-Zeit beider Limits, Titel und Ordner deiner offenen Tabs, ob sie arbeiten oder warten, und der Name deines Computers. Nie gesendet: deine Nachrichten, Claudes Antworten, Dateien oder Anmeldedaten. Einzelheiten: DATENSCHUTZ.md.

Für Entwickler

Mod für Claude Code (Hooks-Modul in hooks/register.ts, Tests mit claude plugin test .). Claude Code lädt Plugins aus ~/.claude/skills/<name>/ automatisch (limits-handy@skills-dir); alternativ /plugin install limits-handy --marketplace bonnibonni13-star/limits-handy.

Source 1 files
hooks/register.ts 204 lines
1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3// Limit Ring: meldet Sitzungs- und Wochenlimit sowie den Status dieses Tabs an den
4// Briefkasten, aus dem die Android-App liest. Läuft im Terminal und in der Desktop-App.
5// Gesendet werden nur Zahlen, Zeiten, Sitzungs-ID, Ordner, Titel und Gerätename –
6// nie ein Prompt oder eine Antwort.
7
8const BRIEFKASTEN = 'https://qrivbqxjaivirfmircbv.supabase.co/functions/v1/briefkasten'
9const BEFEHL = 'handy-koppeln'
10const LIMITS_BUENDELN_MS = 5_000
11const LEBENSZEICHEN_MS = 15 * 60_000
12
13type Status = 'arbeitet' | 'wartet'
14type Konfig = { briefkasten: string; code: string }
15
16// Die Ereignisnamen, die der Briefkasten auch von den Hook-Skripten kennt.
17const EREIGNIS: Record<Status | 'beendet', string> = {
18  arbeitet: 'UserPromptSubmit',
19  wartet: 'Stop',
20  beendet: 'SessionEnd',
21}
22
23let titel: string | undefined
24let status: Status | undefined
25let turnLaeuft = false
26let offeneLimits: SessionRateLimit[] | undefined
27let limitsTimer = false
28let geraet: Promise<string | null> | undefined
29
30async function konfigPfad($: EngineInterface): Promise<string | null> {
31  const heim = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
32  return heim ? `${heim.replace(/[\\/]+$/, '')}/.claude-limits/config` : null
33}
34
35function konfigLesen(text: string): Konfig | null {
36  const wert = (name: string) => text.match(new RegExp(`^${name}=(.+)$`, 'm'))?.[1]?.trim()
37  const code = wert('CODE')
38  return code && code.length >= 32 ? { briefkasten: wert('BRIEFKASTEN') ?? BRIEFKASTEN, code } : null
39}
40
41/** Liest ~/.claude-limits/config (gemeinsam mit den Hook-Skripten); legt sie beim ersten Mal an. */
42async function konfig($: EngineInterface, anlegen = true): Promise<{ konfig: Konfig; neu: boolean } | null> {
43  const pfad = await konfigPfad($)
44  if (!pfad) return null
45  const vorhanden = await $.fs.read(pfad).then(konfigLesen, () => null)
46  if (vorhanden) return { konfig: vorhanden, neu: false }
47  if (!anlegen) return null
48  const zufall = crypto.getRandomValues(new Uint8Array(24))
49  const code = [...zufall].map(b => b.toString(16).padStart(2, '0')).join('')
50  await $.fs.write(pfad, `BRIEFKASTEN=${BRIEFKASTEN}\nCODE=${code}\n`)
51  await $.process.run(['chmod', '600', pfad]).catch(() => undefined)
52  // Zwei Sitzungen, die gleichzeitig starten: es gilt, was zuletzt in der Datei steht.
53  const gelesen = await $.fs.read(pfad).then(konfigLesen, () => null)
54  return { konfig: gelesen ?? { briefkasten: BRIEFKASTEN, code }, neu: true }
55}
56
57async function geraetsname($: EngineInterface): Promise<string | null> {
58  const windows = await $.env.get('COMPUTERNAME')
59  if (windows) return windows
60  for (const argv of [['scutil', '--get', 'ComputerName'], ['hostname']]) {
61    const r = await $.process.run(argv, { timeoutMs: 3_000 }).catch(() => undefined)
62    const name = r?.exitCode === 0 ? r.stdout.trim() : ''
63    if (name) return name
64  }
65  return null
66}
67
68async function senden($: EngineInterface, body: Record<string, unknown>): Promise<unknown> {
69  const k = await konfig($)
70  if (!k) return undefined
71  geraet ??= geraetsname($).catch(() => null)
72  const antwort = await $.http.fetch(k.konfig.briefkasten, {
73    method: 'POST',
74    headers: { 'content-type': 'application/json', 'x-kopplung': k.konfig.code },
75    body: JSON.stringify({ ...body, geraet: await geraet }),
76  })
77  if (!antwort.ok) $.ui.log(`limits-handy: Briefkasten antwortet ${antwort.status}`, { to: 'debug' })
78  return antwort.ok ? JSON.parse(antwort.text) : undefined
79}
80
81const leise = (p: Promise<unknown>) => void p.catch(() => undefined)
82
83async function tabMelden($: EngineInterface, neu: Status | 'beendet', sessionId?: string) {
84  if (neu !== 'beendet') status = neu
85  await senden($, {
86    aktion: 'tab',
87    ereignis: EREIGNIS[neu],
88    session_id: sessionId ?? (await $.session.id()),
89    cwd: await $.session.cwd(),
90    titel,
91  })
92}
93
94/** Nur wenn sich der Status ändert – Werkzeugaufrufe kommen oft im Sekundentakt. */
95function statusSetzen($: EngineInterface, neu: Status) {
96  if (status !== neu) leise(tabMelden($, neu))
97}
98
99/** Bündelt die Messungen eines Turns zu einer Meldung. */
100function limitsVormerken($: EngineInterface, liste: readonly SessionRateLimit[]) {
101  const fenster = liste.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
102  if (fenster.length === 0) return
103  offeneLimits = fenster
104  if (limitsTimer) return
105  limitsTimer = true
106  $.clock.after(LIMITS_BUENDELN_MS, () => {
107    limitsTimer = false
108    const rate_limits = offeneLimits
109    offeneLimits = undefined
110    if (rate_limits) leise(senden($, { aktion: 'limits', rate_limits }))
111  })
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    const started = await next(e)
117    await $.command.register({
118      name: BEFEHL,
119      description: 'Limit Ring: PIN anzeigen, um dein Android-Handy zu koppeln',
120    })
121    leise(
122      (async () => {
123        const k = await konfig($)
124        if (k?.neu) $.ui.toast(`Limit Ring: Mit /${BEFEHL} koppelst du dein Handy.`)
125        await tabMelden($, 'wartet')
126        const { rateLimits } = await $.session.usage()
127        limitsVormerken($, rateLimits)
128      })(),
129    )
130    $.clock.every(LEBENSZEICHEN_MS, () => leise(tabMelden($, status ?? 'wartet')))
131    return started
132  })
133
134  // Der Titel, den die Desktop-App der Sitzung gibt. Der Prompt selbst wird nicht gelesen.
135  on('classic.SessionStart', ($, e, next) => {
136    if (e.session_title) titel = e.session_title
137    return next(e)
138  })
139  on('classic.UserPromptSubmit', ($, e, next) => {
140    if (e.session_title) titel = e.session_title
141    return next(e)
142  })
143
144  on('turn.start', ($, e, next) => {
145    turnLaeuft = true
146    statusSetzen($, 'arbeitet')
147    return next(e)
148  })
149
150  on('turn.complete', ($, e, next) => {
151    if (e.agentId === undefined) {
152      turnLaeuft = false
153      statusSetzen($, 'wartet')
154    }
155    return next(e)
156  })
157
158  // Freigabe nötig oder Rückfrage: der Tab wartet auf dich.
159  on('classic.Notification', ($, e, next) => {
160    if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
161      statusSetzen($, 'wartet')
162    }
163    return next(e)
164  })
165
166  // Nach einer Freigabe geht es weiter.
167  on('tool.call', async ($, e, next) => {
168    if (turnLaeuft) statusSetzen($, 'arbeitet')
169    const ergebnis = await next(e)
170    if (turnLaeuft) statusSetzen($, 'arbeitet')
171    return ergebnis
172  })
173
174  on('session.measure', ($, e, next) => {
175    if (e.changed.includes('rateLimits')) limitsVormerken($, e.rateLimits)
176    return next(e)
177  })
178
179  on('session.end', async ($, e, next) => {
180    const zeit = Math.max(0, Math.min(2_000, next.budget.remainingMs - 300))
181    await Promise.race([tabMelden($, 'beendet', e.sessionId).catch(() => undefined), $.clock.sleep(zeit)])
182    const beendet = await next(e)
183    if (e.reason === 'clear') {
184      // /clear: dieselbe Sitzung läuft unter neuer ID weiter.
185      titel = undefined
186      turnLaeuft = false
187      $.clock.after(1_000, () => leise(tabMelden($, 'wartet')))
188    }
189    return beendet
190  })
191
192  on('command.run', { command: BEFEHL }, async $ => {
193    const r = (await senden($, { aktion: 'koppeln-start' }).catch(() => undefined)) as
194      | { pin?: string }
195      | undefined
196    if (!r?.pin) return { text: 'Limit Ring: Die PIN konnte gerade nicht abgeholt werden. Bitte später noch einmal.' }
197    return {
198      text:
199        `Kopplungs-PIN fürs Handy: ${r.pin}\n` +
200        'In der Handy-App „Limit Ring“ bei Schritt 3 eintragen und auf „Koppeln“ tippen. Gültig für 15 Minuten.',
201    }
202  })
203}
204