Skip to content

feat(daemon): multi-device remote with per-device tokens and per-connection streaming#55

Merged
AliHamzaAzam merged 12 commits into
mainfrom
feat/multi-device-remote
Jul 17, 2026
Merged

feat(daemon): multi-device remote with per-device tokens and per-connection streaming#55
AliHamzaAzam merged 12 commits into
mainfrom
feat/multi-device-remote

Conversation

@AliHamzaAzam

Copy link
Copy Markdown
Owner

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

  • Per-device tokens (remote_devices table, migration 0006): repomon remote pair --name ipad mints a named token; remote devices lists; remote revoke kicks live connections, including silent ones, and 401s future handshakes. The legacy shared config token keeps working. Credential minting RPCs are local-socket-only.
  • Per-connection sessions (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.
  • Refcounted byte watches: one tmux pipe-pane per window shared by all watchers, generation-unique fifo paths (kills a stop/restart unlink race), per-connection delivery of event.agent.bytes and event.agent.output. The TUI and a device can stream different panes, or the same pane, concurrently.
  • Fit arbitration: the local TUI's fresh focus still owns pane sizing; among remote devices, the one you last typed on wins. agent.resize stays local-only.
  • Full-control allowlist: paired devices can now spawn/stop/adopt agents and create/merge/delete lanes. Still blocked: daemon lifecycle, config/secrets, host terminal and filesystem, credential minting. lane.create over the bridge strips caller-supplied paths (defense in depth).
  • Client: the TUI's RPC client re-asserts its active byte watch on reconnect (watches are per-connection now).
  • Workspace version bumped to 0.5.0: daemon.status.version is the iOS feature gate.
  • docs/protocol.md Remote transport section rewritten.

Testing

  • 350+ workspace tests incl. new unit suites (fit arbitration table, watch refcount/generation, delivery filtering, device store) and bridge integration tests (two devices with distinct tokens receiving only their windows' bytes; silent-device revocation; concurrent pair/revoke cache coherence; remote path-strip; reconnect watch replay).
  • Live end-to-end on an isolated instance (own socket/DB/config/tmux server): 8/8 scenarios passed, including concurrent TUI + device streaming with correct isolation, revocation kick, and legacy-token auth. Real fleet untouched.
  • Adversarial multi-agent review: every workstream task-reviewed, then two whole-branch reviews; all Critical/Important findings fixed and re-verified.

🤖 Generated with Claude Code

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.
Copilot AI review requested due to automatic review settings July 17, 2026 03:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AliHamzaAzam
AliHamzaAzam merged commit 79f41d2 into main Jul 17, 2026
0 of 2 checks passed
@AliHamzaAzam
AliHamzaAzam deleted the feat/multi-device-remote branch July 17, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants