Statuszeile mit Balken für 5-h- und 7-Tage-Verbrauch, Kontext und Cache; /verbrauch zeigt alles farbig mit Prognose im Pane; warnt ab 250k Kontext und bei…

Mikes Mods für Claude Code, auf Deutsch. Entwickelt auf dem Mac mini, MIT-Lizenz.
| Mod | Version | Kurz |
|---|---|---|
verbrauch/ | 0.7.2 | Statuszeile + /verbrauch-Pane: 5-h-/7-Tage-Limits, Kontext, Cache-Sperre, Hintergrund-Agenten, Auto-Neustart (/auto) |
sessions/ | 0.4.1 | /sessions, /pause (sichern, neu, selbst, auto, abbrechen) |
clean-view hat ein eigenes öffentliches Repo: https://github.com/skaterino/claude-clean-view
In ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/verbrauch:~/.claude/mods/sessions:~/.claude/mods/clean-view" }
Unter Windows Pfade mit ; trennen. Installation auf Windows Schritt für Schritt: INSTALL-WINDOWS.md.
Nicht im geladenen Ordner entwickeln (jede Session lädt neu): Kopie bauen, claude plugin validate + claude plugin test, dann einspielen, hier committen und pushen.
Der Windows-Stand hat die Mac-Anpassungen (HOME vor USERPROFILE, liste.js mit procStart-Prüfung, geteilte Limits ab verbrauch 0.4.0) noch nicht. Dieses Repo ist die gemeinsame Quelle.
hooks/register.tsx 1787 lines1import { atom, read, update } from 'claude-code'
2import type {
3 AgentInfo,
4 EngineInterface,
5 Register,
6 RenderChildren,
7 SessionRateLimit,
8 SessionUsage,
9 TurnUsage,
10} from 'claude-code'
11
12import type { AgentRow, Auto, Background, Limit, Pickup, ShellRow, Snapshot, Warned } from '../types'
13
14// Ab hier gilt der Kontext als gross: jeder API-Call liest ihn komplett neu.
15const WARN = 250_000
16const ALARM = 400_000
17// Ab so viel Prozent eines Limit-Fensters kommt eine Meldung.
18const LIMIT_LEVELS = [95, 80]
19const TZ = 'Europe/Zurich'
20const EVERY_MS = 15_000
21const MIN = 60_000
22const HOUR = 60 * MIN
23const DAY = 24 * HOUR
24// Im Abo läuft der Prompt-Cache eine Stunde nach dem letzten Call ab. Danach schreibt der
25// nächste Prompt den ganzen Kontext neu, je nach Modell 20- bis 80-mal so teuer wie das Lesen
26// aus dem warmen Cache (COLD_FACTORS).
27const COLD_MS = HOUR
28// Ab so viel Kontext hält die Cache-Sperre einen Prompt einmal an.
29const HOLD_FROM = 150_000
30// Ab so viel Kontext zeigt die Statuszeile den Cache.
31const COLD_SHOWN_FROM = 50_000
32// So lange bleibt das Ende des letzten Turns einer Session im Store.
33const KEEP_MS = 30 * DAY
34// Statuszeile: Zellen je Balken.
35const CELLS = 8
36// Pane: Breite der Beschriftungsspalte und Skala des Kontextbalkens.
37const PANE = 'verbrauch'
38const LABEL_W = 13
39const CONTEXT_SCALE = 500_000
40// Prognose erst, wenn so viel vom Fenster vorbei ist: davor sagt das Tempo nichts.
41const PACE_FROM = 0.1
42// Zwei Messungen gehören zum selben Fenster, wenn ihre Resets so nah beieinander liegen.
43const SAME_WINDOW_MS = 30 * MIN
44// Ab diesem Alter zeigt die Anzeige, wie alt eine Limit-Messung ist.
45const STALE_MS = 5 * MIN
46// So lange gilt der erste Druck auf „Sichern & neu starten“; bestätigt wird mit einem eigenen Knopf, frühestens
47// nach CONFIRM_MIN_MS, damit kein doppelter Druck (Enter, Klick) beides auf einmal auslöst.
48const CONFIRM_MS = 5_000
49const CONFIRM_MIN_MS = 700
50// Hintergrund: so lange bleibt ein beendeter Agent oder eine Shell im Pane stehen.
51const BG_SHOWN_MS = 30 * MIN
52// Höchstens so viele Zeilen im Pane, und so viele Agenten merkt sich der Mod je Session.
53const BG_ROWS = 8
54const BG_KEEP = 300
55// Ein Loop ohne Listeneintrag (Workflow-Agent oder interner Fork der Engine) heisst „aktiv“, solange sein
56// letzter Aufruf so jung ist. Er zählt nur bei den Kosten: ein Fork fürs Komprimieren ist keine Arbeit,
57// auf die die Session wartet, und ein laufender Workflow steht ohnehin als Task in der Liste.
58const UNLISTED_ACTIVE_MS = 2 * MIN
59const BG_CELLS = 10
60// Auto-Neustart: Standardwerte, was erlaubt ist, Schritt der −/+-Knöpfe, Mindestabstand und Countdown.
61const AUTO_GRENZE = 400_000
62const AUTO_MAX = 3
63const GRENZE_MIN = 50_000
64const GRENZE_MAX = 900_000
65const GRENZE_STEP = 50_000
66const MAX_MIN = 1
67const MAX_MAX = 20
68const AUTO_GAP_MS = 10 * MIN
69const COUNTDOWN_S = 30
70// So lange nach dem Turn-Ende prüft der Auslöser: classic.Stop (Crons, Hintergrund) kommt laut Messung ~130 ms vor
71// turn.complete, so ist es auch bei anderer Reihenfolge da.
72const TRIGGER_DELAY_MS = 500
73// So lange nach fire gilt ein /clear als der eigene Auto-Neustart (Kette bleibt) und ein Abbruch geht an den
74// sessions-Mod. Grosszügig, denn ein langer Sicherungs-Turn darf die Kettenbremse nicht aushebeln.
75const FIRED_VALID_MS = 30 * MIN
76
77const warned = atom({ plugin: 'verbrauch', key: 'warned' } as const, { context: 0, limits: {} } as Warned)
78// Ende des Turns, nach dem schon einmal angehalten wurde: das zweite Enter geht durch.
79const held = atom({ plugin: 'verbrauch', key: 'held' } as const, 0)
80const confirm = atom({ plugin: 'verbrauch', key: 'confirm' } as const, 0)
81const snap = atom({ plugin: 'verbrauch', key: 'snap' } as const, {
82 at: 0,
83 limits: [],
84 window: 0,
85 startedAt: 0,
86 lastTurn: 0,
87 running: false,
88} as Snapshot)
89const bg = atom({ plugin: 'verbrauch', key: 'bg' } as const, {
90 agents: {},
91 shells: {},
92 main: { calls: 0, usd: 0, unpriced: false },
93} as Background)
94// Nur Anzeige: Sekunden bis zum Auto-Neustart. Der Schalter selbst liegt im Store (Atome leert ein /clear).
95const countdown = atom({ plugin: 'verbrauch', key: 'countdown' } as const, 0)
96
97const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7T', spend_limit: 'Limit' }
98const TITLE: Record<string, string> = { five_hour: '5 Stunden', seven_day: '7 Tage', spend_limit: 'Ausgaben' }
99const SPAN: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
100const DAY_NAME: Record<string, string> = { Sun: 'So', Mon: 'Mo', Tue: 'Di', Wed: 'Mi', Thu: 'Do', Fri: 'Fr', Sat: 'Sa' }
101// Farbzonen der Balken, als Theme-Farben: grün, gelb, rot.
102const LIMIT_ZONES: [number, string][] = [[60, 'success'], [80, 'warning'], [Infinity, 'error']]
103const CONTEXT_ZONES: [number, string][] = [[WARN, 'success'], [ALARM, 'warning'], [Infinity, 'error']]
104// Preise in USD je Million Tokens: Input, Output, Lesen aus dem Cache (Anthropic-Preisliste laut
105// claude-api-Skill, Stand 2026-10-06, geprüft 2026-10-14). Schreiben in den Cache kostet 2× Input:
106// Claude Code nutzt im Abo den 1-h-Cache. Fast Mode (2×) ist an der Antwort nicht zu erkennen.
107type Price = { input: number; output: number; read: number }
108const PRICES: Record<string, Price> = {
109 'fable-5-1': { input: 10, output: 50, read: 0.25 },
110 'mythos-5-1': { input: 10, output: 50, read: 0.25 },
111 'fable-5': { input: 10, output: 50, read: 1 },
112 'mythos-5': { input: 10, output: 50, read: 1 },
113 'opus-5-5': { input: 4, output: 20, read: 0.2 },
114 'opus-5': { input: 5, output: 25, read: 0.5 },
115 'opus-4-8': { input: 5, output: 25, read: 0.5 },
116 'opus-4-7': { input: 5, output: 25, read: 0.5 },
117 'opus-4-6': { input: 5, output: 25, read: 0.5 },
118 'sonnet-5-5': { input: 2, output: 10, read: 0.2 },
119 'sonnet-5': { input: 2, output: 10, read: 0.2 },
120 'sonnet-4-6': { input: 3, output: 15, read: 0.3 },
121 // Haiku 5.5 liest den Cache zu 0,1× des jeweiligen Input-Preises.
122 'haiku-5-5': { input: 0.1, output: 0.5, read: 0.01 },
123 'haiku-4-5': { input: 1, output: 5, read: 0.1 },
124}
125// Haiku 5.5 kostet ab so viel Prompt das Fünffache.
126const HAIKU_LONG_FROM = 100_000
127const CACHE_WRITE = 2
128
129// "claude-opus-5-5", "claude-opus-5-5[1m]", "us.anthropic.claude-sonnet-4-6" → "opus-5-5", "sonnet-4-6".
130// Die Nebenversion hat höchstens zwei Ziffern, damit ein Datum dahinter nicht mitzählt.
131const modelKey = (model: string): string | undefined => {
132 const hit = /claude-(fable|mythos|opus|sonnet|haiku)-(\d{1,2}(?:-\d{1,2}(?!\d))?)/i.exec(model)
133 return hit ? `${hit[1]?.toLowerCase()}-${hit[2]}` : undefined
134}
135
136const priceOf = (model: string): Price | undefined => {
137 const key = modelKey(model)
138 return key === undefined ? undefined : PRICES[key]
139}
140
141// API-Gegenwert eines Aufrufs; undefined, wenn das Modell keinen Preis hat.
142const costOf = (u: TurnUsage): number | undefined => {
143 let p = priceOf(u.model)
144 if (!p) return undefined
145 const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
146 if (modelKey(u.model) === 'haiku-5-5' && prompt > HAIKU_LONG_FROM) p = { input: 0.5, output: 2.5, read: 0.05 }
147 return (
148 (u.input_tokens * p.input +
149 u.cache_creation_input_tokens * p.input * CACHE_WRITE +
150 u.cache_read_input_tokens * p.read +
151 u.output_tokens * p.output) /
152 1e6
153 )
154}
155
156// Kalt gegen warm: Neu schreiben in den 1-h-Cache gegen Lesen aus dem Cache. 20 bei 0,1×,
157// 40 bei Opus 5.5 (0,05×), 80 bei Fable und Mythos 5.1 (0,025×). $.session.model() nennt die
158// volle ID, auch hinter „opus“, samt „[1m]“; ohne Preis gilt der übliche Faktor.
159const coldFactor = (model: string): number => {
160 const p = priceOf(model)
161 return p ? Math.round((p.input * CACHE_WRITE) / p.read) : 20
162}
163
164const fmtTokens = (n: number): string =>
165 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`
166
167// Restzeit bis zum Reset: "in 1h12", "in 5T 18h".
168const fmtLeft = (ms: number): string => {
169 const min = Math.max(0, Math.round(ms / MIN))
170 const d = Math.floor(min / 1440)
171 const h = Math.floor((min % 1440) / 60)
172 const m = min % 60
173 return d ? `in ${d}T ${h}h` : h ? `in ${h}h${String(m).padStart(2, '0')}` : `in ${m}m`
174}
175
176// Wie lange der Cache schon ruht: "75 Min.", "12 h", "3 Tagen".
177const fmtIdle = (ms: number): string => {
178 const min = Math.round(ms / MIN)
179 if (min < 120) return `${min} Min.`
180 const h = Math.round(min / 60)
181 return h < 48 ? `${h} h` : `${Math.round(h / 24)} Tagen`
182}
183
184// Dasselbe knapp für die Statuszeile: "61′", "12h", "3T".
185const fmtIdleShort = (ms: number): string => {
186 const min = Math.round(ms / MIN)
187 if (min < 120) return `${min}′`
188 const h = Math.round(min / 60)
189 return h < 48 ? `${h}h` : `${Math.round(h / 24)}T`
190}
191
192// Zeitpunkt als Ortszeit: am selben Tag "18:30", sonst "Mo 09:00".
193const fmtWhen = (at: number, now: number): string => {
194 try {
195 const fmt = new Intl.DateTimeFormat('en-GB', {
196 timeZone: TZ,
197 weekday: 'short',
198 day: '2-digit',
199 hour: '2-digit',
200 minute: '2-digit',
201 hourCycle: 'h23',
202 })
203 const parts = (t: number): Record<string, string> =>
204 Object.fromEntries(fmt.formatToParts(t).map(p => [p.type, p.value]))
205 const r = parts(at)
206 const n = parts(now)
207 const time = `${r.hour}:${r.minute}`
208 return r.day === n.day && Math.abs(at - now) < DAY ? time : `${DAY_NAME[r.weekday ?? ''] ?? r.weekday} ${time}`
209 } catch {
210 // Laufzeit ohne Zeitzonen: dann wenigstens die Restzeit.
211 return fmtLeft(at - now)
212 }
213}
214
215const parseReset = (iso: string | undefined): number | undefined => {
216 if (!iso) return undefined
217 const at = Date.parse(iso)
218 return Number.isNaN(at) ? undefined : at
219}
220
221const fmtReset = (iso: string | undefined, now: number): string => {
222 const at = parseReset(iso)
223 return at === undefined ? '' : fmtWhen(at, now)
224}
225
226// Statuszeile: "▰▰▰▱▱▱▱▱", voll bei 1.
227const cells = (frac: number): string => {
228 const n = Math.max(0, Math.min(CELLS, Math.round(frac * CELLS)))
229 return '▰'.repeat(n) + '▱'.repeat(CELLS - n)
230}
231
232// Alter einer Limit-Messung, sobald es sich zu zeigen lohnt; sonst undefined.
233const ageOf = (l: Limit, now: number): number | undefined =>
234 l.at !== undefined && now - l.at >= STALE_MS ? now - l.at : undefined
235
236type Cache = { state: 'running' } | { state: 'warm'; leftMs: number } | { state: 'cold'; idleMs: number }
237
238const cacheOf = (s: Snapshot): Cache | undefined => {
239 if (s.running) return { state: 'running' }
240 if (!s.lastTurn) return undefined
241 const idle = s.at - s.lastTurn
242 return idle < COLD_MS ? { state: 'warm', leftMs: COLD_MS - idle } : { state: 'cold', idleMs: idle }
243}
244
245const ENDED = new Set(['completed', 'failed', 'killed', 'gone'])
246
247// Arbeitet der Agent noch, wartet die Session also auf ihn? Ein Teammate, das auf eine Nachricht
248// wartet (`idle`), zählt nicht, ein Loop ohne Listeneintrag auch nicht (UNLISTED_ACTIVE_MS).
249const isAgentActive = (a: AgentRow): boolean => a.status !== 'unlisted' && a.endedAt === undefined && a.status !== 'idle'
250
251const isRecentUnlisted = (a: AgentRow, now: number): boolean =>
252 a.status === 'unlisted' && a.lastCallAt > 0 && now - a.lastCallAt < UNLISTED_ACTIVE_MS
253
254// Ende eines Agenten für Laufzeit und Aufräumen; undefined solange er läuft.
255const agentEnd = (a: AgentRow, now: number): number | undefined =>
256 a.status === 'unlisted' ? (isRecentUnlisted(a, now) ? undefined : a.lastCallAt || a.startedAt) : a.endedAt
257
258const newAgent = (id: string, now: number): AgentRow => ({
259 id,
260 description: '',
261 type: '',
262 status: 'unlisted',
263 startedAt: now,
264 lastCallAt: 0,
265 calls: 0,
266 usd: 0,
267 unpriced: false,
268 outputTokens: 0,
269})
270
271// Zählt einen API-Aufruf zum Haupt-Loop oder zum Agenten, in dessen Loop er lief.
272const withStep = (b: Background, agentId: string | undefined, u: TurnUsage, now: number): Background => {
273 const usd = costOf(u)
274 if (agentId === undefined) {
275 return {
276 ...b,
277 main: { calls: b.main.calls + 1, usd: b.main.usd + (usd ?? 0), unpriced: b.main.unpriced || usd === undefined },
278 }
279 }
280 const had = b.agents[agentId] ?? newAgent(agentId, now)
281 return {
282 ...b,
283 agents: {
284 ...b.agents,
285 [agentId]: {
286 ...had,
287 calls: had.calls + 1,
288 usd: had.usd + (usd ?? 0),
289 unpriced: had.unpriced || usd === undefined,
290 outputTokens: had.outputTokens + u.output_tokens,
291 lastCallAt: now,
292 model: u.model,
293 },
294 },
295 }
296}
297
298// Übernimmt Beschreibung, Typ und Status aus $.agent.list(). Wer aus der Liste fällt, ohne dass ein
299// Ende zu sehen war, gilt ab jetzt als beendet. Über BG_KEEP fallen die ältesten beendeten heraus.
300const withList = (b: Background, list: AgentInfo[], now: number): Background => {
301 const agents = { ...b.agents }
302 const listed = new Set<string>()
303 for (const info of list) {
304 listed.add(info.id)
305 const had = agents[info.id] ?? newAgent(info.id, now)
306 const isEnded = ENDED.has(info.status)
307 agents[info.id] = {
308 ...had,
309 description: info.description,
310 type: info.type,
311 status: info.status,
312 endedAt: isEnded ? (had.endedAt ?? now) : undefined,
313 }
314 }
315 for (const [id, a] of Object.entries(agents)) {
316 if (!listed.has(id) && a.status !== 'unlisted' && a.endedAt === undefined) {
317 agents[id] = { ...a, status: 'gone', endedAt: now }
318 }
319 }
320 const ids = Object.keys(agents)
321 if (ids.length > BG_KEEP) {
322 const old = ids
323 .filter(id => agentEnd(agents[id] as AgentRow, now) !== undefined)
324 .sort((x, y) => (agentEnd(agents[x] as AgentRow, now) ?? 0) - (agentEnd(agents[y] as AgentRow, now) ?? 0))
325 let prunedUsd = b.prunedUsd ?? 0
326 for (const id of old.slice(0, ids.length - BG_KEEP)) {
327 prunedUsd += agents[id]?.usd ?? 0
328 delete agents[id]
329 }
330 return { ...b, agents, prunedUsd }
331 }
332 return { ...b, agents }
333}
334
335type TaskSummary = { id: string; type: string; description: string; command?: string }
336
337// Die Liste der Engine beim Turn-Ende ist der Massstab: was dort fehlt, ist fertig.
338// Agenten führt $.agent.list(), hier zählen nur Shells, Monitore, Workflows.
339const withTasks = (b: Background, tasks: readonly TaskSummary[], now: number): Background => {
340 const shells = { ...b.shells }
341 const live = new Set<string>()
342 for (const t of tasks) {
343 if (/agent/i.test(t.type)) continue
344 live.add(t.id)
345 const had = shells[t.id]
346 shells[t.id] = had
347 ? { ...had, endedAt: undefined }
348 : { id: t.id, kind: t.type, description: t.description || t.command || t.type, startedAt: now }
349 }
350 for (const [id, s] of Object.entries(shells)) {
351 if (!live.has(id) && s.endedAt === undefined) shells[id] = { ...s, endedAt: now }
352 }
353 for (const [id, s] of Object.entries(shells)) {
354 if (s.endedAt !== undefined && now - s.endedAt > BG_SHOWN_MS) delete shells[id]
355 }
356 return { ...b, shells }
357}
358
359// Die Benachrichtigung der Engine, dass eine Hintergrund-Arbeit endete: so sieht man das Ende schon vor dem
360// nächsten Turn-Ende. Form wie sie ankommt: <task-notification><task-id>…</task-id>…<status>completed</status>.
361const NOTIFIED = /<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/g
362const LIVE_STATUS = new Set(['running', 'pending'])
363
364const withNotified = (b: Background, text: string, now: number): Background => {
365 let shells = b.shells
366 for (const [, id = '', status = ''] of text.matchAll(NOTIFIED)) {
367 const s = shells[id.trim()]
368 if (s && s.endedAt === undefined && !LIVE_STATUS.has(status.trim())) {
369 shells = { ...shells, [s.id]: { ...s, endedAt: now } }
370 }
371 }
372 return shells === b.shells ? b : { ...b, shells }
373}
374
375const withShell = (b: Background, s: ShellRow): Background =>
376 b.shells[s.id] ? b : { ...b, shells: { ...b.shells, [s.id]: s } }
377
378type Busy = { agents: AgentRow[]; shells: ShellRow[]; usd: number }
379
380const busyOf = (b: Background, now: number): Busy => {
381 const agents = Object.values(b.agents).filter(isAgentActive)
382 const shells = Object.values(b.shells).filter(s => s.endedAt === undefined)
383 return { agents, shells, usd: agents.reduce((sum, a) => sum + a.usd, 0) }
384}
385
386const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
387
388// "2 Agenten, 1 Shell": Shells beim Namen, alles andere (Monitor, Workflow) als Task.
389const busyText = (busy: Busy, joiner: string): string => {
390 const shells = busy.shells.filter(s => s.kind === 'shell').length
391 const tasks = busy.shells.length - shells
392 return [
393 busy.agents.length ? plural(busy.agents.length, 'Agent', 'Agenten') : '',
394 shells ? plural(shells, 'Shell', 'Shells') : '',
395 tasks ? plural(tasks, 'Task', 'Tasks') : '',
396 ]
397 .filter(Boolean)
398 .join(joiner)
399}
400
401const fmtUsd = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
402
403// Laufzeit: "42″", "4′12", "1h05".
404const fmtRun = (ms: number): string => {
405 const sec = Math.max(0, Math.round(ms / 1000))
406 if (sec < 60) return `${sec}″`
407 const min = Math.floor(sec / 60)
408 if (min < 60) return `${min}′${String(sec % 60).padStart(2, '0')}`
409 return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
410}
411
412const statusLine = (s: Snapshot, b: Background): string => {
413 const parts = s.limits
414 .filter(l => LABEL[l.kind])
415 .map(l => {
416 const reset = fmtReset(l.resetsAt, s.at)
417 return `${LABEL[l.kind]} ${cells(l.percent / 100)} ${Math.round(l.percent)}%${reset ? ` ↻${reset}` : ''}`
418 })
419 // Alter der Limits einmal hinter den Balken, nach der ältesten Messung.
420 const ages = s.limits.filter(l => LABEL[l.kind]).flatMap(l => ageOf(l, s.at) ?? [])
421 if (parts.length && ages.length) parts[parts.length - 1] += ` (vor ${fmtIdleShort(Math.max(...ages))})`
422 const tokens = s.tokens
423 if (tokens === undefined) {
424 parts.push('Ctx –')
425 } else {
426 const flag = tokens >= ALARM ? ' ⚠ wechseln' : tokens >= WARN ? ' ⚠' : ''
427 parts.push(`Ctx ${cells(tokens / ALARM)} ${fmtTokens(tokens)}${flag}`)
428 }
429 const cache = cacheOf(s)
430 if (cache && (cache.state === 'running' || (tokens ?? 0) >= COLD_SHOWN_FROM)) {
431 parts.push(
432 cache.state === 'running'
433 ? '● aktiv'
434 : cache.state === 'warm'
435 ? `● warm ${Math.ceil(cache.leftMs / MIN)}′`
436 : `❄ kalt ${fmtIdleShort(cache.idleMs)}`,
437 )
438 }
439 // Nur solange im Hintergrund etwas arbeitet.
440 const busy = busyOf(b, s.at)
441 if (busy.agents.length || busy.shells.length) {
442 parts.push(`⏳ ${busyText(busy, ' ')}${busy.agents.length ? ` ${fmtUsd(busy.usd)}` : ''}`)
443 }
444 // Nur solange der Auto-Neustart an ist.
445 if (s.auto?.an) parts.push(autoPart(s.auto))
446 return parts.join(' │ ')
447}
448
449// "🔁 auto", in einer Kette "🔁 auto 2/5", an der Bremse "🔁 auto 5/5 – wartet".
450const autoPart = (a: Auto): string =>
451 a.kette >= a.max ? `🔁 auto ${a.kette}/${a.max} – wartet` : a.kette > 0 ? `🔁 auto ${a.kette}/${a.max}` : '🔁 auto'
452
453// Wohin das Fenster beim jetzigen Tempo bis zum Reset kommt.
454type Pace =
455 | { kind: 'early' }
456 | { kind: 'past' }
457 | { kind: 'full' }
458 | { kind: 'projected'; percent: number; hitAt?: number }
459
460const paceOf = (l: Limit, now: number): Pace | undefined => {
461 const span = SPAN[l.kind]
462 const reset = parseReset(l.resetsAt)
463 if (!span || reset === undefined) return undefined
464 if (now >= reset) return { kind: 'past' }
465 if (l.percent >= 100) return { kind: 'full' }
466 const start = reset - span
467 const elapsed = now - start
468 if (elapsed < span * PACE_FROM || l.percent <= 0) return { kind: 'early' }
469 const percent = (l.percent * span) / elapsed
470 return percent >= 100 ? { kind: 'projected', percent, hitAt: start + (elapsed * 100) / l.percent } : { kind: 'projected', percent }
471}
472
473const zoneColor = (value: number, zones: [number, string][]): string =>
474 zones.find(([to]) => value < to)?.[1] ?? 'error'
475
476type Segment = { text: string; color: string; isEmpty: boolean }
477
478// Balken in Farbzonen: gefüllt kräftig, der Rest gedämpft, so sieht man die Schwellen auch leer.
479const gauge = (value: number, max: number, zones: [number, string][], width: number): Segment[] => {
480 const out: Segment[] = []
481 for (let i = 0; i < width; i++) {
482 const mid = ((i + 0.5) / width) * max
483 const color = zoneColor(mid, zones)
484 const isEmpty = value < mid
485 const ch = isEmpty ? '░' : '█'
486 const last = out[out.length - 1]
487 if (last && last.color === color && last.isEmpty === isEmpty) last.text += ch
488 else out.push({ text: ch, color, isEmpty })
489 }
490 return out
491}
492
493// Modulzustand: ein Reload setzt ihn zurück, session.start läuft dann neu.
494let active = false
495let busy = false
496let again = false
497let shown: string | undefined
498// Wann diese Session zuletzt Limits von der API bekam (ms); 0 noch nie.
499let measuredAt = 0
500// Woran refresh eine neue API-Antwort erkennt: Kosten und Limits beim letzten Lesen.
501let seenUsd: number | undefined
502let seenLimits: string | undefined
503let isBased = false
504// Laufende Turns: solange einer läuft, ist der Cache in Gebrauch.
505const turns = new Set<string>()
506// Auto-Neustart: die ID vor dem letzten /clear, bis der Schalter umgezogen ist ('' nichts umzuziehen); was das
507// letzte classic.Stop an Crons und Hintergrund-Arbeit nannte (-1 unbekannt, undefined seit dem Turn-Beginn
508// keines); ob ein Prompt von Mike hinter dem laufenden Turn wartet; der laufende Countdown und seine Generation
509// (jeder Abbruch zählt hoch); welcher Neustart zuletzt an den sessions-Mod ging; für welchen Stand die Meldung
510// „Kette erreicht“ schon kam.
511let clearedFrom = ''
512let moving: Promise<void> | undefined
513let lastStop: { crons: number; tasks: number } | undefined
514let queuedOwn = false
515let ticking: { cancel: () => void } | undefined
516let generation = 0
517let fired: { id: string; at: number } | undefined
518let limitShown = ''
519
520const turnKey = (id: string): string => `turn:${id}`
521
522// Ende des letzten Haupt-Turns dieser Session. Liegt im Store, damit es einen Reload und
523// ein --resume übersteht.
524async function lastTurn($: EngineInterface): Promise<number | undefined> {
525 try {
526 const at = await $.store.get(turnKey(await $.session.id()))
527 return typeof at === 'number' ? at : undefined
528 } catch {
529 return undefined
530 }
531}
532
533// Limits kommen nur mit einer API-Antwort. Damit eine ruhende Session nicht den Stand ihrer
534// letzten Antwort zeigt, legt jede Session ihre Messung in einer Datei ab, die alle Sessions
535// dieses Rechners lesen. Eine Datei statt $.store, weil die Sessions eigene Prozesse sind.
536// Je Konfigurationsordner eine Datei: ein anderer Ordner kann ein anderes Konto sein.
537async function sharedFile($: EngineInterface): Promise<string> {
538 const config = await $.env.get('CLAUDE_CONFIG_DIR')
539 const dir = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
540 if (!config && !dir) throw new Error('HOME ist nicht gesetzt')
541 return `${config || `${dir}/.claude`}/mod-daten/verbrauch/limits.json`
542}
543
544const isLimit = (x: unknown): x is Limit => {
545 if (typeof x !== 'object' || x === null) return false
546 const l = x as Record<string, unknown>
547 return (
548 typeof l.kind === 'string' &&
549 typeof l.percent === 'number' &&
550 Number.isFinite(l.percent) &&
551 (l.resetsAt === undefined || typeof l.resetsAt === 'string') &&
552 (l.at === undefined || typeof l.at === 'number') &&
553 (l.session === undefined || typeof l.session === 'string')
554 )
555}
556
557// Die geteilten Messungen; fehlt die Datei oder ist sie halb geschrieben, gibt es keine.
558async function readShared($: EngineInterface): Promise<Limit[]> {
559 try {
560 const data = JSON.parse(await $.fs.read(await sharedFile($))) as { limits?: unknown } | null
561 return Array.isArray(data?.limits) ? data.limits.filter(isLimit) : []
562 } catch {
563 return []
564 }
565}
566
567// Welche von zwei Messungen gilt. Im selben Fenster steigt der Verbrauch nur: die höhere, so
568// schlägt ein falsch frischer Stempel keinen echten Stand. Sonst die jüngere, denn sie kennt den
569// Reset oder das neue Fenster; ohne Zeitpunkt zählt eine Messung als älteste.
570const better = (a: Limit, b: Limit): Limit => {
571 const ra = parseReset(a.resetsAt)
572 const rb = parseReset(b.resetsAt)
573 if (ra !== undefined && rb !== undefined && Math.abs(ra - rb) < SAME_WINDOW_MS && a.percent !== b.percent) {
574 return a.percent > b.percent ? a : b
575 }
576 const at = (a.at ?? 0) - (b.at ?? 0)
577 if (at !== 0) return at > 0 ? a : b
578 return a.percent >= b.percent ? a : b
579}
580
581// Je Fenster die geltende Messung, in der Reihenfolge des ersten Auftretens.
582const merge = (...lists: Limit[][]): Limit[] => {
583 const out = new Map<string, Limit>()
584 for (const l of lists.flat()) {
585 const had = out.get(l.kind)
586 out.set(l.kind, had ? better(had, l) : l)
587 }
588 return [...out.values()]
589}
590
591const fromApi = (limits: SessionRateLimit[], at: number, session: string): Limit[] =>
592 limits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt, at: at || undefined, session }))
593
594// Nach dem Reset ist ein Fenster leer, bis wieder jemand etwas braucht.
595const current = (l: Limit, now: number): Limit => {
596 const reset = parseReset(l.resetsAt)
597 return reset !== undefined && reset <= now ? { kind: l.kind, percent: 0 } : l
598}
599
600// Die eigenen Limits zusammen mit den geteilten. Kam seit dem letzten Lesen eine API-Antwort
601// (Kosten gestiegen oder Limits anders), ist die eigene Messung frisch und geht in die Datei.
602async function limitsOf($: EngineInterface, u: SessionUsage, now: number): Promise<Limit[]> {
603 const usd = u.cost?.usd
604 const key = JSON.stringify(u.rateLimits)
605 const answered = isBased && ((usd ?? 0) > (seenUsd ?? 0) || key !== seenLimits)
606 isBased = true
607 seenUsd = usd
608 seenLimits = key
609 if (answered && u.rateLimits.length) measuredAt = now
610 const shared = await readShared($)
611 const merged = merge(fromApi(u.rateLimits, measuredAt, await $.session.id()), shared)
612 if (answered && u.rateLimits.length && JSON.stringify(merged) !== JSON.stringify(shared)) {
613 try {
614 await $.fs.write(await sharedFile($), JSON.stringify({ limits: merged }))
615 } catch {
616 // Ohne Ablage zeigen die anderen Sessions nur ihren eigenen Stand.
617 }
618 }
619 return merged.map(l => current(l, now))
620}
621
622// Pickup-Datei dieser Session aus der Marke des sessions-Mods; null wenn es keine Marke gibt (Mod läuft nicht).
623async function pickupOf($: EngineInterface): Promise<Pickup | null> {
624 try {
625 const dir = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
626 if (!dir) return null
627 const data = JSON.parse(await $.fs.read(`${dir}/.claude/mod-daten/sessions/${await $.session.id()}.json`)) as {
628 pickup?: unknown
629 file?: unknown
630 } | null
631 return {
632 phrase: typeof data?.pickup === 'string' ? data.pickup : '',
633 file: typeof data?.file === 'string' ? data.file : '',
634 }
635 } catch {
636 return null
637 }
638}
639
640// Erster Knopf: nur nachfragen. Die Nachfrage verfällt nach CONFIRM_MS.
641async function askRestart($: EngineInterface): Promise<void> {
642 const armedUntil = (await $.clock.now()) + CONFIRM_MS
643 await update($, confirm, () => armedUntil)
644 $.clock.after(CONFIRM_MS, () => {
645 void update($, confirm, v => (v === armedUntil ? 0 : v)).catch(() => undefined)
646 })
647}
648
649// „Ja“: lässt den sessions-Mod sichern und neu starten. Seine Antwort steht im Verlauf; gemeldet wird nur ein Fehler.
650async function confirmRestart($: EngineInterface): Promise<void> {
651 const now = await $.clock.now()
652 const until = await read($, confirm)
653 if (until <= now || now < until - CONFIRM_MS + CONFIRM_MIN_MS) return
654 await update($, confirm, () => 0)
655 try {
656 await $.command.run({ command: 'pause', args: 'selbst' })
657 } catch (err) {
658 $.ui.toast(`Sichern & Neustart ging nicht: ${String(err).slice(0, 160)}`, { timeoutMs: 10_000 })
659 }
660}
661
662// Hält den Store klein: Sessions, die einen Monat ruhen, fallen heraus.
663async function prune($: EngineInterface): Promise<void> {
664 const now = await $.clock.now()
665 for (const key of await $.store.keys()) {
666 if (key.startsWith('auto:')) {
667 const a = asAuto(await $.store.get(key))
668 if (!a || now - a.at > KEEP_MS) await $.store.delete(key)
669 continue
670 }
671 if (!key.startsWith('turn:')) continue
672 const at = await $.store.get(key)
673 if (typeof at !== 'number' || now - at > KEEP_MS) await $.store.delete(key)
674 }
675}
676
677const autoKey = (id: string): string => `auto:${id}`
678
679const freshAuto = (now: number): Auto => ({ an: false, grenze: AUTO_GRENZE, max: AUTO_MAX, kette: 0, letzter: 0, at: now })
680
681const isNum = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x)
682
683// Ein Eintrag aus dem Store; ein kaputter oder fremder Wert gilt als keiner. Felder älterer Stände fallen weg.
684const asAuto = (x: unknown): Auto | undefined => {
685 if (typeof x !== 'object' || x === null) return undefined
686 const a = x as Record<string, unknown>
687 if (typeof a.an !== 'boolean' || !isNum(a.grenze) || !isNum(a.max) || !isNum(a.kette) || !isNum(a.letzter) || !isNum(a.at)) {
688 return undefined
689 }
690 return { an: a.an, grenze: a.grenze, max: a.max, kette: a.kette, letzter: a.letzter, at: a.at }
691}
692
693// Nach /clear läuft der Prozess unter neuer ID weiter: der Eintrag zieht mit, sobald $.session.id() die neue nennt.
694// Schalter, Grenze und Max. bleiben. Die Kette nur nach dem eigenen Auto-Neustart; ein /clear von Hand ist Mike.
695function migrate($: EngineInterface): Promise<void> {
696 if (!clearedFrom) return Promise.resolve()
697 moving ??= (async () => {
698 const from = clearedFrom
699 const id = await $.session.id()
700 if (id === from) return
701 // Kam inzwischen noch ein /clear, gilt dessen ID.
702 if (clearedFrom === from) clearedFrom = ''
703 const now = await $.clock.now()
704 const isOwn = fired !== undefined && fired.id === from && now - fired.at < FIRED_VALID_MS
705 if (isOwn) fired = undefined
706 const old = asAuto(await $.store.get(autoKey(from)))
707 if (!old) return
708 await $.store.set(autoKey(id), { ...old, kette: isOwn ? old.kette : 0, at: now })
709 await $.store.delete(autoKey(from))
710 })().finally(() => {
711 moving = undefined
712 })
713 return moving
714}
715
716// Der Auto-Neustart dieser Session; undefined ohne Eintrag.
717async function autoOf($: EngineInterface): Promise<Auto | undefined> {
718 await migrate($)
719 return asAuto(await $.store.get(autoKey(await $.session.id())))
720}
721
722// Liest den Eintrag frisch und schreibt fn(Eintrag) gleich danach, ohne Warten dazwischen: so überschreibt keine
723// Änderung eine andere, die während eines früheren await kam. fn gibt undefined zurück, wenn nichts zu schreiben
724// ist; ohne Eintrag gelten die Standardwerte (create) oder es bleibt beim Nichts.
725async function patchAuto($: EngineInterface, fn: (a: Auto) => Auto | undefined, create = true): Promise<Auto | undefined> {
726 await migrate($)
727 const key = autoKey(await $.session.id())
728 const now = await $.clock.now()
729 const had = asAuto(await $.store.get(key)) ?? (create ? freshAuto(now) : undefined)
730 const next = had && fn(had)
731 if (!next) return undefined
732 await $.store.set(key, { ...next, at: now })
733 return next
734}
735
736// Warum es keine Pickup-Datei gibt, für Pane und Befehl.
737const noPickupWhy = (p: Pickup | null | undefined): string =>
738 p === null || p === undefined ? 'keine Marke vom sessions-Mod'
739 : p.phrase ? `„${p.phrase}“ hat keine 🔑-Zeile in MEMORY.md`
740 : 'Session ohne 🔑-Phrase'
741
742// "400k", "400000", "0.4m" → Tokens; sonst der Grund als Text. Unter dem Kontextfenster, wenn es bekannt ist.
743const parseGrenze = (text: string, window: number): number | string => {
744 const hit = /^(\d+(?:[.,]\d+)?)\s*([km])?$/i.exec(text.trim())
745 const unit = hit?.[2]?.toLowerCase()
746 const n = hit?.[1] ? Math.round(Number(hit[1].replace(',', '.')) * (unit === 'm' ? 1e6 : unit === 'k' ? 1e3 : 1)) : NaN
747 if (!Number.isFinite(n)) return `„${text.trim()}“ ist keine Grenze: z. B. 400k, 300000 oder 0.4m.`
748 if (n < GRENZE_MIN || n > GRENZE_MAX) {
749 return `Grenze ${fmtTokens(n)} geht nicht: erlaubt sind ${fmtTokens(GRENZE_MIN)} bis ${fmtTokens(GRENZE_MAX)}.`
750 }
751 if (window > 0 && n >= window) {
752 return `Grenze ${fmtTokens(n)} geht nicht: sie muss unter dem Kontextfenster (${fmtTokens(window)}) liegen.`
753 }
754 return n
755}
756
757const parseMax = (text: string): number | string => {
758 const n = /^\d+$/.test(text.trim()) ? Number(text.trim()) : NaN
759 return n >= MAX_MIN && n <= MAX_MAX ? n : `„${text.trim()}“ geht nicht als Max. Neustarts: eine ganze Zahl von ${MAX_MIN} bis ${MAX_MAX}.`
760}
761
762const AUTO_USAGE =
763 'Aufruf: /auto (Stand), /auto an [grenze] [max] (z. B. /auto an 300k 5), /auto aus, /auto grenze 300k, /auto max 5'
764
765type Said = { ok: boolean; text: string }
766
767// Hält einen Neustart an, den fire schon an den sessions-Mod gab (Sicherungs-Turn läuft oder steht an). Zwei Wege:
768// die Abbruch-Datei, die der sessions-Mod vor dem /clear liest (sofort, auch mitten im Turn), und /pause abbrechen
769// (läuft erst, wenn die Session ruht: $.command.run wartet darauf). Nur wenn fire hier eben auslöste.
770async function stopHandedOver($: EngineInterface): Promise<void> {
771 const now = await $.clock.now()
772 const id = await $.session.id()
773 if (!fired || fired.id !== id || now - fired.at >= FIRED_VALID_MS) return
774 fired = undefined
775 try {
776 const dir = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
777 if (!dir) throw new Error('HOME ist nicht gesetzt')
778 await $.fs.write(`${dir}/.claude/mod-daten/sessions/${id}.abbruch`, JSON.stringify({ at: now }))
779 } catch (err) {
780 $.ui.toast(`Auto-Neustart: Abbruch an den sessions-Mod ging nicht: ${String(err).slice(0, 160)}`, { timeoutMs: 10_000 })
781 }
782 // Nicht aus dem /auto-Befehl heraus: ein Befehl darf keinen anderen starten, solange er läuft.
783 $.clock.after(200, () => {
784 void $.command.run({ command: 'pause', args: 'abbrechen' }).catch(err => {
785 $.ui.toast(`Auto-Neustart: /pause abbrechen ging nicht: ${String(err).slice(0, 160)}`, { timeoutMs: 10_000 })
786 })
787 })
788}
789
790// /auto und die Bedienelemente im Pane: ändert den Eintrag oder sagt, warum nicht (der alte Wert bleibt).
791// Grenze oder Max. bei ausgeschaltetem Auto-Neustart: die Werte gelten erst nach dem Einschalten.
792const OFF_HINT = ' Gilt erst, wenn er an ist: /auto an oder Knopf „einschalten“.'
793
794async function autoCommand($: EngineInterface, args: string): Promise<Said> {
795 const [sub = '', ...rest] = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
796 const fail = (text: string): Said => ({ ok: false, text })
797 if (sub === 'aus' && !rest.length) {
798 // Zuerst, vor jedem await: ein Countdown oder ein fire, das gerade wartet, schreibt danach nichts mehr.
799 generation++
800 const wasCounting = cancelCountdown($)
801 await patchAuto($, a => ({ ...a, an: false, kette: 0 }), false)
802 await stopHandedOver($)
803 await refresh($)
804 return { ok: true, text: wasCounting ? 'Auto-Neustart aus, der Countdown ist abgebrochen.' : 'Auto-Neustart aus.' }
805 }
806 const had = (await autoOf($)) ?? freshAuto(await $.clock.now())
807 const u = await $.session.usage()
808 // Zu Grenze und Max. bei ausgeschaltetem Auto-Neustart: wie er angeht, oder warum er hier nicht angehen kann.
809 const offNote = async (): Promise<string> => {
810 if (had.an) return ''
811 const p = await pickupOf($)
812 return p?.file ? OFF_HINT : ` Einschalten geht hier nicht: ${noPickupWhy(p)}.`
813 }
814 let change: Partial<Auto>
815 let text: string
816 if (sub === '') {
817 const p = await pickupOf($)
818 const k = `Kette ${had.kette}/${had.max}`
819 return {
820 ok: true,
821 text: had.an
822 ? `Auto-Neustart an: ab ${fmtTokens(had.grenze)} Kontext (jetzt ${fmtTokens(u.context.tokens ?? 0)}), ${k}` +
823 `${had.kette >= had.max ? ', wartet auf dich' : ''}${p?.file ? '' : `; löst nicht aus: ${noPickupWhy(p)}`}.\n${AUTO_USAGE}`
824 : `Auto-Neustart aus (Grenze ${fmtTokens(had.grenze)}, höchstens ${had.max} in Folge)` +
825 `${p?.file ? '' : `; einschalten geht hier nicht: ${noPickupWhy(p)}`}.\n${AUTO_USAGE}`,
826 }
827 }
828 if (sub === 'an' && rest.length <= 2) {
829 const p = await pickupOf($)
830 if (!p?.file) return fail(`Auto-Neustart bleibt aus: ${noPickupWhy(p)}. Er braucht eine Pickup-Datei (🔑-Zeile in MEMORY.md).`)
831 const grenze = parseGrenze(rest[0] ?? String(had.grenze), u.context.window)
832 if (typeof grenze === 'string') return fail(rest[0] === undefined ? `${grenze} Mit anderer Grenze: /auto an 150k` : grenze)
833 const max = rest[1] === undefined ? had.max : parseMax(rest[1])
834 if (typeof max === 'string') return fail(max)
835 change = { an: true, grenze, max, kette: 0 }
836 text =
837 `Auto-Neustart an: ab ${fmtTokens(grenze)} Kontext sichert diese Session am Turn-Ende ihren Stand in ` +
838 `${p.file.split(/[\\/]/).pop() ?? p.file}, startet frisch mit „${p.phrase}“ und arbeitet weiter, höchstens ` +
839 `${max}-mal in Folge ohne dich. Aus: /auto aus.`
840 } else if (sub === 'grenze' && rest.length === 1) {
841 const grenze = parseGrenze(rest[0] ?? '', u.context.window)
842 if (typeof grenze === 'string') return fail(grenze)
843 change = { grenze }
844 text = `Auto-Neustart: Grenze jetzt ${fmtTokens(grenze)}.${await offNote()}`
845 } else if (sub === 'max' && rest.length === 1) {
846 const max = parseMax(rest[0] ?? '')
847 if (typeof max === 'string') return fail(max)
848 change = { max }
849 text =
850 `Auto-Neustart: höchstens ${max} Neustarts in Folge.` +
851 (had.an && had.kette >= max ? ` Die Kette steht schon bei ${had.kette}: sie wartet, bis du schreibst.` : '') +
852 (await offNote())
853 } else {
854 return fail(AUTO_USAGE)
855 }
856 // Frisch gelesen geschrieben: was inzwischen kam (etwa die Kette aus fire), bleibt.
857 await patchAuto($, a => ({ ...a, ...change }))
858 await refresh($)
859 return { ok: true, text }
860}
861
862// −/+ im Pane (auch am Telefon, das keine Felder zeichnet): Grenze in 50k-Schritten, Max. Neustarts um 1.
863async function stepAuto($: EngineInterface, what: 'grenze' | 'max', dir: 1 | -1): Promise<void> {
864 try {
865 const a = (await autoOf($)) ?? freshAuto(await $.clock.now())
866 let value = what === 'max' ? a.max + dir : a.grenze + dir * GRENZE_STEP
867 // Abwärts bei einer Grenze über dem Fenster: gleich auf die höchste erlaubte Stufe darunter.
868 const window = (await $.session.usage()).context.window
869 if (what === 'grenze' && dir < 0 && window > 0 && value >= window) value = Math.floor((window - 1) / GRENZE_STEP) * GRENZE_STEP
870 const said = await autoCommand($, `${what} ${value}`)
871 if (!said.ok || said.text.endsWith(OFF_HINT)) $.ui.toast(said.text, { timeoutMs: 10_000 })
872 } catch (err) {
873 $.ui.toast(`Auto-Neustart: Änderung ging nicht: ${String(err).slice(0, 160)}`, { timeoutMs: 10_000 })
874 }
875}
876
877// Eingabefeld, Knopf an/aus: wie der Befehl, eine Absage als Meldung.
878async function autoInput($: EngineInterface, args: string): Promise<void> {
879 try {
880 const said = await autoCommand($, args)
881 // Gesetzt, aber der Schalter ist aus: sagen, sonst sieht der Wert im Feld wie „eingerichtet“ aus.
882 if (said.ok && said.text.endsWith(OFF_HINT)) $.ui.toast(said.text, { timeoutMs: 10_000 })
883 if (said.ok) return
884 $.ui.toast(said.text, { timeoutMs: 10_000 })
885 // Nach einer Absage neu zeichnen: das Feld zeigt dann wieder den alten Wert.
886 await refresh($)
887 } catch (err) {
888 $.ui.toast(`Auto-Neustart: Änderung ging nicht: ${String(err).slice(0, 160)}`, { timeoutMs: 10_000 })
889 }
890}
891
892const stopTicking = (): void => {
893 ticking?.cancel()
894 ticking = undefined
895}
896
897// Bricht einen laufenden Countdown ab; true, wenn einer lief. Zählt die Generation hoch, damit ein fire, das
898// schon läuft, nach seinem nächsten await aufhört.
899function cancelCountdown($: EngineInterface, why?: string): boolean {
900 generation++
901 if (!ticking) return false
902 stopTicking()
903 void update($, countdown, () => 0).catch(() => undefined)
904 if (why) $.ui.toast(why, { timeoutMs: 6_000 })
905 return true
906}
907
908// Countdown vor dem Auto-Neustart: Toast, Pane-Zeile mit Abbrechen, danach fire.
909function startCountdown($: EngineInterface, id: string): void {
910 if (ticking) return
911 const gen = ++generation
912 let left = COUNTDOWN_S
913 void update($, countdown, () => left).catch(() => undefined)
914 $.ui.toast(
915 `Auto-Neustart in ${COUNTDOWN_S} s: Die Session sichert ihren Stand und startet frisch. Abbrechen: schreiben, ` +
916 '/auto aus oder Taste x im /verbrauch-Pane.',
917 { timeoutMs: COUNTDOWN_S * 1_000 },
918 )
919 ticking = $.clock.every(1_000, () => {
920 left--
921 if (left > 0) {
922 void update($, countdown, () => left).catch(() => undefined)
923 return
924 }
925 stopTicking()
926 void fire($, id, gen)
927 })
928}
929
930// Ende des Countdowns: Kette zählen, dann sichert und startet der sessions-Mod (/pause auto). Nach jedem await
931// prüft fire die Generation: ein Abbruch in der Zwischenzeit (Knopf, /auto aus, Prompt, Turn) gewinnt. Gemeldet
932// wird nur, was nicht klappte; die Antwort des sessions-Mods steht im Verlauf.
933async function fire($: EngineInterface, id: string, gen: number): Promise<void> {
934 try {
935 await update($, countdown, () => 0)
936 if (gen !== generation || turns.size || (await $.session.id()) !== id || gen !== generation) return
937 const now = await $.clock.now()
938 if (gen !== generation) return
939 // Frisch gelesen: nur wenn noch an, nie ein an über ein aus schreiben.
940 const counted = await patchAuto($, a => (a.an && gen === generation ? { ...a, kette: a.kette + 1, letzter: now } : undefined), false)
941 if (!counted || gen !== generation) return
942 fired = { id, at: now }
943 void refresh($)
944 const { text = '' } = await $.command.run({ command: 'pause', args: 'auto' })
945 // Der sessions-Mod startet nicht neu (keine Pickup-Datei, läuft schon, alte Version ohne auto): melden.
946 // Kein Auto-Neustart vorgemerkt: fired vergessen, sonst bricht ein späteres /auto aus Mikes eigenes /pause ab.
947 if (/^(Kein Neustart|Läuft schon|Aufruf)/.test(text)) fired = undefined
948 if (/^(Kein Neustart|Läuft schon|Aufruf)/.test(text)) $.ui.toast(`Auto-Neustart: ${text.slice(0, 200)}`, { timeoutMs: 10_000 })
949 } catch (err) {
950 $.ui.toast(`Auto-Neustart ging nicht: ${String(err).slice(0, 160)}`, { timeoutMs: 10_000 })
951 }
952}
953
954// Läuft im Hintergrund noch etwas? Jeder Agent, der nicht beendet ist, auch ein wartendes Teammate (idle) oder
955// ein eben aktiver Loop ohne Listeneintrag: /clear darf keinen abschneiden. Shells, Monitore, Workflows dazu.
956const isAnythingAlive = (b: Background, now: number): boolean =>
957 Object.values(b.agents).some(a => (a.status === 'unlisted' ? isRecentUnlisted(a, now) : a.endedAt === undefined)) ||
958 Object.values(b.shells).some(s => s.endedAt === undefined)
959
960// Kurz nach dem Ende eines Haupt-Turns: löst den Countdown aus, wenn alles passt. id ist die Session dieses
961// Turns: kam inzwischen ein /clear (der sessions-Mod nach dem Sicherungs-Turn) oder ein neuer Turn, nichts tun.
962async function maybeTrigger($: EngineInterface, id: string, isAnswer: boolean): Promise<void> {
963 if (turns.size || (await $.session.id()) !== id) return
964 const a = await autoOf($)
965 if (!a?.an || !isAnswer || ticking || turns.size || queuedOwn) return
966 const tokens = (await $.session.usage()).context.tokens ?? 0
967 if (tokens < a.grenze) return
968 // Eine /loop-Session verlöre durch /clear ihre Crons; jede Hintergrund-Arbeit, auch ein Subagent, wartet auf
969 // ihr Ende.
970 if (!lastStop || lastStop.crons !== 0 || lastStop.tasks !== 0) return
971 const now = await $.clock.now()
972 if (isAnythingAlive(await read($, bg), now)) return
973 if (!(await pickupOf($))?.file) return
974 if (a.kette >= a.max) {
975 if (limitShown !== `${id}:${a.kette}`) {
976 limitShown = `${id}:${a.kette}`
977 $.ui.toast(`Auto-Neustart: ${a.kette}/${a.max} in Folge erreicht, die Session wartet auf dich.`, { timeoutMs: 10_000 })
978 }
979 return
980 }
981 if (now - a.letzter < AUTO_GAP_MS) return
982 if (turns.size || (await $.session.id()) !== id) return
983 startCountdown($, id)
984}
985
986// Mike schreibt (Tastatur oder Telefon): ein Countdown bricht ab, die Kette zählt von vorn. Einen schon
987// übergebenen Neustart hält der sessions-Mod selbst an (sein prompt.submit sieht denselben Prompt).
988async function ownPrompt($: EngineInterface, isQueued: boolean): Promise<void> {
989 try {
990 if (isQueued) queuedOwn = true
991 limitShown = ''
992 cancelCountdown($, 'Auto-Neustart abgebrochen: du hast geschrieben.')
993 fired = undefined
994 if ((await autoOf($))?.kette) {
995 await patchAuto($, a => (a.kette ? { ...a, kette: 0 } : undefined), false)
996 void refresh($)
997 }
998 } catch {
999 // Dann zählt die Kette weiter; die Bremse bleibt.
1000 }
1001}
1002
1003// Prod-Deploys, die eine Auto-Kette ohne Mike nicht starten darf: ansible-playbook ohne --check, --syntax-check
1004// oder -C; vercel mit --prod; wrangler deploy/publish, auch pages deploy und versions deploy. Nur an Befehlsstelle:
1005// am Anfang, nach ; && || | $( ` oder ( , auch hinter sudo, env VAR=…, VAR=…, time/nohup/nice/timeout N,
1006// uv/poetry run, bash/sh/zsh -c '…', einem Pfad oder npx.
1007// Bewusst nicht erkannt (Sperre ist eine zweite Linie hinter der Halt-Liste im Prompt): Skripte und Makefiles
1008// (./deploy.sh, make deploy, npm run deploy), Variablen und eval, xargs, ssh auf einen anderen Rechner, Deploys,
1009// die ein git push in der CI auslöst.
1010const AT_COMMAND =
1011 String.raw`(?:^|[;&|\n(` + '`' + String.raw`])\s*(?:(?:sudo|env)(?:\s+(?:-\S+|\w+=\S*))*\s+|\w+=\S*\s+|` +
1012 String.raw`(?:time|nohup|nice|command|exec)(?:\s+-\S+)*\s+|timeout(?:\s+-\S+)*\s+\S+\s+|` +
1013 String.raw`(?:uv|poetry|pipenv|pdm|hatch)\s+run(?:\s+-\S+)*\s+)*` +
1014 String.raw`(?:(?:ba|z|da)?sh\s+-c\s+['"]\s*)?(?:\S*/)?(?:(?:npx|bunx|pnpx)\s+|(?:pnpm|yarn)\s+(?:dlx\s+)?)?`
1015const ANSIBLE = new RegExp(`${AT_COMMAND}ansible-playbook(?=\\s|$|['"])`, 'g')
1016const VERCEL = new RegExp(`${AT_COMMAND}vercel(?=\\s|$|['"])`, 'g')
1017const WRANGLER = new RegExp(`${AT_COMMAND}wrangler\\s+(?:(?:pages|versions)\\s+)?(?:deploy|publish)\\b`)
1018// Der eigene Teilbefehl hinter einem Treffer, bis zum nächsten Trenner oder Anführungszeichen. Endet er an einem
1019// Anführungszeichen vor einem --check, gilt er als Deploy: lieber zu streng.
1020const restOf = (command: string, end: number): string => command.slice(end).split(/[;&|\n`)'"]/)[0] ?? ''
1021const hits = (re: RegExp, command: string): string[] =>
1022 [...command.matchAll(re)].map(m => restOf(command, (m.index ?? 0) + m[0].length))
1023const isDeploy = (command: string): boolean =>
1024 hits(ANSIBLE, command).some(rest => !/(?:^|\s)(?:--check|--syntax-check|-C)(?=\s|=|$)/.test(rest)) ||
1025 hits(VERCEL, command).some(rest => /(?:^|\s)--prod(?=\s|=|$)/.test(rest)) ||
1026 WRANGLER.test(command)
1027
1028const DEPLOY_HINT =
1029 'Ein Prod-Deploy (ansible-playbook ohne --check, vercel --prod, wrangler deploy/publish) braucht Mike. Nicht umgehen: ' +
1030 'schreib in die Pickup-Datei `Auto-Weiter: nein — Prod-Deploy wartet auf Mike`, berichte kurz und warte, bis Mike schreibt.'
1031// Ohne lesbaren Schalter: lieber sperren als einen Deploy ohne Mike durchlassen.
1032const DEPLOY_UNKNOWN = `Gesperrt vom verbrauch-Mod: Ob eine Auto-Neustart-Kette läuft, war nicht lesbar. ${DEPLOY_HINT}`
1033
1034// Sperrt den Deploy, solange der Auto-Neustart an ist und seit dem letzten Prompt von Mike schon einer lief.
1035async function deployLock($: EngineInterface): Promise<string | undefined> {
1036 const a = await autoOf($)
1037 return a?.an && a.kette >= 1
1038 ? `Gesperrt vom verbrauch-Mod: Diese Session läuft in einer Auto-Neustart-Kette ohne Mike (${a.kette}/${a.max}). ${DEPLOY_HINT}`
1039 : undefined
1040}
1041
1042// Die Pickup-Phrase, mit der die Session begann ("mods pickup"), für den Hinweis beim Anhalten.
1043async function pickupPhrase($: EngineInterface): Promise<string | undefined> {
1044 const rows = await $.session.messages()
1045 for (const row of rows.slice(0, 10)) {
1046 if (row.role !== 'user' || row.text.includes('<')) continue
1047 const hit = /^\s*(.{1,40}?\bpickup)\b/i.exec(row.text)
1048 if (hit) return hit[1]
1049 }
1050 return undefined
1051}
1052
1053// Meldet jede Schwelle einmal: Kontext je Stufe, Limits je Fenster und Stufe.
1054async function warn($: EngineInterface, u: SessionUsage, now: number): Promise<void> {
1055 const before = await read($, warned)
1056 let after: Warned = before
1057 const tokens = u.context.tokens ?? 0
1058 const level = tokens >= ALARM ? 2 : tokens >= WARN ? 1 : 0
1059 if (level > before.context) {
1060 $.ui.toast(
1061 level === 2
1062 ? `Kontext über ${fmtTokens(ALARM)}: Session per Pickup sichern und neu starten lohnt sich jetzt deutlich.`
1063 : `Kontext über ${fmtTokens(WARN)}: Jeder Call liest das komplett neu. Ab hier spart ein Session-Wechsel spürbar Tokens.`,
1064 { timeoutMs: 10_000 },
1065 )
1066 after = { ...after, context: level }
1067 } else if (level < before.context) {
1068 // Nach /compact oder /clear wieder scharf.
1069 after = { ...after, context: level }
1070 }
1071 for (const l of u.rateLimits) {
1072 const label = LABEL[l.kind]
1073 const hit = LIMIT_LEVELS.find(p => l.percentUsed >= p)
1074 if (!label || hit === undefined) continue
1075 const window = l.resetsAt ?? ''
1076 const [prevWindow, prevHit] = (after.limits[l.kind] ?? '|').split('|')
1077 if (prevWindow === window && Number(prevHit) >= hit) continue
1078 const reset = fmtReset(l.resetsAt, now)
1079 $.ui.toast(`${label}-Limit bei ${Math.round(l.percentUsed)} %${reset ? `, Reset ${reset}` : ''}.`, { timeoutMs: 10_000 })
1080 after = { ...after, limits: { ...after.limits, [l.kind]: `${window}|${hit}` } }
1081 }
1082 if (after !== before) await update($, warned, () => after)
1083}
1084
1085// Schreibt nur, wenn sich etwas geändert hat: jeder Schreibvorgang zeichnet das Pane neu.
1086async function updateBg($: EngineInterface, fn: (b: Background) => Background): Promise<Background> {
1087 const before = await read($, bg)
1088 if (JSON.stringify(fn(before)) === JSON.stringify(before)) return before
1089 return update($, bg, fn)
1090}
1091
1092// Gleicht die Agenten mit $.agent.list() ab und merkt sich beim ersten Mal, wo die Engine beim
1093// API-Gegenwert stand. Scheitert etwas, bleibt der letzte Stand: Statuszeile und Warnungen laufen weiter.
1094async function syncBackground(
1095 $: EngineInterface,
1096 now: number,
1097 usd: number | undefined,
1098 startedAt: number,
1099): Promise<Background | undefined> {
1100 try {
1101 const list = await $.agent.list().catch(() => undefined)
1102 return await updateBg($, b => {
1103 const listed = list ? withList(b, list, now) : b
1104 if (usd === undefined) return listed
1105 const since = listed.since
1106 if (!since) return { ...listed, since: { at: now, usd, from: startedAt } }
1107 // Die Engine zählt nach /clear wieder ab 0 (neues startedAt), das Atom ist dann auch leer. Kam der Startwert
1108 // noch vom alten Stand (erstes Lesen gleich nach dem /clear), gilt ab dem neuen Start 0; ein Wert unter dem
1109 // Startwert heisst dasselbe, auch wenn startedAt beim Lesen noch der alte war.
1110 if (since.from !== undefined && startedAt !== since.from) return { ...listed, since: { at: startedAt, usd: 0, from: startedAt } }
1111 return usd < since.usd ? { ...listed, since: { ...since, usd: 0 } } : listed
1112 })
1113 } catch {
1114 return read($, bg).catch(() => undefined)
1115 }
1116}
1117
1118async function reconcile($: EngineInterface, tasks: readonly TaskSummary[]): Promise<void> {
1119 try {
1120 const now = await $.clock.now()
1121 await updateBg($, b => withTasks(b, tasks, now))
1122 void refresh($)
1123 } catch {
1124 // Dann gleicht erst das nächste Turn-Ende ab.
1125 }
1126}
1127
1128async function refresh($: EngineInterface): Promise<void> {
1129 // Läuft schon einer, danach noch einmal: sonst ginge etwa der Turn-Beginn bis zum nächsten Takt verloren.
1130 if (busy) {
1131 again = true
1132 return
1133 }
1134 busy = true
1135 try {
1136 const u = await $.session.usage()
1137 const now = await $.clock.now()
1138 const s: Snapshot = {
1139 at: now,
1140 limits: await limitsOf($, u, now),
1141 tokens: u.context.tokens,
1142 window: u.context.window,
1143 usd: u.cost?.usd,
1144 startedAt: u.startedAt,
1145 lastTurn: (await lastTurn($)) ?? 0,
1146 running: turns.size > 0,
1147 pickup: await pickupOf($),
1148 auto: (await autoOf($).catch(() => undefined)) ?? null,
1149 }
1150 await update($, snap, () => s)
1151 const b = await syncBackground($, now, u.cost?.usd, u.startedAt)
1152 const text = statusLine(s, b ?? { agents: {}, shells: {}, main: { calls: 0, usd: 0, unpriced: false } })
1153 if (text !== shown) {
1154 shown = text
1155 $.ui.status(text)
1156 }
1157 await warn($, u, now)
1158 } catch {
1159 // Anzeige ist Komfort: ein Fehler darf nie eine Session stören.
1160 } finally {
1161 busy = false
1162 if (again) {
1163 again = false
1164 void refresh($)
1165 }
1166 }
1167}
1168
1169export const register: Register = on => {
1170 on('session.start', async ($, e, next) => {
1171 const started = await next(e)
1172 // claude -p (Skripte) hat keine Statuszeile: dort nichts tun.
1173 active = e.isInteractive
1174 if (!active) return started
1175 shown = undefined
1176 turns.clear()
1177 await update($, confirm, () => 0)
1178 measuredAt = 0
1179 isBased = false
1180 clearedFrom = ''
1181 lastStop = undefined
1182 queuedOwn = false
1183 limitShown = ''
1184 fired = undefined
1185 generation++
1186 stopTicking()
1187 await update($, countdown, () => 0)
1188 await $.command.register({
1189 name: 'verbrauch',
1190 description: 'Verbrauch als Grafik: Limits mit Prognose, Kontext, Cache warm oder kalt',
1191 })
1192 // Läuft sofort, auch mitten in einem Turn: /auto aus soll nicht bis zum Turn-Ende warten.
1193 await $.command.register({
1194 name: 'auto',
1195 description: 'Auto-Neustart dieser Session: ab einer Kontextgrenze selbst sichern, frisch starten, weiterarbeiten',
1196 argumentHint: '[an [grenze] [max]|aus|grenze 300k|max 5]',
1197 immediate: true,
1198 })
1199 await prune($).catch(() => undefined)
1200 await refresh($)types/index.d.ts 138 lines1export type Warned = {
2 /** Kontextstufe, die schon gemeldet wurde: 0 keine, 1 über WARN, 2 über ALARM. */
3 context: number
4 /** Je Limit-Fenster (`five_hour`, `seven_day`): `<resetsAt>|<prozent>` der letzten Meldung. */
5 limits: Record<string, string>
6}
7
8/** Ein Limit-Fenster, wie `$.session.usage()` es meldet, oder die geteilte Messung einer Session. */
9export type Limit = {
10 kind: string
11 percent: number
12 resetsAt?: string
13 /** Wann gemessen (ms), also wann die API-Antwort kam; fehlt, wenn unbekannt. */
14 at?: number
15 /** Session, aus deren API-Antwort der Wert stammt; fehlt, wenn unbekannt. */
16 session?: string
17}
18
19/** Der letzte gelesene Stand, aus dem das Pane zeichnet. */
20export type Snapshot = {
21 /** Wann gelesen (ms); 0 noch nie. */
22 at: number
23 limits: Limit[]
24 /** Kontext in Tokens; fehlt vor der ersten Antwort. */
25 tokens?: number
26 /** Kontextfenster des Modells in Tokens. */
27 window: number
28 /** API-Gegenwert der Session in USD, wie /cost ihn zählt. */
29 usd?: number
30 /** Beginn der Session (ms). */
31 startedAt: number
32 /** Ende des letzten Haupt-Turns (ms); 0 noch keiner. */
33 lastTurn: number
34 /** Ein Turn läuft gerade: der Cache wird laufend genutzt. */
35 running: boolean
36 /** Pickup-Phrase und -Datei laut Marke des sessions-Mods; null wenn der Mod hier nicht läuft. */
37 pickup?: Pickup | null
38 /** Der Auto-Neustart dieser Session, wie er im Store steht; null ohne Eintrag (aus, Standardwerte). */
39 auto?: Auto | null
40}
41
42/**
43 * Auto-Neustart einer Session, im Store unter `auto:<sessionId>`: Atome leert ein /clear, der Store bleibt, und
44 * der Eintrag zieht auf die neue ID um. Werte gelten pro Session.
45 */
46export type Auto = {
47 /** Eingeschaltet. */
48 an: boolean
49 /** Ab so viel Kontext (Tokens) löst er am Turn-Ende aus. */
50 grenze: number
51 /** Höchstens so viele Auto-Neustarts in Folge ohne Prompt von Mike. */
52 max: number
53 /** Auto-Neustarts seit dem letzten Prompt (Tastatur oder Telefon) oder /clear von Mike. */
54 kette: number
55 /** Beginn des letzten Auto-Neustarts (ms); 0 noch keiner. */
56 letzter: number
57 /** Letzte Änderung (ms): danach räumt der Mod alte Einträge auf. */
58 at: number
59}
60
61/** Was der sessions-Mod in seiner Marke über die Pickup-Datei dieser Session festhält; '' wenn keine. */
62export type Pickup = { phrase: string; file: string }
63
64/** Ein Agent-Loop dieser Session, wie der Mod ihn sieht: aus `$.agent.list()` und den eigenen API-Aufrufen. */
65export type AgentRow = {
66 id: string
67 /** Die paar Worte des Agent-Aufrufs; '' für einen Loop ohne Listeneintrag (Workflow, interner Fork). */
68 description: string
69 /** Agent-Typ (`Explore`, `general-purpose`, ...); '' ohne Listeneintrag. */
70 type: string
71 /**
72 * AgentStatus laut Liste; `gone`, wenn er aus der Liste fiel, ohne dass ein Ende zu sehen war;
73 * `unlisted` für einen Loop, den die Liste nie nannte.
74 */
75 status: string
76 /** Wann zuerst gesehen (ms), in der Liste oder beim ersten API-Aufruf. */
77 startedAt: number
78 /** Wann zuerst als beendet gesehen (ms); fehlt, solange er läuft. */
79 endedAt?: number
80 /** Letzter API-Aufruf (ms); 0 noch keiner. */
81 lastCallAt: number
82 /** API-Aufrufe seit dem Laden des Mods. */
83 calls: number
84 /** API-Gegenwert in USD aus den gezählten Tokens. */
85 usd: number
86 /** Ein Aufruf lief auf einem Modell ohne Preis in der Tabelle: `usd` ist dann zu tief. */
87 unpriced: boolean
88 /** Ausgegebene Tokens. */
89 outputTokens: number
90 /** Modell des letzten Aufrufs. */
91 model?: string
92}
93
94/** Eine Hintergrund-Arbeit ohne eigenen Loop: Shell, Monitor, Workflow. */
95export type ShellRow = {
96 id: string
97 /** `shell`, `monitor`, `workflow`, ... wie die Engine sie nennt. */
98 kind: string
99 description: string
100 startedAt: number
101 endedAt?: number
102}
103
104/** Was im Hintergrund läuft und lief, samt Kosten je Agent. */
105export type Background = {
106 agents: Record<string, AgentRow>
107 shells: Record<string, ShellRow>
108 /** Kosten des Haupt-Loops seit Beginn der Zählung. */
109 main: { calls: number; usd: number; unpriced: boolean }
110 /** Beginn der Zählung (ms) und der API-Gegenwert der Engine in diesem Moment, zum Abgleich. */
111 since?: { at: number; usd: number; from?: number }
112 /** Kosten der Agenten, die über BG_KEEP herausgefallen sind: bleiben in der Summe. */
113 prunedUsd?: number
114 /**
115 * Wie oft die Engine nach einem Turn einen Vorschlag fürs Eingabefeld erzeugt hat. Jeder kostet einen
116 * Modell-Aufruf über den ganzen Kontext, der an keinem `turn.step` vorbeikommt: der Hauptteil dessen,
117 * was die Engine mehr zählt als der Mod (live gemessen 2026-10-14: ~$0.06 je Turn-Ende bei 280k).
118 */
119 suggestions?: number
120}
121
122declare module 'claude-code' {
123 interface PluginState {
124 verbrauch: {
125 warned: Warned
126 /** Ende des Haupt-Turns (ms), nach dem die Cache-Sperre schon einmal angehalten hat; 0 noch nie. */
127 held: number
128 snap: Snapshot
129 /** Bis wann (ms) der zweite Druck auf „Sichern & neu starten“ gilt; 0 kein erster Druck. */
130 confirm: number
131 /** Hintergrund-Agenten und -Shells dieser Session mit ihren Kosten. */
132 bg: Background
133 /** Sekunden bis zum Auto-Neustart, solange der Countdown läuft; 0 kein Countdown. */
134 countdown: number
135 }
136 }
137}
138