- --verbose ist Pflicht für --output-format stream-json - total_cost_usd statt cost_usd für Kosten - usage.input_tokens/output_tokens + Cache-Info - Session-ID aus system.init Events - rate_limit_event ignorieren - allowedTools entfernt (Claude entscheidet selbst) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
324 lines
8.8 KiB
JavaScript
324 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Claude Desktop — Bridge zwischen Tauri-Backend und Claude CLI
|
|
//
|
|
// Kommunikation: stdin/stdout als NDJSON (eine JSON-Zeile pro Nachricht)
|
|
//
|
|
// Eingehend (von Tauri):
|
|
// { "command": "message", "id": "req-1", "message": "Fixe den Bug..." }
|
|
// { "command": "stop", "id": "req-2" }
|
|
//
|
|
// Ausgehend (an Tauri):
|
|
// { "type": "event", "event": "ready" }
|
|
// { "type": "event", "event": "agent-started", "payload": { "id": "...", "type": "Main" } }
|
|
// { "type": "event", "event": "text", "payload": { "text": "..." } }
|
|
// { "type": "event", "event": "tool-start", "payload": { "id": "...", "tool": "Read", "input": {...} } }
|
|
// { "type": "event", "event": "tool-end", "payload": { "id": "...", "success": true } }
|
|
// { "type": "event", "event": "result", "payload": { "cost": 0.01, "tokens": {...} } }
|
|
// { "type": "event", "event": "agent-stopped", "payload": { "id": "..." } }
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { createInterface } from 'node:readline';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
// Aktive Claude-Prozesse
|
|
const activeProcesses = new Map();
|
|
|
|
// Session-ID für --resume
|
|
let currentSessionId = null;
|
|
|
|
// ============ Kommunikation mit Tauri ============
|
|
|
|
function sendToTauri(msg) {
|
|
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
}
|
|
|
|
function sendEvent(event, payload = {}) {
|
|
sendToTauri({ type: 'event', event, payload });
|
|
}
|
|
|
|
function sendResponse(id, result) {
|
|
sendToTauri({ type: 'response', id, result });
|
|
}
|
|
|
|
function sendError(id, error) {
|
|
sendToTauri({ type: 'response', id, error });
|
|
}
|
|
|
|
// ============ Claude CLI aufrufen ============
|
|
|
|
function spawnClaude(message, requestId) {
|
|
const agentId = randomUUID();
|
|
const args = [
|
|
'-p', // Print-Modus (nicht interaktiv)
|
|
'--output-format', 'stream-json', // Streaming JSON Events
|
|
'--verbose', // Pflicht für stream-json
|
|
];
|
|
|
|
// Bei Fortsetzung letzte Session verwenden
|
|
if (currentSessionId) {
|
|
args.push('--resume', currentSessionId);
|
|
}
|
|
|
|
// Nachricht als Argument
|
|
args.push(message);
|
|
|
|
// Claude CLI Pfad — npx oder global installiert
|
|
const claudePath = process.env.CLAUDE_PATH || 'claude';
|
|
|
|
const proc = spawn(claudePath, args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
// Keine interaktive UI
|
|
CLAUDE_CODE_HEADLESS: '1',
|
|
},
|
|
});
|
|
|
|
activeProcesses.set(agentId, { proc, requestId });
|
|
|
|
sendEvent('agent-started', {
|
|
id: agentId,
|
|
type: 'Main',
|
|
task: message.substring(0, 100),
|
|
});
|
|
|
|
// NDJSON-Zeilen von stdout parsen
|
|
let fullText = '';
|
|
let toolCounter = 0;
|
|
|
|
const rl = createInterface({ input: proc.stdout });
|
|
|
|
rl.on('line', (line) => {
|
|
if (!line.trim()) return;
|
|
|
|
try {
|
|
const event = JSON.parse(line);
|
|
handleClaudeEvent(event, agentId, requestId, { fullText: () => fullText, addText: (t) => { fullText += t; } });
|
|
} catch {
|
|
// Nicht-JSON Zeile — als Text weiterleiten
|
|
fullText += line;
|
|
sendEvent('text', { text: line });
|
|
}
|
|
});
|
|
|
|
// stderr für Debug-Infos
|
|
const stderrRl = createInterface({ input: proc.stderr });
|
|
stderrRl.on('line', (line) => {
|
|
if (process.env.CLAUDE_DEBUG) {
|
|
process.stderr.write(`[claude-stderr] ${line}\n`);
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
activeProcesses.delete(agentId);
|
|
|
|
sendEvent('agent-stopped', {
|
|
id: agentId,
|
|
code,
|
|
});
|
|
|
|
if (activeProcesses.size === 0) {
|
|
sendEvent('all-stopped');
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
sendError(requestId, `Claude konnte nicht gestartet werden: ${err.message}`);
|
|
activeProcesses.delete(agentId);
|
|
});
|
|
|
|
return agentId;
|
|
}
|
|
|
|
// ============ Claude Stream-JSON Events verarbeiten ============
|
|
|
|
function handleClaudeEvent(event, agentId, requestId, textState) {
|
|
// Das stream-json Format hat verschiedene Event-Typen:
|
|
// { "type": "assistant", "message": { "content": [...], ... } }
|
|
// { "type": "content_block_start", "content_block": { "type": "text", "text": "..." } }
|
|
// { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "..." } }
|
|
// { "type": "content_block_start", "content_block": { "type": "tool_use", "name": "Read", ... } }
|
|
// { "type": "content_block_delta", "delta": { "type": "input_json_delta", ... } }
|
|
// { "type": "result", "result": "...", "cost_usd": 0.01, ... }
|
|
// { "type": "system", "subtype": "session_id", "session_id": "..." }
|
|
|
|
const type = event.type;
|
|
|
|
switch (type) {
|
|
case 'system':
|
|
if (event.session_id) {
|
|
currentSessionId = event.session_id;
|
|
}
|
|
break;
|
|
|
|
case 'rate_limit_event':
|
|
// Ignorieren
|
|
break;
|
|
|
|
case 'assistant':
|
|
// Vollständige Nachricht — Inhalt extrahieren
|
|
if (event.message?.content) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === 'text') {
|
|
sendEvent('text', { text: block.text });
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'content_block_start':
|
|
if (event.content_block?.type === 'text') {
|
|
// Text-Block startet
|
|
if (event.content_block.text) {
|
|
sendEvent('text', { text: event.content_block.text });
|
|
}
|
|
} else if (event.content_block?.type === 'tool_use') {
|
|
// Tool-Aufruf startet
|
|
sendEvent('tool-start', {
|
|
id: event.content_block.id || randomUUID(),
|
|
tool: event.content_block.name,
|
|
input: event.content_block.input || {},
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'content_block_delta':
|
|
if (event.delta?.type === 'text_delta' && event.delta.text) {
|
|
sendEvent('text', { text: event.delta.text });
|
|
}
|
|
break;
|
|
|
|
case 'content_block_stop':
|
|
// Block fertig — wenn es ein Tool war, Tool-Ende senden
|
|
break;
|
|
|
|
case 'tool_result':
|
|
case 'tool_use_result':
|
|
sendEvent('tool-end', {
|
|
id: event.tool_use_id || event.id || '',
|
|
success: !event.is_error,
|
|
output: typeof event.content === 'string'
|
|
? event.content
|
|
: JSON.stringify(event.content)?.substring(0, 500),
|
|
});
|
|
break;
|
|
|
|
case 'result':
|
|
// Endergebnis mit Kosten
|
|
sendEvent('result', {
|
|
text: event.result || '',
|
|
cost: event.total_cost_usd || event.cost_usd || 0,
|
|
tokens: {
|
|
input: event.usage?.input_tokens || 0,
|
|
output: event.usage?.output_tokens || 0,
|
|
cache_read: event.usage?.cache_read_input_tokens || 0,
|
|
cache_create: event.usage?.cache_creation_input_tokens || 0,
|
|
},
|
|
session_id: event.session_id || currentSessionId,
|
|
duration_ms: event.duration_ms || 0,
|
|
num_turns: event.num_turns || 0,
|
|
});
|
|
|
|
// Session-ID für Fortsetzung merken
|
|
if (event.session_id) {
|
|
currentSessionId = event.session_id;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// Unbekannte Events durchreichen
|
|
if (event.subagent_id || event.agent_id) {
|
|
// Sub-Agent Events
|
|
sendEvent('subagent-event', {
|
|
agentId: event.subagent_id || event.agent_id,
|
|
type,
|
|
data: event,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ============ Alle Prozesse stoppen ============
|
|
|
|
function stopAll() {
|
|
for (const [agentId, { proc }] of activeProcesses) {
|
|
try {
|
|
proc.kill('SIGTERM');
|
|
// Nach 2 Sekunden hart killen
|
|
setTimeout(() => {
|
|
try { proc.kill('SIGKILL'); } catch {}
|
|
}, 2000);
|
|
} catch {}
|
|
}
|
|
activeProcesses.clear();
|
|
sendEvent('all-stopped');
|
|
}
|
|
|
|
// ============ Eingehende Befehle verarbeiten ============
|
|
|
|
function handleCommand(msg) {
|
|
switch (msg.command) {
|
|
case 'message':
|
|
if (!msg.message) {
|
|
sendError(msg.id, 'Keine Nachricht angegeben');
|
|
return;
|
|
}
|
|
const agentId = spawnClaude(msg.message, msg.id);
|
|
sendResponse(msg.id, { agentId, status: 'gestartet' });
|
|
break;
|
|
|
|
case 'stop':
|
|
stopAll();
|
|
sendResponse(msg.id, { status: 'gestoppt' });
|
|
break;
|
|
|
|
case 'status':
|
|
const agents = [];
|
|
for (const [id, { proc }] of activeProcesses) {
|
|
agents.push({
|
|
id,
|
|
running: !proc.killed,
|
|
pid: proc.pid,
|
|
});
|
|
}
|
|
sendResponse(msg.id, { agents, sessionId: currentSessionId });
|
|
break;
|
|
|
|
case 'ping':
|
|
sendResponse(msg.id, { pong: true });
|
|
break;
|
|
|
|
default:
|
|
sendError(msg.id, `Unbekannter Befehl: ${msg.command}`);
|
|
}
|
|
}
|
|
|
|
// ============ Main ============
|
|
|
|
// stdin zeilenweise lesen
|
|
const rl = createInterface({ input: process.stdin });
|
|
|
|
rl.on('line', (line) => {
|
|
if (!line.trim()) return;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
handleCommand(msg);
|
|
} catch (err) {
|
|
process.stderr.write(`Ungültige Eingabe: ${err.message}\n`);
|
|
}
|
|
});
|
|
|
|
// Sauber beenden
|
|
process.on('SIGTERM', () => {
|
|
stopAll();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
stopAll();
|
|
process.exit(0);
|
|
});
|
|
|
|
// Bereit-Signal senden
|
|
sendEvent('ready', { version: '0.1.0', pid: process.pid });
|