The product is "know when a session needs you without watching terminals". Away from the desk is where it matters most: agents run long, and the amber moment usually lands once you have walked away. This surface is the board in your pocket.
It is a native app. That keeps the hub simple: a browser cannot set headers on an
EventSource, so a web board would force cookies, sessions, a login page and CORS onto the
hub. URLSession sets an Authorization header freely, so the hub stays a pure
API and the phone is another client.
Jumping spawns tmux against a socket on a local filesystem, or raises a local window.
A phone has neither, so it cannot jump itself. The laptop can: it is already listening to the hub and
already knows how to jump, so the phone asks it to.
The ask is a command on the same bus the board rides. Fire the jump from a card with the arrow or a right-swipe, and the machine that owns that session raises the window. The phone in your hand becomes a jumplist for the laptop on your desk.
Two rules:
jump fires
seen only once the switch has landed.origin.kind reserves ssh as a future kind. When it lands, a row on a
machine that is not next to you becomes jumpable too.
Same anatomy and the same words as the jumplist, so the two surfaces never disagree: status mark, agent glyph, name, host, age, and the subtext.
A bold name means unread. The amber mark alone means the session is asking for you; there is no extra badge.
The line under each name is the latest line of the exchange, cropped to two lines. While a session works you see your prompt (what it is working on); once it finishes, asks, or fails you see its reply (what came back). Identical rule to the jumplist.
A row shows the machine it lives on when more than one machine is on the board. The machine decides whether a row can be acted on, so it matters most on a shared hub. With one machine every row shows the same host and the state line already says "1 machine", so the chip drops out.
The glyph comes from the event's agent field, mapped locally to the same per-agent
icons the jumplist uses (see the table), with the plain ring as the
fallback for anything unknown. Naming priority is label, then title, then
the cwd folder name.
The hub owns the order. GET /state returns rows in display order (most recently
engaged first) and the app adopts it verbatim. Do not re-sort client-side: the ordering rules live in
the reducer, and a surface that re-derives them will drift.
The search field lives in the navigation bar's drawer: it is hidden until you pull the list down,
which keeps the board itself uncluttered. Search filters live over name, prompt, reply and agent. A
query starting with # switches to tag mode (#ops shows only sessions carrying
that tag, case-insensitive, exact match), matching the jumplist. Hidden rows drop out of the main
list (see below); acked rows stay, drawn quiet.
Long-press a card and choose Pin (the Messages / Telegram gesture). The session stays at the top of the board with a small pin mark, regardless of engagement order. Long-press again to unpin.
Pinned rows sort first. Within the pinned group, and within everything below it, the order is still
the hub's engagement MRU. The hub owns the order, so pin and unpin are
user events like seen and hide: the reducer serializes pinned,
and every surface (this app, the jumplist, the menu bar) shows the same partition.
Hiding a pinned session unpins it. Hide is the stronger, more recent intent.
Hidden sessions collapse into a quiet divider at the bottom of the list - the Reminders "Show Completed" pattern. The section holds only sessions that are currently silent and deliberately hidden: a hidden session that speaks again returns to the main list on its own, so the section stays small.
show user event - clears hidden without waiting for the
agent). Tap to collapse again.An empty board means two different things, and the app says which. On a live hub with nothing
running, it is the quiet moon: No sessions. Fire an agent and it appears here. Before the first
connection lands - not paired, offline, or a rejected token - the app shows Not connected
instead, pointing at the qrcode.viewfinder button top-right to scan a pairing code, or the
manual address in Settings.
| Tab | What it is for |
|---|---|
| Sessions | Every session on the hub, in the hub's order, as cards. The one surface: browse, search, ack, and jump. |
| Settings | The hub and pairing. Machine and session counts live on the Sessions connection line. |
| Gesture | Action |
|---|---|
| Tap a card | Expand the preview in place, to a few lines. It never marks the session seen. |
| The arrow, or swipe right, Jump | Ask the owning machine to raise the window. Only on a row that can be jumped to. |
| Swipe left, Seen | Fire seen. The row goes quiet everywhere, on every surface. built Offered only on an unread row; a quiet, already-acked row offers no no-op (the Mail rule: "Mark Read" appears only on unread mail). |
| Swipe left, Hide | Fire hide. The row leaves the main list for the collapsed Hidden section until the session speaks again, or until unhidden there. |
| Long-press built | Context menu: Pin / Unpin (see Pinned above). |
| Pull down a little | Reveal the search field. |
| Pull down further | Force a full /state resync. The escape hatch when a stream has gone strange. |
Expanding a card does not mark it seen. On the desktop, jumping is the ack because looking at the pane is seeing it. On a phone, reading a breadcrumb is not dealing with the session, so marking seen is always deliberate here.
The board offers no way to remove a session. Removal is destructive and irreversible from the phone.
A remote hub needs the network, so the app always says what it is showing. The state line under the hub name is always visible, so you can tell a stale board from a live one.
| State | Line | Meaning |
|---|---|---|
| ● Live | Connected to 192.168.1.94 · 2 machines | Stream connected. What you see is current. |
| ● Connecting | Reconnecting… | Retrying with backoff. Rows stay, drawn normally. |
| ● Offline | Offline · last seen 4m ago | Unreachable. The board is a memory and says so. |
| ● Rejected | Hub rejected this token | 401. Send them to hub settings, do not silently retry. |
Reconnect with ?since=N carrying the highest seq seen, so the hub replays
the gap. That is the primitive a phone needs across a tunnel, a lift, or a suspended app. Do not poll
/state in a loop instead.
iOS suspends the app and the stream dies with it. Reconnect on foreground with ?since=N.
Do not fight for background execution; that is what push is for, and push is a later piece of work.
Settings holds one thing: the hub you connect to. Connecting is a mode, so the screen has two faces.
{ name, url, token }. The app holds a list from day one and shows one at a
time; the nav bar hub name is the switcher, so one identity can carry several hubs (work, personal).192.168.1.94, fly-hub. Loopback
reads as Signalbox (the simulator reaching the laptop's own hub).GET /healthz before saving and
show a failure plainly.http:// works from the simulator with no ATS key: App
Transport Security already exempts loopback (a public cleartext URL fails -1022 in the
same build). NSLocalNetworkUsageDescription covers a real device reaching a hub over the
LAN, which the simulator never exercises.Run signalbox pair on the CLI or use Connect Phone from the desktop app. Each
encodes the hub URL and token into a short-lived QR code the app scans to connect. The manual address
and token fields stay for when there is no QR.
Scanning is a main-screen action:
qrcode.viewfinder button sits top-right of the Sessions nav bar, and the
Not connected empty state points at it. Settings keeps a Pair with a code entry too.
Both open the same scanner sheet.The link is the contract; the QR carries it:
signalbox://pair?url=<percent-encoded "http://LAN-IP:PORT">&code=<base64url-code>
pair rides in the URL host (a custom scheme has no path authority). url is
the hub to redeem against; code is a one-time pairing code. The phone POSTs
{ "code": <code> } to <url>/pair and gets back
{ "token": <string> }; any 4xx means the code is bad, expired, or spent. The token
goes to the Keychain and the app reconnects.
Two security invariants:
Authorization header. The URL arrives from outside
the app (a QR or a link), so the request is built by hand and ships only the one-time code, keeping
the real token off an attacker-supplied URL.onOpenURL fires for any
signalbox:// link, so opening one raises a confirmation naming the hub
(Pair with the hub at 192.168.1.20?) and, when a hub is already set, that it replaces
the current one. Only the Pair button touches the network. The embedded url is validated
as plain http/https, so file:, javascript: and
scheme-less links are rejected.The deep link is what the tests drive; the scanner is one way to deliver it. The camera cannot run
in the simulator, so the scanner guards on availability and falls back to the same instructions a
device without a camera shows. NSCameraUsageDescription and the signalbox URL
scheme live in Partial-Info.plist.
The point of the app is to be told when a session needs you. v1 ships without notifications to get the board and stream right first; the design lands in its own spec.
Shape, so v1 does not foreclose it: attention and error always notify, done only after more than thirty seconds of work (quick turnarounds are noise), keyed by session so a newer signal replaces a stale banner. The same rules as the menu bar. Web push works over a tailnet, and the hub sends outbound to the push service, so no tier of the deployment model rules it out.
acked, hidden and
naming; the app renders. A stream event triggers a /state resync, so the app never
re-derives state and drifts from the hub. Events are small and rare, so refetching is cheap.seen cannot be delivered, say so and let the user retry.