Requirements on the host (the always-on machine your agents run on):
macOS or Linux, tmux, git, and at least one supported
coding agent. Clients need nothing but a modern browser.
curl -fsSL https://tuimote.org/install.sh | sh
The installer downloads the signed release binaries, verifies them, checks
dependencies, and hands off to tuimote setup. Or use Homebrew:
brew tap tuimote/tap https://codeberg.org/soundworker/homebrew-tuimote
brew install tuimote/tap/tuimote
tuimote setup
Or build from source (repo):
cargo build --release produces tuimote-server (the host)
and tuimote (the CLI).
tuimote setup is a one-shot, re-runnable bootstrap: it runs the
doctor checks, installs and starts the background
service (a launchd user agent on macOS, a systemd user unit with lingering on
Linux), prompts for a password if the server is reachable beyond loopback, and
prints your dashboard URL — by default http://<host-ip>:8787.
tuimote attaches to live tmux sessions, so a network-facing instance without auth would be an unauthenticated remote shell. The server therefore refuses to bind a non-loopback address until a password is set:
tuimote config password # prompts; or pipe it on stdin
⚠ Never expose tuimote to the public internet. It serves a live terminal over HTTP — run it on your LAN or behind a VPN (WireGuard, NetBird, Tailscale) only. The password gate is for trusted-network hygiene, not internet hardening.
tuimote update --check # report only
tuimote update # apply
Behavior follows the install method: script installs download the signed
release, verify it (minisign + SHA-256), swap atomically, and restart the
service. Homebrew installs print brew upgrade tuimote && tuimote
setup, which refreshes and restarts the service; source builds are left
to your own build loop.
What changed between your version and the latest lives on the
release notes page, in the dashboard's settings
modal while an update is pending, and in the CLI:
tuimote changelog --since <your-version>.
tuimote uninstall # remove the service + installer-managed binaries
tuimote uninstall --purge # also remove global config + state
Homebrew and source-built binaries are left to their package/build workflow.
Per-repo .tuimote/ directories are always listed and preserved.
One small Rust binary per machine. tuimote-server owns the tmux
sessions your agents run in and serves the dashboard, the browser terminal, and
the HTTP API. The tuimote CLI talks to that server — it is the same
tool for you scripting from a shell and for agents messaging each other. State
and config live in ~/.tuimote/ (config.json, logs,
panels, fonts, trust grants).
A session is one agent running in one tmux pane, tied to the repo it was
started in. Every running session gets a stable shorthand —
<repo-slug>-<N>, e.g. dev-storefront-2 —
which is how you (and other agents) address it:
tuimote session ls --running --pretty # who's around
tuimote session send dev-storefront-2 "run the tests again"
tuimote session view dev-storefront-2 # read its screen, read-only
tuimote whoami # a session's own identity
Sessions can message each other, ask each other blocking questions
(session request / session reply), read each other's
screens, and hand work across harness boundaries
(session copy --to codex) — including across federated machines.
Each repo can pin one session as its entrance point
(tuimote repo pin): pinned sessions skip idle auto-stop and are the
default target when a command names no session.
The web UI groups sessions into one card per repo: an activity pulse, token usage, and a one-line status per session, plus buttons to start a new agent in that repo. Click a session and you are in its real terminal — the full TUI with every keybind, not a reduced remote mode. On the phone you get touch scrolling, a key bar for the keys a TUI needs, and a slash-command composer. Repos on federated machines appear as their own cards, marked with the owning host, and are fully interactive.
Initialize a repo once with tuimote repo init. Its
.tuimote/ directory becomes a harness-neutral shared store:
memory/ (an indexed memory base injected into new and resumed
sessions), STATE.md, and HISTORY.md
(tuimote repo history appends dated entries). Harnesses with a
native system-prompt hook get the memory index injected directly; the others
read it through a managed AGENTS.md fallback. Repo-local and
global skills are exposed to every harness through compatibility views, so
switching harnesses doesn't mean maintaining copies.
| Harness | Status |
|---|---|
| Claude Code | Well tested, wired to the full tuimote feature set. |
| Pi | |
| Codex | Available; might lack a feature or two. |
| OpenCode | |
| oh-my-pi | |
| Hermes | |
| Reasonix |
At least one harness must be installed on the host. You can run several at once, switch between them, and translate a session from one to another.
Every app-level hotkey uses the Ctrl+Shift modifier — chosen because neither the browser, the OS, nor a TUI running in the session claims it. The combos work even while the terminal has keyboard focus: tuimote routes Ctrl+Shift chords to the app and passes everything else through to the session untouched. Keys are matched by physical position, so the bindings are keyboard-layout independent. The same cheat sheet is built into the app: press Ctrl+Shift+?.
Available anywhere:
| Shortcut | Action |
|---|---|
| Ctrl+Shift+N | New session — open the repo/agent picker |
| Ctrl+Shift+, | Open settings |
| Ctrl+Shift+? | Shortcuts help (Esc closes) |
Inside a session's terminal view:
| Shortcut | Action |
|---|---|
| Ctrl+Shift+← / → | Previous / next running session (wraps around) |
| Ctrl+Shift+1…9 | Jump straight to the Nth running session |
| Ctrl+Shift+D | Detach — back to the dashboard; the session keeps running |
| Ctrl+Shift+P | Toggle the dock panel |
A few related behaviors that aren't chords:
On the phone the hotkeys don't apply; the terminal's key bar and touch gestures cover the same ground.
The full tuimote <subject> <verb> command tree lives
on its own page, CLI reference. It is generated
from the CLI's own command definitions, so it always matches
--help.
The dock is the right-edge slide-in panel next to the terminal (the pull tab on the terminal's right edge). It hosts pluggable pages: the built-in Docs reader for artifacts, a git page, and whatever panels you or a repo add — file browsers, issue trackers, system monitors. Panels are plain JS files, discovered and hot-loaded: drop a file, reload the PWA, no rebuild.
Panels shipped inside a repo are untrusted by default: the first time a
repo's panels are seen you get a one-time trust prompt, and even after that
their shell access is limited to an allowlist the panel declares. Manage this
with tuimote panel ls and tuimote panel trust [--revoke].
Per repo, you can also reorder and hide panels from the repo settings menu.
A panel is one file that registers a page object — id equals the filename stem. The smallest useful page:
// ~/.tuimote/panels/hello.js
TuimoteDock.register({
id: "hello",
title: "Hello",
order: 50,
apiVersion: 1,
requires: { tools: ["git"] }, // hide the tab until git resolves
available(ctx) { return !!(ctx && ctx.cwd); },
mount(host) {
host.body.innerHTML = '<pre class="hello-pre" style="padding:14px"></pre>';
this._pre = host.body.querySelector(".hello-pre");
},
async sync(host, ctx) {
const { stdout, exitCode } = await host.exec("git status --short");
this._pre.textContent = exitCode === 0 ? (stdout || "clean") : "error";
},
});
Where the file lives decides its scope:
| Scope | Path | Trust |
|---|---|---|
| user-global (every repo, just you) | ~/.tuimote/panels/<id>.js | runs free |
| repo-local (ships with one repo) | <repo>/.tuimote/panels/<id>.js | trust prompt + exec allowlist |
A repo-local panel must ship a sidecar <id>.panel.json
declaring the command prefixes its host.exec calls may run, e.g.
{ "commands": ["git ", "ls "] } — the server enforces it.
Everything a page needs comes through the host facade it is
handed: host.exec (argv-only, no shell — pipes, globs and
$VAR are inert; compose in JS), host.fs (repo-confined
listings), host.tuimote (list / spawn / message agent sessions),
host.tmux (a persistent panel terminal), host.pty
(a one-shot TUI like htop that dies when you look away), and
host.ui (menus, toasts, sanitized markdown). Never fetch
raw routes: the facade is the stability boundary, the security boundary, and
what makes a panel work transparently on federated
sessions — the same panel that works locally drives a repo on another machine
unchanged.
Before shipping, lint it — the same checks gate loading server-side:
tuimote panel verify ~/.tuimote/panels/hello.js
It reports rule id, line, and a fix hint for each problem: structure and id
checks, lifecycle-hook shapes, global-namespace collisions with the app shell,
facade discipline (no raw fetch/eval), and the sidecar
allowlist rules. Working built-in examples to crib from live in the tuimote
repo under public/panels/.
Several machines, one dashboard: federate the tuimote servers on your network and every peer's repos and sessions appear alongside local ones, fully interactive. Federation is opt-in and off by default.
Setup on each machine — set the same pairing secret everywhere:
tuimote peers secret "one-shared-secret"
tuimote peers discovery on # mDNS: peers on the same LAN find each other
mDNS peers exchange certificate pins automatically. For a static peer (no
mDNS between the hosts), read the peer's fingerprint with
tuimote peers ls on that machine, then pin it explicitly:
tuimote peers add peer-host:8787 --cert-fingerprint <sha256> [--label NAME] [--mac AA:BB:..]
Server-to-server traffic runs over TLS pinned by that fingerprint. For a
peer reachable only via SSH (behind NAT, off-overlay), add
--transport ssh --ssh-target <openssh-alias>: tuimote then
maintains a local forward using your OpenSSH config and
known_hosts. A peer with a configured MAC can be woken from the
dashboard or with tuimote peers wake. Manage the rest with
tuimote peers ls / toggle / remove.
Direct federation requires inbound TCP access to each server's configured
port (default 8787; check with tuimote config get port).
If a firewall is enabled, allow that port, for example
sudo firewall-cmd --permanent --add-port=8787/tcp followed by
sudo firewall-cmd --reload, or sudo ufw allow 8787/tcp.
Substitute the configured port. If opening an inbound port is not possible,
use the SSH transport.
Two commands cover most of it:
tuimote status # read-only health report: server, config, security,
# each harness, peers, usage probes
tuimote doctor # same checks, then offers a fix for each problem
tuimote doctor --yes # apply every fix unattended
tuimote service status # installed? running?
tuimote service load # start + enable at boot
tuimote service unload # stop
tuimote service install # (re)write the unit file
tuimote service uninstall # stop + remove it
The service is a launchd user agent on macOS and a systemd user unit (with lingering enabled) on Linux; it runs as your user, in your home.
tuimote server logs # last stdout lines
tuimote server logs --err # the error log
tuimote server logs --follow # stream
The files live at ~/.tuimote/server.log and
~/.tuimote/server.err.log; they are rotated at 10 MiB with
three numbered archives kept alongside.
tuimote config password, or bind loopback only
("host": "127.0.0.1" in ~/.tuimote/config.json).tuimote status first; check
the port (tuimote config get port, default 8787) and that your
VPN/firewall allows the host.tuimote status lists
each harness's availability.tuimote peers ls on both
ends. Its diagnostic distinguishes timeout/firewall, connection refusal,
DNS, TLS fingerprint, authentication, and HTTP failures. Check the same
pairing secret and certificate fingerprint; for SSH transport, also check a
working ssh <target> from the hub.tuimote config ls shows every setting;
tuimote config set <key> <value> changes one.