Put your terminal — and your coding agents — in a browser tab, right next to the webapp they're working on. Live reload in one tab, Claude Code / Codex / OpenCode in the next. If your browser has workspaces or tab groups, group the terminals with the project they belong to and switch between projects cleanly.
Persistent PTY sessions that survive tab close, tabs with binary pane splits, exact screen restore on reconnect (the server keeps a terminal emulator per session, so full-screen and inline-redrawing programs come back as they are, not as a replay of stale frames), per-session CWD tracking. Single small Go binary. Loopback-only — see SECURITY.md.
go install github.com/sudiptadeb/termulaa/src/cmd/termulaa@latest
termulaacurl -fsSL https://raw.githubusercontent.com/sudiptadeb/termulaa/main/install.sh | bashDetects OS (linux/darwin) and arch (amd64/arm64), installs to
~/.local/bin/termulaa.
Install, pair and keep the tunnel agent running in one command (see Remote access and Run it as a service):
curl -fsSL https://raw.githubusercontent.com/sudiptadeb/termulaa/main/install.sh \
| bash -s -- --service --rc-server https://your.server --rc-token <token>Nothing updates by itself unless you ask. Two ways to ask:
Re-run the install command. This is the full update. It compares
termulaa -version against the latest release and replaces the binary
only when they differ. With --service it restarts the tunnel agent
onto the new binary and leaves a terminal server that is already on the
installed version untouched, so open terminals survive; with
--rc-token it also re-pairs. Without --service it restarts whatever
the service manager runs (server first, then the tunnel agent). A
termulaa you started by hand keeps running the old binary — the
installer names those processes but never kills them. Pass
--no-restart to stage the binary without touching anything running.
termulaa update. termulaa updates itself through
amarit: it fetches the release
manifest (releases.json on the latest GitHub release), downloads the newest
version for this platform, checks size and sha256, smoke-tests it, swaps it
in keeping termulaa.prev, and re-execs; then it prints the version and
exits. A server that updates this way keeps every open terminal: it hands
its listening socket and the live PTYs to the new version, which picks
them up on the same PID. Other running copies keep the old binary until
they restart. --update-url <manifest> points it at a mirror.
Under amarit. amarit run termulaa and amarit run termulaa -rc keep
both running and turn auto-update on; termulaa then checks every five
minutes ("interval": "5m" in amarit.json; AMARIT_UPDATE_INTERVAL=1h
in the unit's environment or --update-interval change it). Anywhere
else, --auto-update does the same loop and is off by default.
Grab the binary for your platform from the
Releases page
and drop it somewhere on your PATH.
git clone https://github.com/sudiptadeb/termulaa
cd termulaa
build/build.sh # cross-compiles to dist/<os>/
./dist/darwin/termulaa-arm64-v0.1.0termulaaThen open http://127.0.0.1:17380/ in your browser.
termulaa -version prints the installed version (also shown on
/health).
Change the port with -port 17381, or edit settings at
http://127.0.0.1:17380/settings.
The release binaries aren't signed or notarized. On first run macOS will refuse to open them. Fix:
xattr -d com.apple.quarantine ~/.local/bin/termulaaOptional. termulaa itself stays loopback-only — the server never listens
on anything but 127.0.0.1, even in this mode. Remote access works
through a reverse tunnel: termulaa -rc runs a separate agent that
dials out to a rendezvous server over WebSocket and splices bytes
between the rendezvous and your local termulaa. No inbound port is ever
opened on your machine.
The protocol is owned by this repo and specified in
docs/rc-protocol.md — any server implementing it
works. The default rendezvous is the reference implementation (built in
memd); point the agent at your own with -rc-server https://your.server
(persisted to ~/.termulaa/rc.json, so you only pass it once).
The agent holds a small pool of long-lived outbound connections (tunnels), each carrying an smux session; every browser connection is a stream multiplexed inside one of them. The agent parses no HTTP and knows nothing about terminals — it is a dumb byte pump.
Pairing:
- Start termulaa normally (
termulaa). - Open
<rendezvous>/rcin a browser, sign in, mint a tunnel token. - Run
termulaa -rcon the same machine and paste the token when prompted. It persists to~/.termulaa/rc.jsonalong with the server URL; later runs reconnect without prompting.termulaa -rcstays in the foreground and stops with its terminal — to pair without running it, usetermulaa -rc-save -rc-token <token>, which saves the settings and exits, then let a service run the agent. - Open your terminal from the rendezvous in any browser — either a
link on its
/rcpage (path-prefix serving) or its dedicated view host with the pairing link it gives you, depending on how the rendezvous is configured.
The token is an opaque string to termulaa — it is stored and presented
verbatim, never parsed. Tokens expire by design (the rendezvous decides
when); when one does, the agent stops with a message pointing at
<rendezvous>/rc. Mint a fresh token and run -rc again with
-rc-token <new token>, or re-run the installer with --rc-token.
Prefer the saved pairing over flags once a machine is paired: a token
passed as -rc-token sits on the agent's command line, readable by
every local user in ps, for as long as the agent runs.
There is a native Android app in android/: it lists your
connected machines from the rendezvous, opens their terminals, and —
the main point — notifies you when a session produces output while you
are away (useful when long-running agents work on remote machines).
Every release ships the APK as a sideloadable asset; grab
termulaa.apk from the
latest release.
It signs in with your rendezvous account and needs a path-mode
rendezvous (the default).
The terminal UI itself is also usable straight from a phone browser
(e.g. over the reverse tunnel).
On touch devices a key bar docks above the on-screen keyboard with the
keys mobile keyboards lack: Tab, Esc, Ctrl, Alt, arrows, and characters
like | ~ and backtick. Ctrl and Alt are sticky — tap for one-shot,
double-tap to lock — and arrows send the right sequences inside
full-screen programs like vim. The terminal resizes to stay clear of
the keyboard, and the bar can be hidden (and stays hidden) if you use a
hardware keyboard.
Flags:
| Flag | Meaning |
|---|---|
-rc |
run the tunnel agent (does not start the terminal server) |
-rc-server URL |
rendezvous base URL |
-rc-token TOKEN |
tunnel token (otherwise saved/prompted) |
-rc-target HOST:PORT |
local termulaa to splice to (default 127.0.0.1:<port>) |
-rc-label NAME |
agent label shown on the rendezvous (saved to ~/.termulaa/rc.json, so you only pass it once; default: saved label, else hostname) |
-rc-tunnels N |
pooled tunnel connections, 1–8 (default 4) |
-rc-insecure |
skip TLS verification — dev-only, for self-signed rendezvous certs |
See SECURITY.md for why this does not violate the loopback rule, and what the residual risks are.
termulaa can be served behind a reverse proxy under a subpath (e.g.
https://example.com/term/), not just at a site root. The proxy must:
- strip the prefix before forwarding (termulaa's routes never change —
it still sees
/api/tabs,/ws/session/<id>, …), and - set
X-Forwarded-Prefixto the stripped prefix (no trailing slash), never passing through a client-supplied value.
termulaa then renders its pages with <base href="<prefix>/"> so the
whole UI — fetches and WebSockets included — resolves under the prefix.
The header is strictly sanitized (see
docs/rc-protocol.md §6.1 for the exact contract);
anything invalid falls back to root-relative pages. Served directly with
no proxy, nothing changes.
Reverse-proxying does not relax the loopback rule: the listener
still binds 127.0.0.1 only, and everything in SECURITY.md
about exposing termulaa beyond loopback applies to the proxy you put in
front of it.
Optional. install.sh --service installs the binary and sets both
processes up to start at login/boot and restart on failure — systemd
user units on Linux (no sudo), launchd LaunchAgents on macOS:
curl -fsSL https://raw.githubusercontent.com/sudiptadeb/termulaa/main/install.sh | bash -s -- --serviceRe-running it is safe; it rewrites the service files in place and
restarts the services onto the freshly installed binary (an already
running unit would otherwise keep executing the old one). The
templates live in resources/service/ if you prefer
to install them by hand (replace the @TERMULAA_BIN@ / @HOME@
placeholders).
Pair before the tunnel agent can run as a service. The agent needs a
token, and under systemd/launchd there is no terminal to prompt on — it
exits immediately with no token entered. So the installer starts the
terminal server right away but leaves the agent service installed,
not started, until ~/.termulaa/rc.json holds a token. Give the
installer the token and it does all three steps — save the pairing,
install the services, start the agent:
curl -fsSL https://raw.githubusercontent.com/sudiptadeb/termulaa/main/install.sh \
| bash -s -- --service --rc-server https://your.server --rc-token <token> --rc-label my-boxWhen a token expires the agent exits the same way; mint a fresh one and run that command again with it. A terminal server that is already on the installed version is left alone on every re-run, because restarting it closes the terminals it holds — only the tunnel agent is restarted.
| Started as | Close the terminal | Log out | Restart the machine |
|---|---|---|---|
| macOS LaunchAgents | keeps running | stops | starts when you log in at the desktop |
| Linux systemd user units | keeps running | keeps running | starts at boot, no login needed |
| WSL2 systemd user units | stops soon after the last WSL window closes | stops | starts when WSL is next started |
| Detached (no service manager) | keeps running | keeps running | stays down until you re-run the installer |
Detached is what --service falls back to where no per-user service
manager can serve the account: SSH into a Mac account that is not logged
in at the desktop (launchd has no gui/<uid> domain for it), or a Linux
login without a systemd user session. The installer says so, starts the
terminal server and the tunnel agent with nohup, and logs to
~/.termulaa/logs/. It replaces only the tunnel agent it started itself
(tracked in ~/.termulaa/rc.pid); an agent you started by hand is
reported and left alone. Stop everything with pkill -x termulaa.
On a Mac, per-user services start only when that user logs in at the
desktop — an SSH login does not count, and with FileVault on nothing
starts until the disk is unlocked. To start at boot with nobody logged
in, run termulaa as a LaunchDaemon instead (needs sudo): copy the two
plists from ~/Library/LaunchAgents/ to /Library/LaunchDaemons/, add
a UserName key with your account and an EnvironmentVariables dict
setting HOME, chown root:wheel them, and load each with
sudo launchctl bootstrap system <plist>. Terminals started that way
run outside your desktop session, so they cannot reach your login
keychain.
Units are written to ~/.config/systemd/user/. The installer also runs
loginctl enable-linger $USER so your user services keep running after
logout and start at boot; if that fails it prints the command for you to
run yourself. systemctl --user needs a systemd user session — where
there is none (some containers and SSH setups), the installer says so
and starts termulaa detached instead (see
What keeps running).
systemctl --user enable --now termulaa-rc # after pairing
systemctl --user status termulaa termulaa-rc # status
journalctl --user -u termulaa -f # server logs
journalctl --user -u termulaa-rc -f # agent logsIf the agent service starts without a token it exits and systemd retries
briefly (StartLimitBurst=5 over 2 minutes), then gives up rather than
looping forever. After pairing, systemctl --user restart termulaa-rc.
Disable / remove:
systemctl --user disable --now termulaa termulaa-rc
rm ~/.config/systemd/user/termulaa.service ~/.config/systemd/user/termulaa-rc.service
systemctl --user daemon-reloadPlists are written to ~/Library/LaunchAgents/; logs go to
~/.termulaa/logs/server.log and ~/.termulaa/logs/rc.log.
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.termulaa.rc.plist # after pairing
launchctl print gui/$UID/com.termulaa.server # status
tail -f ~/.termulaa/logs/server.log ~/.termulaa/logs/rc.log # logsWithout a saved token the agent exits at start and launchd retries every
30 seconds (ThrottleInterval), logging to rc.log, until you pair.
Disable / remove:
launchctl bootout gui/$UID/com.termulaa.server
launchctl bootout gui/$UID/com.termulaa.rc
rm ~/Library/LaunchAgents/com.termulaa.server.plist ~/Library/LaunchAgents/com.termulaa.rc.plistttyd + tmux gets you "browser-rendered PTY with persistence," but
with seams — reattaching doesn't replay scrollback cleanly, layout lives
in tmux, and ttyd has no tab or pane concept of its own. termulaa
folds the pieces into one small binary and aims it at one use case: a
browser tab you leave open next to whatever you're building.
-
Persistent sessions — the PTY stays alive after the browser tab closes; reopening the tab restores the exact screen and scrollback. The server runs a headless terminal emulator per session and hands a reconnecting client a snapshot of its state, so
vim,htopor a coding agent redrawing inline come back as they are. -
Tabs + binary pane splits — layout is first-class, persisted per tab.
Cmd/Ctrl+Dsplits vertically,Cmd/Ctrl+Shift+Dsplits horizontally,Cmd/Ctrl+Wcloses a pane,Cmd/Ctrl+]/Cmd/Ctrl+[cycle focus.Cmd/Ctrl+/(or the?in the corner) shows the in-app shortcut guide; rebind any of them via theterminalKeybindingslocalStorage key. -
Dead-session revival — if the PTY exited, the last screen and scrollback are restored from disk and a new shell spawns below them in the last-known cwd.
-
Per-session CWD tracking — follows
/proc/<pid>/cwdon Linux,lsof -pon macOS. -
Shell history — per-session
HISTFILE. -
Side-by-side with your work — it's a browser tab, so workspaces and tab groups work out of the box. Flip between projects by flipping workspaces; each one keeps its own termulaa tab.
build/build.sh # cross-compile to dist/<os>/
src/cmd/termulaa/ # Go sources + embedded ui/
docs/ # rc-protocol.md — the remote-access protocol spec
resources/plans/ # design docs
resources/scripts/ # run + benchmark helpers
resources/service/ # systemd user unit + launchd plist templates
resources/images/ # README screenshots + GIFs
Four Go dependencies: creack/pty,
gorilla/websocket,
xtaci/smux (multiplexes remote-access
viewers over the -rc tunnel pool), and
sudiptadeb/amarit (opt-in
self-update from the release manifest, configured by amarit.json). Frontend is vendored — Alpine.js,
Twind, xterm.js + addons — no npm, no bundler, no build step.
~/.termulaa/
config.json # user settings (port, shell, scrollback size, ...)
state.json # tabs + session metadata
scrollback/<id>.raw # per-session raw PTY output (ring buffer)
history/<id>.hist # per-session shell HISTFILE
Settings are editable in-app at /settings or via GET/PUT /api/settings.
Builds produced by build/build.sh and the release workflow:
darwin/amd64,darwin/arm64linux/amd64,linux/arm64
Windows is not supported. PTY handling via creack/pty is
POSIX-only; Windows would need a ConPTY port. Tracked as an open issue.
Loopback-only (127.0.0.1). Host-header allowlist and strict Origin
checks on both HTTP and WebSocket. Any page open in a browser on the
same machine can still talk to the server if it uses the right Host —
accepted risk for a single-user dev tool. Full threat model and the
controls required before exposing on a non-loopback interface are in
SECURITY.md.


