Skip to Content
Native IntegrationTerminal Controller

Terminal Plane Controller (TPC) v0.1

Observe + Reveal only. The native client lists the user’s real IDE integrated terminals and can bring one of them to focus on the desktop. It does NOT stream output, send input, or run commands. Those surfaces are deliberately out of scope for v0.1.

Capability

terminal_observe

Native clients negotiate terminal_observe in their auth capabilities[]. Without it, every terminal_* request returns { type: 'error', errorCode: 'capability_not_negotiated' } and the server does not broadcast terminal_event to that client.

Wire shapes

TerminalInfo

The atomic terminal-state record. Tolerate unknown fields.

{ "id": "term-1", // string — stable opaque id (NEVER empty, NEVER a sentinel) "name": "bash", // string — VS Code's terminal.name "cwd": "/path/...", // optional string — resolved cwd if shellIntegration provides it "isInteractedWith": false, // optional boolean — mirrors vscode.Terminal.state "processId": 1234, // optional number — shell PID when exposed "shellIntegration": "available", // 'available' | 'unavailable' | 'unknown' (treat as OPEN union) "exitCode": null, // optional number | null — set on close "isActive": true, // boolean — currently active terminal in the IDE "createdAt": 1700000000000 // number — wall-clock ms }

Client → Server

{ "type": "terminal_list", "requestId": "r-1", "traceId": "t-..." } { "type": "terminal_reveal_request", "requestId": "r-2", "traceId": "t-...", "terminalId": "term-1", "preserveFocus": false // default: false (take focus on desktop) }

Server → Client

{ "type": "terminal_list_result", "requestId": "r-1", "seqId": 42, "terminals": [ /* TerminalInfo[] */ ] } { "type": "terminal_event", "terminalId": "term-1", // ALWAYS a real opaque id; never empty "event": "opened" | "closed" | "active_changed" | "state_changed" | "shell_integration_changed", "info": { /* TerminalInfo */ }, // omitted only for 'closed' "seqId": 43, "traceId": "t-..." } { "type": "terminal_reveal_ack", "requestId": "r-2", "seqId": 44, "terminalId": "term-1", "delivered": true, // or false with errorCode + error "errorCode": "terminal_not_found",// optional, only when delivered=false "error": "..." // human-readable, always present on failure }

Lifecycle events

eventWhenCarries info?Native action
openedA terminal was opened in the IDE (or existed at extension start).YesUpsert into your terminals map.
closedA terminal was closed in the IDE.NoRemove from your terminals map.
state_changedTerminal.state changed (e.g., user interacted with it).YesUpsert (replace info) — isInteractedWith may have flipped.
shell_integration_changedTerminal.shellIntegration activated / deactivated.YesUpsert — shellIntegration and cwd may have changed.
active_changedThe active terminal changed. Real-id semantics, see below.YesUpdate isActive on the named id only.

active_changed — the symmetric real-id model

This is the one subtle case. The protocol forbids sentinel ids (no terminalId = ''). The extension emits zero, one, or two events to represent any transition:

TransitionEvents emittedNative rendering action
active A → active BTWO events: (A, isActive:false) then (B, isActive:true)On each event, set the named terminal’s isActive per info.isActive. Order is preserved by seqId.
active A → no active terminalONE event: (A, isActive:false)Flip A inactive. No “the new active is nothing” event — none is sent.
no active → active BONE event: (B, isActive:true)Flip B active.
no active → no activeZERO eventsNothing arrives; nothing to render.

A defensive client-side reconciliation matches what the PWA does:

on terminal_event(active_changed, info): if info.isActive == true: for each terminal in map: if terminal.id != info.id: terminal.isActive = false map[info.id].isActive = true else: // info.isActive == false map[info.id].isActive = false

That guarantees “exactly one active at a time” even under unusual ordering. The same logic in the PWA lives in packages/web/src/store/index.tsapplyTerminalEvent.

Reveal flow

When the user taps Reveal on a non-active terminal row:

client → server: { type: 'terminal_reveal_request', requestId: <uuid>, terminalId: 'term-1', preserveFocus: false } server (via vscode.Terminal.show on the desktop): ← { type: 'terminal_reveal_ack', requestId: <uuid>, seqId: N, terminalId: 'term-1', delivered: true } server (separately, as a side effect of the IDE focus shift): ← { type: 'terminal_event', event: 'active_changed', terminalId: <previous>, info: {..., isActive: false} } ← { type: 'terminal_event', event: 'active_changed', terminalId: 'term-1', info: {..., isActive: true} }

If the terminal id is unknown to the server (it was closed between the user’s tap and the request arriving), the ack returns delivered: false, errorCode: 'terminal_not_found'.

What native UI must render

For each terminal row:

  • Name (the VS Code terminal label).
  • Active indicator — a coloured dot driven by info.isActive.
  • shellIntegration badge — green dot for available, amber for unavailable, grey (neutral) for unknown. Future values render as grey + the literal label string — never assume a closed enum.
  • cwd if present (omit the line otherwise).
  • processId if present (omit otherwise).
  • Reveal button that fires terminal_reveal_request with preserveFocus: false.

Empty state copy:

No terminals open in the IDE.

What native UI must NOT render (v0.1 scope)

These are forbidden by the v0.1 doctrine for kind: 'user' terminals — the v0.2A “managed terminals” section below relaxes them strictly within the terminal_control capability scope.

  • Terminal output area / scrollback for user terminals — v0.1 has no terminal_output wire shape for user-created terminals. The server cannot reliably stream arbitrary terminal output via stable VS Code APIs today.
  • Input field for typing into user terminalsterminal_input is rejected with errorCode:'terminal_not_managed' if the targeted terminalId is user-created.
  • Ctrl-C / Ctrl-D / signal buttons on user terminals — same reason: input is rejected on user-kind terminals.
  • “Run command” allowlist surface on user terminals.
  • “Create new terminal” of arbitrary type — v0.1 has no spawn. v0.2A adds a strictly-managed spawn that the extension owns (see below).

If you find yourself sketching any of these for a kind: 'user' row, you have walked off scope. They are now permitted only for kind: 'managed' rows under v0.2A, gated by the terminal_control capability.


v0.2A — Managed Terminal Mode (additive to v0.1)

Status: shipped 2026-05-21. Backwards-compatible additive change: clients that do NOT negotiate terminal_control see exactly the v0.1 contract above.

Scope: the phone may spawn, observe, type into, and kill managed terminals — terminals the extension created on the phone’s request via vscode.window.createTerminal({pty}) backed by node-pty. The pty subprocess lives in the extension host. User-created terminals remain observe-and-reveal only.

What this is NOT: not a generic IDE terminal mirror, not a cloud shell, not a detached headless shell, not SSH-into-the-server-from-the-phone, not a way to mirror existing user terminals’ output. Adding any of those is a separate doctrine pass.

New capability + wire shapes

SUPPORTED_CAPABILITIES gains: 'terminal_control' (additive to 'terminal_observe') TerminalInfo gains: kind?: 'user' | 'managed' | 'unknown' | (string & {}) // open string union TerminalLifecycle gains: 'kind_changed' and becomes an open string union. Client → Server: terminal_spawn_request { requestId?, traceId?, name?, cwd?, shell? } terminal_input { requestId?, traceId?, terminalId, data } terminal_dispose_request { requestId?, traceId?, terminalId } Server → Client: terminal_spawn_ack { requestId?, delivered, terminalId?, errorCode?, error?, seqId } terminal_output { terminalId, data, traceId?, seqId } terminal_dispose_ack { requestId?, terminalId, delivered, errorCode?, error?, seqId }

data on terminal_output is raw bytes including ANSI escapes — the native renderer decides paint quality. data on terminal_input is raw bytes too, including control codes (e.g. \x03 for SIGINT). Unknown lifecycle values and unknown errorCode strings MUST degrade gracefully (display the human error text; never crash; never assume a closed enum).

Server-enforced security gates (locked, all four)

A native client MUST NOT mirror or weaken any of these. They are server-side; the client renders whatever the server allows.

  1. Capability gate. terminal_control is default-OFF in the PWA. The user must opt in explicitly (toggle lives in VersionsPanel). Old clients without the toggle never negotiate the capability; the server rejects all terminal_*_request from clients without the capability with errorCode: 'capability_not_negotiated'.
  2. Desktop confirm — once per extension-host lifetime. The first terminal_spawn_request to arrive on the wire triggers vscode.window.showInformationMessage('Phone wants to open a managed terminal in <workspace>. Allow? / Block') inside Cursor / VS Code on the desktop. On Allow, an in-memory flag flips and subsequent spawns skip the prompt. On Block (or window reload), the flag stays off and the next spawn re-prompts. Server response: errorCode: 'desktop_denied'.
  3. Per-extension-host cap of 4. Constant MAX_MANAGED_TERMINALS = 4. The 5th concurrent spawn returns errorCode: 'terminal_limit_reached'. Tunable later via server config; clients should treat it as opaque (“server said the cap was reached”) and disable the ”+ New” affordance accordingly.
  4. Per-keystroke audit log. Every terminal_input produces a diagnostic-log entry of the form {terminalId, len, hasNewline, hash: sha256(data)}the raw data bytes NEVER reach the log. The hash chain is tamper-evident if a sequence ever needs forensic replay; the bytes themselves are gone forever. Same redaction applies to extension-side terminal_input log entries.

Other documented errorCode values: 'shell_not_allowed' (shell override outside the small allowlist), 'cwd_outside_allowed_roots' (cwd validation failed via WorkspaceController.validateWorkspaceTarget), 'terminal_not_found', 'terminal_not_managed' (input or dispose against a user-kind terminal), 'capability_unavailable' (build without managed-terminal wiring), 'spawn_failed' (catch-all spawn error with error string detail).

Native UI guidance for v0.2A

Mobile clients adopting terminal_control should:

  • Render a per-row UX split on kind. kind: 'user' rows keep the v0.1 list-and-reveal contract (no output area, no input). kind: 'managed' rows expand into an inline pty pane: output <pre> (bounded ring), input field, Ctrl-C button (sends \x03), Kill button (sends terminal_dispose_request).
  • Expose a single ”+ New Terminal” affordance somewhere on the Terminals tab. Disable it gracefully when the cap is signalled.
  • Treat output as raw bytes. Ship an ANSI-to-styled-text pass (libs like ansi-to-html are data-transform, doctrine-safe — they don’t fetch code). Full TUI (vim, htop) will be best-effort and ugly on small screens; that’s a v0.2A documented limit, not a bug.
  • Bound the output buffer per terminal. The PWA uses a 64 KB cap with tail-keep; pick whatever fits your platform’s memory budget. The server does NOT replay output history on reconnect — if the phone disconnects, the user loses scrollback. By design.
  • Surface unknown errorCode strings with the server-provided error text. Never gate UI on the specific code value. New codes ship without a native release. App-Store-resilience.

What native UI still must NOT render in v0.2A

  • ❌ A ”+ New Terminal” button OR an output / input pane attached to a kind: 'user' row. The line stays clean between user and managed.
  • ❌ A “spawn arbitrary shell” surface that bypasses the server’s small ALLOWED_SHELLS list (the wire path will reject with shell_not_allowed, but it’s wasted round-trip noise).
  • ❌ A persistent “managed terminal scrollback” stored across phone reloads. Ephemeral by protocol contract.
  • ❌ Any in-app implementation of the desktop confirm, the cap-of-4, or the input audit. These are server-side. Mirroring them client-side will drift across releases.

Phase verification (server-side; pinned at commit 349143c)

  • 175 → 213 extension tests (packages/extension/test/protocol-managed-terminal-types.test.ts, managed-terminal.test.ts, terminal-registry.managed.test.ts, ws-server.terminalControl.test.ts, logger.redaction.terminalControl.test.ts).
  • 88 → 101 web tests (packages/web/test/dispatcher.managedTerminal.test.ts).
  • Phase 0 evidence + ABI risk closed: docs/architecture/2026-05-21-tpc-v0.2a-spike-evidence (in the source repo).

Doctrine red flags for a TPC client

  • Hardcoding a list of allowed shell commands. Allowlists, when they exist later, are server data.
  • Building a renderer that “knows” how Claude Code formats output. The phone is not a terminal emulator.
  • Special-casing specific shell types (bash vs zsh vs fish). The server reports shellIntegration; that’s all you get.
  • Showing different UI based on processId value. The PID is informational only.
  • Persisting terminal state across launches. Ephemeral by design; rebuilt on each connect.

Reference implementation

The PWA is the living reference today:

When you port this to Swift / Kotlin, the dispatcher → store flow maps to your platform’s state-container (@Observable on iOS, StateFlow on Android). The component maps to your view layer. Keep the protocol layer pure.