using System.Threading; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Agents; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Online; using ClaudeDo.Worker.Online.Interfaces; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using Serilog; // Single-instance per user session. Multiple launch paths exist (logon task, // app ensure-running, Restart button); a second instance exits cleanly instead // of fighting over the SignalR port. var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew); if (!createdNew) return; // another instance already owns the port; exit 0 var cfg = WorkerConfig.Load(); var builder = WebApplication.CreateBuilder(args); var logRoot = cfg.LogRoot; Directory.CreateDirectory(logRoot); builder.Host.UseSerilog((ctx, lc) => lc .MinimumLevel.Information() .WriteTo.File( System.IO.Path.Combine(logRoot, "worker-.log"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7, shared: true)); builder.Services.AddDbContextFactory(opt => opt.UseSqlite($"Data Source={cfg.DbPath}")); builder.Services.AddSingleton(cfg); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddSignalR().AddJsonProtocol(options => { options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); }); // Runner stack. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Queue dispatch primitives. QueueWaker holds the wake semaphore; the queue picker // performs atomic Queued→Running claim. Both injected into the state service so it // can wake the dispatcher without depending on QueueService directly. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton>(sp => () => sp.GetRequiredService()); builder.Services.AddSingleton(sp => new TaskStateService( sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>())); // Agent file management. var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents"); Directory.CreateDirectory(agentsDir); builder.Services.AddSingleton(new AgentFileService(agentsDir)); var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents"); builder.Services.AddSingleton(sp => new DefaultAgentSeeder( defaultAgentsBundleDir, agentsDir, sp.GetService>())); // Override slot owns RunNow / ContinueTask. Queue slot is the BackgroundService. builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new ClaudeHistoryReader(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects"))); builder.Services.AddSingleton(); // Prime Claude builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(PrimeSchedulerOptions.Default); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); // Refine builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // QueueService: singleton + hosted service (same instance). builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Planning session services. var planningSessionsDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".todo-app", "planning-sessions"); builder.Services.AddSingleton(sp => new PlanningSessionManager( sp.GetRequiredService>(), sp.GetRequiredService(), cfg, sp.GetRequiredService(), sp.GetRequiredService(), planningSessionsDir)); builder.Services.AddHostedService(sp => new PlanningLineageRecovery( sp.GetRequiredService>(), planningSessionsDir, sp.GetRequiredService>())); builder.Services.AddSingleton(sp => new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin)); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddMcpServer() .WithHttpTransport() .WithTools() .WithTools(); // OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work // even when sync is disabled. The sync stack (api client, auth, hosted service) is // only registered when enabled. builder.Services.AddSingleton(cfg.OnlineInbox); #pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. builder.Services.AddSingleton(); #pragma warning restore CA1416 if (cfg.OnlineInbox.Enabled) { OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl); builder.Services.AddHttpClient(); #pragma warning disable CA1416 builder.Services.AddSingleton(); #pragma warning restore CA1416 builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/"); }); builder.Services.AddHostedService(); } // Loopback-only bind. Firewall is irrelevant for 127.0.0.1. builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { ClaudeDoDbContext.MigrateAndConfigure( scope.ServiceProvider.GetRequiredService()); } try { var seeder = app.Services.GetRequiredService(); var seedResult = await seeder.SeedMissingAsync(); app.Logger.LogInformation( "Default agents seeded: {Copied} copied, {Skipped} already present", seedResult.Copied, seedResult.Skipped); } catch (Exception ex) { app.Logger.LogWarning(ex, "Default agent seeding failed"); } app.UseMiddleware(); app.MapHub("/hub"); app.MapMcp("/mcp"); // Claude CLI preflight: fail fast if the configured binary is unreachable or non-zero. // Skippable via CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (e.g. tests). if (Environment.GetEnvironmentVariable("CLAUDEDO_SKIP_CLI_PREFLIGHT") != "1") { var preflight = await ClaudeCliPreflight.CheckAsync(cfg.ClaudeBin); if (!preflight.Ok) { app.Logger.LogCritical( "Claude CLI preflight failed (bin: '{Bin}', exit: {Exit}): {Error}. " + "Fix `claude_bin` in worker.config.json or set CLAUDEDO_SKIP_CLI_PREFLIGHT=1 to bypass.", cfg.ClaudeBin, preflight.ExitCode, preflight.Error); Environment.Exit(1); } app.Logger.LogInformation("Claude CLI preflight OK: {Version}", preflight.Version); } app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", cfg.SignalRPort, cfg.DbPath); // Build the external MCP endpoint as a separate WebApplication on its own port. // Rationale: ModelContextProtocol.AspNetCore registers one server per DI container, // so we need a second app to expose a different tool set under different auth. // Shared singletons (QueueService, HubBroadcaster, WorkerConfig, db factory) are // injected by instance so both apps operate on the same runtime state. WebApplication? externalApp = null; if (cfg.ExternalMcpPort > 0) { var externalBuilder = WebApplication.CreateBuilder(); externalBuilder.Services.AddSingleton(cfg); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddMcpServer() .WithHttpTransport() .WithTools() .WithTools() .WithTools() .WithTools() .WithTools() .WithTools() .WithTools(); externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); externalApp = externalBuilder.Build(); externalApp.UseMiddleware(); externalApp.MapMcp("/mcp"); externalApp.Logger.LogInformation( "ClaudeDo.Worker external MCP listening on http://127.0.0.1:{Port} (auth: {Auth})", cfg.ExternalMcpPort, string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key"); } if (externalApp is null) { await app.RunAsync(); } else { await Task.WhenAll(app.RunAsync(), externalApp.RunAsync()); } GC.KeepAlive(mutex);