Skip to content

Aggregate remote daemons into the desktop app's rail, tray, and launcher - #799

Merged
alexeyzimarev merged 66 commits into
mainfrom
capacitor/agent-9b445321a3644a
Sep 7, 2026
Merged

alexeyzimarev merged 66 commits into
mainfrom
capacitor/agent-9b445321a3644a

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

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.Models wire-contract project plus ServerConnectionService, 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) and HomeViewModel'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.Unit 1465/1465, Capacitor.Cli.Core.Tests.Unit 2996 passed / 9 skipped, Capacitor.Remote.Models.Tests.Unit 4/4; dotnet publish -c Release IL2026/IL3050 grep prints nothing. With no other daemons registered, defaulted wiring reproduces today's behavior, pinned by NullRemoteKeepsLegacyBehavior and the untouched pre-existing suites.

🤖 Generated with Claude Code

alexeyzimarev and others added 30 commits September 6, 2026 11:45
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>
)

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>
alexeyzimarev and others added 26 commits September 7, 2026 11:09
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>
@alexeyzimarev
alexeyzimarev merged commit 0b2ab4e into main Sep 7, 2026
14 of 16 checks passed
@alexeyzimarev
alexeyzimarev deleted the capacitor/agent-9b445321a3644a branch September 7, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop shell: show and control hosted agents running on other daemons

1 participant