@hugpy/console
v0.3.1
Published
Portable terminal / file-browser / keeper console for the web — a framework-agnostic mountConsole() module plus a reusable PTY broker. Embeds into any app.
Downloads
49
Maintainers
Readme
station-console
A portable terminal / file-browser / LLM-chat console you can drop into any web app. It is the console UI from the LXD station manager, extracted into a mountable JS module plus a reusable backend broker so other applications can embed it without the vm_mgr station chrome.
station-console/
ui/
station-console.js ESM module — export mountConsole(el, opts)
style.css all styles scoped under .sc-root (no host collisions)
vendor/ xterm.js + fit/canvas addons + xterm.css
server/
pty_broker.py aiohttp broker: ws PTY + fs REST + optional LLM proxy
example/index.html demo host page that mounts the module
install.sh systemd install of the broker1. The UI module
<link rel="stylesheet" href="ui/vendor/xterm.css">
<link rel="stylesheet" href="ui/style.css">
<script src="ui/vendor/xterm.js"></script>
<script src="ui/vendor/xterm-addon-fit.js"></script>
<script src="ui/vendor/xterm-addon-canvas.js"></script>
<div id="console" style="height:600px"></div>
<script type="module">
import { mountConsole } from "./ui/station-console.js";
const api = mountConsole(document.getElementById("console"), {
backend: "https://my-broker:8801", // '' = same origin
vm: "local", // target the broker exposes
token: "…", // optional API token
namespace: "app1", // localStorage prefix (isolate multiple mounts)
features: { files: true, llm: true, keeper: true, composer: true },
});
</script>xterm.js and the fit addon must be loaded before the module (the canvas addon is optional — it falls back to the DOM renderer). The module owns only the DOM inside the element you pass; the host app keeps its own chrome.
Bundler / npm path
The script-tag example above is the zero-build path — it needs nothing from
npm besides @hugpy/console itself, which is why @xterm/xterm,
@xterm/addon-fit, and @xterm/addon-canvas are declared as optional
peer dependencies rather than hard ones: the vendored UMD copies under
ui/vendor/ already satisfy the module at runtime via window.Terminal /
window.FitAddon / window.CanvasAddon. If you're bundling (webpack, vite,
esbuild, …) instead, install xterm yourself and pass the classes in directly
so the module never touches window:
npm install @hugpy/console @xterm/xterm @xterm/addon-fit @xterm/addon-canvasimport { mountConsole } from '@hugpy/console';
import '@hugpy/console/style.css';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { CanvasAddon } from '@xterm/addon-canvas';
import '@xterm/xterm/css/xterm.css';
const api = mountConsole(document.getElementById('console'), {
backend: 'https://my-broker:8801',
vm: 'local',
token: '…',
namespace: 'app1',
features: { files: true, llm: true, keeper: true, composer: true },
xterm: { Terminal, FitAddon, CanvasAddon },
});xterm.Terminal and xterm.FitAddon are required if you use this path —
mountConsole throws immediately if neither they nor the window globals
are present. xterm.CanvasAddon is optional here too; omit it (or pass
xterm: { Terminal, FitAddon }) and the terminal falls back to the DOM
renderer, same as the script-tag path. Pick the script-tag path for a
zero-build host page; pick this path when your app already has a bundler and
you'd rather not vendor a second copy of xterm.
API returned by mountConsole
| method | description |
|---|---|
| openPane(spec) | open a pane. spec: {type:'shell'} or {type:'keeper', backend, model, skipPerms}. Returns the pane id. |
| closePane(id) | close a pane (kills its server session). |
| setTarget(name) | switch the whole console to another target (tears down panes, reboots from that target's saved layout). |
| getTarget() | current target name. |
| setView('term'\|'files'\|'llm') | switch the active view. |
| on(event, fn) | subscribe; returns an unsubscribe fn. Events: paneOpen, paneClose, paneExit, targetChange. |
| destroy() | close sockets, detach listeners, clear the element. |
Retained features: keeper terminal with model selection, optional
--dangerously-skip-permissions, split panes, copy-on-select, the read/write
file browser, the streaming LLM chat, and the multiline composer. The
winsize-before-spawn fix is built in — a full-screen TUI keeper paints its
first frame at the real pane geometry, not a stale 24×80.
File browser: search + navigation
The file browser (features.files) adds three things on top of the read/write
listing/editor:
- Content search — the search row greps a literal substring through the
files under the current folder (recursive) and lists
file:line — matched texthits; clicking a hit opens the file in the editor and scrolls near the line. Clear returns to the directory listing. It skips binary files and noise directories (.git,node_modules,__pycache__, …), caps at 500 hits / 1 MB per file, and is time-bounded — see thefs/searchbroker route below. - Breadcrumb path — the current path renders as clickable ancestor segments
(root included); each jumps there. The
✎toggle swaps in an editable text input for typing/pasting a path (Enter navigates, Esc cancels). - Recent folders — the
🕘dropdown lists the last ~8 distinct folders visited, most-recent first; picking one navigates to it. Recents are stored inlocalStorage, keyed pernamespace/target.
Model chat replies are rendered as plain text via textContent (never
innerHTML), so model output cannot inject markup.
2. The broker
Speaks exactly the protocol the module expects. The target resolver is the
portable seam — it decides how {name} maps to a PTY/filesystem:
--resolver local— a plain shell + the host filesystem. Works anywhere.--resolver lxd—lxc exec <name>as an unprivileged dev user (LXD VMs).
# standalone, any host, open in dev:
python3 server/pty_broker.py --resolver local --token "$TOK" --cors-origin '*'
# LXD parity with the original station console:
python3 server/pty_broker.py --resolver lxd --port 8801| env / flag | meaning |
|---|---|
| --token / STATION_BROKER_TOKEN | API token (X-Console-Token header or ?token=). |
| --set-password | print a pbkdf2 hash; set STATION_BROKER_PASSWORD_HASH to require a login. |
| --cors-origin / STATION_BROKER_CORS_ORIGIN | * or comma-separated origins — required for cross-origin embeds. |
| --llm-base / STATION_BROKER_LLM_BASE | OpenAI-compatible gateway base URL; enables /api/llm/models + /chat. Omit → chat shows "no models". |
| --cert / --key | enable TLS. |
| STATION_BROKER_DIRECTIVE | global keeper directive file (default /opt/llm-station/directive.md). |
| STATION_BROKER_KEEPER_SOCK | tmux socket for persistent keepers (default console). Use a distinct socket to isolate keepers from another console on the same host. |
| STATION_BROKER_LOCAL_NAME | station name the local resolver advertises (default local); set to label the host, e.g. hugpy. |
Content search. GET /api/stations/{name}/fs/search?path=<rel>&q=<text>
greps the literal substring q (case-insensitive; add &case=sensitive for
exact case) through the text files under path, returning
{"results":[{"file","path","line","text"}], "truncated":bool}. It runs through
the same resolver seam as fs/list — reading the host for local, inside
the VM for lxd — and is confined to the same sandbox root: a .. or
absolute path that resolves outside the root is refused with 400 before any
file is touched. The query and path are passed as argv (never interpolated into
a shell), so metacharacters are inert. Binary files, .git/node_modules/
__pycache__-style noise dirs, and files ≥1 MB are skipped; results are capped
at 500 hits and the whole search is time-bounded.
Keeper persistence. A Claude keeper (+🤖) runs inside a tmux session, so it
survives a broker restart — the PTY we spawn is just a tmux client; the keeper
keeps running (in the VM for lxd, on the host for local) and reconnecting
reattaches. Its system prompt layers the global directive above, then a per-target
file — /srv/share/projects/<name>/.keeper-directive.md (lxd) or
<home>/.keeper-directive.md (local) — each applied only if present.
To add your own target type (ssh, docker, k8s pod…), implement a resolver class
(list_targets, safe_path, shell_argv, run, pull, keeper_argv) and
register it in main().
3. One-command install
sudo ./install.sh --resolver local --port 8801 --cors-origin 'https://my-app.example'Generates an API token (printed once), a self-signed TLS cert, and a systemd
unit (station-console-broker.service). The broker also serves ui/ at /ui,
so a host page can import the module from the same origin.
0.3.0 — fleet-console fold-back
0.3.0 converges the terminal-tab UX that was proven this epoch in the multi-host
fleet console (blackbird console-ui) back into this single-host package. Only
the parts that map to a one-host console were folded in — per-VM grouping, board
features, and power controls were intentionally left out.
- Keeper-primary pane tabs. Keeper terminals (
+🤖/openPane({type:'keeper'}), tagged bypane.spec.keeper) now lead the tab strip and render primary (bright + bold); shell tabs follow and render secondary (dim). The keeper is the operator's workspace and must never read as a mere peer of the shell — the same semantics the fleet console's unified tab bar settled on. Per-kind numbering (🤖1…,sh1…) keeps labels sequential regardless of open order. - Mobile tab strip. Under a narrow-viewport media query (
≤640px) the.pane-tabsstrip becomes a hidden-scrollbar, touch-scrollable horizontal strip, and the toolbar buttons wrap to a new row instead of scrolling off the right edge (the fleet-console mobile bug where+🤖/ view buttons became unreachable). The console never forces page-level horizontal overflow. - Wrap / overflow guards. Long filenames, paths, search-hit text (file browser)
and long chat messages / command output (LLM chat) get the fleet console's
min-width:0/overflow-wrap:anywheretreatment so an unbreakable token can't force a horizontal scrollbar or widen the panel.
Provenance: fleet console console-ui commits 3e85e78..9ca8ed1.
Relation to vm_mgr
The original console at vm_mgr/console/ is unchanged and still runs the full
station manager (VM tabs, lifecycle, provisioning). This package is the reusable
extraction of its console core; vm_mgr can later be re-pointed at the module, but
nothing here modifies it.
