MindAttic.Mobile

Self-hosted Windows bridge that exposes ConPTY-wrapped CLI sessions (Claude Code, Codex, etc.) to any browser on your tailnet. Blazor Server + SignalR + xterm.js. PIN/bearer auth, Tailscale CIDR gate, multi-CLI roster, ring-buffer replay for late joiners.

# MindAttic.Mobile

Your desktop CLI, on your phone, over your private network.

Keep coding from the couch, the bus, or the beach. MindAttic.Mobile streams your live Claude Code / Codex / Windows CLI session into any browser on your tailnet as a real web terminal — PIN-protected, Tailscale-gated, with full ConPTY semantics so colors, cursor moves, and TUIs all just work. Self-hosted, zero cloud dependency, and unlike Anthropic's /remote-control it starts new sessions instead of just resuming existing ones.

A self-hosted bridge that lets you drive a desktop CLI session (Claude Code, Codex, anything Windows runs under ConPTY) from any browser on your tailnet. You start the CLI on your workstation; you read and type into it from your phone on the bus.

It's the same idea as tmux + tailscale + a phone SSH client, but rendered as a real web terminal (xterm.js) with PIN auth, multi-tab roster, and proper PTY semantics on Windows. Unlike Anthropic's /remote-control (which only resumes existing sessions), MindAttic.Mobile starts and owns new CLI sessions per terminal tab.

  ┌─────────────────────┐                       ┌────────────────────────┐   │  Phone / iPad       │                       │   Windows workstation  │   │  Chrome / Safari    │                       │                        │   │                     │      SignalR over     │  ┌──────────────────┐  │   │  xterm.js + JS      │◀─── tailnet (HTTP) ──▶│  │ MindAttic.Mobile │  │   │  PIN login / cookie │                       │  │ .Server (Blazor) │  │   └─────────────────────┘                       │  │  port 7780       │  │                                                 │  └──────────────────┘  │                                                 │           ▲             │                                                 │  SignalR  │  in-proc    │                                                 │           ▼             │                                                 │  ┌──────────────────┐  │                                                 │  │ AgentHost.exe    │  │                                                 │  │ (ConPTY wrapper) │  │                                                 │  └──────────────────┘  │                                                 │           ▲             │                                                 │   stdio   │             │                                                 │           ▼             │                                                 │  ┌──────────────────┐  │                                                 │  │  claude / codex  │  │                                                 │  │  (the real CLI)  │  │                                                 │  └──────────────────┘  │                                                 └────────────────────────┘ 

Projects #

Project What it is
MindAttic.Mobile.Core Shared library — RingBuffer, BusyDetector, CliInfo, TokenValidator. No I/O.
MindAttic.Mobile.AgentHost Windows-only console exe. Wraps a CLI under ConPTY (ConPty.cs, PtyBridge.cs), forwards bytes to the server over SignalR (SignalRBridge.cs).
MindAttic.Mobile.Server ASP.NET Core 10 Blazor Server. Hosts the AgentHub, the static login page, the /session/{id} Blazor page, and the JSON/health APIs.
MindAttic.Mobile.Tests NUnit tests for the shared library.
cypress/ End-to-end browser tests against a live server (see below).

How it works #

There is one Session per server process. A Session contains many CLI entries (one per AgentHost connection). Each entry has:

The Session Id is regenerated each time the server boots. The Blazor page checks the route's session id against the live one and redirects home if they don't match — that's how stale /session/{old-id} bookmarks recover gracefully across server restarts.

Wire protocol #

Host → server (AgentHub):

Viewer (browser) → server:

Server → viewer: Hello (registered), Output, Busy, Replay, HostDisconnected.

Server → host: Input, Resize.

HTTP surface #

All routes are bearer-token gated except /_blazor, /_framework, /_content (Blazor bootstrap) and /api/auth (the PIN exchange itself).

Route What it does
GET /healthz Counts: cliCount, busyCount, viewerCount, connectedHostCount.
GET /api/session Roster — id, startedAt, and a list of CLIs ordered by most-recent output.
GET /api/session/clis/{id}/output Raw bytes from the ring buffer (the same bytes a SignalR Replay would send).
DELETE /api/session/clis/{id} Drop a disconnected CLI. 409 if a host is still connected.
POST /api/auth/pin PIN → ma_token cookie. Sets a 30-day cookie on success.

Auth model #

Three carriers, checked in this order by BearerTokenMiddleware:

  1. Authorization: Bearer <token> — API clients, curl, AgentHost.
  2. ?token=<token> — "magic link" entry. TokenLandingMiddleware upgrades this to a cookie on /?token=... and redirects to /.
  3. ma_token cookie — the PIN-login path.

GETs that ask for HTML and fail auth get redirected to /login.html instead of a 401 body, so browsers see the PIN form. Everything else gets 401.

The PIN itself is PBKDF2-SHA256 with a per-server salt; raw PIN never touches disk. Brute force is throttled per remote IP (5 failures → 5 minute lockout, in-memory bucket).

TailscaleGuardMiddleware runs first and rejects any remote IP that isn't loopback or in 100.64.0.0/10. Disable with Mobile:EnforceTailscaleCidr=false for dev/CI.

Settings #

The server reads:

  1. appsettings.json / appsettings.Development.json (the .NET defaults).
  2. In non-dev environments: settings.json and settings.local.json from the parent MindAttic folder (env var MINDATTIC_ROOT or a few hard-coded probes — see Program.cs ResolveSettingsPath). settings.local.json is the untracked overlay where the real Mobile:Token and Mobile:PinHash live.

Keys under Mobile::

Key Meaning
ServerUrl Bind URL. Default http://0.0.0.0:7780.
Token Bearer token. Validated at startup by TokenValidator.EnsureValid.
PinHash / PinSalt / PinIterations PBKDF2 material from Set-MobilePin.ps1.
EnforceTailscaleCidr Default true. Set false to allow non-tailnet clients.

Generate a token: ./Generate-Token.ps1. Set the PIN: ./Set-MobilePin.ps1. Both write into settings.local.json next to the parent settings.json.

Running #

# from D:\Projects\MindAttic\MindAttic.Mobile dotnet run --project MindAttic.Mobile.Server -c Release # server is up on http://0.0.0.0:7780 

To wrap a CLI with AgentHost manually (the launcher does this for you):

.\MindAttic.Mobile.AgentHost\bin\Release\net10.0\MindAttic.Mobile.AgentHost.exe `   --provider Claude `   --project MyProject `   --title "MyProject [Claude]" `   --server http://127.0.0.1:7780 `   --token <Mobile:Token from settings.json> `   --run-command "claude --dangerously-skip-permissions" `   --working-directory D:\path\to\repo 

In practice you don't run AgentHost directly — console-launcher.ps1 (parent repo, D:\Projects\MindAttic\) inspects per-project Mobile.Enabled settings and either spawns the CLI directly or wraps it via AgentHost based on those flags.

Tests #

Unit tests #

dotnet test MindAttic.Mobile.Tests 

Covers RingBuffer, BusyDetector, MobileSettings, TokenValidator.

Cypress e2e tests #

End-to-end browser tests that exercise the full PIN-skip → SignalR → xterm.js → /api/output round trip. See cypress/README.md.

cd cypress npm install npm test 

Tests require:

Files of interest #

License #

Private. © Ryan DeBraal / MindAttic.