Skip to content

Architecture

iPhone App Mac menu bar app
┌────────────────┐ iroh (QUIC, direct/relay) ┌──────────────────────────┐
│ NohupClient │ ──────────────────────────▶ │ NohupServer │
│ SecureSession │ framed protocol + app-layer │ ├ RunRegistry ─ claude -p │
│ IrohNode │ encryption │ ├ SessionStore (jsonl) │
└────────────────┘ │ ├ WorktreeService (git) │
│ └ IrohNode │
└──────────────────────────┘
  • Mac: a menu bar app that runs an iroh node and accepts connections from devices paired by QR code. Each session maps to a long-running claude -p --input-format stream-json process whose output is streamed to clients as events.
  • iOS: the app embeds an iroh node (no account, no system VPN slot), pairs with Macs by QR code, manages multiple Macs, projects and git worktrees, and shows runs live.
Module Purpose
NohupKit/Sources/NohupProtocol Frame codec, message types, the SecureSession encryption layer, fd-based FramedConnection
NohupKit/Sources/NohupIroh Swift wrapper around the iroh bridge (iroh-bridge/, Rust): each connection is a bidirectional QUIC stream bridged to a plain fd
NohupKit/Sources/NohupClient Client, shared by iOS and the CLI
NohupKit/Sources/NohupServer Server core: process scheduling, sessions, worktrees, routing
NohupKit/Sources/nohup-server Headless server for debugging
NohupKit/Sources/nohup-cli Debug client
NohupMac / NohupiOS The two apps
  • Frames: "RAUD" | ver | kind | flags | rsv | len u32 | JSON payload; kind is one of request / response / event / ping / pong.
  • Encryption: see Security.
  • Pairing: the hello message carries the one-time pairing code from the QR code; the Mac checks it and records the peer’s iroh node ID. From then on, only the node ID (authenticated by iroh) is needed.
  • Timeline: the session’s jsonl is the source of truth (history + watchSession); live run events provide streaming text. Both sources share message uuids and are de-duplicated by id. Sessions started directly in the Mac’s terminal can be watched live too.

No lost messages, no duplicate runs.

Stage Mechanism
Sending The iOS app writes each prompt to a persistent outbox (outbox.json) before sending, and removes it only after the server acknowledges. After a network drop, request timeout or the app being killed, pending prompts are resent in order on reconnect
De-duplication Every message carries a client-generated clientMessageId. The server handles it idempotently and persists it to runs.json, so a message runs only once no matter how often it’s resent
Session ownership The client pre-generates the sessionId for new sessions; the server creates them with claude --session-id and continues existing ones with --resume. Resent messages always land in the right session
Receiving Run events carry an increasing seq; after reconnecting the client resumes with subscribe(runId, afterSeq). Once a complete text or thinking item arrives, the streaming deltas before it are compacted out of the buffer, so buffer size is largely independent of output length. If the buffer ever overflows, the server returns complete=false and the client reloads from jsonl
Disconnect detection 10s heartbeat; 30s without data counts as disconnected. Every request has a timeout that closes the connection, so half-open connections don’t hang. iOS watches network changes, reconnects immediately when the network returns and probes the connection when switching between Wi-Fi and cellular. Reconnects use exponential backoff with jitter
Mac restart Runs that were in progress are marked interrupted after a restart and are not re-run automatically, so side-effecting operations never execute twice. The client asks you to confirm before resending