2026-06-02 11:53:37 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
2026-06-02 12:17:13 +02:00
|
|
|
|
import { Type } from "typebox";
|
2026-06-02 11:53:37 +02:00
|
|
|
|
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(
|
2026-06-02 12:03:41 +02:00
|
|
|
|
`tmux capture-pane -t '${name}' -p 2>/dev/null | grep -v '^[[:space:]]*$' | tail -5`,
|
2026-06-02 11:53:37 +02:00
|
|
|
|
{ encoding: "utf-8", timeout: 1000 },
|
2026-06-02 12:03:41 +02:00
|
|
|
|
).trim().slice(0, 500);
|
2026-06-02 11:53:37 +02:00
|
|
|
|
|
|
|
|
|
|
// 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}`;
|
|
|
|
|
|
}
|
2026-06-02 12:03:41 +02:00
|
|
|
|
// 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)"}`;
|
2026-06-02 11:53:37 +02:00
|
|
|
|
} 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;
|
|
|
|
|
|
|
2026-06-02 12:17:13 +02:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// 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: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 11:53:37 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|