feat(daemon): multi-device remote with per-device tokens and per-connection streaming#55
Merged
Merged
Conversation
Pairing now mints a named, individually revocable bearer token per device. Devices are listable and revocable; the legacy shared [remote] token in config.toml keeps working alongside them. Store: new 0006_remote_devices migration + remote_device_pair/list/revoke/seen (mint-or-return, capped at 16 without eviction since these are credentials). Daemon: Ctx.remote_tokens std RwLock auth cache (read inside the sync handshake callback), seeded at startup and refreshed on every mutation via a single refresh_remote_tokens choke point; serve_remote drops its token arg and matches the cache, returning device identity; live revocation kicks a connection whose token vanished (-32000) and named devices stamp last_seen (throttled 60s). RPCs (local socket only, still blocked over the bridge): remote.pair/devices/ revoke. CLI: remote pair --name, remote devices, remote revoke <name>.
…itration
Move the daemon-global viewport/focus slots into per-connection ConnSession
structs so an iPhone, iPad, and the Mac TUI can each hold their own view at
once. The capture poll loop now streams the union of every live session's
viewport (Ctx::viewport_snapshot), and agent.fit arbitrates pane sizing across
sessions (fit_allowed): a Local TUI focus always wins; a remote peer wins only
while its focus beat is fresh and it drove the agent more recently than the
caller. With a single connection every observable behavior is unchanged.
- new conn module: ConnKind, ConnSession, SessionGuard
- Ctx gains sessions map + next_conn; open_session/close_session/viewport_snapshot
- dispatch takes the connection's session; viewport.set writes it; agent-driving
calls stamp last_interaction
- socket.rs (Local) and remote.rs (Remote{device}) register and release a session
on every exit path via the guard
- watched_bytes field created for task A4 (unpopulated)
…delivery
Replace the single global byte-watch slot with a refcounted map keyed by
window. tmux allows only one pipe-pane per pane, so a window has exactly one
shared pipe; the entry tracks the connection ids watching it. The first watcher
starts the pipe, later watchers join its readership, and the pipe is torn down
only when the last watcher leaves. This unblocks phone + iPad + TUI watching
concurrently: a new watch no longer kills the previous one.
- bytes_stream: watch / unwatch / unwatch_all over a HashMap<window, WatchEntry>
{ lane, fifo, refs, generation }. A globally-unique generation guards EOF
cleanup so a dying reader from a superseded pipe cannot delete a freshly
started entry (which would then accept refs and stream nothing). Pure refcount
and generation helpers are unit-tested beside the module.
- agent.watch_bytes: on:true joins/starts and records the window in the session;
on:false with a window releases just that one; on:false without a window
releases every window this session watches for the lane, matched by the
WatchEntry.lane field (the TUI stop path sends no window even for a non-default
window, so resolving a default name would orphan the real watch). The handler
never stops another session's watch. Ack shape unchanged.
- Per-connection filtering at both forwarding loops (socket + remote):
event.agent.bytes reaches only the connections watching its window; all other
topics forward as before. Sync std-Mutex read, no await added on the hot path.
- Ctx::close_session releases the connection's watches via unwatch_all.
- sess_snapshot binds each session guard to its own local so the three guards no
longer overlap across awaits.
Tests: unit refcount/generation/filter transitions; a two-connection bridge test
asserting per-window delivery plus shared non-bytes topics; a disconnect test
asserting unwatch_all releases only the closing connection's refs; and a
two-session fit arbitration test through real dispatch.
…ead watch names
Two review fixes on the refcounted byte-watch registry:
- The EOF reader unlinked its fifo unconditionally before taking the map lock,
and the fifo path was deterministic per window. A rapid unwatch then rewatch
of the same window could have the dying reader unlink the NEW pipe's fifo
just before tmux's cat opened it; cat would then create a plain regular file,
the fresh watch would silently stream nothing, and the new reader would leak.
Fix: embed the pipe's generation in the fifo filename, so pipe instances
never collide on a path and a reader can only ever unlink its own fifo. This
also covers the entry-already-replaced case with no lock-ordering subtlety:
the superseded reader's cleanup touches only its own generation's file. The
pre-mkfifo unlink stays for the one same-path case left (a leftover fifo from
a previous daemon run, since the counter restarts at zero).
- The on:false-without-window arm skipped watched names whose registry entry
had already died (EOF-cleaned), leaving them in the session's watched_bytes
forever; a later window-name reuse would then deliver bytes the session never
asked for. The arm now purges every watched name with no live registry entry,
lane-independent, since a dead entry's lane is unknowable and a dead name is
stale for every lane.
Adds handler-level coverage through real dispatch (tmux-gated): watch two
windows on one lane plus one on another, send the TUI-shaped {lane_id,
on:false} with no window, and assert only that lane's watches are released
(the non-default window included, matched by the WatchEntry.lane field), the
other lane's pipe survives, and watched_bytes reflects it including the purge
of a stale name.
event.agent.output was broadcast to every connection, so with union viewports one device's tiles burned another's bandwidth. Extend the A4 per-connection delivery decision: an output event now forwards to a session only when its lane_id is in that session's viewport lanes or its window is in the session's viewport_windows. Filtering is on lane-membership (what viewport.set literally requested), not the resolved stream-target window per lane, which can change between viewport.set calls and would let a name-resolved cache go stale and silently drop a subscribed lane. viewport.set (the single writer of the viewport fields) mirrors them into a std-Mutex output_filter snapshot the forwarding loops read without awaiting, matching the A4 hot-path rule.
The iOS companion enables the multi-device remote feature set on daemon.status version >= 0.5.0, which reports repomon_core::version() (the workspace package version). The workspace still declared 0.4.0, so every remote feature stayed dark against a current daemon. Bump to 0.5.0 and refresh the lockfile.
DaemonClient reconnects transparently and replayed only subscribe. Byte watches are per-connection on the daemon, so the Focus-view emulator's agent.watch_bytes died with the old socket and the emu froze until the user left and re-entered Focus. Track the active watch (params of the last on:true agent.watch_bytes, cleared on on:false) in a single slot next to subscribe_params, and replay it right after the subscribe replay in reconnect(). A single slot suffices: the TUI holds one live emulator at a time and always stops the previous watch before starting the next. No app-layer change is needed because the emu reads bytes from the reconnect-surviving broadcast channel; re-asserting the daemon-side watch restores the stream. Adds client-level unit tests: a reconnect replays subscribe + the active watch, and a stopped watch is not replayed.
…ane.create path) Three interleaving fixes on the remote WebSocket bridge and its dispatch. Passive revocation gap: handle_conn only re-checked the token in the request arm, so a revoked device that stayed silent kept receiving event.agent.bytes/output forever. Gate the event-forward arm on the token still being present and break the loop when it is gone. Auth-cache refresh race: refresh_remote_tokens is read-then-write and was unserialized, so a concurrent remote.pair + remote.revoke could land a stale cache that resurrects a just-revoked token. Add Ctx::remote_mutate_lock and hold it across the store mutation and the refresh in the pair/revoke handlers and the startup seed; document the race on refresh_remote_tokens. lane.create over the bridge honored a caller-supplied path. A remote caller has no fs.browse and is expected to let the daemon derive the template worktree location, so for a Remote ConnKind strip the path (warn and derive) rather than trusting it. Local callers are unaffected. Tests: a silently-revoked device stops receiving events and the socket closes; concurrent pair+revoke leave the cache mirroring the store; a remote lane.create with a path does not create at that path while a local one still does.
This was referenced Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The remote bridge grows from "one phone, one viewer" to true multi-device: a Mac TUI, an iPhone, and an iPad can be connected simultaneously, each independently viewing and driving the fleet. Companion PR: the repomon-ios iPad mission-control app.
Design spec:
docs/superpowers/specs/2026-07-17-ipad-multi-device-design.md(in this diff).Changes
remote_devicestable, migration 0006):repomon remote pair --name ipadmints a named token;remote deviceslists;remote revokekicks live connections, including silent ones, and 401s future handshakes. The legacy shared config token keeps working. Credential minting RPCs are local-socket-only.ConnSession): viewport, focus, byte watches, and last-interaction move from daemon globals to per-connection state. The capture poll loop streams the union of live viewports; single-connection behavior is provably identical to before, so existing clients are wire-compatible.event.agent.bytesandevent.agent.output. The TUI and a device can stream different panes, or the same pane, concurrently.agent.resizestays local-only.lane.createover the bridge strips caller-supplied paths (defense in depth).daemon.status.versionis the iOS feature gate.docs/protocol.mdRemote transport section rewritten.Testing
🤖 Generated with Claude Code