← Blog

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:

The same shape of problem keeps showing up:

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.

ProblemResolution under tuicommander-remote
Agent detection misses WSL binariesDaemon runs which claude natively inside WSL
MCP config written to wrong filesystemDaemon writes to ~/.claude.json inside WSL — same FS the agent reads from
MCP bridge unreachable from WSL agentsBridge runs in WSL, agents connect via plain localhost
PTY env vars (TERM_PROGRAM, COLORTERM) skipped on WindowsDaemon spawns Linux PTYs natively with full env
UNC paths fail as cwd for CreateProcessWNo 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:

  1. Build a container image with tuicommander-remote baked in (or docker cp it into a running one).
  2. Start it with the project mounted, port 9877 exposed to the host.
  3. 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:

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:

Desktoptuicommander-remote
Binary size~50–80 MB~15–20 MB (static)
System depsWebView2 / WebKitGTKNone
StartupSecondsMilliseconds
Attack surfaceClipboard, opener, hotkeys, micPTY, git, MCP, file ops
Container-friendlyNoYes

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.