remote-pi 0.1.2 → 0.2.0

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