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>
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, removeUseWindowsService.
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— dropServiceAccount. - 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— registerWorkerLocator, 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 theMicrosoft.Extensions.Hosting.WindowsServicesPackageReference. 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 (setCLAUDEDO_SKIP_CLI_PREFLIGHT=1if 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; ifRestartDelayMsexists onInstallContextalready, 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,RegisterServiceStepacrosssrc/— 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.csremove theServiceAccountproperty (keepAutoStart,RestartDelayMs,SignalRPort,ClaudeBin, etc.). -
Step 2: In
ServicePageViewModel.csremoveIsLocalSystem/IsCurrentUser[ObservableProperty]fields and the_context.ServiceAccount = ...line inApplyAsync. Keep port/claudeBin/autostart/restartDelay. -
Step 3: In
ServicePageView.xamlremove 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.csBuildServices, replace the service-step registrations. Fresh-installIInstallSteporder 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/fieldsStopServiceStep/StartServiceStep→StopWorkerStep/StartWorkerStep(rename type usages only; the Save/Repair logic stays). Update theRepairstep array to{ _stopWorker, _downloadStep, _startWorker }. -
Step 2: In
UninstallRunner.cs:- Constructor param
StopServiceStep→StopWorkerStep(field too). - Replace
sc.exe delete ClaudeDoWorkerwith task removal + legacy service cleanup:
- Constructor param
// 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.ExecuteAsynccall 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. newScheduledTaskXmlTests).
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.csregister the locator:sc.AddSingleton<WorkerLocator>();and ensureIslandsShellViewModelreceives it (constructor injection; the VM isAddSingleton<IslandsShellViewModel>()so DI supplies it). -
Step 2: In
IslandsShellViewModel, add aWorkerLocatorconstructor dependency and store it. ReplaceRestartWorkerService(theServiceControllerversion) 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 theServiceControllerusage. Remove theSystem.ServiceProcess.ServiceProcesspackage reference fromClaudeDo.Ui.csprojif 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). IfIslandsShellViewModelconstruction is exercised in a test, supply aWorkerLocatorinstance.
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,ServiceAccountinsrc/— Expected: no matches (except the legacysc delete ClaudeDoWorkermigration/cleanup strings).
Notes for the implementer
- Worker config property for the log directory: confirm the exact name on
WorkerConfig(spec assumesLogRoot). Use the real one. ProcessRunner.RunAsyncsignature 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 ClaudeDoWorkercalls (migration + uninstall) so existing service installs are cleaned up.