all writing

Porthole: A Phone Window Into My Claude Code Sessions

I run multiple Claude Code sessions across tmux windows and wanted to read them — and reply to them — from my phone. So I had Claude Fable 5 build the tool, orchestrating a fleet of Sonnet and Opus sub-agents, while friends watched the commits land in real time.

7 min read

AI-generated abstract workflow illustration of a graph of connected nodes, in purple on a dark charcoal background
On this page

The Itch

I usually have three or four Claude Code sessions going at once, each in its own tmux window — one building a feature, one babysitting infra, one doing something I’ll regret. The workflow problem isn’t starting them, it’s reading them. A long Claude response is miserable to review standing in the kitchen, and when a session asks a question, the answer lives on a keyboard in another room.

What I wanted was small and specific: every session on one screen, readable from my phone over Tailscale, live as it streams, with a reply box that lands my text in the right tmux pane. Not a cloud product, not an agent platform. A porthole.

After the loupe experiment I knew the shape of the method: write a spec that’s mostly guardrails, hand it to a capable model, let it run. This time the model was Claude Fable 5, the new tier above Opus, and the spec added a twist — it told the model to orchestrate other models. Sonnet 5 for scaffolding and docs, Opus 4.8 for the hairy parts: the JSONL parser, the SSE layer, the tmux injection adapter, process-tree detection.

A few friends had the repo open watching commits land while it built. One of them was me, on my phone, using the half-built tool to watch the session that was building it. We’ll get to that.

What Got Built

porthole is a Bun backend plus a Vue 3 SPA. The backend discovers every Claude Code session on the box by scanning ~/.claude/projects/**/*.jsonl, parses the event stream into typed transcript events, figures out which sessions are live by correlating tmux panes with running processes, and serves it all over plain HTTP + SSE. The frontend is a dark, monospace, thumb-reach UI with an animated shader background — session list with pulsing active indicators, live transcript with tool output folded into collapsible blocks, and a draft pad with Copy and Send.

porthole's session list on a phone: active sessions with amber indicators and tmux target badges, recent sessions below

Send is the dangerous part, so it’s the most carefully specified part. Text goes to the backend, which injects it into the exact tmux pane via bracketed paste — load-buffer from stdin, paste-buffer -p, then Enter. Every tmux invocation is an argv array through Bun.spawn. There is no code path where my draft text touches a shell string, and exactly one module in the codebase is allowed to spawn tmux at all. Dead sessions get a Resume button instead, which opens a fresh tmux window running claude --resume <uuid>.

Security model: Tailscale is the auth. The server binds wide and trusts the tailnet, because adding a login page to a personal tool on a private network is theater. The one real threat — some random webpage in my phone’s browser POSTing to the tailnet IP from inside the perimeter — is blocked with an origin check and a forced JSON content type. That hole, by the way, was found by an automated security review bot mid-build, and the model fixed it before I’d read the finding.

Contracts First, Then a Fleet

The build followed the same contracts-first pattern that worked for loupe, but with the parallelism turned up. Fable wrote shared/types.ts (the API contract) and stub files with frozen signatures for the four backend modules, committed them, and then dispatched four sub-agents simultaneously — three Opus, one Sonnet — each owning disjoint files and forbidden from committing. The orchestrator reviewed each result, committed, and pushed. Twenty-odd conventional commits, each gated through commit-guard hooks that were installed before the first commit existed.

Every sub-agent brief ended the same way: verify against reality before reporting back. Not “make the types check” — actually run it. The parser agent had to parse all 105 real session files on the box and report timing. The tmux agent had to spawn a disposable tmux session running cat, inject multiline text into it, capture the pane, and prove the text arrived intact. The activity agent had to correctly map the two Claude sessions that were live on the machine at that moment — including the one running the build.

The spec assumed wrong, and the verification caught it

The spec suggested detecting active sessions via /proc/<pid>/fd — find the claude process holding the session’s .jsonl open. Reality: Claude Code doesn’t keep the file open. It appends and closes. The fd table of a live session has no jsonl in it at all. Because the spec said “verify which signal is reliable on this box” instead of “use this signal,” the model probed first, found the truth, and built cwd-correlation as the primary signal with fd-matching as an opportunistic bonus. A spec that dictates mechanism gets confidently wrong code; a spec that dictates verification gets working code.

The Bugs Only Reality Could Catch

Three bugs made it into the build. None of them would have been caught by tests-and-typecheck, and all three were caught because the working method demanded live end-to-end proof before calling anything done.

The pane-process bug. Detection walked each tmux pane’s descendant processes looking for claude — which works when your shell launched claude, because the pane process is zsh and claude is its child. Then the end-to-end test spawned a fresh session with tmux new-session ... 'claude' and detection returned nothing. When tmux is handed a command directly, the pane process is claude. No shell, no descendants. Every resumed session — the tool’s own Resume feature! — would have been invisible forever. One-line fix: include the pane pid itself in the search.

The dying streams. During screenshot passes, the browser console showed ERR_INCOMPLETE_CHUNKED_ENCODING on the SSE endpoint every twenty seconds or so. Easy to dismiss as navigation noise — EventSource reconnects silently, the UI looked fine. It wasn’t noise. Bun.serve has a default idleTimeout of 10 seconds, and the SSE heartbeat was set to 15. Every stream was being killed between heartbeats and resurrected by the browser, forever. On a phone that’s battery drain and missed events; on the desk it’s invisible. One config line.

The horizontal scroll. Mid-build, from my phone, I hit the deployed tool and texted one line back: chat view scrolls sideways. Root cause was a stack of three: MCP tool names like mcp__plugin_playwright_playwright__browser_run_code_unsafe in a flex row that refused to shrink, unbroken URLs in assistant markdown, and markdown tables with nowhere to scroll. The fix landed and was verified with headless-browser overflow measurements — every element checked against the viewport, expanded and collapsed — a few minutes after the text message.

porthole's transcript view showing the session that built porthole: markdown, collapsed tool blocks with truncated names, a running tool, and the reply pad

That screenshot is the tool showing the session that built the tool, including the moment it fixed the SSE bug. I enjoy this more than I should.

Same Rules As Last Time

Everything I said about loupe applies here unchanged, so I’ll compress it: this is a personal tool with a blast radius of approximately zero, which is exactly why full autonomy was the right call. It is not how I’d build something people depend on. The method scales down beautifully and I still don’t believe it scales up unsupervised.

What was new this time was the model dispatching models. The orchestrator stayed one context — reviewing, committing, keeping the narrative — while specialist agents burned their own context windows on deep work and reported back summaries. That division kept the main thread coherent across the whole build in a way a single context never manages. The senior-dev-with-juniors shape from the loupe post, but now the seniority gradient is literal model tiers.

Code’s at github.com/codywilliamson/porthole. Prereqs are just bun, tmux, and a tailnet. Fork it, point it at your own sessions, reply to your agents from the hammock. No roadmap, no promises. 🔭

What To Take Away

  • Spec verification, not mechanism. 'Verify which signal is reliable on this box' produced working detection; 'use /proc fd matching' would have produced confident garbage — the obvious signal turned out not to exist.
  • Freeze the contracts, then parallelize. Typed stubs committed before dispatch let four sub-agents build disjoint modules simultaneously with zero merge pain, and the orchestrator owned every commit.
  • Match the model to the work. Sonnet for scaffolding and docs, Opus for parsers, process forensics, and streaming — and one orchestrating context that never dives so deep it loses the plot.
  • End-to-end against live reality is non-negotiable. The pane-process bug, the idle-timeout stream kills, and the mobile overflow were all invisible to tests and typecheck; all three fell out of actually driving the running system.
  • Console noise is data. Repeating 'harmless' reconnect errors were a default timeout silently killing every SSE stream on a schedule.
  • Inject into terminals like it's hostile input, because it is. Argv arrays only, bracketed paste for multiline, one module allowed to touch tmux, and UUID validation on anything that reaches a command line.
  • A private network is a perimeter, not an excuse. The CSRF hole was reachable from inside the tailnet via my own phone's browser — origin and content-type checks cost three lines.

written by

Cody Williamson

Senior Software Engineer