All checks were successful
Build AppImage / build (push) Successful in 10m19s
Accept/Reject DiffView: - Bridge sendet checkpoint-before/after Events bei Edit/Write - Rust speichert Datei-Snapshots in SQLite (content_before/after) - Frontend zeigt interaktive DiffView mit Accept/Reject-Buttons - Bei Reject: Datei wird automatisch auf Zustand VOR der Aenderung zurueckgesetzt - Neues Modul: checkpoint.rs mit Accept/Reject/Rewind Commands @-Mentions (Datei-Referenzen): - @datei.ts im Chat-Input oeffnet Fuzzy-Autocomplete - Fuzzy-Suche scannt Projektverzeichnis (max 5000 Dateien) - Score-basiertes Ranking (Anfang, Separator, konsekutive Matches) - Bei Auswahl: Dateiinhalt wird in Prompt injiziert (als Code-Block) - @datei.ts#5-10 fuer Zeilenbereiche - Neue Komponente: FileMention.svelte Checkpoint/Rewind: - Automatische Snapshots bei jeder Dateiänderung (Edit/Write) - SQLite-Tabelle checkpoints mit content_before/content_after - Rewind: Alle Dateien ab einem Checkpoint zuruecksetzen - Commands: accept_change, reject_change, list_checkpoints, rewind_to_checkpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1194 lines
43 KiB
JavaScript
1194 lines
43 KiB
JavaScript
#!/usr/bin/env node
|
|
// Claude Desktop — Bridge via Claude Agent SDK
|
|
//
|
|
// Nutzt @anthropic-ai/claude-agent-sdk (query-Funktion)
|
|
// OAuth-Auth funktioniert automatisch (Claude Max Abo)
|
|
// Kein CLI-Spawn, kein Overhead — direkte SDK-Aufrufe
|
|
//
|
|
// Modi:
|
|
// stdio (Default): node claude-bridge.js
|
|
// UDS-Daemon: node claude-bridge.js --socket /tmp/claude-bridge.sock
|
|
|
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
import { createInterface } from 'node:readline';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { createServer } from 'node:net';
|
|
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
|
|
// ============ IPC-Modus erkennen ============
|
|
|
|
const socketArg = process.argv.indexOf('--socket');
|
|
const SOCKET_PATH = socketArg !== -1 ? process.argv[socketArg + 1] : null;
|
|
const PID_PATH = SOCKET_PATH ? SOCKET_PATH.replace('.sock', '.pid') : null;
|
|
const IS_DAEMON = !!SOCKET_PATH;
|
|
|
|
// Aktive UDS-Clients (bei Daemon-Modus)
|
|
let udsClients = new Set();
|
|
|
|
// Prozess am Leben halten + Heartbeat alle 30s
|
|
const keepAlive = setInterval(() => {
|
|
sendEvent('heartbeat', { ts: Date.now(), uptime: process.uptime() });
|
|
}, 30000);
|
|
if (!IS_DAEMON) process.stdin.resume();
|
|
|
|
// ============ State ============
|
|
|
|
let activeAbort = null;
|
|
let currentAgentId = null;
|
|
let currentModel = process.env.CLAUDE_MODEL || 'opus';
|
|
let isQueryRunning = false;
|
|
|
|
// Pending-Queue: Nachrichten die während einer laufenden query() eingehen
|
|
// werden hier gepuffert und nach dem aktuellen Turn automatisch abgearbeitet.
|
|
// So kann der User wie in Claude Code/VS Code Extension weiter tippen.
|
|
const pendingMessages = [];
|
|
|
|
// Tool-Changes: Bei Edit/Write wird der Diff an Rust/Frontend gemeldet
|
|
// damit der User Accept/Reject entscheiden kann (Post-Execution mit Rewind)
|
|
const CHANGE_TOOLS = ['Edit', 'Write'];
|
|
|
|
// Agent-Modus (solo | handlanger | experten | auto)
|
|
let agentMode = 'solo';
|
|
|
|
// Sticky Context (Schicht 1) — wird bei JEDEM API-Call injiziert
|
|
let stickyContext = '';
|
|
|
|
// MCP-Server Configs (vom Rust-Backend oder aus .claude.json geladen)
|
|
// Format: { "name": { type: "stdio", command: "...", args: [...], env: {...} } }
|
|
let mcpServerConfigs = {};
|
|
|
|
// Lokales Modell (Ollama) — für einfache Tasks ohne Cloud
|
|
let ollamaAvailable = false;
|
|
let ollamaEndpoint = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
let ollamaModel = process.env.OLLAMA_MODEL || 'qwen2.5-coder:7b';
|
|
|
|
// ============ Orchestrator Prompts ============
|
|
|
|
const ORCHESTRATOR_PROMPTS = {
|
|
handlanger: `
|
|
Du bist der HAUPT-AGENT im HANDLANGER-MODUS.
|
|
|
|
KRITISCH: Dir stehen NUR Task + TodoWrite zur Verfügung.
|
|
Du kannst NICHT direkt lesen, suchen oder ausführen — du MUSST delegieren!
|
|
|
|
Task-Tool mit den RICHTIGEN Sub-Agent-Typen:
|
|
- "general-purpose" — Standard-Agent mit VOLLEM Tool-Zugriff (Bash, Read, Write, Grep, Glob).
|
|
Nutze diesen für JEDE Aufgabe die Bash/Shell benötigt (ls, cat, find, grep auf System-Ebene).
|
|
- "Explore" — read-only Agent. NUR für reine Code-/Dateisuche innerhalb des Projekts.
|
|
Hat KEINEN Bash-Zugriff! Nicht für Systembefehle wie "ls /etc" verwenden.
|
|
|
|
Arbeitsweise (verbindlich):
|
|
1. Wähle den RICHTIGEN subagent_type basierend auf der Aufgabe.
|
|
2. Rufe das Task-Tool auf mit EXAKTER Anweisung.
|
|
3. Prüfe im Ergebnis das "tool_uses"-Feld. Wenn tool_uses:0 → Sub-Agent hat halluziniert!
|
|
In dem Fall: Neuer Task-Call mit "general-purpose" statt "Explore".
|
|
4. Verarbeite das Ergebnis und gib dem User die Zusammenfassung.
|
|
|
|
Halluziniere NIEMALS Dateilisten aus dem Gedächtnis — delegiere immer real.
|
|
|
|
Beispiele:
|
|
- "List /etc files" → Task(subagent_type:"general-purpose", prompt:"Run 'ls -1 /etc | sort' and return the output")
|
|
- "Find handleError in src/" → Task(subagent_type:"Explore", prompt:"Grep for 'handleError' in src/")
|
|
- "Read /etc/hosts" → Task(subagent_type:"general-purpose", prompt:"cat /etc/hosts and return output")
|
|
`,
|
|
|
|
experten: `
|
|
Du bist der HAUPT-AGENT und arbeitest im EXPERTEN-MODUS.
|
|
|
|
WICHTIG: Du koordinierst vier autonome Experten-Agents!
|
|
|
|
Task-Tool Sub-Agent-Typen (autonome Experten):
|
|
- **research**: Durchsucht Code/Docs, findet Infos. Wähle diesen für "Finde heraus…", "Wo ist…"
|
|
- **implement**: Schreibt Code nach Best-Practices. Wähle diesen für "Implementiere…", "Baue…"
|
|
- **test**: Schreibt und führt Tests. Wähle diesen für "Teste…"
|
|
- **review**: Prüft Code auf Qualität/Sicherheit. Wähle diesen für "Prüfe…"
|
|
|
|
Arbeitsweise:
|
|
1. ZERLEGE die Aufgabe in Experten-Bereiche
|
|
2. DELEGIERE via Task(subagent_type: "research"|"implement"|"test"|"review", prompt: "...")
|
|
3. Formuliere das WAS, nicht das WIE — die Experten planen selbst
|
|
4. Integriere die Zusammenfassungen, orchestriere weitere Schritte
|
|
|
|
Beispiel-Delegationen:
|
|
- Task(subagent_type:"research", prompt:"Finde heraus wie Authentication implementiert ist")
|
|
- Task(subagent_type:"implement", prompt:"Füge OAuth2-Support hinzu mit Token-Refresh")
|
|
- Task(subagent_type:"test", prompt:"Teste die neue Auth-Funktionalität")
|
|
- Task(subagent_type:"review", prompt:"Prüfe die OAuth-Implementierung auf Sicherheitsprobleme")
|
|
`,
|
|
|
|
auto: `
|
|
Du analysierst Aufgaben und wählst den optimalen Arbeitsmodus.
|
|
|
|
Entscheide basierend auf:
|
|
- SOLO: Einfache, schnelle Aufgaben (Typo fix, Code erklären, einzelne Datei ändern)
|
|
- HANDLANGER: Koordinations-intensive Aufgaben (viele Dateien lesen, Bug in großer Codebase)
|
|
- EXPERTEN: Komplexe Features (neues System implementieren, großes Refactoring)
|
|
|
|
Teile deine Wahl am Anfang mit: "[Modus: X] Begründung"
|
|
`,
|
|
};
|
|
|
|
// ============ Custom Sub-Agent Definitionen ============
|
|
// Werden je nach Modus an query() übergeben
|
|
|
|
const HANDLANGER_AGENTS = {
|
|
worker: {
|
|
description: 'Führt exakte Anweisungen des Haupt-Agents aus (lesen, suchen, triviale Edits). Denkt NICHT selbst, berichtet komprimiert zurück.',
|
|
// Günstiges Modell — Handlanger muss nicht planen
|
|
model: 'haiku',
|
|
tools: ['Read', 'Grep', 'Glob', 'Bash', 'Edit', 'Write'],
|
|
prompt: `Du bist ein HANDLANGER-Agent.
|
|
|
|
WICHTIG:
|
|
1. Führe GENAU aus was der Haupt-Agent verlangt — denke NICHT selbst
|
|
2. Plane keine eigene Herangehensweise
|
|
3. Berichte KOMPRIMIERT zurück (max. 500 Tokens):
|
|
- Bei Read: Relevante Zeilen/Passagen, keine Volltext-Dumps
|
|
- Bei Grep: Liste der Treffer mit Zeilennummern
|
|
- Bei Bash: Exit-Code + wichtigste Ausgabe-Zeilen
|
|
- Bei Edit/Write: Bestätigung was geändert wurde
|
|
4. Keine Erklärungen, keine Vorschläge — nur das verlangte Ergebnis`,
|
|
},
|
|
};
|
|
|
|
const EXPERTEN_AGENTS = {
|
|
research: {
|
|
description: 'Durchsucht Code und Dokumentation autonom. Findet selbst heraus was relevant ist.',
|
|
model: 'inherit',
|
|
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
prompt: `Du bist ein RESEARCH-Experte.
|
|
|
|
Du bekommst eine Frage — plane selbst wie du sie beantwortest:
|
|
- Wähle selbst welche Dateien/Patterns zu suchen sind
|
|
- Priorisiere wichtige Infos
|
|
- Berichte strukturiert: Was gefunden, wo (Pfade/Zeilen), warum relevant
|
|
- Max 1000 Tokens Zusammenfassung`,
|
|
},
|
|
implement: {
|
|
description: 'Schreibt Code-Änderungen nach Best-Practices. Entscheidet selbst über Architektur und Details.',
|
|
model: 'inherit',
|
|
tools: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash'],
|
|
prompt: `Du bist ein IMPLEMENT-Experte.
|
|
|
|
Du bekommst das WAS — entscheide selbst das WIE:
|
|
- Lies relevanten Code zum Verständnis
|
|
- Implementiere nach Best-Practices (Codierrichtlinien des Projekts beachten)
|
|
- Berichte: welche Dateien geändert, was war der Kern, was beibehalten
|
|
- Max 800 Tokens Zusammenfassung`,
|
|
},
|
|
test: {
|
|
description: 'Schreibt und führt Tests aus. Wählt selbst sinnvolle Testfälle.',
|
|
model: 'inherit',
|
|
tools: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash'],
|
|
prompt: `Du bist ein TEST-Experte.
|
|
|
|
Du bekommst ein Feature — wähle selbst passende Testfälle:
|
|
- Happy Path + sinnvolle Edge Cases
|
|
- Nutze vorhandene Test-Infrastruktur
|
|
- Berichte: Tests geschrieben (Anzahl), was ist abgedeckt, passed/failed
|
|
- Max 500 Tokens Zusammenfassung`,
|
|
},
|
|
review: {
|
|
description: 'Prüft Code auf Qualität, Sicherheit und Stil. Findet selbst Probleme.',
|
|
model: 'inherit',
|
|
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
prompt: `Du bist ein REVIEW-Experte.
|
|
|
|
Du bekommst Code zum Prüfen — finde selbst Probleme:
|
|
- Sicherheit (Injections, Secrets, Auth)
|
|
- Performance (N+1, unnötige Loops)
|
|
- Fehlerbehandlung (Boundary-Cases)
|
|
- Stil (Konsistenz mit Projekt)
|
|
- Berichte strukturiert nach Schwere (kritisch/warnung/info)
|
|
- Max 800 Tokens`,
|
|
},
|
|
};
|
|
|
|
// Subagent-Tracking
|
|
// Map: toolUseId → { agentId, parentId, type, task, depth }
|
|
const activeSubagents = new Map();
|
|
|
|
// Verfügbare Modelle
|
|
const AVAILABLE_MODELS = [
|
|
{ id: 'haiku', name: 'Claude Haiku', description: 'Schnell & günstig' },
|
|
{ id: 'sonnet', name: 'Claude Sonnet', description: 'Ausgewogen' },
|
|
{ id: 'opus', name: 'Claude Opus', description: 'Leistungsstark' },
|
|
];
|
|
|
|
// Tools die Subagents spawnen
|
|
const SUBAGENT_TOOLS = ['Task', 'Agent', 'spawn_agent', 'launch_agent'];
|
|
|
|
// Subagent-Typ aus Tool-Input ermitteln
|
|
function getSubagentType(toolName, input) {
|
|
if (input?.subagent_type) return input.subagent_type.toLowerCase();
|
|
if (input?.agent_type) return input.agent_type.toLowerCase();
|
|
|
|
// Fallback basierend auf description/prompt
|
|
const desc = (input?.description || input?.prompt || '').toLowerCase();
|
|
if (desc.includes('explore') || desc.includes('search') || desc.includes('find')) return 'explore';
|
|
if (desc.includes('plan') || desc.includes('design')) return 'plan';
|
|
if (desc.includes('bash') || desc.includes('command') || desc.includes('terminal')) return 'bash';
|
|
if (desc.includes('code') || desc.includes('implement') || desc.includes('write')) return 'code';
|
|
if (desc.includes('test') || desc.includes('verify')) return 'test';
|
|
if (desc.includes('review') || desc.includes('check')) return 'review';
|
|
|
|
return 'explore'; // Default
|
|
}
|
|
|
|
// ============ Kommunikation mit Tauri ============
|
|
|
|
function sendToTauri(msg) {
|
|
const line = JSON.stringify(msg) + '\n';
|
|
|
|
if (IS_DAEMON) {
|
|
// UDS-Modus: An alle verbundenen Clients senden
|
|
for (const client of udsClients) {
|
|
try {
|
|
client.write(line);
|
|
} catch (err) {
|
|
process.stderr.write(`UDS-Client Schreibfehler: ${err.message}\n`);
|
|
udsClients.delete(client);
|
|
}
|
|
}
|
|
} else {
|
|
// stdio-Modus: An stdout (wie bisher)
|
|
process.stdout.write(line);
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// ============ Monitor-Events ============
|
|
|
|
// Sendet ein Event für den System-Monitor
|
|
function sendMonitorEvent(type, summary, details = {}, options = {}) {
|
|
sendEvent('monitor', {
|
|
type, // 'api' | 'hook' | 'tool' | 'mcp' | 'agent' | 'error' | 'debug'
|
|
summary, // Einzeiler für Kompakt-Ansicht
|
|
details, // Vollständige Daten
|
|
agentId: options.agentId || currentAgentId,
|
|
durationMs: options.durationMs,
|
|
error: options.error,
|
|
});
|
|
}
|
|
|
|
// AUTO-Modus: Heuristik wählt passenden Modus basierend auf Aufgabe
|
|
// Rückgabe: 'solo' | 'handlanger' | 'experten'
|
|
function chooseAutoMode(message) {
|
|
const text = (message || '').toLowerCase();
|
|
const charCount = text.length;
|
|
|
|
// Keywords die klar auf Experten-Aufgaben hinweisen (komplexe, parallelisierbare Arbeit)
|
|
const expertKeywords = [
|
|
'implementiere', 'implementier ', 'refactor', 'architektur', 'entwickle',
|
|
'erstelle feature', 'feature ', 'design', 'baue ', 'optimiere',
|
|
'migration', 'umbau', 'umstruktur',
|
|
];
|
|
|
|
// Keywords die auf Handlanger-Aufgaben hinweisen (viel koordinieren/sammeln)
|
|
const handlangerKeywords = [
|
|
'lies ', 'suche ', 'finde ', 'zeig mir ', 'untersuche',
|
|
'analysiere', 'durchsuche', 'alle dateien', 'sammle',
|
|
'liste alle', 'vergleiche',
|
|
];
|
|
|
|
// Klar triviale Aufgaben → solo
|
|
if (charCount < 80) return 'solo';
|
|
|
|
if (expertKeywords.some(kw => text.includes(kw))) return 'experten';
|
|
if (handlangerKeywords.some(kw => text.includes(kw)) && charCount > 120) return 'handlanger';
|
|
|
|
// Längere Nachrichten ohne klare Keywords → handlanger (safer default)
|
|
if (charCount > 300) return 'handlanger';
|
|
|
|
return 'solo';
|
|
}
|
|
|
|
// Tool-Input für Logging kürzen (sensitive Daten maskieren)
|
|
function summarizeToolInput(tool, input) {
|
|
if (!input) return '';
|
|
|
|
// Bestimmte Tools speziell behandeln
|
|
if (tool === 'Read') {
|
|
return input.file_path || '';
|
|
}
|
|
if (tool === 'Edit' || tool === 'Write') {
|
|
const path = input.file_path || '';
|
|
const size = input.content ? `(${input.content.length} chars)` : '';
|
|
return `${path} ${size}`;
|
|
}
|
|
if (tool === 'Grep') {
|
|
return `"${input.pattern}" in ${input.path || '.'}`;
|
|
}
|
|
if (tool === 'Bash') {
|
|
const cmd = input.command || '';
|
|
return cmd.length > 50 ? cmd.substring(0, 50) + '...' : cmd;
|
|
}
|
|
if (tool === 'Task') {
|
|
return input.description || input.prompt || '';
|
|
}
|
|
|
|
// Default: Erstes String-Feld nehmen
|
|
const firstString = Object.values(input).find(v => typeof v === 'string');
|
|
if (firstString) {
|
|
return firstString.length > 50 ? firstString.substring(0, 50) + '...' : firstString;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ============ Claude Agent SDK ============
|
|
|
|
async function sendMessage(message, requestId, model = null, contextOverride = null, resumeSessionId = null) {
|
|
// Wenn bereits eine query() läuft: Nachricht in Pending-Queue puffern
|
|
// und sofort bestätigen. Wird nach aktuellem Turn automatisch abgearbeitet.
|
|
if (isQueryRunning) {
|
|
pendingMessages.push({ message, requestId, model, contextOverride, resumeSessionId });
|
|
sendResponse(requestId, { status: 'queued', position: pendingMessages.length });
|
|
sendMonitorEvent('agent', `Nachricht gepuffert (Position ${pendingMessages.length})`, {
|
|
messageLength: message.length,
|
|
queueSize: pendingMessages.length,
|
|
});
|
|
return;
|
|
}
|
|
|
|
isQueryRunning = true;
|
|
|
|
// Modell für diese Anfrage (Parameter > State > Default)
|
|
const useModel = model || currentModel;
|
|
|
|
// Context für diese Anfrage (Parameter > State)
|
|
const useContext = contextOverride || stickyContext;
|
|
|
|
currentAgentId = randomUUID();
|
|
activeAbort = new AbortController();
|
|
|
|
const isResuming = !!resumeSessionId;
|
|
|
|
sendEvent('agent-started', {
|
|
id: currentAgentId,
|
|
type: 'Main',
|
|
task: message.substring(0, 100),
|
|
model: useModel,
|
|
resuming: isResuming,
|
|
});
|
|
|
|
// Monitor: Agent gestartet
|
|
const resumeInfo = isResuming ? ' (Fortsetzung)' : '';
|
|
sendMonitorEvent('agent', `Main Agent gestartet (${useModel})${resumeInfo}`, {
|
|
agentId: currentAgentId,
|
|
model: useModel,
|
|
task: message.substring(0, 100),
|
|
contextTokens: useContext ? Math.ceil(useContext.length / 4) : 0,
|
|
resumeSessionId: resumeSessionId || null,
|
|
});
|
|
|
|
// Monitor: API-Request
|
|
const contextInfo = useContext ? ` +${Math.ceil(useContext.length / 4)} ctx` : '';
|
|
sendMonitorEvent('api', `→ ${useModel}${contextInfo}${resumeInfo}`, {
|
|
model: useModel,
|
|
promptLength: message.length,
|
|
contextLength: useContext?.length || 0,
|
|
maxTurns: 200,
|
|
resumeSessionId: resumeSessionId || null,
|
|
});
|
|
|
|
// AUTO-Modus: Effektiven Modus aus der Nachricht ableiten
|
|
let effectiveMode = agentMode;
|
|
if (agentMode === 'auto') {
|
|
effectiveMode = chooseAutoMode(message);
|
|
sendEvent('auto-mode-chosen', { chosen: effectiveMode, messageLength: message.length });
|
|
sendMonitorEvent('agent', `Auto-Modus gewählt: ${effectiveMode}`, {
|
|
chosen: effectiveMode,
|
|
messageLength: message.length,
|
|
});
|
|
}
|
|
|
|
sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel, resuming: isResuming, mode: agentMode, effectiveMode });
|
|
|
|
// Orchestrator-Prompt für nicht-Solo Modi (nutzt effektiven Modus)
|
|
let orchestratorPrompt = '';
|
|
if (effectiveMode !== 'solo' && ORCHESTRATOR_PROMPTS[effectiveMode]) {
|
|
orchestratorPrompt = ORCHESTRATOR_PROMPTS[effectiveMode];
|
|
sendMonitorEvent('agent', `Orchestrator-Modus: ${effectiveMode}`, { mode: effectiveMode });
|
|
}
|
|
|
|
// Basis-Anweisung: Sprache + Verhalten
|
|
const BASE_INSTRUCTION = `WICHTIG: Antworte IMMER auf Deutsch. Denke und formuliere deine Gedanken (Thinking) ebenfalls auf Deutsch. Du bist ein technischer Assistent für Eddy (Eduard Wisch). Fasse dich kurz und präzise.`;
|
|
|
|
// Nachricht mit Context, Basis-Anweisung und Orchestrator kombinieren
|
|
let fullPrompt = message;
|
|
if (orchestratorPrompt) {
|
|
fullPrompt = `${orchestratorPrompt}\n\n---\n\n${message}`;
|
|
}
|
|
fullPrompt = `${BASE_INSTRUCTION}\n\n---\n\n${fullPrompt}`;
|
|
if (useContext) {
|
|
fullPrompt = `${useContext}\n\n---\n\n${fullPrompt}`;
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
let fullText = '';
|
|
let usedModel = useModel;
|
|
|
|
try {
|
|
// Query-Optionen zusammenstellen
|
|
const queryOptions = {
|
|
model: useModel,
|
|
maxTurns: 200,
|
|
abortController: activeAbort,
|
|
};
|
|
|
|
// Session-ID für Fortsetzung — SDK erwartet `resume`, nicht `sessionId`
|
|
if (resumeSessionId) {
|
|
queryOptions.resume = resumeSessionId;
|
|
}
|
|
|
|
// MCP-Server injizieren (aus App-Config oder DB geladen)
|
|
if (Object.keys(mcpServerConfigs).length > 0) {
|
|
queryOptions.mcpServers = mcpServerConfigs;
|
|
}
|
|
|
|
// In @anthropic-ai/claude-agent-sdk 0.2.104 vererbt sich JEDE tools/disallowedTools-
|
|
// Konfiguration auf Sub-Agents. Es gibt keine saubere Trennung Main vs. Sub.
|
|
// Daher: Tool-Preset fuer alle Modi freischalten, Restriktion via System-Prompt.
|
|
queryOptions.tools = { type: 'preset', preset: 'claude_code' };
|
|
queryOptions.allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'];
|
|
|
|
if (effectiveMode === 'handlanger') {
|
|
sendMonitorEvent('agent', 'Handlanger: Delegation per System-Prompt durchgesetzt', {
|
|
mode: effectiveMode,
|
|
});
|
|
} else if (effectiveMode === 'experten') {
|
|
sendMonitorEvent('agent', 'Experten: Multi-Agent via System-Prompt', {
|
|
mode: effectiveMode,
|
|
});
|
|
}
|
|
|
|
let conversation = query({
|
|
prompt: fullPrompt,
|
|
options: queryOptions,
|
|
});
|
|
|
|
// Auto-Retry bei transienten Fehlern (Rate-Limit, Netzwerk, 5xx)
|
|
const MAX_RETRIES = 3;
|
|
const RETRY_DELAYS = [2000, 5000, 10000]; // Exponentielles Backoff
|
|
|
|
function isRetryableError(err) {
|
|
const msg = (err?.message || String(err)).toLowerCase();
|
|
return (
|
|
msg.includes('rate limit') ||
|
|
msg.includes('429') ||
|
|
msg.includes('overloaded') ||
|
|
msg.includes('529') ||
|
|
msg.includes('500') ||
|
|
msg.includes('502') ||
|
|
msg.includes('503') ||
|
|
msg.includes('network') ||
|
|
msg.includes('econnreset') ||
|
|
msg.includes('timeout') ||
|
|
msg.includes('etimedout') ||
|
|
msg.includes('fetch failed')
|
|
);
|
|
}
|
|
|
|
// Dedupe: Manche Tool-Events kommen sowohl in assistant-Blocks
|
|
// als auch als standalone tool_use Event. Via toolUseId deduplizieren.
|
|
const handledTools = new Set();
|
|
|
|
// Tool-Info Cache: toolId → { name, filePath } fuer Checkpoint-After
|
|
const toolInfoCache = new Map();
|
|
|
|
// Tool-Use handhaben — funktioniert sowohl fuer standalone tool_use Events
|
|
// als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht
|
|
function handleToolUse(ev) {
|
|
const toolId = ev.tool_use_id || ev.id || randomUUID();
|
|
if (handledTools.has(toolId)) return;
|
|
handledTools.add(toolId);
|
|
|
|
const toolName = ev.name || 'unknown';
|
|
const toolInput = ev.input || {};
|
|
|
|
if (SUBAGENT_TOOLS.includes(toolName)) {
|
|
const subagentId = randomUUID();
|
|
const subagentType = getSubagentType(toolName, toolInput);
|
|
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || 'Subagent-Aufgabe';
|
|
const subagentModel = toolInput.model || useModel;
|
|
const depth = 1;
|
|
|
|
activeSubagents.set(toolId, {
|
|
agentId: subagentId,
|
|
parentId: currentAgentId,
|
|
type: subagentType,
|
|
task: subagentTask,
|
|
depth,
|
|
model: subagentModel,
|
|
});
|
|
|
|
sendEvent('subagent-started', {
|
|
id: subagentId,
|
|
parentAgentId: currentAgentId,
|
|
type: subagentType,
|
|
task: subagentTask.substring(0, 100),
|
|
depth,
|
|
model: subagentModel,
|
|
toolUseId: toolId,
|
|
});
|
|
}
|
|
|
|
sendEvent('tool-start', {
|
|
id: toolId,
|
|
tool: toolName,
|
|
input: toolInput,
|
|
agentId: currentAgentId,
|
|
});
|
|
|
|
// Bei Edit/Write: Tool-Info cachen + Checkpoint-Event senden
|
|
if (CHANGE_TOOLS.includes(toolName) && toolInput.file_path) {
|
|
toolInfoCache.set(toolId, { name: toolName, filePath: toolInput.file_path });
|
|
sendEvent('checkpoint-before', {
|
|
toolId,
|
|
tool: toolName,
|
|
filePath: toolInput.file_path,
|
|
oldString: toolInput.old_string || null, // Edit: was ersetzt wird
|
|
newString: toolInput.new_string || null, // Edit: womit ersetzt wird
|
|
content: toolInput.content || null, // Write: neuer Inhalt
|
|
});
|
|
}
|
|
|
|
const toolSummary = summarizeToolInput(toolName, toolInput);
|
|
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
|
|
toolId,
|
|
tool: toolName,
|
|
input: toolInput,
|
|
});
|
|
}
|
|
|
|
// Tool-Result handhaben
|
|
function handleToolResult(ev) {
|
|
const toolId = ev.tool_use_id || '';
|
|
|
|
if (activeSubagents.has(toolId)) {
|
|
const subagent = activeSubagents.get(toolId);
|
|
sendEvent('subagent-stopped', {
|
|
id: subagent.agentId,
|
|
parentAgentId: subagent.parentId,
|
|
success: !ev.is_error,
|
|
toolUseId: toolId,
|
|
});
|
|
activeSubagents.delete(toolId);
|
|
}
|
|
|
|
sendEvent('tool-end', {
|
|
id: toolId,
|
|
success: !ev.is_error,
|
|
agentId: currentAgentId,
|
|
});
|
|
|
|
// Bei Edit/Write: Checkpoint-After senden damit Frontend den Diff anzeigen kann
|
|
const toolInfo = toolInfoCache.get(toolId);
|
|
if (!ev.is_error && toolInfo) {
|
|
sendEvent('checkpoint-after', {
|
|
toolId,
|
|
tool: toolInfo.name,
|
|
filePath: toolInfo.filePath,
|
|
});
|
|
toolInfoCache.delete(toolId);
|
|
}
|
|
}
|
|
|
|
// Hilfsfunktion: Iteration mit Fallback bei ungueltiger Session-ID.
|
|
// Typischer Auslöser: "No conversation found with session ID: <uuid>"
|
|
// passiert wenn die in SQLite gespeicherte Claude-Session lokal fehlt
|
|
// (neue Maschine, Projekt-Cache geloescht, frische Installation, ...).
|
|
async function* iterateWithRetry() {
|
|
try {
|
|
for await (const ev of conversation) yield ev;
|
|
} catch (err) {
|
|
const msg = err?.message || String(err);
|
|
const isStaleSession = /no conversation found with session id/i.test(msg);
|
|
if (queryOptions.resume && isStaleSession) {
|
|
const staleId = queryOptions.resume;
|
|
// Rust-Seite informieren, damit die stale ID aus der DB geloescht
|
|
// wird — sonst probieren wir beim naechsten Start wieder dasselbe.
|
|
sendEvent('session-reset', { staleSessionId: staleId, reason: msg });
|
|
sendMonitorEvent('agent', 'Resume fehlgeschlagen, starte neue Session', {
|
|
reason: msg,
|
|
oldSessionId: staleId,
|
|
});
|
|
delete queryOptions.resume;
|
|
conversation = query({ prompt: fullPrompt, options: queryOptions });
|
|
for await (const ev of conversation) yield ev;
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
for await (const event of iterateWithRetry()) {
|
|
switch (event.type) {
|
|
case 'assistant':
|
|
// Content-Bloecke durchgehen (Text, tool_use, thinking, ...)
|
|
if (event.message?.content) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
fullText += block.text;
|
|
sendEvent('text', { text: block.text });
|
|
} else if (block.type === 'thinking' && block.thinking) {
|
|
// Extended Thinking — als kompaktes Inline-Element (immer sichtbar)
|
|
const escaped = block.thinking.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const inlineThinking = `<div class="thinking-inline"><span class="thinking-label">💭 Gedanken</span><span class="thinking-text">${escaped}</span></div>\n\n`;
|
|
fullText += inlineThinking;
|
|
sendEvent('text', { text: inlineThinking });
|
|
} else if (block.type === 'tool_use') {
|
|
// Tool-Call von Main-Agent — manuell weiterreichen, damit
|
|
// der tool_use-Case weiter unten greift
|
|
handleToolUse(block);
|
|
}
|
|
}
|
|
}
|
|
if (event.message?.model) {
|
|
usedModel = event.message.model;
|
|
}
|
|
break;
|
|
|
|
case 'tool_use': {
|
|
handleToolUse(event);
|
|
break;
|
|
}
|
|
|
|
case 'tool_result': {
|
|
handleToolResult(event);
|
|
break;
|
|
}
|
|
|
|
case 'user': {
|
|
// tool_result kommt vom SDK meist als Block innerhalb user-message
|
|
if (event.message?.content) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === 'tool_result') {
|
|
handleToolResult(block);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'result': {
|
|
// Endergebnis
|
|
const durationMs = Date.now() - startTime;
|
|
|
|
// Token-Zählung: input_tokens + cache_read + cache_creation = tatsächlicher Kontext
|
|
const usage = event.usage || {};
|
|
const inputTokens = usage.input_tokens || 0;
|
|
const cacheRead = usage.cache_read_input_tokens || 0;
|
|
const cacheCreation = usage.cache_creation_input_tokens || 0;
|
|
const contextTokens = inputTokens + cacheRead + cacheCreation;
|
|
const outputTokens = usage.output_tokens || 0;
|
|
const cost = event.total_cost_usd || 0;
|
|
|
|
sendEvent('result', {
|
|
text: fullText,
|
|
cost,
|
|
tokens: {
|
|
input: contextTokens, // Gesamter Kontext (inkl. Cache)
|
|
output: outputTokens,
|
|
raw_input: inputTokens, // Nur neue Token (für Debug)
|
|
cache_read: cacheRead,
|
|
cache_creation: cacheCreation,
|
|
},
|
|
session_id: event.session_id || '',
|
|
duration_ms: durationMs,
|
|
model: usedModel,
|
|
});
|
|
|
|
// Monitor: API-Response
|
|
const tokenK = ((contextTokens + outputTokens) / 1000).toFixed(1);
|
|
sendMonitorEvent('api', `← ${usedModel} [${durationMs}ms] ${tokenK}k ctx $${cost.toFixed(4)}`, {
|
|
model: usedModel,
|
|
contextTokens,
|
|
outputTokens,
|
|
cacheRead,
|
|
cacheCreation,
|
|
cost,
|
|
sessionId: event.session_id,
|
|
}, { durationMs });
|
|
break;
|
|
}
|
|
|
|
default:
|
|
// Andere Events still ignorieren
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
// Abgebrochen — kein Fehler
|
|
sendMonitorEvent('agent', 'Abgebrochen (User)', { reason: 'abort' });
|
|
} else if (isRetryableError(err) && !activeAbort?.signal?.aborted) {
|
|
// Transienter Fehler — Retry mit Backoff
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
const delay = RETRY_DELAYS[attempt] || 10000;
|
|
sendMonitorEvent('agent', `Retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms: ${err.message}`, {
|
|
attempt: attempt + 1,
|
|
delay,
|
|
error: err.message,
|
|
});
|
|
sendEvent('text', { text: `\n⏳ Netzwerk-Fehler, Retry ${attempt + 1}/${MAX_RETRIES}...` });
|
|
|
|
await new Promise(r => setTimeout(r, delay));
|
|
|
|
if (activeAbort?.signal?.aborted) break;
|
|
|
|
try {
|
|
conversation = query({ prompt: fullPrompt, options: queryOptions });
|
|
for await (const event of conversation) {
|
|
switch (event.type) {
|
|
case 'assistant':
|
|
if (event.message?.content) {
|
|
for (const block of event.message.content) {
|
|
if (block.type === 'text' && block.text) {
|
|
fullText += block.text;
|
|
sendEvent('text', { text: block.text });
|
|
} else if (block.type === 'tool_use') {
|
|
handleToolUse(block);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case 'tool_use': handleToolUse(event); break;
|
|
case 'tool_result': handleToolResult(event); break;
|
|
case 'result':
|
|
sendEvent('result', {
|
|
text: fullText,
|
|
cost: event.total_cost_usd || 0,
|
|
tokens: { input: (event.usage?.input_tokens || 0) + (event.usage?.cache_read_input_tokens || 0), output: event.usage?.output_tokens || 0 },
|
|
session_id: event.session_id || '',
|
|
duration_ms: Date.now() - startTime,
|
|
model: event.message?.model || usedModel,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
// Retry erfolgreich — raus
|
|
sendMonitorEvent('agent', `Retry ${attempt + 1} erfolgreich`, {});
|
|
break;
|
|
} catch (retryErr) {
|
|
if (retryErr.name === 'AbortError') break;
|
|
if (attempt === MAX_RETRIES - 1) {
|
|
sendEvent('text', { text: `\n\n**Fehler nach ${MAX_RETRIES} Versuchen:** ${retryErr.message || retryErr}` });
|
|
sendMonitorEvent('error', `Endgültig fehlgeschlagen nach ${MAX_RETRIES} Retries: ${retryErr.message}`, {
|
|
name: retryErr.name,
|
|
attempts: MAX_RETRIES,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
sendEvent('text', { text: `\n\n**Fehler:** ${err.message || err}` });
|
|
|
|
// Monitor: Fehler
|
|
sendMonitorEvent('error', `${err.message || err}`, {
|
|
name: err.name,
|
|
message: err.message,
|
|
stack: err.stack,
|
|
}, { error: err.message || String(err) });
|
|
}
|
|
} finally {
|
|
// Alle noch aktiven Subagents stoppen
|
|
for (const [toolId, subagent] of activeSubagents) {
|
|
sendEvent('subagent-stopped', {
|
|
id: subagent.agentId,
|
|
parentAgentId: subagent.parentId,
|
|
success: false, // Vorzeitig beendet
|
|
toolUseId: toolId,
|
|
});
|
|
}
|
|
activeSubagents.clear();
|
|
|
|
sendEvent('agent-stopped', { id: currentAgentId, code: 0 });
|
|
sendEvent('all-stopped');
|
|
currentAgentId = null;
|
|
activeAbort = null;
|
|
isQueryRunning = false;
|
|
|
|
// Pending-Queue: Nächste Nachricht automatisch abarbeiten (FIFO)
|
|
if (pendingMessages.length > 0) {
|
|
const next = pendingMessages.shift();
|
|
sendMonitorEvent('agent', `Pending-Nachricht wird verarbeitet (${pendingMessages.length} verbleibend)`, {
|
|
messageLength: next.message.length,
|
|
remaining: pendingMessages.length,
|
|
});
|
|
// Asynchron starten — nicht awaiten damit finally sauber abschließt
|
|
sendMessage(next.message, next.requestId, next.model, next.contextOverride, next.resumeSessionId)
|
|
.catch(err => sendMonitorEvent('error', `Pending-Dispatch fehlgeschlagen: ${err.message}`, {}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============ Befehle von Tauri ============
|
|
|
|
function handleCommand(msg) {
|
|
switch (msg.command) {
|
|
case 'message':
|
|
if (!msg.message) {
|
|
sendError(msg.id, 'Keine Nachricht angegeben');
|
|
return;
|
|
}
|
|
// Modell, Context und Resume-Session-ID können pro Anfrage überschrieben werden
|
|
sendMessage(msg.message, msg.id, msg.model, msg.context, msg.resumeSessionId);
|
|
break;
|
|
|
|
case 'set-context':
|
|
// Sticky Context setzen (wird bei allen folgenden Nachrichten verwendet)
|
|
stickyContext = msg.context || '';
|
|
const ctxTokens = stickyContext ? Math.ceil(stickyContext.length / 4) : 0;
|
|
sendResponse(msg.id, { status: 'Context gesetzt', tokens: ctxTokens });
|
|
sendMonitorEvent('hook', `Sticky Context gesetzt (~${ctxTokens} Token)`, {
|
|
contextLength: stickyContext.length,
|
|
estimatedTokens: ctxTokens,
|
|
});
|
|
break;
|
|
|
|
case 'get-context':
|
|
sendResponse(msg.id, {
|
|
context: stickyContext,
|
|
tokens: stickyContext ? Math.ceil(stickyContext.length / 4) : 0,
|
|
});
|
|
break;
|
|
|
|
case 'clear-context':
|
|
stickyContext = '';
|
|
sendResponse(msg.id, { status: 'Context gelöscht' });
|
|
sendMonitorEvent('hook', 'Sticky Context gelöscht', {});
|
|
break;
|
|
|
|
case 'stop':
|
|
if (activeAbort) {
|
|
activeAbort.abort();
|
|
}
|
|
sendResponse(msg.id, { status: 'gestoppt' });
|
|
break;
|
|
|
|
case 'set-model':
|
|
if (!msg.model) {
|
|
sendError(msg.id, 'Kein Modell angegeben');
|
|
return;
|
|
}
|
|
const validModels = AVAILABLE_MODELS.map(m => m.id);
|
|
if (!validModels.includes(msg.model)) {
|
|
sendError(msg.id, `Ungültiges Modell: ${msg.model}. Verfügbar: ${validModels.join(', ')}`);
|
|
return;
|
|
}
|
|
currentModel = msg.model;
|
|
sendResponse(msg.id, { model: currentModel, status: 'Modell geändert' });
|
|
sendEvent('model-changed', { model: currentModel });
|
|
break;
|
|
|
|
case 'get-models':
|
|
sendResponse(msg.id, {
|
|
current: currentModel,
|
|
available: AVAILABLE_MODELS,
|
|
});
|
|
break;
|
|
|
|
case 'set-mode':
|
|
// Agent-Modus setzen (solo, handlanger, experten, auto)
|
|
const validModes = ['solo', 'handlanger', 'experten', 'auto'];
|
|
if (!msg.mode || !validModes.includes(msg.mode)) {
|
|
sendError(msg.id, `Ungültiger Modus: ${msg.mode}. Verfügbar: ${validModes.join(', ')}`);
|
|
return;
|
|
}
|
|
agentMode = msg.mode;
|
|
sendResponse(msg.id, { mode: agentMode, status: 'Modus geändert' });
|
|
sendEvent('mode-changed', { mode: agentMode });
|
|
sendMonitorEvent('agent', `Agent-Modus geändert: ${agentMode}`, { mode: agentMode });
|
|
break;
|
|
|
|
case 'get-mode':
|
|
sendResponse(msg.id, { mode: agentMode });
|
|
break;
|
|
|
|
case 'status':
|
|
sendResponse(msg.id, {
|
|
model: currentModel,
|
|
mode: agentMode,
|
|
isProcessing: !!currentAgentId,
|
|
availableModels: AVAILABLE_MODELS,
|
|
});
|
|
break;
|
|
|
|
case 'local-query': {
|
|
// Lokale Ollama-Abfrage für einfache Tasks (Commit-Messages, Übersetzungen)
|
|
if (!ollamaAvailable) {
|
|
sendError(msg.id, 'Ollama nicht verfügbar');
|
|
return;
|
|
}
|
|
const prompt = msg.message || msg.prompt || '';
|
|
localQuery(prompt, msg.id);
|
|
break;
|
|
}
|
|
|
|
case 'check-ollama': {
|
|
// Ollama-Verfügbarkeit prüfen
|
|
checkOllamaAvailability()
|
|
.then(status => sendResponse(msg.id, status))
|
|
.catch(err => sendError(msg.id, `Ollama-Check fehlgeschlagen: ${err.message}`));
|
|
break;
|
|
}
|
|
|
|
case 'set-ollama-config': {
|
|
if (msg.endpoint) ollamaEndpoint = msg.endpoint;
|
|
if (msg.model) ollamaModel = msg.model;
|
|
checkOllamaAvailability()
|
|
.then(status => sendResponse(msg.id, { ...status, endpoint: ollamaEndpoint, model: ollamaModel }))
|
|
.catch(err => sendError(msg.id, `Ollama-Config fehlgeschlagen: ${err.message}`));
|
|
break;
|
|
}
|
|
|
|
case 'set-mcp-servers':
|
|
// MCP-Server-Configs empfangen (von Rust-Backend aus DB/Config geladen)
|
|
if (msg.servers && typeof msg.servers === 'object') {
|
|
mcpServerConfigs = msg.servers;
|
|
const names = Object.keys(mcpServerConfigs);
|
|
sendResponse(msg.id, { status: 'MCP-Server gesetzt', count: names.length, servers: names });
|
|
sendMonitorEvent('mcp', `${names.length} MCP-Server konfiguriert: ${names.join(', ')}`, {
|
|
servers: names,
|
|
count: names.length,
|
|
});
|
|
} else {
|
|
sendError(msg.id, 'Ungültige MCP-Server-Konfiguration');
|
|
}
|
|
break;
|
|
|
|
case 'get-mcp-servers':
|
|
sendResponse(msg.id, {
|
|
servers: Object.keys(mcpServerConfigs),
|
|
count: Object.keys(mcpServerConfigs).length,
|
|
configs: mcpServerConfigs,
|
|
});
|
|
break;
|
|
|
|
case 'ping':
|
|
sendResponse(msg.id, { pong: true });
|
|
break;
|
|
|
|
default:
|
|
sendError(msg.id, `Unbekannter Befehl: ${msg.command}`);
|
|
}
|
|
}
|
|
|
|
// ============ Ollama (Lokales Modell) ============
|
|
|
|
async function checkOllamaAvailability() {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
const res = await fetch(`${ollamaEndpoint}/api/tags`, { signal: controller.signal });
|
|
clearTimeout(timeout);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const models = (data.models || []).map(m => m.name);
|
|
ollamaAvailable = models.length > 0;
|
|
const hasModel = models.some(m => m.startsWith(ollamaModel.split(':')[0]));
|
|
sendEvent('ollama-status', { available: ollamaAvailable, models, configured: ollamaModel, hasModel });
|
|
return { available: ollamaAvailable, models, configured: ollamaModel, hasModel, endpoint: ollamaEndpoint };
|
|
} catch (err) {
|
|
ollamaAvailable = false;
|
|
return { available: false, models: [], error: err.message, endpoint: ollamaEndpoint };
|
|
}
|
|
}
|
|
|
|
async function localQuery(prompt, requestId) {
|
|
if (!ollamaAvailable) {
|
|
sendError(requestId, 'Ollama nicht verfügbar');
|
|
return;
|
|
}
|
|
|
|
sendEvent('agent-started', { id: 'local-' + requestId, type: 'Local', task: prompt.substring(0, 50), model: ollamaModel });
|
|
|
|
const startTime = Date.now();
|
|
try {
|
|
const res = await fetch(`${ollamaEndpoint}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: ollamaModel,
|
|
prompt,
|
|
stream: false,
|
|
options: { temperature: 0.3, num_predict: 500 },
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const text = data.response || '';
|
|
const durationMs = Date.now() - startTime;
|
|
|
|
sendEvent('text', { text });
|
|
sendEvent('result', {
|
|
text,
|
|
cost: 0,
|
|
tokens: { input: data.prompt_eval_count || 0, output: data.eval_count || 0 },
|
|
session_id: '',
|
|
duration_ms: durationMs,
|
|
model: ollamaModel,
|
|
local: true,
|
|
});
|
|
sendMonitorEvent('api', `← Lokal (${ollamaModel}) [${durationMs}ms]`, {
|
|
model: ollamaModel, local: true,
|
|
tokens: { input: data.prompt_eval_count || 0, output: data.eval_count || 0 },
|
|
}, { durationMs });
|
|
} catch (err) {
|
|
sendEvent('text', { text: `**Lokaler Fehler:** ${err.message}` });
|
|
sendMonitorEvent('error', `Ollama Fehler: ${err.message}`, { model: ollamaModel });
|
|
} finally {
|
|
sendEvent('agent-stopped', { id: 'local-' + requestId, code: 0 });
|
|
sendEvent('all-stopped');
|
|
}
|
|
}
|
|
|
|
// Ollama beim Start prüfen (non-blocking)
|
|
checkOllamaAvailability().then(status => {
|
|
if (status.available) {
|
|
process.stderr.write(`🧠 Ollama verfügbar: ${status.models.length} Modelle (${ollamaEndpoint})\n`);
|
|
}
|
|
}).catch(() => { /* Ollama nicht erreichbar — kein Problem */ });
|
|
|
|
// ============ Main ============
|
|
|
|
function cleanupDaemon() {
|
|
if (IS_DAEMON) {
|
|
try { if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH); } catch {}
|
|
try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
|
|
process.stderr.write('🔌 Daemon aufgeräumt\n');
|
|
}
|
|
}
|
|
|
|
if (IS_DAEMON) {
|
|
// ---- UDS-Daemon-Modus ----
|
|
// Alte Socket-Datei aufräumen
|
|
try { if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH); } catch {}
|
|
|
|
// PID-File schreiben
|
|
writeFileSync(PID_PATH, String(process.pid));
|
|
|
|
const udsServer = createServer((client) => {
|
|
process.stderr.write(`🔌 UDS-Client verbunden (${udsClients.size + 1} aktiv)\n`);
|
|
udsClients.add(client);
|
|
|
|
// JSON-Lines Protokoll: jede Zeile = ein Befehl
|
|
let buffer = '';
|
|
client.on('data', (data) => {
|
|
buffer += data.toString();
|
|
let idx;
|
|
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
const line = buffer.slice(0, idx).trim();
|
|
buffer = buffer.slice(idx + 1);
|
|
if (!line) continue;
|
|
try {
|
|
handleCommand(JSON.parse(line));
|
|
} catch (err) {
|
|
process.stderr.write(`UDS Ungültige Eingabe: ${err.message}\n`);
|
|
}
|
|
}
|
|
});
|
|
|
|
client.on('end', () => {
|
|
udsClients.delete(client);
|
|
process.stderr.write(`🔌 UDS-Client getrennt (${udsClients.size} aktiv)\n`);
|
|
});
|
|
|
|
client.on('error', (err) => {
|
|
udsClients.delete(client);
|
|
process.stderr.write(`UDS-Client Fehler: ${err.message}\n`);
|
|
});
|
|
|
|
// Neuem Client sofort den aktuellen Status senden
|
|
try {
|
|
client.write(JSON.stringify({
|
|
type: 'event', event: 'ready',
|
|
payload: { version: '1.2.0', pid: process.pid, model: currentModel, availableModels: AVAILABLE_MODELS, daemon: true }
|
|
}) + '\n');
|
|
} catch {}
|
|
});
|
|
|
|
udsServer.listen(SOCKET_PATH, () => {
|
|
process.stderr.write(`🔌 Bridge-Daemon lauscht auf ${SOCKET_PATH} (PID: ${process.pid})\n`);
|
|
});
|
|
|
|
udsServer.on('error', (err) => {
|
|
process.stderr.write(`UDS-Server Fehler: ${err.message}\n`);
|
|
cleanupDaemon();
|
|
process.exit(1);
|
|
});
|
|
|
|
} else {
|
|
// ---- stdio-Modus (Kompatibilität) ----
|
|
const rl = createInterface({ input: process.stdin });
|
|
rl.on('line', (line) => {
|
|
if (!line.trim()) return;
|
|
try {
|
|
handleCommand(JSON.parse(line));
|
|
} catch (err) {
|
|
process.stderr.write(`Ungültige Eingabe: ${err.message}\n`);
|
|
}
|
|
});
|
|
|
|
rl.on('close', () => {
|
|
process.stderr.write('stdin geschlossen\n');
|
|
});
|
|
}
|
|
|
|
process.on('SIGTERM', () => { clearInterval(keepAlive); cleanupDaemon(); process.exit(0); });
|
|
process.on('SIGINT', () => { clearInterval(keepAlive); cleanupDaemon(); process.exit(0); });
|
|
process.on('exit', () => { cleanupDaemon(); });
|
|
|
|
// Globale Fehler-Handler — Bridge darf nicht still abstürzen
|
|
// WICHTIG: err.stack ist ein lazy Getter der bei jedem Zugriff neu formatiert wird
|
|
// und bei V8-OOM selbst einen OOM-Abort auslösen kann. Daher: einmal lesen, kürzen,
|
|
// try/catch drumrum — der Handler darf nicht selbst crashen.
|
|
function safeStack(err) {
|
|
try {
|
|
const raw = (err && err.stack) ? String(err.stack) : '';
|
|
return raw.length > 2000 ? raw.slice(0, 2000) + '\n...[gekürzt]' : raw;
|
|
} catch {
|
|
return '[stack nicht lesbar]';
|
|
}
|
|
}
|
|
process.on('uncaughtException', (err) => {
|
|
try {
|
|
const msg = (err && err.message) ? String(err.message).slice(0, 500) : String(err).slice(0, 500);
|
|
const stack = safeStack(err);
|
|
process.stderr.write(`❌ Unbehandelter Fehler: ${msg}\n${stack}\n`);
|
|
sendEvent('bridge-error', { type: 'uncaughtException', message: msg, stack });
|
|
sendMonitorEvent('error', `Bridge Crash: ${msg}`, {});
|
|
} catch (inner) {
|
|
try { process.stderr.write(`❌ Handler-Fehler: ${inner && inner.message}\n`); } catch {}
|
|
}
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
try {
|
|
const msg = reason instanceof Error
|
|
? String(reason.message).slice(0, 500)
|
|
: String(reason).slice(0, 500);
|
|
process.stderr.write(`❌ Unhandled Promise Rejection: ${msg}\n`);
|
|
sendEvent('bridge-error', { type: 'unhandledRejection', message: msg });
|
|
sendMonitorEvent('error', `Unhandled Rejection: ${msg}`, {});
|
|
} catch (inner) {
|
|
try { process.stderr.write(`❌ Handler-Fehler: ${inner && inner.message}\n`); } catch {}
|
|
}
|
|
});
|
|
|
|
// Bereit-Signal (im stdio-Modus sofort senden, im Daemon-Modus pro Client bei Connect)
|
|
if (!IS_DAEMON) {
|
|
sendEvent('ready', { version: '1.2.0', pid: process.pid, model: currentModel, availableModels: AVAILABLE_MODELS });
|
|
}
|