pi-system/extensions/rule-enforcer.ts
Raimund Bauer 6371fb9f60 fix/extensions: rule-enforcer — setInterval gegen uncaught exceptions härten
Pis Loader (loader.js:298-311) kapselt nur die synchrone Factory-Ausführung.
Das 30s-setInterval feuert später und läge außerhalb dieses Schutzes — eine
Exception dort wäre uncaught und könnte den Pi-Prozess beenden. Daher: gesamter
Intervall-Rumpf in try/catch.

Empirisch verifiziert (Pi-SDK createAgentSession, agentDir=~/.pi/agent):
- rule-enforcer.ts lädt mit 0 Fehlern (10/10 Extensions geladen)
- absichtlich kaputte Test-Extension crasht Pi NICHT — Fehler isoliert, gesunde
  Extensions laden weiter, Session startet normal
2026-06-02 18:40:44 +02:00

319 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Rule Enforcer Extension
*
* Mechanismen:
*
* 1. before_agent_start — injiziert Kern-Regeln in jeden System-Prompt-Turn
* + Selbstkorrektur wenn letzter Turn W06 verletzt hat
* + Subagenten-Lage aus Alert-File (passiver Pfad)
*
* 2. message_end — scannt Pi's Ausgabe auf W06-Deliberations-Muster,
* setzt Flag für nächsten before_agent_start
*
* 3. setInterval (30s) — erzwingt die Invariante:
* "Solange ein Subagent läuft, darf der Orchestrator nicht idle sein."
* Erkennt laufende Subagenten per PROZESSBAUM (lebt ein `pi`-Kind unter dem
* tmux-Pane?), nicht per Keyword. Ist der Orchestrator idle, obwohl ein
* Subagent läuft, wird er AKTIV via pi.sendUserMessage() geweckt — das löst
* garantiert einen Turn aus. Der passive Alert-File-Pfad allein reicht nicht,
* weil before_agent_start bei einem idle Orchestrator nie feuert.
*
* Nur aktiv wenn PI_ORCHESTRATOR=1.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const ORCHESTRATOR_AKTIV = !!process.env.PI_ORCHESTRATOR;
const ALERT_FILE = "/tmp/.pi-subagent-alert";
const CHECK_INTERVAL_MS = 30_000;
// Idle = seit so langer Zeit kein Pi-Event. 45s > 30s watch_subagents-Pollintervall,
// damit aktive Überwachung nicht fälschlich als idle gilt.
const IDLE_SCHWELLE_MS = 45_000;
// Höchstens eine aktive Weckung in diesem Abstand.
const WECK_MIN_ABSTAND_MS = 60_000;
const SUBAGENT_PFAD_MARKER = "/.pi/subagents/";
// ---------------------------------------------------------------------------
// Kern-Regeln — werden in jeden System-Prompt-Turn injiziert
// ---------------------------------------------------------------------------
const KERN_REGELN = `
## PFLICHT-REGELN — gelten für JEDE Antwort ohne Ausnahme
**W06** Kein Deliberieren: Niemals "Let me", "Actually", "I should", "Ich sollte", "Das liegt daran", "Weil ich" in Antworten ausgeben. Nur Ergebnis oder Entscheidung.
**W07** Subagenten nicht aufgeben: Bei Problemen Benutzer kurz informieren, auf Anweisung warten. Nie eigenständig auf direkte Ausführung umschwenken.
**W09** sleep verboten: Stattdessen watch_subagents nutzen oder Benutzer informieren.
**W10** Subagent nie doppelt starten: Einmal starten, dann watch_subagents abwarten.
**WATCH** watch_subagents SOFORT nach jeder Antwort erneut aufrufen — permanent, ohne Ausnahme. Solange ein Subagent läuft, darfst du NIE idle werden.
`.trim();
// ---------------------------------------------------------------------------
// W06-Deliberations-Muster
// ---------------------------------------------------------------------------
const DELIBERATIONS_MUSTER: RegExp[] = [
/\bLet me\b/,
/\bActually,?\s/,
/\bI should\b/,
/\bI need to\b/,
/\bI'll check\b/,
/\bI'll look\b/,
/\bLet me check\b/,
/\bLet me look\b/,
/\bLet me verify\b/,
/\bLet me first\b/,
/\bHmm,?\s/i,
/\bWait,?\s/,
/\bIch sollte\b/,
/\bLass mich\b/,
/\bDas liegt daran\b/,
/\bWeil ich\b/,
/\bIch muss\b/,
/\bIch werde\b.*\bprüfen\b/,
];
function hatDeliberation(text: string): boolean {
return DELIBERATIONS_MUSTER.some((p) => p.test(text));
}
function textAusNachricht(message: any): string {
const content = message?.content;
if (!Array.isArray(content)) return "";
return content
.filter((c: any) => c.type === "text")
.map((c: any) => String(c.text ?? ""))
.join(" ");
}
// ---------------------------------------------------------------------------
// Alert-File (passiver Pfad)
// ---------------------------------------------------------------------------
function leseAlerts(): string | null {
try {
if (!fs.existsSync(ALERT_FILE)) return null;
const content = fs.readFileSync(ALERT_FILE, "utf-8").trim();
if (!content) return null;
fs.writeFileSync(ALERT_FILE, "");
return content;
} catch {
return null;
}
}
function schreibeAlert(text: string): void {
try {
fs.writeFileSync(ALERT_FILE, text);
} catch {}
}
// ---------------------------------------------------------------------------
// Laufende Subagenten per PROZESSBAUM erkennen
//
// pane_current_command ist unbrauchbar (meldet "bash", obwohl pi als Kind läuft).
// Stattdessen: für jede tmux-Session unter ~/.pi/subagents/ prüfen, ob unter dem
// Pane-Shell-PID ein lebender `pi`-Prozess hängt.
// ---------------------------------------------------------------------------
interface LaufenderSubagent {
name: string;
dialog: string | null; // Pane-Auszug, falls ein Bestätigungs-Dialog erkennbar ist
}
function laeuftPiUnter(panePid: number): boolean {
try {
const kids = execSync(`pgrep -P ${panePid} 2>/dev/null || true`, {
encoding: "utf-8",
timeout: 1500,
}).trim();
const pids = kids.split(/\s+/).filter(Boolean);
for (const k of pids) {
const n = Number(k);
if (!Number.isInteger(n)) continue;
const comm = execSync(`ps -o comm= -p ${n} 2>/dev/null || true`, {
encoding: "utf-8",
timeout: 1000,
}).trim();
if (comm === "pi") return true;
// pi könnte eine Ebene tiefer laufen (z.B. via node-Wrapper)
if (laeuftPiUnter(n)) return true;
}
return false;
} catch {
return false;
}
}
function laufendeSubagenten(): LaufenderSubagent[] {
try {
const raw = execSync(
"tmux list-panes -a -F '#{session_name}::#{pane_pid}::#{pane_current_path}' 2>/dev/null || true",
{ encoding: "utf-8", timeout: 2500 },
).trim();
if (!raw) return [];
const ergebnis: LaufenderSubagent[] = [];
const gesehen = new Set<string>();
for (const line of raw.split("\n")) {
const teile = line.split("::");
if (teile.length < 3) continue;
const name = teile[0].trim();
const panePid = Number(teile[1].trim());
const path = teile[2].trim();
if (!name || gesehen.has(name)) continue;
if (!path.includes(SUBAGENT_PFAD_MARKER)) continue;
if (!Number.isInteger(panePid)) continue;
if (!laeuftPiUnter(panePid)) continue; // Pi nicht (mehr) aktiv ⇒ kein laufender Subagent
gesehen.add(name);
// Optional: Dialog-Inhalt zur Anreicherung der Weckmeldung
let dialog: string | null = null;
try {
const pane = execSync(
`tmux capture-pane -t '${name}' -p 2>/dev/null | tail -8`,
{ encoding: "utf-8", timeout: 1000 },
).trim();
if (
/Erlauben\?|→ Yes|Überschreiben\?|ACHTUNG:|Bestätigung/.test(pane)
) {
dialog = pane;
}
} catch {}
ergebnis.push({ name, dialog });
}
return ergebnis;
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
if (!ORCHESTRATOR_AKTIV) return;
let letzterVerstoss: string | null = null;
let letzterCtx: any = null;
let letzteAktivitaet = Date.now();
let letzteWeckung = 0;
function markAktiv(ctx?: any): void {
letzteAktivitaet = Date.now();
if (ctx) letzterCtx = ctx;
}
// ── 30s Background-Check: Invariante erzwingen ───────────────────────────
// WICHTIG: Der gesamte Rumpf ist in try/catch gekapselt. Pis Loader umschließt
// nur die synchrone Factory-Ausführung (loader.js:298-311); dieses Intervall
// feuert später und läge sonst AUSSERHALB von Pis Schutz — eine Exception hier
// wäre uncaught und könnte den Pi-Prozess beenden. Darum: niemals werfen.
const interval = setInterval(() => {
try {
const subagenten = laufendeSubagenten();
if (subagenten.length === 0) return; // kein laufender Subagent ⇒ idle ist erlaubt
const dialoge = subagenten.filter((s) => s.dialog);
const beschreibung = subagenten
.map(
(s) =>
`${s.name}${s.dialog ? " — WARTET AUF BESTÄTIGUNG" : " — läuft"}`,
)
.join("\n");
// Passiver Pfad: greift, falls der Orchestrator gerade in einem Turn ist
schreibeAlert(
`[30s-Check] Laufende Subagenten — Orchestrator muss überwachen:\n${beschreibung}`,
);
// Idle-Erkennung: primär zeitbasiert (ctx-unabhängig)
const idleMs = Date.now() - letzteAktivitaet;
if (idleMs < IDLE_SCHWELLE_MS) return; // Orchestrator aktiv ⇒ nichts tun
// Zusätzliches Veto, falls ctx zuverlässig "beschäftigt" meldet
try {
if (letzterCtx && typeof letzterCtx.isIdle === "function") {
if (letzterCtx.isIdle() === false) return;
}
} catch {}
const jetzt = Date.now();
if (jetzt - letzteWeckung < WECK_MIN_ABSTAND_MS) return;
letzteWeckung = jetzt;
const dringlich = dialoge.length > 0;
const dialogText = dialoge
.map((d) => `--- ${d.name} ---\n${d.dialog}`)
.join("\n");
try {
letzterCtx?.ui?.notify?.(
dringlich
? "🚨 Subagent wartet auf Bestätigung — Orchestrator wird geweckt."
: "⏱️ Subagent läuft, Orchestrator war idle — wird geweckt.",
"warning",
);
} catch {}
try {
pi.sendUserMessage(
`🚨 [Auto-Weckung] Es läuft mindestens ein Subagent, aber du warst idle. ` +
`Das ist nicht erlaubt, solange Subagenten laufen.\n${beschreibung}\n` +
(dringlich
? `\nEin Subagent wartet auf eine Bestätigung:\n${dialogText}\n→ Sofort watch_subagents aufrufen, den Dialog prüfen und nach Regel bestätigen ODER den Benutzer informieren.\n`
: ``) +
`→ Nimm die watch_subagents-Überwachung sofort wieder auf und halte sie, bis ALLE Subagenten fertig sind.`,
);
} catch {}
} catch {
// Defensive Notbremse: Das Intervall darf den Pi-Prozess NIE crashen.
}
}, CHECK_INTERVAL_MS);
pi.on("session_shutdown", async () => {
clearInterval(interval);
});
// ── Aktivitäts-Tracking für die zeitbasierte Idle-Erkennung ──────────────
pi.on("turn_start", async (_e, ctx) => markAktiv(ctx));
pi.on("turn_end", async (_e, ctx) => markAktiv(ctx));
pi.on("message_start", async (_e, ctx) => markAktiv(ctx));
pi.on("message_update", async (_e, ctx) => markAktiv(ctx));
pi.on("tool_execution_start", async (_e, ctx) => markAktiv(ctx));
pi.on("tool_execution_end", async (_e, ctx) => markAktiv(ctx));
// ── message_end: W06-Scan + Aktivität ────────────────────────────────────
pi.on("message_end", async (event, ctx) => {
markAktiv(ctx);
const text = textAusNachricht(event.message);
if (hatDeliberation(text)) {
letzterVerstoss = "W06 (Internes Deliberieren)";
}
});
// ── before_agent_start: System-Prompt-Injection ──────────────────────────
pi.on("before_agent_start", async (event, ctx) => {
markAktiv(ctx);
let zusatz = `\n\n---\n${KERN_REGELN}`;
if (letzterVerstoss) {
zusatz += `\n\n⚠ REGELVERSTOSS erkannt (${letzterVerstoss}): Diese Antwort OHNE Gedankenkommentar. Nur Ergebnis oder Entscheidung.`;
letzterVerstoss = null;
}
const alerts = leseAlerts();
if (alerts) {
zusatz += `\n\n🚨 SUBAGENT-LAGE:\n${alerts}\n→ watch_subagents aufrufen und die Überwachung halten, bis alle Subagenten fertig sind.`;
}
return { systemPrompt: event.systemPrompt + zusatz };
});
}