Files
ClaudeDo/docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md
mika kuns 26c4e5771b feat(worker): run worker as per-user logon task instead of Windows service
A LocalSystem Windows service can't see the logged-in user's Claude CLI
authentication, so the worker now runs as the current user via a hidden
per-user logon Scheduled Task with restart-on-failure.

- Worker is WinExe (no console window) with a Serilog rolling file sink and
  a single-instance mutex so the logon task, app ensure-running, and Restart
  button can't fight over the SignalR port.
- Installer replaces the service steps (register/start/stop) with autostart
  task steps, migrates the legacy ClaudeDoWorker service away on update, and
  removes the task on uninstall. ServicePage drops the service-account UI.
- UI gains a WorkerLocator; the app ensures the worker is running at startup
  and the Restart button kills+relaunches this install's worker process.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:41 +02:00

27 KiB

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.csStopWorkerStep.cs, StartServiceStep.csStartWorkerStep.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 <PropertyGroup> add <OutputType>WinExe</OutputType>. Remove the Microsoft.Extensions.Hosting.WindowsServices PackageReference. Add:
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
  • 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:
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:

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:
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("<LogonTrigger>", xml);
        Assert.Contains("<UserId>MACHINE\\mika</UserId>", xml);
        Assert.Contains("<LogonType>InteractiveToken</LogonType>", xml);
        Assert.Contains("<Hidden>true</Hidden>", xml);
        Assert.Contains("<RunLevel>LeastPrivilege</RunLevel>", xml);
        Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml);
        Assert.Contains("<Interval>PT1M</Interval>", xml);
    }

    [Fact]
    public void Build_ClampsRestartIntervalToOneMinuteMinimum()
    {
        var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0);
        Assert.Contains("<Interval>PT1M</Interval>", 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:

using System.Security;

namespace ClaudeDo.Installer.Core;

/// <summary>Builds a Task Scheduler definition XML for the per-user worker autostart.
/// Pure function so it can be unit-tested without admin rights.</summary>
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 $"""
        <?xml version="1.0" encoding="UTF-16"?>
        <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
          <RegistrationInfo>
            <Description>ClaudeDo background worker (per-user).</Description>
          </RegistrationInfo>
          <Triggers>
            <LogonTrigger>
              <Enabled>true</Enabled>
              <UserId>{user}</UserId>
            </LogonTrigger>
          </Triggers>
          <Principals>
            <Principal id="Author">
              <UserId>{user}</UserId>
              <LogonType>InteractiveToken</LogonType>
              <RunLevel>LeastPrivilege</RunLevel>
            </Principal>
          </Principals>
          <Settings>
            <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
            <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
            <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
            <AllowHardTerminate>true</AllowHardTerminate>
            <StartWhenAvailable>true</StartWhenAvailable>
            <Hidden>true</Hidden>
            <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
            <RestartOnFailure>
              <Interval>PT{minutes}M</Interval>
              <Count>3</Count>
            </RestartOnFailure>
          </Settings>
          <Actions Context="Author">
            <Exec>
              <Command>{cmd}</Command>
            </Exec>
          </Actions>
        </Task>
        """;
    }
}
  • 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):
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<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> 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:
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<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> 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:
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<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> 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:
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:

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:
steps = new IInstallStep[]
{
    _serviceProvider.GetRequiredService<StopWorkerStep>(),
    _serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
    _serviceProvider.GetRequiredService<RegisterAutostartStep>(),
    _serviceProvider.GetRequiredService<StartWorkerStep>(),
    _serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
    _serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
};
  • 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:
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<WriteUninstallRegistryStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
sc.AddSingleton<StartWorkerStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());

// Not part of the default fresh IEnumerable<IInstallStep> — pulled individually.
sc.AddSingleton<StopWorkerStep>();

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/StartServiceStepStopWorkerStep/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 StopServiceStepStopWorkerStep (field too).
    • Replace sc.exe delete ClaudeDoWorker with task removal + legacy service cleanup:
// 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:
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):

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<WorkerLocator>(); and ensure IslandsShellViewModel receives it (constructor injection; the VM is AddSingleton<IslandsShellViewModel>() 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:

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:
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:
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:
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<string> 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.