Root Cause (log-belegt, 36,6-min-Lücke 15:25–16:01): watch_subagents ist ein Polling-Tool; die Überwachung lebt nur solange das LLM es neu aufruft. Eine User-Zwischenfrage riss die Schleife ab, der Orchestrator ging idle. Der passive Alert-File-Pfad (nur in before_agent_start gelesen) feuert bei idle nie → Orchestrator schlief 36 min bis der Mensch tippte. Fix: 30s-Check erkennt laufende Subagenten per Prozessbaum (lebt ein pi-Kind unter dem tmux-Pane?), nicht per Keyword. Idle + laufender Subagent → aktive Weckung via pi.sendUserMessage() (löst garantiert einen Turn aus) + ui.notify. Idle-Erkennung zeitbasiert (45s, > 30s Pollintervall), ctx-unabhängig. Verifiziert: Syntax, Modul-Load, Handler-Registrierung, Prompt-Injection, W06, Prozessbaum-Erkennung an echten Sessions. NICHT verifiziert: Live-Weckpfad (erfordert Orchestrator-Test). Plan: doku/fix-plan-orchestrator-wecker-v2026-06-02-18-19.md
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
/**
|
||
* 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 ───────────────────────────
|
||
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 };
|
||
});
|
||
}
|