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

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:
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.
Du brauchst: ein Android-Handy (ab Android 12) und Claude Code auf deinem Computer.
Öffne auf dem Handy diesen Link und lade die App herunter:
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.
Ö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.
Ö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.
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.
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.
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.
hooks/register.ts 204 lines1import 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