Skip to content

Spec: multi-machine fleet (pair several computers, sidebar list, per-machine status) #1

Description

@Jhazy33

Spec: multi-machine fleet — pair several computers, sidebar list, per-machine status

Problem Statement

pew2 pairs with exactly one machine. A user with two computers (a MacBook Pro and a Mac mini, say) must unpair and re-pair to switch, losing cached provider lists and per-machine context each time, and can never see at a glance which machine has an agent running. Claude's mobile app already models this: a session list grouped by source, per-source status dots, per-source notifications.

Solution

The app pairs with several machines and keeps a fleet. The hamburger sidebar gains a Machines section: every paired machine as a row with a name, a status dot, and a busy count. An online machine expands to its CLI list and drives the screen when active; an offline machine is greyed but tappable into an info sheet (last seen, last error, retry) — never inert. Machine names come from the hostname by default and are renameable in-app and via pew2 pair --name. Notifications are titled per machine and route to the right machine and conversation on tap. Unpairing rotates the machine's pairing token when reachable and says exactly what it could not do when not.

All machines stay connected while the app is foregrounded; repeatedly-failing background machines park into a slow probe instead of hammering the relay.

User Stories

  1. As a user with two computers, I want to pair both without losing the first, so that I never re-scan a QR just to switch machines.
  2. As a user, I want to re-scan the same machine's QR and have it update in place, so that a duplicate scan cannot create two rows for one machine.
  3. As a user, I want every paired machine listed in the sidebar with a status dot, so that I can see the fleet at a glance.
  4. As a user, I want the machine running an agent to show a pulsing green dot with a busy count, so that I know work is in flight without opening the conversation.
  5. As a user, I want offline machines greyed out, so that the list never lies about reachability.
  6. As a user, I want to tap an offline machine and get an info sheet (last seen, last error, retry), so that "offline" is always actionable rather than a dead row.
  7. As a user, I want an all-offline or zero-machine state that explains itself and offers retry/add, so that a fresh or network-less install never shows an empty void.
  8. As a user, I want to tap an online machine to make it active, so that its conversations, projects, and composer take over the screen.
  9. As a user, I want the conversation list to switch with the active machine, so that I am never looking at one machine's chats over another machine's agent.
  10. As a user, I want a distinct stale/reconnecting indicator on the active machine until a fresh status arrives, so that a foregrounded resume never looks confidently wrong.
  11. As a user, I want the composer to go read-only with a banner when the active machine drops mid-conversation, so that I understand why I cannot send (and queued drafts still deliver on reconnect).
  12. As a user, I want to rename a machine in-app, so that "Mac-mini.local" can become "Mac mini".
  13. As a user renaming a machine, I want invalid names (empty, whitespace-only, over 64 chars, control/bidi characters) rejected inline with a clear message, so that the name field cannot poison announcements or notification titles.
  14. As a user, I want a rename round-trip to show pending and failed states, so that I know whether the machine accepted the new name.
  15. As a user, I want the machine's hostname as the default name, so that I do not have to type anything.
  16. As a user, I want to unpair a machine such that the machine actually stops accepting my key (token rotation) when it is reachable, so that unpair is a real revocation.
  17. As a user unpairing an offline machine, I want an explicit warning that the machine will still accept the key until I re-pair at the desk, so that local-only removal is never silent.
  18. As a user, I want unpairing the active machine to promote the most-recently-seen online machine, so that the screen stays useful.
  19. As a user, I want unpairing my last machine to land me on the pairing screen, so that the app state is always coherent.
  20. As a user, I want exactly one unpair affordance per machine (not a global "Forget" beside it), so that I cannot destroy the wrong thing by accident.
  21. As a user, I want notifications titled "[Machine] Claude finished…", so that I know which computer is talking to me from the lock screen.
  22. As a user, I want tapping a notification to open that machine and that conversation, so that routing never lands me in the wrong chat.
  23. As a user tapping a notification from an older daemon (no machine id), I want resolution by session id across machines and, failing that, the machine list — never a wrong conversation.
  24. As a user, I want near-simultaneous notifications about the same event from different machines collapsed into one, so that a burst does not spam me.
  25. As a user, I want a newly paired machine to notify me immediately (its push token registered on pairing), so that machine two behaves like machine one from minute zero.
  26. As a user on mobile data, I want offline background machines probed slowly (60–120s) rather than every ~10 seconds, so that my battery and data are not spent hammering a dead endpoint.
  27. As a user, I want a machine's return noticed instantly when I open the drawer or foreground the app, so that parking never costs freshness when I am actually looking.
  28. As a user, I want each machine's CLI list to show icon, name, live status light, busy count or unavailable reason, so that the sheet answers "what's running?" at a glance.
  29. As a user, I want "Add a machine" in the sidebar to reuse the pairing flow in add-mode, so that adding machine two feels exactly like pairing machine one.
  30. As a user starting a new chat with multiple machines paired, I want CLI choices labelled "CLI · machine", so that I never start a conversation on the wrong computer.
  31. As a user with reduced motion enabled, I want status conveyed by static dots and spoken labels, so that nothing depends on animation to be understood.
  32. As a screen-reader user, I want every status dot to carry a spoken label naming its state in words, so that status is never colour/motion-only.
  33. As a user whose keychain is locked at launch, I want migration to retry rather than half-complete, so that a locked keychain can never eat my legacy pairing.
  34. As a user of an old app version, I want a new daemon to keep working with me (new wire fields absent = older peer), so that an App Store update lag never breaks my setup.
  35. As a user of an old daemon, I want the new app to never send frames the daemon cannot parse into paths that clear my requests or kill push registration, so that the daemon's update cadence never breaks my phone.

Implementation Decisions

  • Local identity: a machine's local id is the relay room id derived from the pairing link's root key, using the shared protocol crypto already exported to both sides. Announced machine { id, name } from ProviderAnnounce is display metadata attached after first connect; hostname from the forwarded daemon hello is the next fallback; host:port the last. (ADR 0001)
  • Storage: one SecureStore entry per machine holding only the link with its key fragment; an index of local ids; all mutable metadata (rename, lastSeenAt, cached providers) in plain async storage keyed by local id. Migration from the legacy single entry uses verified-write semantics: null/locked reads never complete migration; the legacy key dies only after the new entry re-reads back. Adding a machine dedupes by local id, updating in place. (ADR 0002)
  • Wire additions (protocol + daemon only, reusing existing sealed-frame plumbing):
    • SetName — sealed, accepted only from a proven paired app sender, rate-limited, idempotent; renames the machine, persisted daemon-side, re-announced on change.
    • Unpair request — sealed rotate/forget; the daemon rotates the pairing token (existing rotatePairing path), matching the codebase's only revocation mechanism. (ADR 0005)
    • pew2 pair [--name] — defaults to os.hostname(), persisted, re-announced.
    • Name validation at both trust boundaries (daemon CLI/frame and app inline): max 64 chars, strip control and bidi-override characters, reject empty/whitespace-only — before the value reaches announcements, notification titles, or UI.
    • Version-skew gating: the app sends any new frame type only after receiving machine metadata from that daemon, so an old daemon's error unknown_message never trips the app's clear-requests/push-kill reaction.
  • Connection manager: mechanical extraction of a single-machine connection module from the app's daemon hook (no behaviour change), then a machines registry on top: active machine drives full state; background machines expose a light slice (status, fatal, providers, machine metadata, busy count, pending permissions, lastSeenAt). Per-machine backoff; background machines park into a 60–120s slow probe plus event-driven probes (drawer open, foreground); foreground reconnect staggers active-first. (ADR 0003)
  • UI: sidebar Machines section (top, collapsed to active-machine summary, expandable); new machine sheet component (editable name with validation and round-trip states, status line, this machine's CLI list, footer actions: use / rename / unpair); add-mode reuse of the pairing screen with immediate push registration on success; rescoped footer (the single-machine "Forget" gives way to per-machine unpair); CLI·machine labels in new-chat when >1 machine paired. All fleet UI iOS-first. (ADR 0009)
  • Status lights: a dot component with green pulsing (busy), green solid (idle/available), amber (connecting/stale or permission pending), grey (offline/unavailable), red (fatal); Reanimated pulse; static + spoken-label path under reduced motion; accessibilityRole/accessibilityLabel always present.
  • Notifications: per-machine titles; tap routing by machine id → machine + conversation, falling back to cross-machine session-id resolution, else machine list. Push token registered with every daemon on every successful pairing and on token rotation. Dedupe reasons across machines inside the existing pure notification policy. (ADRs 0006, 0007)
  • Catch-up cap: wire-level per-session event limit beside the hello cursors (absent = full replay for old daemons), exact shape gated on the upstream design issue; old-daemon fallback is always full replay. (ADR 0004)

Testing Decisions

  • Good tests assert external behaviour through pure seams — table-driven, arrange-act-assert, injected clocks/dependencies, no shared mutable fixtures — following the existing pure-module suites (pairingLink, cursors, notificationPolicy, hapticsPolicy).
  • Pure logic first: local-id derivation/dedupe/migration state machine; name validation edges (empty, whitespace, 64+, control, bidi); slow-probe parking transitions with an injected clock; status rollup (machine row + CLI row states); notification routing (with/without machine id, offline machine, cross-machine dedupe); catch-up cap cursor shaping.
  • Adversarial security tests per repo convention (channel.test.ts shape): SetName from an unproven sender refused; rate limit enforced; replay refused; unpair rotation actually revokes the old token.
  • UI via harness: machine sheet, status dot (incl. reduced-motion + accessibility labels), sidebar machines section — colocated .harness.tsx suites.
  • Parity: the connection-module extraction is proven by the existing daemon-hook suite staying green, untouched.
  • Cross-cutting: skew both directions (new app → old daemon must not trip unknown_message/push-kill; old app → new daemon); interrupted migration and locked-keychain migration; SecureStore write failure at fleet size ~8; re-pair same machine; unpair succession rules; active-machine drop mid-conversation.

Out of Scope

  • Cloud sessions (anything not running on the user's own machines).
  • Relay functional changes (routing, storage, admission) — none needed.
  • Opaque relay device ids (the daemon's hostname-as-deviceId status quo stays; noted as an optional follow-up).
  • Byte counters / data-usage UI. (Deferred per plan §3.G.)
  • Android-first-class fleet support (iOS-first; Android must not regress at single-machine level). (ADR 0009)
  • Measured battery accounting. (ADR 0010)
  • Server-side push suppression while the phone socket is live. (ADR 0011)

Further Notes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions