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 relayEnvelope(hello,box_auth,welcome,err,expect,vouch,unvouch,device_up,device_down); binary frames areRoutedon the box side (32-byte device id ‖ 4-byte session id ‖ body).routedEncode/Decode,frameEncode/Decodefrom 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; afterread(),remoteStatic()is the device’s Noise static; admit a pairing device iffToken.matches(payload), a known device iff its static is on the trusted list; thenwrite(empty),intoSession(),Session.seal/open. -
Control records (session 0, encrypted, JSON tagged
t):ToBoxintroduce | open | close | refresh;ToDevicehello | 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 anextension_ui_requestrecord (method,title,message,options,placeholder,timeoutonly when headless); the answer comes back asextension_ui_response(confirmed|value|cancelled). -
Pairing as the box drives it:
expect { token_hash, until ≤ now+120 }, printpairingUrl(relay, me, token);device_up(placeholder id = token hash) → responder handshake;introduce { device_id, label }→ persist,vouch,paired { box_id },unvouchplaceholder. On every relay connection:box_auth, re-vouchevery trusted device, re-expectan open window.forget= drop the record +unvouch. -
pivot’s asks of us: send
idleonsettle(answered-locally | timeout | cancelled);hello.allowed_dirsempty;open/close→refused. -
Loading: the package is CommonJS (wasm-bindgen
nodejs); from our ESM throughcreateRequire(import.meta.url), which jiti honours (this repo’s packages already useimport.meta.urlunder pi). -
pi (installed 0.85.1):
pi.sendUserMessage(types.d.ts:980);ctx.abort()(:238);ctx.sessionManager.getSessionName()(session-manager.d.ts:230); eventsmessage_start/update/end,tool_execution_start/update/end,agent_start/end,turn_start/end,ui_prompt_start/end,session_shutdown { reason }(:478); globalWebSocketis stable in Node ≥ 22.4 (pi ships 22.23.2), so the relay client needs no dependency.
Decisions
-
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 ondevice_down, socket loss or/remote-control stop- pivot’s rule 1 (registration means a live link) falls out of the structure. -
A virtual RPC host, not an interpreter. Extension events are forwarded as records with their own
typeand fields (the shapes already match pi’s RPC events); the phone’sprompt→pi.sendUserMessage,abort→ctx.abort(),extension_ui_response→ the pending ask by id. Nothing else is understood; unknown records from the phone are dropped with a debug line. -
The pairing URL never touches the transcript. It carries the token. In the TUI it is shown in a
ui.custompane (QR + text + "waiting for the phone / Esc cancels") that closes onpairedor cancel; headless it goes out as anotify. NeversendMessage, never a session entry, never a log line. The token and the identity secrets are never logged. -
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 andunvouch`es. The box id (public) is printed by `/remote-control statusfor the relay’sPIVOT_BOXESallow list. -
Config by the ADR-004 convention:
<agentDir>/gadhs-pi-remote.json{ relay: "wss://…/ws", boxName?: string },GADHS_PIVOT_RELAYoverridingrelay.relaymust bewss://(plainws://only forlocalhost/127.0.0.1, for a local relay in tests); a missing or invalid relay makes/remote-controlsay exactly what to set. No secrets in config; the relay URL is not one. -
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-controlis on; each attempt is asetStatus-freenotifyonly 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. -
QR rendering:
uqr0.1.3 (unjs; zero dependencies, ESM, TypeScript, ~4 M weekly downloads, released 2026-04) overqrcode-terminal(CJS, last meaningful release 2017, no types) andnode-qrcode(canvas-first, larger, pullspngjs).renderUnicodeCompactgives a half-height block QR that fits a pane. The only new dependency besides@gadhs/pivot-wire. -
Nudges:
ui_prompt_start { kind, title }→waitingto every device unless an in-flight broker ask has the samekindandtitle(that one has a sheet);ui_prompt_end→idle.settlewith any outcome →idleto that device: the winner is never settled, and with one answerer per device a phone settledanswered-remotelyis a losing phone whose sheet must close (pivot’s list assumed one device; the PWA already closes onidle). -
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. -
Load order: loader
60-remote.tsafter modes (10): it imports the broker contract from@gadhs/pi-modes/ask-broker(the subpath inpackages/pi-modes/package.jsonexports).@gadhs/pidepends on@gadhs/pi-remote; nothing connects or writes until/remote-control. -
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 `.wasmload 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-controlas 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 forforget, the memory-picker pattern), the event hooks,session_shutdown→session_endedto every device and stop. -
config.ts-loadRemoteConfig(agentDir, env)→{ relay, boxName }or a problem string; thewss://rule. -
identity.ts-loadOrCreateIdentity(dir, wire)(0600, refuses a file of the wrong length),TrustedDevicesload/save/forget. -
wire.ts- thecreateRequireloader and a narrowWiretype over the wasm surface this package uses (so tests inject the real wasm once). -
link.ts- the relay connection:SocketFactoryinjected (default the globalWebSocket,binaryType = "arraybuffer"), the envelope state machine (hello→box_auth→welcome→ re-vouch→ re-expect),Routeddemux 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, theRemoteAnswerer(register on ready, unregister on close). -
mirror.ts- pure: extension event → record;RemoteAsk→extension_ui_request;extension_ui_response→RemoteAnswer | undefined; nudge matching. -
pairing.ts- token mint,expect, the pane/notify, the window’s expiry. -
qr.ts-uqrrender 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.jsonreproduced throughwire.ts(identity from secrets, token hash, relay-nonce signature, frame and routed encodings, pairing URL) and theloopbackfixture crossing the rekey boundary - proves our loading of the wasm, not the wasm. -
unit (
config,identity,mirror,qr): thewss://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 andforgetremoves exactly one; every mirrored event keeps itstypeand fields (property: for any object with a stringtype,record(event)is JSON-round-trippable and equal); aRemoteAskof each kind becomes the documentedextension_ui_request(timeout only when set); eachextension_ui_responseshape maps to itsRemoteAnswer,cancelledand an unknown id toundefined; 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_authwith a signature the device side verifies →welcome→ re-vouchof every trusted device → re-expectof an open window; a pairing device (real wasm initiator, token in message 1) is admitted,introducepersists it,vouch+paired+unvouchplaceholder are sent in that order, the pane closes; a wrong token gets no message 2 and anunvouch; a known device with an untrusted static is refused; after the handshakehellonames this session;promptreachessendUserMessage,abortreachesctx.abort,opengetsrefused; events become session-1 frames the device opens and parses;device_downunregisters that device’s answerer; socket close unregisters all and reconnects with backoff (fake timers);stop()sendssession_ended, closes, unregisters;session_shutdowndoes the same. -
wiring, the answerer: a broker ask → one
extension_ui_requestper device with the ask’s id; the device’sconfirmed: true→{kind:"confirm", value:true};valuefor select/input;cancelled→ decline; a response for an unknown id is dropped;signalabort removes the pending ask;settleof any outcome sendsidleto 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 oneRemoteAnswerreaches 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-controlnever invoked, the factory has loaded the wire (theWirehandle exists) but no socket is opened, no timer is pending, nothing is written under the agent dir, andremoteAttached()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
pairis 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,appendEntryor 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, socketclose/error, andstop(); the regression and wiring tests assertremoteAttached()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
remoteeither 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.