SLOPSHOPPER

helfer-band

Dashboard /helfer und Band über dem Prompt: Kontingente von Claude, Codex und Antigravity, FreeLLMAPI, Free Claude Code und laufende Hintergrund-Aufträge auf…

newpanebandcommandtoastprocess
v0.2.0MITupdated 2026-10-13MacStenk/claude-mods/plugins/helfer-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helfer-band
│ ┃ Helfer ✕ › fix the failing auth test and add an audit log call │ ┃ Lade helfer-status … │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /helfer │ ⎿ helfer-band: Helfer-Dashboard geöffnet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Helfer
Lade helfer-status …
Pane · helfer-artikel
╭──────────────────────────────────────────────────────────╮ │ Artikel (0) │ │ Lade Artikel von stevennoack.de … │ ╰──────────────────────────────────────────────────────────╯ Esc schließt
README

helfer-band

English summary: A Claude Code mod that adds a /helfer dashboard and a status band above the prompt. It shows usage limits of Claude, OpenAI Codex and Google Antigravity, the local FreeLLMAPI and Free Claude Code proxies, and background jobs started by the user's own helper scripts. Everything is read locally; nothing is sent anywhere unless HELFER_LINKS=1 is set (see below). Sections for tools that are not installed show "nicht eingerichtet" (not set up).

Was die Mod zeigt

  • Claude: 5-Stunden-Fenster und Woche dieses Abos, aus den Daten, die Claude Code selbst liefert.
  • Codex (OpenAI): Auslastung aus den lokalen Sitzungsdateien unter ~/.codex/sessions.
  • Antigravity (Google): verbleibende Kontingente je Modellgruppe.
  • FreeLLMAPI: eingerichtete Anbieter, Anfragen und Tokens von heute.
  • Free Claude Code: ob der lokale Proxy läuft, Modell, bereite und fehlgeschlagene Anbieter.
  • Aufträge: laufende und beendete Hintergrund-Aufträge der letzten 24 Stunden mit Dauer, Tokens und Link zum Bericht.

Was die Mod ausführt, liest und abruft

Offen gelegt für die Prüfung und für alle, die sie installieren:

  • Datenlieferant: Die Mod startet alle 10 Sekunden helfer-status --json. Liegt eine ausführbare Datei unter ~/.local/bin/helfer-status, nimmt sie diese (eigene Anpassung des Nutzers), sonst das mitgelieferte bin/helfer-status (Python 3).
  • Lokale Dateien, nur lesend: ~/.codex/sessions, ~/.local/state/helfer/auftraege.jsonl, die Protokolldatei .helfer.log im Ordner eines Auftrags, die FreeLLMAPI-Datenbank unter ~/Library/Application Support/FreeLLMAPI/freeapi.db (mit sqlite3 -readonly). Schlüssel werden nicht gelesen.
  • Lokale Programme, falls vorhanden: agy -p /usage (Antigravity-Kontingent) und ~/.local/bin/ki-weiche (eigene Empfehlung des Nutzers).
  • Lokales Netz: GET http://127.0.0.1:8082/admin/api/status für Free Claude Code (Port über FCC_PORT). Nur Status, keine Schlüssel.
  • Internet: nur mit gesetzter Umgebungsvariable HELFER_LINKS=1. Dann lädt /artikel die Artikelliste von https://stevennoack.de/index.json und das Dashboard zeigt Links zu stevennoack.de. Ohne die Variable gibt es keinen Abruf.
  • Knopf „Bericht öffnen“: öffnet die ERGEBNIS.md eines Auftrags mit open -a TextEdit (macOS).
  • Eine Datei, die die Mod schreibt: ~/.local/state/helfer/claude-kontingent.json mit den Prozentwerten und Rücksetzzeiten des Claude-Kontingents (sonst nichts). Die Datei liest das optionale Skript ki-weiche des Nutzers. Andere Dateien schreibt die Mod nicht.

Warum die Mod Programme startet

Die Prüfung von Anthropic meldet in hooks/register.tsx einen Programmaufruf, dessen Befehl sie nicht ganz lesen kann (Zeile mit $.process.run([bin, '--json'])). bin ist genau einer von zwei festen Pfaden: ~/.local/bin/helfer-status, falls vorhanden, sonst ${CLAUDE_PLUGIN_ROOT}/bin/helfer-status aus diesem Plugin. Das Argument ist immer --json. Der zweite Aufruf, open -a TextEdit <Pfad>, öffnet nur eine ERGEBNIS.md zum Lesen. Die Mod nimmt keine Befehle von einem Server an und reicht keine Netzwerkdaten an Programme weiter.

Was die Hooks tun

  • session.start: startet den Datenlieferanten alle 10 Sekunden und registriert die Befehle /helfer (und mit HELFER_LINKS=1 auch /artikel).
  • command.run für helfer und artikel: öffnet das Dashboard bzw. die Artikelliste.
  • ui.render für das eigene Panel und AbovePrompt: zeichnet das Dashboard, die Artikelliste und das Band über dem Prompt aus den gelesenen Daten. Die Hooks lesen und verändern keine anderen Aufrufe.

Datenschutz

Die Mod erhebt, speichert und überträgt keine personenbezogenen Daten. Alles bleibt auf dem eigenen Rechner. Die einzige Verbindung ins Internet ist der Abruf der öffentlichen Artikelliste https://stevennoack.de/index.json, und den gibt es nur mit HELFER_LINKS=1. Dabei werden keine Daten aus dem Rechner mitgeschickt. Die Mod schickt keine Eingaben ab und ändert keine Einstellungen.

Voraussetzungen

Claude Code mit Mod-Unterstützung, Python 3. Optional: codex, agy, FreeLLMAPI, Free Claude Code.

Installation, Anpassung und das JSON-Format von helfer-status stehen im README des Repos: https://github.com/MacStenk/claude-mods

Lizenz

MIT, siehe LICENSE.

Source 2 files
hooks/register.tsx 403 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ArtikelEintrag, Auftrag, FccStand, Fenster, Stand } from '../types'
5
6const auftraege = atom({ plugin: 'helfer-band', key: 'auftraege' } as const, [] as Auftrag[])
7const jetzt = atom({ plugin: 'helfer-band', key: 'jetzt' } as const, 0)
8const stand = atom({ plugin: 'helfer-band', key: 'stand' } as const, null as Stand | null)
9const claude = atom({ plugin: 'helfer-band', key: 'claude' } as const, [] as Fenster[])
10const artikel = atom({ plugin: 'helfer-band', key: 'artikel' } as const, [] as ArtikelEintrag[])
11const PANE = 'helfer-dashboard'
12const ARTIKEL_PANE = 'helfer-artikel'
13
14export function datumFormatiert(ts: number | string): string {
15  const d = new Date(typeof ts === 'number' ? (ts > 1e11 ? ts : ts * 1000) : ts)
16  if (isNaN(d.getTime())) return ''
17  const tag = String(d.getDate()).padStart(2, '0')
18  const monat = String(d.getMonth() + 1).padStart(2, '0')
19  const jahr = d.getFullYear()
20  return `${tag}.${monat}.${jahr}`
21}
22
23export function sortierteArtikel(entries: ArtikelEintrag[]): ArtikelEintrag[] {
24  const zeit = (v: number | string | undefined): number => {
25    if (typeof v === 'number') return v > 1e11 ? v : v * 1000
26    if (typeof v === 'string') {
27      const n = Number(v)
28      if (!isNaN(n)) return n > 1e11 ? n : n * 1000
29      const t = new Date(v).getTime()
30      return isNaN(t) ? 0 : t
31    }
32    return 0
33  }
34  return [...entries].sort((a, b) => zeit(b.published_at) - zeit(a.published_at))
35}
36
37export function sichtbareArtikel(entries: ArtikelEintrag[], rows?: number): ArtikelEintrag[] {
38  const room = Math.max(1, (rows ?? 24) - 4)
39  return entries.slice(0, room)
40}
41
42const ZIEL_NAME: Record<string, string> = {
43  'agy-sonnet': 'helfer auto → agy mit Claude Sonnet (Antigravity)',
44  'agy-gemini': 'helfer auto → agy mit Gemini (Antigravity)',
45  'claude-code': 'helfer auto → Claude Code (dein Abo)',
46  codex: 'helfer auto → Codex (OpenAI-Abo)',
47  keins: 'nichts frei, Aufträge warten',
48}
49
50export function wann(ziel: Date, jetzt: Date = new Date()): string {
51  const hhmm = `${String(ziel.getHours()).padStart(2, '0')}:${String(ziel.getMinutes()).padStart(2, '0')}`
52  const tag = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
53  const diffTage = Math.round((tag(ziel) - tag(jetzt)) / 86400000)
54  const min = Math.round((ziel.getTime() - jetzt.getTime()) / 60000)
55  const abstand = min <= 0 ? '' : min < 60 ? ` (in ${min} Min.)` : min < 1440 ? ` (in ${Math.floor(min / 60)} Std. ${min % 60} Min.)` : ''
56  if (diffTage === 0) return `heute ${hhmm}${abstand}`
57  if (diffTage === 1) return `morgen ${hhmm}${abstand}`
58  const wt = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'][ziel.getDay()]
59  return `${wt} ${String(ziel.getDate()).padStart(2, '0')}.${String(ziel.getMonth() + 1).padStart(2, '0')}. ${hhmm}`
60}
61
62export function empfehlung(st: Stand, cl: Fenster[]): string[] {
63  if (st.weiche && (st.weiche.schreiben || st.weiche.bauen)) {
64    const z: string[] = []
65    const n = (w: { ziel: string } | null | undefined) => (w ? ZIEL_NAME[w.ziel] ?? w.ziel : 'unbekannt')
66    if (st.weiche.schreiben) z.push(`Schreiben: ${n(st.weiche.schreiben)}`)
67    if (st.weiche.bauen) z.push(`Bauen: ${n(st.weiche.bauen)}`)
68    z.push('Einzelfragen, Texte, Klassifizieren: FreeLLMAPI (127.0.0.1:31415)')
69    const woche = cl.find(f => f.kind === 'seven_day')?.percentUsed ?? 0
70    if (woche >= 80) z.push(`Claude sparen: Woche bei ${Math.round(woche)} %, nur planen und prüfen`)
71    return z
72  }
73  const k = st.kontingent.codex
74  const woche = cl.find(f => f.kind === 'seven_day')?.percentUsed ?? 0
75  const z: string[] = []
76  z.push(k && k.fenster_prozent >= 95 ? `Bauen: helfer adler (Codex frei ab ${uhr(k.fenster_reset)})` : 'Bauen: helfer codex')
77  z.push('Einzelfragen, Texte, Klassifizieren: FreeLLMAPI (127.0.0.1:31415)')
78  if (woche >= 80) z.push(`Claude sparen: Woche bei ${Math.round(woche)} %, nur planen und prüfen`)
79  return z
80}
81
82export function zahl(n: number): string {
83  return n.toLocaleString('de-DE')
84}
85
86export type FccZeile = {
87  text: string
88  farbe?: 'green' | 'red'
89  dim?: boolean
90}
91
92export function fccZeilen(fcc: FccStand | null | undefined): FccZeile[] {
93  if (!fcc) {
94    return [{ text: 'nicht gestartet (App ‚Free Claude Code‘ oder `fcc-server`)', dim: true }]
95  }
96  const zeilen: FccZeile[] = []
97  zeilen.push({ text: `${fcc.laeuft ? 'läuft' : 'gestoppt'}${fcc.modell ? ` · Modell ${fcc.modell}` : ''}` })
98  if (fcc.bereit && fcc.bereit.length > 0) {
99    zeilen.push({ text: `bereit: ${fcc.bereit.join(', ')}`, farbe: 'green' })
100  }
101  if (fcc.fehler && fcc.fehler.length > 0) {
102    zeilen.push({ text: `Start fehlgeschlagen: ${fcc.fehler.join(', ')}`, farbe: 'red' })
103  }
104  zeilen.push({ text: `${fcc.eingerichtet} Anbieter eingerichtet, ${fcc.modelle} Modelle` })
105  return zeilen
106}
107
108function uhr(ts: number): string {
109  const d = new Date(ts * 1000)
110  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
111}
112
113export function restText(alle: { wer: string; start: number; ende: number | null; rc: number | null }[], a: { wer: string; start: number }, nun: number): string {
114  const fertig = alle.filter(x => x.wer === a.wer && x.ende !== null && x.rc === 0).map(x => (x.ende as number) - x.start)
115  if (fertig.length === 0) return ''
116  const ueblich = Math.round(fertig.reduce((s, d) => s + d, 0) / fertig.length)
117  const bisher = nun - a.start
118  return bisher < ueblich ? `, noch etwa ${dauer(ueblich - bisher)}` : `, länger als üblich (sonst ${dauer(ueblich)})`
119}
120
121export function balken(prozent: number, breite = 20): string {
122  const voll = Math.max(0, Math.min(breite, Math.round((prozent / 100) * breite)))
123  return '█'.repeat(voll) + '░'.repeat(breite - voll)
124}
125
126// Beendete Aufträge bleiben so lange sichtbar
127const NACHLAUF_S = 30 * 60
128
129export function auswerten(text: string, nun: number): Auftrag[] {
130  const byId = new Map<string, Auftrag>()
131  for (const zeile of text.split('\n')) {
132    if (!zeile.trim()) continue
133    let z: Record<string, unknown>
134    try { z = JSON.parse(zeile) } catch { continue }
135    const id = String(z.id ?? '')
136    if (!id) continue
137    if (typeof z.start === 'number') {
138      byId.set(id, {
139        id, wer: String(z.wer ?? '?'), ordner: String(z.ordner ?? ''), auftrag: String(z.auftrag ?? ''),
140        start: z.start, ende: byId.get(id)?.ende ?? null, rc: byId.get(id)?.rc ?? null,
141      })
142    } else if (typeof z.ende === 'number') {
143      const a = byId.get(id)
144      if (a) { a.ende = z.ende; a.rc = typeof z.rc === 'number' ? z.rc : null }
145    }
146  }
147  return [...byId.values()]
148    .filter(a => a.ende === null ? nun - a.start < 24 * 3600 : nun - a.ende < NACHLAUF_S)
149    .sort((a, b) => b.start - a.start)
150}
151
152export function dauer(s: number): string {
153  const m = Math.floor(s / 60)
154  if (m < 1) return `${s} s`
155  return m >= 60 ? `${Math.floor(m / 60)} Std. ${m % 60} Min.` : `${m} Min.`
156}
157
158export const register: Register = on => {
159  on('session.start', async ($, e, next) => {
160    const home = (await $.env.get('HOME')) ?? ''
161    const pfad = `${home}/.local/state/helfer/auftraege.jsonl`
162    const laden = async () => {
163      const nun = Math.floor((await $.clock.now()) / 1000)
164      let text = ''
165      try { text = await $.fs.read(pfad) } catch { text = '' }
166      await update($, auftraege, () => auswerten(text, nun))
167      await update($, jetzt, () => nun)
168    }
169    await laden()
170    $.clock.every(5000, () => { void laden() })
171
172    const statusLaden = async () => {
173      try {
174        let bin = `${home}/.local/bin/helfer-status`
175        let hatLocal = false
176        try {
177          await $.fs.stat(bin)
178          hatLocal = true
179        } catch {
180          hatLocal = false
181        }
182        if (!hatLocal) {
183          const pluginRoot = (await $.env.get('CLAUDE_PLUGIN_ROOT')) ?? $.plugin.root ?? ''
184          if (pluginRoot) {
185            bin = `${pluginRoot}/bin/helfer-status`
186          }
187        }
188        const r = await $.process.run([bin, '--json'])
189        if (r.exitCode === 0) await update($, stand, () => JSON.parse(r.stdout) as Stand)
190        const u = await $.session.usage()
191        await update($, claude, () => u.rateLimits.map(f => ({ kind: f.kind, percentUsed: f.percentUsed, resetsAt: f.resetsAt })))
192        // Für ki-weiche: Claude-Code-Kontingent als Datei (nur Prozentwerte)
193        try { await $.fs.write(`${home}/.local/state/helfer/claude-kontingent.json`, JSON.stringify(u.rateLimits.map(f => ({ kind: f.kind, percentUsed: f.percentUsed, resetsAt: f.resetsAt })))) } catch { /* ohne Datei nimmt ki-weiche Claude Code nicht */ }
194      } catch { /* nächster Versuch in 10 s */ }
195    }
196    void statusLaden()
197    $.clock.every(10000, () => { void statusLaden() })
198
199    const linksAktiv = Boolean(await $.env.get('HELFER_LINKS'))
200    if (linksAktiv) {
201      const artikelLaden = async () => {
202        try {
203          const r = await $.http.fetch('https://stevennoack.de/index.json')
204          if (r.ok) {
205            const daten = JSON.parse(r.text) as { entries?: ArtikelEintrag[] }
206            if (Array.isArray(daten.entries)) {
207              await update($, artikel, () => sortierteArtikel(daten.entries!))
208            }
209          }
210        } catch {
211          /* Fehler still abfangen */
212        }
213      }
214      void artikelLaden()
215      $.clock.every(10 * 60 * 1000, () => { void artikelLaden() })
216
217      await $.command.register({ name: 'artikel', description: 'Steven Noacks Artikel (stevennoack.de)' })
218    }
219
220    await $.command.register({ name: 'helfer', description: 'Dashboard: Helfer-Aufträge, Codex-Kontingent, FreeLLMAPI' })
221    return next(e)
222  })
223
224  on('command.run', { command: 'helfer' }, async $ => {
225    await $.ui.open({ id: PANE, title: 'Helfer' })
226    return { text: 'Helfer-Dashboard geöffnet.' }
227  })
228
229  on('command.run', { command: 'artikel' }, async $ => {
230    const linksAktiv = Boolean(await $.env.get('HELFER_LINKS'))
231    if (!linksAktiv) {
232      return { text: 'Artikel-Panel ist deaktiviert (HELFER_LINKS nicht gesetzt).' }
233    }
234    await $.ui.open({ id: ARTIKEL_PANE, title: 'Artikel' })
235    return { text: 'Artikel-Panel geöffnet.' }
236  })
237
238  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
239    const { Box, Button, Link, Text } = $.ui.resolve(e)
240    const st = await read($, stand)
241    if (!st) return <Text dimColor>Lade helfer-status …</Text>
242    const k = st.kontingent.codex
243    const cl = await read($, claude)
244    const farbe = (p: number) => (p >= 90 ? 'red' : p >= 70 ? 'yellow' : 'green')
245    const name: Record<string, string> = { five_hour: '5-Std.-Fenster', seven_day: 'Woche         ' }
246    const port = String(st.fcc?.port || (await $.env.get('FCC_PORT')) || '8082')
247    const linksAktiv = Boolean(await $.env.get('HELFER_LINKS'))
248    return (
249      <Box flexDirection="column" gap={1}>
250        {linksAktiv ? (
251          <Box flexDirection="row" gap={2}>
252            <Link href="https://stevennoack.de/schreiben/">Schreiben</Link>
253            <Link href="https://stevennoack.de/logbuch/">Logbuch</Link>
254            <Link href="https://stevennoack.de/freigabe/">Freigaben</Link>
255            <Link href="https://stevennoack.de/">Website</Link>
256            <Button
257              key="pane-artikel"
258              label="Artikel"
259              onPress={async () => {
260                await $.ui.open({ id: ARTIKEL_PANE, title: 'Artikel' })
261              }}
262            />
263          </Box>
264        ) : null}
265        <Box flexDirection="column" borderStyle="round" paddingX={1}>
266          <Text bold>Jetzt nehmen</Text>
267          {empfehlung(st, cl).map(z => <Text key={z}>→ {z}</Text>)}
268        </Box>
269        <Box flexDirection="column" borderStyle="round" paddingX={1}>
270          <Text bold>Claude (dieses Abo)</Text>
271          {cl.length === 0 && <Text dimColor>noch keine Messung in dieser Sitzung</Text>}
272          {cl.map(f => (
273            <Text key={f.kind}>{name[f.kind] ?? f.kind} <Text color={farbe(f.percentUsed)}>{balken(f.percentUsed)}</Text> {Math.round(f.percentUsed)} %{f.resetsAt ? <Text dimColor>  wieder voll {wann(new Date(f.resetsAt))}</Text> : null}</Text>
274          ))}
275        </Box>
276        <Box flexDirection="column" borderStyle="round" paddingX={1}>
277          <Text bold>Codex-Abo (OpenAI)</Text>
278          {k ? (
279            <Box flexDirection="column">
280              <Text>5-Std.-Fenster <Text color={farbe(k.fenster_prozent)}>{balken(k.fenster_prozent)}</Text> {Math.round(k.fenster_prozent)} %  <Text dimColor>frei {wann(new Date(k.fenster_reset * 1000))}</Text></Text>
281              <Text>Woche         <Text color={farbe(k.woche_prozent)}>{balken(k.woche_prozent)}</Text> {Math.round(k.woche_prozent)} %</Text>
282            </Box>
283          ) : <Text dimColor>nicht eingerichtet</Text>}
284        </Box>
285        <Box flexDirection="column" borderStyle="round" paddingX={1}>
286          <Text bold>Antigravity / agy (Google-Abo)</Text>
287          {!st.kontingent.agy && <Text dimColor>nicht eingerichtet</Text>}
288          {(st.kontingent.agy ?? []).map(g => {
289            const benutzt = 100 - g.rest
290            return (
291              <Text key={g.gruppe + g.fenster}>{(g.gruppe.startsWith('Gemini') ? 'Gemini' : 'Claude/GPT').padEnd(11)}{g.fenster === '5h' ? '5 Std.' : 'Woche '} <Text color={farbe(benutzt)}>{balken(benutzt)}</Text> {Math.round(benutzt)} %{g.reset ? <Text dimColor>  wieder voll {wann(new Date(g.reset))}</Text> : null}</Text>
292            )
293          })}
294        </Box>
295        <Box flexDirection="column" borderStyle="round" paddingX={1}>
296          <Text bold>FreeLLMAPI {st.freellmapi && st.freellmapi.anbieter.length > 0 ? <Text dimColor>({st.freellmapi.anbieter.length} von {st.freellmapi.im_katalog ?? '?'} Anbietern eingerichtet)</Text> : null}</Text>
297          {(!st.freellmapi || st.freellmapi.anbieter.length === 0) && <Text dimColor>nicht eingerichtet</Text>}
298          {(st.freellmapi?.anbieter ?? []).map(a => {
299            const quote = a.anfragen_stunde ? Math.round((a.ok_stunde / a.anfragen_stunde) * 100) : 100
300            return (
301              <Text key={a.anbieter}>
302                <Text color={a.aktiv ? 'green' : 'gray'}>{a.aktiv ? '●' : '○'}</Text> {a.anbieter.padEnd(16)} letzte Std. <Text color={farbe(100 - quote)}>{a.ok_stunde}/{a.anfragen_stunde} ok</Text>  <Text dimColor>heute {a.ok}/{a.anfragen}, {zahl(a.tokens)} Tokens{a.sperren ? `, ${a.sperren} gesperrt` : ''}</Text>
303              </Text>
304            )
305          })}
306        </Box>
307        <Box flexDirection="column" borderStyle="round" paddingX={1}>
308          <Box flexDirection="row" gap={2}>
309            <Text bold>Free Claude Code (127.0.0.1:{port})</Text>
310            <Link href={`http://127.0.0.1:${port}/admin`}>Admin</Link>
311          </Box>
312          {fccZeilen(st.fcc).map((z, idx) => (
313            <Text key={String(idx)} color={z.farbe} dimColor={z.dim}>{z.text}</Text>
314          ))}
315        </Box>
316        <Box flexDirection="column" borderStyle="round" paddingX={1}>
317          <Text bold>Aufträge (24 Std.)</Text>
318          {st.auftraege === null && <Text dimColor>nicht eingerichtet</Text>}
319          {st.auftraege !== null && st.auftraege.length === 0 && <Text dimColor>keine</Text>}
320          {(st.auftraege ?? []).slice(0, 8).map(a => {
321            const ort = a.ordner.split('/').filter(Boolean).pop() ?? a.ordner
322            const laeuft = a.ende === null
323            const f = laeuft ? 'yellow' : a.rc === 0 ? 'green' : 'red'
324            const z = laeuft ? `läuft ${dauer(st.jetzt - a.start)}${restText(st.auftraege ?? [], a, st.jetzt)}` : a.rc === 0 ? `fertig nach ${dauer((a.ende ?? st.jetzt) - a.start)}` : `Fehler ${a.rc}`
325            return (
326              <Box key={a.id} flexDirection="column">
327                <Box flexDirection="row" gap={1}>
328                  <Text><Text color={f}>{laeuft ? '⚙' : a.rc === 0 ? '✓' : '✗'} {a.wer}</Text> {ort} · {a.auftrag} · <Text color={f}>{z}</Text>{a.tokens ? <Text dimColor> · {zahl(a.tokens)} Tokens, {a.runden} Runden</Text> : null}</Text>
329                  <Button
330                    key={`bericht-${a.id}`}
331                    label="Bericht öffnen"
332                    onPress={async () => {
333                      const pfad = `${a.ordner}/ERGEBNIS.md`
334                      try {
335                        await $.fs.stat(pfad)
336                        await $.process.run(['open', '-a', 'TextEdit', pfad])
337                      } catch {
338                        $.ui.toast(`Kein Bericht in ${a.ordner}`)
339                      }
340                    }}
341                  />
342                </Box>
343                {a.letzte_zeile ? <Text dimColor>   {a.letzte_zeile.slice(0, 110)}</Text> : null}
344              </Box>
345            )
346          })}
347        </Box>
348        <Text dimColor>aktualisiert {uhr(st.jetzt)} · Esc schließt</Text>
349      </Box>
350    )
351  })
352
353  on('ui.render', { component: 'Pane', requestId: ARTIKEL_PANE }, async ($, e) => {
354    const { Box, Link, Text } = $.ui.resolve(e)
355    const alle = await read($, artikel)
356    const sichtbar = sichtbareArtikel(alle, e.viewport?.rows)
357    return (
358      <Box flexDirection="column" gap={1}>
359        <Box flexDirection="column" borderStyle="round" paddingX={1}>
360          <Text bold>Artikel ({alle.length})</Text>
361          {alle.length === 0 && <Text dimColor>Lade Artikel von stevennoack.de …</Text>}
362          {sichtbar.map(a => (
363            <Text key={a.slug}>
364              <Text dimColor>{datumFormatiert(a.published_at)}</Text>{' '}
365              <Link href={`https://stevennoack.de/artikel/${a.slug}/`} label={a.title}>
366                {a.title}
367              </Link>
368            </Text>
369          ))}
370        </Box>
371        <Text dimColor>Esc schließt</Text>
372      </Box>
373    )
374  })
375
376  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
377    const liste = await read($, auftraege)
378    if (liste.length === 0) return next(e)
379    const nun = await read($, jetzt)
380    const standJetzt = await read($, stand)
381    const { Box, Text } = $.ui.resolve(e)
382    return (
383      <Box flexDirection="column">
384        {liste.slice(0, 5).map(a => {
385          const ort = a.ordner.split('/').filter(Boolean).pop() ?? a.ordner
386          const laeuft = a.ende === null
387          const ok = a.rc === 0
388          const zeichen = laeuft ? '⚙' : ok ? '✓' : '✗'
389          const farbe = laeuft ? 'yellow' : ok ? 'green' : 'red'
390          const zustand = laeuft ? `läuft ${dauer(nun - a.start)}${restText(standJetzt?.auftraege ?? liste, a, nun)}` : ok ? `fertig nach ${dauer((a.ende ?? nun) - a.start)}` : `Fehler ${a.rc} nach ${dauer((a.ende ?? nun) - a.start)}`
391          return (
392            <Text key={a.id}>
393              <Text color={farbe}>{zeichen} {a.wer}</Text>
394              <Text dimColor> {ort} · {a.auftrag} · </Text>
395              <Text color={farbe}>{zustand}</Text>
396            </Text>
397          )
398        })}
399      </Box>
400    )
401  })
402}
403
types/index.d.ts 53 lines
1export type Auftrag = {
2  id: string
3  wer: string
4  ordner: string
5  auftrag: string
6  start: number
7  ende: number | null
8  rc: number | null
9}
10
11export type StandAuftrag = Auftrag & { tokens: number | null; runden: number | null; letzte_zeile: string }
12export type Anbieter = { anbieter: string; schluessel: number; aktiv: number; ok: number; anfragen: number; tokens: number; sperren: number; ok_stunde: number; anfragen_stunde: number }
13export type Fenster = { kind: string; percentUsed: number; resetsAt?: string }
14export type WeicheWahl = { ziel: string; grund?: string } | null
15export type FccStand = {
16  laeuft: boolean
17  modell: string
18  bereit: string[]
19  fehler: string[]
20  eingerichtet: number
21  modelle: number
22  port?: number | string
23}
24export type Stand = {
25  weiche?: { schreiben: WeicheWahl; bauen: WeicheWahl } | null
26  jetzt: number
27  kontingent: { agy: { gruppe: string; fenster: string; rest: number; reset: string | null }[] | null; codex: { fenster_prozent: number; fenster_reset: number; woche_prozent: number; woche_reset: number } | null }
28  freellmapi: { anbieter: Anbieter[]; im_katalog: number | null } | null
29  fcc?: FccStand | null
30  auftraege: StandAuftrag[] | null
31}
32
33export type ArtikelEintrag = {
34  slug: string
35  title: string
36  published_at: number | string
37  type?: string
38  summary?: string
39  url?: string
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'helfer-band': {
45      auftraege: Auftrag[]
46      jetzt: number
47      stand: Stand | null
48      claude: Fenster[]
49      artikel: ArtikelEintrag[]
50    }
51  }
52}
53