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>
257 lines
9.7 KiB
JavaScript
257 lines
9.7 KiB
JavaScript
/**
|
|
* 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
|