/** * MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten. * * Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die * Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden * Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge: * * 1. WERKZEUG-ZAUN (tool.execute.before) * Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push, * Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser * Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 — * nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen. * * 2. PRUEF-TOR + SCHLEIFE (session.idle) * Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei * `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler * geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu * MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse. * * 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted) * Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper * "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md * zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat. * * 4. STIMME (MC2 /api/voice/announce) * Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten. * Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code. * * Schalter (Umgebungsvariablen): * MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001) * MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an) * MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3) * MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an) * MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen * MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle) * * Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt — * am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser. */ const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001" const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0" const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10) const SILENT = process.env.MC2_LOOP_SILENT === "1" const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0" const FENCE_OFF = process.env.MC2_FENCE_OFF === "1" /** * Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile — * ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt. * Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen * Entgleisen, keine Abwehr gegen einen boesartigen Akteur. */ const FENCE: Array<{ rx: RegExp; why: string }> = [ { rx: /\bgit\s+push\b/, why: "git push — Veroeffentlichen ist Sache des Menschen (oder der CI-Ampel)." }, { rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." }, { rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." }, { rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." }, { rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." }, { rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." }, { rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." }, { rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." }, { rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." }, { rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." }, { rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." }, ] /** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */ const rounds = new Map() /** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */ const busy = new Set() async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") { if (!ANNOUNCE_ON) return try { await fetch(`${BOX_URL}/api/voice/announce`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject, text, source: "loop", priority: SILENT ? "silent" : priority, }), signal: AbortSignal.timeout(4000), }) } catch { /* best effort — eine stumme Lucy darf den Bau nie aufhalten */ } } export const MC2Governor = async ({ client, $, directory, worktree }: any) => { const root: string = worktree || directory || process.cwd() /** * Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS. * Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und * nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig. */ async function readVerify(): Promise { try { const { readFile } = await import("node:fs/promises") const { join } = await import("node:path") const raw = await readFile(join(root, "VERIFY"), "utf8") const cmd = raw .split("\n") .map((l: string) => l.trim()) .filter((l: string) => l && !l.startsWith("#")) .join(" && ") return cmd || null } catch { return null } } /** * Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt. * `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales * `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen. * Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt. */ async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> { try { const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet() const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}` return { ok: res.exitCode === 0, out: out.slice(-4000) } } catch (e: any) { return { ok: false, out: String(e?.message ?? e).slice(-4000) } } } /** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */ async function sendPrompt(sessionID: string, text: string): Promise { try { await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text }] }, }) return true } catch { return false } } return { // ── 1. Werkzeug-Zaun ─────────────────────────────────────────────────── "tool.execute.before": async (input: any, output: any) => { if (FENCE_OFF) return if (input?.tool !== "bash") return const cmd: string = output?.args?.command ?? "" if (!cmd) return for (const rule of FENCE) { if (rule.rx.test(cmd)) { await announce( "[Zaun]", `Ich habe einen Befehl geblockt: ${rule.why}`, "silent", ) // Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem // Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders. throw new Error( `[MC2-ZAUN] Blockiert: ${rule.why}\n` + `Befehl war: ${cmd}\n` + `Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` + `er macht es selbst.`, ) } } }, // ── 2.-4. Ereignisse ─────────────────────────────────────────────────── event: async ({ event }: any) => { const type: string = event?.type ?? "" const props: any = event?.properties ?? event ?? {} const sessionID: string = props.sessionID || props.sessionId || props.id || "" // ── Savepoint statt Zusammenfassen ────────────────────────────────── if (type === "session.compacted" || type === "experimental.session.compacting") { await announce( "[Sitzung]", "Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.", "silent", ) if (sessionID) { await sendPrompt( sessionID, "[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " + "Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " + "erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " + "Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.", ) } return } // ── Pruef-Tor + Selbstreparatur ───────────────────────────────────── if (type !== "session.idle" || !sessionID) return if (busy.has(sessionID)) return const cmd = await readVerify() if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus. busy.add(sessionID) try { const { ok, out } = await runVerify(cmd) const round = rounds.get(sessionID) ?? 0 if (ok) { rounds.delete(sessionID) await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal") return } if (!AUTOFIX || round >= MAX_ROUNDS) { rounds.delete(sessionID) await announce( "[Pruefung]", `Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` + `Hier komme ich allein nicht weiter, Commander.`, "normal", ) return } rounds.set(sessionID, round + 1) await announce( "[Pruefung]", `Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`, "silent", ) await sendPrompt( sessionID, `[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` + `Projekts ist fehlgeschlagen.\n\n` + `Befehl: ${cmd}\n\n` + `Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` + `Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` + `Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` + `(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`, ) } finally { busy.delete(sessionID) } }, } } export default MC2Governor