# Worker Per-User Autostart Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart. **Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running. **Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`. **Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files. --- ## File Structure **Worker** - Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices. - Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`. **Installer** - Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder. - Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task. - Rename/rewrite `StopServiceStep.cs` → `StopWorkerStep.cs`, `StartServiceStep.cs` → `StartWorkerStep.cs`. - Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`. - Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios. - Modify `Core/InstallContext.cs` — drop `ServiceAccount`. - Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring. - Modify `App.xaml.cs` — DI registration. - Modify `Core/UninstallRunner.cs` — task delete + process kill. - Modify `Views/SettingsViewModel.cs` — use renamed steps. **App** - Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path. - Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running. - Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed. **Tests** - Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`. - Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`. --- ## Task 1: Worker → WinExe + Serilog packages **Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` - [ ] **Step 1:** In the main `` add `WinExe`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add: ```xml ``` - [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore). --- ## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService **Files:** Modify `src/ClaudeDo.Worker/Program.cs` - [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard: ```csharp using System.Threading; // 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 ``` - [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment). - [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging: ```csharp using Serilog; var logRoot = ClaudeDo.Data.Paths.Expand(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)); ``` (If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.) - [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected. - [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds. - [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this). --- ## Task 3: Scheduled-task XML builder (pure, TDD) **Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs` - [ ] **Step 1: Write the failing test:** ```csharp using ClaudeDo.Installer.Core; using Xunit; namespace ClaudeDo.Installer.Tests; public class ScheduledTaskXmlTests { [Fact] public void Build_EmbedsUserExeAndLogonTrigger() { var xml = ScheduledTaskXml.Build( userId: "MACHINE\\mika", workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", restartIntervalMinutes: 1); Assert.Contains("", xml); Assert.Contains("MACHINE\\mika", xml); Assert.Contains("InteractiveToken", xml); Assert.Contains("true", xml); Assert.Contains("LeastPrivilege", xml); Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml); Assert.Contains("PT1M", xml); } [Fact] public void Build_ClampsRestartIntervalToOneMinuteMinimum() { var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0); Assert.Contains("PT1M", xml); } } ``` - [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing). - [ ] **Step 3: Implement:** ```csharp using System.Security; namespace ClaudeDo.Installer.Core; /// Builds a Task Scheduler definition XML for the per-user worker autostart. /// Pure function so it can be unit-tested without admin rights. public static class ScheduledTaskXml { public static string Build(string userId, string workerExePath, int restartIntervalMinutes) { var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes; var user = SecurityElement.Escape(userId); var cmd = SecurityElement.Escape(workerExePath); return $""" ClaudeDo background worker (per-user). true {user} {user} InteractiveToken LeastPrivilege IgnoreNew false false true true true PT0S PT{minutes}M 3 {cmd} """; } } ``` - [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS. --- ## Task 4: RegisterAutostartStep (migrate service + register task) **Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` - [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin): ```csharp using System.IO; using System.Security.Principal; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Steps; public sealed class RegisterAutostartStep : IInstallStep { public const string TaskName = "ClaudeDoWorker"; private const string LegacyServiceName = "ClaudeDoWorker"; public string Name => "Register Autostart"; public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) { var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe"); if (!File.Exists(workerExe)) return StepResult.Fail($"Worker executable not found: {workerExe}"); // 1) Migrate away the legacy Windows service if present. progress.Report("Checking for legacy worker service..."); var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct); if (queryExit == 0) { progress.Report("Removing legacy worker service..."); await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct); await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct); for (var i = 0; i < 30; i++) { ct.ThrowIfCancellationRequested(); var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct); if (q != 0) break; await Task.Delay(1000, ct); } } // 2) Register (or replace) the per-user logon task. var userId = WindowsIdentity.GetCurrent().Name; var minutes = Math.Max(1, ctx.RestartDelayMs / 60000); var xml = ScheduledTaskXml.Build(userId, workerExe, minutes); var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml"); await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct); try { progress.Report("Registering logon task..."); var (exit, output) = await ProcessRunner.RunAsync( "schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct); if (exit != 0) return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}"); } finally { try { File.Delete(xmlPath); } catch { /* best effort */ } } return StepResult.Ok(); } } ``` - [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now). --- ## Task 5: StopWorkerStep + StartWorkerStep (replace service steps) **Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`. - [ ] **Step 1: Create `StopWorkerStep.cs`:** ```csharp using System.Diagnostics; using System.IO; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Steps; public sealed class StopWorkerStep : IInstallStep { public const string TaskName = "ClaudeDoWorker"; public const string ProcessName = "ClaudeDo.Worker"; public string Name => "Stop Worker"; public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) { progress.Report("Stopping worker task (if running)..."); await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct); progress.Report("Stopping worker process (if running)..."); var installDir = ctx.InstallDirectory; foreach (var p in Process.GetProcessesByName(ProcessName)) { try { var path = p.MainModule?.FileName; if (path is not null && !IsUnder(path, installDir)) continue; p.Kill(entireProcessTree: true); p.WaitForExit(10000); } catch { /* process may have exited or be inaccessible */ } finally { p.Dispose(); } } await Task.CompletedTask; return StepResult.Ok(); } private static bool IsUnder(string filePath, string dir) { try { if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive var full = Path.GetFullPath(filePath); var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; return full.StartsWith(root, StringComparison.OrdinalIgnoreCase); } catch { return false; } } } ``` - [ ] **Step 2: Create `StartWorkerStep.cs`:** ```csharp using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Steps; public sealed class StartWorkerStep : IInstallStep { public const string TaskName = "ClaudeDoWorker"; public string Name => "Start Worker"; public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) { progress.Report("Starting worker..."); var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct); if (exit != 0) return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}"); return StepResult.Ok(); } } ``` - [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`. - [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them). --- ## Task 6: InstallContext + ServicePage cleanup **Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml` - [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.). - [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay. - [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest. - [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9. --- ## Task 7: Pipeline wiring + DI **Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs` - [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to: ```csharp Steps.Add(new StepViewModel("Stop Worker")); Steps.Add(new StepViewModel("Download and Extract")); Steps.Add(new StepViewModel("Register Autostart")); Steps.Add(new StepViewModel("Start Worker")); Steps.Add(new StepViewModel("Write Install Manifest")); Steps.Add(new StepViewModel("Register in Add/Remove Programs")); ``` And the **Fresh** display steps to: ```csharp Steps.Add(new StepViewModel("Download and Extract")); Steps.Add(new StepViewModel("Write Configuration")); Steps.Add(new StepViewModel("Initialize Database")); Steps.Add(new StepViewModel("Register Autostart")); Steps.Add(new StepViewModel("Create Shortcuts")); Steps.Add(new StepViewModel("Register in Add/Remove Programs")); Steps.Add(new StepViewModel("Write Install Manifest")); Steps.Add(new StepViewModel("Start Worker")); ``` - [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to: ```csharp steps = new IInstallStep[] { _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), }; ``` - [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register: ```csharp sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); // Not part of the default fresh IEnumerable — pulled individually. sc.AddSingleton(); ``` Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations. - [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9. --- ## Task 8: SettingsViewModel + UninstallRunner **Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs` - [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep` → `StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`. - [ ] **Step 2:** In `UninstallRunner.cs`: - Constructor param `StopServiceStep` → `StopWorkerStep` (field too). - Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup: ```csharp // 3) Unregister autostart task + remove any legacy service. progress.Report("Removing autostart task..."); await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct); await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort ``` - The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files). - [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**. - [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`). --- ## Task 9: App WorkerLocator (TDD) **Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs` - [ ] **Step 1: Write failing test:** ```csharp using ClaudeDo.Ui.Services; using Xunit; namespace ClaudeDo.Ui.Tests.Services; public class WorkerLocatorTests { [Fact] public void FindByWalkingUp_FindsWorkerExeBesideInstallJson() { var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N")); var appDir = Path.Combine(root, "app"); var workerDir = Path.Combine(root, "worker"); Directory.CreateDirectory(appDir); Directory.CreateDirectory(workerDir); File.WriteAllText(Path.Combine(root, "install.json"), "{}"); var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe"); File.WriteAllText(exe, ""); try { var found = new WorkerLocator().FindByWalkingUp(appDir); Assert.Equal(exe, found); } finally { Directory.Delete(root, recursive: true); } } [Fact] public void FindByWalkingUp_ReturnsNullWhenNoManifest() { var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); } finally { Directory.Delete(dir, recursive: true); } } } ``` - [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL. - [ ] **Step 3: Implement** (mirror `InstallerLocator`): ```csharp namespace ClaudeDo.Ui.Services; public sealed class WorkerLocator { private const string InstallJson = "install.json"; private const string WorkerExe = "ClaudeDo.Worker.exe"; private const string WorkerSubdir = "worker"; public string? Find() => FindByWalkingUp(AppContext.BaseDirectory) ?? (OperatingSystem.IsWindows() ? FindByRegistry() : null); public string? FindByWalkingUp(string startDir) { var dir = new DirectoryInfo(startDir); while (dir is not null) { if (File.Exists(Path.Combine(dir.FullName, InstallJson))) { var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe); return File.Exists(candidate) ? candidate : null; } dir = dir.Parent; } return null; } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public string? FindByRegistry() { if (!OperatingSystem.IsWindows()) return null; try { using var key = Microsoft.Win32.Registry.LocalMachine .OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo"); var location = key?.GetValue("InstallLocation") as string; if (string.IsNullOrEmpty(location)) return null; var candidate = Path.Combine(location, WorkerSubdir, WorkerExe); return File.Exists(candidate) ? candidate : null; } catch { return null; } } } ``` - [ ] **Step 4: Run, verify pass.** --- ## Task 10: App restart-worker + ensure-running **Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs` - [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton()` so DI supplies it). - [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch: ```csharp private void RestartWorkerService() { var exe = _workerLocator.Find(); if (exe is null) throw new InvalidOperationException("Worker executable not found."); foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) { try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); } catch { /* may have exited */ } finally { p.Dispose(); } } System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); } ``` Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure). - [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check: ```csharp private bool _ensureRunningAttempted; private async Task EnsureWorkerRunningAsync() { if (_ensureRunningAttempted) return; _ensureRunningAttempted = true; await Task.Delay(TimeSpan.FromSeconds(4)); if (_worker.IsConnected) return; var exe = _workerLocator.Find(); if (exe is null) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); } catch { /* logon task is the primary mechanism; this is a convenience */ } } ``` Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member. - [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused. - [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds. - [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance. --- ## Task 11: Full build + test sweep - [ ] **Step 1:** Build each project: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj ``` Expected: all succeed, 0 errors. - [ ] **Step 2:** Run all test projects: ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj ``` Expected: all pass. - [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings). --- ## Notes for the implementer - Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one. - `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites. - Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up.