Plan: @gadhs/pi-remote/remote-control hands the running session to a paired phone (#142)

On this page

Status: Done (2026-09-15) — one branch, three commits; the packed distribution mints a box id through the installed wasm in consumer-sim, with and without a model

Branch: feat/pi-remote · Issue: #142 · Close step: the MR carries Closes #142; after merge one line on #142 with the merge SHA, and a note on pivot’s #66 that @gadhs/pi 0.45.0 carries pi-remote so their phone gates can run.

Erratum (2026-09-15): decision 11’s proof is not the distribution’s "pi loads every extension" check - a wire that fails to load is caught into an explanatory command, which that check cannot see - but a consumer-sim probe that runs /remote-control status against the packed distribution and expects a box id minted through the wasm; it needs no model, so it runs in the publish pipeline’s --no-model rehearsal. status mints the identity on a fresh box so onboarding can print the id before the relay knows it. @gadhs/pi states node >=22.4.0 for the global WebSocket, and the command says so on an older Node rather than retrying. settle of any outcome sends idle (see decision 8’s amended text).

Design

What this is

pivot’s box side, as a pi extension (pivot ADR-004; the design agreed in pivot #64 and this repo’s #140). In the TUI the developer types /remote-control; a QR appears; the phone scans it and from then on follows this session - text, tool cards, every ask this distribution owns - and can prompt, stop, and answer. No daemon, nothing to keep running besides pi.

Ownership by runtime: everything that runs inside pi’s process is this package. Relay, PWA, crypto and the wire protocol are pivot’s and reach us as @gadhs/pivot-wire 0.1.0 (published; installs anonymously through the @gadhs group registry this repo already uses; verified on this box: the wasm loads under pi’s node and pairingUrl/Identity/Token work).

The contract we build against (pinned; not in this tree)

pivot’s docs/modules/ROOT/pages/protocol.adoc (main, 2026-09-15) and the package README are the source. What this package relies on:

  • Transport: one wss://<relay>/ws; text frames are the relay Envelope (hello, box_auth, welcome, err, expect, vouch, unvouch, device_up, device_down); binary frames are Routed on the box side (32-byte device id ‖ 4-byte session id ‖ body). routedEncode/Decode, frameEncode/Decode from the wasm. Frames ≤ 65 535 + 36 bytes.

  • Handshake: Noise IK, device initiates, box responds (Handshake.responder(me)); message 1’s payload is the raw token when pairing, empty when reconnecting; after read(), remoteStatic() is the device’s Noise static; admit a pairing device iff Token.matches(payload), a known device iff its static is on the trusted list; then write(empty), intoSession(), Session.seal/open.

  • Control records (session 0, encrypted, JSON tagged t): ToBox introduce | open | close | refresh; ToDevice hello | paired | session_opened | session_ended | refused | waiting | idle (types/ToBox.ts, types/ToDevice.ts).

  • Session-frame rule: any session id ≠ 0 carries pi’s RPC records verbatim - box→device pi’s events, device→box prompt / abort / extension_ui_response. A broker ask goes as an extension_ui_request record (method, title, message, options, placeholder, timeout only when headless); the answer comes back as extension_ui_response (confirmed | value | cancelled).

  • Pairing as the box drives it: expect { token_hash, until ≤ now+120 }, print pairingUrl(relay, me, token); device_up (placeholder id = token hash) → responder handshake; introduce { device_id, label } → persist, vouch, paired { box_id }, unvouch placeholder. On every relay connection: box_auth, re-vouch every trusted device, re-expect an open window. forget = drop the record + unvouch.

  • pivot’s asks of us: send idle on settle(answered-locally | timeout | cancelled); hello.allowed_dirs empty; open/closerefused.

  • Loading: the package is CommonJS (wasm-bindgen nodejs); from our ESM through createRequire(import.meta.url), which jiti honours (this repo’s packages already use import.meta.url under pi).

  • pi (installed 0.85.1): pi.sendUserMessage (types.d.ts:980); ctx.abort() (:238); ctx.sessionManager.getSessionName() (session-manager.d.ts:230); events message_start/update/end, tool_execution_start/update/end, agent_start/end, turn_start/end, ui_prompt_start/end, session_shutdown { reason } (:478); global WebSocket is stable in Node ≥ 22.4 (pi ships 22.23.2), so the relay client needs no dependency.

Decisions

  1. One RemoteAnswerer per live device. The broker’s own race then gives first-phone-wins across several phones and per-device settle; a device registers on handshake completion and unregisters on device_down, socket loss or /remote-control stop - pivot’s rule 1 (registration means a live link) falls out of the structure.

  2. A virtual RPC host, not an interpreter. Extension events are forwarded as records with their own type and fields (the shapes already match pi’s RPC events); the phone’s promptpi.sendUserMessage, abortctx.abort(), extension_ui_response → the pending ask by id. Nothing else is understood; unknown records from the phone are dropped with a debug line.

  3. The pairing URL never touches the transcript. It carries the token. In the TUI it is shown in a ui.custom pane (QR + text + "waiting for the phone / Esc cancels") that closes on paired or cancel; headless it goes out as a notify. Never sendMessage, never a session entry, never a log line. The token and the identity secrets are never logged.

  4. Identity and trust on disk, under pi’s agent dir: <agentDir>/gadhs-pi-remote/identity.key (64 bytes, mode 0600, created on first /remote-control) and <agentDir>/gadhs-pi-remote/devices.json ([{ device_id, dh, label, paired_at }]). Forgetting a device removes it and unvouch`es. The box id (public) is printed by `/remote-control status for the relay’s PIVOT_BOXES allow list.

  5. Config by the ADR-004 convention: <agentDir>/gadhs-pi-remote.json { relay: "wss://…/ws", boxName?: string }, GADHS_PIVOT_RELAY overriding relay. relay must be wss:// (plain ws:// only for localhost/127.0.0.1, for a local relay in tests); a missing or invalid relay makes /remote-control say exactly what to set. No secrets in config; the relay URL is not one.

  6. Reconnect, bounded and visible: on socket loss every device is unregistered at once (the desk must not stay narrowed), then reconnect with exponential backoff 1 s → 30 s, forever while /remote-control is on; each attempt is a setStatus-free notify only on the first failure and on recovery. pi’s own timeout on a headless dialog still bounds an ask offered to a device that vanished between frames.

  7. QR rendering: uqr 0.1.3 (unjs; zero dependencies, ESM, TypeScript, ~4 M weekly downloads, released 2026-04) over qrcode-terminal (CJS, last meaningful release 2017, no types) and node-qrcode (canvas-first, larger, pulls pngjs). renderUnicodeCompact gives a half-height block QR that fits a pane. The only new dependency besides @gadhs/pivot-wire.

  8. Nudges: ui_prompt_start { kind, title }waiting to every device unless an in-flight broker ask has the same kind and title (that one has a sheet); ui_prompt_endidle. settle with any outcome → idle to that device: the winner is never settled, and with one answerer per device a phone settled answered-remotely is a losing phone whose sheet must close (pivot’s list assumed one device; the PWA already closes on idle).

  9. What the phone gets on arrival: hello { box_name, allowed_dirs: [], sessions: [{ session: 1, dir: cwd, opened_at, name }] } - this session is id 1; history replay is pivot’s #61 and out of scope here.

  10. Load order: loader 60-remote.ts after modes (10): it imports the broker contract from @gadhs/pi-modes/ask-broker (the subpath in packages/pi-modes/package.json exports). @gadhs/pi depends on @gadhs/pi-remote; nothing connects or writes until /remote-control.

  11. The wasm is loaded in the factory, not on first use. Resolving is not loading (#129): consumer-sim’s "pi loads every extension of the installed distribution" check is the one proof that @gadhs/pivot-wire’s CJS and `.wasm load from the flat npm root under pi’s jiti, and it runs at extension load. So the factory requires the wire at once; a failure there registers /remote-control as a command that says why the wire is unavailable and registers nothing else. Sockets, timers and disk writes stay lazy behind /remote-control.

Package layout (packages/pi-remote/)

  • index.ts - the factory: /remote-control [pair|stop|status|devices| forget [<id>]] with argument completion (verbs; device labels for forget, the memory-picker pattern), the event hooks, session_shutdownsession_ended to every device and stop.

  • config.ts - loadRemoteConfig(agentDir, env){ relay, boxName } or a problem string; the wss:// rule.

  • identity.ts - loadOrCreateIdentity(dir, wire) (0600, refuses a file of the wrong length), TrustedDevices load/save/forget.

  • wire.ts - the createRequire loader and a narrow Wire type over the wasm surface this package uses (so tests inject the real wasm once).

  • link.ts - the relay connection: SocketFactory injected (default the global WebSocket, binaryType = "arraybuffer"), the envelope state machine (hellobox_authwelcome → re-vouch → re-expect), Routed demux to peers, reconnect, stop().

  • peer.ts - one device: responder handshake with the pairing/known decision, Session, control in/out, session-1 in/out, the pending-ask map, the RemoteAnswerer (register on ready, unregister on close).

  • mirror.ts - pure: extension event → record; RemoteAskextension_ui_request; extension_ui_responseRemoteAnswer | undefined; nudge matching.

  • pairing.ts - token mint, expect, the pane/notify, the window’s expiry.

  • qr.ts - uqr render to lines.

Functions ≤ 40 lines; the state machines are small named handlers over a LinkState / PeerState object, the shape pi-modes uses.

Scope

Units

U1 wire + identity + config + mirror (pure, no sockets): wire.ts, identity.ts, config.ts, mirror.ts, qr.ts; contract tests against pivot’s vectors. U2 link + peer + pairing: the state machines over an in-memory fake relay and a real wasm device peer. U3 extension + docs
release
: index.ts, loader, meta dependency, ADR-004 amendment, remote.adoc, tuning.adoc, security.adoc, architecture.adoc, the changelog entry under Unreleased (CHANGELOG.adoc exceeds the review’s 80 KB per-file cap and is not among the declared files), README stub, vitest.config.ts with the bar measured and rounded down, pi-remote 0.1.0, @gadhs/pi 0.45.0. One branch, three commits.

Tests, by category

  • contract (vectors.test.ts): every deterministic vector in @gadhs/pivot-wire/vectors/vectors.json reproduced through wire.ts (identity from secrets, token hash, relay-nonce signature, frame and routed encodings, pairing URL) and the loopback fixture crossing the rekey boundary - proves our loading of the wasm, not the wasm.

  • unit (config, identity, mirror, qr): the wss:// rule and the localhost exception; env over file; a missing relay names the file and the env; identity created 0600 and reloaded byte-equal, a wrong-length file refused; devices round-trip and forget removes exactly one; every mirrored event keeps its type and fields (property: for any object with a string type, record(event) is JSON-round-trippable and equal); a RemoteAsk of each kind becomes the documented extension_ui_request (timeout only when set); each extension_ui_response shape maps to its RemoteAnswer, cancelled and an unknown id to undefined; the nudge filter suppresses a broker ask’s own prompt and passes others; the QR renders to non-empty lines of equal width.

  • wiring (link, peer, pairing, over the fake relay): connect → box_auth with a signature the device side verifies → welcome → re-vouch of every trusted device → re-expect of an open window; a pairing device (real wasm initiator, token in message 1) is admitted, introduce persists it, vouch + paired + unvouch placeholder are sent in that order, the pane closes; a wrong token gets no message 2 and an unvouch; a known device with an untrusted static is refused; after the handshake hello names this session; prompt reaches sendUserMessage, abort reaches ctx.abort, open gets refused; events become session-1 frames the device opens and parses; device_down unregisters that device’s answerer; socket close unregisters all and reconnects with backoff (fake timers); stop() sends session_ended, closes, unregisters; session_shutdown does the same.

  • wiring, the answerer: a broker ask → one extension_ui_request per device with the ask’s id; the device’s confirmed: true{kind:"confirm", value:true}; value for select/input; cancelled → decline; a response for an unknown id is dropped; signal abort removes the pending ask; settle of any outcome sends idle to that device; the winning device, never settled, gets none.

  • concurrency: two devices answering one ask - the broker takes the first, the other is settled and gets idle, exactly one RemoteAnswer reaches the ask; a device that goes down (device_down) with an ask pending - its answerer is unregistered, its signal aborted, the ask declined, the desk dialog untouched; socket loss during a handshake - the half-built peer is dropped, nothing registered, reconnect proceeds; stop() during a pending ask - every pending ask declined before the socket closes.

  • regression: with /remote-control never invoked, the factory has loaded the wire (the Wire handle exists) but no socket is opened, no timer is pending, nothing is written under the agent dir, and remoteAttached() is false; a factory whose wire fails to load registers only the explanatory command.

  • timing (fake timers): a pairing window expires at 120 s - the pane says so and a late pair is refused by the box; reconnect backoff doubles from 1 s to a 30 s ceiling; the connection notices fire on the first failure and on recovery only, not on every attempt.

  • security: the pairing URL appears in the pane’s lines and nowhere in sendMessage, appendEntry or the debug log; the identity file is 0600; no log line contains token bytes or secrets (grep the captured debug log for the hex of both).

  • live: none here by pivot’s rule 2 - the end-to-end test with the PWA and a real relay is pivot’s #66 against the published package. What this repo cannot prove is stated in remote.adoc.

Risks, by failure shape

  • fail-open: a phone answer reaching an ask it was never offered - the pending map is keyed by the ask id we minted, per device, session 1 only; an unknown id is dropped; the broker validates the value again.

  • fail-open: an untrusted device admitted - a known device is admitted only when remoteStatic() is on the trusted list; a pairing device only inside an open window with a matching token, once; both refusals send no message 2.

  • silent (narrowed desk): a dead link leaving answerers registered - unregister on device_down, socket close/error, and stop(); the regression and wiring tests assert remoteAttached() false after each.

  • hang (fail-closed): an ask offered to a device that dies mid-ask - its answerer is unregistered, which aborts its signal and declines; the desk dialog is still there and nothing is decided until a person acts; headless keeps pi’s timeout.

  • secret exposure (silent): the token in the transcript or a log - the pane-only rule and the security test; identity 0600.

  • leak (silent): sockets or timers surviving stop(), so a later session’s phone talks to a stale link - stop() clears the reconnect timer and closes the socket; tests use fake timers and assert none pending.

  • misattribution (silent): with two phones, which one answered - the broker names remote either way; the device label goes to the debug log only, never to the verdict.

  • silent (unproven load): the wasm resolving but not loading from the installed flat root under pi’s jiti - decision 11 loads it in the factory, so consumer-sim’s "pi loads every extension" check exercises it on the packed distribution before every tag.

Out of scope

History replay to a joining device (pivot #61); a phone starting a session (open is refused by design); forwarding setStatus/notify to the phone; pi-permission-system’s own prompts and third-party dialogs (nudge only, per #140); a relay of our own; TLS options beyond the system CA store.

Release

@gadhs/pi-remote 0.1.0; @gadhs/pi 0.45.0 with the new dependency and loader. Pack consumer-sim before the tag (its loader-resolution check gains the ninth loader; the rpc headless check is unchanged), registry after. ADR-004 gains an amendment section naming the seventh package and why it is here and not in pivot.

Edit this page · latest