10 KiB
@elizaos/plugin-pty
Interactive PTY terminal service for elizaOS. Registers PTY_SERVICE — the one
piece the app's already-built web terminal needs to drive a real interactive
CLI (most importantly the interactive eliza-code CLI running on Eliza
Cloud/cerebras).
Purpose / role
The elizaOS app ships the full front half of a web terminal: the xterm UI
(PtyTerminalPane), the typed client methods (spawnShellSession,
subscribePtyOutput, sendPtyInput, resizePty), and the agent-server
WebSocket handlers (pty-subscribe / pty-input / pty-output / pty-resize).
Those handlers call getPtyConsoleBridge(state), which resolves
runtime.getService("PTY_SERVICE")?.consoleBridge. Without a registered
PTY_SERVICE, that bridge is null and the terminal is inert.
This plugin supplies that service. It is the missing keystone — everything else already exists, so this connects three finished pieces (xterm UI, WS keystroke path, interactive CLI) rather than building them.
It is opt-in: add @elizaos/plugin-pty to an agent's plugin list. It has no
autoEnable, so it stays dormant fleet-wide unless a character explicitly loads
it (intended for the developer-gated cockpit). It disables interactive spawning
automatically on store builds.
Why eliza-code on cerebras (not the Claude/Codex CLIs)
Running a real interactive CLI on a subscription inherently means impersonating
that vendor's CLI — the TOS-unsafe tier. eliza-code (packages/examples/code)
is a real interactive slash-command TUI we own: it already implements
/help, /clear, /task, etc., and selects its model provider purely from env.
Pointing it at Eliza Cloud's OpenAI-compatible endpoint routes inference to
cerebras (gemma-4-31b for both fast and smart) — a real CLI with all slash
commands, on any device, with zero TOS exposure.
Experimental vendor-CLI tier (kind claude / codex, #10832 Phase 2)
The same PTY_SERVICE can spawn the real interactive Claude Code /
Codex CLIs on the user's own subscription — the TOS-unsafe tier. It is
gated by PTY_VENDOR_CLI_ENABLED (a separate gate from
PTY_INTERACTIVE_ENABLED, default off, exact truthy allowlist only, never
on store builds); when off, spawning kind: "claude" | "codex" is rejected
with a 403 even if interactive spawning is on.
Spec builders live in lib/vendor-cli-spec.ts (buildClaudeCliSpec /
buildCodexCliSpec + resolveClaudeCliBin / resolveCodexCliBin). Both CLIs
launch PLAIN (no args) — the interactive TUI, not the claude --print /
codex exec one-shot paths. Credentials reuse the existing subscription-plugin
conventions: claude gets CLAUDE_CODE_OAUTH_TOKEN passed through when
configured (plugin-anthropic-proxy's env credential path), else reads
~/.claude/.credentials.json itself via the inherited HOME; codex gets
CODEX_HOME passed through when configured (the coding-account-bridge
per-account convention), else reads ~/.codex/auth.json. The tokens are
opaque passthroughs — never parsed, logged, or persisted here.
Plugin surface
- Service
PtyService(serviceType = "PTY_SERVICE",services/pty-service.ts) — exposesconsoleBridge(theConsoleBridgethe agent server drives) plusstartSession/stopSession/listSessions/hasSession. - Routes (
routes/pty-routes.ts, authenticated + terminal-token gated for HTTP callers,rawPath):POST /api/pty/sessions— spawn (kind: "eliza-code"default, or the gated"claude"/"codex"; returns{ session }).GET /api/pty/sessions— list live sessions.DELETE /api/pty/sessions/:id— kill a session.
Runtime-aware PTY engine
node-pty's write path is broken under Bun (this._socket.write is not a function — output streams, keystrokes throw), and the agent runs under Bun in
dev. So defaultSpawnResolver (services/pty-session-store.ts) picks the engine
by runtime:
- Bun → Bun's native truePty (
Bun.spawn({ terminal })), the same engine the Electrobun host uses (services/bun-pty-spawn.ts). One gotcha handled here: the terminalexitcallback reports the PTY-teardown status (always1), so the real exit code is taken fromproc.exited. - Node →
@lydell/node-pty(optional native dependency), which works end to end.
Both are adapted to one PtyHandle interface, so PtySessionStore is
engine-agnostic and unit-testable with an injected fake PTY (test/fake-pty.ts).
Layout
index.ts Plugin def (services + routes + dispose) and public exports
services/
pty-service.ts PtyService — the PTY_SERVICE registration
pty-session-store.ts PtyConsoleBridge + PtySessionStore + defaultSpawnResolver
bun-pty-spawn.ts Bun native truePty adapter (isBunRuntime, bunTruePtySpawn)
pty-contract.ts ConsoleBridge/event contract (mirror of packages/agent)
pty-types.ts PtyHandle / PtySpawn / PtySpawnSpec / PtySessionInfo
lib/
eliza-code-spec.ts buildElizaCodeCerebrasSpec + resolveElizaCodeBin (pure)
vendor-cli-spec.ts buildClaudeCliSpec / buildCodexCliSpec + bin resolvers (pure)
routes/
pty-routes.ts spawn / list / stop route handlers
test/
fake-pty.ts Controllable in-memory PTY double + fake spawn
eliza-code-spec.test.ts Spec builder + bin resolver
vendor-cli-spec.test.ts Vendor spec builders + bin resolvers
pty-session-store.test.ts Bridge routing, streaming, lifecycle, confinement, cap
pty-service.test.ts Service wiring
pty-routes.test.ts Route handlers (gates, errors, spawn/list/stop)
pty.real.test.ts Gated real PTY coverage (excluded from the normal lane)
Commands
bun run --cwd plugins/plugin-pty build # tsup ESM + declarations
bun run --cwd plugins/plugin-pty test # vitest unit suite
bun run --cwd plugins/plugin-pty typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-pty lint
Config / env vars
| Variable | Default | Purpose |
|---|---|---|
PTY_INTERACTIVE_ENABLED |
true |
Explicit values enable only on true, 1, on, or yes; any other non-empty value disables spawning. Store builds also disable spawning. |
PTY_ALLOWED_DIRECTORY |
process cwd | Directory sessions are confined to. |
ELIZA_TERMINAL_RUN_TOKEN |
— | Required step-up token for remote HTTP access to PTY spawn/list/stop routes. Trusted loopback cockpit traffic is allowed server-side without exposing the token to browser JavaScript. |
PTY_ELIZA_CLOUD_API_KEY |
— | Dedicated Eliza Cloud key eliza-code authenticates with. Do not use the agent server's primary OPENAI_API_KEY for PTY sessions. |
PTY_ELIZA_CLOUD_FAST_MODEL / PTY_ELIZA_CLOUD_SMART_MODEL |
gemma-4-31b |
Optional deployment pins for the fast/smart eliza-code model ids. Request body model values still take precedence. |
PTY_ALLOWED_BASE_URLS |
Eliza Cloud API | Comma-separated operator allowlist for non-default OpenAI-compatible base URLs. |
PTY_IDLE_TIMEOUT_MS |
900000 |
Idle live-session timeout. Set 0 to disable the fallback reaper. |
ELIZA_CODE_BIN |
auto-resolved | Absolute path to built eliza-code dist/index.js. |
PTY_VENDOR_CLI_ENABLED |
false |
Experimental vendor-CLI tier (kind: "claude" | "codex" — the real vendor CLI on the user's own subscription). Enables only on true, 1, on, or yes; never on store builds. |
PTY_CLAUDE_BIN / PTY_CODEX_BIN |
PATH lookup | Absolute path to the claude / codex launcher. |
CLAUDE_CODE_OAUTH_TOKEN |
— | Optional Claude Code OAuth token passed through to kind: "claude" sessions; without it the CLI reads ~/.claude/.credentials.json. |
CODEX_HOME |
— | Optional codex auth dir passed through to kind: "codex" sessions; without it the CLI reads ~/.codex/auth.json. |
ELIZA_BUILD_VARIANT |
— | store disables interactive spawning (including the vendor tier). |
How the cerebras wiring works
buildElizaCodeCerebrasSpec sets the env eliza-code reads
(packages/examples/code/src/lib/model-provider.ts):
ELIZA_CODE_PROVIDER=openai, ELIZA_CODE_CODING_ONLY=1, OPENAI_API_KEY, OPENAI_BASE_URL
(https://api.elizacloud.ai/v1), and OPENAI_{SMALL,MEDIUM,LARGE}_MODEL. The
tier (fast/smart) controls which model small/medium lead with; large is
always the smart model so heavy calls escalate. CODING_TOOLS_WORKSPACE_ROOTS
and SHELL_ALLOWED_DIRECTORY confine eliza-code's own file/shell tools to the
session cwd. --coding-only / ELIZA_CODE_CODING_ONLY=1 keeps the cockpit REPL
from loading the orchestrator and recursively spawning sub-agents.
Conventions / gotchas
pty-contract.tsmust stay structurally in sync withConsoleBridge/PTYServiceinpackages/agent/src/api/parse-action-block.ts. We redeclare it (not import from@elizaos/agent) to avoid a dependency cycle — the runtime binds them at thegetServicecast.session_output/session_exitare the two bridge events. The agent server subscribes tosession_output({ sessionId, data }).- node-pty is an
optionalDependency(native). Under Bun it isn't used at all; under Node it is required for spawning. - Never log the spawn request body — it can carry an API key.
- PTY child processes do not inherit the full server
process.env; only a small runtime allowlist plus explicit eliza-code spec env is passed through. - See the root
AGENTS.mdfor repo-wide conventions.
⛔ NON-NEGOTIABLE — evidence & real end-to-end tests
The binding standard is AGENTS.md. The unit suite
proves the store/bridge/routing/spec logic against an injected PTY; the gated
pty.real.test.ts (and the manual real-runtime checks) prove the actual
node-pty / Bun-truePty path spawns real processes, streams output, round-trips
keystrokes, and reports exit codes. The full "real CLI on a phone" proof —
interactive eliza-code answering /help against live cerebras on-device —
requires a built eliza-code bundle + a real Eliza Cloud key and is captured as
the device handoff.