Skip to content

Proposal: Client-Executed Terminals — let terminals run pty processes on clients #281

Description

@joshvoigts

Proposal: Client-Executed Terminals — let terminals run pty processes on clients

Disclaimer: This proposal was developed with help from an LLM, although it stems from a real experienced implementation constraint. It was reviewed by a human and leaves some parts underspecified in the interest of explaining the concept.

Summary

Extend the terminal channel to support terminals whose pty processes execute on a client rather than the server. The server remains the coordination hub and source of truth for terminal state; the pty-owning client produces output that the server distributes to all subscribers. For non-owning clients and agents, client-executed terminals are observation-only — they can subscribe and receive output but cannot send input, resize, or clear.

Motivation

Today, AHP terminals assume the server owns every pty. This works when the agent host has direct access to the shell — local development, containers, remote VMs. But many setups don't:

  • IDE-connected agents. A VS Code extension wants to use the terminals the user already has open — with their environment, credentials, and working directory. Duplicating that environment on the server is impractical.
  • Thin agent hosts. The agent runs remotely, but the user's workspace is their local machine. The only pty available is on the client side.
  • Shared terminal visibility. A client has an existing terminal running a dev server. The agent and other clients should be able to observe it.

In all these cases the pty lives on a client, but coordination — who owns it, who sees output — still needs to flow through the server.

Proposal

Four small changes:

1. executionTarget on createTerminal

interface CreateTerminalParams extends BaseParams {
  channel: URI;
  claim: TerminalClaim;
  name?: string;
  cwd?: URI;
  cols?: number;
  rows?: number;
  executionTarget?: 'server' | 'client';  // default: 'server'
}

When executionTarget: 'client':

  • The server does not spawn a pty. It allocates the terminal resource, records the claim, and dispatches root/terminalsChanged.
  • The claim MUST be a client claim (kind: 'client'). Session claims are rejected — there is no client to own the pty.
  • The claiming client creates a local pty and produces output via terminal/output.

When executionTarget: 'server' (or omitted): behavior is identical to today.

2. terminal/output — new client-dispatchable action

interface TerminalOutputAction {
  type: ActionType.TerminalOutput;
  data: string;
}

Direction: client → server

Server behavior on receipt:

  1. Validate the dispatching client holds the terminal's claim. Reject otherwise.
  2. Apply the data to the terminal's state identically to terminal/data (append to the tail content part).
  3. Re-broadcast as terminal/data to all other subscribers.

Why a separate action instead of reusing terminal/data? terminal/data is server-only. Making it bidirectional would change the semantics of every reducer that handles it.

3. terminal/exited becomes client-dispatchable for client-executed terminals

Today terminal/exited is server-only. For client-executed terminals, the owning client dispatches terminal/exited with an exitCode when its local pty exits. The server validates the sender holds the claim, applies it to state, and re-broadcasts to all subscribers.

4. executionTarget on TerminalInfo and TerminalState

Add executionTarget?: 'server' | 'client' to both. Informational — clients can use it to adjust behavior (e.g., show a "local" vs. "remote" indicator).

Wire flows

Server-executed terminal (unchanged)

Client A → createTerminal { executionTarget: 'server' } → Server
Server spawns pty, dispatches root/terminalsChanged
Client A → subscribe ahp-terminal:/<id>
Server → terminal/data { data: "ls\r\nfile1  file2\r\n$" } → Client A
Client A → terminal/input { data: "echo hello\r" } → Server
Server writes to pty
Server → terminal/data { data: "echo hello\r\nhello\r\n$" } → Client A

Client-executed terminal (new)

Client A → createTerminal { executionTarget: 'client', claim: { kind: 'client', clientId: 'client-a' } } → Server
Server allocates resource, dispatches root/terminalsChanged
Client A creates local pty, subscribes to ahp-terminal:/<id>

# Client A types locally — writes to pty directly, no server round-trip:
Client A pty produces output
Client A → terminal/output { data: "echo hello\r\nhello\r\n$" } → Server
Server applies, re-broadcasts as terminal/data → other subscribers

# Agent reads output:
Agent subscribes to ahp-terminal:/<id>
Agent → terminal/data { data: "echo hello\r\nhello\r\n$" } (received automatically)

# Agent attempts to send input — rejected:
Agent → terminal/input { data: "ls\r" } → Server
Server → error (client-executed terminals are observation-only for non-owners)

Disconnection and disposal

If the owning client disconnects, the server dispatches terminal/exited with exit code -1 and removes the terminal from the catalogue. This is identical to the server killing a pty for a server-executed terminal whose process died.

disposeTerminal from any authorized client (or the agent) removes the terminal from the catalogue. The server sends terminal/exited to the owning client, which kills its local pty.

Authorization

For client-executed terminals, only the owning client (origin.clientId === claim.clientId) may dispatch actions — terminal/output, terminal/exited, terminal/cleared, terminal/resized. All other clients and agents are observation-only: they may subscribe and receive terminal/data but any attempt to dispatch terminal/input, terminal/cleared, or terminal/resized is rejected by the server. The server can also dispatch terminal/exited itself (e.g., on client disconnect or disposeTerminal).

Open questions

  • Capability advertisement. Should the server advertise support for executionTarget: 'client' in InitializeResult or AgentInfo, or is it always available?
  • Command detection for client-executed terminals. The owning client may produce terminal/commandExecuted/terminal/commandFinished locally if its shell has integration. Should the server accept these from the owning client, making command detection available? Or is it simply unavailable in v1?
  • executionTarget immutability. Should executionTarget be immutable after creation? The proposal implies it is (it's on createTerminal, not a dispatchable action), but this should be stated explicitly.
  • createTerminal rejection. What error code should the server return when executionTarget: 'client' is paired with a session claim?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions