Server & daemon
The PTY daemon, screen model, and protocol in @mp-lb/termkit-server.
@mp-lb/termkit-server is the Node side: the daemon that owns the PTYs, the headless
screen model it uses for clean reattach, and an HTTP client for controlling a running
daemon.
Why a separate daemon
PTY sessions live in their own process, separate from whatever dev/app server proxies to them. Restarting that server (config changes, code reloads) doesn't kill running shells — restarting the daemon is the deliberate "reset everything." On reattach the daemon replays a serialized snapshot of the current screen, so a reconnect repaints cleanly instead of garbling.
Standalone
The simplest way to run it — shells open in the daemon's own cwd:
TERMKIT_PORT=5179 pnpm termkit-daemonEnv: TERMKIT_PORT (or PORT), TERMKIT_PATH (the route, default /_terminal).
Embedded with working-directory resolution
For anything beyond a single fixed directory, call createTerminalDaemon and supply
resolveCwd. This is the seam that keeps the daemon free of host-specific layout: it
receives the connect metadata (the params from the browser) and returns a directory.
import { createTerminalDaemon } from '@mp-lb/termkit-server';
import path from 'node:path';
const daemon = createTerminalDaemon({
resolveCwd: (meta) => (meta.slug ? path.join(projectsDir, meta.slug) : undefined),
});
await daemon.listen(5179);TerminalDaemonOptions
| Option | Type | Notes |
|---|---|---|
resolveCwd | (meta) => string | undefined | Pick a working directory from the connect metadata. Falls back to process.cwd(). |
shell | string | Defaults to $SHELL, then bash. |
env | Record<string, string> | Merged over process.env for spawned shells. |
scrollback | number | Lines retained per session (replayed on reattach). |
cols / rows | number | Initial PTY size. |
path | string | HTTP + WebSocket route. Default /_terminal. |
TerminalDaemon
createTerminalDaemon returns { server, listen, close, list, kill, markSeen }.
server is the bare http.Server (attach it to an existing port or share it);
list/kill/markSeen are the same operations the HTTP routes expose, for hosts that
embed the daemon rather than talk to it over HTTP.
Controlling a running daemon
createTerminalClient talks to a daemon's HTTP routes from your own backend — the
point being you wrap these in your own API (e.g. a tRPC router) rather
than exposing the daemon to the browser directly. The WebSocket byte stream stays a
raw proxy; only these control routes go through the client.
import { createTerminalClient } from '@mp-lb/termkit-server';
const client = createTerminalClient({ port: 5179 });
await client.list(); // LiveSession[]
await client.kill(id);
await client.seen(id);The wire protocol
From @mp-lb/termkit-core:
- Client → server messages are JSON:
{ type: 'input', data },{ type: 'resize', cols, rows },{ type: 'ping' }. - Server → client is the raw PTY byte stream as text frames (plus one serialized
screen snapshot on attach) — never JSON-wrapped, so it pipes straight into xterm.
The one control frame, the keepalive
pong, is sent as a binary frame so it stays distinguishable from terminal output.
You don't normally touch this — <Terminal> and the daemon speak it for you.