pi-system/extensions/arbeitsweise-guard.ts
Raimund Bauer fb3daab33f feat/init: PiSystem Infrastruktur-Repo mit SubConfirm
Enthält alle Pi-Orchestrator-Infrastrukturkomponenten:
- bin/Sub* Skripte (SubAgenten, SubStatus, SubWatcher, SubConfirm)
- extensions/ (arbeitsweise-guard, confirm-deletion, etc.)
- memory/ (arbeitsweise, subagent-autocheck)
- agent/AGENTS.md mit SubConfirm-Reaktionslogik
- install.sh: deterministisches, idempotentes Setup für neue Maschinen

SubConfirm (neu): Stasis-Detektor der alle 30s tmux-Sessions prüft.
Bei unverändertem Output sendet er den vollständigen Pane-Inhalt
an die Alert-Datei — der Orchestrator beurteilt selbst ob Handlung nötig.
Kein Keyword-Matching.
2026-06-02 11:53:37 +02:00

287 lines
9.7 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 { 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 -1`,
{ encoding: "utf-8", timeout: 1000 },
).trim().slice(0, 100);
// 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}`;
}
return `${name}: ${lastLine || "(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;
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;
}
}