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— sharedIShellLinkCOM helper (CreateShortcut). - Create:
Core/AutostartShortcut.cs— install/remove the worker Startup-folder.lnk. - Modify:
Steps/CreateShortcutsStep.cs— useShortcutFactory, drop embedded COM. - Modify:
Steps/RegisterAutostartStep.cs— Startup shortcut + legacy-task delete (no more task XML). - Modify:
Steps/StartWorkerStep.cs—Process.Startinstead ofschtasks /Run. - Modify:
Steps/StopWorkerStep.cs— dropschtasks /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/— deleteScheduledTaskXmlTests.cs; addShortcutFactoryTests.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 ofCreateShortcutsStep)
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
CreateShortcutsStepwith 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
StartWorkerStepto 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 /Endcall inStopWorkerStep
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
.lnkremoval
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
EnsureWorkerRunningAsyncmethod 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"