feat(zwischenruf): Korrektur mitten in den laufenden Turn schieben

Neben Senden ein 'Zwischenruf' (App: oranger Button, nur wenn ARIA im
aktiven Kontext arbeitet; Diagnostic: Buttons neben beiden Senden). Geht
NICHT in die Queue und bricht NICHT ab — die Nachricht wird in den
laufenden claude-Subprozess geschoben; er greift sie an der naechsten
Tool-Grenze auf.

Technik:
- proxy-patches/manager.js: claude laeuft jetzt im --input-format
  stream-json-Modus, initialer Prompt als stream-json User-Message,
  stdin bleibt OFFEN; sendMessage() schiebt weitere User-Messages nach;
  bei 'result' wird stdin geschlossen (Turn endet sauber). Ersetzt die
  bisherigen sed-Patches (jetzt volle Datei via cp, docker-compose.yml).
- proxy-patches/routes.js: Side-Channel POST /interject {projectId,text}
  → subprocess.sendMessage der Kontext-Subprozesse.
- rvs: 'interject' erlaubt. bridge: RVS interject → Proxy /interject.
- App/Diagnostic: Zwischenruf-Buttons + lokale '📣 Zwischenruf'-Bubble.

Empirisch verifiziert: mid-turn injizierte Message wird an der naechsten
Tool-Grenze aufgegriffen (nicht mitten in einem blockierenden Befehl).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 05:05:13 +02:00
co-authored by Claude Opus 4.8
parent 2ad8c2f245
commit 648e3b04fd
8 changed files with 395 additions and 9 deletions
+256
View File
@@ -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
+36
View File
@@ -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 }));