Skip to content

WithTerminal(): retrospective architecture documentation for 13.4 interactive terminal support #16967

@mitchdenny

Description

@mitchdenny

Summary

WithTerminal() adds first-class interactive terminal (PTY) support to Aspire resources. An AppHost author opts a resource into terminal access with a single call, and the dashboard then renders a live xterm.js terminal per replica. The same session is also attachable from the CLI via aspire terminal attach.

This issue retrospectively documents the architecture that landed for Aspire 13.4 alongside #16317 and microsoft/dcp#133, so future contributors have a single high-level reference for the moving parts.

End-user experience

AppHost (C#)

var agent = builder.AddExecutable("agent", "my-agent.exe", ".")
    .WithReplicas(3)
    .WithTerminal();

That's it. The order of WithReplicas(...) and WithTerminal() does not matter - per-replica hosts are materialised during BeforeStartEvent once the model is fully built.

WithTerminal() accepts an optional configuration callback for terminal dimensions:

.WithTerminal(options =>
{
    options.Columns = 200;
    options.Rows = 50;
});

AppHost (polyglot - TS / JS)

const agent = builder.addExecutable("agent", "my-agent.exe", ".")
    .withReplicas(3)
    .withTerminal();

The polyglot withTerminal uses the default 120x30 grid. Custom dimensions for non-C# AppHosts are a follow-up.

Dashboard

When a resource is configured with WithTerminal(), the dashboard's Console logs view is replaced by a Terminal view for that resource. Each replica gets its own xterm.js session - keystrokes are forwarded server-side; resizes are negotiated end-to-end. (Console log lines still flow through to the AppHost log pipeline, so log queries and OTLP-exported logs are unaffected.)

CLI

aspire terminal ps                                  # list sessions in the connected AppHost
aspire terminal attach agent --replica 0            # attach the local terminal to replica 0

When a resource has multiple replicas and the CLI is interactive, aspire terminal attach prompts for replica selection. In non-interactive mode, --replica N is required.

Process topology

flowchart LR
    AppHost["AppHost<br/>(Aspire.Hosting + DCP control plane)"]
    DCP["DCP<br/>(Windows ConPTY)"]
    Replica["Replica process<br/>(executable / container)"]
    Host["TerminalHost<br/>(Hex1b HMP v1 broker)<br/>1 process / replica"]
    Dashboard["Dashboard<br/>/api/terminal proxy"]
    CLI["aspire terminal attach"]
    Browser["xterm.js (browser)"]

    AppHost -- spawns --> Host
    AppHost -- spec.Terminal --> DCP
    DCP -- spawns + ConPTY --> Replica
    DCP <-- "PTY bytes (producer-uds)<br/>DCP dials, host listens" --> Host
    Host <-- "broadcast (consumer-uds)" --> Dashboard
    Host <-- "broadcast (consumer-uds)" --> CLI
    AppHost <-- "lifecycle/stats (control-uds)" --> Host
    Dashboard <-- WebSocket --> Browser
Loading

Three actors, three socket roles

Actor Socket Direction Lifetime
DCP producerUdsPath DCP -> host (PTY bytes + control) Per replica
TerminalHost consumerUdsPath host -> consumers (broadcast) Per replica
TerminalHost controlUdsPath AppHost -> host (lifecycle, stats) Per resource

The producer/consumer split lets multiple consumers (dashboard + multiple CLI sessions) attach simultaneously without coupling DCP to consumer counts.

Per-replica fan-out

WithReplicas(3).WithTerminal() produces three terminal host processes named {parent}-terminalhost-0 ... {parent}-terminalhost-2, each brokering the PTY bytes for its replica. They are hidden resources - they don't show up as primary cards in the dashboard, but they are present in the resource graph for diagnostics.

Wire protocol

We don't define a custom wire protocol. The terminal traffic uses Hex1b's HMP v1 (Hex Multiplex Protocol, version 1), which already handles:

  • VT byte streaming with backpressure
  • Resize requests in both directions
  • Hello/StateSync replay so a late-attaching consumer sees the current scrollback
  • Connection lifecycle (close, disconnect, reconnect)

Hmp1WorkloadAdapter is what the AppHost-side terminal host uses to multiplex DCP's PTY traffic to the consumer-facing listener. Hmp1PresentationAdapter is what consumers (Dashboard WebSocket proxy and the CLI) use to attach.

Property contract (gRPC ResourceService snapshots)

When WithTerminal() is applied, every replica snapshot emitted by the dashboard service carries four properties:

Key Sensitivity Meaning
terminal.enabled non-sensitive Marker. "true" when the replica has a PTY.
terminal.replicaIndex non-sensitive 0-based stable index from DcpInstancesAnnotation.
terminal.replicaCount non-sensitive Total replicas for the parent resource.
terminal.consumerUdsPath sensitive The local UDS that consumers connect to.

The consumer UDS path is marked sensitive so the dashboard UI masks the value in the property list. The browser never sees the path - the dashboard proxy resolves it server-side from (resource, replica) pairs presented by the resource snapshot stream.

Dashboard /api/terminal WebSocket endpoint

Authenticated (RequireAuthorization(FrontendAuthorizationDefaults.PolicyName)) endpoint at /api/terminal?resource=<displayName>&replica=<index>.

TerminalWebSocketProxy resolves the connection entirely server-side via ITerminalConnectionResolver, then runs two pumps over an Hmp1WorkloadAdapter:

  • Inbound (browser -> producer): binary frames -> HMP v1 Input; text frames -> JSON resize control ({"type":"resize","cols":N,"rows":N}).
  • Outbound (producer -> browser): VT bytes -> binary WebSocket frames; producer resize hints -> JSON text frames.

Frame type - not content - distinguishes keystrokes from control messages. The browser cannot induce the dashboard to connect to an arbitrary local socket; it can only request (resource, replica) pairs already present in the snapshot stream.

Packaging

Aspire.TerminalHost ships as per-RID NuGet packages and is also rolled into the polyglot CLI bundle alongside the dashboard. The hosting layer resolves the binary path via:

  1. DcpPublisher:TerminalHostPath config (priority 1)
  2. ASPIRE_TERMINAL_HOST_PATH env var
  3. aspireterminalhostpath assembly metadata (inner-loop dev)
  4. Bundle fallback: when the resolved dashboard path points at aspire-managed, reuse it with the terminalhost subcommand. This keeps the polyglot bundle light (no second binary copy).

Status (Aspire 13.4)

  • Public API: WithTerminal<T>(this IResourceBuilder<T>, Action<TerminalOptions>?)
  • Polyglot ATS export: withTerminal
  • Per-replica TerminalHost processes (one per replica, ordering-independent)
  • Dashboard /api/terminal WebSocket proxy and per-replica TerminalView
  • aspire terminal ps and aspire terminal attach CLI commands
  • Hex1b HMP v1 broker on producer/consumer/control UDS sockets
  • Windows executable PTY via DCP ConPTY
  • DCP packaging blocker: end-to-end PTY only works once the microsoft/dcp#133 terminal/PTY changes ship in the Microsoft.DeveloperControlPlane.* NuGet packages consumed by Aspire. Until then, the AppHost spawns the terminal host correctly but the bundled DCP (release/0.23, no terminal support) never dials the producer-uds. Local development can override via DcpPublisher__CliPath.
  • Linux / macOS / container PTY (Phase 3 follow-ups)
  • Polyglot overload that accepts a configuration DTO (so non-C# AppHosts can customise columns/rows)

Files of interest

Concern File
Public API entry point src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs
Per-resource hidden host resource src/Aspire.Hosting/ApplicationModel/TerminalHostResource.cs
DCP wire-up src/Aspire.Hosting/Dcp/ExecutableCreator.cs
Backchannel GetTerminalInfoAsync src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
Snapshot stamping src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
TerminalHost process src/Aspire.TerminalHost/
CLI parent command src/Aspire.Cli/Commands/TerminalCommand.cs
CLI attach / ps src/Aspire.Cli/Commands/TerminalAttachCommand.cs, TerminalPsCommand.cs
Dashboard WebSocket proxy src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs
Dashboard resolver src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs
TerminalView (xterm.js host) src/Aspire.Dashboard/Components/Controls/TerminalView.razor.*
Property keys src/Shared/Model/KnownProperties.cs (Terminal.*)
Playground sample playground/Terminals/Terminals.AppHost/AppHost.cs
Spec doc docs/specs/with-terminal.md

Related

Metadata

Metadata

Assignees

Labels

area-cliarea-dashboardarea-terminalTerminal/PTY support — TerminalHost, WithTerminal, dashboard terminal view
No fields configured for Feature.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions