@@ -1412,6 +1423,11 @@
return;
}
+ if (msg.type === 'agent_stream') {
+ appendAriaStreamEvent(msg.payload || {});
+ return;
+ }
+
if (msg.type === 'voice_preview_audio') {
const statusEl = document.getElementById('voice-preview-status');
const audio = document.getElementById('voice-preview-audio');
@@ -1555,8 +1571,8 @@
return;
}
// core_auth WS-Event entfernt — aria-core ist raus.
- // Live SSH + Desktop
- if (msg.type?.startsWith('live_ssh_')) { handleLiveSSH(msg); return; }
+ // SSH-Terminal entfernt — durch ARIA-Live-Mirror ersetzt.
+ // Desktop bleibt.
if (msg.type === 'desktop_status') { handleDesktop(msg); return; }
if (msg.type === 'term_ready') {
@@ -2962,96 +2978,133 @@
// ── ARIA Live-Ansicht (SSH + Desktop) ──────────────────
- let liveSshTerm = null;
- let liveSshFit = null;
-
function switchLiveTab(tab) {
- document.getElementById('live-ssh').style.display = tab === 'ssh' ? 'block' : 'none';
+ document.getElementById('live-aria').style.display = tab === 'aria' ? 'flex' : 'none';
document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none';
- document.getElementById('live-tab-ssh').className = 'tab-btn' + (tab === 'ssh' ? ' active' : '');
+ document.getElementById('live-tab-aria').className = 'tab-btn' + (tab === 'aria' ? ' active' : '');
document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : '');
- if (tab === 'ssh' && liveSshTerm && liveSshFit) {
- setTimeout(() => liveSshFit.fit(), 50);
- }
}
- function startLiveSSH() {
- const statusEl = document.getElementById('live-ssh-status');
- const btn = document.getElementById('btn-live-ssh');
-
- // Wenn schon verbunden, trennen
- if (liveSshTerm && liveSshTerm._sshConnected) {
- send({ action: 'live_ssh_close' });
- statusEl.textContent = 'Getrennt';
- statusEl.style.color = '#FF6B6B';
- btn.textContent = 'Verbinden';
- liveSshTerm._sshConnected = false;
- return;
+ // ── ARIA Live (read-only Mirror der Claude-Code-Session) ──────
+ //
+ // Empfaengt agent_stream Events vom RVS (Proxy → Bridge → RVS → wir).
+ // Rendert sie als monospace-Liste — Tool-Calls in cyan, Tool-Results
+ // in grau (truncated), ARIA-Text in weiss, Thinking kursiv. Auto-Scroll
+ // bleibt am unteren Rand kleben solange der User nicht hochgescrollt hat.
+ // Not-Aus killt via Bridge → Proxy-Side-Channel alle Subprocesses.
+ function _ariaStreamEl() { return document.getElementById('live-aria-stream'); }
+ function _ariaStatusEl() { return document.getElementById('live-aria-status'); }
+ function _ariaIsAtBottom() {
+ const el = _ariaStreamEl();
+ if (!el) return true;
+ return (el.scrollHeight - el.scrollTop - el.clientHeight) < 24;
+ }
+ function _ariaMaybeScroll() {
+ if (!document.getElementById('live-aria-autoscroll')?.checked) return;
+ const el = _ariaStreamEl();
+ if (el) el.scrollTop = el.scrollHeight;
+ }
+ // Truncate UI: groessere Backlogs koennen viele MB werden. Wir halten
+ // max 2000 Zeilen — beim Ueberlauf den oberen Block wegwerfen.
+ const ARIA_MAX_LINES = 2000;
+ function _ariaTrimBacklog() {
+ const el = _ariaStreamEl();
+ if (!el) return;
+ while (el.childElementCount > ARIA_MAX_LINES) {
+ el.removeChild(el.firstChild);
}
-
- statusEl.textContent = 'Verbinde...';
- statusEl.style.color = '#FFD60A';
-
- function initSSHTerm() {
- const container = document.getElementById('live-ssh-term');
- if (!liveSshTerm) {
- liveSshTerm = new Terminal({
- theme: { background: '#080810', foreground: '#E0E0F0', cursor: '#0096FF' },
- fontFamily: 'Courier New, monospace',
- fontSize: 12,
- cursorBlink: true,
- });
- liveSshFit = new FitAddon.FitAddon();
- liveSshTerm.loadAddon(liveSshFit);
- liveSshTerm.open(container);
- liveSshFit.fit();
- liveSshTerm.onData((data) => {
- send({ action: 'live_ssh_input', data });
- });
- }
- liveSshTerm.clear();
- send({ action: 'live_ssh_start' });
- }
-
- if (typeof Terminal === 'undefined') {
- const s = document.createElement('script');
- s.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js';
- s.onload = () => {
- const s2 = document.createElement('script');
- s2.src = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js';
- s2.onload = () => initSSHTerm();
- document.head.appendChild(s2);
- };
- document.head.appendChild(s);
+ }
+ function _ariaTimePrefix(ts) {
+ try {
+ const d = ts ? new Date(ts) : new Date();
+ const h = String(d.getHours()).padStart(2, '0');
+ const m = String(d.getMinutes()).padStart(2, '0');
+ const s = String(d.getSeconds()).padStart(2, '0');
+ return `${h}:${m}:${s}`;
+ } catch (_) { return ''; }
+ }
+ function _ariaEsc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ }
+ function _ariaPushLine(html, color, opts = {}) {
+ const el = _ariaStreamEl();
+ if (!el) return;
+ const wasAtBottom = _ariaIsAtBottom();
+ const row = document.createElement('div');
+ row.style.cssText = `color:${color};${opts.style||''}`;
+ row.innerHTML = html;
+ // Erste statische "Sobald ARIA..."-Zeile beim ersten Event entfernen
+ const placeholder = el.querySelector('div[style*="italic"]');
+ if (placeholder && el.childElementCount === 1) el.removeChild(placeholder);
+ el.appendChild(row);
+ _ariaTrimBacklog();
+ if (wasAtBottom) _ariaMaybeScroll();
+ }
+ function appendAriaStreamEvent(p) {
+ const t = _ariaTimePrefix(p.ts);
+ const kind = p.kind || '';
+ if (kind === 'start') {
+ _ariaPushLine(
+ `
━━━ ${t} session start (${_ariaEsc(p.model || 'unknown')}) ━━━`,
+ '#444460',
+ );
+ const st = _ariaStatusEl(); if (st) { st.textContent = 'ARIA aktiv...'; st.style.color = '#34C759'; }
+ } else if (kind === 'end') {
+ const reason = p.reason || '?';
+ const codePart = (p.code !== undefined && p.code !== null) ? ` code=${_ariaEsc(p.code)}` : '';
+ const errPart = p.error ? ` err=${_ariaEsc(String(p.error).slice(0,120))}` : '';
+ _ariaPushLine(
+ `
━━━ ${t} session end (${_ariaEsc(reason)}${codePart}${errPart}) ━━━`,
+ '#444460',
+ );
+ const st = _ariaStatusEl(); if (st) { st.textContent = 'Idle'; st.style.color = '#8888AA'; }
+ } else if (kind === 'text') {
+ _ariaPushLine(
+ `
[${t}] ${_ariaEsc(p.text || '')}`,
+ '#D0D0E0',
+ { style: 'white-space:pre-wrap;word-break:break-word;' },
+ );
+ } else if (kind === 'thinking') {
+ _ariaPushLine(
+ `
[${t}] 💭 ${_ariaEsc(p.text || '')}`,
+ '#888866',
+ { style: 'white-space:pre-wrap;word-break:break-word;' },
+ );
+ } else if (kind === 'tool_use') {
+ const name = _ariaEsc(p.name || '?');
+ const inp = _ariaEsc(p.input || '');
+ const tail = p.inputTruncatedBytes ? `
...(+${p.inputTruncatedBytes} bytes)` : '';
+ _ariaPushLine(
+ `
[${t}] ▶ ${name} ${inp}${tail}`,
+ '#C0C0D0',
+ { style: 'white-space:pre-wrap;word-break:break-word;' },
+ );
+ } else if (kind === 'tool_result') {
+ const isError = p.isError === true;
+ const head = isError ? '
✗ result (ERROR)' : '
✓ result';
+ const tail = p.truncatedBytes ? `
...(+${p.truncatedBytes} bytes)` : '';
+ _ariaPushLine(
+ `
[${t}] ${head}
${_ariaEsc(p.content || '')}${tail}`,
+ '#9090A0',
+ );
} else {
- initSSHTerm();
+ _ariaPushLine(
+ `
[${t}] ${_ariaEsc(kind)}: ${_ariaEsc(JSON.stringify(p))}`,
+ '#AAAACC',
+ );
}
}
-
- function handleLiveSSH(msg) {
- const statusEl = document.getElementById('live-ssh-status');
- const btn = document.getElementById('btn-live-ssh');
- if (msg.type === 'live_ssh_data' && liveSshTerm) {
- const raw = atob(msg.data);
- const bytes = new Uint8Array(raw.length);
- for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
- liveSshTerm.write(bytes);
- } else if (msg.type === 'live_ssh_connected') {
- statusEl.textContent = 'Verbunden mit aria-wohnung';
- statusEl.style.color = '#34C759';
- btn.textContent = 'Trennen';
- if (liveSshTerm) liveSshTerm._sshConnected = true;
- } else if (msg.type === 'live_ssh_error') {
- statusEl.textContent = msg.error || 'Fehler';
- statusEl.style.color = '#FF6B6B';
- btn.textContent = 'Verbinden';
- if (liveSshTerm) liveSshTerm._sshConnected = false;
- } else if (msg.type === 'live_ssh_closed') {
- statusEl.textContent = 'Getrennt';
- statusEl.style.color = '#8888AA';
- btn.textContent = 'Verbinden';
- if (liveSshTerm) liveSshTerm._sshConnected = false;
- }
+ function clearAriaLive() {
+ const el = _ariaStreamEl();
+ if (el) el.innerHTML = '
Geleert.
';
+ }
+ function ariaPanicStop() {
+ if (!confirm('Wirklich NOT-AUS? Alle aktiven Claude-Subprocesses werden sofort gekillt.')) return;
+ send({ action: 'aria_panic_stop' });
+ _ariaPushLine(
+ `
━━━ ${_ariaTimePrefix()} ⛔ NOT-AUS ausgeloest ━━━`,
+ '#FF3B30',
+ );
}
function checkDesktop() {
diff --git a/diagnostic/server.js b/diagnostic/server.js
index dafdb45..b9c62b5 100644
--- a/diagnostic/server.js
+++ b/diagnostic/server.js
@@ -633,6 +633,11 @@ function connectRVS(forcePlain) {
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.
+ 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 || {};
@@ -1887,6 +1892,18 @@ wss.on("connection", (ws) => {
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...`);
diff --git a/proxy-patches/routes.js b/proxy-patches/routes.js
index 100ad62..626b2f8 100644
--- a/proxy-patches/routes.js
+++ b/proxy-patches/routes.js
@@ -7,6 +7,10 @@
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
+ * - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
+ * geht an ARIA_STREAM_HOOK_URL → Bridge → RVS `agent_stream` → Diagnostic
+ * "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
+ * - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
* der Brain-Call NICHT ab.
*
@@ -21,42 +25,121 @@ import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-activity";
+const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
+ || "http://aria-bridge:8090/internal/agent-stream";
+
+// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
+// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
+// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
+// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
+const TOOL_RESULT_MAX_CHARS = 4096;
+const TOOL_INPUT_MAX_CHARS = 2048;
/**
- * Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
- * keine Fehler nach oben. Logged Fehler still.
+ * Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
+ * nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
*/
-function _emitToolEvent(toolName) {
- if (!toolName) return;
+function _postJson(url, body) {
try {
- const u = new URL(TOOL_HOOK_URL);
- const body = JSON.stringify({ tool: String(toolName) });
+ const u = new URL(url);
+ const data = JSON.stringify(body);
const req = http.request({
method: "POST",
hostname: u.hostname,
port: u.port || 80,
path: u.pathname,
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
timeout: 2000,
}, (res) => { res.resume(); });
req.on("error", () => {});
req.on("timeout", () => req.destroy());
- req.write(body);
+ req.write(data);
req.end();
} catch (_) { /* niemals weiterwerfen */ }
}
/**
- * Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
- * kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
+ * Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
*/
-function _attachToolHook(subprocess) {
+function _emitToolEvent(toolName) {
+ if (!toolName) return;
+ _postJson(TOOL_HOOK_URL, { tool: String(toolName) });
+}
+
+/**
+ * Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
+ * kind: "start" | "text" | "tool_use" | "tool_result" | "end"
+ */
+function _emitStreamEvent(requestId, kind, fields) {
+ _postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
+}
+
+function _truncate(str, max) {
+ if (typeof str !== "string") str = String(str ?? "");
+ if (str.length <= max) return { text: str, truncatedBytes: 0 };
+ return { text: str.slice(0, max), truncatedBytes: str.length - max };
+}
+
+// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
+// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
+// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
+const _activeSubprocesses = new Map();
+function _trackSubprocess(requestId, subprocess) {
+ _activeSubprocesses.set(requestId, subprocess);
+ const cleanup = () => _activeSubprocesses.delete(requestId);
+ subprocess.on("close", cleanup);
+ subprocess.on("error", cleanup);
+}
+
+/**
+ * Hookt assistant + user Events und pusht beides an Bridge:
+ * - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
+ * - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
+ */
+function _attachToolHook(subprocess, requestId) {
subprocess.on("assistant", (message) => {
try {
const blocks = message?.message?.content || [];
for (const b of blocks) {
- if (b && b.type === "tool_use" && b.name) {
- _emitToolEvent(b.name);
+ if (!b) continue;
+ if (b.type === "tool_use") {
+ if (b.name) _emitToolEvent(b.name);
+ const inputStr = b.input ? JSON.stringify(b.input) : "";
+ const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
+ _emitStreamEvent(requestId, "tool_use", {
+ id: b.id || null,
+ name: b.name || "",
+ input: inp.text,
+ inputTruncatedBytes: inp.truncatedBytes,
+ });
+ } else if (b.type === "text" && b.text) {
+ _emitStreamEvent(requestId, "text", { text: b.text });
+ } else if (b.type === "thinking" && b.thinking) {
+ // Wenn das Modell Extended Thinking emittiert — selten in
+ // Claude Code CLI, aber moeglich. Markieren wir extra.
+ _emitStreamEvent(requestId, "thinking", { text: b.thinking });
+ }
+ }
+ } catch (_) { /* fail-open */ }
+ });
+ // user-Events enthalten tool_result-Blocks
+ subprocess.on("user", (message) => {
+ try {
+ const blocks = message?.message?.content || [];
+ for (const b of blocks) {
+ if (b && b.type === "tool_result") {
+ let content = "";
+ if (typeof b.content === "string") content = b.content;
+ else if (Array.isArray(b.content)) {
+ content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
+ }
+ const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
+ _emitStreamEvent(requestId, "tool_result", {
+ id: b.tool_use_id || null,
+ content: out.text,
+ truncatedBytes: out.truncatedBytes,
+ isError: b.is_error === true,
+ });
}
}
} catch (_) { /* fail-open */ }
@@ -86,9 +169,14 @@ export async function handleChatCompletions(req, res) {
// Convert to CLI input format
const cliInput = openaiToCli(body);
const subprocess = new ClaudeSubprocess();
- // ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
- // Greift fuer beide Branches (stream + non-stream).
- _attachToolHook(subprocess);
+ // ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
+ // Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
+ _attachToolHook(subprocess, requestId);
+ _trackSubprocess(requestId, subprocess);
+ _emitStreamEvent(requestId, "start", { model: body.model || null });
+ subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
+ subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
+ subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
if (stream) {
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
}
@@ -306,4 +394,55 @@ export function handleHealth(_req, res) {
timestamp: new Date().toISOString(),
});
}
+
+// ── Not-Aus Side-Channel ───────────────────────────────────
+//
+// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
+// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
+// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
+// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
+// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
+const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
+const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
+
+function _cancelAll() {
+ const ids = Array.from(_activeSubprocesses.keys());
+ let killed = 0;
+ for (const [id, subp] of _activeSubprocesses) {
+ try {
+ subp.kill();
+ killed++;
+ } catch (e) {
+ console.error("[aria-not-aus] kill failed for", id, e?.message);
+ }
+ }
+ _activeSubprocesses.clear();
+ return { killed, requestIds: ids };
+}
+
+try {
+ const internalServer = http.createServer((req, res) => {
+ if (req.method === "POST" && req.url === "/cancel-all") {
+ const result = _cancelAll();
+ console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: true, ...result }));
+ return;
+ }
+ if (req.method === "GET" && req.url === "/health") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
+ return;
+ }
+ res.writeHead(404).end();
+ });
+ internalServer.on("error", (err) => {
+ console.error("[aria-not-aus] internal listener error:", err.message);
+ });
+ internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
+ console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
+ });
+} catch (e) {
+ console.error("[aria-not-aus] startup failed:", e?.message);
+}
//# sourceMappingURL=routes.js.map
\ No newline at end of file
diff --git a/rvs/server.js b/rvs/server.js
index 2c86bf8..d459f87 100644
--- a/rvs/server.js
+++ b/rvs/server.js
@@ -40,6 +40,7 @@ const ALLOWED_TYPES = new Set([
"service_status",
"config_request",
"flux_request", "flux_response",
+ "agent_stream",
]);
// Token-Raum: token -> { clients: Set
}