← Blog May 8, 2026

# 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 claude` on Windows paths and missed `~/.local/bin/claude` in WSL.
- MCP configs were written to `C:\Users\<name>\.claude.json` — the WSL agent reads from `/home/<user>/.claude.json` and 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\project` can't be used as a working directory by `CreateProcessW` at 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:

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:

- 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.