Mirror terminal programs to Even Realities G2 smart glasses. This is designed to work with the Terminal app inside Faceclaw; it does not currently have a client compatible with the stock Even G2 firmware.
g2mirror <command> [args...] runs the command in a pseudoterminal. While
the glasses are connected and viewing the terminal, the wrapped app is
resized (via SIGWINCH) to the glasses' screen dimensions, and its output is
parsed into an in-memory vt100 screen
model and re-emitted so it renders correctly on both the host terminal and
the glasses, despite the dimension mismatch — the same approach tmux uses.
If the host terminal is smaller than the glasses screen, the host view is
truncated.
g2mirror— the command wrapper. Exposes a session socket at~/.g2mirror/<pid>-<cwd>speaking newline-delimited JSON; a client can ask toview(resize the app to the device size, get a snapshot, then a live output stream) andunview. Ctrl+G simulates a glasses connect/disconnect at 96×24 without a real client. It is also the CLI for detached sessions:g2mirror --detached <command>starts one without a terminal,g2mirror -llists sessions, andg2mirror -aclaims a detached session into the current terminal (see "Launching shells and detached sessions" below).g2mirror-server— a websocket gateway for device drivers. Reads~/.g2mirror/config.json(create it withg2mirror-server --init-config, which prints the first auth token once and writes a template with every option documented in comments — the config is JSON with//and/* */comments allowed, and--add-tokenedits keep them), cleans up stale session sockets, authenticates devices, lists sessions, and relays messages. It listens on a private address (loopback by default); encryption is delegated to tailscale or an ssh tunnel. It also keeps a monitor connection to every session and tracks each terminal's last bell, pushing debounced bell notifications to connected devices — useful for watching AI agents and other long-running programs that ring the terminal bell (printf '\a') when they want attention.g2mirror-view— a terminal client for humans without glasses (e.g. a coworker following a shared project).g2mirror-view g2mirror://<token>@<host>[:port]shows the list of live terminals (arrows + enter to attach,qto quit); attaching prints recent scrollback history into your terminal's own scrollback, then mirrors the live viewport. Ctrl+D detaches back to the list; every other key is forwarded to the wrapped app unless the token or session is read-only.
See PROTOCOL.md for the full protocol (aimed at glasses- driver implementers).
The server config holds an auth_tokens array; each entry has a name, a
token_hash, and a readonly flag (default true — add tokens with
g2mirror-server --add-token <name>, or --add-token <name> --writable
for one that may send input). A viewer can send input (e.g. voice-to-text)
only if its token is writable and the wrapper wasn't started with
--readonly. Legacy configs with a single auth_token_hash still work
(as a writable token named default).
A token can also be restricted to a subset of terminals with a filter
array:
{"name": "robert", "token_hash": "…", "readonly": true,
"filter": [
{"path": "/Users/jim/repositories/shared-project.*"},
{"windowtitle": ".*SHARED.*"}
]}A terminal is visible when any rule matches; within one rule every
present field must match. path is matched against the session's real
working directory and windowtitle against its current title; both are
regexes anchored at both ends. Filters govern everything: hidden terminals
are absent from list, refuse connect, and produce no bell/title
notifications — and since a title change can toggle visibility (handy as
an on/off switch: have the program or your prompt set a title containing a
marker like SHARED), a viewer attached to a terminal that stops matching
is disconnected on the spot. Unknown rule keys and invalid regexes are
config errors, so a typo fails at startup instead of silently widening
access.
Several viewers can view one terminal at the same time; they all receive
the same stream. Which of them sets the wrapped app's size is the
size_precedence config list — an ordered list of token names plus
"host" (the host terminal), e.g. ["glasses", "host", "spectator"]:
the app is sized to the earliest listed party that is currently viewing
("host" always counts as present). Everyone ranked lower gets a stream
at that size, cropped bottom-left to fit their screen — g2mirror-view
tolerates this mismatch natively, and re-synchronizes automatically when
the stream size changes. Unlisted tokens rank below everything listed;
with no list at all, any viewer resizes the app (the original behavior).
When the stream is taller than a viewer's terminal (or than the host terminal), the visible region shows the bottom of the mirrored screen; the hidden top rows are pushed into that terminal's native scrollback, so scrolling up shows the whole screen. Those pushed copies are point-in-time — if the app later edits rows that are above the fold, the scrollback copies go stale (tolerated) — and Ctrl+L pushes fresh copies and repaints (in g2mirror-view and on the wrapper's host terminal alike; the keystroke is still forwarded to the app, so a shell's usual Ctrl+L behavior is preserved).
A device can start a new shell or command on the laptop — without any pre-existing terminal — if its token is allowed to. What it may start is pinned down by named launch presets in the config; the wire can only pick a preset by name, never supply a command line:
"launch": {
"shell": { "argv": ["/bin/zsh", "-l"], "allow_cwd": true },
"watch": { "argv": ["make", "watch"], "cwd": "~/repositories/proj",
"title": "build watcher", "readonly": true }
}Per-preset fields: argv (required), cwd (default ~, ~ expanded),
title (default: the preset name), env (merged over the server's),
allow_cwd (may the request override cwd — e.g. "open a shell in the
directory of the terminal I'm viewing"; default false), scrollback,
readonly, and size (initial pty size like "80x24", until a viewer
resizes it). Tokens opt in with "launch": true (any preset) or
"launch": ["shell", "watch"]; the default is no launching. A launch
grant is remote code execution by design — give it only to tokens you'd
hand a shell. It also requires the token to be writable; a read-only
token with a launch grant is rejected at startup as a contradiction, as
is a grant naming a preset that doesn't exist. --init-config seeds a
"shell" preset from $SHELL and grants it to the glasses token;
--add-token <name> --writable --launch shell (repeatable, or
--launch-all) grants launching to new tokens.
Launched sessions are headless: no terminal shows them, they are sized by their viewers (keeping their last size when the glasses look away, rather than reflowing), and they keep running when every client disconnects. While no one holds their host role they are detached — marked in session lists and claimable from the laptop:
g2mirror -l # list sessions; detached ones first, marked DETACHED
g2mirror -a # claim: attaches immediately if exactly one is detached
g2mirror -a claude # pattern matches pid, title, cwd, command, or presetThe intended flow is "open a fresh tmux/screen tab, run g2mirror -a":
recent scrollback prints into the tab's own history, the live viewport
mirrors bottom-anchored (all the size-mismatch handling above applies),
every key is forwarded, and the claimed terminal's size occupies the
"host" slot of size_precedence — so the glasses still win the app's
size if ranked above "host", and the tab follows along. Ctrl+\
detaches, leaving the session running (--detach-key ctrl-x/none
changes it; ^D is forwarded to the shell, unlike in g2mirror-view). If
several sessions match, a small picker opens; --force takes the host
role over from another attach client (e.g. a tab you lost over ssh).
When the wrapped command exits, the attach client exits with its status.
g2mirror --detached [--title …] -- <command> starts a detached session
from the laptop side (it prints the socket name and pid) — handy for
kicking off long jobs to watch from the glasses later. Attaching to
sessions that have a real host terminal is refused; that's what
g2mirror-view is for.
cargo build
./target/debug/g2mirror htop # press Ctrl+G to toggle the simulated view
./target/debug/g2mirror --title "build watcher" -- make watch
./target/debug/g2mirror-server --init-config # once; prints the "glasses" token
./target/debug/g2mirror-server --add-token spectator # a read-only coworker token
./target/debug/g2mirror-server # ws://127.0.0.1:8737
./target/debug/g2mirror-view "g2mirror://<token>@127.0.0.1:8737"
./target/debug/g2mirror --detached -- make watch # headless session
./target/debug/g2mirror -l # list; DETACHED marks claimable
./target/debug/g2mirror -a watch # claim it into this terminalFor viewers outside your tailnet, terminate TLS with tailscale
funnel instead of building it into
the server. Add loopback to listen_addr (it takes a string or an array):
"listen_addr": ["127.0.0.1", "100.68.94.67"]then tailscale funnel --bg 8737. Direct tailnet clients (the glasses)
keep using the tailscale address; funnel proxies public
wss://<node>.<tailnet>.ts.net traffic — websocket upgrades pass through
its HTTPS proxy — to the loopback listener. A coworker then needs nothing
installed beyond g2mirror-view:
g2mirror-view "g2mirrors://<token>@<node>.<tailnet>.ts.net"(g2mirrors:// is the TLS form, default port 443.) Treat the funnel
hostname as public knowledge — TLS certificates land in Certificate
Transparency logs — so security rests on the tokens: keep coworker tokens
read-only and filtered. The server hardens the public surface by capping
concurrent unauthenticated connections (32), enforcing a 10s
handshake+auth deadline, logging failed authentications with the peer
address, and sending websocket keepalive pings every 30s so idle
connections survive the proxy path. If funnel's HTTP proxy ever misbehaves
for websockets, its TLS-terminated-TCP mode forwards the raw byte stream
and works identically.
--title sets the initial window title (shown in session lists and pushed
to devices) for programs that never set one themselves; a program-set title
takes over from there. Lines that scroll off screen (including before any
device connects) are archived — 10,000 lines by default, --scrollback
to change — and devices fetch them lazily in pages.
While a device is viewing, the host terminal shows the live view anchored
at the bottom of the screen, with scrolled lines flowing up above it and
into the host terminal's own native scrollback in real time — so
scrolling up in your terminal works during and after a view, and
detaching preserves everything that scrolled. The wrapped command's exit status is propagated, and
a client watching when the program quits receives an exit message carrying
that status. cargo test runs unit
tests plus end-to-end tests of the session socket, the websocket server,
and the full device→server→wrapper chain.
src/main.rs— wrapper: pty + child spawn, raw mode, event loop, headless/detached modessrc/attach.rs—--list/--attach: session probing, the picker, and the attach client (a host-role viewer over the session socket)src/control.rs— session socket listener/client framingsrc/lib.rsetc. — shared library:mirror(view state machine and vt100-based output translation, also used by the viewer for its local rendering),history(scrollback archive),protocol(message types),paths(~/.g2mirror handling),raw_guard(RAII raw-mode guard)src/bin/g2mirror-server.rs— websocket gatewaysrc/bin/g2mirror-view.rs— terminal viewer client