Files
ClaudeDo/docs/superpowers/plans/2026-06-01-worker-lifecycle.md
mika kuns 5baa1d7fbb docs: add worker lifecycle implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:19:32 +02:00

30 KiB

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

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:

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(...):

ShortcutFactory.CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
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
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:

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:

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

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:

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

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):

        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
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 .lnks. Add the Startup shortcut removal right after them:

        // 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
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):

        _ = EnsureWorkerRunningAsync();
  • Step 2: Remove the EnsureWorkerRunningAsync method and its flag

Delete the _ensureRunningAttempted field (line 308) and the whole EnsureWorkerRunningAsync method (lines 310-320):

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

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:

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

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

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):

    // 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):

    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):

    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):

        _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
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:

            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:

        <!-- 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

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:

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:

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)
git add -A
git commit -m "chore: worker lifecycle redesign cleanup" || echo "nothing to commit"