"use strict";

/**
 * ARIA Diagnostic Server
 *
 * Leichtgewichtiges Diagnose-Tool:
 * - Verbindet sich direkt mit OpenClaw Gateway (ws://127.0.0.1:18789)
 * - Fuehrt den vollstaendigen Handshake durch
 * - Sendet Testnachrichten und zeigt Antworten
 * - Zeigt Verbindungsstatus aller Komponenten
 *
 * Laeuft im selben Netzwerk wie aria-core (network_mode: service:aria)
 */

const http = require("http");
const { WebSocket, WebSocketServer } = require("ws");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

// ── Konfiguration ───────────────────────────────────────
const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10);
const GATEWAY_URL = process.env.ARIA_CORE_WS || "ws://127.0.0.1:18789";
const GATEWAY_TOKEN = process.env.ARIA_AUTH_TOKEN || "";
const RVS_HOST = process.env.RVS_HOST || "";
const RVS_PORT = process.env.RVS_PORT || "443";
const RVS_TLS = process.env.RVS_TLS || "true";
const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true";
const RVS_TOKEN = process.env.RVS_TOKEN || "";
const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456";

// ── Persistenz fuer agent_stream-Events ──────────────────
// Jeder agent_stream-Event wird parallel zum Broadcast in eine .jsonl
// geschrieben. Live-View laedt beim Tab-Oeffnen die letzten ~200 Zeilen,
// damit Browser-Reload / Standby den Verlauf nicht wegwerfen. Rotation
// haendelt logrotate / manual cleanup — wir cappen hier nur weichweich.
const AGENT_STREAM_LOG = process.env.AGENT_STREAM_LOG || "/shared/logs/agent_stream.jsonl";
const AGENT_STREAM_MAX_BYTES = 50 * 1024 * 1024;  // 50 MB → halten den File handlebar
function appendAgentStream(payload) {
  if (!payload || typeof payload !== "object") return;
  try {
    const line = JSON.stringify({ ts: Date.now(), ...payload }) + "\n";
    // Soft-Cap: bei >50 MB ein Truncate auf den letzten ~25 MB Inhalt
    try {
      const st = fs.statSync(AGENT_STREAM_LOG);
      if (st.size > AGENT_STREAM_MAX_BYTES) {
        const half = Math.floor(AGENT_STREAM_MAX_BYTES / 2);
        const fd = fs.openSync(AGENT_STREAM_LOG, "r");
        const buf = Buffer.alloc(half);
        fs.readSync(fd, buf, 0, half, st.size - half);
        fs.closeSync(fd);
        // bis zum naechsten Newline springen damit wir keine halbe Zeile haben
        const firstNl = buf.indexOf(0x0a);
        const start = firstNl >= 0 ? firstNl + 1 : 0;
        fs.writeFileSync(AGENT_STREAM_LOG, buf.slice(start));
      }
    } catch {}
    // Verzeichnis sicherstellen
    try { fs.mkdirSync(path.dirname(AGENT_STREAM_LOG), { recursive: true }); } catch {}
    fs.appendFileSync(AGENT_STREAM_LOG, line);
  } catch (e) {
    // Schweigend ignorieren — Persistence darf den Stream nicht blockieren
  }
}

// ── State ───────────────────────────────────────────────
const state = {
  gateway: { status: "disconnected", lastError: null, handshakeOk: false },
  rvs: { status: "disconnected", lastError: null },
  proxy: { status: "unknown", lastError: null },
};
const SESSION_KEY_FILE = "/data/active-session";
// /data Verzeichnis sicherstellen (Volume Mount)
try { fs.mkdirSync("/data", { recursive: true }); } catch (e) {
  console.error(`[startup] /data mkdir fehlgeschlagen: ${e.message}`);
}
// sessionFromFile zeigt an, ob der aktive Key aus der Datei kam.
// Wenn true, darf resolveActiveSession NICHT mehr auto-picken (Wahl respektieren).
let sessionFromFile = false;
let activeSessionKey = (() => {
  try {
    const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim();
    if (saved) {
      console.log(`[startup] Gespeicherte Session geladen: '${saved}'`);
      sessionFromFile = true;
      return saved;
    }
  } catch (e) {
    console.error(`[startup] SESSION_KEY_FILE read: ${e.code || e.message}`);
  }
  console.log("[startup] Keine gespeicherte Session — Fallback 'main'");
  return "main";
})();

// ── Auto-Versionierung /shared/uploads/ via git ────────────────
//
// Jede Aenderung im uploads/-Verzeichnis (User-Upload, ARIA-Generate,
// ARIA-Bearbeitung) wird durch eine 30s-Polling-Loop in einen git-Commit
// gepackt. Idempotent (kein Commit ohne Diff), kein Bloat im Normalbetrieb.
// Stefan kann via UI eine Version anschauen, herunterladen oder als
// neue aktive Version setzen (Restore = neuer commit mit altem Inhalt,
// non-destructive).
const SHARED_UPLOADS = "/shared/uploads";
const VERSIONING_INTERVAL_MS = 30 * 1000;
const { execFile } = require("child_process");

function git(args, opts = {}) {
  return new Promise((resolve, reject) => {
    const child = execFile(
      "git",
      ["-C", SHARED_UPLOADS, ...args],
      { maxBuffer: 20 * 1024 * 1024, ...opts },
      (err, stdout, stderr) => {
        if (err && !opts.allowFail) {
          err.stderr = stderr;
          return reject(err);
        }
        resolve({
          stdout: stdout || "",
          stderr: stderr || "",
          code: err ? (err.code || 1) : 0,
        });
      },
    );
    if (opts.input != null) {
      try { child.stdin.write(opts.input); } catch (_) {}
      try { child.stdin.end(); } catch (_) {}
    }
  });
}

async function initSharedVersioning() {
  try {
    fs.mkdirSync(SHARED_UPLOADS, { recursive: true });
  } catch (e) {
    console.error(`[shared-git] mkdir uploads fehlgeschlagen: ${e.message}`);
    return;
  }
  const gitDir = path.join(SHARED_UPLOADS, ".git");
  if (!fs.existsSync(gitDir)) {
    console.log("[shared-git] Initialisiere /shared/uploads als git-Repo");
    try {
      await git(["init", "-q", "-b", "main"]);
      await git(["config", "user.email", "aria@diagnostic"]);
      await git(["config", "user.name", "aria-diagnostic"]);
      // Initial commit (auch wenn leer) damit log/checkout immer funktioniert
      await git(["commit", "-q", "--allow-empty", "-m", "initial snapshot"]);
      // Falls schon Files drin sind: noch ein 'auto'-Commit hinten dran
      const status = await git(["status", "--porcelain"]);
      if (status.stdout.trim()) {
        await git(["add", "-A"]);
        await git(["commit", "-q", "-m", `auto: ${new Date().toISOString()}`]);
      }
      console.log("[shared-git] Init OK");
    } catch (e) {
      console.error(`[shared-git] Init fehlgeschlagen: ${e.message}`);
      return;
    }
  } else {
    console.log("[shared-git] Bestehendes git-Repo erkannt — uebernehme");
  }
  setInterval(autoCommitTick, VERSIONING_INTERVAL_MS);
  console.log(`[shared-git] Auto-Commit-Loop alle ${VERSIONING_INTERVAL_MS}ms aktiv`);
}

let autoCommitBusy = false;
async function autoCommitTick() {
  if (autoCommitBusy) return;  // re-entrancy guard fuer langsame git ops
  autoCommitBusy = true;
  try {
    const status = await git(["status", "--porcelain"]);
    if (!status.stdout.trim()) return;
    await git(["add", "-A"]);
    const ts = new Date().toISOString();
    await git(["commit", "-q", "-m", `auto: ${ts}`]);
    console.log(`[shared-git] auto-commit @ ${ts}`);
  } catch (e) {
    console.error(`[shared-git] auto-commit fehlgeschlagen: ${e.message}`);
  } finally {
    autoCommitBusy = false;
  }
}

// Versions-API helpers — werden weiter unten von den Routen genutzt.
function isPathSafe(rel) {
  if (!rel || typeof rel !== "string") return false;
  if (rel.includes("..") || rel.startsWith("/") || rel.startsWith(".git")) return false;
  return true;
}
async function listVersionsForFile(rel) {
  // git log --follow damit Renames trotzdem die Historie zeigen.
  // NUL-Separator damit Subjects mit Leerzeichen nicht falsch splitten.
  const out = await git(["log", "--follow", "--format=%H%x00%aI%x00%s", "--", rel]);
  const lines = out.stdout.trim().split("\n").filter(Boolean);
  const enriched = [];
  for (const line of lines) {
    const [hash, isoTs, subject] = line.split("\x00");
    if (!hash) continue;
    let blob = null;
    try {
      const ls = await git(["ls-tree", hash, "--", rel]);
      // Format: "100644 blob <40-hex>\t<path>"
      const m = ls.stdout.match(/blob ([0-9a-f]{40})/);
      if (m) blob = m[1];
    } catch (_) {
      continue;
    }
    if (!blob) continue;
    enriched.push({ hash, ts: Date.parse(isoTs) || 0, subject: subject || "", blob });
  }
  // Dedup auf Blob-Ebene — Restore-Commits sind inhaltlich gleich mit dem
  // restorten alten Commit. Zeige nur den AELTESTEN (= zuerst erschienenen)
  // Eintrag pro identischem Blob. Damit blaeht Restore die Liste nicht auf.
  const seen = new Set();
  const unique = [];
  for (let i = enriched.length - 1; i >= 0; i--) {
    const v = enriched[i];
    if (seen.has(v.blob)) continue;
    seen.add(v.blob);
    unique.push(v);
  }
  unique.reverse();  // wieder neueste-zuerst fuers UI
  // AKTIV-Marker: Commit dessen Blob == aktuelle Working-Copy. Nach Restore
  // wandert AKTIV auf den restorten alten Stand, nicht auf den gefilterten
  // Restore-Commit.
  let currentBlob = null;
  try {
    const abs = path.join(SHARED_UPLOADS, rel);
    if (fs.existsSync(abs)) {
      const r = await git(["hash-object", abs]);
      currentBlob = (r.stdout || "").trim();
    }
  } catch (_) {}
  for (const v of unique) {
    if (currentBlob && v.blob === currentBlob) v.isCurrent = true;
  }
  // Blob aus Response strippen — sieht im UI aus wie zweite Commit-ID, unnoetig.
  return unique.map(({ blob, ...rest }) => rest);
}
async function getVersionContent(rel, hash) {
  // git show <hash>:<path> liefert den Inhalt aus diesem Commit
  // Binary-safe via stdio buffer
  const out = await git(["show", `${hash}:${rel}`], { encoding: "buffer" });
  return out.stdout;  // Buffer
}
async function restoreVersion(rel, hash) {
  // Variante: non-destructive — wir holen den alten Inhalt und schreiben
  // ihn als NEUE Version drueber. Damit bleibt die aktuelle Version
  // ebenfalls in der git-History rollback-bar.
  const content = await getVersionContent(rel, hash);
  const abs = path.join(SHARED_UPLOADS, rel);
  fs.writeFileSync(abs, content);
  await git(["add", "--", rel]);
  await git(["commit", "-q", "-m", `restore: ${rel} <- ${hash.slice(0, 7)}`]);
  return true;
}

// Beim Startup einmalig aufrufen
initSharedVersioning().catch(e =>
  console.error(`[shared-git] initSharedVersioning crashed: ${e.message}`),
);

// ── Runtime-Config: /shared/config/runtime.json ─────────────
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
// Bridge und ggf. andere Komponenten lesen dieselbe Datei.
const RUNTIME_CONFIG_FILE = "/shared/config/runtime.json";
const RUNTIME_CONFIG_FIELDS = [
  "RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
  "ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
  "brainModel",
];
function readRuntimeConfig() {
  const envDefaults = {
    RVS_HOST, RVS_PORT, RVS_TLS, RVS_TOKEN,
    ARIA_AUTH_TOKEN: process.env.ARIA_AUTH_TOKEN || "",
    WHISPER_MODEL: process.env.WHISPER_MODEL || "medium",
    WHISPER_LANGUAGE: process.env.WHISPER_LANGUAGE || "de",
  };
  try {
    const raw = fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8");
    const parsed = JSON.parse(raw);
    return { ...envDefaults, ...parsed };
  } catch {
    return envDefaults;
  }
}
function writeRuntimeConfig(patch) {
  let current = {};
  try { current = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_FILE, "utf-8")); } catch {}
  for (const key of Object.keys(patch)) {
    if (RUNTIME_CONFIG_FIELDS.includes(key)) current[key] = patch[key];
  }
  fs.mkdirSync("/shared/config", { recursive: true });
  const tmp = RUNTIME_CONFIG_FILE + ".tmp";
  fs.writeFileSync(tmp, JSON.stringify(current, null, 2));
  fs.renameSync(tmp, RUNTIME_CONFIG_FILE);
}

// Atomic write: temp-file + rename, laute Logs bei Fehler.

// ── Local-LLM-Config: /shared/config/local_llm.json ─────────────────
// Der Router im Brain (router.py) liest diese Datei pro Request. Wir schreiben
// sie hier aus den Diagnostic-Schaltern. Default = alles aus (nur Claude).
const LOCAL_LLM_CONFIG_FILE = "/shared/config/local_llm.json";
function readLocalLlmConfig() {
  try {
    const p = JSON.parse(fs.readFileSync(LOCAL_LLM_CONFIG_FILE, "utf-8"));
    return {
      enabled: !!p.enabled,
      localOnly: !!p.localOnly,
      toolVariant: p.toolVariant === "full" ? "full" : "slim",
    };
  } catch {
    return { enabled: false, localOnly: false, toolVariant: "slim" };
  }
}
function writeLocalLlmConfig(patch) {
  const cur = readLocalLlmConfig();
  if (typeof patch.enabled === "boolean") cur.enabled = patch.enabled;
  if (typeof patch.localOnly === "boolean") cur.localOnly = patch.localOnly;
  if (patch.toolVariant === "slim" || patch.toolVariant === "full") cur.toolVariant = patch.toolVariant;
  fs.mkdirSync("/shared/config", { recursive: true });
  const tmp = LOCAL_LLM_CONFIG_FILE + ".tmp";
  fs.writeFileSync(tmp, JSON.stringify(cur, null, 2));
  fs.renameSync(tmp, LOCAL_LLM_CONFIG_FILE);
  return cur;
}

// ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt.
const FILE_PROJECTS_FILE = "/shared/config/file_projects.json";

function loadFileProjects() {
  try {
    if (!fs.existsSync(FILE_PROJECTS_FILE)) return {};
    const data = JSON.parse(fs.readFileSync(FILE_PROJECTS_FILE, "utf-8"));
    return (data && typeof data === "object") ? data : {};
  } catch {
    return {};
  }
}

function saveFileProjects(manifest) {
  try {
    fs.mkdirSync("/shared/config", { recursive: true });
    const tmp = FILE_PROJECTS_FILE + ".tmp";
    fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
    fs.renameSync(tmp, FILE_PROJECTS_FILE);
  } catch (err) {
    log("warn", "files", `file-projects-Manifest schreiben fehlgeschlagen: ${err.message}`);
  }
}

function persistActiveSession(key) {
  try {
    const tmp = SESSION_KEY_FILE + ".tmp";
    fs.writeFileSync(tmp, key);
    fs.renameSync(tmp, SESSION_KEY_FILE);
    sessionFromFile = true;
    console.log(`[session] Aktive Session persistiert: '${key}'`);
    return true;
  } catch (e) {
    console.error(`[session] FEHLER beim Persistieren von '${key}': ${e.message}`);
    return false;
  }
}
const logs = [];
let gatewayWs = null;
let rvsWs = null;
let reqIdCounter = 0;
const browserClients = new Set();

// ── Trace-Tracking (End-to-End-Mitschnitt einer Anfrage) ──────────────────────────────────
let traceActive = false;
let traceStartTime = 0;

// Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses
// Fensters unterdruecken wir agent_activity-Broadcasts, damit der
// Thinking-Indicator nicht wieder anspringt.
let lastChatFinalAt = 0;
const SETTLED_WINDOW_MS = 3000;

function plog(message, level) {
  const elapsed = traceActive ? `+${Date.now() - traceStartTime}ms` : "";
  const entry = { ts: new Date().toISOString(), level: level || "info", source: "trace", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` };
  logs.push(entry);
  if (logs.length > 500) logs.shift();
  console.log(`[TRACE] ${entry.message}`);
  broadcast({ type: "log", entry });
}

let traceTimeout = null;

function traceStart(method, text) {
  // Falls noch ein Trace laeuft, beenden
  if (traceActive) traceEnd(false, "Abgebrochen (neue Nachricht)");
  traceActive = true;
  traceStartTime = Date.now();
  if (traceTimeout) clearTimeout(traceTimeout);
  traceTimeout = setTimeout(() => {
    if (traceActive) traceEnd(false, "Timeout — keine Antwort nach 10min");
  }, 600000);
  plog(`━━━ Trace Start: ${method} ━━━`);
  plog(`Nachricht: "${text}"`);
}

function traceEnd(ok, detail) {
  if (!traceActive) return;
  if (traceTimeout) { clearTimeout(traceTimeout); traceTimeout = null; }
  const elapsed = Date.now() - traceStartTime;
  if (ok) {
    plog(`>>> Fertig (${elapsed}ms): ${detail}`);
  } else {
    plog(`>>> FEHLER (${elapsed}ms): ${detail}`, "error");
  }
  plog(`━━━ Trace Ende ━━━`);
  traceActive = false;
  // Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch
  broadcast({ type: "agent_activity", activity: "idle" });
  pendingMessageTime = 0;
}

// Auto-Restart-Heuristik entfernt (war an aria-core-Network gebunden).
// connectGateway ist ein No-Op solange der Brain-Loop noch nicht steht.
let gatewayFailCount = 0;
function checkGatewayHealth() { /* No-Op */ }

function nextReqId() {
  return `diag-${++reqIdCounter}`;
}

function log(level, source, message) {
  const entry = { ts: new Date().toISOString(), level, source, message };
  logs.push(entry);
  if (logs.length > 500) logs.shift();
  console.log(`[${entry.ts}] [${level}] [${source}] ${message}`);
  // An alle Browser-Clients senden
  broadcast({ type: "log", entry });
}

function broadcast(msg) {
  const data = JSON.stringify(msg);
  for (const client of browserClients) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  }
}

function broadcastState() {
  broadcast({ type: "state", state });
}

// ── OpenClaw Gateway Verbindung ─────────────────────────

async function connectGateway() {
  // aria-core/OpenClaw-Gateway abgerissen — diese Funktion ist ein No-Op,
  // bis der neue Brain-Loop angedockt ist. Wir setzen den Status nur einmal.
  state.gateway.status = "disabled";
  state.gateway.lastError = "aria-core entfernt — Brain-Loop in Arbeit";
  broadcastState();
  return;

  // Originaler Connect-Code unten ist toter Code, bleibt zur Referenz.
  // eslint-disable-next-line no-unreachable
  log("info", "gateway", `Verbinde: ${GATEWAY_URL}`);

  try {
    const ws = new WebSocket(GATEWAY_URL);

    await new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        ws.close();
        reject(new Error("Verbindungs-Timeout (10s)"));
      }, 10000);

      ws.on("open", () => {
        clearTimeout(timeout);
        resolve();
      });

      ws.on("error", (err) => {
        clearTimeout(timeout);
        reject(err);
      });
    });

    log("info", "gateway", "TCP-Verbindung hergestellt — warte auf Challenge");

    // Schritt 1: Auf Challenge warten
    const challengeRaw = await waitForMessage(ws, 10000);
    const challenge = JSON.parse(challengeRaw);

    if (challenge.type !== "event" || challenge.event !== "connect.challenge") {
      throw new Error(`Unerwartete erste Nachricht: ${challengeRaw.slice(0, 200)}`);
    }

    const nonce = challenge.payload?.nonce || "";
    log("info", "gateway", `Challenge empfangen (nonce: ${nonce.slice(0, 8)}...)`);

    // Schritt 2: Connect Request senden
    const connectReq = {
      type: "req",
      id: nextReqId(),
      method: "connect",
      params: {
        minProtocol: 3,
        maxProtocol: 3,
        client: {
          id: "cli",
          version: "0.0.1",
          platform: "linux",
          mode: "cli",
        },
        role: "operator",
        scopes: ["operator.read", "operator.write"],
        caps: [],
        commands: [],
        permissions: {},
        auth: GATEWAY_TOKEN ? { token: GATEWAY_TOKEN } : {},
        locale: "de-DE",
        userAgent: "aria-diagnostic/0.0.1",
      },
    };

    ws.send(JSON.stringify(connectReq));
    log("info", "gateway", "Connect-Request gesendet");

    // Schritt 3: hello-ok warten
    const responseRaw = await waitForMessage(ws, 10000);
    const response = JSON.parse(responseRaw);

    if (response.type === "res" && response.ok) {
      log("info", "gateway", "Handshake erfolgreich — hello-ok!");
      state.gateway.status = "connected";
      state.gateway.handshakeOk = true;
      state.gateway.lastError = null;
      gatewayFailCount = 0;
    } else {
      const error = typeof response.error === "string"
        ? response.error
        : JSON.stringify(response.error || response).slice(0, 300);
      throw new Error(`Handshake fehlgeschlagen: ${error}`);
    }

    gatewayWs = ws;
    broadcastState();

    // Nach (Re-)Connect: letzte aktive Session aus OpenClaw wiederherstellen
    resolveActiveSession().catch(err => {
      log("warn", "server", `Session-Aufloesung fehlgeschlagen: ${err.message}`);
    });

    // Nachrichten-Loop — RAW logging fuer Debugging
    ws.on("message", (raw) => {
      try {
        const rawStr = raw.toString();
        log("debug", "gateway", `RAW <<< ${rawStr.slice(0, 300)}`);
        const msg = JSON.parse(rawStr);
        handleGatewayMessage(msg);
      } catch (err) {
        log("error", "gateway", `Parse-Fehler: ${err.message}`);
      }
    });

    ws.on("close", (code, reason) => {
      log("warn", "gateway", `Verbindung geschlossen (Code: ${code}, Reason: ${reason || "-"})`);
      state.gateway.status = "disconnected";
      state.gateway.handshakeOk = false;
      gatewayWs = null;
      broadcastState();
      // Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Trace abkackt
      if (traceActive) traceEnd(false, `Gateway-Verbindung verloren (${code})`);
      else broadcast({ type: "agent_activity", activity: "idle" });
      checkGatewayHealth();
      setTimeout(connectGateway, 5000);
    });

    ws.on("error", (err) => {
      log("error", "gateway", `WebSocket-Fehler: ${err.message}`);
      state.gateway.lastError = err.message;
      broadcastState();
    });

  } catch (err) {
    log("error", "gateway", `Fehler: ${err.message}`);
    state.gateway.status = "error";
    state.gateway.lastError = err.message;
    state.gateway.handshakeOk = false;
    gatewayWs = null;
    broadcastState();
    checkGatewayHealth();
    // Retry nach 5s
    setTimeout(connectGateway, 5000);
  }
}

// Extrahiert Text aus OpenClaw chat-Event message.content Array
function extractChatText(payload) {
  try {
    const content = payload.message?.content;
    if (Array.isArray(content)) {
      return content
        .filter(c => c.type === "text")
        .map(c => c.text || "")
        .join("");
    }
    if (typeof payload.message === "string") return payload.message;
    return payload.text || "";
  } catch { return ""; }
}

// Deduplizierung: nur ein chat_final pro runId broadcasten
const seenFinalRuns = new Set();

function handleGatewayMessage(msg) {
  if (msg.type === "res") {
    const status = msg.ok ? "OK" : `FEHLER: ${JSON.stringify(msg.error).slice(0, 100)}`;
    log("info", "gateway", `Response [${msg.id}]: ${status}`);
    if (traceActive) {
      if (msg.ok) plog(`Gateway ACK [${msg.id}] — Nachricht angenommen`);
      else plog(`Gateway NACK [${msg.id}] — ${JSON.stringify(msg.error).slice(0, 100)}`, "error");
    }
    broadcast({ type: "response", msg });
    return;
  }

  if (msg.type === "event") {
    const event = msg.event || "?";
    const payload = msg.payload || {};

    // ── agent Events: Streaming-Deltas vom LLM ──
    if (event === "agent") {
      const data = payload.data || {};
      const delta = data.delta || "";
      const stream = payload.stream || "";

      if (delta && stream === "assistant") {
        broadcast({ type: "chat_delta", delta, payload });
      }

      // Nach chat:final trickeln noch Aufraeum-Events rein — unterdruecken,
      // damit der Thinking-Indicator nicht wieder anspringt.
      const settled = lastChatFinalAt && (Date.now() - lastChatFinalAt) < SETTLED_WINDOW_MS;

      // Tool-Nutzung erkennen und broadcasten
      if (stream === "tool_use" || data.type === "tool_use") {
        const toolName = data.name || data.tool || payload.tool || "";
        if (toolName && !settled) {
          broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data });
          log("info", "gateway", `Tool: ${toolName}`);
        }
      }

      if (!settled) {
        broadcast({ type: "agent_activity", activity: stream || "thinking" });
      }
      updateAgentActivity();
      return;
    }

    // ── chat Events: Snapshots mit state=delta|final ──
    if (event === "chat") {
      const state = payload.state || "";
      const text = extractChatText(payload);

      if (state === "final") {
        const runId = payload.runId || "";
        if (runId && seenFinalRuns.has(runId)) return; // Duplikat
        if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }

        // NO_REPLY → ARIA signalisiert "nicht antworten", Trace beenden aber nichts zeigen
        const trimmed = (text || "").trim().replace(/^["'`*.\s]+|["'`*.\s]+$/g, "").toUpperCase();
        if (trimmed === "NO_REPLY" || trimmed.startsWith("NO_REPLY")) {
          log("info", "gateway", "NO_REPLY empfangen — still verworfen");
          lastChatFinalAt = Date.now();
          if (traceActive) traceEnd(true, "NO_REPLY (stumm)");
          broadcast({ type: "agent_activity", activity: "idle" });
          pendingMessageTime = 0;
          updateAgentActivity();
          return;
        }

        log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
        lastChatFinalAt = Date.now();
        if (traceActive) traceEnd(true, `"${text.slice(0, 120)}"`);
        broadcast({ type: "chat_final", text, payload });
        broadcast({ type: "agent_activity", activity: "idle" });
        pendingMessageTime = 0; // Watchdog: Antwort erhalten
        updateAgentActivity();
        // KEIN chat_backup-Write mehr hier: die Bridge (_process_core_response)
        // ist der massgebliche Writer und schreibt den Assistant-Eintrag MIT
        // project_id. Dieser Gateway-Watch-Pfad kennt die project_id nicht —
        // ein Write hier erzeugte ein untagged Duplikat, das beim Reload im
        // Hauptchat auftaucht (statt im Projekt).
        return;
      }

      if (state === "delta") {
        // Periodischer Snapshot — nicht einzeln loggen
        return;
      }

      if (state === "error") {
        const error = payload.error || text || "Unbekannt";
        log("error", "gateway", `Chat-Fehler: ${error}`);
        if (traceActive) traceEnd(false, error);
        else broadcast({ type: "agent_activity", activity: "idle" });
        broadcast({ type: "chat_error", error, payload });
        return;
      }

      log("debug", "gateway", `chat state=${state}`);
      return;
    }

    // ── Legacy event names (chat:delta, chat:final, chat:error) ──
    if (event === "chat:delta") {
      const delta = payload.delta || payload.text || "";
      if (delta) broadcast({ type: "chat_delta", delta, payload });
      return;
    }
    if (event === "chat:final") {
      const runId = payload.runId || "";
      if (runId && seenFinalRuns.has(runId)) return; // Duplikat
      if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); }
      const text = extractChatText(payload) || payload.text || "";
      log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`);
      lastChatFinalAt = Date.now();
      if (traceActive) traceEnd(true, `"${text.slice(0, 120)}"`);
      else broadcast({ type: "agent_activity", activity: "idle" });
      broadcast({ type: "chat_final", text, payload });
      return;
    }
    if (event === "chat:error") {
      const error = payload.error || payload.message || "Unbekannt";
      log("error", "gateway", `Chat-Fehler: ${error}`);
      if (traceActive) traceEnd(false, error);
      else broadcast({ type: "agent_activity", activity: "idle" });
      broadcast({ type: "chat_error", error, payload });
      return;
    }

    // ── Andere Events (tick, health, presence) ──
    if (event === "tick" || event === "health") return; // Noise
    log("debug", "gateway", `Event: ${event}`);
  }
}

function sendToGateway(text, isTrace) {
  // OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
  // Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
  if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
    if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
    return false;
  }

  const reqId = nextReqId();
  const msg = {
    type: "req",
    id: reqId,
    method: "chat.send",
    params: {
      sessionKey: activeSessionKey,
      message: text,
      idempotencyKey: crypto.randomUUID(),
    },
  };

  const payload = JSON.stringify(msg);
  log("debug", "gateway", `RAW >>> ${payload}`);
  gatewayWs.send(payload);
  pendingMessageTime = Date.now(); // Watchdog: Nachricht gesendet
  // Nachricht sofort in Backup-Log schreiben (OpenClaw speichert erst nach Run-Ende)
  try {
    fs.mkdirSync("/shared/config", { recursive: true });
    const entry = JSON.stringify({ ts: Date.now(), role: "user", text, session: activeSessionKey }) + "\n";
    fs.appendFileSync("/shared/config/chat_backup.jsonl", entry);
  } catch {}
  log("info", "gateway", `chat.send [${reqId}]: "${text}"`);
  if (isTrace) plog(`chat.send [${reqId}] an Gateway gesendet — warte auf ACK...`);

  // Gateway-Nachrichten NICHT an RVS senden (sonst doppelter ARIA-Request via Bridge)
  return true;
}

// ── RVS Verbindung (optional) ───────────────────────────

function connectRVS(forcePlain) {
  if (!RVS_HOST || !RVS_TOKEN) {
    log("info", "rvs", "Nicht konfiguriert — ueberspringe");
    state.rvs.status = "not_configured";
    broadcastState();
    return;
  }

  // Alte Verbindung sauber schliessen
  if (rvsWs) {
    try { rvsWs.removeAllListeners(); rvsWs.close(); } catch (_) {}
    rvsWs = null;
  }

  // TLS-Logik: wss zuerst, bei Fehler Fallback auf ws
  const useTls = RVS_TLS === "true" && !forcePlain;
  const proto = useTls ? "wss" : "ws";
  const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;

  state.rvs.status = "connecting";
  broadcastState();
  log("info", "rvs", `Verbinde: ${proto}://${RVS_HOST}:${RVS_PORT}`);

  let ws;
  try {
    ws = new WebSocket(url);
  } catch (err) {
    log("error", "rvs", `WebSocket erstellen fehlgeschlagen: ${err.message}`);
    if (useTls && RVS_TLS_FALLBACK === "true") {
      connectRVS(true);
    }
    return;
  }

  let fallbackTriggered = false;

  ws.on("open", () => {
    log("info", "rvs", `Verbunden (${proto})`);
    state.rvs.status = "connected";
    state.rvs.lastError = null;
    rvsWs = ws;
    broadcastState();

    // Keepalive: alle 25s ein Ping senden damit die Verbindung nicht stirbt
    const keepalive = setInterval(() => {
      if (ws.readyState === WebSocket.OPEN) {
        try { ws.ping(); } catch (_) {}
      } else {
        clearInterval(keepalive);
      }
    }, 25000);
    ws._keepalive = keepalive;
  });

  ws.on("message", (raw) => {
    try {
      const msg = JSON.parse(raw.toString());
      if (msg.type === "chat" && msg.payload) {
        const sender = msg.payload.sender || "?";
        // Eigene Nachrichten ignorieren (Echo)
        if (sender === "diagnostic") return;
        log("info", "rvs", `Chat von ${sender}: "${(msg.payload.text || "").slice(0, 100)}"`);
        if (traceActive) {
          traceEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`);
        }
        broadcast({ type: "rvs_chat", msg });
      } else if (msg.type === "file_saved" && msg.payload) {
        // Bild/Datei-Upload von der App — im Chat anzeigen
        const name = msg.payload.name || "?";
        const serverPath = msg.payload.serverPath || "";
        const mimeType = msg.payload.mimeType || "";
        log("info", "rvs", `Datei empfangen: ${name} (${serverPath})`);
        // Als User-Nachricht mit Pfad broadcasten (Diagnostic zeigt Bilder inline)
        broadcast({ type: "rvs_chat", msg: {
          type: "chat",
          payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" }
        }});
      } else if (msg.type === "file_from_aria" && msg.payload) {
        // ARIA hat eine Datei fuer den User erstellt — im Chat als Anhang anzeigen
        const p = msg.payload;
        log("info", "rvs", `ARIA-Datei: ${p.name} (${p.mimeType}, ${(p.size||0)/1024|0}KB)`);
        broadcast({ type: "file_from_aria", payload: p });
      } else if (msg.type === "heartbeat") {
        // ignorieren
      } else if (msg.type === "mode") {
        // Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
        log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
        broadcast({ type: "mode", payload: msg.payload });
      } else if (msg.type === "agent_activity") {
        // Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
        // weiterreichen, damit der Thinking-Indikator im Chat erscheint.
        // Wenn gerade ein chat:final vorbei ist, unterdruecken wir trailing
        // 'thinking'-Events (gleiches Schema wie alter OpenClaw-Pfad).
        const activity = msg.payload?.activity || msg.activity || "idle";
        if (activity !== "idle" && Date.now() - lastChatFinalAt < SETTLED_WINDOW_MS) {
          // chat:final ist gerade durch — verstaubende thinking-Events ignorieren
        } else {
          broadcast({
            type: "agent_activity",
            activity,
            tool: msg.payload?.tool || msg.tool || "",
          });
        }
      } else if (msg.type === "agent_stream") {
        // Voller Live-Stream der Claude-Code-Session (assistant_text +
        // tool_use mit Input + tool_result mit truncated Output). Geht
        // 1:1 an Browser durch — die ARIA-Live-View rendert's.
        // Zusaetzlich persistieren damit Browser-Reload / Standby den
        // History-Verlauf nicht wegwirft.
        try { appendAgentStream(msg.payload); } catch {}
        broadcast({ type: "agent_stream", payload: msg.payload });
      } else if (msg.type === "memory_saved") {
        // ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
        const m = msg.payload || {};
        log("info", "rvs", `ARIA-Memory gespeichert: "${m.title}" (type=${m.type}, pinned=${m.pinned})`);
        broadcast({ type: "memory_saved", payload: m });
      } else if (msg.type === "chat_message_deleted") {
        // Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
        // An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
        const ts = msg.payload?.ts;
        log("info", "rvs", `chat_message_deleted ts=${ts}`);
        broadcast({ type: "chat_message_deleted", payload: msg.payload });
      } else if (msg.type === "voice_ready") {
        // XTTS-Bridge meldet Stimme fertig geladen → an Browser durchreichen
        const v = msg.payload?.voice || "";
        const err = msg.payload?.error;
        const ms = msg.payload?.loadMs;
        if (err) {
          log("warn", "rvs", `Voice-Ready Fehler fuer "${v}": ${err}`);
        } else {
          log("info", "rvs", `Voice "${v || "default"}" geladen${ms ? ` in ${(ms/1000).toFixed(1)}s` : ""}`);
        }
        broadcast({ type: "voice_ready", payload: msg.payload });
      } else if (msg.type === "service_status") {
        // Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status —
        // an Browser durchreichen fuer das Banner unten rechts
        const svc = msg.payload?.service || "?";
        const state = msg.payload?.state || "?";
        const model = msg.payload?.model || "";
        const sec = msg.payload?.loadSeconds;
        const err = msg.payload?.error;
        if (err) {
          log("warn", "rvs", `service_status ${svc}: ${err}`);
        } else if (state === "ready" && sec) {
          log("info", "rvs", `service_status ${svc} ready (${model}, ${sec.toFixed(1)}s)`);
        } else {
          log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
        }
        broadcast({ type: "service_status", payload: msg.payload });
      } else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
        // PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
        _handlePreviewChunk(msg.payload);
      } else {
        log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
      }
    } catch {}
  });

  ws.on("close", () => {
    log("warn", "rvs", "Verbindung geschlossen");
    if (ws._keepalive) clearInterval(ws._keepalive);
    state.rvs.status = "disconnected";
    if (rvsWs === ws) rvsWs = null;
    broadcastState();
    if (!fallbackTriggered) {
      setTimeout(() => connectRVS(), 5000);
    }
  });

  ws.on("error", (err) => {
    log("error", "rvs", `Fehler (${proto}): ${err.message}`);
    state.rvs.lastError = err.message;
    broadcastState();

    // TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
    // Bei Netz-Problemen wie EHOSTUNREACH, ECONNREFUSED, ENETUNREACH,
    // EAI_AGAIN ist der Server eh tot — Fallback bringt nichts ausser
    // Log-Spam und doppelten Retries.
    const netErr = (err.code || err.message || "").toString();
    const isNetDown =
      /^(EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/.test(netErr) ||
      /EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/.test(err.message || "");

    if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered && !isNetDown) {
      fallbackTriggered = true;
      log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
      try { ws.removeAllListeners(); ws.close(); } catch (_) {}
      if (rvsWs === ws) rvsWs = null;
      connectRVS(true);
    }
  });
}

function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
  if (!RVS_HOST || !RVS_TOKEN) return;
  const proto = RVS_TLS === "true" ? "wss" : "ws";
  const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
  const freshWs = new WebSocket(url);
  const timeout = setTimeout(() => {
    try { freshWs.close(); } catch (_) {}
    clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
  }, 15000);
  freshWs.on("open", () => {
    freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
  });
  freshWs.on("message", (raw) => {
    try {
      const resp = JSON.parse(raw.toString());
      if (resp.type === expectType) {
        clearTimeout(timeout);
        clientWs.send(JSON.stringify(resp));
        setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 1000);
      }
    } catch {}
  });
  freshWs.on("error", () => {});
}

function sendToRVS_raw(msgObj) {
  if (!RVS_HOST || !RVS_TOKEN) return;
  const payload = JSON.stringify(msgObj);
  // Persistente Connection bevorzugen — die ist garantiert connected
  // und wird vom RVS direkt an alle anderen Clients (App, Bridge) broadcastet.
  // Frische Connections hatten Race-Probleme: die WS war nach dem send manchmal
  // schon zu, bevor RVS broadcasten konnte → App-Nachrichten verloren.
  if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
    try {
      rvsWs.send(payload);
      return;
    } catch (err) {
      log("warn", "rvs", `persistente Verbindung send failed (${err.message}) — Fallback frische WS`);
    }
  }
  const proto = RVS_TLS === "true" ? "wss" : "ws";
  const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
  const freshWs = new WebSocket(url);
  freshWs.on("open", () => {
    freshWs.send(payload);
    setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000);
  });
  freshWs.on("error", () => {});
}

function sendToRVS(text, isTrace, projectId) {
  // Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
  // Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
  // den Text als User-Nachricht ans Brain weiterleitet und die App +
  // Diagnostic die Bubble live spiegeln koennen.
  //
  // projectId (Multi-Threading 06/2026): optional — leerer/undefined String
  // = Hauptchat, sonst project_id. Bridge liest payload.projectId und routet
  // an /chat body.project_id — Brain queued per Kontext.
  if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
    if (isTrace) traceEnd(false, "RVS nicht verbunden");
    return false;
  }
  sendToRVS_raw({
    type: "chat",
    payload: {
      text,
      sender: "diagnostic",
      projectId: projectId || "",
    },
    timestamp: Date.now(),
  });
  return true;
}

// ── Claude Proxy Test ────────────────────────────────────

async function testProxy(prompt) {
  state.proxy.status = "testing";
  state.proxy.lastError = null;
  broadcastState();
  log("info", "proxy", `Teste Proxy: ${PROXY_URL}`);

  try {
    // Schritt 1: Erreichbarkeit pruefen
    const healthUrl = `${PROXY_URL}/v1/models`;
    log("info", "proxy", `Rufe ab: ${healthUrl}`);

    const modelsRes = await fetch(healthUrl, {
      headers: { "Authorization": "Bearer not-needed" },
      signal: AbortSignal.timeout(30000),
    });

    if (!modelsRes.ok) {
      throw new Error(`Models-Endpoint: HTTP ${modelsRes.status} ${modelsRes.statusText}`);
    }

    const modelsData = await modelsRes.json();
    const models = (modelsData.data || []).map(m => m.id).filter(Boolean);
    log("info", "proxy", `Proxy erreichbar — ${models.length} Model(s) verfuegbar`);

    // Modellnamen loggen + OpenClaw-Config Hinweis
    if (models.length > 0) {
      log("info", "proxy", `Modelle: ${models.join(", ")}`);
      log("info", "proxy", `Fuer docker-compose.yml (DEFAULT_MODEL): ${models.map(m => m.replace("openai/", "")).join(" | ")}`);
    }

    // Schritt 1b: Auth-Dateien im Proxy-Container pruefen
    try {
      const authInfo = await dockerExec("aria-proxy", "echo '--- /root/.config/claude/ ---' && ls -la /root/.config/claude/ 2>&1 && echo '--- /root/.claude/ ---' && ls -la /root/.claude/ 2>&1 && echo '--- Credential-Dateien ---' && find /root/.config/claude /root/.claude -name '*.json' -o -name '*credential*' -o -name '*auth*' -o -name '*token*' 2>/dev/null | head -20");
      log("info", "proxy", `Auth-Dateien im Container:\n${authInfo}`);
      broadcast({ type: "proxy_auth", info: authInfo });
    } catch (authErr) {
      log("warn", "proxy", `Auth-Check fehlgeschlagen: ${authErr.message}`);
    }

    // Schritt 2: Chat Completion testen (kurzer Prompt)
    const testPrompt = prompt || "Antworte in einem Satz: Wer bist du und funktionierst du?";
    log("info", "proxy", `Sende Test-Prompt: "${testPrompt}"`);

    const chatRes = await fetch(`${PROXY_URL}/v1/chat/completions`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer not-needed",
      },
      body: JSON.stringify({
        model: "claude-sonnet-4-6",
        messages: [{ role: "user", content: testPrompt }],
        max_tokens: 200,
      }),
      signal: AbortSignal.timeout(120000), // 2min — Cold Start braucht Zeit
    });

    if (!chatRes.ok) {
      const errBody = await chatRes.text().catch(() => "");
      throw new Error(`Chat-Completion: HTTP ${chatRes.status} — ${errBody.slice(0, 300)}`);
    }

    const chatData = await chatRes.json();
    const reply = chatData.choices?.[0]?.message?.content || "(leer)";
    log("info", "proxy", `Antwort: "${reply.slice(0, 200)}"`);

    state.proxy.status = "connected";
    state.proxy.lastError = null;
    state.proxy.models = models;
    broadcastState();
    broadcast({ type: "proxy_result", ok: true, reply, models });

  } catch (err) {
    log("error", "proxy", `Fehler: ${err.message}`);
    state.proxy.status = "error";
    state.proxy.lastError = err.message;
    broadcastState();
    broadcast({ type: "proxy_result", ok: false, error: err.message });
  }
}

// ── Claude Login im Proxy-Container ─────────────────────



// ── Interaktives Terminal (xterm.js ↔ Docker Exec) ──────

const net = require("net");

function attachTerminal(clientWs, containerName, cmd) {
  const createBody = JSON.stringify({
    AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true,
    Cmd: Array.isArray(cmd) ? cmd : ["sh", "-c", cmd],
  });

  const createReq = http.request({
    socketPath: "/var/run/docker.sock",
    path: `/containers/${containerName}/exec`,
    method: "POST",
    headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) },
  }, (res) => {
    let data = "";
    res.on("data", (c) => data += c);
    res.on("end", () => {
      if (res.statusCode !== 201) {
        clientWs.send(JSON.stringify({ type: "term_error", error: `Exec create failed: HTTP ${res.statusCode}` }));
        return;
      }
      const execId = JSON.parse(data).Id;

      // Exec starten — raw TCP socket fuer bidirektionalen Stream
      // Docker Exec mit Tty macht ein HTTP Upgrade auf raw stream
      const startBody = JSON.stringify({ Detach: false, Tty: true });

      const sock = net.connect({ path: "/var/run/docker.sock" }, () => {
        // Raw HTTP Request senden (Upgrade-artig)
        const req = `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(startBody)}\r\n\r\n${startBody}`;
        sock.write(req);
      });

      let headersParsed = false;
      let headerBuf = "";

      sock.on("data", (chunk) => {
        if (!headersParsed) {
          // HTTP Response Header parsen
          headerBuf += chunk.toString("utf-8");
          const headerEnd = headerBuf.indexOf("\r\n\r\n");
          if (headerEnd === -1) return; // Noch nicht komplett

          const headers = headerBuf.slice(0, headerEnd);
          const remaining = chunk.slice(chunk.length - (headerBuf.length - headerEnd - 4));

          headersParsed = true;

          if (!headers.includes("200") && !headers.includes("101")) {
            clientWs.send(JSON.stringify({ type: "term_error", error: `Exec start failed: ${headers.split("\r\n")[0]}` }));
            sock.end();
            return;
          }

          log("info", "proxy", "Terminal-Session gestartet");
          clientWs.send(JSON.stringify({ type: "term_ready" }));
          clientWs._termSock = sock;

          // Verbleibende Daten nach dem Header
          if (remaining.length > 0) {
            clientWs.send(JSON.stringify({ type: "term_data", data: remaining.toString("base64") }));
          }
          return;
        }

        // Tty=true: Rohdaten direkt durchreichen
        if (clientWs.readyState === WebSocket.OPEN) {
          clientWs.send(JSON.stringify({ type: "term_data", data: chunk.toString("base64") }));
        }
      });

      sock.on("end", () => {
        log("info", "proxy", "Terminal-Session beendet");
        if (clientWs.readyState === WebSocket.OPEN) {
          clientWs.send(JSON.stringify({ type: "term_exit" }));
        }
        clientWs._termSock = null;
        setTimeout(() => checkProxyAuth(), 1000);
      });

      sock.on("error", (err) => {
        log("error", "proxy", `Terminal-Socket-Fehler: ${err.message}`);
        if (clientWs.readyState === WebSocket.OPEN) {
          clientWs.send(JSON.stringify({ type: "term_error", error: err.message }));
        }
      });

      // Wenn Browser-Client disconnected, Socket schliessen
      clientWs.on("close", () => {
        if (sock && !sock.destroyed) sock.end();
      });
    });
  });
  createReq.on("error", (err) => {
    clientWs.send(JSON.stringify({ type: "term_error", error: err.message }));
  });
  createReq.write(createBody);
  createReq.end();
}

function handleTermInput(clientWs, data) {
  if (clientWs._termSock && !clientWs._termSock.destroyed) {
    clientWs._termSock.write(Buffer.from(data, "base64"));
  }
}

// Credentials manuell einfuegen (von einem Rechner wo Claude eingeloggt ist)
async function writeProxyCredentials(credentialsJson) {
  try {
    // Validieren
    const parsed = JSON.parse(credentialsJson);
    if (!parsed.claudeAiOauth && !parsed.oauth) {
      throw new Error("Ungueltig: Kein OAuth-Objekt gefunden. Erwartet 'claudeAiOauth' oder 'oauth' Key.");
    }

    log("info", "proxy", "Schreibe Credentials in Proxy-Container...");

    // Escaped fuer Shell — Einfache Anfuehrungszeichen im JSON escapen
    const escaped = credentialsJson.replace(/'/g, "'\\''");
    // In beide moegliche Speicherorte schreiben
    await dockerExec("aria-proxy", `mkdir -p /root/.config/claude && echo '${escaped}' > /root/.config/claude/.credentials.json && mkdir -p /root/.claude && echo '${escaped}' > /root/.claude/credentials.json`);

    log("info", "proxy", "Credentials geschrieben!");
    broadcast({ type: "login_status", status: "done" });
    broadcast({ type: "login_output", text: "Credentials erfolgreich geschrieben! Proxy muss neu gestartet werden." });

    // Auth pruefen
    setTimeout(() => checkProxyAuth(), 500);
  } catch (err) {
    log("error", "proxy", `Credentials schreiben fehlgeschlagen: ${err.message}`);
    broadcast({ type: "login_status", status: "error", error: err.message });
  }
}

async function checkProxyAuth() {
  try {
    log("info", "proxy", "Pruefe Auth-Dateien im Proxy-Container...");
    // Breit suchen: Claude Code speichert Credentials je nach Version an verschiedenen Orten
    const authInfo = await dockerExec("aria-proxy", `
      echo '=== /root/.config/claude/ ===' &&
      ls -la /root/.config/claude/ 2>&1 &&
      echo '' &&
      echo '=== /root/.claude/ ===' &&
      ls -la /root/.claude/ 2>&1 &&
      echo '' &&
      echo '=== /root/.claude/auth/ ===' &&
      ls -la /root/.claude/auth/ 2>&1 &&
      echo '' &&
      echo '=== Credentials-Dateien (rekursiv) ===' &&
      find /root/.config/claude /root/.claude -name '*.json' -o -name '*credential*' -o -name '*auth*' -o -name '*token*' -o -name '*oauth*' -o -name '*session*' 2>/dev/null | head -20 &&
      echo '' &&
      echo '=== .credentials.json ===' &&
      cat /root/.config/claude/.credentials.json 2>/dev/null || echo '(nicht in .config/claude/)' &&
      echo '' &&
      echo '=== /root/.claude/credentials.json ===' &&
      cat /root/.claude/credentials.json 2>/dev/null || echo '(nicht in .claude/)' &&
      echo '' &&
      echo '=== /root/.claude/auth/*.json ===' &&
      cat /root/.claude/auth/*.json 2>/dev/null || echo '(keine auth/*.json)'
    `.trim());
    log("info", "proxy", `Auth-Dateien:\n${authInfo}`);
    broadcast({ type: "proxy_auth", info: authInfo });
  } catch (err) {
    log("error", "proxy", `Auth-Check fehlgeschlagen: ${err.message}`);
    broadcast({ type: "proxy_auth", info: null, error: err.message });
  }
}

// ── OpenClaw Agent-Auth pruefen ──────────────────────────

async function checkCoreAuth() {
  try {
    log("info", "gateway", "Pruefe OpenClaw Agent-Konfiguration...");
    const info = await dockerExec("aria-core", `
      echo '=== Agent-Verzeichnis ===' &&
      ls -la /home/node/.openclaw/agents/main/agent/ 2>&1 &&
      echo '' &&
      echo '=== auth-profiles.json ===' &&
      cat /home/node/.openclaw/agents/main/agent/auth-profiles.json 2>/dev/null || echo '(nicht vorhanden)' &&
      echo '' &&
      echo '=== Umgebungsvariablen ===' &&
      echo "OPENAI_BASE_URL=$OPENAI_BASE_URL" &&
      echo "OPENAI_API_KEY=$(echo $OPENAI_API_KEY | head -c 15)..." &&
      echo "DEFAULT_MODEL=$DEFAULT_MODEL" &&
      echo '' &&
      echo '=== OpenClaw Version ===' &&
      openclaw --version 2>/dev/null || echo '(openclaw CLI nicht gefunden)' &&
      echo '' &&
      echo '=== Agents Liste ===' &&
      openclaw agents list 2>/dev/null || echo '(Befehl fehlgeschlagen)'
    `.trim());
    log("info", "gateway", `OpenClaw Config:\n${info}`);
    broadcast({ type: "core_auth", info });
  } catch (err) {
    log("error", "gateway", `Core-Auth-Check fehlgeschlagen: ${err.message}`);
    broadcast({ type: "core_auth", info: null, error: err.message });
  }
}

// ── Docker Container Logs ────────────────────────────────

const CONTAINER_MAP = {
  gateway: "aria-core",
  proxy: "aria-proxy",
  bridge: "aria-bridge",
};

function fetchDockerLogs(tab, tail) {
  const containerName = CONTAINER_MAP[tab];
  if (!containerName) return;

  const lines = parseInt(tail, 10) || 100;
  const dockerPath = `/containers/${containerName}/logs?stdout=true&stderr=true&tail=${lines}&timestamps=true`;

  return new Promise((resolve, reject) => {
    const req = http.request(
      { socketPath: "/var/run/docker.sock", path: dockerPath, method: "GET" },
      (res) => {
        const chunks = [];
        res.on("data", (chunk) => chunks.push(chunk));
        res.on("end", () => {
          // Docker log stream hat 8-byte Header pro Frame (stream type + size)
          const raw = Buffer.concat(chunks);
          const logLines = [];
          let offset = 0;
          while (offset < raw.length) {
            if (offset + 8 > raw.length) break;
            const size = raw.readUInt32BE(offset + 4);
            if (offset + 8 + size > raw.length) break;
            const line = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd();
            if (line) logLines.push(line);
            offset += 8 + size;
          }
          resolve(logLines);
        });
      }
    );
    req.on("error", (err) => reject(err));
    req.end();
  });
}

async function handleDockerLogs(ws, tab, tail) {
  const containerName = CONTAINER_MAP[tab];
  if (!containerName) {
    ws.send(JSON.stringify({ type: "docker_logs", tab, error: `Unbekannter Tab: ${tab}` }));
    return;
  }

  try {
    log("info", "server", `Lade Docker-Logs: ${containerName} (tail ${tail || 100})`);
    const lines = await fetchDockerLogs(tab, tail);
    ws.send(JSON.stringify({ type: "docker_logs", tab, container: containerName, lines }));
  } catch (err) {
    log("error", "server", `Docker-Logs Fehler (${containerName}): ${err.message}`);
    ws.send(JSON.stringify({ type: "docker_logs", tab, error: err.message }));
  }
}

// ── Docker Exec (Befehl in Container ausfuehren) ────────

function dockerContainerStop(name, timeoutSec = 10) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      socketPath: "/var/run/docker.sock",
      path: `/containers/${name}/stop?t=${timeoutSec}`,
      method: "POST",
      headers: { "Content-Length": 0 },
      timeout: (timeoutSec + 5) * 1000,
    }, (dRes) => {
      // 204 = OK, 304 = already stopped, 404 = nicht da
      if (dRes.statusCode === 204 || dRes.statusCode === 304) resolve();
      else if (dRes.statusCode === 404) resolve();  // Container fehlt: nicht fatal
      else reject(new Error(`docker stop ${name}: HTTP ${dRes.statusCode}`));
      dRes.resume();
    });
    req.on("error", reject);
    req.end();
  });
}

function dockerContainerStart(name) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      socketPath: "/var/run/docker.sock",
      path: `/containers/${name}/start`,
      method: "POST",
      headers: { "Content-Length": 0 },
      timeout: 30000,
    }, (dRes) => {
      if (dRes.statusCode === 204 || dRes.statusCode === 304) resolve();
      else reject(new Error(`docker start ${name}: HTTP ${dRes.statusCode}`));
      dRes.resume();
    });
    req.on("error", reject);
    req.end();
  });
}

function dockerExec(containerName, cmd) {
  return new Promise((resolve, reject) => {
    const createBody = JSON.stringify({
      AttachStdout: true, AttachStderr: true,
      Cmd: Array.isArray(cmd) ? cmd : ["sh", "-c", cmd],
    });

    const createReq = http.request({
      socketPath: "/var/run/docker.sock",
      path: `/containers/${containerName}/exec`,
      method: "POST",
      headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) },
    }, (res) => {
      let data = "";
      res.on("data", (c) => data += c);
      res.on("end", () => {
        if (res.statusCode !== 201) return reject(new Error(`Exec create: HTTP ${res.statusCode} — ${data.slice(0, 200)}`));
        const execId = JSON.parse(data).Id;

        // Exec starten
        const startBody = JSON.stringify({ Detach: false });
        const startReq = http.request({
          socketPath: "/var/run/docker.sock",
          path: `/exec/${execId}/start`,
          method: "POST",
          headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(startBody) },
        }, (sRes) => {
          const chunks = [];
          sRes.on("data", (c) => chunks.push(c));
          sRes.on("end", () => {
            // Docker multiplexed stream: 8-byte header pro Frame
            const raw = Buffer.concat(chunks);
            const lines = [];
            let offset = 0;
            while (offset < raw.length) {
              if (offset + 8 > raw.length) { lines.push(raw.slice(offset).toString("utf-8").trim()); break; }
              const size = raw.readUInt32BE(offset + 4);
              if (size === 0 || offset + 8 + size > raw.length) { lines.push(raw.slice(offset).toString("utf-8").trim()); break; }
              const line = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd();
              if (line) lines.push(line);
              offset += 8 + size;
            }
            resolve(lines.join("\n"));
          });
        });
        startReq.on("error", reject);
        startReq.write(startBody);
        startReq.end();
      });
    });
    createReq.on("error", reject);
    createReq.write(createBody);
    createReq.end();
  });
}

// ── Hilfsfunktionen ─────────────────────────────────────

function waitForMessage(ws, timeoutMs) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error(`Timeout (${timeoutMs}ms)`));
    }, timeoutMs);

    ws.once("message", (data) => {
      clearTimeout(timeout);
      resolve(data.toString());
    });
  });
}

// ── Watchdog entfernt — pendingMessageTime bleibt fuer /api/cancel
let pendingMessageTime = 0;
function updateAgentActivity() { /* No-Op nach Watchdog-Ausbau */ }

// ── Disk-Space Monitor ───────────────────────────────
// Prueft regelmaessig die Host-Disk (via gemountetem /shared) und
// broadcastet bei kritischen Schwellwerten ein disk_status Event.
let lastDiskStatus = null;
let currentDiskStatus = null; // Vollstaendig fuer neu verbundene Clients
function checkDiskSpace() {
  const { exec } = require("child_process");
  exec("df -B1 /shared", (err, stdout) => {
    if (err) return;
    const lines = stdout.trim().split("\n");
    if (lines.length < 2) return;
    const cols = lines[1].split(/\s+/);
    // Filesystem  Size  Used  Avail  Use%  MountedOn
    const total = parseInt(cols[1], 10);
    const used = parseInt(cols[2], 10);
    const avail = parseInt(cols[3], 10);
    if (!total) return;
    const pct = Math.round((used / total) * 100);
    let level = "ok";
    if (pct >= 95) level = "critical";
    else if (pct >= 85) level = "warn";
    else if (pct >= 70) level = "info";
    const status = {
      type: "disk_status",
      level,
      percent: pct,
      usedBytes: used,
      totalBytes: total,
      availBytes: avail,
    };
    currentDiskStatus = status;
    // Nur broadcasten wenn sich was geaendert hat (oder alle 60s Refresh)
    const key = `${level}-${pct}`;
    if (lastDiskStatus !== key) {
      lastDiskStatus = key;
      broadcast(status);
      if (level !== "ok") {
        log(level === "critical" ? "error" : "warn", "server",
          `Disk ${pct}% belegt (${(used/1024/1024/1024).toFixed(1)}GB von ${(total/1024/1024/1024).toFixed(1)}GB)`);
      }
    }
  });
}
// Beim Start + alle 30s
setTimeout(checkDiskSpace, 2000);
setInterval(checkDiskSpace, 30000);

// Watchdog entfernt — war auf aria-core/OpenClaw zugeschnitten (doctor --fix,
// docker restart aria-core). Der Brain-Loop bekommt seinen eigenen Health-Check.

// ── HTTP Server + WebSocket fuer Browser ────────────────

const htmlPath = path.join(__dirname, "index.html");

const server = http.createServer((req, res) => {
  if (req.url === "/" || req.url === "/index.html") {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.end(fs.readFileSync(htmlPath, "utf-8"));
  } else if (req.url === "/api/state") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
  } else if (req.url === "/api/session") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ sessionKey: activeSessionKey }));
  } else if (req.url === "/api/runtime-config" && req.method === "GET") {
    // Zentrale Runtime-Config (ENV + Override aus /shared/config/runtime.json)
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(readRuntimeConfig()));
  } else if (req.url === "/api/runtime-config" && req.method === "POST") {
    let body = "";
    req.on("data", chunk => { body += chunk; if (body.length > 32768) req.destroy(); });
    req.on("end", () => {
      try {
        const patch = JSON.parse(body);
        writeRuntimeConfig(patch);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, config: readRuntimeConfig() }));
        log("info", "server", `Runtime-Config aktualisiert: ${Object.keys(patch).join(", ")}`);
      } catch (err) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if (req.url === "/api/local-llm-config" && req.method === "GET") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(readLocalLlmConfig()));
  } else if (req.url === "/api/local-llm-config" && req.method === "POST") {
    let body = "";
    req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
    req.on("end", () => {
      try {
        const cfg = writeLocalLlmConfig(JSON.parse(body));
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, config: cfg }));
        log("info", "server", `Local-LLM-Config: enabled=${cfg.enabled} localOnly=${cfg.localOnly} tools=${cfg.toolVariant}`);
      } catch (err) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if (req.url === "/api/onboarding") {
    // RVS-Credentials fuer QR-Code App-Onboarding
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({
      rvsHost: RVS_HOST,
      rvsPort: RVS_PORT,
      rvsTLS: RVS_TLS === "true" || RVS_TLS === true,
      rvsToken: RVS_TOKEN,
    }));
  } else if (req.url === "/api/cancel" && req.method === "POST") {
    log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)");
    pendingMessageTime = 0;
    if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen (App)");
    else broadcast({ type: "agent_activity", activity: "idle" });
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ ok: true }));
  } else if (req.url.startsWith("/api/app-log") && req.method === "GET") {
    // App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS,
    // Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die
    // letzten 200 Eintraege (oder ?limit=N).
    const url = new URL(req.url, "http://x");
    const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200));
    try {
      const file = "/shared/logs/app.log";
      if (!fs.existsSync(file)) {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ count: 0, entries: [] }));
        return;
      }
      const raw = fs.readFileSync(file, "utf-8");
      const lines = raw.split("\n").filter(l => l.trim());
      const tail = lines.slice(-limit);
      const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ count: entries.length, entries }));
    } catch (err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: err.message }));
    }
    return;
  } else if (req.url === "/api/app-log/clear" && req.method === "POST") {
    // App-Log leeren — nach erfolgreichem Debug.
    try {
      const file = "/shared/logs/app.log";
      if (fs.existsSync(file)) fs.unlinkSync(file);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    } catch (err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: err.message }));
    }
    return;
  } else if (req.url === "/api/models-list" && req.method === "GET") {
    // Kuratierte Model-Liste vom Proxy (/v1/models) — Tier-Auswahl fuers
    // Sprachmodell-Dropdown. ARIA laeuft ueber das Max-Abo/CLI, waehlbar ist
    // der Tier (opus/sonnet/haiku), keine feste Version.
    (async () => {
      try {
        const r = await fetch(`${PROXY_URL}/v1/models`);
        const d = await r.json();
        const models = (d.data || []).map(m => ({
          id: m.id,
          tier: m.tier || m.id,
          displayName: m.display_name || m.id,
          description: m.description || "",
        }));
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, models }));
      } catch (err) {
        res.writeHead(502, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
      }
    })();
    return;
  } else if (req.url === "/api/files-list" && req.method === "GET") {
    // Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
    // (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
    try {
      const dir = "/shared/uploads";
      let entries = [];
      try { entries = fs.readdirSync(dir); } catch { entries = []; }
      const manifest = loadFileProjects();
      const files = entries
        .map(name => {
          try {
            const full = path.join(dir, name);
            const st = fs.statSync(full);
            if (!st.isFile()) return null;
            return {
              name,
              path: full,
              size: st.size,
              mtime: Math.floor(st.mtimeMs),
              fromAria: name.startsWith("aria_"),
              projectId: manifest[full] || '',
            };
          } catch { return null; }
        })
        .filter(Boolean)
        .sort((a, b) => b.mtime - a.mtime);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true, files }));
    } catch (err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: err.message }));
    }
    return;
  } else if (req.url === "/api/files-set-project" && req.method === "POST") {
    // Body: { path, projectId } — projectId leer = Hauptchat (= Eintrag entfernen)
    let body = "";
    req.on("data", c => { body += c; if (body.length > 8192) req.destroy(); });
    req.on("end", () => {
      try {
        const data = JSON.parse(body || "{}");
        const fpath = String(data.path || "");
        const pid = String(data.projectId || "");
        if (!fpath.startsWith("/shared/uploads/") || !fs.existsSync(fpath)) {
          res.writeHead(404, { "Content-Type": "application/json" });
          return res.end(JSON.stringify({ ok: false, error: "Datei nicht gefunden" }));
        }
        const manifest = loadFileProjects();
        if (pid) manifest[fpath] = pid;
        else delete manifest[fpath];
        saveFileProjects(manifest);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, path: fpath, projectId: pid }));
      } catch (err) {
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if ((req.url.startsWith("/api/files-download?") || req.url.startsWith("/api/files-view?")) && req.method === "GET") {
    // /api/files-download → mit Content-Disposition:attachment (Browser downloaded)
    // /api/files-view     → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)
    const isInline = req.url.startsWith("/api/files-view?");
    const u = new URL("http://x" + req.url);
    const p = u.searchParams.get("path") || "";
    const safe = path.resolve(p);
    if (!safe.startsWith("/shared/uploads/") || !fs.existsSync(safe)) {
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: "Datei nicht gefunden" }));
      return;
    }
    const stat = fs.statSync(safe);
    const fname = path.basename(safe);
    // Beim View-Modus echten MIME-Type setzen damit Browser inline rendert.
    // Bei Download-Modus weiter octet-stream + attachment-Disposition.
    const ext = path.extname(fname).toLowerCase();
    const mimeMap = {
      ".pdf": "application/pdf",
      ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
      ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
      ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
      ".mp4": "video/mp4", ".webm": "video/webm",
      ".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8",
      ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
      ".json": "application/json; charset=utf-8", ".csv": "text/csv; charset=utf-8",
      ".zip": "application/zip",
    };
    const mime = isInline ? (mimeMap[ext] || "application/octet-stream")
                          : "application/octet-stream";
    res.writeHead(200, {
      "Content-Type": mime,
      "Content-Length": stat.size,
      "Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`,
    });
    fs.createReadStream(safe).pipe(res);
    return;
  } else if (req.url === "/api/files-download-zip" && req.method === "POST") {
    // Multi-Datei-Download als ZIP. Body: {paths: ["/shared/uploads/...", ...]}.
    // Streamt zip stdout direkt in die Response.
    let body = "";
    req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
    req.on("end", () => {
      let paths = [];
      try { paths = (JSON.parse(body || "{}").paths || []); } catch { paths = []; }
      // Whitelist: nur /shared/uploads/, existieren muessen sie
      paths = paths
        .map(p => path.resolve(String(p)))
        .filter(p => p.startsWith("/shared/uploads/") && fs.existsSync(p));
      if (!paths.length) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: "Keine gueltigen Pfade" }));
        return;
      }
      const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
      const fname = `aria-files-${ts}.zip`;
      res.writeHead(200, {
        "Content-Type": "application/zip",
        "Content-Disposition": `attachment; filename="${fname}"`,
      });
      // zip -j: junk paths (Dateien ohne Verzeichnisstruktur ablegen)
      const { spawn } = require("child_process");
      const zip = spawn("zip", ["-j", "-q", "-", ...paths]);
      zip.stdout.pipe(res);
      let stderr = "";
      zip.stderr.on("data", d => stderr += d.toString());
      zip.on("close", code => {
        if (code !== 0 && code !== 12) {
          log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
        }
      });
      // SIGTERM an zip nur wenn der Client wirklich disconnected
      // (res.close vor res.end). req.on("close") feuert auch wenn
      // der Request-Body durch ist — das wuerde zip vorzeitig killen.
      res.on("close", () => {
        if (!res.writableEnded && !zip.killed) zip.kill("SIGTERM");
      });
    });
    return;
  } else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
    let body = "";
    req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
    req.on("end", () => {
      try {
        let paths = (JSON.parse(body || "{}").paths || []);
        paths = paths
          .map(p => path.resolve(String(p)))
          .filter(p => p.startsWith("/shared/uploads/"));
        const deleted = [];
        const errors = [];
        for (const p of paths) {
          try {
            if (fs.existsSync(p)) fs.unlinkSync(p);
            deleted.push(p);
            broadcast({ type: "file_deleted", path: p });
            sendToRVS_raw({ type: "file_deleted", payload: { path: p }, timestamp: Date.now() });
            try {
              fs.appendFileSync("/shared/config/chat_backup.jsonl",
                JSON.stringify({ type: "file_deleted", path: p, ts: Date.now(), by: "user" }) + "\n");
            } catch {}
          } catch (e) {
            errors.push({ path: p, error: e.message });
          }
        }
        log("info", "server", `Bulk-Delete: ${deleted.length} OK, ${errors.length} Fehler`);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, deleted, errors }));
      } catch (err) {
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if (req.url === "/api/files-delete" && req.method === "POST") {
    let body = "";
    req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
    req.on("end", () => {
      try {
        const { path: p } = JSON.parse(body || "{}");
        const safe = path.resolve(p || "");
        if (!safe.startsWith("/shared/uploads/")) {
          res.writeHead(400, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: false, error: "Pfad nicht erlaubt" }));
          return;
        }
        if (!fs.existsSync(safe)) {
          res.writeHead(404, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: false, error: "Datei nicht vorhanden" }));
          return;
        }
        fs.unlinkSync(safe);
        log("info", "server", `Datei geloescht: ${safe}`);
        // Live-Event an alle Browser-Clients
        broadcast({ type: "file_deleted", path: safe });
        // Auch an die Bridge weiterleiten, damit die App-Bubbles aktualisiert
        sendToRVS_raw({
          type: "file_deleted",
          payload: { path: safe },
          timestamp: Date.now(),
        });
        // Im chat_backup.jsonl markieren — beim Reload sehen Clients dass weg
        try {
          const backupFile = "/shared/config/chat_backup.jsonl";
          const line = JSON.stringify({
            type: "file_deleted",
            path: safe,
            ts: Date.now(),
            by: "user",
          }) + "\n";
          fs.appendFileSync(backupFile, line);
        } catch {}
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true }));
      } catch (err) {
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if (req.url.startsWith("/api/files-versions?") && req.method === "GET") {
    // Liste der git-Versionen einer Datei. Query: ?path=<rel-to-uploads>
    const u = new URL("http://x" + req.url);
    const rel = u.searchParams.get("path") || "";
    if (!isPathSafe(rel)) {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad" }));
      return;
    }
    listVersionsForFile(rel)
      .then(versions => {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, path: rel, versions }));
      })
      .catch(err => {
        log("warn", "server", `files-versions failed: ${err.message}`);
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      });
    return;
  } else if (req.url.startsWith("/api/files-version-content?") && req.method === "GET") {
    // Inhalt einer alten Version downloaden. Query: ?path=...&hash=<sha>
    const u = new URL("http://x" + req.url);
    const rel = u.searchParams.get("path") || "";
    const hash = u.searchParams.get("hash") || "";
    if (!isPathSafe(rel) || !/^[0-9a-f]{7,40}$/i.test(hash)) {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
      return;
    }
    getVersionContent(rel, hash)
      .then(content => {
        const base = path.basename(rel);
        const stem = base.replace(/(\.[^.]+)?$/, "");
        const ext = path.extname(base);
        const shortHash = hash.slice(0, 7);
        const downloadName = `${stem}@${shortHash}${ext}`;
        res.writeHead(200, {
          "Content-Type": "application/octet-stream",
          "Content-Disposition": `attachment; filename="${downloadName}"`,
          "Content-Length": content.length,
        });
        res.end(content);
      })
      .catch(err => {
        log("warn", "server", `files-version-content failed: ${err.message}`);
        res.writeHead(404, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      });
    return;
  } else if (req.url === "/api/files-version-restore" && req.method === "POST") {
    // Eine alte Version als neue aktive Version setzen — non-destructive,
    // erzeugt einen neuen "restore:"-Commit. Body: {path, hash}
    let body = "";
    req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
    req.on("end", () => {
      let p, h;
      try {
        const parsed = JSON.parse(body || "{}");
        p = String(parsed.path || "");
        h = String(parsed.hash || "");
      } catch (e) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: "bad json" }));
        return;
      }
      if (!isPathSafe(p) || !/^[0-9a-f]{7,40}$/i.test(h)) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
        return;
      }
      restoreVersion(p, h)
        .then(() => {
          log("info", "server", `Version restored: ${p} <- ${h.slice(0,7)}`);
          // Datei hat sich geaendert — Browser-Listen invalidieren
          broadcast({ type: "file_version_restored", path: p, hash: h });
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: true, path: p, hash: h }));
        })
        .catch(err => {
          log("warn", "server", `restore failed: ${err.message}`);
          res.writeHead(500, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: false, error: err.message }));
        });
    });
    return;
  } else if (req.url === "/api/voice-config-export" && req.method === "GET") {
    // voice_config.json + highlight_triggers.json als JSON-Bundle exportieren
    try {
      const bundle = {};
      try { bundle.voice_config = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {}
      try { bundle.highlight_triggers = JSON.parse(fs.readFileSync("/shared/config/highlight_triggers.json", "utf-8")); } catch {}
      const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
      res.writeHead(200, {
        "Content-Type": "application/json",
        "Content-Disposition": `attachment; filename="aria-voice-settings-${ts}.json"`,
      });
      res.end(JSON.stringify(bundle, null, 2));
    } catch (err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: err.message }));
    }
    return;
  } else if (req.url === "/api/voice-config-import" && req.method === "POST") {
    let body = "";
    req.on("data", c => { body += c; if (body.length > 1024 * 1024) req.destroy(); });
    req.on("end", () => {
      try {
        const bundle = JSON.parse(body || "{}");
        fs.mkdirSync("/shared/config", { recursive: true });
        if (bundle.voice_config && typeof bundle.voice_config === "object") {
          fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(bundle.voice_config, null, 2));
        }
        if (bundle.highlight_triggers && typeof bundle.highlight_triggers === "object") {
          fs.writeFileSync("/shared/config/highlight_triggers.json", JSON.stringify(bundle.highlight_triggers, null, 2));
        }
        log("info", "server", "Voice-Settings importiert");
        // Bridge bekommt die neue Config via RVS (bei naechstem Reload), aber
        // ein Restart ist sauberer damit Whisper/F5 sofort neu laden.
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, message: "Voice-Settings importiert — Bridge-Restart empfohlen." }));
      } catch (err) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if (req.url === "/api/chat-history-clear" && req.method === "POST") {
    // Leert die Diagnostic-Anzeige-History (chat_backup.jsonl) UND broadcastet
    // chat_cleared an alle RVS-Clients (App leert lokal). Brain's
    // Rolling-Window (conversation.jsonl) ist davon unabhaengig — Caller
    // sollte zusaetzlich /api/brain/conversation/reset triggern.
    log("warn", "server", "HTTP /api/chat-history-clear");
    try {
      const file = "/shared/config/chat_backup.jsonl";
      if (fs.existsSync(file)) fs.unlinkSync(file);
      // Browser-Clients: leere chat_history
      broadcast({ type: "chat_history", messages: [] });
      // App via RVS: chat_cleared
      sendToRVS_raw({
        type: "chat_cleared",
        payload: { ts: Date.now() },
        timestamp: Date.now(),
      });
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    } catch (err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: err.message }));
    }
    return;
  } else if (req.url === "/api/wipe-all" && req.method === "POST") {
    // Komplett-Reset — Gedaechtnis, Stimmen, Config alle weg. SSH-Keys
    // und .env bleiben, RVS-Anbindung bleibt. Brain + Qdrant werden
    // gestoppt, Filesystem geleert, dann neu gestartet.
    log("warn", "server", "HTTP /api/wipe-all — Gesamt-Reset");
    const { spawn } = require("child_process");

    Promise.resolve()
      .then(() => dockerContainerStop("aria-brain").catch(() => {}))
      .then(() => dockerContainerStop("aria-qdrant").catch(() => {}))
      .then(() => new Promise((resolve, reject) => {
        // Liste der Pfade die innerhalb des Diagnostic-Containers gemountet
        // sind: /shared (config + voices) und /brain (Memory + Skills).
        const cmd = [
          "rm -rf /shared/config /shared/voices /shared/conversations-archive /shared/chat_backup.jsonl",
          "rm -rf /brain/data /brain/qdrant /brain/.[!.]* 2>/dev/null || true",
          "mkdir -p /brain/data /brain/qdrant /shared/config /shared/voices",
        ].join(" && ");
        const sh = spawn("sh", ["-c", cmd]);
        let stderr = "";
        sh.stderr.on("data", d => stderr += d.toString());
        sh.on("close", c => c === 0 ? resolve() : reject(new Error(`wipe exit ${c}: ${stderr}`)));
      }))
      .then(async () => {
        // Bridge auch neustarten, damit Whisper-Config + Voice-Config frisch
        await dockerContainerStop("aria-bridge").catch(() => {});
        await dockerContainerStart("aria-qdrant");
        await new Promise(r => setTimeout(r, 2000));
        await dockerContainerStart("aria-brain");
        await dockerContainerStart("aria-bridge");
        log("info", "server", "wipe-all OK — Brain + Qdrant + Bridge neu gestartet");
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, message: "Komplett-Reset durchgefuehrt — ARIA ist leer." }));
      })
      .catch(async err => {
        log("error", "server", `wipe-all: ${err.message}`);
        // Container trotzdem hochfahren
        try { await dockerContainerStart("aria-qdrant"); } catch {}
        try { await dockerContainerStart("aria-brain"); } catch {}
        try { await dockerContainerStart("aria-bridge"); } catch {}
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      });
    return;
  } else if (req.url === "/api/container-restart" && req.method === "POST") {
    // Generischer Restart fuer ARIAs Container — Whitelist verhindert
    // dass jemand aria-proxy oder das Diagnostic selbst kickt.
    let body = "";
    req.on("data", c => { body += c; if (body.length > 1024) req.destroy(); });
    req.on("end", async () => {
      try {
        const { name } = JSON.parse(body || "{}");
        const ALLOWED = ["aria-bridge", "aria-brain", "aria-qdrant"];
        if (!ALLOWED.includes(name)) {
          res.writeHead(400, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: false, error: `Container '${name}' nicht erlaubt (Whitelist: ${ALLOWED.join(", ")})` }));
          return;
        }
        log("info", "server", `HTTP /api/container-restart — ${name}`);
        await dockerContainerStop(name).catch(() => {});
        await new Promise(r => setTimeout(r, 500));
        await dockerContainerStart(name);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, container: name }));
      } catch (err) {
        log("error", "server", `container-restart: ${err.message}`);
        res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: false, error: err.message }));
      }
    });
    return;
  } else if (req.url.startsWith("/api/brain/")) {
    // Reverse-Proxy zum aria-brain Container (intern auf 8080, nicht expose'd).
    // Frontend ruft z.B. /api/brain/health → http://aria-brain:8080/health
    const targetPath = req.url.replace(/^\/api\/brain/, "");
    // Uploads brauchen laenger als die 30s default — Memory-Anhang-Endpoints
    // koennen bis zu 20 MB tragen, plus chat/distill-Calls dauern manchmal
    // mehr als eine Minute.
    const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
    const timeout = isUpload ? 120000 : 60000;
    const proxyReq = http.request({
      host: "aria-brain",
      port: 8080,
      path: targetPath,
      method: req.method,
      headers: req.headers,
      timeout,
    }, (proxyRes) => {
      res.writeHead(proxyRes.statusCode, proxyRes.headers);
      proxyRes.pipe(res);
    });
    proxyReq.on("error", (err) => {
      res.writeHead(503, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "unreachable", error: err.message }));
    });
    req.pipe(proxyReq);
    return;
  } else if (req.url.startsWith("/api/chat-backup") && req.method === "GET") {
    // Tail des chat_backup.jsonl — fuer Debug-Sessions (was hat ARIA wirklich
    // gesagt/getan). ?lines=N (Default 200, Max 5000).
    try {
      const u = new URL(req.url, "http://localhost");
      const lines = Math.max(1, Math.min(5000, parseInt(u.searchParams.get("lines") || "200", 10) || 200));
      const file = "/shared/config/chat_backup.jsonl";
      let raw = "";
      try { raw = fs.readFileSync(file, "utf-8"); } catch {
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ ok: true, file, lines: [] }));
      }
      const all = raw.split("\n").filter(l => l.trim());
      const tail = all.slice(-lines);
      const parsed = tail.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ ok: true, file, count: parsed.length, total: all.length, lines: parsed }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ ok: false, error: e.message }));
    }
  } else if (req.url.startsWith("/api/agent-stream") && req.method === "GET") {
    // Tail / paginierter Slice des persistierten agent_stream.jsonl.
    // Modi:
    //   ?lines=N               → letzte N Zeilen (Live-View Initial-Load)
    //   ?page=P&perPage=M      → 1-indexed Pagination (Modal-Browser);
    //                            page=1 = neueste Seite, hoehere Pages = aelter
    try {
      const u = new URL(req.url, "http://localhost");
      const linesParam = u.searchParams.get("lines");
      const pageParam = u.searchParams.get("page");
      const perPageParam = u.searchParams.get("perPage");
      const file = AGENT_STREAM_LOG;
      let raw = "";
      try { raw = fs.readFileSync(file, "utf-8"); } catch {
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ ok: true, file, total: 0, lines: [] }));
      }
      const all = raw.split("\n").filter(l => l.trim());
      let slice, page = 1, perPage = 0, pagesTotal = 1;
      if (pageParam || perPageParam) {
        perPage = Math.max(10, Math.min(5000, parseInt(perPageParam || "100", 10) || 100));
        pagesTotal = Math.max(1, Math.ceil(all.length / perPage));
        page = Math.max(1, Math.min(pagesTotal, parseInt(pageParam || "1", 10) || 1));
        // page=1 = juengste Seite → vom Ende her slicen
        const end = all.length - (page - 1) * perPage;
        const start = Math.max(0, end - perPage);
        slice = all.slice(start, end);
      } else {
        const lines = Math.max(1, Math.min(5000, parseInt(linesParam || "200", 10) || 200));
        slice = all.slice(-lines);
      }
      const parsed = slice.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({
        ok: true, file, total: all.length, count: parsed.length,
        page, perPage, pagesTotal, lines: parsed,
      }));
    } catch (e) {
      res.writeHead(500, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ ok: false, error: e.message }));
    }
  } else if (req.url === "/api/brain-export" && req.method === "GET") {
    // Komplettes Gehirn als tar.gz streamen.
    // Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten.
    log("info", "server", "HTTP /api/brain-export — Gehirn-Export gestartet");
    const { spawn } = require("child_process");

    dockerContainerStop("aria-brain").catch(() => {})
      .then(() => dockerContainerStop("aria-qdrant").catch(() => {}))
      .then(() => {
        const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
        res.writeHead(200, {
          "Content-Type": "application/gzip",
          "Content-Disposition": `attachment; filename="aria-brain-${ts}.tar.gz"`,
          "Cache-Control": "no-store",
        });
        const tar = spawn("tar", ["czf", "-", "-C", "/brain", "."]);
        tar.stdout.pipe(res);
        let stderr = "";
        tar.stderr.on("data", d => stderr += d.toString());
        const restartAll = async () => {
          try {
            await dockerContainerStart("aria-qdrant");
            await new Promise(r => setTimeout(r, 1500));
            await dockerContainerStart("aria-brain");
            log("info", "server", "brain-export: Container wieder gestartet");
          } catch (e) {
            log("error", "server", `brain-export Restart fehlgeschlagen: ${e.message}`);
          }
        };
        tar.on("close", (code) => {
          if (code !== 0) log("error", "server", `brain-export tar exit ${code}: ${stderr.slice(0, 200)}`);
          restartAll();
        });
        // Client-Disconnect → tar abbrechen + Container wieder hoch
        req.on("close", () => { if (!tar.killed) { tar.kill("SIGTERM"); restartAll(); } });
      })
      .catch(err => {
        log("error", "server", `brain-export: ${err.message}`);
        if (!res.headersSent) {
          res.writeHead(500, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: false, error: err.message }));
        }
      });
    return;
  } else if (req.url === "/api/brain-import" && req.method === "POST") {
    // Body ist die rohe tar.gz-Datei (kein multipart). Beliebig grosse Uploads
    // werden direkt in tar -xz gepiped → kein Memory-Bloat.
    log("warn", "server", "HTTP /api/brain-import — Gehirn-Import gestartet");
    const { spawn } = require("child_process");

    dockerContainerStop("aria-brain").catch(() => {})
      .then(() => dockerContainerStop("aria-qdrant").catch(() => {}))
      .then(() => new Promise((resolve, reject) => {
        // Brain-Verzeichnis leeren (Mount-Point selbst nicht entfernen)
        const rm = spawn("sh", ["-c", "rm -rf /brain/data /brain/qdrant /brain/.[!.]* 2>/dev/null; mkdir -p /brain/data /brain/qdrant"]);
        rm.on("close", (code) => code === 0 ? resolve() : reject(new Error(`cleanup exit ${code}`)));
        rm.on("error", reject);
      }))
      .then(() => new Promise((resolve, reject) => {
        const tar = spawn("tar", ["xzf", "-", "-C", "/brain"]);
        req.pipe(tar.stdin);
        let stderr = "";
        tar.stderr.on("data", d => stderr += d.toString());
        tar.on("close", (code) => {
          if (code === 0) resolve();
          else reject(new Error(`tar exit ${code}: ${stderr.slice(0, 300)}`));
        });
        tar.on("error", reject);
        // Falls Client mittendrin abbricht
        req.on("close", () => { if (!tar.killed && !req.complete) tar.kill("SIGTERM"); });
      }))
      .then(async () => {
        // Subdirs sicherstellen (falls Archiv unsauber war)
        await new Promise(r => spawn("mkdir", ["-p", "/brain/data", "/brain/qdrant"]).on("close", r));
        await dockerContainerStart("aria-qdrant");
        await new Promise(r => setTimeout(r, 2000));
        await dockerContainerStart("aria-brain");
        log("info", "server", "brain-import OK — Container neu gestartet");
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ ok: true, message: "Gehirn importiert, Container neu gestartet" }));
      })
      .catch(async (err) => {
        log("error", "server", `brain-import fehlgeschlagen: ${err.message}`);
        // Container trotzdem wieder hochfahren, sonst steht alles
        try { await dockerContainerStart("aria-qdrant"); } catch {}
        try { await dockerContainerStart("aria-brain"); } catch {}
        if (!res.headersSent) {
          res.writeHead(500, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: false, error: err.message }));
        }
      });
    return;
  } else if (req.url.startsWith("/shared/")) {
    // Dateien aus Shared Volume ausliefern (Bilder, Uploads)
    const filePath = decodeURIComponent(req.url);
    const safePath = path.resolve(filePath);
    if (!safePath.startsWith("/shared/")) {
      res.writeHead(403);
      res.end("Forbidden");
      return;
    }
    try {
      if (!fs.existsSync(safePath)) { res.writeHead(404); res.end("Not Found"); return; }
      const ext = path.extname(safePath).toLowerCase();
      const mimeTypes = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
                          ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json" };
      const contentType = mimeTypes[ext] || "application/octet-stream";
      const data = fs.readFileSync(safePath);
      res.writeHead(200, { "Content-Type": contentType, "Content-Length": data.length });
      res.end(data);
    } catch (err) {
      res.writeHead(500);
      res.end("Error");
    }
  } else {
    res.writeHead(404);
    res.end("Not Found");
  }
});

const wss = new WebSocketServer({ server });

wss.on("connection", (ws) => {
  browserClients.add(ws);
  // Initialen State + letzte Logs senden
  ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
  // Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
  if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));

  ws.on("message", (raw) => {
    try {
      const msg = JSON.parse(raw.toString());

      if (msg.action === "test_gateway") {
        // Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
        // alte Browser-Sessions die noch den Button anzeigen nicht stumm
        // ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
        traceStart("RVS", msg.text || "aria lebst du noch?");
        sendToRVS(msg.text || "aria lebst du noch?", true);
      } else if (msg.action === "test_rvs") {
        traceStart("RVS", msg.text || "aria lebst du noch?");
        sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
      } else if (msg.action === "reconnect_gateway") {
        connectGateway();
      } else if (msg.action === "reconnect_rvs") {
        connectRVS();
      } else if (msg.action === "test_proxy") {
        testProxy(msg.text);
      } else if (msg.action === "check_proxy_auth") {
        checkProxyAuth();
      } else if (msg.action === "proxy_login") {
        attachTerminal(ws, "aria-proxy", "claude login");
      } else if (msg.action === "core_terminal") {
        // Interaktive Shell in aria-core (fuer openclaw agents, etc.)
        attachTerminal(ws, "aria-core", msg.cmd || "sh");
      } else if (msg.action === "check_core_auth") {
        checkCoreAuth();
      } else if (msg.action === "term_input") {
        handleTermInput(ws, msg.data);
      } else if (msg.action === "write_credentials") {
        writeProxyCredentials(msg.credentials);
      } else if (msg.action === "docker_logs") {
        handleDockerLogs(ws, msg.tab, msg.tail);
      } else if (msg.action === "live_ssh_start") {
        startLiveSSH(ws);
      } else if (msg.action === "live_ssh_input") {
        if (ws._sshSock) ws._sshSock.write(msg.data);
      } else if (msg.action === "live_ssh_close") {
        if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; }
      } else if (msg.action === "send_file") {
        // Datei von Diagnostic an Bridge via RVS senden
        sendToRVS_raw({
          type: "file",
          payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 },
          timestamp: Date.now(),
        });
        log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
      } else if (msg.action === "cancel_request") {
        // Laufende Anfrage abbrechen — doctor --fix beendet stuck runs
        log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus");
        pendingMessageTime = 0;
        watchdogWarned = false;
        watchdogFixAttempted = false;
        if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
        broadcast({ type: "agent_activity", activity: "idle" });
        dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
      } else if (msg.action === "aria_panic_stop") {
        // NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
        // Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
        log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
        pendingMessageTime = 0;
        watchdogWarned = false;
        watchdogFixAttempted = false;
        if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
        broadcast({ type: "agent_activity", activity: "idle" });
        // RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
        // den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
        sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
      } else if (msg.action === "voice_upload") {
        // Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
        log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
        sendToRVS_withResponse("voice_upload", { name: msg.name, samples: msg.samples }, "xtts_voice_saved", ws);
      } else if (msg.action === "xtts_list_voices") {
        // Frische Verbindung die auf Antwort wartet
        sendToRVS_withResponse("xtts_list_voices", {}, "xtts_voices_list", ws);
      } else if (msg.action === "xtts_export_voice") {
        // Anfrage an XTTS-Bridge — gibt die Stimme als base64 tar.gz zurueck
        sendToRVS_withResponse("xtts_export_voice", { name: msg.name }, "xtts_voice_exported", ws);
      } else if (msg.action === "xtts_import_voice") {
        // tar.gz (base64) an XTTS-Bridge schicken — die packt aus
        sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws);
      } else if (msg.action === "xtts_delete_voice") {
        // Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste
        sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
        log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`);
      } else if (msg.action === "delete_chat_message") {
        // Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
        // + broadcastet chat_message_deleted via RVS.
        const ts = Number(msg.ts);
        if (!Number.isFinite(ts)) {
          ws.send(JSON.stringify({ type: "log", level: "error", source: "server",
            message: `delete_chat_message: ungueltiges ts=${msg.ts}` }));
          return;
        }
        sendToRVS_raw({ type: "delete_message_request", payload: { ts }, timestamp: Date.now() });
        log("info", "server", `delete_message_request ts=${ts} an Bridge gesendet`);
      } else if (msg.action === "set_mode") {
        // Mode-Wechsel → Bridge bearbeitet und broadcastet an alle Clients
        sendToRVS_raw({ type: "mode", payload: { mode: msg.mode }, timestamp: Date.now() });
        log("info", "server", `Mode-Wechsel angefordert: ${msg.mode}`);
      } else if (msg.action === "get_voice_config") {
        handleGetVoiceConfig(ws);
      } else if (msg.action === "send_voice_config") {
        // Stimmen-Config persistent speichern + an Bridge via RVS senden
        let existing = {};
        try { existing = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {}
        const voiceConfig = {
          ...existing,
          ttsEnabled: msg.ttsEnabled !== false,
          xttsVoice: msg.xttsVoice || "",
        };
        if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel;
        // F5-TTS Tuning-Felder — immer mit dem vom User gesendeten Wert setzen,
        // auch leeren String. Leer = "reset auf Hard-Default". Sonst merkt die
        // Bridge nicht dass der User den Wert loeschen wollte (absent key war
        // vorher 'keep current' semantik → BigVGAN blieb drin obwohl User
        // leer eingetragen hatte).
        if (msg.f5ttsModel !== undefined)     voiceConfig.f5ttsModel = msg.f5ttsModel || "";
        if (msg.f5ttsCkptFile !== undefined)  voiceConfig.f5ttsCkptFile = msg.f5ttsCkptFile || "";
        if (msg.f5ttsVocabFile !== undefined) voiceConfig.f5ttsVocabFile = msg.f5ttsVocabFile || "";
        if (msg.f5ttsCfgStrength !== undefined && !isNaN(msg.f5ttsCfgStrength)) {
          voiceConfig.f5ttsCfgStrength = msg.f5ttsCfgStrength;
        }
        if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
          voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
        }
        // FLUX-Settings (Default-Modell + User-Keywords). flux-bridge nutzt
        // fluxDefaultModel zum Hot-Swap, Brain liest die Keywords direkt aus
        // /shared/config/voice_config.json fuer den System-Prompt.
        if (msg.fluxDefaultModel !== undefined) {
          voiceConfig.fluxDefaultModel = (msg.fluxDefaultModel === "schnell") ? "schnell" : "dev";
        }
        if (msg.fluxKeywordRaw !== undefined) {
          voiceConfig.fluxKeywordRaw = String(msg.fluxKeywordRaw || "").trim().toLowerCase() || "flux";
        }
        if (msg.fluxKeywordSwitch !== undefined) {
          voiceConfig.fluxKeywordSwitch = String(msg.fluxKeywordSwitch || "").trim().toLowerCase() || "fix";
        }
        // HuggingFace-Token fuer gated FLUX.1-dev. Wird per RVS an die
        // flux-bridge gepusht, dort als HF_TOKEN env gesetzt vor dem
        // naechsten from_pretrained. Leerer String = "kein Token" (statt
        // 'behalt was du hattest'), damit Stefan ihn auch wieder loeschen
        // kann.
        if (msg.huggingfaceToken !== undefined) {
          voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
        }
        // Voice-ID Match-Threshold (0.30-0.70). Wird von der whisper-bridge
        // ueber den config-Broadcast aufgenommen — Phase 3 nutzt's beim Gating.
        if (msg.voiceIdThreshold !== undefined && !isNaN(msg.voiceIdThreshold)) {
          const t = parseFloat(msg.voiceIdThreshold);
          if (t >= 0.0 && t <= 1.0) voiceConfig.voiceIdThreshold = t;
        }
        try {
          fs.mkdirSync("/shared/config", { recursive: true });
          fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
        } catch {}
        sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
        log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`);
      } else if (msg.action === "preview_voice") {
        handleVoicePreview(ws, msg.voice || "", msg.text || "Hallo.", msg.speed);
      } else if (msg.action === "check_desktop") {
        checkDesktopAvailable(ws);
      } else if (msg.action === "load_chat_history") {
        handleLoadChatHistory(ws);
      // Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
      // laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
      // restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
      } else if (msg.action === "restart_session") {
        handleRestartSession(ws);
      // ── Einstellungen ──
      // list_permissions / save_permissions entfernt — Alles-oder-Nichts via --dangerously-skip-permissions
      } else if (msg.action === "get_model") {
        handleGetModel(ws);
      } else if (msg.action === "set_model") {
        handleSetModel(ws, msg.model);
      } else if (msg.action === "voice_id_status") {
        // An whisper-bridge weiterleiten + Antwort an Browser zurueck
        const reqId = `vid_${Date.now().toString(36)}`;
        sendToRVS_withResponse("voice_id_status_request", { requestId: reqId },
                               "voice_id_status_response", ws);
      } else if (msg.action === "voice_id_delete") {
        const reqId = `viddel_${Date.now().toString(36)}`;
        sendToRVS_withResponse("voice_id_delete_request", { requestId: reqId },
                               "voice_id_delete_response", ws);
      }
      // get_openclaw_config entfernt — aria-core ist raus.
    } catch {}
  });

  ws.on("close", () => {
    browserClients.delete(ws);
  });
});

// ── Live SSH ─────────────────────────────────────────────

function startLiveSSH(clientWs) {
  // Bestehende Session schliessen
  if (clientWs._sshSock) {
    try { clientWs._sshSock.end(); } catch (_) {}
    clientWs._sshSock = null;
  }

  const createBody = JSON.stringify({
    AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true,
    Cmd: ["ssh", "-tt", "aria-wohnung"],
  });

  const createReq = http.request({
    socketPath: "/var/run/docker.sock",
    path: "/containers/aria-core/exec",
    method: "POST",
    headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) },
  }, (res) => {
    let data = "";
    res.on("data", (c) => data += c);
    res.on("end", () => {
      if (res.statusCode !== 201) {
        clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `Exec create failed: HTTP ${res.statusCode}` }));
        return;
      }
      const execId = JSON.parse(data).Id;
      const startBody = JSON.stringify({ Detach: false, Tty: true });

      const sock = net.connect({ path: "/var/run/docker.sock" }, () => {
        const req = `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(startBody)}\r\n\r\n${startBody}`;
        sock.write(req);
      });

      let headersParsed = false;
      let headerBuf = "";

      sock.on("data", (chunk) => {
        if (!headersParsed) {
          headerBuf += chunk.toString("utf-8");
          const headerEnd = headerBuf.indexOf("\r\n\r\n");
          if (headerEnd === -1) return;
          const headers = headerBuf.slice(0, headerEnd);
          const remaining = chunk.slice(chunk.length - (headerBuf.length - headerEnd - 4));
          headersParsed = true;

          if (!headers.includes("200") && !headers.includes("101")) {
            clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `SSH start failed` }));
            sock.end();
            return;
          }

          log("info", "server", "Live SSH-Session gestartet");
          clientWs.send(JSON.stringify({ type: "live_ssh_connected" }));
          clientWs._sshSock = sock;

          if (remaining.length > 0) {
            clientWs.send(JSON.stringify({ type: "live_ssh_data", data: remaining.toString("base64") }));
          }
          return;
        }
        // Stream-Daten ans Frontend
        try {
          clientWs.send(JSON.stringify({ type: "live_ssh_data", data: chunk.toString("base64") }));
        } catch (_) { sock.end(); }
      });

      sock.on("close", () => {
        clientWs._sshSock = null;
        try { clientWs.send(JSON.stringify({ type: "live_ssh_closed" })); } catch (_) {}
        log("info", "server", "Live SSH-Session beendet");
      });

      sock.on("error", (err) => {
        clientWs._sshSock = null;
        try { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: err.message })); } catch (_) {}
      });
    });
  });

  createReq.on("error", (err) => {
    clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `Docker: ${err.message}` }));
  });
  createReq.end(createBody);
}

// ── Voice-Config laden ────────────────────────────────

function handleGetVoiceConfig(clientWs) {
  try {
    const configPath = "/shared/config/voice_config.json";
    if (fs.existsSync(configPath)) {
      const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
      clientWs.send(JSON.stringify({ type: "voice_config", ...config }));
    } else {
      clientWs.send(JSON.stringify({ type: "voice_config", ttsEnabled: true, xttsVoice: "" }));
    }
  } catch (err) {
    clientWs.send(JSON.stringify({ type: "voice_config", ttsEnabled: true, xttsVoice: "" }));
  }
}

// ── TTS Diagnose (XTTS) ───────────────────────────────
// ── Voice Preview ────────────────────────────────────────
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
// und schickt sie base64-kodiert an den Browser-Client.
//
// Map requestId → { clientWs, chunks: [Buffer], sampleRate, channels }
const _previewPending = new Map();

function _buildWavFromPcm(pcmBuf, sampleRate, channels) {
  const bitsPerSample = 16;
  const byteRate = sampleRate * channels * bitsPerSample / 8;
  const blockAlign = channels * bitsPerSample / 8;
  const dataSize = pcmBuf.length;
  const header = Buffer.alloc(44);
  header.write("RIFF", 0);
  header.writeUInt32LE(36 + dataSize, 4);
  header.write("WAVE", 8);
  header.write("fmt ", 12);
  header.writeUInt32LE(16, 16);            // subchunk1 size
  header.writeUInt16LE(1, 20);             // PCM
  header.writeUInt16LE(channels, 22);
  header.writeUInt32LE(sampleRate, 24);
  header.writeUInt32LE(byteRate, 28);
  header.writeUInt16LE(blockAlign, 32);
  header.writeUInt16LE(bitsPerSample, 34);
  header.write("data", 36);
  header.writeUInt32LE(dataSize, 40);
  return Buffer.concat([header, pcmBuf]);
}

function _handlePreviewChunk(payload) {
  const reqId = payload?.requestId || "";
  const entry = _previewPending.get(reqId);
  if (!entry) return;
  if (payload.base64) {
    try { entry.chunks.push(Buffer.from(payload.base64, "base64")); } catch {}
  }
  if (!entry.sampleRate && payload.sampleRate) entry.sampleRate = payload.sampleRate;
  if (!entry.channels && payload.channels) entry.channels = payload.channels;
  if (payload.final) {
    _previewPending.delete(reqId);
    try {
      const pcm = Buffer.concat(entry.chunks);
      const wav = _buildWavFromPcm(pcm, entry.sampleRate || 24000, entry.channels || 1);
      const b64 = wav.toString("base64");
      if (entry.clientWs && entry.clientWs.readyState === 1) {
        entry.clientWs.send(JSON.stringify({
          type: "voice_preview_audio",
          base64: b64,
          size: wav.length,
        }));
      }
    } catch (err) {
      if (entry.clientWs && entry.clientWs.readyState === 1) {
        entry.clientWs.send(JSON.stringify({
          type: "voice_preview_audio",
          error: err.message,
        }));
      }
    }
  }
}

async function handleVoicePreview(clientWs, voice, text, speed) {
  try {
    // Speed clampen — Browser-Slider ist 0.1-5.0
    let spd = parseFloat(speed);
    if (!isFinite(spd) || spd < 0.1 || spd > 5.0) spd = 1.0;
    const requestId = crypto.randomUUID();
    _previewPending.set(requestId, { clientWs, chunks: [], sampleRate: 0, channels: 0 });
    // Timeout safety net
    setTimeout(() => {
      if (_previewPending.has(requestId)) {
        _previewPending.delete(requestId);
        if (clientWs && clientWs.readyState === 1) {
          clientWs.send(JSON.stringify({
            type: "voice_preview_audio",
            error: "Timeout (60s) — keine Antwort vom f5tts-bridge",
          }));
        }
      }
    }, 60000);
    log("info", "server", `Voice-Preview: voice="${voice}" speed=${spd.toFixed(1)}x text="${text.slice(0, 60)}"`);
    sendToRVS_raw({
      type: "xtts_request",
      payload: { text, language: "de", requestId, voice, speed: spd },
      timestamp: Date.now(),
    });
  } catch (err) {
    clientWs.send(JSON.stringify({ type: "voice_preview_audio", error: err.message }));
  }
}

function checkDesktopAvailable(clientWs) {
  // Pruefen ob VNC auf der VM laeuft (Port 5900/5901)
  const checkSock = net.connect({ host: "host.docker.internal", port: 5901 }, () => {
    checkSock.end();
    clientWs.send(JSON.stringify({
      type: "desktop_status",
      available: true,
      url: `http://${clientWs._socket?.remoteAddress || "localhost"}:6080/vnc.html?autoconnect=true`,
      message: "VNC Desktop verfuegbar",
    }));
  });
  checkSock.on("error", () => {
    clientWs.send(JSON.stringify({
      type: "desktop_status",
      available: false,
      message: "Kein VNC-Server auf aria-wohnung gefunden (Port 5901)",
    }));
  });
  checkSock.setTimeout(3000, () => {
    checkSock.destroy();
    clientWs.send(JSON.stringify({
      type: "desktop_status",
      available: false,
      message: "VNC-Server nicht erreichbar (Timeout)",
    }));
  });
}

// ── Session-Viewer + Brain-File-Viewer entfernt ─────────
// OpenClaw-Sessions sind raus. Wer alte jsonl-Konversationen sichern
// will: POST /api/sessions-snapshot kopiert sie nach
// /shared/conversations-archive/ → spaeter Brain-Migration.
// Memory laeuft jetzt komplett ueber den aria-brain Container
// (Vector-DB, siehe /api/brain/* Proxy).

// ── Chat-History: liest aus chat_backup.jsonl ────────────
// Ablage: jede Zeile ein JSON-Eintrag.
//   {ts, role: "user"|"assistant", text, session}
//   {ts, type: "file_deleted", path, by}
// Marker [FILE: /shared/uploads/...] werden zu aria_file-Bubbles aufgeloest,
// wenn die Datei noch existiert; geloeschte Dateien zu deleted-Markern.
async function handleLoadChatHistory(clientWs) {
  try {
    const file = "/shared/config/chat_backup.jsonl";
    let raw = "";
    try { raw = fs.readFileSync(file, "utf-8"); } catch {
      clientWs.send(JSON.stringify({ type: "chat_history", messages: [] }));
      return;
    }
    const lines = raw.split("\n").filter(l => l.trim());
    const deletedPaths = new Set();
    const messages = [];
    const mimeMap = {
      ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
      ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf",
      ".mp3": "audio/mpeg", ".wav": "audio/wav", ".txt": "text/plain",
      ".md": "text/markdown", ".json": "application/json", ".zip": "application/zip",
    };

    // Erst Durchlauf: deleted-Marker sammeln
    for (const line of lines) {
      try {
        const obj = JSON.parse(line);
        if (obj.type === "file_deleted" && obj.path) deletedPaths.add(obj.path);
      } catch {}
    }

    for (const line of lines) {
      let obj;
      try { obj = JSON.parse(line); } catch { continue; }
      if (obj.type === "file_deleted") continue; // Marker selbst nicht anzeigen
      if (obj.role !== "user" && obj.role !== "assistant") continue;
      const ts = obj.ts || 0;
      const text = String(obj.text || "");
      const projectId = String(obj.project_id || "");  // Multi-Threading: Kontext-Zuordnung
      if (obj.role === "user") {
        if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
        continue;
      }
      // assistant: nach FILE-Markern scannen, eigene aria_file-Eintraege pro Datei
      const fileRe = /\[FILE:\s*(\/shared\/uploads\/[^\]]+?)\s*\]/gi;
      let m;
      while ((m = fileRe.exec(text)) !== null) {
        const p = m[1].trim();
        const wasDeleted = deletedPaths.has(p);
        let size = 0;
        let exists = false;
        try { const st = fs.statSync(p); size = st.size; exists = true; } catch {}
        if (!exists && !wasDeleted) continue;  // war vielleicht nie da
        const ext = path.extname(p).toLowerCase();
        messages.push({
          type: "aria_file",
          serverPath: p,
          name: path.basename(p),
          mimeType: mimeMap[ext] || "application/octet-stream",
          size,
          ts,
          deleted: wasDeleted || !exists,
          projectId,
        });
      }
      if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId });
    }

    clientWs.send(JSON.stringify({ type: "chat_history", messages }));
    log("info", "server", `Chat-History geladen: ${messages.length} Eintraege (${deletedPaths.size} geloescht)`);
  } catch (err) {
    log("error", "server", `Chat-History: ${err.message}`);
    clientWs.send(JSON.stringify({ type: "chat_history", messages: [], error: err.message }));
  }
}

// ── Session neu starten (Container Restart) — toter Code fuer aria-core
async function handleRestartSession(clientWs) {
  try {
    log("info", "server", "Starte aria-core Container neu (Session Restart)...");
    clientWs.send(JSON.stringify({ type: "session_restarted", status: "restarting" }));

    // Container neu starten via Docker API
    const http = require("http");
    await new Promise((resolve, reject) => {
      const req = http.request({
        socketPath: "/var/run/docker.sock",
        path: "/containers/aria-core/restart?t=5",
        method: "POST",
      }, (res) => {
        let body = "";
        res.on("data", d => body += d);
        res.on("end", () => {
          if (res.statusCode === 204 || res.statusCode === 200) resolve();
          else reject(new Error(`Restart fehlgeschlagen: HTTP ${res.statusCode} ${body}`));
        });
      });
      req.on("error", reject);
      req.end();
    });

    log("info", "server", "aria-core Container neu gestartet");
    // Warten bis Gateway wieder erreichbar (max 30s)
    for (let i = 0; i < 15; i++) {
      await new Promise(r => setTimeout(r, 2000));
      try {
        await dockerExec("aria-core", "echo ok");
        log("info", "server", "aria-core ist wieder erreichbar");
        clientWs.send(JSON.stringify({
          type: "session_restarted", status: "ok",
          info: "aria-core neu gestartet — Permissions werden bei der naechsten Nachricht geladen",
        }));
        // Aktive Session beibehalten
        for (const c of browserClients) {
          c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey }));
        }
        return;
      } catch {}
    }
    clientWs.send(JSON.stringify({ type: "session_restarted", status: "timeout", error: "aria-core antwortet nicht nach 30s" }));
  } catch (err) {
    log("error", "server", `Session Restart fehlgeschlagen: ${err.message}`);
    clientWs.send(JSON.stringify({ type: "session_restarted", status: "error", error: err.message }));
  }
}


// ── Einstellungen: Tool-Berechtigungen ──────────────────
// ENTFERNT: Granulare Permissions haben nie funktioniert.
// Claude Code laeuft mit --dangerously-skip-permissions (Alles oder Nichts).
// Root-Check wird via CLAUDE_CODE_BUBBLEWRAP=1 in docker-compose.yml umgangen.

// ── Einstellungen: Model (Brain) ────────────────────────
// Liest/schreibt brainModel in /shared/config/runtime.json — Brain liest
// das beim Container-Start. Bei Aenderung: aria-brain restarten
// (Einstellungen → Reparatur → 🚨 aria-brain neu).

function handleGetModel(clientWs) {
  try {
    const cfg = readRuntimeConfig();
    const model = (cfg.brainModel || "").trim() || "claude-sonnet-4";
    clientWs.send(JSON.stringify({
      type: "model_info",
      model,
      info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
    }));
  } catch (err) {
    clientWs.send(JSON.stringify({ type: "model_info", error: err.message }));
  }
}

function handleSetModel(clientWs, model) {
  if (!model || typeof model !== "string") {
    clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" }));
    return;
  }
  try {
    writeRuntimeConfig({ brainModel: model.trim() });
    log("info", "server", `Brain-Model gesetzt: ${model.trim()}`);
    clientWs.send(JSON.stringify({
      type: "model_info",
      model: model.trim(),
      info: `Model auf "${model.trim()}" gesetzt — aria-brain neu starten damit's greift`,
    }));
  } catch (err) {
    clientWs.send(JSON.stringify({ type: "model_info", error: err.message }));
  }
  return;
}

// OpenClaw-Config-Handler entfernt — aria-core ist raus.

// ── Start ───────────────────────────────────────────────

server.listen(HTTP_PORT, "0.0.0.0", () => {
  log("info", "server", `Diagnostic Server laeuft auf http://0.0.0.0:${HTTP_PORT}`);
  log("info", "server", `RVS: ${RVS_HOST ? `${RVS_HOST}:${RVS_PORT}` : "(nicht konfiguriert)"}`);
  log("info", "server", `Proxy: ${PROXY_URL}`);
  log("info", "server", `Brain: ${process.env.BRAIN_URL || "http://aria-brain:8080"}`);

  // RVS-Verbindung — aria-core/OpenClaw-Gateway entfaellt.
  connectRVS();

  // gateway-State bleibt "disconnected" stehen; State-Card im UI
  // wurde mit ausgebaut.
  state.gateway.status = "disabled";
  state.gateway.lastError = "aria-core entfernt — Brain-Loop in Arbeit";
  broadcastState();
});
