diff --git a/ClawdDotNet.csproj b/ClawdDotNet.csproj
index add38ea..145952a 100644
--- a/ClawdDotNet.csproj
+++ b/ClawdDotNet.csproj
@@ -34,6 +34,14 @@
+
+
+
+
+
@@ -43,6 +51,7 @@
+
@@ -57,7 +66,11 @@
+
+
diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index 2ee4af4..24ceb0a 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -1,5 +1,7 @@
+
+
@@ -15,10 +17,21 @@
+
-
+
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..fbc3730
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,24 @@
+
+
+
+
+ 0.1.0
+
+
+
diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs
deleted file mode 100644
index 4917a71..0000000
--- a/Models/AppSettings.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using System.ComponentModel;
-using System.Text.Json.Serialization;
-
-namespace ClawdDotNet.Models;
-
-[TypeConverter(typeof(ExpandableObjectConverter))]
-public sealed class AppSettings
-{
- [Category("Allgemein")]
- [DisplayName("Log-Verzeichnis")]
- [Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
- [JsonPropertyName("logDirectory")]
- public string LogDirectory { get; set; } = "./Logs";
-
- [Category("Allgemein")]
- [DisplayName("Instanzen-Verzeichnis")]
- [Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
- [JsonPropertyName("instancesDirectory")]
- public string InstancesDirectory { get; set; } = "./Instances";
-
- [Category("Allgemein")]
- [DisplayName("Standard-Konfigurations-Datei")]
- [Description("Pfad zur Standard-Instanz-Konfiguration (Legacy). Neue Instanzen nutzen das Instanzen-Verzeichnis.")]
- [JsonPropertyName("defaultConfigPath")]
- public string DefaultConfigPath { get; set; } = "./configs/config.json";
-
- [Category("Allgemein")]
- [DisplayName("Minimaler Log-Level")]
- [Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
- [JsonPropertyName("minimumLogLevel")]
- public string MinimumLogLevel { get; set; } = "Info";
-
- [Category("UI")]
- [DisplayName("Max. Log-Zeilen in UI")]
- [Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
- [JsonPropertyName("maxLogLinesInUi")]
- public int MaxLogLinesInUi { get; set; } = 2000;
-
- [Category("UI")]
- [DisplayName("Log-Aktualisierungsintervall (ms)")]
- [Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
- [JsonPropertyName("logRefreshIntervalMs")]
- public int LogRefreshIntervalMs { get; set; } = 500;
-
- [Category("API")]
- [DisplayName("Status-Check-Intervall (Sek)")]
- [Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
- [JsonPropertyName("statusCheckIntervalSeconds")]
- public int StatusCheckIntervalSeconds { get; set; } = 60;
-
- [Category("API")]
- [DisplayName("OpenRouter Base-URL")]
- [Description("Basis-URL der OpenRouter-API.")]
- [JsonPropertyName("openRouterBaseUrl")]
- public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
-
- [Category("Backup")]
- [DisplayName("Backup-Verzeichnis")]
- [Description("Ordner, in dem Sicherungen abgelegt werden.")]
- [JsonPropertyName("backupDirectory")]
- public string BackupDirectory { get; set; } = "./Backups";
-
- [Category("Backup")]
- [DisplayName("Automatisch sichern")]
- [Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")]
- [JsonPropertyName("autoBackupEnabled")]
- public bool AutoBackupEnabled { get; set; }
-
- [Category("Backup")]
- [DisplayName("Uhrzeit der automatischen Sicherung")]
- [Description("Tageszeit im Format HH:mm.")]
- [JsonPropertyName("autoBackupTime")]
- public string AutoBackupTime { get; set; } = "03:00";
-
- [Category("Backup")]
- [DisplayName("Aufbewahrte Sicherungen")]
- [Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")]
- [JsonPropertyName("backupKeepCount")]
- public int BackupKeepCount { get; set; } = 14;
-
- public override string ToString() => "Anwendungseinstellungen";
-}
diff --git a/Models/InstanceSettingsViewModel.cs b/Models/InstanceSettingsViewModel.cs
index 6d31361..603b4a1 100644
--- a/Models/InstanceSettingsViewModel.cs
+++ b/Models/InstanceSettingsViewModel.cs
@@ -78,6 +78,45 @@ public sealed class InstanceSettingsViewModel
[ReadOnly(true)]
public int AgentCount => _config.Agents.Count;
+ // ─── WatchDog (Instanz-Heartbeat) ───
+ // Server-URL und Master-Token stehen app-weit in den Anwendungseinstellungen. Die
+ // Instanz registriert sich damit selbst; hier stehen nur Source/Gruppe/Intervall.
+ // Ein/Aus läuft über den Service „Instanz-Watchdog" im Worker-Tab. Änderungen greifen
+ // beim nächsten Start der Instanz.
+
+ [Category("WatchDog")]
+ [DisplayName("Source")]
+ [Description("Dienst-Kennung im WatchDog. Alle Instanzen teilen sich dieselbe Source.")]
+ public string WatchdogSource
+ {
+ get => _config.Watchdog.Source;
+ set => _config.Watchdog.Source = value;
+ }
+
+ [Category("WatchDog")]
+ [DisplayName("Gruppe")]
+ [Description("Gruppierung im WatchDog-Dashboard.")]
+ public string WatchdogGroup
+ {
+ get => _config.Watchdog.Group;
+ set => _config.Watchdog.Group = value;
+ }
+
+ [Category("WatchDog")]
+ [DisplayName("Intervall (Sek)")]
+ [Description("Sende-Takt der Heartbeats in Sekunden (mindestens 5).")]
+ public int WatchdogIntervalSeconds
+ {
+ get => _config.Watchdog.IntervalSeconds;
+ set => _config.Watchdog.IntervalSeconds = value;
+ }
+
+ [Category("WatchDog")]
+ [DisplayName("Token registriert")]
+ [Description("Ob diese Instanz bereits einen eigenen Agent-Token vom WatchDog erhalten hat.")]
+ [ReadOnly(true)]
+ public bool WatchdogTokenRegistered => _config.Watchdog.HasToken;
+
[Browsable(false)]
public InstanceConfig UnderlyingConfig => _config;
diff --git a/NuGet.Config b/NuGet.Config
index d293173..d4597c1 100644
--- a/NuGet.Config
+++ b/NuGet.Config
@@ -26,6 +26,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Program.cs b/Program.cs
index 9551f0b..1d792a9 100644
--- a/Program.cs
+++ b/Program.cs
@@ -3,7 +3,6 @@ using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Logging;
using ClawdDotNet.Core.Security;
-using ClawdDotNet.Core.Scheduling;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Storage;
@@ -106,6 +105,13 @@ internal static class Program
instanceConfig.InstanceName, instanceConfig.InstanceId);
coreLogger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
+ // ─── 5b. Lizenzprüfung ───
+ // Entfällt in dieser Fassung. Startprüfung, laufende Nachprüfung und die
+ // Deploymentcenter-Anbindung (Watchdog, Updates, Fehler-Stream, Bugtracker)
+ // liegen in ClawdDotNet.App (AppHost); Einstiegspunkt ist
+ // src/ClawdDotNet.Desktop. Diese Datei ist nur noch Vorlage — siehe
+ // ClawdDotNet.slnx.
+
// ─── 6. Core-Komponenten erzeugen ───
var toolRegistry = new ToolRegistry();
toolRegistry.Register(new FileRWTool());
@@ -121,6 +127,7 @@ internal static class Program
toolRegistry.Register(new AgentSpawnTool());
toolRegistry.Register(new AgentEditorTool());
toolRegistry.Register(new ClawdDotNet.Tools.Memory.MemoryTool());
+ toolRegistry.Register(new ClawdDotNet.Tools.Taskboard.TaskboardTool());
// ─── 6a. TelegramClient (MTProto User-API) ───
TelegramClientManager? tgClientManager = null;
@@ -142,8 +149,9 @@ internal static class Program
OpenRouterClient? openRouterClient = null;
AgentEngine? agentEngine = null;
- AgentScheduler? agentScheduler = null;
- ToolJobScheduler? toolJobScheduler = null;
+ ClawdDotNet.Core.Tasks.TaskScanner? taskScanner = null;
+ ClawdDotNet.Core.Staging.StagingService? stagingService = null;
+ SqliteUsageRepository? usageRepository = null;
if (!string.IsNullOrWhiteSpace(instanceConfig.OpenRouterApiKey))
{
@@ -159,7 +167,12 @@ internal static class Program
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
var stateStore = new SqliteStateStore(storage);
var memoryRepository = new SqliteMemoryRepository(storage);
- var usageRepository = new SqliteUsageRepository(storage);
+ var taskRepository = new ClawdDotNet.Core.Tasks.SqliteTaskRepository(storage);
+ var auditRepository = new ClawdDotNet.Core.Audit.SqliteAuditRepository(storage);
+ var stagingRepository = new ClawdDotNet.Core.Staging.SqliteStagingRepository(storage);
+ var stagingGate = new ClawdDotNet.Core.Staging.StagingGate(
+ new ClawdDotNet.Core.Staging.StagingPolicy(), stagingRepository);
+ usageRepository = new SqliteUsageRepository(storage);
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
var pricingCatalog = new ModelPricingCatalog();
@@ -171,7 +184,7 @@ internal static class Program
agentEngine = new AgentEngine(
openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory,
- memoryRepository, usageRepository, pricingCatalog)
+ memoryRepository, usageRepository, pricingCatalog, taskRepository, auditRepository, stagingGate)
{
InstanceBudget = instanceConfig.Budget
};
@@ -185,13 +198,65 @@ internal static class Program
return string.IsNullOrWhiteSpace(agent.AgentDir) ? null : agent.AgentDir;
});
agentEngine.LoadPersistedChats();
- agentScheduler = new AgentScheduler(agentEngine, instanceConfig.InstanceId, loggerFactory);
- agentScheduler.RegisterAll(instanceConfig.Agents);
- toolJobScheduler = new ToolJobScheduler(agentEngine, toolRegistry, stateStore, loggerFactory, instanceConfig.InstanceId);
- toolJobScheduler.RegisterAll(instanceConfig.Agents);
+ // ─── Taskboard: Dateien in die DB spiegeln (Reconciliation beim Start) ───
+ // Verwaiste Claims eines abgestürzten Laufs lösen, dann alle Task-Dateien
+ // importieren. Nicht blockierend — der Start soll nicht auf das Dateisystem warten.
+ var sharedWorkspace = instanceConfig.Agents
+ .Select(a => a.SharedWorkspacePath)
+ .FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
+ if (!string.IsNullOrWhiteSpace(sharedWorkspace))
+ {
+ var board = new ClawdDotNet.Core.Tasks.TaskboardService(
+ taskRepository, Path.Combine(sharedWorkspace, "tasks"));
- coreLogger.LogInformation("AgentEngine und Scheduler erstellt, Chat-Verläufe geladen");
+ // Staging (A2): Freigabe-/Ablehnungs-Dienst für die Review-Oberfläche.
+ // Weckt den Agenten nach der Entscheidung über einen Folge-Task (A1).
+ stagingService = new ClawdDotNet.Core.Staging.StagingService(
+ stagingRepository, agentEngine, board, loggerFactory, auditRepository);
+
+ // Scanner (A1): der Taktgeber. Startet erst, wenn die Reconciliation durch
+ // ist — sonst könnte er einen noch nicht bereinigten Claim antreffen.
+ // Der Scanner ist der EINZIGE periodische Treiber (A1) — er ersetzt die
+ // früheren AgentScheduler/ToolJobScheduler. Geplante Agent-Läufe wie auch
+ // Tool-Job-Polls sind jetzt Tasks.
+ var dispatcher = new ClawdDotNet.Core.Tasks.EngineTaskDispatcher(
+ agentEngine, () => instanceConfig.Agents, instanceConfig.InstanceId,
+ toolRegistry, stateStore, loggerFactory);
+ taskScanner = new ClawdDotNet.Core.Tasks.TaskScanner(
+ taskRepository, dispatcher, loggerFactory);
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var reset = await taskRepository.ReleaseStaleClaimsAsync(
+ DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
+
+ // Einmalige Übernahme der alten coordination/task_*-Dateien …
+ var coordMigration = new ClawdDotNet.Core.Tasks.CoordinationMigration(
+ board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
+ var migratedCoord = await coordMigration.RunAsync(CancellationToken.None);
+
+ // … und der alten scheduler/toolJobs-Konfiguration in Tasks.
+ var schedMigration = new ClawdDotNet.Core.Tasks.SchedulerTaskMigration(
+ board, taskRepository, loggerFactory);
+ var migratedSched = await schedMigration.RunAsync(instanceConfig.Agents, CancellationToken.None);
+
+ var imported = await board.ImportAllAsync(CancellationToken.None);
+ coreLogger.LogInformation(
+ "Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + {Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
+ imported, migratedCoord, migratedSched, reset);
+ taskScanner.Start();
+ }
+ catch (Exception ex)
+ {
+ coreLogger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
+ }
+ });
+ }
+
+ coreLogger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
}
else
{
@@ -234,6 +299,11 @@ internal static class Program
}
}
+ // ─── 6c. Instanz-Watchdog ───
+ // Entfällt hier ebenfalls. Der Heartbeat ans Deploymentcenter (ein Monitor je
+ // Instanz, mit Gesundheitsprüfungen und angekündigtem Ende) steht in
+ // ClawdDotNet.App/Services/DeploymentcenterService.cs.
+
// ─── 7. MainForm starten ───
var form = new frm_main(
settingsManager,
@@ -244,19 +314,16 @@ internal static class Program
toolRegistry,
dirManager,
agentEngine,
- agentScheduler,
- toolJobScheduler);
+ taskScanner,
+ stagingService);
Application.Run(form);
// ─── 8. Aufräumen ───
coreLogger.LogInformation("ClawdDotNet wird beendet");
- if (toolJobScheduler is not null)
- toolJobScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult();
-
- if (agentScheduler is not null)
- agentScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ if (taskScanner is not null)
+ taskScanner.DisposeAsync().AsTask().GetAwaiter().GetResult();
if (tgClientManager is not null)
tgClientManager.DisposeAsync().AsTask().GetAwaiter().GetResult();
diff --git a/Services/BackupScheduler.cs b/Services/BackupScheduler.cs
deleted file mode 100644
index 958c359..0000000
--- a/Services/BackupScheduler.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-using ClawdDotNet.Core.Backup;
-using Microsoft.Extensions.Logging;
-
-namespace ClawdDotNet.Services;
-
-///
-/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung.
-///
-/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und
-/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
-/// mitsichern will, macht das von Hand.
-///
-/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit
-/// eine Änderung ohne Neustart greift.
-///
-public sealed class BackupScheduler : IDisposable
-{
- private readonly string _instanceDir;
- private readonly string _instanceName;
- private readonly SettingsManager _settings;
- private readonly ILogger _logger;
- private readonly BackupService _service = new();
- private readonly System.Windows.Forms.Timer _timer;
-
- /// Verhindert mehrere Sicherungen innerhalb derselben Minute.
- private DateTime? _lastRun;
-
- private bool _running;
-
- public event Action? OnBackupCreated;
-
- public BackupScheduler(string instanceDir, string instanceName,
- SettingsManager settings, ILogger logger)
- {
- _instanceDir = instanceDir;
- _instanceName = instanceName;
- _settings = settings;
- _logger = logger;
-
- // Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
- _timer = new System.Windows.Forms.Timer { Interval = 60_000 };
- _timer.Tick += async (_, _) => await TickAsync();
- }
-
- public void Start() => _timer.Start();
-
- public void Stop() => _timer.Stop();
-
- private async Task TickAsync()
- {
- if (_running)
- return;
-
- var settings = _settings.AppSettings;
- if (!settings.AutoBackupEnabled)
- return;
-
- if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
- return;
-
- var now = DateTime.Now;
-
- // Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
- if (now.TimeOfDay < scheduled)
- return;
-
- if (_lastRun?.Date == now.Date)
- return;
-
- _running = true;
- try
- {
- await RunAsync(settings.BackupDirectory, settings.BackupKeepCount);
- _lastRun = now;
- }
- catch (Exception ex)
- {
- // Ein Fehlschlag darf die Anwendung nicht stören; er wird protokolliert
- // und morgen erneut versucht.
- _logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
- _lastRun = now;
- }
- finally
- {
- _running = false;
- }
- }
-
- private async Task RunAsync(string folder, int keepCount)
- {
- var safeName = string.Concat(
- _instanceName.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
-
- var file = Path.Combine(
- Path.GetFullPath(folder),
- $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip");
-
- var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
- {
- Secrets = SecretMode.Exclude,
- IncludeChatHistory = true,
- IncludeLogs = false
- });
-
- _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
- result.ZipPath, result.SizeBytes);
-
- ApplyRotation(Path.GetFullPath(folder), safeName, keepCount);
-
- OnBackupCreated?.Invoke(result.ZipPath);
- }
-
- /// Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.
- private void ApplyRotation(string folder, string safeName, int keepCount)
- {
- if (keepCount <= 0 || !Directory.Exists(folder))
- return;
-
- try
- {
- var prefix = $"backup_{safeName}_";
-
- var obsolete = new DirectoryInfo(folder)
- .GetFiles("*.zip")
- .Where(f => f.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- .OrderByDescending(f => f.LastWriteTime)
- .Skip(keepCount)
- .ToList();
-
- foreach (var file in obsolete)
- {
- file.Delete();
- _logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name);
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
- }
- }
-
- public void Dispose()
- {
- _timer.Stop();
- _timer.Dispose();
- }
-}
diff --git a/Services/SettingsManager.cs b/Services/SettingsManager.cs
deleted file mode 100644
index 8d67edb..0000000
--- a/Services/SettingsManager.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System.Text.Json;
-using ClawdDotNet.Models;
-
-namespace ClawdDotNet.Services;
-
-public sealed class SettingsManager
-{
- private const string SettingsFileName = "Settings.json";
-
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- WriteIndented = true,
- ReadCommentHandling = JsonCommentHandling.Skip,
- AllowTrailingCommas = true,
- PropertyNameCaseInsensitive = true
- };
-
- private readonly string _settingsPath;
-
- public AppSettings AppSettings { get; private set; } = new();
-
- public SettingsManager(string? basePath = null)
- {
- var dir = basePath ?? AppDomain.CurrentDomain.BaseDirectory;
- _settingsPath = Path.Combine(dir, SettingsFileName);
- }
-
- public void Load()
- {
- if (!File.Exists(_settingsPath))
- {
- AppSettings = new AppSettings();
- Save(); // Defaults schreiben
- return;
- }
-
- try
- {
- var json = File.ReadAllText(_settingsPath);
- AppSettings = JsonSerializer.Deserialize(json, JsonOptions)
- ?? new AppSettings();
- }
- catch
- {
- AppSettings = new AppSettings();
- }
- }
-
- public void Save()
- {
- try
- {
- var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
- ClawdDotNet.Core.Storage.AtomicFile.WriteAllText(_settingsPath, json);
- }
- catch (Exception ex)
- {
- // Logging ist hier ggf. noch nicht verfügbar – Fallback auf MessageBox
- MessageBox.Show(
- $"Settings konnten nicht gespeichert werden:\n{ex.Message}",
- "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
- }
- }
-}
diff --git a/UI/BackupPanel.cs b/UI/BackupPanel.cs
index 5f57a12..9e1e8d6 100644
--- a/UI/BackupPanel.cs
+++ b/UI/BackupPanel.cs
@@ -335,7 +335,7 @@ public sealed partial class BackupPanel : UserControl
{
if (SelectedBackupPath is not { } path) return;
- System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
+ ClawdDotNet.Core.Storage.SystemShell.RevealFile(path);
}
private void DeleteSelected()
diff --git a/docs/Agentenkommunikation-Konzept.md b/docs/Agentenkommunikation-Konzept.md
new file mode 100644
index 0000000..fa21fb9
--- /dev/null
+++ b/docs/Agentenkommunikation-Konzept.md
@@ -0,0 +1,318 @@
+# Agentenkommunikation — Erfassung, Ansicht, Auswertung
+
+Ziel: Die **gesamte** Kommunikation zwischen Agenten wird erfasst, ist im WhatsApp-Stil
+paarweise nachlesbar und lässt sich von einem Agenten automatisiert auswerten — um zu
+finden, wo die Zusammenarbeit klemmt.
+
+Abgegrenzt davon: Rocket.Chat (siehe [RocketChat-Nextcloud-Konzept](RocketChat-Nextcloud-Konzept.md))
+trägt **ausschließlich** das, was ein Mensch wissen soll. Interne Absprachen der Agenten
+gehen dort nie hin.
+
+Aufbauend auf [Audit-Konzept](Audit-Konzept.md) (A3) und [Taskboard-Konzept](Taskboard-Konzept.md) (A1).
+
+---
+
+## 1 — Beschlüsse (August 2026)
+
+| # | Beschluss |
+|---|---|
+| 1 | **Korrelation**: `ParentRunId` + `RootRunId` in Audit-Log und Receipts. Eine Delegationskette wird damit zu einer Abfrage. |
+| 2 | **Nachrichtenspeicher**: eigene Tabelle `AgentMessages`, die alle Wege gleich behandelt und beide Richtungen festhält. |
+| 3 | **Rocket.Chat bleibt außen vor** — kein Spiegeln der Agentenkommunikation dorthin. Ein Gruppenchat mit allem drin wäre unlesbar. |
+| 4 | **Ansicht**: Paar auswählen (Agent A / Agent B), Verlauf im Chat-Stil scrollen. |
+| 5 | **Auswertung**: ein Analyse-Tool, das ein dafür vorgesehener Agent bekommt. |
+
+---
+
+## 2 — AgentComm behalten oder durch das Taskboard ersetzen?
+
+Das war die offene Frage. Der Befund zuerst, die Empfehlung danach.
+
+### 2.1 Was heute passiert
+
+`AgentComm.send_message` ist ein **synchroner Aufruf**: A ruft, `SendMessageAsync` startet
+`ChatAsync(B)`, wartet auf den vollständigen Lauf von B und gibt dessen Schlussnachricht
+als Tool-Ergebnis an A zurück. Drei Eigenschaften folgen daraus:
+
+- **Es gibt keine Tiefenbegrenzung** (B8). A→B→A→B… läuft, bis ein Timeout greift.
+- **Es kann echt verklemmen.** Seit B2 serialisiert ein Gate je Agent alle Läufe. A hält
+ sein Gate, während es auf B wartet. Ruft B nun `send_message(A)`, wartet B auf As Gate —
+ das A hält, während es auf B wartet. Das löst nur der Timeout auf. Der Selbstaufruf
+ A→A wurde damals abgefangen, der Zweierzyklus nicht.
+- **Der Fehler ist teuer**: Jeder Hop ist ein vollständiger, bezahlter Lauf.
+
+### 2.2 Was ein Task nicht kann
+
+Trotzdem ist „einfach alles über Tasks" nicht ohne Verlust. Zwei Dinge kann der
+asynchrone Weg strukturell nicht:
+
+- **Antwort im selben Lauf.** Bei `send_message` kommt die Antwort als Tool-Ergebnis
+ zurück, und A arbeitet damit sofort weiter. Über einen Task endet As Lauf; die Antwort
+ kommt später als neuer Weckvorgang, und A muss seinen Gedankengang neu aufnehmen. Für
+ eine Rückfrage sind das **zwei Läufe statt einem** — der asynchrone Weg ist hier also
+ nicht nur langsamer, sondern *teurer*.
+- **Antwortzeit.** Der Scanner tickt im Minutentakt. Eine Rückfrage „hast du die Datei
+ schon abgelegt?" braucht damit im Mittel eine halbe Minute plus den Lauf des anderen.
+
+### 2.3 Empfehlung: nach Zweck trennen, nicht beides parallel führen
+
+Der Fehler in der jetzigen Lage ist nicht, dass es zwei Mechanismen gibt — es ist, dass
+**beide dasselbe können**. Ein Agent kann Arbeit sowohl per Task delegieren als auch per
+`send_message` „mal eben" abschieben, und der zweite Weg ist der gefährliche.
+
+Vorschlag:
+
+> **Delegation gehört ausschließlich ins Taskboard.** `AgentComm` verliert diese Rolle
+> vollständig — kein „mach du mal", kein Auftrag, kein Arbeitspaket.
+>
+> **Die kurze Rückfrage bleibt**, aber als eigenes, eng gefasstes Werkzeug: `ask_agent`.
+
+`ask_agent` mit harten Grenzen:
+
+| Grenze | Begründung |
+|---|---|
+| **Tiefe 1** — wer gerade eine Rückfrage *beantwortet*, darf selbst keine stellen | Beendet die Rekursion an der Wurzel. Prüfbar, sobald `ParentRunId` steht (Punkt 1) — die Synergie ist der Grund, warum das jetzt fast umsonst zu haben ist |
+| **Zyklusprüfung** — Ziel darf nicht im aktuellen Aufrufpfad liegen | Schließt den Deadlock aus 2.1 aus, statt auf den Timeout zu hoffen |
+| **Kurzer Timeout + kleines Schrittbudget** (z. B. 60 s, 5 Schritte) | Eine Rückfrage, die fünf Schritte braucht, war keine Rückfrage, sondern ein Auftrag |
+| **Eigene Beschreibung im Prompt**: „für kurze Fragen an einen Kollegen, nicht um Arbeit abzugeben" | Der häufigste Missbrauch ist der falsche Griff, nicht die böse Absicht |
+
+Damit gibt es weiterhin zwei Wege, aber sie überschneiden sich nicht mehr: Der eine ist
+ein Auftrag (dauerhaft, nachvollziehbar, mit Abnahme), der andere eine Frage
+(flüchtig, sofort, begrenzt).
+
+**Die Gegenposition, fairerweise:** Man kann `AgentComm` auch ersatzlos streichen und die
+Rückfrage über einen Task mit hoher Priorität abbilden. Das wäre die konsequentere
+Umsetzung des „alles ist ein Task"-Prinzips und spart ein Tool. Der Preis sind die zwei
+Läufe je Rückfrage und die Minute Wartezeit. **Meine Empfehlung ist die Trennung**, weil
+Rückfragen im Mehr-Agenten-Betrieb häufig sind und der Aufpreis sich dann summiert — aber
+das ist eine Abwägung, keine technische Notwendigkeit.
+
+`AgentSpawn` geht in beiden Varianten im Taskboard auf (`assignee: @new:` ist genau
+das) und wird zurückgebaut.
+
+---
+
+## 3 — Korrelation: `ParentRunId` und `RootRunId`
+
+Heute erzeugt jeder Lauf eine frische `runId`; der Lauf des Empfängers weiß nichts vom
+Lauf des Absenders. Eine Kette A→B→C ist deshalb nur über Zeitstempel zu erraten.
+
+Zwei Felder auf `AuditEntry` und `RunReceipt`:
+
+- **`ParentRunId`** — der Lauf, aus dem dieser hervorging. `null` bei einem Lauf, den ein
+ Mensch oder der Scanner auslöst.
+- **`RootRunId`** — die Wurzel der Kette. Das ist faktisch die **Vorgangs-Id**: Alles, was
+ aus einer Anweisung entstand, trägt denselben Wert.
+
+Regeln:
+
+- **Die Engine stempelt.** Wie beim Audit gilt: Herkunft wird nie vom Agenten behauptet.
+- Ein Lauf ohne Vorgänger ist seine eigene Wurzel (`RootRunId = RunId`).
+- Weitergereicht wird über die Aufrufstellen, an denen ein Lauf einen anderen auslöst:
+ `ask_agent`, Task-Dispatch, Staging-Folgetask.
+- **Migration**: Bestandszeilen bekommen `RootRunId = RunId` und `ParentRunId = NULL`.
+ Das ist nicht rückwirkend korrekt, aber ehrlich — alte Ketten bleiben unbekannt, statt
+ falsch zusammengesetzt zu werden.
+
+Der Nutzen reicht über die Analyse hinaus: Was eine Delegationskette insgesamt gekostet
+hat, ist danach ein `SUM` über `RunReceipts` gruppiert nach `RootRunId`. Damit fällt ein
+Teil von C7 nebenbei ab.
+
+---
+
+## 4 — Der Nachrichtenspeicher
+
+### 4.1 Tabelle
+
+Neue Tabelle in der Instanz-DB, neben `AuditLog` und `RunReceipts`:
+
+```sql
+CREATE TABLE IF NOT EXISTS AgentMessages (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ PairKey TEXT NOT NULL, -- sortiertes Paar: "agentA|agentB"
+ FromAgentId TEXT NOT NULL,
+ ToAgentId TEXT NOT NULL,
+ Channel TEXT NOT NULL, -- ask | task | task_comment | task_result
+ Direction TEXT NOT NULL, -- request | response
+ Content TEXT NOT NULL, -- vollständig, nach Scrubbing
+ RunId TEXT NOT NULL,
+ ParentRunId TEXT,
+ RootRunId TEXT NOT NULL,
+ TaskId TEXT,
+ Status TEXT NOT NULL, -- delivered | failed | timeout | denied
+ OccurredAt TEXT NOT NULL
+);
+CREATE INDEX IX_AgentMessages_Pair ON AgentMessages(PairKey, OccurredAt);
+CREATE INDEX IX_AgentMessages_Root ON AgentMessages(RootRunId, OccurredAt);
+```
+
+Dazu ein FTS5-Index auf `Content` — dieselbe Technik wie beim geplanten Historien-Umzug
+(K6), damit die Suche in der Ansicht und im Analyse-Tool nicht über `LIKE` läuft.
+
+### 4.2 Die Entscheidungen dahinter
+
+**`PairKey` als sortiertes Paar.** Die geforderte Ansicht („A und B auswählen, scrollen")
+wird damit zu `WHERE PairKey = ? ORDER BY OccurredAt` — eine Abfrage auf einem Index,
+unabhängig davon, wer gerade wen anspricht.
+
+**Beide Richtungen als eigene Zeilen.** Eine Rückfrage erzeugt zwei Zeilen (`request`
+A→B, `response` B→A) mit derselben `RootRunId`. Nur so entsteht ein Verlauf, der sich wie
+ein Chat liest. Das Audit-Log kann das nicht leisten: Es speichert nur `Arguments`, die
+Antwort landet dort nirgends.
+
+**Inhalt ungekappt.** Das Audit kappt bei 4.000 Zeichen — richtig, denn es dient der
+Nachvollziehbarkeit. Für die Auswertung braucht es den vollen Text. Gekappt wird erst
+dort, wo Text in einen LLM-Kontext zurückfließt (Abschnitt 6).
+
+**Alle Wege in einer Tabelle.** `ask_agent`, Task-Delegation, `task_comment` und das
+Ergebnis eines Tasks landen im selben Format. Sonst müsste die Auswertung drei Quellen
+zusammensuchen — und genau daran scheitert sie heute.
+
+**Fan-out statt Sammelzeile.** Eine Nachricht an mehrere Empfänger wird zu mehreren
+Zeilen. Etwas redundant, dafür bleibt jede Zeile paarweise auswertbar.
+
+### 4.3 Wer schreibt
+
+Ein `IAgentMessageLog` im Core, aufgerufen an genau den Stellen, an denen eine Nachricht
+eine Agentengrenze überschreitet:
+
+| Aufrufstelle | Zeilen |
+|---|---|
+| `AgentEngine` — `ask_agent` | `request` beim Absenden, `response` beim Rückgabewert (auch bei Fehler/Timeout, mit passendem `Status`) |
+| Taskboard — `task_create` mit fremdem Assignee | `request` |
+| Taskboard — `task_comment` | `request` bzw. `response`, je nach Richtung |
+| Taskboard — Task abgeschlossen/geblockt | `response` mit Ergebnis oder Blocker-Grund |
+
+Drei bis vier Stellen, alle im Core. Kein Tool schreibt selbst — sonst könnte ein Agent
+seine eigene Kommunikationsakte färben.
+
+**Fehlschläge werden mitgeschrieben.** Eine nicht zugestellte Nachricht ist für die
+Analyse wertvoller als eine erfolgreiche.
+
+### 4.4 Was hier nicht hineingehört
+
+- **Mensch↔Agent-Chat.** Das ist die Chat-Historie, ein anderer Gegenstand mit anderem
+ Umzugsplan (K6). *Ausnahme mit gutem Preis-Leistungs-Verhältnis:* Tasks mit
+ `assignee: @human` durchlaufen dieselben Aufrufstellen — man kann sie als Paar
+ (Agent, `@human`) mitschreiben und bekommt die Ansicht dafür geschenkt. Vorschlag: ja,
+ aber als Nachzügler, nicht als Teil der ersten Fassung.
+- **Tool-Aufrufe.** Die stehen im Audit-Log und gehören nicht in einen Gesprächsverlauf.
+- **Rocket.Chat-Nachrichten.** Anderer Gegenstand, andere Vertrauensgrenze.
+
+### 4.5 Zwei Pflichten
+
+- **Output-Scrubbing vor dem Schreiben.** Nachrichteninhalte enthalten Tool-Ergebnisse.
+ Ohne Maskierung bekannter Geheimnisse wird dieser Speicher zur zweiten Fundstelle für
+ Zugangsdaten — und über den MySQL-Spiegel (A6) verlässt er sogar die Maschine. Der
+ Roadmap-Punkt „Output-Scrubbing" ist damit **Voraussetzung**, nicht Beiwerk.
+- **Aufbewahrung.** Die Tabelle wächst unbegrenzt. Ein Instanz-Wert
+ (`agentMessageRetentionDays`, 0 = unbegrenzt) plus ein Aufräum-Task gehören von Anfang
+ an dazu, nicht erst, wenn die DB groß ist.
+
+---
+
+## 5 — Die Ansicht
+
+Neuer Reiter im Hauptfenster: **Agentenkommunikation**.
+
+**Bedienung**: zwei Auswahlfelder (Agent A, Agent B) — dazu „alle" für einen Agenten, um
+zu sehen, mit wem er überhaupt spricht. Zeitraum, Kanalfilter, Freitextsuche.
+
+**Darstellung**: Chat-Stil, A rechts, B links, Zeitstempel, Tagestrenner. Jede Blase
+trägt eine kleine Kennzeichnung des Kanals (`Rückfrage` / `Auftrag` / `Kommentar` /
+`Ergebnis`) und, wo vorhanden, die anklickbare Task-Id.
+
+**Technisch**: über den vorhandenen **WebView2**-Unterbau, wie ihn `frm_chat` schon nutzt
+(inkl. Virtual-Host-Mapping auf einen lokalen Ordner). Der Verlauf wird als HTML
+gerendert. Handgezeichnete Sprechblasen in WinForms wären ein Vielfaches an Aufwand für
+ein schlechteres Ergebnis.
+
+**Paging**: die jüngsten ~200 Nachrichten, „ältere laden" nach oben. Ein Paar mit 50.000
+Zeilen darf die Oberfläche nicht am Start blockieren.
+
+**Der eigentliche Mehrwert** liegt über dem flachen Verlauf: Ein Klick auf eine Nachricht
+zeigt die ganze Kette zu ihrer `RootRunId` — also den Vorgang von der auslösenden
+Anweisung bis zum letzten Beitrag, über alle beteiligten Agenten hinweg, mit den Kosten
+aus den Receipts. Das ist die Ansicht, die die Frage „warum hat das drei Stunden und
+vier Dollar gekostet" tatsächlich beantwortet.
+
+---
+
+## 6 — Automatisierte Auswertung
+
+Ein Tool `AgentCommAnalysis`, das **bewusst nur einem dafür vorgesehenen Agenten**
+zugewiesen wird (die Zuweisung je Agent gibt es ohnehin).
+
+| Aktion | Zweck |
+|---|---|
+| `list_pairs` | Wer spricht mit wem, wie oft, seit wann — der Einstieg |
+| `stats` | Kennzahlen je Paar/Kanal/Zeitraum (siehe unten) |
+| `read_conversation` | Verlauf eines Paares, gekappt und seitenweise |
+| `chain` | Ein kompletter Vorgang über `RootRunId`, inkl. Kosten |
+| `search` | Volltext über FTS5 |
+
+**Fragen, die das beantworten soll** — sie sind der Grund für den Schnitt des Schemas:
+
+- Wie viele Delegationen führen zu einem Ergebnis, und wie viele versanden?
+- Wie tief werden Ketten, und ab welcher Tiefe steigt die Fehlerquote?
+- Welche Paare stellen sich wiederholt dieselbe Rückfrage? (Ein Hinweis auf unklare
+ Zuständigkeit oder einen fehlenden Skill — genau die Art Problem, die man sucht.)
+- Was kostet ein Vorgang von der Anweisung bis zum Ergebnis?
+- Wo häufen sich `failed`/`timeout`?
+
+**Drei Sicherungen**, weil ein Agent hier fremde Kommunikation liest:
+
+1. Ergebnisse werden als `` gerahmt. Der Inhalt stammt aus anderen
+ Läufen und kann Anweisungen enthalten — auch ohne böse Absicht.
+2. Standardmäßig liefert das Tool **Kennzahlen**, Rohtext nur auf ausdrückliche Anfrage
+ und mit harter Obergrenze. Ein unbedachtes „lies mir alles vor" ist sonst ein
+ Kontext-Überlauf mit Rechnung.
+3. Das Tool ist **lesend**. Es gibt keine Schreibaktion.
+
+---
+
+## 7 — Verhältnis zu Rocket.Chat
+
+Klargestellt, weil es die vorherige Überlegung ablöst:
+
+- Agentenkommunikation wird **nicht** nach Rocket.Chat gespiegelt. Ein Raum, in dem jede
+ interne Absprache mitläuft, ist nach einer Woche unlesbar und verdeckt genau das, was
+ man sehen soll.
+- Nach Rocket.Chat geht nur, was ein Mensch wissen soll oder muss — über den
+ `ChannelRouter` bzw. eine bewusste Handlung des Agenten.
+- Wer den internen Verlauf sehen will, nimmt die Ansicht aus Abschnitt 5. Die ist dafür
+ gebaut; ein Gruppenchat ist es nicht.
+
+---
+
+## 8 — Schnitt
+
+| Phase | Inhalt | Abhängigkeit |
+|---|---|---|
+| **1** | `ParentRunId` + `RootRunId` in `AuditLog`/`RunReceipts`, Migration, Stempelung in der Engine | — |
+| **2** | `AgentMessages` + FTS5 + `IAgentMessageLog`, Schreiben an den Aufrufstellen | 1 |
+| **3** | `ask_agent` (Tiefe 1, Zyklusprüfung), Rückbau von `AgentComm`/`AgentSpawn` | 1 — die Tiefe kommt aus der Kette |
+| **4** | Ansicht (WebView2-Reiter) | 2 |
+| **5** | `AgentCommAnalysis`-Tool | 2 |
+| **—** | Output-Scrubbing | **vor** 2 |
+
+Phase 1 zuerst, weil Phase 3 die Kette braucht und Phase 2 die Felder mitschreibt. Das
+Scrubbing muss vor Phase 2 stehen — sonst legen wir einen Speicher an, der erst
+nachträglich bereinigt werden müsste.
+
+Zur Einstufung: Phasen 1, 2, 4 und 5 sind gut spezifizierbar. Phase 3 fasst das
+Agent-Gate an, an dem schon einmal ein Deadlock lauerte (B2/B8) — dafür gehören die Tests
+aus der [Teststrategie](Teststrategie.md) mit dazu, insbesondere der dort vorgesehene,
+bis heute fehlende Fall **A13** (`A→B→A` wird begrenzt statt zu verklemmen).
+
+---
+
+## 9 — Offene Punkte
+
+1. **`ask_agent` behalten oder ersatzlos streichen?** Meine Empfehlung steht in 2.3
+ (behalten, eng gefasst) — die Gegenposition ist dort ebenfalls notiert.
+2. **`@human`-Tasks mitschreiben?** Gibt die Paar-Ansicht auch für Mensch↔Agent, fast
+ ohne Zusatzaufwand. Vorschlag: ja, aber nach Phase 4.
+3. **Aufbewahrungsdauer** — Vorgabewert? (Vorschlag: unbegrenzt, bis der Spiegel aus A6
+ steht; dann 180 Tage lokal.)
+4. **Wer bekommt `AgentCommAnalysis`?** Ein eigener Analyse-Agent oder ein bestehender?
diff --git a/docs/Audit-Konzept.md b/docs/Audit-Konzept.md
new file mode 100644
index 0000000..a8ba239
--- /dev/null
+++ b/docs/Audit-Konzept.md
@@ -0,0 +1,91 @@
+# Audit-Log & Receipts — Nachvollziehbarkeit
+
+Setzt A3 aus der [Roadmap](Roadmap.md) um (F-A2). Zwei zusammengehörige Dinge:
+
+- **Audit-Log** — ein Eintrag je Tool-Aufruf: wer, wann, welches Tool, mit welchem
+ Ausgang.
+- **Receipts** — ein Abschluss-Beleg je Lauf: Ergebnis, Schritte, Tokens, Kosten,
+ verknüpft mit dem Task.
+
+A3 ist das Fundament für A2 (jede Staging-Entscheidung wird als Datensatz verankert)
+und für C7 („Kosten pro Ergebnis", fällt aus den Receipts ab). Die Tool-Fehlerquote aus
+der Leistungsanalyse liest sich direkt aus dem Log.
+
+## Warum eigene Tabellen, nicht der State-Store
+
+Dieselbe Überlegung wie bei Gedächtnis und Taskboard: `IStateStore` ist Schlüssel-Wert.
+Ein Log, das man nach Lauf, Task oder Tool filtern und dessen Fehlerquote man auswerten
+will, braucht typisierte Spalten. Zwei Tabellen auf dem vorhandenen
+[`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs): `AuditLog` und
+`RunReceipts`.
+
+## Provenienz — von der Engine gestempelt, nie vom Agenten behauptet
+
+Die entscheidende Regel (aus dem OpenAlice-Provenance-Konzept):
+
+- **Herkunft stempelt die Engine.** `AgentId`, `Model` und `Source` kommen aus dem
+ Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Ein Tool kann seine
+ Herkunft nicht fälschen, weil es sie gar nicht schreibt.
+- **Einträge sind unveränderlich.** Das Repository hat kein Update und kein Delete —
+ eine Korrektur ist ein neuer Eintrag. Das ist die eigentliche Zusage, keine fehlende
+ Funktion.
+- **Unbekanntes wird als unbekannt markiert, nicht geraten.** Fehlt die Quelle, steht
+ `unknown`, nicht ein plausibel geratener Kanal.
+- **Worker-Typ und Session sind getrennt.** `Model` (das ausführende Modell) und
+ `Source` (die verantwortliche Session: `webview`, `telegram`, `task`, `agentcomm`,
+ `job`, `direct`) sind verschiedene Begriffe und stehen in eigenen Spalten.
+
+## Audit-Log
+
+Gestempelt an genau einer Stelle: `AgentEngine.ExecuteToolCallAsync` — dort, wo jeder
+Tool-Aufruf durchläuft. Je Aufruf ein Eintrag mit Ausgang:
+
+| Status | Wann |
+|---|---|
+| `Ok` | Tool lief und lieferte ein Ergebnis |
+| `Error` | Tool meldete einen Fehler oder warf |
+| `Denied` | das `PermissionGate` hat abgelehnt |
+| `NotFound` | Tool dem Agenten nicht zugewiesen/unbekannt |
+
+Ein Abbruch (Cancellation) wird **nicht** protokolliert — der Aufruf kam nicht zum
+Abschluss. Die Argumente werden roh, aber gekappt abgelegt (4 000 Zeichen); die
+Ausgangsnotiz kurz (500).
+
+**Best effort:** Ein Fehler beim Schreiben des Audits darf den Lauf nie scheitern
+lassen — dieselbe Linie wie bei der Verbrauchserfassung. Der Eintrag wird geschrieben,
+nachdem die eigentliche Arbeit getan ist.
+
+## Receipts
+
+Je Lauf ein Beleg, geschrieben beim Abschluss von `RunAsync`/`ChatAsync` (neben der
+vorhandenen `RunUsage`-Erfassung). Er trägt Status, Schritte, Prompt-/Completion-/
+Cached-Tokens, geschätzte Kosten (aus dem `ModelPricingCatalog`, mit
+`CostIsKnown`-Flag) und einen kurzen Ergebnis-Verweis.
+
+**Verknüpfung `RunUsage` ↔ Task:** Der Receipt trägt die `TaskId`, wenn der Lauf aus dem
+Taskboard kam — der `EngineTaskDispatcher` reicht sie (samt `source: task`) durch. Damit
+ist „Kosten pro Ergebnis" (C7) ein Abfallprodukt: `ListReceiptsForTaskAsync` liefert
+alle Belege zu einem Task.
+
+## RunId — die Klammer
+
+Jeder Lauf bekommt zu Beginn eine `RunId` (GUID). Alle Audit-Einträge **und** der
+Receipt eines Laufs tragen sie. So lässt sich ein Lauf lückenlos rekonstruieren:
+`ListForRunAsync(runId)` gibt die Aufrufe in Reihenfolge, `GetReceiptForRunAsync(runId)`
+den Abschluss.
+
+## Verdrahtung
+
+`IAuditRepository` ist optional (wie Gedächtnis und Taskboard): ohne Repo läuft die
+Engine unverändert. In `Program.cs` wird ein `SqliteAuditRepository` auf der Instanz-DB
+erzeugt und der Engine übergeben.
+
+## Offen
+
+- **Output-Scrubbing** — die `Arguments` können Secrets enthalten. Das zentrale
+ Maskieren bekannter Secret-Werte (eigener beschlossener Roadmap-Punkt) greift, sobald
+ es steht; der Andockpunkt (`ExecuteToolCallAsync`) ist derselbe.
+- **Review-Oberfläche** — die Anzeige/Durchsicht des Logs und der Receipts gehört zu A2
+ (Staging-Review im Hauptfenster); die Abfragemethoden dafür stehen bereit.
+- **Export** — ein JSONL-Export des Logs wäre für externe Auswertung nützlich (später,
+ passt zum A6-Spiegel).
diff --git a/docs/Avalonia-Portierungsleitfaden.md b/docs/Avalonia-Portierungsleitfaden.md
new file mode 100644
index 0000000..5b26cf7
--- /dev/null
+++ b/docs/Avalonia-Portierungsleitfaden.md
@@ -0,0 +1,186 @@
+# Avalonia-Portierung — Leitfaden
+
+Für alle, die weitere Ansichten von WinForms nach Avalonia übertragen.
+Stand: 2026-08-07.
+
+---
+
+## 1. Auftrag
+
+Drei Bereiche des Hauptfensters sind noch Platzhalter. In dieser Reihenfolge portieren —
+sie steigen im Umfang, und jede baut auf dem Muster der vorigen auf:
+
+| # | Bereich | WinForms-Vorlage | Daten aus |
+|---|---|---|---|
+| 1 | **Info** | `frm_main.Designer.cs`, Suchwort `tabPage_Info` | `AppHost.AppVersion`, `AppHost.BuildSummary`, `host.Instance` |
+| 2 | **Sicherung** | `UI/BackupPanel.cs` + `UI/BackupPanel.Designer.cs` | `host.InstancePath`, `host.Settings`, `Core.Backup.BackupService` |
+| 3 | **Aufgaben** (Jobs/Services/Verlauf) | `frm_main.cs`, Abschnitt `WORKER TAB` ab Zeile 932 | `host.Instance.Agents`, `App.Services.JobHistoryService` |
+
+**Nicht anfassen:** Chat und Einstellungen. Beide sind Entwurfsarbeit, nicht Übersetzung,
+und werden gesondert gemacht.
+
+Die WinForms-Dateien liegen noch im Repository, sind aber **nicht mehr Teil des Builds**
+(siehe Kommentar in `ClawdDotNet.slnx`). Sie sind Vorlage zum Lesen — nicht zum Kompilieren,
+nicht zum Reparieren.
+
+---
+
+## 2. Drei Regeln, die nicht verletzt werden dürfen
+
+### 2.1 Der Schichtschnitt
+
+```
+src/ClawdDotNet.App ← Fachlogik. KEIN Verweis auf Avalonia. Niemals.
+src/ClawdDotNet.Desktop ← Oberfläche. Darf App und Core verwenden.
+```
+
+`ClawdDotNet.App` muss ohne Fenster laufen — darauf setzt der geplante systemd-Dienst auf.
+Sobald dort ein `using Avalonia…` steht, ist der Schnitt kaputt und fällt erst Wochen
+später auf.
+
+**Faustregel:** Alles, was Dateien liest, rechnet oder mit der Engine spricht, gehört nach
+`App`. Alles, was etwas anzeigt, nach `Desktop`.
+
+### 2.2 Fäden
+
+Ereignisse aus `AgentEngine`, `TaskScanner`, `OpenRouterStatusService` und
+`BackupScheduler` kommen auf **Hintergrundfäden**. Eine `ObservableCollection` von dort aus
+zu ändern wirft entweder oder beschädigt still die Anzeige.
+
+```csharp
+// Aus einem Ereignis der Fachschicht heraus:
+Dispatcher.UIThread.Post(() => Lines.Add(neu));
+
+// Wenn ein Rückgabewert gebraucht wird:
+await Dispatcher.UIThread.InvokeAsync(() => …);
+```
+
+Ein `DispatcherTimer` läuft dagegen bereits auf dem Oberflächenfaden — dort ist kein
+Wechsel nötig (siehe `LogPageViewModel`).
+
+### 2.3 Avalonia **12**, nicht 11
+
+Praktisch alle Anleitungen im Netz sind für Avalonia 11 und lassen sich hier nicht
+übernehmen. Bekannte Unterschiede:
+
+- `BindingPlugins` ist nicht mehr öffentlich. Das übliche
+ `DisableAvaloniaDataAnnotationValidation()` aus den 11er-Vorlagen **entfällt ersatzlos** —
+ nicht nachbauen.
+- `ShutdownMode` voll qualifizieren: `Avalonia.Controls.ShutdownMode`.
+
+Diese Fehler brechen den Build. Das ist gut — sie fallen sofort auf.
+
+---
+
+## 3. Das Muster
+
+Der Logs-Bereich ist als vollständiges Beispiel gebaut. Drei Dateien, drei Aufgaben:
+
+**`src/ClawdDotNet.App/Services/LogTail.cs`** — die Fachlogik. Liest Dateien, kennt keine
+Oberfläche, wäre ohne Fenster lauffähig.
+
+**`src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs`** — das Ansichtsmodell. Erbt von
+`PageViewModel`, hält Zustand und Befehle. Kennt keine Steuerelemente.
+
+**`src/ClawdDotNet.Desktop/Views/LogPageView.axaml`** — die Ansicht. Nur Aufbau und
+Bindungen.
+
+### Ein neuer Bereich in vier Schritten
+
+**1.** Ansichtsmodell anlegen, von `PageViewModel` erbend:
+
+```csharp
+public sealed partial class InfoPageViewModel : PageViewModel
+{
+ public InfoPageViewModel(AppHost? host) : base("Info") { … }
+}
+```
+
+`AppHost?` ist **nullbar** — der Entwurfsmodus des Editors erzeugt das Ansichtsmodell ohne
+laufenden Aufbau. Bei `null` einfach nichts starten und Beispielwerte zeigen.
+
+**2.** Ansicht anlegen: `Views/InfoPageView.axaml` + `.axaml.cs`. Der Name muss der
+Konvention folgen — `ViewLocator` sucht `…ViewModels.FooViewModel` → `…Views.FooView`.
+Passt der Name nicht, steht der gesuchte Typ im Fenster statt der Ansicht.
+
+**3.** In `MainWindowViewModel` den Platzhalter ersetzen:
+
+```csharp
+new PlaceholderPageViewModel("Info", "…") // vorher
+new InfoPageViewModel(host) // nachher
+```
+
+**4.** `x:DataType` in der AXAML setzen. Ohne das greifen die kompilierten Bindungen nicht
+und Tippfehler in Bindungspfaden fallen erst zur Laufzeit auf.
+
+### Werkzeugkasten
+
+- Zustand: `[ObservableProperty] private string _text = "";` → erzeugt `Text` samt
+ Benachrichtigung.
+- Befehle: `[RelayCommand] private void Speichern() { … }` → bindbar als
+ `SpeichernCommand`.
+- Formatierung gehört in `Styles/Shell.axaml`, nicht an einzelne Steuerelemente.
+ Vorhandene Klassen: `heading`, `caption`, `toolbar`, `card`, `statusbar`.
+- Ordner öffnen: `Core.Storage.SystemShell.OpenFolder(pfad)`. **Kein** `explorer.exe`.
+- Dateinamen erzeugen: `Core.Storage.PortableFileName.Sanitize(name)`.
+
+---
+
+## 4. Prüfliste für die leisen Fehler
+
+Diese Klasse bricht weder den Build noch die Tests. Vor jeder Abgabe durchgehen:
+
+- [ ] **Fenster-Schließen behandelt?** Wartet der Code auf eine Antwort aus einem Fenster
+ (`TaskCompletionSource`), muss `window.Closed` als Abbruch gelten. Sonst hängt der
+ Ablauf lautlos für immer.
+- [ ] **Sammlungen nur vom Oberflächenfaden geändert?** Siehe 2.2.
+- [ ] **Wächst etwas unbegrenzt?** Listen, die im Betrieb volllaufen, brauchen eine
+ Obergrenze (`LogPageViewModel.MaxLines = 2000` als Vorbild).
+- [ ] **Timer beendet?** `DispatcherTimer` in einem Ansichtsmodell läuft weiter, auch wenn
+ der Bereich nicht sichtbar ist. Bei teuren Abfragen anhalten.
+- [ ] **Farben aus dem Thema?** Keine festen Farbwerte — die Anwendung läuft hell und
+ dunkel. `{DynamicResource …}` verwenden.
+- [ ] **Keine relativen Pfade.** `./Backups` und Ähnliches hängt vom Arbeitsverzeichnis ab
+ und zeigt unter Linux ins Leere. `AppPaths.DataDirectory` verwenden.
+- [ ] **Kein `MessageBox`, kein `System.Windows.Forms`, kein `System.Drawing`.**
+
+---
+
+## 5. Abnahme
+
+```bash
+dotnet build ClawdDotNet.slnx
+```
+
+```bash
+dotnet test tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj
+```
+
+Beide müssen fehlerfrei sein — 567 Tests, keine neuen Fehlschläge.
+
+**Und dann tatsächlich starten.** Die Oberfläche hat keine Testabdeckung; die Fehler aus
+Abschnitt 4 fallen ausschließlich beim Laufen auf.
+
+```bash
+dotnet run --project src/ClawdDotNet.Desktop
+```
+
+Hinweis: Ein Starttest hinterlässt unter Windows einen Prozess, der die `.exe` sperrt und
+den nächsten Build mit `MSB3021` scheitern lässt. Aufräumen mit:
+
+```bash
+powershell -Command "Get-Process ClawdDotNet -EA SilentlyContinue | Stop-Process -Force"
+```
+
+---
+
+## 6. Wenn etwas unklar ist
+
+Lieber nachfragen als raten. Zwei Dinge sind besonders leicht falsch zu machen:
+
+- **Was gehört in welche Schicht?** Im Zweifel nach `App` — von dort kann die Oberfläche
+ es holen, umgekehrt nicht.
+- **Wie kommen Daten aus der Engine in die Ansicht?** `AppHost` gibt `Engine`, `Scanner`,
+ `Staging`, `Status` und `Usage` heraus; alle sind **nullbar**, wenn kein
+ OpenRouter-Schlüssel hinterlegt ist. Diesen Fall mitdenken — die Anwendung läuft dann
+ bewusst ohne Agenten.
diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md
index ebc16eb..9c43c94 100644
--- a/docs/Bestandsaufnahme-2026-07.md
+++ b/docs/Bestandsaufnahme-2026-07.md
@@ -523,6 +523,11 @@ Siehe K3.
## 6. Vorgeschlagene Reihenfolge
+> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte
+> werden dort weitergeführt; dieser Abschnitt bleibt als Stand der Bestandsaufnahme
+> eingefroren. F-A1/S4, F-A2, F-A5, T6, T7 sowie B6–B8 sind in den Roadmap-Vorhaben
+> A1–A4 aufgegangen.
+
**Sofort — es blockiert oder gefährdet den Betrieb**
1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
@@ -535,9 +540,9 @@ Siehe K3.
6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`)
7. ~~T2 Tool-Ergebnisse kappen (= B5)~~ ✅ umgesetzt
8. ~~T3 Günstiges Compaction-Modell~~ ✅ umgesetzt
-9. ~~B4 Kostenerfassung korrigieren~~ ✅ teilweise: Prompt/Completion werden jetzt
- getrennt erfasst statt 50/50 geschätzt. Offen bleibt die veraltete, hartcodierte
- Preistabelle (`ModelPricing`) — Preise sollten vom `/models`-Endpoint kommen.
+9. ~~B4 Kostenerfassung korrigieren~~ ✅ vollständig: Prompt/Completion getrennt
+ erfasst, Preise kommen live vom `/models`-Endpunkt (`ModelPricingCatalog`),
+ Modelle ohne Preisdaten werden sichtbar gemeldet statt still mit 0 gerechnet.
10. ~~B12 Retry/Backoff~~ ✅ umgesetzt
11. T4 Proaktiv statt reaktiv kompaktieren
diff --git a/docs/Deploymentcenter-Anbindung-Review.md b/docs/Deploymentcenter-Anbindung-Review.md
new file mode 100644
index 0000000..40dc6a0
--- /dev/null
+++ b/docs/Deploymentcenter-Anbindung-Review.md
@@ -0,0 +1,308 @@
+# Deploymentcenter-Anbindung — Durchsicht
+
+> **Nachtrag 2026-08-08 — die Anbindung ist umgestellt, Server und SDK stehen auf 2.1.**
+> Abschnitt 4 und 5 sind abgearbeitet; wie es jetzt aussieht, steht in
+> [Deploymentcenter-Integration](Deploymentcenter-Integration.md).
+>
+> Mit **SDK 2.1 erledigt** (waren Befunde aus Abschnitt 3 bzw. aus der Durchsicht der
+> 2.0-Anbindung):
+>
+> - `HttpClient` ohne Zeitgrenze → intern 15 s. Unsere Umgehung (eigener Client mit
+> 8 s) ist zurückgebaut.
+> - HTTP 429/5xx entzogen die Lizenz, ohne den Zwischenspeicher zu befragen → jeder
+> Nicht-Erfolg führt jetzt in denselben Offline-Zweig, `IsTransient` macht den
+> Unterschied sichtbar. Unsere Behelfsprüfung auf `unknown_error` ist entfernt.
+> - `cache_ttl_hours` wurde ignoriert, die Gnadenfrist war faktisch unbegrenzt.
+> - `app_version` fest `"1.0.0"` → kommt jetzt aus `ReleaseInfo.Version`.
+> - `BuildInfo.targets` war nicht einbindbar (CS0433/CS0103) → erzeugt die Klasse im
+> eigenen Namensraum, ist eingebunden.
+> - `UpdateClient`: API-Zweig las snake_case in ein camelCase-Modell → eigenes Modell
+> `ApiReleaseInfo`, `is_critical` von der obersten Ebene.
+> - `DeactivateAsync` schickte den Shared Key zusätzlich als `X-Watchdog-Key`.
+> - Kein `CancellationToken` in der Lizenz-API.
+>
+> **Weiterhin offen** — betrifft das Deploymentcenter, nicht ClawdDotNet:
+>
+> - **2.1 (keine Signaturprüfung)** — unverändert. `LicenseInfo.PublicKeyBase64` ist
+> gestrichen, damit nichts Totes stehenbleibt und niemand Schutz vermutet, wo keiner
+> ist. Kommt die Signatur, kommt das Feld mit ihr zurück.
+> - **2.2 (v1-Ersatzhash)** und **2.3 (Klartext-Rückfall)** — unverändert, beides im
+> SDK zu beheben.
+> - **3 (HW-ID bei jedem Aufruf neu)** — clientseitig umgangen: einmal berechnet und
+> behalten.
+> - **`parent_source` ist nur eine `source`, kein Paar** — damit schließen sich „ein
+> Monitor je Instanz" und instanzweise Alarmunterdrückung gegenseitig aus.
+
+Stand: 2026-08-06. Geprüft: `J:\Softwareprojekte\Deploymentcenter` (Client, Server,
+Schema, beide Integrationsleitfäden) gegen den
+[HW-ID-v2-Vorschlag](Lizenz-HardwareId-v2-Implementierungsvorschlag.md) und die
+[Linux-Analyse](Linux-Portierung-Analyse.md).
+
+**Ergebnis vorweg: Die Lizenz blockiert den Linux-Umzug nicht mehr.** Alles, was
+an Hardware-ID v2 plattformrelevant war, ist da und richtig. Was hier steht, sind
+Punkte aus derselben Durchsicht — drei davon würden beim Ausrollen wehtun.
+
+---
+
+## 1. Was erledigt ist
+
+| Punkt aus dem Vorschlag | Umsetzung |
+|---|---|
+| Format `2::` | [HardwareId.cs:125](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs) |
+| **Kein `MachineName` im Hash** | `ComputeV2Hash`, `:138` — der wichtigste Punkt, sauber umgesetzt |
+| Quellenkette Windows/Linux | `:42–107`, inklusive `dmi-uuid` |
+| `IsPlausibleMachineId` (Länge, `uninitialized`, nur Nullen) | `:161` |
+| MAC-Filter über locally-administered-Bit | `:224` |
+| `/sys/class/net//device`-Prüfung | `:228` |
+| Erweiterte Stoppwortliste | `:23` — inkl. `br-`, `virbr`, `cni`, `cali` |
+| `machine.key` mit `0600` | `:290`, `SetUnixPermissions` mit `#if NET8_0_OR_GREATER` |
+| Vorgabe per Umgebungsvariable | `LicenseConfig.HardwareIdOverride`, beide Namen |
+| XDG-Auflösungskette, nie leerer Pfad | [LicenseConfig.cs:28](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs), mit `ValidateNonEmpty` |
+| Mehrfachziel `netstandard2.0;net8.0` | csproj, BouncyCastle nur im netstandard-Zweig |
+| `LLS2`-Hülle, AES-GCM, HKDF | [StateStore.cs:169](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) — Schlüssel aus HW-ID abgeleitet, bindet den Cache also echt an die Maschine |
+| `ILicensePrompt` + Konsolenfassung | vorhanden — genau das, was der kopflose Host braucht |
+| Servermigration v1→v2 | [LicenseService.php:108](../../Deploymentcenter/src/Modules/License/LicenseService.php), mit Prüfprotokolleintrag `hwid_migrated` |
+| Schema `hwid_version`/`hwid_source`/`platform` | `sql/migrations/v2_hardware_id.sql`, rückwärtskompatibel |
+| Verwaltungsansicht zeigt Quelle/Plattform | `public/index.php:1227` |
+
+`OperatingSystemHelpers` nutzt jetzt `RuntimeInformation`. Der Client hat auf dem
+Linux-Pfad keine Windows-Laufzeitabhängigkeit — `ProtectedData` wird nur unter
+`IsWindows()` aufgerufen.
+
+**Für die Portierung heißt das:** Punkt 4 aus der Entscheidungsliste der
+Linux-Analyse („Erlaubt LicenseLabrador den Wechsel der Hardware-ID?") ist
+beantwortet. Der Aufwandsblock „Lizenz" schrumpft von 3–5 PT auf **2–3 PT** —
+das ist jetzt reine Anschlussarbeit in ClawdDotNet, keine Konzeptarbeit mehr.
+
+---
+
+## 2. Drei Befunde, die vor dem Ausrollen geklärt sein sollten
+
+### 2.1 Es wird nichts signiert — die Lizenzprüfung ist eine Vertrauensfrage an DNS
+
+[LicenseClient.cs:62](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs):
+
+```csharp
+string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
+if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
+{
+ // → gültig
+}
+```
+
+Das ist die vollständige Prüfung. Es gibt im neuen Client **kein `Signature.cs`,
+keinen hinterlegten öffentlichen Schlüssel, keine Hüllenprüfung** — die Dateien
+`Signature.cs`, `LicenseResult.cs` und `LicenseState.cs` aus dem alten
+LicenseLabrador-SDK sind beim Umzug nicht mitgekommen.
+
+Folge: Wer die HTTP-Anfrage umlenken kann, hat eine gültige Lizenz. Ein Eintrag
+in `/etc/hosts`, ein Proxy, ein eigener DNS — die Antwort `{"status":"valid"}`
+genügt. Auf einem Linux-Server, den der Betreiber ohnehin vollständig
+kontrolliert, ist das kein Kunststück.
+
+Serverseitig sieht es passend dazu aus. `public/index.php:45`:
+
+```php
+'signature' => 'ED25519_SIG_' . base64_encode(hash('sha256', $lic['license_key'] . 'DC_OFFLINE_SECRET', true))
+```
+
+Das ist ein SHA-256 über den Lizenzschlüssel plus eine fest verdrahtete
+Zeichenkette — keine Signatur, sondern ein Wert, der jeder erzeugen kann, der den
+Quelltext kennt. Und `public/index.php:1993` im JavaScript:
+
+```javascript
+"ED25519_SIG_" + btoa(key + hwId).substring(0, 32)
+```
+
+Base64 der Eingabe, abgeschnitten. Auch kein Hash.
+
+Das ist erkennbar ein Platzhalter — nur trägt er einen Namen, der nach fertigem
+Verfahren klingt, und darauf verlässt sich [LicenseGate](Services/LicenseGate.cs)
+mit seiner harten Startsperre. **Es ist keine Portierungsfrage** (unter Windows
+gilt heute dasselbe) und auch kein Grund, den Linux-Umzug aufzuhalten — aber es
+sollte eine bewusste Entscheidung sein und nicht in dem Glauben untergehen, die
+Signaturprüfung sei bereits da.
+
+Wenn das Verfahren zurückkommen soll: Ed25519 über die kanonisch serialisierte
+Antwort, öffentlicher Schlüssel im Client einkompiliert, `nonce` aus der Anfrage
+in der signierten Nutzlast gegenprüfen (gegen Wiedereinspielung). Der alte
+`Signer.php` und `Signature.cs` sind im LicenseLabrador-Repo noch vorhanden und
+lassen sich als Vorlage nehmen.
+
+### 2.2 Der v1-Ersatzhash trifft die alten Aktivierungen nicht
+
+Der Migrationsweg ist auf beiden Seiten korrekt gebaut — er wird nur nie
+auslösen, weil der Client eine andere v1-ID berechnet als die, die in der
+Datenbank steht.
+
+Alt ([LicenseLabrador/HardwareId.cs:20](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)):
+
+```csharp
+rawBuilder.Append(machineId); // MachineGuid, sonst MAC
+rawBuilder.Append(Environment.MachineName); // direkt angehängt, kein Trenner
+→ sha256(machineGuid + machineName)
+```
+
+Neu ([HardwareId.cs:149](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs)):
+
+```csharp
+string raw = $"{Environment.MachineName}:{firstMac}";
+→ sha256(machineName + ":" + mac)
+```
+
+Andere Reihenfolge, anderer Trenner, und **MAC statt MachineGuid**. Auf jedem
+Windows-Rechner, auf dem `MachineGuid` lesbar war — also praktisch allen —
+stimmen die Hashes nicht überein. Der Server sucht die Altaktivierung, findet
+nichts und legt eine neue an: **genau der Platzverbrauch, den die Migration
+verhindern sollte.** Bei `max_activations = 2` ist danach ein Platz für den
+Linux-Server weniger da.
+
+Auch `GetFirstPhysicalMacLegacy` (`:254`) weicht ab: keine Stoppwortfilterung,
+keine Sortierung, erste Schnittstelle in Aufzählungsreihenfolge. Die alte
+Fassung nahm die alphabetisch erste *gefilterte* MAC.
+
+Zu tun: `GetLegacyHardwareId()` muss den v1-Algorithmus zeichengenau
+nachbilden — inklusive der alten Stichwortliste (`virtual`, `veth`, `docker`,
+`hyper-v`, `wsl`, `mullvad`, `wireguard`, `tap`, `tun`, `vpn`, `bluetooth`,
+`vmware`, `box`, `pseudo`, `loopback`, `npcap`, `pcap`), `OrderBy(…, Ordinal)`
+und `FirstOrDefault()`. Der Code steht im LicenseLabrador-Repo noch da und kann
+weitgehend übernommen werden.
+
+Am besten mit einem Test absichern, der einen bekannten Eingabewert gegen den
+erwarteten v1-Hash prüft — sonst fällt eine Abweichung erst auf, wenn die
+Aktivierungsplätze schon verbraucht sind.
+
+### 2.3 Der Klartext-Rückfall ist noch da, nur woanders
+
+[StateStore.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) —
+„Legacy Migration Check":
+
+```csharp
+string legacyJson = Encoding.UTF8.GetString(payloadBytes);
+var legacyData = JsonSerializer.Deserialize(legacyJson);
+if (legacyData != null)
+{
+ legacyData.SchemaVersion = 2;
+ Save(productSlug, hardwareId, legacyData);
+ return legacyData;
+}
+```
+
+Der LLS2-Zweig darüber ist genau richtig — Entschlüsselung fehlgeschlagen heißt
+Cache-Fehltreffer, kein Klartext. Der Zweig darunter hebt das wieder auf: Jede
+Datei ohne `LLS2`-Kennung wird als JSON gelesen und, wenn sie sich deserialisieren
+lässt, **übernommen und anschließend verschlüsselt neu geschrieben**.
+
+Durchgespielt: Eine von Hand angelegte `state.dat` mit
+
+```json
+{"SchemaVersion":2,"Status":"valid","ExpiresAt":99999999999,"MaxSeenTime":0}
+```
+
+wird angenommen. In `ValidateAsync` greift bei fehlender Verbindung der
+Cache-Zweig (`:110`): `Status == "valid"` ✓, `now < MaxSeenTime` ✗, `now >
+ExpiresAt` ✗ → **`IsValid = true`**. Die Bindung an die Hardware, die
+`DeriveKey(hardwareId, …)` sonst herstellt, ist auf diesem Weg umgangen; die
+Datei ist zwischen Maschinen übertragbar.
+
+Der Zweig hilft dabei nicht einmal beim eigentlichen Zweck. Die alte
+`LocalCacheData` hieß `last_envelope`, `max_seen_time`, `endpoints`,
+`last_license_key`; die neue `SchemaVersion`, `Status`, `ExpiresAt`, … Kein
+gemeinsames Feld, und `JsonSerializer` ist ohne
+`PropertyNameCaseInsensitive`/`JsonPropertyName` bei den Namen streng. Eine echte
+v1-Datei ergibt also ein Objekt mit lauter Vorgabewerten (`Status = "invalid"`)
+und ist als Cache wertlos.
+
+**Empfehlung: den Zweig ersatzlos streichen.** Er kostet Sicherheit und leistet
+nichts. Alte Cachedateien sollen verworfen werden — eine einmalige
+Online-Prüfung ist der ganze Preis.
+
+Nebenbei: `Checksum = hwInfo.HardwareId` ([LicenseClient.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs))
+ist keine Prüfsumme, sondern eine Kopie der HW-ID. Das Feld ist damit ohne
+Funktion — entweder mit einem HMAC über die übrigen Felder füllen oder entfernen,
+damit niemand später Schutz vermutet, wo keiner ist.
+
+---
+
+## 3. Kleinere Punkte
+
+| Fundstelle | Sache |
+|---|---|
+| [LicenseClient.cs:26](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs) | Eigener `HttpClient` je Instanz, nie freigegeben, **ohne Zeitgrenze** (Vorgabe 100 s). Der alte `LicenseConfig.HttpTimeout` war 6 s. In `LicenseGate.RunStartupCheck` bedeutet das bis zu 100 s Standbild beim Start, wenn der Server nicht antwortet. |
+| `:107` | `catch (Exception ex)` um den gesamten Block: Auch ein Fehler beim Auswerten einer *erfolgreichen* Antwort landet im Offline-Zweig. Ein defekter Server gilt dann als „offline". |
+| `:47` | `app_version = "1.0.0"` fest verdrahtet. ClawdDotNet hat `BuildInfo.Build` — sollte Parameter sein, sonst steht in der Verwaltungsansicht bei jeder Instanz dasselbe. |
+| `:32`, `:172` | `HardwareId.GetHardwareId()` bei jedem Aufruf neu: liest unter Linux Dateien und zählt Netzwerkschnittstellen auf. Einmal berechnen und halten. |
+| `HardwareId.cs:205` | MAC-Auswahl überspringt Schnittstellen, die nicht `Up` oder `Unknown` sind. Ein Kabel, das beim Start nicht steckt, ändert damit die Hardware-ID. Für die Ausweichlösung sollte der Betriebszustand keine Rolle spielen — sonst ist sie genau in dem Moment instabil, in dem sie gebraucht wird. |
+| `HardwareId.cs:231` | `/sys/class/net//device` ist ein Symlink. `Directory.Exists`/`File.Exists` folgen ihm — funktioniert, ist aber Zufall und sollte kommentiert sein. |
+
+---
+
+## 4. Watchdog: die Anbindung passt noch nicht
+
+Kein Linux-Thema, fällt aber in dieselbe Umbauarbeit.
+
+[WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) sendet an:
+
+| ClawdDotNet | Deploymentcenter |
+|---|---|
+| `POST /api/heartbeat` | `POST /api/watchdog/v1/ping` (nimmt auch `/heartbeat`) |
+| `POST /api/event` | `POST /api/watchdog/v1/event` |
+| `POST /api/register` | **existiert nicht** |
+
+Die Pfade sind also alle um `/watchdog/v1` zu ergänzen. Der Kopfzeilenname passt:
+`public/api/watchdog/v1/index.php:30` akzeptiert `X-Watchdog-Key`,
+`Authorization` und `X-Agent-Token`.
+
+Der Selbstregistrierungsweg aus [Program.cs:334](Program.cs:334) — mit dem
+Master-Token einen eigenen Agent-Token holen und in der Instanzkonfiguration
+zwischenspeichern — hat serverseitig kein Gegenstück mehr. Zu klären: Tokens
+künftig von Hand in der Verwaltung anlegen und in die Instanzkonfiguration
+eintragen, oder `/register` im Deploymentcenter nachziehen. Für den ersten Weg
+spricht, dass er den Master-Token gar nicht erst auf die Instanzen verteilt.
+
+Die Feldnamen des Ping-Rumpfs (`source`, `instance`, `type`, `status`, `message`,
+`interval`, `group`, `os`) sind gegen
+[InstanceHealthProvider](src/ClawdDotNet.Core/Watchdog/InstanceHealthProvider.cs)
+abzugleichen.
+
+---
+
+## 5. Was in ClawdDotNet zu tun ist
+
+| Datei | Was |
+|---|---|
+| [ClawdDotNet.csproj](ClawdDotNet.csproj) | Projektverweis von `..\LicenseLabrador\client-dotnet\…` auf `..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\…` umhängen. Langfristig als Submodul unter `external/` — der Kommentar dazu steht schon im csproj. |
+| [Services/LicenseGate.cs](Services/LicenseGate.cs) | Neu gegen `LicenseValidationResult` schreiben. `LicenseState` gibt es nicht mehr, `Status` ist jetzt eine Zeichenkette — `DescribeProblem` (`:125`) muss auf `revoked`/`expired`/`activation_limit`/`not_found` umgestellt werden. `MessageBox` durch `ILicensePrompt` ersetzen; die Konsolenfassung bringt der Client mit. |
+| [Services/LicenseInfo.cs](Services/LicenseInfo.cs) | `PublicKeyBase64` hat ohne Signaturprüfung keine Funktion mehr — entweder mit 2.1 zurückholen oder streichen, damit nichts Totes stehenbleibt. |
+| [Program.cs:112](Program.cs:112) | Lizenzprüfung so verlagern, dass sie ohne Fenster auskommt (kopfloser Host). |
+| Host (neu) | `--license-status`, `--license-set-key`, `--license-deactivate` — der Client bringt alles Nötige mit. |
+| [WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) | Pfade auf `/api/watchdog/v1/…`; Registrierungsweg klären (Abschnitt 4). |
+| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Abgelöst durch [Deploymentcenter-Integration](Deploymentcenter-Integration.md). |
+
+---
+
+## 6. Antwort auf die Ausgangsfrage
+
+**Ja — Avalonia und Linux sind damit machbar.** Die einzige Frage, die ich als
+möglicher Blocker außerhalb unserer Hand markiert hatte, ist geklärt: Der Client
+läuft auf beiden Plattformen, zielt auf `net8.0` (von net10.0 problemlos
+verwendbar), löst seinen Ablageort auch ohne `HOME` auf, und die HW-ID ist
+container- und umbenennungsfest.
+
+Der Lizenzblock in der Aufwandsschätzung fällt von 3–5 PT auf **2–3 PT**. Die
+Gesamtspanne bleibt bei **50–80 PT**, weil die Lizenz nie der große Posten war —
+das sind PropertyGrid und Chat-Ansicht.
+
+Zwei Dinge sollten aber vor dem Ausrollen erledigt sein, unabhängig von Linux:
+
+- **2.2 (v1-Ersatzhash)** — klein, aber wenn es beim Ausrollen falsch ist, sind
+ Aktivierungsplätze verbraucht und man bekommt sie nur einzeln über die
+ Verwaltung zurück. Das ist der Punkt mit dem schlechtesten Verhältnis von
+ Aufwand zu Schaden.
+- **2.3 (Klartext-Rückfall)** — eine Zeile weniger Code, dafür wieder das
+ Verhalten, das der `LLS2`-Umbau eigentlich herstellen sollte.
+
+**2.1 (keine Signaturprüfung)** ist eine eigene Entscheidung mit eigenem Umfang
+und hält den Umzug nicht auf. Sie sollte nur getroffen und nicht übersehen
+werden — der Name `ED25519_SIG_` im Serverquelltext legt sonst nahe, dass die
+Sache erledigt sei.
diff --git a/docs/Deploymentcenter-Integration.md b/docs/Deploymentcenter-Integration.md
new file mode 100644
index 0000000..b213983
--- /dev/null
+++ b/docs/Deploymentcenter-Integration.md
@@ -0,0 +1,338 @@
+# Deploymentcenter-Integration
+
+Stand: 2026-08-08, Deploymentcenter **2.1**. Ersetzt den früheren
+`Integrationsplan-WatchDog-LicenseLabrador.md`.
+
+ClawdDotNet spricht das [Deploymentcenter](../../Deploymentcenter/docs/README.md) als
+**eine** Gegenstelle an. Vorher waren es zwei Fremdprojekte mit je eigenem Server,
+eigenem Schlüssel und eigener Anleitung:
+
+| Vorher | Jetzt |
+|---|---|
+| WatchDog (`watchdog.mhdf.de`, `X-Watchdog-Key`) | Deploymentcenter-Modul Watchdog, `Authorization: Bearer` |
+| LicenseLabrador (`license.mhdf.de`, Ed25519-Public-Key) | Deploymentcenter-Modul Lizenz |
+| — | Update-Prüfung |
+| — | Fehler-Stream (ungefangene Ausnahmen) |
+| — | Bugtracker |
+
+Eine Adresse, ein Token. Beides steht in den Anwendungseinstellungen.
+
+---
+
+## 1. Was der Betreiber einzutragen hat
+
+| Ort | Wert |
+|---|---|
+| Einstellungen → Deploymentcenter → **Server-URL** | `https://dc.mhdf.de` (Vorgabe) |
+| Einstellungen → Deploymentcenter → **Token** | Master-Token mit `watchdog:ping` + `bugtracker:report` |
+| Einstellungen → Lizenz → **Lizenzschlüssel** | Der Schlüssel für das Projekt `clawddotnet` |
+| Worker-Tab → Dienst **Instanz-Watchdog** | einschalten, greift beim nächsten Start der Instanz |
+
+Das Token entsteht im WebUI unter **Token-Verwaltung → Master-Token erstellen**. Es
+wird verschlüsselt (DPAPI) in `Settings.json` abgelegt.
+
+> **Bis die Avalonia-Einstellungsansicht steht**, gibt es für diese Felder noch keine
+> Oberfläche — die Seite „Einstellungen" ist ein Platzhalter. Die Werte kommen
+> vorläufig von Hand in `Settings.json` (Ort steht beim Start im Protokoll:
+> `%APPDATA%\ClawdDotNet\Settings.json`, unter Linux `$XDG_CONFIG_HOME`) bzw. in die
+> `instance.json` der Instanz. Das Token wird beim ersten Speichern durch die Anwendung
+> verschlüsselt; im Klartext eingetragen funktioniert es ebenfalls, weil der
+> `SecretProtector` beide Richtungen verträgt.
+
+**Serverseitig ist eine Sache Pflicht**, sonst ist die Überwachung wertlos: der
+Evaluator-Cron. Ohne ihn ändert sich ein Monitor-Zustand nur beim Eintreffen eines
+Heartbeats — eine abgestürzte Instanz bliebe dauerhaft grün.
+
+```bash
+* * * * * curl -fsS -H "Authorization: Bearer " https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
+```
+
+---
+
+## 2. Watchdog — ein Monitor je Instanz
+
+Das war die Vorgabe und ist jetzt sauber abgedeckt: Der Server führt Monitore über das
+Paar `source` + `instance` (`UNIQUE KEY uq_monitor (source, instance)` in
+`sql/schema.sql`). Alle Instanzen melden unter `source = "clawddotnet"` und tragen ihre
+eigene `instance`. Fällt eine von dreien aus, fällt genau deren Monitor — und nur der
+schlägt Alarm.
+
+`instance` ist standardmäßig die `InstanceId` (stabil, aber im Dashboard nichtssagend).
+In den Instanz-Einstellungen lässt sich stattdessen ein Name eintragen
+([`WatchdogConfig.Instance`](../src/ClawdDotNet.Core/Config/WatchdogConfig.cs)); ein
+späterer Wechsel legt allerdings einen neuen Monitor an.
+
+**Eine Registrierung vorab gibt es nicht mehr.** Der Monitor entsteht beim ersten
+Heartbeat von selbst (`INSERT … ON DUPLICATE KEY UPDATE`). Der frühere Weg über
+`POST /api/register` hatte im Deploymentcenter nie ein Gegenstück — die alte Anbindung
+lief in dieser Form also gegen einen Endpunkt, den es nicht gibt.
+
+### Was der Heartbeat trägt
+
+```
+POST /api/watchdog/v1/ping
+Authorization: Bearer
+```
+
+| Feld | Inhalt |
+|---|---|
+| `source` / `instance` | `clawddotnet` / InstanceId bzw. eingestellter Name |
+| `status` | `ok`, `warning`, `error` — beim Beenden `stopped` |
+| `interval` | 60 s (Vorgabe). Daraus leitet der Evaluator ab: 2× → `warning`, 4× → `down` |
+| `message` | Instanzname + Kurzbegründung |
+| `os` | Betriebssystem + .NET-Version |
+| `version` | Produktversion (2.1). Landet in `watchdog_monitors.app_version` — bei mehreren Instanzen der Unterschied zwischen „läuft" und „läuft noch auf der alten Fassung" |
+| `checks` | `agents`, `scheduler`, `budget` — siehe unten |
+| `metrics` | `agentCount`, `runningChats`, `todayCostUsd`, `todayTokens` |
+
+### `checks` — der eigentliche Gewinn
+
+Ein Heartbeat beweist nur, dass ein Faden läuft. Deshalb geht der selbst ermittelte
+Zustand je Teilbereich mit; schlägt eine Prüfung fehl, stuft der Server einen als `ok`
+gemeldeten Beat auf `warning` herab und nennt in der Antwort die betroffene.
+
+| Prüfung | Fehlschlag bedeutet |
+|---|---|
+| `agents` | Kein OpenRouter-Key — die Instanz läuft, arbeitet aber nichts ab |
+| `scheduler` | Die Taktschleife des Aufgaben-Scanners ist ausgestiegen |
+| `budget` | Tagesgrenze für Kosten oder Token erreicht |
+
+„Scanner noch nicht gestartet" gilt **nicht** als Fehlschlag: Er läuft erst nach der
+Startabgleichung an, der erste Heartbeat geht sofort raus. Sonst gäbe es bei jedem
+Start ein `warning_raised` und kurz darauf ein `recovered` — zwei Einträge im
+Ereignisprotokoll für einen Normalvorgang.
+
+### Metriken sind nur Zahlen
+
+Der Server legt numerische Werte mit Zeitstempel ab (14 Tage) und vergleicht den
+aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische Werte
+verwirft er dabei stillschweigend — `instanceId`, `instanceName` und `buildVersion`
+standen früher in den Metriken und waren dort wirkungslos. Beschreibendes steht jetzt
+in `message` und `os`.
+
+### Angekündigtes Ende
+
+Beim Herunterfahren geht ein Heartbeat mit `status: "stopped"` raus, danach das Ereignis
+`stopped_graceful`. Der Evaluator lässt einen so gemeldeten Monitor in Ruhe. Ohne das
+erzeugte jedes geplante Beenden wenige Minuten später einen Fehlalarm.
+
+Nebenbei korrigiert: Die alte Anbindung schickte die Ereignisarten `start` und `stop` —
+beide stehen nicht auf der Liste des Servers und landeten stillschweigend als `started`.
+Jetzt sind es `started` und `stopped_graceful`.
+
+### Token je Instanz
+
+Beim ersten Start tauscht die Instanz das anwendungsweite Token über
+`POST /api/tokens/v1/provision` gegen ein eigenes, auf `watchdog:ping` und
+`bugtracker:report` beschränktes Sub-Token und legt es verschlüsselt in der
+Instanzkonfiguration ab. Danach liegt auf der Instanz nicht mehr das Master-Token, und
+ein einzelner Zugang lässt sich widerrufen, ohne die anderen mitzunehmen.
+
+Das ist derselbe Zweck, den die frühere Selbstregistrierung hatte. Scheitert es (etwa
+weil das hinterlegte Token selbst ein Sub-Token ist und keine weiteren ausstellen darf),
+wird mit dem hinterlegten Token gemeldet — Monitoring, das nur bei perfekter Rechtelage
+läuft, ist genau dann still, wenn man es braucht.
+
+---
+
+## 3. Lizenz
+
+Startprüfung in [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs). Die
+Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung einen mit
+AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an (`LLS2`,
+seit 2.1 Schema 3) und trägt damit über Ausfälle hinweg.
+
+### Urteil und Fehlversuch sind zwei verschiedene Dinge
+
+Das ist der Kern der 2.1-Anpassung. `LicenseValidationResult.IsTransient` unterscheidet:
+
+| | Statuswerte | Folge |
+|---|---|---|
+| **Urteil des Servers** | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Anwendung startet nicht bzw. beendet sich |
+| **Kein Urteil erhalten** | `server_unavailable`, `cache_expired` | Warnung, Betrieb läuft weiter |
+
+Nur das Urteil sperrt. Ein Serverausfall darf nicht jede Installation gleichzeitig
+aussperren — und eine Drosselung (`429`) oder ein `500` sind Aussagen über den Server,
+nicht über die Lizenz. Das gilt an beiden Stellen gleich: Startprüfung und laufende
+Nachprüfung fragen dasselbe Merkmal ab.
+
+> **Bewusst in Kauf genommen:** Ein Rechner, der die Gegenstelle nie erreicht, läuft
+> damit auf Dauer mit Warnung weiter — auch nach Ablauf der Gnadenfrist
+> (`cache_expired` ist laut SDK-Vertrag vorübergehend). Wer das anders will, prüft in
+> [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs) zusätzlich auf
+> `cache_expired` und behandelt es als Urteil. Es sollte eine Entscheidung sein, nicht
+> ein Nebeneffekt.
+
+### Offline-Gnadenfrist ist echt begrenzt
+
+Seit 2.1 wertet der Client `cache_ttl_hours` des Projekts aus (Vorgabe 168 h). Vorher
+galt faktisch das Ablaufdatum der Lizenz — bei einer Lizenz bis 2040 also unbegrenzt.
+Der verbleibende Rest steht in `CacheExpiresAt` und wird beim Start angezeigt, wenn die
+Prüfung aus dem Zwischenspeicher kam.
+
+`state.dat` steigt auf Schema 3; Schema 2 wird weiter gelesen. Ein Rückschritt auf ein
+älteres SDK verwirft den Zwischenspeicher — dann ist einmal eine Online-Prüfung nötig.
+
+### Kein Public-Key mehr
+
+Die frühere Fassung führte einen Ed25519-Public-Key als „Vertrauensanker". Im
+Deploymentcenter gibt es dazu keine Gegenseite — der Client liest ausschließlich das Feld
+`status`. Ein Schlüssel, der nichts prüft, ist schlimmer als keiner: Er lässt Schutz
+vermuten, wo keiner ist. Details in
+[Deploymentcenter-Anbindung-Review](Deploymentcenter-Anbindung-Review.md), Abschnitt 2.1.
+
+### Der Projekt-Slug ist `clawddotnet`
+
+[`LicenseInfo.ProductSlug`](../src/ClawdDotNet.App/Services/LicenseInfo.cs) gilt für
+**alle** Module — Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf
+dieselbe Tabelle `dc_projects` zu.
+
+Bis zur Umstellung stand hier `clawd`, der Name aus dem LicenseLabrador-Backend. Das
+ist eine Falle mit langer Zündschnur: Der Server beantwortet ein unbekanntes Projekt mit
+demselben `not_found` wie einen unbekannten Schlüssel — der Unterschied steht
+ausschließlich in `message` (`"Project not found"` gegen `"Invalid license key"`). Wer
+den Text nicht durchreicht, sucht den Fehler beim Lizenzschlüssel, während das Projekt
+gar nicht existiert. Der Torwächter gibt die Serverantwort deshalb mit aus und schreibt
+sie ins Protokoll.
+
+### Deaktivieren läuft über das WebUI
+
+`POST /api/license/v1/deactivate` verlangt den `shared_key` des Servers. Der gehört nicht
+in eine ausgelieferte Anwendung, deshalb ist der Weg die Hardware-Liste im WebUI
+(Schaltfläche „Freigeben").
+
+### Laufende Nachprüfung
+
+[`LicenseWatch`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) prüft alle zwölf
+Stunden nach — dieselbe Unterscheidung wie oben. Ohne das wirkt ein Widerruf erst beim
+nächsten Start, bei einem wochenlang laufenden Dienst also praktisch nie.
+
+---
+
+## 4. Version und Updates
+
+### Eine Stelle für die Version
+
+`` in [Directory.Build.props](../Directory.Build.props) ist die Wahrheit.
+`Deploymentcenter.BuildInfo.targets` (seit 2.1 einbindbar) erzeugt daraus zur
+Übersetzungszeit `ClawdDotNet.App.ReleaseInfo` mit `Version`, `GitCommit`,
+`GitCommitShort`, `BuildDateUtc`, `Channel` und `Summary`.
+
+Der Wert geht an vier Stellen nach draußen, die vorher alle geraten haben:
+
+| Stelle | Vorher |
+|---|---|
+| Aktivierungsliste (`app_version`) | fest `"1.0.0"` im SDK — jede Installation gleich |
+| Heartbeat (`version`) | gab es nicht |
+| Fehlermeldungen (`build`) | — |
+| Versionsvergleich der Update-Prüfung | `0.0.`, behelfsweise |
+
+Die Klasse heißt bewusst `ReleaseInfo`, nicht `BuildInfo`: Diesen Namen trägt in
+`ClawdDotNet.Core` schon ein von Hand geführter Zähler mit Änderungstext. Zwei
+gleichnamige Klassen mit verschiedener Bedeutung wären eine Falle. Umgestellt über
+`DeploymentcenterBuildInfoClass` in der csproj.
+
+### Prüfung
+
+Einmalig beim Start gegen `GET /api/updateservice/v1/check`, über
+`Deploymentcenter.Client.UpdateClient`. Läuft nebenher und blockiert nichts; liegt eine
+neuere Version vor, erscheint ein Hinweis mit Changelog. Ob und wann aktualisiert wird,
+entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
+erneuern, ist genau dann im Weg, wenn man sie braucht.
+
+Seit 2.1 liefern beide Wege vollständige Daten: die statische `latest.json` in camelCase,
+die API in snake_case, jeweils über ein eigenes Modell (`VersionInfo` bzw.
+`ApiReleaseInfo`). Vorher kam über den API-Zweig außer der Versionsnummer nichts an — und
+der ist genau der Rückfall, wenn die `latest.json` fehlt. Die Download-Adresse wird
+mitgeführt (`UpdateAvailability.DownloadUrl`), damit der `update-agent` später ohne
+weitere Änderung anschließen kann.
+
+Der `update-agent` ist noch **nicht** eingebunden, und solange kein Release über
+`pack-and-deploy` veröffentlicht wird, hat die Prüfung nichts zu finden.
+
+---
+
+## 5. Fehler-Stream
+
+Ungefangene Ausnahmen gehen an `POST /api/errors/v1/report`. Verdrahtet in
+[`App.axaml.cs`](../src/ClawdDotNet.Desktop/App.axaml.cs) an drei Stellen:
+`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException` und
+`Dispatcher.UIThread.UnhandledException`.
+
+Erst nach dem Aufbau verdrahtet, nicht in `Main`: Vorher gibt es weder Einstellungen
+noch Token. Die Kehrseite ist bewusst in Kauf genommen — ein Absturz *während* des
+Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.
+
+**Eigene Drosselung** in
+[`ErrorReporter`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs): Derselbe
+Fehler (Typ + oberste Stelle im Stacktrace) geht höchstens einmal alle fünf Minuten
+raus. Der Server drosselt auch, aber erst, nachdem die Anfragen über die Leitung waren.
+Die Fehlermeldung selbst gehört nicht zum Kennzeichen — sie enthält oft wechselnde
+Werte, und dann wäre jeder Aufruf ein neuer Fehler.
+
+Bekannte, harmlose Fehler lassen sich serverseitig unter **Bugtracker → Ignore-Regeln**
+stummschalten. Sie werden weiter gezählt; der Zähler ist der Zweck: Dass ein bekannter
+Fehler auftritt, ist normal — dass er plötzlich hundertmal so oft auftritt, bedeutet,
+dass sich etwas geändert hat.
+
+---
+
+## 6. Bugtracker
+
+[`BugtrackerClient`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) für
+bewusst formulierte Einträge (Fehler, Wunsch, Idee) mit Titel und Beschreibung, gegen
+`POST /api/bugtracker/v1/report`. Der Absender wird serverseitig aus dem Token
+abgeleitet und lässt sich nicht frei wählen.
+
+Der Client ist da und über `AppHost.Deploymentcenter.Bugtracker` erreichbar; **eine
+Oberfläche dafür fehlt noch** („Fehler melden"-Schaltfläche). Ein Agenten-Tool wäre der
+nächste sinnvolle Schritt — Agenten könnten dann selbst Wünsche und Fehler eintragen,
+und der Agenten-Workflow des Deploymentcenters (Claim/Lease über
+`manage?action=next`) würde sie abarbeiten.
+
+---
+
+## 7. Wo was liegt
+
+| Datei | Inhalt |
+|---|---|
+| [`Deploymentcenter/DeploymentcenterApi.cs`](../src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs) | Gemeinsamer Unterbau: Bearer-Header, HTTPS-Pflicht, Umschlag auspacken, `DeploymentcenterException` mit stabilem `Code` |
+| [`Deploymentcenter/Watchdog/`](../src/ClawdDotNet.Core/Deploymentcenter/Watchdog) | Heartbeat-Client, Zustandsermittlung, Takt-Dienst |
+| [`Deploymentcenter/ErrorReporter.cs`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs) | Fehler-Stream mit Drosselung |
+| [`Deploymentcenter/BugtrackerClient.cs`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) | Bugtracker-Einträge |
+| [`Deploymentcenter/TokenProvisioner.cs`](../src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs) | Sub-Token je Instanz |
+| [`Services/DeploymentcenterService.cs`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs) | Verdrahtung: Token beschaffen, Heartbeat starten, Update prüfen |
+| [`Services/LicenseGate.cs`](../src/ClawdDotNet.App/Services/LicenseGate.cs) | Startprüfung |
+| [`Services/LicenseWatch.cs`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) | Laufende Nachprüfung |
+
+Die Lizenz läuft bewusst **nicht** über `DeploymentcenterApi`: Sie hat ein eigenes
+Antwortformat (kein `status`/`error`-Umschlag — `status` trägt dort den Lizenzzustand),
+einen eigenen Zwischenspeicher und muss vor allem anderen laufen.
+
+Watchdog, Fehler-Stream und Bugtracker deckt das SDK `Deploymentcenter.Client` nicht ab;
+dafür ist der eigene Unterbau da. Hardware-ID v2, Lizenz-Zwischenspeicher und
+Update-Prüfung kommen aus dem SDK — die nachzubauen wäre Verdopplung.
+
+---
+
+## 8. Tests
+
+[`tests/ClawdDotNet.Core.Tests/Deploymentcenter/`](../tests/ClawdDotNet.Core.Tests/Deploymentcenter):
+Bearer-Header, Fehlerumschlag → Ausnahme mit Code (auch bei HTTP 200), HTTPS-Pflicht mit
+Localhost-Ausnahme, Heartbeat-Pfad und -Rumpf, zwei Instanzen → zwei Monitore, Checks
+und Metriken, Antwort-Auswertung, Drosselung des Fehler-Streams, Sub-Token-Bezug.
+
+---
+
+## 9. Offen
+
+1. **Oberfläche für den Bugtracker** — Client vorhanden, Schaltfläche fehlt.
+2. **Agenten-Tool für den Bugtracker** — würde den Agenten-Workflow des
+ Deploymentcenters nutzbar machen.
+3. **Release-Strecke** — `pack-and-deploy` aufrufen und `` dabei mitgeben,
+ danach `update-agent` einbinden. Die Versionsnummer selbst ist mit 2.1 erledigt.
+4. **SDK als Git-Submodul** unter `external/` statt Cross-Repo-Pfad.
+5. **Hierarchie** (`parent_source`): Läuft die Instanz auf einem Host, der selbst als
+ Monitor geführt wird, sollte sie ihn als übergeordnete Entität eingetragen bekommen —
+ sonst erzeugt ein Hostausfall eine Meldung je Instanz. Das ist im WebUI zu pflegen,
+ nicht im Client (siehe aber Anmerkung 2 in den Rückmeldungen).
diff --git a/docs/Konzepte-Backup-Finanz-Analyse.md b/docs/Konzepte-Backup-Finanz-Analyse.md
index 691d8a8..59e9a51 100644
--- a/docs/Konzepte-Backup-Finanz-Analyse.md
+++ b/docs/Konzepte-Backup-Finanz-Analyse.md
@@ -278,10 +278,14 @@ Rohzahlen.
# Vorgeschlagene Reihenfolge
+> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte
+> laufen dort als C1–C8 weiter; S4 + K2 sind in den Vorhaben A2/A3
+> (Staging-Freigabe, Audit-Log) aufgegangen.
+
| # | Was | Warum zuerst |
|---|---|---|
-| 1 | Atomares Schreiben | Datenverlust ist bereits eingetreten |
-| 2 | Backup + Restore mit Test | Schützt alles Folgende |
+| 1 | ~~Atomares Schreiben~~ ✅ | umgesetzt (`File.Replace`-Muster) |
+| 2 | ~~Backup + Restore mit Test~~ ✅ | umgesetzt inkl. Oberfläche im Settings-Tab |
| 3 | Marktkalender | Spart sofort Kosten, verbessert Datenlage |
| 4 | `Indicators` | Qualität hoch, Tokens runter |
| 5 | Ergebnisregister (Stufe 2) | Grundlage jeder Bewertung |
diff --git a/docs/Linux-Portierung-Analyse.md b/docs/Linux-Portierung-Analyse.md
new file mode 100644
index 0000000..a9ec1b8
--- /dev/null
+++ b/docs/Linux-Portierung-Analyse.md
@@ -0,0 +1,574 @@
+# Linux-Portierung — Analyse
+
+Stand: 2026-08-06. Reine Bestandsaufnahme und Aufwandsschätzung, **kein** Umbau.
+
+Frage: Was ist nötig, damit ClawdDotNet unter Linux läuft, und was kostet das?
+
+---
+
+## 0. Kurzfassung
+
+Die gute Nachricht zuerst: **Der Kern ist bereits portabel.** Alle 16 Bibliotheks-
+und beide Testprojekte zielen auf `net10.0` (nicht `net10.0-windows`), es gibt im
+gesamten Repository **kein einziges `DllImport`, keinen Registry-Zugriff und keine
+`System.Drawing`-Nutzung** in `src/`. Windows steckt an genau drei Stellen im Kern:
+DPAPI-Verschlüsselung, Groß-/Kleinschreibung bei Pfadvergleichen und die
+Zeitzonen-IDs.
+
+Die schlechte Nachricht: Die gesamte Bedienoberfläche — rund **8.900 Zeilen** in
+`frm_*.cs`, `UI/`, `Models/` und `Services/` — hängt an Windows Forms, an WebView2
+und, am unangenehmsten, an vier `PropertyGrid`-Instanzen, die praktisch die
+komplette Einstellungsverwaltung ausmachen. Dafür gibt es in Avalonia keine
+Eins-zu-eins-Entsprechung.
+
+**Empfehlung: den Umzug in zwei Schnitte teilen.** Ein kopfloser Host (ohne GUI)
+auf Linux ist in etwa **12–18 Personentagen** erreichbar und liefert den
+eigentlichen Nutzen — Agenten laufen auf einem Server, nicht auf einem
+Windows-Desktop. Die Avalonia-Oberfläche ist ein davon unabhängiges Vorhaben
+von **32–52 Personentagen**, das man danach in Ruhe angehen kann.
+
+Gesamt für „alles auf Linux, mit GUI": **50–80 Personentage.**
+
+---
+
+## 1. Bestandsaufnahme
+
+### 1.1 Was bereits portabel ist
+
+| Bereich | Zeilen | Zielframework | Windows-Abhängigkeit |
+|---|---:|---|---|
+| `src/ClawdDotNet.Core` | 8.959 | `net10.0` | nur DPAPI (1 Datei) |
+| 15 Tool-Projekte | 6.415 | `net10.0` | nur `.exe`-Pfade im SocialMediaManager |
+| `tests/` (348 Tests, 39 Dateien) | 6.888 | `net10.0` | 3 Testfälle mit `C:\`-Pfaden |
+
+Alle NuGet-Pakete laufen unter Linux: `Microsoft.Data.Sqlite` (bringt
+`e_sqlite3` nativ für linux-x64/arm64 mit), `MySqlConnector`, `Npgsql`,
+`Microsoft.Data.SqlClient`, `MongoDB.Driver`, `MailKit`, `FluentFTP`,
+`Telegram.Bot`, `WTelegramClient`, `SharpCompress`, `Snappier`,
+`Microsoft.Extensions.Logging`. Kein Paket muss ersetzt werden — mit zwei
+Ausnahmen (siehe 2.1 und 2.3).
+
+Auch die Dinge, bei denen man Ärger erwarten würde, sind sauber gelöst:
+
+- [AtomicFile.cs:167](src/ClawdDotNet.Core/Storage/AtomicFile.cs:167) — `Commit`
+ prüft `File.Exists` und weicht auf `File.Move` aus. `File.Replace` verlangt
+ unter Unix ebenfalls eine vorhandene Zieldatei; der Fall ist also schon
+ abgedeckt. Die Wiederholschleife ist unter Linux überflüssig, aber harmlos.
+- [TaskFrontmatter.cs:27](src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs:27) —
+ normalisiert `\r\n` und `\r` vor dem Zerlegen. Task-Dateien von einem
+ Windows-Rechner werden unter Linux korrekt gelesen.
+- Textdateien werden durchgängig als **UTF-8 ohne BOM** geschrieben
+ (`AtomicFile`, `FileLogWriter`, `AgentEditorTool`). Kein `Encoding.Default`,
+ keine Codepage-Fallen.
+- Zeitstempel gehen als `DateTime.UtcNow` in die Datenbank und werden mit
+ `DateTimeStyles.RoundtripKind` gelesen.
+
+### 1.2 Was am Windows-Teil hängt
+
+| Bereich | Zeilen | davon Designer |
+|---|---:|---:|
+| `frm_*.cs` (6 Formulare + Dialoge) | 4.946 | 1.865 |
+| `UI/` (BackupPanel, WebViewBridge, EmbeddedUiManager) | 1.087 | 428 |
+| `Models/` (PropertyGrid-ViewModels) | 1.257 | — |
+| `Services/` (4 Dienste, an WinForms-Timer gekoppelt) | 1.501 | — |
+| `Properties/` | 123 | — |
+| **Summe** | **8.914** | **2.293** |
+
+Dazu drei `.resx`-Dateien à ~272 KB (eingebettete Symbole/Bilder) und eine
+`frm_main.en.resx` für die englische Lokalisierung über den
+WinForms-Resx-Mechanismus.
+
+Steuerelement-Inventar aus den Designer-Dateien: 24 `Label`, 19
+`ToolStripButton`, 12 `TabPage`, 12 `Button`, 9 `TextBox`, 6 `DataGridView`, 6
+`ToolStrip`, 5 `ComboBox`, **4 `PropertyGrid`**, 4 `TableLayoutPanel`, 4
+`FlowLayoutPanel`, 3 `TabControl`, 3 `SplitContainer`, 1 `RichTextBox`, 1
+`ListView`, 1 `NotifyIcon`, 1 `DateTimePicker`, 1 `NumericUpDown`.
+
+Tabs in `frm_main`: Chat, Logs, Settings (mit Unter-Tabs App-Settings,
+Instance-Settings), Agent Settings, Jobs/Services (mit Unter-Tabs Jobs,
+Services, Job History), Info, Backup.
+
+---
+
+## 2. Die harten Brocken
+
+### 2.1 WebView2 → kein Linux (Chat- und Übersichts-Ansicht)
+
+`Microsoft.Web.WebView2` ist die einzige Windows-only-Paketabhängigkeit des
+Hauptprojekts und trägt die zwei sichtbarsten Ansichten:
+[frm_main.cs:235](frm_main.cs:235) und [frm_chat.cs:44](frm_chat.cs:44) laden
+`overview.html` bzw. `chat.html` aus `EmbeddedUI/` über
+`SetVirtualHostNameToFolderMapping` unter `https://ui.clwd.internal/`. Die
+Kommunikation läuft über [WebViewBridge.cs](UI/WebViewBridge.cs) —
+`WebMessageReceived` in die eine, `ExecuteScriptAsync` in die andere Richtung.
+
+Drei Wege, jeder mit einem eigenen Preis:
+
+| Variante | Was passiert | Aufwand | Risiko |
+|---|---|---:|---|
+| **A — Avalonia.WebView** | HTML/JS bleiben. Unter Linux rendert WebKitGTK, unter Windows weiterhin WebView2. Die Bridge wird auf die Abstraktion der Bibliothek umgeschrieben. | 4–6 PT | Bibliothek ist deutlich weniger reif als WebView2; WebKitGTK-Abhängigkeit muss auf dem Zielserver vorhanden sein; Verhalten unterscheidet sich je Plattform. |
+| **B — nativ neu in Avalonia** | Chat als echte Avalonia-Ansicht mit `ItemsControl` und einem Markdown-Renderer. `EmbeddedUI/` entfällt. | 8–12 PT | Kein Fremdrisiko, aber Neuentwicklung. Am Ende deutlich wartbarer als HTML-in-Container. |
+| **C — lokaler HTTP-Server + Systembrowser** | Die App liefert `EmbeddedUI/` über `http://localhost:port` aus, der Nutzer öffnet den Browser. | 3–4 PT | Bricht die Ein-Fenster-Anmutung. Passt aber ausgezeichnet zum kopflosen Betrieb — dort **ist** der Browser die Oberfläche. |
+
+**Empfehlung:** C für den kopflosen Host (fällt dort ohnehin an), B für die
+Desktop-Oberfläche. Variante A koppelt uns an eine Bibliothek, die weniger stabil
+ist als alles andere im Projekt.
+
+### 2.2 PropertyGrid → es gibt keinen Ersatz von der Stange
+
+Vier `PropertyGrid`-Instanzen in [frm_main.Designer.cs](frm_main.Designer.cs)
+bilden App-Settings, Instance-Settings, Agent-Settings und Tool-Settings ab. Sie
+werden vollständig durch Attribute gesteuert — **246 `[Category]`,
+`[DisplayName]`, `[Description]`-Angaben** verteilt auf vier Dateien:
+
+- [Models/ToolSettingsViewModels.cs](Models/ToolSettingsViewModels.cs) — 108
+- [Models/AgentSettingsViewModel.cs](Models/AgentSettingsViewModel.cs) — 54
+- [Models/AppSettings.cs](Models/AppSettings.cs) — 51
+- [Models/InstanceSettingsViewModel.cs](Models/InstanceSettingsViewModel.cs) — 33
+
+Dazu kommen `[TypeConverter(typeof(ExpandableObjectConverter))]` für
+verschachtelte Objekte, `[PasswordPropertyText(true)]` für Geheimnisse und ein
+eigener [ModelTypeConverter](Models/ModelTypeConverter.cs), der das
+Modell-Auswahlfeld dynamisch aus der OpenRouter-Modellliste füllt.
+
+Avalonia hat kein `PropertyGrid`. Zwei Möglichkeiten:
+
+1. **`Avalonia.PropertyGrid`** (Community, MIT). Versteht `Category`,
+ `DisplayName`, `Description`, `Browsable`, `ReadOnly` und
+ `ExpandableObjectConverter`. Die ViewModels und ihre Attribute könnten
+ weitgehend unverändert bleiben — das spart am meisten. Zu prüfen ist, ob der
+ dynamische `ModelTypeConverter` mit `GetStandardValues` unterstützt wird; das
+ ist der Punkt, an dem so etwas erfahrungsgemäß hakt. **Aufwand 6–8 PT**, plus
+ dauerhafte Abhängigkeit an ein Ein-Personen-Projekt.
+2. **Von Hand gebaute Einstellungsformulare.** Mehr Arbeit, aber wir bekommen
+ eine Oberfläche, die man Nutzern zumuten kann — das `PropertyGrid` ist
+ ehrlicherweise eine Entwickleransicht. Passwörter, Verzeichnisauswahl,
+ Validierung und die Modell-Auswahl werden dabei richtig statt behelfsmäßig.
+ **Aufwand 10–14 PT.**
+
+Das ist der größte Einzelposten der GUI-Portierung. Die Entscheidung kann und
+sollte man verschieben, bis das Grundgerüst steht.
+
+### 2.3 DPAPI → Geheimnisse liegen unter Linux im Klartext
+
+[SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42):
+
+```csharp
+if (!OperatingSystem.IsWindows())
+ return plainText;
+```
+
+Unter Linux verschlüsselt `Protect` **stillschweigend nicht**. OpenRouter-Key,
+Datenbank-Verbindungszeichenfolgen mit Passwort, Mail-Zugangsdaten und das
+Telegram-2FA-Passwort lägen im Klartext in `InstanceConfig.json` und
+`AgentSettings.json` — genau der Zustand, den S7 behoben hat. Auf einem Server,
+der per SSH erreichbar ist und gesichert wird, ist das schlechter als auf einem
+Einzelplatz-Windows.
+
+Dasselbe gilt für den Lizenz-Zustandsspeicher:
+`LicenseLabrador/client-dotnet/.../StateStore.cs:92` schützt seine Datei ebenfalls
+nur unter Windows per DPAPI.
+
+Zu klären ist also ein plattformübergreifendes Verfahren. Realistisch:
+
+- **AES-GCM mit Schlüssel aus einer Datei mit `0600`** neben der Konfiguration
+ (Linux) bzw. weiterhin DPAPI (Windows). Einfach, wirkt gegen versehentliche
+ Weitergabe und Backups, nicht gegen einen Angreifer mit demselben Benutzer —
+ dieselbe Schutzstufe wie DPAPI heute.
+- Optional zusätzlich `libsecret`/Schlüsselbund, wenn eine Desktop-Sitzung da
+ ist. Auf einem Server gibt es die nicht, also braucht es den Dateiweg ohnehin.
+
+Nebenwirkung, die man einplanen muss: **Konfigurationen sind nicht mehr zwischen
+Betriebssystemen austauschbar.** Ein `enc:v1:`-Wert von Windows ist unter Linux
+nicht lesbar und umgekehrt. `Unprotect` wirft dann korrekterweise eine
+`SecretProtectionException` ([SecretProtector.cs:81](src/ClawdDotNet.Core/Security/SecretProtector.cs:81)) —
+für den Umzug einer Instanz braucht es einen Migrationsweg (Präfix `enc:v2:`,
+Werte neu eintragen oder ein Export/Import-Kommando).
+
+**Aufwand 3–5 PT** inklusive Tests und Migration.
+
+### 2.4 Zeitzonen → das ist die stillste Fehlerquelle
+
+[TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161):
+
+```csharp
+try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
+catch { return TimeZoneInfo.Utc; }
+```
+
+Und [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32)
+schreibt `TimeZoneInfo.Local.Id` in die Task-Frontmatter. Auf dem
+Entwicklungsrechner ergibt das `"W. Europe Standard Time"`, unter Linux
+`"Europe/Berlin"`.
+
+Task-Dateien sind Markdown im `SharedWorkspace` und wandern zwischen Rechnern.
+Trifft eine Windows-ID auf ein System ohne die Umsetzungsdaten, greift das
+`catch` — und der Task läuft ab sofort nach **UTC statt Ortszeit**, also im
+Sommer zwei Stunden zu früh. Ohne Fehlermeldung, ohne Logeintrag. Ein Task, der
+um 08:00 die Marktübersicht holen soll, läuft um 06:00.
+
+.NET 6+ kann Windows-IDs unter Linux über ICU auflösen, aber nur wenn ICU
+vorhanden ist. In einem schlanken Container (Alpine ohne `icu-libs`, distroless)
+oder bei `InvariantGlobalization=true` ist es das nicht — dann schlägt jede
+Auflösung fehl und alles fällt auf UTC.
+
+Was zu tun ist:
+
+- Beim Schreiben auf **IANA normalisieren**
+ (`TimeZoneInfo.TryConvertWindowsIdToIanaId`), beim Lesen beide Formen
+ akzeptieren.
+- Das `catch` **nicht mehr still schlucken** — eine unbekannte Zeitzone muss
+ protokolliert werden, besser noch den Task als fehlerhaft markieren.
+- Das Zielsystem muss `tzdata` haben. Für Container explizit installieren.
+
+Verwandt: **82 Vorkommen von `DateTime.Now`/`UtcNow`**. Die meisten sind
+unkritisch, zwei fallen auf:
+[TaskboardService.cs:80](src/ClawdDotNet.Core/Tasks/TaskboardService.cs:80)
+schreibt `DateTime.Now`-Zeitstempel in Task-Dateien, und
+[LiveLogViewerService.cs:98](Services/LiveLogViewerService.cs:98) sucht die
+Logdatei des Tages über `DateTime.Now`. Server laufen üblicherweise mit `TZ=UTC`
+— dort wechselt die Logdatei dann um 02:00 Ortszeit statt um Mitternacht, und
+Task-Zeitstempel bekommen eine andere Bedeutung als bisher. Kein Fehler, aber
+eine Verhaltensänderung, die man kennen sollte.
+
+**Aufwand 2–3 PT.**
+
+### 2.5 Groß-/Kleinschreibung bei Pfaden → sicherheitsrelevant
+
+Linux-Dateisysteme unterscheiden Groß- und Kleinschreibung, Windows nicht. An
+vier Stellen wird das Gegenteil angenommen — und drei davon bewachen eine
+Sandbox-Grenze:
+
+- [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72) —
+ `normalizedCandidate.StartsWith(normalizedRoot, OrdinalIgnoreCase)`. Das ist
+ die Prüfung, die Agenten daran hindert, aus ihrem Arbeitsverzeichnis
+ auszubrechen.
+- [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174) —
+ Abgleich gegen die Liste geschützter Pfade.
+- [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155) — dieselbe
+ Einschließungsprüfung.
+- [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384).
+
+Unter Linux sind `/home/x/Workspace` und `/home/x/workspace` **zwei
+verschiedene Verzeichnisse**. Der Vergleich mit `OrdinalIgnoreCase` würde einen
+Pfad im zweiten als „innerhalb" des ersten durchwinken. Genauso liefe die
+Sperrliste in `FileRWTool` ins Leere, sobald jemand die Schreibweise ändert.
+
+Nötig ist ein Vergleichsverfahren, das die Plattform berücksichtigt — ein
+`PathComparer`, der unter Windows `OrdinalIgnoreCase` und unter Unix `Ordinal`
+verwendet, konsequent an allen vier Stellen.
+
+Ebenfalls betroffen, aber harmlos:
+[AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) schlüsselt
+seine Sperren mit `fullPath.ToLowerInvariant()`. Unter Linux teilen sich damit
+zwei verschiedene Dateien eine Sperre — das serialisiert zu viel, gefährdet aber
+nichts.
+
+**Aufwand 2–3 PT**, davon der größere Teil Tests.
+
+### 2.6 Prozessaufrufe und `.exe`-Annahmen
+
+- **`Process.Start("explorer.exe", …)`** — 4 Stellen
+ ([frm_main.cs:1552](frm_main.cs:1552), [frm_main.cs:1557](frm_main.cs:1557),
+ [frm_main.cs:1562](frm_main.cs:1562), [BackupPanel.cs:338](UI/BackupPanel.cs:338)).
+ Ersatz: `Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })`
+ bzw. `xdg-open`. Die Variante `explorer.exe /select,"…"` hat unter Linux kein
+ Gegenstück — dort öffnet man nur den Ordner.
+- **`Microsoft.VisualBasic.Interaction.InputBox`** — 3 Stellen
+ ([Program.cs:280](Program.cs:280), [Program.cs:291](Program.cs:291),
+ [frm_main.cs:658](frm_main.cs:658)), zwei davon für den interaktiven
+ Telegram-Login (Code und 2FA-Passwort). Braucht einen eigenen Dialog. Für den
+ kopflosen Betrieb ohnehin problematisch: **ein Login, der ein Eingabefenster
+ öffnet, blockiert einen Dienst.** Dort muss der Telegram-Login anders gelöst
+ werden (vorab per CLI, oder über die Weboberfläche).
+- **`yt-dlp.exe` / `ffmpeg.exe`** —
+ [SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782)
+ und `:825`. Die PATH-Suche davor funktioniert unter Linux bereits; nur die
+ Ausweichpfade sind fest auf `.exe` verdrahtet und laufen dort ins Leere.
+ Kleine Änderung, aber sie fällt sonst erst zur Laufzeit auf.
+
+**Aufwand zusammen 1–2 PT.**
+
+### 2.7 WinForms-Timer in der Dienstschicht
+
+`Services/` ist logisch kein UI-Code, hängt aber an
+`System.Windows.Forms.Timer`:
+
+- [BackupScheduler.cs:41](Services/BackupScheduler.cs:41)
+- [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38) — schreibt
+ zusätzlich direkt in eine `RichTextBox`
+- [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43)
+- [frm_main.License.cs:41](frm_main.License.cs:41)
+
+Der Backup-Zeitplan und die Lizenzprüfung gehören in den kopflosen Host und
+müssen dafür auf `System.Threading.PeriodicTimer` umgestellt werden. Der
+Log-Betrachter ist echte Oberfläche und wird ohnehin neu gebaut.
+
+**Aufwand 2–3 PT.**
+
+### 2.8 Lizenzierung — erledigt (Stand 2026-08-06)
+
+> **Nachtrag.** LicenseLabrador und WatchDog sind im **Deploymentcenter**
+> zusammengefasst, Hardware-ID v2 ist dort umgesetzt. Der Client
+> (`Deploymentcenter.Client`, `netstandard2.0;net8.0`) läuft auf beiden
+> Plattformen, die HW-ID ist container- und umbenennungsfest, der Ablageort
+> löst sich auch ohne `HOME` auf, und der Zustandsspeicher ist mit AES-GCM
+> plattformübergreifend verschlüsselt.
+>
+> **Damit ist die einzige potenziell blockierende Frage dieser Analyse
+> beantwortet.** Details und offene Punkte der Anbindung:
+> [Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md).
+
+Es bleibt reine Anschlussarbeit in ClawdDotNet: Projektverweis umhängen,
+[LicenseGate](Services/LicenseGate.cs) gegen die neue Ergebnisklasse schreiben
+(`LicenseState` ist entfallen, `Status` ist jetzt eine Zeichenkette), `MessageBox`
+durch das mitgelieferte `ILicensePrompt` ersetzen und die Lizenzprüfung aus
+[Program.cs:112](Program.cs:112) fensterfrei machen.
+
+**Aufwand 2–3 PT** (vorher 3–5).
+
+---
+
+## 3. Kleinere Punkte, die trotzdem beißen
+
+### 3.1 Kultur- und Zahlenformatierung
+
+Nur 16 Stellen im gesamten Projekt nennen eine Kultur explizit. Das heißt
+umgekehrt: fast alles formatiert mit `CurrentCulture`. Auf dem
+Entwicklungsrechner ist das `de-DE`, auf einem Server mit unbesetztem `LANG`
+ist es `InvariantCulture`. Aus `1,25` wird `1.25`.
+
+Wo das folgenlos bleibt:
+- **JSON** — `System.Text.Json` schreibt Zahlen immer invariant. Alle
+ Konfigurationen, Zustandsdateien und API-Aufrufe sind sicher.
+- **SQLite** — Werte gehen typisiert über Parameter, nicht als Text.
+
+Wo hinzuschauen ist:
+- Zeichenkettenverkettung in Logeinträgen und Prompts (`$"{cost:F4}"`). Wenn
+ eine Zahl mit deutschem Dezimalkomma in einen Prompt gerät, muss das Modell
+ raten.
+- Anzeigewerte in der Oberfläche — dort ist Ortsformat gewünscht, aber es sollte
+ bewusst gesetzt sein, nicht zufällig.
+
+**Empfehlung:** einmal alle Formatierungen durchgehen und trennen — invariant
+für alles Maschinenlesbare, `CurrentCulture` nur für die Anzeige. Am besten mit
+einem Analyzer (`CA1305`, `CA1304`, `CA1310`) als Warnung im Build, damit es so
+bleibt.
+
+**Aufwand 2–3 PT.**
+
+### 3.2 Globalisierungsmodus festlegen
+
+`InvariantGlobalization=true` macht das Publikat kleiner und ICU überflüssig —
+kostet aber `TimeZoneInfo.FindSystemTimeZoneById` (siehe 2.4), kulturabhängige
+Vergleiche und korrektes `ToLower()` für Umlaute. Für dieses Projekt mit
+zeitzonenabhängiger Planung ist das **keine Option**; die Entscheidung sollte im
+Projekt dokumentiert und ICU/tzdata als Voraussetzung festgehalten werden.
+
+Nebenbemerkung: `COLLATE NOCASE` in
+[SqliteMemoryRepository.cs:143](src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs:143)
+und [SqliteTaskRepository.cs:105](src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs:105)
+ist ASCII-beschränkt — `Ä` und `ä` gelten SQLite als verschieden. Das ist heute
+schon so und ändert sich beim Umzug nicht, ist also kein Portierungsthema,
+sondern eine bestehende Eigenheit.
+
+### 3.3 Zeilenenden
+
+1.230 Stellen verwenden `Environment.NewLine` oder `\r\n`. Für Logdateien ist
+das egal. Bei **Task-Dateien** und Agenten-erzeugten Dateien im geteilten
+Arbeitsverzeichnis führt es zu Rauschen: Eine Datei, die unter Windows
+geschrieben und unter Linux angefasst wird, ändert komplett ihre Zeilenenden.
+Wenn der Arbeitsbereich unter Git liegt oder synchronisiert wird, sieht jede
+Änderung wie eine Vollumschreibung aus. Der Parser kommt damit klar (siehe 1.1)
+— es ist eine Frage der Ordnung, kein Fehler. Empfehlung: für Task- und
+Konfigurationsdateien fest `\n` schreiben.
+
+### 3.4 Dateinamen
+
+`Path.GetInvalidFileNameChars()` liefert unter Windows 41 Zeichen, unter Linux
+genau zwei (`\0` und `/`). [FileLogWriter.cs:95](src/ClawdDotNet.Core/Logging/FileLogWriter.cs:95)
+säubert Modulnamen damit — unter Linux entstehen also Dateinamen, die auf
+Windows nicht mehr lesbar sind. Betrifft Sicherungen, die zwischen Systemen
+wandern. Ebenso die Windows-Sonderfälle `CON`, `PRN`, `AUX` und Namen mit
+abschließendem Punkt: unter Linux erlaubt, beim Rückspielen auf Windows nicht.
+Für den Sicherungs-/Wiederherstellungsweg über Systemgrenzen hinweg relevant.
+
+### 3.5 Ablageorte
+
+[SettingsManager.cs:24](Services/SettingsManager.cs:24) legt `AppSettings.json`
+neben die Programmdatei (`AppDomain.CurrentDomain.BaseDirectory`). Unter Windows
+in einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung typisch
+in `/opt/…` oder `/usr/lib/…` und ist für den Dienstbenutzer **nicht
+schreibbar**. Dasselbe gilt für die Zielordner `tools/`, `Logs/` und
+`Instances/`, die die Build-Ziele in `ClawdDotNet.csproj` neben der
+Programmdatei anlegen.
+
+Nötig ist eine Trennung von Programm und Daten nach XDG-Konvention:
+`$XDG_CONFIG_HOME` bzw. `/etc/clawddotnet` für die Konfiguration,
+`$XDG_DATA_HOME` bzw. `/var/lib/clawddotnet` für Instanzen und Datenbanken,
+`/var/log/clawddotnet` für Logs. Dazu Dateirechte: Instanzverzeichnisse mit
+Geheimnissen gehören auf `0700`, Konfigurationsdateien auf `0600` — unter
+Windows regelt das die ACL des Benutzerprofils, unter Linux muss man es setzen.
+
+**Aufwand 2–3 PT.**
+
+### 3.6 Tests
+
+Von 348 Tests sind fast alle portabel. Auffällig ist
+[WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49):
+
+```csharp
+[InlineData(@"C:\Windows\System32\config\SAM")]
+[InlineData(@"\\server\share\evil.txt")]
+[InlineData(@"C:\temp\datei.txt")]
+```
+
+Unter Linux liefert `Path.IsPathRooted(@"C:\temp\datei.txt")` **`false`** — das
+ist ein gewöhnlicher relativer Dateiname mit Doppelpunkt und Backslashes darin.
+Der Test prüft dort also etwas anderes als beabsichtigt. Und da er die
+Sandbox-Grenze absichert, ist das keine Kleinigkeit: Er muss
+betriebssystemabhängig aufgeteilt werden, mit einer eigenen Linux-Fassung
+(`/etc/passwd`, `../../etc/passwd`, Symlinks). Symlinks sind überhaupt ein
+Prüfpunkt, den es unter Windows so nicht gab — `Path.GetFullPath` löst sie
+**nicht** auf, `File.ResolveLinkTarget` schon. Ein Agent könnte im
+Arbeitsverzeichnis einen Symlink nach `/etc` anlegen und die Prüfung ginge
+durch.
+
+Ebenso in [YouTubeUrlTests.cs:118](tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs:118)
+(harmlos, nur Beispieldaten).
+
+**Aufwand 2–4 PT**, inklusive Symlink-Absicherung in `WorkspacePath` selbst.
+
+### 3.7 Bau und Auslieferung
+
+[Deploy-Build.ps1](Deploy-Build.ps1) setzt PowerShell 5.1 voraus, verwendet
+Backslash-Pfade und den festen Ausgabepfad `bin\Release\net10.0-windows`. Für
+Linux braucht es entweder eine `pwsh`-taugliche Fassung oder — besser — einen
+schlichten `dotnet publish -r linux-x64 --self-contained` mit einer
+systemd-Unit-Datei. Dazu:
+
+- systemd-Unit mit eigenem Dienstbenutzer, `Restart=on-failure`
+- Prüfen, ob der bestehende Watchdog-Heartbeat
+ ([Program.cs:367](Program.cs:367)) mit `systemd-notify` zusammenspielen soll
+- optional `.deb` oder AppImage für den Desktop-Fall
+
+**Aufwand 3–5 PT.**
+
+---
+
+## 4. Der empfohlene Schnitt
+
+Der entscheidende Befund dieser Analyse: **Die Oberfläche ist nicht der Grund,
+warum wir Linux wollen.** Der Grund ist, dass Agenten auf einem Server laufen
+sollen. [Program.cs](Program.cs) baut bereits alles — Speicher, Engine,
+Taskboard-Scanner, Watchdog, Lizenzprüfung — vollständig auf, **bevor**
+`frm_main` überhaupt entsteht (Zeilen 36–385 gegen 388–401). Diese Trennung
+existiert faktisch schon; sie muss nur formalisiert werden.
+
+### Stufe 1 — Kern Linux-fest und kopfloser Host (12–18 PT)
+
+| Schritt | PT |
+|---|---:|
+| Geheimnisse plattformübergreifend (2.3) | 3–5 |
+| Zeitzonen normalisieren, Fehler nicht mehr schlucken (2.4) | 2–3 |
+| Pfadvergleiche plattformabhängig + Symlink-Prüfung (2.5, 3.6) | 3–5 |
+| Prozessaufrufe, `.exe`-Pfade (2.6) | 1–2 |
+| Ablageorte und Dateirechte nach XDG (3.5) | 2–3 |
+| `ClawdDotNet.Host` — Startlogik aus `Program.cs` herauslösen, `PeriodicTimer` statt WinForms-Timer, Telegram-Login ohne Dialog | 4–6 |
+| Tests auf Linux grün, CI-Lauf für linux-x64 | 2–3 |
+
+**Ergebnis:** Die Anwendung läuft als systemd-Dienst auf einem Linux-Server. Die
+Windows-GUI bleibt unverändert bestehen und wird weiter benutzt. Das ist der
+Punkt, an dem der Nutzen anfällt.
+
+### Stufe 2 — Avalonia-Oberfläche (32–52 PT)
+
+| Schritt | PT |
+|---|---:|
+| Grundgerüst: Avalonia-Projekt, DI, Dispatcher, Shell mit Tabs, MVVM-Schicht | 5–7 |
+| Logs-Tab (`RichTextBox` → `SelectingItemsControl` mit Filterung) | 2–3 |
+| Agent-Settings: Liste, Werkzeugauswahl, Aktionsschaltflächen | 5–8 |
+| Einstellungs-Tabs — PropertyGrid-Ersatz (2.2) | 6–10 |
+| Jobs / Services / Job History (4 `DataGridView`) | 4–6 |
+| Backup-Panel | 3–4 |
+| Instance-Manager und die fünf Dialoge | 4–6 |
+| Chat-Ansicht (Variante B, siehe 2.1) | 8–12 |
+| Info, Statusleiste, Werkzeugleisten, Menü, Lokalisierung de/en | 3–4 |
+
+Die Spanne ist breit, weil zwei Entscheidungen noch offen sind (PropertyGrid-Ersatz
+und Chat-Variante). Sind die getroffen, lässt sich das auf etwa ±15 % genau
+angeben.
+
+### Stufe 3 — Auslieferung und Härtung (6–10 PT)
+
+Publish-Pipeline, systemd-Unit, Paketierung, Abnahme auf echter Hardware,
+Dokumentation, Umzugsweg für bestehende Instanzen.
+
+### Gesamt
+
+| | PT | bei Vollzeit |
+|---|---:|---|
+| Stufe 1 | 12–18 | 2,5–3,5 Wochen |
+| Stufe 2 | 32–52 | 6,5–10,5 Wochen |
+| Stufe 3 | 6–10 | 1,5–2 Wochen |
+| **Summe** | **50–80** | **10–16 Wochen** |
+
+---
+
+## 5. LiveCharts2
+
+Zur Einordnung: **Das Projekt enthält heute keine einzige Diagrammdarstellung.**
+Die Suche nach `Chart`, `Series` oder `Plot` findet nur JSON-Feldnamen der
+Yahoo-Finance-Abfrage in
+[DirectAPITool.cs:126](src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs:126).
+
+LiveCharts2 ist damit **keine Portierung, sondern neue Funktionalität** — sie
+gehört zum Trading-Teil, nicht zum Linux-Umzug, und ist in den 50–80 PT oben
+nicht enthalten. Wenn die Kursansichten kommen, sind dafür grob 5–10 PT
+zusätzlich zu rechnen. Das passt zu dem, was in
+[docs/Roadmap.md](docs/Roadmap.md) und der Notiz „Basis vor Trading härten"
+festgehalten ist: Erst die Basis, dann die Handelsansichten.
+
+Ein Punkt, der jetzt schon zählt: LiveCharts2 setzt auf SkiaSharp, genau wie
+Avalonia. Das spricht zusätzlich dafür, die Diagramme erst **nach** der
+Avalonia-Portierung zu bauen — sonst entstehen sie zweimal.
+
+---
+
+## 6. Was vor dem ersten Handgriff zu entscheiden ist
+
+1. **Ist das Ziel Server oder Desktop?** Bei „Server" reicht Stufe 1, und Stufe 2
+ kann entfallen oder durch eine Weboberfläche ersetzt werden. Das ändert die
+ Schätzung um den Faktor drei.
+2. **Chat-Ansicht: HTML behalten oder nativ neu bauen?** (2.1)
+3. **PropertyGrid: Fremdbibliothek oder eigene Formulare?** (2.2)
+4. ~~**Erlaubt LicenseLabrador den Wechsel der Hardware-ID?**~~ — **geklärt**,
+ siehe 2.8 und
+ [Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md).
+5. **Bleibt Windows als Zielplattform bestehen?** Wenn ja, muss alles doppelt
+ getestet werden, und die Geheimnis-Verschlüsselung braucht beide Wege plus
+ Umzugspfad. Wenn nein, wird 2.3 deutlich einfacher.
+
+Frage 1 und 5 beantworten sich vermutlich schnell; 2 und 3 kann man bis zum
+Beginn von Stufe 2 offenlassen, ohne Stufe 1 zu blockieren. Damit liegt nichts
+mehr außerhalb unserer Hand — **Stufe 1 kann beginnen.**
+
+---
+
+## Anhang — Vollständige Fundstellenliste
+
+| Thema | Datei:Zeile |
+|---|---|
+| DPAPI | [SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42), `:66`, `:90`, `:94` |
+| DPAPI (Lizenz) | `LicenseLabrador/client-dotnet/.../StateStore.cs:43`, `:92` |
+| Zeitzone | [TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161), [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32) |
+| Pfad-Groß-/Kleinschreibung | [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72), [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174), [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155), [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384), [AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) |
+| `explorer.exe` | [frm_main.cs:1552](frm_main.cs:1552), `:1557`, `:1562`, [BackupPanel.cs:338](UI/BackupPanel.cs:338) |
+| `VisualBasic.InputBox` | [Program.cs:280](Program.cs:280), `:291`, [frm_main.cs:658](frm_main.cs:658) |
+| `.exe`-Werkzeugpfade | [SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782), `:825` |
+| WinForms-Timer | [BackupScheduler.cs:41](Services/BackupScheduler.cs:41), [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38), [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43), [frm_main.License.cs:41](frm_main.License.cs:41) |
+| WebView2 | [frm_main.cs:235](frm_main.cs:235), [frm_chat.cs:44](frm_chat.cs:44), [WebViewBridge.cs](UI/WebViewBridge.cs), [ClawdDotNet.csproj](ClawdDotNet.csproj) |
+| PropertyGrid | [frm_main.Designer.cs](frm_main.Designer.cs) (4×), [Models/](Models/) (246 Attribute) |
+| Datenablage | [SettingsManager.cs:24](Services/SettingsManager.cs:24), [ClawdDotNet.csproj](ClawdDotNet.csproj) (Build-Ziele) |
+| Testdaten mit Windows-Pfaden | [WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49) |
+| Build-Skript | [Deploy-Build.ps1](Deploy-Build.ps1) |
diff --git a/docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md b/docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md
new file mode 100644
index 0000000..d08baa2
--- /dev/null
+++ b/docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md
@@ -0,0 +1,520 @@
+# Hardware-ID v2 — Implementierungsvorschlag
+
+Stand: 2026-08-06. Betrifft `LicenseLabrador` (Client + Server) und die
+Aufrufseite in ClawdDotNet ([Services/LicenseGate.cs](Services/LicenseGate.cs)).
+
+Anlass: Für den [Linux-Umzug](Linux-Portierung-Analyse.md) muss die
+Hardware-Bindung auf beiden Plattformen funktionieren. Bei der Durchsicht sind
+dabei zwei Probleme aufgefallen, die **nichts mit Linux zu tun haben**, aber
+denselben Code betreffen — die sollten in einem Zug mit erledigt werden.
+
+---
+
+## 1. Befund
+
+### 1.1 Der Rechnername steckt im Hash — das ist das eigentliche Problem
+
+[HardwareId.cs:43](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs):
+
+```csharp
+rawBuilder.Append(Environment.MachineName); // "for system isolation"
+```
+
+Folge: **Ein umbenannter Rechner ist eine neue Maschine.** Er verbraucht einen
+weiteren Aktivierungsplatz, und der alte bleibt für immer belegt
+(`max_activations` ist standardmäßig 2 — nach zwei Umbenennungen ist die Lizenz
+dicht). Das gilt bereits heute unter Windows.
+
+Unter Linux wird daraus ein Totalausfall: In einem Container ist der Hostname
+standardmäßig die gekürzte Container-ID, also **bei jedem Start ein anderer**.
+Die Lizenz wäre nach dem zweiten `docker run` verbraucht.
+
+Die Absicht („system isolation") ist auch nicht erfüllt: Der Rechnername steht
+ohnehin im Feld `hostname`, das der Server bei jeder Prüfung mitschreibt
+([LicenseService.php:112](../../LicenseLabrador/server/src/LicenseService.php)).
+Diagnostisch verlieren wir nichts, wenn er aus dem Hash verschwindet.
+
+### 1.2 Die MAC-Ausweichlösung ist unter Linux instabil
+
+[HardwareId.cs:86](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)
+nimmt die alphabetisch erste physische MAC. Unter Linux:
+
+- Die Stoppwortliste kennt `docker` und `veth`, aber **nicht** `br-` (Bridges),
+ `virbr` (libvirt), `cni`, `flannel`, `cali` (Kubernetes), `zt` (ZeroTier).
+- `NetworkInterfaceType` meldet unter Linux für die meisten virtuellen Geräte
+ schlicht `Ethernet` — die Typprüfung greift also nicht.
+- Bridge- und veth-MACs werden von systemd **je Boot neu zufällig** vergeben.
+
+Sortiert man solche Adressen mit, wechselt die Hardware-ID beim Neustart. Die
+Ausweichlösung ist damit unter Linux schlimmer als keine.
+
+### 1.3 Der Zustandsspeicher fällt still auf Klartext zurück
+
+[StateStore.cs:41](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)
+beim Lesen und `:90` beim Schreiben:
+
+```csharp
+try { decryptedData = ProtectedData.Unprotect(rawData, null, ...); }
+catch { decryptedData = rawData; } // ← Klartext wird akzeptiert
+```
+
+Unter Linux wirft DPAPI immer, also läuft alles über den Klartextzweig. Zwei
+Folgen:
+
+- `SECURITY.md` behauptet, der Cache sei „strikt an die `hardware_id` gebunden".
+ Das stimmt für die *Hülle* (die Prüfung in
+ [LicenseClient.cs:198](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)),
+ nicht für die Cache-Datei selbst.
+- Ernster: `max_seen_time` ist die Uhr-Rückdreh-Sperre
+ ([StateStore.cs:107](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)).
+ Wer eine `state.dat` von Hand schreiben kann, setzt den Wert auf 0 und stellt
+ die Systemuhr zurück. Der Klartext-Rückfall beim **Lesen** macht das möglich,
+ und zwar auf jeder Plattform, auf der DPAPI nicht greift.
+
+### 1.4 Ablageort bricht bei einem systemd-Dienst weg
+
+[LicenseConfig.cs:22](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseConfig.cs)
+verwendet `Environment.GetFolderPath(SpecialFolder.ApplicationData)`. Läuft der
+Dienst unter `User=clawd` ohne Heimatverzeichnis, ist `HOME` nicht gesetzt und
+`GetFolderPath` liefert einen **leeren String**. `Path.Combine("", slug,
+"license")` ergibt einen relativen Pfad — die Lizenz landet im Arbeitsverzeichnis
+oder gar nicht.
+
+### 1.5 Kein Formatkennzeichen, keine Plattformangabe
+
+Die Hardware-ID ist heute ein nackter SHA-256-Hex-String. Es gibt keine
+Möglichkeit, im Server zu erkennen, aus welcher Quelle oder von welchem
+Betriebssystem eine Aktivierung stammt — und keinen Weg, das Format je zu
+wechseln, ohne alle bestehenden Aktivierungen zu verlieren.
+
+**Randnotiz:** `OperatingSystemHelpers.IsWindows()` nutzt
+`Environment.OSVersion.Platform == PlatformID.Win32NT`. Das funktioniert
+zufällig richtig (Linux liefert `Unix`), ist aber die veraltete API.
+`RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` ist in netstandard2.0
+verfügbar und der korrekte Weg.
+
+---
+
+## 2. Zielbild: das Format
+
+```
+2::<64 Hex-Zeichen>
+
+Beispiele:
+ 2:win:9f3ab7c1… (Windows, MachineGuid)
+ 2:lin:41e0d5aa… (Linux, /etc/machine-id)
+ 2:lin:7c9182ff… (Linux, Vorgabe per Umgebungsvariable)
+```
+
+68 Zeichen — passt in `activations.hardware_id VARCHAR(128)` ohne
+Schemaänderung. Der Doppelpunkt ist unproblematisch, die Spalte ist
+`utf8mb4_unicode_ci` und wird nur verglichen.
+
+Der Hash selbst:
+
+```
+sha256( "LicenseLabrador-HWID-v2" ‖ "\n" ‖ plattform ‖ "\n" ‖ quelle ‖ "\n" ‖ rohwert )
+```
+
+- **Kein `MachineName`.** (1.1)
+- Die Domänenzeichenkette verhindert, dass derselbe Rohwert in anderem
+ Zusammenhang wiederverwendbar ist.
+- `quelle` geht mit in den Hash: Findet der Client später eine bessere Quelle,
+ ändert sich die ID bewusst und nachvollziehbar, statt zufällig.
+
+Zusätzlich gehen drei neue Felder mit in die Anfrage — **nicht** in den Hash,
+nur zur Diagnose und für die Migration:
+
+| Feld | Beispiel | Zweck |
+|---|---|---|
+| `hwid_version` | `2` | Formaterkennung serverseitig |
+| `hwid_source` | `machine-id` | Admin sieht, wie stabil die Bindung ist |
+| `legacy_hardware_id` | `` | Migration ohne Platzverlust (Abschnitt 4) |
+
+---
+
+## 3. Quellen je Plattform
+
+Reihenfolge = Priorität. Die erste Quelle, die einen nichtleeren, plausiblen Wert
+liefert, gewinnt.
+
+### 3.1 Vorgabe (alle Plattformen, höchste Priorität)
+
+```
+LicenseConfig.HardwareIdOverride (Code)
+LICENSELABRADOR_HWID (Umgebungsvariable)
+```
+
+Quelle: `override`. Der Rohwert wird trotzdem gehasht, damit das Format
+einheitlich bleibt.
+
+**Das ist der ehrliche Weg für Container und Serverbetrieb.** Heuristik kann dort
+nicht gewinnen — in einem Container gibt es keine Hardware, an die man binden
+könnte. Der Betreiber setzt einen stabilen Wert, hinterlegt ihn im
+Deployment-Geheimnis, und die Bindung ist so verlässlich wie dieser Wert. Eine
+Zeile in der systemd-Unit statt eines Ratespiels.
+
+### 3.2 Windows
+
+| # | Quelle | `hwid_source` |
+|---|---|---|
+| 1 | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (Registry64) | `machine-guid` |
+| 2 | Stabile physische MAC (Abschnitt 3.4) | `mac` |
+| 3 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` |
+
+Unverändert zu heute — nur ohne `MachineName` im Hash.
+
+### 3.3 Linux
+
+| # | Quelle | `hwid_source` | Anmerkung |
+|---|---|---|---|
+| 1 | `/etc/machine-id` | `machine-id` | Von systemd bei der Installation erzeugt, überlebt Neustarts und Kernel-Updates. Die richtige Wahl auf einem echten System. |
+| 2 | `/var/lib/dbus/machine-id` | `dbus-machine-id` | Ältere Systeme ohne systemd. |
+| 3 | `/sys/class/dmi/id/product_uuid` | `dmi-uuid` | SMBIOS-UUID, echte Hardware-Bindung. **Meist nur für root lesbar** (`0400`) — Versuch in `try` einpacken, kein Fehler wenn nicht lesbar. Bei VMs vom Hypervisor gesetzt und dort stabil. |
+| 4 | Stabile physische MAC (Abschnitt 3.4) | `mac` | |
+| 5 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | |
+
+Zwei Fallen bei `/etc/machine-id`, die geprüft werden müssen:
+
+- **Leer oder nur Zeilenumbruch.** Auf Systemen mit `systemd-firstboot` oder in
+ manchen Images existiert die Datei, ist aber leer. Muss als „nicht vorhanden"
+ behandelt werden, nicht als gültiger Wert — sonst haben *alle* diese
+ Installationen dieselbe ID.
+- **Der Wert `uninitialized`.** Genau diese Zeichenkette schreibt systemd, wenn
+ die ID im laufenden Betrieb noch nicht festgelegt ist. Ebenfalls verwerfen.
+
+```csharp
+private static bool IsPlausibleMachineId(string? v)
+ => !string.IsNullOrWhiteSpace(v)
+ && v.Trim().Length >= 16
+ && !v.Trim().Equals("uninitialized", StringComparison.OrdinalIgnoreCase)
+ && v.Trim().Trim('0').Length > 0; // nicht alles Nullen
+```
+
+### 3.4 MAC-Ausweichlösung, überarbeitet
+
+Die heutige Fassung nimmt `FirstOrDefault()` der sortierten Liste. Wenn eine
+Schnittstelle dazukommt oder wegfällt, kann sich damit die gewählte MAC ändern.
+Besser: **alle** gültigen MACs sortiert verketten — dann ändert sich der Wert
+nur, wenn sich die Netzwerkausstattung wirklich ändert, und nicht schon, weil
+eine Adresse hinzukommt, die vorher sortiert davor lag.
+
+Stoppwortliste erweitern um: `br-`, `virbr`, `cni`, `flannel`, `cali`, `weave`,
+`zt`, `tailscale`, `ipsec`, `sit`, `gre`, `dummy`, `bond`, `macvlan`, `ovs`.
+
+Zusätzlich hart ausschließen (unabhängig vom Namen):
+
+- Schnittstellen mit gesetztem **„locally administered"-Bit** (zweites Bit des
+ ersten Oktetts, `mac[0] & 0x02`). Genau das setzt systemd bei zufällig
+ erzeugten MACs für veth und Bridges. Ein sauberer, namensunabhängiger Filter —
+ und der wirksamste von allen.
+- Unter Linux zusätzlich prüfen: existiert
+ `/sys/class/net//device`? Fehlt das Verzeichnis, hat die Schnittstelle
+ kein physisches Gerät und ist virtuell. Das ist zuverlässiger als jede
+ Namensliste.
+
+```csharp
+// Namensunabhängig: zufällig erzeugte MACs tragen dieses Bit.
+private static bool IsLocallyAdministered(PhysicalAddress addr)
+{
+ var b = addr.GetAddressBytes();
+ return b.Length > 0 && (b[0] & 0x02) != 0;
+}
+```
+
+### 3.5 Erzeugte Datei als letzte Stufe
+
+`/machine.key` — 32 Zufallsbytes, Base64, Dateirechte `0600`.
+Wird nur angelegt, wenn keine Quelle davor greift.
+
+Das ist eine **Installations-** und keine Hardware-Bindung. Für einen Container
+ohne Vorgabe ist das aber die Wahrheit, und mit einem gemounteten Datenverzeichnis
+bleibt sie über Container-Neustarts stabil. `hwid_source` = `keyfile` macht dem
+Admin sichtbar, dass diese Aktivierung schwächer gebunden ist als die anderen.
+
+Wichtig: Die Datei gehört ins **Datenverzeichnis**, nicht neben die
+Programmdatei. Sonst ist sie bei jedem Deployment weg.
+
+---
+
+## 4. Migration v1 → v2 ohne Platzverlust
+
+Der Kern: Der Client kennt **beide** IDs und schickt beide mit. Der Server zieht
+die alte Aktivierung auf die neue ID um, statt eine zweite anzulegen.
+
+**Client** — `HardwareId` bekommt neben `GetHardwareId()` (v2) ein
+`GetLegacyHardwareId()`, das die heutige v1-Berechnung *unverändert* beibehält
+(inklusive `MachineName`, damit sie zu bestehenden Aktivierungen passt). Beides
+geht in die Anfrage:
+
+```csharp
+hardware_id = "2:lin:41e0…",
+legacy_hardware_id = "8fa2…", // nur solange v1-Aktivierungen existieren
+hwid_version = 2,
+hwid_source = "machine-id",
+```
+
+**Server** — in `LicenseService::validate`, an der Stelle der heutigen Suche
+([LicenseService.php:100](../../LicenseLabrador/server/src/LicenseService.php)):
+
+```
+1. Aktivierung mit hardware_id = suchen
+ → gefunden: normaler Weg (last_seen, hostname, app_version aktualisieren)
+
+2. nicht gefunden, und legacy_hardware_id ist gesetzt:
+ Aktivierung mit hardware_id = suchen
+ → gefunden: UPDATE activations SET hardware_id = , hwid_version = 2,
+ hwid_source = WHERE id = …
+ + audit_log-Eintrag 'hwid_migrated'
+ → weiter wie unter 1. KEIN neuer Platz verbraucht.
+
+3. weder noch: neue Aktivierung anlegen, max_activations prüfen (wie heute)
+```
+
+Damit wandern alle bestehenden Windows-Installationen beim ersten Start nach dem
+Update lautlos auf v2 — niemand merkt etwas, kein Aktivierungsplatz geht
+verloren. Das `legacy_hardware_id`-Feld kann nach einer Übergangszeit (etwa zwei
+Veröffentlichungen) aus dem Client fallen.
+
+### 4.1 Der lokale Cache muss einmal verworfen werden
+
+Nicht übersehen: Die Hardware-ID geht in zwei weitere Berechnungen ein —
+`CalculateHmac(state, licenseKey, _hardwareId)` für die Prüfsumme in
+`LicenseResult`
+([LicenseClient.cs:272](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs))
+und den Seed des Speicherschutzes (`:287`). Nach dem Formatwechsel schlägt
+`VerifyChecksum` für jede zwischengespeicherte Hülle fehl.
+
+Das ist kein Fehler, sondern erwartet — muss aber als **Cache-Fehltreffer**
+behandelt werden (einmal online neu prüfen), nicht als
+`TamperSuspected`. Sonst sperrt sich jede bestehende Installation beim ersten
+Start nach dem Update selbst aus. Der Weg dorthin: Cache-Version im
+`LocalCacheData` mitführen (`schema_version: 2`) und einen Datensatz mit
+abweichender Version verwerfen, bevor die Prüfsumme überhaupt geprüft wird.
+
+### 4.2 Schemaerweiterung
+
+```sql
+ALTER TABLE activations
+ ADD COLUMN hwid_version TINYINT NOT NULL DEFAULT 1 AFTER hardware_id,
+ ADD COLUMN hwid_source VARCHAR(32) NULL AFTER hwid_version,
+ ADD COLUMN platform VARCHAR(8) NULL AFTER hwid_source;
+```
+
+Alles mit Vorgabewerten, also rückwärtskompatibel — ein alter Client, der die
+Felder nicht schickt, funktioniert unverändert weiter.
+
+---
+
+## 5. Umzug Windows → Linux
+
+Das ist etwas anderes als die Formatmigration: hier wechselt die Maschine
+wirklich, die ID muss sich also ändern. Drei Wege, alle drei sinnvoll parallel:
+
+### 5.1 Der Normalfall braucht gar nichts
+
+`max_activations` ist standardmäßig **2**. Ein Windows-Entwicklungsrechner und
+ein Linux-Server passen also ohne jeden Eingriff hinein. Für den anstehenden
+Umzug ist das wahrscheinlich die ganze Antwort — die anderen beiden Punkte sind
+für den Fall danach.
+
+### 5.2 Abmelden vor dem Umzug (existiert, aber nicht erreichbar)
+
+`LicenseService::deactivate` löscht die Aktivierungszeile und gibt den Platz frei
+([LicenseService.php:153](../../LicenseLabrador/server/src/LicenseService.php)),
+und `LicenseClient.DeactivateAsync` ruft es auf. In ClawdDotNet ist die Methode
+aber nur über [LicenseGate.cs:39](Services/LicenseGate.cs) erreichbar und dort
+an die GUI gebunden.
+
+Nachzuliefern: ein Kommandozeilenschalter am Host, damit das auch ohne
+Oberfläche geht.
+
+```bash
+clawddotnet --license-deactivate
+```
+
+Das braucht der kopflose Betrieb ohnehin (siehe
+[Linux-Portierung-Analyse.md](Linux-Portierung-Analyse.md), 2.6 — der
+Lizenzdialog ist ein `MessageBox`, der einen Dienst blockieren würde).
+
+### 5.3 Umbinden aus der Verwaltung (fehlt noch)
+
+Für den Fall, dass die alte Maschine schon weg ist: In
+`public/admin/license_detail.php` je Aktivierungszeile eine Schaltfläche
+**„Aktivierung freigeben"** (löscht die Zeile, gibt den Platz frei). Ein echtes
+„Umbinden" auf eine bekannte neue ID ist unnötig — Freigeben plus Neuaktivierung
+auf dem Zielsystem ist derselbe Vorgang mit weniger Code und einer klareren
+Spur im Prüfprotokoll.
+
+Beides sollte in `audit_log` landen, mit altem und neuem Wert.
+
+---
+
+## 6. Zustandsspeicher härten
+
+Zusammen mit dem HW-ID-Umbau, weil dieselbe Datei betroffen ist und die
+Verschlüsselung den HW-ID als Schlüsselmaterial braucht.
+
+**Format** — feste Hülle statt „mal so, mal so":
+
+```
+Magic "LLS2" (4 Byte) │ Nonce (12) │ Ciphertext │ GCM-Tag (16)
+```
+
+- **AES-GCM**, Schlüssel abgeleitet aus HW-ID + `ProductSlug` per HKDF-SHA256.
+- Auf Windows das Ergebnis **zusätzlich** in DPAPI wickeln (Gürtel und
+ Hosenträger, kostet nichts).
+- Dateirechte `0600` auf Unix.
+
+**Der entscheidende Punkt: den Klartext-Rückfall beim Lesen entfernen.** Eine
+Datei, die sich nicht entschlüsseln oder nicht authentifizieren lässt, ist
+**kein Cache** — sie wird verworfen und der Client prüft online. Nicht als
+Klartext akzeptieren. Genau dieser Rückfall macht heute die
+Uhr-Rückdreh-Sperre umgehbar (1.3).
+
+Einmalig weiterhin lesbar bleiben muss das alte Format (Datei ohne `LLS2`-Magic):
+einlesen, in v2 neu schreiben, fertig. Nach einer Veröffentlichung kann der Pfad
+weg.
+
+### 6.1 netstandard2.0 hat kein AesGcm — Empfehlung: mehrfach zielen
+
+`System.Security.Cryptography.AesGcm` gibt es erst ab .NET Core 3.0, `HKDF` erst
+ab .NET 5, `File.SetUnixFileMode` erst ab .NET 7. Das Projekt zielt heute auf
+`netstandard2.0`.
+
+Zwei Wege:
+
+1. **`netstandard2.0;net8.0`** —
+ *empfohlen*. ClawdDotNet (net10.0) zieht automatisch das net8.0-Ziel und
+ bekommt `AesGcm`, `HKDF` und `File.SetUnixFileMode` ohne Umwege. Der
+ netstandard2.0-Zweig bleibt für andere Abnehmer erhalten und nutzt dort
+ BouncyCastle. Kosten: ein paar `#if NET8_0_OR_GREATER`-Blöcke an genau drei
+ Stellen.
+2. **Durchgängig BouncyCastle** (`GcmBlockCipher`, `HkdfBytesGenerator`) — die
+ Bibliothek ist mit `BouncyCastle.Cryptography` bereits als Abhängigkeit da,
+ also kein neues Paket. Kein Mehrfachziel nötig, aber die Dateirechte bleiben
+ ein Problem: `chmod` müsste per P/Invoke laufen.
+
+Weg 1 ist sauberer, weil er nebenbei das Dateirechte-Problem löst.
+
+---
+
+## 7. Ablageort (1.4)
+
+Auflösungskette in `LicenseConfig.StorageDirectory`, erste nutzbare gewinnt:
+
+1. Explizit gesetzter Wert (ClawdDotNet setzt ihn künftig — der Host hat ohnehin
+ eine eigene XDG-Auflösung).
+2. `LICENSELABRADOR_STORAGE_DIR`.
+3. Unix: `$XDG_CONFIG_HOME//license`, sonst `$HOME/.config//license`.
+4. Windows: `SpecialFolder.ApplicationData` wie heute.
+5. Letzter Ausweg: `/license`.
+
+**Und in jedem Fall: nie einen leeren Pfad durchlassen.** Der heutige Code kann
+`Path.Combine("", …)` erzeugen, ohne dass es auffällt. Ein `if
+(string.IsNullOrEmpty(...)) throw` an dieser Stelle ist besser als eine
+Lizenzdatei, die im Arbeitsverzeichnis landet und beim nächsten Start nicht mehr
+gefunden wird.
+
+---
+
+## 8. Änderungsliste
+
+### LicenseLabrador — Client
+
+| Datei | Was |
+|---|---|
+| `HardwareId.cs` | Neuschreiben: v2-Format, Quellenkette je Plattform, `GetLegacyHardwareId()`, `HwidSource`/`Platform` als Eigenschaften, MAC-Filter (locally-administered-Bit, `/sys/class/net/*/device`), Plausibilitätsprüfung für machine-id, `machine.key`-Erzeugung |
+| `LicenseConfig.cs` | `HardwareIdOverride`, Auflösungskette für `StorageDirectory`, leeren Pfad ausschließen |
+| `StateStore.cs` | `LLS2`-Hülle, AES-GCM, `schema_version`, **Klartext-Rückfall beim Lesen entfernen**, v1-Einmalmigration, `0600` |
+| `LicenseClient.cs` | Neue Felder in `validate`/`deactivate` senden; Cache mit abweichender `schema_version` als Fehltreffer behandeln, **nicht** als `TamperSuspected` |
+| `OperatingSystemHelpers` | `RuntimeInformation.IsOSPlatform`, dazu `IsLinux()`/`IsMacOs()` |
+| `LicenseLabrador.Client.csproj` | `netstandard2.0;net8.0` |
+
+### LicenseLabrador — Server
+
+| Datei | Was |
+|---|---|
+| `sql/schema.sql` + Migrationsskript | `hwid_version`, `hwid_source`, `platform` |
+| `src/LicenseService.php` | `legacy_hardware_id` entgegennehmen; Migrationssuche (Abschnitt 4); neue Felder speichern |
+| `src/Audit.php` | Ereignisart `hwid_migrated`, `activation_released` |
+| `public/admin/license_detail.php` | Quelle/Plattform je Aktivierung anzeigen, „Aktivierung freigeben" |
+| `docs/SECURITY.md` | Aussage zur Cache-Bindung korrigieren (1.3) |
+
+### ClawdDotNet
+
+| Datei | Was |
+|---|---|
+| [Services/LicenseGate.cs](Services/LicenseGate.cs) | `StorageDirectory` explizit setzen; `MessageBox`/`frm_License` hinter eine Schnittstelle (`ILicensePrompt`) legen, damit der kopflose Host eine Konsolenfassung einsetzen kann |
+| Host (neu) | `--license-deactivate`, `--license-set-key`, `--license-status` |
+| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Format v2 und Migrationsweg nachtragen |
+
+---
+
+## 9. Testplan
+
+Das Wichtigste zuerst — die Fälle, die heute schiefgehen würden:
+
+| Fall | Erwartung |
+|---|---|
+| Rechner umbenennen | **ID unverändert** (Kern von 1.1) |
+| Container zweimal starten, `/etc/machine-id` im Abbild | beide Male dieselbe ID |
+| Container ohne `machine-id`, Datenverzeichnis gemountet | ID über Neustarts stabil, `hwid_source = keyfile` |
+| Container ohne `machine-id`, **ohne** Mount | ID wechselt — muss so sein, und im Protokoll erkennbar |
+| `LICENSELABRADOR_HWID` gesetzt | gewinnt gegen alles, `hwid_source = override` |
+| `/etc/machine-id` leer bzw. `uninitialized` | wird verworfen, nächste Quelle greift |
+| Docker-Bridge und veth vorhanden, keine machine-id | MAC-Wahl ignoriert sie, ID über Neustart stabil |
+| Bestehende v1-Windows-Aktivierung, Client aktualisiert | Zeile wird auf v2 umgeschrieben, `max_activations` unverändert, Prüfprotokolleintrag |
+| v1-Cache-Datei nach dem Update | einmal online geprüft, dann v2-Cache — **kein** `TamperSuspected` |
+| `state.dat` von Hand mit `max_seen_time = 0` | Datei wird verworfen, Uhr-Rückdreh-Sperre bleibt wirksam |
+| systemd-Dienst ohne `HOME` | Ablageort auflösbar, keine Datei im Arbeitsverzeichnis |
+| Alter Client gegen neuen Server | funktioniert unverändert (Felder haben Vorgabewerte) |
+| Neuer Client gegen alten Server | funktioniert, Zusatzfelder werden ignoriert |
+
+Die letzten beiden Zeilen sind nicht optional: Client und Server werden nicht
+gleichzeitig ausgerollt.
+
+---
+
+## 10. Aufwand
+
+| Block | PT |
+|---|---:|
+| `HardwareId` v2 samt Quellenkette, MAC-Filter, `machine.key` | 2–3 |
+| Client mehrfach zielen + `StateStore`-Härtung | 2–3 |
+| Server: Migrationssuche, Schema, Prüfprotokoll, Verwaltungsansicht | 2–3 |
+| ClawdDotNet: `ILicensePrompt`, Lizenz-Kommandozeile | 1–2 |
+| Tests (Container-Fälle brauchen echtes Docker) und Abnahme | 1–2 |
+| **Summe** | **8–13** |
+
+Das ist mehr als die 3–5 PT, die in der Linux-Analyse für „Lizenz" standen —
+weil dort nur die Plattformverträglichkeit gerechnet war. Die Punkte 1.1 und 1.3
+sind bestehende Fehler, die unabhängig vom Umzug behoben werden sollten; sie
+machen den Unterschied aus.
+
+Der Block ist **unabhängig vom übrigen Linux-Umzug** und kann sofort beginnen —
+er hängt an keiner der offenen GUI-Entscheidungen.
+
+---
+
+## 11. Was ich anders machen würde als heute — kurz begründet
+
+Drei Entscheidungen im Vorschlag verdienen eine Begründung, weil sie vom
+bisherigen Ansatz abweichen:
+
+**Rechnername raus.** Er ist der Grund, warum die heutige Bindung fragiler ist
+als nötig, und er trägt nichts bei, was `activations.hostname` nicht schon
+festhält. Eine Bindung, die bei einer Umbenennung bricht, bindet nicht an
+Hardware, sondern an eine Konfiguration.
+
+**Vorgabe per Umgebungsvariable statt besserer Heuristik für Container.** Man
+kann eine Container-Umgebung nicht sinnvoll erraten — es gibt dort keine
+Hardware. Jede zusätzliche Heuristik verschiebt nur, wo es falsch wird. Eine
+explizite Vorgabe ist ein bewusster Betreiberentscheid, in der Unit-Datei
+sichtbar, im Prüfprotokoll nachvollziehbar.
+
+**Kein Klartext-Rückfall, auch nicht „zur Sicherheit".** Der heutige Rückfall
+sollte Robustheit bringen, kostet aber genau die Eigenschaft, für die der Cache
+existiert. Ein verworfener Cache bedeutet: einmal online prüfen. Das ist der
+mildere Schaden — und wer keine Verbindung hat, hat immer noch die
+Offline-Gnadenfrist aus der signierten Hülle, die von dieser Datei nicht abhängt.
diff --git a/docs/Roadmap.md b/docs/Roadmap.md
new file mode 100644
index 0000000..347b6f4
--- /dev/null
+++ b/docs/Roadmap.md
@@ -0,0 +1,301 @@
+# Roadmap
+
+Zentrale Liste aller offenen Vorhaben. Sie löst die beiden „Vorgeschlagene
+Reihenfolge"-Abschnitte in der [Bestandsaufnahme](Bestandsaufnahme-2026-07.md) und im
+[Konzepte-Dokument](Konzepte-Backup-Finanz-Analyse.md) ab — die bleiben als Befund bzw.
+Konzept bestehen, gepflegt wird nur noch hier.
+
+Kürzel (S4, K2, T4, F-A1, …) verweisen auf die Bestandsaufnahme.
+
+---
+
+## A — Beschlossen (aus dem OpenAlice-Vergleich, Juli 2026)
+
+Hintergrund: Konzeptvergleich mit [OpenAlice](https://github.com/TraderAlice/OpenAlice)
+(AGPL-3.0 — Konzepte übernehmen ja, Code nein). Übernommen werden Taskboard,
+Staging-Freigabe, Audit-Log und das Skill-Modell. Die Inbox-Idee entfällt zugunsten
+der geplanten Matrix-Migration (A5).
+
+### A1 — Taskboard
+
+Aufgaben als Markdown-Dateien mit YAML-Frontmatter im `SharedWorkspace`:
+`title`, `status` (`backlog | todo | in_progress | done | canceled`), `priority`,
+`assignee`, optional `when` (`at` | `every` | `cron` **mit Zeitzone**).
+
+- **Scanner statt Delay-Schleifen**: Ein Takt (~60 s) prüft, was fällig ist.
+ Persistiert werden nur Last-Fired-Marker — ein fehlgeschlagener Lauf bleibt der
+ einzige Versuch für diesen Termin, kein automatischer Retry-Sturm.
+- **Assignee bestimmt die Ausführung**: `@new` = frischer Lauf ohne Historie,
+ `@` = bestehender Agent mit seinem Kontext, `@human` = wartet auf uns.
+ Das ersetzt das implizite `UseChatContext`-Flag (T7) durch eine explizite Angabe
+ am Auftrag.
+- **Agenten-Tool**: `task_create`, `task_list`, `task_update`, `task_comment`.
+ Agent-zu-Agent-Delegation läuft künftig über Tasks statt über rekursives
+ `send_message`.
+- **Migration**: Die improvisierten `coordination/*.md`-Dateien der Agenten
+ (task_*, status_*, broadcast) gehen im Taskboard auf.
+
+**Detailbauplan** (aus dem Fünf-Repo-Vergleich, Juli 2026 beschlossen):
+
+- Status zusätzlich mit **`in_review`**; im Frontmatter **`require_approval`**
+ (Task gilt erst nach Review als done) und **`acceptance`** (Abnahmekriterien,
+ gegen die das Ergebnis geprüft wird).
+- **Task-Typen `approval` und `human_input`** — ein Mensch ist einfach ein
+ Assignee; seine Antwort ist das Task-Ergebnis und Input für Folgetasks.
+- **Atomares Claiming**: Die DB verhindert, dass zwei Läufe denselben Task
+ ziehen. Der Scanner arbeitet mit **Claim-before-run** (at-most-once — ein
+ doppelter Tick findet den Claim bereits vergeben) und
+ **Startup-Reconciliation**: Beim Start wird Soll (Frontmatter) gegen Ist
+ (Marker/Claims) abgeglichen, verpasste Läufe werden erkannt statt still
+ übersprungen.
+- **`blocked_by`-Abhängigkeiten** mit Auto-Dispatch: Wird der letzte Blocker
+ fertig, wird der wartende Task automatisch angestoßen. Meldet ein Agent einen
+ Blocker, fällt der Task und der Zuständige (Lead/Benutzer) wird benachrichtigt
+ (**Blocker-Eskalation**).
+- **Reopen-/Feedback-Semantik**: Ergebnis + Kritik gehen per `task_comment` an
+ denselben Agenten zur Nachbesserung zurück, statt einen neuen Task von vorn
+ zu beginnen.
+
+Damit erledigt oder aufgegangen:
+
+| Punkt | Warum |
+|---|---|
+| F-A5 Task-Queue | das Taskboard **ist** die Queue |
+| F-A4 Run-Historie | Läufe werden am Task verknüpft und persistiert |
+| B8 Rekursion `send_message` | Delegation über Tasks ist strukturell zyklenfrei |
+| B6 `Task.Delay`-Überlauf | Scanner-Modell kennt keine langen Delays |
+| B7 Cron in Lokalzeit | Frontmatter-`when` ist zeitzonen-explizit |
+| T7 `RunAsync` vs. `ChatAsync` | Assignee-Semantik beantwortet die Frage |
+
+Verzahnung: Das Marktkalender-Flag (`onlyWhenMarketOpen`, siehe C1) gehört ins
+Frontmatter, nicht in einen eigenen Mechanismus.
+
+Konzept-Doc: [Taskboard-Konzept](Taskboard-Konzept.md) (Dateiformat,
+Wahrheitsaufteilung Datei/DB, Scanner-Verhalten, Invarianten, Migration).
+
+### A2 — Staging-Freigabe für irreversible Aktionen (F-A1 + S4)
+
+Konzept-Doc: [Staging-Konzept](Staging-Konzept.md).
+
+Irreversible Aktionen (Mail senden, X posten, DB-Schreibzugriff, Datei löschen,
+perspektivisch Orders) werden **gestaged statt ausgeführt**: Vorschlag → Review im
+Hauptfenster → Freigabe/Ablehnung. Pro Tool/Aktion konfigurierbar:
+`auto | approve | deny`.
+
+Das bisher wirkungslose `PermissionGate` (S4) wird dabei zum zentralen
+Durchsetzungspunkt ausgebaut: Policy-Prüfung, Staging-Entscheidung und Audit-Hook
+(A3) an einer Stelle statt ad-hoc in jedem Tool. S4 wird nicht separat bearbeitet,
+sondern geht hier auf.
+
+Ergänzungen (Juli 2026 beschlossen):
+
+- **Plan-Freeze**: Freigegeben wird ein eingefrorener, konkreter Aufruf — Tool,
+ Aktion und exakte Argumente zum Zeitpunkt des Stagings. Ausgeführt wird genau
+ das Eingefrorene; jede nachträgliche Änderung ist eine neue Freigabe.
+- **Approval-Records**: Jede Entscheidung (Freigabe wie Ablehnung) wird als
+ Datensatz im Audit-Log (A3) verankert — wer, wann, was, mit welchem Ergebnis.
+
+Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen Vorschlag
+erzeugen, keine Ausführung.
+
+### A3 — Audit-Log (F-A2)
+
+Konzept-Doc: [Audit-Konzept](Audit-Konzept.md).
+
+Jeder Tool-Aufruf wird protokolliert: Agent, Lauf/Session, Zeitstempel, Argumente,
+Ergebnis-Status. Append-only (JSONL oder SQLite-Tabelle auf dem vorhandenen
+`SqliteStorage`).
+
+Designregeln (aus dem OpenAlice-Provenance-Konzept):
+
+- Herkunft wird **von der Engine gestempelt**, nie vom Agenten behauptet.
+- Einträge sind unveränderlich; Korrekturen sind neue Einträge.
+- Unbekannte Herkunft wird als unbekannt markiert, nicht geraten.
+- Worker-Typ (Modell/Engine) und verantwortliche Session sind getrennte Begriffe.
+
+Ergänzung (Juli 2026 beschlossen) — **Receipts**: Jeder abgeschlossene Task/Lauf
+erhält einen Abschluss-Beleg mit Ergebnis-Verweis, Schritten, Tokens und Kosten
+(Verknüpfung `RunUsage` ↔ Task). Damit fällt C7 („Kosten pro Ergebnis")
+weitgehend als Abfallprodukt ab.
+
+Das Audit-Log ist zugleich das Fundament für das Ergebnisregister (C7) und die
+Tool-Fehlerquote aus der Leistungsanalyse.
+
+### A4 — Skill-/Toolset-Modell (ersetzt T6)
+
+Dreischichtig statt „alles immer im System-Prompt":
+
+1. **Dauerhafter Kern** — Identity, Soul, unveränderliche Regeln. Schlank, damit der
+ Prompt-Cache (T1) stabil bleibt.
+2. **Nachladbare Skills/Toolsets** — fachliche Abläufe und selten genutzte Tools
+ werden erst auf Anforderung geladen (`list_toolsets` → `load_toolset`).
+3. **Selbstkorrigierende Tool-Fehler** — Fehlermeldungen nennen die gültigen
+ Parameter/Aktionen, statt das Modell raten zu lassen.
+
+Mechanik (Juli 2026 beschlossen, nach GoClaw-Vorbild): Skills liegen als
+`SKILL.md` mit Frontmatter (`name`, `description`) im Instanz- bzw.
+Agenten-Verzeichnis. Bei wenigen Skills werden die Kurzbeschreibungen inline in
+den Prompt eingebettet, bei vielen gibt es stattdessen ein `skill_search`-Tool.
+Änderungen an Skill-Dateien werden per Hot-Reload übernommen.
+
+Dazu Hermes' Selbstverbesserungs-Idee: **Agenten dürfen Skills aus Erfahrung
+selbst schreiben** (das AgentEditor-Tool ist die Vorstufe). Wichtig: Ein Skill
+ist Prompt-Input — agentengeschriebene Skills werden erst nach Freigabe (A2)
+aktiv, sonst wäre das ein Injection-Kanal in künftige Läufe.
+
+### A5 — Matrix/Element-Migration
+
+Beschlossene Richtung: Die Kommunikation (Benachrichtigungen, Berichte, Chat mit
+Agenten) wird auf Element/Matrix umgestellt.
+
+- Ersetzt die im OpenAlice-Vergleich erwogene Inbox — Berichte landen in
+ Matrix-Räumen.
+- Der Tool-Kandidat **Notify** entfällt und geht hierin auf.
+- Betroffen: Telegram-Tool (Rolle klären), WebView-Chat (bleibt er Haupt-UI?),
+ Streaming K4 (Matrix streamt nicht — nach dieser Entscheidung neu bewerten).
+
+Scope ist noch unbestimmt — braucht ein eigenes Konzept-Doc, bevor es in die
+Reihenfolge eingeordnet wird.
+
+### A6 — MySQL-Replikations-Spiegel (optional)
+
+Beschlossen Juli 2026. **SQLite bleibt die einzige Wahrheit** — gearbeitet wird
+ausschließlich auf der Instanz-DB. MySQL ist ein reiner, nachgelagerter Spiegel:
+Er empfängt nur INSERT/UPDATE/DELETE vom Replikator; die App liest im Betrieb
+**nie** daraus. Einziger Lesezweck: Wiederherstellung, falls die SQLite korrupt
+ist — daneben steht der Spiegel externen Auswertungen (Dashboards, Ad-hoc-SQL)
+offen, ohne die Agenten-Maschine zu berühren.
+
+Leitplanken:
+
+- **Outbox-Muster** an der vorhandenen Schreib-Warteschlange von `SqliteStorage`:
+ Replikations-Einträge lokal puffern, idempotente Upserts nach MySQL.
+ Nie blockierend — ist MySQL nicht erreichbar, staut die Outbox und holt auf.
+- Mehrere Instanzen replizieren in denselben Spiegel; Zeilen tragen `instance_id`.
+- **Restore-Pfad (Spiegel → frische SQLite) muss existieren und getestet sein** —
+ gleiche Regel wie beim Backup: ein ungeprüftes Restore ist eine Vermutung.
+- Kein Koordinationspunkt: Claiming, Locks, Taskboard-Zustand bleiben lokal.
+ Die Regel „nur der Replikator schreibt, niemand liest im Betrieb" gehört ins
+ Konzept-Doc.
+- Sicherheit: TLS zur Datenbank, Zugangsdaten über `SecretProtector`;
+ Output-Scrubbing der Tool-Ergebnisse wird wichtiger, weil Kontext-Daten
+ künftig auch im Spiegel liegen.
+- Ergänzt das ZIP-Backup, ersetzt es nicht — Identity/Soul, Settings, Workspace
+ und Telegram-Session bleiben Sache des Instanz-Backups.
+
+**Voraussetzung — Historie-Umzug:** `ChatHistory.json` zieht in die Instanz-DB um
+(aktive Tabelle + Archiv-Tabelle mit FTS5-Volltextindex und `history_search`-Tool,
+siehe K6 in Abschnitt B). Das löst
+nebenbei B10 (O(n²)-Schreiblast) und gibt K6 seine Form; erst danach schützt der
+Spiegel auch die Historie. Zeitlich passt A6 zu dem Server, der ggf. mit A5
+(Matrix) ohnehin dazukommt.
+
+---
+
+## B — Offen aus der Bestandsaufnahme
+
+| Punkt | Was | Stand |
+|---|---|---|
+| T4 | Proaktiv statt reaktiv kompaktieren | offen, unverändert |
+| K2-Rest | Untrusted Content als Daten rahmen (``) | A2 nimmt die Schärfe; die Rahmung selbst bleibt nötig |
+| K4 | Streaming | **zurückgestellt** bis A5 entschieden ist |
+| K6 | Historie **archivieren + durchsuchbar machen** (FTS5-Index, `history_search`-Tool) statt nur rotieren | beschlossen; Teil des Historie-Umzugs (Voraussetzung von A6) |
+| Memory-Flush vor Compaction | Bevor der ContextCompactor zusammenfasst, bekommt der Agent ein eng begrenztes Fenster, Dauerhaftes per `memory_store` zu sichern — sonst wirft die Compaction Wissen weg | beschlossen (GoClaw-Muster) |
+| Memory-Auto-Injection | Relevante Memory-Abstracts werden automatisch eingeblendet (Relevanzschwelle, Deckel ~200 Tokens), **in die Nutzernachricht, nie in den System-Prompt** (Prompt-Cache T1) | beschlossen; löst den offenen Punkt „Automatische Einblendung" im [Memory-Konzept](Memory-Konzept.md) |
+| Output-Scrubbing | Bekannte Secret-Werte (Register des `SecretProtector`) werden zentral aus **allen** Tool-Ergebnissen maskiert, bevor sie in Kontext, Historie oder Spiegel (A6) gelangen | beschlossen; schließt die Lücke, die S3 nur für URLs schloss |
+| Hygiene-Paket | B9 (`index_Count`-Race), B11/T8 (`max_tokens` setzen), B13 (`instanceId`-Inkonsistenz), F-A6-Rest (UI zum Setzen/Rotieren der Secrets) | Kleinbugs, in einem Aufwasch. B10 geht im Historie-Umzug (A6) auf |
+
+Erledigt seit der letzten Fortschreibung: B4 vollständig (Preise kommen live vom
+`/models`-Endpunkt, unbekannte Modelle werden sichtbar gemeldet).
+
+### B-DC — Deploymentcenter-Anbindung
+
+WatchDog und LicenseLabrador sind durch das
+[Deploymentcenter](Deploymentcenter-Integration.md) ersetzt: eine Adresse, ein Token,
+und dazu Update-Prüfung, Fehler-Stream und Bugtracker. Watchdog läuft wieder pro
+Instanz (ein Monitor je Instanz, mit Gesundheitsprüfungen und angekündigtem Ende).
+
+Offen:
+
+| Punkt | Was | Bemerkung |
+|---|---|---|
+| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt |
+| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar |
+| DC3 | Release-Strecke: `pack-and-deploy` mit `` aufrufen, dann `update-agent` | ohne Release hat die Update-Prüfung nichts zu finden. Versionsnummer selbst ist erledigt (`Directory.Build.props` → `ReleaseInfo`) |
+| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI |
+| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** |
+
+---
+
+## C — Offen aus dem Finanz-/Analyse-Konzept
+
+Punkte 1–2 von dort (atomares Schreiben, Backup/Restore inkl. UI) sind umgesetzt.
+
+| # | Was | Bemerkung |
+|---|---|---|
+| C1 | Marktkalender (`onlyWhenMarketOpen` + `MarketCalendar`-Tool) | Scheduler-Teil gehört ins Taskboard-Frontmatter (A1) |
+| C2 | `Indicators`-Tool — deterministische Berechnung | Qualität hoch, Tokens runter |
+| C3 | Datenaktualität erzwingen (`maxAgeSeconds`) | |
+| C4 | Termine & Fundamentaldaten (Earnings, EDGAR, Wirtschaftskalender) | |
+| C5 | Bestandsregister (Positionen) | Grundlage für C7/C8 |
+| C6 | Nachrichten-Entdopplung (`SeenItems`) | |
+| C7 | Ergebnisregister (Stufe 2, „Kosten pro Ergebnis") | fällt weitgehend aus den A3-Receipts ab |
+| C8 | Falsifizierbare Aussagen + Auflösung, Brier-Score (Stufe 3) | braucht C5, C7 und einen Auflösungs-Task (A1) |
+
+---
+
+## D — Toolkandidaten (unbeschlossen)
+
+WebSearch, Http (generisch mit Allowlist), Shell (sandboxed), Git, Vision.
+Notify ist gestrichen — geht in A5 auf.
+
+---
+
+## Vorgeschlagene Reihenfolge
+
+| # | Vorhaben | Begründung |
+|---|---|---|
+| 1 | A1 Taskboard | Fundament; löst sechs bestehende Punkte auf einmal |
+| 2 | A3 Audit-Log | klein, sofort nützlich; muss vor A2 da sein, damit Freigaben protokolliert werden |
+| 3 | A2 Staging-Freigabe | größter Sicherheitsgewinn; Voraussetzung für unbeaufsichtigten Betrieb |
+| 4 | C1 Marktkalender | spart sofort Kosten; nutzt A1-Frontmatter |
+| 5 | A4 Skills/Toolsets | Token-Hebel, Cache-stabil |
+| 6 | C2 Indicators | Qualität + Kosten |
+| 7 | C7 + C8 Ergebnisregister, Aussagen | das eigentliche Leistungsmaß; braucht A3 |
+| — | Hygiene-Paket (B) | zwischendurch, unabhängig |
+| — | Historie-Umzug in die Instanz-DB | löst B10 + K6; Voraussetzung für A6 |
+| — | A6 MySQL-Spiegel | nach dem Historie-Umzug; natürliches Zuhause auf dem A5-Server |
+| — | A5 Matrix | eigenes Konzept-Doc zuerst; Scope klären, dann einordnen |
+
+Leitlinie der Reihung: erst Nachvollziehbarkeit und Kontrolle (Audit, Staging),
+dann Fähigkeiten — ein Agent, der unbeaufsichtigt läuft, braucht zuerst Bremsen,
+dann PS.
+
+---
+
+## Umsetzung mit Opus 4.6 — Einstufung
+
+Die Entwicklung erfolgt mit Opus 4.6. Die meisten Vorhaben sind damit gut
+machbar, sofern die hier notierten Vorgaben mitgegeben werden. Zwei Stellen
+berühren Nebenläufigkeits-Invarianten bzw. Engine-Querschnitte — sie sind für
+Opus 5 / Fable markiert oder durch eine Architektur-Vorgabe entschärft.
+
+| Vorhaben | Einstufung | Vorgabe / Begründung |
+|---|---|---|
+| A1: Dateiformat, Frontmatter-Parsing, `task_*`-Tool, Migration | 4.6 | klar spezifizierbar, gut testbar |
+| A1: **Scanner-Kern** (atomares Claiming, Auto-Dispatch, Reconciliation) | ⚠️ **Opus 5 / Fable** | At-most-once-Semantik, Claim-CAS und das Zusammenspiel mit den seit B2 serialisierten Chat-Läufen sind genau die Fehlerklasse, die hier schon einmal schiefging. Falls doch 4.6: erst Konzept-Doc, Umsetzung strikt dagegen, Property-Tests für die Invarianten („nie zwei Claims auf einen Task", „kein Dispatch bei offenem Blocker", „doppelter Tick = ein Lauf") |
+| A2: Gate, Policy, Staging-Queue, Review-UI, Approval-Records | 4.6 | mit der folgenden Architektur-Vorgabe |
+| A2: **Fortsetzung nach Freigabe** | 4.6 nur mit Vorgabe | **Kein pausierter, im Speicher gehaltener Lauf.** Vorgabe: Der Lauf endet beim Staging regulär — das Tool liefert „zur Freigabe vorgelegt" als Ergebnis, der Agent schließt ab. Die Freigabe erzeugt einen Folge-Task (A1), der den Agenten mit dem **eingefrorenen** Aufruf weckt. Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser Vereinfachung unnötig |
+| A3: Audit-Log + Receipts | 4.6 | append-only, klares Schema, keine Nebenläufigkeitsfallen |
+| A4: Skills | 4.6 | `FileSystemWatcher` mit Debounce (~500 ms); agentengeschriebene Skills erst nach Freigabe aktiv (siehe A4) |
+| Memory-Flush vor Compaction | 4.6 mit Anleitung | Harte Grenzen: max. 3–5 Schritte, einziges Tool `memory_store`, Timeout, günstiges Modell (wie T3), höchstens einmal je Compaction-Zyklus. Vorsicht: Der ContextCompactor hatte B1/B14 — die bestehenden Paarungs-Tests müssen unverändert grün bleiben |
+| Memory-Auto-Injection | 4.6 | in die Nutzernachricht, nie in den System-Prompt (sonst verfällt der Prompt-Cache T1); Deckel ~200 Tokens |
+| Output-Scrubbing | 4.6 | ein zentraler Filter an der Stelle, wo Tool-Ergebnisse in den Kontext gelangen (`ExecuteToolCallAsync`); Werte aus dem Secret-Register |
+| Historie-Umzug + FTS5 + `history_search` | 4.6 | Migration nur nach frischem Backup; alte JSON-Dateien erst nach verifiziertem Import löschen |
+| A6: MySQL-Spiegel | 4.6 mit Anleitung | Outbox mit Wasserzeichen, idempotente Upserts, nie blockieren; der getestete Restore-Pfad ist Teil der Definition of Done |
+| C1 Marktkalender, C2 Indicators | 4.6 | reine Fachlogik, deterministisch testbar |
+
+Generell: Neue Subsysteme (Scanner, Staging, Audit, Replikator) kommen mit Tests
+nach der [Teststrategie](Teststrategie.md) — die Invarianten-Tests sind bei den
+markierten Punkten kein Nice-to-have, sondern die Absicherung dafür, dass ein
+schwächeres Modell sie umsetzen darf.
diff --git a/docs/RocketChat-Nextcloud-Konzept.md b/docs/RocketChat-Nextcloud-Konzept.md
new file mode 100644
index 0000000..946f971
--- /dev/null
+++ b/docs/RocketChat-Nextcloud-Konzept.md
@@ -0,0 +1,544 @@
+# Rocket.Chat und Nextcloud — Konzept
+
+Zwei neue Tools, ein gemeinsamer Zweck: **Rocket.Chat** wird der Ort, an dem wir mit den
+Agenten reden; **Nextcloud** wird der Ort, an dem die Agenten uns Ergebnisse hinlegen.
+Der typische Ablauf ist die Kombination aus beidem — „schreib mir die Auswertung und leg
+sie in die Cloud" im Chat, Datei in Nextcloud, Link zurück in den Chat.
+
+Dieses Dokument prüft die Machbarkeit, legt den Schnitt fest und benennt die Punkte, die
+vor der Umsetzung entschieden werden müssen. **Es ist noch keine Umsetzungsfreigabe.**
+
+Verwandt: [Taskboard-Konzept](Taskboard-Konzept.md) (Scanner/Wake), [Staging-Konzept](Staging-Konzept.md)
+(Freigaben), [Audit-Konzept](Audit-Konzept.md), [Roadmap](Roadmap.md) (A5 — siehe Konflikt unten).
+
+---
+
+## 0 — Kurzfassung des Befunds
+
+| Frage | Antwort |
+|---|---|
+| Ist es umsetzbar? | Ja, beides. Ohne neue Architektur — die vorhandenen Bausteine tragen. |
+| Braucht es Änderungen am Core? | Für Phase 1: **nein**, nur zwei neue Tool-Projekte + Staging-Defaults. Für den automatischen Rückweg (Antwort landet ohne Zutun des Modells im Raum) und den Notfallkanal: ja, zwei kleine Core-Ergänzungen. |
+| Größtes technisches Risiko | Nicht die API — sondern **Antwort-Schleifen zwischen Agenten** und **Kosten durch zu häufiges Wecken**. |
+| Größte Konzeptkollision | Roadmap **A5** sieht Matrix/Element für genau diesen Zweck vor. Rocket.Chat ersetzt A5, oder wir haben zwei Chat-Wege. Muss entschieden werden. |
+| „Agent erstellt Dokument direkt über die Nextcloud-API" | So nicht. Nextcloud hat keine API, die Inhalte *erzeugt*. Der Weg ist: Datei lokal im Workspace erzeugen → hochladen. Für PDF/XLSX kann **Collabora als Konverter** dienen — das ist der elegante Teil, siehe 5.4. |
+
+---
+
+## 1 — Was schon da ist (und deshalb nicht neu gebaut wird)
+
+Der Rückkanal von außen nach innen existiert vollständig:
+
+```
+TaskScanner (60-s-Takt)
+ └─ Task vom Typ tool_job
+ └─ EngineTaskDispatcher.DispatchToolJobAsync
+ └─ IToolJobProvider.ExecuteJobAsync ← kein LLM, kostenlos
+ └─ ToolJobResult.Wake(text) ← nur wenn wirklich etwas da ist
+ └─ AgentEngine.ChatAsync ← hier erst kostet es Tokens
+```
+
+Das Telegram-Tool nutzt genau das (`telegram_poll`). **Rocket.Chat bekommt dieselbe
+Bauform** — `rocketchat_poll`. Damit gilt automatisch:
+
+- Zustand (letzter gesehener Zeitpunkt) über `IStateStore`, überlebt Neustarts.
+- Ein Takt ohne neue Nachricht kostet nichts.
+- Kein eigener Thread, kein eigener Scheduler, keine Sonderbehandlung beim Start.
+- Jeder Tool-Aufruf läuft ohnehin durch `StagingGate` (A2) und Audit (A3).
+
+Ebenso vorhanden und wiederverwendbar:
+
+- **Pro-Agent-Konfiguration** (`AgentConfig.Tools["RocketChat"]`) — jeder Agent bekommt
+ seine eigenen Zugangsdaten, ohne dass ein Agent die eines anderen sehen kann.
+- **`ConfigSecrets`** verschlüsselt Felder nach Namen (`token`, `password`, `apikey` …) —
+ ein Feld namens `authToken` bzw. `appPassword` ist automatisch geschützt.
+- **Workspace-Prefixe** `personal:` / `shared:` samt Path-Traversal-Prüfung — aus dem
+ FTP-Tool wortgleich übernehmbar für Nextcloud-Uploads.
+
+---
+
+## 2 — Rocket.Chat: Machbarkeit
+
+Geprüft gegen die REST- und Realtime-API von Rocket.Chat. Alles Folgende ist
+Standardfunktion einer selbstgehosteten Instanz, kein Enterprise-Feature.
+
+### 2.1 Identität — ein echter Benutzer je Agent
+
+Die Anforderung „jeder Agent mit eigenem Benutzer, in Gruppen und im Direktkontakt" ist
+der richtige Ansatz und wird von Rocket.Chat direkt unterstützt.
+
+- Admin legt je Agent einen Benutzer an: `POST /api/v1/users.create`
+ (`{ name, username, email, password, roles: ["bot"] }`).
+- Die Rolle **`bot`** ist wichtig: Sie markiert den Benutzer als Maschine (relevant für
+ Schleifenschutz, siehe 2.5) und wird in neueren Versionen bei der Sitzplatzzählung
+ nicht als normaler Nutzer gewertet. *Gegen die eigene Version zu prüfen.*
+- Für jeden Agenten wird ein **Personal Access Token** erzeugt
+ (`POST /api/v1/users.generatePersonalAccessToken`, oder im Konto des Benutzers).
+ Dauerhaft gültig, einzeln widerrufbar — deutlich besser als Login mit Passwort, weil
+ kein Session-Ablauf und keine gespeicherten Passwörter im Spiel sind.
+- Authentifiziert wird jeder Aufruf über zwei Header: `X-Auth-Token` und `X-User-Id`.
+
+**Entscheidung, die ich empfehle:** Das Anlegen der Benutzer ist **kein Agenten-Tool**.
+Es ist eine einmalige Einrichtungsfunktion in der WinForms-Oberfläche
+(Instanz-Einstellungen → Rocket.Chat → „Agenten-Benutzer anlegen"). Sonst müsste ein
+Agent ein Admin-Token halten — und ein Admin-Token in Reichweite einer Prompt-Injection
+ist genau das, was A2 verhindern soll. Der Admin-Token liegt in der **Instanz**-Konfiguration,
+nicht in einer Agenten-Tool-Konfiguration.
+
+### 2.2 Ausgang — Nachrichten senden
+
+| Zweck | Endpunkt |
+|---|---|
+| In Kanal/Gruppe/DM schreiben | `POST /api/v1/chat.postMessage` (`roomId` oder `channel`) |
+| Auf eine Nachricht antworten (Thread) | dasselbe, mit `tmid` |
+| Datei anhängen | `POST /api/v1/rooms.upload/{roomId}` (multipart) |
+| Reaktion setzen | `POST /api/v1/chat.react` |
+
+Gesendet wird als der Agenten-Benutzer — Direktnachrichten funktionieren dadurch echt und
+nicht als „Bot mit Alias".
+
+### 2.3 Eingang — der Poll-Weg (Phase 1)
+
+Der sparsame Weg, ohne jede neue Infrastruktur:
+
+1. `GET /api/v1/subscriptions.get?updatedSince=` — **ein einziger Aufruf**
+ liefert für diesen Agenten alle Räume mit Ungelesen-Zähler, Erwähnungs-Zähler und
+ „zuletzt gesehen"-Marke. Auch bei 50 Räumen bleibt es ein Aufruf.
+2. Nur für Räume mit relevanten Neuigkeiten wird die Historie geholt:
+ `channels.history` (öffentlich) / `groups.history` (privat) / `im.history` (DM),
+ jeweils mit `oldest=`.
+3. `POST /api/v1/subscriptions.read` markiert gelesen — der Zähler geht zurück auf null.
+
+Zustand im `IStateStore`: `rocketchat:{agentId}:lastCheck` sowie je Raum die zuletzt
+verarbeitete Nachrichtenzeit.
+
+**Rate-Limits:** Rocket.Chat begrenzt REST-Aufrufe (Standard in der Größenordnung von
+10 Aufrufen je Minute und Endpunkt). Bei einem Takt von 30–60 Sekunden und einem
+Sammelaufruf pro Takt ist das unkritisch — es ist aber der Grund, warum der Entwurf über
+`subscriptions.get` sammelt statt jeden Raum einzeln zu pollen.
+
+**Latenz:** Bei 60-Sekunden-Takt antwortet ein Agent im Mittel nach ~30 s plus Laufzeit.
+Für Gespräche mit Agenten ist das spürbar, aber tragbar. Der Takt lässt sich pro Job
+setzen (`*/1 * * * *` ist das Minimum des Cron-Modells; feiner ginge nur über die
+Realtime-API).
+
+### 2.4 Eingang — die Realtime-Variante (Phase 3, optional)
+
+Rocket.Chat bietet eine WebSocket-/DDP-Schnittstelle (`wss://host/websocket`): nach
+`login` mit dem Token abonniert man `stream-notify-user/{userId}/notification` und
+bekommt DMs und Erwähnungen **sofort** gepusht, ohne Polling.
+
+Das ist die richtige Endstufe (Antwortzeit ~1 s statt ~30 s), aber es ist eine dauerhafte
+Verbindung je Agent mit Wiederverbindungs-Logik — also eine echte Komponente, keine
+Ergänzung eines Tools. Vorschlag: **erst nachrüsten, wenn Phase 1 im Alltag steht** und
+sich die Verzögerung tatsächlich stört.
+
+Eine dritte Möglichkeit — Rocket.Chats *Outgoing Webhook* auf unsere vorhandene
+`ClawdDotNetApi` (Port 5082) — wäre die einfachste Push-Lösung, setzt aber voraus, dass
+der Rocket.Chat-Server den Windows-Rechner über das Netz erreicht. Das ist eine Frage
+deiner Netztopologie und keine der Software. Falls erreichbar: der kürzeste Weg zu
+niedriger Latenz.
+
+### 2.5 Die zwei echten Fallen
+
+Diese beiden Punkte sind wichtiger als jede API-Frage.
+
+**(a) Mehrere Agenten im selben Raum.** Wenn drei Agenten denselben Gruppenchat pollen,
+antworten drei Agenten auf jede Nachricht. Regel im Entwurf:
+
+> Ein Agent wird nur geweckt bei (1) Direktnachrichten an ihn oder (2) Nachrichten, die
+> ihn per `@name` erwähnen. Alles andere liest er nicht einmal.
+
+Ein Raum kann per Konfiguration auf `respondToAll: true` gestellt werden — das ist die
+bewusste Ausnahme für einen Raum mit genau einem Agenten.
+
+**(b) Agenten-Schleifen.** Agent A schreibt, Agent B wird geweckt, antwortet, weckt A —
+und das läuft, bis das Tagesbudget greift. Der `LoopGuard` schützt nur *innerhalb* eines
+Laufs, nicht über Agenten hinweg. Regel im Entwurf:
+
+> Nachrichten von Benutzern mit der Rolle `bot` werden **ignoriert**, außer der Agent ist
+> namentlich erwähnt. Zusätzlich eine Drossel: höchstens N Weckvorgänge je Raum und
+> Stunde (Zähler im `IStateStore`), danach schweigt der Agent in diesem Raum bis zur
+> nächsten Stunde und protokolliert das.
+
+Das Tagesbudget (K5) ist das letzte Netz, nicht das erste.
+
+### 2.6 Der Rückweg der Antwort
+
+Der Wake-Mechanismus liefert die Nachricht *in* den Agenten. Seine Antwort geht heute in
+den Chat-Verlauf, nicht zurück nach Rocket.Chat. Zwei Wege:
+
+- **(a) Der Agent antwortet selbst** — die Weck-Nachricht enthält die `roomId` und die
+ Anweisung, mit `RocketChat.send_message` zu antworten. Kein Core-Eingriff, funktioniert
+ sofort. Schwäche: Es hängt daran, dass das Modell es tut. Erfahrungsgemäß klappt das
+ gut, aber nicht in 100 % der Fälle.
+- **(b) Automatischer Rückweg** — der Tool-Job merkt sich „Antwort gehört nach Raum X",
+ und der Dispatcher schickt die Abschlussnachricht des Laufs dorthin. Zuverlässig, aber
+ es braucht einen kleinen Haken in `ToolJobResult`/`EngineTaskDispatcher`
+ (etwa ein `ReplyTo`-Feld, das der Dispatcher nach dem Lauf an dasselbe Tool zurückgibt).
+
+**Empfehlung:** (a) in Phase 1, (b) in Phase 2 nachziehen — denn (b) ist der Unterschied
+zwischen „meistens antwortet er" und „er antwortet". Für die Hauptkommunikationsschiene
+ist das am Ende nicht optional.
+
+### 2.7 Sicherheit
+
+- **Nachrichten aus Rocket.Chat sind fremder Text.** Sie müssen als
+ `` gerahmt in den Kontext (Roadmap K2-Rest). Bei Telegram fehlt das
+ bis heute; hier sollte es von Anfang an drin sein, weil Gruppenchats mehrere Absender
+ haben.
+- **Raum-Allowlist** je Agent (`allowedRooms`), analog `allowedChatIds` beim Telegram-Tool.
+- **Staging-Vorschlag** (siehe 6): Senden in erlaubte Räume `auto`, alles darüber hinaus
+ `approve`.
+- Zugangsdaten heißen im Konfigurationsfeld `authToken` → `ConfigSecrets` verschlüsselt sie
+ automatisch. Der Admin-Token der Instanz muss in `ConfigSecrets.Apply(InstanceConfig)`
+ ergänzt werden.
+- **TLS** ist Pflicht; selbstsignierte Zertifikate ausdrücklich konfigurieren müssen statt
+ Validierung generell abschalten.
+
+### 2.8 Tool-Zuschnitt
+
+```
+Tool: RocketChat
+Aktionen: send_message | reply | send_file | list_rooms | read_room
+ | mark_read | search
+Job: rocketchat_poll
+```
+
+Konfiguration je Agent:
+
+```json
+"RocketChat": {
+ "baseUrl": "https://chat.example.org",
+ "userId": "aBcD…",
+ "authToken": "…", // von ConfigSecrets geschützt
+ "allowedRooms": ["GENERAL", "finanz-team"],
+ "defaultRoom": "finanz-team",
+ "mentionOnly": true,
+ "maxWakesPerRoomPerHour": 12
+}
+```
+
+---
+
+## 3 — Redundanz: was passiert, wenn Rocket.Chat ausfällt
+
+Das ist die Anforderung, die die Architektur bestimmt — nicht der Chat selbst. Der Kern:
+**Rocket.Chat darf ein Kanal sein, nicht der Kanal.**
+
+### 3.1 Was heute schon unabhängig funktioniert
+
+| Kanal | Unabhängig von Rocket.Chat? | Richtung |
+|---|---|---|
+| WinForms-Chat (`frm_chat`) | vollständig — läuft in der App selbst | beide |
+| Telegram-Bot-Tool | ja — fremde Infrastruktur | beide |
+| Mail-Tool | ja, sofern der Mailserver anderswo läuft | beide |
+| Web-Chat / `ClawdDotNetApi` | ja, aber nur im lokalen Netz | beide |
+
+Wir sind also nicht bei null. Was fehlt, ist die **Umschaltung** — heute muss ein Mensch
+merken, dass nichts mehr ankommt.
+
+### 3.2 Vorschlag: `ChannelRouter` im Core
+
+Eine kleine Komponente im Core (kein neues Tool, keine Tool-zu-Tool-Abhängigkeit —
+sie löst Tools über die vorhandene `ToolRegistry` nach Namen auf, wie es der Dispatcher
+schon tut):
+
+- Je Agent eine **geordnete Kanalliste**, z. B. `["RocketChat", "Telegram", "Mail"]`.
+- Eine Methode „stelle dem Menschen diese Nachricht zu": versucht der Reihe nach, bis
+ einer erfolgreich ist, und protokolliert im Audit-Log, **über welchen Kanal** zugestellt
+ wurde — inklusive des Hinweises „Primärkanal war nicht erreichbar".
+- Genutzt von: Agenten (`notify_user`), aber vor allem von **systemseitigen** Meldungen,
+ die heute keinen Weg nach außen haben: Staging-Vorschlag wartet auf Freigabe, Budget
+ überschritten, Watchdog-Alarm, Task blockiert.
+
+Der zweite Teil ist der wichtigere: Gerade wenn etwas kaputt ist, ist die Meldung darüber
+diejenige, die ankommen muss.
+
+### 3.3 Gesundheitsprüfung und Eskalation
+
+Ein Tool-Job `rocketchat_health` (Takt ~5 Minuten, `GET /api/info`):
+
+- Nach **drei** aufeinanderfolgenden Fehlschlägen: einmalige Meldung über den nächsten
+ Kanal der Liste — „Rocket.Chat ist seit HH:MM nicht erreichbar, ich melde mich hier."
+ Einmalig, nicht je Takt.
+- Bei Rückkehr: „Rocket.Chat ist wieder da", und der Zustand wird zurückgesetzt.
+- Nachrichten, die während des Ausfalls nicht gesendet werden konnten, werden **nicht**
+ in einer eigenen Warteschlange gehalten — sie gehen über den Ersatzkanal raus. Eine
+ zweite Zustellwarteschlange wäre eine zweite Fehlerquelle.
+
+**Eingehend während des Ausfalls:** Der Telegram-Poll-Job bleibt dauerhaft aktiv, nur mit
+langsamem Takt (z. B. alle 5 Minuten). Er kostet nichts, wenn nichts kommt — und ist im
+Ernstfall der Weg, auf dem *du* die Agenten erreichst. Der WinForms-Chat ist ohnehin immer
+da, solange die App läuft.
+
+**Entschieden (August 2026): Telegram ist der Notfallkanal.** Die Kanalliste lautet damit
+`["RocketChat", "Telegram"]`. Konsequenzen:
+
+- Das Telegram-Tool wird **nicht** abgebaut und geht nicht in Rocket.Chat auf. Es behält
+ seine Rolle, verliert aber die Rolle als Alltagskanal.
+- `Telegram.send_message` bleibt in der Staging-Policy auf `approve` — mit einer Ausnahme:
+ Meldungen, die der `ChannelRouter` selbst erzeugt (Ausfall, Budget, Watchdog, offene
+ Freigabe), laufen **ohne** Freigabe. Sonst bliebe die Warnung, dass eine Freigabe
+ aussteht, selbst in der Freigabewarteschlange hängen — ein Ringschluss, der genau im
+ Ernstfall zuschlägt.
+- Der Telegram-Poll bleibt dauerhaft eingerichtet, aber mit langsamem Takt. Ein Kanal, der
+ erst im Notfall eingeschaltet wird, ist im Notfall ungetestet.
+- Mail bleibt außen vor. Zwei Ersatzkanäle zu pflegen lohnt nicht; das Mail-Tool behält
+ seinen fachlichen Zweck.
+
+### 3.4 Was das für die Prompts heißt
+
+Ein Agent soll seinen Kanal nicht selbst wählen. Er sagt „ich möchte dem Nutzer das hier
+mitteilen", der Router entscheidet. Sonst muss das Modell im Fehlerfall improvisieren —
+und genau dann ist Improvisation das Letzte, was man will.
+
+---
+
+## 4 — Konflikt mit Roadmap A5 (Matrix)
+
+Roadmap-Punkt **A5** legt fest: „Die Kommunikation (Benachrichtigungen, Berichte, Chat mit
+Agenten) wird auf Element/Matrix umgestellt", und der gestrichene Tool-Kandidat *Notify*
+geht darin auf.
+
+Rocket.Chat besetzt exakt dieselbe Rolle. Drei mögliche Auflösungen:
+
+1. **Rocket.Chat ersetzt A5.** A5 wird umgeschrieben, Matrix entfällt. Vorteil: eine
+ Schiene, ein Betriebsaufwand, die Instanz läuft bereits.
+2. **A5 bleibt, Rocket.Chat ist nur ein weiteres Tool.** Dann bauen wir zweimal dasselbe.
+ Schwer zu begründen.
+3. **Rocket.Chat primär, Matrix als späterer Zweitkanal.** Passt formal zur
+ Redundanz-Anforderung, verdoppelt aber den Wartungsaufwand für einen Fall, den
+ Telegram schon abdeckt.
+
+**Meine Empfehlung: (1).** Der `ChannelRouter` aus 3.2 ist ohnehin die Verallgemeinerung,
+die A5 gebraucht hätte — mit ihm ist ein späterer Matrix-Kanal ein zusätzlicher Eintrag in
+der Liste, keine Migration. Das ist eine Entscheidung für dich, keine technische Sachfrage.
+
+---
+
+## 5 — Nextcloud: Machbarkeit
+
+### 5.1 Der Zugriffsweg
+
+Nextcloud hat zwei Schnittstellen, beide brauchen wir:
+
+| Zweck | Schnittstelle |
+|---|---|
+| Dateien lesen/schreiben/auflisten/verschieben | **WebDAV**: `/remote.php/dav/files/{benutzer}/{pfad}` |
+| Öffentlichen Link erzeugen | **OCS**: `/ocs/v2.php/apps/files_sharing/api/v1/shares` |
+
+Authentifiziert wird mit **App-Passwörtern** (Nextcloud → Einstellungen → Sicherheit →
+„Neues App-Passwort erstellen") per Basic-Auth. Ein App-Passwort ist einzeln widerrufbar
+und lässt das eigentliche Kontopasswort unangetastet — dieselbe Logik wie das Personal
+Access Token bei Rocket.Chat.
+
+WebDAV braucht keine Bibliothek: `HttpClient` mit den Methoden `PUT`, `GET`, `MKCOL`,
+`PROPFIND`, `MOVE`, `DELETE`. Nur `PROPFIND` liefert XML (Multistatus), das geparst werden
+muss — überschaubar, und es erspart uns eine weitere Abhängigkeit.
+
+### 5.2 Ein Benutzer je Agent — oder ein Sammelkonto?
+
+Zwei Modelle:
+
+- **Je Agent ein Nextcloud-Benutzer.** Sauber nachvollziehbar („wer hat das abgelegt"),
+ passt zum Rocket.Chat-Modell, kostet je nach Lizenzmodell Nutzer.
+- **Ein Dienstkonto `clawd-agents` mit Unterordnern je Agent.** Einfacher zu verwalten,
+ Herkunft steht dann im Pfad statt im Konto.
+
+**Empfehlung:** Ein Dienstkonto mit Ordnerstruktur `/ClawdDotNet/{Agent}/…`, **plus** einen
+mit dir geteilten Ordner `/ClawdDotNet/Berichte/`. Begründung: Bei Rocket.Chat ist die
+eigene Identität funktional zwingend (DMs, Erwähnungen), bei Dateien ist sie es nicht —
+und ein Ordnerbaum ist leichter aufzuräumen als zehn Konten. Falls du die Trennung dennoch
+willst, ändert das am Tool nichts, nur an der Konfiguration.
+
+Die Ordnerdurchsetzung gehört ins Tool: eine konfigurierte `rootPath`, aus der der Agent
+nicht ausbrechen kann — dieselbe Prüfung wie in `FTPTool.ResolveLocalPath`.
+
+### 5.3 Die ehrliche Antwort zu „direkt über die API erstellen"
+
+Nextcloud hat **keine** API, die Dokumenteninhalte erzeugt. Es ist ein Dateiablage- und
+Freigabesystem; Collabora ist ein *Editor* im Browser (über WOPI angebunden), kein
+Generator, den man von außen mit „erstelle eine Tabelle mit diesen Zahlen" beauftragen
+kann.
+
+Der tatsächliche Weg ist deshalb der, den du selbst schon beschrieben hast:
+
+```
+Agent erzeugt die Datei im eigenen Workspace (FileRW-Tool, schon vorhanden)
+ → Nextcloud.upload (WebDAV PUT)
+ → Nextcloud.share (optional) (OCS, liefert Link)
+ → RocketChat.send_message mit dem Link
+```
+
+Das ist kein Umweg, sondern die richtige Aufteilung: Der Agent kann seine Datei lokal
+prüfen und korrigieren, bevor sie irgendwo landet.
+
+### 5.4 Formate — und wo Collabora doch nützlich wird
+
+Was ein Agent von sich aus gut schreiben kann: **Markdown** (Nextcloud rendert `.md`
+direkt in der Weboberfläche — für Berichte oft die beste Wahl), **CSV**, **HTML**, JSON.
+
+Was er nicht von sich aus schreiben kann: `.xlsx`, `.docx`, `.pdf`.
+
+Hier gibt es einen eleganten Weg, weil du Collabora ohnehin betreibst: Collabora Online
+bringt einen **Konvertierungs-Endpunkt** mit (`POST /cool/convert-to/{format}`, multipart).
+Damit gilt:
+
+| Ziel | Weg |
+|---|---|
+| PDF | Agent schreibt HTML oder ODT → Collabora → PDF |
+| XLSX | Agent schreibt CSV → Collabora → XLSX |
+| DOCX | Agent schreibt HTML/ODT → Collabora → DOCX |
+
+Vorteil: **keine zusätzliche PDF- oder Excel-Bibliothek** im Projekt (und keine
+Lizenzfrage, die wir uns damit einhandeln — mehrere verbreitete .NET-Bibliotheken für
+XLSX und PDF sind für kommerzielle Nutzung nicht frei).
+
+Zu prüfen, bevor wir darauf bauen:
+- Ist der Endpunkt in deiner Collabora-Installation erreichbar? Er muss in `coolwsd.xml`
+ für die IP des ClawdDotNet-Rechners freigegeben sein (`net`/`post_allow`-Allowlist).
+ Standardmäßig ist das eng gefasst.
+- Der Pfad heißt je nach Version `/cool/convert-to/…` (neu) oder `/lool/convert-to/…` (alt).
+
+Falls der Endpunkt nicht freigegeben werden soll: Rückfallebene ist Markdown/CSV — für
+den Alltag völlig ausreichend, PDF wäre dann ein späterer eigener Punkt.
+
+### 5.5 Freigabe-Links
+
+`POST /ocs/v2.php/apps/files_sharing/api/v1/shares` (Header `OCS-APIRequest: true`),
+`shareType=3` = öffentlicher Link. Optional `password`, `expireDate`, `permissions=1`
+(nur lesen). Die Antwort enthält die fertige URL.
+
+Zwei Hinweise:
+- Manche Instanzen erzwingen Passwortschutz für öffentliche Links — dann muss das Tool ein
+ Passwort mitgeben und zurückliefern.
+- Ein öffentlicher Link ist **irreversibel im Sinne von A2**: Einmal geteilt, kann er
+ weitergegeben worden sein, auch wenn man ihn danach löscht. Deshalb steht er unten in
+ der Staging-Tabelle auf `approve`.
+
+Innerhalb der eigenen Instanz ist die freundlichere Variante `shareType=0` (an einen
+konkreten Nextcloud-Benutzer) — kein öffentlicher Link nötig, wenn du ohnehin ein Konto
+hast. Das sollte der **Standard** sein, öffentlich die Ausnahme.
+
+### 5.6 Fallstricke
+
+- **Dateisperren (HTTP 423).** Wenn du eine Datei gerade in Collabora offen hast, kann ein
+ Upload auf dieselbe Datei scheitern. Das Tool muss 423 sauber melden statt kryptisch zu
+ scheitern — und beim Überschreiben eines Berichts lieber einen neuen Dateinamen mit
+ Zeitstempel vergeben.
+- **Überschreiben ist nicht destruktiv**, solange die Versionierung aktiv ist (Nextcloud
+ legt automatisch eine Vorversion an). Das ist der Grund, warum `Nextcloud.upload` unten
+ auf `auto` steht, `FTP.upload` aber auf `approve`.
+- **Größenbegrenzung.** Ein einfaches `PUT` reicht für Berichte problemlos; erst bei sehr
+ großen Dateien bräuchte es den Chunked-Upload (`/remote.php/dav/uploads/…`). Für den
+ angedachten Zweck (Berichte, Tabellen, PDFs) nicht nötig — und wenn doch, meldet der
+ Server einen klaren Fehler.
+- **Quota.** Ein Agent, der stündlich Berichte ablegt, füllt das Konto. Ein Aufräum-Task
+ („Berichte älter als 90 Tage") gehört mittelfristig ins Taskboard.
+
+### 5.7 Tool-Zuschnitt
+
+```
+Tool: Nextcloud
+Aktionen: upload | download | list | mkdir | move | delete
+ | share | unshare | convert (convert nur falls Collabora freigegeben)
+```
+
+Konfiguration je Agent:
+
+```json
+"Nextcloud": {
+ "baseUrl": "https://cloud.example.org",
+ "username": "clawd-agents",
+ "appPassword": "…", // von ConfigSecrets geschützt
+ "rootPath": "/ClawdDotNet/Hermes",
+ "allowPublicShares": false,
+ "collaboraUrl": "https://collabora.example.org"
+}
+```
+
+`appPassword` muss der Schlüsselliste in `ConfigSecrets` hinzugefügt werden — `password`
+allein greift nicht, weil dort auf ganze Feldnamen verglichen wird.
+
+---
+
+## 6 — Verzahnung mit Staging (A2) und Audit (A3)
+
+Vorschlag für die `StagingPolicy.DefaultRules`:
+
+| Aktion | Standard | Begründung |
+|---|---|---|
+| `RocketChat.send_message` (erlaubter Raum) | **auto** | Sonst ist Chat unbenutzbar — jede Antwort bräuchte einen Klick |
+| `RocketChat.send_message` (Raum nicht in `allowedRooms`) | **deny** | Wird vom Tool selbst abgewiesen, gar nicht erst vorgelegt |
+| `RocketChat.send_file` | **approve** | Dateiabfluss in einen Chatraum |
+| `Nextcloud.upload`, `mkdir`, `move` | **auto** | Versioniert, im eigenen Ordner, umkehrbar |
+| `Nextcloud.delete` | **approve** | wie `FileRW.delete` |
+| `Nextcloud.share` (an Benutzer) | **auto** | bleibt innerhalb der Instanz |
+| `Nextcloud.share` (öffentlicher Link) | **approve** | nicht zurückholbar |
+
+Der Unterschied zu `Telegram.send_message` (heute `approve`) ist Absicht: Telegram ist ein
+Benachrichtigungskanal nach außen, Rocket.Chat ist der Arbeitsraum. Ein Arbeitsraum, in
+dem jede Antwort eine Freigabe braucht, ist kein Arbeitsraum. Der Schutz sitzt hier an der
+Raum-Allowlist statt an der Einzelfreigabe.
+
+Für das Audit-Log entstehen keine Sonderfälle — die Tool-Aufrufe laufen ohnehin durch.
+
+---
+
+## 7 — Was dieses Konzept **nicht** vorsieht
+
+Damit der Zuschnitt klar ist:
+
+- Keine Rocket.Chat-**App** (Apps-Engine, TypeScript im Server) — wir bleiben Client.
+- Keine Verwaltung von Rocket.Chat durch Agenten (Benutzer anlegen, Räume erstellen,
+ Rechte vergeben). Das ist Admin-Arbeit in der WinForms-Oberfläche.
+- Keine Sprach-/Videofunktionen, keine Nextcloud Talk-Anbindung.
+- Kein Ersatz für den WinForms-Chat — der bleibt und ist die unterste Rückfallebene.
+- Keine Ende-zu-Ende-Verschlüsselung. Rocket.Chat kann das, aber verschlüsselte Räume sind
+ über die REST-API nicht lesbar. Agenten arbeiten in unverschlüsselten Räumen — das ist
+ eine bewusste Einschränkung, die du kennen solltest.
+
+---
+
+## 8 — Vorschlag für den Schnitt
+
+| Phase | Inhalt | Ergebnis |
+|---|---|---|
+| **1** | `Nextcloud`-Tool: upload/download/list/mkdir/move/delete/share | Agent kann Berichte ablegen und einen Link liefern |
+| **2** | `RocketChat`-Tool: senden, lesen, `rocketchat_poll`-Job, Raum-Allowlist, Erwähnungsfilter, Schleifendrossel | Gespräch mit Agenten über Rocket.Chat, Antwort per Prompt |
+| **3** | Automatischer Rückweg (`ReplyTo` in `ToolJobResult`) | Antwort landet zuverlässig im richtigen Raum/Thread |
+| **4** | `ChannelRouter` + `rocketchat_health` + Eskalation | Der Notfallkanal — Ausfall wird erkannt und umschifft |
+| **5** | Collabora-Konvertierung (PDF/XLSX) | Berichte in Büroformaten |
+| **6** *(optional)* | Realtime/DDP statt Polling | Antwortzeit ~1 s statt ~30 s |
+
+Nextcloud zuerst, weil es das kleinere, in sich abgeschlossene Stück ist und sofort Nutzen
+bringt — und weil es sich unabhängig vom Ausgang der A5-Entscheidung lohnt.
+
+Phase 4 ist **kein Nice-to-have**: Ohne sie ist Rocket.Chat ein Einzelpunkt, dessen Ausfall
+niemand meldet. Sie sollte nicht hinter Phase 5 rutschen.
+
+Zur Modell-Einstufung im Sinne der Roadmap: Phasen 1, 2 und 5 sind klar spezifizierbare
+Tool-Arbeit (4.6-tauglich). Phase 3 und 4 fassen Engine bzw. Zustellwege an und sollten
+mit vorheriger Festlegung der Invarianten und mit Tests gebaut werden.
+
+---
+
+## 9 — Offene Punkte für die Diskussion
+
+1. **A5/Matrix** — ersetzt Rocket.Chat den Punkt, oder bleibt Matrix als Ziel bestehen?
+ (Abschnitt 4; das entscheidet, ob der `ChannelRouter` Pflicht oder Kür ist.)
+2. **Nextcloud-Identität** — ein Dienstkonto mit Ordnern je Agent (mein Vorschlag) oder
+ je Agent ein eigener Nextcloud-Benutzer?
+3. **Rückweg der Antwort** — reicht Phase 2 (Agent antwortet selbst) für den Anfang, oder
+ soll Phase 3 direkt mitgebaut werden?
+4. ~~**Notfallkanal** — Telegram oder Mail?~~ **Entschieden: Telegram** (siehe 3.3).
+ Offen bleibt nur die Kleinigkeit, ob die Reihenfolge instanzweit gilt (mein Vorschlag)
+ oder pro Agent einstellbar sein soll.
+5. **Collabora-Konvertierung** — ist der `convert-to`-Endpunkt für den ClawdDotNet-Rechner
+ freigebbar? Falls nein, bleibt es bei Markdown/CSV.
+6. **Versionen** — welche Rocket.Chat- und welche Nextcloud-Version läuft bei dir?
+ Einzelne Endpunkte und Rollennamen sind versionsabhängig; das prüfe ich vor der
+ Umsetzung gegen deine Instanz statt gegen die Dokumentation.
+7. **Öffentliche Links** — grundsätzlich erlauben (mit Freigabe) oder ganz sperren
+ (`allowPublicShares: false` als harte Voreinstellung)?
diff --git a/docs/Staging-Konzept.md b/docs/Staging-Konzept.md
new file mode 100644
index 0000000..26b36e8
--- /dev/null
+++ b/docs/Staging-Konzept.md
@@ -0,0 +1,108 @@
+# Staging-Freigabe für irreversible Aktionen
+
+Setzt A2 aus der [Roadmap](Roadmap.md) um (F-A1 + S4). Irreversible Aktionen werden
+**gestaged statt ausgeführt**: Vorschlag → Review im Hauptfenster → Freigabe/Ablehnung.
+Aufbauend auf dem Taskboard (A1, Fortsetzung nach Freigabe) und dem Audit-Log (A3,
+Approval-Records).
+
+Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen **Vorschlag**
+erzeugen, keine Ausführung.
+
+## Das Gate wird zum Durchsetzungspunkt (S4)
+
+Das bisher wirkungslose `PermissionGate` (es prüfte nur, ob ein Tool zugewiesen ist)
+wird zur zentralen Stelle: Policy-Prüfung, Staging-Entscheidung und Audit-Hook an
+**einer** Stelle (`AgentEngine.ExecuteToolCallAsync`), statt ad-hoc in jedem Tool. S4
+geht hier auf.
+
+## Policy: `auto | approve | deny` pro Tool/Aktion
+
+Je (Tool, Aktion) eine Entscheidung:
+
+- **`auto`** — läuft wie bisher.
+- **`approve`** — wird gestaged; die Ausführung wartet auf eine menschliche Freigabe.
+- **`deny`** — wird gar nicht erst vorgeschlagen, sondern abgelehnt.
+
+Auflösung vom Speziellen zum Allgemeinen: `Tool.Aktion` → `Tool` → Standard (`auto`).
+Die „Aktion" ist das `action`-Argument des Aufrufs (die meisten Tools haben es).
+
+**Eingebaute Standardregeln** (nach Sichtung der Tools, überschreibbar per Konfiguration)
+— genau die in der Roadmap genannten irreversiblen Aktionen:
+
+| Tool.Aktion | Standard |
+|---|---|
+| `Mail.send` | approve |
+| `Telegram.send_message` | approve |
+| `Database.insert`, `Database.upsert` | approve |
+| `FileRW.delete` | approve |
+| `FTP.upload`, `FTP.delete` | approve |
+| alles andere | auto |
+
+Lesende Aktionen (`Mail.read_inbox`, `FileRW.read`, `Database.query`, …) bleiben `auto` —
+Staging soll schützen, nicht lähmen.
+
+## Plan-Freeze
+
+Freigegeben wird ein **eingefrorener, konkreter Aufruf**: Tool, Aktion und die **exakten
+Argumente** zum Zeitpunkt des Stagings. Ausgeführt wird genau das Eingefrorene (der
+gespeicherte Argument-JSON), nie eine nachträglich veränderte Fassung. Jede Änderung
+wäre eine neue Freigabe.
+
+Das ist auch der Grund, warum der Agent den Aufruf **nicht** nach der Freigabe erneut
+formuliert (er könnte etwas anderes bauen) — der eingefrorene JSON wird direkt an das
+Tool gegeben.
+
+## Fortsetzung nach Freigabe — kein pausierter Lauf
+
+Die tragende Architektur-Vorgabe (aus der Roadmap-Einstufung): **Kein pausierter, im
+Speicher gehaltener Lauf.** Der Ablauf:
+
+1. **Vorschlag.** Der Agent ruft eine `approve`-Aktion auf. Das Gate führt sie nicht aus,
+ sondern legt einen **Pending**-Datensatz an (eingefrorener Aufruf) und gibt dem Agenten
+ „Zur Freigabe vorgelegt (#id)" als Tool-Ergebnis zurück. Der Lauf endet **regulär** —
+ der Agent schließt ab, nichts hängt im Speicher.
+2. **Review.** Ein Mensch sieht die offenen Vorschläge im Hauptfenster und entscheidet.
+3a. **Freigabe.** Der eingefrorene Aufruf wird **direkt** ausgeführt (standalone, nicht
+ über einen Chat-Lauf). Das Ergebnis wird festgehalten, und ein **Folge-Task** (A1)
+ weckt den Agenten: „Deine Aktion X wurde freigegeben und ausgeführt, Ergebnis: Y —
+ mach weiter." Der Scanner stellt ihn zu (`ChatAsync`, bestehender Kontext).
+3b. **Ablehnung.** Ein Folge-Task weckt den Agenten mit der Ablehnung (samt Grund).
+
+Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser
+Vereinfachung unnötig.
+
+## Approval-Records (A3)
+
+Jede Entscheidung — Freigabe wie Ablehnung — wird an zwei Stellen verankert:
+
+- Im **Staging-Datensatz** selbst: Status, `DecidedBy`, `DecidedAt`, Ergebnis-Verweis
+ bzw. Ablehnungsgrund.
+- Als **Audit-Eintrag** (A3): Tool, Ausgang, `source: approval`, „Freigegeben von …" bzw.
+ „Abgelehnt von …". Die Herkunft stempelt auch hier das System, nicht der Agent.
+
+Der Vorschlag selbst wird beim Anlegen als Audit-Eintrag mit Status **`Staged`** notiert —
+so ist die ganze Kette (Vorschlag → Entscheidung → Ausführung) im Log nachvollziehbar.
+
+## Nebenläufigkeit
+
+Zwei Reviewer dürfen nicht denselben Vorschlag doppelt freigeben. Der Übergang
+`pending → approved/rejected` ist ein **atomares, bedingtes `UPDATE`** (dieselbe
+Claim-Technik wie beim Taskboard): Genau einer gewinnt, der zweite Klick läuft ins Leere.
+Erst nach gewonnenem Übergang wird der eingefrorene Aufruf ausgeführt.
+
+## Verdrahtung
+
+- `StagingGate` (Policy + Anlegen des Vorschlags) hängt optional an der Engine — ohne es
+ läuft alles wie bisher (`auto`).
+- Der eingefrorene Aufruf wird über `IFrozenCallExecutor` (von der Engine implementiert)
+ ausgeführt: gültiger Tool-Kontext, aber ohne LLM-Schleife.
+- `StagingService` (Freigabe/Ablehnung) nutzt Executor, Audit und das Taskboard für den
+ Folge-Task. Es ist die API, die die Review-Oberfläche aufruft.
+
+## Offen
+
+- **Review-Oberfläche** im Hauptfenster (Liste der offenen Vorschläge, Freigeben/Ablehnen)
+ — die Dienst-API steht bereit; die WinForms-Ansicht ist die verbleibende Integration.
+- **Output-Scrubbing** greift auch hier auf den gespeicherten Argument-JSON, sobald es
+ steht (eigener Roadmap-Punkt).
+- **Orders** (Handelsaufträge) reihen sich später als weitere `approve`-Aktionen ein.
diff --git a/docs/Taskboard-Konzept.md b/docs/Taskboard-Konzept.md
new file mode 100644
index 0000000..a8c8776
--- /dev/null
+++ b/docs/Taskboard-Konzept.md
@@ -0,0 +1,334 @@
+# Taskboard — Aufgaben statt Delay-Schleifen
+
+Setzt A1 aus der [Roadmap](Roadmap.md) um. Das Taskboard ist das Fundament, auf dem
+Audit (A3), Staging (A2), Marktkalender (C1) und das Ergebnisregister (C7/C8)
+aufsetzen. Es löst zugleich sechs Altpunkte auf einmal (F-A5 Queue, F-A4 Run-Historie,
+B8 Rekursion, B6 `Task.Delay`-Überlauf, B7 Cron in Lokalzeit, T7 `RunAsync` vs.
+`ChatAsync`).
+
+Aufbau analog zum [Memory-Konzept](Memory-Konzept.md): erst warum die vorhandenen
+Mechanismen nicht reichen, dann Dateiformat, Wahrheitsaufteilung, Scanner-Verhalten,
+Invarianten, Migration.
+
+## Das Problem
+
+Heute gibt es zwei getrennte, je für sich unzureichende Wege, einen Agenten Arbeit
+tun zu lassen:
+
+- **Der Cron-Scheduler** ([`AgentScheduler`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs),
+ [`ToolJobScheduler`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs)) hängt starr
+ am Agenten: eine Cron-Zeile je Agent, ausgeführt über ein `Task.Delay` bis zum
+ nächsten Termin. Ein jährlicher Termin bedeutet ein `Task.Delay` über Monate (B6).
+ Cron läuft in Lokalzeit ohne explizite Zone (B7). Ob mit oder ohne Kontext gelaufen
+ wird, entscheidet ein implizites Flag (`UseChatContext`, T7).
+- **Die `coordination/*.md`-Dateien** im SharedWorkspace sind die improvisierte
+ Antwort der Agenten darauf, dass es kein Aufgabenmodell gibt: `task_*`-, `status_*`-
+ und `broadcast`-Dateien, per Konvention beschrieben, ohne Schema, ohne Claiming,
+ ohne Zustandsübergänge. Zwei Agenten, die dieselbe Datei „übernehmen", tun das ohne
+ jede Absicherung.
+
+Delegation läuft heute über rekursives `send_message`/`spawn` — ein Agent ruft
+synchron einen anderen, der wieder einen dritten (B8: Zyklengefahr, deshalb ein
+`LoopGuard` als Notbremse). Es gibt keine Run-Historie am Auftrag und keine Queue.
+
+## Warum ein neues Subsystem, nicht der vorhandene State-Store
+
+Dieselbe Überlegung wie beim Gedächtnis: `IStateStore` ist eine Schlüssel-Wert-Tabelle
+für kleine Marker. Ein Aufgabenmodell mit Status, Zuweisung, Abhängigkeiten und
+Terminen darin abzulegen hieße, JSON in eine `Value`-Spalte zu schreiben — nicht
+filterbar, nicht atomar claimbar, nicht auswertbar.
+
+Der Kern ist eine **atomare Anspruchsnahme** (Claim). Genau das kann ein Dateisystem
+nicht verlässlich und der Schlüssel-Wert-Store nicht ausdrücken, eine SQL-Zeile mit
+einem bedingten `UPDATE` aber sehr wohl. Deshalb eine eigene Tabelle auf dem
+vorhandenen [`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs) (WAL,
+`busy_timeout`, prozessweite Schreib-Warteschlange) — dieselbe Grundlage, die schon
+Gedächtnis, Zustand und Verbrauch teilen.
+
+## Wahrheitsaufteilung — Datei ist Definition, DB ist Koordination
+
+Die eine Entscheidung, an der alles hängt:
+
+| Ebene | Wahrheit über | Wer schreibt |
+|---|---|---|
+| **Markdown-Datei** (Frontmatter + Rumpf) | die *Definition* der Aufgabe: Titel, Priorität, Assignee, Termin, Abnahme, Abhängigkeiten. Menschen- und agentenlesbar. | Mensch (Editor), Agent (`task_*`-Tool) |
+| **SQLite-Tabelle `Tasks`** | den *Ausführungszustand*: Status, Claim, Lease, Last-Fired-Marker je Termin, Blocker-Auflösung. | ausschließlich das Taskboard selbst (Importer, Scanner, Tool) |
+
+**Regel:** Für die Definition ist die Datei die Wahrheit. Für jede
+Ausführungsentscheidung ist die DB die Wahrheit. Weichen beide ab (Absturz zwischen
+DB-Claim und Datei-Schreiben), gewinnt die DB, und die Datei wird bei der
+Reconciliation nachgezogen.
+
+Warum nicht alles nur in die DB und die Datei als reine Projektion? Weil die Datei der
+Bedienpunkt ist: Ein Mensch soll eine Aufgabe im Editor anlegen und ändern können, ein
+Agent über sein Tool, und beides soll im SharedWorkspace sichtbar und versionierbar
+bleiben. Warum nicht alles nur in Dateien? Weil das Claiming dort nicht atomar geht —
+siehe oben. Die Aufteilung nimmt von beidem das Belastbare.
+
+Der **Importer** ist die Brücke: Er liest die Frontmatter-Definition und spiegelt sie
+idempotent in die DB-Zeile (`UPSERT` auf `task_id`). Er läuft beim Start (alle Dateien),
+nach jeder `task_*`-Änderung (die betroffene Datei) und optional per
+`FileSystemWatcher` mit Debounce (~500 ms, wie bei A4), damit von Hand editierte
+Dateien zeitnah einfließen.
+
+## Dateiformat
+
+Aufgaben liegen als eine Datei je Aufgabe unter `SharedWorkspace/tasks/`. Der
+Dateiname ist beschreibend (`recherche-nvda-earnings.md`); die stabile Identität ist
+die `id` im Frontmatter, nicht der Name — so überlebt eine Aufgabe das Umbenennen.
+
+```markdown
+---
+id: t-8f3a2c # stabil, beim Anlegen vergeben; Wahrheit der Identität
+title: NVDA Earnings recherchieren
+status: todo # backlog | todo | in_progress | in_review | done | canceled | blocked
+type: work # work | approval | human_input
+priority: 3 # 1 (niedrig) .. 5 (hoch)
+assignee: "@crawler" # @new | @ | @human
+when: # optional; fehlt = einmalige Aufgabe, sofort fällig
+ kind: cron # at | every | cron
+ value: "0 7 * * 1-5"
+ tz: Europe/Berlin # PFLICHT, wenn when gesetzt ist — kein Termin ohne Zone
+require_approval: false # true = gilt erst nach Review (in_review) als done
+acceptance: | # Abnahmekriterien, gegen die das Ergebnis geprüft wird
+ Aktuelle Zahlen mit Datum und Quelle, in SharedWorkspace/data/nvda.json abgelegt.
+blocked_by: [t-4b1e] # diese Aufgabe startet erst, wenn alle Blocker done sind
+onlyWhenMarketOpen: false # C1: Termin nur auslösen, wenn der Markt offen ist
+---
+
+Freitext-Rumpf: Auftragsbeschreibung, Kontext, Verweise. Geht als Aufgabenstellung
+an den Agenten. Kommentare (Ergebnisse, Kritik, Reopen) werden unten angehängt.
+```
+
+Feldregeln:
+
+- **`when.kind`**: `at` (einmaliger Zeitpunkt, ISO 8601), `every` (Intervall, z. B.
+ `30m`), `cron` (5-Felder-Ausdruck wie bisher). `tz` ist bei allen dreien Pflicht —
+ das ist die Antwort auf B7. Die vorhandene
+ [`CronExpression`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs) rechnet
+ heute zonen-blind in `DateTime.Now`; sie wird in einen zonen-bewussten Aufruf
+ gekapselt (nächsten Termin in `tz` bestimmen, dann in UTC vergleichen). Das
+ verzahnt sich mit R2 (`TimeProvider`) aus der [Teststrategie](Teststrategie.md).
+- **`assignee`** ersetzt das `UseChatContext`-Flag durch eine explizite Angabe (T7):
+ - `@new` bzw. `@new:` → frischer Lauf ohne Historie (`AgentEngine.RunAsync`).
+ Bloßes `@new` ist nur eindeutig, wenn die Instanz genau **einen** Agenten hat; sonst
+ benennt `@new:` den Ziel-Agenten. Ist er nicht auflösbar, scheitert der
+ Dispatch mit klarer Meldung, statt einen falschen Agenten zu raten.
+ - `@` → bestehender Agent mit seinem Kontext (`AgentEngine.ChatAsync`)
+ - `@human` → wartet auf einen Menschen; kein Modell-Lauf
+- **`type`**: `work` (Standard), `approval` und `human_input`. Bei den letzten beiden
+ ist ein Mensch der Assignee; seine Antwort **ist** das Ergebnis und Input für
+ Folgeaufgaben. Kein Sonderpfad — nur ein Assignee, der kein Modell ist.
+
+## Scanner-Verhalten
+
+Ein einziger Takt (~60 s) statt vieler langer `Task.Delay`. Damit kennt das System
+keine Monats-Delays mehr (B6), und ein verpasster Takt ist ein verpasster Termin, kein
+Zeitbombe.
+
+Je Takt:
+
+1. **Fällige Termine bestimmen.** Aus jeder aktiven Task-Zeile den nächsten Termin in
+ ihrer `tz` berechnen und gegen „jetzt" prüfen. Ein Termin ist durch einen
+ **Occurrence-Key** eindeutig: `task_id` + geplante Feuerzeit (bei `at`/`cron`/`every`).
+2. **Claim vor Lauf (at-most-once).** Bevor gelaufen wird, wird der Occurrence-Key
+ atomar beansprucht:
+
+ ```sql
+ UPDATE Tasks
+ SET claim_token = @token, claimed_at = @now, last_occurrence = @occ, status = 'in_progress'
+ WHERE id = @id
+ AND status IN ('todo','backlog')
+ AND (last_occurrence IS NULL OR last_occurrence < @occ)
+ AND (claim_token IS NULL OR claimed_at < @leaseCutoff);
+ ```
+
+ Genau eine Zeile betroffen = Anspruch gewonnen. Ein zweiter, gleichzeitiger Takt
+ findet die Bedingung nicht mehr erfüllt (Rowcount 0) und läuft **nicht** — so wird
+ ein doppelter Tick zu einem Lauf.
+3. **Dispatch nach Assignee.** `@new` → `RunAsync`; `@` → `ChatAsync`; `@human`
+ → kein Lauf, Status bleibt/wird `in_review` bzw. `todo`, die UI zeigt die Aufgabe
+ als wartend. Der Rumpf (+ Abnahmekriterien) ist die Nachricht an den Agenten.
+4. **Abschluss verbuchen.** Last-Fired-Marker setzen (`last_occurrence = @occ`), Status
+ fortschreiben, Claim lösen. Bei `require_approval` → `in_review` statt `done`. Der
+ Lauf wird am Task verknüpft (Grundlage für A3-Receipts und die Run-Historie F-A4).
+
+**Kein Retry-Sturm.** Der Marker wird auch bei einem **fehlgeschlagenen** Lauf gesetzt:
+ein fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin. Wiederholung
+ist eine bewusste Entscheidung (neuer Termin oder Reopen), kein Automatismus. Das
+schützt vor einem Agenten, der bei jedem Takt erneut in denselben Fehler läuft und
+Budget verbrennt.
+
+**Serialisierung mit den Chat-Läufen (B2).** Der Scanner ruft die Engine über
+dieselben Einstiegspunkte wie WebView, ToolJob und AgentComm. `ChatAsync` ist bereits
+je Agent über ein `SemaphoreSlim`-Gate serialisiert
+([`AgentEngine`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs)) — der Scanner fügt
+sich dort ein, statt einen zweiten, konkurrierenden Pfad in den geteilten
+Konversationskontext aufzumachen. Der Claim ist eine **DB**-Grenze (welcher Termin wird
+behandelt), das Agent-Gate eine **Kontext**-Grenze (kein verschränkter Nachrichtenstrom).
+Beide werden gebraucht; keine ersetzt die andere.
+
+### Startup-Reconciliation
+
+Beim Start wird Soll (Frontmatter) gegen Ist (DB-Marker/Claims) abgeglichen, statt
+verpasste Läufe still zu überspringen:
+
+- Alle Task-Dateien importieren (UPSERT), gelöschte Dateien in der DB als `archived`
+ markieren.
+- **Stale Claims freigeben:** Ein Claim, dessen `claimed_at` älter ist als die
+ Lease-Dauer (ein während des Laufs abgestürzter Prozess), wird verworfen — die
+ Bedingung `claimed_at < @leaseCutoff` im Claim-`UPDATE` erledigt das ohnehin, die
+ Reconciliation stellt den Status zusätzlich von `in_progress` auf `todo` zurück.
+- **Verpasste Termine erkennen:** Liegt der letzte planmäßige Termin nach dem
+ Last-Fired-Marker, ist ein Lauf ausgefallen. Er wird als solcher gemeldet (Log, später
+ A5), nicht heimlich verschluckt. Ob nachgeholt wird, ist Politik — Standard: einmal
+ nachholen, sonst würde ein über Nacht ausgeschalteter Rechner beim Start eine Welle
+ auslösen.
+
+## Die drei Invarianten
+
+Der Scanner-Kern ist in der Roadmap als der heikle Teil markiert (die Fehlerklasse, die
+hier schon einmal schiefging). Er wird strikt gegen diese Invarianten gebaut und mit
+Property-Tests (FsCheck, siehe Teststrategie) abgesichert:
+
+1. **Nie zwei Claims auf einen Task-Termin.** Garantiert durch das bedingte `UPDATE`:
+ Rowcount ≤ 1 pro Occurrence-Key. Test: N parallele Claims auf denselben Key → genau
+ einer gewinnt.
+2. **Kein Dispatch bei offenem Blocker.** Eine Aufgabe mit unerfüllten `blocked_by`
+ ist nicht `todo`, sondern `blocked`, und die Claim-Bedingung (`status IN
+ ('todo','backlog')`) greift nicht. Test: Blocker offen → kein Lauf; letzter Blocker
+ `done` → genau ein Auto-Dispatch.
+3. **Doppelter Tick = ein Lauf.** Zwei Takte im selben Fenster konkurrieren um denselben
+ Occurrence-Key; der Claim lässt nur einen durch. Test: zwei gleichzeitige
+ `ScanOnce` → ein Lauf, ein Marker.
+
+Diese drei sind keine Kür, sondern die Bedingung dafür, dass der Kern mit einem
+schwächeren Modell umgesetzt werden darf.
+
+## Abhängigkeiten und Eskalation
+
+- **`blocked_by` mit Auto-Dispatch:** Wird eine Aufgabe `done`, sucht das Board alle
+ Aufgaben, deren `blocked_by` sie enthält. Sind für eine davon **alle** Blocker `done`,
+ wechselt sie `blocked → todo` und der Scanner nimmt sie beim nächsten Takt auf.
+- **Blocker-Eskalation:** Meldet ein Agent einen Blocker (`task_update status=blocked`
+ mit Begründung), fällt die Aufgabe und der Zuständige (Lead/Benutzer) wird
+ benachrichtigt. Bis A5 (Matrix) steht, geht das über den vorhandenen Log-/UI-Weg.
+
+## Zusammenspiel mit Staging (A2) und Reopen
+
+- **`require_approval` / `in_review`:** Eine Aufgabe mit `require_approval: true` gilt
+ nach dem Lauf nicht als `done`, sondern als `in_review`. Das ist der natürliche
+ Andockpunkt für A2: Die Freigabe erzeugt einen Folge-Task, der den Agenten mit dem
+ eingefrorenen Aufruf weckt (so bleibt der Lauf regulär beendet, kein pausierter
+ In-Memory-Zustand — genau die Architektur-Vorgabe aus der Roadmap-Einstufung für A2).
+- **Reopen/Feedback:** Ergebnis + Kritik gehen per `task_comment` an **denselben**
+ Agenten zurück (`ChatAsync` in dessen Kontext), statt eine neue Aufgabe von vorn zu
+ beginnen. Die Aufgabe kehrt nach `todo`/`in_progress` zurück, der Verlauf am Task
+ bleibt erhalten.
+
+## Agenten-Tool
+
+Ein Tool `Taskboard` im Muster von `MemoryTool` (eine Aktion je Aufruf), das über einen
+neuen `ITaskRepository` auf dem `AgentToolContext` arbeitet (analog `IMemoryRepository?
+Memory`):
+
+| Aktion | Zweck |
+|---|---|
+| `task_create` | Aufgabe anlegen — schreibt Datei **und** DB-Zeile (über den Importer). Vergibt die `id`. |
+| `task_list` | Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. |
+| `task_update` | Status/Felder ändern; `blocked` melden; Ergebnis eintragen. |
+| `task_comment` | Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. |
+
+Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem `send_message` legt
+ein Agent eine Aufgabe mit `assignee: @` an. Das ist strukturell zyklenfrei (B8) —
+eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack.
+
+**Sicherheit:** Ein Task-Rumpf ist Prompt-Input für den Assignee. Fremdbestimmte Inhalte
+(Ergebnisse anderer Tools, die in einen Task fließen) werden als Daten gerahmt, nicht als
+Anweisung — dieselbe Linie wie K2. Irreversibles, das ein Task auslöst, läuft über A2.
+
+## Migration
+
+Die `coordination/*.md`-Dateien gehen im Taskboard auf. Die Altdateien haben kein Schema
+(freies Markdown wie `# Task: …`, `## Status: ASSIGNED`), deshalb bewusst konservativ
+(`CoordinationMigration`, beim Start ausgeführt):
+
+- **`task_*`-Dateien** → einmalig als `backlog`-Aufgaben übernommen: Titel aus der
+ `# Task:`-Überschrift (sonst erste Überschrift, sonst Dateiname), das ganze Markdown
+ als Rumpf, `assignee: @human` als sicherer Default, bis ein Mensch sie zuordnet.
+ `backlog` (nicht `todo`), damit der Scanner nichts unbesehen ausführt.
+- **`status_*`, `broadcast`, Incident-Berichte, `*.json`-Artefakte** → keine Aufgaben;
+ bleiben unangetastet (später nach A5/Matrix bzw. verfallen als Altbestand).
+
+Idempotent durch **Verschieben statt Löschen**: eine übernommene `task_*`-Datei wandert
+nach `coordination/migrated/` — die Historie bleibt, ein zweiter Start findet sie nicht
+mehr. Vor der Migration greift die übliche Regel: frisches Backup.
+
+## Ein Takt für alles — Ablösung der Alt-Scheduler
+
+Der Scanner ist der **einzige** periodische Treiber. Die früheren `AgentScheduler` und
+`ToolJobScheduler` (zwei `Task.Delay`-Schleifen mit B6/B7, ungetestet) sind **gelöscht** —
+alles Periodische ist jetzt ein Task:
+
+- **Geplanter Agent-Lauf** (früher `scheduler`-Config) → ein normaler Task mit
+ `when: cron` und Assignee `@new:`.
+- **Tool-Job-Poll** (früher `toolJobs`-Config, z. B. `telegram_poll`) → ein Task vom Typ
+ **`tool_job`** mit `tool_name`/`job_type`. Beim fälligen Termin tickt der Dispatcher den
+ `IToolJobProvider` und weckt den Zielagenten (Assignee) nur, wenn der Tick etwas meldet —
+ mit oder ohne Kontext, je nach `ToolJobResult`.
+
+Zwei Feinheiten, die dabei geradegezogen wurden:
+
+- **Wiederkehrende Tasks** (`cron`/`every`/`tool_job`) kehren nach dem Feuern auf `todo`
+ zurück statt auf `done` — sonst liefe ein Cron-Task nur ein einziges Mal. Der
+ Last-Fired-Marker verhindert weiterhin, dass **derselbe** Termin doppelt feuert.
+- **`backlog` ist ein Halte-Status**: Der Scanner claimt nur `todo`. Eine Aufgabe in
+ `backlog` (frisch importiert, migriert, oder ein deaktivierter Poll) ruht, bis ein
+ Mensch sie auf `todo` setzt.
+
+Die Alt-Konfiguration (`scheduler`, `toolJobs`) wird beim Start einmalig und
+nicht-destruktiv in Tasks migriert (`SchedulerTaskMigration`, stabile Ids
+`sched-` / `tj--`). Ein manuelles „Jetzt ausführen" im Host läuft über
+`TaskScanner.RunTaskNowAsync`.
+
+## Verzahnung
+
+- **C1 Marktkalender:** `onlyWhenMarketOpen` gehört ins Frontmatter, nicht in einen
+ eigenen Mechanismus — der Scanner überspringt einen Termin, wenn der Markt zu ist.
+- **A3 Audit/Receipts:** Jeder Lauf wird am Task verknüpft; der Abschluss-Beleg (Schritte,
+ Tokens, Kosten) fällt daraus ab und macht C7 weitgehend zum Abfallprodukt.
+- **F-A5/F-A4:** Das Board **ist** die Queue; die verknüpften Läufe **sind** die Historie.
+
+## Umsetzungsreihenfolge
+
+Bewusst so geschnitten, dass der heikle Kern zuletzt und gegen grüne Invarianten kommt:
+
+1. **Modelle + `ITaskRepository` + Schema + Frontmatter-Parser.** Reine, testbare
+ Bausteine. Der Parser wird eng gebaut (die Frontmatter ist ein kleiner, flacher
+ Satz aus Skalaren und kurzen Listen) — keine YAML-Bibliothek, passend zum
+ dependency-armen Stil des Projekts. 4.6-tauglich.
+2. **`Taskboard`-Tool + Importer.** Datei ↔ DB, `task_*`-Aktionen. 4.6-tauglich.
+3. **Scanner-Kern** (Claim, Dispatch, Reconciliation, Auto-Dispatch) — strikt gegen die
+ drei Invarianten, mit Property-Tests. Der in der Roadmap für Opus 5/Fable markierte
+ Teil; mit Opus 4.8 nur streng nach diesem Dokument und mit den Invarianten-Tests als
+ Netz.
+4. **Migration** der `coordination/*.md`.
+
+Neue Subsysteme kommen mit Tests nach der [Teststrategie](Teststrategie.md); die
+Invarianten-Tests sind bei Punkt 3 die Absicherung, kein Nice-to-have.
+
+## Offen
+
+- **Nachhol-Politik verpasster Termine** — umgesetzt als „höchstens einmal nachholen":
+ der Scanner nimmt den jüngsten verpassten Termin, nicht jeden einzelnen. Eine frische
+ Aufgabe holt zudem keinen Termin von **vor** ihrer Anlage nach. Ob das je Task
+ abschaltbar sein soll (`catchUp: true|false`), ist offen.
+- **Marktkalender (C1)** — der Scanner fragt eine `IMarketCalendar` (derzeit Platzhalter
+ „immer offen"). C1 liefert später den echten Kalender; die Verzahnung steht.
+- **DST-Randfall** — eine bei der Zeitumstellung nicht existierende Ortszeit
+ (Frühjahr, „02:30") wird derzeit übersprungen statt verschoben. Für die geplanten
+ Termine unkritisch; die Härtung gehört zur Scheduler-Nacharbeit (Teststrategie R4/R5).
+- **Priorität als Reihenfolge** — bei mehreren fälligen Aufgaben desselben Agenten
+ bestimmt `priority` die Reihenfolge; ob strikt oder gewichtet, ist noch offen.
+- **Aufräumen** — `done`/`canceled`-Aufgaben nach einer Frist archivieren, damit
+ `tasks/` nicht zuwächst (dieselbe Überlegung wie „Verfall" beim Gedächtnis).
diff --git a/frm_main.cs b/frm_main.cs
index 20d5980..ce210d8 100644
--- a/frm_main.cs
+++ b/frm_main.cs
@@ -4,6 +4,7 @@ using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Scheduling;
+using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Models;
using ClawdDotNet.Services;
@@ -25,8 +26,24 @@ public partial class frm_main : Form
private readonly ToolRegistry _toolRegistry;
private readonly InstanceDirectoryManager _dirManager;
private readonly AgentEngine? _agentEngine;
- private readonly AgentScheduler? _agentScheduler;
- private readonly ToolJobScheduler? _toolJobScheduler;
+
+ ///
+ /// Der einzige periodische Treiber (A1). Ersetzt die früheren AgentScheduler/
+ /// ToolJobScheduler — geplante Läufe und Tool-Job-Polls sind jetzt Tasks.
+ ///
+ private readonly ClawdDotNet.Core.Tasks.TaskScanner? _taskScanner;
+
+ ///
+ /// A2: Freigabe-/Ablehnungs-Dienst. Die Review-Oberfläche (Liste offener Vorschläge,
+ /// Freigeben/Ablehnen) ruft auf —
+ /// die Anbindung ist die verbleibende Integration; die Dienst-API steht bereit.
+ ///
+ private readonly ClawdDotNet.Core.Staging.StagingService? _stagingService;
+
+ // Der Lizenz-Teil ist mit der Deploymentcenter-Umstellung aus dieser Fassung
+ // verschwunden: Startprüfung, laufende Nachprüfung und Notausschalter sitzen jetzt
+ // in ClawdDotNet.App (LicenseGate, LicenseWatch) und in der Avalonia-Oberfläche.
+ // Diese Datei ist nur noch Vorlage für die Portierung — siehe ClawdDotNet.slnx.
// ─── Services ───
private LiveLogViewerService? _logViewer;
@@ -51,6 +68,9 @@ public partial class frm_main : Form
private readonly Dictionary _jobLastRunTimes = new();
private bool _suppressAgentSelectionChanged;
+ /// Setzt die Beenden-Rückfrage außer Kraft (Lizenz-Sperre/-Deaktivierung).
+ private bool _forceClose;
+
public frm_main(
SettingsManager settingsManager,
InstanceConfig instanceConfig,
@@ -60,8 +80,8 @@ public partial class frm_main : Form
ToolRegistry toolRegistry,
InstanceDirectoryManager dirManager,
AgentEngine? agentEngine,
- AgentScheduler? agentScheduler,
- ToolJobScheduler? toolJobScheduler = null)
+ ClawdDotNet.Core.Tasks.TaskScanner? taskScanner = null,
+ ClawdDotNet.Core.Staging.StagingService? stagingService = null)
{
_settingsManager = settingsManager;
_instanceConfig = instanceConfig;
@@ -72,8 +92,8 @@ public partial class frm_main : Form
_toolRegistry = toolRegistry;
_dirManager = dirManager;
_agentEngine = agentEngine;
- _agentScheduler = agentScheduler;
- _toolJobScheduler = toolJobScheduler;
+ _taskScanner = taskScanner;
+ _stagingService = stagingService;
_instanceSettingsVm = new InstanceSettingsViewModel(_instanceConfig);
InitializeComponent();
@@ -580,8 +600,6 @@ public partial class frm_main : Form
foreach (var agent in _instanceConfig.Agents)
{
- var lastResult = _agentScheduler?.GetLastResult(agent.AgentId);
-
_agentListEntries.Add(new AgentListDisplayEntry
{
AgentId = agent.AgentId,
@@ -590,7 +608,7 @@ public partial class frm_main : Form
ToolCount = agent.Tools.Count,
HasIdentity = !string.IsNullOrWhiteSpace(agent.Identity) ? "Ja" : "—",
HasSoul = !string.IsNullOrWhiteSpace(agent.Soul) ? "Ja" : "—",
- Status = lastResult?.Status.ToString() ?? "Bereit"
+ Status = _agentEngine?.IsRunning(agent.AgentId) == true ? "Läuft" : "Bereit"
});
}
}
@@ -752,7 +770,7 @@ public partial class frm_main : Form
private async void OnRunAgentNowClick(object? sender, EventArgs e)
{
- if (_agentScheduler is null || _agentEngine is null)
+ if (_agentEngine is null)
{
MessageBox.Show("Kein API-Key konfiguriert – Agenten können nicht ausgeführt werden.",
"Hinweis", MessageBoxButtons.OK, MessageBoxIcon.Warning);
@@ -778,8 +796,8 @@ public partial class frm_main : Form
try
{
using var cts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
- var result = await _agentScheduler.RunNowAsync(
- agentConfig, "Manual execution triggered by user.", cts.Token);
+ var result = await _agentEngine.RunAsync(
+ agentConfig, "Manual execution triggered by user.", _instanceConfig.InstanceId, cts.Token);
RefreshAgentList();
@@ -925,52 +943,14 @@ public partial class frm_main : Form
dgv_jobhistory.DataSource = _jobHistoryEntries;
RefreshJobHistoryGrid();
- // ─── ToolJobScheduler: OnJobTick → History + LastRun ───
- if (_toolJobScheduler is not null)
- {
- _toolJobScheduler.OnJobTick += OnToolJobTick;
- }
+ // Tool-Job-Ticks laufen jetzt über den Scanner (Tasks); ihre Historie ergibt sich
+ // aus dem Audit-Log/den Receipts (A3), nicht mehr aus einem Scheduler-Event.
EnsureBuiltInServices();
RefreshJobList();
RefreshServiceList();
}
- private void OnToolJobTick(string agentId, string jobId, ToolJobResult result)
- {
- if (IsDisposed || !IsHandleCreated) return;
-
- BeginInvoke(() =>
- {
- // Track LastRun timestamp
- _jobLastRunTimes[jobId] = DateTime.Now;
-
- // Add to history
- var agent = _instanceConfig.Agents.FirstOrDefault(a => a.AgentId == agentId);
- var jobConfig = agent?.ToolJobs.FirstOrDefault(j => j.JobId == jobId);
-
- var historyEntry = new JobHistoryEntry
- {
- JobName = jobConfig?.JobTypeId ?? jobId,
- Agent = agent?.DisplayName ?? agentId,
- Time = DateTime.Now,
- JobDescription = $"Tool Job: {jobConfig?.ToolName ?? "?"}",
- Info = result.LogSummary ?? (result.ShouldWakeAgent ? "Wake Agent" : "No Action"),
- Status = result.ShouldWakeAgent ? "Success" : (result.LogSummary?.Contains("Fehler") == true ? "Error" : "Success")
- };
-
- _jobHistoryService?.Add(historyEntry);
- _jobHistoryEntries.Insert(0, historyEntry);
-
- // Trim UI list
- while (_jobHistoryEntries.Count > 200)
- _jobHistoryEntries.RemoveAt(_jobHistoryEntries.Count - 1);
-
- // Refresh job list to update LastRun column
- RefreshJobList();
- });
- }
-
private void RefreshJobHistoryGrid()
{
_jobHistoryEntries.Clear();
@@ -1123,9 +1103,9 @@ public partial class frm_main : Form
if (entry.JobType == "Tool Job")
{
- if (_toolJobScheduler is null)
+ if (_taskScanner is null)
{
- MessageBox.Show("Tool-Job-Scheduler ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ MessageBox.Show("Scanner ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
@@ -1136,11 +1116,13 @@ public partial class frm_main : Form
btn_runJob.Text = "⏳ Läuft...";
try
{
- var result = await _toolJobScheduler.TriggerJobAsync(agent, jobConfig, CancellationToken.None);
+ // Poll-Jobs sind jetzt Tasks (Id: tj--); der Scanner führt sie aus.
+ var ran = await _taskScanner.RunTaskNowAsync(
+ $"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
_jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
RefreshJobList();
- var status = result.ShouldWakeAgent ? "Wake → Agent gestartet" : (result.LogSummary ?? "Keine Aktion");
+ var status = ran ? "Ausgeführt" : "Nicht bereit (läuft evtl. schon oder ist deaktiviert)";
_jobHistoryService?.Add(new JobHistoryEntry
{
@@ -1181,9 +1163,9 @@ public partial class frm_main : Form
else
{
// Agent Wakeup Job
- if (_agentScheduler is null || _agentEngine is null)
+ if (_agentEngine is null)
{
- MessageBox.Show("Agent-Scheduler ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ MessageBox.Show("Agent-Engine ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
@@ -1193,7 +1175,7 @@ public partial class frm_main : Form
btn_runJob.Text = "⏳ Läuft...";
try
{
- var result = await _agentScheduler.RunNowAsync(agent, taskMessage, CancellationToken.None);
+ var result = await _agentEngine.RunAsync(agent, taskMessage, _instanceConfig.InstanceId, CancellationToken.None);
_jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
RefreshJobList();
@@ -1244,8 +1226,6 @@ public partial class frm_main : Form
// Agent Wakeup Jobs
if (agent.Scheduler is not null)
{
- var lastResult = _agentScheduler?.GetLastResult(agent.AgentId);
-
string nextRun = "—";
if (!string.IsNullOrWhiteSpace(agent.Scheduler.Cron))
{
@@ -1263,7 +1243,7 @@ public partial class frm_main : Form
var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
- : (lastResult is not null ? "Letzter Lauf bekannt" : "—");
+ : "—";
_jobEntries.Add(new JobDisplayEntry
{
@@ -1275,8 +1255,8 @@ public partial class frm_main : Form
RunOnStart = agent.Scheduler.RunOnStart,
NextRun = nextRun,
LastRun = agentLastRun,
- LastStatus = lastResult?.Status.ToString() ?? "—",
- Status = _agentScheduler is not null ? "Aktiv" : "Inaktiv"
+ LastStatus = "—",
+ Status = _taskScanner is not null ? "Aktiv" : "Inaktiv"
});
}
@@ -1298,8 +1278,6 @@ public partial class frm_main : Form
}
}
- var lastToolResult = _toolJobScheduler?.GetLastResult(toolJob.JobId);
-
var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
@@ -1316,7 +1294,7 @@ public partial class frm_main : Form
RunOnStart = toolJob.RunOnStart,
NextRun = nextRun,
LastRun = toolLastRun,
- LastStatus = lastToolResult?.LogSummary ?? "—",
+ LastStatus = "—",
Status = toolJob.Enabled ? "Aktiv" : "Deaktiviert"
});
}
@@ -1516,7 +1494,8 @@ public partial class frm_main : Form
private void OnFormClosing(object? sender, FormClosingEventArgs e)
{
- if (e.CloseReason == CloseReason.UserClosing)
+ // Erzwungenes Schließen (Lizenz-Sperre/-Deaktivierung) überspringt die Rückfrage.
+ if (e.CloseReason == CloseReason.UserClosing && !_forceClose)
{
var result = MessageBox.Show(
"ClawdDotNet ist für den 24/7-Betrieb ausgelegt.\n\n" +
@@ -1568,17 +1547,20 @@ public partial class frm_main : Form
private void btn_openAgentFolder_Click(object sender, EventArgs e)
{
- Process.Start("explorer.exe", _instancePath + "\\Agents");
+ SystemShell.OpenFolder(Path.Combine(_instancePath, "Agents"));
}
private void btn_showInstanceFolder_Click(object sender, EventArgs e)
{
- Process.Start("explorer.exe", _instancePath);
+ SystemShell.OpenFolder(_instancePath);
}
private void btn_ShowLogFolder2_Click(object sender, EventArgs e)
{
- Process.Start("explorer.exe", Environment.CurrentDirectory + "/Logs");
+ // _logDirectory statt CurrentDirectory + "/Logs": Das Arbeitsverzeichnis ist
+ // nicht zwingend das Programmverzeichnis, und die Instanz kann ein eigenes
+ // Log-Ziel haben — geöffnet werden soll der Ordner, in den auch geschrieben wird.
+ SystemShell.OpenFolder(Path.GetFullPath(_logDirectory));
}
private void dgv_Jobs_CellContentClick(object sender, DataGridViewCellEventArgs e)
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
new file mode 100644
index 0000000..4dbd6fa
--- /dev/null
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -0,0 +1,485 @@
+using ClawdDotNet.App.Services;
+using ClawdDotNet.App.Settings;
+using ClawdDotNet.Core.Accounting;
+using ClawdDotNet.Core.Api;
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Engine;
+using ClawdDotNet.Core.Logging;
+using ClawdDotNet.Core.Memory;
+using ClawdDotNet.Core.Security;
+using ClawdDotNet.Core.Staging;
+using ClawdDotNet.Core.State;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+using ClawdDotNet.Core.Deploymentcenter;
+using ClawdDotNet.Core.Deploymentcenter.Watchdog;
+using ClawdDotNet.Core.Tools;
+using ClawdDotNet.Tools.AgentComm;
+using ClawdDotNet.Tools.AgentEditor;
+using ClawdDotNet.Tools.AgentSpawn;
+using ClawdDotNet.Tools.Database;
+using ClawdDotNet.Tools.DirectAPI;
+using ClawdDotNet.Tools.FileRW;
+using ClawdDotNet.Tools.FTP;
+using ClawdDotNet.Tools.Mail;
+using ClawdDotNet.Tools.SocialMediaManager;
+using ClawdDotNet.Tools.Telegram;
+using ClawdDotNet.Tools.TelegramClient;
+using ClawdDotNet.Tools.WebFetch;
+using ClawdDotNet.Tools.WebMonitor;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.App;
+
+///
+/// Baut alles auf, was ClawdDotNet zum Laufen braucht — ohne eine einzige Zeile
+/// Oberflächencode.
+///
+/// Vorher lag das in Program.cs der WinForms-Anwendung: 350 Zeilen zwischen
+/// ApplicationConfiguration.Initialize() und Application.Run(form). Die
+/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
+/// überhaupt entstand. Sie war nur nirgends festgehalten.
+///
+/// Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
+/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
+/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
+/// festgeschrieben zu sein.
+///
+public sealed class AppHost : IAsyncDisposable
+{
+ private readonly List> _shutdown = [];
+
+ public required SettingsManager Settings { get; init; }
+ public required InstanceDirectoryManager Directories { get; init; }
+ public required InstanceConfig Instance { get; init; }
+ public required string InstancePath { get; init; }
+ public required string LogDirectory { get; init; }
+ public required ILoggerFactory LoggerFactory { get; init; }
+ public required ToolRegistry Tools { get; init; }
+
+ /// Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.
+ public AgentEngine? Engine { get; private init; }
+
+ public TaskScanner? Scanner { get; private init; }
+ public StagingService? Staging { get; private init; }
+ public SqliteUsageRepository? Usage { get; private init; }
+ public TelegramClientManager? TelegramClient { get; private init; }
+ public OpenRouterStatusService? Status { get; private init; }
+
+ ///
+ /// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
+ /// Null, wenn Adresse oder Token fehlen.
+ ///
+ public DeploymentcenterService? Deploymentcenter { get; private set; }
+
+ ///
+ /// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
+ /// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
+ ///
+ public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
+
+ ///
+ /// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
+ /// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
+ ///
+ public LicenseGate? License { get; private set; }
+
+ ///
+ /// Die laufende Nachprüfung. Der Aufrufer hängt sich an
+ /// und beendet die Anwendung, wenn es feuert.
+ ///
+ public LicenseWatch? LicenseWatch { get; private set; }
+
+ /// Was der Aufrufer beim Start erfragen muss.
+ public sealed class Callbacks
+ {
+ ///
+ /// Wählt die Instanz. Gibt null zurück, wenn der Nutzer abbricht.
+ /// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
+ ///
+ public required Func> SelectInstance { get; init; }
+
+ /// Wie der Lizenz-Torwächter mit dem Benutzer spricht.
+ public required ILicensePrompt License { get; init; }
+
+ ///
+ /// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
+ /// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
+ /// Dienst dauerhaft blockieren würde.
+ ///
+ public Func>? TelegramLogin { get; init; }
+
+ public Func>? Telegram2FA { get; init; }
+ }
+
+ ///
+ /// Ergebnis des Aufbaus. ist null, wenn der Nutzer abgebrochen hat
+ /// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
+ /// nachzureichen: Die hat der Torwächter schon gezeigt.
+ ///
+ public readonly record struct StartupResult(AppHost? Host, string? Error);
+
+ public static async Task StartAsync(Callbacks callbacks, CancellationToken ct = default)
+ {
+ // ─── 1. Anwendungseinstellungen ───
+ var settings = new SettingsManager();
+ settings.Load();
+
+ // ─── 2. Instanz wählen ───
+ var directories = new InstanceDirectoryManager(
+ Path.GetFullPath(settings.AppSettings.InstancesDirectory));
+
+ var instancePath = await callbacks.SelectInstance(directories);
+ if (string.IsNullOrWhiteSpace(instancePath))
+ return new StartupResult(null, null); // Abbruch, keine Meldung nötig
+
+ InstanceConfig instance;
+ try
+ {
+ instance = directories.LoadInstanceConfig(instancePath);
+ }
+ catch (Exception ex)
+ {
+ return new StartupResult(null,
+ $"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
+ }
+
+ // ─── 3. Protokollierung ───
+ var logDirectory = Path.GetFullPath(
+ !string.IsNullOrWhiteSpace(instance.LogDirectory)
+ ? instance.LogDirectory
+ : settings.AppSettings.LogDirectory);
+
+ var minLevel = Enum.TryParse(
+ settings.AppSettings.MinimumLogLevel, true, out var parsed)
+ ? parsed
+ : Core.Logging.LogLevel.Info;
+
+ var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
+ var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
+
+ logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
+ instance.InstanceName, instance.InstanceId);
+ logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
+ logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
+
+ // ─── 4. Lizenz ───
+ var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
+ callbacks.License);
+
+ if (!await license.RunStartupCheckAsync(ct))
+ {
+ logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
+ loggerFactory.Dispose();
+ return new StartupResult(null, null);
+ }
+
+ // ─── 5. Werkzeuge ───
+ var tools = RegisterTools();
+
+ TelegramClientManager? telegram = null;
+ if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
+ {
+ telegram = new TelegramClientManager(instance, instancePath,
+ loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
+
+ tools.Register(new TelegramClientTool(telegram));
+ logger.LogInformation("TelegramClient-Tool registriert");
+ }
+ else if (instance.TelegramClient is not null)
+ {
+ logger.LogInformation(
+ "TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
+ }
+
+ var host = BuildCore(settings, directories, instance, instancePath,
+ logDirectory, loggerFactory, tools, telegram, logger);
+
+ // Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
+ host.License = license;
+
+ if (license.IsEnforcementConfigured)
+ {
+ host.LicenseWatch = new LicenseWatch(
+ license, loggerFactory.CreateLogger("ClawdDotNet.License"));
+
+ host.LicenseWatch.Start();
+ host._shutdown.Add(host.LicenseWatch.DisposeAsync);
+ }
+
+ await host.ConnectTelegramAsync(callbacks, logger);
+ await host.StartDeploymentcenterAsync(loggerFactory, ct);
+
+ return new StartupResult(host, null);
+ }
+
+ // ─── Aufbau ───
+
+ private static ToolRegistry RegisterTools()
+ {
+ var registry = new ToolRegistry();
+
+ registry.Register(new FileRWTool());
+ registry.Register(new TelegramTool());
+ registry.Register(new MailTool());
+ registry.Register(new DatabaseTool());
+ registry.Register(new FTPTool());
+ registry.Register(new DirectApiTool());
+ registry.Register(new WebFetchTool());
+ registry.Register(new WebMonitorTool());
+ registry.Register(new AgentCommTool());
+ registry.Register(new SocialMediaManagerTool());
+ registry.Register(new AgentSpawnTool());
+ registry.Register(new AgentEditorTool());
+ registry.Register(new Tools.Memory.MemoryTool());
+ registry.Register(new Tools.Taskboard.TaskboardTool());
+
+ return registry;
+ }
+
+ private static AppHost BuildCore(
+ SettingsManager settings,
+ InstanceDirectoryManager directories,
+ InstanceConfig instance,
+ string instancePath,
+ string logDirectory,
+ ILoggerFactory loggerFactory,
+ ToolRegistry tools,
+ TelegramClientManager? telegram,
+ ILogger logger)
+ {
+ if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
+ {
+ logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
+
+ return new AppHost
+ {
+ Settings = settings,
+ Directories = directories,
+ Instance = instance,
+ InstancePath = instancePath,
+ LogDirectory = logDirectory,
+ LoggerFactory = loggerFactory,
+ Tools = tools,
+ TelegramClient = telegram
+ };
+ }
+
+ var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
+ loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
+
+ // Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
+ var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
+ var stateStore = new SqliteStateStore(storage);
+ var memory = new SqliteMemoryRepository(storage);
+ var taskRepository = new SqliteTaskRepository(storage);
+ var audit = new SqliteAuditRepository(storage);
+ var stagingRepository = new SqliteStagingRepository(storage);
+ var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository);
+ var usage = new SqliteUsageRepository(storage);
+
+ // Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
+ var pricing = new ModelPricingCatalog();
+ _ = Task.Run(async () =>
+ {
+ try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
+ catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
+ });
+
+ var engine = new AgentEngine(
+ openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
+ memory, usage, pricing, taskRepository, audit, stagingGate)
+ {
+ InstanceBudget = instance.Budget
+ };
+
+ engine.SetAgentConfigProvider(
+ () => instance.Agents,
+ instance.InstanceId,
+ agentId =>
+ {
+ var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
+ return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
+ });
+
+ engine.LoadPersistedChats();
+
+ var (scanner, staging) = BuildTaskboard(
+ instance, taskRepository, engine, tools, stateStore, audit,
+ stagingRepository, loggerFactory, logger);
+
+ var host = new AppHost
+ {
+ Settings = settings,
+ Directories = directories,
+ Instance = instance,
+ InstancePath = instancePath,
+ LogDirectory = logDirectory,
+ LoggerFactory = loggerFactory,
+ Tools = tools,
+ Engine = engine,
+ Scanner = scanner,
+ Staging = staging,
+ Usage = usage,
+ TelegramClient = telegram,
+ Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
+ };
+
+ host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
+
+ logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
+
+ return host;
+ }
+
+ ///
+ /// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
+ /// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
+ ///
+ private static (TaskScanner?, StagingService?) BuildTaskboard(
+ InstanceConfig instance,
+ SqliteTaskRepository taskRepository,
+ AgentEngine engine,
+ ToolRegistry tools,
+ SqliteStateStore stateStore,
+ SqliteAuditRepository audit,
+ SqliteStagingRepository stagingRepository,
+ ILoggerFactory loggerFactory,
+ ILogger logger)
+ {
+ var sharedWorkspace = instance.Agents
+ .Select(a => a.SharedWorkspacePath)
+ .FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
+
+ if (string.IsNullOrWhiteSpace(sharedWorkspace))
+ return (null, null);
+
+ var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
+
+ var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit);
+
+ var dispatcher = new EngineTaskDispatcher(
+ engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
+
+ var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory);
+
+ // Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var reset = await taskRepository.ReleaseStaleClaimsAsync(
+ DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
+
+ var coordination = new CoordinationMigration(
+ board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
+ var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
+
+ var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
+ var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
+
+ var imported = await board.ImportAllAsync(CancellationToken.None);
+
+ logger.LogInformation(
+ "Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
+ + "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
+ imported, migratedCoordination, migratedScheduler, reset);
+
+ scanner.Start();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
+ }
+ });
+
+ return (scanner, staging);
+ }
+
+ private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
+ {
+ if (TelegramClient is null || callbacks.TelegramLogin is null) return;
+
+ TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
+
+ if (callbacks.Telegram2FA is not null)
+ TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
+
+ try
+ {
+ await TelegramClient.ConnectAsync(CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Telegram: Login fehlgeschlagen");
+ }
+ }
+
+ ///
+ /// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
+ /// die einmalige Update-Prüfung.
+ ///
+ /// Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
+ /// sie über source + instance. Stürzt eine von mehreren ab, fällt
+ /// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.
+ ///
+ private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
+ {
+ Deploymentcenter = DeploymentcenterService.TryCreate(
+ Settings.AppSettings, AppVersion, loggerFactory);
+
+ if (Deploymentcenter is null)
+ return;
+
+ _shutdown.Add(Deploymentcenter.DisposeAsync);
+
+ var health = new InstanceHealthProvider(
+ Instance.InstanceName,
+ agentsEnabled: Engine is not null,
+ Instance.Budget,
+ Usage,
+ () => Instance.Agents.Count,
+ () => Engine?.RunningChatCount ?? 0,
+ // „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
+ // Startabgleichung los, der erste Heartbeat geht sofort raus.
+ schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
+
+ await Deploymentcenter.StartWatchdogAsync(
+ Instance, health,
+ saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
+ ct);
+
+ // Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
+ _ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
+ }
+
+ ///
+ /// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
+ /// Fehlermeldungen, Versionsvergleich. Kommt aus <Version> in
+ /// Directory.Build.props und wird zur Übersetzungszeit eingebettet
+ /// (Deploymentcenter.BuildInfo.targets) — zusammen mit Commit und Build-Datum.
+ ///
+ /// Nicht zu verwechseln mit ClawdDotNet.Core.BuildInfo.Build: das ist
+ /// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.
+ ///
+ public static string AppVersion => ReleaseInfo.Version;
+
+ /// Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.
+ public static string BuildSummary => ReleaseInfo.Summary;
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var step in _shutdown)
+ {
+ try { await step(); }
+ catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
+ }
+
+ if (Status is not null) await Status.DisposeAsync();
+ if (Scanner is not null) await Scanner.DisposeAsync();
+ if (TelegramClient is not null) await TelegramClient.DisposeAsync();
+
+ LoggerFactory.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.App/ClawdDotNet.App.csproj b/src/ClawdDotNet.App/ClawdDotNet.App.csproj
new file mode 100644
index 0000000..1c2478a
--- /dev/null
+++ b/src/ClawdDotNet.App/ClawdDotNet.App.csproj
@@ -0,0 +1,49 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.App
+
+
+ ReleaseInfo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Models/AgentListEntry.cs b/src/ClawdDotNet.App/Models/AgentListEntry.cs
similarity index 95%
rename from Models/AgentListEntry.cs
rename to src/ClawdDotNet.App/Models/AgentListEntry.cs
index a179640..b510ce0 100644
--- a/Models/AgentListEntry.cs
+++ b/src/ClawdDotNet.App/Models/AgentListEntry.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace ClawdDotNet.Models;
+namespace ClawdDotNet.App.Models;
///
/// Eintrag in der AgentList.json – Basisinformationen zu einem Agenten.
diff --git a/Models/InstanceInfo.cs b/src/ClawdDotNet.App/Models/InstanceInfo.cs
similarity index 91%
rename from Models/InstanceInfo.cs
rename to src/ClawdDotNet.App/Models/InstanceInfo.cs
index 2290c15..c633427 100644
--- a/Models/InstanceInfo.cs
+++ b/src/ClawdDotNet.App/Models/InstanceInfo.cs
@@ -1,4 +1,4 @@
-namespace ClawdDotNet.Models;
+namespace ClawdDotNet.App.Models;
///
/// Zusammenfassung einer Instanz für die Anzeige im InstanceManager.
diff --git a/Models/JobHistoryEntry.cs b/src/ClawdDotNet.App/Models/JobHistoryEntry.cs
similarity index 94%
rename from Models/JobHistoryEntry.cs
rename to src/ClawdDotNet.App/Models/JobHistoryEntry.cs
index 7678a23..4b026e1 100644
--- a/Models/JobHistoryEntry.cs
+++ b/src/ClawdDotNet.App/Models/JobHistoryEntry.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace ClawdDotNet.Models;
+namespace ClawdDotNet.App.Models;
public sealed class JobHistoryEntry
{
diff --git a/Models/TokenUsageRecord.cs b/src/ClawdDotNet.App/Models/TokenUsageRecord.cs
similarity index 97%
rename from Models/TokenUsageRecord.cs
rename to src/ClawdDotNet.App/Models/TokenUsageRecord.cs
index 9cfabdc..ace0d01 100644
--- a/Models/TokenUsageRecord.cs
+++ b/src/ClawdDotNet.App/Models/TokenUsageRecord.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace ClawdDotNet.Models;
+namespace ClawdDotNet.App.Models;
public sealed class TokenUsageRecord
{
diff --git a/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs b/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs
new file mode 100644
index 0000000..b4c9a60
--- /dev/null
+++ b/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs
@@ -0,0 +1,566 @@
+using System.ComponentModel;
+using System.Text.Json;
+
+namespace ClawdDotNet.Models;
+
+static class ConfigHelper
+{
+ public static string GetString(Dictionary config, string key, string fallback = "")
+ {
+ var val = config.GetValueOrDefault(key);
+ return val switch
+ {
+ JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? fallback,
+ JsonElement je => je.ToString(),
+ string s => s,
+ null => fallback,
+ _ => val.ToString() ?? fallback
+ };
+ }
+
+ public static int GetInt(Dictionary config, string key, int fallback = 0)
+ {
+ var val = config.GetValueOrDefault(key);
+ return val switch
+ {
+ JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
+ JsonElement je => int.TryParse(je.ToString(), out var r) ? r : fallback,
+ int i => i,
+ _ => int.TryParse(val?.ToString(), out var r) ? r : fallback
+ };
+ }
+
+ public static bool GetBool(Dictionary config, string key, bool fallback = false)
+ {
+ var val = config.GetValueOrDefault(key);
+ return val switch
+ {
+ JsonElement je when je.ValueKind is JsonValueKind.True => true,
+ JsonElement je when je.ValueKind is JsonValueKind.False => false,
+ JsonElement je => bool.TryParse(je.ToString(), out var r) ? r : fallback,
+ bool b => b,
+ _ => bool.TryParse(val?.ToString(), out var r) ? r : fallback
+ };
+ }
+
+ public static string GetStringArray(Dictionary config, string key)
+ {
+ var val = config.GetValueOrDefault(key);
+ return val switch
+ {
+ JsonElement je when je.ValueKind == JsonValueKind.Array =>
+ string.Join(",", je.EnumerateArray().Select(e => e.GetString())),
+ object[] arr => string.Join(",", arr),
+ string s => s,
+ _ => ""
+ };
+ }
+}
+
+public enum FileRWAccessLevel
+{
+ Denied,
+ Read,
+ ReadWrite,
+ Admin
+}
+
+public sealed class FileRWToolSettings
+{
+ [Category("Persönlicher Workspace")]
+ [DisplayName("Erlaubte Endungen")]
+ [Description("Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)")]
+ public string PersonalAllowedExtensions { get; set; } = ".txt,.json,.md,.html,.js,.css";
+
+ [Category("Shared Workspace")]
+ [DisplayName("Zugriffslevel")]
+ [Description("Legt fest, welche Operationen im SharedWorkspace erlaubt sind")]
+ public FileRWAccessLevel SharedAccessLevel { get; set; } = FileRWAccessLevel.Denied;
+
+ [Category("Shared Workspace")]
+ [DisplayName("Erlaubte Endungen")]
+ [Description("Dateiendungen für den geteilten Workspace")]
+ public string SharedAllowedExtensions { get; set; } = ".txt,.json,.md";
+
+ [Category("Shared Workspace – Schutz")]
+ [DisplayName("Geschützte Pfade")]
+ [Description("Komma-getrennte Pfade im SharedWorkspace die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.")]
+ public string ProtectedPaths { get; set; } = "stocks/";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["personalAllowedExtensions"] = PersonalAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ ["sharedAccessLevel"] = SharedAccessLevel.ToString(),
+ ["sharedAllowedExtensions"] = SharedAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ ["protectedPaths"] = ProtectedPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ };
+
+ public static FileRWToolSettings FromConfig(Dictionary config) => new()
+ {
+ PersonalAllowedExtensions = ConfigHelper.GetStringArray(config, "personalAllowedExtensions") is { Length: > 0 } s1
+ ? s1 : (ConfigHelper.GetStringArray(config, "allowedExtensions") is { Length: > 0 } sOld ? sOld : ".txt,.json,.md,.html,.js,.css"),
+
+ SharedAccessLevel = Enum.TryParse(ConfigHelper.GetString(config, "sharedAccessLevel"), true, out var level)
+ ? level : FileRWAccessLevel.Denied,
+
+ SharedAllowedExtensions = ConfigHelper.GetStringArray(config, "sharedAllowedExtensions") is { Length: > 0 } s2
+ ? s2 : ".txt,.json,.md",
+
+ ProtectedPaths = ConfigHelper.GetStringArray(config, "protectedPaths") is { Length: > 0 } s3
+ ? s3 : "stocks/"
+ };
+}
+
+public sealed class MailToolSettings
+{
+ [Category("Mail - Konto")]
+ [DisplayName("Benutzername")]
+ public string Username { get; set; } = "";
+
+ [Category("Mail - Konto")]
+ [DisplayName("Passwort")]
+ [PasswordPropertyText(true)]
+ public string Password { get; set; } = "";
+
+ [Category("Mail - IMAP")]
+ [DisplayName("IMAP-Host")]
+ public string ImapHost { get; set; } = "";
+
+ [Category("Mail - IMAP")]
+ [DisplayName("IMAP-Port")]
+ public int ImapPort { get; set; } = 993;
+
+ [Category("Mail - SMTP")]
+ [DisplayName("SMTP-Host")]
+ public string SmtpHost { get; set; } = "";
+
+ [Category("Mail - SMTP")]
+ [DisplayName("SMTP-Port")]
+ public int SmtpPort { get; set; } = 587;
+
+ [Category("Mail - Sicherheit")]
+ [DisplayName("Erlaubte Empfänger")]
+ [Description("Komma-getrennte Liste erlaubter E-Mail-Adressen")]
+ public string AllowedRecipients { get; set; } = "";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["username"] = Username,
+ ["password"] = Password,
+ ["imapHost"] = ImapHost,
+ ["imapPort"] = ImapPort,
+ ["smtpHost"] = SmtpHost,
+ ["smtpPort"] = SmtpPort,
+ ["allowedRecipients"] = AllowedRecipients.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ };
+
+ public static MailToolSettings FromConfig(Dictionary config) => new()
+ {
+ Username = ConfigHelper.GetString(config, "username"),
+ Password = ConfigHelper.GetString(config, "password"),
+ ImapHost = ConfigHelper.GetString(config, "imapHost"),
+ ImapPort = ConfigHelper.GetInt(config, "imapPort", 993),
+ SmtpHost = ConfigHelper.GetString(config, "smtpHost"),
+ SmtpPort = ConfigHelper.GetInt(config, "smtpPort", 587),
+ AllowedRecipients = ConfigHelper.GetStringArray(config, "allowedRecipients")
+ };
+}
+
+public enum DatabaseType
+{
+ MySql,
+ Postgres,
+ MsSql,
+ MongoDb
+}
+
+public enum DatabaseAccessLevel
+{
+ [Description("Nur Lesen (SELECT/find)")]
+ ReadOnly,
+ [Description("Lesen und Schreiben (INSERT/UPDATE/DELETE)")]
+ ReadWrite,
+ [Description("Vollzugriff (Admin/Schema-Änderungen)")]
+ Admin
+}
+
+public sealed class DatabaseToolSettings
+{
+ [Category("Datenbank")]
+ [DisplayName("Typ")]
+ [Description("Der zu verwendende Datenbanktyp")]
+ public DatabaseType Type { get; set; } = DatabaseType.MySql;
+
+ [Category("Datenbank")]
+ [DisplayName("Connection-String")]
+ public string ConnectionString { get; set; } = "";
+
+ [Category("Datenbank")]
+ [DisplayName("Zugriffsebene")]
+ [Description("Legt fest, welche Operationen der Agent ausführen darf")]
+ public DatabaseAccessLevel AccessLevel { get; set; } = DatabaseAccessLevel.ReadOnly;
+
+ [Category("Datenbank - Sicherheit")]
+ [DisplayName("Erlaubte Tabellen")]
+ [Description("Komma-getrennte Liste erlaubter Tabellen/Collections")]
+ public string AllowedTables { get; set; } = "";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["type"] = Type.ToString().ToLowerInvariant(),
+ ["connectionString"] = ConnectionString,
+ ["accessLevel"] = AccessLevel.ToString(),
+ ["allowedTables"] = AllowedTables.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ };
+
+ public static DatabaseToolSettings FromConfig(Dictionary config) => new()
+ {
+ Type = Enum.TryParse(config.GetValueOrDefault("type")?.ToString(), true, out var result) ? result : DatabaseType.MySql,
+ ConnectionString = config.GetValueOrDefault("connectionString")?.ToString() ?? "",
+ AccessLevel = Enum.TryParse(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level)
+ ? level
+ : (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly),
+ AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr
+ ? string.Join(",", arr)
+ : config.GetValueOrDefault("allowedTables")?.ToString() ?? ""
+ };
+}
+
+public sealed class FTPToolSettings
+{
+ [Category("FTP Server")]
+ [DisplayName("Host")]
+ public string Host { get; set; } = "";
+
+ [Category("FTP Server")]
+ [DisplayName("Port")]
+ public int Port { get; set; } = 21;
+
+ [Category("FTP Server")]
+ [DisplayName("Benutzername")]
+ public string Username { get; set; } = "";
+
+ [Category("FTP Server")]
+ [DisplayName("Passwort")]
+ [PasswordPropertyText(true)]
+ public string Password { get; set; } = "";
+
+ [Category("FTP Lokal")]
+ [DisplayName("Root-Pfad")]
+ [Description("Basisverzeichnis für Dateiübertragungen")]
+ public string RootPath { get; set; } = "./data/";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["host"] = Host,
+ ["port"] = Port,
+ ["username"] = Username,
+ ["password"] = Password,
+ ["rootPath"] = RootPath
+ };
+
+ public static FTPToolSettings FromConfig(Dictionary config) => new()
+ {
+ Host = ConfigHelper.GetString(config, "host"),
+ Port = ConfigHelper.GetInt(config, "port", 21),
+ Username = ConfigHelper.GetString(config, "username"),
+ Password = ConfigHelper.GetString(config, "password"),
+ RootPath = ConfigHelper.GetString(config, "rootPath", "./data/")
+ };
+}
+
+public sealed class TelegramToolSettings
+{
+ [Category("Telegram")]
+ [DisplayName("Bot-Token")]
+ [PasswordPropertyText(true)]
+ public string BotToken { get; set; } = "";
+
+ [Category("Telegram")]
+ [DisplayName("Standard Chat-ID")]
+ [Description("Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.")]
+ public string DefaultChatId { get; set; } = "";
+
+ [Category("Telegram - Sicherheit")]
+ [DisplayName("Erlaubte Chat-IDs")]
+ [Description("Komma-getrennte Liste erlaubter Chat-IDs")]
+ public string AllowedChatIds { get; set; } = "";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["botToken"] = BotToken,
+ ["defaultChatId"] = DefaultChatId,
+ ["allowedChatIds"] = AllowedChatIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ };
+
+ public static TelegramToolSettings FromConfig(Dictionary config) => new()
+ {
+ BotToken = ConfigHelper.GetString(config, "botToken"),
+ DefaultChatId = ConfigHelper.GetString(config, "defaultChatId"),
+ AllowedChatIds = ConfigHelper.GetStringArray(config, "allowedChatIds")
+ };
+}
+
+public sealed class DirectAPIToolSettings
+{
+ [Category("DirectAPI")]
+ [DisplayName("Standard-Provider")]
+ public string DefaultProvider { get; set; } = "twelvedata";
+
+ [Category("DirectAPI")]
+ [DisplayName("Cache TTL (Sekunden)")]
+ public int CacheTtlSeconds { get; set; } = 60;
+
+ [Category("DirectAPI - API Keys")]
+ [DisplayName("Twelve Data Key")]
+ [PasswordPropertyText(true)]
+ public string TwelveDataKey { get; set; } = "";
+
+ [Category("DirectAPI - API Keys")]
+ [DisplayName("Alpha Vantage Key")]
+ [PasswordPropertyText(true)]
+ public string AlphaVantageKey { get; set; } = "";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["defaultProvider"] = DefaultProvider,
+ ["cacheTtlSeconds"] = CacheTtlSeconds,
+ ["providers"] = new Dictionary
+ {
+ ["twelvedata"] = new { apiKey = TwelveDataKey },
+ ["alphavantage"] = new { apiKey = AlphaVantageKey }
+ }
+ };
+
+ public static DirectAPIToolSettings FromConfig(Dictionary config)
+ {
+ var settings = new DirectAPIToolSettings
+ {
+ DefaultProvider = ConfigHelper.GetString(config, "defaultProvider", "twelvedata"),
+ CacheTtlSeconds = ConfigHelper.GetInt(config, "cacheTtlSeconds", 60)
+ };
+
+ if (config.GetValueOrDefault("providers") is JsonElement providersJe)
+ {
+ var providers = JsonSerializer.Deserialize>(providersJe.GetRawText());
+ if (providers != null)
+ {
+ if (providers.TryGetValue("twelvedata", out var td) && td.TryGetProperty("apiKey", out var tdk))
+ settings.TwelveDataKey = tdk.GetString() ?? "";
+ if (providers.TryGetValue("alphavantage", out var av) && av.TryGetProperty("apiKey", out var avk))
+ settings.AlphaVantageKey = avk.GetString() ?? "";
+ }
+ }
+
+ return settings;
+ }
+}
+
+public sealed class WebFetchToolSettings
+{
+ [Category("WebFetch")]
+ [DisplayName("Erlaubte Domains")]
+ [Description("Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)")]
+ public string AllowedDomains { get; set; } = "";
+
+ [Category("WebFetch")]
+ [DisplayName("Max Response KB")]
+ public int MaxResponseKb { get; set; } = 512;
+
+ [Category("WebFetch")]
+ [DisplayName("User Agent")]
+ public string UserAgent { get; set; } = "ClawdDotNet-Agent/1.0";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["allowedDomains"] = AllowedDomains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ ["maxResponseKb"] = MaxResponseKb,
+ ["userAgent"] = UserAgent
+ };
+
+ public static WebFetchToolSettings FromConfig(Dictionary config) => new()
+ {
+ AllowedDomains = ConfigHelper.GetStringArray(config, "allowedDomains"),
+ MaxResponseKb = ConfigHelper.GetInt(config, "maxResponseKb", 512),
+ UserAgent = ConfigHelper.GetString(config, "userAgent", "ClawdDotNet-Agent/1.0")
+ };
+}
+
+public sealed class WebMonitorToolSettings
+{
+ [Category("WebMonitor")]
+ [DisplayName("Monitore (JSON)")]
+ [Description("JSON-Konfiguration der Monitore")]
+ public string MonitorsJson { get; set; } = "{}";
+
+ public Dictionary ToConfig()
+ {
+ try
+ {
+ return new Dictionary
+ {
+ ["monitors"] = JsonSerializer.Deserialize>(MonitorsJson) ?? new()
+ };
+ }
+ catch { return new Dictionary { ["monitors"] = new Dictionary() }; }
+ }
+
+ public static WebMonitorToolSettings FromConfig(Dictionary config) => new()
+ {
+ MonitorsJson = config.GetValueOrDefault("monitors") is JsonElement je ? je.GetRawText() : "{}"
+ };
+}
+
+public sealed class AgentCommToolSettings
+{
+ [Category("AgentComm")]
+ [DisplayName("Info")]
+ [Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.")]
+ [ReadOnly(true)]
+ public string Status { get; set; } = "Aktiv";
+
+ public Dictionary ToConfig() => new();
+
+ public static AgentCommToolSettings FromConfig(Dictionary config) => new();
+}
+
+public sealed class SocialMediaManagerToolSettings
+{
+ // ─── X (Twitter) ───
+
+ [Category("1. X (Twitter) - API")]
+ [DisplayName("Bearer Token")]
+ [Description("X API v2 Bearer Token für die Authentifizierung")]
+ [PasswordPropertyText(true)]
+ public string XApiKey { get; set; } = "";
+
+ [Category("1. X (Twitter) - Monitoring")]
+ [DisplayName("Überwachte Accounts")]
+ [Description("Komma-getrennte Liste von X-Accounts die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone")]
+ public string XWatchAccounts { get; set; } = "";
+
+ // ─── Reddit ───
+
+ [Category("2. Reddit - Monitoring")]
+ [DisplayName("Überwachte Subreddits")]
+ [Description("Komma-getrennte Liste von Subreddits die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options")]
+ public string RedditWatchSubreddits { get; set; } = "";
+
+ [Category("2. Reddit - Monitoring")]
+ [DisplayName("Posts pro Subreddit")]
+ [Description("Maximale Anzahl Posts die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)")]
+ public int RedditPostLimit { get; set; } = 15;
+
+ // ─── YouTube / STT ───
+
+ [Category("3. YouTube / STT")]
+ [DisplayName("OpenRouter API Key")]
+ [Description("API Key für Speech-to-Text Transkription über OpenRouter")]
+ [PasswordPropertyText(true)]
+ public string OpenRouterApiKey { get; set; } = "";
+
+ [Category("3. YouTube / STT")]
+ [DisplayName("STT Modell")]
+ [Description("OpenRouter Modell-ID für die Transkription")]
+ public string STTModel { get; set; } = "openai/whisper-1";
+
+ [Category("3. YouTube / STT")]
+ [DisplayName("YouTube Kanäle")]
+ [Description("Komma-getrennte Liste von YouTube Kanal-URLs für automatische Überwachung")]
+ public string YoutubeChannels { get; set; } = "";
+
+ public Dictionary ToConfig() => new()
+ {
+ ["xApiKey"] = XApiKey,
+ ["xWatchAccounts"] = SplitToArray(XWatchAccounts),
+ ["redditWatchSubreddits"] = SplitToArray(RedditWatchSubreddits),
+ ["redditPostLimit"] = RedditPostLimit,
+ ["openRouterApiKey"] = OpenRouterApiKey,
+ ["sttModel"] = STTModel,
+ ["youtubeChannels"] = SplitToArray(YoutubeChannels)
+ };
+
+ public static SocialMediaManagerToolSettings FromConfig(Dictionary config) => new()
+ {
+ XApiKey = ConfigHelper.GetString(config, "xApiKey"),
+ XWatchAccounts = ConfigHelper.GetStringArray(config, "xWatchAccounts"),
+ RedditWatchSubreddits = ConfigHelper.GetStringArray(config, "redditWatchSubreddits"),
+ RedditPostLimit = ConfigHelper.GetInt(config, "redditPostLimit", 15),
+ OpenRouterApiKey = ConfigHelper.GetString(config, "openRouterApiKey"),
+ STTModel = ConfigHelper.GetString(config, "sttModel", "openai/whisper-1"),
+ YoutubeChannels = ConfigHelper.GetStringArray(config, "youtubeChannels")
+ };
+
+ private static string[] SplitToArray(string csv)
+ => csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+}
+
+public sealed class AgentEditorToolSettings
+{
+ [Category("AgentEditor")]
+ [DisplayName("Info")]
+ [Description("Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen, zu bearbeiten und neue Agenten zu erstellen. Keine weitere Konfiguration nötig.")]
+ [ReadOnly(true)]
+ public string Status { get; set; } = "Aktiv";
+
+ public Dictionary ToConfig() => new();
+
+ public static AgentEditorToolSettings FromConfig(Dictionary config) => new();
+}
+
+public sealed class AgentSpawnToolSettings
+{
+ [Category("AgentSpawn")]
+ [DisplayName("Info")]
+ [Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.")]
+ [ReadOnly(true)]
+ public string Status { get; set; } = "Aktiv";
+
+ public Dictionary ToConfig() => new();
+
+ public static AgentSpawnToolSettings FromConfig(Dictionary config) => new();
+}
+
+public static class ToolSettingsFactory
+{
+ public static object? CreateViewModel(string toolName, Dictionary? config)
+ {
+ config ??= new();
+ return toolName switch
+ {
+ "FileRW" => FileRWToolSettings.FromConfig(config),
+ "Mail" => MailToolSettings.FromConfig(config),
+ "Database" => DatabaseToolSettings.FromConfig(config),
+ "Telegram" => TelegramToolSettings.FromConfig(config),
+ "FTP" => FTPToolSettings.FromConfig(config),
+ "DirectAPI" => DirectAPIToolSettings.FromConfig(config),
+ "WebFetch" => WebFetchToolSettings.FromConfig(config),
+ "WebMonitor" => WebMonitorToolSettings.FromConfig(config),
+ "AgentComm" => AgentCommToolSettings.FromConfig(config),
+ "SocialMediaManager" => SocialMediaManagerToolSettings.FromConfig(config),
+ "AgentSpawn" => AgentSpawnToolSettings.FromConfig(config),
+ "AgentEditor" => AgentEditorToolSettings.FromConfig(config),
+ _ => null
+ };
+ }
+
+ public static Dictionary? ToConfig(string toolName, object? viewModel)
+ {
+ return viewModel switch
+ {
+ FileRWToolSettings f => f.ToConfig(),
+ MailToolSettings m => m.ToConfig(),
+ DatabaseToolSettings d => d.ToConfig(),
+ TelegramToolSettings t => t.ToConfig(),
+ FTPToolSettings ftp => ftp.ToConfig(),
+ DirectAPIToolSettings dapi => dapi.ToConfig(),
+ WebFetchToolSettings wf => wf.ToConfig(),
+ WebMonitorToolSettings wm => wm.ToConfig(),
+ AgentCommToolSettings ac => ac.ToConfig(),
+ SocialMediaManagerToolSettings smm => smm.ToConfig(),
+ AgentSpawnToolSettings asp => asp.ToConfig(),
+ AgentEditorToolSettings ae => ae.ToConfig(),
+ _ => null
+ };
+ }
+}
diff --git a/src/ClawdDotNet.App/Services/BackupScheduler.cs b/src/ClawdDotNet.App/Services/BackupScheduler.cs
new file mode 100644
index 0000000..73faa8c
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/BackupScheduler.cs
@@ -0,0 +1,169 @@
+using ClawdDotNet.App.Settings;
+using ClawdDotNet.Core.Backup;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.App.Services;
+
+///
+/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung.
+///
+/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und
+/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
+/// mitsichern will, macht das von Hand.
+///
+/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit
+/// eine Änderung ohne Neustart greift.
+///
+/// Takt. Früher ein System.Windows.Forms.Timer — der braucht eine
+/// Nachrichtenschleife und damit ein Fenster. Jetzt über
+/// einen : läuft ohne Oberfläche, driftet nicht, und Tests
+/// können die Zeit steuern statt zu warten.
+///
+public sealed class BackupScheduler : IAsyncDisposable
+{
+ private readonly string _instanceDir;
+ private readonly string _instanceName;
+ private readonly SettingsManager _settings;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _clock;
+ private readonly TimeSpan _tick;
+ private readonly BackupService _service = new();
+ private readonly CancellationTokenSource _cts = new();
+
+ private Task? _loop;
+
+ /// Verhindert mehrere Sicherungen am selben Tag.
+ private DateTime? _lastRun;
+
+ public event Action? OnBackupCreated;
+
+ public BackupScheduler(
+ string instanceDir,
+ string instanceName,
+ SettingsManager settings,
+ ILogger logger,
+ TimeProvider? clock = null,
+ TimeSpan? tick = null)
+ {
+ _instanceDir = instanceDir;
+ _instanceName = instanceName;
+ _settings = settings;
+ _logger = logger;
+ _clock = clock ?? TimeProvider.System;
+
+ // Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
+ _tick = tick ?? TimeSpan.FromMinutes(1);
+ }
+
+ public void Start() => _loop ??= RunLoopAsync(_cts.Token);
+
+ private async Task RunLoopAsync(CancellationToken ct)
+ {
+ using var timer = new PeriodicTimer(_tick, _clock);
+
+ while (await timer.WaitForNextTickAsync(ct))
+ {
+ try
+ {
+ await TickAsync();
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Ein Fehlschlag darf die Anwendung nicht stören.
+ _logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
+ }
+ }
+ }
+
+ /// Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.
+ public async Task TickAsync()
+ {
+ var settings = _settings.AppSettings;
+ if (!settings.AutoBackupEnabled)
+ return;
+
+ if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
+ return;
+
+ var now = _clock.GetLocalNow().DateTime;
+
+ // Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
+ if (now.TimeOfDay < scheduled) return;
+ if (_lastRun?.Date == now.Date) return;
+
+ // Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht,
+ // sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
+ _lastRun = now;
+
+ await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now);
+ }
+
+ private async Task RunAsync(string folder, int keepCount, DateTime now)
+ {
+ // PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
+ // nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
+ // Windows nicht mehr anlegen lässt.
+ var safeName = PortableFileName.Sanitize(_instanceName, "Instanz");
+
+ var file = Path.Combine(
+ Path.GetFullPath(folder),
+ $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
+
+ var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
+ {
+ Secrets = SecretMode.Exclude,
+ IncludeChatHistory = true,
+ IncludeLogs = false
+ });
+
+ _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
+ result.ZipPath, result.SizeBytes);
+
+ ApplyRotation(Path.GetFullPath(folder), safeName, keepCount);
+
+ OnBackupCreated?.Invoke(result.ZipPath);
+ }
+
+ /// Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.
+ private void ApplyRotation(string folder, string safeName, int keepCount)
+ {
+ if (keepCount <= 0 || !Directory.Exists(folder))
+ return;
+
+ try
+ {
+ var prefix = $"backup_{safeName}_";
+
+ var obsolete = new DirectoryInfo(folder)
+ .GetFiles("*.zip")
+ .Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison))
+ .OrderByDescending(f => f.LastWriteTime)
+ .Skip(keepCount)
+ .ToList();
+
+ foreach (var file in obsolete)
+ {
+ file.Delete();
+ _logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+
+ if (_loop is not null)
+ {
+ try { await _loop; }
+ catch (OperationCanceledException) { /* erwartet */ }
+ }
+
+ _cts.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.App/Services/DeploymentcenterService.cs b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs
new file mode 100644
index 0000000..9666952
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs
@@ -0,0 +1,269 @@
+using ClawdDotNet.App.Settings;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Deploymentcenter;
+using ClawdDotNet.Core.Deploymentcenter.Watchdog;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.App.Services;
+
+///
+/// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat,
+/// Fehler-Stream, Bugtracker und Update-Prüfung.
+///
+/// Zuvor lagen Watchdog (eigener Server, eigener Schlüssel) und Lizenz
+/// (LicenseLabrador, eigener Server, eigener Public-Key) getrennt nebeneinander. Beides
+/// sind jetzt Module derselben Anwendung mit einer Adresse und einem Token — und dazu
+/// kommen Updates, Fehler-Stream und Bugtracker, die es vorher gar nicht gab.
+///
+/// Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein
+/// status/error-Umschlag), einen eigenen Zwischenspeicher und muss vor
+/// allem anderen laufen. Dafür ist zuständig.
+///
+public sealed class DeploymentcenterService : IAsyncDisposable
+{
+ private readonly DeploymentcenterApi _api;
+ private readonly string _appToken;
+ private readonly string _build;
+ private readonly ILogger _logger;
+
+ private WatchdogHeartbeatService? _heartbeat;
+
+ public IErrorReporter Errors { get; private set; } = NullErrorReporter.Instance;
+
+ /// Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden.
+ public BugtrackerClient? Bugtracker { get; private set; }
+
+ /// Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist.
+ public UpdateAvailability? Update { get; private set; }
+
+ private DeploymentcenterService(
+ DeploymentcenterApi api, string appToken, string build, ILogger logger)
+ {
+ _api = api;
+ _appToken = appToken;
+ _build = build;
+ _logger = logger;
+ }
+
+ ///
+ /// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt null zurück,
+ /// wenn Adresse oder Token fehlen — der Aufrufer läuft dann ohne weiter, denn keines
+ /// dieser Module darf ein Startgrund oder ein Hindernis sein.
+ ///
+ public static DeploymentcenterService? TryCreate(
+ AppSettings settings, string build, ILoggerFactory loggerFactory)
+ {
+ var logger = loggerFactory.CreateLogger("ClawdDotNet.Deploymentcenter");
+
+ if (string.IsNullOrWhiteSpace(settings.DeploymentcenterUrl))
+ {
+ logger.LogInformation("Deploymentcenter: keine Server-URL hinterlegt – Anbindung aus.");
+ return null;
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.DeploymentcenterToken))
+ {
+ logger.LogInformation(
+ "Deploymentcenter: kein Token hinterlegt – Heartbeat, Fehler-Stream und "
+ + "Bugtracker bleiben aus.");
+ return null;
+ }
+
+ DeploymentcenterApi api;
+ try
+ {
+ api = new DeploymentcenterApi(settings.DeploymentcenterUrl, settings.DeploymentcenterToken);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Deploymentcenter: Konfiguration unbrauchbar – Anbindung aus.");
+ return null;
+ }
+
+ var service = new DeploymentcenterService(
+ api, settings.DeploymentcenterToken, build, logger);
+
+ if (settings.ErrorReportingEnabled)
+ {
+ service.Errors = new ErrorReporter(
+ api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build, logger);
+ }
+
+ service.Bugtracker = new BugtrackerClient(
+ api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build);
+
+ return service;
+ }
+
+ // ─── Watchdog ───
+
+ ///
+ /// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist.
+ ///
+ /// Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über
+ /// das Paar source + instance. Fällt eine von mehreren aus, fällt
+ /// genau deren Monitor — und nur der schlägt Alarm.
+ ///
+ ///
+ /// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration
+ /// geschrieben werden soll (dort verschlüsselt).
+ ///
+ public async Task StartWatchdogAsync(
+ InstanceConfig instance,
+ IInstanceHealthProvider health,
+ Action saveInstanceConfig,
+ CancellationToken ct = default)
+ {
+ var service = instance.Services.FirstOrDefault(s => s.Type == BuiltInServices.InstanceWatchdog);
+ if (service is not { Enabled: true, AutoStart: true })
+ return;
+
+ var watchdog = instance.Watchdog;
+ var token = await ResolveInstanceTokenAsync(instance, saveInstanceConfig, ct);
+
+ try
+ {
+ _heartbeat = WatchdogHeartbeatService.Create(
+ _api.BaseUrl,
+ token,
+ watchdog.Source,
+ watchdog.ResolveInstance(instance.InstanceId),
+ watchdog.Group,
+ DescribePlatform(),
+ _build,
+ watchdog.IntervalSeconds,
+ health,
+ _logger);
+
+ _heartbeat.Start();
+
+ _logger.LogInformation("Instanz-Watchdog aktiv: {Source}/{Instance}, alle {Interval}s",
+ watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.IntervalSeconds);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Instanz-Watchdog konnte nicht gestartet werden");
+ }
+ }
+
+ ///
+ /// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte
+ /// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite.
+ ///
+ /// Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das
+ /// Master-Token liegt, sondern ein auf watchdog:ping und
+ /// bugtracker:report beschränktes, das sich einzeln widerrufen lässt.
+ /// Scheitert das, wird trotzdem gemeldet — Monitoring, das nur bei perfekter
+ /// Rechtelage läuft, ist genau dann still, wenn man es braucht.
+ ///
+ private async Task ResolveInstanceTokenAsync(
+ InstanceConfig instance, Action saveInstanceConfig, CancellationToken ct)
+ {
+ if (instance.Watchdog.HasToken)
+ return instance.Watchdog.AgentToken;
+
+ try
+ {
+ var provisioned = await new TokenProvisioner(_api).ProvisionAsync(
+ clientName: $"ClawdDotNet {instance.InstanceName}",
+ instanceId: instance.InstanceId,
+ scopes: TokenProvisioner.InstanceScopes,
+ ct: ct);
+
+ instance.Watchdog.AgentToken = provisioned.Token;
+ saveInstanceConfig();
+
+ _logger.LogInformation(
+ "Deploymentcenter: eigenes Token für diese Instanz bezogen ({TokenId}, Rechte: {Scopes})",
+ provisioned.TokenId, string.Join(", ", provisioned.Scopes));
+
+ return provisioned.Token;
+ }
+ catch (Exception ex)
+ {
+ // Häufigster Fall: Das hinterlegte Token ist selbst ein Sub-Token und darf
+ // keine weiteren ausstellen. Kein Grund, das Monitoring aufzugeben.
+ _logger.LogInformation(
+ "Deploymentcenter: kein eigenes Instanz-Token beziehbar ({Reason}) – "
+ + "es wird mit dem hinterlegten Token gemeldet.", ex.Message);
+
+ return _appToken;
+ }
+ }
+
+ private static string DescribePlatform() =>
+ $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} / "
+ + $".NET {System.Environment.Version}";
+
+ // ─── Updates ───
+
+ ///
+ /// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das
+ /// Ergebnis wird protokolliert und über bereitgestellt; ob und
+ /// wann aktualisiert wird, entscheidet der Benutzer.
+ ///
+ public async Task CheckForUpdateAsync(AppSettings settings, string currentVersion, CancellationToken ct = default)
+ {
+ if (!settings.UpdateCheckEnabled)
+ return;
+
+ try
+ {
+ var client = new global::Deploymentcenter.Client.UpdateClient();
+ var result = await client.CheckForUpdateAsync(
+ settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
+ settings.UpdateChannel, cancellationToken: ct);
+
+ if (result.Error is not null)
+ {
+ _logger.LogDebug(result.Error, "Update-Prüfung fehlgeschlagen (ignoriert).");
+ return;
+ }
+
+ // Seit SDK 2.1 liefern beide Wege vollständige Daten: die statische
+ // latest.json in camelCase, die API in snake_case, jeweils über ein eigenes
+ // Modell. Vorher kam über den API-Zweig außer der Versionsnummer nichts an —
+ // und der ist genau der Rückfall, wenn die latest.json fehlt.
+ Update = new UpdateAvailability(
+ result.UpdateAvailable,
+ result.LatestRelease?.Version ?? currentVersion,
+ result.IsCritical,
+ result.LatestRelease?.Changelog,
+ result.LatestRelease?.PackageUrl);
+
+ if (result.UpdateAvailable)
+ {
+ _logger.LogInformation("Update verfügbar: {Version}{Critical}",
+ Update.LatestVersion, Update.IsCritical ? " (kritisch)" : "");
+ }
+ else
+ {
+ _logger.LogInformation("Kein Update verfügbar (installiert: {Version}).", currentVersion);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Update-Prüfung fehlgeschlagen (ignoriert).");
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_heartbeat is not null)
+ await _heartbeat.DisposeAsync();
+
+ if (Errors is IDisposable disposableReporter)
+ disposableReporter.Dispose();
+
+ _api.Dispose();
+ }
+}
+
+/// Ergebnis der Update-Prüfung.
+///
+/// Adresse des Pakets — für einen späteren Anschluss des update-agent. Solange
+/// der nicht eingebunden ist, dient sie nur der Anzeige.
+///
+public sealed record UpdateAvailability(
+ bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes,
+ string? DownloadUrl = null);
diff --git a/src/ClawdDotNet.App/Services/ILicensePrompt.cs b/src/ClawdDotNet.App/Services/ILicensePrompt.cs
new file mode 100644
index 0000000..e8d3c07
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/ILicensePrompt.cs
@@ -0,0 +1,93 @@
+namespace ClawdDotNet.App.Services;
+
+///
+/// Wie der mit dem Benutzer spricht.
+///
+/// Vorher rief er unmittelbar MessageBox.Show und einen WinForms-Dialog.
+/// Das band die Lizenzprüfung an WinForms — und wäre im kopflosen Betrieb fatal
+/// gewesen: Ein Dienst, der beim Start ein Fenster öffnet und auf eine Eingabe wartet,
+/// hängt für immer, ohne dass jemand die Meldung je zu sehen bekäme.
+///
+/// Es gibt drei Umsetzungen:
+///
+/// AvaloniaLicensePrompt — Dialoge im Fenster (Desktop).
+/// — Eingabe über die Konsole (Kommandozeile).
+/// — antwortet nie (systemd-Dienst).
+///
+///
+public interface ILicensePrompt
+{
+ ///
+ /// Fragt einen Lizenzschlüssel ab. null heißt Abbruch — der Aufrufer beendet
+ /// die Anwendung.
+ ///
+ /// Wird angezeigt, damit der Nutzer ihn an den Support geben kann.
+ /// Warum der bisherige Schlüssel nicht taugt; null beim ersten Fragen.
+ /// Vorbelegung des Eingabefelds.
+ Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey);
+
+ /// Eine Meldung, die keine Antwort braucht (etwa „offline gültig bis …").
+ Task ShowInfoAsync(string title, string message);
+
+ /// Ein Fehler, nach dem die Anwendung nicht weiterläuft.
+ Task ShowErrorAsync(string title, string message);
+}
+
+///
+/// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab.
+///
+/// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren
+/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile
+/// hinterlegt (--license-set-key).
+///
+public sealed class NonInteractiveLicensePrompt(Action log) : ILicensePrompt
+{
+ public Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
+ {
+ log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. "
+ + (problem is null ? "" : $"Grund: {problem}. ")
+ + "Schlüssel mit '--license-set-key ' hinterlegen.");
+
+ return Task.FromResult(null);
+ }
+
+ public Task ShowInfoAsync(string title, string message)
+ {
+ log($"{title}: {message}");
+ return Task.CompletedTask;
+ }
+
+ public Task ShowErrorAsync(string title, string message)
+ {
+ log($"{title}: {message}");
+ return Task.CompletedTask;
+ }
+}
+
+/// Eingabe über die Konsole — für Kommandozeilenaufrufe.
+public sealed class ConsoleLicensePrompt : ILicensePrompt
+{
+ public Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
+ {
+ if (problem is not null)
+ Console.Error.WriteLine($"Lizenz: {problem}");
+
+ Console.WriteLine($"Hardware-ID: {hardwareId}");
+ Console.Write("Lizenzschlüssel: ");
+
+ var input = Console.ReadLine()?.Trim();
+ return Task.FromResult(string.IsNullOrWhiteSpace(input) ? null : input);
+ }
+
+ public Task ShowInfoAsync(string title, string message)
+ {
+ Console.WriteLine($"{title}: {message}");
+ return Task.CompletedTask;
+ }
+
+ public Task ShowErrorAsync(string title, string message)
+ {
+ Console.Error.WriteLine($"{title}: {message}");
+ return Task.CompletedTask;
+ }
+}
diff --git a/Services/InstanceDirectoryManager.cs b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs
similarity index 99%
rename from Services/InstanceDirectoryManager.cs
rename to src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs
index e93fba9..4dd4c32 100644
--- a/Services/InstanceDirectoryManager.cs
+++ b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs
@@ -2,9 +2,9 @@ using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
-using ClawdDotNet.Models;
+using ClawdDotNet.App.Models;
-namespace ClawdDotNet.Services;
+namespace ClawdDotNet.App.Services;
///
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
diff --git a/Services/JobHistoryService.cs b/src/ClawdDotNet.App/Services/JobHistoryService.cs
similarity index 97%
rename from Services/JobHistoryService.cs
rename to src/ClawdDotNet.App/Services/JobHistoryService.cs
index a577b98..f20366f 100644
--- a/Services/JobHistoryService.cs
+++ b/src/ClawdDotNet.App/Services/JobHistoryService.cs
@@ -1,7 +1,7 @@
using System.Text.Json;
-using ClawdDotNet.Models;
+using ClawdDotNet.App.Models;
-namespace ClawdDotNet.Services;
+namespace ClawdDotNet.App.Services;
///
/// Thread-safe JSON persistence for job execution history.
diff --git a/src/ClawdDotNet.App/Services/LicenseGate.cs b/src/ClawdDotNet.App/Services/LicenseGate.cs
new file mode 100644
index 0000000..4087d00
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/LicenseGate.cs
@@ -0,0 +1,237 @@
+using ClawdDotNet.App.Settings;
+using Deploymentcenter.Client;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.App.Services;
+
+///
+/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
+///
+/// Nur ein Urteil sperrt. Das SDK trennt seit 2.1 zwei Dinge, die vorher
+/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
+/// (revoked, expired, not_found, activation_limit,
+/// suspended, clock_rollback) und ein gescheiterter Versuch, überhaupt
+/// eine zu bekommen (). Nur das Urteil
+/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
+/// aussperren.
+///
+/// Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung
+/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und
+/// trägt damit über Ausfälle hinweg — begrenzt durch cache_ttl_hours des Projekts
+/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.
+///
+/// Was diese Fassung nicht kann. Deaktivieren (Aktivierungsplatz freigeben)
+/// verlangt den shared_key des Servers. Der gehört nicht in eine ausgelieferte
+/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben").
+/// Eine Signaturprüfung findet nicht statt — siehe .
+///
+public sealed class LicenseGate
+{
+ private readonly SettingsManager _settings;
+ private readonly ILogger _logger;
+ private readonly ILicensePrompt _prompt;
+ private readonly LicenseClient _client;
+ private readonly string _serverUrl;
+
+ private string? _hardwareId;
+
+ public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
+ {
+ _settings = settings;
+ _logger = logger;
+ _prompt = prompt;
+
+ _serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
+ ? LicenseInfo.DefaultServerUrl
+ : settings.AppSettings.DeploymentcenterUrl.Trim();
+
+ // Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
+ // trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
+ LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
+
+ // Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
+ // von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
+ // Server nicht antwortete.
+ _client = new LicenseClient();
+ }
+
+ ///
+ /// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung
+ /// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas
+ /// eintragen kann.
+ ///
+ public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
+
+ ///
+ /// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
+ /// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
+ ///
+ public string HardwareId =>
+ // Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
+ _hardwareId ??= global::Deploymentcenter.Client.HardwareId
+ .GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
+
+ ///
+ /// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein
+ /// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
+ ///
+ public Task RevalidateAsync(CancellationToken ct = default)
+ => ValidateAsync(_settings.AppSettings.LicenseKey, ct);
+
+ ///
+ /// Startprüfung. Fragt über nach, bis eine nutzbare
+ /// Lizenz vorliegt, oder gibt false zurück — dann beendet der Aufrufer die
+ /// Anwendung.
+ ///
+ public async Task RunStartupCheckAsync(CancellationToken ct = default)
+ {
+ if (!IsEnforcementConfigured)
+ {
+ _logger.LogWarning(
+ "Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) – übersprungen.");
+ return true;
+ }
+
+ var key = _settings.AppSettings.LicenseKey;
+
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ key = await _prompt.RequestKeyAsync(HardwareId, null, null);
+ if (key is null) return false;
+ }
+
+ var result = await ValidateAsync(key, ct);
+
+ while (!result.IsValid)
+ {
+ // Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer
+ // Schlüssel würde daran nichts ändern, und danach zu fragen ließe den
+ // Benutzer raten. Der Notausschalter greift, sobald der Server wieder
+ // antwortet (LicenseWatch).
+ if (result.IsTransient)
+ {
+ _logger.LogWarning(
+ "Lizenz nicht prüfbar ({Status}): {Message} – Start wird fortgesetzt.",
+ result.Status, result.Message);
+
+ await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
+ "Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder "
+ + "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später "
+ + "erneut.");
+
+ return true;
+ }
+
+ if (result.Status == "clock_rollback")
+ {
+ // Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung.
+ await _prompt.ShowErrorAsync("ClawdDotNet – Lizenz",
+ "Die Lizenzprüfung konnte nicht abgeschlossen werden. "
+ + "Bitte den Support kontaktieren.");
+ return false;
+ }
+
+ _logger.LogWarning(
+ "Lizenz abgelehnt: {Status} – {Message} (Produkt {Slug}, Server {Server})",
+ result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl);
+
+ var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key);
+ if (retry is null) return false;
+
+ key = retry;
+ result = await ValidateAsync(key, ct);
+ }
+
+ _settings.AppSettings.LicenseKey = key;
+
+ try
+ {
+ _settings.Save();
+ }
+ catch (SettingsPersistenceException ex)
+ {
+ // Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
+ // beim nächsten Start wird eben erneut gefragt.
+ _logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden");
+ }
+
+ if (result.IsCached)
+ {
+ var until = Describe(result.CacheExpiresAt);
+
+ _logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until);
+
+ await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
+ $"Lizenzserver nicht erreichbar. Offline gültig bis {until}.");
+ }
+
+ return true;
+ }
+
+ private async Task ValidateAsync(string key, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return new LicenseValidationResult
+ {
+ IsValid = false,
+ Status = "not_found",
+ Message = "Kein Lizenzschlüssel hinterlegt."
+ };
+ }
+
+ try
+ {
+ return await _client.ValidateAsync(
+ LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als
+ // IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem
+ // nicht als Urteil über die Lizenz gelten.
+ _logger.LogError(ex, "Lizenzprüfung fehlgeschlagen.");
+
+ return new LicenseValidationResult
+ {
+ IsValid = false,
+ Status = "server_unavailable",
+ Message = "Lizenz konnte nicht geprüft werden.",
+ IsTransient = true
+ };
+ }
+ }
+
+ private static string Describe(long? unixSeconds) =>
+ unixSeconds is { } seconds and > 0
+ ? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g")
+ : "auf Weiteres";
+
+ private static string DescribeProblem(LicenseValidationResult result) => result.Status switch
+ {
+ "revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.",
+ "suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.",
+ "expired" => "Diese Lizenz ist abgelaufen.",
+ "activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. "
+ + "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste "
+ + "über \"Freigeben\" räumen.",
+ // Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes
+ // Projekt und unbekannter Schlüssel. Welches davon, steht nur in message —
+ // deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch
+ // eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man
+ // sucht am falschen Ende.
+ "not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss "
+ + $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt "
+ + "sein und der Schlüssel dort hinterlegt."
+ + (string.IsNullOrWhiteSpace(result.Message)
+ ? ""
+ : $" (Server: {result.Message})"),
+ _ => string.IsNullOrWhiteSpace(result.Message)
+ ? "Die Lizenz ist nicht gültig."
+ : result.Message
+ };
+}
diff --git a/src/ClawdDotNet.App/Services/LicenseInfo.cs b/src/ClawdDotNet.App/Services/LicenseInfo.cs
new file mode 100644
index 0000000..dd35388
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/LicenseInfo.cs
@@ -0,0 +1,38 @@
+namespace ClawdDotNet.App.Services;
+
+///
+/// Fest hinterlegte Lizenz-Eckdaten.
+///
+/// Kein Public-Key mehr. Die frühere Fassung führte einen
+/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine
+/// Gegenseite: Der Client liest ausschließlich das Feld status aus der Antwort,
+/// eine Signaturprüfung findet nicht statt (siehe
+/// docs/Deploymentcenter-Anbindung-Review.md, Abschnitt 2.1). Ein Schlüssel, der
+/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist.
+/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück.
+///
+/// Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann
+/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz.
+///
+public static class LicenseInfo
+{
+ ///
+ /// Projekt-Slug, wie er in dc_projects angelegt ist. Gilt für alle Module:
+ /// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe
+ /// Projekttabelle zu.
+ ///
+ /// Hier stand bis zur Umstellung clawd — der Name aus dem
+ /// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt
+ /// clawddotnet. Der Server beantwortet einen unbekannten Slug mit demselben
+ /// not_found wie einen unbekannten Schlüssel, weshalb das wie ein falsch
+ /// eingegebener Lizenzschlüssel aussah.
+ ///
+ public const string ProductSlug = "clawddotnet";
+
+ ///
+ /// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine
+ /// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung
+ /// und teilen sich diese Adresse.
+ ///
+ public const string DefaultServerUrl = "https://dc.mhdf.de";
+}
diff --git a/src/ClawdDotNet.App/Services/LicenseWatch.cs b/src/ClawdDotNet.App/Services/LicenseWatch.cs
new file mode 100644
index 0000000..7f3aa79
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/LicenseWatch.cs
@@ -0,0 +1,110 @@
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.App.Services;
+
+///
+/// Prüft die Lizenz im laufenden Betrieb nach.
+///
+/// Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die
+/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle
+/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro
+/// Klick.
+///
+/// Nur ein Urteil zählt. Ein Netzproblem, eine Drosselung oder eine
+/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als
+/// IsTransient, und ein Serverausfall darf nicht alle laufenden Instanzen
+/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg.
+///
+public sealed class LicenseWatch : IAsyncDisposable
+{
+ private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12);
+
+ private readonly LicenseGate _gate;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _interval;
+
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+
+ ///
+ /// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber
+ /// ohne Rückfrage; der übergebene Text erklärt den Grund.
+ ///
+ public event Func? Revoked;
+
+ public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null)
+ {
+ _gate = gate;
+ _logger = logger;
+ _interval = interval ?? DefaultInterval;
+ }
+
+ public void Start()
+ {
+ if (_loop is { IsCompleted: false })
+ return;
+
+ _cts = new CancellationTokenSource();
+ _loop = RunAsync(_cts.Token);
+ }
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ try
+ {
+ using var timer = new PeriodicTimer(_interval);
+
+ while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
+ {
+ var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false);
+
+ if (result.IsValid)
+ continue;
+
+ if (result.IsTransient)
+ {
+ _logger.LogInformation(
+ "Lizenz-Nachprüfung ohne Ergebnis ({Status}) – Betrieb läuft weiter.",
+ result.Status);
+ continue;
+ }
+
+ _logger.LogWarning("Lizenz gilt nicht mehr ({Status}) – Instanz wird beendet.",
+ result.Status);
+
+ if (Revoked is { } handler)
+ {
+ await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). "
+ + "ClawdDotNet wird beendet.").ConfigureAwait(false);
+ }
+
+ return;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Regulärer Stopp.
+ }
+ catch (Exception ex)
+ {
+ // Die Nachprüfung darf den Betrieb nicht mitnehmen.
+ _logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen.");
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_cts is null)
+ return;
+
+ await _cts.CancelAsync().ConfigureAwait(false);
+
+ if (_loop is not null)
+ {
+ try { await _loop.ConfigureAwait(false); }
+ catch (OperationCanceledException) { /* erwartet */ }
+ }
+
+ _cts.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.App/Services/LogTail.cs b/src/ClawdDotNet.App/Services/LogTail.cs
new file mode 100644
index 0000000..2248778
--- /dev/null
+++ b/src/ClawdDotNet.App/Services/LogTail.cs
@@ -0,0 +1,154 @@
+using System.Collections.Concurrent;
+using System.Text.RegularExpressions;
+
+namespace ClawdDotNet.App.Services;
+
+/// Eine gelesene Logzeile.
+/// Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei.
+/// INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat.
+public readonly record struct LogLine(string Module, string Level, string Text);
+
+/// Ab welcher Stufe angezeigt wird.
+public enum LogLevelFilter { All, Info, Warn, Error }
+
+///
+/// Liest neu hinzugekommene Zeilen aus den Logdateien.
+///
+/// Die vorige Fassung (LiveLogViewerService) schrieb unmittelbar in eine
+/// RichTextBox und taktete über einen System.Windows.Forms.Timer — Lesen
+/// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur
+/// das Lesen; was damit geschieht, entscheidet der Aufrufer.
+///
+/// Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das
+/// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen.
+///
+public sealed partial class LogTail(string logDirectory)
+{
+ private readonly ConcurrentDictionary _positions = new();
+
+ [GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)]
+ private static partial Regex LevelPattern();
+
+ /// Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.
+ public IReadOnlyList AvailableModules()
+ {
+ var directory = TodayDirectory();
+ if (directory is null) return [];
+
+ return Directory.GetFiles(directory, "*.log")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(name => !string.IsNullOrEmpty(name))
+ .Select(name => name!)
+ .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ ///
+ /// Alles, was seit dem letzten Aufruf dazugekommen ist.
+ ///
+ /// leer heißt: alle Module.
+ ///
+ public IReadOnlyList ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All)
+ {
+ var directory = TodayDirectory();
+ if (directory is null) return [];
+
+ var result = new List();
+
+ foreach (var path in Directory.GetFiles(directory, "*.log"))
+ {
+ var moduleName = Path.GetFileNameWithoutExtension(path);
+
+ if (!string.IsNullOrEmpty(module)
+ && !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase))
+ {
+ // Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters
+ // die gesamte bisherige Datei auf einmal herein.
+ TrackWithoutReading(path);
+ continue;
+ }
+
+ ReadFile(path, moduleName, level, result);
+ }
+
+ return result;
+ }
+
+ private void ReadFile(string path, string module, LogLevelFilter level, List into)
+ {
+ var lastPosition = _positions.GetOrAdd(path, 0L);
+
+ try
+ {
+ // FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die
+ // Datei auch ersetzen können, während wir lesen.
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete);
+
+ if (stream.Length < lastPosition)
+ lastPosition = 0; // rotiert oder gekürzt
+
+ if (stream.Length == lastPosition)
+ return;
+
+ stream.Seek(lastPosition, SeekOrigin.Begin);
+ using var reader = new StreamReader(stream);
+
+ while (reader.ReadLine() is { } line)
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ var lineLevel = ExtractLevel(line);
+ if (!Passes(lineLevel, level)) continue;
+
+ into.Add(new LogLine(module, lineLevel, line));
+ }
+
+ _positions[path] = stream.Position;
+ }
+ catch (IOException)
+ {
+ // Wird gerade geschrieben — beim nächsten Takt erneut versuchen.
+ }
+ }
+
+ private void TrackWithoutReading(string path)
+ {
+ try { _positions[path] = new FileInfo(path).Length; }
+ catch (IOException) { }
+ }
+
+ private static string ExtractLevel(string line)
+ {
+ var match = LevelPattern().Match(line);
+ return match.Success ? match.Groups[1].Value.ToUpperInvariant() : "";
+ }
+
+ private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch
+ {
+ LogLevelFilter.All => true,
+ // Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine
+ // Fehlermeldung, die der Filter verschluckt.
+ _ when lineLevel.Length == 0 => true,
+ LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL",
+ LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL",
+ LogLevelFilter.Error => lineLevel is "ERR" or "FTL",
+ _ => true
+ };
+
+ ///
+ /// Das Verzeichnis des heutigen Tages, oder null.
+ ///
+ /// DateTime.Now und nicht UTC: Die Schreibseite legt die Verzeichnisse nach
+ /// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit
+ /// TZ=UTC wechselt der Ordner damit um Mitternacht UTC — richtig, aber
+ /// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht.
+ ///
+ private string? TodayDirectory()
+ {
+ if (!Directory.Exists(logDirectory)) return null;
+
+ var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
+ return Directory.Exists(directory) ? directory : null;
+ }
+}
diff --git a/Services/OpenRouterStatusService.cs b/src/ClawdDotNet.App/Services/OpenRouterStatusService.cs
similarity index 84%
rename from Services/OpenRouterStatusService.cs
rename to src/ClawdDotNet.App/Services/OpenRouterStatusService.cs
index cd1b059..44f5719 100644
--- a/Services/OpenRouterStatusService.cs
+++ b/src/ClawdDotNet.App/Services/OpenRouterStatusService.cs
@@ -4,12 +4,25 @@ using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
-namespace ClawdDotNet.Services;
+namespace ClawdDotNet.App.Services;
-public sealed class OpenRouterStatusService : IDisposable
+///
+/// Fragt regelmäßig Erreichbarkeit und Guthaben der OpenRouter-API ab.
+///
+/// Der Takt lief früher über einen System.Windows.Forms.Timer — der braucht eine
+/// Nachrichtenschleife und damit ein Fenster. Jetzt : läuft
+/// auch ohne Oberfläche, was der kopflose Betrieb voraussetzt.
+///
+/// wird auf einem Hintergrundfaden ausgelöst. Wer daran
+/// eine Oberfläche hängt, muss selbst auf den Oberflächenfaden wechseln — in Avalonia
+/// über Dispatcher.UIThread.
+///
+public sealed class OpenRouterStatusService : IAsyncDisposable
{
private readonly HttpClient _http;
- private readonly System.Windows.Forms.Timer _timer;
+ private readonly TimeSpan _interval;
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _loop;
private readonly ConcurrentBag _usageRecords = new();
@@ -40,17 +53,26 @@ public sealed class OpenRouterStatusService : IDisposable
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
- _timer = new System.Windows.Forms.Timer { Interval = checkIntervalSeconds * 1000 };
- _timer.Tick += async (_, _) => await CheckStatusAsync();
+ _interval = TimeSpan.FromSeconds(checkIntervalSeconds);
}
public void Start()
{
- _timer.Start();
+ _loop ??= RunLoopAsync(_cts.Token);
_ = CheckStatusAsync();
}
- public void Stop() => _timer.Stop();
+ private async Task RunLoopAsync(CancellationToken ct)
+ {
+ using var timer = new PeriodicTimer(_interval);
+
+ while (await timer.WaitForNextTickAsync(ct))
+ {
+ // CheckStatusAsync fängt bereits alles ab und setzt IsApiReachable — hier
+ // muss nichts mehr behandelt werden.
+ await CheckStatusAsync();
+ }
+ }
public void RecordUsage(string model, int promptTokens, int completionTokens)
{
@@ -238,10 +260,17 @@ public sealed class OpenRouterStatusService : IDisposable
CreditsTooltip = sb.ToString().TrimEnd();
}
- public void Dispose()
+ public async ValueTask DisposeAsync()
{
- _timer.Stop();
- _timer.Dispose();
+ await _cts.CancelAsync();
+
+ if (_loop is not null)
+ {
+ try { await _loop; }
+ catch (OperationCanceledException) { /* erwartet */ }
+ }
+
+ _cts.Dispose();
_http.Dispose();
}
diff --git a/src/ClawdDotNet.App/Settings/AppSettings.cs b/src/ClawdDotNet.App/Settings/AppSettings.cs
new file mode 100644
index 0000000..efdf126
--- /dev/null
+++ b/src/ClawdDotNet.App/Settings/AppSettings.cs
@@ -0,0 +1,153 @@
+using System.ComponentModel;
+using System.Text.Json.Serialization;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.App.Settings;
+
+///
+/// Anwendungsweite Einstellungen (nicht instanzgebunden).
+///
+/// Die -, - und
+/// -Angaben stammen aus der PropertyGrid-Zeit. Sie
+/// bleiben stehen: Sie sind die Beschriftungen und Hilfetexte, aus denen die
+/// Avalonia-Einstellungsansicht gebaut wird — nur eben von Hand statt automatisch.
+///
+public sealed class AppSettings
+{
+ // Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis.
+ // Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht
+ // schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde.
+ // Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths).
+ [Category("Allgemein")]
+ [DisplayName("Log-Verzeichnis")]
+ [Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
+ [JsonPropertyName("logDirectory")]
+ public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs");
+
+ [Category("Allgemein")]
+ [DisplayName("Instanzen-Verzeichnis")]
+ [Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
+ [JsonPropertyName("instancesDirectory")]
+ public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
+
+ // DefaultConfigPath ist ersatzlos entfallen. Die Eigenschaft war als „(Legacy)"
+ // markiert und wurde von keiner Stelle mehr gelesen — sie stand nur noch als
+ // relativer Pfad in der Datei und hätte unter Linux ohnehin ins Leere gezeigt.
+
+ [Category("Allgemein")]
+ [DisplayName("Minimaler Log-Level")]
+ [Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
+ [JsonPropertyName("minimumLogLevel")]
+ public string MinimumLogLevel { get; set; } = "Info";
+
+ [Category("UI")]
+ [DisplayName("Max. Log-Zeilen in UI")]
+ [Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
+ [JsonPropertyName("maxLogLinesInUi")]
+ public int MaxLogLinesInUi { get; set; } = 2000;
+
+ [Category("UI")]
+ [DisplayName("Log-Aktualisierungsintervall (ms)")]
+ [Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
+ [JsonPropertyName("logRefreshIntervalMs")]
+ public int LogRefreshIntervalMs { get; set; } = 500;
+
+ [Category("API")]
+ [DisplayName("Status-Check-Intervall (Sek)")]
+ [Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
+ [JsonPropertyName("statusCheckIntervalSeconds")]
+ public int StatusCheckIntervalSeconds { get; set; } = 60;
+
+ [Category("API")]
+ [DisplayName("OpenRouter Base-URL")]
+ [Description("Basis-URL der OpenRouter-API.")]
+ [JsonPropertyName("openRouterBaseUrl")]
+ public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
+
+ [Category("Backup")]
+ [DisplayName("Backup-Verzeichnis")]
+ [Description("Ordner, in dem Sicherungen abgelegt werden.")]
+ [JsonPropertyName("backupDirectory")]
+ public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups");
+
+ [Category("Backup")]
+ [DisplayName("Automatisch sichern")]
+ [Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")]
+ [JsonPropertyName("autoBackupEnabled")]
+ public bool AutoBackupEnabled { get; set; }
+
+ [Category("Backup")]
+ [DisplayName("Uhrzeit der automatischen Sicherung")]
+ [Description("Tageszeit im Format HH:mm.")]
+ [JsonPropertyName("autoBackupTime")]
+ public string AutoBackupTime { get; set; } = "03:00";
+
+ [Category("Backup")]
+ [DisplayName("Aufbewahrte Sicherungen")]
+ [Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")]
+ [JsonPropertyName("backupKeepCount")]
+ public int BackupKeepCount { get; set; } = 14;
+
+ // ─── Deploymentcenter ───
+ //
+ // Ein Server, ein Token. Lizenz, Watchdog, Updates, Fehler-Stream und Bugtracker
+ // sind Module derselben Anwendung — die frühere Aufteilung auf zwei Adressen
+ // (watchdog.mhdf.de, license.mhdf.de) mit je eigenem Schlüssel gibt es nicht mehr.
+
+ [Category("Deploymentcenter")]
+ [DisplayName("Server-URL")]
+ [Description("Basis-URL des Deploymentcenters (nur HTTPS, Ausnahme localhost).")]
+ [JsonPropertyName("deploymentcenterUrl")]
+ public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
+
+ [Category("Deploymentcenter")]
+ [DisplayName("Token")]
+ [Description("Master-Token mit den Rechten 'watchdog:ping' und 'bugtracker:report'. " +
+ "Jede Instanz tauscht es beim ersten Start gegen ein eigenes, " +
+ "eingeschränktes Sub-Token. Wird verschlüsselt gespeichert.")]
+ [PasswordPropertyText(true)]
+ [JsonPropertyName("deploymentcenterToken")]
+ public string DeploymentcenterToken { get; set; } = "";
+
+ [Category("Deploymentcenter")]
+ [DisplayName("Umgebung")]
+ [Description("Wird an Fehler- und Bugtracker-Meldungen gehängt: production oder development.")]
+ [JsonPropertyName("deploymentcenterEnvironment")]
+ public string DeploymentcenterEnvironment { get; set; } = "production";
+
+ [Category("Deploymentcenter")]
+ [DisplayName("Fehler automatisch melden")]
+ [Description("Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. " +
+ "Derselbe Fehler geht höchstens alle fünf Minuten einmal raus.")]
+ [JsonPropertyName("errorReportingEnabled")]
+ public bool ErrorReportingEnabled { get; set; } = true;
+
+ [Category("Deploymentcenter")]
+ [DisplayName("Beim Start auf Updates prüfen")]
+ [Description("Fragt einmalig beim Start, ob ein neueres Release vorliegt. Blockiert nicht.")]
+ [JsonPropertyName("updateCheckEnabled")]
+ public bool UpdateCheckEnabled { get; set; } = true;
+
+ [Category("Deploymentcenter")]
+ [DisplayName("Update-Kanal")]
+ [Description("prod, beta oder dev.")]
+ [JsonPropertyName("updateChannel")]
+ public string UpdateChannel { get; set; } = "prod";
+
+ // ─── Lizenz ───
+
+ [Category("Lizenz")]
+ [DisplayName("Lizenzschlüssel")]
+ [Description("Der Lizenzschlüssel für ClawdDotNet. Wird verschlüsselt gespeichert.")]
+ [PasswordPropertyText(true)]
+ [JsonPropertyName("licenseKey")]
+ public string LicenseKey { get; set; } = "";
+
+ // LicensePublicKeyBase64 und LicenseEndpoints sind entfallen. Das Deploymentcenter
+ // signiert seine Antworten nicht (siehe LicenseInfo), ein Public-Key hätte also
+ // nichts zu prüfen; und der Lizenzserver ist dasselbe Deploymentcenter, dessen
+ // Adresse oben steht — zwei Felder für eine Adresse waren nur eine Gelegenheit,
+ // sie widersprüchlich zu füllen.
+
+ public override string ToString() => "Anwendungseinstellungen";
+}
diff --git a/src/ClawdDotNet.App/Settings/SettingsManager.cs b/src/ClawdDotNet.App/Settings/SettingsManager.cs
new file mode 100644
index 0000000..0eb0116
--- /dev/null
+++ b/src/ClawdDotNet.App/Settings/SettingsManager.cs
@@ -0,0 +1,124 @@
+using System.Text.Json;
+using ClawdDotNet.Core.Security;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.App.Settings;
+
+///
+/// Lädt und speichert die anwendungsweiten Einstellungen.
+///
+/// Ablageort. Bis zur Linux-Portierung lag Settings.json neben der
+/// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt
+/// die Anwendung in /opt oder /usr/lib und ist für den Dienstbenutzer nicht
+/// beschreibbar. Jetzt entscheidet — XDG unter
+/// Linux, %APPDATA% unter Windows, per CLAWD_CONFIG_DIR überschreibbar.
+///
+/// Kein Migrationspfad. Bewusst: Zum Zeitpunkt der Umstellung lief noch
+/// keine Installation produktiv. Eine bestehende Settings.json neben der
+/// Programmdatei wird also nicht übernommen — der Ort wechselt einmal sauber,
+/// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst.
+///
+/// Keine Meldungsfenster. Diese Schicht kennt keine Oberfläche. Ein
+/// Speicherfehler kommt als heraus; ob daraus
+/// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer.
+///
+public sealed class SettingsManager
+{
+ private const string SettingsFileName = "Settings.json";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly string _settingsPath;
+
+ public AppSettings AppSettings { get; private set; } = new();
+
+ /// Der Ort der Einstellungsdatei — für Meldungen und Diagnose.
+ public string SettingsPath => _settingsPath;
+
+ public SettingsManager(string? basePath = null)
+ {
+ var dir = basePath ?? AppPaths.ConfigDirectory;
+ _settingsPath = Path.Combine(dir, SettingsFileName);
+ }
+
+ public void Load()
+ {
+ if (!File.Exists(_settingsPath))
+ {
+ AppSettings = new AppSettings();
+ Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
+ return;
+ }
+
+ try
+ {
+ var json = AtomicFile.ReadAllText(_settingsPath);
+ AppSettings = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? new AppSettings();
+ }
+ catch (Exception ex) when (ex is JsonException or IOException)
+ {
+ // Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben
+ // weiterzumachen ist besser, als gar nicht zu starten.
+ AppSettings = new AppSettings();
+ return;
+ }
+
+ // Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im
+ // Klartext gehalten.
+ AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey);
+ AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
+ }
+
+ ///
+ /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner,
+ /// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen
+ /// unbrauchbaren Wert an eine Gegenstelle zu schicken.
+ ///
+ private static string TryUnprotect(string value)
+ {
+ try { return SecretProtector.Unprotect(value) ?? ""; }
+ catch (SecretProtectionException) { return ""; }
+ }
+
+ /// Wenn die Datei nicht geschrieben werden kann.
+ public void Save()
+ {
+ // Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext.
+ var plainLicenseKey = AppSettings.LicenseKey;
+ var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken;
+
+ try
+ {
+ AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? "";
+ AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? "";
+
+ AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!);
+
+ var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
+ AtomicFile.WriteAllText(_settingsPath, json);
+ AppPaths.RestrictToOwner(_settingsPath);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
+ or SecretProtectionException)
+ {
+ throw new SettingsPersistenceException(
+ $"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}",
+ ex);
+ }
+ finally
+ {
+ AppSettings.LicenseKey = plainLicenseKey;
+ AppSettings.DeploymentcenterToken = plainDeploymentcenterToken;
+ }
+ }
+}
+
+public sealed class SettingsPersistenceException(string message, Exception inner)
+ : Exception(message, inner);
diff --git a/src/ClawdDotNet.Core/Audit/AuditModels.cs b/src/ClawdDotNet.Core/Audit/AuditModels.cs
new file mode 100644
index 0000000..86cb984
--- /dev/null
+++ b/src/ClawdDotNet.Core/Audit/AuditModels.cs
@@ -0,0 +1,110 @@
+namespace ClawdDotNet.Core.Audit;
+
+/// Ausgang eines Tool-Aufrufs, von der Engine festgestellt.
+public enum AuditStatus
+{
+ /// Tool lief und lieferte ein Ergebnis.
+ Ok,
+
+ /// Tool meldete einen Fehler oder warf eine Ausnahme.
+ Error,
+
+ /// Das hat den Aufruf abgelehnt.
+ Denied,
+
+ /// Das angeforderte Tool ist dem Agenten nicht zugewiesen/unbekannt.
+ NotFound,
+
+ /// Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.
+ Staged
+}
+
+///
+/// Ein Eintrag im Audit-Log: ein einzelner Tool-Aufruf, wie die Engine ihn gesehen hat.
+///
+/// Die Herkunft wird von der Engine gestempelt, nie vom Agenten behauptet:
+/// , und stammen aus dem
+/// Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Einträge sind
+/// unveränderlich — eine Korrektur ist ein neuer Eintrag, kein Überschreiben.
+///
+public sealed record AuditEntry
+{
+ public long Id { get; init; }
+
+ /// Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.
+ public string RunId { get; init; } = "";
+
+ public string AgentId { get; init; } = "";
+
+ /// Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).
+ public string Model { get; init; } = "";
+
+ /// Verantwortliche Session/Kanal (webview, telegram, task …). unknown,
+ /// wenn nicht bekannt — geraten wird nichts.
+ public string Source { get; init; } = AuditSource.Unknown;
+
+ public string Tool { get; init; } = "";
+
+ /// Übergebene Argumente (gekappt). Rohdaten, wie das Modell sie schickte.
+ public string Arguments { get; init; } = "";
+
+ public AuditStatus Status { get; init; }
+
+ /// Kurze Notiz zum Ausgang (Fehlermeldung, knapper Hinweis).
+ public string Summary { get; init; } = "";
+
+ public long DurationMs { get; init; }
+
+ public DateTime OccurredAt { get; init; }
+}
+
+///
+/// Abschluss-Beleg eines Laufs (Receipt): das Ergebnis mit Schritten, Tokens und Kosten.
+/// Verknüpft RunUsage mit einem Task und macht so C7 („Kosten pro Ergebnis")
+/// weitgehend zum Abfallprodukt.
+///
+public sealed record RunReceipt
+{
+ public long Id { get; init; }
+ public string RunId { get; init; } = "";
+ public string AgentId { get; init; } = "";
+ public string Model { get; init; } = "";
+ public string Source { get; init; } = AuditSource.Unknown;
+
+ /// Verknüpfter Task, falls der Lauf aus dem Taskboard kam — sonst null.
+ public string? TaskId { get; init; }
+
+ /// Endzustand (aus AgentRunStatus).
+ public string Status { get; init; } = "";
+
+ public int StepCount { get; init; }
+ public int PromptTokens { get; init; }
+ public int CompletionTokens { get; init; }
+ public int CachedTokens { get; init; }
+
+ public decimal CostUsd { get; init; }
+ public bool CostIsKnown { get; init; }
+
+ public long DurationMs { get; init; }
+
+ /// Kurzer Verweis auf das Ergebnis (gekappte Schlussnachricht).
+ public string ResultRef { get; init; } = "";
+
+ public DateTime OccurredAt { get; init; }
+}
+
+/// Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit
+/// ChatSource; steht für „nicht bekannt", nicht für geraten.
+public static class AuditSource
+{
+ public const string Unknown = "unknown";
+
+ /// Ein direkter, quellenloser Lauf (z. B. RunAsync ohne Kanal).
+ public const string Direct = "direct";
+
+ /// Ausführung eines freigegebenen, eingefrorenen Aufrufs (A2).
+ public const string Approval = "approval";
+
+ public static string Normalize(string? source)
+ => string.IsNullOrWhiteSpace(source) ? Unknown : source.Trim();
+}
diff --git a/src/ClawdDotNet.Core/Audit/IAuditRepository.cs b/src/ClawdDotNet.Core/Audit/IAuditRepository.cs
new file mode 100644
index 0000000..508f258
--- /dev/null
+++ b/src/ClawdDotNet.Core/Audit/IAuditRepository.cs
@@ -0,0 +1,29 @@
+namespace ClawdDotNet.Core.Audit;
+
+///
+/// Das Audit-Log (A3): append-only. Es gibt kein Ändern und kein Löschen — Korrekturen
+/// sind neue Einträge. Das ist die bewusste Designregel, nicht eine fehlende Funktion.
+///
+public interface IAuditRepository
+{
+ /// Schreibt einen Tool-Aufruf ins Log.
+ Task AppendAsync(AuditEntry entry, CancellationToken ct);
+
+ /// Hält den Abschluss-Beleg eines Laufs fest.
+ Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct);
+
+ /// Die jüngsten Log-Einträge (für eine Übersicht/Diagnose).
+ Task> ListRecentAsync(int limit, CancellationToken ct);
+
+ /// Alle Aufrufe eines Laufs, in zeitlicher Reihenfolge.
+ Task> ListForRunAsync(string runId, CancellationToken ct);
+
+ /// Der Abschluss-Beleg eines Laufs, falls vorhanden.
+ Task GetReceiptForRunAsync(string runId, CancellationToken ct);
+
+ /// Alle Belege zu einem Task — die Kosten pro Ergebnis (C7).
+ Task> ListReceiptsForTaskAsync(string taskId, CancellationToken ct);
+
+ /// Zahl der Log-Einträge insgesamt.
+ Task CountAsync(CancellationToken ct);
+}
diff --git a/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs
new file mode 100644
index 0000000..fda88f0
--- /dev/null
+++ b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs
@@ -0,0 +1,182 @@
+using System.Globalization;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Data.Sqlite;
+
+namespace ClawdDotNet.Core.Audit;
+
+///
+/// Das Audit-Log in der Instanz-Datenbank. Bewusst nur Einfügen und Lesen — es gibt keine
+/// Update-/Delete-Methoden, weil die Unveränderlichkeit die eigentliche Zusage ist.
+///
+public sealed class SqliteAuditRepository : IAuditRepository
+{
+ private readonly SqliteStorage _storage;
+
+ public SqliteAuditRepository(SqliteStorage storage) => _storage = storage;
+
+ public Task AppendAsync(AuditEntry entry, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO AuditLog
+ (RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
+ VALUES
+ (@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
+ """;
+ cmd.Parameters.AddWithValue("@runId", entry.RunId);
+ cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
+ cmd.Parameters.AddWithValue("@model", entry.Model);
+ cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
+ cmd.Parameters.AddWithValue("@tool", entry.Tool);
+ cmd.Parameters.AddWithValue("@arguments", entry.Arguments);
+ cmd.Parameters.AddWithValue("@status", entry.Status.ToString());
+ cmd.Parameters.AddWithValue("@summary", entry.Summary);
+ cmd.Parameters.AddWithValue("@durationMs", entry.DurationMs);
+ cmd.Parameters.AddWithValue("@occurredAt", Format(entry.OccurredAt));
+ await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO RunReceipts
+ (RunId, AgentId, Model, Source, TaskId, Status, StepCount,
+ PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown,
+ DurationMs, ResultRef, OccurredAt)
+ VALUES
+ (@runId, @agentId, @model, @source, @taskId, @status, @stepCount,
+ @prompt, @completion, @cached, @cost, @costKnown,
+ @durationMs, @resultRef, @occurredAt)
+ """;
+ cmd.Parameters.AddWithValue("@runId", receipt.RunId);
+ cmd.Parameters.AddWithValue("@agentId", receipt.AgentId);
+ cmd.Parameters.AddWithValue("@model", receipt.Model);
+ cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(receipt.Source));
+ cmd.Parameters.AddWithValue("@taskId", (object?)receipt.TaskId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@status", receipt.Status);
+ cmd.Parameters.AddWithValue("@stepCount", receipt.StepCount);
+ cmd.Parameters.AddWithValue("@prompt", receipt.PromptTokens);
+ cmd.Parameters.AddWithValue("@completion", receipt.CompletionTokens);
+ cmd.Parameters.AddWithValue("@cached", receipt.CachedTokens);
+ cmd.Parameters.AddWithValue("@cost", receipt.CostUsd.ToString(CultureInfo.InvariantCulture));
+ cmd.Parameters.AddWithValue("@costKnown", receipt.CostIsKnown ? 1 : 0);
+ cmd.Parameters.AddWithValue("@durationMs", receipt.DurationMs);
+ cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef);
+ cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt));
+ await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ public async Task> ListRecentAsync(int limit, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog ORDER BY Id DESC LIMIT @limit";
+ cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
+ return await ReadEntriesAsync(cmd, ct);
+ }
+
+ public async Task> ListForRunAsync(string runId, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id";
+ cmd.Parameters.AddWithValue("@runId", runId);
+ return await ReadEntriesAsync(cmd, ct);
+ }
+
+ public async Task GetReceiptForRunAsync(string runId, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1";
+ cmd.Parameters.AddWithValue("@runId", runId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null;
+ }
+
+ public async Task> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE TaskId = @taskId ORDER BY Id";
+ cmd.Parameters.AddWithValue("@taskId", taskId);
+
+ var results = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ results.Add(ReadReceipt(reader));
+ return results;
+ }
+
+ public async Task CountAsync(CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM AuditLog";
+ return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private const string AuditColumns =
+ "Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt";
+
+ private const string ReceiptColumns =
+ "Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
+ "CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt";
+
+ private static async Task> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct)
+ {
+ var results = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ results.Add(ReadEntry(reader));
+ return results;
+ }
+
+ private static AuditEntry ReadEntry(SqliteDataReader r) => new()
+ {
+ Id = r.GetInt64(0),
+ RunId = r.GetString(1),
+ AgentId = r.GetString(2),
+ Model = r.GetString(3),
+ Source = r.GetString(4),
+ Tool = r.GetString(5),
+ Arguments = r.GetString(6),
+ Status = Enum.TryParse(r.GetString(7), out var s) ? s : AuditStatus.Ok,
+ Summary = r.GetString(8),
+ DurationMs = r.GetInt64(9),
+ OccurredAt = Parse(r.GetString(10))
+ };
+
+ private static RunReceipt ReadReceipt(SqliteDataReader r) => new()
+ {
+ Id = r.GetInt64(0),
+ RunId = r.GetString(1),
+ AgentId = r.GetString(2),
+ Model = r.GetString(3),
+ Source = r.GetString(4),
+ TaskId = r.IsDBNull(5) ? null : r.GetString(5),
+ Status = r.GetString(6),
+ StepCount = r.GetInt32(7),
+ PromptTokens = r.GetInt32(8),
+ CompletionTokens = r.GetInt32(9),
+ CachedTokens = r.GetInt32(10),
+ CostUsd = decimal.TryParse(r.GetString(11), NumberStyles.Any, CultureInfo.InvariantCulture, out var c) ? c : 0m,
+ CostIsKnown = r.GetInt32(12) != 0,
+ DurationMs = r.GetInt64(13),
+ ResultRef = r.GetString(14),
+ OccurredAt = Parse(r.GetString(15))
+ };
+
+ private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+
+ private static DateTime Parse(string value)
+ => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
+ ? dt
+ : DateTime.MinValue;
+}
diff --git a/src/ClawdDotNet.Core/Backup/BackupService.cs b/src/ClawdDotNet.Core/Backup/BackupService.cs
index 9e3f774..0547ee0 100644
--- a/src/ClawdDotNet.Core/Backup/BackupService.cs
+++ b/src/ClawdDotNet.Core/Backup/BackupService.cs
@@ -377,11 +377,10 @@ public sealed class BackupService
var root = Path.GetFullPath(targetDir);
var full = Path.GetFullPath(Path.Combine(root, relative));
- var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
- ? root
- : root + Path.DirectorySeparatorChar;
-
- if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
+ // Der Vergleich muss dem Dateisystem folgen: Unter Linux sind "Ziel" und "ziel"
+ // zwei Verzeichnisse, und ein Eintrag darf auch nicht über eine symbolische
+ // Verknüpfung hinauszeigen. Beides steckt in PathBoundary.
+ if (!PathBoundary.IsInside(full, root))
throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus.");
return full;
diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs
index ef22d28..b7ae115 100644
--- a/src/ClawdDotNet.Core/Config/AgentConfig.cs
+++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs
@@ -188,6 +188,14 @@ public sealed class LoopGuardConfig
[JsonPropertyName("maxContextTokens")]
public int MaxContextTokens { get; set; } = 100_000;
+ ///
+ /// Obergrenze für die Ausgabe eines einzelnen Schritts (max_tokens im Request).
+ /// Deckelt die teuerste Token-Art gegen Ausreißer (B11/T8). 0 = keine Angabe, dann gilt
+ /// der Standard des Anbieters.
+ ///
+ [JsonPropertyName("maxResponseTokens")]
+ public int MaxResponseTokens { get; set; } = 8_192;
+
[JsonPropertyName("compactionThreshold")]
public double CompactionThreshold { get; set; } = 0.80;
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index 47ef240..1d57e87 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -25,6 +25,10 @@ public sealed class InstanceConfig
[JsonPropertyName("telegramClient")]
public TelegramClientConfig? TelegramClient { get; set; }
+ /// Anbindung an das Watchdog-Modul des Deploymentcenters (Instanz-Heartbeat).
+ [JsonPropertyName("watchdog")]
+ public WatchdogConfig Watchdog { get; set; } = new();
+
/// Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze.
[JsonPropertyName("budget")]
public InstanceBudget Budget { get; set; } = new();
diff --git a/src/ClawdDotNet.Core/Config/ServiceConfig.cs b/src/ClawdDotNet.Core/Config/ServiceConfig.cs
index f3286ea..30d8f6f 100644
--- a/src/ClawdDotNet.Core/Config/ServiceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/ServiceConfig.cs
@@ -34,6 +34,7 @@ public static class BuiltInServices
public const string AgentChatWebUI = "AgentChatWebUI";
public const string AgentWebsite = "AgentWebsite";
public const string ClawdDotNetApi = "ClawdDotNetApi";
+ public const string InstanceWatchdog = "InstanceWatchdog";
public static List CreateDefaults() =>
[
@@ -69,6 +70,17 @@ public static class BuiltInServices
AutoStart = true,
BuiltIn = true,
Description = "REST-API für die Kommunikation mit der WebApp"
+ },
+ new()
+ {
+ ServiceId = "svc_watchdog",
+ Name = "Instanz-Watchdog",
+ Type = InstanceWatchdog,
+ Port = 0,
+ Enabled = false,
+ AutoStart = true,
+ BuiltIn = true,
+ Description = "Sendet Heartbeats ans Deploymentcenter (URL/Token in den Anwendungseinstellungen)"
}
];
}
diff --git a/src/ClawdDotNet.Core/Config/WatchdogConfig.cs b/src/ClawdDotNet.Core/Config/WatchdogConfig.cs
new file mode 100644
index 0000000..3de7e50
--- /dev/null
+++ b/src/ClawdDotNet.Core/Config/WatchdogConfig.cs
@@ -0,0 +1,60 @@
+using System.Text.Json.Serialization;
+
+namespace ClawdDotNet.Core.Config;
+
+///
+/// Pro-Instanz-Teil der Watchdog-Anbindung ans Deploymentcenter.
+///
+/// Ein Monitor je Instanz. Der Monitor wird serverseitig über das Paar
+/// source + instance geführt. Alle Instanzen melden unter derselben
+/// und tragen ihre eigene — damit hat jede
+/// laufende Instanz einen eigenen Zustand, ein eigenes Intervall und einen eigenen
+/// Metrik-Verlauf. Fällt eine von dreien aus, fällt genau deren Monitor.
+///
+/// Server-URL und das anwendungsweite Token liegen in den Anwendungseinstellungen.
+/// Beim ersten Start tauscht die Instanz das Token gegen ein eigenes, eingeschränktes
+/// Sub-Token (/api/tokens/v1/provision) und legt es hier verschlüsselt ab —
+/// danach liegt auf der Instanz nicht mehr das Master-Token.
+///
+/// Ein/Aus läuft über den eingebauten Dienst InstanceWatchdog.
+///
+public sealed class WatchdogConfig
+{
+ /// Dienst-Kennung im Deploymentcenter. Alle Instanzen teilen sich dieselbe Source.
+ [JsonPropertyName("source")]
+ public string Source { get; set; } = "clawddotnet";
+
+ ///
+ /// Name dieser Instanz im Monitor. Leer bedeutet: die InstanceId wird
+ /// verwendet — stabil, aber im Dashboard nichtssagend. Wer lesbare Namen möchte,
+ /// trägt hier einen ein; ein späterer Wechsel legt allerdings einen neuen Monitor an.
+ ///
+ [JsonPropertyName("instance")]
+ public string Instance { get; set; } = "";
+
+ /// Gruppierung im Dashboard (reine Anzeige, keine Hierarchie).
+ [JsonPropertyName("group")]
+ public string Group { get; set; } = "ClawdDotNet";
+
+ ///
+ /// Sende-Takt in Sekunden. Daraus leitet der Evaluator die Schwellen ab:
+ /// nach dem Doppelten warning, nach dem Vierfachen down.
+ ///
+ [JsonPropertyName("intervalSeconds")]
+ public int IntervalSeconds { get; set; } = 60;
+
+ ///
+ /// Das für diese Instanz ausgestellte Sub-Token. Wird automatisch gesetzt und
+ /// verschlüsselt gespeichert.
+ ///
+ [JsonPropertyName("agentToken")]
+ public string AgentToken { get; set; } = "";
+
+ /// True, sobald ein eigenes Token vorliegt.
+ [JsonIgnore]
+ public bool HasToken => !string.IsNullOrWhiteSpace(AgentToken);
+
+ /// Der Wert, der als instance gemeldet wird.
+ public string ResolveInstance(string instanceId) =>
+ string.IsNullOrWhiteSpace(Instance) ? instanceId : Instance.Trim();
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs b/src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs
new file mode 100644
index 0000000..3ca3648
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+
+namespace ClawdDotNet.Core.Deploymentcenter;
+
+/// Was aus einem Bugtracker-Eintrag geworden ist.
+/// Nummer des Eintrags im Deploymentcenter.
+/// False, wenn ein bestehender Eintrag hochgezählt wurde.
+/// Wie oft dieses Vorkommnis bisher gezählt wurde.
+/// Adresse der Übersicht, für einen Hinweis an den Benutzer.
+public sealed record BugtrackerReport(long ItemId, bool IsNew, int OccurrenceCount, string? Url);
+
+/// Art eines Eintrags. Der Server kennt darüber hinaus noch idea.
+public static class BugtrackerItemType
+{
+ public const string Bug = "bug";
+ public const string Feature = "feature";
+ public const string Idea = "idea";
+}
+
+///
+/// Anbindung an POST /api/bugtracker/v1/report — der Weg, auf dem ClawdDotNet
+/// selbst (oder ein Benutzer über die Oberfläche) einen Fehler oder Wunsch einträgt.
+///
+/// Abgegrenzt vom : Der meldet ungefangene
+/// Ausnahmen automatisch; hier geht es um bewusst formulierte Einträge mit Titel und
+/// Beschreibung. Serverseitig landen beide in derselben Tabelle — was richtig ist,
+/// denn ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste.
+///
+/// Der Absender kommt aus dem Token und lässt sich nicht frei wählen —
+/// sonst könnte sich ein Agent als ein anderer ausgeben.
+///
+public sealed class BugtrackerClient(
+ DeploymentcenterApi api, string projectSlug, string environment, string build)
+{
+ /// Siehe .
+ ///
+ /// Freier Idempotenz-Schlüssel. Zweimal derselbe Wert erzeugt keinen zweiten
+ /// Eintrag — nützlich, wenn eine Meldung nach einem Verbindungsabbruch wiederholt
+ /// wird.
+ ///
+ public async Task ReportAsync(
+ string type,
+ string title,
+ string? description = null,
+ string severity = "medium",
+ string? clientRef = null,
+ IReadOnlyDictionary? context = null,
+ CancellationToken ct = default)
+ {
+ var payload = new Dictionary
+ {
+ ["project_slug"] = projectSlug,
+ ["type"] = type,
+ ["title"] = title,
+ ["description"] = description,
+ ["severity"] = severity,
+ ["environment"] = environment,
+ ["build_version"] = build
+ };
+
+ if (!string.IsNullOrWhiteSpace(clientRef))
+ payload["client_ref"] = clientRef;
+
+ if (context is { Count: > 0 })
+ payload["context"] = context;
+
+ var response = await api.PostAsync("/api/bugtracker/v1/report", payload, ct)
+ .ConfigureAwait(false);
+
+ return new BugtrackerReport(
+ ReadLong(response, "item_id"),
+ ReadBool(response, "is_new"),
+ (int)ReadLong(response, "occurrence_count"),
+ ReadString(response, "url"));
+ }
+
+ private static long ReadLong(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
+ ? v.GetInt64()
+ : 0;
+
+ private static bool ReadBool(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
+
+ private static string? ReadString(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString()
+ : null;
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs b/src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs
new file mode 100644
index 0000000..19fd3d5
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs
@@ -0,0 +1,172 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+
+namespace ClawdDotNet.Core.Deploymentcenter;
+
+///
+/// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream,
+/// Bugtracker, Token-Provisionierung).
+///
+/// Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag —
+/// {"status":"success",…} bzw. {"status":"error","error":{"code":…}}. Der
+/// code ist stabil und für Programme gedacht, die message für Menschen.
+/// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine
+/// mit dem Code daran.
+///
+/// Ausnahme: Die Lizenz-Endpunkte tragen diesen Umschlag bewusst
+/// nicht — dort steht im Feld status der Lizenzzustand. Sie werden
+/// deshalb nicht hierüber, sondern über Deploymentcenter.Client angesprochen.
+///
+public sealed class DeploymentcenterApi : IDisposable
+{
+ private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
+
+ private readonly HttpClient _http;
+ private readonly bool _ownsHttp;
+ private readonly string _token;
+
+ /// Die Basis-URL ohne abschließenden Schrägstrich.
+ public string BaseUrl { get; }
+
+ /// Basis-URL des Deploymentcenters, etwa https://dc.mhdf.de.
+ /// Token mit den nötigen Rechten. Geht als Authorization: Bearer mit.
+ /// Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt.
+ public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null)
+ {
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl));
+
+ // Über eine ungesicherte Verbindung ginge das Token im Klartext. Ausnahme ist
+ // nur der eigene Rechner — dort gibt es keine Strecke, auf der jemand mithören
+ // könnte, und eine lokale Testinstallation hat selten ein Zertifikat.
+ if (!IsAcceptableUrl(baseUrl))
+ {
+ throw new ArgumentException(
+ "Deploymentcenter-URL muss mit https:// beginnen (Ausnahme: localhost).",
+ nameof(baseUrl));
+ }
+
+ BaseUrl = baseUrl.TrimEnd('/');
+ _token = token ?? throw new ArgumentNullException(nameof(token));
+
+ _ownsHttp = httpClient is null;
+ _http = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ }
+
+ private static bool IsAcceptableUrl(string url)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ return false;
+
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ return true;
+
+ return uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback;
+ }
+
+ public async Task PostAsync(string path, object payload, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + path)
+ {
+ Content = new StringContent(
+ JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json")
+ };
+
+ return await SendAsync(request, ct).ConfigureAwait(false);
+ }
+
+ public async Task GetAsync(string path, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path);
+ return await SendAsync(request, ct).ConfigureAwait(false);
+ }
+
+ private async Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ if (_token.Length > 0)
+ request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _token);
+
+ using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+
+ JsonElement root;
+ try
+ {
+ // Geklont, weil das JsonDocument am Ende dieses Blocks freigegeben wird —
+ // ein JsonElement daraus wäre danach nicht mehr lesbar.
+ using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
+ root = document.RootElement.Clone();
+ }
+ catch (JsonException)
+ {
+ throw new DeploymentcenterException(
+ "invalid_response",
+ $"Antwort war kein JSON (HTTP {(int)response.StatusCode}).",
+ response.StatusCode);
+ }
+
+ if (IsErrorEnvelope(root, out var code, out var message))
+ throw new DeploymentcenterException(code, message, response.StatusCode);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new DeploymentcenterException(
+ "http_error",
+ $"Deploymentcenter antwortete HTTP {(int)response.StatusCode}.",
+ response.StatusCode);
+ }
+
+ return root;
+ }
+
+ private static bool IsErrorEnvelope(JsonElement root, out string code, out string message)
+ {
+ code = "error";
+ message = "Unbekannter Fehler.";
+
+ if (root.ValueKind != JsonValueKind.Object)
+ return false;
+
+ if (!root.TryGetProperty("status", out var status)
+ || status.ValueKind != JsonValueKind.String
+ || !string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
+ {
+ if (error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String)
+ code = c.GetString() ?? code;
+
+ if (error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
+ message = m.GetString() ?? message;
+ }
+
+ return true;
+ }
+
+ public void Dispose()
+ {
+ if (_ownsHttp)
+ _http.Dispose();
+ }
+}
+
+///
+/// Ein vom Deploymentcenter abgelehnter Aufruf. ist der stabile
+/// Fehlercode aus dem Umschlag (unauthorized, rate_limited, …) — er ist
+/// zum Auswerten gedacht, der Text nicht.
+///
+public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode)
+ : Exception($"{message} [{code}]")
+{
+ public string Code { get; } = code;
+
+ public HttpStatusCode StatusCode { get; } = statusCode;
+
+ /// Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab.
+ public bool IsAuthorizationProblem =>
+ StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs b/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs
new file mode 100644
index 0000000..75e475c
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs
@@ -0,0 +1,170 @@
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Deploymentcenter;
+
+/// Meldet Laufzeitfehler an das Deploymentcenter.
+public interface IErrorReporter
+{
+ ///
+ /// Meldet eine Ausnahme. heißt: Der Prozess endet daran.
+ /// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen.
+ ///
+ Task ReportAsync(Exception exception, bool fatal = false,
+ IReadOnlyDictionary? context = null, CancellationToken ct = default);
+}
+
+/// Tut nichts. Für abgeschaltete Meldung und für Tests.
+public sealed class NullErrorReporter : IErrorReporter
+{
+ public static readonly NullErrorReporter Instance = new();
+
+ public Task ReportAsync(Exception exception, bool fatal = false,
+ IReadOnlyDictionary? context = null, CancellationToken ct = default)
+ => Task.FromResult(false);
+}
+
+///
+/// Anbindung an POST /api/errors/v1/report — den Eingang für den globalen
+/// Ausnahmebehandler.
+///
+/// Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die
+/// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter,
+/// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt
+/// Alarm, wenn er plötzlich hundertmal so oft auftritt.
+///
+/// Eigener Schutz gegen Fehlerschleifen. Der Server drosselt auf 300
+/// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler
+/// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das
+/// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server
+/// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung.
+///
+public sealed class ErrorReporter : IErrorReporter, IDisposable
+{
+ /// Wie lange derselbe Fehler nach einer Meldung stumm bleibt.
+ private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5);
+
+ /// Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab.
+ private const int MaxStackTraceLength = 8000;
+
+ private readonly DeploymentcenterApi _api;
+ private readonly bool _ownsApi;
+ private readonly string _projectSlug;
+ private readonly string _environment;
+ private readonly string _build;
+ private readonly ILogger _logger;
+ private readonly Func _now;
+
+ private readonly Dictionary _lastSent = [];
+ private readonly Lock _gate = new();
+
+ public ErrorReporter(
+ DeploymentcenterApi api,
+ string projectSlug,
+ string environment,
+ string build,
+ ILogger logger,
+ bool ownsApi = false,
+ Func? now = null)
+ {
+ _api = api;
+ _projectSlug = projectSlug;
+ _environment = environment;
+ _build = build;
+ _logger = logger;
+ _ownsApi = ownsApi;
+ _now = now ?? (() => DateTimeOffset.UtcNow);
+ }
+
+ public async Task ReportAsync(Exception exception, bool fatal = false,
+ IReadOnlyDictionary? context = null, CancellationToken ct = default)
+ {
+ if (!ShouldSend(exception))
+ return false;
+
+ var payload = new Dictionary
+ {
+ ["project_slug"] = _projectSlug,
+ ["exception"] = exception.GetType().FullName,
+ ["message"] = exception.Message,
+ ["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength),
+ ["level"] = fatal ? "fatal" : "error",
+ ["build"] = _build,
+ ["environment"] = _environment,
+
+ // Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der
+ // Server den Zähler, statt einen zweiten Eintrag anzulegen.
+ ["client_ref"] = Fingerprint(exception)
+ };
+
+ if (context is { Count: > 0 })
+ payload["context"] = context;
+
+ if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType)
+ payload["file"] = declaringType;
+
+ try
+ {
+ await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten:
+ // Der ursprüngliche Fehler ginge dabei verloren.
+ _logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert).");
+ return false;
+ }
+ }
+
+ /// Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet.
+ private bool ShouldSend(Exception exception)
+ {
+ var key = Fingerprint(exception);
+ var now = _now();
+
+ lock (_gate)
+ {
+ if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow)
+ return false;
+
+ // Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst.
+ if (_lastSent.Count > 200)
+ {
+ foreach (var stale in _lastSent
+ .Where(e => now - e.Value > RepeatWindow)
+ .Select(e => e.Key)
+ .ToList())
+ {
+ _lastSent.Remove(stale);
+ }
+ }
+
+ _lastSent[key] = now;
+ return true;
+ }
+ }
+
+ ///
+ /// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung
+ /// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und
+ /// dann wäre jeder Aufruf ein neuer Fehler.
+ ///
+ private static string Fingerprint(Exception exception)
+ {
+ var frame = exception.StackTrace?
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault()?
+ .Trim() ?? "";
+
+ return $"{exception.GetType().FullName}|{frame}";
+ }
+
+ private static string Truncate(string value, int max) =>
+ value.Length <= max ? value : value[..max] + "\n… (gekürzt)";
+
+ public void Dispose()
+ {
+ if (_ownsApi)
+ _api.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs b/src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs
new file mode 100644
index 0000000..4ea743c
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs
@@ -0,0 +1,73 @@
+using System.Text.Json;
+
+namespace ClawdDotNet.Core.Deploymentcenter;
+
+/// Ein für diese Instanz ausgestelltes Sub-Token.
+/// Der Klartext — wird nur einmal ausgeliefert.
+/// Kennung zum Widerrufen in der Verwaltung.
+/// Welche Rechte tatsächlich durchgereicht wurden.
+public sealed record ProvisionedToken(string Token, string TokenId, IReadOnlyList Scopes);
+
+///
+/// Tauscht das anwendungsweite Master-Token gegen ein eigenes Sub-Token je Instanz
+/// (POST /api/tokens/v1/provision).
+///
+/// Das ersetzt die frühere Selbstregistrierung über POST /api/register —
+/// diesen Endpunkt gibt es im Deploymentcenter nicht (und im alten WatchDog-Server war
+/// er der einzige Weg, überhaupt an einen Token zu kommen). Der Zweck bleibt derselbe
+/// und ist es wert, erhalten zu bleiben: Auf den Instanzen liegt danach nicht das
+/// Master-Token, sondern ein eingeschränktes, einzeln widerrufbares.
+///
+/// Rechte lassen sich dabei nur einschränken, nie erweitern — was das
+/// Master-Token nicht hat, bekommt auch das Sub-Token nicht.
+///
+public sealed class TokenProvisioner(DeploymentcenterApi api)
+{
+ /// Was eine Instanz braucht: Heartbeats senden und Fehler melden.
+ public static readonly string[] InstanceScopes = ["watchdog:ping", "bugtracker:report"];
+
+ public async Task ProvisionAsync(
+ string clientName,
+ string instanceId,
+ IReadOnlyList scopes,
+ string environment = "production",
+ CancellationToken ct = default)
+ {
+ var payload = new
+ {
+ client_name = clientName,
+ instance_id = instanceId,
+ scopes,
+ environment
+ };
+
+ var response = await api.PostAsync("/api/tokens/v1/provision", payload, ct)
+ .ConfigureAwait(false);
+
+ var token = response.TryGetProperty("sub_token", out var t) && t.ValueKind == JsonValueKind.String
+ ? t.GetString()
+ : null;
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new DeploymentcenterException(
+ "no_token",
+ "Die Provisionierung lieferte kein Token.",
+ System.Net.HttpStatusCode.OK);
+ }
+
+ var tokenId = response.TryGetProperty("token_id", out var i) && i.ValueKind == JsonValueKind.String
+ ? i.GetString() ?? ""
+ : "";
+
+ var granted = new List();
+ if (response.TryGetProperty("scopes", out var s) && s.ValueKind == JsonValueKind.Array)
+ {
+ granted.AddRange(s.EnumerateArray()
+ .Where(e => e.ValueKind == JsonValueKind.String)
+ .Select(e => e.GetString()!));
+ }
+
+ return new ProvisionedToken(token, tokenId, granted);
+ }
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs
new file mode 100644
index 0000000..627b491
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs
@@ -0,0 +1,54 @@
+namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
+
+///
+/// Die Statuswerte, die der Watchdog kennt. stopped und maintenance sind
+/// angekündigte Zustände — der Evaluator lässt solche Monitore in Ruhe, statt wenige
+/// Minuten nach einem geplanten Herunterfahren einen Fehlalarm zu erzeugen.
+///
+public static class WatchdogStatus
+{
+ public const string Ok = "ok";
+ public const string Warning = "warning";
+ public const string Error = "error";
+ public const string Stopped = "stopped";
+ public const string Maintenance = "maintenance";
+}
+
+///
+/// Eine selbst ermittelte Teilprüfung. Das Deploymentcenter interpretiert den Namen
+/// nicht — es liest nur und . Was „gesund"
+/// bedeutet, entscheidet damit jede Anwendung selbst.
+///
+/// Schlägt eine Prüfung fehl, stuft der Server einen als ok gemeldeten
+/// Heartbeat auf warning herab. Das ist der Unterschied zwischen „ein Faden
+/// läuft" und „die Anwendung tut, was sie soll".
+///
+public sealed record HealthCheck(bool Ok, string? Message = null);
+
+/// Momentaufnahme des Instanz-Zustands für einen Heartbeat.
+/// Einer der Werte aus .
+/// Kurzbegründung, erscheint im Dashboard.
+///
+/// Nur Zahlen: Das Deploymentcenter legt sie mit Zeitstempel ab (14 Tage) und vergleicht
+/// den aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische
+/// Werte würden dabei stillschweigend verworfen — beschreibende Angaben gehören
+/// deshalb in oder in die Checks.
+///
+/// Selbst ermittelter Gesundheitszustand je Teilbereich.
+public sealed record InstanceHealth(
+ string Status,
+ string? Message,
+ IReadOnlyDictionary Metrics,
+ IReadOnlyDictionary Checks)
+{
+ public static InstanceHealth Ok(string? message = null) => new(
+ WatchdogStatus.Ok, message,
+ new Dictionary(),
+ new Dictionary());
+}
+
+/// Liefert vor jedem Heartbeat den aktuellen Instanz-Zustand.
+public interface IInstanceHealthProvider
+{
+ Task GetAsync(CancellationToken ct);
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs
new file mode 100644
index 0000000..e4100ea
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs
@@ -0,0 +1,128 @@
+using ClawdDotNet.Core.Accounting;
+using ClawdDotNet.Core.Config;
+
+namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
+
+///
+/// Leitet den Instanz-Zustand für den Heartbeat ab.
+///
+/// Ein Heartbeat allein beweist nur, dass ein Faden läuft. Deshalb geht der
+/// selbst ermittelte Gesundheitszustand als checks mit — der Server stuft einen
+/// als ok gemeldeten Beat herab, sobald eine Prüfung fehlschlägt, und nennt in
+/// der Antwort die betroffene. Der klassische Fall, den das abfängt: Der Takt meldet
+/// brav ok, während der Aufgaben-Scanner seit einer Stunde tot ist.
+///
+///
+/// error — kein OpenRouter-Key konfiguriert (Agenten deaktiviert).
+/// warning — Tagesbudget der Instanz erschöpft oder Scanner steht.
+/// ok — sonst.
+///
+///
+/// Die Metriken sind bewusst schlank und ausschließlich numerisch: keine
+/// sensiblen Nutzdaten, und nur Zahlen landen im Verlauf.
+///
+public sealed class InstanceHealthProvider : IInstanceHealthProvider
+{
+ private readonly string _instanceName;
+ private readonly bool _agentsEnabled;
+ private readonly InstanceBudget _budget;
+ private readonly IUsageRepository? _usage;
+ private readonly Func _agentCount;
+ private readonly Func _runningChats;
+ private readonly Func? _schedulerRunning;
+ private readonly Func _now;
+
+ public InstanceHealthProvider(
+ string instanceName,
+ bool agentsEnabled,
+ InstanceBudget budget,
+ IUsageRepository? usage,
+ Func agentCount,
+ Func runningChats,
+ Func? schedulerRunning = null,
+ Func? now = null)
+ {
+ _instanceName = instanceName;
+ _agentsEnabled = agentsEnabled;
+ _budget = budget;
+ _usage = usage;
+ _agentCount = agentCount;
+ _runningChats = runningChats;
+ _schedulerRunning = schedulerRunning;
+ _now = now ?? (() => DateTime.Now);
+ }
+
+ public async Task GetAsync(CancellationToken ct)
+ {
+ var metrics = new Dictionary
+ {
+ ["agentCount"] = _agentCount(),
+ ["runningChats"] = _runningChats()
+ };
+
+ var checks = new Dictionary
+ {
+ ["agents"] = new(_agentsEnabled,
+ _agentsEnabled ? null : "Kein OpenRouter-API-Key konfiguriert.")
+ };
+
+ if (_schedulerRunning is not null)
+ {
+ var running = _schedulerRunning();
+ checks["scheduler"] = new(running,
+ running ? null : "Aufgaben-Scanner läuft nicht.");
+ }
+
+ string? budgetProblem = null;
+
+ if (_usage is not null)
+ {
+ var today = DateOnly.FromDateTime(_now());
+ var used = await _usage.GetDailyAsync(today, agentId: "", ct).ConfigureAwait(false);
+
+ metrics["todayCostUsd"] = (double)decimal.Round(used.CostUsd, 4);
+ metrics["todayTokens"] = used.TotalTokens;
+
+ if (Exceeds(_budget.DailyCostUsd, used.CostUsd))
+ {
+ budgetProblem =
+ $"Tagesbudget erschöpft: {used.CostUsd:F2} von {_budget.DailyCostUsd:F2} USD.";
+ }
+ else if (Exceeds(_budget.DailyTokens, used.TotalTokens))
+ {
+ budgetProblem =
+ $"Token-Tageslimit erschöpft: {used.TotalTokens:N0} von {_budget.DailyTokens:N0}.";
+ }
+
+ checks["budget"] = new(budgetProblem is null, budgetProblem);
+ }
+
+ // Der Instanzname steht in der Meldung, nicht in den Metriken: Metriken sind
+ // Zahlen, alles andere würde der Server beim Verdichten ohnehin verwerfen.
+ if (!_agentsEnabled)
+ {
+ return new InstanceHealth(
+ WatchdogStatus.Error,
+ $"{_instanceName}: Kein OpenRouter-API-Key konfiguriert – Agenten deaktiviert.",
+ metrics, checks);
+ }
+
+ if (budgetProblem is not null)
+ return new InstanceHealth(WatchdogStatus.Warning, $"{_instanceName}: {budgetProblem}", metrics, checks);
+
+ if (_schedulerRunning is not null && !_schedulerRunning())
+ {
+ return new InstanceHealth(
+ WatchdogStatus.Warning,
+ $"{_instanceName}: Aufgaben-Scanner läuft nicht.",
+ metrics, checks);
+ }
+
+ return new InstanceHealth(WatchdogStatus.Ok, $"{_instanceName}: Betrieb normal.", metrics, checks);
+ }
+
+ /// 0 oder kleiner bedeutet: keine Grenze gesetzt. Deckungsgleich mit BudgetGuard.
+ private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
+
+ private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs
new file mode 100644
index 0000000..7413204
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs
@@ -0,0 +1,174 @@
+using System.Text.Json;
+
+namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
+
+/// Was der Server zu einem Heartbeat zurückmeldet.
+/// Der daraus abgeleitete Monitor-Zustand (up, warning, …).
+/// Welche der mitgeschickten Prüfungen fehlgeschlagen sind.
+public sealed record WatchdogPingResult(string State, IReadOnlyList FailingChecks);
+
+///
+/// Sendet Heartbeats und Ereignisse an das Watchdog-Modul des Deploymentcenters.
+///
+public interface IWatchdogClient
+{
+ Task SendHeartbeatAsync(
+ InstanceHealth health, int intervalSeconds, CancellationToken ct);
+
+ Task SendEventAsync(
+ string kind, string severity, string? message, object? meta, CancellationToken ct);
+}
+
+///
+/// Watchdog-Anbindung: POST /api/watchdog/v1/ping und
+/// POST /api/watchdog/v1/event.
+///
+/// Ein Monitor je Instanz. Der Schlüssel des Monitors ist das Paar
+/// source + instance (so das Datenbankschema:
+/// UNIQUE KEY uq_monitor (source, instance)). Alle ClawdDotNet-Instanzen melden
+/// unter derselben source und tragen ihre eigene instance — damit ist jede
+/// laufende Instanz ein eigener Monitor mit eigenem Zustand, eigenem Intervall und
+/// eigenem Metrik-Verlauf. Stürzt eine von dreien ab, fällt genau deren Monitor.
+///
+/// Der Monitor entsteht beim ersten Heartbeat von selbst (INSERT … ON DUPLICATE
+/// KEY UPDATE) — eine Registrierung vorab gibt es nicht mehr und ist auch nicht
+/// nötig.
+///
+public sealed class WatchdogClient : IWatchdogClient, IDisposable
+{
+ private readonly DeploymentcenterApi _api;
+ private readonly bool _ownsApi;
+ private readonly string _source;
+ private readonly string _instance;
+ private readonly string _group;
+ private readonly string _os;
+ private readonly string _version;
+
+ public WatchdogClient(
+ DeploymentcenterApi api, string source, string instance, string group, string os,
+ string version, bool ownsApi = false)
+ {
+ _api = api;
+ _ownsApi = ownsApi;
+ _source = source;
+ _instance = instance;
+ _group = group;
+ _os = os;
+ _version = version;
+ }
+
+ public static WatchdogClient Create(
+ string baseUrl, string token, string source, string instance, string group, string os,
+ string version, HttpClient? httpClient = null)
+ => new(new DeploymentcenterApi(baseUrl, token, httpClient),
+ source, instance, group, os, version, ownsApi: true);
+
+ public async Task SendHeartbeatAsync(
+ InstanceHealth health, int intervalSeconds, CancellationToken ct)
+ {
+ var payload = new Dictionary
+ {
+ ["source"] = _source,
+ ["instance"] = _instance,
+ ["type"] = "heartbeat",
+ ["status"] = health.Status,
+ ["interval"] = intervalSeconds,
+ ["message"] = health.Message,
+ ["group"] = _group,
+ ["os"] = _os,
+
+ // Landet in watchdog_monitors.app_version. Damit steht im Dashboard, welche
+ // Fassung eine Instanz gerade fährt — bei mehreren Instanzen der
+ // Unterschied zwischen „läuft" und „läuft noch auf der alten Version".
+ ["version"] = _version
+ };
+
+ // Leere Objekte weglassen: Der Server übernimmt health_json nur, wenn etwas
+ // mitkommt — ein leeres würde den letzten bekannten Zustand nicht ersetzen,
+ // aber unnötig Platz im Protokoll kosten.
+ if (health.Checks.Count > 0)
+ {
+ payload["checks"] = health.Checks.ToDictionary(
+ c => c.Key,
+ c => (object)new { ok = c.Value.Ok, message = c.Value.Message });
+ }
+
+ if (health.Metrics.Count > 0)
+ payload["metrics"] = health.Metrics;
+
+ var response = await _api.PostAsync("/api/watchdog/v1/ping", payload, ct)
+ .ConfigureAwait(false);
+
+ return ReadPingResult(response);
+ }
+
+ private static WatchdogPingResult ReadPingResult(JsonElement response)
+ {
+ if (!response.TryGetProperty("monitor", out var monitor)
+ || monitor.ValueKind != JsonValueKind.Object)
+ {
+ return new WatchdogPingResult("unknown", []);
+ }
+
+ var state = monitor.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String
+ ? s.GetString() ?? "unknown"
+ : "unknown";
+
+ var failing = new List();
+ if (monitor.TryGetProperty("failing_checks", out var checks)
+ && checks.ValueKind == JsonValueKind.Array)
+ {
+ failing.AddRange(checks.EnumerateArray()
+ .Where(e => e.ValueKind == JsonValueKind.String)
+ .Select(e => e.GetString()!));
+ }
+
+ return new WatchdogPingResult(state, failing);
+ }
+
+ ///
+ /// Ein einmaliges Vorkommnis statt einer zyklischen Meldung. Zulässige
+ /// -Werte siehe — der Server
+ /// weist andere ab.
+ ///
+ public async Task SendEventAsync(
+ string kind, string severity, string? message, object? meta, CancellationToken ct)
+ {
+ var payload = new
+ {
+ source = _source,
+ instance = _instance,
+ kind,
+ severity,
+ message,
+ meta
+ };
+
+ await _api.PostAsync("/api/watchdog/v1/event", payload, ct).ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ if (_ownsApi)
+ _api.Dispose();
+ }
+}
+
+///
+/// Die vom Server akzeptierten Ereignisarten. Die frühere Anbindung schickte
+/// start und stop — beide stehen nicht auf dieser Liste und wurden
+/// stillschweigend als started abgelegt.
+///
+public static class WatchdogEventKind
+{
+ public const string Started = "started";
+ public const string StoppedGraceful = "stopped_graceful";
+ public const string CrashSuspected = "crash_suspected";
+ public const string HardError = "hard_error";
+ public const string Recovered = "recovered";
+ public const string WarningRaised = "warning_raised";
+ public const string WarningCleared = "warning_cleared";
+ public const string MaintenanceStart = "maintenance_start";
+ public const string MaintenanceEnd = "maintenance_end";
+ public const string WatchdogStarted = "watchdog_started";
+}
diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs
new file mode 100644
index 0000000..c00ebbe
--- /dev/null
+++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs
@@ -0,0 +1,197 @@
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
+
+///
+/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende.
+/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen —
+/// alle Sendefehler werden geloggt und verschluckt.
+///
+/// Sauberes Beenden. Beim Herunterfahren geht ein Heartbeat mit
+/// status: "stopped" raus. Der Evaluator lässt einen so gemeldeten Monitor in
+/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen
+/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den
+/// Monitor-Zustand.
+///
+public sealed class WatchdogHeartbeatService : IAsyncDisposable
+{
+ private readonly IWatchdogClient _client;
+ private readonly bool _ownsClient;
+ private readonly IInstanceHealthProvider _health;
+ private readonly int _intervalSeconds;
+ private readonly ILogger _logger;
+
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+ private string _lastState = "unknown";
+
+ public WatchdogHeartbeatService(
+ IWatchdogClient client,
+ IInstanceHealthProvider health,
+ int intervalSeconds,
+ ILogger logger,
+ bool ownsClient = false)
+ {
+ _client = client;
+ _health = health;
+ _intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400);
+ _logger = logger;
+ _ownsClient = ownsClient;
+ }
+
+ ///
+ /// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration
+ /// (fehlende oder nicht-HTTPS-URL).
+ ///
+ public static WatchdogHeartbeatService Create(
+ string baseUrl, string token, string source, string instance, string group, string os,
+ string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger)
+ {
+ var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version);
+ return new WatchdogHeartbeatService(
+ client, health, intervalSeconds, logger, ownsClient: true);
+ }
+
+ public bool IsRunning => _loop is { IsCompleted: false };
+
+ /// Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.
+ public string LastState => _lastState;
+
+ public void Start()
+ {
+ if (IsRunning)
+ return;
+
+ _cts = new CancellationTokenSource();
+ _loop = RunAsync(_cts.Token);
+ _logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds);
+ }
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ await TrySendAsync(
+ () => _client.SendEventAsync(
+ WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct),
+ "Start-Ereignis").ConfigureAwait(false);
+
+ // Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen
+ // Intervall im Dashboard auftaucht.
+ await BeatAsync(ct).ConfigureAwait(false);
+
+ try
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds));
+ while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
+ await BeatAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Regulärer Stopp.
+ }
+ }
+
+ private async Task BeatAsync(CancellationToken ct)
+ {
+ InstanceHealth health;
+ try
+ {
+ health = await _health.GetAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen
+ // rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall.
+ _logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen – melde warning.");
+ health = new InstanceHealth(
+ WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.",
+ new Dictionary(), new Dictionary());
+ }
+
+ await TrySendAsync(async () =>
+ {
+ var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct)
+ .ConfigureAwait(false);
+
+ if (result.State != _lastState)
+ {
+ _logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}",
+ _lastState, result.State,
+ result.FailingChecks.Count > 0
+ ? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})"
+ : "");
+
+ _lastState = result.State;
+ }
+ }, "Heartbeat").ConfigureAwait(false);
+ }
+
+ private async Task TrySendAsync(Func send, string what)
+ {
+ try
+ {
+ await send().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem)
+ {
+ // Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor
+ // für immer stumm, und niemand merkt es, weil ja nichts abstürzt.
+ _logger.LogWarning(
+ "Watchdog: {What} abgelehnt ({Code}) – Token prüfen (Recht watchdog:ping).",
+ what, ex.Code);
+ }
+ catch (Exception ex)
+ {
+ // Ausfall des Monitorings darf den Betrieb nie stören.
+ _logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_cts is null)
+ return;
+
+ await _cts.CancelAsync().ConfigureAwait(false);
+
+ if (_loop is not null)
+ {
+ try { await _loop.ConfigureAwait(false); }
+ catch (OperationCanceledException) { /* erwartet */ }
+ catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); }
+ }
+
+ // Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein
+ // Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft.
+ try
+ {
+ using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+
+ await _client.SendHeartbeatAsync(
+ new InstanceHealth(
+ WatchdogStatus.Stopped, "Instanz planmäßig beendet.",
+ new Dictionary(), new Dictionary()),
+ _intervalSeconds, stopCts.Token).ConfigureAwait(false);
+
+ await _client.SendEventAsync(
+ WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert).");
+ }
+
+ _cts.Dispose();
+
+ if (_ownsClient && _client is IDisposable disposable)
+ disposable.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index 1934c8c..14d5773 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
+using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Memory;
@@ -14,13 +15,16 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Engine;
-public sealed class AgentEngine : IAgentMessageRouter
+public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecutor
{
private readonly IChatCompletionClient _client;
private readonly ToolRegistry _toolRegistry;
private readonly PermissionGate _permissionGate;
private readonly IStateStore _stateStore;
private readonly IMemoryRepository? _memoryRepository;
+ private readonly Tasks.ITaskRepository? _taskRepository;
+ private readonly IAuditRepository? _auditRepository;
+ private readonly Staging.StagingGate? _stagingGate;
private readonly IUsageRepository? _usageRepository;
private readonly BudgetGuard? _budgetGuard;
private readonly ModelPricingCatalog? _pricing;
@@ -67,7 +71,10 @@ public sealed class AgentEngine : IAgentMessageRouter
ILoggerFactory loggerFactory,
IMemoryRepository? memoryRepository = null,
IUsageRepository? usageRepository = null,
- ModelPricingCatalog? pricing = null)
+ ModelPricingCatalog? pricing = null,
+ Tasks.ITaskRepository? taskRepository = null,
+ IAuditRepository? auditRepository = null,
+ Staging.StagingGate? stagingGate = null)
{
_client = client;
_toolRegistry = toolRegistry;
@@ -75,6 +82,9 @@ public sealed class AgentEngine : IAgentMessageRouter
_stateStore = stateStore;
_loggerFactory = loggerFactory;
_memoryRepository = memoryRepository;
+ _taskRepository = taskRepository;
+ _auditRepository = auditRepository;
+ _stagingGate = stagingGate;
_usageRepository = usageRepository;
_pricing = pricing;
_budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository);
@@ -100,14 +110,18 @@ public sealed class AgentEngine : IAgentMessageRouter
AgentConfig agentConfig,
string userMessage,
string instanceId,
- CancellationToken externalCt)
+ CancellationToken externalCt,
+ string? source = null,
+ string? taskId = null)
{
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
return denied;
- var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt);
+ var runId = Guid.NewGuid().ToString("N");
+ var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source);
await RecordUsageAsync(agentConfig, result);
+ await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -115,7 +129,9 @@ public sealed class AgentEngine : IAgentMessageRouter
AgentConfig agentConfig,
string userMessage,
string instanceId,
- CancellationToken externalCt)
+ CancellationToken externalCt,
+ string runId,
+ string? source)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -157,7 +173,11 @@ public sealed class AgentEngine : IAgentMessageRouter
{
Model = agentConfig.Model,
Messages = messages,
- Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
+ Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
+ // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
+ MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
+ ? agentConfig.LoopGuard.MaxResponseTokens
+ : null
};
var response = await _client.CompleteAsync(request, ct);
@@ -194,7 +214,7 @@ public sealed class AgentEngine : IAgentMessageRouter
foreach (var toolCall in assistantMessage.ToolCalls)
{
var toolResult = await ExecuteToolCallAsync(
- toolCall, agentConfig, instanceId, tools, logger, ct);
+ toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
}
@@ -282,11 +302,14 @@ public sealed class AgentEngine : IAgentMessageRouter
string userMessage,
string instanceId,
CancellationToken externalCt,
- string? source = null)
+ string? source = null,
+ string? taskId = null)
{
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
return denied;
+ var runId = Guid.NewGuid().ToString("N");
+
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
@@ -307,8 +330,9 @@ public sealed class AgentEngine : IAgentMessageRouter
try
{
- var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
+ var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId);
await RecordUsageAsync(agentConfig, result);
+ await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
return result;
}
finally
@@ -323,7 +347,8 @@ public sealed class AgentEngine : IAgentMessageRouter
string userMessage,
string instanceId,
CancellationToken runCt,
- string? source)
+ string? source,
+ string runId)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -380,7 +405,11 @@ public sealed class AgentEngine : IAgentMessageRouter
{
Model = agentConfig.Model,
Messages = messages,
- Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
+ Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
+ // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
+ MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
+ ? agentConfig.LoopGuard.MaxResponseTokens
+ : null
};
var response = await _client.CompleteAsync(request, ct);
@@ -418,7 +447,7 @@ public sealed class AgentEngine : IAgentMessageRouter
foreach (var toolCall in assistantMessage.ToolCalls)
{
var toolResult = await ExecuteToolCallAsync(
- toolCall, agentConfig, instanceId, tools, logger, ct);
+ toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
}
@@ -538,6 +567,12 @@ public sealed class AgentEngine : IAgentMessageRouter
return _runningChats.ContainsKey(agentId);
}
+ /// Anzahl Agenten mit mindestens einem aktiven Chat-Lauf — für Diagnose/Heartbeat.
+ public int RunningChatCount
+ {
+ get { lock (_lock) return _runningChats.Count; }
+ }
+
///
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
@@ -805,9 +840,19 @@ public sealed class AgentEngine : IAgentMessageRouter
string instanceId,
IReadOnlyList availableTools,
ILogger logger,
- CancellationToken ct)
+ CancellationToken ct,
+ string runId,
+ string? source)
{
var toolName = toolCall.Function.Name;
+ var arguments = toolCall.Function.Arguments ?? "";
+ var sw = Stopwatch.StartNew();
+
+ // Das Audit wird von der Engine gestempelt (A3) — Herkunft aus dem Wissen der
+ // Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf
+ // nicht scheitern lassen.
+ Task Audit(AuditStatus status, string summary)
+ => RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary, sw.ElapsedMilliseconds);
try
{
@@ -815,29 +860,36 @@ public sealed class AgentEngine : IAgentMessageRouter
var tool = availableTools.FirstOrDefault(t => t.Name == toolName);
if (tool is null)
+ {
+ await Audit(AuditStatus.NotFound, $"Tool '{toolName}' nicht zugewiesen/unbekannt");
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
+ }
+
+ // Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
+ // ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
+ if (_stagingGate is not null)
+ {
+ var intercept = await _stagingGate.InterceptAsync(
+ agentConfig.AgentId, instanceId, runId, toolName, arguments, ct);
+
+ if (intercept.Outcome == Staging.StagingOutcome.Denied)
+ {
+ await Audit(AuditStatus.Denied, intercept.Message);
+ return JsonSerializer.Serialize(new { error = intercept.Message });
+ }
+
+ if (intercept.Outcome == Staging.StagingOutcome.Staged)
+ {
+ await Audit(AuditStatus.Staged, intercept.Message);
+ return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis
+ }
+ }
var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments)
? default
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
- var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
- ? cfg.AsReadOnly()
- : new Dictionary().AsReadOnly();
-
- var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
-
- var context = new AgentToolContext(
- agentConfig.AgentId,
- instanceId,
- toolConfig,
- _stateStore,
- toolLogger,
- ct,
- agentConfig.WorkspacePath,
- agentConfig.SharedWorkspacePath,
- this,
- _memoryRepository);
+ var context = BuildToolContext(agentConfig, instanceId, toolName, ct);
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
@@ -846,7 +898,10 @@ public sealed class AgentEngine : IAgentMessageRouter
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success);
if (!result.Success)
+ {
+ await Audit(AuditStatus.Error, result.ErrorMessage ?? "");
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
+ }
var content = TruncateToolResult(result.Content, agentConfig.MaxToolResultChars);
if (content.Length != result.Content.Length)
@@ -856,22 +911,92 @@ public sealed class AgentEngine : IAgentMessageRouter
toolName, result.Content.Length, agentConfig.MaxToolResultChars);
}
+ await Audit(AuditStatus.Ok, "");
return content;
}
catch (ToolAccessDeniedException ex)
{
logger.LogWarning("Tool access denied: {Message}", ex.Message);
+ await Audit(AuditStatus.Denied, ex.Message);
return JsonSerializer.Serialize(new { error = ex.Message });
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
- // Schritt weiter und der Abbruch greift erst verzögert.
+ // Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit —
+ // der Aufruf kam nicht zum Abschluss.
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
+ await Audit(AuditStatus.Error, ex.Message);
+ return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
+ }
+ }
+
+ private AgentToolContext BuildToolContext(
+ AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct)
+ {
+ var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
+ ? cfg.AsReadOnly()
+ : new Dictionary().AsReadOnly();
+
+ var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
+
+ return new AgentToolContext(
+ agentConfig.AgentId,
+ instanceId,
+ toolConfig,
+ _stateStore,
+ toolLogger,
+ ct,
+ agentConfig.WorkspacePath,
+ agentConfig.SharedWorkspacePath,
+ this,
+ _memoryRepository,
+ _taskRepository);
+ }
+
+ ///
+ /// Führt einen freigegebenen, eingefrorenen Aufruf aus (A2) — mit gültigem Tool-Kontext,
+ /// aber ohne LLM-Schleife und ohne erneute Staging-Prüfung. Genau der übergebene
+ /// Argument-JSON wird ausgeführt (Plan-Freeze).
+ ///
+ public async Task ExecuteApprovedCallAsync(
+ string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
+ {
+ var config = _agentConfigProvider?.Invoke().FirstOrDefault(a => a.AgentId == agentId);
+ if (config is null)
+ return JsonSerializer.Serialize(new { error = $"Agent '{agentId}' nicht gefunden." });
+
+ var agentTool = _toolRegistry.GetForAgent(config).FirstOrDefault(t => t.Name == tool)
+ ?? _toolRegistry.Get(tool);
+ if (agentTool is null)
+ return JsonSerializer.Serialize(new { error = $"Tool '{tool}' nicht gefunden." });
+
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ var input = string.IsNullOrWhiteSpace(argumentsJson)
+ ? default
+ : JsonDocument.Parse(argumentsJson).RootElement;
+
+ var context = BuildToolContext(config, _instanceId, tool, ct);
+ var result = await agentTool.ExecuteAsync(input, context, ct);
+
+ var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
+ await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
+ status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
+
+ return result.Success
+ ? TruncateToolResult(result.Content, config.MaxToolResultChars)
+ : JsonSerializer.Serialize(new { error = result.ErrorMessage });
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
+ AuditStatus.Error, ex.Message, sw.ElapsedMilliseconds);
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
}
}
@@ -938,6 +1063,84 @@ public sealed class AgentEngine : IAgentMessageRouter
}
}
+ // ─── Audit-Log und Receipts (A3) ───
+
+ ///
+ /// Schreibt einen Tool-Aufruf ins Audit-Log. Best effort — ein Fehler hierbei darf den
+ /// Lauf nicht scheitern lassen; die eigentliche Arbeit ist bereits getan.
+ ///
+ private async Task RecordAuditAsync(
+ string runId, AgentConfig agentConfig, string? source, string tool,
+ string arguments, AuditStatus status, string summary, long durationMs)
+ {
+ if (_auditRepository is null)
+ return;
+
+ try
+ {
+ await _auditRepository.AppendAsync(new AuditEntry
+ {
+ RunId = runId,
+ AgentId = agentConfig.AgentId,
+ Model = agentConfig.Model,
+ Source = AuditSource.Normalize(source),
+ Tool = tool,
+ Arguments = Cap(arguments, 4_000),
+ Status = status,
+ Summary = Cap(summary, 500),
+ DurationMs = durationMs,
+ OccurredAt = DateTime.Now
+ }, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Audit")
+ .LogWarning(ex, "Audit-Eintrag konnte nicht geschrieben werden ({Tool})", tool);
+ }
+ }
+
+ ///
+ /// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
+ ///
+ private async Task RecordReceiptAsync(
+ string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
+ {
+ if (_auditRepository is null)
+ return;
+
+ try
+ {
+ var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
+
+ await _auditRepository.RecordReceiptAsync(new RunReceipt
+ {
+ RunId = runId,
+ AgentId = agentConfig.AgentId,
+ Model = agentConfig.Model,
+ Source = AuditSource.Normalize(source),
+ TaskId = taskId,
+ Status = result.Status.ToString(),
+ StepCount = result.StepCount,
+ PromptTokens = result.PromptTokens,
+ CompletionTokens = result.CompletionTokens,
+ CachedTokens = result.CachedTokens,
+ CostUsd = estimate?.Usd ?? 0m,
+ CostIsKnown = estimate?.IsKnown ?? false,
+ DurationMs = (long)result.Duration.TotalMilliseconds,
+ ResultRef = Cap(result.FinalMessage ?? "", 500),
+ OccurredAt = DateTime.Now
+ }, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Receipt")
+ .LogWarning(ex, "Receipt konnte nicht geschrieben werden für {AgentId}", agentConfig.AgentId);
+ }
+ }
+
+ private static string Cap(string value, int max)
+ => value.Length <= max ? value : value[..max] + "…";
+
/// Sammelt die Token-Zahlen über alle Schritte eines Runs.
private sealed class TokenTally
{
diff --git a/src/ClawdDotNet.Core/Engine/ChatEntry.cs b/src/ClawdDotNet.Core/Engine/ChatEntry.cs
index 30c0a16..5215519 100644
--- a/src/ClawdDotNet.Core/Engine/ChatEntry.cs
+++ b/src/ClawdDotNet.Core/Engine/ChatEntry.cs
@@ -18,4 +18,5 @@ public static class ChatSource
public const string Telegram = "telegram";
public const string AgentComm = "agentcomm";
public const string Job = "job";
+ public const string Task = "task";
}
diff --git a/src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs b/src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs
deleted file mode 100644
index 5944842..0000000
--- a/src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs
+++ /dev/null
@@ -1,143 +0,0 @@
-using ClawdDotNet.Core.Config;
-using ClawdDotNet.Core.Engine;
-using Microsoft.Extensions.Logging;
-
-namespace ClawdDotNet.Core.Scheduling;
-
-public sealed class AgentScheduler : IAsyncDisposable
-{
- private readonly AgentEngine _engine;
- private readonly string _instanceId;
- private readonly ILogger _logger;
- private readonly CancellationTokenSource _cts = new();
- private readonly List _schedulerTasks = new();
- private readonly Dictionary _lastResults = new();
- private readonly Lock _resultsLock = new();
-
- public event Action? OnRunCompleted;
-
- public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
- {
- _engine = engine;
- _instanceId = instanceId;
- _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
- }
-
- public void RegisterAgent(AgentConfig agentConfig)
- {
- if (agentConfig.Scheduler is null)
- return;
-
- _logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
- agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
-
- var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
- _schedulerTasks.Add(task);
- }
-
- public void RegisterAll(IEnumerable agents)
- {
- foreach (var agent in agents)
- RegisterAgent(agent);
- }
-
- public async Task RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
- {
- _logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
- var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
- StoreResult(agentConfig.AgentId, result);
- OnRunCompleted?.Invoke(agentConfig.AgentId, result);
- return result;
- }
-
- public AgentRunResult? GetLastResult(string agentId)
- {
- lock (_resultsLock)
- return _lastResults.GetValueOrDefault(agentId);
- }
-
- private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
- {
- var scheduler = agentConfig.Scheduler!;
-
- if (scheduler.RunOnStart)
- {
- await ExecuteScheduledRunAsync(agentConfig, ct);
- }
-
- if (string.IsNullOrWhiteSpace(scheduler.Cron))
- return;
-
- var cron = CronExpression.Parse(scheduler.Cron);
-
- while (!ct.IsCancellationRequested)
- {
- var now = DateTime.Now;
- var next = cron.GetNextOccurrence(now);
-
- if (next is null)
- {
- _logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
- return;
- }
-
- var delay = next.Value - now;
- _logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
-
- try
- {
- await Task.Delay(delay, ct);
- }
- catch (OperationCanceledException)
- {
- break;
- }
-
- await ExecuteScheduledRunAsync(agentConfig, ct);
- }
- }
-
- private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
- {
- try
- {
- var result = await _engine.RunAsync(
- agentConfig,
- agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
- _instanceId,
- ct);
-
- StoreResult(agentConfig.AgentId, result);
- OnRunCompleted?.Invoke(agentConfig.AgentId, result);
-
- _logger.LogInformation(
- "Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
- agentConfig.AgentId, result.Status, result.TokensUsed);
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
- {
- _logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
- }
- }
-
- private void StoreResult(string agentId, AgentRunResult result)
- {
- lock (_resultsLock)
- _lastResults[agentId] = result;
- }
-
- public async ValueTask DisposeAsync()
- {
- await _cts.CancelAsync();
-
- try
- {
- await Task.WhenAll(_schedulerTasks);
- }
- catch (OperationCanceledException)
- {
- }
-
- _cts.Dispose();
- }
-}
diff --git a/src/ClawdDotNet.Core/Scheduling/CronExpression.cs b/src/ClawdDotNet.Core/Scheduling/CronExpression.cs
index 2901a1f..edd9a2d 100644
--- a/src/ClawdDotNet.Core/Scheduling/CronExpression.cs
+++ b/src/ClawdDotNet.Core/Scheduling/CronExpression.cs
@@ -90,6 +90,9 @@ public sealed class CronExpression
foreach (var part in field.Split(','))
{
+ if (part.Length == 0)
+ throw Bad(field, "leeres Teilfeld");
+
if (part == "*")
{
for (var i = min; i <= max; i++) result.Add(i);
@@ -97,23 +100,44 @@ public sealed class CronExpression
else if (part.Contains('/'))
{
var split = part.Split('/');
- var start = split[0] == "*" ? min : int.Parse(split[0]);
- var step = int.Parse(split[1]);
+ if (split.Length != 2)
+ throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
+
+ var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max);
+ var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
for (var i = start; i <= max; i += step) result.Add(i);
}
else if (part.Contains('-'))
{
var split = part.Split('-');
- var from = int.Parse(split[0]);
- var to = int.Parse(split[1]);
+ if (split.Length != 2)
+ throw Bad(field, "Bereich erwartet die Form 'von-bis'");
+
+ var from = ParseNumber(field, split[0], min, max);
+ var to = ParseNumber(field, split[1], min, max);
+ if (from > to)
+ throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
+
for (var i = from; i <= to; i++) result.Add(i);
}
else
{
- result.Add(int.Parse(part));
+ result.Add(ParseNumber(field, part, min, max));
}
}
return result;
}
+
+ private static int ParseNumber(string field, string value, int min, int max)
+ {
+ if (!int.TryParse(value, out var n))
+ throw Bad(field, $"'{value}' ist keine Zahl");
+ if (n < min || n > max)
+ throw Bad(field, $"{n} liegt außerhalb von {min}-{max}");
+ return n;
+ }
+
+ private static FormatException Bad(string field, string reason)
+ => new($"Ungültiges Cron-Feld '{field}': {reason}.");
}
diff --git a/src/ClawdDotNet.Core/Scheduling/TimeZones.cs b/src/ClawdDotNet.Core/Scheduling/TimeZones.cs
new file mode 100644
index 0000000..73ee765
--- /dev/null
+++ b/src/ClawdDotNet.Core/Scheduling/TimeZones.cs
@@ -0,0 +1,96 @@
+namespace ClawdDotNet.Core.Scheduling;
+
+///
+/// Deutet Zeitzonen-Kennungen unabhängig davon, auf welchem Betriebssystem sie
+/// geschrieben wurden.
+///
+/// Hintergrund (Linux-Portierung): Zeitzonen werden auf Windows und Linux
+/// unterschiedlich benannt — "W. Europe Standard Time" gegen
+/// "Europe/Berlin". Task-Dateien sind Markdown im geteilten Arbeitsverzeichnis
+/// und wandern zwischen Rechnern. Eine Kennung, die auf dem einen System entstanden ist,
+/// muss auf dem anderen lesbar bleiben.
+///
+/// Zuvor fing TaskSchedule die unbekannte Kennung ab und rechnete still
+/// in UTC weiter. Ein Task, der um 08:00 Ortszeit laufen sollte, lief damit im Sommer
+/// um 06:00 — ohne Meldung, ohne Logeintrag. Deshalb hier: beide Schreibweisen deuten,
+/// und was sich nicht deuten lässt, meldet als null
+/// zurück, statt es zu erraten.
+///
+/// Voraussetzung auf dem Zielsystem: Die Umsetzung zwischen beiden
+/// Schreibweisen kommt aus den ICU-Daten, die Zeitzonen selbst aus tzdata. In
+/// einem schlanken Abbild (Alpine ohne icu-libs, distroless) oder bei
+/// InvariantGlobalization=true fehlen sie — dann schlägt jede Auflösung außer
+/// UTC fehl. Beides gehört ins Abbild.
+///
+public static class TimeZones
+{
+ ///
+ /// Die lokale Zeitzone in IANA-Schreibweise (Europe/Berlin).
+ ///
+ /// Das ist die Form, die in Task-Dateien geschrieben werden soll: Sie gilt auf
+ /// Linux, macOS und — seit .NET 8 — auch auf Windows.
+ ///
+ public static string LocalIanaId => ToIana(TimeZoneInfo.Local.Id);
+
+ ///
+ /// Löst eine Kennung auf, gleich ob IANA- oder Windows-Schreibweise.
+ /// Gibt null zurück, wenn sie auf diesem System nicht auflösbar ist —
+ /// der Aufrufer muss dann entscheiden, und zwar sichtbar.
+ ///
+ public static TimeZoneInfo? TryResolve(string? id)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return null;
+
+ var value = id.Trim();
+
+ if (string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase))
+ return TimeZoneInfo.Utc;
+
+ // Direkt versuchen: .NET nimmt je nach Version und Plattform bereits beide
+ // Formen an. Wenn das reicht, sind wir fertig.
+ try { return TimeZoneInfo.FindSystemTimeZoneById(value); }
+ catch (TimeZoneNotFoundException) { }
+ catch (InvalidTimeZoneException) { }
+
+ // Sonst die jeweils andere Schreibweise versuchen.
+ if (TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana))
+ {
+ try { return TimeZoneInfo.FindSystemTimeZoneById(iana); }
+ catch (TimeZoneNotFoundException) { }
+ catch (InvalidTimeZoneException) { }
+ }
+
+ if (TimeZoneInfo.TryConvertIanaIdToWindowsId(value, out var windows))
+ {
+ try { return TimeZoneInfo.FindSystemTimeZoneById(windows); }
+ catch (TimeZoneNotFoundException) { }
+ catch (InvalidTimeZoneException) { }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Ob die Kennung auf diesem System auflösbar ist. Leer gilt als gültig — das
+ /// bedeutet „keine Angabe" und wird vom Aufrufer als UTC gedeutet.
+ ///
+ public static bool IsKnown(string? id)
+ => string.IsNullOrWhiteSpace(id) || TryResolve(id) is not null;
+
+ ///
+ /// Bringt eine Kennung auf IANA-Schreibweise. Lässt sie sich nicht umsetzen, kommt
+ /// sie unverändert zurück — eine unbekannte Kennung zu verfälschen wäre schlimmer,
+ /// als sie durchzureichen.
+ ///
+ public static string ToIana(string? id)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return "";
+
+ var value = id.Trim();
+
+ // Enthält einen Schrägstrich → bereits IANA (Windows-Kennungen haben keinen).
+ if (value.Contains('/')) return value;
+
+ return TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana) ? iana : value;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs b/src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs
deleted file mode 100644
index 798e76e..0000000
--- a/src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-using ClawdDotNet.Core.Config;
-using ClawdDotNet.Core.Engine;
-using ClawdDotNet.Core.State;
-using ClawdDotNet.Core.Tools;
-using Microsoft.Extensions.Logging;
-
-namespace ClawdDotNet.Core.Scheduling;
-
-public sealed class ToolJobScheduler : IAsyncDisposable
-{
- private readonly AgentEngine _engine;
- private readonly ToolRegistry _toolRegistry;
- private readonly IStateStore _stateStore;
- private readonly string _instanceId;
- private readonly ILogger _logger;
- private readonly ILoggerFactory _loggerFactory;
- private readonly CancellationTokenSource _cts = new();
- private readonly List _schedulerTasks = new();
- private readonly Dictionary _lastResults = new();
- private readonly Lock _resultsLock = new();
-
- public event Action? OnJobTick;
-
- public ToolJobScheduler(
- AgentEngine engine,
- ToolRegistry toolRegistry,
- IStateStore stateStore,
- ILoggerFactory loggerFactory,
- string instanceId)
- {
- _engine = engine;
- _toolRegistry = toolRegistry;
- _stateStore = stateStore;
- _instanceId = instanceId;
- _loggerFactory = loggerFactory;
- _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob");
- }
-
- public void RegisterAll(IEnumerable agents)
- {
- foreach (var agent in agents)
- {
- foreach (var jobConfig in agent.ToolJobs)
- {
- if (!jobConfig.Enabled)
- continue;
-
- var tool = _toolRegistry.Get(jobConfig.ToolName);
- if (tool is not IToolJobProvider provider)
- {
- _logger.LogWarning(
- "Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found",
- jobConfig.ToolName, jobConfig.JobId, agent.AgentId);
- continue;
- }
-
- _logger.LogInformation(
- "Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}",
- agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron);
-
- var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token);
- _schedulerTasks.Add(task);
- }
- }
- }
-
- public ToolJobResult? GetLastResult(string jobId)
- {
- lock (_resultsLock)
- return _lastResults.GetValueOrDefault(jobId);
- }
-
- ///
- /// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans).
- ///
- public async Task TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct)
- {
- var tool = _toolRegistry.Get(jobConfig.ToolName);
- if (tool is not IToolJobProvider provider)
- return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert.");
-
- _logger.LogInformation(
- "Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}",
- agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId);
-
- await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
-
- lock (_resultsLock)
- return _lastResults.GetValueOrDefault(jobConfig.JobId)
- ?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden.");
- }
-
- private async Task RunToolJobAsync(
- AgentConfig agentConfig,
- ToolJobConfig jobConfig,
- IToolJobProvider provider,
- CancellationToken ct)
- {
- if (jobConfig.RunOnStart)
- {
- await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
- }
-
- if (string.IsNullOrWhiteSpace(jobConfig.Cron))
- return;
-
- var cron = CronExpression.Parse(jobConfig.Cron);
-
- while (!ct.IsCancellationRequested && jobConfig.Enabled)
- {
- var now = DateTime.Now;
- var next = cron.GetNextOccurrence(now);
-
- if (next is null)
- {
- _logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId);
- return;
- }
-
- var delay = next.Value - now;
- _logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}",
- jobConfig.JobId, jobConfig.JobTypeId, next.Value);
-
- try
- {
- await Task.Delay(delay, ct);
- }
- catch (OperationCanceledException)
- {
- break;
- }
-
- await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
- }
- }
-
- private async Task ExecuteTickAsync(
- AgentConfig agentConfig,
- ToolJobConfig jobConfig,
- IToolJobProvider provider,
- CancellationToken ct)
- {
- var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job");
-
- if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName))
- {
- _logger.LogWarning(
- "Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'",
- jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId);
-
- jobConfig.Enabled = false;
-
- var disabledResult = new ToolJobResult(false, null,
- $"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'");
- lock (_resultsLock)
- _lastResults[jobConfig.JobId] = disabledResult;
- OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult);
- return;
- }
-
- try
- {
- var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg)
- ? (IReadOnlyDictionary)cfg.AsReadOnly()
- : new Dictionary().AsReadOnly();
-
- var result = await provider.ExecuteJobAsync(
- jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct,
- agentConfig.AgentId, agentConfig.WorkspacePath);
-
- lock (_resultsLock)
- _lastResults[jobConfig.JobId] = result;
-
- OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result);
-
- _logger.LogInformation(
- "Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}",
- agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary);
-
- if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage))
- {
- _logger.LogInformation(
- "Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}",
- agentConfig.AgentId, jobConfig.JobId, result.UseChatContext);
-
- if (result.UseChatContext)
- await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job);
- else
- await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct);
- }
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
- {
- _logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}",
- agentConfig.AgentId, jobConfig.JobId);
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- await _cts.CancelAsync();
-
- try
- {
- await Task.WhenAll(_schedulerTasks);
- }
- catch (OperationCanceledException)
- {
- }
-
- _cts.Dispose();
- }
-}
diff --git a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs
index d502d54..72b7341 100644
--- a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs
+++ b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs
@@ -45,6 +45,9 @@ public static class ConfigSecrets
telegram.Password2FA = transform(telegram.Password2FA);
}
+ if (config.Watchdog is { } watchdog)
+ watchdog.AgentToken = transform(watchdog.AgentToken) ?? "";
+
foreach (var agent in config.Agents)
Apply(agent, transform);
}
diff --git a/src/ClawdDotNet.Core/Security/SecretKeyStore.cs b/src/ClawdDotNet.Core/Security/SecretKeyStore.cs
new file mode 100644
index 0000000..98af8e1
--- /dev/null
+++ b/src/ClawdDotNet.Core/Security/SecretKeyStore.cs
@@ -0,0 +1,107 @@
+using System.Security.Cryptography;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.Core.Security;
+
+///
+/// Verwaltet den lokalen Schlüssel, mit dem die
+/// enc:v2-Werte sichert.
+///
+/// Ein Schlüssel je Benutzer und Rechner. Er liegt als
+/// secret.key in — bewusst außerhalb
+/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
+/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
+/// nützt auf einem anderen Rechner nichts.
+///
+/// Rechte. Unter Unix 0600. Unter Windows erbt die Datei die
+/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
+/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
+/// plattformübergreifend ist.
+///
+public static class SecretKeyStore
+{
+ private const string KeyFileName = "secret.key";
+ private const int KeySize = 32; // AES-256
+
+ /// DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows).
+ private static readonly byte[] KeyEntropy =
+ System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
+
+ private static readonly Lock Gate = new();
+ private static byte[]? _cached;
+ private static string? _overrideDirectory;
+
+ public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
+
+ ///
+ /// Verlegt den Schlüssel — für Tests, damit sie den echten Benutzerschlüssel weder
+ /// lesen noch überschreiben.
+ ///
+ public static void UseDirectory(string? directory)
+ {
+ lock (Gate)
+ {
+ _overrideDirectory = directory;
+ _cached = null;
+ }
+ }
+
+ ///
+ /// Liest den Schlüssel oder legt ihn beim ersten Aufruf an.
+ ///
+ /// Wenn das Verzeichnis nicht beschreibbar ist.
+ public static byte[] GetOrCreateKey()
+ {
+ lock (Gate)
+ {
+ if (_cached is not null) return _cached;
+
+ var directory = AppPaths.EnsureDirectory(Directory());
+ var path = Path.Combine(directory, KeyFileName);
+
+ if (File.Exists(path))
+ {
+ var stored = Unwrap(File.ReadAllBytes(path));
+ if (stored.Length == KeySize)
+ {
+ _cached = stored;
+ return _cached;
+ }
+
+ // Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen
+ // würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt,
+ // warum — deshalb hier abbrechen und den Pfad nennen.
+ throw new CryptographicException(
+ $"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). "
+ + "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen.");
+ }
+
+ var key = RandomNumberGenerator.GetBytes(KeySize);
+
+ // Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der
+ // würde alle Geheimnisse dieser Installation unlesbar machen.
+ AtomicFile.WriteAllBytes(path, Wrap(key));
+ AppPaths.RestrictToOwner(path);
+
+ _cached = key;
+ return _cached;
+ }
+ }
+
+ private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
+
+ /// Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden.
+ private static byte[] Wrap(byte[] key)
+ => OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key;
+
+ private static byte[] Unwrap(byte[] stored)
+ => OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored;
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static byte[] ProtectWithDpapi(byte[] key)
+ => ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser);
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static byte[] UnprotectWithDpapi(byte[] stored)
+ => ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser);
+}
diff --git a/src/ClawdDotNet.Core/Security/SecretProtector.cs b/src/ClawdDotNet.Core/Security/SecretProtector.cs
index 89bdc34..02ed5e4 100644
--- a/src/ClawdDotNet.Core/Security/SecretProtector.cs
+++ b/src/ClawdDotNet.Core/Security/SecretProtector.cs
@@ -1,6 +1,7 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
+using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
@@ -12,23 +13,46 @@ namespace ClawdDotNet.Core.Security;
/// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein
/// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge.
///
-/// Verwendet wird DPAPI im Benutzerkontext: Die Daten lassen sich nur von demselben
-/// Windows-Benutzer auf demselben Rechner entschlüsseln. Das schützt gegen Weitergabe
-/// der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer läuft —
-/// für einen lokal laufenden Dienst ist das die angemessene Stufe.
+/// Zwei Formate.
///
-/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen
-/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird.
+///
+/// enc:v1: — DPAPI im Benutzerkontext. Nur unter Windows lesbar. Wird
+/// nicht mehr geschrieben, aber weiterhin gelesen: bestehende Installationen sollen
+/// ohne Zutun weiterlaufen und wandern beim nächsten Speichern von selbst auf v2.
+/// enc:v2: — AES-256-GCM mit einem Schlüssel aus
+/// . Läuft auf jeder Plattform.
+///
+///
+/// Warum v2 überhaupt nötig wurde. Die vorige Fassung gab unter Linux
+/// stillschweigend den Klartext zurück — Protect verschlüsselte dort schlicht
+/// nicht. Auf einem Server, der per SSH erreichbar ist und gesichert wird, wäre das
+/// schlechter gewesen als auf einem Einzelplatz-Windows.
+///
+/// Schutzstufe. Dieselbe wie DPAPI zuvor: gegen Weitergabe der
+/// Konfigurationsdatei, gegen ein Backup, gegen einen versehentlichen Commit — nicht
+/// gegen einen Angreifer, der bereits als dieser Benutzer läuft. Der Schlüssel liegt
+/// deshalb bewusst außerhalb des Instanzverzeichnisses: Eine Sicherung der
+/// Instanz enthält ihn nicht, und ob Geheimnisse mitreisen, entscheidet weiterhin
+/// allein die Sicherungsrichtlinie in BackupService.
///
public static class SecretProtector
{
- private const string Prefix = "enc:v1:";
+ private const string PrefixV1 = "enc:v1:";
+ private const string PrefixV2 = "enc:v2:";
/// Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist.
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1");
+ /// Wird v2 als Zusatzangabe mitgeschrieben und beim Entschlüsseln geprüft.
+ private static readonly byte[] AssociatedData = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v2");
+
+ private const int NonceSize = 12; // AES-GCM: vorgeschriebene Länge
+ private const int TagSize = 16;
+
public static bool IsProtected(string? value)
- => value?.StartsWith(Prefix, StringComparison.Ordinal) == true;
+ => value is not null
+ && (value.StartsWith(PrefixV2, StringComparison.Ordinal)
+ || value.StartsWith(PrefixV1, StringComparison.Ordinal));
///
/// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert,
@@ -39,18 +63,35 @@ public static class SecretProtector
if (string.IsNullOrEmpty(plainText) || IsProtected(plainText))
return plainText;
- if (!OperatingSystem.IsWindows())
- return plainText;
-
try
{
- var encrypted = ProtectWindows(Encoding.UTF8.GetBytes(plainText));
- return Prefix + Convert.ToBase64String(encrypted);
+ var key = SecretKeyStore.GetOrCreateKey();
+
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var plain = Encoding.UTF8.GetBytes(plainText);
+ var cipher = new byte[plain.Length];
+ var tag = new byte[TagSize];
+
+ using (var aes = new AesGcm(key, TagSize))
+ aes.Encrypt(nonce, plain, cipher, tag, AssociatedData);
+
+ // nonce ‖ tag ‖ ciphertext — feste Längen vorn, damit das Zerlegen eindeutig ist.
+ var payload = new byte[NonceSize + TagSize + cipher.Length];
+ nonce.CopyTo(payload, 0);
+ tag.CopyTo(payload, NonceSize);
+ cipher.CopyTo(payload, NonceSize + TagSize);
+
+ return PrefixV2 + Convert.ToBase64String(payload);
}
- catch (CryptographicException)
+ catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
{
- // Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren.
- return plainText;
+ // Kein Schlüssel anlegbar (etwa ein schreibgeschütztes Konfigurationsverzeichnis).
+ // Den Wert unverschlüsselt zu speichern wäre die stille Rückkehr zu genau dem
+ // Zustand, den S7 behoben hat — deshalb hier abbrechen statt weiterreichen.
+ throw new SecretProtectionException(
+ "Ein Wert konnte nicht verschlüsselt werden, weil der lokale Schlüssel nicht "
+ + $"lesbar oder anlegbar ist ({SecretKeyStore.KeyFilePath}). Ohne ihn würden "
+ + "Zugangsdaten im Klartext gespeichert — der Vorgang wurde abgebrochen.", ex);
}
}
@@ -60,34 +101,74 @@ public static class SecretProtector
///
public static string? Unprotect(string? value)
{
- if (string.IsNullOrEmpty(value) || !IsProtected(value))
- return value;
+ if (string.IsNullOrEmpty(value)) return value;
- if (!OperatingSystem.IsWindows())
- return value;
+ if (value.StartsWith(PrefixV2, StringComparison.Ordinal))
+ return UnprotectV2(value[PrefixV2.Length..]);
- var payload = value[Prefix.Length..];
+ if (value.StartsWith(PrefixV1, StringComparison.Ordinal))
+ return UnprotectV1(value[PrefixV1.Length..]);
+ return value; // Klartext aus älteren Konfigurationen
+ }
+
+ private static string UnprotectV2(string payload)
+ {
try
{
- var decrypted = UnprotectWindows(Convert.FromBase64String(payload));
- return Encoding.UTF8.GetString(decrypted);
+ var raw = Convert.FromBase64String(payload);
+ if (raw.Length < NonceSize + TagSize)
+ throw new CryptographicException("Der verschlüsselte Block ist unvollständig.");
+
+ var key = SecretKeyStore.GetOrCreateKey();
+
+ var nonce = raw.AsSpan(0, NonceSize);
+ var tag = raw.AsSpan(NonceSize, TagSize);
+ var cipher = raw.AsSpan(NonceSize + TagSize);
+ var plain = new byte[cipher.Length];
+
+ using (var aes = new AesGcm(key, TagSize))
+ aes.Decrypt(nonce, cipher, tag, plain, AssociatedData);
+
+ return Encoding.UTF8.GetString(plain);
}
- catch (Exception ex) when (ex is CryptographicException or FormatException)
+ catch (Exception ex) when (ex is CryptographicException or FormatException
+ or IOException or UnauthorizedAccessException)
{
- // Etwa nach Benutzerwechsel oder Rechnerwechsel: Der Wert ist hier nicht
- // lesbar. Ihn als Klartext auszugeben wäre falsch — dann würde ein
- // unbrauchbarer Schlüssel an die API gehen.
throw new SecretProtectionException(
- "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " +
- "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " +
- "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
+ "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
+ + "Konfiguration von einem anderen Rechner oder Benutzer stammt — der Schlüssel "
+ + $"dazu liegt in {SecretKeyStore.KeyFilePath} und reist nicht mit. "
+ + "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
}
}
- [SupportedOSPlatform("windows")]
- private static byte[] ProtectWindows(byte[] data)
- => ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser);
+ private static string UnprotectV1(string payload)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ // Der Fall beim Umzug einer Windows-Instanz auf Linux. Ihn als Klartext
+ // durchzureichen wäre falsch — dann ginge ein unbrauchbarer Schlüssel an die API.
+ throw new SecretProtectionException(
+ "Dieser Wert wurde mit der Windows-Verschlüsselung (DPAPI) gesichert und lässt "
+ + "sich hier nicht lesen. Beim Umzug einer Instanz von Windows müssen die "
+ + "betroffenen Werte einmal neu eingetragen werden; danach liegen sie im "
+ + "plattformübergreifenden Format vor.",
+ new PlatformNotSupportedException("DPAPI ist nur unter Windows verfügbar."));
+ }
+
+ try
+ {
+ return Encoding.UTF8.GetString(UnprotectWindows(Convert.FromBase64String(payload)));
+ }
+ catch (Exception ex) when (ex is CryptographicException or FormatException)
+ {
+ throw new SecretProtectionException(
+ "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
+ + "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. "
+ + "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
+ }
+ }
[SupportedOSPlatform("windows")]
private static byte[] UnprotectWindows(byte[] data)
diff --git a/src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs b/src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs
new file mode 100644
index 0000000..941acd4
--- /dev/null
+++ b/src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs
@@ -0,0 +1,139 @@
+using System.Globalization;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Data.Sqlite;
+
+namespace ClawdDotNet.Core.Staging;
+
+///
+/// Die Staging-Warteschlange in der Instanz-Datenbank. Der bedingte Statuswechsel
+/// () ist dieselbe atomare Claim-Technik wie beim
+/// Taskboard: Er verhindert, dass zwei Reviewer denselben Vorschlag doppelt entscheiden.
+///
+public sealed class SqliteStagingRepository : IStagingRepository
+{
+ private readonly SqliteStorage _storage;
+
+ public SqliteStagingRepository(SqliteStorage storage) => _storage = storage;
+
+ public Task AppendAsync(StagedCall call, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO StagedCalls
+ (RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, CreatedAt)
+ VALUES
+ (@runId, @agentId, @instanceId, @tool, @action, @args, @proposal, 'Pending', @createdAt);
+ SELECT last_insert_rowid();
+ """;
+ cmd.Parameters.AddWithValue("@runId", call.RunId);
+ cmd.Parameters.AddWithValue("@agentId", call.AgentId);
+ cmd.Parameters.AddWithValue("@instanceId", call.InstanceId);
+ cmd.Parameters.AddWithValue("@tool", call.Tool);
+ cmd.Parameters.AddWithValue("@action", (object?)call.Action ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@args", call.ArgumentsJson);
+ cmd.Parameters.AddWithValue("@proposal", call.Proposal);
+ cmd.Parameters.AddWithValue("@createdAt", Format(DateTime.UtcNow));
+ return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct));
+ }, ct);
+
+ public async Task GetAsync(long id, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Id = @id LIMIT 1";
+ cmd.Parameters.AddWithValue("@id", id);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ return await reader.ReadAsync(ct) ? Read(reader) : null;
+ }
+
+ public async Task> ListPendingAsync(CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Status = 'Pending' ORDER BY Id";
+
+ var results = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ results.Add(Read(reader));
+ return results;
+ }
+
+ public Task TryTransitionAsync(
+ long id, StagingStatus from, StagingStatus to,
+ string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE StagedCalls
+ SET Status = @to, DecidedBy = @decidedBy, DecidedAt = @now,
+ RejectionReason = @reason
+ WHERE Id = @id AND Status = @from
+ """;
+ cmd.Parameters.AddWithValue("@to", to.ToString());
+ cmd.Parameters.AddWithValue("@decidedBy", (object?)decidedBy ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@now", Format(now));
+ cmd.Parameters.AddWithValue("@reason", (object?)rejectionReason ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@id", id);
+ cmd.Parameters.AddWithValue("@from", from.ToString());
+ return await cmd.ExecuteNonQueryAsync(ct) == 1;
+ }, ct);
+
+ public Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE StagedCalls
+ SET Status = @status, ResultRef = @resultRef, DecidedAt = @now
+ WHERE Id = @id
+ """;
+ cmd.Parameters.AddWithValue("@status", status.ToString());
+ cmd.Parameters.AddWithValue("@resultRef", (object?)resultRef ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@now", Format(now));
+ cmd.Parameters.AddWithValue("@id", id);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ public async Task CountPendingAsync(CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM StagedCalls WHERE Status = 'Pending'";
+ return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private const string Columns =
+ "Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " +
+ "CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason";
+
+ private static StagedCall Read(SqliteDataReader r) => new()
+ {
+ Id = r.GetInt64(0),
+ RunId = r.GetString(1),
+ AgentId = r.GetString(2),
+ InstanceId = r.GetString(3),
+ Tool = r.GetString(4),
+ Action = r.IsDBNull(5) ? null : r.GetString(5),
+ ArgumentsJson = r.GetString(6),
+ Proposal = r.GetString(7),
+ Status = Enum.TryParse(r.GetString(8), out var s) ? s : StagingStatus.Pending,
+ CreatedAt = Parse(r.GetString(9)),
+ DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
+ DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
+ ResultRef = r.IsDBNull(12) ? null : r.GetString(12),
+ RejectionReason = r.IsDBNull(13) ? null : r.GetString(13)
+ };
+
+ private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+
+ private static DateTime Parse(string value)
+ => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
+ ? dt
+ : DateTime.MinValue;
+}
diff --git a/src/ClawdDotNet.Core/Staging/StagingGate.cs b/src/ClawdDotNet.Core/Staging/StagingGate.cs
new file mode 100644
index 0000000..e2d094a
--- /dev/null
+++ b/src/ClawdDotNet.Core/Staging/StagingGate.cs
@@ -0,0 +1,119 @@
+using System.Text.Json;
+
+namespace ClawdDotNet.Core.Staging;
+
+/// Was die Engine mit einem Aufruf tun soll, nachdem das Gate ihn geprüft hat.
+public enum StagingOutcome
+{
+ /// Normal ausführen.
+ Proceed,
+
+ /// Als Vorschlag angelegt — die Ausführung wartet auf Freigabe.
+ Staged,
+
+ /// Abgelehnt (Policy deny).
+ Denied
+}
+
+/// Ergebnis der Gate-Prüfung samt der Nachricht, die der Agent als Tool-Ergebnis sieht.
+public readonly record struct StagingInterception(StagingOutcome Outcome, string Message, long StagedId)
+{
+ public static StagingInterception Proceed { get; } = new(StagingOutcome.Proceed, "", 0);
+}
+
+///
+/// Der Durchsetzungspunkt (A2): prüft die Policy für einen konkreten Aufruf und legt bei
+/// approve einen eingefrorenen Vorschlag an, statt auszuführen. Optional an der
+/// Engine — ohne Gate läuft alles wie bisher.
+///
+public sealed class StagingGate
+{
+ private readonly StagingPolicy _policy;
+ private readonly IStagingRepository _repo;
+
+ public StagingGate(StagingPolicy policy, IStagingRepository repo)
+ {
+ _policy = policy;
+ _repo = repo;
+ }
+
+ public async Task InterceptAsync(
+ string agentId, string instanceId, string runId,
+ string tool, string argumentsJson, CancellationToken ct)
+ {
+ var action = ExtractAction(argumentsJson);
+
+ switch (_policy.Decide(tool, action))
+ {
+ case StagingDecision.Auto:
+ return StagingInterception.Proceed;
+
+ case StagingDecision.Deny:
+ return new StagingInterception(
+ StagingOutcome.Denied,
+ $"Aktion '{Label(tool, action)}' ist gesperrt (Policy: deny) und wurde nicht ausgeführt.",
+ 0);
+
+ case StagingDecision.Approve:
+ var id = await _repo.AppendAsync(new StagedCall
+ {
+ RunId = runId,
+ AgentId = agentId,
+ InstanceId = instanceId,
+ Tool = tool,
+ Action = action,
+ ArgumentsJson = argumentsJson,
+ Proposal = BuildProposal(tool, action, argumentsJson)
+ }, ct);
+
+ return new StagingInterception(
+ StagingOutcome.Staged,
+ $"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. " +
+ "Die Aktion wird erst nach menschlicher Freigabe ausgeführt; du wirst danach " +
+ "mit dem Ergebnis geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
+ id);
+
+ default:
+ return StagingInterception.Proceed;
+ }
+ }
+
+ /// Liest das action-Argument, wenn vorhanden — der Aktionsschlüssel der Policy.
+ public static string? ExtractAction(string argumentsJson)
+ {
+ if (string.IsNullOrWhiteSpace(argumentsJson))
+ return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(argumentsJson);
+ return doc.RootElement.ValueKind == JsonValueKind.Object
+ && doc.RootElement.TryGetProperty("action", out var a)
+ && a.ValueKind == JsonValueKind.String
+ ? a.GetString()
+ : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string Label(string tool, string? action)
+ => string.IsNullOrWhiteSpace(action) ? tool : $"{tool}.{action}";
+
+ private static string BuildProposal(string tool, string? action, string argumentsJson)
+ {
+ var args = argumentsJson.Length > 500 ? argumentsJson[..500] + "…" : argumentsJson;
+ return $"{Label(tool, action)} {args}".Trim();
+ }
+}
+
+///
+/// Führt einen freigegebenen, eingefrorenen Aufruf aus — mit gültigem Tool-Kontext, aber
+/// ohne LLM-Schleife. Von der Engine implementiert.
+///
+public interface IFrozenCallExecutor
+{
+ Task ExecuteApprovedCallAsync(
+ string agentId, string tool, string argumentsJson, string runId, CancellationToken ct);
+}
diff --git a/src/ClawdDotNet.Core/Staging/StagingModels.cs b/src/ClawdDotNet.Core/Staging/StagingModels.cs
new file mode 100644
index 0000000..48b3857
--- /dev/null
+++ b/src/ClawdDotNet.Core/Staging/StagingModels.cs
@@ -0,0 +1,95 @@
+namespace ClawdDotNet.Core.Staging;
+
+/// Was mit einem Tool-Aufruf geschehen soll — die Policy-Entscheidung.
+public enum StagingDecision
+{
+ /// Ausführen wie bisher.
+ Auto,
+
+ /// Stagen und auf menschliche Freigabe warten.
+ Approve,
+
+ /// Gar nicht erst vorschlagen — ablehnen.
+ Deny
+}
+
+/// Lebenszyklus eines eingefrorenen Aufrufs.
+public enum StagingStatus
+{
+ /// Vorgeschlagen, wartet auf Entscheidung.
+ Pending,
+
+ /// Freigegeben und beansprucht (wird ausgeführt).
+ Approved,
+
+ /// Freigegeben und ausgeführt.
+ Executed,
+
+ /// Freigegeben, aber die Ausführung schlug fehl.
+ Failed,
+
+ /// Abgelehnt.
+ Rejected
+}
+
+///
+/// Ein eingefrorener, konkreter Tool-Aufruf, der auf eine Freigabe wartet. „Eingefroren"
+/// heißt: Tool, Aktion und die exakten Argumente zum Zeitpunkt des Vorschlags.
+/// Ausgeführt wird genau das (Plan-Freeze) — nie eine nachträglich veränderte Fassung.
+///
+public sealed record StagedCall
+{
+ public long Id { get; init; }
+
+ /// Lauf, aus dem der Vorschlag stammt — verbindet ihn mit dem Audit-Log.
+ public string RunId { get; init; } = "";
+
+ public string AgentId { get; init; } = "";
+ public string InstanceId { get; init; } = "";
+
+ public string Tool { get; init; } = "";
+ public string? Action { get; init; }
+
+ /// Die eingefrorenen Argumente (roher JSON, exakt wie vom Modell geschickt).
+ public string ArgumentsJson { get; init; } = "";
+
+ /// Kurze, menschenlesbare Zusammenfassung des Vorschlags.
+ public string Proposal { get; init; } = "";
+
+ public StagingStatus Status { get; init; } = StagingStatus.Pending;
+
+ public DateTime CreatedAt { get; init; }
+ public DateTime? DecidedAt { get; init; }
+ public string? DecidedBy { get; init; }
+
+ /// Nach der Ausführung: kurzer Verweis auf das Ergebnis.
+ public string? ResultRef { get; init; }
+
+ /// Bei Ablehnung: der Grund.
+ public string? RejectionReason { get; init; }
+}
+
+public interface IStagingRepository
+{
+ /// Legt einen Vorschlag an und gibt seine Id zurück.
+ Task AppendAsync(StagedCall call, CancellationToken ct);
+
+ Task GetAsync(long id, CancellationToken ct);
+
+ /// Die offenen Vorschläge (Pending), älteste zuerst — für die Review-Ansicht.
+ Task> ListPendingAsync(CancellationToken ct);
+
+ ///
+ /// Atomarer, bedingter Statuswechsel: nur wirksam, wenn der Vorschlag noch im Status
+ /// steht. Verhindert, dass zwei Reviewer denselben Vorschlag
+ /// doppelt entscheiden. Gibt zurück, ob der Wechsel gelang.
+ ///
+ Task TryTransitionAsync(
+ long id, StagingStatus from, StagingStatus to,
+ string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct);
+
+ /// Setzt Endstatus und Ergebnis-Verweis nach der Ausführung (zweite Phase).
+ Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct);
+
+ Task CountPendingAsync(CancellationToken ct);
+}
diff --git a/src/ClawdDotNet.Core/Staging/StagingPolicy.cs b/src/ClawdDotNet.Core/Staging/StagingPolicy.cs
new file mode 100644
index 0000000..cef4716
--- /dev/null
+++ b/src/ClawdDotNet.Core/Staging/StagingPolicy.cs
@@ -0,0 +1,52 @@
+namespace ClawdDotNet.Core.Staging;
+
+///
+/// Entscheidet je (Tool, Aktion), ob ein Aufruf läuft, gestaged oder abgelehnt wird.
+///
+/// Auflösung vom Speziellen zum Allgemeinen: Tool.Aktion → Tool → Standard.
+/// Rein und ohne Zustand, damit die Entscheidung testbar bleibt.
+///
+public sealed class StagingPolicy
+{
+ private readonly Dictionary _rules;
+ private readonly StagingDecision _default;
+
+ public StagingPolicy(
+ IReadOnlyDictionary? rules = null,
+ StagingDecision defaultDecision = StagingDecision.Auto)
+ {
+ _rules = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in rules ?? DefaultRules)
+ _rules[key] = value;
+ _default = defaultDecision;
+ }
+
+ public StagingDecision Decide(string tool, string? action)
+ {
+ if (!string.IsNullOrWhiteSpace(action)
+ && _rules.TryGetValue($"{tool}.{action}", out var specific))
+ return specific;
+
+ if (_rules.TryGetValue(tool, out var byTool))
+ return byTool;
+
+ return _default;
+ }
+
+ ///
+ /// Eingebaute Standardregeln — genau die irreversiblen Aktionen aus der Roadmap, nach
+ /// Sichtung der Tools. Lesende Aktionen bleiben bewusst außen vor (Staging soll
+ /// schützen, nicht lähmen). Überschreibbar per Konfiguration.
+ ///
+ public static IReadOnlyDictionary DefaultRules { get; } =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Mail.send"] = StagingDecision.Approve,
+ ["Telegram.send_message"] = StagingDecision.Approve,
+ ["Database.insert"] = StagingDecision.Approve,
+ ["Database.upsert"] = StagingDecision.Approve,
+ ["FileRW.delete"] = StagingDecision.Approve,
+ ["FTP.upload"] = StagingDecision.Approve,
+ ["FTP.delete"] = StagingDecision.Approve
+ };
+}
diff --git a/src/ClawdDotNet.Core/Staging/StagingService.cs b/src/ClawdDotNet.Core/Staging/StagingService.cs
new file mode 100644
index 0000000..945f719
--- /dev/null
+++ b/src/ClawdDotNet.Core/Staging/StagingService.cs
@@ -0,0 +1,156 @@
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Staging;
+
+public enum StagingResultKind { NotFound, AlreadyDecided, Executed, Failed, Rejected }
+
+public readonly record struct StagingResult(StagingResultKind Kind, string Message)
+{
+ public static StagingResult NotFound { get; } = new(StagingResultKind.NotFound, "Vorschlag nicht gefunden.");
+ public static StagingResult AlreadyDecided { get; } =
+ new(StagingResultKind.AlreadyDecided, "Der Vorschlag wurde bereits entschieden.");
+}
+
+///
+/// Die Freigabe-/Ablehnungs-Seite von A2 — die API, die die Review-Oberfläche aufruft.
+///
+/// Kein pausierter Lauf: Bei Freigabe wird der eingefrorene Aufruf direkt
+/// ausgeführt (Plan-Freeze), das Ergebnis festgehalten und der Agent über einen
+/// Folge-Task (A1) mit dem Ergebnis geweckt. Jede Entscheidung wird als Approval-Record
+/// ins Audit-Log (A3) geschrieben.
+///
+public sealed class StagingService
+{
+ private readonly IStagingRepository _repo;
+ private readonly IFrozenCallExecutor _executor;
+ private readonly TaskboardService _board;
+ private readonly IAuditRepository? _audit;
+ private readonly ILogger _logger;
+
+ public StagingService(
+ IStagingRepository repo,
+ IFrozenCallExecutor executor,
+ TaskboardService board,
+ ILoggerFactory loggerFactory,
+ IAuditRepository? audit = null)
+ {
+ _repo = repo;
+ _executor = executor;
+ _board = board;
+ _audit = audit;
+ _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
+ }
+
+ public Task> ListPendingAsync(CancellationToken ct) => _repo.ListPendingAsync(ct);
+ public Task GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
+ public Task CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
+
+ public async Task ApproveAsync(long id, string decidedBy, CancellationToken ct)
+ {
+ var call = await _repo.GetAsync(id, ct);
+ if (call is null) return StagingResult.NotFound;
+
+ // Atomar beanspruchen — ein zweiter Reviewer läuft ins Leere, bevor irgendetwas
+ // ausgeführt wird.
+ if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
+ return StagingResult.AlreadyDecided;
+
+ string result;
+ StagingStatus final;
+ try
+ {
+ // Genau der eingefrorene Aufruf — nie eine neu formulierte Fassung.
+ result = await _executor.ExecuteApprovedCallAsync(
+ call.AgentId, call.Tool, call.ArgumentsJson, Guid.NewGuid().ToString("N"), ct);
+ final = StagingStatus.Executed;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ result = $"Ausführung fehlgeschlagen: {ex.Message}";
+ final = StagingStatus.Failed;
+ _logger.LogError(ex, "Freigegebener Aufruf #{Id} ({Tool}) schlug fehl", id, call.Tool);
+ }
+
+ await _repo.FinalizeAsync(id, final, Cap(result, 500), DateTime.UtcNow, ct);
+ await RecordAuditAsync(call, decidedBy, final == StagingStatus.Executed ? AuditStatus.Ok : AuditStatus.Error,
+ $"Freigegeben von {decidedBy}", ct);
+ await WakeAgentAsync(call,
+ $"Freigabe-Ergebnis: {call.Tool}",
+ $"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben und ausgeführt.\n\n" +
+ $"Ergebnis:\n{result}\n\nSetze deine Arbeit fort.", ct);
+
+ return new StagingResult(
+ final == StagingStatus.Executed ? StagingResultKind.Executed : StagingResultKind.Failed, result);
+ }
+
+ public async Task RejectAsync(long id, string decidedBy, string reason, CancellationToken ct)
+ {
+ var call = await _repo.GetAsync(id, ct);
+ if (call is null) return StagingResult.NotFound;
+
+ if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
+ return StagingResult.AlreadyDecided;
+
+ await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
+ await WakeAgentAsync(call,
+ $"Freigabe abgelehnt: {call.Tool}",
+ $"[Ablehnung] Deine vorgelegte Aktion '{Label(call)}' wurde abgelehnt.\n\n" +
+ $"Grund: {reason}\n\nFühre sie nicht erneut ohne Rücksprache aus.", ct);
+
+ return new StagingResult(StagingResultKind.Rejected, reason);
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private async Task WakeAgentAsync(StagedCall call, string title, string body, CancellationToken ct)
+ {
+ try
+ {
+ await _board.CreateAsync(new TaskItem
+ {
+ Title = title,
+ Body = body,
+ Status = TaskItemStatus.Todo,
+ Assignee = "@" + call.AgentId,
+ Type = TaskItemType.Work
+ }, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Folge-Task für Vorschlag #{Id} konnte nicht angelegt werden", call.Id);
+ }
+ }
+
+ private async Task RecordAuditAsync(
+ StagedCall call, string decidedBy, AuditStatus status, string summary, CancellationToken ct)
+ {
+ if (_audit is null) return;
+ try
+ {
+ await _audit.AppendAsync(new AuditEntry
+ {
+ RunId = call.RunId,
+ AgentId = call.AgentId,
+ Model = "",
+ Source = "approval",
+ Tool = call.Tool,
+ Arguments = Cap(call.ArgumentsJson, 4_000),
+ Status = status,
+ Summary = Cap(summary, 500),
+ OccurredAt = DateTime.Now
+ }, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Approval-Record für #{Id} konnte nicht geschrieben werden", call.Id);
+ }
+ }
+
+ private static string Label(StagedCall call)
+ => string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
+
+ private static string Cap(string value, int max)
+ => value.Length <= max ? value : value[..max] + "…";
+}
diff --git a/src/ClawdDotNet.Core/Storage/AppPaths.cs b/src/ClawdDotNet.Core/Storage/AppPaths.cs
new file mode 100644
index 0000000..df55737
--- /dev/null
+++ b/src/ClawdDotNet.Core/Storage/AppPaths.cs
@@ -0,0 +1,123 @@
+namespace ClawdDotNet.Core.Storage;
+
+///
+/// Wo die Anwendung ihre eigenen Dateien ablegt — getrennt nach Programm und Daten.
+///
+/// Hintergrund (Linux-Portierung): Bisher lagen Einstellungen und Arbeitsordner neben
+/// der Programmdatei (AppDomain.CurrentDomain.BaseDirectory). Unter Windows in
+/// einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung in /opt
+/// oder /usr/lib und ist für den Dienstbenutzer nicht schreibbar.
+///
+/// Deshalb hier die übliche Trennung: Programm bleibt, wo es installiert ist, Daten
+/// wandern in das Verzeichnis des Benutzers (XDG unter Linux, %APPDATA% unter
+/// Windows). Beides lässt sich per Umgebungsvariable überschreiben — für Dienste, die
+/// nach /var/lib schreiben sollen, und für Tests.
+///
+public static class AppPaths
+{
+ private const string AppFolder = "ClawdDotNet";
+
+ /// Einstellungen und Schlüssel. Klein, selten geschrieben, gehört gesichert.
+ public static string ConfigDirectory { get; } = ResolveConfig();
+
+ /// Instanzen, Datenbanken, Logs. Groß, oft geschrieben.
+ public static string DataDirectory { get; } = ResolveData();
+
+ ///
+ /// Das Verzeichnis der Programmdatei.
+ ///
+ /// Weiterhin nötig, um bestehende Windows-Installationen zu finden: Dort liegt die
+ /// AppSettings.json noch am alten Ort, und ein Update darf sie nicht
+ /// verwaisen lassen.
+ ///
+ public static string ProgramDirectory => AppContext.BaseDirectory;
+
+ /// Legt das Verzeichnis an und schränkt unter Unix die Rechte auf den Benutzer ein.
+ public static string EnsureDirectory(string path)
+ {
+ Directory.CreateDirectory(path);
+
+ // 0700: In den Konfigurationsverzeichnissen liegen Schlüssel und Zugangsdaten.
+ // Unter Windows regelt das die Vererbung aus dem Benutzerprofil, unter Linux
+ // wären es sonst je nach umask 0755 — für alle lesbar.
+ if (!OperatingSystem.IsWindows())
+ {
+ try
+ {
+ File.SetUnixFileMode(path,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+ catch (IOException) { /* etwa auf Netzlaufwerken ohne Rechteverwaltung */ }
+ catch (UnauthorizedAccessException) { }
+ }
+
+ return path;
+ }
+
+ /// Schränkt eine Datei unter Unix auf 0600 ein. Unter Windows wirkungslos.
+ public static void RestrictToOwner(string filePath)
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ try
+ {
+ File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+
+ // ─── Auflösung ───
+
+ private static string ResolveConfig()
+ {
+ if (FromEnvironment("CLAWD_CONFIG_DIR") is { } explicitDir)
+ return explicitDir;
+
+ if (OperatingSystem.IsWindows())
+ return Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppFolder);
+
+ if (FromEnvironment("XDG_CONFIG_HOME") is { } xdg)
+ return Path.Combine(xdg, AppFolder.ToLowerInvariant());
+
+ if (FromEnvironment("HOME") is { } home)
+ return Path.Combine(home, ".config", AppFolder.ToLowerInvariant());
+
+ // Ein Dienst ohne HOME. Lieber ein fester, dokumentierter Ort als ein relativer
+ // Pfad, der vom Arbeitsverzeichnis abhängt und beim nächsten Start woanders liegt.
+ return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
+ }
+
+ private static string ResolveData()
+ {
+ if (FromEnvironment("CLAWD_DATA_DIR") is { } explicitDir)
+ return explicitDir;
+
+ if (OperatingSystem.IsWindows())
+ return Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolder);
+
+ if (FromEnvironment("XDG_DATA_HOME") is { } xdg)
+ return Path.Combine(xdg, AppFolder.ToLowerInvariant());
+
+ if (FromEnvironment("HOME") is { } home)
+ return Path.Combine(home, ".local", "share", AppFolder.ToLowerInvariant());
+
+ return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
+ }
+
+ private static string? FromEnvironment(string name)
+ {
+ var value = Environment.GetEnvironmentVariable(name);
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+
+ ///
+ /// GetFolderPath kann eine leere Zeichenkette liefern (Dienstkonto ohne
+ /// geladenes Profil). Path.Combine("", …) ergäbe dann einen relativen Pfad —
+ /// die Datei landete im Arbeitsverzeichnis und wäre beim nächsten Start verschwunden.
+ ///
+ private static string Combine(string root, string folder)
+ => string.IsNullOrWhiteSpace(root)
+ ? Path.Combine(AppContext.BaseDirectory, folder)
+ : Path.Combine(root, folder);
+}
diff --git a/src/ClawdDotNet.Core/Storage/AtomicFile.cs b/src/ClawdDotNet.Core/Storage/AtomicFile.cs
index d3d612b..ae800a3 100644
--- a/src/ClawdDotNet.Core/Storage/AtomicFile.cs
+++ b/src/ClawdDotNet.Core/Storage/AtomicFile.cs
@@ -29,11 +29,15 @@ public static class AtomicFile
/// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
/// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
/// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
+ ///
+ /// Der Schlüssel kommt aus : Unter Windows
+ /// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe
+ /// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen.
///
private static readonly ConcurrentDictionary PathLocks = new();
private static SemaphoreSlim LockFor(string fullPath)
- => PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
+ => PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1));
///
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
diff --git a/src/ClawdDotNet.Core/Storage/PathBoundary.cs b/src/ClawdDotNet.Core/Storage/PathBoundary.cs
new file mode 100644
index 0000000..5b01c15
--- /dev/null
+++ b/src/ClawdDotNet.Core/Storage/PathBoundary.cs
@@ -0,0 +1,155 @@
+namespace ClawdDotNet.Core.Storage;
+
+///
+/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut.
+///
+/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit
+/// OrdinalIgnoreCase — die Annahme von Windows, dass Groß- und Kleinschreibung
+/// keine Rolle spielt. Unter Linux ist das falsch: /home/x/Workspace und
+/// /home/x/workspace sind zwei verschiedene Verzeichnisse. Ein Kandidat im
+/// zweiten würde als „innerhalb" des ersten durchgehen.
+///
+/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung.
+///
+/// Zweiter Punkt, den es unter Windows so nicht gab: symbolische Verknüpfungen.
+/// Path.GetFullPath löst sie nicht auf — es rechnet nur .. heraus. Legt
+/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach /etc an, liegt
+/// workspace/etc/passwd nach reiner Zeichenkettenrechnung innerhalb, zeigt aber
+/// hinaus. löst deshalb jeden Pfadabschnitt auf.
+///
+public static class PathBoundary
+{
+ ///
+ /// Wie das Dateisystem Pfade vergleicht.
+ ///
+ /// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist
+ /// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber
+ /// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt,
+ /// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab,
+ /// statt zu wenig.
+ ///
+ public static StringComparison Comparison { get; } =
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ ///
+ public static StringComparer Comparer { get; } =
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+
+ /// Absoluter Pfad mit genau einem abschließenden Trenner.
+ public static string NormalizeDirectory(string path)
+ {
+ var full = Path.GetFullPath(path);
+ return full.EndsWith(Path.DirectorySeparatorChar)
+ ? full
+ : full + Path.DirectorySeparatorChar;
+ }
+
+ ///
+ /// Prüft, ob im Verzeichnis liegt.
+ ///
+ /// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne
+ /// den abschließenden Trenner gälte …/Workspace-Backup als Teil von
+ /// …/Workspace.
+ ///
+ /// Symbolische Verknüpfungen werden aufgelöst () — ein
+ /// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
+ ///
+ public static bool IsInside(string candidate, string root)
+ {
+ var normalizedRoot = Canonicalize(NormalizeDirectory(root));
+ var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));
+
+ // Der Root selbst gilt als innerhalb.
+ if (string.Equals(
+ normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
+ normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
+ Comparison))
+ {
+ return true;
+ }
+
+ return normalizedCandidate.StartsWith(
+ NormalizeDirectory(normalizedRoot), Comparison);
+ }
+
+ ///
+ /// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen.
+ ///
+ /// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort
+ /// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen.
+ ///
+ public static string CanonicalKey(string path)
+ {
+ var full = Path.GetFullPath(path);
+ return Comparison == StringComparison.OrdinalIgnoreCase
+ ? full.ToLowerInvariant()
+ : full;
+ }
+
+ ///
+ /// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
+ ///
+ /// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt
+ /// workspace/daten nach /etc, dann liegt workspace/daten/passwd
+ /// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung
+ /// ist. ResolveLinkTarget(returnFinalTarget: true) folgt dabei ganzen Ketten,
+ /// ein Aufruf je Abschnitt genügt also.
+ ///
+ /// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
+ /// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
+ /// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
+ ///
+ public static string Canonicalize(string path)
+ {
+ var full = Path.GetFullPath(path);
+
+ var root = Path.GetPathRoot(full);
+ if (string.IsNullOrEmpty(root))
+ return full;
+
+ var rest = full[root.Length..]
+ .Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
+
+ var current = root;
+
+ foreach (var segment in rest)
+ {
+ current = Path.Combine(current, segment);
+
+ string? target;
+ try
+ {
+ // Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
+ // nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
+ target = Directory.Exists(current)
+ ? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
+ : File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
+ }
+ catch (IOException)
+ {
+ // Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
+ // wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
+ // dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
+ return full;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return full;
+ }
+
+ if (!string.IsNullOrEmpty(target))
+ {
+ current = Path.IsPathRooted(target)
+ ? target
+ : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target));
+ }
+ }
+
+ return Path.GetFullPath(current);
+ }
+}
diff --git a/src/ClawdDotNet.Core/Storage/PortableFileName.cs b/src/ClawdDotNet.Core/Storage/PortableFileName.cs
new file mode 100644
index 0000000..261ac5e
--- /dev/null
+++ b/src/ClawdDotNet.Core/Storage/PortableFileName.cs
@@ -0,0 +1,55 @@
+namespace ClawdDotNet.Core.Storage;
+
+///
+/// Erzeugt Dateinamen, die auf Windows und Linux gültig sind.
+///
+/// Hintergrund (Linux-Portierung): liefert
+/// unter Windows 41 Zeichen, unter Unix genau zwei (\0 und /). Wer sich
+/// darauf verlässt, erzeugt unter Linux Namen wie bericht:2026.zip — dort
+/// zulässig, unter Windows nicht anlegbar.
+///
+/// Das trifft alles, was zwischen Systemen wandert: Sicherungsarchive, Logdateien,
+/// Task-Dateien im geteilten Arbeitsverzeichnis. Deshalb hier bewusst der strengere
+/// Maßstab auf beiden Plattformen — ein paar Unterstriche mehr sind billiger als eine
+/// Sicherung, die sich auf dem Zielsystem nicht auspacken lässt.
+///
+public static class PortableFileName
+{
+ /// Unter Windows unzulässig, unter Unix erlaubt — hier immer ersetzt.
+ private static readonly char[] WindowsReserved =
+ ['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
+
+ ///
+ /// Gerätenamen, die Windows unabhängig von der Endung nicht als Datei zulässt.
+ /// Unter Linux völlig gewöhnliche Namen — deshalb fällt es dort erst beim
+ /// Zurückspielen auf.
+ ///
+ private static readonly string[] ReservedNames =
+ [
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
+ ];
+
+ public static string Sanitize(string? name, string fallback = "unbenannt")
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return fallback;
+
+ var chars = name.Select(c =>
+ c < 32 || WindowsReserved.Contains(c) ? '_' : c).ToArray();
+
+ // Windows schneidet abschließende Punkte und Leerzeichen stillschweigend ab —
+ // aus "bericht." würde "bericht", und zwei Dateien fielen zusammen.
+ var result = new string(chars).TrimEnd('.', ' ');
+
+ if (result.Length == 0)
+ return fallback;
+
+ var stem = Path.GetFileNameWithoutExtension(result);
+ if (ReservedNames.Contains(stem, StringComparer.OrdinalIgnoreCase))
+ result = "_" + result;
+
+ return result;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index b0bbacc..582943f 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -152,6 +152,112 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
ON RunUsage (OccurredAt DESC);
+
+ -- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
+ -- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
+ CREATE TABLE IF NOT EXISTS Tasks (
+ Id TEXT PRIMARY KEY,
+ Title TEXT NOT NULL DEFAULT '',
+ Status TEXT NOT NULL DEFAULT 'todo',
+ Type TEXT NOT NULL DEFAULT 'work',
+ Priority INTEGER NOT NULL DEFAULT 3,
+ Assignee TEXT NOT NULL DEFAULT '@human',
+ WhenKind TEXT NULL,
+ WhenValue TEXT NULL,
+ WhenTz TEXT NULL,
+ RequireApproval INTEGER NOT NULL DEFAULT 0,
+ Acceptance TEXT NOT NULL DEFAULT '',
+ -- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
+ BlockedBy TEXT NOT NULL DEFAULT '',
+ OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
+ Body TEXT NOT NULL DEFAULT '',
+ FileName TEXT NOT NULL DEFAULT '',
+ -- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
+ ToolName TEXT NOT NULL DEFAULT '',
+ JobTypeId TEXT NOT NULL DEFAULT '',
+ -- Ausführungszustand: nur das Board schreibt hier.
+ LastOccurrence TEXT NULL,
+ ClaimToken TEXT NULL,
+ ClaimedAt TEXT NULL,
+ CreatedAt TEXT NOT NULL,
+ UpdatedAt TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS IX_Tasks_Status
+ ON Tasks (Status);
+
+ CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
+ ON Tasks (Assignee);
+
+ -- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
+ -- stempelt die Engine, Korrekturen sind neue Zeilen.
+ CREATE TABLE IF NOT EXISTS AuditLog (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ RunId TEXT NOT NULL,
+ AgentId TEXT NOT NULL,
+ Model TEXT NOT NULL DEFAULT '',
+ Source TEXT NOT NULL DEFAULT 'unknown',
+ Tool TEXT NOT NULL,
+ Arguments TEXT NOT NULL DEFAULT '',
+ Status TEXT NOT NULL,
+ Summary TEXT NOT NULL DEFAULT '',
+ DurationMs INTEGER NOT NULL DEFAULT 0,
+ OccurredAt TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
+ ON AuditLog (RunId, Id);
+
+ CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
+ ON AuditLog (Id DESC);
+
+ -- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
+ CREATE TABLE IF NOT EXISTS RunReceipts (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ RunId TEXT NOT NULL,
+ AgentId TEXT NOT NULL,
+ Model TEXT NOT NULL DEFAULT '',
+ Source TEXT NOT NULL DEFAULT 'unknown',
+ TaskId TEXT NULL,
+ Status TEXT NOT NULL DEFAULT '',
+ StepCount INTEGER NOT NULL DEFAULT 0,
+ PromptTokens INTEGER NOT NULL DEFAULT 0,
+ CompletionTokens INTEGER NOT NULL DEFAULT 0,
+ CachedTokens INTEGER NOT NULL DEFAULT 0,
+ CostUsd TEXT NOT NULL DEFAULT '0',
+ CostIsKnown INTEGER NOT NULL DEFAULT 0,
+ DurationMs INTEGER NOT NULL DEFAULT 0,
+ ResultRef TEXT NOT NULL DEFAULT '',
+ OccurredAt TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
+ ON RunReceipts (TaskId);
+
+ CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
+ ON RunReceipts (RunId);
+
+ -- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
+ -- genau der gespeicherte Argument-JSON (Plan-Freeze).
+ CREATE TABLE IF NOT EXISTS StagedCalls (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ RunId TEXT NOT NULL,
+ AgentId TEXT NOT NULL,
+ InstanceId TEXT NOT NULL DEFAULT '',
+ Tool TEXT NOT NULL,
+ Action TEXT NULL,
+ ArgumentsJson TEXT NOT NULL DEFAULT '',
+ Proposal TEXT NOT NULL DEFAULT '',
+ Status TEXT NOT NULL DEFAULT 'Pending',
+ CreatedAt TEXT NOT NULL,
+ DecidedAt TEXT NULL,
+ DecidedBy TEXT NULL,
+ ResultRef TEXT NULL,
+ RejectionReason TEXT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
+ ON StagedCalls (Status, Id);
""";
cmd.ExecuteNonQuery();
diff --git a/src/ClawdDotNet.Core/Storage/SystemShell.cs b/src/ClawdDotNet.Core/Storage/SystemShell.cs
new file mode 100644
index 0000000..9d04577
--- /dev/null
+++ b/src/ClawdDotNet.Core/Storage/SystemShell.cs
@@ -0,0 +1,101 @@
+using System.Diagnostics;
+
+namespace ClawdDotNet.Core.Storage;
+
+///
+/// Öffnet Ordner und Dateien im Dateimanager des Systems.
+///
+/// Hintergrund (Linux-Portierung): Die Oberfläche rief an vier Stellen direkt
+/// explorer.exe auf. Hier liegt das plattformneutral — und in Core statt in der
+/// Oberfläche, weil die künftige Avalonia-Fassung dieselben Aufrufe braucht.
+///
+/// Bewusst ohne Rückmeldung im Fehlerfall: Einen Ordner zu öffnen ist eine
+/// Bequemlichkeit. Schlägt es fehl (kein Dateimanager installiert, Dienst ohne
+/// Sitzung), soll das den Aufrufer nicht beschäftigen.
+///
+public static class SystemShell
+{
+ /// Öffnet ein Verzeichnis im Dateimanager.
+ public static bool OpenFolder(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
+ return false;
+
+ return Open(path);
+ }
+
+ ///
+ /// Öffnet den Ordner einer Datei und hebt sie nach Möglichkeit hervor.
+ ///
+ /// Das Hervorheben können nur Windows und macOS. Unter Linux gibt es keinen
+ /// Aufruf, der über alle Dateimanager hinweg funktioniert — dort wird nur der
+ /// Ordner geöffnet. Das ist der kleinere Verlust gegenüber einer Liste von
+ /// Sonderfällen je Desktop-Umgebung.
+ ///
+ public static bool RevealFile(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ return false;
+
+ var folder = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (string.IsNullOrEmpty(folder)) return false;
+
+ try
+ {
+ if (OperatingSystem.IsWindows())
+ return Start("explorer.exe", ["/select,", Path.GetFullPath(path)]);
+
+ if (OperatingSystem.IsMacOS())
+ return Start("open", ["-R", Path.GetFullPath(path)]);
+ }
+ catch { /* siehe Klassenkommentar */ }
+
+ return OpenFolder(folder);
+ }
+
+ private static bool Open(string target)
+ {
+ var full = Path.GetFullPath(target);
+
+ try
+ {
+ if (OperatingSystem.IsLinux())
+ return Start("xdg-open", [full]);
+
+ if (OperatingSystem.IsMacOS())
+ return Start("open", [full]);
+
+ // Windows: UseShellExecute lässt die Shell entscheiden, statt explorer.exe
+ // festzuschreiben.
+ using var process = Process.Start(new ProcessStartInfo(full)
+ {
+ UseShellExecute = true
+ });
+ return process is not null;
+ }
+ catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Startet mit ArgumentList statt einer Argumentzeichenkette — so muss nichts
+ /// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen
+ /// zusätzlichen Aufruf einschleusen.
+ ///
+ private static bool Start(string fileName, string[] arguments)
+ {
+ var info = new ProcessStartInfo(fileName)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ foreach (var argument in arguments)
+ info.ArgumentList.Add(argument);
+
+ using var process = Process.Start(info);
+ return process is not null;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs b/src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs
new file mode 100644
index 0000000..2ae38ec
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs
@@ -0,0 +1,123 @@
+using System.Text.RegularExpressions;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Einmalige Überführung der improvisierten coordination/*.md-Dateien ins Taskboard
+/// (A1, Schritt 4).
+///
+/// Bewusst konservativ, weil die Altdateien kein Schema haben: Nur task_*.md gelten
+/// als Aufgaben und werden als Backlog-Aufgaben mit Assignee @human
+/// übernommen — ein Mensch ordnet sie zu, bevor irgendetwas läuft. status_*,
+/// broadcast, Incident-Berichte und *.json-Artefakte sind keine Aufgaben und
+/// bleiben unangetastet. Was sich nicht sicher deuten lässt, wird nicht verfälscht.
+///
+/// Idempotent: Eine übernommene Datei wandert nach coordination/migrated/ — sie
+/// wird nicht gelöscht (die Historie bleibt), aber ein zweiter Start findet sie nicht mehr.
+///
+public sealed class CoordinationMigration
+{
+ private readonly TaskboardService _board;
+ private readonly string _coordinationDir;
+ private readonly ILogger _logger;
+
+ private static readonly Regex TaskHeading =
+ new(@"^#\s*Task\b[^:]*:\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex AnyHeading =
+ new(@"^#\s+(.+)$", RegexOptions.Compiled);
+
+ public CoordinationMigration(
+ TaskboardService board, string coordinationDirectory, ILoggerFactory loggerFactory)
+ {
+ _board = board;
+ _coordinationDir = coordinationDirectory;
+ _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Migration");
+ }
+
+ /// Führt die Migration aus. Gibt die Zahl der übernommenen Aufgaben zurück.
+ public async Task RunAsync(CancellationToken ct)
+ {
+ if (!Directory.Exists(_coordinationDir))
+ return 0;
+
+ var migratedDir = Path.Combine(_coordinationDir, "migrated");
+ var count = 0;
+
+ // Nur die oberste Ebene — der migrated/-Unterordner wird so nie erneut gelesen.
+ foreach (var path in Directory.EnumerateFiles(_coordinationDir, "task_*.md"))
+ {
+ string text;
+ try { text = AtomicFile.ReadAllText(path); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Migration: {File} nicht lesbar — übersprungen", path);
+ continue;
+ }
+
+ var fileName = Path.GetFileName(path);
+ var title = ExtractTitle(text, fileName);
+
+ var body =
+ $"_Übernommen aus coordination/{fileName} bei der Taskboard-Migration (A1). " +
+ "Assignee und Status bitte prüfen._\n\n" + text.Trim();
+
+ await _board.CreateAsync(new TaskItem
+ {
+ Title = title,
+ Body = body,
+ Status = TaskItemStatus.Backlog, // erst nach menschlicher Sichtung bereit
+ Assignee = TaskAssignee.Human, // sicherer Standard, bis jemand zuordnet
+ Type = TaskItemType.Work
+ }, ct);
+
+ try
+ {
+ Directory.CreateDirectory(migratedDir);
+ File.Move(path, Path.Combine(migratedDir, fileName), overwrite: true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Migration: {File} übernommen, konnte aber nicht verschoben werden", fileName);
+ }
+
+ count++;
+ }
+
+ if (count > 0)
+ _logger.LogInformation("Taskboard-Migration: {Count} coordination/task_*-Datei(en) übernommen", count);
+
+ return count;
+ }
+
+ private static string ExtractTitle(string text, string fileName)
+ {
+ foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
+ {
+ var match = TaskHeading.Match(line.Trim());
+ if (match.Success)
+ return Trim(match.Groups[1].Value);
+ }
+
+ foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
+ {
+ var match = AnyHeading.Match(line.Trim());
+ if (match.Success)
+ return Trim(match.Groups[1].Value);
+ }
+
+ // Kein Titel im Text: aus dem Dateinamen ableiten ("task_video_x" → "video x").
+ var stem = Path.GetFileNameWithoutExtension(fileName);
+ if (stem.StartsWith("task_", StringComparison.OrdinalIgnoreCase))
+ stem = stem[5..];
+ return Trim(stem.Replace('_', ' '));
+ }
+
+ private static string Trim(string value)
+ {
+ var v = value.Trim();
+ return v.Length > 120 ? v[..120].TrimEnd() : v;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs
new file mode 100644
index 0000000..cc539c6
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs
@@ -0,0 +1,171 @@
+using System.Text;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Engine;
+using ClawdDotNet.Core.State;
+using ClawdDotNet.Core.Tools;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Verbindet den Scanner mit der Engine: übersetzt den Assignee einer fälligen Aufgabe in
+/// einen konkreten Lauf.
+///
+/// - @new / @new:<agent> → : frischer
+/// Lauf ohne Historie.
+/// - @<agent> → : der Agent mit seinem
+/// bestehenden Kontext. Das Agent-Gate der Engine serialisiert solche Läufe (B2).
+///
+/// Ist der Ziel-Agent nicht auflösbar (unbekannt, oder bloßes @new bei mehreren
+/// Agenten), scheitert der Dispatch mit einer klaren Meldung, statt einen falschen Agenten
+/// zu raten.
+///
+public sealed class EngineTaskDispatcher : ITaskDispatcher
+{
+ private readonly AgentEngine _engine;
+ private readonly Func> _agents;
+ private readonly string _instanceId;
+ private readonly ToolRegistry _toolRegistry;
+ private readonly IStateStore _stateStore;
+ private readonly ILogger _logger;
+
+ public EngineTaskDispatcher(
+ AgentEngine engine,
+ Func> agents,
+ string instanceId,
+ ToolRegistry toolRegistry,
+ IStateStore stateStore,
+ ILoggerFactory loggerFactory)
+ {
+ _engine = engine;
+ _agents = agents;
+ _instanceId = instanceId;
+ _toolRegistry = toolRegistry;
+ _stateStore = stateStore;
+ _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher");
+ }
+
+ public async Task DispatchAsync(TaskItem task, CancellationToken ct)
+ {
+ // Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
+ if (task.Type == TaskItemType.ToolJob)
+ return await DispatchToolJobAsync(task, ct);
+
+ var agents = _agents();
+ var message = BuildMessage(task);
+ var kind = TaskAssignee.KindOf(task.Assignee);
+
+ AgentRunResult result;
+ switch (kind)
+ {
+ case TaskAssigneeKind.New:
+ {
+ var config = ResolveFreshAgent(agents, TaskAssignee.AgentId(task.Assignee));
+ if (config is null)
+ {
+ _logger.LogWarning(
+ "Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)",
+ task.Id, task.Assignee);
+ return false;
+ }
+ result = await _engine.RunAsync(
+ config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
+ break;
+ }
+ case TaskAssigneeKind.Agent:
+ {
+ var agentId = TaskAssignee.AgentId(task.Assignee);
+ var config = agents.FirstOrDefault(a => a.AgentId == agentId);
+ if (config is null)
+ {
+ _logger.LogWarning(
+ "Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId);
+ return false;
+ }
+ result = await _engine.ChatAsync(
+ config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
+ break;
+ }
+ default:
+ // @human wird vom Scanner gar nicht erst angestoßen.
+ return false;
+ }
+
+ return result.Status == AgentRunStatus.Completed;
+ }
+
+ ///
+ /// Tickt einen (Poll) und weckt den Ziel-Agenten nur,
+ /// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts
+ /// zu tun war — ein Poll ohne Fund ist kein Fehler.
+ ///
+ private async Task DispatchToolJobAsync(TaskItem task, CancellationToken ct)
+ {
+ if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider)
+ {
+ _logger.LogWarning(
+ "Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert",
+ task.Id, task.ToolName);
+ return false;
+ }
+
+ // Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
+ var agentId = TaskAssignee.AgentId(task.Assignee);
+ var config = _agents().FirstOrDefault(a => a.AgentId == agentId);
+ var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg)
+ ? (IReadOnlyDictionary)cfg.AsReadOnly()
+ : new Dictionary().AsReadOnly();
+
+ var result = await provider.ExecuteJobAsync(
+ task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);
+
+ if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage))
+ return true; // Poll lief, nichts zu wecken
+
+ if (config is null)
+ {
+ _logger.LogWarning(
+ "Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde",
+ task.Id, agentId);
+ return true;
+ }
+
+ // Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos.
+ if (result.UseChatContext)
+ await _engine.ChatAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
+ else
+ await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
+
+ return true;
+ }
+
+ private static AgentConfig? ResolveFreshAgent(IReadOnlyList agents, string agentId)
+ {
+ if (!string.IsNullOrWhiteSpace(agentId))
+ return agents.FirstOrDefault(a => a.AgentId == agentId);
+
+ // Bloßes @new ohne Agent: nur eindeutig, wenn die Instanz genau einen Agenten hat.
+ return agents.Count == 1 ? agents[0] : null;
+ }
+
+ private static string BuildMessage(TaskItem task)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
+
+ if (!string.IsNullOrWhiteSpace(task.Body))
+ {
+ sb.AppendLine();
+ sb.AppendLine(task.Body.Trim());
+ }
+
+ if (!string.IsNullOrWhiteSpace(task.Acceptance))
+ {
+ sb.AppendLine();
+ sb.AppendLine("Abnahmekriterien (das Ergebnis wird daran gemessen):");
+ sb.AppendLine(task.Acceptance.Trim());
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+}
diff --git a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
new file mode 100644
index 0000000..325706d
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
@@ -0,0 +1,65 @@
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Der Ausführungszustand des Taskboards. Die Definition der Aufgaben lebt in
+/// Markdown-Dateien; dieses Repository ist die DB-Seite, die das Claiming atomar macht
+/// (was ein Dateisystem nicht verlässlich kann) und dem Scanner erlaubt, ohne
+/// Dateizugriff zu entscheiden.
+///
+public interface ITaskRepository
+{
+ ///
+ /// Legt eine Aufgabe an oder aktualisiert ihre Definition (Importer). Idempotent über
+ /// die — ein zweiter Import erzeugt keine Dublette. Der
+ /// Ausführungszustand (Marker, Claim) bleibt dabei unangetastet.
+ ///
+ Task UpsertAsync(TaskItem task, CancellationToken ct);
+
+ Task GetAsync(string id, CancellationToken ct);
+
+ Task> ListAsync(TaskQuery query, CancellationToken ct);
+
+ ///
+ /// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
+ /// jeder weitere erhält false. Setzt den Marker ()
+ /// sofort — ein Termin ist damit auch dann verbraucht, wenn der Lauf später scheitert
+ /// oder der Prozess abstürzt (at-most-once, kein Retry-Sturm).
+ ///
+ /// Sortierbarer ISO-UTC-Zeitstempel des Termins.
+ ///
+ /// Claims, die älter sind, gelten als verwaist (abgestürzter Lauf) und dürfen
+ /// überschrieben werden.
+ ///
+ Task TryClaimAsync(
+ string id, string occurrenceKey, string claimToken,
+ DateTime now, DateTime leaseCutoff, CancellationToken ct);
+
+ ///
+ /// Schließt einen beanspruchten Lauf ab und setzt den Endstatus. Nur wirksam, solange
+ /// der Aufrufer den Claim noch hält ( passt) — ein Lauf,
+ /// der seine Lease verloren hat, überschreibt nichts mehr. Gibt zurück, ob der Claim
+ /// noch gehörte.
+ ///
+ Task CompleteClaimAsync(
+ string id, string claimToken, TaskItemStatus finalStatus,
+ DateTime now, CancellationToken ct);
+
+ /// Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).
+ Task SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
+
+ ///
+ /// Reconciliation beim Start: verwaiste Claims (älter als )
+ /// lösen und die betroffenen Aufgaben von zurück
+ /// auf stellen. Gibt die Zahl der zurückgesetzten
+ /// Aufgaben zurück.
+ ///
+ Task ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct);
+
+ ///
+ /// Aufgaben, die auf warten — Grundlage für den
+ /// Auto-Dispatch, wenn der letzte Blocker fertig wird.
+ ///
+ Task> ListBlockedByAsync(string blockerId, CancellationToken ct);
+
+ Task CountAsync(CancellationToken ct);
+}
diff --git a/src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs b/src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs
new file mode 100644
index 0000000..88d78e3
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs
@@ -0,0 +1,106 @@
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Scheduling;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Überführt die alten Scheduler-Konfigurationen ins Taskboard — der letzte Schritt, um
+/// die Alt-Scheduler abzulösen: alles Periodische ist danach ein Task.
+///
+/// - Ein scheduler (Agent nach Cron) → ein Task mit when: cron und Assignee
+/// @new:<agent> (frischer Lauf, wie der alte AgentScheduler).
+/// - Jeder toolJobs-Eintrag (Poll) → ein Task vom Typ tool_job.
+///
+/// Einmalig und nicht-destruktiv: Existiert der Task (stabile Id) schon, wird er
+/// übersprungen — spätere Änderungen an der Task-Datei bleiben erhalten.
+///
+public sealed class SchedulerTaskMigration
+{
+ private readonly TaskboardService _board;
+ private readonly ITaskRepository _repo;
+ private readonly ILogger _logger;
+
+ public SchedulerTaskMigration(TaskboardService board, ITaskRepository repo, ILoggerFactory loggerFactory)
+ {
+ _board = board;
+ _repo = repo;
+ _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.SchedulerMigration");
+ }
+
+ public async Task RunAsync(IEnumerable agents, CancellationToken ct)
+ {
+ // IANA-Schreibweise, nicht TimeZoneInfo.Local.Id: Unter Windows lieferte das
+ // "W. Europe Standard Time", unter Linux "Europe/Berlin". Die erzeugten
+ // Task-Dateien wandern zwischen Rechnern — sie brauchen die Form, die überall
+ // gilt.
+ var localTz = TimeZones.LocalIanaId;
+ var created = 0;
+
+ foreach (var agent in agents)
+ {
+ if (agent.Scheduler is { } scheduler && !string.IsNullOrWhiteSpace(scheduler.Cron))
+ created += await MigrateSchedulerAsync(agent, scheduler, localTz, ct);
+
+ foreach (var job in agent.ToolJobs)
+ created += await MigrateToolJobAsync(agent, job, localTz, ct);
+ }
+
+ if (created > 0)
+ _logger.LogInformation("Scheduler-Migration: {Count} Task(s) aus Alt-Konfiguration angelegt", created);
+
+ return created;
+ }
+
+ private async Task MigrateSchedulerAsync(
+ AgentConfig agent, SchedulerConfig scheduler, string tz, CancellationToken ct)
+ {
+ var id = $"sched-{agent.AgentId}";
+ if (await _repo.GetAsync(id, ct) is not null)
+ return 0;
+
+ await _board.CreateAsync(new TaskItem
+ {
+ Id = id,
+ Title = $"Geplanter Lauf: {Display(agent)}",
+ Body = scheduler.TaskMessage,
+ Status = TaskItemStatus.Todo,
+ Type = TaskItemType.Work,
+ Assignee = $"@new:{agent.AgentId}", // frischer Lauf, wie der alte AgentScheduler
+ When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = scheduler.Cron, TimeZone = tz }
+ }, ct);
+
+ return 1;
+ }
+
+ private async Task MigrateToolJobAsync(
+ AgentConfig agent, ToolJobConfig job, string tz, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(job.Cron) || string.IsNullOrWhiteSpace(job.ToolName))
+ return 0;
+
+ var id = $"tj-{agent.AgentId}-{job.JobId}";
+ if (await _repo.GetAsync(id, ct) is not null)
+ return 0;
+
+ await _board.CreateAsync(new TaskItem
+ {
+ Id = id,
+ Title = $"Poll: {job.ToolName}/{job.JobTypeId} → {Display(agent)}",
+ Body = $"Wiederkehrender {job.ToolName}-Poll ({job.JobTypeId}). Weckt {Display(agent)} bei einem Ereignis.",
+ // Deaktivierte Jobs kommen als backlog — der Scanner nimmt nur todo/backlog...
+ // backlog wird aber nicht geclaimt, also ruht der Poll, bis jemand ihn aktiviert.
+ Status = job.Enabled ? TaskItemStatus.Todo : TaskItemStatus.Backlog,
+ Type = TaskItemType.ToolJob,
+ ToolName = job.ToolName,
+ JobTypeId = job.JobTypeId,
+ Assignee = $"@{agent.AgentId}", // Zielagent zum Wecken
+ When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = job.Cron, TimeZone = tz }
+ }, ct);
+
+ return 1;
+ }
+
+ private static string Display(AgentConfig agent)
+ => string.IsNullOrWhiteSpace(agent.DisplayName) ? agent.AgentId : agent.DisplayName;
+}
diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
new file mode 100644
index 0000000..5b2f48e
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
@@ -0,0 +1,318 @@
+using System.Text;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Data.Sqlite;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Der Ausführungszustand des Taskboards in der Instanz-Datenbank.
+///
+/// Der Kern ist : ein bedingtes UPDATE, das genau eine
+/// Zeile trifft und damit garantiert, dass nie zwei Läufe denselben Termin ziehen. Genau
+/// das kann ein Dateisystem nicht verlässlich — deshalb liegt der Zustand hier und nicht
+/// bei den Task-Dateien.
+///
+/// Zeitangaben werden als sortierbares ISO-UTC ("O") abgelegt, damit
+/// Zeichenketten-Vergleiche in SQL derselben Ordnung folgen wie die Zeit selbst.
+///
+public sealed class SqliteTaskRepository : ITaskRepository
+{
+ private readonly SqliteStorage _storage;
+
+ public SqliteTaskRepository(SqliteStorage storage) => _storage = storage;
+
+ public Task UpsertAsync(TaskItem task, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ var now = DateTime.UtcNow;
+ var existing = await ReadAsync(conn, task.Id, ct);
+
+ if (existing is null)
+ {
+ using var insert = conn.CreateCommand();
+ insert.CommandText = """
+ INSERT INTO Tasks
+ (Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
+ RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
+ ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt)
+ VALUES
+ (@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
+ @requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
+ @toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
+ """;
+ BindDefinition(insert, task);
+ insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
+ insert.Parameters.AddWithValue("@createdAt", Format(now));
+ insert.Parameters.AddWithValue("@updatedAt", Format(now));
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+ else
+ {
+ // Nur die Definition aktualisieren. Status und Ausführungszustand
+ // (Marker, Claim) gehören der DB — ein Re-Import darf einen laufenden
+ // oder abgeschlossenen Zustand nicht zurücksetzen. Status-Änderungen
+ // laufen über SetStatusAsync bzw. den Scanner.
+ using var update = conn.CreateCommand();
+ update.CommandText = """
+ UPDATE Tasks
+ SET Title = @title, Type = @type, Priority = @priority, Assignee = @assignee,
+ WhenKind = @whenKind, WhenValue = @whenValue, WhenTz = @whenTz,
+ RequireApproval = @requireApproval, Acceptance = @acceptance,
+ BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
+ FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
+ UpdatedAt = @updatedAt
+ WHERE Id = @id
+ """;
+ BindDefinition(update, task);
+ update.Parameters.AddWithValue("@updatedAt", Format(now));
+ await update.ExecuteNonQueryAsync(ct);
+ }
+
+ return (await ReadAsync(conn, task.Id, ct))!;
+ }, ct);
+
+ public async Task GetAsync(string id, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ return await ReadAsync(conn, id, ct);
+ }
+
+ public async Task> ListAsync(TaskQuery query, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+
+ var sql = new StringBuilder("SELECT " + Columns + " FROM Tasks WHERE 1 = 1");
+
+ if (query.Status is { } status)
+ {
+ sql.Append(" AND Status = @status");
+ cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
+ }
+ else if (!query.IncludeArchived)
+ {
+ sql.Append(" AND Status <> 'archived'");
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Assignee))
+ {
+ sql.Append(" AND Assignee = @assignee COLLATE NOCASE");
+ cmd.Parameters.AddWithValue("@assignee", query.Assignee.Trim());
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Search))
+ {
+ sql.Append(" AND (Title LIKE @search ESCAPE '\\' COLLATE NOCASE"
+ + " OR Body LIKE @search ESCAPE '\\' COLLATE NOCASE)");
+ cmd.Parameters.AddWithValue("@search", "%" + Escape(query.Search.Trim()) + "%");
+ }
+
+ // Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält.
+ sql.Append(" ORDER BY Priority DESC, UpdatedAt DESC LIMIT @limit");
+ cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 500));
+ cmd.CommandText = sql.ToString();
+
+ var results = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ results.Add(Read(reader));
+
+ return results;
+ }
+
+ public Task TryClaimAsync(
+ string id, string occurrenceKey, string claimToken,
+ DateTime now, DateTime leaseCutoff, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE Tasks
+ SET ClaimToken = @token, ClaimedAt = @now, Status = 'in_progress',
+ LastOccurrence = @occ, UpdatedAt = @now
+ WHERE Id = @id
+ AND Status = 'todo'
+ AND (LastOccurrence IS NULL OR LastOccurrence < @occ)
+ AND (ClaimToken IS NULL OR ClaimedAt < @leaseCutoff)
+ """;
+ cmd.Parameters.AddWithValue("@token", claimToken);
+ cmd.Parameters.AddWithValue("@now", Format(now));
+ cmd.Parameters.AddWithValue("@occ", occurrenceKey);
+ cmd.Parameters.AddWithValue("@id", id);
+ cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
+
+ return await cmd.ExecuteNonQueryAsync(ct) == 1;
+ }, ct);
+
+ public Task CompleteClaimAsync(
+ string id, string claimToken, TaskItemStatus finalStatus, DateTime now, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE Tasks
+ SET Status = @status, ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
+ WHERE Id = @id AND ClaimToken = @token
+ """;
+ cmd.Parameters.AddWithValue("@status", TaskText.Of(finalStatus));
+ cmd.Parameters.AddWithValue("@now", Format(now));
+ cmd.Parameters.AddWithValue("@id", id);
+ cmd.Parameters.AddWithValue("@token", claimToken);
+
+ return await cmd.ExecuteNonQueryAsync(ct) > 0;
+ }, ct);
+
+ public Task SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE Tasks SET Status = @status, UpdatedAt = @now WHERE Id = @id";
+ cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
+ cmd.Parameters.AddWithValue("@now", Format(now));
+ cmd.Parameters.AddWithValue("@id", id);
+
+ return await cmd.ExecuteNonQueryAsync(ct) > 0;
+ }, ct);
+
+ public Task ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
+ => _storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE Tasks
+ SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
+ WHERE Status = 'in_progress' AND ClaimToken IS NOT NULL AND ClaimedAt < @leaseCutoff
+ """;
+ cmd.Parameters.AddWithValue("@now", Format(now));
+ cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
+
+ return await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ public async Task> ListBlockedByAsync(string blockerId, CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + Columns
+ + " FROM Tasks WHERE BlockedBy LIKE @needle ESCAPE '\\'";
+ cmd.Parameters.AddWithValue("@needle", "%|" + Escape(blockerId.Trim()) + "|%");
+
+ var results = new List();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ results.Add(Read(reader));
+
+ return results;
+ }
+
+ public async Task CountAsync(CancellationToken ct)
+ {
+ await using var conn = await _storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM Tasks";
+ return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private const string Columns =
+ "Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
+ "RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
+ "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
+
+ private static void BindDefinition(SqliteCommand cmd, TaskItem task)
+ {
+ cmd.Parameters.AddWithValue("@id", task.Id);
+ cmd.Parameters.AddWithValue("@title", task.Title);
+ cmd.Parameters.AddWithValue("@type", TaskText.Of(task.Type));
+ cmd.Parameters.AddWithValue("@priority", Math.Clamp(task.Priority, 1, 5));
+ cmd.Parameters.AddWithValue("@assignee", task.Assignee);
+ cmd.Parameters.AddWithValue("@whenKind", (object?)(task.When is null ? null : TaskText.Of(task.When.Kind)) ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@whenValue", (object?)task.When?.Value ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@whenTz", (object?)task.When?.TimeZone ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@requireApproval", task.RequireApproval ? 1 : 0);
+ cmd.Parameters.AddWithValue("@acceptance", task.Acceptance);
+ cmd.Parameters.AddWithValue("@blockedBy", SerializeBlockedBy(task.BlockedBy));
+ cmd.Parameters.AddWithValue("@market", task.OnlyWhenMarketOpen ? 1 : 0);
+ cmd.Parameters.AddWithValue("@body", task.Body);
+ cmd.Parameters.AddWithValue("@fileName", task.FileName);
+ cmd.Parameters.AddWithValue("@toolName", task.ToolName);
+ cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
+ }
+
+ private static async Task ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Id = @id LIMIT 1";
+ cmd.Parameters.AddWithValue("@id", id);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ return await reader.ReadAsync(ct) ? Read(reader) : null;
+ }
+
+ private static TaskItem Read(SqliteDataReader r)
+ {
+ TaskWhen? when = null;
+ if (!r.IsDBNull(6) && TaskText.WhenKind(r.GetString(6)) is { } kind)
+ {
+ when = new TaskWhen
+ {
+ Kind = kind,
+ Value = r.IsDBNull(7) ? "" : r.GetString(7),
+ TimeZone = r.IsDBNull(8) ? "" : r.GetString(8)
+ };
+ }
+
+ return new TaskItem
+ {
+ Id = r.GetString(0),
+ Title = r.GetString(1),
+ Status = TaskText.Status(r.GetString(2)),
+ Type = TaskText.Type(r.GetString(3)),
+ Priority = r.GetInt32(4),
+ Assignee = r.GetString(5),
+ When = when,
+ RequireApproval = r.GetInt32(9) != 0,
+ Acceptance = r.GetString(10),
+ BlockedBy = DeserializeBlockedBy(r.GetString(11)),
+ OnlyWhenMarketOpen = r.GetInt32(12) != 0,
+ Body = r.GetString(13),
+ FileName = r.GetString(14),
+ ToolName = r.GetString(15),
+ JobTypeId = r.GetString(16),
+ LastOccurrence = r.IsDBNull(17) ? null : r.GetString(17),
+ ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
+ ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
+ CreatedAt = Parse(r.GetString(20)),
+ UpdatedAt = Parse(r.GetString(21))
+ };
+ }
+
+ /// Blocker als "|a|b|" — die Begrenzer erlauben eine Suche nach ganzen Ids,
+ /// ohne dass t-1 auch t-10 trifft.
+ private static string SerializeBlockedBy(IReadOnlyList ids)
+ {
+ var cleaned = ids
+ .Select(x => x.Trim().Replace("|", ""))
+ .Where(x => x.Length > 0)
+ .Distinct()
+ .ToList();
+
+ return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|";
+ }
+
+ private static IReadOnlyList DeserializeBlockedBy(string raw)
+ => raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ private static string Escape(string value) => value
+ .Replace(@"\", @"\\")
+ .Replace("%", @"\%")
+ .Replace("_", @"\_");
+
+ private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+
+ private static DateTime Parse(string value)
+ => DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)
+ ? dt
+ : DateTime.MinValue;
+}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs
new file mode 100644
index 0000000..e854ced
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs
@@ -0,0 +1,313 @@
+using System.Text;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen
+/// ----Zeilen, gefolgt vom Rumpf.
+///
+/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist
+/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten when, einer
+/// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt
+/// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die
+/// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten.
+///
+public static class TaskFrontmatter
+{
+ ///
+ /// Zerlegt eine Task-Datei in ihre Definition. Gibt false mit einer Meldung
+ /// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand
+ /// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB.
+ ///
+ public static bool TryParse(string text, out TaskItem task, out string? error)
+ {
+ task = new TaskItem();
+ error = null;
+
+ var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
+
+ // Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren).
+ var lines = normalized.Split('\n');
+ var open = -1;
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var t = lines[i].TrimStart('').Trim();
+ if (t.Length == 0) continue;
+ if (t == "---") { open = i; break; }
+ break; // erste nicht-leere Zeile ist kein Fence
+ }
+
+ if (open < 0)
+ {
+ error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile).";
+ return false;
+ }
+
+ var close = -1;
+ for (var i = open + 1; i < lines.Length; i++)
+ {
+ if (lines[i].Trim() == "---") { close = i; break; }
+ }
+
+ if (close < 0)
+ {
+ error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt).";
+ return false;
+ }
+
+ var block = lines[(open + 1)..close];
+ var body = string.Join('\n', lines[(close + 1)..]).Trim('\n');
+
+ var scalars = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var when = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var blockedBy = new List();
+ string? acceptance = null;
+
+ var j = 0;
+ while (j < block.Length)
+ {
+ var raw = block[j];
+ var trimmed = raw.Trim();
+ if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; }
+
+ // Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen
+ // gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen.
+ if (Indent(raw) > 0) { j++; continue; }
+
+ var colon = trimmed.IndexOf(':');
+ if (colon < 0) { j++; continue; }
+
+ var key = trimmed[..colon].Trim();
+ var rest = trimmed[(colon + 1)..].Trim();
+
+ if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0)
+ {
+ j = CollectChildren(block, j + 1, child =>
+ {
+ var c = child.IndexOf(':');
+ if (c < 0) return;
+ when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim());
+ });
+ continue;
+ }
+
+ if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest))
+ {
+ acceptance = ReadBlockScalar(block, ref j);
+ continue;
+ }
+
+ if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase))
+ {
+ if (rest.StartsWith('['))
+ {
+ blockedBy.AddRange(ParseInlineList(rest));
+ j++;
+ }
+ else if (rest.Length == 0)
+ {
+ // Block-Liste: nachfolgende "- item"-Zeilen.
+ j = CollectChildren(block, j + 1, child =>
+ {
+ var item = child.TrimStart();
+ if (item.StartsWith('-'))
+ blockedBy.Add(Unquote(item[1..].Trim()));
+ });
+ }
+ else
+ {
+ blockedBy.AddRange(ParseInlineList(rest));
+ j++;
+ }
+ continue;
+ }
+
+ scalars[key] = Unquote(rest);
+ j++;
+ }
+
+ TaskWhen? whenValue = null;
+ if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind)
+ {
+ whenValue = new TaskWhen
+ {
+ Kind = kind,
+ Value = when.GetValueOrDefault("value", ""),
+ TimeZone = when.GetValueOrDefault("tz", "")
+ };
+ }
+
+ task = new TaskItem
+ {
+ Id = scalars.GetValueOrDefault("id", ""),
+ Title = scalars.GetValueOrDefault("title", ""),
+ Status = TaskText.Status(scalars.GetValueOrDefault("status")),
+ Type = TaskText.Type(scalars.GetValueOrDefault("type")),
+ Priority = ParsePriority(scalars.GetValueOrDefault("priority")),
+ Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human),
+ When = whenValue,
+ RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")),
+ Acceptance = acceptance?.Trim() ?? "",
+ BlockedBy = blockedBy,
+ OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
+ ToolName = scalars.GetValueOrDefault("tool_name", ""),
+ JobTypeId = scalars.GetValueOrDefault("job_type", ""),
+ Body = body
+ };
+ return true;
+ }
+
+ /// Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand
+ /// bleibt außen vor — er gehört in die DB, nicht in die Datei.
+ public static string Serialize(TaskItem task)
+ {
+ var sb = new StringBuilder();
+ sb.Append("---\n");
+ sb.Append($"id: {Scalar(task.Id)}\n");
+ sb.Append($"title: {Scalar(task.Title)}\n");
+ sb.Append($"status: {TaskText.Of(task.Status)}\n");
+ sb.Append($"type: {TaskText.Of(task.Type)}\n");
+ sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
+ sb.Append($"assignee: {Scalar(task.Assignee)}\n");
+
+ if (task.When is { } w)
+ {
+ sb.Append("when:\n");
+ sb.Append($" kind: {TaskText.Of(w.Kind)}\n");
+ sb.Append($" value: {Scalar(w.Value)}\n");
+ sb.Append($" tz: {Scalar(w.TimeZone)}\n");
+ }
+
+ // Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird.
+ if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName))
+ {
+ sb.Append($"tool_name: {Scalar(task.ToolName)}\n");
+ sb.Append($"job_type: {Scalar(task.JobTypeId)}\n");
+ }
+
+ sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n");
+
+ if (!string.IsNullOrWhiteSpace(task.Acceptance))
+ {
+ sb.Append("acceptance: |\n");
+ foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n'))
+ sb.Append(" ").Append(line).Append('\n');
+ }
+
+ if (task.BlockedBy.Count > 0)
+ sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n");
+
+ sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n");
+ sb.Append("---\n");
+
+ if (!string.IsNullOrWhiteSpace(task.Body))
+ sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n');
+
+ return sb.ToString();
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private static int Indent(string line)
+ {
+ var n = 0;
+ while (n < line.Length && line[n] == ' ') n++;
+ return n;
+ }
+
+ /// Ruft für jede eingerückte Folgezeile auf und
+ /// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück.
+ private static int CollectChildren(string[] block, int start, Action onChild)
+ {
+ var k = start;
+ while (k < block.Length)
+ {
+ var line = block[k];
+ if (line.Trim().Length == 0) { k++; continue; }
+ if (Indent(line) == 0) break;
+ onChild(line.Trim());
+ k++;
+ }
+ return k;
+ }
+
+ private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-";
+
+ /// Liest einen eingerückten Block-Skalar und entfernt die gemeinsame
+ /// Einrückung. Setzt hinter den Block.
+ private static string ReadBlockScalar(string[] block, ref int j)
+ {
+ var collected = new List();
+ var k = j + 1;
+ var commonIndent = int.MaxValue;
+
+ while (k < block.Length)
+ {
+ var line = block[k];
+ if (line.Trim().Length == 0) { collected.Add(""); k++; continue; }
+ if (Indent(line) == 0) break;
+ commonIndent = Math.Min(commonIndent, Indent(line));
+ collected.Add(line);
+ k++;
+ }
+
+ j = k;
+ if (commonIndent == int.MaxValue) commonIndent = 0;
+
+ var sb = new StringBuilder();
+ foreach (var line in collected)
+ sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n');
+
+ return sb.ToString().TrimEnd('\n');
+ }
+
+ private static IEnumerable ParseInlineList(string rest)
+ {
+ var inner = rest.Trim();
+ if (inner.StartsWith('[')) inner = inner[1..];
+ if (inner.EndsWith(']')) inner = inner[..^1];
+
+ return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(Unquote)
+ .Where(s => s.Length > 0);
+ }
+
+ private static string Unquote(string value)
+ {
+ var v = value.Trim();
+ if (v.Length >= 2 && v[0] == '"' && v[^1] == '"')
+ return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\");
+ if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'')
+ return v[1..^1].Replace("''", "'");
+ return v;
+ }
+
+ /// Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes
+ /// @ (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert
+ /// werden.
+ private static string Scalar(string? value)
+ {
+ var v = value ?? "";
+ if (v.Length == 0) return "\"\"";
+
+ var needsQuote =
+ v != v.Trim()
+ || "@-[]{}>|*&!%#`,\"'".Contains(v[0])
+ || v.Contains(':')
+ || v.Contains('#')
+ || v.Contains('\n');
+
+ if (!needsQuote) return v;
+
+ return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+ }
+
+ private static bool ParseBool(string? value)
+ => value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on";
+
+ private static int ParsePriority(string? value)
+ => int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
+
+ private static string EmptyToDefault(string? value, string fallback)
+ => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
+}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs
new file mode 100644
index 0000000..31e390b
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs
@@ -0,0 +1,233 @@
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Lebenszyklus einer Aufgabe. Als Zeichenkette in der DB und im Frontmatter abgelegt,
+/// damit beide Seiten dieselbe, menschenlesbare Form teilen.
+///
+public enum TaskItemStatus
+{
+ /// Angelegt, aber noch nicht zur Ausführung freigegeben.
+ Backlog,
+
+ /// Bereit; der Scanner darf sie beim nächsten fälligen Termin nehmen.
+ Todo,
+
+ /// Ein Lauf hat sie beansprucht und arbeitet daran.
+ InProgress,
+
+ /// Erledigt, wartet aber auf Review (require_approval).
+ InReview,
+
+ /// Abgeschlossen.
+ Done,
+
+ /// Verworfen, ohne erledigt zu sein.
+ Canceled,
+
+ /// Wartet auf einen offenen Blocker (blocked_by) oder auf eine Meldung.
+ Blocked,
+
+ /// Aus dem Board genommen (Datei gelöscht, aufgeräumt) — bleibt für die Historie.
+ Archived
+}
+
+///
+/// Art der Aufgabe. Ein Mensch ist einfach ein Assignee, kein Sonderpfad — approval
+/// und human_input unterscheiden sich nur darin, dass ihr Ergebnis von einem
+/// Menschen statt von einem Modell kommt.
+///
+public enum TaskItemType
+{
+ Work,
+ Approval,
+ HumanInput,
+
+ ///
+ /// Ein wiederkehrender Poll: Der Scanner tickt beim fälligen Termin einen
+ /// IToolJobProvider (siehe /)
+ /// und weckt den Agenten nur, wenn der Tick etwas meldet. Ersetzt den früheren
+ /// ToolJobScheduler — alles Periodische ist jetzt ein Task.
+ ///
+ ToolJob
+}
+
+/// Wie ein Termin zu verstehen ist.
+public enum TaskWhenKind
+{
+ /// Einmaliger Zeitpunkt (ISO 8601).
+ At,
+
+ /// Wiederkehrendes Intervall (z. B. 30m, 2h).
+ Every,
+
+ /// 5-Felder-Cron-Ausdruck.
+ Cron
+}
+
+///
+/// Ein Termin. Die Zeitzone ist Pflicht — das ist die Antwort auf B7 (Cron lief bisher
+/// zonen-blind in Lokalzeit). Ohne Zone lässt sich 0 7 * * * nicht eindeutig
+/// deuten.
+///
+public sealed record TaskWhen
+{
+ public TaskWhenKind Kind { get; init; }
+ public string Value { get; init; } = "";
+
+ /// IANA-Zeitzone, etwa Europe/Berlin.
+ public string TimeZone { get; init; } = "";
+}
+
+///
+/// Wer eine Aufgabe ausführt. Löst das implizite UseChatContext-Flag (T7) durch
+/// eine explizite Angabe am Auftrag ab.
+///
+public enum TaskAssigneeKind
+{
+ /// @new — frischer Lauf ohne Historie (RunAsync).
+ New,
+
+ /// @<agentId> — bestehender Agent mit seinem Kontext (ChatAsync).
+ Agent,
+
+ /// @human — wartet auf einen Menschen; kein Modell-Lauf.
+ Human
+}
+
+///
+/// Deutet den assignee-String, ohne ihn selbst zu speichern. Formen:
+/// @human, @<agentId> (bestehender Kontext), @new bzw.
+/// @new:<agentId> (frischer Lauf ohne Historie). Bei bloßem @new ohne
+/// Agent bleibt die Ziel-Id offen — dann muss die Instanz genau einen Agenten haben.
+///
+public static class TaskAssignee
+{
+ public const string New = "@new";
+ public const string Human = "@human";
+
+ public static TaskAssigneeKind KindOf(string? assignee)
+ {
+ var value = assignee?.Trim();
+ if (string.Equals(value, Human, StringComparison.OrdinalIgnoreCase))
+ return TaskAssigneeKind.Human;
+ if (string.Equals(value, New, StringComparison.OrdinalIgnoreCase)
+ || value?.StartsWith("@new:", StringComparison.OrdinalIgnoreCase) == true)
+ return TaskAssigneeKind.New;
+ return TaskAssigneeKind.Agent;
+ }
+
+ ///
+ /// Die reine Agent-Id. Für @<agent> der Teil hinter dem @, für
+ /// @new:<agent> der Teil hinter dem Doppelpunkt. Leer bei @human
+ /// und bloßem @new.
+ ///
+ public static string AgentId(string? assignee)
+ {
+ var value = assignee?.Trim() ?? "";
+ switch (KindOf(value))
+ {
+ case TaskAssigneeKind.Agent:
+ return value.StartsWith('@') ? value[1..] : value;
+ case TaskAssigneeKind.New:
+ var colon = value.IndexOf(':');
+ return colon >= 0 ? value[(colon + 1)..].Trim() : "";
+ default:
+ return "";
+ }
+ }
+}
+
+///
+/// Eine Aufgabe — die Spiegelung der Frontmatter-Definition zusammen mit dem
+/// Ausführungszustand. Die Definition ist in der Datei die Wahrheit, der
+/// Ausführungszustand in der DB (siehe Taskboard-Konzept). Diese Zeile hält beides,
+/// damit der Scanner ohne Dateizugriff entscheiden kann.
+///
+public sealed record TaskItem
+{
+ // ─── Definition (aus dem Frontmatter gespiegelt) ───
+
+ /// Stabile Identität. Überlebt das Umbenennen der Datei.
+ public string Id { get; init; } = "";
+
+ public string Title { get; init; } = "";
+ public TaskItemStatus Status { get; init; } = TaskItemStatus.Todo;
+ public TaskItemType Type { get; init; } = TaskItemType.Work;
+
+ /// 1 (niedrig) .. 5 (hoch).
+ public int Priority { get; init; } = 3;
+
+ public string Assignee { get; init; } = TaskAssignee.Human;
+
+ /// Termin; null = einmalige, sofort fällige Aufgabe.
+ public TaskWhen? When { get; init; }
+
+ /// Gilt erst nach Review als .
+ public bool RequireApproval { get; init; }
+
+ /// Abnahmekriterien, gegen die das Ergebnis geprüft wird.
+ public string Acceptance { get; init; } = "";
+
+ /// Ids der Aufgaben, die erst erledigt sein müssen.
+ public IReadOnlyList BlockedBy { get; init; } = [];
+
+ /// C1: Termin nur auslösen, wenn der Markt offen ist.
+ public bool OnlyWhenMarketOpen { get; init; }
+
+ /// Auftragsbeschreibung — geht als Aufgabenstellung an den Agenten.
+ public string Body { get; init; } = "";
+
+ /// Dateiname relativ zu tasks/. Dient dem Rückschreiben.
+ public string FileName { get; init; } = "";
+
+ // ─── Nur für Typ tool_job ───
+
+ /// Das zu tickende Tool (z. B. Telegram) — nur bei tool_job.
+ public string ToolName { get; init; } = "";
+
+ /// Die Job-Art des Tools (z. B. telegram_poll) — nur bei tool_job.
+ public string JobTypeId { get; init; } = "";
+
+ // ─── Ausführungszustand (nur das Board schreibt) ───
+
+ ///
+ /// Occurrence-Key des zuletzt behandelten Termins (sortierbares ISO-UTC). Ein neuer
+ /// Termin gilt nur als fällig, wenn er hierüber liegt — so wird ein Termin höchstens
+ /// einmal ausgelöst.
+ ///
+ public string? LastOccurrence { get; init; }
+
+ /// Gesetzt, solange ein Lauf die Aufgabe beansprucht.
+ public string? ClaimToken { get; init; }
+
+ public DateTime? ClaimedAt { get; init; }
+
+ public DateTime CreatedAt { get; init; }
+ public DateTime UpdatedAt { get; init; }
+
+ ///
+ /// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf todo zurück statt auf
+ /// done — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
+ /// tool_job ist immer ein Poll und damit wiederkehrend.
+ ///
+ public bool IsRecurring
+ => Type == TaskItemType.ToolJob
+ || When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
+}
+
+/// Suchkriterien für .
+public sealed record TaskQuery
+{
+ public TaskItemStatus? Status { get; init; }
+
+ /// Filtert auf einen Assignee (roher String, z. B. @crawler).
+ public string? Assignee { get; init; }
+
+ /// Freitext über Titel und Beschreibung.
+ public string? Search { get; init; }
+
+ /// Archivierte werden standardmäßig ausgeblendet.
+ public bool IncludeArchived { get; init; }
+
+ public int Limit { get; init; } = 50;
+}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
new file mode 100644
index 0000000..5fe5ac7
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
@@ -0,0 +1,262 @@
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der
+/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar.
+///
+public interface ITaskDispatcher
+{
+ /// Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss.
+ Task DispatchAsync(TaskItem task, CancellationToken ct);
+}
+
+///
+/// Entscheidet, ob ein marktabhängiger Termin ()
+/// jetzt laufen darf. Platzhalter, bis C1 (Marktkalender) den echten Kalender liefert.
+///
+public interface IMarketCalendar
+{
+ bool IsOpen(DateTime nowUtc);
+}
+
+/// Bis C1 kommt: der Markt gilt als immer offen.
+public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
+{
+ public bool IsOpen(DateTime nowUtc) => true;
+}
+
+///
+/// Der Taktgeber des Taskboards (A1). Ein einziger Takt prüft, was fällig ist —
+/// keine langen Delays (B6). Persistiert wird nur der Last-Fired-Marker; ein
+/// fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin (kein Retry-Sturm).
+///
+/// Der heikle Kern ist streng gegen drei Invarianten gebaut, die die Repository-Tests
+/// und die Scanner-Tests absichern:
+/// 1. Nie zwei Läufe auf denselben Termin — das atomare .
+/// 2. Kein Dispatch bei offenem Blocker — blockierte Aufgaben sind nicht claimbar.
+/// 3. Doppelter Takt = ein Lauf — konkurrierende Claims um denselben Occurrence-Key.
+///
+public sealed class TaskScanner : IAsyncDisposable
+{
+ private readonly ITaskRepository _repo;
+ private readonly ITaskDispatcher _dispatcher;
+ private readonly IMarketCalendar _market;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _tick;
+ private readonly TimeSpan _lease;
+
+ /// Aufgaben, deren unauflösbare Zeitzone bereits gemeldet wurde — der Takt
+ /// läuft jede Minute, die Meldung soll nicht mitlaufen.
+ private readonly HashSet _warnedTimeZones = [];
+
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _loop;
+
+ public TaskScanner(
+ ITaskRepository repo,
+ ITaskDispatcher dispatcher,
+ ILoggerFactory loggerFactory,
+ IMarketCalendar? market = null,
+ TimeProvider? clock = null,
+ TimeSpan? tick = null,
+ TimeSpan? lease = null)
+ {
+ _repo = repo;
+ _dispatcher = dispatcher;
+ _market = market ?? new AlwaysOpenMarketCalendar();
+ _clock = clock ?? TimeProvider.System;
+ _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner");
+ _tick = tick ?? TimeSpan.FromSeconds(60);
+ _lease = lease ?? TimeSpan.FromMinutes(15);
+ }
+
+ public void Start()
+ {
+ _loop ??= RunLoopAsync(_cts.Token);
+ }
+
+ /// Läuft die Taktschleife gerade?
+ public bool IsRunning => _loop is { IsCompleted: false };
+
+ ///
+ /// Die Schleife lief und ist beendet — anders als „noch nicht gestartet". Der
+ /// Unterschied zählt für die Zustandsmeldung an den Watchdog: Ein Scanner, der noch
+ /// auf die Startabgleichung wartet, ist in Ordnung; einer, dessen Schleife
+ /// ausgestiegen ist, bedeutet, dass keine Aufgabe mehr läuft.
+ ///
+ public bool HasStopped => _loop is { IsCompleted: true };
+
+ private async Task RunLoopAsync(CancellationToken ct)
+ {
+ // PeriodicTimer über den TimeProvider — im Test steuerbar, im Betrieb driftfrei.
+ using var timer = new PeriodicTimer(_tick, _clock);
+ while (await timer.WaitForNextTickAsync(ct))
+ {
+ try
+ {
+ await ScanOnceAsync(ct);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Ein Scanner-Takt ist gescheitert");
+ }
+ }
+ }
+
+ ///
+ /// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der
+ /// tatsächlich angestoßenen Läufe zurück. Öffentlich, damit Tests einen Takt
+ /// deterministisch auslösen können.
+ ///
+ public async Task ScanOnceAsync(CancellationToken ct)
+ {
+ var now = _clock.GetUtcNow().UtcDateTime;
+ var leaseCutoff = now - _lease;
+
+ var candidates = await _repo.ListAsync(new TaskQuery { Limit = 500 }, ct);
+
+ var claimed = new List<(TaskItem Task, string Token)>();
+ foreach (var task in candidates)
+ {
+ if (task.Status != TaskItemStatus.Todo)
+ continue; // backlog (Halte-Status), blockiert, laufend, erledigt bleiben außen vor
+
+ // Ein Mensch ist kein Modell-Lauf — solche Aufgaben rührt der Scanner nicht an.
+ if (TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human)
+ continue;
+
+ // Eine Aufgabe mit unauflösbarer Zeitzone feuert nie. Das einmal melden,
+ // sonst sucht man den Fehler bei der Aufgabe statt beim System.
+ if (TaskSchedule.UnresolvableTimeZone(task) is { } badZone
+ && _warnedTimeZones.Add(task.Id))
+ {
+ _logger.LogWarning(
+ "Aufgabe {TaskId} ({Title}) hat die Zeitzone '{TimeZone}', die auf diesem "
+ + "System nicht auflösbar ist — sie wird nicht ausgeführt. Zeitzone in "
+ + "IANA-Schreibweise eintragen (z.B. 'Europe/Berlin') und sicherstellen, "
+ + "dass tzdata und ICU vorhanden sind.",
+ task.Id, task.Title, badZone);
+ }
+
+ var occ = TaskSchedule.DueOccurrence(task, now);
+ if (occ is null)
+ continue;
+
+ if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
+ continue;
+
+ var token = Guid.NewGuid().ToString("N");
+ if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
+ claimed.Add((task, token));
+ }
+
+ // Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das
+ // Agent-Gate der Engine; verschiedene Agenten laufen echt parallel.
+ await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct)));
+
+ return claimed.Count;
+ }
+
+ ///
+ /// Führt einen Task sofort aus (manueller „Jetzt ausführen"-Knopf), unabhängig vom
+ /// Termin. Beansprucht ihn atomar wie ein Takt — läuft er schon oder ist er kein
+ /// todo, gibt die Methode false zurück, statt ihn doppelt anzustoßen.
+ ///
+ public async Task RunTaskNowAsync(string taskId, CancellationToken ct)
+ {
+ var task = await _repo.GetAsync(taskId, ct);
+ if (task is null)
+ return false;
+
+ var now = _clock.GetUtcNow().UtcDateTime;
+ var token = Guid.NewGuid().ToString("N");
+ if (!await _repo.TryClaimAsync(task.Id, now.ToString("O"), token, now, now - _lease, ct))
+ return false;
+
+ await ProcessAsync(task, token, ct);
+ return true;
+ }
+
+ private async Task ProcessAsync(TaskItem task, string token, CancellationToken ct)
+ {
+ bool ok;
+ try
+ {
+ ok = await _dispatcher.DispatchAsync(task, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id);
+ ok = false;
+ }
+
+ var now = _clock.GetUtcNow().UtcDateTime;
+
+ // Endstatus:
+ // - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
+ // wird nicht wiederholt).
+ // - Wiederkehrend (Cron/every/tool_job) → zurück auf todo, damit der nächste Termin
+ // feuern kann. Sonst liefe ein Cron-Task nur ein einziges Mal.
+ // - Einmalig → done bzw. in_review (A2).
+ var final = !ok || task.IsRecurring
+ ? TaskItemStatus.Todo
+ : task.RequireApproval
+ ? TaskItemStatus.InReview
+ : TaskItemStatus.Done;
+
+ await _repo.CompleteClaimAsync(task.Id, token, final, now, ct);
+
+ if (ok && final == TaskItemStatus.Done)
+ await UnblockDependentsAsync(task.Id, now, ct);
+ }
+
+ ///
+ /// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
+ /// freigegeben, sobald alle ihre Blocker erledigt sind.
+ ///
+ private async Task UnblockDependentsAsync(string completedId, DateTime now, CancellationToken ct)
+ {
+ var dependents = await _repo.ListBlockedByAsync(completedId, ct);
+ foreach (var dependent in dependents)
+ {
+ if (dependent.Status != TaskItemStatus.Blocked)
+ continue;
+
+ var allDone = true;
+ foreach (var blockerId in dependent.BlockedBy)
+ {
+ var blocker = await _repo.GetAsync(blockerId, ct);
+ if (blocker is null || blocker.Status != TaskItemStatus.Done)
+ {
+ allDone = false;
+ break;
+ }
+ }
+
+ if (allDone)
+ {
+ await _repo.SetStatusAsync(dependent.Id, TaskItemStatus.Todo, now, ct);
+ _logger.LogInformation(
+ "Aufgabe {TaskId} freigegeben — alle Blocker erledigt", dependent.Id);
+ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+ if (_loop is not null)
+ {
+ try { await _loop; }
+ catch (OperationCanceledException) { }
+ }
+ _cts.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
new file mode 100644
index 0000000..8a3958f
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
@@ -0,0 +1,205 @@
+using System.Globalization;
+using ClawdDotNet.Core.Scheduling;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
+/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
+///
+/// Das ist die Antwort auf B6 und B7: Statt eines langen Task.Delay bis zum
+/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
+/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
+/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
+///
+/// Höchstens ein Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
+/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
+/// eine Welle aus.
+///
+public static class TaskSchedule
+{
+ ///
+ /// Der Occurrence-Key des fälligen Termins, oder null, wenn nichts ansteht.
+ /// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
+ /// () liegt.
+ ///
+ public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
+ {
+ nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
+
+ if (task.When is null)
+ {
+ // Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
+ if (task.LastOccurrence is not null) return null;
+ var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
+ return Iso(basis);
+ }
+
+ return task.When.Kind switch
+ {
+ TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
+ TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
+ TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
+ _ => null
+ };
+ }
+
+ ///
+ /// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
+ /// null.
+ ///
+ /// Ein solcher Termin feuert nie (siehe ResolveTimeZone). Damit das nicht
+ /// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
+ /// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
+ /// (W. Europe Standard Time) auf einem System ohne ICU-Daten.
+ ///
+ public static string? UnresolvableTimeZone(TaskItem task)
+ => task.When is { } when
+ && !string.IsNullOrWhiteSpace(when.TimeZone)
+ && !TimeZones.IsKnown(when.TimeZone)
+ ? when.TimeZone
+ : null;
+
+ private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
+ {
+ if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
+ if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
+ if (nowUtc < targetUtc) return null;
+ return Iso(targetUtc);
+ }
+
+ private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
+ {
+ if (!TryParseInterval(task.When!.Value, out var interval)) return null;
+
+ var anchor = marker is not null
+ ? ParseIso(marker)
+ : (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
+
+ return nowUtc < anchor + interval ? null : Iso(nowUtc);
+ }
+
+ private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
+ {
+ var when = task.When!;
+ CronExpression cron;
+ try { cron = CronExpression.Parse(when.Value); }
+ catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen
+
+ var tz = ResolveTimeZone(when.TimeZone);
+ if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit
+
+ var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));
+
+ // Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
+ // ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
+ // nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
+ var lowerUtc = marker is not null ? ParseIso(marker)
+ : task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
+ : nowUtc;
+ var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));
+
+ var limit = lowerLocal;
+ DateTime? matchLocal = null;
+ for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
+ {
+ if (cron.Matches(candidate)) { matchLocal = candidate; break; }
+ }
+
+ if (matchLocal is null) return null;
+
+ // Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen.
+ var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified);
+ if (tz.IsInvalidTime(unspecified)) return null;
+
+ var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
+ var occ = Iso(occUtc);
+
+ // Nur fällig, wenn der Termin echt über dem Marker liegt.
+ return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ /// Deutet einen at-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
+ /// angegebenen Zeitzone.
+ private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
+ {
+ utc = default;
+ var v = value.Trim();
+ if (v.Length == 0) return false;
+
+ if (v.EndsWith('Z') || v.EndsWith('z'))
+ {
+ if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal, out var dto))
+ {
+ utc = dto.UtcDateTime;
+ return true;
+ }
+ return false;
+ }
+
+ if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
+ {
+ var tz = ResolveTimeZone(timeZone);
+ if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
+
+ var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
+ if (tz.IsInvalidTime(unspecified)) return false;
+ utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
+ return true;
+ }
+
+ return false;
+ }
+
+ /// Ein Intervall wie 30s, 15m, 2h, 1d.
+ private static bool TryParseInterval(string value, out TimeSpan interval)
+ {
+ interval = default;
+ var v = value.Trim().ToLowerInvariant();
+ if (v.Length < 2) return false;
+
+ var unit = v[^1];
+ if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
+ return false;
+
+ interval = unit switch
+ {
+ 's' => TimeSpan.FromSeconds(n),
+ 'm' => TimeSpan.FromMinutes(n),
+ 'h' => TimeSpan.FromHours(n),
+ 'd' => TimeSpan.FromDays(n),
+ _ => TimeSpan.Zero
+ };
+ return interval > TimeSpan.Zero;
+ }
+
+ ///
+ /// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
+ ///
+ /// Eine unbekannte Zone gibt null zurück — und der Aufrufer behandelt
+ /// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
+ /// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
+ /// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
+ ///
+ /// Sichtbar wird er beim Import — weist eine Aufgabe
+ /// mit unbekannter Zone mit Meldung ab.
+ ///
+ private static TimeZoneInfo? ResolveTimeZone(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
+ return TimeZones.TryResolve(id);
+ }
+
+ private static DateTime Truncate(DateTime value)
+ => new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);
+
+ private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
+
+ private static DateTime ParseIso(string value)
+ => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
+ ? dt.ToUniversalTime()
+ : DateTime.MinValue;
+}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskText.cs b/src/ClawdDotNet.Core/Tasks/TaskText.cs
new file mode 100644
index 0000000..b870e54
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskText.cs
@@ -0,0 +1,70 @@
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Übersetzt die Aufzählungen in ihre menschenlesbare Form und zurück. Dieselbe
+/// Schreibweise wird im Frontmatter wie in der DB verwendet, damit beide Seiten sich
+/// nicht auseinanderentwickeln. Unbekannte Eingaben fallen auf einen sicheren Standard
+/// zurück, statt zu werfen — eine von Hand editierte Datei soll das Board nicht kippen.
+///
+public static class TaskText
+{
+ public static string Of(TaskItemStatus status) => status switch
+ {
+ TaskItemStatus.Backlog => "backlog",
+ TaskItemStatus.Todo => "todo",
+ TaskItemStatus.InProgress => "in_progress",
+ TaskItemStatus.InReview => "in_review",
+ TaskItemStatus.Done => "done",
+ TaskItemStatus.Canceled => "canceled",
+ TaskItemStatus.Blocked => "blocked",
+ TaskItemStatus.Archived => "archived",
+ _ => "todo"
+ };
+
+ public static TaskItemStatus Status(string? value) => value?.Trim().ToLowerInvariant() switch
+ {
+ "backlog" => TaskItemStatus.Backlog,
+ "todo" => TaskItemStatus.Todo,
+ "in_progress" => TaskItemStatus.InProgress,
+ "in_review" => TaskItemStatus.InReview,
+ "done" => TaskItemStatus.Done,
+ "canceled" or "cancelled" => TaskItemStatus.Canceled,
+ "blocked" => TaskItemStatus.Blocked,
+ "archived" => TaskItemStatus.Archived,
+ _ => TaskItemStatus.Todo
+ };
+
+ public static string Of(TaskItemType type) => type switch
+ {
+ TaskItemType.Work => "work",
+ TaskItemType.Approval => "approval",
+ TaskItemType.HumanInput => "human_input",
+ TaskItemType.ToolJob => "tool_job",
+ _ => "work"
+ };
+
+ public static TaskItemType Type(string? value) => value?.Trim().ToLowerInvariant() switch
+ {
+ "approval" => TaskItemType.Approval,
+ "human_input" => TaskItemType.HumanInput,
+ "tool_job" => TaskItemType.ToolJob,
+ _ => TaskItemType.Work
+ };
+
+ public static string Of(TaskWhenKind kind) => kind switch
+ {
+ TaskWhenKind.At => "at",
+ TaskWhenKind.Every => "every",
+ TaskWhenKind.Cron => "cron",
+ _ => "cron"
+ };
+
+ /// Gibt null zurück, wenn die Angabe keine bekannte Terminart ist.
+ public static TaskWhenKind? WhenKind(string? value) => value?.Trim().ToLowerInvariant() switch
+ {
+ "at" => TaskWhenKind.At,
+ "every" => TaskWhenKind.Every,
+ "cron" => TaskWhenKind.Cron,
+ _ => null
+ };
+}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskboardService.cs b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs
new file mode 100644
index 0000000..b0612f3
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs
@@ -0,0 +1,186 @@
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.Core.Tasks;
+
+///
+/// Die Brücke der Wahrheitsaufteilung: Markdown-Dateien unter SharedWorkspace/tasks/
+/// halten die Definition, die DB () den Ausführungszustand.
+/// Dieser Dienst hält beide im Gleichschritt und wird von drei Seiten genutzt — dem
+/// Agenten-Tool, dem Scanner und dem Start (Reconciliation).
+///
+/// Bewusst zustandslos über den Aufrufen: nur Repository und Verzeichnis, keine
+/// gepufferten Aufgaben. So kann jede Seite ihn nach Bedarf erzeugen.
+///
+public sealed class TaskboardService
+{
+ private readonly ITaskRepository _repo;
+ private readonly string _tasksDir;
+
+ public TaskboardService(ITaskRepository repo, string tasksDirectory)
+ {
+ _repo = repo;
+ _tasksDir = tasksDirectory;
+ }
+
+ public string TasksDirectory => _tasksDir;
+
+ // ─── Lesen (direkt aus der DB) ───
+
+ public Task GetAsync(string id, CancellationToken ct) => _repo.GetAsync(id, ct);
+
+ public Task> ListAsync(TaskQuery query, CancellationToken ct)
+ => _repo.ListAsync(query, ct);
+
+ // ─── Anlegen ───
+
+ ///
+ /// Legt eine Aufgabe an: vergibt (falls nötig) eine stabile Id, schreibt die Datei und
+ /// spiegelt sie in die DB. Datei und DB entstehen in einem Zug.
+ ///
+ public async Task CreateAsync(TaskItem definition, CancellationToken ct)
+ {
+ var id = string.IsNullOrWhiteSpace(definition.Id) ? await NewIdAsync(ct) : definition.Id.Trim();
+ var fileName = string.IsNullOrWhiteSpace(definition.FileName)
+ ? BuildFileName(definition.Title, id)
+ : definition.FileName;
+
+ var toWrite = definition with { Id = id, FileName = fileName };
+ WriteFile(toWrite);
+ return await _repo.UpsertAsync(toWrite, ct);
+ }
+
+ // ─── Ändern ───
+
+ ///
+ /// Ändert die Definition einer Aufgabe (Tool-Aktion). Schreibt Datei und DB. Eine
+ /// Status-Änderung wird zusätzlich explizit gesetzt — der Import spiegelt nur die
+ /// Definition und lässt den Ausführungszustand absichtlich unangetastet, damit ein
+ /// blinder Re-Import einen laufenden Zustand nicht zurücksetzt.
+ ///
+ public async Task UpdateAsync(
+ string id, Func mutate, CancellationToken ct)
+ {
+ var current = await _repo.GetAsync(id, ct);
+ if (current is null) return null;
+
+ var updated = mutate(current) with { Id = id, FileName = current.FileName };
+ WriteFile(updated);
+ await _repo.UpsertAsync(updated, ct);
+
+ if (updated.Status != current.Status)
+ await _repo.SetStatusAsync(id, updated.Status, DateTime.UtcNow, ct);
+
+ return await _repo.GetAsync(id, ct);
+ }
+
+ /// Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an.
+ public Task AddCommentAsync(string id, string author, string text, CancellationToken ct)
+ => UpdateAsync(id, current =>
+ {
+ var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
+ var block = $"---\n**{author}** ({stamp}):\n\n{text.Trim()}";
+ var body = string.IsNullOrWhiteSpace(current.Body) ? block : $"{current.Body.TrimEnd()}\n\n{block}";
+ return current with { Body = body };
+ }, ct);
+
+ // ─── Import (Datei → DB) ───
+
+ ///
+ /// Liest alle Task-Dateien und spiegelt sie in die DB. DB-Zeilen, deren Datei
+ /// verschwunden ist, werden archiviert (nicht gelöscht — die Historie bleibt). Gibt
+ /// die Zahl der importierten Dateien zurück.
+ ///
+ public async Task ImportAllAsync(CancellationToken ct)
+ {
+ if (!Directory.Exists(_tasksDir))
+ return 0;
+
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var imported = 0;
+
+ foreach (var path in Directory.EnumerateFiles(_tasksDir, "*.md"))
+ {
+ var task = await ImportFileAsync(path, ct);
+ if (task is not null) { seen.Add(task.Id); imported++; }
+ }
+
+ // Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch;
+ // wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her.
+ var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct);
+ foreach (var task in known)
+ if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
+ await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
+
+ return imported;
+ }
+
+ /// Importiert eine einzelne Datei. Eine von Hand angelegte Datei ohne
+ /// id bekommt eine zugewiesen und wird einmalig kanonisch zurückgeschrieben.
+ public async Task ImportFileAsync(string path, CancellationToken ct)
+ {
+ string text;
+ try { text = AtomicFile.ReadAllText(path); }
+ catch { return null; }
+
+ if (!TaskFrontmatter.TryParse(text, out var definition, out _))
+ return null;
+
+ var fileName = Path.GetFileName(path);
+
+ if (string.IsNullOrWhiteSpace(definition.Id))
+ {
+ definition = definition with { Id = await NewIdAsync(ct), FileName = fileName };
+ WriteFile(definition); // Id festschreiben, damit sie den nächsten Start überlebt
+ }
+ else
+ {
+ definition = definition with { FileName = fileName };
+ }
+
+ return await _repo.UpsertAsync(definition, ct);
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private void WriteFile(TaskItem definition)
+ {
+ Directory.CreateDirectory(_tasksDir);
+ var path = Path.Combine(_tasksDir, definition.FileName);
+ AtomicFile.WriteAllText(path, TaskFrontmatter.Serialize(definition));
+ }
+
+ private async Task NewIdAsync(CancellationToken ct)
+ {
+ for (var i = 0; i < 5; i++)
+ {
+ var id = "t-" + Guid.NewGuid().ToString("N")[..6];
+ if (await _repo.GetAsync(id, ct) is null)
+ return id;
+ }
+ return "t-" + Guid.NewGuid().ToString("N")[..12];
+ }
+
+ private static string BuildFileName(string title, string id)
+ {
+ var slug = Slug(title);
+ var suffix = id.StartsWith("t-", StringComparison.Ordinal) ? id[2..] : id;
+ return (slug.Length == 0 ? "task" : slug) + "-" + suffix + ".md";
+ }
+
+ private static string Slug(string title)
+ {
+ var chars = title.Trim().ToLowerInvariant()
+ .Select(c => char.IsLetterOrDigit(c) ? c : '-')
+ .ToArray();
+ var slug = new string(chars);
+
+ while (slug.Contains("--"))
+ slug = slug.Replace("--", "-");
+ slug = slug.Trim('-');
+
+ if (slug.Length > 40)
+ slug = slug[..40].Trim('-');
+
+ return slug;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index bf33aff..16445f7 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -1,5 +1,6 @@
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.State;
+using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tools;
@@ -14,5 +15,6 @@ public sealed record AgentToolContext(
string? WorkspacePath = null,
string? SharedWorkspacePath = null,
IAgentMessageRouter? MessageRouter = null,
- IMemoryRepository? Memory = null
+ IMemoryRepository? Memory = null,
+ ITaskRepository? Tasks = null
);
diff --git a/src/ClawdDotNet.Desktop/App.axaml b/src/ClawdDotNet.Desktop/App.axaml
new file mode 100644
index 0000000..8a284dc
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/App.axaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs
new file mode 100644
index 0000000..9a9675c
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/App.axaml.cs
@@ -0,0 +1,213 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using ClawdDotNet.App;
+using ClawdDotNet.App.Services;
+using ClawdDotNet.Desktop.Services;
+using ClawdDotNet.Desktop.ViewModels;
+using ClawdDotNet.Desktop.Views;
+
+namespace ClawdDotNet.Desktop;
+
+public partial class App : Application
+{
+ private AppHost? _host;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // Erst beenden, wenn wir es sagen: Zwischen Instanzauswahl und Hauptfenster
+ // ist kurz gar kein Fenster offen. Mit OnLastWindowClose würde die Anwendung
+ // in genau dieser Lücke aussteigen.
+ desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
+
+ desktop.ShutdownRequested += async (_, _) =>
+ {
+ if (_host is not null) await _host.DisposeAsync();
+ };
+
+ // Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren,
+ // damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf
+ // dem die Fenster des Startvorgangs überhaupt erscheinen könnten.
+ _ = StartAsync(desktop);
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var result = await AppHost.StartAsync(new AppHost.Callbacks
+ {
+ SelectInstance = SelectInstanceAsync,
+ License = new AvaloniaLicensePrompt(),
+ TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt),
+ Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:")
+ });
+
+ if (result.Error is { } error)
+ {
+ await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Fehler", error);
+ desktop.Shutdown(1);
+ return;
+ }
+
+ if (result.Host is null)
+ {
+ // Abbruch durch den Benutzer oder fehlende Lizenz — beides ist bereits
+ // erklärt worden, hier kommt keine weitere Meldung hinterher.
+ desktop.Shutdown();
+ return;
+ }
+
+ _host = result.Host;
+
+ HookErrorReporting(_host);
+ HookLicenseWatch(_host, desktop);
+
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new MainWindowViewModel(_host)
+ };
+
+ desktop.MainWindow.Show();
+ desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
+
+ await ShowUpdateNoticeAsync(_host);
+ }
+
+ ///
+ /// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters.
+ ///
+ /// Erst hier verdrahtet, nicht in Main: Vor dem Aufbau gibt es weder
+ /// Einstellungen noch Token, und ohne die wäre der Meldeweg ohnehin der Leerlauf.
+ /// Die Kehrseite ist bewusst in Kauf genommen — ein Absturz während des
+ /// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.
+ ///
+ private static void HookErrorReporting(AppHost host)
+ {
+ AppDomain.CurrentDomain.UnhandledException += (_, args) =>
+ {
+ if (args.ExceptionObject is Exception ex)
+ {
+ // Der Prozess endet gleich: kurze Frist, dann weiterlaufen lassen.
+ host.Errors.ReportAsync(ex, fatal: args.IsTerminating)
+ .Wait(TimeSpan.FromSeconds(3));
+ }
+ };
+
+ TaskScheduler.UnobservedTaskException += (_, args) =>
+ {
+ _ = host.Errors.ReportAsync(args.Exception, fatal: false);
+
+ // Ohne Observe reißt eine unbeobachtete Ausnahme in manchen Konfigurationen
+ // den Prozess mit — und das wäre eine Nebenwirkung des Meldens.
+ args.SetObserved();
+ };
+
+ Dispatcher.UIThread.UnhandledException += (_, args) =>
+ {
+ _ = host.Errors.ReportAsync(args.Exception, fatal: false);
+
+ // Ein Fehler in einem Ereignisbehandler soll die Oberfläche nicht beenden.
+ args.Handled = true;
+ };
+ }
+
+ /// Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage.
+ private static void HookLicenseWatch(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ if (host.LicenseWatch is null) return;
+
+ host.LicenseWatch.Revoked += async message =>
+ {
+ await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Lizenz", message);
+ await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(2));
+ };
+ }
+
+ ///
+ /// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: Wann aktualisiert
+ /// wird, entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst
+ /// beendet, um sich zu erneuern, ist genau dann im Weg, wenn man sie braucht.
+ ///
+ private static async Task ShowUpdateNoticeAsync(AppHost host)
+ {
+ if (host.Deploymentcenter is null) return;
+
+ // Die Prüfung läuft nebenher; kurz Zeit geben, dann aufgeben.
+ for (var waited = 0; host.Deploymentcenter.Update is null && waited < 10; waited++)
+ await Task.Delay(TimeSpan.FromSeconds(1));
+
+ if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
+
+ await new AvaloniaLicensePrompt().ShowInfoAsync(
+ update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update",
+ $"Version {update.LatestVersion} ist verfügbar."
+ + (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
+ }
+
+ private static async Task SelectInstanceAsync(InstanceDirectoryManager directories)
+ => await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var viewModel = new InstancePickerViewModel(directories);
+ var window = new InstancePickerWindow { DataContext = viewModel };
+
+ // Schließt der Benutzer das Fenster, gilt das als Abbruch — sonst wartete
+ // der Start für immer auf eine Auswahl, die nie kommt.
+ window.Closed += (_, _) => viewModel.CancelCommand.Execute(null);
+
+ window.Show();
+
+ var path = await viewModel.Result;
+ window.Close();
+
+ return path;
+ });
+
+ ///
+ /// Einzeilige Abfrage — ersetzt Microsoft.VisualBasic.Interaction.InputBox,
+ /// das die Telegram-Anmeldung an Windows band.
+ ///
+ private static async Task AskAsync(string title, string prompt)
+ => await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var completion = new TaskCompletionSource();
+
+ var input = new TextBox();
+ var ok = new Button { Content = "OK", IsDefault = true };
+
+ var window = new Window
+ {
+ Title = title,
+ Width = 420,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterScreen,
+ Content = new StackPanel
+ {
+ Margin = new Thickness(20),
+ Spacing = 12,
+ Children =
+ {
+ new TextBlock { Text = prompt, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
+ input,
+ ok
+ }
+ }
+ };
+
+ ok.Click += (_, _) => window.Close();
+ window.Closed += (_, _) => completion.TrySetResult(input.Text?.Trim() ?? "");
+
+ window.Show();
+ input.Focus();
+
+ return await completion.Task;
+ });
+}
diff --git a/src/ClawdDotNet.Desktop/Assets/app.ico b/src/ClawdDotNet.Desktop/Assets/app.ico
new file mode 100644
index 0000000..0dd1605
Binary files /dev/null and b/src/ClawdDotNet.Desktop/Assets/app.ico differ
diff --git a/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj b/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj
new file mode 100644
index 0000000..28cb1d2
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj
@@ -0,0 +1,38 @@
+
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Desktop
+ ClawdDotNet
+ Assets\app.ico
+
+
+ true
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs b/src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs
new file mode 100644
index 0000000..2e93ae4
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs
@@ -0,0 +1,65 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace ClawdDotNet.Desktop.Converters;
+
+///
+/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten.
+///
+public sealed class ChatBubbleBrushConverter : IValueConverter
+{
+ public static readonly ChatBubbleBrushConverter Instance = new();
+
+ private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4));
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool isUser && isUser)
+ return UserBubble;
+
+ return null;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException("Nur zur Anzeige.");
+}
+
+///
+/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten.
+///
+public sealed class ChatAlignmentConverter : IValueConverter
+{
+ public static readonly ChatAlignmentConverter Instance = new();
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool isUser && isUser)
+ return HorizontalAlignment.Right;
+
+ return HorizontalAlignment.Left;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException("Nur zur Anzeige.");
+}
+
+///
+/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt.
+///
+public sealed class ToolMatchConverter : IValueConverter
+{
+ public static readonly ToolMatchConverter Instance = new();
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is string toolName && parameter is string targetTool)
+ return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
+
+ return false;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException("Nur zur Anzeige.");
+}
diff --git a/src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs b/src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs
new file mode 100644
index 0000000..ec59592
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs
@@ -0,0 +1,33 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace ClawdDotNet.Desktop.Converters;
+
+///
+/// Färbt eine Logzeile nach ihrer Stufe.
+///
+/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht
+/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die
+/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel.
+///
+public sealed class LogLevelBrushConverter : IValueConverter
+{
+ public static readonly LogLevelBrushConverter Instance = new();
+
+ private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B));
+ private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A));
+
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value as string switch
+ {
+ "ERR" or "FTL" => Error,
+ "WRN" => Warn,
+ // null heißt: Bindung greift nicht, das Steuerelement behält seine eigene
+ // Farbe aus dem Thema.
+ _ => null
+ };
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException("Nur zur Anzeige.");
+}
diff --git a/src/ClawdDotNet.Desktop/Program.cs b/src/ClawdDotNet.Desktop/Program.cs
new file mode 100644
index 0000000..4e55ef5
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Program.cs
@@ -0,0 +1,33 @@
+using Avalonia;
+
+namespace ClawdDotNet.Desktop;
+
+internal static class Program
+{
+ ///
+ /// Einstiegspunkt der Oberfläche.
+ ///
+ /// Bewusst schlank: Hier wird nur Avalonia hochgefahren. Alles Fachliche —
+ /// Einstellungen, Instanzauswahl, Engine, Scanner, Watchdog — baut
+ /// über die Anwendungsschicht auf, damit derselbe Aufbau später
+ /// auch ohne Fenster laufen kann.
+ ///
+ /// Kein [STAThread] mehr: Das war eine COM-Anforderung von WinForms. Avalonia
+ /// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung.
+ ///
+ public static int Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ ///
+ /// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und
+ /// getrennt von .
+ ///
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ // Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen:
+ // Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die
+ // Oberfläche leer.
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs
new file mode 100644
index 0000000..b483e7c
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs
@@ -0,0 +1,85 @@
+using Avalonia.Controls;
+using Avalonia.Threading;
+using ClawdDotNet.App.Services;
+using ClawdDotNet.Desktop.ViewModels;
+using ClawdDotNet.Desktop.Views;
+
+namespace ClawdDotNet.Desktop.Services;
+
+///
+/// Lizenzabfrage über Fenster.
+///
+/// Der Aufbau läuft auf einem Hintergrundfaden — Avalonia besteht aber darauf, dass
+/// Fenster auf dem Oberflächenfaden entstehen. Deshalb geht hier alles über
+/// . Das ist der Grund, warum
+/// durchgehend asynchron ist: Die WinForms-Fassung konnte
+/// ShowDialog einfach blockierend aufrufen, weil sie ohnehin auf dem
+/// Oberflächenfaden lief.
+///
+public sealed class AvaloniaLicensePrompt : ILicensePrompt
+{
+ public async Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
+ => await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var viewModel = new LicenseViewModel(hardwareId, problem, currentKey);
+ var window = new LicenseWindow { DataContext = viewModel };
+
+ window.Show();
+
+ var key = await viewModel.Result;
+ window.Close();
+
+ return key;
+ });
+
+ public Task ShowInfoAsync(string title, string message)
+ => ShowMessageAsync(title, message);
+
+ public Task ShowErrorAsync(string title, string message)
+ => ShowMessageAsync(title, message);
+
+ ///
+ /// Ein Meldungsfenster.
+ ///
+ /// Avalonia bringt kein MessageBox mit — bewusst, weil es auf allen
+ /// Plattformen anders aussähe. Ein schlichtes Fenster ist hier ausreichend und
+ /// erspart uns eine weitere Abhängigkeit für drei Aufrufstellen.
+ ///
+ private static async Task ShowMessageAsync(string title, string message)
+ => await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var completion = new TaskCompletionSource();
+
+ var button = new Button
+ {
+ Content = "OK",
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
+ IsDefault = true
+ };
+
+ var window = new Window
+ {
+ Title = title,
+ Width = 460,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterScreen,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 16,
+ Children =
+ {
+ new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
+ button
+ }
+ }
+ };
+
+ button.Click += (_, _) => window.Close();
+ window.Closed += (_, _) => completion.TrySetResult();
+
+ window.Show();
+ await completion.Task;
+ });
+}
diff --git a/src/ClawdDotNet.Desktop/Styles/Shell.axaml b/src/ClawdDotNet.Desktop/Styles/Shell.axaml
new file mode 100644
index 0000000..490b4b9
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Styles/Shell.axaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/ViewLocator.cs b/src/ClawdDotNet.Desktop/ViewLocator.cs
new file mode 100644
index 0000000..9693908
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewLocator.cs
@@ -0,0 +1,40 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop;
+
+///
+/// Findet zu einem Ansichtsmodell die passende Ansicht über die Namenskonvention
+/// …ViewModels.FooViewModel → …Views.FooView.
+///
+/// Das erspart es, jede Zuordnung von Hand als DataTemplate einzutragen — bei
+/// den sieben Bereichen und ihren Unteransichten wären das schnell dreißig Einträge,
+/// die man beim Umbenennen übersieht.
+///
+/// Passt nichts, erscheint der gesuchte Typname im Fenster statt einer leeren Fläche.
+/// Beim schrittweisen Umbau ist eine sichtbare Fehlstelle mehr wert als ein stiller
+/// weißer Bereich.
+///
+public sealed class ViewLocator : IDataTemplate
+{
+ public Control Build(object? data)
+ {
+ if (data is null)
+ return new TextBlock { Text = "(kein Inhalt)" };
+
+ var viewModelName = data.GetType().FullName!;
+ var viewName = viewModelName
+ .Replace(".ViewModels.", ".Views.", StringComparison.Ordinal)
+ .Replace("ViewModel", "View", StringComparison.Ordinal);
+
+ var type = Type.GetType(viewName);
+
+ if (type is null)
+ return new TextBlock { Text = $"Ansicht nicht gefunden: {viewName}" };
+
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ public bool Match(object? data) => data is ViewModelBase;
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs
new file mode 100644
index 0000000..5b016ed
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs
@@ -0,0 +1,76 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record AddAgentResultData(
+ string AgentId,
+ string DisplayName,
+ string Model,
+ string Description
+);
+
+public sealed partial class AddAgentViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _agentId = "";
+
+ [ObservableProperty]
+ private string _displayName = "";
+
+ [ObservableProperty]
+ private string _selectedModel = "anthropic/claude-3-5-sonnet";
+
+ [ObservableProperty]
+ private string _description = "";
+
+ [ObservableProperty]
+ private string _statusMessage = "";
+
+ public List AvailableModels { get; } =
+ [
+ "anthropic/claude-3-5-sonnet",
+ "anthropic/claude-3-opus",
+ "openai/gpt-4o",
+ "openai/gpt-4o-mini",
+ "google/gemini-2.5-flash",
+ "google/gemini-2.5-pro",
+ "deepseek/deepseek-r1"
+ ];
+
+ public AddAgentResultData? Result { get; private set; }
+
+ public event Action? CloseRequested;
+
+ [RelayCommand]
+ private void Confirm()
+ {
+ if (string.IsNullOrWhiteSpace(AgentId))
+ {
+ StatusMessage = "Bitte eine Agenten-ID eingeben.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(DisplayName))
+ {
+ StatusMessage = "Bitte einen Anzeigenamen eingeben.";
+ return;
+ }
+
+ Result = new AddAgentResultData(
+ AgentId.Trim(),
+ DisplayName.Trim(),
+ SelectedModel,
+ Description.Trim()
+ );
+
+ CloseRequested?.Invoke();
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ Result = null;
+ CloseRequested?.Invoke();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs
new file mode 100644
index 0000000..516f0fa
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs
@@ -0,0 +1,111 @@
+using ClawdDotNet.Core.Config;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record AddJobResultData(
+ string AgentId,
+ bool IsToolJob,
+ string ToolName,
+ string JobTypeId,
+ string CronExpression,
+ string TaskMessage,
+ bool RunOnStart
+);
+
+public sealed partial class AddJobViewModel : ViewModelBase
+{
+ public List AvailableAgents { get; }
+
+ [ObservableProperty]
+ private AgentConfig? _selectedAgent;
+
+ [ObservableProperty]
+ private bool _isToolJob;
+
+ [ObservableProperty]
+ private bool _isAgentWakeup = true;
+
+ [ObservableProperty]
+ private string _selectedToolName = "FileRW";
+
+ [ObservableProperty]
+ private string _jobTypeId = "PollJob";
+
+ [ObservableProperty]
+ private string _cronExpression = "0 * * * *";
+
+ [ObservableProperty]
+ private string _taskMessage = "Führe deine zugewiesenen Aufgaben aus.";
+
+ [ObservableProperty]
+ private bool _runOnStart;
+
+ [ObservableProperty]
+ private string _statusMessage = "";
+
+ public List AvailableTools { get; } =
+ [
+ "FileRW", "Telegram", "Mail", "Database", "FTP",
+ "DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
+ "SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
+ ];
+
+ public AddJobResultData? Result { get; private set; }
+
+ public event Action? CloseRequested;
+
+ public AddJobViewModel() : this([]) { }
+
+ public AddJobViewModel(List agents)
+ {
+ AvailableAgents = agents;
+ SelectedAgent = AvailableAgents.FirstOrDefault();
+ }
+
+ partial void OnIsToolJobChanged(bool value)
+ {
+ IsAgentWakeup = !value;
+ }
+
+ partial void OnIsAgentWakeupChanged(bool value)
+ {
+ IsToolJob = !value;
+ }
+
+ [RelayCommand]
+ private void Confirm()
+ {
+ if (SelectedAgent is null)
+ {
+ StatusMessage = "Bitte einen Agenten auswählen.";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(CronExpression))
+ {
+ StatusMessage = "Bitte einen Cron-Ausdruck angeben.";
+ return;
+ }
+
+ Result = new AddJobResultData(
+ SelectedAgent.AgentId,
+ IsToolJob,
+ SelectedToolName,
+ JobTypeId,
+ CronExpression.Trim(),
+ TaskMessage.Trim(),
+ RunOnStart
+ );
+
+ CloseRequested?.Invoke();
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ Result = null;
+ CloseRequested?.Invoke();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs
new file mode 100644
index 0000000..6ec6fd7
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs
@@ -0,0 +1,67 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record AddServiceResultData(
+ string ServiceName,
+ string ServiceType,
+ int ServicePort,
+ string ServiceDescription
+);
+
+public sealed partial class AddServiceViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _serviceName = "";
+
+ [ObservableProperty]
+ private string _serviceType = "HTTP";
+
+ [ObservableProperty]
+ private int _servicePort = 8080;
+
+ [ObservableProperty]
+ private string _serviceDescription = "";
+
+ [ObservableProperty]
+ private string _statusMessage = "";
+
+ public List AvailableServiceTypes { get; } = ["HTTP", "TCP", "gRPC", "WebSocket", "Custom"];
+
+ public AddServiceResultData? Result { get; private set; }
+
+ public event Action? CloseRequested;
+
+ [RelayCommand]
+ private void Confirm()
+ {
+ if (string.IsNullOrWhiteSpace(ServiceName))
+ {
+ StatusMessage = "Bitte einen Dienstnamen eingeben.";
+ return;
+ }
+
+ if (ServicePort <= 0 || ServicePort > 65535)
+ {
+ StatusMessage = "Gültigen Port zwischen 1 und 65535 eingeben.";
+ return;
+ }
+
+ Result = new AddServiceResultData(
+ ServiceName.Trim(),
+ ServiceType,
+ ServicePort,
+ ServiceDescription.Trim()
+ );
+
+ CloseRequested?.Invoke();
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ Result = null;
+ CloseRequested?.Invoke();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs
new file mode 100644
index 0000000..d79c7fd
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs
@@ -0,0 +1,280 @@
+using System.Collections.ObjectModel;
+using ClawdDotNet.App;
+using ClawdDotNet.Core.Config;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed partial class ToolItemViewModel : ObservableObject
+{
+ public string Name { get; }
+
+ [ObservableProperty]
+ private bool _isEnabled;
+
+ public ToolItemViewModel(string name, bool isEnabled)
+ {
+ Name = name;
+ _isEnabled = isEnabled;
+ }
+}
+
+public sealed partial class AgentItemViewModel : ObservableObject
+{
+ public AgentConfig Config { get; }
+
+ public string AgentId => Config.AgentId;
+
+ [ObservableProperty]
+ private string _displayName;
+
+ [ObservableProperty]
+ private string _model;
+
+ [ObservableProperty]
+ private string _systemPrompt;
+
+ [ObservableProperty]
+ private string _identity;
+
+ [ObservableProperty]
+ private string _soul;
+
+ [ObservableProperty]
+ private string _description;
+
+ [ObservableProperty]
+ private string _promptCaching;
+
+ [ObservableProperty]
+ private int _maxToolResultChars;
+
+ [ObservableProperty]
+ private int _maxSteps;
+
+ [ObservableProperty]
+ private int _maxContextTokens;
+
+ [ObservableProperty]
+ private int _timeoutSeconds;
+
+ [ObservableProperty]
+ private decimal _dailyCostUsd;
+
+ [ObservableProperty]
+ private long _dailyTokens;
+
+ public ObservableCollection AvailableTools { get; } = [];
+
+ public static readonly List KnownTools =
+ [
+ "FileRW", "Telegram", "Mail", "Database", "FTP",
+ "DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
+ "SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
+ ];
+
+ public AgentItemViewModel(AgentConfig config)
+ {
+ Config = config;
+ _displayName = config.DisplayName;
+ _model = config.Model;
+ _systemPrompt = config.SystemPrompt;
+ _identity = config.Identity;
+ _soul = config.Soul;
+ _description = config.Description;
+ _promptCaching = config.PromptCaching;
+ _maxToolResultChars = config.MaxToolResultChars;
+ _maxSteps = config.LoopGuard.MaxSteps;
+ _maxContextTokens = config.LoopGuard.MaxContextTokens;
+ _timeoutSeconds = config.LoopGuard.TimeoutSeconds;
+ _dailyCostUsd = config.Budget.DailyCostUsd;
+ _dailyTokens = config.Budget.DailyTokens;
+
+ foreach (var tool in KnownTools)
+ {
+ var isEnabled = config.Tools.ContainsKey(tool);
+ var item = new ToolItemViewModel(tool, isEnabled);
+ item.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
+ {
+ if (item.IsEnabled && !Config.Tools.ContainsKey(tool))
+ Config.Tools[tool] = new Dictionary();
+ else if (!item.IsEnabled)
+ Config.Tools.Remove(tool);
+ }
+ };
+ AvailableTools.Add(item);
+ }
+ }
+
+ public void ApplyChanges()
+ {
+ Config.DisplayName = DisplayName;
+ Config.Model = Model;
+ Config.SystemPrompt = SystemPrompt;
+ Config.Identity = Identity;
+ Config.Soul = Soul;
+ Config.Description = Description;
+ Config.PromptCaching = PromptCaching;
+ Config.MaxToolResultChars = MaxToolResultChars;
+ Config.LoopGuard.MaxSteps = MaxSteps;
+ Config.LoopGuard.MaxContextTokens = MaxContextTokens;
+ Config.LoopGuard.TimeoutSeconds = TimeoutSeconds;
+ Config.Budget.DailyCostUsd = DailyCostUsd;
+ Config.Budget.DailyTokens = DailyTokens;
+ }
+}
+
+///
+/// Ansichtsmodell für die Agenten-Verwaltungsseite.
+///
+public sealed partial class AgentsPageViewModel : PageViewModel
+{
+ private readonly AppHost? _host;
+ private readonly ILogger? _logger;
+
+ public ObservableCollection Agents { get; } = [];
+
+ [ObservableProperty]
+ private AgentItemViewModel? _selectedAgent;
+
+ [ObservableProperty]
+ private string _statusText = "Bereit";
+
+ public List KnownModels { get; } =
+ [
+ "anthropic/claude-3-5-sonnet",
+ "anthropic/claude-3-opus",
+ "openai/gpt-4o",
+ "openai/gpt-4o-mini",
+ "google/gemini-2.5-flash",
+ "google/gemini-2.5-pro",
+ "deepseek/deepseek-r1"
+ ];
+
+ public List CachingOptions { get; } = ["auto", "on", "off"];
+
+ public event Func>? RequestAddAgentDialog;
+ public event Func?>>? RequestToolSettingsDialog;
+
+ public AgentsPageViewModel() : this(null) { }
+
+ public AgentsPageViewModel(AppHost? host) : base("Agenten")
+ {
+ _host = host;
+
+ if (host is not null)
+ {
+ _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
+ ReloadAgents();
+ }
+ else
+ {
+ StatusText = "Entwurfsmodus";
+ }
+ }
+
+ [RelayCommand]
+ private void ReloadAgents()
+ {
+ Agents.Clear();
+ if (_host is null) return;
+
+ foreach (var agent in _host.Instance.Agents)
+ {
+ Agents.Add(new AgentItemViewModel(agent));
+ }
+
+ SelectedAgent = Agents.FirstOrDefault();
+ }
+
+ [RelayCommand]
+ private async Task AddAgentAsync()
+ {
+ if (_host is null || RequestAddAgentDialog is null) return;
+
+ var vm = new AddAgentViewModel();
+ var result = await RequestAddAgentDialog(vm);
+ if (result is null) return;
+
+ if (_host.Instance.Agents.Any(a => a.AgentId.Equals(result.AgentId, StringComparison.OrdinalIgnoreCase)))
+ {
+ StatusText = $"Ein Agent mit der ID '{result.AgentId}' existiert bereits.";
+ return;
+ }
+
+ var newConfig = new AgentConfig
+ {
+ AgentId = result.AgentId,
+ DisplayName = result.DisplayName,
+ Model = result.Model,
+ Description = result.Description
+ };
+
+ _host.Instance.Agents.Add(newConfig);
+ SaveAgents();
+
+ var item = new AgentItemViewModel(newConfig);
+ Agents.Add(item);
+ SelectedAgent = item;
+
+ StatusText = $"Agent '{result.DisplayName}' angelegt.";
+ }
+
+ [RelayCommand]
+ private void RemoveAgent()
+ {
+ if (_host is null || SelectedAgent is null) return;
+
+ var agentId = SelectedAgent.AgentId;
+ _host.Instance.Agents.RemoveAll(a => a.AgentId == agentId);
+
+ SaveAgents();
+ Agents.Remove(SelectedAgent);
+ SelectedAgent = Agents.FirstOrDefault();
+
+ StatusText = $"Agent '{agentId}' entfernt.";
+ }
+
+ [RelayCommand]
+ private async Task ConfigureToolAsync(ToolItemViewModel? toolItem)
+ {
+ if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return;
+
+ var toolName = toolItem.Name;
+ if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config))
+ {
+ config = new Dictionary();
+ SelectedAgent.Config.Tools[toolName] = config;
+ toolItem.IsEnabled = true;
+ }
+
+ var vm = new ToolSettingsViewModel(toolName, config);
+ var newConfig = await RequestToolSettingsDialog(vm);
+
+ if (newConfig is not null)
+ {
+ SelectedAgent.Config.Tools[toolName] = newConfig;
+ SaveAgents();
+ StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert.";
+ }
+ }
+
+ [RelayCommand]
+ private void SaveAgents()
+ {
+ if (_host is null) return;
+
+ foreach (var agentVm in Agents)
+ {
+ agentVm.ApplyChanges();
+ }
+
+ _host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
+ _logger?.LogInformation("Agenten-Konfiguration gespeichert.");
+ StatusText = "Agenten-Konfiguration gespeichert.";
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs
new file mode 100644
index 0000000..c7383a9
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs
@@ -0,0 +1,397 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using ClawdDotNet.App;
+using ClawdDotNet.App.Settings;
+using ClawdDotNet.Core.Backup;
+using ClawdDotNet.Core.Storage;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record BackupItemViewModel(
+ string FileName,
+ DateTime LastWriteTime,
+ string InstanceName,
+ long SizeBytes,
+ string SizeFormatted,
+ bool HasSecrets,
+ string SecretsInfo,
+ string FullPath,
+ bool IsReadable
+);
+
+///
+/// Ansichtsmodell für die Sicherungs- und Wiederherstellungsseite.
+///
+public sealed partial class BackupPageViewModel : PageViewModel
+{
+ private readonly BackupService _service = new();
+ private readonly AppHost? _host;
+ private readonly SettingsManager? _settingsManager;
+ private readonly ILogger? _logger;
+ private readonly string _instanceDir;
+ private readonly string _instanceName;
+ private bool _loading;
+
+ [ObservableProperty]
+ private string _targetFolder = "";
+
+ [ObservableProperty]
+ private bool _secretsExclude = true;
+
+ [ObservableProperty]
+ private bool _secretsPassphrase;
+
+ [ObservableProperty]
+ private string _passphrase = "";
+
+ [ObservableProperty]
+ private string _passphraseRepeat = "";
+
+ [ObservableProperty]
+ private bool _includeLogs;
+
+ [ObservableProperty]
+ private bool _includeChatHistory = true;
+
+ [ObservableProperty]
+ private bool _autoBackupEnabled;
+
+ [ObservableProperty]
+ private string _autoBackupTime = "03:00";
+
+ [ObservableProperty]
+ private int _keepCount = 10;
+
+ [ObservableProperty]
+ private string _statusText = "Bereit";
+
+ [ObservableProperty]
+ private bool _isBusy;
+
+ [ObservableProperty]
+ private BackupItemViewModel? _selectedBackup;
+
+ public ObservableCollection Backups { get; } = [];
+
+ public event Func>? RequestRestoreDialog;
+
+ public BackupPageViewModel() : this(null) { }
+
+ public BackupPageViewModel(AppHost? host) : base("Sicherung")
+ {
+ _host = host;
+
+ if (host is not null)
+ {
+ _instanceDir = host.InstancePath;
+ _instanceName = host.Instance.InstanceName;
+ _settingsManager = host.Settings;
+ _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup");
+
+ LoadSettings();
+ RefreshBackupList();
+ }
+ else
+ {
+ _instanceDir = "";
+ _instanceName = "Entwurf";
+ TargetFolder = @"C:\ClawdDotNet\Backups";
+ }
+ }
+
+ partial void OnAutoBackupEnabledChanged(bool value) => SaveSettings();
+ partial void OnAutoBackupTimeChanged(string value) => SaveSettings();
+ partial void OnKeepCountChanged(int value) => SaveSettings();
+
+ private void LoadSettings()
+ {
+ if (_settingsManager is null) return;
+
+ _loading = true;
+ try
+ {
+ var settings = _settingsManager.AppSettings;
+ TargetFolder = Path.GetFullPath(settings.BackupDirectory);
+ AutoBackupEnabled = settings.AutoBackupEnabled;
+ KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999);
+
+ AutoBackupTime = TimeSpan.TryParse(settings.AutoBackupTime, out var time)
+ ? time.ToString(@"hh\:mm")
+ : "03:00";
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
+
+ public void SaveSettings()
+ {
+ if (_loading || _settingsManager is null) return;
+
+ var settings = _settingsManager.AppSettings;
+ settings.BackupDirectory = TargetFolder.Trim();
+ settings.AutoBackupEnabled = AutoBackupEnabled;
+ settings.AutoBackupTime = AutoBackupTime.Trim();
+ settings.BackupKeepCount = KeepCount;
+
+ _settingsManager.Save();
+ }
+
+ [RelayCommand]
+ private void RefreshBackupList()
+ {
+ Backups.Clear();
+ var folder = TargetFolder.Trim();
+
+ if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
+ return;
+
+ Task.Run(async () =>
+ {
+ var files = new DirectoryInfo(folder)
+ .GetFiles("*.zip")
+ .OrderByDescending(f => f.LastWriteTime)
+ .ToList();
+
+ var list = new List();
+
+ foreach (var file in files)
+ {
+ try
+ {
+ var manifest = await _service.InspectAsync(file.FullName);
+ var secretsInfo = manifest.HasSecrets
+ ? $"ja ({manifest.SecretCount}, Passphrase)"
+ : "nein";
+
+ list.Add(new BackupItemViewModel(
+ file.Name, file.LastWriteTime, manifest.InstanceName,
+ file.Length, FormatSize(file.Length), manifest.HasSecrets, secretsInfo,
+ file.FullName, true));
+ }
+ catch
+ {
+ list.Add(new BackupItemViewModel(
+ file.Name, file.LastWriteTime, "—",
+ file.Length, FormatSize(file.Length), false, "unlesbar",
+ file.FullName, false));
+ }
+ }
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Backups.Clear();
+ foreach (var item in list)
+ Backups.Add(item);
+ });
+ });
+ }
+
+ [RelayCommand]
+ private async Task CreateBackupAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_instanceDir))
+ {
+ StatusText = "Keine gültige Instanz geladen.";
+ return;
+ }
+
+ var folder = TargetFolder.Trim();
+ if (string.IsNullOrWhiteSpace(folder))
+ {
+ StatusText = "Bitte einen Zielordner angeben.";
+ return;
+ }
+
+ BackupOptions options;
+ if (SecretsPassphrase)
+ {
+ if (string.IsNullOrEmpty(Passphrase))
+ {
+ StatusText = "Bitte eine Passphrase eingeben.";
+ return;
+ }
+
+ if (Passphrase != PassphraseRepeat)
+ {
+ StatusText = "Die Passphrasen stimmen nicht überein.";
+ return;
+ }
+
+ options = new BackupOptions
+ {
+ Secrets = SecretMode.Passphrase,
+ Passphrase = Passphrase,
+ IncludeLogs = IncludeLogs,
+ IncludeChatHistory = IncludeChatHistory
+ };
+ }
+ else
+ {
+ options = new BackupOptions
+ {
+ Secrets = SecretMode.Exclude,
+ IncludeLogs = IncludeLogs,
+ IncludeChatHistory = IncludeChatHistory
+ };
+ }
+
+ Directory.CreateDirectory(folder);
+ var fileName = BuildFileName();
+ var zipPath = Path.Combine(folder, fileName);
+
+ IsBusy = true;
+ StatusText = "Sicherung läuft…";
+
+ try
+ {
+ var result = await Task.Run(() => _service.CreateAsync(_instanceDir, zipPath, options));
+
+ StatusText = $"Fertig: {Path.GetFileName(result.ZipPath)} ({FormatSize(result.SizeBytes)})";
+ _logger?.LogInformation("Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes);
+
+ Passphrase = "";
+ PassphraseRepeat = "";
+
+ ApplyRotation(folder);
+ RefreshBackupList();
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Fehler: {ex.Message}";
+ _logger?.LogError(ex, "Sicherung fehlgeschlagen");
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ [RelayCommand]
+ private void ShowSelectedInFolder()
+ {
+ if (SelectedBackup is null) return;
+ SystemShell.RevealFile(SelectedBackup.FullPath);
+ }
+
+ [RelayCommand]
+ private void DeleteSelected()
+ {
+ if (SelectedBackup is null) return;
+
+ try
+ {
+ File.Delete(SelectedBackup.FullPath);
+ RefreshBackupList();
+ StatusText = $"Gelöscht: {SelectedBackup.FileName}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Konnte nicht gelöscht werden: {ex.Message}";
+ }
+ }
+
+ [RelayCommand]
+ private async Task RestoreSelectedAsync()
+ {
+ if (SelectedBackup is null || RequestRestoreDialog is null) return;
+
+ BackupManifest manifest;
+ try
+ {
+ manifest = await _service.InspectAsync(SelectedBackup.FullPath);
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Sicherung unlesbar: {ex.Message}";
+ return;
+ }
+
+ var suggestedTarget = SuggestRestoreTarget(manifest);
+ var vm = new RestoreBackupViewModel(manifest, suggestedTarget);
+
+ var resultData = await RequestRestoreDialog(vm);
+ if (resultData is null) return;
+
+ IsBusy = true;
+ StatusText = "Wiederherstellung läuft…";
+
+ try
+ {
+ var restoreOptions = new RestoreOptions
+ {
+ Passphrase = resultData.Passphrase,
+ Overwrite = resultData.Overwrite
+ };
+
+ var result = await Task.Run(() => _service.RestoreAsync(
+ SelectedBackup.FullPath, resultData.TargetDirectory, restoreOptions));
+
+ StatusText = $"{result.Written.Count} Datei(en) wiederhergestellt nach {resultData.TargetDirectory}";
+ _logger?.LogInformation("Wiederherstellung: {Written} geschrieben nach {Target}",
+ result.Written.Count, resultData.TargetDirectory);
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Wiederherstellung fehlgeschlagen: {ex.Message}";
+ _logger?.LogError(ex, "Wiederherstellung fehlgeschlagen");
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private string BuildFileName()
+ {
+ var name = string.IsNullOrWhiteSpace(_instanceName) ? "Instanz" : _instanceName;
+ var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
+ return $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip";
+ }
+
+ private string SuggestRestoreTarget(BackupManifest manifest)
+ {
+ var parent = Directory.GetParent(_instanceDir)?.FullName ?? _instanceDir;
+ var name = string.IsNullOrWhiteSpace(manifest.InstanceName) ? "Instanz" : manifest.InstanceName;
+ var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
+ return Path.Combine(parent, $"Instance-{safeName}_wiederhergestellt_{DateTime.Now:yyyyMMdd_HHmm}");
+ }
+
+ private void ApplyRotation(string folder)
+ {
+ if (KeepCount <= 0 || !Directory.Exists(folder)) return;
+
+ try
+ {
+ var prefix = $"backup_{_instanceName}_";
+ var obsolete = new DirectoryInfo(folder)
+ .GetFiles("*.zip")
+ .Where(f => f.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(f => f.LastWriteTime)
+ .Skip(KeepCount)
+ .ToList();
+
+ foreach (var file in obsolete)
+ {
+ file.Delete();
+ _logger?.LogInformation("Alte Sicherung rotiert: {Name}", file.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
+ }
+ }
+
+ private static string FormatSize(long bytes) => bytes switch
+ {
+ < 1024 => $"{bytes} B",
+ < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
+ < 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
+ _ => $"{bytes / 1024.0 / 1024 / 1024:F2} GB"
+ };
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs
new file mode 100644
index 0000000..cb8a3d6
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs
@@ -0,0 +1,158 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using ClawdDotNet.App;
+using ClawdDotNet.Core.Config;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record ChatEntryItemViewModel(
+ string Role,
+ string Sender,
+ string Text,
+ DateTime Timestamp,
+ bool IsUser
+);
+
+///
+/// Ansichtsmodell für die native Chat-Seite (Avalonia UI statt WebView2).
+///
+public sealed partial class ChatPageViewModel : PageViewModel
+{
+ private readonly AppHost? _host;
+
+ public ObservableCollection AvailableAgents { get; } = [];
+ public ObservableCollection ChatEntries { get; } = [];
+
+ [ObservableProperty]
+ private AgentConfig? _selectedAgent;
+
+ [ObservableProperty]
+ private string _inputPrompt = "";
+
+ [ObservableProperty]
+ private bool _isSending;
+
+ [ObservableProperty]
+ private string _statusText = "Bereit";
+
+ public ChatPageViewModel() : this(null) { }
+
+ public ChatPageViewModel(AppHost? host) : base("Chat")
+ {
+ _host = host;
+
+ if (host is not null)
+ {
+ foreach (var agent in host.Instance.Agents)
+ AvailableAgents.Add(agent);
+
+ SelectedAgent = AvailableAgents.FirstOrDefault();
+
+ if (host.Engine is not null)
+ {
+ host.Engine.OnChatEntryAdded += OnEngineChatEntryAdded;
+ }
+ }
+ else
+ {
+ StatusText = "Entwurfsmodus";
+ }
+ }
+
+ partial void OnSelectedAgentChanged(AgentConfig? value)
+ {
+ LoadChatHistory();
+ }
+
+ private void LoadChatHistory()
+ {
+ ChatEntries.Clear();
+ if (_host?.Engine is null || SelectedAgent is null) return;
+
+ var history = _host.Engine.GetChatHistory(SelectedAgent.AgentId);
+ foreach (var entry in history)
+ {
+ var isUser = entry.Role.Equals("user", StringComparison.OrdinalIgnoreCase);
+ ChatEntries.Add(new ChatEntryItemViewModel(
+ entry.Role,
+ isUser ? "Du" : SelectedAgent.DisplayName,
+ entry.Content,
+ entry.Timestamp,
+ isUser
+ ));
+ }
+
+ StatusText = $"Chat-Verlauf für '{SelectedAgent.DisplayName}' geladen ({ChatEntries.Count} Nachrichten).";
+ }
+
+ private void OnEngineChatEntryAdded(string agentId, string role, string text, string? toolName)
+ {
+ if (SelectedAgent is null || SelectedAgent.AgentId != agentId) return;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ var isUser = role.Equals("user", StringComparison.OrdinalIgnoreCase);
+ var sender = isUser ? "Du" : (string.IsNullOrWhiteSpace(toolName) ? SelectedAgent.DisplayName : $"Tool: {toolName}");
+
+ ChatEntries.Add(new ChatEntryItemViewModel(
+ role, sender, text, DateTime.Now, isUser
+ ));
+ });
+ }
+
+ [RelayCommand]
+ private async Task SendMessageAsync()
+ {
+ if (_host?.Engine is null || SelectedAgent is null)
+ {
+ StatusText = "Engine nicht aktiv oder kein Agent ausgewählt.";
+ return;
+ }
+
+ var text = InputPrompt.Trim();
+ if (string.IsNullOrWhiteSpace(text)) return;
+
+ InputPrompt = "";
+ IsSending = true;
+ StatusText = $"{SelectedAgent.DisplayName} denkt nach…";
+
+ try
+ {
+ var agent = SelectedAgent;
+ var instanceId = _host.Instance.InstanceId;
+
+ var result = await Task.Run(() =>
+ _host.Engine.ChatAsync(agent, text, instanceId, CancellationToken.None));
+
+ StatusText = $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens).";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Fehler: {ex.Message}";
+ }
+ finally
+ {
+ IsSending = false;
+ }
+ }
+
+ [RelayCommand]
+ private void Abort()
+ {
+ if (_host?.Engine is null || SelectedAgent is null) return;
+ _host.Engine.AbortChat(SelectedAgent.AgentId);
+ StatusText = "Chat abgebrochen.";
+ IsSending = false;
+ }
+
+ [RelayCommand]
+ private void ClearHistory()
+ {
+ if (_host?.Engine is null || SelectedAgent is null) return;
+ _host.Engine.ClearChatHistory(SelectedAgent.AgentId);
+ ChatEntries.Clear();
+ StatusText = "Verlauf geleert.";
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs
new file mode 100644
index 0000000..85a8283
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs
@@ -0,0 +1,89 @@
+using System.Collections.ObjectModel;
+using System.Reflection;
+using ClawdDotNet.App;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record AssemblyInfoItem(string ShortName, int BuildNumber, string BuildDate, string Changes);
+
+///
+/// Ansichtsmodell für die Info-Seite (Version, Build-Informationen, Instanz-Details).
+///
+public sealed partial class InfoPageViewModel : PageViewModel
+{
+ [ObservableProperty]
+ private string _appVersion = "—";
+
+ [ObservableProperty]
+ private string _buildSummary = "—";
+
+ [ObservableProperty]
+ private string _instanceName = "—";
+
+ [ObservableProperty]
+ private string _instanceId = "—";
+
+ [ObservableProperty]
+ private string _instancePath = "—";
+
+ [ObservableProperty]
+ private string _logDirectory = "—";
+
+ [ObservableProperty]
+ private string _openRouterStatus = "—";
+
+ public ObservableCollection Assemblies { get; } = [];
+
+ public InfoPageViewModel() : this(null) { }
+
+ public InfoPageViewModel(AppHost? host) : base("Info")
+ {
+ AppVersion = AppHost.AppVersion;
+ BuildSummary = AppHost.BuildSummary;
+
+ if (host is not null)
+ {
+ InstanceName = host.Instance.InstanceName;
+ InstanceId = host.Instance.InstanceId;
+ InstancePath = host.InstancePath;
+ LogDirectory = host.LogDirectory;
+ OpenRouterStatus = host.Engine is null
+ ? "Deaktiviert (kein API-Key hinterlegt)"
+ : "Aktiv (AgentEngine bereit)";
+ }
+ else
+ {
+ InstanceName = "Entwurfsmodus";
+ InstanceId = "00000000-0000-0000-0000-000000000000";
+ InstancePath = @"C:\ClawdDotNet\Instances\Default";
+ LogDirectory = @"C:\ClawdDotNet\Logs";
+ OpenRouterStatus = "Entwurf";
+ }
+
+ LoadAssemblyBuildInfos();
+ }
+
+ private void LoadAssemblyBuildInfos()
+ {
+ Assemblies.Clear();
+
+ foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()
+ .Where(a => a.GetName().Name?.StartsWith("ClawdDotNet") == true)
+ .OrderBy(a => a.GetName().Name))
+ {
+ var name = asm.GetName().Name!;
+ var shortName = name.Replace("ClawdDotNet.", "").Replace("Tools.", "");
+
+ var buildDate = asm.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
+ .OfType()
+ .FirstOrDefault(a => a.Key == "BuildDate")?.Value ?? "?";
+
+ var buildInfoType = asm.GetType(asm.GetName().Name + ".BuildInfo");
+ var buildNum = buildInfoType?.GetField("Build")?.GetValue(null) as int? ?? 0;
+ var changes = buildInfoType?.GetField("Changes")?.GetValue(null) as string ?? "—";
+
+ Assemblies.Add(new AssemblyInfoItem(shortName, buildNum, buildDate, changes));
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs
new file mode 100644
index 0000000..b7db4c1
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs
@@ -0,0 +1,103 @@
+using System.Collections.ObjectModel;
+using ClawdDotNet.App.Models;
+using ClawdDotNet.App.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+///
+/// Auswahl der Instanz beim Start — der erste Rückruf, den AppHost erfragt.
+///
+/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster in
+/// einem Formular. Hier steht nur, was geschieht; das wie es aussieht
+/// liegt in der Ansicht, und Rückfragen laufen über statt über
+/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.
+///
+public sealed partial class InstancePickerViewModel : ViewModelBase
+{
+ private readonly InstanceDirectoryManager _directories;
+ private readonly TaskCompletionSource _completion = new();
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(StartCommand))]
+ private InstanceInfo? _selected;
+
+ [ObservableProperty]
+ private string _newInstanceName = "";
+
+ /// Fehlertext für die Ansicht; leer heißt: alles in Ordnung.
+ [ObservableProperty]
+ private string _errorText = "";
+
+ public ObservableCollection Instances { get; } = [];
+
+ /// Der gewählte Pfad, oder null bei Abbruch.
+ public Task Result => _completion.Task;
+
+ public string InstancesDirectory => _directories.InstancesDirectory;
+
+ public InstancePickerViewModel(InstanceDirectoryManager directories)
+ {
+ _directories = directories;
+ Refresh();
+ }
+
+ [RelayCommand]
+ private void Refresh()
+ {
+ Instances.Clear();
+
+ foreach (var instance in _directories.ListInstances())
+ Instances.Add(instance);
+
+ // Eine einzelne Instanz gleich vorwählen — der häufigste Fall, und ein Klick
+ // weniger bei jedem Start.
+ Selected ??= Instances.FirstOrDefault();
+ }
+
+ private bool CanStart => Selected is not null;
+
+ [RelayCommand(CanExecute = nameof(CanStart))]
+ private void Start() => _completion.TrySetResult(Selected!.FolderPath);
+
+ /// Abbruch — der Aufrufer beendet die Anwendung.
+ [RelayCommand]
+ private void Cancel() => _completion.TrySetResult(null);
+
+ [RelayCommand]
+ private void Create()
+ {
+ var name = NewInstanceName.Trim();
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ ErrorText = "Bitte einen Instanznamen eingeben.";
+ return;
+ }
+
+ try
+ {
+ _directories.CreateInstance(name);
+ NewInstanceName = "";
+ ErrorText = "";
+ Refresh();
+
+ // Die frisch angelegte Instanz vorwählen — wer sie erstellt, will sie starten.
+ Selected = Instances.FirstOrDefault(i =>
+ string.Equals(i.InstanceName, name, StringComparison.Ordinal));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
+ or ArgumentException)
+ {
+ ErrorText = $"Instanz konnte nicht angelegt werden: {ex.Message}";
+ }
+ }
+
+ ///
+ /// Wird gesetzt, wenn ein Fehler von außen kommt (etwa eine unlesbare
+ /// Instanzkonfiguration), damit das Fenster offen bleiben und eine andere Wahl
+ /// zulassen kann.
+ ///
+ public void ConfirmError(string message) => ErrorText = message;
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs
new file mode 100644
index 0000000..5cdfad1
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs
@@ -0,0 +1,46 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+/// Eingabe eines Lizenzschlüssels. Siehe AvaloniaLicensePrompt.
+public sealed partial class LicenseViewModel : ViewModelBase
+{
+ private readonly TaskCompletionSource _completion = new();
+
+ [ObservableProperty]
+ private string _key;
+
+ public string HardwareId { get; }
+
+ /// Warum der bisherige Schlüssel nicht taugt; leer beim ersten Fragen.
+ public string Problem { get; }
+
+ /// Der eingegebene Schlüssel, oder null bei Abbruch.
+ public Task Result => _completion.Task;
+
+ public LicenseViewModel(string hardwareId, string? problem, string? currentKey)
+ {
+ HardwareId = hardwareId;
+ Problem = problem ?? "";
+ _key = currentKey ?? "";
+ }
+
+ [RelayCommand]
+ private void Confirm()
+ {
+ var value = Key.Trim();
+
+ // Leere Eingabe ist kein Abbruch — sonst schließt sich das Fenster, wenn jemand
+ // versehentlich die Eingabetaste drückt.
+ if (value.Length == 0) return;
+
+ _completion.TrySetResult(value);
+ }
+
+ [RelayCommand]
+ private void Cancel() => _completion.TrySetResult(null);
+
+ /// Schließt der Benutzer das Fenster, gilt das als Abbruch.
+ public void CancelIfPending() => _completion.TrySetResult(null);
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs
new file mode 100644
index 0000000..b573dd6
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs
@@ -0,0 +1,112 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using ClawdDotNet.App;
+using ClawdDotNet.App.Services;
+using ClawdDotNet.Core.Storage;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+///
+/// Live-Ansicht der Logdateien.
+///
+/// Der Takt läuft über — das Avalonia-Gegenstück zum
+/// System.Windows.Forms.Timer, also auf dem Oberflächenfaden. Für eine Ansicht
+/// ist das genau richtig: Das Lesen selbst dauert Millisekunden, und so entfällt jedes
+/// Marshalling. Alles, was ohne Fenster laufen muss, liegt dagegen in
+/// und taktet dort über PeriodicTimer.
+///
+public sealed partial class LogPageViewModel : PageViewModel
+{
+ ///
+ /// Obergrenze der angezeigten Zeilen.
+ ///
+ /// Ohne Deckel wächst die Liste unbegrenzt — ein Agent, der eine Nacht durchläuft,
+ /// hinterlässt sonst Hunderttausende Einträge im Speicher, und das Scrollen wird zäh.
+ ///
+ private const int MaxLines = 2000;
+
+ private readonly LogTail? _tail;
+ private readonly DispatcherTimer? _timer;
+ private readonly string _logDirectory;
+
+ public ObservableCollection Lines { get; } = [];
+
+ public ObservableCollection Modules { get; } = ["Alle"];
+
+ public IReadOnlyList Levels { get; } = ["Alle", "Info", "Warn", "Error"];
+
+ [ObservableProperty]
+ private string _selectedModule = "Alle";
+
+ [ObservableProperty]
+ private string _selectedLevel = "Alle";
+
+ ///
+ /// Hängt die Ansicht am unteren Ende? Sobald jemand nach oben scrollt, soll nicht
+ /// weitergesprungen werden — sonst kann man nichts lesen, während etwas läuft.
+ ///
+ [ObservableProperty]
+ private bool _followTail = true;
+
+ public LogPageViewModel(AppHost? host) : base("Logs")
+ {
+ _logDirectory = host?.LogDirectory ?? "";
+
+ if (host is null) return; // Entwurfsmodus
+
+ _tail = new LogTail(_logDirectory);
+ RefreshModules();
+
+ _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
+ _timer.Tick += (_, _) => Poll();
+ _timer.Start();
+ }
+
+ private void Poll()
+ {
+ if (_tail is null) return;
+
+ var module = SelectedModule == "Alle" ? null : SelectedModule;
+ var level = SelectedLevel switch
+ {
+ "Info" => LogLevelFilter.Info,
+ "Warn" => LogLevelFilter.Warn,
+ "Error" => LogLevelFilter.Error,
+ _ => LogLevelFilter.All
+ };
+
+ foreach (var line in _tail.ReadNew(module, level))
+ {
+ Lines.Add(line);
+
+ // Von vorn kürzen statt am Ende zu deckeln: Das Neueste soll bleiben.
+ while (Lines.Count > MaxLines)
+ Lines.RemoveAt(0);
+ }
+ }
+
+ [RelayCommand]
+ private void Clear() => Lines.Clear();
+
+ [RelayCommand]
+ private void RefreshModules()
+ {
+ if (_tail is null) return;
+
+ var current = SelectedModule;
+
+ Modules.Clear();
+ Modules.Add("Alle");
+
+ foreach (var module in _tail.AvailableModules())
+ Modules.Add(module);
+
+ // Auswahl halten, wenn es das Modul noch gibt.
+ SelectedModule = Modules.Contains(current) ? current : "Alle";
+ }
+
+ [RelayCommand]
+ private void OpenFolder() => SystemShell.OpenFolder(_logDirectory);
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..ef743cf
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,102 @@
+using System.Collections.ObjectModel;
+using ClawdDotNet.App;
+using ClawdDotNet.Core.Storage;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+///
+/// Das Gerüst des Hauptfensters: welche Bereiche es gibt und welcher gerade sichtbar ist.
+///
+public sealed partial class MainWindowViewModel : ViewModelBase
+{
+ private readonly AppHost? _host;
+
+ [ObservableProperty]
+ private PageViewModel? _selectedPage;
+
+ public ObservableCollection Pages { get; }
+
+ /// Text der Statusleiste am unteren Rand.
+ [ObservableProperty]
+ private string _statusText = "Bereit";
+
+ public event Action? RequestExit;
+ public event Action? RequestInstanceManagerDialog;
+
+ /// Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.
+ public MainWindowViewModel() : this(null) { }
+
+ public MainWindowViewModel(AppHost? host)
+ {
+ _host = host;
+
+ Pages =
+ [
+ new ChatPageViewModel(host),
+ new LogPageViewModel(host),
+ new AgentsPageViewModel(host),
+ new SettingsPageViewModel(host),
+ new TasksPageViewModel(host),
+ new BackupPageViewModel(host),
+ new InfoPageViewModel(host)
+ ];
+
+ SelectedPage = Pages[0];
+
+ StatusText = host is null
+ ? "Entwurfsmodus"
+ : $"Instanz: {host.Instance.InstanceName} · "
+ + (host.Engine is null ? "Agenten deaktiviert (kein API-Key)" : "bereit");
+ }
+
+ [RelayCommand]
+ private void OpenInstanceFolder()
+ {
+ if (_host is null) return;
+ SystemShell.OpenFolder(_host.InstancePath);
+ }
+
+ [RelayCommand]
+ private void OpenLogFolder()
+ {
+ if (_host is null) return;
+ SystemShell.OpenFolder(_host.LogDirectory);
+ }
+
+ [RelayCommand]
+ private void OpenInstanceManager()
+ {
+ RequestInstanceManagerDialog?.Invoke();
+ }
+
+ [RelayCommand]
+ private void SelectPageByTitle(string title)
+ {
+ var target = Pages.FirstOrDefault(p => p.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
+ if (target is not null)
+ SelectedPage = target;
+ }
+
+ [RelayCommand]
+ private void Exit()
+ {
+ RequestExit?.Invoke();
+ }
+}
+
+/// Ein Bereich des Hauptfensters.
+public abstract partial class PageViewModel(string title) : ViewModelBase
+{
+ public string Title { get; } = title;
+}
+
+///
+/// Platzhalter, solange der Bereich noch nicht portiert ist.
+///
+public sealed partial class PlaceholderPageViewModel(string title, string note)
+ : PageViewModel(title)
+{
+ public string Note { get; } = note;
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs
new file mode 100644
index 0000000..d72a847
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs
@@ -0,0 +1,77 @@
+using ClawdDotNet.Core.Backup;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record RestoreResultData(string TargetDirectory, string? Passphrase, bool Overwrite);
+
+public sealed partial class RestoreBackupViewModel : ViewModelBase
+{
+ private readonly BackupManifest _manifest;
+
+ public string InstanceName => _manifest.InstanceName;
+ public DateTime CreatedAt => _manifest.CreatedAt;
+ public int FileCount => _manifest.Files.Count;
+ public bool NeedsPassphrase => _manifest.HasSecrets;
+
+ public string SecretsDescription => _manifest.HasSecrets
+ ? $"{_manifest.SecretCount}, mit Passphrase geschützt"
+ : _manifest.SecretCount > 0
+ ? $"{_manifest.SecretCount} entfernt"
+ : "keine";
+
+ [ObservableProperty]
+ private string _targetDirectory = "";
+
+ [ObservableProperty]
+ private string _passphrase = "";
+
+ [ObservableProperty]
+ private bool _overwrite;
+
+ [ObservableProperty]
+ private string _statusMessage = "";
+
+ public RestoreResultData? Result { get; private set; }
+
+ public event Action? CloseRequested;
+
+ public RestoreBackupViewModel() : this(new BackupManifest(), @"C:\Instance_Restored") { }
+
+ public RestoreBackupViewModel(BackupManifest manifest, string suggestedTarget)
+ {
+ _manifest = manifest;
+ TargetDirectory = suggestedTarget;
+ }
+
+ [RelayCommand]
+ private void Confirm()
+ {
+ if (string.IsNullOrWhiteSpace(TargetDirectory))
+ {
+ StatusMessage = "Bitte einen Zielordner angeben.";
+ return;
+ }
+
+ if (NeedsPassphrase && string.IsNullOrEmpty(Passphrase))
+ {
+ StatusMessage = "Bitte Passphrase für geschützte Zugangsdaten eingeben.";
+ return;
+ }
+
+ Result = new RestoreResultData(
+ TargetDirectory.Trim(),
+ NeedsPassphrase ? Passphrase : null,
+ Overwrite);
+
+ CloseRequested?.Invoke();
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ Result = null;
+ CloseRequested?.Invoke();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs
new file mode 100644
index 0000000..576557f
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs
@@ -0,0 +1,183 @@
+using ClawdDotNet.App;
+using ClawdDotNet.App.Settings;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+///
+/// Ansichtsmodell für die Einstellungen-Seite (Anwendungs- und Instanz-Einstellungen).
+///
+public sealed partial class SettingsPageViewModel : PageViewModel
+{
+ private readonly AppHost? _host;
+ private readonly ILogger? _logger;
+
+ // ─── Allgemein & Pfade ───
+ [ObservableProperty]
+ private string _logDirectory = "";
+
+ [ObservableProperty]
+ private string _instancesDirectory = "";
+
+ [ObservableProperty]
+ private string _minimumLogLevel = "Info";
+
+ [ObservableProperty]
+ private int _maxLogLinesInUi = 2000;
+
+ [ObservableProperty]
+ private int _logRefreshIntervalMs = 500;
+
+ // ─── API & OpenRouter ───
+ [ObservableProperty]
+ private string _openRouterBaseUrl = "https://openrouter.ai/api/v1/";
+
+ [ObservableProperty]
+ private int _statusCheckIntervalSeconds = 60;
+
+ // ─── Backup ───
+ [ObservableProperty]
+ private string _backupDirectory = "";
+
+ [ObservableProperty]
+ private bool _autoBackupEnabled;
+
+ [ObservableProperty]
+ private string _autoBackupTime = "03:00";
+
+ [ObservableProperty]
+ private int _backupKeepCount = 14;
+
+ // ─── Deploymentcenter ───
+ [ObservableProperty]
+ private string _deploymentcenterUrl = "https://dc.mhdf.de";
+
+ [ObservableProperty]
+ private string _deploymentcenterToken = "";
+
+ [ObservableProperty]
+ private string _deploymentcenterEnvironment = "production";
+
+ [ObservableProperty]
+ private bool _errorReportingEnabled = true;
+
+ [ObservableProperty]
+ private bool _updateCheckEnabled = true;
+
+ [ObservableProperty]
+ private string _updateChannel = "prod";
+
+ // ─── Lizenz ───
+ [ObservableProperty]
+ private string _licenseKey = "";
+
+ // ─── Instanz-Einstellungen ───
+ [ObservableProperty]
+ private string _instanceName = "";
+
+ [ObservableProperty]
+ private string _openRouterApiKey = "";
+
+ [ObservableProperty]
+ private decimal _instanceDailyCostUsd;
+
+ [ObservableProperty]
+ private long _instanceDailyTokens;
+
+ [ObservableProperty]
+ private string _statusText = "Bereit";
+
+ public List LogLevels { get; } = ["Debug", "Info", "Warn", "Error"];
+ public List UpdateChannels { get; } = ["prod", "beta", "dev"];
+ public List Environments { get; } = ["production", "development", "staging"];
+
+ public SettingsPageViewModel() : this(null) { }
+
+ public SettingsPageViewModel(AppHost? host) : base("Einstellungen")
+ {
+ _host = host;
+
+ if (host is not null)
+ {
+ _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Settings");
+ LoadFromHost();
+ }
+ else
+ {
+ StatusText = "Entwurfsmodus";
+ }
+ }
+
+ [RelayCommand]
+ private void LoadFromHost()
+ {
+ if (_host is null) return;
+
+ var s = _host.Settings.AppSettings;
+ LogDirectory = s.LogDirectory;
+ InstancesDirectory = s.InstancesDirectory;
+ MinimumLogLevel = s.MinimumLogLevel;
+ MaxLogLinesInUi = s.MaxLogLinesInUi;
+ LogRefreshIntervalMs = s.LogRefreshIntervalMs;
+ OpenRouterBaseUrl = s.OpenRouterBaseUrl;
+ StatusCheckIntervalSeconds = s.StatusCheckIntervalSeconds;
+ BackupDirectory = s.BackupDirectory;
+ AutoBackupEnabled = s.AutoBackupEnabled;
+ AutoBackupTime = s.AutoBackupTime;
+ BackupKeepCount = s.BackupKeepCount;
+ DeploymentcenterUrl = s.DeploymentcenterUrl;
+ DeploymentcenterToken = s.DeploymentcenterToken;
+ DeploymentcenterEnvironment = s.DeploymentcenterEnvironment;
+ ErrorReportingEnabled = s.ErrorReportingEnabled;
+ UpdateCheckEnabled = s.UpdateCheckEnabled;
+ UpdateChannel = s.UpdateChannel;
+ LicenseKey = s.LicenseKey;
+
+ var inst = _host.Instance;
+ InstanceName = inst.InstanceName;
+ OpenRouterApiKey = inst.OpenRouterApiKey;
+ InstanceDailyCostUsd = inst.Budget.DailyCostUsd;
+ InstanceDailyTokens = inst.Budget.DailyTokens;
+ }
+
+ [RelayCommand]
+ private void SaveSettings()
+ {
+ if (_host is null) return;
+
+ var s = _host.Settings.AppSettings;
+ s.LogDirectory = LogDirectory.Trim();
+ s.InstancesDirectory = InstancesDirectory.Trim();
+ s.MinimumLogLevel = MinimumLogLevel;
+ s.MaxLogLinesInUi = MaxLogLinesInUi;
+ s.LogRefreshIntervalMs = LogRefreshIntervalMs;
+ s.OpenRouterBaseUrl = OpenRouterBaseUrl.Trim();
+ s.StatusCheckIntervalSeconds = StatusCheckIntervalSeconds;
+ s.BackupDirectory = BackupDirectory.Trim();
+ s.AutoBackupEnabled = AutoBackupEnabled;
+ s.AutoBackupTime = AutoBackupTime.Trim();
+ s.BackupKeepCount = BackupKeepCount;
+ s.DeploymentcenterUrl = DeploymentcenterUrl.Trim();
+ s.DeploymentcenterToken = DeploymentcenterToken.Trim();
+ s.DeploymentcenterEnvironment = DeploymentcenterEnvironment.Trim();
+ s.ErrorReportingEnabled = ErrorReportingEnabled;
+ s.UpdateCheckEnabled = UpdateCheckEnabled;
+ s.UpdateChannel = UpdateChannel;
+ s.LicenseKey = LicenseKey.Trim();
+
+ _host.Settings.Save();
+
+ var inst = _host.Instance;
+ inst.InstanceName = InstanceName.Trim();
+ inst.OpenRouterApiKey = OpenRouterApiKey.Trim();
+ inst.Budget.DailyCostUsd = InstanceDailyCostUsd;
+ inst.Budget.DailyTokens = InstanceDailyTokens;
+
+ _host.Directories.SaveInstanceConfig(_host.InstancePath, inst);
+
+ _logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert.");
+ StatusText = "Einstellungen erfolgreich gespeichert.";
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs
new file mode 100644
index 0000000..44ce6a3
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs
@@ -0,0 +1,446 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using ClawdDotNet.App;
+using ClawdDotNet.App.Models;
+using ClawdDotNet.App.Services;
+using ClawdDotNet.Core.Config;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ClawdDotNet.Core.Scheduling;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed record JobDisplayItemViewModel(
+ string JobType,
+ string JobId,
+ string AgentId,
+ string AgentName,
+ string ToolName,
+ string CronExpression,
+ string TaskMessage,
+ bool RunOnStart,
+ string NextRun,
+ string LastRun,
+ string LastStatus,
+ string Status
+);
+
+public sealed record ServiceDisplayItemViewModel(
+ string ServiceId,
+ string Name,
+ string Type,
+ int Port,
+ string Status,
+ string Description
+);
+
+///
+/// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf).
+///
+public sealed partial class TasksPageViewModel : PageViewModel
+{
+ private readonly AppHost? _host;
+ private readonly JobHistoryService? _jobHistoryService;
+ private readonly ILogger? _logger;
+ private readonly Dictionary _jobLastRunTimes = new();
+
+ public ObservableCollection JobEntries { get; } = [];
+ public ObservableCollection ServiceEntries { get; } = [];
+ public ObservableCollection JobHistoryEntries { get; } = [];
+
+ [ObservableProperty]
+ private JobDisplayItemViewModel? _selectedJob;
+
+ [ObservableProperty]
+ private ServiceDisplayItemViewModel? _selectedService;
+
+ [ObservableProperty]
+ private string _statusText = "Bereit";
+
+ [ObservableProperty]
+ private bool _isRunningJob;
+
+ public event Func>? RequestAddJobDialog;
+ public event Func>? RequestAddServiceDialog;
+
+ public TasksPageViewModel() : this(null) { }
+
+ public TasksPageViewModel(AppHost? host) : base("Aufgaben")
+ {
+ _host = host;
+
+ if (host is not null)
+ {
+ _jobHistoryService = new JobHistoryService(host.InstancePath);
+ _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
+
+ EnsureBuiltInServices();
+ RefreshJobList();
+ RefreshServiceList();
+ RefreshJobHistory();
+ }
+ else
+ {
+ StatusText = "Entwurfsmodus";
+ }
+ }
+
+ private void SaveInstanceConfig()
+ {
+ if (_host is null) return;
+ _host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
+ }
+
+ private void EnsureBuiltInServices()
+ {
+ if (_host is null) return;
+
+ var defaults = BuiltInServices.CreateDefaults();
+ var changed = false;
+
+ foreach (var def in defaults)
+ {
+ if (_host.Instance.Services.Any(s => s.ServiceId == def.ServiceId))
+ continue;
+
+ _host.Instance.Services.Insert(0, def);
+ changed = true;
+ }
+
+ if (changed)
+ SaveInstanceConfig();
+ }
+
+ [RelayCommand]
+ private void RefreshJobList()
+ {
+ JobEntries.Clear();
+ if (_host is null) return;
+
+ foreach (var agent in _host.Instance.Agents)
+ {
+ // Agent Wakeup Jobs
+ if (agent.Scheduler is not null)
+ {
+ var nextRun = CalculateNextRun(agent.Scheduler.Cron);
+ var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
+ ? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
+ : "—";
+
+ JobEntries.Add(new JobDisplayItemViewModel(
+ "Agent Wakeup",
+ $"agent_{agent.AgentId}",
+ agent.AgentId,
+ agent.DisplayName,
+ "—",
+ agent.Scheduler.Cron,
+ agent.Scheduler.TaskMessage,
+ agent.Scheduler.RunOnStart,
+ nextRun,
+ agentLastRun,
+ "—",
+ _host.Scanner is not null ? "Aktiv" : "Inaktiv"
+ ));
+ }
+
+ // Tool Jobs
+ foreach (var toolJob in agent.ToolJobs)
+ {
+ var nextRun = CalculateNextRun(toolJob.Cron);
+ var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
+ ? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
+ : "—";
+
+ JobEntries.Add(new JobDisplayItemViewModel(
+ "Tool Job",
+ toolJob.JobId,
+ agent.AgentId,
+ agent.DisplayName,
+ toolJob.ToolName,
+ toolJob.Cron,
+ toolJob.JobTypeId,
+ toolJob.RunOnStart,
+ nextRun,
+ toolLastRun,
+ "—",
+ toolJob.Enabled ? "Aktiv" : "Deaktiviert"
+ ));
+ }
+ }
+ }
+
+ [RelayCommand]
+ private void RefreshServiceList()
+ {
+ ServiceEntries.Clear();
+ if (_host is null) return;
+
+ foreach (var svc in _host.Instance.Services)
+ {
+ ServiceEntries.Add(new ServiceDisplayItemViewModel(
+ svc.ServiceId,
+ svc.Name,
+ svc.Type,
+ svc.Port,
+ svc.Enabled ? "Bereit" : "Deaktiviert",
+ svc.Description
+ ));
+ }
+ }
+
+ [RelayCommand]
+ private void RefreshJobHistory()
+ {
+ JobHistoryEntries.Clear();
+ if (_jobHistoryService is null) return;
+
+ foreach (var entry in _jobHistoryService.GetAll().Take(200))
+ JobHistoryEntries.Add(entry);
+ }
+
+ [RelayCommand]
+ private async Task AddJobAsync()
+ {
+ if (_host is null || RequestAddJobDialog is null) return;
+
+ var vm = new AddJobViewModel(_host.Instance.Agents);
+ var result = await RequestAddJobDialog(vm);
+ if (result is null) return;
+
+ var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId);
+ if (agent is null) return;
+
+ if (result.IsToolJob)
+ {
+ agent.ToolJobs.Add(new ToolJobConfig
+ {
+ ToolName = result.ToolName,
+ JobTypeId = result.JobTypeId,
+ Cron = result.CronExpression,
+ RunOnStart = result.RunOnStart,
+ Enabled = true
+ });
+
+ _logger?.LogInformation("Tool-Job hinzugefügt: Agent={Agent}, Tool={Tool}, Cron={Cron}",
+ agent.DisplayName, result.ToolName, result.CronExpression);
+ }
+ else
+ {
+ agent.Scheduler = new SchedulerConfig
+ {
+ Cron = result.CronExpression,
+ RunOnStart = result.RunOnStart,
+ TaskMessage = result.TaskMessage
+ };
+
+ _logger?.LogInformation("Agent-Wakeup-Job hinzugefügt: Agent={Agent}, Cron={Cron}",
+ agent.DisplayName, result.CronExpression);
+ }
+
+ SaveInstanceConfig();
+ RefreshJobList();
+ }
+
+ [RelayCommand]
+ private async Task EditJobAsync()
+ {
+ if (_host is null || SelectedJob is null || RequestAddJobDialog is null) return;
+
+ var entry = SelectedJob;
+ var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
+ if (agent is null) return;
+
+ var isToolJob = entry.JobType == "Tool Job";
+ var vm = new AddJobViewModel(_host.Instance.Agents)
+ {
+ SelectedAgent = agent,
+ IsToolJob = isToolJob,
+ SelectedToolName = isToolJob ? entry.ToolName : "FileRW",
+ JobTypeId = isToolJob ? entry.TaskMessage : "PollJob",
+ CronExpression = entry.CronExpression,
+ TaskMessage = isToolJob ? "" : entry.TaskMessage,
+ RunOnStart = entry.RunOnStart
+ };
+
+ var result = await RequestAddJobDialog(vm);
+ if (result is null) return;
+
+ if (isToolJob)
+ {
+ var toolJob = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
+ if (toolJob is not null)
+ {
+ toolJob.Cron = result.CronExpression;
+ toolJob.RunOnStart = result.RunOnStart;
+ toolJob.JobTypeId = result.JobTypeId;
+ toolJob.ToolName = result.ToolName;
+ }
+ }
+ else
+ {
+ if (agent.Scheduler is not null)
+ {
+ agent.Scheduler.Cron = result.CronExpression;
+ agent.Scheduler.TaskMessage = result.TaskMessage;
+ agent.Scheduler.RunOnStart = result.RunOnStart;
+ }
+ }
+
+ SaveInstanceConfig();
+ RefreshJobList();
+ }
+
+ [RelayCommand]
+ private void RemoveJob()
+ {
+ if (_host is null || SelectedJob is null) return;
+
+ var entry = SelectedJob;
+ var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
+ if (agent is null) return;
+
+ if (entry.JobType == "Tool Job")
+ {
+ agent.ToolJobs.RemoveAll(j => j.JobId == entry.JobId);
+ }
+ else
+ {
+ agent.Scheduler = null;
+ }
+
+ SaveInstanceConfig();
+ RefreshJobList();
+ }
+
+ [RelayCommand]
+ private async Task RunJobNowAsync()
+ {
+ if (_host is null || SelectedJob is null) return;
+
+ var entry = SelectedJob;
+ var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
+ if (agent is null) return;
+
+ IsRunningJob = true;
+ StatusText = $"Führe Job für '{agent.DisplayName}' aus…";
+
+ try
+ {
+ if (entry.JobType == "Tool Job")
+ {
+ if (_host.Scanner is null)
+ {
+ StatusText = "Scanner ist nicht aktiv.";
+ return;
+ }
+
+ var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
+ if (jobConfig is null) return;
+
+ var ran = await _host.Scanner.RunTaskNowAsync(
+ $"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
+
+ _jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
+ var info = ran ? "Ausgeführt" : "Nicht bereit";
+
+ _jobHistoryService?.Add(new JobHistoryEntry
+ {
+ JobName = jobConfig.JobTypeId,
+ Agent = agent.DisplayName,
+ Time = DateTime.Now,
+ JobDescription = $"Tool Job: {jobConfig.ToolName}",
+ Info = info,
+ Status = "Manual"
+ });
+
+ StatusText = $"Tool Job '{jobConfig.JobTypeId}' abgeschlossen ({info}).";
+ }
+ else
+ {
+ if (_host.Engine is null)
+ {
+ StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
+ return;
+ }
+
+ var taskMessage = agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
+ var result = await _host.Engine.RunAsync(agent, taskMessage, _host.Instance.InstanceId, CancellationToken.None);
+
+ _jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
+
+ _jobHistoryService?.Add(new JobHistoryEntry
+ {
+ JobName = "Agent Wakeup",
+ Agent = agent.DisplayName,
+ Time = DateTime.Now,
+ JobDescription = taskMessage,
+ Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
+ Status = "Manual"
+ });
+
+ StatusText = $"Agent '{agent.DisplayName}' abgeschlossen: {result.Status} ({result.TokensUsed:N0} Tokens).";
+ }
+
+ RefreshJobList();
+ RefreshJobHistory();
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Fehler beim Ausführen: {ex.Message}";
+ _logger?.LogError(ex, "Job-Ausführung fehlgeschlagen");
+ }
+ finally
+ {
+ IsRunningJob = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddServiceAsync()
+ {
+ if (_host is null || RequestAddServiceDialog is null) return;
+
+ var vm = new AddServiceViewModel();
+ var result = await RequestAddServiceDialog(vm);
+ if (result is null) return;
+
+ var svcConfig = new ServiceConfig
+ {
+ Name = result.ServiceName,
+ Type = result.ServiceType,
+ Port = result.ServicePort,
+ Enabled = true,
+ Description = result.ServiceDescription
+ };
+
+ _host.Instance.Services.Add(svcConfig);
+ SaveInstanceConfig();
+ RefreshServiceList();
+ }
+
+ [RelayCommand]
+ private void RemoveService()
+ {
+ if (_host is null || SelectedService is null) return;
+
+ _host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId);
+ SaveInstanceConfig();
+ RefreshServiceList();
+ }
+
+ private static string CalculateNextRun(string? cronExpr)
+ {
+ if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
+ try
+ {
+ var cron = CronExpression.Parse(cronExpr);
+ var next = cron.GetNextOccurrence(DateTime.UtcNow);
+ return next?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
+ }
+ catch
+ {
+ return "Ungültig";
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs
new file mode 100644
index 0000000..0260670
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs
@@ -0,0 +1,221 @@
+using ClawdDotNet.Models;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+public sealed partial class ToolSettingsViewModel : ViewModelBase
+{
+ public string ToolName { get; }
+ public object? SettingsObject { get; }
+
+ // FTP
+ [ObservableProperty] private string _ftpHost = "";
+ [ObservableProperty] private int _ftpPort = 21;
+ [ObservableProperty] private string _ftpUsername = "";
+ [ObservableProperty] private string _ftpPassword = "";
+ [ObservableProperty] private string _ftpRootPath = "./data/";
+
+ // Mail
+ [ObservableProperty] private string _mailUsername = "";
+ [ObservableProperty] private string _mailPassword = "";
+ [ObservableProperty] private string _mailImapHost = "";
+ [ObservableProperty] private int _mailImapPort = 993;
+ [ObservableProperty] private string _mailSmtpHost = "";
+ [ObservableProperty] private int _mailSmtpPort = 587;
+ [ObservableProperty] private string _mailAllowedRecipients = "";
+
+ // Database
+ [ObservableProperty] private DatabaseType _dbType = DatabaseType.MySql;
+ [ObservableProperty] private string _dbConnectionString = "";
+ [ObservableProperty] private DatabaseAccessLevel _dbAccessLevel = DatabaseAccessLevel.ReadOnly;
+ [ObservableProperty] private string _dbAllowedTables = "";
+
+ // Telegram
+ [ObservableProperty] private string _telegramBotToken = "";
+ [ObservableProperty] private string _telegramDefaultChatId = "";
+ [ObservableProperty] private string _telegramAllowedChatIds = "";
+
+ // DirectAPI
+ [ObservableProperty] private string _directApiDefaultProvider = "twelvedata";
+ [ObservableProperty] private int _directApiCacheTtlSeconds = 60;
+ [ObservableProperty] private string _directApiTwelveDataKey = "";
+ [ObservableProperty] private string _directApiAlphaVantageKey = "";
+
+ // WebFetch
+ [ObservableProperty] private string _webFetchAllowedDomains = "";
+ [ObservableProperty] private int _webFetchMaxResponseKb = 512;
+ [ObservableProperty] private string _webFetchUserAgent = "ClawdDotNet-Agent/1.0";
+
+ // SocialMediaManager
+ [ObservableProperty] private string _smmXApiKey = "";
+ [ObservableProperty] private string _smmXWatchAccounts = "";
+ [ObservableProperty] private string _smmRedditWatchSubreddits = "";
+ [ObservableProperty] private int _smmRedditPostLimit = 15;
+ [ObservableProperty] private string _smmOpenRouterApiKey = "";
+ [ObservableProperty] private string _smmSttModel = "openai/whisper-1";
+ [ObservableProperty] private string _smmYoutubeChannels = "";
+
+ // FileRW
+ [ObservableProperty] private string _fileRwPersonalAllowedExtensions = ".txt,.json,.md,.html,.js,.css";
+ [ObservableProperty] private FileRWAccessLevel _fileRwSharedAccessLevel = FileRWAccessLevel.Denied;
+ [ObservableProperty] private string _fileRwSharedAllowedExtensions = ".txt,.json,.md";
+ [ObservableProperty] private string _fileRwProtectedPaths = "stocks/";
+
+ public List DbTypes { get; } = [DatabaseType.MySql, DatabaseType.Postgres, DatabaseType.MsSql, DatabaseType.MongoDb];
+ public List DbAccessLevels { get; } = [DatabaseAccessLevel.ReadOnly, DatabaseAccessLevel.ReadWrite, DatabaseAccessLevel.Admin];
+ public List FileRwAccessLevels { get; } = [FileRWAccessLevel.Denied, FileRWAccessLevel.Read, FileRWAccessLevel.ReadWrite, FileRWAccessLevel.Admin];
+
+ public Dictionary? Result { get; private set; }
+ public event Action? CloseRequested;
+
+ public ToolSettingsViewModel() : this("FTP", new()) { }
+
+ public ToolSettingsViewModel(string toolName, Dictionary config)
+ {
+ ToolName = toolName;
+ SettingsObject = ToolSettingsFactory.CreateViewModel(toolName, config);
+
+ switch (SettingsObject)
+ {
+ case FTPToolSettings ftp:
+ FtpHost = ftp.Host;
+ FtpPort = ftp.Port;
+ FtpUsername = ftp.Username;
+ FtpPassword = ftp.Password;
+ FtpRootPath = ftp.RootPath;
+ break;
+
+ case MailToolSettings mail:
+ MailUsername = mail.Username;
+ MailPassword = mail.Password;
+ MailImapHost = mail.ImapHost;
+ MailImapPort = mail.ImapPort;
+ MailSmtpHost = mail.SmtpHost;
+ MailSmtpPort = mail.SmtpPort;
+ MailAllowedRecipients = mail.AllowedRecipients;
+ break;
+
+ case DatabaseToolSettings db:
+ DbType = db.Type;
+ DbConnectionString = db.ConnectionString;
+ DbAccessLevel = db.AccessLevel;
+ DbAllowedTables = db.AllowedTables;
+ break;
+
+ case TelegramToolSettings tg:
+ TelegramBotToken = tg.BotToken;
+ TelegramDefaultChatId = tg.DefaultChatId;
+ TelegramAllowedChatIds = tg.AllowedChatIds;
+ break;
+
+ case DirectAPIToolSettings dapi:
+ DirectApiDefaultProvider = dapi.DefaultProvider;
+ DirectApiCacheTtlSeconds = dapi.CacheTtlSeconds;
+ DirectApiTwelveDataKey = dapi.TwelveDataKey;
+ DirectApiAlphaVantageKey = dapi.AlphaVantageKey;
+ break;
+
+ case WebFetchToolSettings wf:
+ WebFetchAllowedDomains = wf.AllowedDomains;
+ WebFetchMaxResponseKb = wf.MaxResponseKb;
+ WebFetchUserAgent = wf.UserAgent;
+ break;
+
+ case SocialMediaManagerToolSettings smm:
+ SmmXApiKey = smm.XApiKey;
+ SmmXWatchAccounts = smm.XWatchAccounts;
+ SmmRedditWatchSubreddits = smm.RedditWatchSubreddits;
+ SmmRedditPostLimit = smm.RedditPostLimit;
+ SmmOpenRouterApiKey = smm.OpenRouterApiKey;
+ SmmSttModel = smm.STTModel;
+ SmmYoutubeChannels = smm.YoutubeChannels;
+ break;
+
+ case FileRWToolSettings frw:
+ FileRwPersonalAllowedExtensions = frw.PersonalAllowedExtensions;
+ FileRwSharedAccessLevel = frw.SharedAccessLevel;
+ FileRwSharedAllowedExtensions = frw.SharedAllowedExtensions;
+ FileRwProtectedPaths = frw.ProtectedPaths;
+ break;
+ }
+ }
+
+ [RelayCommand]
+ private void Confirm()
+ {
+ switch (SettingsObject)
+ {
+ case FTPToolSettings ftp:
+ ftp.Host = FtpHost;
+ ftp.Port = FtpPort;
+ ftp.Username = FtpUsername;
+ ftp.Password = FtpPassword;
+ ftp.RootPath = FtpRootPath;
+ break;
+
+ case MailToolSettings mail:
+ mail.Username = MailUsername;
+ mail.Password = MailPassword;
+ mail.ImapHost = MailImapHost;
+ mail.ImapPort = MailImapPort;
+ mail.SmtpHost = MailSmtpHost;
+ mail.SmtpPort = MailSmtpPort;
+ mail.AllowedRecipients = MailAllowedRecipients;
+ break;
+
+ case DatabaseToolSettings db:
+ db.Type = DbType;
+ db.ConnectionString = DbConnectionString;
+ db.AccessLevel = DbAccessLevel;
+ db.AllowedTables = DbAllowedTables;
+ break;
+
+ case TelegramToolSettings tg:
+ tg.BotToken = TelegramBotToken;
+ tg.DefaultChatId = TelegramDefaultChatId;
+ tg.AllowedChatIds = TelegramAllowedChatIds;
+ break;
+
+ case DirectAPIToolSettings dapi:
+ dapi.DefaultProvider = DirectApiDefaultProvider;
+ dapi.CacheTtlSeconds = DirectApiCacheTtlSeconds;
+ dapi.TwelveDataKey = DirectApiTwelveDataKey;
+ dapi.AlphaVantageKey = DirectApiAlphaVantageKey;
+ break;
+
+ case WebFetchToolSettings wf:
+ wf.AllowedDomains = WebFetchAllowedDomains;
+ wf.MaxResponseKb = WebFetchMaxResponseKb;
+ wf.UserAgent = WebFetchUserAgent;
+ break;
+
+ case SocialMediaManagerToolSettings smm:
+ smm.XApiKey = SmmXApiKey;
+ smm.XWatchAccounts = SmmXWatchAccounts;
+ smm.RedditWatchSubreddits = SmmRedditWatchSubreddits;
+ smm.RedditPostLimit = SmmRedditPostLimit;
+ smm.OpenRouterApiKey = SmmOpenRouterApiKey;
+ smm.STTModel = SmmSttModel;
+ smm.YoutubeChannels = SmmYoutubeChannels;
+ break;
+
+ case FileRWToolSettings frw:
+ frw.PersonalAllowedExtensions = FileRwPersonalAllowedExtensions;
+ frw.SharedAccessLevel = FileRwSharedAccessLevel;
+ frw.SharedAllowedExtensions = FileRwSharedAllowedExtensions;
+ frw.ProtectedPaths = FileRwProtectedPaths;
+ break;
+ }
+
+ Result = ToolSettingsFactory.ToConfig(ToolName, SettingsObject) ?? new();
+ CloseRequested?.Invoke();
+ }
+
+ [RelayCommand]
+ private void Cancel()
+ {
+ Result = null;
+ CloseRequested?.Invoke();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs b/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..b453a19
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs
@@ -0,0 +1,15 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClawdDotNet.Desktop.ViewModels;
+
+///
+/// Grundlage aller Ansichtsmodelle.
+///
+/// aus dem CommunityToolkit bringt
+/// INotifyPropertyChanged samt Quelltextgenerator mit — aus
+/// [ObservableProperty] private string _name; wird die vollständige Eigenschaft
+/// mit Benachrichtigung. Das spart gegenüber der WinForms-Fassung, in der die
+/// Oberfläche bei jeder Änderung von Hand nachgezogen wurde
+/// (38 Stellen mit Invoke/BeginInvoke).
+///
+public abstract class ViewModelBase : ObservableObject;
diff --git a/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml
new file mode 100644
index 0000000..8106bec
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml.cs
new file mode 100644
index 0000000..a538132
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class AddAgentWindow : Window
+{
+ public AddAgentWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is AddAgentViewModel vm)
+ {
+ vm.CloseRequested += () => Close();
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml b/src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml
new file mode 100644
index 0000000..566833f
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml.cs
new file mode 100644
index 0000000..746812d
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class AddJobWindow : Window
+{
+ public AddJobWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is AddJobViewModel vm)
+ {
+ vm.CloseRequested += () => Close();
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml b/src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml
new file mode 100644
index 0000000..bd52333
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml.cs
new file mode 100644
index 0000000..ae9e848
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class AddServiceWindow : Window
+{
+ public AddServiceWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is AddServiceViewModel vm)
+ {
+ vm.CloseRequested += () => Close();
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml
new file mode 100644
index 0000000..050a9f8
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs
new file mode 100644
index 0000000..ef79cfc
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs
@@ -0,0 +1,46 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class AgentsPageView : UserControl
+{
+ public AgentsPageView()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is AgentsPageViewModel vm)
+ {
+ vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
+ vm.RequestToolSettingsDialog += OnRequestToolSettingsDialog;
+ }
+ }
+
+ private async Task OnRequestAddAgentDialog(AddAgentViewModel vm)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window window)
+ {
+ var dialog = new AddAgentWindow { DataContext = vm };
+ await dialog.ShowDialog(window);
+ return vm.Result;
+ }
+ return null;
+ }
+
+ private async Task?> OnRequestToolSettingsDialog(ToolSettingsViewModel vm)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window window)
+ {
+ var dialog = new ToolSettingsWindow { DataContext = vm };
+ await dialog.ShowDialog(window);
+ return vm.Result;
+ }
+ return null;
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml
new file mode 100644
index 0000000..0a0bff8
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml.cs
new file mode 100644
index 0000000..dbc80ef
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml.cs
@@ -0,0 +1,56 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class BackupPageView : UserControl
+{
+ public BackupPageView()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is BackupPageViewModel vm)
+ {
+ vm.RequestRestoreDialog += OnRequestRestoreDialog;
+ }
+ }
+
+ private async Task OnRequestRestoreDialog(RestoreBackupViewModel vm)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window window)
+ {
+ var dialog = new RestoreBackupWindow { DataContext = vm };
+ await dialog.ShowDialog(window);
+ return vm.Result;
+ }
+ return null;
+ }
+
+ private async void OnBrowseTargetFolder(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not BackupPageViewModel vm) return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider is null) return;
+
+ var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Zielordner für Sicherungen wählen",
+ AllowMultiple = false
+ });
+
+ if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path)
+ {
+ vm.TargetFolder = path;
+ vm.SaveSettings();
+ vm.RefreshBackupListCommand.Execute(null);
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/ChatPageView.axaml b/src/ClawdDotNet.Desktop/Views/ChatPageView.axaml
new file mode 100644
index 0000000..4cb8b70
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/ChatPageView.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/ChatPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/ChatPageView.axaml.cs
new file mode 100644
index 0000000..a480aa1
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/ChatPageView.axaml.cs
@@ -0,0 +1,25 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class ChatPageView : UserControl
+{
+ public ChatPageView()
+ {
+ InitializeComponent();
+ }
+
+ private void OnInputKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter && e.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ if (DataContext is ChatPageViewModel vm && vm.SendMessageCommand.CanExecute(null))
+ {
+ vm.SendMessageCommand.Execute(null);
+ e.Handled = true;
+ }
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml b/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml
new file mode 100644
index 0000000..e24c641
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs
new file mode 100644
index 0000000..21db0ba
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class InfoPageView : UserControl
+{
+ public InfoPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml b/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml
new file mode 100644
index 0000000..ec3d57f
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml.cs
new file mode 100644
index 0000000..118675c
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class InstancePickerWindow : Window
+{
+ public InstancePickerWindow()
+ {
+ InitializeComponent();
+
+ // Doppelklick auf eine Zeile startet — wie in der WinForms-Fassung. Das laesst
+ // sich nicht sinnvoll als Befehl binden, weil DataGrid kein DoubleTapped je
+ // Zeile anbietet; deshalb hier und nicht im Ansichtsmodell.
+ AddHandler(DoubleTappedEvent, OnDoubleTapped, handledEventsToo: false);
+ }
+
+ private void OnDoubleTapped(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is InstancePickerViewModel vm && vm.StartCommand.CanExecute(null))
+ vm.StartCommand.Execute(null);
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml b/src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml
new file mode 100644
index 0000000..906a5f6
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml.cs
new file mode 100644
index 0000000..c4ceb2a
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class LicenseWindow : Window
+{
+ public LicenseWindow()
+ {
+ InitializeComponent();
+
+ // Wer das Fenster ueber das Kreuz schliesst, hat abgebrochen. Ohne das bliebe
+ // der wartende Task fuer immer offen und der Start haenge.
+ Closed += (_, _) => (DataContext as LicenseViewModel)?.CancelIfPending();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/LogPageView.axaml b/src/ClawdDotNet.Desktop/Views/LogPageView.axaml
new file mode 100644
index 0000000..3624b01
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/LogPageView.axaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs
new file mode 100644
index 0000000..bbf7971
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class LogPageView : UserControl
+{
+ public LogPageView()
+ {
+ InitializeComponent();
+
+ // Mitlaufen: Nach jeder neuen Zeile ans Ende springen — aber nur, solange der
+ // Haken gesetzt ist. Das gehoert in die Ansicht, weil nur sie den ScrollViewer
+ // kennt; das Ansichtsmodell haelt lediglich den Schalter.
+ Scroller.ScrollChanged += (_, _) =>
+ {
+ if (DataContext is LogPageViewModel { FollowTail: true })
+ Scroller.ScrollToEnd();
+ };
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml
new file mode 100644
index 0000000..eb50db0
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..3622aed
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs
@@ -0,0 +1,28 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.RequestExit += Close;
+ vm.RequestInstanceManagerDialog += OnOpenInstanceManager;
+ }
+ }
+
+ private async void OnOpenInstanceManager()
+ {
+ var dialog = new InstancePickerWindow();
+ await dialog.ShowDialog(this);
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml b/src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml
new file mode 100644
index 0000000..1cb1e01
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml.cs
new file mode 100644
index 0000000..16832b5
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml.cs
@@ -0,0 +1,8 @@
+using Avalonia.Controls;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class PlaceholderPageView : UserControl
+{
+ public PlaceholderPageView() => InitializeComponent();
+}
diff --git a/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml b/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml
new file mode 100644
index 0000000..ea5864f
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml.cs
new file mode 100644
index 0000000..14adbfa
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml.cs
@@ -0,0 +1,42 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class RestoreBackupWindow : Window
+{
+ public RestoreBackupWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is RestoreBackupViewModel vm)
+ {
+ vm.CloseRequested += () => Close();
+ }
+ }
+
+ private async void OnBrowseFolder(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not RestoreBackupViewModel vm) return;
+
+ var storage = StorageProvider;
+ if (storage is null) return;
+
+ var folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Zielordner für Wiederherstellung wählen",
+ AllowMultiple = false
+ });
+
+ if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path)
+ {
+ vm.TargetDirectory = path;
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml
new file mode 100644
index 0000000..0c7d865
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs
new file mode 100644
index 0000000..790d578
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class SettingsPageView : UserControl
+{
+ public SettingsPageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml b/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml
new file mode 100644
index 0000000..5da3c1c
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs
new file mode 100644
index 0000000..fae5b62
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs
@@ -0,0 +1,46 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class TasksPageView : UserControl
+{
+ public TasksPageView()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is TasksPageViewModel vm)
+ {
+ vm.RequestAddJobDialog += OnRequestAddJobDialog;
+ vm.RequestAddServiceDialog += OnRequestAddServiceDialog;
+ }
+ }
+
+ private async Task OnRequestAddJobDialog(AddJobViewModel vm)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window window)
+ {
+ var dialog = new AddJobWindow { DataContext = vm };
+ await dialog.ShowDialog(window);
+ return vm.Result;
+ }
+ return null;
+ }
+
+ private async Task OnRequestAddServiceDialog(AddServiceViewModel vm)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window window)
+ {
+ var dialog = new AddServiceWindow { DataContext = vm };
+ await dialog.ShowDialog(window);
+ return vm.Result;
+ }
+ return null;
+ }
+}
diff --git a/src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml b/src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml
new file mode 100644
index 0000000..f96775d
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml.cs
new file mode 100644
index 0000000..885b382
--- /dev/null
+++ b/src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using ClawdDotNet.Desktop.ViewModels;
+
+namespace ClawdDotNet.Desktop.Views;
+
+public partial class ToolSettingsWindow : Window
+{
+ public ToolSettingsWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is ToolSettingsViewModel vm)
+ {
+ vm.CloseRequested += () => Close();
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Tools.FTP/FtpTool.cs b/src/ClawdDotNet.Tools.FTP/FtpTool.cs
index 2bd511a..e73be82 100644
--- a/src/ClawdDotNet.Tools.FTP/FtpTool.cs
+++ b/src/ClawdDotNet.Tools.FTP/FtpTool.cs
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
+using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tools;
using FluentFTP;
using Microsoft.Extensions.Logging;
@@ -151,8 +152,13 @@ public sealed class FTPTool : IAgentTool
basePath = Path.GetFullPath(basePath);
var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
- // Sicherheitsprüfung: Kein Ausbruch aus dem Workspace
- if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
+ // Sicherheitsprüfung: Kein Ausbruch aus dem Workspace.
+ //
+ // PathBoundary statt eines Zeichenketten-Präfixes: Es vergleicht auf
+ // Verzeichnisgrenzen (sonst gälte "…/Workspace-Backup" als Teil von
+ // "…/Workspace"), berücksichtigt die Groß-/Kleinschreibung des Dateisystems
+ // und löst symbolische Verknüpfungen auf.
+ if (!PathBoundary.IsInside(fullPath, basePath))
{
throw new UnauthorizedAccessException(
$"Zugriff außerhalb des {label} Workspace-Verzeichnisses verweigert.");
diff --git a/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs b/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs
index b45f05c..9f1400c 100644
--- a/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs
+++ b/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs
@@ -1,3 +1,5 @@
+using ClawdDotNet.Core.Storage;
+
namespace ClawdDotNet.Tools.FileRW;
///
@@ -11,6 +13,11 @@ namespace ClawdDotNet.Tools.FileRW;
/// Ohne abschließenden Verzeichnistrenner erlaubte ein Root wie
/// "…\Agent-X\Workspace" damit auch Zugriffe auf "…\Agent-X\Workspace-Backup\…" —
/// ein fremdes Verzeichnis, das zufällig mit demselben Präfix beginnt.
+///
+/// Der Vergleich selbst liegt in : Unter Linux
+/// unterscheidet das Dateisystem Groß- und Kleinschreibung, und symbolische
+/// Verknüpfungen können aus dem Workspace hinauszeigen, ohne dass die reine
+/// Pfadrechnung das sieht.
///
public static class WorkspacePath
{
@@ -31,6 +38,12 @@ public static class WorkspacePath
}
// Alternate Data Streams (datei.txt:versteckt) umgehen die Endungsprüfung.
+ //
+ // Die Prüfung trägt unter Linux zusätzlich: Dort meldet Path.IsPathRooted für
+ // "C:\Windows\…" false — ein Windows-Pfad ist unter Linux ein gewöhnlicher
+ // Dateiname. Ohne diese Zeile käme er als relativer Pfad durch. Ein Doppelpunkt
+ // im Dateinamen ist unter Linux zwar erlaubt, aber nichts, was ein Agent
+ // braucht; die Einschränkung kostet uns nichts.
if (value.Contains(':'))
{
throw new UnauthorizedAccessException(
@@ -51,33 +64,13 @@ public static class WorkspacePath
///
/// Prüft, ob im Verzeichnis liegt.
- /// Der Vergleich erfolgt auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen.
+ /// Der Vergleich erfolgt auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen,
+ /// und löst symbolische Verknüpfungen auf.
///
public static bool IsInside(string candidate, string root)
- {
- var normalizedRoot = NormalizeDirectory(root);
- var normalizedCandidate = Path.GetFullPath(candidate);
-
- // Der Root selbst gilt als innerhalb.
- if (string.Equals(
- normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
- normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
- StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- // Der Trenner ist entscheidend: Ohne ihn gälte "…\Workspace-Backup" als
- // Teil von "…\Workspace".
- return normalizedCandidate.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
- }
+ => PathBoundary.IsInside(candidate, root);
/// Absoluter Pfad mit genau einem abschließenden Trenner.
private static string NormalizeDirectory(string path)
- {
- var full = Path.GetFullPath(path);
- return full.EndsWith(Path.DirectorySeparatorChar)
- ? full
- : full + Path.DirectorySeparatorChar;
- }
+ => PathBoundary.NormalizeDirectory(path);
}
diff --git a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
index 497e908..4a732d7 100644
--- a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
+++ b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
@@ -776,12 +776,10 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
if (detectedPath == null)
{
// 2. Check local paths
- var baseDir = AppDomain.CurrentDomain.BaseDirectory;
- string[] candidates =
- [
- Path.Combine(baseDir, "yt-dlp.exe"),
- Path.Combine(baseDir, "tools", "yt-dlp.exe")
- ];
+ // Unter Linux heißt das Programm ohne Endung. Ohne diese Variante fände die
+ // Suche im Programmverzeichnis dort nie etwas — und die PATH-Prüfung davor
+ // ist die einzige, die griffe.
+ var candidates = LocalToolCandidates("yt-dlp");
foreach (var p in candidates)
{
@@ -819,12 +817,7 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
if (!hasFfmpeg)
{
// Check local paths for ffmpeg
- var baseDir = AppDomain.CurrentDomain.BaseDirectory;
- string[] ffmpegCandidates =
- [
- Path.Combine(baseDir, "ffmpeg.exe"),
- Path.Combine(baseDir, "tools", "ffmpeg.exe")
- ];
+ var ffmpegCandidates = LocalToolCandidates("ffmpeg");
foreach (var fp in ffmpegCandidates)
{
@@ -840,6 +833,29 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
return (true, detectedPath, hasFfmpeg, ffmpegPath);
}
+ ///
+ /// Wo ein mitgeliefertes Hilfsprogramm liegen könnte — Programmverzeichnis und
+ /// tools/, je mit und ohne .exe.
+ ///
+ /// Die Endung wird auf beiden Plattformen mitgeprüft, nicht nur auf der jeweils
+ /// „richtigen": Ein Deployment-Paket, das unter Windows geschnürt wurde, kann die
+ /// Windows-Binärdatei enthalten, und umgekehrt. Vier Aufrufe von
+ /// sind billiger als eine Fallunterscheidung, die man
+ /// vergessen kann.
+ ///
+ private static string[] LocalToolCandidates(string name)
+ {
+ var baseDir = AppContext.BaseDirectory;
+
+ return
+ [
+ Path.Combine(baseDir, name),
+ Path.Combine(baseDir, name + ".exe"),
+ Path.Combine(baseDir, "tools", name),
+ Path.Combine(baseDir, "tools", name + ".exe")
+ ];
+ }
+
private string GetYtDlpInstallationGuide()
{
return "FEHLER: Abhängigkeiten fehlen für YouTube-Funktionen.\n\n" +
diff --git a/src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj b/src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj
new file mode 100644
index 0000000..d146c73
--- /dev/null
+++ b/src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Tools.Taskboard
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs
new file mode 100644
index 0000000..7a57cc0
--- /dev/null
+++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs
@@ -0,0 +1,374 @@
+using System.Text;
+using System.Text.Json;
+using ClawdDotNet.Core.Tasks;
+using ClawdDotNet.Core.Tools;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Tools.Taskboard;
+
+///
+/// Das Taskboard — Aufgaben statt Delay-Schleifen (A1).
+///
+/// Aufgaben sind Markdown-Dateien mit Frontmatter im geteilten Arbeitsbereich; dieses
+/// Tool legt sie an, ändert und listet sie. Agent-zu-Agent-Delegation läuft hierüber
+/// (eine Aufgabe mit assignee: @<agent>) statt über rekursive Nachrichten —
+/// das ist strukturell zyklenfrei. Wann eine fällige Aufgabe tatsächlich läuft,
+/// entscheidet der Scanner, nicht dieses Tool.
+///
+public sealed class TaskboardTool : IAgentTool
+{
+ private const int MaxResultChars = 8_000;
+
+ public string Name => "Taskboard";
+
+ public string Description => """
+ Das gemeinsame Aufgabenbrett — überdauert Läufe und Neustarts.
+
+ Nutze es, um Arbeit festzuhalten und zu verteilen, statt sie im Kontext zu
+ behalten: eigene offene Punkte, Aufträge an andere Agenten, wiederkehrende
+ Termine. Eine Aufgabe an einen anderen Agenten legst du mit assignee='@'
+ an — sie läuft, wenn sie fällig ist, ohne dass du wartest.
+
+ Aktionen: task_create, task_list, task_update, task_comment
+
+ Assignee:
+ - '@new' → frischer Lauf ohne Vorgeschichte
+ - '@'→ der genannte Agent mit seinem Kontext
+ - '@human' → wartet auf einen Menschen (Standard, wenn unklar)
+
+ Termine (Feld 'when'): einmalig ('at'), wiederkehrend ('every') oder 'cron'.
+ Die Zeitzone 'tz' ist dabei Pflicht (z.B. 'Europe/Berlin').
+
+ Fehlt 'when', ist die Aufgabe einmalig und sofort fällig.
+ """;
+
+ public JsonElement InputSchema { get; } = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["task_create", "task_list", "task_update", "task_comment"],
+ "description": "Die auszuführende Aktion."
+ },
+ "id": {
+ "type": "string",
+ "description": "Id der Aufgabe. Pflicht bei task_update und task_comment."
+ },
+ "title": {
+ "type": "string",
+ "description": "Titel der Aufgabe. Pflicht bei task_create."
+ },
+ "body": {
+ "type": "string",
+ "description": "Beschreibung/Auftrag — geht als Aufgabenstellung an den ausführenden Agenten."
+ },
+ "assignee": {
+ "type": "string",
+ "description": "@new | @ | @human. Standard @human."
+ },
+ "status": {
+ "type": "string",
+ "enum": ["backlog", "todo", "in_progress", "in_review", "done", "canceled", "blocked"],
+ "description": "Neuer Status (task_update). 'blocked' meldet einen Blocker."
+ },
+ "type": {
+ "type": "string",
+ "enum": ["work", "approval", "human_input"],
+ "description": "Art der Aufgabe. Standard 'work'."
+ },
+ "priority": {
+ "type": "integer",
+ "description": "1 (niedrig) bis 5 (hoch). Standard 3."
+ },
+ "when": {
+ "type": "object",
+ "properties": {
+ "kind": { "type": "string", "enum": ["at", "every", "cron"] },
+ "value": { "type": "string", "description": "Zeitpunkt (ISO), Intervall (z.B. '30m') oder Cron-Ausdruck." },
+ "tz": { "type": "string", "description": "IANA-Zeitzone, z.B. 'Europe/Berlin'. PFLICHT." }
+ },
+ "description": "Termin. Fehlt er, ist die Aufgabe einmalig und sofort fällig."
+ },
+ "require_approval": {
+ "type": "boolean",
+ "description": "Wenn true, gilt die Aufgabe erst nach Review als erledigt."
+ },
+ "acceptance": {
+ "type": "string",
+ "description": "Abnahmekriterien, gegen die das Ergebnis geprüft wird."
+ },
+ "blocked_by": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Ids der Aufgaben, die erst erledigt sein müssen."
+ },
+ "onlyWhenMarketOpen": {
+ "type": "boolean",
+ "description": "Termin nur auslösen, wenn der Markt offen ist."
+ },
+ "text": {
+ "type": "string",
+ "description": "Kommentartext (task_comment) — Ergebnis oder Kritik."
+ },
+ "reopen": {
+ "type": "boolean",
+ "description": "task_comment: die Aufgabe wieder auf 'todo' setzen, damit sie erneut läuft."
+ },
+ "search": {
+ "type": "string",
+ "description": "Freitextsuche über Titel und Beschreibung (task_list)."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximale Trefferzahl bei task_list. Standard 50."
+ }
+ },
+ "required": ["action"]
+ }
+ """).RootElement.Clone();
+
+ public async Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
+ {
+ if (context.Tasks is not { } repository)
+ return ToolResult.Fail("Das Taskboard ist für diese Instanz nicht verfügbar.");
+
+ if (string.IsNullOrWhiteSpace(context.SharedWorkspacePath))
+ return ToolResult.Fail("Kein geteilter Arbeitsbereich konfiguriert — das Taskboard braucht ihn.");
+
+ var tasksDir = Path.Combine(context.SharedWorkspacePath, "tasks");
+ var board = new TaskboardService(repository, tasksDir);
+
+ var action = GetString(input, "action");
+ if (string.IsNullOrWhiteSpace(action))
+ return ToolResult.Fail("'action' ist erforderlich.");
+
+ try
+ {
+ return action switch
+ {
+ "task_create" => await CreateAsync(input, board, ct),
+ "task_list" => await ListAsync(input, board, ct),
+ "task_update" => await UpdateAsync(input, board, ct),
+ "task_comment" => await CommentAsync(input, board, context.AgentId, ct),
+ _ => ToolResult.Fail($"Unbekannte Aktion: {action}")
+ };
+ }
+ catch (Exception ex)
+ {
+ context.Logger.LogError(ex, "Fehler im Taskboard-Tool bei Aktion {Action}", action);
+ return ToolResult.Fail($"Fehler: {ex.Message}");
+ }
+ }
+
+ // ─── Aktionen ───
+
+ private static async Task CreateAsync(
+ JsonElement input, TaskboardService board, CancellationToken ct)
+ {
+ var title = GetString(input, "title");
+ if (string.IsNullOrWhiteSpace(title))
+ return ToolResult.Fail("'title' ist erforderlich.");
+
+ if (!TryReadWhen(input, out var when, out var whenError))
+ return ToolResult.Fail(whenError!);
+
+ var definition = new TaskItem
+ {
+ Title = title.Trim(),
+ Body = GetString(input, "body")?.Trim() ?? "",
+ Status = TaskItemStatus.Todo,
+ Type = TaskText.Type(GetString(input, "type")),
+ Priority = GetInt(input, "priority") ?? 3,
+ Assignee = GetString(input, "assignee")?.Trim() is { Length: > 0 } a ? a : TaskAssignee.Human,
+ When = when,
+ RequireApproval = GetBool(input, "require_approval") ?? false,
+ Acceptance = GetString(input, "acceptance")?.Trim() ?? "",
+ BlockedBy = GetStringList(input, "blocked_by"),
+ OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false
+ };
+
+ var created = await board.CreateAsync(definition, ct);
+ return ToolResult.Ok(
+ $"Aufgabe angelegt: {created.Id} — {created.Title}\n" +
+ $"Status: {TaskText.Of(created.Status)} | Assignee: {created.Assignee} | " +
+ $"Priorität: {created.Priority}{FormatWhen(created.When)}");
+ }
+
+ private static async Task ListAsync(
+ JsonElement input, TaskboardService board, CancellationToken ct)
+ {
+ var query = new TaskQuery
+ {
+ Status = ParseStatusFilter(GetString(input, "status")),
+ Assignee = GetString(input, "assignee"),
+ Search = GetString(input, "search"),
+ Limit = GetInt(input, "limit") ?? 50
+ };
+
+ var tasks = await board.ListAsync(query, ct);
+ if (tasks.Count == 0)
+ return ToolResult.Ok("Keine passenden Aufgaben.");
+
+ return ToolResult.Ok(Render(tasks));
+ }
+
+ private static async Task UpdateAsync(
+ JsonElement input, TaskboardService board, CancellationToken ct)
+ {
+ var id = GetString(input, "id");
+ if (string.IsNullOrWhiteSpace(id))
+ return ToolResult.Fail("'id' ist erforderlich.");
+
+ if (!TryReadWhen(input, out var when, out var whenError))
+ return ToolResult.Fail(whenError!);
+ var hasWhen = input.TryGetProperty("when", out _);
+
+ var result = await board.UpdateAsync(id.Trim(), current => current with
+ {
+ Title = GetString(input, "title")?.Trim() ?? current.Title,
+ Body = GetString(input, "body")?.Trim() ?? current.Body,
+ Status = ParseStatusFilter(GetString(input, "status")) ?? current.Status,
+ Type = input.TryGetProperty("type", out _) ? TaskText.Type(GetString(input, "type")) : current.Type,
+ Priority = GetInt(input, "priority") ?? current.Priority,
+ Assignee = GetString(input, "assignee")?.Trim() ?? current.Assignee,
+ When = hasWhen ? when : current.When,
+ RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval,
+ Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
+ BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy,
+ OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen
+ }, ct);
+
+ if (result is null)
+ return ToolResult.Fail($"Keine Aufgabe mit der Id '{id}' gefunden.");
+
+ return ToolResult.Ok(
+ $"Aufgabe {result.Id} aktualisiert.\n" +
+ $"Status: {TaskText.Of(result.Status)} | Assignee: {result.Assignee} | Priorität: {result.Priority}");
+ }
+
+ private static async Task CommentAsync(
+ JsonElement input, TaskboardService board, string author, CancellationToken ct)
+ {
+ var id = GetString(input, "id");
+ if (string.IsNullOrWhiteSpace(id))
+ return ToolResult.Fail("'id' ist erforderlich.");
+
+ var text = GetString(input, "text");
+ if (string.IsNullOrWhiteSpace(text))
+ return ToolResult.Fail("'text' ist erforderlich — was soll kommentiert werden?");
+
+ var result = await board.AddCommentAsync(id.Trim(), author, text.Trim(), ct);
+ if (result is null)
+ return ToolResult.Fail($"Keine Aufgabe mit der Id '{id}' gefunden.");
+
+ if (GetBool(input, "reopen") == true && result.Status != TaskItemStatus.Todo)
+ {
+ result = await board.UpdateAsync(id.Trim(), c => c with { Status = TaskItemStatus.Todo }, ct);
+ return ToolResult.Ok($"Kommentar angehängt und Aufgabe {id} wieder geöffnet (todo).");
+ }
+
+ return ToolResult.Ok($"Kommentar an Aufgabe {id} angehängt.");
+ }
+
+ // ─── Darstellung ───
+
+ private static string Render(IReadOnlyList tasks)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"{tasks.Count} Aufgabe(n), wichtigste zuerst:");
+ sb.AppendLine();
+
+ var shown = 0;
+ foreach (var task in tasks)
+ {
+ var block = new StringBuilder();
+ block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
+ block.AppendLine();
+ block.AppendLine($" Assignee: {task.Assignee} | Priorität: {task.Priority}{FormatWhen(task.When)}");
+ if (task.BlockedBy.Count > 0)
+ block.AppendLine($" Wartet auf: {string.Join(", ", task.BlockedBy)}");
+
+ if (sb.Length + block.Length > MaxResultChars)
+ {
+ sb.AppendLine($"[… {tasks.Count - shown} weitere ausgelassen. Grenze die Suche ein.]");
+ break;
+ }
+
+ sb.Append(block);
+ shown++;
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+
+ private static string FormatWhen(TaskWhen? when)
+ => when is null ? "" : $" | Termin: {TaskText.Of(when.Kind)} {when.Value} ({when.TimeZone})";
+
+ // ─── Eingabe lesen ───
+
+ private static bool TryReadWhen(JsonElement input, out TaskWhen? when, out string? error)
+ {
+ when = null;
+ error = null;
+
+ if (!input.TryGetProperty("when", out var w) || w.ValueKind != JsonValueKind.Object)
+ return true; // kein Termin ist gültig (einmalige Aufgabe)
+
+ var kindText = w.TryGetProperty("kind", out var k) ? k.GetString() : null;
+ var kind = TaskText.WhenKind(kindText);
+ if (kind is null)
+ {
+ error = "'when.kind' muss 'at', 'every' oder 'cron' sein.";
+ return false;
+ }
+
+ var value = w.TryGetProperty("value", out var v) ? v.GetString()?.Trim() : null;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ error = "'when.value' ist erforderlich (Zeitpunkt, Intervall oder Cron-Ausdruck).";
+ return false;
+ }
+
+ var tz = w.TryGetProperty("tz", out var t) ? t.GetString()?.Trim() : null;
+ if (string.IsNullOrWhiteSpace(tz))
+ {
+ error = "'when.tz' ist erforderlich — ein Termin ohne Zeitzone ist mehrdeutig (z.B. 'Europe/Berlin').";
+ return false;
+ }
+
+ when = new TaskWhen { Kind = kind.Value, Value = value, TimeZone = tz };
+ return true;
+ }
+
+ private static TaskItemStatus? ParseStatusFilter(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : TaskText.Status(value);
+
+ private static string? GetString(JsonElement input, string name)
+ => input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ private static int? GetInt(JsonElement input, string name)
+ => input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
+ ? value.GetInt32()
+ : null;
+
+ private static bool? GetBool(JsonElement input, string name)
+ => input.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? value.GetBoolean()
+ : null;
+
+ private static IReadOnlyList GetStringList(JsonElement input, string name)
+ {
+ if (!input.TryGetProperty(name, out var arr) || arr.ValueKind != JsonValueKind.Array)
+ return [];
+
+ return arr.EnumerateArray()
+ .Where(e => e.ValueKind == JsonValueKind.String)
+ .Select(e => e.GetString()!)
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Select(s => s.Trim())
+ .ToList();
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs
new file mode 100644
index 0000000..629501c
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs
@@ -0,0 +1,127 @@
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Storage;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Audit;
+
+///
+/// A3: das append-only Audit-Log und die Receipts. Gegen echte SQLite, weil Schema und
+/// Persistenz der Punkt sind.
+///
+public sealed class AuditRepositoryTests : IDisposable
+{
+ private readonly string _directory;
+ private readonly SqliteStorage _storage;
+ private readonly SqliteAuditRepository _repo;
+
+ public AuditRepositoryTests()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
+ _storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
+ _repo = new SqliteAuditRepository(_storage);
+ }
+
+ public void Dispose()
+ {
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ try { Directory.Delete(_directory, recursive: true); }
+ catch { /* Aufräumen ist Nebensache */ }
+ }
+
+ private AuditEntry Entry(string runId, string tool, AuditStatus status = AuditStatus.Ok) => new()
+ {
+ RunId = runId,
+ AgentId = "agent-a",
+ Model = "test/model",
+ Source = "task",
+ Tool = tool,
+ Arguments = "{\"x\":1}",
+ Status = status,
+ Summary = "",
+ DurationMs = 5,
+ OccurredAt = DateTime.UtcNow
+ };
+
+ [Fact]
+ public async Task Ein_Eintrag_wird_geschrieben_und_wiedergefunden()
+ {
+ await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
+
+ var recent = await _repo.ListRecentAsync(10, default);
+ recent.Count.ShouldBe(1);
+ recent[0].Tool.ShouldBe("FileRW");
+ recent[0].RunId.ShouldBe("run-1");
+ (await _repo.CountAsync(default)).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Die_Aufrufe_eines_Laufs_kommen_in_Reihenfolge()
+ {
+ await _repo.AppendAsync(Entry("run-1", "Memory"), default);
+ await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
+ await _repo.AppendAsync(Entry("run-2", "Mail"), default);
+
+ var forRun = await _repo.ListForRunAsync("run-1", default);
+ forRun.Select(e => e.Tool).ShouldBe(["Memory", "FileRW"]);
+ }
+
+ [Fact]
+ public async Task Die_juengsten_Eintraege_kommen_zuerst()
+ {
+ await _repo.AppendAsync(Entry("run-1", "erst"), default);
+ await _repo.AppendAsync(Entry("run-1", "zuletzt"), default);
+
+ (await _repo.ListRecentAsync(10, default))[0].Tool.ShouldBe("zuletzt");
+ }
+
+ [Fact]
+ public async Task Der_Status_ueberlebt_den_Rundlauf()
+ {
+ await _repo.AppendAsync(Entry("r", "X", AuditStatus.Denied), default);
+ (await _repo.ListRecentAsync(1, default))[0].Status.ShouldBe(AuditStatus.Denied);
+ }
+
+ // ─── Receipts ───
+
+ [Fact]
+ public async Task Ein_Receipt_verknuepft_Lauf_und_Task()
+ {
+ await _repo.RecordReceiptAsync(new RunReceipt
+ {
+ RunId = "run-9",
+ AgentId = "agent-a",
+ Model = "test/model",
+ Source = "task",
+ TaskId = "t-42",
+ Status = "Completed",
+ StepCount = 3,
+ PromptTokens = 100,
+ CompletionTokens = 20,
+ CachedTokens = 10,
+ CostUsd = 0.0123m,
+ CostIsKnown = true,
+ DurationMs = 1200,
+ ResultRef = "fertig",
+ OccurredAt = DateTime.UtcNow
+ }, default);
+
+ var byRun = await _repo.GetReceiptForRunAsync("run-9", default);
+ byRun.ShouldNotBeNull();
+ byRun!.TaskId.ShouldBe("t-42");
+ byRun.CostUsd.ShouldBe(0.0123m);
+ byRun.CostIsKnown.ShouldBeTrue();
+
+ var byTask = await _repo.ListReceiptsForTaskAsync("t-42", default);
+ byTask.Count.ShouldBe(1);
+ byTask[0].RunId.ShouldBe("run-9");
+ }
+
+ [Fact]
+ public async Task Eintraege_ueberdauern_das_Schliessen_der_Datenbank()
+ {
+ await _repo.AppendAsync(Entry("run-1", "FileRW"), default);
+
+ var reopened = new SqliteAuditRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
+ (await reopened.CountAsync(default)).ShouldBe(1);
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs b/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs
new file mode 100644
index 0000000..4fd1aeb
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs
@@ -0,0 +1,125 @@
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Tests.Infrastructure;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Audit;
+
+///
+/// A3 an der Engine: Jeder Tool-Aufruf wird protokolliert, jeder Lauf bekommt einen Beleg.
+/// Entscheidend ist die Provenienz — die Engine stempelt Agent, Modell und Herkunft aus
+/// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis.
+///
+public sealed class EngineAuditTests
+{
+ [Fact]
+ public async Task Jeder_Tool_Aufruf_landet_im_Audit_Log()
+ {
+ var audit = new InMemoryAuditRepository();
+ var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
+ var agent = fixture.AddAgent("agent-a", "TestTool");
+
+ // Zwei Tool-Aufrufe, dann Text.
+ fixture.Client
+ .RespondsWithToolCall("TestTool")
+ .RespondsWithToolCall("TestTool")
+ .RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+
+ var entries = audit.Entries.ToList();
+ entries.Count.ShouldBe(2);
+ entries.ShouldAllBe(e => e.Tool == "TestTool");
+ entries.ShouldAllBe(e => e.Status == AuditStatus.Ok);
+ }
+
+ [Fact]
+ public async Task Die_Herkunft_wird_von_der_Engine_gestempelt()
+ {
+ var audit = new InMemoryAuditRepository();
+ var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
+ var agent = fixture.AddAgent("agent-a", "TestTool");
+ fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7");
+
+ var entry = audit.Entries.Single();
+ entry.AgentId.ShouldBe("agent-a");
+ entry.Model.ShouldBe("test/model");
+ entry.Source.ShouldBe("task");
+ entry.RunId.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task Ein_verweigerter_Aufruf_wird_als_denied_protokolliert()
+ {
+ var audit = new InMemoryAuditRepository();
+ // Tool ist registriert, aber dem Agenten NICHT zugewiesen → PermissionGate greift.
+ var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
+ var agent = fixture.AddAgent("agent-a" /* kein Tool zugewiesen */);
+ fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+
+ audit.Entries.Single().Status.ShouldBe(AuditStatus.Denied);
+ }
+
+ [Fact]
+ public async Task Ein_Tool_Fehler_wird_als_error_protokolliert()
+ {
+ var audit = new InMemoryAuditRepository();
+ var fixture = new EngineFixture(audit)
+ .WithTool(FakeTool.Throwing(new InvalidOperationException("kaputt")));
+ var agent = fixture.AddAgent("agent-a", "TestTool");
+ fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+
+ var entry = audit.Entries.Single();
+ entry.Status.ShouldBe(AuditStatus.Error);
+ entry.Summary.ShouldContain("kaputt");
+ }
+
+ [Fact]
+ public async Task Ein_Lauf_bekommt_einen_Receipt_mit_Task_Verknuepfung()
+ {
+ var audit = new InMemoryAuditRepository();
+ var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
+ var agent = fixture.AddAgent("agent-a", "TestTool");
+ fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("erledigt");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-42");
+
+ var receipt = audit.Receipts.Single();
+ receipt.TaskId.ShouldBe("t-42");
+ receipt.Source.ShouldBe("task");
+ receipt.Status.ShouldBe("Completed");
+ receipt.ResultRef.ShouldBe("erledigt");
+ receipt.StepCount.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task Receipt_und_Audit_teilen_dieselbe_RunId()
+ {
+ var audit = new InMemoryAuditRepository();
+ var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
+ var agent = fixture.AddAgent("agent-a", "TestTool");
+ fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+
+ var runId = audit.Receipts.Single().RunId;
+ audit.Entries.Single().RunId.ShouldBe(runId, "alle Aufrufe eines Laufs tragen dieselbe RunId");
+ }
+
+ [Fact]
+ public async Task Ohne_Audit_Repository_laeuft_alles_normal_weiter()
+ {
+ // Gegenprobe: A3 ist optional — die Engine darf ohne Audit-Repo nicht anders laufen.
+ var fixture = new EngineFixture().WithTool(FakeTool.Returning("ok"));
+ var agent = fixture.AddAgent("agent-a", "TestTool");
+ fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig");
+
+ var result = await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+ result.Status.ToString().ShouldBe("Completed");
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs
index 7d70689..9ef214b 100644
--- a/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs
+++ b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs
@@ -4,6 +4,7 @@ using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Backup;
@@ -11,7 +12,11 @@ namespace ClawdDotNet.Core.Tests.Backup;
///
/// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung.
/// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf.
+///
+/// Läuft in , weil das Umschreiben der Geheimnisse
+/// beim Sichern denselben statischen Schlüssel benutzt wie die Geheimnistests.
///
+[Collection(SecretKeyCollection.Name)]
public sealed class BackupServiceTests : IDisposable
{
private readonly string _root;
diff --git a/tests/ClawdDotNet.Core.Tests/Deploymentcenter/CapturingHandler.cs b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/CapturingHandler.cs
new file mode 100644
index 0000000..ed13d79
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/CapturingHandler.cs
@@ -0,0 +1,32 @@
+using System.Net;
+using System.Text;
+
+namespace ClawdDotNet.Core.Tests.Deploymentcenter;
+
+/// Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.
+internal sealed class CapturingHandler(
+ HttpStatusCode status = HttpStatusCode.OK,
+ string responseBody = """{"status":"success"}""")
+ : HttpMessageHandler
+{
+ public HttpRequestMessage? LastRequest { get; private set; }
+
+ public string? LastBody { get; private set; }
+
+ public int Calls { get; private set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Calls++;
+ LastRequest = request;
+
+ if (request.Content is not null)
+ LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
+
+ return new HttpResponseMessage(status)
+ {
+ Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
+ };
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Deploymentcenter/DeploymentcenterApiTests.cs b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/DeploymentcenterApiTests.cs
new file mode 100644
index 0000000..3497201
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/DeploymentcenterApiTests.cs
@@ -0,0 +1,69 @@
+using System.Net;
+using ClawdDotNet.Core.Deploymentcenter;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Deploymentcenter;
+
+public sealed class DeploymentcenterApiTests
+{
+ [Fact]
+ public async Task Token_geht_als_Bearer_mit()
+ {
+ var handler = new CapturingHandler();
+ using var api = new DeploymentcenterApi("https://dc.example", "tok-1", new HttpClient(handler));
+
+ await api.PostAsync("/api/health", new { }, default);
+
+ handler.LastRequest!.Headers.Authorization!.ToString().ShouldBe("Bearer tok-1");
+ }
+
+ [Fact]
+ public async Task Fehlerumschlag_wird_zu_einer_Ausnahme_mit_Code()
+ {
+ var handler = new CapturingHandler(HttpStatusCode.Unauthorized,
+ """{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}""");
+
+ using var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
+
+ var ex = await Should.ThrowAsync(
+ () => api.PostAsync("/api/watchdog/v1/ping", new { }, default));
+
+ ex.Code.ShouldBe("unauthorized");
+ ex.IsAuthorizationProblem.ShouldBeTrue();
+ }
+
+ ///
+ /// Ein Fehlerumschlag zählt auch dann, wenn der Statuscode 200 lautet — der Server
+ /// antwortet nicht überall mit passendem HTTP-Code.
+ ///
+ [Fact]
+ public async Task Fehlerumschlag_mit_HTTP_200_zaehlt_trotzdem()
+ {
+ var handler = new CapturingHandler(HttpStatusCode.OK,
+ """{"status":"error","error":{"code":"rate_limited","message":"Zu viele."}}""");
+
+ using var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
+
+ var ex = await Should.ThrowAsync(
+ () => api.PostAsync("/x", new { }, default));
+
+ ex.Code.ShouldBe("rate_limited");
+ ex.IsAuthorizationProblem.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Nicht_HTTPS_wird_abgelehnt()
+ {
+ // Über eine ungesicherte Verbindung ginge das Token im Klartext.
+ Should.Throw(() => new DeploymentcenterApi("http://dc.example", "tok"));
+ }
+
+ [Fact]
+ public void Localhost_darf_auch_ohne_TLS()
+ {
+ // Eine lokale Testinstallation hat selten ein Zertifikat, und mithören kann
+ // auf dem eigenen Rechner niemand.
+ using var api = new DeploymentcenterApi("http://localhost:8080", "tok");
+ api.BaseUrl.ShouldBe("http://localhost:8080");
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Deploymentcenter/ErrorReporterTests.cs b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/ErrorReporterTests.cs
new file mode 100644
index 0000000..ef791ca
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/ErrorReporterTests.cs
@@ -0,0 +1,97 @@
+using System.Net;
+using ClawdDotNet.Core.Deploymentcenter;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Deploymentcenter;
+
+public sealed class ErrorReporterTests
+{
+ private static (ErrorReporter Reporter, CapturingHandler Handler) Build(
+ Func? now = null,
+ HttpStatusCode status = HttpStatusCode.Created)
+ {
+ var handler = new CapturingHandler(status,
+ """{"status":"success","item_id":7,"is_new":true,"occurrence_count":1}""");
+
+ var api = new DeploymentcenterApi("https://dc.example", "tok", new HttpClient(handler));
+ var reporter = new ErrorReporter(
+ api, "clawddotnet", "production", "0.1.0", NullLogger.Instance, ownsApi: true, now: now);
+
+ return (reporter, handler);
+ }
+
+ private static Exception Thrown(string message)
+ {
+ try { throw new InvalidOperationException(message); }
+ catch (Exception ex) { return ex; }
+ }
+
+ [Fact]
+ public async Task Meldung_geht_an_den_Fehler_Endpunkt()
+ {
+ var (reporter, handler) = Build();
+ using var _ = reporter;
+
+ var sent = await reporter.ReportAsync(Thrown("Kaputt"), fatal: true);
+
+ sent.ShouldBeTrue();
+ handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/errors/v1/report");
+ handler.LastBody!.ShouldContain("\"exception\":\"System.InvalidOperationException\"");
+ handler.LastBody.ShouldContain("\"message\":\"Kaputt\"");
+ handler.LastBody.ShouldContain("\"level\":\"fatal\"");
+ handler.LastBody.ShouldContain("\"project_slug\":\"clawddotnet\"");
+ }
+
+ ///
+ /// Eine Fehlerschleife darf nicht in tausend Anfragen münden. Derselbe Fehler geht
+ /// höchstens einmal je Zeitfenster raus — der Server drosselt zwar auch, aber erst,
+ /// nachdem die Anfragen schon über die Leitung waren.
+ ///
+ [Fact]
+ public async Task Derselbe_Fehler_geht_nur_einmal_je_Zeitfenster_raus()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var (reporter, handler) = Build(() => now);
+ using var _ = reporter;
+
+ var error = Thrown("Immer wieder");
+
+ (await reporter.ReportAsync(error)).ShouldBeTrue();
+ (await reporter.ReportAsync(error)).ShouldBeFalse();
+ (await reporter.ReportAsync(error)).ShouldBeFalse();
+
+ handler.Calls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Nach_dem_Zeitfenster_wird_wieder_gemeldet()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var (reporter, handler) = Build(() => now);
+ using var _ = reporter;
+
+ var error = Thrown("Immer wieder");
+
+ await reporter.ReportAsync(error);
+ now = now.AddMinutes(6);
+ await reporter.ReportAsync(error);
+
+ handler.Calls.ShouldBe(2);
+ }
+
+ ///
+ /// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten — der
+ /// ursprüngliche Fehler ginge dabei verloren.
+ ///
+ [Fact]
+ public async Task Ein_abgelehnter_Aufruf_wirft_nicht_nach_aussen()
+ {
+ var (reporter, _) = Build(status: HttpStatusCode.Unauthorized);
+ using var __ = reporter;
+
+ var sent = await reporter.ReportAsync(Thrown("Egal"));
+
+ sent.ShouldBeFalse();
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Deploymentcenter/InstanceHealthProviderTests.cs b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/InstanceHealthProviderTests.cs
new file mode 100644
index 0000000..0416013
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/InstanceHealthProviderTests.cs
@@ -0,0 +1,107 @@
+using ClawdDotNet.Core.Accounting;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Deploymentcenter.Watchdog;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Deploymentcenter;
+
+public sealed class InstanceHealthProviderTests
+{
+ /// Liefert einen festen Tagesverbrauch, ohne DB.
+ private sealed class FakeUsage(DailyUsage daily) : IUsageRepository
+ {
+ public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
+
+ public Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
+ => Task.FromResult(daily);
+
+ public Task> GetRecentAsync(int limit, CancellationToken ct)
+ => Task.FromResult((IReadOnlyList)Array.Empty());
+
+ public Task PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
+ }
+
+ private static DailyUsage Usage(decimal cost, int tokens = 0) =>
+ new(DateOnly.FromDateTime(DateTime.Now), tokens, 0, cost, true, 1);
+
+ [Fact]
+ public async Task Ohne_API_Key_meldet_error()
+ {
+ var provider = new InstanceHealthProvider(
+ "Test", agentsEnabled: false, InstanceBudget.Unlimited,
+ usage: null, () => 2, () => 0);
+
+ var health = await provider.GetAsync(default);
+
+ health.Status.ShouldBe(WatchdogStatus.Error);
+ health.Metrics["agentCount"].ShouldBe(2);
+ health.Checks["agents"].Ok.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Erschoepftes_Kostenbudget_meldet_warning()
+ {
+ var budget = new InstanceBudget { DailyCostUsd = 5m };
+ var provider = new InstanceHealthProvider(
+ "Test", agentsEnabled: true, budget,
+ new FakeUsage(Usage(cost: 5m)), () => 1, () => 0);
+
+ var health = await provider.GetAsync(default);
+
+ health.Status.ShouldBe(WatchdogStatus.Warning);
+ health.Checks["budget"].Ok.ShouldBeFalse();
+ health.Message.ShouldNotBeNull();
+ }
+
+ ///
+ /// Ein Heartbeat beweist nur, dass ein Faden läuft. Steht der Scanner, arbeitet die
+ /// Instanz nichts mehr ab — und genau das soll die Prüfung sichtbar machen.
+ ///
+ [Fact]
+ public async Task Stehender_Scanner_meldet_warning()
+ {
+ var provider = new InstanceHealthProvider(
+ "Test", agentsEnabled: true, InstanceBudget.Unlimited,
+ new FakeUsage(Usage(cost: 0m)), () => 1, () => 0,
+ schedulerRunning: () => false);
+
+ var health = await provider.GetAsync(default);
+
+ health.Status.ShouldBe(WatchdogStatus.Warning);
+ health.Checks["scheduler"].Ok.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Normalbetrieb_meldet_ok_mit_Metriken()
+ {
+ var provider = new InstanceHealthProvider(
+ "Test", agentsEnabled: true, InstanceBudget.Unlimited,
+ new FakeUsage(Usage(cost: 0.5m, tokens: 1200)), () => 3, () => 1,
+ schedulerRunning: () => true);
+
+ var health = await provider.GetAsync(default);
+
+ health.Status.ShouldBe(WatchdogStatus.Ok);
+ health.Metrics["runningChats"].ShouldBe(1);
+ health.Metrics.Keys.ShouldContain("todayCostUsd");
+ health.Metrics.Keys.ShouldContain("todayTokens");
+ health.Checks.Values.ShouldAllBe(c => c.Ok);
+ }
+
+ ///
+ /// Metriken werden serverseitig nur ausgewertet, wenn sie Zahlen sind — alles andere
+ /// verwirft der Verlauf stillschweigend. Beschreibendes gehört in die Meldung.
+ ///
+ [Fact]
+ public async Task Instanzname_steht_in_der_Meldung_nicht_in_den_Metriken()
+ {
+ var provider = new InstanceHealthProvider(
+ "Produktion", agentsEnabled: true, InstanceBudget.Unlimited,
+ usage: null, () => 1, () => 0);
+
+ var health = await provider.GetAsync(default);
+
+ health.Message.ShouldContain("Produktion");
+ health.Metrics.Keys.ShouldNotContain("instanceName");
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Deploymentcenter/TokenProvisionerTests.cs b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/TokenProvisionerTests.cs
new file mode 100644
index 0000000..d9126cb
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/TokenProvisionerTests.cs
@@ -0,0 +1,50 @@
+using System.Net;
+using ClawdDotNet.Core.Deploymentcenter;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Deploymentcenter;
+
+public sealed class TokenProvisionerTests
+{
+ [Fact]
+ public async Task Instanz_bekommt_ein_eigenes_Sub_Token()
+ {
+ var handler = new CapturingHandler(HttpStatusCode.Created,
+ """
+ {"status":"success","sub_token":"dc_sub_abc","token_id":"tok_s_1",
+ "scopes":["watchdog:ping","bugtracker:report"]}
+ """);
+
+ using var api = new DeploymentcenterApi("https://dc.example", "master", new HttpClient(handler));
+
+ var token = await new TokenProvisioner(api).ProvisionAsync(
+ "ClawdDotNet Produktion", "inst-1", TokenProvisioner.InstanceScopes);
+
+ handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/tokens/v1/provision");
+ handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer master");
+ handler.LastBody!.ShouldContain("\"instance_id\":\"inst-1\"");
+ handler.LastBody.ShouldContain("watchdog:ping");
+
+ token.Token.ShouldBe("dc_sub_abc");
+ token.TokenId.ShouldBe("tok_s_1");
+ token.Scopes.ShouldContain("bugtracker:report");
+ }
+
+ ///
+ /// Ein Sub-Token darf keine weiteren ausstellen. Das ist der Regelfall beim zweiten
+ /// Start und muss als sauberer Fehler herauskommen, nicht als Absturz.
+ ///
+ [Fact]
+ public async Task Abgelehnte_Provisionierung_wirft_mit_Code()
+ {
+ var handler = new CapturingHandler(HttpStatusCode.Forbidden,
+ """{"status":"error","error":{"code":"provision_denied","message":"Kein Master-Token."}}""");
+
+ using var api = new DeploymentcenterApi("https://dc.example", "sub", new HttpClient(handler));
+
+ var ex = await Should.ThrowAsync(
+ () => new TokenProvisioner(api).ProvisionAsync("x", "inst-1", ["watchdog:ping"]));
+
+ ex.Code.ShouldBe("provision_denied");
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Deploymentcenter/WatchdogClientTests.cs b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/WatchdogClientTests.cs
new file mode 100644
index 0000000..dda5b71
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Deploymentcenter/WatchdogClientTests.cs
@@ -0,0 +1,137 @@
+using System.Net;
+using ClawdDotNet.Core.Deploymentcenter;
+using ClawdDotNet.Core.Deploymentcenter.Watchdog;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Deploymentcenter;
+
+public sealed class WatchdogClientTests
+{
+ private static WatchdogClient Client(HttpMessageHandler handler, string instance = "inst-1") =>
+ WatchdogClient.Create("https://dc.example", "tok", "clawddotnet", instance,
+ "ClawdDotNet", "Windows / .NET 10", "0.1.0", new HttpClient(handler));
+
+ private static InstanceHealth Health(
+ string status = WatchdogStatus.Ok,
+ string? message = null,
+ Dictionary? metrics = null,
+ Dictionary? checks = null)
+ => new(status, message, metrics ?? [], checks ?? []);
+
+ [Fact]
+ public async Task Heartbeat_geht_an_den_v1_Pfad_mit_Bearer_Token()
+ {
+ var handler = new CapturingHandler();
+
+ await Client(handler).SendHeartbeatAsync(Health(message: "Betrieb normal."), 60, default);
+
+ handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/ping");
+ handler.LastRequest.Method.ShouldBe(HttpMethod.Post);
+ handler.LastRequest.Headers.Authorization!.ToString().ShouldBe("Bearer tok");
+
+ handler.LastBody.ShouldNotBeNull();
+ handler.LastBody.ShouldContain("\"source\":\"clawddotnet\"");
+ handler.LastBody.ShouldContain("\"instance\":\"inst-1\"");
+ handler.LastBody.ShouldContain("\"type\":\"heartbeat\"");
+ handler.LastBody.ShouldContain("\"interval\":60");
+ }
+
+ ///
+ /// Der Server legt die Version in watchdog_monitors.app_version ab. Bei
+ /// mehreren Instanzen ist das der Unterschied zwischen „läuft" und „läuft noch auf
+ /// der alten Fassung".
+ ///
+ [Fact]
+ public async Task Heartbeat_traegt_die_Anwendungsversion()
+ {
+ var handler = new CapturingHandler();
+
+ await Client(handler).SendHeartbeatAsync(Health(), 60, default);
+
+ handler.LastBody!.ShouldContain("\"version\":\"0.1.0\"");
+ }
+
+ ///
+ /// Der Monitor wird serverseitig über source + instance geführt. Zwei Instanzen
+ /// derselben Anwendung müssen deshalb unterschiedliche instance-Werte melden —
+ /// sonst überschreiben sie sich gegenseitig, und der Ausfall einer bliebe unsichtbar.
+ ///
+ [Fact]
+ public async Task Jede_Instanz_meldet_unter_eigenem_Instanznamen()
+ {
+ var first = new CapturingHandler();
+ var second = new CapturingHandler();
+
+ await Client(first, "inst-a").SendHeartbeatAsync(Health(), 60, default);
+ await Client(second, "inst-b").SendHeartbeatAsync(Health(), 60, default);
+
+ first.LastBody!.ShouldContain("\"instance\":\"inst-a\"");
+ second.LastBody!.ShouldContain("\"instance\":\"inst-b\"");
+ }
+
+ [Fact]
+ public async Task Checks_und_Metriken_gehen_mit()
+ {
+ var handler = new CapturingHandler();
+
+ await Client(handler).SendHeartbeatAsync(
+ Health(
+ metrics: new Dictionary { ["agentCount"] = 3 },
+ checks: new Dictionary
+ {
+ ["scheduler"] = new(false, "Steht.")
+ }),
+ 60, default);
+
+ handler.LastBody!.ShouldContain("\"agentCount\":3");
+ handler.LastBody.ShouldContain("\"scheduler\":{\"ok\":false,\"message\":\"Steht.\"}");
+ }
+
+ /// Leere Angaben bleiben weg, statt als leeres Objekt zu erscheinen.
+ [Fact]
+ public async Task Ohne_Checks_und_Metriken_bleiben_die_Felder_weg()
+ {
+ var handler = new CapturingHandler();
+
+ await Client(handler).SendHeartbeatAsync(Health(), 60, default);
+
+ handler.LastBody!.ShouldNotContain("\"checks\"");
+ handler.LastBody.ShouldNotContain("\"metrics\"");
+ }
+
+ [Fact]
+ public async Task Antwort_liefert_Zustand_und_fehlgeschlagene_Pruefungen()
+ {
+ var handler = new CapturingHandler(HttpStatusCode.OK,
+ """{"status":"success","monitor":{"state":"warning","failing_checks":["scheduler"]}}""");
+
+ var result = await Client(handler).SendHeartbeatAsync(Health(), 60, default);
+
+ result.State.ShouldBe("warning");
+ result.FailingChecks.ShouldHaveSingleItem().ShouldBe("scheduler");
+ }
+
+ [Fact]
+ public async Task Ereignis_geht_an_den_Event_Endpunkt()
+ {
+ var handler = new CapturingHandler();
+
+ await Client(handler).SendEventAsync(
+ WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, default);
+
+ handler.LastRequest!.RequestUri!.ToString().ShouldBe("https://dc.example/api/watchdog/v1/event");
+ handler.LastBody!.ShouldContain("\"kind\":\"stopped_graceful\"");
+ }
+
+ [Fact]
+ public async Task Abgelehntes_Token_wirft_mit_Code()
+ {
+ var handler = new CapturingHandler(HttpStatusCode.Unauthorized,
+ """{"status":"error","error":{"code":"unauthorized","message":"Kein gueltiges Token."}}""");
+
+ var ex = await Should.ThrowAsync(
+ () => Client(handler).SendHeartbeatAsync(Health(), 60, default));
+
+ ex.Code.ShouldBe("unauthorized");
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Engine/RequestShapeTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/RequestShapeTests.cs
new file mode 100644
index 0000000..48e2556
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Engine/RequestShapeTests.cs
@@ -0,0 +1,38 @@
+using ClawdDotNet.Core.Tests.Infrastructure;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Engine;
+
+///
+/// Form des ausgehenden Requests. B11/T8: max_tokens muss gesetzt sein, damit ein
+/// Modell nicht in einem Schritt sein volles (teuerstes) Ausgabe-Limit ausschöpft.
+///
+public sealed class RequestShapeTests
+{
+ [Fact]
+ public async Task Der_Request_setzt_max_tokens()
+ {
+ var fixture = new EngineFixture();
+ var agent = fixture.AddAgent("agent-a");
+ agent.LoopGuard.MaxResponseTokens = 4_096;
+ fixture.Client.RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+
+ fixture.Client.ReceivedRequests.ShouldNotBeEmpty();
+ fixture.Client.ReceivedRequests[0].MaxTokens.ShouldBe(4_096);
+ }
+
+ [Fact]
+ public async Task Bei_Wert_0_bleibt_max_tokens_offen()
+ {
+ var fixture = new EngineFixture();
+ var agent = fixture.AddAgent("agent-a");
+ agent.LoopGuard.MaxResponseTokens = 0; // Anbieter-Standard
+ fixture.Client.RespondsWithText("fertig");
+
+ await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
+
+ fixture.Client.ReceivedRequests[0].MaxTokens.ShouldBeNull();
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
index 3a635d8..eb09c2f 100644
--- a/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
@@ -1,6 +1,8 @@
+using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Security;
+using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Infrastructure;
@@ -18,14 +20,19 @@ internal sealed class EngineFixture
private readonly List _agents = new();
- public EngineFixture()
+ /// Die registrierten Agenten — für Tests, die einen Dispatcher selbst bauen.
+ public IReadOnlyList Agents => _agents;
+
+ public EngineFixture(IAuditRepository? audit = null, StagingGate? staging = null)
{
Engine = new AgentEngine(
Client,
Registry,
new PermissionGate(),
StateStore,
- TestLogging.Factory);
+ TestLogging.Factory,
+ auditRepository: audit,
+ stagingGate: staging);
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTimeProvider.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTimeProvider.cs
new file mode 100644
index 0000000..2cd50ac
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTimeProvider.cs
@@ -0,0 +1,19 @@
+namespace ClawdDotNet.Core.Tests.Infrastructure;
+
+///
+/// Ein steuerbarer für die Zeit-abhängigen Tests (R2 der
+/// Teststrategie). Bewusst schlank: die Tests treiben den Scanner über
+/// ScanOnceAsync direkt, sie brauchen nur eine feste, verstellbare Uhr.
+///
+internal sealed class FakeTimeProvider : TimeProvider
+{
+ private DateTimeOffset _now;
+
+ public FakeTimeProvider(DateTimeOffset start) => _now = start;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void SetUtcNow(DateTimeOffset value) => _now = value;
+
+ public void Advance(TimeSpan delta) => _now += delta;
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs
new file mode 100644
index 0000000..48dc4e4
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs
@@ -0,0 +1,40 @@
+using System.Collections.Concurrent;
+using ClawdDotNet.Core.Audit;
+
+namespace ClawdDotNet.Core.Tests.Infrastructure;
+
+///
+/// In-Memory-Attrappe des Audit-Logs für Engine-Tests — hält Einträge und Belege in
+/// Listen, damit ein Test prüfen kann, was die Engine stempelt, ohne eine Datei.
+///
+internal sealed class InMemoryAuditRepository : IAuditRepository
+{
+ public ConcurrentQueue Entries { get; } = new();
+ public ConcurrentQueue Receipts { get; } = new();
+
+ public Task AppendAsync(AuditEntry entry, CancellationToken ct)
+ {
+ Entries.Enqueue(entry);
+ return Task.CompletedTask;
+ }
+
+ public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct)
+ {
+ Receipts.Enqueue(receipt);
+ return Task.CompletedTask;
+ }
+
+ public Task> ListRecentAsync(int limit, CancellationToken ct)
+ => Task.FromResult>(Entries.Reverse().Take(limit).ToList());
+
+ public Task> ListForRunAsync(string runId, CancellationToken ct)
+ => Task.FromResult>(Entries.Where(e => e.RunId == runId).ToList());
+
+ public Task GetReceiptForRunAsync(string runId, CancellationToken ct)
+ => Task.FromResult(Receipts.LastOrDefault(r => r.RunId == runId));
+
+ public Task> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
+ => Task.FromResult>(Receipts.Where(r => r.TaskId == taskId).ToList());
+
+ public Task CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryStagingRepository.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryStagingRepository.cs
new file mode 100644
index 0000000..e56af98
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryStagingRepository.cs
@@ -0,0 +1,52 @@
+using System.Collections.Concurrent;
+using ClawdDotNet.Core.Staging;
+
+namespace ClawdDotNet.Core.Tests.Infrastructure;
+
+/// In-Memory-Attrappe der Staging-Warteschlange für Engine-Tests.
+internal sealed class InMemoryStagingRepository : IStagingRepository
+{
+ private readonly ConcurrentDictionary _calls = new();
+ private long _nextId;
+ private readonly Lock _lock = new();
+
+ public Task AppendAsync(StagedCall call, CancellationToken ct)
+ {
+ var id = Interlocked.Increment(ref _nextId);
+ _calls[id] = call with { Id = id, Status = StagingStatus.Pending, CreatedAt = DateTime.UtcNow };
+ return Task.FromResult(id);
+ }
+
+ public Task GetAsync(long id, CancellationToken ct)
+ => Task.FromResult(_calls.GetValueOrDefault(id));
+
+ public Task