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:
- Validate the dispatching client holds the terminal's claim. Reject otherwise.
- Apply the data to the terminal's state identically to
terminal/data (append to the tail content part).
- 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?
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:
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.
executionTargetoncreateTerminalWhen
executionTarget: 'client':root/terminalsChanged.kind: 'client'). Session claims are rejected — there is no client to own the pty.terminal/output.When
executionTarget: 'server'(or omitted): behavior is identical to today.2.
terminal/output— new client-dispatchable actionDirection: client → server
Server behavior on receipt:
terminal/data(append to the tail content part).terminal/datato all other subscribers.Why a separate action instead of reusing
terminal/data?terminal/datais server-only. Making it bidirectional would change the semantics of every reducer that handles it.3.
terminal/exitedbecomes client-dispatchable for client-executed terminalsToday
terminal/exitedis server-only. For client-executed terminals, the owning client dispatchesterminal/exitedwith anexitCodewhen its local pty exits. The server validates the sender holds the claim, applies it to state, and re-broadcasts to all subscribers.4.
executionTargetonTerminalInfoandTerminalStateAdd
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-executed terminal (new)
Disconnection and disposal
If the owning client disconnects, the server dispatches
terminal/exitedwith 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.disposeTerminalfrom any authorized client (or the agent) removes the terminal from the catalogue. The server sendsterminal/exitedto 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 receiveterminal/databut any attempt to dispatchterminal/input,terminal/cleared, orterminal/resizedis rejected by the server. The server can also dispatchterminal/exiteditself (e.g., on client disconnect ordisposeTerminal).Open questions
executionTarget: 'client'inInitializeResultorAgentInfo, or is it always available?terminal/commandExecuted/terminal/commandFinishedlocally 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?executionTargetimmutability. ShouldexecutionTargetbe immutable after creation? The proposal implies it is (it's oncreateTerminal, not a dispatchable action), but this should be stated explicitly.createTerminalrejection. What error code should the server return whenexecutionTarget: 'client'is paired with a session claim?