# 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:
- A
CliInforecord (project, title, provider, busy/connected/viewer counts). - A 64 KiB ring buffer of captured PTY output. Late-joining viewers get a
Replayof this buffer on subscribe so they don't see a blank terminal while they wait for the next byte. - A SignalR connection id pointing to the bound AgentHost, plus a set of viewer connection ids.
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):
RegisterHost(CliInfo info)— once at startup, idempotent oninfo.Id.PublishOutput(string cliId, byte[] data)— every PTY stdout/stderr chunk.PublishBusy(string cliId, bool isBusy)— edge transitions only, driven byBusyDetectormatching "esc to interrupt" etc.
Viewer (browser) → server:
SubscribeViewer(string cliId)/UnsubscribeViewer(string cliId).SendInput(string cliId, byte[] data)— keystrokes.Resize(string cliId, int cols, int rows).
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:
Authorization: Bearer <token>— API clients, curl, AgentHost.?token=<token>— "magic link" entry.TokenLandingMiddlewareupgrades this to a cookie on/?token=...and redirects to/.ma_tokencookie — 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:
appsettings.json/appsettings.Development.json(the .NET defaults).- In non-dev environments:
settings.jsonandsettings.local.jsonfrom the parent MindAttic folder (env varMINDATTIC_ROOTor a few hard-coded probes — seeProgram.cs ResolveSettingsPath).settings.local.jsonis the untracked overlay where the realMobile:TokenandMobile:PinHashlive.
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:
- The server running at
http://127.0.0.1:7780. MindAttic.Mobile.AgentHost.exebuilt in Release.- A valid
Mobile:Tokeninsettings.local.json(Cypress reads it directly to drop thema_tokencookie — no PIN flow at runtime).
Files of interest #
MindAttic.Mobile.Server/Program.cs— DI wiring, middleware order, minimal-API endpoints.MindAttic.Mobile.Server/Hubs/AgentHub.cs— the entire wire protocol surface.MindAttic.Mobile.Server/Services/Session.cs— the singleton roster.MindAttic.Mobile.Server/Services/BearerTokenAuth.cs— bearer middleware + token-landing middleware.MindAttic.Mobile.Server/Services/PinAuth.cs— PBKDF2 + lockout.MindAttic.Mobile.Server/Services/TailscaleGuard.cs— CIDR gate.MindAttic.Mobile.AgentHost/Program.cs— CLI option parsing + glue betweenPtyBridgeandSignalRBridge.MindAttic.Mobile.AgentHost/PtyBridge.cs/MindAttic.Mobile.AgentHost/ConPty.cs— Windows ConPTY interop.MindAttic.Mobile.Core/RingBuffer.cs— fixed-size circular byte buffer with thread-safe write and snapshot.MindAttic.Mobile.Core/BusyDetector.cs— substring scan against the PTY byte stream for busy/idle inference.
License #
Private. © Ryan DeBraal / MindAttic.