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.
- package/README.md +195 -36
- package/dist/bin/supervisord.d.ts +2 -0
- package/dist/bin/supervisord.js +44 -0
- package/dist/bin/supervisord.js.map +1 -0
- package/dist/config.d.ts +49 -5
- package/dist/config.js +73 -9
- package/dist/config.js.map +1 -1
- package/dist/daemon/client.d.ts +20 -0
- package/dist/daemon/client.js +128 -0
- package/dist/daemon/client.js.map +1 -0
- package/dist/daemon/control_protocol.d.ts +100 -0
- package/dist/daemon/control_protocol.js +63 -0
- package/dist/daemon/control_protocol.js.map +1 -0
- package/dist/daemon/id.d.ts +18 -0
- package/dist/daemon/id.js +30 -0
- package/dist/daemon/id.js.map +1 -0
- package/dist/daemon/install.d.ts +132 -0
- package/dist/daemon/install.js +312 -0
- package/dist/daemon/install.js.map +1 -0
- package/dist/daemon/registry.d.ts +47 -0
- package/dist/daemon/registry.js +123 -0
- package/dist/daemon/registry.js.map +1 -0
- package/dist/daemon/rpc_child.d.ts +76 -0
- package/dist/daemon/rpc_child.js +130 -0
- package/dist/daemon/rpc_child.js.map +1 -0
- package/dist/daemon/supervisor.d.ts +38 -0
- package/dist/daemon/supervisor.js +301 -0
- package/dist/daemon/supervisor.js.map +1 -0
- package/dist/index.d.ts +62 -8
- package/dist/index.js +1232 -304
- package/dist/index.js.map +1 -1
- package/dist/mesh/canonical.d.ts +30 -0
- package/dist/mesh/canonical.js +61 -0
- package/dist/mesh/canonical.js.map +1 -0
- package/dist/mesh/client.d.ts +31 -0
- package/dist/mesh/client.js +56 -0
- package/dist/mesh/client.js.map +1 -0
- package/dist/mesh/encoding.d.ts +36 -0
- package/dist/mesh/encoding.js +53 -0
- package/dist/mesh/encoding.js.map +1 -0
- package/dist/mesh/self_revoke.d.ts +111 -0
- package/dist/mesh/self_revoke.js +182 -0
- package/dist/mesh/self_revoke.js.map +1 -0
- package/dist/mesh/siblings.d.ts +62 -0
- package/dist/mesh/siblings.js +95 -0
- package/dist/mesh/siblings.js.map +1 -0
- package/dist/mesh/types.d.ts +34 -0
- package/dist/mesh/types.js +11 -0
- package/dist/mesh/types.js.map +1 -0
- package/dist/mesh/verify.d.ts +17 -0
- package/dist/mesh/verify.js +77 -0
- package/dist/mesh/verify.js.map +1 -0
- package/dist/pairing/qr.d.ts +16 -5
- package/dist/pairing/qr.js +27 -8
- package/dist/pairing/qr.js.map +1 -1
- package/dist/pairing/storage.d.ts +41 -0
- package/dist/pairing/storage.js +158 -21
- package/dist/pairing/storage.js.map +1 -1
- package/dist/protocol/types.d.ts +23 -0
- package/dist/session/broker.d.ts +74 -0
- package/dist/session/broker.js +142 -4
- package/dist/session/broker.js.map +1 -1
- package/dist/session/broker_remote.d.ts +110 -0
- package/dist/session/broker_remote.js +397 -0
- package/dist/session/broker_remote.js.map +1 -0
- package/dist/session/cwd_lock.d.ts +28 -0
- package/dist/session/cwd_lock.js +89 -0
- package/dist/session/cwd_lock.js.map +1 -0
- package/dist/session/global_config.d.ts +9 -0
- package/dist/session/global_config.js +9 -0
- package/dist/session/global_config.js.map +1 -1
- package/dist/session/leader_election.d.ts +16 -0
- package/dist/session/leader_election.js +22 -0
- package/dist/session/leader_election.js.map +1 -1
- package/dist/session/local_config.d.ts +12 -5
- package/dist/session/local_config.js +24 -3
- package/dist/session/local_config.js.map +1 -1
- package/dist/session/peer.d.ts +28 -1
- package/dist/session/peer.js +69 -2
- package/dist/session/peer.js.map +1 -1
- package/dist/session/peer_inventory.d.ts +13 -0
- package/dist/session/peer_inventory.js +48 -0
- package/dist/session/peer_inventory.js.map +1 -0
- package/dist/session/setup_wizard.d.ts +32 -8
- package/dist/session/setup_wizard.js +45 -33
- package/dist/session/setup_wizard.js.map +1 -1
- package/dist/session/tools.d.ts +15 -7
- package/dist/session/tools.js +145 -31
- package/dist/session/tools.js.map +1 -1
- package/dist/transport/pi_forward_client.d.ts +29 -0
- package/dist/transport/pi_forward_client.js +62 -0
- package/dist/transport/pi_forward_client.js.map +1 -0
- package/dist/ui/footer.js +8 -6
- package/dist/ui/footer.js.map +1 -1
- package/docs/daemon.md +289 -0
- package/package.json +10 -3
- package/service-templates/launchd.plist.template +35 -0
- package/service-templates/systemd.service.template +19 -0
- 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,
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
/**
|
|
159
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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
|
|
541
|
+
export function _onPeerDisconnect(appPeerId) {
|
|
542
|
+
if (_state === "idle")
|
|
294
543
|
return;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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]
|
|
303
|
-
//
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
|
573
|
+
function _attachOwner(relay, appPeerId, peerName, firstInner) {
|
|
315
574
|
const peerShort = appPeerId.slice(0, 8);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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]
|
|
581
|
+
_lastCtx?.ui.notify(`[remote-pi] Owner attached: peer=${peerShort}, name=${peerName} ` +
|
|
582
|
+
`(${_activePeers.size} active)`, "info");
|
|
323
583
|
if (firstInner) {
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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-
|
|
591
|
+
// ── Auto-listener ─────────────────────────────────────────────────────────────
|
|
330
592
|
//
|
|
331
593
|
// Installed while in 'started' state. Decodes the outer envelope as
|
|
332
|
-
// base64(JSON) and dispatches
|
|
333
|
-
// •
|
|
334
|
-
//
|
|
335
|
-
//
|
|
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
|
|
372
|
-
//
|
|
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
|
-
|
|
376
|
-
// The PlainPeerChannel
|
|
377
|
-
// the
|
|
378
|
-
|
|
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" ? "
|
|
410
|
-
: code === "token_consumed" ? "Token
|
|
411
|
-
: "Token
|
|
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
|
-
|
|
431
|
-
|
|
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
|
|
472
|
-
// 'extension' source is our own sendUserMessage call
|
|
473
|
-
// which already set _currentTurnId — skip to
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
810
|
+
if (!_anyPeerActive() || !_currentTurnId)
|
|
509
811
|
return;
|
|
510
812
|
const ae = event.assistantMessageEvent;
|
|
511
813
|
if (ae.type === "text_delta") {
|
|
512
|
-
|
|
814
|
+
_broadcastToActive({ type: "agent_chunk", in_reply_to: _currentTurnId, delta: ae.delta });
|
|
513
815
|
}
|
|
514
816
|
});
|
|
515
|
-
// Notify
|
|
516
|
-
// tool_execution_start fires before the tool
|
|
517
|
-
// closes the loop with the result
|
|
518
|
-
//
|
|
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 (!
|
|
822
|
+
if (!_anyPeerActive())
|
|
521
823
|
return;
|
|
522
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
555
|
-
if (!
|
|
856
|
+
// turn signal to every connected owner. No buffer mutation.
|
|
857
|
+
if (!_anyPeerActive() || !_currentTurnId)
|
|
556
858
|
return;
|
|
557
|
-
|
|
859
|
+
_broadcastToActive({ type: "agent_done", in_reply_to: _currentTurnId });
|
|
558
860
|
_currentTurnId = null;
|
|
559
861
|
});
|
|
560
|
-
//
|
|
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
|
|
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
|
-
"
|
|
570
|
-
"
|
|
571
|
-
"set-relay",
|
|
572
|
-
//
|
|
573
|
-
"
|
|
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
|
|
913
|
+
await _cmdRoot(ctx);
|
|
583
914
|
}
|
|
584
915
|
else if (sub === "setup") {
|
|
585
916
|
await _cmdSetup(ctx);
|
|
586
917
|
}
|
|
587
|
-
else if (sub === "
|
|
588
|
-
|
|
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 === "
|
|
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 === "
|
|
627
|
-
|
|
936
|
+
else if (sub === "peers") {
|
|
937
|
+
await _cmdPeers(ctx);
|
|
628
938
|
}
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
636
|
-
|
|
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 === "
|
|
641
|
-
ctx
|
|
642
|
-
|
|
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
|
|
645
|
-
ctx
|
|
646
|
-
|
|
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
|
|
970
|
+
await _cmdRoot(ctx);
|
|
650
971
|
}
|
|
651
972
|
},
|
|
652
973
|
});
|
|
653
|
-
// Nested registrations (
|
|
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
|
|
656
|
-
pi.registerCommand("remote-pi
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
688
|
-
description: "
|
|
689
|
-
handler: async (args, ctx) => { _lastCtx = 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
|
-
|
|
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
|
-
|
|
697
|
-
let
|
|
698
|
-
if (
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
else
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
1108
|
+
_cmdStatus(ctx);
|
|
713
1109
|
return;
|
|
714
1110
|
}
|
|
715
1111
|
const baseDefault = defaultAgentName(cwd);
|
|
716
|
-
const
|
|
1112
|
+
const wizardResult = await runSetupWizard(ui, {
|
|
717
1113
|
agent_name: baseDefault,
|
|
718
|
-
|
|
719
|
-
|
|
1114
|
+
use_relay: true,
|
|
1115
|
+
enable_daemon: false,
|
|
720
1116
|
});
|
|
721
|
-
if (!
|
|
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(
|
|
1125
|
+
await _cmdJoin(ctx);
|
|
728
1126
|
if (effectiveAutoStartRelay(newConfig))
|
|
729
1127
|
await _cmdStart(ctx);
|
|
730
|
-
|
|
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
|
-
|
|
737
|
-
await _cmdJoin(sessionName, ctx);
|
|
1136
|
+
await _cmdJoin(ctx);
|
|
738
1137
|
if (_state === "idle")
|
|
739
1138
|
await _cmdStart(ctx);
|
|
740
1139
|
}
|
|
741
|
-
|
|
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
|
|
1155
|
+
const wizardResult = await runSetupWizard(ui, {
|
|
753
1156
|
agent_name: current.agent_name ?? baseDefault,
|
|
754
|
-
|
|
755
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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
|
-
|
|
832
|
-
|
|
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
|
-
|
|
837
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
851
|
-
|
|
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
|
|
862
|
-
return `• ${shortid} — ${p.name}${
|
|
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
|
-
|
|
887
|
-
|
|
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
|
|
900
|
-
if (!
|
|
901
|
-
ctx.ui.notify("[remote-pi] Usage: /remote-pi set-relay <
|
|
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
|
-
|
|
905
|
-
|
|
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
|
-
|
|
909
|
-
|
|
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
|
|
912
|
-
const
|
|
913
|
-
|
|
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
|
-
|
|
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 =
|
|
1803
|
+
const sessionName = LOCAL_SESSION_NAME;
|
|
964
1804
|
const agentName = local.agent_name || defaultAgentName(cwd);
|
|
965
1805
|
if (_sessionPeer) {
|
|
966
|
-
ctx.ui.notify(
|
|
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
|
|
1016
|
-
ctx.ui.notify(`[remote-pi] Joined
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1259
|
-
if (!
|
|
2126
|
+
const raw = (cliArgs[0] ?? "").trim();
|
|
2127
|
+
if (!raw) {
|
|
1260
2128
|
console.log(`Usage: set-relay <url> (default: ${kDefaultRelayUrl})`);
|
|
1261
2129
|
}
|
|
1262
|
-
else if (
|
|
1263
|
-
console.log(`
|
|
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:
|
|
1267
|
-
console.log(`Relay set to ${
|
|
2137
|
+
saveConfig({ relay: raw });
|
|
2138
|
+
console.log(`Relay set to ${raw}`);
|
|
1268
2139
|
}
|
|
1269
2140
|
}
|
|
1270
|
-
else if (subcmd === "
|
|
1271
|
-
|
|
1272
|
-
|
|
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();
|