Post Mortem - Session not Over
TL;DR: Eine selbstgeschriebene Pi-Extension hat sich zwei Tage lang selbst neu gestartet: 6.795 Agent-Läufe, 18.825 LLM-Requests, rund 70 $ OpenRouter-Kosten. Der Mac lag dabei zugeklappt im Rucksack.
- Auslöser: Eine Extension startet nach jedem Agent-Lauf einen neuen
pi-Prozess. Dieser lädt dieselbe Extension und startet beim eigenen Ende wieder einen. - Warum es nie aufhörte: Die Sperre gegen doppelte Läufe lag nur im Arbeitsspeicher des jeweiligen Prozesses. Jeder neue Prozess fing mit einer leeren Sperre an.
- Warum im Rucksack: macOS weckt einen schlafenden Mac etwa alle 15 Minuten für 20–45 Sekunden auf (DarkWake). In diesen Fenstern liefen die losgelösten Prozesse einfach weiter.
- Fix: Extension deaktiviert. Für eine Wiederaufnahme braucht der Kindprozess eine Kennung, damit er selbst nichts mehr startet.
Was zunächst wie ein gestohlener API-Key aussah, war hausgemacht.
Ausgangslage
Gemerkt habe ich es an meinem Konto: zwei Abbuchungen von OpenRouter über je rund 50 $. Das war das Auto-Top-up, das mein Guthaben automatisch nachlädt, sobald es leer ist. Am 17.10. kurz nach 18 Uhr habe ich sofort den Key deaktiviert, das Auto-Top-up abgeschaltet und mir die zweite Aufladung zurückbuchen lassen.
Im Dashboard fiel mir dann der letzte Request auf: 17.10.2026, 16:04:40 UTC (18:04:40 MESZ), über meinen Key „OAuth: Pi Coding Agent“, mit dem User-Agent pi (darwin 27.0.0; arm64). Das passt genau zu meinem MacBook. Nur lag der Mac den ganzen Tag zugeklappt im Rucksack, und Pi hatte ich an diesem Tag nicht geöffnet. Meine erste Vermutung war deshalb, dass der Key abgeflossen ist und jemand anderes ihn nutzt.
Ich habe Claude Code als Forensik-Assistenten auf den Mac angesetzt, strikt read-only. Die Frage: Kann der Request überhaupt von diesem Rechner stammen, und wenn nicht, wie ist der Key abgeflossen?
Zeitachse
Vom ersten Lauf bis zur Abschaltung vergingen knapp 46 Stunden. Alle Zeiten in MESZ, chronologisch.
| Zeitpunkt | Ereignis |
|---|---|
| 06.08., 09:51 | OpenRouter-Key „OAuth: Pi Coding Agent“ angelegt, Pi schreibt ihn nach ~/.pi/agent/auth.json |
| 16.10., 13:54 | Extension docs-maintainer angelegt und per Symlink in sechs Repos eingebunden |
| 16.10., 14:37 | Erster automatischer docs-maintainer-Lauf |
| 16.10., 16:00–18:00 | 927 Läufe in zwei Stunden, während ich normal am Mac arbeite |
| 17.10., ganztägig | Mac zugeklappt im Rucksack. Trotzdem 20–40 Läufe und bis zu 4,47 $ pro Stunde |
| 17.10., ab 06:56 | Erste Fehler 402: Das Restguthaben von rund 20 $ ist aufgebraucht, das Auto-Top-up lädt 50 $ nach. Am Abend folgt eine zweite Aufladung über 50 $ |
| 17.10., 18:04:08 | Letzter erfolgreicher OpenRouter-Request |
| 17.10., 18:04:40 | Der verdächtige Request. Er bricht mit terminated ab, weil der Mac in derselben Sekunde wieder einschläft |
| 17.10., kurz nach 18:04 | Ich sehe die Abbuchungen, deaktiviere den Key, schalte das Auto-Top-up ab und lasse mir die zweite Aufladung zurückbuchen. Ab jetzt antwortet OpenRouter nur noch mit 401 „User not found“. Die Läufe scheitern in Sekunden, die Kette dreht schneller |
| 18.10., 11:00–13:00 | Bis zu 735 Läufe pro Stunde, alle erfolglos |
| 18.10., 12:43 | Letzter Lauf |
| 18.10., 12:50 | Extension deaktiviert |
Die Ermittlung
Die Spur führte nicht zu einem Angreifer, sondern in ein Logverzeichnis auf meinem eigenen Mac.
- War der Mac wach?
pmset -g logzeigt für den 17.10. keinen einzigen vollen Wake, nur Akkubetrieb (73 % → 59 %). Aber es gibt alle ~15 Minuten einen DarkWake. Einer davon lief von 18:04:08 bis 18:04:40, genau bis zur Sekunde des verdächtigen Requests. - Gibt es eine Pi-Session vom 17.10.? Unter
~/.pi/agent/sessions/nein. Das sprach zunächst gegen den Mac. - Wo speichert Pi den Key? In
~/.pi/agent/auth.json, Rechte600, zuletzt geändert am 06.08. um 09:51, also genau beim Anlegen des Keys. Die Datei liegt in keinem Git-Repo und keinem Cloud-Sync-Ordner. - Der Fund: In
~/.pi/agent/logs/docs-maintainer/lagen 6.795 Logdateien. Eine davon enthält die Session-ID aus dem Dashboard und dreimal die Generation-IDgen-1792253080-…, mit Provideropenrouter, Modellmoonshotai/kimi-k2.6undstopReason: error,terminated.
Damit war belegt: Der Request kam von diesem Mac. Die Unix-Zeit in der Generation-ID (1792253080) entspricht exakt 18:04:40 MESZ.
Root Cause: eine Extension, die sich selbst aufruft
Im Code der Extension gibt es keine Schleife. Die Schleife entsteht über Prozessgrenzen hinweg, weil jeder gestartete Prozess die Extension wieder lädt.
Der Pfeil zurück ist die ganze Schleife: Er steht in keiner Codezeile, sondern entsteht, weil das Kind die Extension erneut lädt.
Die Extension sollte nach jedem Pi-Lauf die Doku unter docs/ nachziehen, als Pi-Gegenstück zu einem Claude-Code-Stop-Hook. Der Kern:
pi.on("agent_end", async (_event, ctx) => {
if (!isUnderWorkspaceRoot(ctx.cwd)) return;
if (!ctx.isProjectTrusted()) return;
const signature = codeChangeSignature(repoRoot); // Hash von git status
if (!signature) return; // nichts geändert
if (lastSignatures.get(repoRoot) === signature) return; // schon erledigt
lastSignatures.set(repoRoot, signature);
spawnDocsMaintainer(repoRoot, prompt); // detached pi-Prozess
});
Jede einzelne Prüfung ist korrekt, aber im Kindprozess greift keine davon:
- Gleiches Repo, gleiches Vertrauen. Der Kindprozess startet mit
cwd: repoRoot. Er lädt die projektlokalen Extensions aus.pi/extensions/, also dieselbe Extension. Das Projekt ist als vertrauenswürdig markiert. - Die Änderungen bleiben. Der docs-maintainer committet nicht und fasst nur
docs/an. Die Code-Änderungen, die ihn ausgelöst haben, liegen weiter uncommittet herum, die Signatur ist nicht leer. - Die Sperre ist pro Prozess.
lastSignaturesist eineMapim Arbeitsspeicher. Jeder neue Prozess beginnt mit einer leeren Map und hält die Arbeit für neu. - Auch Fehler lösen aus.
agent_endfeuert auch nach einem abgebrochenen oder fehlgeschlagenen Lauf. Ein 401 stoppt die Kette also nicht, er beschleunigt sie.
Dazu kommt, dass jeder agent_end einer interaktiven Session mit neuem Änderungsstand eine eigene Kette startet. In den Logs starten oft zwei Läufe in derselben Millisekunde, etwa 16-04-08-693Z und 16-04-08-695Z. Die Prozesse sind detached und hängen an keinem Terminal, deshalb sieht man sie im Alltag nicht.
Belegt ist: Nur spawnDocsMaintainer legt diese Logdateien an, und am 17.10. gab es keine interaktive Session, aber 1.546 neue Läufe. Die Eltern-Kind-Beziehung selbst ist abgeleitet, denn die Logs enthalten keine Prozess-IDs.
Exkurs: DarkWake, oder warum ein schlafender Mac arbeitet
Ein zugeklappter Mac schläft nicht durchgehend. Am 17.10. wachte meiner rund alle 15 Minuten für 20 bis 45 Sekunden auf, ohne Bildschirm und ohne dass ich etwas davon bemerkte.
macOS nennt das DarkWake: Der Rechner wacht auf, das Display bleibt aus. Das System nutzt diese Fenster für Wartung, etwa Push-Nachrichten, iCloud-Abgleich, Mail und Netzwerkpflege. Im Log sieht das so aus:
18:04:08 DarkWake DarkWake from Deep Idle [CDNP] : due to smc.sysState.Wake wifibt Using BATT (Charge:60%) 32 secs
18:04:40 Sleep Entering Sleep state due to 'Maintenance Sleep':TCPKeepAlive=active 839 secs
Am 17.10. gab es zwei Auslöser:
wifibt: Ein Ereignis über WLAN oder Bluetooth weckt den Rechner, zum Beispiel eine Push-Nachricht.rtc/Maintenance: Ein geplanter Wartungs-Timer, hier vom NetzwerkdienstmDNSResponder.
Der entscheidende Punkt: Beim Schlafen werden alle Prozesse eingefroren, nicht beendet. Beim nächsten DarkWake laufen sie einfach weiter. Ein losgelöster pi-Prozess, der mitten in einem Request steckt, setzt ihn fort, sobald der Mac für ein paar Sekunden wach ist. Ist der Request fertig, feuert agent_end, und der nächste Prozess startet. Die Startzeiten der Logdateien fallen am 17.10. sekundengenau auf die DarkWakes, etwa 16:02:01, 16:08:40, 16:32:23 und 17:03:33.
Der verdächtige Request lief genau an der Kante: Er wurde in der letzten Sekunde des DarkWake um 18:04:40 abgeschickt. Als der Mac in derselben Sekunde wieder einschlief, brach er mit terminated ab.
Praktisch bedeutet das: „Der Mac war zugeklappt“ ist kein Alibi. Wer das nicht will, schaltet in den Energie-Einstellungen Power Nap und „Ruhezustand für Netzwerkzugriff beenden“ aus, zumindest im Akkubetrieb. Den DarkWake ganz verhindern kann man damit nicht.
Auswirkungen
In 46 Stunden liefen 6.795 docs-maintainer-Läufe mit 18.825 LLM-Requests. Bezahlt wurde fast nur am 17.10., als ich den Mac gar nicht benutzt habe.
Nach 18 Uhr am 17.10. kostete die Kette nichts mehr, weil ich den Key deaktiviert hatte und OpenRouter jeden Request mit 401 ablehnte. Dafür lief sie umso schneller, bis zu 735 Läufe pro Stunde.
- Kosten laut Pi-Logs: rund 32 $, davon 28,56 $ am 17.10. Das OpenRouter-Dashboard zeigt rund 67 $ Verbrauch. Vermutlich fehlen in den Logs die 1.568 abgebrochenen Requests (
terminated): Pi verbucht Kosten nur für fertige Antworten, OpenRouter rechnet bereits erzeugte Tokens trotzdem ab. Verbraucht wurden rund 20 $ Restguthaben und eine Aufladung über 50 $. Die zweite Aufladung über 50 $ hat OpenRouter mir zurückgebucht. - Modelle: 14.875 Requests an
moonshotai/kimi-k2.6über OpenRouter, 3.950 an ein lokales Modell, das nichts kostet. - Fehler: 5.093-mal 401, 3.592 Verbindungsfehler, 2.595 Timeouts, 1.568 Abbrüche durch Sleep, 71-mal 402 (Guthaben aufgebraucht).
- Logvolumen: 1,3 GB in 6.795 Dateien.
Fix und Sofortmaßnahmen
Die Extension ist abgeschaltet, ohne dass etwas gelöscht wurde. Seit 12:43 am 18.10. ist kein Lauf mehr dazugekommen.
- Die Quelle
<workspace>/.pi/extensions/docs-maintainer/nach.pi/extensions-disabled/verschoben. - Die sechs Symlinks in den Repos ebenfalls nach
.pi/extensions-disabled/verschoben. Zurückholen geht mit einemmv. - Die Logs bleiben als Beweismaterial liegen.
- Offene Pi-Sessions neu starten, denn sie haben die Extension noch im Speicher.
Bevor die Extension je wieder läuft, braucht sie einen Schutz gegen sich selbst. Der kleinste wirksame Fix ist eine Umgebungsvariable, die der Kindprozess erbt:
// in spawnDocsMaintainer:
const proc = spawn(command, fullArgs, {
cwd: repoRoot,
detached: true,
env: { ...process.env, PI_DOCS_MAINTAINER_CHILD: "1" },
stdio: ["ignore", logStream, logStream],
});
// ganz oben im agent_end-Handler:
if (process.env.PI_DOCS_MAINTAINER_CHILD) return; // Kindprozess startet nie selbst
Die Sperre bleibt dann zwar pro Prozess, aber es gibt keine Kette mehr, die sie umgehen könnte.
Lessons Learned
Die wichtigste Lehre: Ein Hook, der einen Agenten startet, läuft im Agenten wieder. Wer einen Prozess aus einem Hook startet, muss davon ausgehen, dass derselbe Hook im Kind wieder feuert.
Was schiefging
- Rekursion über Prozessgrenzen. Der Code sah harmlos aus, weil die Schleife nicht im Code stand, sondern in der Laufzeitumgebung.
- Zustand im Speicher als Sperre. Eine Duplikatsperre, die nicht über Prozesse hinweg gilt, schützt nur vor dem Fall, der nie das Problem war.
- Unsichtbare Hintergrundprozesse.
detachedplus Logdatei statt Terminal heißt: kein Fenster, keine Benachrichtigung, kein Geräusch. Gemerkt habe ich es nur über die Abbuchungen. - Fehler als Beschleuniger. Nach dem Wechsel auf 401 dauerte ein Lauf Sekunden statt Minuten. Die Kette lief danach schneller, nicht langsamer.
- Auto-Top-up ohne Obergrenze. Jedes Mal, wenn das Guthaben leer war, wurde nachgeladen. Ein leeres Guthaben hätte die Kette sonst gestoppt.
Was geholfen hat
- Sofort abschalten. Nachdem ich Key und Auto-Top-up deaktiviert hatte, kostete die Kette keinen Cent mehr, obwohl sie noch über 18 Stunden weiterlief. Die zweite Aufladung habe ich mir zurückbuchen lassen, das lohnt sich zu fragen.
- Gute Logs. Ohne die Logdateien hätte die Generation-ID nie zu einer Ursache geführt.
- Die Unix-Zeit in der OpenRouter-Generation-ID. Sie macht den Abgleich mit
pmset -g logsekundengenau.
Was ich anderen rate
- Hooks, die einen Agenten starten, brauchen einen Rekursionsschutz, zum Beispiel eine Umgebungsvariable, an der der Kindprozess sich erkennt.
- Sperren gegen doppelte Läufe gehören in eine Datei, nicht in den Arbeitsspeicher eines Prozesses.
- Nach einem Fehler keinen neuen Lauf automatisch starten.
- Auto-Top-up nur mit Obergrenze nutzen, besser noch ein Tageslimit direkt am API-Key.