Repository navigation
Aggregate remote daemons into the desktop app's rail, tray, and launcher - #799
Merged
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task.Run in RestartAsync passes CancellationToken.None explicitly: the loop's lifetime is _loopCts.Token, not the caller's ct, and CA2016 otherwise flags the unforwarded parameter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…708) closed.Task's result was awaited but discarded, so a connected-then- closed hub left Status stale until the next dial; now it publishes Retrying with the close reason unless the loop itself is cancelling. DisposeAsync no longer disposes _restartGate, matching DaemonClientService: a RestartAsync racing shutdown must not throw ObjectDisposedException on the gate. HubTestHost now caps ShutdownTimeout at 500ms — Kestrel's default 30s graceful drain made the covering test's timing nondeterministic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Concat serialized the merged trigger stream, so an in-flight refresh's WaitAsync(0) could never contend and overlapping pings ran one full sequential re-fetch each instead of coalescing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RailRepoViewModel's RootPath now holds the repo-identity group key, not a resolved path — a merged repo can span more than one clone, so it no longer feeds CheckoutLabel formatting. RailWorktreeViewModel resolves a checkout's main-ness against its own path instead, and compares selection/pending sets by AgentRow.Id, not the cache's scoped key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RootPath is the internal group key now (repo:/path:/daemon: prefixed), unfit for a tooltip once a repo can merge more than one clone. Also drops the rail's dead daemon dependency and two banned spec citations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProjectAggregate upgrades a local Stopped/Idle verdict to Running once the server lane reports live agents; SummaryFrom seeds the empty-cache case with StartWith(0) since Rows.Connect() emits nothing until an edit has actually run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
canLaunch/FindMachine and StartAsync now re-verify the selected remote daemon's owner and connected state, since ReactiveCommand. Execute() does not itself gate on CanExecute in this ReactiveUI version. Remote->local also restores/revalidates the local repo and vendor instead of leaking the prior remote selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RefreshAfterReauthAsync also awaited RestartAsync directly, so a completed sign-in restarted the lane twice (RestartAsync serializes, not coalesces) — a visible flicker. The SignInCompleted subscription is now the only trigger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A repo picked while a remote machine was selected fell through to the local HarnessByRepo/DefaultVendor rule, which could set a vendor the remote machine doesn't host; ChooseHarnessAsync also persisted a remote pick into the local, this-machine-scoped store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HttpFetch collapsed a 401/auth-status failure into a plain null, so the sign-in surface never heard about it. The fetch now returns a RemoteFetch(Rows, Unauthorized); RemoteAgentsService's onUnauthorized calls ServerConnectionService.ParkSignedOut, and HomeViewModel ORs the lane's SignedOut state into its sign-in affordance so it surfaces even with the local daemon down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenInWeb's server URL was overwritten by every local snapshot, so a remote row's OpenInWeb could target the local daemon's (different) server. OpenInWebRemote now always uses the app profile's fixed URL, never the mutable snapshot-fed one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MachinePickerVisible hid the picker the moment the owned-remote list emptied, trapping an already-selected remote machine with no way to switch back to local. Visibility now also stays true while a remote selection is live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RowExists looked up "local:{id}"/"remote:{id}" directly, but directory
keys preserve the incoming id's spelling verbatim — a dashed-Guid row
never matched the "N"-normalized pending id. It now scans rows and
compares under NormalizeAgentId, mirroring ConfirmPendingRows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NoRemoteAgents' comment claimed remote wiring "lands in a later task", which is false — it is already wired. The cold-start test's comment narrated abandoned timing approaches instead of what the test pins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each DTO's full [JsonPropertyName] vocabulary now drives the Contains assertions from a string array, so a missed key is a one-line addition instead of a silent gap. Also covers the AccessGrant nested keys and a nullable-field representative for each DTO. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generation now bumps on every Connected status, not only a subject change, and onUnauthorized fires only after that generation still matches — a fetch issued before the latest connect can no longer park a lane that connect has already superseded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DiagnoseAsync can outlast ParkSignedOut (or a negotiate 401), letting a stale Connected publish silently overwrite the park it raced. A publish epoch, bumped by every park and checked before publishing, closes the window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local availability alone can never clear "awaiting" when the local daemon sits behind a different server than the app's own lane, so the sign-in banner could stay parked as finishing forever. A terminal lane outcome (Connected or SignedOut) now clears it too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only Connected publishes were epoch-checked; a Reconnecting/closed/ generic-catch Retrying from a park-superseded attempt could still overwrite a parked SignedOut. Every publish in a connect attempt now shares one epoch captured at Connecting, and ParkSignedOut gained an overload that parks only when a caller's own captured epoch is still current — closing the race where a delayed decision outlives the state it was made under. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RemoteAgentsService's own generation check releases its lock before invoking onUnauthorized, so a Connected can land in the gap and a now- stale park would still fire. onUnauthorized now carries the lane epoch its fetch started under, and ServerConnectionService. ParkSignedOut(int) re-checks it atomically at the actual park — without inverting the two services' lock order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RecordPendingLaunch rendered a buffered failure before checking whether the row already existed, so a failure that arrived while the launch call was still in flight could surface even after the row itself had already confirmed success. The row check now runs first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
They said the machine picker is the one place ownership is checked; StartAsync separately re-verifies it right before the wire request is built, and is the actual boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An owned remote daemon can share the local daemon's name (different servers), so matching by name equality could never select it. The view now passes the clicked option's own IsLocal straight through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only canLaunch read RemoteAvailabilityFor; the banner, tooltip, and sign-in visibility still read the local daemon's state, so a remote selection with the local daemon down showed "Press Start daemon", and a lane loss under a remote selection showed nothing. Every notice surface now reads the same selection-aware availability canLaunch gates on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The badge's horizontal StackPanel gave the title unbounded width, so CharacterEllipsis never trimmed a long title, local rows included. A Grid with a star title column and an auto badge column bounds it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suppression only ever hid the remote twin's rows; the local ones, once retained past a disconnect, kept showing regardless — both rows could appear together, and a server-side termination left a stale local "Running" with nothing to override it. The recompute now owns both directions from one full local snapshot, so a proven twin suppresses local rows entirely while its own socket is down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One lock owns the generation, the admitted loop's cancellation source and every publish, so a park cancels the loop it decided against and every superseded loop is silent. A restart advances the generation, which is what makes the epoch a caller captured off a Connected status name that connection and no later one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Absent server data is not evidence an agent ended: a private agent is never registered, and the registry has a seed gap after every connect. Precedence is pairwise, so an unpaired local row survives the twin's disconnect as history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A directory Add landing between the leading check and the registration finds nothing pending, so it clears nothing; the row is what settles the launch, so it is looked for again once the entry exists and before any failure is rendered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its messages are about the local daemon, so under a remote pick they outranked a notice pipeline that had already decided the selected machine was healthy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A same-id agent on another daemon is a different agent: proving one daemon's registry twin establishes correspondence with that daemon's rows and no others. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…321a3644a # Conflicts: # docs/CHANGES.md
This was referenced Sep 7, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #708 — AI-2371
What & why
The desktop app renders only what the local daemon's socket carries, so agents hosted on the user's other machines are invisible outside the web UI. This gives the app a long-lived authenticated lane to the server (a new
Capacitor.Remote.Modelswire-contract project plusServerConnectionService, absorbing the per-launch client) and merges the server registry's agents into the existing surfaces: the rail groups by repository identity with machine badges (remote rows deep-link to the web), the tray aggregates both lanes, and the launcher gains a machine picker over the signed-in user's own daemons with launch outcomes correlated — a consent denial on the target machine renders as a readable explanation instead of silence.Where to look
AgentDirectory's fail-open twin dedup (an agent renders twice on uncertain identity, never hidden) andHomeViewModel's ownership re-verification at launch time — the two invariants recorded in docs/CHANGES.md. The design spec and plan ride under docs/superpowers/.Verification
On the final tree:
Capacitor.App.Tests.Unit1465/1465,Capacitor.Cli.Core.Tests.Unit2996 passed / 9 skipped,Capacitor.Remote.Models.Tests.Unit4/4;dotnet publish -c ReleaseIL2026/IL3050 grep prints nothing. With no other daemons registered, defaulted wiring reproduces today's behavior, pinned byNullRemoteKeepsLegacyBehaviorand the untouched pre-existing suites.🤖 Generated with Claude Code