procdeck
v0.5.0
Published
Interactive dev-process multiplexer with a web UI: terminal panes in a browser tab, declarative dependencies, assigned ports and a *.localhost reverse proxy.
Maintainers
Readme
procdeck
An interactive dev-process multiplexer with a web UI instead of a TUI — the thing
mprocs/overmind do, but the panes live in a browser tab, and the tool also solves
the two problems terminal multiplexers can't touch: port conflicts and remembering
which port is which.
┌──────────┬───────────────────────────────────────────────┐
│ ● api │ Restart Stop Start api.localhost:4820 │
│ ● web ├───────────────────────────────────────────────┤
│ ● clock │ $ node servers/api.mjs │
│ ● flaky ⚠│ api listening on :52341 │
│ │ GET / │
└──────────┴───────────────────────────────────────────────┘What it does
- Terminal panes in the browser. Every process runs in a real PTY and renders in xterm.js — colours, spinners, cursor redraws and interactive hotkeys work exactly like in a terminal. Keystrokes are posted back into the PTY.
- Assigned ports. Write
${port}in a proc's command, env or url and procdeck hands it a free port before spawning (also exported asPORT).${port:api}references another proc's assigned port — so dependents get wired (API_URL=http://localhost:${port:api}) without a single hardcoded number. Nothing can conflict: ports are assigned once per run and never collide. - A reverse proxy on
*.localhost. Every pane is reachable at a stable, memorable address on the UI's own port —api.localhost:4820,web.localhost:4820— whatever port the process actually got. Browsers hardcode*.localhost→ 127.0.0.1, so there is zero system setup. WebSocket upgrades pass through (vite HMR works); theHostheader is rewritten upstream so host-allowlisting dev servers need no config. - Port auto-detection. A 1s poll asks the OS (
pgrep+lsof) which TCP ports each process group listens on; they render as links in the UI. No cooperation from the process needed. - Declarative dependencies.
needs: ["api"]parks a proc inwaitinguntil every dependency is ready. Ready = "listening on its port" by default, orreadyWhen: "started"for procs that never listen. Unknown names, duplicates and cycles are rejected by the config schema. - Preflight gates. A shell command that must pass before the proc spawns (plus an
optional
expectregex for checks that exit 0 either way, likewrangler whoami). Failure parks the pane inblockedwith the output and a hint for the human — keeps interactive auth flows out of supervised panes, where a restart would kill an OAuth callback server mid-handshake. - Alerts. Regexes matched against a rolling tail of pane output; a match raises a badge in the UI ("needs login").
- Knows when you're not looking. Crashed, blocked or alerting procs and unread error
lines count into the tab title (
(2) garage) and a red dot on the favicon; turn on the bell and a crash or alert while the tab is hidden becomes a system notification. - Whole-tree restarts.
pnpm→wrangler→workerd: killing the pid would leave the grandchild holding the port. procdeck signals the process group, escalating SIGTERM → SIGKILL, and Ctrl-C tears down every tree it spawned. - Single or grid layout. One pane at a time with a sidebar, or every pane tiled (⌥G, ⌥Z zooms one tile in and out); each pane header carries its address, status and restart/stop/clear. Too many tiles? Pin the ones you watch (📌, ⌥P) — the grid shows only those and the rest collapse into a tray strip with their status dots and badges, one click from a peek. Layout, pins, selected pane and theme (system / light / dark — terminals included) survive a reload.
- Runs in the background.
procdeck upstarts the deck, opens the UI and returns — no terminal tab kept hostage.down,restart,status,logsandopenwork from anywhere inside the project;procdeck lslists every deck on the machine; ⏻ in the UI shuts one down. Prefer a terminal?up --fg. - Installable as an app. The UI ships a web-app manifest named after the deck
(
namein the config, else the directory), so "⤓ install" in the bar — or File → Add to Dock in Safari — gives each project its own window and Dock icon. Works over plainhttp://localhost, no HTTPS needed. Several projects? The deck name in the bar lists the other running decks, one click away.
Install
Nothing to install, if you don't want to:
npx procdeck init # writes procdeck.config.json from your workspace's dev scripts
npx procdeck # picks up ./procdeck.config.json
npx procdeck decks/backend.json # or point it at any config fileThe deck comes up in the background, the UI opens at http://localhost:4820, and the terminal is yours again. Requires Node ≥ 22.
Or add it to the project, which is what you want if you keep the config in TypeScript:
pnpm add -D procdeck # npm i -D procdeck · bun add -d procdeckCommands
procdeck init # write procdeck.config.json: one proc per workspace dev script
procdeck up [config] # start (detached) and open the UI — idempotent
procdeck down # stop: every process tree is terminated
procdeck restart # down + up — after editing the config or updating procdeck
procdeck status # this project's deck: address, uptime, every proc's state
procdeck ls # every running deck on this machine
procdeck open # open the UI — no port to remember
procdeck logs [-f] # procdeck's own log (startup, shutdown, errors)
procdeck up --fg # foreground instead: Ctrl-C stops the deckprocdeck alone is procdeck up. Without a config path, the nearest
procdeck.config.{json,ts,js,mjs} up from the current directory is used, so every
command works from any subdirectory of the project. Running decks register in
~/.procdeck/instances/ (one JSON per deck — that is how down, ls and the UI's
deck switcher find them; stale entries are pruned by pid), and a detached deck's own
output goes to ~/.procdeck/logs/. --no-open skips the browser. Updating procdeck
needs a procdeck restart — the running deck keeps the old code until then.
The server binds 127.0.0.1 only — the UI types into real terminals, so it is not
something to put on the LAN by accident. "host": "0.0.0.0" in the config opens it up
(a devcontainer whose browser is on the host, a VM). No telemetry, no service worker.
Try the example
In a clone of this repo:
pnpm install
pnpm dev # builds the UI, starts the example stack in the foregroundInstall footprint. ~2 MB, one dependency. The server is shipped as a bundle, so nothing but the PTY bindings is installed — and those are
@lydell/node-pty: the same sources as node-pty, distributed as per-platform packages. Nothing compiles and no install script runs, so there is nopnpm approve-builds/trustedDependenciesdetour, and no compiler is needed on Linux.
Open http://localhost:4820 for the panes, then http://web.localhost:4820 and
http://api.localhost:4820 — the example's two servers found each other (and their own
ports) entirely through ${port} templates. See example/.
Config
procdeck init writes a first one from what is already there, trying in order: a
Procfile (already a list of processes); workspaces (pnpm-workspace.yaml,
workspaces in package.json) — one proc per package with a dev (or start:dev,
serve, watch, start) script, run through the package manager your lockfile
points at (pnpm --filter web dev, yarn workspace web dev, …); plain
subdirectories — backend/, frontend/, each with its own package.json (its own
lockfile decides the manager) or a recognisable non-JS project (Django, Go, Rust, Rails,
Phoenix, docker compose), run with cwd set; and finally the root itself. Ports and
dependencies are yours to add; the tips it prints say how.
Two formats, one schema. JSON needs nothing from your toolchain — no TypeScript, and
procdeck itself doesn't have to be a dependency. Point $schema at the published schema
and the editor completes and validates every field:
{
"$schema": "https://unpkg.com/procdeck/schema.json",
"port": 4820,
"procs": [
{ "id": "api", "shell": "pnpm --filter api dev", "env": { "PORT": "${port}" } },
{
"id": "web",
"shell": "pnpm --filter web dev",
"env": { "PORT": "${port}", "API_URL": "http://localhost:${port:api}" },
"needs": ["api"]
}
]
}(Installed locally, "./node_modules/procdeck/schema.json" works too and needs no
network.)
TypeScript buys comments and computed configs, at the price of Node ≥ 22.18 (native type stripping) and procdeck in your dependencies:
import { defineConfig } from "procdeck"
export default defineConfig({
port: 4820, // UI + proxy port
procs: [
{
id: "api", // pane name and the proxy subdomain
shell: "pnpm --filter api dev", // or cmd: ["node", "server.js"]
env: { PORT: "${port}" }, // ask procdeck for a free port
preflight: { // gate: must pass before spawning
shell: "wrangler whoami",
expect: "You are logged in",
hint: "run `wrangler login`, then Start",
},
alerts: [{ pattern: "ERROR", label: "check me" }],
},
{
id: "web",
shell: "pnpm --filter web dev",
env: { PORT: "${port}", API_URL: "http://localhost:${port:api}" },
needs: ["api"], // wait until api is listening
},
{
id: "worker",
shell: "node worker.js",
readyWhen: "started", // never listens; running = ready
},
],
})Full field reference: packages/procdeck/src/config.ts
(the schema is the documentation — schema.json is generated from it at build time, so
the two formats validate identically). Config files are looked up in this order when no
path is given: procdeck.config.json, .ts, .js, .mjs.
Tips:
- Give tools that auto-increment on a busy port (vite) a
strictPortflag, so a violated port assignment fails loudly instead of drifting. - A good pattern for app dev scripts:
vite dev --port ${PORT:-3000} --strictPort— fixed default when run by hand, assigned port under procdeck. - The proxy is for humans and browsers. Scripts and server-to-server calls should use
the injected env (
${port:api}) — system resolvers don't all know*.localhost.
How it fits together
| Path | What |
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
| packages/procdeck/src | The server: plain-Node PTY layer, Effect supervisor, HTTP/SSE server with the reverse proxy. |
| packages/procdeck/ui | Foldkit (Elm-on-Effect) app; built by vite, served statically by the server. |
| example/ | Self-contained demo stack (Node one-liners) exercising every feature. |
| File | What |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| proc.ts | Plain Node: PTY spawn, process-group kill, SIGTERM → SIGKILL escalation. |
| config.ts | Effect Schema for the config, ${port} templating, loader. |
| supervisor.ts | Effect layer: scoped lifecycle, port assignment, PubSub fan-out, per-proc state. |
| ports.ts | Free-port allocation and pgrep+lsof listening-port discovery. |
| server.ts | effect/unstable/http router behind a small Node↔web-handler bridge; SSE downstream, POST upstream; Host-routed reverse proxy with WebSocket pass-through. |
| registry.ts | ~/.procdeck/instances/<id>.json per running deck: how down/ls/the deck switcher find decks. |
| lifecycle.ts | Detaching (up re-spawns itself as up --fg and waits for the registry entry), down, port probe. |
| init.ts | procdeck init: workspace scan → a first config (one proc per package with a dev script). |
| cli.ts | effect/unstable/cli commands; up --fg is the server, everything else talks to the registry/API. |
Design notes worth keeping:
- Effect end to end. The server is Effect; the UI is a Foldkit app — one Model (a
Schema), pure
update, side effects as Commands. Terminals are imperative islands viaMount.defineStream; PTY log chunks bypass the Model (ReceivedLog→WriteTerminalCommand into a module-level terminal registry — the view never renders them); the SSE subscription is gated on a Model condition ("every pane mounted"), so the replayed backlog ordering is declarative, not sequenced by hand. - The server has no bundler. It runs as
.tsvia Node type stripping (Node ≥ 22.18). - SSE, not WebSocket. Logs only flow towards the browser; commands are ordinary
POSTs. No upgrade handshake, no extra dependency, and
EventSourcereconnects by itself. - Per-proc backlog, one stream per subscriber. Each proc keeps its last 256 KB of
output; a subscriber (an SSE connection) gets every proc's backlog and status, a
syncedmarker, then live events from aPubSubit subscribed to before the snapshot — no gap, and a per-proc chunk counter cuts the duplicate at the seam. A tab opened on a deck that has run for days still shows every pane's history, and the UI knows exactly which chunks are news (unread tallies, notifications). - The supervisor is
Effect.acquireRelease. Shutdown is not a code path anyone has to remember to call: closing the scope terminates every process tree. The SSE response stream lives in the request scope, so a dropped tab cleans up its subscription too. Ctrl-C,procdeck downand the UI's ⏻ are all the same SIGTERM into the same scope. - Detached is just
up --fgin the background. No daemon, no IPC:upspawns itself detached with its output in a log file, and waits for the child's registry entry to appear — written only afterlistensucceeds — so "up" means reachable. The registry is plain files pruned by pid; every deck serves its own UI, and the deck switcher is a list of links.
Tests
pnpm testIntegration tests with real (tiny) processes — node -e one-liners and shells with
background children — because the risks live at the OS boundary: PTY detection, group
kills, SIGTERM-ignoring survivors, scope teardown. Mocking any of that would test the
mock. They need a real PTY (/dev/ptmx), so they won't run inside sandboxes that block
PTY allocation.
Gotcha: if every spawn fails with posix_spawnp failed., the usual culprit is the
node-pty spawn-helper prebuild arriving without its execute bit (pnpm/Bun skipping
its install script). proc.ts restores the bit at import time, so this should
self-heal — if it still fails, PTY allocation itself is probably blocked (sandboxes).
Status
macOS/Linux only (PTYs, pgrep, lsof). Known shortcut: statuses are a mutated map
rather than SubscriptionRefs. Not done yet: an injected FAB overlay inside the developed apps, and a dependency-death
policy (a dependency dying does not cascade — deliberately).
