# 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<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) 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<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> 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
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
        xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
        x:Class="ClaudeDo.Ui.Views.Modals.WorkerConnectionModalView"
        x:DataType="vm:WorkerConnectionModalViewModel"
        Title="Worker not reachable"
        Width="520" Height="240"
        WindowDecorations="None"
        ExtendClientAreaToDecorationsHint="True"
        WindowStartupLocation="CenterOwner"
        Background="{DynamicResource SurfaceBrush}">
  <Window.KeyBindings>
    <KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
  </Window.KeyBindings>

  <ctl:ModalShell Title="WORKER NOT REACHABLE" CloseCommand="{Binding CloseCommand}">
    <Grid RowDefinitions="*,Auto" Margin="20,16">
      <TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
                 Text="ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists."/>
      <StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"
                  HorizontalAlignment="Right" Margin="0,16,0,0">
        <Button Classes="btn" Content="Dismiss" Command="{Binding CloseCommand}"/>
        <Button Classes="btn" Content="Rerun Installer" Command="{Binding RerunInstallerCommand}"/>
        <Button Classes="btn primary" Content="Start Worker" Command="{Binding StartWorkerCommand}"/>
      </StackPanel>
    </Grid>
  </ctl:ModalShell>
</Window>
```

`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace ClaudeDo.Ui.Views.Modals;

public partial class WorkerConnectionModalView : Window
{
    public WorkerConnectionModalView()
    {
        InitializeComponent();
    }

    private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
```

- [ ] **Step 3: Build to verify it compiles**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs
git commit -m "feat(ui): add worker connection help modal"
```

---

## Task 8: Shell hook, command, grace timer + decision gate

**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`

- [ ] **Step 1: Write the failing test for the decision gate**

`tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`:
```csharp
using ClaudeDo.Ui.ViewModels;
using Xunit;

namespace ClaudeDo.Ui.Tests;

public class ConnectionPromptGateTests
{
    [Fact]
    public void Shows_once_when_offline()
    {
        var vm = new IslandsShellViewModel();
        Assert.True(vm.DecideShowConnectionPrompt(isOffline: true));
        Assert.False(vm.DecideShowConnectionPrompt(isOffline: true)); // not a second time
    }

    [Fact]
    public void Does_not_show_when_connected_before_grace()
    {
        var vm = new IslandsShellViewModel();
        Assert.False(vm.DecideShowConnectionPrompt(isOffline: false));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
Expected: FAIL — `DecideShowConnectionPrompt` does not exist.

- [ ] **Step 3: Add the hook, command, gate, and grace timer**

In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:

Add a hook property near the other `Show*Modal` hooks (after line 52):
```csharp
    // Set by MainWindow to open the worker-connection help dialog.
    public Func<Modals.WorkerConnectionModalViewModel, Task>? ShowWorkerConnectionModal { get; set; }
```

Add the gate field + method and the open command (place near `OpenAbout`, around line 271):
```csharp
    private bool _connectionPromptShown;

    internal bool DecideShowConnectionPrompt(bool isOffline)
    {
        if (!isOffline) return false;
        if (_connectionPromptShown) return false;
        _connectionPromptShown = true;
        return true;
    }

    private async Task OpenWorkerConnectionHelpAsync()
    {
        var vm = new Modals.WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
        if (ShowWorkerConnectionModal is not null) await ShowWorkerConnectionModal(vm);
    }

    [RelayCommand]
    private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
```

Add the grace timer field near `_clearTimer` (line 74):
```csharp
    private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
```

Wire and start it inside the **public** constructor (after the `_primeStatusTimer.Elapsed` wiring, near line 222 — NOT in the parameterless test constructor):
```csharp
        _connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
        {
            if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
        });
        _connectTimer.Start();
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
Expected: PASS (2 tests).

- [ ] **Step 5: Build the app**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs
git commit -m "feat(ui): prompt once on worker connection failure with grace timer"
```

---

## Task 9: Wire the modal in MainWindow + clickable status pill

**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`

- [ ] **Step 1: Wire the dialog hook**

In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged`, after the existing `vm.ShowRepoImportModal = ...` block (line 70), add:
```csharp
            vm.ShowWorkerConnectionModal = async (connVm) =>
            {
                var dlg = new WorkerConnectionModalView { DataContext = connVm };
                connVm.CloseAction = () => dlg.Close();
                await dlg.ShowDialog(this);
            };
```
(`ClaudeDo.Ui.Views.Modals` is already imported at line 10.)

- [ ] **Step 2: Make the status pill a button**

In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, replace the left "connection pill" `StackPanel` (lines 190-202) with a `Button` wrapping the same content:
```xml
        <!-- Left: connection pill (click to open worker help) -->
        <Button DockPanel.Dock="Left"
                Command="{Binding OpenWorkerConnectionHelpCommand}"
                Background="Transparent" BorderThickness="0" Padding="0"
                Cursor="Hand" VerticalAlignment="Center">
          <StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
            <Ellipse Width="7" Height="7" Fill="{DynamicResource StatusRunningBrush}"
                     IsVisible="{Binding Worker.IsConnected}"/>
            <Ellipse Width="7" Height="7" Fill="{DynamicResource StatusReviewBrush}"
                     IsVisible="{Binding Worker.IsReconnecting}"/>
            <Ellipse Width="7" Height="7" Fill="{DynamicResource StatusErrorBrush}"
                     IsVisible="{Binding IsOffline}"/>
            <TextBlock Classes="eyebrow"
                       Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
                       LetterSpacing="1.4"
                       VerticalAlignment="Center"/>
          </StackPanel>
        </Button>
```

- [ ] **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"
```
