/** * 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(); 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 ─────────────────────────── const interval = setInterval(() => { 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 {} }, 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 }; }); }