# Worker Lifecycle Redesign Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the worker owned by a single external mechanism (a per-user Startup-folder shortcut in production), stop the App from auto-spawning its own worker, and show an actionable prompt when the App can't connect. **Architecture:** Installer creates a `.lnk` in the Windows Startup folder instead of a Scheduled Task (migrating existing installs by deleting the old task). The App's `IslandsShellViewModel` drops `EnsureWorkerRunningAsync` and instead runs a one-shot grace timer that opens a `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss) if still offline; the footer status pill becomes a button that reopens it. **Tech Stack:** .NET 8, WPF installer (COM `IShellLink` for shortcuts), Avalonia + CommunityToolkit.Mvvm UI, xUnit. --- ## File Structure **Installer (`src/ClaudeDo.Installer`)** - Create: `Core/ShortcutFactory.cs` — shared `IShellLink` COM helper (`CreateShortcut`). - Create: `Core/AutostartShortcut.cs` — install/remove the worker Startup-folder `.lnk`. - Modify: `Steps/CreateShortcutsStep.cs` — use `ShortcutFactory`, drop embedded COM. - Modify: `Steps/RegisterAutostartStep.cs` — Startup shortcut + legacy-task delete (no more task XML). - Modify: `Steps/StartWorkerStep.cs` — `Process.Start` instead of `schtasks /Run`. - Modify: `Steps/StopWorkerStep.cs` — drop `schtasks /End`. - Modify: `Core/UninstallRunner.cs` — remove the Startup `.lnk`. - Delete: `Core/ScheduledTaskXml.cs` (and its test). **App (`src/ClaudeDo.Ui`)** - Create: `ViewModels/Modals/WorkerConnectionModalViewModel.cs`. - Create: `Views/Modals/WorkerConnectionModalView.axaml` (+ `.axaml.cs`). - Modify: `ViewModels/IslandsShellViewModel.cs` — remove auto-spawn; add hook, command, grace timer, decision gate. - Modify: `Views/MainWindow.axaml.cs` — wire the new modal. - Modify: `Views/MainWindow.axaml` — clickable status pill. **Tests** - Modify: `tests/ClaudeDo.Installer.Tests/` — delete `ScheduledTaskXmlTests.cs`; add `ShortcutFactoryTests.cs`, `AutostartShortcutTests.cs`. - Add: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`. --- ## Task 1: ShortcutFactory (shared COM helper) **Files:** - Create: `src/ClaudeDo.Installer/Core/ShortcutFactory.cs` - Modify: `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs` - Test: `tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs` - [ ] **Step 1: Write the failing test** `tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`: ```csharp using System.IO; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Tests; public class ShortcutFactoryTests { [Fact] public void CreateShortcut_writes_lnk_file() { var dir = Path.Combine(Path.GetTempPath(), "cdshortcut-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var target = Path.Combine(dir, "fake.exe"); File.WriteAllText(target, ""); var lnk = Path.Combine(dir, "x.lnk"); ShortcutFactory.CreateShortcut(lnk, target, dir, "desc"); Assert.True(File.Exists(lnk)); } finally { Directory.Delete(dir, recursive: true); } } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests` Expected: FAIL — `ShortcutFactory` does not exist (compile error). - [ ] **Step 3: Create `ShortcutFactory` (move COM interop out of `CreateShortcutsStep`)** `src/ClaudeDo.Installer/Core/ShortcutFactory.cs`: ```csharp using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; namespace ClaudeDo.Installer.Core; public static class ShortcutFactory { public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description) { var link = (IShellLink)new ShellLink(); link.SetPath(targetPath); link.SetWorkingDirectory(workingDir); link.SetDescription(description); link.SetIconLocation(targetPath, 0); var file = (IPersistFile)link; file.Save(shortcutPath, false); } [ComImport] [Guid("00021401-0000-0000-C000-000000000046")] private class ShellLink { } [ComImport] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] [Guid("000214F9-0000-0000-C000-000000000046")] private interface IShellLink { void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags); void GetIDList(out IntPtr ppidl); void SetIDList(IntPtr pidl); void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); void GetHotkey(out short pwHotkey); void SetHotkey(short wHotkey); void GetShowCmd(out int piShowCmd); void SetShowCmd(int iShowCmd); void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon); void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved); void Resolve(IntPtr hwnd, int fFlags); void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); } } ``` - [ ] **Step 4: Replace the embedded COM in `CreateShortcutsStep` with the helper** In `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`: delete the private `CreateShortcut` method and the entire `#region COM Interop for IShellLink` block (lines 47-90), remove the now-unused `using System.Runtime.InteropServices;`, `using System.Runtime.InteropServices.ComTypes;`, and `using System.Text;`. Replace the two `CreateShortcut(...)` call sites with `ShortcutFactory.CreateShortcut(...)`: ```csharp ShortcutFactory.CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager"); ``` ```csharp ShortcutFactory.CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager"); ``` - [ ] **Step 5: Run tests to verify they pass** Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Installer/Core/ShortcutFactory.cs src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs git commit -m "refactor(installer): extract ShortcutFactory COM helper" ``` --- ## Task 2: AutostartShortcut helper **Files:** - Create: `src/ClaudeDo.Installer/Core/AutostartShortcut.cs` - Test: `tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs` - [ ] **Step 1: Write the failing tests** `tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`: ```csharp using System.IO; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Tests; public class AutostartShortcutTests { private static string TempDir() { var dir = Path.Combine(Path.GetTempPath(), "cdautostart-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); return dir; } [Fact] public void Install_creates_lnk_with_expected_name() { var startup = TempDir(); var workerDir = TempDir(); try { var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe"); File.WriteAllText(workerExe, ""); AutostartShortcut.Install(startup, workerExe); Assert.True(File.Exists(Path.Combine(startup, AutostartShortcut.FileName))); } finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); } } [Fact] public void Remove_deletes_existing_lnk() { var startup = TempDir(); var workerDir = TempDir(); try { var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe"); File.WriteAllText(workerExe, ""); AutostartShortcut.Install(startup, workerExe); AutostartShortcut.Remove(startup); Assert.False(File.Exists(Path.Combine(startup, AutostartShortcut.FileName))); } finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); } } [Fact] public void Remove_is_noop_when_missing() { var startup = TempDir(); try { AutostartShortcut.Remove(startup); } // must not throw finally { Directory.Delete(startup, true); } } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests` Expected: FAIL — `AutostartShortcut` does not exist. - [ ] **Step 3: Create `AutostartShortcut`** `src/ClaudeDo.Installer/Core/AutostartShortcut.cs`: ```csharp using System.IO; namespace ClaudeDo.Installer.Core; public static class AutostartShortcut { public const string FileName = "ClaudeDo Worker.lnk"; public static string DefaultStartupDir => Environment.GetFolderPath(Environment.SpecialFolder.Startup); public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName); public static void Install(string startupDir, string workerExe) { Directory.CreateDirectory(startupDir); var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir; ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker"); } public static void Remove(string startupDir) { var path = PathIn(startupDir); if (File.Exists(path)) File.Delete(path); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Installer/Core/AutostartShortcut.cs tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs git commit -m "feat(installer): add AutostartShortcut helper for Startup-folder lnk" ``` --- ## Task 3: RegisterAutostartStep → Startup shortcut + task migration **Files:** - Modify: `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` - Delete: `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` - Delete: `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs` - [ ] **Step 1: Replace the step body** Rewrite `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` to: ```csharp using System.IO; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Steps; public sealed class RegisterAutostartStep : IInstallStep { public const string LegacyTaskName = "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) Migrate away the legacy logon scheduled task if present (best-effort). progress.Report("Removing legacy logon task..."); await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct); // 3) Register per-user autostart via a Startup-folder shortcut. progress.Report("Creating Startup shortcut..."); try { AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe); } catch (Exception ex) { return StepResult.Fail($"Failed to create Startup shortcut: {ex.Message}"); } return StepResult.Ok(); } } ``` - [ ] **Step 2: Delete the obsolete scheduled-task code and its test** Run: ```bash git rm src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs ``` - [ ] **Step 3: Build the installer to verify it compiles** Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` Expected: Build succeeded. (If `RegisterAutostartStep.TaskName` was referenced elsewhere, the build will flag it — Task 4 and Task 5 update those references; if the build fails only there, proceed to those tasks before re-running.) - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs git commit -m "feat(installer): register autostart via Startup shortcut, drop scheduled task" ``` --- ## Task 4: StartWorkerStep + StopWorkerStep **Files:** - Modify: `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs` - Modify: `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs` - [ ] **Step 1: Rewrite `StartWorkerStep` to launch the exe directly** `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`: ```csharp using System.Diagnostics; using System.IO; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Steps; public sealed class StartWorkerStep : IInstallStep { public string Name => "Start Worker"; public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) { var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe"); if (!File.Exists(workerExe)) return Task.FromResult(StepResult.Fail($"Worker executable not found: {workerExe}")); progress.Report("Starting worker..."); try { Process.Start(new ProcessStartInfo(workerExe) { UseShellExecute = true }); return Task.FromResult(StepResult.Ok()); } catch (Exception ex) { return Task.FromResult(StepResult.Fail($"Failed to start worker: {ex.Message}")); } } } ``` - [ ] **Step 2: Drop the `schtasks /End` call in `StopWorkerStep`** In `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, remove these two lines (the task no longer exists; the process kill below is the real stop): ```csharp progress.Report("Stopping worker task (if running)..."); await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct); ``` Keep the `public const string TaskName = "ClaudeDoWorker";` line — `UninstallRunner` still references it for legacy-task cleanup (Task 5). The method keeps its `async` modifier (it still has `await Task.CompletedTask;`). - [ ] **Step 3: Build to verify it compiles** Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` Expected: Build succeeded. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Installer/Steps/StartWorkerStep.cs src/ClaudeDo.Installer/Steps/StopWorkerStep.cs git commit -m "feat(installer): start worker via Process.Start, drop schtasks stop" ``` --- ## Task 5: UninstallRunner removes the Startup shortcut **Files:** - Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs` - [ ] **Step 1: Add Startup `.lnk` removal** In `src/ClaudeDo.Installer/Core/UninstallRunner.cs`, the shortcut-removal block (step 4, around lines 53-60) currently removes the Desktop and Start Menu `.lnk`s. Add the Startup shortcut removal right after them: ```csharp // 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest). progress.Report("Removing shortcuts..."); TryDeleteFile(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), "ClaudeDo.lnk")); TryDeleteFile(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), "Programs", "ClaudeDo.lnk")); TryDeleteFile(AutostartShortcut.PathIn(AutostartShortcut.DefaultStartupDir)); ``` The existing `schtasks /Delete /TN "{StopWorkerStep.TaskName}" /F` line (step 3) stays — it cleans up the legacy task on machines that still have it. - [ ] **Step 2: Build to verify it compiles** Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` Expected: Build succeeded. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Installer/Core/UninstallRunner.cs git commit -m "feat(installer): remove Startup worker shortcut on uninstall" ``` --- ## Task 6: App stops auto-spawning the worker **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` - [ ] **Step 1: Remove the auto-spawn call** In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, delete this line from the constructor (line 224): ```csharp _ = EnsureWorkerRunningAsync(); ``` - [ ] **Step 2: Remove the `EnsureWorkerRunningAsync` method and its flag** Delete the `_ensureRunningAttempted` field (line 308) and the whole `EnsureWorkerRunningAsync` method (lines 310-320): ```csharp private bool _ensureRunningAttempted; private async Task EnsureWorkerRunningAsync() { if (_ensureRunningAttempted) return; _ensureRunningAttempted = true; await Task.Delay(TimeSpan.FromSeconds(4)); if (Worker?.IsConnected == true) 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 */ } } ``` Keep `RestartWorkerAsync` / `RestartWorkerService` (still used by the existing Restart button). `_workerLocator` stays in use (RestartWorkerService + Task 8). - [ ] **Step 3: Build to verify it compiles** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` Expected: Build succeeded (no remaining references to `EnsureWorkerRunningAsync`). - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs git commit -m "refactor(ui): stop auto-spawning the worker on app start" ``` --- ## Task 7: WorkerConnectionModal (VM + View) **Files:** - Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs` - Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml` - Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs` - [ ] **Step 1: Create the ViewModel** `src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`: ```csharp using System; using System.Diagnostics; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals; public sealed partial class WorkerConnectionModalViewModel : ViewModelBase { private readonly WorkerLocator _workerLocator; private readonly InstallerLocator _installerLocator; public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator) { _workerLocator = workerLocator; _installerLocator = installerLocator; } public Action? CloseAction { get; set; } [RelayCommand] private void Close() => CloseAction?.Invoke(); [RelayCommand] private void StartWorker() { var exe = _workerLocator.Find(); if (exe is null) return; try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); } catch { /* nothing useful to show */ } CloseAction?.Invoke(); } [RelayCommand] private void RerunInstaller() { var path = _installerLocator.Find(); if (path is null) return; try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); Environment.Exit(0); } catch { /* nothing useful to show */ } } } ``` - [ ] **Step 2: Create the View (mirrors `AboutModalView` + `ModalShell`)** `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`: ```xml ``` - [ ] **Step 3: Build the app** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` Expected: Build succeeded. - [ ] **Step 4: Manual verification** Start the worker (or leave it stopped) and run the App: - Worker stopped → after ~12s the "WORKER NOT REACHABLE" dialog appears once. **Start Worker** launches it (footer pill turns ONLINE); **Rerun Installer** launches the installer and exits; **Dismiss** closes and does not reappear automatically. - Click the footer status pill anytime → the dialog reopens. - Worker running before launch → no dialog appears. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui/Views/MainWindow.axaml.cs src/ClaudeDo.Ui/Views/MainWindow.axaml git commit -m "feat(ui): wire worker connection modal and make status pill clickable" ``` --- ## Task 10: Full build + test sweep - [ ] **Step 1: Build the touched projects** Run: ```bash dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj ``` Expected: both Build succeeded. - [ ] **Step 2: Run the affected test suites** Run: ```bash dotnet test tests/ClaudeDo.Installer.Tests dotnet test tests/ClaudeDo.Ui.Tests ``` Expected: all pass; no references to the deleted `ScheduledTaskXml`. - [ ] **Step 3: Final commit (if any stragglers)** ```bash git add -A git commit -m "chore: worker lifecycle redesign cleanup" || echo "nothing to commit" ```