SLOPSHOPPER

verbrauch

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…

newpaneguardcommandtoaststatus
v0.7.2MITupdated 2026-10-15skaterino/claude-mods/verbrauch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verbrauch
│ ┃ Verbrauch ✕ › fix the failing auth test and add an audit log call │ ┃ 5 Stunden ██████████░░░░░░░░░░░░░░░░░░░░░ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Kontext ██████░░░░░░░░░░░░░░░░░░░░░░░░░ ⎿ Added 2 lines, removed 1 line │ ┃ Warnung ab 250k · wechseln ab ⏺ Bash(bun test) │ ┃ 400k ⎿ 3 pass, 1 fail │ ┃ Cache ● warm, läuft in 60 Min. ab │ ┃ Pickup unbekannt: keine Marke vom sess ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Auto-Neustart: nicht möglich, ✻ Worked for 42s · done 4:20 PM │ ┃ keine Marke vom sessions-Mod │ ┃ › /verbrauch │ ┃ ⎿ verbrauch: Verbrauch offen, /verbrauch schliesst ihn wieder. │ ┃ Session seit 10:23 · API-Gegenwert $0.4 │ ┃ │ ┃ Hintergrund nichts läuft │ ┃ │ ┃ │ ┃ │ ┃ Stand 10:53 · /verbrauch schliesst │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ verbrauch: 5h ▰▰▱▱▱▱▱▱ 31% │ Ctx ▰▰▱▱▱▱▱▱ 97k │ ● warm 60′

Draws

Pane · Verbrauch
5 Stunden ██████████░░░░░░░░░░░░░░░░░░░░░░ 31 % Kontext ██████░░░░░░░░░░░░░░░░░░░░░░░░░░ 97k Warnung ab 250k · wechseln ab 400k Cache ● warm, läuft in 60 Min. ab Pickup unbekannt: keine Marke vom sessions-Mod Auto-Neustart: nicht möglich, keine Marke vom sessions-Mod Session seit 10:23 · API-Gegenwert $0.42 Hintergrund nichts läuft Stand 10:53 · /verbrauch schliesst
README

claude-mods

Mikes Mods für Claude Code, auf Deutsch. Entwickelt auf dem Mac mini, MIT-Lizenz.

ModVersionKurz
verbrauch/0.7.2Statuszeile + /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

Laden

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.

Arbeiten

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.

Source 2 files
hooks/register.tsx 1787 lines
1import { 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 lines
1export 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