Skip to content

feat: standalone embedded terminal — launch and drive agents from the browser - #347

Open
pablodelucca wants to merge 15 commits into
mainfrom
feat/standalone-terminal
Open

feat: standalone embedded terminal — launch and drive agents from the browser#347
pablodelucca wants to merge 15 commits into
mainfrom
feat/standalone-terminal

Conversation

@pablodelucca

Copy link
Copy Markdown
Collaborator

What

npx pixel-agents becomes a full surface: + Agent in the browser spawns claude in a server-side PTY, streams it into an xterm.js drawer next to the office, and routes its hooks to the character — launch, watch, type, and close without VS Code.

How it works

  • PtySessionManager (server/src/terminal/): one PTY per agent via @lydell/node-pty (optional dep, node-pty fallback, availability probed by actually spawning). PTYs outlive the WebSocket, so a reload reattaches to the still-running session.
  • Data plane: WS /terminal/:agentId, deliberately outside the AsyncAPI contract (raw byte stream, not control-plane state). Control plane gains three additive ServerMessage variants (terminalAvailability, terminalSessionOpened, terminalSessionClosed).
  • Launcher (standaloneAgentLauncher.ts): mirrors the VS Code launch path — provider.buildLaunchCommand(), pre-registered JSONL, existing file-watcher/session-router machinery.
  • Webview: TerminalDrawer with one xterm tab per launched agent (character mug-shot cards double as tabs), standalone-only — VS Code keeps its own terminal panel.
  • Faithful reattach: each PtySession mirrors output into a headless xterm; every attach sends a @xterm/addon-serialize snapshot of the current screen (a raw byte ring garbles a full-screen TUI). Client-side, xterm open() is deferred until the pane has real dimensions so cell metrics are never measured while hidden.

Security

/terminal/:agentId is arbitrary code execution, so unlike /ws it authenticates in both modes: bearer token via WS subprotocol (not URL — Fastify logs req.url), same-origin guard (CORS reflects any origin; WS is CORS-exempt), and a loopback-Host allowlist that blunts DNS rebinding. See docs/design/standalone-terminal.md.

Opt-out

--no-terminal keeps the office watch-only: launch button disabled with the reason as tooltip, hook-driven agents still render. Default is on.

Testing

  • Unit: PTY lifecycle, snapshot semantics (screen state, not byte history), auth incl. DNS-rebound attach, replay-before-live ordering on a real socket, launcher refusal paths, control-plane dispatch.
  • E2E: browser-launched agent end to end with the mock claude in a real PTY (output, keystroke echo, hook routing, reload reattach, close cleanup) plus the --no-terminal watch-only path. PTY spawn of the .cmd mock shim is untested on Windows (recorded in the design doc).

🤖 Generated with Claude Code

pablodelucca and others added 14 commits July 17, 2026 16:18
…sion, security model

Design doc gating the implementation of an embedded terminal in the standalone
SPA. Records the measured evidence behind the node-pty decision:

- node-pty@1.1.0 ships prebuilds for darwin/win32 only — no linux-x64/arm64, so
  `npx pixel-agents` on Linux would need python + a C++ toolchain via node-gyp.
- npm >=11.16 gates lifecycle scripts by default, so node-pty's prebuild copy and
  its spawn-helper chmod never run: the module imports fine and then throws
  `posix_spawnp failed` on first spawn. Availability must therefore be probed by
  actually spawning a PTY, not by a successful require().
- @lydell/node-pty ships all six platform/arch targets as prebuilt
  optionalDependencies with no install scripts, and works out of the box.

Also records why raw terminal I/O stays out of the AsyncAPI contract (data plane
vs control plane), and why the terminal WS must authenticate even though /ws does
not in standalone (WebSockets are exempt from CORS, and cors({origin:true})
reflects any origin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dependencies for the standalone embedded terminal, added ahead of the code that
uses them so the lockfile stays consistent at every commit.

@lydell/node-pty is an OPTIONAL dependency, chosen over the official node-pty
after measuring both (docs/design/standalone-terminal.md):

- node-pty@1.1.0 ships prebuilds for darwin-{x64,arm64} and win32-{x64,arm64}
  only. There is no linux-x64/linux-arm64 prebuild, so `npx pixel-agents` on the
  most common server/dev-container platform would fall back to `node-gyp rebuild`
  and require python plus a C++ toolchain.
- npm >=11.16 gates lifecycle scripts by default, so node-pty's prebuild copy and
  its spawn-helper chmod don't run at all: it imports fine and then throws
  `posix_spawnp failed` on first spawn.
- @lydell/node-pty ships all six platform/arch targets as prebuilt
  optionalDependencies with no install scripts (the model esbuild uses), and
  works out of the box.

Optional, so a platform without a prebuild still installs cleanly — the terminal
just reports itself unavailable. The loader tries a candidate list, so anyone who
prefers the Microsoft package can install it and have it picked up.

The PTY candidates are esbuild externals: native .node binaries cannot be
bundled, and the runtime require() must survive so its failure can be caught.
xterm is bundled by Vite into the SPA — the standalone server serves every asset
itself, never a CDN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gives the standalone server the terminal-lifecycle role vscode.Terminal plays for
the extension. `launchAgent` and `closeAgent` were previously dropped on the
floor in standalone (they fell through to `default:` as "IDE-specific"), so the
browser could only observe agents, never start one.

- ptyModule.ts: loads the first working PTY module from a candidate list.
  Availability is established by ACTUALLY SPAWNING a throwaway PTY, not by a
  successful require(): under npm's install-script gating the module imports fine
  and dies on first spawn, so a try/catch around require would report the
  terminal as working and break on the user's first keystroke.
- ptySessionManager.ts: spawn/write/resize/dispose keyed by agent id, bounded
  256KB scrollback ring so a reconnecting browser replays recent output instead
  of seeing a blank terminal, SIGHUP->SIGKILL escalation.
- standaloneAgentLauncher.ts: mirrors the VS Code launch path rather than sharing
  a seam with it (that function imports vscode and takes twelve positional deps;
  unifying it would rewrite the VS Code lifecycle and collide with the sibling
  branches). Both reuse provider.buildLaunchCommand, so the command is shared.
- terminalRoutes.ts: GET /terminal/:agentId (WS) + GET /api/terminal/session.

Security — the terminal route authenticates in BOTH modes, unlike /ws which
skips auth in standalone reasoning that a loopback bind suffices. That reasoning
doesn't hold for a shell: WebSocket connections are exempt from CORS, so any page
the user visits can open a socket to 127.0.0.1, and cors({origin:true}) reflects
any Origin, which would let any site fetch the token. Hence token + same-origin
guard. The token rides in Sec-WebSocket-Protocol, not a query param, because
standalone runs Fastify with logger:true and would write it to the request log on
every connection.

Protocol additions are strictly additive (three ServerMessage variants). The raw
byte stream stays out of the AsyncAPI unions: it's a data plane, and routing it
through the store's broadcast would fan one agent's output out to every client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The browser half of standalone launch/focus/close parity: a bottom drawer with
one xterm tab per launched agent. Launching opens the drawer; clicking a
character focuses that agent's tab — the standalone counterpart of VS Code's
terminalRef.show(), resolved client-side because focusAgent has nothing to do
server-side (there is no editor panel to raise).

- terminalClient.ts: per-agent reconnecting WebSocket. Deliberately NOT routed
  through MessageTransport: that seam carries the AsyncAPI control plane over one
  shared socket, while this is a per-agent raw byte stream. Reconnect replays the
  server's scrollback, and 4401/4404 are treated as terminal (retrying a
  rejected auth is pointless).
- TerminalPane.tsx: one xterm per agent, driven imperatively through refs (the
  pattern OfficeCanvas already uses). Hidden panes stay mounted so switching tabs
  doesn't drop the buffer or force a reconnect.
- TerminalDrawer.tsx: tab bar with per-agent connection status.
- BottomToolbar: `+ Agent` was hidden in browser mode ("no terminal to interact
  with"). It now shows when the server reports a working PTY, and renders
  disabled with the reason when it doesn't — silently missing UI reads as a bug,
  and a native module that failed to install is otherwise undiscoverable.

Terminal CONTENT uses a mono font (a TUI mis-renders in FS Pixel Sans); the
drawer chrome around it keeps the pixel font. xterm's theme lives in
constants.ts, the one file where the no-inline-colors rule permits literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two rendering bugs found by driving the real SPA in a browser; both are
invisible to assertions that only check the terminal has text in it.

1. Terminal content rendered in FS Pixel Sans, not monospace. index.css's base
   layer applies `* { font-pixel }` to EVERY element, including the span xterm
   renders each cell into, which beat the font xterm sets on its container. A
   proportional face breaks column alignment for a full-screen TUI. Re-asserted
   the mono stack in the components layer (`.xterm *` outranks `*`), fed from the
   same constant xterm measures its cell grid with so CSS can't drift from
   metrics. Drawer chrome keeps the pixel font.

2. The drawer is full-width along the bottom and buried the BottomToolbar, so
   + Agent / Layout / Settings became unclickable as soon as an agent launched.
   The toolbar now lifts by the drawer's height via a --terminal-drawer-h custom
   property published by App. It defaults to 0px, so with no drawer the toolbar
   is exactly where it was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leak (review)

The terminal token endpoint and WS route guarded only same-origin by
comparing the request's Origin host against its Host header. A DNS-rebound
page sends BOTH Origin: http://evil.com AND Host: evil.com (the Host header is
the URL hostname the browser derives from the rebound domain), so the two
match and the guard passed. Verified end-to-end against dist/cli.js: a rebound
request fetched the real terminal token (HTTP 200) and, combined with the
standalone /ws (unauthenticated) launchAgent, attached a live shell.

Add a Host-header allowlist: when the server is bound to loopback (the
default), the Host must name a loopback host (127.0.0.1 / localhost / ::1).
A rebound request's Host is always the attacker's domain, so it is now
rejected at both /api/terminal/session (403) and /terminal/:agentId (4401),
before any PTY attach or scrollback replay. When the operator has deliberately
bound off-loopback (a warned, opt-in exposure) the clause is skipped and the
auth token remains the guard — no legitimate LAN Host is broken.

Centralises the loopback host set in core constants (reused by the CLI's
off-loopback warning). Adds pure-function and live-socket regression tests for
the rebinding shape, and corrects the design doc, which claimed the
same-origin check blunted rebinding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iew)

fetchTerminalSession() memoised the request with `sessionPromise ??= fetch(...)`
so all tabs share one call. But a REJECTED promise was cached too: a single
transient failure (server momentarily unreachable, a reconnect racing a
restart) pinned a rejected promise forever, so every subsequent connect() and
reconnect awaited the same rejection and the terminal could never recover even
after the server returned.

Clear the cache in a .catch so only successful fetches are memoised; the next
connect()/reconnect re-fetches. Success-path dedupe is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the terminal panel's tab strip with a transparent card bar drawn
over the office's right edge. One card per agent — pixel mug shot, close
button, activity dot — extracted into AgentCard.tsx and visible at all
times, whether the panel is open or closed.

- Panel opens to the right of the bar; no chrome beyond the resize
  handle and a ghost close button floating top-right (bg on hover only).
- No toggle button: selecting an agent (card or character) opens the
  panel; the card click also selects the character in the office and
  follows it with the camera, mirroring a canvas click.
- Cards are the tabs: mug shot identity (CharacterMugShot crops the
  front-facing sprite head), status dot for idle/working/attention plus
  drawer-owned disconnected, awaiting-input and seen-activity tracking
  in useExtensionMessages.
- Panel stays mounted while closed (display:none), so xterm buffers and
  sockets survive toggling instead of tearing down and reconnecting.
- Fix reload restore: the standalone webviewReady burst sends
  layoutLoaded before existingAgents, so the webview now adds restored
  agents immediately when layout is already ready — characters reappear
  in their seats with their persisted palettes instead of vanishing
  while the cards fall back to skin 0.
- Pixel-style the xterm scrollbar: transparent track, slim square thumb,
  accent on hover; nothing paints when the TUI doesn't overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/ws carries the AsyncAPI control plane, and in standalone it can now spawn
agents (launchAgent runs claude --dangerously-skip-permissions in a
caller-chosen cwd). It previously skipped auth in standalone, trusting the
127.0.0.1 bind — but a browser is a local client, so any page the user
visits could open ws://127.0.0.1/ws (WebSocket connections bypass CORS) and
drive it. That was an unauthenticated process-spawn / RCE surface.

Require the bearer token on /ws in BOTH modes, mirroring the terminal
socket's posture:

- Non-browser clients (VS Code host, MCP bridge, curl) present it as
  `Authorization: Bearer <token>` — the MCP bridge already sends this
  unconditionally, so it complies for free.
- The browser SPA can't set a WebSocket header, so it rides the token as
  the second subprotocol value (CONTROL_WS_PROTOCOL), the same mechanism
  the terminal socket uses; a `?token=` param is avoided so the token never
  hits the request log.
- Plus the same-origin + loopback-Host guard as the terminal socket, as
  defence in depth against DNS rebinding.

The SPA fetches the token from a new same-origin-guarded /api/session
endpoint (standalone only) before connecting; a cross-origin or rebound
page can't read it. The transport re-fetches on every (re)connect so a
restarted server's new token is picked up, and skips the real socket in
Vite dev (browserMock has no server to hand out a token).

extractTokenFromProtocolHeader gains an expected-protocol parameter so the
terminal and control sockets share one parser. Covered by a new wsAuth
integration test (no/wrong/valid token across both header and subprotocol
paths; the session endpoint's same-origin guard) and verified live against
the built CLI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The terminal is on by default; --no-terminal opts out for watch-only
setups (e.g. a dashboard bound off-loopback) where exposing a shell
surface is unwanted. A disabled PtySessionManager reports unavailable
through the existing plumbing -- availability broadcast, session route,
launcher -- so the browser shows a disabled + Agent button with the
reason and no downstream code needs a special case. The native PTY
module is never resolved or probed when disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…flow

Fills the three coverage gaps on this branch:

- standaloneAgentLauncher unit tests: launch registration, real
  buildLaunchCommand shape, refusal paths that must not burn agent ids,
  PTY-exit cleanup, and the JSONL adoption poll.
- clientMessageHandler terminal control plane: terminalAvailability on
  webviewReady (incl. the --no-terminal reason and VS Code-mode absence),
  live-session re-announcement ordering, launchAgent/closeAgent routing.
- e2e standalone terminal spec: browser + Agent spawns the mock claude in
  a server PTY, drawer tab + xterm output, keystroke echo round-trip,
  reload reattach with scrollback replay, close cleanup; and --no-terminal
  keeps the office watch-only with hook-driven agents still rendering.
  The PTY-launch test skips on Windows (spawning the .cmd shim without a
  shell hop is the exact untested risk the design doc records).

The standalone e2e helper learns cliArgs + mockClaude options (mock bin
on the host's PATH), and the fixture exposes them via test.use and
attaches the mock invocation log on teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… terminal panel

The standalone page still carried Vite's scaffold <title>webview-ui</title>.
The terminal panel's floating close button now reads '>' instead of '×' —
it collapses the panel toward the right edge rather than destroying anything,
and the glyph should say so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…creen snapshots

Reloading the standalone page glitched the terminal in two ways, both
rooted in reattach:

1. Narrow-column squeeze. TerminalPane mounted one commit before App
   flips the drawer open, so xterm's open() measured its cell grid inside
   a display:none subtree; the poisoned metrics made the first visible
   fit() compute ~10 columns and resize the PTY to match. open() is now
   deferred to the first fit that sees real dimensions (xterm buffers
   earlier writes), and a next-frame self-check re-fits whenever the grid
   stops matching what the container calls for.

2. Garbled overlapping fragments. Reconnects replayed the raw scrollback
   byte ring, which is unreplayable for a full-screen TUI: it starts
   mid-stream and its cursor movements assume screen state a fresh client
   doesn't have — and with unchanged PTY dims no SIGWINCH ever triggered
   a repaint. Each PtySession now mirrors its output into a headless
   xterm (@xterm/headless) and every attach sends a {type:"replay"} frame
   first: an @xterm/addon-serialize snapshot of the current screen plus
   the geometry it was laid out at. The client resets in-band (RIS),
   resizes to that geometry, writes the snapshot, then re-fits to its
   container. Frames racing the snapshot are queued server-side and
   flushed after it, so nothing is dropped or doubled in the handoff.

The mirror lives only in the CLI bundle — the VS Code adapter only ever
type-imports the terminal modules.

Unit tests pin the new semantics (snapshot reflects current screen, not
byte history; bounded scrollback; replay-before-live ordering on a real
socket), and the standalone e2e suite still passes end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Windows E2E shard 3 failed on standalone hosts dying at boot (health
ECONNREFUSED, server.json timeouts, resets), intermittently. New on this
branch: every standalone host boot runs the PTY availability probe, and
on Windows that spawns a ConPTY and kills it immediately — the instant
kill races the conpty agent's socket connect, a known node-pty crash
mode that can take the whole process down. The probe only exists to
catch the POSIX-specific spawn-helper chmod failure (npm script
gating); ConPTY has no helper binary, so on win32 a successful import
is the whole test and the spawn probe is now skipped.

Also:
- terminal.spec's win32 skip moves to describe level — the standalone
  fixture launches the mock-PATH host before a test body runs, so the
  in-body skip fired too late to prevent the doomed host launch.
- launchStandalone folds host stdout/stderr into setup-failure errors;
  a host that dies at boot on CI previously left nothing to diagnose.
- prettier on ptySessionManager.test.ts (CI format check; the commit
  hook's glob misses nested server test files).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant