pi-system/extensions/arbeitsweise-guard.ts
Raimund Bauer 5057f500a0 feat/guard: watch_subagents Custom-Tool — proaktiver 30s-Watcher ohne externen Prozess
pi.registerTool('watch_subagents'): wartet 30s (respektiert AbortSignal),
gibt dann tmux-Status aller Sessions zurück. AGENTS.md: Orchestrator MUSS
das Tool nach jedem Return sofort neu aufrufen — permanente Polling-Schleife.
Kein SubConfirm, kein tmux send-keys, kein externer Prozess nötig.
2026-06-02 12:17:13 +02:00

332 lines
12 KiB
TypeScript
Raw 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.

/**
* Arbeitsweise-Guard Extension
*
* Aktiv NUR wenn PI_ORCHESTRATOR=1 (d.h. gestartet über OrchestratorPi).
*
* Blockiert Tool-Aufrufe, die typische Subagenten-Arbeit sind, und zeigt
* eine lokale Benachrichtigung in der Orchestrator-Session. Kein Intercom,
* keine externe Verbindung nötig.
*
* Erlaubt im Orchestrator:
* - Alle lesenden Operationen (read, grep, find, ls, ...)
* - Schreiben in ~/.pi/agent/memory/
* - Schreiben in /tmp/subagent-* (Übergabe-Dateien für Subagenten)
* - SESSION_HANDOVER.md und AGENTS.md
* - Die eigene Guard-Datei
* - SubAgenten starten (bash: SubAgenten-Befehl)
* - Intercom-Kommandos
*
* Blockiert im Orchestrator:
* - write/edit auf Projektpfade ausserhalb der erlaubten Pfade
* - bash: curl, wget, ssh, scp, rsync, apt/pip/npm install,
* git clone/push/commit (ausser in /tmp/subagent-*),
* firecrawl, tavily, jina, playwright, pkexec
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
// Nur im Orchestrator aktiv — in Subagenten-Sessions keine Wirkung.
const ORCHESTRATOR_AKTIV = !!process.env.PI_ORCHESTRATOR;
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const ERLAUBTE_SCHREIB_PRAEFIXE = [
"/home/xray/.pi/agent/memory/",
"/home/xray/.pi/subagents/",
"/tmp/subagent-", // Fallback für bestehende Sessions
];
const ERLAUBTE_SCHREIB_DATEIEN = [
"AGENTS.md",
"SESSION_HANDOVER.md",
"arbeitsweise-guard.ts",
];
const VERDAECHTIGE_BASH: Array<{
muster: string;
label: string;
}> = [
{ muster: "curl ", label: "HTTP-Request (curl)" },
{ muster: "curl\t", label: "HTTP-Request (curl)" },
{ muster: "wget ", label: "HTTP-Download (wget)" },
{ muster: "ssh ", label: "SSH-Verbindung" },
{ muster: "scp ", label: "Datei-Transfer (scp)" },
{ muster: "rsync ", label: "Datei-Transfer (rsync)" },
{ muster: "apt install", label: "Paketinstallation (apt)" },
{ muster: "apt-get install", label: "Paketinstallation (apt-get)" },
{ muster: "pip install", label: "Paketinstallation (pip)" },
{ muster: "pip3 install", label: "Paketinstallation (pip3)" },
{ muster: "npm install", label: "Paketinstallation (npm)" },
{ muster: "git clone", label: "Repository klonen (git clone)" },
{ muster: "git push", label: "Git push" },
{ muster: "git commit", label: "Git commit" },
{ muster: "firecrawl", label: "Web-Crawling (firecrawl)" },
{ muster: "tavily", label: "Web-Suche (tavily)" },
{ muster: "jina", label: "Web-Recherche (jina)" },
{ muster: "playwright", label: "Browser-Automation (playwright)" },
{ muster: "pkexec", label: "Privilegierte Ausfuehrung (pkexec)" },
{ muster: "sleep ", label: "Kuenstliches Warten (sleep)" },
];
// Bash-Befehle die der Orchestrator selbst ausführen darf.
const ERLAUBTE_BASH_PRAEFIXE = [
"ls", "cat ", "cat\t", "grep ", "grep\t", "find ", "find\t",
"rg ", "rg\t", "ps", "stat ", "file ", "head ", "tail ",
"wc", "date", "echo ", "echo\t", "pwd", "test ",
"mkdir -p /tmp/subagent-",
"mkdir -p ~/.pi/agent/",
"SubAgenten ", "subagenten ",
"SubStatus", "substatus",
"intercom ", "intercom\t",
"which ", "type ", "command -v ",
"env ", "printenv ",
"tmux ", "tmux\t",
];
// ---------------------------------------------------------------------------
// Hilfsfunktionen
// ---------------------------------------------------------------------------
function istErlaubterSchreibPfad(pfad: string): boolean {
const p = pfad.startsWith("~/")
? process.env.HOME + pfad.slice(1)
: pfad;
if (ERLAUBTE_SCHREIB_PRAEFIXE.some((pref) => p.startsWith(pref))) return true;
const basename = p.split("/").pop() ?? "";
if (ERLAUBTE_SCHREIB_DATEIEN.includes(basename)) return true;
// .pi/agent/memory via Regex
if (/\/\.pi\/agent\/memory\//.test(p)) return true;
return false;
}
function pruefeWriteEdit(
toolName: string,
input: { path?: string; file_path?: string },
): string | null {
const pfad = input.path ?? input.file_path ?? "";
if (!pfad) return null;
if (istErlaubterSchreibPfad(pfad)) return null;
return `${toolName} auf "${pfad}" → ausserhalb Orchestrator-Pfade. Bitte an Subagent delegieren.`;
}
function pruefeBasch(input: { command?: string }): string | null {
const cmd = input.command ?? "";
if (!cmd) return null;
const trimmed = cmd.trim();
// Erlaubte Praefixe freigeben.
for (const pref of ERLAUBTE_BASH_PRAEFIXE) {
if (trimmed === pref.trimEnd() || trimmed.startsWith(pref)) return null;
}
// Verdaechtige Muster pruefen.
for (const { muster, label } of VERDAECHTIGE_BASH) {
if (!cmd.includes(muster)) continue;
// git commit/push in Subagent-Verzeichnissen ist erlaubt.
if (
(muster === "git commit" || muster === "git push") &&
(cmd.includes("/tmp/subagent-") || cmd.includes("/.pi/subagents/"))
) {
return null;
}
return `bash "${label}" → Subagenten-Arbeit. Bitte an Subagent delegieren.`;
}
return null;
}
// ---------------------------------------------------------------------------
// tmux-Status-Abfrage (fuer tool_result-Hook)
// ---------------------------------------------------------------------------
function tmuxSubagentenStatus(): string | null {
try {
const raw = execSync("tmux ls -F '#{session_name}' 2>/dev/null || true", {
encoding: "utf-8",
timeout: 2000,
}).trim();
if (!raw) return null;
const sessions = raw.split("\n").map((s) => s.trim()).filter(Boolean);
if (sessions.length === 0) return null;
const fertigSessions: string[] = [];
const lines = sessions.map((name) => {
try {
const lastLine = execSync(
`tmux capture-pane -t '${name}' -p 2>/dev/null | grep -v '^[[:space:]]*$' | tail -5`,
{ encoding: "utf-8", timeout: 1000 },
).trim().slice(0, 500);
// W03: Beendete Sessions erkennen und explizit markieren.
if (lastLine.includes("Pi beendet") || lastLine.includes("=== Pi beendet ===") || lastLine.includes("Fenster kann geschlossen werden")) {
fertigSessions.push(name);
return `${name}: ✅ FERTIG → tmux kill-session -t ${name}`;
}
// Dialog-Erkennung: Erlauben/Ablehnen-Prompt sichtbar machen.
if (lastLine.includes("Erlauben?") || lastLine.includes("→ Yes") || lastLine.includes("navigate") || lastLine.includes("ACHTUNG:")) {
return `${name} ⚠️ WARTET AUF BESTÄTIGUNG:\n${lastLine.split("\n").map(l => ` ${l}`).join("\n")}`;
}
return `${name}: ${lastLine.split("\n").pop() || "(leer)"}`;
} catch {
return `${name}`;
}
});
let result = `[Auto-Check tmux] ${sessions.length} Session(s) aktiv:\n${lines.join("\n")}`;
// W03: Wenn fertige Sessions erkannt, expliziten Hinweis hinzufügen.
if (fertigSessions.length > 0) {
result += `\n\n⚠ [W03] ${fertigSessions.length} fertige Session(s) — bitte schliessen:\n` +
fertigSessions.map((n) => ` tmux kill-session -t ${n}`).join("\n");
}
return result;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
if (!ORCHESTRATOR_AKTIV) return;
// -------------------------------------------------------------------------
// watch_subagents — Proaktiver 30s-Watcher
//
// Wartet 30 Sekunden, dann gibt es den aktuellen Status aller tmux-Sessions
// zurück. Der Orchestrator MUSS dieses Tool sofort nach dem Return neu
// aufrufen — so entsteht eine permanente 30s-Polling-Schleife ohne externen
// Prozess. AbortSignal wird respektiert: bei Nutzer-Input endet der Sleep
// sofort und der Status wird sofort zurückgegeben.
// -------------------------------------------------------------------------
pi.registerTool({
name: "watch_subagents",
label: "Subagenten-Watcher (30s)",
description:
"Wartet 30 Sekunden und prüft dann alle Subagenten-Sessions. " +
"PFLICHT: Nach dem Return sofort wieder aufrufen. " +
"Nur im Orchestrator aktiv. Nutzer-Input unterbricht den Sleep sofort.",
parameters: Type.Object({}),
execute: async (_toolCallId, _params, signal, _onUpdate, _ctx) => {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 30_000);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
resolve();
});
});
const status = tmuxSubagentenStatus();
const alerts = leseSubWatcherAlerts();
const parts = ["⏱️ [watch_subagents] 30s-Check:"];
if (status) parts.push(status);
if (alerts) parts.push(alerts);
if (!status && !alerts) parts.push("Alle Sessions aktiv, kein Handlungsbedarf.");
return {
content: [{ type: "text" as const, text: parts.join("\n\n") }],
details: {},
};
},
});
pi.on("tool_call", async (event, ctx) => {
let grund: string | null = null;
if (isToolCallEventType("write", event)) {
grund = pruefeWriteEdit("write", event.input as { path?: string });
} else if (isToolCallEventType("edit", event)) {
grund = pruefeWriteEdit("edit", event.input as { file_path?: string });
} else if (isToolCallEventType("bash", event)) {
grund = pruefeBasch(event.input as { command?: string });
}
if (!grund) return;
ctx.ui.notify(
`[Orchestrator-Guard] Blockiert: ${grund}`,
"warning",
);
return { block: true, reason: grund };
});
// Nach jedem Tool-Result: tmux-Status automatisch anhängen.
// Verhindert, dass der Orchestrator sleep-Intervalle erfindet um
// Subagenten-Status zu prüfen — er sieht den Status direkt im Result.
pi.on("tool_result", async (_event) => {
const status = tmuxSubagentenStatus();
// W08: Intercom-Hänger prüfen.
const extraHinweis = pruefeIntercomHaenger();
// W02: SubWatcher-Alerts aus Alert-Datei lesen und einmalig anzeigen.
const subWatcherAlerts = leseSubWatcherAlerts();
const extras = [status, extraHinweis, subWatcherAlerts].filter(Boolean).join("\n\n");
if (!extras) return;
return {
content: [
..._event.content,
{ type: "text" as const, text: `\n\n${extras}` },
],
};
});
}
// ---------------------------------------------------------------------------
// W08: Intercom-Hänger-Check (nach SubAgent-Starts)
// ---------------------------------------------------------------------------
function pruefeIntercomHaenger(): string | null {
try {
// 'intercom' ist kein Shell-Befehl sondern ein Pi-internes Tool —
// dieser Check funktioniert nur wenn Pi intercom im PATH bereitstellt.
const raw = execSync("intercom pending 2>/dev/null || true", {
encoding: "utf-8",
timeout: 3000,
}).trim();
// Kein Hänger wenn leer oder "No unresolved inbound asks"
if (!raw || raw.includes("No unresolved") || raw.includes("no pending")) return null;
return `[Auto-Check intercom] Offene Rückfragen:\n${raw.slice(0, 300)}`;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// W02: SubWatcher-Alerts — von SubWatcher geschriebene Benachrichtigungen
// ---------------------------------------------------------------------------
function leseSubWatcherAlerts(): string | null {
const alertFile = "/tmp/.pi-subagent-alert";
try {
if (!existsSync(alertFile)) return null;
const content = readFileSync(alertFile, "utf-8").trim();
if (!content) return null;
// Alert-Datei leeren nach dem Lesen (einmalige Anzeige)
writeFileSync(alertFile, "");
return `📬 [W02 SubWatcher-Alerts]:\n${content}`;
} catch {
return null;
}
}