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