diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs index d6f4bdc..2f1dc47 100644 --- a/Services/ClaudeCliService.cs +++ b/Services/ClaudeCliService.cs @@ -159,6 +159,12 @@ public class ClaudeCliService if (!_isAvailable) return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" }; + // Permission-Mode: Im Plan-Modus (auch Panel-Member laufen read-only via planMode=true) + // MUSS die Schreib-Sperre über die Permission-Ebene erzwungen werden. 'acceptEdits' würde + // Write/Edit AUTOMATISCH akzeptieren – UNABHÄNGIG von --allowed-tools – sodass ein als + // read-only gedachter Member (oder ein regulärer Plan-Modus-User) trotzdem Dateien schreiben + // könnte. 'plan' blockiert alle Schreib-/Edit-/Bash-Operationen auf Permission-Ebene. + var permissionMode = planMode ? "plan" : "acceptEdits"; var args = new List { "-p", @@ -166,7 +172,7 @@ public class ClaudeCliService "--output-format", "stream-json", "--include-partial-messages", "--no-session-persistence", - "--permission-mode", "acceptEdits", + "--permission-mode", permissionMode, "--disallowed-tools", "AskUserQuestion" }; diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index f92a486..6e0959c 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -236,6 +236,14 @@ let _planModeActive = false; // true genau wenn _compositeMode === 'plan' const _COMPOSITE_ORDER = ['off', 'plan', 'fusion', 'moa']; +// Serialisierte Modus-Persistenz. Schnelles Durchklicken (off→plan→fusion→moa) feuert sonst +// ÜBERLAPPENDE PATCHes, deren Commit-Reihenfolge nicht garantiert ist → der zuletzt committete +// Zwischenwert 'fusion' kann 'moa' überschreiben. Und ein direkt danach gesendeter Chat-Request +// lädt die Session, BEVOR 'moa' committed ist → das Backend liest den alten Modus (Fusion → 1 Runde +// statt MoA). Die Kette garantiert (a) Reihenfolge (letzter Klick gewinnt) und (b) dass sendMessage +// das offene PATCH abwarten kann. Die Kette rejected nie (Fehler werden hier geschluckt). +let _modePersist = Promise.resolve(); + function _renderCompositeBtn() { var btn = $('#planModeBtn'); if (!btn) return; @@ -281,17 +289,19 @@ async function cycleCompositeMode() { _planModeActive = (_compositeMode === 'plan'); _renderCompositeBtn(); - // Nur Fusion/MoA/Off brauchen einen Server-Call (Plan ist transient) + // Nur Fusion/MoA/Off brauchen einen Server-Call (Plan ist transient). Auf die serialisierte + // Kette legen, damit aufeinanderfolgende Klicks in Reihenfolge committen (letzter Klick gewinnt) + // und sendMessage das offene PATCH abwarten kann. if (_compositeMode !== 'plan') { var sid = getCurrentSessionId(); if (sid) { - try { - await apiUpdateSession(sid, { multiModelMode: _compositeMode }); - } catch (e) { - console.error('Modus speichern fehlgeschlagen:', e); - if (typeof showToast === 'function') showToast('Modus konnte nicht gespeichert werden', 'error'); - return; - } + var targetMode = _compositeMode; + _modePersist = _modePersist + .then(function () { return apiUpdateSession(sid, { multiModelMode: targetMode }); }) + .catch(function (e) { + console.error('Modus speichern fehlgeschlagen:', e); + if (typeof showToast === 'function') showToast('Modus konnte nicht gespeichert werden', 'error'); + }); } } @@ -580,6 +590,11 @@ async function sendMessage() { input.value = ''; input.dispatchEvent(new Event('input')); + // Ein evtl. noch offenes Modus-PATCH (Fusion/MoA/Aus aus dem Toggle) MUSS committed sein, + // bevor der Chat-Request die Session lädt – sonst läuft die Chain mit dem alten Modus + // (z.B. MoA → nur 1 Runde, weil das Backend noch 'fusion' liest). + await _modePersist; + await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive)); } @@ -616,6 +631,7 @@ async function sendMessageDirect(text) { if (isStreaming || !text) return; addUserBubble(text, true); scrollDownForce(); + await _modePersist; // offenes Modus-PATCH abwarten (s. sendMessage) await _runStream((signal) => apiChat(text, getCurrentSessionId(), signal, _planModeActive)); }