Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6522dbc
Plan the remote workspace slice (#806)
alexeyzimarev Sep 15, 2026
377ca50
Name the session stream and take the Eventuous SignalR client (#806)
alexeyzimarev Sep 15, 2026
5bb3bbf
Keep the session event's timestamp trailing (#806)
alexeyzimarev Sep 15, 2026
29e370f
Read a persisted canonical event back into chat rows (#806)
alexeyzimarev Sep 15, 2026
2edfd70
Read the chat through a transcript feed and a session seam (#806)
alexeyzimarev Sep 15, 2026
7265b79
Keep the chat's projection-failure log deduplicated (#806)
alexeyzimarev Sep 15, 2026
be58b00
Tail a stream, carry the terminal and send input over the server lane…
alexeyzimarev Sep 15, 2026
75d232f
End a stream tail on lane loss and dispose the terminal subjects (#806)
alexeyzimarev Sep 15, 2026
3b1a60c
Seed a remote chat from the session detail and tail its stream (#806)
alexeyzimarev Sep 15, 2026
f9a21ac
Keep a remote seed ahead of a stream refusal and escalate the retry g…
alexeyzimarev Sep 15, 2026
1364b7c
Send the composer's text to a remote agent over the hub (#806)
alexeyzimarev Sep 15, 2026
31e408b
Pin the remote composer's refused and faulted sends (#806)
alexeyzimarev Sep 15, 2026
463dbf3
Show a remote session's chat in the desktop app (#806)
alexeyzimarev Sep 15, 2026
769de23
Acknowledge a remote send made before its seed drained (#806)
alexeyzimarev Sep 15, 2026
9e00a41
Drop the remote host's unbound cards flag (#806)
alexeyzimarev Sep 15, 2026
cd34d88
Map keystrokes to special keys and size a pane from its source (#806)
alexeyzimarev Sep 15, 2026
15cb675
Watch a remote agent's terminal read-only, sized by its source (#806)
alexeyzimarev Sep 15, 2026
fa7e73c
Unsubscribe a remote terminal whose subscribe outlived the pane (#806)
alexeyzimarev Sep 15, 2026
c6f15b5
Keep the remote terminal's replay that lands during the subscribe (#806)
alexeyzimarev Sep 15, 2026
7e10ebc
Offer a remote PTY session's terminal as a tab (#806)
alexeyzimarev Sep 15, 2026
89480db
Say what the remote terminal smoke test pins (#806)
alexeyzimarev Sep 15, 2026
79accf8
Follow an open workspace's row when its agent changes lane (#806)
alexeyzimarev Sep 15, 2026
241fe2e
Rebind to a remote row only for the same session (#806)
alexeyzimarev Sep 15, 2026
fafdcff
Fall back to the chat tab when a rebound workspace has no terminal (#…
alexeyzimarev Sep 15, 2026
64d1111
Show the server's queued prompts in a remote chat (#806)
alexeyzimarev Sep 15, 2026
88bbb63
Keep the remote queue honest about unreadable, unkeyed and stale prom…
alexeyzimarev Sep 15, 2026
650c3eb
Record the remote workspace's invariants (#806)
alexeyzimarev Sep 15, 2026
9666d3d
Report the remote terminal's viewport only while its pane shows (#806)
alexeyzimarev Sep 15, 2026
5d8b733
Keep the remote panes on screen while the lane is down (#806)
alexeyzimarev Sep 15, 2026
12ce6dc
Say why the remote feed is empty when the server refuses (#806)
alexeyzimarev Sep 15, 2026
8ba83b2
Let a test supply the lane's SignalR reconnect ladder (#806)
alexeyzimarev Sep 15, 2026
ace6425
Keep a rebind off the shell view and out of its own arming (#806)
alexeyzimarev Sep 15, 2026
f11c6c0
Run the AOT analysers over the remote wire models (#806)
alexeyzimarev Sep 15, 2026
559b83b
Say why the chat pane has no transcript when a read is refused (#806)
alexeyzimarev Sep 15, 2026
ab485c0
Give a terminal viewport back before the next attach can report one (…
alexeyzimarev Sep 15, 2026
a0271a7
Read the queue snapshot through the shared JSON shape helper (#806)
alexeyzimarev Sep 15, 2026
f1ce848
Give the special-key choice its own file (#806)
alexeyzimarev Sep 15, 2026
e0766a7
Run one tail per stream and unsubscribe on the server when it ends (#…
alexeyzimarev Sep 15, 2026
c7c077c
Keep a transcript refusal on screen until a read delivers (#806)
alexeyzimarev Sep 15, 2026
4fa435a
Let the terminal follow a session that returns after reading as ended…
alexeyzimarev Sep 15, 2026
221fd9f
Follow a dropped local row once its server twin learns the session id…
alexeyzimarev Sep 15, 2026
3e90433
Let the remote feed say how long its refusal stands (#806)
alexeyzimarev Sep 15, 2026
3e5f69f
Judge a local row's move on the directory as it stands and keep watch…
alexeyzimarev Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageVersion Include="Duende.IdentityModel" Version="8.1.0" />
<PackageVersion Include="DotNext" Version="6.7.1" />
<PackageVersion Include="DynamicData" Version="9.4.33" />
<PackageVersion Include="Eventuous.SignalR.Client" Version="0.16.5-alpha.0.22" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="MarkView.Avalonia" Version="12.2.1" />
<PackageVersion Include="MarkView.Avalonia.SyntaxHighlighting" Version="12.2.1" />
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ npm automatically selects the right native binary for your [platform](#requireme

### Desktop app (macOS)

Download `Kurrent-Capacitor-osx-arm64.dmg` from https://www.kurrent.io/download/mac (Apple silicon, macOS 15 or later), open it and drag **Kurrent Capacitor** to **Applications**. The app bundles its own `kcap` CLI and daemon: you do not need the npm install as well, and the first run offers to link `kcap` onto your terminal PATH and to install the daemon as a background service.
Download `Kurrent-Capacitor-osx-arm64.dmg` from https://www.kurrent.io/download/mac (Apple silicon, macOS 15 or later), open it and drag **Kurrent Capacitor** to **Applications**. The app bundles its own `kcap` CLI and daemon: you do not need the npm install as well, and the first run offers to link `kcap` onto your terminal PATH and to install the daemon as a background service. Sessions running on your other machines' daemons open in the app too — their chat, their prompts, and for a terminal harness a read-only view of the terminal — over the server, without a local daemon.

The app must run from the Applications folder — launched from the disk image or from Downloads it offers to move itself there first, because the terminal link and the background service point at its location.

Expand Down
22 changes: 22 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,28 @@ the lane connected in a session never opened still need the server's pending-int
it lands, remote attention covers prompts raised while the lane is up plus whatever opening the
session discovers.

## Desktop shell: remote workspace — chat and read-only terminal

A session on another machine opens as a workspace: its transcript as chat, a composer, and for a
PTY harness a read-only terminal, all over the server. Three rules hold it together.

**One chat pane, two feeds.** The chat reads rows through a feed seam: locally a tail of the
transcript file, remotely a seed from the session detail route followed by a live tail of the
session's stream from the position the seed ended at. Every access establishment restarts the
tail from the last position seen and the seed is fetched only until one lands, so a reconnect
resumes rather than replays rows under the user. Server events reach the same envelope mapping
and vendor rules the file path applies, so the two paths cannot disagree about a row.

**Authorization is the server's word, never inferred from silence.** The seed fetch and the stream
subscribe both refuse loudly; the terminal subscribe, which the server refuses with silence, is
attempted only once the session's access lease reads Established. An empty terminal after that is
"no output yet", and a lane loss keeps the rows it already has.

**A reported viewport is released, and (0,0) is never sent.** The server folds every viewer's size
into the PTY's clamp until told otherwise, so a viewer that stops driving releases its size, and
each establishment subscribes onto a fresh surface so the replay never stacks on old scrollback.
Keystrokes cross only as the daemon's seven special keys; everything else stays local.

## A vendor update under a running daemon is re-advertised

The vendor CLI version a daemon advertises was a startup probe cached for the process lifetime, and
Expand Down
3,674 changes: 3,674 additions & 0 deletions docs/superpowers/plans/2026-09-14-desktop-remote-daemons-slice3.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,8 @@ void BuildDaemonGraph(
// host, and the click opens nothing.
RemoteSessionViewModel? BuildRemote(string agentId) =>
directory.Rows.Lookup($"remote:{agentId}") is { HasValue: true, Value: var row }
? new RemoteSessionViewModel(row, directory, sessionAccess, permissions, actions)
? new RemoteSessionViewModel(row, directory, sessionAccess, permissions, actions, serverLane, readDetail, opener, TimeProvider.System,
() => new XtermTerminalSurface(80, 24, PtyDumpPath))
: null;

_coordinator = new MainWindowCoordinator(
Expand Down Expand Up @@ -1143,7 +1144,7 @@ internal static MainWindow BuildAndShowMainWindow(
navigation: navigation, trackWorkspaceTeardown: trackWorkspaceTeardown, workspaceFactory: workspaceFactory,
rail: rail, tenantName: tenantName, lifecycleAttention: lifecycleAttention,
laneStatus: lane?.Status, restartPending: restartPending,
originOf: originOf, remoteWorkspaceFactory: remoteWorkspaceFactory);
originOf: originOf, remoteWorkspaceFactory: remoteWorkspaceFactory, directory: resolvedDirectory);
var window = new MainWindow {
DataContext = vm,
Notifier = notifier,
Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.App/Capacitor.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageReference Include="MarkView.Avalonia" />
<PackageReference Include="MarkView.Avalonia.SyntaxHighlighting" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Eventuous.SignalR.Client" />
<PackageReference Include="SvcSystems.UI.Terminal" />
<PackageReference Include="Velopack" />
<PackageReference Include="XTerm.NET" />
Expand Down
15 changes: 15 additions & 0 deletions src/Capacitor.App/Services/IServerLane.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Eventuous.SignalR;

namespace Capacitor.App.Services;

public enum ServerLaneState { Dormant, Connecting, Connected, Retrying, SignedOut }
Expand All @@ -24,10 +26,23 @@ public interface IServerLane {
IObservable<ServerPermissionRequest> PermissionRequests { get; }
IObservable<ServerElicitationRequest> ElicitationRequests { get; }
IObservable<string> SessionAccessChanged { get; }
IObservable<PendingInputUpdate> PendingInputChanged { get; }
/// Null when the lane has no live connection right now.
Task<IReadOnlyList<Capacitor.Remote.Models.DaemonInfo>?> GetConnectedDaemonsAsync(CancellationToken ct);
Task<HubCallOutcome> RequestStopAgentAsync(string agentId, CancellationToken ct);
Task<HubCallOutcome> SubscribeToChatAsync(string sessionId, CancellationToken ct);
Task<HubCallOutcome> UnsubscribeFromChatAsync(string sessionId, CancellationToken ct);
Task<HubCallOutcome> RegisterSessionAccessWatchAsync(string sessionId, CancellationToken ct);
IObservable<TerminalOutputFrame> TerminalOutput { get; }
IObservable<TerminalSize> TerminalDimensions { get; }
/// A live tail from the position after `fromPosition` (null: the start). Completes when the
/// lane has no live hub, when the connection goes away, or when the stream is subscribed
/// again; throws HubException on a refused subscribe at the first MoveNextAsync.
IAsyncEnumerable<StreamEventEnvelope> TailStreamAsync(string stream, ulong? fromPosition, CancellationToken ct);
Task<HubCallOutcome> SubscribeToTerminalAsync(string agentId, CancellationToken ct);
Task<HubCallOutcome> UnsubscribeFromTerminalAsync(string agentId, CancellationToken ct);
Task<HubCallOutcome> RequestResizeTerminalAsync(string agentId, int cols, int rows, CancellationToken ct);
Task<HubCallOutcome> ReleaseResizeTerminalAsync(string agentId, CancellationToken ct);
Task<HubCallOutcome> SendUserInputAsync(string agentId, string text, CancellationToken ct);
Task<HubCallOutcome> SendSpecialKeyAsync(string agentId, string key, CancellationToken ct);
}
14 changes: 8 additions & 6 deletions src/Capacitor.App/Services/ITerminalSurface.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
namespace Capacitor.App.Services;

/// Minimal surface the VM drives; the production implementation (Task 11/12) wraps the
/// SvcSystems control model. Kept App-local (no Avalonia/SvcSystems types in the signature) so
/// TerminalTabViewModel's own tests never need a real terminal control.
/// Minimal surface the VM drives, wrapped in production over the SvcSystems control model. Kept
/// App-local (no Avalonia/SvcSystems types in the signature) so TerminalTabViewModel's own tests
/// never need a real terminal control.
public interface ITerminalSurface {
/// Renders already-decoded text — callers own UTF-8 decoding (Utf8StreamDecoder) since the
/// control's own byte-array Feed re-decodes fresh on every call and corrupts a code point
Expand All @@ -16,8 +16,10 @@ public interface ITerminalSurface {
/// User-driven resize (cols, rows) — the VM forwards this to ResizeAsync.
event Action<int, int>? Resized;

/// The surface's own current dimensions — read once, right after a read-write Attached, to
/// correct the client's post-attach nudge (sent at RunAsync's phantom initial size, before the
/// real pane size was ever known) to what the pane is actually showing.
/// The surface's own current dimensions — what the pane is actually showing, which corrects
/// the phantom initial size a client carries until the pane has been measured.
(int Cols, int Rows) CurrentSize { get; }

/// Sizes the pane to the source PTY — a remote viewer follows the source, it never drives it.
void Resize(int cols, int rows);
}
12 changes: 12 additions & 0 deletions src/Capacitor.App/Services/NoRemoteAgents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Reactive.Linq;
using Capacitor.Remote.Models;
using DynamicData;
using Eventuous.SignalR;

namespace Capacitor.App.Services;

Expand All @@ -25,6 +26,7 @@ internal sealed class NoServerLane : IServerLane {
public IObservable<ServerPermissionRequest> PermissionRequests => Observable.Never<ServerPermissionRequest>();
public IObservable<ServerElicitationRequest> ElicitationRequests => Observable.Never<ServerElicitationRequest>();
public IObservable<string> SessionAccessChanged => Observable.Never<string>();
public IObservable<PendingInputUpdate> PendingInputChanged => Observable.Never<PendingInputUpdate>();
public Task<IReadOnlyList<DaemonInfo>?> GetConnectedDaemonsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<DaemonInfo>?>(null);
public Task<HubCallOutcome> RequestStopAgentAsync(string agentId, CancellationToken ct) =>
Expand All @@ -35,4 +37,14 @@ public Task<HubCallOutcome> UnsubscribeFromChatAsync(string sessionId, Cancellat
Task.FromResult(HubCallOutcome.NotConnected);
public Task<HubCallOutcome> RegisterSessionAccessWatchAsync(string sessionId, CancellationToken ct) =>
Task.FromResult(HubCallOutcome.NotConnected);
public IObservable<TerminalOutputFrame> TerminalOutput => Observable.Never<TerminalOutputFrame>();
public IObservable<TerminalSize> TerminalDimensions => Observable.Never<TerminalSize>();
public IAsyncEnumerable<StreamEventEnvelope> TailStreamAsync(string stream, ulong? fromPosition, CancellationToken ct) =>
AsyncEnumerable.Empty<StreamEventEnvelope>();
public Task<HubCallOutcome> SubscribeToTerminalAsync(string agentId, CancellationToken ct) => Task.FromResult(HubCallOutcome.NotConnected);
public Task<HubCallOutcome> UnsubscribeFromTerminalAsync(string agentId, CancellationToken ct) => Task.FromResult(HubCallOutcome.NotConnected);
public Task<HubCallOutcome> RequestResizeTerminalAsync(string agentId, int cols, int rows, CancellationToken ct) => Task.FromResult(HubCallOutcome.NotConnected);
public Task<HubCallOutcome> ReleaseResizeTerminalAsync(string agentId, CancellationToken ct) => Task.FromResult(HubCallOutcome.NotConnected);
public Task<HubCallOutcome> SendUserInputAsync(string agentId, string text, CancellationToken ct) => Task.FromResult(HubCallOutcome.NotConnected);
public Task<HubCallOutcome> SendSpecialKeyAsync(string agentId, string key, CancellationToken ct) => Task.FromResult(HubCallOutcome.NotConnected);
}
6 changes: 6 additions & 0 deletions src/Capacitor.App/Services/PendingInputUpdate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Capacitor.Remote.Models;

namespace Capacitor.App.Services;

/// The server's whole queue for one session, as of one push or one chat join.
public sealed record PendingInputUpdate(string SessionId, IReadOnlyList<QueuedInputItem> Items);
Loading