remote-pi 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +160 -40
  2. package/dist/bin/supervisord.d.ts +2 -0
  3. package/dist/bin/supervisord.js +44 -0
  4. package/dist/bin/supervisord.js.map +1 -0
  5. package/dist/config.d.ts +44 -13
  6. package/dist/config.js +61 -22
  7. package/dist/config.js.map +1 -1
  8. package/dist/daemon/client.d.ts +20 -0
  9. package/dist/daemon/client.js +128 -0
  10. package/dist/daemon/client.js.map +1 -0
  11. package/dist/daemon/control_protocol.d.ts +100 -0
  12. package/dist/daemon/control_protocol.js +63 -0
  13. package/dist/daemon/control_protocol.js.map +1 -0
  14. package/dist/daemon/id.d.ts +18 -0
  15. package/dist/daemon/id.js +30 -0
  16. package/dist/daemon/id.js.map +1 -0
  17. package/dist/daemon/install.d.ts +132 -0
  18. package/dist/daemon/install.js +312 -0
  19. package/dist/daemon/install.js.map +1 -0
  20. package/dist/daemon/registry.d.ts +47 -0
  21. package/dist/daemon/registry.js +123 -0
  22. package/dist/daemon/registry.js.map +1 -0
  23. package/dist/daemon/rpc_child.d.ts +76 -0
  24. package/dist/daemon/rpc_child.js +130 -0
  25. package/dist/daemon/rpc_child.js.map +1 -0
  26. package/dist/daemon/supervisor.d.ts +38 -0
  27. package/dist/daemon/supervisor.js +301 -0
  28. package/dist/daemon/supervisor.js.map +1 -0
  29. package/dist/index.d.ts +62 -8
  30. package/dist/index.js +1231 -303
  31. package/dist/index.js.map +1 -1
  32. package/dist/mesh/canonical.d.ts +30 -0
  33. package/dist/mesh/canonical.js +61 -0
  34. package/dist/mesh/canonical.js.map +1 -0
  35. package/dist/mesh/client.d.ts +31 -0
  36. package/dist/mesh/client.js +56 -0
  37. package/dist/mesh/client.js.map +1 -0
  38. package/dist/mesh/encoding.d.ts +36 -0
  39. package/dist/mesh/encoding.js +53 -0
  40. package/dist/mesh/encoding.js.map +1 -0
  41. package/dist/mesh/self_revoke.d.ts +111 -0
  42. package/dist/mesh/self_revoke.js +182 -0
  43. package/dist/mesh/self_revoke.js.map +1 -0
  44. package/dist/mesh/siblings.d.ts +62 -0
  45. package/dist/mesh/siblings.js +95 -0
  46. package/dist/mesh/siblings.js.map +1 -0
  47. package/dist/mesh/types.d.ts +34 -0
  48. package/dist/mesh/types.js +11 -0
  49. package/dist/mesh/types.js.map +1 -0
  50. package/dist/mesh/verify.d.ts +17 -0
  51. package/dist/mesh/verify.js +77 -0
  52. package/dist/mesh/verify.js.map +1 -0
  53. package/dist/pairing/qr.d.ts +16 -5
  54. package/dist/pairing/qr.js +27 -8
  55. package/dist/pairing/qr.js.map +1 -1
  56. package/dist/pairing/storage.d.ts +41 -0
  57. package/dist/pairing/storage.js +160 -21
  58. package/dist/pairing/storage.js.map +1 -1
  59. package/dist/protocol/types.d.ts +23 -0
  60. package/dist/session/broker.d.ts +74 -0
  61. package/dist/session/broker.js +142 -4
  62. package/dist/session/broker.js.map +1 -1
  63. package/dist/session/broker_remote.d.ts +110 -0
  64. package/dist/session/broker_remote.js +397 -0
  65. package/dist/session/broker_remote.js.map +1 -0
  66. package/dist/session/cwd_lock.d.ts +28 -0
  67. package/dist/session/cwd_lock.js +89 -0
  68. package/dist/session/cwd_lock.js.map +1 -0
  69. package/dist/session/global_config.d.ts +9 -0
  70. package/dist/session/global_config.js +9 -0
  71. package/dist/session/global_config.js.map +1 -1
  72. package/dist/session/leader_election.d.ts +16 -0
  73. package/dist/session/leader_election.js +22 -0
  74. package/dist/session/leader_election.js.map +1 -1
  75. package/dist/session/local_config.d.ts +12 -5
  76. package/dist/session/local_config.js +24 -3
  77. package/dist/session/local_config.js.map +1 -1
  78. package/dist/session/peer.d.ts +28 -1
  79. package/dist/session/peer.js +69 -2
  80. package/dist/session/peer.js.map +1 -1
  81. package/dist/session/peer_inventory.d.ts +13 -0
  82. package/dist/session/peer_inventory.js +48 -0
  83. package/dist/session/peer_inventory.js.map +1 -0
  84. package/dist/session/setup_wizard.d.ts +32 -8
  85. package/dist/session/setup_wizard.js +45 -33
  86. package/dist/session/setup_wizard.js.map +1 -1
  87. package/dist/session/tools.d.ts +15 -7
  88. package/dist/session/tools.js +139 -31
  89. package/dist/session/tools.js.map +1 -1
  90. package/dist/transport/pi_forward_client.d.ts +29 -0
  91. package/dist/transport/pi_forward_client.js +62 -0
  92. package/dist/transport/pi_forward_client.js.map +1 -0
  93. package/dist/ui/footer.js +8 -6
  94. package/dist/ui/footer.js.map +1 -1
  95. package/docs/daemon.md +289 -0
  96. package/package.json +8 -2
  97. package/service-templates/launchd.plist.template +35 -0
  98. package/service-templates/systemd.service.template +19 -0
  99. package/skills/agent-network/SKILL.md +273 -294
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  /**
2
3
  * pi-extension — remote-pi slash commands + AgentBridge wiring
3
4
  *
@@ -29,27 +30,49 @@
29
30
  * for integration tests.
30
31
  */
31
32
  import { randomUUID } from "node:crypto";
32
- import { buildQRUri, displayQR, qrSession, startQRRotation } from "./pairing/qr.js";
33
- import { addPeer, getOrCreateEd25519Keypair, listPeers, removePeer, } from "./pairing/storage.js";
33
+ import { buildQRUri, qrSession, renderQRAscii, startQRRotation } from "./pairing/qr.js";
34
+ import { addPeer, getOrCreateEd25519Keypair, listOwnerPubkeys, listPeers, removePeer, } from "./pairing/storage.js";
35
+ import { MeshClient } from "./mesh/client.js";
36
+ import { SelfRevoke } from "./mesh/self_revoke.js";
34
37
  import { RelayClient, RoomAlreadyOpenError } from "./transport/relay_client.js";
35
38
  import { PlainPeerChannel } from "./transport/peer_channel.js";
36
39
  import { roomIdForCwd } from "./rooms.js";
37
40
  import { SessionPeer } from "./session/peer.js";
38
41
  import { registerAgentTools } from "./session/tools.js";
39
- import { ensureGlobalDirs, listSessions, sessionAuditPath, sessionHasSock, sessionSockPath, skillsDir, } from "./session/global_config.js";
42
+ import { BrokerRemote } from "./session/broker_remote.js";
43
+ import { formatPeerInventory } from "./session/peer_inventory.js";
44
+ import { PiForwardClient } from "./transport/pi_forward_client.js";
45
+ import { discoverSelfLabel, discoverSiblings, fallbackLabel } from "./mesh/siblings.js";
46
+ import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionAuditPath, sessionSockPath, skillsDir, } from "./session/global_config.js";
47
+ import { acquireCwdLock } from "./session/cwd_lock.js";
48
+ import { addDaemon, listDaemons, removeDaemon } from "./daemon/registry.js";
49
+ import { callSupervisor, supervisorOnline, SupervisorOfflineError } from "./daemon/client.js";
50
+ import { installService, uninstallService, linkCliBinaries, unlinkCliBinaries } from "./daemon/install.js";
40
51
  import { defaultAgentName, effectiveAutoStartRelay, loadLocalConfig, localConfigExists, saveLocalConfig, } from "./session/local_config.js";
41
52
  import { runSetupWizard } from "./session/setup_wizard.js";
42
53
  import { updateFooter } from "./ui/footer.js";
43
54
  import { join } from "node:path";
44
55
  import { fileURLToPath } from "node:url";
45
- import { mkdirSync, copyFileSync, existsSync, unlinkSync } from "node:fs";
46
- import { kDefaultRelayUrl, resolveRelayUrl, saveConfig, isValidRelayUrl, normalizeRelayUrl, } from "./config.js";
56
+ import { mkdirSync, copyFileSync, existsSync, unlinkSync, readFileSync } from "node:fs";
57
+ import { hostname } from "node:os";
58
+ import { kDefaultRelayUrl, resolveRelayUrl, saveConfig, isValidRelayUrl, isWebSocketScheme, toWebSocketUrl, } from "./config.js";
47
59
  let _state = "idle";
48
60
  let _relay = null;
49
61
  let _relayUrl = null; // URL used by current _relay connection
50
- let _peerChannel = null;
51
- let _appPeerId = null; // active app peer ID (Ed25519 pk base64 std)
52
- let _peerShort = "";
62
+ /**
63
+ * Owners currently connected via the relay. Key = app peer pubkey (Ed25519,
64
+ * base64 standard); value = the dedicated PlainPeerChannel routing messages
65
+ * to/from that owner.
66
+ *
67
+ * Operational notes:
68
+ * - Adding/removing entries is exclusively in `_attachPeerChannel` and
69
+ * `_detachPeerChannel` (or `_goIdle` for the bulk teardown). Don't mutate
70
+ * directly elsewhere — those helpers keep the footer/log/state in sync.
71
+ * - `paired` UX state is `_activePeers.size > 0`. The footer and the
72
+ * `/remote-pi status` output both derive from this.
73
+ */
74
+ const _activePeers = new Map();
75
+ let _peerShort = ""; // shortid of the most recently attached peer (UX hint only)
53
76
  let _myRoomId = null; // this Pi's room id (derived from cwd)
54
77
  let _myRoomMeta = null;
55
78
  let _currentModel = undefined; // last-known model name
@@ -57,6 +80,11 @@ let _currentModel = undefined; // last-known model name
57
80
  let _sessionPeer = null;
58
81
  let _sessionName = null;
59
82
  let _sessionPeerCount = 0;
83
+ // Plan/25 Wave B/C — cross-PC routing. Instantiated when this Pi has the
84
+ // local broker (leader) AND the relay WS is up. Detached on either side
85
+ // of those preconditions falling away.
86
+ let _brokerRemote = null;
87
+ let _piForwardClient = null;
60
88
  // Cached state of global pairings (`peers.json`). Pairing is per-machine, so a
61
89
  // device paired in any Pi process is paired everywhere. Refreshed on boot,
62
90
  // after addPeer (handle_pair_request), and after removePeer (revoke).
@@ -90,6 +118,78 @@ function _refreshSessionPeerCount(peer, ctx) {
90
118
  function _currentModelName() {
91
119
  return _currentModel;
92
120
  }
121
+ // ── Cross-PC mesh wiring (plan/25 Wave B/C) ───────────────────────────────────
122
+ /**
123
+ * Bring up the cross-PC router when both prerequisites are met:
124
+ * 1. local session peer is the leader (we host the Broker)
125
+ * 2. relay WS is up (`_relay !== null`)
126
+ *
127
+ * Idempotent: safe to call from `_cmdStart`, `_cmdJoin`, post-failover
128
+ * reconnect, etc. Self-heals: if siblings can't be loaded (no mesh blob
129
+ * yet, network hiccup) we still attach a `BrokerRemote` with an empty
130
+ * sibling set — push from a remote sibling will populate the cache later.
131
+ */
132
+ async function _ensureBrokerRemote() {
133
+ if (_brokerRemote !== null)
134
+ return;
135
+ if (!_sessionPeer || _sessionPeer.currentRole() !== "leader")
136
+ return;
137
+ const broker = _sessionPeer.localBroker();
138
+ if (!broker)
139
+ return;
140
+ if (!_relay || !_relayUrl || !_cachedEd25519)
141
+ return;
142
+ const pi = new PiForwardClient(_relay);
143
+ _piForwardClient = pi;
144
+ const selfPubkeyB64 = Buffer.from(_cachedEd25519.publicKey).toString("base64");
145
+ // Best-effort sibling + label discovery — failures are non-fatal.
146
+ let selfPcLabel = fallbackLabel(selfPubkeyB64);
147
+ let siblings = [];
148
+ try {
149
+ const meshClient = new MeshClient(_relayUrl);
150
+ const owners = await listOwnerPubkeys();
151
+ if (owners.length > 0) {
152
+ // Pass a silent `log` so per-Owner fetch failures (relay 404 before
153
+ // any Owner published, transient HTTP errors, etc.) don't leak as
154
+ // stderr into the TUI chat panel.
155
+ const silentDiscoverLog = { warn: (_m) => { } };
156
+ const [labelRes, sibs] = await Promise.all([
157
+ discoverSelfLabel({ client: meshClient, ownerEpks: owners, myPubkey: _cachedEd25519.publicKey, log: silentDiscoverLog }),
158
+ discoverSiblings({ client: meshClient, ownerEpks: owners, myPubkey: _cachedEd25519.publicKey, log: silentDiscoverLog }),
159
+ ]);
160
+ selfPcLabel = labelRes.selfPcLabel;
161
+ siblings = sibs;
162
+ }
163
+ }
164
+ catch (err) {
165
+ // Bootstrap is best-effort — siblings populate later via SelfRevoke's
166
+ // onMembersChanged callback. Silent fail (no console output) avoids
167
+ // leaking diagnostic noise into the Pi TUI's chat panel.
168
+ void err;
169
+ }
170
+ // Silent logger injected into the mesh helpers below so their
171
+ // diagnostic warnings (sibling fetch failures, anti-spoof drops, etc.)
172
+ // don't bleed into the Pi TUI chat panel as raw stderr lines. The
173
+ // user-facing events (revoke, peer joined/left) flow through proper
174
+ // channels — `onRevoke` → `pi.sendMessage`, broker peer events →
175
+ // footer state. Daemon mode (supervisor-managed) ignores these
176
+ // entirely; we instead lean on `audit.jsonl` for postmortem.
177
+ const silentLog = (_msg) => { };
178
+ _brokerRemote = new BrokerRemote({
179
+ broker,
180
+ pi,
181
+ selfPcLabel,
182
+ selfPcPubkey: selfPubkeyB64,
183
+ siblings,
184
+ log: silentLog,
185
+ });
186
+ }
187
+ function _teardownBrokerRemote() {
188
+ _brokerRemote?.detach();
189
+ _brokerRemote = null;
190
+ _piForwardClient?.detach();
191
+ _piForwardClient = null;
192
+ }
93
193
  /** Refreshes the Pi TUI footer slots from current module state. Safe no-op when ctx lacks ui. */
94
194
  function _refreshFooter(ctx) {
95
195
  const target = ctx ?? _lastCtx;
@@ -100,7 +200,10 @@ function _refreshFooter(ctx) {
100
200
  session: _sessionName ?? undefined,
101
201
  peerCount: _sessionPeerCount,
102
202
  relayOn: _state !== "idle",
103
- devicePaired: _state === "paired" ? _peerShort : undefined,
203
+ // `devicePaired` now reflects "any owner currently attached" picks one
204
+ // shortid representatively (multi-owner UX detail surfaces in the
205
+ // `/remote-pi status` line, not the footer slot).
206
+ devicePaired: _anyPeerActive() ? _peerShort : undefined,
104
207
  hasPairings: _hasGlobalPairings,
105
208
  agentName: _sessionPeer?.name(),
106
209
  };
@@ -112,6 +215,34 @@ function _refreshFooter(ctx) {
112
215
  let _sessionStartedAt = null;
113
216
  let _messageBuffer = [];
114
217
  /** Test-only override of the message buffer. */
218
+ /**
219
+ * Test-only: emulate what `/remote-pi` does on the returning-user path
220
+ * (join the local mesh, then start the relay) without touching the FS for
221
+ * a `localConfigExists()` lookup. Lets tests bring the relay up without
222
+ * mocking the wizard or the local config storage.
223
+ *
224
+ * Typed loosely to accept any ctx shape with `ui.notify` + `cwd` — the
225
+ * unit tests use minimal mocks that don't satisfy the full
226
+ * `ExtensionContext` interface.
227
+ */
228
+ export async function _connectForTest(ctx) {
229
+ const real = ctx;
230
+ await _cmdJoin(real);
231
+ await _cmdStart(real);
232
+ }
233
+ /** Test-only: tear everything down (mirrors `/remote-pi stop`). */
234
+ export async function _stopForTest(ctx) {
235
+ await _cmdStop(ctx);
236
+ }
237
+ /**
238
+ * Test-only: relay-only startup, no UDS mesh join. Replaces the old
239
+ * `remote-pi relay start` handler that some tests captured to bring up
240
+ * the relay in isolation (e.g. ping/pong tests that don't care about the
241
+ * agent-network broker).
242
+ */
243
+ export async function _startRelayForTest(ctx) {
244
+ await _cmdStart(ctx);
245
+ }
115
246
  export function _setMessageBufferForTest(msgs) {
116
247
  _messageBuffer = msgs;
117
248
  }
@@ -134,6 +265,15 @@ let _pi = null;
134
265
  let _stopAutoListener = null;
135
266
  // Cached keypair (loaded once, reused across start/pair cycles)
136
267
  let _cachedEd25519 = null;
268
+ // Mesh-membership poller (plan/24 Wave 3). Lives across the relay
269
+ // connection lifecycle: started in _cmdStart after the WS is up, stopped
270
+ // in _goIdle when the relay is torn down.
271
+ let _selfRevoke = null;
272
+ // Per-cwd lock acquired by the first `/remote-pi` invocation in this
273
+ // process. Holds the UDS socket open until the process exits (OS auto-
274
+ // releases on crash too). Stays held across `/remote-pi stop` cycles —
275
+ // only released when the Node process itself dies.
276
+ let _cwdLock = null;
137
277
  // ── Session sync limit (mirror cache cap) ─────────────────────────────────────
138
278
  //
139
279
  // Configurable via REMOTE_PI_SYNC_LIMIT env var (positive int, default 30).
@@ -155,8 +295,102 @@ let _reconnectAttempt = 0;
155
295
  export function _hasPendingReconnect() {
156
296
  return _reconnectTimer !== null;
157
297
  }
158
- /** Exported for tests. */
159
- export function _getState() { return _state; }
298
+ /**
299
+ * Public state-snapshot helper. Returns the derived UX state, not the raw
300
+ * `_state` enum: the W2D refactor collapsed the internal machine to
301
+ * `idle | started` and made `paired` a derived metric
302
+ * (`_activePeers.size > 0`). Tests and the footer keep the three-state
303
+ * mental model via this getter.
304
+ */
305
+ export function _getState() {
306
+ if (_state === "idle")
307
+ return "idle";
308
+ return _activePeers.size > 0 ? "paired" : "started";
309
+ }
310
+ /** Test-only: number of owners currently attached via PlainPeerChannel. */
311
+ export function _getActivePeerCountForTest() {
312
+ return _activePeers.size;
313
+ }
314
+ /** Test-only: true if a specific peer (base64 std) has an attached channel. */
315
+ export function _hasActivePeerForTest(appPeerIdStd) {
316
+ return _activePeers.has(appPeerIdStd);
317
+ }
318
+ // ── Multi-channel helpers ─────────────────────────────────────────────────────
319
+ /**
320
+ * Sends `msg` to every currently-attached owner channel. The default
321
+ * dispatch for application-level events that are part of "the agent
322
+ * session is doing X" (agent_chunk, tool_request, tool_result, agent_done,
323
+ * user_input mirror, room_meta_update, etc.) — all paired devices see the
324
+ * same stream.
325
+ *
326
+ * Per-request responses (e.g. `session_history` answering a specific
327
+ * `session_sync` query, or `pair_ok` answering `pair_request`) must NOT
328
+ * use this — they go to the sender channel directly.
329
+ */
330
+ function _broadcastToActive(msg) {
331
+ for (const ch of _activePeers.values()) {
332
+ try {
333
+ ch.send(msg);
334
+ }
335
+ catch { /* best-effort per channel */ }
336
+ }
337
+ }
338
+ /** Returns true when at least one owner is attached. Derived `paired` UX. */
339
+ function _anyPeerActive() {
340
+ return _activePeers.size > 0;
341
+ }
342
+ /**
343
+ * Adds an owner's channel to `_activePeers`. Also updates the UX hint
344
+ * `_peerShort` (last-attached shortid) so the footer + status can pick
345
+ * a representative device when only one is connected.
346
+ */
347
+ function _attachPeerChannel(appPeerId, channel) {
348
+ _activePeers.set(appPeerId, channel);
349
+ _peerShort = appPeerId.slice(0, 8);
350
+ }
351
+ /** Detaches a single owner's channel + removes it from the map. Used by
352
+ * `_onPeerDisconnect`, `_cmdRevoke`, and the SelfRevoke callback. */
353
+ function _detachPeerChannel(appPeerId) {
354
+ const ch = _activePeers.get(appPeerId);
355
+ if (!ch)
356
+ return;
357
+ try {
358
+ ch.detach();
359
+ }
360
+ catch { /* best-effort */ }
361
+ _activePeers.delete(appPeerId);
362
+ if (_peerShort === appPeerId.slice(0, 8)) {
363
+ // Pick a different remaining peer for the UX hint, or clear when none.
364
+ const next = _activePeers.keys().next().value;
365
+ _peerShort = next ? next.slice(0, 8) : "";
366
+ }
367
+ }
368
+ // ── Display-name helpers ──────────────────────────────────────────────────────
369
+ /**
370
+ * Resolves the name this Pi shows to the mobile app and the relay's
371
+ * `room_meta.name`. Single source of truth for "what does this Pi call
372
+ * itself when talking to others".
373
+ *
374
+ * Resolution order:
375
+ * 1. Broker-assigned name (when this Pi is on the local UDS mesh) — may
376
+ * carry a `#N` suffix from a name collision. Matches what other
377
+ * agents see, so the mobile UI shows the exact same string.
378
+ * 2. `agent_name` from `<cwd>/.pi/remote-pi/config.json` — set by the
379
+ * wizard on first run; this is "the name the user configured".
380
+ * 3. `defaultAgentName(cwd)` (parent/folder) — fallback when no config
381
+ * exists yet and the mesh hasn't been joined.
382
+ *
383
+ * Pre-2026-05-23 callers computed `cwd.split('/').slice(-2).join('/')`
384
+ * inline at three different sites (pair_ok, room_meta, QR URI); this
385
+ * helper consolidates them and lifts the user's configured name above
386
+ * the raw cwd path.
387
+ */
388
+ function _displayName(cwd) {
389
+ if (_sessionPeer)
390
+ return _sessionPeer.name();
391
+ const local = loadLocalConfig(cwd);
392
+ return local.agent_name || defaultAgentName(cwd);
393
+ }
160
394
  // ── Peer lookup helpers ───────────────────────────────────────────────────────
161
395
  async function _findKnownPeer(appPeerIdStd) {
162
396
  const peers = await listPeers();
@@ -173,13 +407,10 @@ async function _findKnownPeer(appPeerIdStd) {
173
407
  * by omitting the reason; app falls back to ping miss naturally.
174
408
  */
175
409
  function _goIdle(byeReason) {
176
- if (_peerChannel && byeReason && _state !== "idle") {
177
- try {
178
- _peerChannel.send({ type: "bye", reason: byeReason });
179
- }
180
- catch {
181
- // peer already offline — fine
182
- }
410
+ // Broadcast bye to every still-attached owner so each app surfaces
411
+ // "offline" immediately instead of waiting ~50s for a ping miss.
412
+ if (byeReason && _state !== "idle" && _anyPeerActive()) {
413
+ _broadcastToActive({ type: "bye", reason: byeReason });
183
414
  }
184
415
  // Cancel any pending reconnect attempt. Critical: /remote-pi stop must
185
416
  // win the race against a scheduled reconnect.
@@ -190,14 +421,26 @@ function _goIdle(byeReason) {
190
421
  _reconnectAttempt = 0;
191
422
  _stopAutoListener?.();
192
423
  _stopAutoListener = null;
193
- _peerChannel?.detach();
194
- _peerChannel = null;
195
- _appPeerId = null;
424
+ // Tear down every per-owner channel and clear the map.
425
+ for (const ch of _activePeers.values()) {
426
+ try {
427
+ ch.detach();
428
+ }
429
+ catch { /* best-effort */ }
430
+ }
431
+ _activePeers.clear();
196
432
  _peerShort = "";
197
433
  _currentTurnId = null;
198
434
  _relay?.close();
199
435
  _relay = null;
200
436
  _relayUrl = null;
437
+ // Stop the mesh poller — it's bound to the relay-up lifecycle so a new
438
+ // _cmdStart will spin up a fresh instance (with potentially a new relay
439
+ // URL if the user changed it via /remote-pi relay url).
440
+ _selfRevoke?.stop();
441
+ _selfRevoke = null;
442
+ // Cross-PC routing relies on _relay being up; tear it down here too.
443
+ _teardownBrokerRemote();
201
444
  // Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
202
445
  // The Pi agent session outlives the relay connection — `message_end` keeps
203
446
  // firing for terminal turns even while idle, and the buffer must survive
@@ -221,11 +464,22 @@ function _onRelayClose() {
221
464
  return; // already torn down (e.g. /remote-pi stop)
222
465
  _stopAutoListener?.();
223
466
  _stopAutoListener = null;
224
- _peerChannel?.detach();
225
- _peerChannel = null;
226
- _appPeerId = null;
467
+ // Detach every per-owner channel — relay is gone, none can route. The
468
+ // auto-listener re-attaches owners after `_attemptReconnect` succeeds
469
+ // (via the same known-peer + pair_request paths used on first connect).
470
+ for (const ch of _activePeers.values()) {
471
+ try {
472
+ ch.detach();
473
+ }
474
+ catch { /* best-effort */ }
475
+ }
476
+ _activePeers.clear();
477
+ _peerShort = "";
227
478
  _currentTurnId = null;
228
479
  _relay = null; // _relayUrl preserved for retry
480
+ // Cross-PC routing relies on _relay; bring it down. Will be re-instated
481
+ // by _attemptReconnect on success.
482
+ _teardownBrokerRemote();
229
483
  _state = "started";
230
484
  _refreshFooter();
231
485
  _scheduleReconnect();
@@ -254,7 +508,9 @@ async function _attemptReconnect() {
254
508
  return;
255
509
  const edKp = _cachedEd25519;
256
510
  const url = _relayUrl;
257
- const relay = new RelayClient(url, edKp);
511
+ // _relayUrl is stored in canonical http(s):// form — convert at the
512
+ // WS boundary, same as _cmdStart.
513
+ const relay = new RelayClient(toWebSocketUrl(url), edKp);
258
514
  try {
259
515
  // Replay the same room identity from _cmdStart. Without this the relay
260
516
  // would log this WS as a default-room peer and the app would see a
@@ -279,60 +535,85 @@ async function _attemptReconnect() {
279
535
  _reconnectAttempt = 0;
280
536
  relay.on("close", _onRelayClose);
281
537
  _stopAutoListener = _installAutoListener(relay);
538
+ // Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
539
+ void _ensureBrokerRemote().catch(() => { });
282
540
  // _state stays "started"; peer reconnect (if previously paired) flows
283
541
  // through _installAutoListener → _findKnownPeer → _promoteToPaired
284
542
  // automatically when the app sends any inner.
285
543
  }
286
544
  /**
287
- * App-level peer disconnect (relay still up).
288
- * Transitions paired started and re-installs the auto-listener.
289
- * Exported so tests can trigger it directly; in production it will be
290
- * called when the relay sends a peer-disconnect notification (future).
545
+ * Per-owner disconnect callback. Fires when one specific owner's channel
546
+ * detaches (e.g. relay told us the peer is gone). Other owners' channels
547
+ * keep running relay stays "started".
548
+ *
549
+ * Exported so tests can trigger the disconnect path for a specific peer.
550
+ *
551
+ * Backward-compat: a no-arg call (legacy tests / pre-W2D callers) falls
552
+ * back to detaching the most recently attached peer, mirroring the old
553
+ * singleton semantics.
291
554
  */
292
- export function _onPeerDisconnect() {
293
- if (_state !== "paired")
555
+ export function _onPeerDisconnect(appPeerId) {
556
+ if (_state === "idle")
294
557
  return;
295
- _peerChannel?.detach();
296
- _peerChannel = null;
297
- _appPeerId = null;
298
- _peerShort = "";
558
+ const target = appPeerId ?? [..._activePeers.keys()].pop();
559
+ if (!target)
560
+ return;
561
+ if (!_activePeers.has(target))
562
+ return;
563
+ _detachPeerChannel(target);
564
+ if (_anyPeerActive()) {
565
+ // Other owners still attached — keep _currentTurnId so they continue
566
+ // seeing the in-flight agent stream.
567
+ _refreshFooter();
568
+ return;
569
+ }
570
+ // No owner left. Conservatively clear the turn so the next pair_request
571
+ // starts cleanly.
299
572
  _currentTurnId = null;
300
- _state = "started";
301
573
  _refreshFooter();
302
- _lastCtx?.ui.notify("[remote-pi] App disconnected, listening for reconnect", "info");
303
- // Re-install auto-listener so reconnect works
304
- if (_relay) {
305
- _stopAutoListener?.();
306
- _stopAutoListener = _installAutoListener(_relay);
307
- }
574
+ _lastCtx?.ui.notify("[remote-pi] All app peers disconnected, listening for reconnect", "info");
575
+ // Auto-listener stays up — same listener catches the reconnect on any peer.
308
576
  }
309
577
  /**
310
- * Promotes started paired by installing a PlainPeerChannel for `appPeerId`.
311
- * Routes `firstInner` immediately so the message that triggered reconnection
312
- * isn't dropped.
578
+ * Attaches a new owner channel to the multi-owner set. Replaces the
579
+ * pre-W2D singleton `_promoteToPaired` which set `_state = "paired"` and
580
+ * a single `_peerChannel`. The relay state remains `started`; pairing
581
+ * status is derived from `_activePeers.size`.
582
+ *
583
+ * Idempotent for the same `appPeerId` (re-attaching tears down the prior
584
+ * channel and installs a fresh one — covers reconnect from the same
585
+ * device without leaking listeners).
313
586
  */
314
- function _promoteToPaired(relay, appPeerId, peerName, firstInner) {
587
+ function _attachOwner(relay, appPeerId, peerName, firstInner) {
315
588
  const peerShort = appPeerId.slice(0, 8);
316
- const channel = new PlainPeerChannel(relay, appPeerId, _myRoomId ?? undefined, (msg) => routeClientMessage(msg, _lastCtx ?? _noopCtx), () => _onPeerDisconnect());
317
- _peerChannel = channel;
318
- _appPeerId = appPeerId;
319
- _peerShort = peerShort;
320
- _state = "paired";
589
+ // Drop any stale channel for this owner before re-attaching.
590
+ if (_activePeers.has(appPeerId))
591
+ _detachPeerChannel(appPeerId);
592
+ const channel = new PlainPeerChannel(relay, appPeerId, _myRoomId ?? undefined, (msg) => _routeClientMessageFrom(channel, msg, _lastCtx ?? _noopCtx), () => _onPeerDisconnect(appPeerId));
593
+ _attachPeerChannel(appPeerId, channel);
321
594
  _refreshFooter();
322
- _lastCtx?.ui.notify(`[remote-pi] state: paired (peer=${peerShort}, name=${peerName})`, "info");
595
+ _lastCtx?.ui.notify(`[remote-pi] Owner attached: peer=${peerShort}, name=${peerName} ` +
596
+ `(${_activePeers.size} active)`, "info");
323
597
  if (firstInner) {
324
- // Route the inner that triggered the reconnect the channel listener
325
- // also saw it, but we route through routeClientMessage to be explicit.
598
+ // The PlainPeerChannel listener fired on the same line that triggered
599
+ // attachment in some flows; we route explicitly here too to ensure the
600
+ // inner reaches the handler exactly once.
326
601
  void firstInner;
327
602
  }
603
+ return channel;
328
604
  }
329
- // ── Auto-reconnect listener ───────────────────────────────────────────────────
605
+ // ── Auto-listener ─────────────────────────────────────────────────────────────
330
606
  //
331
607
  // Installed while in 'started' state. Decodes the outer envelope as
332
- // base64(JSON) and dispatches based on inner type:
333
- // • pair_request from any peervalidate token, persist peer, send pair_ok/pair_error
334
- // any inner from a known peer (peers.json) promote to paired and route
335
- // anything else ignored
608
+ // base64(JSON) and dispatches per sender peer_id:
609
+ // • Sender already in `_activePeers`ignored here (the per-owner
610
+ // PlainPeerChannel listens on the same relay event and handles its own
611
+ // traffic via its `remotePeerId` filter)
612
+ // • `pair_request` from a new peer → validate token, persist peer, send
613
+ // pair_ok/pair_error, attach a new channel
614
+ // • Non-pair message from a known peer (peers.json) without an active
615
+ // channel yet → attach + route the inner (reconnect path)
616
+ // • Anything else (unknown peer + non-pair) → emit `error: unknown_peer`
336
617
  function _installAutoListener(relay) {
337
618
  const onMsg = async (line) => {
338
619
  let outer;
@@ -344,11 +625,11 @@ function _installAutoListener(relay) {
344
625
  }
345
626
  if (!outer.peer || !outer.ct)
346
627
  return;
347
- // Once paired, the PlainPeerChannel handles application messages.
348
- if (_state === "paired")
349
- return;
350
628
  if (_state !== "started")
351
629
  return;
630
+ // Already-attached owners: their PlainPeerChannel handles routing.
631
+ if (_activePeers.has(outer.peer))
632
+ return;
352
633
  // Decode inner envelope (base64 JSON)
353
634
  let inner;
354
635
  try {
@@ -368,14 +649,16 @@ function _installAutoListener(relay) {
368
649
  await _handlePairRequest(relay, appPeerId, inner);
369
650
  return;
370
651
  }
371
- // Reconnect path: known peer sends a non-pair message → promote to paired
372
- // and route through the new PlainPeerChannel. See pairing.md §Reconexão.
652
+ // Reconnect path: known peer (peers.json) without an active channel
653
+ // sends a non-pair message → attach + route through the new channel.
654
+ // See pairing.md §Reconexão.
373
655
  const known = await _findKnownPeer(appPeerId);
374
656
  if (known) {
375
- _promoteToPaired(relay, appPeerId, known.name);
376
- // The PlainPeerChannel that was just installed will not have observed
377
- // the line we already consumed; route the inner directly.
378
- routeClientMessage(inner, _lastCtx ?? _noopCtx);
657
+ const channel = _attachOwner(relay, appPeerId, known.name);
658
+ // The PlainPeerChannel listener for this owner won't have seen the
659
+ // line that triggered the attach (we already consumed it); route
660
+ // it explicitly via the new channel so the sender gets a reply.
661
+ _routeClientMessageFrom(channel, inner, _lastCtx ?? _noopCtx);
379
662
  return;
380
663
  }
381
664
  // Unknown peer with non-pair_request inner — signal so the app can react
@@ -393,6 +676,30 @@ function _installAutoListener(relay) {
393
676
  relay.on("message", onMsg);
394
677
  return () => relay.off("message", onMsg);
395
678
  }
679
+ /**
680
+ * Plan/27 Wave A: lazily resolve the pi-extension package version from
681
+ * disk so the `pair_ok.harness.version` field reflects what's actually
682
+ * shipped. The lookup is best-effort — a parse failure (or running this
683
+ * file out-of-tree) falls back to "0.0.0" which is still semver-valid
684
+ * and the app tolerates it. Cached at module load.
685
+ */
686
+ function _readExtensionVersion() {
687
+ try {
688
+ const here = fileURLToPath(import.meta.url);
689
+ // dist/index.js → ../package.json. src/index.ts under tsx → also one level up.
690
+ const pkgPath = join(here, "..", "..", "package.json");
691
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
692
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
693
+ }
694
+ catch {
695
+ return "0.0.0";
696
+ }
697
+ }
698
+ const _HARNESS = {
699
+ name: "Pi coding agent",
700
+ version: _readExtensionVersion(),
701
+ };
702
+ const _HOSTNAME = hostname();
396
703
  async function _handlePairRequest(relay, appPeerId, inner) {
397
704
  const sendInner = (msg) => {
398
705
  const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
@@ -406,9 +713,9 @@ async function _handlePairRequest(relay, appPeerId, inner) {
406
713
  const code = status === "expired" ? "token_expired"
407
714
  : status === "consumed" ? "token_consumed"
408
715
  : "token_unknown";
409
- const msg = code === "token_expired" ? "Token efêmero expirou. Gere um novo QR com /remote-pi pair."
410
- : code === "token_consumed" ? "Token consumido por outro pair_request."
411
- : "Token não foi emitido por este Pi.";
716
+ const msg = code === "token_expired" ? "Ephemeral token expired. Generate a new QR with /remote-pi pair."
717
+ : code === "token_consumed" ? "Token already consumed by another pair_request."
718
+ : "Token was not issued by this Pi.";
412
719
  sendError(code, msg);
413
720
  return;
414
721
  }
@@ -427,8 +734,11 @@ async function _handlePairRequest(relay, appPeerId, inner) {
427
734
  const cwd = _lastCtx && "cwd" in _lastCtx
428
735
  ? _lastCtx.cwd
429
736
  : process.cwd();
430
- const sessionName = cwd.split("/").slice(-2).join("/") || "remote";
431
- _promoteToPaired(relay, appPeerId, inner.device_name);
737
+ // Prefer the user-configured agent_name (with broker suffix when on the
738
+ // mesh) over the legacy parent/folder path — matches what the user sees
739
+ // in the terminal title and in /remote-pi status.
740
+ const sessionName = _displayName(cwd);
741
+ _attachOwner(relay, appPeerId, inner.device_name);
432
742
  sendInner({
433
743
  type: "pair_ok",
434
744
  in_reply_to: inner.id,
@@ -439,6 +749,11 @@ async function _handlePairRequest(relay, appPeerId, inner) {
439
749
  // to roomIdForCwd(cwd) covers the edge case where pair_request lands
440
750
  // before _cmdStart could set _myRoomId (shouldn't happen in practice).
441
751
  room_id: _myRoomId ?? roomIdForCwd(cwd),
752
+ // Plan/27 Wave A — surface the host coding-agent identity + machine
753
+ // hostname so the app can render a meaningful device row (and tell
754
+ // two PCs apart even when nicknames collide).
755
+ harness: _HARNESS,
756
+ hostname: _HOSTNAME,
442
757
  });
443
758
  }
444
759
  // ── Extension factory (default export) ───────────────────────────────────────
@@ -447,7 +762,6 @@ let _lastCtx = null;
447
762
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
448
763
  const extension = (pi) => {
449
764
  _pi = pi;
450
- console.error(`[remote-pi] session sync limit: ${_getSyncLimit()}`);
451
765
  // Plano 19: ensure ~/.pi/remote/{sessions,skills}/ exist and deploy the
452
766
  // agent-network skill on first load. resources_discover lets Pi find it.
453
767
  try {
@@ -468,17 +782,18 @@ const extension = (pi) => {
468
782
  // misfired on every custom tool from third-party packages. Approval will
469
783
  // come back when the Pi ecosystem ships a permissions convention. tool_result
470
784
  // is still forwarded so the app shows tool activity transparently.
471
- // Mirror input typed in the Pi terminal (or sent via RPC) to the remote app.
472
- // 'extension' source is our own sendUserMessage call from routeClientMessage,
473
- // which already set _currentTurnId — skip to avoid double turnId.
785
+ // Mirror input typed in the Pi terminal (or sent via RPC) to every
786
+ // connected owner. 'extension' source is our own sendUserMessage call
787
+ // from routeClientMessage, which already set _currentTurnId — skip to
788
+ // avoid a double turnId.
474
789
  pi.on("input", (event) => {
475
- if (!_peerChannel)
790
+ if (!_anyPeerActive())
476
791
  return;
477
792
  if (event.source === "extension")
478
793
  return;
479
794
  const turnId = `local_${randomUUID()}`;
480
795
  _currentTurnId = turnId;
481
- _peerChannel.send({ type: "user_input", id: turnId, text: event.text });
796
+ _broadcastToActive({ type: "user_input", id: turnId, text: event.text });
482
797
  });
483
798
  // Track active model so the app can show it in the SessionTile (plano 18).
484
799
  // SDK fires model_select on settings load + every user switch. We cache the
@@ -497,7 +812,6 @@ const extension = (pi) => {
497
812
  _myRoomMeta = { ..._myRoomMeta, model: modelName };
498
813
  if (!_relay || !_myRoomId)
499
814
  return;
500
- console.error(`[remote-pi] model_select → ${modelName}`);
501
815
  _relay.sendControl({
502
816
  type: "room_meta_update",
503
817
  room_id: _myRoomId,
@@ -505,21 +819,21 @@ const extension = (pi) => {
505
819
  });
506
820
  });
507
821
  pi.on("message_update", (event) => {
508
- if (!_peerChannel || !_currentTurnId)
822
+ if (!_anyPeerActive() || !_currentTurnId)
509
823
  return;
510
824
  const ae = event.assistantMessageEvent;
511
825
  if (ae.type === "text_delta") {
512
- _peerChannel.send({ type: "agent_chunk", in_reply_to: _currentTurnId, delta: ae.delta });
826
+ _broadcastToActive({ type: "agent_chunk", in_reply_to: _currentTurnId, delta: ae.delta });
513
827
  }
514
828
  });
515
- // Notify the app a tool is about to run (visibility only, NOT approval).
516
- // tool_execution_start fires before the tool executes; tool_execution_end
517
- // closes the loop with the result (success or error). Together they let
518
- // the app render a "Tool running… done" timeline without any gating.
829
+ // Notify every connected owner that a tool is about to run (visibility
830
+ // only, NOT approval). tool_execution_start fires before the tool
831
+ // executes; tool_execution_end closes the loop with the result. Together
832
+ // they render a "Tool running… done" timeline in each paired app.
519
833
  pi.on("tool_execution_start", (event) => {
520
- if (!_peerChannel)
834
+ if (!_anyPeerActive())
521
835
  return;
522
- _peerChannel.send({
836
+ _broadcastToActive({
523
837
  type: "tool_request",
524
838
  tool_call_id: event.toolCallId,
525
839
  tool: event.toolName,
@@ -527,12 +841,12 @@ const extension = (pi) => {
527
841
  });
528
842
  });
529
843
  pi.on("tool_execution_end", (event) => {
530
- if (!_peerChannel)
844
+ if (!_anyPeerActive())
531
845
  return;
532
846
  const msg = event.isError
533
847
  ? { type: "tool_result", tool_call_id: event.toolCallId, error: String(event.result) }
534
848
  : { type: "tool_result", tool_call_id: event.toolCallId, result: event.result };
535
- _peerChannel.send(msg);
849
+ _broadcastToActive(msg);
536
850
  });
537
851
  // Cumulative session buffer fed via `message_end`, which fires once per
538
852
  // persisted message (user, assistant, toolResult) — same hook the SDK uses
@@ -551,26 +865,55 @@ const extension = (pi) => {
551
865
  });
552
866
  pi.on("agent_end", () => {
553
867
  // Buffer is fed by `message_end`; here we only finalize the outbound
554
- // turn signal to the app. No buffer mutation.
555
- if (!_peerChannel || !_currentTurnId)
868
+ // turn signal to every connected owner. No buffer mutation.
869
+ if (!_anyPeerActive() || !_currentTurnId)
556
870
  return;
557
- _peerChannel.send({ type: "agent_done", in_reply_to: _currentTurnId });
871
+ _broadcastToActive({ type: "agent_done", in_reply_to: _currentTurnId });
558
872
  _currentTurnId = null;
559
873
  });
560
- // ── Commands (plano 19 taxonomy) ──────────────────────────────────────────
874
+ // plan/25 Wave 0: notify the local broker of turn lifecycle so it can
875
+ // ACK incoming agent-network envelopes as `busy` while this peer's LLM is
876
+ // mid-turn (and `received` once idle). Fire-and-forget — if the broker
877
+ // can't be reached, the worst case is a bad ACK answer; recovery is the
878
+ // next turn boundary. Skip silently when no mesh session is joined.
879
+ pi.on("turn_start", () => {
880
+ if (!_sessionPeer)
881
+ return;
882
+ void _sessionPeer.send("broker", { type: "turn_state", busy: true })
883
+ .catch(() => { });
884
+ });
885
+ pi.on("turn_end", () => {
886
+ if (!_sessionPeer)
887
+ return;
888
+ void _sessionPeer.send("broker", { type: "turn_state", busy: false })
889
+ .catch(() => { });
890
+ });
891
+ // ── Commands ──────────────────────────────────────────────────────────────
892
+ //
893
+ // Final surface: 8 commands. Pre-2026-05-23 we had 20 commands covering
894
+ // multi-session UDS + granular relay control; in practice every install
895
+ // converged on one session and the relay was always either fully on or
896
+ // fully off. The simplified surface keeps the day-to-day path one-key
897
+ // (`/remote-pi`) and exposes only the actions that have distinct user
898
+ // intent: setup, status, stop, pair, devices, revoke, set-relay.
561
899
  pi.registerCommand("remote-pi", {
562
- description: "Connect (join session + start relay), or run setup on first use",
900
+ description: "Connect (join local mesh + start relay), or run setup on first use",
563
901
  getArgumentCompletions: async (prefix) => {
564
902
  if (prefix.startsWith("revoke ") || prefix === "revoke") {
565
903
  const shortPrefix = prefix === "revoke" ? "" : prefix.slice("revoke ".length);
566
904
  return _shortidCompletions(shortPrefix, "revoke ");
567
905
  }
568
906
  return [
569
- "join", "leave", "rename", "sessions", "setup",
570
- "relay", "pair", "devices", "revoke",
571
- "set-relay", "config",
572
- // legacy aliases (still autocomplete-visible during the deprecation window)
573
- "start", "stop", "list", "add-relay",
907
+ "setup", "status", "stop",
908
+ "pair", "devices", "revoke",
909
+ "set-relay",
910
+ "peers", // plan/25 Wave D local + cross-PC inventory
911
+ "create", "remove", "daemons", // daemon registry (plan/26 W1)
912
+ // Fleet ops use the `daemon` prefix so `/remote-pi stop` keeps
913
+ // meaning "stop this local Pi" — the local UX shipped in plan/25.
914
+ "daemon start", "daemon stop", "daemon restart",
915
+ "daemon send", "daemon status",
916
+ "install", "uninstall", // service install (plan/26 W3)
574
917
  ]
575
918
  .filter((o) => o.startsWith(prefix))
576
919
  .map((o) => ({ value: o, label: o }));
@@ -579,38 +922,17 @@ const extension = (pi) => {
579
922
  _lastCtx = ctx;
580
923
  const sub = args.trim();
581
924
  if (sub === "") {
582
- await _cmdStatus(ctx);
925
+ await _cmdRoot(ctx);
583
926
  }
584
927
  else if (sub === "setup") {
585
928
  await _cmdSetup(ctx);
586
929
  }
587
- else if (sub === "join" || sub.startsWith("join ")) {
588
- await _cmdJoin(sub.slice("join".length).trim(), ctx);
589
- }
590
- else if (sub === "leave") {
591
- await _cmdLeave(ctx);
592
- }
593
- else if (sub.startsWith("rename")) {
594
- await _cmdRename(sub.slice("rename".length).trim(), ctx);
595
- }
596
- else if (sub === "sessions") {
597
- _cmdSessions(ctx);
930
+ else if (sub === "status") {
931
+ _cmdStatus(ctx);
598
932
  }
599
- else if (sub === "relay") {
600
- await _cmdRelayToggle(ctx);
601
- }
602
- else if (sub === "relay start") {
603
- await _cmdStart(ctx);
604
- }
605
- else if (sub === "relay stop") {
933
+ else if (sub === "stop") {
606
934
  await _cmdStop(ctx);
607
935
  }
608
- else if (sub === "relay status") {
609
- _cmdRelayStatus(ctx);
610
- }
611
- else if (sub.startsWith("relay url")) {
612
- _cmdSetRelay(sub.slice("relay url".length).trim(), ctx);
613
- }
614
936
  else if (sub === "pair") {
615
937
  await _cmdPair(ctx);
616
938
  }
@@ -623,44 +945,50 @@ const extension = (pi) => {
623
945
  else if (sub.startsWith("set-relay")) {
624
946
  _cmdSetRelay(sub.slice("set-relay".length).trim(), ctx);
625
947
  }
626
- else if (sub === "config") {
627
- _cmdConfig(ctx);
948
+ else if (sub === "peers") {
949
+ await _cmdPeers(ctx);
628
950
  }
629
- // ── Legacy aliases (deprecated, 1-release window) ─────────────────────
630
- else if (sub === "start") {
631
- ctx.ui.notify("[remote-pi] '/remote-pi start' deprecated — use '/remote-pi relay start' (auto-joining default session)", "warning");
632
- await _cmdJoin("", ctx);
633
- await _cmdStart(ctx);
951
+ else if (sub.startsWith("create")) {
952
+ _cmdCreate(sub.slice("create".length).trim(), ctx);
634
953
  }
635
- else if (sub === "stop") {
636
- ctx.ui.notify("[remote-pi] '/remote-pi stop' deprecated — use '/remote-pi leave' + '/remote-pi relay stop'", "warning");
637
- await _cmdLeave(ctx);
638
- await _cmdStop(ctx);
954
+ else if (sub.startsWith("remove")) {
955
+ _cmdRemove(sub.slice("remove".length).trim(), ctx);
639
956
  }
640
- else if (sub === "list") {
641
- ctx.ui.notify("[remote-pi] '/remote-pi list' deprecated — use '/remote-pi devices'", "warning");
642
- await _cmdList(ctx);
957
+ else if (sub === "daemons") {
958
+ await _cmdDaemonsList(ctx);
959
+ }
960
+ else if (sub === "daemon start") {
961
+ await _cmdDaemonStart(ctx);
962
+ }
963
+ else if (sub === "daemon stop") {
964
+ await _cmdDaemonStop(ctx);
643
965
  }
644
- else if (sub.startsWith("add-relay")) {
645
- ctx.ui.notify("[remote-pi] '/remote-pi add-relay' deprecated — use '/remote-pi relay url <...>'", "warning");
646
- _cmdSetRelay(sub.slice("add-relay".length).trim(), ctx);
966
+ else if (sub === "daemon restart") {
967
+ await _cmdDaemonRestart(ctx);
968
+ }
969
+ else if (sub === "daemon status") {
970
+ await _cmdDaemonStatus(ctx);
971
+ }
972
+ else if (sub.startsWith("daemon send")) {
973
+ await _cmdDaemonSend(sub.slice("daemon send".length).trim(), ctx);
974
+ }
975
+ else if (sub === "install") {
976
+ _cmdInstall(ctx, { linkCli: true });
977
+ }
978
+ else if (sub === "uninstall") {
979
+ _cmdUninstall(ctx, { linkCli: true });
647
980
  }
648
981
  else {
649
- await _cmdStatus(ctx);
982
+ await _cmdRoot(ctx);
650
983
  }
651
984
  },
652
985
  });
653
- // Nested registrations (full taxonomy)
986
+ // Nested registrations (one entry per public action). The flat handler
987
+ // above already routes `/remote-pi <sub>` — these exist for the SDK's
988
+ // command palette and slash-autocomplete in some UI modes.
654
989
  pi.registerCommand("remote-pi setup", { description: "Run the setup wizard and update local config", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdSetup(ctx); } });
655
- pi.registerCommand("remote-pi join", { description: "Join (or create) a local agent session", handler: async (args, ctx) => { _lastCtx = ctx; await _cmdJoin(args.trim(), ctx); } });
656
- pi.registerCommand("remote-pi leave", { description: "Leave the current agent session", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdLeave(ctx); } });
657
- pi.registerCommand("remote-pi rename", { description: "Rename this agent in the current session", handler: async (args, ctx) => { _lastCtx = ctx; await _cmdRename(args.trim(), ctx); } });
658
- pi.registerCommand("remote-pi sessions", { description: "List local agent sessions", handler: async (_, ctx) => { _lastCtx = ctx; _cmdSessions(ctx); } });
659
- pi.registerCommand("remote-pi relay", { description: "Toggle the relay connection on/off", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdRelayToggle(ctx); } });
660
- pi.registerCommand("remote-pi relay start", { description: "Connect to the relay", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdStart(ctx); } });
661
- pi.registerCommand("remote-pi relay stop", { description: "Disconnect from the relay", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdStop(ctx); } });
662
- pi.registerCommand("remote-pi relay status", { description: "Show current relay status", handler: async (_, ctx) => { _lastCtx = ctx; _cmdRelayStatus(ctx); } });
663
- pi.registerCommand("remote-pi relay url", { description: "Set relay URL (alias of /remote-pi set-relay)", handler: async (args, ctx) => { _lastCtx = ctx; _cmdSetRelay(args.trim(), ctx); } });
990
+ pi.registerCommand("remote-pi status", { description: "Show local mesh + relay status", handler: async (_, ctx) => { _lastCtx = ctx; _cmdStatus(ctx); } });
991
+ pi.registerCommand("remote-pi stop", { description: "Stop everything (leave local mesh + disconnect relay)", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdStop(ctx); } });
664
992
  pi.registerCommand("remote-pi pair", { description: "Show a QR code to pair a new mobile device", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdPair(ctx); } });
665
993
  pi.registerCommand("remote-pi devices", { description: "List paired mobile devices", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdList(ctx); } });
666
994
  pi.registerCommand("remote-pi revoke", {
@@ -669,77 +997,164 @@ const extension = (pi) => {
669
997
  handler: async (args, ctx) => { _lastCtx = ctx; await _cmdRevoke(args.trim(), ctx); },
670
998
  });
671
999
  pi.registerCommand("remote-pi set-relay", { description: "Persist a new relay URL to user config", handler: async (args, ctx) => { _lastCtx = ctx; _cmdSetRelay(args.trim(), ctx); } });
672
- pi.registerCommand("remote-pi config", { description: "Show the effective relay URL and its source", handler: async (_, ctx) => { _lastCtx = ctx; _cmdConfig(ctx); } });
673
- // Legacy aliases (deprecated, 1-release deprecation window).
674
- const legacyWarn = (ctx, old, neu) => ctx.ui.notify(`[remote-pi] '${old}' deprecated use '${neu}'`, "warning");
675
- pi.registerCommand("remote-pi start", {
676
- description: "[DEPRECATED] alias of /remote-pi relay start (also auto-joins the default session)",
677
- handler: async (_, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi start", "/remote-pi relay start"); await _cmdJoin("", ctx); await _cmdStart(ctx); },
678
- });
679
- pi.registerCommand("remote-pi stop", {
680
- description: "[DEPRECATED] alias of /remote-pi leave + /remote-pi relay stop",
681
- handler: async (_, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi stop", "/remote-pi leave + /remote-pi relay stop"); await _cmdLeave(ctx); await _cmdStop(ctx); },
1000
+ // Plan/25 Wave D
1001
+ pi.registerCommand("remote-pi peers", {
1002
+ description: "List local + cross-PC mesh peers, grouped by PC label",
1003
+ handler: async (_, ctx) => { _lastCtx = ctx; await _cmdPeers(ctx); },
682
1004
  });
683
- pi.registerCommand("remote-pi list", {
684
- description: "[DEPRECATED] alias of /remote-pi devices",
685
- handler: async (_, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi list", "/remote-pi devices"); await _cmdList(ctx); },
1005
+ // Daemon registry (plan/26 Wave 1) — create + remove. start/stop/send/
1006
+ // status/install/uninstall come in later waves with the supervisor.
1007
+ pi.registerCommand("remote-pi create", {
1008
+ description: "Register a folder as a daemon (will be supervised once `install` runs)",
1009
+ handler: async (args, ctx) => { _lastCtx = ctx; _cmdCreate(args.trim(), ctx); },
686
1010
  });
687
- pi.registerCommand("remote-pi add-relay", {
688
- description: "[DEPRECATED] alias of /remote-pi relay url",
689
- handler: async (args, ctx) => { _lastCtx = ctx; legacyWarn(ctx, "/remote-pi add-relay", "/remote-pi relay url"); _cmdSetRelay(args.trim(), ctx); },
1011
+ pi.registerCommand("remote-pi remove", {
1012
+ description: "Unregister a daemon by id (local config is preserved)",
1013
+ handler: async (args, ctx) => { _lastCtx = ctx; _cmdRemove(args.trim(), ctx); },
690
1014
  });
1015
+ // Fleet ops via the supervisor (plan/26 W2). `/remote-pi stop` stays as
1016
+ // local stop — fleet stop is `/remote-pi daemon stop`.
1017
+ pi.registerCommand("remote-pi daemons", { description: "List registered daemons + state", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdDaemonsList(ctx); } });
1018
+ pi.registerCommand("remote-pi daemon start", { description: "Start every registered daemon", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdDaemonStart(ctx); } });
1019
+ pi.registerCommand("remote-pi daemon stop", { description: "Stop every running daemon", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdDaemonStop(ctx); } });
1020
+ pi.registerCommand("remote-pi daemon restart", { description: "Restart every registered daemon", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdDaemonRestart(ctx); } });
1021
+ pi.registerCommand("remote-pi daemon status", { description: "Show fleet runtime status (pid, uptime, restarts)", handler: async (_, ctx) => { _lastCtx = ctx; await _cmdDaemonStatus(ctx); } });
1022
+ pi.registerCommand("remote-pi daemon send", { description: "Send a prompt to a daemon: `daemon send <id> \"<text>\"`", handler: async (args, ctx) => { _lastCtx = ctx; await _cmdDaemonSend(args.trim(), ctx); } });
1023
+ // Service install / uninstall (plan/26 W3)
1024
+ pi.registerCommand("remote-pi install", { description: "Install pi-supervisord as a system service + link the remote-pi CLI into ~/.local/bin (systemd/launchd)", handler: async (_, ctx) => { _lastCtx = ctx; _cmdInstall(ctx, { linkCli: true }); } });
1025
+ pi.registerCommand("remote-pi uninstall", { description: "Remove the pi-supervisord system service + the ~/.local/bin symlinks (daemons registry preserved)", handler: async (_, ctx) => { _lastCtx = ctx; _cmdUninstall(ctx, { linkCli: true }); } });
691
1026
  };
692
1027
  export default extension;
693
1028
  // ── Command implementations ───────────────────────────────────────────────────
694
- function _showStatus(ctx) {
1029
+ /**
1030
+ * `/remote-pi status` — full state snapshot. Two lines: local mesh + relay.
1031
+ *
1032
+ * Always callable; safe when nothing is up (renders the off variants).
1033
+ * Reuses the same icons as the footer so terminal + status output stay
1034
+ * visually consistent.
1035
+ */
1036
+ function _cmdStatus(ctx) {
695
1037
  const relayUrl = _relayUrl ?? resolveRelayUrl().url;
696
- const sessionPart = _sessionName ? `session=${_sessionName} (${_sessionPeerCount}) · ` : "";
697
- let msg;
698
- if (_state === "idle")
699
- msg = `[remote-pi] ${sessionPart}relay=idle (${relayUrl}). Run /remote-pi relay start to connect.`;
700
- else if (_state === "started")
701
- msg = `[remote-pi] ${sessionPart}relay=started (peer=${_peerShort || "?"}, ${relayUrl}) — run /remote-pi pair to show QR`;
702
- else
703
- msg = `[remote-pi] ${sessionPart}relay=paired (peer=${_peerShort}, ${relayUrl}) connected and ready`;
704
- ctx.ui.notify(msg, "info");
705
- }
706
- async function _cmdStatus(ctx) {
1038
+ // Mesh line
1039
+ let meshLine;
1040
+ if (_sessionPeer) {
1041
+ const name = _sessionPeer.name();
1042
+ meshLine = `🟢 Local mesh: connected as "${name}" (${_sessionPeerCount} peer${_sessionPeerCount === 1 ? "" : "s"})`;
1043
+ }
1044
+ else {
1045
+ meshLine = "⚪ Local mesh: not connected";
1046
+ }
1047
+ // Relay line — paired state is derived from _activePeers.size now.
1048
+ let relayLine;
1049
+ if (_state === "idle") {
1050
+ relayLine = `⚪ Relay: off (${relayUrl}) — run /remote-pi to start`;
1051
+ }
1052
+ else if (_activePeers.size > 0) {
1053
+ const count = _activePeers.size;
1054
+ const shortids = [..._activePeers.keys()].map((k) => k.slice(0, 8)).join(", ");
1055
+ relayLine = `🟢 Relay: ${count} owner${count === 1 ? "" : "s"} online (${shortids}) (${relayUrl})`;
1056
+ }
1057
+ else {
1058
+ relayLine = _hasGlobalPairings
1059
+ ? `🟢 Relay: on, waiting for an app to connect (${relayUrl})`
1060
+ : `🟡 Relay: on, waiting for first pairing (${relayUrl})`;
1061
+ }
1062
+ ctx.ui.notify(`[remote-pi]\n ${meshLine}\n ${relayLine}`, "info");
1063
+ }
1064
+ /**
1065
+ * Plan/25 Wave D: `/remote-pi peers`.
1066
+ *
1067
+ * Queries the local broker for the aggregated peer inventory (`list_peers`
1068
+ * returns locals + cross-PC entries prefixed with `<pc_label>:`). Formats
1069
+ * the result grouped by source so users can see at a glance who's on
1070
+ * their machine vs. on a paired sibling Pi.
1071
+ */
1072
+ async function _cmdPeers(ctx) {
1073
+ if (!_sessionPeer) {
1074
+ ctx.ui.notify("[remote-pi] Not on the local mesh. Run /remote-pi to join.", "warning");
1075
+ return;
1076
+ }
1077
+ let peers;
1078
+ try {
1079
+ const reply = await _sessionPeer.request("broker", { type: "list_peers" }, 2000);
1080
+ peers = reply.body?.peers ?? [];
1081
+ }
1082
+ catch (err) {
1083
+ ctx.ui.notify(`[remote-pi] peers list failed: ${String(err)}`, "error");
1084
+ return;
1085
+ }
1086
+ // Exclude self from the printed list — `list_peers` returns every peer
1087
+ // registered with the broker including the caller, which is noise here.
1088
+ const selfName = _sessionPeer.name();
1089
+ ctx.ui.notify(`[remote-pi] peers:\n${formatPeerInventory(peers, selfName)}`, "info");
1090
+ }
1091
+ /**
1092
+ * Root handler for `/remote-pi`. On first run (no local config) drops into
1093
+ * the wizard; on subsequent runs auto-joins the local mesh + starts the
1094
+ * relay (if opted in during setup), then prints the status.
1095
+ *
1096
+ * `/remote-pi` is intentionally the only command users need day-to-day:
1097
+ * idempotent connect + status display.
1098
+ */
1099
+ async function _cmdRoot(ctx) {
707
1100
  const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
1101
+ // Per-cwd singleton: at most one Pi process per folder may run /remote-pi.
1102
+ // Bind a UDS socket as the lock (kernel auto-releases on process exit, even
1103
+ // crash); a second invocation in the same cwd sees the live socket and is
1104
+ // refused here, before any wizard / mesh / relay side-effect can run.
1105
+ // Once acquired, the lock is bound to the lifetime of THIS process — repeat
1106
+ // calls to /remote-pi from the same terminal are idempotent (no re-acquire).
1107
+ if (_cwdLock === null) {
1108
+ const result = await acquireCwdLock(cwd);
1109
+ if (!result.ok) {
1110
+ ctx.ui.notify("[remote-pi] Another agent is already running in this folder. " +
1111
+ "Use the existing terminal or run from a different folder.", "warning");
1112
+ return;
1113
+ }
1114
+ _cwdLock = result;
1115
+ }
708
1116
  // First-time wizard: no local config in this cwd → run interactive setup.
709
1117
  if (!localConfigExists(cwd)) {
710
1118
  const ui = ctx.ui;
711
1119
  if (typeof ui.select !== "function") {
712
- _showStatus(ctx);
1120
+ _cmdStatus(ctx);
713
1121
  return;
714
1122
  }
715
1123
  const baseDefault = defaultAgentName(cwd);
716
- const newConfig = await runSetupWizard(ui, {
1124
+ const wizardResult = await runSetupWizard(ui, {
717
1125
  agent_name: baseDefault,
718
- session_name: baseDefault,
719
- auto_start_relay: true,
1126
+ use_relay: true,
1127
+ enable_daemon: false,
720
1128
  });
721
- if (!newConfig) {
1129
+ if (!wizardResult) {
722
1130
  ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
723
1131
  return;
724
1132
  }
1133
+ // `enable_daemon` is wizard-only state, not part of LocalConfig.
1134
+ const { enable_daemon, ...newConfig } = wizardResult;
725
1135
  saveLocalConfig(cwd, newConfig);
726
1136
  ctx.ui.notify(`[remote-pi] Config saved to ${cwd}/.pi/remote-pi/config.json`, "info");
727
- await _cmdJoin(newConfig.session_name ?? baseDefault, ctx);
1137
+ await _cmdJoin(ctx);
728
1138
  if (effectiveAutoStartRelay(newConfig))
729
1139
  await _cmdStart(ctx);
730
- _showStatus(ctx);
1140
+ if (enable_daemon)
1141
+ _cmdInstall(ctx, { linkCli: true });
1142
+ _cmdStatus(ctx);
731
1143
  return;
732
1144
  }
733
1145
  // Returning user with config: auto-start if requested + currently inactive.
734
1146
  const config = loadLocalConfig(cwd);
735
1147
  if (effectiveAutoStartRelay(config) && !_sessionPeer) {
736
- const sessionName = config.session_name ?? defaultAgentName(cwd);
737
- await _cmdJoin(sessionName, ctx);
1148
+ await _cmdJoin(ctx);
738
1149
  if (_state === "idle")
739
1150
  await _cmdStart(ctx);
740
1151
  }
741
- _showStatus(ctx);
1152
+ _cmdStatus(ctx);
742
1153
  }
1154
+ /**
1155
+ * `/remote-pi setup` — re-run the wizard. Defaults pre-fill from the
1156
+ * existing config so it doubles as an "edit" flow.
1157
+ */
743
1158
  async function _cmdSetup(ctx) {
744
1159
  const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
745
1160
  const ui = ctx.ui;
@@ -749,17 +1164,23 @@ async function _cmdSetup(ctx) {
749
1164
  }
750
1165
  const current = loadLocalConfig(cwd);
751
1166
  const baseDefault = defaultAgentName(cwd);
752
- const newConfig = await runSetupWizard(ui, {
1167
+ const wizardResult = await runSetupWizard(ui, {
753
1168
  agent_name: current.agent_name ?? baseDefault,
754
- session_name: current.session_name ?? baseDefault,
755
- auto_start_relay: effectiveAutoStartRelay(current),
1169
+ use_relay: effectiveAutoStartRelay(current),
1170
+ // No way to introspect launchd/systemd here without shelling out;
1171
+ // default the wizard to "off" — re-running setup is for changing
1172
+ // your mind, not for re-confirming current OS state.
1173
+ enable_daemon: false,
756
1174
  });
757
- if (!newConfig) {
1175
+ if (!wizardResult) {
758
1176
  ctx.ui.notify("[remote-pi] Setup cancelled.", "info");
759
1177
  return;
760
1178
  }
1179
+ const { enable_daemon, ...newConfig } = wizardResult;
761
1180
  saveLocalConfig(cwd, newConfig);
762
- ctx.ui.notify("[remote-pi] Config updated. Run /remote-pi to apply now (join + relay).", "info");
1181
+ ctx.ui.notify("[remote-pi] Config updated. Run /remote-pi to apply now.", "info");
1182
+ if (enable_daemon)
1183
+ _cmdInstall(ctx, { linkCli: true });
763
1184
  }
764
1185
  async function _cmdStart(ctx) {
765
1186
  if (_state !== "idle") {
@@ -774,7 +1195,9 @@ async function _cmdStart(ctx) {
774
1195
  // share the same Ed25519 identity without colliding on the relay.
775
1196
  const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
776
1197
  const roomId = roomIdForCwd(cwd);
777
- const sessionName = cwd.split("/").slice(-2).join("/") || "remote";
1198
+ // Same name we send in pair_ok — keeps room_meta.name and the per-pair
1199
+ // session_name aligned so the app shows consistent labels.
1200
+ const sessionName = _displayName(cwd);
778
1201
  // Initial model from ctx (ExtensionContext.model is the SDK's current
779
1202
  // selection — set by user settings or last-used). May be undefined on
780
1203
  // first boot before any model_select; that's fine, room_meta omits the
@@ -791,7 +1214,10 @@ async function _cmdStart(ctx) {
791
1214
  // entry that surfaces in the app as a phantom legacy session.
792
1215
  _myRoomMeta = roomMeta;
793
1216
  ctx.ui.notify(`[remote-pi] Connecting to relay ${relayUrl} (source: ${source}, room: ${roomId})…`, "info");
794
- const relay = new RelayClient(relayUrl, edKp);
1217
+ // Transport opens WebSocket; convert the canonical http(s):// stored
1218
+ // form to ws(s):// at this boundary. The relayUrl variable keeps the
1219
+ // http(s):// form for logging + mesh client construction below.
1220
+ const relay = new RelayClient(toWebSocketUrl(relayUrl), edKp);
795
1221
  try {
796
1222
  await relay.connect({ roomId, roomMeta });
797
1223
  }
@@ -821,34 +1247,153 @@ async function _cmdStart(ctx) {
821
1247
  relay.on("close", _onRelayClose);
822
1248
  _stopAutoListener = _installAutoListener(relay);
823
1249
  _refreshFooter(ctx);
1250
+ // Plan/24 Wave 3: poll mesh_versions to detect remote revocation. The
1251
+ // poller is independent of WS (uses HTTP) and self-heals across relay
1252
+ // reconnects, so a single start here per relay-up cycle is enough.
1253
+ if (_selfRevoke === null) {
1254
+ _selfRevoke = new SelfRevoke({
1255
+ client: new MeshClient(relayUrl),
1256
+ storage: { listOwnerPubkeys, removePeer },
1257
+ myPubkey: edKp.publicKey,
1258
+ onRevoke: (ownerEpk) => {
1259
+ // Multi-channel (W2D): drop only the revoked owner's channel.
1260
+ // Other owners keep their session. Only fall back to full idle
1261
+ // when there are zero attached owners left.
1262
+ _refreshPairingsCache();
1263
+ if (_activePeers.has(ownerEpk)) {
1264
+ _detachPeerChannel(ownerEpk);
1265
+ _refreshFooter();
1266
+ }
1267
+ // Surface the revocation inside the Pi chat panel via
1268
+ // `pi.sendMessage` (same channel the QR pair-code uses). Plain
1269
+ // `console.info` from the SelfRevoke poller bypasses the TUI
1270
+ // widget and bleeds into the prompt area, garbling the layout
1271
+ // — same issue we hit with the QR ASCII before plan/24 Wave 3.
1272
+ // sendMessage with a customType keeps the line inline with
1273
+ // the agent's transcript. The poller's own `log.info` keeps
1274
+ // running for daemons/CI where no TUI is attached.
1275
+ const short = ownerEpk.slice(0, 8);
1276
+ _pi?.sendMessage({
1277
+ customType: "remote-pi:mesh-revoked",
1278
+ content: `🔒 Revoked by Owner ${short}…\n\n` +
1279
+ `The mobile app for this Owner removed this PC from the mesh. ` +
1280
+ `Re-pair via /remote-pi pair if this was unexpected.`,
1281
+ display: true,
1282
+ });
1283
+ },
1284
+ // Plan/25 Wave D: keep broker_remote's sibling list in sync with
1285
+ // mesh_versions. The poller fires this whenever the union of Pi
1286
+ // members across owners changes — adding/removing a sibling, or
1287
+ // an owner relabeling a nickname. `_brokerRemote` may be null
1288
+ // until `_ensureBrokerRemote` finishes; the callback short-circuits.
1289
+ onMembersChanged: (siblings) => {
1290
+ _brokerRemote?.setSiblings(siblings);
1291
+ },
1292
+ // Silent log: routine self-revoke audit and per-Owner fetch
1293
+ // failures don't belong in the TUI chat panel. User-facing
1294
+ // revocation events flow through `onRevoke` → `pi.sendMessage`.
1295
+ log: { info: () => { }, warn: () => { }, error: () => { } },
1296
+ });
1297
+ _selfRevoke.start();
1298
+ }
1299
+ // Plan/25 Wave B/C: bring up cross-PC routing if local broker is ready.
1300
+ // No-op when we're a follower (broker_remote needs the local Broker
1301
+ // instance the leader hosts). Best-effort; failures don't surface.
1302
+ void _ensureBrokerRemote().catch(() => { });
824
1303
  ctx.ui.notify(`[remote-pi] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
825
1304
  }
1305
+ /**
1306
+ * `/remote-pi pair` — always generates a fresh QR when the relay is up.
1307
+ *
1308
+ * Pre-W2D this rejected with "Already paired with X" once one owner was
1309
+ * connected, forcing /remote-pi stop to pair a second device — the
1310
+ * catch-22 the multi-channel refactor was designed to break. Now the new
1311
+ * device is **added** to `_activePeers` after scanning, while existing
1312
+ * owners keep their session.
1313
+ */
826
1314
  async function _cmdPair(ctx) {
1315
+ const cwd = "cwd" in ctx ? ctx.cwd : "";
1316
+ // Auto-bootstrap when services are down. Before this, `/remote-pi pair`
1317
+ // on a fresh terminal forced the user to call `/remote-pi` first — every
1318
+ // session began with the same surprise warning + second command. Now we
1319
+ // do the join + relay-start inline so the common "I just opened a
1320
+ // terminal and want to pair my phone" flow is a single command.
1321
+ //
1322
+ // We don't run the first-time wizard here: pair is a focused operation
1323
+ // and the wizard prompts are wrong UX in that flow. If there's no local
1324
+ // config, the user truly needs to run `/remote-pi` first to configure.
827
1325
  if (_state === "idle") {
828
- ctx.ui.notify("[remote-pi] Run /remote-pi start first.", "warning");
829
- return;
1326
+ if (!localConfigExists(cwd)) {
1327
+ ctx.ui.notify("[remote-pi] First-time setup needed. Run /remote-pi to configure, then /remote-pi pair.", "warning");
1328
+ return;
1329
+ }
1330
+ ctx.ui.notify("[remote-pi] Starting mesh + relay before pairing…", "info");
1331
+ if (!_sessionPeer)
1332
+ await _cmdJoin(ctx);
1333
+ if (_state === "idle")
1334
+ await _cmdStart(ctx);
830
1335
  }
831
- if (_state === "paired") {
832
- ctx.ui.notify(`[remote-pi] Already paired with ${_peerShort}. Run /remote-pi stop first.`, "warning");
1336
+ // Relay must be up — the QR carries a token the app exchanges through
1337
+ // the relay. Without a live WS there's nothing for the scan to land on.
1338
+ if (_state === "idle" || !_relay) {
1339
+ ctx.ui.notify("[remote-pi] Pair requires the relay to be connected. " +
1340
+ "Run /remote-pi to start it (or fix your relay URL via /remote-pi set-relay).", "warning");
833
1341
  return;
834
1342
  }
835
1343
  const edKp = _cachedEd25519;
836
- const cwd = "cwd" in ctx ? ctx.cwd : "";
837
- const sessionName = cwd.split("/").slice(-2).join("/") || "remote";
1344
+ // Embed the user-configured name in the QR so the app shows it on the
1345
+ // pairing screen before pair_ok lands (better UX than "remote" or a
1346
+ // raw path snippet).
1347
+ const sessionName = _displayName(cwd);
838
1348
  const { token, expiresAt } = qrSession.issueToken();
839
1349
  const roomId = _myRoomId ?? roomIdForCwd(cwd);
840
1350
  const qrUri = buildQRUri(token, edKp.publicKey, sessionName, roomId);
841
- displayQR(qrUri);
842
- ctx.ui.notify(`[remote-pi] QR ready valid until ${new Date(expiresAt).toLocaleTimeString()}. Scan with the app.`, "info");
1351
+ // Render both the QR ASCII and the copy-paste URI inside the Pi TUI's
1352
+ // chat panel via `pi.sendMessage` the same channel the SDK uses for
1353
+ // agent responses + tool results. `process.stderr.write` (the old QR
1354
+ // path via `displayQR`) broke the TUI layout because it bypassed the
1355
+ // chat widget and bled into the prompt area. qrcode-terminal v0.12
1356
+ // small mode is pure Unicode (█ ▀ ▄ space, no ANSI escapes — see
1357
+ // `lib/main.js:48-53`), so embedding the ASCII inside a sendMessage
1358
+ // content string renders correctly without raw escape bytes.
1359
+ if (_pi) {
1360
+ const qrAscii = renderQRAscii(qrUri);
1361
+ _pi.sendMessage({
1362
+ customType: "remote-pi:pair-code",
1363
+ content: `📱 Scan to pair:\n\n${qrAscii}\n` +
1364
+ `📋 Or copy this pairing code (camera-less devices):\n\n${qrUri}`,
1365
+ display: true,
1366
+ });
1367
+ }
1368
+ ctx.ui.notify(`[remote-pi] QR ready — valid until ${new Date(expiresAt).toLocaleTimeString()}. ` +
1369
+ `Scan with the app, or copy the pairing code printed above.`, "info");
843
1370
  // Returns immediately; the auto-listener transitions to 'paired' on pair_request.
844
1371
  }
1372
+ /**
1373
+ * `/remote-pi stop` — full teardown. Leaves the local UDS mesh AND closes
1374
+ * the relay. Safe when one or both are already off. To resume, run
1375
+ * `/remote-pi` again.
1376
+ */
845
1377
  async function _cmdStop(ctx) {
846
- if (_state === "idle") {
847
- ctx.ui.notify("[remote-pi] Already idle nothing to stop.", "info");
1378
+ const meshUp = _sessionPeer !== null;
1379
+ const relayUp = _state !== "idle";
1380
+ if (!meshUp && !relayUp) {
1381
+ ctx.ui.notify("[remote-pi] Already stopped — nothing to do.", "info");
848
1382
  return;
849
1383
  }
850
- _goIdle("peer_stop");
851
- ctx.ui.notify("[remote-pi] state: idle — Disconnected.", "info");
1384
+ if (meshUp) {
1385
+ try {
1386
+ await _sessionPeer.leave();
1387
+ }
1388
+ catch { /* best-effort */ }
1389
+ _sessionPeer = null;
1390
+ _sessionName = null;
1391
+ _sessionPeerCount = 0;
1392
+ }
1393
+ if (relayUp)
1394
+ _goIdle("peer_stop");
1395
+ ctx.ui.notify("[remote-pi] Stopped (mesh + relay disconnected).", "info");
1396
+ _refreshFooter(ctx);
852
1397
  }
853
1398
  async function _cmdList(ctx) {
854
1399
  const peers = await listPeers();
@@ -856,10 +1401,13 @@ async function _cmdList(ctx) {
856
1401
  ctx.ui.notify("[remote-pi] No paired devices.", "info");
857
1402
  return;
858
1403
  }
1404
+ // Multi-channel (W2D): each peer is either `online` (channel attached
1405
+ // right now) or `offline` (in peers.json but not connected). Replaces
1406
+ // the singleton " (active)" marker that only ever marked one peer.
859
1407
  const lines = peers.map((p) => {
860
1408
  const shortid = p.remote_epk.slice(0, 8);
861
- const active = _state === "paired" && _appPeerId === p.remote_epk ? " (active)" : "";
862
- return `• ${shortid} — ${p.name}${active}`;
1409
+ const tag = _activePeers.has(p.remote_epk) ? " 🟢 online" : " ⚪ offline";
1410
+ return `• ${shortid} — ${p.name}${tag}`;
863
1411
  }).join("\n");
864
1412
  ctx.ui.notify(`[remote-pi] Paired devices:\n${lines}`, "info");
865
1413
  }
@@ -883,8 +1431,18 @@ async function _cmdRevoke(arg, ctx) {
883
1431
  const peer = matches[0];
884
1432
  await removePeer(peer.remote_epk);
885
1433
  _refreshPairingsCache();
886
- if (_state === "paired" && _appPeerId === peer.remote_epk) {
887
- _goIdle("session_replaced");
1434
+ // Multi-channel (W2D): close just this owner's channel. Other connected
1435
+ // owners keep their session — the relay stays `started`.
1436
+ if (_activePeers.has(peer.remote_epk)) {
1437
+ // Notify the revoked device explicitly before tearing the channel
1438
+ // down — otherwise it would only know via ping miss.
1439
+ const ch = _activePeers.get(peer.remote_epk);
1440
+ try {
1441
+ ch?.send({ type: "bye", reason: "session_replaced" });
1442
+ }
1443
+ catch { /* best-effort */ }
1444
+ _detachPeerChannel(peer.remote_epk);
1445
+ _refreshFooter();
888
1446
  }
889
1447
  ctx.ui.notify(`[remote-pi] Revoked: ${peer.name} (${peer.remote_epk.slice(0, 8)}…)`, "info");
890
1448
  }
@@ -898,21 +1456,307 @@ async function _shortidCompletions(prefix, valuePrefix = "") {
898
1456
  function _cmdSetRelay(arg, ctx) {
899
1457
  const raw = arg.trim();
900
1458
  if (!raw) {
901
- ctx.ui.notify("[remote-pi] Usage: /remote-pi set-relay <ws:// | wss:// | http:// | https:// url>", "warning");
1459
+ ctx.ui.notify("[remote-pi] Usage: /remote-pi set-relay <http:// or https:// url>", "warning");
1460
+ return;
1461
+ }
1462
+ if (isWebSocketScheme(raw)) {
1463
+ ctx.ui.notify(`[remote-pi] Use http:// or https://. The extension converts to WebSocket automatically.`, "error");
902
1464
  return;
903
1465
  }
904
1466
  if (!isValidRelayUrl(raw)) {
905
- ctx.ui.notify(`[remote-pi] Invalid URL: ${raw}. Must start with ws://, wss://, http:// or https://`, "error");
1467
+ ctx.ui.notify(`[remote-pi] Invalid URL: ${raw}. Must start with http:// or https://`, "error");
1468
+ return;
1469
+ }
1470
+ saveConfig({ relay: raw });
1471
+ ctx.ui.notify(`[remote-pi] Relay set to ${raw}. Run /remote-pi start (or restart) to apply.`, "info");
1472
+ }
1473
+ // ── Daemon registry commands (plan/26 Wave 1) ─────────────────────────────────
1474
+ /**
1475
+ * `/remote-pi create [<cwd>] [--name <name>]`
1476
+ *
1477
+ * Promotes a folder to a daemon entry in `~/.pi/remote/daemons.json`. The
1478
+ * cwd is **always normalized to an absolute realpath** before storage —
1479
+ * `~/Movies`, `./Movies`, `../foo/Movies` all collapse to a single
1480
+ * canonical entry. Relative paths resolve against the Pi process's
1481
+ * current working directory, not the slash-command's `ctx.cwd`.
1482
+ *
1483
+ * Side effects on the cwd's local config (`<cwd>/.pi/remote-pi/config.json`):
1484
+ * - If the config doesn't exist: created with `auto_start_relay=true`
1485
+ * (mandatory for daemons) and `agent_name` from `--name` if provided.
1486
+ * - If the config already exists: left untouched. Re-running `create`
1487
+ * on an existing daemon is idempotent at this layer; the registry
1488
+ * itself rejects duplicate cwds.
1489
+ */
1490
+ function _cmdCreate(arg, ctx) {
1491
+ // Parse `[cwd] [--name "value with spaces" | --name word]` in any order.
1492
+ // The first non-flag token is the cwd; the rest of the line after
1493
+ // `--name` (quoted or unquoted) is the display name.
1494
+ const nameMatch = arg.match(/--name\s+"([^"]+)"|--name\s+(\S+)/);
1495
+ const name = nameMatch ? (nameMatch[1] ?? nameMatch[2]) : undefined;
1496
+ const cwdRaw = arg.replace(/--name\s+"[^"]+"|--name\s+\S+/, "").trim();
1497
+ if (!cwdRaw) {
1498
+ ctx.ui.notify("[remote-pi] Usage: /remote-pi create <absolute-or-relative-cwd> [--name \"Display name\"]", "warning");
1499
+ return;
1500
+ }
1501
+ let result;
1502
+ try {
1503
+ result = addDaemon(cwdRaw);
1504
+ }
1505
+ catch (err) {
1506
+ ctx.ui.notify(`[remote-pi] create failed: ${String(err)}`, "error");
1507
+ return;
1508
+ }
1509
+ // Provision local config when missing. Use the existing helpers so the
1510
+ // daemon's first boot sees the same shape as a manually-configured Pi.
1511
+ // `saveLocalConfig` is a partial-merge: if the file already exists with
1512
+ // a different agent_name we DON'T overwrite — user's existing config
1513
+ // wins to avoid surprises.
1514
+ if (!localConfigExists(result.cwd)) {
1515
+ saveLocalConfig(result.cwd, {
1516
+ agent_name: name ?? defaultAgentName(result.cwd),
1517
+ auto_start_relay: true,
1518
+ });
1519
+ }
1520
+ const finalName = loadLocalConfig(result.cwd).agent_name ?? defaultAgentName(result.cwd);
1521
+ ctx.ui.notify(`[remote-pi] Daemon registered: id=${result.id} name="${finalName}" cwd=${result.cwd}`, "info");
1522
+ }
1523
+ /**
1524
+ * `/remote-pi remove <id>`
1525
+ *
1526
+ * Unregisters a daemon by its 8-hex-char id (the same id printed by
1527
+ * `/remote-pi create` and `/remote-pi daemons`). The cwd's local config
1528
+ * stays on disk — re-creating later with the same cwd is a no-op
1529
+ * because the existing config wins.
1530
+ */
1531
+ function _cmdRemove(arg, ctx) {
1532
+ const id = arg.trim();
1533
+ if (!id) {
1534
+ ctx.ui.notify("[remote-pi] Usage: /remote-pi remove <id>. Run /remote-pi daemons to see ids.", "warning");
1535
+ return;
1536
+ }
1537
+ let result;
1538
+ try {
1539
+ result = removeDaemon(id);
1540
+ }
1541
+ catch (err) {
1542
+ ctx.ui.notify(`[remote-pi] remove failed: ${String(err)}`, "error");
1543
+ return;
1544
+ }
1545
+ if (!result.removed) {
1546
+ // Surface the registered ids for a quick visual diff.
1547
+ const known = listDaemons().map((d) => d.id).join(", ") || "(none)";
1548
+ ctx.ui.notify(`[remote-pi] No daemon with id "${id}". Known ids: ${known}`, "warning");
1549
+ return;
1550
+ }
1551
+ ctx.ui.notify(`[remote-pi] Daemon removed: id=${id} cwd=${result.cwd}. ` +
1552
+ `Local config at ${result.cwd}/.pi/remote-pi/config.json was kept.`, "info");
1553
+ }
1554
+ // ── Fleet-ops commands (plan/26 W2) — talk to the supervisor over UDS ─────────
1555
+ //
1556
+ // Every command here is a thin wrapper around `callSupervisor(...)`. When
1557
+ // the supervisor isn't running we fall back to a friendly hint instead of
1558
+ // the raw error, so the user can't get stuck on "what's wrong?".
1559
+ function _notifyOffline(ctx, err) {
1560
+ ctx.ui.notify(`[remote-pi] ${err.message}`, "warning");
1561
+ }
1562
+ function _formatDaemonTable(daemons) {
1563
+ if (daemons.length === 0)
1564
+ return "(no daemons registered)";
1565
+ const rows = daemons.map((d) => {
1566
+ const uptime = d.uptime_s !== undefined ? `${d.uptime_s}s` : "—";
1567
+ const pid = d.pid !== undefined ? String(d.pid) : "—";
1568
+ const restarts = d.restart_count ?? 0;
1569
+ return ` ${d.id} ${d.state.padEnd(8)} pid=${pid} up=${uptime} restarts=${restarts} ${d.name} ${d.cwd}`;
1570
+ });
1571
+ return rows.join("\n");
1572
+ }
1573
+ /**
1574
+ * `/remote-pi daemons` — registry + runtime state in one view. When the
1575
+ * supervisor is offline we still show registry-only output (state =
1576
+ * "stopped" everywhere), so the user can see what's configured even
1577
+ * before `install`.
1578
+ */
1579
+ async function _cmdDaemonsList(ctx) {
1580
+ if (!(await supervisorOnline())) {
1581
+ const registry = listDaemons();
1582
+ if (registry.length === 0) {
1583
+ ctx.ui.notify("[remote-pi] No daemons registered. Run /remote-pi create <cwd>.", "info");
1584
+ return;
1585
+ }
1586
+ const rows = registry.map((d) => {
1587
+ const cfg = loadLocalConfig(d.cwd);
1588
+ const name = cfg.agent_name ?? defaultAgentName(d.cwd);
1589
+ return ` ${d.id} ${name} ${d.cwd} (supervisor offline)`;
1590
+ }).join("\n");
1591
+ ctx.ui.notify(`[remote-pi] Daemons (registry only — run install to bring supervisor up):\n${rows}`, "info");
1592
+ return;
1593
+ }
1594
+ try {
1595
+ const data = await callSupervisor({ op: "list" });
1596
+ ctx.ui.notify(`[remote-pi] Daemons:\n${_formatDaemonTable(data.daemons)}`, "info");
1597
+ }
1598
+ catch (err) {
1599
+ if (err instanceof SupervisorOfflineError) {
1600
+ _notifyOffline(ctx, err);
1601
+ return;
1602
+ }
1603
+ ctx.ui.notify(`[remote-pi] daemons failed: ${String(err)}`, "error");
1604
+ }
1605
+ }
1606
+ async function _cmdDaemonStatus(ctx) {
1607
+ try {
1608
+ const data = await callSupervisor({ op: "status" });
1609
+ ctx.ui.notify(`[remote-pi] Fleet status:\n${_formatDaemonTable(data.daemons)}`, "info");
1610
+ }
1611
+ catch (err) {
1612
+ if (err instanceof SupervisorOfflineError) {
1613
+ _notifyOffline(ctx, err);
1614
+ return;
1615
+ }
1616
+ ctx.ui.notify(`[remote-pi] status failed: ${String(err)}`, "error");
1617
+ }
1618
+ }
1619
+ async function _cmdDaemonStart(ctx) {
1620
+ try {
1621
+ const data = await callSupervisor({ op: "start_all" });
1622
+ ctx.ui.notify(`[remote-pi] Started ${data.started.length} daemon(s), ` +
1623
+ `${data.already_running.length} already running.`, "info");
1624
+ }
1625
+ catch (err) {
1626
+ if (err instanceof SupervisorOfflineError) {
1627
+ _notifyOffline(ctx, err);
1628
+ return;
1629
+ }
1630
+ ctx.ui.notify(`[remote-pi] start failed: ${String(err)}`, "error");
1631
+ }
1632
+ }
1633
+ async function _cmdDaemonStop(ctx) {
1634
+ try {
1635
+ const data = await callSupervisor({ op: "stop_all" });
1636
+ ctx.ui.notify(`[remote-pi] Stopped ${data.stopped.length} daemon(s), ` +
1637
+ `${data.already_stopped.length} already stopped.`, "info");
1638
+ }
1639
+ catch (err) {
1640
+ if (err instanceof SupervisorOfflineError) {
1641
+ _notifyOffline(ctx, err);
1642
+ return;
1643
+ }
1644
+ ctx.ui.notify(`[remote-pi] stop failed: ${String(err)}`, "error");
1645
+ }
1646
+ }
1647
+ async function _cmdDaemonRestart(ctx) {
1648
+ try {
1649
+ const data = await callSupervisor({ op: "restart_all" });
1650
+ ctx.ui.notify(`[remote-pi] Restarted ${data.restarted.length} daemon(s).`, "info");
1651
+ }
1652
+ catch (err) {
1653
+ if (err instanceof SupervisorOfflineError) {
1654
+ _notifyOffline(ctx, err);
1655
+ return;
1656
+ }
1657
+ ctx.ui.notify(`[remote-pi] restart failed: ${String(err)}`, "error");
1658
+ }
1659
+ }
1660
+ /**
1661
+ * `/remote-pi daemon send <id> "<text>"` — injects a prompt into a
1662
+ * running daemon via its RPC stdin. The agent processes the prompt as
1663
+ * if a user typed it; output flows back via the relay/mesh, not here.
1664
+ *
1665
+ * Fire-and-forget at this layer — the CLI just confirms delivery.
1666
+ */
1667
+ async function _cmdDaemonSend(arg, ctx) {
1668
+ // Parse `<id> <text...>` — id is the first token, rest is the prompt.
1669
+ // The text may be quoted; if so, strip the outer quotes. Otherwise
1670
+ // take the entire remainder verbatim.
1671
+ const m = arg.match(/^(\S+)\s+(?:"([^"]*)"|(.*))$/);
1672
+ if (!m) {
1673
+ ctx.ui.notify("[remote-pi] Usage: /remote-pi daemon send <id> \"<prompt text>\"", "warning");
1674
+ return;
1675
+ }
1676
+ const id = m[1];
1677
+ const text = (m[2] ?? m[3] ?? "").trim();
1678
+ if (!text) {
1679
+ ctx.ui.notify("[remote-pi] daemon send: prompt text is empty.", "warning");
906
1680
  return;
907
1681
  }
908
- const url = normalizeRelayUrl(raw);
909
- saveConfig({ relay: url });
910
- const note = url === raw ? "" : ` (normalized from ${raw})`;
911
- ctx.ui.notify(`[remote-pi] Relay set to ${url}${note}. Run /remote-pi start (or restart) to apply.`, "info");
1682
+ try {
1683
+ const data = await callSupervisor({ op: "send", id, text });
1684
+ if (data.delivered) {
1685
+ ctx.ui.notify(`[remote-pi] Sent to ${id}: ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`, "info");
1686
+ }
1687
+ else {
1688
+ ctx.ui.notify(`[remote-pi] daemon ${id} did not accept the prompt (not running?)`, "warning");
1689
+ }
1690
+ }
1691
+ catch (err) {
1692
+ if (err instanceof SupervisorOfflineError) {
1693
+ _notifyOffline(ctx, err);
1694
+ return;
1695
+ }
1696
+ ctx.ui.notify(`[remote-pi] daemon send failed: ${String(err)}`, "error");
1697
+ }
1698
+ }
1699
+ // ── Install/uninstall the supervisor service (plan/26 W3) ────────────────────
1700
+ //
1701
+ // Installs `pi-supervisord` as a user-level system service (systemd
1702
+ // `--user` unit on Linux, launchd LaunchAgent on macOS). Once installed:
1703
+ // - Supervisor starts at login + survives reboots.
1704
+ // - `remote-pi daemon start/stop/send/...` work without manually
1705
+ // spawning the supervisor.
1706
+ // Uninstall is the inverse — leaves the registry (`daemons.json`) intact,
1707
+ // so re-installing later picks up where you left off.
1708
+ /**
1709
+ * `linkCli` controls whether we symlink `remote-pi` + `pi-supervisord`
1710
+ * into `~/.local/bin/`. The slash-command path passes `true` (user is
1711
+ * inside Pi's TUI — they installed via `pi install npm:remote-pi` and
1712
+ * need us to expose the CLI for them). The standalone-CLI path passes
1713
+ * `false` because the user is already running our binary from PATH (they
1714
+ * did `npm install -g remote-pi`), so re-linking would point their
1715
+ * `remote-pi` at the Pi-extension copy and diverge on upgrades.
1716
+ */
1717
+ function _cmdInstall(ctx, opts = {}) {
1718
+ const linkCli = opts.linkCli ?? false;
1719
+ try {
1720
+ const result = installService();
1721
+ const sections = [
1722
+ `[remote-pi] Supervisor service installed (${result.platform}).`,
1723
+ ` Unit: ${result.unitPath}`,
1724
+ ` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
1725
+ ];
1726
+ if (linkCli) {
1727
+ const link = linkCliBinaries();
1728
+ sections.push(` CLI bins linked into ${link.binDir}:`, link.links.map((l) => ` ${l.name} → ${l.target}`).join("\n"), ` Steps:\n${link.log.map((l) => " " + l).join("\n")}`);
1729
+ if (!link.onPath) {
1730
+ sections.push(` ⚠ ${link.binDir} is not on $PATH yet. Add this line to ~/.zshrc / ~/.bashrc:`, ` export PATH="$HOME/.local/bin:$PATH"`, ` Then open a new terminal and run \`remote-pi daemons\` to verify.`);
1731
+ }
1732
+ }
1733
+ ctx.ui.notify(sections.join("\n"), "info");
1734
+ }
1735
+ catch (err) {
1736
+ ctx.ui.notify(`[remote-pi] install failed: ${String(err)}`, "error");
1737
+ }
912
1738
  }
913
- function _cmdConfig(ctx) {
914
- const { url, source } = resolveRelayUrl();
915
- ctx.ui.notify(`[remote-pi] Relay: ${url}\n Source: ${source}`, "info");
1739
+ function _cmdUninstall(ctx, opts = {}) {
1740
+ const linkCli = opts.linkCli ?? false;
1741
+ try {
1742
+ const result = uninstallService();
1743
+ const sections = [
1744
+ `[remote-pi] Supervisor service uninstalled (${result.platform}).`,
1745
+ ` Unit: ${result.unitPath} (${result.removed ? "removed" : "not present"})`,
1746
+ ` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
1747
+ ` Note: daemons registry (~/.pi/remote/daemons.json) kept — re-install restores everything.`,
1748
+ ];
1749
+ if (linkCli) {
1750
+ const unlink = unlinkCliBinaries();
1751
+ sections.push(` CLI bins cleanup (${unlink.binDir}):`, unlink.removed
1752
+ .map((r) => ` ${r.name} (${r.existed ? "removed" : "not present"})`)
1753
+ .join("\n"));
1754
+ }
1755
+ ctx.ui.notify(sections.join("\n"), "info");
1756
+ }
1757
+ catch (err) {
1758
+ ctx.ui.notify(`[remote-pi] uninstall failed: ${String(err)}`, "error");
1759
+ }
916
1760
  }
917
1761
  // ── Agent-network commands (plano 19) ─────────────────────────────────────────
918
1762
  function _resolveExtensionDir() {
@@ -959,13 +1803,20 @@ function _deployAgentNetworkSkill() {
959
1803
  }
960
1804
  catch { /* best-effort */ }
961
1805
  }
962
- async function _cmdJoin(arg, ctx) {
1806
+ /**
1807
+ * Joins the fixed local UDS mesh ("local" session — see LOCAL_SESSION_NAME).
1808
+ * Called by `_cmdRoot` on first run and on subsequent runs when the relay
1809
+ * is up and the user hasn't explicitly stopped. The session name is no
1810
+ * longer user-configurable: every Pi on the same machine joins the same
1811
+ * broker.
1812
+ */
1813
+ async function _cmdJoin(ctx) {
963
1814
  const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
964
1815
  const local = loadLocalConfig(cwd);
965
- const sessionName = (arg || local.session_name || defaultAgentName(cwd)).trim();
1816
+ const sessionName = LOCAL_SESSION_NAME;
966
1817
  const agentName = local.agent_name || defaultAgentName(cwd);
967
1818
  if (_sessionPeer) {
968
- ctx.ui.notify(`[remote-pi] Already joined "${_sessionName}". Leave first.`, "warning");
1819
+ ctx.ui.notify("[remote-pi] Already on the local mesh.", "warning");
969
1820
  return;
970
1821
  }
971
1822
  ensureGlobalDirs();
@@ -980,6 +1831,19 @@ async function _cmdJoin(arg, ctx) {
980
1831
  // failover, etc.) — querying list_peers makes the count self-healing.
981
1832
  if (body && (body.type === "peer_joined" || body.type === "peer_left")) {
982
1833
  _refreshSessionPeerCount(peer, ctx);
1834
+ // Plan/25 Wave B: push fresh peer list to all siblings so their
1835
+ // remotePeers cache stays current without polling.
1836
+ void peer.request("broker", { type: "list_peers" }, 2000)
1837
+ .then((reply) => {
1838
+ const peers = reply.body?.peers;
1839
+ if (Array.isArray(peers) && _brokerRemote) {
1840
+ // Strip remote-prefixed entries — onLocalPeersChanged wants
1841
+ // local-only names (`list_peers` returns aggregated).
1842
+ const local = peers.filter((p) => !p.includes(":"));
1843
+ _brokerRemote.onLocalPeersChanged(local);
1844
+ }
1845
+ })
1846
+ .catch(() => { });
983
1847
  return;
984
1848
  }
985
1849
  if (env.from === "broker")
@@ -1002,8 +1866,23 @@ async function _cmdJoin(arg, ctx) {
1002
1866
  // After failover (leader died, we re-elected): the new broker's peers map
1003
1867
  // starts fresh, but our cached `_sessionPeerCount` is stale. Re-seed it so
1004
1868
  // surviving peers don't carry the pre-failover count forever.
1869
+ //
1870
+ // Plan/25 Wave D: when this peer was promoted to leader by the failover,
1871
+ // it now hosts a fresh `Broker` instance with no `RemoteRouter` attached.
1872
+ // The previous broker_remote (on the dead leader) is gone with that
1873
+ // process. Recreate ours here. Followers stay no-op (broker_remote only
1874
+ // runs on the leader). Idempotent — _ensureBrokerRemote short-circuits
1875
+ // when one is already wired.
1005
1876
  peer.onReconnect(() => {
1006
1877
  _refreshSessionPeerCount(peer, ctx);
1878
+ if (peer.currentRole() === "leader") {
1879
+ // Tear down any previous instance first — its broker reference is now
1880
+ // stale (it pointed at the broker we hosted before the prior
1881
+ // disconnect). The new broker comes from `peer.localBroker()` after
1882
+ // reconnect.
1883
+ _teardownBrokerRemote();
1884
+ void _ensureBrokerRemote().catch(() => { });
1885
+ }
1007
1886
  });
1008
1887
  try {
1009
1888
  const assigned = await peer.start();
@@ -1014,84 +1893,57 @@ async function _cmdJoin(arg, ctx) {
1014
1893
  // arrives — the newcomer doesn't get retroactive joined events. Ask the
1015
1894
  // broker for the live peer list to seed the count correctly on join.
1016
1895
  _refreshSessionPeerCount(peer, ctx);
1017
- saveLocalConfig(cwd, { agent_name: assigned, session_name: sessionName });
1018
- ctx.ui.notify(`[remote-pi] Joined session "${sessionName}" as "${assigned}" (${peer.currentRole()})`, "info");
1896
+ saveLocalConfig(cwd, { agent_name: assigned });
1897
+ ctx.ui.notify(`[remote-pi] Joined local mesh as "${assigned}" (${peer.currentRole()})`, "info");
1019
1898
  _refreshFooter(ctx);
1899
+ // Plan/25 Wave B/C: try to bring up cross-PC routing now that the
1900
+ // local broker exists. No-op if the relay isn't up yet (will fire
1901
+ // again from `_cmdStart`).
1902
+ void _ensureBrokerRemote().catch(() => { });
1020
1903
  }
1021
1904
  catch (err) {
1022
1905
  ctx.ui.notify(`[remote-pi] join failed: ${String(err)}`, "error");
1023
1906
  }
1024
1907
  }
1025
- async function _cmdLeave(ctx) {
1026
- if (!_sessionPeer) {
1027
- ctx.ui.notify("[remote-pi] Not in any session.", "info");
1028
- return;
1029
- }
1030
- await _sessionPeer.leave();
1031
- const name = _sessionName;
1032
- _sessionPeer = null;
1033
- _sessionName = null;
1034
- _sessionPeerCount = 0;
1035
- ctx.ui.notify(`[remote-pi] Left session "${name}".`, "info");
1036
- _refreshFooter(ctx);
1037
- }
1038
- async function _cmdRename(arg, ctx) {
1039
- const newName = arg.trim();
1040
- if (!newName) {
1041
- ctx.ui.notify("[remote-pi] Usage: /remote-pi rename <new-name>", "warning");
1042
- return;
1043
- }
1044
- if (!_sessionPeer) {
1045
- ctx.ui.notify("[remote-pi] Not in any session. Run /remote-pi join first.", "warning");
1046
- return;
1047
- }
1048
- try {
1049
- const assigned = await _sessionPeer.rename(newName);
1050
- const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
1051
- saveLocalConfig(cwd, { agent_name: assigned });
1052
- ctx.ui.notify(`[remote-pi] Renamed to "${assigned}".`, "info");
1053
- }
1054
- catch (err) {
1055
- ctx.ui.notify(`[remote-pi] rename failed: ${String(err)}`, "error");
1056
- }
1057
- }
1058
- function _cmdSessions(ctx) {
1059
- const sessions = listSessions();
1060
- if (sessions.length === 0) {
1061
- ctx.ui.notify("[remote-pi] No sessions found.", "info");
1062
- return;
1063
- }
1064
- const lines = sessions.map((s) => {
1065
- const live = sessionHasSock(s) ? "🟢" : "⚪";
1066
- const me = s === _sessionName ? " (current)" : "";
1067
- return ` ${live} ${s}${me}`;
1068
- });
1069
- ctx.ui.notify(`[remote-pi] Sessions:\n${lines.join("\n")}`, "info");
1070
- }
1071
- async function _cmdRelayToggle(ctx) {
1072
- if (_state === "idle")
1073
- await _cmdStart(ctx);
1074
- else
1075
- await _cmdStop(ctx);
1076
- }
1077
- function _cmdRelayStatus(ctx) {
1078
- _showStatus(ctx);
1079
- }
1080
1908
  // ── routeClientMessage ────────────────────────────────────────────────────────
1081
- export function routeClientMessage(msg, ctx) {
1909
+ /**
1910
+ * Per-channel router. Replaces the W2D-pre `routeClientMessage` which
1911
+ * implicitly used the `_peerChannel` singleton for replies. Each
1912
+ * PlainPeerChannel now carries its own `sender` and passes it here so
1913
+ * sender-specific responses (cancelled, pong, session_history) flow back
1914
+ * through the right wire instead of being broadcast.
1915
+ *
1916
+ * Broadcast messages (user_input mirror, agent_chunk, tool_*) still use
1917
+ * `_broadcastToActive` from the SDK event handlers; this router only
1918
+ * handles incoming app→pi requests.
1919
+ */
1920
+ export function _routeClientMessageFrom(sender, msg, ctx) {
1082
1921
  // session_sync has its own internal guards — handle before the strict
1083
- // peer/pi guard so a missing _pi doesn't drop the reply.
1922
+ // pi-binding guard so a missing _pi doesn't drop the reply.
1084
1923
  if (msg.type === "session_sync") {
1085
- _handleSessionSync(msg);
1924
+ _handleSessionSync(sender, msg);
1086
1925
  return;
1087
1926
  }
1088
- if (!_peerChannel || !_pi)
1927
+ if (!_pi)
1089
1928
  return;
1090
1929
  switch (msg.type) {
1091
- case "user_message":
1930
+ case "user_message": {
1931
+ // Source-of-truth rebroadcast (plan/24 W2D fix). Echo the message
1932
+ // back to every attached owner (sender included) BEFORE handing it
1933
+ // off to the agent — so that:
1934
+ // 1. The sender's app waits for this echo to render (no eager
1935
+ // local store), keeping all owners visually consistent.
1936
+ // 2. Other owners see what was said, not just the agent's reply.
1937
+ // 3. `id` is preserved verbatim, so future dedup logic on the app
1938
+ // side can key off it.
1939
+ // The user_message is also recorded in _messageBuffer indirectly
1940
+ // via `pi.on("message_end")` after the SDK persists the turn — so
1941
+ // a later `session_sync` returns it in the history events.
1942
+ _broadcastToActive({ type: "user_message", id: msg.id, text: msg.text });
1092
1943
  _currentTurnId = msg.id;
1093
1944
  _pi.sendUserMessage(msg.text);
1094
1945
  break;
1946
+ }
1095
1947
  case "approve_tool":
1096
1948
  // Approval gate was removed (plano 10.2 revisado). Type kept in
1097
1949
  // ClientMessage for forward-compat with a future permissions model;
@@ -1099,10 +1951,12 @@ export function routeClientMessage(msg, ctx) {
1099
1951
  break;
1100
1952
  case "cancel":
1101
1953
  ctx.abort();
1102
- _peerChannel.send({ type: "cancelled", in_reply_to: msg.id, target_id: msg.target_id });
1954
+ // Reply to the sender that asked to cancel — broadcasting would tell
1955
+ // every owner about a cancellation they didn't request.
1956
+ sender.send({ type: "cancelled", in_reply_to: msg.id, target_id: msg.target_id });
1103
1957
  break;
1104
1958
  case "ping":
1105
- _peerChannel.send({ type: "pong", in_reply_to: msg.id });
1959
+ sender.send({ type: "pong", in_reply_to: msg.id });
1106
1960
  break;
1107
1961
  case "pair_request":
1108
1962
  // Already paired — ignore subsequent pair_request to maintain idempotency.
@@ -1110,12 +1964,27 @@ export function routeClientMessage(msg, ctx) {
1110
1964
  break;
1111
1965
  }
1112
1966
  }
1113
- // ── session_sync handler + helpers ────────────────────────────────────────────
1114
- function _handleSessionSync(msg) {
1115
- if (!_peerChannel)
1967
+ /**
1968
+ * Backward-compatible shim for legacy callers + tests that didn't track
1969
+ * a specific sender channel. Routes to the most recently attached owner,
1970
+ * mirroring the pre-W2D singleton behavior.
1971
+ */
1972
+ export function routeClientMessage(msg, ctx) {
1973
+ const fallback = [..._activePeers.values()].pop();
1974
+ if (!fallback)
1116
1975
  return;
1976
+ _routeClientMessageFrom(fallback, msg, ctx);
1977
+ }
1978
+ // ── session_sync handler + helpers ────────────────────────────────────────────
1979
+ /**
1980
+ * `session_sync` is a per-sender query: the owner asking gets the reply,
1981
+ * not the whole broadcast. Otherwise a session_sync from owner A would
1982
+ * also dump history to owner B's wire — duplicate traffic + the wrong
1983
+ * `in_reply_to`.
1984
+ */
1985
+ function _handleSessionSync(sender, msg) {
1117
1986
  if (_sessionStartedAt === null) {
1118
- _peerChannel.send({
1987
+ sender.send({
1119
1988
  type: "session_history",
1120
1989
  in_reply_to: msg.id,
1121
1990
  session_started_at: 0,
@@ -1133,7 +2002,7 @@ function _handleSessionSync(msg) {
1133
2002
  const allEvents = _mapAgentMessagesToEvents(_messageBuffer);
1134
2003
  const slice = effectiveLimit > 0 ? allEvents.slice(-effectiveLimit) : [];
1135
2004
  const truncated = allEvents.length > effectiveLimit;
1136
- _peerChannel.send({
2005
+ sender.send({
1137
2006
  type: "session_history",
1138
2007
  in_reply_to: msg.id,
1139
2008
  session_started_at: _sessionStartedAt,
@@ -1226,7 +2095,7 @@ export function _mapAgentMessagesToEvents(messages) {
1226
2095
  // ── Standalone CLI ────────────────────────────────────────────────────────────
1227
2096
  if (import.meta.url === `file://${process.argv[1]}`) {
1228
2097
  const [, , subcmd, ...cliArgs] = process.argv;
1229
- if (subcmd === "list") {
2098
+ if (subcmd === "devices" || subcmd === "list") {
1230
2099
  const peers = await listPeers();
1231
2100
  if (peers.length === 0) {
1232
2101
  console.log("[remote-pi] No peers");
@@ -1261,18 +2130,77 @@ if (import.meta.url === `file://${process.argv[1]}`) {
1261
2130
  if (!raw) {
1262
2131
  console.log(`Usage: set-relay <url> (default: ${kDefaultRelayUrl})`);
1263
2132
  }
2133
+ else if (isWebSocketScheme(raw)) {
2134
+ console.log(`Use http:// or https://. The extension converts to WebSocket automatically.`);
2135
+ }
1264
2136
  else if (!isValidRelayUrl(raw)) {
1265
- console.log(`Invalid URL: ${raw}. Must start with ws://, wss://, http:// or https://`);
2137
+ console.log(`Invalid URL: ${raw}. Must start with http:// or https://`);
2138
+ }
2139
+ else {
2140
+ saveConfig({ relay: raw });
2141
+ console.log(`Relay set to ${raw}`);
2142
+ }
2143
+ }
2144
+ else if (subcmd === "create") {
2145
+ // Standalone: `remote-pi create <cwd> [--name "X"]`. The shell already
2146
+ // split the args and stripped the outer quotes, so an arg like
2147
+ // `Tmp Agent` arrives as a single element with embedded space. Re-add
2148
+ // quotes around any arg containing whitespace so the regex-based
2149
+ // parser (shared with the slash-command path) sees the same shape
2150
+ // as it would from a Pi interactive prompt.
2151
+ const joined = cliArgs.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
2152
+ _cmdCreate(joined, {
2153
+ ui: { notify: (msg) => console.log(msg) },
2154
+ });
2155
+ }
2156
+ else if (subcmd === "remove") {
2157
+ const id = (cliArgs[0] ?? "").trim();
2158
+ _cmdRemove(id, {
2159
+ ui: { notify: (msg) => console.log(msg) },
2160
+ });
2161
+ }
2162
+ else if (subcmd === "daemons") {
2163
+ // Mirror the slash handler: ask the supervisor when reachable,
2164
+ // fall back to registry-only when not.
2165
+ const stubCtx = { ui: { notify: (msg) => console.log(msg) } };
2166
+ await _cmdDaemonsList(stubCtx);
2167
+ }
2168
+ else if (subcmd === "daemon") {
2169
+ // `remote-pi daemon <op> [args]`. Reuse the fleet-ops handlers — they
2170
+ // already accept a minimal ctx with `notify`.
2171
+ const op = cliArgs[0] ?? "";
2172
+ const rest = cliArgs.slice(1).map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
2173
+ const stubCtx = { ui: { notify: (msg) => console.log(msg) } };
2174
+ if (op === "start") {
2175
+ await _cmdDaemonStart(stubCtx);
2176
+ }
2177
+ else if (op === "stop") {
2178
+ await _cmdDaemonStop(stubCtx);
2179
+ }
2180
+ else if (op === "restart") {
2181
+ await _cmdDaemonRestart(stubCtx);
2182
+ }
2183
+ else if (op === "status") {
2184
+ await _cmdDaemonStatus(stubCtx);
2185
+ }
2186
+ else if (op === "send") {
2187
+ await _cmdDaemonSend(rest, stubCtx);
1266
2188
  }
1267
2189
  else {
1268
- const url = normalizeRelayUrl(raw);
1269
- saveConfig({ relay: url });
1270
- console.log(`Relay set to ${url}${url === raw ? "" : ` (normalized from ${raw})`}`);
2190
+ console.log("Usage: remote-pi daemon <start|stop|restart|status|send <id> \"<text>\">");
1271
2191
  }
1272
2192
  }
1273
- else if (subcmd === "config") {
1274
- const { url, source } = resolveRelayUrl();
1275
- console.log(`Relay: ${url}\n Source: ${source}`);
2193
+ else if (subcmd === "install") {
2194
+ // CLI mode = user installed via `npm install -g remote-pi`, so the
2195
+ // `remote-pi` / `pi-supervisord` bins are already on $PATH via npm's
2196
+ // global prefix. Explicit `linkCli: false` so we never stomp those
2197
+ // with symlinks pointing at a parallel Pi-extension install.
2198
+ const stubCtx = { ui: { notify: (msg) => console.log(msg) } };
2199
+ _cmdInstall(stubCtx, { linkCli: false });
2200
+ }
2201
+ else if (subcmd === "uninstall") {
2202
+ const stubCtx = { ui: { notify: (msg) => console.log(msg) } };
2203
+ _cmdUninstall(stubCtx, { linkCli: false });
1276
2204
  }
1277
2205
  else {
1278
2206
  const edKp = await getOrCreateEd25519Keypair();