SLOPSHOPPER

deploy-wacht

Beobachtet nach jedem git push den Cloudflare-Build (Workers Builds), zeigt den Stand unter dem Prompt, gibt Claude bei einem roten Build das Log und meldet…

newguardcommandtoaststatusprocess
v0.1.0MITupdated 2026-10-09Savo2610/claude-mods/deploy-wacht
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deploy-wacht
› fix the failing auth test and add an audit log call ⏺ 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 › /deploy ⎿ deploy-wacht: deploy-wacht ⎿ deploy-wacht: Zugang ⚠️ noch nicht eingerichtet: /deploy einrichten ⎿ deploy-wacht: Roter Build Claude bekommt das Log und behebt Kleines selbst (höchstens 2× hintereinander) ⎿ deploy-wacht: Telegram wenn du weg bist ⎿ deploy-wacht: ⎿ deploy-wacht: /deploy einrichten · fehler beheben|melden · telegram an|aus ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

deploy-wacht

Claude-Code-Mod für Projekte mit Cloudflare Workers Builds (Auto-Deploy bei jedem Push). Nach einem git push muss niemand mehr warten und nachsehen: die Mod beobachtet den Build und sagt Bescheid.

Was sie tut

  • Erkennt jeden Push, den Claude macht (auch git -C <pfad> push oder cd … && git push aus einem Worktree): Sie vergleicht die Remote-Branches vor und nach dem Befehl. Ein Repo mit wrangler.jsonc/.json/.toml im Hauptordner gilt als Cloudflare-Projekt; der Worker-Name kommt von dort.
  • Sagt Claude sofort, dass die Mod den Build beobachtet: keine selbstgebauten curl-und-sleep-Schleifen mehr.
  • Statuszeile unter dem Prompt: 🚀 website baut 1:20 → ✅ website live (2:41) oder ❌ website: Build fehlgeschlagen; das Ergebnis bleibt 5 min stehen.
  • Grün: Danach ruft sie einmal die Domain aus der wrangler-Konfiguration auf (z. B. veerka.mp) und warnt, falls die mit einem Fehler antwortet. Claude bekommt eine kurze Notiz, dass es live ist.
  • Rot: Claude bekommt das Ende des Build-Logs als neue Nachricht, mit dem Auftrag, die Ursache zu finden und Kleines selbst zu beheben. Höchstens zweimal hintereinander; danach nur noch erklären, damit keine Schleife aus Fix, Push und Fehler entsteht. Mit /deploy fehler melden gibt es nur eine Notiz.
  • Kein Build nach 3 min zu einem Push auf main: Hinweis (Repo nicht verbunden, Branch nicht im Trigger, Pfad ausgeschlossen). Bei anderen Branches ohne Vorschau-Builds bleibt sie still.
  • Telegram: Bist du weg (1 min ohne Maus und Tastatur), kommt das Ergebnis aufs Handy. Nutzt ~/.claude/telegram.env von telegram-draht; ohne die Datei passiert hier nichts.

Einrichten (einmalig)

  1. Einen Benutzer-Token anlegen (dash.cloudflare.com → Profil → API-Tokens), keinen Konto-Token: die Builds-API weist Konto-Token mit „Invalid token“ ab. Berechtigungen (Konto): Workers Builds Configuration · Lesen (auch „Workers CI“) und Workers-Skripte · Lesen; Kontoressourcen: dein Konto. Nur Leserechte.
  2. Den Token in eine Datei schreiben, nicht in den Claude-Chat: ``bash echo 'CLOUDFLARE_API_TOKEN=…' > ~/.claude/cloudflare.env chmod 600 ~/.claude/cloudflare.env ``
  3. In Claude Code: /deploy einrichten. Die Mod prüft den Token, sucht die Konto-ID, trägt sie in die Datei ein und testet, ob Builds lesbar sind.

Befehle

BefehlWirkung
/deployZustand, laufende Builds und der letzte Build des aktuellen Projekts (rot: mit Log-Ende)
/deploy einrichtenToken prüfen, Konto eintragen
`/deploy fehler beheben\melden`Roter Build: Claude behebt Kleines selbst (Standard) oder bekommt nur eine Notiz
`/deploy telegram an\aus`Ergebnis aufs Handy, wenn du weg bist (Standard an)

Entwickeln

claude plugin validate deploy-wacht und claude plugin test deploy-wacht. Die Plugin-API für Mods ist Early Access und kann sich ändern.

Source 1 files
hooks/register.ts 743 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const SEK = 1000
4const MIN = 60 * SEK
5// Ein Build von veerka.mp dauert 1–3 min: alle 10 s nachsehen reicht, die
6// Statuszeile tickt trotzdem jede Sekunde.
7const POLL_MS = 10 * SEK
8// Taucht so lange kein Build zum Commit auf, hat Cloudflare den Push nicht gebaut
9// (kein Trigger für den Branch, Pfad ausgeschlossen, Repo nicht verbunden).
10export const KEIN_BUILD_NACH_MS = 3 * MIN
11const AUFGEBEN_NACH_MS = 25 * MIN
12// So lange bleibt das Ergebnis unter dem Prompt stehen.
13const ERGEBNIS_ZEIGEN_MS = 5 * MIN
14const LOG_ZEILEN = 60
15// Höchstens so oft hintereinander bekommt Claude einen roten Build zum Beheben:
16// danach nur noch melden, damit keine Schleife aus Fix, Push, Fehler entsteht.
17export const BEHEBEN_MAX = 2
18// Wer so lange weder Maus noch Tastatur angefasst hat, ist weg (wie telegram-draht).
19const WEG_AB_MS = MIN
20const API = 'https://api.cloudflare.com/client/v4'
21// git push, auch als git -C <pfad> push, irgendwo in einer Befehlskette.
22export const PUSH = /(^|[\s;&|(])git\s+(?:-[Cc]\s+("[^"]+"|'[^']+'|\S+)\s+)*push\b/
23const PRODUKTION = new Set(['main', 'master'])
24
25type CfZugang = { token: string; konto?: string }
26type TgZugang = { token: string; chat: string }
27export type Build = {
28  build_uuid: string
29  status?: string
30  build_outcome?: string | null
31  created_on?: string
32  stopped_on?: string
33  build_trigger_metadata?: { commit_hash?: string; branch?: string; commit_message?: string }
34}
35export type Stand = 'wartet' | 'baut' | 'live' | 'fehler' | 'abgebrochen' | 'kein-build' | 'aufgegeben'
36export type Konfig = { name?: string; konto?: string; domain?: string }
37export type Geschoben = { branch: string; sha: string }
38
39type Wache = {
40  repo: string
41  projekt: string
42  worker: string
43  konto?: string
44  domain?: string
45  branch: string
46  sha: string
47  start: number
48  stand: Stand
49  build?: string
50  nachricht?: string
51  dauer?: number
52  ende?: number
53  hinweis?: string
54}
55
56type Zustand = {
57  cf: CfZugang | null | undefined
58  tg: TgZugang | null | undefined
59  wachen: Wache[]
60  /** beheben: Claude bekommt einen roten Build als Nachricht; melden: nur Hinweis. */
61  fehlerArt: 'beheben' | 'melden'
62  telegram: boolean
63  /** Wie oft hintereinander Claude pro Repo schon beheben sollte. */
64  behoben: Map<string, number>
65  /** Für Claude, sobald kein Zug mehr läuft. */
66  notizen: string[]
67  zugLaeuft: boolean
68  ticker: { cancel(): void } | null
69  zuletztGeholt: number
70  holt: boolean
71  status: string | undefined
72  ohneTokenGesagt: boolean
73  fehler: string
74}
75
76function neuerZustand(): Zustand {
77  return {
78    cf: undefined, tg: undefined, wachen: [], fehlerArt: 'beheben', telegram: true, behoben: new Map(),
79    notizen: [], zugLaeuft: false, ticker: null, zuletztGeholt: 0, holt: false, status: undefined,
80    ohneTokenGesagt: false, fehler: '',
81  }
82}
83
84// ---------- Reine Hilfen (getestet) ----------
85
86function leseWerte(text: string): Record<string, string> {
87  const werte: Record<string, string> = {}
88  for (const zeile of text.split('\n')) {
89    const m = /^\s*(?:export\s+)?([A-Z_]+)=(.*)$/.exec(zeile)
90    if (m?.[1] && m[2] !== undefined) werte[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2')
91  }
92  return werte
93}
94
95/** Liest ~/.claude/cloudflare.env. */
96export function leseCf(text: string): CfZugang | null {
97  const w = leseWerte(text)
98  return w.CLOUDFLARE_API_TOKEN ? { token: w.CLOUDFLARE_API_TOKEN, konto: w.CLOUDFLARE_ACCOUNT_ID || undefined } : null
99}
100
101/** Liest ~/.claude/telegram.env (dieselbe Datei wie telegram-draht). */
102export function leseTg(text: string): TgZugang | null {
103  const w = leseWerte(text)
104  return w.TELEGRAM_BOT_TOKEN && w.TELEGRAM_CHAT_ID ? { token: w.TELEGRAM_BOT_TOKEN, chat: w.TELEGRAM_CHAT_ID } : null
105}
106
107// Entfernt Zeilen- und Blockkommentare aus JSONC; Zeichenketten bleiben unberührt (https://…).
108export function ohneKommentare(text: string): string {
109  let aus = ''
110  let inText = false
111  for (let i = 0; i < text.length; i++) {
112    const c = text[i]!
113    if (inText) {
114      aus += c
115      if (c === '\\') aus += text[++i] ?? ''
116      else if (c === '"') inText = false
117    } else if (c === '"') {
118      inText = true
119      aus += c
120    } else if (c === '/' && text[i + 1] === '/') {
121      while (i < text.length && text[i] !== '\n') i++
122      aus += '\n'
123    } else if (c === '/' && text[i + 1] === '*') {
124      i = text.indexOf('*/', i + 2)
125      if (i < 0) break
126      i++
127    } else aus += c
128  }
129  return aus
130}
131
132function domainAus(muster: unknown): string | undefined {
133  const host = typeof muster === 'string' ? muster.split('/')[0] : undefined
134  return host && !host.includes('*') ? host : undefined
135}
136
137/** Name, Konto und (erste feste) Domain aus wrangler.jsonc/.json/.toml. */
138export function leseKonfig(datei: string, text: string): Konfig {
139  if (datei.endsWith('.toml')) {
140    const kopf = text.split(/^\s*\[/m)[0] ?? ''
141    const wert = (s: string, k: string) => new RegExp(`^\\s*${k}\\s*=\\s*["']([^"']+)["']`, 'm').exec(s)?.[1]
142    const route = /pattern\s*=\s*["']([^"']+)["']/.exec(text)?.[1] ?? wert(kopf, 'route')
143    return { name: wert(kopf, 'name'), konto: wert(kopf, 'account_id'), domain: domainAus(route) }
144  }
145  try {
146    const j = JSON.parse(ohneKommentare(text).replace(/,(\s*[}\]])/g, '$1')) as {
147      name?: string; account_id?: string; route?: string | { pattern?: string }; routes?: (string | { pattern?: string })[]
148    }
149    const r = j.routes?.[0] ?? j.route
150    return { name: j.name, konto: j.account_id, domain: domainAus(typeof r === 'string' ? r : r?.pattern) }
151  } catch {
152    return { name: /"name"\s*:\s*"([^"]+)"/.exec(text)?.[1] }
153  }
154}
155
156/** Ausgabe von git for-each-ref refs/remotes: Remote-Branch → Commit. */
157export function leseRefs(ausgabe: string): Map<string, string> {
158  const m = new Map<string, string>()
159  for (const zeile of ausgabe.split('\n')) {
160    const [name, sha] = zeile.trim().split(/\s+/)
161    if (name && sha && name.includes('/') && !name.endsWith('/HEAD')) m.set(name, sha)
162  }
163  return m
164}
165
166/** Was ein Push bewegt hat: Remote-Branches mit neuem Commit. */
167export function geschoben(vorher: Map<string, string>, nachher: Map<string, string>): Geschoben[] {
168  const aus: Geschoben[] = []
169  for (const [name, sha] of nachher) {
170    if (vorher.get(name) !== sha) aus.push({ branch: name.slice(name.indexOf('/') + 1), sha })
171  }
172  return aus
173}
174
175function pfad(p: string, cwd: string, home: string): string {
176  const q = p.replace(/^(['"])(.*)\1$/, '$2')
177  if (q === '~' || q.startsWith('~/')) return home + q.slice(1)
178  return q.startsWith('/') ? q : `${cwd.replace(/\/+$/, '')}/${q}`
179}
180
181/** In welchem Ordner der Push lief: git -C <pfad>, sonst das letzte cd davor, sonst cwd. */
182export function repoOrt(befehl: string, cwd: string, home: string): string {
183  const m = PUSH.exec(befehl)
184  if (!m) return cwd
185  const c = /-C\s+("[^"]+"|'[^']+'|\S+)/.exec(m[0])
186  if (c?.[1]) return pfad(c[1], cwd, home)
187  const davor = befehl.slice(0, m.index + m[1]!.length)
188  let ort = cwd
189  for (const cd of davor.matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) ort = pfad(cd[1]!, ort, home)
190  return ort
191}
192
193export function findeBuild(builds: readonly Build[], sha: string): Build | undefined {
194  return builds.find(b => {
195    const h = b.build_trigger_metadata?.commit_hash
196    return !!h && h.length >= 7 && (sha.startsWith(h) || h.startsWith(sha))
197  })
198}
199
200export function standVon(b: Build): Stand {
201  if (b.status === 'stopped') return b.build_outcome === 'success' ? 'live' : b.build_outcome === 'fail' ? 'fehler' : 'abgebrochen'
202  return b.status === 'queued' ? 'wartet' : 'baut'
203}
204
205/** Die letzten Zeilen des Build-Logs, ohne Farbcodes; nimmt [zeit, text], {message} oder Text. */
206export function logZeilen(result: unknown, n = LOG_ZEILEN): string[] {
207  const roh = (result as { lines?: unknown } | null)?.lines ?? result
208  if (!Array.isArray(roh)) return []
209  return roh
210    .map(z => Array.isArray(z) ? String(z.at(-1) ?? '') : typeof z === 'string' ? z : String((z as { message?: unknown })?.message ?? ''))
211    // eslint-disable-next-line no-control-regex
212    .map(z => z.replace(/\u001b\[[0-9;]*m/g, '').trimEnd())
213    .filter(Boolean)
214    .slice(-n)
215}
216
217/** Ob der Branch live geht (main/master); andere bauen höchstens eine Vorschau. */
218export function produktion(w: { branch: string }): boolean {
219  return PRODUKTION.has(w.branch)
220}
221
222export function mmss(ms: number): string {
223  const s = Math.max(0, Math.round(ms / SEK))
224  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
225}
226
227/** Was unter dem Prompt steht: laufende Builds mit Zeit, frische Ergebnisse. */
228export function statusText(wachen: readonly Wache[], now: number): string | undefined {
229  const teile: string[] = []
230  for (const w of wachen) {
231    const wer = produktion(w) ? w.worker : `${w.worker} (${w.branch})`
232    if (w.stand === 'wartet') teile.push(`🚀 ${wer}: wartet auf Build ${mmss(now - w.start)}`)
233    else if (w.stand === 'baut') teile.push(`🚀 ${wer} baut ${mmss(now - w.start)}`)
234    else if (w.ende && now - w.ende < ERGEBNIS_ZEIGEN_MS) {
235      if (w.stand === 'live') teile.push(`✅ ${wer} live${w.dauer ? ` (${mmss(w.dauer)})` : ''}${w.hinweis ? ` · ⚠️ ${w.hinweis}` : ''}`)
236      else if (w.stand === 'fehler') teile.push(`❌ ${wer}: Build fehlgeschlagen`)
237      else if (w.stand === 'kein-build') teile.push(`⚠️ ${wer}: kein Build zu ${w.sha.slice(0, 7)}`)
238      else if (w.stand === 'abgebrochen') teile.push(`⏹ ${wer}: Build abgebrochen`)
239      else teile.push(`⏱ ${wer}: nach ${Math.round(AUFGEBEN_NACH_MS / MIN)} min kein Ergebnis`)
240    }
241  }
242  return teile.length ? `${teile.join(' · ')}` : undefined
243}
244
245
246// ---------- Host: Dateien, git, Netz ----------
247
248async function home($: EngineInterface): Promise<string> {
249  return (await $.env.get('HOME')) ?? ''
250}
251
252async function lies($: EngineInterface, p: string): Promise<string | null> {
253  try { return await $.fs.read(p) } catch { return null }
254}
255
256async function cfZugang($: EngineInterface, z: Zustand): Promise<CfZugang | null> {
257  if (z.cf !== undefined) return z.cf
258  const t = await lies($, `${await home($)}/.claude/cloudflare.env`)
259  z.cf = t ? leseCf(t) : null
260  return z.cf
261}
262
263async function tgZugang($: EngineInterface, z: Zustand): Promise<TgZugang | null> {
264  if (z.tg !== undefined) return z.tg
265  const t = await lies($, `${await home($)}/.claude/telegram.env`)
266  z.tg = t ? leseTg(t) : null
267  return z.tg
268}
269
270async function git($: EngineInterface, ort: string, args: string[]): Promise<string | null> {
271  try {
272    const r = await $.process.run(['git', '-C', ort, ...args], { timeoutMs: 10 * SEK })
273    return r.exitCode === 0 ? r.stdout : null
274  } catch {
275    return null
276  }
277}
278
279async function refs($: EngineInterface, top: string): Promise<Map<string, string> | null> {
280  const aus = await git($, top, ['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/remotes'])
281  return aus === null ? null : leseRefs(aus)
282}
283
284async function konfig($: EngineInterface, top: string): Promise<Konfig | null> {
285  for (const datei of ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']) {
286    const t = await lies($, `${top}/${datei}`)
287    if (t !== null) return leseKonfig(datei, t)
288  }
289  return null
290}
291
292// Die Fehlermeldung nennt nie den Token: er steht nur im Header.
293async function cf<T>($: EngineInterface, z: Zustand, weg: string): Promise<T> {
294  const zg = await cfZugang($, z)
295  if (!zg) throw new Error('kein Token in ~/.claude/cloudflare.env')
296  let r
297  try {
298    r = await $.http.fetch(`${API}${weg}`, { headers: { authorization: `Bearer ${zg.token}` } })
299  } catch (err) {
300    throw new Error(`Cloudflare nicht erreichbar (${err instanceof Error ? err.message : String(err)})`)
301  }
302  let d: { success?: boolean; result?: unknown; errors?: { message?: string }[] } | null = null
303  try { d = JSON.parse(r.text) } catch { /* bleibt null */ }
304  if (!d?.success) {
305    const was = d?.errors?.map(e => e.message).filter(Boolean).join('; ') ?? ''
306    throw new Error(`Cloudflare ${weg.split('?')[0]?.replace(/[0-9a-f]{32}/g, '…')}: ${r.status} ${was}`.trim())
307  }
308  return d.result as T
309}
310
311async function konto($: EngineInterface, z: Zustand, ausKonfig?: string): Promise<string> {
312  const zg = await cfZugang($, z)
313  if (zg?.konto) return zg.konto
314  if (ausKonfig) return ausKonfig
315  const gemerkt = await $.store.get('konto')
316  if (typeof gemerkt === 'string') return gemerkt
317  const liste = await cf<{ id: string; name: string }[]>($, z, '/accounts')
318  if (liste.length !== 1) throw new Error(`Der Token sieht ${liste.length} Konten: CLOUDFLARE_ACCOUNT_ID in ~/.claude/cloudflare.env eintragen`)
319  await $.store.set('konto', liste[0]!.id)
320  return liste[0]!.id
321}
322
323/** Der Tag des Workers (die Builds-API will ihn statt des Namens), gemerkt im Store. */
324async function tagVon($: EngineInterface, z: Zustand, k: string, worker: string): Promise<string> {
325  const key = `tag:${k}:${worker}`
326  const gemerkt = await $.store.get(key)
327  if (typeof gemerkt === 'string') return gemerkt
328  const skripte = await cf<{ id: string; tag: string }[]>($, z, `/accounts/${k}/workers/scripts`)
329  const tag = skripte.find(s => s.id === worker)?.tag
330  if (!tag) throw new Error(`Worker „${worker}“ gibt es im Konto nicht`)
331  await $.store.set(key, tag)
332  return tag
333}
334
335async function builds($: EngineInterface, z: Zustand, w: Wache): Promise<Build[]> {
336  const k = await konto($, z, w.konto)
337  const tag = await tagVon($, z, k, w.worker)
338  return cf<Build[]>($, z, `/accounts/${k}/builds/workers/${tag}/builds?per_page=10`)
339}
340
341async function logVon($: EngineInterface, z: Zustand, w: Wache): Promise<string[]> {
342  if (!w.build) return []
343  try {
344    const k = await konto($, z, w.konto)
345    // Lange Logs (npm install …) kommen seitenweise; der Fehler steht am Ende.
346    let zeilen: string[] = []
347    let cursor = ''
348    for (let seite = 0; seite < 20; seite++) {
349      const r = await cf<{ lines?: unknown; truncated?: boolean; cursor?: string }>($, z,
350        `/accounts/${k}/builds/builds/${w.build}/logs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`)
351      zeilen = [...zeilen, ...logZeilen(r, Infinity)].slice(-LOG_ZEILEN)
352      if (!r.truncated || !r.cursor || r.cursor === cursor) break
353      cursor = r.cursor
354    }
355    return zeilen
356  } catch (err) {
357    return [`(Log nicht lesbar: ${err instanceof Error ? err.message : String(err)})`]
358  }
359}
360
361/** Leerlauf aus `ioreg -k HIDIdleTime` (Nanosekunden) in ms; null = nicht lesbar. */
362export function leseLeerlauf(ausgabe: string): number | null {
363  const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ausgabe)
364  return m?.[1] ? Number(m[1]) / 1e6 : null
365}
366
367async function istWeg($: EngineInterface): Promise<boolean> {
368  try {
369    const r = await $.process.run(['/usr/sbin/ioreg', '-r', '-k', 'HIDIdleTime', '-d', '1'], { timeoutMs: 5 * SEK })
370    const leer = r.exitCode === 0 ? leseLeerlauf(r.stdout) : null
371    return leer !== null && leer >= WEG_AB_MS
372  } catch {
373    return false
374  }
375}
376
377async function telegram($: EngineInterface, z: Zustand, text: string) {
378  if (!z.telegram || !(await istWeg($))) return
379  const zg = await tgZugang($, z)
380  if (!zg) return
381  try {
382    const r = await $.http.fetch(`https://api.telegram.org/bot${zg.token}/sendMessage`, {
383      method: 'POST',
384      headers: { 'content-type': 'application/json' },
385      body: JSON.stringify({ chat_id: zg.chat, text, disable_web_page_preview: true }),
386    })
387    if (!r.ok) merkeFehler($, z, `Telegram: ${r.status}`)
388  } catch {
389    merkeFehler($, z, 'Telegram nicht erreichbar')
390  }
391}
392
393function merkeFehler($: EngineInterface, z: Zustand, text: string) {
394  if (text !== z.fehler) $.ui.log(`deploy-wacht: ${text}`, { to: 'debug' })
395  z.fehler = text
396}
397
398// ---------- Beobachten ----------
399
400function zeichne($: EngineInterface, z: Zustand, now: number) {
401  const text = statusText(z.wachen, now)
402  if (text !== z.status) {
403    z.status = text
404    $.ui.status(text)
405  }
406}
407
408function aktiv(w: Wache): boolean {
409  return w.stand === 'wartet' || w.stand === 'baut'
410}
411
412function wecken($: EngineInterface, z: Zustand) {
413  if (z.ticker) return
414  z.ticker = $.clock.every(SEK, () => void tick($, z))
415}
416
417async function tick($: EngineInterface, z: Zustand) {
418  const now = await $.clock.now()
419  // Abgelaufene Ergebnisse fallen weg; ohne etwas zu zeigen schläft die Uhr.
420  z.wachen = z.wachen.filter(w => aktiv(w) || (w.ende ?? now) + ERGEBNIS_ZEIGEN_MS > now)
421  zeichne($, z, now)
422  if (!z.wachen.length) {
423    z.ticker?.cancel()
424    z.ticker = null
425    return
426  }
427  if (z.wachen.some(aktiv) && !z.holt && now - z.zuletztGeholt >= POLL_MS) await pruefe($, z)
428}
429
430async function pruefe($: EngineInterface, z: Zustand) {
431  z.holt = true
432  try {
433    const now = await $.clock.now()
434    z.zuletztGeholt = now
435    const jeWorker = new Map<string, Build[]>()
436    for (const w of z.wachen.filter(aktiv)) {
437      let liste = jeWorker.get(w.worker)
438      if (!liste) {
439        try {
440          liste = await builds($, z, w)
441          z.fehler = ''
442        } catch (err) {
443          merkeFehler($, z, err instanceof Error ? err.message : String(err))
444          liste = []
445        }
446        jeWorker.set(w.worker, liste)
447      }
448      const b = findeBuild(liste, w.sha)
449      if (b) {
450        w.build = b.build_uuid
451        w.nachricht ??= b.build_trigger_metadata?.commit_message?.split('\n')[0]
452        const stand = standVon(b)
453        if (stand === 'wartet' || stand === 'baut') w.stand = stand
454        else {
455          const a = Date.parse(b.created_on ?? ''), e = Date.parse(b.stopped_on ?? '')
456          w.dauer = Number.isFinite(a) && Number.isFinite(e) ? e - a : now - w.start
457          await abschliessen($, z, w, stand, now)
458        }
459      } else if (w.stand === 'wartet' && now - w.start >= KEIN_BUILD_NACH_MS) {
460        // Ein Nebenbranch ohne Vorschau-Builds ist normal: dann still aufhören.
461        if (produktion(w)) await abschliessen($, z, w, 'kein-build', now)
462        else z.wachen = z.wachen.filter(x => x !== w)
463      }
464      if (aktiv(w) && now - w.start >= AUFGEBEN_NACH_MS) await abschliessen($, z, w, 'aufgegeben', now)
465    }
466    zeichne($, z, now)
467  } finally {
468    z.holt = false
469  }
470}
471
472function beschreibung(w: Wache): string {
473  return `„${w.worker}“ (Commit ${w.sha.slice(0, 7)}${w.nachricht ? ` „${w.nachricht}“` : ''}, Branch ${w.branch})`
474}
475
476async function abschliessen($: EngineInterface, z: Zustand, w: Wache, stand: Stand, now: number) {
477  w.stand = stand
478  w.ende = now
479  const wer = produktion(w) ? w.worker : `${w.worker} (${w.branch})`
480  const ort = `📁 ${w.projekt}`
481  if (stand === 'live') {
482    z.behoben.delete(w.repo)
483    if (w.domain && produktion(w)) {
484      try {
485        const r = await $.http.fetch(`https://${w.domain}/`)
486        if (r.status >= 400) w.hinweis = `${w.domain} antwortet ${r.status}`
487      } catch {
488        w.hinweis = `${w.domain} nicht erreichbar`
489      }
490    }
491    const wo = w.domain && produktion(w) ? ` auf ${w.domain}` : ''
492    const satz = `✅ ${wer} ist live${wo} (Build ${mmss(w.dauer ?? 0)}, Commit ${w.sha.slice(0, 7)})${w.hinweis ? `, aber ⚠️ ${w.hinweis}` : ''}`
493    $.ui.toast(satz, { timeoutMs: 8 * SEK })
494    await notiz($, z, `deploy-wacht: Der Cloudflare-Build ${beschreibung(w)} ist durch, die Änderung ist live${wo}.${w.hinweis ? ` Achtung: ${w.hinweis}.` : ''}`)
495    await telegram($, z, `${satz}\n${w.nachricht ?? ''}\n${ort}`.trim())
496    return
497  }
498  if (stand === 'fehler') {
499    const log = await logVon($, z, w)
500    $.ui.toast(`❌ ${wer}: Build fehlgeschlagen`, { timeoutMs: 10 * SEK })
501    const versuche = z.behoben.get(w.repo) ?? 0
502    const beheben = z.fehlerArt === 'beheben' && produktion(w) && versuche < BEHEBEN_MAX
503    const text = [
504      `deploy-wacht: Der Cloudflare-Build ${beschreibung(w)} ist fehlgeschlagen, live ist weiter der alte Stand.`,
505      '',
506      'Ende des Build-Logs:',
507      '```',
508      ...(log.length ? log : ['(kein Log)']),
509      '```',
510      '',
511      beheben
512        ? 'Finde die Ursache. Ist die Behebung klein und eindeutig, behebe sie und bring sie wie gewohnt auf main; sonst erkläre mir kurz, was los ist.'
513        : `Erklär mir kurz, woran es liegt${versuche >= BEHEBEN_MAX ? ` (schon ${versuche} Behebungen hintereinander, daher nicht selbst weiterpushen)` : ''}.`,
514    ].join('\n')
515    if (beheben) {
516      z.behoben.set(w.repo, versuche + 1)
517      void $.prompt.submit({ text })
518    } else await notiz($, z, text)
519    const letzte = log.slice(-6).join('\n')
520    await telegram($, z, `❌ Build fehlgeschlagen · ${wer}\n${w.sha.slice(0, 7)} ${w.nachricht ?? ''}\n${ort}${beheben ? '\nClaude schaut nach.' : ''}${letzte ? `\n\n${letzte}` : ''}`)
521    return
522  }
523  if (stand === 'kein-build') {
524    $.ui.toast(`⚠️ ${wer}: Cloudflare hat zu ${w.sha.slice(0, 7)} keinen Build gestartet`, { timeoutMs: 10 * SEK })
525    await notiz($, z, `deploy-wacht: Zum Push ${beschreibung(w)} hat Cloudflare nach ${Math.round(KEIN_BUILD_NACH_MS / MIN)} min keinen Build gestartet. Ist das Repo mit Workers Builds verbunden und der Branch im Trigger?`)
526    await telegram($, z, `⚠️ Kein Build zu ${w.sha.slice(0, 7)} · ${wer}\n${ort}`)
527    return
528  }
529  if (stand === 'aufgegeben') {
530    await notiz($, z, `deploy-wacht: Der Cloudflare-Build ${beschreibung(w)} hatte nach ${Math.round(AUFGEBEN_NACH_MS / MIN)} min kein Ergebnis; die Mod schaut nicht mehr nach.`)
531    return
532  }
533  // abgebrochen: meist überholt von einem neueren Push, also nur die Statuszeile.
534}
535
536/** Für Claude: sofort, wenn die Sitzung ruht, sonst nach dem laufenden Zug. */
537async function notiz($: EngineInterface, z: Zustand, text: string) {
538  z.notizen.push(text)
539  if (!z.zugLaeuft) await notizenAbgeben($, z)
540}
541
542async function notizenAbgeben($: EngineInterface, z: Zustand) {
543  if (!z.notizen.length) return
544  const text = z.notizen.join('\n\n')
545  z.notizen = []
546  try {
547    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
548  } catch (err) {
549    merkeFehler($, z, `Notiz an Claude ging nicht: ${err instanceof Error ? err.message : String(err)}`)
550  }
551}
552
553/** Nach einem Push: was hat er bewegt, und ist das ein Cloudflare-Projekt? */
554async function nachPush($: EngineInterface, z: Zustand, top: string, neu: Geschoben[]): Promise<string | null> {
555  const k = await konfig($, top)
556  if (!k?.name) return null
557  if (!(await cfZugang($, z))) {
558    if (!z.ohneTokenGesagt) $.ui.toast('deploy-wacht: kein Cloudflare-Token, /deploy einrichten', { timeoutMs: 8 * SEK })
559    z.ohneTokenGesagt = true
560    return null
561  }
562  const now = await $.clock.now()
563  const projekt = top.replace(/\/+$/, '').split('/').pop() ?? top
564  const neue: Wache[] = []
565  for (const g of neu) {
566    // Ein neuerer Push auf denselben Branch überholt den alten.
567    z.wachen = z.wachen.filter(w => !(w.worker === k.name && w.branch === g.branch && aktiv(w)))
568    const w: Wache = ({ repo: top, projekt, worker: k.name, konto: k.konto, domain: k.domain, branch: g.branch, sha: g.sha, start: now, stand: 'wartet' })
569    z.wachen.push(w)
570    neue.push(w)
571  }
572  // Die erste Abfrage kommt nach 10 s: so früh gibt es den Build meist noch nicht.
573  z.zuletztGeholt = now
574  zeichne($, z, now)
575  wecken($, z)
576  const was = neue.map(w => `${w.branch} (${w.sha.slice(0, 7)})`).join(', ')
577  return `deploy-wacht: Push auf ${was} erkannt. Die Mod beobachtet den Cloudflare-Build von „${k.name}“ und meldet das Ergebnis selbst: unter dem Prompt, und bei einem Fehler als neue Nachricht mit dem Build-Log. Warte also nicht selbst auf den Live-Stand und frag ihn nicht per Schleife ab.`
578}
579
580// ---------- /deploy ----------
581
582// Die Builds-API nimmt nur Benutzer-Token (Profil → API-Tokens); ein Konto-Token
583// (Konto verwalten → Konto-API-Tokens) liest Workers, bekommt bei Builds aber
584// „Invalid token“ (so in der Cloudflare-Doku, 09.10. selbst erlebt).
585const VORLAGE = 'https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22workers_scripts%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=deploy-wacht'
586const ANLEITUNG = [
587  'Einrichten (einmalig):',
588  '1. Einen Benutzer-Token anlegen, kein Konto-Token (den nimmt die Builds-API nicht):',
589  `   ${VORLAGE}`,
590  '   Die Vorlage setzt „Workers-Skripte · Lesen“; dazu „Workers Builds Configuration · Lesen“',
591  '   (auch „Workers CI“ genannt) hinzufügen. Kontoressourcen: dein Konto. Nur Leserechte.',
592  '2. Den Token in die Datei ~/.claude/cloudflare.env schreiben, nicht hier in den Chat:',
593  '     open -e ~/.claude/cloudflare.env      (Zeile: CLOUDFLARE_API_TOKEN=…)',
594  '3. /deploy einrichten',
595].join('\n')
596
597const KONTO_TOKEN = 'Das ist offenbar ein Konto-Token: Workers liest er, die Builds-API nimmt aber nur Benutzer-Token (Profil → API-Tokens).'
598
599async function einrichten($: EngineInterface, z: Zustand): Promise<string> {
600  z.cf = undefined
601  const zg = await cfZugang($, z)
602  if (!zg) return `In ~/.claude/cloudflare.env steht noch kein CLOUDFLARE_API_TOKEN.\n\n${ANLEITUNG}`
603  try {
604    let k = zg.konto
605    let zusatz = ''
606    if (!k) {
607      k = await konto($, z)
608      const p = `${await home($)}/.claude/cloudflare.env`
609      const roh = (await lies($, p)) ?? ''
610      const zeilen = roh.split('\n').filter(l => l.trim() && !/^\s*(?:export\s+)?CLOUDFLARE_ACCOUNT_ID=/.test(l))
611      await $.fs.write(p, [...zeilen, `CLOUDFLARE_ACCOUNT_ID=${k}`, ''].join('\n'))
612      z.cf = { ...zg, konto: k }
613      zusatz = ' Konto-ID eingetragen.'
614    }
615    const skripte = await cf<{ id: string; tag: string }[]>($, z, `/accounts/${k}/workers/scripts`)
616    const probe = skripte[0]
617    if (probe) {
618      try {
619        await cf<Build[]>($, z, `/accounts/${k}/builds/workers/${probe.tag}/builds?per_page=1`)
620      } catch (err) {
621        const text = err instanceof Error ? err.message : String(err)
622        const warum = /invalid token/i.test(text) ? KONTO_TOKEN : 'Fehlt „Workers Builds Configuration · Lesen“?'
623        return `Workers sieht der Token (${skripte.length}), aber keine Builds: ${text}\n${warum}${zusatz}\n\n${ANLEITUNG}`
624      }
625    }
626    return `Verbunden: ${skripte.length} Workers sichtbar, Builds lesbar.${zusatz} Ab dem nächsten git push wird beobachtet.`
627  } catch (err) {
628    return `Einrichten ging nicht: ${err instanceof Error ? err.message : String(err)}\n\n${ANLEITUNG}`
629  }
630}
631
632async function karte($: EngineInterface, z: Zustand): Promise<string> {
633  const now = await $.clock.now()
634  const zg = await cfZugang($, z)
635  const zeilen = ['deploy-wacht']
636  zeilen.push(`Zugang         ${zg ? 'ok' : '⚠️ noch nicht eingerichtet: /deploy einrichten'}`)
637  zeilen.push(`Roter Build    ${z.fehlerArt === 'beheben' ? `Claude bekommt das Log und behebt Kleines selbst (höchstens ${BEHEBEN_MAX}× hintereinander)` : 'nur melden'}`)
638  zeilen.push(`Telegram       ${z.telegram ? 'wenn du weg bist' : 'aus'}`)
639  for (const w of z.wachen) zeilen.push(`Gerade         ${statusText([w], now) ?? w.stand}`)
640  // Der letzte Build des Projekts, in dem die Sitzung steht.
641  const top = (await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel']))?.trim()
642  const k = top ? await konfig($, top) : null
643  if (zg && k?.name) {
644    try {
645      const w: Wache = ({ repo: top!, projekt: '', worker: k.name, konto: k.konto, branch: 'main', sha: '', start: now, stand: 'wartet' })
646      const b = (await builds($, z, w))[0]
647      if (b) {
648        const m = b.build_trigger_metadata
649        const stand = standVon(b)
650        const wann = Date.parse(b.created_on ?? '')
651        zeilen.push(`Letzter Build  ${k.name}: ${stand} · ${m?.branch ?? '?'} ${m?.commit_hash?.slice(0, 7) ?? ''} ${m?.commit_message?.split('\n')[0] ?? ''}${Number.isFinite(wann) ? ` · vor ${Math.round((now - wann) / MIN)} min` : ''}`)
652        if (stand === 'fehler') {
653          w.build = b.build_uuid
654          zeilen.push('', ...(await logVon($, z, w)).slice(-15))
655        }
656      }
657    } catch (err) {
658      zeilen.push(`Letzter Build  ${k.name}: ${err instanceof Error ? err.message : String(err)}`)
659    }
660  }
661  if (z.fehler) zeilen.push(`Letzter Fehler ${z.fehler}`)
662  zeilen.push('', '/deploy einrichten · fehler beheben|melden · telegram an|aus')
663  return zeilen.join('\n')
664}
665
666// ---------- Hooks ----------
667
668export const register: Register = on => {
669  const z = neuerZustand()
670
671  on('session.start', async ($, e, next) => {
672    const r = await next(e)
673    const art = await $.store.get('fehlerArt')
674    if (art === 'beheben' || art === 'melden') z.fehlerArt = art
675    const tg = await $.store.get('telegram')
676    if (typeof tg === 'boolean') z.telegram = tg
677    await $.command.register({
678      name: 'deploy',
679      description: 'Cloudflare-Builds nach git push beobachten (deploy-wacht)',
680      argumentHint: '[einrichten | fehler beheben|melden | telegram an|aus]',
681      immediate: true,
682    })
683    return r
684  })
685
686  on('turn.start', async ($, e, next) => {
687    z.zugLaeuft = true
688    return next(e)
689  })
690
691  on('turn.complete', async ($, e, next) => {
692    const r = await next(e)
693    if (e.agentId) return r
694    z.zugLaeuft = false
695    await notizenAbgeben($, z)
696    return r
697  })
698
699  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
700    if (e.tool !== 'Bash' || e.run_in_background || !PUSH.test(e.command)) return next(e)
701    // Der Push selbst darf an der Mod nie scheitern: alles Eigene ist abgefangen.
702    let top: string | undefined
703    let vorher: Map<string, string> | null = null
704    try {
705      const ort = repoOrt(e.command, await $.session.cwd(), await home($))
706      top = (await git($, ort, ['rev-parse', '--show-toplevel']))?.trim()
707      vorher = top ? await refs($, top) : null
708    } catch (err) {
709      merkeFehler($, z, `vor dem Push: ${err instanceof Error ? err.message : String(err)}`)
710    }
711    const r = await next(e)
712    if (!top || !vorher || r.deny !== undefined || r.isError) return r
713    try {
714      const nachher = await refs($, top)
715      const neu = nachher ? geschoben(vorher, nachher) : []
716      const hinweis = neu.length ? await nachPush($, z, top, neu) : null
717      return hinweis ? { ...r, context: [...(r.context ?? []), hinweis] } : r
718    } catch (err) {
719      merkeFehler($, z, `nach dem Push: ${err instanceof Error ? err.message : String(err)}`)
720      return r
721    }
722  })
723
724  on('command.run', { command: 'deploy' }, async ($, e) => {
725    const [was = '', wert = ''] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
726    if (was === 'einrichten') return { text: await einrichten($, z) }
727    if (was === 'fehler') {
728      if (wert !== 'beheben' && wert !== 'melden') return { text: 'Roter Build: beheben oder melden?' }
729      z.fehlerArt = wert
730      await $.store.set('fehlerArt', wert)
731      return { text: wert === 'beheben' ? 'Bei einem roten Build bekommt Claude das Log und behebt Kleines selbst.' : 'Rote Builds werden nur gemeldet.' }
732    }
733    if (was === 'telegram') {
734      if (wert !== 'an' && wert !== 'aus') return { text: 'Telegram: an oder aus?' }
735      z.telegram = wert === 'an'
736      await $.store.set('telegram', z.telegram)
737      return { text: z.telegram ? 'Ergebnisse kommen aufs Handy, wenn du weg bist.' : 'Keine Telegram-Meldungen mehr.' }
738    }
739    const unbekannt = was && was !== 'status' ? `„${String(e.args).trim()}“ kenne ich nicht.\n\n` : ''
740    return { text: unbekannt + await karte($, z) }
741  })
742}
743