using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; using System; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels; public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable { public ListsIslandViewModel? Lists { get; } public TasksIslandViewModel? Tasks { get; } public DetailsIslandViewModel? Details { get; } public IWorkerClient? Worker { get; internal set; } public MissionControlViewModel? MissionControl { get; } public UsagePillViewModel? UsagePill { get; } public UpdateCheckService UpdateCheck => _updateCheck; // Stable opKey the six Lifecycle/*Recovery startup sweeps broadcast under (see // OperationProgressOpKeys.StartupRecovery on the worker side — not shared across the // SignalR wire, so the literal is duplicated here on purpose). private const string StartupRecoveryOpKey = "startup-recovery"; // Last startup-recovery text received this connection cycle, shown in place of the generic // "connecting" label. Cleared once actually connected so a later transient reconnect (not a // fresh worker boot) doesn't replay stale recovery text. private string? _startupRecoveryText; public string ConnectionText => Worker?.IsConnected == true ? Loc.T("vm.connection.online") : Worker?.IsReconnecting == true ? (_startupRecoveryText ?? Loc.T("vm.connection.connecting")) : Loc.T("vm.connection.offline"); public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true; // Gate for the footer pill: the help dialog says "WORKER NOT REACHABLE", so it must never open // on a connected worker. Not `IsOffline` — the retry loop stays in "connecting" forever while // the worker is down, and that's exactly when the dialog's "Start Worker" is needed. public bool CanOpenWorkerConnectionHelp => DecideCanOpenConnectionHelp(Worker?.IsConnected == true); internal static bool DecideCanOpenConnectionHelp(bool isConnected) => !isConnected; private readonly UpdateCheckService _updateCheck = null!; private readonly InstallerLocator _installerLocator = null!; private readonly WorkerLocator _workerLocator = null!; private readonly IDbContextFactory? _dbFactory; private readonly Func _worktreesOverviewVmFactory = () => null!; private readonly Func _weeklyReportVmFactory = () => null!; private readonly Func _usageMonitorVmFactory = () => null!; private readonly Func _mergeVmFactory = () => null!; private readonly Func? _repoImportVmFactory; public Func ResolveMergeVm => _mergeVmFactory; // Layer C seam: composition root sets the factory; the dialog service shows the resolver. public Func? ConflictResolverFactory { get; set; } // Set by MainWindow so a reveal can bring the main window to the foreground. public Action? BringToFront { get; set; } // Single dialog seam (set by MainWindow); propagated to the islands. private IDialogService? _dialogs; public IDialogService? Dialogs { get => _dialogs; set { _dialogs = value; if (Lists is not null) Lists.Dialogs = value; if (Tasks is not null) Tasks.Dialogs = value; if (Details is not null) Details.Dialogs = value; } } public async Task RevealTaskAsync(string taskId) { if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; } string? listId = null; if (_dbFactory is not null) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); listId = entity?.ListId; } catch { /* best-effort list resolution */ } } if (listId is not null) { var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}"); if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem)) Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList } await Tasks.SelectByIdAsync(taskId); BringToFront?.Invoke(); } public async Task RequestConflictResolutionAsync(string taskId, string targetBranch) { if (ConflictResolverFactory is null || Dialogs is null) return; var vm = ConflictResolverFactory(taskId); var hasConflicts = await vm.OpenAsync(targetBranch); if (hasConflicts) await Dialogs.ShowConflictResolverAsync(vm); } [ObservableProperty] private bool _isUpdateBannerVisible; [ObservableProperty] private string? _updateBannerLatestVersion; private bool _bannerDismissedThisSession; // planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started. // Kept as a dictionary so a later conflict on the same planning task updates in place instead // of piling up duplicate entries. private readonly Dictionary _externalMergeConflicts = new(); [ObservableProperty] private bool _isExternalMergeBannerVisible; [ObservableProperty] private double _windowWidth = 1280; [ObservableProperty] private string? _workerLogText; [ObservableProperty] private WorkerLogLevel _workerLogLevel; [ObservableProperty] private bool _isWorkerLogVisible; /// The notes list has nothing to show a detail pane for, so the pane goes away and /// the editor takes the space instead of leaving an empty column beside it. public bool ShowDetails => WindowWidth >= 1100 && Tasks?.IsNotesList != true; /// Widens the tasks island over the splitter and the details column when that column /// is hidden — a fixed-width ColumnDefinition keeps its space even with an invisible child. public int TasksColumnSpan => ShowDetails ? 1 : 3; public bool ShowLists => WindowWidth >= 780; private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false }; private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false }; [ObservableProperty] private string? _primeStatus; private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false }; [RelayCommand] private void FocusSearch() => Lists?.RequestFocusSearch(); [RelayCommand] private void FocusAddTask() => Tasks?.RequestFocusAddTask(); public async Task ToggleSelectedDoneAsync() { if (Tasks?.SelectedTask is { } row) await Tasks.ToggleDoneCommand.ExecuteAsync(row); } partial void OnWindowWidthChanged(double value) { NotifyDetailsVisibility(); OnPropertyChanged(nameof(ShowLists)); } private void NotifyDetailsVisibility() { OnPropertyChanged(nameof(ShowDetails)); OnPropertyChanged(nameof(TasksColumnSpan)); } public void OnWorkerLogReceived(WorkerLogEntry entry) { var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm"); WorkerLogText = $"{hhmm} · {entry.Message}"; WorkerLogLevel = entry.Level; IsWorkerLogVisible = true; _clearTimer.Stop(); _clearTimer.Start(); } public void ClearWorkerLog() { IsWorkerLogVisible = false; WorkerLogText = null; } // Surfaces a UI-originated failure in the footer status strip (same line as the // worker log), color-coded as an error and auto-cleared by _clearTimer. public void FlashFooterError(string message) => FlashFooter(message, WorkerLogLevel.Error); // Same strip, for an action that succeeded and has something to report ("3 new tasks"). // The strip has always carried a level — routing a success through FlashFooterError would // paint a good outcome red, which reads as a failure. public void FlashFooterSuccess(string message) => FlashFooter(message, WorkerLogLevel.Success); private void FlashFooter(string message, WorkerLogLevel level) { WorkerLogText = $"{DateTime.Now:HH:mm} · {message}"; WorkerLogLevel = level; IsWorkerLogVisible = true; _clearTimer.Stop(); _clearTimer.Start(); } private void OnPrimeFired(PrimeFiredEvent evt) { var when = evt.FiredAt.LocalDateTime.ToString("HH:mm"); PrimeStatus = evt.Success ? $"✓ Primed Claude at {when}" : $"⚠ Prime failed: {evt.Message}"; _primeStatusTimer.Stop(); _primeStatusTimer.Start(); } public void OnPlanningMergeConflict( string planningTaskId, string subtaskId, IReadOnlyList conflictedFiles, bool externallyDriven) { // Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post). if (externallyDriven) { // An MCP session (review_task/continue_merge) is driving this merge — it owns // resolution. Auto-opening the resolver here raced with the session's own writes // (two parties editing the same shared checkout at once); show a banner instead and // leave the resolver reachable only via a deliberate click. _externalMergeConflicts[planningTaskId] = subtaskId; IsExternalMergeBannerVisible = true; return; } // A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge. _ = OpenPlanningConflictAsync(planningTaskId, subtaskId); } public void OnPlanningMergeAborted(string planningTaskId, string? reason = null) { ClearExternalMergeConflict(planningTaskId); // A deliberate abort/conflict pause and a real merge failure both flow through this // event; only the latter carries a reason worth surfacing in the footer strip. if (!string.IsNullOrWhiteSpace(reason)) FlashFooterError(reason); } public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId); // Wired to Worker.OperationProgressEvent; also called directly by tests. A foreign opKey // (merge phases, worktree cleanup, ...) is left untouched — this only tracks the // startup-recovery channel. public void OnOperationProgress(string opKey, string phase, int current, int total) { if (opKey != StartupRecoveryOpKey) return; _startupRecoveryText = total > 0 ? $"{Loc.T("ops.worker.startupRecovery")} ({current}/{total})" : Loc.T("ops.worker.startupRecovery"); OnPropertyChanged(nameof(ConnectionText)); } private void ClearExternalMergeConflict(string planningTaskId) { _externalMergeConflicts.Remove(planningTaskId); IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0; } /// Re-syncs the external-merge banner from the worker on (re)connect — the /// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is /// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active, /// so a session that died mid-merge without cleaning up doesn't leave a stale banner up. private async Task RefreshExternalMergeConflictsAsync() { if (Worker is null) return; IReadOnlyList active; try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); } catch { return; } _externalMergeConflicts.Clear(); foreach (var c in active) _externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId; IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0; } [RelayCommand] private Task OpenExternalMergeConflictAsync() { if (_externalMergeConflicts.Count == 0) return Task.CompletedTask; var (planningTaskId, subtaskId) = _externalMergeConflicts.First(); return OpenPlanningConflictAsync(planningTaskId, subtaskId); } private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId) { if (ConflictResolverFactory is null || Dialogs is null) return; var vm = ConflictResolverFactory(subtaskId); var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId); if (hasConflicts) await Dialogs.ShowConflictResolverAsync(vm); } // For tests only — does NOT wire up events. internal IslandsShellViewModel() { } public IslandsShellViewModel( ListsIslandViewModel lists, TasksIslandViewModel tasks, DetailsIslandViewModel details, IWorkerClient worker, UpdateCheckService updateCheck, InstallerLocator installerLocator, WorkerLocator workerLocator, IDbContextFactory dbFactory, Func worktreesOverviewVmFactory, Func weeklyReportVmFactory, Func usageMonitorVmFactory, Func mergeVmFactory, Func repoImportVmFactory, MissionControlViewModel missionControl, UsagePillViewModel usagePill) { Lists = lists; Tasks = tasks; Details = details; Worker = worker; MissionControl = missionControl; UsagePill = usagePill; UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor(); MissionControl.OpenInApp = id => _ = RevealTaskAsync(id); MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null); MissionControl.OpenRequested = OpenMissionControl; MissionControl.ErrorReported += FlashFooterError; // Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes. MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips(); _updateCheck = updateCheck; _installerLocator = installerLocator; _workerLocator = workerLocator; _dbFactory = dbFactory; _worktreesOverviewVmFactory = worktreesOverviewVmFactory; _weeklyReportVmFactory = weeklyReportVmFactory; _usageMonitorVmFactory = usageMonitorVmFactory; _mergeVmFactory = mergeVmFactory; _repoImportVmFactory = repoImportVmFactory; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource); Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(TasksIslandViewModel.IsNotesList)) NotifyDetailsVisibility(); }; Tasks.ErrorReported += FlashFooterError; Lists.ErrorReported += FlashFooterError; Lists.SuccessReported += FlashFooterSuccess; Tasks.OpenConPtySessionRequested += taskId => { OpenMissionControl(); _ = MissionControl.OpenConPtySessionAsync(taskId); }; Tasks.OpenQuickClaudeSessionRequested += dir => { OpenMissionControl(); _ = MissionControl.OpenAdHocConPtySessionAsync(dir); }; Tasks.OpenPlanningConPtyRequested += (taskId, resume) => { OpenMissionControl(); _ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume); }; Lists.LetClaudeHandleRequested += req => { OpenMissionControl(); _ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds, req.TriageModel); }; Lists.MergeReviewsRequested += req => { OpenMissionControl(); _ = MissionControl.OpenMergeReviewConPtySessionAsync(req.ListId, req.TaskIds); }; Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Tasks.OpenListSettingsRequested += (_, _) => { if (Lists.SelectedList is { } row) Lists.OpenListSettingsCommand.Execute(row); }; Tasks.LetClaudeHandleRequested += (_, _) => { if (Lists.SelectedList is { } row) Lists.LetClaudeHandleListCommand.Execute(row); }; Tasks.MergeReviewsRequested += (_, _) => Lists.MergeReviewsCommand.Execute(null); Details.ErrorReported += FlashFooterError; Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail"); Details.DeleteFromList = row => { Tasks.LoadForList(Lists.SelectedList); _ = Lists.RefreshCountsAsync(); return System.Threading.Tasks.Task.CompletedTask; }; Worker.PropertyChanged += (_, e) => { if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting)) { if (Worker.IsConnected) _startupRecoveryText = null; OnPropertyChanged(nameof(ConnectionText)); OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp)); } }; Worker.OperationProgressEvent += OnOperationProgress; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted; Worker.PlanningCompletedEvent += OnPlanningMergeCompleted; Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync(); Worker.PrimeFired += OnPrimeFired; _clearTimer.Elapsed += (_, _) => { if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog(); else Dispatcher.UIThread.Post(ClearWorkerLog); }; _primeStatusTimer.Elapsed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null); _connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => { if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync(); }); _connectTimer.Start(); _ = Lists.LoadAsync(); _updateCheck.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus)) { RefreshBannerFromStatus(); } }; _updateCheck.Op.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged(); }; // Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is // pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties // on the calling thread. From a threadpool thread the final `ShowIndicator=false` never // reaches the binding, so the indicator froze on the last tick ("Checking… 00:03"). _ = StartupUpdateCheckAsync(); } private async Task StartupUpdateCheckAsync() { try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { } } public void Dispose() { if (Worker is not null) Worker.OperationProgressEvent -= OnOperationProgress; _clearTimer.Stop(); _clearTimer.Dispose(); _connectTimer.Stop(); _connectTimer.Dispose(); _primeStatusTimer.Stop(); _primeStatusTimer.Dispose(); } private void RefreshBannerFromStatus() { switch (_updateCheck.LastCheckStatus) { case UpdateCheckStatus.UpdateAvailable: if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; } UpdateBannerLatestVersion = _updateCheck.LatestVersion; IsUpdateBannerVisible = true; break; case UpdateCheckStatus.UpToDate: IsUpdateBannerVisible = false; break; case UpdateCheckStatus.CheckFailed: break; } } [RelayCommand] private void OpenMissionControl() { if (Dialogs is not null && MissionControl is not null) Dialogs.ShowMissionControl(MissionControl); } /// Number of Mission Control ConPTY panes currently open — the main window's /// close guard asks for confirmation whenever this is greater than zero. public int OpenMissionControlSessionCount => MissionControl?.ConPtySessions.Count ?? 0; // Pure decision extracted for testability — closing the main window kills every open // Mission Control pane's `claude` process (ConPtyPaneViewModel.Dispose), so ask first // rather than let it happen silently. internal static bool RequiresCloseConfirmation(int openMissionControlSessionCount) => openMissionControlSessionCount > 0; private void SyncInteractiveSessionChips() { if (MissionControl is null || Tasks is null) return; Tasks.SyncInteractiveSessions( MissionControl.ConPtySessions .Select(s => s.TaskId) .Where(id => !string.IsNullOrEmpty(id)) .Select(id => id!)); } [RelayCommand] private async Task OpenAbout() { var vm = new AboutModalViewModel(); vm.ErrorReported += FlashFooterError; if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm); } // One shared VM so the sheets are parsed once and the window keeps its selection/pin // across reopens; the window itself is single-instance in the dialog service. private CheatSheetsViewModel? _cheatSheets; [RelayCommand] private void OpenCheatSheet(string? key) { if (Dialogs is null) return; _cheatSheets ??= new CheatSheetsViewModel(); if (!string.IsNullOrEmpty(key)) _cheatSheets.Select(key); Dialogs.ShowCheatSheets(_cheatSheets); } [RelayCommand] private async Task OpenLogVisualizer() { if (Dialogs is null || Worker is null) return; var vm = new LogVisualizerViewModel(Worker); await vm.RefreshAsync(); await Dialogs.ShowLogVisualizerAsync(vm); } 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 WorkerConnectionModalViewModel(_workerLocator, _installerLocator); if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm); } // The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a // disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection, // the opposite of what it says. [RelayCommand] private Task OpenWorkerConnectionHelp() => CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask; [RelayCommand] private async Task OpenRepoImport() { if (Dialogs is null || _repoImportVmFactory is null) return; var vm = _repoImportVmFactory(); await vm.LoadAsync(); await Dialogs.ShowRepoImportAsync(vm); if (Lists is not null) await Lists.LoadAsync(); } private bool _worktreesOverviewOpen; [RelayCommand] private async Task OpenWorktreesOverviewGlobalAsync() { if (Dialogs is null || _worktreesOverviewOpen) return; _worktreesOverviewOpen = true; try { var vm = _worktreesOverviewVmFactory(); vm.ErrorReported += FlashFooterError; vm.Configure(null, null); await vm.LoadAsync(); await Dialogs.ShowWorktreesOverviewAsync(vm); } finally { _worktreesOverviewOpen = false; } } private bool _weeklyReportOpen; [RelayCommand] private async Task OpenWeeklyReport() { if (Dialogs is null || _weeklyReportOpen) return; _weeklyReportOpen = true; try { var vm = _weeklyReportVmFactory(); await vm.InitializeAsync(); await Dialogs.ShowWeeklyReportAsync(vm); } finally { _weeklyReportOpen = false; } } private bool _usageMonitorOpen; [RelayCommand] private async Task OpenUsageMonitor() { if (Dialogs is null || _usageMonitorOpen) return; _usageMonitorOpen = true; try { var vm = _usageMonitorVmFactory(); vm.ErrorReported += FlashFooterError; // Show first, load after: the initial transcript scan takes seconds, and awaiting it // here left the pill looking unresponsive until the window finally appeared. vm.BeginLoad(); await Dialogs.ShowUsageMonitorAsync(vm); } finally { _usageMonitorOpen = false; } } private bool CanCheckForUpdates() => !_updateCheck.Op.IsRunning; [RelayCommand(CanExecute = nameof(CanCheckForUpdates))] private async Task CheckForUpdatesAsync() { await _updateCheck.CheckNowAsync(CancellationToken.None); } // How long to wait after launching the new worker process before deciding it survived // (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix). private const int RestartSurvivalCheckMs = 2000; // Test seam: overrides the real kill-and-relaunch body, so tests can assert the // success/failure footer routing without spawning a real worker process. internal Func? RestartWorkerOperation { get; set; } [RelayCommand] private async Task RestartWorkerAsync() { try { await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))(); FlashFooterSuccess("Worker restarted."); } catch (Exception ex) { FlashFooterError($"Restart failed: {ex.Message} — check the worker log."); } } private void RestartWorkerService() { // Prefer the exe path of the worker that's actually running: _workerLocator.Find() // walks up from AppContext.BaseDirectory looking for install.json and falls back to // the registry's InstallLocation otherwise — on a debug build launched from the repo // there's no install.json above it, so it returns the *installed* worker's path. That // never matches the running debug process, so KillMatchingWorkerProcesses silently // skips it (it only kills a process whose path equals the given exe) and this either // launches a second worker that immediately dies on the already-bound port, or leaves // the real one running untouched while nothing visibly changes. var exe = RunningWorkerExePath() ?? _workerLocator.Find(); if (exe is null) throw new InvalidOperationException("Worker executable not found."); KillMatchingWorkerProcesses(exe); var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); if (started is not null && started.WaitForExit(RestartSurvivalCheckMs)) throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})"); } private static string? RunningWorkerExePath() { foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) { try { if (p.MainModule?.FileName is { } path) return path; } catch { /* inaccessible */ } finally { p.Dispose(); } } return null; } // Only kill the worker belonging to THIS installation — not any other // ClaudeDo.Worker on the machine (e.g. a second install). private static void KillMatchingWorkerProcesses(string exe) { var exeFull = System.IO.Path.GetFullPath(exe); foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) { try { var path = p.MainModule?.FileName; if (path is not null && !string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase)) continue; p.Kill(entireProcessTree: true); p.WaitForExit(10000); } catch { /* may have exited or be inaccessible */ } finally { p.Dispose(); } } } // Test seam: overrides the real kill-worker + close-window body. internal Action? ShutdownOperation { get; set; } [RelayCommand] private async Task ShutdownAsync() { if (Dialogs is null) return; var confirmed = await Dialogs.ConfirmAsync(Loc.T("vm.shell.shutdownConfirm")); if (!confirmed) return; (ShutdownOperation ?? ShutdownWorkerAndCloseApp)(); } private void ShutdownWorkerAndCloseApp() { var exe = _workerLocator.Find(); if (exe is not null) KillMatchingWorkerProcesses(exe); // Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks // every open window to close and backs off if any of them cancels — and // MissionControlWindow always cancels its own close (it hides instead, see // MissionControlWindow.OnClosing), so the process never actually exited. Shutdown() // force-closes every window, ignoring that cancellation. if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown(); } private bool _updatingClaudeCli; private bool CanUpdateClaudeCli() => !_updatingClaudeCli; [RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))] private async Task UpdateClaudeCliAsync() { _updatingClaudeCli = true; UpdateClaudeCliCommand.NotifyCanExecuteChanged(); try { var dto = Worker is null ? null : await Worker.GetClaudeBinAsync(); if (dto is null) { FlashFooterError("Update failed: worker offline."); return; } var (success, message) = await RunClaudeUpdateAsync(dto); if (success) FlashFooterSuccess(message); else FlashFooterError(message); } finally { _updatingClaudeCli = false; UpdateClaudeCliCommand.NotifyCanExecuteChanged(); } } private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto) { var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value); if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found."); var psi = resolved.IsShim ? BuildShimUpdatePsi(resolved.Path) : new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } }; psi.UseShellExecute = false; psi.RedirectStandardOutput = true; psi.RedirectStandardError = true; psi.CreateNoWindow = true; using var process = new System.Diagnostics.Process { StartInfo = psi }; var stdout = new System.Text.StringBuilder(); var stderr = new System.Text.StringBuilder(); process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); }; process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); }; try { process.Start(); } catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); var timedOut = false; // On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the // async output readers drain/complete naturally (mirrors VerifyCommandRunner). await using var ctr = cts.Token.Register(() => { timedOut = true; try { process.Kill(entireProcessTree: true); } catch { /* already exited */ } }); await process.WaitForExitAsync(CancellationToken.None); return timedOut ? (false, "Update failed: timed out after 5 minutes.") : SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString()); } private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath) { var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" }); return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments }; } // Pure so a test can drive it with fake process output instead of spawning a real `claude`. internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr) { if (exitCode == 0) return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date."); var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? ""; return (false, $"Update failed (exit {exitCode}): {detail}"); } private static string? LastNonEmptyLine(string text) => text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0); [RelayCommand] private void DismissBanner() { _bannerDismissedThisSession = true; IsUpdateBannerVisible = false; } [RelayCommand] private void UpdateNow() { var path = _installerLocator.Find(); if (path is null) return; try { // WorkingDirectory must NOT stay empty: the child would inherit ours (\app) // and its locked current directory blocks the installer's own app\ rename. System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true, WorkingDirectory = System.IO.Path.GetTempPath(), }); Environment.Exit(0); } catch { // Intentionally silent — if this fails there's nothing useful to show. } } }