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_observeNative 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
event | When | Carries info? | Native action |
|---|---|---|---|
opened | A terminal was opened in the IDE (or existed at extension start). | Yes | Upsert into your terminals map. |
closed | A terminal was closed in the IDE. | No | Remove from your terminals map. |
state_changed | Terminal.state changed (e.g., user interacted with it). | Yes | Upsert (replace info) — isInteractedWith may have flipped. |
shell_integration_changed | Terminal.shellIntegration activated / deactivated. | Yes | Upsert — shellIntegration and cwd may have changed. |
active_changed | The active terminal changed. Real-id semantics, see below. | Yes | Update 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:
| Transition | Events emitted | Native rendering action |
|---|---|---|
| active A → active B | TWO 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 terminal | ONE event: (A, isActive:false) | Flip A inactive. No “the new active is nothing” event — none is sent. |
| no active → active B | ONE event: (B, isActive:true) | Flip B active. |
| no active → no active | ZERO events | Nothing 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 = falseThat guarantees “exactly one active at a time” even under unusual ordering. The same logic in the PWA lives in packages/web/src/store/index.ts — applyTerminalEvent.
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. shellIntegrationbadge — green dot foravailable, amber forunavailable, grey (neutral) forunknown. Future values render as grey + the literal label string — never assume a closed enum.cwdif present (omit the line otherwise).processIdif present (omit otherwise).- Reveal button that fires
terminal_reveal_requestwithpreserveFocus: 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_outputwire 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 terminals —
terminal_inputis rejected witherrorCode:'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_controlsee 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.
- Capability gate.
terminal_controlis 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 allterminal_*_requestfrom clients without the capability witherrorCode: 'capability_not_negotiated'. - Desktop confirm — once per extension-host lifetime. The first
terminal_spawn_requestto arrive on the wire triggersvscode.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'. - Per-extension-host cap of 4. Constant
MAX_MANAGED_TERMINALS = 4. The 5th concurrent spawn returnserrorCode: '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. - Per-keystroke audit log. Every
terminal_inputproduces a diagnostic-log entry of the form{terminalId, len, hasNewline, hash: sha256(data)}— the rawdatabytes 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-sideterminal_inputlog 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 (sendsterminal_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-htmlare 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
errorCodestrings with the server-providederrortext. 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_SHELLSlist (the wire path will reject withshell_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
processIdvalue. 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:
- Dispatcher cases:
packages/web/src/core/dispatcher.ts— v0.1:terminal_list_result,terminal_event,terminal_reveal_ack; v0.2A:terminal_spawn_ack,terminal_output,terminal_dispose_ack. - Store + actions:
packages/web/src/store/index.ts—terminalsslice (withoutputBufferon managed rows),setTerminals,applyTerminalEvent,applyTerminalRevealAck,applyTerminalOutput,spawnTerminal,sendTerminalInput,disposeTerminal,applyTerminalSpawnAck,applyTerminalDisposeAck,setEnableManagedTerminals,clearPendingSpawnError. - Component:
packages/web/src/components/TerminalsView.tsx— split rendering bykind, ”+ New Terminal” affordance gated on negotiation, inline pty pane for managed rows. - Settings toggle:
packages/web/src/components/VersionsPanel.tsx—Allow managed terminalscheckbox withnegotiated/reconnecting…chip. - Server side (extension):
packages/extension/src/terminal-registry.ts(registry +spawnManaged/writeToManaged/disposeManaged),packages/extension/src/managed-terminal.ts(thevscode.Pseudoterminal+ node-pty bridge class),packages/extension/src/message-handler.ts(the 3 new wire cases + 4 security gates),packages/extension/src/logger.ts(sha256helper + redaction cases that stripterminal_input.data).
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.