diff --git a/src/ClaudeDo.App/App.axaml.cs b/src/ClaudeDo.App/App.axaml.cs
index 5d7c6733..0d9667aa 100644
--- a/src/ClaudeDo.App/App.axaml.cs
+++ b/src/ClaudeDo.App/App.axaml.cs
@@ -52,6 +52,7 @@ public partial class App : Application
Dispatcher.UIThread.UnhandledException += (_, e) =>
{
e.Handled = true;
+ Serilog.Log.Error(e.Exception, "Unhandled exception on the UI dispatcher");
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
};
diff --git a/src/ClaudeDo.App/ClaudeDo.App.csproj b/src/ClaudeDo.App/ClaudeDo.App.csproj
index 1257d87c..e0b0481c 100644
--- a/src/ClaudeDo.App/ClaudeDo.App.csproj
+++ b/src/ClaudeDo.App/ClaudeDo.App.csproj
@@ -26,6 +26,8 @@
+
+
diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs
index 381207fc..88221107 100644
--- a/src/ClaudeDo.App/Program.cs
+++ b/src/ClaudeDo.App/Program.cs
@@ -13,6 +13,7 @@ using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+using Serilog;
using System;
using System.Globalization;
using System.IO;
@@ -20,6 +21,7 @@ using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
+using System.Threading.Tasks;
namespace ClaudeDo.App;
@@ -34,6 +36,35 @@ sealed class Program
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
+ var logRoot = Path.Combine(Paths.AppDataRoot(), "logs");
+ Directory.CreateDirectory(logRoot);
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .WriteTo.File(
+ Path.Combine(logRoot, "ui-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 7,
+ shared: true)
+ .CreateLogger();
+
+ var version = Assembly.GetEntryAssembly()?
+ .GetCustomAttribute()?.InformationalVersion
+ ?? "0.0.0";
+ Log.Information("ClaudeDo.App starting (version: {Version})", version);
+
+ // Last-resort net: any exception that misses every other handler still gets one
+ // Fatal line in the log before the process dies.
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ {
+ Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (IsTerminating: {IsTerminating})", e.IsTerminating);
+ Log.CloseAndFlush();
+ };
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ Log.Error(e.Exception, "Unobserved task exception");
+ e.SetObserved();
+ };
+
var services = BuildServices();
using (var scope = services.CreateScope())
@@ -62,6 +93,8 @@ sealed class Program
{
/* best effort on shutdown */
}
+
+ Log.CloseAndFlush();
}
}
diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
index 4b4dc211..9de87d15 100644
--- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
+++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index 7943e390..51e36120 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -304,7 +304,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
ok = true;
return result;
}
- catch { return default; }
+ catch (Exception ex)
+ {
+ Serilog.Log.Warning(ex, "Hub call {Method} failed", method);
+ return default;
+ }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
@@ -318,6 +322,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await invoke();
ok = true;
}
+ catch (Exception ex)
+ {
+ Serilog.Log.Warning(ex, "Hub call {Method} failed", method);
+ throw;
+ }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
@@ -332,6 +341,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
ok = true;
return result;
}
+ catch (Exception ex)
+ {
+ Serilog.Log.Warning(ex, "Hub call {Method} failed", method);
+ throw;
+ }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index 8ee08b02..6a4c9ac4 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -208,6 +208,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void FlashFooter(string message, WorkerLogLevel level)
{
+ if (level == WorkerLogLevel.Error)
+ Serilog.Log.Warning("Footer error: {Message}", message);
+
WorkerLogText = $"{DateTime.Now:HH:mm} ยท {message}";
WorkerLogLevel = level;
IsWorkerLogVisible = true;
diff --git a/src/ClaudeDo.Worker/Hub/HubExceptionLoggingFilter.cs b/src/ClaudeDo.Worker/Hub/HubExceptionLoggingFilter.cs
new file mode 100644
index 00000000..0c70ddc8
--- /dev/null
+++ b/src/ClaudeDo.Worker/Hub/HubExceptionLoggingFilter.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+
+namespace ClaudeDo.Worker.Hub;
+
+///
+/// Logs a Warning for any exception thrown out of a hub method body, so a failed UI action
+/// leaves a trace in worker-.log instead of only a generic error at the caller.
+/// Does NOT see an argument-binding failure (e.g. a stray CancellationToken parameter) โ SignalR
+/// rejects those while parsing the invocation message, before a hub method is ever dispatched.
+///
+public sealed class HubExceptionLoggingFilter(ILogger logger) : IHubFilter
+{
+ public async ValueTask