microsoft / microsoft/agent-host-protocol

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

Open
#281 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
344
Forks
122
Avg merge
14h 12m
Merged PRs (30d)
22

Description

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?

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reviewing the terminal channel definitions for CreateTerminalParams, TerminalOutputAction, TerminalInfo, TerminalState, and the terminal actions described in the proposal. Resolve the listed authorization, capability, command-detection, immutability, and rejection-error questions, then document and implement the agreed protocol behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend-api-design
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.