Repository navigation
Tray presence, agents list, per-agent stop, pause launches (desktop supervisor slice 2) #474
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
alexeyzimarev
merged 29 commits into
main
from
alexeyzimarev/ai-1651-tray-presence-agents-list-stop-desktop-supervisor-slice-2
Aug 7, 2026
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
a2bb618
AI-1651 design: tray presence, agents list, stop (desktop supervisor …
alexeyzimarev 7354fcc
AI-1651 spec: address reviewer round-2 findings
alexeyzimarev 74c1ec1
AI-1651 spec: address reviewer round-3 findings
alexeyzimarev 2c6c825
AI-1651 spec: address reviewer round-4 findings
alexeyzimarev 6fe5742
AI-1651 spec: address reviewer round-5 findings
alexeyzimarev dc3be25
AI-1651 spec: address reviewer round-6 findings
alexeyzimarev 7bf8781
AI-1651 implementation plan: 7 tasks from the signed-off spec
alexeyzimarev ec6d95f
feat: LocalControlOps one-shot stop/consent IPC operations
alexeyzimarev 378fd76
feat: tray state projection and menu model
alexeyzimarev ecf3b95
fix: defend TrayViewModel's OAPH seed against a non-synchronous IPaus…
alexeyzimarev aa75f38
feat: pause-launches controller with serialized consent lane
alexeyzimarev d261a8e
fix: never leak the PauseController lane on an unmapped exception
alexeyzimarev b7b9d0d
feat: agent stop and open-in-web actions with banner surfacing
alexeyzimarev 1880646
feat: agents grid with uptime, actions and transient banner
alexeyzimarev 408aec1
fix: dispose recreated AgentRowViewModel instances via DisposeMany
alexeyzimarev f183064
feat: tray icon and native menu adapter
alexeyzimarev 8f21371
fix: assign pause item IsEnabled after Command in TrayMenuBuilder
alexeyzimarev 26b67a6
feat: hide-to-tray lifecycle and tray composition
alexeyzimarev d1db4b5
fix: flag QuitInProgress on every shutdown pass
alexeyzimarev 723dbe2
test: close final-review §12 coverage gaps; fix two comments
alexeyzimarev c107f37
test: carry capability into the disconnected push to isolate connected
alexeyzimarev 8b28078
fix: stable Agents binding, scrollable grid, serialized notifier, sto…
alexeyzimarev 6fdfc4a
polish: main window status area, action visibility, grid density
alexeyzimarev 07a26d4
polish: align section divider; worktree-aware repo labels
alexeyzimarev 7bebbd6
fix: kick pause refresh from NeedsUpdate (macOS never raises Opening)…
alexeyzimarev 7edeaa3
fix: subscribe the uptime ticker on the UI thread; off-thread Interva…
alexeyzimarev a26dfea
polish: brand tray/window icon from the product mark; friendly app ex…
alexeyzimarev e7a403c
feat: confirm-then-force stop for review participants; toast notifica…
alexeyzimarev 5ba016d
polish: repo labels show the repo name only; ellipsis-trim grid cells
alexeyzimarev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
399 changes: 399 additions & 0 deletions
399
docs/superpowers/plans/2026-08-06-ai1651-tray-agents-stop.md
Large diffs are not rendered by default.
Oops, something went wrong.
480 changes: 480 additions & 0 deletions
480
docs/superpowers/specs/2026-08-06-ai1651-tray-agents-stop-design.md
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| using System.Collections.Immutable; | ||
| using System.Reactive.Linq; | ||
| using System.Reactive.Subjects; | ||
| using Capacitor.Cli.Core.LocalIpc; | ||
|
|
||
| namespace Capacitor.App.Services; | ||
|
|
||
| /// Shared by the tray menu and the main-window rows (spec §7: one code path) — the single place | ||
| /// that calls ILocalControlOps.StopAgentAsync and builds the open-in-web URL, so both surfaces | ||
| /// get identical in-flight gating, toast text, and link construction. No local cache mutation: | ||
| /// a stopped agent's disappearance comes only from the next daemon snapshot (spec §7). | ||
| public sealed class AgentActionService { | ||
| const string DaemonUnreachableReason = "daemon_unreachable"; | ||
| const string UnreachableCopy = "The daemon is not reachable"; | ||
|
|
||
| readonly ILocalControlOps _ops; | ||
| readonly IAppNotifier _notifier; | ||
| readonly IUrlOpener _opener; | ||
| readonly CancellationToken _shutdownToken; | ||
| readonly Func<string, Task<bool>> _confirmForceStop; | ||
|
|
||
| // ONE lock guards both the in-flight set and the latest server URL — both are cheap, | ||
| // occasional writes, never held across the async stop call itself. | ||
| readonly Lock _lock = new(); | ||
| ImmutableHashSet<string> _inFlight = ImmutableHashSet<string>.Empty; | ||
| string? _serverUrl; | ||
|
|
||
| readonly BehaviorSubject<IReadOnlySet<string>> _stopsInFlight; | ||
|
|
||
| /// <param name="confirmForceStop"> | ||
| /// The confirm-then-force seam for a protected kind (decision 5): invoked with the agent's | ||
| /// label, resolves true to proceed with force:true, false to no-op. The composed delegate is | ||
| /// UI glue (a dialog Window) — this service only awaits it, never marshals to the UI thread | ||
| /// itself; that is the caller's job (App.axaml.cs, via Dispatcher.UIThread.InvokeAsync). | ||
| /// </param> | ||
| public AgentActionService( | ||
| ILocalControlOps ops, IAppNotifier notifier, IUrlOpener opener, | ||
| IObservable<DaemonStatusDto> snapshots, CancellationToken shutdownToken, | ||
| Func<string, Task<bool>> confirmForceStop) { | ||
| _ops = ops; | ||
| _notifier = notifier; | ||
| _opener = opener; | ||
| _shutdownToken = shutdownToken; | ||
| _confirmForceStop = confirmForceStop; | ||
| _stopsInFlight = new BehaviorSubject<IReadOnlySet<string>>(_inFlight); | ||
|
|
||
| // Held for the service's lifetime, same as TrayViewModel's constructor-scoped | ||
| // subscriptions — this service is a singleton for the app's lifetime, never disposed | ||
| // mid-run, so there is no unsubscribe seam to wire up. | ||
| snapshots.Subscribe(s => { lock (_lock) _serverUrl = s.Daemon.ServerUrl; }); | ||
| } | ||
|
|
||
| /// Replay-1, starts empty. Consumed by TrayViewModel (and later the main-window grid) to | ||
| /// disable a Stop control while its op is pending (spec §7). | ||
| public IObservable<IReadOnlySet<string>> StopsInFlight => _stopsInFlight.AsObservable(); | ||
|
|
||
| /// A kind other than exactly "agent" is protected (KindText vocabulary: agent|review| | ||
| /// review-flow). Any non-"agent" value — including one this build doesn't recognise — fails | ||
| /// safe as protected, mirroring the daemon's own `Kind != LaunchKind.Default` check and the | ||
| /// CLI's `IsProtectedKind`. | ||
| internal static bool IsProtectedKind(string kind) => kind is not "agent"; | ||
|
|
||
| /// Per-id gating: a second Stop for the same id no-ops while one is pending — including while | ||
| /// a protected kind's confirm-then-force dialog is still open, since the id stays in-flight | ||
| /// for the whole RunStopAsync call — different ids run concurrently (spec §7, decision 5). | ||
| /// Never throws — this is a UI command target, not a Task the caller awaits. | ||
| public void RequestStop(string agentId, string label, string kind) { | ||
| lock (_lock) { | ||
| if (_inFlight.Contains(agentId)) return; | ||
| _inFlight = _inFlight.Add(agentId); | ||
| _stopsInFlight.OnNext(_inFlight); | ||
| } | ||
| _ = Task.Run(() => RunStopAsync(agentId, label, kind)); | ||
| } | ||
|
|
||
| async Task RunStopAsync(string agentId, string label, string kind) { | ||
| try { | ||
| var force = false; | ||
| if (IsProtectedKind(kind)) { | ||
| // false (dialog cancelled) is a quiet no-op — the finally below still clears the | ||
| // in-flight entry, but nothing is sent to the daemon and no toast is shown. | ||
| if (!await _confirmForceStop(label).ConfigureAwait(false)) return; | ||
| force = true; | ||
| } | ||
|
|
||
| var result = await _ops.StopAgentAsync(agentId, force, _shutdownToken).ConfigureAwait(false); | ||
| switch (result.Status) { | ||
| case "stopped": break; // no toast — disappearance from the next snapshot is the confirmation | ||
| case "failed": _notifier.Notify($"Couldn't stop {label}"); break; | ||
| case "skipped": _notifier.Notify($"The daemon declined to stop {label}"); break; | ||
| case "error": | ||
| // The daemon's Error text may name an id or CLI-speak ("Pass --force…") the | ||
| // app cannot act on (spec §7) — never surfaced verbatim in the UI. The full | ||
| // text goes to stderr only; the toast stays generic. With force-after-confirm | ||
| // above, a legitimate protected-refusal Error should no longer occur — this | ||
| // now covers unknown-id/stale cases. | ||
| Console.Error.WriteLine($"kcap: stop {agentId} failed: {result.Error}"); | ||
| _notifier.Notify($"Couldn't stop {label}"); | ||
| break; | ||
| } | ||
| } catch (OperationCanceledException) { | ||
| // Deliberate shutdown: absorbed quietly, no toast, no log. | ||
| } catch (LocalControlOpsException ex) { | ||
| _notifier.Notify(ex.Reason == DaemonUnreachableReason ? UnreachableCopy : $"Couldn't stop {label}: {ex.Message}"); | ||
| } catch (Exception ex) { | ||
| // An unmapped exception still gets a toast (never a silent drop) — AppNotifier.Notify | ||
| // covers both the toast AND stderr (spec §11), so this one call satisfies both | ||
| // without a separate Console.Error write. The finally below still runs either way. | ||
| _notifier.Notify($"Couldn't stop {label}: {ex.Message}"); | ||
| } finally { | ||
| // A completion into an already-vanished row/entry is naturally a no-op — the set | ||
| // just loses a member nothing is rendering against anymore. | ||
| lock (_lock) { | ||
| _inFlight = _inFlight.Remove(agentId); | ||
| _stopsInFlight.OnNext(_inFlight); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Opens {ServerUrl trimmed of trailing '/'}/agents/{Uri.EscapeDataString(id)} from the | ||
| /// latest snapshot in the default browser (spec §7). Never throws. | ||
| public void OpenInWeb(string agentId) { | ||
| string? serverUrl; | ||
| lock (_lock) serverUrl = _serverUrl; | ||
|
|
||
| if (serverUrl is null) { | ||
| _notifier.Notify("Not connected to a daemon yet"); // cannot happen from live UI; defensive only | ||
| return; | ||
| } | ||
|
|
||
| var url = $"{serverUrl.TrimEnd('/')}/agents/{Uri.EscapeDataString(agentId)}"; | ||
| try { | ||
| _opener.Open(url); | ||
| } catch (Exception ex) { | ||
| _notifier.Notify($"Couldn't open the browser: {ex.Message}"); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| using System.Reactive.Linq; | ||
| using System.Reactive.Subjects; | ||
|
|
||
| namespace Capacitor.App.Services; | ||
|
|
||
| public interface IAppNotifier { | ||
| IObservable<string> Messages { get; } | ||
| void Notify(string message); | ||
| } | ||
|
|
||
| /// Replay-0: a message emitted before a subscriber attaches is lost to the UI — the accepted | ||
| /// missed-banner-while-hidden limitation (spec §11). Notify ALSO writes to Console.Error, which | ||
| /// is the only channel that survives a hidden main window (spec §11). | ||
| public sealed class AppNotifier : IAppNotifier { | ||
| readonly Subject<string> _messages = new(); | ||
|
|
||
| // Producers are concurrent Task.Run bodies (AgentActionService's per-agent stops, pause ops, | ||
| // etc.) — Rx's grammar requires OnNext calls to a single Subject be serialized (never | ||
| // overlapping), and a bare Subject does not do this itself. ONE lock around both effects also | ||
| // keeps the stderr line and the pushed message in the same relative order across threads. | ||
| readonly Lock _lock = new(); | ||
|
|
||
| public IObservable<string> Messages => _messages.AsObservable(); | ||
|
|
||
| public void Notify(string message) { | ||
| lock (_lock) { | ||
| Console.Error.WriteLine($"kcap: {message}"); | ||
| _messages.OnNext(message); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| namespace Capacitor.App.Services; | ||
|
|
||
| public sealed record PauseState(bool Checked, bool Verified, bool Busy); | ||
|
|
||
| /// Implemented in a later task; this task consumes only the contract. State is replay-1, | ||
| /// seeded with (Checked: false, Verified: false, Busy: false) — unverified until the first | ||
| /// successful refresh. | ||
| public interface IPauseController { | ||
| IObservable<PauseState> State { get; } | ||
| void RequestRefresh(); // passive; DROPPED while the lane is busy (spec §6) | ||
| void RequestToggle(bool desired); // desired checked value, single-flight + one queued slot (spec §6) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| using Capacitor.App.Views; | ||
|
|
||
| namespace Capacitor.App.Services; | ||
|
|
||
| /// Owns the app's single main window across hide-to-tray cycles (spec §9). Closing the window | ||
| /// hides it — the app keeps living in the tray — and the tray's "Open Kurrent Capacitor" re-shows | ||
| /// that same instance. A window is only ever really closed during a quit, and a later | ||
| /// ShowMainWindow then builds a fresh one from the factory: Avalonia refuses to Show() a closed | ||
| /// window, and nothing is lost because no state lives in the view (everything displayed is | ||
| /// projected from the live service). | ||
| public sealed class MainWindowCoordinator(Func<MainWindow> windowFactory) { | ||
| MainWindow? _window; | ||
|
|
||
| /// Set by App.OnShutdownRequested's FIRST (deferring) pass, so the second pass's real window | ||
| /// teardown is never cancelled by the interception below. | ||
| public bool QuitInProgress { get; set; } | ||
|
|
||
| /// The tracked window — live (visible or hidden) from the first ShowMainWindow until a real | ||
| /// close. Null outside that window; App reads it once to assign desktop.MainWindow. | ||
| public MainWindow? Window => _window; | ||
|
|
||
| public void ShowMainWindow() { | ||
| if (_window is null) { | ||
| var window = windowFactory(); // the factory shows it (App.BuildAndShowMainWindow) | ||
| window.CloseInterceptor = OnWindowClosing; | ||
| // Only a REAL close reaches this (an intercepted one is cancelled before Closed) — | ||
| // the identity check keeps a late event from a superseded window from clearing a | ||
| // newer one. | ||
| window.Closed += (_, _) => { | ||
| if (ReferenceEquals(_window, window)) _window = null; | ||
| }; | ||
| _window = window; | ||
| } | ||
|
|
||
| _window.Show(); // no-op when already visible | ||
| _window.Activate(); // bring it forward when it was merely behind other windows | ||
| } | ||
|
|
||
| /// Called from MainWindow.Closing. True → the close must be cancelled; the window is hidden | ||
| /// here instead. | ||
| public bool OnWindowClosing() { | ||
| if (QuitInProgress) return false; | ||
| _window?.Hide(); | ||
| return true; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.