One daemon, every environment: meet tuicommander-remote
A headless Rust daemon that runs inside WSL, Docker dev containers, and remote servers — and lets the desktop app drive them as if they were local. No path translation. No cross-OS shims. One protocol the frontend already speaks.
The problem: TUICommander only saw the local machine
For most users, "where the agent runs" and "where the desktop app runs" are the same machine. For a growing chunk of users, they aren't.
The clearest case came in issue #20: a Windows user with WSL2 as their development environment. Claude Code, Codex CLI, Gemini CLI — all installed inside WSL because that's where the toolchain, the repos, and the project's node_modules live. TUICommander on Windows could spawn a wsl.exe shell, but that's where the integration ended:
- Agent detection ran
where claudeon Windows paths and missed~/.local/bin/claudein WSL. - MCP configs were written to
C:\Users\<name>\.claude.json— the WSL agent reads from/home/<user>/.claude.jsonand never saw them. - The MCP bridge listened on a Windows named pipe (
\\.\pipe\tuicommander-mcp) that WSL processes can't reach without extra plumbing. - UNC paths like
\\wsl.localhost\Ubuntu\home\user\projectcan't be used as a working directory byCreateProcessWat all.
The same shape of problem keeps showing up:
- Docker dev containers. The agent should run inside the container so dependencies, env vars, and filesystem isolation match the project. From the desktop side, you're looking at a separate Linux userspace with its own paths.
- Remote servers. Beefy VPS, cloud dev machines, internal lab servers. SSH gets you a shell, but the parsed terminal state, agent detection, MCP bridge, repo/PR awareness — all of that lives in the desktop process and never makes it across.
We could have shipped three special cases — a WSL mode, a Docker mode, an SSH mode — each with its own path translation, each fragile in different ways. Instead we built one thing.
The shape of the answer: run the backend where the code lives
tuicommander-remote is a headless Rust binary that contains the same backend the desktop app uses, minus the desktop bits. It runs inside the target environment — the WSL distro, the Docker container, the remote host — and the desktop app talks to it over HTTP/WebSocket.
TUIC Desktop / PWA (client)
├─ localhost (embedded backend, today — no change)
├─ wsl-ubuntu:9877 (tuicommander-remote in WSL)
├─ container:9877 (tuicommander-remote in a dev container)
└─ vps.example.com:9877 (tuicommander-remote on a server)
The key insight: 185 of ~200 backend commands already had HTTP mappings. We built them months ago for the PWA / browser mode that powers couch-from-the-iPad and phone access. The remote daemon isn't a new protocol — it's the existing protocol pointed at a different host.
The Axum HTTP server already runs unconditionally inside the desktop app. Two fields on AppState were Tauri-specific (app_handle, grid_channels) and both already had non-Tauri equivalents bypassed in browser mode. So "extracting a daemon" mostly meant: take what already runs, drop the WebView shell, ship it as a separate binary.
Use case 1: WSL on Windows
You install tuicommander-remote inside your WSL distro. It binds to 127.0.0.1:9877 — invisible to the public internet, reachable from Windows because WSL2's loopback is shared. The Windows desktop app adds a connection pointing at it, and that connection becomes a routing target for any repo you flag as "lives in WSL".
Every problem in issue #20 dissolves the same way: there is no cross-OS boundary in the execution path.
| Problem | Resolution under tuicommander-remote |
|---|---|
| Agent detection misses WSL binaries | Daemon runs which claude natively inside WSL |
| MCP config written to wrong filesystem | Daemon writes to ~/.claude.json inside WSL — same FS the agent reads from |
| MCP bridge unreachable from WSL agents | Bridge runs in WSL, agents connect via plain localhost |
| PTY env vars (TERM_PROGRAM, COLORTERM) skipped on Windows | Daemon spawns Linux PTYs natively with full env |
UNC paths fail as cwd for CreateProcessW | No path translation — daemon uses native Linux paths |
The only cross-OS hop is the WebSocket from the Windows desktop to the WSL daemon. That's TCP over a virtual loopback. It's the boring part.
Use case 2: isolated Docker dev containers
If you've ever run npm install in a project and then watched your global Node version get sideways, you know why people care about dev containers. The agent should run where the project's dependencies, lockfiles, and runtime versions are pinned — not on your laptop's host.
With the daemon, the workflow is:
- Build a container image with
tuicommander-remotebaked in (ordocker cpit into a running one). - Start it with the project mounted, port
9877exposed to the host. - Add the connection in TUICommander Settings.
From there, agents you spawn against that repo run inside the container. Their node_modules is the container's. Their $PATH is the container's. Their MCP bridge is the container's. If you tear the container down, nothing leaks back to the host. Spin up three containers with three different toolchains, point the desktop at all three, and run agents against each in parallel — the desktop app doesn't care that they're separate userspaces.
The daemon is small enough that this is reasonable: estimated ~15-20 MB, statically linked, no system dependencies. It runs on Alpine.
Use case 3: remote servers
The use cases here run together. A VPS where you actually have CPU and bandwidth for long agent runs. A lab server with a GPU and a private dataset. A cloud dev machine that's always-on so you can pick up where you left off from any laptop. A teammate's machine you've been invited to pair on.
SSH gets you a terminal. It does not get you the parsed state, the agent detection, the activity dashboard, the repo-aware UI, or the MCP bridge. Those all live in the backend process. Run the backend on the remote box and they all come with you.
Auth scales with the threat model:
- Bearer token + localhost binding for LAN and WSL — the simple case.
- Tailscale for "anywhere on my devices" — TLS via
tailscale cert, identity via WireGuard. We already implemented this for PWA access. - mTLS for public-internet exposure without Tailscale — ephemeral CA, pinned client cert exchanged once at setup.
- SSH tunnel for environments where you only have SSH — daemon binds localhost, you forward the port. Zero custom auth.
One hard rule, enforced in code: the daemon binds to 127.0.0.1 by default. Public binding requires Tailscale or mTLS — there's no setting that turns a token-only daemon into a publicly-reachable service. That mistake should be impossible to make.
Why a separate binary, not "Tauri in headless mode"
Running the desktop app with the GUI hidden was the obvious shortcut. We didn't take it. The Tauri shell bundles a WebView runtime, a clipboard plugin, an updater, deep-link handling, a global-shortcut subsystem, a single-instance lock, the Whisper model loader for dictation, and a system tray. None of that belongs in a daemon you're going to deploy to an Alpine container.
So we extracted the shared core into a workspace crate, tuic-core, and built two binaries against it:
tuicommander/
├── src-tauri/ # Desktop app (Tauri shell + plugins)
├── tuicommander-remote/ # Headless daemon (Axum only)
└── crates/
└── tuic-core/ # Shared: state, PTY, git, MCP, file ops, HTTP routes
This is the same pattern Zed uses (zed-remote-server) and the same shape Warp ships. Same code, two builds, zero divergence risk because there's only one place the logic lives.
The size and surface differences are real:
| Desktop | tuicommander-remote | |
|---|---|---|
| Binary size | ~50–80 MB | ~15–20 MB (static) |
| System deps | WebView2 / WebKitGTK | None |
| Startup | Seconds | Milliseconds |
| Attack surface | Clipboard, opener, hotkeys, mic | PTY, git, MCP, file ops |
| Container-friendly | No | Yes |
What's already on main, and what's next
Phase 1 — the tuic-core extraction and a working headless binary — landed on the feat/remote-daemon branch and is being merged in stages. The Tauri-specific fields on AppState are cfg-gated, the binary entry point exists, and the daemon serves requests against the same Axum routes the desktop uses.
Phase 2 is the connection manager UI — adding remote hosts in Settings, tagging repos with a connection, transport routing. That's the work that turns "the daemon runs" into "you can actually use it from the GUI without editing config files."
Phase 3 is deployment ergonomics: tuic remote install <host> to copy the binary over SSH or docker cp, generate a one-time pairing token, register it in the desktop. The goal is that adding a new environment is a single command, not a checklist.
And there are two quick wins from issue #20 that are independent of the daemon and worth shipping anyway: parsing shell arguments (so wsl.exe -d Ubuntu -- /bin/zsh -l works as a shell setting) and injecting TERM_PROGRAM/COLORTERM/KITTY_WINDOW_ID when spawning WSL shells. Those land sooner.
The bigger arc, though, is that "where TUICommander runs" stops mattering. You have a single window. You have a sidebar with repos. Some of them happen to live on your laptop, some inside a container, some on a server in a different country. The agents run where the code is. The desktop drives them. The line between local and remote becomes a connection setting in the same panel as your theme.