Termkit

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-daemon

Env: 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

OptionTypeNotes
resolveCwd(meta) => string | undefinedPick a working directory from the connect metadata. Falls back to process.cwd().
shellstringDefaults to $SHELL, then bash.
envRecord<string, string>Merged over process.env for spawned shells.
scrollbacknumberLines retained per session (replayed on reattach).
cols / rowsnumberInitial PTY size.
pathstringHTTP + 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.

On this page