Blog
Inside the Mac Companion App: Architecture of a Lightweight Session Manager
The Mac companion app is the quiet backbone of Tactic Remote — a menu bar utility that manages tmux sessions, runs an embedded HTTP/WebSocket server, and keeps your Mac awake during active AI work.
Follow product and engineering updates from this channel.
Browse categoryThe Mac companion app is the least visible part of Tactic Remote and arguably the most important. It runs in your menu bar, manages every Claude Code session on your machine, serves the API your iPhone connects to, and handles the unglamorous work of keeping sessions alive across sleep cycles and network changes. This post explains how it works and why we made the architectural decisions we did.
Design Constraints
We set three constraints early in development:
- The app must be imperceptible when idle. Developers already run dozens of background processes. A session manager that consumes noticeable CPU or memory at rest is unacceptable.
- It must handle multiple concurrent sessions. Many developers run two or three Claude Code tasks simultaneously — one for a feature branch, one for tests, one for documentation.
- It must survive Mac sleep and network changes gracefully. A developer closes their laptop, walks to a coffee shop, opens it again, and everything should reconnect within seconds.
These constraints shaped every layer of the architecture.
The Embedded Server Model
Rather than running a separate server process, the companion app embeds an HTTP and WebSocket server directly. This was a deliberate choice over the alternative of shipping a standalone daemon.
Why embedded? A separate daemon requires its own lifecycle management — launchd configuration, process monitoring, log rotation, and crash recovery. Each of these is a potential failure point and a support burden. By embedding the server in the app itself, the lifecycle is simple: when the app runs, the server runs. When the app quits, everything stops cleanly.
The embedded server binds to a configurable port (default 48736) on either localhost only (for local network mode with a separate network proxy layer) or on all interfaces. It exposes a REST API for session management and status queries, and a WebSocket endpoint for real-time terminal streaming and approval push.
At idle, the server holds open sockets but performs no work. In our benchmarks, the companion app at rest with no active sessions consumes approximately 18 MB of memory and negligible CPU — comparable to a typical menu bar utility.
tmux as the Session Backbone
Every Claude Code session managed by Tactic Remote runs inside a tmux session. We chose tmux over alternatives like screen or raw PTY management for several reasons:
Session persistence. tmux sessions survive independently of the process that created them. If the companion app crashes and restarts, the tmux sessions and their Claude Code processes continue running undisturbed. The app simply re-attaches to existing sessions on startup.
Capture without interference. tmux's capture-pane command lets us read terminal content without injecting input or modifying the running process. This is critical — we need to stream terminal output to the iPhone without any risk of disrupting Claude Code's operation.
Named sessions. Each Claude Code session gets a predictable tmux session name (e.g., claude-remote-0, claude-remote-1), making it straightforward to enumerate, attach to, and manage sessions programmatically.
Mature and universal. tmux ships with macOS or is trivially installable via Homebrew. It has decades of stability behind it and handles edge cases (terminal resizing, Unicode, escape sequences) that we would otherwise have to solve ourselves.
The companion app polls active tmux sessions at a configurable interval (default 200ms) to capture fresh terminal content. When content changes, it pushes the delta over WebSocket to connected clients. This polling approach is intentionally simple — we evaluated filesystem-based notification mechanisms and found them less reliable across macOS versions than straightforward polling at this interval.
Session Lifecycle Management
A Claude Code session in Tactic Remote goes through a defined lifecycle:
- Creation — The iPhone sends a "start session" request. The companion app creates a new tmux session and launches Claude Code inside it with the specified working directory and task description.
- Active monitoring — Terminal content is captured and streamed. Approval requests from Claude Code's hook system are intercepted and forwarded to the iPhone via WebSocket and push notification.
- Idle detection — If a session has no terminal output changes for a configurable period, its polling frequency drops to reduce resource usage.
- Completion — Claude Code exits. The companion app detects the exit, captures final output, and notifies the iPhone.
- Cleanup — The tmux session is preserved for a configurable retention period (default 30 minutes) so users can review output, then cleaned up automatically.
Managing multiple concurrent sessions means multiplexing these lifecycles independently. Each session has its own capture loop, its own WebSocket channel, and its own state machine. The companion app currently supports up to 8 concurrent sessions, a limit based on practical resource testing rather than architectural constraints.
Preventing Mac Sleep
This was one of the less obvious engineering challenges. macOS aggressively sleeps when it detects inactivity, and a Claude Code task running in tmux doesn't generate the kind of user input that macOS recognizes as "activity."
The companion app uses the IOPMAssertionCreateWithName API to hold a power assertion whenever the server is running and the user has the "Prevent Sleep While Running" preference enabled. The assertion type is kIOPMAssertionTypePreventUserIdleSystemSleep, which is precisely the contract Apple documents: it prevents idle-timeout sleep while still allowing the display to turn off. When the server stops or the user toggles the preference off, the assertion is released immediately.
Worth being explicit about what this assertion does not cover, because the documented contract is narrower than users often assume:
- Closed lid — Apple Silicon laptops force sleep at the hardware level when the lid magnet engages. No power assertion can override this. For 24/7 remote use on a laptop, run in clamshell mode (AC power + external display + external keyboard) per Apple's published requirements.
- Manual sleep — when the user picks Sleep from the Apple menu, the assertion is intentionally honored as user intent and the system sleeps.
- Low battery — once macOS triggers low-power sleep, the assertion does not block it.
We considered using caffeinate as an external process but rejected it for the same reason we rejected a separate daemon — it adds lifecycle complexity. The IOKit assertion API gives us precise control and integrates cleanly with the app's own session tracking.
Menu Bar Integration
The companion app presents as a menu bar icon with a minimal dropdown interface. The menu shows:
- Connection status — Whether the server is running, which port it's bound to, and whether a Cloudflare Tunnel is active.
- Active sessions — A count of running Claude Code sessions with brief status indicators.
- QR code access — A one-click option to display a QR code containing the connection URL and authentication token for iPhone pairing.
- Quick actions — Start/stop server, open settings, copy connection URL.
We intentionally avoided building a full windowed interface. The companion app's job is to be infrastructure, not a destination. Developers interact with Tactic Remote through their iPhone; the Mac app should require attention only during initial setup and occasional configuration changes.
Cloudflare Tunnel Integration
When Cloudflare Tunnel mode is enabled, the companion app manages the cloudflared process as a child process. It starts cloudflared with the configured tunnel credentials, monitors its health via the local metrics endpoint, and restarts it automatically if the tunnel drops.
The integration handles a subtle timing issue: the tunnel must be fully established before the iPhone can connect through it. The companion app waits for cloudflared to report a healthy connection (typically 1-3 seconds after launch) before advertising the tunnel URL as available. During this window, the menu bar icon shows a distinct "connecting" state.
If the tunnel process crashes, the companion app attempts up to 3 restarts with exponential backoff (2s, 4s, 8s) before marking tunnel mode as failed and notifying connected clients to fall back to local mode if available.
Authentication
Every API request and WebSocket connection requires a bearer token. The token is generated on first launch and stored in the macOS Keychain. It's included in the QR code displayed during setup, so pairing is a single scan rather than a manual token copy.
The token is a 256-bit random value, generated using SecRandomCopyBytes. We chose a static token model over session-based authentication because the primary threat model is unauthorized network access, not multi-user access control. For teams that need per-user authentication, we recommend placing Cloudflare Access in front of the tunnel.
What We Considered and Rejected
A full Electron app. The memory overhead alone disqualified this — Electron's baseline is 80-150 MB, roughly 5-8x what our native Swift app uses at rest.
A standalone CLI daemon. Simpler to build initially, but harder for non-technical users to manage and impossible to integrate with macOS menu bar conventions.
Direct PTY management instead of tmux. More control, but we would have had to reimplement session persistence, terminal emulation edge cases, and process lifecycle management that tmux already handles reliably.
A LaunchAgent for auto-start. We implemented this but made it opt-in rather than default. Developers have strong opinions about what runs at login, and we respect that.
Performance Profile
Under typical load (2 active sessions, 1 connected iPhone), the companion app's resource usage:
| Metric | Value |
|---|---|
| Memory (RSS) | 22-35 MB |
| CPU (idle, no output changes) | < 0.1% |
| CPU (active streaming, 2 sessions) | 1-3% |
| Disk I/O | Negligible (no logging to disk by default) |
| Network (local mode, active streaming) | 10-50 KB/s |
These numbers were measured on a MacBook Pro M2 running macOS 14. Performance on Intel Macs is comparable, with slightly higher CPU usage during active streaming.
Looking Ahead
The companion app's architecture was designed to support capabilities beyond what we've shipped today. The session management layer can accommodate session sharing (multiple iPhones monitoring the same session), session recording (capturing full terminal history for playback), and richer integration with Claude Code's extensibility APIs as they evolve.
For setup instructions, see Getting Started. For troubleshooting the Mac companion app specifically, see Common Issues.
Try Tactic Remote
Control your coding Agents from your phone
Connect to Claude Code, Codex, and other Agents on your Mac, Windows, or Linux computer. Check progress and send the next instruction from iPhone or iPad.