diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 62d2012..d6d1c84 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -2444,6 +2444,24 @@ const ChatScreen: React.FC = () => { } }, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]); + // Zwischenruf: waehrend ARIA arbeitet eine Korrektur MITTEN in den laufenden + // Turn schieben — NICHT in die Queue, KEIN Abbruch. Geht als 'interject' ueber + // RVS an die Bridge → Proxy → laufender Subprozess (greift es an der naechsten + // Tool-Grenze auf). Sichtbar nur, wenn der aktive Kontext gerade arbeitet. + const sendInterject = useCallback(() => { + const text = inputText.trim(); + if (!text) return; + const activePid = focusedProjectIdRef.current; + rvs.send('interject' as any, { projectId: activePid, text }); + // Lokale Bubble zur Rueckmeldung (laeuft NICHT durch Send/Queue). + setMessages(prev => capMessages([...prev, { + id: nextId(), sender: 'user', text: `📣 Zwischenruf: ${text}`, + timestamp: Date.now(), projectId: activePid, + }])); + projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' }; + setInputText(''); + }, [inputText]); + // --- Rendering --- const renderMessage = ({ item }: { item: ChatMessage }) => { @@ -3218,9 +3236,19 @@ const ChatScreen: React.FC = () => { {/* Senden oder Sprache */} {inputText.trim() || pendingAttachments.length > 0 ? ( - - {'\u2B06\uFE0F'} - + <> + {/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und + Text da ist. Schiebt die Korrektur in den laufenden Turn statt + sie anzustellen. */} + {inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? ( + + {'\uD83D\uDCE3'} + + ) : null} + + {'\u2B06\uFE0F'} + + ) : ( <> None: + """Zwischenruf: schiebt eine User-Message in den laufenden Turn dieses + Kontexts (proxy-internes /interject) — ohne Abbruch. claude greift sie + an der naechsten Tool-Grenze auf.""" + url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/interject" + data = json.dumps({"projectId": project_id or "", "text": text}).encode("utf-8") + + def _do_request(): + try: + req = urllib.request.Request( + url, method="POST", data=data, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=3) as resp: + return resp.status, resp.read().decode("utf-8", "ignore")[:200] + except Exception as e: + return f"error: {e}", "" + + status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request) + logger.info("[interject] proxy /interject project=%s: %s %s", + project_id or "(main)", status, body) + async def _emit_activity(self, activity: str, tool: str = "", force: bool = False, project_id: str = "") -> None: """Sendet agent_activity an die App — nur wenn sich der State geaendert hat. diff --git a/diagnostic/index.html b/diagnostic/index.html index 27ce6ed..bf5464a 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -328,6 +328,7 @@ + @@ -345,6 +346,7 @@
+
@@ -2244,6 +2246,18 @@ diagCtxStates[pid] = 'running'; renderDiagQueue(); } + // Zwischenruf: waehrend ARIA arbeitet eine Korrektur in den laufenden Turn + // schieben — NICHT anstellen, NICHT abbrechen. + function interjectDiag(inputId) { + const input = document.getElementById(inputId || 'chat-input'); + const text = (input.value || '').trim(); + if (!text) return; + send({ action: 'interject', text, projectId: focusedContextId }); + addChat('sent', '📣 Zwischenruf: ' + text, 'interject', { projectId: focusedContextId }); + diagDrafts[focusedContextId] = ''; + localStorage.setItem('diag_ctx_drafts', JSON.stringify(diagDrafts)); + input.value = ''; + } function advanceDiagQueue(pid) { const q = diagCtxQueues[pid] || []; if (q.length === 0) { diagCtxStates[pid] = 'idle'; renderDiagQueue(); return; } diff --git a/diagnostic/server.js b/diagnostic/server.js index 51cd314..74c9d52 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -2455,6 +2455,14 @@ wss.on("connection", (ws) => { } 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 === "interject") { + // Zwischenruf: in den laufenden Turn schieben (kein Abbruch, keine + // Queue) → RVS interject → Bridge → Proxy /interject. + const t = String(msg.text || ""); + if (t.trim()) { + sendToRVS_raw({ type: "interject", payload: { projectId: msg.projectId || "", text: t }, timestamp: Date.now() }); + log("info", "server", "Zwischenruf an RVS (project=" + (msg.projectId || "(main)") + "): " + t.slice(0, 60)); + } } else if (msg.action === "reconnect_gateway") { connectGateway(); } else if (msg.action === "reconnect_rvs") { diff --git a/docker-compose.yml b/docker-compose.yml index 292be7b..384733f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,11 +11,7 @@ services: npm install -g @anthropic-ai/claude-code claude-max-api-proxy && DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && - sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js && - sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js && - sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js && - sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js && - sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js && + cp /proxy-patches/manager.js $$DIST/subprocess/manager.js && cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js && cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js && cp /proxy-patches/routes.js $$DIST/server/routes.js && diff --git a/proxy-patches/manager.js b/proxy-patches/manager.js new file mode 100644 index 0000000..2ad1181 --- /dev/null +++ b/proxy-patches/manager.js @@ -0,0 +1,256 @@ +/** + * Claude Code CLI Subprocess Manager — ARIA-Patch + * + * Basis: claude-max-api-proxy dist/subprocess/manager.js, plus die bisher per + * sed in docker-compose.yml eingespielten Anpassungen (dangerously-skip- + * permissions, system-prompt, 24h-Timeout, Prompt via stdin) — hier fest im + * File, damit der groessere Zwischenruf-Umbau nicht per sed gefrickelt werden + * muss. Wird per `cp` ueber die npm-Version gelegt (siehe docker-compose.yml). + * + * ZWISCHENRUF (interject): Statt den Prompt als Text zu schreiben und stdin + * sofort zu schliessen (--print/text), laeuft claude jetzt im + * `--input-format stream-json`-Modus. Der initiale Prompt geht als + * stream-json User-Message rein, stdin bleibt OFFEN — so kann waehrend des + * laufenden Turns per sendMessage() eine weitere User-Message reingeschoben + * werden, die claude an der naechsten Tool-Grenze aufgreift (kein Abbruch). + * Bei 'result' (Turn fertig) wird stdin geschlossen, damit claude sauber + * beendet und die HTTP-Response (in routes.js an 'close' gebunden) rausgeht. + */ +import { spawn } from "child_process"; +import { EventEmitter } from "events"; +import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js"; +const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.) +export class ClaudeSubprocess extends EventEmitter { + process = null; + buffer = ""; + timeoutId = null; + isKilled = false; + _stdinClosed = false; + /** + * Start the Claude CLI subprocess with the given prompt + */ + async start(prompt, options) { + const args = this.buildArgs(prompt, options); + const timeout = options.timeout || DEFAULT_TIMEOUT; + return new Promise((resolve, reject) => { + try { + // Use spawn() for security - no shell interpretation + this.process = spawn("claude", args, { + cwd: options.cwd || process.cwd(), + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + // Set timeout + this.timeoutId = setTimeout(() => { + if (!this.isKilled) { + this.isKilled = true; + this.process?.kill("SIGTERM"); + this.emit("error", new Error(`Request timed out after ${timeout}ms`)); + } + }, timeout); + // Handle spawn errors (e.g., claude not found) + this.process.on("error", (err) => { + this.clearTimeout(); + if (err.message.includes("ENOENT")) { + reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code")); + } + else { + reject(err); + } + }); + // stdin BLEIBT OFFEN: initialen Prompt als stream-json User- + // Message schreiben; spaetere Zwischenrufe kommen via + // sendMessage(). Geschlossen wird bei 'result' (s. processBuffer). + this._writeUserMessage(prompt); + // Falls stdin (z.B. EPIPE) frueh stirbt: nicht crashen. + this.process.stdin?.on("error", () => {}); + console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`); + // Parse JSON stream from stdout + this.process.stdout?.on("data", (chunk) => { + const data = chunk.toString(); + console.error(`[Subprocess] Received ${data.length} bytes of stdout`); + this.buffer += data; + this.processBuffer(); + }); + // Capture stderr for debugging + this.process.stderr?.on("data", (chunk) => { + const errorText = chunk.toString().trim(); + if (errorText) { + // Don't emit as error unless it's actually an error + // Claude CLI may write debug info to stderr + console.error("[Subprocess stderr]:", errorText.slice(0, 200)); + } + }); + // Handle process close + this.process.on("close", (code) => { + console.error(`[Subprocess] Process closed with code: ${code}`); + this.clearTimeout(); + // Process any remaining buffer + if (this.buffer.trim()) { + this.processBuffer(); + } + this.emit("close", code); + }); + // Resolve immediately since we're streaming + resolve(); + } + catch (err) { + this.clearTimeout(); + reject(err); + } + }); + } + /** + * Build CLI arguments array + */ + buildArgs(prompt, options) { + const args = [ + "--print", // Non-interactive mode + "--output-format", + "stream-json", // JSON streaming output + "--verbose", // Required for stream-json + "--include-partial-messages", // Enable streaming chunks + "--input-format", + "stream-json", // ARIA: User-Messages via stdin (Zwischenruf) + "--model", + options.model, // Model alias (opus/sonnet/haiku) + "--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode", + ]; + if (options.sessionId) { + args.push("--session-id", options.sessionId); + } + return args; + } + /** + * Eine User-Message im stream-json-Input-Format an stdin schreiben. + * Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage). + */ + _writeUserMessage(text) { + const p = this.process; + if (!p || !p.stdin || p.stdin.destroyed || this._stdinClosed) + return false; + try { + p.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: String(text) } }) + "\n"); + return true; + } + catch (_) { + return false; + } + } + /** + * Zwischenruf: waehrend eines laufenden Turns eine weitere User-Message + * reinschieben. claude greift sie an der naechsten Tool-Grenze auf, ohne + * den Turn abzubrechen. Kein Effekt, wenn stdin schon geschlossen ist + * (Turn praktisch fertig) — dann ist der Zwischenruf schlicht zu spaet. + */ + sendMessage(text) { + return this._writeUserMessage(text); + } + /** + * stdin schliessen → claude beendet den stream-json-Input und exit't. + */ + _closeStdin() { + if (this._stdinClosed) + return; + this._stdinClosed = true; + try { + this.process?.stdin?.end(); + } + catch (_) { } + } + /** + * Process the buffer and emit parsed messages + */ + processBuffer() { + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() || ""; // Keep incomplete line + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) + continue; + try { + const message = JSON.parse(trimmed); + this.emit("message", message); + if (isContentDelta(message)) { + // Emit content delta for streaming + this.emit("content_delta", message); + } + else if (isAssistantMessage(message)) { + this.emit("assistant", message); + } + else if (isResultMessage(message)) { + this.emit("result", message); + // Turn fertig → stdin schliessen, sonst wartet claude im + // stream-json-Input auf weitere Messages und der Prozess + // (und damit die HTTP-Response) haengt fuer immer. + this._closeStdin(); + } + } + catch { + // Non-JSON output, emit as raw + this.emit("raw", trimmed); + } + } + } + /** + * Clear the timeout timer + */ + clearTimeout() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + /** + * Kill the subprocess + */ + kill(signal = "SIGTERM") { + if (!this.isKilled && this.process) { + this.isKilled = true; + this.clearTimeout(); + this.process.kill(signal); + } + } + /** + * Check if the process is still running + */ + isRunning() { + return this.process !== null && !this.isKilled && this.process.exitCode === null; + } +} +/** + * Verify that Claude CLI is installed and accessible + */ +export async function verifyClaude() { + return new Promise((resolve) => { + const proc = spawn("claude", ["--version"], { stdio: "pipe" }); + let output = ""; + proc.stdout?.on("data", (chunk) => { + output += chunk.toString(); + }); + proc.on("error", () => { + resolve({ + ok: false, + error: "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code", + }); + }); + proc.on("close", (code) => { + if (code === 0) { + resolve({ ok: true, version: output.trim() }); + } + else { + resolve({ + ok: false, + error: "Claude CLI returned non-zero exit code", + }); + } + }); + }); +} +/** + * Check if Claude CLI is authenticated + */ +export async function verifyAuth() { + return { ok: true }; +} +//# sourceMappingURL=manager.js.map diff --git a/proxy-patches/routes.js b/proxy-patches/routes.js index 49bcb35..8aabc67 100644 --- a/proxy-patches/routes.js +++ b/proxy-patches/routes.js @@ -621,6 +621,28 @@ function _cancelByProject(projectId) { return { killed, requestIds: ids, projectId: pid }; } +// Zwischenruf: schiebt eine User-Message in den/die laufenden Subprozess(e) +// eines Kontexts, OHNE sie zu killen. claude greift sie an der naechsten Tool- +// Grenze auf (stream-json-Input, s. manager.js). Kein Treffer / stdin schon +// zu (Turn quasi fertig) → delivered=0. +function _interjectByProject(projectId, text) { + const pid = String(projectId || ""); + const ids = []; + let delivered = 0; + for (const [id, entry] of Array.from(_activeSubprocesses)) { + if (entry.projectId !== pid) continue; + try { + if (typeof entry.subprocess.sendMessage === "function" && entry.subprocess.sendMessage(text)) { + delivered++; + ids.push(id); + } + } catch (e) { + console.error("[aria-interject] sendMessage failed for", id, e?.message); + } + } + return { delivered, requestIds: ids, projectId: pid }; +} + try { const internalServer = http.createServer((req, res) => { if (req.method === "POST" && req.url === "/cancel-all") { @@ -645,6 +667,20 @@ try { }); return; } + if (req.method === "POST" && req.url === "/interject") { + // Body: {projectId, text}. Zwischenruf in den laufenden Turn. + let raw = ""; + req.on("data", (c) => { raw += c; if (raw.length > 65536) req.destroy(); }); + req.on("end", () => { + let projectId = "", text = ""; + try { const b = JSON.parse(raw || "{}"); projectId = String(b.projectId || ""); text = String(b.text || ""); } catch (_) {} + const result = text ? _interjectByProject(projectId, text) : { delivered: 0, requestIds: [], projectId }; + console.warn("[aria-interject] /interject project=%s — delivered %d", projectId || "(main)", result.delivered); + 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 })); diff --git a/rvs/server.js b/rvs/server.js index be25f0e..d42023d 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -17,7 +17,7 @@ const ALLOWED_TYPES = new Set([ "file_request", "file_response", "file_saved", "stt_result", "config", "tts_request", "xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved", "update_check", "update_available", "update_download", "update_data", - "agent_activity", "cancel_request", + "agent_activity", "cancel_request", "interject", "audio_pcm", "file_from_aria", "container_restart",